Browse Source

add ability to deep scan just a single tv show for Plex, Emby, and Jellyfin (#2318)

* add ability to deep scan just a single tv show for Plex, Emby, and Jellyfin

Including "/api/libraries/{id:int}/scan-show" REST API endpoint to
trigger.

* restrict plex search results to the intended library

* restrict scanning to media server libraries that are marked to sync with etv

* fix previous commit

* also guard library scan api

* add scan buttons to show ui

* scan single plex show by id

* scan jellyfin and emby single shows by id

* update changelog

---------

Co-authored-by: Jeff Slutter <MrMustard@gmail.com>
Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
pull/2321/head
midnite8177 3 days ago committed by GitHub
parent
commit
d0af507bef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 82
      ErsatzTV.Application/Emby/Commands/CallEmbyShowScannerHandler.cs
  3. 6
      ErsatzTV.Application/Emby/Commands/SynchronizeEmbyShowById.cs
  4. 82
      ErsatzTV.Application/Jellyfin/Commands/CallJellyfinShowScannerHandler.cs
  5. 6
      ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinShowById.cs
  6. 14
      ErsatzTV.Application/Libraries/Commands/QueueLibraryScanByLibraryIdHandler.cs
  7. 3
      ErsatzTV.Application/Libraries/Commands/QueueShowScanByLibraryId.cs
  8. 90
      ErsatzTV.Application/Libraries/Commands/QueueShowScanByLibraryIdHandler.cs
  9. 82
      ErsatzTV.Application/Plex/Commands/CallPlexShowScannerHandler.cs
  10. 6
      ErsatzTV.Application/Plex/Commands/SynchronizePlexShowById.cs
  11. 16
      ErsatzTV.Application/Television/Mapper.cs
  12. 59
      ErsatzTV.Application/Television/Queries/GetTelevisionShowByIdHandler.cs
  13. 3
      ErsatzTV.Application/Television/TelevisionShowViewModel.cs
  14. 2
      ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs
  15. 9
      ErsatzTV.Core/Domain/MediaSource/MediaSourceKind.cs
  16. 12
      ErsatzTV.Core/Interfaces/Emby/IEmbyApiClient.cs
  17. 9
      ErsatzTV.Core/Interfaces/Emby/IEmbyTelevisionLibraryScanner.cs
  18. 12
      ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinApiClient.cs
  19. 9
      ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinTelevisionLibraryScanner.cs
  20. 12
      ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs
  21. 9
      ErsatzTV.Core/Interfaces/Plex/IPlexTelevisionLibraryScanner.cs
  22. 3
      ErsatzTV.Core/Interfaces/Repositories/IEmbyTelevisionRepository.cs
  23. 3
      ErsatzTV.Core/Interfaces/Repositories/IJellyfinTelevisionRepository.cs
  24. 3
      ErsatzTV.Core/Interfaces/Repositories/IPlexTelevisionRepository.cs
  25. 1
      ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs
  26. 21
      ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs
  27. 21
      ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs
  28. 21
      ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs
  29. 18
      ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs
  30. 80
      ErsatzTV.Infrastructure/Emby/EmbyApiClient.cs
  31. 17
      ErsatzTV.Infrastructure/Emby/IEmbyApi.cs
  32. 20
      ErsatzTV.Infrastructure/Emby/Models/EmbySearchHintsResponse.cs
  33. 17
      ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs
  34. 80
      ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs
  35. 20
      ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinSearchHintsResponse.cs
  36. 11
      ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs
  37. 15
      ErsatzTV.Infrastructure/Plex/Models/PlexHubResponse.cs
  38. 3
      ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs
  39. 61
      ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs
  40. 6
      ErsatzTV.Scanner/Application/Emby/Commands/SynchronizeEmbyShowById.cs
  41. 135
      ErsatzTV.Scanner/Application/Emby/Commands/SynchronizeEmbyShowByIdHandler.cs
  42. 6
      ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinShowById.cs
  43. 135
      ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinShowByIdHandler.cs
  44. 6
      ErsatzTV.Scanner/Application/Plex/Commands/SynchronizePlexShowById.cs
  45. 135
      ErsatzTV.Scanner/Application/Plex/Commands/SynchronizePlexShowByIdHandler.cs
  46. 85
      ErsatzTV.Scanner/Core/Emby/EmbyTelevisionLibraryScanner.cs
  47. 85
      ErsatzTV.Scanner/Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs
  48. 58
      ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs
  49. 101
      ErsatzTV.Scanner/Core/Plex/PlexTelevisionLibraryScanner.cs
  50. 72
      ErsatzTV.Scanner/Worker.cs
  51. 27
      ErsatzTV/Controllers/Api/LibrariesController.cs
  52. 57
      ErsatzTV/Pages/TelevisionSeasonList.razor

4
CHANGELOG.md

@ -46,6 +46,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -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

82
ErsatzTV.Application/Emby/Commands/CallEmbyShowScannerHandler.cs

@ -0,0 +1,82 @@ @@ -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<SynchronizeEmbyShowById>,
IRequestHandler<SynchronizeEmbyShowById, Either<BaseError, string>>
{
public CallEmbyShowScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo)
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
{
}
Task<Either<BaseError, string>> IRequestHandler<SynchronizeEmbyShowById, Either<BaseError, string>>.Handle(
SynchronizeEmbyShowById request,
CancellationToken cancellationToken) => Handle(request, cancellationToken);
private async Task<Either<BaseError, string>> Handle(
SynchronizeEmbyShowById request,
CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate(request);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
error =>
{
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
{
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
}
return Task.FromResult<Either<BaseError, string>>(error.Join());
});
}
private async Task<Either<BaseError, string>> PerformScan(
string scanner,
SynchronizeEmbyShowById request,
CancellationToken cancellationToken)
{
var arguments = new List<string>
{
"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<DateTimeOffset> GetLastScan(
TvContext dbContext,
SynchronizeEmbyShowById request)
{
return Task.FromResult(DateTimeOffset.MinValue);
}
protected override bool ScanIsRequired(
DateTimeOffset lastScan,
int libraryRefreshInterval,
SynchronizeEmbyShowById request)
{
return true;
}
}

