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/). @@ -7,6 +7,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Fixed
- 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
### Added
- Add button to copy/clone schedule from schedules table

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

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

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

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

14
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -140,10 +140,22 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -140,10 +140,22 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
videoPath == audioPath ? playbackSettings.AudioDuration : Option<TimeSpan>.None,
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(
videoStream.Index,
videoStream.Codec,
AvailablePixelFormats.ForPixelFormat(videoStream.PixelFormat, _logger),
Some(pixelFormat),
new ColorParams(
videoStream.ColorRange,
videoStream.ColorSpace,

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

@ -10,6 +10,7 @@ public interface IMediaServerMovieRepository<in TLibrary, TMovie, TEtag> where T @@ -10,6 +10,7 @@ public interface IMediaServerMovieRepository<in TLibrary, TMovie, TEtag> where T
Task<List<TEtag>> GetExistingMovies(TLibrary library);
Task<bool> FlagNormal(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<Either<BaseError, MediaItemScanResult<TMovie>>> GetOrAdd(TLibrary library, TMovie item);
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 @@ -25,4 +25,5 @@ public interface IMediaServerTelevisionRepository<in TLibrary, TShow, TSeason, T
Task<List<int>> FlagFileNotFoundSeasons(TLibrary library, List<string> seasonItemIds);
Task<List<int>> FlagFileNotFoundEpisodes(TLibrary library, List<string> episodeItemIds);
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 @@ -12,7 +12,7 @@ public interface IMetadataRepository
Task<bool> RemoveActor(Actor actor);
Task<bool> Update(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> AddArtwork(Domain.Metadata metadata, Artwork artwork);
Task<Unit> RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind);

7
ErsatzTV.FFmpeg/Format/PixelFormatUnknown.cs

@ -2,7 +2,12 @@ @@ -2,7 +2,12 @@
public class PixelFormatUnknown : IPixelFormat
{
public PixelFormatUnknown(int bitDepth = 8)
{
BitDepth = bitDepth;
}
public string Name => "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 @@ -66,6 +66,28 @@ public class EmbyMovieRepository : IEmbyMovieRepository
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)
{
if (movieItemIds.Count == 0)

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

@ -338,6 +338,29 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository @@ -338,6 +338,29 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository
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)
{
// library path is used for search indexing later

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

@ -67,6 +67,28 @@ public class JellyfinMovieRepository : IJellyfinMovieRepository @@ -67,6 +67,28 @@ public class JellyfinMovieRepository : IJellyfinMovieRepository
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)
{
if (movieItemIds.Count == 0)

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

@ -342,6 +342,28 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository @@ -342,6 +342,28 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
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)
{
// library path is used for search indexing later

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

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

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

@ -66,6 +66,28 @@ public class PlexMovieRepository : IPlexMovieRepository @@ -66,6 +66,28 @@ public class PlexMovieRepository : IPlexMovieRepository
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)
{
if (plexMovieKeys.Count == 0)

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

@ -84,6 +84,29 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository @@ -84,6 +84,29 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository
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)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();

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

@ -19,6 +19,9 @@ public class PlexStreamResponse @@ -19,6 +19,9 @@ public class PlexStreamResponse
[XmlAttribute("languageCode")]
public string LanguageCode { get; set; }
[XmlAttribute("title")]
public string Title { get; set; }
[XmlAttribute("streamType")]
public int StreamType { get; set; }
@ -31,6 +34,12 @@ public class PlexStreamResponse @@ -31,6 +34,12 @@ public class PlexStreamResponse
[XmlAttribute("channels")]
public int Channels { get; set; }
[XmlAttribute("width")]
public int Width { get; set; }
[XmlAttribute("height")]
public int Height { get; set; }
[XmlAttribute("anamorphic")]
public bool Anamorphic { get; set; }
@ -40,6 +49,24 @@ public class PlexStreamResponse @@ -40,6 +49,24 @@ public class PlexStreamResponse
[XmlAttribute("scanType")]
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")]
public string DisplayTitle { get; set; }

35
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
using System.Xml.Serialization;
using System.Globalization;
using System.Xml.Serialization;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex;
@ -541,7 +541,8 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -541,7 +541,8 @@ public class PlexServerApiClient : IPlexServerApiClient
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;
Option<PlexStreamResponse> maybeVideoStream = streams.Find(s => s.StreamType == 1);
return maybeVideoStream.Map(
@ -549,7 +550,9 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -549,7 +550,9 @@ public class PlexServerApiClient : IPlexServerApiClient
{
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
{
"interlaced" => VideoScanKind.Interlaced,
@ -557,21 +560,34 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -557,21 +560,34 @@ public class PlexServerApiClient : IPlexServerApiClient
_ => VideoScanKind.Unknown
},
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(
new MediaStream
{
MediaVersionId = version.Id,
MediaStreamKind = MediaStreamKind.Video,
Index = videoStream.Index,
Codec = videoStream.Codec,
Profile = (videoStream.Profile ?? string.Empty).ToLowerInvariant(),
Default = videoStream.Default,
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))
{
var stream = new MediaStream
@ -584,9 +600,10 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -584,9 +600,10 @@ public class PlexServerApiClient : IPlexServerApiClient
Channels = audioStream.Channels,
Default = audioStream.Default,
Forced = audioStream.Forced,
Language = audioStream.LanguageCode
Language = audioStream.LanguageCode,
Title = audioStream.Title ?? string.Empty
};
version.Streams.Add(stream);
}

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

@ -283,7 +283,7 @@ public class TranscodingTests @@ -283,7 +283,7 @@ public class TranscodingTests
var metadataRepository = new Mock<IMetadataRepository>();
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>(
(_, version, _) =>
{

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

@ -28,6 +28,7 @@ public class EmbyMovieLibraryScanner : @@ -28,6 +28,7 @@ public class EmbyMovieLibraryScanner :
IEmbyMovieRepository embyMovieRepository,
IEmbyPathReplacementService pathReplacementService,
ILocalFileSystem localFileSystem,
IMetadataRepository metadataRepository,
ILocalStatisticsProvider localStatisticsProvider,
ILocalSubtitlesProvider localSubtitlesProvider,
ILogger<EmbyMovieLibraryScanner> logger)
@ -35,6 +36,7 @@ public class EmbyMovieLibraryScanner : @@ -35,6 +36,7 @@ public class EmbyMovieLibraryScanner :
localStatisticsProvider,
localSubtitlesProvider,
localFileSystem,
metadataRepository,
mediator,
logger)
{
@ -103,6 +105,12 @@ public class EmbyMovieLibraryScanner : @@ -103,6 +105,12 @@ public class EmbyMovieLibraryScanner :
bool deepScan) =>
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(
MediaItemScanResult<EmbyMovie> result,
MovieMetadata fullMetadata) =>

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

@ -27,6 +27,7 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner< @@ -27,6 +27,7 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
IEmbyTelevisionRepository televisionRepository,
IEmbyPathReplacementService pathReplacementService,
ILocalFileSystem localFileSystem,
IMetadataRepository metadataRepository,
ILocalStatisticsProvider localStatisticsProvider,
ILocalSubtitlesProvider localSubtitlesProvider,
IMediator mediator,
@ -35,6 +36,7 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner< @@ -35,6 +36,7 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
localStatisticsProvider,
localSubtitlesProvider,
localFileSystem,
metadataRepository,
mediator,
logger)
{
@ -163,6 +165,12 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner< @@ -163,6 +165,12 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
bool deepScan) =>
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(
MediaItemScanResult<EmbyShow> result,
ShowMetadata fullMetadata) =>

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

@ -28,6 +28,7 @@ public class JellyfinMovieLibraryScanner : @@ -28,6 +28,7 @@ public class JellyfinMovieLibraryScanner :
IJellyfinPathReplacementService pathReplacementService,
IMediaSourceRepository mediaSourceRepository,
ILocalFileSystem localFileSystem,
IMetadataRepository metadataRepository,
ILocalStatisticsProvider localStatisticsProvider,
ILocalSubtitlesProvider localSubtitlesProvider,
ILogger<JellyfinMovieLibraryScanner> logger)
@ -35,6 +36,7 @@ public class JellyfinMovieLibraryScanner : @@ -35,6 +36,7 @@ public class JellyfinMovieLibraryScanner :
localStatisticsProvider,
localSubtitlesProvider,
localFileSystem,
metadataRepository,
mediator,
logger)
{
@ -106,6 +108,12 @@ public class JellyfinMovieLibraryScanner : @@ -106,6 +108,12 @@ public class JellyfinMovieLibraryScanner :
bool deepScan) =>
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(
MediaItemScanResult<JellyfinMovie> result,
MovieMetadata fullMetadata) =>

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

@ -28,6 +28,7 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan @@ -28,6 +28,7 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
IJellyfinTelevisionRepository televisionRepository,
IJellyfinPathReplacementService pathReplacementService,
ILocalFileSystem localFileSystem,
IMetadataRepository metadataRepository,
ILocalStatisticsProvider localStatisticsProvider,
ILocalSubtitlesProvider localSubtitlesProvider,
IMediator mediator,
@ -36,6 +37,7 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan @@ -36,6 +37,7 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
localStatisticsProvider,
localSubtitlesProvider,
localFileSystem,
metadataRepository,
mediator,
logger)
{
@ -169,6 +171,12 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan @@ -169,6 +171,12 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
bool deepScan) =>
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(
MediaItemScanResult<JellyfinShow> result,
ShowMetadata fullMetadata) =>

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

