using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.Jellypod.Models; using Jellyfin.Plugin.Jellypod.Services; using MediaBrowser.Controller; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Channels; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.Jellypod.Channels; /// /// Jellypod channel for browsing and playing podcast episodes. /// public class JellypodChannel : IChannel, IHasCacheKey, IRequiresMediaInfoCallback { private readonly ILogger _logger; private readonly IPodcastStorageService _storageService; private readonly IRssFeedService _rssFeedService; private readonly IPodcastDownloadService _downloadService; private readonly IServerApplicationHost _appHost; /// /// Initializes a new instance of the class. /// /// The logger factory. /// The podcast storage service. /// The RSS feed service. /// The download service. /// The server application host. public JellypodChannel( ILoggerFactory loggerFactory, IPodcastStorageService storageService, IRssFeedService rssFeedService, IPodcastDownloadService downloadService, IServerApplicationHost appHost) { _logger = loggerFactory.CreateLogger(); _storageService = storageService; _rssFeedService = rssFeedService; _downloadService = downloadService; _appHost = appHost; _logger.LogDebug("JellypodChannel initialized"); } /// public string Name => "Podcasts"; /// public string Description => "Browse and listen to your podcast subscriptions"; /// public string DataVersion => "1.0"; /// public string HomePageUrl => string.Empty; /// public ChannelParentalRating ParentalRating => ChannelParentalRating.GeneralAudience; /// public InternalChannelFeatures GetChannelFeatures() { return new InternalChannelFeatures { ContentTypes = new List { ChannelMediaContentType.Podcast }, MediaTypes = new List { ChannelMediaType.Audio }, SupportsSortOrderToggle = true, DefaultSortFields = new List { ChannelItemSortField.PremiereDate, ChannelItemSortField.DateCreated, ChannelItemSortField.Name }, AutoRefreshLevels = 1, MaxPageSize = 100 }; } /// public Task GetChannelImage(ImageType type, CancellationToken cancellationToken) { _logger.LogInformation("GetChannelImage called with type: {Type}", type); var assembly = GetType().Assembly; var resourceName = "Jellyfin.Plugin.Jellypod.Images.channel-icon.jpg"; // Log available resources for debugging var resourceNames = assembly.GetManifestResourceNames(); _logger.LogInformation("Available embedded resources: {Resources}", string.Join(", ", resourceNames)); var stream = assembly.GetManifestResourceStream(resourceName); if (stream != null) { _logger.LogInformation("Found channel icon, stream length: {Length}", stream.Length); return Task.FromResult(new DynamicImageResponse { HasImage = true, Stream = stream, Format = MediaBrowser.Model.Drawing.ImageFormat.Jpg }); } _logger.LogWarning("Channel icon resource not found: {ResourceName}", resourceName); return Task.FromResult(new DynamicImageResponse { HasImage = false }); } /// public IEnumerable GetSupportedChannelImages() { return new List { ImageType.Primary, ImageType.Thumb }; } /// public async Task GetChannelItems(InternalChannelItemQuery query, CancellationToken cancellationToken) { _logger.LogDebug("GetChannelItems called for folder {FolderId}, SortBy: {SortBy}, SortDescending: {SortDescending}", query.FolderId, query.SortBy, query.SortDescending); try { var items = await GetFolderItemsAsync(query.FolderId, query.SortBy, query.SortDescending, cancellationToken).ConfigureAwait(false); _logger.LogDebug("Returning {Count} channel items for folder {FolderId}", items.Count, query.FolderId); return new ChannelItemResult { Items = items, TotalRecordCount = items.Count }; } catch (Exception ex) { _logger.LogError(ex, "Error getting channel items for folder {FolderId}", query.FolderId); return new ChannelItemResult { Items = new List(), TotalRecordCount = 0 }; } } private async Task> GetFolderItemsAsync(string? folderId, ChannelItemSortField? sortBy, bool? sortDescending, CancellationToken cancellationToken) { // Root level - show all subscribed podcasts as folders if (string.IsNullOrEmpty(folderId)) { return await GetPodcastFoldersAsync(cancellationToken).ConfigureAwait(false); } // Podcast folder - show episodes if (Guid.TryParse(folderId, out var podcastId)) { return await GetPodcastEpisodesAsync(podcastId, sortBy, sortDescending, cancellationToken).ConfigureAwait(false); } return new List(); } private async Task> GetPodcastFoldersAsync(CancellationToken cancellationToken) { var items = new List(); var podcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false); foreach (var podcast in podcasts) { var episodeCount = podcast.Episodes.Count; var downloadedCount = podcast.Episodes.Count(e => e.Status == EpisodeStatus.Downloaded); items.Add(new ChannelItemInfo { Id = podcast.Id.ToString("N"), Name = podcast.Title, Overview = podcast.Description, ImageUrl = podcast.ImageUrl, Type = ChannelItemType.Folder, FolderType = ChannelFolderType.Container, DateCreated = podcast.DateAdded, DateModified = podcast.LastUpdated }); _logger.LogDebug("Added podcast folder: {Title} ({EpisodeCount} episodes, {DownloadedCount} downloaded)", podcast.Title, episodeCount, downloadedCount); } _logger.LogInformation("Returning {Count} podcast folders", items.Count); return items; } private async Task> GetPodcastEpisodesAsync(Guid podcastId, ChannelItemSortField? sortBy, bool? sortDescending, CancellationToken cancellationToken) { var items = new List(); var podcast = await _storageService.GetPodcastAsync(podcastId).ConfigureAwait(false); if (podcast == null) { _logger.LogWarning("Podcast {PodcastId} not found", podcastId); return items; } // Default to sorting by premiere date descending (newest first) var sortField = sortBy ?? ChannelItemSortField.PremiereDate; var descending = sortDescending ?? true; _logger.LogDebug("Sorting episodes by {SortField}, descending: {Descending}", sortField, descending); // Sort episodes based on query parameters IEnumerable sortedEpisodes = sortField switch { ChannelItemSortField.Name => descending ? podcast.Episodes.OrderByDescending(e => e.Title) : podcast.Episodes.OrderBy(e => e.Title), ChannelItemSortField.DateCreated or ChannelItemSortField.PremiereDate => descending ? podcast.Episodes.OrderByDescending(e => e.PublishedDate) : podcast.Episodes.OrderBy(e => e.PublishedDate), _ => podcast.Episodes.OrderByDescending(e => e.PublishedDate) }; var episodes = sortedEpisodes.ToList(); foreach (var episode in episodes) { // Don't provide MediaSources here - this forces Jellyfin to call GetChannelItemMediaInfo // which allows us to download-on-demand and return proper local file paths items.Add(new ChannelItemInfo { Id = episode.Id.ToString("N"), Name = episode.Title, Overview = episode.Description, ImageUrl = episode.ImageUrl ?? podcast.ImageUrl, Type = ChannelItemType.Media, ContentType = ChannelMediaContentType.Podcast, MediaType = ChannelMediaType.Audio, DateCreated = episode.PublishedDate, PremiereDate = episode.PublishedDate, RunTimeTicks = episode.Duration?.Ticks, SeriesName = podcast.Title // MediaSources intentionally omitted - see GetChannelItemMediaInfo }); } _logger.LogInformation("Returning {Count} episodes for podcast {PodcastTitle}", items.Count, podcast.Title); return items; } private MediaSourceInfo CreateMediaSource(Podcast podcast, Episode episode) { // If episode is downloaded, use local file; otherwise proxy through our endpoint var isLocal = episode.Status == EpisodeStatus.Downloaded && !string.IsNullOrEmpty(episode.LocalFilePath); string path; MediaProtocol protocol; if (isLocal) { path = episode.LocalFilePath!; protocol = MediaProtocol.File; } else { // Use proxy URL to avoid ffprobe issues with remote URLs var encodedUrl = Convert.ToBase64String(Encoding.UTF8.GetBytes(episode.AudioUrl)); var serverUrl = _appHost.GetSmartApiUrl(string.Empty); path = $"{serverUrl}/Jellypod/Stream/{encodedUrl}"; protocol = MediaProtocol.Http; } var container = GetContainerFromUrl(episode.AudioUrl); var codec = GetCodecFromContainer(container); // Create audio stream info - required for Jellyfin to play without probing var audioStream = new MediaStream { Type = MediaStreamType.Audio, Index = 0, Codec = codec, Channels = 2, SampleRate = 44100, BitRate = 128000, IsDefault = true, Language = "und" }; return new MediaSourceInfo { Id = episode.Id.ToString("N"), Name = episode.Title, Path = path, Protocol = protocol, Container = container, Type = MediaSourceType.Default, IsRemote = !isLocal, SupportsDirectPlay = true, SupportsDirectStream = true, SupportsTranscoding = false, SupportsProbing = false, RequiresOpening = false, RequiresClosing = false, RunTimeTicks = episode.Duration?.Ticks, Size = episode.FileSizeBytes, Bitrate = 128000, MediaStreams = new List { audioStream }, DefaultAudioStreamIndex = 0, ReadAtNativeFramerate = false, AnalyzeDurationMs = 0, IsInfiniteStream = false }; } private static string GetCodecFromContainer(string container) { return container switch { "mp3" => "mp3", "m4a" => "aac", "ogg" => "vorbis", "opus" => "opus", _ => "mp3" }; } private static string GetContainerFromUrl(string url) { try { var uri = new Uri(url); var path = uri.AbsolutePath.ToLowerInvariant(); if (path.EndsWith(".mp3", StringComparison.Ordinal)) { return "mp3"; } if (path.EndsWith(".m4a", StringComparison.Ordinal)) { return "m4a"; } if (path.EndsWith(".ogg", StringComparison.Ordinal)) { return "ogg"; } if (path.EndsWith(".opus", StringComparison.Ordinal)) { return "opus"; } } catch { // Ignore URL parsing errors } return "mp3"; // Default to mp3 } /// public string? GetCacheKey(string? userId) { // Use 5-minute time buckets for cache key var now = DateTime.Now; var timeBucket = new DateTime(now.Year, now.Month, now.Day, now.Hour, (now.Minute / 5) * 5, 0); return timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture); } /// public bool IsEnabledFor(string userId) { return true; } /// public async Task> GetChannelItemMediaInfo(string id, CancellationToken cancellationToken) { _logger.LogDebug("GetChannelItemMediaInfo called for id: {Id}", id); // Parse the episode ID if (!Guid.TryParse(id, out var episodeId)) { _logger.LogWarning("Invalid episode ID format: {Id}", id); return Enumerable.Empty(); } // Find the episode across all podcasts var podcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false); foreach (var podcast in podcasts) { var episode = podcast.Episodes.FirstOrDefault(e => e.Id == episodeId); if (episode != null) { var currentPodcast = podcast; var currentEpisode = episode; // Download episode if not already downloaded if (currentEpisode.Status != EpisodeStatus.Downloaded || string.IsNullOrEmpty(currentEpisode.LocalFilePath)) { _logger.LogInformation("Downloading episode on demand: {Title}", currentEpisode.Title); try { await _downloadService.DownloadEpisodeAsync(currentPodcast, currentEpisode, null, cancellationToken).ConfigureAwait(false); // Reload the episode to get updated status var reloadedPodcast = await _storageService.GetPodcastAsync(currentPodcast.Id).ConfigureAwait(false); var reloadedEpisode = reloadedPodcast?.Episodes.FirstOrDefault(e => e.Id == episodeId); if (reloadedEpisode == null) { _logger.LogError("Episode disappeared after download: {Id}", id); return Enumerable.Empty(); } currentEpisode = reloadedEpisode; } catch (Exception ex) { _logger.LogError(ex, "Failed to download episode: {Title}", currentEpisode.Title); return Enumerable.Empty(); } } var mediaSource = CreateMediaSourceForLocalFile(currentEpisode); _logger.LogDebug("Returning media source for episode: {Title}, Path: {Path}", currentEpisode.Title, mediaSource.Path); return new[] { mediaSource }; } } _logger.LogWarning("Episode not found: {Id}", id); return Enumerable.Empty(); } private MediaSourceInfo CreateMediaSourceForLocalFile(Episode episode) { // This is for downloaded local files var container = GetContainerFromUrl(episode.AudioUrl); var codec = GetCodecFromContainer(container); // Jellyfin requires MediaStreams with audio info for StreamBuilder.GetOptimalAudioStream var audioStream = new MediaStream { Type = MediaStreamType.Audio, Index = 0, Codec = codec, Channels = 2, SampleRate = 44100, BitRate = 128000, IsDefault = true, Language = "und" }; return new MediaSourceInfo { Id = episode.Id.ToString("N"), Name = episode.Title, Path = episode.LocalFilePath!, Protocol = MediaProtocol.File, Container = container, Type = MediaSourceType.Default, IsRemote = false, SupportsDirectPlay = true, SupportsDirectStream = true, SupportsTranscoding = true, SupportsProbing = false, // Don't probe - we provide stream info RequiresOpening = false, RequiresClosing = false, RunTimeTicks = episode.Duration?.Ticks, Size = episode.FileSizeBytes, Bitrate = 128000, MediaStreams = new List { audioStream }, DefaultAudioStreamIndex = 0, ReadAtNativeFramerate = false }; } }