feat: media library scanner + full library search

BREAKING CHANGE:

A massive paradigm shift in the way libraries and media are accessed
within Tunarr.
This commit is contained in:
Christian Benincasa
2025-02-12 13:24:14 -05:00
parent 87736c9870
commit 0f1a1ce820
324 changed files with 33369 additions and 9715 deletions

View File

@@ -1,5 +1,10 @@
on:
workflow_dispatch:
inputs:
base_ref:
description: "Ref to build pre-release from"
required: true
default: 'dev'
push:
branches:
- media-scanner

View File

@@ -60,4 +60,5 @@ jobs:
echo ${{ steps.semantic.outputs.new_release_major_version }}
echo ${{ steps.semantic.outputs.new_release_minor_version }}
echo ${{ steps.semantic.outputs.new_release_patch_version }}
echo ${{ steps.semantic.outputs.new_release_prerelease_version }}
echo ${{ steps.semantic.outputs.new_release_prerelease_version }}
echo ${{ steps.semantic.outputs.new_release_git_tag }}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -30,7 +30,7 @@
"eslint-import-resolver-typescript": "^3.7.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-react": "^7.37.3",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.16",
"eslint-plugin-unused-imports": "^4.1.4",
"globals": "^15.0.0",
@@ -48,7 +48,6 @@
"packageManager": "pnpm@9.12.3",
"pnpm": {
"patchedDependencies": {
"ts-essentials@9.4.1": "patches/ts-essentials@9.4.1.patch",
"kysely": "patches/kysely.patch"
},
"overrides": {

3347
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
[test]
preload = ['./src/testing/matchers/PixelFormatMatcher.ts']

View File

@@ -3,6 +3,7 @@ import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'sqlite',
schema: './src/db/schema/**/*.ts',
out: './src/migration/db/sql',
casing: 'snake_case',
dbCredentials: {
url: process.env.TUNARR_DATABASE_PATH,

View File

@@ -3,3 +3,9 @@ export const __import_meta_url =
? new (require('url'.replace('', '')).URL)('file:' + __filename).href
: (document.currentScript && document.currentScript.src) ||
new URL('main.js', document.baseURI).href;
export const __import_meta_dirname =
typeof document === 'undefined'
? new (require('url'.replace('', '')).URL)('file:' + __dirname).href
: (document.currentScript && document.currentScript.src) ||
new URL('main.js', document.baseURI).href;

View File

@@ -12,12 +12,13 @@
"build-dev": "cross-env NODE_ENV=development tsc -p tsconfig.build.json --noEmit --watch",
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 tsc -p tsconfig.build.json",
"bundle": "dotenv -- tsx scripts/bundle.ts",
"clean": "rimraf --glob ./build/ ./dist/ ./src/generated/web-imports.ts ./src/generated/web-imports.js",
"make-bin": "dotenv -- tsx scripts/make-bin.ts",
"clean": "rimraf --glob ./build/ ./dist/ ./bin/tunar*",
"debug": "dotenv -e .env.development -- tsx watch --trace-warnings --tsconfig ./tsconfig.build.json --ignore 'src/streams' --inspect-wait ./src",
"dev": "dotenv -e .env.development -- tsx watch --trace-warnings --tsconfig ./tsconfig.build.json --ignore 'build' --ignore 'src/streams' --ignore 'src/**/*.test.ts' ./src/index.ts",
"generate-openapi": "tsx src/index.ts generate-openapi",
"install-meilisearch": "tsx scripts/download-meilisearch.ts",
"kysely": "dotenv -e .env.development -- kysely",
"make-bin": "dotenv -- tsx scripts/make-bin.ts",
"preinstall": "npx only-allow pnpm",
"run-fixer": "dotenv -e .env.development -- tsx src/index.ts fixer",
"test:watch": "vitest --watch",
@@ -26,99 +27,106 @@
"typecheck": "tsc -p tsconfig.build.json --noEmit"
},
"dependencies": {
"@dotenvx/dotenvx": "^1.45.1",
"@fastify/cors": "^10.0.1",
"@fastify/error": "^4.1.0",
"@fastify/multipart": "^9.0.1",
"@fastify/static": "^8.0.1",
"@dotenvx/dotenvx": "^1.49.0",
"@fastify/cors": "^10.1.0",
"@fastify/error": "^4.2.0",
"@fastify/multipart": "^9.0.3",
"@fastify/static": "^8.2.0",
"@fastify/swagger": "^9.5.1",
"@iptv/xmltv": "^1.0.1",
"@logdna/tail-file": "^4.0.2",
"@scalar/fastify-api-reference": "^1.25.106",
"@scalar/fastify-api-reference": "^1.34.6",
"@tunarr/playlist": "^1.1.0",
"@tunarr/shared": "workspace:*",
"@tunarr/types": "workspace:*",
"@types/better-sqlite3": "^7.6.12",
"@types/better-sqlite3": "^7.6.13",
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
"async-retry": "^1.3.3",
"axios": "^1.11.0",
"base32": "^0.0.7",
"better-sqlite3": "11.8.1",
"chalk": "^5.3.0",
"chalk": "^5.6.0",
"cron-parser": "^4.9.0",
"dayjs": "^1.11.10",
"dayjs": "^1.11.14",
"drizzle-orm": "^0.39.3",
"fast-xml-parser": "^4.3.5",
"fastify": "^5.0.0",
"fast-xml-parser": "^4.5.3",
"fastify": "^5.5.0",
"fastify-graceful-shutdown": "^4.0.1",
"fastify-plugin": "^5.0.1",
"fastify-print-routes": "^3.2.0",
"fastify-type-provider-zod": "^5.0.3",
"file-type": "^19.6.0",
"find-process": "^2.0.0",
"graphology": "^0.26.0",
"graphology-dag": "^0.4.1",
"inversify": "^6.2.1",
"inversify": "^6.2.2",
"jsonpath-plus": "^10.3.0",
"kysely": "^0.27.4",
"kysely": "^0.27.6",
"lodash-es": "^4.17.21",
"lowdb": "^7.0.0",
"lowdb": "^7.0.1",
"meilisearch": "^0.49.0",
"node-cache": "^5.1.2",
"node-schedule": "^2.1.1",
"node-ssdp": "^4.0.0",
"p-queue": "^8.0.1",
"pino": "^9.0.0",
"pino-pretty": "^11.2.2",
"pino-roll": "^1.1.0",
"node-ssdp": "^4.0.1",
"p-queue": "^8.1.0",
"pino": "^9.9.1",
"pino-pretty": "^11.3.0",
"pino-roll": "^1.3.0",
"random-js": "2.1.0",
"reflect-metadata": "^0.2.2",
"retry": "^0.13.1",
"split2": "^4.2.0",
"ts-pattern": "^5.4.0",
"tslib": "^2.6.2",
"ts-pattern": "^5.8.0",
"tslib": "^2.8.1",
"uuid": "^9.0.1",
"yargs": "^17.7.2",
"zod": "^4.0.17"
"zod": "^4.1.5"
},
"devDependencies": {
"@faker-js/faker": "^9.9.0",
"@octokit/types": "^13.10.0",
"@rollup/plugin-swc": "^0.4.0",
"@types/archiver": "^6.0.2",
"@types/async-retry": "^1.4.8",
"@types/archiver": "^6.0.3",
"@types/async-retry": "^1.4.9",
"@types/lodash-es": "4.17.9",
"@types/node": "22.10.7",
"@types/node-abi": "^3.0.3",
"@types/node-schedule": "^2.1.3",
"@types/node-schedule": "^2.1.8",
"@types/retry": "^0.12.5",
"@types/split2": "^4.2.3",
"@types/tmp": "^0.2.6",
"@types/unzip-stream": "^0.3.4",
"@types/uuid": "^9.0.6",
"@types/yargs": "^17.0.29",
"@types/uuid": "^9.0.8",
"@types/yargs": "^17.0.33",
"@vitest/coverage-v8": "^3.2.4",
"@yao-pkg/pkg": "^6.5.1",
"@yao-pkg/pkg": "^6.6.0",
"cross-env": "^7.0.3",
"del-cli": "^3.0.0",
"dotenv-cli": "^7.4.1",
"drizzle-kit": "^0.30.4",
"esbuild-plugin-pino": "^2.2.1",
"del-cli": "^3.0.1",
"dotenv-cli": "^7.4.4",
"drizzle-kit": "^0.30.6",
"esbuild-plugin-pino": "^2.3.3",
"fast-check": "^4.2.0",
"fast-glob": "^3.3.2",
"globals": "^15.0.0",
"fast-glob": "^3.3.3",
"globals": "^15.15.0",
"kysely-ctl": "^0.9.0",
"node-abi": "^3.74.0",
"prettier": "^3.5.1",
"rimraf": "^5.0.5",
"node-abi": "^3.75.0",
"prettier": "^3.6.2",
"rimraf": "^5.0.10",
"tar": "^7.4.3",
"thread-stream": "^3.1.0",
"tmp": "^0.2.1",
"tmp": "^0.2.5",
"tmp-promise": "^3.0.3",
"ts-essentials": "^10.0.0",
"ts-essentials": "^10.1.1",
"ts-mockito": "^2.6.1",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.19.2",
"tsx": "^4.20.5",
"typed-emitter": "^2.1.0",
"typescript": "5.7.3",
"typescript-eslint": "^8.19.0",
"typescript-eslint": "^8.41.0",
"vitest": "^3.2.4"
},
"meilisearch": {
"version": "1.15.2"
}
}

View File

@@ -1,6 +1,5 @@
{
"pkg": {
"assets": ["./dist/**/*"],
"outputPath": "dist/bin"
"assets": ["./dist/**/*"]
}
}

View File

@@ -1,39 +0,0 @@
datasource db {
provider = "sqlite"
url = "file://${env(\"PRISMA_DATABASE_PATH\")}"
}
generator kysely {
provider = "prisma-kysely"
// Optionally provide a destination directory for the generated file
// and a filename of your choice
output = "./src/db/schema"
fileName = "types.ts"
// Optionally generate runtime enums to a separate file
enumFileName = "enums.ts"
}
model Channel {
uuid String @id
createdAt DateTime?
updatedAt DateTime?
disableFillerOverlay Boolean @default(false)
duration Int
fillerRepeatCooldown Int?
groupTitle String?
guideFlexTitle String?
guideMinimumDuration Int
/// @kyselyType( import('./base.ts').ChannelIcon )
icon Json
name String
number Int @unique
/// @kyselyType( import('./base.ts).ChannelOfflineSettings )
offline Json @default("{\"mode\":\"clip\"}")
startTime Int
stealth Boolean @default(false)
streamMode String
transcoding
transcodeConfigId
watermark
}

View File

@@ -0,0 +1,95 @@
import 'dotenv/config';
import esbuild from 'esbuild';
import esbuildPluginPino from 'esbuild-plugin-pino';
import fg from 'fast-glob';
import fs from 'node:fs';
import { basename } from 'node:path';
import { rimraf } from 'rimraf';
import { nativeNodeModulesPlugin } from '../esbuild/native-node-module.js';
import { nodeProtocolPlugin } from '../esbuild/node-protocol.js';
const DIST_DIR = 'dist';
if (fs.existsSync(DIST_DIR)) {
console.log('Deleting old build...');
await rimraf(DIST_DIR);
}
fs.mkdirSync(DIST_DIR);
console.log('Copying images...');
fs.cpSync('src/resources/images', `${DIST_DIR}/resources/images`, {
recursive: true,
});
const isEdgeBuild = process.env.TUNARR_EDGE_BUILD === 'true';
console.log('Bundling app...');
const result = await esbuild.build({
entryPoints: {
bundle: 'src/index.ts',
},
outExtension: {
'.js': '.cjs',
},
bundle: true,
minify: true,
outdir: DIST_DIR,
logLevel: 'info',
format: 'cjs',
platform: 'node',
target: 'node22',
inject: [
'./esbuild/bundlerPathsOverrideShim.ts',
'./esbuild/importMetaUrlShim.ts',
],
tsconfig: './tsconfig.build.json',
external: [
'mysql',
'mysql2',
'sqlite3',
'pg',
'tedious',
'pg-query-stream',
'oracledb',
'mariadb',
'libsql',
],
mainFields: ['module', 'main'],
plugins: [
nativeNodeModulesPlugin(),
nodeProtocolPlugin(),
// copy({
// resolveFrom: 'cwd',
// assets: {
// from: ['node_modules/@fastify/swagger-ui/static/*'],
// to: ['build/static'],
// },
// }),
esbuildPluginPino({
transports: ['pino-pretty', 'pino-roll'],
}),
],
keepNames: true, // This is to ensure that Entity class names remain the same
metafile: true,
define: {
'process.env.NODE_ENV': '"production"',
'process.env.TUNARR_BUILD': `"${process.env.TUNARR_BUILD}"`,
'process.env.TUNARR_EDGE_BUILD': `"${isEdgeBuild}"`,
'import.meta.url': '__import_meta_url',
'import.meta.dirname': '__import_meta_dirname',
},
});
fs.writeFileSync(`${DIST_DIR}/meta.json`, JSON.stringify(result.metafile));
fs.cpSync('package.json', `${DIST_DIR}/package.json`);
const nativeBindings = await fg('node_modules/better-sqlite3/**/*.node');
for (const binding of nativeBindings) {
console.log(`Copying ${binding} to out dir`);
fs.cpSync(binding, `${DIST_DIR}/build/${basename(binding)}`);
}
console.log('Done bundling!');
process.exit(0);

View File

@@ -0,0 +1,167 @@
import { Endpoints } from '@octokit/types';
import axios from 'axios';
import { execSync } from 'node:child_process';
import { createWriteStream } from 'node:fs';
import fs from 'node:fs/promises';
import os from 'node:os';
import { dirname } from 'node:path';
import stream from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { match } from 'ts-pattern';
import serverPackage from '../package.json' with { type: 'json' };
import { fileExists } from '../src/util/fsUtil.ts';
import { groupByUniq } from '../src/util/index.ts';
const outPath = './bin/meilisearch';
const wantedVersion = serverPackage.meilisearch.version;
async function hasExecutePermission() {
try {
const stats = await fs.stat(outPath);
const mode = stats.mode;
// Check for execute permission for the owner
const ownerExecute = mode & 0o100;
// Check for execute permission for the group
const groupExecute = mode & 0o010;
// Check for execute permission for others
const othersExecute = mode & 0o001;
// Determine the current user's permissions based on their ID and the file's ownership
if (os.userInfo().uid === stats.uid) {
return !!ownerExecute;
} else if (os.userInfo().gid === stats.gid) {
return !!groupExecute;
} else {
return !!othersExecute;
}
} catch (err) {
// Handle errors like the file not existing
console.error('Error getting file stats:', err);
return false;
}
}
async function addExecPermission() {
try {
const stat = await fs.stat(outPath);
const currentMode = stat.mode;
await fs.chmod(outPath, currentMode | 0o100);
} catch (e) {
console.error(e, 'Error while trying to chmod +x meilisearch binary');
}
}
async function needsToDownloadNewBinary() {
const exists = await fileExists(outPath);
let shouldDownload = !exists;
if (exists) {
// check version against package
const stdout = execSync(`${outPath} --version`).toString('utf-8').trim();
const extractedVersionMatch = /meilisearch\s*(\d+\.\d+\.\d+).*/.exec(
stdout,
);
if (!extractedVersionMatch) {
console.warn(`Could not parse meilisearch version output: ${stdout}`);
shouldDownload = true;
} else {
const version = extractedVersionMatch[1];
if (version === wantedVersion) {
console.info(
'Skipping meilisearch download. Already have right version',
);
const hasExec = await hasExecutePermission();
if (hasExec) {
console.debug('meilisearch has execute permissions. Woohoo!');
} else {
console.warn(
'meilisearch does not have execute permissions. Attempting to add them',
);
await addExecPermission();
}
} else {
shouldDownload = true;
}
}
}
return shouldDownload;
}
type getReleaseByTagResponse =
Endpoints['GET /repos/{owner}/{repo}/releases/tags/{tag}']['response']['data'];
async function copyToTarget(targetPath: string) {
const dir = dirname(targetPath);
if (!(await fileExists(dir))) {
await fs.mkdir(dir, { recursive: true });
}
await fs.cp(outPath, targetPath);
}
export async function grabMeilisearch(targetPath?: string) {
const needsDownload = await needsToDownloadNewBinary();
if (!needsDownload) {
console.debug(
'Current meilisearch binary version already at version ' + wantedVersion,
);
if (targetPath) {
await copyToTarget(targetPath);
}
return outPath;
}
console.info(`Downloading meilisearch version ${wantedVersion} from Github`);
const response = await axios.get<getReleaseByTagResponse>(
`https://api.github.com/repos/meilisearch/meilisearch/releases/tags/v${wantedVersion}`,
);
const assetsByName = groupByUniq(response.data.assets, (asset) => asset.name);
const meilisearchArchName = match([os.platform(), os.arch()])
.with(['linux', 'x64'], () => 'linux-amd64')
.with(['linux', 'arm64'], () => 'linux-aarch64')
.with(['darwin', 'x64'], () => 'macos-amd64')
.with(['darwin', 'arm64'], () => 'macos-apple-silicon')
.with(['win32', 'x64'], () => 'windows-amd64')
.otherwise(() => null);
if (!meilisearchArchName) {
console.error(
`Unsupported platform/arch combo: ${os.platform()} / ${os.arch()}`,
);
return;
}
const asset = assetsByName[`meilisearch-${meilisearchArchName}`];
if (!asset) {
console.error(`No asset found for type: ${meilisearchArchName}`);
return;
}
const outStream = await axios.request<stream.Readable>({
method: 'get',
url: asset.browser_download_url,
responseType: 'stream',
});
await pipeline(outStream.data, createWriteStream(outPath));
console.log(`Successfully wrote meilisearch binary to ${outPath}`);
await addExecPermission();
console.log('Successfully set exec permissions on new binary');
if (targetPath) {
await copyToTarget(targetPath);
return targetPath;
}
return outPath;
}
if (process.argv[1] === import.meta.filename) {
await grabMeilisearch(process.argv?.[2]);
}

View File

@@ -6,12 +6,14 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import stream from 'node:stream';
import { format } from 'node:util';
import { rimraf } from 'rimraf';
import * as tar from 'tar';
import tmp from 'tmp-promise';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import serverPackage from '../package.json' with { type: 'json' };
import { fileExists } from '../src/util/fsUtil.ts';
import { grabMeilisearch } from './download-meilisearch.ts';
const betterSqlite3ReleaseFmt =
'https://github.com/WiseLibs/better-sqlite3/releases/download/v%s/better-sqlite3-v%s-node-v%s-%s-%s.tar.gz';
@@ -73,10 +75,18 @@ const args = await yargs(hideBin(process.argv))
type: 'boolean',
default: true,
})
.option('clean', {
type: 'boolean',
default: false,
})
.parseAsync();
!(await fileExists('./bin')) && (await fs.mkdir('./bin'));
if (args.clean) {
await rimraf('./bin/tunarr*', { glob: true });
}
(await fileExists('./dist/web')) &&
(await fs.rm('./dist/web', { recursive: true }));
@@ -87,6 +97,12 @@ await fs.cp(path.resolve(process.cwd(), '../web/dist'), './dist/web', {
recursive: true,
});
await fs.cp(
path.resolve(process.cwd(), './src/migration/db/sql'),
'./dist/sql',
{ recursive: true },
);
const originalWorkingDir = process.cwd();
console.log(`Going to build archs: ${args.target.join(' ')}`);
@@ -108,6 +124,13 @@ for (const arch of args.target) {
});
});
const meilisearchBinaryPath = await grabMeilisearch();
if (!meilisearchBinaryPath) {
throw new Error('Could not download Meilisearch binary');
} else {
console.log(`Meilisearch found at ${meilisearchBinaryPath}`);
}
// Untar
await new Promise((resolve, reject) => {
const outstream = betterSqliteDlStream.data.pipe(
@@ -164,6 +187,7 @@ for (const arch of args.target) {
// Look into whether we want this sometimes...
'--no-bytecode',
'--signature', // for macos arm64
'--debug',
'-o',
`dist/bin/${execName}`,
];

View File

@@ -1,101 +0,0 @@
{
"version": 1,
"migration": {
"legacyMigration": false,
"isFreshSettings": true
},
"settings": {
"clientId": "332e9a98-63a7-44d0-8add-854ccc5d5cee",
"hdhr": {
"autoDiscoveryEnabled": true,
"tunerCount": 2
},
"xmltv": {
"programmingHours": 12,
"refreshHours": 4,
"outputPath": "/Users/christianbenincasa/Code/projects/tunarr/server/xmltv.xml",
"enableImageCache": false
},
"plexStream": {
"streamPath": "network",
"enableDebugLogging": false,
"directStreamBitrate": 20000,
"transcodeBitrate": 2000,
"mediaBufferSize": 1000,
"transcodeMediaBufferSize": 20000,
"maxPlayableResolution": {
"widthPx": 1920,
"heightPx": 1080
},
"maxTranscodeResolution": {
"widthPx": 1920,
"heightPx": 1080
},
"videoCodecs": [
"h264",
"hevc",
"mpeg2video",
"av1"
],
"audioCodecs": [
"ac3"
],
"maxAudioChannels": "2.0",
"audioBoost": 100,
"enableSubtitles": false,
"subtitleSize": 100,
"updatePlayStatus": false,
"streamProtocol": "http",
"forceDirectPlay": false,
"pathReplace": "",
"pathReplaceWith": ""
},
"ffmpeg": {
"configVersion": 5,
"ffmpegExecutablePath": "/usr/bin/ffmpeg",
"numThreads": 4,
"concatMuxDelay": 0,
"enableLogging": false,
"enableTranscoding": true,
"audioVolumePercent": 100,
"videoEncoder": "libx264",
"hardwareAccelerationMode": "none",
"videoFormat": "h264",
"audioEncoder": "aac",
"targetResolution": {
"widthPx": 1920,
"heightPx": 1080
},
"videoBitrate": 10000,
"videoBufferSize": 1000,
"audioBitrate": 192,
"audioBufferSize": 50,
"audioSampleRate": 48,
"audioChannels": 2,
"errorScreen": "pic",
"errorAudio": "silent",
"normalizeVideoCodec": true,
"normalizeAudioCodec": true,
"normalizeResolution": true,
"normalizeAudio": true,
"maxFPS": 60,
"scalingAlgorithm": "bicubic",
"deinterlaceFilter": "none",
"disableChannelOverlay": false,
"disableChannelPrelude": false
}
},
"system": {
"backup": {
"configurations": []
},
"logging": {
"logLevel": "debug",
"logsDirectory": "/Users/christianbenincasa/Library/Preferences/tunarr/logs",
"useEnvVarLevel": true
},
"cache": {
"enablePlexRequestCache": false
}
}
}

View File

@@ -232,7 +232,6 @@ export class Server {
const roundedTime = round(rep.elapsedTime, 4);
this.logger[req.routeOptions.config.logAtLevel ?? 'http'](
`${req.method} ${req.url} ${rep.statusCode} -${lengthStr}${roundedTime}ms`,
{
req: {
method: req.method,
@@ -241,6 +240,7 @@ export class Server {
elapsedTime: roundedTime,
},
},
`${req.method} ${req.url} ${rep.statusCode} -${lengthStr}${roundedTime}ms`,
);
done();
});
@@ -463,6 +463,8 @@ export class Server {
this.logger.debug(e, 'Error sending shutdown signal to frontend');
}
this.serverContext.searchService.stop();
try {
this.logger.debug('Pausing all on-demand channels');
await this.serverContext.onDemandChannelService.pauseAllChannels();

View File

@@ -21,9 +21,12 @@ import { FileCacheService } from './services/FileCacheService.ts';
import { HdhrService } from './services/HDHRService.ts';
import { HealthCheckService } from './services/HealthCheckService.js';
import { M3uService } from './services/M3UService.ts';
import { MediaSourceLibraryRefresher } from './services/MediaSourceLibraryRefresher.ts';
import { MeilisearchService } from './services/MeilisearchService.ts';
import { OnDemandChannelService } from './services/OnDemandChannelService.js';
import { TVGuideService } from './services/TvGuideService.ts';
import { CacheImageService } from './services/cacheImageService.js';
import { MediaSourceScanCoordinator } from './services/scanner/MediaSourceScanCoordinator.ts';
import { ChannelCache } from './stream/ChannelCache.js';
import { SessionManager } from './stream/SessionManager.js';
import { StreamProgramCalculator } from './stream/StreamProgramCalculator.js';
@@ -69,6 +72,15 @@ export class ServerContext {
@inject(KEYS.WorkerPool)
public readonly workerPool: IWorkerPool;
@inject(MeilisearchService)
public readonly searchService!: MeilisearchService;
@inject(MediaSourceScanCoordinator)
public readonly mediaSourceScanCoordinator: MediaSourceScanCoordinator;
@inject(MediaSourceLibraryRefresher)
public readonly mediaSourceLibraryRefresher: MediaSourceLibraryRefresher;
}
export class ServerRequestContext {

View File

@@ -6,6 +6,7 @@ import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
import { isDefined } from '@/util/index.js';
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
import { timeNamedAsync } from '@/util/perf.js';
import { seq } from '@tunarr/shared/util';
import type { ChannelSession, CreateChannelRequest } from '@tunarr/types';
import {
BasicIdParamSchema,
@@ -66,7 +67,11 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
});
fastify.addHook('onError', (req, _, error, done) => {
logger.error(error, '%s %s', req.routeOptions.method, req.routeOptions.url);
logger.error({
error,
method: req.routeOptions.method,
url: req.routeOptions.url,
});
done();
});
@@ -247,6 +252,8 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
params: z.object({ id: z.string() }),
response: {
200: ChannelSchema,
404: z.void(),
500: z.void(),
},
},
},
@@ -362,7 +369,7 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
);
return res.send(
programs.map((program) =>
seq.collect(programs, (program) =>
req.serverCtx.programConverter.programDaoToContentProgram(
program,
program.externalIds ?? [],
@@ -395,6 +402,7 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
result: shows.map((show) =>
req.serverCtx.programConverter.tvShowDaoToDto(show),
),
size: shows.length,
});
},
);
@@ -422,6 +430,7 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
result: shows.map((show) =>
req.serverCtx.programConverter.musicArtistDaoToDto(show),
),
size: shows.length,
});
},
);
@@ -566,7 +575,7 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
async (req, res) => {
const fallbacks =
await req.serverCtx.channelDB.getChannelFallbackPrograms(req.params.id);
const converted = map(fallbacks, (p) =>
const converted = seq.collect(fallbacks, (p) =>
req.serverCtx.programConverter.programDaoToContentProgram(p, []),
);
return res.send(converted);

View File

@@ -2,6 +2,7 @@ import { FfmpegStreamFactory } from '@/ffmpeg/FfmpegStreamFactory.js';
import { MpegTsOutputFormat } from '@/ffmpeg/builder/constants.js';
import { FfprobeStreamDetails } from '@/stream/FfprobeStreamDetails.js';
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
import { tag } from '@tunarr/types';
import dayjs from 'dayjs';
import { z } from 'zod/v4';
import { container } from '../../container.ts';
@@ -92,7 +93,7 @@ export const debugFfmpegApiRouter: RouterPluginAsyncCallback = async (
streamDuration: +dayjs.duration({ seconds: 30 }),
externalKey: 'none',
externalSource: 'emby',
externalSourceId: 'none',
externalSourceId: tag('none'),
programBeginMs: 0,
programId: '',
programType: 'movie',
@@ -115,7 +116,7 @@ export const debugFfmpegApiRouter: RouterPluginAsyncCallback = async (
return res.status(500).send();
}
const server = await req.serverCtx.mediaSourceDB.getByName(
const server = await req.serverCtx.mediaSourceDB.getById(
item.externalSourceId,
);

View File

@@ -1,10 +1,15 @@
import { container } from '@/container.js';
import { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
import type { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
import { JellyfinItemFinder } from '@/external/jellyfin/JellyfinItemFinder.js';
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
import type { Nilable } from '@/types/util.js';
import { tag } from '@tunarr/types';
import { isNil } from 'lodash-es';
import { v4 } from 'uuid';
import { z } from 'zod/v4';
import { MediaSourceType } from '../../db/schema/MediaSource.ts';
import type { MediaSourceApiClientFactory } from '../../external/MediaSourceApiClient.ts';
import { KEYS } from '../../types/inject.ts';
export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
fastify,
@@ -23,12 +28,19 @@ export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
},
},
async (req, res) => {
const client = new JellyfinApiClient({
url: req.query.uri,
accessToken: req.query.apiKey,
userId: req.query.userId ?? null,
name: 'debug',
username: null,
const client = container.get<
MediaSourceApiClientFactory<JellyfinApiClient>
>(KEYS.JellyfinApiClientFactory)({
mediaSource: {
uri: req.query.uri,
accessToken: req.query.apiKey,
userId: req.query.userId ?? null,
name: tag('debug'),
uuid: tag(v4()),
username: null,
libraries: [],
type: 'jellyfin',
},
});
await res.send(await client.getUserLibraries());
@@ -54,12 +66,19 @@ export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
},
},
async (req, res) => {
const client = new JellyfinApiClient({
url: req.query.uri,
accessToken: req.query.apiKey,
name: 'debug',
userId: null,
username: null,
const client = container.get<
MediaSourceApiClientFactory<JellyfinApiClient>
>(KEYS.JellyfinApiClientFactory)({
mediaSource: {
uri: req.query.uri,
accessToken: req.query.apiKey,
name: tag('debug'),
uuid: tag(v4()),
userId: null,
username: null,
libraries: [],
type: 'jellyfin',
},
});
let pageParams: Nilable<{ offset: number; limit: number }> = null;
@@ -68,7 +87,7 @@ export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
}
await res.send(
await client.getItems(req.query.parentId, [], [], pageParams),
await client.getRawItems(req.query.parentId, [], [], pageParams),
);
},
);
@@ -89,4 +108,54 @@ export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
return res.status(match ? 200 : 404).send(match);
},
);
fastify.get(
'/jellyfin/:libraryId/enumerate',
{
schema: {
params: z.object({
libraryId: z.string(),
}),
},
},
async (req, res) => {
const library = await req.serverCtx.mediaSourceDB.getLibrary(
req.params.libraryId,
);
if (!library) {
return res.status(404).send();
}
if (library.mediaSource.type !== MediaSourceType.Jellyfin) {
return res.status(400).send();
}
const jfClient =
await req.serverCtx.mediaSourceApiFactory.getJellyfinApiClientForMediaSource(
{ ...library.mediaSource, libraries: [library] },
);
switch (library.mediaType) {
case 'movies':
for await (const movie of jfClient.getMovieLibraryContents(
library.externalKey,
)) {
console.log(movie);
}
break;
case 'shows': {
for await (const series of jfClient.getTvShowLibraryContents(
library.externalKey,
)) {
console.log(series);
}
break;
}
default:
break;
}
return res.send();
},
);
};

View File

@@ -2,6 +2,7 @@ import { container } from '@/container.js';
import { ProgramSourceType } from '@/db/custom_types/ProgramSourceType.js';
import { PlexStreamDetails } from '@/stream/plex/PlexStreamDetails.js';
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
import { tag } from '@tunarr/types';
import { z } from 'zod/v4';
export const DebugPlexApiRouter: RouterPluginAsyncCallback = async (
@@ -22,14 +23,14 @@ export const DebugPlexApiRouter: RouterPluginAsyncCallback = async (
async (req, res) => {
const mediaSource = await req.serverCtx.mediaSourceDB.findByType(
'plex',
req.query.mediaSource,
tag(req.query.mediaSource),
);
if (!mediaSource) {
return res.status(400).send('No media source');
}
const program = await req.serverCtx.programDB.lookupByExternalId({
externalSourceId: mediaSource.name,
externalSourceId: mediaSource.uuid,
externalKey: req.query.key,
sourceType: ProgramSourceType.PLEX,
});
@@ -38,16 +39,23 @@ export const DebugPlexApiRouter: RouterPluginAsyncCallback = async (
return res.status(400).send('No program');
}
const contentProgram =
req.serverCtx.programConverter.programDaoToContentProgram(program);
if (!contentProgram) {
return res.status(500).send();
}
const streamDetails = await container.get(PlexStreamDetails).getStream({
server: mediaSource,
lineupItem: {
...program,
programId: program.id!,
...contentProgram,
programId: contentProgram.id,
externalKey: req.query.key,
programType: program.subtype,
programType: contentProgram.subtype,
externalSource: 'plex',
duration: program.duration,
externalFilePath: program.serverFilePath,
duration: contentProgram.duration,
externalFilePath: contentProgram.serverFilePath,
},
});

View File

@@ -325,5 +325,6 @@ function createStreamItemFromProgram(
contentDuration: program.duration,
streamDuration: program.duration,
infiniteLoop: false,
externalSourceId: program.mediaSourceId!,
};
}

View File

@@ -10,6 +10,7 @@ import { OpenDateTimeRange } from '@/types/OpenDateTimeRange.js';
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
import { enumValues } from '@/util/enumUtil.js';
import { ifDefined } from '@/util/index.js';
import { tag } from '@tunarr/types';
import { ChannelLineupQuery } from '@tunarr/types/api';
import { ChannelLineupSchema } from '@tunarr/types/schemas';
import dayjs from 'dayjs';
@@ -342,7 +343,7 @@ export const debugApi: RouterPluginAsyncCallback = async (fastify) => {
},
async (req, res) => {
const mediaSource = (await req.serverCtx.mediaSourceDB.getById(
req.query.id,
tag(req.query.id),
))!;
const knownProgramIds = await req.serverCtx

View File

@@ -1,21 +1,21 @@
import type { MediaSource } from '@/db/schema/MediaSource.js';
import { MediaSourceType } from '@/db/schema/MediaSource.js';
import { isQueryError } from '@/external/BaseApiClient.js';
import { EmbyApiClient } from '@/external/emby/EmbyApiClient.js';
import { TruthyQueryParam } from '@/types/schemas.js';
import { isDefined, nullToUndefined } from '@/util/index.js';
import { EmbyLoginRequest } from '@tunarr/types/api';
import type { EmbyCollectionType } from '@tunarr/types/emby';
import { groupByUniq, isDefined, nullToUndefined } from '@/util/index.js';
import type { Library } from '@tunarr/types';
import { tag } from '@tunarr/types';
import { EmbyLoginRequest, PagedResult } from '@tunarr/types/api';
import {
EmbyItemFields,
EmbyItemKind,
EmbyItemSortBy,
EmbyLibraryItemsResponse,
type EmbyLibraryItemsResponse as EmbyLibraryItemsResponseType,
} from '@tunarr/types/emby';
import { ItemOrFolder, Library as LibrarySchema } from '@tunarr/types/schemas';
import type { FastifyReply } from 'fastify/types/reply.js';
import { filter, isEmpty, isNil, isUndefined, uniq } from 'lodash-es';
import { isEmpty, isNil, isUndefined, uniq } from 'lodash-es';
import { z } from 'zod/v4';
import type { MediaSourceWithLibraries } from '../db/schema/derivedTypes.js';
import { ServerRequestContext } from '../ServerContext.ts';
import type {
RouterPluginCallback,
ZodFastifyRequest,
@@ -25,19 +25,6 @@ const mediaSourceParams = z.object({
mediaSourceId: z.string(),
});
const ValidEmbyCollectionTypes: EmbyCollectionType[] = [
'movies',
'tvshows',
'music',
'trailers',
'musicvideos',
'homevideos',
'playlists',
'boxsets',
'folders',
'unknown',
];
function isNonEmptyTyped<T>(f: T[]): f is [T, ...T[]] {
return !isEmpty(f);
}
@@ -83,7 +70,7 @@ export const embyApiRouter: RouterPluginCallback = (fastify, _, done) => {
schema: {
params: mediaSourceParams,
response: {
200: EmbyLibraryItemsResponse,
200: z.array(LibrarySchema),
},
},
},
@@ -94,27 +81,34 @@ export const embyApiRouter: RouterPluginCallback = (fastify, _, done) => {
mediaSource,
);
const response = await api.getUserViews();
const response = await api.getUserLibraries();
if (isQueryError(response)) {
throw new Error(response.message);
if (response.isFailure()) {
throw response.error;
}
const sanitizedResponse: EmbyLibraryItemsResponseType = {
...response.data,
Items: filter(response.data.Items, (library) => {
// Mixed collections don't have this set
if (!library.CollectionType) {
return true;
}
// const sanitizedResponse: EmbyLibraryItemsResponseType = {
// ...response.get(),
// Items: filter(response.get().Items, (library) => {
// // Mixed collections don't have this set
// if (!library.CollectionType) {
// return true;
// }
return ValidEmbyCollectionTypes.includes(
library.CollectionType as EmbyCollectionType,
);
}),
};
// return ValidEmbyCollectionTypes.includes(
// library.CollectionType as EmbyCollectionType,
// );
// }),
// };
return res.send(sanitizedResponse);
// await addTunarrLibraryIdsToResponse(
// sanitizedResponse.Items,
// mediaSource,
// );
await addTunarrLibraryIdsToResponse(response.get(), mediaSource);
return res.send(response.get());
}),
);
@@ -164,7 +158,7 @@ export const embyApiRouter: RouterPluginCallback = (fastify, _, done) => {
.or(z.array(z.enum(['Artist', 'AlbumArtist'])).optional()),
}),
response: {
200: EmbyLibraryItemsResponse,
200: PagedResult(ItemOrFolder.array()),
},
},
},
@@ -201,11 +195,11 @@ export const embyApiRouter: RouterPluginCallback = (fastify, _, done) => {
: ['SortName', 'ProductionYear'],
);
if (isQueryError(response)) {
throw new Error(response.message);
if (response.isFailure()) {
throw response.error;
}
return res.send(response.data);
return res.send(response.get());
}),
);
@@ -216,10 +210,10 @@ export const embyApiRouter: RouterPluginCallback = (fastify, _, done) => {
>(
req: Req,
res: FastifyReply,
cb: (m: MediaSource) => Promise<FastifyReply>,
cb: (m: MediaSourceWithLibraries) => Promise<FastifyReply>,
) {
const mediaSource = await req.serverCtx.mediaSourceDB.getById(
req.params.mediaSourceId,
tag(req.params.mediaSourceId),
);
if (isNil(mediaSource)) {
@@ -241,3 +235,42 @@ export const embyApiRouter: RouterPluginCallback = (fastify, _, done) => {
done();
};
async function addTunarrLibraryIdsToResponse(
response: Library[],
mediaSource: MediaSourceWithLibraries,
attempts: number = 1,
) {
if (attempts > 2) {
return;
}
const librariesByExternalId = groupByUniq(
mediaSource.libraries,
(lib) => lib.externalKey,
);
let needsRefresh = false;
for (const library of response) {
const tunarrLibrary = librariesByExternalId[library.externalId];
if (!tunarrLibrary) {
needsRefresh = true;
continue;
}
library.uuid = tunarrLibrary.uuid;
}
if (needsRefresh) {
const ctx = ServerRequestContext.currentServerContext()!;
await ctx.mediaSourceLibraryRefresher.refreshMediaSource(mediaSource);
// This definitely exists...
const newMediaSource = await ctx.mediaSourceDB.getById(mediaSource.uuid);
return addTunarrLibraryIdsToResponse(
response,
newMediaSource!,
attempts + 1,
);
}
return;
}

View File

@@ -290,6 +290,7 @@ export const ffmpegSettingsRouter: RouterPluginCallback = (
params: IdPathParamSchema,
response: {
200: z.void(),
404: z.void(),
},
},
},

View File

@@ -116,6 +116,7 @@ export const fillerListsApi: RouterPluginAsyncCallback = async (fastify) => {
body: UpdateFillerListRequestSchema,
response: {
200: FillerListSchema,
404: z.void(),
},
},
},

View File

@@ -1,4 +1,3 @@
import { MediaSourceType } from '@/db/schema/MediaSource.js';
import type { FfmpegEncoder } from '@/ffmpeg/ffmpegInfo.js';
import { FfmpegInfo } from '@/ffmpeg/ffmpegInfo.js';
import { serverOptions } from '@/globals.js';
@@ -10,7 +9,7 @@ import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
import { getTunarrVersion } from '@/util/version.js';
import { VersionApiResponseSchema } from '@tunarr/types/api';
import { fileTypeFromStream } from 'file-type';
import { isEmpty, isNil } from 'lodash-es';
import { isEmpty } from 'lodash-es';
import { createReadStream, promises as fsPromises } from 'node:fs';
import path from 'node:path';
import { z } from 'zod/v4';
@@ -28,6 +27,7 @@ import { hdhrSettingsRouter } from './hdhrSettingsApi.js';
import { jellyfinApiRouter } from './jellyfinApi.js';
import { mediaSourceRouter } from './mediaSourceApi.js';
import { metadataApiRouter } from './metadataApi.js';
import { plexApiRouter } from './plexApi.ts';
import { plexSettingsRouter } from './plexSettingsApi.js';
import { programmingApi } from './programmingApi.js';
import { sessionApiRouter } from './sessionApi.js';
@@ -62,6 +62,7 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
.register(hdhrSettingsRouter)
.register(systemApiRouter)
.register(guideRouter)
.register(plexApiRouter)
.register(jellyfinApiRouter)
.register(sessionApiRouter)
.register(embyApiRouter);
@@ -142,6 +143,7 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
name: z.string(),
fileUrl: z.string(),
}),
400: z.void(),
},
},
},
@@ -225,7 +227,7 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
.header('Content-Type', 'application/xml')
.send(fileFinal);
} catch (err) {
logger.error('%O', err);
logger.error(err);
return res.status(500).send('error');
}
},
@@ -286,33 +288,4 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
return res.status(204).send();
},
);
fastify.get(
'/plex',
{
schema: {
querystring: z.object({ id: z.string(), path: z.string() }),
operationId: 'queryPlex',
},
},
async (req, res) => {
req.logRequestAtLevel = 'trace';
const server = await req.serverCtx.mediaSourceDB.findByType(
MediaSourceType.Plex,
req.query.id,
);
if (isNil(server)) {
return res
.status(404)
.send({ error: 'No server found with id: ' + req.query.id });
}
const plex =
await req.serverCtx.mediaSourceApiFactory.getPlexApiClientForMediaSource(
server,
);
return res.send(await plex.doGetPath(req.query.path));
},
);
};

