diff --git a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation.cs b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation.cs index 03793a2d2..91bf5fe8e 100644 --- a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation.cs +++ b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation.cs @@ -44,7 +44,8 @@ public partial class ShokoServiceImplementation : Controller, IShokoServer private readonly JobFactory _jobFactory; private readonly TvDBApiHelper _tvdbHelper; private readonly TraktTVHelper _traktHelper; - private readonly TmdbMetadataService _tmdbService; + private readonly TmdbLinkingService _tmdbLinkingService; + private readonly TmdbMetadataService _tmdbMetadataService; private readonly ISettingsProvider _settingsProvider; private readonly ISchedulerFactory _schedulerFactory; private readonly ActionService _actionService; @@ -55,6 +56,7 @@ public partial class ShokoServiceImplementation : Controller, IShokoServer public ShokoServiceImplementation( TvDBApiHelper tvdbHelper, TraktTVHelper traktHelper, + TmdbLinkingService tmdbLinkingService, TmdbMetadataService tmdbService, ISchedulerFactory schedulerFactory, ISettingsProvider settingsProvider, @@ -62,7 +64,6 @@ public ShokoServiceImplementation( ActionService actionService, AnimeGroupCreator groupCreator, JobFactory jobFactory, - AnimeSeriesService seriesService, AnimeEpisodeService episodeService, WatchedStatusService watchedService, VideoLocalService videoLocalService @@ -70,7 +71,8 @@ VideoLocalService videoLocalService { _tvdbHelper = tvdbHelper; _traktHelper = traktHelper; - _tmdbService = tmdbService; + _tmdbLinkingService = tmdbLinkingService; + _tmdbMetadataService = tmdbService; _schedulerFactory = schedulerFactory; _settingsProvider = settingsProvider; _logger = logger; diff --git a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Providers.cs b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Providers.cs index 2861a3d4c..916fd8e43 100644 --- a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Providers.cs +++ b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Providers.cs @@ -1018,8 +1018,8 @@ public string LinkAniDBOther(int animeID, int id, int crossRefType) switch (xrefType) { case CrossRefType.MovieDB: - _tmdbService.AddMovieLink(animeID, id).ConfigureAwait(false).GetAwaiter().GetResult(); - _tmdbService.ScheduleUpdateOfMovie(id, downloadImages: true).ConfigureAwait(false).GetAwaiter().GetResult(); + _tmdbLinkingService.AddMovieLink(animeID, id).ConfigureAwait(false).GetAwaiter().GetResult(); + _tmdbMetadataService.ScheduleUpdateOfMovie(id, downloadImages: true).ConfigureAwait(false).GetAwaiter().GetResult(); break; } @@ -1048,7 +1048,7 @@ public string RemoveLinkAniDBOther(int animeID, int crossRefType) switch (xrefType) { case CrossRefType.MovieDB: - _tmdbService.RemoveAllMovieLinks(animeID).ConfigureAwait(false).GetAwaiter().GetResult(); + _tmdbLinkingService.RemoveAllMovieLinksForAnime(animeID).ConfigureAwait(false).GetAwaiter().GetResult(); break; } @@ -1071,7 +1071,7 @@ public List SearchTheMovieDB(string criteria) var results = new List(); try { - var (movieResults, _) = _tmdbService.SearchMovies(System.Web.HttpUtility.UrlDecode(criteria)).ConfigureAwait(false).GetAwaiter().GetResult(); + var (movieResults, _) = _tmdbMetadataService.SearchMovies(System.Web.HttpUtility.UrlDecode(criteria)).ConfigureAwait(false).GetAwaiter().GetResult(); results.AddRange(movieResults.Select(movie => movie.ToContract())); @@ -1131,7 +1131,7 @@ public string UpdateMovieDBData(int movieID) { try { - _tmdbService.ScheduleUpdateOfMovie(movieID, downloadImages: true, forceRefresh: true).ConfigureAwait(false).GetAwaiter().GetResult(); + _tmdbMetadataService.ScheduleUpdateOfMovie(movieID, downloadImages: true, forceRefresh: true).ConfigureAwait(false).GetAwaiter().GetResult(); } catch (Exception ex) { diff --git a/Shoko.Server/API/v3/Controllers/SeriesController.cs b/Shoko.Server/API/v3/Controllers/SeriesController.cs index 9435d77dd..96006a861 100644 --- a/Shoko.Server/API/v3/Controllers/SeriesController.cs +++ b/Shoko.Server/API/v3/Controllers/SeriesController.cs @@ -58,18 +58,38 @@ namespace Shoko.Server.API.v3.Controllers; public class SeriesController : BaseController { private readonly AnimeSeriesService _seriesService; + private readonly AniDBTitleHelper _titleHelper; + private readonly ISchedulerFactory _schedulerFactory; + + private readonly TmdbLinkingService _tmdbLinkingService; + private readonly TmdbMetadataService _tmdbMetadataService; + + private readonly TmdbSearchService _tmdbSearchService; + private readonly CrossRef_File_EpisodeRepository _crossRefFileEpisode; + private readonly WatchedStatusService _watchedService; - public SeriesController(ISettingsProvider settingsProvider, AnimeSeriesService seriesService, AniDBTitleHelper titleHelper, ISchedulerFactory schedulerFactory, TmdbMetadataService tmdbMetadataService, TmdbSearchService tmdbSearchService, CrossRef_File_EpisodeRepository crossRefFileEpisode, WatchedStatusService watchedService) : base(settingsProvider) + public SeriesController( + ISettingsProvider settingsProvider, + AnimeSeriesService seriesService, + AniDBTitleHelper titleHelper, + ISchedulerFactory schedulerFactory, + TmdbLinkingService tmdbLinkingService, + TmdbMetadataService tmdbMetadataService, + TmdbSearchService tmdbSearchService, + CrossRef_File_EpisodeRepository crossRefFileEpisode, + WatchedStatusService watchedService + ) : base(settingsProvider) { _seriesService = seriesService; _titleHelper = titleHelper; _schedulerFactory = schedulerFactory; + _tmdbLinkingService = tmdbLinkingService; _tmdbMetadataService = tmdbMetadataService; _tmdbSearchService = tmdbSearchService; _crossRefFileEpisode = crossRefFileEpisode; @@ -1217,7 +1237,7 @@ public async Task AddLinkToTMDBMoviesBySeriesID( if (!User.AllowedSeries(series)) return Forbid(SeriesForbiddenForUser); - await _tmdbMetadataService.AddMovieLink(series.AniDB_ID, body.ID, body.EpisodeID, additiveLink: !body.Replace); + await _tmdbLinkingService.AddMovieLink(series.AniDB_ID, body.ID, body.EpisodeID, additiveLink: !body.Replace); var needRefresh = RepoFactory.TMDB_Movie.GetByTmdbMovieID(body.ID) is null || body.Refresh; if (needRefresh) @@ -1247,9 +1267,9 @@ public async Task RemoveLinkToTMDBMoviesBySeriesID( return Forbid(SeriesForbiddenForUser); if (body != null && body.ID > 0) - await _tmdbMetadataService.RemoveMovieLink(series.AniDB_ID, body.ID, body.Purge); + await _tmdbLinkingService.RemoveMovieLink(series.AniDB_ID, body.ID, body.Purge); else - await _tmdbMetadataService.RemoveAllMovieLinks(series.AniDB_ID, body?.Purge ?? false); + await _tmdbLinkingService.RemoveAllMovieLinksForAnime(series.AniDB_ID, body?.Purge ?? false); return NoContent(); } @@ -1411,7 +1431,7 @@ public async Task AddLinkToTMDBShowsBySeriesID( if (!User.AllowedSeries(series)) return Forbid(SeriesForbiddenForUser); - await _tmdbMetadataService.AddShowLink(series.AniDB_ID, body.ID, additiveLink: !body.Replace); + await _tmdbLinkingService.AddShowLink(series.AniDB_ID, body.ID, additiveLink: !body.Replace); var needRefresh = RepoFactory.TMDB_Show.GetByTmdbShowID(body.ID) is null || body.Refresh; if (needRefresh) @@ -1441,9 +1461,9 @@ public async Task RemoveLinkToTMDBShowsBySeriesID( return Forbid(SeriesForbiddenForUser); if (body != null && body.ID > 0) - await _tmdbMetadataService.RemoveShowLink(series.AniDB_ID, body.ID, body.Purge); + await _tmdbLinkingService.RemoveShowLink(series.AniDB_ID, body.ID, body.Purge); else - await _tmdbMetadataService.RemoveAllShowLinks(series.AniDB_ID, body?.Purge ?? false); + await _tmdbLinkingService.RemoveAllShowLinksForAnime(series.AniDB_ID, body?.Purge ?? false); return NoContent(); } @@ -1644,15 +1664,15 @@ public async Task OverrideTMDBEpisodeMappingsBySeriesID( // Add any missing links if needed. foreach (var showId in missingIDs) - await _tmdbMetadataService.AddShowLink(series.AniDB_ID, showId, additiveLink: true); + await _tmdbLinkingService.AddShowLink(series.AniDB_ID, showId, additiveLink: true); // Reset the existing links if we wanted to replace all. if (body.ResetAll) - _tmdbMetadataService.ResetAllEpisodeLinks(series.AniDB_ID); + _tmdbLinkingService.ResetAllEpisodeLinks(series.AniDB_ID); // Do the actual linking. foreach (var link in body.Mapping) - _tmdbMetadataService.SetEpisodeLink(link.AniDBID, link.TmdbID, !link.Replace); + _tmdbLinkingService.SetEpisodeLink(link.AniDBID, link.TmdbID, !link.Replace); return NoContent(); } @@ -1709,7 +1729,7 @@ public async Task OverrideTMDBEpisodeMappingsBySeriesID( return ValidationProblem("The selected tmdbSeasonID does not belong to the selected tmdbShowID", "tmdbSeasonID"); } - return _tmdbMetadataService.MatchAnidbToTmdbEpisodes(series.AniDB_ID, tmdbShowID.Value, tmdbSeasonID, keepExisting, saveToDatabase: false) + return _tmdbLinkingService.MatchAnidbToTmdbEpisodes(series.AniDB_ID, tmdbShowID.Value, tmdbSeasonID, keepExisting, saveToDatabase: false) .ToListResult(x => new TmdbEpisode.CrossReference(x), page, pageSize); } @@ -1762,7 +1782,7 @@ public async Task AutoTMDBEpisodeMappingsBySeriesID( // Add the missing link if needed. if (isMissing) - await _tmdbMetadataService.AddShowLink(series.AniDB_ID, tmdbShowID.Value, additiveLink: true); + await _tmdbLinkingService.AddShowLink(series.AniDB_ID, tmdbShowID.Value, additiveLink: true); if (tmdbSeasonID.HasValue) { @@ -1774,7 +1794,7 @@ public async Task AutoTMDBEpisodeMappingsBySeriesID( return ValidationProblem("The selected tmdbSeasonID does not belong to the selected tmdbShowID", "tmdbSeasonID"); } - _tmdbMetadataService.MatchAnidbToTmdbEpisodes(series.AniDB_ID, tmdbShowID.Value, tmdbSeasonID, keepExisting, saveToDatabase: true); + _tmdbLinkingService.MatchAnidbToTmdbEpisodes(series.AniDB_ID, tmdbShowID.Value, tmdbSeasonID, keepExisting, saveToDatabase: true); return NoContent(); } @@ -1797,7 +1817,7 @@ public ActionResult RemoveTMDBEpisodeMappingsBySeriesID( if (!User.AllowedSeries(series)) return Forbid(TvdbForbiddenForUser); - _tmdbMetadataService.ResetAllEpisodeLinks(series.AniDB_ID); + _tmdbLinkingService.ResetAllEpisodeLinks(series.AniDB_ID); return NoContent(); } diff --git a/Shoko.Server/Providers/TMDB/TmdbImageService.cs b/Shoko.Server/Providers/TMDB/TmdbImageService.cs new file mode 100644 index 000000000..91b22e7ad --- /dev/null +++ b/Shoko.Server/Providers/TMDB/TmdbImageService.cs @@ -0,0 +1,228 @@ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Quartz; +using Shoko.Models.Enums; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Plugin.Abstractions.Enums; +using Shoko.Plugin.Abstractions.Extensions; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Repositories; +using Shoko.Server.Scheduling; +using Shoko.Server.Scheduling.Jobs.TMDB; +using Shoko.Server.Server; +using Shoko.Server.Settings; +using TMDbLib.Objects.General; + +// Suggestions we don't need in this file. +#pragma warning disable CA1822 +#pragma warning disable CA1826 + +#nullable enable +namespace Shoko.Server.Providers.TMDB; + +public class TmdbImageService +{ + private readonly ILogger _logger; + + private readonly ISettingsProvider _settingsProvider; + + private readonly ISchedulerFactory _schedulerFactory; + + public TmdbImageService( + ILogger logger, + ISettingsProvider settingsProvider, + ISchedulerFactory schedulerFactory + ) + { + _logger = logger; + _settingsProvider = settingsProvider; + _schedulerFactory = schedulerFactory; + } + + #region Image + + public async Task DownloadImageByType(string filePath, ImageEntityType type, ForeignEntityType foreignType, int foreignId, bool forceDownload = false) + { + var image = RepoFactory.TMDB_Image.GetByRemoteFileNameAndType(filePath, type) ?? new(filePath, type); + image.Populate(foreignType, foreignId); + if (string.IsNullOrEmpty(image.LocalPath)) + return; + + RepoFactory.TMDB_Image.Save(image); + + // Skip downloading if it already exists and we're not forcing it. + if (File.Exists(image.LocalPath) && !forceDownload) + return; + + await (await _schedulerFactory.GetScheduler().ConfigureAwait(false)).StartJob(c => + { + c.ImageID = image.TMDB_ImageID; + c.ImageType = image.ImageType; + c.ForceDownload = forceDownload; + }); + } + + public async Task DownloadImagesByType(IReadOnlyList images, ImageEntityType type, ForeignEntityType foreignType, int foreignId, int maxCount, List languages, bool forceDownload = false) + { + var count = 0; + var isLimitEnabled = maxCount > 0; + if (languages.Count > 0) + images = isLimitEnabled + ? images + .Select(image => (Image: image, Language: (image.Iso_639_1 ?? string.Empty).GetTitleLanguage())) + .Where(x => languages.Contains(x.Language)) + .OrderBy(x => languages.IndexOf(x.Language)) + .Select(x => x.Image) + .ToList() + : images + .Where(x => languages.Contains((x.Iso_639_1 ?? string.Empty).GetTitleLanguage())) + .ToList(); + foreach (var imageData in images) + { + if (isLimitEnabled && count >= maxCount) + break; + + count++; + var image = RepoFactory.TMDB_Image.GetByRemoteFileNameAndType(imageData.FilePath, type) ?? new(imageData.FilePath, type); + var updated = image.Populate(imageData, foreignType, foreignId); + if (updated) + RepoFactory.TMDB_Image.Save(image); + } + + count = 0; + var scheduler = await _schedulerFactory.GetScheduler(); + var storedImages = RepoFactory.TMDB_Image.GetByForeignIDAndType(foreignId, foreignType, type); + if (languages.Count > 0 && isLimitEnabled) + storedImages = storedImages + .OrderBy(x => languages.IndexOf(x.Language) is var index && index >= 0 ? index : int.MaxValue) + .ToList(); + foreach (var image in storedImages) + { + // Clean up invalid entries. + var path = image.LocalPath; + if (string.IsNullOrEmpty(path)) + { + RepoFactory.TMDB_Image.Delete(image.TMDB_ImageID); + continue; + } + + // Download image if the limit is disabled or if we're below the limit. + var fileExists = File.Exists(path); + if (!isLimitEnabled || count < maxCount) + { + // Skip downloading if it already exists and we're not forcing it. + count++; + if (fileExists && !forceDownload) + continue; + + // Otherwise scheduled the image to be downloaded. + await scheduler.StartJob(c => + { + c.ImageID = image.TMDB_ImageID; + c.ImageType = image.ImageType; + c.ForceDownload = forceDownload; + }); + } + // TODO: check if the image is linked to any other entries, and keep it if the other entries are within the limit. + // Else delete it from the local cache and database. + else + { + if (fileExists) + { + try + { + File.Delete(path); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete image file: {Path}", path); + } + } + RepoFactory.TMDB_Image.Delete(image.TMDB_ImageID); + } + } + } + + public void PurgeImages(ForeignEntityType foreignType, int foreignId, bool removeImageFiles) + { + var imagesToRemove = RepoFactory.TMDB_Image.GetByForeignID(foreignId, foreignType); + + _logger.LogDebug( + "Removing {count} images for {type} with id {EntityId}", + imagesToRemove.Count, + foreignType.ToString().ToLowerInvariant(), + foreignId); + foreach (var image in imagesToRemove) + PurgeImage(image, foreignType, removeImageFiles); + } + + public void PurgeImage(TMDB_Image image, ForeignEntityType foreignType, bool removeFile) + { + // Skip the operation if th flag is not set. + if (!image.ForeignType.HasFlag(foreignType)) + return; + + // Disable the flag. + image.ForeignType &= ~foreignType; + + // Only delete the image metadata and/or file if all references were removed. + if (image.ForeignType is ForeignEntityType.None) + { + if (removeFile && !string.IsNullOrEmpty(image.LocalPath) && File.Exists(image.LocalPath)) + File.Delete(image.LocalPath); + + RepoFactory.TMDB_Image.Delete(image.TMDB_ImageID); + } + // Remove the ID since we're keeping the metadata a little bit longer. + else + { + switch (foreignType) + { + case ForeignEntityType.Movie: + image.TmdbMovieID = null; + break; + case ForeignEntityType.Episode: + image.TmdbEpisodeID = null; + break; + case ForeignEntityType.Season: + image.TmdbSeasonID = null; + break; + case ForeignEntityType.Show: + image.TmdbShowID = null; + break; + case ForeignEntityType.Collection: + image.TmdbCollectionID = null; + break; + } + } + } + + public void ResetPreferredImage(int anidbAnimeId, ForeignEntityType foreignType, int foreignId) + { + var images = RepoFactory.AniDB_Anime_PreferredImage.GetByAnimeID(anidbAnimeId); + foreach (var defaultImage in images) + { + if (defaultImage.ImageSource == DataSourceType.TMDB) + { + var image = RepoFactory.TMDB_Image.GetByID(defaultImage.ImageID); + if (image == null) + { + _logger.LogTrace("Removing preferred image for anime {AnimeId} because the preferred image could not be found.", anidbAnimeId); + RepoFactory.AniDB_Anime_PreferredImage.Delete(defaultImage); + } + else if (image.ForeignType.HasFlag(foreignType) && image.GetForeignID(foreignType) == foreignId) + { + _logger.LogTrace("Removing preferred image for anime {AnimeId} because it belongs to now TMDB {Type} {Id}", anidbAnimeId, foreignType.ToString(), foreignId); + RepoFactory.AniDB_Anime_PreferredImage.Delete(defaultImage); + } + } + } + } + + #endregion +} diff --git a/Shoko.Server/Providers/TMDB/TmdbLinkingService.cs b/Shoko.Server/Providers/TMDB/TmdbLinkingService.cs new file mode 100644 index 000000000..2cebe59a6 --- /dev/null +++ b/Shoko.Server/Providers/TMDB/TmdbLinkingService.cs @@ -0,0 +1,494 @@ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Quartz; +using Shoko.Commons.Extensions; +using Shoko.Models.Enums; +using Shoko.Plugin.Abstractions.DataModels; +using Shoko.Server.Models; +using Shoko.Server.Models.CrossReference; +using Shoko.Server.Models.TMDB; +using Shoko.Server.Repositories; +using Shoko.Server.Scheduling; +using Shoko.Server.Scheduling.Jobs.TMDB; +using Shoko.Server.Server; +using Shoko.Server.Settings; +using Shoko.Server.Utilities; +using EpisodeType = Shoko.Models.Enums.EpisodeType; + +// Suggestions we don't need in this file. +#pragma warning disable CA1822 +#pragma warning disable CA1826 + +#nullable enable +namespace Shoko.Server.Providers.TMDB; + +public class TmdbLinkingService +{ + private readonly ILogger _logger; + + private readonly ISettingsProvider _settingsProvider; + + private readonly ISchedulerFactory _schedulerFactory; + + private readonly TmdbImageService _imageService; + + public TmdbLinkingService( + ILogger logger, + ISettingsProvider settingsProvider, + ISchedulerFactory schedulerFactory, + TmdbImageService imageService + ) + { + _logger = logger; + _settingsProvider = settingsProvider; + _schedulerFactory = schedulerFactory; + _imageService = imageService; + } + + #region Movie Links + + public async Task AddMovieLink(int animeId, int movieId, int? episodeId = null, bool additiveLink = false, bool isAutomatic = false) + { + // Remove all existing links. + if (!additiveLink) + await RemoveAllMovieLinksForAnime(animeId); + + // Add or update the link. + _logger.LogInformation("Adding TMDB Movie Link: AniDB (ID:{AnidbID}) → TMDB Movie (ID:{TmdbID})", animeId, movieId); + var xref = RepoFactory.CrossRef_AniDB_TMDB_Movie.GetByAnidbAnimeAndTmdbMovieIDs(animeId, movieId) ?? + new(animeId, movieId); + if (episodeId.HasValue) + xref.AnidbEpisodeID = episodeId.Value <= 0 ? null : episodeId.Value; + xref.Source = isAutomatic ? CrossRefSource.Automatic : CrossRefSource.User; + RepoFactory.CrossRef_AniDB_TMDB_Movie.Save(xref); + } + + public async Task RemoveMovieLink(int animeId, int movieId, bool purge = false, bool removeImageFiles = true) + { + var xref = RepoFactory.CrossRef_AniDB_TMDB_Movie.GetByAnidbAnimeAndTmdbMovieIDs(animeId, movieId); + if (xref == null) + return; + + // Disable auto-matching when we remove an existing match for the series. + var series = RepoFactory.AnimeSeries.GetByAnimeID(animeId); + if (series != null && !series.IsTMDBAutoMatchingDisabled) + { + series.IsTMDBAutoMatchingDisabled = true; + RepoFactory.AnimeSeries.Save(series, false, true, true); + } + + await RemoveMovieLink(xref, removeImageFiles, purge ? true : null); + } + + public async Task RemoveAllMovieLinksForAnime(int animeId, bool purge = false, bool removeImageFiles = true) + { + _logger.LogInformation("Removing All TMDB Movie Links for: {AnimeID}", animeId); + var xrefs = RepoFactory.CrossRef_AniDB_TMDB_Movie.GetByAnidbAnimeID(animeId); + if (xrefs.Count == 0) + return; + + // Disable auto-matching when we remove an existing match for the series. + var series = RepoFactory.AnimeSeries.GetByAnimeID(animeId); + if (series != null && !series.IsTMDBAutoMatchingDisabled) + { + series.IsTMDBAutoMatchingDisabled = true; + RepoFactory.AnimeSeries.Save(series, false, true, true); + } + + foreach (var xref in xrefs) + await RemoveMovieLink(xref, removeImageFiles, purge ? true : null); + } + + public async Task RemoveAllMovieLinksForMovie(int movieId) + { + var xrefs = RepoFactory.CrossRef_AniDB_TMDB_Movie.GetByTmdbMovieID(movieId); + if (xrefs.Count == 0) + return; + + foreach (var xref in xrefs) + await RemoveMovieLink(xref, false, false); + } + + private async Task RemoveMovieLink(CrossRef_AniDB_TMDB_Movie xref, bool removeImageFiles = true, bool? purge = null) + { + _imageService.ResetPreferredImage(xref.AnidbAnimeID, ForeignEntityType.Movie, xref.TmdbMovieID); + + _logger.LogInformation("Removing TMDB Movie Link: AniDB ({AnidbID}) → TMDB Movie (ID:{TmdbID})", xref.AnidbAnimeID, xref.TmdbMovieID); + RepoFactory.CrossRef_AniDB_TMDB_Movie.Delete(xref); + + if (purge ?? RepoFactory.CrossRef_AniDB_TMDB_Movie.GetByTmdbMovieID(xref.TmdbMovieID).Count == 0) + await (await _schedulerFactory.GetScheduler().ConfigureAwait(false)).StartJob(c => + { + c.TmdbMovieID = xref.TmdbMovieID; + c.RemoveImageFiles = removeImageFiles; + }); + } + + #endregion + + #region Show Links + + public async Task AddShowLink(int animeId, int showId, bool additiveLink = true, bool isAutomatic = false) + { + // Remove all existing links. + if (!additiveLink) + await RemoveAllShowLinksForAnime(animeId); + + // Add or update the link. + _logger.LogInformation("Adding TMDB Show Link: AniDB (ID:{AnidbID}) → TMDB Show (ID:{TmdbID})", animeId, showId); + var xref = RepoFactory.CrossRef_AniDB_TMDB_Show.GetByAnidbAnimeAndTmdbShowIDs(animeId, showId) ?? + new(animeId, showId); + xref.Source = isAutomatic ? CrossRefSource.Automatic : CrossRefSource.User; + RepoFactory.CrossRef_AniDB_TMDB_Show.Save(xref); + } + + public async Task RemoveShowLink(int animeId, int showId, bool purge = false, bool removeImageFiles = true) + { + var xref = RepoFactory.CrossRef_AniDB_TMDB_Show.GetByAnidbAnimeAndTmdbShowIDs(animeId, showId); + if (xref == null) + return; + + // Disable auto-matching when we remove an existing match for the series. + var series = RepoFactory.AnimeSeries.GetByAnimeID(animeId); + if (series != null && !series.IsTMDBAutoMatchingDisabled) + { + series.IsTMDBAutoMatchingDisabled = true; + RepoFactory.AnimeSeries.Save(series, false, true, true); + } + + await RemoveShowLink(xref, removeImageFiles, purge ? true : null); + } + + public async Task RemoveAllShowLinksForAnime(int animeId, bool purge = false, bool removeImageFiles = true) + { + _logger.LogInformation("Removing All TMDB Show Links for: {AnimeID}", animeId); + var xrefs = RepoFactory.CrossRef_AniDB_TMDB_Show.GetByAnidbAnimeID(animeId); + if (xrefs == null || xrefs.Count == 0) + return; + + // Disable auto-matching when we remove an existing match for the series. + var series = RepoFactory.AnimeSeries.GetByAnimeID(animeId); + if (series != null && !series.IsTMDBAutoMatchingDisabled) + { + series.IsTMDBAutoMatchingDisabled = true; + RepoFactory.AnimeSeries.Save(series, false, true, true); + } + + foreach (var xref in xrefs) + await RemoveShowLink(xref, removeImageFiles, purge ? true : null); + } + + public async Task RemoveAllShowLinksForShow(int showId) + { + var xrefs = RepoFactory.CrossRef_AniDB_TMDB_Show.GetByTmdbShowID(showId); + if (xrefs.Count == 0) + return; + + foreach (var xref in xrefs) + await RemoveShowLink(xref, false, false); + } + + private async Task RemoveShowLink(CrossRef_AniDB_TMDB_Show xref, bool removeImageFiles = true, bool? purge = null) + { + _imageService.ResetPreferredImage(xref.AnidbAnimeID, ForeignEntityType.Show, xref.TmdbShowID); + + _logger.LogInformation("Removing TMDB Show Link: AniDB ({AnidbID}) → TMDB Show (ID:{TmdbID})", xref.AnidbAnimeID, xref.TmdbShowID); + RepoFactory.CrossRef_AniDB_TMDB_Show.Delete(xref); + + var xrefs = RepoFactory.CrossRef_AniDB_TMDB_Episode.GetOnlyByAnidbAnimeAndTmdbShowIDs(xref.AnidbAnimeID, xref.TmdbShowID); + _logger.LogInformation("Removing {XRefsCount} Show Episodes for AniDB Anime ({AnidbID})", xrefs.Count, xref.AnidbAnimeID); + RepoFactory.CrossRef_AniDB_TMDB_Episode.Delete(xrefs); + + var scheduler = await _schedulerFactory.GetScheduler(); + if (purge ?? RepoFactory.CrossRef_AniDB_TMDB_Show.GetByTmdbShowID(xref.TmdbShowID).Count == 0) + await (await _schedulerFactory.GetScheduler().ConfigureAwait(false)).StartJob(c => + { + c.TmdbShowID = xref.TmdbShowID; + c.RemoveImageFiles = removeImageFiles; + }); + } + + public void ResetAllEpisodeLinks(int anidbAnimeId) + { + var showId = RepoFactory.CrossRef_AniDB_TMDB_Show.GetByAnidbAnimeID(anidbAnimeId) + .FirstOrDefault()?.TmdbShowID; + if (showId.HasValue) + { + var xrefs = RepoFactory.CrossRef_AniDB_TMDB_Episode.GetByAnidbAnimeID(anidbAnimeId); + var toSave = new List(); + var toDelete = new List(); + + // Reset existing xrefs. + var existingIDs = new HashSet(); + foreach (var xref in xrefs) + { + if (existingIDs.Add(xref.AnidbEpisodeID)) + { + xref.TmdbEpisodeID = 0; + toSave.Add(xref); + } + else + { + toDelete.Add(xref); + } + } + + // Add missing xrefs. + var anidbEpisodesWithoutXrefs = RepoFactory.AniDB_Episode.GetByAnimeID(anidbAnimeId) + .Where(episode => !existingIDs.Contains(episode.AniDB_EpisodeID) && episode.EpisodeType is (int)EpisodeType.Episode or (int)EpisodeType.Special); + foreach (var anidbEpisode in anidbEpisodesWithoutXrefs) + toSave.Add(new(anidbEpisode.AniDB_EpisodeID, anidbAnimeId, 0, showId.Value, MatchRating.UserVerified)); + + // Save the changes. + RepoFactory.CrossRef_AniDB_TMDB_Episode.Save(toSave); + RepoFactory.CrossRef_AniDB_TMDB_Episode.Delete(toDelete); + } + else + { + // Remove all episode cross-references if no show is linked. + var xrefs = RepoFactory.CrossRef_AniDB_TMDB_Episode.GetByAnidbAnimeID(anidbAnimeId); + RepoFactory.CrossRef_AniDB_TMDB_Episode.Delete(xrefs); + } + } + + public bool SetEpisodeLink(int anidbEpisodeId, int tmdbEpisodeId, bool additiveLink = true, int? index = null) + { + var anidbEpisode = RepoFactory.AniDB_Episode.GetByEpisodeID(anidbEpisodeId); + if (anidbEpisode == null) + return false; + + // Set an empty link. + if (tmdbEpisodeId == 0) + { + var xrefs = RepoFactory.CrossRef_AniDB_TMDB_Episode.GetByAnidbEpisodeID(anidbEpisodeId); + var toSave = xrefs.Count > 0 ? xrefs[0] : new(anidbEpisodeId, anidbEpisode.AnimeID, 0, 0); + toSave.TmdbShowID = 0; + toSave.TmdbEpisodeID = 0; + toSave.Ordering = 0; + var toDelete = xrefs.Skip(1).ToList(); + RepoFactory.CrossRef_AniDB_TMDB_Episode.Save(toSave); + RepoFactory.CrossRef_AniDB_TMDB_Episode.Delete(toDelete); + + return true; + } + + var tmdbEpisode = RepoFactory.TMDB_Episode.GetByTmdbEpisodeID(tmdbEpisodeId); + if (tmdbEpisode == null) + return false; + + // Add another link + if (additiveLink) + { + var toSave = RepoFactory.CrossRef_AniDB_TMDB_Episode.GetByAnidbEpisodeAndTmdbEpisodeIDs(anidbEpisodeId, tmdbEpisodeId) + ?? new(anidbEpisodeId, anidbEpisode.AnimeID, tmdbEpisodeId, tmdbEpisode.TmdbShowID); + var existingAnidbLinks = RepoFactory.CrossRef_AniDB_TMDB_Episode.GetByAnidbEpisodeID(anidbEpisodeId).Count; + var existingTmdbLinks = RepoFactory.CrossRef_AniDB_TMDB_Episode.GetByTmdbEpisodeID(tmdbEpisodeId).Count; + if (toSave.CrossRef_AniDB_TMDB_EpisodeID == 0 && !index.HasValue) + index = existingAnidbLinks > 0 ? existingAnidbLinks - 1 : existingTmdbLinks > 0 ? existingTmdbLinks - 1 : 0; + if (index.HasValue) + toSave.Ordering = index.Value; + RepoFactory.CrossRef_AniDB_TMDB_Episode.Save(toSave); + } + else + { + var xrefs = RepoFactory.CrossRef_AniDB_TMDB_Episode.GetByAnidbEpisodeID(anidbEpisodeId); + var toSave = xrefs.Count > 0 ? xrefs[0] : new(anidbEpisodeId, anidbEpisode.AnimeID, tmdbEpisodeId, tmdbEpisode.TmdbShowID); + toSave.TmdbShowID = tmdbEpisode.TmdbShowID; + toSave.TmdbEpisodeID = tmdbEpisode.TmdbEpisodeID; + toSave.Ordering = 0; + var toDelete = xrefs.Skip(1).ToList(); + RepoFactory.CrossRef_AniDB_TMDB_Episode.Save(toSave); + RepoFactory.CrossRef_AniDB_TMDB_Episode.Delete(toDelete); + } + + return true; + } + + #endregion + + #region Episode Links + + public IReadOnlyList MatchAnidbToTmdbEpisodes(int anidbAnimeId, int tmdbShowId, int? tmdbSeasonId, bool useExisting = false, bool saveToDatabase = false) + { + var anime = RepoFactory.AniDB_Anime.GetByAnimeID(anidbAnimeId); + if (anime == null) + return []; + + // Mapping logic + var toSkip = new HashSet(); + var toAdd = new List(); + var crossReferences = new List(); + var existing = RepoFactory.CrossRef_AniDB_TMDB_Episode.GetAllByAnidbAnimeAndTmdbShowIDs(anidbAnimeId, tmdbShowId) + .GroupBy(xref => xref.AnidbEpisodeID) + .ToDictionary(grouped => grouped.Key, grouped => grouped.ToList()); + var anidbEpisodes = RepoFactory.AniDB_Episode.GetByAnimeID(anidbAnimeId) + .Where(episode => episode.EpisodeType is (int)EpisodeType.Episode or (int)EpisodeType.Special) + .OrderBy(episode => episode.EpisodeTypeEnum) + .ThenBy(episode => episode.EpisodeNumber) + .ToDictionary(episode => episode.EpisodeID); + var tmdbEpisodes = RepoFactory.TMDB_Episode.GetByTmdbShowID(tmdbShowId) + .Where(episode => episode.SeasonNumber == 0 || !tmdbSeasonId.HasValue || episode.TmdbSeasonID == tmdbSeasonId.Value) + .ToList(); + var tmdbNormalEpisodes = tmdbEpisodes + .Where(episode => episode.SeasonNumber != 0) + .OrderBy(episode => episode.SeasonNumber) + .ThenBy(episode => episode.EpisodeNumber) + .ToList(); + var tmdbSpecialEpisodes = tmdbEpisodes + .Where(episode => episode.SeasonNumber == 0) + .OrderBy(episode => episode.EpisodeNumber) + .ToList(); + foreach (var episode in anidbEpisodes.Values) + { + if (useExisting && existing.TryGetValue(episode.EpisodeID, out var existingLinks)) + { + // If hidden then return an empty link for the hidden episode. + if (episode.AnimeEpisode?.IsHidden ?? false) + { + var link = existingLinks[0]; + if (link.TmdbEpisodeID is 0 && link.TmdbShowID is 0) + { + crossReferences.Add(link); + toSkip.Add(link.CrossRef_AniDB_TMDB_EpisodeID); + } + else + { + crossReferences.Add(new(episode.EpisodeID, anidbAnimeId, 0, 0, MatchRating.SarahJessicaParker, 0)); + } + continue; + } + + // Else return all existing links. + foreach (var link in existingLinks.DistinctBy((link => (link.TmdbShowID, link.TmdbEpisodeID)))) + { + crossReferences.Add(link); + toSkip.Add(link.CrossRef_AniDB_TMDB_EpisodeID); + } + } + else + { + // If hidden then skip linking episode. + if (episode.AnimeEpisode?.IsHidden ?? false) + { + crossReferences.Add(new(episode.EpisodeID, anidbAnimeId, 0, 0, MatchRating.SarahJessicaParker, 0)); + continue; + } + + // Else try find a match. + var isSpecial = episode.EpisodeTypeEnum is EpisodeType.Special; + var episodeList = isSpecial ? tmdbSpecialEpisodes : tmdbNormalEpisodes; + var crossRef = TryFindAnidbAndTmdbMatch(episode, episodeList, isSpecial); + if (crossRef.TmdbEpisodeID != 0) + { + var index = episodeList.FindIndex(episode => episode.TmdbEpisodeID == crossRef.TmdbEpisodeID); + if (index != -1) + episodeList.RemoveAt(index); + } + crossReferences.Add(crossRef); + toAdd.Add(crossRef); + } + } + + if (!saveToDatabase) + return crossReferences; + + // Remove the current anidb episodes that does not overlap with the show. + var toRemove = existing.Values + .SelectMany(list => list) + .Where(xref => anidbEpisodes.ContainsKey(xref.AnidbEpisodeID) && !toSkip.Contains(xref.CrossRef_AniDB_TMDB_EpisodeID)) + .ToList(); + + _logger.LogDebug( + "Added/removed/skipped {a}/{r}/{s} anidb/tmdb episode cross-references for show {ShowTitle} (Anime={AnimeId},Show={ShowId})", + toAdd.Count, + toRemove.Count, + existing.Count - toRemove.Count, + anime.PreferredTitle, + anidbAnimeId, + tmdbShowId); + RepoFactory.CrossRef_AniDB_TMDB_Episode.Save(toAdd); + RepoFactory.CrossRef_AniDB_TMDB_Episode.Delete(toRemove); + + return crossReferences; + } + + private static CrossRef_AniDB_TMDB_Episode TryFindAnidbAndTmdbMatch(SVR_AniDB_Episode anidbEpisode, IReadOnlyList tmdbEpisodes, bool isSpecial) + { + var anidbDate = anidbEpisode.GetAirDateAsDateOnly(); + var anidbTitles = RepoFactory.AniDB_Episode_Title.GetByEpisodeIDAndLanguage(anidbEpisode.EpisodeID, TitleLanguage.English) + .Where(title => !title.Title.Trim().Equals($"Episode {anidbEpisode.EpisodeNumber}", StringComparison.InvariantCultureIgnoreCase)) + .ToList(); + + var airdateProbability = tmdbEpisodes + .Select(episode => (episode, probability: CalculateAirDateProbability(anidbDate, episode.AiredAt))) + .Where(result => result.probability != 0) + .OrderByDescending(result => result.probability) + .ToList(); + var titleSearchResults = anidbTitles.Count > 0 ? tmdbEpisodes + .Select(episode => anidbTitles.Search(episode.EnglishTitle, title => new string[] { title.Title }, true, 1).FirstOrDefault()?.Map(episode)) + .WhereNotNull() + .OrderBy(result => result) + .ToList() : []; + + // title first, then date + if (isSpecial) + { + if (titleSearchResults.Count > 0) + { + var tmdbEpisode = titleSearchResults[0]!.Result; + var dateAndTitleMatches = airdateProbability.Any(result => result.episode == tmdbEpisode); + var rating = dateAndTitleMatches ? MatchRating.DateAndTitleMatches : MatchRating.TitleMatches; + return new(anidbEpisode.EpisodeID, anidbEpisode.AnimeID, tmdbEpisode.TmdbEpisodeID, tmdbEpisode.TmdbShowID, rating); + } + + if (airdateProbability.Count > 0) + { + var tmdbEpisode = airdateProbability[0]!.episode; + return new(anidbEpisode.EpisodeID, anidbEpisode.AnimeID, tmdbEpisode.TmdbEpisodeID, tmdbEpisode.TmdbShowID, MatchRating.DateMatches); + } + } + // date first, then title + else + { + if (airdateProbability.Count > 0) + { + var tmdbEpisode = airdateProbability[0]!.episode; + var dateAndTitleMatches = titleSearchResults.Any(result => result.Result == tmdbEpisode); + var rating = dateAndTitleMatches ? MatchRating.DateAndTitleMatches : MatchRating.DateMatches; + return new(anidbEpisode.EpisodeID, anidbEpisode.AnimeID, tmdbEpisode.TmdbEpisodeID, tmdbEpisode.TmdbShowID, rating); + } + + if (titleSearchResults.Count > 0) + { + var tmdbEpisode = titleSearchResults[0]!.Result; + return new(anidbEpisode.EpisodeID, anidbEpisode.AnimeID, tmdbEpisode.TmdbEpisodeID, tmdbEpisode.TmdbShowID, MatchRating.TitleMatches); + } + } + + if (tmdbEpisodes.Count > 0) + return new(anidbEpisode.EpisodeID, anidbEpisode.AnimeID, tmdbEpisodes[0].TmdbEpisodeID, tmdbEpisodes[0].TmdbShowID, MatchRating.FirstAvailable); + + return new(anidbEpisode.EpisodeID, anidbEpisode.AnimeID, 0, 0, MatchRating.SarahJessicaParker); + } + + private static double CalculateAirDateProbability(DateOnly? firstDate, DateOnly? secondDate, int maxDifferenceInDays = 2) + { + if (!firstDate.HasValue || !secondDate.HasValue) + return 0; + + var difference = Math.Abs(secondDate.Value.DayNumber - firstDate.Value.DayNumber); + if (difference == 0) + return 1; + + if (difference <= maxDifferenceInDays) + return (maxDifferenceInDays - difference) / (double)maxDifferenceInDays; + + return 0; + } + + #endregion +} diff --git a/Shoko.Server/Providers/TMDB/TmdbMetadataService.cs b/Shoko.Server/Providers/TMDB/TmdbMetadataService.cs index 8d9df7e5b..75f86c0f6 100644 --- a/Shoko.Server/Providers/TMDB/TmdbMetadataService.cs +++ b/Shoko.Server/Providers/TMDB/TmdbMetadataService.cs @@ -1,22 +1,19 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Threading.Tasks; using System.Threading; using Microsoft.Extensions.Logging; using Quartz; using Shoko.Commons.Extensions; -using Shoko.Models.Enums; -using Shoko.Models.Server; using Shoko.Plugin.Abstractions.Enums; using Shoko.Plugin.Abstractions.Extensions; -using Shoko.Server.Models.CrossReference; using Shoko.Server.Models.Interfaces; using Shoko.Server.Models.TMDB; using Shoko.Server.Repositories; using Shoko.Server.Scheduling; +using Shoko.Server.Scheduling.Jobs.TMDB; using Shoko.Server.Server; using Shoko.Server.Settings; using Shoko.Server.Utilities; @@ -30,7 +27,6 @@ using TitleLanguage = Shoko.Plugin.Abstractions.DataModels.TitleLanguage; using MovieCredits = TMDbLib.Objects.Movies.Credits; -using Shoko.Server.Scheduling.Jobs.TMDB; // Suggestions we don't need in this file. #pragma warning disable CA1822 @@ -73,6 +69,10 @@ public static string? ImageServerUrl private readonly ISettingsProvider _settingsProvider; + private readonly TmdbImageService _imageService; + + private readonly TmdbLinkingService _linkingService; + private readonly TMDbClient? _client = null; // We lazy-init it on first use, this will give us time to set up the server before we attempt to init the tmdb client. @@ -85,11 +85,19 @@ public static string? ImageServerUrl MaxRetryCount = 3, }; - public TmdbMetadataService(ILoggerFactory loggerFactory, ISchedulerFactory commandFactory, ISettingsProvider settingsProvider) + public TmdbMetadataService( + ILogger logger, + ISchedulerFactory commandFactory, + ISettingsProvider settingsProvider, + TmdbImageService imageService, + TmdbLinkingService linkingService + ) { - _logger = loggerFactory.CreateLogger(); + _logger = logger; _schedulerFactory = commandFactory; _settingsProvider = settingsProvider; + _imageService = imageService; + _linkingService = linkingService; _instance ??= this; } @@ -174,78 +182,6 @@ public async Task ScanForMatches() #endregion - #region Links - - public async Task AddMovieLink(int animeId, int movieId, int? episodeId = null, bool additiveLink = false, bool isAutomatic = false) - { - // Remove all existing links. - if (!additiveLink) - await RemoveAllMovieLinks(animeId); - - // Add or update the link. - _logger.LogInformation("Adding TMDB Movie Link: AniDB (ID:{AnidbID}) → TMDB Movie (ID:{TmdbID})", animeId, movieId); - var xref = RepoFactory.CrossRef_AniDB_TMDB_Movie.GetByAnidbAnimeAndTmdbMovieIDs(animeId, movieId) ?? - new(animeId, movieId); - if (episodeId.HasValue) - xref.AnidbEpisodeID = episodeId.Value <= 0 ? null : episodeId.Value; - xref.Source = isAutomatic ? CrossRefSource.Automatic : CrossRefSource.User; - RepoFactory.CrossRef_AniDB_TMDB_Movie.Save(xref); - - } - - public async Task RemoveMovieLink(int animeId, int movieId, bool purge = false, bool removeImageFiles = true) - { - var xref = RepoFactory.CrossRef_AniDB_TMDB_Movie.GetByAnidbAnimeAndTmdbMovieIDs(animeId, movieId); - if (xref == null) - return; - - // Disable auto-matching when we remove an existing match for the series. - var series = RepoFactory.AnimeSeries.GetByAnimeID(animeId); - if (series != null && !series.IsTMDBAutoMatchingDisabled) - { - series.IsTMDBAutoMatchingDisabled = true; - RepoFactory.AnimeSeries.Save(series, false, true, true); - } - - await RemoveMovieLink(xref, removeImageFiles, purge ? true : null); - } - - public async Task RemoveAllMovieLinks(int animeId, bool purge = false, bool removeImageFiles = true) - { - _logger.LogInformation("Removing All TMDB Movie Links for: {AnimeID}", animeId); - var xrefs = RepoFactory.CrossRef_AniDB_TMDB_Movie.GetByAnidbAnimeID(animeId); - if (xrefs.Count == 0) - return; - - // Disable auto-matching when we remove an existing match for the series. - var series = RepoFactory.AnimeSeries.GetByAnimeID(animeId); - if (series != null && !series.IsTMDBAutoMatchingDisabled) - { - series.IsTMDBAutoMatchingDisabled = true; - RepoFactory.AnimeSeries.Save(series, false, true, true); - } - - foreach (var xref in xrefs) - await RemoveMovieLink(xref, removeImageFiles, purge ? true : null); - } - - private async Task RemoveMovieLink(CrossRef_AniDB_TMDB_Movie xref, bool removeImageFiles = true, bool? purge = null) - { - ResetPreferredImage(xref.AnidbAnimeID, ForeignEntityType.Movie, xref.TmdbMovieID); - - _logger.LogInformation("Removing TMDB Movie Link: AniDB ({AnidbID}) → TMDB Movie (ID:{TmdbID})", xref.AnidbAnimeID, xref.TmdbMovieID); - RepoFactory.CrossRef_AniDB_TMDB_Movie.Delete(xref); - - if (purge ?? RepoFactory.CrossRef_AniDB_TMDB_Movie.GetByTmdbMovieID(xref.TmdbMovieID).Count == 0) - await (await _schedulerFactory.GetScheduler().ConfigureAwait(false)).StartJob(c => - { - c.TmdbMovieID = xref.TmdbMovieID; - c.RemoveImageFiles = removeImageFiles; - }); - } - - #endregion - #region Update public async Task UpdateAllMovies(bool force, bool saveImages) @@ -608,11 +544,11 @@ public async Task DownloadMovieImages(int movieId, TitleLanguage? mainLanguage = var images = await Client.GetMovieImagesAsync(movieId); var languages = GetLanguages(mainLanguage); if (settings.TMDB.AutoDownloadPosters) - await DownloadImagesByType(images.Posters, ImageEntityType.Poster, ForeignEntityType.Movie, movieId, settings.TMDB.MaxAutoPosters, languages, forceDownload); + await _imageService.DownloadImagesByType(images.Posters, ImageEntityType.Poster, ForeignEntityType.Movie, movieId, settings.TMDB.MaxAutoPosters, languages, forceDownload); if (settings.TMDB.AutoDownloadLogos) - await DownloadImagesByType(images.Logos, ImageEntityType.Logo, ForeignEntityType.Movie, movieId, settings.TMDB.MaxAutoLogos, languages, forceDownload); + await _imageService.DownloadImagesByType(images.Logos, ImageEntityType.Logo, ForeignEntityType.Movie, movieId, settings.TMDB.MaxAutoLogos, languages, forceDownload); if (settings.TMDB.AutoDownloadBackdrops) - await DownloadImagesByType(images.Backdrops, ImageEntityType.Backdrop, ForeignEntityType.Movie, movieId, settings.TMDB.MaxAutoBackdrops, languages, forceDownload); + await _imageService.DownloadImagesByType(images.Backdrops, ImageEntityType.Backdrop, ForeignEntityType.Movie, movieId, settings.TMDB.MaxAutoBackdrops, languages, forceDownload); } #endregion @@ -659,10 +595,9 @@ public async Task SchedulePurgeOfMovie(int movieId, bool removeImageFiles = true /// Remove image files. public async Task PurgeMovie(int movieId, bool removeImageFiles = true) { - var xrefs = RepoFactory.CrossRef_AniDB_TMDB_Movie.GetByTmdbMovieID(movieId); - if (xrefs.Count > 0) - foreach (var xref in xrefs) - await RemoveMovieLink(xref, removeImageFiles, false); + await _linkingService.RemoveAllMovieLinksForMovie(movieId); + + _imageService.PurgeImages(ForeignEntityType.Movie, movieId, removeImageFiles); var movie = RepoFactory.TMDB_Movie.GetByTmdbMovieID(movieId); if (movie != null) @@ -671,8 +606,6 @@ public async Task PurgeMovie(int movieId, bool removeImageFiles = true) RepoFactory.TMDB_Movie.Delete(movie); } - PurgeImages(ForeignEntityType.Movie, movieId, removeImageFiles); - PurgeMovieCompanies(movieId, removeImageFiles); PurgeMovieCastAndCrew(movieId, removeImageFiles); @@ -741,7 +674,7 @@ private void PurgeMovieCollection(int collectionId, bool removeImageFiles = true RepoFactory.TMDB_Collection_Movie.Delete(collectionXRefs); } - PurgeImages(ForeignEntityType.Collection, collectionId, removeImageFiles); + _imageService.PurgeImages(ForeignEntityType.Collection, collectionId, removeImageFiles); PurgeTitlesAndOverviews(ForeignEntityType.Collection, collectionId); @@ -800,176 +733,6 @@ private void PurgeMovieCollection(int collectionId, bool removeImageFiles = true #endregion - #region Links - - public async Task AddShowLink(int animeId, int showId, bool additiveLink = true, bool isAutomatic = false) - { - // Remove all existing links. - if (!additiveLink) - await RemoveAllShowLinks(animeId); - - // Add or update the link. - _logger.LogInformation("Adding TMDB Show Link: AniDB (ID:{AnidbID}) → TMDB Show (ID:{TmdbID})", animeId, showId); - var xref = RepoFactory.CrossRef_AniDB_TMDB_Show.GetByAnidbAnimeAndTmdbShowIDs(animeId, showId) ?? - new(animeId, showId); - xref.Source = isAutomatic ? CrossRefSource.Automatic : CrossRefSource.User; - RepoFactory.CrossRef_AniDB_TMDB_Show.Save(xref); - } - - public async Task RemoveShowLink(int animeId, int showId, bool purge = false, bool removeImageFiles = true) - { - var xref = RepoFactory.CrossRef_AniDB_TMDB_Show.GetByAnidbAnimeAndTmdbShowIDs(animeId, showId); - if (xref == null) - return; - - // Disable auto-matching when we remove an existing match for the series. - var series = RepoFactory.AnimeSeries.GetByAnimeID(animeId); - if (series != null && !series.IsTMDBAutoMatchingDisabled) - { - series.IsTMDBAutoMatchingDisabled = true; - RepoFactory.AnimeSeries.Save(series, false, true, true); - } - - await RemoveShowLink(xref, removeImageFiles, purge ? true : null); - } - - public async Task RemoveAllShowLinks(int animeId, bool purge = false, bool removeImageFiles = true) - { - _logger.LogInformation("Removing All TMDB Show Links for: {AnimeID}", animeId); - var xrefs = RepoFactory.CrossRef_AniDB_TMDB_Show.GetByAnidbAnimeID(animeId); - if (xrefs == null || xrefs.Count == 0) - return; - - // Disable auto-matching when we remove an existing match for the series. - var series = RepoFactory.AnimeSeries.GetByAnimeID(animeId); - if (series != null && !series.IsTMDBAutoMatchingDisabled) - { - series.IsTMDBAutoMatchingDisabled = true; - RepoFactory.AnimeSeries.Save(series, false, true, true); - } - - foreach (var xref in xrefs) - await RemoveShowLink(xref, removeImageFiles, purge ? true : null); - } - - private async Task RemoveShowLink(CrossRef_AniDB_TMDB_Show xref, bool removeImageFiles = true, bool? purge = null) - { - ResetPreferredImage(xref.AnidbAnimeID, ForeignEntityType.Show, xref.TmdbShowID); - - _logger.LogInformation("Removing TMDB Show Link: AniDB ({AnidbID}) → TMDB Show (ID:{TmdbID})", xref.AnidbAnimeID, xref.TmdbShowID); - RepoFactory.CrossRef_AniDB_TMDB_Show.Delete(xref); - - var xrefs = RepoFactory.CrossRef_AniDB_TMDB_Episode.GetOnlyByAnidbAnimeAndTmdbShowIDs(xref.AnidbAnimeID, xref.TmdbShowID); - _logger.LogInformation("Removing {XRefsCount} Show Episodes for AniDB Anime ({AnidbID})", xrefs.Count, xref.AnidbAnimeID); - RepoFactory.CrossRef_AniDB_TMDB_Episode.Delete(xrefs); - - var scheduler = await _schedulerFactory.GetScheduler(); - if (purge ?? RepoFactory.CrossRef_AniDB_TMDB_Show.GetByTmdbShowID(xref.TmdbShowID).Count == 0) - await (await _schedulerFactory.GetScheduler().ConfigureAwait(false)).StartJob(c => - { - c.TmdbShowID = xref.TmdbShowID; - c.RemoveImageFiles = removeImageFiles; - }); - } - - public void ResetAllEpisodeLinks(int anidbAnimeId) - { - var showId = RepoFactory.CrossRef_AniDB_TMDB_Show.GetByAnidbAnimeID(anidbAnimeId) - .FirstOrDefault()?.TmdbShowID; - if (showId.HasValue) - { - var xrefs = RepoFactory.CrossRef_AniDB_TMDB_Episode.GetByAnidbAnimeID(anidbAnimeId); - var toSave = new List(); - var toDelete = new List(); - - // Reset existing xrefs. - var existingIDs = new HashSet(); - foreach (var xref in xrefs) - { - if (existingIDs.Add(xref.AnidbEpisodeID)) - { - xref.TmdbEpisodeID = 0; - toSave.Add(xref); - } - else - { - toDelete.Add(xref); - } - } - - // Add missing xrefs. - var anidbEpisodesWithoutXrefs = RepoFactory.AniDB_Episode.GetByAnimeID(anidbAnimeId) - .Where(episode => !existingIDs.Contains(episode.AniDB_EpisodeID) && episode.EpisodeType is (int)EpisodeType.Episode or (int)EpisodeType.Special); - foreach (var anidbEpisode in anidbEpisodesWithoutXrefs) - toSave.Add(new(anidbEpisode.AniDB_EpisodeID, anidbAnimeId, 0, showId.Value, MatchRating.UserVerified)); - - // Save the changes. - RepoFactory.CrossRef_AniDB_TMDB_Episode.Save(toSave); - RepoFactory.CrossRef_AniDB_TMDB_Episode.Delete(toDelete); - } - else - { - // Remove all episode cross-references if no show is linked. - var xrefs = RepoFactory.CrossRef_AniDB_TMDB_Episode.GetByAnidbAnimeID(anidbAnimeId); - RepoFactory.CrossRef_AniDB_TMDB_Episode.Delete(xrefs); - } - } - - public bool SetEpisodeLink(int anidbEpisodeId, int tmdbEpisodeId, bool additiveLink = true, int? index = null) - { - var anidbEpisode = RepoFactory.AniDB_Episode.GetByEpisodeID(anidbEpisodeId); - if (anidbEpisode == null) - return false; - - // Set an empty link. - if (tmdbEpisodeId == 0) - { - var xrefs = RepoFactory.CrossRef_AniDB_TMDB_Episode.GetByAnidbEpisodeID(anidbEpisodeId); - var toSave = xrefs.Count > 0 ? xrefs[0] : new(anidbEpisodeId, anidbEpisode.AnimeID, 0, 0); - toSave.TmdbShowID = 0; - toSave.TmdbEpisodeID = 0; - toSave.Ordering = 0; - var toDelete = xrefs.Skip(1).ToList(); - RepoFactory.CrossRef_AniDB_TMDB_Episode.Save(toSave); - RepoFactory.CrossRef_AniDB_TMDB_Episode.Delete(toDelete); - - return true; - } - - var tmdbEpisode = RepoFactory.TMDB_Episode.GetByTmdbEpisodeID(tmdbEpisodeId); - if (tmdbEpisode == null) - return false; - - // Add another link - if (additiveLink) - { - var toSave = RepoFactory.CrossRef_AniDB_TMDB_Episode.GetByAnidbEpisodeAndTmdbEpisodeIDs(anidbEpisodeId, tmdbEpisodeId) - ?? new(anidbEpisodeId, anidbEpisode.AnimeID, tmdbEpisodeId, tmdbEpisode.TmdbShowID); - var existingAnidbLinks = RepoFactory.CrossRef_AniDB_TMDB_Episode.GetByAnidbEpisodeID(anidbEpisodeId).Count; - var existingTmdbLinks = RepoFactory.CrossRef_AniDB_TMDB_Episode.GetByTmdbEpisodeID(tmdbEpisodeId).Count; - if (toSave.CrossRef_AniDB_TMDB_EpisodeID == 0 && !index.HasValue) - index = existingAnidbLinks > 0 ? existingAnidbLinks - 1 : existingTmdbLinks > 0 ? existingTmdbLinks - 1 : 0; - if (index.HasValue) - toSave.Ordering = index.Value; - RepoFactory.CrossRef_AniDB_TMDB_Episode.Save(toSave); - } - else - { - var xrefs = RepoFactory.CrossRef_AniDB_TMDB_Episode.GetByAnidbEpisodeID(anidbEpisodeId); - var toSave = xrefs.Count > 0 ? xrefs[0] : new(anidbEpisodeId, anidbEpisode.AnimeID, tmdbEpisodeId, tmdbEpisode.TmdbShowID); - toSave.TmdbShowID = tmdbEpisode.TmdbShowID; - toSave.TmdbEpisodeID = tmdbEpisode.TmdbEpisodeID; - toSave.Ordering = 0; - var toDelete = xrefs.Skip(1).ToList(); - RepoFactory.CrossRef_AniDB_TMDB_Episode.Save(toSave); - RepoFactory.CrossRef_AniDB_TMDB_Episode.Delete(toDelete); - } - - return true; - } - - #endregion - #region Update public async Task UpdateAllShows(bool force = false, bool downloadImages = false) @@ -1039,7 +802,7 @@ public async Task UpdateShow(int showId, bool forceRefresh = false, bool d foreach (var xref in RepoFactory.CrossRef_AniDB_TMDB_Show.GetByTmdbShowID(showId)) { - MatchAnidbToTmdbEpisodes(xref.AnidbAnimeID, xref.TmdbShowID, null, true, true); + _linkingService.MatchAnidbToTmdbEpisodes(xref.AnidbAnimeID, xref.TmdbShowID, null, true, true); if ((titlesUpdated || overviewsUpdated) && xref.AnimeSeries is { } series) { @@ -1562,11 +1325,11 @@ public async Task DownloadShowImages(int showId, TitleLanguage? mainLanguage = n var images = await Client.GetTvShowImagesAsync(showId); var languages = GetLanguages(mainLanguage); if (settings.TMDB.AutoDownloadPosters) - await DownloadImagesByType(images.Posters, ImageEntityType.Poster, ForeignEntityType.Show, showId, settings.TMDB.MaxAutoBackdrops, languages, forceDownload); + await _imageService.DownloadImagesByType(images.Posters, ImageEntityType.Poster, ForeignEntityType.Show, showId, settings.TMDB.MaxAutoBackdrops, languages, forceDownload); if (settings.TMDB.AutoDownloadLogos) - await DownloadImagesByType(images.Logos, ImageEntityType.Logo, ForeignEntityType.Show, showId, settings.TMDB.MaxAutoBackdrops, languages, forceDownload); + await _imageService.DownloadImagesByType(images.Logos, ImageEntityType.Logo, ForeignEntityType.Show, showId, settings.TMDB.MaxAutoBackdrops, languages, forceDownload); if (settings.TMDB.AutoDownloadBackdrops) - await DownloadImagesByType(images.Backdrops, ImageEntityType.Backdrop, ForeignEntityType.Show, showId, settings.TMDB.MaxAutoBackdrops, languages, forceDownload); + await _imageService.DownloadImagesByType(images.Backdrops, ImageEntityType.Backdrop, ForeignEntityType.Show, showId, settings.TMDB.MaxAutoBackdrops, languages, forceDownload); } private async Task DownloadSeasonImages(int seasonId, int showId, int seasonNumber, TitleLanguage? mainLanguage = null, bool forceDownload = false) @@ -1577,7 +1340,7 @@ private async Task DownloadSeasonImages(int seasonId, int showId, int seasonNumb var images = await Client.GetTvSeasonImagesAsync(showId, seasonNumber); var languages = GetLanguages(mainLanguage); - await DownloadImagesByType(images.Posters, ImageEntityType.Poster, ForeignEntityType.Season, seasonId, settings.TMDB.MaxAutoBackdrops, languages, forceDownload); + await _imageService.DownloadImagesByType(images.Posters, ImageEntityType.Poster, ForeignEntityType.Season, seasonId, settings.TMDB.MaxAutoBackdrops, languages, forceDownload); } private async Task DownloadEpisodeImages(int episodeId, int showId, int seasonNumber, int episodeNumber, TitleLanguage mainLanguage, bool forceDownload = false) @@ -1588,7 +1351,7 @@ private async Task DownloadEpisodeImages(int episodeId, int showId, int seasonNu var images = await Client.GetTvEpisodeImagesAsync(showId, seasonNumber, episodeNumber); var languages = GetLanguages(mainLanguage); - await DownloadImagesByType(images.Stills, ImageEntityType.Thumbnail, ForeignEntityType.Episode, episodeId, settings.TMDB.MaxAutoBackdrops, languages, forceDownload); + await _imageService.DownloadImagesByType(images.Stills, ImageEntityType.Thumbnail, ForeignEntityType.Episode, episodeId, settings.TMDB.MaxAutoBackdrops, languages, forceDownload); } private List GetLanguages(TitleLanguage? mainLanguage = null) => _settingsProvider.GetSettings().TMDB.ImageLanguageOrder @@ -1596,184 +1359,6 @@ private List GetLanguages(TitleLanguage? mainLanguage = null) => .WhereNotNull() .ToList(); - public IReadOnlyList MatchAnidbToTmdbEpisodes(int anidbAnimeId, int tmdbShowId, int? tmdbSeasonId, bool useExisting = false, bool saveToDatabase = false) - { - var anime = RepoFactory.AniDB_Anime.GetByAnimeID(anidbAnimeId); - if (anime == null) - return []; - - // Mapping logic - var toSkip = new HashSet(); - var toAdd = new List(); - var crossReferences = new List(); - var existing = RepoFactory.CrossRef_AniDB_TMDB_Episode.GetAllByAnidbAnimeAndTmdbShowIDs(anidbAnimeId, tmdbShowId) - .GroupBy(xref => xref.AnidbEpisodeID) - .ToDictionary(grouped => grouped.Key, grouped => grouped.ToList()); - var anidbEpisodes = RepoFactory.AniDB_Episode.GetByAnimeID(anidbAnimeId) - .Where(episode => episode.EpisodeType is (int)EpisodeType.Episode or (int)EpisodeType.Special) - .OrderBy(episode => episode.EpisodeTypeEnum) - .ThenBy(episode => episode.EpisodeNumber) - .ToDictionary(episode => episode.EpisodeID); - var tmdbEpisodes = RepoFactory.TMDB_Episode.GetByTmdbShowID(tmdbShowId) - .Where(episode => episode.SeasonNumber == 0 || !tmdbSeasonId.HasValue || episode.TmdbSeasonID == tmdbSeasonId.Value) - .ToList(); - var tmdbNormalEpisodes = tmdbEpisodes - .Where(episode => episode.SeasonNumber != 0) - .OrderBy(episode => episode.SeasonNumber) - .ThenBy(episode => episode.EpisodeNumber) - .ToList(); - var tmdbSpecialEpisodes = tmdbEpisodes - .Where(episode => episode.SeasonNumber == 0) - .OrderBy(episode => episode.EpisodeNumber) - .ToList(); - foreach (var episode in anidbEpisodes.Values) - { - if (useExisting && existing.TryGetValue(episode.EpisodeID, out var existingLinks)) - { - // If hidden then return an empty link for the hidden episode. - if (episode.AnimeEpisode?.IsHidden ?? false) - { - var link = existingLinks[0]; - if (link.TmdbEpisodeID is 0 && link.TmdbShowID is 0) - { - crossReferences.Add(link); - toSkip.Add(link.CrossRef_AniDB_TMDB_EpisodeID); - } - else - { - crossReferences.Add(new(episode.EpisodeID, anidbAnimeId, 0, 0, MatchRating.SarahJessicaParker, 0)); - } - continue; - } - - // Else return all existing links. - foreach (var link in existingLinks.DistinctBy((link => (link.TmdbShowID, link.TmdbEpisodeID)))) - { - crossReferences.Add(link); - toSkip.Add(link.CrossRef_AniDB_TMDB_EpisodeID); - } - } - else - { - // If hidden then skip linking episode. - if (episode.AnimeEpisode?.IsHidden ?? false) - { - crossReferences.Add(new(episode.EpisodeID, anidbAnimeId, 0, 0, MatchRating.SarahJessicaParker, 0)); - continue; - } - - // Else try find a match. - var isSpecial = episode.EpisodeType is (int)EpisodeType.Special; - var episodeList = isSpecial ? tmdbSpecialEpisodes : tmdbNormalEpisodes; - var crossRef = TryFindAnidbAndTmdbMatch(episode, episodeList, isSpecial); - if (crossRef.TmdbEpisodeID != 0) - { - var index = episodeList.FindIndex(episode => episode.TmdbEpisodeID == crossRef.TmdbEpisodeID); - if (index != -1) - episodeList.RemoveAt(index); - } - crossReferences.Add(crossRef); - toAdd.Add(crossRef); - } - } - - if (!saveToDatabase) - return crossReferences; - - // Remove the current anidb episodes that does not overlap with the show. - var toRemove = existing.Values - .SelectMany(list => list) - .Where(xref => anidbEpisodes.ContainsKey(xref.AnidbEpisodeID) && !toSkip.Contains(xref.CrossRef_AniDB_TMDB_EpisodeID)) - .ToList(); - - _logger.LogDebug( - "Added/removed/skipped {a}/{r}/{s} anidb/tmdb episode cross-references for show {ShowTitle} (Anime={AnimeId},Show={ShowId})", - toAdd.Count, - toRemove.Count, - existing.Count - toRemove.Count, - anime.PreferredTitle, - anidbAnimeId, - tmdbShowId); - RepoFactory.CrossRef_AniDB_TMDB_Episode.Save(toAdd); - RepoFactory.CrossRef_AniDB_TMDB_Episode.Delete(toRemove); - - return crossReferences; - } - - private static CrossRef_AniDB_TMDB_Episode TryFindAnidbAndTmdbMatch(AniDB_Episode anidbEpisode, IReadOnlyList tmdbEpisodes, bool isSpecial) - { - var anidbDate = anidbEpisode.GetAirDateAsDateOnly(); - var anidbTitles = RepoFactory.AniDB_Episode_Title.GetByEpisodeIDAndLanguage(anidbEpisode.EpisodeID, TitleLanguage.English) - .Where(title => !title.Title.Trim().Equals($"Episode {anidbEpisode.EpisodeNumber}", StringComparison.InvariantCultureIgnoreCase)) - .ToList(); - - var airdateProbability = tmdbEpisodes - .Select(episode => (episode, probability: CalculateAirDateProbability(anidbDate, episode.AiredAt))) - .Where(result => result.probability != 0) - .OrderByDescending(result => result.probability) - .ToList(); - var titleSearchResults = anidbTitles.Count > 0 ? tmdbEpisodes - .Select(episode => anidbTitles.Search(episode.EnglishTitle, title => new string[] { title.Title }, true, 1).FirstOrDefault()?.Map(episode)) - .WhereNotNull() - .OrderBy(result => result) - .ToList() : []; - - // title first, then date - if (isSpecial) - { - if (titleSearchResults.Count > 0) - { - var tmdbEpisode = titleSearchResults[0]!.Result; - var dateAndTitleMatches = airdateProbability.Any(result => result.episode == tmdbEpisode); - var rating = dateAndTitleMatches ? MatchRating.DateAndTitleMatches : MatchRating.TitleMatches; - return new(anidbEpisode.EpisodeID, anidbEpisode.AnimeID, tmdbEpisode.TmdbEpisodeID, tmdbEpisode.TmdbShowID, rating); - } - - if (airdateProbability.Count > 0) - { - var tmdbEpisode = airdateProbability[0]!.episode; - return new(anidbEpisode.EpisodeID, anidbEpisode.AnimeID, tmdbEpisode.TmdbEpisodeID, tmdbEpisode.TmdbShowID, MatchRating.DateMatches); - } - } - // date first, then title - else - { - if (airdateProbability.Count > 0) - { - var tmdbEpisode = airdateProbability[0]!.episode; - var dateAndTitleMatches = titleSearchResults.Any(result => result.Result == tmdbEpisode); - var rating = dateAndTitleMatches ? MatchRating.DateAndTitleMatches : MatchRating.DateMatches; - return new(anidbEpisode.EpisodeID, anidbEpisode.AnimeID, tmdbEpisode.TmdbEpisodeID, tmdbEpisode.TmdbShowID, rating); - } - - if (titleSearchResults.Count > 0) - { - var tmdbEpisode = titleSearchResults[0]!.Result; - return new(anidbEpisode.EpisodeID, anidbEpisode.AnimeID, tmdbEpisode.TmdbEpisodeID, tmdbEpisode.TmdbShowID, MatchRating.TitleMatches); - } - } - - if (tmdbEpisodes.Count > 0) - return new(anidbEpisode.EpisodeID, anidbEpisode.AnimeID, tmdbEpisodes[0].TmdbEpisodeID, tmdbEpisodes[0].TmdbShowID, MatchRating.FirstAvailable); - - return new(anidbEpisode.EpisodeID, anidbEpisode.AnimeID, 0, 0, MatchRating.SarahJessicaParker); - } - - private static double CalculateAirDateProbability(DateOnly? firstDate, DateOnly? secondDate, int maxDifferenceInDays = 2) - { - if (!firstDate.HasValue || !secondDate.HasValue) - return 0; - - var difference = Math.Abs(secondDate.Value.DayNumber - firstDate.Value.DayNumber); - if (difference == 0) - return 1; - - if (difference <= maxDifferenceInDays) - return (maxDifferenceInDays - difference) / (double)maxDifferenceInDays; - - return 0; - } - #endregion #region Purge @@ -1816,12 +1401,10 @@ public async Task SchedulePurgeOfShow(int showId, bool removeImageFiles = true) public async Task PurgeShow(int showId, bool removeImageFiles = true) { var show = RepoFactory.TMDB_Show.GetByTmdbShowID(showId); - var xrefs = RepoFactory.CrossRef_AniDB_TMDB_Show.GetByTmdbShowID(showId); - if (xrefs.Count > 0) - foreach (var xref in xrefs) - await RemoveShowLink(xref, removeImageFiles, false); - PurgeImages(ForeignEntityType.Show, showId, removeImageFiles); + await _linkingService.RemoveAllShowLinksForShow(showId); + + _imageService.PurgeImages(ForeignEntityType.Show, showId, removeImageFiles); PurgeTitlesAndOverviews(ForeignEntityType.Show, showId); @@ -1890,7 +1473,7 @@ private void PurgeShowNetwork(int networkId, bool removeImageFiles = true) var images = RepoFactory.TMDB_Image.GetByTmdbCompanyID(networkId); if (images.Count > 0) foreach (var image in images) - PurgeImage(image, ForeignEntityType.Company, removeImageFiles); + _imageService.PurgeImage(image, ForeignEntityType.Company, removeImageFiles); var xrefs = RepoFactory.TMDB_Show_Network.GetByTmdbNetworkID(networkId); if (xrefs.Count > 0) @@ -1917,7 +1500,7 @@ private void PurgeShowEpisodes(int showId, bool removeImageFiles = true) private void PurgeShowEpisode(TMDB_Episode episode, bool removeImageFiles = true) { - PurgeImages(ForeignEntityType.Episode, episode.Id, removeImageFiles); + _imageService.PurgeImages(ForeignEntityType.Episode, episode.Id, removeImageFiles); PurgeTitlesAndOverviews(ForeignEntityType.Episode, episode.Id); } @@ -1939,7 +1522,7 @@ private void PurgeShowSeasons(int showId, bool removeImageFiles = true) private void PurgeShowSeason(TMDB_Season season, bool removeImageFiles = true) { - PurgeImages(ForeignEntityType.Season, season.Id, removeImageFiles); + _imageService.PurgeImages(ForeignEntityType.Season, season.Id, removeImageFiles); PurgeTitlesAndOverviews(ForeignEntityType.Season, season.Id); } @@ -1980,188 +1563,6 @@ private void PurgeShowEpisodeGroups(int showId) #region Shared - #region Image - - private async Task DownloadImageByType(string filePath, ImageEntityType type, ForeignEntityType foreignType, int foreignId, bool forceDownload = false) - { - var image = RepoFactory.TMDB_Image.GetByRemoteFileNameAndType(filePath, type) ?? new(filePath, type); - image.Populate(foreignType, foreignId); - if (string.IsNullOrEmpty(image.LocalPath)) - return; - - RepoFactory.TMDB_Image.Save(image); - - // Skip downloading if it already exists and we're not forcing it. - if (File.Exists(image.LocalPath) && !forceDownload) - return; - - await (await _schedulerFactory.GetScheduler().ConfigureAwait(false)).StartJob(c => - { - c.ImageID = image.TMDB_ImageID; - c.ImageType = image.ImageType; - c.ForceDownload = forceDownload; - }); - } - - private async Task DownloadImagesByType(IReadOnlyList images, ImageEntityType type, ForeignEntityType foreignType, int foreignId, int maxCount, List languages, bool forceDownload = false) - { - var count = 0; - var isLimitEnabled = maxCount > 0; - if (languages.Count > 0) - images = isLimitEnabled - ? images - .Select(image => (Image: image, Language: (image.Iso_639_1 ?? string.Empty).GetTitleLanguage())) - .Where(x => languages.Contains(x.Language)) - .OrderBy(x => languages.IndexOf(x.Language)) - .Select(x => x.Image) - .ToList() - : images - .Where(x => languages.Contains((x.Iso_639_1 ?? string.Empty).GetTitleLanguage())) - .ToList(); - foreach (var imageData in images) - { - if (isLimitEnabled && count >= maxCount) - break; - - count++; - var image = RepoFactory.TMDB_Image.GetByRemoteFileNameAndType(imageData.FilePath, type) ?? new(imageData.FilePath, type); - var updated = image.Populate(imageData, foreignType, foreignId); - if (updated) - RepoFactory.TMDB_Image.Save(image); - } - - count = 0; - var scheduler = await _schedulerFactory.GetScheduler(); - var storedImages = RepoFactory.TMDB_Image.GetByForeignIDAndType(foreignId, foreignType, type); - if (languages.Count > 0 && isLimitEnabled) - storedImages = storedImages - .OrderBy(x => languages.IndexOf(x.Language) is var index && index >= 0 ? index : int.MaxValue) - .ToList(); - foreach (var image in storedImages) - { - // Clean up invalid entries. - var path = image.LocalPath; - if (string.IsNullOrEmpty(path)) - { - RepoFactory.TMDB_Image.Delete(image.TMDB_ImageID); - continue; - } - - // Download image if the limit is disabled or if we're below the limit. - var fileExists = File.Exists(path); - if (!isLimitEnabled || count < maxCount) - { - // Skip downloading if it already exists and we're not forcing it. - count++; - if (fileExists && !forceDownload) - continue; - - // Otherwise scheduled the image to be downloaded. - await scheduler.StartJob(c => - { - c.ImageID = image.TMDB_ImageID; - c.ImageType = image.ImageType; - c.ForceDownload = forceDownload; - }); - } - // TODO: check if the image is linked to any other entries, and keep it if the other entries are within the limit. - // Else delete it from the local cache and database. - else - { - if (fileExists) - { - try - { - File.Delete(path); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to delete image file: {Path}", path); - } - } - RepoFactory.TMDB_Image.Delete(image.TMDB_ImageID); - } - } - } - - private void PurgeImages(ForeignEntityType foreignType, int foreignId, bool removeImageFiles) - { - var imagesToRemove = RepoFactory.TMDB_Image.GetByForeignID(foreignId, foreignType); - - _logger.LogDebug( - "Removing {count} images for {type} with id {EntityId}", - imagesToRemove.Count, - foreignType.ToString().ToLowerInvariant(), - foreignId); - foreach (var image in imagesToRemove) - PurgeImage(image, foreignType, removeImageFiles); - } - - private static void PurgeImage(TMDB_Image image, ForeignEntityType foreignType, bool removeFile) - { - // Skip the operation if th flag is not set. - if (!image.ForeignType.HasFlag(foreignType)) - return; - - // Disable the flag. - image.ForeignType &= ~foreignType; - - // Only delete the image metadata and/or file if all references were removed. - if (image.ForeignType is ForeignEntityType.None) - { - if (removeFile && !string.IsNullOrEmpty(image.LocalPath) && File.Exists(image.LocalPath)) - File.Delete(image.LocalPath); - - RepoFactory.TMDB_Image.Delete(image.TMDB_ImageID); - } - // Remove the ID since we're keeping the metadata a little bit longer. - else - { - switch (foreignType) - { - case ForeignEntityType.Movie: - image.TmdbMovieID = null; - break; - case ForeignEntityType.Episode: - image.TmdbEpisodeID = null; - break; - case ForeignEntityType.Season: - image.TmdbSeasonID = null; - break; - case ForeignEntityType.Show: - image.TmdbShowID = null; - break; - case ForeignEntityType.Collection: - image.TmdbCollectionID = null; - break; - } - } - } - - private void ResetPreferredImage(int anidbAnimeId, ForeignEntityType foreignType, int foreignId) - { - var images = RepoFactory.AniDB_Anime_PreferredImage.GetByAnimeID(anidbAnimeId); - foreach (var defaultImage in images) - { - if (defaultImage.ImageSource == DataSourceType.TMDB) - { - var image = RepoFactory.TMDB_Image.GetByID(defaultImage.ImageID); - if (image == null) - { - _logger.LogTrace("Removing preferred image for anime {AnimeId} because the preferred image could not be found.", anidbAnimeId); - RepoFactory.AniDB_Anime_PreferredImage.Delete(defaultImage); - } - else if (image.ForeignType.HasFlag(foreignType) && image.GetForeignID(foreignType) == foreignId) - { - _logger.LogTrace("Removing preferred image for anime {AnimeId} because it belongs to now TMDB {Type} {Id}", anidbAnimeId, foreignType.ToString(), foreignId); - RepoFactory.AniDB_Anime_PreferredImage.Delete(defaultImage); - } - } - } - } - - #endregion - #region Titles & Overviews /// @@ -2387,7 +1788,7 @@ private async Task UpdateCompany(ProductionCompany company) var settings = _settingsProvider.GetSettings(); if (!string.IsNullOrEmpty(company.LogoPath) && settings.TMDB.AutoDownloadStudioImages) - await DownloadImageByType(company.LogoPath, ImageEntityType.Logo, ForeignEntityType.Company, company.Id); + await _imageService.DownloadImageByType(company.LogoPath, ImageEntityType.Logo, ForeignEntityType.Company, company.Id); } private void PurgeCompany(int companyId, bool removeImageFiles = true) @@ -2402,7 +1803,7 @@ private void PurgeCompany(int companyId, bool removeImageFiles = true) var images = RepoFactory.TMDB_Image.GetByTmdbCompanyID(companyId); if (images.Count > 0) foreach (var image in images) - PurgeImage(image, ForeignEntityType.Company, removeImageFiles); + _imageService.PurgeImage(image, ForeignEntityType.Company, removeImageFiles); var xrefs = RepoFactory.TMDB_Company_Entity.GetByTmdbCompanyID(companyId); if (xrefs.Count > 0) @@ -2423,7 +1824,7 @@ public async Task DownloadPersonImages(int personId, bool forceDownload = false) return; var images = await Client.GetPersonImagesAsync(personId); - await DownloadImagesByType(images.Profiles, ImageEntityType.Person, ForeignEntityType.Person, personId, settings.TMDB.MaxAutoStaffImages, [], forceDownload); + await _imageService.DownloadImagesByType(images.Profiles, ImageEntityType.Person, ForeignEntityType.Person, personId, settings.TMDB.MaxAutoStaffImages, [], forceDownload); } private void PurgePerson(int personId, bool removeImageFiles = true) @@ -2438,7 +1839,7 @@ private void PurgePerson(int personId, bool removeImageFiles = true) var images = RepoFactory.TMDB_Image.GetByTmdbPersonID(personId); if (images.Count > 0) foreach (var image in images) - PurgeImage(image, ForeignEntityType.Person, removeImageFiles); + _imageService.PurgeImage(image, ForeignEntityType.Person, removeImageFiles); var movieCast = RepoFactory.TMDB_Movie_Cast.GetByTmdbPersonID(personId); if (movieCast.Count > 0) diff --git a/Shoko.Server/Scheduling/Jobs/TMDB/SearchTmdbJob.cs b/Shoko.Server/Scheduling/Jobs/TMDB/SearchTmdbJob.cs index f0508a715..341f585a0 100644 --- a/Shoko.Server/Scheduling/Jobs/TMDB/SearchTmdbJob.cs +++ b/Shoko.Server/Scheduling/Jobs/TMDB/SearchTmdbJob.cs @@ -23,6 +23,8 @@ namespace Shoko.Server.Scheduling.Jobs.TMDB; [JobKeyGroup(JobKeyGroup.TMDB)] public partial class SearchTmdbJob : BaseJob { + private readonly TmdbLinkingService _tmdbLinkingService; + private readonly TmdbMetadataService _tmdbMetadataService; private readonly TmdbSearchService _tmdbSearchService; @@ -55,20 +57,25 @@ public override async Task Process() if (result.IsMovie) { _logger.LogInformation("Linking anime {AnimeName} ({AnimeID}), episode {EpisodeName} ({EpisodeID}) to movie {MovieName} ({MovieID})", result.AnidbAnime.PreferredTitle, result.AnidbAnime.AnimeID, result.AnidbEpisode.PreferredTitle, result.AnidbEpisode.EpisodeID, result.TmdbMovie.OriginalTitle, result.TmdbMovie.Id); - await _tmdbMetadataService.AddMovieLink(result.AnidbAnime.AnimeID, result.TmdbMovie.Id, result.AnidbEpisode.EpisodeID, additiveLink: true, isAutomatic: true).ConfigureAwait(false); + await _tmdbLinkingService.AddMovieLink(result.AnidbAnime.AnimeID, result.TmdbMovie.Id, result.AnidbEpisode.EpisodeID, additiveLink: true, isAutomatic: true).ConfigureAwait(false); await _tmdbMetadataService.ScheduleUpdateOfMovie(result.TmdbMovie.Id, forceRefresh: ForceRefresh, downloadImages: true).ConfigureAwait(false); } else { _logger.LogInformation("Linking anime {AnimeName} ({AnimeID}) to show {ShowName} ({ShowID})", result.AnidbAnime.PreferredTitle, result.AnidbAnime.AnimeID, result.TmdbShow.OriginalName, result.TmdbShow.Id); - await _tmdbMetadataService.AddShowLink(result.AnidbAnime.AnimeID, result.TmdbShow.Id, additiveLink: true, isAutomatic: true).ConfigureAwait(false); + await _tmdbLinkingService.AddShowLink(result.AnidbAnime.AnimeID, result.TmdbShow.Id, additiveLink: true, isAutomatic: true).ConfigureAwait(false); await _tmdbMetadataService.ScheduleUpdateOfShow(result.TmdbShow.Id, forceRefresh: ForceRefresh, downloadImages: true).ConfigureAwait(false); } } } - public SearchTmdbJob(TmdbMetadataService metadataService, TmdbSearchService searchService) + public SearchTmdbJob( + TmdbLinkingService tmdbLinkingService, + TmdbMetadataService metadataService, + TmdbSearchService searchService + ) { + _tmdbLinkingService = tmdbLinkingService; _tmdbMetadataService = metadataService; _tmdbSearchService = searchService; } diff --git a/Shoko.Server/Server/Startup.cs b/Shoko.Server/Server/Startup.cs index 41e6be8c9..ec54bccfe 100644 --- a/Shoko.Server/Server/Startup.cs +++ b/Shoko.Server/Server/Startup.cs @@ -59,6 +59,8 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton();