This commit is contained in:
2026-02-17 22:50:57 -08:00
parent ffeef9c136
commit f20d019c11
315 changed files with 9876 additions and 12537 deletions

View File

@@ -1,11 +1,17 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { spawnSync } from "node:child_process";
import type { LogLevel, JellyfinSessionConfig, JellyfinLibraryEntry, JellyfinItemEntry, JellyfinGroupEntry } from "./types.js";
import { VIDEO_EXTENSIONS, ROFI_THEME_FILE } from "./types.js";
import { log, fail } from "./log.js";
import { commandExists, realpathMaybe } from "./util.js";
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { spawnSync } from 'node:child_process';
import type {
LogLevel,
JellyfinSessionConfig,
JellyfinLibraryEntry,
JellyfinItemEntry,
JellyfinGroupEntry,
} from './types.js';
import { VIDEO_EXTENSIONS, ROFI_THEME_FILE } from './types.js';
import { log, fail } from './log.js';
import { commandExists, realpathMaybe } from './util.js';
export function escapeShellSingle(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -14,87 +20,69 @@ export function escapeShellSingle(value: string): string {
export function showRofiFlatMenu(
items: string[],
prompt: string,
initialQuery = "",
initialQuery = '',
themePath: string | null = null,
): string {
const args = [
"-dmenu",
"-i",
"-matching",
"fuzzy",
"-p",
prompt,
];
const args = ['-dmenu', '-i', '-matching', 'fuzzy', '-p', prompt];
if (themePath) {
args.push("-theme", themePath);
args.push('-theme', themePath);
} else {
args.push(
"-theme-str",
'configuration { font: "Noto Sans CJK JP Regular 8";}',
);
args.push('-theme-str', 'configuration { font: "Noto Sans CJK JP Regular 8";}');
}
if (initialQuery.trim().length > 0) {
args.push("-filter", initialQuery.trim());
args.push('-filter', initialQuery.trim());
}
const result = spawnSync(
"rofi",
args,
{
input: `${items.join("\n")}\n`,
encoding: "utf8",
stdio: ["pipe", "pipe", "ignore"],
},
);
const result = spawnSync('rofi', args, {
input: `${items.join('\n')}\n`,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
});
if (result.error) {
fail(formatPickerLaunchError("rofi", result.error as NodeJS.ErrnoException));
fail(formatPickerLaunchError('rofi', result.error as NodeJS.ErrnoException));
}
return (result.stdout || "").trim();
return (result.stdout || '').trim();
}
export function showFzfFlatMenu(
lines: string[],
prompt: string,
previewCommand: string,
initialQuery = "",
initialQuery = '',
): string {
const args = [
"--ansi",
"--reverse",
"--ignore-case",
'--ansi',
'--reverse',
'--ignore-case',
`--prompt=${prompt}`,
"--delimiter=\t",
"--with-nth=2",
"--preview-window=right:50%:wrap",
"--preview",
'--delimiter=\t',
'--with-nth=2',
'--preview-window=right:50%:wrap',
'--preview',
previewCommand,
];
if (initialQuery.trim().length > 0) {
args.push("--query", initialQuery.trim());
args.push('--query', initialQuery.trim());
}
const result = spawnSync(
"fzf",
args,
{
input: `${lines.join("\n")}\n`,
encoding: "utf8",
stdio: ["pipe", "pipe", "inherit"],
},
);
const result = spawnSync('fzf', args, {
input: `${lines.join('\n')}\n`,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'inherit'],
});
if (result.error) {
fail(formatPickerLaunchError("fzf", result.error as NodeJS.ErrnoException));
fail(formatPickerLaunchError('fzf', result.error as NodeJS.ErrnoException));
}
return (result.stdout || "").trim();
return (result.stdout || '').trim();
}
export function parseSelectionId(selection: string): string {
if (!selection) return "";
const tab = selection.indexOf("\t");
if (tab === -1) return "";
if (!selection) return '';
const tab = selection.indexOf('\t');
if (tab === -1) return '';
return selection.slice(0, tab);
}
export function parseSelectionLabel(selection: string): string {
const tab = selection.indexOf("\t");
const tab = selection.indexOf('\t');
if (tab === -1) return selection;
return selection.slice(tab + 1);
}
@@ -121,51 +109,39 @@ export async function promptOptionalJellyfinSearch(
useRofi: boolean,
themePath: string | null = null,
): Promise<string> {
if (useRofi && commandExists("rofi")) {
const rofiArgs = [
"-dmenu",
"-i",
"-p",
"Jellyfin Search (optional)",
];
if (useRofi && commandExists('rofi')) {
const rofiArgs = ['-dmenu', '-i', '-p', 'Jellyfin Search (optional)'];
if (themePath) {
rofiArgs.push("-theme", themePath);
rofiArgs.push('-theme', themePath);
} else {
rofiArgs.push(
"-theme-str",
'configuration { font: "Noto Sans CJK JP Regular 8";}',
);
rofiArgs.push('-theme-str', 'configuration { font: "Noto Sans CJK JP Regular 8";}');
}
const result = spawnSync(
"rofi",
rofiArgs,
{
input: "\n",
encoding: "utf8",
stdio: ["pipe", "pipe", "ignore"],
},
);
if (result.error) return "";
return (result.stdout || "").trim();
const result = spawnSync('rofi', rofiArgs, {
input: '\n',
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
});
if (result.error) return '';
return (result.stdout || '').trim();
}
if (!process.stdin.isTTY || !process.stdout.isTTY) return "";
if (!process.stdin.isTTY || !process.stdout.isTTY) return '';
process.stdout.write("Jellyfin search term (optional, press Enter to skip): ");
process.stdout.write('Jellyfin search term (optional, press Enter to skip): ');
const chunks: Buffer[] = [];
return await new Promise<string>((resolve) => {
const onData = (data: Buffer) => {
const line = data.toString("utf8");
if (line.includes("\n") || line.includes("\r")) {
chunks.push(Buffer.from(line, "utf8"));
process.stdin.off("data", onData);
const text = Buffer.concat(chunks).toString("utf8").trim();
const line = data.toString('utf8');
if (line.includes('\n') || line.includes('\r')) {
chunks.push(Buffer.from(line, 'utf8'));
process.stdin.off('data', onData);
const text = Buffer.concat(chunks).toString('utf8').trim();
resolve(text);
return;
}
chunks.push(data);
};
process.stdin.on("data", onData);
process.stdin.on('data', onData);
});
}
@@ -177,41 +153,35 @@ interface RofiIconEntry {
function showRofiIconMenu(
entries: RofiIconEntry[],
prompt: string,
initialQuery = "",
initialQuery = '',
themePath: string | null = null,
): number {
if (entries.length === 0) return -1;
const rofiArgs = ["-dmenu", "-i", "-show-icons", "-format", "i", "-p", prompt];
if (initialQuery) rofiArgs.push("-filter", initialQuery);
const rofiArgs = ['-dmenu', '-i', '-show-icons', '-format', 'i', '-p', prompt];
if (initialQuery) rofiArgs.push('-filter', initialQuery);
if (themePath) {
rofiArgs.push("-theme", themePath);
rofiArgs.push("-theme-str", "configuration { show-icons: true; }");
rofiArgs.push("-theme-str", "element-icon { enabled: true; size: 3em; }");
rofiArgs.push('-theme', themePath);
rofiArgs.push('-theme-str', 'configuration { show-icons: true; }');
rofiArgs.push('-theme-str', 'element-icon { enabled: true; size: 3em; }');
} else {
rofiArgs.push(
"-theme-str",
'-theme-str',
'configuration { font: "Noto Sans CJK JP Regular 8"; show-icons: true; }',
);
rofiArgs.push("-theme-str", "element-icon { enabled: true; size: 3em; }");
rofiArgs.push('-theme-str', 'element-icon { enabled: true; size: 3em; }');
}
const lines = entries.map((entry) =>
entry.iconPath
? `${entry.label}\u0000icon\u001f${entry.iconPath}`
: entry.label
);
const input = Buffer.from(`${lines.join("\n")}\n`, "utf8");
const result = spawnSync(
"rofi",
rofiArgs,
{
input,
encoding: "utf8",
stdio: ["pipe", "pipe", "ignore"],
},
entry.iconPath ? `${entry.label}\u0000icon\u001f${entry.iconPath}` : entry.label,
);
const input = Buffer.from(`${lines.join('\n')}\n`, 'utf8');
const result = spawnSync('rofi', rofiArgs, {
input,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
});
if (result.error) return -1;
const out = (result.stdout || "").trim();
const out = (result.stdout || '').trim();
if (!out) return -1;
const idx = Number.parseInt(out, 10);
return Number.isFinite(idx) ? idx : -1;
@@ -222,47 +192,35 @@ export function pickLibrary(
libraries: JellyfinLibraryEntry[],
useRofi: boolean,
ensureIcon: (session: JellyfinSessionConfig, id: string) => string | null,
initialQuery = "",
initialQuery = '',
themePath: string | null = null,
): string {
const visibleLibraries = initialQuery.trim().length > 0
? libraries.filter((lib) =>
matchesMenuQuery(`${lib.name} ${lib.kind}`, initialQuery)
)
: libraries;
if (visibleLibraries.length === 0) fail("No Jellyfin libraries found.");
const visibleLibraries =
initialQuery.trim().length > 0
? libraries.filter((lib) => matchesMenuQuery(`${lib.name} ${lib.kind}`, initialQuery))
: libraries;
if (visibleLibraries.length === 0) fail('No Jellyfin libraries found.');
if (useRofi) {
const entries = visibleLibraries.map((lib) => ({
label: `${lib.name} [${lib.kind}]`,
iconPath: ensureIcon(session, lib.id) || undefined,
}));
const idx = showRofiIconMenu(
entries,
"Jellyfin Library",
initialQuery,
themePath,
);
return idx >= 0 ? visibleLibraries[idx].id : "";
const idx = showRofiIconMenu(entries, 'Jellyfin Library', initialQuery, themePath);
return idx >= 0 ? visibleLibraries[idx].id : '';
}
const lines = visibleLibraries.map(
(lib) => `${lib.id}\t${lib.name} [${lib.kind}]`,
);
const preview = commandExists("chafa") && commandExists("curl")
? `
const lines = visibleLibraries.map((lib) => `${lib.id}\t${lib.name} [${lib.kind}]`);
const preview =
commandExists('chafa') && commandExists('curl')
? `
id={1}
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${escapeShellSingle(session.accessToken)}
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${"${FZF_PREVIEW_COLUMNS}"}x${"${FZF_PREVIEW_LINES}"} - 2>/dev/null
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
`.trim()
: 'echo "Install curl + chafa for image preview"';
: 'echo "Install curl + chafa for image preview"';
const picked = showFzfFlatMenu(
lines,
"Jellyfin Library: ",
preview,
initialQuery,
);
const picked = showFzfFlatMenu(lines, 'Jellyfin Library: ', preview, initialQuery);
return parseSelectionId(picked);
}
@@ -271,38 +229,35 @@ export function pickItem(
items: JellyfinItemEntry[],
useRofi: boolean,
ensureIcon: (session: JellyfinSessionConfig, id: string) => string | null,
initialQuery = "",
initialQuery = '',
themePath: string | null = null,
): string {
const visibleItems = initialQuery.trim().length > 0
? items.filter((item) => matchesMenuQuery(item.display, initialQuery))
: items;
if (visibleItems.length === 0) fail("No playable Jellyfin items found.");
const visibleItems =
initialQuery.trim().length > 0
? items.filter((item) => matchesMenuQuery(item.display, initialQuery))
: items;
if (visibleItems.length === 0) fail('No playable Jellyfin items found.');
if (useRofi) {
const entries = visibleItems.map((item) => ({
label: item.display,
iconPath: ensureIcon(session, item.id) || undefined,
}));
const idx = showRofiIconMenu(
entries,
"Jellyfin Item",
initialQuery,
themePath,
);
return idx >= 0 ? visibleItems[idx].id : "";
const idx = showRofiIconMenu(entries, 'Jellyfin Item', initialQuery, themePath);
return idx >= 0 ? visibleItems[idx].id : '';
}
const lines = visibleItems.map((item) => `${item.id}\t${item.display}`);
const preview = commandExists("chafa") && commandExists("curl")
? `
const preview =
commandExists('chafa') && commandExists('curl')
? `
id={1}
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${escapeShellSingle(session.accessToken)}
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${"${FZF_PREVIEW_COLUMNS}"}x${"${FZF_PREVIEW_LINES}"} - 2>/dev/null
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
`.trim()
: 'echo "Install curl + chafa for image preview"';
: 'echo "Install curl + chafa for image preview"';
const picked = showFzfFlatMenu(lines, "Jellyfin Item: ", preview, initialQuery);
const picked = showFzfFlatMenu(lines, 'Jellyfin Item: ', preview, initialQuery);
return parseSelectionId(picked);
}
@@ -311,54 +266,46 @@ export function pickGroup(
groups: JellyfinGroupEntry[],
useRofi: boolean,
ensureIcon: (session: JellyfinSessionConfig, id: string) => string | null,
initialQuery = "",
initialQuery = '',
themePath: string | null = null,
): string {
const visibleGroups = initialQuery.trim().length > 0
? groups.filter((group) => matchesMenuQuery(group.display, initialQuery))
: groups;
if (visibleGroups.length === 0) return "";
const visibleGroups =
initialQuery.trim().length > 0
? groups.filter((group) => matchesMenuQuery(group.display, initialQuery))
: groups;
if (visibleGroups.length === 0) return '';
if (useRofi) {
const entries = visibleGroups.map((group) => ({
label: group.display,
iconPath: ensureIcon(session, group.id) || undefined,
}));
const idx = showRofiIconMenu(
entries,
"Jellyfin Anime/Folder",
initialQuery,
themePath,
);
return idx >= 0 ? visibleGroups[idx].id : "";
const idx = showRofiIconMenu(entries, 'Jellyfin Anime/Folder', initialQuery, themePath);
return idx >= 0 ? visibleGroups[idx].id : '';
}
const lines = visibleGroups.map((group) => `${group.id}\t${group.display}`);
const preview = commandExists("chafa") && commandExists("curl")
? `
const preview =
commandExists('chafa') && commandExists('curl')
? `
id={1}
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${escapeShellSingle(session.accessToken)}
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${"${FZF_PREVIEW_COLUMNS}"}x${"${FZF_PREVIEW_LINES}"} - 2>/dev/null
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
`.trim()
: 'echo "Install curl + chafa for image preview"';
: 'echo "Install curl + chafa for image preview"';
const picked = showFzfFlatMenu(
lines,
"Jellyfin Anime/Folder: ",
preview,
initialQuery,
);
const picked = showFzfFlatMenu(lines, 'Jellyfin Anime/Folder: ', preview, initialQuery);
return parseSelectionId(picked);
}
export function formatPickerLaunchError(
picker: "rofi" | "fzf",
picker: 'rofi' | 'fzf',
error: NodeJS.ErrnoException,
): string {
if (error.code === "ENOENT") {
return picker === "rofi"
? "rofi not found. Install rofi or use --no-rofi to use fzf."
: "fzf not found. Install fzf or use --rofi to use rofi.";
if (error.code === 'ENOENT') {
return picker === 'rofi'
? 'rofi not found. Install rofi or use --no-rofi to use fzf.'
: 'fzf not found. Install fzf or use --rofi to use rofi.';
}
return `Failed to launch ${picker}: ${error.message}`;
}
@@ -388,23 +335,15 @@ export function collectVideos(dir: string, recursive: boolean): string[] {
};
walk(root);
return out.sort((a, b) =>
a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }),
);
return out.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
}
export function buildRofiMenu(
videos: string[],
dir: string,
recursive: boolean,
): Buffer {
export function buildRofiMenu(videos: string[], dir: string, recursive: boolean): Buffer {
const chunks: Buffer[] = [];
for (const video of videos) {
const display = recursive
? path.relative(dir, video)
: path.basename(video);
const display = recursive ? path.relative(dir, video) : path.basename(video);
const line = `${display}\0icon\x1fthumbnail://${video}\n`;
chunks.push(Buffer.from(line, "utf8"));
chunks.push(Buffer.from(line, 'utf8'));
}
return Buffer.concat(chunks);
}
@@ -416,22 +355,15 @@ export function findRofiTheme(scriptPath: string): string | null {
const scriptDir = path.dirname(realpathMaybe(scriptPath));
const candidates: string[] = [];
if (process.platform === "darwin") {
if (process.platform === 'darwin') {
candidates.push(
path.join(
os.homedir(),
"Library/Application Support/SubMiner/themes",
ROFI_THEME_FILE,
),
path.join(os.homedir(), 'Library/Application Support/SubMiner/themes', ROFI_THEME_FILE),
);
} else {
const xdgDataHome =
process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local/share");
candidates.push(path.join(xdgDataHome, "SubMiner/themes", ROFI_THEME_FILE));
candidates.push(
path.join("/usr/local/share/SubMiner/themes", ROFI_THEME_FILE),
);
candidates.push(path.join("/usr/share/SubMiner/themes", ROFI_THEME_FILE));
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local/share');
candidates.push(path.join(xdgDataHome, 'SubMiner/themes', ROFI_THEME_FILE));
candidates.push(path.join('/usr/local/share/SubMiner/themes', ROFI_THEME_FILE));
candidates.push(path.join('/usr/share/SubMiner/themes', ROFI_THEME_FILE));
}
candidates.push(path.join(scriptDir, ROFI_THEME_FILE));
@@ -451,52 +383,50 @@ export function showRofiMenu(
logLevel: LogLevel,
): string {
const args = [
"-dmenu",
"-i",
"-p",
"Select Video ",
"-show-icons",
"-theme-str",
'-dmenu',
'-i',
'-p',
'Select Video ',
'-show-icons',
'-theme-str',
'configuration { font: "Noto Sans CJK JP Regular 8";}',
];
const theme = findRofiTheme(scriptPath);
if (theme) {
args.push("-theme", theme);
args.push('-theme', theme);
} else {
log(
"warn",
'warn',
logLevel,
"Rofi theme not found; using rofi defaults (set SUBMINER_ROFI_THEME to override)",
'Rofi theme not found; using rofi defaults (set SUBMINER_ROFI_THEME to override)',
);
}
const result = spawnSync("rofi", args, {
const result = spawnSync('rofi', args, {
input: buildRofiMenu(videos, dir, recursive),
encoding: "utf8",
stdio: ["pipe", "pipe", "ignore"],
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
});
if (result.error) {
fail(
formatPickerLaunchError("rofi", result.error as NodeJS.ErrnoException),
);
fail(formatPickerLaunchError('rofi', result.error as NodeJS.ErrnoException));
}
const selection = (result.stdout || "").trim();
if (!selection) return "";
const selection = (result.stdout || '').trim();
if (!selection) return '';
return path.join(dir, selection);
}
export function buildFzfMenu(videos: string[]): string {
return videos.map((video) => `${path.basename(video)}\t${video}`).join("\n");
return videos.map((video) => `${path.basename(video)}\t${video}`).join('\n');
}
export function showFzfMenu(videos: string[]): string {
const chafaFormat = process.env.TMUX
? "--format=symbols --symbols=vhalf+wide --color-space=din99d"
: "--format=kitty";
? '--format=symbols --symbols=vhalf+wide --color-space=din99d'
: '--format=kitty';
const previewCmd = commandExists("chafa")
const previewCmd = commandExists('chafa')
? `
video={2}
thumb_dir="$HOME/.cache/thumbnails/large"
@@ -521,35 +451,35 @@ get_thumb() {
}
thumb=$(get_thumb)
[[ -n "$thumb" ]] && chafa ${chafaFormat} --size=${"${FZF_PREVIEW_COLUMNS}"}x${"${FZF_PREVIEW_LINES}"} "$thumb" 2>/dev/null
[[ -n "$thumb" ]] && chafa ${chafaFormat} --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} "$thumb" 2>/dev/null
`.trim()
: 'echo "Install chafa for thumbnail preview"';
const result = spawnSync(
"fzf",
'fzf',
[
"--ansi",
"--reverse",
"--prompt=Select Video: ",
"--delimiter=\t",
"--with-nth=1",
"--preview-window=right:50%:wrap",
"--preview",
'--ansi',
'--reverse',
'--prompt=Select Video: ',
'--delimiter=\t',
'--with-nth=1',
'--preview-window=right:50%:wrap',
'--preview',
previewCmd,
],
{
input: buildFzfMenu(videos),
encoding: "utf8",
stdio: ["pipe", "pipe", "inherit"],
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'inherit'],
},
);
if (result.error) {
fail(formatPickerLaunchError("fzf", result.error as NodeJS.ErrnoException));
fail(formatPickerLaunchError('fzf', result.error as NodeJS.ErrnoException));
}
const selection = (result.stdout || "").trim();
if (!selection) return "";
const tabIndex = selection.indexOf("\t");
if (tabIndex === -1) return "";
const selection = (result.stdout || '').trim();
if (!selection) return '';
const tabIndex = selection.indexOf('\t');
if (tabIndex === -1) return '';
return selection.slice(tabIndex + 1);
}