Homelabtoolkit v2
This commit is contained in:
@@ -0,0 +1,634 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import uuid
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
EMBY_USERS_CACHE_PATH = Path(os.environ.get("EMBY_USER_CACHE_PATH", "cache/homescreen-emby-users.json"))
|
||||
EMBY_USER_CONTEXT_CACHE_PATH = Path(os.environ.get("EMBY_USER_CONTEXT_CACHE_PATH", "cache/homescreen-emby-user-context.json"))
|
||||
HOMESCREEN_UPLOAD_DIR = Path(os.environ.get("HOMESCREEN_UPLOAD_DIR", "cache/homescreen_uploads"))
|
||||
HOMESCREEN_UPLOAD_STATE_PATH = Path(os.environ.get("HOMESCREEN_UPLOAD_STATE_PATH", "cache/homescreen-upload-state.json"))
|
||||
|
||||
|
||||
SECTION_TYPES = [
|
||||
{"value": "resume", "label": "Resume / Next Up"},
|
||||
{"value": "items", "label": "Items (filtered)"},
|
||||
{"value": "userviews", "label": "Libraries"},
|
||||
{"value": "boxset", "label": "Box Set"},
|
||||
{"value": "collections", "label": "Collections"},
|
||||
{"value": "latestepisodereleases", "label": "Latest episode releases"},
|
||||
{"value": "latestmoviereleases", "label": "Latest movie releases"},
|
||||
{"value": "latestmediablock", "label": "Latest media"},
|
||||
]
|
||||
|
||||
COLLECTION_TYPES = [
|
||||
{"value": "", "label": "(none)"},
|
||||
{"value": "movies", "label": "Movies"},
|
||||
{"value": "tvshows", "label": "TV Shows"},
|
||||
{"value": "boxsets", "label": "Box Sets"},
|
||||
]
|
||||
|
||||
ITEM_TYPES = ["Movie", "Series", "Episode", "BoxSet"]
|
||||
|
||||
SORT_OPTIONS = [
|
||||
{"value": "", "label": "(none)"},
|
||||
{"value": "default", "label": "Default (boxset)"},
|
||||
{"value": "DatePlayed", "label": "Date played"},
|
||||
{"value": "DateLastContentAdded,SortName", "label": "Date added"},
|
||||
{"value": "ProductionYear,PremiereDate,SortName", "label": "Release year"},
|
||||
{"value": "CommunityRating", "label": "Community rating"},
|
||||
{"value": "CriticRating,SortName", "label": "Critic rating"},
|
||||
{"value": "DateCreated,SortName", "label": "Date created"},
|
||||
{"value": "Random", "label": "Random"},
|
||||
{"value": "SortName", "label": "Name"},
|
||||
]
|
||||
|
||||
IMAGE_TYPES = [
|
||||
{"value": "", "label": "Default"},
|
||||
{"value": "Thumb", "label": "Thumb"},
|
||||
{"value": "Primary", "label": "Primary / Poster"},
|
||||
]
|
||||
|
||||
|
||||
def enums_payload() -> dict[str, Any]:
|
||||
return {
|
||||
"section_types": SECTION_TYPES,
|
||||
"collection_types": COLLECTION_TYPES,
|
||||
"item_types": ITEM_TYPES,
|
||||
"sort_options": SORT_OPTIONS,
|
||||
"image_types": IMAGE_TYPES,
|
||||
}
|
||||
|
||||
|
||||
def normalize_guid(value: str | None) -> str:
|
||||
return str(value or "").replace("-", "").strip().lower()
|
||||
|
||||
|
||||
def gen_id() -> str:
|
||||
return uuid.uuid4().hex[:32]
|
||||
|
||||
|
||||
def create_empty_section(user_id: str) -> dict[str, Any]:
|
||||
return {
|
||||
"UserId": user_id,
|
||||
"Name": "New Section",
|
||||
"CustomName": "New Section",
|
||||
"Id": gen_id(),
|
||||
"SectionType": "items",
|
||||
"ImageType": "Thumb",
|
||||
"CollectionType": "movies",
|
||||
"SortBy": "Random",
|
||||
"SortOrder": "Descending",
|
||||
"Monitor": [],
|
||||
"ItemTypes": ["Movie"],
|
||||
"ExcludedFolders": [],
|
||||
"CardSizeOffset": 0,
|
||||
"IncludeNextUpInResume": True,
|
||||
"Query": {
|
||||
"StudioIds": [],
|
||||
"TagIds": [],
|
||||
"GenreIds": [],
|
||||
"CollectionTypes": [],
|
||||
"IsPlayed": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def create_recently_watched_section(user_id: str, user_name: str = "") -> dict[str, Any]:
|
||||
label = f"Recently Watched - {user_name}" if user_name else "Recently Watched"
|
||||
return {
|
||||
"UserId": user_id,
|
||||
"Name": label,
|
||||
"CustomName": label,
|
||||
"Id": gen_id(),
|
||||
"SectionType": "items",
|
||||
"ImageType": "Thumb",
|
||||
"CollectionType": "",
|
||||
"SortBy": "DatePlayed",
|
||||
"SortOrder": "Descending",
|
||||
"Monitor": [],
|
||||
"ItemTypes": ["Movie", "Series"],
|
||||
"ExcludedFolders": [],
|
||||
"CardSizeOffset": 0,
|
||||
"IncludeNextUpInResume": True,
|
||||
"Query": {
|
||||
"StudioIds": [],
|
||||
"TagIds": [],
|
||||
"GenreIds": [],
|
||||
"CollectionTypes": [],
|
||||
"IsPlayed": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def create_boxset_section(user_id: str, collection_name: str = "", collection_id: str = "") -> dict[str, Any]:
|
||||
label = collection_name or "New Collection"
|
||||
return {
|
||||
"UserId": user_id,
|
||||
"Name": label,
|
||||
"CustomName": label,
|
||||
"Id": gen_id(),
|
||||
"SectionType": "boxset",
|
||||
"ImageType": "Thumb",
|
||||
"ItemTypes": [],
|
||||
"SortBy": "Random",
|
||||
"SortOrder": "Descending",
|
||||
"Monitor": [],
|
||||
"ExcludedFolders": [],
|
||||
"CardSizeOffset": 0,
|
||||
"IncludeNextUpInResume": True,
|
||||
"ParentItem": {
|
||||
"Name": label,
|
||||
"Id": str(collection_id or ""),
|
||||
},
|
||||
"ParentId": str(collection_id or ""),
|
||||
}
|
||||
|
||||
|
||||
def normalize_sections_for_user(sections: Any, expected_emby_guid: str) -> list[dict[str, Any]]:
|
||||
if not isinstance(sections, list):
|
||||
return []
|
||||
if not expected_emby_guid:
|
||||
return sections
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for section in sections:
|
||||
if not isinstance(section, dict):
|
||||
continue
|
||||
next_section = dict(section)
|
||||
next_section["UserId"] = expected_emby_guid
|
||||
normalized.append(next_section)
|
||||
return normalized
|
||||
|
||||
|
||||
def _parse_json_blob(blob: Any) -> dict | None:
|
||||
if blob is None:
|
||||
return None
|
||||
try:
|
||||
text = blob if isinstance(blob, str) else bytes(blob).decode("utf-8")
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def blob_to_emby_guid(blob: bytes | bytearray | memoryview | None) -> str:
|
||||
if not blob:
|
||||
return ""
|
||||
raw = bytes(blob)
|
||||
if len(raw) != 16:
|
||||
return raw.hex().lower()
|
||||
reordered = bytes(
|
||||
[
|
||||
raw[3], raw[2], raw[1], raw[0],
|
||||
raw[5], raw[4],
|
||||
raw[7], raw[6],
|
||||
raw[8], raw[9], raw[10], raw[11], raw[12], raw[13], raw[14], raw[15],
|
||||
]
|
||||
)
|
||||
return reordered.hex().lower()
|
||||
|
||||
|
||||
def _has_table(conn: sqlite3.Connection, table_name: str) -> bool:
|
||||
row = conn.execute("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", (table_name,)).fetchone()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def _users_table_columns(conn: sqlite3.Connection) -> list[str]:
|
||||
rows = conn.execute("PRAGMA table_info(Users)").fetchall()
|
||||
return [str(row[1]) for row in rows]
|
||||
|
||||
|
||||
def _find_column(columns: list[str], *patterns: str) -> str | None:
|
||||
lower_map = {col.lower(): col for col in columns}
|
||||
for pattern in patterns:
|
||||
for lower, original in lower_map.items():
|
||||
if lower == pattern.lower():
|
||||
return original
|
||||
return None
|
||||
|
||||
|
||||
def _load_users_table_users(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
columns = _users_table_columns(conn)
|
||||
name_col = _find_column(columns, "Username", "Name")
|
||||
guid_col = _find_column(columns, "Guid")
|
||||
id_col = _find_column(columns, "Id") or "Id"
|
||||
if not name_col:
|
||||
raise RuntimeError(f"Cannot find a name column in Users table. Columns found: {', '.join(columns)}")
|
||||
select_cols = ", ".join([col for col in [id_col, name_col, guid_col] if col])
|
||||
rows = conn.execute(f"SELECT {select_cols} FROM Users").fetchall()
|
||||
users: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
raw_id = row[id_col]
|
||||
raw_guid = row[guid_col] if guid_col else None
|
||||
emby_guid = ""
|
||||
guid = ""
|
||||
if raw_guid:
|
||||
if isinstance(raw_guid, (bytes, bytearray, memoryview)):
|
||||
buf = bytes(raw_guid)
|
||||
guid = buf.hex().upper()
|
||||
emby_guid = blob_to_emby_guid(buf)
|
||||
elif isinstance(raw_guid, str):
|
||||
clean = normalize_guid(raw_guid)
|
||||
emby_guid = clean
|
||||
guid = clean.upper()
|
||||
users.append(
|
||||
{
|
||||
"id": raw_id,
|
||||
"name": row[name_col] or f"User {raw_id}",
|
||||
"guid": guid,
|
||||
"embyGuid": emby_guid,
|
||||
"sourceTable": "Users",
|
||||
}
|
||||
)
|
||||
return users
|
||||
|
||||
|
||||
def _load_local_users(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
rows = conn.execute("SELECT Id, guid, data FROM LocalUsersv2").fetchall()
|
||||
users: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
parsed = _parse_json_blob(row["data"])
|
||||
guid_blob = row["guid"]
|
||||
guid = bytes(guid_blob).hex().upper() if guid_blob else ""
|
||||
emby_guid_from_blob = blob_to_emby_guid(guid_blob)
|
||||
emby_guid_from_json = normalize_guid((parsed or {}).get("IdString"))
|
||||
emby_guid = emby_guid_from_json or emby_guid_from_blob
|
||||
users.append(
|
||||
{
|
||||
"id": row["Id"],
|
||||
"name": (parsed or {}).get("Name") or f"User {row['Id']}",
|
||||
"guid": guid,
|
||||
"embyGuid": emby_guid,
|
||||
"sourceTable": "LocalUsersv2",
|
||||
"profile": parsed,
|
||||
}
|
||||
)
|
||||
return users
|
||||
|
||||
|
||||
def load_canonical_users(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
if _has_table(conn, "LocalUsersv2"):
|
||||
return _load_local_users(conn)
|
||||
if _has_table(conn, "Users"):
|
||||
return _load_users_table_users(conn)
|
||||
raise RuntimeError("No supported user table found. Expected LocalUsersv2 or Users.")
|
||||
|
||||
|
||||
def _home_screen_setting_rows(conn: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||
return conn.execute(
|
||||
"""
|
||||
SELECT us.UserId, us.Value
|
||||
FROM UserSettings us
|
||||
JOIN UserSettingsKeys usk ON us.UserSettingsKeyId = usk.UserSettingsKeyId
|
||||
WHERE usk.Name = 'homescreensettings'
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
|
||||
def read_db(db_path: str) -> dict[str, Any]:
|
||||
path = Path(db_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Database file not found: {path}")
|
||||
conn = sqlite3.connect(str(path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
users = load_canonical_users(conn)
|
||||
settings_rows = _home_screen_setting_rows(conn)
|
||||
settings_map = {str(row["UserId"]): row["Value"] for row in settings_rows}
|
||||
user_ids = {str(user["id"]) for user in users}
|
||||
|
||||
matched_users = 0
|
||||
mismatched_users = 0
|
||||
normalized_users = 0
|
||||
missing_section_user_ids = 0
|
||||
hydrated_users: list[dict[str, Any]] = []
|
||||
|
||||
for user in users:
|
||||
raw_value = settings_map.get(str(user["id"]))
|
||||
sections: list[dict[str, Any]] = []
|
||||
try:
|
||||
if raw_value:
|
||||
sections = (json.loads(raw_value) or {}).get("Sections") or []
|
||||
except Exception:
|
||||
sections = []
|
||||
|
||||
actual_user_ids = sorted({normalize_guid(section.get("UserId")) for section in sections if normalize_guid(section.get("UserId"))})
|
||||
mismatched_section_user_ids = (
|
||||
[value for value in actual_user_ids if value != user["embyGuid"]]
|
||||
if user.get("embyGuid")
|
||||
else list(actual_user_ids)
|
||||
)
|
||||
missing_ids_for_user = sum(1 for section in sections if not normalize_guid(section.get("UserId")))
|
||||
normalized_sections = normalize_sections_for_user(sections, user.get("embyGuid", ""))
|
||||
sections_were_normalized = json.dumps(sections, sort_keys=True) != json.dumps(normalized_sections, sort_keys=True)
|
||||
|
||||
if mismatched_section_user_ids:
|
||||
mismatched_users += 1
|
||||
else:
|
||||
matched_users += 1
|
||||
if sections_were_normalized:
|
||||
normalized_users += 1
|
||||
missing_section_user_ids += missing_ids_for_user
|
||||
|
||||
profile = user.get("profile") or {}
|
||||
hydrated_users.append(
|
||||
{
|
||||
"id": user["id"],
|
||||
"name": user["name"],
|
||||
"dbName": user["name"],
|
||||
"guid": user.get("guid", ""),
|
||||
"embyGuid": user.get("embyGuid", ""),
|
||||
"embyName": None,
|
||||
"sections": normalized_sections,
|
||||
"details": {
|
||||
"sourceTable": user["sourceTable"],
|
||||
"lastLoginDate": profile.get("LastLoginDate"),
|
||||
"lastActivityDate": profile.get("LastActivityDate"),
|
||||
"usesIdForConfigurationPath": profile.get("UsesIdForConfigurationPath"),
|
||||
"importedCollectionsCount": len(profile.get("ImportedCollections") or []) if isinstance(profile.get("ImportedCollections"), list) else 0,
|
||||
},
|
||||
"match": {
|
||||
"sourceTable": user["sourceTable"],
|
||||
"settingsUserId": user["id"],
|
||||
"expectedSectionUserId": user.get("embyGuid", ""),
|
||||
"actualSectionUserIds": actual_user_ids,
|
||||
"mismatchedSectionUserIds": mismatched_section_user_ids,
|
||||
"missingSectionUserIds": missing_ids_for_user,
|
||||
"ok": not mismatched_section_user_ids,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
orphaned_settings_user_ids = sorted({str(row["UserId"]) for row in settings_rows if str(row["UserId"]) not in user_ids})
|
||||
return {
|
||||
"users": hydrated_users,
|
||||
"validation": {
|
||||
"userSource": users[0]["sourceTable"] if users else None,
|
||||
"userCount": len(hydrated_users),
|
||||
"settingsCount": len(settings_rows),
|
||||
"matchedUsers": matched_users,
|
||||
"mismatchedUsers": mismatched_users,
|
||||
"normalizedUsers": normalized_users,
|
||||
"missingSectionUserIds": missing_section_user_ids,
|
||||
"orphanedSettingsUserIds": orphaned_settings_user_ids,
|
||||
},
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def write_db(db_path: str, changes: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
path = Path(db_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Database file not found: {path}")
|
||||
conn = sqlite3.connect(str(path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
user_lookup = {str(user["id"]): user for user in load_canonical_users(conn)}
|
||||
key_row = conn.execute(
|
||||
"SELECT UserSettingsKeyId FROM UserSettingsKeys WHERE Name = 'homescreensettings'"
|
||||
).fetchone()
|
||||
if not key_row:
|
||||
raise RuntimeError("'homescreensettings' key not found in UserSettingsKeys table")
|
||||
key_id = key_row["UserSettingsKeyId"]
|
||||
count = 0
|
||||
normalized_sections = 0
|
||||
conn.execute("BEGIN")
|
||||
try:
|
||||
for change in changes:
|
||||
user_id = str(change.get("userId"))
|
||||
sections = change.get("sections")
|
||||
user = user_lookup.get(user_id)
|
||||
if not user:
|
||||
raise RuntimeError(f"UserId {user_id} does not exist in {path}")
|
||||
next_sections = normalize_sections_for_user(sections, user.get("embyGuid", ""))
|
||||
if json.dumps(next_sections, sort_keys=True) != json.dumps(sections, sort_keys=True):
|
||||
normalized_sections += len(next_sections)
|
||||
value = json.dumps({"Sections": next_sections}, separators=(",", ":"))
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM UserSettings WHERE UserId = ? AND UserSettingsKeyId = ?",
|
||||
(change.get("userId"), key_id),
|
||||
).fetchone()
|
||||
if exists:
|
||||
conn.execute(
|
||||
"UPDATE UserSettings SET Value = ? WHERE UserId = ? AND UserSettingsKeyId = ?",
|
||||
(value, change.get("userId"), key_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO UserSettings (UserId, UserSettingsKeyId, Value) VALUES (?, ?, ?)",
|
||||
(change.get("userId"), key_id, value),
|
||||
)
|
||||
count += 1
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
return {"ok": True, "count": count, "normalizedSections": normalized_sections}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def generate_sql(users: list[dict[str, Any]], original_users: list[dict[str, Any]]) -> str:
|
||||
statements: list[str] = []
|
||||
original_lookup = {str(user.get("id")): user for user in original_users}
|
||||
for user in users:
|
||||
sections = user.get("sections")
|
||||
if not isinstance(sections, list):
|
||||
continue
|
||||
original = original_lookup.get(str(user.get("id")))
|
||||
if not original:
|
||||
continue
|
||||
orig_json = json.dumps({"Sections": original.get("sections") or []}, separators=(",", ":"))
|
||||
new_json = json.dumps({"Sections": sections}, separators=(",", ":"))
|
||||
if orig_json == new_json:
|
||||
continue
|
||||
escaped_value = new_json.replace("'", "''")
|
||||
statements.extend(
|
||||
[
|
||||
f"-- User: {user.get('name', 'Unknown')} (DB ID: {user.get('id')})",
|
||||
"UPDATE UserSettings "
|
||||
f"SET Value = '{escaped_value}' "
|
||||
f"WHERE UserId = {user.get('id')} "
|
||||
"AND UserSettingsKeyId = "
|
||||
"(SELECT UserSettingsKeyId FROM UserSettingsKeys WHERE Name = 'homescreensettings');",
|
||||
"",
|
||||
]
|
||||
)
|
||||
if not statements:
|
||||
return "-- No changes detected"
|
||||
header = [
|
||||
"-- ===========================================",
|
||||
"-- Emby Home Screen Settings Update",
|
||||
f"-- Generated: {datetime.now().astimezone().isoformat(timespec='seconds')}",
|
||||
"-- ===========================================",
|
||||
"-- IMPORTANT: Stop Emby before running this!",
|
||||
"-- sqlite3 /path/to/users.db < this_file.sql",
|
||||
"-- Then restart Emby.",
|
||||
"-- ===========================================",
|
||||
"",
|
||||
"BEGIN TRANSACTION;",
|
||||
"",
|
||||
]
|
||||
return "\n".join(header + statements + ["COMMIT;"])
|
||||
|
||||
|
||||
def _read_json_cache(path: Path, default: Any) -> Any:
|
||||
if not path.exists():
|
||||
return default
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _write_json_cache(path: Path, payload: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _iso_now() -> str:
|
||||
return datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _upload_path(upload_id: str) -> Path:
|
||||
safe_id = "".join(ch for ch in str(upload_id or "") if ch.isalnum() or ch in ("-", "_")).strip()
|
||||
if not safe_id:
|
||||
raise ValueError("Invalid upload id.")
|
||||
return HOMESCREEN_UPLOAD_DIR / f"{safe_id}.db"
|
||||
|
||||
|
||||
def get_active_upload() -> dict[str, Any] | None:
|
||||
payload = _read_json_cache(HOMESCREEN_UPLOAD_STATE_PATH, {})
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
upload_id = str(payload.get("upload_id") or "").strip()
|
||||
if not upload_id:
|
||||
return None
|
||||
path = _upload_path(upload_id)
|
||||
if not path.exists():
|
||||
return None
|
||||
return {
|
||||
"upload_id": upload_id,
|
||||
"filename": str(payload.get("filename") or path.name),
|
||||
"size_bytes": int(payload.get("size_bytes") or path.stat().st_size),
|
||||
"uploaded_at": payload.get("uploaded_at"),
|
||||
"sha256": str(payload.get("sha256") or ""),
|
||||
"path": str(path),
|
||||
}
|
||||
|
||||
|
||||
def save_uploaded_db(filename: str, content: bytes) -> dict[str, Any]:
|
||||
if not content:
|
||||
raise ValueError("Uploaded file is empty.")
|
||||
HOMESCREEN_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
upload_id = uuid.uuid4().hex
|
||||
path = _upload_path(upload_id)
|
||||
path.write_bytes(content)
|
||||
meta = {
|
||||
"upload_id": upload_id,
|
||||
"filename": Path(filename or "users.db").name or "users.db",
|
||||
"size_bytes": len(content),
|
||||
"uploaded_at": _iso_now(),
|
||||
"sha256": hashlib.sha256(content).hexdigest(),
|
||||
}
|
||||
_write_json_cache(HOMESCREEN_UPLOAD_STATE_PATH, meta)
|
||||
return {**meta, "path": str(path)}
|
||||
|
||||
|
||||
def resolve_db_source(db_path: str | None = None, upload_id: str | None = None) -> tuple[str, dict[str, Any] | None]:
|
||||
if upload_id:
|
||||
path = _upload_path(upload_id)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Uploaded database not found for id {upload_id}.")
|
||||
active = get_active_upload()
|
||||
if active and active.get("upload_id") == upload_id:
|
||||
return str(path), active
|
||||
stat = path.stat()
|
||||
return str(path), {
|
||||
"upload_id": upload_id,
|
||||
"filename": path.name,
|
||||
"size_bytes": stat.st_size,
|
||||
"uploaded_at": None,
|
||||
"sha256": "",
|
||||
"path": str(path),
|
||||
}
|
||||
active = get_active_upload()
|
||||
if active:
|
||||
return active["path"], active
|
||||
if db_path:
|
||||
return db_path, None
|
||||
raise FileNotFoundError("No homescreen database source configured.")
|
||||
|
||||
|
||||
def read_cached_emby_users() -> dict[str, Any]:
|
||||
payload = _read_json_cache(EMBY_USERS_CACHE_PATH, {"users": [], "lastSyncedAt": None})
|
||||
users = payload.get("users") if isinstance(payload, dict) else []
|
||||
last_synced = payload.get("lastSyncedAt") if isinstance(payload, dict) else None
|
||||
normalized = [
|
||||
{"embyGuid": normalize_guid(user.get("embyGuid")), "name": str(user.get("name") or "").strip()}
|
||||
for user in users
|
||||
if normalize_guid(user.get("embyGuid")) and str(user.get("name") or "").strip()
|
||||
]
|
||||
normalized.sort(key=lambda user: (user["name"].lower(), user["embyGuid"]))
|
||||
return {"users": normalized, "lastSyncedAt": last_synced}
|
||||
|
||||
|
||||
def write_cached_emby_users(users: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
fetched_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
payload = {
|
||||
"users": [
|
||||
{"embyGuid": normalize_guid(user.get("embyGuid")), "name": str(user.get("name") or "").strip()}
|
||||
for user in users
|
||||
if normalize_guid(user.get("embyGuid")) and str(user.get("name") or "").strip()
|
||||
],
|
||||
"lastSyncedAt": fetched_at,
|
||||
}
|
||||
payload["users"].sort(key=lambda user: (user["name"].lower(), user["embyGuid"]))
|
||||
_write_json_cache(EMBY_USERS_CACHE_PATH, payload)
|
||||
return payload
|
||||
|
||||
|
||||
def apply_cached_emby_names(users: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
cached = read_cached_emby_users()
|
||||
lookup = {user["embyGuid"]: user for user in cached["users"]}
|
||||
enriched = []
|
||||
matched = 0
|
||||
for user in users:
|
||||
emby_guid = normalize_guid(user.get("embyGuid"))
|
||||
cached_user = lookup.get(emby_guid)
|
||||
if cached_user:
|
||||
matched += 1
|
||||
enriched.append(
|
||||
{
|
||||
**user,
|
||||
"dbName": user.get("dbName") or user.get("name"),
|
||||
"embyName": cached_user["name"] if cached_user else user.get("embyName"),
|
||||
"name": cached_user["name"] if cached_user else user.get("name"),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"users": enriched,
|
||||
"cache": {
|
||||
"matchedCount": matched,
|
||||
"totalCachedUsers": len(cached["users"]),
|
||||
"lastSyncedAt": cached.get("lastSyncedAt"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def read_cached_user_context(emby_guid: str) -> dict[str, Any] | None:
|
||||
payload = _read_json_cache(EMBY_USER_CONTEXT_CACHE_PATH, {})
|
||||
return payload.get(normalize_guid(emby_guid)) if isinstance(payload, dict) else None
|
||||
|
||||
|
||||
def write_cached_user_context(emby_guid: str, context: dict[str, Any]) -> dict[str, Any]:
|
||||
payload = _read_json_cache(EMBY_USER_CONTEXT_CACHE_PATH, {})
|
||||
normalized_guid = normalize_guid(emby_guid)
|
||||
payload[normalized_guid] = context
|
||||
_write_json_cache(EMBY_USER_CONTEXT_CACHE_PATH, payload)
|
||||
return context
|
||||
Reference in New Issue
Block a user