Files
jellyfin-srfPlay/Jellyfin.Plugin.SRFPlay/Services/MediaSourceFactory.cs
T
dtourolleandClaude Opus 5 390146e8d4
🏗️ Build Plugin / build (push) Successful in 1m43s
🧪 Test Plugin / test (push) Successful in 34s
🚀 Release Plugin / build-and-release (push) Successful in 51s
Nightly Build / nightly-build (push) Failing after 50s
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>
2026-08-12 18:55:41 +02:00

183 lines
6.9 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Constants;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
using MediaBrowser.Controller;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Factory for creating MediaSourceInfo objects with consistent configuration.
/// Consolidates duplicated logic from SRFMediaProvider and SRFPlayChannel.
/// </summary>
public class MediaSourceFactory : IMediaSourceFactory
{
private readonly ILogger<MediaSourceFactory> _logger;
private readonly IStreamUrlResolver _streamResolver;
private readonly IStreamProxyService _proxyService;
private readonly IServerApplicationHost _appHost;
/// <summary>
/// Initializes a new instance of the <see cref="MediaSourceFactory"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="streamResolver">The stream URL resolver.</param>
/// <param name="proxyService">The stream proxy service.</param>
/// <param name="appHost">The server application host.</param>
public MediaSourceFactory(
ILogger<MediaSourceFactory> logger,
IStreamUrlResolver streamResolver,
IStreamProxyService proxyService,
IServerApplicationHost appHost)
{
_logger = logger;
_streamResolver = streamResolver;
_proxyService = proxyService;
_appHost = appHost;
}
/// <inheritdoc />
public Task<MediaSourceInfo?> CreateMediaSourceAsync(
Chapter chapter,
string itemId,
string urn,
QualityPreference qualityPreference,
CancellationToken cancellationToken = default)
{
// Get stream URL based on quality preference (unauthenticated)
var streamUrl = _streamResolver.GetStreamUrl(chapter, qualityPreference);
if (string.IsNullOrEmpty(streamUrl))
{
_logger.LogWarning("Could not resolve stream URL for chapter: {ChapterId}", chapter.Id);
return Task.FromResult<MediaSourceInfo?>(null);
}
// A scheduled livestream is only treated as live when the broadcast window is open.
// Past replays (ValidTo in the past) and upcoming events (ValidFrom in the future)
// are treated as VOD to avoid IsInfiniteStream/IgnoreDts flags and FFmpeg -re mode.
var isScheduledLivestream = chapter.Type == "SCHEDULED_LIVESTREAM" ||
UrnHelper.IsLivestreamUrn(urn);
var now = DateTime.UtcNow;
var isLiveStream = isScheduledLivestream &&
(chapter.ValidFrom == null || chapter.ValidFrom.Value.ToUniversalTime() <= now) &&
(chapter.ValidTo == null || chapter.ValidTo.Value.ToUniversalTime() > now);
// Register stream with UNAUTHENTICATED URL - proxy will authenticate on-demand
// This avoids wasting 30-second tokens during category browsing
_proxyService.RegisterStreamDeferred(itemId, streamUrl, urn, isLiveStream);
// Build proxy URL
var proxyUrl = BuildProxyUrl(itemId);
_logger.LogDebug(
"Created media source for {Title} - ItemId: {ItemId}, IsLiveStream: {IsLiveStream}",
chapter.Title,
itemId,
isLiveStream);
// Create MediaSourceInfo with codec info so clients know they can direct play
// Provide MediaStreams with H.264+AAC so Android TV/ExoPlayer doesn't trigger transcoding
var mediaStreams = CreateMediaStreams(qualityPreference);
var mediaSource = new MediaSourceInfo
{
Id = itemId,
Name = chapter.Title,
Path = proxyUrl,
Protocol = MediaProtocol.Http,
// Use "hls" to trigger hls.js player in web client
Container = "hls",
SupportsDirectStream = false,
SupportsDirectPlay = true,
SupportsTranscoding = true,
IsRemote = true,
Type = MediaSourceType.Default,
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
VideoType = VideoType.VideoFile,
IsInfiniteStream = isLiveStream,
// RequiresOpening = false: proxy handles auth directly, no need for Jellyfin to open the stream
RequiresOpening = false,
RequiresClosing = false,
// Disable probing - we provide stream info directly
SupportsProbing = false,
ReadAtNativeFramerate = isLiveStream,
// Provide codec info so clients know they can direct play H.264+AAC
MediaStreams = mediaStreams.ToList(),
AnalyzeDurationMs = isLiveStream ? 1000 : 3000,
IgnoreDts = isLiveStream,
IgnoreIndex = isLiveStream,
};
return Task.FromResult<MediaSourceInfo?>(mediaSource);
}
/// <inheritdoc />
public string BuildProxyUrl(string itemId)
{
return $"{GetServerBaseUrl()}{ApiEndpoints.ProxyMasterManifestPath.Replace("{0}", itemId, StringComparison.Ordinal)}";
}
/// <inheritdoc />
public string GetServerBaseUrl()
{
var config = Plugin.Instance?.Configuration;
return config != null && !string.IsNullOrWhiteSpace(config.PublicServerUrl)
? config.PublicServerUrl.TrimEnd('/')
: _appHost.GetSmartApiUrl(string.Empty);
}
/// <inheritdoc />
public IReadOnlyList<MediaStream> CreateMediaStreams(QualityPreference quality)
{
// Set resolution/bitrate based on quality preference
var (width, height, videoBitrate) = quality switch
{
QualityPreference.SD => (1280, 720, 2500000),
QualityPreference.HD => (1920, 1080, 5000000),
_ => (1280, 720, 3000000)
};
return new List<MediaStream>
{
new MediaStream
{
Type = MediaStreamType.Video,
Codec = "h264",
Profile = "high",
Level = 40,
Width = width,
Height = height,
BitRate = videoBitrate,
BitDepth = 8,
IsInterlaced = false,
IsDefault = true,
Index = 0,
IsAVC = true,
PixelFormat = "yuv420p"
},
new MediaStream
{
Type = MediaStreamType.Audio,
Codec = "aac",
Profile = "LC",
Channels = 2,
SampleRate = 48000,
BitRate = 128000,
IsDefault = true,
Index = 1
}
};
}
}