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
|
- 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
|
||||||
|
|||||||
+34
-20
@@ -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 })
|
|
||||||
.Map(Optional);
|
|
||||||
|
|
||||||
foreach (int plexMediaSourceId in maybeId)
|
foreach (int plexMediaSourceId in maybeId)
|
||||||
{
|
{
|
||||||
return new PlayoutItemWithPath(
|
return new PlayoutItemWithPath(
|
||||||
playoutItem,
|
playoutItem,
|
||||||
$"http://localhost:{Settings.ListenPort}/media/plex/{plexMediaSourceId}/{pmf.Key}");
|
$"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);
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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,
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user