Split translation store partials
This commit is contained in:
@@ -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;
|
||||
|
||||
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<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()
|
||||
{
|
||||
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<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)
|
||||
{
|
||||
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<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; } = [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user