Browse Source

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
pull/1168/head
Jason Dove 3 years ago committed by GitHub
parent
commit
b751f1054b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      CHANGELOG.md
  2. 56
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  3. 2
      ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs
  4. 6
      ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinApiClient.cs
  5. 2
      ErsatzTV.Core/Interfaces/Repositories/IMediaServerMovieRepository.cs
  6. 8
      ErsatzTV.Core/Jellyfin/JellyfinMediaStreamType.cs
  7. 36
      ErsatzTV.FFmpeg/MediaStream.cs
  8. 7
      ErsatzTV.Infrastructure/Data/Repositories/EmbyMovieRepository.cs
  9. 8
      ErsatzTV.Infrastructure/Data/Repositories/JellyfinMovieRepository.cs
  10. 2
      ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs
  11. 7
      ErsatzTV.Infrastructure/Data/Repositories/PlexMovieRepository.cs
  12. 12
      ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs
  13. 180
      ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs
  14. 7
      ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinChapterResponse.cs
  15. 1
      ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinLibraryItemResponse.cs
  16. 7
      ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinMediaSourceResponse.cs
  17. 8
      ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinMediaStreamResponse.cs
  18. 6
      ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinPlaybackInfoResponse.cs
  19. 33
      ErsatzTV.Scanner/Core/Jellyfin/JellyfinMovieLibraryScanner.cs
  20. 33
      ErsatzTV.Scanner/Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs
  21. 11
      ErsatzTV.Scanner/Core/Metadata/LocalStatisticsProvider.cs
  22. 6
      ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs
  23. 2
      ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs
  24. 32
      ErsatzTV/Controllers/InternalController.cs

4
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 - 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 - 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 - 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 ## [0.7.4-beta] - 2023-02-12
### Added ### Added

56
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -453,34 +453,48 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
{ {
string path = await GetPlayoutItemPath(playoutItem); string path = await GetPlayoutItemPath(playoutItem);
// check filesystem first
if (_localFileSystem.FileExists(path)) if (_localFileSystem.FileExists(path))
{ {
return new PlayoutItemWithPath(playoutItem, 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(); case PlexMediaFile pmf:
switch (file) Option<int> maybeId = await dbContext.Connection.QuerySingleOrDefaultAsync<int>(
{ @"SELECT PMS.Id FROM PlexMediaSource PMS
case PlexMediaFile pmf: INNER JOIN Library L on PMS.Id = L.MediaSourceId
Option<int> maybeId = await dbContext.Connection.QuerySingleOrDefaultAsync<int>( INNER JOIN LibraryPath LP on L.Id = LP.LibraryId
@"SELECT PMS.Id FROM PlexMediaSource PMS WHERE LP.Id = @LibraryPathId",
INNER JOIN Library L on PMS.Id = L.MediaSourceId new { playoutItem.MediaItem.LibraryPathId })
INNER JOIN LibraryPath LP on L.Id = LP.LibraryId .Map(Optional);
WHERE LP.Id = @LibraryPathId",
new { playoutItem.MediaItem.LibraryPathId }) foreach (int plexMediaSourceId in maybeId)
.Map(Optional); {
return new PlayoutItemWithPath(
foreach (int plexMediaSourceId in maybeId) playoutItem,
{ $"http://localhost:{Settings.ListenPort}/media/plex/{plexMediaSourceId}/{pmf.Key}");
return new PlayoutItemWithPath( }
playoutItem,
$"http://localhost:{Settings.ListenPort}/media/plex/{plexMediaSourceId}/{pmf.Key}");
}
break; break;
} }
// attempt to remotely stream jellyfin
Option<string> 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); return new PlayoutItemDoesNotExistOnDisk(path);

2
ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs

@ -86,7 +86,7 @@ public class FFmpegPlaybackSettingsCalculator
case StreamingMode.TransportStream: case StreamingMode.TransportStream:
result.HardwareAcceleration = ffmpegProfile.HardwareAcceleration; result.HardwareAcceleration = ffmpegProfile.HardwareAcceleration;
if (NeedToScale(ffmpegProfile, videoVersion)) if (NeedToScale(ffmpegProfile, videoVersion) && videoVersion.SampleAspectRatio != "0:0")
{ {
IDisplaySize scaledSize = CalculateScaledSize(ffmpegProfile, videoVersion); IDisplaySize scaledSize = CalculateScaledSize(ffmpegProfile, videoVersion);
if (!scaledSize.IsSameSizeAs(videoVersion)) if (!scaledSize.IsSameSizeAs(videoVersion))

6
ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinApiClient.cs

@ -40,4 +40,10 @@ public interface IJellyfinApiClient
string parentId, string parentId,
string includeItemTypes, string includeItemTypes,
bool excludeFolders); bool excludeFolders);
Task<Either<BaseError, MediaVersion>> GetPlaybackInfo(
string address,
string apiKey,
JellyfinLibrary library,
string itemId);
} }

2
ErsatzTV.Core/Interfaces/Repositories/IMediaServerMovieRepository.cs

@ -12,6 +12,6 @@ public interface IMediaServerMovieRepository<in TLibrary, TMovie, TEtag> where T
Task<Option<int>> FlagUnavailable(TLibrary library, TMovie movie); Task<Option<int>> FlagUnavailable(TLibrary library, TMovie movie);
Task<Option<int>> FlagRemoteOnly(TLibrary library, TMovie movie); Task<Option<int>> FlagRemoteOnly(TLibrary library, TMovie movie);
Task<List<int>> FlagFileNotFound(TLibrary library, List<string> movieItemIds); Task<List<int>> FlagFileNotFound(TLibrary library, List<string> movieItemIds);
Task<Either<BaseError, MediaItemScanResult<TMovie>>> GetOrAdd(TLibrary library, TMovie item); Task<Either<BaseError, MediaItemScanResult<TMovie>>> GetOrAdd(TLibrary library, TMovie item, bool deepScan);
Task<Unit> SetEtag(TMovie movie, string etag); Task<Unit> SetEtag(TMovie movie, string etag);
} }

