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}", query.FolderId);
try
{
var items = await GetFolderItemsAsync(query.FolderId, 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, 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, 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, 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;
}
// Sort episodes by published date, newest first
var episodes = podcast.Episodes
.OrderByDescending(e => e.PublishedDate)
.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
};
}
}