Initial jellyfin-multilang rewrite

This commit is contained in:
ajp_anton
2026-05-31 18:28:04 +00:00
commit 63dbc3a6d4
32 changed files with 9326 additions and 0 deletions
@@ -0,0 +1,51 @@
using System.Text;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Multilang.Services.Injection;
internal static class WebScriptInjector
{
private const string StartComment = "<!-- BEGIN Multilang Plugin -->";
private const string EndComment = "<!-- END Multilang Plugin -->";
public static bool EnsureInjected(string webRootPath, string scriptTag, ILogger logger)
{
if (string.IsNullOrWhiteSpace(webRootPath))
return false;
var indexPath = Path.Combine(webRootPath, "index.html");
if (!File.Exists(indexPath))
{
logger.LogWarning("Multilang inject: index.html not found at {Path}", indexPath);
return false;
}
var content = File.ReadAllText(indexPath);
var block = $"{StartComment}\n{scriptTag}\n{EndComment}\n";
if (content.Contains(block, StringComparison.Ordinal))
return false;
content = RemoveOldBlock(content);
var insertAt = content.IndexOf("</body>", StringComparison.OrdinalIgnoreCase);
if (insertAt < 0)
{
logger.LogWarning("Multilang inject: </body> tag not found in index.html");
return false;
}
File.WriteAllText(indexPath, content.Insert(insertAt, block + Environment.NewLine), Encoding.UTF8);
logger.LogInformation("Multilang inject: updated index.html");
return true;
}
private static string RemoveOldBlock(string content)
{
var start = content.IndexOf(StartComment, StringComparison.Ordinal);
if (start < 0)
return content;
var end = content.IndexOf(EndComment, start, StringComparison.Ordinal);
if (end < 0)
return content;
return content.Remove(start, end + EndComment.Length - start);
}
}
@@ -0,0 +1,309 @@
namespace Jellyfin.Plugin.Multilang.Services;
public sealed class ItemsProxyCache
{
private sealed class Entry
{
public required string Key { get; init; }
public required string UserId { get; init; }
public required string Url { get; init; }
public required HashSet<string> ItemIds { get; init; }
public required string Body { get; init; }
public required string ContentType { get; init; }
public required DateTimeOffset CreatedUtc { get; init; }
public required long DurationMs { get; init; }
public required long SizeBytes { get; init; }
}
private sealed class RecentRequest
{
public required string Url { get; init; }
public required DateTimeOffset CreatedUtc { get; init; }
public required bool CacheHit { get; init; }
public required bool CacheStored { get; init; }
public required int StatusCode { get; init; }
public required int ItemCount { get; init; }
public required long TotalMs { get; init; }
public required long UpstreamMs { get; init; }
public required long TransformMs { get; init; }
public required long SizeBytes { get; init; }
}
private readonly object _lock = new();
private readonly Dictionary<string, Entry> _entries = new(StringComparer.Ordinal);
private readonly Dictionary<string, Entry> _microEntries = new(StringComparer.Ordinal);
private readonly Queue<RecentRequest> _recentRequests = new();
private long _totalBytes;
private const int MaxRecentRequests = 100;
private const int MaxMicroEntries = 50;
private static readonly TimeSpan MicroTtl = TimeSpan.FromSeconds(3);
public void ClearAll()
{
lock (_lock)
{
_entries.Clear();
_microEntries.Clear();
_totalBytes = 0;
}
}
public void InvalidateItems(IEnumerable<string> itemIds)
{
var ids = itemIds
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim().ToLowerInvariant())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (ids.Count == 0)
return;
lock (_lock)
{
foreach (var key in _entries.Values
.Where(e => e.ItemIds.Overlaps(ids))
.Select(e => e.Key)
.ToArray())
{
Remove(key);
}
foreach (var key in _microEntries.Values
.Where(e => e.ItemIds.Overlaps(ids))
.Select(e => e.Key)
.ToArray())
{
_microEntries.Remove(key);
}
}
}
public bool TryGet(string key, TimeSpan ttl, out CachedItemsProxyResponse response)
{
lock (_lock)
{
PurgeExpired(ttl);
if (!_entries.TryGetValue(key, out var entry))
{
PurgeExpired(_microEntries, MicroTtl);
if (!_microEntries.TryGetValue(key, out var microEntry))
{
response = default;
return false;
}
response = new CachedItemsProxyResponse(microEntry.Body, microEntry.ContentType, microEntry.ItemIds.Count, microEntry.SizeBytes);
return true;
}
response = new CachedItemsProxyResponse(entry.Body, entry.ContentType, entry.ItemIds.Count, entry.SizeBytes);
return true;
}
}
public bool Store(string key, string userId, string url, IEnumerable<string> itemIds, string body, string contentType, long durationMs, long maxBytes, TimeSpan ttl)
{
if (maxBytes <= 0 || ttl <= TimeSpan.Zero)
return false;
var sizeBytes = System.Text.Encoding.UTF8.GetByteCount(body);
if (sizeBytes > maxBytes)
return false;
lock (_lock)
{
PurgeExpired(ttl);
Remove(key);
while (_totalBytes + sizeBytes > maxBytes && _entries.Count > 0)
{
var victim = _entries.Values.OrderBy(e => e.CreatedUtc).First();
Remove(victim.Key);
}
if (_totalBytes + sizeBytes > maxBytes)
return false;
_entries[key] = new Entry
{
Key = key,
UserId = userId,
Url = url,
ItemIds = itemIds
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim().ToLowerInvariant())
.ToHashSet(StringComparer.OrdinalIgnoreCase),
Body = body,
ContentType = contentType,
CreatedUtc = DateTimeOffset.UtcNow,
DurationMs = durationMs,
SizeBytes = sizeBytes
};
_totalBytes += sizeBytes;
return true;
}
}
public bool StoreMicro(string key, string userId, string url, IEnumerable<string> itemIds, string body, string contentType, long durationMs, long maxBytes)
{
if (maxBytes <= 0)
return false;
var sizeBytes = System.Text.Encoding.UTF8.GetByteCount(body);
if (sizeBytes > maxBytes)
return false;
lock (_lock)
{
PurgeExpired(_microEntries, MicroTtl);
_microEntries.Remove(key);
while (_microEntries.Count >= MaxMicroEntries)
{
var victim = _microEntries.Values.OrderBy(e => e.CreatedUtc).First();
_microEntries.Remove(victim.Key);
}
_microEntries[key] = new Entry
{
Key = key,
UserId = userId,
Url = url,
ItemIds = itemIds
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim().ToLowerInvariant())
.ToHashSet(StringComparer.OrdinalIgnoreCase),
Body = body,
ContentType = contentType,
CreatedUtc = DateTimeOffset.UtcNow,
DurationMs = durationMs,
SizeBytes = sizeBytes
};
return true;
}
}
public object[] GetEntries()
{
lock (_lock)
{
var now = DateTimeOffset.UtcNow;
return _entries.Values
.OrderByDescending(e => e.CreatedUtc)
.Select(e => new
{
e.UserId,
e.Url,
AgeSeconds = (long)(now - e.CreatedUtc).TotalSeconds,
e.DurationMs,
e.SizeBytes,
ItemCount = e.ItemIds.Count
})
.Cast<object>()
.ToArray();
}
}
public void RecordRequest(
string url,
bool cacheHit,
bool cacheStored,
int statusCode,
int itemCount,
long totalMs,
long upstreamMs,
long transformMs,
long sizeBytes)
{
lock (_lock)
{
_recentRequests.Enqueue(new RecentRequest
{
Url = url,
CreatedUtc = DateTimeOffset.UtcNow,
CacheHit = cacheHit,
CacheStored = cacheStored,
StatusCode = statusCode,
ItemCount = itemCount,
TotalMs = totalMs,
UpstreamMs = upstreamMs,
TransformMs = transformMs,
SizeBytes = sizeBytes
});
while (_recentRequests.Count > MaxRecentRequests)
_recentRequests.Dequeue();
}
}
public object[] GetRecentRequests()
{
lock (_lock)
{
var now = DateTimeOffset.UtcNow;
return _recentRequests
.Reverse()
.Select(e => new
{
e.Url,
AgeSeconds = (long)(now - e.CreatedUtc).TotalSeconds,
e.CacheHit,
e.CacheStored,
e.StatusCode,
e.ItemCount,
e.TotalMs,
e.UpstreamMs,
e.TransformMs,
e.SizeBytes
})
.Cast<object>()
.ToArray();
}
}
private void PurgeExpired(TimeSpan ttl)
{
if (ttl <= TimeSpan.Zero || _entries.Count == 0)
return;
var now = DateTimeOffset.UtcNow;
foreach (var key in _entries.Values.Where(e => now - e.CreatedUtc > ttl).Select(e => e.Key).ToArray())
Remove(key);
}
private static void PurgeExpired(Dictionary<string, Entry> entries, TimeSpan ttl)
{
if (ttl <= TimeSpan.Zero || entries.Count == 0)
return;
var now = DateTimeOffset.UtcNow;
foreach (var key in entries.Values.Where(e => now - e.CreatedUtc > ttl).Select(e => e.Key).ToArray())
entries.Remove(key);
}
private void Remove(string key)
{
if (!_entries.Remove(key, out var existing))
return;
_totalBytes = Math.Max(0, _totalBytes - existing.SizeBytes);
}
}
public readonly record struct CachedItemsProxyResponse(string Body, string ContentType, int ItemCount, long SizeBytes);
@@ -0,0 +1,246 @@
using System.Globalization;
using System.Text;
using System.Text.Json.Nodes;
namespace Jellyfin.Plugin.Multilang.Services;
public sealed record ItemsProxyControls(
string SortBy,
string SortOrder,
string NameStartsWith,
int StartIndex,
int Limit,
string ClientLocale,
string GenreIds);
public static class ItemsProxySorting
{
private readonly record struct SortSpec(string Field, SortKind Kind);
private enum SortKind
{
Text,
Number,
Date,
SortName,
SeriesSortName,
Random
}
public static void Apply(JsonNode root, ItemsProxyControls controls, CultureInfo culture, string[] titleArticles)
{
if (!TryGetItemsArray(root, out var itemsArray))
return;
var items = itemsArray.OfType<JsonObject>().ToList();
var specs = BuildSortSpecs(controls.SortBy);
if (specs.Length > 0 && items.Count > 1)
items = Sort(items, specs, controls.SortOrder, culture, titleArticles);
items = FilterByNameStartsWith(items, controls.NameStartsWith, culture, titleArticles);
var filteredCount = items.Count;
items = Slice(items, controls.StartIndex, controls.Limit);
itemsArray.Clear();
foreach (var item in items)
itemsArray.Add(item);
if (root is JsonObject obj && obj.ContainsKey("TotalRecordCount"))
{
obj["TotalRecordCount"] = filteredCount;
obj["StartIndex"] = Math.Clamp(controls.StartIndex, 0, filteredCount);
}
}
private static bool TryGetItemsArray(JsonNode root, out JsonArray items)
{
if (root is JsonObject obj && obj["Items"] is JsonArray array)
{
items = array;
return true;
}
if (root is JsonArray direct)
{
items = direct;
return true;
}
items = [];
return false;
}
private static SortSpec[] BuildSortSpecs(string sortBy)
{
var fields = (sortBy ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(f => f.Length > 0)
.ToList();
if (fields.Count == 0)
return [];
if (!fields.Any(f => f.Equals("SortName", StringComparison.OrdinalIgnoreCase)))
fields.Add("SortName");
if (!fields.Any(f => f.Equals("ProductionYear", StringComparison.OrdinalIgnoreCase)))
fields.Add("ProductionYear");
return fields.Select(f => new SortSpec(f, KindFor(f))).ToArray();
}
private static SortKind KindFor(string field)
=> field.ToLowerInvariant() switch
{
"sortname" => SortKind.SortName,
"seriessortname" => SortKind.SeriesSortName,
"random" => SortKind.Random,
"isfolder" or "productionyear" or "playcount" or "communityrating" or "criticrating" or "runtimeticks" or "runtime" or "indexnumber" or "parentindexnumber" => SortKind.Number,
"premieredate" or "datecreated" or "dateplayed" => SortKind.Date,
_ => SortKind.Text
};
private static List<JsonObject> Sort(List<JsonObject> items, SortSpec[] specs, string sortOrder, CultureInfo culture, string[] titleArticles)
{
var descending = sortOrder.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault()?.Equals("Descending", StringComparison.OrdinalIgnoreCase) == true;
var comparer = StringComparer.Create(culture, true);
var values = items.Select(item => new { Item = item, Values = specs.Select(spec => ValueFor(item, spec, culture, titleArticles)).ToArray() }).ToList();
values.Sort((left, right) =>
{
for (var i = 0; i < specs.Length; i++)
{
var a = left.Values[i];
var b = right.Values[i];
if (!a.HasValue && !b.HasValue)
continue;
if (!a.HasValue)
return descending ? 1 : -1;
if (!b.HasValue)
return descending ? -1 : 1;
var cmp = specs[i].Kind is SortKind.Text or SortKind.SortName or SortKind.SeriesSortName
? comparer.Compare(a.Text, b.Text)
: a.Number.CompareTo(b.Number);
if (cmp != 0)
return descending ? -cmp : cmp;
}
return 0;
});
return values.Select(v => v.Item).ToList();
}
private static SortValue ValueFor(JsonObject item, SortSpec spec, CultureInfo culture, string[] titleArticles)
{
return spec.Kind switch
{
SortKind.Random => new SortValue(true, string.Empty, Random.Shared.NextDouble()),
SortKind.SortName => new SortValue(true, NormalizeSortText(GetString(item, "Name"), titleArticles, culture), 0),
SortKind.SeriesSortName => new SortValue(true, NormalizeSortText(GetString(item, "SeriesName"), titleArticles, culture), 0),
SortKind.Number => TryGetNumber(item, spec.Field, out var number) ? new SortValue(true, string.Empty, number) : default,
SortKind.Date => TryGetDate(item, spec.Field, out var ticks) ? new SortValue(true, string.Empty, ticks) : default,
_ => !string.IsNullOrWhiteSpace(GetString(item, spec.Field)) ? new SortValue(true, GetString(item, spec.Field), 0) : default
};
}
private readonly record struct SortValue(bool HasValue, string Text, double Number);
private static List<JsonObject> FilterByNameStartsWith(List<JsonObject> items, string prefix, CultureInfo culture, string[] titleArticles)
{
prefix = (prefix ?? string.Empty).Trim();
if (prefix.Length == 0)
return items;
return items
.Where(i => culture.CompareInfo.IsPrefix(StripArticle(GetString(i, "Name"), titleArticles), prefix, CompareOptions.IgnoreCase))
.ToList();
}
private static List<JsonObject> Slice(List<JsonObject> items, int startIndex, int limit)
{
startIndex = Math.Clamp(startIndex, 0, items.Count);
if (limit > 0)
return items.Skip(startIndex).Take(limit).ToList();
return startIndex > 0 ? items.Skip(startIndex).ToList() : items;
}
private static string NormalizeSortText(string value, string[] titleArticles, CultureInfo culture)
=> PadNumberSequences(StripArticle(value, titleArticles)).ToLower(culture);
private static string StripArticle(string value, string[] titleArticles)
{
var text = (value ?? string.Empty).Trim();
foreach (var article in titleArticles)
{
var word = article.Trim();
if (word.Length == 0 || text.Length <= word.Length)
continue;
if (text.StartsWith(word + " ", StringComparison.OrdinalIgnoreCase))
return text[(word.Length + 1)..].TrimStart();
}
return text;
}
private static string PadNumberSequences(string input)
{
if (!input.Any(char.IsDigit))
return input;
var sb = new StringBuilder(input.Length + 8);
for (var i = 0; i < input.Length;)
{
if (!char.IsDigit(input[i]))
{
sb.Append(input[i++]);
continue;
}
var start = i;
while (i < input.Length && char.IsDigit(input[i]))
i++;
var len = i - start;
if (len < 10)
sb.Append('0', 10 - len);
sb.Append(input, start, len);
}
return sb.ToString();
}
private static string GetString(JsonObject item, string property)
=> item.TryGetPropertyValue(property, out var node) && node is not null ? node.GetValueKind() == System.Text.Json.JsonValueKind.String ? node.GetValue<string>() : node.ToString() : string.Empty;
private static bool TryGetNumber(JsonObject item, string property, out double value)
{
value = 0;
if (!item.TryGetPropertyValue(property, out var node) || node is null)
return false;
if (node is JsonValue jsonValue)
{
if (jsonValue.TryGetValue<double>(out value))
return true;
if (jsonValue.TryGetValue<bool>(out var b))
{
value = b ? 1 : 0;
return true;
}
if (jsonValue.TryGetValue<string>(out var s) && double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out value))
return true;
}
return double.TryParse(node.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out value);
}
private static bool TryGetDate(JsonObject item, string property, out double value)
{
value = 0;
var raw = GetString(item, property);
if (!DateTimeOffset.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var date))
return false;
value = date.UtcTicks;
return true;
}
}
@@ -0,0 +1,25 @@
namespace Jellyfin.Plugin.Multilang.Services;
public sealed record ProviderInfo(
string Id,
string Name,
bool SupportsMetadata,
bool SupportsArtwork,
string ApiKeyField,
string ApiKeyPlaceholder);
public sealed class ProviderCatalog
{
private static readonly ProviderInfo[] Items =
[
new("tmdb", "TMDb", SupportsMetadata: true, SupportsArtwork: true, "TmdbApiKey", "Mandatory API key"),
new("fanart", "Fanart.tv", SupportsMetadata: false, SupportsArtwork: true, "FanartApiKey", "Optional API key")
];
private readonly Dictionary<string, ProviderInfo> _byId =
Items.ToDictionary(p => p.Id, StringComparer.OrdinalIgnoreCase);
public IReadOnlyList<ProviderInfo> Providers => Items;
public bool IsKnown(string id) => _byId.ContainsKey(id);
}
@@ -0,0 +1,100 @@
namespace Jellyfin.Plugin.Multilang.Services.Providers;
public sealed class FanartClient
{
private readonly IHttpClientFactory _httpClientFactory;
public FanartClient(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task<bool> TestApiKeyAsync(string apiKey, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(apiKey))
return false;
var http = _httpClientFactory.CreateClient();
var url = $"https://webservice.fanart.tv/v3/movies/550?api_key={Uri.EscapeDataString(apiKey)}";
using var response = await http.GetAsync(url, cancellationToken).ConfigureAwait(false);
return response.IsSuccessStatusCode;
}
public async Task<FanartImages?> FetchMovieImagesAsync(
string tmdbId,
string apiKey,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tmdbId) || string.IsNullOrWhiteSpace(apiKey))
return null;
var http = _httpClientFactory.CreateClient();
var url = $"https://webservice.fanart.tv/v3/movies/{Uri.EscapeDataString(tmdbId)}?api_key={Uri.EscapeDataString(apiKey)}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
using var response = await ProviderHttp.SendWithRetryAsync(http, request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
return null;
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var doc = await System.Text.Json.JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var posters = Simplify(Extract(doc.RootElement, "movieposter"));
var logos = Simplify(Extract(doc.RootElement, "hdmovielogo"));
foreach (var kv in Simplify(Extract(doc.RootElement, "movielogo")))
{
if (!logos.ContainsKey(kv.Key))
logos[kv.Key] = kv.Value;
}
return new FanartImages(
Posters: posters,
Logos: logos,
Banners: Simplify(Extract(doc.RootElement, "moviebanner")),
Thumbs: Simplify(Extract(doc.RootElement, "moviethumb")));
}
private static Dictionary<string, (string Url, int Likes)> Extract(System.Text.Json.JsonElement root, string property)
{
var result = new Dictionary<string, (string Url, int Likes)>(StringComparer.OrdinalIgnoreCase);
if (!root.TryGetProperty(property, out var value) || value.ValueKind != System.Text.Json.JsonValueKind.Array)
return result;
foreach (var image in value.EnumerateArray())
{
if (image.ValueKind != System.Text.Json.JsonValueKind.Object ||
!image.TryGetProperty("lang", out var langElement) ||
langElement.ValueKind != System.Text.Json.JsonValueKind.String ||
!image.TryGetProperty("url", out var urlElement) ||
urlElement.ValueKind != System.Text.Json.JsonValueKind.String)
continue;
var lang = (langElement.GetString() ?? string.Empty).Trim();
var imageUrl = (urlElement.GetString() ?? string.Empty).Trim();
if (lang.Length == 0 || lang.Equals("00", StringComparison.OrdinalIgnoreCase) || imageUrl.Length == 0)
continue;
var likes = 0;
if (image.TryGetProperty("likes", out var likesElement))
{
if (likesElement.ValueKind == System.Text.Json.JsonValueKind.Number)
likesElement.TryGetInt32(out likes);
else if (likesElement.ValueKind == System.Text.Json.JsonValueKind.String)
int.TryParse(likesElement.GetString(), out likes);
}
if (!result.TryGetValue(lang, out var current) || likes > current.Likes)
result[lang] = (imageUrl, likes);
}
return result;
}
private static Dictionary<string, string> Simplify(Dictionary<string, (string Url, int Likes)> images)
=> images.ToDictionary(k => k.Key, v => v.Value.Url, StringComparer.OrdinalIgnoreCase);
}
public sealed record FanartImages(
Dictionary<string, string> Posters,
Dictionary<string, string> Logos,
Dictionary<string, string> Banners,
Dictionary<string, string> Thumbs);
@@ -0,0 +1,28 @@
using System.Diagnostics;
namespace Jellyfin.Plugin.Multilang.Services.Providers;
public sealed class FetchRateLimiter
{
private readonly TimeSpan _minimumSpacing;
private long _lastStartTicks;
public FetchRateLimiter(TimeSpan minimumSpacing)
{
_minimumSpacing = minimumSpacing;
}
public async Task WaitAsync(CancellationToken cancellationToken)
{
var last = Interlocked.Read(ref _lastStartTicks);
if (last != 0)
{
var elapsed = TimeSpan.FromSeconds((Stopwatch.GetTimestamp() - last) / (double)Stopwatch.Frequency);
var remaining = _minimumSpacing - elapsed;
if (remaining > TimeSpan.Zero)
await Task.Delay(remaining, cancellationToken).ConfigureAwait(false);
}
Interlocked.Exchange(ref _lastStartTicks, Stopwatch.GetTimestamp());
}
}
@@ -0,0 +1,141 @@
using System.Net;
namespace Jellyfin.Plugin.Multilang.Services.Providers;
public enum ProviderFailureKind
{
Retryable,
Terminal
}
public sealed record ProviderHttpDiagnostics(
ProviderHttpCount[] Counts,
ProviderHttpRecent[] Recent);
public sealed record ProviderHttpCount(
string Provider,
string Outcome,
int Count);
public sealed record ProviderHttpRecent(
string Provider,
string Url,
string Outcome,
int Attempt,
long At);
public static class ProviderHttp
{
private static readonly object DiagnosticsLock = new();
private static readonly Dictionary<string, int> Counts = new(StringComparer.OrdinalIgnoreCase);
private static readonly Queue<ProviderHttpRecent> Recent = new();
public static ProviderFailureKind Classify(HttpStatusCode code)
=> code is HttpStatusCode.TooManyRequests or HttpStatusCode.RequestTimeout ||
(int)code >= 500
? ProviderFailureKind.Retryable
: ProviderFailureKind.Terminal;
public static async Task<HttpResponseMessage> SendWithRetryAsync(
HttpClient http,
HttpRequestMessage request,
CancellationToken cancellationToken)
{
const int maxAttempts = 3;
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
var response = await http.SendAsync(Clone(request), cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode || Classify(response.StatusCode) == ProviderFailureKind.Terminal || attempt == maxAttempts)
{
Record(request, "HTTP " + (int)response.StatusCode, attempt);
return response;
}
var delay = GetRetryDelay(response, attempt);
response.Dispose();
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
if (attempt >= maxAttempts)
{
Record(request, ex.GetType().Name, attempt);
throw;
}
await Task.Delay(TimeSpan.FromMilliseconds(250 * attempt), cancellationToken).ConfigureAwait(false);
}
}
throw new HttpRequestException("Provider request failed after retries.");
}
public static ProviderHttpDiagnostics GetDiagnostics()
{
lock (DiagnosticsLock)
{
return new ProviderHttpDiagnostics(
Counts
.Select(kvp =>
{
var index = kvp.Key.IndexOf('|', StringComparison.Ordinal);
var provider = index >= 0 ? kvp.Key[..index] : kvp.Key;
var outcome = index >= 0 ? kvp.Key[(index + 1)..] : string.Empty;
return new ProviderHttpCount(provider, outcome, kvp.Value);
})
.OrderBy(c => c.Provider)
.ThenBy(c => c.Outcome)
.ToArray(),
Recent.Reverse().ToArray());
}
}
private static TimeSpan GetRetryDelay(HttpResponseMessage response, int attempt)
=> response.Headers.RetryAfter?.Delta ??
(response.Headers.RetryAfter?.Date is { } date
? date - DateTimeOffset.UtcNow
: TimeSpan.FromMilliseconds(250 * attempt));
private static void Record(HttpRequestMessage request, string outcome, int attempt)
{
var provider = ProviderName(request.RequestUri);
var key = provider + "|" + outcome;
var recent = new ProviderHttpRecent(
provider,
SafeUrl(request.RequestUri),
outcome,
attempt,
DateTimeOffset.UtcNow.ToUnixTimeSeconds());
lock (DiagnosticsLock)
{
Counts[key] = Counts.TryGetValue(key, out var count) ? count + 1 : 1;
Recent.Enqueue(recent);
while (Recent.Count > 50)
Recent.Dequeue();
}
}
private static string ProviderName(Uri? uri)
{
var host = uri?.Host ?? string.Empty;
if (host.Contains("themoviedb", StringComparison.OrdinalIgnoreCase) || host.Contains("tmdb", StringComparison.OrdinalIgnoreCase))
return "TMDb";
if (host.Contains("fanart", StringComparison.OrdinalIgnoreCase))
return "Fanart.tv";
return string.IsNullOrWhiteSpace(host) ? "unknown" : host;
}
private static string SafeUrl(Uri? uri)
=> uri is null ? string.Empty : uri.GetLeftPart(UriPartial.Path);
private static HttpRequestMessage Clone(HttpRequestMessage request)
{
var clone = new HttpRequestMessage(request.Method, request.RequestUri);
foreach (var header in request.Headers)
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
return clone;
}
}
@@ -0,0 +1,201 @@
using System.Net;
using System.Text.Json;
using Jellyfin.Plugin.Multilang.Services.Refresh;
namespace Jellyfin.Plugin.Multilang.Services.Providers;
public sealed class TmdbClient
{
private readonly IHttpClientFactory _httpClientFactory;
public TmdbClient(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task<TmdbMetadata?> FetchMetadataAsync(
RefreshItemInfo item,
string language,
string apiKey,
FetchRateLimiter rateLimiter,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(apiKey))
throw new InvalidOperationException("TMDb API key is required.");
await rateLimiter.WaitAsync(cancellationToken).ConfigureAwait(false);
var http = _httpClientFactory.CreateClient();
var url = $"https://api.themoviedb.org/3/{BuildPath(item)}?api_key={Uri.EscapeDataString(apiKey)}&language={Uri.EscapeDataString(language)}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
using var response = await ProviderHttp.SendWithRetryAsync(http, request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
return new TmdbMetadata(
Title: ExtractTitle(doc.RootElement, item.Kind),
Overview: ExtractString(doc.RootElement, "overview"),
Tagline: ExtractString(doc.RootElement, "tagline"),
OriginalTitle: ExtractOriginalTitle(doc.RootElement, item.Kind),
OriginalLanguage: ExtractString(doc.RootElement, "original_language"),
OriginCountries: ExtractStringArray(doc.RootElement, "origin_country"),
ProductionCountries: ExtractIsoFromObjectArray(doc.RootElement, "production_countries", "iso_3166_1"),
SpokenLanguages: ExtractIsoFromObjectArray(doc.RootElement, "spoken_languages", "iso_639_1"),
Genres: ExtractGenres(doc.RootElement));
}
public async Task<TmdbImages?> FetchImagesAsync(
RefreshItemInfo item,
string apiKey,
FetchRateLimiter rateLimiter,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(apiKey))
throw new InvalidOperationException("TMDb API key is required.");
if (item.Kind is "tvepisode")
return new TmdbImages([], [], []);
await rateLimiter.WaitAsync(cancellationToken).ConfigureAwait(false);
var http = _httpClientFactory.CreateClient();
var url = $"https://api.themoviedb.org/3/{BuildPath(item)}/images?api_key={Uri.EscapeDataString(apiKey)}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
using var response = await ProviderHttp.SendWithRetryAsync(http, request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
return new TmdbImages(
Posters: ExtractImageUrls(doc.RootElement, "posters"),
Logos: ExtractImageUrls(doc.RootElement, "logos"),
Backdrops: ExtractImageUrls(doc.RootElement, "backdrops"));
}
private static string BuildPath(RefreshItemInfo item)
=> item.Kind switch
{
"tvseason" => $"tv/{item.TmdbId}/season/{item.SeasonNumber}",
"tvepisode" => $"tv/{item.TmdbId}/season/{item.SeasonNumber}/episode/{item.EpisodeNumber}",
_ => $"{item.Kind}/{item.TmdbId}"
};
private static string ExtractTitle(JsonElement root, string kind)
=> kind is "tv" or "collection" or "tvseason" or "tvepisode"
? ExtractString(root, "name")
: ExtractString(root, "title");
private static string ExtractOriginalTitle(JsonElement root, string kind)
=> kind switch
{
"tv" => ExtractString(root, "original_name"),
"movie" => ExtractString(root, "original_title"),
"collection" => FirstNonEmpty(ExtractString(root, "original_name"), ExtractString(root, "name")),
"tvseason" => ExtractString(root, "name"),
"tvepisode" => ExtractString(root, "name"),
_ => ExtractString(root, "original_title")
};
private static string ExtractString(JsonElement root, string property)
=> root.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String
? value.GetString() ?? string.Empty
: string.Empty;
private static string[] ExtractStringArray(JsonElement root, string property)
{
if (!root.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.Array)
return [];
return value.EnumerateArray()
.Where(el => el.ValueKind == JsonValueKind.String)
.Select(el => el.GetString() ?? string.Empty)
.Where(s => !string.IsNullOrWhiteSpace(s))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static string[] ExtractIsoFromObjectArray(JsonElement root, string property, string isoProperty)
{
if (!root.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.Array)
return [];
return value.EnumerateArray()
.Where(el => el.ValueKind == JsonValueKind.Object)
.Select(el => el.TryGetProperty(isoProperty, out var iso) && iso.ValueKind == JsonValueKind.String ? iso.GetString() ?? string.Empty : string.Empty)
.Where(s => !string.IsNullOrWhiteSpace(s))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static TmdbGenre[] ExtractGenres(JsonElement root)
{
if (!root.TryGetProperty("genres", out var value) || value.ValueKind != JsonValueKind.Array)
return [];
return value.EnumerateArray()
.Where(el => el.ValueKind == JsonValueKind.Object && el.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.Number)
.Select(el => new TmdbGenre(
el.GetProperty("id").GetInt32(),
el.TryGetProperty("name", out var name) && name.ValueKind == JsonValueKind.String ? name.GetString() ?? string.Empty : string.Empty))
.Where(g => g.Id > 0 && !string.IsNullOrWhiteSpace(g.Name))
.GroupBy(g => g.Id)
.Select(g => g.First())
.ToArray();
}
private static Dictionary<string, string> ExtractImageUrls(JsonElement root, string property)
{
if (!root.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.Array)
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var image in value.EnumerateArray())
{
if (image.ValueKind != JsonValueKind.Object ||
!image.TryGetProperty("file_path", out var pathElement) ||
pathElement.ValueKind != JsonValueKind.String)
continue;
var path = pathElement.GetString();
if (string.IsNullOrWhiteSpace(path))
continue;
var iso = image.TryGetProperty("iso_639_1", out var isoElement) && isoElement.ValueKind == JsonValueKind.String
? isoElement.GetString() ?? string.Empty
: string.Empty;
if (string.IsNullOrWhiteSpace(iso) || result.ContainsKey(iso))
continue;
result[iso] = "https://image.tmdb.org/t/p/original" + path;
}
return result;
}
private static string FirstNonEmpty(params string[] values)
=> values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? string.Empty;
}
public sealed record TmdbMetadata(
string Title,
string Overview,
string Tagline,
string OriginalTitle,
string OriginalLanguage,
string[] OriginCountries,
string[] ProductionCountries,
string[] SpokenLanguages,
TmdbGenre[] Genres)
{
public int[] GenreIds { get; } = Genres.Select(g => g.Id).Distinct().ToArray();
}
public sealed record TmdbGenre(int Id, string Name);
public sealed record TmdbImages(
Dictionary<string, string> Posters,
Dictionary<string, string> Logos,
Dictionary<string, string> Backdrops);
@@ -0,0 +1,181 @@
using Jellyfin.Plugin.Multilang.Services;
using System.Diagnostics;
namespace Jellyfin.Plugin.Multilang.Services.Refresh;
public sealed class RefreshCoordinator
{
private sealed class QueueEntry
{
public required string ItemId { get; init; }
public required RefreshSourceTier SourceTier { get; set; }
public required RefreshWorkClass WorkClass { get; init; }
public required RefreshJobType JobType { get; set; }
public required Func<RefreshJobType, CancellationToken, Task<bool>> Work { get; set; }
public required TaskCompletionSource<bool> Completion { get; init; }
public long Sequence { get; init; }
}
private readonly object _lock = new();
private readonly Dictionary<string, QueueEntry> _entries = new(StringComparer.OrdinalIgnoreCase);
private readonly Queue<RefreshRecentItem> _recent = new();
private readonly SemaphoreSlim _signal = new(0, int.MaxValue);
private readonly ItemsProxyCache _itemsProxyCache;
private long _sequence;
private QueueEntry? _active;
public SemaphoreSlim ScanMutex { get; } = new(1, 1);
public RefreshCoordinator(ItemsProxyCache itemsProxyCache)
{
_itemsProxyCache = itemsProxyCache;
_ = Task.Run(WorkerLoop);
}
public Task<bool> EnqueueAsync(
string itemId,
RefreshSourceTier sourceTier,
RefreshWorkClass workClass,
RefreshJobType jobType,
Func<RefreshJobType, CancellationToken, Task<bool>> work,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(itemId))
return Task.FromResult(false);
if (cancellationToken.IsCancellationRequested)
return Task.FromCanceled<bool>(cancellationToken);
lock (_lock)
{
if (_entries.TryGetValue(itemId, out var existing))
{
if (jobType > existing.JobType)
{
existing.JobType = jobType;
existing.Work = work;
}
if (sourceTier > existing.SourceTier)
existing.SourceTier = sourceTier;
return existing.Completion.Task;
}
var entry = new QueueEntry
{
ItemId = itemId,
SourceTier = sourceTier,
WorkClass = workClass,
JobType = jobType,
Work = work,
Completion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously),
Sequence = ++_sequence
};
_entries.Add(itemId, entry);
_signal.Release();
return entry.Completion.Task;
}
}
private async Task WorkerLoop()
{
while (true)
{
await _signal.WaitAsync().ConfigureAwait(false);
QueueEntry? entry;
lock (_lock)
entry = DequeueLocked();
if (entry is null)
continue;
var sw = Stopwatch.StartNew();
var ok = false;
var error = string.Empty;
try
{
lock (_lock)
_active = entry;
ok = await entry.Work(entry.JobType, CancellationToken.None).ConfigureAwait(false);
if (ok)
_itemsProxyCache.InvalidateItems([entry.ItemId]);
entry.Completion.TrySetResult(ok);
}
catch (Exception ex)
{
error = ex.Message;
entry.Completion.TrySetException(ex);
}
finally
{
sw.Stop();
lock (_lock)
{
if (ReferenceEquals(_active, entry))
_active = null;
_entries.Remove(entry.ItemId);
_recent.Enqueue(new RefreshRecentItem(
entry.ItemId,
entry.SourceTier.ToString(),
entry.WorkClass.ToString(),
entry.JobType.ToString(),
ok,
sw.ElapsedMilliseconds,
error,
DateTimeOffset.UtcNow.ToUnixTimeSeconds()));
while (_recent.Count > 50)
_recent.Dequeue();
}
}
}
}
private QueueEntry? DequeueLocked()
{
if (_entries.Count == 0)
return null;
return _entries.Values
.OrderByDescending(e => e.SourceTier)
.ThenBy(e => e.WorkClass)
.ThenBy(e => e.Sequence)
.First();
}
public RefreshQueueDiagnostics GetDiagnostics()
{
lock (_lock)
{
var queued = _entries.Values
.Where(e => !ReferenceEquals(e, _active))
.OrderByDescending(e => e.SourceTier)
.ThenBy(e => e.WorkClass)
.ThenBy(e => e.Sequence)
.Select(ToDiagnostics)
.ToArray();
return new RefreshQueueDiagnostics(
queued.Length,
queued,
_active is null ? null : ToDiagnostics(_active),
_recent.Reverse().ToArray());
}
}
private static RefreshQueueEntry ToDiagnostics(QueueEntry entry)
=> new(
entry.ItemId,
entry.SourceTier.ToString(),
entry.WorkClass.ToString(),
entry.JobType.ToString(),
entry.Sequence);
}
@@ -0,0 +1,85 @@
namespace Jellyfin.Plugin.Multilang.Services.Refresh;
public enum RefreshJobType
{
Aggregate = 0,
Missing = 1,
Full = 2
}
public enum RefreshSourceTier
{
Background = 0,
Manual = 1,
OnTheFly = 2
}
public enum RefreshWorkClass
{
Movies = 0,
Collections = 1,
Episodes = 2,
Seasons = 3,
Series = 4
}
public sealed record RefreshItemInfo(
string ItemId,
string TmdbId,
string Kind,
RefreshWorkClass WorkClass,
int SeasonNumber,
int EpisodeNumber,
string? AudioLanguage,
string DisplayName);
public sealed record RefreshQueueEntry(
string ItemId,
string SourceTier,
string WorkClass,
string JobType,
long Sequence);
public sealed record RefreshRecentItem(
string ItemId,
string SourceTier,
string WorkClass,
string JobType,
bool Ok,
long DurationMs,
string Error,
long FinishedAt);
public sealed record RefreshQueueDiagnostics(
int QueuedCount,
RefreshQueueEntry[] Queued,
RefreshQueueEntry? Active,
RefreshRecentItem[] Recent);
public sealed record RefreshScanDiagnostics(
bool Running,
string Mode,
string Source,
long StartedAt,
long FinishedAt,
long ElapsedMs,
int Items,
int Due,
int Refreshed,
int SkippedNotDue,
int SkippedNoData,
int New,
int Deleted,
int CurrentIndex,
string CurrentItemId,
string CurrentName,
string CurrentKind,
string CurrentAction,
bool Success,
string Error);
public sealed record RefreshDiagnostics(
long LastScanStarted,
RefreshScanDiagnostics Scan,
RefreshQueueDiagnostics Queue,
object ProviderHttp);
@@ -0,0 +1,748 @@
using System.Diagnostics;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.Multilang.Configuration;
using Jellyfin.Plugin.Multilang.Data;
using Jellyfin.Plugin.Multilang.Services.Providers;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Querying;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Multilang.Services.Refresh;
public sealed class RefreshService
{
private const string OriginalLanguageTag = "Original";
private readonly ILibraryManager _libraryManager;
private readonly TranslationStore _store;
private readonly TmdbClient _tmdbClient;
private readonly FanartClient _fanartClient;
private readonly RefreshCoordinator _coordinator;
private readonly ILogger<RefreshService> _logger;
private readonly object _diagnosticsLock = new();
private RefreshScanDiagnostics _scanDiagnostics = EmptyScanDiagnostics();
public RefreshService(
ILibraryManager libraryManager,
TranslationStore store,
TmdbClient tmdbClient,
FanartClient fanartClient,
RefreshCoordinator coordinator,
ILogger<RefreshService> logger)
{
_libraryManager = libraryManager;
_store = store;
_tmdbClient = tmdbClient;
_fanartClient = fanartClient;
_coordinator = coordinator;
_logger = logger;
}
public RefreshDiagnostics GetDiagnostics()
=> new(
_store.GetLastScanStarted(),
GetScanDiagnostics(),
_coordinator.GetDiagnostics(),
ProviderHttp.GetDiagnostics());
private RefreshScanDiagnostics GetScanDiagnostics()
{
lock (_diagnosticsLock)
return _scanDiagnostics;
}
private void SetScanDiagnostics(RefreshScanDiagnostics diagnostics)
{
lock (_diagnosticsLock)
_scanDiagnostics = diagnostics;
}
private static RefreshScanDiagnostics EmptyScanDiagnostics()
=> new(false, string.Empty, string.Empty, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, string.Empty, string.Empty, string.Empty, string.Empty, true, string.Empty);
public async Task RunScanAsync(RefreshJobType jobType, IProgress<double> progress, CancellationToken cancellationToken, string runSource)
{
var cfg = Plugin.Instance?.Configuration ?? throw new InvalidOperationException("Plugin configuration unavailable.");
ValidateRefreshConfiguration(cfg);
await _coordinator.ScanMutex.WaitAsync(cancellationToken).ConfigureAwait(false);
var startedAt = TranslationStore.NowUnixUtc();
var sw = Stopwatch.StartNew();
var mode = jobType.ToString();
var source = runSource ?? string.Empty;
var itemCount = 0;
var dueCount = 0;
var refreshedCount = 0;
var skippedNotDueCount = 0;
var skippedNoDataCount = 0;
var newCount = 0;
var deletedCount = 0;
var currentIndex = 0;
var currentItemId = string.Empty;
var currentName = string.Empty;
var currentKind = string.Empty;
var currentAction = "initializing";
void UpdateRunningScan(string action = "")
{
if (!string.IsNullOrWhiteSpace(action))
currentAction = action;
SetScanDiagnostics(new RefreshScanDiagnostics(
true,
mode,
source,
startedAt,
0,
sw.ElapsedMilliseconds,
itemCount,
dueCount,
refreshedCount,
skippedNotDueCount,
skippedNoDataCount,
newCount,
deletedCount,
currentIndex,
currentItemId,
currentName,
currentKind,
currentAction,
true,
string.Empty));
}
void SetCurrent(RefreshItemInfo item, int index, string action)
{
currentIndex = index;
currentItemId = item.ItemId;
currentName = item.DisplayName;
currentKind = item.Kind;
currentAction = action;
}
void CompleteScan(bool success, string error)
=> SetScanDiagnostics(new RefreshScanDiagnostics(
false,
mode,
source,
startedAt,
TranslationStore.NowUnixUtc(),
sw.ElapsedMilliseconds,
itemCount,
dueCount,
refreshedCount,
skippedNotDueCount,
skippedNoDataCount,
newCount,
deletedCount,
currentIndex,
currentItemId,
currentName,
currentKind,
success ? "complete" : "failed",
success,
error));
try
{
UpdateRunningScan();
var items = GetAllItemsWithTmdbId();
var now = TranslationStore.NowUnixUtc();
var missingCutoff = now - (long)TimeSpan.FromDays(Math.Max(0, cfg.WaitDaysForMissingData)).TotalSeconds;
var fullCutoff = now - (long)TimeSpan.FromDays(Math.Max(0, cfg.WaitDaysForExistingData)).TotalSeconds;
var ordered = items.OrderBy(i => i.WorkClass).ToList();
itemCount = ordered.Count;
var liveIds = ordered.Select(i => i.ItemId).ToHashSet(StringComparer.OrdinalIgnoreCase);
var trackedBefore = _store.GetTrackedItemIds();
deletedCount = trackedBefore.Except(liveIds, StringComparer.OrdinalIgnoreCase).Count();
newCount = liveIds.Except(trackedBefore, StringComparer.OrdinalIgnoreCase).Count();
_store.CleanupNotInLibrary(liveIds);
_logger.LogInformation("Multilang refresh started mode={Mode} source={Source} items={Items} new={New} deleted={Deleted}", jobType, runSource, ordered.Count, newCount, deletedCount);
var index = 0;
UpdateRunningScan();
foreach (var item in ordered)
{
cancellationToken.ThrowIfCancellationRequested();
index++;
SetCurrent(item, index, "checking");
var status = _store.GetFactsStatus(item.ItemId);
var isNew = !status.Exists;
var due = jobType == RefreshJobType.Full
? !status.Exists || status.FullCheckedAt < fullCutoff
: !status.Exists || status.MissingCheckedAt < missingCutoff;
if (!due)
{
skippedNotDueCount++;
if (cfg.VerboseLogging && cfg.EnableLogging)
{
_logger.LogInformation(
"Multilang refresh skip [{Index}/{Total}] item={ItemId} name=\"{Name}\" kind={Kind} reason=not-due",
index,
ordered.Count,
item.ItemId,
item.DisplayName,
item.Kind);
}
progress.Report(index * 100.0 / Math.Max(1, ordered.Count));
UpdateRunningScan("skipped-not-due");
continue;
}
var scanIndex = index;
var actualJobType = jobType == RefreshJobType.Full || !status.Exists ? RefreshJobType.Full : RefreshJobType.Missing;
dueCount++;
SetCurrent(item, scanIndex, "fetching");
Log(
cfg.EnableLogging,
LogLevel.Information,
"Multilang refresh fetch [{Index}/{Total}] item={ItemId} name=\"{Name}\" kind={Kind} tmdb={TmdbId} mode={Mode} new={New}",
scanIndex,
ordered.Count,
item.ItemId,
item.DisplayName,
item.Kind,
item.TmdbId,
actualJobType,
isNew);
var ok = await _coordinator.EnqueueAsync(
item.ItemId,
RefreshSourceTier.Background,
item.WorkClass,
actualJobType,
async (actualJobType, ct) => await RefreshItemCoreAsync(item, cfg, actualJobType, scanIndex, ordered.Count, ct).ConfigureAwait(false),
cancellationToken).ConfigureAwait(false);
if (ok)
refreshedCount++;
else
skippedNoDataCount++;
progress.Report(index * 100.0 / Math.Max(1, ordered.Count));
UpdateRunningScan(ok ? "refreshed" : "skipped-no-data");
}
sw.Stop();
_store.SetLastScanStarted(now);
CompleteScan(true, string.Empty);
_logger.LogInformation(
"Multilang refresh complete mode={Mode} source={Source} elapsed={ElapsedMs}ms items={Items} due={Due} refreshed={Refreshed} skippedNotDue={SkippedNotDue} skippedNoData={SkippedNoData} new={New} deleted={Deleted}",
jobType,
runSource,
sw.ElapsedMilliseconds,
ordered.Count,
dueCount,
refreshedCount,
skippedNotDueCount,
skippedNoDataCount,
newCount,
deletedCount);
}
catch (Exception ex)
{
sw.Stop();
CompleteScan(false, ex.Message);
throw;
}
finally
{
_coordinator.ScanMutex.Release();
}
}
public async Task RefreshItemAsync(Guid itemId, bool includeChildren, CancellationToken cancellationToken)
{
var cfg = Plugin.Instance?.Configuration ?? throw new InvalidOperationException("Plugin configuration unavailable.");
ValidateRefreshConfiguration(cfg);
var root = _libraryManager.GetItemById(itemId);
if (root is null)
{
Log(cfg.EnableLogging, LogLevel.Warning, "Manual refresh requested for missing item {ItemId}", itemId);
return;
}
var items = BuildItemInfos(GetItemAndChildren(root, includeChildren)).ToList();
var total = items.Count;
var index = 0;
_logger.LogInformation("Manual Multilang refresh started item={ItemId} name={Name} items={Items} includeChildren={IncludeChildren}", itemId, root.Name ?? string.Empty, total, includeChildren);
foreach (var item in items)
{
index++;
var scanIndex = index;
Log(
cfg.EnableLogging,
LogLevel.Information,
"Manual Multilang refresh fetch [{Index}/{Total}] item={ItemId} name=\"{Name}\" kind={Kind} tmdb={TmdbId}",
scanIndex,
total,
item.ItemId,
item.DisplayName,
item.Kind,
item.TmdbId);
var ok = await _coordinator.EnqueueAsync(
item.ItemId,
RefreshSourceTier.Manual,
item.WorkClass,
RefreshJobType.Full,
async (actualJobType, ct) => await RefreshItemCoreAsync(item, cfg, actualJobType, scanIndex, total, ct).ConfigureAwait(false),
cancellationToken).ConfigureAwait(false);
Log(
cfg.EnableLogging,
ok ? LogLevel.Information : LogLevel.Warning,
"Manual Multilang refresh item {Status} [{Index}/{Total}] item={ItemId} name=\"{Name}\" kind={Kind}",
ok ? "complete" : "skipped",
scanIndex,
total,
item.ItemId,
item.DisplayName,
item.Kind);
}
}
public bool EnqueueOnTheFlyMissing(IEnumerable<string> itemIds)
{
var cfg = Plugin.Instance?.Configuration;
if (cfg is null)
return false;
try
{
ValidateRefreshConfiguration(cfg);
}
catch (InvalidOperationException)
{
return false;
}
var ids = itemIds
.Where(id => !string.IsNullOrWhiteSpace(id))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (ids.Length == 0)
return false;
var existingFacts = _store.GetExistingFactsItemIds(ids);
var queuedAny = false;
var sourceTier = ids.Length <= 5 ? RefreshSourceTier.OnTheFly : RefreshSourceTier.Background;
foreach (var itemId in ids)
{
if (existingFacts.Contains(itemId))
continue;
if (!Guid.TryParseExact(itemId, "N", out var guid))
continue;
var item = _libraryManager.GetItemById(guid);
if (item is null)
continue;
var info = BuildItemInfos([item]).FirstOrDefault();
if (info is null)
continue;
queuedAny = true;
_ = _coordinator.EnqueueAsync(
info.ItemId,
sourceTier,
info.WorkClass,
RefreshJobType.Full,
async (actualJobType, ct) => await RefreshItemCoreAsync(info, cfg, actualJobType, 0, 0, ct).ConfigureAwait(false),
CancellationToken.None)
.ContinueWith(
task =>
{
if (task.Exception is not null)
_logger.LogWarning(task.Exception, "On-demand Multilang refresh failed for item={ItemId}", info.ItemId);
},
TaskContinuationOptions.OnlyOnFaulted);
_logger.LogInformation("Queued on-demand Multilang refresh item={ItemId} tier={Tier}", info.ItemId, sourceTier);
}
return queuedAny;
}
public RefreshItemInfo? GetRefreshItemInfo(Guid itemId)
{
var item = _libraryManager.GetItemById(itemId);
return item is null ? null : BuildItemInfos([item]).FirstOrDefault();
}
private async Task<bool> RefreshItemCoreAsync(
RefreshItemInfo item,
PluginConfiguration cfg,
RefreshJobType jobType,
int scanIndex,
int totalItems,
CancellationToken cancellationToken)
{
var langs = NormalizeLanguages(cfg.Languages);
if (langs.Length == 0)
throw new InvalidOperationException("No Multilang languages are configured.");
if (cfg.VerboseLogging && cfg.EnableLogging)
_logger.LogInformation("[{Index}/{Total}] Fetching item={ItemId} name=\"{Name}\" kind={Kind} tmdb={TmdbId}", scanIndex, totalItems, item.ItemId, item.DisplayName, item.Kind, item.TmdbId);
var now = TranslationStore.NowUnixUtc();
var limiter = new FetchRateLimiter(TimeSpan.FromMilliseconds(250));
var wroteAny = false;
TmdbMetadata? firstMeta = null;
var fetchedMetadata = new Dictionary<string, TmdbMetadata>(StringComparer.OrdinalIgnoreCase);
foreach (var lang in langs)
{
var meta = await _tmdbClient.FetchMetadataAsync(item, lang, cfg.TmdbApiKey, limiter, cancellationToken).ConfigureAwait(false);
if (meta is null)
{
Log(cfg.EnableLogging, LogLevel.Information, "[{Index}/{Total}] TMDb 404, skipping item={ItemId} name=\"{Name}\" tmdb={TmdbId} kind={Kind}", scanIndex, totalItems, item.ItemId, item.DisplayName, item.TmdbId, item.Kind);
_store.MarkFactsChecked(item, now, jobType == RefreshJobType.Full ? now : 0);
return false;
}
Log(cfg.VerboseLogging && cfg.EnableLogging, LogLevel.Information, "[{Index}/{Total}] TMDb metadata ok item={ItemId} lang={Lang}", scanIndex, totalItems, item.ItemId, lang);
_store.UpsertTranslation(item.ItemId, lang, "title", meta.Title);
_store.UpsertTranslation(item.ItemId, lang, "overview", meta.Overview);
_store.UpsertTranslation(item.ItemId, lang, "tagline", meta.Tagline);
StoreGenres(item, lang, meta);
fetchedMetadata[lang] = meta;
firstMeta ??= meta;
if (!wroteAny)
{
_store.UpsertFacts(item, meta, now, jobType == RefreshJobType.Full ? now : 0);
wroteAny = true;
}
}
var artworkLanguages = langs;
if (firstMeta is not null && !string.IsNullOrWhiteSpace(firstMeta.OriginalLanguage))
{
var originalLang = firstMeta.OriginalLanguage.Trim();
var existingLang = langs.FirstOrDefault(lang => SameLanguageBase(lang, originalLang));
Log(
cfg.EnableLogging,
LogLevel.Information,
"[{Index}/{Total}] Original language item={ItemId} lang={OriginalLang} metadataSource={Source}",
scanIndex,
totalItems,
item.ItemId,
originalLang,
existingLang is not null ? "configured-language:" + existingLang : "extra-fetch");
var originalMeta = existingLang is not null
? fetchedMetadata[existingLang]
: await _tmdbClient.FetchMetadataAsync(item, originalLang, cfg.TmdbApiKey, limiter, cancellationToken).ConfigureAwait(false);
if (originalMeta is not null)
{
_store.UpsertTranslation(item.ItemId, OriginalLanguageTag, "title", originalMeta.Title);
_store.UpsertTranslation(item.ItemId, OriginalLanguageTag, "overview", originalMeta.Overview);
_store.UpsertTranslation(item.ItemId, OriginalLanguageTag, "tagline", originalMeta.Tagline);
StoreGenres(item, OriginalLanguageTag, originalMeta);
Log(
cfg.VerboseLogging && cfg.EnableLogging,
LogLevel.Information,
"[{Index}/{Total}] Original metadata stored item={ItemId} lang={OriginalLang} titlePresent={TitlePresent} overviewPresent={OverviewPresent} taglinePresent={TaglinePresent}",
scanIndex,
totalItems,
item.ItemId,
originalLang,
!string.IsNullOrWhiteSpace(originalMeta.Title),
!string.IsNullOrWhiteSpace(originalMeta.Overview),
!string.IsNullOrWhiteSpace(originalMeta.Tagline));
}
else
{
Log(
cfg.EnableLogging,
LogLevel.Warning,
"[{Index}/{Total}] Original metadata unavailable item={ItemId} lang={OriginalLang}",
scanIndex,
totalItems,
item.ItemId,
originalLang);
}
artworkLanguages = AppendOriginalLanguage(langs, originalLang);
Log(
cfg.VerboseLogging && cfg.EnableLogging,
LogLevel.Information,
"[{Index}/{Total}] Artwork language set item={ItemId} langs={Languages}",
scanIndex,
totalItems,
item.ItemId,
string.Join(",", artworkLanguages));
}
await StoreArtworkAsync(item, cfg, artworkLanguages, limiter, now, cancellationToken).ConfigureAwait(false);
Log(cfg.EnableLogging, LogLevel.Information, "Multilang refresh item complete item={ItemId} kind={Kind} tmdb={TmdbId} mode={Mode}", item.ItemId, item.Kind, item.TmdbId, jobType);
return true;
}
private void StoreGenres(RefreshItemInfo item, string lang, TmdbMetadata metadata)
{
var media = GenreMediaFor(item.Kind);
if (media is null)
return;
foreach (var genre in metadata.Genres)
_store.UpsertGenre(genre.Id, media, lang, genre.Name);
}
private static string? GenreMediaFor(string kind)
=> kind.Equals("movie", StringComparison.OrdinalIgnoreCase) ||
kind.Equals("collection", StringComparison.OrdinalIgnoreCase)
? "movie"
: kind.Equals("tv", StringComparison.OrdinalIgnoreCase) ||
kind.Equals("tvseason", StringComparison.OrdinalIgnoreCase) ||
kind.Equals("tvepisode", StringComparison.OrdinalIgnoreCase)
? "tv"
: null;
private async Task StoreArtworkAsync(
RefreshItemInfo item,
PluginConfiguration cfg,
string[] languages,
FetchRateLimiter limiter,
long updatedAt,
CancellationToken cancellationToken)
{
if (item.Kind is "tvepisode")
return;
var providers = cfg.Providers
.Where(p => p.ArtworkOrder >= 0)
.OrderBy(p => p.ArtworkOrder)
.Select(p => p.Id.Trim().ToLowerInvariant())
.ToArray();
if (providers.Length == 0)
return;
TmdbImages? tmdbImages = null;
FanartImages? fanartImages = null;
var written = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var provider in providers)
{
cancellationToken.ThrowIfCancellationRequested();
if (provider.Equals("tmdb", StringComparison.OrdinalIgnoreCase))
{
tmdbImages ??= await _tmdbClient.FetchImagesAsync(item, cfg.TmdbApiKey, limiter, cancellationToken).ConfigureAwait(false);
if (tmdbImages is null)
continue;
StoreImageMap(item.ItemId, languages, "poster", tmdbImages.Posters, updatedAt, written);
StoreImageMap(item.ItemId, languages, "logo", tmdbImages.Logos, updatedAt, written);
StoreImageMap(item.ItemId, languages, "backdrop", tmdbImages.Backdrops, updatedAt, written);
Log(cfg.VerboseLogging && cfg.EnableLogging, LogLevel.Information, "TMDb artwork stored item={ItemId} langs={Languages}", item.ItemId, string.Join(",", languages));
continue;
}
if (provider.Equals("fanart", StringComparison.OrdinalIgnoreCase) && item.Kind.Equals("movie", StringComparison.OrdinalIgnoreCase))
{
fanartImages ??= await _fanartClient.FetchMovieImagesAsync(item.TmdbId, cfg.FanartApiKey, cancellationToken).ConfigureAwait(false);
if (fanartImages is null)
continue;
StoreImageMap(item.ItemId, languages, "poster", fanartImages.Posters, updatedAt, written);
StoreImageMap(item.ItemId, languages, "logo", fanartImages.Logos, updatedAt, written);
StoreImageMap(item.ItemId, languages, "banner", fanartImages.Banners, updatedAt, written);
StoreImageMap(item.ItemId, languages, "thumb", fanartImages.Thumbs, updatedAt, written);
Log(cfg.VerboseLogging && cfg.EnableLogging, LogLevel.Information, "Fanart artwork stored item={ItemId} langs={Languages}", item.ItemId, string.Join(",", languages));
}
}
}
private void StoreImageMap(
string itemId,
IEnumerable<string> languages,
string kind,
IReadOnlyDictionary<string, string> images,
long updatedAt,
HashSet<string> written)
{
foreach (var lang in languages)
{
var storeLang = lang.StartsWith(OriginalLanguageTag + ":", StringComparison.OrdinalIgnoreCase)
? OriginalLanguageTag
: lang;
var key = kind + ":" + storeLang;
if (written.Contains(key))
continue;
var iso = ToIso639(lang);
if (!images.TryGetValue(iso, out var url) || string.IsNullOrWhiteSpace(url))
continue;
_store.UpsertAsset(itemId, storeLang, kind, url, updatedAt);
written.Add(key);
}
}
private static string[] AppendOriginalLanguage(string[] languages, string originalLanguage)
=> languages
.Concat([OriginalLanguageTag + ":" + originalLanguage])
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
private static bool SameLanguageBase(string left, string right)
=> ToIso639(left).Equals(ToIso639(right), StringComparison.OrdinalIgnoreCase);
private static string ToIso639(string language)
{
if (language.StartsWith(OriginalLanguageTag + ":", StringComparison.OrdinalIgnoreCase))
language = language[(OriginalLanguageTag.Length + 1)..];
var index = language.IndexOf('-', StringComparison.Ordinal);
return (index > 0 ? language[..index] : language).Trim().ToLowerInvariant();
}
private List<RefreshItemInfo> GetAllItemsWithTmdbId()
{
var query = new InternalItemsQuery
{
IncludeItemTypes =
[
BaseItemKind.Movie,
BaseItemKind.Series,
BaseItemKind.BoxSet,
BaseItemKind.Season,
BaseItemKind.Episode
],
Recursive = true
};
return BuildItemInfos(_libraryManager.GetItemList(query)).ToList();
}
private IEnumerable<RefreshItemInfo> BuildItemInfos(IEnumerable<BaseItem> items)
{
foreach (var item in items)
{
var itemId = TranslationStore.ToItemId32(item.Id);
var typeName = item.GetType().Name;
if (typeName.Equals("Season", StringComparison.OrdinalIgnoreCase))
{
if (TryResolveEpisodeContext(item, out var seriesTmdbId, out var seasonNumber, out _))
yield return new RefreshItemInfo(itemId, seriesTmdbId, "tvseason", RefreshWorkClass.Seasons, seasonNumber, 0, string.Empty, item.Name ?? string.Empty);
continue;
}
if (typeName.Equals("Episode", StringComparison.OrdinalIgnoreCase))
{
if (TryResolveEpisodeContext(item, out var seriesTmdbId, out var seasonNumber, out var episodeNumber))
yield return new RefreshItemInfo(itemId, seriesTmdbId, "tvepisode", RefreshWorkClass.Episodes, seasonNumber, episodeNumber, string.Empty, item.Name ?? string.Empty);
continue;
}
var providerIds = item.ProviderIds ?? new Dictionary<string, string>();
providerIds.TryGetValue("Tmdb", out var tmdbId);
providerIds.TryGetValue("TmdbCollection", out var tmdbCollectionId);
if (typeName.Equals("Series", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(tmdbId))
{
yield return new RefreshItemInfo(itemId, tmdbId.Trim(), "tv", RefreshWorkClass.Series, 0, 0, string.Empty, item.Name ?? string.Empty);
continue;
}
if (typeName.Equals("BoxSet", StringComparison.OrdinalIgnoreCase))
{
var collectionId = FirstNonEmpty(tmdbCollectionId, tmdbId);
if (!string.IsNullOrWhiteSpace(collectionId))
yield return new RefreshItemInfo(itemId, collectionId.Trim(), "collection", RefreshWorkClass.Collections, 0, 0, string.Empty, item.Name ?? string.Empty);
continue;
}
if (!string.IsNullOrWhiteSpace(tmdbId))
yield return new RefreshItemInfo(itemId, tmdbId.Trim(), "movie", RefreshWorkClass.Movies, 0, 0, string.Empty, item.Name ?? string.Empty);
}
}
private List<BaseItem> GetItemAndChildren(BaseItem root, bool includeChildren)
{
var items = new List<BaseItem> { root };
if (!includeChildren)
return items;
items.AddRange(_libraryManager.GetItemList(new InternalItemsQuery
{
ParentId = root.Id,
Recursive = true
}));
return items;
}
private bool TryResolveEpisodeContext(BaseItem item, out string seriesTmdbId, out int seasonNumber, out int episodeNumber)
{
seriesTmdbId = string.Empty;
seasonNumber = item.ParentIndexNumber ?? 0;
episodeNumber = item.IndexNumber ?? 0;
var typeName = item.GetType().Name;
var isSeason = typeName.Equals("Season", StringComparison.OrdinalIgnoreCase);
var seasonGuid = !isSeason ? TryGetGuid(item, "SeasonId") ?? (item.ParentId == Guid.Empty ? null : item.ParentId) : null;
var seriesGuid = TryGetGuid(item, "SeriesId") ?? (isSeason && item.ParentId != Guid.Empty ? item.ParentId : null);
var season = isSeason ? item : (seasonGuid.HasValue ? _libraryManager.GetItemById(seasonGuid.Value) : null);
if (!seriesGuid.HasValue && season is not null && season.ParentId != Guid.Empty)
seriesGuid = season.ParentId;
var series = seriesGuid.HasValue ? _libraryManager.GetItemById(seriesGuid.Value) : null;
if (isSeason)
{
seasonNumber = item.IndexNumber ?? 0;
}
if (seasonNumber <= 0 || (!isSeason && episodeNumber <= 0) || series is null)
return false;
var providerIds = series.ProviderIds ?? new Dictionary<string, string>();
if (!providerIds.TryGetValue("Tmdb", out var tmdbId) || string.IsNullOrWhiteSpace(tmdbId))
return false;
seriesTmdbId = tmdbId.Trim();
return true;
}
private static Guid? TryGetGuid(BaseItem item, string propertyName)
{
var property = item.GetType().GetProperty(propertyName);
var value = property?.GetValue(item);
return value switch
{
Guid guid when guid != Guid.Empty => guid,
string text when Guid.TryParse(text, out var guid) && guid != Guid.Empty => guid,
_ => (Guid?)null
};
}
private void Log(bool enabled, LogLevel level, string message, params object[] args)
{
if (enabled)
_logger.Log(level, message, args);
}
private static void ValidateRefreshConfiguration(PluginConfiguration cfg)
{
var metadataProviders = cfg.Providers
.Where(p => p.MetadataOrder >= 0)
.OrderBy(p => p.MetadataOrder)
.Select(p => p.Id)
.ToArray();
if (!metadataProviders.Contains("tmdb", StringComparer.OrdinalIgnoreCase))
throw new InvalidOperationException("TMDb must be enabled as a metadata provider before refresh can run.");
if (string.IsNullOrWhiteSpace(cfg.TmdbApiKey))
throw new InvalidOperationException("TMDb API key is required before refresh can run.");
if (NormalizeLanguages(cfg.Languages).Length == 0)
throw new InvalidOperationException("At least one Multilang language must be configured before refresh can run.");
}
private static string[] NormalizeLanguages(IEnumerable<string> languages)
=> languages.Select(l => (l ?? string.Empty).Trim())
.Where(l => l.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
private static string FirstNonEmpty(params string?[] values)
=> values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? string.Empty;
}
@@ -0,0 +1,22 @@
namespace Jellyfin.Plugin.Multilang.Services;
public sealed class SortArticleCatalog
{
private static readonly Dictionary<string, string[]> BuiltIns = new(StringComparer.OrdinalIgnoreCase)
{
["en"] = ["a", "an", "the"],
["en-US"] = ["a", "an", "the"],
["en-GB"] = ["a", "an", "the"],
["fi"] = [],
["fi-FI"] = [],
["sv"] = ["en", "ett"],
["sv-SE"] = ["en", "ett"],
["fr"] = ["l'", "le", "la", "les", "un", "une", "des"],
["de"] = ["der", "die", "das", "ein", "eine"],
["es"] = ["el", "la", "los", "las", "un", "una"],
["it"] = ["il", "lo", "la", "gli", "le", "un", "una"]
};
public IReadOnlyDictionary<string, string[]> GetBuiltIns()
=> BuiltIns;
}