8
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";
}

36
ErsatzTV.FFmpeg/MediaStream.cs

@ -63,18 +63,15 @@ public record VideoStream(
if (IsAnamorphic) if (IsAnamorphic)
{ {
string[] split = SampleAspectRatio.Split(':'); double sar = GetSAR();
var num = double.Parse(split[0]);
var den = double.Parse(split[1]);
bool edgeCase = IsAnamorphicEdgeCase; bool edgeCase = IsAnamorphicEdgeCase;
width = edgeCase width = edgeCase
? FrameSize.Width ? FrameSize.Width
: (int)Math.Floor(FrameSize.Width * num / den); : (int)Math.Floor(FrameSize.Width * sar);
height = edgeCase height = edgeCase
? (int)Math.Floor(FrameSize.Height * num / den) ? (int)Math.Floor(FrameSize.Height * sar)
: FrameSize.Height; : FrameSize.Height;
} }
@ -88,4 +85,31 @@ public record VideoStream(
return result; 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;
}
}
} }

7
ErsatzTV.Infrastructure/Data/Repositories/EmbyMovieRepository.cs

@ -113,7 +113,10 @@ public class EmbyMovieRepository : IEmbyMovieRepository
return ids; return ids;
} }
public async Task<Either<BaseError, MediaItemScanResult<EmbyMovie>>> GetOrAdd(EmbyLibrary library, EmbyMovie item) public async Task<Either<BaseError, MediaItemScanResult<EmbyMovie>>> GetOrAdd(
EmbyLibrary library,
EmbyMovie item,
bool deepScan)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<EmbyMovie> maybeExisting = await dbContext.EmbyMovies Option<EmbyMovie> maybeExisting = await dbContext.EmbyMovies
@ -146,7 +149,7 @@ public class EmbyMovieRepository : IEmbyMovieRepository
foreach (EmbyMovie embyMovie in maybeExisting) foreach (EmbyMovie embyMovie in maybeExisting)
{ {
var result = new MediaItemScanResult<EmbyMovie>(embyMovie) { IsAdded = false }; var result = new MediaItemScanResult<EmbyMovie>(embyMovie) { IsAdded = false };
if (embyMovie.Etag != item.Etag) if (embyMovie.Etag != item.Etag || deepScan)
{ {
await UpdateMovie(dbContext, embyMovie, item); await UpdateMovie(dbContext, embyMovie, item);
result.IsUpdated = true; result.IsUpdated = true;

8
ErsatzTV.Infrastructure/Data/Repositories/JellyfinMovieRepository.cs

@ -116,7 +116,8 @@ public class JellyfinMovieRepository : IJellyfinMovieRepository
public async Task<Either<BaseError, MediaItemScanResult<JellyfinMovie>>> GetOrAdd( public async Task<Either<BaseError, MediaItemScanResult<JellyfinMovie>>> GetOrAdd(
JellyfinLibrary library, JellyfinLibrary library,
JellyfinMovie item) JellyfinMovie item,
bool deepScan)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<JellyfinMovie> maybeExisting = await dbContext.JellyfinMovies Option<JellyfinMovie> maybeExisting = await dbContext.JellyfinMovies
@ -126,6 +127,8 @@ public class JellyfinMovieRepository : IJellyfinMovieRepository
.ThenInclude(mv => mv.MediaFiles) .ThenInclude(mv => mv.MediaFiles)
.Include(m => m.MediaVersions) .Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Streams) .ThenInclude(mv => mv.Streams)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MovieMetadata) .Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Genres) .ThenInclude(mm => mm.Genres)
.Include(m => m.MovieMetadata) .Include(m => m.MovieMetadata)
@ -149,7 +152,7 @@ public class JellyfinMovieRepository : IJellyfinMovieRepository
foreach (JellyfinMovie jellyfinMovie in maybeExisting) foreach (JellyfinMovie jellyfinMovie in maybeExisting)
{ {
var result = new MediaItemScanResult<JellyfinMovie>(jellyfinMovie) { IsAdded = false }; var result = new MediaItemScanResult<JellyfinMovie>(jellyfinMovie) { IsAdded = false };
if (jellyfinMovie.Etag != item.Etag) if (jellyfinMovie.Etag != item.Etag || deepScan)
{ {
await UpdateMovie(dbContext, jellyfinMovie, item); await UpdateMovie(dbContext, jellyfinMovie, item);
result.IsUpdated = true; result.IsUpdated = true;
@ -339,6 +342,7 @@ public class JellyfinMovieRepository : IJellyfinMovieRepository
MediaVersion incomingVersion = incoming.MediaVersions.Head(); MediaVersion incomingVersion = incoming.MediaVersions.Head();
version.Name = incomingVersion.Name; version.Name = incomingVersion.Name;
version.DateAdded = incomingVersion.DateAdded; version.DateAdded = incomingVersion.DateAdded;
version.Chapters = incomingVersion.Chapters;
// media file // media file
MediaFile file = version.MediaFiles.Head(); MediaFile file = version.MediaFiles.Head();

2
ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs

@ -139,6 +139,8 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
.ThenInclude(mv => mv.MediaFiles) .ThenInclude(mv => mv.MediaFiles)
.Include(m => m.MediaVersions) .Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Streams) .ThenInclude(mv => mv.Streams)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.EpisodeMetadata) .Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Artwork) .ThenInclude(mm => mm.Artwork)
.Include(m => m.EpisodeMetadata) .Include(m => m.EpisodeMetadata)

7
ErsatzTV.Infrastructure/Data/Repositories/PlexMovieRepository.cs

@ -113,7 +113,10 @@ public class PlexMovieRepository : IPlexMovieRepository
return ids; return ids;
} }
public async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> GetOrAdd(PlexLibrary library, PlexMovie item) public async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> GetOrAdd(
PlexLibrary library,
PlexMovie item,
bool deepScan)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<PlexMovie> maybeExisting = await dbContext.PlexMovies Option<PlexMovie> maybeExisting = await dbContext.PlexMovies
@ -148,7 +151,7 @@ public class PlexMovieRepository : IPlexMovieRepository
foreach (PlexMovie plexMovie in maybeExisting) foreach (PlexMovie plexMovie in maybeExisting)
{ {
var result = new MediaItemScanResult<PlexMovie>(plexMovie) { IsAdded = false }; var result = new MediaItemScanResult<PlexMovie>(plexMovie) { IsAdded = false };
if (plexMovie.Etag != item.Etag) if (plexMovie.Etag != item.Etag || deepScan)
{ {
await UpdateMoviePath(dbContext, plexMovie, item); await UpdateMoviePath(dbContext, plexMovie, item);
result.IsUpdated = true; result.IsUpdated = true;

12
ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs

@ -51,7 +51,7 @@ public interface IJellyfinApi
string parentId, string parentId,
[Query] [Query]
string fields = 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] [Query]
string includeItemTypes = "Movie", string includeItemTypes = "Movie",
[Query] [Query]
@ -111,7 +111,7 @@ public interface IJellyfinApi
[Query] [Query]
string parentId, string parentId,
[Query] [Query]
string fields = "Path,Genres,Tags,DateCreated,Etag,Overview,ProviderIds,People", string fields = "Path,Genres,Tags,DateCreated,Etag,Overview,ProviderIds,People,Chapters",
[Query] [Query]
string includeItemTypes = "Episode", string includeItemTypes = "Episode",
[Query] [Query]
@ -158,4 +158,12 @@ public interface IJellyfinApi
int startIndex = 0, int startIndex = 0,
[Query] [Query]
int limit = 0); int limit = 0);
[Get("/Items/{itemId}/PlaybackInfo")]
public Task<JellyfinPlaybackInfoResponse> GetPlaybackInfo(
[Header("X-Emby-Token")]
string apiKey,
[Query]
string userId,
string itemId);
} }

