502 lines
18 KiB
C#
502 lines
18 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Jellypod channel for browsing and playing podcast episodes.
|
|
/// </summary>
|
|
public class JellypodChannel : IChannel, IHasCacheKey, IRequiresMediaInfoCallback
|
|
{
|
|
private readonly ILogger<JellypodChannel> _logger;
|
|
private readonly IPodcastStorageService _storageService;
|
|
private readonly IRssFeedService _rssFeedService;
|
|
private readonly IPodcastDownloadService _downloadService;
|
|
private readonly IServerApplicationHost _appHost;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="JellypodChannel"/> class.
|
|
/// </summary>
|
|
/// <param name="loggerFactory">The logger factory.</param>
|
|
/// <param name="storageService">The podcast storage service.</param>
|
|
/// <param name="rssFeedService">The RSS feed service.</param>
|
|
/// <param name="downloadService">The download service.</param>
|
|
/// <param name="appHost">The server application host.</param>
|
|
public JellypodChannel(
|
|
ILoggerFactory loggerFactory,
|
|
IPodcastStorageService storageService,
|
|
IRssFeedService rssFeedService,
|
|
IPodcastDownloadService downloadService,
|
|
IServerApplicationHost appHost)
|
|
{
|
|
_logger = loggerFactory.CreateLogger<JellypodChannel>();
|
|
_storageService = storageService;
|
|
_rssFeedService = rssFeedService;
|
|
_downloadService = downloadService;
|
|
_appHost = appHost;
|
|
_logger.LogDebug("JellypodChannel initialized");
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public string Name => "Podcasts";
|
|
|
|
/// <inheritdoc />
|
|
public string Description => "Browse and listen to your podcast subscriptions";
|
|
|
|
/// <inheritdoc />
|
|
public string DataVersion => "1.0";
|
|
|
|
/// <inheritdoc />
|
|
public string HomePageUrl => string.Empty;
|
|
|
|
/// <inheritdoc />
|
|
public ChannelParentalRating ParentalRating => ChannelParentalRating.GeneralAudience;
|
|
|
|
/// <inheritdoc />
|
|
public InternalChannelFeatures GetChannelFeatures()
|
|
{
|
|
return new InternalChannelFeatures
|
|
{
|
|
ContentTypes = new List<ChannelMediaContentType>
|
|
{
|
|
ChannelMediaContentType.Podcast
|
|
},
|
|
MediaTypes = new List<ChannelMediaType>
|
|
{
|
|
ChannelMediaType.Audio
|
|
},
|
|
SupportsSortOrderToggle = true,
|
|
DefaultSortFields = new List<ChannelItemSortField>
|
|
{
|
|
ChannelItemSortField.PremiereDate,
|
|
ChannelItemSortField.DateCreated,
|
|
ChannelItemSortField.Name
|
|
},
|
|
AutoRefreshLevels = 1,
|
|
MaxPageSize = 100
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<DynamicImageResponse> 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
|
|
});
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public IEnumerable<ImageType> GetSupportedChannelImages()
|
|
{
|
|
return new List<ImageType>
|
|
{
|
|
ImageType.Primary,
|
|
ImageType.Thumb
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<ChannelItemResult> 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<ChannelItemInfo>(), TotalRecordCount = 0 };
|
|
}
|
|
}
|
|
|
|
private async Task<List<ChannelItemInfo>> 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<ChannelItemInfo>();
|
|
}
|
|
|
|
private async Task<List<ChannelItemInfo>> GetPodcastFoldersAsync(CancellationToken cancellationToken)
|
|
{
|
|
var items = new List<ChannelItemInfo>();
|
|
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<List<ChannelItemInfo>> GetPodcastEpisodesAsync(Guid podcastId, ChannelItemSortField? sortBy, bool? sortDescending, CancellationToken cancellationToken)
|
|
{
|
|
var items = new List<ChannelItemInfo>();
|
|
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<Episode> 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)
|
|
{
|
|
// Build episode name with played indicator
|
|
var episodeName = episode.IsPlayed
|
|
? $"[Played] {episode.Title}"
|
|
: episode.Title;
|
|
|
|
// Build overview with progress info if partially played
|
|
var overview = episode.Description ?? string.Empty;
|
|
if (episode.PlaybackPositionTicks > 0 && !episode.IsPlayed && episode.Duration.HasValue)
|
|
{
|
|
var progressPercent = (int)((double)episode.PlaybackPositionTicks / episode.Duration.Value.Ticks * 100);
|
|
var positionTime = TimeSpan.FromTicks(episode.PlaybackPositionTicks);
|
|
overview = $"[{progressPercent}% - {positionTime:hh\\:mm\\:ss}] {overview}";
|
|
}
|
|
|
|
// 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 = episodeName,
|
|
Overview = overview,
|
|
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<MediaStream> { 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
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public bool IsEnabledFor(string userId)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IEnumerable<MediaSourceInfo>> 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<MediaSourceInfo>();
|
|
}
|
|
|
|
// 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<MediaSourceInfo>();
|
|
}
|
|
|
|
currentEpisode = reloadedEpisode;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to download episode: {Title}", currentEpisode.Title);
|
|
return Enumerable.Empty<MediaSourceInfo>();
|
|
}
|
|
}
|
|
|
|
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<MediaSourceInfo>();
|
|
}
|
|
|
|
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<MediaStream> { audioStream },
|
|
DefaultAudioStreamIndex = 0,
|
|
ReadAtNativeFramerate = false
|
|
};
|
|
}
|
|
}
|