Remove long hex root ids and use only friendly names
This commit is contained in:
1 parent
3551808351
commit
ada61e1400
8 files changed
+20
-52
No files matched your search
+2
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -11,7 +11,6 @@ export interface PlayerInfo {
|
||||
|
||||
export interface RootStatus {
|
||||
root_id: string
|
||||
name: string
|
||||
path: string
|
||||
status: string
|
||||
error: string | null
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -75,7 +75,6 @@ class RootsRequest(msgspec.Struct):
|
||||
class RootEntryResponse(msgspec.Struct):
|
||||
"""Single root entry in responses."""
|
||||
|
||||
name: str
|
||||
path: str
|
||||
root_id: str
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user