diff options
Diffstat (limited to 'MediaBrowser.Providers')
19 files changed, 603 insertions, 84 deletions
diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs index a06de95fce..61615b288a 100644 --- a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs +++ b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs @@ -54,9 +54,10 @@ public class ComicBookInfoProvider : IComicProvider var archive = await ZipArchive.CreateAsync(stream, ZipArchiveMode.Read, false, null, cancellationToken).ConfigureAwait(false); await using (archive.ConfigureAwait(false)) { - if (archive.Comment is null) + // ZipArchive.Comment is an empty string, not null, when the archive has no comment + if (string.IsNullOrWhiteSpace(archive.Comment)) { - _logger.LogInformation("missing ComicBookInfo in archive comment: {Path}", info.Path); + _logger.LogDebug("missing ComicBookInfo in archive comment: {Path}", info.Path); return new MetadataResult<Book> { HasMetadata = false }; } @@ -71,6 +72,12 @@ public class ComicBookInfoProvider : IComicProvider } } } + catch (JsonException ex) + { + // the archive comment is not reserved for ComicBookInfo, so any other content is not an error + _logger.LogDebug("archive comment is not valid ComicBookInfo metadata: {Path}: {Message}", info.Path, ex.Message); + return new MetadataResult<Book> { HasMetadata = false }; + } catch (Exception ex) { _logger.LogError(ex, "failed to load ComicBookInfo metadata: {Path}", info.Path); diff --git a/MediaBrowser.Providers/Lyric/LyricManager.cs b/MediaBrowser.Providers/Lyric/LyricManager.cs index af31e373ef..a19262c3a7 100644 --- a/MediaBrowser.Providers/Lyric/LyricManager.cs +++ b/MediaBrowser.Providers/Lyric/LyricManager.cs @@ -32,6 +32,7 @@ public class LyricManager : ILyricManager private readonly IFileSystem _fileSystem; private readonly ILibraryMonitor _libraryMonitor; private readonly IMediaSourceManager _mediaSourceManager; + private readonly IDirectoryService _directoryService; private readonly ILyricProvider[] _lyricProviders; private readonly ILyricParser[] _lyricParsers; @@ -43,6 +44,7 @@ public class LyricManager : ILyricManager /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryMonitor">Instance of the <see cref="ILibraryMonitor"/> interface.</param> /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param> /// <param name="lyricProviders">The list of <see cref="ILyricProvider"/>.</param> /// <param name="lyricParsers">The list of <see cref="ILyricParser"/>.</param> public LyricManager( @@ -50,6 +52,7 @@ public class LyricManager : ILyricManager IFileSystem fileSystem, ILibraryMonitor libraryMonitor, IMediaSourceManager mediaSourceManager, + IDirectoryService directoryService, IEnumerable<ILyricProvider> lyricProviders, IEnumerable<ILyricParser> lyricParsers) { @@ -57,6 +60,7 @@ public class LyricManager : ILyricManager _fileSystem = fileSystem; _libraryMonitor = libraryMonitor; _mediaSourceManager = mediaSourceManager; + _directoryService = directoryService; _lyricProviders = lyricProviders .OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0) .ToArray(); @@ -250,6 +254,8 @@ public class LyricManager : ILyricManager { _libraryMonitor.ReportFileSystemChangeComplete(path, false); } + + _directoryService.Invalidate(path); } return audio.RefreshMetadata(CancellationToken.None); @@ -446,6 +452,8 @@ public class LyricManager : ILyricManager await stream.CopyToAsync(fs).ConfigureAwait(false); } + _directoryService.Invalidate(savePath); + return; } catch (Exception ex) diff --git a/MediaBrowser.Providers/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs index 727f481b65..ce5468c5c4 100644 --- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs +++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs @@ -254,6 +254,7 @@ namespace MediaBrowser.Providers.Manager } catch (Exception ex) { + result.Failures++; result.ErrorMessage = ex.Message; _logger.LogError(ex, "Error in {Provider} for {Item}", provider.Name, item.Path ?? item.Name); } @@ -338,6 +339,7 @@ namespace MediaBrowser.Providers.Manager } catch (Exception ex) { + result.Failures++; result.ErrorMessage = ex.Message; _logger.LogError(ex, "Error in {Provider} for {Item}", provider.Name, item.Path ?? item.Name); } diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index fe5285bf65..05c542337e 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -192,9 +192,13 @@ namespace MediaBrowser.Providers.Manager } } - // Next run remote image providers, but only if local image providers didn't throw an exception - if (!localImagesFailed && refreshOptions.ImageRefreshMode > MetadataRefreshMode.ValidationOnly) + if (localImagesFailed) { + hasRefreshedImages = false; + } + else if (refreshOptions.ImageRefreshMode > MetadataRefreshMode.ValidationOnly) + { + // Next run remote image providers, now that local image providers didn't throw var providers = GetNonLocalImageProviders(item, allImageProviders, refreshOptions).ToList(); if (providers.Count > 0) @@ -937,6 +941,7 @@ namespace MediaBrowser.Providers.Manager } catch (Exception ex) { + refreshResult.Failures++; refreshResult.ErrorMessage = ex.Message; Logger.LogError(ex, "Error in {Provider} for {Item}", provider.Name, logName); } diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index fbd9e5435e..8387f24bc9 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -76,7 +76,8 @@ namespace MediaBrowser.Providers.Manager /// <summary> /// Cache for ordered metadata providers per library/item type combination. - /// Key: (LibraryPath, ItemTypeName, IncludeDisabled, ForceEnableInternetMetadata). + /// Key: (LibraryPath, ItemTypeName, IncludeDisabled, ForceEnableInternetMetadata), where + /// LibraryPath is the collection folder path the library options are stored against. /// Value: Array of ordered metadata providers (before per-item filtering). /// </summary> private readonly ConcurrentDictionary<MetadataProviderCacheKey, IMetadataProvider[]> _metadataProviderCache = new(); @@ -136,6 +137,7 @@ namespace MediaBrowser.Providers.Manager _similarItemsManager = similarItemsManager; CollectionFolder.LibraryOptionsUpdated += OnLibraryOptionsUpdated; + _configurationManager.ConfigurationUpdated += OnConfigurationUpdated; } /// <inheritdoc/> @@ -476,15 +478,15 @@ namespace MediaBrowser.Providers.Manager return GetMetadataProvidersInternal<T>(item, libraryOptions, globalMetadataOptions, includeDisabled, false, libraryPath); } - private static string GetLibraryPathForItem(BaseItem item) + private string GetLibraryPathForItem(BaseItem item) { if (item is CollectionFolder collectionFolder) { return collectionFolder.Path ?? string.Empty; } - var topParent = item.GetTopParent(); - return topParent?.Path ?? string.Empty; + return _libraryManager.GetCollectionFolders(item) + .Find(folder => folder is CollectionFolder)?.Path ?? string.Empty; } /// <inheritdoc /> @@ -1143,16 +1145,21 @@ namespace MediaBrowser.Providers.Manager return; } - _refreshQueue.Enqueue((itemId, options), priority); - + // PriorityQueue is not thread safe and the processor dequeues concurrently, so every + // touch of the queue takes the lock. lock (_refreshQueueLock) { - if (!_isProcessingRefreshQueue) + _refreshQueue.Enqueue((itemId, options), priority); + + if (_isProcessingRefreshQueue) { - _isProcessingRefreshQueue = true; - Task.Run(StartProcessingRefreshQueue); + return; } + + _isProcessingRefreshQueue = true; } + + Task.Run(StartProcessingRefreshQueue); } private async Task StartProcessingRefreshQueue() @@ -1161,17 +1168,33 @@ namespace MediaBrowser.Providers.Manager if (_disposed) { + lock (_refreshQueueLock) + { + _isProcessingRefreshQueue = false; + } + return; } var cancellationToken = _disposeCancellationTokenSource.Token; libraryManager.ClearIgnoreRuleCache(); - while (_refreshQueue.TryDequeue(out var refreshItem, out _)) + + while (true) { - if (_disposed) + (Guid ItemId, MetadataRefreshOptions RefreshOptions) refreshItem; + + // Dequeueing and standing down happen under one lock, otherwise a refresh queued + // just after the queue ran dry would see a processor that has already stopped. + lock (_refreshQueueLock) { - return; + if (_disposed + || cancellationToken.IsCancellationRequested + || !_refreshQueue.TryDequeue(out refreshItem, out _)) + { + _isProcessingRefreshQueue = false; + break; + } } try @@ -1188,19 +1211,21 @@ namespace MediaBrowser.Providers.Manager await task.ConfigureAwait(false); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - break; + // Shutting down: the next pass sees the token and stands the processor down. + continue; } catch (Exception ex) { + // Includes a provider that cancelled for its own reasons, such as an HTTP + // timeout, which must not stop the queue draining. _logger.LogError(ex, "Error refreshing item"); } } - lock (_refreshQueueLock) + if (!_disposed) { - _isProcessingRefreshQueue = false; libraryManager.ClearIgnoreRuleCache(); } } @@ -1291,6 +1316,7 @@ namespace MediaBrowser.Providers.Manager if (disposing) { CollectionFolder.LibraryOptionsUpdated -= OnLibraryOptionsUpdated; + _configurationManager.ConfigurationUpdated -= OnConfigurationUpdated; if (!_disposeCancellationTokenSource.IsCancellationRequested) { @@ -1318,6 +1344,11 @@ namespace MediaBrowser.Providers.Manager _logger.LogDebug("Invalidated metadata provider cache for library: {LibraryPath}", e.LibraryPath); } + private void OnConfigurationUpdated(object? sender, EventArgs e) + { + ClearMetadataProviderCache(); + } + internal void ClearMetadataProviderCache() { _metadataProviderCache.Clear(); @@ -1327,7 +1358,7 @@ namespace MediaBrowser.Providers.Manager /// <summary> /// Cache key for metadata provider lookups. /// </summary> - /// <param name="LibraryPath">The library path for the collection folder.</param> + /// <param name="LibraryPath">The path of the collection folder providing the library options.</param> /// <param name="ItemTypeName">The item type name.</param> /// <param name="IncludeDisabled">Whether to include disabled providers.</param> /// <param name="ForceEnableInternetMetadata">Whether internet metadata is force-enabled.</param> diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj index 2b0f480b1c..7c3e1867ef 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj @@ -26,6 +26,7 @@ <PackageReference Include="SharpCompress" /> <PackageReference Include="z440.atl.core" /> <PackageReference Include="TMDbLib" /> + <PackageReference Include="UTF.Unknown" /> </ItemGroup> <PropertyGroup> diff --git a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs index 6f9d5f19da..ecb6e5d990 100644 --- a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs +++ b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs @@ -231,10 +231,28 @@ namespace MediaBrowser.Providers.MediaInfo return Array.Empty<ExternalPathParserResult>(); } + // VobSub .sub payloads only carry per-track language metadata when read via + // their paired .idx file, so probe the .idx instead and skip the .sub. Pairing + // requires the same directory (ffprobe can't resolve a split pair) and an + // ordinal comparison (ffprobe matches the .sub by exact case on case-sensitive + // filesystems, so a looser match could suppress a .sub with no working .idx). + // An .idx file with no paired .sub cannot be probed at all, so it is left out + // entirely rather than surfaced (which would otherwise fail every probe and, + // since the .idx would keep "existing" from Jellyfin's point of view, prevent + // stale subtitle stream metadata from ever being cleared once the .sub is gone). + HashSet<string>? pairedVobSubKeys = _type == DlnaProfileType.Subtitle + ? GetPairedVobSubKeys(files) + : null; + var externalPathInfos = new List<ExternalPathParserResult>(); ReadOnlySpan<char> prefix = video.FileNameWithoutExtension; foreach (var file in files) { + if (IsSuppressedVobSubFile(file, pairedVobSubKeys)) + { + continue; + } + var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file.AsSpan()); if (fileNameWithoutExtension.Length >= prefix.Length && prefix.Equals(fileNameWithoutExtension[..prefix.Length], StringComparison.OrdinalIgnoreCase) @@ -305,6 +323,77 @@ namespace MediaBrowser.Providers.MediaInfo } /// <summary> + /// Determines whether a candidate file is part of a VobSub .idx/.sub pair that + /// should be resolved to only its .idx file, or an .idx file with no paired .sub + /// that cannot be probed at all. + /// </summary> + /// <param name="file">The full path to the candidate file.</param> + /// <param name="pairedVobSubKeys">The set of pairing keys with both an .idx and .sub present, or null if not applicable.</param> + /// <returns><c>true</c> if the file should be suppressed; otherwise, <c>false</c>.</returns> + private static bool IsSuppressedVobSubFile(string file, HashSet<string>? pairedVobSubKeys) + { + if (pairedVobSubKeys is null) + { + return false; + } + + var extension = Path.GetExtension(file.AsSpan()); + if (extension.Equals(".sub", StringComparison.OrdinalIgnoreCase)) + { + // A paired .idx exists; probe it instead of the .sub payload. + return pairedVobSubKeys.Contains(GetVobSubPairingKey(file)); + } + + if (extension.Equals(".idx", StringComparison.OrdinalIgnoreCase)) + { + // Without its .sub payload, the .idx cannot be probed for any data. + return !pairedVobSubKeys.Contains(GetVobSubPairingKey(file)); + } + + return false; + } + + /// <summary> + /// Builds the set of directory+basename keys that have both an .idx and a .sub + /// file present, in a single pass over the candidate files. + /// </summary> + /// <param name="files">The candidate files to search.</param> + /// <returns>The set of pairing keys with both an .idx and .sub present.</returns> + private static HashSet<string> GetPairedVobSubKeys(IEnumerable<string> files) + { + var idxKeys = new HashSet<string>(StringComparer.Ordinal); + var subKeys = new HashSet<string>(StringComparer.Ordinal); + foreach (var file in files) + { + var extension = Path.GetExtension(file.AsSpan()); + if (extension.Equals(".idx", StringComparison.OrdinalIgnoreCase)) + { + idxKeys.Add(GetVobSubPairingKey(file)); + } + else if (extension.Equals(".sub", StringComparison.OrdinalIgnoreCase)) + { + subKeys.Add(GetVobSubPairingKey(file)); + } + } + + idxKeys.IntersectWith(subKeys); + return idxKeys; + } + + /// <summary> + /// Builds a directory+basename key used to pair a VobSub .idx file with its .sub + /// payload only when both live in the same directory. + /// </summary> + /// <param name="file">The full path to the file.</param> + /// <returns>A key combining the containing directory and file name without extension.</returns> + private static string GetVobSubPairingKey(string file) + { + var directory = Path.GetDirectoryName(file) ?? string.Empty; + var baseName = Path.GetFileNameWithoutExtension(file); + return Path.Combine(directory, baseName); + } + + /// <summary> /// Returns the media info of the given file. /// </summary> /// <param name="path">The path to the file.</param> diff --git a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs index 221c6bff5e..5d78cdd0be 100644 --- a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs @@ -142,6 +142,12 @@ namespace MediaBrowser.Providers.MediaInfo } } + if (IsMissingMediaInfo(item)) + { + _logger.LogDebug("Refreshing {ItemPath} because it has no media information.", item.Path); + return true; + } + if (video is not null && item.SupportsLocalMetadata && !video.IsPlaceHolder) @@ -175,6 +181,25 @@ namespace MediaBrowser.Providers.MediaInfo return false; } + private static bool IsMissingMediaInfo(BaseItem item) + { + if (item.RunTimeTicks.HasValue + || item.TotalBitrate.HasValue + || item.IsVirtualItem + || item.IsShortcut + || !item.IsFileProtocol) + { + return false; + } + + return item switch + { + Video video => !video.IsPlaceHolder && video.IsCompleteMedia, + Audio => true, + _ => false + }; + } + /// <inheritdoc /> public Task<ItemUpdateType> FetchAsync(Episode item, MetadataRefreshOptions options, CancellationToken cancellationToken) { diff --git a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs index 924fde4808..4952d03e8a 100644 --- a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs +++ b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; @@ -15,6 +16,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; using PlaylistsNET.Content; +using UtfUnknown; namespace MediaBrowser.Providers.Playlists; @@ -26,6 +28,11 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>, IForcedProvider, IHasItemChangeMonitor { + /// <summary> + /// Minimum confidence required before a detected encoding is preferred over UTF-8. + /// </summary> + private const float MinimumEncodingConfidence = 0.5f; + private readonly IFileSystem _fileSystem; private readonly ILibraryManager _libraryManager; private readonly ILogger<PlaylistItemsProvider> _logger; @@ -136,23 +143,38 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>, private IEnumerable<LinkedChild> GetPlsItems(Stream stream, string playlistPath, List<string> libraryRoots) { var content = new PlsContent(); - var playlist = content.GetFromStream(stream); + var playlist = content.GetFromStream(stream, DetectEncoding(stream, playlistPath)); return playlist.PlaylistEntries .Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots)) .Where(i => i is not null); } - private IEnumerable<LinkedChild> GetM3uItems(Stream stream, string playlistPath, List<string> libraryRoots) + internal IEnumerable<LinkedChild> GetM3uItems(Stream stream, string playlistPath, List<string> libraryRoots) { var content = new M3uContent(); - var playlist = content.GetFromStream(stream); + var playlist = content.GetFromStream(stream, DetectEncoding(stream, playlistPath)); return playlist.PlaylistEntries .Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots)) .Where(i => i is not null); } + private Encoding DetectEncoding(Stream stream, string playlistPath) + { + var detected = CharsetDetector.DetectFromStream(stream).Detected; + stream.Seek(0, SeekOrigin.Begin); + + if (detected?.Encoding is null || detected.Confidence < MinimumEncodingConfidence) + { + _logger.LogDebug("Could not detect the encoding of playlist {Path}, assuming UTF-8", playlistPath); + return Encoding.UTF8; + } + + _logger.LogDebug("Detected encoding {Encoding} for playlist {Path}", detected.Encoding.WebName, playlistPath); + return detected.Encoding; + } + private IEnumerable<LinkedChild> GetZplItems(Stream stream, string playlistPath, List<string> libraryRoots) { var content = new ZplContent(); @@ -191,7 +213,7 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>, { item = null; string pathToCheck = _fileSystem.MakeAbsolutePath(Path.GetDirectoryName(playlistPath), itemPath); - if (!File.Exists(pathToCheck)) + if (!File.Exists(pathToCheck) && !TryNormalizePath(ref pathToCheck)) { return false; } @@ -208,6 +230,36 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>, return false; } + private static bool TryNormalizePath(ref string path) + { + foreach (var form in new[] { NormalizationForm.FormC, NormalizationForm.FormD }) + { + string normalized; + try + { + if (path.IsNormalized(form)) + { + continue; + } + + normalized = path.Normalize(form); + } + catch (ArgumentException) + { + // The path is not valid Unicode, there is nothing to normalize. + return false; + } + + if (File.Exists(normalized)) + { + path = normalized; + return true; + } + } + + return false; + } + /// <inheritdoc /> public bool HasChanged(BaseItem item, IDirectoryService directoryService) { diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html index dec21d1b42..d485fe555a 100644 --- a/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html @@ -7,7 +7,6 @@ <div id="configPage" data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-select"> <div data-role="content"> <div class="content-primary"> - <img id="listenBrainzLogo" alt="ListenBrainz" style="max-width:240px;display:block;margin:0 auto 1em;" /> <h1>ListenBrainz</h1> <p>Get similar artist recommendations from ListenBrainz Labs.</p> <form class="configForm"> @@ -18,12 +17,12 @@ <div class="selectContainer"> <label class="selectLabel" for="algorithm">Similarity Algorithm</label> <select is="emby-select" id="algorithm" class="emby-select-withcolor"> - <option value="0" selected>~5 years / 1825 days (Recommended)</option> - <option value="1">~5 years / 1800 days</option> - <option value="2">~20 years / 7500 days</option> - <option value="3">~20 years / 7500 days (high contribution)</option> - <option value="4">~25 years / 9000 days</option> - <option value="5">~75 days (recent)</option> + <option value="SessionBased1825Days" selected>~5 years / 1825 days (Recommended)</option> + <option value="SessionBased1800Days">~5 years / 1800 days</option> + <option value="SessionBased7500Days">~20 years / 7500 days</option> + <option value="SessionBased7500DaysHighContribution">~20 years / 7500 days (high contribution)</option> + <option value="SessionBased9000Days">~25 years / 9000 days</option> + <option value="SessionBased75Days">~75 days (recent)</option> </select> <div class="fieldDescription">The algorithm used for artist similarity calculation.</div> </div> @@ -52,13 +51,14 @@ </div> <script type="text/javascript"> var ListenBrainzPluginConfig = { - uniquePluginId: "a5b2e8c1-9d4f-4a3b-8c7e-6f1a2b3c4d5e" + uniquePluginId: "a5b2e8c1-9d4f-4a3b-8c7e-6f1a2b3c4d5e", + defaultAlgorithm: "SessionBased1825Days" }; document.querySelector('.configPage') .addEventListener('pageshow', function () { Dashboard.showLoadingMsg(); - document.querySelector('#listenBrainzLogo').src = ApiClient.getUrl('web/ConfigurationPage', { name: 'ListenBrainzLogo' }); + ApiClient.getPluginConfiguration(ListenBrainzPluginConfig.uniquePluginId).then(function (config) { var labsServer = document.querySelector('#labsServer'); labsServer.value = config.LabsServer; @@ -67,7 +67,13 @@ cancelable: false })); - document.querySelector('#algorithm').value = config.Algorithm; + // The API serialises the algorithm as its enum name, so an unknown value here + // means a config written by an older build; fall back to the default. + var algorithm = document.querySelector('#algorithm'); + algorithm.value = config.Algorithm; + if (!algorithm.value) { + algorithm.value = ListenBrainzPluginConfig.defaultAlgorithm; + } var rateLimit = document.querySelector('#rateLimit'); rateLimit.value = config.RateLimit; @@ -93,7 +99,7 @@ ApiClient.getPluginConfiguration(ListenBrainzPluginConfig.uniquePluginId).then(function (config) { config.LabsServer = document.querySelector('#labsServer').value; - config.Algorithm = parseInt(document.querySelector('#algorithm').value, 10); + config.Algorithm = document.querySelector('#algorithm').value; config.RateLimit = document.querySelector('#rateLimit').value; config.SimilarItemsCacheDays = parseInt(document.querySelector('#similarItemsCacheDays').value, 10); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs index b3a67189bb..c94a6455bc 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs @@ -128,6 +128,6 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <summary> /// Gets or sets the cache duration in days for similar item results. A value of 0 disables caching. /// </summary> - public int SimilarItemsCacheDays { get; set; } = 7; + public int SimilarItemsCacheDays { get; set; } = 90; } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs index ef952082da..9501da7ec6 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs @@ -163,12 +163,15 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies // Caller provides the filename with extension stripped and NOT the parsed filename var parsedName = _libraryManager.ParseName(info.Name); var cleanedName = TmdbUtils.CleanName(parsedName.Name); + var searchYear = info.Year ?? parsedName.Year ?? 0; - var searchResults = await _tmdbClientManager.SearchMovieAsync(cleanedName, info.Year ?? parsedName.Year ?? 0, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); + var searchResults = await _tmdbClientManager.SearchMovieAsync(cleanedName, searchYear, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); - if (searchResults?.Count > 0) + var match = TmdbUtils.FindBestMatch(searchResults, parsedName.Name, searchYear); + + if (match is not null) { - tmdbId = searchResults[0].Id; + tmdbId = match.Id; } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs index 5206de78ce..6a3c72d2fa 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs @@ -58,7 +58,7 @@ public class TmdbMovieSimilarProvider : IRemoteSimilarItemsProvider<Movie> } var providerName = MetadataProvider.Tmdb.ToString(); - var page = 0; + var page = 1; var totalPages = 1; while (page <= totalPages && !cancellationToken.IsCancellationRequested) @@ -67,12 +67,12 @@ public class TmdbMovieSimilarProvider : IRemoteSimilarItemsProvider<Movie> try { (pageResults, totalPages) = await _tmdbClientManager - .GetMovieSimilarPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken) + .GetMovieRecommendationsPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken) .ConfigureAwait(false); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to get similar movies from TMDb for {TmdbId} page {Page}", tmdbId, page); + _logger.LogWarning(ex, "Failed to get recommended movies from TMDb for {TmdbId} page {Page}", tmdbId, page); yield break; } @@ -81,12 +81,12 @@ public class TmdbMovieSimilarProvider : IRemoteSimilarItemsProvider<Movie> yield break; } - foreach (var similar in pageResults) + foreach (var recommendation in pageResults) { yield return new SimilarItemReference { ProviderName = providerName, - ProviderId = similar.Id.ToString(CultureInfo.InvariantCulture) + ProviderId = recommendation.Id.ToString(CultureInfo.InvariantCulture) }; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs index 6163e20194..b6bb7aa013 100755 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs @@ -202,11 +202,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // Caller provides the filename with extension stripped and NOT the parsed filename var parsedName = _libraryManager.ParseName(info.Name); var cleanedName = TmdbUtils.CleanName(parsedName.Name); - var searchResults = await _tmdbClientManager.SearchSeriesAsync(cleanedName, info.MetadataLanguage, info.MetadataCountryCode, info.Year ?? parsedName.Year ?? 0, cancellationToken).ConfigureAwait(false); + var searchYear = info.Year ?? parsedName.Year ?? 0; + var searchResults = await _tmdbClientManager.SearchSeriesAsync(cleanedName, info.MetadataLanguage, info.MetadataCountryCode, searchYear, cancellationToken).ConfigureAwait(false); - if (searchResults?.Count > 0) + var match = TmdbUtils.FindBestMatch(searchResults, parsedName.Name, searchYear); + + if (match is not null) { - tmdbId = searchResults[0].Id.ToString(CultureInfo.InvariantCulture); + tmdbId = match.Id.ToString(CultureInfo.InvariantCulture); } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs index c85718b993..40c7de05ad 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs @@ -67,12 +67,12 @@ public class TmdbSeriesSimilarProvider : IRemoteSimilarItemsProvider<Series> try { (pageResults, totalPages) = await _tmdbClientManager - .GetSeriesSimilarPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken) + .GetSeriesRecommendationsPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken) .ConfigureAwait(false); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to get similar TV shows from TMDb for {TmdbId} page {Page}", tmdbId, page); + _logger.LogWarning(ex, "Failed to get recommended TV shows from TMDb for {TmdbId} page {Page}", tmdbId, page); yield break; } @@ -81,12 +81,12 @@ public class TmdbSeriesSimilarProvider : IRemoteSimilarItemsProvider<Series> yield break; } - foreach (var similar in pageResults) + foreach (var recommendation in pageResults) { yield return new SimilarItemReference { ProviderName = providerName, - ProviderId = similar.Id.ToString(CultureInfo.InvariantCulture) + ProviderId = recommendation.Id.ToString(CultureInfo.InvariantCulture) }; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index 5379796465..d75ebae988 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs @@ -25,16 +25,27 @@ namespace MediaBrowser.Providers.Plugins.Tmdb { private const int CacheDurationInHours = 1; - private readonly IMemoryCache _memoryCache; + // Sized in TMDb records - see EstimateSize - rather than in responses, because the responses + // differ in weight by orders of magnitude. + private const int CacheSizeLimit = 100_000; + + private static readonly Dictionary<string, string> ThumbnailSizes = new Dictionary<string, string> + { + { "Primary", "w500" }, + { "Backdrop", "w780" }, + { "Thumb", "w780" }, + { "Logo", "w500" }, + }; + + private readonly MemoryCache _memoryCache; private readonly TMDbClient _tmDbClient; /// <summary> /// Initializes a new instance of the <see cref="TmdbClientManager"/> class. /// </summary> - /// <param name="memoryCache">An instance of <see cref="IMemoryCache"/>.</param> - public TmdbClientManager(IMemoryCache memoryCache) + public TmdbClientManager() { - _memoryCache = memoryCache; + _memoryCache = new MemoryCache(new MemoryCacheOptions { SizeLimit = CacheSizeLimit }); var apiKey = Plugin.Instance.Configuration.TmdbApiKey; apiKey = string.IsNullOrEmpty(apiKey) ? TmdbUtils.ApiKey : apiKey; @@ -78,7 +89,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb if (movie is not null) { - _memoryCache.Set(key, movie, TimeSpan.FromHours(CacheDurationInHours)); + Cache(key, movie); } return movie; @@ -112,7 +123,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb if (collection is not null) { - _memoryCache.Set(key, collection, TimeSpan.FromHours(CacheDurationInHours)); + Cache(key, collection); } return collection; @@ -152,7 +163,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb if (series is not null) { - _memoryCache.Set(key, series, TimeSpan.FromHours(CacheDurationInHours)); + Cache(key, series); } return series; @@ -208,7 +219,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb if (group is not null) { - _memoryCache.Set(key, group, TimeSpan.FromHours(CacheDurationInHours)); + Cache(key, group); } return group; @@ -244,7 +255,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb if (season is not null) { - _memoryCache.Set(key, season, TimeSpan.FromHours(CacheDurationInHours)); + Cache(key, season); } return season; @@ -296,7 +307,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb if (episode is not null) { - _memoryCache.Set(key, episode, TimeSpan.FromHours(CacheDurationInHours)); + Cache(key, episode); } return episode; @@ -323,12 +334,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb person = await _tmDbClient.GetPersonAsync( personTmdbId, TmdbUtils.NormalizeLanguage(language, countryCode), - PersonMethods.TvCredits | PersonMethods.MovieCredits | PersonMethods.Images | PersonMethods.ExternalIds, + PersonMethods.Images | PersonMethods.ExternalIds, cancellationToken).ConfigureAwait(false); if (person is not null) { - _memoryCache.Set(key, person, TimeSpan.FromHours(CacheDurationInHours)); + Cache(key, person); } return person; @@ -366,7 +377,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb if (result is not null) { - _memoryCache.Set(key, result, TimeSpan.FromHours(CacheDurationInHours)); + Cache(key, result); } return result; @@ -397,7 +408,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb if (searchResults?.Results?.Count > 0) { - _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours)); + CacheSearch(key, searchResults); } return searchResults?.Results; @@ -425,7 +436,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb if (searchResults?.Results?.Count > 0) { - _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours)); + CacheSearch(key, searchResults); } return searchResults?.Results; @@ -468,7 +479,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb if (searchResults?.Results?.Count > 0) { - _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours)); + CacheSearch(key, searchResults); } return searchResults?.Results; @@ -498,26 +509,26 @@ namespace MediaBrowser.Providers.Plugins.Tmdb if (searchResults?.Results?.Count > 0) { - _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours)); + CacheSearch(key, searchResults); } return searchResults?.Results; } /// <summary> - /// Gets a single page of similar movies for a movie from the TMDb API. + /// Gets a single page of recommended movies for a movie from the TMDb API. /// </summary> /// <param name="tmdbId">The TMDb id of the movie.</param> /// <param name="page">The page number to fetch (1-based).</param> /// <param name="language">The language for results.</param> /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>A tuple containing the list of similar movies and the total number of pages available.</returns> - public async Task<(IReadOnlyList<SearchMovie> Results, int TotalPages)> GetMovieSimilarPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken) + /// <returns>A tuple containing the list of recommended movies and the total number of pages available.</returns> + public async Task<(IReadOnlyList<SearchMovie> Results, int TotalPages)> GetMovieRecommendationsPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken) { await EnsureClientConfigAsync().ConfigureAwait(false); var searchResults = await _tmDbClient - .GetMovieSimilarAsync(tmdbId, language, page, cancellationToken) + .GetMovieRecommendationsAsync(tmdbId, language, page, cancellationToken) .ConfigureAwait(false); if (searchResults?.Results is null || searchResults.Results.Count == 0) @@ -529,19 +540,19 @@ namespace MediaBrowser.Providers.Plugins.Tmdb } /// <summary> - /// Gets a single page of similar TV shows for a series from the TMDb API. + /// Gets a single page of recommended TV shows for a series from the TMDb API. /// </summary> /// <param name="tmdbId">The TMDb id of the TV show.</param> /// <param name="page">The page number to fetch (1-based).</param> /// <param name="language">The language for results.</param> /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>A tuple containing the list of similar TV shows and the total number of pages available.</returns> - public async Task<(IReadOnlyList<SearchTv> Results, int TotalPages)> GetSeriesSimilarPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken) + /// <returns>A tuple containing the list of recommended TV shows and the total number of pages available.</returns> + public async Task<(IReadOnlyList<SearchTv> Results, int TotalPages)> GetSeriesRecommendationsPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken) { await EnsureClientConfigAsync().ConfigureAwait(false); var searchResults = await _tmDbClient - .GetTvShowSimilarAsync(tmdbId, language, page, cancellationToken) + .GetTvShowRecommendationsAsync(tmdbId, language, page, cancellationToken) .ConfigureAwait(false); if (searchResults?.Results is null || searchResults.Results.Count == 0) @@ -565,8 +576,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb return null; } - // Use "original" as default size if size is null or empty to prevent malformed URLs - var imageSize = string.IsNullOrEmpty(size) ? "original" : size; + // Use the original size as default if size is null or empty to prevent malformed URLs + var imageSize = string.IsNullOrEmpty(size) ? TmdbUtils.OriginalImageSize : size; return _tmDbClient.GetImageUrl(imageSize, path, true).ToString(); } @@ -657,7 +668,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb private IEnumerable<RemoteImageInfo> ConvertToRemoteImageInfo(IReadOnlyList<ImageData> images, string? size, ImageType type, string requestLanguage) { // sizes provided are for original resolution, don't store them when downloading scaled images - var scaleImage = !string.Equals(size, "original", StringComparison.OrdinalIgnoreCase); + var scaleImage = !TmdbUtils.IsOriginalImageSize(size); for (var i = 0; i < images.Count; i++) { @@ -675,6 +686,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb yield return new RemoteImageInfo { Url = GetUrl(size, image.FilePath), + ThumbnailUrl = GetUrl(ThumbnailSizes.GetValueOrDefault(type.ToString(), string.Empty), image.FilePath), CommunityRating = image.VoteAverage, VoteCount = image.VoteCount, Width = scaleImage ? null : image.Width, @@ -753,6 +765,84 @@ namespace MediaBrowser.Providers.Plugins.Tmdb return _tmDbClient.Config; } + /// <summary> + /// Stores a response under the shared expiry, weighed by what it costs to keep. + /// </summary> + private void Cache<T>(string key, T value) + where T : class + => _memoryCache.Set( + key, + value, + new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(CacheDurationInHours), + Size = EstimateSize(value) + }); + + /// <summary> + /// Stores a page of search results, whose weight is simply how many there are. + /// </summary> + private void CacheSearch<T>(string key, SearchContainer<T> results) + => _memoryCache.Set( + key, + results, + new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(CacheDurationInHours), + Size = 1 + Count(results.Results) + }); + + private static long Count<T>(IReadOnlyCollection<T>? items) => items?.Count ?? 0; + + /// <summary> + /// Estimates what keeping a response costs, counting the sub-records that dominate it. + /// </summary> + private static long EstimateSize(object? value) => value switch + { + TvShow series => 1 + + Count(series.Credits?.Cast) + Count(series.Credits?.Crew) + + EstimateAggregateSize(series.AggregateCredits) + + Count(series.Seasons), + TvSeason season => 1 + + Count(season.Credits?.Cast) + Count(season.Credits?.Crew) + + Count(season.Episodes), + TvEpisode episode => 1 + + Count(episode.Credits?.Cast) + Count(episode.Credits?.Crew) + + Count(episode.Credits?.GuestStars), + Movie movie => 1 + Count(movie.Credits?.Cast) + Count(movie.Credits?.Crew), + Collection collection => 1 + Count(collection.Parts), + TvGroupCollection groups => 1 + Count(groups.Groups), + FindContainer found => 1 + + Count(found.MovieResults) + Count(found.TvResults) + + Count(found.PersonResults) + Count(found.TvEpisode) + Count(found.TvSeason), + _ => 1 + }; + + /// <summary> + /// Weighs aggregate credits, where each person carries one record per episode they worked on. + /// </summary> + private static long EstimateAggregateSize(CreditsAggregate? credits) + { + if (credits is null) + { + return 0; + } + + var size = Count(credits.Cast) + Count(credits.Crew); + + foreach (var cast in credits.Cast ?? []) + { + size += Count(cast.Roles); + } + + foreach (var crew in credits.Crew ?? []) + { + size += Count(crew.Jobs); + } + + return size; + } + /// <inheritdoc /> public void Dispose() { @@ -768,7 +858,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb { if (disposing) { - _memoryCache?.Dispose(); + _memoryCache.Dispose(); _tmDbClient?.Dispose(); } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs index 44a2f7291e..f004251594 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs @@ -8,6 +8,7 @@ using System.Text.RegularExpressions; using Jellyfin.Data.Enums; using MediaBrowser.Model.Entities; using TMDbLib.Objects.General; +using TMDbLib.Objects.Search; using TMDbLib.Objects.TvShows; using PersonInfo = MediaBrowser.Controller.Entities.PersonInfo; @@ -34,6 +35,16 @@ namespace MediaBrowser.Providers.Plugins.Tmdb public const string ApiKey = "4219e299c89411838049ab0dab19ebd5"; /// <summary> + /// The image size representing the unscaled image as served by TMDb. + /// </summary> + public const string OriginalImageSize = "original"; + + private const int TitleExactScore = 8; + private const int TitlePrefixScore = 4; + private const int YearExactScore = 2; + private const int YearAdjacentScore = 1; + + /// <summary> /// The crew types to keep. /// </summary> public static readonly string[] WantedCrewTypes = @@ -63,8 +74,20 @@ namespace MediaBrowser.Providers.Plugins.Tmdb "novel" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); - [GeneratedRegex(@"[\W_-[·]]+")] - private static partial Regex NonWordRegex(); + /// <summary> + /// Everything that is not a letter, a number or a combining mark separates two search terms. The + /// interpunct is kept because TMDb uses it inside titles such as "WALL·E", where it matches better + /// than a space does. + /// </summary> + [GeneratedRegex(@"[^\p{L}\p{N}\p{M}·]+")] + private static partial Regex NonSearchTermRegex(); + + /// <summary> + /// As <see cref="NonSearchTermRegex"/>, but the interpunct is a separator too, so a "WALL-E" folder + /// and the "WALL·E" title TMDb returns compare equal. + /// </summary> + [GeneratedRegex(@"[^\p{L}\p{N}\p{M}]+")] + private static partial Regex NonComparableRegex(); /// <summary> /// Gets the TMDb id of an item, if it has one TMDb can be queried with. @@ -101,7 +124,140 @@ namespace MediaBrowser.Providers.Plugins.Tmdb public static string CleanName(string name) { // TMDb expects a space separated list of words make sure that is the case - return NonWordRegex().Replace(name, " "); + return NonSearchTermRegex().Replace(name, " ").Trim(); + } + + /// <summary> + /// Reduces a title to the form used to compare a local name against a TMDb search result. + /// </summary> + /// <param name="title">The title to normalize.</param> + /// <returns>The normalized title, or an empty string if there was nothing to normalize.</returns> + public static string NormalizeTitle(string? title) + { + return string.IsNullOrEmpty(title) + ? string.Empty + : NonComparableRegex().Replace(title, " ").Trim().ToLowerInvariant(); + } + + /// <summary> + /// Picks the movie search result that best matches the name and year an item was looked up by. + /// </summary> + /// <param name="results">The search results, in the order TMDb returned them.</param> + /// <param name="name">The parsed name of the local item.</param> + /// <param name="year">The year of the local item, or 0 if it is unknown.</param> + /// <returns>The best match, or <c>null</c> if there were no results.</returns> + public static SearchMovie? FindBestMatch(IReadOnlyList<SearchMovie>? results, string? name, int year) + { + return FindBestMatch( + results, + name, + year, + static movie => movie.Title, + static movie => movie.OriginalTitle, + static movie => movie.ReleaseDate); + } + + /// <summary> + /// Picks the series search result that best matches the name and year an item was looked up by. + /// </summary> + /// <param name="results">The search results, in the order TMDb returned them.</param> + /// <param name="name">The parsed name of the local item.</param> + /// <param name="year">The year of the local item, or 0 if it is unknown.</param> + /// <returns>The best match, or <c>null</c> if there were no results.</returns> + public static SearchTv? FindBestMatch(IReadOnlyList<SearchTv>? results, string? name, int year) + { + return FindBestMatch( + results, + name, + year, + static series => series.Name, + static series => series.OriginalName, + static series => series.FirstAirDate); + } + + /// <summary> + /// Picks the search result that best matches the name and year an item was looked up by. + /// </summary> + /// <remarks> + /// TMDb's year parameter only nudges relevance, it does not filter, so the first hit is regularly a + /// different film or show that happens to share the title - searching for "Mulan" with year 2020 + /// returns the 1998 film first. A title that matches outranks one that does not, and the year only + /// separates candidates that are otherwise equally good. When nothing matches at all TMDb's own + /// ordering is kept, so a name that needs fuzzy matching, such as "A Christmas No. 1" for + /// "A Christmas Number One", still resolves. + /// </remarks> + private static T? FindBestMatch<T>( + IReadOnlyList<T>? results, + string? name, + int year, + Func<T, string?> titleSelector, + Func<T, string?> originalTitleSelector, + Func<T, DateTime?> releaseDateSelector) + where T : class + { + if (results is null || results.Count == 0) + { + return null; + } + + var normalizedName = NormalizeTitle(name); + if (normalizedName.Length == 0) + { + return results[0]; + } + + var best = results[0]; + var bestScore = 0; + + foreach (var result in results) + { + var score = Math.Max( + ScoreTitle(normalizedName, titleSelector(result)), + ScoreTitle(normalizedName, originalTitleSelector(result))) + + ScoreYear(year, releaseDateSelector(result)?.Year); + + // Strictly greater, so ties keep the earlier, more relevant result. + if (score > bestScore) + { + bestScore = score; + best = result; + } + } + + return best; + } + + private static int ScoreTitle(string normalizedName, string? title) + { + var normalizedTitle = NormalizeTitle(title); + + if (string.Equals(normalizedName, normalizedTitle, StringComparison.Ordinal)) + { + return TitleExactScore; + } + + // Whole words only, otherwise "Wall" half matches "Wall Street". + return normalizedTitle.Length > normalizedName.Length + && normalizedTitle[normalizedName.Length] == ' ' + && normalizedTitle.StartsWith(normalizedName, StringComparison.Ordinal) + ? TitlePrefixScore + : 0; + } + + private static int ScoreYear(int year, int? resultYear) + { + if (year <= 0 || resultYear is not int candidateYear) + { + return 0; + } + + return Math.Abs(candidateYear - year) switch + { + 0 => YearExactScore, + // Regional release dates routinely straddle a new year. + 1 => YearAdjacentScore, + _ => 0 + }; } /// <summary> @@ -335,6 +491,15 @@ namespace MediaBrowser.Providers.Plugins.Tmdb } /// <summary> + /// Determines whether the configured image size fetches the image at its original resolution. + /// An unset size falls back to <see cref="OriginalImageSize"/>, see TmdbClientManager.GetUrl. + /// </summary> + /// <param name="size">The configured image size.</param> + /// <returns><c>true</c> if the original image is fetched; otherwise, <c>false</c>.</returns> + public static bool IsOriginalImageSize(string? size) + => string.IsNullOrEmpty(size) || string.Equals(size, OriginalImageSize, StringComparison.OrdinalIgnoreCase); + + /// <summary> /// Combines the metadata country code and the parental rating from the API into the value we store in our database. /// </summary> /// <param name="countryCode">The ISO 3166-1 country code of the rating country.</param> diff --git a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs index c3458d4b2a..cd9dda21a0 100644 --- a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs +++ b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs @@ -33,6 +33,7 @@ namespace MediaBrowser.Providers.Subtitles private readonly ILibraryMonitor _monitor; private readonly IMediaSourceManager _mediaSourceManager; private readonly ILocalizationManager _localization; + private readonly IDirectoryService _directoryService; private readonly HashSet<string> _allowedSubtitleFormats; private readonly ISubtitleProvider[] _subtitleProviders; @@ -43,6 +44,7 @@ namespace MediaBrowser.Providers.Subtitles ILibraryMonitor monitor, IMediaSourceManager mediaSourceManager, ILocalizationManager localizationManager, + IDirectoryService directoryService, IEnumerable<ISubtitleProvider> subtitleProviders, NamingOptions namingOptions) { @@ -51,6 +53,7 @@ namespace MediaBrowser.Providers.Subtitles _monitor = monitor; _mediaSourceManager = mediaSourceManager; _localization = localizationManager; + _directoryService = directoryService; _subtitleProviders = subtitleProviders .OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0) .ToArray(); @@ -281,6 +284,8 @@ namespace MediaBrowser.Providers.Subtitles await stream.CopyToAsync(fs).ConfigureAwait(false); } + _directoryService.Invalidate(path); + return; } else @@ -395,6 +400,8 @@ namespace MediaBrowser.Providers.Subtitles _monitor.ReportFileSystemChangeComplete(path, false); } + _directoryService.Invalidate(path); + return item.RefreshMetadata(CancellationToken.None); } diff --git a/MediaBrowser.Providers/TV/EpisodeMetadataService.cs b/MediaBrowser.Providers/TV/EpisodeMetadataService.cs index 596ca8d201..f662ac2367 100644 --- a/MediaBrowser.Providers/TV/EpisodeMetadataService.cs +++ b/MediaBrowser.Providers/TV/EpisodeMetadataService.cs @@ -44,6 +44,31 @@ public class EpisodeMetadataService : MetadataService<Episode, EpisodeInfo> { var updatedType = base.BeforeSaveInternal(item, isFullRefresh, updateType); + // An episode cannot end before it starts. + if (item.IndexNumberEnd < item.IndexNumber) + { + Logger.LogWarning( + "Discarding episode range end {IndexNumberEnd} preceding episode number {IndexNumber} for {Path}", + item.IndexNumberEnd, + item.IndexNumber, + item.Path); + + item.IndexNumberEnd = null; + updatedType |= ItemUpdateType.MetadataImport; + } + else if (item.IndexNumberEnd.HasValue && !item.IndexNumber.HasValue) + { + // Without a first episode the end does not describe a range. Promoting it to the episode number + // would invent an identity the metadata never supplied, so drop the orphaned value instead. + Logger.LogWarning( + "Discarding episode range end {IndexNumberEnd} without an episode number for {Path}", + item.IndexNumberEnd, + item.Path); + + item.IndexNumberEnd = null; + updatedType |= ItemUpdateType.MetadataImport; + } + var seriesName = item.FindSeriesName(); if (!string.Equals(item.SeriesName, seriesName, StringComparison.Ordinal)) { |