6
ErsatzTV.Application/Emby/Commands/SynchronizeEmbyShowById.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Emby;
public record SynchronizeEmbyShowById(int EmbyLibraryId, int ShowId, bool DeepScan)
: IRequest<Either<BaseError, string>>, IScannerBackgroundServiceRequest;

82
ErsatzTV.Application/Jellyfin/Commands/CallJellyfinShowScannerHandler.cs

@ -0,0 +1,82 @@ @@ -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<SynchronizeJellyfinShowById>,
IRequestHandler<SynchronizeJellyfinShowById, Either<BaseError, string>>
{
public CallJellyfinShowScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo)
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
{
}
Task<Either<BaseError, string>> IRequestHandler<SynchronizeJellyfinShowById, Either<BaseError, string>>.Handle(
SynchronizeJellyfinShowById request,
CancellationToken cancellationToken) => Handle(request, cancellationToken);
private async Task<Either<BaseError, string>> Handle(
SynchronizeJellyfinShowById request,
CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate(request);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
error =>
{
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
{
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
}
return Task.FromResult<Either<BaseError, string>>(error.Join());
});
}
private async Task<Either<BaseError, string>> PerformScan(
string scanner,
SynchronizeJellyfinShowById request,
CancellationToken cancellationToken)
{
var arguments = new List<string>
{
"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<DateTimeOffset> GetLastScan(
TvContext dbContext,
SynchronizeJellyfinShowById request)
{
return Task.FromResult(DateTimeOffset.MinValue);
}
protected override bool ScanIsRequired(
DateTimeOffset lastScan,
int libraryRefreshInterval,
SynchronizeJellyfinShowById request)
{
return true;
}
}

6
ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinShowById.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Jellyfin;
public record SynchronizeJellyfinShowById(int JellyfinLibraryId, int ShowId, bool DeepScan)
: IRequest<Either<BaseError, string>>, IScannerBackgroundServiceRequest;

14
ErsatzTV.Application/Libraries/Commands/QueueLibraryScanByLibraryIdHandler.cs

@ -29,6 +29,20 @@ public class QueueLibraryScanByLibraryIdHandler( @@ -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);

3
ErsatzTV.Application/Libraries/Commands/QueueShowScanByLibraryId.cs

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Libraries;
public record QueueShowScanByLibraryId(int LibraryId, int ShowId, string ShowTitle, bool DeepScan) : IRequest<bool>;

90
ErsatzTV.Application/Libraries/Commands/QueueShowScanByLibraryIdHandler.cs

@ -0,0 +1,90 @@ @@ -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<TvContext> dbContextFactory,
IEntityLocker locker,
IMediator mediator,
ILogger<QueueShowScanByLibraryIdHandler> logger)
: IRequestHandler<QueueShowScanByLibraryId, bool>
{
public async Task<bool> Handle(QueueShowScanByLibraryId request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Library> 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;
}
}

82
ErsatzTV.Application/Plex/Commands/CallPlexShowScannerHandler.cs

@ -0,0 +1,82 @@ @@ -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<SynchronizePlexShowById>,
IRequestHandler<SynchronizePlexShowById, Either<BaseError, string>>
{
public CallPlexShowScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo)
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
{
}
Task<Either<BaseError, string>> IRequestHandler<SynchronizePlexShowById, Either<BaseError, string>>.Handle(
SynchronizePlexShowById request,
CancellationToken cancellationToken) => Handle(request, cancellationToken);
private async Task<Either<BaseError, string>> Handle(
SynchronizePlexShowById request,
CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate(request);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
error =>
{
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
{
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
}
return Task.FromResult<Either<BaseError, string>>(error.Join());
});
}
private async Task<Either<BaseError, string>> PerformScan(
string scanner,
SynchronizePlexShowById request,
CancellationToken cancellationToken)
{
var arguments = new List<string>
{
"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<DateTimeOffset> GetLastScan(
TvContext dbContext,
SynchronizePlexShowById request)
{
return Task.FromResult(DateTimeOffset.MinValue);
}
protected override bool ScanIsRequired(
DateTimeOffset lastScan,
int libraryRefreshInterval,
SynchronizePlexShowById request)
{
return true;
}
}

6
ErsatzTV.Application/Plex/Commands/SynchronizePlexShowById.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Plex;
public record SynchronizePlexShowById(int PlexLibraryId, int ShowId, bool DeepScan) :
IRequest<Either<BaseError, string>>, IScannerBackgroundServiceRequest;

16
ErsatzTV.Application/Television/Mapper.cs

@ -12,9 +12,20 @@ internal static class Mapper @@ -12,9 +12,20 @@ internal static class Mapper
Show show,
List<string> languages,
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby) =>
new(
Option<EmbyMediaSource> 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 @@ -36,6 +47,7 @@ internal static class Mapper
.Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin, maybeEmby))
.ToList())
.IfNone([]));
}
internal static TelevisionSeasonViewModel ProjectToViewModel(
Season season,

59
ErsatzTV.Application/Television/Queries/GetTelevisionShowByIdHandler.cs

@ -1,5 +1,8 @@ @@ -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; @@ -7,15 +10,15 @@ namespace ErsatzTV.Application.Television;
public class GetTelevisionShowByIdHandler : IRequestHandler<GetTelevisionShowById, Option<TelevisionShowViewModel>>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchRepository _searchRepository;
private readonly ITelevisionRepository _televisionRepository;
public GetTelevisionShowByIdHandler(
ITelevisionRepository televisionRepository,
IDbContextFactory<TvContext> dbContextFactory,
ISearchRepository searchRepository,
IMediaSourceRepository mediaSourceRepository)
{
_televisionRepository = televisionRepository;
_dbContextFactory = dbContextFactory;
_searchRepository = searchRepository;
_mediaSourceRepository = mediaSourceRepository;
}
@ -24,20 +27,40 @@ public class GetTelevisionShowByIdHandler : IRequestHandler<GetTelevisionShowByI @@ -24,20 +27,40 @@ public class GetTelevisionShowByIdHandler : IRequestHandler<GetTelevisionShowByI
GetTelevisionShowById request,
CancellationToken cancellationToken)
{
Option<Show> maybeShow = await _televisionRepository.GetShow(request.Id);
return await maybeShow.Match<Task<Option<TelevisionShowViewModel>>>(
async show =>
{
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone());
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
.Map(list => list.HeadOrNone());
List<string> mediaCodes = await _searchRepository.GetLanguagesForShow(show);
List<string> languageCodes = await _searchRepository.GetAllThreeLetterLanguageCodes(mediaCodes);
return ProjectToViewModel(show, languageCodes, maybeJellyfin, maybeEmby);
},
() => Task.FromResult(Option<TelevisionShowViewModel>.None));
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Show> 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);
foreach (Show show in maybeShow)
{
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone());
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
.Map(list => list.HeadOrNone());
List<string> mediaCodes = await _searchRepository.GetLanguagesForShow(show);
List<string> languageCodes = await _searchRepository.GetAllThreeLetterLanguageCodes(mediaCodes);
return ProjectToViewModel(show, languageCodes, maybeJellyfin, maybeEmby);
}
return Option<TelevisionShowViewModel>.None;
}
}

