Browse Source

enable plex for television (#73)

* add plex show, season sync

* sync plex episodes

* sync plex episode statistics

* update plex artwork as needed

* code cleanup

* add note about tests
pull/74/head
Jason Dove 4 years ago committed by GitHub
parent
commit
9ba0cbd84f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs
  2. 10
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  3. 11
      ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs
  4. 7
      ErsatzTV.Core/Domain/MediaItem/PlexEpisode.cs
  5. 7
      ErsatzTV.Core/Domain/MediaItem/PlexSeason.cs
  6. 7
      ErsatzTV.Core/Domain/MediaItem/PlexShow.cs
  7. 5
      ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs
  8. 21
      ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs
  9. 15
      ErsatzTV.Core/Interfaces/Plex/IPlexTelevisionLibraryScanner.cs
  10. 15
      ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs
  11. 4
      ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs
  12. 58
      ErsatzTV.Core/Plex/PlexLibraryScanner.cs
  13. 51
      ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs
  14. 281
      ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs
  15. 2
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  16. 11
      ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexEpisodeConfiguration.cs
  17. 11
      ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexSeasonConfiguration.cs
  18. 11
      ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexShowConfiguration.cs
  19. 28
      ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs
  20. 27
      ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs
  21. 78
      ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs
  22. 121
      ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs
  23. 3
      ErsatzTV.Infrastructure/Data/TvContext.cs
  24. 1686
      ErsatzTV.Infrastructure/Migrations/20210314023600_Add_PlexTelevision.Designer.cs
  25. 79
      ErsatzTV.Infrastructure/Migrations/20210314023600_Add_PlexTelevision.cs
  26. 69
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  27. 7
      ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs
  28. 1
      ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs
  29. 244
      ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs
  30. 56
      ErsatzTV/Controllers/ArtworkController.cs
  31. 2
      ErsatzTV/Startup.cs

9
ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs

@ -24,17 +24,20 @@ namespace ErsatzTV.Application.Plex.Commands @@ -24,17 +24,20 @@ namespace ErsatzTV.Application.Plex.Commands
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IPlexMovieLibraryScanner _plexMovieLibraryScanner;
private readonly IPlexSecretStore _plexSecretStore;
private readonly IPlexTelevisionLibraryScanner _plexTelevisionLibraryScanner;
public SynchronizePlexLibraryByIdHandler(
IMediaSourceRepository mediaSourceRepository,
IPlexSecretStore plexSecretStore,
IPlexMovieLibraryScanner plexMovieLibraryScanner,
IPlexTelevisionLibraryScanner plexTelevisionLibraryScanner,
IEntityLocker entityLocker,
ILogger<SynchronizePlexLibraryByIdHandler> logger)
{
_mediaSourceRepository = mediaSourceRepository;
_plexSecretStore = plexSecretStore;
_plexMovieLibraryScanner = plexMovieLibraryScanner;
_plexTelevisionLibraryScanner = plexTelevisionLibraryScanner;
_entityLocker = entityLocker;
_logger = logger;
}
@ -67,8 +70,10 @@ namespace ErsatzTV.Application.Plex.Commands @@ -67,8 +70,10 @@ namespace ErsatzTV.Application.Plex.Commands
parameters.Library);
break;
case LibraryMediaKind.Shows:
// TODO: plex tv scanner
// await _televisionFolderScanner.ScanFolder(parameters.LocalMediaSource, parameters.FFprobePath);
await _plexTelevisionLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection,
parameters.ConnectionParameters.PlexServerAuthToken,
parameters.Library);
break;
}

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

@ -158,12 +158,12 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -158,12 +158,12 @@ namespace ErsatzTV.Application.Streaming.Queries
MediaFile file = version.MediaFiles.Head();
string path = file.Path;
if (playoutItem.MediaItem is PlexMovie plexMovie)
return playoutItem.MediaItem switch
{
path = await GetReplacementPlexPath(plexMovie.LibraryPathId, path);
}
return path;
PlexMovie plexMovie => await GetReplacementPlexPath(plexMovie.LibraryPathId, path),
PlexEpisode plexEpisode => await GetReplacementPlexPath(plexEpisode.LibraryPathId, path),
_ => path
};
}
private async Task<string> GetReplacementPlexPath(int libraryPathId, string path)

11
ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs

@ -66,5 +66,16 @@ namespace ErsatzTV.Core.Tests.Fakes @@ -66,5 +66,16 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath) => throw new NotSupportedException();
public Task<Unit> DeleteEmptyShows(LibraryPath libraryPath) => throw new NotSupportedException();
public Task<Either<BaseError, PlexShow>> GetOrAddPlexShow(PlexLibrary library, PlexShow item) =>
throw new NotSupportedException();
public Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item) =>
throw new NotSupportedException();
public Task<Either<BaseError, PlexEpisode>> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item) =>
throw new NotSupportedException();
public Task<Unit> AddGenre(ShowMetadata metadata, Genre genre) => throw new NotSupportedException();
}
}

7
ErsatzTV.Core/Domain/MediaItem/PlexEpisode.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain
{
public class PlexEpisode : Episode
{
public string Key { get; set; }
}
}

7
ErsatzTV.Core/Domain/MediaItem/PlexSeason.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain
{
public class PlexSeason : Season
{
public string Key { get; set; }
}
}

7
ErsatzTV.Core/Domain/MediaItem/PlexShow.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain
{
public class PlexShow : Show
{
public string Key { get; set; }
}
}

5
ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs

@ -218,13 +218,14 @@ namespace ErsatzTV.Core.FFmpeg @@ -218,13 +218,14 @@ namespace ErsatzTV.Core.FFmpeg
public FFmpegProcessBuilder WithErrorText(IDisplaySize desiredResolution, string text)
{
const string FONT_FILE = "fontfile=Resources/Roboto-Regular.ttf";
const string FONT_SIZE = "fontsize=60";
const string FONT_COLOR = "fontcolor=white";
const string X = "x=(w-text_w)/2";
const string Y = "y=(h-text_h)/3*2";
string fontSize = text.Length > 60 ? "fontsize=40" : "fontsize=60";
return WithFilterComplex(
$"[0:0]scale={desiredResolution.Width}:{desiredResolution.Height},drawtext={FONT_FILE}:{FONT_SIZE}:{FONT_COLOR}:{X}:{Y}:text='{text}'[v]",
$"[0:0]scale={desiredResolution.Width}:{desiredResolution.Height},drawtext={FONT_FILE}:{fontSize}:{FONT_COLOR}:{X}:{Y}:text='{text}'[v]",
"[v]",
"1:a");
}

21
ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs

@ -12,13 +12,30 @@ namespace ErsatzTV.Core.Interfaces.Plex @@ -12,13 +12,30 @@ namespace ErsatzTV.Core.Interfaces.Plex
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, List<PlexMovie>>> GetLibraryContents(
Task<Either<BaseError, List<PlexMovie>>> GetMovieLibraryContents(
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, List<PlexShow>>> GetShowLibraryContents(
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, List<PlexSeason>>> GetShowSeasons(
PlexLibrary library,
PlexShow show,
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, List<PlexEpisode>>> GetSeasonEpisodes(
PlexLibrary library,
PlexSeason season,
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, MediaVersion>> GetStatistics(
PlexMovie movie,
string key,
PlexConnection connection,
PlexServerAuthToken token);
}

15
ErsatzTV.Core/Interfaces/Plex/IPlexTelevisionLibraryScanner.cs

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Plex;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Plex
{
public interface IPlexTelevisionLibraryScanner
{
Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary plexMediaSourceLibrary);
}
}

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

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IMetadataRepository
{
Task<Unit> RemoveGenre(Genre genre);
Task<Unit> UpdateStatistics(MediaVersion mediaVersion);
Task<Unit> UpdateArtworkPath(Artwork artwork);
Task<Unit> AddArtwork(Domain.Metadata metadata, Artwork artwork);
Task<Unit> RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind);
}
}

4
ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs

@ -32,5 +32,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -32,5 +32,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Unit> DeleteByPath(LibraryPath libraryPath, string path);
Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath);
Task<Unit> DeleteEmptyShows(LibraryPath libraryPath);
Task<Either<BaseError, PlexShow>> GetOrAddPlexShow(PlexLibrary library, PlexShow item);
Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item);
Task<Either<BaseError, PlexEpisode>> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item);
Task<Unit> AddGenre(ShowMetadata metadata, Genre genre);
}
}

58
ErsatzTV.Core/Plex/PlexLibraryScanner.cs

@ -0,0 +1,58 @@ @@ -0,0 +1,58 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Plex
{
public abstract class PlexLibraryScanner
{
private readonly IMetadataRepository _metadataRepository;
protected PlexLibraryScanner(IMetadataRepository metadataRepository) =>
_metadataRepository = metadataRepository;
protected async Task<Unit> UpdateArtworkIfNeeded(
Domain.Metadata existingMetadata,
Domain.Metadata incomingMetadata,
ArtworkKind artworkKind)
{
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
{
Option<Artwork> maybeIncomingArtwork = Optional(incomingMetadata.Artwork).Flatten()
.Find(a => a.ArtworkKind == artworkKind);
await maybeIncomingArtwork.Match(
async incomingArtwork =>
{
Option<Artwork> maybeExistingArtwork = Optional(existingMetadata.Artwork).Flatten()
.Find(a => a.ArtworkKind == artworkKind);
await maybeExistingArtwork.Match(
async existingArtwork =>
{
existingArtwork.Path = incomingArtwork.Path;
existingArtwork.DateUpdated = incomingArtwork.DateUpdated;
await _metadataRepository.UpdateArtworkPath(existingArtwork);
},
async () =>
{
existingMetadata.Artwork ??= new List<Artwork>();
existingMetadata.Artwork.Add(incomingArtwork);
await _metadataRepository.AddArtwork(existingMetadata, incomingArtwork);
});
},
async () =>
{
existingMetadata.Artwork ??= new List<Artwork>();
existingMetadata.Artwork.RemoveAll(a => a.ArtworkKind == artworkKind);
await _metadataRepository.RemoveArtwork(existingMetadata, artworkKind);
});
}
return Unit.Default;
}
}
}

51
ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs

@ -10,7 +10,7 @@ using static LanguageExt.Prelude; @@ -10,7 +10,7 @@ using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Plex
{
public class PlexMovieLibraryScanner : IPlexMovieLibraryScanner
public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScanner
{
private readonly ILogger<PlexMovieLibraryScanner> _logger;
private readonly IMovieRepository _movieRepository;
@ -19,7 +19,9 @@ namespace ErsatzTV.Core.Plex @@ -19,7 +19,9 @@ namespace ErsatzTV.Core.Plex
public PlexMovieLibraryScanner(
IPlexServerApiClient plexServerApiClient,
IMovieRepository movieRepository,
IMetadataRepository metadataRepository,
ILogger<PlexMovieLibraryScanner> logger)
: base(metadataRepository)
{
_plexServerApiClient = plexServerApiClient;
_movieRepository = movieRepository;
@ -31,7 +33,7 @@ namespace ErsatzTV.Core.Plex @@ -31,7 +33,7 @@ namespace ErsatzTV.Core.Plex
PlexServerAuthToken token,
PlexLibrary plexMediaSourceLibrary)
{
Either<BaseError, List<PlexMovie>> entries = await _plexServerApiClient.GetLibraryContents(
Either<BaseError, List<PlexMovie>> entries = await _plexServerApiClient.GetMovieLibraryContents(
plexMediaSourceLibrary,
connection,
token);
@ -71,8 +73,6 @@ namespace ErsatzTV.Core.Plex @@ -71,8 +73,6 @@ namespace ErsatzTV.Core.Plex
return Task.CompletedTask;
});
// need plex media item model that can be used to lookup by unique id (metadata key?)
return Unit.Default;
}
@ -89,7 +89,7 @@ namespace ErsatzTV.Core.Plex @@ -89,7 +89,7 @@ namespace ErsatzTV.Core.Plex
string.IsNullOrWhiteSpace(existingVersion.SampleAspectRatio))
{
Either<BaseError, MediaVersion> maybeStatistics =
await _plexServerApiClient.GetStatistics(incoming, connection, token);
await _plexServerApiClient.GetStatistics(incoming.Key.Split("/").Last(), connection, token);
maybeStatistics.IfRight(
mediaVersion =>
@ -128,51 +128,18 @@ namespace ErsatzTV.Core.Plex @@ -128,51 +128,18 @@ namespace ErsatzTV.Core.Plex
return Right<BaseError, PlexMovie>(existing).AsTask();
}
private Task<Either<BaseError, PlexMovie>> UpdateArtwork(PlexMovie existing, PlexMovie incoming)
private async Task<Either<BaseError, PlexMovie>> UpdateArtwork(PlexMovie existing, PlexMovie incoming)
{
MovieMetadata existingMetadata = existing.MovieMetadata.Head();
MovieMetadata incomingMetadata = incoming.MovieMetadata.Head();
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
{
UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
}
return Right<BaseError, PlexMovie>(existing).AsTask();
}
private void UpdateArtworkIfNeeded(
MovieMetadata existingMetadata,
MovieMetadata incomingMetadata,
ArtworkKind artworkKind)
{
Option<Artwork> maybeIncomingArtwork = Optional(incomingMetadata.Artwork).Flatten()
.Find(a => a.ArtworkKind == artworkKind);
maybeIncomingArtwork.Match(
incomingArtwork =>
{
Option<Artwork> maybeExistingArtwork = Optional(existingMetadata.Artwork).Flatten()
.Find(a => a.ArtworkKind == artworkKind);
maybeExistingArtwork.Match(
existingArtwork =>
{
existingArtwork.Path = incomingArtwork.Path;
existingArtwork.DateUpdated = incomingArtwork.DateUpdated;
},
() =>
{
existingMetadata.Artwork ??= new List<Artwork>();
existingMetadata.Artwork.Add(incomingArtwork);
});
},
() =>
{
existingMetadata.Artwork ??= new List<Artwork>();
existingMetadata.Artwork.RemoveAll(a => a.ArtworkKind == artworkKind);
});
return existing;
}
}
}

281
ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs

@ -0,0 +1,281 @@ @@ -0,0 +1,281 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Plex
{
public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionLibraryScanner
{
private readonly ILogger<PlexTelevisionLibraryScanner> _logger;
private readonly IMetadataRepository _metadataRepository;
private readonly IPlexServerApiClient _plexServerApiClient;
private readonly ITelevisionRepository _televisionRepository;
public PlexTelevisionLibraryScanner(
IPlexServerApiClient plexServerApiClient,
ITelevisionRepository televisionRepository,
IMetadataRepository metadataRepository,
ILogger<PlexTelevisionLibraryScanner> logger)
: base(metadataRepository)
{
_plexServerApiClient = plexServerApiClient;
_televisionRepository = televisionRepository;
_metadataRepository = metadataRepository;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary plexMediaSourceLibrary)
{
Either<BaseError, List<PlexShow>> entries = await _plexServerApiClient.GetShowLibraryContents(
plexMediaSourceLibrary,
connection,
token);
return await entries.Match<Task<Either<BaseError, Unit>>>(
async showEntries =>
{
foreach (PlexShow incoming in showEntries)
{
// TODO: optimize dbcontext use here, do we need tracking? can we make partial updates with dapper?
// TODO: figure out how to rebuild playlists
Either<BaseError, PlexShow> maybeShow = await _televisionRepository
.GetOrAddPlexShow(plexMediaSourceLibrary, incoming)
.BindT(existing => UpdateMetadata(existing, incoming))
.BindT(existing => UpdateArtwork(existing, incoming));
await maybeShow.Match(
async show => await ScanSeasons(plexMediaSourceLibrary, show, connection, token),
error =>
{
_logger.LogWarning(
"Error processing plex show at {Key}: {Error}",
incoming.Key,
error.Value);
return Task.CompletedTask;
});
}
// TODO: delete removed shows
return Unit.Default;
},
error =>
{
_logger.LogWarning(
"Error synchronizing plex library {Path}: {Error}",
plexMediaSourceLibrary.Name,
error.Value);
return Left<BaseError, Unit>(error).AsTask();
});
}
private Task<Either<BaseError, PlexShow>> UpdateMetadata(PlexShow existing, PlexShow incoming)
{
ShowMetadata existingMetadata = existing.ShowMetadata.Head();
ShowMetadata incomingMetadata = incoming.ShowMetadata.Head();
// TODO: this probably doesn't work
// plex doesn't seem to update genres returned by the main library call
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
{
foreach (Genre genre in existingMetadata.Genres
.Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Genres.Remove(genre);
_metadataRepository.RemoveGenre(genre);
}
foreach (Genre genre in incomingMetadata.Genres
.Filter(g => existingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Genres.Add(genre);
_televisionRepository.AddGenre(existingMetadata, genre);
}
}
return Right<BaseError, PlexShow>(existing).AsTask();
}
private async Task<Either<BaseError, PlexShow>> UpdateArtwork(PlexShow existing, PlexShow incoming)
{
ShowMetadata existingMetadata = existing.ShowMetadata.Head();
ShowMetadata incomingMetadata = incoming.ShowMetadata.Head();
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
{
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
}
return existing;
}
private async Task<Either<BaseError, Unit>> ScanSeasons(
PlexLibrary plexMediaSourceLibrary,
PlexShow show,
PlexConnection connection,
PlexServerAuthToken token)
{
Either<BaseError, List<PlexSeason>> entries = await _plexServerApiClient.GetShowSeasons(
plexMediaSourceLibrary,
show,
connection,
token);
return await entries.Match<Task<Either<BaseError, Unit>>>(
async seasonEntries =>
{
foreach (PlexSeason incoming in seasonEntries)
{
incoming.ShowId = show.Id;
// TODO: optimize dbcontext use here, do we need tracking? can we make partial updates with dapper?
// TODO: figure out how to rebuild playlists
Either<BaseError, PlexSeason> maybeSeason = await _televisionRepository
.GetOrAddPlexSeason(plexMediaSourceLibrary, incoming)
.BindT(existing => UpdateArtwork(existing, incoming));
await maybeSeason.Match(
async season => await ScanEpisodes(plexMediaSourceLibrary, season, connection, token),
error =>
{
_logger.LogWarning(
"Error processing plex show at {Key}: {Error}",
incoming.Key,
error.Value);
return Task.CompletedTask;
});
}
// TODO: delete removed seasons
return Unit.Default;
},
error =>
{
_logger.LogWarning(
"Error synchronizing plex library {Path}: {Error}",
plexMediaSourceLibrary.Name,
error.Value);
return Left<BaseError, Unit>(error).AsTask();
});
}
private async Task<Either<BaseError, PlexSeason>> UpdateArtwork(PlexSeason existing, PlexSeason incoming)
{
SeasonMetadata existingMetadata = existing.SeasonMetadata.Head();
SeasonMetadata incomingMetadata = incoming.SeasonMetadata.Head();
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
{
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
}
return existing;
}
private async Task<Either<BaseError, Unit>> ScanEpisodes(
PlexLibrary plexMediaSourceLibrary,
PlexSeason season,
PlexConnection connection,
PlexServerAuthToken token)
{
Either<BaseError, List<PlexEpisode>> entries = await _plexServerApiClient.GetSeasonEpisodes(
plexMediaSourceLibrary,
season,
connection,
token);
return await entries.Match<Task<Either<BaseError, Unit>>>(
async episodeEntries =>
{
foreach (PlexEpisode incoming in episodeEntries)
{
incoming.SeasonId = season.Id;
// TODO: optimize dbcontext use here, do we need tracking? can we make partial updates with dapper?
// TODO: figure out how to rebuild playlists
Either<BaseError, PlexEpisode> maybeEpisode = await _televisionRepository
.GetOrAddPlexEpisode(plexMediaSourceLibrary, incoming)
.BindT(existing => UpdateStatistics(existing, incoming, connection, token))
.BindT(existing => UpdateArtwork(existing, incoming));
maybeEpisode.IfLeft(
error => _logger.LogWarning(
"Error processing plex episode at {Key}: {Error}",
incoming.Key,
error.Value));
}
// TODO: delete removed episodes
return Unit.Default;
},
error =>
{
_logger.LogWarning(
"Error synchronizing plex library {Path}: {Error}",
plexMediaSourceLibrary.Name,
error.Value);
return Left<BaseError, Unit>(error).AsTask();
});
}
private async Task<Either<BaseError, PlexEpisode>> UpdateStatistics(
PlexEpisode existing,
PlexEpisode incoming,
PlexConnection connection,
PlexServerAuthToken token)
{
MediaVersion existingVersion = existing.MediaVersions.Head();
MediaVersion incomingVersion = incoming.MediaVersions.Head();
if (incomingVersion.DateUpdated > existingVersion.DateUpdated ||
string.IsNullOrWhiteSpace(existingVersion.SampleAspectRatio))
{
Either<BaseError, MediaVersion> maybeStatistics =
await _plexServerApiClient.GetStatistics(incoming.Key.Split("/").Last(), connection, token);
await maybeStatistics.Match(
async mediaVersion =>
{
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio ?? "1:1";
existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
existingVersion.DateUpdated = incomingVersion.DateUpdated;
await _metadataRepository.UpdateStatistics(existingVersion);
},
_ => Task.CompletedTask);
}
return Right<BaseError, PlexEpisode>(existing);
}
private async Task<Either<BaseError, PlexEpisode>> UpdateArtwork(PlexEpisode existing, PlexEpisode incoming)
{
EpisodeMetadata existingMetadata = existing.EpisodeMetadata.Head();
EpisodeMetadata incomingMetadata = incoming.EpisodeMetadata.Head();
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
{
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Thumbnail);
}
return existing;
}
}
}