View File

@@ -1,21 +1,21 @@
import type { MediaSource } from '@/db/schema/MediaSource.js';
import { MediaSourceType } from '@/db/schema/MediaSource.js';
import { isQueryError } from '@/external/BaseApiClient.js';
import { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
import { TruthyQueryParam } from '@/types/schemas.js';
import { inConstArr, isDefined, nullToUndefined } from '@/util/index.js';
import { JellyfinLoginRequest } from '@tunarr/types/api';
import type { JellyfinCollectionType } from '@tunarr/types/jellyfin';
import { mediaSourceParamsSchema, TruthyQueryParam } from '@/types/schemas.js';
import { groupByUniq, isDefined, nullToUndefined } from '@/util/index.js';
import { tag, type Library } from '@tunarr/types';
import { JellyfinLoginRequest, PagedResult } from '@tunarr/types/api';
import {
JellyfinItemFields,
JellyfinItemKind,
JellyfinItemSortBy,
JellyfinLibraryItemsResponse,
TunarrAmendedJellyfinVirtualFolder,
} from '@tunarr/types/jellyfin';
import { ItemOrFolder, Library as LibrarySchema } from '@tunarr/types/schemas';
import type { FastifyReply } from 'fastify/types/reply.js';
import { isEmpty, isNil, uniq } from 'lodash-es';
import { z } from 'zod/v4';
import type { MediaSourceWithLibraries } from '../db/schema/derivedTypes.js';
import { ServerRequestContext } from '../ServerContext.ts';
import type {
RouterPluginCallback,
ZodFastifyRequest,
@@ -25,19 +25,6 @@ const mediaSourceParams = z.object({
mediaSourceId: z.string(),
});
const ValidJellyfinCollectionTypes = [
'movies',
'tvshows',
'music',
'trailers',
'musicvideos',
'homevideos',
'playlists',
'boxsets',
'folders',
'unknown',
] satisfies JellyfinCollectionType[];
function isNonEmptyTyped<T>(f: T[]): f is [T, ...T[]] {
return !isEmpty(f);
}
@@ -84,8 +71,7 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
schema: {
params: mediaSourceParams,
response: {
// HACK
200: z.array(TunarrAmendedJellyfinVirtualFolder),
200: z.array(LibrarySchema),
},
operationId: 'getJellyfinLibraries',
},
@@ -99,28 +85,34 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
const response = await api.getUserViews();
if (isQueryError(response)) {
throw new Error(response.message);
if (response.isFailure()) {
throw response.error;
}
return res.send(
response.data
.filter((library) => {
// Mixed collections don't have this set
if (!library.CollectionType) {
return true;
}
// const amendedResponse = response
// .get()
// .filter((library) => {
// // Mixed collections don't have this set
// if (!library.CollectionType) {
// return true;
// }
return inConstArr(
ValidJellyfinCollectionTypes,
library.CollectionType ?? '',
);
})
.map((lib) => ({
...lib,
jellyfinType: 'VirtualFolder',
})),
);
// return inConstArr(
// ValidJellyfinCollectionTypes,
// library.CollectionType ?? '',
// );
// })
// .map(
// (lib) =>
// ({
// ...lib,
// jellyfinType: 'VirtualFolder',
// }) satisfies TunarrAmendedJellyfinVirtualFolder,
// );
await addTunarrLibraryIdsToResponse(response.get(), mediaSource);
return res.send(response.get());
}),
);
@@ -128,7 +120,7 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
'/jellyfin/:mediaSourceId/libraries/:libraryId/genres',
{
schema: {
params: mediaSourceParams.extend({
params: mediaSourceParamsSchema.extend({
libraryId: z.string(),
}),
querystring: z.object({
@@ -152,11 +144,11 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
req.query.includeItemTypes,
);
if (isQueryError(response)) {
throw new Error(response.message);
if (response.isFailure()) {
throw response.error;
}
return res.send(response.data);
return res.send(response.get());
}),
);
@@ -164,7 +156,8 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
'/jellyfin/:mediaSourceId/libraries/:libraryId/items',
{
schema: {
params: mediaSourceParams.extend({
operationId: 'getJellyfinLibraryItems',
params: mediaSourceParamsSchema.extend({
libraryId: z.string(),
}),
querystring: z.object({
@@ -201,9 +194,8 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
parentId: z.string().optional(),
}),
response: {
200: JellyfinLibraryItemsResponse,
200: PagedResult(ItemOrFolder.array()),
},
operationId: 'getJellyfinLibraryItems',
},
},
(req, res) =>
@@ -239,25 +231,25 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
: ['SortName', 'ProductionYear'],
);
if (isQueryError(response)) {
throw new Error(response.message);
if (response.isFailure()) {
throw response.error;
}
return res.send(response.data);
return res.send(response.get());
}),
);
async function withJellyfinMediaSource<
Req extends ZodFastifyRequest<{
params: typeof mediaSourceParams;
params: typeof mediaSourceParamsSchema;
}>,
>(
req: Req,
res: FastifyReply,
cb: (m: MediaSource) => Promise<FastifyReply>,
cb: (m: MediaSourceWithLibraries) => Promise<FastifyReply>,
) {
const mediaSource = await req.serverCtx.mediaSourceDB.getById(
req.params.mediaSourceId,
tag(req.params.mediaSourceId),
);
if (isNil(mediaSource)) {
@@ -279,3 +271,42 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
done();
};
async function addTunarrLibraryIdsToResponse(
response: Library[],
mediaSource: MediaSourceWithLibraries,
attempts: number = 1,
) {
if (attempts > 2) {
return;
}
const librariesByExternalId = groupByUniq(
mediaSource.libraries,
(lib) => lib.externalKey,
);
let needsRefresh = false;
for (const library of response) {
const tunarrLibrary = librariesByExternalId[library.externalId];
if (!tunarrLibrary) {
needsRefresh = true;
continue;
}
library.uuid = tunarrLibrary.uuid;
}
if (needsRefresh) {
const ctx = ServerRequestContext.currentServerContext()!;
await ctx.mediaSourceLibraryRefresher.refreshMediaSource(mediaSource);
// This definitely exists...
const newMediaSource = await ctx.mediaSourceDB.getById(mediaSource.uuid);
return addTunarrLibraryIdsToResponse(
response,
newMediaSource!,
attempts + 1,
);
}
return;
}

View File

@@ -1,30 +1,45 @@
import { MediaSourceType } from '@/db/schema/MediaSource.js';
import { GlobalScheduler } from '@/services/Scheduler.js';
import { UpdateXmlTvTask } from '@/tasks/UpdateXmlTvTask.js';
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
import { nullToUndefined, wait } from '@/util/index.js';
import { nullToUndefined } from '@/util/index.js';
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
import { numberToBoolean } from '@/util/sqliteUtil.js';
import { seq } from '@tunarr/shared/util';
import type { MediaSourceSettings } from '@tunarr/types';
import {
tag,
type MediaSourceLibrary,
type MediaSourceSettings,
} from '@tunarr/types';
import type {
MediaSourceStatus,
MediaSourceUnhealthyStatus,
ScanProgress,
} from '@tunarr/types/api';
import {
BaseErrorSchema,
BasicIdParamSchema,
InsertMediaSourceRequestSchema,
MediaSourceStatusSchema,
ScanProgressSchema,
UpdateMediaSourceLibraryRequest,
UpdateMediaSourceRequestSchema,
} from '@tunarr/types/api';
import {
ContentProgramSchema,
ExternalSourceTypeSchema,
MediaSourceLibrarySchema,
MediaSourceSettingsSchema,
} from '@tunarr/types/schemas';
import { isError, isNil } from 'lodash-es';
import { isEmpty, isError, isNil, isNull } from 'lodash-es';
import type { MarkOptional } from 'ts-essentials';
import { match, P } from 'ts-pattern';
import { v4 } from 'uuid';
import z from 'zod/v4';
import { container } from '../container.ts';
import type { MediaSourceWithLibraries } from '../db/schema/derivedTypes.js';
import { EntityMutex } from '../services/EntityMutex.ts';
import { MediaSourceLibraryRefresher } from '../services/MediaSourceLibraryRefresher.ts';
import { MediaSourceProgressService } from '../services/scanner/MediaSourceProgressService.ts';
import { TruthyQueryParam } from '../types/schemas.ts';
export const mediaSourceRouter: RouterPluginAsyncCallback = async (
fastify,
@@ -47,27 +62,13 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
},
},
async (req, res) => {
const entityLocker = container.get<EntityMutex>(EntityMutex);
try {
const sources = await req.serverCtx.mediaSourceDB.getAll();
const dtos = seq.collect(sources, (source) => {
return match(source)
.returnType<MediaSourceSettings | null>()
.with({ type: P.union('plex', 'jellyfin', 'emby') }, (source) => ({
id: source.uuid,
index: source.index,
uri: source.uri,
type: source.type,
name: source.name,
accessToken: source.accessToken,
clientIdentifier: nullToUndefined(source.clientIdentifier),
sendChannelUpdates: numberToBoolean(source.sendChannelUpdates),
sendGuideUpdates: numberToBoolean(source.sendGuideUpdates),
userId: source.userId,
username: source.username,
}))
.otherwise(() => null);
});
const dtos = seq.collect(sources, (source) =>
convertToApiMediaSource(entityLocker, source),
);
return res.send(dtos);
} catch (err) {
@@ -77,6 +78,322 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
},
);
fastify.get(
'/media-sources/:id/libraries',
{
schema: {
tags: ['Media Source'],
params: BasicIdParamSchema,
response: {
200: z.array(MediaSourceLibrarySchema),
404: z.void(),
500: z.string(),
},
},
},
async (req, res) => {
const mediaSource = await req.serverCtx.mediaSourceDB.getById(
tag(req.params.id),
);
if (!mediaSource) {
return res.status(404).send();
}
const entityLocker = container.get<EntityMutex>(EntityMutex);
const apiMediaSource = convertToApiMediaSource(entityLocker, mediaSource);
if (isNull(apiMediaSource)) {
return res
.status(500)
.send('Invalid media source type: ' + mediaSource.type);
}
return res.send(
mediaSource.libraries.map(
(library) =>
({
...library,
id: library.uuid,
type: mediaSource.type,
enabled: numberToBoolean(library.enabled),
lastScannedAt: nullToUndefined(library.lastScannedAt),
isLocked: entityLocker.isLibraryLocked(library),
mediaSource: apiMediaSource,
}) satisfies MediaSourceLibrary,
),
);
},
);
fastify.put(
'/media-sources/:id/libraries/:libraryId',
{
schema: {
tags: ['Media Source'],
params: BasicIdParamSchema.extend({
libraryId: z.string(),
}),
body: UpdateMediaSourceLibraryRequest,
response: {
200: MediaSourceLibrarySchema,
404: z.void(),
500: z.string(),
},
},
},
async (req, res) => {
const mediaSource = await req.serverCtx.mediaSourceDB.getById(
tag(req.params.id),
);
if (!mediaSource) {
return res.status(404).send();
}
const entityLocker = container.get(EntityMutex);
const apiMediaSource = convertToApiMediaSource(entityLocker, mediaSource);
if (isNull(apiMediaSource)) {
return res
.status(500)
.send('Invalid media source type: ' + mediaSource.type);
}
const updatedLibrary =
await req.serverCtx.mediaSourceDB.setLibraryEnabled(
tag(req.params.id),
req.params.libraryId,
req.body.enabled,
);
if (req.body.enabled) {
const result = await req.serverCtx.mediaSourceScanCoordinator.add({
libraryId: updatedLibrary.uuid,
forceScan: false,
});
if (!result) {
logger.error(
'Unable to schedule library ID %s for scanning',
updatedLibrary.uuid,
);
}
}
return res.send({
...updatedLibrary,
id: updatedLibrary.uuid,
type: mediaSource.type,
enabled: numberToBoolean(updatedLibrary.enabled),
lastScannedAt: nullToUndefined(updatedLibrary.lastScannedAt),
isLocked: entityLocker.isLibraryLocked(updatedLibrary),
mediaSource: apiMediaSource,
});
},
);
fastify.get(
'/media-libraries/:libraryId',
{
schema: {
tags: ['Media Library'],
params: z.object({
libraryId: z.string(),
}),
response: {
200: MediaSourceLibrarySchema.extend({
mediaSource: MediaSourceSettingsSchema,
}),
404: z.void(),
},
},
},
async (req, res) => {
const library = await req.serverCtx.mediaSourceDB.getLibrary(
req.params.libraryId,
);
if (!library) {
return res.status(404).send();
}
const entityLocker = container.get<EntityMutex>(EntityMutex);
return res.send({
...library,
id: library.uuid,
type: library.mediaSource.type,
enabled: numberToBoolean(library.enabled),
lastScannedAt: nullToUndefined(library.lastScannedAt),
isLocked: entityLocker.isLibraryLocked(library),
mediaSource: convertToApiMediaSource(
entityLocker,
library.mediaSource,
)!,
// TODO this is dumb
} satisfies MediaSourceLibrary & {
mediaSource: MediaSourceSettings;
});
},
);
fastify.get(
'/media-libraries/:libraryId/programs',
{
schema: {
tags: ['Media Library'],
params: z.object({
libraryId: z.string(),
}),
response: {
200: z.array(ContentProgramSchema),
404: z.void(),
},
},
},
async (req, res) => {
const library = await req.serverCtx.mediaSourceDB.getLibrary(
req.params.libraryId,
);
if (!library) {
return res.status(404).send();
}
const programs =
await req.serverCtx.programDB.getMediaSourceLibraryPrograms(
req.params.libraryId,
);
return res.send(
seq.collect(programs, (program) =>
req.serverCtx.programConverter.programDaoToContentProgram(
program,
program.externalIds ?? [],
),
),
);
},
);
fastify.get(
'/media-libraries/:libraryId/status',
{
schema: {
params: z.object({
libraryId: z.string(),
}),
response: {
200: ScanProgressSchema,
},
},
},
async (req, res) => {
const progressService = container.get<MediaSourceProgressService>(
MediaSourceProgressService,
);
const progress = progressService.getScanProgress(req.params.libraryId);
const response = match(progress)
.returnType<ScanProgress>()
.with({ state: 'in_progress' }, (ip) => ({
...ip,
startedAt: +ip.startedAt,
}))
.with(P._, (p) => p)
.exhaustive();
return res.send(response);
},
);
fastify.post(
'/media-sources/:id/libraries/refresh',
{
schema: {
tags: ['Media Source'],
params: BasicIdParamSchema,
response: {
200: z.void(),
404: z.void(),
501: z.void(),
},
},
},
async (req, res) => {
const mediaSource = await req.serverCtx.mediaSourceDB.getById(
tag(req.params.id),
);
if (!mediaSource) {
return res.status(404).send();
}
const refresher = container.get<MediaSourceLibraryRefresher>(
MediaSourceLibraryRefresher,
);
await refresher.refreshAll();
return res.status(200).send();
},
);
fastify.post(
'/media-sources/:id/libraries/:libraryId/scan',
{
schema: {
tags: ['Media Source'],
params: BasicIdParamSchema.extend({
libraryId: z.string(),
}),
querystring: z.object({
forceScan: TruthyQueryParam.optional(),
}),
response: {
202: z.void(),
404: z.void(),
501: z.void(),
},
},
},
async (req, res) => {
const mediaSource = await req.serverCtx.mediaSourceDB.getById(
tag(req.params.id),
);
if (!mediaSource) {
return res.status(404).send();
}
const all = req.params.libraryId === 'all';
const libraries = all
? mediaSource.libraries.filter((lib) => lib.enabled)
: mediaSource.libraries.filter(
(lib) => lib.uuid === req.params.libraryId && lib.enabled,
);
if (!libraries || isEmpty(libraries)) {
return res.status(501);
}
for (const library of libraries) {
const result = await req.serverCtx.mediaSourceScanCoordinator.add({
libraryId: library.uuid,
forceScan: !!req.query.forceScan,
});
if (!result) {
logger.error(
'Unable to schedule library ID %s for scanning',
library.uuid,
);
}
}
return res.status(202).send();
},
);
fastify.get(
'/media-sources/:id/status',
{
@@ -94,7 +411,9 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
},
async (req, res) => {
try {
const server = await req.serverCtx.mediaSourceDB.getById(req.params.id);
const server = await req.serverCtx.mediaSourceDB.getById(
tag(req.params.id),
);
if (isNil(server)) {
return res.status(404).send();
@@ -166,11 +485,15 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
case 'plex': {
const plex =
await req.serverCtx.mediaSourceApiFactory.getPlexApiClient({
...req.body,
url: req.body.uri,
userId: null,
username: null,
name: req.body.name ?? 'unknown',
mediaSource: {
...req.body,
uri: req.body.uri,
userId: null,
username: null,
name: tag(req.body.name ?? 'unknown'),
uuid: tag(v4()),
libraries: [],
},
});
healthyPromise = plex.checkServerStatus();
@@ -179,11 +502,15 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
case 'jellyfin': {
const jellyfin =
await req.serverCtx.mediaSourceApiFactory.getJellyfinApiClient({
...req.body,
url: req.body.uri,
userId: null,
username: null,
name: req.body.name ?? 'unknown',
mediaSource: {
...req.body,
uri: req.body.uri,
userId: null,
username: null,
name: tag(req.body.name ?? 'unknown'),
uuid: tag(v4()),
libraries: [],
},
});
healthyPromise = jellyfin.ping();
@@ -192,11 +519,15 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
case 'emby': {
const emby =
await req.serverCtx.mediaSourceApiFactory.getEmbyApiClient({
...req.body,
url: req.body.uri,
userId: null,
username: null,
name: req.body.name ?? 'unknown',
mediaSource: {
...req.body,
uri: req.body.uri,
userId: null,
username: null,
name: tag(req.body.name ?? 'unknown'),
uuid: tag(v4()),
libraries: [],
},
});
healthyPromise = emby.ping();
@@ -233,7 +564,9 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
async (req, res) => {
try {
const { deletedServer } =
await req.serverCtx.mediaSourceDB.deleteMediaSource(req.params.id);
await req.serverCtx.mediaSourceDB.deleteMediaSource(
tag(req.params.id),
);
// Are these useful? What do they even do?
req.serverCtx.eventService.push({
@@ -259,7 +592,7 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
return res.send();
} catch (err) {
logger.error('Error %O', err);
logger.error(err);
req.serverCtx.eventService.push({
type: 'settings-update',
message: 'Error deleting media-source.',
@@ -343,6 +676,7 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
}),
// TODO: Change this
400: z.string(),
500: z.string(),
},
},
},
@@ -381,54 +715,38 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
},
);
fastify.get(
'/plex/status',
{
schema: {
tags: ['Media Source'],
querystring: z.object({
serverName: z.string(),
}),
response: {
200: MediaSourceStatusSchema,
404: BaseErrorSchema,
500: BaseErrorSchema,
},
},
},
async (req, res) => {
try {
const server = await req.serverCtx.mediaSourceDB.findByType(
MediaSourceType.Plex,
req.query.serverName,
);
if (isNil(server)) {
return res.status(404).send({ message: 'Plex server not found.' });
}
const plex =
await req.serverCtx.mediaSourceApiFactory.getPlexApiClientForMediaSource(
server,
);
const s: MediaSourceStatus = await Promise.race([
plex.checkServerStatus(),
wait(15000).then(
() =>
({
healthy: false,
status: 'timeout',
}) satisfies MediaSourceUnhealthyStatus,
),
]);
return res.send(s);
} catch (err) {
return res.status(500).send({
message: isError(err) ? err.message : 'Unknown error occurred',
});
}
},
);
// TODO put this in its own class.
function convertToApiMediaSource(
entityLocker: EntityMutex,
source: MarkOptional<MediaSourceWithLibraries, 'libraries'>,
): MediaSourceSettings | null {
return match(source)
.returnType<MediaSourceSettings | null>()
.with(
{ type: P.union('plex', 'jellyfin', 'emby') },
(source) =>
({
id: source.uuid,
index: source.index,
uri: source.uri,
type: source.type,
name: source.name,
accessToken: source.accessToken,
clientIdentifier: nullToUndefined(source.clientIdentifier),
sendChannelUpdates: numberToBoolean(source.sendChannelUpdates),
sendGuideUpdates: numberToBoolean(source.sendGuideUpdates),
libraries: (source.libraries ?? []).map((library) => ({
...library,
id: library.uuid,
type: source.type,
enabled: numberToBoolean(library.enabled),
lastScannedAt: nullToUndefined(library.lastScannedAt),
isLocked: entityLocker.isLibraryLocked(library),
})),
userId: source.userId,
username: source.username,
}) satisfies MediaSourceSettings,
)
.otherwise(() => null);
}
};

View File

@@ -1,6 +1,7 @@
import { TruthyQueryParam } from '@/types/schemas.js';
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
import { isNonEmptyString } from '@/util/index.js';
import { tag } from '@tunarr/types';
import axios, { AxiosHeaders } from 'axios';
import dayjs from 'dayjs';
import type { FastifyReply } from 'fastify';
@@ -22,6 +23,7 @@ import {
ProgramSourceType,
programSourceTypeFromString,
} from '../db/custom_types/ProgramSourceType.ts';
import type { MediaSourceId } from '../db/schema/base.ts';
import { getServerContext } from '../ServerContext.ts';
const externalIdSchema = z
@@ -46,7 +48,7 @@ const externalIdSchema = z
const [sourceType, sourceId, itemId] = val.split('|', 3);
return {
externalSourceType: programSourceTypeFromString(sourceType)!,
externalSourceId: sourceId,
externalSourceId: tag<MediaSourceId>(sourceId),
externalItemId: itemId,
};
});
@@ -58,7 +60,9 @@ const thumbOptsSchema = z.object({
const ExternalMetadataQuerySchema = z.object({
id: externalIdSchema,
asset: z.enum(['thumb', 'external-link', 'image']),
asset: z.enum(['image', 'external-link', 'thumb']),
imageType: z.enum(['poster', 'background']).default('poster'),
mode: z.enum(['json', 'redirect', 'proxy']),
cache: TruthyQueryParam.optional().default(true),
thumbOptions: z
@@ -66,7 +70,6 @@ const ExternalMetadataQuerySchema = z.object({
.transform((s) => JSON.parse(s) as unknown)
.pipe(thumbOptsSchema)
.optional(),
imageType: z.enum(['poster', 'background']).default('poster'),
});
type ExternalMetadataQuery = z.infer<typeof ExternalMetadataQuerySchema>;
@@ -192,7 +195,7 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
res: FastifyReply,
) {
const plexApi =
await getServerContext().mediaSourceApiFactory.getPlexApiClientByName(
await getServerContext().mediaSourceApiFactory.getPlexApiClientById(
query.id.externalSourceId,
);
@@ -209,7 +212,7 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
imageType: query.imageType,
});
} else if (query.asset === 'external-link') {
const server = await getServerContext().mediaSourceDB.getByIdOrName(
const server = await getServerContext().mediaSourceDB.getById(
query.id.externalSourceId,
);
if (!server || isNil(server.clientIdentifier)) {
@@ -228,7 +231,7 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
async function handleJellyfinItem(query: ExternalMetadataQuery) {
const jellyfinClient =
await getServerContext().mediaSourceApiFactory.getJellyfinApiClientByName(
await getServerContext().mediaSourceApiFactory.getJellyfinApiClientById(
query.id.externalSourceId,
);
@@ -237,7 +240,10 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
}
if (query.asset === 'thumb' || query.asset === 'image') {
return jellyfinClient.getThumbUrl(query.id.externalItemId);
return jellyfinClient.getThumbUrl(
query.id.externalItemId,
query.imageType === 'poster' ? 'Primary' : 'Thumb',
);
} else if (query.asset === 'external-link') {
return jellyfinClient.getExternalUrl(query.id.externalItemId);
}
@@ -247,7 +253,7 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
async function handleEmbyItem(query: ExternalMetadataQuery) {
const embyClient =
await getServerContext().mediaSourceApiFactory.getEmbyApiClientByName(
await getServerContext().mediaSourceApiFactory.getEmbyApiClientById(
query.id.externalSourceId,
);
@@ -256,7 +262,10 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
}
if (query.asset === 'thumb' || query.asset === 'image') {
return embyClient.getThumbUrl(query.id.externalItemId);
return embyClient.getThumbUrl(
query.id.externalItemId,
query.imageType === 'poster' ? 'Thumb' : 'Primary',
);
} else if (query.asset === 'external-link') {
return embyClient.getExternalUrl(query.id.externalItemId);
}

419
server/src/api/plexApi.ts Normal file
View File

@@ -0,0 +1,419 @@
import { tag, type Library } from '@tunarr/types';
import { PagedResult } from '@tunarr/types/api';
import {
PlexFiltersResponseSchema,
PlexTagResultSchema,
} from '@tunarr/types/plex';
import {
Collection,
ItemOrFolder,
ItemSchema,
Library as LibrarySchema,
Playlist,
} from '@tunarr/types/schemas';
import type { FastifyReply } from 'fastify/types/reply.js';
import { isNil } from 'lodash-es';
import { z } from 'zod/v4';
import type { PageParams } from '../db/interfaces/IChannelDB.ts';
import type { MediaSourceWithLibraries } from '../db/schema/derivedTypes.js';
import { MediaSourceType } from '../db/schema/MediaSource.ts';
import { ServerRequestContext } from '../ServerContext.ts';
import { mediaSourceParamsSchema } from '../types/schemas.ts';
import type {
RouterPluginAsyncCallback,
ZodFastifyRequest,
} from '../types/serverType.js';
import type { Maybe } from '../types/util.ts';
import { groupByUniq, isDefined } from '../util/index.ts';
// eslint-disable-next-line @typescript-eslint/require-await
export const plexApiRouter: RouterPluginAsyncCallback = async (fastify, _) => {
fastify.addHook('onRoute', (routeOptions) => {
if (!routeOptions.schema) {
routeOptions.schema = {};
}
});
fastify.get(
'/plex/:mediaSourceId/search',
{
schema: {
params: mediaSourceParamsSchema,
querystring: z.object({
key: z.string(),
searchParam: z.string().optional(),
offset: z.coerce.number().optional(),
limit: z.coerce.number().optional(),
parentType: z.string().optional(),
}),
response: {
200: PagedResult(ItemSchema.array()),
400: z.string(),
},
},
},
async (req, res) => {
return await withPlexMediaSource(req, res, async (ms) => {
const api =
await req.serverCtx.mediaSourceApiFactory.getPlexApiClientForMediaSource(
ms,
);
// const library = ms.libraries.find(
// (lib) => lib.externalKey === req.query.key,
// );
// if (
// !library &&
// req.query.parentType !== 'playlist' &&
// req.query.parentType !== 'collection'
// ) {
// return res
// .status(400)
// .send(
// `No Tunarr library found for media source ID ${ms.uuid} with external key ${req.query.key}`,
// );
// }
const result = await api.search(
req.query.key,
isDefined(req.query.offset) && isDefined(req.query.limit)
? { offset: req.query.offset, limit: req.query.limit }
: undefined,
req.query.searchParam,
req.query.parentType,
);
if (result.isFailure()) {
throw result.error;
}
return res.send(result.get());
});
},
);
fastify.get(
'/plex/:mediaSourceId/libraries',
{
schema: {
params: mediaSourceParamsSchema,
response: {
200: z.array(LibrarySchema),
},
},
},
async (req, res) => {
return await withPlexMediaSource(req, res, async (mediaSource) => {
const api =
await req.serverCtx.mediaSourceApiFactory.getPlexApiClientForMediaSource(
mediaSource,
);
const result = await api.getLibraries();
if (result.isFailure()) {
throw result.error;
}
await result.forEachAsync((response) =>
addTunarrLibraryIdsToResponse(response, mediaSource),
);
return res.send(result.get());
});
},
);
fastify.get(
'/plex/:mediaSourceId/libraries/:libraryId/collections',
{
schema: {
params: mediaSourceParamsSchema.extend({
libraryId: z.string(),
}),
querystring: z.object({
offset: z.coerce.number().nonnegative().optional(),
limit: z.coerce.number().nonnegative().optional(),
}),
response: {
200: PagedResult(Collection.array()),
},
},
},
async (req, res) => {
return await withPlexMediaSource(req, res, async (mediaSource) => {
const api =
await req.serverCtx.mediaSourceApiFactory.getPlexApiClientForMediaSource(
mediaSource,
);
let pageParams: Maybe<PageParams>;
if (!isNil(req.query.offset) && !isNil(req.query.limit)) {
pageParams = {
limit: req.query.limit,
offset: req.query.offset,
};
}
const result = await api.getLibraryCollections(
req.params.libraryId,
pageParams,
);
if (result.isFailure()) {
throw result.error;
}
return res.send(result.get());
});
},
);
fastify.get(
'/plex/:mediaSourceId/libraries/:libraryId/playlists',
{
schema: {
params: mediaSourceParamsSchema.extend({
libraryId: z.string(),
}),
querystring: z.object({
offset: z.coerce.number().nonnegative().optional(),
limit: z.coerce.number().nonnegative().optional(),
}),
response: {
200: PagedResult(Playlist.array()),
},
},
},
async (req, res) => {
return await withPlexMediaSource(req, res, async (mediaSource) => {
const api =
await req.serverCtx.mediaSourceApiFactory.getPlexApiClientForMediaSource(
mediaSource,
);
let pageParams: Maybe<PageParams>;
if (!isNil(req.query.offset) && !isNil(req.query.limit)) {
pageParams = {
limit: req.query.limit,
offset: req.query.offset,
};
}
const result = await api.getPlaylists(req.params.libraryId, pageParams);
if (result.isFailure()) {
throw result.error;
}
return res.send(result.get());
});
},
);
fastify.get(
'/plex/:mediaSourceId/playlists',
{
schema: {
params: mediaSourceParamsSchema,
querystring: z.object({
offset: z.coerce.number().nonnegative().optional(),
limit: z.coerce.number().nonnegative().optional(),
}),
response: {
200: PagedResult(Playlist.array()),
},
},
},
async (req, res) => {
return await withPlexMediaSource(req, res, async (mediaSource) => {
const api =
await req.serverCtx.mediaSourceApiFactory.getPlexApiClientForMediaSource(
mediaSource,
);
let pageParams: Maybe<PageParams>;
if (!isNil(req.query.offset) && !isNil(req.query.limit)) {
pageParams = {
limit: req.query.limit,
offset: req.query.offset,
};
}
const result = await api.getPlaylists(undefined, pageParams);
if (result.isFailure()) {
throw result.error;
}
return res.send(result.get());
});
},
);
fastify.get(
'/plex/:mediaSourceId/filters',
{
schema: {
params: mediaSourceParamsSchema,
querystring: z.object({
key: z.string(),
}),
response: {
200: PlexFiltersResponseSchema,
},
},
},
async (req, res) => {
return await withPlexMediaSource(req, res, async (mediaSource) => {
const api =
await req.serverCtx.mediaSourceApiFactory.getPlexApiClientForMediaSource(
mediaSource,
);
const result = await api.getFilters(req.query.key);
if (result.isFailure()) {
throw result.error;
}
return res.send(result.get());
});
},
);
fastify.get(
'/plex/:mediaSourceId/tags',
{
schema: {
params: mediaSourceParamsSchema,
querystring: z.object({
libraryKey: z.string(),
itemKey: z.string(),
}),
response: {
200: PlexTagResultSchema,
},
},
},
async (req, res) => {
return await withPlexMediaSource(req, res, async (mediaSource) => {
const api =
await req.serverCtx.mediaSourceApiFactory.getPlexApiClientForMediaSource(
mediaSource,
);
const result = await api.getTags(
req.query.libraryKey,
req.query.itemKey,
);
if (result.isFailure()) {
throw result.error;
}
return res.send(result.get());
});
},
);
fastify.get(
'/plex/:mediaSourceId/items/:itemId/children',
{
schema: {
params: mediaSourceParamsSchema.extend({
itemId: z.string(),
}),
querystring: z.object({
parentType: z.enum(['item', 'collection', 'playlist']),
}),
response: {
200: z.array(ItemOrFolder),
},
},
},
async (req, res) => {
return await withPlexMediaSource(req, res, async (mediaSource) => {
const api =
await req.serverCtx.mediaSourceApiFactory.getPlexApiClientForMediaSource(
mediaSource,
);
const result = await api.getItemChildren(
req.params.itemId,
req.query.parentType,
);
if (result.isFailure()) {
throw result.error;
}
return res.send(result.get());
});
},
);
async function withPlexMediaSource<
Req extends ZodFastifyRequest<{
params: typeof mediaSourceParamsSchema;
}>,
>(
req: Req,
res: FastifyReply,
cb: (m: MediaSourceWithLibraries) => Promise<FastifyReply>,
) {
const mediaSource = await req.serverCtx.mediaSourceDB.getById(
tag(req.params.mediaSourceId),
);
if (isNil(mediaSource)) {
return res
.status(400)
.send(`No media source with ID ${req.params.mediaSourceId} found.`);
}
if (mediaSource.type !== MediaSourceType.Plex) {
return res
.status(400)
.send(
`Media source with ID = ${req.params.mediaSourceId} is not a Jellyfin server.`,
);
}
return cb(mediaSource);
}
};
async function addTunarrLibraryIdsToResponse(
response: Library[],
mediaSource: MediaSourceWithLibraries,
attempts: number = 1,
) {
if (attempts > 2) {
return;
}
const librariesByExternalId = groupByUniq(
mediaSource.libraries,
(lib) => lib.externalKey,
);
let needsRefresh = false;
for (const library of response) {
const tunarrLibrary = librariesByExternalId[library.externalId];
if (!tunarrLibrary) {
needsRefresh = true;
continue;
}
library.uuid = tunarrLibrary.uuid;
}
if (needsRefresh) {
const ctx = ServerRequestContext.currentServerContext()!;
await ctx.mediaSourceLibraryRefresher.refreshMediaSource(mediaSource);
// This definitely exists...
const newMediaSource = await ctx.mediaSourceDB.getById(mediaSource.uuid);
return addTunarrLibraryIdsToResponse(
response,
newMediaSource!,
attempts + 1,
);
}
return;
}

View File

@@ -1,19 +1,52 @@
import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
import type { MediaSource } from '@/db/schema/MediaSource.js';
import type {
MediaSource,
MediaSourceLibrary,
} from '@/db/schema/MediaSource.js';
import { ProgramType } from '@/db/schema/Program.js';
import { ProgramGroupingType } from '@/db/schema/ProgramGrouping.js';
import type { ProgramGrouping as ProgramGroupingDao } from '@/db/schema/ProgramGrouping.js';
import {
AllProgramGroupingFields,
ProgramGroupingType,
} from '@/db/schema/ProgramGrouping.js';
import { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
import { PlexApiClient } from '@/external/plex/PlexApiClient.js';
import { PagingParams, TruthyQueryParam } from '@/types/schemas.js';
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
import { ifDefined, isNonEmptyString } from '@/util/index.js';
import {
groupByUniq,
groupByUniqAndMap,
ifDefined,
isNonEmptyString,
} from '@/util/index.js';
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
import { BasicIdParamSchema, ProgramChildrenResult } from '@tunarr/types/api';
import { seq } from '@tunarr/shared/util';
import {
tag,
type Episode,
type Movie,
type MusicAlbum,
type MusicArtist,
type MusicTrack,
type ProgramGrouping,
type Season,
type Show,
type TerminalProgram,
} from '@tunarr/types';
import {
BasicIdParamSchema,
ProgramChildrenResult,
ProgramSearchRequest,
ProgramSearchResponse,
SearchFilterQuerySchema,
} from '@tunarr/types/api';
import { ContentProgramSchema } from '@tunarr/types/schemas';
import axios, { AxiosHeaders, isAxiosError } from 'axios';
import dayjs from 'dayjs';
import type { HttpHeader } from 'fastify/types/utils.js';
import { jsonArrayFrom } from 'kysely/helpers/sqlite';
import {
compact,
every,
find,
first,
@@ -25,19 +58,33 @@ import {
values,
} from 'lodash-es';
import type stream from 'node:stream';
import { match } from 'ts-pattern';
import z from 'zod/v4';
import { container } from '../container.ts';
import {
ProgramSourceType,
programSourceTypeFromString,
} from '../db/custom_types/ProgramSourceType.ts';
import type { ProgramGroupingChildCounts } from '../db/interfaces/IProgramDB.ts';
import {
AllProgramFields,
AllProgramGroupingFields,
selectProgramsBuilder,
} from '../db/programQueryHelpers.ts';
import type { MediaSourceId } from '../db/schema/base.ts';
import type {
MediaSourceWithLibraries,
ProgramWithRelations,
} from '../db/schema/derivedTypes.js';
import type {
ProgramGroupingSearchDocument,
ProgramSearchDocument,
TerminalProgramSearchDocument,
} from '../services/MeilisearchService.ts';
import { decodeCaseSensitiveId } from '../services/MeilisearchService.ts';
import { FfprobeStreamDetails } from '../stream/FfprobeStreamDetails.ts';
import { ExternalStreamDetailsFetcherFactory } from '../stream/StreamDetailsFetcher.ts';
import type { Path } from '../types/path.ts';
import type { Maybe } from '../types/util.ts';
const LookupExternalProgrammingSchema = z.object({
externalId: z
@@ -62,6 +109,243 @@ const BatchLookupExternalProgrammingSchema = z.object({
}),
});
function isProgramGroupingDocument(
doc: ProgramSearchDocument,
): doc is ProgramGroupingSearchDocument {
switch (doc.type) {
case 'show':
case 'season':
case 'artist':
case 'album':
return true;
default:
return false;
}
}
function convertProgramSearchResult(
doc: TerminalProgramSearchDocument,
program: ProgramWithRelations,
mediaSource: MediaSourceWithLibraries,
mediaLibrary: MediaSourceLibrary,
): TerminalProgram {
if (!program.canonicalId) {
throw new Error('');
}
const externalId = doc.externalIds.find(
(eid) => eid.source === mediaSource.type,
)?.id;
if (!externalId) {
throw new Error('');
}
const base = {
mediaSourceId: mediaSource.uuid,
libraryId: mediaLibrary.uuid,
externalLibraryId: mediaLibrary.externalKey,
releaseDate: doc.originalReleaseDate,
releaseDateString: doc.originalReleaseDate
? dayjs(doc.originalReleaseDate).format('YYYY-MM-DD')
: null,
externalId,
sourceType: mediaSource.type,
};
const identifiers = doc.externalIds.map((eid) => ({
id: eid.id,
sourceId: isNonEmptyString(eid.sourceId)
? decodeCaseSensitiveId(eid.sourceId)
: undefined,
type: eid.source,
}));
const uuid = doc.id;
const year =
doc.originalReleaseYear ??
(doc.originalReleaseDate && doc.originalReleaseDate > 0
? dayjs(doc.originalReleaseDate).year()
: null);
const releaseDate =
doc.originalReleaseDate && doc.originalReleaseDate > 0
? doc.originalReleaseDate
: null;
const result = match(doc)
.returnType<TerminalProgram | null>()
.with(
{ type: 'episode' },
(ep) =>
({
...ep,
...base,
uuid,
originalTitle: null,
year,
releaseDate,
identifiers,
episodeNumber: ep.index ?? 0,
canonicalId: program.canonicalId!,
// mediaItem: {
// displayAspectRatio: '',
// duration: doc.duration,
// resolution: {
// widthPx: doc.videoWidth ?? 0,
// heightPx: doc.videoHeight ?? 0,
// },
// sampleAspectRatio: '',
// },
}) satisfies Episode,
)
.with(
{ type: 'movie' },
(movie) =>
({
...movie,
...base,
identifiers,
uuid,
originalTitle: null,
year,
releaseDate,
canonicalId: program.canonicalId!,
}) satisfies Movie,
)
.with(
{ type: 'track' },
(track) =>
({
...track,
...base,
identifiers,
uuid,
originalTitle: null,
year,
releaseDate,
canonicalId: program.canonicalId!,
trackNumber: doc.index ?? 0,
}) satisfies MusicTrack,
)
.otherwise(() => null);
if (!result) {
throw new Error('');
}
return result;
}
function convertProgramGroupingSearchResult(
doc: ProgramGroupingSearchDocument,
grouping: ProgramGroupingDao,
childCounts: Maybe<ProgramGroupingChildCounts>,
mediaSource: MediaSourceWithLibraries,
mediaLibrary: MediaSourceLibrary,
) {
if (!grouping.canonicalId) {
throw new Error('');
}
const childCount = childCounts?.childCount;
const grandchildCount = childCounts?.grandchildCount;
const identifiers = doc.externalIds.map((eid) => ({
id: eid.id,
sourceId: isNonEmptyString(eid.sourceId)
? decodeCaseSensitiveId(eid.sourceId)
: undefined,
type: eid.source,
}));
const uuid = doc.id;
const studios = doc?.studio?.map(({ name }) => ({ name })) ?? [];
const externalId = doc.externalIds.find(
(eid) => eid.source === mediaSource.type,
)?.id;
if (!externalId) {
throw new Error('');
}
const base = {
mediaSourceId: mediaSource.uuid,
libraryId: mediaLibrary.uuid,
externalLibraryId: mediaLibrary.externalKey,
releaseDate: doc.originalReleaseDate,
releaseDateString: doc.originalReleaseDate
? dayjs(doc.originalReleaseDate).format('YYYY-MM-DD')
: null,
externalId,
sourceType: mediaSource.type,
};
const result = match(doc)
.returnType<ProgramGrouping>()
.with(
{ type: 'season' },
(season) =>
({
...season,
...base,
identifiers,
uuid,
canonicalId: grouping.canonicalId!,
studios,
year: doc.originalReleaseYear,
index: doc.index ?? 0,
childCount,
grandchildCount,
}) satisfies Season,
)
.with(
{ type: 'show' },
(show) =>
({
...show,
...base,
identifiers,
uuid,
canonicalId: grouping.canonicalId!,
studios,
year: doc.originalReleaseYear,
childCount,
grandchildCount,
}) satisfies Show,
)
.with(
{ type: 'album' },
(album) =>
({
...album,
...base,
identifiers,
uuid,
canonicalId: grouping.canonicalId!,
// studios,
year: doc.originalReleaseYear,
childCount,
grandchildCount,
}) satisfies MusicAlbum,
)
.with(
{ type: 'artist' },
(artist) =>
({
...artist,
...base,
identifiers,
uuid,
canonicalId: grouping.canonicalId!,
childCount,
grandchildCount,
}) satisfies MusicArtist,
)
.exhaustive();
return result;
}
// eslint-disable-next-line @typescript-eslint/require-await
export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
const logger = LoggerFactory.child({
@@ -69,6 +353,237 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
className: 'ProgrammingApi',
});
fastify.post(
'/programs/search',
{
schema: {
body: ProgramSearchRequest,
response: {
200: ProgramSearchResponse,
},
},
},
async (req, res) => {
const result = await req.serverCtx.searchService.search('programs', {
query: req.body.query.query,
filter: req.body.query.filter,
paging: {
offset: req.body.page ?? 1,
limit: req.body.limit ?? 20,
},
libraryId: req.body.libraryId,
// TODO not a great cast...
restrictSearchTo: req.body.query
.restrictSearchTo as Path<ProgramSearchDocument>[],
});
const [programIds, groupingIds] = result.hits.reduce(
(acc, curr) => {
const [programs, groupings] = acc;
if (isProgramGroupingDocument(curr)) {
groupings.push(curr.id);
} else {
programs.push(curr.id);
}
return acc;
},
[[], []] as [string[], string[]],
);
const allMediaSources = await req.serverCtx.mediaSourceDB.getAll();
const allMediaSourcesById = groupByUniq(
allMediaSources,
(ms) => ms.uuid as string,
);
const allLibrariesById = groupByUniq(
allMediaSources.flatMap((ms) => ms.libraries),
(lib) => lib.uuid,
);
const [programs, groupings, groupingCounts] = await Promise.all([
req.serverCtx.programDB
.getProgramsByIds(programIds)
.then((res) => groupByUniq(res, (p) => p.uuid)),
req.serverCtx.programDB.getProgramGroupings(groupingIds),
req.serverCtx.programDB.getProgramGroupingChildCounts(groupingIds),
]);
const results = seq.collect(result.hits, (program) => {
const mediaSourceId = decodeCaseSensitiveId(program.mediaSourceId);
const mediaSource = allMediaSourcesById[mediaSourceId];
if (!mediaSource) {
console.log('no media src');
return;
}
const libraryId = decodeCaseSensitiveId(program.libraryId);
const library = allLibrariesById[libraryId];
if (!library) {
console.log('no library');
return;
}
if (isProgramGroupingDocument(program) && groupings[program.id]) {
return convertProgramGroupingSearchResult(
program,
groupings[program.id],
groupingCounts[program.id],
mediaSource,
library,
);
} else if (
!isProgramGroupingDocument(program) &&
programs[program.id]
) {
return convertProgramSearchResult(
program,
programs[program.id],
mediaSource,
library,
);
}
console.log('here');
return;
});
return res.send({
results,
page: result.page,
totalHits: result.totalHits,
totalPages: result.totalPages,
});
},
);
fastify.get(
'/programs/:id/descendants',
{
schema: {
params: z.object({
id: z.uuid(),
}),
response: {
200: z.array(ContentProgramSchema),
404: z.void(),
},
},
},
async (req, res) => {
const grouping = await req.serverCtx.programDB.getProgramGrouping(
req.params.id,
);
if (isNil(grouping)) {
const program = await req.serverCtx.programDB.getProgramById(
req.params.id,
);
if (program) {
return res.send(
compact([
req.serverCtx.programConverter.convertProgramWithExternalIds(
program,
),
]),
);
}
return res.status(404).send();
}
const programs =
await req.serverCtx.programDB.getProgramGroupingDescendants(
req.params.id,
grouping.type,
);
const apiPrograms = seq.collect(programs, (program) =>
req.serverCtx.programConverter.convertProgramWithExternalIds(program),
);
return res.send(apiPrograms);
},
);
fastify.get(
'/programs/facets/:facetName',
{
schema: {
params: z.object({
facetName: z.string(),
}),
querystring: z.object({
facetQuery: z.string().optional(),
libraryId: z.string().uuid().optional(),
}),
response: {
200: z.object({
facetValues: z.record(z.string(), z.number()),
}),
},
},
},
async (req, res) => {
const facetResult = await req.serverCtx.searchService.facetSearch(
'programs',
{
facetQuery: req.query.facetQuery,
facetName: req.params.facetName,
libraryId: req.query.libraryId,
},
);
return res.send({
facetValues: groupByUniqAndMap(
facetResult.facetHits,
'value',
(hit) => hit.count,
),
});
},
);
fastify.post(
'/programs/facets/:facetName',
{
schema: {
params: z.object({
facetName: z.string(),
}),
querystring: z.object({
facetQuery: z.string().optional(),
libraryId: z.string().uuid().optional(),
}),
body: z.object({
filter: SearchFilterQuerySchema.optional(),
}),
response: {
200: z.object({
facetValues: z.record(z.string(), z.number()),
}),
},
},
},
async (req, res) => {
const facetResult = await req.serverCtx.searchService.facetSearch(
'programs',
{
facetQuery: req.query.facetQuery,
facetName: req.params.facetName,
libraryId: req.query.libraryId,
filter: req.body.filter,
},
);
return res.send({
facetValues: groupByUniqAndMap(
facetResult.facetHits,
'value',
(hit) => hit.count,
),
});
},
);
fastify.get(
'/programs/:id',
{
@@ -111,11 +626,15 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
if (!program) {
return res.status(404).send('Program not found');
} else if (!program.mediaSourceId) {
return res
.status(404)
.send('Program has no associated media source ID');
}
const server = await req.serverCtx.mediaSourceDB.findByType(
program.sourceType,
program.mediaSourceId ?? program.externalSourceId,
program.mediaSourceId,
);
if (!server) {
@@ -184,6 +703,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
}),
response: {
200: ProgramChildrenResult,
400: z.void(),
404: z.void(),
},
},
@@ -202,7 +722,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
grouping.type,
req.query,
);
const result = results.map((program) =>
const result = seq.collect(results, (program) =>
req.serverCtx.programConverter.programDaoToContentProgram(
program,
program.externalIds,
@@ -215,6 +735,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
type: grouping.type === 'album' ? 'track' : 'episode',
programs: result,
},
size: result.length,
});
} else if (grouping.type === 'artist') {
const { total, results } = await req.serverCtx.programDB.getChildren(
@@ -225,7 +746,11 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
const result = results.map((program) =>
req.serverCtx.programConverter.programGroupingDaoToDto(program),
);
return res.send({ total, result: { type: 'album', programs: result } });
return res.send({
total,
result: { type: 'album', programs: result },
size: result.length,
});
} else if (grouping.type === 'show') {
const { total, results } = await req.serverCtx.programDB.getChildren(
req.params.id,
@@ -238,6 +763,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
return res.send({
total,
result: { type: 'season', programs: result },
size: result.length,
});
}
@@ -283,6 +809,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
const handleResult = async (mediaSource: MediaSource, result: string) => {
if (req.query.method === 'proxy') {
try {
logger.debug('Proxying response to %s', result);
const proxyRes = await axios.request<stream.Readable>({
url: result,
responseType: 'stream',
@@ -317,14 +844,17 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
return res.redirect(result, 302).send();
};
if (!isNil(program)) {
const mediaSource = await req.serverCtx.mediaSourceDB.getByExternalId(
if (!isNil(program?.mediaSourceId)) {
const mediaSource = await req.serverCtx.mediaSourceDB.findByType(
program.sourceType,
program.externalSourceId,
program.mediaSourceId,
);
if (isNil(mediaSource)) {
return res.status(404).send();
logger.error('No media source: %O', program);
return res
.status(404)
.send(`No media source for id/name ${program.externalSourceId}`);
}
let keyToUse = program.externalKey;
@@ -418,14 +948,14 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
const mediaSource = await (isNonEmptyString(source.mediaSourceId)
? req.serverCtx.mediaSourceDB.getById(source.mediaSourceId)
: req.serverCtx.mediaSourceDB.getByExternalId(
// This was asserted above
source.sourceType as 'plex' | 'jellyfin',
source.externalSourceId,
));
: null);
if (isNil(mediaSource)) {
return res.status(404).send();
return res
.status(404)
.send(
`Could not find media source with id ${source.externalSourceId}`,
);
}
switch (mediaSource.type) {
@@ -475,6 +1005,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
200: z.object({ url: z.string() }),
302: z.void(),
404: z.void(),
405: z.void(),
},
},
},
@@ -501,7 +1032,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
const server = find(
mediaSources,
(source) =>
source.uuid === externalId.externalSourceId ||
source.uuid === externalId.mediaSourceId ||
source.name === externalId.externalSourceId,
);
@@ -552,11 +1083,12 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
200: ContentProgramSchema,
400: z.object({ message: z.string() }),
404: z.void(),
500: z.string(),
},
},
},
async (req, res) => {
const [sourceType, ,] = req.params.externalId;
const [sourceType, rawServerId, id] = req.params.externalId;
const sourceTypeParsed = programSourceTypeFromString(sourceType);
if (isUndefined(sourceTypeParsed)) {
return res
@@ -565,7 +1097,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
}
const result = await req.serverCtx.programDB.lookupByExternalIds(
new Set([req.params.externalId]),
new Set([[sourceType, tag(rawServerId), id]]),
);
const program = first(values(result));
@@ -573,7 +1105,18 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
return res.status(404).send();
}
return res.send(program);
const converted =
req.serverCtx.programConverter.programDaoToContentProgram(program);
if (!converted) {
return res
.status(500)
.send(
'Could not convert program. It might be missing a mediaSourceId',
);
}
return res.send(converted);
},
);
@@ -590,8 +1133,24 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
},
},
async (req, res) => {
const ids = req.body.externalIds
.values()
.map(
([source, sourceId, id]) =>
[source, tag<MediaSourceId>(sourceId), id] as const,
)
.toArray();
const results = await req.serverCtx.programDB.lookupByExternalIds(
new Set(ids),
);
return res.send(
await req.serverCtx.programDB.lookupByExternalIds(req.body.externalIds),
groupByUniq(
seq.collect(results, (p) =>
req.serverCtx.programConverter.programDaoToContentProgram(p),
),
(p) => p.id,
),
);
},
);

View File

@@ -111,6 +111,7 @@ export const sessionApiRouter: RouterPluginAsyncCallback = async (fastify) => {
}),
response: {
200: ChannelSessionsResponseSchema,
201: z.void(),
404: z.string(),
},
},

View File

@@ -12,7 +12,7 @@ import type { StreamConnectionDetails } from '@tunarr/types/api';
import { ChannelStreamModeSchema } from '@tunarr/types/schemas';
import dayjs from 'dayjs';
import type { FastifyReply } from 'fastify';
import { isNil, isNumber, isUndefined } from 'lodash-es';
import { isArray, isNil, isNumber, isUndefined } from 'lodash-es';
import fs from 'node:fs/promises';
import { join } from 'node:path';
import { PassThrough } from 'node:stream';
@@ -27,7 +27,14 @@ export const streamApi: RouterPluginAsyncCallback = async (fastify) => {
});
fastify.addHook('onError', (req, _, error, done) => {
logger.error(error, '%s %s', req.routeOptions.method, req.routeOptions.url);
logger.error(
error,
'%s %s',
isArray(req.routeOptions.method)
? req.routeOptions.method.join(', ')
: req.routeOptions.method,
req.routeOptions.url,
);
done();
});

View File

@@ -72,7 +72,7 @@ export const videoApiRouter: RouterPluginAsyncCallback = async (fastify) => {
});
ffmpeg.on('error', (err) => {
logger.error('FFMPEG ERROR', err);
logger.error(err, 'FFMPEG ERROR');
buffer.push(null);
void res.status(500).send('FFMPEG ERROR');
return;
@@ -114,7 +114,7 @@ export const videoApiRouter: RouterPluginAsyncCallback = async (fastify) => {
done();
},
onError(req, _, e) {
logger.error(e, 'Error on /stream: %s. %O', req.raw.url);
logger.error(e, 'Error on /stream: %s', req.raw.url);
},
},
async (req, res) => {
@@ -155,9 +155,9 @@ export const videoApiRouter: RouterPluginAsyncCallback = async (fastify) => {
if (rawStreamResult.type === 'error') {
logger.error(
'Error starting stream! Message: %s, Error: %O',
rawStreamResult.message,
rawStreamResult.error ?? null,
'Error starting stream! Message: %s',
rawStreamResult.message,
);
return res
.status(rawStreamResult.httpStatus)

View File

@@ -18,6 +18,7 @@ export type ServerArgsType = GlobalArgsType & {
port: number;
printRoutes: boolean;
trustProxy: boolean;
searchPort?: number;
};
export const RunServerCommand: CommandModule<GlobalArgsType, ServerArgsType> = {
@@ -39,6 +40,9 @@ export const RunServerCommand: CommandModule<GlobalArgsType, ServerArgsType> = {
default: () =>
getBooleanEnvVar(TUNARR_ENV_VARS.TRUST_PROXY_ENV_VAR, false),
},
searchPort: {
type: 'number',
},
},
handler: async (
opts: ArgumentsCamelCase<MarkOptional<ServerArgsType, 'port'>>,

View File

@@ -1,17 +1,23 @@
import { isMainThread, parentPort } from 'node:worker_threads';
import { isMainThread, parentPort, workerData } from 'node:worker_threads';
import type { CommandModule } from 'yargs';
import { container } from '../container.ts';
import type { ServerOptions } from '../globals.ts';
import { setServerOptions } from '../globals.ts';
import { StartupService } from '../services/StartupService.ts';
import { TunarrWorker } from '../services/TunarrWorker.ts';
import type { GenerateOpenApiCommandArgs } from './GenerateOpenApiCommand.ts';
import type { GlobalArgsType } from './types.ts';
type WorkerData = {
serverOptions: ServerOptions;
};
export const StartWorkerCommand: CommandModule<
GlobalArgsType,
GenerateOpenApiCommandArgs
> = {
command: 'start-worker',
describe: 'Starts a Tunarr worker (internal use only)',
// eslint-disable-next-line @typescript-eslint/require-await
handler: async () => {
if (isMainThread) {
console.error('This module is only meant to be run as a worker thread.');
@@ -23,6 +29,11 @@ export const StartWorkerCommand: CommandModule<
process.exit(1);
}
// TODO: parse
const { serverOptions } = workerData as WorkerData;
setServerOptions(serverOptions);
await container.get<StartupService>(StartupService).runStartupServices();
container.get<TunarrWorker>(TunarrWorker).start();
},
};

View File

@@ -28,11 +28,16 @@ import { isMainThread } from 'node:worker_threads';
import type { DeepPartial } from 'ts-essentials';
import { App } from './App.ts';
import { SettingsDBFactory } from './db/SettingsDBFactory.ts';
import { ExternalApiModule } from './external/ExternalApiModule.ts';
import { MediaSourceApiFactory } from './external/MediaSourceApiFactory.ts';
import { FfmpegPipelineBuilderModule } from './ffmpeg/builder/pipeline/PipelineBuilderFactory.ts';
import type { IWorkerPool } from './interfaces/IWorkerPool.ts';
import { EntityMutex } from './services/EntityMutex.ts';
import { FileSystemService } from './services/FileSystemService.ts';
import { MediaSourceLibraryRefresher } from './services/MediaSourceLibraryRefresher.js';
import { MeilisearchService } from './services/MeilisearchService.ts';
import { NoopWorkerPool } from './services/NoopWorkerPool.ts';
import { ServicesModule } from './services/ServicesModule.ts';
import { StartupService } from './services/StartupService.ts';
import { SystemDevicesService } from './services/SystemDevicesService.ts';
import { TunarrWorkerPool } from './services/TunarrWorkerPool.ts';
@@ -47,6 +52,7 @@ import { SeedFfmpegInfoCache } from './services/startup/SeedFfmpegInfoCache.ts';
import { SeedSystemDevicesStartupTask } from './services/startup/SeedSystemDevicesStartupTask.ts';
import { ChannelCache } from './stream/ChannelCache.ts';
import { FixerRunner } from './tasks/fixers/FixerRunner.ts';
import { ChildProcessHelper } from './util/ChildProcessHelper.ts';
import { Timer } from './util/Timer.ts';
import { getBooleanEnvVar, USE_WORKER_POOL_ENV_VAR } from './util/env.ts';
@@ -93,6 +99,7 @@ const RootModule = new ContainerModule((bind) => {
>(() => (timeout?: number) => new MutexMap(timeout));
container.bind(MediaSourceApiFactory).toSelf().inSingletonScope();
// If we need lazy init...
// container
// .bind<MediaSourceApiFactory>(KEYS.MediaSourceApiFactory)
@@ -108,6 +115,12 @@ const RootModule = new ContainerModule((bind) => {
bind(FixerRunner).toSelf().inSingletonScope();
bind(StartupService).toSelf().inSingletonScope();
container
.bind<
interfaces.Factory<MediaSourceLibraryRefresher>
>(KEYS.MediaSourceLibraryRefresher)
.toAutoFactory(MediaSourceLibraryRefresher);
bind(TVGuideService).toSelf().inSingletonScope();
bind(EventService).toSelf().inSingletonScope();
bind(HdhrService).toSelf().inSingletonScope();
@@ -139,6 +152,11 @@ const RootModule = new ContainerModule((bind) => {
bind<interfaces.AutoFactory<IWorkerPool>>(
KEYS.WorkerPoolFactory,
).toAutoFactory(KEYS.WorkerPool);
bind(EntityMutex).toSelf().inSingletonScope();
bind(MeilisearchService).toSelf().inSingletonScope();
bind(KEYS.SearchService).toService(MeilisearchService);
bind(ChildProcessHelper).toSelf().inSingletonScope();
bind(App).toSelf().inSingletonScope();
});
@@ -152,5 +170,7 @@ container.load(FixerModule);
container.load(FFmpegModule);
container.load(FfmpegPipelineBuilderModule);
container.load(DynamicChannelsModule);
container.load(ServicesModule);
container.load(ExternalApiModule);
export { container };

View File

@@ -78,6 +78,7 @@ import {
isDefined,
isNonEmptyString,
mapReduceAsyncSeq,
programExternalIdString,
run,
} from '../util/index.ts';
import { ProgramConverter } from './converters/ProgramConverter.ts';
@@ -99,8 +100,6 @@ import {
import { SchemaBackedDbAdapter } from './json/SchemaBackedJsonDBAdapter.ts';
import { calculateStartTimeOffsets } from './lineupUtil.ts';
import {
AllProgramGroupingFields,
MinimalProgramGroupingFields,
withFallbackPrograms,
withMusicArtistAlbums,
withProgramExternalIds,
@@ -119,8 +118,12 @@ import {
NewChannelProgram,
Channel as RawChannel,
} from './schema/Channel.ts';
import { programExternalIdString, ProgramType } from './schema/Program.ts';
import { ProgramGroupingType } from './schema/ProgramGrouping.ts';
import { ProgramType } from './schema/Program.ts';
import {
AllProgramGroupingFields,
MinimalProgramGroupingFields,
ProgramGroupingType,
} from './schema/ProgramGrouping.ts';
import {
ChannelSubtitlePreferences,
NewChannelSubtitlePreference,
@@ -1389,7 +1392,9 @@ export class ChannelDB implements IChannelDB {
externalIdsByProgramId[program.uuid] ?? [],
);
ret[converted.id] = converted;
if (converted) {
ret[converted.id] = converted;
}
});
return ret;

View File

@@ -1,5 +1,6 @@
import { KEYS } from '@/types/inject.js';
import { isNonEmptyString } from '@/util/index.js';
import { isNonEmptyString, programExternalIdString } from '@/util/index.js';
import { seq } from '@tunarr/shared/util';
import { CustomProgram } from '@tunarr/types';
import {
CreateCustomShowRequest,
@@ -21,7 +22,6 @@ import type {
NewCustomShow,
NewCustomShowContent,
} from './schema/CustomShow.ts';
import { programExternalIdString } from './schema/Program.ts';
import { DB } from './schema/db.ts';
@injectable()
@@ -53,15 +53,21 @@ export class CustomShowDB {
.select((eb) => withCustomShowPrograms(eb, { joins: AllProgramJoins }))
.executeTakeFirst();
return map(programs?.customShowContent, (csc) => ({
type: 'custom' as const,
persisted: true,
duration: csc.duration,
program: this.programConverter.programDaoToContentProgram(csc, []),
customShowId: id,
index: csc.index,
id: csc.uuid,
}));
return seq.collect(programs?.customShowContent, (csc) => {
const program = this.programConverter.programDaoToContentProgram(csc, []);
if (!program) {
return;
}
return {
type: 'custom' as const,
persisted: true,
duration: csc.duration,
program,
customShowId: id,
index: csc.index,
id: csc.uuid,
};
});
}
async saveShow(id: string, updateRequest: UpdateCustomShowRequest) {

View File

@@ -68,7 +68,7 @@ class Connection {
case 'query':
if (process.env['DATABASE_DEBUG_LOGGING']) {
this.logger.debug(
'Query: %O (%d ms)',
'Query: %s (%d ms)',
event.query.sql,
event.queryDurationMillis,
);
@@ -77,7 +77,7 @@ class Connection {
case 'error':
this.logger.error(
event.error,
'Query error: %O\n%O',
'Query error: %s\n%O',
event.query.sql,
event.query.parameters,
);

View File

@@ -8,6 +8,7 @@ import { ContainerModule } from 'inversify';
import type { Kysely } from 'kysely';
import { DBAccess } from './DBAccess.ts';
import { FillerDB } from './FillerListDB.ts';
import { ProgramDaoMinter } from './converters/ProgramMinter.ts';
import type { DB } from './schema/db.ts';
const DBModule = new ContainerModule((bind) => {
@@ -21,6 +22,11 @@ const DBModule = new ContainerModule((bind) => {
KEYS.Database,
);
bind(KEYS.FillerListDB).to(FillerDB).inSingletonScope();
bind(ProgramDaoMinter).toSelf();
bind<interfaces.AutoFactory<ProgramDaoMinter>>(
KEYS.ProgramDaoMinterFactory,
).toAutoFactory<ProgramDaoMinter>(ProgramDaoMinter);
});
export { DBModule as dbContainer };

View File

@@ -1,7 +1,8 @@
import type { IProgramDB } from '@/db/interfaces/IProgramDB.js';
import { ChannelCache } from '@/stream/ChannelCache.js';
import { KEYS } from '@/types/inject.js';
import { isNonEmptyString } from '@/util/index.js';
import { isNonEmptyString, programExternalIdString } from '@/util/index.js';
import { seq } from '@tunarr/shared/util';
import { ContentProgram } from '@tunarr/types';
import {
CreateFillerListRequest,
@@ -44,7 +45,6 @@ import type {
NewFillerShow,
NewFillerShowContent,
} from './schema/FillerShow.ts';
import { programExternalIdString } from './schema/Program.ts';
import { DB } from './schema/db.ts';
import type { ChannelFillerShowWithContent } from './schema/derivedTypes.ts';
@@ -356,7 +356,7 @@ export class FillerDB implements IFillerListDB {
)
.executeTakeFirst();
return map(programs?.fillerContent, (program) =>
return seq.collect(programs?.fillerContent, (program) =>
this.programConverter.programDaoToContentProgram(program, []),
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,7 @@ import {
SystemSettingsSchema,
XmlTvSettings,
defaultFfmpegSettings,
defaultGlobalMediaSourceSettings,
defaultHdhrSettings,
defaultPlexStreamSettings,
defaultXmlTvSettings as defaultXmlTvSettingsSchema,
@@ -23,6 +24,8 @@ import {
import {
BackupSettings,
FfmpegSettingsSchema,
GlobalMediaSourceSettings,
GlobalMediaSourceSettingsSchema,
HdhrSettingsSchema,
PlexStreamSettingsSchema,
XmlTvSettingsSchema,
@@ -56,6 +59,7 @@ export const SettingsSchema = z.object({
xmltv: XmlTvSettingsSchema,
plexStream: PlexStreamSettingsSchema,
ffmpeg: FfmpegSettingsSchema,
mediaSource: GlobalMediaSourceSettingsSchema,
});
export type Settings = z.infer<typeof SettingsSchema>;
@@ -96,6 +100,7 @@ export const defaultSettings = (dbBasePath: string): SettingsFile => ({
xmltv: defaultXmlTvSettings(dbBasePath),
plexStream: defaultPlexStreamSettings,
ffmpeg: defaultFfmpegSettings,
mediaSource: defaultGlobalMediaSourceSettings,
},
system: {
backup: {
@@ -175,6 +180,10 @@ export class SettingsDB extends ITypedEventEmitter implements ISettingsDB {
return this.db.data.system;
}
globalMediaSourceSettings(): DeepReadonly<GlobalMediaSourceSettings> {
return this.db.data.settings.mediaSource;
}
updateFfmpegSettings(ffmpegSettings: FfmpegSettings) {
return this.updateSettings('ffmpeg', { ...ffmpegSettings });
}

View File

@@ -4,7 +4,7 @@ import { inject, injectable } from 'inversify';
import { Kysely } from 'kysely';
import { omit } from 'lodash-es';
import { v4 } from 'uuid';
import { TranscodeConfigNotFoundError } from '../types/errors.ts';
import { TranscodeConfigNotFoundError, WrappedError } from '../types/errors.ts';
import { KEYS } from '../types/inject.ts';
import { Result } from '../types/result.ts';
import {
@@ -88,7 +88,9 @@ export class TranscodeConfigDB {
async duplicateConfig(
id: string,
): Promise<Result<TranscodeConfigDAO, TranscodeConfigNotFoundError | Error>> {
): Promise<
Result<TranscodeConfigDAO, TranscodeConfigNotFoundError | WrappedError>
> {
const baseConfig = await this.getById(id);
if (!baseConfig) {
return Result.failure(new TranscodeConfigNotFoundError(id));

View File

@@ -184,7 +184,7 @@ export class ArchiveDatabaseBackup extends DatabaseBackup<string> {
)) {
if (result.isFailure()) {
this.logger.warn(
'Unable to delete old backup file: %s',
'Unable to delete old backup file: %O',
result.error.input,
);
} else {

View File

@@ -26,6 +26,7 @@ import { Kysely } from 'kysely';
import { find, isNil, omitBy } from 'lodash-es';
import { isPromise } from 'node:util/types';
import { DeepNullable, DeepPartial, MarkRequired } from 'ts-essentials';
import { MarkNonNullable, Nullable } from '../../types/util.ts';
import {
LineupItem,
OfflineItem,
@@ -91,11 +92,12 @@ export class ProgramConverter {
}
return this.redirectLineupItemToProgram(item, redirectChannel);
} else if (item.type === 'content') {
console.log(channel.programs);
const program =
preMaterializedProgram && preMaterializedProgram.uuid === item.id
? preMaterializedProgram
: channel.programs.find((p) => p.uuid === item.id);
if (isNil(program)) {
if (isNil(program) || isNil(program.mediaSourceId)) {
return null;
}
@@ -108,10 +110,44 @@ export class ProgramConverter {
return null;
}
convertProgramWithExternalIds(
program: MarkNonNullable<
MarkRequired<ProgramWithRelations, 'externalIds'>,
'mediaSourceId'
>,
): MarkRequired<ContentProgram, 'id'>;
convertProgramWithExternalIds(
program: MarkRequired<ProgramWithRelations, 'externalIds'>,
): MarkRequired<ContentProgram, 'id'> | null;
convertProgramWithExternalIds(
program:
| MarkRequired<ProgramWithRelations, 'externalIds'>
| MarkRequired<
MarkNonNullable<ProgramWithRelations, 'mediaSourceId'>,
'externalIds'
>,
): Nullable<MarkRequired<ContentProgram, 'id'>> {
return this.programDaoToContentProgram(program);
}
programDaoToContentProgram(
program: MarkNonNullable<ProgramWithRelations, 'mediaSourceId'>,
externalIds?: MinimalProgramExternalId[],
): MarkRequired<ContentProgram, 'id'>;
programDaoToContentProgram(
program: ProgramWithRelations,
externalIds?: MinimalProgramExternalId[],
): MarkRequired<ContentProgram, 'id'> | null;
programDaoToContentProgram(
program:
| ProgramWithRelations
| MarkNonNullable<ProgramWithRelations, 'mediaSourceId'>,
externalIds: MinimalProgramExternalId[] = program.externalIds ?? [],
): MarkRequired<ContentProgram, 'id'> {
): MarkRequired<ContentProgram, 'id'> | null {
if (!program.mediaSourceId) {
return null;
}
let extraFields: Partial<ContentProgram> = {};
if (program.type === ProgramType.Episode) {
extraFields = {
@@ -216,11 +252,14 @@ export class ProgramConverter {
type: 'content',
id: program.uuid,
subtype: program.type,
externalIds: seq.collect(externalIds, (eid) => this.toExternalId(eid)),
externalIds: seq.collect(program.externalIds ?? externalIds, (eid) =>
this.toExternalId(eid),
),
externalKey: program.externalKey,
externalSourceId: program.externalSourceId,
externalSourceId: program.mediaSourceId,
externalSourceName: program.externalSourceId,
externalSourceType: program.sourceType,
canonicalId: nullToUndefined(program.canonicalId),
...omitBy(extraFields, isNil),
};
}

View File

@@ -1,73 +1,47 @@
import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
import type { NewSingleOrMultiProgramGroupingExternalId } from '@/db/schema/ProgramGroupingExternalId.js';
import { isNonEmptyString } from '@/util/index.js';
import { seq } from '@tunarr/shared/util';
import type { ContentProgram } from '@tunarr/types';
import type { JellyfinItem } from '@tunarr/types/jellyfin';
import type { PlexEpisode, PlexMusicTrack } from '@tunarr/types/plex';
import {
isValidMultiExternalIdType,
isValidSingleExternalIdType,
} from '@tunarr/types/schemas';
import dayjs from 'dayjs';
import { injectable } from 'inversify';
import { first } from 'lodash-es';
import type { MarkRequired } from 'ts-essentials';
import { v4 } from 'uuid';
import {
MediaSourceMusicAlbum,
MediaSourceMusicArtist,
MediaSourceSeason,
MediaSourceShow,
} from '../../types/Media.ts';
import type { Nullable } from '../../types/util.ts';
import { MediaSourceId, MediaSourceName } from '../schema/base.ts';
import {
NewMusicAlbum,
NewMusicArtist,
NewProgramGroupingWithExternalIds,
NewTvSeason,
NewTvShow,
} from '../schema/derivedTypes.js';
import { MediaSource, MediaSourceLibrary } from '../schema/MediaSource.ts';
import {
ProgramGroupingType,
type NewProgramGrouping,
} from '../schema/ProgramGrouping.ts';
@injectable()
export class ProgramGroupingMinter {
static mintParentProgramGroupingForPlex(
plexItem: PlexEpisode | PlexMusicTrack,
): NewProgramGrouping {
const now = +dayjs();
return {
uuid: v4(),
type:
plexItem.type === 'episode'
? ProgramGroupingType.Season
: ProgramGroupingType.Album,
createdAt: now,
updatedAt: now,
index: plexItem.parentIndex ?? null,
title: plexItem.parentTitle ?? '',
summary: null,
icon: null,
artistUuid: null,
showUuid: null,
year: null,
};
}
static mintParentProgramGroupingForJellyfin(jellyfinItem: JellyfinItem) {
if (jellyfinItem.Type !== 'Episode' && jellyfinItem.Type !== 'Audio') {
return null;
}
const now = +dayjs();
return {
uuid: v4(),
type:
jellyfinItem.Type === 'Episode'
? ProgramGroupingType.Show
: ProgramGroupingType.Album,
createdAt: now,
updatedAt: now,
index: jellyfinItem.ParentIndexNumber ?? null,
title: jellyfinItem.SeasonName ?? jellyfinItem.Album ?? '',
summary: null,
icon: null,
artistUuid: null,
showUuid: null,
year: jellyfinItem.ProductionYear,
} satisfies NewProgramGrouping;
}
constructor() {}
static mintGroupingExternalIds(
program: ContentProgram,
groupingId: string,
externalSourceId: string,
mediaSourceId: string,
externalSourceId: MediaSourceName,
mediaSourceId: MediaSourceId,
relationType: 'parent' | 'grandparent',
): NewSingleOrMultiProgramGroupingExternalId[] {
if (program.subtype === 'movie') {
@@ -124,6 +98,10 @@ export class ProgramGroupingMinter {
return null;
}
if (!item.canonicalId || !item.libraryId) {
return null;
}
const now = +dayjs();
return {
uuid: v4(),
@@ -140,6 +118,8 @@ export class ProgramGroupingMinter {
artistUuid: null,
showUuid: null,
year: item.grandparent.year,
canonicalId: item.canonicalId,
libraryId: item.libraryId,
};
}
@@ -150,6 +130,10 @@ export class ProgramGroupingMinter {
return null;
}
if (!item.canonicalId || !item.libraryId) {
return null;
}
const now = +dayjs();
return {
uuid: v4(),
@@ -166,6 +150,210 @@ export class ProgramGroupingMinter {
artistUuid: null,
showUuid: null,
year: item.parent.year,
canonicalId: item.canonicalId,
libraryId: item.libraryId,
} satisfies NewProgramGrouping;
}
mintForMediaSourceShow(
mediaSource: MediaSource,
mediaSourceLibrary: MediaSourceLibrary,
show: MediaSourceShow,
): NewTvShow {
const now = +dayjs();
const groupingId = v4();
const externalIds = seq.collect(show.identifiers, (id) => {
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
return {
type: 'single',
externalKey: id.id,
groupUuid: groupingId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
} satisfies NewSingleOrMultiProgramGroupingExternalId;
} else if (isValidMultiExternalIdType(id.type)) {
return {
type: 'multi',
externalKey: id.id,
groupUuid: groupingId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
externalSourceId: mediaSource.name, // legacy
mediaSourceId: mediaSource.uuid, // new
} satisfies NewSingleOrMultiProgramGroupingExternalId;
}
return;
});
return {
uuid: groupingId,
type: ProgramGroupingType.Show,
createdAt: now,
updatedAt: now,
// index: show.index,
title: show.title,
summary: show.summary,
year: show.year,
libraryId: mediaSourceLibrary.uuid,
canonicalId: show.canonicalId,
externalIds,
} satisfies NewProgramGroupingWithExternalIds;
}
mintForMediaSourceArtist(
mediaSource: MediaSource,
mediaSourceLibrary: MediaSourceLibrary,
artist: MediaSourceMusicArtist,
): NewMusicArtist {
const now = +dayjs();
const groupingId = v4();
const externalIds = seq.collect(artist.identifiers, (id) => {
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
return {
type: 'single',
externalKey: id.id,
groupUuid: groupingId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
} satisfies NewSingleOrMultiProgramGroupingExternalId;
} else if (isValidMultiExternalIdType(id.type)) {
return {
type: 'multi',
externalKey: id.id,
groupUuid: groupingId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
externalSourceId: mediaSource.name, // legacy
mediaSourceId: mediaSource.uuid, // new
} satisfies NewSingleOrMultiProgramGroupingExternalId;
}
return;
});
return {
uuid: groupingId,
type: ProgramGroupingType.Artist,
createdAt: now,
updatedAt: now,
// index: show.index,
title: artist.title,
summary: artist.summary,
year: null,
libraryId: mediaSourceLibrary.uuid,
canonicalId: artist.canonicalId,
externalIds,
} satisfies NewMusicArtist;
}
mintSeason(
mediaSource: MediaSource,
mediaSourceLibrary: MediaSourceLibrary,
season: MediaSourceSeason,
): NewTvSeason {
const now = +dayjs();
const groupingId = v4();
const externalIds = seq.collect(season.identifiers, (id) => {
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
return {
type: 'single',
externalKey: id.id,
groupUuid: groupingId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
} satisfies NewSingleOrMultiProgramGroupingExternalId;
} else if (isValidMultiExternalIdType(id.type)) {
return {
type: 'multi',
externalKey: id.id,
groupUuid: groupingId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
externalSourceId: mediaSource.name, // legacy
mediaSourceId: mediaSource.uuid, // new
} satisfies NewSingleOrMultiProgramGroupingExternalId;
}
return;
});
return {
uuid: groupingId,
type: ProgramGroupingType.Season,
createdAt: now,
updatedAt: now,
index: season.index,
title: season.title,
summary: season.summary,
libraryId: mediaSourceLibrary.uuid,
canonicalId: season.canonicalId,
externalIds,
} satisfies NewProgramGroupingWithExternalIds;
}
mintMusicAlbum(
mediaSource: MediaSource,
mediaSourceLibrary: MediaSourceLibrary,
album: MediaSourceMusicAlbum,
): NewMusicAlbum {
const now = +dayjs();
const groupingId = v4();
const externalIds = seq.collect(album.identifiers, (id) => {
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
return {
type: 'single',
externalKey: id.id,
groupUuid: groupingId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
} satisfies NewSingleOrMultiProgramGroupingExternalId;
} else if (isValidMultiExternalIdType(id.type)) {
return {
type: 'multi',
externalKey: id.id,
groupUuid: groupingId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
externalSourceId: mediaSource.name, // legacy
mediaSourceId: mediaSource.uuid, // new
} satisfies NewSingleOrMultiProgramGroupingExternalId;
}
return;
});
return {
uuid: groupingId,
type: ProgramGroupingType.Album,
createdAt: now,
updatedAt: now,
index: album.index,
title: album.title,
summary: album.summary,
libraryId: mediaSourceLibrary.uuid,
canonicalId: album.canonicalId,
externalIds,
} satisfies NewMusicAlbum;
}
}

View File

@@ -5,33 +5,89 @@ import type {
NewSingleOrMultiExternalId,
} from '@/db/schema/ProgramExternalId.js';
import { seq } from '@tunarr/shared/util';
import type { ContentProgram } from '@tunarr/types';
import { tag, type ContentProgram } from '@tunarr/types';
import type { JellyfinItem } from '@tunarr/types/jellyfin';
import type {
PlexMovie as ApiPlexMovie,
PlexEpisode,
PlexMovie,
PlexMedia,
PlexMusicTrack,
PlexTerminalMedia,
} from '@tunarr/types/plex';
import type { ContentProgramOriginalProgram } from '@tunarr/types/schemas';
import {
isValidMultiExternalIdType,
isValidSingleExternalIdType,
type ContentProgramOriginalProgram,
} from '@tunarr/types/schemas';
import dayjs from 'dayjs';
import { find, first, isError } from 'lodash-es';
import { inject, injectable } from 'inversify';
import { find, first, head, isError } from 'lodash-es';
import { P, match } from 'ts-pattern';
import { v4 } from 'uuid';
import type { NewProgramDao as NewRawProgram } from '../schema/Program.ts';
import { Canonicalizer } from '../../services/Canonicalizer.ts';
import {
MediaSourceEpisode,
MediaSourceMovie,
MediaSourceMusicTrack,
} from '../../types/Media.ts';
import { KEYS } from '../../types/inject.ts';
import { Maybe } from '../../types/util.ts';
import { parsePlexGuid } from '../../util/externalIds.ts';
import { isNonEmptyString } from '../../util/index.ts';
import { Logger } from '../../util/logging/LoggerFactory.ts';
import { MediaSource, MediaSourceLibrary } from '../schema/MediaSource.ts';
import type {
NewProgramDao,
NewProgramDao as NewRawProgram,
} from '../schema/Program.ts';
import { ProgramType } from '../schema/Program.ts';
import { MediaSourceId, MediaSourceName } from '../schema/base.ts';
import {
NewEpisodeProgram,
NewMovieProgram,
NewMusicTrack,
NewProgramWithExternalIds,
} from '../schema/derivedTypes.js';
// type MovieMintRequest =
// | { sourceType: 'plex'; program: PlexMovie }
// | { sourceType: 'jellyfin'; program: SpecificJellyfinType<'Movie'> }
// | { sourceType: 'emby'; program: SpecificEmbyType<'Movie'> };
// type EpisodeMintRequest =
// | { sourceType: 'plex'; program: PlexEpisode }
// | { sourceType: 'jellyfin'; program: SpecificJellyfinType<'Episode'> }
// | { sourceType: 'emby'; program: SpecificEmbyType<'Episode'> };
/**
* Generates Program DB entities for Plex media
*/
class ProgramDaoMinter {
contentProgramDtoToDao(program: ContentProgram): NewRawProgram {
@injectable()
export class ProgramDaoMinter {
constructor(
@inject(KEYS.Logger) private logger: Logger,
@inject(KEYS.PlexCanonicalizer)
private plexProgramCanonicalizer: Canonicalizer<PlexMedia>,
@inject(KEYS.JellyfinCanonicalizer)
private jellyfinCanonicalizer: Canonicalizer<JellyfinItem>,
) {}
contentProgramDtoToDao(program: ContentProgram): Maybe<NewRawProgram> {
if (!isNonEmptyString(program.canonicalId)) {
this.logger.warn('Program missing canonical ID on upsert: %O', program);
return;
} else if (!isNonEmptyString(program.libraryId)) {
this.logger.warn('Program missing library ID on upsert: %O', program);
return;
}
const now = +dayjs();
return {
uuid: v4(),
sourceType: program.externalSourceType,
// Deprecated
externalSourceId: program.externalSourceName,
mediaSourceId: program.externalSourceId,
externalSourceId: tag(program.externalSourceName),
mediaSourceId: tag(program.externalSourceId),
externalKey: program.externalKey,
originalAirDate: program.date ?? null,
duration: program.duration,
@@ -50,30 +106,40 @@ class ProgramDaoMinter {
grandparentExternalKey: program.grandparent?.externalKey,
createdAt: now,
updatedAt: now,
canonicalId: program.canonicalId,
libraryId: program.libraryId,
};
}
mint(
serverName: string,
serverId: string,
mediaSource: MediaSource,
library: MediaSourceLibrary,
program: ContentProgramOriginalProgram,
): NewRawProgram {
): NewProgramWithExternalIds {
const ret = match(program)
.with(
{ sourceType: 'plex', program: { type: 'movie' } },
({ program: movie }) =>
this.mintProgramForPlexMovie(serverName, serverId, movie),
)
.with(
{ sourceType: 'plex', program: { type: 'episode' } },
({ program: episode }) =>
this.mintProgramForPlexEpisode(serverName, serverId, episode),
)
.with(
{ sourceType: 'plex', program: { type: 'track' } },
({ program: track }) =>
this.mintProgramForPlexTrack(serverName, serverId, track),
)
.with({ sourceType: 'plex' }, ({ program }) => {
const dao = match(program)
.with({ type: 'movie' }, (movie) =>
this.mintProgramForPlexMovie(mediaSource, library, movie),
)
.with({ type: 'episode' }, (ep) =>
this.mintProgramForPlexEpisode(mediaSource, library, ep),
)
.with({ type: 'track' }, (track) =>
this.mintProgramForPlexTrack(mediaSource, library, track),
)
.exhaustive();
const externalIds = this.mintPlexExternalIdsFromApiItem(
mediaSource.name,
mediaSource.uuid,
dao,
program,
);
return {
...dao,
externalIds,
} satisfies NewProgramWithExternalIds;
})
.with(
{
sourceType: 'jellyfin',
@@ -88,8 +154,7 @@ class ProgramDaoMinter {
),
},
},
({ program }) =>
this.mintProgramForJellyfinItem(serverName, serverId, program),
({ program }) => this.mintProgramForJellyfinItem(mediaSource, program),
)
.otherwise(() => new Error('Unexpected program type'));
if (isError(ret)) {
@@ -98,11 +163,219 @@ class ProgramDaoMinter {
return ret;
}
mintMovie(
mediaSource: MediaSource,
mediaLibrary: MediaSourceLibrary,
movie: MediaSourceMovie,
): NewMovieProgram {
const programId = v4();
const now = +dayjs();
return {
uuid: programId,
sourceType: movie.sourceType,
externalKey: movie.externalKey,
originalAirDate: dayjs(movie.releaseDate)?.format(),
duration: movie.duration,
// filePath: file?.file ?? null,
externalSourceId: mediaSource.name,
mediaSourceId: mediaSource.uuid,
libraryId: mediaLibrary.uuid,
// plexRatingKey: plexMovie.ratingKey,
// plexFilePath: file?.key ?? null,
rating: movie.rating,
summary: movie.summary,
title: movie.title,
type: ProgramType.Movie,
year: movie.year,
createdAt: now,
updatedAt: now,
canonicalId: movie.canonicalId,
externalIds: seq.collect(movie.identifiers, (id) => {
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
return {
type: 'single',
externalKey: id.id,
programUuid: programId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
} satisfies NewSingleOrMultiExternalId;
} else if (isValidMultiExternalIdType(id.type)) {
const isMediaSourceId = id.type === mediaSource.type;
// This stinks
const location = isMediaSourceId
? find(movie.mediaItem?.locations, { sourceType: mediaSource.type })
: null;
return {
type: 'multi',
externalKey: id.id,
programUuid: programId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
externalSourceId: mediaSource.name, // legacy
mediaSourceId: mediaSource.uuid, // new
// TODO
directFilePath: location?.path,
externalFilePath:
location?.type === 'remote' ? location.externalKey : null,
// externalFilePath
} satisfies NewSingleOrMultiExternalId;
}
return;
}),
};
}
mintEpisode(
mediaSource: MediaSource,
mediaLibrary: MediaSourceLibrary,
episode: MediaSourceEpisode,
): NewEpisodeProgram {
const programId = v4();
const now = +dayjs();
return {
uuid: programId,
sourceType: episode.sourceType,
externalKey: episode.externalKey,
originalAirDate: dayjs(episode.releaseDate).format(),
duration: episode.duration,
// filePath: file?.file ?? null,
externalSourceId: mediaSource.name,
mediaSourceId: mediaSource.uuid,
libraryId: mediaLibrary.uuid,
// plexRatingKey: plexMovie.ratingKey,
// plexFilePath: file?.key ?? null,
rating: null,
summary: episode.summary,
title: episode.title,
type: ProgramType.Episode,
year: episode.year,
createdAt: now,
updatedAt: now,
canonicalId: episode.canonicalId,
externalIds: seq.collect(episode.identifiers, (id) => {
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
return {
type: 'single',
externalKey: id.id,
programUuid: programId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
} satisfies NewSingleOrMultiExternalId;
} else if (isValidMultiExternalIdType(id.type)) {
const isMediaSourceId = id.type === mediaSource.type;
// This stinks
const location = isMediaSourceId
? find(episode.mediaItem?.locations, {
sourceType: mediaSource.type,
})
: null;
return {
type: 'multi',
externalKey: id.id,
programUuid: programId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
externalSourceId: mediaSource.name, // legacy
mediaSourceId: mediaSource.uuid, // new
// TODO
directFilePath: location?.path,
externalFilePath:
location?.type === 'remote' ? location.externalKey : null,
// externalFilePath
} satisfies NewSingleOrMultiExternalId;
}
return;
}),
};
}
mintMusicTrack(
mediaSource: MediaSource,
mediaLibrary: MediaSourceLibrary,
track: MediaSourceMusicTrack,
): NewMusicTrack {
const programId = v4();
const now = +dayjs();
return {
uuid: programId,
sourceType: track.sourceType,
externalKey: track.externalKey,
originalAirDate: dayjs(track.releaseDate)?.format(),
duration: track.duration,
// filePath: file?.file ?? null,
externalSourceId: mediaSource.name,
mediaSourceId: mediaSource.uuid,
libraryId: mediaLibrary.uuid,
// plexRatingKey: plexMovie.ratingKey,
// plexFilePath: file?.key ?? null,
rating: null,
summary: null,
title: track.title,
type: ProgramType.Track,
year: track.year,
createdAt: now,
updatedAt: now,
canonicalId: track.canonicalId,
externalIds: seq.collect(track.identifiers, (id) => {
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
return {
type: 'single',
externalKey: id.id,
programUuid: programId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
} satisfies NewSingleOrMultiExternalId;
} else if (isValidMultiExternalIdType(id.type)) {
const isMediaSourceId = id.type === mediaSource.type;
// This stinks
const location = isMediaSourceId
? find(track.mediaItem?.locations, {
sourceType: mediaSource.type,
})
: null;
return {
type: 'multi',
externalKey: id.id,
programUuid: programId,
sourceType: id.type,
uuid: v4(),
createdAt: now,
updatedAt: now,
externalSourceId: mediaSource.name, // legacy
mediaSourceId: mediaSource.uuid, // new
// TODO
directFilePath: location?.path,
externalFilePath:
location?.type === 'remote' ? location.externalKey : null,
// externalFilePath
} satisfies NewSingleOrMultiExternalId;
}
return;
}),
};
}
private mintProgramForPlexMovie(
serverName: string,
serverId: string,
plexMovie: PlexMovie,
): NewRawProgram {
mediaSource: MediaSource,
mediaLibrary: MediaSourceLibrary,
plexMovie: ApiPlexMovie,
): NewProgramDao {
const file = first(first(plexMovie.Media)?.Part ?? []);
return {
uuid: v4(),
@@ -110,8 +383,9 @@ class ProgramDaoMinter {
originalAirDate: plexMovie.originallyAvailableAt ?? null,
duration: plexMovie.duration ?? 0,
filePath: file?.file ?? null,
externalSourceId: serverName,
mediaSourceId: serverId,
externalSourceId: mediaSource.name,
mediaSourceId: mediaSource.uuid,
libraryId: mediaLibrary.uuid,
externalKey: plexMovie.ratingKey,
plexRatingKey: plexMovie.ratingKey,
plexFilePath: file?.key ?? null,
@@ -122,25 +396,26 @@ class ProgramDaoMinter {
year: plexMovie.year ?? null,
createdAt: +dayjs(),
updatedAt: +dayjs(),
canonicalId: this.plexProgramCanonicalizer.getCanonicalId(plexMovie),
};
}
private mintProgramForJellyfinItem(
serverName: string,
serverId: string,
mediaSource: MediaSource,
item: Omit<JellyfinItem, 'Type'> & {
Type: 'Movie' | 'Episode' | 'Audio' | 'Video' | 'MusicVideo' | 'Trailer';
},
): NewRawProgram {
return {
uuid: v4(),
): NewProgramWithExternalIds {
const id = v4();
const dao: NewProgramDao = {
uuid: id,
createdAt: +dayjs(),
updatedAt: +dayjs(),
sourceType: ProgramSourceType.JELLYFIN,
originalAirDate: item.PremiereDate,
duration: (item.RunTimeTicks ?? 0) / 10_000,
externalSourceId: serverName,
mediaSourceId: serverId,
duration: Math.ceil((item.RunTimeTicks ?? 0) / 10_000),
externalSourceId: mediaSource.name,
mediaSourceId: mediaSource.uuid,
externalKey: item.Id,
rating: item.OfficialRating,
summary: item.Overview,
@@ -161,14 +436,27 @@ class ProgramDaoMinter {
grandparentExternalKey:
item.SeriesId ??
find(item.AlbumArtists, { Name: item.AlbumArtist })?.Id,
canonicalId: this.jellyfinCanonicalizer.getCanonicalId(item),
};
const externalIds = this.mintAllJellyfinExternalIdForApiItem(
mediaSource.name,
mediaSource.uuid,
dao,
item,
);
return {
...dao,
externalIds,
} satisfies NewProgramWithExternalIds;
}
private mintProgramForPlexEpisode(
serverName: string,
serverId: string,
mediaSource: MediaSource,
mediaLibrary: MediaSourceLibrary,
plexEpisode: PlexEpisode,
): NewRawProgram {
): NewProgramDao {
const file = first(first(plexEpisode.Media)?.Part ?? []);
return {
uuid: v4(),
@@ -178,8 +466,9 @@ class ProgramDaoMinter {
originalAirDate: plexEpisode.originallyAvailableAt,
duration: plexEpisode.duration ?? 0,
filePath: file?.file,
externalSourceId: serverName,
mediaSourceId: serverId,
externalSourceId: mediaSource.name,
mediaSourceId: mediaSource.uuid,
libraryId: mediaLibrary.uuid,
externalKey: plexEpisode.ratingKey,
plexRatingKey: plexEpisode.ratingKey,
plexFilePath: file?.key,
@@ -194,12 +483,13 @@ class ProgramDaoMinter {
episode: plexEpisode.index,
parentExternalKey: plexEpisode.parentRatingKey,
grandparentExternalKey: plexEpisode.grandparentRatingKey,
canonicalId: this.plexProgramCanonicalizer.getCanonicalId(plexEpisode),
};
}
private mintProgramForPlexTrack(
serverName: string,
serverId: string,
mediaSource: MediaSource,
mediaLibrary: MediaSourceLibrary,
plexTrack: PlexMusicTrack,
): NewRawProgram {
const file = first(first(plexTrack.Media)?.Part ?? []);
@@ -210,8 +500,9 @@ class ProgramDaoMinter {
sourceType: ProgramSourceType.PLEX,
duration: plexTrack.duration ?? 0,
filePath: file?.file,
externalSourceId: serverName,
mediaSourceId: serverId,
externalSourceId: mediaSource.name,
mediaSourceId: mediaSource.uuid,
libraryId: mediaLibrary.uuid,
externalKey: plexTrack.ratingKey,
plexRatingKey: plexTrack.ratingKey,
plexFilePath: file?.key,
@@ -227,12 +518,13 @@ class ProgramDaoMinter {
grandparentExternalKey: plexTrack.grandparentRatingKey,
albumName: plexTrack.parentTitle,
artistName: plexTrack.grandparentTitle,
canonicalId: this.plexProgramCanonicalizer.getCanonicalId(plexTrack),
};
}
mintExternalIds(
serverName: string,
serverId: string,
serverName: MediaSourceName,
serverId: MediaSourceId,
programId: string,
program: ContentProgram,
): NewSingleOrMultiExternalId[] {
@@ -250,8 +542,8 @@ class ProgramDaoMinter {
}
mintPlexExternalIds(
serverName: string,
serverId: string,
serverName: MediaSourceName,
serverId: MediaSourceId,
programId: string,
program: ContentProgram,
): NewSingleOrMultiExternalId[] {
@@ -310,25 +602,66 @@ class ProgramDaoMinter {
return ids;
}
mintJellyfinExternalIdForApiItem(
serverName: string,
programId: string,
media: JellyfinItem,
) {
return {
uuid: v4(),
createdAt: +dayjs(),
updatedAt: +dayjs(),
externalKey: media.Id,
sourceType: ProgramExternalIdType.JELLYFIN,
programUuid: programId,
externalSourceId: serverName,
} satisfies NewProgramExternalId;
mintPlexExternalIdsFromApiItem(
serverName: MediaSourceName,
serverId: MediaSourceId,
program: NewProgramDao,
plexEntity: PlexTerminalMedia,
): NewSingleOrMultiExternalId[] {
const now = +dayjs();
const file = first(first(plexEntity.Media)?.Part ?? []);
const ids: NewSingleOrMultiExternalId[] = [
{
type: 'multi',
uuid: v4(),
createdAt: now,
updatedAt: now,
externalKey: program.externalKey,
sourceType: ProgramExternalIdType.PLEX,
programUuid: program.uuid,
externalSourceId: serverName,
mediaSourceId: serverId,
externalFilePath: file?.key,
directFilePath: file?.file,
} satisfies NewSingleOrMultiExternalId,
];
if (plexEntity.guid) {
ids.push({
type: 'single',
uuid: v4(),
createdAt: now,
updatedAt: now,
externalKey: plexEntity.guid,
sourceType: ProgramExternalIdType.PLEX_GUID,
programUuid: program.uuid,
});
}
ids.push(
...seq
.collect(plexEntity.Guid, ({ id }) => parsePlexGuid(id))
.map(
(eid) =>
({
type: 'single',
uuid: v4(),
createdAt: now,
updatedAt: now,
externalKey: eid.externalKey,
sourceType: eid.sourceType,
programUuid: program.uuid,
}) satisfies NewSingleOrMultiExternalId,
),
);
return ids;
}
mintJellyfinExternalIds(
serverName: string,
serverId: string,
serverName: MediaSourceName,
serverId: MediaSourceId,
programId: string,
program: ContentProgram,
) {
@@ -373,9 +706,75 @@ class ProgramDaoMinter {
return ids;
}
mintJellyfinExternalIdForApiItem(
serverName: MediaSourceName,
programId: string,
media: JellyfinItem,
) {
return {
uuid: v4(),
createdAt: +dayjs(),
updatedAt: +dayjs(),
externalKey: media.Id,
sourceType: ProgramExternalIdType.JELLYFIN,
programUuid: programId,
externalSourceId: serverName,
} satisfies NewProgramExternalId;
}
mintAllJellyfinExternalIdForApiItem(
serverName: MediaSourceName,
serverId: MediaSourceId,
program: NewProgramDao,
entity: JellyfinItem,
) {
const now = +dayjs();
const ids: NewSingleOrMultiExternalId[] = [
{
type: 'multi',
uuid: v4(),
createdAt: now,
updatedAt: now,
externalKey: program.externalKey,
sourceType: ProgramExternalIdType.JELLYFIN,
programUuid: program.uuid,
externalSourceId: serverName,
mediaSourceId: serverId,
directFilePath: head(entity.MediaSources)?.Path,
},
];
ids.push(
...seq.collectMapValues(entity.ProviderIds, (value, source) => {
if (!value) {
return;
}
switch (source) {
case 'tmdb':
case 'imdb':
case 'tvdb':
return {
type: 'single',
uuid: v4(),
createdAt: now,
updatedAt: now,
externalKey: value,
sourceType: source,
programUuid: program.uuid,
} satisfies NewSingleOrMultiExternalId;
default:
return null;
}
}),
);
return ids;
}
mintEmbyExternalIds(
serverName: string,
serverId: string,
serverName: MediaSourceName,
serverId: MediaSourceId,
programId: string,
program: ContentProgram,
) {
@@ -420,9 +819,3 @@ class ProgramDaoMinter {
return ids;
}
}
export class ProgramMinterFactory {
static create(): ProgramDaoMinter {
return new ProgramDaoMinter();
}
}

View File

@@ -3,10 +3,12 @@
// active streaming session
import { MediaSourceType } from '@/db/schema/MediaSource.js';
import { tag } from '@tunarr/types';
import { ContentProgramTypeSchema } from '@tunarr/types/schemas';
import type { StrictOmit } from 'ts-essentials';
import { z } from 'zod/v4';
import type { EmbyT, JellyfinT } from '../../types/internal.ts';
import type { MediaSourceId } from '../schema/base.ts';
import type { ProgramType } from '../schema/Program.ts';
const baseStreamLineupItemSchema = z.object({
@@ -125,7 +127,7 @@ const BaseContentBackedStreamLineupItemSchema =
programId: z.uuid(),
// These are taken from the Program DB entity
plexFilePath: z.string().optional(),
externalSourceId: z.string(),
externalSourceId: z.string().transform((s) => tag<MediaSourceId>(s)),
filePath: z.string().optional(),
externalKey: z.string(),
programType: ContentProgramTypeSchema,

View File

@@ -1,6 +1,6 @@
import type { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
import type { ProgramSourceType } from '@/db/custom_types/ProgramSourceType.js';
import type { ProgramDao } from '@/db/schema/Program.js';
import type { ProgramDao, ProgramType } from '@/db/schema/Program.js';
import type {
MinimalProgramExternalId,
NewProgramExternalId,
@@ -10,15 +10,19 @@ import type {
import type { ProgramExternalIdSourceType } from '@/db/schema/base.js';
import type {
MusicAlbumWithExternalIds,
NewProgramGroupingWithExternalIds,
NewProgramWithExternalIds,
ProgramGroupingWithExternalIds,
ProgramWithExternalIds,
ProgramWithRelations,
TvSeasonWithExternalIds,
} from '@/db/schema/derivedTypes.js';
import type { Maybe, PagedResult } from '@/types/util.js';
import type { ChannelProgram, ContentProgram } from '@tunarr/types';
import type { MarkOptional } from 'ts-essentials';
import type { MarkNonNullable, Maybe, PagedResult } from '@/types/util.js';
import type { ChannelProgram } from '@tunarr/types';
import type { Dictionary, MarkOptional } from 'ts-essentials';
import type { MediaSourceType } from '../schema/MediaSource.ts';
import type { ProgramGroupingType } from '../schema/ProgramGrouping.ts';
import type { MediaSourceId } from '../schema/base.ts';
import type { PageParams } from './IChannelDB.ts';
export interface IProgramDB {
@@ -35,13 +39,21 @@ export interface IProgramDB {
getProgramsByIds(
ids: string[],
batchSize: number,
batchSize?: number,
): Promise<ProgramWithRelations[]>;
getProgramGrouping(
id: string,
): Promise<Maybe<ProgramGroupingWithExternalIds>>;
getProgramGroupings(
ids: string[],
): Promise<Record<string, ProgramGroupingWithExternalIds>>;
getProgramGroupingByExternalId(
eid: ProgramGroupingExternalIdLookup,
): Promise<Maybe<ProgramGroupingWithExternalIds>>;
getProgramParent(
programId: string,
): Promise<Maybe<ProgramGroupingWithExternalIds>>;
@@ -73,11 +85,21 @@ export interface IProgramDB {
sourceType: ProgramSourceType;
externalSourceId: string;
externalKey: string;
}): Promise<Maybe<ContentProgram>>;
}): Promise<Maybe<ProgramWithRelations>>;
lookupByExternalIds(
ids: Set<[string, string, string]>,
): Promise<Record<string, ContentProgram>>;
ids:
| Set<[string, MediaSourceId, string]>
| Set<readonly [string, MediaSourceId, string]>,
chunkSize?: number,
): Promise<ProgramWithRelations[]>;
lookupByMediaSource(
sourceType: MediaSourceType,
sourceId: MediaSourceId,
mediaType?: ProgramType,
chunkSize?: number,
): Promise<ProgramDao[]>;
programIdsByExternalIds(
ids: Set<[string, string, string]>,
@@ -107,7 +129,12 @@ export interface IProgramDB {
upsertContentPrograms(
programs: ChannelProgram[],
programUpsertBatchSize?: number,
): Promise<ProgramDao[]>;
): Promise<MarkNonNullable<ProgramDao, 'mediaSourceId'>[]>;
upsertPrograms(
programs: NewProgramWithExternalIds[],
programUpsertBatchSize?: number,
): Promise<ProgramWithExternalIds[]>;
programIdsByExternalIds(
ids: Set<[string, string, string]>,
@@ -117,9 +144,81 @@ export interface IProgramDB {
upsertProgramExternalIds(
externalIds: NewSingleOrMultiExternalId[],
chunkSize?: number,
): Promise<void>;
): Promise<Dictionary<ProgramExternalId[]>>;
getProgramsForMediaSource(
mediaSourceId: string,
type?: ProgramType,
): Promise<ProgramDao[]>;
getMediaSourceLibraryPrograms(
libraryId: string,
): Promise<ProgramWithRelations[]>;
getProgramCanonicalIdsForMediaSource(
mediaSourceLibraryId: string,
type: ProgramType,
): Promise<Dictionary<ProgramCanonicalIdLookupResult>>;
getProgramGroupingCanonicalIds(
mediaSourceLibraryId: string,
type: ProgramGroupingType,
sourceType: MediaSourceType,
): Promise<Dictionary<ProgramGroupingCanonicalIdLookupResult>>;
getOrInsertProgramGrouping(
dao: NewProgramGroupingWithExternalIds,
externalId: ProgramGroupingExternalIdLookup,
forceUpdate?: boolean,
): Promise<GetOrInsertResult<ProgramGroupingWithExternalIds>>;
getShowSeasons(showUuid: string): Promise<ProgramGroupingWithExternalIds[]>;
getArtistAlbums(
artistUuid: string,
): Promise<ProgramGroupingWithExternalIds[]>;
getProgramGroupingChildCounts(
groupIds: string[],
): Promise<Record<string, ProgramGroupingChildCounts>>;
getProgramGroupingDescendants(
groupId: string,
groupTypeHint?: ProgramGroupingType,
): Promise<ProgramWithExternalIds[]>;
}
export type WithChannelIdFilter<T> = T & {
channelId?: string;
};
export type ProgramCanonicalIdLookupResult = {
uuid: string;
canonicalId: string;
libraryId: string;
externalKey: string;
};
export type ProgramGroupingCanonicalIdLookupResult = {
uuid: string;
canonicalId: string;
libraryId: string;
};
export type ProgramGroupingExternalIdLookup = {
sourceType: ProgramExternalIdSourceType;
externalKey: string;
externalSourceId: MediaSourceId;
};
export type GetOrInsertResult<Entity> = {
wasInserted: boolean;
wasUpdated: boolean;
entity: Entity;
};
export type ProgramGroupingChildCounts = {
type: ProgramGroupingType;
childCount?: number;
grandchildCount?: number;
};

View File

@@ -10,7 +10,10 @@ import type {
SystemSettings,
XmlTvSettings,
} from '@tunarr/types';
import type { BackupSettings } from '@tunarr/types/schemas';
import type {
BackupSettings,
GlobalMediaSourceSettings,
} from '@tunarr/types/schemas';
import type { DeepReadonly } from 'ts-essentials';
import type { TypedEventEmitter } from '../../types/eventEmitter.ts';
@@ -32,6 +35,8 @@ export interface ISettingsDB extends TypedEventEmitter<SettingsChangeEvents> {
ffmpegSettings(): ReadableFfmpegSettings;
globalMediaSourceSettings(): DeepReadonly<GlobalMediaSourceSettings>;
ffprobePath: string;
systemSettings(): DeepReadonly<SystemSettings>;
@@ -54,6 +59,7 @@ export interface ISettingsDB extends TypedEventEmitter<SettingsChangeEvents> {
flush(): Promise<void>;
}
export type ReadableFfmpegSettings = DeepReadonly<FfmpegSettings>;
export type SettingsChangeEvents = {
change(): void;

View File

@@ -34,7 +34,7 @@ export class SchemaBackedDbAdapter<T extends z.ZodTypeAny>
});
if (data === null && this.defaultValue === null) {
this.logger.debug('Unexpected null data at %s; %O', this.path, data);
this.logger.debug('Unexpected null data at %s', this.path.toString());
return null;
}
@@ -55,8 +55,8 @@ export class SchemaBackedDbAdapter<T extends z.ZodTypeAny>
}
this.logger.error(
`Error while parsing schema-backed JSON file ${this.path.toString()}. Returning null. This could mean the DB got corrupted somehow`,
parseResult.error,
`Error while parsing schema-backed JSON file ${this.path.toString()}. Returning null. This could mean the DB got corrupted somehow`,
);
return null;
}
@@ -74,7 +74,7 @@ export class SchemaBackedDbAdapter<T extends z.ZodTypeAny>
}
// eslint can't seem to handle this but TS compiler gets it right.
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return parseResult.data;
}

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
import type { Nullable } from '@/types/util.js';
import { isProduction } from '@/util/index.js';
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
@@ -51,8 +50,8 @@ export class SyncSchemaBackedDbAdapter<T extends z.ZodTypeAny>
if (!parseResult.success) {
this.logger.error(
`Error while parsing schema-backed JSON file ${this.path.toString()}. Returning null. This could mean the DB got corrupted somehow`,
parseResult.error,
`Error while parsing schema-backed JSON file ${this.path.toString()}. Returning null. This could mean the DB got corrupted somehow`,
);
return null;
}
@@ -64,8 +63,8 @@ export class SyncSchemaBackedDbAdapter<T extends z.ZodTypeAny>
const parseResult = this.schema.safeParse(data);
if (!parseResult.success) {
this.logger.warn(
'Could not verify schema before saving to DB - the given type does not match the expected schema.',
parseResult.error,
'Could not verify schema before saving to DB - the given type does not match the expected schema.',
);
throw new Error(
'Could not verify schema before saving to DB - the given type does not match the expected schema.',

View File

@@ -8,6 +8,7 @@ import dayjs from 'dayjs';
import {
chunk,
first,
isEmpty,
isNil,
isUndefined,
keys,
@@ -21,21 +22,34 @@ import { v4 } from 'uuid';
import { type IChannelDB } from '@/db/interfaces/IChannelDB.js';
import { KEYS } from '@/types/inject.js';
import { booleanToNumber } from '@/util/sqliteUtil.js';
import { inject, injectable } from 'inversify';
import { retag, tag } from '@tunarr/types';
import { inject, injectable, interfaces } from 'inversify';
import { Kysely } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/sqlite';
import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory.ts';
import { MediaSourceLibraryRefresher } from '../services/MediaSourceLibraryRefresher.ts';
import { withLibraries } from './mediaSourceQueryHelpers.ts';
import {
withProgramChannels,
withProgramCustomShows,
withProgramFillerShows,
} from './programQueryHelpers.ts';
import { MediaSourceId, MediaSourceName } from './schema/base.ts';
import { DB } from './schema/db.ts';
import {
EmbyMediaSource,
JellyfinMediaSource,
MediaSource,
MediaSourceType,
MediaSourceWithLibraries,
PlexMediaSource,
} from './schema/derivedTypes.js';
import {
MediaSource,
MediaSourceFields,
MediaSourceLibrary,
MediaSourceLibraryUpdate,
MediaSourceType,
MediaSourceUpdate,
NewMediaSourceLibrary,
} from './schema/MediaSource.ts';
type Report = {
@@ -59,68 +73,79 @@ export class MediaSourceDB {
@inject(KEYS.MediaSourceApiFactory)
private mediaSourceApiFactory: () => MediaSourceApiFactory,
@inject(KEYS.Database) private db: Kysely<DB>,
@inject(KEYS.MediaSourceLibraryRefresher)
private mediaSourceLibraryRefresher: interfaces.AutoFactory<MediaSourceLibraryRefresher>,
) {}
async getAll(): Promise<MediaSource[]> {
return this.db.selectFrom('mediaSource').selectAll().execute();
}
async getById(id: string) {
async getAll(): Promise<MediaSourceWithLibraries[]> {
return this.db
.selectFrom('mediaSource')
.select(withLibraries)
.selectAll()
.execute();
}
async getById(id: MediaSourceId): Promise<Maybe<MediaSourceWithLibraries>> {
return this.db
.selectFrom('mediaSource')
.select(withLibraries)
.selectAll()
.where('mediaSource.uuid', '=', id)
.executeTakeFirst();
}
async getByName(name: string) {
return this.db
.selectFrom('mediaSource')
.selectAll()
.where('mediaSource.name', '=', name)
.executeTakeFirst();
}
async getByIdOrName(id: string) {
return this.db
.selectFrom('mediaSource')
.selectAll()
.where((eb) => eb.or([eb('uuid', '=', id), eb('name', '=', id)]))
.executeTakeFirst();
async getLibrary(id: string) {
return (
this.db
.selectFrom('mediaSourceLibrary')
.where('uuid', '=', id)
.select((eb) =>
jsonObjectFrom(
eb
.selectFrom('mediaSource')
.whereRef(
'mediaSource.uuid',
'=',
'mediaSourceLibrary.mediaSourceId',
)
.select(MediaSourceFields),
).as('mediaSource'),
)
.selectAll()
// Should be safe before of referential integrity of foreign keys
.$narrowType<{ mediaSource: MediaSource }>()
.executeTakeFirst()
);
}
async findByType(
type: typeof MediaSourceType.Plex,
nameOrId: string,
nameOrId: MediaSourceId,
): Promise<PlexMediaSource | undefined>;
async findByType(
type: typeof MediaSourceType.Jellyfin,
nameOrId: string,
nameOrId: MediaSourceId,
): Promise<JellyfinMediaSource | undefined>;
async findByType(
type: typeof MediaSourceType.Emby,
nameOrId: string,
nameOrId: MediaSourceId,
): Promise<EmbyMediaSource | undefined>;
async findByType(
type: MediaSourceType,
nameOrId: string,
): Promise<MediaSource | undefined>;
async findByType(type: MediaSourceType): Promise<MediaSource[]>;
nameOrId: MediaSourceId,
): Promise<MediaSourceWithLibraries | undefined>;
async findByType(type: MediaSourceType): Promise<MediaSourceWithLibraries[]>;
async findByType(
type: MediaSourceType,
nameOrId?: string,
): Promise<MediaSource[] | Maybe<MediaSource>> {
nameOrId?: MediaSourceId,
): Promise<MediaSourceWithLibraries[] | Maybe<MediaSourceWithLibraries>> {
const found = await this.db
.selectFrom('mediaSource')
.selectAll()
.select(withLibraries)
.where('mediaSource.type', '=', type)
.$if(isNonEmptyString(nameOrId), (qb) =>
qb.where((eb) =>
eb.or([
eb('mediaSource.name', '=', nameOrId!),
eb('mediaSource.uuid', '=', nameOrId!),
]),
),
qb.where('mediaSource.uuid', '=', retag<MediaSourceId>(nameOrId!)),
)
.execute();
@@ -131,26 +156,7 @@ export class MediaSourceDB {
}
}
async getByExternalId(
sourceType: MediaSourceType,
nameOrClientId: string,
): Promise<Maybe<MediaSource>> {
return this.db
.selectFrom('mediaSource')
.selectAll()
.where((eb) =>
eb.and([
eb('type', '=', sourceType),
eb.or([
eb('name', '=', nameOrClientId),
eb('clientIdentifier', '=', nameOrClientId),
]),
]),
)
.executeTakeFirst();
}
async deleteMediaSource(id: string) {
async deleteMediaSource(id: MediaSourceId) {
const deletedServer = await this.getById(id);
if (isNil(deletedServer)) {
throw new Error(`MediaSource not found: ${id}`);
@@ -185,7 +191,7 @@ export class MediaSourceDB {
async updateMediaSource(server: UpdateMediaSourceRequest) {
const id = server.id;
const mediaSource = await this.getById(id);
const mediaSource = await this.getById(tag(id));
if (isNil(mediaSource)) {
throw new Error("Server doesn't exist.");
@@ -199,7 +205,7 @@ export class MediaSourceDB {
await this.db
.updateTable('mediaSource')
.set({
name: server.name,
name: tag<MediaSourceName>(server.name),
uri: trimEnd(server.uri, '/'),
accessToken: server.accessToken,
sendGuideUpdates: booleanToNumber(sendGuideUpdates),
@@ -208,8 +214,8 @@ export class MediaSourceDB {
// This allows clearing the values
userId: server.userId,
username: server.username,
})
.where('uuid', '=', server.id)
} satisfies MediaSourceUpdate)
.where('uuid', '=', tag<MediaSourceId>(server.id))
// TODO: Blocked on https://github.com/oven-sh/bun/issues/16909
// .limit(1)
.executeTakeFirst();
@@ -217,7 +223,7 @@ export class MediaSourceDB {
this.mediaSourceApiFactory().deleteCachedClient(mediaSource);
const report = await this.fixupProgramReferences(
id,
tag(id),
mediaSource.type,
mediaSource,
);
@@ -226,7 +232,7 @@ export class MediaSourceDB {
}
async setMediaSourceUserInfo(
mediaSourceId: string,
mediaSourceId: MediaSourceId,
info: MediaSourceUserInfo,
) {
if (isNonEmptyString(info.userId) && isNonEmptyString(info.username)) {
@@ -244,7 +250,9 @@ export class MediaSourceDB {
}
async addMediaSource(server: InsertMediaSourceRequest): Promise<string> {
const name = isUndefined(server.name) ? 'plex' : server.name;
const name = tag<MediaSourceName>(
isUndefined(server.name) ? 'plex' : server.name,
);
const sendGuideUpdates =
server.type === 'plex' ? (server.sendGuideUpdates ?? false) : false;
const sendChannelUpdates =
@@ -260,7 +268,7 @@ export class MediaSourceDB {
.insertInto('mediaSource')
.values({
...server,
uuid: v4(),
uuid: tag<MediaSourceId>(v4()),
name,
uri: trimEnd(server.uri, '/'),
sendChannelUpdates: sendChannelUpdates ? 1 : 0,
@@ -275,11 +283,65 @@ export class MediaSourceDB {
.returning('uuid')
.executeTakeFirstOrThrow();
await this.mediaSourceLibraryRefresher().refreshMediaSource(newServer.uuid);
return newServer?.uuid;
}
async updateLibraries(updates: MediaSourceLibrariesUpdate) {
return this.db.transaction().execute(async (tx) => {
if (!isEmpty(updates.addedLibraries)) {
await tx
.insertInto('mediaSourceLibrary')
.values(updates.addedLibraries)
.execute();
}
if (updates.updatedLibraries.length) {
// TODO;
}
if (updates.deletedLibraries.length) {
await tx
.deleteFrom('mediaSourceLibrary')
.where(
'uuid',
'in',
updates.deletedLibraries.map((lib) => lib.uuid),
)
.execute();
}
});
}
async setLibraryEnabled(
mediaSourceId: MediaSourceId,
libraryId: string,
enabled: boolean,
) {
return this.db
.updateTable('mediaSourceLibrary')
.set({
enabled: booleanToNumber(enabled),
})
.where('mediaSourceLibrary.mediaSourceId', '=', mediaSourceId)
.where('uuid', '=', libraryId)
.returningAll()
.executeTakeFirstOrThrow();
}
setLibraryLastScannedTime(libraryId: string, lastScannedAt: dayjs.Dayjs) {
return this.db
.updateTable('mediaSourceLibrary')
.set({
lastScannedAt: +lastScannedAt,
})
.where('uuid', '=', libraryId)
.executeTakeFirstOrThrow();
}
private async fixupProgramReferences(
serverName: string,
serverId: MediaSourceId,
serverType: MediaSourceType,
newServer?: MediaSource,
) {
@@ -292,7 +354,7 @@ export class MediaSourceDB {
.selectFrom('program')
.selectAll()
.where('sourceType', '=', serverType)
.where('externalSourceId', '=', serverName)
.where('mediaSourceId', '=', serverId)
.select(withProgramChannels)
.select(withProgramFillerShows)
.select(withProgramCustomShows)
@@ -334,7 +396,7 @@ export class MediaSourceDB {
.length,
);
const isUpdate = newServer && newServer.uuid !== serverName;
const isUpdate = newServer && newServer.uuid !== serverId;
if (!isUpdate) {
// Remove all associations of this program
// TODO: See if we can just get this automatically with foreign keys...
@@ -399,3 +461,9 @@ export class MediaSourceDB {
return [...channelReports, ...fillerReports, ...customShowReports];
}
}
export type MediaSourceLibrariesUpdate = {
addedLibraries: NewMediaSourceLibrary[];
updatedLibraries: MediaSourceLibraryUpdate[];
deletedLibraries: MediaSourceLibrary[];
};

View File

@@ -0,0 +1,13 @@
import type { ExpressionBuilder } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/sqlite';
import type { DB } from './schema/db.ts';
import { MediaSourceLibraryColumns } from './schema/MediaSource.ts';
export function withLibraries(eb: ExpressionBuilder<DB, 'mediaSource'>) {
return jsonArrayFrom(
eb
.selectFrom('mediaSourceLibrary')
.whereRef('mediaSourceLibrary.mediaSourceId', '=', 'mediaSource.uuid')
.select(MediaSourceLibraryColumns),
).as('libraries');
}

View File

@@ -1,7 +1,7 @@
import { isNonEmptyString } from '@/util/index.js';
import { createExternalId } from '@tunarr/shared';
import type { ContentProgram, CustomProgram } from '@tunarr/types';
import { isContentProgram, isCustomProgram } from '@tunarr/types';
import { isContentProgram, isCustomProgram, tag } from '@tunarr/types';
import { reduce } from 'lodash-es';
// Takes a listing of programs and makes a mapping of a unique identifier,
@@ -21,14 +21,14 @@ export function createPendingProgramIndexMap(
// TODO handle other types of programs
} else if (
isContentProgram(p) &&
isNonEmptyString(p.externalSourceName) &&
isNonEmptyString(p.externalSourceId) &&
isNonEmptyString(p.externalSourceType) &&
isNonEmptyString(p.externalKey)
) {
acc[
createExternalId(
p.externalSourceType,
p.externalSourceName,
tag(p.externalSourceId),
p.externalKey,
)
] = idx++;

View File

@@ -11,6 +11,7 @@ import type {
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/sqlite';
import { identity, isBoolean, isEmpty, keys, merge, reduce } from 'lodash-es';
import type { DeepPartial, DeepRequired, StrictExclude } from 'ts-essentials';
import type { Replace } from '../types/util.ts';
import type { FillerShowTable as RawFillerShow } from './schema/FillerShow.js';
import type {
ProgramDao,
@@ -19,40 +20,16 @@ import type {
import { ProgramType } from './schema/Program.ts';
import type { ProgramExternalId } from './schema/ProgramExternalId.ts';
import { ProgramExternalIdFieldsWithAlias } from './schema/ProgramExternalId.ts';
import type { ProgramGroupingFields } from './schema/ProgramGrouping.ts';
import {
AllProgramGroupingFields,
AllProgramGroupingFieldsAliased,
ProgramGroupingType,
type ProgramGroupingTable as RawProgramGrouping,
} from './schema/ProgramGrouping.ts';
import type { ProgramGroupingExternalId } from './schema/ProgramGroupingExternalId.ts';
import { ProgramGroupingExternalIdFieldsWithAlias } from './schema/ProgramGroupingExternalId.ts';
import type { DB } from './schema/db.ts';
type ProgramGroupingFields<Alias extends string = 'programGrouping'> =
readonly `${Alias}.${keyof RawProgramGrouping}`[];
const ProgramGroupingKeys: (keyof RawProgramGrouping)[] = [
'artistUuid',
'createdAt',
'icon',
'index',
'showUuid',
'summary',
'title',
'type',
'updatedAt',
'uuid',
'year',
];
// TODO move this definition to the ProgramGrouping DAO file
export const AllProgramGroupingFields: ProgramGroupingFields =
ProgramGroupingKeys.map((key) => `programGrouping.${key}` as const);
export const AllProgramGroupingFieldsAliased = <Alias extends string>(
alias: Alias,
): ProgramGroupingFields<Alias> =>
ProgramGroupingKeys.map((key) => `${alias}.${key}` as const);
type ProgramGroupingExternalIdFields<
Alias extends string = 'programGroupingExternalId',
> = readonly `${Alias}.${keyof ProgramGroupingExternalId}`[];
@@ -209,6 +186,7 @@ export function withProgramExternalIds(
'externalKey',
'sourceType',
'externalSourceId',
'mediaSourceId',
],
) {
return jsonArrayFrom(
@@ -254,6 +232,7 @@ export function withProgramGroupingExternalIds(
'sourceType',
'externalSourceId',
'mediaSourceId',
'libraryId',
],
) {
return jsonArrayFrom(
@@ -288,34 +267,31 @@ export const AllProgramJoins: ProgramJoins = {
customShows: true,
};
type Replace<
T extends string,
S extends string,
D extends string,
A extends string = '',
> = T extends `${infer L}${S}${infer R}`
? Replace<R, S, D, `${A}${L}${D}`>
: `${A}${T}`;
type ProgramField = `program.${keyof RawProgram}`;
type ProgramFields = readonly ProgramField[];
// const ProgramUpsertMapping =
export const AllProgramFields: ProgramFields = [
export const AllProgramFields = [
'program.uuid',
'program.createdAt',
'program.updatedAt',
'program.albumName',
'program.canonicalId',
'program.icon',
'program.summary',
'program.title',
'program.type',
'program.year',
'program.artistUuid',
'program.externalKey',
'program.libraryId',
'program.albumUuid',
'program.artistName',
'program.artistUuid',
'program.createdAt',
'program.duration',
'program.episode',
'program.episodeIcon',
'program.externalKey',
'program.externalSourceId',
'program.filePath',
'program.grandparentExternalKey',
'program.icon',
'program.originalAirDate',
'program.parentExternalKey',
'program.plexFilePath',
@@ -327,14 +303,9 @@ export const AllProgramFields: ProgramFields = [
'program.showIcon',
'program.showTitle',
'program.sourceType',
'program.summary',
'program.title',
'program.tvShowUuid',
'program.type',
'program.updatedAt',
'program.uuid',
'program.year',
];
'program.mediaSourceId',
] as const;
type ProgramUpsertFields = StrictExclude<
Replace<ProgramField, 'program', 'excluded'>,
@@ -348,6 +319,7 @@ const ProgramUpsertIgnoreFields = [
'program.albumUuid',
'program.artistUuid',
'program.seasonUuid',
// 'program.libraryId',
] as const;
type KnownProgramUpsertFields = StrictExclude<
@@ -371,11 +343,13 @@ export const ProgramUpsertFields: ProgramUpsertFields[] =
export type WithProgramsOptions = {
joins?: Partial<ProgramJoins>;
fields?: ProgramFields;
includeGroupingExternalIds?: boolean;
};
export const defaultWithProgramOptions: DeepRequired<WithProgramsOptions> = {
joins: defaultProgramJoins,
fields: AllProgramFields,
includeGroupingExternalIds: false,
};
type BaseWithProgramsAvailableTables =
@@ -403,6 +377,18 @@ function baseWithProgramsExpressionBuilder(
ProgramDao
> = identity,
) {
function getJoinFields(key: keyof ProgramJoins) {
if (!opts.joins[key]) {
return [];
}
if (isBoolean(opts.joins[key])) {
return opts.joins[key] ? AllProgramGroupingFields : [];
}
return opts.joins[key];
}
const builder = eb.selectFrom('program').select(opts.fields);
return builderFunc(builder)
@@ -410,15 +396,38 @@ function baseWithProgramsExpressionBuilder(
qb.select((eb) =>
withTrackAlbum(
eb,
isBoolean(opts.joins.trackAlbum)
? AllProgramGroupingFields
: opts.joins.trackAlbum,
getJoinFields('trackAlbum'),
opts.includeGroupingExternalIds,
),
),
)
.$if(!!opts.joins.trackArtist, (qb) =>
qb.select((eb) =>
withTrackArtist(
eb,
getJoinFields('trackArtist'),
opts.includeGroupingExternalIds,
),
),
)
.$if(!!opts.joins.tvSeason, (qb) =>
qb.select((eb) =>
withTvSeason(
eb,
getJoinFields('tvSeason'),
opts.includeGroupingExternalIds,
),
),
)
.$if(!!opts.joins.tvShow, (qb) =>
qb.select((eb) =>
withTvShow(
eb,
getJoinFields('tvShow'),
opts.includeGroupingExternalIds,
),
),
)
.$if(!!opts.joins.trackArtist, (qb) => qb.select(withTrackArtist))
.$if(!!opts.joins.tvSeason, (qb) => qb.select(withTvSeason))
.$if(!!opts.joins.tvSeason, (qb) => qb.select(withTvShow))
.$if(!!opts.joins.customShows, (qb) => qb.select(withProgramCustomShows));
}
@@ -484,10 +493,21 @@ export function withPrograms(
export function withProgramByExternalId(
eb: ExpressionBuilder<DB, 'programExternalId'>,
options: WithProgramsOptions = defaultWithProgramOptions,
builderFunc: (
qb: SelectQueryBuilder<
DB,
BaseWithProgramsAvailableTables | 'program',
ProgramDao
>,
) => SelectQueryBuilder<
DB,
BaseWithProgramsAvailableTables | 'program',
ProgramDao
> = identity,
) {
const mergedOpts = merge({}, defaultWithProgramOptions, options);
return jsonObjectFrom(
baseWithProgramsExpressionBuilder(eb, mergedOpts).whereRef(
baseWithProgramsExpressionBuilder(eb, mergedOpts, builderFunc).whereRef(
'programExternalId.programUuid',
'=',
'program.uuid',

View File

@@ -28,6 +28,14 @@ type InferBool<
T['_']['columns'][Key]['notNull'] extends true ? number : number | null
>;
type InferDateMs<
T extends Table,
Key extends keyof T['_']['columns'] & string,
> = ColumnType<
number,
T['_']['columns'][Key]['notNull'] extends true ? number : number | null
>;
export type KyselifyBetter<T extends Table> = Simplify<{
[Key in keyof T['_']['columns'] & string as MapColumnName<
Key,
@@ -37,46 +45,48 @@ export type KyselifyBetter<T extends Table> = Simplify<{
? InferJson<T, Key>
: T['_']['columns'][Key]['dataType'] extends 'boolean'
? InferBool<T, Key>
: ColumnType<
InferSelectModel<
T,
{
dbColumnNames: true;
}
>[MapColumnName<Key, T['_']['columns'][Key], true>],
MapColumnName<
Key,
T['_']['columns'][Key],
true
> extends keyof InferInsertModel<
T,
{
dbColumnNames: true;
}
>
? InferInsertModel<
T,
{
dbColumnNames: true;
}
>[MapColumnName<Key, T['_']['columns'][Key], true>]
: never,
MapColumnName<
Key,
T['_']['columns'][Key],
true
> extends keyof InferInsertModel<
T,
{
dbColumnNames: true;
}
>
? InferInsertModel<
T,
{
dbColumnNames: true;
}
>[MapColumnName<Key, T['_']['columns'][Key], true>]
: never
>;
: T['_']['columns'][Key]['dataType'] extends 'date'
? InferDateMs<T, Key>
: ColumnType<
InferSelectModel<
T,
{
dbColumnNames: true;
}
>[MapColumnName<Key, T['_']['columns'][Key], true>],
MapColumnName<
Key,
T['_']['columns'][Key],
true
> extends keyof InferInsertModel<
T,
{
dbColumnNames: true;
}
>
? InferInsertModel<
T,
{
dbColumnNames: true;
}
>[MapColumnName<Key, T['_']['columns'][Key], true>]
: never,
MapColumnName<
Key,
T['_']['columns'][Key],
true
> extends keyof InferInsertModel<
T,
{
dbColumnNames: true;
}
>
? InferInsertModel<
T,
{
dbColumnNames: true;
}
>[MapColumnName<Key, T['_']['columns'][Key], true>]
: never
>;
}>;

View File

@@ -1,9 +1,11 @@
import type { TupleToUnion } from '@tunarr/types';
import { inArray } from 'drizzle-orm';
import { check, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import type { Updateable } from 'kysely';
import { type Insertable, type Selectable } from 'kysely';
import type { StrictOmit } from 'ts-essentials';
import { type KyselifyBetter } from './KyselifyBetter.ts';
import type { MediaSourceName } from './base.ts';
import { type MediaSourceId } from './base.ts';
export const MediaSourceTypes = ['plex', 'jellyfin', 'emby'] as const;
@@ -22,13 +24,13 @@ export const MediaSourceType: MediaSourceMap = {
export const MediaSource = sqliteTable(
'media_source',
{
uuid: text().primaryKey(),
uuid: text().primaryKey().$type<MediaSourceId>(),
createdAt: integer(),
updatedAt: integer(),
accessToken: text().notNull(),
clientIdentifier: text(),
index: integer().notNull(),
name: text().notNull(),
name: text().notNull().$type<MediaSourceName>(),
sendChannelUpdates: integer({ mode: 'boolean' }).default(false),
sendGuideUpdates: integer({ mode: 'boolean' }).default(false),
type: text({ enum: MediaSourceTypes }).notNull(),
@@ -63,20 +65,63 @@ export const MediaSourceFields: (keyof MediaSourceTable)[] = [
export type MediaSourceTable = KyselifyBetter<typeof MediaSource>;
export type MediaSource = Selectable<MediaSourceTable>;
export type NewMediaSource = Insertable<MediaSourceTable>;
export type MediaSourceUpdate = Updateable<MediaSourceTable>;
export type SpecificMediaSourceType<Typ extends MediaSourceType> = StrictOmit<
MediaSource,
'type'
> & {
type: Typ;
};
export const MediaLibraryTypes = [
'movies',
'shows',
'music_videos',
'other_videos',
'tracks',
] as const;
export type PlexMediaSource = SpecificMediaSourceType<
typeof MediaSourceType.Plex
>;
export type JellyfinMediaSource = SpecificMediaSourceType<
typeof MediaSourceType.Jellyfin
>;
export type EmbyMediaSource = SpecificMediaSourceType<
typeof MediaSourceType.Emby
>;
export type MediaLibraryType = TupleToUnion<typeof MediaLibraryTypes>;
export const MediaSourceLibrary = sqliteTable(
'media_source_library',
{
uuid: text().primaryKey().notNull(),
name: text().notNull(),
mediaType: text({ enum: MediaLibraryTypes }).notNull(),
mediaSourceId: text()
.references(() => MediaSource.uuid, { onDelete: 'cascade' })
.notNull()
.$type<MediaSourceId>(),
lastScannedAt: integer({ mode: 'timestamp_ms' }),
externalKey: text().notNull(),
enabled: integer({ mode: 'boolean' }).default(false).notNull(),
},
(table) => [
check(
'media_type_check',
inArray(table.mediaType, table.mediaType.enumValues).inlineParams(),
),
],
);
export const MediaSourceLibraryColumns: (keyof MediaSourceLibraryTable)[] = [
'enabled',
'externalKey',
'lastScannedAt',
'mediaSourceId',
'mediaType',
'uuid',
'name',
];
export type MediaSourceLibraryTable = KyselifyBetter<typeof MediaSourceLibrary>;
export type MediaSourceLibrary = Selectable<MediaSourceLibraryTable>;
export type NewMediaSourceLibrary = Insertable<MediaSourceLibraryTable>;
export type MediaSourceLibraryUpdate = Updateable<MediaSourceLibraryTable>;
export const MediaSourceLibraryReplacePath = sqliteTable(
'media_source_library_replace_path',
{
uuid: text().primaryKey().notNull(),
serverPath: text().notNull(),
localPath: text().notNull(),
mediaSourceId: text()
.notNull()
.references(() => MediaSource.uuid, { onDelete: 'cascade' }),
},
);

View File

@@ -1,4 +1,3 @@
import { createExternalId } from '@tunarr/shared';
import type { TupleToUnion } from '@tunarr/types';
import { inArray } from 'drizzle-orm';
import {
@@ -12,8 +11,14 @@ import {
import type { Insertable, Selectable, Updateable } from 'kysely';
import type { MarkNotNilable } from '../../types/util.ts';
import { type KyselifyBetter } from './KyselifyBetter.ts';
import { MediaSource, MediaSourceTypes } from './MediaSource.ts';
import {
MediaSource,
MediaSourceLibrary,
MediaSourceTypes,
} from './MediaSource.ts';
import { ProgramGrouping } from './ProgramGrouping.ts';
import type { MediaSourceName } from './base.ts';
import { type MediaSourceId } from './base.ts';
export const ProgramTypes = [
'movie',
@@ -41,14 +46,18 @@ export const Program = sqliteTable(
albumUuid: text().references(() => ProgramGrouping.uuid),
artistName: text(),
artistUuid: text().references(() => ProgramGrouping.uuid),
canonicalId: text(),
duration: integer().notNull(),
episode: integer(),
episodeIcon: text(),
externalKey: text().notNull(),
externalSourceId: text().notNull(),
mediaSourceId: text().references(() => MediaSource.uuid, {
onDelete: 'cascade',
}),
externalSourceId: text().notNull().$type<MediaSourceName>(),
mediaSourceId: text()
.references(() => MediaSource.uuid, {
onDelete: 'cascade',
})
.$type<MediaSourceId>(),
libraryId: text().references(() => MediaSourceLibrary.uuid),
filePath: text(),
grandparentExternalKey: text(),
icon: text(),
@@ -77,6 +86,11 @@ export const Program = sqliteTable(
uniqueIndex(
'program_source_type_external_source_id_external_key_unique',
).on(table.sourceType, table.externalSourceId, table.externalKey),
uniqueIndex('program_source_type_media_source_external_key_unique').on(
table.sourceType,
table.mediaSourceId,
table.externalKey,
),
check(
'program_type_check',
inArray(table.type, table.type.enumValues).inlineParams(),
@@ -85,17 +99,15 @@ export const Program = sqliteTable(
'program_source_type_check',
inArray(table.sourceType, table.sourceType.enumValues).inlineParams(),
),
index('program_canonical_id_index').on(table.canonicalId),
],
);
export type ProgramTable = KyselifyBetter<typeof Program>;
export type ProgramDao = Selectable<ProgramTable>;
// Make canonicalId required on insert.
export type NewProgramDao = MarkNotNilable<
Insertable<ProgramTable>,
'mediaSourceId'
'canonicalId' | 'mediaSourceId'
>;
export type ProgramDaoUpdate = Updateable<ProgramTable>;
export function programExternalIdString(p: ProgramDao | NewProgramDao) {
return createExternalId(p.sourceType, p.externalSourceId, p.externalKey);
}

View File

@@ -11,6 +11,7 @@ import type { Insertable, Selectable } from 'kysely';
import { omit } from 'lodash-es';
import type { MarkRequired, StrictOmit } from 'ts-essentials';
import type { MarkNotNilable } from '../../types/util.ts';
import type { MediaSourceId, MediaSourceName } from './base.ts';
import { ProgramExternalIdSourceTypes } from './base.ts';
import { type KyselifyBetter } from './KyselifyBetter.ts';
import { MediaSource } from './MediaSource.ts';
@@ -25,10 +26,12 @@ export const ProgramExternalId = sqliteTable(
directFilePath: text(),
externalFilePath: text(),
externalKey: text().notNull(),
externalSourceId: text(),
mediaSourceId: text().references(() => MediaSource.uuid, {
onDelete: 'cascade',
}),
externalSourceId: text().$type<MediaSourceName>(),
mediaSourceId: text()
.references(() => MediaSource.uuid, {
onDelete: 'cascade',
})
.$type<MediaSourceId>(),
programUuid: text()
.notNull()
.references(() => Program.uuid, { onDelete: 'cascade' }),
@@ -94,6 +97,7 @@ export const ProgramExternalIdKeys: (keyof ProgramExternalId)[] = [
'externalSourceId',
'programUuid',
'sourceType',
'mediaSourceId',
// 'updatedAt',
'uuid',
];

View File

@@ -9,11 +9,12 @@ import {
text,
} from 'drizzle-orm/sqlite-core';
import type { Insertable, Selectable, Updateable } from 'kysely';
import type { MarkRequiredNotNull } from '../../types/util.ts';
import { type KyselifyBetter } from './KyselifyBetter.ts';
import { MediaSourceLibrary } from './MediaSource.ts';
import type { ProgramGroupingTable as RawProgramGrouping } from './ProgramGrouping.ts';
export const ProgramGroupingType: Readonly<
Record<Capitalize<ProgramGroupingType>, ProgramGroupingType>
> = {
export const ProgramGroupingType = {
Show: 'show',
Season: 'season',
Artist: 'artist',
@@ -33,16 +34,18 @@ export const ProgramGrouping = sqliteTable(
'program_grouping',
{
uuid: text().primaryKey(),
canonicalId: text(),
createdAt: integer(),
updatedAt: integer(),
artistUuid: text().references((): AnySQLiteColumn => ProgramGrouping.uuid),
icon: text(),
index: integer(),
showUuid: text().references((): AnySQLiteColumn => ProgramGrouping.uuid),
summary: text(),
title: text().notNull(),
type: text({ enum: ProgramGroupingTypes }).notNull(),
year: integer(),
artistUuid: text().references((): AnySQLiteColumn => ProgramGrouping.uuid),
showUuid: text().references((): AnySQLiteColumn => ProgramGrouping.uuid),
libraryId: text().references(() => MediaSourceLibrary.uuid),
},
(table) => [
index('program_grouping_show_uuid_index').on(table.showUuid),
@@ -56,5 +59,40 @@ export const ProgramGrouping = sqliteTable(
export type ProgramGroupingTable = KyselifyBetter<typeof ProgramGrouping>;
export type ProgramGrouping = Selectable<ProgramGroupingTable>;
export type NewProgramGrouping = Insertable<ProgramGroupingTable>;
export type NewProgramGrouping = MarkRequiredNotNull<
Insertable<ProgramGroupingTable>,
'canonicalId' | 'libraryId'
>;
export type ProgramGroupingUpdate = Updateable<ProgramGroupingTable>;
const ProgramGroupingKeys: (keyof RawProgramGrouping)[] = [
'artistUuid',
'createdAt',
'icon',
'index',
'showUuid',
'summary',
'title',
'type',
'updatedAt',
'uuid',
'year',
];
// TODO move this definition to the ProgramGrouping DAO file
export const AllProgramGroupingFields: ProgramGroupingFields =
ProgramGroupingKeys.map((key) => `programGrouping.${key}` as const);
export const AllProgramGroupingFieldsAliased = <Alias extends string>(
alias: Alias,
): ProgramGroupingFields<Alias> =>
ProgramGroupingKeys.map((key) => `${alias}.${key}` as const);
export const MinimalProgramGroupingFields: ProgramGroupingFields = [
'programGrouping.uuid',
'programGrouping.title',
'programGrouping.year',
// 'programGrouping.index',
];
export type ProgramGroupingFields<Alias extends string = 'programGrouping'> =
readonly `${Alias}.${keyof RawProgramGrouping}`[];

View File

@@ -10,9 +10,10 @@ import type { Insertable, Selectable } from 'kysely';
import { omit } from 'lodash-es';
import type { StrictOmit } from 'ts-essentials';
import type { MarkNotNilable } from '../../types/util.ts';
import type { MediaSourceId, MediaSourceName } from './base.ts';
import { ProgramExternalIdSourceTypes } from './base.ts';
import { type KyselifyBetter } from './KyselifyBetter.ts';
import { MediaSource } from './MediaSource.ts';
import { MediaSource, MediaSourceLibrary } from './MediaSource.ts';
import { ProgramGrouping } from './ProgramGrouping.ts';
export const ProgramGroupingExternalId = sqliteTable(
@@ -23,10 +24,12 @@ export const ProgramGroupingExternalId = sqliteTable(
updatedAt: integer(),
externalFilePath: text(),
externalKey: text().notNull(),
externalSourceId: text(),
mediaSourceId: text().references(() => MediaSource.uuid, {
onDelete: 'cascade',
}),
externalSourceId: text().$type<MediaSourceName>(),
mediaSourceId: text()
.references(() => MediaSource.uuid, {
onDelete: 'cascade',
})
.$type<MediaSourceId>(),
groupUuid: text()
.notNull()
.references(() => ProgramGrouping.uuid, {
@@ -34,6 +37,9 @@ export const ProgramGroupingExternalId = sqliteTable(
onUpdate: 'cascade',
}),
sourceType: text({ enum: ProgramExternalIdSourceTypes }).notNull(),
libraryId: text().references(() => MediaSourceLibrary.uuid, {
onDelete: 'cascade',
}),
},
(table) => [
index('program_grouping_group_uuid_index').on(table.groupUuid),
@@ -63,7 +69,7 @@ export type NewSingleOrMultiProgramGroupingExternalId =
> & { type: 'multi' });
export function toInsertableProgramGroupingExternalId(
eid: NewSingleOrMultiProgramGroupingExternalId,
eid: NewProgramGroupingExternalId | NewSingleOrMultiProgramGroupingExternalId,
): NewProgramGroupingExternalId {
return omit(eid, 'type') satisfies NewProgramGroupingExternalId;
}
@@ -74,13 +80,13 @@ export type ProgramGroupingExternalIdFields<
export const ProgramGroupingExternalIdKeys: (keyof ProgramGroupingExternalId)[] =
[
// 'createdAt',
'createdAt',
'externalFilePath',
'externalKey',
'externalSourceId',
'sourceType',
'groupUuid',
// 'updatedAt',
'updatedAt',
'uuid',
];

View File

@@ -1,4 +1,4 @@
import { type TupleToUnion } from '@tunarr/types';
import { type Tag, type TupleToUnion } from '@tunarr/types';
import {
ContentProgramTypeSchema,
ResolutionSchema,
@@ -120,3 +120,6 @@ export const ChannelOfflineSettingsSchema = z.object({
export type ChannelOfflineSettings = z.infer<
typeof ChannelOfflineSettingsSchema
>;
export type MediaSourceId = Tag<string, 'mediaSourceId'>;
export type MediaSourceName = Tag<string, 'mediaSourceName'>;

View File

@@ -8,7 +8,10 @@ import type {
} from './Channel.ts';
import type { CustomShowContentTable, CustomShowTable } from './CustomShow.js';
import type { FillerShowContentTable, FillerShowTable } from './FillerShow.js';
import type { MediaSourceTable } from './MediaSource.ts';
import type {
MediaSourceLibraryTable,
MediaSourceTable,
} from './MediaSource.ts';
import type { MikroOrmMigrationsTable } from './MikroOrmMigrations.js';
import type { ProgramTable } from './Program.ts';
import type { ProgramExternalIdTable } from './ProgramExternalId.ts';
@@ -34,6 +37,7 @@ export interface DB {
fillerShow: FillerShowTable;
fillerShowContent: FillerShowContentTable;
mediaSource: MediaSourceTable;
mediaSourceLibrary: MediaSourceLibraryTable;
program: ProgramTable;
programExternalId: ProgramExternalIdTable;
programGrouping: ProgramGroupingTable;

View File

@@ -3,10 +3,25 @@ import type { MarkNonNullable } from '@/types/util.js';
import type { DeepNullable, MarkRequired, StrictOmit } from 'ts-essentials';
import type { Channel, ChannelFillerShow } from './Channel.ts';
import type { FillerShow } from './FillerShow.ts';
import type { ProgramDao } from './Program.ts';
import type { MinimalProgramExternalId } from './ProgramExternalId.ts';
import type { ProgramGrouping } from './ProgramGrouping.ts';
import type { ProgramGroupingExternalId } from './ProgramGroupingExternalId.ts';
import type {
MediaSource,
MediaSourceLibrary,
MediaSourceType,
} from './MediaSource.ts';
import type { NewProgramDao, ProgramDao, ProgramType } from './Program.ts';
import type {
MinimalProgramExternalId,
NewSingleOrMultiExternalId,
} from './ProgramExternalId.ts';
import type {
NewProgramGrouping,
ProgramGrouping,
ProgramGroupingType,
} from './ProgramGrouping.ts';
import type {
NewSingleOrMultiProgramGroupingExternalId,
ProgramGroupingExternalId,
} from './ProgramGroupingExternalId.ts';
import type { ChannelSubtitlePreferences } from './SubtitlePreferences.ts';
export type ProgramWithRelations = ProgramDao & {
@@ -18,6 +33,39 @@ export type ProgramWithRelations = ProgramDao & {
externalIds?: MinimalProgramExternalId[];
};
export type SpecificProgramGroupingType<
Typ extends ProgramGroupingType,
ProgramGroupingT = ProgramGrouping,
> = StrictOmit<ProgramGroupingT, 'type'> & { type: Typ };
export type SpecificProgramType<
Typ extends ProgramType,
ProgramT extends { type: ProgramType } = ProgramDao,
> = StrictOmit<ProgramT, 'type'> & { type: Typ };
export type MovieProgram = SpecificProgramType<'movie'> & {
externalIds: MinimalProgramExternalId[];
};
export type TvSeason = SpecificProgramGroupingType<'season'> & {
externalIds: ProgramGroupingExternalId[];
};
export type TvShow = SpecificProgramGroupingType<'show'> & {
externalIds: ProgramGroupingExternalId[];
};
export type EpisodeProgram = SpecificProgramType<'episode'> & {
tvSeason: TvSeason;
tvShow: TvShow;
externalIds: MinimalProgramExternalId[];
};
export type EpisodeProgramWithRelations = EpisodeProgram & {
tvShow: ProgramGroupingWithExternalIds;
tvSeason: ProgramGroupingWithExternalIds;
};
export type ChannelWithRelations = Channel & {
programs?: ProgramWithRelations[];
fillerContent?: ProgramWithRelations[];
@@ -58,6 +106,21 @@ export type ProgramWithExternalIds = ProgramDao & {
externalIds: MinimalProgramExternalId[];
};
export type NewProgramWithExternalIds = NewProgramDao & {
externalIds: NewSingleOrMultiExternalId[];
};
export type NewMovieProgram = SpecificProgramType<'movie', NewProgramDao> & {
externalIds: NewSingleOrMultiExternalId[];
};
export type NewEpisodeProgram = SpecificProgramType<
'episode',
NewProgramDao
> & {
externalIds: NewSingleOrMultiExternalId[];
};
export type ProgramGroupingWithExternalIds = ProgramGrouping & {
externalIds: ProgramGroupingExternalId[];
};
@@ -96,3 +159,55 @@ export type GeneralizedProgramGroupingWithExternalIds =
| TvSeasonWithExternalIds
| MusicAlbumWithExternalIds
| MusicArtistWithExternalIds;
type WithNewGroupingExternalIds = {
externalIds: NewSingleOrMultiProgramGroupingExternalId[];
};
export type NewProgramGroupingWithExternalIds = NewProgramGrouping &
WithNewGroupingExternalIds;
export type NewTvShow = SpecificProgramGroupingType<
'show',
NewProgramGrouping
> &
WithNewGroupingExternalIds;
export type NewTvSeason = SpecificProgramGroupingType<
'season',
NewProgramGrouping
> &
WithNewGroupingExternalIds;
export type NewMusicArtist = SpecificProgramGroupingType<
'artist',
NewProgramGrouping
> &
WithNewGroupingExternalIds;
export type NewMusicAlbum = SpecificProgramGroupingType<
'album',
NewProgramGrouping
> &
WithNewGroupingExternalIds;
export type NewMusicTrack = SpecificProgramType<'track', NewProgramDao> & {
externalIds: NewSingleOrMultiExternalId[];
};
export type MediaSourceWithLibraries = MediaSource & {
libraries: MediaSourceLibrary[];
};
export type SpecificMediaSourceType<Typ extends MediaSourceType> = StrictOmit<
MediaSourceWithLibraries,
'type'
> & {
type: Typ;
};
export type PlexMediaSource = SpecificMediaSourceType<
typeof MediaSourceType.Plex
>;
export type JellyfinMediaSource = SpecificMediaSourceType<
typeof MediaSourceType.Jellyfin
>;
export type EmbyMediaSource = SpecificMediaSourceType<
typeof MediaSourceType.Emby
>;

View File

@@ -0,0 +1,30 @@
import type {
EpisodeProgram,
MovieProgram,
NewEpisodeProgram,
NewMovieProgram,
NewProgramWithExternalIds,
ProgramWithExternalIds,
} from './derivedTypes.js';
export function isMovieProgram(p: ProgramWithExternalIds): p is MovieProgram {
return p.type === 'movie' && !!p.externalIds;
}
export function isNewMovieProgram(
p: NewProgramWithExternalIds,
): p is NewMovieProgram {
return p.type === 'movie';
}
export function isEpisodeProgram(
p: ProgramWithExternalIds,
): p is EpisodeProgram {
return p.type === 'movie' && !!p.externalIds;
}
export function isNewEpisodeProgram(
p: NewProgramWithExternalIds,
): p is NewEpisodeProgram {
return p.type === 'movie';
}

View File

@@ -4,6 +4,7 @@ import { configureAxiosLogging } from '@/util/axios.js';
import { isDefined, isNodeError } from '@/util/index.js';
import type { Logger } from '@/util/logging/LoggerFactory.js';
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
import { type TupleToUnion } from '@tunarr/types';
import type { MediaSourceUnhealthyStatus } from '@tunarr/types/api';
import type {
AxiosHeaderValue,
@@ -11,54 +12,75 @@ import type {
AxiosRequestConfig,
} from 'axios';
import axios, { isAxiosError } from 'axios';
import { isError, isString } from 'lodash-es';
import type { Duration } from 'dayjs/plugin/duration.js';
import { has, isError, isString } from 'lodash-es';
import PQueue from 'p-queue';
import type { StrictOmit } from 'ts-essentials';
import { z } from 'zod/v4';
import type { MediaSourceWithLibraries } from '../db/schema/derivedTypes.js';
import { WrappedError } from '../types/errors.ts';
import { Result } from '../types/result.ts';
export type ApiClientOptions = {
name: string;
mediaSourceUuid?: string;
accessToken: string;
url: string;
userId: string | null;
username: string | null;
mediaSource: StrictOmit<
MediaSourceWithLibraries,
| 'createdAt'
| 'updatedAt'
| 'clientIdentifier'
| 'index'
| 'sendChannelUpdates'
| 'sendGuideUpdates'
>;
extraHeaders?: {
[key: string]: AxiosHeaderValue;
};
enableRequestCache?: boolean;
queueOpts?: {
concurrency: number;
interval: Duration;
};
};
export type QuerySuccessResult<T> = {
type: 'success';
data: T;
export type RemoteMediaSourceOptions = ApiClientOptions & {
apiKey: string;
};
type QueryErrorCode =
| 'not_found'
| 'no_access_token'
| 'parse_error'
| 'generic_request_error';
const QueryErrorCodes = [
'not_found',
'no_access_token',
'parse_error',
'generic_request_error',
] as const;
type QueryErrorCode = TupleToUnion<typeof QueryErrorCodes>;
export type QueryErrorResult = {
type: 'error';
code: QueryErrorCode;
message?: string;
};
export abstract class QueryError extends WrappedError {
readonly type: QueryErrorCode;
export type QueryResult<T> = QuerySuccessResult<T> | QueryErrorResult;
static isQueryError(e: unknown): e is QueryError {
return (
has(e, 'type') &&
isString(e.type) &&
QueryErrorCodes.some((x) => x === e.type)
);
}
export function isQueryError(x: QueryResult<unknown>): x is QueryErrorResult {
return x.type === 'error';
static genericQueryError(message?: string): QueryError {
return this.create('generic_request_error', message);
}
static create(type: QueryErrorCode, message?: string): QueryError {
return new (class extends QueryError {
type = type;
})(message);
}
}
export function isQuerySuccess<T>(
x: QueryResult<T>,
): x is QuerySuccessResult<T> {
return x.type === 'success';
}
export type QueryResult<T> = Result<T, QueryError>;
export abstract class BaseApiClient<
OptionsType extends ApiClientOptions = ApiClientOptions,
> {
private queue?: PQueue;
protected logger: Logger;
protected axiosInstance: AxiosInstance;
protected redacter?: AxiosRequestRedacter;
@@ -66,12 +88,13 @@ export abstract class BaseApiClient<
constructor(protected options: OptionsType) {
this.logger = LoggerFactory.child({
className: this.constructor.name,
serverName: options.name,
serverName: options.mediaSource.name,
});
const url = options.url.endsWith('/')
? options.url.slice(0, options.url.length - 1)
: options.url;
const url = options.mediaSource.uri.endsWith('/')
? options.mediaSource.uri.slice(0, options.mediaSource.uri.length - 1)
: options.mediaSource.uri;
this.options.mediaSource.uri = url;
this.axiosInstance = axios.create({
baseURL: url,
@@ -81,9 +104,20 @@ export abstract class BaseApiClient<
},
});
if (options.queueOpts) {
this.queue = new PQueue({
concurrency: options.queueOpts.concurrency,
interval: options.queueOpts.interval.asMilliseconds(),
});
}
configureAxiosLogging(this.axiosInstance, this.logger);
}
setApiClientOptions(opts: OptionsType) {
this.options = opts;
}
async doTypeCheckedGet<T extends z.ZodType, Out = z.infer<T>>(
path: string,
schema: T,
@@ -120,28 +154,23 @@ export abstract class BaseApiClient<
return this.makeErrorResult('parse_error');
}
protected preRequestValidate(
protected preRequestValidate<T>(
_req: AxiosRequestConfig,
): Maybe<QueryErrorResult> {
): Maybe<QueryResult<T>> {
return;
}
protected makeErrorResult(
code: QueryErrorCode,
protected makeErrorResult<T>(
type: QueryErrorCode,
message?: string,
): QueryErrorResult {
return {
type: 'error',
code,
message,
};
): QueryResult<T> {
return Result.failure<T, QueryError>(
QueryError.create(type, message ?? 'Unknown Error'),
);
}
protected makeSuccessResult<T>(data: T): QuerySuccessResult<T> {
return {
type: 'success',
data,
};
protected makeSuccessResult<T>(data: T): QueryResult<T> {
return Result.success<T, QueryError>(data);
}
doGet<T>(req: Omit<AxiosRequestConfig, 'method'>) {
@@ -162,13 +191,17 @@ export abstract class BaseApiClient<
getFullUrl(path: string): string {
const sanitizedPath = path.startsWith('/') ? path : `/${path}`;
const url = new URL(`${this.options.url}${sanitizedPath}`);
const url = new URL(`${this.options.mediaSource.uri}${sanitizedPath}`);
return url.toString();
}
protected async doRequest<T>(req: AxiosRequestConfig): Promise<T> {
try {
const response = await this.axiosInstance.request<T>(req);
const response = await (this.queue
? this.queue.add(() => this.axiosInstance.request<T>(req), {
throwOnTimeout: true,
})
: this.axiosInstance.request<T>(req));
return response.data;
} catch (error) {
if (isAxiosError(error)) {
@@ -185,7 +218,7 @@ export abstract class BaseApiClient<
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
this.logger.warn(
'API client response error: path: %O, status %d, params: %O, data: %O, headers: %O',
'API client response error: path: %s, status %d, params: %O, data: %O, headers: %O',
error.config?.url ?? '',
status,
error.config?.params ?? {},
@@ -214,7 +247,7 @@ export abstract class BaseApiClient<
// At this point we have no idea what the object is... attempt to log
// and just return a generic error. Something is probably fatally wrong
// at this point.
this.logger.error('Unknown error type thrown: %O', error);
this.logger.error(error, 'Unknown error type thrown: %O');
throw new Error('Unknown error', { cause: error });
}
}
@@ -245,4 +278,10 @@ export abstract class BaseApiClient<
return status;
}
protected findMatchingLibrary(externalLibraryId: string) {
return this.options.mediaSource.libraries.find(
(lib) => lib.externalKey === externalLibraryId,
);
}
}

View File

@@ -0,0 +1,52 @@
import type { EmbyItem } from '@tunarr/types/emby';
import type { JellyfinItem } from '@tunarr/types/jellyfin';
import type { PlexMedia } from '@tunarr/types/plex';
import { ContainerModule } from 'inversify';
import type { Canonicalizer } from '../services/Canonicalizer.ts';
import { KEYS } from '../types/inject.ts';
import { bindFactoryFunc } from '../util/inject.ts';
import type { MediaSourceApiClientFactory } from './MediaSourceApiClient.ts';
import { EmbyApiClient } from './emby/EmbyApiClient.ts';
import { JellyfinApiClient } from './jellyfin/JellyfinApiClient.ts';
import type { PlexApiClientFactory } from './plex/PlexApiClient.ts';
import { PlexApiClient } from './plex/PlexApiClient.ts';
export const ExternalApiModule = new ContainerModule((bind) => {
bindFactoryFunc<PlexApiClientFactory>(
bind,
KEYS.PlexApiClientFactory,
(ctx) => {
return (opts) =>
new PlexApiClient(
ctx.container.get<Canonicalizer<PlexMedia>>(KEYS.PlexCanonicalizer),
opts,
);
},
);
bindFactoryFunc<MediaSourceApiClientFactory<JellyfinApiClient>>(
bind,
KEYS.JellyfinApiClientFactory,
(ctx) => {
return (opts) =>
new JellyfinApiClient(
ctx.container.get<Canonicalizer<JellyfinItem>>(
KEYS.JellyfinCanonicalizer,
),
opts,
);
},
);
bindFactoryFunc<MediaSourceApiClientFactory<EmbyApiClient>>(
bind,
KEYS.EmbyApiClientFactory,
(ctx) => {
return (opts) =>
new EmbyApiClient(
ctx.container.get<Canonicalizer<EmbyItem>>(KEYS.EmbyCanonicalizer),
opts,
);
},
);
});

View File

@@ -0,0 +1,120 @@
import type { ProgramType } from '../db/schema/Program.ts';
import type { ProgramGroupingType } from '../db/schema/ProgramGrouping.ts';
import type {
Episode,
Movie,
MusicAlbum,
MusicArtist,
MusicTrack,
Season,
Show,
} from '../types/Media.ts';
import type { ApiClientOptions, QueryResult } from './BaseApiClient.ts';
import { BaseApiClient } from './BaseApiClient.ts';
export type ProgramTypeMap<
MovieType extends Movie = Movie,
ShowType extends Show = Show,
SeasonType extends Season<ShowType> = Season<ShowType>,
EpisodeType extends Episode<ShowType, SeasonType> = Episode<
ShowType,
SeasonType
>,
ArtistType extends MusicArtist = MusicArtist,
AlbumType extends MusicAlbum<ArtistType> = MusicAlbum<ArtistType>,
TrackType extends MusicTrack<ArtistType, AlbumType> = MusicTrack<
ArtistType,
AlbumType
>,
> = {
[ProgramType.Movie]: MovieType;
[ProgramGroupingType.Show]: ShowType;
[ProgramGroupingType.Season]: SeasonType;
[ProgramType.Episode]: EpisodeType;
[ProgramGroupingType.Artist]: ArtistType;
[ProgramGroupingType.Album]: AlbumType;
[ProgramType.Track]: TrackType;
};
export type ExtractMediaType<
Client extends MediaSourceApiClient,
Key extends keyof ProgramTypeMap,
> =
Client extends MediaSourceApiClient<infer ProgramMapType, any>
? ProgramMapType[Key]
: never;
export type ExtractShowType<Client extends MediaSourceApiClient> =
ExtractMediaType<Client, 'show'> extends MusicArtist
? ExtractMediaType<Client, 'show'>
: never;
export type MediaSourceApiClientFactory<
Type extends MediaSourceApiClient,
OptsType extends ApiClientOptions = Type extends MediaSourceApiClient<
any,
infer Opts
>
? Opts
: ApiClientOptions,
> = (opts: OptsType) => Type;
export abstract class MediaSourceApiClient<
ProgramTypes extends ProgramTypeMap = ProgramTypeMap,
OptionsType extends ApiClientOptions = ApiClientOptions,
> extends BaseApiClient<OptionsType> {
abstract getMovieLibraryContents(
libraryId: string,
pageSize?: number,
): AsyncIterable<ProgramTypes['movie']>;
abstract getMovie(
externalKey: string,
): Promise<QueryResult<ProgramTypes['movie']>>;
abstract getTvShowLibraryContents(
libraryId: string,
pageSize?: number,
): AsyncIterable<ProgramTypes['show']>;
abstract getShow(
externalKey: string,
): Promise<QueryResult<ProgramTypes['show']>>;
abstract getShowSeasons(
externalKey: string,
pageSize?: number,
): AsyncIterable<ProgramTypes['season']>;
abstract getSeasonEpisodes(
seasonKey: string,
pageSize?: number,
): AsyncIterable<ProgramTypes['episode']>;
abstract getSeason(
externalKey: string,
): Promise<QueryResult<ProgramTypes['season']>>;
abstract getEpisode(
externalKey: string,
): Promise<QueryResult<ProgramTypes['episode']>>;
abstract getMusicLibraryContents(
libraryId: string,
pageSize: number,
): AsyncIterable<ProgramTypes['artist']>;
abstract getArtistAlbums(
artistKey: string,
pageSize: number,
): AsyncIterable<ProgramTypes['album']>;
abstract getAlbumTracks(
albumKey: string,
pageSize: number,
): AsyncIterable<ProgramTypes['track']>;
abstract getMusicTrack(
key: string,
): Promise<QueryResult<ProgramTypes['track']>>;
}

View File

@@ -8,20 +8,18 @@ import dayjs from 'dayjs';
import { inject, injectable, LazyServiceIdentifier } from 'inversify';
import { forEach, isBoolean, isEmpty, isNil } from 'lodash-es';
import NodeCache from 'node-cache';
import { MarkRequired } from 'ts-essentials';
import type { ISettingsDB } from '../db/interfaces/ISettingsDB.ts';
import { MediaSourceId } from '../db/schema/base.ts';
import { MediaSourceWithLibraries } from '../db/schema/derivedTypes.js';
import { KEYS } from '../types/inject.ts';
import { Result } from '../types/result.ts';
import { cacheGetOrSet } from '../util/cache.ts';
import { Logger } from '../util/logging/LoggerFactory.ts';
import {
isQueryError,
type ApiClientOptions,
type BaseApiClient,
} from './BaseApiClient.js';
import { type ApiClientOptions } from './BaseApiClient.js';
import { EmbyApiClient } from './emby/EmbyApiClient.ts';
import { JellyfinApiClient } from './jellyfin/JellyfinApiClient.js';
import { PlexApiClient } from './plex/PlexApiClient.js';
import { MediaSourceApiClientFactory } from './MediaSourceApiClient.ts';
import { PlexApiClient, PlexApiClientFactory } from './plex/PlexApiClient.js';
type TypeToClient = [
[typeof MediaSourceType.Plex, PlexApiClient],
@@ -45,6 +43,12 @@ export class MediaSourceApiFactory {
@inject(new LazyServiceIdentifier(() => MediaSourceDB))
private mediaSourceDB: MediaSourceDB,
@inject(KEYS.SettingsDB) private settings: ISettingsDB,
@inject(KEYS.PlexApiClientFactory)
private plexApiClientFactory: PlexApiClientFactory,
@inject(KEYS.JellyfinApiClientFactory)
private jellyfinApiClientFactory: MediaSourceApiClientFactory<JellyfinApiClient>,
@inject(KEYS.EmbyApiClientFactory)
private embyApiClientFactory: MediaSourceApiClientFactory<EmbyApiClient>,
) {
this.#requestCacheEnabled =
settings.systemSettings().cache?.enablePlexRequestCache ?? false;
@@ -60,88 +64,95 @@ export class MediaSourceApiFactory {
});
}
getJellyfinApiClientForMediaSource(mediaSource: MediaSource) {
return this.getJellyfinApiClient(mediaSourceToApiOptions(mediaSource));
getJellyfinApiClientForMediaSource(mediaSource: MediaSourceWithLibraries) {
return this.getJellyfinApiClient({ mediaSource });
}
getJellyfinApiClient(opts: ApiClientOptions) {
return this.getTyped(MediaSourceType.Jellyfin, opts, (opts) => {
return Promise.resolve(new JellyfinApiClient(opts));
});
getJellyfinApiClient(opts: ApiClientOptions): Promise<JellyfinApiClient> {
const client = this.jellyfinApiClientFactory(opts);
client.setApiClientOptions(opts);
return Promise.resolve(client);
}
getEmbyApiClientForMediaSource(mediaSource: MediaSource) {
return this.getEmbyApiClient(mediaSourceToApiOptions(mediaSource));
getEmbyApiClientForMediaSource(mediaSource: MediaSourceWithLibraries) {
return this.getEmbyApiClient({ mediaSource });
}
getEmbyApiClient(opts: ApiClientOptions) {
return this.getTyped(MediaSourceType.Jellyfin, opts, async (opts) => {
let userId = opts.userId;
let username: Maybe<string>;
if (isEmpty(userId)) {
this.logger.warn(
'Emby connection does not have a user ID set. This could lead to errors. Please reconnect Emby.',
);
const adminResult = await Result.attemptAsync(() =>
EmbyApiClient.findAdminUser(opts, opts.accessToken),
);
async getEmbyApiClient(opts: ApiClientOptions) {
let userId = opts.mediaSource.userId;
let username: Maybe<string>;
if (isEmpty(userId)) {
this.logger.warn(
'Emby connection does not have a user ID set. This could lead to errors. Please reconnect Emby.',
);
const adminResult = await Result.attemptAsync(() =>
EmbyApiClient.findAdminUser(opts, opts.mediaSource.accessToken),
);
adminResult
.filter((res) => isNonEmptyString(res?.Id))
.forEach((adminUser) => {
userId = adminUser!.Id!;
username = adminUser!.Name ?? undefined;
});
}
adminResult
.filter((res) => isNonEmptyString(res?.Id))
.forEach((adminUser) => {
userId = adminUser!.Id!;
username = adminUser!.Name ?? undefined;
});
}
if (
isNonEmptyString(opts.mediaSourceUuid) &&
(isEmpty(opts.userId) ||
opts.userId !== userId ||
isEmpty(opts.username) ||
opts.username != username)
) {
this.mediaSourceDB
.setMediaSourceUserInfo(opts.mediaSourceUuid, {
userId: userId ?? undefined,
username,
})
.catch((e) => {
this.logger.error(
e,
'Error updating Jellyfin media source user info',
);
});
}
if (
isNonEmptyString(opts.mediaSource.uuid) &&
(isEmpty(opts.mediaSource.userId) ||
opts.mediaSource.userId !== userId ||
isEmpty(opts.mediaSource.username) ||
opts.mediaSource.username != username)
) {
this.mediaSourceDB
.setMediaSourceUserInfo(opts.mediaSource.uuid, {
userId: userId ?? undefined,
username,
})
.catch((e) => {
this.logger.error(
e,
'Error updating Jellyfin media source user info',
);
});
}
return new EmbyApiClient({ ...opts, userId });
return this.embyApiClientFactory({
...opts,
mediaSource: { ...opts.mediaSource, userId },
});
}
getPlexApiClientForMediaSource(
mediaSource: MediaSource,
mediaSource: MediaSourceWithLibraries,
): Promise<PlexApiClient> {
const opts = mediaSourceToApiOptions(mediaSource);
return this.getPlexApiClient(opts);
// const opts = mediaSourceToApiOptions(mediaSource);
return this.getPlexApiClient({ mediaSource });
}
getPlexApiClient(opts: ApiClientOptions): Promise<PlexApiClient> {
const key = `${opts.url}|${opts.accessToken}`;
return cacheGetOrSet(MediaSourceApiFactory.cache, key, () => {
return Promise.resolve(
new PlexApiClient({
...opts,
enableRequestCache: this.requestCacheEnabledForServer(opts.name),
}),
);
});
// const key = `${opts.url}|${opts.accessToken}`;
// const client = await cacheGetOrSet(MediaSourceApiFactory.cache, key, () => {
// return Promise.resolve(
// ,
// );
// });
// client.setApiClientOptions(opts);
// return client;
return Promise.resolve(
this.plexApiClientFactory({
...opts,
enableRequestCache: this.requestCacheEnabledForServer(
opts.mediaSource.name,
),
}),
);
}
async getPlexApiClientByName(name: string) {
async getPlexApiClientById(name: MediaSourceId) {
return this.getTypedByName(MediaSourceType.Plex, name, (mediaSource) => {
const client = new PlexApiClient({
...mediaSource,
url: mediaSource.uri,
const client = this.plexApiClientFactory({
mediaSource,
enableRequestCache: this.requestCacheEnabledForServer(mediaSource.name),
});
@@ -154,29 +165,25 @@ export class MediaSourceApiFactory {
});
}
async getJellyfinApiClientByName(name: string, userId?: string) {
return this.getTypedByName(
MediaSourceType.Jellyfin,
name,
(opts) =>
new JellyfinApiClient({
async getJellyfinApiClientById(name: MediaSourceId, userId?: string) {
return this.getTypedByName(MediaSourceType.Jellyfin, name, (opts) =>
this.jellyfinApiClientFactory({
mediaSource: {
...opts,
url: opts.uri,
userId: opts.userId ?? userId ?? null,
}),
},
}),
);
}
async getEmbyApiClientByName(name: string, userId?: string) {
return this.getTypedByName(
MediaSourceType.Emby,
name,
(opts) =>
new EmbyApiClient({
async getEmbyApiClientById(name: MediaSourceId, userId?: string) {
return this.getTypedByName(MediaSourceType.Emby, name, (opts) =>
this.embyApiClientFactory({
mediaSource: {
...opts,
url: opts.uri,
userId: opts.userId ?? userId ?? null,
}),
},
}),
);
}
@@ -185,34 +192,13 @@ export class MediaSourceApiFactory {
return MediaSourceApiFactory.cache.del(key) === 1;
}
private async getTyped<
Typ extends MediaSourceType,
ApiClient = FindChild<Typ, TypeToClient>,
ApiClientOptionsT extends
ApiClientOptions = ApiClient extends BaseApiClient<infer Opts>
? Opts extends ApiClientOptions
? Opts
: never
: never,
>(
typ: Typ,
opts: ApiClientOptionsT,
factory: (opts: ApiClientOptionsT) => Promise<ApiClient>,
): Promise<ApiClient> {
return await cacheGetOrSet<ApiClient>(
MediaSourceApiFactory.cache,
this.getCacheKey(typ, opts.url, opts.accessToken),
() => factory(opts),
);
}
private async getTypedByName<
X extends MediaSourceType,
ApiClient = FindChild<X, TypeToClient>,
>(
type: X,
name: string,
factory: (opts: MediaSource) => ApiClient,
name: MediaSourceId,
factory: (opts: MediaSourceWithLibraries) => ApiClient,
): Promise<Maybe<ApiClient>> {
const key = `${type}|${name}`;
return cacheGetOrSet<Maybe<ApiClient>>(
@@ -247,19 +233,21 @@ export class MediaSourceApiFactory {
}
private async backfillPlexUserId(
mediaSourceId: string,
mediaSourceId: MediaSourceId,
client: PlexApiClient,
) {
this.logger.debug('Attempting to backfill Plex user');
const result = await Result.attemptAsync(async () => {
const user = await client.getUser();
if (isQueryError(user)) {
throw new Error(user.message);
const userResult = await client.getUser();
if (userResult.isFailure()) {
throw userResult.error;
}
const user = userResult.get();
await this.mediaSourceDB.setMediaSourceUserInfo(mediaSourceId, {
userId: user.data.id?.toString(),
username: user.data.username,
userId: user.id?.toString(),
username: user.username,
});
});
if (result.isFailure()) {
@@ -270,13 +258,3 @@ export class MediaSourceApiFactory {
}
}
}
export function mediaSourceToApiOptions(
mediaSource: MediaSource,
): MarkRequired<ApiClientOptions, 'mediaSourceUuid'> {
return {
...mediaSource,
url: mediaSource.uri,
mediaSourceUuid: mediaSource.uuid,
};
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,21 @@
import { ProgramMinterFactory } from '@/db/converters/ProgramMinter.js';
import { ProgramDaoMinter } from '@/db/converters/ProgramMinter.js';
import type { IProgramDB } from '@/db/interfaces/IProgramDB.js';
import { ProgramType } from '@/db/schema/Program.js';
import type { ProgramWithExternalIds } from '@/db/schema/derivedTypes.js';
import { isQueryError } from '@/external/BaseApiClient.js';
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
import { GlobalScheduler } from '@/services/Scheduler.js';
import { ReconcileProgramDurationsTask } from '@/tasks/ReconcileProgramDurationsTask.js';
import { KEYS } from '@/types/inject.js';
import { Maybe } from '@/types/util.js';
import { groupByUniq, isDefined, run } from '@/util/index.js';
import { groupByUniq, isDefined, isNonEmptyString, run } from '@/util/index.js';
import { type Logger } from '@/util/logging/LoggerFactory.js';
import { JellyfinItem, JellyfinItemKind } from '@tunarr/types/jellyfin';
import { inject, injectable } from 'inversify';
import {
inject,
injectable,
interfaces,
LazyServiceIdentifier,
} from 'inversify';
import { find, isUndefined, some } from 'lodash-es';
import { match } from 'ts-pattern';
import { container } from '../../container.ts';
@@ -21,6 +25,7 @@ import {
} from '../../db/custom_types/ProgramExternalIdType.ts';
import { MediaSourceDB } from '../../db/mediaSourceDB.ts';
import { MediaSourceType } from '../../db/schema/MediaSource.ts';
import { MediaSourceId } from '../../db/schema/base.ts';
import { ReconcileProgramDurationsTaskFactory } from '../../tasks/TasksModule.ts';
import { JellyfinGetItemsQuery } from './JellyfinApiClient.ts';
@@ -29,9 +34,11 @@ export class JellyfinItemFinder {
constructor(
@inject(KEYS.ProgramDB) private programDB: IProgramDB,
@inject(KEYS.Logger) private logger: Logger,
@inject(MediaSourceApiFactory)
@inject(new LazyServiceIdentifier(() => MediaSourceApiFactory))
private mediaSourceApiFactory: MediaSourceApiFactory,
@inject(MediaSourceDB) private mediaSourceDB: MediaSourceDB,
@inject(KEYS.ProgramDaoMinterFactory)
private programMinterFactory: interfaces.AutoFactory<ProgramDaoMinter>,
) {}
async findForProgramAndUpdate(programId: string) {
@@ -53,7 +60,7 @@ export class JellyfinItemFinder {
(eid) => eid.sourceType === ProgramExternalIdType.JELLYFIN,
);
const minter = ProgramMinterFactory.create();
const minter = this.programMinterFactory();
const newExternalId = minter.mintJellyfinExternalIdForApiItem(
program.externalSourceId,
program.uuid,
@@ -69,25 +76,41 @@ export class JellyfinItemFinder {
// Right now just check if the durations are different.
// otherwise we might blow away details we already have, since
// Jellyfin collects metadata asynchronously (sometimes)
const mediaSourceId =
program.mediaSourceId ??
(await run(async () => {
const ms = await this.findMediaSource(program.externalSourceId);
if (!ms)
throw new Error(
`Could not find media source by name: ${program.externalSourceId}`,
);
return ms.uuid;
}));
const updatedProgram = minter.mint(
program.externalSourceId,
mediaSourceId,
{
sourceType: 'jellyfin',
program: potentialApiMatch,
},
const mediaSource = await run(async () => {
if (!isNonEmptyString(program.mediaSourceId)) {
throw new Error(`Program ${program.uuid} has no media source ID`);
}
const ms = await this.findMediaSource(program.mediaSourceId);
if (!ms)
throw new Error(
`Could not find media source by name: ${program.externalSourceId}`,
);
return ms;
});
if (!program.libraryId) {
throw new Error(
'Cannot find JF item match without a library ID. Consider syncing the library the missing item belongs to.',
);
}
const library = mediaSource.libraries.find(
(lib) => lib.uuid === program.libraryId,
);
if (!library) {
throw new Error(
`Cannot find matching library for program. Library ID = ${program.libraryId}. Maybe the library was deleted?`,
);
}
const updatedProgram = minter.mint(mediaSource, library, {
sourceType: 'jellyfin',
program: potentialApiMatch,
});
if (updatedProgram.duration !== program.duration) {
await this.programDB.updateProgramDuration(
program.uuid,
@@ -121,22 +144,29 @@ export class JellyfinItemFinder {
return;
}
const jfClient =
await this.mediaSourceApiFactory.getJellyfinApiClientByName(
program.externalSourceId,
if (!isNonEmptyString(program.mediaSourceId)) {
this.logger.error(
'Program %s does not have an associated media source ID',
program.uuid,
);
return;
}
const jfClient = await this.mediaSourceApiFactory.getJellyfinApiClientById(
program.mediaSourceId,
);
if (!jfClient) {
this.logger.error(
"Couldn't get jellyfin api client for id: %s",
program.externalSourceId,
program.mediaSourceId,
);
return;
}
// If we can locate the item on JF, there is no problem.
const existingItem = await jfClient.getItem(program.externalKey);
if (!isQueryError(existingItem) && isDefined(existingItem.data)) {
if (existingItem.isSuccess() && isDefined(existingItem.get())) {
this.logger.error(
existingItem,
'Item exists on Jellyfin - no need to find a new match',
@@ -181,7 +211,7 @@ export class JellyfinItemFinder {
.with(ProgramType.OtherVideo, () => 'Video')
.exhaustive();
const queryResult = await jfClient.getItems(
const queryResult = await jfClient.getRawItems(
null,
[jellyfinItemType],
[],
@@ -189,22 +219,24 @@ export class JellyfinItemFinder {
opts,
);
if (queryResult.type === 'success') {
return find(queryResult.data.Items, (match) =>
some(
match.ProviderIds,
(val, key) =>
programExternalIdTypeFromJellyfinProvider(key) === type &&
val === idsBySourceType[type].externalKey,
),
);
} else {
this.logger.error(
{ error: queryResult },
'Error while querying items on Jellyfin',
);
}
return queryResult.either(
(data) => {
return find(data.Items, (match) =>
some(
match.ProviderIds,
(val, key) =>
programExternalIdTypeFromJellyfinProvider(key) === type &&
val === idsBySourceType[type].externalKey,
),
);
},
(err) => {
this.logger.error(err, 'Error while querying items on Jellyfin');
return undefined;
},
);
}
return;
};
@@ -231,10 +263,10 @@ export class JellyfinItemFinder {
return possibleMatch;
}
private findMediaSource(mediaSourceName: string) {
private findMediaSource(mediaSourceId: MediaSourceId) {
return this.mediaSourceDB.findByType(
MediaSourceType.Jellyfin,
mediaSourceName,
mediaSourceId,
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,4 @@
import type { QueryResult } from '@/external/BaseApiClient.js';
import { isQueryError, isQuerySuccess } from '@/external/BaseApiClient.js';
import { isDefined } from '@/util/index.js';
import NodeCache from 'node-cache';
@@ -44,7 +43,7 @@ export class PlexQueryCache {
}
const value = await getter();
if (isQuerySuccess(value) || (isQueryError(value) && opts?.setOnError)) {
if (value.isSuccess() || opts?.setOnError) {
this.#cache.set(key, value);
}

View File

@@ -11,8 +11,8 @@ import { exec, spawn } from 'node:child_process';
import events from 'node:events';
import fs from 'node:fs/promises';
import path from 'node:path';
import type { WritableOptions } from 'node:stream';
import stream from 'node:stream';
import type stream from 'node:stream';
import { LastNBytesStream } from '../util/LastNBytesStream.ts';
export type FfmpegEvents = {
// Emitted when the process ended with a code === 0, i.e. it exited
@@ -258,52 +258,3 @@ export class FfmpegProcess extends (events.EventEmitter as new () => TypedEventE
return this.ffmpegArgs;
}
}
type LastNBytesStreamOpts = WritableOptions & {
bufSizeBytes?: number;
};
class LastNBytesStream extends stream.Writable {
public bufSizeBytes!: number;
#bytesWritten = 0;
#buf: Buffer;
constructor(options?: LastNBytesStreamOpts) {
super(options);
this.bufSizeBytes = options?.bufSizeBytes ?? 1024;
this.#buf = Buffer.alloc(this.bufSizeBytes);
}
_write(
chunk: Buffer,
_encoding: BufferEncoding,
callback: (error?: Error | null) => void,
): void {
const chunkLength = chunk.length;
if (chunkLength >= this.bufSizeBytes) {
// If the chunk is larger than or equal to the buffer, just take the last 1KB
chunk.copy(this.#buf, 0, chunkLength - this.bufSizeBytes, chunkLength);
this.#bytesWritten = this.bufSizeBytes;
} else {
// If the chunk is smaller, shift existing buffer content and append
const remainingSpace = this.bufSizeBytes - this.#bytesWritten;
if (chunkLength <= remainingSpace) {
// Chunk fits in the remaining space
chunk.copy(this.#buf, this.#bytesWritten);
this.#bytesWritten += chunkLength;
} else {
// Chunk doesn't fit completely, overwrite from the beginning
chunk.copy(this.#buf, this.#bytesWritten, 0, remainingSpace);
chunk.copy(this.#buf, 0, remainingSpace, chunkLength);
this.#bytesWritten = this.bufSizeBytes;
}
}
callback();
}
getLastN() {
return this.#buf.subarray(0, this.#bytesWritten);
}
}

View File

@@ -77,7 +77,7 @@ export class SubtitleStreamPicker {
if (stream.languageCodeISO6392 !== pref.languageCode) {
this.logger.debug(
'Skipping subtitle index %d, not a language match',
stream.index,
stream.index ?? -1,
);
continue;
}
@@ -86,13 +86,13 @@ export class SubtitleStreamPicker {
if (pref.filterType === 'forced' && !stream.forced) {
this.logger.debug(
'Skipping subtitle index %d, wanted forced',
stream.index,
stream.index ?? -1,
);
continue;
} else if (pref.filterType === 'default' && !stream.default) {
this.logger.debug(
'Skipping subtitle index %d, wanted default',
stream.index,
stream.index ?? -1,
);
continue;
}
@@ -101,7 +101,7 @@ export class SubtitleStreamPicker {
if (!pref.allowExternal && stream.type === 'external') {
this.logger.debug(
'Skipping subtitle index %d, disallowed external',
stream.index,
stream.index ?? -1,
);
continue;
}
@@ -109,7 +109,7 @@ export class SubtitleStreamPicker {
if (!pref.allowImageBased && isImageBasedSubtitle(stream.codec)) {
this.logger.debug(
'Skipping subtitle index %d, disallowed image-based',
stream.index,
stream.index ?? -1,
);
continue;
}
@@ -150,9 +150,9 @@ export class SubtitleStreamPicker {
if (!filePath) {
this.logger.debug(
'Unsupported subtitle codec at index %d: %s',
stream.index,
stream.codec,
'Unsupported subtitle codec at index %d: codec = %s',
stream.index ?? -1,
stream.codec ?? 'unkonwn',
);
return;
}
@@ -161,7 +161,7 @@ export class SubtitleStreamPicker {
if (!(await fileExists(fullPath))) {
this.logger.debug(
'Subtitle stream at index %d has not been extracted yet.',
stream.index,
stream.index ?? -1,
);
return;
}

View File

@@ -158,10 +158,7 @@ export class PipelineBuilderContext {
merge(this, props);
}
isSubtitleOverlay(): this is MarkRequired<
PipelineBuilderContext,
'subtitleStream'
> {
isSubtitleOverlay(): boolean {
return (
(this.subtitleStream?.isImageBased &&
this.subtitleStream?.method === SubtitleMethods.Burn) ??
@@ -169,10 +166,7 @@ export class PipelineBuilderContext {
);
}
isSubtitleTextContext(): this is MarkRequired<
PipelineBuilderContext,
'subtitleStream'
> {
isSubtitleTextContext(): boolean {
return (
(this.subtitleStream &&
!this.subtitleStream.isImageBased &&
@@ -524,7 +518,9 @@ export abstract class BasePipelineBuilder implements PipelineBuilder {
this.desiredState.videoFormat !== VideoFormats.Copy
) {
this.decoder = this.setupDecoder();
this.logger.debug('Setup decoder: %O', this.decoder);
if (this.decoder) {
this.logger.debug('Setup decoder: %O', this.decoder);
}
}
this.setRealtime();

View File

@@ -33,6 +33,8 @@ import Migration1746123876_ReworkSubtitleFilter from './db/Migration1746123876_R
import Migration1746128022_FixSubtitlePriorityType from './db/Migration1746128022_FixSubtitlePriorityType.ts';
import Migration1748345299_AddMoreProgramTypes from './db/Migration1748345299_AddMoreProgramTypes.ts';
import Migration1756312561_InitialAdvancedTranscodeConfig from './db/Migration1756312561_InitialAdvancedTranscodeConfig.ts';
import Migration1756381281_AddLibraries from './db/Migration1756381281_AddLibraries.ts';
import Migration1757704591_AddProgramMediaSourceIndex from './db/Migration1757704591_AddProgramMediaSourceIndex.ts';
export const LegacyMigrationNameToNewMigrationName = [
['Migration20240124115044', '_Legacy_Migration00'],
@@ -113,6 +115,8 @@ export class DirectMigrationProvider implements MigrationProvider {
migration1748345299: Migration1748345299_AddMoreProgramTypes,
migration1756312561:
Migration1756312561_InitialAdvancedTranscodeConfig,
migration1756381281: Migration1756381281_AddLibraries,
migration1757704591: Migration1757704591_AddProgramMediaSourceIndex,
},
wrapWithTransaction,
),

View File

@@ -0,0 +1,14 @@
import { type Kysely } from 'kysely';
import { processSqlMigrationFile } from './util.ts';
export default {
fullCopy: true,
async up(db: Kysely<unknown>) {
for (const statement of await processSqlMigrationFile(
'./sql/0010_lazy_nova.sql',
)) {
await db.executeQuery(statement);
}
},
};

View File

@@ -0,0 +1,14 @@
import { type Kysely } from 'kysely';
import { processSqlMigrationFile } from './util.ts';
export default {
// fullCopy: true,
async up(db: Kysely<unknown>) {
for (const statement of await processSqlMigrationFile(
'./sql/0011_stormy_stark_industries.sql',
)) {
await db.executeQuery(statement);
}
},
};

View File

@@ -0,0 +1,26 @@
CREATE TABLE `media_source_library` (
`uuid` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`media_type` text NOT NULL,
`media_source_id` text NOT NULL,
`last_scanned_at` integer,
`external_key` text NOT NULL,
`enabled` integer DEFAULT false NOT NULL,
FOREIGN KEY (`media_source_id`) REFERENCES `media_source`(`uuid`) ON UPDATE no action ON DELETE cascade,
CONSTRAINT "media_type_check" CHECK("media_source_library"."media_type" in ('movies', 'shows', 'music_videos', 'other_videos', 'tracks'))
);
--> statement-breakpoint
CREATE TABLE `media_source_library_replace_path` (
`uuid` text PRIMARY KEY NOT NULL,
`server_path` text NOT NULL,
`local_path` text NOT NULL,
`media_source_id` text NOT NULL,
FOREIGN KEY (`media_source_id`) REFERENCES `media_source`(`uuid`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
ALTER TABLE `program` ADD `canonical_id` text;--> statement-breakpoint
ALTER TABLE `program` ADD `library_id` text REFERENCES media_source_library(uuid);--> statement-breakpoint
CREATE INDEX `program_canonical_id_index` ON `program` (`canonical_id`);--> statement-breakpoint
ALTER TABLE `program_grouping` ADD `canonical_id` text;--> statement-breakpoint
ALTER TABLE `program_grouping` ADD `library_id` text REFERENCES media_source_library(uuid);--> statement-breakpoint
ALTER TABLE `program_grouping_external_id` ADD `library_id` text REFERENCES media_source_library(uuid);

View File

@@ -0,0 +1,26 @@
CREATE TABLE `media_source_library` (
`uuid` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`media_type` text NOT NULL,
`media_source_id` text NOT NULL,
`last_scanned_at` integer,
`external_key` text NOT NULL,
`enabled` integer DEFAULT false NOT NULL,
FOREIGN KEY (`media_source_id`) REFERENCES `media_source`(`uuid`) ON UPDATE no action ON DELETE cascade,
CONSTRAINT "media_type_check" CHECK("media_source_library"."media_type" in ('movies', 'shows', 'music_videos', 'other_videos', 'tracks'))
);
--> statement-breakpoint
CREATE TABLE `media_source_library_replace_path` (
`uuid` text PRIMARY KEY NOT NULL,
`server_path` text NOT NULL,
`local_path` text NOT NULL,
`media_source_id` text NOT NULL,
FOREIGN KEY (`media_source_id`) REFERENCES `media_source`(`uuid`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
ALTER TABLE `program` ADD `canonical_id` text;--> statement-breakpoint
ALTER TABLE `program` ADD `library_id` text REFERENCES media_source_library(uuid);--> statement-breakpoint
CREATE INDEX `program_canonical_id_index` ON `program` (`canonical_id`);--> statement-breakpoint
ALTER TABLE `program_grouping` ADD `canonical_id` text;--> statement-breakpoint
ALTER TABLE `program_grouping` ADD `library_id` text REFERENCES media_source_library(uuid);--> statement-breakpoint
ALTER TABLE `program_grouping_external_id` ADD `library_id` text REFERENCES media_source_library(uuid);

View File

@@ -0,0 +1 @@
CREATE UNIQUE INDEX `program_source_type_media_source_external_key_unique` ON `program` (`source_type`,`media_source_id`,`external_key`);

Some files were not shown because too many files have changed in this diff Show More