diff --git a/.gitignore b/.gitignore index 0b72c24..7c39d6b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ obj/ .vs/ .idea/ artifacts +*.log diff --git a/Jellyfin.Plugin.SRFPlay/Api/Models/PlayV3/PlayV3TvProgramGuideResponse.cs b/Jellyfin.Plugin.SRFPlay/Api/Models/PlayV3/PlayV3TvProgramGuideResponse.cs deleted file mode 100644 index b860bdf..0000000 --- a/Jellyfin.Plugin.SRFPlay/Api/Models/PlayV3/PlayV3TvProgramGuideResponse.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3; - -/// -/// TV Program Guide response from Play v3 API. -/// -public class PlayV3TvProgramGuideResponse -{ - /// - /// Gets the list of TV program entries. - /// - [JsonPropertyName("data")] - public IReadOnlyList? Data { get; init; } -} diff --git a/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs b/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs index abaa0dc..5879b95 100644 --- a/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs +++ b/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs @@ -2,13 +2,13 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; -using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.SRFPlay.Api; using Jellyfin.Plugin.SRFPlay.Constants; using Jellyfin.Plugin.SRFPlay.Services.Interfaces; +using Jellyfin.Plugin.SRFPlay.Utilities; using MediaBrowser.Controller; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Providers; @@ -81,7 +81,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey /// public InternalChannelFeatures GetChannelFeatures() { - _logger.LogInformation("=== GetChannelFeatures called for SRF Play channel ==="); + _logger.LogDebug("GetChannelFeatures called"); return new InternalChannelFeatures { @@ -127,12 +127,12 @@ public class SRFPlayChannel : IChannel, IHasCacheKey /// public async Task GetChannelItems(InternalChannelItemQuery query, CancellationToken cancellationToken) { - _logger.LogInformation("=== GetChannelItems called! FolderId: {FolderId} ===", query.FolderId); + _logger.LogDebug("GetChannelItems called for folder {FolderId}", query.FolderId); try { var items = await GetFolderItemsAsync(query.FolderId, cancellationToken).ConfigureAwait(false); - _logger.LogInformation("Returning {Count} channel items for folder {FolderId}", items.Count, query.FolderId); + _logger.LogDebug("Returning {Count} channel items for folder {FolderId}", items.Count, query.FolderId); return new ChannelItemResult { @@ -393,8 +393,14 @@ public class SRFPlayChannel : IChannel, IHasCacheKey var enabledTopics = config?.EnabledTopics != null && config.EnabledTopics.Count > 0 ? string.Join(",", config.EnabledTopics) : "all"; - var date = DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); - return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{date}"; + + // Use 15-minute time buckets for cache key so live content refreshes frequently + // This ensures livestream folders update as programs start/end throughout the day + var now = DateTime.Now; + var timeBucket = new DateTime(now.Year, now.Month, now.Day, now.Hour, (now.Minute / 15) * 15, 0); + var timeKey = timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture); + + return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}"; } private async Task> ConvertUrnsToChannelItems(List urns, CancellationToken cancellationToken) @@ -449,7 +455,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey } // Generate deterministic GUID from URN - var itemId = UrnToGuid(urn); + var itemId = UrnHelper.ToGuid(urn); // Use factory to create MediaSourceInfo (handles stream URL, auth, proxy registration) var mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync( @@ -557,25 +563,6 @@ public class SRFPlayChannel : IChannel, IHasCacheKey return items; } - /// - /// Generates a deterministic GUID from a URN. - /// This ensures the same URN always produces the same GUID. - /// MD5 is used for non-cryptographic purposes only (generating IDs). - /// - /// The URN to convert. - /// A deterministic GUID. -#pragma warning disable CA5351 // MD5 is used for non-cryptographic purposes (ID generation) - private static string UrnToGuid(string urn) - { - // Use MD5 to generate a deterministic hash from the URN - var hash = MD5.HashData(Encoding.UTF8.GetBytes(urn)); - - // Convert the first 16 bytes to a GUID - var guid = new Guid(hash); - return guid.ToString(); - } -#pragma warning restore CA5351 - /// /// Creates a proxy URL for an image to fix Content-Type headers from SRF CDN. /// diff --git a/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs b/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs index 72fb6da..d799429 100644 --- a/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs +++ b/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs @@ -5,6 +5,7 @@ using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.SRFPlay.Services.Interfaces; +using Jellyfin.Plugin.SRFPlay.Utilities; using MediaBrowser.Common.Net; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -242,7 +243,7 @@ public class StreamProxyController : ControllerBase } // Determine content type based on file extension - var contentType = GetContentType(segmentPath); + var contentType = MimeTypeHelper.GetSegmentContentType(segmentPath); _logger.LogDebug("Returning segment {SegmentPath} ({Length} bytes, {ContentType})", segmentPath, segmentData.Length, contentType); return File(segmentData, contentType); @@ -382,32 +383,6 @@ public class StreamProxyController : ControllerBase return result.ToString(); } - /// - /// Gets the content type for a segment based on file extension. - /// - /// The segment path. - /// The MIME content type. - private static string GetContentType(string path) - { - if (path.EndsWith(".ts", StringComparison.OrdinalIgnoreCase)) - { - return "video/MP2T"; - } - - if (path.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase) || - path.EndsWith(".m4s", StringComparison.OrdinalIgnoreCase)) - { - return "video/mp4"; - } - - if (path.EndsWith(".aac", StringComparison.OrdinalIgnoreCase)) - { - return "audio/aac"; - } - - return "application/octet-stream"; - } - /// /// Proxies image requests from SRF CDN, fixing Content-Type headers. /// @@ -471,7 +446,7 @@ public class StreamProxyController : ControllerBase .ConfigureAwait(false); // Determine correct content type - var contentType = GetImageContentType(decodedUrl); + var contentType = MimeTypeHelper.GetImageContentType(decodedUrl); _logger.LogDebug( "Returning proxied image ({Length} bytes, {ContentType})", @@ -486,38 +461,4 @@ public class StreamProxyController : ControllerBase return StatusCode(StatusCodes.Status502BadGateway); } } - - /// - /// Gets the content type for an image based on URL or file extension. - /// - /// The image URL. - /// The MIME content type. - private static string GetImageContentType(string url) - { - if (string.IsNullOrEmpty(url)) - { - return "image/jpeg"; - } - - var uri = new Uri(url); - var path = uri.AbsolutePath.ToLowerInvariant(); - - if (path.EndsWith(".png", StringComparison.Ordinal)) - { - return "image/png"; - } - - if (path.EndsWith(".gif", StringComparison.Ordinal)) - { - return "image/gif"; - } - - if (path.EndsWith(".webp", StringComparison.Ordinal)) - { - return "image/webp"; - } - - // Default to JPEG for SRF images (most common) - return "image/jpeg"; - } } diff --git a/Jellyfin.Plugin.SRFPlay/LiveTv/SRFLiveTvService.cs b/Jellyfin.Plugin.SRFPlay/LiveTv/SRFLiveTvService.cs new file mode 100644 index 0000000..69675b0 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/LiveTv/SRFLiveTvService.cs @@ -0,0 +1,390 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.SRFPlay.Api; +using Jellyfin.Plugin.SRFPlay.Api.Models; +using Jellyfin.Plugin.SRFPlay.Configuration; +using Jellyfin.Plugin.SRFPlay.Services.Interfaces; +using Jellyfin.Plugin.SRFPlay.Utilities; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.LiveTv; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.SRFPlay.LiveTv; + +/// +/// Live TV service providing virtual channels for SRF scheduled livestreams. +/// +public class SRFLiveTvService : ILiveTvService +{ + private readonly ILogger _logger; + private readonly ISRFApiClientFactory _apiClientFactory; + private readonly IStreamUrlResolver _streamResolver; + private readonly IMediaSourceFactory _mediaSourceFactory; + + // Virtual channel definitions + private static readonly VirtualChannel[] VirtualChannels = new[] + { + new VirtualChannel("srf-sport", "SRF Sport", "SPORT", "Live sports events from SRF"), + new VirtualChannel("srf-events", "SRF Events", "EVENT", "Special events and broadcasts from SRF") + }; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The API client factory. + /// The stream URL resolver. + /// The media source factory. + public SRFLiveTvService( + ILogger logger, + ISRFApiClientFactory apiClientFactory, + IStreamUrlResolver streamResolver, + IMediaSourceFactory mediaSourceFactory) + { + _logger = logger; + _apiClientFactory = apiClientFactory; + _streamResolver = streamResolver; + _mediaSourceFactory = mediaSourceFactory; + } + + /// + public string Name => "SRF Play Live"; + + /// + public string HomePageUrl => "https://www.srf.ch/play"; + + /// + public async Task> GetChannelsAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("GetChannelsAsync called - returning {Count} virtual channels", VirtualChannels.Length); + + var channels = new List(); + + foreach (var vc in VirtualChannels) + { + channels.Add(new ChannelInfo + { + Id = vc.Id, + Name = vc.Name, + Number = vc.Id, // Use ID as channel number + ImageUrl = null, // Could add SRF logo here + ChannelType = ChannelType.TV + }); + } + + return await Task.FromResult(channels).ConfigureAwait(false); + } + + /// + public async Task> GetProgramsAsync(string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) + { + _logger.LogInformation( + "GetProgramsAsync called for channel {ChannelId} from {Start} to {End}", + channelId, + startDateUtc, + endDateUtc); + + var programs = new List(); + var virtualChannel = VirtualChannels.FirstOrDefault(c => c.Id == channelId); + + if (virtualChannel == null) + { + _logger.LogWarning("Unknown channel ID: {ChannelId}", channelId); + return programs; + } + + try + { + var config = Plugin.Instance?.Configuration; + var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "srf"; + + using var apiClient = _apiClientFactory.CreateClient(); + var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync( + businessUnit, + virtualChannel.MediaType, + cancellationToken).ConfigureAwait(false); + + if (scheduledLivestreams == null) + { + _logger.LogWarning("No scheduled livestreams returned for channel {ChannelId}", channelId); + return programs; + } + + // Filter to events within the requested date range + var eventsInRange = scheduledLivestreams + .Where(e => e.Urn != null && + !string.IsNullOrEmpty(e.Title) && + e.ValidFrom != null && + e.ValidFrom.Value.ToUniversalTime() >= startDateUtc.AddHours(-3) && // Include events that started recently + e.ValidFrom.Value.ToUniversalTime() <= endDateUtc) + .OrderBy(e => e.ValidFrom) + .ToList(); + + _logger.LogInformation( + "Found {Count} scheduled events for channel {ChannelId} in date range", + eventsInRange.Count, + channelId); + + foreach (var evt in eventsInRange) + { + if (evt.Urn == null || evt.ValidFrom == null) + { + continue; + } + + var startTime = evt.ValidFrom.Value.ToUniversalTime(); + var endTime = evt.ValidTo?.ToUniversalTime() ?? startTime.AddHours(2); // Default 2h duration + + programs.Add(new ProgramInfo + { + Id = UrnHelper.ToGuid(evt.Urn), + ChannelId = channelId, + Name = evt.Title, + Overview = evt.Description ?? evt.Lead, + StartDate = startTime, + EndDate = endTime, + ImageUrl = evt.ImageUrl, + IsLive = true, + IsPremiere = false, + IsRepeat = false, + // Store URN in external IDs for later retrieval + EpisodeTitle = evt.Urn // Hack: Store URN here for stream lookup + }); + } + + // If no events, add an "Off Air" placeholder + if (programs.Count == 0) + { + programs.Add(new ProgramInfo + { + Id = $"{channelId}-offair-{startDateUtc:yyyyMMdd}", + ChannelId = channelId, + Name = "No Scheduled Events", + Overview = "There are no scheduled livestreams at this time. Check back later for upcoming events.", + StartDate = startDateUtc, + EndDate = endDateUtc, + IsLive = false + }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching programs for channel {ChannelId}", channelId); + } + + return programs; + } + + /// + public async Task GetChannelStream(string channelId, string streamId, CancellationToken cancellationToken) + { + _logger.LogInformation( + "GetChannelStream called for channel {ChannelId}, stream {StreamId}", + channelId, + streamId ?? "(null)"); + + // Find what's currently live on this channel + var now = DateTime.UtcNow; + var programs = await GetProgramsAsync(channelId, now.AddHours(-1), now.AddHours(1), cancellationToken).ConfigureAwait(false); + + var currentProgram = programs + .Where(p => p.StartDate <= now && p.EndDate >= now && !string.IsNullOrEmpty(p.EpisodeTitle)) + .FirstOrDefault(); + + if (currentProgram == null || string.IsNullOrEmpty(currentProgram.EpisodeTitle)) + { + // Find the next upcoming program to show a helpful message + var nextProgram = programs + .Where(p => p.StartDate > now && !string.IsNullOrEmpty(p.EpisodeTitle)) + .OrderBy(p => p.StartDate) + .FirstOrDefault(); + + string message; + if (nextProgram != null) + { + var timeUntil = nextProgram.StartDate - now; + var timeStr = timeUntil.TotalHours >= 1 + ? $"{(int)timeUntil.TotalHours}h {timeUntil.Minutes}m" + : $"{timeUntil.Minutes}m"; + + message = $"No live content right now. Next: \"{nextProgram.Name}\" starts in {timeStr}"; + _logger.LogInformation( + "No live program on channel {ChannelId}. Next program: {NextProgram} at {StartTime}", + channelId, + nextProgram.Name, + nextProgram.StartDate); + } + else + { + message = "No live content available on this channel right now. Check back later for scheduled events."; + _logger.LogWarning("No live or upcoming programs found for channel {ChannelId}", channelId); + } + + throw new InvalidOperationException(message); + } + + var urn = currentProgram.EpisodeTitle; // We stored URN in EpisodeTitle + _logger.LogInformation("Found live program: {Title} (URN: {Urn})", currentProgram.Name, urn); + + // Fetch media composition and create media source + using var apiClient = _apiClientFactory.CreateClient(); + var mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false); + + if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0) + { + throw new InvalidOperationException($"Could not fetch stream for URN: {urn}"); + } + + var chapter = mediaComposition.ChapterList[0]; + var config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); + + // Use channelId as base for MediaSourceInfo.Id + // This ensures Jellyfin can find the MediaSource during transcoding/playback + // The channelId is Jellyfin's internal ID for our virtual channel + var urnGuid = UrnHelper.ToGuid(urn); + var mediaSourceId = $"{channelId}_{urnGuid}"; + + _logger.LogInformation( + "Creating MediaSource with ID: {MediaSourceId} (channelId={ChannelId}, urnGuid={UrnGuid})", + mediaSourceId, + channelId, + urnGuid); + + var mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync( + chapter, + mediaSourceId, + urn, + config.QualityPreference, + cancellationToken).ConfigureAwait(false); + + if (mediaSource == null) + { + throw new InvalidOperationException($"Could not create media source for URN: {urn}"); + } + + return mediaSource; + } + + /// + public Task> GetChannelStreamMediaSources(string channelId, CancellationToken cancellationToken) + { + _logger.LogDebug("GetChannelStreamMediaSources called for {ChannelId}", channelId); + // Return empty - Jellyfin will call GetChannelStream instead + return Task.FromResult(new List()); + } + + /// + public Task CloseLiveStream(string id, CancellationToken cancellationToken) + { + _logger.LogDebug("CloseLiveStream called for {Id}", id); + return Task.CompletedTask; + } + + /// + public Task ResetTuner(string id, CancellationToken cancellationToken) + { + _logger.LogDebug("ResetTuner called for {Id}", id); + return Task.CompletedTask; + } + + // Timer/Recording methods - Not supported (return empty/throw) + + /// + public Task> GetTimersAsync(CancellationToken cancellationToken) + { + return Task.FromResult(Enumerable.Empty()); + } + + /// + public Task GetNewTimerDefaultsAsync(CancellationToken cancellationToken, ProgramInfo? program = null) + { + return Task.FromResult(new SeriesTimerInfo()); + } + + /// + public Task> GetSeriesTimersAsync(CancellationToken cancellationToken) + { + return Task.FromResult(Enumerable.Empty()); + } + + /// + public Task CreateTimerAsync(TimerInfo info, CancellationToken cancellationToken) + { + throw new NotSupportedException("Recording is not supported by SRF Play"); + } + + /// + public Task CreateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) + { + throw new NotSupportedException("Recording is not supported by SRF Play"); + } + + /// + public Task UpdateTimerAsync(TimerInfo updatedTimer, CancellationToken cancellationToken) + { + throw new NotSupportedException("Recording is not supported by SRF Play"); + } + + /// + public Task UpdateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) + { + throw new NotSupportedException("Recording is not supported by SRF Play"); + } + + /// + public Task CancelTimerAsync(string timerId, CancellationToken cancellationToken) + { + throw new NotSupportedException("Recording is not supported by SRF Play"); + } + + /// + public Task CancelSeriesTimerAsync(string timerId, CancellationToken cancellationToken) + { + throw new NotSupportedException("Recording is not supported by SRF Play"); + } + + /// + /// Represents a virtual channel definition. + /// + private sealed class VirtualChannel + { + /// + /// Initializes a new instance of the class. + /// + /// The channel ID. + /// The channel name. + /// The media type filter for the API. + /// The channel description. + public VirtualChannel(string id, string name, string mediaType, string description) + { + Id = id; + Name = name; + MediaType = mediaType; + Description = description; + } + + /// + /// Gets the channel ID. + /// + public string Id { get; } + + /// + /// Gets the channel name. + /// + public string Name { get; } + + /// + /// Gets the media type filter for the API. + /// + public string MediaType { get; } + + /// + /// Gets the channel description. + /// + public string Description { get; } + } +} diff --git a/Jellyfin.Plugin.SRFPlay/Providers/SRFEpisodeProvider.cs b/Jellyfin.Plugin.SRFPlay/Providers/SRFEpisodeProvider.cs index 2a27611..55680e2 100644 --- a/Jellyfin.Plugin.SRFPlay/Providers/SRFEpisodeProvider.cs +++ b/Jellyfin.Plugin.SRFPlay/Providers/SRFEpisodeProvider.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -19,22 +18,18 @@ namespace Jellyfin.Plugin.SRFPlay.Providers; public class SRFEpisodeProvider : IRemoteMetadataProvider { private readonly ILogger _logger; - private readonly IHttpClientFactory _httpClientFactory; private readonly IMediaCompositionFetcher _compositionFetcher; /// /// Initializes a new instance of the class. /// - /// The logger factory. - /// The HTTP client factory. + /// The logger. /// The media composition fetcher. public SRFEpisodeProvider( - ILoggerFactory loggerFactory, - IHttpClientFactory httpClientFactory, + ILogger logger, IMediaCompositionFetcher compositionFetcher) { - _logger = loggerFactory.CreateLogger(); - _httpClientFactory = httpClientFactory; + _logger = logger; _compositionFetcher = compositionFetcher; } diff --git a/Jellyfin.Plugin.SRFPlay/Providers/SRFImageProvider.cs b/Jellyfin.Plugin.SRFPlay/Providers/SRFImageProvider.cs index c4d176b..8bbb058 100644 --- a/Jellyfin.Plugin.SRFPlay/Providers/SRFImageProvider.cs +++ b/Jellyfin.Plugin.SRFPlay/Providers/SRFImageProvider.cs @@ -5,6 +5,7 @@ using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.SRFPlay.Services.Interfaces; +using Jellyfin.Plugin.SRFPlay.Utilities; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Providers; @@ -186,7 +187,7 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder if (needsContentTypeFix) { // Determine correct content type from URL extension or default to JPEG - var contentType = GetContentTypeFromUrl(url); + var contentType = MimeTypeHelper.GetImageContentType(url); if (!string.IsNullOrEmpty(contentType)) { _logger.LogInformation( @@ -201,42 +202,4 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder return response; } - - /// - /// Determines the correct content type based on URL file extension. - /// - private static string? GetContentTypeFromUrl(string url) - { - if (string.IsNullOrEmpty(url)) - { - return null; - } - - // Get the file extension from the URL (ignore query string) - var uri = new Uri(url); - var path = uri.AbsolutePath.ToLowerInvariant(); - - if (path.EndsWith(".jpg", StringComparison.Ordinal) || path.EndsWith(".jpeg", StringComparison.Ordinal)) - { - return "image/jpeg"; - } - - if (path.EndsWith(".png", StringComparison.Ordinal)) - { - return "image/png"; - } - - if (path.EndsWith(".gif", StringComparison.Ordinal)) - { - return "image/gif"; - } - - if (path.EndsWith(".webp", StringComparison.Ordinal)) - { - return "image/webp"; - } - - // Default to JPEG for SRF images (most common) - return "image/jpeg"; - } } diff --git a/Jellyfin.Plugin.SRFPlay/Providers/SRFSeriesProvider.cs b/Jellyfin.Plugin.SRFPlay/Providers/SRFSeriesProvider.cs index e2fe0a4..86d89b1 100644 --- a/Jellyfin.Plugin.SRFPlay/Providers/SRFSeriesProvider.cs +++ b/Jellyfin.Plugin.SRFPlay/Providers/SRFSeriesProvider.cs @@ -1,13 +1,11 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; @@ -19,22 +17,18 @@ namespace Jellyfin.Plugin.SRFPlay.Providers; public class SRFSeriesProvider : IRemoteMetadataProvider { private readonly ILogger _logger; - private readonly IHttpClientFactory _httpClientFactory; private readonly IMediaCompositionFetcher _compositionFetcher; /// /// Initializes a new instance of the class. /// - /// The logger factory. - /// The HTTP client factory. + /// The logger. /// The media composition fetcher. public SRFSeriesProvider( - ILoggerFactory loggerFactory, - IHttpClientFactory httpClientFactory, + ILogger logger, IMediaCompositionFetcher compositionFetcher) { - _logger = loggerFactory.CreateLogger(); - _httpClientFactory = httpClientFactory; + _logger = logger; _compositionFetcher = compositionFetcher; } diff --git a/Jellyfin.Plugin.SRFPlay/ScheduledTasks/ExpirationCheckTask.cs b/Jellyfin.Plugin.SRFPlay/ScheduledTasks/ExpirationCheckTask.cs index 9cdb592..916c2f3 100644 --- a/Jellyfin.Plugin.SRFPlay/ScheduledTasks/ExpirationCheckTask.cs +++ b/Jellyfin.Plugin.SRFPlay/ScheduledTasks/ExpirationCheckTask.cs @@ -15,18 +15,22 @@ public class ExpirationCheckTask : IScheduledTask { private readonly ILogger _logger; private readonly IContentExpirationService _expirationService; + private readonly IStreamProxyService _streamProxyService; /// /// Initializes a new instance of the class. /// /// The logger instance. /// The content expiration service. + /// The stream proxy service. public ExpirationCheckTask( ILogger logger, - IContentExpirationService expirationService) + IContentExpirationService expirationService, + IStreamProxyService streamProxyService) { _logger = logger; _expirationService = expirationService; + _streamProxyService = streamProxyService; } /// @@ -77,6 +81,10 @@ public class ExpirationCheckTask : IScheduledTask progress?.Report(50); var removedCount = await _expirationService.CheckAndRemoveExpiredContentAsync(cancellationToken).ConfigureAwait(false); + // Clean up old stream proxy mappings + progress?.Report(75); + _streamProxyService.CleanupOldMappings(); + progress?.Report(100); _logger.LogInformation("SRF Play expiration check task completed. Removed {Count} expired items", removedCount); } diff --git a/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs b/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs index fad5557..56331fa 100644 --- a/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs +++ b/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs @@ -1,11 +1,13 @@ using Jellyfin.Plugin.SRFPlay.Api; using Jellyfin.Plugin.SRFPlay.Channels; +using Jellyfin.Plugin.SRFPlay.LiveTv; using Jellyfin.Plugin.SRFPlay.Providers; using Jellyfin.Plugin.SRFPlay.ScheduledTasks; using Jellyfin.Plugin.SRFPlay.Services; using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using MediaBrowser.Controller; using MediaBrowser.Controller.Channels; +using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Plugins; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.DependencyInjection; @@ -47,5 +49,8 @@ public class ServiceRegistrator : IPluginServiceRegistrator // Register channel - must register as IChannel interface for Jellyfin to discover it serviceCollection.AddSingleton(); + + // Register Live TV service - provides virtual channels for scheduled livestreams + serviceCollection.AddSingleton(); } } diff --git a/Jellyfin.Plugin.SRFPlay/Services/CategoryService.cs b/Jellyfin.Plugin.SRFPlay/Services/CategoryService.cs index 85575bd..81c8c2a 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/CategoryService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/CategoryService.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -16,8 +15,8 @@ namespace Jellyfin.Plugin.SRFPlay.Services; /// public class CategoryService : ICategoryService { - private readonly ILogger _logger; - private readonly ILoggerFactory _loggerFactory; + private readonly ILogger _logger; + private readonly ISRFApiClientFactory _apiClientFactory; private readonly TimeSpan _topicsCacheDuration = TimeSpan.FromHours(24); private Dictionary? _topicsCache; private DateTime _topicsCacheExpiry = DateTime.MinValue; @@ -25,19 +24,15 @@ public class CategoryService : ICategoryService /// /// Initializes a new instance of the class. /// - /// The logger factory. - public CategoryService(ILoggerFactory loggerFactory) + /// The logger. + /// The API client factory. + public CategoryService(ILogger logger, ISRFApiClientFactory apiClientFactory) { - _loggerFactory = loggerFactory; - _logger = loggerFactory.CreateLogger(); + _logger = logger; + _apiClientFactory = apiClientFactory; } - /// - /// Gets all topics for a business unit. - /// - /// The business unit. - /// The cancellation token. - /// List of topics. + /// public async Task> GetTopicsAsync(string businessUnit, CancellationToken cancellationToken = default) { // Return cached topics if still valid @@ -48,7 +43,7 @@ public class CategoryService : ICategoryService } _logger.LogInformation("Fetching topics for business unit: {BusinessUnit}", businessUnit); - using var apiClient = new SRFApiClient(_loggerFactory); + using var apiClient = _apiClientFactory.CreateClient(); var topics = await apiClient.GetAllTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false); if (topics != null && topics.Count > 0) @@ -65,13 +60,7 @@ public class CategoryService : ICategoryService return topics ?? new List(); } - /// - /// Gets a topic by ID. - /// - /// The topic ID. - /// The business unit. - /// The cancellation token. - /// The topic, or null if not found. + /// public async Task GetTopicByIdAsync(string topicId, string businessUnit, CancellationToken cancellationToken = default) { // Ensure topics are loaded @@ -83,70 +72,14 @@ public class CategoryService : ICategoryService return _topicsCache?.GetValueOrDefault(topicId); } - /// - /// Filters shows by topic ID. - /// - /// The shows to filter. - /// The topic ID to filter by. - /// Filtered list of shows. - public IReadOnlyList FilterShowsByTopic(IReadOnlyList shows, string topicId) - { - if (string.IsNullOrEmpty(topicId)) - { - return shows; - } - - return shows - .Where(s => s.TopicList != null && s.TopicList.Contains(topicId)) - .ToList(); - } - - /// - /// Groups shows by their topics. - /// - /// The shows to group. - /// Dictionary mapping topic IDs to shows. - public IReadOnlyDictionary> GroupShowsByTopics(IReadOnlyList shows) - { - var groupedShows = new Dictionary>(); - - foreach (var show in shows) - { - if (show.TopicList == null || show.TopicList.Count == 0) - { - continue; - } - - foreach (var topicId in show.TopicList) - { - if (!groupedShows.TryGetValue(topicId, out var showList)) - { - showList = new List(); - groupedShows[topicId] = showList; - } - - showList.Add(show); - } - } - - return groupedShows; - } - - /// - /// Gets shows for a specific topic, sorted by number of episodes. - /// - /// The topic ID. - /// The business unit. - /// Maximum number of results to return. - /// The cancellation token. - /// List of shows for the topic. + /// public async Task> GetShowsByTopicAsync( string topicId, string businessUnit, int maxResults = 50, CancellationToken cancellationToken = default) { - using var apiClient = new SRFApiClient(_loggerFactory); + using var apiClient = _apiClientFactory.CreateClient(); var allShows = await apiClient.GetAllShowsAsync(businessUnit, cancellationToken).ConfigureAwait(false); if (allShows == null || allShows.Count == 0) @@ -165,43 +98,29 @@ public class CategoryService : ICategoryService return filteredShows; } - /// - /// Gets video count for each topic. - /// - /// The shows to analyze. - /// Dictionary mapping topic IDs to video counts. - public IReadOnlyDictionary GetVideoCountByTopic(IReadOnlyList shows) - { - var topicCounts = new Dictionary(); - - foreach (var show in shows) - { - if (show.TopicList == null || show.TopicList.Count == 0) - { - continue; - } - - foreach (var topicId in show.TopicList) - { - if (!topicCounts.TryGetValue(topicId, out var count)) - { - count = 0; - } - - topicCounts[topicId] = count + show.NumberOfEpisodes; - } - } - - return topicCounts; - } - - /// - /// Clears the topics cache. - /// + /// public void ClearCache() { _topicsCache = null; _topicsCacheExpiry = DateTime.MinValue; _logger.LogInformation("Topics cache cleared"); } + + /// + /// Filters shows by topic ID. + /// + /// The shows to filter. + /// The topic ID to filter by. + /// Filtered list of shows. + private static IReadOnlyList FilterShowsByTopic(IReadOnlyList shows, string topicId) + { + if (string.IsNullOrEmpty(topicId)) + { + return shows; + } + + return shows + .Where(s => s.TopicList != null && s.TopicList.Contains(topicId)) + .ToList(); + } } diff --git a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/ICategoryService.cs b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/ICategoryService.cs index c8780a5..92014c0 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/ICategoryService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/ICategoryService.cs @@ -27,21 +27,6 @@ public interface ICategoryService /// The topic, or null if not found. Task GetTopicByIdAsync(string topicId, string businessUnit, CancellationToken cancellationToken = default); - /// - /// Filters shows by topic ID. - /// - /// The shows to filter. - /// The topic ID to filter by. - /// Filtered list of shows. - IReadOnlyList FilterShowsByTopic(IReadOnlyList shows, string topicId); - - /// - /// Groups shows by their topics. - /// - /// The shows to group. - /// Dictionary mapping topic IDs to shows. - IReadOnlyDictionary> GroupShowsByTopics(IReadOnlyList shows); - /// /// Gets shows for a specific topic, sorted by number of episodes. /// @@ -56,13 +41,6 @@ public interface ICategoryService int maxResults = 50, CancellationToken cancellationToken = default); - /// - /// Gets video count for each topic. - /// - /// The shows to analyze. - /// Dictionary mapping topic IDs to video counts. - IReadOnlyDictionary GetVideoCountByTopic(IReadOnlyList shows); - /// /// Clears the topics cache. /// diff --git a/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs b/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs index 789c579..1f3e2ce 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs @@ -73,7 +73,7 @@ public class StreamProxyService : IStreamProxyService if (tokenExpiry.HasValue) { - _logger.LogInformation( + _logger.LogDebug( "Registered stream for item {ItemId} (token expires at {ExpiresAt} UTC): {Url}", itemId, tokenExpiry.Value, @@ -81,7 +81,7 @@ public class StreamProxyService : IStreamProxyService } else { - _logger.LogInformation("Registered stream for item {ItemId}: {Url}", itemId, authenticatedUrl); + _logger.LogDebug("Registered stream for item {ItemId}: {Url}", itemId, authenticatedUrl); } } @@ -219,6 +219,50 @@ public class StreamProxyService : IStreamProxyService } } + // Fallback: Live TV channelId_guid format lookup + // For Live TV streams, itemId format is "channelId_urnGuid" + // Try matching by suffix (urnGuid part) or prefix (channelId part) + if (itemId.Contains('_', StringComparison.Ordinal)) + { + var parts = itemId.Split('_', 2); + var prefix = parts[0]; // channelId part + var suffix = parts.Length > 1 ? parts[1] : null; // urnGuid part + + foreach (var kvp in _streamMappings) + { + // Check if registered key contains the same suffix (urnGuid) + if (suffix != null && kvp.Key.Contains(suffix, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation( + "Found stream by Live TV suffix match - Requested: {RequestedId}, Registered: {RegisteredId}", + itemId, + kvp.Key); + var url = await ValidateAndReturnStreamAsync(kvp.Key, kvp.Value, cancellationToken).ConfigureAwait(false); + if (url != null) + { + // Also register with the requested itemId for future lookups + _streamMappings.AddOrUpdate(itemId, kvp.Value, (key, old) => kvp.Value); + return url; + } + } + + // Check if registered key starts with the same prefix (channelId) + if (kvp.Key.StartsWith(prefix + "_", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation( + "Found stream by Live TV prefix match - Requested: {RequestedId}, Registered: {RegisteredId}", + itemId, + kvp.Key); + var url = await ValidateAndReturnStreamAsync(kvp.Key, kvp.Value, cancellationToken).ConfigureAwait(false); + if (url != null) + { + _streamMappings.AddOrUpdate(itemId, kvp.Value, (key, old) => kvp.Value); + return url; + } + } + } + } + // Last resort: Use active stream fallbacks (helps when Jellyfin creates random transcoding session IDs) var activeStreams = _streamMappings.Where(kvp => { diff --git a/Jellyfin.Plugin.SRFPlay/Services/StreamUrlResolver.cs b/Jellyfin.Plugin.SRFPlay/Services/StreamUrlResolver.cs index 8d56d6b..470e040 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/StreamUrlResolver.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/StreamUrlResolver.cs @@ -142,8 +142,8 @@ public class StreamUrlResolver : IStreamUrlResolver if (selectedResource != null) { - _logger.LogInformation( - "✅ Selected stream for chapter {ChapterId}: Quality={Quality}, Protocol={Protocol}, URL={Url}", + _logger.LogDebug( + "Selected stream for chapter {ChapterId}: Quality={Quality}, Protocol={Protocol}, URL={Url}", chapter.Id, selectedResource.Quality ?? "NULL", selectedResource.Protocol, diff --git a/Jellyfin.Plugin.SRFPlay/Utilities/Extensions.cs b/Jellyfin.Plugin.SRFPlay/Utilities/Extensions.cs new file mode 100644 index 0000000..c400649 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Utilities/Extensions.cs @@ -0,0 +1,41 @@ +using Jellyfin.Plugin.SRFPlay.Configuration; +using MediaBrowser.Controller.Entities; + +namespace Jellyfin.Plugin.SRFPlay.Utilities; + +/// +/// Extension methods for common plugin operations. +/// +public static class Extensions +{ + /// + /// Gets the SRF URN from the item's provider IDs. + /// + /// The base item. + /// The SRF URN, or null if not found or empty. + public static string? GetSrfUrn(this BaseItem item) + { + return item.ProviderIds.TryGetValue("SRF", out var urn) && !string.IsNullOrEmpty(urn) + ? urn + : null; + } + + /// + /// Converts the BusinessUnit enum to its lowercase string representation. + /// + /// The business unit. + /// The lowercase string representation. + public static string ToLowerString(this BusinessUnit businessUnit) + { + return businessUnit.ToString().ToLowerInvariant(); + } + + /// + /// Gets the plugin configuration safely, returning null if not available. + /// + /// The plugin configuration, or null if not available. + public static PluginConfiguration? GetPluginConfig() + { + return Plugin.Instance?.Configuration; + } +} diff --git a/Jellyfin.Plugin.SRFPlay/Utilities/MimeTypeHelper.cs b/Jellyfin.Plugin.SRFPlay/Utilities/MimeTypeHelper.cs new file mode 100644 index 0000000..d5cd828 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Utilities/MimeTypeHelper.cs @@ -0,0 +1,74 @@ +using System; + +namespace Jellyfin.Plugin.SRFPlay.Utilities; + +/// +/// Helper class for determining MIME content types. +/// +public static class MimeTypeHelper +{ + /// + /// Gets the MIME content type for an image based on URL or file extension. + /// + /// The image URL. + /// The MIME content type, defaulting to "image/jpeg" for SRF images. + public static string GetImageContentType(string? url) + { + if (string.IsNullOrEmpty(url)) + { + return "image/jpeg"; + } + + var uri = new Uri(url); + var path = uri.AbsolutePath.ToLowerInvariant(); + + if (path.EndsWith(".png", StringComparison.Ordinal)) + { + return "image/png"; + } + + if (path.EndsWith(".gif", StringComparison.Ordinal)) + { + return "image/gif"; + } + + if (path.EndsWith(".webp", StringComparison.Ordinal)) + { + return "image/webp"; + } + + if (path.EndsWith(".jpg", StringComparison.Ordinal) || path.EndsWith(".jpeg", StringComparison.Ordinal)) + { + return "image/jpeg"; + } + + // Default to JPEG for SRF images (most common) + return "image/jpeg"; + } + + /// + /// Gets the MIME content type for a media segment based on file extension. + /// + /// The segment path or filename. + /// The MIME content type. + public static string GetSegmentContentType(string path) + { + if (path.EndsWith(".ts", StringComparison.OrdinalIgnoreCase)) + { + return "video/MP2T"; + } + + if (path.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".m4s", StringComparison.OrdinalIgnoreCase)) + { + return "video/mp4"; + } + + if (path.EndsWith(".aac", StringComparison.OrdinalIgnoreCase)) + { + return "audio/aac"; + } + + return "application/octet-stream"; + } +} diff --git a/Jellyfin.Plugin.SRFPlay/Utilities/UrnHelper.cs b/Jellyfin.Plugin.SRFPlay/Utilities/UrnHelper.cs new file mode 100644 index 0000000..44d6728 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Utilities/UrnHelper.cs @@ -0,0 +1,27 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace Jellyfin.Plugin.SRFPlay.Utilities; + +/// +/// Helper class for URN-related operations. +/// +public static class UrnHelper +{ + /// + /// Generates a deterministic GUID from a URN. + /// This ensures the same URN always produces the same GUID. + /// MD5 is used for non-cryptographic purposes only (generating stable IDs). + /// + /// The URN to convert. + /// A deterministic GUID string. +#pragma warning disable CA5351 // MD5 is used for non-cryptographic purposes (ID generation) + public static string ToGuid(string urn) + { + var hash = MD5.HashData(Encoding.UTF8.GetBytes(urn)); + var guid = new Guid(hash); + return guid.ToString(); + } +#pragma warning restore CA5351 +}