180
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.Domain;
using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using ErsatzTV.Infrastructure.Jellyfin.Models; using ErsatzTV.Infrastructure.Jellyfin.Models;
@ -246,6 +246,31 @@ public class JellyfinApiClient : IJellyfinApiClient
} }
} }
public async Task<Either<BaseError, MediaVersion>> 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<IJellyfinApi>(address);
JellyfinPlaybackInfoResponse playbackInfo = await service.GetPlaybackInfo(apiKey, userId, itemId);
Option<MediaVersion> 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<TItem> GetPagedLibraryItems<TItem>( private async IAsyncEnumerable<TItem> GetPagedLibraryItems<TItem>(
string address, string address,
string apiKey, string apiKey,
@ -387,10 +412,11 @@ public class JellyfinApiClient : IJellyfinApiClient
} }
} }
var duration = TimeSpan.FromTicks(item.RunTimeTicks);
var version = new MediaVersion var version = new MediaVersion
{ {
Name = "Main", Name = "Main",
Duration = TimeSpan.FromTicks(item.RunTimeTicks), Duration = duration,
DateAdded = item.DateCreated.UtcDateTime, DateAdded = item.DateCreated.UtcDateTime,
MediaFiles = new List<MediaFile> MediaFiles = new List<MediaFile>
{ {
@ -399,7 +425,8 @@ public class JellyfinApiClient : IJellyfinApiClient
Path = path Path = path
} }
}, },
Streams = new List<MediaStream>() Streams = new List<MediaStream>(),
Chapters = ProjectToModel(Optional(item.Chapters).Flatten(), duration)
}; };
MovieMetadata metadata = ProjectToMovieMetadata(item); MovieMetadata metadata = ProjectToMovieMetadata(item);
@ -422,6 +449,29 @@ public class JellyfinApiClient : IJellyfinApiClient
} }
} }
private static List<MediaChapter> ProjectToModel(
IEnumerable<JellyfinChapterResponse> 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) private MovieMetadata ProjectToMovieMetadata(JellyfinLibraryItemResponse item)
{ {
DateTime dateAdded = item.DateCreated.UtcDateTime; DateTime dateAdded = item.DateCreated.UtcDateTime;
@ -727,10 +777,11 @@ public class JellyfinApiClient : IJellyfinApiClient
} }
} }
var duration = TimeSpan.FromTicks(item.RunTimeTicks);
var version = new MediaVersion var version = new MediaVersion
{ {
Name = "Main", Name = "Main",
Duration = TimeSpan.FromTicks(item.RunTimeTicks), Duration = duration,
DateAdded = item.DateCreated.UtcDateTime, DateAdded = item.DateCreated.UtcDateTime,
MediaFiles = new List<MediaFile> MediaFiles = new List<MediaFile>
{ {
@ -739,7 +790,8 @@ public class JellyfinApiClient : IJellyfinApiClient
Path = path Path = path
} }
}, },
Streams = new List<MediaStream>() Streams = new List<MediaStream>(),
Chapters = ProjectToModel(Optional(item.Chapters).Flatten(), duration)
}; };
EpisodeMetadata metadata = ProjectToEpisodeMetadata(item); EpisodeMetadata metadata = ProjectToEpisodeMetadata(item);
@ -833,4 +885,120 @@ public class JellyfinApiClient : IJellyfinApiClient
return result; return result;
} }
private Option<MediaVersion> 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<JellyfinMediaStreamResponse> streams = mediaSource.MediaStreams;
Option<JellyfinMediaStreamResponse> 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<MediaStream>(),
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<MediaChapter>()
};
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;
});
}
} }

