diff --git a/src/Jellyfin.Plugin.Multilang/Data/TranslationStore.Cleanup.cs b/src/Jellyfin.Plugin.Multilang/Data/TranslationStore.Cleanup.cs new file mode 100644 index 0000000..09daaee --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Data/TranslationStore.Cleanup.cs @@ -0,0 +1,183 @@ +using Jellyfin.Plugin.Multilang; + +namespace Jellyfin.Plugin.Multilang.Data; + +public sealed partial class TranslationStore +{ + public HashSet GetTrackedItemIds() + { + using var con = Open(); + con.Open(); + using var cmd = con.CreateCommand(); + cmd.CommandText = "SELECT item_id FROM facts;"; + using var reader = cmd.ExecuteReader(); + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + while (reader.Read()) + ids.Add(reader.GetString(0)); + return ids; + } + + public void CleanupNotInLibrary(IReadOnlySet liveItemIds) + { + using var con = Open(); + con.Open(); + using var tx = con.BeginTransaction(); + using var select = con.CreateCommand(); + select.Transaction = tx; + select.CommandText = "SELECT item_id FROM facts;"; + using var reader = select.ExecuteReader(); + var stale = new List(); + while (reader.Read()) + { + var itemId = reader.GetString(0); + if (!liveItemIds.Contains(itemId)) + stale.Add(itemId); + } + + reader.Close(); + foreach (var itemId in stale) + { + using var delete = con.CreateCommand(); + delete.Transaction = tx; + delete.CommandText = @" +DELETE FROM facts WHERE item_id = $item_id; +DELETE FROM translations WHERE item_id = $item_id; +DELETE FROM assets WHERE item_id = $item_id;"; + delete.Parameters.AddWithValue("$item_id", itemId); + delete.ExecuteNonQuery(); + } + + tx.Commit(); + CleanupUnreferencedAssetFiles(); + } + + public LocalCleanupResult CleanupForConfiguration(IReadOnlySet liveItemIds, IEnumerable allowedLanguages, bool localAssetStorage) + { + CleanupNotInLibrary(liveItemIds); + + var allowed = allowedLanguages + .Where(l => !string.IsNullOrWhiteSpace(l)) + .Select(l => l.Trim()) + .Append(MultilangConstants.OriginalAction) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var translationsDeleted = DeleteRowsOutsideLanguages("translations", allowed); + var genresDeleted = DeleteRowsOutsideLanguages("genres", allowed); + var assetsDeleted = DeleteRowsOutsideLanguages("assets", allowed); + var modeRowsDeleted = localAssetStorage ? 0 : DeleteLocalAssetRows(resetMissingCheckedAt: true); + var assetFilesDeleted = CleanupUnreferencedAssetFiles(); + return new LocalCleanupResult(translationsDeleted, assetsDeleted + modeRowsDeleted, genresDeleted, assetFilesDeleted); + } + + private int DeleteRowsOutsideLanguages(string table, IReadOnlyList allowedLanguages) + { + using var con = Open(); + con.Open(); + using var cmd = con.CreateCommand(); + var langParams = AddParams(cmd, allowedLanguages, "$lang"); + cmd.CommandText = $"DELETE FROM {table} WHERE lang NOT IN ({langParams});"; + return cmd.ExecuteNonQuery(); + } + + private int DeleteLocalAssetRows(bool resetMissingCheckedAt) + { + using var con = Open(); + con.Open(); + using var tx = con.BeginTransaction(); + var affected = new List(); + using (var select = con.CreateCommand()) + { + select.Transaction = tx; + select.CommandText = "SELECT DISTINCT item_id FROM assets WHERE path_low LIKE '/multilang/assets/%';"; + using var reader = select.ExecuteReader(); + while (reader.Read()) + affected.Add(reader.GetString(0)); + } + + int deleted; + using (var delete = con.CreateCommand()) + { + delete.Transaction = tx; + delete.CommandText = "DELETE FROM assets WHERE path_low LIKE '/multilang/assets/%';"; + deleted = delete.ExecuteNonQuery(); + } + + if (resetMissingCheckedAt && affected.Count > 0) + { + using var update = con.CreateCommand(); + update.Transaction = tx; + var idParams = AddParams(update, affected, "$item"); + update.CommandText = $"UPDATE facts SET missing_checked_at = 0 WHERE item_id IN ({idParams});"; + update.ExecuteNonQuery(); + } + + tx.Commit(); + return deleted; + } + + public int CleanupUnreferencedAssetFiles() + { + if (!Directory.Exists(_assetsDir)) + return 0; + + var referenced = GetReferencedLocalAssetPaths(); + var deleted = 0; + foreach (var file in Directory.EnumerateFiles(_assetsDir, "*", SearchOption.AllDirectories)) + { + var full = Path.GetFullPath(file); + if (referenced.Contains(full)) + continue; + + File.Delete(file); + deleted++; + } + + foreach (var dir in Directory.EnumerateDirectories(_assetsDir, "*", SearchOption.AllDirectories).OrderByDescending(d => d.Length)) + { + if (!Directory.EnumerateFileSystemEntries(dir).Any()) + Directory.Delete(dir); + } + + return deleted; + } + + private HashSet GetReferencedLocalAssetPaths() + { + using var con = Open(); + con.Open(); + using var cmd = con.CreateCommand(); + cmd.CommandText = "SELECT path FROM assets;"; + using var reader = cmd.ExecuteReader(); + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + while (reader.Read()) + { + var path = reader.GetString(0); + if (TryNormalizeLocalAssetPath(path, out var fullPath)) + result.Add(fullPath); + } + + return result; + } + + public bool TryNormalizeLocalAssetPath(string path, out string fullPath) + { + fullPath = string.Empty; + if (string.IsNullOrWhiteSpace(path) || Uri.TryCreate(path, UriKind.Absolute, out _)) + return false; + + var relative = path; + if (relative.StartsWith(LocalAssetUrlPrefix, StringComparison.OrdinalIgnoreCase)) + relative = relative[LocalAssetUrlPrefix.Length..]; + else if (Path.IsPathRooted(relative)) + return false; + + var candidate = Path.GetFullPath(Path.Combine(_assetsDir, relative)); + var root = Path.GetFullPath(_assetsDir) + Path.DirectorySeparatorChar; + if (!candidate.StartsWith(root, StringComparison.OrdinalIgnoreCase)) + return false; + + fullPath = candidate; + return true; + } +} diff --git a/src/Jellyfin.Plugin.Multilang/Data/TranslationStore.ImportExport.cs b/src/Jellyfin.Plugin.Multilang/Data/TranslationStore.ImportExport.cs new file mode 100644 index 0000000..851de78 --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Data/TranslationStore.ImportExport.cs @@ -0,0 +1,225 @@ +using System.Globalization; +using Jellyfin.Plugin.Multilang; +using Microsoft.Data.Sqlite; + +namespace Jellyfin.Plugin.Multilang.Data; + +public sealed partial class TranslationStore +{ + public void ExportTranslationDatabase(string destinationPath) + { + if (File.Exists(destinationPath)) + File.Delete(destinationPath); + + using var destination = new SqliteConnection($"Data Source={destinationPath}"); + destination.Open(); + using (var schema = destination.CreateCommand()) + { + schema.CommandText = SchemaSql; + schema.ExecuteNonQuery(); + } + + using var source = Open(); + source.Open(); + CopyTable(source, destination, "facts", "item_id, tmdb_id, kind, original_title, original_language, original_language_all, origin_countries_json, production_countries_json, spoken_languages_json, audio_track_language, genre_tmdb_ids_json, missing_checked_at, full_checked_at"); + CopyTable(source, destination, "translations", "item_id, lang, field, text"); + CopyTable(source, destination, "assets", "item_id, lang, kind, path, path_low, updated_at"); + CopyTable(source, destination, "genres", "tmdb_id, media, lang, name, name_norm"); + CopyTable(source, destination, "scan_state", "id, last_scan_started"); + } + + public ImportDatabaseResult ImportTranslationDatabase(string sourcePath, IReadOnlySet liveItemIds, IReadOnlySet allowedLanguages) + { + var allowed = allowedLanguages.ToHashSet(StringComparer.OrdinalIgnoreCase); + allowed.Add(MultilangConstants.OriginalAction); + + using var destination = Open(); + destination.Open(); + using var source = new SqliteConnection($"Data Source={sourcePath};Mode=ReadOnly"); + source.Open(); + using var tx = destination.BeginTransaction(); + + var facts = ImportFacts(source, destination, tx, liveItemIds); + var translations = ImportTranslations(source, destination, tx, liveItemIds, allowed); + var assets = ImportAssets(source, destination, tx, liveItemIds, allowed); + var genres = ImportGenres(source, destination, tx, allowed); + tx.Commit(); + return new ImportDatabaseResult(facts, translations, assets, genres); + } + + private static void CopyTable(SqliteConnection source, SqliteConnection destination, string table, string columns) + { + using var read = source.CreateCommand(); + read.CommandText = $"SELECT {columns} FROM {table};"; + using var reader = read.ExecuteReader(); + using var tx = destination.BeginTransaction(); + while (reader.Read()) + { + using var insert = destination.CreateCommand(); + insert.Transaction = tx; + var names = columns.Split(',', StringSplitOptions.TrimEntries); + insert.CommandText = $"INSERT OR REPLACE INTO {table}({columns}) VALUES({string.Join(",", names.Select((_, i) => "$v" + i.ToString(CultureInfo.InvariantCulture)))});"; + for (var i = 0; i < names.Length; i++) + insert.Parameters.AddWithValue("$v" + i.ToString(CultureInfo.InvariantCulture), reader.GetValue(i)); + insert.ExecuteNonQuery(); + } + + tx.Commit(); + } + + private static int ImportFacts(SqliteConnection source, SqliteConnection destination, SqliteTransaction tx, IReadOnlySet liveItemIds) + { + using var read = source.CreateCommand(); + read.CommandText = @" +SELECT item_id, tmdb_id, kind, original_title, original_language, original_language_all, + origin_countries_json, production_countries_json, spoken_languages_json, audio_track_language, + genre_tmdb_ids_json, missing_checked_at, full_checked_at +FROM facts;"; + using var reader = read.ExecuteReader(); + var count = 0; + while (reader.Read()) + { + var itemId = reader.GetString(0); + if (!liveItemIds.Contains(itemId)) + continue; + using var insert = destination.CreateCommand(); + insert.Transaction = tx; + insert.CommandText = @" +INSERT INTO facts(item_id, tmdb_id, kind, original_title, original_language, original_language_all, + origin_countries_json, production_countries_json, spoken_languages_json, audio_track_language, + genre_tmdb_ids_json, missing_checked_at, full_checked_at) +VALUES($item_id, $tmdb_id, $kind, $original_title, $original_language, $original_language_all, + $origin_countries_json, $production_countries_json, $spoken_languages_json, $audio_track_language, + $genre_tmdb_ids_json, $missing_checked_at, $full_checked_at) +ON CONFLICT(item_id) DO UPDATE SET + tmdb_id = excluded.tmdb_id, + kind = excluded.kind, + original_title = excluded.original_title, + original_language = excluded.original_language, + original_language_all = excluded.original_language_all, + origin_countries_json = excluded.origin_countries_json, + production_countries_json = excluded.production_countries_json, + spoken_languages_json = excluded.spoken_languages_json, + audio_track_language = excluded.audio_track_language, + genre_tmdb_ids_json = excluded.genre_tmdb_ids_json, + missing_checked_at = excluded.missing_checked_at, + full_checked_at = excluded.full_checked_at;"; + var names = new[] + { + "$item_id", + "$tmdb_id", + "$kind", + "$original_title", + "$original_language", + "$original_language_all", + "$origin_countries_json", + "$production_countries_json", + "$spoken_languages_json", + "$audio_track_language", + "$genre_tmdb_ids_json", + "$missing_checked_at", + "$full_checked_at" + }; + for (var i = 0; i < names.Length; i++) + insert.Parameters.AddWithValue(names[i], reader.GetValue(i)); + insert.ExecuteNonQuery(); + count++; + } + + return count; + } + + private static int ImportTranslations(SqliteConnection source, SqliteConnection destination, SqliteTransaction tx, IReadOnlySet liveItemIds, IReadOnlySet allowedLanguages) + { + using var read = source.CreateCommand(); + read.CommandText = "SELECT item_id, lang, field, text FROM translations;"; + using var reader = read.ExecuteReader(); + var count = 0; + while (reader.Read()) + { + var itemId = reader.GetString(0); + var lang = reader.GetString(1); + if (!liveItemIds.Contains(itemId) || !allowedLanguages.Contains(lang)) + continue; + using var insert = destination.CreateCommand(); + insert.Transaction = tx; + insert.CommandText = @" +INSERT INTO translations(item_id, lang, field, text) +VALUES($item_id, $lang, $field, $text) +ON CONFLICT(item_id, lang, field) DO UPDATE SET text = excluded.text;"; + insert.Parameters.AddWithValue("$item_id", itemId); + insert.Parameters.AddWithValue("$lang", lang); + insert.Parameters.AddWithValue("$field", reader.GetString(2)); + insert.Parameters.AddWithValue("$text", reader.GetString(3)); + insert.ExecuteNonQuery(); + count++; + } + + return count; + } + + private static int ImportAssets(SqliteConnection source, SqliteConnection destination, SqliteTransaction tx, IReadOnlySet liveItemIds, IReadOnlySet allowedLanguages) + { + using var read = source.CreateCommand(); + read.CommandText = "SELECT item_id, lang, kind, path, path_low, updated_at FROM assets;"; + using var reader = read.ExecuteReader(); + var count = 0; + while (reader.Read()) + { + var itemId = reader.GetString(0); + var lang = reader.GetString(1); + if (!liveItemIds.Contains(itemId) || !allowedLanguages.Contains(lang)) + continue; + using var insert = destination.CreateCommand(); + insert.Transaction = tx; + insert.CommandText = @" +INSERT INTO assets(item_id, lang, kind, path, path_low, updated_at) +VALUES($item_id, $lang, $kind, $path, $path_low, $updated_at) +ON CONFLICT(item_id, lang, kind) DO UPDATE SET + path = excluded.path, + path_low = excluded.path_low, + updated_at = excluded.updated_at;"; + insert.Parameters.AddWithValue("$item_id", itemId); + insert.Parameters.AddWithValue("$lang", lang); + insert.Parameters.AddWithValue("$kind", reader.GetString(2)); + insert.Parameters.AddWithValue("$path", reader.GetString(3)); + insert.Parameters.AddWithValue("$path_low", reader.GetString(4)); + insert.Parameters.AddWithValue("$updated_at", reader.GetInt64(5)); + insert.ExecuteNonQuery(); + count++; + } + + return count; + } + + private static int ImportGenres(SqliteConnection source, SqliteConnection destination, SqliteTransaction tx, IReadOnlySet allowedLanguages) + { + using var read = source.CreateCommand(); + read.CommandText = "SELECT tmdb_id, media, lang, name, name_norm FROM genres;"; + using var reader = read.ExecuteReader(); + var count = 0; + while (reader.Read()) + { + var lang = reader.GetString(2); + if (!allowedLanguages.Contains(lang)) + continue; + using var insert = destination.CreateCommand(); + insert.Transaction = tx; + insert.CommandText = @" +INSERT INTO genres(tmdb_id, media, lang, name, name_norm) +VALUES($tmdb_id, $media, $lang, $name, $name_norm) +ON CONFLICT(tmdb_id, media, lang) DO UPDATE SET + name = excluded.name, + name_norm = excluded.name_norm;"; + insert.Parameters.AddWithValue("$tmdb_id", reader.GetInt32(0)); + insert.Parameters.AddWithValue("$media", reader.GetString(1)); + insert.Parameters.AddWithValue("$lang", lang); + insert.Parameters.AddWithValue("$name", reader.GetString(3)); + insert.Parameters.AddWithValue("$name_norm", reader.GetString(4)); + insert.ExecuteNonQuery(); + count++; + } + + return count; + } +} diff --git a/src/Jellyfin.Plugin.Multilang/Data/TranslationStore.Models.cs b/src/Jellyfin.Plugin.Multilang/Data/TranslationStore.Models.cs new file mode 100644 index 0000000..6bba5ba --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Data/TranslationStore.Models.cs @@ -0,0 +1,75 @@ +using Jellyfin.Plugin.Multilang; + +namespace Jellyfin.Plugin.Multilang.Data; + +public readonly record struct FactsStatus(bool Exists, long MissingCheckedAt, long FullCheckedAt); + +public readonly record struct AssetPresence(bool Any, bool AnyRemote); + +public readonly record struct LocalCleanupResult(int TranslationsDeleted, int AssetsDeleted, int GenresDeleted, int AssetFilesDeleted); + +public readonly record struct ImportDatabaseResult(int Facts, int Translations, int Assets, int Genres); + +public readonly record struct GenreRow(int TmdbId, string Media, string Lang, string Name); + +public sealed record FactsData( + string ItemId, + string TmdbId, + string Kind, + string OriginalTitle, + string OriginalLanguage, + string OriginalLanguageAll, + string OriginCountriesJson, + string ProductionCountriesJson, + string SpokenLanguagesJson, + string AudioTrackLanguage, + string GenreTmdbIdsJson, + long MissingCheckedAt, + long FullCheckedAt); + +public sealed class UserRulesDocument +{ + public bool Enabled { get; set; } = true; + + public string SortLocale { get; set; } = "Auto"; + + public Dictionary FallbackFieldActions { get; set; } = MultilangConstants.DefaultActionLists(); + + public bool TrustTmdbCollections { get; set; } = true; + + public UserCategoryRule[] Categories { get; set; } = []; +} + +public sealed class UserCategoryRule +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + + public string Label { get; set; } = string.Empty; + + public string CriteriaText { get; set; } = string.Empty; + + public UserCategoryRequirement[] Requirements { get; set; } = []; + + public bool MatchAllConditions { get; set; } = true; + + public string[] Scopes { get; set; } = ["M", "S", "C"]; + + public Dictionary FieldActions { get; set; } = MultilangConstants.DefaultFieldActions(); + + public Dictionary FieldActionLists { get; set; } = MultilangConstants.DefaultActionLists(); +} + +public sealed class UserCategoryRequirement +{ + public string Field { get; set; } = string.Empty; + + public string[] Fields { get; set; } = []; + + public bool UseFieldOr { get; set; } + + public string Relation { get; set; } = string.Empty; + + public bool UseOr { get; set; } + + public string[] Values { get; set; } = []; +} diff --git a/src/Jellyfin.Plugin.Multilang/Data/TranslationStore.Schema.cs b/src/Jellyfin.Plugin.Multilang/Data/TranslationStore.Schema.cs new file mode 100644 index 0000000..10b6315 --- /dev/null +++ b/src/Jellyfin.Plugin.Multilang/Data/TranslationStore.Schema.cs @@ -0,0 +1,75 @@ +namespace Jellyfin.Plugin.Multilang.Data; + +public sealed partial class TranslationStore +{ + private const string SchemaSql = @" +CREATE TABLE IF NOT EXISTS facts ( + item_id TEXT PRIMARY KEY, + tmdb_id TEXT NOT NULL, + kind TEXT NOT NULL, + original_title TEXT NOT NULL DEFAULT '', + original_language TEXT NOT NULL DEFAULT '', + original_language_all TEXT NOT NULL DEFAULT '', + origin_countries_json TEXT NOT NULL DEFAULT '[]', + production_countries_json TEXT NOT NULL DEFAULT '[]', + spoken_languages_json TEXT NOT NULL DEFAULT '[]', + audio_track_language TEXT NOT NULL DEFAULT '', + genre_tmdb_ids_json TEXT NOT NULL DEFAULT '[]', + missing_checked_at INTEGER NOT NULL DEFAULT 0, + full_checked_at INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_facts_tmdb ON facts(tmdb_id); +CREATE INDEX IF NOT EXISTS idx_facts_kind ON facts(kind); +CREATE INDEX IF NOT EXISTS idx_facts_missing ON facts(missing_checked_at); +CREATE INDEX IF NOT EXISTS idx_facts_full ON facts(full_checked_at); + +CREATE TABLE IF NOT EXISTS translations ( + item_id TEXT NOT NULL, + lang TEXT NOT NULL, + field TEXT NOT NULL, + text TEXT NOT NULL, + PRIMARY KEY (item_id, lang, field) +); +CREATE INDEX IF NOT EXISTS idx_translations_item ON translations(item_id); + +CREATE TABLE IF NOT EXISTS assets ( + item_id TEXT NOT NULL, + lang TEXT NOT NULL, + kind TEXT NOT NULL, + path TEXT NOT NULL, + path_low TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL, + PRIMARY KEY (item_id, lang, kind) +); +CREATE INDEX IF NOT EXISTS idx_assets_item ON assets(item_id); + +CREATE TABLE IF NOT EXISTS genres ( + tmdb_id INTEGER NOT NULL, + media TEXT NOT NULL, + lang TEXT NOT NULL, + name TEXT NOT NULL, + name_norm TEXT NOT NULL, + PRIMARY KEY (tmdb_id, media, lang) +); +CREATE INDEX IF NOT EXISTS idx_genres_media_lang ON genres(media, lang); +CREATE INDEX IF NOT EXISTS idx_genres_name ON genres(name_norm, media, lang); + +CREATE TABLE IF NOT EXISTS user_rules ( + user_id TEXT PRIMARY KEY, + rules_json TEXT NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_display_langs ( + user_id TEXT PRIMARY KEY, + langs_json TEXT NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS scan_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + last_scan_started INTEGER NOT NULL DEFAULT 0 +); +INSERT OR IGNORE INTO scan_state(id, last_scan_started) VALUES (1, 0); +"; +} diff --git a/src/Jellyfin.Plugin.Multilang/Data/TranslationStore.cs b/src/Jellyfin.Plugin.Multilang/Data/TranslationStore.cs index bcfbcd9..fb4854e 100644 --- a/src/Jellyfin.Plugin.Multilang/Data/TranslationStore.cs +++ b/src/Jellyfin.Plugin.Multilang/Data/TranslationStore.cs @@ -8,81 +8,10 @@ using Microsoft.Data.Sqlite; namespace Jellyfin.Plugin.Multilang.Data; -public sealed class TranslationStore +public sealed partial class TranslationStore { public const string LocalAssetUrlPrefix = "/Multilang/Assets/"; - private const string SchemaSql = @" -CREATE TABLE IF NOT EXISTS facts ( - item_id TEXT PRIMARY KEY, - tmdb_id TEXT NOT NULL, - kind TEXT NOT NULL, - original_title TEXT NOT NULL DEFAULT '', - original_language TEXT NOT NULL DEFAULT '', - original_language_all TEXT NOT NULL DEFAULT '', - origin_countries_json TEXT NOT NULL DEFAULT '[]', - production_countries_json TEXT NOT NULL DEFAULT '[]', - spoken_languages_json TEXT NOT NULL DEFAULT '[]', - audio_track_language TEXT NOT NULL DEFAULT '', - genre_tmdb_ids_json TEXT NOT NULL DEFAULT '[]', - missing_checked_at INTEGER NOT NULL DEFAULT 0, - full_checked_at INTEGER NOT NULL DEFAULT 0 -); -CREATE INDEX IF NOT EXISTS idx_facts_tmdb ON facts(tmdb_id); -CREATE INDEX IF NOT EXISTS idx_facts_kind ON facts(kind); -CREATE INDEX IF NOT EXISTS idx_facts_missing ON facts(missing_checked_at); -CREATE INDEX IF NOT EXISTS idx_facts_full ON facts(full_checked_at); - -CREATE TABLE IF NOT EXISTS translations ( - item_id TEXT NOT NULL, - lang TEXT NOT NULL, - field TEXT NOT NULL, - text TEXT NOT NULL, - PRIMARY KEY (item_id, lang, field) -); -CREATE INDEX IF NOT EXISTS idx_translations_item ON translations(item_id); - -CREATE TABLE IF NOT EXISTS assets ( - item_id TEXT NOT NULL, - lang TEXT NOT NULL, - kind TEXT NOT NULL, - path TEXT NOT NULL, - path_low TEXT NOT NULL DEFAULT '', - updated_at INTEGER NOT NULL, - PRIMARY KEY (item_id, lang, kind) -); -CREATE INDEX IF NOT EXISTS idx_assets_item ON assets(item_id); - -CREATE TABLE IF NOT EXISTS genres ( - tmdb_id INTEGER NOT NULL, - media TEXT NOT NULL, - lang TEXT NOT NULL, - name TEXT NOT NULL, - name_norm TEXT NOT NULL, - PRIMARY KEY (tmdb_id, media, lang) -); -CREATE INDEX IF NOT EXISTS idx_genres_media_lang ON genres(media, lang); -CREATE INDEX IF NOT EXISTS idx_genres_name ON genres(name_norm, media, lang); - -CREATE TABLE IF NOT EXISTS user_rules ( - user_id TEXT PRIMARY KEY, - rules_json TEXT NOT NULL, - updated_at INTEGER NOT NULL -); - -CREATE TABLE IF NOT EXISTS user_display_langs ( - user_id TEXT PRIMARY KEY, - langs_json TEXT NOT NULL, - updated_at INTEGER NOT NULL -); - -CREATE TABLE IF NOT EXISTS scan_state ( - id INTEGER PRIMARY KEY CHECK (id = 1), - last_scan_started INTEGER NOT NULL DEFAULT 0 -); -INSERT OR IGNORE INTO scan_state(id, last_scan_started) VALUES (1, 0); -"; - private readonly string _dataDir; private readonly string _assetsDir; private readonly string _dbPath; @@ -164,183 +93,6 @@ INSERT OR IGNORE INTO scan_state(id, last_scan_started) VALUES (1, 0); alter.ExecuteNonQuery(); } - public HashSet GetTrackedItemIds() - { - using var con = Open(); - con.Open(); - using var cmd = con.CreateCommand(); - cmd.CommandText = "SELECT item_id FROM facts;"; - using var reader = cmd.ExecuteReader(); - var ids = new HashSet(StringComparer.OrdinalIgnoreCase); - while (reader.Read()) - ids.Add(reader.GetString(0)); - return ids; - } - - public void CleanupNotInLibrary(IReadOnlySet liveItemIds) - { - using var con = Open(); - con.Open(); - using var tx = con.BeginTransaction(); - using var select = con.CreateCommand(); - select.Transaction = tx; - select.CommandText = "SELECT item_id FROM facts;"; - using var reader = select.ExecuteReader(); - var stale = new List(); - while (reader.Read()) - { - var itemId = reader.GetString(0); - if (!liveItemIds.Contains(itemId)) - stale.Add(itemId); - } - - reader.Close(); - foreach (var itemId in stale) - { - using var delete = con.CreateCommand(); - delete.Transaction = tx; - delete.CommandText = @" -DELETE FROM facts WHERE item_id = $item_id; -DELETE FROM translations WHERE item_id = $item_id; -DELETE FROM assets WHERE item_id = $item_id;"; - delete.Parameters.AddWithValue("$item_id", itemId); - delete.ExecuteNonQuery(); - } - - tx.Commit(); - CleanupUnreferencedAssetFiles(); - } - - public LocalCleanupResult CleanupForConfiguration(IReadOnlySet liveItemIds, IEnumerable allowedLanguages, bool localAssetStorage) - { - CleanupNotInLibrary(liveItemIds); - - var allowed = allowedLanguages - .Where(l => !string.IsNullOrWhiteSpace(l)) - .Select(l => l.Trim()) - .Append(MultilangConstants.OriginalAction) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - var translationsDeleted = DeleteRowsOutsideLanguages("translations", allowed); - var genresDeleted = DeleteRowsOutsideLanguages("genres", allowed); - var assetsDeleted = DeleteRowsOutsideLanguages("assets", allowed); - var modeRowsDeleted = localAssetStorage ? 0 : DeleteLocalAssetRows(resetMissingCheckedAt: true); - var assetFilesDeleted = CleanupUnreferencedAssetFiles(); - return new LocalCleanupResult(translationsDeleted, assetsDeleted + modeRowsDeleted, genresDeleted, assetFilesDeleted); - } - - private int DeleteRowsOutsideLanguages(string table, IReadOnlyList allowedLanguages) - { - using var con = Open(); - con.Open(); - using var cmd = con.CreateCommand(); - var langParams = AddParams(cmd, allowedLanguages, "$lang"); - cmd.CommandText = $"DELETE FROM {table} WHERE lang NOT IN ({langParams});"; - return cmd.ExecuteNonQuery(); - } - - private int DeleteLocalAssetRows(bool resetMissingCheckedAt) - { - using var con = Open(); - con.Open(); - using var tx = con.BeginTransaction(); - var affected = new List(); - using (var select = con.CreateCommand()) - { - select.Transaction = tx; - select.CommandText = "SELECT DISTINCT item_id FROM assets WHERE path_low LIKE '/multilang/assets/%';"; - using var reader = select.ExecuteReader(); - while (reader.Read()) - affected.Add(reader.GetString(0)); - } - - int deleted; - using (var delete = con.CreateCommand()) - { - delete.Transaction = tx; - delete.CommandText = "DELETE FROM assets WHERE path_low LIKE '/multilang/assets/%';"; - deleted = delete.ExecuteNonQuery(); - } - - if (resetMissingCheckedAt && affected.Count > 0) - { - using var update = con.CreateCommand(); - update.Transaction = tx; - var idParams = AddParams(update, affected, "$item"); - update.CommandText = $"UPDATE facts SET missing_checked_at = 0 WHERE item_id IN ({idParams});"; - update.ExecuteNonQuery(); - } - - tx.Commit(); - return deleted; - } - - public int CleanupUnreferencedAssetFiles() - { - if (!Directory.Exists(_assetsDir)) - return 0; - - var referenced = GetReferencedLocalAssetPaths(); - var deleted = 0; - foreach (var file in Directory.EnumerateFiles(_assetsDir, "*", SearchOption.AllDirectories)) - { - var full = Path.GetFullPath(file); - if (referenced.Contains(full)) - continue; - - File.Delete(file); - deleted++; - } - - foreach (var dir in Directory.EnumerateDirectories(_assetsDir, "*", SearchOption.AllDirectories).OrderByDescending(d => d.Length)) - { - if (!Directory.EnumerateFileSystemEntries(dir).Any()) - Directory.Delete(dir); - } - - return deleted; - } - - private HashSet GetReferencedLocalAssetPaths() - { - using var con = Open(); - con.Open(); - using var cmd = con.CreateCommand(); - cmd.CommandText = "SELECT path FROM assets;"; - using var reader = cmd.ExecuteReader(); - var result = new HashSet(StringComparer.OrdinalIgnoreCase); - while (reader.Read()) - { - var path = reader.GetString(0); - if (TryNormalizeLocalAssetPath(path, out var fullPath)) - result.Add(fullPath); - } - - return result; - } - - public bool TryNormalizeLocalAssetPath(string path, out string fullPath) - { - fullPath = string.Empty; - if (string.IsNullOrWhiteSpace(path) || Uri.TryCreate(path, UriKind.Absolute, out _)) - return false; - - var relative = path; - if (relative.StartsWith(LocalAssetUrlPrefix, StringComparison.OrdinalIgnoreCase)) - relative = relative[LocalAssetUrlPrefix.Length..]; - else if (Path.IsPathRooted(relative)) - return false; - - var candidate = Path.GetFullPath(Path.Combine(_assetsDir, relative)); - var root = Path.GetFullPath(_assetsDir) + Path.DirectorySeparatorChar; - if (!candidate.StartsWith(root, StringComparison.OrdinalIgnoreCase)) - return false; - - fullPath = candidate; - return true; - } - public long GetLastScanStarted() { using var con = Open(); @@ -852,223 +604,6 @@ ON CONFLICT(user_id) DO UPDATE SET rules_json = excluded.rules_json, updated_at return result; } - public void ExportTranslationDatabase(string destinationPath) - { - if (File.Exists(destinationPath)) - File.Delete(destinationPath); - - using var destination = new SqliteConnection($"Data Source={destinationPath}"); - destination.Open(); - using (var schema = destination.CreateCommand()) - { - schema.CommandText = SchemaSql; - schema.ExecuteNonQuery(); - } - - using var source = Open(); - source.Open(); - CopyTable(source, destination, "facts", "item_id, tmdb_id, kind, original_title, original_language, original_language_all, origin_countries_json, production_countries_json, spoken_languages_json, audio_track_language, genre_tmdb_ids_json, missing_checked_at, full_checked_at"); - CopyTable(source, destination, "translations", "item_id, lang, field, text"); - CopyTable(source, destination, "assets", "item_id, lang, kind, path, path_low, updated_at"); - CopyTable(source, destination, "genres", "tmdb_id, media, lang, name, name_norm"); - CopyTable(source, destination, "scan_state", "id, last_scan_started"); - } - - public ImportDatabaseResult ImportTranslationDatabase(string sourcePath, IReadOnlySet liveItemIds, IReadOnlySet allowedLanguages) - { - var allowed = allowedLanguages.ToHashSet(StringComparer.OrdinalIgnoreCase); - allowed.Add(MultilangConstants.OriginalAction); - - using var destination = Open(); - destination.Open(); - using var source = new SqliteConnection($"Data Source={sourcePath};Mode=ReadOnly"); - source.Open(); - using var tx = destination.BeginTransaction(); - - var facts = ImportFacts(source, destination, tx, liveItemIds); - var translations = ImportTranslations(source, destination, tx, liveItemIds, allowed); - var assets = ImportAssets(source, destination, tx, liveItemIds, allowed); - var genres = ImportGenres(source, destination, tx, allowed); - tx.Commit(); - return new ImportDatabaseResult(facts, translations, assets, genres); - } - - private static void CopyTable(SqliteConnection source, SqliteConnection destination, string table, string columns) - { - using var read = source.CreateCommand(); - read.CommandText = $"SELECT {columns} FROM {table};"; - using var reader = read.ExecuteReader(); - using var tx = destination.BeginTransaction(); - while (reader.Read()) - { - using var insert = destination.CreateCommand(); - insert.Transaction = tx; - var names = columns.Split(',', StringSplitOptions.TrimEntries); - insert.CommandText = $"INSERT OR REPLACE INTO {table}({columns}) VALUES({string.Join(",", names.Select((_, i) => "$v" + i.ToString(CultureInfo.InvariantCulture)))});"; - for (var i = 0; i < names.Length; i++) - insert.Parameters.AddWithValue("$v" + i.ToString(CultureInfo.InvariantCulture), reader.GetValue(i)); - insert.ExecuteNonQuery(); - } - - tx.Commit(); - } - - private static int ImportFacts(SqliteConnection source, SqliteConnection destination, SqliteTransaction tx, IReadOnlySet liveItemIds) - { - using var read = source.CreateCommand(); - read.CommandText = @" -SELECT item_id, tmdb_id, kind, original_title, original_language, original_language_all, - origin_countries_json, production_countries_json, spoken_languages_json, audio_track_language, - genre_tmdb_ids_json, missing_checked_at, full_checked_at -FROM facts;"; - using var reader = read.ExecuteReader(); - var count = 0; - while (reader.Read()) - { - var itemId = reader.GetString(0); - if (!liveItemIds.Contains(itemId)) - continue; - using var insert = destination.CreateCommand(); - insert.Transaction = tx; - insert.CommandText = @" -INSERT INTO facts(item_id, tmdb_id, kind, original_title, original_language, original_language_all, - origin_countries_json, production_countries_json, spoken_languages_json, audio_track_language, - genre_tmdb_ids_json, missing_checked_at, full_checked_at) -VALUES($item_id, $tmdb_id, $kind, $original_title, $original_language, $original_language_all, - $origin_countries_json, $production_countries_json, $spoken_languages_json, $audio_track_language, - $genre_tmdb_ids_json, $missing_checked_at, $full_checked_at) -ON CONFLICT(item_id) DO UPDATE SET - tmdb_id = excluded.tmdb_id, - kind = excluded.kind, - original_title = excluded.original_title, - original_language = excluded.original_language, - original_language_all = excluded.original_language_all, - origin_countries_json = excluded.origin_countries_json, - production_countries_json = excluded.production_countries_json, - spoken_languages_json = excluded.spoken_languages_json, - audio_track_language = excluded.audio_track_language, - genre_tmdb_ids_json = excluded.genre_tmdb_ids_json, - missing_checked_at = excluded.missing_checked_at, - full_checked_at = excluded.full_checked_at;"; - var names = new[] - { - "$item_id", - "$tmdb_id", - "$kind", - "$original_title", - "$original_language", - "$original_language_all", - "$origin_countries_json", - "$production_countries_json", - "$spoken_languages_json", - "$audio_track_language", - "$genre_tmdb_ids_json", - "$missing_checked_at", - "$full_checked_at" - }; - for (var i = 0; i < names.Length; i++) - insert.Parameters.AddWithValue(names[i], reader.GetValue(i)); - insert.ExecuteNonQuery(); - count++; - } - - return count; - } - - private static int ImportTranslations(SqliteConnection source, SqliteConnection destination, SqliteTransaction tx, IReadOnlySet liveItemIds, IReadOnlySet allowedLanguages) - { - using var read = source.CreateCommand(); - read.CommandText = "SELECT item_id, lang, field, text FROM translations;"; - using var reader = read.ExecuteReader(); - var count = 0; - while (reader.Read()) - { - var itemId = reader.GetString(0); - var lang = reader.GetString(1); - if (!liveItemIds.Contains(itemId) || !allowedLanguages.Contains(lang)) - continue; - using var insert = destination.CreateCommand(); - insert.Transaction = tx; - insert.CommandText = @" -INSERT INTO translations(item_id, lang, field, text) -VALUES($item_id, $lang, $field, $text) -ON CONFLICT(item_id, lang, field) DO UPDATE SET text = excluded.text;"; - insert.Parameters.AddWithValue("$item_id", itemId); - insert.Parameters.AddWithValue("$lang", lang); - insert.Parameters.AddWithValue("$field", reader.GetString(2)); - insert.Parameters.AddWithValue("$text", reader.GetString(3)); - insert.ExecuteNonQuery(); - count++; - } - - return count; - } - - private static int ImportAssets(SqliteConnection source, SqliteConnection destination, SqliteTransaction tx, IReadOnlySet liveItemIds, IReadOnlySet allowedLanguages) - { - using var read = source.CreateCommand(); - read.CommandText = "SELECT item_id, lang, kind, path, path_low, updated_at FROM assets;"; - using var reader = read.ExecuteReader(); - var count = 0; - while (reader.Read()) - { - var itemId = reader.GetString(0); - var lang = reader.GetString(1); - if (!liveItemIds.Contains(itemId) || !allowedLanguages.Contains(lang)) - continue; - using var insert = destination.CreateCommand(); - insert.Transaction = tx; - insert.CommandText = @" -INSERT INTO assets(item_id, lang, kind, path, path_low, updated_at) -VALUES($item_id, $lang, $kind, $path, $path_low, $updated_at) -ON CONFLICT(item_id, lang, kind) DO UPDATE SET - path = excluded.path, - path_low = excluded.path_low, - updated_at = excluded.updated_at;"; - insert.Parameters.AddWithValue("$item_id", itemId); - insert.Parameters.AddWithValue("$lang", lang); - insert.Parameters.AddWithValue("$kind", reader.GetString(2)); - insert.Parameters.AddWithValue("$path", reader.GetString(3)); - insert.Parameters.AddWithValue("$path_low", reader.GetString(4)); - insert.Parameters.AddWithValue("$updated_at", reader.GetInt64(5)); - insert.ExecuteNonQuery(); - count++; - } - - return count; - } - - private static int ImportGenres(SqliteConnection source, SqliteConnection destination, SqliteTransaction tx, IReadOnlySet allowedLanguages) - { - using var read = source.CreateCommand(); - read.CommandText = "SELECT tmdb_id, media, lang, name, name_norm FROM genres;"; - using var reader = read.ExecuteReader(); - var count = 0; - while (reader.Read()) - { - var lang = reader.GetString(2); - if (!allowedLanguages.Contains(lang)) - continue; - using var insert = destination.CreateCommand(); - insert.Transaction = tx; - insert.CommandText = @" -INSERT INTO genres(tmdb_id, media, lang, name, name_norm) -VALUES($tmdb_id, $media, $lang, $name, $name_norm) -ON CONFLICT(tmdb_id, media, lang) DO UPDATE SET - name = excluded.name, - name_norm = excluded.name_norm;"; - insert.Parameters.AddWithValue("$tmdb_id", reader.GetInt32(0)); - insert.Parameters.AddWithValue("$media", reader.GetString(1)); - insert.Parameters.AddWithValue("$lang", lang); - insert.Parameters.AddWithValue("$name", reader.GetString(3)); - insert.Parameters.AddWithValue("$name_norm", reader.GetString(4)); - insert.ExecuteNonQuery(); - count++; - } - - return count; - } - private static string AddParams(SqliteCommand cmd, IReadOnlyList values, string prefix) { var names = new string[values.Count]; @@ -1082,76 +617,3 @@ ON CONFLICT(tmdb_id, media, lang) DO UPDATE SET return string.Join(",", names); } } - -public readonly record struct FactsStatus(bool Exists, long MissingCheckedAt, long FullCheckedAt); - -public readonly record struct AssetPresence(bool Any, bool AnyRemote); - -public readonly record struct LocalCleanupResult(int TranslationsDeleted, int AssetsDeleted, int GenresDeleted, int AssetFilesDeleted); - -public readonly record struct ImportDatabaseResult(int Facts, int Translations, int Assets, int Genres); - -public readonly record struct GenreRow(int TmdbId, string Media, string Lang, string Name); - -public sealed record FactsData( - string ItemId, - string TmdbId, - string Kind, - string OriginalTitle, - string OriginalLanguage, - string OriginalLanguageAll, - string OriginCountriesJson, - string ProductionCountriesJson, - string SpokenLanguagesJson, - string AudioTrackLanguage, - string GenreTmdbIdsJson, - long MissingCheckedAt, - long FullCheckedAt); - -public sealed class UserRulesDocument -{ - public bool Enabled { get; set; } = true; - - public string SortLocale { get; set; } = "Auto"; - - public Dictionary FallbackFieldActions { get; set; } = MultilangConstants.DefaultActionLists(); - - public bool TrustTmdbCollections { get; set; } = true; - - public UserCategoryRule[] Categories { get; set; } = []; - -} - -public sealed class UserCategoryRule -{ - public string Id { get; set; } = Guid.NewGuid().ToString("N"); - - public string Label { get; set; } = string.Empty; - - public string CriteriaText { get; set; } = string.Empty; - - public UserCategoryRequirement[] Requirements { get; set; } = []; - - public bool MatchAllConditions { get; set; } = true; - - public string[] Scopes { get; set; } = ["M", "S", "C"]; - - public Dictionary FieldActions { get; set; } = MultilangConstants.DefaultFieldActions(); - - public Dictionary FieldActionLists { get; set; } = MultilangConstants.DefaultActionLists(); -} - -public sealed class UserCategoryRequirement -{ - public string Field { get; set; } = string.Empty; - - public string[] Fields { get; set; } = []; - - public bool UseFieldOr { get; set; } - - public string Relation { get; set; } = string.Empty; - - public bool UseOr { get; set; } - - public string[] Values { get; set; } = []; -}