Browse Source

direct stream content from plex if needed (#1165)

* start to stream directly from plex

* update metadata and statistics with one plex api call

* stream movies from plex

* scanning bug fix; update changelog
pull/1166/head
Jason Dove 4 years ago committed by GitHub
parent
commit
62c28d9f51
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      CHANGELOG.md
  2. 36
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  3. 3
      ErsatzTV.Core/Domain/MediaItem/MediaItemState.cs
  4. 14
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  5. 1
      ErsatzTV.Core/Interfaces/Repositories/IMediaServerMovieRepository.cs
  6. 1
      ErsatzTV.Core/Interfaces/Repositories/IMediaServerTelevisionRepository.cs
  7. 2
      ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs
  8. 7
      ErsatzTV.FFmpeg/Format/PixelFormatUnknown.cs
  9. 22
      ErsatzTV.Infrastructure/Data/Repositories/EmbyMovieRepository.cs
  10. 23
      ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs
  11. 22
      ErsatzTV.Infrastructure/Data/Repositories/JellyfinMovieRepository.cs
  12. 22
      ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs
  13. 2
      ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs
  14. 22
      ErsatzTV.Infrastructure/Data/Repositories/PlexMovieRepository.cs
  15. 23
      ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs
  16. 27
      ErsatzTV.Infrastructure/Plex/Models/PlexStreamResponse.cs
  17. 35
      ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs
  18. 2
      ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs
  19. 8
      ErsatzTV.Scanner/Core/Emby/EmbyMovieLibraryScanner.cs
  20. 8
      ErsatzTV.Scanner/Core/Emby/EmbyTelevisionLibraryScanner.cs
  21. 8
      ErsatzTV.Scanner/Core/Jellyfin/JellyfinMovieLibraryScanner.cs
  22. 8
      ErsatzTV.Scanner/Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs
  23. 2
      ErsatzTV.Scanner/Core/Metadata/LocalStatisticsProvider.cs
  24. 198
      ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs
  25. 199
      ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs
  26. 56
      ErsatzTV.Scanner/Core/Plex/PlexMovieLibraryScanner.cs
  27. 51
      ErsatzTV.Scanner/Core/Plex/PlexTelevisionLibraryScanner.cs
  28. 8
      ErsatzTV.sln
  29. 28
      ErsatzTV/Controllers/InternalController.cs

6
CHANGELOG.md

@ -7,6 +7,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Fixed ### Fixed
- Align default docker image (no acceleration) with new images from [ErsatzTV-ffmpeg](https://github.com/jasongdove/ErsatzTV-ffmpeg) - Align default docker image (no acceleration) with new images from [ErsatzTV-ffmpeg](https://github.com/jasongdove/ErsatzTV-ffmpeg)
### Changed
- Plex libraries now retrieve all metadata and statistics from Plex; ffprobe is no longer used
- 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
## [0.7.4-beta] - 2023-02-12 ## [0.7.4-beta] - 2023-02-12
### Added ### Added
- Add button to copy/clone schedule from schedules table - Add button to copy/clone schedule from schedules table

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

@ -1,4 +1,5 @@
using CliWrap; using CliWrap;
using Dapper;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Domain.Filler;
@ -126,7 +127,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.Include(i => i.Watermark) .Include(i => i.Watermark)
.ForChannelAndTime(channel.Id, now) .ForChannelAndTime(channel.Id, now)
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem())) .Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
.BindT(ValidatePlayoutItemPath); .BindT(item => ValidatePlayoutItemPath(dbContext, item));
if (maybePlayoutItem.LeftAsEnumerable().Any(e => e is UnableToLocatePlayoutItem)) if (maybePlayoutItem.LeftAsEnumerable().Any(e => e is UnableToLocatePlayoutItem))
{ {
@ -440,13 +441,15 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
DisableWatermarks = !fallbackPreset.AllowWatermarks DisableWatermarks = !fallbackPreset.AllowWatermarks
}; };
return await ValidatePlayoutItemPath(playoutItem); return await ValidatePlayoutItemPath(dbContext, playoutItem);
} }
return new UnableToLocatePlayoutItem(); return new UnableToLocatePlayoutItem();
} }
private async Task<Either<BaseError, PlayoutItemWithPath>> ValidatePlayoutItemPath(PlayoutItem playoutItem) private async Task<Either<BaseError, PlayoutItemWithPath>> ValidatePlayoutItemPath(
TvContext dbContext,
PlayoutItem playoutItem)
{ {
string path = await GetPlayoutItemPath(playoutItem); string path = await GetPlayoutItemPath(playoutItem);
@ -455,14 +458,39 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
return new PlayoutItemWithPath(playoutItem, path); return new PlayoutItemWithPath(playoutItem, path);
} }
if (playoutItem.MediaItem.State == MediaItemState.RemoteOnly)
{
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}");
}
break;
}
}
return new PlayoutItemDoesNotExistOnDisk(path); return new PlayoutItemDoesNotExistOnDisk(path);
} }
private async Task<string> GetPlayoutItemPath(PlayoutItem playoutItem) private async Task<string> GetPlayoutItemPath(PlayoutItem playoutItem)
{ {
MediaVersion version = playoutItem.MediaItem.GetHeadVersion(); MediaVersion version = playoutItem.MediaItem.GetHeadVersion();
MediaFile file = version.MediaFiles.Head(); MediaFile file = version.MediaFiles.Head();
string path = file.Path; string path = file.Path;
return playoutItem.MediaItem switch return playoutItem.MediaItem switch
{ {

3
ErsatzTV.Core/Domain/MediaItem/MediaItemState.cs

@ -4,5 +4,6 @@ public enum MediaItemState
{ {
Normal = 0, Normal = 0,
FileNotFound = 1, FileNotFound = 1,
Unavailable = 2 Unavailable = 2,
RemoteOnly = 3
} }

14
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -140,10 +140,22 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
videoPath == audioPath ? playbackSettings.AudioDuration : Option<TimeSpan>.None, videoPath == audioPath ? playbackSettings.AudioDuration : Option<TimeSpan>.None,
playbackSettings.NormalizeLoudness); playbackSettings.NormalizeLoudness);
IPixelFormat pixelFormat = await AvailablePixelFormats.ForPixelFormat(videoStream.PixelFormat, _logger)
.IfNoneAsync(
() =>
{
return videoStream.BitsPerRawSample switch
{
8 => new PixelFormatYuv420P(),
10 => new PixelFormatYuv420P10Le(),
_ => new PixelFormatUnknown(videoStream.BitsPerRawSample)
};
});
var ffmpegVideoStream = new VideoStream( var ffmpegVideoStream = new VideoStream(
videoStream.Index, videoStream.Index,
videoStream.Codec, videoStream.Codec,
AvailablePixelFormats.ForPixelFormat(videoStream.PixelFormat, _logger), Some(pixelFormat),
new ColorParams( new ColorParams(
videoStream.ColorRange, videoStream.ColorRange,
videoStream.ColorSpace, videoStream.ColorSpace,

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

@ -10,6 +10,7 @@ public interface IMediaServerMovieRepository<in TLibrary, TMovie, TEtag> where T
Task<List<TEtag>> GetExistingMovies(TLibrary library); Task<List<TEtag>> GetExistingMovies(TLibrary library);
Task<bool> FlagNormal(TLibrary library, TMovie movie); Task<bool> FlagNormal(TLibrary library, TMovie movie);
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<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);
Task<Unit> SetEtag(TMovie movie, string etag); Task<Unit> SetEtag(TMovie movie, string etag);

1
ErsatzTV.Core/Interfaces/Repositories/IMediaServerTelevisionRepository.cs

@ -25,4 +25,5 @@ public interface IMediaServerTelevisionRepository<in TLibrary, TShow, TSeason, T
Task<List<int>> FlagFileNotFoundSeasons(TLibrary library, List<string> seasonItemIds); Task<List<int>> FlagFileNotFoundSeasons(TLibrary library, List<string> seasonItemIds);
Task<List<int>> FlagFileNotFoundEpisodes(TLibrary library, List<string> episodeItemIds); Task<List<int>> FlagFileNotFoundEpisodes(TLibrary library, List<string> episodeItemIds);
Task<Option<int>> FlagUnavailable(TLibrary library, TEpisode episode); Task<Option<int>> FlagUnavailable(TLibrary library, TEpisode episode);
Task<Option<int>> FlagRemoteOnly(TLibrary library, TEpisode episode);
} }

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

@ -12,7 +12,7 @@ public interface IMetadataRepository
Task<bool> RemoveActor(Actor actor); Task<bool> RemoveActor(Actor actor);
Task<bool> Update(Domain.Metadata metadata); Task<bool> Update(Domain.Metadata metadata);
Task<bool> Add(Domain.Metadata metadata); Task<bool> Add(Domain.Metadata metadata);
Task<bool> UpdateLocalStatistics(MediaItem mediaItem, MediaVersion incoming, bool updateVersion = true); Task<bool> UpdateStatistics(MediaItem mediaItem, MediaVersion incoming, bool updateVersion = true);
Task<Unit> UpdateArtworkPath(Artwork artwork); Task<Unit> UpdateArtworkPath(Artwork artwork);
Task<Unit> AddArtwork(Domain.Metadata metadata, Artwork artwork); Task<Unit> AddArtwork(Domain.Metadata metadata, Artwork artwork);
Task<Unit> RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind); Task<Unit> RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind);

7
ErsatzTV.FFmpeg/Format/PixelFormatUnknown.cs

@ -2,7 +2,12 @@
public class PixelFormatUnknown : IPixelFormat public class PixelFormatUnknown : IPixelFormat
{ {
public PixelFormatUnknown(int bitDepth = 8)
{
BitDepth = bitDepth;
}
public string Name => "unknown"; public string Name => "unknown";
public string FFmpegName => "unknown"; public string FFmpegName => "unknown";
public int BitDepth => 8; public int BitDepth { get; }
} }

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

@ -66,6 +66,28 @@ public class EmbyMovieRepository : IEmbyMovieRepository
return None; return None;
} }
public async Task<Option<int>> FlagRemoteOnly(EmbyLibrary library, EmbyMovie movie)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
movie.State = MediaItemState.RemoteOnly;
Option<int> maybeId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT EmbyMovie.Id FROM EmbyMovie
INNER JOIN MediaItem MI ON MI.Id = EmbyMovie.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE EmbyMovie.ItemId = @ItemId",
new { LibraryId = library.Id, movie.ItemId });
foreach (int id in maybeId)
{
return await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 2 WHERE Id = @Id",
new { Id = id }).Map(count => count > 0 ? Some(id) : None);
}
return None; }
public async Task<List<int>> FlagFileNotFound(EmbyLibrary library, List<string> movieItemIds) public async Task<List<int>> FlagFileNotFound(EmbyLibrary library, List<string> movieItemIds)
{ {
if (movieItemIds.Count == 0) if (movieItemIds.Count == 0)

23
ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs

@ -338,6 +338,29 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository
return None; return None;
} }
public async Task<Option<int>> FlagRemoteOnly(EmbyLibrary library, EmbyEpisode episode)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
episode.State = MediaItemState.RemoteOnly;
Option<int> maybeId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT EmbyEpisode.Id FROM EmbyEpisode
INNER JOIN MediaItem MI ON MI.Id = EmbyEpisode.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE EmbyEpisode.ItemId = @ItemId",
new { LibraryId = library.Id, episode.ItemId });
foreach (int id in maybeId)
{
return await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 3 WHERE Id = @Id",
new { Id = id }).Map(count => count > 0 ? Some(id) : None);
}
return None;
}
private async Task UpdateShow(TvContext dbContext, EmbyShow existing, EmbyShow incoming) private async Task UpdateShow(TvContext dbContext, EmbyShow existing, EmbyShow incoming)
{ {
// library path is used for search indexing later // library path is used for search indexing later

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

@ -67,6 +67,28 @@ public class JellyfinMovieRepository : IJellyfinMovieRepository
return None; return None;
} }
public async Task<Option<int>> FlagRemoteOnly(JellyfinLibrary library, JellyfinMovie movie)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
movie.State = MediaItemState.RemoteOnly;
Option<int> maybeId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT JellyfinMovie.Id FROM JellyfinMovie
INNER JOIN MediaItem MI ON MI.Id = JellyfinMovie.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE JellyfinMovie.ItemId = @ItemId",
new { LibraryId = library.Id, movie.ItemId });
foreach (int id in maybeId)
{
return await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 3 WHERE Id = @Id",
new { Id = id }).Map(count => count > 0 ? Some(id) : None);
}
return None; }
public async Task<List<int>> FlagFileNotFound(JellyfinLibrary library, List<string> movieItemIds) public async Task<List<int>> FlagFileNotFound(JellyfinLibrary library, List<string> movieItemIds)
{ {
if (movieItemIds.Count == 0) if (movieItemIds.Count == 0)

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

@ -342,6 +342,28 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
return None; return None;
} }
public async Task<Option<int>> FlagRemoteOnly(JellyfinLibrary library, JellyfinEpisode episode)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
episode.State = MediaItemState.RemoteOnly;
Option<int> maybeId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT JellyfinEpisode.Id FROM JellyfinEpisode
INNER JOIN MediaItem MI ON MI.Id = JellyfinEpisode.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE JellyfinEpisode.ItemId = @ItemId",
new { LibraryId = library.Id, episode.ItemId });
foreach (int id in maybeId)
{
return await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 3 WHERE Id = @Id",
new { Id = id }).Map(count => count > 0 ? Some(id) : None);
}
return None; }
private async Task UpdateShow(TvContext dbContext, JellyfinShow existing, JellyfinShow incoming) private async Task UpdateShow(TvContext dbContext, JellyfinShow existing, JellyfinShow incoming)
{ {
// library path is used for search indexing later // library path is used for search indexing later

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

@ -119,7 +119,7 @@ public class MetadataRepository : IMetadataRepository
return await dbContext.SaveChangesAsync() > 0; return await dbContext.SaveChangesAsync() > 0;
} }
public async Task<bool> UpdateLocalStatistics( public async Task<bool> UpdateStatistics(
MediaItem mediaItem, MediaItem mediaItem,
MediaVersion incoming, MediaVersion incoming,
bool updateVersion = true) bool updateVersion = true)

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

