Fix root path handling and release action menus

This commit is contained in:
2026-05-24 17:40:55 +00:00
parent 487e410a8f
commit 94f2f10dcd
10 changed files with 444 additions and 103 deletions
+15 -5
View File
@@ -67,6 +67,7 @@
:item="selectedItem" :item="selectedItem"
:focus-episode="focusEpisode" :focus-episode="focusEpisode"
:has-resume-position="hasResumePosition" :has-resume-position="hasResumePosition"
:get-root-name="getRootName"
@close="closeDetail" @close="closeDetail"
@play="handlePlay" @play="handlePlay"
@open-folder="handleOpenFolder" @open-folder="handleOpenFolder"
@@ -232,15 +233,24 @@ const { mediaIndex, loading, error, connected: wsConnected, tasks, setActiveRoot
const activeTasks = computed<TaskInfo[]>(() => Array.from(tasks.value.values())); const activeTasks = computed<TaskInfo[]>(() => Array.from(tasks.value.values()));
// Poll for active roots and connect WS to them // Poll for active roots and connect WS to them
const rootStatuses = ref<Map<string, { path: string; status: string }>>(new Map()); const rootStatuses = ref<Map<string, { name: string; path: string; status: string }>>(new Map());
function getRootName(rootId: string | null | undefined): string | null {
if (!rootId) return null;
return rootStatuses.value.get(rootId)?.name || null;
}
async function refreshRoots() { async function refreshRoots() {
try { try {
const roots = await fetchRoots(); const roots = await fetchRoots();
const newMap = new Map<string, { path: string; status: string }>(); const newMap = new Map<string, { name: string; path: string; status: string }>();
const activeIds: string[] = []; const activeIds: string[] = [];
for (const r of roots) { 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') { if (r.status === 'ready' || r.status === 'scanning' || r.status === 'loading') {
activeIds.push(r.root_id); activeIds.push(r.root_id);
} }
@@ -1516,8 +1526,8 @@ async function handlePlay(filePath: string) {
} }
} }
async function handleOpenFolder(folderPath: string) { async function handleOpenFolder(folderPath: string, explicitRootId?: string | null) {
const rootId = findRootIdForPath(folderPath); const rootId = explicitRootId || findRootIdForPath(folderPath);
if (!rootId) { if (!rootId) {
console.error('Cannot open folder: unknown root for path', folderPath); console.error('Cannot open folder: unknown root for path', folderPath);
return; return;
+1
View File
@@ -6,6 +6,7 @@ export interface PlayerStatus {
export interface RootStatus { export interface RootStatus {
root_id: string; root_id: string;
name: string;
path: string; path: string;
status: string; status: string;
error: string | null; error: string | null;
+1 -1
View File
@@ -177,7 +177,7 @@ async function refreshRoots() {
const data = await fetchRoots(); const data = await fetchRoots();
roots.value = data.map(r => ({ roots.value = data.map(r => ({
root_id: r.root_id, root_id: r.root_id,
name: r.path.split('/').pop() || r.path.split('\\').pop() || r.root_id, name: r.name,
path: r.path, path: r.path,
status: r.status, status: r.status,
})); }));
+124 -6
View File
@@ -5,6 +5,7 @@
:series="item.data as Series" :series="item.data as Series"
:focus-episode="focusEpisode" :focus-episode="focusEpisode"
:has-resume-position="hasResumePosition" :has-resume-position="hasResumePosition"
:get-root-name="getRootName"
@close="$emit('close')" @close="$emit('close')"
@play="handlePlay" @play="handlePlay"
@openFolder="handleOpenFolder" @openFolder="handleOpenFolder"
@@ -96,7 +97,9 @@
:disabled="!version.playable_file" :disabled="!version.playable_file"
v-bind="navAttrs(2, index, index === 0 ? 0 : undefined)" v-bind="navAttrs(2, index, index === 0 ? 0 : undefined)"
@activate="handleVersionActivate(version, $event)" @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'"
/> />
</div> </div>
</div> </div>
@@ -152,28 +155,49 @@
</div> </div>
</div> </div>
</div> </div>
<Teleport to="body">
<div
v-if="versionActionMenu.visible"
class="movie-menu-backdrop"
@click="closeVersionActionMenu"
@contextmenu.prevent="closeVersionActionMenu"
></div>
<ReleaseActionMenu
:visible="versionActionMenu.visible"
:x="versionActionMenu.x"
:y="versionActionMenu.y"
:file-path="versionActionMenu.filePath"
:root-name="versionActionMenu.rootName"
:play-label="getPlayLabel(versionActionMenu.filePath)"
@play="handlePlayVersion(versionActionMenu.filePath)"
@open-folder="handleOpenFolder(versionActionMenu.filePath || '', versionActionMenu.rootId)"
/>
</Teleport>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, onMounted, nextTick } from 'vue'; import { computed, ref, watch, onMounted, onUnmounted, nextTick } from 'vue';
import type { CastMember, MediaItem, Movie, Series, Torrent } from '../types'; import type { CastMember, MediaItem, Movie, Series, Torrent } from '../types';
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser, type VideoSourceAttributes } from '../api'; import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser, type VideoSourceAttributes } from '../api';
import castPlaceholderFemaleUrl from '../assets/cast-placeholder-female.svg'; import castPlaceholderFemaleUrl from '../assets/cast-placeholder-female.svg';
import castPlaceholderMaleUrl from '../assets/cast-placeholder-male.svg'; import castPlaceholderMaleUrl from '../assets/cast-placeholder-male.svg';
import SeriesFullView from './SeriesFullView.vue'; import SeriesFullView from './SeriesFullView.vue';
import ReleaseVersionCard from './ReleaseVersionCard.vue'; import ReleaseVersionCard from './ReleaseVersionCard.vue';
import ReleaseActionMenu from './ReleaseActionMenu.vue';
import { navAttrs } from '../composables/useKeyboardNavigation'; import { navAttrs } from '../composables/useKeyboardNavigation';
const props = defineProps<{ const props = defineProps<{
item: MediaItem; item: MediaItem;
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null; focusEpisode?: { seasonNumber: number; episodeNumber: number } | null;
hasResumePosition: (filePath: string | null) => boolean; hasResumePosition: (filePath: string | null) => boolean;
getRootName: (rootId: string | null | undefined) => string | null;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
close: []; close: [];
play: [string]; play: [string];
openFolder: [string]; openFolder: [string, string | null | undefined];
searchActor: [string]; searchActor: [string];
}>(); }>();
@@ -456,6 +480,84 @@ const seasons = computed(() => {
const selectedSeasonIndex = ref<number>(0); const selectedSeasonIndex = ref<number>(0);
const versionActionMenu = ref<{
visible: boolean;
x: number;
y: number;
filePath: string | null;
rootName: string | null;
rootId: string | null;
}>({
visible: false,
x: 0,
y: 0,
filePath: null,
rootName: null,
rootId: null,
});
function closeVersionActionMenu() {
versionActionMenu.value.visible = false;
versionActionMenu.value.filePath = null;
versionActionMenu.value.rootName = null;
versionActionMenu.value.rootId = null;
}
function getPlayLabel(filePath: string | null): string {
return props.hasResumePosition(filePath) ? 'Continue' : 'Play';
}
function handlePlayVersion(filePath: string | null) {
if (filePath) {
emit('play', filePath);
}
closeVersionActionMenu();
}
function handleVersionContextMenu(event: MouseEvent, version: Torrent) {
event.preventDefault();
event.stopPropagation();
const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id);
versionActionMenu.value = {
visible: true,
x: event.clientX,
y: event.clientY,
filePath: version.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;
firstAction?.focus();
});
}
function handleVersionShortcutKeydown(event: KeyboardEvent, version: Torrent) {
if (!version.playable_file) return;
const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id);
const key = event.key.toLowerCase();
if (key === 'e' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
event.stopPropagation();
handleOpenFolder(version.playable_file, rootId);
return;
}
if (key === 'enter' && event.altKey) {
event.preventDefault();
event.stopPropagation();
handleOpenFolder(version.playable_file, rootId);
}
}
function handleMovieMenuKeydown(event: KeyboardEvent) {
if (!versionActionMenu.value.visible) return;
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
closeVersionActionMenu();
}
}
// Select first season by default // Select first season by default
watch(seasons, (s) => { watch(seasons, (s) => {
if (s.length > 0 && selectedSeasonIndex.value >= s.length) { if (s.length > 0 && selectedSeasonIndex.value >= s.length) {
@@ -471,15 +573,17 @@ function handlePlay(filePath: string | null) {
function handleVersionActivate(version: Torrent, event: MouseEvent | KeyboardEvent) { function handleVersionActivate(version: Torrent, event: MouseEvent | KeyboardEvent) {
if (!version.playable_file) return; if (!version.playable_file) return;
const rootId = version.root_id || ((props.item.data as Movie).root_id ?? props.item.root_id);
if (event.altKey) { if (event.altKey) {
handleOpenFolder(version.playable_file); handleOpenFolder(version.playable_file, rootId);
return; return;
} }
handlePlay(version.playable_file); handlePlay(version.playable_file);
} }
function handleOpenFolder(folderPath: string) { function handleOpenFolder(folderPath: string, rootId?: string | null) {
emit('openFolder', folderPath); closeVersionActionMenu();
emit('openFolder', folderPath, rootId);
} }
function handleCastSelect(castName: string) { function handleCastSelect(castName: string) {
@@ -487,6 +591,14 @@ function handleCastSelect(castName: string) {
if (!name) return; if (!name) return;
emit('searchActor', name); emit('searchActor', name);
} }
onMounted(() => {
document.addEventListener('keydown', handleMovieMenuKeydown, true);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleMovieMenuKeydown, true);
});
</script> </script>
<style scoped> <style scoped>
@@ -495,6 +607,12 @@ function handleCastSelect(castName: string) {
background-color: var(--bg-primary); background-color: var(--bg-primary);
} }
.movie-menu-backdrop {
position: fixed;
inset: 0;
z-index: 999;
}
.movie-page-content { .movie-page-content {
position: relative; position: relative;
} }
@@ -0,0 +1,192 @@
<template>
<div
v-if="visible"
ref="menuRef"
class="version-action-menu"
:style="menuStyle"
>
<div class="version-action-path" :title="resolvedPath">
{{ resolvedPath }}
</div>
<button
class="version-action-item"
:disabled="disabled"
@click="emit('play')"
>
<span class="version-action-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" focusable="false">
<path d="M4 3.2c0-.54.6-.86 1.05-.56l6.2 4.14a.67.67 0 0 1 0 1.12l-6.2 4.14A.67.67 0 0 1 4 11.44V3.2Z" />
</svg>
</span>
{{ playLabel }}
</button>
<button
class="version-action-item"
:disabled="disabled"
@click="emit('openFolder')"
>
<span class="version-action-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" focusable="false">
<path d="M1.4 4.3c0-.72.58-1.3 1.3-1.3h3.55c.3 0 .58.13.77.35l.72.85h5.56c.72 0 1.3.58 1.3 1.3v.92H1.4V4.3Zm0 3.22h13.2v4.2c0 .72-.58 1.3-1.3 1.3H2.7c-.72 0-1.3-.58-1.3-1.3v-4.2Z" />
</svg>
</span>
Open Folder
</button>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
const props = withDefaults(defineProps<{
visible: boolean;
x: number;
y: number;
filePath: string | null;
rootName?: string | null;
playLabel?: string;
}>(), {
rootName: null,
playLabel: 'Play',
});
const emit = defineEmits<{
play: [];
openFolder: [];
}>();
const menuRef = ref<HTMLElement | null>(null);
const menuLeft = ref(0);
const menuTop = ref(0);
const VIEWPORT_MARGIN = 12;
function toPosixPath(value: string | null | undefined): string {
return (value || '').replace(/\\/g, '/');
}
const resolvedPath = computed(() => {
if (!props.filePath) return 'No playable file';
const normalizedFilePath = toPosixPath(props.filePath);
const rootName = toPosixPath((props.rootName || '').trim());
if (!rootName) return normalizedFilePath;
return `${rootName}/${normalizedFilePath}`;
});
const menuStyle = computed(() => ({
left: `${menuLeft.value}px`,
top: `${menuTop.value}px`,
}));
const disabled = computed(() => !props.filePath);
function clampToViewport() {
const menu = menuRef.value;
if (!menu) return;
const width = menu.offsetWidth;
const height = menu.offsetHeight;
const maxLeft = Math.max(VIEWPORT_MARGIN, window.innerWidth - width - VIEWPORT_MARGIN);
const maxTop = Math.max(VIEWPORT_MARGIN, window.innerHeight - height - VIEWPORT_MARGIN);
menuLeft.value = Math.min(Math.max(props.x, VIEWPORT_MARGIN), maxLeft);
menuTop.value = Math.min(Math.max(props.y, VIEWPORT_MARGIN), maxTop);
}
function handleViewportChange() {
if (!props.visible) return;
clampToViewport();
}
watch(
() => [props.visible, props.x, props.y, resolvedPath.value],
async ([visible]) => {
if (!visible) return;
await nextTick();
clampToViewport();
},
{ immediate: true }
);
watch(
() => props.visible,
(visible) => {
if (visible) {
window.addEventListener('resize', handleViewportChange);
return;
}
window.removeEventListener('resize', handleViewportChange);
},
{ immediate: true }
);
onBeforeUnmount(() => {
window.removeEventListener('resize', handleViewportChange);
});
</script>
<style scoped>
.version-action-menu {
position: fixed;
z-index: 1001;
min-width: 260px;
max-width: min(680px, calc(100vw - 24px));
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-path {
padding: 8px 12px;
font-size: 0.74rem;
line-height: 1.35;
color: rgba(255, 255, 255, 0.78);
background: rgba(255, 255, 255, 0.05);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
word-break: break-all;
white-space: normal;
}
.version-action-item {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
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;
}
.version-action-icon {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.85);
flex: 0 0 16px;
}
.version-action-icon svg {
width: 16px;
height: 16px;
fill: currentColor;
}
</style>
+32 -61
View File
@@ -150,26 +150,16 @@
No versions available No versions available
</div> </div>
</div> </div>
<div <ReleaseActionMenu
v-if="versionActionMenu.visible && versionActionMenu.torrent" :visible="versionActionMenu.visible"
class="version-action-menu" :x="versionActionMenu.x"
:style="{ left: versionActionMenu.x + 'px', top: versionActionMenu.y + 'px' }" :y="versionActionMenu.y"
> :file-path="versionActionMenu.filePath"
<button :root-name="versionActionMenu.rootName"
class="version-action-item" :play-label="getPlayLabel(versionActionMenu.filePath)"
:disabled="!versionActionMenu.torrent.playable_file" @play="handlePlayVersion(versionActionMenu.filePath)"
@click="handlePlayVersion(versionActionMenu.torrent.playable_file)" @open-folder="handleOpenFolder(versionActionMenu.filePath || '', versionActionMenu.rootId)"
> />
{{ getPlayLabel(versionActionMenu.torrent.playable_file) }}
</button>
<button
class="version-action-item"
:disabled="!versionActionMenu.torrent.playable_file"
@click="handleOpenFolder(versionActionMenu.torrent.playable_file || '')"
>
Open Folder
</button>
</div>
</Teleport> </Teleport>
</div> </div>
</template> </template>
@@ -180,17 +170,19 @@ import type { Series, Season, Episode, Torrent } from '../types';
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser } from '../api'; import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser } from '../api';
import { navAttrs } from '../composables/useKeyboardNavigation'; import { navAttrs } from '../composables/useKeyboardNavigation';
import ReleaseVersionCard from './ReleaseVersionCard.vue'; import ReleaseVersionCard from './ReleaseVersionCard.vue';
import ReleaseActionMenu from './ReleaseActionMenu.vue';
const props = defineProps<{ const props = defineProps<{
series: Series; series: Series;
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null; focusEpisode?: { seasonNumber: number; episodeNumber: number } | null;
hasResumePosition: (filePath: string | null) => boolean; hasResumePosition: (filePath: string | null) => boolean;
getRootName: (rootId: string | null | undefined) => string | null;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
close: []; close: [];
play: [string]; play: [string];
openFolder: [string]; openFolder: [string, string | null | undefined];
}>(); }>();
// Focus on matched episode when provided // Focus on matched episode when provided
@@ -235,12 +227,16 @@ const versionActionMenu = ref<{
visible: boolean; visible: boolean;
x: number; x: number;
y: number; y: number;
torrent: Torrent | null; filePath: string | null;
rootName: string | null;
rootId: string | null;
}>({ }>({
visible: false, visible: false,
x: 0, x: 0,
y: 0, y: 0,
torrent: null, filePath: null,
rootName: null,
rootId: null,
}); });
const releaseMenuOriginElement = ref<HTMLElement | null>(null); const releaseMenuOriginElement = ref<HTMLElement | null>(null);
@@ -348,7 +344,9 @@ function closeContextMenu() {
function closeVersionActionMenu() { function closeVersionActionMenu() {
versionActionMenu.value.visible = false; versionActionMenu.value.visible = false;
versionActionMenu.value.torrent = null; versionActionMenu.value.filePath = null;
versionActionMenu.value.rootName = null;
versionActionMenu.value.rootId = null;
} }
function handleGamepadAction(event: Event) { function handleGamepadAction(event: Event) {
@@ -384,17 +382,18 @@ function getPlayLabel(filePath: string | null): string {
} }
// Open folder for a version // Open folder for a version
function handleOpenFolder(folderPath: string) { function handleOpenFolder(folderPath: string, rootId?: string | null) {
if (!folderPath) return; if (!folderPath) return;
emit('openFolder', folderPath); emit('openFolder', folderPath, rootId);
closeVersionActionMenu(); closeVersionActionMenu();
closeContextMenu(); closeContextMenu();
} }
function handleVersionActivate(torrent: Torrent, event: MouseEvent | KeyboardEvent) { function handleVersionActivate(torrent: Torrent, event: MouseEvent | KeyboardEvent) {
if (!torrent.playable_file) return; if (!torrent.playable_file) return;
const rootId = torrent.root_id || props.series.root_id;
if (event.altKey) { if (event.altKey) {
handleOpenFolder(torrent.playable_file); handleOpenFolder(torrent.playable_file, rootId);
return; return;
} }
handlePlayVersion(torrent.playable_file); handlePlayVersion(torrent.playable_file);
@@ -402,22 +401,26 @@ function handleVersionActivate(torrent: Torrent, event: MouseEvent | KeyboardEve
function handleVersionShortcutKeydown(event: KeyboardEvent, torrent: Torrent) { function handleVersionShortcutKeydown(event: KeyboardEvent, torrent: Torrent) {
if (!torrent.playable_file) return; if (!torrent.playable_file) return;
const rootId = torrent.root_id || props.series.root_id;
const key = event.key.toLowerCase(); const key = event.key.toLowerCase();
if (key === 'e' && (event.metaKey || event.ctrlKey)) { if (key === 'e' && (event.metaKey || event.ctrlKey)) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
handleOpenFolder(torrent.playable_file); handleOpenFolder(torrent.playable_file, rootId);
} }
} }
function handleVersionContextMenu(event: MouseEvent, torrent: Torrent) { function handleVersionContextMenu(event: MouseEvent, torrent: Torrent) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const rootId = torrent.root_id || props.series.root_id;
versionActionMenu.value = { versionActionMenu.value = {
visible: true, visible: true,
x: event.clientX, x: event.clientX,
y: event.clientY, y: event.clientY,
torrent, filePath: torrent.playable_file || null,
rootName: props.getRootName(rootId) || null,
rootId,
}; };
nextTick(() => { nextTick(() => {
const firstAction = document.querySelector('.version-action-menu .version-action-item:not(:disabled)') as HTMLElement | null; 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; 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;
}
</style> </style>
@@ -610,6 +610,8 @@ function handleKeyDown(event: KeyboardEvent) {
*/ */
function handleEnterKey(event: KeyboardEvent) { function handleEnterKey(event: KeyboardEvent) {
if (event.key !== 'Enter') return; 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; const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') { if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
+5 -2
View File
@@ -69,11 +69,14 @@ class IndexStore:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _maybe_migrate_id(self, item_id: str) -> str: 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: if not self.root_id:
return item_id return item_id
if ":" in 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}" return f"{self.root_id}:{item_id}"
async def load_snapshot(self) -> None: async def load_snapshot(self) -> None:
+44 -14
View File
@@ -26,18 +26,23 @@ logger = logging.getLogger("mediahive.root_registry")
def _normalize_path(path: str) -> str: def _normalize_path(path: str) -> str:
"""Canonicalize a path for stable ID generation. """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 - lower-case drive letter on Windows
- strip trailing separators - strip trailing separators
- use forward slashes - use forward slashes
""" """
p = Path(path).expanduser().resolve() p = Path(path).expanduser()
posix = p.as_posix() 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 # Windows drive letter normalization
if len(posix) >= 2 and posix[1] == ":": if len(posix) >= 2 and posix[1] == ":":
posix = posix[0].lower() + posix[1:] posix = posix[0].lower() + posix[1:]
# Strip trailing slash (except root "/") # 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] posix = posix[:-1]
return posix return posix
@@ -49,6 +54,20 @@ def compute_root_id(path: str) -> str:
return h[:12] 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 # Root entry
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -68,8 +87,9 @@ class RootEntry(msgspec.Struct):
class RootContext: class RootContext:
"""Runtime container for a single media root.""" """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.root_id = root_id
self.name = name or root_id
self.root_path = root_path self.root_path = root_path
self.status = "loading" self.status = "loading"
self.error: Optional[str] = None self.error: Optional[str] = None
@@ -171,6 +191,7 @@ class Supervisor:
return [ return [
{ {
"root_id": ctx.root_id, "root_id": ctx.root_id,
"name": ctx.name,
"path": ctx.root_path.as_posix(), "path": ctx.root_path.as_posix(),
"status": ctx.status, "status": ctx.status,
"error": ctx.error, "error": ctx.error,
@@ -229,18 +250,14 @@ class Supervisor:
seen_ids: set[str] = set() seen_ids: set[str] = set()
failed: list[dict] = [] failed: list[dict] = []
for name, path_str in roots.items(): for requested_name, path_str in roots.items():
name = name.strip() p = Path(path_str).expanduser()
if not name:
failed.append({"name": name, "path": path_str, "reason": "empty name"})
continue
p = Path(path_str).expanduser().resolve()
if not p.exists() or not p.is_dir(): 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 continue
norm = _normalize_path(p.as_posix()) norm = _normalize_path(p.as_posix())
if norm in seen_paths: 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 continue
seen_paths.add(norm) seen_paths.add(norm)
rid = compute_root_id(str(p)) rid = compute_root_id(str(p))
@@ -248,7 +265,19 @@ class Supervisor:
# Extremely unlikely hash collision — fall back to full hash # Extremely unlikely hash collision — fall back to full hash
rid = hashlib.sha256(norm.encode("utf-8")).hexdigest()[:16] rid = hashlib.sha256(norm.encode("utf-8")).hexdigest()[:16]
seen_ids.add(rid) 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 # Build desired root_id set
desired_ids = {e.root_id for e in candidates} desired_ids = {e.root_id for e in candidates}
@@ -265,12 +294,13 @@ class Supervisor:
existing = self._contexts.get(entry.root_id) existing = self._contexts.get(entry.root_id)
if existing and existing.root_path.as_posix() == entry.path: if existing and existing.root_path.as_posix() == entry.path:
# Reuse existing context # Reuse existing context
existing.name = entry.name
new_contexts[entry.root_id] = existing new_contexts[entry.root_id] = existing
else: else:
# If existing path changed, stop old one # If existing path changed, stop old one
if existing: if existing:
asyncio.create_task(existing.stop()) 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() await ctx.start()
new_contexts[entry.root_id] = ctx new_contexts[entry.root_id] = ctx
+28 -14
View File
@@ -147,11 +147,21 @@ def _validate_root_paths(roots: dict[str, str]) -> dict[str, str]:
""" """
validated: dict[str, str] = {} validated: dict[str, str] = {}
for name, path_str in roots.items(): for name, path_str in roots.items():
p = Path(path_str).expanduser().resolve() configured_posix = Path(path_str).expanduser().as_posix()
if not p.exists() or not p.is_dir(): 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) logger.warning("Root path invalid, skipping: %s", path_str)
continue 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 return validated
@@ -177,21 +187,24 @@ async def _activate_all_roots() -> None:
""" """
desired: dict[str, str] = {} desired: dict[str, str] = {}
# 1. Persisted config roots # 1. CLI roots via MEDIAHIVE_ROOTS (JSON dict)
cfg = load_config()
if cfg.roots:
desired.update(cfg.roots)
# 2. CLI roots via MEDIAHIVE_ROOTS (JSON dict)
env_roots_raw = os.environ.get("MEDIAHIVE_ROOTS") env_roots_raw = os.environ.get("MEDIAHIVE_ROOTS")
env_roots: dict[str, str] | None = None
if env_roots_raw: if env_roots_raw:
try: try:
env_roots = json.loads(env_roots_raw) parsed = json.loads(env_roots_raw)
if isinstance(env_roots, dict): if isinstance(parsed, dict):
desired.update(env_roots) env_roots = parsed
except Exception: except Exception:
logger.exception("Failed to parse MEDIAHIVE_ROOTS") 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: if not desired:
logger.info("No roots configured; waiting for PUT /api/roots") logger.info("No roots configured; waiting for PUT /api/roots")
return return
@@ -384,12 +397,13 @@ async def open_folder(root_id: str, request: Request):
try: try:
if sys.platform == "win32": if sys.platform == "win32":
native_path = str(target_path).replace("/", "\\")
if target_path.is_file(): if target_path.is_file():
subprocess.Popen( subprocess.Popen(
["explorer", "/select,", str(target_path)], **_POPEN_KWARGS ["explorer", "/select,", native_path], **_POPEN_KWARGS
) )
else: else:
subprocess.Popen(["explorer", str(target_path)], **_POPEN_KWARGS) subprocess.Popen(["explorer", native_path], **_POPEN_KWARGS)
elif sys.platform == "darwin": elif sys.platform == "darwin":
if target_path.is_file(): if target_path.is_file():
subprocess.Popen(["open", "-R", str(target_path)]) subprocess.Popen(["open", "-R", str(target_path)])