@ -214,7 +214,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider @@ -214,7 +214,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
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)

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

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

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

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

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

@ -40,6 +40,7 @@ public class PlexMovieLibraryScanner : @@ -40,6 +40,7 @@ public class PlexMovieLibraryScanner :
localStatisticsProvider,
localSubtitlesProvider,
localFileSystem,
metadataRepository,
mediator,
logger)
{
@ -52,6 +53,9 @@ public class PlexMovieLibraryScanner : @@ -52,6 +53,9 @@ public class PlexMovieLibraryScanner :
_logger = logger;
}
protected override bool ServerSupportsRemoteStreaming => true;
protected override bool ServerReturnsStatisticsWithMetadata => true;
public async Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection,
PlexServerAuthToken token,
@ -129,6 +133,53 @@ public class PlexMovieLibraryScanner : @@ -129,6 +133,53 @@ public class PlexMovieLibraryScanner :
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(
MediaItemScanResult<PlexMovie> result,
MovieMetadata fullMetadata)
@ -136,11 +187,6 @@ public class PlexMovieLibraryScanner : @@ -136,11 +187,6 @@ public class PlexMovieLibraryScanner :
PlexMovie existing = result.Item;
MovieMetadata existingMetadata = existing.MovieMetadata.Head();
_logger.LogDebug(
"Refreshing {Attribute} for {Title}",
"Plex Metadata",
existingMetadata.Title);
if (existingMetadata.MetadataKind != MetadataKind.External)
{
existingMetadata.MetadataKind = MetadataKind.External;

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

@ -40,6 +40,7 @@ public class PlexTelevisionLibraryScanner : @@ -40,6 +40,7 @@ public class PlexTelevisionLibraryScanner :
localStatisticsProvider,
localSubtitlesProvider,
localFileSystem,
metadataRepository,
mediator,
logger)
{
@ -52,6 +53,9 @@ public class PlexTelevisionLibraryScanner : @@ -52,6 +53,9 @@ public class PlexTelevisionLibraryScanner :
_logger = logger;
}
protected override bool ServerSupportsRemoteStreaming => true;
protected override bool ServerReturnsStatisticsWithMetadata => true;
public async Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection,
PlexServerAuthToken token,
@ -259,6 +263,53 @@ public class PlexTelevisionLibraryScanner : @@ -259,6 +263,53 @@ public class PlexTelevisionLibraryScanner :
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(
MediaItemScanResult<PlexShow> result,
ShowMetadata fullMetadata)

