Split translation store partials

This commit is contained in:
ajp_anton
2026-07-15 22:33:41 +00:00
parent 933123608d
commit 0d6f454bed
5 changed files with 559 additions and 539 deletions
@@ -0,0 +1,183 @@
using Jellyfin.Plugin.Multilang;
namespace Jellyfin.Plugin.Multilang.Data;
public sealed partial class TranslationStore
{
public HashSet<string> 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<string>(StringComparer.OrdinalIgnoreCase);
while (reader.Read())
ids.Add(reader.GetString(0));
return ids;
}
public void CleanupNotInLibrary(IReadOnlySet<string> 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<string>();
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<string> liveItemIds, IEnumerable<string> 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<string> 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<string>();
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<string> 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<string>(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;
}
}
@@ -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<string> liveItemIds, IReadOnlySet<string> 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<string> 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<string> liveItemIds, IReadOnlySet<string> 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<string> liveItemIds, IReadOnlySet<string> 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<string> 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;
}
}
@@ -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<string, string[]> 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<string, string> FieldActions { get; set; } = MultilangConstants.DefaultFieldActions();
public Dictionary<string, string[]> 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; } = [];
}
@@ -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);
";
}
@@ -8,81 +8,10 @@ using Microsoft.Data.Sqlite;
namespace Jellyfin.Plugin.Multilang.Data; namespace Jellyfin.Plugin.Multilang.Data;
public sealed class TranslationStore public sealed partial class TranslationStore
{ {
public const string LocalAssetUrlPrefix = "/Multilang/Assets/"; 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 _dataDir;
private readonly string _assetsDir; private readonly string _assetsDir;
private readonly string _dbPath; private readonly string _dbPath;
@@ -164,183 +93,6 @@ INSERT OR IGNORE INTO scan_state(id, last_scan_started) VALUES (1, 0);
alter.ExecuteNonQuery(); alter.ExecuteNonQuery();
} }
public HashSet<string> 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<string>(StringComparer.OrdinalIgnoreCase);
while (reader.Read())
ids.Add(reader.GetString(0));
return ids;
}
public void CleanupNotInLibrary(IReadOnlySet<string> 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<string>();
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<string> liveItemIds, IEnumerable<string> 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<string> 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<string>();
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<string> 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<string>(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() public long GetLastScanStarted()
{ {
using var con = Open(); using var con = Open();
@@ -852,223 +604,6 @@ ON CONFLICT(user_id) DO UPDATE SET rules_json = excluded.rules_json, updated_at
return result; 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<string> liveItemIds, IReadOnlySet<string> 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<string> 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<string> liveItemIds, IReadOnlySet<string> 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<string> liveItemIds, IReadOnlySet<string> 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<string> 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<string> values, string prefix) private static string AddParams(SqliteCommand cmd, IReadOnlyList<string> values, string prefix)
{ {
var names = new string[values.Count]; var names = new string[values.Count];
@@ -1082,76 +617,3 @@ ON CONFLICT(tmdb_id, media, lang) DO UPDATE SET
return string.Join(",", names); 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<string, string[]> 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<string, string> FieldActions { get; set; } = MultilangConstants.DefaultFieldActions();
public Dictionary<string, string[]> 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; } = [];
}