3
ErsatzTV.Application/Television/TelevisionShowViewModel.cs

@ -1,10 +1,13 @@ @@ -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,

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

@ -14,7 +14,7 @@ public class FakeTelevisionRepository : ITelevisionRepository @@ -14,7 +14,7 @@ public class FakeTelevisionRepository : ITelevisionRepository
public Task<List<Show>> GetAllShows() => throw new NotSupportedException();
public Task<Option<Show>> GetShow(int showId) => throw new NotSupportedException();
public Task<Option<int>> GetShowIdByTitle(int libraryId, string title) => throw new NotSupportedException();
public Task<List<ShowMetadata>> GetShowsForCards(List<int> ids) => throw new NotSupportedException();
public Task<List<SeasonMetadata>> GetSeasonsForCards(List<int> ids) => throw new NotSupportedException();

9
ErsatzTV.Core/Domain/MediaSource/MediaSourceKind.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Domain;
public enum MediaSourceKind
{
Local = 1,
Plex = 2,
Jellyfin = 3,
Emby = 4
}

12
ErsatzTV.Core/Interfaces/Emby/IEmbyApiClient.cs

@ -34,4 +34,16 @@ public interface IEmbyApiClient @@ -34,4 +34,16 @@ public interface IEmbyApiClient
string apiKey,
EmbyLibrary library,
string itemId);
Task<Either<BaseError, Option<EmbyShow>>> GetSingleShow(
string address,
string apiKey,
EmbyLibrary library,
string showId);
Task<Either<BaseError, List<EmbyShow>>> SearchShowsByTitle(
string address,
string apiKey,
EmbyLibrary library,
string showTitle);
}

9
ErsatzTV.Core/Interfaces/Emby/IEmbyTelevisionLibraryScanner.cs

@ -10,4 +10,13 @@ public interface IEmbyTelevisionLibraryScanner @@ -10,4 +10,13 @@ public interface IEmbyTelevisionLibraryScanner
EmbyLibrary library,
bool deepScan,
CancellationToken cancellationToken);
Task<Either<BaseError, Unit>> ScanSingleShow(
string address,
string apiKey,
EmbyLibrary library,
string showId,
string showTitle,
bool deepScan,
CancellationToken cancellationToken);
}

12
ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinApiClient.cs

@ -46,4 +46,16 @@ public interface IJellyfinApiClient @@ -46,4 +46,16 @@ public interface IJellyfinApiClient
string apiKey,
JellyfinLibrary library,
string itemId);
Task<Either<BaseError, Option<JellyfinShow>>> GetSingleShow(
string address,
string apiKey,
JellyfinLibrary library,
string showId);
Task<Either<BaseError, List<JellyfinShow>>> SearchShowsByTitle(
string address,
string apiKey,
JellyfinLibrary library,
string showTitle);
}

9
ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinTelevisionLibraryScanner.cs

@ -10,4 +10,13 @@ public interface IJellyfinTelevisionLibraryScanner @@ -10,4 +10,13 @@ public interface IJellyfinTelevisionLibraryScanner
JellyfinLibrary library,
bool deepScan,
CancellationToken cancellationToken);
Task<Either<BaseError, Unit>> ScanSingleShow(
string address,
string apiKey,
JellyfinLibrary library,
string showId,
string showTitle,
bool deepScan,
CancellationToken cancellationToken);
}

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

@ -88,4 +88,16 @@ public interface IPlexServerApiClient @@ -88,4 +88,16 @@ public interface IPlexServerApiClient
PlexConnection connection,
PlexServerAuthToken token,
PlexTag tag);
Task<Either<BaseError, List<PlexShow>>> SearchShowsByTitle(
PlexLibrary library,
string showTitle,
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, Option<PlexShow>>> GetSingleShow(
PlexLibrary library,
string showKey,
PlexConnection connection,
PlexServerAuthToken token);
}

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

@ -11,4 +11,13 @@ public interface IPlexTelevisionLibraryScanner @@ -11,4 +11,13 @@ public interface IPlexTelevisionLibraryScanner
PlexLibrary library,
bool deepScan,
CancellationToken cancellationToken);
Task<Either<BaseError, Unit>> ScanSingleShow(
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary library,
string showKey,
string showTitle,
bool deepScan,
CancellationToken cancellationToken);
}

3
ErsatzTV.Core/Interfaces/Repositories/IEmbyTelevisionRepository.cs

@ -6,4 +6,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories; @@ -6,4 +6,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IEmbyTelevisionRepository : IMediaServerTelevisionRepository<EmbyLibrary, EmbyShow, EmbySeason,
EmbyEpisode, EmbyItemEtag>
{
Task<Option<EmbyShowTitleItemIdResult>> GetShowTitleItemId(int libraryId, int showId);
}
public record EmbyShowTitleItemIdResult(string Title, string ItemId);

3
ErsatzTV.Core/Interfaces/Repositories/IJellyfinTelevisionRepository.cs