@ -66,6 +66,28 @@ public class PlexMovieRepository : IPlexMovieRepository
return None; return None;
} }
public async Task<Option<int>> FlagRemoteOnly(PlexLibrary library, PlexMovie movie)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
movie.State = MediaItemState.RemoteOnly;
Option<int> maybeId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT PlexMovie.Id FROM PlexMovie
INNER JOIN MediaItem MI ON MI.Id = PlexMovie.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE PlexMovie.Key = @Key",
new { LibraryId = library.Id, movie.Key });
foreach (int id in maybeId)
{
return await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 3 WHERE Id = @Id",
new { Id = id }).Map(count => count > 0 ? Some(id) : None);
}
return None;
}
public async Task<List<int>> FlagFileNotFound(PlexLibrary library, List<string> plexMovieKeys) public async Task<List<int>> FlagFileNotFound(PlexLibrary library, List<string> plexMovieKeys)
{ {
if (plexMovieKeys.Count == 0) if (plexMovieKeys.Count == 0)

23
ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs

@ -84,6 +84,29 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository
return None; return None;
} }
public async Task<Option<int>> FlagRemoteOnly(PlexLibrary library, PlexEpisode episode)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
episode.State = MediaItemState.RemoteOnly;
Option<int> maybeId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT PlexEpisode.Id FROM PlexEpisode
INNER JOIN MediaItem MI ON MI.Id = PlexEpisode.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE PlexEpisode.Key = @Key",
new { LibraryId = library.Id, episode.Key });
foreach (int id in maybeId)
{
return await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 3 WHERE Id = @Id",
new { Id = id }).Map(count => count > 0 ? Some(id) : None);
}
return None;
}
public async Task<List<PlexItemEtag>> GetExistingShows(PlexLibrary library) public async Task<List<PlexItemEtag>> GetExistingShows(PlexLibrary library)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();

