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
This commit is contained in:
@@ -24,17 +24,20 @@ namespace ErsatzTV.Application.Plex.Commands
|
|||||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||||
private readonly IPlexMovieLibraryScanner _plexMovieLibraryScanner;
|
private readonly IPlexMovieLibraryScanner _plexMovieLibraryScanner;
|
||||||
private readonly IPlexSecretStore _plexSecretStore;
|
private readonly IPlexSecretStore _plexSecretStore;
|
||||||
|
private readonly IPlexTelevisionLibraryScanner _plexTelevisionLibraryScanner;
|
||||||
|
|
||||||
public SynchronizePlexLibraryByIdHandler(
|
public SynchronizePlexLibraryByIdHandler(
|
||||||
IMediaSourceRepository mediaSourceRepository,
|
IMediaSourceRepository mediaSourceRepository,
|
||||||
IPlexSecretStore plexSecretStore,
|
IPlexSecretStore plexSecretStore,
|
||||||
IPlexMovieLibraryScanner plexMovieLibraryScanner,
|
IPlexMovieLibraryScanner plexMovieLibraryScanner,
|
||||||
|
IPlexTelevisionLibraryScanner plexTelevisionLibraryScanner,
|
||||||
IEntityLocker entityLocker,
|
IEntityLocker entityLocker,
|
||||||
ILogger<SynchronizePlexLibraryByIdHandler> logger)
|
ILogger<SynchronizePlexLibraryByIdHandler> logger)
|
||||||
{
|
{
|
||||||
_mediaSourceRepository = mediaSourceRepository;
|
_mediaSourceRepository = mediaSourceRepository;
|
||||||
_plexSecretStore = plexSecretStore;
|
_plexSecretStore = plexSecretStore;
|
||||||
_plexMovieLibraryScanner = plexMovieLibraryScanner;
|
_plexMovieLibraryScanner = plexMovieLibraryScanner;
|
||||||
|
_plexTelevisionLibraryScanner = plexTelevisionLibraryScanner;
|
||||||
_entityLocker = entityLocker;
|
_entityLocker = entityLocker;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
@@ -67,8 +70,10 @@ namespace ErsatzTV.Application.Plex.Commands
|
|||||||
parameters.Library);
|
parameters.Library);
|
||||||
break;
|
break;
|
||||||
case LibraryMediaKind.Shows:
|
case LibraryMediaKind.Shows:
|
||||||
// TODO: plex tv scanner
|
await _plexTelevisionLibraryScanner.ScanLibrary(
|
||||||
// await _televisionFolderScanner.ScanFolder(parameters.LocalMediaSource, parameters.FFprobePath);
|
parameters.ConnectionParameters.ActiveConnection,
|
||||||
|
parameters.ConnectionParameters.PlexServerAuthToken,
|
||||||
|
parameters.Library);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -158,12 +158,12 @@ namespace ErsatzTV.Application.Streaming.Queries
|
|||||||
|
|
||||||
MediaFile file = version.MediaFiles.Head();
|
MediaFile file = version.MediaFiles.Head();
|
||||||
string path = file.Path;
|
string path = file.Path;
|
||||||
if (playoutItem.MediaItem is PlexMovie plexMovie)
|
return playoutItem.MediaItem switch
|
||||||
{
|
{
|
||||||
path = await GetReplacementPlexPath(plexMovie.LibraryPathId, path);
|
PlexMovie plexMovie => await GetReplacementPlexPath(plexMovie.LibraryPathId, path),
|
||||||
}
|
PlexEpisode plexEpisode => await GetReplacementPlexPath(plexEpisode.LibraryPathId, path),
|
||||||
|
_ => path
|
||||||
return path;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> GetReplacementPlexPath(int libraryPathId, string path)
|
private async Task<string> GetReplacementPlexPath(int libraryPathId, string path)
|
||||||
|
|||||||
@@ -66,5 +66,16 @@ namespace ErsatzTV.Core.Tests.Fakes
|
|||||||
public Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath) => throw new NotSupportedException();
|
public Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath) => throw new NotSupportedException();
|
||||||
|
|
||||||
public Task<Unit> DeleteEmptyShows(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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace ErsatzTV.Core.Domain
|
||||||
|
{
|
||||||
|
public class PlexEpisode : Episode
|
||||||
|
{
|
||||||
|
public string Key { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace ErsatzTV.Core.Domain
|
||||||
|
{
|
||||||
|
public class PlexSeason : Season
|
||||||
|
{
|
||||||
|
public string Key { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace ErsatzTV.Core.Domain
|
||||||
|
{
|
||||||
|
public class PlexShow : Show
|
||||||
|
{
|
||||||
|
public string Key { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -218,13 +218,14 @@ namespace ErsatzTV.Core.FFmpeg
|
|||||||
public FFmpegProcessBuilder WithErrorText(IDisplaySize desiredResolution, string text)
|
public FFmpegProcessBuilder WithErrorText(IDisplaySize desiredResolution, string text)
|
||||||
{
|
{
|
||||||
const string FONT_FILE = "fontfile=Resources/Roboto-Regular.ttf";
|
const string FONT_FILE = "fontfile=Resources/Roboto-Regular.ttf";
|
||||||
const string FONT_SIZE = "fontsize=60";
|
|
||||||
const string FONT_COLOR = "fontcolor=white";
|
const string FONT_COLOR = "fontcolor=white";
|
||||||
const string X = "x=(w-text_w)/2";
|
const string X = "x=(w-text_w)/2";
|
||||||
const string Y = "y=(h-text_h)/3*2";
|
const string Y = "y=(h-text_h)/3*2";
|
||||||
|
|
||||||
|
string fontSize = text.Length > 60 ? "fontsize=40" : "fontsize=60";
|
||||||
|
|
||||||
return WithFilterComplex(
|
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]",
|
"[v]",
|
||||||
"1:a");
|
"1:a");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,30 @@ namespace ErsatzTV.Core.Interfaces.Plex
|
|||||||
PlexConnection connection,
|
PlexConnection connection,
|
||||||
PlexServerAuthToken token);
|
PlexServerAuthToken token);
|
||||||
|
|
||||||
Task<Either<BaseError, List<PlexMovie>>> GetLibraryContents(
|
Task<Either<BaseError, List<PlexMovie>>> GetMovieLibraryContents(
|
||||||
PlexLibrary library,
|
PlexLibrary library,
|
||||||
PlexConnection connection,
|
PlexConnection connection,
|
||||||
PlexServerAuthToken token);
|
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(
|
Task<Either<BaseError, MediaVersion>> GetStatistics(
|
||||||
PlexMovie movie,
|
string key,
|
||||||
PlexConnection connection,
|
PlexConnection connection,
|
||||||
PlexServerAuthToken token);
|
PlexServerAuthToken token);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,5 +32,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
|||||||
Task<Unit> DeleteByPath(LibraryPath libraryPath, string path);
|
Task<Unit> DeleteByPath(LibraryPath libraryPath, string path);
|
||||||
Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath);
|
Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath);
|
||||||
Task<Unit> DeleteEmptyShows(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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ using static LanguageExt.Prelude;
|
|||||||
|
|
||||||
namespace ErsatzTV.Core.Plex
|
namespace ErsatzTV.Core.Plex
|
||||||
{
|
{
|
||||||
public class PlexMovieLibraryScanner : IPlexMovieLibraryScanner
|
public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScanner
|
||||||
{
|
{
|
||||||
private readonly ILogger<PlexMovieLibraryScanner> _logger;
|
private readonly ILogger<PlexMovieLibraryScanner> _logger;
|
||||||
private readonly IMovieRepository _movieRepository;
|
private readonly IMovieRepository _movieRepository;
|
||||||
@@ -19,7 +19,9 @@ namespace ErsatzTV.Core.Plex
|
|||||||
public PlexMovieLibraryScanner(
|
public PlexMovieLibraryScanner(
|
||||||
IPlexServerApiClient plexServerApiClient,
|
IPlexServerApiClient plexServerApiClient,
|
||||||
IMovieRepository movieRepository,
|
IMovieRepository movieRepository,
|
||||||
|
IMetadataRepository metadataRepository,
|
||||||
ILogger<PlexMovieLibraryScanner> logger)
|
ILogger<PlexMovieLibraryScanner> logger)
|
||||||
|
: base(metadataRepository)
|
||||||
{
|
{
|
||||||
_plexServerApiClient = plexServerApiClient;
|
_plexServerApiClient = plexServerApiClient;
|
||||||
_movieRepository = movieRepository;
|
_movieRepository = movieRepository;
|
||||||
@@ -31,7 +33,7 @@ namespace ErsatzTV.Core.Plex
|
|||||||
PlexServerAuthToken token,
|
PlexServerAuthToken token,
|
||||||
PlexLibrary plexMediaSourceLibrary)
|
PlexLibrary plexMediaSourceLibrary)
|
||||||
{
|
{
|
||||||
Either<BaseError, List<PlexMovie>> entries = await _plexServerApiClient.GetLibraryContents(
|
Either<BaseError, List<PlexMovie>> entries = await _plexServerApiClient.GetMovieLibraryContents(
|
||||||
plexMediaSourceLibrary,
|
plexMediaSourceLibrary,
|
||||||
connection,
|
connection,
|
||||||
token);
|
token);
|
||||||
@@ -71,8 +73,6 @@ namespace ErsatzTV.Core.Plex
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
});
|
});
|
||||||
|
|
||||||
// need plex media item model that can be used to lookup by unique id (metadata key?)
|
|
||||||
|
|
||||||
return Unit.Default;
|
return Unit.Default;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ namespace ErsatzTV.Core.Plex
|
|||||||
string.IsNullOrWhiteSpace(existingVersion.SampleAspectRatio))
|
string.IsNullOrWhiteSpace(existingVersion.SampleAspectRatio))
|
||||||
{
|
{
|
||||||
Either<BaseError, MediaVersion> maybeStatistics =
|
Either<BaseError, MediaVersion> maybeStatistics =
|
||||||
await _plexServerApiClient.GetStatistics(incoming, connection, token);
|
await _plexServerApiClient.GetStatistics(incoming.Key.Split("/").Last(), connection, token);
|
||||||
|
|
||||||
maybeStatistics.IfRight(
|
maybeStatistics.IfRight(
|
||||||
mediaVersion =>
|
mediaVersion =>
|
||||||
@@ -128,51 +128,18 @@ namespace ErsatzTV.Core.Plex
|
|||||||
return Right<BaseError, PlexMovie>(existing).AsTask();
|
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 existingMetadata = existing.MovieMetadata.Head();
|
||||||
MovieMetadata incomingMetadata = incoming.MovieMetadata.Head();
|
MovieMetadata incomingMetadata = incoming.MovieMetadata.Head();
|
||||||
|
|
||||||
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
|
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
|
||||||
{
|
{
|
||||||
UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
|
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
|
||||||
UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
|
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Right<BaseError, PlexMovie>(existing).AsTask();
|
return existing;
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ using Map = LanguageExt.Map;
|
|||||||
|
|
||||||
namespace ErsatzTV.Core.Scheduling
|
namespace ErsatzTV.Core.Scheduling
|
||||||
{
|
{
|
||||||
|
// TODO: these tests fail on days when offset changes
|
||||||
|
// because the change happens during the playout
|
||||||
public class PlayoutBuilder : IPlayoutBuilder
|
public class PlayoutBuilder : IPlayoutBuilder
|
||||||
{
|
{
|
||||||
private static readonly Random Random = new();
|
private static readonly Random Random = new();
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Data;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
@@ -11,18 +12,30 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
{
|
{
|
||||||
public class MediaItemRepository : IMediaItemRepository
|
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) =>
|
public async Task<Option<MediaItem>> Get(int id)
|
||||||
_dbContext.MediaItems
|
{
|
||||||
|
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||||
|
return await context.MediaItems
|
||||||
.Include(i => i.LibraryPath)
|
.Include(i => i.LibraryPath)
|
||||||
.OrderBy(i => i.Id)
|
.OrderBy(i => i.Id)
|
||||||
.SingleOrDefaultAsync(i => i.Id == id)
|
.SingleOrDefaultAsync(i => i.Id == id)
|
||||||
.Map(Optional);
|
.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) =>
|
public Task<List<MediaItem>> Search(string searchString) =>
|
||||||
// TODO: fix this when we need to search
|
// TODO: fix this when we need to search
|
||||||
@@ -47,8 +60,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
|
|
||||||
public async Task<bool> Update(MediaItem mediaItem)
|
public async Task<bool> Update(MediaItem mediaItem)
|
||||||
{
|
{
|
||||||
_dbContext.MediaItems.Update(mediaItem);
|
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||||
return await _dbContext.SaveChangesAsync() > 0;
|
context.MediaItems.Update(mediaItem);
|
||||||
|
return await context.SaveChangesAsync() > 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,33 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
INNER JOIN Library l ON l.Id = lp.LibraryId
|
INNER JOIN Library l ON l.Id = lp.LibraryId
|
||||||
WHERE l.Id IN @ids)",
|
WHERE l.Id IN @ids)",
|
||||||
new { ids = libraryIds });
|
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) =>
|
public Task EnablePlexLibrarySync(IEnumerable<int> libraryIds) =>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,11 +17,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
{
|
{
|
||||||
private readonly IDbConnection _dbConnection;
|
private readonly IDbConnection _dbConnection;
|
||||||
private readonly TvContext _dbContext;
|
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;
|
_dbContext = dbContext;
|
||||||
_dbConnection = dbConnection;
|
_dbConnection = dbConnection;
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<bool> AllShowsExist(List<int> showIds) =>
|
public Task<bool> AllShowsExist(List<int> showIds) =>
|
||||||
@@ -305,6 +310,60 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
})
|
})
|
||||||
.ToUnit();
|
.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)
|
public async Task<List<Episode>> GetShowItems(int showId)
|
||||||
{
|
{
|
||||||
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
|
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
|
||||||
@@ -381,5 +440,65 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
return BaseError.New(ex.Message);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ namespace ErsatzTV.Infrastructure.Data
|
|||||||
public DbSet<Episode> Episodes { get; set; }
|
public DbSet<Episode> Episodes { get; set; }
|
||||||
public DbSet<EpisodeMetadata> EpisodeMetadata { get; set; }
|
public DbSet<EpisodeMetadata> EpisodeMetadata { get; set; }
|
||||||
public DbSet<PlexMovie> PlexMovies { 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<Collection> Collections { get; set; }
|
||||||
public DbSet<CollectionItem> CollectionItems { get; set; }
|
public DbSet<CollectionItem> CollectionItems { get; set; }
|
||||||
public DbSet<ProgramSchedule> ProgramSchedules { get; set; }
|
public DbSet<ProgramSchedule> ProgramSchedules { get; set; }
|
||||||
|
|||||||
+1686
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1044,6 +1044,18 @@ namespace ErsatzTV.Infrastructure.Migrations
|
|||||||
b.ToTable("ProgramScheduleOneItem");
|
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(
|
modelBuilder.Entity(
|
||||||
"ErsatzTV.Core.Domain.PlexMovie",
|
"ErsatzTV.Core.Domain.PlexMovie",
|
||||||
b =>
|
b =>
|
||||||
@@ -1056,6 +1068,30 @@ namespace ErsatzTV.Infrastructure.Migrations
|
|||||||
b.ToTable("PlexMovie");
|
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(
|
modelBuilder.Entity(
|
||||||
"ErsatzTV.Core.Domain.Artwork",
|
"ErsatzTV.Core.Domain.Artwork",
|
||||||
b =>
|
b =>
|
||||||
@@ -1645,6 +1681,17 @@ namespace ErsatzTV.Infrastructure.Migrations
|
|||||||
.IsRequired();
|
.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(
|
modelBuilder.Entity(
|
||||||
"ErsatzTV.Core.Domain.PlexMovie",
|
"ErsatzTV.Core.Domain.PlexMovie",
|
||||||
b =>
|
b =>
|
||||||
@@ -1656,6 +1703,28 @@ namespace ErsatzTV.Infrastructure.Migrations
|
|||||||
.IsRequired();
|
.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(
|
modelBuilder.Entity(
|
||||||
"ErsatzTV.Core.Domain.Channel",
|
"ErsatzTV.Core.Domain.Channel",
|
||||||
b =>
|
b =>
|
||||||
|
|||||||
@@ -25,5 +25,12 @@ namespace ErsatzTV.Infrastructure.Plex
|
|||||||
string key,
|
string key,
|
||||||
[Query] [AliasAs("X-Plex-Token")]
|
[Query] [AliasAs("X-Plex-Token")]
|
||||||
string token);
|
string token);
|
||||||
|
|
||||||
|
[Get("/library/metadata/{key}/children")]
|
||||||
|
public Task<PlexMediaContainerResponse<PlexMediaContainerMetadataContent<PlexMetadataResponse>>>
|
||||||
|
GetChildren(
|
||||||
|
string key,
|
||||||
|
[Query] [AliasAs("X-Plex-Token")]
|
||||||
|
string token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ namespace ErsatzTV.Infrastructure.Plex.Models
|
|||||||
public string OriginallyAvailableAt { get; set; }
|
public string OriginallyAvailableAt { get; set; }
|
||||||
public int AddedAt { get; set; }
|
public int AddedAt { get; set; }
|
||||||
public int UpdatedAt { get; set; }
|
public int UpdatedAt { get; set; }
|
||||||
|
public int Index { get; set; }
|
||||||
public List<PlexMediaResponse> Media { get; set; }
|
public List<PlexMediaResponse> Media { get; set; }
|
||||||
public List<PlexGenreResponse> Genre { get; set; }
|
public List<PlexGenreResponse> Genre { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
PlexLibrary library,
|
||||||
PlexConnection connection,
|
PlexConnection connection,
|
||||||
PlexServerAuthToken token)
|
PlexServerAuthToken token)
|
||||||
@@ -60,15 +60,71 @@ namespace ErsatzTV.Infrastructure.Plex
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Either<BaseError, MediaVersion>> GetStatistics(
|
public async Task<Either<BaseError, List<PlexShow>>> GetShowLibraryContents(
|
||||||
PlexMovie movie,
|
PlexLibrary library,
|
||||||
PlexConnection connection,
|
PlexConnection connection,
|
||||||
PlexServerAuthToken token)
|
PlexServerAuthToken token)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
IPlexServerApi service = RestService.For<IPlexServerApi>(connection.Uri);
|
IPlexServerApi service = RestService.For<IPlexServerApi>(connection.Uri);
|
||||||
return await service.GetMetadata(movie.Key.Split("/").Last(), token.AuthToken)
|
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(
|
||||||
|
string key,
|
||||||
|
PlexConnection connection,
|
||||||
|
PlexServerAuthToken token)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
IPlexServerApi service = RestService.For<IPlexServerApi>(connection.Uri);
|
||||||
|
return await service.GetMetadata(key, token.AuthToken)
|
||||||
.Map(
|
.Map(
|
||||||
r => r.MediaContainer.Metadata.Filter(m => m.Media.Count > 0 && m.Media[0].Part.Count > 0)
|
r => r.MediaContainer.Metadata.Filter(m => m.Media.Count > 0 && m.Media[0].Part.Count > 0)
|
||||||
.HeadOrNone())
|
.HeadOrNone())
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,56 +49,22 @@ namespace ErsatzTV.Controllers
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("/artwork/posters/plex/{plexMediaSourceId}/{*path}")]
|
[HttpGet("/artwork/posters/plex/{plexMediaSourceId}/{*path}")]
|
||||||
public async Task<IActionResult> GetPlexPoster(int plexMediaSourceId, string path)
|
public Task<IActionResult> GetPlexPoster(int plexMediaSourceId, string path) =>
|
||||||
{
|
GetPlexArtwork(
|
||||||
Either<BaseError, PlexConnectionParametersViewModel> connectionParameters =
|
plexMediaSourceId,
|
||||||
await _mediator.Send(new GetPlexConnectionParameters(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);
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
return new FileStreamResult(
|
|
||||||
stream,
|
|
||||||
response.Content.Headers.ContentType?.MediaType ?? "image/jpeg");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("/artwork/fanart/plex/{plexMediaSourceId}/{*path}")]
|
[HttpGet("/artwork/fanart/plex/{plexMediaSourceId}/{*path}")]
|
||||||
public async Task<IActionResult> GetPlexFanArt(int plexMediaSourceId, string path)
|
public Task<IActionResult> GetPlexFanArt(int plexMediaSourceId, string path) =>
|
||||||
{
|
GetPlexArtwork(
|
||||||
Either<BaseError, PlexConnectionParametersViewModel> connectionParameters =
|
plexMediaSourceId,
|
||||||
await _mediator.Send(new GetPlexConnectionParameters(plexMediaSourceId));
|
$"/{path}");
|
||||||
|
|
||||||
return await connectionParameters.Match<Task<IActionResult>>(
|
[HttpGet("/artwork/thumbnails/plex/{plexMediaSourceId}/{*path}")]
|
||||||
Left: _ => new NotFoundResult().AsTask<IActionResult>(),
|
public Task<IActionResult> GetPlexThumbnail(int plexMediaSourceId, string path) =>
|
||||||
Right: async r =>
|
GetPlexArtwork(
|
||||||
{
|
plexMediaSourceId,
|
||||||
HttpClient client = _httpClientFactory.CreateClient();
|
$"photo/:/transcode?url=/{path}&height=220&width=392&minSize=1&upscale=0");
|
||||||
client.DefaultRequestHeaders.Add("X-Plex-Token", r.AuthToken);
|
|
||||||
|
|
||||||
var transcodePath = $"/{path}";
|
|
||||||
var fullPath = new Uri(r.Uri, transcodePath);
|
|
||||||
HttpResponseMessage response = await client.GetAsync(
|
|
||||||
fullPath,
|
|
||||||
HttpCompletionOption.ResponseHeadersRead);
|
|
||||||
Stream stream = await response.Content.ReadAsStreamAsync();
|
|
||||||
|
|
||||||
return new FileStreamResult(
|
|
||||||
stream,
|
|
||||||
response.Content.Headers.ContentType?.MediaType ?? "image/jpeg");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("/artwork/thumbnails/{fileName}")]
|
[HttpGet("/artwork/thumbnails/{fileName}")]
|
||||||
public async Task<IActionResult> GetThumbnail(string fileName)
|
public async Task<IActionResult> GetThumbnail(string fileName)
|
||||||
@@ -109,5 +75,29 @@ namespace ErsatzTV.Controllers
|
|||||||
Left: _ => new NotFoundResult(),
|
Left: _ => new NotFoundResult(),
|
||||||
Right: r => new FileContentResult(r.Contents, r.MimeType));
|
Right: r => new FileContentResult(r.Contents, r.MimeType));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult> GetPlexArtwork(int plexMediaSourceId, string transcodePath)
|
||||||
|
{
|
||||||
|
Either<BaseError, PlexConnectionParametersViewModel> connectionParameters =
|
||||||
|
await _mediator.Send(new GetPlexConnectionParameters(plexMediaSourceId));
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
var fullPath = new Uri(r.Uri, transcodePath);
|
||||||
|
HttpResponseMessage response = await client.GetAsync(
|
||||||
|
fullPath,
|
||||||
|
HttpCompletionOption.ResponseHeadersRead);
|
||||||
|
Stream stream = await response.Content.ReadAsStreamAsync();
|
||||||
|
|
||||||
|
return new FileStreamResult(
|
||||||
|
stream,
|
||||||
|
response.Content.Headers.ContentType?.MediaType ?? "image/jpeg");
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ namespace ErsatzTV
|
|||||||
services.AddScoped<ISearchRepository, SearchRepository>();
|
services.AddScoped<ISearchRepository, SearchRepository>();
|
||||||
services.AddScoped<IMovieRepository, MovieRepository>();
|
services.AddScoped<IMovieRepository, MovieRepository>();
|
||||||
services.AddScoped<ILibraryRepository, LibraryRepository>();
|
services.AddScoped<ILibraryRepository, LibraryRepository>();
|
||||||
|
services.AddScoped<IMetadataRepository, MetadataRepository>();
|
||||||
services.AddScoped<IFFmpegLocator, FFmpegLocator>();
|
services.AddScoped<IFFmpegLocator, FFmpegLocator>();
|
||||||
services.AddScoped<ILocalMetadataProvider, LocalMetadataProvider>();
|
services.AddScoped<ILocalMetadataProvider, LocalMetadataProvider>();
|
||||||
services.AddScoped<IFallbackMetadataProvider, FallbackMetadataProvider>();
|
services.AddScoped<IFallbackMetadataProvider, FallbackMetadataProvider>();
|
||||||
@@ -206,6 +207,7 @@ namespace ErsatzTV
|
|||||||
services.AddScoped<IMovieFolderScanner, MovieFolderScanner>();
|
services.AddScoped<IMovieFolderScanner, MovieFolderScanner>();
|
||||||
services.AddScoped<ITelevisionFolderScanner, TelevisionFolderScanner>();
|
services.AddScoped<ITelevisionFolderScanner, TelevisionFolderScanner>();
|
||||||
services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>();
|
services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>();
|
||||||
|
services.AddScoped<IPlexTelevisionLibraryScanner, PlexTelevisionLibraryScanner>();
|
||||||
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
|
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
|
||||||
|
|
||||||
services.AddHostedService<PlexService>();
|
services.AddHostedService<PlexService>();
|
||||||
|
|||||||
Reference in New Issue
Block a user