@ -7,4 +7,7 @@ public interface IJellyfinTelevisionRepository : IMediaServerTelevisionRepositor @@ -7,4 +7,7 @@ public interface IJellyfinTelevisionRepository : IMediaServerTelevisionRepositor
JellyfinSeason,
JellyfinEpisode, JellyfinItemEtag>
{
Task<Option<JellyfinShowTitleItemIdResult>> GetShowTitleItemId(int libraryId, int showId);
}
public record JellyfinShowTitleItemIdResult(string Title, string ItemId);

3
ErsatzTV.Core/Interfaces/Repositories/IPlexTelevisionRepository.cs

@ -9,6 +9,9 @@ public interface IPlexTelevisionRepository : IMediaServerTelevisionRepository<Pl @@ -9,6 +9,9 @@ public interface IPlexTelevisionRepository : IMediaServerTelevisionRepository<Pl
Task<List<int>> RemoveAllTags(PlexLibrary library, PlexTag tag, System.Collections.Generic.HashSet<int> keep);
Task<PlexShowAddTagResult> AddTag(PlexLibrary library, PlexShow show, PlexTag tag);
Task UpdateLastNetworksScan(PlexLibrary library);
Task<Option<PlexShowTitleKeyResult>> GetShowTitleKey(int libraryId, int showId);
}
public record PlexShowAddTagResult(Option<int> Existing, Option<int> Added);
public record PlexShowTitleKeyResult(string Title, string Key);

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

@ -10,6 +10,7 @@ public interface ITelevisionRepository @@ -10,6 +10,7 @@ public interface ITelevisionRepository
Task<bool> AllEpisodesExist(List<int> episodeIds);
Task<List<Show>> GetAllShows();
Task<Option<Show>> GetShow(int showId);
Task<Option<int>> GetShowIdByTitle(int libraryId, string title);
Task<List<ShowMetadata>> GetShowsForCards(List<int> ids);
Task<List<SeasonMetadata>> GetSeasonsForCards(List<int> ids);
Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids);

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

@ -420,6 +420,27 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository @@ -420,6 +420,27 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository
return None;
}
public async Task<Option<EmbyShowTitleItemIdResult>> GetShowTitleItemId(int libraryId, int showId)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<EmbyShow> 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<EmbyShowTitleItemIdResult>.None;
}
private static async Task UpdateShow(TvContext dbContext, EmbyShow existing, EmbyShow incoming)
{
// library path is used for search indexing later

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

@ -424,6 +424,27 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository @@ -424,6 +424,27 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
return None;
}
public async Task<Option<JellyfinShowTitleItemIdResult>> GetShowTitleItemId(int libraryId, int showId)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<JellyfinShow> 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<JellyfinShowTitleItemIdResult>.None;
}
private static async Task UpdateShow(TvContext dbContext, JellyfinShow existing, JellyfinShow incoming)
{
// library path is used for search indexing later

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

@ -493,6 +493,27 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository @@ -493,6 +493,27 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository
new { library.LastNetworksScan, library.Id });
}
public async Task<Option<PlexShowTitleKeyResult>> GetShowTitleKey(int libraryId, int showId)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<PlexShow> 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<PlexShowTitleKeyResult>.None;
}
private static async Task<Either<BaseError, MediaItemScanResult<PlexShow>>> AddShow(
TvContext dbContext,
PlexLibrary library,

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

@ -77,9 +77,21 @@ public class TelevisionRepository : ITelevisionRepository @@ -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<Option<int>> 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<int>.Some(showId) : Option<int>.None);
}
public async Task<List<ShowMetadata>> GetShowsForCards(List<int> ids)

80
ErsatzTV.Infrastructure/Emby/EmbyApiClient.cs

@ -187,6 +187,37 @@ public class EmbyApiClient : IEmbyApiClient @@ -187,6 +187,37 @@ public class EmbyApiClient : IEmbyApiClient
}
}
public async Task<Either<BaseError, Option<EmbyShow>>> GetSingleShow(
string address,
string apiKey,
EmbyLibrary library,
string showId)
{
try
{
IEmbyApi service = RestService.For<IEmbyApi>(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<Tuple<TItem, int>> GetPagedLibraryContents<TItem>(
string address,
Option<EmbyLibrary> maybeLibrary,
@ -893,4 +924,53 @@ public class EmbyApiClient : IEmbyApiClient @@ -893,4 +924,53 @@ public class EmbyApiClient : IEmbyApiClient
return version;
});
}
public async Task<Either<BaseError, List<EmbyShow>>> SearchShowsByTitle(
string address,
string apiKey,
EmbyLibrary library,
string showTitle)
{
try
{
IEmbyApi service = RestService.For<IEmbyApi>(address);
EmbySearchHintsResponse searchResponse = await service.SearchHints(
apiKey,
showTitle,
"Series",
library.ItemId);
var shows = new List<EmbyShow>();
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<EmbyShow> 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);
}
}
}

17
ErsatzTV.Infrastructure/Emby/IEmbyApi.cs

@ -51,7 +51,9 @@ public interface IEmbyApi @@ -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<EmbyLibraryItemsResponse> GetSeasonLibraryItems(
@ -122,4 +124,17 @@ public interface IEmbyApi @@ -122,4 +124,17 @@ public interface IEmbyApi
[Header("X-Emby-Token")]
string apiKey,
string itemId);
[Get("/Search/Hints")]
public Task<EmbySearchHintsResponse> SearchHints(
[Header("X-Emby-Token")]
string apiKey,
[Query]
string searchTerm,
[Query]
string includeItemTypes = "Series",
[Query]
string parentId = null,
[Query]
int limit = 20);
}

20
ErsatzTV.Infrastructure/Emby/Models/EmbySearchHintsResponse.cs

@ -0,0 +1,20 @@ @@ -0,0 +1,20 @@
namespace ErsatzTV.Infrastructure.Emby.Models;
public class EmbySearchHintsResponse
{
public List<EmbySearchHintResponse> 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<string> BackdropImageTags { get; set; } = [];
}

17
ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs

@ -58,7 +58,9 @@ public interface IJellyfinApi @@ -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<JellyfinLibraryItemsResponse> GetSeasonLibraryItems(
@ -133,4 +135,17 @@ public interface IJellyfinApi @@ -133,4 +135,17 @@ public interface IJellyfinApi
[Header("X-Emby-Token")]
string apiKey,
string itemId);
[Get("/Search/Hints")]
public Task<JellyfinSearchHintsResponse> SearchHints(
[Header("X-Emby-Token")]
string apiKey,
[Query]
string searchTerm,
[Query]
string includeItemTypes = "Series",
[Query]
string parentId = null,
[Query]
int limit = 20);
}

80
ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs

@ -201,6 +201,37 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -201,6 +201,37 @@ public class JellyfinApiClient : IJellyfinApiClient
}
}
public async Task<Either<BaseError, Option<JellyfinShow>>> GetSingleShow(
string address,
string apiKey,
JellyfinLibrary library,
string showId)
{
try
{
IJellyfinApi service = RestService.For<IJellyfinApi>(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<Tuple<TItem, int>> GetPagedLibraryItems<TItem>(
string address,
Option<JellyfinLibrary> maybeLibrary,
@ -962,4 +993,53 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -962,4 +993,53 @@ public class JellyfinApiClient : IJellyfinApiClient
return version;
});
}
public async Task<Either<BaseError, List<JellyfinShow>>> SearchShowsByTitle(
string address,
string apiKey,
JellyfinLibrary library,
string showTitle)
{
try
{
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
JellyfinSearchHintsResponse searchResponse = await service.SearchHints(
apiKey,
showTitle,
"Series",
library.ItemId);
var shows = new List<JellyfinShow>();
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<JellyfinShow> 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);
}
}
}

20
ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinSearchHintsResponse.cs

@ -0,0 +1,20 @@ @@ -0,0 +1,20 @@
namespace ErsatzTV.Infrastructure.Jellyfin.Models;
public class JellyfinSearchHintsResponse
{
public List<JellyfinSearchHintResponse> 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<string> BackdropImageTags { get; set; } = [];
}

11
ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs

@ -168,4 +168,15 @@ public interface IPlexServerApi @@ -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<PlexMediaContainerResponse<PlexMediaContainerHubContent<PlexHubResponse>>>
Search(
[Query] [AliasAs("query")]
string searchTerm,
[Query] [AliasAs("sectionId")]
string sectionId,
[Query] [AliasAs("X-Plex-Token")]
string token);
}

15
ErsatzTV.Infrastructure/Plex/Models/PlexHubResponse.cs

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
namespace ErsatzTV.Infrastructure.Plex.Models;
public class PlexMediaContainerHubContent<T>
{
public List<T> 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<PlexMetadataResponse> Metadata { get; set; } = [];
}

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

@ -7,6 +7,9 @@ public class PlexMetadataResponse @@ -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; }

61
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

