From 94f2f10dcd11ea50468067cb68110625d6a864ef Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 24 May 2026 17:40:55 +0000 Subject: [PATCH] Fix root path handling and release action menus --- frontend/src/App.vue | 20 +- frontend/src/api.ts | 1 + frontend/src/components/Header.vue | 2 +- frontend/src/components/MediaDetail.vue | 130 +++++++++++- frontend/src/components/ReleaseActionMenu.vue | 192 ++++++++++++++++++ frontend/src/components/SeriesFullView.vue | 93 +++------ .../src/composables/useKeyboardNavigation.ts | 2 + mediahive/index_store.py | 7 +- mediahive/root_registry.py | 58 ++++-- mediahive/server.py | 42 ++-- 10 files changed, 444 insertions(+), 103 deletions(-) create mode 100644 frontend/src/components/ReleaseActionMenu.vue diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 6860c88..0a534ec 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -67,6 +67,7 @@ :item="selectedItem" :focus-episode="focusEpisode" :has-resume-position="hasResumePosition" + :get-root-name="getRootName" @close="closeDetail" @play="handlePlay" @open-folder="handleOpenFolder" @@ -232,15 +233,24 @@ const { mediaIndex, loading, error, connected: wsConnected, tasks, setActiveRoot const activeTasks = computed(() => Array.from(tasks.value.values())); // Poll for active roots and connect WS to them -const rootStatuses = ref>(new Map()); +const rootStatuses = ref>(new Map()); + +function getRootName(rootId: string | null | undefined): string | null { + if (!rootId) return null; + return rootStatuses.value.get(rootId)?.name || null; +} async function refreshRoots() { try { const roots = await fetchRoots(); - const newMap = new Map(); + const newMap = new Map(); const activeIds: string[] = []; for (const r of roots) { - newMap.set(r.root_id, { path: r.path, status: r.status }); + newMap.set(r.root_id, { + name: r.name, + path: r.path, + status: r.status, + }); if (r.status === 'ready' || r.status === 'scanning' || r.status === 'loading') { activeIds.push(r.root_id); } @@ -1516,8 +1526,8 @@ async function handlePlay(filePath: string) { } } -async function handleOpenFolder(folderPath: string) { - const rootId = findRootIdForPath(folderPath); +async function handleOpenFolder(folderPath: string, explicitRootId?: string | null) { + const rootId = explicitRootId || findRootIdForPath(folderPath); if (!rootId) { console.error('Cannot open folder: unknown root for path', folderPath); return; diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 4d17faf..06300f2 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -6,6 +6,7 @@ export interface PlayerStatus { 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 c59f152..563006d 100644 --- a/frontend/src/components/Header.vue +++ b/frontend/src/components/Header.vue @@ -177,7 +177,7 @@ async function refreshRoots() { const data = await fetchRoots(); roots.value = data.map(r => ({ root_id: r.root_id, - name: r.path.split('/').pop() || r.path.split('\\').pop() || r.root_id, + name: r.name, path: r.path, status: r.status, })); diff --git a/frontend/src/components/MediaDetail.vue b/frontend/src/components/MediaDetail.vue index 0035d56..751e5fe 100644 --- a/frontend/src/components/MediaDetail.vue +++ b/frontend/src/components/MediaDetail.vue @@ -5,6 +5,7 @@ :series="item.data as Series" :focus-episode="focusEpisode" :has-resume-position="hasResumePosition" + :get-root-name="getRootName" @close="$emit('close')" @play="handlePlay" @openFolder="handleOpenFolder" @@ -96,7 +97,9 @@ :disabled="!version.playable_file" v-bind="navAttrs(2, index, index === 0 ? 0 : undefined)" @activate="handleVersionActivate(version, $event)" - :title="version.playable_file ? 'Click to play/continue. Alt+Click to open folder.' : 'No playable file'" + @keydown="handleVersionShortcutKeydown($event, version)" + @contextmenu="handleVersionContextMenu($event, version)" + :title="version.playable_file ? 'Click to play/continue. Alt+Click, Alt+Enter, or Cmd/Ctrl+E to open folder. Right-click for actions.' : 'No playable file'" /> @@ -152,28 +155,49 @@ + + +
+ +
diff --git a/frontend/src/components/SeriesFullView.vue b/frontend/src/components/SeriesFullView.vue index 40666e4..ecce48b 100644 --- a/frontend/src/components/SeriesFullView.vue +++ b/frontend/src/components/SeriesFullView.vue @@ -150,26 +150,16 @@ No versions available -
- - -
+ @@ -180,17 +170,19 @@ import type { Series, Season, Episode, Torrent } from '../types'; import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser } from '../api'; import { navAttrs } from '../composables/useKeyboardNavigation'; import ReleaseVersionCard from './ReleaseVersionCard.vue'; +import ReleaseActionMenu from './ReleaseActionMenu.vue'; const props = defineProps<{ series: Series; focusEpisode?: { seasonNumber: number; episodeNumber: number } | null; hasResumePosition: (filePath: string | null) => boolean; + getRootName: (rootId: string | null | undefined) => string | null; }>(); const emit = defineEmits<{ close: []; play: [string]; - openFolder: [string]; + openFolder: [string, string | null | undefined]; }>(); // Focus on matched episode when provided @@ -235,12 +227,16 @@ const versionActionMenu = ref<{ visible: boolean; x: number; y: number; - torrent: Torrent | null; + filePath: string | null; + rootName: string | null; + rootId: string | null; }>({ visible: false, x: 0, y: 0, - torrent: null, + filePath: null, + rootName: null, + rootId: null, }); const releaseMenuOriginElement = ref(null); @@ -348,7 +344,9 @@ function closeContextMenu() { function closeVersionActionMenu() { versionActionMenu.value.visible = false; - versionActionMenu.value.torrent = null; + versionActionMenu.value.filePath = null; + versionActionMenu.value.rootName = null; + versionActionMenu.value.rootId = null; } function handleGamepadAction(event: Event) { @@ -384,17 +382,18 @@ function getPlayLabel(filePath: string | null): string { } // Open folder for a version -function handleOpenFolder(folderPath: string) { +function handleOpenFolder(folderPath: string, rootId?: string | null) { if (!folderPath) return; - emit('openFolder', folderPath); + emit('openFolder', folderPath, rootId); closeVersionActionMenu(); closeContextMenu(); } function handleVersionActivate(torrent: Torrent, event: MouseEvent | KeyboardEvent) { if (!torrent.playable_file) return; + const rootId = torrent.root_id || props.series.root_id; if (event.altKey) { - handleOpenFolder(torrent.playable_file); + handleOpenFolder(torrent.playable_file, rootId); return; } handlePlayVersion(torrent.playable_file); @@ -402,22 +401,26 @@ function handleVersionActivate(torrent: Torrent, event: MouseEvent | KeyboardEve function handleVersionShortcutKeydown(event: KeyboardEvent, torrent: Torrent) { if (!torrent.playable_file) return; + const rootId = torrent.root_id || props.series.root_id; const key = event.key.toLowerCase(); if (key === 'e' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); event.stopPropagation(); - handleOpenFolder(torrent.playable_file); + handleOpenFolder(torrent.playable_file, rootId); } } function handleVersionContextMenu(event: MouseEvent, torrent: Torrent) { event.preventDefault(); event.stopPropagation(); + const rootId = torrent.root_id || props.series.root_id; versionActionMenu.value = { visible: true, x: event.clientX, y: event.clientY, - torrent, + filePath: torrent.playable_file || null, + rootName: props.getRootName(rootId) || null, + rootId, }; nextTick(() => { const firstAction = document.querySelector('.version-action-menu .version-action-item:not(:disabled)') as HTMLElement | null; @@ -1059,36 +1062,4 @@ html.mouse-active .episode-tile:hover .tile-play { font-size: 0.85rem; } -.version-action-menu { - position: fixed; - z-index: 1001; - min-width: 180px; - background: rgba(18, 20, 28, 0.98); - border: 1px solid rgba(255, 255, 255, 0.18); - border-radius: 8px; - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45); - overflow: hidden; -} - -.version-action-item { - width: 100%; - border: none; - background: transparent; - color: #fff; - text-align: left; - padding: 10px 12px; - font-size: 0.82rem; - cursor: pointer; -} - -html.mouse-active .version-action-item:hover:not(:disabled), -.version-action-item:focus-visible:not(:disabled) { - background: rgba(255, 255, 255, 0.12); - outline: none; -} - -.version-action-item:disabled { - opacity: 0.45; - cursor: not-allowed; -} diff --git a/frontend/src/composables/useKeyboardNavigation.ts b/frontend/src/composables/useKeyboardNavigation.ts index 1d82def..376ab01 100644 --- a/frontend/src/composables/useKeyboardNavigation.ts +++ b/frontend/src/composables/useKeyboardNavigation.ts @@ -610,6 +610,8 @@ function handleKeyDown(event: KeyboardEvent) { */ function handleEnterKey(event: KeyboardEvent) { if (event.key !== 'Enter') return; + if (event.defaultPrevented) return; + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; const target = event.target as HTMLElement; if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') { diff --git a/mediahive/index_store.py b/mediahive/index_store.py index feaa360..f17cbed 100644 --- a/mediahive/index_store.py +++ b/mediahive/index_store.py @@ -69,11 +69,14 @@ class IndexStore: # ------------------------------------------------------------------ def _maybe_migrate_id(self, item_id: str) -> str: - """Prepend root_id to legacy item IDs that lack it.""" + """Normalize item ID to this store's current root_id namespace.""" if not self.root_id: return item_id if ":" in item_id: - return item_id + # If snapshot was created under a different root_id prefix, + # remap to current root_id while preserving content hash. + _, content_hash = item_id.split(":", 1) + return f"{self.root_id}:{content_hash}" return f"{self.root_id}:{item_id}" async def load_snapshot(self) -> None: diff --git a/mediahive/root_registry.py b/mediahive/root_registry.py index 7cc1d62..1ec50dd 100644 --- a/mediahive/root_registry.py +++ b/mediahive/root_registry.py @@ -26,18 +26,23 @@ logger = logging.getLogger("mediahive.root_registry") def _normalize_path(path: str) -> str: """Canonicalize a path for stable ID generation. - - resolve() to follow symlinks and normalize .. + - expanduser() only (do not resolve symlinks/mapped drives) - lower-case drive letter on Windows - strip trailing separators - use forward slashes """ - p = Path(path).expanduser().resolve() + p = Path(path).expanduser() posix = p.as_posix() + # Canonicalize drive-only roots ("Z:") to drive root ("Z:/") so paths are absolute. + if len(posix) == 2 and posix[1] == ":" and posix[0].isalpha(): + posix = f"{posix}/" # Windows drive letter normalization if len(posix) >= 2 and posix[1] == ":": posix = posix[0].lower() + posix[1:] # Strip trailing slash (except root "/") - while len(posix) > 1 and posix.endswith("/"): + while len(posix) > 1 and posix.endswith("/") and not ( + len(posix) == 3 and posix[1] == ":" and posix[2] == "/" + ): posix = posix[:-1] return posix @@ -49,6 +54,20 @@ def compute_root_id(path: str) -> str: 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("/") + if not normalized: + return "media" + parts = [segment for segment in normalized.split("/") if segment] + if parts: + leaf = parts[-1] + if len(leaf) == 2 and leaf[1] == ":" and leaf[0].isalpha(): + return leaf[0] + return leaf + return "media" + + # --------------------------------------------------------------------------- # Root entry # --------------------------------------------------------------------------- @@ -68,8 +87,9 @@ class RootEntry(msgspec.Struct): class RootContext: """Runtime container for a single media root.""" - def __init__(self, root_id: str, root_path: Path): + def __init__(self, root_id: str, root_path: Path, name: str | None = None): self.root_id = root_id + self.name = name or root_id self.root_path = root_path self.status = "loading" self.error: Optional[str] = None @@ -171,6 +191,7 @@ class Supervisor: return [ { "root_id": ctx.root_id, + "name": ctx.name, "path": ctx.root_path.as_posix(), "status": ctx.status, "error": ctx.error, @@ -229,18 +250,14 @@ class Supervisor: seen_ids: set[str] = set() failed: list[dict] = [] - for name, path_str in roots.items(): - name = name.strip() - if not name: - failed.append({"name": name, "path": path_str, "reason": "empty name"}) - continue - p = Path(path_str).expanduser().resolve() + for requested_name, path_str in roots.items(): + p = Path(path_str).expanduser() if not p.exists() or not p.is_dir(): - failed.append({"name": name, "path": path_str, "reason": "not a directory"}) + failed.append({"name": requested_name, "path": path_str, "reason": "not a directory"}) continue norm = _normalize_path(p.as_posix()) if norm in seen_paths: - failed.append({"name": name, "path": path_str, "reason": "duplicate path"}) + failed.append({"name": requested_name, "path": path_str, "reason": "duplicate path"}) continue seen_paths.add(norm) rid = compute_root_id(str(p)) @@ -248,7 +265,19 @@ class Supervisor: # Extremely unlikely hash collision — fall back to full hash rid = hashlib.sha256(norm.encode("utf-8")).hexdigest()[:16] seen_ids.add(rid) - candidates.append(RootEntry(name=name, path=norm, root_id=rid)) + + # Friendly names should reflect the configured root path (e.g. "Z:" -> "Z"), + # not the resolved physical target (which may be a UNC path). + configured_path = Path(path_str).expanduser().as_posix() + base_name = _derive_root_name(configured_path) + unique_name = base_name + suffix = 2 + existing_names = {e.name for e in candidates} + while unique_name in existing_names: + unique_name = f"{base_name}{suffix}" + suffix += 1 + + candidates.append(RootEntry(name=unique_name, path=norm, root_id=rid)) # Build desired root_id set desired_ids = {e.root_id for e in candidates} @@ -265,12 +294,13 @@ 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)) + ctx = RootContext(entry.root_id, Path(entry.path), entry.name) await ctx.start() new_contexts[entry.root_id] = ctx diff --git a/mediahive/server.py b/mediahive/server.py index 0d9a7e4..65c63b5 100644 --- a/mediahive/server.py +++ b/mediahive/server.py @@ -147,11 +147,21 @@ def _validate_root_paths(roots: dict[str, str]) -> dict[str, str]: """ validated: dict[str, str] = {} for name, path_str in roots.items(): - p = Path(path_str).expanduser().resolve() - if not p.exists() or not p.is_dir(): + configured_posix = Path(path_str).expanduser().as_posix() + if ( + len(configured_posix) == 2 + and configured_posix[1] == ":" + and configured_posix[0].isalpha() + ): + configured_posix = f"{configured_posix}/" + + configured_path = Path(configured_posix) + if not configured_path.exists() or not configured_path.is_dir(): logger.warning("Root path invalid, skipping: %s", path_str) continue - validated[name] = p.as_posix() + # Keep the configured path form (POSIX separators) so downstream naming + # can reflect user intent (e.g. mapped drive "Z:") instead of UNC. + validated[name] = configured_posix return validated @@ -177,21 +187,24 @@ async def _activate_all_roots() -> None: """ desired: dict[str, str] = {} - # 1. Persisted config roots - cfg = load_config() - if cfg.roots: - desired.update(cfg.roots) - - # 2. CLI roots via MEDIAHIVE_ROOTS (JSON dict) + # 1. CLI roots via MEDIAHIVE_ROOTS (JSON dict) env_roots_raw = os.environ.get("MEDIAHIVE_ROOTS") + env_roots: dict[str, str] | None = None if env_roots_raw: try: - env_roots = json.loads(env_roots_raw) - if isinstance(env_roots, dict): - desired.update(env_roots) + parsed = json.loads(env_roots_raw) + if isinstance(parsed, dict): + env_roots = parsed except Exception: logger.exception("Failed to parse MEDIAHIVE_ROOTS") + # 2. Persisted config roots (used only when CLI roots are not provided) + cfg = load_config() + if env_roots is not None: + desired.update(env_roots) + elif cfg.roots: + desired.update(cfg.roots) + if not desired: logger.info("No roots configured; waiting for PUT /api/roots") return @@ -384,12 +397,13 @@ async def open_folder(root_id: str, request: Request): try: if sys.platform == "win32": + native_path = str(target_path).replace("/", "\\") if target_path.is_file(): subprocess.Popen( - ["explorer", "/select,", str(target_path)], **_POPEN_KWARGS + ["explorer", "/select,", native_path], **_POPEN_KWARGS ) else: - subprocess.Popen(["explorer", str(target_path)], **_POPEN_KWARGS) + subprocess.Popen(["explorer", native_path], **_POPEN_KWARGS) elif sys.platform == "darwin": if target_path.is_file(): subprocess.Popen(["open", "-R", str(target_path)])