217 lines
8.7 KiB
C#
217 lines
8.7 KiB
C#
using ErsatzTV.Core;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Errors;
|
|
using ErsatzTV.Core.Extensions;
|
|
using ErsatzTV.Core.Interfaces.Jellyfin;
|
|
using ErsatzTV.Core.Interfaces.Repositories;
|
|
using ErsatzTV.Core.Jellyfin;
|
|
using ErsatzTV.Core.Metadata;
|
|
using ErsatzTV.Scanner.Core.Interfaces;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace ErsatzTV.Scanner.Core.Jellyfin;
|
|
|
|
public class JellyfinMusicVideoLibraryScanner : IJellyfinMusicVideoLibraryScanner
|
|
{
|
|
private const string UnknownArtist = "Unknown Artist";
|
|
|
|
private readonly IArtistRepository _artistRepository;
|
|
private readonly IJellyfinApiClient _jellyfinApiClient;
|
|
private readonly IJellyfinPathReplacementService _pathReplacementService;
|
|
private readonly ILibraryRepository _libraryRepository;
|
|
private readonly ILogger<JellyfinMusicVideoLibraryScanner> _logger;
|
|
private readonly IMediaSourceRepository _mediaSourceRepository;
|
|
private readonly IMetadataRepository _metadataRepository;
|
|
private readonly IMusicVideoRepository _musicVideoRepository;
|
|
private readonly IScannerProxy _scannerProxy;
|
|
|
|
public JellyfinMusicVideoLibraryScanner(
|
|
IScannerProxy scannerProxy,
|
|
IJellyfinApiClient jellyfinApiClient,
|
|
IJellyfinPathReplacementService pathReplacementService,
|
|
IMediaSourceRepository mediaSourceRepository,
|
|
IArtistRepository artistRepository,
|
|
IMusicVideoRepository musicVideoRepository,
|
|
ILibraryRepository libraryRepository,
|
|
IMetadataRepository metadataRepository,
|
|
ILogger<JellyfinMusicVideoLibraryScanner> logger)
|
|
{
|
|
_scannerProxy = scannerProxy;
|
|
_jellyfinApiClient = jellyfinApiClient;
|
|
_pathReplacementService = pathReplacementService;
|
|
_mediaSourceRepository = mediaSourceRepository;
|
|
_artistRepository = artistRepository;
|
|
_musicVideoRepository = musicVideoRepository;
|
|
_libraryRepository = libraryRepository;
|
|
_metadataRepository = metadataRepository;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<Either<BaseError, Unit>> ScanLibrary(
|
|
JellyfinConnectionParameters connectionParameters,
|
|
JellyfinLibrary library,
|
|
bool deepScan,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
Option<LibraryPath> maybeLibraryPath = Optional(library.Paths).Flatten().HeadOrNone();
|
|
return await maybeLibraryPath.Match(
|
|
libraryPath => ScanLibrary(connectionParameters, library, libraryPath, cancellationToken),
|
|
() => Task.FromResult<Either<BaseError, Unit>>(
|
|
BaseError.New($"Jellyfin library {library.Id} has no library path")));
|
|
}
|
|
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
|
{
|
|
return new ScanCanceled();
|
|
}
|
|
}
|
|
|
|
private async Task<Either<BaseError, Unit>> ScanLibrary(
|
|
JellyfinConnectionParameters connectionParameters,
|
|
JellyfinLibrary library,
|
|
LibraryPath libraryPath,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
List<JellyfinPathReplacement> pathReplacements =
|
|
await _mediaSourceRepository.GetJellyfinPathReplacements(library.MediaSourceId);
|
|
|
|
var processed = 0;
|
|
await foreach ((MusicVideo incoming, int totalCount) in _jellyfinApiClient
|
|
.GetMusicVideoLibraryItems(
|
|
connectionParameters.Address,
|
|
connectionParameters.AuthorizationHeader,
|
|
library)
|
|
.WithCancellation(cancellationToken))
|
|
{
|
|
if (cancellationToken.IsCancellationRequested)
|
|
{
|
|
return new ScanCanceled();
|
|
}
|
|
|
|
processed++;
|
|
decimal percentCompletion = totalCount == 0 ? 1 : Math.Clamp((decimal)processed / totalCount, 0, 1);
|
|
if (!await _scannerProxy.UpdateProgress(percentCompletion, cancellationToken))
|
|
{
|
|
return new ScanCanceled();
|
|
}
|
|
|
|
Either<BaseError, MediaItemScanResult<MusicVideo>> maybeMusicVideo =
|
|
await ProcessMusicVideo(library, libraryPath, pathReplacements, incoming, cancellationToken);
|
|
|
|
foreach (BaseError error in maybeMusicVideo.LeftToSeq())
|
|
{
|
|
_logger.LogWarning("Error processing Jellyfin music video: {Error}", error.Value);
|
|
}
|
|
|
|
foreach (MediaItemScanResult<MusicVideo> result in maybeMusicVideo.RightToSeq()
|
|
.Filter(result => result.IsAdded || result.IsUpdated))
|
|
{
|
|
if (!await _scannerProxy.ReindexMediaItems([result.Item.Id], cancellationToken))
|
|
{
|
|
_logger.LogWarning("Failed to reindex media items from scanner process");
|
|
}
|
|
}
|
|
}
|
|
|
|
return Unit.Default;
|
|
}
|
|
|
|
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> ProcessMusicVideo(
|
|
JellyfinLibrary library,
|
|
LibraryPath libraryPath,
|
|
List<JellyfinPathReplacement> pathReplacements,
|
|
MusicVideo incoming,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
string localPath = GetLocalPath(pathReplacements, incoming);
|
|
string folder = Path.GetDirectoryName(localPath) ?? libraryPath.Path;
|
|
Option<int> maybeParentFolder = await _libraryRepository.GetParentFolderId(libraryPath, folder, cancellationToken);
|
|
LibraryFolder libraryFolder = await _libraryRepository.GetOrAddFolder(libraryPath, maybeParentFolder, folder);
|
|
|
|
return await GetOrAddArtist(libraryPath, incoming)
|
|
.BindT(artist => _musicVideoRepository.GetOrAdd(artist, libraryPath, libraryFolder, localPath))
|
|
.BindT(result => UpdateMusicVideo(result, incoming, localPath, cancellationToken));
|
|
}
|
|
|
|
private string GetLocalPath(List<JellyfinPathReplacement> pathReplacements, MusicVideo musicVideo) =>
|
|
_pathReplacementService.GetReplacementJellyfinPath(
|
|
pathReplacements,
|
|
musicVideo.GetHeadVersion().MediaFiles.Head().Path,
|
|
false);
|
|
|
|
private async Task<Either<BaseError, Artist>> GetOrAddArtist(LibraryPath libraryPath, MusicVideo musicVideo)
|
|
{
|
|
string artistName = musicVideo.MusicVideoMetadata
|
|
.HeadOrNone()
|
|
.Bind(metadata => Optional(metadata.Artists).Flatten().HeadOrNone())
|
|
.Map(artist => artist.Name)
|
|
.IfNone(UnknownArtist);
|
|
|
|
var metadata = new ArtistMetadata
|
|
{
|
|
MetadataKind = MetadataKind.External,
|
|
Title = artistName,
|
|
SortTitle = SortTitle.GetSortTitle(artistName),
|
|
DateAdded = DateTime.UtcNow,
|
|
Genres = [],
|
|
Styles = [],
|
|
Moods = [],
|
|
Artwork = [],
|
|
Guids = [],
|
|
Subtitles = []
|
|
};
|
|
|
|
Option<Artist> maybeArtist = await _artistRepository.GetArtistByMetadata(libraryPath.Id, metadata);
|
|
foreach (Artist artist in maybeArtist)
|
|
{
|
|
return artist;
|
|
}
|
|
|
|
Either<BaseError, MediaItemScanResult<Artist>> result =
|
|
await _artistRepository.AddArtist(libraryPath.Id, artistName, metadata);
|
|
return result.Map(scanResult => scanResult.Item);
|
|
}
|
|
|
|
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> UpdateMusicVideo(
|
|
MediaItemScanResult<MusicVideo> result,
|
|
MusicVideo incoming,
|
|
string localPath,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
result.LocalPath = localPath;
|
|
MusicVideoMetadata incomingMetadata = incoming.MusicVideoMetadata.Head();
|
|
|
|
bool updated = await UpdateMetadata(result.Item, incomingMetadata);
|
|
updated = await _metadataRepository.UpdateStatistics(result.Item, incoming.GetHeadVersion()) || updated;
|
|
|
|
if (updated)
|
|
{
|
|
result.IsUpdated = true;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private async Task<bool> UpdateMetadata(MusicVideo musicVideo, MusicVideoMetadata incoming)
|
|
{
|
|
Option<MusicVideoMetadata> maybeExisting = Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone();
|
|
foreach (MusicVideoMetadata existing in maybeExisting)
|
|
{
|
|
existing.Title = incoming.Title;
|
|
existing.SortTitle = incoming.SortTitle;
|
|
existing.Plot = incoming.Plot;
|
|
existing.Year = incoming.Year;
|
|
existing.ReleaseDate = incoming.ReleaseDate;
|
|
existing.DateUpdated = DateTime.UtcNow;
|
|
existing.MetadataKind = MetadataKind.External;
|
|
|
|
return await _metadataRepository.Update(existing);
|
|
}
|
|
|
|
incoming.MusicVideoId = musicVideo.Id;
|
|
musicVideo.MusicVideoMetadata = [incoming];
|
|
return await _metadataRepository.Add(incoming);
|
|
}
|
|
}
|