aboutsummaryrefslogtreecommitdiff
path: root/MediaBrowser.Providers
diff options
context:
space:
mode:
Diffstat (limited to 'MediaBrowser.Providers')
-rw-r--r--MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs11
-rw-r--r--MediaBrowser.Providers/Lyric/LyricManager.cs8
-rw-r--r--MediaBrowser.Providers/Manager/ItemImageProvider.cs2
-rw-r--r--MediaBrowser.Providers/Manager/MetadataService.cs9
-rw-r--r--MediaBrowser.Providers/Manager/ProviderManager.cs65
-rw-r--r--MediaBrowser.Providers/MediaBrowser.Providers.csproj1
-rw-r--r--MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs89
-rw-r--r--MediaBrowser.Providers/MediaInfo/ProbeProvider.cs25
-rw-r--r--MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs60
-rw-r--r--MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html28
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs2
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs9
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs10
-rwxr-xr-xMediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs9
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs8
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs148
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs171
-rw-r--r--MediaBrowser.Providers/Subtitles/SubtitleManager.cs7
-rw-r--r--MediaBrowser.Providers/TV/EpisodeMetadataService.cs25
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))
{