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