Initial jellyfin-multilang rewrite
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user