27
ErsatzTV.Infrastructure/Plex/Models/PlexStreamResponse.cs

@ -19,6 +19,9 @@ public class PlexStreamResponse
[XmlAttribute("languageCode")] [XmlAttribute("languageCode")]
public string LanguageCode { get; set; } public string LanguageCode { get; set; }
[XmlAttribute("title")]
public string Title { get; set; }
[XmlAttribute("streamType")] [XmlAttribute("streamType")]
public int StreamType { get; set; } public int StreamType { get; set; }
@ -31,6 +34,12 @@ public class PlexStreamResponse
[XmlAttribute("channels")] [XmlAttribute("channels")]
public int Channels { get; set; } public int Channels { get; set; }
[XmlAttribute("width")]
public int Width { get; set; }
[XmlAttribute("height")]
public int Height { get; set; }
[XmlAttribute("anamorphic")] [XmlAttribute("anamorphic")]
public bool Anamorphic { get; set; } public bool Anamorphic { get; set; }
@ -40,6 +49,24 @@ public class PlexStreamResponse
[XmlAttribute("scanType")] [XmlAttribute("scanType")]
public string ScanType { get; set; } public string ScanType { get; set; }
[XmlAttribute("frameRate")]
public string FrameRate { get; set; }
[XmlAttribute("bitDepth")]
public int BitDepth { get; set; }
[XmlAttribute("colorRange")]
public string ColorRange { get; set; }
[XmlAttribute("colorSpace")]
public string ColorSpace { get; set; }
[XmlAttribute("colorTrc")]
public string ColorTrc { get; set; }
[XmlAttribute("colorPrimaries")]
public string ColorPrimaries { get; set; }
[XmlAttribute("displayTitle")] [XmlAttribute("displayTitle")]
public string DisplayTitle { get; set; } public string DisplayTitle { get; set; }

35
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

