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/). @@ -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

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

@ -453,34 +453,48 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -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<int> maybeId = await dbContext.Connection.QuerySingleOrDefaultAsync<int>(
@"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}");
}
case PlexMediaFile pmf:
Option<int> maybeId = await dbContext.Connection.QuerySingleOrDefaultAsync<int>(
@"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}");
}
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);

2
ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs

@ -86,7 +86,7 @@ public class FFmpegPlaybackSettingsCalculator @@ -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))

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

@ -40,4 +40,10 @@ public interface IJellyfinApiClient @@ -40,4 +40,10 @@ public interface IJellyfinApiClient
string parentId,
string includeItemTypes,
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 @@ -12,6 +12,6 @@ public interface IMediaServerMovieRepository<in TLibrary, TMovie, TEtag> where T
Task<Option<int>> FlagUnavailable(TLibrary library, TMovie movie);
Task<Option<int>> FlagRemoteOnly(TLibrary library, TMovie movie);
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);
}

8
ErsatzTV.Core/Jellyfin/JellyfinMediaStreamType.cs

@ -0,0 +1,8 @@ @@ -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( @@ -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( @@ -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;
}
}
}

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

@ -113,7 +113,10 @@ public class EmbyMovieRepository : IEmbyMovieRepository @@ -113,7 +113,10 @@ public class EmbyMovieRepository : IEmbyMovieRepository
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();
Option<EmbyMovie> maybeExisting = await dbContext.EmbyMovies
@ -146,7 +149,7 @@ public class EmbyMovieRepository : IEmbyMovieRepository @@ -146,7 +149,7 @@ public class EmbyMovieRepository : IEmbyMovieRepository
foreach (EmbyMovie embyMovie in maybeExisting)
{
var result = new MediaItemScanResult<EmbyMovie>(embyMovie) { IsAdded = false };
if (embyMovie.Etag != item.Etag)
if (embyMovie.Etag != item.Etag || deepScan)
{
await UpdateMovie(dbContext, embyMovie, item);
result.IsUpdated = true;

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

@ -116,7 +116,8 @@ public class JellyfinMovieRepository : IJellyfinMovieRepository @@ -116,7 +116,8 @@ public class JellyfinMovieRepository : IJellyfinMovieRepository
public async Task<Either<BaseError, MediaItemScanResult<JellyfinMovie>>> GetOrAdd(
JellyfinLibrary library,
JellyfinMovie item)
JellyfinMovie item,
bool deepScan)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<JellyfinMovie> maybeExisting = await dbContext.JellyfinMovies
@ -126,6 +127,8 @@ public class JellyfinMovieRepository : IJellyfinMovieRepository @@ -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 @@ -149,7 +152,7 @@ public class JellyfinMovieRepository : IJellyfinMovieRepository
foreach (JellyfinMovie jellyfinMovie in maybeExisting)
{
var result = new MediaItemScanResult<JellyfinMovie>(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 @@ -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();

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

@ -139,6 +139,8 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository @@ -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)

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

@ -113,7 +113,10 @@ public class PlexMovieRepository : IPlexMovieRepository @@ -113,7 +113,10 @@ public class PlexMovieRepository : IPlexMovieRepository
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();
Option<PlexMovie> maybeExisting = await dbContext.PlexMovies
@ -148,7 +151,7 @@ public class PlexMovieRepository : IPlexMovieRepository @@ -148,7 +151,7 @@ public class PlexMovieRepository : IPlexMovieRepository
foreach (PlexMovie plexMovie in maybeExisting)
{
var result = new MediaItemScanResult<PlexMovie>(plexMovie) { IsAdded = false };
if (plexMovie.Etag != item.Etag)
if (plexMovie.Etag != item.Etag || deepScan)
{
await UpdateMoviePath(dbContext, plexMovie, item);
result.IsUpdated = true;

12
ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs

@ -51,7 +51,7 @@ public interface IJellyfinApi @@ -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 @@ -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 @@ -158,4 +158,12 @@ public interface IJellyfinApi
int startIndex = 0,
[Query]
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 @@ @@ -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 @@ -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>(
string address,
string apiKey,
@ -387,10 +412,11 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -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<MediaFile>
{
@ -399,7 +425,8 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -399,7 +425,8 @@ public class JellyfinApiClient : IJellyfinApiClient
Path = path
}
},
Streams = new List<MediaStream>()
Streams = new List<MediaStream>(),
Chapters = ProjectToModel(Optional(item.Chapters).Flatten(), duration)
};
MovieMetadata metadata = ProjectToMovieMetadata(item);
@ -422,6 +449,29 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -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)
{
DateTime dateAdded = item.DateCreated.UtcDateTime;
@ -727,10 +777,11 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -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<MediaFile>
{
@ -739,7 +790,8 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -739,7 +790,8 @@ public class JellyfinApiClient : IJellyfinApiClient
Path = path
}
},
Streams = new List<MediaStream>()
Streams = new List<MediaStream>(),
Chapters = ProjectToModel(Optional(item.Chapters).Flatten(), duration)
};
EpisodeMetadata metadata = ProjectToEpisodeMetadata(item);
@ -833,4 +885,120 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -833,4 +885,120 @@ public class JellyfinApiClient : IJellyfinApiClient
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 @@ @@ -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 @@ -24,4 +24,5 @@ public class JellyfinLibraryItemResponse
public List<string> BackdropImageTags { get; set; }
public int? IndexNumber { get; set; }
public string Type { get; set; }
public IList<JellyfinChapterResponse> Chapters { get; set; }
}

