Homelabtoolkit v2

This commit is contained in:
2026-06-08 21:58:16 +12:00
parent 040fbacc70
commit 3c77066beb
75 changed files with 16945 additions and 374 deletions
+634
View File
@@ -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