Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion backend/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
2 changes: 1 addition & 1 deletion backend/db.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
107 changes: 107 additions & 0 deletions backend/library.ts
Original file line number Diff line number Diff line change
@@ -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<LibraryStatus> {
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<LibraryEntry[]> {
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<boolean> {
return await fs.exists(absPath);
}
78 changes: 65 additions & 13 deletions backend/main.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";

Expand Down Expand Up @@ -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");
Expand All @@ -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);

Expand All @@ -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({
Expand All @@ -447,17 +452,18 @@ 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);
if (!tab.public) {
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)) {
Expand All @@ -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<string, string> = {
"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];
}
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion backend/main_test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
31 changes: 29 additions & 2 deletions backend/tab.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -54,7 +54,7 @@ async function findTabFile(dirPath: string): Promise<string | null> {
async function findAudioFiles(dirPath: string): Promise<string[]> {
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);
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion backend/tab_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion backend/util.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
6 changes: 6 additions & 0 deletions backend/zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ export const AudioDataSchema = z.object({

export type AudioData = z.infer<typeof AudioDataSchema>;

const libraryAudioFile = z.string().min(5); // 1 letter plus file extension
export const LibraryAddDataSchema = z.object({
libraryAudioFile,
});
export type LibraryAddData = z.infer<typeof LibraryAddDataSchema>;

export const ConfigJSONSchema = z.object({
tab: TabInfoSchema,
audio: z.array(AudioDataSchema).default([]),
Expand Down
Loading