mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
chore: add project management metadata and remaining repository files
This commit is contained in:
399
launcher/jellyfin.ts
Normal file
399
launcher/jellyfin.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
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,
|
||||
findRofiTheme,
|
||||
} from './picker.js';
|
||||
import { loadLauncherJellyfinConfig } from './config.js';
|
||||
import {
|
||||
runAppCommandWithInheritLogged,
|
||||
launchMpvIdleDetached,
|
||||
waitForUnixSocketReady,
|
||||
} from './mpv.js';
|
||||
|
||||
export function sanitizeServerUrl(value: string): string {
|
||||
return value.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export async function jellyfinApiRequest<T>(
|
||||
session: JellyfinSessionConfig,
|
||||
requestPath: string,
|
||||
): Promise<T> {
|
||||
const url = `${session.serverUrl}${requestPath}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'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.');
|
||||
}
|
||||
if (!response.ok) {
|
||||
fail(`Jellyfin API failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
function itemPreviewUrl(session: JellyfinSessionConfig, id: string): string {
|
||||
return `${session.serverUrl}/Items/${id}/Images/Primary?maxHeight=720&quality=85&api_key=${encodeURIComponent(session.accessToken)}`;
|
||||
}
|
||||
|
||||
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 baseDir = session.iconCacheDir
|
||||
? resolvePathMaybe(session.iconCacheDir)
|
||||
: 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, '_');
|
||||
return path.join(jellyfinIconCacheDir(session), `${safeId}.jpg`);
|
||||
}
|
||||
|
||||
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) {
|
||||
return iconPath;
|
||||
}
|
||||
} catch {
|
||||
// continue to download
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(iconPath), { recursive: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = spawnSync('curl', ['-fsSL', '-o', iconPath, itemPreviewUrl(session, id)], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
|
||||
try {
|
||||
if (fs.existsSync(iconPath) && fs.statSync(iconPath).size > 0) {
|
||||
return iconPath;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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 season =
|
||||
typeof item.ParentIndexNumber === 'number'
|
||||
? String(item.ParentIndexNumber).padStart(2, '0')
|
||||
: '00';
|
||||
const episode =
|
||||
typeof item.IndexNumber === 'number' ? String(item.IndexNumber).padStart(2, '0') : '00';
|
||||
return `${series} S${season}E${episode} ${name}`.trim();
|
||||
}
|
||||
return `${name} (${type})`;
|
||||
}
|
||||
|
||||
export async function resolveJellyfinSelection(
|
||||
args: Args,
|
||||
session: JellyfinSessionConfig,
|
||||
themePath: string | null = null,
|
||||
): Promise<string> {
|
||||
const asNumberOrNull = (value: unknown): number | 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 });
|
||||
const sortEntries = (
|
||||
entries: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
parentIndex: number | null;
|
||||
index: number | null;
|
||||
display: string;
|
||||
}>,
|
||||
) =>
|
||||
entries.sort((left, right) => {
|
||||
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;
|
||||
const leftEpisode = left.index ?? Number.MAX_SAFE_INTEGER;
|
||||
const rightEpisode = right.index ?? Number.MAX_SAFE_INTEGER;
|
||||
if (leftEpisode !== rightEpisode) return leftEpisode - rightEpisode;
|
||||
}
|
||||
if (left.type !== right.type) {
|
||||
const leftEpisodeLike = left.type === 'Episode';
|
||||
const rightEpisodeLike = right.type === 'Episode';
|
||||
if (leftEpisodeLike && !rightEpisodeLike) return -1;
|
||||
if (!leftEpisodeLike && rightEpisodeLike) return 1;
|
||||
}
|
||||
return compareByName(left.display, right.display);
|
||||
});
|
||||
|
||||
const libsPayload = await jellyfinApiRequest<{ Items?: Array<Record<string, unknown>> }>(
|
||||
session,
|
||||
`/Users/${session.userId}/Views`,
|
||||
);
|
||||
const libraries: JellyfinLibraryEntry[] = (libsPayload.Items || [])
|
||||
.map((item) => ({
|
||||
id: typeof item.Id === 'string' ? item.Id : '',
|
||||
name: typeof item.Name === 'string' ? item.Name : 'Untitled',
|
||||
kind:
|
||||
typeof item.CollectionType === 'string'
|
||||
? item.CollectionType
|
||||
: typeof item.Type === 'string'
|
||||
? item.Type
|
||||
: '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.');
|
||||
}
|
||||
const searchTerm = await promptOptionalJellyfinSearch(args.useRofi, themePath);
|
||||
|
||||
const fetchItemsPaged = async (parentId: string) => {
|
||||
const out: Array<Record<string, unknown>> = [];
|
||||
let startIndex = 0;
|
||||
while (true) {
|
||||
const payload = await jellyfinApiRequest<{
|
||||
Items?: Array<Record<string, unknown>>;
|
||||
TotalRecordCount?: number;
|
||||
}>(
|
||||
session,
|
||||
`/Users/${session.userId}/Items?ParentId=${encodeURIComponent(parentId)}&Recursive=false&SortBy=SortName&SortOrder=Ascending&Limit=500&StartIndex=${startIndex}`,
|
||||
);
|
||||
const page = payload.Items || [];
|
||||
if (page.length === 0) break;
|
||||
out.push(...page);
|
||||
startIndex += page.length;
|
||||
const total = typeof payload.TotalRecordCount === 'number' ? payload.TotalRecordCount : null;
|
||||
if (total !== null && startIndex >= total) break;
|
||||
if (page.length < 500) break;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const topLevelEntries = await fetchItemsPaged(libraryId);
|
||||
const groups: JellyfinGroupEntry[] = topLevelEntries
|
||||
.filter((item) => {
|
||||
const type = typeof item.Type === 'string' ? item.Type : '';
|
||||
return (
|
||||
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';
|
||||
return {
|
||||
id: typeof item.Id === 'string' ? item.Id : '',
|
||||
name,
|
||||
type,
|
||||
display: `${name} (${type})`,
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.id.length > 0);
|
||||
|
||||
let contentParentId = libraryId;
|
||||
let contentRecursive = true;
|
||||
const selectedGroupId = pickGroup(
|
||||
session,
|
||||
groups,
|
||||
args.useRofi,
|
||||
ensureJellyfinIcon,
|
||||
searchTerm,
|
||||
themePath,
|
||||
);
|
||||
if (selectedGroupId) {
|
||||
contentParentId = selectedGroupId;
|
||||
const nextLevelEntries = await fetchItemsPaged(selectedGroupId);
|
||||
const seasons: JellyfinGroupEntry[] = nextLevelEntries
|
||||
.filter((item) => {
|
||||
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';
|
||||
return {
|
||||
id: typeof item.Id === 'string' ? item.Id : '',
|
||||
name,
|
||||
type,
|
||||
display: `${name} (${type})`,
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.id.length > 0);
|
||||
if (seasons.length > 0) {
|
||||
const seasonsById = new Map(seasons.map((entry) => [entry.id, entry]));
|
||||
const selectedSeasonId = pickGroup(
|
||||
session,
|
||||
seasons,
|
||||
args.useRofi,
|
||||
ensureJellyfinIcon,
|
||||
'',
|
||||
themePath,
|
||||
);
|
||||
if (!selectedSeasonId) fail('No Jellyfin season selected.');
|
||||
contentParentId = selectedSeasonId;
|
||||
const selectedSeason = seasonsById.get(selectedSeasonId);
|
||||
if (selectedSeason?.type === 'Season') {
|
||||
contentRecursive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPage = async (startIndex: number) =>
|
||||
jellyfinApiRequest<{
|
||||
Items?: Array<Record<string, unknown>>;
|
||||
TotalRecordCount?: number;
|
||||
}>(
|
||||
session,
|
||||
`/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>> = [];
|
||||
let startIndex = 0;
|
||||
while (true) {
|
||||
const payload = await fetchPage(startIndex);
|
||||
const page = payload.Items || [];
|
||||
if (page.length === 0) break;
|
||||
allEntries.push(...page);
|
||||
startIndex += page.length;
|
||||
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),
|
||||
).map(({ id, name, type, display }) => ({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
display,
|
||||
}));
|
||||
|
||||
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),
|
||||
).map(({ id, name, type, display }) => ({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
display,
|
||||
}));
|
||||
}
|
||||
|
||||
const itemId = pickItem(session, items, args.useRofi, ensureJellyfinIcon, '', themePath);
|
||||
if (!itemId) fail('No Jellyfin item selected.');
|
||||
return itemId;
|
||||
}
|
||||
|
||||
export async function runJellyfinPlayMenu(
|
||||
appPath: string,
|
||||
args: Args,
|
||||
scriptPath: string,
|
||||
mpvSocketPath: string,
|
||||
): Promise<never> {
|
||||
const config = loadLauncherJellyfinConfig();
|
||||
const envAccessToken = (process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN || '').trim();
|
||||
const envUserId = (process.env.SUBMINER_JELLYFIN_USER_ID || '').trim();
|
||||
const session: JellyfinSessionConfig = {
|
||||
serverUrl: sanitizeServerUrl(args.jellyfinServer || config.serverUrl || ''),
|
||||
accessToken: envAccessToken,
|
||||
userId: envUserId,
|
||||
defaultLibraryId: config.defaultLibraryId || '',
|
||||
pullPictures: config.pullPictures === true,
|
||||
iconCacheDir: config.iconCacheDir || '',
|
||||
};
|
||||
|
||||
if (!session.serverUrl || !session.accessToken || !session.userId) {
|
||||
fail(
|
||||
'Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.',
|
||||
);
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
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}`);
|
||||
let mpvReady = false;
|
||||
if (fs.existsSync(mpvSocketPath)) {
|
||||
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 250);
|
||||
}
|
||||
if (!mpvReady) {
|
||||
await launchMpvIdleDetached(mpvSocketPath, appPath, args);
|
||||
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 8000);
|
||||
}
|
||||
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');
|
||||
}
|
||||
Reference in New Issue
Block a user