Safeguard against invalid provider ids

This commit is contained in:
Shadowghost
2026-08-08 19:49:26 +02:00
parent a0b291a463
commit 8c4dfc0b71
20 changed files with 437 additions and 57 deletions
@@ -1,14 +1,16 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
namespace MediaBrowser.Model.Entities;
/// <summary>
/// Class ProviderIdsExtensions.
/// </summary>
public static class ProviderIdsExtensions
public static partial class ProviderIdsExtensions
{
/// <summary>
/// Case-insensitive dictionary of <see cref="MetadataProvider"/> string representation.
@@ -20,6 +22,27 @@ public static class ProviderIdsExtensions
enumValue => enumValue.ToString(),
StringComparer.OrdinalIgnoreCase);
/// <summary>
/// The known id formats, keyed by provider name.
/// </summary>
private static readonly Dictionary<string, Func<string, bool>> _providerIdValidators =
new(StringComparer.OrdinalIgnoreCase)
{
[MetadataProvider.Imdb.ToString()] = value => ImdbIdRegex().IsMatch(value),
[MetadataProvider.Tmdb.ToString()] = IsPositiveNumber,
[MetadataProvider.TmdbCollection.ToString()] = IsPositiveNumber,
[MetadataProvider.AudioDbArtist.ToString()] = IsPositiveNumber,
[MetadataProvider.AudioDbAlbum.ToString()] = IsPositiveNumber,
// Every MusicBrainz id is an MBID.
[MetadataProvider.MusicBrainzAlbum.ToString()] = IsGuid,
[MetadataProvider.MusicBrainzAlbumArtist.ToString()] = IsGuid,
[MetadataProvider.MusicBrainzArtist.ToString()] = IsGuid,
[MetadataProvider.MusicBrainzReleaseGroup.ToString()] = IsGuid,
[MetadataProvider.MusicBrainzRecording.ToString()] = IsGuid,
[MetadataProvider.MusicBrainzTrack.ToString()] = IsGuid
};
/// <summary>
/// Checks if this instance has an id for the given provider.
/// </summary>
@@ -101,6 +124,26 @@ public static class ProviderIdsExtensions
return instance.GetProviderId(provider.ToString());
}
/// <summary>
/// Checks whether a value can be an id of the given provider.
/// </summary>
/// <param name="name">The provider name.</param>
/// <param name="value">The provider id.</param>
/// <returns><c>true</c> if the value has a plausible format for the provider; otherwise, <c>false</c>.</returns>
/// <remarks>
/// Providers regularly hand out an id belonging to a different service, e.g. an IMDb person id in the
/// TMDb field. Such an id is not just useless, it also makes the owning provider fail for the item.
/// </remarks>
public static bool IsValidProviderId(string? name, string? value)
{
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(value))
{
return false;
}
return !_providerIdValidators.TryGetValue(name, out var isValid) || isValid(value);
}
/// <summary>
/// Sets a provider id.
/// </summary>
@@ -116,7 +159,8 @@ public static class ProviderIdsExtensions
// When name contains a '=' it can't be deserialized from the database
if (string.IsNullOrWhiteSpace(name)
|| string.IsNullOrWhiteSpace(value)
|| name.Contains('=', StringComparison.Ordinal))
|| name.Contains('=', StringComparison.Ordinal)
|| !IsValidProviderId(name, value))
{
return false;
}
@@ -213,4 +257,15 @@ public static class ProviderIdsExtensions
instance.ProviderIds?.Remove(provider.ToString());
}
private static bool IsPositiveNumber(string value)
=> long.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var id) && id > 0;
private static bool IsGuid(string value)
=> Guid.TryParse(value, CultureInfo.InvariantCulture, out _);
// An IMDb id is a type prefix (tt for titles, nm for people, co for companies, ...) followed by
// digits. The prefix is optional because a bare number has always been accepted for a title.
[GeneratedRegex(@"^(tt|nm|co|ev|ch|ni)?[0-9]+$", RegexOptions.IgnoreCase)]
private static partial Regex ImdbIdRegex();
}
@@ -858,7 +858,10 @@ namespace MediaBrowser.Providers.Manager
{
if (refreshResult.UpdateType > ItemUpdateType.None)
{
if (!options.RemoveOldMetadata)
// A provider that failed contributed nothing, so the result is not the complete
// replacement the caller asked for. Keeping the existing values stops a provider being
// temporarily unreachable, or choking on a bad id, from deleting the data it owns.
if (!options.RemoveOldMetadata || refreshResult.Failures > 0)
{
// Add existing metadata to provider result if it does not exist there
MergeData(metadata, temp, [], false, false);
@@ -932,6 +935,8 @@ namespace MediaBrowser.Providers.Manager
{
result.Provider = provider.Name;
LogInvalidProviderIds(result.Item, providerName, logName);
MergeData(result, temp, [], replaceData, false);
MergeNewData(temp.Item, id);
@@ -957,6 +962,29 @@ namespace MediaBrowser.Providers.Manager
return refreshResult;
}
/// <summary>
/// Reports the ids a provider returned that cannot belong to the provider they are filed under.
/// </summary>
/// <remarks>
/// The ids are dropped when merging, this names the provider that produced them so the source of a
/// recurring bad id can be found.
/// </remarks>
private void LogInvalidProviderIds(TItemType item, string providerName, string logName)
{
if (item?.ProviderIds is null || !Logger.IsEnabled(LogLevel.Debug))
{
return;
}
foreach (var (key, value) in item.ProviderIds)
{
if (!ProviderIdsExtensions.IsValidProviderId(key, value))
{
Logger.LogDebug("Discarding {Key} id '{Value}' returned by {Provider} for {Item}", key, value, providerName, logName);
}
}
}
private void MergeNewData(TItemType source, TIdType lookupInfo)
{
// Copy new provider id's that may have been obtained
@@ -964,6 +992,11 @@ namespace MediaBrowser.Providers.Manager
{
var key = providerId.Key;
if (!ProviderIdsExtensions.IsValidProviderId(key, providerId.Value))
{
continue;
}
// Don't replace existing Id's.
lookupInfo.ProviderIds.TryAdd(key, providerId.Value);
}
@@ -1175,6 +1208,13 @@ namespace MediaBrowser.Providers.Manager
{
var key = id.Key;
// An id that cannot belong to the provider it is filed under only breaks that provider on
// the next refresh, so never let one in - not even when replacing all metadata.
if (!ProviderIdsExtensions.IsValidProviderId(key, id.Value))
{
continue;
}
// Don't replace existing Id's.
if (replaceData)
{
@@ -1,5 +1,7 @@
#pragma warning disable CS1591
using System;
using System.Globalization;
using System.Linq;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
@@ -23,11 +25,11 @@ namespace MediaBrowser.Providers.Music
public static string? GetReleaseGroupId(this AlbumInfo info)
{
var id = info.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup);
var id = MusicBrainzId(info.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup));
if (string.IsNullOrEmpty(id))
{
return info.SongInfos.Select(i => i.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup))
return info.SongInfos.Select(i => MusicBrainzId(i.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup)))
.FirstOrDefault(i => !string.IsNullOrEmpty(i));
}
@@ -36,11 +38,11 @@ namespace MediaBrowser.Providers.Music
public static string? GetReleaseId(this AlbumInfo info)
{
var id = info.GetProviderId(MetadataProvider.MusicBrainzAlbum);
var id = MusicBrainzId(info.GetProviderId(MetadataProvider.MusicBrainzAlbum));
if (string.IsNullOrEmpty(id))
{
return info.SongInfos.Select(i => i.GetProviderId(MetadataProvider.MusicBrainzAlbum))
return info.SongInfos.Select(i => MusicBrainzId(i.GetProviderId(MetadataProvider.MusicBrainzAlbum)))
.FirstOrDefault(i => !string.IsNullOrEmpty(i));
}
@@ -50,15 +52,17 @@ namespace MediaBrowser.Providers.Music
public static string? GetMusicBrainzArtistId(this AlbumInfo info)
{
info.ProviderIds.TryGetValue(MetadataProvider.MusicBrainzAlbumArtist.ToString(), out string? id);
id = MusicBrainzId(id);
if (string.IsNullOrEmpty(id))
{
info.ArtistProviderIds.TryGetValue(MetadataProvider.MusicBrainzArtist.ToString(), out id);
id = MusicBrainzId(id);
}
if (string.IsNullOrEmpty(id))
{
return info.SongInfos.Select(i => i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist))
return info.SongInfos.Select(i => MusicBrainzId(i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist)))
.FirstOrDefault(i => !string.IsNullOrEmpty(i));
}
@@ -68,14 +72,21 @@ namespace MediaBrowser.Providers.Music
public static string? GetMusicBrainzArtistId(this ArtistInfo info)
{
info.ProviderIds.TryGetValue(MetadataProvider.MusicBrainzArtist.ToString(), out var id);
id = MusicBrainzId(id);
if (string.IsNullOrEmpty(id))
{
return info.SongInfos.Select(i => i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist))
return info.SongInfos.Select(i => MusicBrainzId(i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist)))
.FirstOrDefault(i => !string.IsNullOrEmpty(i));
}
return id;
}
/// <summary>
/// Returns the id if it can be a MusicBrainz id, otherwise <c>null</c>.
/// </summary>
private static string? MusicBrainzId(string? id)
=> Guid.TryParse(id, CultureInfo.InvariantCulture, out _) ? id : null;
}
}
@@ -1,6 +1,4 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
@@ -56,7 +54,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
/// <inheritdoc />
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
{
var tmdbId = Convert.ToInt32(item.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
item.TryGetTmdbId(out var tmdbId);
if (tmdbId <= 0)
{
@@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -42,7 +41,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
/// <inheritdoc />
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BoxSetInfo searchInfo, CancellationToken cancellationToken)
{
var tmdbId = Convert.ToInt32(searchInfo.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
searchInfo.TryGetTmdbId(out var tmdbId);
var language = searchInfo.MetadataLanguage;
if (tmdbId > 0)
@@ -97,7 +96,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
/// <inheritdoc />
public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, CancellationToken cancellationToken)
{
var tmdbId = Convert.ToInt32(info.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
info.TryGetTmdbId(out var tmdbId);
var language = info.MetadataLanguage;
// We don't already have an Id, need to fetch it
@@ -1,6 +1,4 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
@@ -61,7 +59,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
var language = item.GetPreferredMetadataLanguage();
var countryCode = item.GetPreferredMetadataCountryCode();
var movieTmdbId = Convert.ToInt32(item.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
item.TryGetTmdbId(out var movieTmdbId);
if (movieTmdbId <= 0)
{
var movieImdbId = item.GetProviderId(MetadataProvider.Imdb);
@@ -54,11 +54,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
/// <inheritdoc />
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(MovieInfo searchInfo, CancellationToken cancellationToken)
{
if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var id))
if (searchInfo.TryGetTmdbId(out var tmdbId))
{
var movie = await _tmdbClientManager
.GetMovieAsync(
int.Parse(id, CultureInfo.InvariantCulture),
tmdbId,
searchInfo.MetadataLanguage,
TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode),
searchInfo.MetadataCountryCode,
@@ -90,7 +90,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
}
IReadOnlyList<SearchMovie>? movieResults = null;
if (searchInfo.TryGetProviderId(MetadataProvider.Imdb, out id))
if (searchInfo.TryGetProviderId(MetadataProvider.Imdb, out var id))
{
var result = await _tmdbClientManager.FindByExternalIdAsync(
id,
@@ -151,11 +151,13 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
/// <inheritdoc />
public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, CancellationToken cancellationToken)
{
var tmdbId = info.GetProviderId(MetadataProvider.Tmdb);
// A stored id that is not a TMDb id is treated as no id, so the search below can repair it
// rather than the lookup failing for as long as the bad id stays on the item.
info.TryGetTmdbId(out var tmdbId);
var imdbId = info.GetProviderId(MetadataProvider.Imdb);
var config = Plugin.Instance.Configuration;
if (string.IsNullOrEmpty(tmdbId) && string.IsNullOrEmpty(imdbId))
if (tmdbId <= 0 && string.IsNullOrEmpty(imdbId))
{
// ParseName is required here.
// Caller provides the filename with extension stripped and NOT the parsed filename
@@ -166,26 +168,26 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
if (searchResults?.Count > 0)
{
tmdbId = searchResults[0].Id.ToString(CultureInfo.InvariantCulture);
tmdbId = searchResults[0].Id;
}
}
if (string.IsNullOrEmpty(tmdbId) && !string.IsNullOrEmpty(imdbId))
if (tmdbId <= 0 && !string.IsNullOrEmpty(imdbId))
{
var movieResultFromImdbId = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
if (movieResultFromImdbId?.MovieResults?.Count > 0)
{
tmdbId = movieResultFromImdbId.MovieResults[0].Id.ToString(CultureInfo.InvariantCulture);
tmdbId = movieResultFromImdbId.MovieResults[0].Id;
}
}
if (string.IsNullOrEmpty(tmdbId))
if (tmdbId <= 0)
{
return new MetadataResult<Movie>();
}
var movieResult = await _tmdbClientManager
.GetMovieAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken)
.GetMovieAsync(tmdbId, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken)
.ConfigureAwait(false);
if (movieResult is null)
@@ -208,7 +210,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
Item = movie
};
movie.SetProviderId(MetadataProvider.Tmdb, tmdbId);
movie.SetProviderId(MetadataProvider.Tmdb, tmdbId.ToString(CultureInfo.InvariantCulture));
movie.TrySetProviderId(MetadataProvider.Imdb, movieResult.ImdbId);
if (movieResult.BelongsToCollection is not null)
{
@@ -1,5 +1,4 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
@@ -54,14 +53,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People
{
var person = (Person)item;
if (!person.TryGetProviderId(MetadataProvider.Tmdb, out var personTmdbId))
if (!person.TryGetTmdbId(out var personTmdbId))
{
return Enumerable.Empty<RemoteImageInfo>();
}
var language = item.GetPreferredMetadataLanguage();
var countryCode = item.GetPreferredMetadataCountryCode();
var personResult = await _tmdbClientManager.GetPersonAsync(int.Parse(personTmdbId, CultureInfo.InvariantCulture), language, countryCode, cancellationToken).ConfigureAwait(false);
var personResult = await _tmdbClientManager.GetPersonAsync(personTmdbId, language, countryCode, cancellationToken).ConfigureAwait(false);
if (personResult?.Images?.Profiles is null)
{
return Enumerable.Empty<RemoteImageInfo>();
@@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net.Http;
@@ -37,9 +36,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People
/// <inheritdoc />
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(PersonLookupInfo searchInfo, CancellationToken cancellationToken)
{
if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var personTmdbId))
if (searchInfo.TryGetTmdbId(out var personTmdbId))
{
var personResult = await _tmdbClientManager.GetPersonAsync(int.Parse(personTmdbId, CultureInfo.InvariantCulture), searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
var personResult = await _tmdbClientManager.GetPersonAsync(personTmdbId, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
if (personResult is not null)
{
@@ -89,7 +88,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People
/// <inheritdoc />
public async Task<MetadataResult<Person>> GetMetadata(PersonLookupInfo info, CancellationToken cancellationToken)
{
var personTmdbId = Convert.ToInt32(info.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
// A person can carry another provider's id under the TMDb key, which is no more usable here
// than no id at all, so both take the search path and get the stored id repaired.
info.TryGetTmdbId(out var personTmdbId);
// We don't already have an Id, need to fetch it
if (personTmdbId <= 0)
@@ -1,6 +1,4 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
@@ -56,9 +54,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
var episode = (Controller.Entities.TV.Episode)item;
var series = episode.Series;
var seriesTmdbId = Convert.ToInt32(series?.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
var seriesTmdbId = 0;
if (series is null || seriesTmdbId <= 0)
if (series?.TryGetTmdbId(out seriesTmdbId) != true)
{
return Enumerable.Empty<RemoteImageInfo>();
}
@@ -91,8 +91,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
info.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out string? tmdbId);
var seriesTmdbId = Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture);
if (seriesTmdbId <= 0)
if (!TmdbUtils.TryParseTmdbId(tmdbId, out var seriesTmdbId))
{
return metadataResult;
}
@@ -1,6 +1,4 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
@@ -57,9 +55,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
var season = (Season)item;
var series = season?.Series;
var seriesTmdbId = Convert.ToInt32(series?.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
var seriesTmdbId = 0;
if (seriesTmdbId <= 0 || season?.IndexNumber is null)
if (season?.IndexNumber is null || series?.TryGetTmdbId(out seriesTmdbId) != true)
{
return Enumerable.Empty<RemoteImageInfo>();
}
@@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -48,13 +47,13 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
var seasonNumber = info.IndexNumber;
if (string.IsNullOrWhiteSpace(seriesTmdbId) || !seasonNumber.HasValue)
if (!seasonNumber.HasValue || !TmdbUtils.TryParseTmdbId(seriesTmdbId, out var seriesId))
{
return result;
}
var seasonResult = await _tmdbClientManager
.GetSeasonAsync(Convert.ToInt32(seriesTmdbId, CultureInfo.InvariantCulture), seasonNumber.Value, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken)
.GetSeasonAsync(seriesId, seasonNumber.Value, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken)
.ConfigureAwait(false);
if (seasonResult is null)
@@ -1,6 +1,4 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
@@ -57,9 +55,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
/// <inheritdoc />
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
{
var tmdbId = item.GetProviderId(MetadataProvider.Tmdb);
if (string.IsNullOrEmpty(tmdbId))
if (!item.TryGetTmdbId(out var tmdbId))
{
return Enumerable.Empty<RemoteImageInfo>();
}
@@ -68,7 +64,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
// TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here
var series = await _tmdbClientManager
.GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), null, null, null, cancellationToken)
.GetSeriesAsync(tmdbId, null, null, null, cancellationToken)
.ConfigureAwait(false);
if (series?.Images is null)
@@ -54,10 +54,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
/// <inheritdoc />
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken)
{
if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbId))
if (searchInfo.TryGetTmdbId(out var tmdbId))
{
var series = await _tmdbClientManager
.GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), searchInfo.MetadataLanguage, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken)
.GetSeriesAsync(tmdbId, searchInfo.MetadataLanguage, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken)
.ConfigureAwait(false);
if (series is not null)
@@ -2,6 +2,7 @@ using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text.RegularExpressions;
using Jellyfin.Data.Enums;
using MediaBrowser.Model.Entities;
@@ -62,6 +63,33 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
[GeneratedRegex(@"[\W_-[·]]+")]
private static partial Regex NonWordRegex();
/// <summary>
/// Gets the TMDb id of an item, if it has one TMDb can be queried with.
/// </summary>
/// <param name="instance">The item.</param>
/// <param name="tmdbId">The TMDb id.</param>
/// <returns><c>true</c> if the item has a usable TMDb id; otherwise, <c>false</c>.</returns>
public static bool TryGetTmdbId(this IHasProviderIds instance, out int tmdbId)
{
instance.TryGetProviderId(MetadataProvider.Tmdb, out var value);
return TryParseTmdbId(value, out tmdbId);
}
/// <summary>
/// Parses a TMDb id.
/// </summary>
/// <param name="value">The stored id.</param>
/// <param name="tmdbId">The TMDb id.</param>
/// <returns><c>true</c> if the value is a usable TMDb id; otherwise, <c>false</c>.</returns>
public static bool TryParseTmdbId(string? value, out int tmdbId)
{
// Another provider can have filed one of its own ids under the TMDb key, e.g. an IMDb person
// id. Reporting that as "no id" lets the caller fall back to a search and repair the id,
// instead of throwing on every refresh of the item.
return int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out tmdbId) && tmdbId > 0;
}
/// <summary>
/// Cleans the name according to TMDb requirements.
/// </summary>
@@ -186,6 +186,49 @@ namespace Jellyfin.Model.Tests.Entities
Assert.Null(nullProvider.ProviderIds);
}
[Theory]
[InlineData(nameof(MetadataProvider.Imdb), "tt0113375", true)]
[InlineData(nameof(MetadataProvider.Imdb), "nm0000123", true)]
[InlineData(nameof(MetadataProvider.Imdb), "0113375", true)]
[InlineData(nameof(MetadataProvider.Imdb), "https://www.imdb.com/title/tt0113375", false)]
[InlineData(nameof(MetadataProvider.Tmdb), "11", true)]
[InlineData(nameof(MetadataProvider.Tmdb), "nm0000123", false)]
[InlineData(nameof(MetadataProvider.Tmdb), "0", false)]
[InlineData(nameof(MetadataProvider.Tmdb), "-11", false)]
[InlineData(nameof(MetadataProvider.TmdbCollection), "nm0000123", false)]
[InlineData(nameof(MetadataProvider.AudioDbArtist), "111239", true)]
[InlineData(nameof(MetadataProvider.AudioDbArtist), "a3cb23fc-acd3-4ce0-8f36-1e5aa6a18432", false)]
[InlineData(nameof(MetadataProvider.MusicBrainzArtist), "a3cb23fc-acd3-4ce0-8f36-1e5aa6a18432", true)]
[InlineData(nameof(MetadataProvider.MusicBrainzArtist), "111239", false)]
[InlineData(nameof(MetadataProvider.MusicBrainzAlbum), "not-an-mbid", false)]
[InlineData(nameof(MetadataProvider.Tvdb), "anything-goes", true)]
[InlineData("SomePlugin", "anything-goes", true)]
[InlineData(nameof(MetadataProvider.Tmdb), null, false)]
[InlineData(null, "11", false)]
public void IsValidProviderId_ChecksKnownFormats(string? name, string? value, bool expected)
{
Assert.Equal(expected, ProviderIdsExtensions.IsValidProviderId(name, value));
}
[Fact]
public void TrySetProviderId_ForeignId_False()
{
var provider = new ProviderIdsExtensionsTestsObject();
Assert.False(provider.TrySetProviderId(MetadataProvider.Tmdb, "nm0000123"));
Assert.Empty(provider.ProviderIds);
}
[Fact]
public void TrySetProviderId_ForeignId_KeepsExisting()
{
var provider = new ProviderIdsExtensionsTestsObject();
provider.ProviderIds[MetadataProvider.Tmdb.ToString()] = "11";
Assert.False(provider.TrySetProviderId(MetadataProvider.Tmdb, "nm0000123"));
Assert.Equal("11", provider.GetProviderId(MetadataProvider.Tmdb));
}
[Fact]
public void RemoveProviderId_Null_Remove()
{
@@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Providers.Tests.Manager
{
public class MetadataServiceRefreshTests
{
[Theory]
[InlineData(false, "existing overview")]
[InlineData(true, null)]
public async Task RefreshWithProviders_ReplaceAllMetadata_KeepsExistingDataOnProviderFailure(bool allProvidersSucceed, string? expectedOverview)
{
var item = new Movie
{
Name = "Test Movie",
Overview = "existing overview"
};
// The provider owning the overview fails, so it contributes nothing to the replacement.
var failing = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
failing.Setup(p => p.Name).Returns("Failing");
failing.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
.Returns(allProvidersSucceed
? Task.FromResult(new MetadataResult<Movie> { HasMetadata = true, Item = new Movie() })
: Task.FromException<MetadataResult<Movie>>(new FormatException("bad id")));
var succeeding = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
succeeding.Setup(p => p.Name).Returns("Succeeding");
succeeding.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new MetadataResult<Movie>
{
HasMetadata = true,
Item = new Movie { Name = "Test Movie", Tagline = "new tagline" }
});
var service = new TestMetadataService();
var result = await service.RefreshWithProvidersInternal(
new MetadataResult<Movie> { Item = item },
new MovieInfo { Name = item.Name },
new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
{
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
ReplaceAllMetadata = true,
RemoveOldMetadata = true
},
[failing.Object, succeeding.Object]).ConfigureAwait(true);
Assert.Equal(allProvidersSucceed ? 0 : 1, result.Failures);
Assert.Equal("new tagline", item.Tagline);
Assert.Equal(expectedOverview, item.Overview);
}
[Fact]
public async Task RefreshWithProviders_ForeignProviderId_NotStored()
{
var item = new Movie { Name = "Test Movie" };
var provider = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose);
provider.Setup(p => p.Name).Returns("Provider");
provider.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
var found = new Movie { Name = "Test Movie" };
found.ProviderIds[MetadataProvider.Tmdb.ToString()] = "nm0000123";
found.ProviderIds[MetadataProvider.Imdb.ToString()] = "tt0113375";
return new MetadataResult<Movie> { HasMetadata = true, Item = found };
});
var service = new TestMetadataService();
await service.RefreshWithProvidersInternal(
new MetadataResult<Movie> { Item = item },
new MovieInfo { Name = item.Name },
new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
{
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
ReplaceAllMetadata = true
},
[provider.Object]).ConfigureAwait(true);
Assert.False(item.HasProviderId(MetadataProvider.Tmdb));
Assert.Equal("tt0113375", item.GetProviderId(MetadataProvider.Imdb));
}
private sealed class TestMetadataService : MetadataService<Movie, MovieInfo>
{
public TestMetadataService()
: base(
Mock.Of<IServerConfigurationManager>(),
NullLogger<MetadataService<Movie, MovieInfo>>.Instance,
Mock.Of<IProviderManager>(),
Mock.Of<IFileSystem>(),
Mock.Of<ILibraryManager>(),
Mock.Of<IExternalDataManager>(),
Mock.Of<IItemRepository>())
{
}
public Task<RefreshResult> RefreshWithProvidersInternal(
MetadataResult<Movie> metadata,
MovieInfo id,
MetadataRefreshOptions options,
ICollection<IMetadataProvider> providers)
=> RefreshWithProviders(metadata, id, options, providers, ImageProvider, false, CancellationToken.None);
}
}
}
@@ -0,0 +1,59 @@
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Providers.Music;
using Xunit;
namespace Jellyfin.Providers.Tests.Music;
public static class AlbumInfoExtensionsTests
{
private const string ExampleMbid = "59b5a40b-e2fd-3f18-a218-e8c9aae12ab5";
private const string SongMbid = "6c301dbd-6ccb-3403-a6c4-6a22240a0297";
[Theory]
[InlineData(ExampleMbid, ExampleMbid)]
// Another provider's id under a MusicBrainz key reads as no id, so the caller searches instead of
// handing a value the MusicBrainz client throws on.
[InlineData("111239", null)]
[InlineData("", null)]
public static void GetReleaseId_OnlyReturnsMbids(string id, string? expected)
{
var info = new AlbumInfo();
info.ProviderIds[MetadataProvider.MusicBrainzAlbum.ToString()] = id;
Assert.Equal(expected, info.GetReleaseId());
}
[Fact]
public static void GetReleaseId_ForeignId_FallsBackToSongs()
{
var song = new SongInfo();
song.ProviderIds[MetadataProvider.MusicBrainzAlbum.ToString()] = SongMbid;
var info = new AlbumInfo { SongInfos = [song] };
info.ProviderIds[MetadataProvider.MusicBrainzAlbum.ToString()] = "111239";
Assert.Equal(SongMbid, info.GetReleaseId());
}
[Fact]
public static void GetMusicBrainzArtistId_ForeignId_FallsBackToArtistIds()
{
var info = new AlbumInfo();
info.ProviderIds[MetadataProvider.MusicBrainzAlbumArtist.ToString()] = "111239";
info.ArtistProviderIds[MetadataProvider.MusicBrainzArtist.ToString()] = ExampleMbid;
Assert.Equal(ExampleMbid, info.GetMusicBrainzArtistId());
}
[Theory]
[InlineData(ExampleMbid, ExampleMbid)]
[InlineData("111239", null)]
public static void GetMusicBrainzArtistId_ArtistInfo_OnlyReturnsMbids(string id, string? expected)
{
var info = new ArtistInfo();
info.ProviderIds[MetadataProvider.MusicBrainzArtist.ToString()] = id;
Assert.Equal(expected, info.GetMusicBrainzArtistId());
}
}
@@ -1,3 +1,5 @@
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Model.Entities;
using MediaBrowser.Providers.Plugins.Tmdb;
using Xunit;
@@ -34,5 +36,40 @@ namespace Jellyfin.Providers.Tests.Tmdb
{
Assert.Equal(expected, TmdbUtils.AdjustImageLanguage(imageLanguage, requestLanguage));
}
[Theory]
[InlineData("11", true, 11)]
// An id another provider filed under the TMDb key must not throw, it is simply not a TMDb id.
[InlineData("nm0000123", false, 0)]
[InlineData("tt0113375", false, 0)]
[InlineData("11.0", false, 0)]
[InlineData("-11", false, 0)]
[InlineData("0", false, 0)]
[InlineData("", false, 0)]
[InlineData(null, false, 0)]
public static void TryParseTmdbId_OnlyAcceptsTmdbIds(string? value, bool expected, int expectedId)
{
Assert.Equal(expected, TmdbUtils.TryParseTmdbId(value, out var tmdbId));
Assert.Equal(expectedId, tmdbId);
}
[Theory]
[InlineData("11", true, 11)]
[InlineData("nm0000123", false, 0)]
public static void TryGetTmdbId_OnlyAcceptsTmdbIds(string value, bool expected, int expectedId)
{
var item = new Movie();
item.ProviderIds[MetadataProvider.Tmdb.ToString()] = value;
Assert.Equal(expected, item.TryGetTmdbId(out var tmdbId));
Assert.Equal(expectedId, tmdbId);
}
[Fact]
public static void TryGetTmdbId_NoId_False()
{
Assert.False(new Movie().TryGetTmdbId(out var tmdbId));
Assert.Equal(0, tmdbId);
}
}
}