diff --git a/README.md b/README.md index 4bf67f0..1462e04 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,17 @@ services: volumes: # Host Path:Container Path - ./data:/app/data + + # OPTIONAL: Uncomment to mount a media library of existing audio files. + # Note that this should be mounted read-only. + # /path/to/audo/library:/app/library:ro + + environment: + # OPTIONAL: Uncomment to specify the location of a media library. Note + # that its-mytabs will not automatically search for the library, just + # because it's mounted at the above location. + # LIBRARY_DIR=/app/library + restart: unless-stopped ``` @@ -121,6 +132,9 @@ MYTABS_PORT=47777 # (boolean) Whether to launch the browser when starting the app (Desktop only) (Default: true) MYTABS_LAUNCH_BROWSER=true + +# Location of existing audio library +LIBRARY_DIR=/app/library ``` ## Keyboard Shortcuts diff --git a/backend/auth.ts b/backend/auth.ts index b5df507..6b0d2e2 100644 --- a/backend/auth.ts +++ b/backend/auth.ts @@ -5,7 +5,7 @@ import { randomBytes } from "node:crypto"; import { Buffer } from "node:buffer"; import { devOriginList } from "./util.ts"; import { createAuthMiddleware } from "better-auth/api"; -import * as path from "@std/path"; +import * as path from "jsr:@std/path"; import { dataDir } from "./util.ts"; import { Context } from "@hono/hono"; diff --git a/backend/db.ts b/backend/db.ts index f61f145..ff3fa6a 100644 --- a/backend/db.ts +++ b/backend/db.ts @@ -1,6 +1,6 @@ import * as fs from "@std/fs"; import { DatabaseSync } from "node:sqlite"; -import * as path from "@std/path"; +import * as path from "jsr:@std/path"; import { dataDir, getSourceDir, isDemoMode, tabDir } from "./util.ts"; import { getNextTabID } from "./tab.ts"; import { AudioDataSchema, ConfigJSONSchema, TabInfoSchema, YoutubeSchema } from "./zod.ts"; diff --git a/backend/library.ts b/backend/library.ts new file mode 100644 index 0000000..4f79adf --- /dev/null +++ b/backend/library.ts @@ -0,0 +1,107 @@ +import * as fs from "@std/fs"; +import * as path from "jsr:@std/path"; +import { supportedAudioFormatList } from "./common.ts"; + +/** + * The library dir is intentionally NOT defaulted to a fixed path (e.g. "/app/library"). + * If LIBRARY_DIR isn't set, the feature is considered "not configured" rather than pointing + * at a path that happens not to exist - this lets the frontend tell the two cases apart. + */ +export function getLibraryDir(): string | undefined { + const dir = Deno.env.get("LIBRARY_DIR"); + return dir && dir.trim() !== "" ? dir : undefined; +} + +export const libraryDir = getLibraryDir(); + +export interface LibraryStatus { + configured: boolean; + path: string | null; + exists: boolean; +} + +/** + * Used by the frontend to decide what to show in the Tree's empty slot: + * - not configured at all + * - configured, but the path doesn't exist (e.g. drive not mounted) + * - configured and exists (browsing proceeds as normal, possibly landing on an empty dir) + */ +export async function getLibraryStatus(): Promise { + if (!libraryDir) { + return { configured: false, path: null, exists: false }; + } + + let exists = false; + try { + const stat = await Deno.stat(libraryDir); + exists = stat.isDirectory; + } catch { + exists = false; + } + + return { configured: true, path: libraryDir, exists }; +} + +/** + * Resolve a path relative to the library root, guarding against escaping it (e.g. "../../etc"). + */ +export function resolveLibraryPath(relativePath: string): string { + const libraryRoot = getLibraryDir(); + if (!libraryRoot) { + throw new Error("Library not configured"); + } + const root = path.resolve(libraryRoot); + const resolved = path.resolve(root, path.normalize(relativePath || ".")); + if (resolved !== root && !resolved.startsWith(root + path.SEPARATOR)) { + throw new Error("Invalid path"); + } + return resolved; +} + +/** + * Validate that an already-absolute path (e.g. one stored in config.json) actually lives + * inside the configured library root. + */ +export function isLibraryPath(filename: string): boolean { + if (!libraryDir || !path.isAbsolute(filename)) { + return false; + } + const root = path.resolve(libraryDir); + const resolved = path.resolve(filename); + return resolved === root || resolved.startsWith(root + path.SEPARATOR); +} + +export interface LibraryEntry { + name: string; + type: "dir" | "file"; +} + +/** + * List the immediate contents of a library directory: subfolders, and only files with a + * supported audio extension (.mp3, .ogg, .flac). + */ +export async function listLibraryDir(relativePath: string): Promise { + const dirPath = resolveLibraryPath(relativePath); + const entries: LibraryEntry[] = []; + + for await (const entry of Deno.readDir(dirPath)) { + if (entry.name.startsWith(".")) { + continue; // Keep hidden files hidden. + } + if (entry.isDirectory) { + entries.push({ name: entry.name, type: "dir" }); + } else { + const ext = entry.name.split(".").pop()?.toLowerCase(); + if (ext && supportedAudioFormatList.includes(ext)) { + entries.push({ name: entry.name, type: "file" }); + } + } + } + + entries.sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1); + return entries; +} + +export async function fileExists(absPath: string): Promise { + return await fs.exists(absPath); +} diff --git a/backend/main.ts b/backend/main.ts index d371c51..e810e07 100644 --- a/backend/main.ts +++ b/backend/main.ts @@ -1,16 +1,19 @@ +import "@std/dotenv/load"; import { serve, ServerType } from "@hono/node-server"; import { Context, Hono } from "@hono/hono"; import * as fs from "@std/fs"; import { auth, checkLogin, getCurrentSession, isFinishSetup, isLoggedIn } from "./auth.ts"; -import { SignUpSchema, SyncRequestSchema, UpdateTabFavSchema, UpdateTabInfoSchema, YoutubeAddDataSchema } from "./zod.ts"; +import { SignUpSchema, SyncRequestSchema, UpdateTabFavSchema, UpdateTabInfoSchema, YoutubeAddDataSchema, LibraryAddDataSchema } from "./zod.ts"; import { db, hasUser, isInitDB, kv, migrate } from "./db.ts"; import { cors } from "@hono/hono/cors"; import { serveStatic } from "@hono/hono/deno"; import { appVersion, checkFilename, dataDir, devOriginList, getFrontendDir, getSourceDir, host, isDemoMode, isDev, port, start, tabDir } from "./util.ts"; -import * as path from "@std/path"; +import * as path from "jsr:@std/path"; import { supportedAudioFormatList, supportedFormatList } from "./common.ts"; +import { getLibraryStatus, listLibraryDir, resolveLibraryPath } from "./library.ts"; import { addAudio, + addLibraryAudio, addYoutube, checkTabExists, createTab, @@ -33,7 +36,6 @@ import { } from "./tab.ts"; import { ZodError } from "zod"; import sanitize from "sanitize-filename"; -import "@std/dotenv/load"; import { socketIO } from "./socket.ts"; import * as cheerio from "cheerio"; @@ -408,7 +410,10 @@ export async function main() { }); // Save Audio (Metadata only) - app.post("/api/tab/:id/audio/:filename", async (c) => { + // filename is a query param, not a path segment - a library reference is a full absolute + // path and always contains "/", and an encoded "/" in a path segment can get unwrapped + // by a reverse proxy before it reaches us. + app.post("/api/tab/:id/audio/save", async (c) => { try { await checkLogin(c); const id = c.req.param("id"); @@ -417,7 +422,7 @@ export async function main() { const data = SyncRequestSchema.parse(body); const tab = await getTab(id); - const filename = c.req.param("filename"); + const filename = c.req.query("filename") || ""; await updateAudio(tab, filename, data); @@ -430,12 +435,12 @@ export async function main() { }); // Remove Audio - app.delete("/api/tab/:id/audio/:filename", async (c) => { + app.delete("/api/tab/:id/audio", async (c) => { try { await checkLogin(c); const id = c.req.param("id"); const tab = await getTab(id); - const filename = c.req.param("filename"); + const filename = c.req.query("filename") || ""; await removeAudio(tab, filename); return c.json({ @@ -447,7 +452,7 @@ export async function main() { }); // Serve audio file - app.get("/api/tab/:id/audio/:filename", async (c) => { + app.get("/api/tab/:id/audio", async (c) => { try { const id = c.req.param("id"); const tab = await getTab(id); @@ -455,9 +460,10 @@ export async function main() { await checkLogin(c); } - const filename = c.req.param("filename"); - checkFilename(filename); - const filePath = path.join(tabDir, id, filename); + const filename = c.req.query("filename") || ""; + + checkFilename(filename); + const filePath = path.join(tabDir, id, filename); // Check if file exists if (!await fs.exists(filePath)) { @@ -469,14 +475,15 @@ export async function main() { read: true, }); - const encodedFilename = encodeURIComponent(filename); + const displayName = path.basename(filename); + const encodedFilename = encodeURIComponent(displayName); let mime = "application/octet-stream"; let mimeList: Record = { "mp3": "audio/mpeg", "ogg": "audio/ogg", }; - const ext = filename.split(".").pop()?.toLowerCase(); + const ext = displayName.split(".").pop()?.toLowerCase(); if (ext && mimeList[ext]) { mime = mimeList[ext]; } @@ -490,6 +497,51 @@ export async function main() { } }); + // Library status - lets the frontend tell "not configured" apart from "path not found" + app.get("/api/library/status", async (c) => { + try { + await checkLogin(c); + const status = await getLibraryStatus(); + return c.json({ ok: true, ...status }); + } catch (e) { + return generalError(c, e); + } + }); + + // List a library directory (folders + supported audio files only) - one level, for lazy loading + app.get("/api/library/browse", async (c) => { + try { + await checkLogin(c); + const relPath = c.req.query("path") || ""; + const entries = await listLibraryDir(relPath); + return c.json({ ok: true, path: relPath, entries }); + } catch (e) { + return generalError(c, e); + } + }); + + // Attach a library song to a tab by symlinking into the library. + app.post("/api/tab/:id/audio/from-library", async (c) => { + try { + await checkLogin(c); + const id = c.req.param("id"); + const body = await c.req.json(); + if (!body) { + throw new Error("No body?"); + } + const data = LibraryAddDataSchema.parse(body); + + await checkTabExists(id); + + // Make sure the path is absolute, and actually *in* the library... + const absLibraryAudioPath = resolveLibraryPath(data.libraryAudioFile); + await addLibraryAudio(id, absLibraryAudioPath); + return c.json({ ok: true }); + } catch (e) { + return generalError(c, e); + } + }); + // Add Youtube (/api/tab/${tabID}/youtube) app.post("/api/tab/:id/youtube", async (c) => { try { diff --git a/backend/main_test.ts b/backend/main_test.ts index 0aaa025..aed5bb1 100644 --- a/backend/main_test.ts +++ b/backend/main_test.ts @@ -1,6 +1,6 @@ import { assertEquals, assertExists } from "jsr:@std/assert@^1.0.17"; import * as fs from "@std/fs"; -import * as path from "@std/path"; +import * as path from "jsr:@std/path"; async function setupTest() { // Set up temporary directory for tests diff --git a/backend/tab.ts b/backend/tab.ts index 233e26c..357ab54 100644 --- a/backend/tab.ts +++ b/backend/tab.ts @@ -1,6 +1,6 @@ import { checkAudioFormat, checkFilename, flacToOgg, tabDir } from "./util.ts"; import * as fs from "@std/fs"; -import * as path from "@std/path"; +import * as path from "jsr:@std/path"; import { AudioData, AudioDataSchema, ConfigJSON, ConfigJSONSchema, SyncRequest, TabInfo, TabInfoSchema, UpdateTabFav, UpdateTabInfo, Youtube, YoutubeSchema } from "./zod.ts"; import { kv } from "./db.ts"; import sanitize from "sanitize-filename"; @@ -54,7 +54,7 @@ async function findTabFile(dirPath: string): Promise { async function findAudioFiles(dirPath: string): Promise { const audioFiles: string[] = []; for await (const entry of Deno.readDir(dirPath)) { - if (!entry.isFile) continue; + if (!entry.isFile && !entry.isSymlink) continue; const ext = path.extname(entry.name).slice(1).toLowerCase(); if (supportedAudioFormatList.includes(ext)) { audioFiles.push(entry.name); @@ -374,6 +374,33 @@ export async function addAudio(tab: TabInfo, audioFileData: Uint8Array, original await Deno.writeFile(filePath, audioFileData); } +/** + * Attach an audio file from the library to a tab, by adding a symlink in the + * tab directory. absLibraryPath is the audio file's full absolute path on disk, + * and must already be validated (i.e. actually be inside libraryDir) by the + * caller. + */ +export async function addLibraryAudio(id: string, absLibraryPath: string) { + const stat = await Deno.stat(absLibraryPath); + if (!stat.isFile) { + throw new Error("Not a file"); + } + checkAudioFormat(absLibraryPath); + + // We don't support linking .flac -- they have to be converted to .ogg + if (absLibraryPath.toLowerCase().endsWith(".flac")) { + throw new Error("FLAC files must be uploaded to convert to .ogg"); + } + + const tabDirPath = path.join(tabDir, id); + const filePath = path.join(tabDirPath, path.basename(absLibraryPath)); + if (await fs.exists(filePath)) { + throw new Error("Audio file with the same name already exists"); + } + + await Deno.symlink(absLibraryPath, filePath); +} + export async function removeAudio(tab: TabInfo, filename: string) { checkAudioFormat(filename); checkFilename(filename); diff --git a/backend/tab_test.ts b/backend/tab_test.ts index 13c9358..81ed886 100644 --- a/backend/tab_test.ts +++ b/backend/tab_test.ts @@ -2,7 +2,7 @@ import { assertEquals, assertExists, assertRejects, assertThrows } from "jsr:@std/assert@^1.0.17"; import * as fs from "@std/fs"; -import * as path from "@std/path"; +import * as path from "jsr:@std/path"; async function setupTest() { // Set up temporary directory for tests diff --git a/backend/util.ts b/backend/util.ts index ac88198..928aeaf 100644 --- a/backend/util.ts +++ b/backend/util.ts @@ -1,5 +1,5 @@ import * as fs from "@std/fs"; -import * as path from "@std/path"; +import * as path from "jsr:@std/path"; import { fileURLToPath } from "node:url"; import childProcess from "node:child_process"; import * as jsonc from "@std/jsonc"; diff --git a/backend/zod.ts b/backend/zod.ts index dc5c576..7e9809e 100644 --- a/backend/zod.ts +++ b/backend/zod.ts @@ -70,6 +70,12 @@ export const AudioDataSchema = z.object({ export type AudioData = z.infer; +const libraryAudioFile = z.string().min(5); // 1 letter plus file extension +export const LibraryAddDataSchema = z.object({ + libraryAudioFile, +}); +export type LibraryAddData = z.infer; + export const ConfigJSONSchema = z.object({ tab: TabInfoSchema, audio: z.array(AudioDataSchema).default([]), diff --git a/compose.yaml b/compose.yaml index c3269e0..01019ec 100644 --- a/compose.yaml +++ b/compose.yaml @@ -7,4 +7,15 @@ services: volumes: # Host Path:Container Path - ./data:/app/data + + # OPTIONAL: Uncomment to mount a media library of existing audio files. + # Note that this should be mounted read-only. + # /path/to/audo/library:/app/library:ro + + environment: + # OPTIONAL: Uncomment to specify the location of a media library. Note + # that its-mytabs will not automatically search for the library, just + # because it's mounted at the above location. + # LIBRARY_DIR=/app/library + restart: unless-stopped diff --git a/frontend/package.json b/frontend/package.json index 7636db0..e7bcddc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "@types/node": "^24.5.2", "@vitejs/plugin-vue": "~6.0.1", "@vue/tsconfig": "~0.8.1", + "@zanmato/vue3-treeselect": "~0.4.4", "sass-embedded": "~1.78.0", "typescript": "~5.8.3", "vite": "~7.3.1", diff --git a/frontend/src/components/LibraryAudioSelector.vue b/frontend/src/components/LibraryAudioSelector.vue new file mode 100644 index 0000000..ae67f6a --- /dev/null +++ b/frontend/src/components/LibraryAudioSelector.vue @@ -0,0 +1,224 @@ + + + + + + diff --git a/frontend/src/pages/Tab.vue b/frontend/src/pages/Tab.vue index 9c25126..4fbab4b 100644 --- a/frontend/src/pages/Tab.vue +++ b/frontend/src/pages/Tab.vue @@ -1028,7 +1028,7 @@ export default defineComponent({ this.api.player.output.handler = this.audioHandler; - const path = baseURL + `/api/tab/${this.tabID}/audio/${encodeURIComponent(filename)}`; + const path = baseURL + `/api/tab/${this.tabID}/audio?filename=${encodeURIComponent(filename)}`; audioPlayer.src = path; audioPlayer.load(); @@ -1397,7 +1397,8 @@ export default defineComponent({ async saveAudio() { let res; try { - res = await fetch(baseURL + `/api/tab/${this.tabID}/audio/${this.audio.filename}`, { + const encoded = encodeURIComponent(this.audio.filename); + res = await fetch(baseURL + `/api/tab/${this.tabID}/audio/save?filename=${encoded}`, { method: "POST", credentials: "include", headers: { diff --git a/frontend/src/pages/TabConfig.vue b/frontend/src/pages/TabConfig.vue index a42efb6..551c184 100644 --- a/frontend/src/pages/TabConfig.vue +++ b/frontend/src/pages/TabConfig.vue @@ -3,6 +3,7 @@ import { defineComponent } from "vue"; import { baseURL, checkFetch, convertAlphaTexSyncPoint, generalError } from "../app.js"; import { notify } from "@kyvg/vue3-notification"; import Vue3Dropzone from "@jaxtheprime/vue3-dropzone"; +import LibraryAudioSelector from "../components/LibraryAudioSelector.vue"; import { supportedAudioFormatCommaString, supportedFormatCommaString } from "../../../backend/common.js"; import SyncOptions from "../components/SyncOptions.vue"; import { FontAwesomeIcon } from "../icon.ts"; @@ -10,7 +11,7 @@ import { FontAwesomeIcon } from "../icon.ts"; const alphaTab = await import("@coderline/alphatab"); export default defineComponent({ - components: { SyncOptions, Vue3Dropzone, FontAwesomeIcon }, + components: { SyncOptions, Vue3Dropzone, FontAwesomeIcon, LibraryAudioSelector }, data() { return { tabID: -1, @@ -25,6 +26,7 @@ export default defineComponent({ filePath: "", tabFiles: [], audioFiles: [], + libraryAudioFile: null, isLoading: true, isUploading: false, showOpenButtons: false, @@ -257,12 +259,37 @@ export default defineComponent({ } }, + async addAudioFromLibrary() { + try { + // Send to api (/tab/:id/from-library) + const tabID = this.tab.id; + const libraryAudioFile = this.libraryAudioFile; + const res = await fetch(baseURL + `/api/tab/${tabID}/audio/from-library`, { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + libraryAudioFile, + }), + }); + + await checkFetch(res); + this.libraryAudioFile = ""; + + await this.load(); + } catch (e) { + generalError(e); + } + }, + async saveAudio(audio) { let res; try { const tabID = this.tab.id; const encoded = encodeURIComponent(audio.filename); - res = await fetch(baseURL + `/api/tab/${tabID}/audio/${encoded}`, { + res = await fetch(baseURL + `/api/tab/${tabID}/audio/save?filename=${encoded}`, { method: "POST", credentials: "include", headers: { @@ -295,7 +322,7 @@ export default defineComponent({ const tabID = this.tab.id; const encoded = encodeURIComponent(audio.filename); - const res = await fetch(baseURL + `/api/tab/${tabID}/audio/${encoded}`, { + const res = await fetch(baseURL + `/api/tab/${tabID}/audio?filename=${encoded}`, { method: "DELETE", credentials: "include", }); @@ -323,7 +350,7 @@ export default defineComponent({ }, getAudioURL(tabID, filename) { - return baseURL + `/api/tab/${tabID}/audio/${encodeURIComponent(filename)}`; + return baseURL + `/api/tab/${tabID}/audio?filename=${encodeURIComponent(filename)}`; }, async openFolder() { @@ -502,6 +529,21 @@ export default defineComponent({ +
+ + +
+