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,20 +1,31 @@
import path from "node:path";
import fs from "node:fs";
import { spawnSync } from "node:child_process";
import type { Args, JellyfinSessionConfig, JellyfinLibraryEntry, JellyfinItemEntry, JellyfinGroupEntry } from "./types.js";
import { log, fail } from "./log.js";
import { commandExists, resolvePathMaybe } from "./util.js";
import path from 'node:path';
import fs from 'node:fs';
import { spawnSync } from 'node:child_process';
import type {
Args,
JellyfinSessionConfig,
JellyfinLibraryEntry,
JellyfinItemEntry,
JellyfinGroupEntry,
} from './types.js';
import { log, fail } from './log.js';
import { commandExists, resolvePathMaybe } from './util.js';
import {
pickLibrary, pickItem, pickGroup, promptOptionalJellyfinSearch,
pickLibrary,
pickItem,
pickGroup,
promptOptionalJellyfinSearch,
findRofiTheme,
} from "./picker.js";
import { loadLauncherJellyfinConfig } from "./config.js";
} from './picker.js';
import { loadLauncherJellyfinConfig } from './config.js';
import {
runAppCommandWithInheritLogged, launchMpvIdleDetached, waitForUnixSocketReady,
} from "./mpv.js";
runAppCommandWithInheritLogged,
launchMpvIdleDetached,
waitForUnixSocketReady,
} from './mpv.js';
export function sanitizeServerUrl(value: string): string {
return value.trim().replace(/\/+$/, "");
return value.trim().replace(/\/+$/, '');
}
export async function jellyfinApiRequest<T>(
@@ -24,12 +35,12 @@ export async function jellyfinApiRequest<T>(
const url = `${session.serverUrl}${requestPath}`;
const response = await fetch(url, {
headers: {
"X-Emby-Token": session.accessToken,
'X-Emby-Token': session.accessToken,
Authorization: `MediaBrowser Token="${session.accessToken}"`,
},
});
if (response.status === 401 || response.status === 403) {
fail("Jellyfin token invalid/expired. Run --jellyfin-login or --jellyfin.");
fail('Jellyfin token invalid/expired. Run --jellyfin-login or --jellyfin.');
}
if (!response.ok) {
fail(`Jellyfin API failed: ${response.status} ${response.statusText}`);
@@ -42,24 +53,21 @@ function itemPreviewUrl(session: JellyfinSessionConfig, id: string): string {
}
function jellyfinIconCacheDir(session: JellyfinSessionConfig): string {
const serverKey = session.serverUrl.replace(/[^a-zA-Z0-9]+/g, "_").slice(0, 96);
const userKey = session.userId.replace(/[^a-zA-Z0-9]+/g, "_").slice(0, 96);
const serverKey = session.serverUrl.replace(/[^a-zA-Z0-9]+/g, '_').slice(0, 96);
const userKey = session.userId.replace(/[^a-zA-Z0-9]+/g, '_').slice(0, 96);
const baseDir = session.iconCacheDir
? resolvePathMaybe(session.iconCacheDir)
: path.join("/tmp", "subminer-jellyfin-icons");
: path.join('/tmp', 'subminer-jellyfin-icons');
return path.join(baseDir, serverKey, userKey);
}
function jellyfinIconPath(session: JellyfinSessionConfig, id: string): string {
const safeId = id.replace(/[^a-zA-Z0-9._-]+/g, "_");
const safeId = id.replace(/[^a-zA-Z0-9._-]+/g, '_');
return path.join(jellyfinIconCacheDir(session), `${safeId}.jpg`);
}
function ensureJellyfinIcon(
session: JellyfinSessionConfig,
id: string,
): string | null {
if (!session.pullPictures || !id || !commandExists("curl")) return null;
function ensureJellyfinIcon(session: JellyfinSessionConfig, id: string): string | null {
if (!session.pullPictures || !id || !commandExists('curl')) return null;
const iconPath = jellyfinIconPath(session, id);
try {
if (fs.existsSync(iconPath) && fs.statSync(iconPath).size > 0) {
@@ -75,11 +83,9 @@ function ensureJellyfinIcon(
return null;
}
const result = spawnSync(
"curl",
["-fsSL", "-o", iconPath, itemPreviewUrl(session, id)],
{ stdio: "ignore" },
);
const result = spawnSync('curl', ['-fsSL', '-o', iconPath, itemPreviewUrl(session, id)], {
stdio: 'ignore',
});
if (result.error || result.status !== 0) return null;
try {
@@ -93,18 +99,16 @@ function ensureJellyfinIcon(
}
export function formatJellyfinItemDisplay(item: Record<string, unknown>): string {
const type = typeof item.Type === "string" ? item.Type : "Item";
const name = typeof item.Name === "string" ? item.Name : "Untitled";
if (type === "Episode") {
const series = typeof item.SeriesName === "string" ? item.SeriesName : "";
const type = typeof item.Type === 'string' ? item.Type : 'Item';
const name = typeof item.Name === 'string' ? item.Name : 'Untitled';
if (type === 'Episode') {
const series = typeof item.SeriesName === 'string' ? item.SeriesName : '';
const season =
typeof item.ParentIndexNumber === "number"
? String(item.ParentIndexNumber).padStart(2, "0")
: "00";
typeof item.ParentIndexNumber === 'number'
? String(item.ParentIndexNumber).padStart(2, '0')
: '00';
const episode =
typeof item.IndexNumber === "number"
? String(item.IndexNumber).padStart(2, "0")
: "00";
typeof item.IndexNumber === 'number' ? String(item.IndexNumber).padStart(2, '0') : '00';
return `${series} S${season}E${episode} ${name}`.trim();
}
return `${name} (${type})`;
@@ -116,11 +120,11 @@ export async function resolveJellyfinSelection(
themePath: string | null = null,
): Promise<string> {
const asNumberOrNull = (value: unknown): number | null => {
if (typeof value !== "number" || !Number.isFinite(value)) return null;
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
return value;
};
const compareByName = (left: string, right: string): number =>
left.localeCompare(right, undefined, { sensitivity: "base", numeric: true });
left.localeCompare(right, undefined, { sensitivity: 'base', numeric: true });
const sortEntries = (
entries: Array<{
type: string;
@@ -131,7 +135,7 @@ export async function resolveJellyfinSelection(
}>,
) =>
entries.sort((left, right) => {
if (left.type === "Episode" && right.type === "Episode") {
if (left.type === 'Episode' && right.type === 'Episode') {
const leftSeason = left.parentIndex ?? Number.MAX_SAFE_INTEGER;
const rightSeason = right.parentIndex ?? Number.MAX_SAFE_INTEGER;
if (leftSeason !== rightSeason) return leftSeason - rightSeason;
@@ -140,8 +144,8 @@ export async function resolveJellyfinSelection(
if (leftEpisode !== rightEpisode) return leftEpisode - rightEpisode;
}
if (left.type !== right.type) {
const leftEpisodeLike = left.type === "Episode";
const rightEpisodeLike = right.type === "Episode";
const leftEpisodeLike = left.type === 'Episode';
const rightEpisodeLike = right.type === 'Episode';
if (leftEpisodeLike && !rightEpisodeLike) return -1;
if (!leftEpisodeLike && rightEpisodeLike) return 1;
}
@@ -154,28 +158,21 @@ export async function resolveJellyfinSelection(
);
const libraries: JellyfinLibraryEntry[] = (libsPayload.Items || [])
.map((item) => ({
id: typeof item.Id === "string" ? item.Id : "",
name: typeof item.Name === "string" ? item.Name : "Untitled",
id: typeof item.Id === 'string' ? item.Id : '',
name: typeof item.Name === 'string' ? item.Name : 'Untitled',
kind:
typeof item.CollectionType === "string"
typeof item.CollectionType === 'string'
? item.CollectionType
: typeof item.Type === "string"
: typeof item.Type === 'string'
? item.Type
: "unknown",
: 'unknown',
}))
.filter((item) => item.id.length > 0);
let libraryId = session.defaultLibraryId;
if (!libraryId) {
libraryId = pickLibrary(
session,
libraries,
args.useRofi,
ensureJellyfinIcon,
"",
themePath,
);
if (!libraryId) fail("No Jellyfin library selected.");
libraryId = pickLibrary(session, libraries, args.useRofi, ensureJellyfinIcon, '', themePath);
if (!libraryId) fail('No Jellyfin library selected.');
}
const searchTerm = await promptOptionalJellyfinSearch(args.useRofi, themePath);
@@ -194,9 +191,7 @@ export async function resolveJellyfinSelection(
if (page.length === 0) break;
out.push(...page);
startIndex += page.length;
const total = typeof payload.TotalRecordCount === "number"
? payload.TotalRecordCount
: null;
const total = typeof payload.TotalRecordCount === 'number' ? payload.TotalRecordCount : null;
if (total !== null && startIndex >= total) break;
if (page.length < 500) break;
}
@@ -206,19 +201,16 @@ export async function resolveJellyfinSelection(
const topLevelEntries = await fetchItemsPaged(libraryId);
const groups: JellyfinGroupEntry[] = topLevelEntries
.filter((item) => {
const type = typeof item.Type === "string" ? item.Type : "";
const type = typeof item.Type === 'string' ? item.Type : '';
return (
type === "Series" ||
type === "Folder" ||
type === "CollectionFolder" ||
type === "Season"
type === 'Series' || type === 'Folder' || type === 'CollectionFolder' || type === 'Season'
);
})
.map((item) => {
const type = typeof item.Type === "string" ? item.Type : "Folder";
const name = typeof item.Name === "string" ? item.Name : "Untitled";
const type = typeof item.Type === 'string' ? item.Type : 'Folder';
const name = typeof item.Name === 'string' ? item.Name : 'Untitled';
return {
id: typeof item.Id === "string" ? item.Id : "",
id: typeof item.Id === 'string' ? item.Id : '',
name,
type,
display: `${name} (${type})`,
@@ -241,14 +233,14 @@ export async function resolveJellyfinSelection(
const nextLevelEntries = await fetchItemsPaged(selectedGroupId);
const seasons: JellyfinGroupEntry[] = nextLevelEntries
.filter((item) => {
const type = typeof item.Type === "string" ? item.Type : "";
return type === "Season" || type === "Folder";
const type = typeof item.Type === 'string' ? item.Type : '';
return type === 'Season' || type === 'Folder';
})
.map((item) => {
const type = typeof item.Type === "string" ? item.Type : "Season";
const name = typeof item.Name === "string" ? item.Name : "Untitled";
const type = typeof item.Type === 'string' ? item.Type : 'Season';
const name = typeof item.Name === 'string' ? item.Name : 'Untitled';
return {
id: typeof item.Id === "string" ? item.Id : "",
id: typeof item.Id === 'string' ? item.Id : '',
name,
type,
display: `${name} (${type})`,
@@ -262,13 +254,13 @@ export async function resolveJellyfinSelection(
seasons,
args.useRofi,
ensureJellyfinIcon,
"",
'',
themePath,
);
if (!selectedSeasonId) fail("No Jellyfin season selected.");
if (!selectedSeasonId) fail('No Jellyfin season selected.');
contentParentId = selectedSeasonId;
const selectedSeason = seasonsById.get(selectedSeasonId);
if (selectedSeason?.type === "Season") {
if (selectedSeason?.type === 'Season') {
contentRecursive = false;
}
}
@@ -280,7 +272,7 @@ export async function resolveJellyfinSelection(
TotalRecordCount?: number;
}>(
session,
`/Users/${session.userId}/Items?ParentId=${encodeURIComponent(contentParentId)}&Recursive=${contentRecursive ? "true" : "false"}&SortBy=SortName&SortOrder=Ascending&Limit=500&StartIndex=${startIndex}`,
`/Users/${session.userId}/Items?ParentId=${encodeURIComponent(contentParentId)}&Recursive=${contentRecursive ? 'true' : 'false'}&SortBy=SortName&SortOrder=Ascending&Limit=500&StartIndex=${startIndex}`,
);
const allEntries: Array<Record<string, unknown>> = [];
@@ -291,28 +283,26 @@ export async function resolveJellyfinSelection(
if (page.length === 0) break;
allEntries.push(...page);
startIndex += page.length;
const total = typeof payload.TotalRecordCount === "number"
? payload.TotalRecordCount
: null;
const total = typeof payload.TotalRecordCount === 'number' ? payload.TotalRecordCount : null;
if (total !== null && startIndex >= total) break;
if (page.length < 500) break;
}
let items: JellyfinItemEntry[] = sortEntries(
allEntries
.filter((item) => {
const type = typeof item.Type === "string" ? item.Type : "";
return type === "Movie" || type === "Episode" || type === "Audio";
})
.map((item) => ({
id: typeof item.Id === "string" ? item.Id : "",
name: typeof item.Name === "string" ? item.Name : "",
type: typeof item.Type === "string" ? item.Type : "Item",
parentIndex: asNumberOrNull(item.ParentIndexNumber),
index: asNumberOrNull(item.IndexNumber),
display: formatJellyfinItemDisplay(item),
}))
.filter((item) => item.id.length > 0),
.filter((item) => {
const type = typeof item.Type === 'string' ? item.Type : '';
return type === 'Movie' || type === 'Episode' || type === 'Audio';
})
.map((item) => ({
id: typeof item.Id === 'string' ? item.Id : '',
name: typeof item.Name === 'string' ? item.Name : '',
type: typeof item.Type === 'string' ? item.Type : 'Item',
parentIndex: asNumberOrNull(item.ParentIndexNumber),
index: asNumberOrNull(item.IndexNumber),
display: formatJellyfinItemDisplay(item),
}))
.filter((item) => item.id.length > 0),
).map(({ id, name, type, display }) => ({
id,
name,
@@ -323,29 +313,28 @@ export async function resolveJellyfinSelection(
if (items.length === 0) {
items = sortEntries(
allEntries
.filter((item) => {
const type = typeof item.Type === "string" ? item.Type : "";
if (type === "Folder" || type === "CollectionFolder") return false;
const mediaType =
typeof item.MediaType === "string" ? item.MediaType.toLowerCase() : "";
if (mediaType === "video" || mediaType === "audio") return true;
return (
type === "Movie" ||
type === "Episode" ||
type === "Audio" ||
type === "Video" ||
type === "MusicVideo"
);
})
.map((item) => ({
id: typeof item.Id === "string" ? item.Id : "",
name: typeof item.Name === "string" ? item.Name : "",
type: typeof item.Type === "string" ? item.Type : "Item",
parentIndex: asNumberOrNull(item.ParentIndexNumber),
index: asNumberOrNull(item.IndexNumber),
display: formatJellyfinItemDisplay(item),
}))
.filter((item) => item.id.length > 0),
.filter((item) => {
const type = typeof item.Type === 'string' ? item.Type : '';
if (type === 'Folder' || type === 'CollectionFolder') return false;
const mediaType = typeof item.MediaType === 'string' ? item.MediaType.toLowerCase() : '';
if (mediaType === 'video' || mediaType === 'audio') return true;
return (
type === 'Movie' ||
type === 'Episode' ||
type === 'Audio' ||
type === 'Video' ||
type === 'MusicVideo'
);
})
.map((item) => ({
id: typeof item.Id === 'string' ? item.Id : '',
name: typeof item.Name === 'string' ? item.Name : '',
type: typeof item.Type === 'string' ? item.Type : 'Item',
parentIndex: asNumberOrNull(item.ParentIndexNumber),
index: asNumberOrNull(item.IndexNumber),
display: formatJellyfinItemDisplay(item),
}))
.filter((item) => item.id.length > 0),
).map(({ id, name, type, display }) => ({
id,
name,
@@ -354,8 +343,8 @@ export async function resolveJellyfinSelection(
}));
}
const itemId = pickItem(session, items, args.useRofi, ensureJellyfinIcon, "", themePath);
if (!itemId) fail("No Jellyfin item selected.");
const itemId = pickItem(session, items, args.useRofi, ensureJellyfinIcon, '', themePath);
if (!itemId) fail('No Jellyfin item selected.');
return itemId;
}
@@ -367,32 +356,28 @@ export async function runJellyfinPlayMenu(
): Promise<never> {
const config = loadLauncherJellyfinConfig();
const session: JellyfinSessionConfig = {
serverUrl: sanitizeServerUrl(args.jellyfinServer || config.serverUrl || ""),
accessToken: config.accessToken || "",
userId: config.userId || "",
defaultLibraryId: config.defaultLibraryId || "",
serverUrl: sanitizeServerUrl(args.jellyfinServer || config.serverUrl || ''),
accessToken: config.accessToken || '',
userId: config.userId || '',
defaultLibraryId: config.defaultLibraryId || '',
pullPictures: config.pullPictures === true,
iconCacheDir: config.iconCacheDir || "",
iconCacheDir: config.iconCacheDir || '',
};
if (!session.serverUrl || !session.accessToken || !session.userId) {
fail(
"Missing Jellyfin session config. Run `subminer --jellyfin` or `subminer --jellyfin-login` first.",
'Missing Jellyfin session config. Run `subminer --jellyfin` or `subminer --jellyfin-login` first.',
);
}
const rofiTheme = args.useRofi ? findRofiTheme(scriptPath) : null;
if (args.useRofi && !rofiTheme) {
log(
"warn",
args.logLevel,
"Rofi theme not found for Jellyfin picker; using rofi defaults.",
);
log('warn', args.logLevel, 'Rofi theme not found for Jellyfin picker; using rofi defaults.');
}
const itemId = await resolveJellyfinSelection(args, session, rofiTheme);
log("debug", args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`);
log("debug", args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`);
log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`);
log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`);
let mpvReady = false;
if (fs.existsSync(mpvSocketPath)) {
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 250);
@@ -401,15 +386,11 @@ export async function runJellyfinPlayMenu(
await launchMpvIdleDetached(mpvSocketPath, appPath, args);
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 8000);
}
log(
"debug",
args.logLevel,
`MPV socket ready check result: ${mpvReady ? "ready" : "not ready"}`,
);
log('debug', args.logLevel, `MPV socket ready check result: ${mpvReady ? 'ready' : 'not ready'}`);
if (!mpvReady) {
fail(`MPV IPC socket not ready: ${mpvSocketPath}`);
}
const forwarded = ["--start", "--jellyfin-play", "--jellyfin-item-id", itemId];
if (args.logLevel !== "info") forwarded.push("--log-level", args.logLevel);
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, "jellyfin-play");
const forwarded = ['--start', '--jellyfin-play', '--jellyfin-item-id', itemId];
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
}