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
This commit is contained in:
@@ -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
|
||||
|
||||
+34
-20
@@ -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);
|
||||
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}");
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
.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>(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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
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;
|
||||
|
||||
@@ -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<JellyfinPlaybackInfoResponse> GetPlaybackInfo(
|
||||
[Header("X-Emby-Token")]
|
||||
string apiKey,
|
||||
[Query]
|
||||
string userId,
|
||||
string itemId);
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Infrastructure.Jellyfin.Models;
|
||||
|
||||
public class JellyfinChapterResponse
|
||||
{
|
||||
public long StartPositionTicks { get; set; }
|
||||
public string Name { get; set; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Infrastructure.Jellyfin.Models;
|
||||
|
||||
public class JellyfinMediaSourceResponse
|
||||
{
|
||||
public long RunTimeTicks { get; set; }
|
||||
public IList<JellyfinMediaStreamResponse> MediaStreams { get; set; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Infrastructure.Jellyfin.Models;
|
||||
|
||||
public class JellyfinPlaybackInfoResponse
|
||||
{
|
||||
public IList<JellyfinMediaSourceResponse> MediaSources { get; set; }
|
||||
}
|
||||
@@ -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 :
|
||||
_jellyfinMovieRepository = jellyfinMovieRepository;
|
||||
_pathReplacementService = pathReplacementService;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override bool ServerSupportsRemoteStreaming => true;
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
string address,
|
||||
string apiKey,
|
||||
@@ -114,6 +118,35 @@ public class JellyfinMovieLibraryScanner :
|
||||
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,
|
||||
MovieMetadata fullMetadata) =>
|
||||
|
||||
@@ -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
|
||||
_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
|
||||
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) =>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
else
|
||||
{
|
||||
maybeMovie = await movieRepository
|
||||
.GetOrAdd(library, incoming)
|
||||
.GetOrAdd(library, incoming, deepScan)
|
||||
.MapT(
|
||||
result =>
|
||||
{
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
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
|
||||
));
|
||||
|
||||
[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
|
||||
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());
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user