Files
dtourolleandClaude Opus 5.5 582c94f5f9
🏗️ Build Plugin / build (push) Successful in 2m27s
Nightly Build / nightly-build (push) Failing after 1m18s
🧪 Test Plugin / test (push) Successful in 43s
🚀 Release Plugin / build-and-release (push) Failing after 1m12s
Build one plugin per Jellyfin generation (10.9, 10.11, 12)
The single net8.0 build compiled against Jellyfin 10.9.11 still loads on
10.11 and 12, so channels and browsing keep working, but 10.11 changed
APIs in ways that only fail at runtime. A binary audit of the DLL against
the real server assemblies found 9 broken references on both 10.11 and
12 (none on 10.9/10.10):

- TaskTriggerInfo.Type became an enum. GetDefaultTriggers throws, so
  Jellyfin silently gives every SRF task a 24h fallback trigger. The
  recording scheduler therefore ran about once a day and scheduled
  recordings never started.
- ILibraryManager.GetItemList changed its return type: the expiration
  check crashed on every run.
- Jellyfin.Data.Entities.User moved (IUserManager.Users, GetUserData,
  SaveUserData, PlaybackProgressEventArgs.Users): resume cleanup returned
  500 and the playback-stop guard threw on every stop.

Jellyfin 12 also rejects the legacy X-Emby-Token header with an empty
401, which the recordings UI surfaced as "Unexpected end of JSON input".
The pages now send Authorization: MediaBrowser Token="...", verified on
10.9, 10.11 and 12.

The plugin now multi-targets net8.0/net9.0/net10.0 against Jellyfin
10.9.11/10.11.0/12.0.0 (the oldest package of each line, so each DLL
loads on every patch release of it), with JELLYFIN_10_11_OR_GREATER and
JELLYFIN_12_OR_GREATER for the differences. Trigger construction moves
into Utilities/TaskTriggers so the version switch lives in one place.

.gitea/scripts/build-plugins.sh builds one zip per generation. jprm has
no targetAbi flag, so it rewrites build.yaml per flavour and restores
it. Jellyfin installs the highest version whose targetAbi it satisfies,
so the flavour code goes into the last version segment:

  release v1.2.0 -> 1.2.0.9 / 1.2.0.11 / 1.2.0.12
  nightly        -> 1.0.<date>.<run>09 / 11 / 12

The PR, nightly and release workflows use the script and publish one
manifest entry per zip. Verified end to end against a three-entry
manifest: 10.11 installs the .11 build, 12.1 installs the .12 build.
The existing .NET 10 builder image builds all three unchanged.

Supporting changes: the test project multi-targets too, because jprm
publishes the whole solution for one framework at a time, and it drops
its Microsoft.Extensions.Logging 8.0.1 pin, which is a NU1605 downgrade
under the newer Jellyfin packages. CA1873 (new in the .NET 10 analyzers)
is silenced beside CA1848. The release manifest step checks out
origin/master like the nightly does since 7e1973a.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 01:42:38 +02:00

347 lines
19 KiB
HTML