8
ErsatzTV.sln

@ -33,8 +33,8 @@ Global @@ -33,8 +33,8 @@ Global
{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.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.Build.0 = 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 No Sync|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}.Release|Any CPU.ActiveCfg = Release|Any CPU
@ -79,10 +79,10 @@ Global @@ -79,10 +79,10 @@ Global
{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.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.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.Build.0 = Debug|Any CPU
{2EF80455-953D-4696-831D-E8CBCA82B0EF}.Release|Any CPU.ActiveCfg = Release|Any CPU

28
ErsatzTV/Controllers/InternalController.cs

@ -1,10 +1,13 @@ @@ -1,10 +1,13 @@
using System.Diagnostics;
using CliWrap;
using ErsatzTV.Application.Plex;
using ErsatzTV.Application.Streaming;
using ErsatzTV.Core;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Flurl;
namespace ErsatzTV.Controllers;
@ -14,10 +17,15 @@ public class InternalController : ControllerBase @@ -14,10 +17,15 @@ public class InternalController : ControllerBase
{
private readonly ILogger<InternalController> _logger;
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;
_httpClientFactory = httpClientFactory;
_logger = logger;
}
@ -79,4 +87,22 @@ public class InternalController : ControllerBase @@ -79,4 +87,22 @@ public class InternalController : ControllerBase
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