2
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -13,6 +13,8 @@ using Map = LanguageExt.Map; @@ -13,6 +13,8 @@ using Map = LanguageExt.Map;
namespace ErsatzTV.Core.Scheduling
{
// TODO: these tests fail on days when offset changes
// because the change happens during the playout
public class PlayoutBuilder : IPlayoutBuilder
{
private static readonly Random Random = new();

11
ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexEpisodeConfiguration.cs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class PlexEpisodeConfiguration : IEntityTypeConfiguration<PlexEpisode>
{
public void Configure(EntityTypeBuilder<PlexEpisode> builder) => builder.ToTable("PlexEpisode");
}
}

11
ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexSeasonConfiguration.cs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class PlexSeasonConfiguration : IEntityTypeConfiguration<PlexSeason>
{
public void Configure(EntityTypeBuilder<PlexSeason> builder) => builder.ToTable("PlexSeason");
}
}

11
ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexShowConfiguration.cs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class PlexShowConfiguration : IEntityTypeConfiguration<PlexShow>
{
public void Configure(EntityTypeBuilder<PlexShow> builder) => builder.ToTable("PlexShow");
}
}

28
ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
@ -11,18 +12,30 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -11,18 +12,30 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{
public class MediaItemRepository : IMediaItemRepository
{
private readonly TvContext _dbContext;
private readonly IDbConnection _dbConnection;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public MediaItemRepository(TvContext dbContext) => _dbContext = dbContext;
public MediaItemRepository(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
{
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
}
public Task<Option<MediaItem>> Get(int id) =>
_dbContext.MediaItems
public async Task<Option<MediaItem>> Get(int id)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
return await context.MediaItems
.Include(i => i.LibraryPath)
.OrderBy(i => i.Id)
.SingleOrDefaultAsync(i => i.Id == id)
.Map(Optional);
}
public Task<List<MediaItem>> GetAll() => _dbContext.MediaItems.ToListAsync();
public async Task<List<MediaItem>> GetAll()
{
await using TvContext context = _dbContextFactory.CreateDbContext();
return await context.MediaItems.ToListAsync();
}
public Task<List<MediaItem>> Search(string searchString) =>
// TODO: fix this when we need to search
@ -47,8 +60,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -47,8 +60,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<bool> Update(MediaItem mediaItem)
{
_dbContext.MediaItems.Update(mediaItem);
return await _dbContext.SaveChangesAsync() > 0;
await using TvContext context = _dbContextFactory.CreateDbContext();
context.MediaItems.Update(mediaItem);
return await context.SaveChangesAsync() > 0;
}
}
}

