Files
jellyfin-srfPlay/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html
T
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

483 lines
33 KiB
HTML

<div id="SRFPlayConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<form id="SRFPlayConfigForm">
<div class="checkboxContainer checkboxContainer-withDescription">
<h3>Channels</h3>
<div class="fieldDescription">Every Swiss broadcaster has its own channel tile: SRF (German), RTS (French), RSI (Italian), RTR (Romansh) and SWI (swissinfo.ch). They are always available — pin the ones you use from the Jellyfin home screen.</div>
</div>
<div class="selectContainer">
<label class="selectLabel" for="QualityPreference">Quality Preference</label>
<select is="emby-select" id="QualityPreference" name="QualityPreference" class="emby-select-withcolor emby-select">
<option id="optAuto" value="Auto">Automatic</option>
<option id="optSD" value="SD">Standard Definition</option>
<option id="optHD" value="HD">High Definition</option>
</select>
<div class="fieldDescription">Preferred video quality for playback</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ContentRefreshIntervalHours">Content Refresh Interval (hours)</label>
<input id="ContentRefreshIntervalHours" name="ContentRefreshIntervalHours" type="number" is="emby-input" min="1" max="168" />
<div class="fieldDescription">How often to check for new content (1-168 hours)</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ExpirationCheckIntervalHours">Expiration Check Interval (hours)</label>
<input id="ExpirationCheckIntervalHours" name="ExpirationCheckIntervalHours" type="number" is="emby-input" min="1" max="168" />
<div class="fieldDescription">How often to check for expired content (1-168 hours)</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="CacheDurationMinutes">Cache Duration (minutes)</label>
<input id="CacheDurationMinutes" name="CacheDurationMinutes" type="number" is="emby-input" min="5" max="1440" />
<div class="fieldDescription">How long to cache metadata (5-1440 minutes)</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableLatestContent" name="EnableLatestContent" type="checkbox" is="emby-checkbox" />
<span>Enable Latest Content</span>
</label>
<div class="fieldDescription">Automatically discover and add latest videos</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="EnableTrendingContent" name="EnableTrendingContent" type="checkbox" is="emby-checkbox" />
<span>Enable Trending Content</span>
</label>
<div class="fieldDescription">Automatically discover and add trending videos</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="GenerateTitleCards" name="GenerateTitleCards" type="checkbox" is="emby-checkbox" />
<span>Generate Title Cards</span>
</label>
<div class="fieldDescription">Generate custom thumbnails with the content title instead of using SRF-provided images</div>
</div>
<br />
<h2>Proxy Settings</h2>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="UseProxy" name="UseProxy" type="checkbox" is="emby-checkbox" />
<span>Use Proxy</span>
</label>
<div class="fieldDescription">Route all SRF API requests through a proxy server</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ProxyAddress">Proxy Address</label>
<input id="ProxyAddress" name="ProxyAddress" type="text" is="emby-input" />
<div class="fieldDescription">Proxy server address (e.g., http://proxy.example.com:8080 or socks5://proxy.example.com:1080)</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ProxyUsername">Proxy Username (Optional)</label>
<input id="ProxyUsername" name="ProxyUsername" type="text" is="emby-input" autocomplete="off" />
<div class="fieldDescription">Username for proxy authentication (leave empty if not required)</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ProxyPassword">Proxy Password (Optional)</label>
<input id="ProxyPassword" name="ProxyPassword" type="password" is="emby-input" autocomplete="off" />
<div class="fieldDescription">Password for proxy authentication (leave empty if not required)</div>
</div>
<br />
<h2>Recording Settings</h2>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="RecordingOutputPath">Recording Output Directory</label>
<input id="RecordingOutputPath" name="RecordingOutputPath" type="text" is="emby-input" placeholder="e.g., /media/recordings/srf" />
<div class="fieldDescription">Directory where sport livestream recordings will be saved (requires ffmpeg)</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="LiveStartSegmentsBack">Live Start Offset (segments)</label>
<input id="LiveStartSegmentsBack" name="LiveStartSegmentsBack" type="number" is="emby-input" min="0" max="20" />
<div class="fieldDescription">How many segments back from the live edge livestreams start playing. Fixes jumpy/stalling playback at the start on Android TV. Minimum 3 (RFC requirement); 0 disables it. Default 3.</div>
</div>
<br />
<h2>Continue Watching Cleanup</h2>
<div class="fieldDescription" style="margin-bottom: 1em;">
Livestreams and abandoned playback otherwise stay pinned in every user's
&quot;Continue Watching&quot; row forever. Clearing a resume point only resets the
playback position &mdash; nothing is deleted and the item stays unwatched.
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="CleanUpResumePoints" name="CleanUpResumePoints" type="checkbox" is="emby-checkbox" />
<span>Clean up stale resume points</span>
</label>
<div class="fieldDescription checkboxFieldDescription">Runs daily at 4 AM (Scheduled Tasks &rarr; &quot;Clean Up SRF Play Continue Watching&quot;)</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="ClearLiveStreamResumePoints" name="ClearLiveStreamResumePoints" type="checkbox" is="emby-checkbox" />
<span>Never keep a resume point for livestreams</span>
</label>
<div class="fieldDescription checkboxFieldDescription">Clears the position as soon as a livestream stops playing, rather than waiting for the daily task</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ResumePointMaxAgeDays">Maximum Resume Point Age (days)</label>
<input id="ResumePointMaxAgeDays" name="ResumePointMaxAgeDays" type="number" is="emby-input" min="0" max="3650" />
<div class="fieldDescription">Clear resume points not touched for this many days. 0 disables the rule. Default 30.</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ResumePointMinPositionSeconds">Minimum Resume Position (seconds)</label>
<input id="ResumePointMinPositionSeconds" name="ResumePointMinPositionSeconds" type="number" is="emby-input" min="0" max="3600" />
<div class="fieldDescription">Positions at or below this are treated as an accidental start and cleared. 0 disables the rule. Default 60.</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ResumePointCompletedPercent">Finished Threshold (%)</label>
<input id="ResumePointCompletedPercent" name="ResumePointCompletedPercent" type="number" is="emby-input" min="0" max="100" />
<div class="fieldDescription">Playback at or beyond this share of the runtime counts as finished. Only applies when the runtime is known. 0 disables the rule. Default 92.</div>
</div>
<br />
<h2>Network Settings</h2>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="PublicServerUrl">Public Server URL (Optional)</label>
<input id="PublicServerUrl" name="PublicServerUrl" type="text" is="emby-input" placeholder="e.g., https://jellyfin.example.com:8920" />
<div class="fieldDescription">
The public/external URL for remote clients (Android, iOS, etc.) to access streaming proxy.
<br />If not set, the plugin will use Jellyfin's automatic URL detection which may return local addresses.
<br /><strong>Important for Android/remote playback!</strong>
</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
</button>
</div>
</form>
<br />
<h2>Continue Watching Maintenance</h2>
<div class="fieldDescription">Run the cleanup right now instead of waiting for the daily task. Only SRF Play items are affected.</div>
<div id="resumeCleanupResult" style="margin: 10px 0;"></div>
<button is="emby-button" type="button" class="raised emby-button" onclick="SRFPlayMaintenance.cleanupResumePoints(false)" style="margin-bottom: 20px;">
<span>Clean Up Stale Entries Now</span>
</button>
<button is="emby-button" type="button" class="raised emby-button" onclick="SRFPlayMaintenance.cleanupResumePoints(true)" style="margin-bottom: 20px;">
<span>Clear All SRF Play Entries</span>
</button>
<br />
<h2>Sport Livestream Recordings</h2>
<h3>Upcoming Sport Livestreams</h3>
<div class="fieldDescription">Select livestreams to record. The recording starts automatically when the stream goes live.</div>
<div id="scheduleContainer" style="margin: 10px 0;">
<p><em>Loading schedule...</em></p>
</div>
<button is="emby-button" type="button" class="raised emby-button" onclick="SRFPlayRecordings.loadSchedule()" style="margin-bottom: 20px;">
<span>Refresh Schedule</span>
</button>
<h3>Scheduled &amp; Active Recordings</h3>
<div id="activeRecordingsContainer" style="margin: 10px 0;">
<p><em>Loading...</em></p>
</div>
<h3>Completed Recordings</h3>
<div id="completedRecordingsContainer" style="margin: 10px 0;">
<p><em>Loading...</em></p>
</div>
<button is="emby-button" type="button" class="raised emby-button" onclick="SRFPlayRecordings.loadRecordings()" style="margin-bottom: 20px;">
<span>Refresh Recordings</span>
</button>
</div>
</div>
<script type="text/javascript">
var SRFPlayConfig = {
pluginUniqueId: 'a4b12f86-8c3d-4e9a-b7f2-1d5e6c8a9b4f'
};
document.querySelector('#SRFPlayConfigPage')
.addEventListener('pageshow', function() {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
document.querySelector('#QualityPreference').value = config.QualityPreference;
document.querySelector('#ContentRefreshIntervalHours').value = config.ContentRefreshIntervalHours;
document.querySelector('#ExpirationCheckIntervalHours').value = config.ExpirationCheckIntervalHours;
document.querySelector('#CacheDurationMinutes').value = config.CacheDurationMinutes;
document.querySelector('#EnableLatestContent').checked = config.EnableLatestContent;
document.querySelector('#EnableTrendingContent').checked = config.EnableTrendingContent;
document.querySelector('#GenerateTitleCards').checked = config.GenerateTitleCards !== false;
document.querySelector('#UseProxy').checked = config.UseProxy || false;
document.querySelector('#ProxyAddress').value = config.ProxyAddress || '';
document.querySelector('#ProxyUsername').value = config.ProxyUsername || '';
document.querySelector('#ProxyPassword').value = config.ProxyPassword || '';
document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || '';
document.querySelector('#RecordingOutputPath').value = config.RecordingOutputPath || '';
document.querySelector('#LiveStartSegmentsBack').value = config.LiveStartSegmentsBack != null ? config.LiveStartSegmentsBack : 3;
document.querySelector('#CleanUpResumePoints').checked = config.CleanUpResumePoints !== false;
document.querySelector('#ClearLiveStreamResumePoints').checked = config.ClearLiveStreamResumePoints !== false;
document.querySelector('#ResumePointMaxAgeDays').value = config.ResumePointMaxAgeDays != null ? config.ResumePointMaxAgeDays : 30;
document.querySelector('#ResumePointMinPositionSeconds').value = config.ResumePointMinPositionSeconds != null ? config.ResumePointMinPositionSeconds : 60;
document.querySelector('#ResumePointCompletedPercent').value = config.ResumePointCompletedPercent != null ? config.ResumePointCompletedPercent : 92;
Dashboard.hideLoadingMsg();
// Load recordings UI
SRFPlayRecordings.loadSchedule();
SRFPlayRecordings.loadRecordings();
});
});
document.querySelector('#SRFPlayConfigForm')
.addEventListener('submit', function(e) {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
config.QualityPreference = document.querySelector('#QualityPreference').value;
config.ContentRefreshIntervalHours = parseInt(document.querySelector('#ContentRefreshIntervalHours').value);
config.ExpirationCheckIntervalHours = parseInt(document.querySelector('#ExpirationCheckIntervalHours').value);
config.CacheDurationMinutes = parseInt(document.querySelector('#CacheDurationMinutes').value);
config.EnableLatestContent = document.querySelector('#EnableLatestContent').checked;
config.EnableTrendingContent = document.querySelector('#EnableTrendingContent').checked;
config.GenerateTitleCards = document.querySelector('#GenerateTitleCards').checked;
config.UseProxy = document.querySelector('#UseProxy').checked;
config.ProxyAddress = document.querySelector('#ProxyAddress').value;
config.ProxyUsername = document.querySelector('#ProxyUsername').value;
config.ProxyPassword = document.querySelector('#ProxyPassword').value;
config.PublicServerUrl = document.querySelector('#PublicServerUrl').value;
config.RecordingOutputPath = document.querySelector('#RecordingOutputPath').value;
config.LiveStartSegmentsBack = parseInt(document.querySelector('#LiveStartSegmentsBack').value) || 0;
config.CleanUpResumePoints = document.querySelector('#CleanUpResumePoints').checked;
config.ClearLiveStreamResumePoints = document.querySelector('#ClearLiveStreamResumePoints').checked;
config.ResumePointMaxAgeDays = parseInt(document.querySelector('#ResumePointMaxAgeDays').value) || 0;
config.ResumePointMinPositionSeconds = parseInt(document.querySelector('#ResumePointMinPositionSeconds').value) || 0;
config.ResumePointCompletedPercent = parseInt(document.querySelector('#ResumePointCompletedPercent').value) || 0;
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
});
e.preventDefault();
return false;
});
var SRFPlayMaintenance = {
cleanupResumePoints: function(clearAll) {
if (clearAll && !confirm('Clear the playback position of every SRF Play item for all users? Nothing is deleted and items stay unwatched.')) {
return;
}
var target = document.querySelector('#resumeCleanupResult');
target.innerHTML = '<p><em>Cleaning up...</em></p>';
fetch(ApiClient.serverAddress() + '/Plugins/SRFPlay/Maintenance/ResumePoints/Cleanup?clearAll=' + (clearAll ? 'true' : 'false'), {
method: 'POST',
headers: { 'Authorization': 'MediaBrowser Token="' + ApiClient.accessToken() + '"' }
})
.then(function(response) {
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
return response.json();
})
.then(function(result) {
var byReason = result.clearedByReason || {};
var reasons = Object.keys(byReason)
.map(function(key) { return key + ': ' + byReason[key]; })
.join(', ');
target.innerHTML = '<p>Cleared <strong>' + result.cleared + '</strong> of ' + result.inspected +
' SRF Play resume point(s) across ' + result.usersChecked + ' user(s).' +
(reasons ? ' (' + reasons + ')' : '') + '</p>';
})
.catch(function(error) {
target.innerHTML = '<p style="color: #d44;">Cleanup failed: ' + error.message + '</p>';
});
}
};
var SRFPlayRecordings = {
apiBase: ApiClient.serverAddress() + '/Plugins/SRFPlay/Recording',
getHeaders: function() {
return {
'Authorization': 'MediaBrowser Token="' + ApiClient.accessToken() + '"'
};
},
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('scheduleContainer');
container.innerHTML = '<p><em>Loading schedule...</em></p>';
fetch(this.apiBase + '/Schedule', { headers: this.getHeaders() })
.then(function(r) { return r.json(); })
.then(function(programs) {
if (!programs || programs.length === 0) {
container.innerHTML = '<p>No upcoming sport livestreams found.</p>';
return;
}
var html = '<table style="width:100%; border-collapse: collapse;">';
html += '<thead><tr style="text-align:left; border-bottom: 1px solid #444;">';
html += '<th style="padding: 8px;">Title</th>';
html += '<th style="padding: 8px;">Start</th>';
html += '<th style="padding: 8px;">End</th>';
html += '<th style="padding: 8px;">Action</th>';
html += '</tr></thead><tbody>';
programs.forEach(function(p) {
html += '<tr style="border-bottom: 1px solid #333;">';
html += '<td style="padding: 8px;">' + (p.title || 'Unknown') + '</td>';
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(p.validFrom || p.date) + '</td>';
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(p.validTo) + '</td>';
html += '<td style="padding: 8px;">';
html += '<button is="emby-button" type="button" class="raised emby-button" ';
html += 'onclick="SRFPlayRecordings.scheduleRecording(\'' + encodeURIComponent(p.urn) + '\')">';
html += '<span>Record</span></button>';
html += '</td></tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p style="color: #f44;">Error loading schedule: ' + err.message + '</p>';
});
},
scheduleRecording: function(encodedUrn) {
fetch(this.apiBase + '/Schedule/' + encodedUrn, {
method: 'POST',
headers: this.getHeaders()
})
.then(function(r) {
if (r.ok) {
Dashboard.alert('Recording scheduled!');
SRFPlayRecordings.loadRecordings();
} else {
Dashboard.alert('Failed to schedule recording');
}
})
.catch(function(err) { Dashboard.alert('Error: ' + err.message); });
},
loadRecordings: function() {
this.loadActiveRecordings();
this.loadCompletedRecordings();
},
loadActiveRecordings: function() {
var container = document.getElementById('activeRecordingsContainer');
container.innerHTML = '<p><em>Loading...</em></p>';
fetch(this.apiBase + '/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>No scheduled or active recordings.</p>';
return;
}
var stateLabels = {'Scheduled': 'Scheduled', 'WaitingForStream': 'Waiting', 'Recording': 'Recording', 'Completed': 'Completed', 'Failed': 'Failed', 'Cancelled': 'Cancelled', 0: 'Scheduled', 1: 'Waiting', 2: 'Recording', 3: 'Completed', 4: 'Failed', 5: 'Cancelled'};
var stateColors = {'Scheduled': '#2196F3', 'WaitingForStream': '#FF9800', 'Recording': '#4CAF50', 'Failed': '#f44336', 'Cancelled': '#9E9E9E', 0: '#2196F3', 1: '#FF9800', 2: '#4CAF50', 4: '#f44336', 5: '#9E9E9E'};
var html = '<table style="width:100%; border-collapse: collapse;">';
html += '<thead><tr style="text-align:left; border-bottom: 1px solid #444;">';
html += '<th style="padding: 8px;">Title</th>';
html += '<th style="padding: 8px;">Status</th>';
html += '<th style="padding: 8px;">Start</th>';
html += '<th style="padding: 8px;">Action</th>';
html += '</tr></thead><tbody>';
active.forEach(function(r) {
html += '<tr style="border-bottom: 1px solid #333;">';
html += '<td style="padding: 8px;">' + (r.title || 'Unknown') + '</td>';
html += '<td style="padding: 8px;"><span style="color:' + (stateColors[r.state] || '#fff') + ';">' + (stateLabels[r.state] || r.state) + '</span></td>';
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(r.validFrom) + '</td>';
html += '<td style="padding: 8px;">';
if (r.state === 2 || r.state === 'Recording') {
html += '<button is="emby-button" type="button" class="raised emby-button" ';
html += 'onclick="SRFPlayRecordings.stopRecording(\'' + r.id + '\')"><span>Stop</span></button>';
} else {
html += '<button is="emby-button" type="button" class="raised emby-button" ';
html += 'onclick="SRFPlayRecordings.cancelRecording(\'' + r.id + '\')"><span>Cancel</span></button>';
}
html += '</td></tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p style="color: #f44;">Error: ' + err.message + '</p>';
});
},
loadCompletedRecordings: function() {
var container = document.getElementById('completedRecordingsContainer');
container.innerHTML = '<p><em>Loading...</em></p>';
fetch(this.apiBase + '/Completed', { headers: this.getHeaders() })
.then(function(r) { return r.json(); })
.then(function(recordings) {
if (!recordings || recordings.length === 0) {
container.innerHTML = '<p>No completed recordings.</p>';
return;
}
var html = '<table style="width:100%; border-collapse: collapse;">';
html += '<thead><tr style="text-align:left; border-bottom: 1px solid #444;">';
html += '<th style="padding: 8px;">Title</th>';
html += '<th style="padding: 8px;">Recorded</th>';
html += '<th style="padding: 8px;">Size</th>';
html += '<th style="padding: 8px;">File</th>';
html += '<th style="padding: 8px;">Action</th>';
html += '</tr></thead><tbody>';
recordings.forEach(function(r) {
html += '<tr style="border-bottom: 1px solid #333;">';
html += '<td style="padding: 8px;">' + (r.title || 'Unknown') + '</td>';
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(r.recordingStartedAt) + '</td>';
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatSize(r.fileSizeBytes) + '</td>';
html += '<td style="padding: 8px; font-size: 0.85em; word-break: break-all;">' + (r.outputPath || '') + '</td>';
html += '<td style="padding: 8px;">';
html += '<button is="emby-button" type="button" class="raised emby-button" style="background:#f44336;" ';
html += 'onclick="SRFPlayRecordings.deleteRecording(\'' + r.id + '\')"><span>Delete</span></button>';
html += '</td></tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p style="color: #f44;">Error: ' + err.message + '</p>';
});
},
stopRecording: function(id) {
fetch(this.apiBase + '/Active/' + id + '/Stop', {
method: 'POST',
headers: this.getHeaders()
}).then(function() { SRFPlayRecordings.loadRecordings(); });
},
cancelRecording: function(id) {
fetch(this.apiBase + '/Schedule/' + id, {
method: 'DELETE',
headers: this.getHeaders()
}).then(function() { SRFPlayRecordings.loadRecordings(); });
},
deleteRecording: function(id) {
if (!confirm('Delete this recording and its file?')) return;
fetch(this.apiBase + '/Completed/' + id + '?deleteFile=true', {
method: 'DELETE',
headers: this.getHeaders()
}).then(function() { SRFPlayRecordings.loadRecordings(); });
}
};
</script>
</div>