@ -425,6 +425,67 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -425,6 +425,67 @@ public class PlexServerApiClient : IPlexServerApiClient
}
}
public async Task<Either<BaseError, Option<PlexShow>>> 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<BaseError>($"Unable to locate show with key {showKey}"));
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
public async Task<Either<BaseError, List<PlexShow>>> SearchShowsByTitle(
PlexLibrary library,
string showTitle,
PlexConnection connection,
PlexServerAuthToken token)
{
try
{
IPlexServerApi service = RestService.For<IPlexServerApi>(
new HttpClient { BaseAddress = new Uri(connection.Uri) });
PlexMediaContainerResponse<PlexMediaContainerHubContent<PlexHubResponse>> searchResponse =
await service.Search(showTitle, library.Key, token.AuthToken);
var shows = new List<PlexShow>();
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();

6
ErsatzTV.Scanner/Application/Emby/Commands/SynchronizeEmbyShowById.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Scanner.Application.Emby;
public record SynchronizeEmbyShowById(int EmbyLibraryId, int ShowId, bool DeepScan)
: IRequest<Either<BaseError, string>>;

135
ErsatzTV.Scanner/Application/Emby/Commands/SynchronizeEmbyShowByIdHandler.cs

@ -0,0 +1,135 @@ @@ -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<SynchronizeEmbyShowById, Either<BaseError, string>>
{
private readonly IEmbySecretStore _embySecretStore;
private readonly IEmbyTelevisionLibraryScanner _embyTelevisionLibraryScanner;
private readonly ILogger<SynchronizeEmbyShowByIdHandler> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IEmbyTelevisionRepository _embyTelevisionRepository;
public SynchronizeEmbyShowByIdHandler(
IMediaSourceRepository mediaSourceRepository,
IEmbyTelevisionRepository embyTelevisionRepository,
IEmbySecretStore embySecretStore,
IEmbyTelevisionLibraryScanner embyTelevisionLibraryScanner,
ILogger<SynchronizeEmbyShowByIdHandler> logger)
{
_mediaSourceRepository = mediaSourceRepository;
_embyTelevisionRepository = embyTelevisionRepository;
_embySecretStore = embySecretStore;
_embyTelevisionLibraryScanner = embyTelevisionLibraryScanner;
_logger = logger;
}
public async Task<Either<BaseError, string>> Handle(
SynchronizeEmbyShowById request,
CancellationToken cancellationToken)
{
Validation<BaseError, RequestParameters> validation = await Validate(request);
return await validation.Match(
parameters => Synchronize(parameters, cancellationToken),
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
}
private async Task<Either<BaseError, string>> 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<BaseError, Unit> 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<Validation<BaseError, RequestParameters>> 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<Validation<BaseError, ConnectionParameters>> ValidateConnection(
SynchronizeEmbyShowById request) =>
EmbyMediaSourceMustExist(request)
.BindT(MediaSourceMustHaveActiveConnection)
.BindT(MediaSourceMustHaveApiKey);
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist(
SynchronizeEmbyShowById request) =>
_mediaSourceRepository.GetEmbyByLibraryId(request.EmbyLibraryId)
.Map(v => v.ToValidation<BaseError>(
$"Emby media source for library {request.EmbyLibraryId} does not exist."));
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
EmbyMediaSource embyMediaSource)
{
Option<EmbyConnection> maybeConnection = embyMediaSource.Connections.HeadOrNone();
return maybeConnection.Map(connection => new ConnectionParameters(connection))
.ToValidation<BaseError>("Emby media source requires an active connection");
}
private async Task<Validation<BaseError, ConnectionParameters>> 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<BaseError>("Emby media source requires an api key");
}
private Task<Validation<BaseError, EmbyLibrary>> EmbyLibraryMustExist(
SynchronizeEmbyShowById request) =>
_mediaSourceRepository.GetEmbyLibrary(request.EmbyLibraryId)
.Map(v => v.ToValidation<BaseError>($"Emby library {request.EmbyLibraryId} does not exist."));
private Task<Validation<BaseError, EmbyShowTitleItemIdResult>> EmbyShowMustExist(
SynchronizeEmbyShowById request) =>
_embyTelevisionRepository.GetShowTitleItemId(request.EmbyLibraryId, request.ShowId)
.Map(v => v.ToValidation<BaseError>($"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; }
}
}

6
ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinShowById.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Scanner.Application.Jellyfin;
public record SynchronizeJellyfinShowById(int JellyfinLibraryId, int ShowId, bool DeepScan)
: IRequest<Either<BaseError, string>>;

135
ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinShowByIdHandler.cs

@ -0,0 +1,135 @@ @@ -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<SynchronizeJellyfinShowById, Either<BaseError, string>>
{
private readonly IJellyfinSecretStore _jellyfinSecretStore;
private readonly IJellyfinTelevisionLibraryScanner _jellyfinTelevisionLibraryScanner;
private readonly ILogger<SynchronizeJellyfinShowByIdHandler> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IJellyfinTelevisionRepository _jellyfinTelevisionRepository;
public SynchronizeJellyfinShowByIdHandler(
IMediaSourceRepository mediaSourceRepository,
IJellyfinTelevisionRepository jellyfinTelevisionRepository,
IJellyfinSecretStore jellyfinSecretStore,
IJellyfinTelevisionLibraryScanner jellyfinTelevisionLibraryScanner,
ILogger<SynchronizeJellyfinShowByIdHandler> logger)
{
_mediaSourceRepository = mediaSourceRepository;
_jellyfinTelevisionRepository = jellyfinTelevisionRepository;
_jellyfinSecretStore = jellyfinSecretStore;
_jellyfinTelevisionLibraryScanner = jellyfinTelevisionLibraryScanner;
_logger = logger;
}
public async Task<Either<BaseError, string>> Handle(
SynchronizeJellyfinShowById request,
CancellationToken cancellationToken)
{
Validation<BaseError, RequestParameters> validation = await Validate(request);
return await validation.Match(
parameters => Synchronize(parameters, cancellationToken),
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
}
private async Task<Either<BaseError, string>> 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<BaseError, Unit> 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<Validation<BaseError, RequestParameters>> 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<Validation<BaseError, ConnectionParameters>> ValidateConnection(
SynchronizeJellyfinShowById request) =>
JellyfinMediaSourceMustExist(request)
.BindT(MediaSourceMustHaveActiveConnection)
.BindT(MediaSourceMustHaveApiKey);
private Task<Validation<BaseError, JellyfinMediaSource>> JellyfinMediaSourceMustExist(
SynchronizeJellyfinShowById request) =>
_mediaSourceRepository.GetJellyfinByLibraryId(request.JellyfinLibraryId)
.Map(v => v.ToValidation<BaseError>(
$"Jellyfin media source for library {request.JellyfinLibraryId} does not exist."));
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
JellyfinMediaSource jellyfinMediaSource)
{
Option<JellyfinConnection> maybeConnection = jellyfinMediaSource.Connections.HeadOrNone();
return maybeConnection.Map(connection => new ConnectionParameters(connection))
.ToValidation<BaseError>("Jellyfin media source requires an active connection");
}
private async Task<Validation<BaseError, ConnectionParameters>> 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<BaseError>("Jellyfin media source requires an api key");
}
private Task<Validation<BaseError, JellyfinLibrary>> JellyfinLibraryMustExist(
SynchronizeJellyfinShowById request) =>
_mediaSourceRepository.GetJellyfinLibrary(request.JellyfinLibraryId)
.Map(v => v.ToValidation<BaseError>($"Jellyfin library {request.JellyfinLibraryId} does not exist."));
private Task<Validation<BaseError, JellyfinShowTitleItemIdResult>> JellyfinShowMustExist(
SynchronizeJellyfinShowById request) =>
_jellyfinTelevisionRepository.GetShowTitleItemId(request.JellyfinLibraryId, request.ShowId)
.Map(v => v.ToValidation<BaseError>($"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; }
}
}

6
ErsatzTV.Scanner/Application/Plex/Commands/SynchronizePlexShowById.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Scanner.Application.Plex;
public record SynchronizePlexShowById(int PlexLibraryId, int ShowId, bool DeepScan)
: IRequest<Either<BaseError, string>>;

135
ErsatzTV.Scanner/Application/Plex/Commands/SynchronizePlexShowByIdHandler.cs

@ -0,0 +1,135 @@ @@ -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<SynchronizePlexShowById, Either<BaseError, string>>
{
private readonly ILogger<SynchronizePlexShowByIdHandler> _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<SynchronizePlexShowByIdHandler> logger)
{
_plexTelevisionRepository = plexTelevisionRepository;
_mediaSourceRepository = mediaSourceRepository;
_plexSecretStore = plexSecretStore;
_plexTelevisionLibraryScanner = plexTelevisionLibraryScanner;
_logger = logger;
}
public async Task<Either<BaseError, string>> Handle(
SynchronizePlexShowById request,
CancellationToken cancellationToken)
{
Validation<BaseError, RequestParameters> validation = await Validate(request);
return await validation.Match(
parameters => Synchronize(parameters, cancellationToken),
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
}
private async Task<Either<BaseError, string>> 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<BaseError, Unit> 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<Validation<BaseError, RequestParameters>> 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<Validation<BaseError, ConnectionParameters>> ValidateConnection(
SynchronizePlexShowById request) =>
PlexMediaSourceMustExist(request)
.BindT(MediaSourceMustHaveActiveConnection)
.BindT(MediaSourceMustHaveToken);
private Task<Validation<BaseError, PlexMediaSource>> PlexMediaSourceMustExist(
SynchronizePlexShowById request) =>
_mediaSourceRepository.GetPlexByLibraryId(request.PlexLibraryId)
.Map(v => v.ToValidation<BaseError>(
$"Plex media source for library {request.PlexLibraryId} does not exist."));
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
PlexMediaSource plexMediaSource)
{
Option<PlexConnection> maybeConnection =
plexMediaSource.Connections.SingleOrDefault(c => c.IsActive);
return maybeConnection.Map(connection => new ConnectionParameters(plexMediaSource, connection))
.ToValidation<BaseError>("Plex media source requires an active connection");
}
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveToken(
ConnectionParameters connectionParameters)
{
Option<PlexServerAuthToken> maybeToken = await
_plexSecretStore.GetServerAuthToken(connectionParameters.PlexMediaSource.ClientIdentifier);
return maybeToken.Map(token => connectionParameters with { PlexServerAuthToken = token })
.ToValidation<BaseError>("Plex media source requires a token");
}
private Task<Validation<BaseError, PlexLibrary>> PlexLibraryMustExist(
SynchronizePlexShowById request) =>
_mediaSourceRepository.GetPlexLibrary(request.PlexLibraryId)
.Map(v => v.ToValidation<BaseError>($"Plex library {request.PlexLibraryId} does not exist."));
private Task<Validation<BaseError, PlexShowTitleKeyResult>> PlexShowMustExist(
SynchronizePlexShowById request) =>
_plexTelevisionRepository.GetShowTitleKey(request.PlexLibraryId, request.ShowId)
.Map(v => v.ToValidation<BaseError>($"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; }
}
}

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

@ -1,6 +1,7 @@ @@ -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< @@ -184,4 +185,88 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
MediaItemScanResult<EmbyEpisode> result,
EpisodeMetadata fullMetadata) =>
Task.FromResult<Either<BaseError, MediaItemScanResult<EmbyEpisode>>>(result);
public async Task<Either<BaseError, Unit>> ScanSingleShow(
string address,
string apiKey,
EmbyLibrary library,
string showId,
string showTitle,
bool deepScan,
CancellationToken cancellationToken)
{
List<EmbyPathReplacement> 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<BaseError, Option<EmbyShow>> 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<BaseError, Unit>(Unit.Default);
},
error => Task.FromResult<Either<BaseError, Unit>>(error));
}
private async Task<Either<BaseError, Unit>> ScanSingleShowInternal(
IEmbyTelevisionRepository televisionRepository,
EmbyConnectionParameters connectionParameters,
EmbyLibrary library,
EmbyShow targetShow,
Func<EmbyEpisode, string> getLocalPath,
bool deepScan,
CancellationToken cancellationToken)
{
try
{
async IAsyncEnumerable<Tuple<EmbyShow, int>> GetSingleShow()
{
yield return new Tuple<EmbyShow, int>(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();
}
}
}

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

@ -1,5 +1,6 @@ @@ -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 @@ -183,4 +184,88 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
MediaItemScanResult<JellyfinEpisode> result,
EpisodeMetadata fullMetadata) =>
Task.FromResult<Either<BaseError, MediaItemScanResult<JellyfinEpisode>>>(result);
public async Task<Either<BaseError, Unit>> ScanSingleShow(
string address,
string apiKey,
JellyfinLibrary library,
string showId,
string showTitle,
bool deepScan,
CancellationToken cancellationToken)
{
List<JellyfinPathReplacement> 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<BaseError, Option<JellyfinShow>> 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<BaseError, Unit>(Unit.Default);
},
error => Task.FromResult<Either<BaseError, Unit>>(error));
}
private async Task<Either<BaseError, Unit>> ScanSingleShowInternal(
IJellyfinTelevisionRepository televisionRepository,
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library,
JellyfinShow targetShow,
Func<JellyfinEpisode, string> getLocalPath,
bool deepScan,
CancellationToken cancellationToken)
{
try
{
async IAsyncEnumerable<Tuple<JellyfinShow, int>> GetSingleShow()
{
yield return new Tuple<JellyfinShow, int>(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();
}
}
}

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

@ -80,13 +80,14 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters, @@ -80,13 +80,14 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
protected abstract string MediaServerEtag(TSeason season);
protected abstract string MediaServerEtag(TEpisode episode);
private async Task<Either<BaseError, Unit>> ScanLibrary(
protected async Task<Either<BaseError, Unit>> InternalScanLibrary(
IMediaServerTelevisionRepository<TLibrary, TShow, TSeason, TEpisode, TEtag> televisionRepository,
TConnectionParameters connectionParameters,
TLibrary library,
Func<TEpisode, string> getLocalPath,
IAsyncEnumerable<Tuple<TShow, int>> showEntries,
bool deepScan,
bool cleanupFileNotFoundItems,
CancellationToken cancellationToken)
{
var incomingItemIds = new List<string>();
@ -168,12 +169,15 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters, @@ -168,12 +169,15 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
}
}
// trash shows that are no longer present on the media server
var fileNotFoundItemIds = existingShows.Map(s => s.MediaServerItemId).Except(incomingItemIds).ToList();
List<int> ids = await televisionRepository.FlagFileNotFoundShows(library, fileNotFoundItemIds);
await _mediator.Publish(
new ScannerProgressUpdate(library.Id, null, null, ids.ToArray(), Array.Empty<int>()),
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<int> ids = await televisionRepository.FlagFileNotFoundShows(library, fileNotFoundItemIds);
await _mediator.Publish(
new ScannerProgressUpdate(library.Id, null, null, ids.ToArray(), Array.Empty<int>()),
cancellationToken);
}
await _mediator.Publish(
new ScannerProgressUpdate(
@ -187,6 +191,46 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters, @@ -187,6 +191,46 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
return Unit.Default;
}
protected async Task<Either<BaseError, Unit>> ScanLibrary(
IMediaServerTelevisionRepository<TLibrary, TShow, TSeason, TEpisode, TEtag> televisionRepository,
TConnectionParameters connectionParameters,
TLibrary library,
Func<TEpisode, string> getLocalPath,
IAsyncEnumerable<Tuple<TShow, int>> showEntries,
bool deepScan,
CancellationToken cancellationToken)
{
return await InternalScanLibrary(
televisionRepository,
connectionParameters,
library,
getLocalPath,
showEntries,
deepScan,
cleanupFileNotFoundItems: true,
cancellationToken);
}
protected async Task<Either<BaseError, Unit>> ScanLibraryWithoutCleanup(
IMediaServerTelevisionRepository<TLibrary, TShow, TSeason, TEpisode, TEtag> televisionRepository,
TConnectionParameters connectionParameters,
TLibrary library,
Func<TEpisode, string> getLocalPath,
IAsyncEnumerable<Tuple<TShow, int>> showEntries,
bool deepScan,
CancellationToken cancellationToken)
{
return await InternalScanLibrary(
televisionRepository,
connectionParameters,
library,
getLocalPath,
showEntries,
deepScan,
cleanupFileNotFoundItems: false,
cancellationToken);
}
protected abstract IAsyncEnumerable<Tuple<TSeason, int>> GetSeasonLibraryItems(
TLibrary library,
TConnectionParameters connectionParameters,

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

@ -1,5 +1,7 @@ @@ -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; @@ -12,10 +14,12 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Scanner.Core.Plex;
public class PlexTelevisionLibraryScanner :
public partial class PlexTelevisionLibraryScanner :
MediaServerTelevisionLibraryScanner<PlexConnectionParameters, PlexLibrary, PlexShow, PlexSeason, PlexEpisode,
PlexItemEtag>, IPlexTelevisionLibraryScanner
{
private static readonly Regex RatingKeyPattern = RatingKey();
private readonly ILogger<PlexTelevisionLibraryScanner> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMetadataRepository _metadataRepository;
@ -81,6 +85,96 @@ public class PlexTelevisionLibraryScanner : @@ -81,6 +85,96 @@ public class PlexTelevisionLibraryScanner :
cancellationToken);
}
public async Task<Either<BaseError, Unit>> ScanSingleShow(
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary library,
string showKey,
string showTitle,
bool deepScan,
CancellationToken cancellationToken)
{
List<PlexPathReplacement> 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<BaseError, Option<PlexShow>> 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<BaseError, Unit>(Unit.Default);
},
error => Task.FromResult<Either<BaseError, Unit>>(error));
}
private async Task<Either<BaseError, Unit>> ScanSingleShowInternal(
IMediaServerTelevisionRepository<PlexLibrary, PlexShow, PlexSeason, PlexEpisode, PlexItemEtag> televisionRepository,
PlexConnectionParameters connectionParameters,
PlexLibrary library,
PlexShow targetShow,
Func<PlexEpisode, string> getLocalPath,
bool deepScan,
CancellationToken cancellationToken)
{
try
{
async IAsyncEnumerable<Tuple<PlexShow, int>> GetSingleShow()
{
yield return new Tuple<PlexShow, int>(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<Either<BaseError, MediaItemScanResult<PlexEpisode>>> UpdateMetadata(
// MediaItemScanResult<PlexEpisode> result,
@ -652,4 +746,7 @@ public class PlexTelevisionLibraryScanner : @@ -652,4 +746,7 @@ public class PlexTelevisionLibraryScanner :
return false;
}
[GeneratedRegex(@".*\/(\d+)\/.*")]
private static partial Regex RatingKey();
}

72
ErsatzTV.Scanner/Worker.cs

@ -101,6 +101,27 @@ public class Worker : BackgroundService @@ -101,6 +101,27 @@ public class Worker : BackgroundService
scanJellyfinCollectionsCommand.Arguments.Add(mediaSourceIdArgument);
scanJellyfinCollectionsCommand.Options.Add(forceOption);
// Show-specific scanning commands
var showIdArgument = new Argument<int>("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 @@ -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<IMediator>();
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<IMediator>();
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<IMediator>();
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 @@ -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;
}

27
ErsatzTV/Controllers/Api/LibrariesController.cs

@ -1,15 +1,40 @@ @@ -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<IActionResult> ResetPlayout(int id) =>
await mediator.Send(new QueueLibraryScanByLibraryId(id))
? new OkResult()
: new NotFoundResult();
[HttpPost("/api/libraries/{id:int}/scan-show")]
public async Task<IActionResult> 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);

57
ErsatzTV/Pages/TelevisionSeasonList.razor

@ -1,5 +1,6 @@ @@ -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 @@ @@ -48,24 +49,28 @@
</MudCard>
}
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="mb-6">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@AddToCollection">
Add To Collection
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.PlaylistAdd"
OnClick="@AddToPlaylist">
Add To Playlist
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Schedule"
OnClick="@AddToSchedule">
Add To Schedule
</MudButton>
<MudMenu>
<ActivatorContent>
<MudButton StartIcon="@Icons.Material.Filled.Add" Variant="Variant.Filled" Color="Color.Primary">Add To</MudButton>
</ActivatorContent>
<ChildContent>
<MudMenuItem Label="Collection" Icon="@Icons.Material.Filled.Add" OnClick="@AddToCollection"/>
<MudMenuItem Label="Playlist" Icon="@Icons.Material.Filled.PlaylistAdd" OnClick="@AddToPlaylist"/>
<MudMenuItem Label="Schedule" Icon="@Icons.Material.Filled.Schedule" OnClick="@AddToSchedule"/>
</ChildContent>
</MudMenu>
@if (_show?.MediaSourceKind is MediaSourceKind.Plex or MediaSourceKind.Jellyfin or MediaSourceKind.Emby)
{
<MudMenu>
<ActivatorContent>
<MudButton StartIcon="@Icons.Material.Filled.Search" Variant="Variant.Filled" Color="Color.Primary">Scan</MudButton>
</ActivatorContent>
<ChildContent>
<MudMenuItem Label="Quick Scan" Icon="@Icons.Material.Filled.Search" OnClick="@(_ => ScanShow(false))"/>
<MudMenuItem Label="Deep Scan" Icon="@Icons.Material.Filled.ManageSearch" OnClick="@(_ => ScanShow(true))"/>
</ChildContent>
</MudMenu>
}
</MudStack>
</MudStack>
</MudStack>
@ -272,7 +277,7 @@ @@ -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 @@ @@ -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();
}
}
}
Loading…
Cancel
Save