Fix root path handling and release action menus
This commit is contained in:
+15
-5
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)])
|
||||||
|
|||||||
Reference in New Issue
Block a user