From ada61e1400d60a7eb73d405c7898912746ea7f29 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sat, 30 May 2026 02:28:58 +0000 Subject: [PATCH] Remove long hex root ids and use only friendly names --- docs/API.md | 3 ++- docs/multi-index-plan.md | 8 ++++---- frontend/src/App.vue | 2 +- frontend/src/api.ts | 1 - frontend/src/components/Header.vue | 20 +++++------------- mediahive/models/protocol.py | 1 - mediahive/root_registry.py | 33 +++++++----------------------- mediahive/server.py | 4 +--- 8 files changed, 20 insertions(+), 52 deletions(-) diff --git a/docs/API.md b/docs/API.md index 2557285..c26d6a1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,7 +1,8 @@ # API MediaHive exposes a small local API used by the desktop app and frontend. -All media paths are scoped to a **root**, identified by a stable `root_id`. +All media paths are scoped to a **root**, identified by a friendly `root_id` +(same identifier shown as the root name). ## Endpoints diff --git a/docs/multi-index-plan.md b/docs/multi-index-plan.md index a7b99ff..301de48 100644 --- a/docs/multi-index-plan.md +++ b/docs/multi-index-plan.md @@ -8,9 +8,9 @@ MediaHive now supports multiple independent media roots. Each root is a filesyst ### Root Identity -- **Root ID**: first 12 hex chars of SHA-256 of the *normalized* absolute path. -- **Normalization**: resolve symlinks, lower-case Windows drive letter, strip trailing slashes, forward slashes only (`as_posix()`). -- **Name**: derived from path basename; collisions resolved with `2`, `3`, … suffix. +- **Root ID**: friendly root name derived from configured path basename. +- **Name/ID collision handling**: suffixes `2`, `3`, … are appended to keep each root ID unique. +- **Path normalization**: lower-case Windows drive letter, strip trailing slashes, forward slashes only (`as_posix()`). ### Per-Root Runtime (`RootContext`) @@ -27,7 +27,7 @@ Each active root gets an isolated `RootContext` managed by the `Supervisor`: - Holds `dict[str, RootContext]` keyed by `root_id`. - `replace_roots(new_roots)` atomically swaps the active set: 1. Validate & canonicalize paths. - 2. Compute `root_id` for each. + 2. Derive unique friendly `root_id` for each. 3. Prepare new `RootContext`s (load snapshots). 4. Swap dict atomically. 5. Stop removed contexts in background with bounded timeout. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 2042068..6bad236 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -271,7 +271,7 @@ async function refreshRoots() { const activeIds: string[] = [] for (const r of roots) { newMap.set(r.root_id, { - name: r.name, + name: r.root_id, path: r.path, status: r.status, }) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 3168107..bf8a287 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -11,7 +11,6 @@ export interface PlayerInfo { export interface RootStatus { root_id: string - name: string path: string status: string error: string | null diff --git a/frontend/src/components/Header.vue b/frontend/src/components/Header.vue index 2a10d6d..b2e8ef8 100644 --- a/frontend/src/components/Header.vue +++ b/frontend/src/components/Header.vue @@ -118,7 +118,7 @@ :class="`roots-item--${root.status}`" >
- {{ root.name }} + {{ root.root_id }} {{ root.path }}
@@ -323,7 +323,6 @@ const selectedPlayerFamily = computed(() => { interface RootEntry { root_id: string - name: string path: string status: string } @@ -410,7 +409,6 @@ async function refreshRoots() { const data = await fetchRoots() roots.value = data.map((r) => ({ root_id: r.root_id, - name: r.name, path: r.path, status: r.status, })) @@ -421,7 +419,7 @@ async function refreshRoots() { async function removeRoot(rootId: string) { const filtered = roots.value.filter((r) => r.root_id !== rootId) - const newRoots = Object.fromEntries(filtered.map((r) => [r.name, r.path])) + const newRoots = Object.fromEntries(filtered.map((r) => [r.root_id, r.path])) try { await replaceRoots(newRoots) await refreshRoots() @@ -434,17 +432,9 @@ async function removeRoot(rootId: string) { async function addRoot() { const folder = await pickFolderAndAddRoot() if (!folder) return - const name = folder.split("/").pop() || folder.split("\\").pop() || "media" - // Resolve name collisions - let uniqueName = name - let suffix = 2 - const currentNames = new Set(roots.value.map((r) => r.name)) - while (currentNames.has(uniqueName)) { - uniqueName = `${name}${suffix}` - suffix++ - } - const newRoots = Object.fromEntries(roots.value.map((r) => [r.name, r.path])) - newRoots[uniqueName] = folder + const suggestedId = folder.split("/").pop() || folder.split("\\").pop() || "media" + const newRoots = Object.fromEntries(roots.value.map((r) => [r.root_id, r.path])) + newRoots[suggestedId] = folder try { await replaceRoots(newRoots) await refreshRoots() diff --git a/mediahive/models/protocol.py b/mediahive/models/protocol.py index 63341e4..4c83350 100644 --- a/mediahive/models/protocol.py +++ b/mediahive/models/protocol.py @@ -75,7 +75,6 @@ class RootsRequest(msgspec.Struct): class RootEntryResponse(msgspec.Struct): """Single root entry in responses.""" - name: str path: str root_id: str diff --git a/mediahive/root_registry.py b/mediahive/root_registry.py index b54a5ba..da099ca 100644 --- a/mediahive/root_registry.py +++ b/mediahive/root_registry.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio import contextlib -import hashlib import logging from pathlib import Path @@ -17,7 +16,7 @@ from mediahive.models.events import ScanEvent, Task, Upsert logger = logging.getLogger("mediahive.root_registry") # --------------------------------------------------------------------------- -# Root ID +# Root path normalization and friendly name derivation # --------------------------------------------------------------------------- @@ -47,13 +46,6 @@ def _normalize_path(path: str) -> str: return posix -def compute_root_id(path: str) -> str: - """Return a stable 12-char hex root ID from a normalized path.""" - normalized = _normalize_path(path) - h = hashlib.sha256(normalized.encode("utf-8")).hexdigest() - return h[:12] - - def _derive_root_name(path: str) -> str: """Derive a friendly root name from a path basename/anchor.""" normalized = (path or "").replace("\\", "/").rstrip("/") @@ -74,7 +66,6 @@ def _derive_root_name(path: str) -> str: class RootEntry(msgspec.Struct): - name: str path: str root_id: str @@ -87,9 +78,8 @@ class RootEntry(msgspec.Struct): class RootContext: """Runtime container for a single media root.""" - def __init__(self, root_id: str, root_path: Path, name: str | None = None) -> None: + def __init__(self, root_id: str, root_path: Path) -> None: self.root_id = root_id - self.name = name or root_id self.root_path = root_path self.status = "loading" self.error: str | None = None @@ -208,7 +198,6 @@ class Supervisor: return [ { "root_id": ctx.root_id, - "name": ctx.name, "path": ctx.root_path.as_posix(), "status": ctx.status, "error": ctx.error, @@ -265,14 +254,12 @@ class Supervisor: # Validate and canonicalize candidates: list[RootEntry] = [] seen_paths: set[str] = set() - seen_ids: set[str] = set() failed: list[dict] = [] - for requested_name, path_str in roots.items(): + for path_str in roots.values(): p = Path(path_str).expanduser() if not p.exists() or not p.is_dir(): failed.append({ - "name": requested_name, "path": path_str, "reason": "not a directory", }) @@ -280,17 +267,11 @@ class Supervisor: norm = _normalize_path(p.as_posix()) if norm in seen_paths: failed.append({ - "name": requested_name, "path": path_str, "reason": "duplicate path", }) continue seen_paths.add(norm) - rid = compute_root_id(str(p)) - if rid in seen_ids: - # Extremely unlikely hash collision — fall back to full hash - rid = hashlib.sha256(norm.encode("utf-8")).hexdigest()[:16] - seen_ids.add(rid) # Friendly names should reflect the configured root path (e.g. "Z:" -> "Z"), # not the resolved physical target (which may be a UNC path). @@ -303,7 +284,8 @@ class Supervisor: unique_name = f"{base_name}{suffix}" suffix += 1 - candidates.append(RootEntry(name=unique_name, path=norm, root_id=rid)) + # root_id now uses the same friendly identifier as the display name. + candidates.append(RootEntry(path=norm, root_id=unique_name)) # Build desired root_id set desired_ids = {e.root_id for e in candidates} @@ -320,13 +302,12 @@ class Supervisor: existing = self._contexts.get(entry.root_id) if existing and existing.root_path.as_posix() == entry.path: # Reuse existing context - existing.name = entry.name new_contexts[entry.root_id] = existing else: # If existing path changed, stop old one if existing: asyncio.create_task(existing.stop()) - ctx = RootContext(entry.root_id, Path(entry.path), entry.name) + ctx = RootContext(entry.root_id, Path(entry.path)) await ctx.start() new_contexts[entry.root_id] = ctx @@ -338,7 +319,7 @@ class Supervisor: save_config( msgspec.structs.replace( cfg, - roots={e.name: e.path for e in candidates}, + roots={e.root_id: e.path for e in candidates}, ) ) diff --git a/mediahive/server.py b/mediahive/server.py index ed218c6..6e66d96 100644 --- a/mediahive/server.py +++ b/mediahive/server.py @@ -448,9 +448,7 @@ async def put_roots(request: Request): return { "status": "ok", - "accepted": [ - {"name": e.name, "path": e.path, "root_id": e.root_id} for e in accepted - ], + "accepted": [{"path": e.path, "root_id": e.root_id} for e in accepted], "failed": failed, }