27
ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs

@ -195,6 +195,33 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -195,6 +195,33 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids)",
new { ids = libraryIds });
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN PlexEpisode pe ON pe.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids)",
new { ids = libraryIds });
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN PlexSeason ps ON ps.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids)",
new { ids = libraryIds });
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN PlexShow ps ON ps.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids)",
new { ids = libraryIds });
}
public Task EnablePlexLibrarySync(IEnumerable<int> libraryIds) =>

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

@ -0,0 +1,78 @@ @@ -0,0 +1,78 @@
using System.Data;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
namespace ErsatzTV.Infrastructure.Data.Repositories
{
public class MetadataRepository : IMetadataRepository
{
private readonly IDbConnection _dbConnection;
public MetadataRepository(IDbConnection dbConnection) => _dbConnection = dbConnection;
public Task<Unit> RemoveGenre(Genre genre) =>
_dbConnection.ExecuteAsync("DELETE FROM Genre WHERE Id = @GenreId", new { GenreId = genre.Id }).ToUnit();
public Task<Unit> UpdateStatistics(MediaVersion mediaVersion) =>
_dbConnection.ExecuteAsync(
@"UPDATE MediaVersion SET
SampleAspectRatio = @SampleAspectRatio,
VideoScanKind = @VideoScanKind,
DateUpdated = @DateUpdated
WHERE Id = @MediaVersionId",
new
{
mediaVersion.SampleAspectRatio,
mediaVersion.VideoScanKind,
mediaVersion.DateUpdated,
MediaVersionId = mediaVersion.Id
}).ToUnit();
public Task<Unit> UpdateArtworkPath(Artwork artwork) =>
_dbConnection.ExecuteAsync(
"UPDATE Artwork SET Path = @Path, DateUpdated = @DateUpdated WHERE Id = @Id",
new { artwork.Path, artwork.DateUpdated, artwork.Id }).ToUnit();
public Task<Unit> AddArtwork(Metadata metadata, Artwork artwork)
{
var parameters = new
{
artwork.ArtworkKind, metadata.Id, artwork.DateAdded, artwork.DateUpdated, artwork.Path
};
return metadata switch
{
MovieMetadata => _dbConnection.ExecuteAsync(
@"INSERT INTO Artwork (ArtworkKind, MovieMetadataId, DateAdded, DateUpdated, Path)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters)
.ToUnit(),
ShowMetadata => _dbConnection.ExecuteAsync(
@"INSERT INTO Artwork (ArtworkKind, ShowMetadataId, DateAdded, DateUpdated, Path)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters)
.ToUnit(),
SeasonMetadata => _dbConnection.ExecuteAsync(
@"INSERT INTO Artwork (ArtworkKind, SeasonMetadataId, DateAdded, DateUpdated, Path)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters)
.ToUnit(),
EpisodeMetadata => _dbConnection.ExecuteAsync(
@"INSERT INTO Artwork (ArtworkKind, EpisodeMetadataId, DateAdded, DateUpdated, Path)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters)
.ToUnit(),
_ => Task.FromResult(Unit.Default)
};
}
public Task<Unit> RemoveArtwork(Metadata metadata, ArtworkKind artworkKind) =>
_dbConnection.ExecuteAsync(
@"DELETE FROM Artwork WHERE ArtworkKind = @ArtworkKind AND (MovieMetadataId = @Id
OR ShowMetadataId = @Id OR SeasonMetadataId = @Id OR EpisodeMetadataId = @Id)",
new { ArtworkKind = artworkKind, metadata.Id }).ToUnit();
}
}

121
ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs

@ -17,11 +17,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -17,11 +17,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{
private readonly IDbConnection _dbConnection;
private readonly TvContext _dbContext;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public TelevisionRepository(TvContext dbContext, IDbConnection dbConnection)
public TelevisionRepository(
TvContext dbContext,
IDbConnection dbConnection,
IDbContextFactory<TvContext> dbContextFactory)
{
_dbContext = dbContext;
_dbConnection = dbConnection;
_dbContextFactory = dbContextFactory;
}
public Task<bool> AllShowsExist(List<int> showIds) =>
@ -305,6 +310,60 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -305,6 +310,60 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
})
.ToUnit();
public async Task<Either<BaseError, PlexShow>> GetOrAddPlexShow(PlexLibrary library, PlexShow item)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
Option<PlexShow> maybeExisting = await context.PlexShows
.AsNoTracking()
.Include(i => i.ShowMetadata)
.ThenInclude(mm => mm.Genres)
.Include(i => i.ShowMetadata)
.ThenInclude(mm => mm.Artwork)
.OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key);
return await maybeExisting.Match(
plexShow => Right<BaseError, PlexShow>(plexShow).AsTask(),
async () => await AddPlexShow(context, library, item));
}
public async Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
Option<PlexSeason> maybeExisting = await context.PlexSeasons
.AsNoTracking()
.Include(i => i.SeasonMetadata)
.ThenInclude(mm => mm.Artwork)
.OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key);
return await maybeExisting.Match(
plexSeason => Right<BaseError, PlexSeason>(plexSeason).AsTask(),
async () => await AddPlexSeason(context, library, item));
}
public async Task<Either<BaseError, PlexEpisode>> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
Option<PlexEpisode> maybeExisting = await context.PlexEpisodes
.AsNoTracking()
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key);
return await maybeExisting.Match(
plexEpisode => Right<BaseError, PlexEpisode>(plexEpisode).AsTask(),
async () => await AddPlexEpisode(context, library, item));
}
public Task<Unit> AddGenre(ShowMetadata metadata, Genre genre) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Genre (Name, SeasonMetadataId) VALUES (@Name, @MetadataId)",
new { genre.Name, MetadataId = metadata.Id }).ToUnit();
public async Task<List<Episode>> GetShowItems(int showId)
{
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
@ -381,5 +440,65 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -381,5 +440,65 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return BaseError.New(ex.Message);
}
}
private async Task<Either<BaseError, PlexShow>> AddPlexShow(
TvContext context,
PlexLibrary library,
PlexShow item)
{
try
{
item.LibraryPathId = library.Paths.Head().Id;
await context.PlexShows.AddAsync(item);
await context.SaveChangesAsync();
await context.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
return item;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
private async Task<Either<BaseError, PlexSeason>> AddPlexSeason(
TvContext context,
PlexLibrary library,
PlexSeason item)
{
try
{
item.LibraryPathId = library.Paths.Head().Id;
await context.PlexSeasons.AddAsync(item);
await context.SaveChangesAsync();
await context.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
return item;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
private async Task<Either<BaseError, PlexEpisode>> AddPlexEpisode(
TvContext context,
PlexLibrary library,
PlexEpisode item)
{
try
{
item.LibraryPathId = library.Paths.Head().Id;
await context.PlexEpisodes.AddAsync(item);
await context.SaveChangesAsync();
await context.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
return item;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
}
}

3
ErsatzTV.Infrastructure/Data/TvContext.cs

@ -33,6 +33,9 @@ namespace ErsatzTV.Infrastructure.Data @@ -33,6 +33,9 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<Episode> Episodes { get; set; }
public DbSet<EpisodeMetadata> EpisodeMetadata { get; set; }
public DbSet<PlexMovie> PlexMovies { get; set; }
public DbSet<PlexShow> PlexShows { get; set; }
public DbSet<PlexSeason> PlexSeasons { get; set; }
public DbSet<PlexEpisode> PlexEpisodes { get; set; }
public DbSet<Collection> Collections { get; set; }
public DbSet<CollectionItem> CollectionItems { get; set; }
public DbSet<ProgramSchedule> ProgramSchedules { get; set; }

1686
ErsatzTV.Infrastructure/Migrations/20210314023600_Add_PlexTelevision.Designer.cs generated

File diff suppressed because it is too large Load Diff

79
ErsatzTV.Infrastructure/Migrations/20210314023600_Add_PlexTelevision.cs

@ -0,0 +1,79 @@ @@ -0,0 +1,79 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_PlexTelevision : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
"PlexEpisode",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Key = table.Column<string>("TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PlexEpisode", x => x.Id);
table.ForeignKey(
"FK_PlexEpisode_Episode_Id",
x => x.Id,
"Episode",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"PlexSeason",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Key = table.Column<string>("TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PlexSeason", x => x.Id);
table.ForeignKey(
"FK_PlexSeason_Season_Id",
x => x.Id,
"Season",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"PlexShow",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Key = table.Column<string>("TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PlexShow", x => x.Id);
table.ForeignKey(
"FK_PlexShow_Show_Id",
x => x.Id,
"Show",
"Id",
onDelete: ReferentialAction.Cascade);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
"PlexEpisode");
migrationBuilder.DropTable(
"PlexSeason");
migrationBuilder.DropTable(
"PlexShow");
}
}
}

69
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -1044,6 +1044,18 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1044,6 +1044,18 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("ProgramScheduleOneItem");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexEpisode",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.Episode");
b.Property<string>("Key")
.HasColumnType("TEXT");
b.ToTable("PlexEpisode");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexMovie",
b =>
@ -1056,6 +1068,30 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1056,6 +1068,30 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("PlexMovie");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexSeason",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.Season");
b.Property<string>("Key")
.HasColumnType("TEXT");
b.ToTable("PlexSeason");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexShow",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.Show");
b.Property<string>("Key")
.HasColumnType("TEXT");
b.ToTable("PlexShow");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Artwork",
b =>
@ -1645,6 +1681,17 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1645,6 +1681,17 @@ namespace ErsatzTV.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexEpisode",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.Episode", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.PlexEpisode", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexMovie",
b =>
@ -1656,6 +1703,28 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1656,6 +1703,28 @@ namespace ErsatzTV.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexSeason",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.Season", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.PlexSeason", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexShow",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.Show", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.PlexShow", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Channel",
b =>

