Clean up stale Continue Watching entries
Livestreams and ended broadcasts accumulated in every user's resume row
because Jellyfin saves a playback position for channel items regardless
of whether the position means anything. A livestream has nothing to
return to, so the entry stayed pinned forever.
Two parts:
- PlaybackResumeGuard, an IHostedService on ISessionManager.PlaybackStopped.
Jellyfin saves the resume point before raising the event, so the guard
zeroes it afterwards for livestreams. Stops new entries at the source.
- ResumeCleanupService plus a daily 4 AM task (after the 3 AM expiration
check) for the existing backlog. Clears livestreams, resume points older
than N days, positions under N seconds and playback past N% of runtime,
each threshold configurable.
Only plugin-owned items are touched, matched by the SRF provider ID with a
fallback to the owning channel ID so recordings are covered too. Clearing
sets PlaybackPositionTicks to 0 and leaves Played false: nothing is deleted
and the item stays unwatched.
Config page gains the thresholds plus "Clean Up Stale Entries Now" and
"Clear All SRF Play Entries" buttons, backed by MaintenanceController
under RequiresElevation.
Also folds the duplicated urn.Contains("livestream") check in
MediaSourceFactory and RecordingService into UrnHelper.IsLivestreamUrn.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -88,6 +88,42 @@
|
||||
<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
|
||||
"Continue Watching" row forever. Clearing a resume point only resets the
|
||||
playback position — 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 → "Clean Up SRF Play Continue Watching")</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>
|
||||
@@ -105,6 +141,17 @@
|
||||
</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>
|
||||
|
||||
@@ -154,6 +201,11 @@
|
||||
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
|
||||
@@ -180,6 +232,11 @@
|
||||
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);
|
||||
});
|
||||
@@ -189,6 +246,40 @@
|
||||
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: { 'X-Emby-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',
|
||||
|
||||
|
||||
Reference in New Issue
Block a user