Remove long hex root ids and use only friendly names

This commit is contained in:
LeoVasanko committed 2026-05-30 02:28:58 +00:00
1 parent 3551808351
commit ada61e1400
8 files changed
+20 -52

No files matched your search

+2 -1
View File
@@ -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
+4 -4
View File
@@ -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.
+1 -1
View File
@@ -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,
})
-1
View File
@@ -11,7 +11,6 @@ export interface PlayerInfo {
export interface RootStatus {
root_id: string
name: string
path: string
status: string
error: string | null
+5 -15
View File
@@ -118,7 +118,7 @@
:class="`roots-item--${root.status}`"
>
<div class="roots-item-info">
<span class="roots-item-name">{{ root.name }}</span>
<span class="roots-item-name">{{ root.root_id }}</span>
<span class="roots-item-path">{{ root.path }}</span>
</div>
<div class="roots-item-meta">
@@ -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()
-1
View File
@@ -75,7 +75,6 @@ class RootsRequest(msgspec.Struct):
class RootEntryResponse(msgspec.Struct):
"""Single root entry in responses."""
name: str
path: str
root_id: str
+7 -26
View File
@@ -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},
)
)
+1 -3
View File
@@ -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,
}