7
ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs

@ -25,5 +25,12 @@ namespace ErsatzTV.Infrastructure.Plex @@ -25,5 +25,12 @@ namespace ErsatzTV.Infrastructure.Plex
string key,
[Query] [AliasAs("X-Plex-Token")]
string token);
[Get("/library/metadata/{key}/children")]
public Task<PlexMediaContainerResponse<PlexMediaContainerMetadataContent<PlexMetadataResponse>>>
GetChildren(
string key,
[Query] [AliasAs("X-Plex-Token")]
string token);
}
}

1
ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs

@ -14,6 +14,7 @@ namespace ErsatzTV.Infrastructure.Plex.Models @@ -14,6 +14,7 @@ namespace ErsatzTV.Infrastructure.Plex.Models
public string OriginallyAvailableAt { get; set; }
public int AddedAt { get; set; }
public int UpdatedAt { get; set; }
public int Index { get; set; }
public List<PlexMediaResponse> Media { get; set; }
public List<PlexGenreResponse> Genre { get; set; }
}

244
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

@ -42,7 +42,7 @@ namespace ErsatzTV.Infrastructure.Plex @@ -42,7 +42,7 @@ namespace ErsatzTV.Infrastructure.Plex
}
}
public async Task<Either<BaseError, List<PlexMovie>>> GetLibraryContents(
public async Task<Either<BaseError, List<PlexMovie>>> GetMovieLibraryContents(
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token)
@ -60,15 +60,71 @@ namespace ErsatzTV.Infrastructure.Plex @@ -60,15 +60,71 @@ namespace ErsatzTV.Infrastructure.Plex
}
}
public async Task<Either<BaseError, List<PlexShow>>> GetShowLibraryContents(
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token)
{
try
{
IPlexServerApi service = RestService.For<IPlexServerApi>(connection.Uri);
return await service.GetLibrarySectionContents(library.Key, token.AuthToken)
.Map(r => r.MediaContainer.Metadata)
.Map(list => list.Map(metadata => ProjectToShow(metadata, library.MediaSourceId)).ToList());
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
public async Task<Either<BaseError, List<PlexSeason>>> GetShowSeasons(
PlexLibrary library,
PlexShow show,
PlexConnection connection,
PlexServerAuthToken token)
{
try
{
IPlexServerApi service = RestService.For<IPlexServerApi>(connection.Uri);
return await service.GetChildren(show.Key.Split("/").Reverse().Skip(1).Head(), token.AuthToken)
.Map(r => r.MediaContainer.Metadata)
.Map(list => list.Map(metadata => ProjectToSeason(metadata, library.MediaSourceId)).ToList());
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
public async Task<Either<BaseError, List<PlexEpisode>>> GetSeasonEpisodes(
PlexLibrary library,
PlexSeason season,
PlexConnection connection,
PlexServerAuthToken token)
{
try
{
IPlexServerApi service = RestService.For<IPlexServerApi>(connection.Uri);
return await service.GetChildren(season.Key.Split("/").Reverse().Skip(1).Head(), token.AuthToken)
.Map(r => r.MediaContainer.Metadata.Filter(m => m.Media.Count > 0 && m.Media[0].Part.Count > 0))
.Map(list => list.Map(metadata => ProjectToEpisode(metadata, library.MediaSourceId)).ToList());
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
public async Task<Either<BaseError, MediaVersion>> GetStatistics(
PlexMovie movie,
string key,
PlexConnection connection,
PlexServerAuthToken token)
{
try
{
IPlexServerApi service = RestService.For<IPlexServerApi>(connection.Uri);
return await service.GetMetadata(movie.Key.Split("/").Last(), token.AuthToken)
return await service.GetMetadata(key, token.AuthToken)
.Map(
r => r.MediaContainer.Metadata.Filter(m => m.Media.Count > 0 && m.Media[0].Part.Count > 0)
.HeadOrNone())
@ -202,5 +258,187 @@ namespace ErsatzTV.Infrastructure.Plex @@ -202,5 +258,187 @@ namespace ErsatzTV.Infrastructure.Plex
}
});
}
private PlexShow ProjectToShow(PlexMetadataResponse response, int mediaSourceId)
{
DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime;
DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime;
var metadata = new ShowMetadata
{
Title = response.Title,
SortTitle = _fallbackMetadataProvider.GetSortTitle(response.Title),
Plot = response.Summary,
ReleaseDate = DateTime.Parse(response.OriginallyAvailableAt),
Year = response.Year,
Tagline = response.Tagline,
DateAdded = dateAdded,
DateUpdated = lastWriteTime,
Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList()
};
if (!string.IsNullOrWhiteSpace(response.Thumb))
{
var path = $"plex/{mediaSourceId}{response.Thumb}";
var artwork = new Artwork
{
ArtworkKind = ArtworkKind.Poster,
Path = path,
DateAdded = dateAdded,
DateUpdated = lastWriteTime
};
metadata.Artwork ??= new List<Artwork>();
metadata.Artwork.Add(artwork);
}
if (!string.IsNullOrWhiteSpace(response.Art))
{
var path = $"plex/{mediaSourceId}{response.Art}";
var artwork = new Artwork
{
ArtworkKind = ArtworkKind.FanArt,
Path = path,
DateAdded = dateAdded,
DateUpdated = lastWriteTime
};
metadata.Artwork ??= new List<Artwork>();
metadata.Artwork.Add(artwork);
}
var show = new PlexShow
{
Key = response.Key,
ShowMetadata = new List<ShowMetadata> { metadata }
};
return show;
}
private PlexSeason ProjectToSeason(PlexMetadataResponse response, int mediaSourceId)
{
DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime;
DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime;
var metadata = new SeasonMetadata
{
Title = response.Title,
SortTitle = _fallbackMetadataProvider.GetSortTitle(response.Title),
Year = response.Year,
DateAdded = dateAdded,
DateUpdated = lastWriteTime
};
if (!string.IsNullOrWhiteSpace(response.Thumb))
{
var path = $"plex/{mediaSourceId}{response.Thumb}";
var artwork = new Artwork
{
ArtworkKind = ArtworkKind.Poster,
Path = path,
DateAdded = dateAdded,
DateUpdated = lastWriteTime
};
metadata.Artwork ??= new List<Artwork>();
metadata.Artwork.Add(artwork);
}
if (!string.IsNullOrWhiteSpace(response.Art))
{
var path = $"plex/{mediaSourceId}{response.Art}";
var artwork = new Artwork
{
ArtworkKind = ArtworkKind.FanArt,
Path = path,
DateAdded = dateAdded,
DateUpdated = lastWriteTime
};
metadata.Artwork ??= new List<Artwork>();
metadata.Artwork.Add(artwork);
}
var season = new PlexSeason
{
Key = response.Key,
SeasonNumber = response.Index,
SeasonMetadata = new List<SeasonMetadata> { metadata }
};
return season;
}
private PlexEpisode ProjectToEpisode(PlexMetadataResponse response, int mediaSourceId)
{
PlexMediaResponse media = response.Media.Head();
PlexPartResponse part = media.Part.Head();
DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime;
DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime;
var metadata = new EpisodeMetadata
{
Title = response.Title,
SortTitle = _fallbackMetadataProvider.GetSortTitle(response.Title),
Plot = response.Summary,
Year = response.Year,
Tagline = response.Tagline,
DateAdded = dateAdded,
DateUpdated = lastWriteTime
};
if (!string.IsNullOrWhiteSpace(response.OriginallyAvailableAt))
{
metadata.ReleaseDate = DateTime.Parse(response.OriginallyAvailableAt);
}
if (!string.IsNullOrWhiteSpace(response.Thumb))
{
var path = $"plex/{mediaSourceId}{response.Thumb}";
var artwork = new Artwork
{
ArtworkKind = ArtworkKind.Thumbnail,
Path = path,
DateAdded = dateAdded,
DateUpdated = lastWriteTime
};
metadata.Artwork ??= new List<Artwork>();
metadata.Artwork.Add(artwork);
}
var version = new MediaVersion
{
Name = "Main",
Duration = TimeSpan.FromMilliseconds(media.Duration),
Width = media.Width,
Height = media.Height,
AudioCodec = media.AudioCodec,
VideoCodec = media.VideoCodec,
VideoProfile = media.VideoProfile,
// specifically omit sample aspect ratio
DateUpdated = lastWriteTime,
MediaFiles = new List<MediaFile>
{
new PlexMediaFile
{
PlexId = part.Id,
Key = part.Key,
Path = part.File
}
}
};
var episode = new PlexEpisode
{
Key = response.Key,
EpisodeNumber = response.Index,
EpisodeMetadata = new List<EpisodeMetadata> { metadata },
MediaVersions = new List<MediaVersion> { version }
};
return episode;
}
}
}

56
ErsatzTV/Controllers/ArtworkController.cs

@ -49,33 +49,34 @@ namespace ErsatzTV.Controllers @@ -49,33 +49,34 @@ namespace ErsatzTV.Controllers
}
[HttpGet("/artwork/posters/plex/{plexMediaSourceId}/{*path}")]
public async Task<IActionResult> GetPlexPoster(int plexMediaSourceId, string path)
{
Either<BaseError, PlexConnectionParametersViewModel> connectionParameters =
await _mediator.Send(new GetPlexConnectionParameters(plexMediaSourceId));
public Task<IActionResult> GetPlexPoster(int plexMediaSourceId, string path) =>
GetPlexArtwork(
plexMediaSourceId,
$"photo/:/transcode?url=/{path}&height=440&width=304&minSize=1&upscale=0");
return await connectionParameters.Match<Task<IActionResult>>(
Left: _ => new NotFoundResult().AsTask<IActionResult>(),
Right: async r =>
{
HttpClient client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Add("X-Plex-Token", r.AuthToken);
[HttpGet("/artwork/fanart/plex/{plexMediaSourceId}/{*path}")]
public Task<IActionResult> GetPlexFanArt(int plexMediaSourceId, string path) =>
GetPlexArtwork(
plexMediaSourceId,
$"/{path}");
var transcodePath = $"photo/:/transcode?url=/{path}&height=440&width=304&minSize=1&upscale=0";
var fullPath = new Uri(r.Uri, transcodePath);
HttpResponseMessage response = await client.GetAsync(
fullPath,
HttpCompletionOption.ResponseHeadersRead);
Stream stream = await response.Content.ReadAsStreamAsync();
[HttpGet("/artwork/thumbnails/plex/{plexMediaSourceId}/{*path}")]
public Task<IActionResult> GetPlexThumbnail(int plexMediaSourceId, string path) =>
GetPlexArtwork(
plexMediaSourceId,
$"photo/:/transcode?url=/{path}&height=220&width=392&minSize=1&upscale=0");
return new FileStreamResult(
stream,
response.Content.Headers.ContentType?.MediaType ?? "image/jpeg");
});
[HttpGet("/artwork/thumbnails/{fileName}")]
public async Task<IActionResult> GetThumbnail(string fileName)
{
Either<BaseError, ImageViewModel> imageContents =
await _mediator.Send(new GetImageContents(fileName, ArtworkKind.Thumbnail, 220));
return imageContents.Match<IActionResult>(
Left: _ => new NotFoundResult(),
Right: r => new FileContentResult(r.Contents, r.MimeType));
}
[HttpGet("/artwork/fanart/plex/{plexMediaSourceId}/{*path}")]
public async Task<IActionResult> GetPlexFanArt(int plexMediaSourceId, string path)
private async Task<IActionResult> GetPlexArtwork(int plexMediaSourceId, string transcodePath)
{
Either<BaseError, PlexConnectionParametersViewModel> connectionParameters =
await _mediator.Send(new GetPlexConnectionParameters(plexMediaSourceId));
@ -87,7 +88,6 @@ namespace ErsatzTV.Controllers @@ -87,7 +88,6 @@ namespace ErsatzTV.Controllers
HttpClient client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Add("X-Plex-Token", r.AuthToken);
var transcodePath = $"/{path}";
var fullPath = new Uri(r.Uri, transcodePath);
HttpResponseMessage response = await client.GetAsync(
fullPath,
@ -99,15 +99,5 @@ namespace ErsatzTV.Controllers @@ -99,15 +99,5 @@ namespace ErsatzTV.Controllers
response.Content.Headers.ContentType?.MediaType ?? "image/jpeg");
});
}
[HttpGet("/artwork/thumbnails/{fileName}")]
public async Task<IActionResult> GetThumbnail(string fileName)
{
Either<BaseError, ImageViewModel> imageContents =
await _mediator.Send(new GetImageContents(fileName, ArtworkKind.Thumbnail, 220));
return imageContents.Match<IActionResult>(
Left: _ => new NotFoundResult(),
Right: r => new FileContentResult(r.Contents, r.MimeType));
}
}
}

2
ErsatzTV/Startup.cs

@ -196,6 +196,7 @@ namespace ErsatzTV @@ -196,6 +196,7 @@ namespace ErsatzTV
services.AddScoped<ISearchRepository, SearchRepository>();
services.AddScoped<IMovieRepository, MovieRepository>();
services.AddScoped<ILibraryRepository, LibraryRepository>();
services.AddScoped<IMetadataRepository, MetadataRepository>();
services.AddScoped<IFFmpegLocator, FFmpegLocator>();
services.AddScoped<ILocalMetadataProvider, LocalMetadataProvider>();
services.AddScoped<IFallbackMetadataProvider, FallbackMetadataProvider>();
@ -206,6 +207,7 @@ namespace ErsatzTV @@ -206,6 +207,7 @@ namespace ErsatzTV
services.AddScoped<IMovieFolderScanner, MovieFolderScanner>();
services.AddScoped<ITelevisionFolderScanner, TelevisionFolderScanner>();
services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>();
services.AddScoped<IPlexTelevisionLibraryScanner, PlexTelevisionLibraryScanner>();
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
services.AddHostedService<PlexService>();

Loading…
Cancel
Save