7
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; }
}

1
ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinLibraryItemResponse.cs

@ -24,4 +24,5 @@ public class JellyfinLibraryItemResponse
public List<string> BackdropImageTags { get; set; } public List<string> BackdropImageTags { get; set; }
public int? IndexNumber { get; set; } public int? IndexNumber { get; set; }
public string Type { get; set; } public string Type { get; set; }
public IList<JellyfinChapterResponse> Chapters { get; set; }
} }

7
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<JellyfinMediaStreamResponse> MediaStreams { get; set; }
}

8
ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinMediaStreamResponse.cs

@ -14,4 +14,12 @@ public class JellyfinMediaStreamResponse
public string Profile { get; set; } public string Profile { get; set; }
public string AspectRatio { get; set; } public string AspectRatio { get; set; }
public int? Channels { 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; }
} }

6
ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinPlaybackInfoResponse.cs

@ -0,0 +1,6 @@
namespace ErsatzTV.Infrastructure.Jellyfin.Models;
public class JellyfinPlaybackInfoResponse
{
public IList<JellyfinMediaSourceResponse> MediaSources { get; set; }
}

33
ErsatzTV.Scanner/Core/Jellyfin/JellyfinMovieLibraryScanner.cs

@ -19,6 +19,7 @@ public class JellyfinMovieLibraryScanner :
private readonly IJellyfinApiClient _jellyfinApiClient; private readonly IJellyfinApiClient _jellyfinApiClient;
private readonly IJellyfinMovieRepository _jellyfinMovieRepository; private readonly IJellyfinMovieRepository _jellyfinMovieRepository;
private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly ILogger<JellyfinMovieLibraryScanner> _logger;
private readonly IJellyfinPathReplacementService _pathReplacementService; private readonly IJellyfinPathReplacementService _pathReplacementService;
public JellyfinMovieLibraryScanner( public JellyfinMovieLibraryScanner(
@ -44,8 +45,11 @@ public class JellyfinMovieLibraryScanner :
_jellyfinMovieRepository = jellyfinMovieRepository; _jellyfinMovieRepository = jellyfinMovieRepository;
_pathReplacementService = pathReplacementService; _pathReplacementService = pathReplacementService;
_mediaSourceRepository = mediaSourceRepository; _mediaSourceRepository = mediaSourceRepository;
_logger = logger;
} }
protected override bool ServerSupportsRemoteStreaming => true;
public async Task<Either<BaseError, Unit>> ScanLibrary( public async Task<Either<BaseError, Unit>> ScanLibrary(
string address, string address,
string apiKey, string apiKey,
@ -113,6 +117,35 @@ public class JellyfinMovieLibraryScanner :
JellyfinLibrary library, JellyfinLibrary library,
MediaItemScanResult<JellyfinMovie> result, MediaItemScanResult<JellyfinMovie> result,
JellyfinMovie incoming) => Task.FromResult(Option<Tuple<MovieMetadata, MediaVersion>>.None); JellyfinMovie incoming) => Task.FromResult(Option<Tuple<MovieMetadata, MediaVersion>>.None);
protected override async Task<Option<MediaVersion>> GetMediaServerStatistics(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library,
MediaItemScanResult<JellyfinMovie> result,
JellyfinMovie incoming)
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Jellyfin Statistics", result.LocalPath);
Either<BaseError, MediaVersion> 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<Either<BaseError, MediaItemScanResult<JellyfinMovie>>> UpdateMetadata( protected override Task<Either<BaseError, MediaItemScanResult<JellyfinMovie>>> UpdateMetadata(
MediaItemScanResult<JellyfinMovie> result, MediaItemScanResult<JellyfinMovie> result,

33
ErsatzTV.Scanner/Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs

@ -20,6 +20,7 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
private readonly IJellyfinApiClient _jellyfinApiClient; private readonly IJellyfinApiClient _jellyfinApiClient;
private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IJellyfinPathReplacementService _pathReplacementService; private readonly IJellyfinPathReplacementService _pathReplacementService;
private readonly ILogger<JellyfinTelevisionLibraryScanner> _logger;
private readonly IJellyfinTelevisionRepository _televisionRepository; private readonly IJellyfinTelevisionRepository _televisionRepository;
public JellyfinTelevisionLibraryScanner( public JellyfinTelevisionLibraryScanner(
@ -45,8 +46,11 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
_mediaSourceRepository = mediaSourceRepository; _mediaSourceRepository = mediaSourceRepository;
_televisionRepository = televisionRepository; _televisionRepository = televisionRepository;
_pathReplacementService = pathReplacementService; _pathReplacementService = pathReplacementService;
_logger = logger;
} }
protected override bool ServerSupportsRemoteStreaming => true;
public async Task<Either<BaseError, Unit>> ScanLibrary( public async Task<Either<BaseError, Unit>> ScanLibrary(
string address, string address,
string apiKey, string apiKey,
@ -177,6 +181,35 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
MediaItemScanResult<JellyfinEpisode> result, MediaItemScanResult<JellyfinEpisode> result,
JellyfinEpisode incoming) => Task.FromResult(Option<Tuple<EpisodeMetadata, MediaVersion>>.None); JellyfinEpisode incoming) => Task.FromResult(Option<Tuple<EpisodeMetadata, MediaVersion>>.None);
protected override async Task<Option<MediaVersion>> GetMediaServerStatistics(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library,
MediaItemScanResult<JellyfinEpisode> result,
JellyfinEpisode incoming)
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Jellyfin Statistics", result.LocalPath);
Either<BaseError, MediaVersion> 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<Either<BaseError, MediaItemScanResult<JellyfinShow>>> UpdateMetadata( protected override Task<Either<BaseError, MediaItemScanResult<JellyfinShow>>> UpdateMetadata(
MediaItemScanResult<JellyfinShow> result, MediaItemScanResult<JellyfinShow> result,
ShowMetadata fullMetadata) => ShowMetadata fullMetadata) =>

11
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<FFprobeChapter>();
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"); return BaseError.New("Unable to deserialize ffprobe output");

6
ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs

@ -133,7 +133,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
if (ServerReturnsStatisticsWithMetadata) if (ServerReturnsStatisticsWithMetadata)
{ {
maybeMovie = await movieRepository maybeMovie = await movieRepository
.GetOrAdd(library, incoming) .GetOrAdd(library, incoming, deepScan)
.MapT( .MapT(
result => result =>
{ {
@ -152,7 +152,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
else else
{ {
maybeMovie = await movieRepository maybeMovie = await movieRepository
.GetOrAdd(library, incoming) .GetOrAdd(library, incoming, deepScan)
.MapT( .MapT(
result => result =>
{ {
@ -308,7 +308,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
existingEtag == MediaServerEtag(incoming)) existingEtag == MediaServerEtag(incoming))
{ {
// skip scanning unavailable/file not found items that are unchanged and still don't exist locally // skip scanning unavailable/file not found items that are unchanged and still don't exist locally
if (!_localFileSystem.FileExists(localPath)) if (!_localFileSystem.FileExists(localPath) && !ServerSupportsRemoteStreaming)
{ {
return false; return false;
} }

2
ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs

@ -591,7 +591,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
existingEtag == MediaServerEtag(incoming)) existingEtag == MediaServerEtag(incoming))
{ {
// skip scanning unavailable/file not found items that are unchanged and still don't exist locally // skip scanning unavailable/file not found items that are unchanged and still don't exist locally
if (!_localFileSystem.FileExists(localPath)) if (!_localFileSystem.FileExists(localPath) && !ServerSupportsRemoteStreaming)
{ {
return false; return false;
} }

32
ErsatzTV/Controllers/InternalController.cs

@ -1,5 +1,6 @@
using System.Diagnostics; using System.Diagnostics;
using CliWrap; using CliWrap;
using ErsatzTV.Application.Jellyfin;
using ErsatzTV.Application.Plex; using ErsatzTV.Application.Plex;
using ErsatzTV.Application.Streaming; using ErsatzTV.Application.Streaming;
using ErsatzTV.Core; using ErsatzTV.Core;
@ -17,15 +18,10 @@ public class InternalController : ControllerBase
{ {
private readonly ILogger<InternalController> _logger; private readonly ILogger<InternalController> _logger;
private readonly IMediator _mediator; private readonly IMediator _mediator;
private readonly IHttpClientFactory _httpClientFactory;
public InternalController( public InternalController(IMediator mediator, ILogger<InternalController> logger)
IMediator mediator,
IHttpClientFactory httpClientFactory,
ILogger<InternalController> logger)
{ {
_mediator = mediator; _mediator = mediator;
_httpClientFactory = httpClientFactory;
_logger = logger; _logger = logger;
} }
@ -89,7 +85,7 @@ public class InternalController : ControllerBase
)); ));
[HttpGet("/media/plex/{plexMediaSourceId:int}/{*path}")] [HttpGet("/media/plex/{plexMediaSourceId:int}/{*path}")]
public async Task<IActionResult> GetPlexFanArt( public async Task<IActionResult> GetPlexMedia(
int plexMediaSourceId, int plexMediaSourceId,
string path, string path,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@ -101,7 +97,27 @@ public class InternalController : ControllerBase
Left: _ => new NotFoundResult(), Left: _ => new NotFoundResult(),
Right: r => 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<IActionResult> GetJellyfinMedia(string path, CancellationToken cancellationToken)
{
Either<BaseError, JellyfinConnectionParametersViewModel> connectionParameters =
await _mediator.Send(new GetJellyfinConnectionParameters(), cancellationToken);
return connectionParameters.Match<IActionResult>(
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()); return new RedirectResult(fullPath.ToString());
}); });
} }

Loading…
Cancel
Save