@ -1,7 +1,7 @@
using System.Xml.Serialization; using System.Globalization;
using System.Xml.Serialization;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex; using ErsatzTV.Core.Plex;
@ -541,7 +541,8 @@ public class PlexServerApiClient : IPlexServerApiClient
private Option<MediaVersion> ProjectToMediaVersion(PlexXmlMetadataResponse response) private Option<MediaVersion> ProjectToMediaVersion(PlexXmlMetadataResponse response)
{ {
List<PlexStreamResponse> streams = response.Media.Head().Part.Head().Stream; PlexMediaResponse<PlexXmlPartResponse> media = response.Media.Head();
List<PlexStreamResponse> streams = media.Part.Head().Stream;
DateTime dateUpdated = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime; DateTime dateUpdated = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime;
Option<PlexStreamResponse> maybeVideoStream = streams.Find(s => s.StreamType == 1); Option<PlexStreamResponse> maybeVideoStream = streams.Find(s => s.StreamType == 1);
return maybeVideoStream.Map( return maybeVideoStream.Map(
@ -549,7 +550,9 @@ public class PlexServerApiClient : IPlexServerApiClient
{ {
var version = new MediaVersion var version = new MediaVersion
{ {
SampleAspectRatio = videoStream.PixelAspectRatio ?? "1:1", Duration = TimeSpan.FromMilliseconds(media.Duration),
SampleAspectRatio = string.IsNullOrWhiteSpace(videoStream.PixelAspectRatio) ? "1:1"
: videoStream.PixelAspectRatio,
VideoScanKind = videoStream.ScanType switch VideoScanKind = videoStream.ScanType switch
{ {
"interlaced" => VideoScanKind.Interlaced, "interlaced" => VideoScanKind.Interlaced,
@ -557,21 +560,34 @@ public class PlexServerApiClient : IPlexServerApiClient
_ => VideoScanKind.Unknown _ => VideoScanKind.Unknown
}, },
Streams = new List<MediaStream>(), Streams = new List<MediaStream>(),
DateUpdated = dateUpdated DateUpdated = dateUpdated,
Width = videoStream.Width,
Height = videoStream.Height,
RFrameRate = videoStream.FrameRate,
DisplayAspectRatio = media.AspectRatio == 0
? string.Empty
: media.AspectRatio.ToString("0.00###", CultureInfo.InvariantCulture),
Chapters = new List<MediaChapter>() // TODO: `?includeChapters=1`
}; };
version.Streams.Add( version.Streams.Add(
new MediaStream new MediaStream
{ {
MediaVersionId = version.Id,
MediaStreamKind = MediaStreamKind.Video, MediaStreamKind = MediaStreamKind.Video,
Index = videoStream.Index, Index = videoStream.Index,
Codec = videoStream.Codec, Codec = videoStream.Codec,
Profile = (videoStream.Profile ?? string.Empty).ToLowerInvariant(), Profile = (videoStream.Profile ?? string.Empty).ToLowerInvariant(),
Default = videoStream.Default, Default = videoStream.Default,
Language = videoStream.LanguageCode, Language = videoStream.LanguageCode,
Forced = videoStream.Forced Forced = videoStream.Forced,
BitsPerRawSample = videoStream.BitDepth,
ColorRange = (videoStream.ColorRange ?? string.Empty).ToLowerInvariant(),
ColorSpace = (videoStream.ColorSpace ?? string.Empty).ToLowerInvariant(),
ColorTransfer = (videoStream.ColorTrc ?? string.Empty).ToLowerInvariant(),
ColorPrimaries = (videoStream.ColorPrimaries ?? string.Empty).ToLowerInvariant()
}); });
foreach (PlexStreamResponse audioStream in streams.Filter(s => s.StreamType == 2)) foreach (PlexStreamResponse audioStream in streams.Filter(s => s.StreamType == 2))
{ {
var stream = new MediaStream var stream = new MediaStream
@ -584,9 +600,10 @@ public class PlexServerApiClient : IPlexServerApiClient
Channels = audioStream.Channels, Channels = audioStream.Channels,
Default = audioStream.Default, Default = audioStream.Default,
Forced = audioStream.Forced, Forced = audioStream.Forced,
Language = audioStream.LanguageCode Language = audioStream.LanguageCode,
Title = audioStream.Title ?? string.Empty
}; };
version.Streams.Add(stream); version.Streams.Add(stream);
} }

2
ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs

@ -283,7 +283,7 @@ public class TranscodingTests
var metadataRepository = new Mock<IMetadataRepository>(); var metadataRepository = new Mock<IMetadataRepository>();
metadataRepository metadataRepository
.Setup(r => r.UpdateLocalStatistics(It.IsAny<MediaItem>(), It.IsAny<MediaVersion>(), It.IsAny<bool>())) .Setup(r => r.UpdateStatistics(It.IsAny<MediaItem>(), It.IsAny<MediaVersion>(), It.IsAny<bool>()))
.Callback<MediaItem, MediaVersion, bool>( .Callback<MediaItem, MediaVersion, bool>(
(_, version, _) => (_, version, _) =>
{ {

8
ErsatzTV.Scanner/Core/Emby/EmbyMovieLibraryScanner.cs

@ -28,6 +28,7 @@ public class EmbyMovieLibraryScanner :
IEmbyMovieRepository embyMovieRepository, IEmbyMovieRepository embyMovieRepository,
IEmbyPathReplacementService pathReplacementService, IEmbyPathReplacementService pathReplacementService,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
IMetadataRepository metadataRepository,
ILocalStatisticsProvider localStatisticsProvider, ILocalStatisticsProvider localStatisticsProvider,
ILocalSubtitlesProvider localSubtitlesProvider, ILocalSubtitlesProvider localSubtitlesProvider,
ILogger<EmbyMovieLibraryScanner> logger) ILogger<EmbyMovieLibraryScanner> logger)
@ -35,6 +36,7 @@ public class EmbyMovieLibraryScanner :
localStatisticsProvider, localStatisticsProvider,
localSubtitlesProvider, localSubtitlesProvider,
localFileSystem, localFileSystem,
metadataRepository,
mediator, mediator,
logger) logger)
{ {
@ -103,6 +105,12 @@ public class EmbyMovieLibraryScanner :
bool deepScan) => bool deepScan) =>
Task.FromResult<Option<MovieMetadata>>(None); Task.FromResult<Option<MovieMetadata>>(None);
protected override Task<Option<Tuple<MovieMetadata, MediaVersion>>> GetFullMetadataAndStatistics(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library,
MediaItemScanResult<EmbyMovie> result,
EmbyMovie incoming) => Task.FromResult(Option<Tuple<MovieMetadata, MediaVersion>>.None);
protected override Task<Either<BaseError, MediaItemScanResult<EmbyMovie>>> UpdateMetadata( protected override Task<Either<BaseError, MediaItemScanResult<EmbyMovie>>> UpdateMetadata(
MediaItemScanResult<EmbyMovie> result, MediaItemScanResult<EmbyMovie> result,
MovieMetadata fullMetadata) => MovieMetadata fullMetadata) =>

8
ErsatzTV.Scanner/Core/Emby/EmbyTelevisionLibraryScanner.cs

@ -27,6 +27,7 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
IEmbyTelevisionRepository televisionRepository, IEmbyTelevisionRepository televisionRepository,
IEmbyPathReplacementService pathReplacementService, IEmbyPathReplacementService pathReplacementService,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
IMetadataRepository metadataRepository,
ILocalStatisticsProvider localStatisticsProvider, ILocalStatisticsProvider localStatisticsProvider,
ILocalSubtitlesProvider localSubtitlesProvider, ILocalSubtitlesProvider localSubtitlesProvider,
IMediator mediator, IMediator mediator,
@ -35,6 +36,7 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
localStatisticsProvider, localStatisticsProvider,
localSubtitlesProvider, localSubtitlesProvider,
localFileSystem, localFileSystem,
metadataRepository,
mediator, mediator,
logger) logger)
{ {
@ -163,6 +165,12 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
bool deepScan) => bool deepScan) =>
Task.FromResult(Option<EpisodeMetadata>.None); Task.FromResult(Option<EpisodeMetadata>.None);
protected override Task<Option<Tuple<EpisodeMetadata, MediaVersion>>> GetFullMetadataAndStatistics(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library,
MediaItemScanResult<EmbyEpisode> result,
EmbyEpisode incoming) => Task.FromResult(Option<Tuple<EpisodeMetadata, MediaVersion>>.None);
protected override Task<Either<BaseError, MediaItemScanResult<EmbyShow>>> UpdateMetadata( protected override Task<Either<BaseError, MediaItemScanResult<EmbyShow>>> UpdateMetadata(
MediaItemScanResult<EmbyShow> result, MediaItemScanResult<EmbyShow> result,
ShowMetadata fullMetadata) => ShowMetadata fullMetadata) =>

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

@ -28,6 +28,7 @@ public class JellyfinMovieLibraryScanner :
IJellyfinPathReplacementService pathReplacementService, IJellyfinPathReplacementService pathReplacementService,
IMediaSourceRepository mediaSourceRepository, IMediaSourceRepository mediaSourceRepository,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
IMetadataRepository metadataRepository,
ILocalStatisticsProvider localStatisticsProvider, ILocalStatisticsProvider localStatisticsProvider,
ILocalSubtitlesProvider localSubtitlesProvider, ILocalSubtitlesProvider localSubtitlesProvider,
ILogger<JellyfinMovieLibraryScanner> logger) ILogger<JellyfinMovieLibraryScanner> logger)
@ -35,6 +36,7 @@ public class JellyfinMovieLibraryScanner :
localStatisticsProvider, localStatisticsProvider,
localSubtitlesProvider, localSubtitlesProvider,
localFileSystem, localFileSystem,
metadataRepository,
mediator, mediator,
logger) logger)
{ {
@ -106,6 +108,12 @@ public class JellyfinMovieLibraryScanner :
bool deepScan) => bool deepScan) =>
Task.FromResult<Option<MovieMetadata>>(None); Task.FromResult<Option<MovieMetadata>>(None);
protected override Task<Option<Tuple<MovieMetadata, MediaVersion>>> GetFullMetadataAndStatistics(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library,
MediaItemScanResult<JellyfinMovie> result,
JellyfinMovie incoming) => Task.FromResult(Option<Tuple<MovieMetadata, MediaVersion>>.None);
protected override Task<Either<BaseError, MediaItemScanResult<JellyfinMovie>>> UpdateMetadata( protected override Task<Either<BaseError, MediaItemScanResult<JellyfinMovie>>> UpdateMetadata(
MediaItemScanResult<JellyfinMovie> result, MediaItemScanResult<JellyfinMovie> result,
MovieMetadata fullMetadata) => MovieMetadata fullMetadata) =>

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

@ -28,6 +28,7 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
IJellyfinTelevisionRepository televisionRepository, IJellyfinTelevisionRepository televisionRepository,
IJellyfinPathReplacementService pathReplacementService, IJellyfinPathReplacementService pathReplacementService,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
IMetadataRepository metadataRepository,
ILocalStatisticsProvider localStatisticsProvider, ILocalStatisticsProvider localStatisticsProvider,
ILocalSubtitlesProvider localSubtitlesProvider, ILocalSubtitlesProvider localSubtitlesProvider,
IMediator mediator, IMediator mediator,
@ -36,6 +37,7 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
localStatisticsProvider, localStatisticsProvider,
localSubtitlesProvider, localSubtitlesProvider,
localFileSystem, localFileSystem,
metadataRepository,
mediator, mediator,
logger) logger)
{ {
@ -169,6 +171,12 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
bool deepScan) => bool deepScan) =>
Task.FromResult(Option<EpisodeMetadata>.None); Task.FromResult(Option<EpisodeMetadata>.None);
protected override Task<Option<Tuple<EpisodeMetadata, MediaVersion>>> GetFullMetadataAndStatistics(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library,
MediaItemScanResult<JellyfinEpisode> result,
JellyfinEpisode incoming) => Task.FromResult(Option<Tuple<EpisodeMetadata, MediaVersion>>.None);
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) =>

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

@ -214,7 +214,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
version.DateUpdated = _localFileSystem.GetLastWriteTime(filePath); version.DateUpdated = _localFileSystem.GetLastWriteTime(filePath);
return await _metadataRepository.UpdateLocalStatistics(mediaItem, version) && durationChange; return await _metadataRepository.UpdateStatistics(mediaItem, version) && durationChange;
} }
private async Task<Either<BaseError, FFprobe>> GetProbeOutput(string ffprobePath, string filePath) private async Task<Either<BaseError, FFprobe>> GetProbeOutput(string ffprobePath, string filePath)

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