7
ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinMediaSourceResponse.cs

@ -0,0 +1,7 @@ @@ -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 @@ -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; }
}

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

@ -0,0 +1,6 @@ @@ -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 : @@ -19,6 +19,7 @@ public class JellyfinMovieLibraryScanner :
private readonly IJellyfinApiClient _jellyfinApiClient;
private readonly IJellyfinMovieRepository _jellyfinMovieRepository;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly ILogger<JellyfinMovieLibraryScanner> _logger;
private readonly IJellyfinPathReplacementService _pathReplacementService;
public JellyfinMovieLibraryScanner(
@ -44,8 +45,11 @@ public class JellyfinMovieLibraryScanner : @@ -44,8 +45,11 @@ public class JellyfinMovieLibraryScanner :
_jellyfinMovieRepository = jellyfinMovieRepository;
_pathReplacementService = pathReplacementService;
_mediaSourceRepository = mediaSourceRepository;
_logger = logger;
}
protected override bool ServerSupportsRemoteStreaming => true;
public async Task<Either<BaseError, Unit>> ScanLibrary(
string address,
string apiKey,
@ -113,6 +117,35 @@ public class JellyfinMovieLibraryScanner : @@ -113,6 +117,35 @@ public class JellyfinMovieLibraryScanner :
JellyfinLibrary library,
MediaItemScanResult<JellyfinMovie> result,
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(
MediaItemScanResult<JellyfinMovie> result,

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

@ -20,6 +20,7 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan @@ -20,6 +20,7 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
private readonly IJellyfinApiClient _jellyfinApiClient;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IJellyfinPathReplacementService _pathReplacementService;
private readonly ILogger<JellyfinTelevisionLibraryScanner> _logger;
private readonly IJellyfinTelevisionRepository _televisionRepository;
public JellyfinTelevisionLibraryScanner(
@ -45,8 +46,11 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan @@ -45,8 +46,11 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
_mediaSourceRepository = mediaSourceRepository;
_televisionRepository = televisionRepository;
_pathReplacementService = pathReplacementService;
_logger = logger;
}
protected override bool ServerSupportsRemoteStreaming => true;
public async Task<Either<BaseError, Unit>> ScanLibrary(
string address,
string apiKey,
@ -177,6 +181,35 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan @@ -177,6 +181,35 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
MediaItemScanResult<JellyfinEpisode> result,
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(
MediaItemScanResult<JellyfinShow> result,
ShowMetadata fullMetadata) =>

11
ErsatzTV.Scanner/Core/Metadata/LocalStatisticsProvider.cs

@ -257,7 +257,16 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider @@ -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");

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

@ -133,7 +133,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib @@ -133,7 +133,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
if (ServerReturnsStatisticsWithMetadata)
{
maybeMovie = await movieRepository
.GetOrAdd(library, incoming)
.GetOrAdd(library, incoming, deepScan)
.MapT(
result =>
{
@ -152,7 +152,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib @@ -152,7 +152,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
else
{
maybeMovie = await movieRepository
.GetOrAdd(library, incoming)
.GetOrAdd(library, incoming, deepScan)
.MapT(
result =>
{
@ -308,7 +308,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib @@ -308,7 +308,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
existingEtag == MediaServerEtag(incoming))
{
// 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;
}

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

@ -591,7 +591,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters, @@ -591,7 +591,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
existingEtag == MediaServerEtag(incoming))
{
// 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;
}

32
ErsatzTV/Controllers/InternalController.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using System.Diagnostics;
using CliWrap;
using ErsatzTV.Application.Jellyfin;
using ErsatzTV.Application.Plex;
using ErsatzTV.Application.Streaming;
using ErsatzTV.Core;
@ -17,15 +18,10 @@ public class InternalController : ControllerBase @@ -17,15 +18,10 @@ public class InternalController : ControllerBase
{
private readonly ILogger<InternalController> _logger;
private readonly IMediator _mediator;
private readonly IHttpClientFactory _httpClientFactory;
public InternalController(
IMediator mediator,
IHttpClientFactory httpClientFactory,
ILogger<InternalController> logger)
public InternalController(IMediator mediator, ILogger<InternalController> logger)
{
_mediator = mediator;
_httpClientFactory = httpClientFactory;
_logger = logger;
}
@ -89,7 +85,7 @@ public class InternalController : ControllerBase @@ -89,7 +85,7 @@ public class InternalController : ControllerBase
));
[HttpGet("/media/plex/{plexMediaSourceId:int}/{*path}")]
public async Task<IActionResult> GetPlexFanArt(
public async Task<IActionResult> GetPlexMedia(
int plexMediaSourceId,
string path,
CancellationToken cancellationToken)
@ -101,7 +97,27 @@ public class InternalController : ControllerBase @@ -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<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());
});
}

Loading…
Cancel
Save