Compare commits
4 Commits
9776b83ac5
...
server
| Author | SHA1 | Date | |
|---|---|---|---|
| a67e189aa0 | |||
| 2b6c56cd15 | |||
| 0f6082061f | |||
| 9662b815db |
@@ -117,9 +117,14 @@ impl Mpv {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_file(&mut self, video_url: &str, audio_url: &str) -> Result<(), String> {
|
pub fn load_file(&mut self, video_url: &str, audio_url: &str) -> Result<(), String> {
|
||||||
// Pass audio-file option during load so both streams sync from the start
|
|
||||||
let options = format!("audio-file={}", audio_url);
|
let options = format!("audio-file={}", audio_url);
|
||||||
self.command(&["loadfile", video_url, "replace", &options])
|
let resp = self.send_and_recv(json!({
|
||||||
|
"command": ["loadfile", video_url, "replace", -1, options]
|
||||||
|
}))?;
|
||||||
|
if resp.get("error").and_then(|e| e.as_str()) != Some("success") {
|
||||||
|
return Err(format!("mpv error: {}", resp.get("error").unwrap_or(&Value::Null)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn seek(&mut self, time: f64) -> Result<(), String> {
|
pub fn seek(&mut self, time: f64) -> Result<(), String> {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
} from "$lib/stores";
|
} from "$lib/stores";
|
||||||
|
|
||||||
let selectedRoot = $state("");
|
let selectedRoot = $state("");
|
||||||
|
let currentFolder = $state("");
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
$roots = await getRoots();
|
$roots = await getRoots();
|
||||||
@@ -30,11 +31,44 @@
|
|||||||
$hiddenFiles = new Set(hidden);
|
$hiddenFiles = new Set(hidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Derive subfolders and files at current folder level
|
||||||
|
let subfolders = $derived.by(() => {
|
||||||
|
const prefix = currentFolder ? currentFolder + "/" : "";
|
||||||
|
const folderSet = new Set<string>();
|
||||||
|
for (const f of $visibleFiles) {
|
||||||
|
if (!f.path.startsWith(prefix)) continue;
|
||||||
|
const rest = f.path.slice(prefix.length);
|
||||||
|
const slashIdx = rest.indexOf("/");
|
||||||
|
if (slashIdx !== -1) {
|
||||||
|
folderSet.add(rest.slice(0, slashIdx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...folderSet].sort();
|
||||||
|
});
|
||||||
|
|
||||||
|
let currentFiles = $derived.by(() => {
|
||||||
|
const prefix = currentFolder ? currentFolder + "/" : "";
|
||||||
|
return $visibleFiles.filter(f => {
|
||||||
|
if (!f.path.startsWith(prefix)) return false;
|
||||||
|
const rest = f.path.slice(prefix.length);
|
||||||
|
return !rest.includes("/"); // only direct children
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
async function selectFile(file: typeof $files[0]) {
|
async function selectFile(file: typeof $files[0]) {
|
||||||
$currentFile = file;
|
$currentFile = file;
|
||||||
$markers = await getMarkers(file.name, $profile);
|
$markers = await getMarkers(file.name, $profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function navigateToFolder(name: string) {
|
||||||
|
currentFolder = currentFolder ? currentFolder + "/" + name : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateUp() {
|
||||||
|
const idx = currentFolder.lastIndexOf("/");
|
||||||
|
currentFolder = idx === -1 ? "" : currentFolder.slice(0, idx);
|
||||||
|
}
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
if (bytes > 1e9) return (bytes / 1e9).toFixed(1) + " GB";
|
if (bytes > 1e9) return (bytes / 1e9).toFixed(1) + " GB";
|
||||||
if (bytes > 1e6) return (bytes / 1e6).toFixed(0) + " MB";
|
if (bytes > 1e6) return (bytes / 1e6).toFixed(0) + " MB";
|
||||||
@@ -53,15 +87,24 @@
|
|||||||
|
|
||||||
<div class="file-browser">
|
<div class="file-browser">
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<select bind:value={selectedRoot} onchange={loadFiles}>
|
<select bind:value={selectedRoot} onchange={() => { currentFolder = ""; loadFiles(); }}>
|
||||||
{#each $roots as root}
|
{#each $roots as root}
|
||||||
<option value={root}>{root}</option>
|
<option value={root}>{root}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
<label><input type="checkbox" bind:checked={$showHidden} /> Hidden</label>
|
<label><input type="checkbox" bind:checked={$showHidden} /> Hidden</label>
|
||||||
</div>
|
</div>
|
||||||
|
{#if currentFolder}
|
||||||
|
<div class="breadcrumb" onclick={navigateUp}>.. / {currentFolder}</div>
|
||||||
|
{/if}
|
||||||
<ul class="file-list">
|
<ul class="file-list">
|
||||||
{#each $visibleFiles as file}
|
{#each subfolders as folder}
|
||||||
|
<li class="folder" onclick={() => navigateToFolder(folder)}>
|
||||||
|
<span class="name">{folder}/</span>
|
||||||
|
<span class="badge">dir</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{#each currentFiles as file}
|
||||||
<li
|
<li
|
||||||
class:selected={$currentFile?.path === file.path}
|
class:selected={$currentFile?.path === file.path}
|
||||||
onclick={() => selectFile(file)}
|
onclick={() => selectFile(file)}
|
||||||
@@ -94,6 +137,18 @@
|
|||||||
border: 1px solid #444;
|
border: 1px solid #444;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
}
|
}
|
||||||
|
.breadcrumb {
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #88aaff;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #252525;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.breadcrumb:hover { background: #2a2a2a; }
|
||||||
.file-list {
|
.file-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -107,8 +162,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.file-list li:hover { background: #333; }
|
.file-list li:hover { background: #333; }
|
||||||
.file-list li.selected { background: #0066cc; }
|
.file-list li.selected { background: #0066cc; }
|
||||||
.size { color: #888; font-size: 11px; }
|
.file-list li.folder { color: #88aaff; }
|
||||||
|
.name { flex: 1; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.size { flex-shrink: 0; margin-left: 8px; color: #888; font-size: 11px; }
|
||||||
|
.badge { flex-shrink: 0; margin-left: 8px; color: #666; font-size: 10px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { getProfiles } from "$lib/api";
|
import { getProfiles, setServer, getServer } from "$lib/api";
|
||||||
import { profile, subprofiles } from "$lib/stores";
|
import { profile, subprofiles, serverUrl } from "$lib/stores";
|
||||||
|
import { saveSettings } from "$lib/settings";
|
||||||
|
|
||||||
let profiles = $state<string[]>([]);
|
let profiles = $state<string[]>([]);
|
||||||
|
let serverInput = $state(getServer());
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
serverInput = getServer();
|
||||||
|
try {
|
||||||
profiles = await getProfiles();
|
profiles = await getProfiles();
|
||||||
if (profiles.length && !profiles.includes($profile)) {
|
if (profiles.length && !profiles.includes($profile)) {
|
||||||
$profile = profiles[0];
|
$profile = profiles[0];
|
||||||
}
|
}
|
||||||
|
} catch { /* server not reachable yet */ }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function applyServer() {
|
||||||
|
const url = serverInput.replace(/\/+$/, "");
|
||||||
|
setServer(url);
|
||||||
|
$serverUrl = url;
|
||||||
|
saveSettings();
|
||||||
|
// Reload profiles from new server
|
||||||
|
getProfiles().then(p => { profiles = p; }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
function addSubprofile() {
|
function addSubprofile() {
|
||||||
const name = prompt("Subprofile suffix:");
|
const name = prompt("Subprofile suffix:");
|
||||||
if (name && !$subprofiles.includes(name)) {
|
if (name && !$subprofiles.includes(name)) {
|
||||||
@@ -25,6 +39,15 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="profile-bar">
|
<div class="profile-bar">
|
||||||
|
<input
|
||||||
|
class="server-input"
|
||||||
|
type="text"
|
||||||
|
bind:value={serverInput}
|
||||||
|
onkeydown={(e) => { if (e.key === "Enter") applyServer(); }}
|
||||||
|
placeholder="http://host:8000"
|
||||||
|
/>
|
||||||
|
<button onclick={applyServer}>Set</button>
|
||||||
|
|
||||||
<select bind:value={$profile}>
|
<select bind:value={$profile}>
|
||||||
{#each profiles as p}
|
{#each profiles as p}
|
||||||
<option value={p}>{p}</option>
|
<option value={p}>{p}</option>
|
||||||
@@ -49,6 +72,14 @@
|
|||||||
padding: 4px;
|
padding: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
.server-input {
|
||||||
|
width: 180px;
|
||||||
|
background: #2d2d2d;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border: 1px solid #444;
|
||||||
|
padding: 2px 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
select { background: #2d2d2d; color: #e0e0e0; border: 1px solid #444; }
|
select { background: #2d2d2d; color: #e0e0e0; border: 1px solid #444; }
|
||||||
.subs { display: flex; gap: 4px; align-items: center; }
|
.subs { display: flex; gap: 4px; align-items: center; }
|
||||||
.sub-tag {
|
.sub-tag {
|
||||||
|
|||||||
@@ -63,6 +63,25 @@ export function audioUrl(path: string, root: string): string {
|
|||||||
return `${serverUrl}/api/audio/${encodePath(path)}?root=${encodeURIComponent(root)}`;
|
return `${serverUrl}/api/audio/${encodePath(path)}?root=${encodeURIComponent(root)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Poll cache status until both video and audio are ready. */
|
||||||
|
export async function waitForCache(
|
||||||
|
path: string, root: string, quality: string,
|
||||||
|
signal: AbortSignal, interval = 2000
|
||||||
|
): Promise<void> {
|
||||||
|
const url = `${serverUrl}/api/cache/status/${encodePath(path)}?root=${encodeURIComponent(root)}`;
|
||||||
|
// Trigger transcode/audio extraction by hitting stream+audio once
|
||||||
|
await fetch(streamUrl(path, root, quality), { signal }).catch(() => {});
|
||||||
|
await fetch(audioUrl(path, root), { signal }).catch(() => {});
|
||||||
|
|
||||||
|
while (!signal.aborted) {
|
||||||
|
const res = await fetch(url, { signal });
|
||||||
|
const status = await res.json();
|
||||||
|
if (status[quality] === "ready" && status.audio === "ready") return;
|
||||||
|
await new Promise(r => setTimeout(r, interval));
|
||||||
|
}
|
||||||
|
throw new Error("Aborted");
|
||||||
|
}
|
||||||
|
|
||||||
export function cacheStatus(path: string, root: string): Promise<Record<string, string>> {
|
export function cacheStatus(path: string, root: string): Promise<Record<string, string>> {
|
||||||
return get(`/api/cache/status/${encodePath(path)}?root=${encodeURIComponent(root)}`);
|
return get(`/api/cache/status/${encodePath(path)}?root=${encodeURIComponent(root)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import ExportPanel from "../components/ExportPanel.svelte";
|
import ExportPanel from "../components/ExportPanel.svelte";
|
||||||
import ProfileBar from "../components/ProfileBar.svelte";
|
import ProfileBar from "../components/ProfileBar.svelte";
|
||||||
import { mpvStart, mpvLoad, mpvSeek, mpvPause, mpvResume, mpvSetLoop, mpvClearLoop, mpvTimePos, mpvDuration } from "$lib/mpv";
|
import { mpvStart, mpvLoad, mpvSeek, mpvPause, mpvResume, mpvSetLoop, mpvClearLoop, mpvTimePos, mpvDuration } from "$lib/mpv";
|
||||||
import { streamUrl, audioUrl, deleteExport, getMarkers } from "$lib/api";
|
import { streamUrl, audioUrl, waitForCache, deleteExport, getMarkers } from "$lib/api";
|
||||||
import { connectExportWs, disconnectExportWs } from "$lib/ws";
|
import { connectExportWs, disconnectExportWs } from "$lib/ws";
|
||||||
import { loadSettings, saveSettings } from "$lib/settings";
|
import { loadSettings, saveSettings } from "$lib/settings";
|
||||||
import {
|
import {
|
||||||
@@ -48,16 +48,24 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Load file into mpv when currentFile OR quality changes
|
// Load file into mpv when currentFile OR quality changes
|
||||||
|
let loadAbort: AbortController | null = null;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const file = $currentFile;
|
const file = $currentFile;
|
||||||
const q = $quality;
|
const q = $quality;
|
||||||
if (file) {
|
if (file) {
|
||||||
|
// Cancel any previous polling
|
||||||
|
loadAbort?.abort();
|
||||||
|
const ac = new AbortController();
|
||||||
|
loadAbort = ac;
|
||||||
|
|
||||||
const vUrl = streamUrl(file.path, file.root, q);
|
const vUrl = streamUrl(file.path, file.root, q);
|
||||||
const aUrl = audioUrl(file.path, file.root);
|
const aUrl = audioUrl(file.path, file.root);
|
||||||
mpvLoad(vUrl, aUrl).then(async () => {
|
waitForCache(file.path, file.root, q, ac.signal).then(() =>
|
||||||
|
mpvLoad(vUrl, aUrl)
|
||||||
|
).then(async () => {
|
||||||
await new Promise(r => setTimeout(r, 500));
|
await new Promise(r => setTimeout(r, 500));
|
||||||
try { $duration = await mpvDuration(); } catch {}
|
try { $duration = await mpvDuration(); } catch {}
|
||||||
});
|
}).catch(() => {}); // aborted or error
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -198,6 +206,8 @@
|
|||||||
}
|
}
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 220px;
|
width: 220px;
|
||||||
|
min-width: 220px;
|
||||||
|
flex-shrink: 0;
|
||||||
border-right: 1px solid #333;
|
border-right: 1px solid #333;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from fastapi import FastAPI, WebSocket
|
from fastapi import FastAPI, WebSocket
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from core.db import ProcessedDB
|
from core.db import ProcessedDB
|
||||||
from .config import DB_PATH
|
from .config import DB_PATH
|
||||||
@@ -7,6 +8,13 @@ from . import ws
|
|||||||
|
|
||||||
app = FastAPI(title="8-cut Server")
|
app = FastAPI(title="8-cut Server")
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
db = ProcessedDB(DB_PATH)
|
db = ProcessedDB(DB_PATH)
|
||||||
|
|
||||||
app.include_router(files.router, prefix="/api")
|
app.include_router(files.router, prefix="/api")
|
||||||
|
|||||||
Reference in New Issue
Block a user