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;
|
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; } = [];
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user