@ -19,6 +19,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
where TEtag : MediaServerItemEtag where TEtag : MediaServerItemEtag
{ {
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly IMetadataRepository _metadataRepository;
private readonly ILocalStatisticsProvider _localStatisticsProvider; private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILocalSubtitlesProvider _localSubtitlesProvider; private readonly ILocalSubtitlesProvider _localSubtitlesProvider;
private readonly ILogger _logger; private readonly ILogger _logger;
@ -28,15 +29,20 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
ILocalStatisticsProvider localStatisticsProvider, ILocalStatisticsProvider localStatisticsProvider,
ILocalSubtitlesProvider localSubtitlesProvider, ILocalSubtitlesProvider localSubtitlesProvider,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
IMetadataRepository metadataRepository,
IMediator mediator, IMediator mediator,
ILogger logger) ILogger logger)
{ {
_localStatisticsProvider = localStatisticsProvider; _localStatisticsProvider = localStatisticsProvider;
_localSubtitlesProvider = localSubtitlesProvider; _localSubtitlesProvider = localSubtitlesProvider;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_metadataRepository = metadataRepository;
_mediator = mediator; _mediator = mediator;
_logger = logger; _logger = logger;
} }
protected virtual bool ServerSupportsRemoteStreaming => false;
protected virtual bool ServerReturnsStatisticsWithMetadata => false;
protected async Task<Either<BaseError, Unit>> ScanLibrary( protected async Task<Either<BaseError, Unit>> ScanLibrary(
IMediaServerMovieRepository<TLibrary, TMovie, TEtag> movieRepository, IMediaServerMovieRepository<TLibrary, TMovie, TEtag> movieRepository,
@ -121,18 +127,51 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
{ {
continue; continue;
} }
Either<BaseError, MediaItemScanResult<TMovie>> maybeMovie;
Either<BaseError, MediaItemScanResult<TMovie>> maybeMovie = await movieRepository if (ServerReturnsStatisticsWithMetadata)
.GetOrAdd(library, incoming) {
.MapT( maybeMovie = await movieRepository
result => .GetOrAdd(library, incoming)
{ .MapT(
result.LocalPath = localPath; result =>
return result; {
}) result.LocalPath = localPath;
.BindT(existing => UpdateMetadata(connectionParameters, library, existing, incoming, deepScan)) return result;
.BindT(existing => UpdateStatistics(existing, incoming, ffmpegPath, ffprobePath)) })
.BindT(UpdateSubtitles); .BindT(
existing => UpdateMetadataAndStatistics(
connectionParameters,
library,
existing,
incoming,
deepScan))
.BindT(UpdateSubtitles);
}
else
{
maybeMovie = await movieRepository
.GetOrAdd(library, incoming)
.MapT(
result =>
{
result.LocalPath = localPath;
return result;
})
.BindT(
existing => UpdateMetadata(connectionParameters, library, existing, incoming, deepScan, None))
.BindT(
existing => UpdateStatistics(
connectionParameters,
library,
existing,
incoming,
ffmpegPath,
ffprobePath,
None))
.BindT(UpdateSubtitles);
}
if (maybeMovie.IsLeft) if (maybeMovie.IsLeft)
{ {
@ -158,6 +197,14 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
result.IsUpdated = true; result.IsUpdated = true;
} }
} }
else if (ServerSupportsRemoteStreaming)
{
Option<int> flagResult = await movieRepository.FlagRemoteOnly(library, result.Item);
if (flagResult.IsSome)
{
result.IsUpdated = true;
}
}
else else
{ {
Option<int> flagResult = await movieRepository.FlagUnavailable(library, result.Item); Option<int> flagResult = await movieRepository.FlagUnavailable(library, result.Item);
@ -218,6 +265,18 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
TMovie incoming, TMovie incoming,
bool deepScan); bool deepScan);
protected virtual Task<Option<MediaVersion>> GetMediaServerStatistics(
TConnectionParameters connectionParameters,
TLibrary library,
MediaItemScanResult<TMovie> result,
TMovie incoming) => Task.FromResult(Option<MediaVersion>.None);
protected abstract Task<Option<Tuple<MovieMetadata, MediaVersion>>> GetFullMetadataAndStatistics(
TConnectionParameters connectionParameters,
TLibrary library,
MediaItemScanResult<TMovie> result,
TMovie incoming);
protected abstract Task<Either<BaseError, MediaItemScanResult<TMovie>>> UpdateMetadata( protected abstract Task<Either<BaseError, MediaItemScanResult<TMovie>>> UpdateMetadata(
MediaItemScanResult<TMovie> result, MediaItemScanResult<TMovie> result,
MovieMetadata fullMetadata); MovieMetadata fullMetadata);
@ -259,11 +318,23 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
// don't scan, but mark as unavailable // don't scan, but mark as unavailable
if (!_localFileSystem.FileExists(localPath)) if (!_localFileSystem.FileExists(localPath))
{ {
foreach (int id in await movieRepository.FlagUnavailable(library, incoming)) if (ServerSupportsRemoteStreaming)
{ {
await _mediator.Publish( foreach (int id in await movieRepository.FlagRemoteOnly(library, incoming))
new ScannerProgressUpdate(library.Id, null, null, new[] { id }, Array.Empty<int>()), {
CancellationToken.None); await _mediator.Publish(
new ScannerProgressUpdate(library.Id, null, null, new[] { id }, Array.Empty<int>()),
CancellationToken.None);
}
}
else
{
foreach (int id in await movieRepository.FlagUnavailable(library, incoming))
{
await _mediator.Publish(
new ScannerProgressUpdate(library.Id, null, null, new[] { id }, Array.Empty<int>()),
CancellationToken.None);
}
} }
} }
@ -282,19 +353,76 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
return true; return true;
} }
private async Task<Either<BaseError, MediaItemScanResult<TMovie>>> UpdateMetadata( private async Task<Either<BaseError, MediaItemScanResult<TMovie>>> UpdateMetadataAndStatistics(
TConnectionParameters connectionParameters, TConnectionParameters connectionParameters,
TLibrary library, TLibrary library,
MediaItemScanResult<TMovie> result, MediaItemScanResult<TMovie> result,
TMovie incoming, TMovie incoming,
bool deepScan) bool deepScan)
{ {
foreach (MovieMetadata fullMetadata in await GetFullMetadata( Option<Tuple<MovieMetadata, MediaVersion>> maybeMetadataAndStatistics = await GetFullMetadataAndStatistics(
connectionParameters, connectionParameters,
library, library,
result, result,
incoming, incoming);
deepScan))
foreach ((MovieMetadata fullMetadata, MediaVersion mediaVersion) in maybeMetadataAndStatistics)
{
Either<BaseError, MediaItemScanResult<TMovie>> metadataResult = await UpdateMetadata(
connectionParameters,
library,
result,
incoming,
deepScan,
fullMetadata);
foreach (BaseError error in metadataResult.LeftToSeq())
{
return error;
}
foreach (MediaItemScanResult<TMovie> r in metadataResult.RightToSeq())
{
result = r;
}
Either<BaseError, MediaItemScanResult<TMovie>> statisticsResult = await UpdateStatistics(
connectionParameters,
library,
result,
incoming,
string.Empty,
string.Empty,
mediaVersion);
foreach (BaseError error in statisticsResult.LeftToSeq())
{
return error;
}
foreach (MediaItemScanResult<TMovie> r in metadataResult.RightToSeq())
{
result = r;
}
}
return result;
}
private async Task<Either<BaseError, MediaItemScanResult<TMovie>>> UpdateMetadata(
TConnectionParameters connectionParameters,
TLibrary library,
MediaItemScanResult<TMovie> result,
TMovie incoming,
bool deepScan,
Option<MovieMetadata> maybeFullMetadata)
{
if (maybeFullMetadata.IsNone)
{
maybeFullMetadata = await GetFullMetadata(connectionParameters, library, result, incoming, deepScan);
}
foreach (MovieMetadata fullMetadata in maybeFullMetadata)
{ {
// TODO: move some of this code into this scanner // TODO: move some of this code into this scanner
// will have to merge JF, Emby, Plex logic // will have to merge JF, Emby, Plex logic
@ -305,17 +433,20 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
} }
private async Task<Either<BaseError, MediaItemScanResult<TMovie>>> UpdateStatistics( private async Task<Either<BaseError, MediaItemScanResult<TMovie>>> UpdateStatistics(
TConnectionParameters connectionParameters,
TLibrary library,
MediaItemScanResult<TMovie> result, MediaItemScanResult<TMovie> result,
TMovie incoming, TMovie incoming,
string ffmpegPath, string ffmpegPath,
string ffprobePath) string ffprobePath,
Option<MediaVersion> maybeMediaVersion)
{ {
TMovie existing = result.Item; TMovie existing = result.Item;
if (result.IsAdded || MediaServerEtag(existing) != MediaServerEtag(incoming) || if (result.IsAdded || MediaServerEtag(existing) != MediaServerEtag(incoming) ||
existing.MediaVersions.Head().Streams.Count == 0) existing.MediaVersions.Head().Streams.Count == 0)
{ {
if (_localFileSystem.FileExists(result.LocalPath)) if (maybeMediaVersion.IsNone && _localFileSystem.FileExists(result.LocalPath))
{ {
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", result.LocalPath); _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", result.LocalPath);
Either<BaseError, bool> refreshResult = Either<BaseError, bool> refreshResult =
@ -339,6 +470,25 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
result.IsUpdated = true; result.IsUpdated = true;
} }
} }
else
{
if (maybeMediaVersion.IsNone)
{
maybeMediaVersion = await GetMediaServerStatistics(
connectionParameters,
library,
result,
incoming);
}
foreach (MediaVersion mediaVersion in maybeMediaVersion)
{
if (await _metadataRepository.UpdateStatistics(result.Item, mediaVersion))
{
result.IsUpdated = true;
}
}
}
} }
return result; return result;

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

@ -21,6 +21,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
where TEtag : MediaServerItemEtag where TEtag : MediaServerItemEtag
{ {
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly IMetadataRepository _metadataRepository;
private readonly ILocalStatisticsProvider _localStatisticsProvider; private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILocalSubtitlesProvider _localSubtitlesProvider; private readonly ILocalSubtitlesProvider _localSubtitlesProvider;
private readonly ILogger _logger; private readonly ILogger _logger;
@ -30,16 +31,21 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
ILocalStatisticsProvider localStatisticsProvider, ILocalStatisticsProvider localStatisticsProvider,
ILocalSubtitlesProvider localSubtitlesProvider, ILocalSubtitlesProvider localSubtitlesProvider,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
IMetadataRepository metadataRepository,
IMediator mediator, IMediator mediator,
ILogger logger) ILogger logger)
{ {
_localStatisticsProvider = localStatisticsProvider; _localStatisticsProvider = localStatisticsProvider;
_localSubtitlesProvider = localSubtitlesProvider; _localSubtitlesProvider = localSubtitlesProvider;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_metadataRepository = metadataRepository;
_mediator = mediator; _mediator = mediator;
_logger = logger; _logger = logger;
} }
protected virtual bool ServerSupportsRemoteStreaming => false;
protected virtual bool ServerReturnsStatisticsWithMetadata => false;
protected async Task<Either<BaseError, Unit>> ScanLibrary( protected async Task<Either<BaseError, Unit>> ScanLibrary(
IMediaServerTelevisionRepository<TLibrary, TShow, TSeason, TEpisode, TEtag> televisionRepository, IMediaServerTelevisionRepository<TLibrary, TShow, TSeason, TEpisode, TEtag> televisionRepository,
TConnectionParameters connectionParameters, TConnectionParameters connectionParameters,
@ -268,6 +274,18 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
TEpisode incoming, TEpisode incoming,
bool deepScan); bool deepScan);
protected virtual Task<Option<MediaVersion>> GetMediaServerStatistics(
TConnectionParameters connectionParameters,
TLibrary library,
MediaItemScanResult<TEpisode> result,
TEpisode incoming) => Task.FromResult(Option<MediaVersion>.None);
protected abstract Task<Option<Tuple<EpisodeMetadata, MediaVersion>>> GetFullMetadataAndStatistics(
TConnectionParameters connectionParameters,
TLibrary library,
MediaItemScanResult<TEpisode> result,
TEpisode incoming);
protected abstract Task<Either<BaseError, MediaItemScanResult<TShow>>> UpdateMetadata( protected abstract Task<Either<BaseError, MediaItemScanResult<TShow>>> UpdateMetadata(
MediaItemScanResult<TShow> result, MediaItemScanResult<TShow> result,
ShowMetadata fullMetadata); ShowMetadata fullMetadata);
@ -279,7 +297,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
protected abstract Task<Either<BaseError, MediaItemScanResult<TEpisode>>> UpdateMetadata( protected abstract Task<Either<BaseError, MediaItemScanResult<TEpisode>>> UpdateMetadata(
MediaItemScanResult<TEpisode> result, MediaItemScanResult<TEpisode> result,
EpisodeMetadata fullMetadata); EpisodeMetadata fullMetadata);
private async Task<Either<BaseError, Unit>> ScanSeasons( private async Task<Either<BaseError, Unit>> ScanSeasons(
IMediaServerTelevisionRepository<TLibrary, TShow, TSeason, TEpisode, TEtag> televisionRepository, IMediaServerTelevisionRepository<TLibrary, TShow, TSeason, TEpisode, TEtag> televisionRepository,
TLibrary library, TLibrary library,
@ -436,17 +454,50 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
incoming.SeasonId = season.Id; incoming.SeasonId = season.Id;
Either<BaseError, MediaItemScanResult<TEpisode>> maybeEpisode = await televisionRepository Either<BaseError, MediaItemScanResult<TEpisode>> maybeEpisode;
.GetOrAdd(library, incoming, deepScan)
.MapT( if (ServerReturnsStatisticsWithMetadata)
result => {
{ maybeEpisode = await televisionRepository
result.LocalPath = localPath; .GetOrAdd(library, incoming, deepScan)
return result; .MapT(
}) result =>
.BindT(existing => UpdateMetadata(connectionParameters, library, existing, incoming, deepScan)) {
.BindT(existing => UpdateStatistics(existing, incoming, ffmpegPath, ffprobePath)) result.LocalPath = localPath;
.BindT(UpdateSubtitles); return result;
})
.BindT(
existing => UpdateMetadataAndStatistics(
connectionParameters,
library,
existing,
incoming,
deepScan))
.BindT(UpdateSubtitles);
}
else
{
maybeEpisode = await televisionRepository
.GetOrAdd(library, incoming, deepScan)
.MapT(
result =>
{
result.LocalPath = localPath;
return result;
})
.BindT(
existing => UpdateMetadata(connectionParameters, library, existing, incoming, deepScan, None))
.BindT(
existing => UpdateStatistics(
connectionParameters,
library,
existing,
incoming,
ffmpegPath,
ffprobePath,
None))
.BindT(UpdateSubtitles);
}
if (maybeEpisode.IsLeft) if (maybeEpisode.IsLeft)
{ {
@ -474,6 +525,14 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
result.IsUpdated = true; result.IsUpdated = true;
} }
} }
else if (ServerSupportsRemoteStreaming)
{
Option<int> flagResult = await televisionRepository.FlagRemoteOnly(library, result.Item);
if (flagResult.IsSome)
{
result.IsUpdated = true;
}
}
else else
{ {
Option<int> flagResult = await televisionRepository.FlagUnavailable(library, result.Item); Option<int> flagResult = await televisionRepository.FlagUnavailable(library, result.Item);
@ -542,11 +601,22 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
// don't scan, but mark as unavailable // don't scan, but mark as unavailable
if (!_localFileSystem.FileExists(localPath)) if (!_localFileSystem.FileExists(localPath))
{ {
foreach (int id in await televisionRepository.FlagUnavailable(library, incoming)) if (ServerSupportsRemoteStreaming) {
foreach (int id in await televisionRepository.FlagRemoteOnly(library, incoming))
{
await _mediator.Publish(
new ScannerProgressUpdate(library.Id, null, null, new[] { id }, Array.Empty<int>()),
CancellationToken.None);
}
}
else
{ {
await _mediator.Publish( foreach (int id in await televisionRepository.FlagUnavailable(library, incoming))
new ScannerProgressUpdate(library.Id, null, null, new[] { id }, Array.Empty<int>()), {
CancellationToken.None); await _mediator.Publish(
new ScannerProgressUpdate(library.Id, null, null, new[] { id }, Array.Empty<int>()),
CancellationToken.None);
}
} }
} }
@ -617,19 +687,76 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
return result; return result;
} }
private async Task<Either<BaseError, MediaItemScanResult<TEpisode>>> UpdateMetadata( private async Task<Either<BaseError, MediaItemScanResult<TEpisode>>> UpdateMetadataAndStatistics(
TConnectionParameters connectionParameters, TConnectionParameters connectionParameters,
TLibrary library, TLibrary library,
MediaItemScanResult<TEpisode> result, MediaItemScanResult<TEpisode> result,
TEpisode incoming, TEpisode incoming,
bool deepScan) bool deepScan)
{ {
foreach (EpisodeMetadata fullMetadata in await GetFullMetadata( Option<Tuple<EpisodeMetadata, MediaVersion>> maybeMetadataAndStatistics = await GetFullMetadataAndStatistics(
connectionParameters, connectionParameters,
library, library,
result, result,
incoming, incoming);
deepScan))
foreach ((EpisodeMetadata fullMetadata, MediaVersion mediaVersion) in maybeMetadataAndStatistics)
{
Either<BaseError, MediaItemScanResult<TEpisode>> metadataResult = await UpdateMetadata(
connectionParameters,
library,
result,
incoming,
deepScan,
fullMetadata);
foreach (BaseError error in metadataResult.LeftToSeq())
{
return error;
}
foreach (MediaItemScanResult<TEpisode> r in metadataResult.RightToSeq())
{
result = r;
}
Either<BaseError, MediaItemScanResult<TEpisode>> statisticsResult = await UpdateStatistics(
connectionParameters,
library,
result,
incoming,
string.Empty,
string.Empty,
mediaVersion);
foreach (BaseError error in statisticsResult.LeftToSeq())
{
return error;
}
foreach (MediaItemScanResult<TEpisode> r in metadataResult.RightToSeq())
{
result = r;
}
}
return result;
}
private async Task<Either<BaseError, MediaItemScanResult<TEpisode>>> UpdateMetadata(
TConnectionParameters connectionParameters,
TLibrary library,
MediaItemScanResult<TEpisode> result,
TEpisode incoming,
bool deepScan,
Option<EpisodeMetadata> maybeFullMetadata)
{
if (maybeFullMetadata.IsNone)
{
maybeFullMetadata = await GetFullMetadata(connectionParameters, library, result, incoming, deepScan);
}
foreach (EpisodeMetadata fullMetadata in maybeFullMetadata)
{ {
// TODO: move some of this code into this scanner // TODO: move some of this code into this scanner
// will have to merge JF, Emby, Plex logic // will have to merge JF, Emby, Plex logic
@ -640,17 +767,20 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
} }
private async Task<Either<BaseError, MediaItemScanResult<TEpisode>>> UpdateStatistics( private async Task<Either<BaseError, MediaItemScanResult<TEpisode>>> UpdateStatistics(
TConnectionParameters connectionParameters,
TLibrary library,
MediaItemScanResult<TEpisode> result, MediaItemScanResult<TEpisode> result,
TEpisode incoming, TEpisode incoming,
string ffmpegPath, string ffmpegPath,
string ffprobePath) string ffprobePath,
Option<MediaVersion> maybeMediaVersion)
{ {
TEpisode existing = result.Item; TEpisode existing = result.Item;
if (result.IsAdded || MediaServerEtag(existing) != MediaServerEtag(incoming) || if (result.IsAdded || MediaServerEtag(existing) != MediaServerEtag(incoming) ||
existing.MediaVersions.Head().Streams.Count == 0) existing.MediaVersions.Head().Streams.Count == 0)
{ {
if (_localFileSystem.FileExists(result.LocalPath)) if (maybeMediaVersion.IsNone && _localFileSystem.FileExists(result.LocalPath))
{ {
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", result.LocalPath); _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", result.LocalPath);
Either<BaseError, bool> refreshResult = Either<BaseError, bool> refreshResult =
@ -674,6 +804,25 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
result.IsUpdated = true; result.IsUpdated = true;
} }
} }
else
{
if (maybeMediaVersion.IsNone)
{
maybeMediaVersion = await GetMediaServerStatistics(
connectionParameters,
library,
result,
incoming);
}
foreach (MediaVersion mediaVersion in maybeMediaVersion)
{
if (await _metadataRepository.UpdateStatistics(result.Item, mediaVersion))
{
result.IsUpdated = true;
}
}
}
} }
return result; return result;

56
ErsatzTV.Scanner/Core/Plex/PlexMovieLibraryScanner.cs

@ -40,6 +40,7 @@ public class PlexMovieLibraryScanner :
localStatisticsProvider, localStatisticsProvider,
localSubtitlesProvider, localSubtitlesProvider,
localFileSystem, localFileSystem,
metadataRepository,
mediator, mediator,
logger) logger)
{ {
@ -52,6 +53,9 @@ public class PlexMovieLibraryScanner :
_logger = logger; _logger = logger;
} }
protected override bool ServerSupportsRemoteStreaming => true;
protected override bool ServerReturnsStatisticsWithMetadata => true;
public async Task<Either<BaseError, Unit>> ScanLibrary( public async Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection, PlexConnection connection,
PlexServerAuthToken token, PlexServerAuthToken token,
@ -129,6 +133,53 @@ public class PlexMovieLibraryScanner :
return None; return None;
} }
protected override async Task<Option<MediaVersion>> GetMediaServerStatistics(
PlexConnectionParameters connectionParameters,
PlexLibrary library,
MediaItemScanResult<PlexMovie> result,
PlexMovie incoming)
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Plex Statistics", result.LocalPath);
Either<BaseError, MediaVersion> maybeVersion =
await _plexServerApiClient.GetMovieMetadataAndStatistics(
library,
incoming.Key.Split("/").Last(),
connectionParameters.Connection,
connectionParameters.Token)
.MapT(tuple => tuple.Item2); // drop the metadata part
foreach (BaseError error in maybeVersion.LeftToSeq())
{
_logger.LogWarning("Failed to get movie statistics from Plex: {Error}", error.ToString());
}
return maybeVersion.ToOption();
}
protected override async Task<Option<Tuple<MovieMetadata, MediaVersion>>> GetFullMetadataAndStatistics(
PlexConnectionParameters connectionParameters,
PlexLibrary library,
MediaItemScanResult<PlexMovie> result,
PlexMovie incoming)
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Plex Metadata and Statistics", result.LocalPath);
Either<BaseError, Tuple<MovieMetadata, MediaVersion>> maybeResult =
await _plexServerApiClient.GetMovieMetadataAndStatistics(
library,
incoming.Key.Split("/").Last(),
connectionParameters.Connection,
connectionParameters.Token);
foreach (BaseError error in maybeResult.LeftToSeq())
{
_logger.LogWarning("Failed to get movie metadata and statistics from Plex: {Error}", error.ToString());
}
return maybeResult.ToOption();
}
protected override async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateMetadata( protected override async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateMetadata(
MediaItemScanResult<PlexMovie> result, MediaItemScanResult<PlexMovie> result,
MovieMetadata fullMetadata) MovieMetadata fullMetadata)
@ -136,11 +187,6 @@ public class PlexMovieLibraryScanner :
PlexMovie existing = result.Item; PlexMovie existing = result.Item;
MovieMetadata existingMetadata = existing.MovieMetadata.Head(); MovieMetadata existingMetadata = existing.MovieMetadata.Head();
_logger.LogDebug(
"Refreshing {Attribute} for {Title}",
"Plex Metadata",
existingMetadata.Title);
if (existingMetadata.MetadataKind != MetadataKind.External) if (existingMetadata.MetadataKind != MetadataKind.External)
{ {
existingMetadata.MetadataKind = MetadataKind.External; existingMetadata.MetadataKind = MetadataKind.External;

51
ErsatzTV.Scanner/Core/Plex/PlexTelevisionLibraryScanner.cs

@ -40,6 +40,7 @@ public class PlexTelevisionLibraryScanner :
localStatisticsProvider, localStatisticsProvider,
localSubtitlesProvider, localSubtitlesProvider,
localFileSystem, localFileSystem,
metadataRepository,
mediator, mediator,
logger) logger)
{ {
@ -52,6 +53,9 @@ public class PlexTelevisionLibraryScanner :
_logger = logger; _logger = logger;
} }
protected override bool ServerSupportsRemoteStreaming => true;
protected override bool ServerReturnsStatisticsWithMetadata => true;
public async Task<Either<BaseError, Unit>> ScanLibrary( public async Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection, PlexConnection connection,
PlexServerAuthToken token, PlexServerAuthToken token,
@ -259,6 +263,53 @@ public class PlexTelevisionLibraryScanner :
return None; return None;
} }
protected override async Task<Option<MediaVersion>> GetMediaServerStatistics(
PlexConnectionParameters connectionParameters,
PlexLibrary library,
MediaItemScanResult<PlexEpisode> result,
PlexEpisode incoming)
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Plex Statistics", result.LocalPath);
Either<BaseError, MediaVersion> maybeVersion =
await _plexServerApiClient.GetEpisodeMetadataAndStatistics(
library,
incoming.Key.Split("/").Last(),
connectionParameters.Connection,
connectionParameters.Token)
.MapT(tuple => tuple.Item2); // drop the metadata part
foreach (BaseError error in maybeVersion.LeftToSeq())
{
_logger.LogWarning("Failed to get episode statistics from Plex: {Error}", error.ToString());
}
return maybeVersion.ToOption();
}
protected override async Task<Option<Tuple<EpisodeMetadata, MediaVersion>>> GetFullMetadataAndStatistics(
PlexConnectionParameters connectionParameters,
PlexLibrary library,
MediaItemScanResult<PlexEpisode> result,
PlexEpisode incoming)
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Plex Metadata and Statistics", result.LocalPath);
Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>> maybeResult =
await _plexServerApiClient.GetEpisodeMetadataAndStatistics(
library,
incoming.Key.Split("/").Last(),
connectionParameters.Connection,
connectionParameters.Token);
foreach (BaseError error in maybeResult.LeftToSeq())
{
_logger.LogWarning("Failed to get episode metadata and statistics from Plex: {Error}", error.ToString());
}
return maybeResult.ToOption();
}
protected override async Task<Either<BaseError, MediaItemScanResult<PlexShow>>> UpdateMetadata( protected override async Task<Either<BaseError, MediaItemScanResult<PlexShow>>> UpdateMetadata(
MediaItemScanResult<PlexShow> result, MediaItemScanResult<PlexShow> result,
ShowMetadata fullMetadata) ShowMetadata fullMetadata)

