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,
}