diff --git a/Data/DatabaseManager.cs b/Data/DatabaseManager.cs index cbd76da..db3f43b 100644 --- a/Data/DatabaseManager.cs +++ b/Data/DatabaseManager.cs @@ -118,11 +118,13 @@ INSERT INTO catalog_items @tvdb_id, @raw_meta_json, @catalog_type, @videos_json, @episodes_expanded, @last_expanded_at, @last_verified_at, @source_manifest_url, @selected_versions_json, @last_version_refresh_at) ON CONFLICT(aio_id, source) DO UPDATE SET - tmdb_id = excluded.tmdb_id, + tmdb_id = COALESCE(excluded.tmdb_id, catalog_items.tmdb_id), unique_ids_json = COALESCE(excluded.unique_ids_json, catalog_items.unique_ids_json), title = excluded.title, - year = excluded.year, - media_type = excluded.media_type, + year = COALESCE(excluded.year, catalog_items.year), + media_type = CASE WHEN (catalog_items.strm_path IS NOT NULL AND catalog_items.strm_path<>'') + OR (catalog_items.item_state=3 AND catalog_items.local_source='library') + THEN catalog_items.media_type ELSE excluded.media_type END, seasons_json = COALESCE(excluded.seasons_json, catalog_items.seasons_json), strm_path = CASE WHEN catalog_items.item_state = 3 THEN NULL ELSE COALESCE(excluded.strm_path, catalog_items.strm_path) END, @@ -158,11 +160,7 @@ private void BindCatalogItemParams(IStatement cmd, CatalogItem item) BindNullableText(cmd, "@unique_ids_json", item.UniqueIdsJson); BindText(cmd, "@title", item.Title); BindNullableInt(cmd, "@year", item.Year); - var rawMediaType = item.MediaType; - var validMediaType = string.IsNullOrEmpty(rawMediaType) ? "movie" : rawMediaType; - if (validMediaType != "movie" && validMediaType != "series" && validMediaType != "anime" && validMediaType != "episode" && validMediaType != "other") - validMediaType = "movie"; - BindText(cmd, "@media_type", validMediaType); + BindText(cmd, "@media_type", ValidCatalogMediaType(item.MediaType)); BindText(cmd, "@source", item.Source); BindNullableText(cmd, "@source_list_id", item.SourceListId); BindNullableText(cmd, "@seasons_json", item.SeasonsJson); @@ -199,9 +197,47 @@ private void BindCatalogItemParams(IStatement cmd, CatalogItem item) BindNullableText(cmd, "@last_version_refresh_at", item.LastVersionRefreshAt); } + private static string ValidCatalogMediaType(string? type) => + type is "movie" or "series" or "anime" or "episode" or "other" ? type : "movie"; + + // A stale worker or source refresh must not change the import family of a + // row that already has managed media. Such conversions require a backed-up + // destination repair, not a generic catalog upsert. + private static bool ManagedCatalogIdentityCompatible(IDatabaseConnection conn, CatalogItem item) + { + using var stmt = conn.PrepareStatement(@" + SELECT media_type FROM catalog_items + WHERE aio_id=@aio_id AND source=@source + AND ((strm_path IS NOT NULL AND strm_path<>'') + OR (item_state=3 AND local_source='library'));"); + BindText(stmt, "@aio_id", item.AioId); + BindText(stmt, "@source", item.Source); + foreach (var row in stmt.AsRows()) + { + var existing = row.GetString(0); + var incoming = ValidCatalogMediaType(item.MediaType); + if (existing == incoming || (existing is "series" or "anime" && incoming is "series" or "anime")) continue; + return false; + } + return true; + } + public async Task UpsertCatalogItemAsync(CatalogItem item, CancellationToken cancellationToken = default) { - await ExecuteWriteAsync(UpsertCatalogItemSql, cmd => BindCatalogItemParams(cmd, item)); + await _dbWriteGate.WaitAsync(cancellationToken); + try + { + using var conn = OpenConnection(); + conn.RunInTransaction(c => + { + if (!ManagedCatalogIdentityCompatible(c, item)) + throw new InvalidOperationException("managed_identity_conflict"); + using var stmt = c.PrepareStatement(UpsertCatalogItemSql); + BindCatalogItemParams(stmt, item); + while (stmt.MoveNext()) { } + }); + } + finally { _dbWriteGate.Release(); } } /// @@ -259,10 +295,11 @@ UPDATE catalog_items /// Batch upsert: wraps all items in a single SQLite transaction. /// Orders of magnitude faster than calling UpsertCatalogItemAsync in a loop. /// - public async Task BulkUpsertCatalogItemsAsync(IEnumerable items, CancellationToken cancellationToken = default) + public async Task BulkUpsertCatalogItemsAsync(IEnumerable items, CancellationToken cancellationToken = default) { var itemList = items as IList ?? items.ToList(); - if (itemList.Count == 0) return; + if (itemList.Count == 0) return 0; + var accepted = 0; await _dbWriteGate.WaitAsync(cancellationToken); try @@ -272,9 +309,16 @@ public async Task BulkUpsertCatalogItemsAsync(IEnumerable items, Ca { foreach (var item in itemList) { + cancellationToken.ThrowIfCancellationRequested(); + if (!ManagedCatalogIdentityCompatible(c, item)) + { + _logger.LogWarning("[InfiniteDrive] Skipped catalog update for {AioId}: managed_identity_conflict", item.AioId); + continue; + } using var stmt = c.PrepareStatement(UpsertCatalogItemSql); BindCatalogItemParams(stmt, item); while (stmt.MoveNext()) { } + accepted++; } }); } @@ -282,6 +326,7 @@ public async Task BulkUpsertCatalogItemsAsync(IEnumerable items, Ca { _dbWriteGate.Release(); } + return accepted; } /// diff --git a/InfiniteDrive.csproj b/InfiniteDrive.csproj index af46649..bcb11fc 100755 --- a/InfiniteDrive.csproj +++ b/InfiniteDrive.csproj @@ -2,9 +2,9 @@ net8.0 - 0.42.8.0 - 0.42.8.0 - 0.42.8.0 + 0.42.9.0 + 0.42.9.0 + 0.42.9.0 enable InfiniteDrive InfiniteDrive diff --git a/README.md b/README.md index dee5119..27c6d2d 100755 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ imports titles and repairs missing files. A stream will appear. Probably. -**Current release: 0.42.8**, built and tested against **Emby 4.10.0.40**. +**Current release: 0.42.9**, built and tested against **Emby 4.10.0.40**. ## How we run it diff --git a/Services/ListFetcher.cs b/Services/ListFetcher.cs index 7ec037f..bcea843 100644 --- a/Services/ListFetcher.cs +++ b/Services/ListFetcher.cs @@ -149,16 +149,16 @@ private static async Task FetchMdblistAsync( // Legacy format: [ ... ] if (doc.RootElement.ValueKind == JsonValueKind.Object) { - var combined = new List(); + var combined = new List<(JsonElement Item, string MediaType)>(); if (doc.RootElement.TryGetProperty("movies", out var movies)) - foreach (var item in movies.EnumerateArray()) combined.Add(item); + foreach (var item in movies.EnumerateArray()) combined.Add((item, "movie")); if (doc.RootElement.TryGetProperty("shows", out var shows)) - foreach (var item in shows.EnumerateArray()) combined.Add(item); + foreach (var item in shows.EnumerateArray()) combined.Add((item, "series")); if (combined.Count == 0) break; foreach (var item in combined) { - var li = ParseMdblistItem(item); + var li = ParseMdblistItem(item.Item, item.MediaType); if (li != null) all.Add(li); } @@ -194,7 +194,7 @@ private static async Task FetchMdblistAsync( return new ListFetchResult { Ok = true, Items = all }; } - private static ListItem? ParseMdblistItem(JsonElement item) + internal static ListItem? ParseMdblistItem(JsonElement item, string? bucketType = null) { string? imdbId = null; if (item.TryGetProperty("imdb_id", out var imdbEl) && imdbEl.ValueKind == JsonValueKind.String) @@ -205,7 +205,25 @@ private static async Task FetchMdblistAsync( ? titleEl.GetString() ?? "Unknown" : "Unknown"; - return new ListItem(title, imdbId.ToLowerInvariant(), null, null); + // MDBList's public /json arrays use mediatype=show and release_year. + // Losing these fields silently routes shows through the movie resolver. + var rawType = item.TryGetProperty("mediatype", out var typeEl) + || item.TryGetProperty("media_type", out typeEl) + || item.TryGetProperty("type", out typeEl) + ? typeEl.ValueKind == JsonValueKind.String ? typeEl.GetString()?.ToLowerInvariant() : null + : null; + var explicitType = NormalizeMediaType(rawType); + if (bucketType != null && explicitType != null && bucketType != explicitType) return null; + var mediaType = explicitType ?? bucketType; + if (mediaType is not ("movie" or "series")) return null; + + int? year = null; + if (item.TryGetProperty("release_year", out var yearEl) || item.TryGetProperty("year", out yearEl)) + { + if (yearEl.ValueKind == JsonValueKind.Number && yearEl.TryGetInt32(out var numericYear)) year = numericYear; + else if (yearEl.ValueKind == JsonValueKind.String && int.TryParse(yearEl.GetString(), out numericYear)) year = numericYear; + } + return new ListItem(title, imdbId.ToLowerInvariant(), year, mediaType); } // ── Trakt ──────────────────────────────────────────────────────────────── @@ -264,7 +282,7 @@ private static async Task FetchTraktAsync( } } - private static ListFetchResult ParseTraktResponse(string json) + internal static ListFetchResult ParseTraktResponse(string json) { var items = new List(); try @@ -275,14 +293,24 @@ private static ListFetchResult ParseTraktResponse(string json) foreach (var entry in doc.RootElement.EnumerateArray()) { - // Wrapped format: { "movie": {...} } or { "show": {...} } + if (entry.ValueKind != JsonValueKind.Object) continue; + var hasType = entry.TryGetProperty("type", out var typeEl); + var declaredType = hasType && typeEl.ValueKind == JsonValueKind.String + ? NormalizeMediaType(typeEl.GetString()) : null; + if (hasType && declaredType == null) continue; + + // Wrapper identity is authoritative; do not promote episode + // history entries into their parent show or accept conflicts. JsonElement? mediaObj = null; - if (entry.TryGetProperty("movie", out var movieEl) && movieEl.ValueKind == JsonValueKind.Object) - mediaObj = movieEl; - else if (entry.TryGetProperty("show", out var showEl) && showEl.ValueKind == JsonValueKind.Object) - mediaObj = showEl; - else if (entry.TryGetProperty("ids", out _)) - mediaObj = entry; // Flat format + string? mediaType = null; + var hasMovie = entry.TryGetProperty("movie", out var movieEl) && movieEl.ValueKind == JsonValueKind.Object; + var hasShow = entry.TryGetProperty("show", out var showEl) && showEl.ValueKind == JsonValueKind.Object; + if (hasMovie && hasShow) continue; + if (hasMovie) { mediaObj = movieEl; mediaType = "movie"; } + else if (hasShow) { mediaObj = showEl; mediaType = "series"; } + else if (entry.TryGetProperty("ids", out _) && declaredType != null) + { mediaObj = entry; mediaType = declaredType; } + if (declaredType != null && mediaType != declaredType) continue; if (mediaObj == null) continue; @@ -303,7 +331,7 @@ private static ListFetchResult ParseTraktResponse(string json) if (m.TryGetProperty("year", out var yearEl) && yearEl.ValueKind == JsonValueKind.Number) year = yearEl.GetInt32(); - items.Add(new ListItem(title, imdbId.ToLowerInvariant(), year, null)); + items.Add(new ListItem(title, imdbId.ToLowerInvariant(), year, mediaType)); } } catch @@ -356,6 +384,19 @@ private static async Task FetchTmdbAsync( return Fail($"TMDB returned HTTP {(int)resp.StatusCode}."); var json = await resp.Content.ReadAsStringAsync(ct); + return ParseTmdbResponse(json); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + return Fail($"Could not fetch TMDB list: {ex.Message}"); + } + } + + internal static ListFetchResult ParseTmdbResponse(string json) + { + try + { using var doc = JsonDocument.Parse(json); if (!doc.RootElement.TryGetProperty("items", out var itemsEl)) @@ -370,26 +411,23 @@ private static async Task FetchTmdbAsync( : null; if (string.IsNullOrWhiteSpace(title)) continue; - var mediaType = entry.TryGetProperty("media_type", out var mt) - ? mt.GetString() ?? "movie" : "movie"; - - if (!entry.TryGetProperty("id", out var idEl) || idEl.ValueKind != JsonValueKind.Number) - continue; - - var rawId = $"tmdb_{idEl.GetInt32()}"; - + var hasType = entry.TryGetProperty("media_type", out var mt); + var mediaType = hasType && mt.ValueKind == JsonValueKind.String + ? NormalizeMediaType(mt.GetString()) + : !hasType && entry.TryGetProperty("title", out var movieTitle) + && movieTitle.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(movieTitle.GetString()) ? "movie" : null; + // V3 movie lists omit media_type; a name-only entry is ambiguous. + if (mediaType == null) continue; + if (!entry.TryGetProperty("id", out var idEl) || idEl.ValueKind != JsonValueKind.Number + || !idEl.TryGetInt32(out var id) || id <= 0) continue; + var rawId = $"tmdb_{id}"; int? year = null; - if (entry.TryGetProperty("release_date", out var rd) && rd.ValueKind == JsonValueKind.String) - { - var dateStr = rd.GetString(); - if (dateStr?.Length >= 4 && int.TryParse(dateStr.Substring(0, 4), out var y)) - year = y; - } - else if (entry.TryGetProperty("first_air_date", out var fad) && fad.ValueKind == JsonValueKind.String) + var dateField = mediaType == "series" ? "first_air_date" : "release_date"; + if (entry.TryGetProperty(dateField, out var dateEl) && dateEl.ValueKind == JsonValueKind.String) { - var dateStr = fad.GetString(); - if (dateStr?.Length >= 4 && int.TryParse(dateStr.Substring(0, 4), out var y)) - year = y; + var date = dateEl.GetString(); + if (date?.Length >= 4 && int.TryParse(date.Substring(0, 4), out var y)) year = y; } items.Add(new ListItem(title, rawId, year, mediaType)); @@ -403,11 +441,8 @@ private static async Task FetchTmdbAsync( return new ListFetchResult { Ok = true, Items = items, DisplayName = listName }; } - catch (OperationCanceledException) { throw; } - catch (Exception ex) - { - return Fail($"Could not fetch TMDB list: {ex.Message}"); - } + catch (Exception ex) when (ex is JsonException or InvalidOperationException or KeyNotFoundException) + { return Fail("Could not parse TMDB response."); } } // ── AniList ────────────────────────────────────────────────────────────── @@ -443,6 +478,7 @@ private static async Task FetchAnilistAsync( title { romaji english native } startDate { year } type + format } } } @@ -470,6 +506,19 @@ private static async Task FetchAnilistAsync( } var json = await resp.Content.ReadAsStringAsync(ct); + return ParseAnilistResponse(json, username); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + return Fail($"Could not fetch AniList: {ex.Message}"); + } + } + + internal static ListFetchResult ParseAnilistResponse(string json, string username) + { + try + { using var doc = JsonDocument.Parse(json); // Check for GraphQL errors @@ -486,7 +535,7 @@ private static async Task FetchAnilistAsync( .GetProperty("MediaListCollection") .GetProperty("lists"); - var items = new List<(string title, string? titleEnglish, string? titleRomaji, int? year, int anilistId)>(); + var items = new List(); foreach (var list in lists.EnumerateArray()) { @@ -494,6 +543,16 @@ private static async Task FetchAnilistAsync( foreach (var entry in entries.EnumerateArray()) { var media = entry.GetProperty("media"); + if (!media.TryGetProperty("type", out var typeEl) || typeEl.GetString() != "ANIME") continue; + var format = media.TryGetProperty("format", out var formatEl) && formatEl.ValueKind == JsonValueKind.String + ? formatEl.GetString() : null; + var mediaType = format switch + { + "MOVIE" => "movie", + "TV" or "TV_SHORT" or "OVA" or "ONA" or "SPECIAL" => "series", + _ => null + }; + if (mediaType == null) continue; var titleObj = media.GetProperty("title"); var titleEnglish = titleObj.TryGetProperty("english", out var te) ? te.GetString() : null; var titleRomaji = titleObj.TryGetProperty("romaji", out var tr) ? tr.GetString() : null; @@ -508,7 +567,7 @@ private static async Task FetchAnilistAsync( && idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : 0; - items.Add((title, titleEnglish, titleRomaji, year, anilistId)); + if (anilistId > 0) items.Add(new ListItem(title, $"anilist:{anilistId}", year, mediaType)); } } @@ -518,25 +577,21 @@ private static async Task FetchAnilistAsync( // Return items with native AniList IDs (anilist:XXX) — resolution // happens downstream via IdResolverService in UserCatalogSyncService. // The official Emby AniList plugin uses these numeric IDs. - var result = items - .Where(i => i.anilistId > 0) - .Select(i => new ListItem(i.title, $"anilist:{i.anilistId}", i.year, "tv")) - .ToList(); - - if (result.Count == 0) - return Fail($"AniList user '{username}' has no anime with valid IDs."); - - return new ListFetchResult { Ok = true, Items = result }; - } - catch (OperationCanceledException) { throw; } - catch (Exception ex) - { - return Fail($"Could not fetch AniList: {ex.Message}"); + return new ListFetchResult { Ok = true, Items = items }; } + catch (Exception ex) when (ex is JsonException or InvalidOperationException or KeyNotFoundException) + { return Fail("Could not parse AniList response."); } } // ── Helpers ────────────────────────────────────────────────────────────── + private static string? NormalizeMediaType(string? type) => type?.ToLowerInvariant() switch + { + "movie" => "movie", + "show" or "series" or "tv" => "series", + _ => null + }; + private static ListFetchResult Fail(string error) => new() { Ok = false, diff --git a/Tasks/CatalogSyncTask.cs b/Tasks/CatalogSyncTask.cs index c07c049..ec3c8b6 100755 --- a/Tasks/CatalogSyncTask.cs +++ b/Tasks/CatalogSyncTask.cs @@ -453,9 +453,9 @@ private async Task UpsertItemsAsync( try { - await db.BulkUpsertCatalogItemsAsync(items, cancellationToken); + var accepted = await db.BulkUpsertCatalogItemsAsync(items, cancellationToken); _logger.LogInformation( - "[InfiniteDrive] Bulk upserted {Count} catalog items", items.Count); + "[InfiniteDrive] Bulk upserted {Count}/{Total} catalog items", accepted, items.Count); } catch (Exception ex) { diff --git a/Tests/CatalogIdentityPersistenceTests.cs b/Tests/CatalogIdentityPersistenceTests.cs new file mode 100644 index 0000000..96dda7c --- /dev/null +++ b/Tests/CatalogIdentityPersistenceTests.cs @@ -0,0 +1,123 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using InfiniteDrive.Data; +using InfiniteDrive.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace InfiniteDrive.Tests; + +public sealed class CatalogIdentityPersistenceTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task WrongTypeRefreshCannotResetManagedIdentityOrSafeguards(bool bulk) + { + using var h = new Harness(); + var original = h.Series(); + original.RetryCount = 2; + original.NextRetryAt = 1900000000; + original.BlockedAt = "2026-09-30T12:00:00Z"; + original.BlockedBy = "test-user"; + await h.Db.UpsertCatalogItemAsync(original); + var stale = new CatalogItem { AioId = original.AioId, Source = original.Source, + MediaType = "movie", Title = original.Title, ItemState = ItemState.Queued }; + if (bulk) Assert.Equal(0, await h.Db.BulkUpsertCatalogItemsAsync(new[] { stale })); + else + { + var error = await Assert.ThrowsAsync(() => h.Db.UpsertCatalogItemAsync(stale)); + Assert.Equal("managed_identity_conflict", error.Message); + } + var actual = (await h.Db.GetCatalogItemByAioIdAsync(original.AioId))!; + Assert.Equal(original.Id, actual.Id); + Assert.Equal("series", actual.MediaType); + Assert.Equal(2026, actual.Year); + Assert.Equal("252105", actual.TmdbId); + Assert.Equal(original.StrmPath, actual.StrmPath); + Assert.Equal(2, actual.RetryCount); + Assert.Equal(original.NextRetryAt, actual.NextRetryAt); + Assert.Equal(original.BlockedAt, actual.BlockedAt); + Assert.Equal(original.BlockedBy, actual.BlockedBy); + Assert.Equal(original.UpdatedAt, actual.UpdatedAt); + } + + [Fact] + public async Task SameTypeSparseRefreshKeepsKnownYearAndTmdb() + { + using var h = new Harness(); var original = h.Series(); + await h.Db.UpsertCatalogItemAsync(original); + await h.Db.UpsertCatalogItemAsync(new CatalogItem { AioId = original.AioId, + Source = original.Source, MediaType = "series", Title = "Updated title" }); + var actual = (await h.Db.GetCatalogItemByAioIdAsync(original.AioId))!; + Assert.Equal("Updated title", actual.Title); + Assert.Equal(2026, actual.Year); Assert.Equal("252105", actual.TmdbId); + Assert.Equal(original.StrmPath, actual.StrmPath); + } + + [Fact] + public async Task BulkIdentityConflictSkipsOnlyConflictingRow() + { + using var h = new Harness(); var original = h.Series(); + await h.Db.UpsertCatalogItemAsync(original); + var unrelated = new CatalogItem { AioId = "tt0000002", Source = "aiostreams", MediaType = "movie", Title = "Original" }; + await h.Db.UpsertCatalogItemAsync(unrelated); unrelated.Title = "Valid update"; + var wrong = new CatalogItem { AioId = original.AioId, Source = original.Source, MediaType = "movie", Title = "Wrong" }; + Assert.Equal(1, await h.Db.BulkUpsertCatalogItemsAsync(new[] { unrelated, wrong })); + Assert.Equal("Valid update", (await h.Db.GetCatalogItemByAioIdAsync(unrelated.AioId))!.Title); + Assert.Equal("series", (await h.Db.GetCatalogItemByAioIdAsync(original.AioId))!.MediaType); + } + + [Fact] + public async Task OwnedRetirementRemainsRetiredAndCannotChangeFamily() + { + using var h = new Harness(); var original = h.Series(); + await h.Db.UpsertCatalogItemAsync(original); + await h.Db.RetireCatalogItemForOwnedMediaAsync(original.Id, "/owned/series"); + var wrong = new CatalogItem { AioId = original.AioId, Source = original.Source, MediaType = "movie", Title = "Wrong" }; + await Assert.ThrowsAsync(() => h.Db.UpsertCatalogItemAsync(wrong)); + var actual = (await h.Db.GetCatalogItemByAioIdAsync(original.AioId))!; + Assert.Equal(ItemState.Retired, actual.ItemState); Assert.Null(actual.StrmPath); + Assert.Equal("library", actual.LocalSource); Assert.Equal("/owned/series", actual.LocalPath); + Assert.Equal("series", actual.MediaType); + } + + [Theory] + [InlineData("anime", "series")] + [InlineData("series", "anime")] + public async Task CompatibleEpisodeFamilyRefreshPreservesManagedDestinationType(string existing, string incoming) + { + using var h = new Harness(); var original = h.Series(); original.MediaType = existing; + await h.Db.UpsertCatalogItemAsync(original); + var refresh = new CatalogItem { AioId = original.AioId, Source = original.Source, MediaType = incoming, Title = "New title" }; + Assert.Equal(1, await h.Db.BulkUpsertCatalogItemsAsync(new[] { refresh })); + var actual = (await h.Db.GetCatalogItemByAioIdAsync(original.AioId))!; + Assert.Equal(existing, actual.MediaType); Assert.Equal(original.StrmPath, actual.StrmPath); + Assert.Equal("New title", actual.Title); + } + + [Fact] + public async Task UnpublishedRowsAndSeparateSourcesCanStillAcceptCatalogIntent() + { + using var h = new Harness(); var pending = h.Series(); pending.StrmPath = null; + await h.Db.UpsertCatalogItemAsync(pending); + pending.MediaType = "movie"; await h.Db.UpsertCatalogItemAsync(pending); + Assert.Equal("movie", (await h.Db.GetCatalogItemByAioIdAsync(pending.AioId))!.MediaType); + var separate = h.Series(); separate.Source = "aiostreams"; + await h.Db.UpsertCatalogItemAsync(separate); + Assert.Equal("series", (await h.Db.GetImportCatalogByIdAsync(separate.Id))!.MediaType); + } + + private sealed class Harness : IDisposable + { + private readonly string _path = Path.Combine(Path.GetTempPath(), "catalog-identity-" + Guid.NewGuid().ToString("N")); + public DatabaseManager Db { get; } + public Harness() { SqliteTestRuntime.EnsureInitialized(); Directory.CreateDirectory(_path); Db = new DatabaseManager(_path, NullLogger.Instance); Db.Initialise(); } + public CatalogItem Series() => new() { AioId = "tt32149571", Source = "external_list", Title = "Golden Axe", + MediaType = "series", Year = 2026, TmdbId = "252105", StrmPath = "/managed/shows/Golden Axe", + LocalPath = "/managed/shows/Golden Axe", LocalSource = "strm", ItemState = ItemState.Queued, + UpdatedAt = "2026-09-30T12:04:46Z" }; + public void Dispose() { try { Directory.Delete(_path, true); } catch { } } + } +} diff --git a/Tests/ExternalListIdentityTests.cs b/Tests/ExternalListIdentityTests.cs new file mode 100644 index 0000000..ecf596e --- /dev/null +++ b/Tests/ExternalListIdentityTests.cs @@ -0,0 +1,119 @@ +using System.Linq; +using System.Text.Json; +using InfiniteDrive.Models; +using InfiniteDrive.Services; +using Xunit; + +namespace InfiniteDrive.Tests; + +public sealed class ExternalListIdentityTests +{ + [Theory] + [InlineData("movie", "movie", false)] + [InlineData("show", "series", true)] + public void TraktWrappersKeepIdentityYearAndImportFamily(string wrapper, string type, bool episodic) + { + var result = ListFetcher.ParseTraktResponse("""[{"type":"__WRAPPER__","__WRAPPER__":{"title":"Example","year":2024,"ids":{"imdb":"tt1234567"}}}]""".Replace("__WRAPPER__", wrapper)); + var item = Assert.Single(result.Items); + Assert.Equal("tt1234567", item.AioId); + Assert.Equal(2024, item.Year); + Assert.Equal(type, item.MediaType); + Assert.Equal(episodic, ImportInventory.IsSeries(new CatalogItem { MediaType = item.MediaType! })); + } + + [Theory] + [InlineData("movie", "movie")] + [InlineData("show", "series")] + public void TraktFlatEntriesRequireAnExplicitType(string rawType, string expected) + { + var result = ListFetcher.ParseTraktResponse("""[{"title":"Example","year":2024,"type":"__RAWTYPE__","ids":{"imdb":"tt1234567"}}]""".Replace("__RAWTYPE__", rawType)); + Assert.Equal(expected, Assert.Single(result.Items).MediaType); + } + + [Theory] + [InlineData("""[{"title":"Unknown type","ids":{"imdb":"tt1234567"}}]""")] + [InlineData("""[{"type":"episode","show":{"title":"Parent","ids":{"imdb":"tt1234567"}},"episode":{"season":1,"number":2}}]""")] + [InlineData("""[{"type":"show","movie":{"title":"Contradiction","ids":{"imdb":"tt1234567"}}}]""")] + public void TraktDoesNotGuessOrPromoteUnsupportedItems(string json) + { + Assert.False(ListFetcher.ParseTraktResponse(json).Ok); + } + + [Fact] + public void TraktMixedListDoesNotRouteShowsThroughMovies() + { + var result = ListFetcher.ParseTraktResponse("""[{"movie":{"title":"Film","year":2023,"ids":{"imdb":"tt1234567"}}},{"show":{"title":"Show","year":2024,"ids":{"imdb":"tt1234568"}}}]"""); + Assert.Equal(new[] { "movie", "series" }, result.Items.Select(x => x.MediaType)); + } + + [Fact] + public void TmdbTvKeepsTvYearAndUsesEpisodeImporter() + { + var result = ListFetcher.ParseTmdbResponse("""{"name":"Mixed list","items":[{"id":203744,"name":"Sugar","media_type":"tv","release_date":"","first_air_date":"2024-04-05"}]}"""); + var item = Assert.Single(result.Items); + Assert.Equal("tmdb_203744", item.AioId); + Assert.Equal("series", item.MediaType); + Assert.Equal(2024, item.Year); + Assert.True(ImportInventory.IsSeries(new CatalogItem { MediaType = item.MediaType! })); + Assert.Equal("Mixed list", result.DisplayName); + } + + [Fact] + public void TmdbV3MovieRetainsUntypedMovieCompatibility() + { + var result = ListFetcher.ParseTmdbResponse("""{"items":[{"id":157336,"title":"Interstellar","release_date":"2014-11-05"}]}"""); + var item = Assert.Single(result.Items); + Assert.Equal("tmdb_157336", item.AioId); + Assert.Equal("movie", item.MediaType); + Assert.Equal(2014, item.Year); + } + + [Theory] + [InlineData("""{"items":[{"id":42,"name":"Ambiguous"}]}""")] + [InlineData("""{"items":[{"id":42,"name":"A person","media_type":"person"}]}""")] + public void TmdbRejectsAmbiguousOrUnsupportedTypes(string json) + { + Assert.False(ListFetcher.ParseTmdbResponse(json).Ok); + } + + [Theory] + [InlineData("MOVIE", "movie", false)] + [InlineData("TV", "series", true)] + [InlineData("TV_SHORT", "series", true)] + [InlineData("OVA", "series", true)] + [InlineData("ONA", "series", true)] + [InlineData("SPECIAL", "series", true)] + public void AnilistFormatDistinguishesFilmsFromEpisodes(string format, string type, bool episodic) + { + var result = ListFetcher.ParseAnilistResponse("""{"data":{"MediaListCollection":{"lists":[{"entries":[{"media":{"id":21519,"type":"ANIME","format":"__FORMAT__","title":{"english":"Example","romaji":"Rei"},"startDate":{"year":2016}}}]}]}}}""".Replace("__FORMAT__", format), "example-user"); + var item = Assert.Single(result.Items); + Assert.Equal("anilist:21519", item.AioId); + Assert.Equal(2016, item.Year); + Assert.Equal(type, item.MediaType); + Assert.Equal(episodic, ImportInventory.IsSeries(new CatalogItem { MediaType = item.MediaType! })); + } + + [Theory] + [InlineData("ANIME", "UNKNOWN")] + [InlineData("MANGA", "TV")] + [InlineData("ANIME", "MUSIC")] + public void AnilistRejectsUnsupportedFormatOrNonAnime(string type, string format) + { + var result = ListFetcher.ParseAnilistResponse("""{"data":{"MediaListCollection":{"lists":[{"entries":[{"media":{"id":1,"type":"__TYPE__","format":"__FORMAT__","title":{"english":"Unsupported"}}}]}]}}}""".Replace("__TYPE__", type).Replace("__FORMAT__", format), "example-user"); + Assert.False(result.Ok); + } + + [Theory] + [InlineData("{}")] + [InlineData("{\"data\":{\"MediaListCollection\":null}}")] + public void AnilistMalformedCollectionIsReportedAsAFailure(string json) + { + Assert.False(ListFetcher.ParseAnilistResponse(json, "example-user").Ok); + } + + [Fact] + public void AnilistGraphqlErrorDoesNotBecomeAnEmptySuccessfulList() + { + Assert.False(ListFetcher.ParseAnilistResponse("""{"errors":[{"message":"Not found"}]}""", "example-user").Ok); + } +} diff --git a/Tests/ImportReconciliationTests.cs b/Tests/ImportReconciliationTests.cs index 86c4eca..c5c014a 100644 --- a/Tests/ImportReconciliationTests.cs +++ b/Tests/ImportReconciliationTests.cs @@ -15,7 +15,7 @@ namespace InfiniteDrive.Tests; public sealed class ImportReconciliationTests { - static ImportReconciliationTests() => SQLitePCLEx.raw.SetProvider(new SQLitePCLEx.SQLite3Provider_sqlite3()); + static ImportReconciliationTests() => SqliteTestRuntime.EnsureInitialized(); private static readonly DateTimeOffset Now = DateTimeOffset.Parse("2026-09-29T18:00:00Z"); private static ImportEpisode Ep(int n = 1) => new() { Key = $"aired:1:{n}", Season = 1, Episode = n, Released = Now.AddDays(-1), Eligible = true }; diff --git a/Tests/MdblistIdentityTests.cs b/Tests/MdblistIdentityTests.cs new file mode 100644 index 0000000..f3be5a4 --- /dev/null +++ b/Tests/MdblistIdentityTests.cs @@ -0,0 +1,47 @@ +using System.Text.Json; +using InfiniteDrive.Services; +using Xunit; + +namespace InfiniteDrive.Tests; + +public sealed class MdblistIdentityTests +{ + [Theory] + [InlineData("show", "series")] + [InlineData("series", "series")] + [InlineData("movie", "movie")] + public void PublicArrayPreservesMediaTypeAndReleaseYear(string sourceType, string expected) + { + using var doc = JsonDocument.Parse($$"""{"title":"Sugar","imdb_id":"tt16418808","mediatype":"{{sourceType}}","release_year":2024}"""); + var item = ListFetcher.ParseMdblistItem(doc.RootElement); + Assert.NotNull(item); + Assert.Equal(expected, item.MediaType); + Assert.Equal(2024, item.Year); + } + + [Theory] + [InlineData("movie")] + [InlineData("series")] + public void GroupedResponseSuppliesTypeWhenIndividualItemOmitsIt(string bucket) + { + using var doc = JsonDocument.Parse("""{"title":"Example","imdb_id":"tt1234567","year":"2024"}"""); + var item = ListFetcher.ParseMdblistItem(doc.RootElement, bucket); + Assert.NotNull(item); + Assert.Equal(bucket, item.MediaType); + Assert.Equal(2024, item.Year); + } + + [Fact] + public void UntypedArrayEntryNeverDefaultsToMovie() + { + using var doc = JsonDocument.Parse("""{"title":"Loki","imdb_id":"tt9140554"}"""); + Assert.Null(ListFetcher.ParseMdblistItem(doc.RootElement)); + } + + [Fact] + public void ContradictoryBucketAndItemTypeAreRejected() + { + using var doc = JsonDocument.Parse("""{"title":"Loki","imdb_id":"tt9140554","mediatype":"show"}"""); + Assert.Null(ListFetcher.ParseMdblistItem(doc.RootElement, "movie")); + } +} diff --git a/Tests/SqliteTestRuntime.cs b/Tests/SqliteTestRuntime.cs new file mode 100644 index 0000000..70f70b5 --- /dev/null +++ b/Tests/SqliteTestRuntime.cs @@ -0,0 +1,17 @@ +namespace InfiniteDrive.Tests; + +internal static class SqliteTestRuntime +{ + private static readonly object Gate = new(); + private static bool _initialized; + + internal static void EnsureInitialized() + { + lock (Gate) + { + if (_initialized) return; + SQLitePCLEx.raw.SetProvider(new SQLitePCLEx.SQLite3Provider_sqlite3()); + _initialized = true; + } + } +} diff --git a/docs/EXTERNAL_LISTS.md b/docs/EXTERNAL_LISTS.md index 3adbc6e..dfae90e 100644 --- a/docs/EXTERNAL_LISTS.md +++ b/docs/EXTERNAL_LISTS.md @@ -18,6 +18,78 @@ intent; user lists belong to their Emby user and may create native playlist state. Adding or refreshing a list fetches it through `ListFetcher`, normalizes provider IDs, and records its sync/error state. +## Media identity + +Each supported parser returns `movie` or `series`, along with its provider ID +and available release year. These values select different metadata and import +paths: a show must reach the episode importer. Provider terms such as `show` and +`tv` cannot be passed through unchanged or replaced with a movie default. + +| Source | Identity handling | +|---|---| +| MDBList | Public JSON arrays use `mediatype` and `release_year`. `show` becomes `series`; grouped `movies`/`shows` responses supply the type when an item omits it. Contradictory buckets and untyped array entries are skipped. | +| Trakt | `movie` and `show` wrappers supply the type; an explicit `type` must agree. Flat entries require an explicit supported type. Episode/history and other unsupported entries are skipped rather than promoted to a whole series. | +| TMDB | Explicit `media_type=tv` becomes `series` and uses `first_air_date` for the year. Movies use `release_date`. V3 movie list entries without a type retain movie compatibility when they have a movie `title`; an untyped name-only entry is skipped. | +| AniList | The query requests both `type` and `format`. `ANIME`/`MOVIE` becomes a movie; TV, TV_SHORT, OVA, ONA and SPECIAL become series. Manga, music and unknown formats are skipped. Native `anilist:` IDs remain available for downstream ID enrichment. | + +AniList's `ANIME` describes its media category; it does not mean every entry is a +television series. Episodic AniList entries use the series import path and series +library destination. This does not enable a new absolute-numbered anime policy. +Season/episode identity still depends on the configured metadata provider. + +Release **0.42.9** includes these parser repairs. On September 30, 2026, +synthetic response tests reproduced the original +Trakt, TMDB and AniList defects before the fixes. The tests exercise the production +parsers and check that their output agrees with the episode import contract. +The repaired 0.42.9 pinned-ABI build passed all 165 tests and published +successfully, including eight real SQLite cases for identity persistence. These +checks do not establish live provider availability, successful enrichment or playback. + +### Existing misclassified rows + +Refreshing a list does **not** automatically convert an existing movie row into +a series. Parser fixes protect newly imported rows; existing rows retain their +media type. Generic single and bulk catalog upserts reject a media-type change +for a row with a managed STRM path or an owned-media retirement. This prevents a +stale worker DTO from undoing a repaired destination; it reports +`managed_identity_conflict` rather than pretending to convert the row. Bulk sync +skips only conflicting rows and reports the accepted count. Compatible +anime/series updates retain the existing managed destination type. A refresh +that omits year or TMDB ID keeps the existing known values. + +Previously misclassified rows need a separate, backed-up repair of +their media type and managed destination. Existing movie STRMs must never be +renamed into fake episodes. Preserve list memberships, user blocks, retry history +and owned media; verify episode imports separately from playback. + +### September 30, 2026 — 0.42.9 + +- Preserve movie/series identity and release year across supported list providers. +- Reject incompatible managed catalog upserts; preserve known year/TMDB values + when a refresh omits them. +- Keep existing rows, files, import journals, blocks and owned-media retirement + safeguards. There is no automatic database migration or STRM wipe. + +## Other implementations + +We checked other list consumers while investigating these repairs: + +- [Kometa's MDBList parser](https://github.com/Kometa-Team/Kometa/blob/master/modules/mdblist.py) + reads `mediatype`/`type` and assigns separate movie/show ID families. Its + [TMDB list builder](https://github.com/Kometa-Team/Kometa/blob/master/modules/tmdb.py) + also distinguishes movies from shows. +- [HomeScreenCompanion's list fetcher](https://github.com/soderlund91/HomeScreenCompanion/blob/main/HomeScreenCompanion/ListFetcher.cs) + collapses MDBList and Trakt results to IMDb IDs. + Its [consumer](https://github.com/soderlund91/HomeScreenCompanion/blob/main/HomeScreenCompanion/HomeScreenCompanionTask.cs) + looks up existing Movie/Series items for list selection. That does not establish + that ignoring the type is safe when creating a new import. +- [Kometa's AniList user-list reader](https://github.com/Kometa-Team/Kometa/blob/master/modules/anilist.py) + returns AniList IDs for later conversion. It does not assign every entry a + television import type at the list-parser boundary. + +These are source comparisons, not runtime tests of those projects. Their +selection and conversion workflows differ from InfiniteDrive's STRM creation. + ## Catalog-less behavior Catalog synchronization evaluates list state before deciding that no providers diff --git a/docs/README.md b/docs/README.md index 9323c31..82cda38 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,4 +26,4 @@ old test plans and dated reports. These are not current configuration instructions. The implementation and current guides take precedence over historical claims. -Updated September 29, 2026 for the 0.42.8 source tree. +Updated September 30, 2026 for the 0.42.9 source tree. diff --git a/plugin.json b/plugin.json index 5a810fb..f7b6016 100755 --- a/plugin.json +++ b/plugin.json @@ -5,7 +5,7 @@ "overview": "InfiniteDrive discovers your AIOStreams catalog, writes .strm files, and resolves debrid URLs on demand. Like the Infinite Improbability Drive: a stream will appear. Probably. No external processes needed.", "owner": "InfiniteDrive", "category": "General", - "version": "0.42.8.0", + "version": "0.42.9.0", "targetAbi": "4.10.0.40", "framework": "net8.0", "imageUrl": ""