From b751f1054bc6b94745702986be2e1f30a81c3f83 Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Sun, 19 Feb 2023 10:37:48 -0600 Subject: [PATCH] direct stream content from jellyfin if needed (#1167) * redirect to jellyfin stream as needed * get jellyfin playback info * sync chapters from jellyfin * update changelog * cleanup --- CHANGELOG.md | 4 + ...layoutItemProcessByChannelNumberHandler.cs | 54 ++++-- .../FFmpegPlaybackSettingsCalculator.cs | 2 +- .../Interfaces/Jellyfin/IJellyfinApiClient.cs | 6 + .../IMediaServerMovieRepository.cs | 2 +- .../Jellyfin/JellyfinMediaStreamType.cs | 8 + ErsatzTV.FFmpeg/MediaStream.cs | 36 +++- .../Data/Repositories/EmbyMovieRepository.cs | 7 +- .../Repositories/JellyfinMovieRepository.cs | 8 +- .../JellyfinTelevisionRepository.cs | 2 + .../Data/Repositories/PlexMovieRepository.cs | 7 +- .../Jellyfin/IJellyfinApi.cs | 12 +- .../Jellyfin/JellyfinApiClient.cs | 180 +++++++++++++++++- .../Models/JellyfinChapterResponse.cs | 7 + .../Models/JellyfinLibraryItemResponse.cs | 1 + .../Models/JellyfinMediaSourceResponse.cs | 7 + .../Models/JellyfinMediaStreamResponse.cs | 8 + .../Models/JellyfinPlaybackInfoResponse.cs | 6 + .../Jellyfin/JellyfinMovieLibraryScanner.cs | 33 ++++ .../JellyfinTelevisionLibraryScanner.cs | 33 ++++ .../Core/Metadata/LocalStatisticsProvider.cs | 11 +- .../MediaServerMovieLibraryScanner.cs | 6 +- .../MediaServerTelevisionLibraryScanner.cs | 2 +- ErsatzTV/Controllers/InternalController.cs | 32 +++- 24 files changed, 419 insertions(+), 55 deletions(-) create mode 100644 ErsatzTV.Core/Jellyfin/JellyfinMediaStreamType.cs create mode 100644 ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinChapterResponse.cs create mode 100644 ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinMediaSourceResponse.cs create mode 100644 ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinPlaybackInfoResponse.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d4627fe2..15c702f04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Plex libraries now direct stream content from Plex when files are not found on ErsatzTV's file system - Content will still be normalized according to the Channel and FFmpeg Profile settings - Streaming from disk is preferred, so every playback attempt will first check the local file system +- Jellyfin libraries will retrieve all metadata and statistics from Jellyfin when local files are unavailable +- Jellyfin libraries now direct stream content from Jellyfin when files are not found on ErsatzTV's file system + - Content will still be normalized according to the Channel and FFmpeg Profile settings + - Streaming from disk is preferred, so every playback attempt will first check the local file system ## [0.7.4-beta] - 2023-02-12 ### Added diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs index 6fb6a0957..9e5dd2a85 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs @@ -453,34 +453,48 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< { string path = await GetPlayoutItemPath(playoutItem); + // check filesystem first if (_localFileSystem.FileExists(path)) { return new PlayoutItemWithPath(playoutItem, path); } - if (playoutItem.MediaItem.State == MediaItemState.RemoteOnly) + // attempt to remotely stream plex + MediaFile file = playoutItem.MediaItem.GetHeadVersion().MediaFiles.Head(); + switch (file) { - MediaFile file = playoutItem.MediaItem.GetHeadVersion().MediaFiles.Head(); - switch (file) - { - case PlexMediaFile pmf: - Option maybeId = await dbContext.Connection.QuerySingleOrDefaultAsync( - @"SELECT PMS.Id FROM PlexMediaSource PMS - INNER JOIN Library L on PMS.Id = L.MediaSourceId - INNER JOIN LibraryPath LP on L.Id = LP.LibraryId - WHERE LP.Id = @LibraryPathId", - new { playoutItem.MediaItem.LibraryPathId }) - .Map(Optional); + case PlexMediaFile pmf: + Option maybeId = await dbContext.Connection.QuerySingleOrDefaultAsync( + @"SELECT PMS.Id FROM PlexMediaSource PMS + INNER JOIN Library L on PMS.Id = L.MediaSourceId + INNER JOIN LibraryPath LP on L.Id = LP.LibraryId + WHERE LP.Id = @LibraryPathId", + new { playoutItem.MediaItem.LibraryPathId }) + .Map(Optional); - foreach (int plexMediaSourceId in maybeId) - { - return new PlayoutItemWithPath( - playoutItem, - $"http://localhost:{Settings.ListenPort}/media/plex/{plexMediaSourceId}/{pmf.Key}"); - } + foreach (int plexMediaSourceId in maybeId) + { + return new PlayoutItemWithPath( + playoutItem, + $"http://localhost:{Settings.ListenPort}/media/plex/{plexMediaSourceId}/{pmf.Key}"); + } - break; - } + break; + } + + // attempt to remotely stream jellyfin + Option jellyfinItemId = playoutItem.MediaItem switch + { + JellyfinEpisode e => e.ItemId, + JellyfinMovie m => m.ItemId, + _ => None + }; + + foreach (string itemId in jellyfinItemId) + { + return new PlayoutItemWithPath( + playoutItem, + $"http://localhost:{Settings.ListenPort}/media/jellyfin/{itemId}"); } return new PlayoutItemDoesNotExistOnDisk(path); diff --git a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs index b5c4c246a..80b531d7d 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs @@ -86,7 +86,7 @@ public class FFmpegPlaybackSettingsCalculator case StreamingMode.TransportStream: result.HardwareAcceleration = ffmpegProfile.HardwareAcceleration; - if (NeedToScale(ffmpegProfile, videoVersion)) + if (NeedToScale(ffmpegProfile, videoVersion) && videoVersion.SampleAspectRatio != "0:0") { IDisplaySize scaledSize = CalculateScaledSize(ffmpegProfile, videoVersion); if (!scaledSize.IsSameSizeAs(videoVersion)) diff --git a/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinApiClient.cs b/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinApiClient.cs index 6406dcca8..7bc15a1f8 100644 --- a/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinApiClient.cs +++ b/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinApiClient.cs @@ -40,4 +40,10 @@ public interface IJellyfinApiClient string parentId, string includeItemTypes, bool excludeFolders); + + Task> GetPlaybackInfo( + string address, + string apiKey, + JellyfinLibrary library, + string itemId); } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMediaServerMovieRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMediaServerMovieRepository.cs index 5d1655eb4..5d6289a49 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMediaServerMovieRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMediaServerMovieRepository.cs @@ -12,6 +12,6 @@ public interface IMediaServerMovieRepository where T Task> FlagUnavailable(TLibrary library, TMovie movie); Task> FlagRemoteOnly(TLibrary library, TMovie movie); Task> FlagFileNotFound(TLibrary library, List movieItemIds); - Task>> GetOrAdd(TLibrary library, TMovie item); + Task>> GetOrAdd(TLibrary library, TMovie item, bool deepScan); Task SetEtag(TMovie movie, string etag); } diff --git a/ErsatzTV.Core/Jellyfin/JellyfinMediaStreamType.cs b/ErsatzTV.Core/Jellyfin/JellyfinMediaStreamType.cs new file mode 100644 index 000000000..8dc4ace4e --- /dev/null +++ b/ErsatzTV.Core/Jellyfin/JellyfinMediaStreamType.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Jellyfin; + +public static class JellyfinMediaStreamType +{ + public static readonly string Video = "Video"; + public static readonly string Audio = "Audio"; + public static readonly string Subtitle = "Subtitle"; +} diff --git a/ErsatzTV.FFmpeg/MediaStream.cs b/ErsatzTV.FFmpeg/MediaStream.cs index f45954a24..1536e8040 100644 --- a/ErsatzTV.FFmpeg/MediaStream.cs +++ b/ErsatzTV.FFmpeg/MediaStream.cs @@ -63,18 +63,15 @@ public record VideoStream( if (IsAnamorphic) { - string[] split = SampleAspectRatio.Split(':'); - var num = double.Parse(split[0]); - var den = double.Parse(split[1]); - + double sar = GetSAR(); bool edgeCase = IsAnamorphicEdgeCase; width = edgeCase ? FrameSize.Width - : (int)Math.Floor(FrameSize.Width * num / den); + : (int)Math.Floor(FrameSize.Width * sar); height = edgeCase - ? (int)Math.Floor(FrameSize.Height * num / den) + ? (int)Math.Floor(FrameSize.Height * sar) : FrameSize.Height; } @@ -88,4 +85,31 @@ public record VideoStream( return result; } + + private double GetSAR() + { + // some media servers don't provide sample aspect ratio so we have to calculate it + if (string.IsNullOrWhiteSpace(SampleAspectRatio)) + { + // first check for decimal DAR + if (!double.TryParse(DisplayAspectRatio, out double dar)) + { + // if not, assume it's a ratio + string[] split = DisplayAspectRatio.Split(':'); + var num = double.Parse(split[0]); + var den = double.Parse(split[1]); + dar = num / den; + } + + double res = FrameSize.Width / (double)FrameSize.Height; + return dar / res; + } + else + { + string[] split = SampleAspectRatio.Split(':'); + var num = double.Parse(split[0]); + var den = double.Parse(split[1]); + return num / den; + } + } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/EmbyMovieRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/EmbyMovieRepository.cs index f2bdd323b..687eeb90c 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/EmbyMovieRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/EmbyMovieRepository.cs @@ -113,7 +113,10 @@ public class EmbyMovieRepository : IEmbyMovieRepository return ids; } - public async Task>> GetOrAdd(EmbyLibrary library, EmbyMovie item) + public async Task>> GetOrAdd( + EmbyLibrary library, + EmbyMovie item, + bool deepScan) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); Option maybeExisting = await dbContext.EmbyMovies @@ -146,7 +149,7 @@ public class EmbyMovieRepository : IEmbyMovieRepository foreach (EmbyMovie embyMovie in maybeExisting) { var result = new MediaItemScanResult(embyMovie) { IsAdded = false }; - if (embyMovie.Etag != item.Etag) + if (embyMovie.Etag != item.Etag || deepScan) { await UpdateMovie(dbContext, embyMovie, item); result.IsUpdated = true; diff --git a/ErsatzTV.Infrastructure/Data/Repositories/JellyfinMovieRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/JellyfinMovieRepository.cs index 21a46ca0e..019ae64d9 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/JellyfinMovieRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/JellyfinMovieRepository.cs @@ -116,7 +116,8 @@ public class JellyfinMovieRepository : IJellyfinMovieRepository public async Task>> GetOrAdd( JellyfinLibrary library, - JellyfinMovie item) + JellyfinMovie item, + bool deepScan) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); Option maybeExisting = await dbContext.JellyfinMovies @@ -126,6 +127,8 @@ public class JellyfinMovieRepository : IJellyfinMovieRepository .ThenInclude(mv => mv.MediaFiles) .Include(m => m.MediaVersions) .ThenInclude(mv => mv.Streams) + .Include(m => m.MediaVersions) + .ThenInclude(mv => mv.Chapters) .Include(m => m.MovieMetadata) .ThenInclude(mm => mm.Genres) .Include(m => m.MovieMetadata) @@ -149,7 +152,7 @@ public class JellyfinMovieRepository : IJellyfinMovieRepository foreach (JellyfinMovie jellyfinMovie in maybeExisting) { var result = new MediaItemScanResult(jellyfinMovie) { IsAdded = false }; - if (jellyfinMovie.Etag != item.Etag) + if (jellyfinMovie.Etag != item.Etag || deepScan) { await UpdateMovie(dbContext, jellyfinMovie, item); result.IsUpdated = true; @@ -339,6 +342,7 @@ public class JellyfinMovieRepository : IJellyfinMovieRepository MediaVersion incomingVersion = incoming.MediaVersions.Head(); version.Name = incomingVersion.Name; version.DateAdded = incomingVersion.DateAdded; + version.Chapters = incomingVersion.Chapters; // media file MediaFile file = version.MediaFiles.Head(); diff --git a/ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs index eee6e42d1..e6dd65458 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs @@ -139,6 +139,8 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository .ThenInclude(mv => mv.MediaFiles) .Include(m => m.MediaVersions) .ThenInclude(mv => mv.Streams) + .Include(m => m.MediaVersions) + .ThenInclude(mv => mv.Chapters) .Include(m => m.EpisodeMetadata) .ThenInclude(mm => mm.Artwork) .Include(m => m.EpisodeMetadata) diff --git a/ErsatzTV.Infrastructure/Data/Repositories/PlexMovieRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/PlexMovieRepository.cs index f72544197..3e43cd9cc 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/PlexMovieRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/PlexMovieRepository.cs @@ -113,7 +113,10 @@ public class PlexMovieRepository : IPlexMovieRepository return ids; } - public async Task>> GetOrAdd(PlexLibrary library, PlexMovie item) + public async Task>> GetOrAdd( + PlexLibrary library, + PlexMovie item, + bool deepScan) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); Option maybeExisting = await dbContext.PlexMovies @@ -148,7 +151,7 @@ public class PlexMovieRepository : IPlexMovieRepository foreach (PlexMovie plexMovie in maybeExisting) { var result = new MediaItemScanResult(plexMovie) { IsAdded = false }; - if (plexMovie.Etag != item.Etag) + if (plexMovie.Etag != item.Etag || deepScan) { await UpdateMoviePath(dbContext, plexMovie, item); result.IsUpdated = true; diff --git a/ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs b/ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs index 924846c31..2233e3807 100644 --- a/ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs +++ b/ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs @@ -51,7 +51,7 @@ public interface IJellyfinApi string parentId, [Query] string fields = - "Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,OfficialRating,ProviderIds", + "Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,OfficialRating,ProviderIds,Chapters", [Query] string includeItemTypes = "Movie", [Query] @@ -111,7 +111,7 @@ public interface IJellyfinApi [Query] string parentId, [Query] - string fields = "Path,Genres,Tags,DateCreated,Etag,Overview,ProviderIds,People", + string fields = "Path,Genres,Tags,DateCreated,Etag,Overview,ProviderIds,People,Chapters", [Query] string includeItemTypes = "Episode", [Query] @@ -158,4 +158,12 @@ public interface IJellyfinApi int startIndex = 0, [Query] int limit = 0); + + [Get("/Items/{itemId}/PlaybackInfo")] + public Task GetPlaybackInfo( + [Header("X-Emby-Token")] + string apiKey, + [Query] + string userId, + string itemId); } diff --git a/ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs b/ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs index b8433535f..53e8a1682 100644 --- a/ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs +++ b/ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs @@ -1,7 +1,7 @@ -using ErsatzTV.Core; +using System.Globalization; +using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Jellyfin; -using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Metadata; using ErsatzTV.Infrastructure.Jellyfin.Models; @@ -246,6 +246,31 @@ public class JellyfinApiClient : IJellyfinApiClient } } + public async Task> GetPlaybackInfo( + string address, + string apiKey, + JellyfinLibrary library, + string itemId) + { + try + { + if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{library.MediaSourceId}", out string userId)) + { + IJellyfinApi service = RestService.For(address); + JellyfinPlaybackInfoResponse playbackInfo = await service.GetPlaybackInfo(apiKey, userId, itemId); + Option maybeVersion = ProjectToMediaVersion(playbackInfo); + return maybeVersion.ToEither(() => BaseError.New("Unable to locate Jellyfin statistics")); + } + + return BaseError.New("Jellyfin admin user id is not available"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting jellyfin playback info"); + return BaseError.New(ex.Message); + } + } + private async IAsyncEnumerable GetPagedLibraryItems( string address, string apiKey, @@ -387,10 +412,11 @@ public class JellyfinApiClient : IJellyfinApiClient } } + var duration = TimeSpan.FromTicks(item.RunTimeTicks); var version = new MediaVersion { Name = "Main", - Duration = TimeSpan.FromTicks(item.RunTimeTicks), + Duration = duration, DateAdded = item.DateCreated.UtcDateTime, MediaFiles = new List { @@ -399,7 +425,8 @@ public class JellyfinApiClient : IJellyfinApiClient Path = path } }, - Streams = new List() + Streams = new List(), + Chapters = ProjectToModel(Optional(item.Chapters).Flatten(), duration) }; MovieMetadata metadata = ProjectToMovieMetadata(item); @@ -422,6 +449,29 @@ public class JellyfinApiClient : IJellyfinApiClient } } + private static List ProjectToModel( + IEnumerable jellyfinChapters, + TimeSpan duration) + { + var models = jellyfinChapters.Map(ProjectToModel).OrderBy(c => c.StartTime).ToList(); + + for (var index = 0; index < models.Count; index++) + { + MediaChapter model = models[index]; + model.ChapterId = index; + model.EndTime = index == models.Count - 1 ? duration : models[index + 1].StartTime; + } + + return models; + } + + private static MediaChapter ProjectToModel(JellyfinChapterResponse chapterResponse) => + new() + { + Title = chapterResponse.Name, + StartTime = TimeSpan.FromTicks(chapterResponse.StartPositionTicks) + }; + private MovieMetadata ProjectToMovieMetadata(JellyfinLibraryItemResponse item) { DateTime dateAdded = item.DateCreated.UtcDateTime; @@ -727,10 +777,11 @@ public class JellyfinApiClient : IJellyfinApiClient } } + var duration = TimeSpan.FromTicks(item.RunTimeTicks); var version = new MediaVersion { Name = "Main", - Duration = TimeSpan.FromTicks(item.RunTimeTicks), + Duration = duration, DateAdded = item.DateCreated.UtcDateTime, MediaFiles = new List { @@ -739,7 +790,8 @@ public class JellyfinApiClient : IJellyfinApiClient Path = path } }, - Streams = new List() + Streams = new List(), + Chapters = ProjectToModel(Optional(item.Chapters).Flatten(), duration) }; EpisodeMetadata metadata = ProjectToEpisodeMetadata(item); @@ -833,4 +885,120 @@ public class JellyfinApiClient : IJellyfinApiClient return result; } + + private Option ProjectToMediaVersion(JellyfinPlaybackInfoResponse response) + { + if (response.MediaSources is null || response.MediaSources.Count == 0) + { + _logger.LogWarning("Received empty playback info from Jellyfin"); + return None; + } + + JellyfinMediaSourceResponse mediaSource = response.MediaSources.Head(); + IList streams = mediaSource.MediaStreams; + Option maybeVideoStream = + streams.Find(s => s.Type == JellyfinMediaStreamType.Video); + return maybeVideoStream.Map( + videoStream => + { + int width = videoStream.Width ?? 1; + int height = videoStream.Height ?? 1; + + var isAnamorphic = false; + if (videoStream.IsAnamorphic.HasValue) + { + isAnamorphic = videoStream.IsAnamorphic.Value; + } + else if (!string.IsNullOrWhiteSpace(videoStream.AspectRatio) && videoStream.AspectRatio.Contains(":")) + { + // if width/height != aspect ratio, is anamorphic + double resolutionRatio = width / (double)height; + + string[] split = videoStream.AspectRatio.Split(":"); + var num = double.Parse(split[0]); + var den = double.Parse(split[1]); + double aspectRatio = num / den; + + isAnamorphic = Math.Abs(resolutionRatio - aspectRatio) > 0.01d; + } + + var version = new MediaVersion + { + Duration = TimeSpan.FromTicks(mediaSource.RunTimeTicks), + SampleAspectRatio = isAnamorphic ? "0:0" : "1:1", + DisplayAspectRatio = string.IsNullOrWhiteSpace(videoStream.AspectRatio) + ? string.Empty + : videoStream.AspectRatio, + VideoScanKind = videoStream.IsInterlaced switch + { + true => VideoScanKind.Interlaced, + false => VideoScanKind.Progressive + }, + Streams = new List(), + Width = videoStream.Width ?? 1, + Height = videoStream.Height ?? 1, + RFrameRate = videoStream.RealFrameRate.HasValue + ? videoStream.RealFrameRate.Value.ToString("0.00###", CultureInfo.InvariantCulture) + : string.Empty, + Chapters = new List() + }; + + version.Streams.Add( + new MediaStream + { + MediaVersionId = version.Id, + MediaStreamKind = MediaStreamKind.Video, + Index = videoStream.Index, + Codec = videoStream.Codec, + Profile = (videoStream.Profile ?? string.Empty).ToLowerInvariant(), + Default = videoStream.IsDefault, + Language = videoStream.Language, + Forced = videoStream.IsForced, + PixelFormat = videoStream.PixelFormat, + ColorRange = (videoStream.ColorRange ?? string.Empty).ToLowerInvariant(), + ColorSpace = (videoStream.ColorSpace ?? string.Empty).ToLowerInvariant(), + ColorTransfer = (videoStream.ColorTransfer ?? string.Empty).ToLowerInvariant(), + ColorPrimaries = (videoStream.ColorPrimaries ?? string.Empty).ToLowerInvariant() + }); + + foreach (JellyfinMediaStreamResponse audioStream in streams.Filter( + s => s.Type == JellyfinMediaStreamType.Audio)) + { + var stream = new MediaStream + { + MediaVersionId = version.Id, + MediaStreamKind = MediaStreamKind.Audio, + Index = audioStream.Index, + Codec = audioStream.Codec, + Profile = (audioStream.Profile ?? string.Empty).ToLowerInvariant(), + Channels = audioStream.Channels ?? 2, + Default = audioStream.IsDefault, + Forced = audioStream.IsForced, + Language = audioStream.Language, + Title = audioStream.Title ?? string.Empty + }; + + version.Streams.Add(stream); + } + + foreach (JellyfinMediaStreamResponse subtitleStream in streams.Filter( + s => s.Type == JellyfinMediaStreamType.Subtitle)) + { + var stream = new MediaStream + { + MediaVersionId = version.Id, + MediaStreamKind = MediaStreamKind.Subtitle, + Index = subtitleStream.Index, + Codec = subtitleStream.Codec, + Default = subtitleStream.IsDefault, + Forced = subtitleStream.IsForced, + Language = subtitleStream.Language + }; + + version.Streams.Add(stream); + } + + return version; + }); + } } diff --git a/ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinChapterResponse.cs b/ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinChapterResponse.cs new file mode 100644 index 000000000..71eadfb65 --- /dev/null +++ b/ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinChapterResponse.cs @@ -0,0 +1,7 @@ +namespace ErsatzTV.Infrastructure.Jellyfin.Models; + +public class JellyfinChapterResponse +{ + public long StartPositionTicks { get; set; } + public string Name { get; set; } +} diff --git a/ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinLibraryItemResponse.cs b/ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinLibraryItemResponse.cs index e05aaa1b9..e1725c7a1 100644 --- a/ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinLibraryItemResponse.cs +++ b/ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinLibraryItemResponse.cs @@ -24,4 +24,5 @@ public class JellyfinLibraryItemResponse public List BackdropImageTags { get; set; } public int? IndexNumber { get; set; } public string Type { get; set; } + public IList Chapters { get; set; } } diff --git a/ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinMediaSourceResponse.cs b/ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinMediaSourceResponse.cs new file mode 100644 index 000000000..287cf5c2d --- /dev/null +++ b/ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinMediaSourceResponse.cs @@ -0,0 +1,7 @@ +namespace ErsatzTV.Infrastructure.Jellyfin.Models; + +public class JellyfinMediaSourceResponse +{ + public long RunTimeTicks { get; set; } + public IList MediaStreams { get; set; } +} diff --git a/ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinMediaStreamResponse.cs b/ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinMediaStreamResponse.cs index 16d9163e5..3b08c1d4f 100644 --- a/ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinMediaStreamResponse.cs +++ b/ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinMediaStreamResponse.cs @@ -14,4 +14,12 @@ public class JellyfinMediaStreamResponse public string Profile { get; set; } public string AspectRatio { get; set; } public int? Channels { get; set; } + public double? RealFrameRate { get; set; } + public string PixelFormat { get; set; } + public string Title { get; set; } + public string ColorRange { get; set; } + public string ColorSpace { get; set; } + public string ColorTransfer { get; set; } + public string ColorPrimaries { get; set; } + public bool? IsAnamorphic { get; set; } } diff --git a/ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinPlaybackInfoResponse.cs b/ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinPlaybackInfoResponse.cs new file mode 100644 index 000000000..05e55d25c --- /dev/null +++ b/ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinPlaybackInfoResponse.cs @@ -0,0 +1,6 @@ +namespace ErsatzTV.Infrastructure.Jellyfin.Models; + +public class JellyfinPlaybackInfoResponse +{ + public IList MediaSources { get; set; } +} diff --git a/ErsatzTV.Scanner/Core/Jellyfin/JellyfinMovieLibraryScanner.cs b/ErsatzTV.Scanner/Core/Jellyfin/JellyfinMovieLibraryScanner.cs index 653500a87..44ba26d3f 100644 --- a/ErsatzTV.Scanner/Core/Jellyfin/JellyfinMovieLibraryScanner.cs +++ b/ErsatzTV.Scanner/Core/Jellyfin/JellyfinMovieLibraryScanner.cs @@ -19,6 +19,7 @@ public class JellyfinMovieLibraryScanner : private readonly IJellyfinApiClient _jellyfinApiClient; private readonly IJellyfinMovieRepository _jellyfinMovieRepository; private readonly IMediaSourceRepository _mediaSourceRepository; + private readonly ILogger _logger; private readonly IJellyfinPathReplacementService _pathReplacementService; public JellyfinMovieLibraryScanner( @@ -44,8 +45,11 @@ public class JellyfinMovieLibraryScanner : _jellyfinMovieRepository = jellyfinMovieRepository; _pathReplacementService = pathReplacementService; _mediaSourceRepository = mediaSourceRepository; + _logger = logger; } + protected override bool ServerSupportsRemoteStreaming => true; + public async Task> ScanLibrary( string address, string apiKey, @@ -113,6 +117,35 @@ public class JellyfinMovieLibraryScanner : JellyfinLibrary library, MediaItemScanResult result, JellyfinMovie incoming) => Task.FromResult(Option>.None); + + protected override async Task> GetMediaServerStatistics( + JellyfinConnectionParameters connectionParameters, + JellyfinLibrary library, + MediaItemScanResult result, + JellyfinMovie incoming) + { + _logger.LogDebug("Refreshing {Attribute} for {Path}", "Jellyfin Statistics", result.LocalPath); + + Either maybeVersion = + await _jellyfinApiClient.GetPlaybackInfo( + connectionParameters.Address, + connectionParameters.ApiKey, + library, + incoming.ItemId); + + foreach (BaseError error in maybeVersion.LeftToSeq()) + { + _logger.LogWarning("Failed to get movie statistics from Jellyfin: {Error}", error.ToString()); + } + + // chapters are pulled with metadata, not with statistics, but we need to save them here + foreach (MediaVersion version in maybeVersion.RightToSeq()) + { + version.Chapters = result.Item.GetHeadVersion().Chapters; + } + + return maybeVersion.ToOption(); + } protected override Task>> UpdateMetadata( MediaItemScanResult result, diff --git a/ErsatzTV.Scanner/Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs b/ErsatzTV.Scanner/Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs index 339bb8265..f2cc34903 100644 --- a/ErsatzTV.Scanner/Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs +++ b/ErsatzTV.Scanner/Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs @@ -20,6 +20,7 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan private readonly IJellyfinApiClient _jellyfinApiClient; private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IJellyfinPathReplacementService _pathReplacementService; + private readonly ILogger _logger; private readonly IJellyfinTelevisionRepository _televisionRepository; public JellyfinTelevisionLibraryScanner( @@ -45,8 +46,11 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan _mediaSourceRepository = mediaSourceRepository; _televisionRepository = televisionRepository; _pathReplacementService = pathReplacementService; + _logger = logger; } + protected override bool ServerSupportsRemoteStreaming => true; + public async Task> ScanLibrary( string address, string apiKey, @@ -177,6 +181,35 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan MediaItemScanResult result, JellyfinEpisode incoming) => Task.FromResult(Option>.None); + protected override async Task> GetMediaServerStatistics( + JellyfinConnectionParameters connectionParameters, + JellyfinLibrary library, + MediaItemScanResult result, + JellyfinEpisode incoming) + { + _logger.LogDebug("Refreshing {Attribute} for {Path}", "Jellyfin Statistics", result.LocalPath); + + Either maybeVersion = + await _jellyfinApiClient.GetPlaybackInfo( + connectionParameters.Address, + connectionParameters.ApiKey, + library, + incoming.ItemId); + + foreach (BaseError error in maybeVersion.LeftToSeq()) + { + _logger.LogWarning("Failed to get episode statistics from Jellyfin: {Error}", error.ToString()); + } + + // chapters are pulled with metadata, not with statistics, but we need to save them here + foreach (MediaVersion version in maybeVersion.RightToSeq()) + { + version.Chapters = result.Item.GetHeadVersion().Chapters; + } + + return maybeVersion.ToOption(); + } + protected override Task>> UpdateMetadata( MediaItemScanResult result, ShowMetadata fullMetadata) => diff --git a/ErsatzTV.Scanner/Core/Metadata/LocalStatisticsProvider.cs b/ErsatzTV.Scanner/Core/Metadata/LocalStatisticsProvider.cs index 2c2fcddf1..dea86dda2 100644 --- a/ErsatzTV.Scanner/Core/Metadata/LocalStatisticsProvider.cs +++ b/ErsatzTV.Scanner/Core/Metadata/LocalStatisticsProvider.cs @@ -257,7 +257,16 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider } } - return ffprobe; + // fix chapter ids to be something sensible + var maybeChapters = Optional(ffprobe.chapters).Flatten().ToList(); + var newChapters = new List(); + for (var index = 0; index < maybeChapters.Count; index++) + { + FFprobeChapter chapter = maybeChapters[index]; + newChapters.Add(chapter with { id = index }); + } + + return ffprobe with { chapters = newChapters }; } return BaseError.New("Unable to deserialize ffprobe output"); diff --git a/ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs b/ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs index 72fe665fa..1e33d43a8 100644 --- a/ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs +++ b/ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs @@ -133,7 +133,7 @@ public abstract class MediaServerMovieLibraryScanner { @@ -152,7 +152,7 @@ public abstract class MediaServerMovieLibraryScanner { @@ -308,7 +308,7 @@ public abstract class MediaServerMovieLibraryScanner _logger; private readonly IMediator _mediator; - private readonly IHttpClientFactory _httpClientFactory; - public InternalController( - IMediator mediator, - IHttpClientFactory httpClientFactory, - ILogger logger) + public InternalController(IMediator mediator, ILogger logger) { _mediator = mediator; - _httpClientFactory = httpClientFactory; _logger = logger; } @@ -89,7 +85,7 @@ public class InternalController : ControllerBase )); [HttpGet("/media/plex/{plexMediaSourceId:int}/{*path}")] - public async Task GetPlexFanArt( + public async Task GetPlexMedia( int plexMediaSourceId, string path, CancellationToken cancellationToken) @@ -101,7 +97,27 @@ public class InternalController : ControllerBase Left: _ => new NotFoundResult(), Right: r => { - Url fullPath = new Uri(r.Uri, path).SetQueryParam("X-Plex-Token", r.AuthToken); + Url fullPath = new Uri(r.Uri, path).SetQueryParam("X-Plex-Token", r.AuthToken); + return new RedirectResult(fullPath.ToString()); + }); + } + + [HttpGet("/media/jellyfin/{*path}")] + public async Task GetJellyfinMedia(string path, CancellationToken cancellationToken) + { + Either connectionParameters = + await _mediator.Send(new GetJellyfinConnectionParameters(), cancellationToken); + + return connectionParameters.Match( + Left: _ => new NotFoundResult(), + Right: r => + { + Url fullPath = Flurl.Url.Parse(r.Address) + .AppendPathSegment("Videos") + .AppendPathSegment(path) + .AppendPathSegment("stream") + .SetQueryParam("static", "true"); + return new RedirectResult(fullPath.ToString()); }); }