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 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
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
// 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();
|
||||
|
||||
@@ -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.Data;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -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
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 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
|
||||
})
|
||||
.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
|
||||
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<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
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");
|
||||
});
|
||||
|
||||
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
|
||||
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
|
||||
.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
|
||||
.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 =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, MediaVersion>> GetStatistics(
|
||||
PlexMovie movie,
|
||||
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.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(
|
||||
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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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}")]
|
||||
public async Task<IActionResult> GetPlexPoster(int plexMediaSourceId, string path)
|
||||
{
|
||||
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 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");
|
||||
});
|
||||
}
|
||||
public Task<IActionResult> GetPlexPoster(int plexMediaSourceId, string path) =>
|
||||
GetPlexArtwork(
|
||||
plexMediaSourceId,
|
||||
$"photo/:/transcode?url=/{path}&height=440&width=304&minSize=1&upscale=0");
|
||||
|
||||
[HttpGet("/artwork/fanart/plex/{plexMediaSourceId}/{*path}")]
|
||||
public async Task<IActionResult> GetPlexFanArt(int plexMediaSourceId, string path)
|
||||
{
|
||||
Either<BaseError, PlexConnectionParametersViewModel> connectionParameters =
|
||||
await _mediator.Send(new GetPlexConnectionParameters(plexMediaSourceId));
|
||||
public Task<IActionResult> GetPlexFanArt(int plexMediaSourceId, string path) =>
|
||||
GetPlexArtwork(
|
||||
plexMediaSourceId,
|
||||
$"/{path}");
|
||||
|
||||
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 = $"/{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/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");
|
||||
|
||||
[HttpGet("/artwork/thumbnails/{fileName}")]
|
||||
public async Task<IActionResult> GetThumbnail(string fileName)
|
||||
@@ -109,5 +75,29 @@ namespace ErsatzTV.Controllers
|
||||
Left: _ => new NotFoundResult(),
|
||||
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<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
|
||||
services.AddScoped<IMovieFolderScanner, MovieFolderScanner>();
|
||||
services.AddScoped<ITelevisionFolderScanner, TelevisionFolderScanner>();
|
||||
services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>();
|
||||
services.AddScoped<IPlexTelevisionLibraryScanner, PlexTelevisionLibraryScanner>();
|
||||
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
|
||||
|
||||
services.AddHostedService<PlexService>();
|
||||
|
||||
Reference in New Issue
Block a user