First POC with podcasts library
This commit is contained in:
@@ -0,0 +1,472 @@
|
||||
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}", 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<ChannelItemInfo>(), TotalRecordCount = 0 };
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<ChannelItemInfo>> 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<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, 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;
|
||||
}
|
||||
|
||||
// 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<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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user