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; } } }