diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b14581f7..d9e92ef2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add support for external chapter files next to video files - Currently supports Matroska Chapter XML format - Chapter files have .xml or .chapters extension +- Add targeted (single-show) library scanning + - Supports quick and deep scans + - Can be triggered from the `Scan` button on show pages + - Can be triggered by API call to `/api/libraries/{library-id}/scan-show` ### Fix - Fix database operations that were slowing down playout builds diff --git a/ErsatzTV.Application/Emby/Commands/CallEmbyShowScannerHandler.cs b/ErsatzTV.Application/Emby/Commands/CallEmbyShowScannerHandler.cs new file mode 100644 index 000000000..4de43e3ba --- /dev/null +++ b/ErsatzTV.Application/Emby/Commands/CallEmbyShowScannerHandler.cs @@ -0,0 +1,82 @@ +using ErsatzTV.Application.Libraries; +using ErsatzTV.Core; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.FFmpeg.Runtime; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using System.Globalization; +using System.Threading.Channels; + +namespace ErsatzTV.Application.Emby; + +public class CallEmbyShowScannerHandler : CallLibraryScannerHandler, + IRequestHandler> +{ + public CallEmbyShowScannerHandler( + IDbContextFactory dbContextFactory, + IConfigElementRepository configElementRepository, + ChannelWriter channel, + IMediator mediator, + IRuntimeInfo runtimeInfo) + : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo) + { + } + + Task> IRequestHandler>.Handle( + SynchronizeEmbyShowById request, + CancellationToken cancellationToken) => Handle(request, cancellationToken); + + private async Task> Handle( + SynchronizeEmbyShowById request, + CancellationToken cancellationToken) + { + Validation validation = await Validate(request); + return await validation.Match( + scanner => PerformScan(scanner, request, cancellationToken), + error => + { + foreach (ScanIsNotRequired scanIsNotRequired in error.OfType()) + { + return Task.FromResult>(scanIsNotRequired); + } + + return Task.FromResult>(error.Join()); + }); + } + + private async Task> PerformScan( + string scanner, + SynchronizeEmbyShowById request, + CancellationToken cancellationToken) + { + var arguments = new List + { + "scan-emby-show", + request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture), + request.ShowId.ToString(CultureInfo.InvariantCulture) + }; + + if (request.DeepScan) + { + arguments.Add("--deep"); + } + + return await base.PerformScan(scanner, arguments, cancellationToken); + } + + protected override Task GetLastScan( + TvContext dbContext, + SynchronizeEmbyShowById request) + { + return Task.FromResult(DateTimeOffset.MinValue); + } + + protected override bool ScanIsRequired( + DateTimeOffset lastScan, + int libraryRefreshInterval, + SynchronizeEmbyShowById request) + { + return true; + } +} \ No newline at end of file diff --git a/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyShowById.cs b/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyShowById.cs new file mode 100644 index 000000000..e3f05990f --- /dev/null +++ b/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyShowById.cs @@ -0,0 +1,6 @@ +using ErsatzTV.Core; + +namespace ErsatzTV.Application.Emby; + +public record SynchronizeEmbyShowById(int EmbyLibraryId, int ShowId, bool DeepScan) + : IRequest>, IScannerBackgroundServiceRequest; diff --git a/ErsatzTV.Application/Jellyfin/Commands/CallJellyfinShowScannerHandler.cs b/ErsatzTV.Application/Jellyfin/Commands/CallJellyfinShowScannerHandler.cs new file mode 100644 index 000000000..3ce6ff3d4 --- /dev/null +++ b/ErsatzTV.Application/Jellyfin/Commands/CallJellyfinShowScannerHandler.cs @@ -0,0 +1,82 @@ +using ErsatzTV.Application.Libraries; +using ErsatzTV.Core; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.FFmpeg.Runtime; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using System.Globalization; +using System.Threading.Channels; + +namespace ErsatzTV.Application.Jellyfin; + +public class CallJellyfinShowScannerHandler : CallLibraryScannerHandler, + IRequestHandler> +{ + public CallJellyfinShowScannerHandler( + IDbContextFactory dbContextFactory, + IConfigElementRepository configElementRepository, + ChannelWriter channel, + IMediator mediator, + IRuntimeInfo runtimeInfo) + : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo) + { + } + + Task> IRequestHandler>.Handle( + SynchronizeJellyfinShowById request, + CancellationToken cancellationToken) => Handle(request, cancellationToken); + + private async Task> Handle( + SynchronizeJellyfinShowById request, + CancellationToken cancellationToken) + { + Validation validation = await Validate(request); + return await validation.Match( + scanner => PerformScan(scanner, request, cancellationToken), + error => + { + foreach (ScanIsNotRequired scanIsNotRequired in error.OfType()) + { + return Task.FromResult>(scanIsNotRequired); + } + + return Task.FromResult>(error.Join()); + }); + } + + private async Task> PerformScan( + string scanner, + SynchronizeJellyfinShowById request, + CancellationToken cancellationToken) + { + var arguments = new List + { + "scan-jellyfin-show", + request.JellyfinLibraryId.ToString(CultureInfo.InvariantCulture), + request.ShowId.ToString(CultureInfo.InvariantCulture) + }; + + if (request.DeepScan) + { + arguments.Add("--deep"); + } + + return await base.PerformScan(scanner, arguments, cancellationToken); + } + + protected override Task GetLastScan( + TvContext dbContext, + SynchronizeJellyfinShowById request) + { + return Task.FromResult(DateTimeOffset.MinValue); + } + + protected override bool ScanIsRequired( + DateTimeOffset lastScan, + int libraryRefreshInterval, + SynchronizeJellyfinShowById request) + { + return true; + } +} \ No newline at end of file diff --git a/ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinShowById.cs b/ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinShowById.cs new file mode 100644 index 000000000..a36441be0 --- /dev/null +++ b/ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinShowById.cs @@ -0,0 +1,6 @@ +using ErsatzTV.Core; + +namespace ErsatzTV.Application.Jellyfin; + +public record SynchronizeJellyfinShowById(int JellyfinLibraryId, int ShowId, bool DeepScan) + : IRequest>, IScannerBackgroundServiceRequest; diff --git a/ErsatzTV.Application/Libraries/Commands/QueueLibraryScanByLibraryIdHandler.cs b/ErsatzTV.Application/Libraries/Commands/QueueLibraryScanByLibraryIdHandler.cs index f793c3eac..18105626e 100644 --- a/ErsatzTV.Application/Libraries/Commands/QueueLibraryScanByLibraryIdHandler.cs +++ b/ErsatzTV.Application/Libraries/Commands/QueueLibraryScanByLibraryIdHandler.cs @@ -29,6 +29,20 @@ public class QueueLibraryScanByLibraryIdHandler( foreach (Library library in maybeLibrary) { + bool shouldSyncItems = library switch + { + PlexLibrary plexLibrary => plexLibrary.ShouldSyncItems, + JellyfinLibrary jellyfinLibrary => jellyfinLibrary.ShouldSyncItems, + EmbyLibrary embyLibrary => embyLibrary.ShouldSyncItems, + _ => true + }; + + if (!shouldSyncItems) + { + logger.LogWarning("Library sync is disabled for library id {Id}", library.Id); + return false; + } + if (locker.LockLibrary(library.Id)) { logger.LogDebug("Queued library scan for library id {Id}", library.Id); diff --git a/ErsatzTV.Application/Libraries/Commands/QueueShowScanByLibraryId.cs b/ErsatzTV.Application/Libraries/Commands/QueueShowScanByLibraryId.cs new file mode 100644 index 000000000..d75ad7d5e --- /dev/null +++ b/ErsatzTV.Application/Libraries/Commands/QueueShowScanByLibraryId.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Libraries; + +public record QueueShowScanByLibraryId(int LibraryId, int ShowId, string ShowTitle, bool DeepScan) : IRequest; \ No newline at end of file diff --git a/ErsatzTV.Application/Libraries/Commands/QueueShowScanByLibraryIdHandler.cs b/ErsatzTV.Application/Libraries/Commands/QueueShowScanByLibraryIdHandler.cs new file mode 100644 index 000000000..0b578bb4f --- /dev/null +++ b/ErsatzTV.Application/Libraries/Commands/QueueShowScanByLibraryIdHandler.cs @@ -0,0 +1,90 @@ +using ErsatzTV.Application.Emby; +using ErsatzTV.Application.Jellyfin; +using ErsatzTV.Application.Plex; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Application.Libraries; + +public class QueueShowScanByLibraryIdHandler( + IDbContextFactory dbContextFactory, + IEntityLocker locker, + IMediator mediator, + ILogger logger) + : IRequestHandler +{ + public async Task Handle(QueueShowScanByLibraryId request, CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + Option maybeLibrary = await dbContext.Libraries + .AsNoTracking() + .SelectOneAsync(l => l.Id, l => l.Id == request.LibraryId); + + foreach (Library library in maybeLibrary) + { + bool shouldSyncItems = library switch + { + PlexLibrary plexLibrary => plexLibrary.ShouldSyncItems, + JellyfinLibrary jellyfinLibrary => jellyfinLibrary.ShouldSyncItems, + EmbyLibrary embyLibrary => embyLibrary.ShouldSyncItems, + _ => true + }; + + if (!shouldSyncItems) + { + logger.LogWarning("Library sync is disabled for library id {Id}", library.Id); + return false; + } + + // Check if library is already being scanned - return false if locked + if (!locker.LockLibrary(library.Id)) + { + logger.LogWarning("Library {Id} is already being scanned, cannot scan individual show", library.Id); + return false; + } + + logger.LogDebug("Queued show scan for library id {Id}, show: {ShowTitle}, deepScan: {DeepScan}", + library.Id, request.ShowTitle, request.DeepScan); + + try + { + switch (library) + { + case PlexLibrary: + var plexResult = await mediator.Send( + new SynchronizePlexShowById(library.Id, request.ShowId, request.DeepScan), + cancellationToken); + return plexResult.IsRight; + case JellyfinLibrary: + var jellyfinResult = await mediator.Send( + new SynchronizeJellyfinShowById(library.Id, request.ShowId, request.DeepScan), + cancellationToken); + return jellyfinResult.IsRight; + case EmbyLibrary: + var embyResult = await mediator.Send( + new SynchronizeEmbyShowById(library.Id, request.ShowId, request.DeepScan), + cancellationToken); + return embyResult.IsRight; + case LocalLibrary: + logger.LogWarning("Single show scanning is not supported for local libraries"); + return false; + default: + logger.LogWarning("Unknown library type for library {Id}", library.Id); + return false; + } + } + finally + { + // Always unlock the library when we're done + locker.UnlockLibrary(library.Id); + } + } + + return false; + } +} \ No newline at end of file diff --git a/ErsatzTV.Application/Plex/Commands/CallPlexShowScannerHandler.cs b/ErsatzTV.Application/Plex/Commands/CallPlexShowScannerHandler.cs new file mode 100644 index 000000000..aeffc026e --- /dev/null +++ b/ErsatzTV.Application/Plex/Commands/CallPlexShowScannerHandler.cs @@ -0,0 +1,82 @@ +using ErsatzTV.Application.Libraries; +using ErsatzTV.Core; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.FFmpeg.Runtime; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using System.Globalization; +using System.Threading.Channels; + +namespace ErsatzTV.Application.Plex; + +public class CallPlexShowScannerHandler : CallLibraryScannerHandler, + IRequestHandler> +{ + public CallPlexShowScannerHandler( + IDbContextFactory dbContextFactory, + IConfigElementRepository configElementRepository, + ChannelWriter channel, + IMediator mediator, + IRuntimeInfo runtimeInfo) + : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo) + { + } + + Task> IRequestHandler>.Handle( + SynchronizePlexShowById request, + CancellationToken cancellationToken) => Handle(request, cancellationToken); + + private async Task> Handle( + SynchronizePlexShowById request, + CancellationToken cancellationToken) + { + Validation validation = await Validate(request); + return await validation.Match( + scanner => PerformScan(scanner, request, cancellationToken), + error => + { + foreach (ScanIsNotRequired scanIsNotRequired in error.OfType()) + { + return Task.FromResult>(scanIsNotRequired); + } + + return Task.FromResult>(error.Join()); + }); + } + + private async Task> PerformScan( + string scanner, + SynchronizePlexShowById request, + CancellationToken cancellationToken) + { + var arguments = new List + { + "scan-plex-show", + request.PlexLibraryId.ToString(CultureInfo.InvariantCulture), + request.ShowId.ToString(CultureInfo.InvariantCulture) + }; + + if (request.DeepScan) + { + arguments.Add("--deep"); + } + + return await base.PerformScan(scanner, arguments, cancellationToken); + } + + protected override Task GetLastScan( + TvContext dbContext, + SynchronizePlexShowById request) + { + return Task.FromResult(DateTimeOffset.MinValue); + } + + protected override bool ScanIsRequired( + DateTimeOffset lastScan, + int libraryRefreshInterval, + SynchronizePlexShowById request) + { + return true; + } +} \ No newline at end of file diff --git a/ErsatzTV.Application/Plex/Commands/SynchronizePlexShowById.cs b/ErsatzTV.Application/Plex/Commands/SynchronizePlexShowById.cs new file mode 100644 index 000000000..563362cd9 --- /dev/null +++ b/ErsatzTV.Application/Plex/Commands/SynchronizePlexShowById.cs @@ -0,0 +1,6 @@ +using ErsatzTV.Core; + +namespace ErsatzTV.Application.Plex; + +public record SynchronizePlexShowById(int PlexLibraryId, int ShowId, bool DeepScan) : + IRequest>, IScannerBackgroundServiceRequest; diff --git a/ErsatzTV.Application/Television/Mapper.cs b/ErsatzTV.Application/Television/Mapper.cs index 25dfdeae6..b0ff95f61 100644 --- a/ErsatzTV.Application/Television/Mapper.cs +++ b/ErsatzTV.Application/Television/Mapper.cs @@ -12,9 +12,20 @@ internal static class Mapper Show show, List languages, Option maybeJellyfin, - Option maybeEmby) => - new( + Option maybeEmby) + { + MediaSourceKind mediaSourceKind = show.LibraryPath.Library switch + { + PlexLibrary => MediaSourceKind.Plex, + JellyfinLibrary => MediaSourceKind.Jellyfin, + EmbyLibrary => MediaSourceKind.Emby, + _ => MediaSourceKind.Local + }; + + return new TelevisionShowViewModel( show.Id, + show.LibraryPath.LibraryId, + mediaSourceKind, show.ShowMetadata.HeadOrNone().Map(m => m.Title ?? string.Empty).IfNone(string.Empty), show.ShowMetadata.HeadOrNone().Map(m => m.Year?.ToString(CultureInfo.InvariantCulture) ?? string.Empty) .IfNone(string.Empty), @@ -36,6 +47,7 @@ internal static class Mapper .Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin, maybeEmby)) .ToList()) .IfNone([])); + } internal static TelevisionSeasonViewModel ProjectToViewModel( Season season, diff --git a/ErsatzTV.Application/Television/Queries/GetTelevisionShowByIdHandler.cs b/ErsatzTV.Application/Television/Queries/GetTelevisionShowByIdHandler.cs index b198049c1..0d2a93d18 100644 --- a/ErsatzTV.Application/Television/Queries/GetTelevisionShowByIdHandler.cs +++ b/ErsatzTV.Application/Television/Queries/GetTelevisionShowByIdHandler.cs @@ -1,5 +1,8 @@ using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; using static ErsatzTV.Application.Television.Mapper; namespace ErsatzTV.Application.Television; @@ -7,15 +10,15 @@ namespace ErsatzTV.Application.Television; public class GetTelevisionShowByIdHandler : IRequestHandler> { private readonly IMediaSourceRepository _mediaSourceRepository; + private readonly IDbContextFactory _dbContextFactory; private readonly ISearchRepository _searchRepository; - private readonly ITelevisionRepository _televisionRepository; public GetTelevisionShowByIdHandler( - ITelevisionRepository televisionRepository, + IDbContextFactory dbContextFactory, ISearchRepository searchRepository, IMediaSourceRepository mediaSourceRepository) { - _televisionRepository = televisionRepository; + _dbContextFactory = dbContextFactory; _searchRepository = searchRepository; _mediaSourceRepository = mediaSourceRepository; } @@ -24,20 +27,40 @@ public class GetTelevisionShowByIdHandler : IRequestHandler maybeShow = await _televisionRepository.GetShow(request.Id); - return await maybeShow.Match>>( - async show => - { - Option maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin() - .Map(list => list.HeadOrNone()); + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - Option maybeEmby = await _mediaSourceRepository.GetAllEmby() - .Map(list => list.HeadOrNone()); + Option maybeShow = await dbContext.Shows + .AsNoTracking() + .Include(s => s.LibraryPath) + .ThenInclude(s => s.Library) + .Include(s => s.ShowMetadata) + .ThenInclude(sm => sm.Artwork) + .Include(s => s.ShowMetadata) + .ThenInclude(sm => sm.Genres) + .Include(s => s.ShowMetadata) + .ThenInclude(sm => sm.Tags) + .Include(s => s.ShowMetadata) + .ThenInclude(sm => sm.Studios) + .Include(s => s.ShowMetadata) + .ThenInclude(sm => sm.Actors) + .ThenInclude(a => a.Artwork) + .Include(s => s.ShowMetadata) + .ThenInclude(sm => sm.Guids) + .SelectOneAsync(s => s.Id, s => s.Id == request.Id); - List mediaCodes = await _searchRepository.GetLanguagesForShow(show); - List languageCodes = await _searchRepository.GetAllThreeLetterLanguageCodes(mediaCodes); - return ProjectToViewModel(show, languageCodes, maybeJellyfin, maybeEmby); - }, - () => Task.FromResult(Option.None)); + foreach (Show show in maybeShow) + { + Option maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin() + .Map(list => list.HeadOrNone()); + + Option maybeEmby = await _mediaSourceRepository.GetAllEmby() + .Map(list => list.HeadOrNone()); + + List mediaCodes = await _searchRepository.GetLanguagesForShow(show); + List languageCodes = await _searchRepository.GetAllThreeLetterLanguageCodes(mediaCodes); + return ProjectToViewModel(show, languageCodes, maybeJellyfin, maybeEmby); + } + + return Option.None; } } diff --git a/ErsatzTV.Application/Television/TelevisionShowViewModel.cs b/ErsatzTV.Application/Television/TelevisionShowViewModel.cs index 93e027309..8b25fa4e9 100644 --- a/ErsatzTV.Application/Television/TelevisionShowViewModel.cs +++ b/ErsatzTV.Application/Television/TelevisionShowViewModel.cs @@ -1,10 +1,13 @@ using System.Globalization; using ErsatzTV.Application.MediaCards; +using ErsatzTV.Core.Domain; namespace ErsatzTV.Application.Television; public record TelevisionShowViewModel( int Id, + int LibraryId, + MediaSourceKind MediaSourceKind, string Title, string Year, string Plot, diff --git a/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs b/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs index 3bbccb52b..4a4bbd35d 100644 --- a/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs +++ b/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs @@ -14,7 +14,7 @@ public class FakeTelevisionRepository : ITelevisionRepository public Task> GetAllShows() => throw new NotSupportedException(); public Task> GetShow(int showId) => throw new NotSupportedException(); - + public Task> GetShowIdByTitle(int libraryId, string title) => throw new NotSupportedException(); public Task> GetShowsForCards(List ids) => throw new NotSupportedException(); public Task> GetSeasonsForCards(List ids) => throw new NotSupportedException(); diff --git a/ErsatzTV.Core/Domain/MediaSource/MediaSourceKind.cs b/ErsatzTV.Core/Domain/MediaSource/MediaSourceKind.cs new file mode 100644 index 000000000..e6d83a542 --- /dev/null +++ b/ErsatzTV.Core/Domain/MediaSource/MediaSourceKind.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Core.Domain; + +public enum MediaSourceKind +{ + Local = 1, + Plex = 2, + Jellyfin = 3, + Emby = 4 +} diff --git a/ErsatzTV.Core/Interfaces/Emby/IEmbyApiClient.cs b/ErsatzTV.Core/Interfaces/Emby/IEmbyApiClient.cs index cfbf2378c..62d70b520 100644 --- a/ErsatzTV.Core/Interfaces/Emby/IEmbyApiClient.cs +++ b/ErsatzTV.Core/Interfaces/Emby/IEmbyApiClient.cs @@ -34,4 +34,16 @@ public interface IEmbyApiClient string apiKey, EmbyLibrary library, string itemId); + + Task>> GetSingleShow( + string address, + string apiKey, + EmbyLibrary library, + string showId); + + Task>> SearchShowsByTitle( + string address, + string apiKey, + EmbyLibrary library, + string showTitle); } diff --git a/ErsatzTV.Core/Interfaces/Emby/IEmbyTelevisionLibraryScanner.cs b/ErsatzTV.Core/Interfaces/Emby/IEmbyTelevisionLibraryScanner.cs index 23d6f972a..2b5ece6b7 100644 --- a/ErsatzTV.Core/Interfaces/Emby/IEmbyTelevisionLibraryScanner.cs +++ b/ErsatzTV.Core/Interfaces/Emby/IEmbyTelevisionLibraryScanner.cs @@ -10,4 +10,13 @@ public interface IEmbyTelevisionLibraryScanner EmbyLibrary library, bool deepScan, CancellationToken cancellationToken); + + Task> ScanSingleShow( + string address, + string apiKey, + EmbyLibrary library, + string showId, + string showTitle, + bool deepScan, + CancellationToken cancellationToken); } diff --git a/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinApiClient.cs b/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinApiClient.cs index 9635e63ac..db964bc64 100644 --- a/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinApiClient.cs +++ b/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinApiClient.cs @@ -46,4 +46,16 @@ public interface IJellyfinApiClient string apiKey, JellyfinLibrary library, string itemId); + + Task>> GetSingleShow( + string address, + string apiKey, + JellyfinLibrary library, + string showId); + + Task>> SearchShowsByTitle( + string address, + string apiKey, + JellyfinLibrary library, + string showTitle); } diff --git a/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinTelevisionLibraryScanner.cs b/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinTelevisionLibraryScanner.cs index 950e906b4..cbe7e3e3a 100644 --- a/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinTelevisionLibraryScanner.cs +++ b/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinTelevisionLibraryScanner.cs @@ -10,4 +10,13 @@ public interface IJellyfinTelevisionLibraryScanner JellyfinLibrary library, bool deepScan, CancellationToken cancellationToken); + + Task> ScanSingleShow( + string address, + string apiKey, + JellyfinLibrary library, + string showId, + string showTitle, + bool deepScan, + CancellationToken cancellationToken); } diff --git a/ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs b/ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs index c8d010ace..385e23ec9 100644 --- a/ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs +++ b/ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs @@ -88,4 +88,16 @@ public interface IPlexServerApiClient PlexConnection connection, PlexServerAuthToken token, PlexTag tag); + + Task>> SearchShowsByTitle( + PlexLibrary library, + string showTitle, + PlexConnection connection, + PlexServerAuthToken token); + + Task>> GetSingleShow( + PlexLibrary library, + string showKey, + PlexConnection connection, + PlexServerAuthToken token); } diff --git a/ErsatzTV.Core/Interfaces/Plex/IPlexTelevisionLibraryScanner.cs b/ErsatzTV.Core/Interfaces/Plex/IPlexTelevisionLibraryScanner.cs index a89448d1d..a85bd909b 100644 --- a/ErsatzTV.Core/Interfaces/Plex/IPlexTelevisionLibraryScanner.cs +++ b/ErsatzTV.Core/Interfaces/Plex/IPlexTelevisionLibraryScanner.cs @@ -11,4 +11,13 @@ public interface IPlexTelevisionLibraryScanner PlexLibrary library, bool deepScan, CancellationToken cancellationToken); + + Task> ScanSingleShow( + PlexConnection connection, + PlexServerAuthToken token, + PlexLibrary library, + string showKey, + string showTitle, + bool deepScan, + CancellationToken cancellationToken); } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IEmbyTelevisionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IEmbyTelevisionRepository.cs index ccf4be05c..428c87234 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IEmbyTelevisionRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IEmbyTelevisionRepository.cs @@ -6,4 +6,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories; public interface IEmbyTelevisionRepository : IMediaServerTelevisionRepository { + Task> GetShowTitleItemId(int libraryId, int showId); } + +public record EmbyShowTitleItemIdResult(string Title, string ItemId); diff --git a/ErsatzTV.Core/Interfaces/Repositories/IJellyfinTelevisionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IJellyfinTelevisionRepository.cs index 4fa452a23..f8bb53ef7 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IJellyfinTelevisionRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IJellyfinTelevisionRepository.cs @@ -7,4 +7,7 @@ public interface IJellyfinTelevisionRepository : IMediaServerTelevisionRepositor JellyfinSeason, JellyfinEpisode, JellyfinItemEtag> { + Task> GetShowTitleItemId(int libraryId, int showId); } + +public record JellyfinShowTitleItemIdResult(string Title, string ItemId); diff --git a/ErsatzTV.Core/Interfaces/Repositories/IPlexTelevisionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IPlexTelevisionRepository.cs index db96f7044..9b26b5be8 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IPlexTelevisionRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IPlexTelevisionRepository.cs @@ -9,6 +9,9 @@ public interface IPlexTelevisionRepository : IMediaServerTelevisionRepository> RemoveAllTags(PlexLibrary library, PlexTag tag, System.Collections.Generic.HashSet keep); Task AddTag(PlexLibrary library, PlexShow show, PlexTag tag); Task UpdateLastNetworksScan(PlexLibrary library); + Task> GetShowTitleKey(int libraryId, int showId); } public record PlexShowAddTagResult(Option Existing, Option Added); + +public record PlexShowTitleKeyResult(string Title, string Key); \ No newline at end of file diff --git a/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs index 58e036120..b4d565b44 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs @@ -10,6 +10,7 @@ public interface ITelevisionRepository Task AllEpisodesExist(List episodeIds); Task> GetAllShows(); Task> GetShow(int showId); + Task> GetShowIdByTitle(int libraryId, string title); Task> GetShowsForCards(List ids); Task> GetSeasonsForCards(List ids); Task> GetEpisodesForCards(List ids); diff --git a/ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs index 0a61be2c4..da86f7f14 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs @@ -420,6 +420,27 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository return None; } + public async Task> GetShowTitleItemId(int libraryId, int showId) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + Option maybeShow = await dbContext.EmbyShows + .Where(s => s.Id == showId) + .Where(s => s.LibraryPath.LibraryId == libraryId) + .Include(s => s.ShowMetadata) + .FirstOrDefaultAsync() + .Map(Optional); + + foreach (var show in maybeShow) + { + return new EmbyShowTitleItemIdResult( + await show.ShowMetadata.HeadOrNone().Map(sm => sm.Title).IfNoneAsync("Unknown Show"), + show.ItemId); + } + + return Option.None; + } + private static async Task UpdateShow(TvContext dbContext, EmbyShow existing, EmbyShow incoming) { // library path is used for search indexing later diff --git a/ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs index 6d9fac2f3..08cc90c07 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs @@ -424,6 +424,27 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository return None; } + public async Task> GetShowTitleItemId(int libraryId, int showId) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + Option maybeShow = await dbContext.JellyfinShows + .Where(s => s.Id == showId) + .Where(s => s.LibraryPath.LibraryId == libraryId) + .Include(s => s.ShowMetadata) + .FirstOrDefaultAsync() + .Map(Optional); + + foreach (var show in maybeShow) + { + return new JellyfinShowTitleItemIdResult( + await show.ShowMetadata.HeadOrNone().Map(sm => sm.Title).IfNoneAsync("Unknown Show"), + show.ItemId); + } + + return Option.None; + } + private static async Task UpdateShow(TvContext dbContext, JellyfinShow existing, JellyfinShow incoming) { // library path is used for search indexing later diff --git a/ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs index 445b53060..08559d992 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs @@ -493,6 +493,27 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository new { library.LastNetworksScan, library.Id }); } + public async Task> GetShowTitleKey(int libraryId, int showId) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + Option maybeShow = await dbContext.PlexShows + .Where(s => s.Id == showId) + .Where(s => s.LibraryPath.LibraryId == libraryId) + .Include(s => s.ShowMetadata) + .FirstOrDefaultAsync() + .Map(Optional); + + foreach (var show in maybeShow) + { + return new PlexShowTitleKeyResult( + await show.ShowMetadata.HeadOrNone().Map(sm => sm.Title).IfNoneAsync("Unknown Show"), + show.Key); + } + + return Option.None; + } + private static async Task>> AddShow( TvContext dbContext, PlexLibrary library, diff --git a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs index 6df1002b0..23ca2a1ff 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs @@ -77,9 +77,21 @@ public class TelevisionRepository : ITelevisionRepository .ThenInclude(a => a.Artwork) .Include(s => s.ShowMetadata) .ThenInclude(sm => sm.Guids) - .OrderBy(s => s.Id) - .SingleOrDefaultAsync() - .Map(Optional); + .SelectOneAsync(s => s.Id, s => s.Id == showId); + } + + public async Task> GetShowIdByTitle(int libraryId, string title) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.ShowMetadata + .AsNoTracking() + .Where(sm => sm.Show.LibraryPath.LibraryId == libraryId) + .Where(sm => EF.Functions.Like( + EF.Functions.Collate(sm.Title, TvContext.CaseInsensitiveCollation), + $"%{title}%")) + .Map(sm => sm.ShowId) + .FirstOrDefaultAsync() + .Map(showId => showId > 0 ? Option.Some(showId) : Option.None); } public async Task> GetShowsForCards(List ids) diff --git a/ErsatzTV.Infrastructure/Emby/EmbyApiClient.cs b/ErsatzTV.Infrastructure/Emby/EmbyApiClient.cs index 60170f799..3a65ee4f1 100644 --- a/ErsatzTV.Infrastructure/Emby/EmbyApiClient.cs +++ b/ErsatzTV.Infrastructure/Emby/EmbyApiClient.cs @@ -187,6 +187,37 @@ public class EmbyApiClient : IEmbyApiClient } } + public async Task>> GetSingleShow( + string address, + string apiKey, + EmbyLibrary library, + string showId) + { + try + { + IEmbyApi service = RestService.For(address); + EmbyLibraryItemsResponse itemsResponse = await service.GetShowLibraryItems( + apiKey, + parentId: library.ItemId, + recursive: false, + startIndex: 0, + limit: 1, + ids: showId); + + foreach (EmbyLibraryItemResponse item in itemsResponse.Items) + { + return ProjectToShow(item); + } + + return BaseError.New($"Unable to locate show with id {showId}"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching Emby shows by id"); + return BaseError.New(ex.Message); + } + } + private static async IAsyncEnumerable> GetPagedLibraryContents( string address, Option maybeLibrary, @@ -893,4 +924,53 @@ public class EmbyApiClient : IEmbyApiClient return version; }); } + + public async Task>> SearchShowsByTitle( + string address, + string apiKey, + EmbyLibrary library, + string showTitle) + { + try + { + IEmbyApi service = RestService.For(address); + EmbySearchHintsResponse searchResponse = await service.SearchHints( + apiKey, + showTitle, + "Series", + library.ItemId); + + var shows = new List(); + + foreach (EmbySearchHintResponse hint in searchResponse.SearchHints) + { + if (hint.Type == "Series" && + string.Equals(hint.Name, showTitle, StringComparison.OrdinalIgnoreCase)) + { + EmbyLibraryItemsResponse detailResponse = await service.GetShowLibraryItems( + apiKey, + hint.Id, + recursive: false, + startIndex: 0, + limit: 1); + + foreach (EmbyLibraryItemResponse item in detailResponse.Items) + { + Option maybeShow = ProjectToShow(item); + foreach (EmbyShow show in maybeShow) + { + shows.Add(show); + } + } + } + } + + return shows; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching Emby shows by title"); + return BaseError.New(ex.Message); + } + } } diff --git a/ErsatzTV.Infrastructure/Emby/IEmbyApi.cs b/ErsatzTV.Infrastructure/Emby/IEmbyApi.cs index f82b3f3ba..a452791a5 100644 --- a/ErsatzTV.Infrastructure/Emby/IEmbyApi.cs +++ b/ErsatzTV.Infrastructure/Emby/IEmbyApi.cs @@ -51,7 +51,9 @@ public interface IEmbyApi [Query] int startIndex = 0, [Query] - int limit = 0); + int limit = 0, + [Query] + string ids = null); [Get("/Shows/{parentId}/Seasons?sortOrder=Ascending&sortBy=SortName")] public Task GetSeasonLibraryItems( @@ -122,4 +124,17 @@ public interface IEmbyApi [Header("X-Emby-Token")] string apiKey, string itemId); + + [Get("/Search/Hints")] + public Task SearchHints( + [Header("X-Emby-Token")] + string apiKey, + [Query] + string searchTerm, + [Query] + string includeItemTypes = "Series", + [Query] + string parentId = null, + [Query] + int limit = 20); } diff --git a/ErsatzTV.Infrastructure/Emby/Models/EmbySearchHintsResponse.cs b/ErsatzTV.Infrastructure/Emby/Models/EmbySearchHintsResponse.cs new file mode 100644 index 000000000..aecc38fdb --- /dev/null +++ b/ErsatzTV.Infrastructure/Emby/Models/EmbySearchHintsResponse.cs @@ -0,0 +1,20 @@ +namespace ErsatzTV.Infrastructure.Emby.Models; + +public class EmbySearchHintsResponse +{ + public List SearchHints { get; set; } = []; + public int TotalRecordCount { get; set; } +} + +public class EmbySearchHintResponse +{ + public string Id { get; set; } + public string Name { get; set; } + public string Type { get; set; } + public string MatchedTerm { get; set; } + public int? IndexNumber { get; set; } + public int? ProductionYear { get; set; } + public string Overview { get; set; } + public EmbyImageTagsResponse ImageTags { get; set; } = new(); + public List BackdropImageTags { get; set; } = []; +} \ No newline at end of file diff --git a/ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs b/ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs index 8cb1ddbc0..0623d6ceb 100644 --- a/ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs +++ b/ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs @@ -58,7 +58,9 @@ public interface IJellyfinApi [Query] int startIndex = 0, [Query] - int limit = 0); + int limit = 0, + [Query] + string ids = null); [Get("/Items?sortOrder=Ascending&sortBy=SortName")] public Task GetSeasonLibraryItems( @@ -133,4 +135,17 @@ public interface IJellyfinApi [Header("X-Emby-Token")] string apiKey, string itemId); + + [Get("/Search/Hints")] + public Task SearchHints( + [Header("X-Emby-Token")] + string apiKey, + [Query] + string searchTerm, + [Query] + string includeItemTypes = "Series", + [Query] + string parentId = null, + [Query] + int limit = 20); } diff --git a/ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs b/ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs index c6cc5bbd3..d740afa9f 100644 --- a/ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs +++ b/ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs @@ -201,6 +201,37 @@ public class JellyfinApiClient : IJellyfinApiClient } } + public async Task>> GetSingleShow( + string address, + string apiKey, + JellyfinLibrary library, + string showId) + { + try + { + IJellyfinApi service = RestService.For(address); + JellyfinLibraryItemsResponse itemsResponse = await service.GetShowLibraryItems( + apiKey, + parentId: library.ItemId, + recursive: false, + startIndex: 0, + limit: 1, + ids: showId); + + foreach (JellyfinLibraryItemResponse item in itemsResponse.Items) + { + return ProjectToShow(item); + } + + return BaseError.New($"Unable to locate show with id {showId}"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching Jellyfin shows by id"); + return BaseError.New(ex.Message); + } + } + private static async IAsyncEnumerable> GetPagedLibraryItems( string address, Option maybeLibrary, @@ -962,4 +993,53 @@ public class JellyfinApiClient : IJellyfinApiClient return version; }); } + + public async Task>> SearchShowsByTitle( + string address, + string apiKey, + JellyfinLibrary library, + string showTitle) + { + try + { + IJellyfinApi service = RestService.For(address); + JellyfinSearchHintsResponse searchResponse = await service.SearchHints( + apiKey, + showTitle, + "Series", + library.ItemId); + + var shows = new List(); + + foreach (JellyfinSearchHintResponse hint in searchResponse.SearchHints) + { + if (hint.Type == "Series" && + string.Equals(hint.Name, showTitle, StringComparison.OrdinalIgnoreCase)) + { + JellyfinLibraryItemsResponse detailResponse = await service.GetShowLibraryItems( + apiKey, + hint.Id, + recursive: false, + startIndex: 0, + limit: 1); + + foreach (JellyfinLibraryItemResponse item in detailResponse.Items) + { + Option maybeShow = ProjectToShow(item); + foreach (JellyfinShow show in maybeShow) + { + shows.Add(show); + } + } + } + } + + return shows; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching Jellyfin shows by title"); + return BaseError.New(ex.Message); + } + } } diff --git a/ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinSearchHintsResponse.cs b/ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinSearchHintsResponse.cs new file mode 100644 index 000000000..bd1bc4150 --- /dev/null +++ b/ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinSearchHintsResponse.cs @@ -0,0 +1,20 @@ +namespace ErsatzTV.Infrastructure.Jellyfin.Models; + +public class JellyfinSearchHintsResponse +{ + public List SearchHints { get; set; } = []; + public int TotalRecordCount { get; set; } +} + +public class JellyfinSearchHintResponse +{ + public string Id { get; set; } + public string Name { get; set; } + public string Type { get; set; } + public string MatchedTerm { get; set; } + public int? IndexNumber { get; set; } + public int? ProductionYear { get; set; } + public string Overview { get; set; } + public JellyfinImageTagsResponse ImageTags { get; set; } = new(); + public List BackdropImageTags { get; set; } = []; +} \ No newline at end of file diff --git a/ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs b/ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs index fb19c0757..9cf8d0f13 100644 --- a/ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs +++ b/ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs @@ -168,4 +168,15 @@ public interface IPlexServerApi int take, [Query] [AliasAs("X-Plex-Token")] string token); + + [Get("/hubs/search")] + [Headers("Accept: application/json")] + public Task>> + Search( + [Query] [AliasAs("query")] + string searchTerm, + [Query] [AliasAs("sectionId")] + string sectionId, + [Query] [AliasAs("X-Plex-Token")] + string token); } diff --git a/ErsatzTV.Infrastructure/Plex/Models/PlexHubResponse.cs b/ErsatzTV.Infrastructure/Plex/Models/PlexHubResponse.cs new file mode 100644 index 000000000..1760afbee --- /dev/null +++ b/ErsatzTV.Infrastructure/Plex/Models/PlexHubResponse.cs @@ -0,0 +1,15 @@ +namespace ErsatzTV.Infrastructure.Plex.Models; + +public class PlexMediaContainerHubContent +{ + public List Hub { get; set; } = []; +} + +public class PlexHubResponse +{ + public string HubIdentifier { get; set; } + public string HubKey { get; set; } + public string Title { get; set; } + public string Type { get; set; } + public List Metadata { get; set; } = []; +} \ No newline at end of file diff --git a/ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs b/ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs index 7f2cdf574..6a0f809c8 100644 --- a/ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs +++ b/ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs @@ -7,6 +7,9 @@ public class PlexMetadataResponse [XmlAttribute("key")] public string Key { get; set; } + [XmlAttribute("librarySectionKey")] + public string LibrarySectionKey { get; set; } + [XmlAttribute("title")] public string Title { get; set; } diff --git a/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs b/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs index 19d44cbbf..4140b33b3 100644 --- a/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs +++ b/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs @@ -425,6 +425,67 @@ public class PlexServerApiClient : IPlexServerApiClient } } + public async Task>> GetSingleShow( + PlexLibrary library, + string showKey, + PlexConnection connection, + PlexServerAuthToken token) + { + try + { + IPlexServerApi service = XmlServiceFor(connection.Uri); + return await service.GetDirectoryMetadata(showKey, token.AuthToken) + .Map(Optional) + .MapT(response => Some(ProjectToShow(response.Metadata, library.MediaSourceId))) + .Map(o => o.ToEither($"Unable to locate show with key {showKey}")); + } + catch (Exception ex) + { + return BaseError.New(ex.ToString()); + } + } + + public async Task>> SearchShowsByTitle( + PlexLibrary library, + string showTitle, + PlexConnection connection, + PlexServerAuthToken token) + { + try + { + IPlexServerApi service = RestService.For( + new HttpClient { BaseAddress = new Uri(connection.Uri) }); + + PlexMediaContainerResponse> searchResponse = + await service.Search(showTitle, library.Key, token.AuthToken); + + var shows = new List(); + + foreach (PlexHubResponse hub in searchResponse.MediaContainer.Hub) + { + if (hub.Type != "show") + continue; + + string fullKey = $"/library/sections/{library.Key}"; + + foreach (PlexMetadataResponse metadata in hub.Metadata.Where(m => m.LibrarySectionKey == fullKey)) + { + if (string.Equals(metadata.Title, showTitle, StringComparison.OrdinalIgnoreCase)) + { + PlexShow show = ProjectToShow(metadata, library.MediaSourceId); + shows.Add(show); + } + } + } + + return shows; + } + catch (Exception ex) + { + return BaseError.New(ex.ToString()); + } + } + private static IPlexServerApi XmlServiceFor(string uri, TimeSpan? timeout = null) { var overrides = new XmlAttributeOverrides(); diff --git a/ErsatzTV.Scanner/Application/Emby/Commands/SynchronizeEmbyShowById.cs b/ErsatzTV.Scanner/Application/Emby/Commands/SynchronizeEmbyShowById.cs new file mode 100644 index 000000000..4e8b63d40 --- /dev/null +++ b/ErsatzTV.Scanner/Application/Emby/Commands/SynchronizeEmbyShowById.cs @@ -0,0 +1,6 @@ +using ErsatzTV.Core; + +namespace ErsatzTV.Scanner.Application.Emby; + +public record SynchronizeEmbyShowById(int EmbyLibraryId, int ShowId, bool DeepScan) + : IRequest>; \ No newline at end of file diff --git a/ErsatzTV.Scanner/Application/Emby/Commands/SynchronizeEmbyShowByIdHandler.cs b/ErsatzTV.Scanner/Application/Emby/Commands/SynchronizeEmbyShowByIdHandler.cs new file mode 100644 index 000000000..96d0f8662 --- /dev/null +++ b/ErsatzTV.Scanner/Application/Emby/Commands/SynchronizeEmbyShowByIdHandler.cs @@ -0,0 +1,135 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Emby; +using ErsatzTV.Core.Interfaces.Emby; +using ErsatzTV.Core.Interfaces.Repositories; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Scanner.Application.Emby; + +public class SynchronizeEmbyShowByIdHandler : IRequestHandler> +{ + private readonly IEmbySecretStore _embySecretStore; + private readonly IEmbyTelevisionLibraryScanner _embyTelevisionLibraryScanner; + private readonly ILogger _logger; + private readonly IMediaSourceRepository _mediaSourceRepository; + private readonly IEmbyTelevisionRepository _embyTelevisionRepository; + + public SynchronizeEmbyShowByIdHandler( + IMediaSourceRepository mediaSourceRepository, + IEmbyTelevisionRepository embyTelevisionRepository, + IEmbySecretStore embySecretStore, + IEmbyTelevisionLibraryScanner embyTelevisionLibraryScanner, + ILogger logger) + { + _mediaSourceRepository = mediaSourceRepository; + _embyTelevisionRepository = embyTelevisionRepository; + _embySecretStore = embySecretStore; + _embyTelevisionLibraryScanner = embyTelevisionLibraryScanner; + _logger = logger; + } + + public async Task> Handle( + SynchronizeEmbyShowById request, + CancellationToken cancellationToken) + { + Validation validation = await Validate(request); + return await validation.Match( + parameters => Synchronize(parameters, cancellationToken), + error => Task.FromResult>(error.Join())); + } + + private async Task> Synchronize( + RequestParameters parameters, + CancellationToken cancellationToken) + { + if (parameters.Library.MediaKind != LibraryMediaKind.Shows) + { + return BaseError.New($"Library {parameters.Library.Name} is not a TV show library"); + } + + _logger.LogInformation( + "Starting targeted scan for show '{ShowTitle}' in Emby library {LibraryName}", + parameters.ShowTitle, + parameters.Library.Name); + + Either result = await _embyTelevisionLibraryScanner.ScanSingleShow( + parameters.ConnectionParameters.ActiveConnection.Address, + parameters.ConnectionParameters.ApiKey, + parameters.Library, + parameters.ItemId, + parameters.ShowTitle, + parameters.DeepScan, + cancellationToken); + + foreach (BaseError error in result.LeftToSeq()) + { + _logger.LogError("Error synchronizing Emby show '{ShowTitle}': {Error}", parameters.ShowTitle, error); + } + + return result.Map(_ => $"Show '{parameters.ShowTitle}' in {parameters.Library.Name}"); + } + + private async Task> Validate(SynchronizeEmbyShowById request) => + (await ValidateConnection(request), await EmbyLibraryMustExist(request), await EmbyShowMustExist(request)) + .Apply((connectionParameters, embyLibrary, showTitleItemId) => + new RequestParameters( + connectionParameters, + embyLibrary, + showTitleItemId.ItemId, + showTitleItemId.Title, + request.DeepScan + )); + + private Task> ValidateConnection( + SynchronizeEmbyShowById request) => + EmbyMediaSourceMustExist(request) + .BindT(MediaSourceMustHaveActiveConnection) + .BindT(MediaSourceMustHaveApiKey); + + private Task> EmbyMediaSourceMustExist( + SynchronizeEmbyShowById request) => + _mediaSourceRepository.GetEmbyByLibraryId(request.EmbyLibraryId) + .Map(v => v.ToValidation( + $"Emby media source for library {request.EmbyLibraryId} does not exist.")); + + private Validation MediaSourceMustHaveActiveConnection( + EmbyMediaSource embyMediaSource) + { + Option maybeConnection = embyMediaSource.Connections.HeadOrNone(); + return maybeConnection.Map(connection => new ConnectionParameters(connection)) + .ToValidation("Emby media source requires an active connection"); + } + + private async Task> MediaSourceMustHaveApiKey( + ConnectionParameters connectionParameters) + { + EmbySecrets secrets = await _embySecretStore.ReadSecrets(); + return Optional(secrets.Address == connectionParameters.ActiveConnection.Address) + .Where(match => match) + .Map(_ => connectionParameters with { ApiKey = secrets.ApiKey }) + .ToValidation("Emby media source requires an api key"); + } + + private Task> EmbyLibraryMustExist( + SynchronizeEmbyShowById request) => + _mediaSourceRepository.GetEmbyLibrary(request.EmbyLibraryId) + .Map(v => v.ToValidation($"Emby library {request.EmbyLibraryId} does not exist.")); + + private Task> EmbyShowMustExist( + SynchronizeEmbyShowById request) => + _embyTelevisionRepository.GetShowTitleItemId(request.EmbyLibraryId, request.ShowId) + .Map(v => v.ToValidation($"Jellyfin show {request.ShowId} does not exist in library {request.EmbyLibraryId}.")); + + private record RequestParameters( + ConnectionParameters ConnectionParameters, + EmbyLibrary Library, + string ItemId, + string ShowTitle, + bool DeepScan); + + private record ConnectionParameters(EmbyConnection ActiveConnection) + { + public string? ApiKey { get; init; } + } +} \ No newline at end of file diff --git a/ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinShowById.cs b/ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinShowById.cs new file mode 100644 index 000000000..e7c716aca --- /dev/null +++ b/ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinShowById.cs @@ -0,0 +1,6 @@ +using ErsatzTV.Core; + +namespace ErsatzTV.Scanner.Application.Jellyfin; + +public record SynchronizeJellyfinShowById(int JellyfinLibraryId, int ShowId, bool DeepScan) + : IRequest>; \ No newline at end of file diff --git a/ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinShowByIdHandler.cs b/ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinShowByIdHandler.cs new file mode 100644 index 000000000..e270611ad --- /dev/null +++ b/ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinShowByIdHandler.cs @@ -0,0 +1,135 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Jellyfin; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Jellyfin; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Scanner.Application.Jellyfin; + +public class SynchronizeJellyfinShowByIdHandler : IRequestHandler> +{ + private readonly IJellyfinSecretStore _jellyfinSecretStore; + private readonly IJellyfinTelevisionLibraryScanner _jellyfinTelevisionLibraryScanner; + private readonly ILogger _logger; + private readonly IMediaSourceRepository _mediaSourceRepository; + private readonly IJellyfinTelevisionRepository _jellyfinTelevisionRepository; + + public SynchronizeJellyfinShowByIdHandler( + IMediaSourceRepository mediaSourceRepository, + IJellyfinTelevisionRepository jellyfinTelevisionRepository, + IJellyfinSecretStore jellyfinSecretStore, + IJellyfinTelevisionLibraryScanner jellyfinTelevisionLibraryScanner, + ILogger logger) + { + _mediaSourceRepository = mediaSourceRepository; + _jellyfinTelevisionRepository = jellyfinTelevisionRepository; + _jellyfinSecretStore = jellyfinSecretStore; + _jellyfinTelevisionLibraryScanner = jellyfinTelevisionLibraryScanner; + _logger = logger; + } + + public async Task> Handle( + SynchronizeJellyfinShowById request, + CancellationToken cancellationToken) + { + Validation validation = await Validate(request); + return await validation.Match( + parameters => Synchronize(parameters, cancellationToken), + error => Task.FromResult>(error.Join())); + } + + private async Task> Synchronize( + RequestParameters parameters, + CancellationToken cancellationToken) + { + if (parameters.Library.MediaKind != LibraryMediaKind.Shows) + { + return BaseError.New($"Library {parameters.Library.Name} is not a TV show library"); + } + + _logger.LogInformation( + "Starting targeted scan for show '{ShowTitle}' in Jellyfin library {LibraryName}", + parameters.ShowTitle, + parameters.Library.Name); + + Either result = await _jellyfinTelevisionLibraryScanner.ScanSingleShow( + parameters.ConnectionParameters.ActiveConnection.Address, + parameters.ConnectionParameters.ApiKey, + parameters.Library, + parameters.ItemId, + parameters.ShowTitle, + parameters.DeepScan, + cancellationToken); + + foreach (BaseError error in result.LeftToSeq()) + { + _logger.LogError("Error synchronizing Jellyfin show '{ShowTitle}': {Error}", parameters.ShowTitle, error); + } + + return result.Map(_ => $"Show '{parameters.ShowTitle}' in {parameters.Library.Name}"); + } + + private async Task> Validate(SynchronizeJellyfinShowById request) => + (await ValidateConnection(request), await JellyfinLibraryMustExist(request), await JellyfinShowMustExist(request)) + .Apply((connectionParameters, jellyfinLibrary, showTitleItemId) => + new RequestParameters( + connectionParameters, + jellyfinLibrary, + showTitleItemId.ItemId, + showTitleItemId.Title, + request.DeepScan + )); + + private Task> ValidateConnection( + SynchronizeJellyfinShowById request) => + JellyfinMediaSourceMustExist(request) + .BindT(MediaSourceMustHaveActiveConnection) + .BindT(MediaSourceMustHaveApiKey); + + private Task> JellyfinMediaSourceMustExist( + SynchronizeJellyfinShowById request) => + _mediaSourceRepository.GetJellyfinByLibraryId(request.JellyfinLibraryId) + .Map(v => v.ToValidation( + $"Jellyfin media source for library {request.JellyfinLibraryId} does not exist.")); + + private Validation MediaSourceMustHaveActiveConnection( + JellyfinMediaSource jellyfinMediaSource) + { + Option maybeConnection = jellyfinMediaSource.Connections.HeadOrNone(); + return maybeConnection.Map(connection => new ConnectionParameters(connection)) + .ToValidation("Jellyfin media source requires an active connection"); + } + + private async Task> MediaSourceMustHaveApiKey( + ConnectionParameters connectionParameters) + { + JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets(); + return Optional(secrets.Address == connectionParameters.ActiveConnection.Address) + .Where(match => match) + .Map(_ => connectionParameters with { ApiKey = secrets.ApiKey }) + .ToValidation("Jellyfin media source requires an api key"); + } + + private Task> JellyfinLibraryMustExist( + SynchronizeJellyfinShowById request) => + _mediaSourceRepository.GetJellyfinLibrary(request.JellyfinLibraryId) + .Map(v => v.ToValidation($"Jellyfin library {request.JellyfinLibraryId} does not exist.")); + + private Task> JellyfinShowMustExist( + SynchronizeJellyfinShowById request) => + _jellyfinTelevisionRepository.GetShowTitleItemId(request.JellyfinLibraryId, request.ShowId) + .Map(v => v.ToValidation($"Jellyfin show {request.ShowId} does not exist in library {request.JellyfinLibraryId}.")); + + private record RequestParameters( + ConnectionParameters ConnectionParameters, + JellyfinLibrary Library, + string ItemId, + string ShowTitle, + bool DeepScan); + + private record ConnectionParameters(JellyfinConnection ActiveConnection) + { + public string? ApiKey { get; init; } + } +} \ No newline at end of file diff --git a/ErsatzTV.Scanner/Application/Plex/Commands/SynchronizePlexShowById.cs b/ErsatzTV.Scanner/Application/Plex/Commands/SynchronizePlexShowById.cs new file mode 100644 index 000000000..0e260ff0f --- /dev/null +++ b/ErsatzTV.Scanner/Application/Plex/Commands/SynchronizePlexShowById.cs @@ -0,0 +1,6 @@ +using ErsatzTV.Core; + +namespace ErsatzTV.Scanner.Application.Plex; + +public record SynchronizePlexShowById(int PlexLibraryId, int ShowId, bool DeepScan) + : IRequest>; \ No newline at end of file diff --git a/ErsatzTV.Scanner/Application/Plex/Commands/SynchronizePlexShowByIdHandler.cs b/ErsatzTV.Scanner/Application/Plex/Commands/SynchronizePlexShowByIdHandler.cs new file mode 100644 index 000000000..7eb9cebc1 --- /dev/null +++ b/ErsatzTV.Scanner/Application/Plex/Commands/SynchronizePlexShowByIdHandler.cs @@ -0,0 +1,135 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Plex; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Plex; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Scanner.Application.Plex; + +public class SynchronizePlexShowByIdHandler : IRequestHandler> +{ + private readonly ILogger _logger; + private readonly IPlexTelevisionRepository _plexTelevisionRepository; + private readonly IMediaSourceRepository _mediaSourceRepository; + private readonly IPlexSecretStore _plexSecretStore; + private readonly IPlexTelevisionLibraryScanner _plexTelevisionLibraryScanner; + + public SynchronizePlexShowByIdHandler( + IPlexTelevisionRepository plexTelevisionRepository, + IMediaSourceRepository mediaSourceRepository, + IPlexSecretStore plexSecretStore, + IPlexTelevisionLibraryScanner plexTelevisionLibraryScanner, + ILogger logger) + { + _plexTelevisionRepository = plexTelevisionRepository; + _mediaSourceRepository = mediaSourceRepository; + _plexSecretStore = plexSecretStore; + _plexTelevisionLibraryScanner = plexTelevisionLibraryScanner; + _logger = logger; + } + + public async Task> Handle( + SynchronizePlexShowById request, + CancellationToken cancellationToken) + { + Validation validation = await Validate(request); + return await validation.Match( + parameters => Synchronize(parameters, cancellationToken), + error => Task.FromResult>(error.Join())); + } + + private async Task> Synchronize( + RequestParameters parameters, + CancellationToken cancellationToken) + { + if (parameters.Library.MediaKind != LibraryMediaKind.Shows) + { + return BaseError.New($"Library {parameters.Library.Name} is not a TV show library"); + } + + _logger.LogInformation( + "Starting targeted scan for show '{ShowTitle}' in Plex library {LibraryName}", + parameters.ShowTitle, + parameters.Library.Name); + + Either result = await _plexTelevisionLibraryScanner.ScanSingleShow( + parameters.ConnectionParameters.ActiveConnection, + parameters.ConnectionParameters.PlexServerAuthToken, + parameters.Library, + parameters.ShowKey, + parameters.ShowTitle, + parameters.DeepScan, + cancellationToken); + + foreach (BaseError error in result.LeftToSeq()) + { + _logger.LogError("Error synchronizing Plex show '{ShowTitle}': {Error}", parameters.ShowTitle, error); + } + + return result.Map(_ => $"Show '{parameters.ShowTitle}' in {parameters.Library.Name}"); + } + + private async Task> Validate(SynchronizePlexShowById request) => + (await ValidateConnection(request), await PlexLibraryMustExist(request), await PlexShowMustExist(request)) + .Apply((connectionParameters, plexLibrary, titleKey) => + new RequestParameters( + connectionParameters, + plexLibrary, + titleKey.Key, + titleKey.Title, + request.DeepScan + )); + + private Task> ValidateConnection( + SynchronizePlexShowById request) => + PlexMediaSourceMustExist(request) + .BindT(MediaSourceMustHaveActiveConnection) + .BindT(MediaSourceMustHaveToken); + + private Task> PlexMediaSourceMustExist( + SynchronizePlexShowById request) => + _mediaSourceRepository.GetPlexByLibraryId(request.PlexLibraryId) + .Map(v => v.ToValidation( + $"Plex media source for library {request.PlexLibraryId} does not exist.")); + + private Validation MediaSourceMustHaveActiveConnection( + PlexMediaSource plexMediaSource) + { + Option maybeConnection = + plexMediaSource.Connections.SingleOrDefault(c => c.IsActive); + return maybeConnection.Map(connection => new ConnectionParameters(plexMediaSource, connection)) + .ToValidation("Plex media source requires an active connection"); + } + + private async Task> MediaSourceMustHaveToken( + ConnectionParameters connectionParameters) + { + Option maybeToken = await + _plexSecretStore.GetServerAuthToken(connectionParameters.PlexMediaSource.ClientIdentifier); + return maybeToken.Map(token => connectionParameters with { PlexServerAuthToken = token }) + .ToValidation("Plex media source requires a token"); + } + + private Task> PlexLibraryMustExist( + SynchronizePlexShowById request) => + _mediaSourceRepository.GetPlexLibrary(request.PlexLibraryId) + .Map(v => v.ToValidation($"Plex library {request.PlexLibraryId} does not exist.")); + + private Task> PlexShowMustExist( + SynchronizePlexShowById request) => + _plexTelevisionRepository.GetShowTitleKey(request.PlexLibraryId, request.ShowId) + .Map(v => v.ToValidation($"Plex show {request.ShowId} does not exist in library {request.PlexLibraryId}.")); + + private record RequestParameters( + ConnectionParameters ConnectionParameters, + PlexLibrary Library, + string ShowKey, + string ShowTitle, + bool DeepScan); + + private record ConnectionParameters(PlexMediaSource PlexMediaSource, PlexConnection ActiveConnection) + { + public PlexServerAuthToken? PlexServerAuthToken { get; set; } + } +} \ No newline at end of file diff --git a/ErsatzTV.Scanner/Core/Emby/EmbyTelevisionLibraryScanner.cs b/ErsatzTV.Scanner/Core/Emby/EmbyTelevisionLibraryScanner.cs index bc6d30cf8..6bbc4c175 100644 --- a/ErsatzTV.Scanner/Core/Emby/EmbyTelevisionLibraryScanner.cs +++ b/ErsatzTV.Scanner/Core/Emby/EmbyTelevisionLibraryScanner.cs @@ -1,6 +1,7 @@ using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Emby; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.Metadata; @@ -184,4 +185,88 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner< MediaItemScanResult result, EpisodeMetadata fullMetadata) => Task.FromResult>>(result); + + public async Task> ScanSingleShow( + string address, + string apiKey, + EmbyLibrary library, + string showId, + string showTitle, + bool deepScan, + CancellationToken cancellationToken) + { + List pathReplacements = + await _mediaSourceRepository.GetEmbyPathReplacements(library.MediaSourceId); + + string GetLocalPath(EmbyEpisode episode) + { + return _pathReplacementService.GetReplacementEmbyPath( + pathReplacements, + episode.GetHeadVersion().MediaFiles.Head().Path, + false); + } + + // Search for the specific show + Either> searchResult = await _embyApiClient.GetSingleShow( + address, + apiKey, + library, + showId); + + return await searchResult.Match( + async maybeShow => + { + foreach (var show in maybeShow) + { + _logger.LogInformation("Found show '{ShowTitle}' with id {ShowId}, starting targeted scan", + showTitle, show.ItemId); + + return await ScanSingleShowInternal( + _televisionRepository, + new EmbyConnectionParameters(address, apiKey), + library, + show, + GetLocalPath, + deepScan, + cancellationToken); + } + + _logger.LogWarning("No show found with id {ShowId} in library {LibraryName}", showId, library.Name); + + return Right(Unit.Default); + }, + error => Task.FromResult>(error)); + } + + private async Task> ScanSingleShowInternal( + IEmbyTelevisionRepository televisionRepository, + EmbyConnectionParameters connectionParameters, + EmbyLibrary library, + EmbyShow targetShow, + Func getLocalPath, + bool deepScan, + CancellationToken cancellationToken) + { + try + { + async IAsyncEnumerable> GetSingleShow() + { + yield return new Tuple(targetShow, 1); + await Task.CompletedTask; + } + + return await ScanLibraryWithoutCleanup( + televisionRepository, + connectionParameters, + library, + getLocalPath, + GetSingleShow(), + deepScan, + cancellationToken); + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + return new ScanCanceled(); + } + } } diff --git a/ErsatzTV.Scanner/Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs b/ErsatzTV.Scanner/Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs index f87b36107..9a70ae928 100644 --- a/ErsatzTV.Scanner/Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs +++ b/ErsatzTV.Scanner/Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs @@ -1,5 +1,6 @@ using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Metadata; @@ -183,4 +184,88 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan MediaItemScanResult result, EpisodeMetadata fullMetadata) => Task.FromResult>>(result); + + public async Task> ScanSingleShow( + string address, + string apiKey, + JellyfinLibrary library, + string showId, + string showTitle, + bool deepScan, + CancellationToken cancellationToken) + { + List pathReplacements = + await _mediaSourceRepository.GetJellyfinPathReplacements(library.MediaSourceId); + + string GetLocalPath(JellyfinEpisode episode) + { + return _pathReplacementService.GetReplacementJellyfinPath( + pathReplacements, + episode.GetHeadVersion().MediaFiles.Head().Path, + false); + } + + // Search for the specific show + Either> searchResult = await _jellyfinApiClient.GetSingleShow( + address, + apiKey, + library, + showId); + + return await searchResult.Match( + async maybeShow => + { + foreach (var show in maybeShow) + { + _logger.LogInformation("Found show '{ShowTitle}' with id {ShowId}, starting targeted scan", + showTitle, show.ItemId); + + return await ScanSingleShowInternal( + _televisionRepository, + new JellyfinConnectionParameters(address, apiKey, library.MediaSourceId), + library, + show, + GetLocalPath, + deepScan, + cancellationToken); + } + + _logger.LogWarning("No show found with id {ShowId} in library {LibraryName}", showId, library.Name); + + return Right(Unit.Default); + }, + error => Task.FromResult>(error)); + } + + private async Task> ScanSingleShowInternal( + IJellyfinTelevisionRepository televisionRepository, + JellyfinConnectionParameters connectionParameters, + JellyfinLibrary library, + JellyfinShow targetShow, + Func getLocalPath, + bool deepScan, + CancellationToken cancellationToken) + { + try + { + async IAsyncEnumerable> GetSingleShow() + { + yield return new Tuple(targetShow, 1); + await Task.CompletedTask; + } + + return await ScanLibraryWithoutCleanup( + televisionRepository, + connectionParameters, + library, + getLocalPath, + GetSingleShow(), + deepScan, + cancellationToken); + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + return new ScanCanceled(); + } + } } diff --git a/ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs b/ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs index bd2e8ba19..303a90c72 100644 --- a/ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs +++ b/ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs @@ -80,13 +80,14 @@ public abstract class MediaServerTelevisionLibraryScanner> ScanLibrary( + protected async Task> InternalScanLibrary( IMediaServerTelevisionRepository televisionRepository, TConnectionParameters connectionParameters, TLibrary library, Func getLocalPath, IAsyncEnumerable> showEntries, bool deepScan, + bool cleanupFileNotFoundItems, CancellationToken cancellationToken) { var incomingItemIds = new List(); @@ -168,12 +169,15 @@ public abstract class MediaServerTelevisionLibraryScanner s.MediaServerItemId).Except(incomingItemIds).ToList(); - List ids = await televisionRepository.FlagFileNotFoundShows(library, fileNotFoundItemIds); - await _mediator.Publish( - new ScannerProgressUpdate(library.Id, null, null, ids.ToArray(), Array.Empty()), - cancellationToken); + if (cleanupFileNotFoundItems) + { + // trash shows that are no longer present on the media server + var fileNotFoundItemIds = existingShows.Map(s => s.MediaServerItemId).Except(incomingItemIds).ToList(); + List ids = await televisionRepository.FlagFileNotFoundShows(library, fileNotFoundItemIds); + await _mediator.Publish( + new ScannerProgressUpdate(library.Id, null, null, ids.ToArray(), Array.Empty()), + cancellationToken); + } await _mediator.Publish( new ScannerProgressUpdate( @@ -187,6 +191,46 @@ public abstract class MediaServerTelevisionLibraryScanner> ScanLibrary( + IMediaServerTelevisionRepository televisionRepository, + TConnectionParameters connectionParameters, + TLibrary library, + Func getLocalPath, + IAsyncEnumerable> showEntries, + bool deepScan, + CancellationToken cancellationToken) + { + return await InternalScanLibrary( + televisionRepository, + connectionParameters, + library, + getLocalPath, + showEntries, + deepScan, + cleanupFileNotFoundItems: true, + cancellationToken); + } + + protected async Task> ScanLibraryWithoutCleanup( + IMediaServerTelevisionRepository televisionRepository, + TConnectionParameters connectionParameters, + TLibrary library, + Func getLocalPath, + IAsyncEnumerable> showEntries, + bool deepScan, + CancellationToken cancellationToken) + { + return await InternalScanLibrary( + televisionRepository, + connectionParameters, + library, + getLocalPath, + showEntries, + deepScan, + cleanupFileNotFoundItems: false, + cancellationToken); + } + protected abstract IAsyncEnumerable> GetSeasonLibraryItems( TLibrary library, TConnectionParameters connectionParameters, diff --git a/ErsatzTV.Scanner/Core/Plex/PlexTelevisionLibraryScanner.cs b/ErsatzTV.Scanner/Core/Plex/PlexTelevisionLibraryScanner.cs index c8ac73eb8..b984da61a 100644 --- a/ErsatzTV.Scanner/Core/Plex/PlexTelevisionLibraryScanner.cs +++ b/ErsatzTV.Scanner/Core/Plex/PlexTelevisionLibraryScanner.cs @@ -1,5 +1,7 @@ -using ErsatzTV.Core; +using System.Text.RegularExpressions; +using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Plex; @@ -12,10 +14,12 @@ using Microsoft.Extensions.Logging; namespace ErsatzTV.Scanner.Core.Plex; -public class PlexTelevisionLibraryScanner : +public partial class PlexTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner, IPlexTelevisionLibraryScanner { + private static readonly Regex RatingKeyPattern = RatingKey(); + private readonly ILogger _logger; private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IMetadataRepository _metadataRepository; @@ -81,6 +85,96 @@ public class PlexTelevisionLibraryScanner : cancellationToken); } + public async Task> ScanSingleShow( + PlexConnection connection, + PlexServerAuthToken token, + PlexLibrary library, + string showKey, + string showTitle, + bool deepScan, + CancellationToken cancellationToken) + { + List pathReplacements = + await _mediaSourceRepository.GetPlexPathReplacements(library.MediaSourceId); + + string GetLocalPath(PlexEpisode episode) + { + return _plexPathReplacementService.GetReplacementPlexPath( + pathReplacements, + episode.GetHeadVersion().MediaFiles.Head().Path, + false); + } + + Match match = RatingKeyPattern.Match(showKey); + if (!match.Success) + { + return BaseError.New($"Unable to parse plex show key {showKey}"); + } + + Either> showResult = await _plexServerApiClient.GetSingleShow( + library, + match.Groups[1].Value, + connection, + token); + + return await showResult.Match( + async maybeShow => + { + foreach (var show in maybeShow) + { + _logger.LogInformation("Found show '{ShowTitle}' with key {ShowKey}, starting targeted scan", + showTitle, + show.Key); + + return await ScanSingleShowInternal( + _plexTelevisionRepository, + new PlexConnectionParameters(connection, token), + library, + show, + GetLocalPath, + deepScan, + cancellationToken); + } + + _logger.LogWarning("No show found with key {ShowKey} in library {LibraryName}", showKey, library.Name); + + return Right(Unit.Default); + }, + error => Task.FromResult>(error)); + } + + private async Task> ScanSingleShowInternal( + IMediaServerTelevisionRepository televisionRepository, + PlexConnectionParameters connectionParameters, + PlexLibrary library, + PlexShow targetShow, + Func getLocalPath, + bool deepScan, + CancellationToken cancellationToken) + { + try + { + async IAsyncEnumerable> GetSingleShow() + { + yield return new Tuple(targetShow, 1); + await Task.CompletedTask; + } + + return await ScanLibraryWithoutCleanup( + televisionRepository, + connectionParameters, + library, + getLocalPath, + GetSingleShow(), + deepScan, + cancellationToken); + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + return new ScanCanceled(); + } + } + // TODO: add or remove metadata? // private async Task>> UpdateMetadata( // MediaItemScanResult result, @@ -652,4 +746,7 @@ public class PlexTelevisionLibraryScanner : return false; } + + [GeneratedRegex(@".*\/(\d+)\/.*")] + private static partial Regex RatingKey(); } diff --git a/ErsatzTV.Scanner/Worker.cs b/ErsatzTV.Scanner/Worker.cs index 6abd6619d..a04e1919b 100644 --- a/ErsatzTV.Scanner/Worker.cs +++ b/ErsatzTV.Scanner/Worker.cs @@ -101,6 +101,27 @@ public class Worker : BackgroundService scanJellyfinCollectionsCommand.Arguments.Add(mediaSourceIdArgument); scanJellyfinCollectionsCommand.Options.Add(forceOption); + // Show-specific scanning commands + var showIdArgument = new Argument("show-id") + { + Description = "The id of the TV show to scan" + }; + + var scanPlexShowCommand = new Command("scan-plex-show", "Scan a specific TV show in a Plex library"); + scanPlexShowCommand.Arguments.Add(libraryIdArgument); + scanPlexShowCommand.Arguments.Add(showIdArgument); + scanPlexShowCommand.Options.Add(deepOption); + + var scanEmbyShowCommand = new Command("scan-emby-show", "Scan a specific TV show in an Emby library"); + scanEmbyShowCommand.Arguments.Add(libraryIdArgument); + scanEmbyShowCommand.Arguments.Add(showIdArgument); + scanEmbyShowCommand.Options.Add(deepOption); + + var scanJellyfinShowCommand = new Command("scan-jellyfin-show", "Scan a specific TV show in a Jellyfin library"); + scanJellyfinShowCommand.Arguments.Add(libraryIdArgument); + scanJellyfinShowCommand.Arguments.Add(showIdArgument); + scanJellyfinShowCommand.Options.Add(deepOption); + scanLocalCommand.SetAction(async (parseResult, token) => { if (IsScanningEnabled()) @@ -240,6 +261,54 @@ public class Worker : BackgroundService } }); + scanPlexShowCommand.SetAction(async (parseResult, token) => + { + if (IsScanningEnabled()) + { + bool deep = parseResult.GetValue(deepOption); + int libraryId = parseResult.GetValue(libraryIdArgument); + int showId = parseResult.GetValue(showIdArgument); + + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + IMediator mediator = scope.ServiceProvider.GetRequiredService(); + + var scan = new SynchronizePlexShowById(libraryId, showId, deep); + await mediator.Send(scan, token); + } + }); + + scanEmbyShowCommand.SetAction(async (parseResult, token) => + { + if (IsScanningEnabled()) + { + bool deep = parseResult.GetValue(deepOption); + int libraryId = parseResult.GetValue(libraryIdArgument); + int showId = parseResult.GetValue(showIdArgument); + + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + IMediator mediator = scope.ServiceProvider.GetRequiredService(); + + var scan = new SynchronizeEmbyShowById(libraryId, showId, deep); + await mediator.Send(scan, token); + } + }); + + scanJellyfinShowCommand.SetAction(async (parseResult, token) => + { + if (IsScanningEnabled()) + { + bool deep = parseResult.GetValue(deepOption); + int libraryId = parseResult.GetValue(libraryIdArgument); + int showId = parseResult.GetValue(showIdArgument); + + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + IMediator mediator = scope.ServiceProvider.GetRequiredService(); + + var scan = new SynchronizeJellyfinShowById(libraryId, showId, deep); + await mediator.Send(scan, token); + } + }); + var rootCommand = new RootCommand(); rootCommand.Subcommands.Add(scanLocalCommand); rootCommand.Subcommands.Add(scanPlexCommand); @@ -249,6 +318,9 @@ public class Worker : BackgroundService rootCommand.Subcommands.Add(scanEmbyCollectionsCommand); rootCommand.Subcommands.Add(scanJellyfinCommand); rootCommand.Subcommands.Add(scanJellyfinCollectionsCommand); + rootCommand.Subcommands.Add(scanPlexShowCommand); + rootCommand.Subcommands.Add(scanEmbyShowCommand); + rootCommand.Subcommands.Add(scanJellyfinShowCommand); return rootCommand; } diff --git a/ErsatzTV/Controllers/Api/LibrariesController.cs b/ErsatzTV/Controllers/Api/LibrariesController.cs index c1d125b0f..ebea370a3 100644 --- a/ErsatzTV/Controllers/Api/LibrariesController.cs +++ b/ErsatzTV/Controllers/Api/LibrariesController.cs @@ -1,15 +1,40 @@ using ErsatzTV.Application.Libraries; +using ErsatzTV.Core.Interfaces.Repositories; using MediatR; using Microsoft.AspNetCore.Mvc; namespace ErsatzTV.Controllers.Api; [ApiController] -public class LibrariesController(IMediator mediator) +public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator) { [HttpPost("/api/libraries/{id:int}/scan")] public async Task ResetPlayout(int id) => await mediator.Send(new QueueLibraryScanByLibraryId(id)) ? new OkResult() : new NotFoundResult(); + + [HttpPost("/api/libraries/{id:int}/scan-show")] + public async Task ScanShow(int id, [FromBody] ScanShowRequest request) + { + if (string.IsNullOrWhiteSpace(request.ShowTitle)) + { + return new BadRequestObjectResult(new { error = "ShowTitle is required" }); + } + + var trimmedTitle = request.ShowTitle.Trim(); + var maybeShowId = await televisionRepository.GetShowIdByTitle(id, trimmedTitle); + foreach (var showId in maybeShowId) + { + bool result = await mediator.Send(new QueueShowScanByLibraryId(id, showId, trimmedTitle, request.DeepScan)); + + return result + ? new OkResult() + : new BadRequestObjectResult(new { error = "Unable to queue show scan. Library may not exist, may not support single show scanning, or may already be scanning." }); + } + + return new BadRequestObjectResult(new { error = $"Unable to locate show with title {request.ShowTitle} in library {id}" }); + } } + +public record ScanShowRequest(string ShowTitle, bool DeepScan = false); diff --git a/ErsatzTV/Pages/TelevisionSeasonList.razor b/ErsatzTV/Pages/TelevisionSeasonList.razor index 838cb59e7..d4fd5ed3e 100644 --- a/ErsatzTV/Pages/TelevisionSeasonList.razor +++ b/ErsatzTV/Pages/TelevisionSeasonList.razor @@ -1,5 +1,6 @@ @page "/media/tv/shows/{ShowId:int}" @using System.Globalization +@using ErsatzTV.Application.Libraries @using ErsatzTV.Application.MediaCards @using ErsatzTV.Application.MediaCollections @using ErsatzTV.Application.ProgramSchedules @@ -48,24 +49,28 @@ } - - Add To Collection - - - Add To Playlist - - - Add To Schedule - + + + Add To + + + + + + + + @if (_show?.MediaSourceKind is MediaSourceKind.Plex or MediaSourceKind.Jellyfin or MediaSourceKind.Emby) + { + + + Scan + + + + + + + } @@ -272,7 +277,7 @@ addResult.Match( Left: error => { - Snackbar.Add($"Unexpected error adding season to collection: {error.Value}"); + Snackbar.Add($"Unexpected error adding season to collection: {error.Value}", Severity.Error); Logger.LogError("Unexpected error adding season to collection: {Error}", error.Value); }, Right: _ => Snackbar.Add($"Added {season.Title} to collection {collection.Name}", Severity.Success)); @@ -285,4 +290,18 @@ return poster.StartsWith("http://") || poster.StartsWith("https://") ? poster : $"artwork/posters/{poster}"; } + private async Task ScanShow(bool deepScan) + { + bool result = await Mediator.Send(new QueueShowScanByLibraryId(_show.LibraryId, _show.Id, _show.Title, deepScan)); + if (!result) + { + Snackbar.Add($"Unable to scan show {_show.Title}", Severity.Error); + } + else + { + Snackbar.Add($"Done scanning show {_show.Title}", Severity.Success); + StateHasChanged(); + } + } + } \ No newline at end of file