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: on:
workflow_dispatch: workflow_dispatch:
inputs:
base_ref:
description: "Ref to build pre-release from"
required: true
default: 'dev'
push: push:
branches: branches:
- media-scanner - media-scanner

View File

@@ -60,4 +60,5 @@ jobs:
echo ${{ steps.semantic.outputs.new_release_major_version }} echo ${{ steps.semantic.outputs.new_release_major_version }}
echo ${{ steps.semantic.outputs.new_release_minor_version }} echo ${{ steps.semantic.outputs.new_release_minor_version }}
echo ${{ steps.semantic.outputs.new_release_patch_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-import-resolver-typescript": "^3.7.0",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.31.0",
"eslint-plugin-react": "^7.37.3", "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-react-refresh": "^0.4.16",
"eslint-plugin-unused-imports": "^4.1.4", "eslint-plugin-unused-imports": "^4.1.4",
"globals": "^15.0.0", "globals": "^15.0.0",
@@ -48,7 +48,6 @@
"packageManager": "pnpm@9.12.3", "packageManager": "pnpm@9.12.3",
"pnpm": { "pnpm": {
"patchedDependencies": { "patchedDependencies": {
"ts-essentials@9.4.1": "patches/ts-essentials@9.4.1.patch",
"kysely": "patches/kysely.patch" "kysely": "patches/kysely.patch"
}, },
"overrides": { "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({ export default defineConfig({
dialect: 'sqlite', dialect: 'sqlite',
schema: './src/db/schema/**/*.ts', schema: './src/db/schema/**/*.ts',
out: './src/migration/db/sql',
casing: 'snake_case', casing: 'snake_case',
dbCredentials: { dbCredentials: {
url: process.env.TUNARR_DATABASE_PATH, 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 ? new (require('url'.replace('', '')).URL)('file:' + __filename).href
: (document.currentScript && document.currentScript.src) || : (document.currentScript && document.currentScript.src) ||
new URL('main.js', document.baseURI).href; 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-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", "build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 tsc -p tsconfig.build.json",
"bundle": "dotenv -- tsx scripts/bundle.ts", "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", "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", "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", "generate-openapi": "tsx src/index.ts generate-openapi",
"install-meilisearch": "tsx scripts/download-meilisearch.ts",
"kysely": "dotenv -e .env.development -- kysely", "kysely": "dotenv -e .env.development -- kysely",
"make-bin": "dotenv -- tsx scripts/make-bin.ts",
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
"run-fixer": "dotenv -e .env.development -- tsx src/index.ts fixer", "run-fixer": "dotenv -e .env.development -- tsx src/index.ts fixer",
"test:watch": "vitest --watch", "test:watch": "vitest --watch",
@@ -26,99 +27,106 @@
"typecheck": "tsc -p tsconfig.build.json --noEmit" "typecheck": "tsc -p tsconfig.build.json --noEmit"
}, },
"dependencies": { "dependencies": {
"@dotenvx/dotenvx": "^1.45.1", "@dotenvx/dotenvx": "^1.49.0",
"@fastify/cors": "^10.0.1", "@fastify/cors": "^10.1.0",
"@fastify/error": "^4.1.0", "@fastify/error": "^4.2.0",
"@fastify/multipart": "^9.0.1", "@fastify/multipart": "^9.0.3",
"@fastify/static": "^8.0.1", "@fastify/static": "^8.2.0",
"@fastify/swagger": "^9.5.1", "@fastify/swagger": "^9.5.1",
"@iptv/xmltv": "^1.0.1", "@iptv/xmltv": "^1.0.1",
"@logdna/tail-file": "^4.0.2", "@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/playlist": "^1.1.0",
"@tunarr/shared": "workspace:*", "@tunarr/shared": "workspace:*",
"@tunarr/types": "workspace:*", "@tunarr/types": "workspace:*",
"@types/better-sqlite3": "^7.6.12", "@types/better-sqlite3": "^7.6.13",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"async-mutex": "^0.5.0", "async-mutex": "^0.5.0",
"async-retry": "^1.3.3", "async-retry": "^1.3.3",
"axios": "^1.11.0", "axios": "^1.11.0",
"base32": "^0.0.7",
"better-sqlite3": "11.8.1", "better-sqlite3": "11.8.1",
"chalk": "^5.3.0", "chalk": "^5.6.0",
"cron-parser": "^4.9.0", "cron-parser": "^4.9.0",
"dayjs": "^1.11.10", "dayjs": "^1.11.14",
"drizzle-orm": "^0.39.3", "drizzle-orm": "^0.39.3",
"fast-xml-parser": "^4.3.5", "fast-xml-parser": "^4.5.3",
"fastify": "^5.0.0", "fastify": "^5.5.0",
"fastify-graceful-shutdown": "^4.0.1", "fastify-graceful-shutdown": "^4.0.1",
"fastify-plugin": "^5.0.1", "fastify-plugin": "^5.0.1",
"fastify-print-routes": "^3.2.0", "fastify-print-routes": "^3.2.0",
"fastify-type-provider-zod": "^5.0.3", "fastify-type-provider-zod": "^5.0.3",
"file-type": "^19.6.0", "file-type": "^19.6.0",
"find-process": "^2.0.0",
"graphology": "^0.26.0", "graphology": "^0.26.0",
"graphology-dag": "^0.4.1", "graphology-dag": "^0.4.1",
"inversify": "^6.2.1", "inversify": "^6.2.2",
"jsonpath-plus": "^10.3.0", "jsonpath-plus": "^10.3.0",
"kysely": "^0.27.4", "kysely": "^0.27.6",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lowdb": "^7.0.0", "lowdb": "^7.0.1",
"meilisearch": "^0.49.0",
"node-cache": "^5.1.2", "node-cache": "^5.1.2",
"node-schedule": "^2.1.1", "node-schedule": "^2.1.1",
"node-ssdp": "^4.0.0", "node-ssdp": "^4.0.1",
"p-queue": "^8.0.1", "p-queue": "^8.1.0",
"pino": "^9.0.0", "pino": "^9.9.1",
"pino-pretty": "^11.2.2", "pino-pretty": "^11.3.0",
"pino-roll": "^1.1.0", "pino-roll": "^1.3.0",
"random-js": "2.1.0", "random-js": "2.1.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"retry": "^0.13.1", "retry": "^0.13.1",
"split2": "^4.2.0", "split2": "^4.2.0",
"ts-pattern": "^5.4.0", "ts-pattern": "^5.8.0",
"tslib": "^2.6.2", "tslib": "^2.8.1",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"yargs": "^17.7.2", "yargs": "^17.7.2",
"zod": "^4.0.17" "zod": "^4.1.5"
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "^9.9.0", "@faker-js/faker": "^9.9.0",
"@octokit/types": "^13.10.0",
"@rollup/plugin-swc": "^0.4.0", "@rollup/plugin-swc": "^0.4.0",
"@types/archiver": "^6.0.2", "@types/archiver": "^6.0.3",
"@types/async-retry": "^1.4.8", "@types/async-retry": "^1.4.9",
"@types/lodash-es": "4.17.9", "@types/lodash-es": "4.17.9",
"@types/node": "22.10.7", "@types/node": "22.10.7",
"@types/node-abi": "^3.0.3", "@types/node-abi": "^3.0.3",
"@types/node-schedule": "^2.1.3", "@types/node-schedule": "^2.1.8",
"@types/retry": "^0.12.5", "@types/retry": "^0.12.5",
"@types/split2": "^4.2.3", "@types/split2": "^4.2.3",
"@types/tmp": "^0.2.6", "@types/tmp": "^0.2.6",
"@types/unzip-stream": "^0.3.4", "@types/unzip-stream": "^0.3.4",
"@types/uuid": "^9.0.6", "@types/uuid": "^9.0.8",
"@types/yargs": "^17.0.29", "@types/yargs": "^17.0.33",
"@vitest/coverage-v8": "^3.2.4", "@vitest/coverage-v8": "^3.2.4",
"@yao-pkg/pkg": "^6.5.1", "@yao-pkg/pkg": "^6.6.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"del-cli": "^3.0.0", "del-cli": "^3.0.1",
"dotenv-cli": "^7.4.1", "dotenv-cli": "^7.4.4",
"drizzle-kit": "^0.30.4", "drizzle-kit": "^0.30.6",
"esbuild-plugin-pino": "^2.2.1", "esbuild-plugin-pino": "^2.3.3",
"fast-check": "^4.2.0", "fast-check": "^4.2.0",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.3",
"globals": "^15.0.0", "globals": "^15.15.0",
"kysely-ctl": "^0.9.0", "kysely-ctl": "^0.9.0",
"node-abi": "^3.74.0", "node-abi": "^3.75.0",
"prettier": "^3.5.1", "prettier": "^3.6.2",
"rimraf": "^5.0.5", "rimraf": "^5.0.10",
"tar": "^7.4.3", "tar": "^7.4.3",
"thread-stream": "^3.1.0", "thread-stream": "^3.1.0",
"tmp": "^0.2.1", "tmp": "^0.2.5",
"tmp-promise": "^3.0.3", "tmp-promise": "^3.0.3",
"ts-essentials": "^10.0.0", "ts-essentials": "^10.1.1",
"ts-mockito": "^2.6.1", "ts-mockito": "^2.6.1",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"tsx": "^4.19.2", "tsx": "^4.20.5",
"typed-emitter": "^2.1.0", "typed-emitter": "^2.1.0",
"typescript": "5.7.3", "typescript": "5.7.3",
"typescript-eslint": "^8.19.0", "typescript-eslint": "^8.41.0",
"vitest": "^3.2.4" "vitest": "^3.2.4"
},
"meilisearch": {
"version": "1.15.2"
} }
} }

View File

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

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 path from 'node:path';
import stream from 'node:stream'; import stream from 'node:stream';
import { format } from 'node:util'; import { format } from 'node:util';
import { rimraf } from 'rimraf';
import * as tar from 'tar'; import * as tar from 'tar';
import tmp from 'tmp-promise'; import tmp from 'tmp-promise';
import yargs from 'yargs'; import yargs from 'yargs';
import { hideBin } from 'yargs/helpers'; import { hideBin } from 'yargs/helpers';
import serverPackage from '../package.json' with { type: 'json' }; import serverPackage from '../package.json' with { type: 'json' };
import { fileExists } from '../src/util/fsUtil.ts'; import { fileExists } from '../src/util/fsUtil.ts';
import { grabMeilisearch } from './download-meilisearch.ts';
const betterSqlite3ReleaseFmt = const betterSqlite3ReleaseFmt =
'https://github.com/WiseLibs/better-sqlite3/releases/download/v%s/better-sqlite3-v%s-node-v%s-%s-%s.tar.gz'; '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', type: 'boolean',
default: true, default: true,
}) })
.option('clean', {
type: 'boolean',
default: false,
})
.parseAsync(); .parseAsync();
!(await fileExists('./bin')) && (await fs.mkdir('./bin')); !(await fileExists('./bin')) && (await fs.mkdir('./bin'));
if (args.clean) {
await rimraf('./bin/tunarr*', { glob: true });
}
(await fileExists('./dist/web')) && (await fileExists('./dist/web')) &&
(await fs.rm('./dist/web', { recursive: true })); (await fs.rm('./dist/web', { recursive: true }));
@@ -87,6 +97,12 @@ await fs.cp(path.resolve(process.cwd(), '../web/dist'), './dist/web', {
recursive: true, recursive: true,
}); });
await fs.cp(
path.resolve(process.cwd(), './src/migration/db/sql'),
'./dist/sql',
{ recursive: true },
);
const originalWorkingDir = process.cwd(); const originalWorkingDir = process.cwd();
console.log(`Going to build archs: ${args.target.join(' ')}`); 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 // Untar
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const outstream = betterSqliteDlStream.data.pipe( const outstream = betterSqliteDlStream.data.pipe(
@@ -164,6 +187,7 @@ for (const arch of args.target) {
// Look into whether we want this sometimes... // Look into whether we want this sometimes...
'--no-bytecode', '--no-bytecode',
'--signature', // for macos arm64 '--signature', // for macos arm64
'--debug',
'-o', '-o',
`dist/bin/${execName}`, `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); const roundedTime = round(rep.elapsedTime, 4);
this.logger[req.routeOptions.config.logAtLevel ?? 'http']( this.logger[req.routeOptions.config.logAtLevel ?? 'http'](
`${req.method} ${req.url} ${rep.statusCode} -${lengthStr}${roundedTime}ms`,
{ {
req: { req: {
method: req.method, method: req.method,
@@ -241,6 +240,7 @@ export class Server {
elapsedTime: roundedTime, elapsedTime: roundedTime,
}, },
}, },
`${req.method} ${req.url} ${rep.statusCode} -${lengthStr}${roundedTime}ms`,
); );
done(); done();
}); });
@@ -463,6 +463,8 @@ export class Server {
this.logger.debug(e, 'Error sending shutdown signal to frontend'); this.logger.debug(e, 'Error sending shutdown signal to frontend');
} }
this.serverContext.searchService.stop();
try { try {
this.logger.debug('Pausing all on-demand channels'); this.logger.debug('Pausing all on-demand channels');
await this.serverContext.onDemandChannelService.pauseAllChannels(); await this.serverContext.onDemandChannelService.pauseAllChannels();

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,15 @@
import { container } from '@/container.js'; 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 { JellyfinItemFinder } from '@/external/jellyfin/JellyfinItemFinder.js';
import type { RouterPluginAsyncCallback } from '@/types/serverType.js'; import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
import type { Nilable } from '@/types/util.js'; import type { Nilable } from '@/types/util.js';
import { tag } from '@tunarr/types';
import { isNil } from 'lodash-es'; import { isNil } from 'lodash-es';
import { v4 } from 'uuid';
import { z } from 'zod/v4'; 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 ( export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
fastify, fastify,
@@ -23,12 +28,19 @@ export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
}, },
}, },
async (req, res) => { async (req, res) => {
const client = new JellyfinApiClient({ const client = container.get<
url: req.query.uri, MediaSourceApiClientFactory<JellyfinApiClient>
accessToken: req.query.apiKey, >(KEYS.JellyfinApiClientFactory)({
userId: req.query.userId ?? null, mediaSource: {
name: 'debug', uri: req.query.uri,
username: null, 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()); await res.send(await client.getUserLibraries());
@@ -54,12 +66,19 @@ export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
}, },
}, },
async (req, res) => { async (req, res) => {
const client = new JellyfinApiClient({ const client = container.get<
url: req.query.uri, MediaSourceApiClientFactory<JellyfinApiClient>
accessToken: req.query.apiKey, >(KEYS.JellyfinApiClientFactory)({
name: 'debug', mediaSource: {
userId: null, uri: req.query.uri,
username: null, 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; let pageParams: Nilable<{ offset: number; limit: number }> = null;
@@ -68,7 +87,7 @@ export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
} }
await res.send( 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); 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 { ProgramSourceType } from '@/db/custom_types/ProgramSourceType.js';
import { PlexStreamDetails } from '@/stream/plex/PlexStreamDetails.js'; import { PlexStreamDetails } from '@/stream/plex/PlexStreamDetails.js';
import type { RouterPluginAsyncCallback } from '@/types/serverType.js'; import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
import { tag } from '@tunarr/types';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
export const DebugPlexApiRouter: RouterPluginAsyncCallback = async ( export const DebugPlexApiRouter: RouterPluginAsyncCallback = async (
@@ -22,14 +23,14 @@ export const DebugPlexApiRouter: RouterPluginAsyncCallback = async (
async (req, res) => { async (req, res) => {
const mediaSource = await req.serverCtx.mediaSourceDB.findByType( const mediaSource = await req.serverCtx.mediaSourceDB.findByType(
'plex', 'plex',
req.query.mediaSource, tag(req.query.mediaSource),
); );
if (!mediaSource) { if (!mediaSource) {
return res.status(400).send('No media source'); return res.status(400).send('No media source');
} }
const program = await req.serverCtx.programDB.lookupByExternalId({ const program = await req.serverCtx.programDB.lookupByExternalId({
externalSourceId: mediaSource.name, externalSourceId: mediaSource.uuid,
externalKey: req.query.key, externalKey: req.query.key,
sourceType: ProgramSourceType.PLEX, sourceType: ProgramSourceType.PLEX,
}); });
@@ -38,16 +39,23 @@ export const DebugPlexApiRouter: RouterPluginAsyncCallback = async (
return res.status(400).send('No program'); 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({ const streamDetails = await container.get(PlexStreamDetails).getStream({
server: mediaSource, server: mediaSource,
lineupItem: { lineupItem: {
...program, ...contentProgram,
programId: program.id!, programId: contentProgram.id,
externalKey: req.query.key, externalKey: req.query.key,
programType: program.subtype, programType: contentProgram.subtype,
externalSource: 'plex', externalSource: 'plex',
duration: program.duration, duration: contentProgram.duration,
externalFilePath: program.serverFilePath, externalFilePath: contentProgram.serverFilePath,
}, },
}); });

View File

@@ -325,5 +325,6 @@ function createStreamItemFromProgram(
contentDuration: program.duration, contentDuration: program.duration,
streamDuration: program.duration, streamDuration: program.duration,
infiniteLoop: false, 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 type { RouterPluginAsyncCallback } from '@/types/serverType.js';
import { enumValues } from '@/util/enumUtil.js'; import { enumValues } from '@/util/enumUtil.js';
import { ifDefined } from '@/util/index.js'; import { ifDefined } from '@/util/index.js';
import { tag } from '@tunarr/types';
import { ChannelLineupQuery } from '@tunarr/types/api'; import { ChannelLineupQuery } from '@tunarr/types/api';
import { ChannelLineupSchema } from '@tunarr/types/schemas'; import { ChannelLineupSchema } from '@tunarr/types/schemas';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@@ -342,7 +343,7 @@ export const debugApi: RouterPluginAsyncCallback = async (fastify) => {
}, },
async (req, res) => { async (req, res) => {
const mediaSource = (await req.serverCtx.mediaSourceDB.getById( const mediaSource = (await req.serverCtx.mediaSourceDB.getById(
req.query.id, tag(req.query.id),
))!; ))!;
const knownProgramIds = await req.serverCtx 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 { MediaSourceType } from '@/db/schema/MediaSource.js';
import { isQueryError } from '@/external/BaseApiClient.js';
import { EmbyApiClient } from '@/external/emby/EmbyApiClient.js'; import { EmbyApiClient } from '@/external/emby/EmbyApiClient.js';
import { TruthyQueryParam } from '@/types/schemas.js'; import { TruthyQueryParam } from '@/types/schemas.js';
import { isDefined, nullToUndefined } from '@/util/index.js'; import { groupByUniq, isDefined, nullToUndefined } from '@/util/index.js';
import { EmbyLoginRequest } from '@tunarr/types/api'; import type { Library } from '@tunarr/types';
import type { EmbyCollectionType } from '@tunarr/types/emby'; import { tag } from '@tunarr/types';
import { EmbyLoginRequest, PagedResult } from '@tunarr/types/api';
import { import {
EmbyItemFields, EmbyItemFields,
EmbyItemKind, EmbyItemKind,
EmbyItemSortBy, EmbyItemSortBy,
EmbyLibraryItemsResponse,
type EmbyLibraryItemsResponse as EmbyLibraryItemsResponseType,
} from '@tunarr/types/emby'; } from '@tunarr/types/emby';
import { ItemOrFolder, Library as LibrarySchema } from '@tunarr/types/schemas';
import type { FastifyReply } from 'fastify/types/reply.js'; 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 { z } from 'zod/v4';
import type { MediaSourceWithLibraries } from '../db/schema/derivedTypes.js';
import { ServerRequestContext } from '../ServerContext.ts';
import type { import type {
RouterPluginCallback, RouterPluginCallback,
ZodFastifyRequest, ZodFastifyRequest,
@@ -25,19 +25,6 @@ const mediaSourceParams = z.object({
mediaSourceId: z.string(), 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[]] { function isNonEmptyTyped<T>(f: T[]): f is [T, ...T[]] {
return !isEmpty(f); return !isEmpty(f);
} }
@@ -83,7 +70,7 @@ export const embyApiRouter: RouterPluginCallback = (fastify, _, done) => {
schema: { schema: {
params: mediaSourceParams, params: mediaSourceParams,
response: { response: {
200: EmbyLibraryItemsResponse, 200: z.array(LibrarySchema),
}, },
}, },
}, },
@@ -94,27 +81,34 @@ export const embyApiRouter: RouterPluginCallback = (fastify, _, done) => {
mediaSource, mediaSource,
); );
const response = await api.getUserViews(); const response = await api.getUserLibraries();
if (isQueryError(response)) { if (response.isFailure()) {
throw new Error(response.message); throw response.error;
} }
const sanitizedResponse: EmbyLibraryItemsResponseType = { // const sanitizedResponse: EmbyLibraryItemsResponseType = {
...response.data, // ...response.get(),
Items: filter(response.data.Items, (library) => { // Items: filter(response.get().Items, (library) => {
// Mixed collections don't have this set // // Mixed collections don't have this set
if (!library.CollectionType) { // if (!library.CollectionType) {
return true; // return true;
} // }
return ValidEmbyCollectionTypes.includes( // return ValidEmbyCollectionTypes.includes(
library.CollectionType as EmbyCollectionType, // 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()), .or(z.array(z.enum(['Artist', 'AlbumArtist'])).optional()),
}), }),
response: { response: {
200: EmbyLibraryItemsResponse, 200: PagedResult(ItemOrFolder.array()),
}, },
}, },
}, },
@@ -201,11 +195,11 @@ export const embyApiRouter: RouterPluginCallback = (fastify, _, done) => {
: ['SortName', 'ProductionYear'], : ['SortName', 'ProductionYear'],
); );
if (isQueryError(response)) { if (response.isFailure()) {
throw new Error(response.message); 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, req: Req,
res: FastifyReply, res: FastifyReply,
cb: (m: MediaSource) => Promise<FastifyReply>, cb: (m: MediaSourceWithLibraries) => Promise<FastifyReply>,
) { ) {
const mediaSource = await req.serverCtx.mediaSourceDB.getById( const mediaSource = await req.serverCtx.mediaSourceDB.getById(
req.params.mediaSourceId, tag(req.params.mediaSourceId),
); );
if (isNil(mediaSource)) { if (isNil(mediaSource)) {
@@ -241,3 +235,42 @@ export const embyApiRouter: RouterPluginCallback = (fastify, _, done) => {
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, params: IdPathParamSchema,
response: { response: {
200: z.void(), 200: z.void(),
404: z.void(),
}, },
}, },
}, },

View File

@@ -116,6 +116,7 @@ export const fillerListsApi: RouterPluginAsyncCallback = async (fastify) => {
body: UpdateFillerListRequestSchema, body: UpdateFillerListRequestSchema,
response: { response: {
200: FillerListSchema, 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 type { FfmpegEncoder } from '@/ffmpeg/ffmpegInfo.js';
import { FfmpegInfo } from '@/ffmpeg/ffmpegInfo.js'; import { FfmpegInfo } from '@/ffmpeg/ffmpegInfo.js';
import { serverOptions } from '@/globals.js'; import { serverOptions } from '@/globals.js';
@@ -10,7 +9,7 @@ import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
import { getTunarrVersion } from '@/util/version.js'; import { getTunarrVersion } from '@/util/version.js';
import { VersionApiResponseSchema } from '@tunarr/types/api'; import { VersionApiResponseSchema } from '@tunarr/types/api';
import { fileTypeFromStream } from 'file-type'; 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 { createReadStream, promises as fsPromises } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
@@ -28,6 +27,7 @@ import { hdhrSettingsRouter } from './hdhrSettingsApi.js';
import { jellyfinApiRouter } from './jellyfinApi.js'; import { jellyfinApiRouter } from './jellyfinApi.js';
import { mediaSourceRouter } from './mediaSourceApi.js'; import { mediaSourceRouter } from './mediaSourceApi.js';
import { metadataApiRouter } from './metadataApi.js'; import { metadataApiRouter } from './metadataApi.js';
import { plexApiRouter } from './plexApi.ts';
import { plexSettingsRouter } from './plexSettingsApi.js'; import { plexSettingsRouter } from './plexSettingsApi.js';
import { programmingApi } from './programmingApi.js'; import { programmingApi } from './programmingApi.js';
import { sessionApiRouter } from './sessionApi.js'; import { sessionApiRouter } from './sessionApi.js';
@@ -62,6 +62,7 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
.register(hdhrSettingsRouter) .register(hdhrSettingsRouter)
.register(systemApiRouter) .register(systemApiRouter)
.register(guideRouter) .register(guideRouter)
.register(plexApiRouter)
.register(jellyfinApiRouter) .register(jellyfinApiRouter)
.register(sessionApiRouter) .register(sessionApiRouter)
.register(embyApiRouter); .register(embyApiRouter);
@@ -142,6 +143,7 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
name: z.string(), name: z.string(),
fileUrl: z.string(), fileUrl: z.string(),
}), }),
400: z.void(),
}, },
}, },
}, },
@@ -225,7 +227,7 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
.header('Content-Type', 'application/xml') .header('Content-Type', 'application/xml')
.send(fileFinal); .send(fileFinal);
} catch (err) { } catch (err) {
logger.error('%O', err); logger.error(err);
return res.status(500).send('error'); return res.status(500).send('error');
} }
}, },
@@ -286,33 +288,4 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
return res.status(204).send(); 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 { MediaSourceType } from '@/db/schema/MediaSource.js';
import { isQueryError } from '@/external/BaseApiClient.js';
import { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js'; import { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
import { TruthyQueryParam } from '@/types/schemas.js'; import { mediaSourceParamsSchema, TruthyQueryParam } from '@/types/schemas.js';
import { inConstArr, isDefined, nullToUndefined } from '@/util/index.js'; import { groupByUniq, isDefined, nullToUndefined } from '@/util/index.js';
import { JellyfinLoginRequest } from '@tunarr/types/api'; import { tag, type Library } from '@tunarr/types';
import type { JellyfinCollectionType } from '@tunarr/types/jellyfin'; import { JellyfinLoginRequest, PagedResult } from '@tunarr/types/api';
import { import {
JellyfinItemFields, JellyfinItemFields,
JellyfinItemKind, JellyfinItemKind,
JellyfinItemSortBy, JellyfinItemSortBy,
JellyfinLibraryItemsResponse, JellyfinLibraryItemsResponse,
TunarrAmendedJellyfinVirtualFolder,
} from '@tunarr/types/jellyfin'; } from '@tunarr/types/jellyfin';
import { ItemOrFolder, Library as LibrarySchema } from '@tunarr/types/schemas';
import type { FastifyReply } from 'fastify/types/reply.js'; import type { FastifyReply } from 'fastify/types/reply.js';
import { isEmpty, isNil, uniq } from 'lodash-es'; import { isEmpty, isNil, uniq } from 'lodash-es';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
import type { MediaSourceWithLibraries } from '../db/schema/derivedTypes.js';
import { ServerRequestContext } from '../ServerContext.ts';
import type { import type {
RouterPluginCallback, RouterPluginCallback,
ZodFastifyRequest, ZodFastifyRequest,
@@ -25,19 +25,6 @@ const mediaSourceParams = z.object({
mediaSourceId: z.string(), 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[]] { function isNonEmptyTyped<T>(f: T[]): f is [T, ...T[]] {
return !isEmpty(f); return !isEmpty(f);
} }
@@ -84,8 +71,7 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
schema: { schema: {
params: mediaSourceParams, params: mediaSourceParams,
response: { response: {
// HACK 200: z.array(LibrarySchema),
200: z.array(TunarrAmendedJellyfinVirtualFolder),
}, },
operationId: 'getJellyfinLibraries', operationId: 'getJellyfinLibraries',
}, },
@@ -99,28 +85,34 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
const response = await api.getUserViews(); const response = await api.getUserViews();
if (isQueryError(response)) { if (response.isFailure()) {
throw new Error(response.message); throw response.error;
} }
return res.send( // const amendedResponse = response
response.data // .get()
.filter((library) => { // .filter((library) => {
// Mixed collections don't have this set // // Mixed collections don't have this set
if (!library.CollectionType) { // if (!library.CollectionType) {
return true; // return true;
} // }
return inConstArr( // return inConstArr(
ValidJellyfinCollectionTypes, // ValidJellyfinCollectionTypes,
library.CollectionType ?? '', // library.CollectionType ?? '',
); // );
}) // })
.map((lib) => ({ // .map(
...lib, // (lib) =>
jellyfinType: 'VirtualFolder', // ({
})), // ...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', '/jellyfin/:mediaSourceId/libraries/:libraryId/genres',
{ {
schema: { schema: {
params: mediaSourceParams.extend({ params: mediaSourceParamsSchema.extend({
libraryId: z.string(), libraryId: z.string(),
}), }),
querystring: z.object({ querystring: z.object({
@@ -152,11 +144,11 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
req.query.includeItemTypes, req.query.includeItemTypes,
); );
if (isQueryError(response)) { if (response.isFailure()) {
throw new Error(response.message); 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', '/jellyfin/:mediaSourceId/libraries/:libraryId/items',
{ {
schema: { schema: {
params: mediaSourceParams.extend({ operationId: 'getJellyfinLibraryItems',
params: mediaSourceParamsSchema.extend({
libraryId: z.string(), libraryId: z.string(),
}), }),
querystring: z.object({ querystring: z.object({
@@ -201,9 +194,8 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
parentId: z.string().optional(), parentId: z.string().optional(),
}), }),
response: { response: {
200: JellyfinLibraryItemsResponse, 200: PagedResult(ItemOrFolder.array()),
}, },
operationId: 'getJellyfinLibraryItems',
}, },
}, },
(req, res) => (req, res) =>
@@ -239,25 +231,25 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
: ['SortName', 'ProductionYear'], : ['SortName', 'ProductionYear'],
); );
if (isQueryError(response)) { if (response.isFailure()) {
throw new Error(response.message); throw response.error;
} }
return res.send(response.data); return res.send(response.get());
}), }),
); );
async function withJellyfinMediaSource< async function withJellyfinMediaSource<
Req extends ZodFastifyRequest<{ Req extends ZodFastifyRequest<{
params: typeof mediaSourceParams; params: typeof mediaSourceParamsSchema;
}>, }>,
>( >(
req: Req, req: Req,
res: FastifyReply, res: FastifyReply,
cb: (m: MediaSource) => Promise<FastifyReply>, cb: (m: MediaSourceWithLibraries) => Promise<FastifyReply>,
) { ) {
const mediaSource = await req.serverCtx.mediaSourceDB.getById( const mediaSource = await req.serverCtx.mediaSourceDB.getById(
req.params.mediaSourceId, tag(req.params.mediaSourceId),
); );
if (isNil(mediaSource)) { if (isNil(mediaSource)) {
@@ -279,3 +271,42 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
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 { GlobalScheduler } from '@/services/Scheduler.js';
import { UpdateXmlTvTask } from '@/tasks/UpdateXmlTvTask.js'; import { UpdateXmlTvTask } from '@/tasks/UpdateXmlTvTask.js';
import type { RouterPluginAsyncCallback } from '@/types/serverType.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 { LoggerFactory } from '@/util/logging/LoggerFactory.js';
import { numberToBoolean } from '@/util/sqliteUtil.js'; import { numberToBoolean } from '@/util/sqliteUtil.js';
import { seq } from '@tunarr/shared/util'; import { seq } from '@tunarr/shared/util';
import type { MediaSourceSettings } from '@tunarr/types'; import {
tag,
type MediaSourceLibrary,
type MediaSourceSettings,
} from '@tunarr/types';
import type { import type {
MediaSourceStatus, MediaSourceStatus,
MediaSourceUnhealthyStatus, MediaSourceUnhealthyStatus,
ScanProgress,
} from '@tunarr/types/api'; } from '@tunarr/types/api';
import { import {
BaseErrorSchema,
BasicIdParamSchema, BasicIdParamSchema,
InsertMediaSourceRequestSchema, InsertMediaSourceRequestSchema,
MediaSourceStatusSchema, MediaSourceStatusSchema,
ScanProgressSchema,
UpdateMediaSourceLibraryRequest,
UpdateMediaSourceRequestSchema, UpdateMediaSourceRequestSchema,
} from '@tunarr/types/api'; } from '@tunarr/types/api';
import { import {
ContentProgramSchema,
ExternalSourceTypeSchema, ExternalSourceTypeSchema,
MediaSourceLibrarySchema,
MediaSourceSettingsSchema, MediaSourceSettingsSchema,
} from '@tunarr/types/schemas'; } 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 { match, P } from 'ts-pattern';
import { v4 } from 'uuid';
import z from 'zod/v4'; 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 ( export const mediaSourceRouter: RouterPluginAsyncCallback = async (
fastify, fastify,
@@ -47,27 +62,13 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
}, },
}, },
async (req, res) => { async (req, res) => {
const entityLocker = container.get<EntityMutex>(EntityMutex);
try { try {
const sources = await req.serverCtx.mediaSourceDB.getAll(); const sources = await req.serverCtx.mediaSourceDB.getAll();
const dtos = seq.collect(sources, (source) => { const dtos = seq.collect(sources, (source) =>
return match(source) convertToApiMediaSource(entityLocker, 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);
});
return res.send(dtos); return res.send(dtos);
} catch (err) { } 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( fastify.get(
'/media-sources/:id/status', '/media-sources/:id/status',
{ {
@@ -94,7 +411,9 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
}, },
async (req, res) => { async (req, res) => {
try { 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)) { if (isNil(server)) {
return res.status(404).send(); return res.status(404).send();
@@ -166,11 +485,15 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
case 'plex': { case 'plex': {
const plex = const plex =
await req.serverCtx.mediaSourceApiFactory.getPlexApiClient({ await req.serverCtx.mediaSourceApiFactory.getPlexApiClient({
...req.body, mediaSource: {
url: req.body.uri, ...req.body,
userId: null, uri: req.body.uri,
username: null, userId: null,
name: req.body.name ?? 'unknown', username: null,
name: tag(req.body.name ?? 'unknown'),
uuid: tag(v4()),
libraries: [],
},
}); });
healthyPromise = plex.checkServerStatus(); healthyPromise = plex.checkServerStatus();
@@ -179,11 +502,15 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
case 'jellyfin': { case 'jellyfin': {
const jellyfin = const jellyfin =
await req.serverCtx.mediaSourceApiFactory.getJellyfinApiClient({ await req.serverCtx.mediaSourceApiFactory.getJellyfinApiClient({
...req.body, mediaSource: {
url: req.body.uri, ...req.body,
userId: null, uri: req.body.uri,
username: null, userId: null,
name: req.body.name ?? 'unknown', username: null,
name: tag(req.body.name ?? 'unknown'),
uuid: tag(v4()),
libraries: [],
},
}); });
healthyPromise = jellyfin.ping(); healthyPromise = jellyfin.ping();
@@ -192,11 +519,15 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
case 'emby': { case 'emby': {
const emby = const emby =
await req.serverCtx.mediaSourceApiFactory.getEmbyApiClient({ await req.serverCtx.mediaSourceApiFactory.getEmbyApiClient({
...req.body, mediaSource: {
url: req.body.uri, ...req.body,
userId: null, uri: req.body.uri,
username: null, userId: null,
name: req.body.name ?? 'unknown', username: null,
name: tag(req.body.name ?? 'unknown'),
uuid: tag(v4()),
libraries: [],
},
}); });
healthyPromise = emby.ping(); healthyPromise = emby.ping();
@@ -233,7 +564,9 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
async (req, res) => { async (req, res) => {
try { try {
const { deletedServer } = 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? // Are these useful? What do they even do?
req.serverCtx.eventService.push({ req.serverCtx.eventService.push({
@@ -259,7 +592,7 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
return res.send(); return res.send();
} catch (err) { } catch (err) {
logger.error('Error %O', err); logger.error(err);
req.serverCtx.eventService.push({ req.serverCtx.eventService.push({
type: 'settings-update', type: 'settings-update',
message: 'Error deleting media-source.', message: 'Error deleting media-source.',
@@ -343,6 +676,7 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
}), }),
// TODO: Change this // TODO: Change this
400: z.string(), 400: z.string(),
500: z.string(),
}, },
}, },
}, },
@@ -381,54 +715,38 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
}, },
); );
fastify.get( // TODO put this in its own class.
'/plex/status', function convertToApiMediaSource(
{ entityLocker: EntityMutex,
schema: { source: MarkOptional<MediaSourceWithLibraries, 'libraries'>,
tags: ['Media Source'], ): MediaSourceSettings | null {
querystring: z.object({ return match(source)
serverName: z.string(), .returnType<MediaSourceSettings | null>()
}), .with(
response: { { type: P.union('plex', 'jellyfin', 'emby') },
200: MediaSourceStatusSchema, (source) =>
404: BaseErrorSchema, ({
500: BaseErrorSchema, id: source.uuid,
}, index: source.index,
}, uri: source.uri,
}, type: source.type,
async (req, res) => { name: source.name,
try { accessToken: source.accessToken,
const server = await req.serverCtx.mediaSourceDB.findByType( clientIdentifier: nullToUndefined(source.clientIdentifier),
MediaSourceType.Plex, sendChannelUpdates: numberToBoolean(source.sendChannelUpdates),
req.query.serverName, sendGuideUpdates: numberToBoolean(source.sendGuideUpdates),
); libraries: (source.libraries ?? []).map((library) => ({
...library,
if (isNil(server)) { id: library.uuid,
return res.status(404).send({ message: 'Plex server not found.' }); type: source.type,
} enabled: numberToBoolean(library.enabled),
lastScannedAt: nullToUndefined(library.lastScannedAt),
const plex = isLocked: entityLocker.isLibraryLocked(library),
await req.serverCtx.mediaSourceApiFactory.getPlexApiClientForMediaSource( })),
server, userId: source.userId,
); username: source.username,
}) satisfies MediaSourceSettings,
const s: MediaSourceStatus = await Promise.race([ )
plex.checkServerStatus(), .otherwise(() => null);
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',
});
}
},
);
}; };

View File

@@ -1,6 +1,7 @@
import { TruthyQueryParam } from '@/types/schemas.js'; import { TruthyQueryParam } from '@/types/schemas.js';
import type { RouterPluginAsyncCallback } from '@/types/serverType.js'; import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
import { isNonEmptyString } from '@/util/index.js'; import { isNonEmptyString } from '@/util/index.js';
import { tag } from '@tunarr/types';
import axios, { AxiosHeaders } from 'axios'; import axios, { AxiosHeaders } from 'axios';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import type { FastifyReply } from 'fastify'; import type { FastifyReply } from 'fastify';
@@ -22,6 +23,7 @@ import {
ProgramSourceType, ProgramSourceType,
programSourceTypeFromString, programSourceTypeFromString,
} from '../db/custom_types/ProgramSourceType.ts'; } from '../db/custom_types/ProgramSourceType.ts';
import type { MediaSourceId } from '../db/schema/base.ts';
import { getServerContext } from '../ServerContext.ts'; import { getServerContext } from '../ServerContext.ts';
const externalIdSchema = z const externalIdSchema = z
@@ -46,7 +48,7 @@ const externalIdSchema = z
const [sourceType, sourceId, itemId] = val.split('|', 3); const [sourceType, sourceId, itemId] = val.split('|', 3);
return { return {
externalSourceType: programSourceTypeFromString(sourceType)!, externalSourceType: programSourceTypeFromString(sourceType)!,
externalSourceId: sourceId, externalSourceId: tag<MediaSourceId>(sourceId),
externalItemId: itemId, externalItemId: itemId,
}; };
}); });
@@ -58,7 +60,9 @@ const thumbOptsSchema = z.object({
const ExternalMetadataQuerySchema = z.object({ const ExternalMetadataQuerySchema = z.object({
id: externalIdSchema, 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']), mode: z.enum(['json', 'redirect', 'proxy']),
cache: TruthyQueryParam.optional().default(true), cache: TruthyQueryParam.optional().default(true),
thumbOptions: z thumbOptions: z
@@ -66,7 +70,6 @@ const ExternalMetadataQuerySchema = z.object({
.transform((s) => JSON.parse(s) as unknown) .transform((s) => JSON.parse(s) as unknown)
.pipe(thumbOptsSchema) .pipe(thumbOptsSchema)
.optional(), .optional(),
imageType: z.enum(['poster', 'background']).default('poster'),
}); });
type ExternalMetadataQuery = z.infer<typeof ExternalMetadataQuerySchema>; type ExternalMetadataQuery = z.infer<typeof ExternalMetadataQuerySchema>;
@@ -192,7 +195,7 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
res: FastifyReply, res: FastifyReply,
) { ) {
const plexApi = const plexApi =
await getServerContext().mediaSourceApiFactory.getPlexApiClientByName( await getServerContext().mediaSourceApiFactory.getPlexApiClientById(
query.id.externalSourceId, query.id.externalSourceId,
); );
@@ -209,7 +212,7 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
imageType: query.imageType, imageType: query.imageType,
}); });
} else if (query.asset === 'external-link') { } else if (query.asset === 'external-link') {
const server = await getServerContext().mediaSourceDB.getByIdOrName( const server = await getServerContext().mediaSourceDB.getById(
query.id.externalSourceId, query.id.externalSourceId,
); );
if (!server || isNil(server.clientIdentifier)) { if (!server || isNil(server.clientIdentifier)) {
@@ -228,7 +231,7 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
async function handleJellyfinItem(query: ExternalMetadataQuery) { async function handleJellyfinItem(query: ExternalMetadataQuery) {
const jellyfinClient = const jellyfinClient =
await getServerContext().mediaSourceApiFactory.getJellyfinApiClientByName( await getServerContext().mediaSourceApiFactory.getJellyfinApiClientById(
query.id.externalSourceId, query.id.externalSourceId,
); );
@@ -237,7 +240,10 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
} }
if (query.asset === 'thumb' || query.asset === 'image') { 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') { } else if (query.asset === 'external-link') {
return jellyfinClient.getExternalUrl(query.id.externalItemId); return jellyfinClient.getExternalUrl(query.id.externalItemId);
} }
@@ -247,7 +253,7 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
async function handleEmbyItem(query: ExternalMetadataQuery) { async function handleEmbyItem(query: ExternalMetadataQuery) {
const embyClient = const embyClient =
await getServerContext().mediaSourceApiFactory.getEmbyApiClientByName( await getServerContext().mediaSourceApiFactory.getEmbyApiClientById(
query.id.externalSourceId, query.id.externalSourceId,
); );
@@ -256,7 +262,10 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
} }
if (query.asset === 'thumb' || query.asset === 'image') { 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') { } else if (query.asset === 'external-link') {
return embyClient.getExternalUrl(query.id.externalItemId); 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 { 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 { 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 { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
import { PlexApiClient } from '@/external/plex/PlexApiClient.js'; import { PlexApiClient } from '@/external/plex/PlexApiClient.js';
import { PagingParams, TruthyQueryParam } from '@/types/schemas.js'; import { PagingParams, TruthyQueryParam } from '@/types/schemas.js';
import type { RouterPluginAsyncCallback } from '@/types/serverType.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 { 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 { ContentProgramSchema } from '@tunarr/types/schemas';
import axios, { AxiosHeaders, isAxiosError } from 'axios'; import axios, { AxiosHeaders, isAxiosError } from 'axios';
import dayjs from 'dayjs';
import type { HttpHeader } from 'fastify/types/utils.js'; import type { HttpHeader } from 'fastify/types/utils.js';
import { jsonArrayFrom } from 'kysely/helpers/sqlite'; import { jsonArrayFrom } from 'kysely/helpers/sqlite';
import { import {
compact,
every, every,
find, find,
first, first,
@@ -25,19 +58,33 @@ import {
values, values,
} from 'lodash-es'; } from 'lodash-es';
import type stream from 'node:stream'; import type stream from 'node:stream';
import { match } from 'ts-pattern';
import z from 'zod/v4'; import z from 'zod/v4';
import { container } from '../container.ts'; import { container } from '../container.ts';
import { import {
ProgramSourceType, ProgramSourceType,
programSourceTypeFromString, programSourceTypeFromString,
} from '../db/custom_types/ProgramSourceType.ts'; } from '../db/custom_types/ProgramSourceType.ts';
import type { ProgramGroupingChildCounts } from '../db/interfaces/IProgramDB.ts';
import { import {
AllProgramFields, AllProgramFields,
AllProgramGroupingFields,
selectProgramsBuilder, selectProgramsBuilder,
} from '../db/programQueryHelpers.ts'; } 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 { FfprobeStreamDetails } from '../stream/FfprobeStreamDetails.ts';
import { ExternalStreamDetailsFetcherFactory } from '../stream/StreamDetailsFetcher.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({ const LookupExternalProgrammingSchema = z.object({
externalId: z 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 // eslint-disable-next-line @typescript-eslint/require-await
export const programmingApi: RouterPluginAsyncCallback = async (fastify) => { export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
const logger = LoggerFactory.child({ const logger = LoggerFactory.child({
@@ -69,6 +353,237 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
className: 'ProgrammingApi', 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( fastify.get(
'/programs/:id', '/programs/:id',
{ {
@@ -111,11 +626,15 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
if (!program) { if (!program) {
return res.status(404).send('Program not found'); 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( const server = await req.serverCtx.mediaSourceDB.findByType(
program.sourceType, program.sourceType,
program.mediaSourceId ?? program.externalSourceId, program.mediaSourceId,
); );
if (!server) { if (!server) {
@@ -184,6 +703,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
}), }),
response: { response: {
200: ProgramChildrenResult, 200: ProgramChildrenResult,
400: z.void(),
404: z.void(), 404: z.void(),
}, },
}, },
@@ -202,7 +722,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
grouping.type, grouping.type,
req.query, req.query,
); );
const result = results.map((program) => const result = seq.collect(results, (program) =>
req.serverCtx.programConverter.programDaoToContentProgram( req.serverCtx.programConverter.programDaoToContentProgram(
program, program,
program.externalIds, program.externalIds,
@@ -215,6 +735,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
type: grouping.type === 'album' ? 'track' : 'episode', type: grouping.type === 'album' ? 'track' : 'episode',
programs: result, programs: result,
}, },
size: result.length,
}); });
} else if (grouping.type === 'artist') { } else if (grouping.type === 'artist') {
const { total, results } = await req.serverCtx.programDB.getChildren( const { total, results } = await req.serverCtx.programDB.getChildren(
@@ -225,7 +746,11 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
const result = results.map((program) => const result = results.map((program) =>
req.serverCtx.programConverter.programGroupingDaoToDto(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') { } else if (grouping.type === 'show') {
const { total, results } = await req.serverCtx.programDB.getChildren( const { total, results } = await req.serverCtx.programDB.getChildren(
req.params.id, req.params.id,
@@ -238,6 +763,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
return res.send({ return res.send({
total, total,
result: { type: 'season', programs: result }, 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) => { const handleResult = async (mediaSource: MediaSource, result: string) => {
if (req.query.method === 'proxy') { if (req.query.method === 'proxy') {
try { try {
logger.debug('Proxying response to %s', result);
const proxyRes = await axios.request<stream.Readable>({ const proxyRes = await axios.request<stream.Readable>({
url: result, url: result,
responseType: 'stream', responseType: 'stream',
@@ -317,14 +844,17 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
return res.redirect(result, 302).send(); return res.redirect(result, 302).send();
}; };
if (!isNil(program)) { if (!isNil(program?.mediaSourceId)) {
const mediaSource = await req.serverCtx.mediaSourceDB.getByExternalId( const mediaSource = await req.serverCtx.mediaSourceDB.findByType(
program.sourceType, program.sourceType,
program.externalSourceId, program.mediaSourceId,
); );
if (isNil(mediaSource)) { 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; let keyToUse = program.externalKey;
@@ -418,14 +948,14 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
const mediaSource = await (isNonEmptyString(source.mediaSourceId) const mediaSource = await (isNonEmptyString(source.mediaSourceId)
? req.serverCtx.mediaSourceDB.getById(source.mediaSourceId) ? req.serverCtx.mediaSourceDB.getById(source.mediaSourceId)
: req.serverCtx.mediaSourceDB.getByExternalId( : null);
// This was asserted above
source.sourceType as 'plex' | 'jellyfin',
source.externalSourceId,
));
if (isNil(mediaSource)) { 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) { switch (mediaSource.type) {
@@ -475,6 +1005,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
200: z.object({ url: z.string() }), 200: z.object({ url: z.string() }),
302: z.void(), 302: z.void(),
404: z.void(), 404: z.void(),
405: z.void(),
}, },
}, },
}, },
@@ -501,7 +1032,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
const server = find( const server = find(
mediaSources, mediaSources,
(source) => (source) =>
source.uuid === externalId.externalSourceId || source.uuid === externalId.mediaSourceId ||
source.name === externalId.externalSourceId, source.name === externalId.externalSourceId,
); );
@@ -552,11 +1083,12 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
200: ContentProgramSchema, 200: ContentProgramSchema,
400: z.object({ message: z.string() }), 400: z.object({ message: z.string() }),
404: z.void(), 404: z.void(),
500: z.string(),
}, },
}, },
}, },
async (req, res) => { async (req, res) => {
const [sourceType, ,] = req.params.externalId; const [sourceType, rawServerId, id] = req.params.externalId;
const sourceTypeParsed = programSourceTypeFromString(sourceType); const sourceTypeParsed = programSourceTypeFromString(sourceType);
if (isUndefined(sourceTypeParsed)) { if (isUndefined(sourceTypeParsed)) {
return res return res
@@ -565,7 +1097,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
} }
const result = await req.serverCtx.programDB.lookupByExternalIds( const result = await req.serverCtx.programDB.lookupByExternalIds(
new Set([req.params.externalId]), new Set([[sourceType, tag(rawServerId), id]]),
); );
const program = first(values(result)); const program = first(values(result));
@@ -573,7 +1105,18 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
return res.status(404).send(); 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) => { 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( 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: { response: {
200: ChannelSessionsResponseSchema, 200: ChannelSessionsResponseSchema,
201: z.void(),
404: z.string(), 404: z.string(),
}, },
}, },

View File

@@ -12,7 +12,7 @@ import type { StreamConnectionDetails } from '@tunarr/types/api';
import { ChannelStreamModeSchema } from '@tunarr/types/schemas'; import { ChannelStreamModeSchema } from '@tunarr/types/schemas';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import type { FastifyReply } from 'fastify'; 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 fs from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
import { PassThrough } from 'node:stream'; import { PassThrough } from 'node:stream';
@@ -27,7 +27,14 @@ export const streamApi: RouterPluginAsyncCallback = async (fastify) => {
}); });
fastify.addHook('onError', (req, _, error, done) => { 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(); done();
}); });

View File

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

View File

@@ -18,6 +18,7 @@ export type ServerArgsType = GlobalArgsType & {
port: number; port: number;
printRoutes: boolean; printRoutes: boolean;
trustProxy: boolean; trustProxy: boolean;
searchPort?: number;
}; };
export const RunServerCommand: CommandModule<GlobalArgsType, ServerArgsType> = { export const RunServerCommand: CommandModule<GlobalArgsType, ServerArgsType> = {
@@ -39,6 +40,9 @@ export const RunServerCommand: CommandModule<GlobalArgsType, ServerArgsType> = {
default: () => default: () =>
getBooleanEnvVar(TUNARR_ENV_VARS.TRUST_PROXY_ENV_VAR, false), getBooleanEnvVar(TUNARR_ENV_VARS.TRUST_PROXY_ENV_VAR, false),
}, },
searchPort: {
type: 'number',
},
}, },
handler: async ( handler: async (
opts: ArgumentsCamelCase<MarkOptional<ServerArgsType, 'port'>>, 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 type { CommandModule } from 'yargs';
import { container } from '../container.ts'; 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 { TunarrWorker } from '../services/TunarrWorker.ts';
import type { GenerateOpenApiCommandArgs } from './GenerateOpenApiCommand.ts'; import type { GenerateOpenApiCommandArgs } from './GenerateOpenApiCommand.ts';
import type { GlobalArgsType } from './types.ts'; import type { GlobalArgsType } from './types.ts';
type WorkerData = {
serverOptions: ServerOptions;
};
export const StartWorkerCommand: CommandModule< export const StartWorkerCommand: CommandModule<
GlobalArgsType, GlobalArgsType,
GenerateOpenApiCommandArgs GenerateOpenApiCommandArgs
> = { > = {
command: 'start-worker', command: 'start-worker',
describe: 'Starts a Tunarr worker (internal use only)', describe: 'Starts a Tunarr worker (internal use only)',
// eslint-disable-next-line @typescript-eslint/require-await
handler: async () => { handler: async () => {
if (isMainThread) { if (isMainThread) {
console.error('This module is only meant to be run as a worker thread.'); 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); process.exit(1);
} }
// TODO: parse
const { serverOptions } = workerData as WorkerData;
setServerOptions(serverOptions);
await container.get<StartupService>(StartupService).runStartupServices();
container.get<TunarrWorker>(TunarrWorker).start(); container.get<TunarrWorker>(TunarrWorker).start();
}, },
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,73 +1,47 @@
import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js'; import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
import type { NewSingleOrMultiProgramGroupingExternalId } from '@/db/schema/ProgramGroupingExternalId.js'; import type { NewSingleOrMultiProgramGroupingExternalId } from '@/db/schema/ProgramGroupingExternalId.js';
import { isNonEmptyString } from '@/util/index.js'; import { isNonEmptyString } from '@/util/index.js';
import { seq } from '@tunarr/shared/util';
import type { ContentProgram } from '@tunarr/types'; import type { ContentProgram } from '@tunarr/types';
import type { JellyfinItem } from '@tunarr/types/jellyfin'; import {
import type { PlexEpisode, PlexMusicTrack } from '@tunarr/types/plex'; isValidMultiExternalIdType,
isValidSingleExternalIdType,
} from '@tunarr/types/schemas';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { injectable } from 'inversify';
import { first } from 'lodash-es'; import { first } from 'lodash-es';
import type { MarkRequired } from 'ts-essentials'; import type { MarkRequired } from 'ts-essentials';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import {
MediaSourceMusicAlbum,
MediaSourceMusicArtist,
MediaSourceSeason,
MediaSourceShow,
} from '../../types/Media.ts';
import type { Nullable } from '../../types/util.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 { import {
ProgramGroupingType, ProgramGroupingType,
type NewProgramGrouping, type NewProgramGrouping,
} from '../schema/ProgramGrouping.ts'; } from '../schema/ProgramGrouping.ts';
@injectable()
export class ProgramGroupingMinter { export class ProgramGroupingMinter {
static mintParentProgramGroupingForPlex( constructor() {}
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;
}
static mintGroupingExternalIds( static mintGroupingExternalIds(
program: ContentProgram, program: ContentProgram,
groupingId: string, groupingId: string,
externalSourceId: string, externalSourceId: MediaSourceName,
mediaSourceId: string, mediaSourceId: MediaSourceId,
relationType: 'parent' | 'grandparent', relationType: 'parent' | 'grandparent',
): NewSingleOrMultiProgramGroupingExternalId[] { ): NewSingleOrMultiProgramGroupingExternalId[] {
if (program.subtype === 'movie') { if (program.subtype === 'movie') {
@@ -124,6 +98,10 @@ export class ProgramGroupingMinter {
return null; return null;
} }
if (!item.canonicalId || !item.libraryId) {
return null;
}
const now = +dayjs(); const now = +dayjs();
return { return {
uuid: v4(), uuid: v4(),
@@ -140,6 +118,8 @@ export class ProgramGroupingMinter {
artistUuid: null, artistUuid: null,
showUuid: null, showUuid: null,
year: item.grandparent.year, year: item.grandparent.year,
canonicalId: item.canonicalId,
libraryId: item.libraryId,
}; };
} }
@@ -150,6 +130,10 @@ export class ProgramGroupingMinter {
return null; return null;
} }
if (!item.canonicalId || !item.libraryId) {
return null;
}
const now = +dayjs(); const now = +dayjs();
return { return {
uuid: v4(), uuid: v4(),
@@ -166,6 +150,210 @@ export class ProgramGroupingMinter {
artistUuid: null, artistUuid: null,
showUuid: null, showUuid: null,
year: item.parent.year, year: item.parent.year,
canonicalId: item.canonicalId,
libraryId: item.libraryId,
} satisfies NewProgramGrouping; } 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, NewSingleOrMultiExternalId,
} from '@/db/schema/ProgramExternalId.js'; } from '@/db/schema/ProgramExternalId.js';
import { seq } from '@tunarr/shared/util'; 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 { JellyfinItem } from '@tunarr/types/jellyfin';
import type { import type {
PlexMovie as ApiPlexMovie,
PlexEpisode, PlexEpisode,
PlexMovie, PlexMedia,
PlexMusicTrack, PlexMusicTrack,
PlexTerminalMedia,
} from '@tunarr/types/plex'; } 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 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 { P, match } from 'ts-pattern';
import { v4 } from 'uuid'; 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 { 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 * Generates Program DB entities for Plex media
*/ */
class ProgramDaoMinter { @injectable()
contentProgramDtoToDao(program: ContentProgram): NewRawProgram { 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(); const now = +dayjs();
return { return {
uuid: v4(), uuid: v4(),
sourceType: program.externalSourceType, sourceType: program.externalSourceType,
// Deprecated // Deprecated
externalSourceId: program.externalSourceName, externalSourceId: tag(program.externalSourceName),
mediaSourceId: program.externalSourceId, mediaSourceId: tag(program.externalSourceId),
externalKey: program.externalKey, externalKey: program.externalKey,
originalAirDate: program.date ?? null, originalAirDate: program.date ?? null,
duration: program.duration, duration: program.duration,
@@ -50,30 +106,40 @@ class ProgramDaoMinter {
grandparentExternalKey: program.grandparent?.externalKey, grandparentExternalKey: program.grandparent?.externalKey,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
canonicalId: program.canonicalId,
libraryId: program.libraryId,
}; };
} }
mint( mint(
serverName: string, mediaSource: MediaSource,
serverId: string, library: MediaSourceLibrary,
program: ContentProgramOriginalProgram, program: ContentProgramOriginalProgram,
): NewRawProgram { ): NewProgramWithExternalIds {
const ret = match(program) const ret = match(program)
.with( .with({ sourceType: 'plex' }, ({ program }) => {
{ sourceType: 'plex', program: { type: 'movie' } }, const dao = match(program)
({ program: movie }) => .with({ type: 'movie' }, (movie) =>
this.mintProgramForPlexMovie(serverName, serverId, movie), this.mintProgramForPlexMovie(mediaSource, library, movie),
) )
.with( .with({ type: 'episode' }, (ep) =>
{ sourceType: 'plex', program: { type: 'episode' } }, this.mintProgramForPlexEpisode(mediaSource, library, ep),
({ program: episode }) => )
this.mintProgramForPlexEpisode(serverName, serverId, episode), .with({ type: 'track' }, (track) =>
) this.mintProgramForPlexTrack(mediaSource, library, track),
.with( )
{ sourceType: 'plex', program: { type: 'track' } }, .exhaustive();
({ program: track }) => const externalIds = this.mintPlexExternalIdsFromApiItem(
this.mintProgramForPlexTrack(serverName, serverId, track), mediaSource.name,
) mediaSource.uuid,
dao,
program,
);
return {
...dao,
externalIds,
} satisfies NewProgramWithExternalIds;
})
.with( .with(
{ {
sourceType: 'jellyfin', sourceType: 'jellyfin',
@@ -88,8 +154,7 @@ class ProgramDaoMinter {
), ),
}, },
}, },
({ program }) => ({ program }) => this.mintProgramForJellyfinItem(mediaSource, program),
this.mintProgramForJellyfinItem(serverName, serverId, program),
) )
.otherwise(() => new Error('Unexpected program type')); .otherwise(() => new Error('Unexpected program type'));
if (isError(ret)) { if (isError(ret)) {
@@ -98,11 +163,219 @@ class ProgramDaoMinter {
return ret; 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( private mintProgramForPlexMovie(
serverName: string, mediaSource: MediaSource,
serverId: string, mediaLibrary: MediaSourceLibrary,
plexMovie: PlexMovie, plexMovie: ApiPlexMovie,
): NewRawProgram { ): NewProgramDao {
const file = first(first(plexMovie.Media)?.Part ?? []); const file = first(first(plexMovie.Media)?.Part ?? []);
return { return {
uuid: v4(), uuid: v4(),
@@ -110,8 +383,9 @@ class ProgramDaoMinter {
originalAirDate: plexMovie.originallyAvailableAt ?? null, originalAirDate: plexMovie.originallyAvailableAt ?? null,
duration: plexMovie.duration ?? 0, duration: plexMovie.duration ?? 0,
filePath: file?.file ?? null, filePath: file?.file ?? null,
externalSourceId: serverName, externalSourceId: mediaSource.name,
mediaSourceId: serverId, mediaSourceId: mediaSource.uuid,
libraryId: mediaLibrary.uuid,
externalKey: plexMovie.ratingKey, externalKey: plexMovie.ratingKey,
plexRatingKey: plexMovie.ratingKey, plexRatingKey: plexMovie.ratingKey,
plexFilePath: file?.key ?? null, plexFilePath: file?.key ?? null,
@@ -122,25 +396,26 @@ class ProgramDaoMinter {
year: plexMovie.year ?? null, year: plexMovie.year ?? null,
createdAt: +dayjs(), createdAt: +dayjs(),
updatedAt: +dayjs(), updatedAt: +dayjs(),
canonicalId: this.plexProgramCanonicalizer.getCanonicalId(plexMovie),
}; };
} }
private mintProgramForJellyfinItem( private mintProgramForJellyfinItem(
serverName: string, mediaSource: MediaSource,
serverId: string,
item: Omit<JellyfinItem, 'Type'> & { item: Omit<JellyfinItem, 'Type'> & {
Type: 'Movie' | 'Episode' | 'Audio' | 'Video' | 'MusicVideo' | 'Trailer'; Type: 'Movie' | 'Episode' | 'Audio' | 'Video' | 'MusicVideo' | 'Trailer';
}, },
): NewRawProgram { ): NewProgramWithExternalIds {
return { const id = v4();
uuid: v4(), const dao: NewProgramDao = {
uuid: id,
createdAt: +dayjs(), createdAt: +dayjs(),
updatedAt: +dayjs(), updatedAt: +dayjs(),
sourceType: ProgramSourceType.JELLYFIN, sourceType: ProgramSourceType.JELLYFIN,
originalAirDate: item.PremiereDate, originalAirDate: item.PremiereDate,
duration: (item.RunTimeTicks ?? 0) / 10_000, duration: Math.ceil((item.RunTimeTicks ?? 0) / 10_000),
externalSourceId: serverName, externalSourceId: mediaSource.name,
mediaSourceId: serverId, mediaSourceId: mediaSource.uuid,
externalKey: item.Id, externalKey: item.Id,
rating: item.OfficialRating, rating: item.OfficialRating,
summary: item.Overview, summary: item.Overview,
@@ -161,14 +436,27 @@ class ProgramDaoMinter {
grandparentExternalKey: grandparentExternalKey:
item.SeriesId ?? item.SeriesId ??
find(item.AlbumArtists, { Name: item.AlbumArtist })?.Id, 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( private mintProgramForPlexEpisode(
serverName: string, mediaSource: MediaSource,
serverId: string, mediaLibrary: MediaSourceLibrary,
plexEpisode: PlexEpisode, plexEpisode: PlexEpisode,
): NewRawProgram { ): NewProgramDao {
const file = first(first(plexEpisode.Media)?.Part ?? []); const file = first(first(plexEpisode.Media)?.Part ?? []);
return { return {
uuid: v4(), uuid: v4(),
@@ -178,8 +466,9 @@ class ProgramDaoMinter {
originalAirDate: plexEpisode.originallyAvailableAt, originalAirDate: plexEpisode.originallyAvailableAt,
duration: plexEpisode.duration ?? 0, duration: plexEpisode.duration ?? 0,
filePath: file?.file, filePath: file?.file,
externalSourceId: serverName, externalSourceId: mediaSource.name,
mediaSourceId: serverId, mediaSourceId: mediaSource.uuid,
libraryId: mediaLibrary.uuid,
externalKey: plexEpisode.ratingKey, externalKey: plexEpisode.ratingKey,
plexRatingKey: plexEpisode.ratingKey, plexRatingKey: plexEpisode.ratingKey,
plexFilePath: file?.key, plexFilePath: file?.key,
@@ -194,12 +483,13 @@ class ProgramDaoMinter {
episode: plexEpisode.index, episode: plexEpisode.index,
parentExternalKey: plexEpisode.parentRatingKey, parentExternalKey: plexEpisode.parentRatingKey,
grandparentExternalKey: plexEpisode.grandparentRatingKey, grandparentExternalKey: plexEpisode.grandparentRatingKey,
canonicalId: this.plexProgramCanonicalizer.getCanonicalId(plexEpisode),
}; };
} }
private mintProgramForPlexTrack( private mintProgramForPlexTrack(
serverName: string, mediaSource: MediaSource,
serverId: string, mediaLibrary: MediaSourceLibrary,
plexTrack: PlexMusicTrack, plexTrack: PlexMusicTrack,
): NewRawProgram { ): NewRawProgram {
const file = first(first(plexTrack.Media)?.Part ?? []); const file = first(first(plexTrack.Media)?.Part ?? []);
@@ -210,8 +500,9 @@ class ProgramDaoMinter {
sourceType: ProgramSourceType.PLEX, sourceType: ProgramSourceType.PLEX,
duration: plexTrack.duration ?? 0, duration: plexTrack.duration ?? 0,
filePath: file?.file, filePath: file?.file,
externalSourceId: serverName, externalSourceId: mediaSource.name,
mediaSourceId: serverId, mediaSourceId: mediaSource.uuid,
libraryId: mediaLibrary.uuid,
externalKey: plexTrack.ratingKey, externalKey: plexTrack.ratingKey,
plexRatingKey: plexTrack.ratingKey, plexRatingKey: plexTrack.ratingKey,
plexFilePath: file?.key, plexFilePath: file?.key,
@@ -227,12 +518,13 @@ class ProgramDaoMinter {
grandparentExternalKey: plexTrack.grandparentRatingKey, grandparentExternalKey: plexTrack.grandparentRatingKey,
albumName: plexTrack.parentTitle, albumName: plexTrack.parentTitle,
artistName: plexTrack.grandparentTitle, artistName: plexTrack.grandparentTitle,
canonicalId: this.plexProgramCanonicalizer.getCanonicalId(plexTrack),
}; };
} }
mintExternalIds( mintExternalIds(
serverName: string, serverName: MediaSourceName,
serverId: string, serverId: MediaSourceId,
programId: string, programId: string,
program: ContentProgram, program: ContentProgram,
): NewSingleOrMultiExternalId[] { ): NewSingleOrMultiExternalId[] {
@@ -250,8 +542,8 @@ class ProgramDaoMinter {
} }
mintPlexExternalIds( mintPlexExternalIds(
serverName: string, serverName: MediaSourceName,
serverId: string, serverId: MediaSourceId,
programId: string, programId: string,
program: ContentProgram, program: ContentProgram,
): NewSingleOrMultiExternalId[] { ): NewSingleOrMultiExternalId[] {
@@ -310,25 +602,66 @@ class ProgramDaoMinter {
return ids; return ids;
} }
mintJellyfinExternalIdForApiItem( mintPlexExternalIdsFromApiItem(
serverName: string, serverName: MediaSourceName,
programId: string, serverId: MediaSourceId,
media: JellyfinItem, program: NewProgramDao,
) { plexEntity: PlexTerminalMedia,
return { ): NewSingleOrMultiExternalId[] {
uuid: v4(), const now = +dayjs();
createdAt: +dayjs(), const file = first(first(plexEntity.Media)?.Part ?? []);
updatedAt: +dayjs(),
externalKey: media.Id, const ids: NewSingleOrMultiExternalId[] = [
sourceType: ProgramExternalIdType.JELLYFIN, {
programUuid: programId, type: 'multi',
externalSourceId: serverName, uuid: v4(),
} satisfies NewProgramExternalId; 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( mintJellyfinExternalIds(
serverName: string, serverName: MediaSourceName,
serverId: string, serverId: MediaSourceId,
programId: string, programId: string,
program: ContentProgram, program: ContentProgram,
) { ) {
@@ -373,9 +706,75 @@ class ProgramDaoMinter {
return ids; 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( mintEmbyExternalIds(
serverName: string, serverName: MediaSourceName,
serverId: string, serverId: MediaSourceId,
programId: string, programId: string,
program: ContentProgram, program: ContentProgram,
) { ) {
@@ -420,9 +819,3 @@ class ProgramDaoMinter {
return ids; return ids;
} }
} }
export class ProgramMinterFactory {
static create(): ProgramDaoMinter {
return new ProgramDaoMinter();
}
}

View File

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

View File

@@ -1,6 +1,6 @@
import type { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js'; import type { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
import type { ProgramSourceType } from '@/db/custom_types/ProgramSourceType.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 { import type {
MinimalProgramExternalId, MinimalProgramExternalId,
NewProgramExternalId, NewProgramExternalId,
@@ -10,15 +10,19 @@ import type {
import type { ProgramExternalIdSourceType } from '@/db/schema/base.js'; import type { ProgramExternalIdSourceType } from '@/db/schema/base.js';
import type { import type {
MusicAlbumWithExternalIds, MusicAlbumWithExternalIds,
NewProgramGroupingWithExternalIds,
NewProgramWithExternalIds,
ProgramGroupingWithExternalIds, ProgramGroupingWithExternalIds,
ProgramWithExternalIds, ProgramWithExternalIds,
ProgramWithRelations, ProgramWithRelations,
TvSeasonWithExternalIds, TvSeasonWithExternalIds,
} from '@/db/schema/derivedTypes.js'; } from '@/db/schema/derivedTypes.js';
import type { Maybe, PagedResult } from '@/types/util.js'; import type { MarkNonNullable, Maybe, PagedResult } from '@/types/util.js';
import type { ChannelProgram, ContentProgram } from '@tunarr/types'; import type { ChannelProgram } from '@tunarr/types';
import type { MarkOptional } from 'ts-essentials'; import type { Dictionary, MarkOptional } from 'ts-essentials';
import type { MediaSourceType } from '../schema/MediaSource.ts';
import type { ProgramGroupingType } from '../schema/ProgramGrouping.ts'; import type { ProgramGroupingType } from '../schema/ProgramGrouping.ts';
import type { MediaSourceId } from '../schema/base.ts';
import type { PageParams } from './IChannelDB.ts'; import type { PageParams } from './IChannelDB.ts';
export interface IProgramDB { export interface IProgramDB {
@@ -35,13 +39,21 @@ export interface IProgramDB {
getProgramsByIds( getProgramsByIds(
ids: string[], ids: string[],
batchSize: number, batchSize?: number,
): Promise<ProgramWithRelations[]>; ): Promise<ProgramWithRelations[]>;
getProgramGrouping( getProgramGrouping(
id: string, id: string,
): Promise<Maybe<ProgramGroupingWithExternalIds>>; ): Promise<Maybe<ProgramGroupingWithExternalIds>>;
getProgramGroupings(
ids: string[],
): Promise<Record<string, ProgramGroupingWithExternalIds>>;
getProgramGroupingByExternalId(
eid: ProgramGroupingExternalIdLookup,
): Promise<Maybe<ProgramGroupingWithExternalIds>>;
getProgramParent( getProgramParent(
programId: string, programId: string,
): Promise<Maybe<ProgramGroupingWithExternalIds>>; ): Promise<Maybe<ProgramGroupingWithExternalIds>>;
@@ -73,11 +85,21 @@ export interface IProgramDB {
sourceType: ProgramSourceType; sourceType: ProgramSourceType;
externalSourceId: string; externalSourceId: string;
externalKey: string; externalKey: string;
}): Promise<Maybe<ContentProgram>>; }): Promise<Maybe<ProgramWithRelations>>;
lookupByExternalIds( lookupByExternalIds(
ids: Set<[string, string, string]>, ids:
): Promise<Record<string, ContentProgram>>; | 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( programIdsByExternalIds(
ids: Set<[string, string, string]>, ids: Set<[string, string, string]>,
@@ -107,7 +129,12 @@ export interface IProgramDB {
upsertContentPrograms( upsertContentPrograms(
programs: ChannelProgram[], programs: ChannelProgram[],
programUpsertBatchSize?: number, programUpsertBatchSize?: number,
): Promise<ProgramDao[]>; ): Promise<MarkNonNullable<ProgramDao, 'mediaSourceId'>[]>;
upsertPrograms(
programs: NewProgramWithExternalIds[],
programUpsertBatchSize?: number,
): Promise<ProgramWithExternalIds[]>;
programIdsByExternalIds( programIdsByExternalIds(
ids: Set<[string, string, string]>, ids: Set<[string, string, string]>,
@@ -117,9 +144,81 @@ export interface IProgramDB {
upsertProgramExternalIds( upsertProgramExternalIds(
externalIds: NewSingleOrMultiExternalId[], externalIds: NewSingleOrMultiExternalId[],
chunkSize?: number, 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 & { export type WithChannelIdFilter<T> = T & {
channelId?: string; 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, SystemSettings,
XmlTvSettings, XmlTvSettings,
} from '@tunarr/types'; } 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 { DeepReadonly } from 'ts-essentials';
import type { TypedEventEmitter } from '../../types/eventEmitter.ts'; import type { TypedEventEmitter } from '../../types/eventEmitter.ts';
@@ -32,6 +35,8 @@ export interface ISettingsDB extends TypedEventEmitter<SettingsChangeEvents> {
ffmpegSettings(): ReadableFfmpegSettings; ffmpegSettings(): ReadableFfmpegSettings;
globalMediaSourceSettings(): DeepReadonly<GlobalMediaSourceSettings>;
ffprobePath: string; ffprobePath: string;
systemSettings(): DeepReadonly<SystemSettings>; systemSettings(): DeepReadonly<SystemSettings>;
@@ -54,6 +59,7 @@ export interface ISettingsDB extends TypedEventEmitter<SettingsChangeEvents> {
flush(): Promise<void>; flush(): Promise<void>;
} }
export type ReadableFfmpegSettings = DeepReadonly<FfmpegSettings>; export type ReadableFfmpegSettings = DeepReadonly<FfmpegSettings>;
export type SettingsChangeEvents = { export type SettingsChangeEvents = {
change(): void; change(): void;

View File

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

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
import type { Nullable } from '@/types/util.js'; import type { Nullable } from '@/types/util.js';
import { isProduction } from '@/util/index.js'; import { isProduction } from '@/util/index.js';
import { LoggerFactory } from '@/util/logging/LoggerFactory.js'; import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
@@ -51,8 +50,8 @@ export class SyncSchemaBackedDbAdapter<T extends z.ZodTypeAny>
if (!parseResult.success) { if (!parseResult.success) {
this.logger.error( 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, parseResult.error,
`Error while parsing schema-backed JSON file ${this.path.toString()}. Returning null. This could mean the DB got corrupted somehow`,
); );
return null; return null;
} }
@@ -64,8 +63,8 @@ export class SyncSchemaBackedDbAdapter<T extends z.ZodTypeAny>
const parseResult = this.schema.safeParse(data); const parseResult = this.schema.safeParse(data);
if (!parseResult.success) { if (!parseResult.success) {
this.logger.warn( this.logger.warn(
'Could not verify schema before saving to DB - the given type does not match the expected schema.',
parseResult.error, parseResult.error,
'Could not verify schema before saving to DB - the given type does not match the expected schema.',
); );
throw new Error( throw new Error(
'Could not verify schema before saving to DB - the given type does not match the expected schema.', '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 { import {
chunk, chunk,
first, first,
isEmpty,
isNil, isNil,
isUndefined, isUndefined,
keys, keys,
@@ -21,21 +22,34 @@ import { v4 } from 'uuid';
import { type IChannelDB } from '@/db/interfaces/IChannelDB.js'; import { type IChannelDB } from '@/db/interfaces/IChannelDB.js';
import { KEYS } from '@/types/inject.js'; import { KEYS } from '@/types/inject.js';
import { booleanToNumber } from '@/util/sqliteUtil.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 { Kysely } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/sqlite';
import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory.ts'; import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory.ts';
import { MediaSourceLibraryRefresher } from '../services/MediaSourceLibraryRefresher.ts';
import { withLibraries } from './mediaSourceQueryHelpers.ts';
import { import {
withProgramChannels, withProgramChannels,
withProgramCustomShows, withProgramCustomShows,
withProgramFillerShows, withProgramFillerShows,
} from './programQueryHelpers.ts'; } from './programQueryHelpers.ts';
import { MediaSourceId, MediaSourceName } from './schema/base.ts';
import { DB } from './schema/db.ts'; import { DB } from './schema/db.ts';
import { import {
EmbyMediaSource, EmbyMediaSource,
JellyfinMediaSource, JellyfinMediaSource,
MediaSource, MediaSourceWithLibraries,
MediaSourceType,
PlexMediaSource, PlexMediaSource,
} from './schema/derivedTypes.js';
import {
MediaSource,
MediaSourceFields,
MediaSourceLibrary,
MediaSourceLibraryUpdate,
MediaSourceType,
MediaSourceUpdate,
NewMediaSourceLibrary,
} from './schema/MediaSource.ts'; } from './schema/MediaSource.ts';
type Report = { type Report = {
@@ -59,68 +73,79 @@ export class MediaSourceDB {
@inject(KEYS.MediaSourceApiFactory) @inject(KEYS.MediaSourceApiFactory)
private mediaSourceApiFactory: () => MediaSourceApiFactory, private mediaSourceApiFactory: () => MediaSourceApiFactory,
@inject(KEYS.Database) private db: Kysely<DB>, @inject(KEYS.Database) private db: Kysely<DB>,
@inject(KEYS.MediaSourceLibraryRefresher)
private mediaSourceLibraryRefresher: interfaces.AutoFactory<MediaSourceLibraryRefresher>,
) {} ) {}
async getAll(): Promise<MediaSource[]> { async getAll(): Promise<MediaSourceWithLibraries[]> {
return this.db.selectFrom('mediaSource').selectAll().execute();
}
async getById(id: string) {
return this.db return this.db
.selectFrom('mediaSource') .selectFrom('mediaSource')
.select(withLibraries)
.selectAll()
.execute();
}
async getById(id: MediaSourceId): Promise<Maybe<MediaSourceWithLibraries>> {
return this.db
.selectFrom('mediaSource')
.select(withLibraries)
.selectAll() .selectAll()
.where('mediaSource.uuid', '=', id) .where('mediaSource.uuid', '=', id)
.executeTakeFirst(); .executeTakeFirst();
} }
async getByName(name: string) { async getLibrary(id: string) {
return this.db return (
.selectFrom('mediaSource') this.db
.selectAll() .selectFrom('mediaSourceLibrary')
.where('mediaSource.name', '=', name) .where('uuid', '=', id)
.executeTakeFirst(); .select((eb) =>
} jsonObjectFrom(
eb
async getByIdOrName(id: string) { .selectFrom('mediaSource')
return this.db .whereRef(
.selectFrom('mediaSource') 'mediaSource.uuid',
.selectAll() '=',
.where((eb) => eb.or([eb('uuid', '=', id), eb('name', '=', id)])) 'mediaSourceLibrary.mediaSourceId',
.executeTakeFirst(); )
.select(MediaSourceFields),
).as('mediaSource'),
)
.selectAll()
// Should be safe before of referential integrity of foreign keys
.$narrowType<{ mediaSource: MediaSource }>()
.executeTakeFirst()
);
} }
async findByType( async findByType(
type: typeof MediaSourceType.Plex, type: typeof MediaSourceType.Plex,
nameOrId: string, nameOrId: MediaSourceId,
): Promise<PlexMediaSource | undefined>; ): Promise<PlexMediaSource | undefined>;
async findByType( async findByType(
type: typeof MediaSourceType.Jellyfin, type: typeof MediaSourceType.Jellyfin,
nameOrId: string, nameOrId: MediaSourceId,
): Promise<JellyfinMediaSource | undefined>; ): Promise<JellyfinMediaSource | undefined>;
async findByType( async findByType(
type: typeof MediaSourceType.Emby, type: typeof MediaSourceType.Emby,
nameOrId: string, nameOrId: MediaSourceId,
): Promise<EmbyMediaSource | undefined>; ): Promise<EmbyMediaSource | undefined>;
async findByType( async findByType(
type: MediaSourceType, type: MediaSourceType,
nameOrId: string, nameOrId: MediaSourceId,
): Promise<MediaSource | undefined>; ): Promise<MediaSourceWithLibraries | undefined>;
async findByType(type: MediaSourceType): Promise<MediaSource[]>; async findByType(type: MediaSourceType): Promise<MediaSourceWithLibraries[]>;
async findByType( async findByType(
type: MediaSourceType, type: MediaSourceType,
nameOrId?: string, nameOrId?: MediaSourceId,
): Promise<MediaSource[] | Maybe<MediaSource>> { ): Promise<MediaSourceWithLibraries[] | Maybe<MediaSourceWithLibraries>> {
const found = await this.db const found = await this.db
.selectFrom('mediaSource') .selectFrom('mediaSource')
.selectAll() .selectAll()
.select(withLibraries)
.where('mediaSource.type', '=', type) .where('mediaSource.type', '=', type)
.$if(isNonEmptyString(nameOrId), (qb) => .$if(isNonEmptyString(nameOrId), (qb) =>
qb.where((eb) => qb.where('mediaSource.uuid', '=', retag<MediaSourceId>(nameOrId!)),
eb.or([
eb('mediaSource.name', '=', nameOrId!),
eb('mediaSource.uuid', '=', nameOrId!),
]),
),
) )
.execute(); .execute();
@@ -131,26 +156,7 @@ export class MediaSourceDB {
} }
} }
async getByExternalId( async deleteMediaSource(id: MediaSourceId) {
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) {
const deletedServer = await this.getById(id); const deletedServer = await this.getById(id);
if (isNil(deletedServer)) { if (isNil(deletedServer)) {
throw new Error(`MediaSource not found: ${id}`); throw new Error(`MediaSource not found: ${id}`);
@@ -185,7 +191,7 @@ export class MediaSourceDB {
async updateMediaSource(server: UpdateMediaSourceRequest) { async updateMediaSource(server: UpdateMediaSourceRequest) {
const id = server.id; const id = server.id;
const mediaSource = await this.getById(id); const mediaSource = await this.getById(tag(id));
if (isNil(mediaSource)) { if (isNil(mediaSource)) {
throw new Error("Server doesn't exist."); throw new Error("Server doesn't exist.");
@@ -199,7 +205,7 @@ export class MediaSourceDB {
await this.db await this.db
.updateTable('mediaSource') .updateTable('mediaSource')
.set({ .set({
name: server.name, name: tag<MediaSourceName>(server.name),
uri: trimEnd(server.uri, '/'), uri: trimEnd(server.uri, '/'),
accessToken: server.accessToken, accessToken: server.accessToken,
sendGuideUpdates: booleanToNumber(sendGuideUpdates), sendGuideUpdates: booleanToNumber(sendGuideUpdates),
@@ -208,8 +214,8 @@ export class MediaSourceDB {
// This allows clearing the values // This allows clearing the values
userId: server.userId, userId: server.userId,
username: server.username, username: server.username,
}) } satisfies MediaSourceUpdate)
.where('uuid', '=', server.id) .where('uuid', '=', tag<MediaSourceId>(server.id))
// TODO: Blocked on https://github.com/oven-sh/bun/issues/16909 // TODO: Blocked on https://github.com/oven-sh/bun/issues/16909
// .limit(1) // .limit(1)
.executeTakeFirst(); .executeTakeFirst();
@@ -217,7 +223,7 @@ export class MediaSourceDB {
this.mediaSourceApiFactory().deleteCachedClient(mediaSource); this.mediaSourceApiFactory().deleteCachedClient(mediaSource);
const report = await this.fixupProgramReferences( const report = await this.fixupProgramReferences(
id, tag(id),
mediaSource.type, mediaSource.type,
mediaSource, mediaSource,
); );
@@ -226,7 +232,7 @@ export class MediaSourceDB {
} }
async setMediaSourceUserInfo( async setMediaSourceUserInfo(
mediaSourceId: string, mediaSourceId: MediaSourceId,
info: MediaSourceUserInfo, info: MediaSourceUserInfo,
) { ) {
if (isNonEmptyString(info.userId) && isNonEmptyString(info.username)) { if (isNonEmptyString(info.userId) && isNonEmptyString(info.username)) {
@@ -244,7 +250,9 @@ export class MediaSourceDB {
} }
async addMediaSource(server: InsertMediaSourceRequest): Promise<string> { 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 = const sendGuideUpdates =
server.type === 'plex' ? (server.sendGuideUpdates ?? false) : false; server.type === 'plex' ? (server.sendGuideUpdates ?? false) : false;
const sendChannelUpdates = const sendChannelUpdates =
@@ -260,7 +268,7 @@ export class MediaSourceDB {
.insertInto('mediaSource') .insertInto('mediaSource')
.values({ .values({
...server, ...server,
uuid: v4(), uuid: tag<MediaSourceId>(v4()),
name, name,
uri: trimEnd(server.uri, '/'), uri: trimEnd(server.uri, '/'),
sendChannelUpdates: sendChannelUpdates ? 1 : 0, sendChannelUpdates: sendChannelUpdates ? 1 : 0,
@@ -275,11 +283,65 @@ export class MediaSourceDB {
.returning('uuid') .returning('uuid')
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
await this.mediaSourceLibraryRefresher().refreshMediaSource(newServer.uuid);
return 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( private async fixupProgramReferences(
serverName: string, serverId: MediaSourceId,
serverType: MediaSourceType, serverType: MediaSourceType,
newServer?: MediaSource, newServer?: MediaSource,
) { ) {
@@ -292,7 +354,7 @@ export class MediaSourceDB {
.selectFrom('program') .selectFrom('program')
.selectAll() .selectAll()
.where('sourceType', '=', serverType) .where('sourceType', '=', serverType)
.where('externalSourceId', '=', serverName) .where('mediaSourceId', '=', serverId)
.select(withProgramChannels) .select(withProgramChannels)
.select(withProgramFillerShows) .select(withProgramFillerShows)
.select(withProgramCustomShows) .select(withProgramCustomShows)
@@ -334,7 +396,7 @@ export class MediaSourceDB {
.length, .length,
); );
const isUpdate = newServer && newServer.uuid !== serverName; const isUpdate = newServer && newServer.uuid !== serverId;
if (!isUpdate) { if (!isUpdate) {
// Remove all associations of this program // Remove all associations of this program
// TODO: See if we can just get this automatically with foreign keys... // TODO: See if we can just get this automatically with foreign keys...
@@ -399,3 +461,9 @@ export class MediaSourceDB {
return [...channelReports, ...fillerReports, ...customShowReports]; 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 { isNonEmptyString } from '@/util/index.js';
import { createExternalId } from '@tunarr/shared'; import { createExternalId } from '@tunarr/shared';
import type { ContentProgram, CustomProgram } from '@tunarr/types'; 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'; import { reduce } from 'lodash-es';
// Takes a listing of programs and makes a mapping of a unique identifier, // 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 // TODO handle other types of programs
} else if ( } else if (
isContentProgram(p) && isContentProgram(p) &&
isNonEmptyString(p.externalSourceName) && isNonEmptyString(p.externalSourceId) &&
isNonEmptyString(p.externalSourceType) && isNonEmptyString(p.externalSourceType) &&
isNonEmptyString(p.externalKey) isNonEmptyString(p.externalKey)
) { ) {
acc[ acc[
createExternalId( createExternalId(
p.externalSourceType, p.externalSourceType,
p.externalSourceName, tag(p.externalSourceId),
p.externalKey, p.externalKey,
) )
] = idx++; ] = idx++;

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
import type { TupleToUnion } from '@tunarr/types'; import type { TupleToUnion } from '@tunarr/types';
import { inArray } from 'drizzle-orm'; import { inArray } from 'drizzle-orm';
import { check, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; import { check, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import type { Updateable } from 'kysely';
import { type Insertable, type Selectable } from 'kysely'; import { type Insertable, type Selectable } from 'kysely';
import type { StrictOmit } from 'ts-essentials';
import { type KyselifyBetter } from './KyselifyBetter.ts'; 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; export const MediaSourceTypes = ['plex', 'jellyfin', 'emby'] as const;
@@ -22,13 +24,13 @@ export const MediaSourceType: MediaSourceMap = {
export const MediaSource = sqliteTable( export const MediaSource = sqliteTable(
'media_source', 'media_source',
{ {
uuid: text().primaryKey(), uuid: text().primaryKey().$type<MediaSourceId>(),
createdAt: integer(), createdAt: integer(),
updatedAt: integer(), updatedAt: integer(),
accessToken: text().notNull(), accessToken: text().notNull(),
clientIdentifier: text(), clientIdentifier: text(),
index: integer().notNull(), index: integer().notNull(),
name: text().notNull(), name: text().notNull().$type<MediaSourceName>(),
sendChannelUpdates: integer({ mode: 'boolean' }).default(false), sendChannelUpdates: integer({ mode: 'boolean' }).default(false),
sendGuideUpdates: integer({ mode: 'boolean' }).default(false), sendGuideUpdates: integer({ mode: 'boolean' }).default(false),
type: text({ enum: MediaSourceTypes }).notNull(), type: text({ enum: MediaSourceTypes }).notNull(),
@@ -63,20 +65,63 @@ export const MediaSourceFields: (keyof MediaSourceTable)[] = [
export type MediaSourceTable = KyselifyBetter<typeof MediaSource>; export type MediaSourceTable = KyselifyBetter<typeof MediaSource>;
export type MediaSource = Selectable<MediaSourceTable>; export type MediaSource = Selectable<MediaSourceTable>;
export type NewMediaSource = Insertable<MediaSourceTable>; export type NewMediaSource = Insertable<MediaSourceTable>;
export type MediaSourceUpdate = Updateable<MediaSourceTable>;
export type SpecificMediaSourceType<Typ extends MediaSourceType> = StrictOmit< export const MediaLibraryTypes = [
MediaSource, 'movies',
'type' 'shows',
> & { 'music_videos',
type: Typ; 'other_videos',
}; 'tracks',
] as const;
export type PlexMediaSource = SpecificMediaSourceType< export type MediaLibraryType = TupleToUnion<typeof MediaLibraryTypes>;
typeof MediaSourceType.Plex
>; export const MediaSourceLibrary = sqliteTable(
export type JellyfinMediaSource = SpecificMediaSourceType< 'media_source_library',
typeof MediaSourceType.Jellyfin {
>; uuid: text().primaryKey().notNull(),
export type EmbyMediaSource = SpecificMediaSourceType< name: text().notNull(),
typeof MediaSourceType.Emby 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 type { TupleToUnion } from '@tunarr/types';
import { inArray } from 'drizzle-orm'; import { inArray } from 'drizzle-orm';
import { import {
@@ -12,8 +11,14 @@ import {
import type { Insertable, Selectable, Updateable } from 'kysely'; import type { Insertable, Selectable, Updateable } from 'kysely';
import type { MarkNotNilable } from '../../types/util.ts'; import type { MarkNotNilable } from '../../types/util.ts';
import { type KyselifyBetter } from './KyselifyBetter.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 { ProgramGrouping } from './ProgramGrouping.ts';
import type { MediaSourceName } from './base.ts';
import { type MediaSourceId } from './base.ts';
export const ProgramTypes = [ export const ProgramTypes = [
'movie', 'movie',
@@ -41,14 +46,18 @@ export const Program = sqliteTable(
albumUuid: text().references(() => ProgramGrouping.uuid), albumUuid: text().references(() => ProgramGrouping.uuid),
artistName: text(), artistName: text(),
artistUuid: text().references(() => ProgramGrouping.uuid), artistUuid: text().references(() => ProgramGrouping.uuid),
canonicalId: text(),
duration: integer().notNull(), duration: integer().notNull(),
episode: integer(), episode: integer(),
episodeIcon: text(), episodeIcon: text(),
externalKey: text().notNull(), externalKey: text().notNull(),
externalSourceId: text().notNull(), externalSourceId: text().notNull().$type<MediaSourceName>(),
mediaSourceId: text().references(() => MediaSource.uuid, { mediaSourceId: text()
onDelete: 'cascade', .references(() => MediaSource.uuid, {
}), onDelete: 'cascade',
})
.$type<MediaSourceId>(),
libraryId: text().references(() => MediaSourceLibrary.uuid),
filePath: text(), filePath: text(),
grandparentExternalKey: text(), grandparentExternalKey: text(),
icon: text(), icon: text(),
@@ -77,6 +86,11 @@ export const Program = sqliteTable(
uniqueIndex( uniqueIndex(
'program_source_type_external_source_id_external_key_unique', 'program_source_type_external_source_id_external_key_unique',
).on(table.sourceType, table.externalSourceId, table.externalKey), ).on(table.sourceType, table.externalSourceId, table.externalKey),
uniqueIndex('program_source_type_media_source_external_key_unique').on(
table.sourceType,
table.mediaSourceId,
table.externalKey,
),
check( check(
'program_type_check', 'program_type_check',
inArray(table.type, table.type.enumValues).inlineParams(), inArray(table.type, table.type.enumValues).inlineParams(),
@@ -85,17 +99,15 @@ export const Program = sqliteTable(
'program_source_type_check', 'program_source_type_check',
inArray(table.sourceType, table.sourceType.enumValues).inlineParams(), inArray(table.sourceType, table.sourceType.enumValues).inlineParams(),
), ),
index('program_canonical_id_index').on(table.canonicalId),
], ],
); );
export type ProgramTable = KyselifyBetter<typeof Program>; export type ProgramTable = KyselifyBetter<typeof Program>;
export type ProgramDao = Selectable<ProgramTable>; export type ProgramDao = Selectable<ProgramTable>;
// Make canonicalId required on insert.
export type NewProgramDao = MarkNotNilable< export type NewProgramDao = MarkNotNilable<
Insertable<ProgramTable>, Insertable<ProgramTable>,
'mediaSourceId' 'canonicalId' | 'mediaSourceId'
>; >;
export type ProgramDaoUpdate = Updateable<ProgramTable>; 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 { omit } from 'lodash-es';
import type { MarkRequired, StrictOmit } from 'ts-essentials'; import type { MarkRequired, StrictOmit } from 'ts-essentials';
import type { MarkNotNilable } from '../../types/util.ts'; import type { MarkNotNilable } from '../../types/util.ts';
import type { MediaSourceId, MediaSourceName } from './base.ts';
import { ProgramExternalIdSourceTypes } from './base.ts'; import { ProgramExternalIdSourceTypes } from './base.ts';
import { type KyselifyBetter } from './KyselifyBetter.ts'; import { type KyselifyBetter } from './KyselifyBetter.ts';
import { MediaSource } from './MediaSource.ts'; import { MediaSource } from './MediaSource.ts';
@@ -25,10 +26,12 @@ export const ProgramExternalId = sqliteTable(
directFilePath: text(), directFilePath: text(),
externalFilePath: text(), externalFilePath: text(),
externalKey: text().notNull(), externalKey: text().notNull(),
externalSourceId: text(), externalSourceId: text().$type<MediaSourceName>(),
mediaSourceId: text().references(() => MediaSource.uuid, { mediaSourceId: text()
onDelete: 'cascade', .references(() => MediaSource.uuid, {
}), onDelete: 'cascade',
})
.$type<MediaSourceId>(),
programUuid: text() programUuid: text()
.notNull() .notNull()
.references(() => Program.uuid, { onDelete: 'cascade' }), .references(() => Program.uuid, { onDelete: 'cascade' }),
@@ -94,6 +97,7 @@ export const ProgramExternalIdKeys: (keyof ProgramExternalId)[] = [
'externalSourceId', 'externalSourceId',
'programUuid', 'programUuid',
'sourceType', 'sourceType',
'mediaSourceId',
// 'updatedAt', // 'updatedAt',
'uuid', 'uuid',
]; ];

View File

@@ -9,11 +9,12 @@ import {
text, text,
} from 'drizzle-orm/sqlite-core'; } from 'drizzle-orm/sqlite-core';
import type { Insertable, Selectable, Updateable } from 'kysely'; import type { Insertable, Selectable, Updateable } from 'kysely';
import type { MarkRequiredNotNull } from '../../types/util.ts';
import { type KyselifyBetter } from './KyselifyBetter.ts'; import { type KyselifyBetter } from './KyselifyBetter.ts';
import { MediaSourceLibrary } from './MediaSource.ts';
import type { ProgramGroupingTable as RawProgramGrouping } from './ProgramGrouping.ts';
export const ProgramGroupingType: Readonly< export const ProgramGroupingType = {
Record<Capitalize<ProgramGroupingType>, ProgramGroupingType>
> = {
Show: 'show', Show: 'show',
Season: 'season', Season: 'season',
Artist: 'artist', Artist: 'artist',
@@ -33,16 +34,18 @@ export const ProgramGrouping = sqliteTable(
'program_grouping', 'program_grouping',
{ {
uuid: text().primaryKey(), uuid: text().primaryKey(),
canonicalId: text(),
createdAt: integer(), createdAt: integer(),
updatedAt: integer(), updatedAt: integer(),
artistUuid: text().references((): AnySQLiteColumn => ProgramGrouping.uuid),
icon: text(), icon: text(),
index: integer(), index: integer(),
showUuid: text().references((): AnySQLiteColumn => ProgramGrouping.uuid),
summary: text(), summary: text(),
title: text().notNull(), title: text().notNull(),
type: text({ enum: ProgramGroupingTypes }).notNull(), type: text({ enum: ProgramGroupingTypes }).notNull(),
year: integer(), year: integer(),
artistUuid: text().references((): AnySQLiteColumn => ProgramGrouping.uuid),
showUuid: text().references((): AnySQLiteColumn => ProgramGrouping.uuid),
libraryId: text().references(() => MediaSourceLibrary.uuid),
}, },
(table) => [ (table) => [
index('program_grouping_show_uuid_index').on(table.showUuid), 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 ProgramGroupingTable = KyselifyBetter<typeof ProgramGrouping>;
export type ProgramGrouping = Selectable<ProgramGroupingTable>; export type ProgramGrouping = Selectable<ProgramGroupingTable>;
export type NewProgramGrouping = Insertable<ProgramGroupingTable>; export type NewProgramGrouping = MarkRequiredNotNull<
Insertable<ProgramGroupingTable>,
'canonicalId' | 'libraryId'
>;
export type ProgramGroupingUpdate = Updateable<ProgramGroupingTable>; 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 { omit } from 'lodash-es';
import type { StrictOmit } from 'ts-essentials'; import type { StrictOmit } from 'ts-essentials';
import type { MarkNotNilable } from '../../types/util.ts'; import type { MarkNotNilable } from '../../types/util.ts';
import type { MediaSourceId, MediaSourceName } from './base.ts';
import { ProgramExternalIdSourceTypes } from './base.ts'; import { ProgramExternalIdSourceTypes } from './base.ts';
import { type KyselifyBetter } from './KyselifyBetter.ts'; import { type KyselifyBetter } from './KyselifyBetter.ts';
import { MediaSource } from './MediaSource.ts'; import { MediaSource, MediaSourceLibrary } from './MediaSource.ts';
import { ProgramGrouping } from './ProgramGrouping.ts'; import { ProgramGrouping } from './ProgramGrouping.ts';
export const ProgramGroupingExternalId = sqliteTable( export const ProgramGroupingExternalId = sqliteTable(
@@ -23,10 +24,12 @@ export const ProgramGroupingExternalId = sqliteTable(
updatedAt: integer(), updatedAt: integer(),
externalFilePath: text(), externalFilePath: text(),
externalKey: text().notNull(), externalKey: text().notNull(),
externalSourceId: text(), externalSourceId: text().$type<MediaSourceName>(),
mediaSourceId: text().references(() => MediaSource.uuid, { mediaSourceId: text()
onDelete: 'cascade', .references(() => MediaSource.uuid, {
}), onDelete: 'cascade',
})
.$type<MediaSourceId>(),
groupUuid: text() groupUuid: text()
.notNull() .notNull()
.references(() => ProgramGrouping.uuid, { .references(() => ProgramGrouping.uuid, {
@@ -34,6 +37,9 @@ export const ProgramGroupingExternalId = sqliteTable(
onUpdate: 'cascade', onUpdate: 'cascade',
}), }),
sourceType: text({ enum: ProgramExternalIdSourceTypes }).notNull(), sourceType: text({ enum: ProgramExternalIdSourceTypes }).notNull(),
libraryId: text().references(() => MediaSourceLibrary.uuid, {
onDelete: 'cascade',
}),
}, },
(table) => [ (table) => [
index('program_grouping_group_uuid_index').on(table.groupUuid), index('program_grouping_group_uuid_index').on(table.groupUuid),
@@ -63,7 +69,7 @@ export type NewSingleOrMultiProgramGroupingExternalId =
> & { type: 'multi' }); > & { type: 'multi' });
export function toInsertableProgramGroupingExternalId( export function toInsertableProgramGroupingExternalId(
eid: NewSingleOrMultiProgramGroupingExternalId, eid: NewProgramGroupingExternalId | NewSingleOrMultiProgramGroupingExternalId,
): NewProgramGroupingExternalId { ): NewProgramGroupingExternalId {
return omit(eid, 'type') satisfies NewProgramGroupingExternalId; return omit(eid, 'type') satisfies NewProgramGroupingExternalId;
} }
@@ -74,13 +80,13 @@ export type ProgramGroupingExternalIdFields<
export const ProgramGroupingExternalIdKeys: (keyof ProgramGroupingExternalId)[] = export const ProgramGroupingExternalIdKeys: (keyof ProgramGroupingExternalId)[] =
[ [
// 'createdAt', 'createdAt',
'externalFilePath', 'externalFilePath',
'externalKey', 'externalKey',
'externalSourceId', 'externalSourceId',
'sourceType', 'sourceType',
'groupUuid', 'groupUuid',
// 'updatedAt', 'updatedAt',
'uuid', 'uuid',
]; ];

View File

@@ -1,4 +1,4 @@
import { type TupleToUnion } from '@tunarr/types'; import { type Tag, type TupleToUnion } from '@tunarr/types';
import { import {
ContentProgramTypeSchema, ContentProgramTypeSchema,
ResolutionSchema, ResolutionSchema,
@@ -120,3 +120,6 @@ export const ChannelOfflineSettingsSchema = z.object({
export type ChannelOfflineSettings = z.infer< export type ChannelOfflineSettings = z.infer<
typeof ChannelOfflineSettingsSchema 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'; } from './Channel.ts';
import type { CustomShowContentTable, CustomShowTable } from './CustomShow.js'; import type { CustomShowContentTable, CustomShowTable } from './CustomShow.js';
import type { FillerShowContentTable, FillerShowTable } from './FillerShow.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 { MikroOrmMigrationsTable } from './MikroOrmMigrations.js';
import type { ProgramTable } from './Program.ts'; import type { ProgramTable } from './Program.ts';
import type { ProgramExternalIdTable } from './ProgramExternalId.ts'; import type { ProgramExternalIdTable } from './ProgramExternalId.ts';
@@ -34,6 +37,7 @@ export interface DB {
fillerShow: FillerShowTable; fillerShow: FillerShowTable;
fillerShowContent: FillerShowContentTable; fillerShowContent: FillerShowContentTable;
mediaSource: MediaSourceTable; mediaSource: MediaSourceTable;
mediaSourceLibrary: MediaSourceLibraryTable;
program: ProgramTable; program: ProgramTable;
programExternalId: ProgramExternalIdTable; programExternalId: ProgramExternalIdTable;
programGrouping: ProgramGroupingTable; 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 { DeepNullable, MarkRequired, StrictOmit } from 'ts-essentials';
import type { Channel, ChannelFillerShow } from './Channel.ts'; import type { Channel, ChannelFillerShow } from './Channel.ts';
import type { FillerShow } from './FillerShow.ts'; import type { FillerShow } from './FillerShow.ts';
import type { ProgramDao } from './Program.ts'; import type {
import type { MinimalProgramExternalId } from './ProgramExternalId.ts'; MediaSource,
import type { ProgramGrouping } from './ProgramGrouping.ts'; MediaSourceLibrary,
import type { ProgramGroupingExternalId } from './ProgramGroupingExternalId.ts'; 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'; import type { ChannelSubtitlePreferences } from './SubtitlePreferences.ts';
export type ProgramWithRelations = ProgramDao & { export type ProgramWithRelations = ProgramDao & {
@@ -18,6 +33,39 @@ export type ProgramWithRelations = ProgramDao & {
externalIds?: MinimalProgramExternalId[]; 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 & { export type ChannelWithRelations = Channel & {
programs?: ProgramWithRelations[]; programs?: ProgramWithRelations[];
fillerContent?: ProgramWithRelations[]; fillerContent?: ProgramWithRelations[];
@@ -58,6 +106,21 @@ export type ProgramWithExternalIds = ProgramDao & {
externalIds: MinimalProgramExternalId[]; 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 & { export type ProgramGroupingWithExternalIds = ProgramGrouping & {
externalIds: ProgramGroupingExternalId[]; externalIds: ProgramGroupingExternalId[];
}; };
@@ -96,3 +159,55 @@ export type GeneralizedProgramGroupingWithExternalIds =
| TvSeasonWithExternalIds | TvSeasonWithExternalIds
| MusicAlbumWithExternalIds | MusicAlbumWithExternalIds
| MusicArtistWithExternalIds; | 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 { isDefined, isNodeError } from '@/util/index.js';
import type { Logger } from '@/util/logging/LoggerFactory.js'; import type { Logger } from '@/util/logging/LoggerFactory.js';
import { LoggerFactory } 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 { MediaSourceUnhealthyStatus } from '@tunarr/types/api';
import type { import type {
AxiosHeaderValue, AxiosHeaderValue,
@@ -11,54 +12,75 @@ import type {
AxiosRequestConfig, AxiosRequestConfig,
} from 'axios'; } from 'axios';
import axios, { isAxiosError } 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 { 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 = { export type ApiClientOptions = {
name: string; mediaSource: StrictOmit<
mediaSourceUuid?: string; MediaSourceWithLibraries,
accessToken: string; | 'createdAt'
url: string; | 'updatedAt'
userId: string | null; | 'clientIdentifier'
username: string | null; | 'index'
| 'sendChannelUpdates'
| 'sendGuideUpdates'
>;
extraHeaders?: { extraHeaders?: {
[key: string]: AxiosHeaderValue; [key: string]: AxiosHeaderValue;
}; };
enableRequestCache?: boolean; enableRequestCache?: boolean;
queueOpts?: {
concurrency: number;
interval: Duration;
};
}; };
export type QuerySuccessResult<T> = { export type RemoteMediaSourceOptions = ApiClientOptions & {
type: 'success'; apiKey: string;
data: T;
}; };
type QueryErrorCode = const QueryErrorCodes = [
| 'not_found' 'not_found',
| 'no_access_token' 'no_access_token',
| 'parse_error' 'parse_error',
| 'generic_request_error'; 'generic_request_error',
] as const;
type QueryErrorCode = TupleToUnion<typeof QueryErrorCodes>;
export type QueryErrorResult = { export abstract class QueryError extends WrappedError {
type: 'error'; readonly type: QueryErrorCode;
code: QueryErrorCode;
message?: string;
};
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 { static genericQueryError(message?: string): QueryError {
return x.type === 'error'; 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>( export type QueryResult<T> = Result<T, QueryError>;
x: QueryResult<T>,
): x is QuerySuccessResult<T> {
return x.type === 'success';
}
export abstract class BaseApiClient< export abstract class BaseApiClient<
OptionsType extends ApiClientOptions = ApiClientOptions, OptionsType extends ApiClientOptions = ApiClientOptions,
> { > {
private queue?: PQueue;
protected logger: Logger; protected logger: Logger;
protected axiosInstance: AxiosInstance; protected axiosInstance: AxiosInstance;
protected redacter?: AxiosRequestRedacter; protected redacter?: AxiosRequestRedacter;
@@ -66,12 +88,13 @@ export abstract class BaseApiClient<
constructor(protected options: OptionsType) { constructor(protected options: OptionsType) {
this.logger = LoggerFactory.child({ this.logger = LoggerFactory.child({
className: this.constructor.name, className: this.constructor.name,
serverName: options.name, serverName: options.mediaSource.name,
}); });
const url = options.url.endsWith('/') const url = options.mediaSource.uri.endsWith('/')
? options.url.slice(0, options.url.length - 1) ? options.mediaSource.uri.slice(0, options.mediaSource.uri.length - 1)
: options.url; : options.mediaSource.uri;
this.options.mediaSource.uri = url;
this.axiosInstance = axios.create({ this.axiosInstance = axios.create({
baseURL: url, 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); configureAxiosLogging(this.axiosInstance, this.logger);
} }
setApiClientOptions(opts: OptionsType) {
this.options = opts;
}
async doTypeCheckedGet<T extends z.ZodType, Out = z.infer<T>>( async doTypeCheckedGet<T extends z.ZodType, Out = z.infer<T>>(
path: string, path: string,
schema: T, schema: T,
@@ -120,28 +154,23 @@ export abstract class BaseApiClient<
return this.makeErrorResult('parse_error'); return this.makeErrorResult('parse_error');
} }
protected preRequestValidate( protected preRequestValidate<T>(
_req: AxiosRequestConfig, _req: AxiosRequestConfig,
): Maybe<QueryErrorResult> { ): Maybe<QueryResult<T>> {
return; return;
} }
protected makeErrorResult( protected makeErrorResult<T>(
code: QueryErrorCode, type: QueryErrorCode,
message?: string, message?: string,
): QueryErrorResult { ): QueryResult<T> {
return { return Result.failure<T, QueryError>(
type: 'error', QueryError.create(type, message ?? 'Unknown Error'),
code, );
message,
};
} }
protected makeSuccessResult<T>(data: T): QuerySuccessResult<T> { protected makeSuccessResult<T>(data: T): QueryResult<T> {
return { return Result.success<T, QueryError>(data);
type: 'success',
data,
};
} }
doGet<T>(req: Omit<AxiosRequestConfig, 'method'>) { doGet<T>(req: Omit<AxiosRequestConfig, 'method'>) {
@@ -162,13 +191,17 @@ export abstract class BaseApiClient<
getFullUrl(path: string): string { getFullUrl(path: string): string {
const sanitizedPath = path.startsWith('/') ? path : `/${path}`; 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(); return url.toString();
} }
protected async doRequest<T>(req: AxiosRequestConfig): Promise<T> { protected async doRequest<T>(req: AxiosRequestConfig): Promise<T> {
try { 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; return response.data;
} catch (error) { } catch (error) {
if (isAxiosError(error)) { if (isAxiosError(error)) {
@@ -185,7 +218,7 @@ export abstract class BaseApiClient<
// The request was made and the server responded with a status code // The request was made and the server responded with a status code
// that falls out of the range of 2xx // that falls out of the range of 2xx
this.logger.warn( 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 ?? '', error.config?.url ?? '',
status, status,
error.config?.params ?? {}, 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 // 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 // and just return a generic error. Something is probably fatally wrong
// at this point. // 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 }); throw new Error('Unknown error', { cause: error });
} }
} }
@@ -245,4 +278,10 @@ export abstract class BaseApiClient<
return status; 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 { inject, injectable, LazyServiceIdentifier } from 'inversify';
import { forEach, isBoolean, isEmpty, isNil } from 'lodash-es'; import { forEach, isBoolean, isEmpty, isNil } from 'lodash-es';
import NodeCache from 'node-cache'; import NodeCache from 'node-cache';
import { MarkRequired } from 'ts-essentials';
import type { ISettingsDB } from '../db/interfaces/ISettingsDB.ts'; 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 { KEYS } from '../types/inject.ts';
import { Result } from '../types/result.ts'; import { Result } from '../types/result.ts';
import { cacheGetOrSet } from '../util/cache.ts'; import { cacheGetOrSet } from '../util/cache.ts';
import { Logger } from '../util/logging/LoggerFactory.ts'; import { Logger } from '../util/logging/LoggerFactory.ts';
import { import { type ApiClientOptions } from './BaseApiClient.js';
isQueryError,
type ApiClientOptions,
type BaseApiClient,
} from './BaseApiClient.js';
import { EmbyApiClient } from './emby/EmbyApiClient.ts'; import { EmbyApiClient } from './emby/EmbyApiClient.ts';
import { JellyfinApiClient } from './jellyfin/JellyfinApiClient.js'; 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 = [ type TypeToClient = [
[typeof MediaSourceType.Plex, PlexApiClient], [typeof MediaSourceType.Plex, PlexApiClient],
@@ -45,6 +43,12 @@ export class MediaSourceApiFactory {
@inject(new LazyServiceIdentifier(() => MediaSourceDB)) @inject(new LazyServiceIdentifier(() => MediaSourceDB))
private mediaSourceDB: MediaSourceDB, private mediaSourceDB: MediaSourceDB,
@inject(KEYS.SettingsDB) private settings: ISettingsDB, @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 = this.#requestCacheEnabled =
settings.systemSettings().cache?.enablePlexRequestCache ?? false; settings.systemSettings().cache?.enablePlexRequestCache ?? false;
@@ -60,88 +64,95 @@ export class MediaSourceApiFactory {
}); });
} }
getJellyfinApiClientForMediaSource(mediaSource: MediaSource) { getJellyfinApiClientForMediaSource(mediaSource: MediaSourceWithLibraries) {
return this.getJellyfinApiClient(mediaSourceToApiOptions(mediaSource)); return this.getJellyfinApiClient({ mediaSource });
} }
getJellyfinApiClient(opts: ApiClientOptions) { getJellyfinApiClient(opts: ApiClientOptions): Promise<JellyfinApiClient> {
return this.getTyped(MediaSourceType.Jellyfin, opts, (opts) => { const client = this.jellyfinApiClientFactory(opts);
return Promise.resolve(new JellyfinApiClient(opts)); client.setApiClientOptions(opts);
}); return Promise.resolve(client);
} }
getEmbyApiClientForMediaSource(mediaSource: MediaSource) { getEmbyApiClientForMediaSource(mediaSource: MediaSourceWithLibraries) {
return this.getEmbyApiClient(mediaSourceToApiOptions(mediaSource)); return this.getEmbyApiClient({ mediaSource });
} }
getEmbyApiClient(opts: ApiClientOptions) { async getEmbyApiClient(opts: ApiClientOptions) {
return this.getTyped(MediaSourceType.Jellyfin, opts, async (opts) => { let userId = opts.mediaSource.userId;
let userId = opts.userId; let username: Maybe<string>;
let username: Maybe<string>; if (isEmpty(userId)) {
if (isEmpty(userId)) { this.logger.warn(
this.logger.warn( 'Emby connection does not have a user ID set. This could lead to errors. Please reconnect Emby.',
'Emby connection does not have a user ID set. This could lead to errors. Please reconnect Emby.', );
); const adminResult = await Result.attemptAsync(() =>
const adminResult = await Result.attemptAsync(() => EmbyApiClient.findAdminUser(opts, opts.mediaSource.accessToken),
EmbyApiClient.findAdminUser(opts, opts.accessToken), );
);
adminResult adminResult
.filter((res) => isNonEmptyString(res?.Id)) .filter((res) => isNonEmptyString(res?.Id))
.forEach((adminUser) => { .forEach((adminUser) => {
userId = adminUser!.Id!; userId = adminUser!.Id!;
username = adminUser!.Name ?? undefined; username = adminUser!.Name ?? undefined;
}); });
} }
if ( if (
isNonEmptyString(opts.mediaSourceUuid) && isNonEmptyString(opts.mediaSource.uuid) &&
(isEmpty(opts.userId) || (isEmpty(opts.mediaSource.userId) ||
opts.userId !== userId || opts.mediaSource.userId !== userId ||
isEmpty(opts.username) || isEmpty(opts.mediaSource.username) ||
opts.username != username) opts.mediaSource.username != username)
) { ) {
this.mediaSourceDB this.mediaSourceDB
.setMediaSourceUserInfo(opts.mediaSourceUuid, { .setMediaSourceUserInfo(opts.mediaSource.uuid, {
userId: userId ?? undefined, userId: userId ?? undefined,
username, username,
}) })
.catch((e) => { .catch((e) => {
this.logger.error( this.logger.error(
e, e,
'Error updating Jellyfin media source user info', 'Error updating Jellyfin media source user info',
); );
}); });
} }
return new EmbyApiClient({ ...opts, userId }); return this.embyApiClientFactory({
...opts,
mediaSource: { ...opts.mediaSource, userId },
}); });
} }
getPlexApiClientForMediaSource( getPlexApiClientForMediaSource(
mediaSource: MediaSource, mediaSource: MediaSourceWithLibraries,
): Promise<PlexApiClient> { ): Promise<PlexApiClient> {
const opts = mediaSourceToApiOptions(mediaSource); // const opts = mediaSourceToApiOptions(mediaSource);
return this.getPlexApiClient(opts); return this.getPlexApiClient({ mediaSource });
} }
getPlexApiClient(opts: ApiClientOptions): Promise<PlexApiClient> { getPlexApiClient(opts: ApiClientOptions): Promise<PlexApiClient> {
const key = `${opts.url}|${opts.accessToken}`; // const key = `${opts.url}|${opts.accessToken}`;
return cacheGetOrSet(MediaSourceApiFactory.cache, key, () => { // const client = await cacheGetOrSet(MediaSourceApiFactory.cache, key, () => {
return Promise.resolve( // return Promise.resolve(
new PlexApiClient({ // ,
...opts, // );
enableRequestCache: this.requestCacheEnabledForServer(opts.name), // });
}), // 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) => { return this.getTypedByName(MediaSourceType.Plex, name, (mediaSource) => {
const client = new PlexApiClient({ const client = this.plexApiClientFactory({
...mediaSource, mediaSource,
url: mediaSource.uri,
enableRequestCache: this.requestCacheEnabledForServer(mediaSource.name), enableRequestCache: this.requestCacheEnabledForServer(mediaSource.name),
}); });
@@ -154,29 +165,25 @@ export class MediaSourceApiFactory {
}); });
} }
async getJellyfinApiClientByName(name: string, userId?: string) { async getJellyfinApiClientById(name: MediaSourceId, userId?: string) {
return this.getTypedByName( return this.getTypedByName(MediaSourceType.Jellyfin, name, (opts) =>
MediaSourceType.Jellyfin, this.jellyfinApiClientFactory({
name, mediaSource: {
(opts) =>
new JellyfinApiClient({
...opts, ...opts,
url: opts.uri,
userId: opts.userId ?? userId ?? null, userId: opts.userId ?? userId ?? null,
}), },
}),
); );
} }
async getEmbyApiClientByName(name: string, userId?: string) { async getEmbyApiClientById(name: MediaSourceId, userId?: string) {
return this.getTypedByName( return this.getTypedByName(MediaSourceType.Emby, name, (opts) =>
MediaSourceType.Emby, this.embyApiClientFactory({
name, mediaSource: {
(opts) =>
new EmbyApiClient({
...opts, ...opts,
url: opts.uri,
userId: opts.userId ?? userId ?? null, userId: opts.userId ?? userId ?? null,
}), },
}),
); );
} }
@@ -185,34 +192,13 @@ export class MediaSourceApiFactory {
return MediaSourceApiFactory.cache.del(key) === 1; 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< private async getTypedByName<
X extends MediaSourceType, X extends MediaSourceType,
ApiClient = FindChild<X, TypeToClient>, ApiClient = FindChild<X, TypeToClient>,
>( >(
type: X, type: X,
name: string, name: MediaSourceId,
factory: (opts: MediaSource) => ApiClient, factory: (opts: MediaSourceWithLibraries) => ApiClient,
): Promise<Maybe<ApiClient>> { ): Promise<Maybe<ApiClient>> {
const key = `${type}|${name}`; const key = `${type}|${name}`;
return cacheGetOrSet<Maybe<ApiClient>>( return cacheGetOrSet<Maybe<ApiClient>>(
@@ -247,19 +233,21 @@ export class MediaSourceApiFactory {
} }
private async backfillPlexUserId( private async backfillPlexUserId(
mediaSourceId: string, mediaSourceId: MediaSourceId,
client: PlexApiClient, client: PlexApiClient,
) { ) {
this.logger.debug('Attempting to backfill Plex user'); this.logger.debug('Attempting to backfill Plex user');
const result = await Result.attemptAsync(async () => { const result = await Result.attemptAsync(async () => {
const user = await client.getUser(); const userResult = await client.getUser();
if (isQueryError(user)) { if (userResult.isFailure()) {
throw new Error(user.message); throw userResult.error;
} }
const user = userResult.get();
await this.mediaSourceDB.setMediaSourceUserInfo(mediaSourceId, { await this.mediaSourceDB.setMediaSourceUserInfo(mediaSourceId, {
userId: user.data.id?.toString(), userId: user.id?.toString(),
username: user.data.username, username: user.username,
}); });
}); });
if (result.isFailure()) { 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 type { IProgramDB } from '@/db/interfaces/IProgramDB.js';
import { ProgramType } from '@/db/schema/Program.js'; import { ProgramType } from '@/db/schema/Program.js';
import type { ProgramWithExternalIds } from '@/db/schema/derivedTypes.js'; import type { ProgramWithExternalIds } from '@/db/schema/derivedTypes.js';
import { isQueryError } from '@/external/BaseApiClient.js';
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js'; import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
import { GlobalScheduler } from '@/services/Scheduler.js'; import { GlobalScheduler } from '@/services/Scheduler.js';
import { ReconcileProgramDurationsTask } from '@/tasks/ReconcileProgramDurationsTask.js'; import { ReconcileProgramDurationsTask } from '@/tasks/ReconcileProgramDurationsTask.js';
import { KEYS } from '@/types/inject.js'; import { KEYS } from '@/types/inject.js';
import { Maybe } from '@/types/util.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 { type Logger } from '@/util/logging/LoggerFactory.js';
import { JellyfinItem, JellyfinItemKind } from '@tunarr/types/jellyfin'; 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 { find, isUndefined, some } from 'lodash-es';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { container } from '../../container.ts'; import { container } from '../../container.ts';
@@ -21,6 +25,7 @@ import {
} from '../../db/custom_types/ProgramExternalIdType.ts'; } from '../../db/custom_types/ProgramExternalIdType.ts';
import { MediaSourceDB } from '../../db/mediaSourceDB.ts'; import { MediaSourceDB } from '../../db/mediaSourceDB.ts';
import { MediaSourceType } from '../../db/schema/MediaSource.ts'; import { MediaSourceType } from '../../db/schema/MediaSource.ts';
import { MediaSourceId } from '../../db/schema/base.ts';
import { ReconcileProgramDurationsTaskFactory } from '../../tasks/TasksModule.ts'; import { ReconcileProgramDurationsTaskFactory } from '../../tasks/TasksModule.ts';
import { JellyfinGetItemsQuery } from './JellyfinApiClient.ts'; import { JellyfinGetItemsQuery } from './JellyfinApiClient.ts';
@@ -29,9 +34,11 @@ export class JellyfinItemFinder {
constructor( constructor(
@inject(KEYS.ProgramDB) private programDB: IProgramDB, @inject(KEYS.ProgramDB) private programDB: IProgramDB,
@inject(KEYS.Logger) private logger: Logger, @inject(KEYS.Logger) private logger: Logger,
@inject(MediaSourceApiFactory) @inject(new LazyServiceIdentifier(() => MediaSourceApiFactory))
private mediaSourceApiFactory: MediaSourceApiFactory, private mediaSourceApiFactory: MediaSourceApiFactory,
@inject(MediaSourceDB) private mediaSourceDB: MediaSourceDB, @inject(MediaSourceDB) private mediaSourceDB: MediaSourceDB,
@inject(KEYS.ProgramDaoMinterFactory)
private programMinterFactory: interfaces.AutoFactory<ProgramDaoMinter>,
) {} ) {}
async findForProgramAndUpdate(programId: string) { async findForProgramAndUpdate(programId: string) {
@@ -53,7 +60,7 @@ export class JellyfinItemFinder {
(eid) => eid.sourceType === ProgramExternalIdType.JELLYFIN, (eid) => eid.sourceType === ProgramExternalIdType.JELLYFIN,
); );
const minter = ProgramMinterFactory.create(); const minter = this.programMinterFactory();
const newExternalId = minter.mintJellyfinExternalIdForApiItem( const newExternalId = minter.mintJellyfinExternalIdForApiItem(
program.externalSourceId, program.externalSourceId,
program.uuid, program.uuid,
@@ -69,25 +76,41 @@ export class JellyfinItemFinder {
// Right now just check if the durations are different. // Right now just check if the durations are different.
// otherwise we might blow away details we already have, since // otherwise we might blow away details we already have, since
// Jellyfin collects metadata asynchronously (sometimes) // Jellyfin collects metadata asynchronously (sometimes)
const mediaSourceId = const mediaSource = await run(async () => {
program.mediaSourceId ?? if (!isNonEmptyString(program.mediaSourceId)) {
(await run(async () => { throw new Error(`Program ${program.uuid} has no media source ID`);
const ms = await this.findMediaSource(program.externalSourceId); }
if (!ms)
throw new Error( const ms = await this.findMediaSource(program.mediaSourceId);
`Could not find media source by name: ${program.externalSourceId}`,
); if (!ms)
return ms.uuid; throw new Error(
})); `Could not find media source by name: ${program.externalSourceId}`,
const updatedProgram = minter.mint( );
program.externalSourceId, return ms;
mediaSourceId, });
{
sourceType: 'jellyfin', if (!program.libraryId) {
program: potentialApiMatch, 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) { if (updatedProgram.duration !== program.duration) {
await this.programDB.updateProgramDuration( await this.programDB.updateProgramDuration(
program.uuid, program.uuid,
@@ -121,22 +144,29 @@ export class JellyfinItemFinder {
return; return;
} }
const jfClient = if (!isNonEmptyString(program.mediaSourceId)) {
await this.mediaSourceApiFactory.getJellyfinApiClientByName( this.logger.error(
program.externalSourceId, 'Program %s does not have an associated media source ID',
program.uuid,
); );
return;
}
const jfClient = await this.mediaSourceApiFactory.getJellyfinApiClientById(
program.mediaSourceId,
);
if (!jfClient) { if (!jfClient) {
this.logger.error( this.logger.error(
"Couldn't get jellyfin api client for id: %s", "Couldn't get jellyfin api client for id: %s",
program.externalSourceId, program.mediaSourceId,
); );
return; return;
} }
// If we can locate the item on JF, there is no problem. // If we can locate the item on JF, there is no problem.
const existingItem = await jfClient.getItem(program.externalKey); const existingItem = await jfClient.getItem(program.externalKey);
if (!isQueryError(existingItem) && isDefined(existingItem.data)) { if (existingItem.isSuccess() && isDefined(existingItem.get())) {
this.logger.error( this.logger.error(
existingItem, existingItem,
'Item exists on Jellyfin - no need to find a new match', 'Item exists on Jellyfin - no need to find a new match',
@@ -181,7 +211,7 @@ export class JellyfinItemFinder {
.with(ProgramType.OtherVideo, () => 'Video') .with(ProgramType.OtherVideo, () => 'Video')
.exhaustive(); .exhaustive();
const queryResult = await jfClient.getItems( const queryResult = await jfClient.getRawItems(
null, null,
[jellyfinItemType], [jellyfinItemType],
[], [],
@@ -189,22 +219,24 @@ export class JellyfinItemFinder {
opts, opts,
); );
if (queryResult.type === 'success') { return queryResult.either(
return find(queryResult.data.Items, (match) => (data) => {
some( return find(data.Items, (match) =>
match.ProviderIds, some(
(val, key) => match.ProviderIds,
programExternalIdTypeFromJellyfinProvider(key) === type && (val, key) =>
val === idsBySourceType[type].externalKey, programExternalIdTypeFromJellyfinProvider(key) === type &&
), val === idsBySourceType[type].externalKey,
); ),
} else { );
this.logger.error( },
{ error: queryResult }, (err) => {
'Error while querying items on Jellyfin', this.logger.error(err, 'Error while querying items on Jellyfin');
); return undefined;
} },
);
} }
return; return;
}; };
@@ -231,10 +263,10 @@ export class JellyfinItemFinder {
return possibleMatch; return possibleMatch;
} }
private findMediaSource(mediaSourceName: string) { private findMediaSource(mediaSourceId: MediaSourceId) {
return this.mediaSourceDB.findByType( return this.mediaSourceDB.findByType(
MediaSourceType.Jellyfin, 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 type { QueryResult } from '@/external/BaseApiClient.js';
import { isQueryError, isQuerySuccess } from '@/external/BaseApiClient.js';
import { isDefined } from '@/util/index.js'; import { isDefined } from '@/util/index.js';
import NodeCache from 'node-cache'; import NodeCache from 'node-cache';
@@ -44,7 +43,7 @@ export class PlexQueryCache {
} }
const value = await getter(); const value = await getter();
if (isQuerySuccess(value) || (isQueryError(value) && opts?.setOnError)) { if (value.isSuccess() || opts?.setOnError) {
this.#cache.set(key, value); this.#cache.set(key, value);
} }

View File

@@ -11,8 +11,8 @@ import { exec, spawn } from 'node:child_process';
import events from 'node:events'; import events from 'node:events';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import type { WritableOptions } from 'node:stream'; import type stream from 'node:stream';
import stream from 'node:stream'; import { LastNBytesStream } from '../util/LastNBytesStream.ts';
export type FfmpegEvents = { export type FfmpegEvents = {
// Emitted when the process ended with a code === 0, i.e. it exited // 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; 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) { if (stream.languageCodeISO6392 !== pref.languageCode) {
this.logger.debug( this.logger.debug(
'Skipping subtitle index %d, not a language match', 'Skipping subtitle index %d, not a language match',
stream.index, stream.index ?? -1,
); );
continue; continue;
} }
@@ -86,13 +86,13 @@ export class SubtitleStreamPicker {
if (pref.filterType === 'forced' && !stream.forced) { if (pref.filterType === 'forced' && !stream.forced) {
this.logger.debug( this.logger.debug(
'Skipping subtitle index %d, wanted forced', 'Skipping subtitle index %d, wanted forced',
stream.index, stream.index ?? -1,
); );
continue; continue;
} else if (pref.filterType === 'default' && !stream.default) { } else if (pref.filterType === 'default' && !stream.default) {
this.logger.debug( this.logger.debug(
'Skipping subtitle index %d, wanted default', 'Skipping subtitle index %d, wanted default',
stream.index, stream.index ?? -1,
); );
continue; continue;
} }
@@ -101,7 +101,7 @@ export class SubtitleStreamPicker {
if (!pref.allowExternal && stream.type === 'external') { if (!pref.allowExternal && stream.type === 'external') {
this.logger.debug( this.logger.debug(
'Skipping subtitle index %d, disallowed external', 'Skipping subtitle index %d, disallowed external',
stream.index, stream.index ?? -1,
); );
continue; continue;
} }
@@ -109,7 +109,7 @@ export class SubtitleStreamPicker {
if (!pref.allowImageBased && isImageBasedSubtitle(stream.codec)) { if (!pref.allowImageBased && isImageBasedSubtitle(stream.codec)) {
this.logger.debug( this.logger.debug(
'Skipping subtitle index %d, disallowed image-based', 'Skipping subtitle index %d, disallowed image-based',
stream.index, stream.index ?? -1,
); );
continue; continue;
} }
@@ -150,9 +150,9 @@ export class SubtitleStreamPicker {
if (!filePath) { if (!filePath) {
this.logger.debug( this.logger.debug(
'Unsupported subtitle codec at index %d: %s', 'Unsupported subtitle codec at index %d: codec = %s',
stream.index, stream.index ?? -1,
stream.codec, stream.codec ?? 'unkonwn',
); );
return; return;
} }
@@ -161,7 +161,7 @@ export class SubtitleStreamPicker {
if (!(await fileExists(fullPath))) { if (!(await fileExists(fullPath))) {
this.logger.debug( this.logger.debug(
'Subtitle stream at index %d has not been extracted yet.', 'Subtitle stream at index %d has not been extracted yet.',
stream.index, stream.index ?? -1,
); );
return; return;
} }

View File

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

View File

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