<div id="SRFPlayRecordingsPage" data-role="page" class="page type-interior pluginConfigurationPage"
data-require="emby-button,emby-input">
<style>
#SRFPlayRecordingsPage .srfrec-wrap { padding: 0 1em; }
#SRFPlayRecordingsPage table { width: 100%; border-collapse: collapse; margin: 10px 0 20px; }
#SRFPlayRecordingsPage th { text-align: left; padding: 10px 8px; border-bottom: 2px solid #444; color: #aaa; font-size: 0.85em; text-transform: uppercase; }
#SRFPlayRecordingsPage td { padding: 10px 8px; border-bottom: 1px solid #2a2a3e; }
#SRFPlayRecordingsPage tr:hover { background: rgba(255,255,255,0.04); }
#SRFPlayRecordingsPage .btn { padding: 6px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 0.85em; color: #fff; transition: opacity 0.2s; }
#SRFPlayRecordingsPage .btn:hover { opacity: 0.85; }
#SRFPlayRecordingsPage .btn-record { background: #4CAF50; }
#SRFPlayRecordingsPage .btn-stop { background: #FF9800; }
#SRFPlayRecordingsPage .btn-cancel { background: #9E9E9E; }
#SRFPlayRecordingsPage .btn-delete { background: #f44336; }
#SRFPlayRecordingsPage .btn-refresh { background: #2196F3; margin-bottom: 16px; }
#SRFPlayRecordingsPage .status { padding: 3px 8px; border-radius: 3px; font-size: 0.8em; font-weight: 600; color: #fff; }
#SRFPlayRecordingsPage .status-scheduled { background: #1565C0; }
#SRFPlayRecordingsPage .status-waiting { background: #E65100; }
#SRFPlayRecordingsPage .status-recording { background: #2E7D32; }
#SRFPlayRecordingsPage .status-failed { background: #C62828; }
#SRFPlayRecordingsPage .srfrec-msg { padding: 12px; color: #999; font-style: italic; }
#SRFPlayRecordingsPage .srfrec-error { color: #f44; }
#SRFPlayRecordingsPage .login-form { max-width: 400px; margin: 40px auto; }
#SRFPlayRecordingsPage .login-form input { width: 100%; padding: 10px; margin: 8px 0; background: #2a2a3e; border: 1px solid #444; color: #fff; border-radius: 4px; font-size: 1em; }
#SRFPlayRecordingsPage .login-form .btn { width: 100%; padding: 12px; font-size: 1em; margin-top: 12px; }
</style>
<div data-role="content">
<div class="content-primary srfrec-wrap">
<div id="srfrecLoginSection" style="display:none;">
<div class="login-form">
<h1>SRF Sport Recordings</h1>
<p class="fieldDescription">Sign in to your Jellyfin server</p>
<input type="text" id="srfrecLoginServer" placeholder="Server URL (e.g. http://192.168.1.50:8096)" />
<input type="text" id="srfrecLoginUser" placeholder="Username" />
<input type="password" id="srfrecLoginPass" placeholder="Password" />
<button type="button" class="btn btn-record" onclick="SRFRec.login()">Sign In</button>
<p id="srfrecLoginError" class="srfrec-error" style="margin-top:10px;"></p>
</div>
</div>
<div id="srfrecMainSection" style="display:none;">
<h1>SRF Sport Recordings</h1>
<p class="fieldDescription">Browse upcoming sport livestreams and schedule recordings. <span id="srfrecUserInfo"></span></p>
<h2>Upcoming Sport Livestreams</h2>
<button type="button" class="btn btn-refresh" onclick="SRFRec.loadSchedule()">Refresh Schedule</button>
<div id="srfrecScheduleContainer"><p class="srfrec-msg">Loading schedule...</p></div>
<h2>Scheduled &amp; Active Recordings</h2>
<div id="srfrecActiveRecordingsContainer"><p class="srfrec-msg">Loading...</p></div>
<h2>Completed Recordings</h2>
<button type="button" class="btn btn-refresh" onclick="SRFRec.loadRecordings()">Refresh</button>
<div id="srfrecCompletedRecordingsContainer"><p class="srfrec-msg">Loading...</p></div>
</div>
</div>
</div>
<script>
var SRFRec = {
serverUrl: '',
token: '',
init: function() {
// Check if we're inside Jellyfin's web client (ApiClient available)
if (typeof ApiClient !== 'undefined' && ApiClient.accessToken()) {
this.serverUrl = ApiClient.serverAddress();
this.token = ApiClient.accessToken();
this.showMain();
return;
}
// Check localStorage for saved session
var saved = localStorage.getItem('srfRecSession');
if (saved) {
try {
var session = JSON.parse(saved);
this.serverUrl = session.serverUrl;
this.token = session.token;
// Verify token still works
this.verifySession(session);
return;
} catch(e) { /* fall through to login */ }
}
// Show login form
var serverInput = document.getElementById('srfrecLoginServer');
serverInput.value = window.location.origin;
document.getElementById('srfrecLoginSection').style.display = 'block';
},
verifySession: function(session) {
var self = this;
fetch(this.serverUrl + '/System/Info', {
headers: { 'Authorization': 'MediaBrowser Token="' + this.token + '"' }
}).then(function(r) {
if (r.ok) {
document.getElementById('srfrecUserInfo').textContent = '(Server: ' + self.serverUrl + ')';
self.showMain();
} else {
localStorage.removeItem('srfRecSession');
document.getElementById('srfrecLoginSection').style.display = 'block';
}
}).catch(function() {
localStorage.removeItem('srfRecSession');
document.getElementById('srfrecLoginSection').style.display = 'block';
});
},
login: function() {
var self = this;
var server = document.getElementById('srfrecLoginServer').value.replace(/\/+$/, '');
var user = document.getElementById('srfrecLoginUser').value;
var pass = document.getElementById('srfrecLoginPass').value;
var errorEl = document.getElementById('srfrecLoginError');
errorEl.textContent = '';
fetch(server + '/Users/AuthenticateByName', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'MediaBrowser Client="SRF Recordings", Device="Web", DeviceId="srfrec-' + Date.now() + '", Version="1.0.0"'
},
body: JSON.stringify({ Username: user, Pw: pass })
})
.then(function(r) {
if (!r.ok) throw new Error('Authentication failed');
return r.json();
})
.then(function(data) {
self.serverUrl = server;
self.token = data.AccessToken;
localStorage.setItem('srfRecSession', JSON.stringify({
serverUrl: server,
token: data.AccessToken,
userName: data.User.Name
}));
document.getElementById('srfrecUserInfo').textContent = '(Signed in as ' + data.User.Name + ')';
self.showMain();
})
.catch(function(err) {
errorEl.textContent = err.message;
});
},
showMain: function() {
document.getElementById('srfrecLoginSection').style.display = 'none';
document.getElementById('srfrecMainSection').style.display = 'block';
this.loadSchedule();
this.loadRecordings();
},
getHeaders: function() {
return { 'Authorization': 'MediaBrowser Token="' + this.token + '"' };
},
formatDate: function(dateStr) {
if (!dateStr) return 'N/A';
var d = new Date(dateStr);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
},
formatSize: function(bytes) {
if (!bytes) return '';
if (bytes > 1073741824) return (bytes / 1073741824).toFixed(1) + ' GB';
if (bytes > 1048576) return (bytes / 1048576).toFixed(0) + ' MB';
return (bytes / 1024).toFixed(0) + ' KB';
},
loadSchedule: function() {
var container = document.getElementById('srfrecScheduleContainer');
container.innerHTML = '<p class="srfrec-msg">Loading schedule...</p>';
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Schedule', { headers: this.getHeaders() })
.then(function(r) { return r.json(); })
.then(function(programs) {
if (!programs || programs.length === 0) {
container.innerHTML = '<p class="srfrec-msg">No upcoming sport livestreams found.</p>';
return;
}
var html = '<table>';
html += '<thead><tr><th>Title</th><th>Start</th><th>End</th><th>Action</th></tr></thead><tbody>';
programs.forEach(function(p) {
html += '<tr>';
html += '<td>' + (p.title || 'Unknown') + '</td>';
html += '<td>' + SRFRec.formatDate(p.validFrom || p.date) + '</td>';
html += '<td>' + SRFRec.formatDate(p.validTo) + '</td>';
html += '<td><button type="button" class="btn btn-record" onclick="SRFRec.scheduleRecording(\'' + encodeURIComponent(p.urn) + '\')">Record</button></td>';
html += '</tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p class="srfrec-error">Error loading schedule: ' + err.message + '</p>';
});
},
scheduleRecording: function(encodedUrn) {
var self = this;
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Schedule/' + encodedUrn, {
method: 'POST',
headers: this.getHeaders()
})
.then(function(r) {
if (r.ok) {
alert('Recording scheduled!');
self.loadRecordings();
} else {
alert('Failed to schedule recording');
}
})
.catch(function(err) { alert('Error: ' + err.message); });
},
loadRecordings: function() {
this.loadActiveRecordings();
this.loadCompletedRecordings();
},
loadActiveRecordings: function() {
var container = document.getElementById('srfrecActiveRecordingsContainer');
container.innerHTML = '<p class="srfrec-msg">Loading...</p>';
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/All', { headers: this.getHeaders() })
.then(function(r) { return r.json(); })
.then(function(recordings) {
var activeStates = ['Scheduled', 'WaitingForStream', 'Recording', 0, 1, 2];
var active = recordings.filter(function(r) {
return activeStates.indexOf(r.state) !== -1;
});
if (active.length === 0) {
container.innerHTML = '<p class="srfrec-msg">No scheduled or active recordings.</p>';
return;
}
var stateMap = {
'Scheduled': {label: 'Scheduled', cls: 'status-scheduled'},
'WaitingForStream': {label: 'Waiting', cls: 'status-waiting'},
'Recording': {label: 'Recording', cls: 'status-recording'},
0: {label: 'Scheduled', cls: 'status-scheduled'},
1: {label: 'Waiting', cls: 'status-waiting'},
2: {label: 'Recording', cls: 'status-recording'}
};
var html = '<table>';
html += '<thead><tr><th>Title</th><th>Status</th><th>Start</th><th>Action</th></tr></thead><tbody>';
active.forEach(function(r) {
var st = stateMap[r.state] || {label: r.state, cls: ''};
html += '<tr>';
html += '<td>' + (r.title || 'Unknown') + '</td>';
html += '<td><span class="status ' + st.cls + '">' + st.label + '</span></td>';
html += '<td>' + SRFRec.formatDate(r.validFrom) + '</td>';
html += '<td>';
if (r.state === 2 || r.state === 'Recording') {
html += '<button type="button" class="btn btn-stop" onclick="SRFRec.stopRecording(\'' + r.id + '\')">Stop</button>';
} else {
html += '<button type="button" class="btn btn-cancel" onclick="SRFRec.cancelRecording(\'' + r.id + '\')">Cancel</button>';
}
html += '</td></tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p class="srfrec-error">Error: ' + err.message + '</p>';
});
},
loadCompletedRecordings: function() {
var container = document.getElementById('srfrecCompletedRecordingsContainer');
container.innerHTML = '<p class="srfrec-msg">Loading...</p>';
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Completed', { headers: this.getHeaders() })
.then(function(r) { return r.json(); })
.then(function(recordings) {
if (!recordings || recordings.length === 0) {
container.innerHTML = '<p class="srfrec-msg">No completed recordings.</p>';
return;
}
var html = '<table>';
html += '<thead><tr><th>Title</th><th>Recorded</th><th>Size</th><th>Action</th></tr></thead><tbody>';
recordings.forEach(function(r) {
html += '<tr>';
html += '<td>' + (r.title || 'Unknown') + '</td>';
html += '<td>' + SRFRec.formatDate(r.recordingStartedAt) + '</td>';
html += '<td>' + SRFRec.formatSize(r.fileSizeBytes) + '</td>';
html += '<td><button type="button" class="btn btn-delete" onclick="SRFRec.deleteRecording(\'' + r.id + '\')">Delete</button></td>';
html += '</tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p class="srfrec-error">Error: ' + err.message + '</p>';
});
},
stopRecording: function(id) {
var self = this;
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Active/' + id + '/Stop', {
method: 'POST',
headers: this.getHeaders()
}).then(function() { self.loadRecordings(); });
},
cancelRecording: function(id) {
var self = this;
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Schedule/' + id, {
method: 'DELETE',
headers: this.getHeaders()
}).then(function() { self.loadRecordings(); });
},
deleteRecording: function(id) {
if (!confirm('Delete this recording and its file?')) return;
var self = this;
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Completed/' + id + '?deleteFile=true', {
method: 'DELETE',
headers: this.getHeaders()
}).then(function() { self.loadRecordings(); });
}
};
(function() {
var page = document.querySelector('#SRFPlayRecordingsPage');
if (page && typeof page.addEventListener === 'function' && typeof ApiClient !== 'undefined') {
// Inside the Jellyfin dashboard: defer to the page lifecycle so we
// never run during the config page's view and never hijack routing.
page.addEventListener('pageshow', function() { SRFRec.init(); });
} else {
// Standalone (e.g. TV/browser): initialise immediately.
SRFRec.init();
}
})();
</script>
</div>