8
ErsatzTV.sln

@ -33,8 +33,8 @@ Global
{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Release|Any CPU.Build.0 = Release|Any CPU {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Release|Any CPU.Build.0 = Release|Any CPU
{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug|Any CPU.Build.0 = Debug|Any CPU {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug No Sync|Any CPU.ActiveCfg = Debug No Sync|Any CPU
{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug No Sync|Any CPU.Build.0 = Debug No Sync|Any CPU
{C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Debug|Any CPU.Build.0 = Debug|Any CPU {C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Release|Any CPU.ActiveCfg = Release|Any CPU {C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Release|Any CPU.ActiveCfg = Release|Any CPU
@ -79,10 +79,10 @@ Global
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU {591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Release|Any CPU.ActiveCfg = Release|Any CPU {5664D574-2B8B-41C1-B091-8D3E887AE24E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Release|Any CPU.Build.0 = Release|Any CPU {5664D574-2B8B-41C1-B091-8D3E887AE24E}.Release|Any CPU.Build.0 = Release|Any CPU
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug|Any CPU.Build.0 = Debug|Any CPU {5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug No Sync|Any CPU.ActiveCfg = Debug No Sync|Any CPU
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug No Sync|Any CPU.Build.0 = Debug No Sync|Any CPU
{2EF80455-953D-4696-831D-E8CBCA82B0EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2EF80455-953D-4696-831D-E8CBCA82B0EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2EF80455-953D-4696-831D-E8CBCA82B0EF}.Debug|Any CPU.Build.0 = Debug|Any CPU {2EF80455-953D-4696-831D-E8CBCA82B0EF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2EF80455-953D-4696-831D-E8CBCA82B0EF}.Release|Any CPU.ActiveCfg = Release|Any CPU {2EF80455-953D-4696-831D-E8CBCA82B0EF}.Release|Any CPU.ActiveCfg = Release|Any CPU

28
ErsatzTV/Controllers/InternalController.cs

@ -1,10 +1,13 @@
using System.Diagnostics; using System.Diagnostics;
using CliWrap; using CliWrap;
using ErsatzTV.Application.Plex;
using ErsatzTV.Application.Streaming; using ErsatzTV.Application.Streaming;
using ErsatzTV.Core;
using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Extensions; using ErsatzTV.Extensions;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Flurl;
namespace ErsatzTV.Controllers; namespace ErsatzTV.Controllers;
@ -14,10 +17,15 @@ 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(IMediator mediator, ILogger<InternalController> logger) public InternalController(
IMediator mediator,
IHttpClientFactory httpClientFactory,
ILogger<InternalController> logger)
{ {
_mediator = mediator; _mediator = mediator;
_httpClientFactory = httpClientFactory;
_logger = logger; _logger = logger;
} }
@ -79,4 +87,22 @@ public class InternalController : ControllerBase
return BadRequest(error.Value); return BadRequest(error.Value);
} }
)); ));
[HttpGet("/media/plex/{plexMediaSourceId:int}/{*path}")]
public async Task<IActionResult> GetPlexFanArt(
int plexMediaSourceId,
string path,
CancellationToken cancellationToken)
{
Either<BaseError, PlexConnectionParametersViewModel> connectionParameters =
await _mediator.Send(new GetPlexConnectionParameters(plexMediaSourceId), cancellationToken);
return connectionParameters.Match<IActionResult>(
Left: _ => new NotFoundResult(),
Right: r =>
{
Url fullPath = new Uri(r.Uri, path).SetQueryParam("X-Plex-Token", r.AuthToken);
return new RedirectResult(fullPath.ToString());
});
}
} }

Loading…
Cancel
Save