mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
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:
@@ -1,5 +1,10 @@
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
base_ref:
|
||||
description: "Ref to build pre-release from"
|
||||
required: true
|
||||
default: 'dev'
|
||||
push:
|
||||
branches:
|
||||
- media-scanner
|
||||
|
||||
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -60,4 +60,5 @@ jobs:
|
||||
echo ${{ steps.semantic.outputs.new_release_major_version }}
|
||||
echo ${{ steps.semantic.outputs.new_release_minor_version }}
|
||||
echo ${{ steps.semantic.outputs.new_release_patch_version }}
|
||||
echo ${{ steps.semantic.outputs.new_release_prerelease_version }}
|
||||
echo ${{ steps.semantic.outputs.new_release_prerelease_version }}
|
||||
echo ${{ steps.semantic.outputs.new_release_git_tag }}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
docs/generated/tunarr-v0.22.0-openapi.json
Normal file
1
docs/generated/tunarr-v0.22.0-openapi.json
Normal file
File diff suppressed because one or more lines are too long
@@ -30,7 +30,7 @@
|
||||
"eslint-import-resolver-typescript": "^3.7.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-react": "^7.37.3",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"globals": "^15.0.0",
|
||||
@@ -48,7 +48,6 @@
|
||||
"packageManager": "pnpm@9.12.3",
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"ts-essentials@9.4.1": "patches/ts-essentials@9.4.1.patch",
|
||||
"kysely": "patches/kysely.patch"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
3347
pnpm-lock.yaml
generated
3347
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
[test]
|
||||
preload = ['./src/testing/matchers/PixelFormatMatcher.ts']
|
||||
@@ -3,6 +3,7 @@ import { defineConfig } from 'drizzle-kit';
|
||||
export default defineConfig({
|
||||
dialect: 'sqlite',
|
||||
schema: './src/db/schema/**/*.ts',
|
||||
out: './src/migration/db/sql',
|
||||
casing: 'snake_case',
|
||||
dbCredentials: {
|
||||
url: process.env.TUNARR_DATABASE_PATH,
|
||||
|
||||
@@ -3,3 +3,9 @@ export const __import_meta_url =
|
||||
? new (require('url'.replace('', '')).URL)('file:' + __filename).href
|
||||
: (document.currentScript && document.currentScript.src) ||
|
||||
new URL('main.js', document.baseURI).href;
|
||||
|
||||
export const __import_meta_dirname =
|
||||
typeof document === 'undefined'
|
||||
? new (require('url'.replace('', '')).URL)('file:' + __dirname).href
|
||||
: (document.currentScript && document.currentScript.src) ||
|
||||
new URL('main.js', document.baseURI).href;
|
||||
|
||||
@@ -12,12 +12,13 @@
|
||||
"build-dev": "cross-env NODE_ENV=development tsc -p tsconfig.build.json --noEmit --watch",
|
||||
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 tsc -p tsconfig.build.json",
|
||||
"bundle": "dotenv -- tsx scripts/bundle.ts",
|
||||
"clean": "rimraf --glob ./build/ ./dist/ ./src/generated/web-imports.ts ./src/generated/web-imports.js",
|
||||
"make-bin": "dotenv -- tsx scripts/make-bin.ts",
|
||||
"clean": "rimraf --glob ./build/ ./dist/ ./bin/tunar*",
|
||||
"debug": "dotenv -e .env.development -- tsx watch --trace-warnings --tsconfig ./tsconfig.build.json --ignore 'src/streams' --inspect-wait ./src",
|
||||
"dev": "dotenv -e .env.development -- tsx watch --trace-warnings --tsconfig ./tsconfig.build.json --ignore 'build' --ignore 'src/streams' --ignore 'src/**/*.test.ts' ./src/index.ts",
|
||||
"generate-openapi": "tsx src/index.ts generate-openapi",
|
||||
"install-meilisearch": "tsx scripts/download-meilisearch.ts",
|
||||
"kysely": "dotenv -e .env.development -- kysely",
|
||||
"make-bin": "dotenv -- tsx scripts/make-bin.ts",
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"run-fixer": "dotenv -e .env.development -- tsx src/index.ts fixer",
|
||||
"test:watch": "vitest --watch",
|
||||
@@ -26,99 +27,106 @@
|
||||
"typecheck": "tsc -p tsconfig.build.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dotenvx/dotenvx": "^1.45.1",
|
||||
"@fastify/cors": "^10.0.1",
|
||||
"@fastify/error": "^4.1.0",
|
||||
"@fastify/multipart": "^9.0.1",
|
||||
"@fastify/static": "^8.0.1",
|
||||
"@dotenvx/dotenvx": "^1.49.0",
|
||||
"@fastify/cors": "^10.1.0",
|
||||
"@fastify/error": "^4.2.0",
|
||||
"@fastify/multipart": "^9.0.3",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@fastify/swagger": "^9.5.1",
|
||||
"@iptv/xmltv": "^1.0.1",
|
||||
"@logdna/tail-file": "^4.0.2",
|
||||
"@scalar/fastify-api-reference": "^1.25.106",
|
||||
"@scalar/fastify-api-reference": "^1.34.6",
|
||||
"@tunarr/playlist": "^1.1.0",
|
||||
"@tunarr/shared": "workspace:*",
|
||||
"@tunarr/types": "workspace:*",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"archiver": "^7.0.1",
|
||||
"async-mutex": "^0.5.0",
|
||||
"async-retry": "^1.3.3",
|
||||
"axios": "^1.11.0",
|
||||
"base32": "^0.0.7",
|
||||
"better-sqlite3": "11.8.1",
|
||||
"chalk": "^5.3.0",
|
||||
"chalk": "^5.6.0",
|
||||
"cron-parser": "^4.9.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"dayjs": "^1.11.14",
|
||||
"drizzle-orm": "^0.39.3",
|
||||
"fast-xml-parser": "^4.3.5",
|
||||
"fastify": "^5.0.0",
|
||||
"fast-xml-parser": "^4.5.3",
|
||||
"fastify": "^5.5.0",
|
||||
"fastify-graceful-shutdown": "^4.0.1",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"fastify-print-routes": "^3.2.0",
|
||||
"fastify-type-provider-zod": "^5.0.3",
|
||||
"file-type": "^19.6.0",
|
||||
"find-process": "^2.0.0",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-dag": "^0.4.1",
|
||||
"inversify": "^6.2.1",
|
||||
"inversify": "^6.2.2",
|
||||
"jsonpath-plus": "^10.3.0",
|
||||
"kysely": "^0.27.4",
|
||||
"kysely": "^0.27.6",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lowdb": "^7.0.0",
|
||||
"lowdb": "^7.0.1",
|
||||
"meilisearch": "^0.49.0",
|
||||
"node-cache": "^5.1.2",
|
||||
"node-schedule": "^2.1.1",
|
||||
"node-ssdp": "^4.0.0",
|
||||
"p-queue": "^8.0.1",
|
||||
"pino": "^9.0.0",
|
||||
"pino-pretty": "^11.2.2",
|
||||
"pino-roll": "^1.1.0",
|
||||
"node-ssdp": "^4.0.1",
|
||||
"p-queue": "^8.1.0",
|
||||
"pino": "^9.9.1",
|
||||
"pino-pretty": "^11.3.0",
|
||||
"pino-roll": "^1.3.0",
|
||||
"random-js": "2.1.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"retry": "^0.13.1",
|
||||
"split2": "^4.2.0",
|
||||
"ts-pattern": "^5.4.0",
|
||||
"tslib": "^2.6.2",
|
||||
"ts-pattern": "^5.8.0",
|
||||
"tslib": "^2.8.1",
|
||||
"uuid": "^9.0.1",
|
||||
"yargs": "^17.7.2",
|
||||
"zod": "^4.0.17"
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^9.9.0",
|
||||
"@octokit/types": "^13.10.0",
|
||||
"@rollup/plugin-swc": "^0.4.0",
|
||||
"@types/archiver": "^6.0.2",
|
||||
"@types/async-retry": "^1.4.8",
|
||||
"@types/archiver": "^6.0.3",
|
||||
"@types/async-retry": "^1.4.9",
|
||||
"@types/lodash-es": "4.17.9",
|
||||
"@types/node": "22.10.7",
|
||||
"@types/node-abi": "^3.0.3",
|
||||
"@types/node-schedule": "^2.1.3",
|
||||
"@types/node-schedule": "^2.1.8",
|
||||
"@types/retry": "^0.12.5",
|
||||
"@types/split2": "^4.2.3",
|
||||
"@types/tmp": "^0.2.6",
|
||||
"@types/unzip-stream": "^0.3.4",
|
||||
"@types/uuid": "^9.0.6",
|
||||
"@types/yargs": "^17.0.29",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@types/yargs": "^17.0.33",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@yao-pkg/pkg": "^6.5.1",
|
||||
"@yao-pkg/pkg": "^6.6.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"del-cli": "^3.0.0",
|
||||
"dotenv-cli": "^7.4.1",
|
||||
"drizzle-kit": "^0.30.4",
|
||||
"esbuild-plugin-pino": "^2.2.1",
|
||||
"del-cli": "^3.0.1",
|
||||
"dotenv-cli": "^7.4.4",
|
||||
"drizzle-kit": "^0.30.6",
|
||||
"esbuild-plugin-pino": "^2.3.3",
|
||||
"fast-check": "^4.2.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"globals": "^15.0.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"globals": "^15.15.0",
|
||||
"kysely-ctl": "^0.9.0",
|
||||
"node-abi": "^3.74.0",
|
||||
"prettier": "^3.5.1",
|
||||
"rimraf": "^5.0.5",
|
||||
"node-abi": "^3.75.0",
|
||||
"prettier": "^3.6.2",
|
||||
"rimraf": "^5.0.10",
|
||||
"tar": "^7.4.3",
|
||||
"thread-stream": "^3.1.0",
|
||||
"tmp": "^0.2.1",
|
||||
"tmp": "^0.2.5",
|
||||
"tmp-promise": "^3.0.3",
|
||||
"ts-essentials": "^10.0.0",
|
||||
"ts-essentials": "^10.1.1",
|
||||
"ts-mockito": "^2.6.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.19.2",
|
||||
"tsx": "^4.20.5",
|
||||
"typed-emitter": "^2.1.0",
|
||||
"typescript": "5.7.3",
|
||||
"typescript-eslint": "^8.19.0",
|
||||
"typescript-eslint": "^8.41.0",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"meilisearch": {
|
||||
"version": "1.15.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"pkg": {
|
||||
"assets": ["./dist/**/*"],
|
||||
"outputPath": "dist/bin"
|
||||
"assets": ["./dist/**/*"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
95
server/scripts/bundle-old.ts
Normal file
95
server/scripts/bundle-old.ts
Normal 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);
|
||||
167
server/scripts/download-meilisearch.ts
Executable file
167
server/scripts/download-meilisearch.ts
Executable 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]);
|
||||
}
|
||||
@@ -6,12 +6,14 @@ import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import stream from 'node:stream';
|
||||
import { format } from 'node:util';
|
||||
import { rimraf } from 'rimraf';
|
||||
import * as tar from 'tar';
|
||||
import tmp from 'tmp-promise';
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import serverPackage from '../package.json' with { type: 'json' };
|
||||
import { fileExists } from '../src/util/fsUtil.ts';
|
||||
import { grabMeilisearch } from './download-meilisearch.ts';
|
||||
|
||||
const betterSqlite3ReleaseFmt =
|
||||
'https://github.com/WiseLibs/better-sqlite3/releases/download/v%s/better-sqlite3-v%s-node-v%s-%s-%s.tar.gz';
|
||||
@@ -73,10 +75,18 @@ const args = await yargs(hideBin(process.argv))
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
})
|
||||
.option('clean', {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
})
|
||||
.parseAsync();
|
||||
|
||||
!(await fileExists('./bin')) && (await fs.mkdir('./bin'));
|
||||
|
||||
if (args.clean) {
|
||||
await rimraf('./bin/tunarr*', { glob: true });
|
||||
}
|
||||
|
||||
(await fileExists('./dist/web')) &&
|
||||
(await fs.rm('./dist/web', { recursive: true }));
|
||||
|
||||
@@ -87,6 +97,12 @@ await fs.cp(path.resolve(process.cwd(), '../web/dist'), './dist/web', {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
await fs.cp(
|
||||
path.resolve(process.cwd(), './src/migration/db/sql'),
|
||||
'./dist/sql',
|
||||
{ recursive: true },
|
||||
);
|
||||
|
||||
const originalWorkingDir = process.cwd();
|
||||
|
||||
console.log(`Going to build archs: ${args.target.join(' ')}`);
|
||||
@@ -108,6 +124,13 @@ for (const arch of args.target) {
|
||||
});
|
||||
});
|
||||
|
||||
const meilisearchBinaryPath = await grabMeilisearch();
|
||||
if (!meilisearchBinaryPath) {
|
||||
throw new Error('Could not download Meilisearch binary');
|
||||
} else {
|
||||
console.log(`Meilisearch found at ${meilisearchBinaryPath}`);
|
||||
}
|
||||
|
||||
// Untar
|
||||
await new Promise((resolve, reject) => {
|
||||
const outstream = betterSqliteDlStream.data.pipe(
|
||||
@@ -164,6 +187,7 @@ for (const arch of args.target) {
|
||||
// Look into whether we want this sometimes...
|
||||
'--no-bytecode',
|
||||
'--signature', // for macos arm64
|
||||
'--debug',
|
||||
'-o',
|
||||
`dist/bin/${execName}`,
|
||||
];
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,7 +232,6 @@ export class Server {
|
||||
const roundedTime = round(rep.elapsedTime, 4);
|
||||
|
||||
this.logger[req.routeOptions.config.logAtLevel ?? 'http'](
|
||||
`${req.method} ${req.url} ${rep.statusCode} -${lengthStr}${roundedTime}ms`,
|
||||
{
|
||||
req: {
|
||||
method: req.method,
|
||||
@@ -241,6 +240,7 @@ export class Server {
|
||||
elapsedTime: roundedTime,
|
||||
},
|
||||
},
|
||||
`${req.method} ${req.url} ${rep.statusCode} -${lengthStr}${roundedTime}ms`,
|
||||
);
|
||||
done();
|
||||
});
|
||||
@@ -463,6 +463,8 @@ export class Server {
|
||||
this.logger.debug(e, 'Error sending shutdown signal to frontend');
|
||||
}
|
||||
|
||||
this.serverContext.searchService.stop();
|
||||
|
||||
try {
|
||||
this.logger.debug('Pausing all on-demand channels');
|
||||
await this.serverContext.onDemandChannelService.pauseAllChannels();
|
||||
|
||||
@@ -21,9 +21,12 @@ import { FileCacheService } from './services/FileCacheService.ts';
|
||||
import { HdhrService } from './services/HDHRService.ts';
|
||||
import { HealthCheckService } from './services/HealthCheckService.js';
|
||||
import { M3uService } from './services/M3UService.ts';
|
||||
import { MediaSourceLibraryRefresher } from './services/MediaSourceLibraryRefresher.ts';
|
||||
import { MeilisearchService } from './services/MeilisearchService.ts';
|
||||
import { OnDemandChannelService } from './services/OnDemandChannelService.js';
|
||||
import { TVGuideService } from './services/TvGuideService.ts';
|
||||
import { CacheImageService } from './services/cacheImageService.js';
|
||||
import { MediaSourceScanCoordinator } from './services/scanner/MediaSourceScanCoordinator.ts';
|
||||
import { ChannelCache } from './stream/ChannelCache.js';
|
||||
import { SessionManager } from './stream/SessionManager.js';
|
||||
import { StreamProgramCalculator } from './stream/StreamProgramCalculator.js';
|
||||
@@ -69,6 +72,15 @@ export class ServerContext {
|
||||
|
||||
@inject(KEYS.WorkerPool)
|
||||
public readonly workerPool: IWorkerPool;
|
||||
|
||||
@inject(MeilisearchService)
|
||||
public readonly searchService!: MeilisearchService;
|
||||
|
||||
@inject(MediaSourceScanCoordinator)
|
||||
public readonly mediaSourceScanCoordinator: MediaSourceScanCoordinator;
|
||||
|
||||
@inject(MediaSourceLibraryRefresher)
|
||||
public readonly mediaSourceLibraryRefresher: MediaSourceLibraryRefresher;
|
||||
}
|
||||
|
||||
export class ServerRequestContext {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
||||
import { isDefined } from '@/util/index.js';
|
||||
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
|
||||
import { timeNamedAsync } from '@/util/perf.js';
|
||||
import { seq } from '@tunarr/shared/util';
|
||||
import type { ChannelSession, CreateChannelRequest } from '@tunarr/types';
|
||||
import {
|
||||
BasicIdParamSchema,
|
||||
@@ -66,7 +67,11 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
});
|
||||
|
||||
fastify.addHook('onError', (req, _, error, done) => {
|
||||
logger.error(error, '%s %s', req.routeOptions.method, req.routeOptions.url);
|
||||
logger.error({
|
||||
error,
|
||||
method: req.routeOptions.method,
|
||||
url: req.routeOptions.url,
|
||||
});
|
||||
done();
|
||||
});
|
||||
|
||||
@@ -247,6 +252,8 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
params: z.object({ id: z.string() }),
|
||||
response: {
|
||||
200: ChannelSchema,
|
||||
404: z.void(),
|
||||
500: z.void(),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -362,7 +369,7 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
);
|
||||
|
||||
return res.send(
|
||||
programs.map((program) =>
|
||||
seq.collect(programs, (program) =>
|
||||
req.serverCtx.programConverter.programDaoToContentProgram(
|
||||
program,
|
||||
program.externalIds ?? [],
|
||||
@@ -395,6 +402,7 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
result: shows.map((show) =>
|
||||
req.serverCtx.programConverter.tvShowDaoToDto(show),
|
||||
),
|
||||
size: shows.length,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -422,6 +430,7 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
result: shows.map((show) =>
|
||||
req.serverCtx.programConverter.musicArtistDaoToDto(show),
|
||||
),
|
||||
size: shows.length,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -566,7 +575,7 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
async (req, res) => {
|
||||
const fallbacks =
|
||||
await req.serverCtx.channelDB.getChannelFallbackPrograms(req.params.id);
|
||||
const converted = map(fallbacks, (p) =>
|
||||
const converted = seq.collect(fallbacks, (p) =>
|
||||
req.serverCtx.programConverter.programDaoToContentProgram(p, []),
|
||||
);
|
||||
return res.send(converted);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { FfmpegStreamFactory } from '@/ffmpeg/FfmpegStreamFactory.js';
|
||||
import { MpegTsOutputFormat } from '@/ffmpeg/builder/constants.js';
|
||||
import { FfprobeStreamDetails } from '@/stream/FfprobeStreamDetails.js';
|
||||
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
||||
import { tag } from '@tunarr/types';
|
||||
import dayjs from 'dayjs';
|
||||
import { z } from 'zod/v4';
|
||||
import { container } from '../../container.ts';
|
||||
@@ -92,7 +93,7 @@ export const debugFfmpegApiRouter: RouterPluginAsyncCallback = async (
|
||||
streamDuration: +dayjs.duration({ seconds: 30 }),
|
||||
externalKey: 'none',
|
||||
externalSource: 'emby',
|
||||
externalSourceId: 'none',
|
||||
externalSourceId: tag('none'),
|
||||
programBeginMs: 0,
|
||||
programId: '',
|
||||
programType: 'movie',
|
||||
@@ -115,7 +116,7 @@ export const debugFfmpegApiRouter: RouterPluginAsyncCallback = async (
|
||||
return res.status(500).send();
|
||||
}
|
||||
|
||||
const server = await req.serverCtx.mediaSourceDB.getByName(
|
||||
const server = await req.serverCtx.mediaSourceDB.getById(
|
||||
item.externalSourceId,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { container } from '@/container.js';
|
||||
import { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
|
||||
import type { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
|
||||
import { JellyfinItemFinder } from '@/external/jellyfin/JellyfinItemFinder.js';
|
||||
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
||||
import type { Nilable } from '@/types/util.js';
|
||||
import { tag } from '@tunarr/types';
|
||||
import { isNil } from 'lodash-es';
|
||||
import { v4 } from 'uuid';
|
||||
import { z } from 'zod/v4';
|
||||
import { MediaSourceType } from '../../db/schema/MediaSource.ts';
|
||||
import type { MediaSourceApiClientFactory } from '../../external/MediaSourceApiClient.ts';
|
||||
import { KEYS } from '../../types/inject.ts';
|
||||
|
||||
export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
|
||||
fastify,
|
||||
@@ -23,12 +28,19 @@ export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
const client = new JellyfinApiClient({
|
||||
url: req.query.uri,
|
||||
accessToken: req.query.apiKey,
|
||||
userId: req.query.userId ?? null,
|
||||
name: 'debug',
|
||||
username: null,
|
||||
const client = container.get<
|
||||
MediaSourceApiClientFactory<JellyfinApiClient>
|
||||
>(KEYS.JellyfinApiClientFactory)({
|
||||
mediaSource: {
|
||||
uri: req.query.uri,
|
||||
accessToken: req.query.apiKey,
|
||||
userId: req.query.userId ?? null,
|
||||
name: tag('debug'),
|
||||
uuid: tag(v4()),
|
||||
username: null,
|
||||
libraries: [],
|
||||
type: 'jellyfin',
|
||||
},
|
||||
});
|
||||
|
||||
await res.send(await client.getUserLibraries());
|
||||
@@ -54,12 +66,19 @@ export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
const client = new JellyfinApiClient({
|
||||
url: req.query.uri,
|
||||
accessToken: req.query.apiKey,
|
||||
name: 'debug',
|
||||
userId: null,
|
||||
username: null,
|
||||
const client = container.get<
|
||||
MediaSourceApiClientFactory<JellyfinApiClient>
|
||||
>(KEYS.JellyfinApiClientFactory)({
|
||||
mediaSource: {
|
||||
uri: req.query.uri,
|
||||
accessToken: req.query.apiKey,
|
||||
name: tag('debug'),
|
||||
uuid: tag(v4()),
|
||||
userId: null,
|
||||
username: null,
|
||||
libraries: [],
|
||||
type: 'jellyfin',
|
||||
},
|
||||
});
|
||||
|
||||
let pageParams: Nilable<{ offset: number; limit: number }> = null;
|
||||
@@ -68,7 +87,7 @@ export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
|
||||
}
|
||||
|
||||
await res.send(
|
||||
await client.getItems(req.query.parentId, [], [], pageParams),
|
||||
await client.getRawItems(req.query.parentId, [], [], pageParams),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -89,4 +108,54 @@ export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
|
||||
return res.status(match ? 200 : 404).send(match);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/jellyfin/:libraryId/enumerate',
|
||||
{
|
||||
schema: {
|
||||
params: z.object({
|
||||
libraryId: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
const library = await req.serverCtx.mediaSourceDB.getLibrary(
|
||||
req.params.libraryId,
|
||||
);
|
||||
|
||||
if (!library) {
|
||||
return res.status(404).send();
|
||||
}
|
||||
|
||||
if (library.mediaSource.type !== MediaSourceType.Jellyfin) {
|
||||
return res.status(400).send();
|
||||
}
|
||||
|
||||
const jfClient =
|
||||
await req.serverCtx.mediaSourceApiFactory.getJellyfinApiClientForMediaSource(
|
||||
{ ...library.mediaSource, libraries: [library] },
|
||||
);
|
||||
|
||||
switch (library.mediaType) {
|
||||
case 'movies':
|
||||
for await (const movie of jfClient.getMovieLibraryContents(
|
||||
library.externalKey,
|
||||
)) {
|
||||
console.log(movie);
|
||||
}
|
||||
break;
|
||||
case 'shows': {
|
||||
for await (const series of jfClient.getTvShowLibraryContents(
|
||||
library.externalKey,
|
||||
)) {
|
||||
console.log(series);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return res.send();
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { container } from '@/container.js';
|
||||
import { ProgramSourceType } from '@/db/custom_types/ProgramSourceType.js';
|
||||
import { PlexStreamDetails } from '@/stream/plex/PlexStreamDetails.js';
|
||||
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
||||
import { tag } from '@tunarr/types';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
export const DebugPlexApiRouter: RouterPluginAsyncCallback = async (
|
||||
@@ -22,14 +23,14 @@ export const DebugPlexApiRouter: RouterPluginAsyncCallback = async (
|
||||
async (req, res) => {
|
||||
const mediaSource = await req.serverCtx.mediaSourceDB.findByType(
|
||||
'plex',
|
||||
req.query.mediaSource,
|
||||
tag(req.query.mediaSource),
|
||||
);
|
||||
if (!mediaSource) {
|
||||
return res.status(400).send('No media source');
|
||||
}
|
||||
|
||||
const program = await req.serverCtx.programDB.lookupByExternalId({
|
||||
externalSourceId: mediaSource.name,
|
||||
externalSourceId: mediaSource.uuid,
|
||||
externalKey: req.query.key,
|
||||
sourceType: ProgramSourceType.PLEX,
|
||||
});
|
||||
@@ -38,16 +39,23 @@ export const DebugPlexApiRouter: RouterPluginAsyncCallback = async (
|
||||
return res.status(400).send('No program');
|
||||
}
|
||||
|
||||
const contentProgram =
|
||||
req.serverCtx.programConverter.programDaoToContentProgram(program);
|
||||
|
||||
if (!contentProgram) {
|
||||
return res.status(500).send();
|
||||
}
|
||||
|
||||
const streamDetails = await container.get(PlexStreamDetails).getStream({
|
||||
server: mediaSource,
|
||||
lineupItem: {
|
||||
...program,
|
||||
programId: program.id!,
|
||||
...contentProgram,
|
||||
programId: contentProgram.id,
|
||||
externalKey: req.query.key,
|
||||
programType: program.subtype,
|
||||
programType: contentProgram.subtype,
|
||||
externalSource: 'plex',
|
||||
duration: program.duration,
|
||||
externalFilePath: program.serverFilePath,
|
||||
duration: contentProgram.duration,
|
||||
externalFilePath: contentProgram.serverFilePath,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -325,5 +325,6 @@ function createStreamItemFromProgram(
|
||||
contentDuration: program.duration,
|
||||
streamDuration: program.duration,
|
||||
infiniteLoop: false,
|
||||
externalSourceId: program.mediaSourceId!,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { OpenDateTimeRange } from '@/types/OpenDateTimeRange.js';
|
||||
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
||||
import { enumValues } from '@/util/enumUtil.js';
|
||||
import { ifDefined } from '@/util/index.js';
|
||||
import { tag } from '@tunarr/types';
|
||||
import { ChannelLineupQuery } from '@tunarr/types/api';
|
||||
import { ChannelLineupSchema } from '@tunarr/types/schemas';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -342,7 +343,7 @@ export const debugApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
},
|
||||
async (req, res) => {
|
||||
const mediaSource = (await req.serverCtx.mediaSourceDB.getById(
|
||||
req.query.id,
|
||||
tag(req.query.id),
|
||||
))!;
|
||||
|
||||
const knownProgramIds = await req.serverCtx
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import type { MediaSource } from '@/db/schema/MediaSource.js';
|
||||
import { MediaSourceType } from '@/db/schema/MediaSource.js';
|
||||
import { isQueryError } from '@/external/BaseApiClient.js';
|
||||
import { EmbyApiClient } from '@/external/emby/EmbyApiClient.js';
|
||||
import { TruthyQueryParam } from '@/types/schemas.js';
|
||||
import { isDefined, nullToUndefined } from '@/util/index.js';
|
||||
import { EmbyLoginRequest } from '@tunarr/types/api';
|
||||
import type { EmbyCollectionType } from '@tunarr/types/emby';
|
||||
import { groupByUniq, isDefined, nullToUndefined } from '@/util/index.js';
|
||||
import type { Library } from '@tunarr/types';
|
||||
import { tag } from '@tunarr/types';
|
||||
import { EmbyLoginRequest, PagedResult } from '@tunarr/types/api';
|
||||
import {
|
||||
EmbyItemFields,
|
||||
EmbyItemKind,
|
||||
EmbyItemSortBy,
|
||||
EmbyLibraryItemsResponse,
|
||||
type EmbyLibraryItemsResponse as EmbyLibraryItemsResponseType,
|
||||
} from '@tunarr/types/emby';
|
||||
import { ItemOrFolder, Library as LibrarySchema } from '@tunarr/types/schemas';
|
||||
import type { FastifyReply } from 'fastify/types/reply.js';
|
||||
import { filter, isEmpty, isNil, isUndefined, uniq } from 'lodash-es';
|
||||
import { isEmpty, isNil, isUndefined, uniq } from 'lodash-es';
|
||||
import { z } from 'zod/v4';
|
||||
import type { MediaSourceWithLibraries } from '../db/schema/derivedTypes.js';
|
||||
import { ServerRequestContext } from '../ServerContext.ts';
|
||||
import type {
|
||||
RouterPluginCallback,
|
||||
ZodFastifyRequest,
|
||||
@@ -25,19 +25,6 @@ const mediaSourceParams = z.object({
|
||||
mediaSourceId: z.string(),
|
||||
});
|
||||
|
||||
const ValidEmbyCollectionTypes: EmbyCollectionType[] = [
|
||||
'movies',
|
||||
'tvshows',
|
||||
'music',
|
||||
'trailers',
|
||||
'musicvideos',
|
||||
'homevideos',
|
||||
'playlists',
|
||||
'boxsets',
|
||||
'folders',
|
||||
'unknown',
|
||||
];
|
||||
|
||||
function isNonEmptyTyped<T>(f: T[]): f is [T, ...T[]] {
|
||||
return !isEmpty(f);
|
||||
}
|
||||
@@ -83,7 +70,7 @@ export const embyApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
||||
schema: {
|
||||
params: mediaSourceParams,
|
||||
response: {
|
||||
200: EmbyLibraryItemsResponse,
|
||||
200: z.array(LibrarySchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -94,27 +81,34 @@ export const embyApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
||||
mediaSource,
|
||||
);
|
||||
|
||||
const response = await api.getUserViews();
|
||||
const response = await api.getUserLibraries();
|
||||
|
||||
if (isQueryError(response)) {
|
||||
throw new Error(response.message);
|
||||
if (response.isFailure()) {
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
const sanitizedResponse: EmbyLibraryItemsResponseType = {
|
||||
...response.data,
|
||||
Items: filter(response.data.Items, (library) => {
|
||||
// Mixed collections don't have this set
|
||||
if (!library.CollectionType) {
|
||||
return true;
|
||||
}
|
||||
// const sanitizedResponse: EmbyLibraryItemsResponseType = {
|
||||
// ...response.get(),
|
||||
// Items: filter(response.get().Items, (library) => {
|
||||
// // Mixed collections don't have this set
|
||||
// if (!library.CollectionType) {
|
||||
// return true;
|
||||
// }
|
||||
|
||||
return ValidEmbyCollectionTypes.includes(
|
||||
library.CollectionType as EmbyCollectionType,
|
||||
);
|
||||
}),
|
||||
};
|
||||
// return ValidEmbyCollectionTypes.includes(
|
||||
// library.CollectionType as EmbyCollectionType,
|
||||
// );
|
||||
// }),
|
||||
// };
|
||||
|
||||
return res.send(sanitizedResponse);
|
||||
// await addTunarrLibraryIdsToResponse(
|
||||
// sanitizedResponse.Items,
|
||||
// mediaSource,
|
||||
// );
|
||||
|
||||
await addTunarrLibraryIdsToResponse(response.get(), mediaSource);
|
||||
|
||||
return res.send(response.get());
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -164,7 +158,7 @@ export const embyApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
||||
.or(z.array(z.enum(['Artist', 'AlbumArtist'])).optional()),
|
||||
}),
|
||||
response: {
|
||||
200: EmbyLibraryItemsResponse,
|
||||
200: PagedResult(ItemOrFolder.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -201,11 +195,11 @@ export const embyApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
||||
: ['SortName', 'ProductionYear'],
|
||||
);
|
||||
|
||||
if (isQueryError(response)) {
|
||||
throw new Error(response.message);
|
||||
if (response.isFailure()) {
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
return res.send(response.data);
|
||||
return res.send(response.get());
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -216,10 +210,10 @@ export const embyApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
||||
>(
|
||||
req: Req,
|
||||
res: FastifyReply,
|
||||
cb: (m: MediaSource) => Promise<FastifyReply>,
|
||||
cb: (m: MediaSourceWithLibraries) => Promise<FastifyReply>,
|
||||
) {
|
||||
const mediaSource = await req.serverCtx.mediaSourceDB.getById(
|
||||
req.params.mediaSourceId,
|
||||
tag(req.params.mediaSourceId),
|
||||
);
|
||||
|
||||
if (isNil(mediaSource)) {
|
||||
@@ -241,3 +235,42 @@ export const embyApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
async function addTunarrLibraryIdsToResponse(
|
||||
response: Library[],
|
||||
mediaSource: MediaSourceWithLibraries,
|
||||
attempts: number = 1,
|
||||
) {
|
||||
if (attempts > 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const librariesByExternalId = groupByUniq(
|
||||
mediaSource.libraries,
|
||||
(lib) => lib.externalKey,
|
||||
);
|
||||
let needsRefresh = false;
|
||||
for (const library of response) {
|
||||
const tunarrLibrary = librariesByExternalId[library.externalId];
|
||||
if (!tunarrLibrary) {
|
||||
needsRefresh = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
library.uuid = tunarrLibrary.uuid;
|
||||
}
|
||||
|
||||
if (needsRefresh) {
|
||||
const ctx = ServerRequestContext.currentServerContext()!;
|
||||
await ctx.mediaSourceLibraryRefresher.refreshMediaSource(mediaSource);
|
||||
// This definitely exists...
|
||||
const newMediaSource = await ctx.mediaSourceDB.getById(mediaSource.uuid);
|
||||
return addTunarrLibraryIdsToResponse(
|
||||
response,
|
||||
newMediaSource!,
|
||||
attempts + 1,
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -290,6 +290,7 @@ export const ffmpegSettingsRouter: RouterPluginCallback = (
|
||||
params: IdPathParamSchema,
|
||||
response: {
|
||||
200: z.void(),
|
||||
404: z.void(),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -116,6 +116,7 @@ export const fillerListsApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
body: UpdateFillerListRequestSchema,
|
||||
response: {
|
||||
200: FillerListSchema,
|
||||
404: z.void(),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { MediaSourceType } from '@/db/schema/MediaSource.js';
|
||||
import type { FfmpegEncoder } from '@/ffmpeg/ffmpegInfo.js';
|
||||
import { FfmpegInfo } from '@/ffmpeg/ffmpegInfo.js';
|
||||
import { serverOptions } from '@/globals.js';
|
||||
@@ -10,7 +9,7 @@ import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
|
||||
import { getTunarrVersion } from '@/util/version.js';
|
||||
import { VersionApiResponseSchema } from '@tunarr/types/api';
|
||||
import { fileTypeFromStream } from 'file-type';
|
||||
import { isEmpty, isNil } from 'lodash-es';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { createReadStream, promises as fsPromises } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod/v4';
|
||||
@@ -28,6 +27,7 @@ import { hdhrSettingsRouter } from './hdhrSettingsApi.js';
|
||||
import { jellyfinApiRouter } from './jellyfinApi.js';
|
||||
import { mediaSourceRouter } from './mediaSourceApi.js';
|
||||
import { metadataApiRouter } from './metadataApi.js';
|
||||
import { plexApiRouter } from './plexApi.ts';
|
||||
import { plexSettingsRouter } from './plexSettingsApi.js';
|
||||
import { programmingApi } from './programmingApi.js';
|
||||
import { sessionApiRouter } from './sessionApi.js';
|
||||
@@ -62,6 +62,7 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
||||
.register(hdhrSettingsRouter)
|
||||
.register(systemApiRouter)
|
||||
.register(guideRouter)
|
||||
.register(plexApiRouter)
|
||||
.register(jellyfinApiRouter)
|
||||
.register(sessionApiRouter)
|
||||
.register(embyApiRouter);
|
||||
@@ -142,6 +143,7 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
||||
name: z.string(),
|
||||
fileUrl: z.string(),
|
||||
}),
|
||||
400: z.void(),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -225,7 +227,7 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
||||
.header('Content-Type', 'application/xml')
|
||||
.send(fileFinal);
|
||||
} catch (err) {
|
||||
logger.error('%O', err);
|
||||
logger.error(err);
|
||||
return res.status(500).send('error');
|
||||
}
|
||||
},
|
||||
@@ -286,33 +288,4 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
||||
return res.status(204).send();
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/plex',
|
||||
{
|
||||
schema: {
|
||||
querystring: z.object({ id: z.string(), path: z.string() }),
|
||||
operationId: 'queryPlex',
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
req.logRequestAtLevel = 'trace';
|
||||
const server = await req.serverCtx.mediaSourceDB.findByType(
|
||||
MediaSourceType.Plex,
|
||||
req.query.id,
|
||||
);
|
||||
|
||||
if (isNil(server)) {
|
||||
return res
|
||||
.status(404)
|
||||
.send({ error: 'No server found with id: ' + req.query.id });
|
||||
}
|
||||
|
||||
const plex =
|
||||
await req.serverCtx.mediaSourceApiFactory.getPlexApiClientForMediaSource(
|
||||
server,
|
||||
);
|
||||
return res.send(await plex.doGetPath(req.query.path));
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import type { MediaSource } from '@/db/schema/MediaSource.js';
|
||||
import { MediaSourceType } from '@/db/schema/MediaSource.js';
|
||||
import { isQueryError } from '@/external/BaseApiClient.js';
|
||||
import { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
|
||||
import { TruthyQueryParam } from '@/types/schemas.js';
|
||||
import { inConstArr, isDefined, nullToUndefined } from '@/util/index.js';
|
||||
import { JellyfinLoginRequest } from '@tunarr/types/api';
|
||||
import type { JellyfinCollectionType } from '@tunarr/types/jellyfin';
|
||||
import { mediaSourceParamsSchema, TruthyQueryParam } from '@/types/schemas.js';
|
||||
import { groupByUniq, isDefined, nullToUndefined } from '@/util/index.js';
|
||||
import { tag, type Library } from '@tunarr/types';
|
||||
import { JellyfinLoginRequest, PagedResult } from '@tunarr/types/api';
|
||||
import {
|
||||
JellyfinItemFields,
|
||||
JellyfinItemKind,
|
||||
JellyfinItemSortBy,
|
||||
JellyfinLibraryItemsResponse,
|
||||
TunarrAmendedJellyfinVirtualFolder,
|
||||
} from '@tunarr/types/jellyfin';
|
||||
import { ItemOrFolder, Library as LibrarySchema } from '@tunarr/types/schemas';
|
||||
import type { FastifyReply } from 'fastify/types/reply.js';
|
||||
import { isEmpty, isNil, uniq } from 'lodash-es';
|
||||
import { z } from 'zod/v4';
|
||||
import type { MediaSourceWithLibraries } from '../db/schema/derivedTypes.js';
|
||||
import { ServerRequestContext } from '../ServerContext.ts';
|
||||
import type {
|
||||
RouterPluginCallback,
|
||||
ZodFastifyRequest,
|
||||
@@ -25,19 +25,6 @@ const mediaSourceParams = z.object({
|
||||
mediaSourceId: z.string(),
|
||||
});
|
||||
|
||||
const ValidJellyfinCollectionTypes = [
|
||||
'movies',
|
||||
'tvshows',
|
||||
'music',
|
||||
'trailers',
|
||||
'musicvideos',
|
||||
'homevideos',
|
||||
'playlists',
|
||||
'boxsets',
|
||||
'folders',
|
||||
'unknown',
|
||||
] satisfies JellyfinCollectionType[];
|
||||
|
||||
function isNonEmptyTyped<T>(f: T[]): f is [T, ...T[]] {
|
||||
return !isEmpty(f);
|
||||
}
|
||||
@@ -84,8 +71,7 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
||||
schema: {
|
||||
params: mediaSourceParams,
|
||||
response: {
|
||||
// HACK
|
||||
200: z.array(TunarrAmendedJellyfinVirtualFolder),
|
||||
200: z.array(LibrarySchema),
|
||||
},
|
||||
operationId: 'getJellyfinLibraries',
|
||||
},
|
||||
@@ -99,28 +85,34 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
||||
|
||||
const response = await api.getUserViews();
|
||||
|
||||
if (isQueryError(response)) {
|
||||
throw new Error(response.message);
|
||||
if (response.isFailure()) {
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
return res.send(
|
||||
response.data
|
||||
.filter((library) => {
|
||||
// Mixed collections don't have this set
|
||||
if (!library.CollectionType) {
|
||||
return true;
|
||||
}
|
||||
// const amendedResponse = response
|
||||
// .get()
|
||||
// .filter((library) => {
|
||||
// // Mixed collections don't have this set
|
||||
// if (!library.CollectionType) {
|
||||
// return true;
|
||||
// }
|
||||
|
||||
return inConstArr(
|
||||
ValidJellyfinCollectionTypes,
|
||||
library.CollectionType ?? '',
|
||||
);
|
||||
})
|
||||
.map((lib) => ({
|
||||
...lib,
|
||||
jellyfinType: 'VirtualFolder',
|
||||
})),
|
||||
);
|
||||
// return inConstArr(
|
||||
// ValidJellyfinCollectionTypes,
|
||||
// library.CollectionType ?? '',
|
||||
// );
|
||||
// })
|
||||
// .map(
|
||||
// (lib) =>
|
||||
// ({
|
||||
// ...lib,
|
||||
// jellyfinType: 'VirtualFolder',
|
||||
// }) satisfies TunarrAmendedJellyfinVirtualFolder,
|
||||
// );
|
||||
|
||||
await addTunarrLibraryIdsToResponse(response.get(), mediaSource);
|
||||
|
||||
return res.send(response.get());
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -128,7 +120,7 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
||||
'/jellyfin/:mediaSourceId/libraries/:libraryId/genres',
|
||||
{
|
||||
schema: {
|
||||
params: mediaSourceParams.extend({
|
||||
params: mediaSourceParamsSchema.extend({
|
||||
libraryId: z.string(),
|
||||
}),
|
||||
querystring: z.object({
|
||||
@@ -152,11 +144,11 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
||||
req.query.includeItemTypes,
|
||||
);
|
||||
|
||||
if (isQueryError(response)) {
|
||||
throw new Error(response.message);
|
||||
if (response.isFailure()) {
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
return res.send(response.data);
|
||||
return res.send(response.get());
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -164,7 +156,8 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
||||
'/jellyfin/:mediaSourceId/libraries/:libraryId/items',
|
||||
{
|
||||
schema: {
|
||||
params: mediaSourceParams.extend({
|
||||
operationId: 'getJellyfinLibraryItems',
|
||||
params: mediaSourceParamsSchema.extend({
|
||||
libraryId: z.string(),
|
||||
}),
|
||||
querystring: z.object({
|
||||
@@ -201,9 +194,8 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
||||
parentId: z.string().optional(),
|
||||
}),
|
||||
response: {
|
||||
200: JellyfinLibraryItemsResponse,
|
||||
200: PagedResult(ItemOrFolder.array()),
|
||||
},
|
||||
operationId: 'getJellyfinLibraryItems',
|
||||
},
|
||||
},
|
||||
(req, res) =>
|
||||
@@ -239,25 +231,25 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
||||
: ['SortName', 'ProductionYear'],
|
||||
);
|
||||
|
||||
if (isQueryError(response)) {
|
||||
throw new Error(response.message);
|
||||
if (response.isFailure()) {
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
return res.send(response.data);
|
||||
return res.send(response.get());
|
||||
}),
|
||||
);
|
||||
|
||||
async function withJellyfinMediaSource<
|
||||
Req extends ZodFastifyRequest<{
|
||||
params: typeof mediaSourceParams;
|
||||
params: typeof mediaSourceParamsSchema;
|
||||
}>,
|
||||
>(
|
||||
req: Req,
|
||||
res: FastifyReply,
|
||||
cb: (m: MediaSource) => Promise<FastifyReply>,
|
||||
cb: (m: MediaSourceWithLibraries) => Promise<FastifyReply>,
|
||||
) {
|
||||
const mediaSource = await req.serverCtx.mediaSourceDB.getById(
|
||||
req.params.mediaSourceId,
|
||||
tag(req.params.mediaSourceId),
|
||||
);
|
||||
|
||||
if (isNil(mediaSource)) {
|
||||
@@ -279,3 +271,42 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
async function addTunarrLibraryIdsToResponse(
|
||||
response: Library[],
|
||||
mediaSource: MediaSourceWithLibraries,
|
||||
attempts: number = 1,
|
||||
) {
|
||||
if (attempts > 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const librariesByExternalId = groupByUniq(
|
||||
mediaSource.libraries,
|
||||
(lib) => lib.externalKey,
|
||||
);
|
||||
let needsRefresh = false;
|
||||
for (const library of response) {
|
||||
const tunarrLibrary = librariesByExternalId[library.externalId];
|
||||
if (!tunarrLibrary) {
|
||||
needsRefresh = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
library.uuid = tunarrLibrary.uuid;
|
||||
}
|
||||
|
||||
if (needsRefresh) {
|
||||
const ctx = ServerRequestContext.currentServerContext()!;
|
||||
await ctx.mediaSourceLibraryRefresher.refreshMediaSource(mediaSource);
|
||||
// This definitely exists...
|
||||
const newMediaSource = await ctx.mediaSourceDB.getById(mediaSource.uuid);
|
||||
return addTunarrLibraryIdsToResponse(
|
||||
response,
|
||||
newMediaSource!,
|
||||
attempts + 1,
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,30 +1,45 @@
|
||||
import { MediaSourceType } from '@/db/schema/MediaSource.js';
|
||||
import { GlobalScheduler } from '@/services/Scheduler.js';
|
||||
import { UpdateXmlTvTask } from '@/tasks/UpdateXmlTvTask.js';
|
||||
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
||||
import { nullToUndefined, wait } from '@/util/index.js';
|
||||
import { nullToUndefined } from '@/util/index.js';
|
||||
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
|
||||
import { numberToBoolean } from '@/util/sqliteUtil.js';
|
||||
import { seq } from '@tunarr/shared/util';
|
||||
import type { MediaSourceSettings } from '@tunarr/types';
|
||||
import {
|
||||
tag,
|
||||
type MediaSourceLibrary,
|
||||
type MediaSourceSettings,
|
||||
} from '@tunarr/types';
|
||||
import type {
|
||||
MediaSourceStatus,
|
||||
MediaSourceUnhealthyStatus,
|
||||
ScanProgress,
|
||||
} from '@tunarr/types/api';
|
||||
import {
|
||||
BaseErrorSchema,
|
||||
BasicIdParamSchema,
|
||||
InsertMediaSourceRequestSchema,
|
||||
MediaSourceStatusSchema,
|
||||
ScanProgressSchema,
|
||||
UpdateMediaSourceLibraryRequest,
|
||||
UpdateMediaSourceRequestSchema,
|
||||
} from '@tunarr/types/api';
|
||||
import {
|
||||
ContentProgramSchema,
|
||||
ExternalSourceTypeSchema,
|
||||
MediaSourceLibrarySchema,
|
||||
MediaSourceSettingsSchema,
|
||||
} from '@tunarr/types/schemas';
|
||||
import { isError, isNil } from 'lodash-es';
|
||||
import { isEmpty, isError, isNil, isNull } from 'lodash-es';
|
||||
import type { MarkOptional } from 'ts-essentials';
|
||||
import { match, P } from 'ts-pattern';
|
||||
import { v4 } from 'uuid';
|
||||
import z from 'zod/v4';
|
||||
import { container } from '../container.ts';
|
||||
import type { MediaSourceWithLibraries } from '../db/schema/derivedTypes.js';
|
||||
import { EntityMutex } from '../services/EntityMutex.ts';
|
||||
import { MediaSourceLibraryRefresher } from '../services/MediaSourceLibraryRefresher.ts';
|
||||
import { MediaSourceProgressService } from '../services/scanner/MediaSourceProgressService.ts';
|
||||
import { TruthyQueryParam } from '../types/schemas.ts';
|
||||
|
||||
export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
||||
fastify,
|
||||
@@ -47,27 +62,13 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
const entityLocker = container.get<EntityMutex>(EntityMutex);
|
||||
try {
|
||||
const sources = await req.serverCtx.mediaSourceDB.getAll();
|
||||
|
||||
const dtos = seq.collect(sources, (source) => {
|
||||
return match(source)
|
||||
.returnType<MediaSourceSettings | null>()
|
||||
.with({ type: P.union('plex', 'jellyfin', 'emby') }, (source) => ({
|
||||
id: source.uuid,
|
||||
index: source.index,
|
||||
uri: source.uri,
|
||||
type: source.type,
|
||||
name: source.name,
|
||||
accessToken: source.accessToken,
|
||||
clientIdentifier: nullToUndefined(source.clientIdentifier),
|
||||
sendChannelUpdates: numberToBoolean(source.sendChannelUpdates),
|
||||
sendGuideUpdates: numberToBoolean(source.sendGuideUpdates),
|
||||
userId: source.userId,
|
||||
username: source.username,
|
||||
}))
|
||||
.otherwise(() => null);
|
||||
});
|
||||
const dtos = seq.collect(sources, (source) =>
|
||||
convertToApiMediaSource(entityLocker, source),
|
||||
);
|
||||
|
||||
return res.send(dtos);
|
||||
} catch (err) {
|
||||
@@ -77,6 +78,322 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/media-sources/:id/libraries',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Media Source'],
|
||||
params: BasicIdParamSchema,
|
||||
response: {
|
||||
200: z.array(MediaSourceLibrarySchema),
|
||||
404: z.void(),
|
||||
500: z.string(),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
const mediaSource = await req.serverCtx.mediaSourceDB.getById(
|
||||
tag(req.params.id),
|
||||
);
|
||||
|
||||
if (!mediaSource) {
|
||||
return res.status(404).send();
|
||||
}
|
||||
|
||||
const entityLocker = container.get<EntityMutex>(EntityMutex);
|
||||
const apiMediaSource = convertToApiMediaSource(entityLocker, mediaSource);
|
||||
if (isNull(apiMediaSource)) {
|
||||
return res
|
||||
.status(500)
|
||||
.send('Invalid media source type: ' + mediaSource.type);
|
||||
}
|
||||
|
||||
return res.send(
|
||||
mediaSource.libraries.map(
|
||||
(library) =>
|
||||
({
|
||||
...library,
|
||||
id: library.uuid,
|
||||
type: mediaSource.type,
|
||||
enabled: numberToBoolean(library.enabled),
|
||||
lastScannedAt: nullToUndefined(library.lastScannedAt),
|
||||
isLocked: entityLocker.isLibraryLocked(library),
|
||||
mediaSource: apiMediaSource,
|
||||
}) satisfies MediaSourceLibrary,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.put(
|
||||
'/media-sources/:id/libraries/:libraryId',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Media Source'],
|
||||
params: BasicIdParamSchema.extend({
|
||||
libraryId: z.string(),
|
||||
}),
|
||||
body: UpdateMediaSourceLibraryRequest,
|
||||
response: {
|
||||
200: MediaSourceLibrarySchema,
|
||||
404: z.void(),
|
||||
500: z.string(),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
const mediaSource = await req.serverCtx.mediaSourceDB.getById(
|
||||
tag(req.params.id),
|
||||
);
|
||||
|
||||
if (!mediaSource) {
|
||||
return res.status(404).send();
|
||||
}
|
||||
|
||||
const entityLocker = container.get(EntityMutex);
|
||||
const apiMediaSource = convertToApiMediaSource(entityLocker, mediaSource);
|
||||
if (isNull(apiMediaSource)) {
|
||||
return res
|
||||
.status(500)
|
||||
.send('Invalid media source type: ' + mediaSource.type);
|
||||
}
|
||||
|
||||
const updatedLibrary =
|
||||
await req.serverCtx.mediaSourceDB.setLibraryEnabled(
|
||||
tag(req.params.id),
|
||||
req.params.libraryId,
|
||||
req.body.enabled,
|
||||
);
|
||||
|
||||
if (req.body.enabled) {
|
||||
const result = await req.serverCtx.mediaSourceScanCoordinator.add({
|
||||
libraryId: updatedLibrary.uuid,
|
||||
forceScan: false,
|
||||
});
|
||||
if (!result) {
|
||||
logger.error(
|
||||
'Unable to schedule library ID %s for scanning',
|
||||
updatedLibrary.uuid,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return res.send({
|
||||
...updatedLibrary,
|
||||
id: updatedLibrary.uuid,
|
||||
type: mediaSource.type,
|
||||
enabled: numberToBoolean(updatedLibrary.enabled),
|
||||
lastScannedAt: nullToUndefined(updatedLibrary.lastScannedAt),
|
||||
isLocked: entityLocker.isLibraryLocked(updatedLibrary),
|
||||
mediaSource: apiMediaSource,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/media-libraries/:libraryId',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Media Library'],
|
||||
params: z.object({
|
||||
libraryId: z.string(),
|
||||
}),
|
||||
response: {
|
||||
200: MediaSourceLibrarySchema.extend({
|
||||
mediaSource: MediaSourceSettingsSchema,
|
||||
}),
|
||||
404: z.void(),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
const library = await req.serverCtx.mediaSourceDB.getLibrary(
|
||||
req.params.libraryId,
|
||||
);
|
||||
|
||||
if (!library) {
|
||||
return res.status(404).send();
|
||||
}
|
||||
|
||||
const entityLocker = container.get<EntityMutex>(EntityMutex);
|
||||
|
||||
return res.send({
|
||||
...library,
|
||||
id: library.uuid,
|
||||
type: library.mediaSource.type,
|
||||
enabled: numberToBoolean(library.enabled),
|
||||
lastScannedAt: nullToUndefined(library.lastScannedAt),
|
||||
isLocked: entityLocker.isLibraryLocked(library),
|
||||
mediaSource: convertToApiMediaSource(
|
||||
entityLocker,
|
||||
library.mediaSource,
|
||||
)!,
|
||||
// TODO this is dumb
|
||||
} satisfies MediaSourceLibrary & {
|
||||
mediaSource: MediaSourceSettings;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/media-libraries/:libraryId/programs',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Media Library'],
|
||||
params: z.object({
|
||||
libraryId: z.string(),
|
||||
}),
|
||||
response: {
|
||||
200: z.array(ContentProgramSchema),
|
||||
404: z.void(),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
const library = await req.serverCtx.mediaSourceDB.getLibrary(
|
||||
req.params.libraryId,
|
||||
);
|
||||
|
||||
if (!library) {
|
||||
return res.status(404).send();
|
||||
}
|
||||
|
||||
const programs =
|
||||
await req.serverCtx.programDB.getMediaSourceLibraryPrograms(
|
||||
req.params.libraryId,
|
||||
);
|
||||
|
||||
return res.send(
|
||||
seq.collect(programs, (program) =>
|
||||
req.serverCtx.programConverter.programDaoToContentProgram(
|
||||
program,
|
||||
program.externalIds ?? [],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/media-libraries/:libraryId/status',
|
||||
{
|
||||
schema: {
|
||||
params: z.object({
|
||||
libraryId: z.string(),
|
||||
}),
|
||||
response: {
|
||||
200: ScanProgressSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
const progressService = container.get<MediaSourceProgressService>(
|
||||
MediaSourceProgressService,
|
||||
);
|
||||
|
||||
const progress = progressService.getScanProgress(req.params.libraryId);
|
||||
|
||||
const response = match(progress)
|
||||
.returnType<ScanProgress>()
|
||||
.with({ state: 'in_progress' }, (ip) => ({
|
||||
...ip,
|
||||
startedAt: +ip.startedAt,
|
||||
}))
|
||||
.with(P._, (p) => p)
|
||||
.exhaustive();
|
||||
|
||||
return res.send(response);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/media-sources/:id/libraries/refresh',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Media Source'],
|
||||
params: BasicIdParamSchema,
|
||||
response: {
|
||||
200: z.void(),
|
||||
404: z.void(),
|
||||
501: z.void(),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
const mediaSource = await req.serverCtx.mediaSourceDB.getById(
|
||||
tag(req.params.id),
|
||||
);
|
||||
|
||||
if (!mediaSource) {
|
||||
return res.status(404).send();
|
||||
}
|
||||
|
||||
const refresher = container.get<MediaSourceLibraryRefresher>(
|
||||
MediaSourceLibraryRefresher,
|
||||
);
|
||||
|
||||
await refresher.refreshAll();
|
||||
|
||||
return res.status(200).send();
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/media-sources/:id/libraries/:libraryId/scan',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Media Source'],
|
||||
params: BasicIdParamSchema.extend({
|
||||
libraryId: z.string(),
|
||||
}),
|
||||
querystring: z.object({
|
||||
forceScan: TruthyQueryParam.optional(),
|
||||
}),
|
||||
response: {
|
||||
202: z.void(),
|
||||
404: z.void(),
|
||||
501: z.void(),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
const mediaSource = await req.serverCtx.mediaSourceDB.getById(
|
||||
tag(req.params.id),
|
||||
);
|
||||
|
||||
if (!mediaSource) {
|
||||
return res.status(404).send();
|
||||
}
|
||||
|
||||
const all = req.params.libraryId === 'all';
|
||||
|
||||
const libraries = all
|
||||
? mediaSource.libraries.filter((lib) => lib.enabled)
|
||||
: mediaSource.libraries.filter(
|
||||
(lib) => lib.uuid === req.params.libraryId && lib.enabled,
|
||||
);
|
||||
|
||||
if (!libraries || isEmpty(libraries)) {
|
||||
return res.status(501);
|
||||
}
|
||||
|
||||
for (const library of libraries) {
|
||||
const result = await req.serverCtx.mediaSourceScanCoordinator.add({
|
||||
libraryId: library.uuid,
|
||||
forceScan: !!req.query.forceScan,
|
||||
});
|
||||
if (!result) {
|
||||
logger.error(
|
||||
'Unable to schedule library ID %s for scanning',
|
||||
library.uuid,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(202).send();
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/media-sources/:id/status',
|
||||
{
|
||||
@@ -94,7 +411,9 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
||||
},
|
||||
async (req, res) => {
|
||||
try {
|
||||
const server = await req.serverCtx.mediaSourceDB.getById(req.params.id);
|
||||
const server = await req.serverCtx.mediaSourceDB.getById(
|
||||
tag(req.params.id),
|
||||
);
|
||||
|
||||
if (isNil(server)) {
|
||||
return res.status(404).send();
|
||||
@@ -166,11 +485,15 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
||||
case 'plex': {
|
||||
const plex =
|
||||
await req.serverCtx.mediaSourceApiFactory.getPlexApiClient({
|
||||
...req.body,
|
||||
url: req.body.uri,
|
||||
userId: null,
|
||||
username: null,
|
||||
name: req.body.name ?? 'unknown',
|
||||
mediaSource: {
|
||||
...req.body,
|
||||
uri: req.body.uri,
|
||||
userId: null,
|
||||
username: null,
|
||||
name: tag(req.body.name ?? 'unknown'),
|
||||
uuid: tag(v4()),
|
||||
libraries: [],
|
||||
},
|
||||
});
|
||||
|
||||
healthyPromise = plex.checkServerStatus();
|
||||
@@ -179,11 +502,15 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
||||
case 'jellyfin': {
|
||||
const jellyfin =
|
||||
await req.serverCtx.mediaSourceApiFactory.getJellyfinApiClient({
|
||||
...req.body,
|
||||
url: req.body.uri,
|
||||
userId: null,
|
||||
username: null,
|
||||
name: req.body.name ?? 'unknown',
|
||||
mediaSource: {
|
||||
...req.body,
|
||||
uri: req.body.uri,
|
||||
userId: null,
|
||||
username: null,
|
||||
name: tag(req.body.name ?? 'unknown'),
|
||||
uuid: tag(v4()),
|
||||
libraries: [],
|
||||
},
|
||||
});
|
||||
|
||||
healthyPromise = jellyfin.ping();
|
||||
@@ -192,11 +519,15 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
||||
case 'emby': {
|
||||
const emby =
|
||||
await req.serverCtx.mediaSourceApiFactory.getEmbyApiClient({
|
||||
...req.body,
|
||||
url: req.body.uri,
|
||||
userId: null,
|
||||
username: null,
|
||||
name: req.body.name ?? 'unknown',
|
||||
mediaSource: {
|
||||
...req.body,
|
||||
uri: req.body.uri,
|
||||
userId: null,
|
||||
username: null,
|
||||
name: tag(req.body.name ?? 'unknown'),
|
||||
uuid: tag(v4()),
|
||||
libraries: [],
|
||||
},
|
||||
});
|
||||
|
||||
healthyPromise = emby.ping();
|
||||
@@ -233,7 +564,9 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { deletedServer } =
|
||||
await req.serverCtx.mediaSourceDB.deleteMediaSource(req.params.id);
|
||||
await req.serverCtx.mediaSourceDB.deleteMediaSource(
|
||||
tag(req.params.id),
|
||||
);
|
||||
|
||||
// Are these useful? What do they even do?
|
||||
req.serverCtx.eventService.push({
|
||||
@@ -259,7 +592,7 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
||||
|
||||
return res.send();
|
||||
} catch (err) {
|
||||
logger.error('Error %O', err);
|
||||
logger.error(err);
|
||||
req.serverCtx.eventService.push({
|
||||
type: 'settings-update',
|
||||
message: 'Error deleting media-source.',
|
||||
@@ -343,6 +676,7 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
||||
}),
|
||||
// TODO: Change this
|
||||
400: z.string(),
|
||||
500: z.string(),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -381,54 +715,38 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/plex/status',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Media Source'],
|
||||
querystring: z.object({
|
||||
serverName: z.string(),
|
||||
}),
|
||||
response: {
|
||||
200: MediaSourceStatusSchema,
|
||||
404: BaseErrorSchema,
|
||||
500: BaseErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
try {
|
||||
const server = await req.serverCtx.mediaSourceDB.findByType(
|
||||
MediaSourceType.Plex,
|
||||
req.query.serverName,
|
||||
);
|
||||
|
||||
if (isNil(server)) {
|
||||
return res.status(404).send({ message: 'Plex server not found.' });
|
||||
}
|
||||
|
||||
const plex =
|
||||
await req.serverCtx.mediaSourceApiFactory.getPlexApiClientForMediaSource(
|
||||
server,
|
||||
);
|
||||
|
||||
const s: MediaSourceStatus = await Promise.race([
|
||||
plex.checkServerStatus(),
|
||||
wait(15000).then(
|
||||
() =>
|
||||
({
|
||||
healthy: false,
|
||||
status: 'timeout',
|
||||
}) satisfies MediaSourceUnhealthyStatus,
|
||||
),
|
||||
]);
|
||||
|
||||
return res.send(s);
|
||||
} catch (err) {
|
||||
return res.status(500).send({
|
||||
message: isError(err) ? err.message : 'Unknown error occurred',
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
// TODO put this in its own class.
|
||||
function convertToApiMediaSource(
|
||||
entityLocker: EntityMutex,
|
||||
source: MarkOptional<MediaSourceWithLibraries, 'libraries'>,
|
||||
): MediaSourceSettings | null {
|
||||
return match(source)
|
||||
.returnType<MediaSourceSettings | null>()
|
||||
.with(
|
||||
{ type: P.union('plex', 'jellyfin', 'emby') },
|
||||
(source) =>
|
||||
({
|
||||
id: source.uuid,
|
||||
index: source.index,
|
||||
uri: source.uri,
|
||||
type: source.type,
|
||||
name: source.name,
|
||||
accessToken: source.accessToken,
|
||||
clientIdentifier: nullToUndefined(source.clientIdentifier),
|
||||
sendChannelUpdates: numberToBoolean(source.sendChannelUpdates),
|
||||
sendGuideUpdates: numberToBoolean(source.sendGuideUpdates),
|
||||
libraries: (source.libraries ?? []).map((library) => ({
|
||||
...library,
|
||||
id: library.uuid,
|
||||
type: source.type,
|
||||
enabled: numberToBoolean(library.enabled),
|
||||
lastScannedAt: nullToUndefined(library.lastScannedAt),
|
||||
isLocked: entityLocker.isLibraryLocked(library),
|
||||
})),
|
||||
userId: source.userId,
|
||||
username: source.username,
|
||||
}) satisfies MediaSourceSettings,
|
||||
)
|
||||
.otherwise(() => null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TruthyQueryParam } from '@/types/schemas.js';
|
||||
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
||||
import { isNonEmptyString } from '@/util/index.js';
|
||||
import { tag } from '@tunarr/types';
|
||||
import axios, { AxiosHeaders } from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
ProgramSourceType,
|
||||
programSourceTypeFromString,
|
||||
} from '../db/custom_types/ProgramSourceType.ts';
|
||||
import type { MediaSourceId } from '../db/schema/base.ts';
|
||||
import { getServerContext } from '../ServerContext.ts';
|
||||
|
||||
const externalIdSchema = z
|
||||
@@ -46,7 +48,7 @@ const externalIdSchema = z
|
||||
const [sourceType, sourceId, itemId] = val.split('|', 3);
|
||||
return {
|
||||
externalSourceType: programSourceTypeFromString(sourceType)!,
|
||||
externalSourceId: sourceId,
|
||||
externalSourceId: tag<MediaSourceId>(sourceId),
|
||||
externalItemId: itemId,
|
||||
};
|
||||
});
|
||||
@@ -58,7 +60,9 @@ const thumbOptsSchema = z.object({
|
||||
|
||||
const ExternalMetadataQuerySchema = z.object({
|
||||
id: externalIdSchema,
|
||||
asset: z.enum(['thumb', 'external-link', 'image']),
|
||||
asset: z.enum(['image', 'external-link', 'thumb']),
|
||||
imageType: z.enum(['poster', 'background']).default('poster'),
|
||||
|
||||
mode: z.enum(['json', 'redirect', 'proxy']),
|
||||
cache: TruthyQueryParam.optional().default(true),
|
||||
thumbOptions: z
|
||||
@@ -66,7 +70,6 @@ const ExternalMetadataQuerySchema = z.object({
|
||||
.transform((s) => JSON.parse(s) as unknown)
|
||||
.pipe(thumbOptsSchema)
|
||||
.optional(),
|
||||
imageType: z.enum(['poster', 'background']).default('poster'),
|
||||
});
|
||||
|
||||
type ExternalMetadataQuery = z.infer<typeof ExternalMetadataQuerySchema>;
|
||||
@@ -192,7 +195,7 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
||||
res: FastifyReply,
|
||||
) {
|
||||
const plexApi =
|
||||
await getServerContext().mediaSourceApiFactory.getPlexApiClientByName(
|
||||
await getServerContext().mediaSourceApiFactory.getPlexApiClientById(
|
||||
query.id.externalSourceId,
|
||||
);
|
||||
|
||||
@@ -209,7 +212,7 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
||||
imageType: query.imageType,
|
||||
});
|
||||
} else if (query.asset === 'external-link') {
|
||||
const server = await getServerContext().mediaSourceDB.getByIdOrName(
|
||||
const server = await getServerContext().mediaSourceDB.getById(
|
||||
query.id.externalSourceId,
|
||||
);
|
||||
if (!server || isNil(server.clientIdentifier)) {
|
||||
@@ -228,7 +231,7 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
||||
|
||||
async function handleJellyfinItem(query: ExternalMetadataQuery) {
|
||||
const jellyfinClient =
|
||||
await getServerContext().mediaSourceApiFactory.getJellyfinApiClientByName(
|
||||
await getServerContext().mediaSourceApiFactory.getJellyfinApiClientById(
|
||||
query.id.externalSourceId,
|
||||
);
|
||||
|
||||
@@ -237,7 +240,10 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
||||
}
|
||||
|
||||
if (query.asset === 'thumb' || query.asset === 'image') {
|
||||
return jellyfinClient.getThumbUrl(query.id.externalItemId);
|
||||
return jellyfinClient.getThumbUrl(
|
||||
query.id.externalItemId,
|
||||
query.imageType === 'poster' ? 'Primary' : 'Thumb',
|
||||
);
|
||||
} else if (query.asset === 'external-link') {
|
||||
return jellyfinClient.getExternalUrl(query.id.externalItemId);
|
||||
}
|
||||
@@ -247,7 +253,7 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
||||
|
||||
async function handleEmbyItem(query: ExternalMetadataQuery) {
|
||||
const embyClient =
|
||||
await getServerContext().mediaSourceApiFactory.getEmbyApiClientByName(
|
||||
await getServerContext().mediaSourceApiFactory.getEmbyApiClientById(
|
||||
query.id.externalSourceId,
|
||||
);
|
||||
|
||||
@@ -256,7 +262,10 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
||||
}
|
||||
|
||||
if (query.asset === 'thumb' || query.asset === 'image') {
|
||||
return embyClient.getThumbUrl(query.id.externalItemId);
|
||||
return embyClient.getThumbUrl(
|
||||
query.id.externalItemId,
|
||||
query.imageType === 'poster' ? 'Thumb' : 'Primary',
|
||||
);
|
||||
} else if (query.asset === 'external-link') {
|
||||
return embyClient.getExternalUrl(query.id.externalItemId);
|
||||
}
|
||||
|
||||
419
server/src/api/plexApi.ts
Normal file
419
server/src/api/plexApi.ts
Normal 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;
|
||||
}
|
||||
@@ -1,19 +1,52 @@
|
||||
import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
|
||||
import type { MediaSource } from '@/db/schema/MediaSource.js';
|
||||
import type {
|
||||
MediaSource,
|
||||
MediaSourceLibrary,
|
||||
} from '@/db/schema/MediaSource.js';
|
||||
import { ProgramType } from '@/db/schema/Program.js';
|
||||
import { ProgramGroupingType } from '@/db/schema/ProgramGrouping.js';
|
||||
import type { ProgramGrouping as ProgramGroupingDao } from '@/db/schema/ProgramGrouping.js';
|
||||
import {
|
||||
AllProgramGroupingFields,
|
||||
ProgramGroupingType,
|
||||
} from '@/db/schema/ProgramGrouping.js';
|
||||
import { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
|
||||
import { PlexApiClient } from '@/external/plex/PlexApiClient.js';
|
||||
import { PagingParams, TruthyQueryParam } from '@/types/schemas.js';
|
||||
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
||||
import { ifDefined, isNonEmptyString } from '@/util/index.js';
|
||||
import {
|
||||
groupByUniq,
|
||||
groupByUniqAndMap,
|
||||
ifDefined,
|
||||
isNonEmptyString,
|
||||
} from '@/util/index.js';
|
||||
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
|
||||
import { BasicIdParamSchema, ProgramChildrenResult } from '@tunarr/types/api';
|
||||
import { seq } from '@tunarr/shared/util';
|
||||
import {
|
||||
tag,
|
||||
type Episode,
|
||||
type Movie,
|
||||
type MusicAlbum,
|
||||
type MusicArtist,
|
||||
type MusicTrack,
|
||||
type ProgramGrouping,
|
||||
type Season,
|
||||
type Show,
|
||||
type TerminalProgram,
|
||||
} from '@tunarr/types';
|
||||
import {
|
||||
BasicIdParamSchema,
|
||||
ProgramChildrenResult,
|
||||
ProgramSearchRequest,
|
||||
ProgramSearchResponse,
|
||||
SearchFilterQuerySchema,
|
||||
} from '@tunarr/types/api';
|
||||
import { ContentProgramSchema } from '@tunarr/types/schemas';
|
||||
import axios, { AxiosHeaders, isAxiosError } from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import type { HttpHeader } from 'fastify/types/utils.js';
|
||||
import { jsonArrayFrom } from 'kysely/helpers/sqlite';
|
||||
import {
|
||||
compact,
|
||||
every,
|
||||
find,
|
||||
first,
|
||||
@@ -25,19 +58,33 @@ import {
|
||||
values,
|
||||
} from 'lodash-es';
|
||||
import type stream from 'node:stream';
|
||||
import { match } from 'ts-pattern';
|
||||
import z from 'zod/v4';
|
||||
import { container } from '../container.ts';
|
||||
import {
|
||||
ProgramSourceType,
|
||||
programSourceTypeFromString,
|
||||
} from '../db/custom_types/ProgramSourceType.ts';
|
||||
import type { ProgramGroupingChildCounts } from '../db/interfaces/IProgramDB.ts';
|
||||
import {
|
||||
AllProgramFields,
|
||||
AllProgramGroupingFields,
|
||||
selectProgramsBuilder,
|
||||
} from '../db/programQueryHelpers.ts';
|
||||
import type { MediaSourceId } from '../db/schema/base.ts';
|
||||
import type {
|
||||
MediaSourceWithLibraries,
|
||||
ProgramWithRelations,
|
||||
} from '../db/schema/derivedTypes.js';
|
||||
import type {
|
||||
ProgramGroupingSearchDocument,
|
||||
ProgramSearchDocument,
|
||||
TerminalProgramSearchDocument,
|
||||
} from '../services/MeilisearchService.ts';
|
||||
import { decodeCaseSensitiveId } from '../services/MeilisearchService.ts';
|
||||
import { FfprobeStreamDetails } from '../stream/FfprobeStreamDetails.ts';
|
||||
import { ExternalStreamDetailsFetcherFactory } from '../stream/StreamDetailsFetcher.ts';
|
||||
import type { Path } from '../types/path.ts';
|
||||
import type { Maybe } from '../types/util.ts';
|
||||
|
||||
const LookupExternalProgrammingSchema = z.object({
|
||||
externalId: z
|
||||
@@ -62,6 +109,243 @@ const BatchLookupExternalProgrammingSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
function isProgramGroupingDocument(
|
||||
doc: ProgramSearchDocument,
|
||||
): doc is ProgramGroupingSearchDocument {
|
||||
switch (doc.type) {
|
||||
case 'show':
|
||||
case 'season':
|
||||
case 'artist':
|
||||
case 'album':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function convertProgramSearchResult(
|
||||
doc: TerminalProgramSearchDocument,
|
||||
program: ProgramWithRelations,
|
||||
mediaSource: MediaSourceWithLibraries,
|
||||
mediaLibrary: MediaSourceLibrary,
|
||||
): TerminalProgram {
|
||||
if (!program.canonicalId) {
|
||||
throw new Error('');
|
||||
}
|
||||
|
||||
const externalId = doc.externalIds.find(
|
||||
(eid) => eid.source === mediaSource.type,
|
||||
)?.id;
|
||||
if (!externalId) {
|
||||
throw new Error('');
|
||||
}
|
||||
|
||||
const base = {
|
||||
mediaSourceId: mediaSource.uuid,
|
||||
libraryId: mediaLibrary.uuid,
|
||||
externalLibraryId: mediaLibrary.externalKey,
|
||||
releaseDate: doc.originalReleaseDate,
|
||||
releaseDateString: doc.originalReleaseDate
|
||||
? dayjs(doc.originalReleaseDate).format('YYYY-MM-DD')
|
||||
: null,
|
||||
externalId,
|
||||
sourceType: mediaSource.type,
|
||||
};
|
||||
|
||||
const identifiers = doc.externalIds.map((eid) => ({
|
||||
id: eid.id,
|
||||
sourceId: isNonEmptyString(eid.sourceId)
|
||||
? decodeCaseSensitiveId(eid.sourceId)
|
||||
: undefined,
|
||||
type: eid.source,
|
||||
}));
|
||||
|
||||
const uuid = doc.id;
|
||||
const year =
|
||||
doc.originalReleaseYear ??
|
||||
(doc.originalReleaseDate && doc.originalReleaseDate > 0
|
||||
? dayjs(doc.originalReleaseDate).year()
|
||||
: null);
|
||||
const releaseDate =
|
||||
doc.originalReleaseDate && doc.originalReleaseDate > 0
|
||||
? doc.originalReleaseDate
|
||||
: null;
|
||||
|
||||
const result = match(doc)
|
||||
.returnType<TerminalProgram | null>()
|
||||
.with(
|
||||
{ type: 'episode' },
|
||||
(ep) =>
|
||||
({
|
||||
...ep,
|
||||
...base,
|
||||
uuid,
|
||||
originalTitle: null,
|
||||
year,
|
||||
releaseDate,
|
||||
identifiers,
|
||||
episodeNumber: ep.index ?? 0,
|
||||
canonicalId: program.canonicalId!,
|
||||
// mediaItem: {
|
||||
// displayAspectRatio: '',
|
||||
// duration: doc.duration,
|
||||
// resolution: {
|
||||
// widthPx: doc.videoWidth ?? 0,
|
||||
// heightPx: doc.videoHeight ?? 0,
|
||||
// },
|
||||
// sampleAspectRatio: '',
|
||||
|
||||
// },
|
||||
}) satisfies Episode,
|
||||
)
|
||||
.with(
|
||||
{ type: 'movie' },
|
||||
(movie) =>
|
||||
({
|
||||
...movie,
|
||||
...base,
|
||||
identifiers,
|
||||
uuid,
|
||||
originalTitle: null,
|
||||
year,
|
||||
releaseDate,
|
||||
canonicalId: program.canonicalId!,
|
||||
}) satisfies Movie,
|
||||
)
|
||||
.with(
|
||||
{ type: 'track' },
|
||||
(track) =>
|
||||
({
|
||||
...track,
|
||||
...base,
|
||||
identifiers,
|
||||
uuid,
|
||||
originalTitle: null,
|
||||
year,
|
||||
releaseDate,
|
||||
canonicalId: program.canonicalId!,
|
||||
trackNumber: doc.index ?? 0,
|
||||
}) satisfies MusicTrack,
|
||||
)
|
||||
.otherwise(() => null);
|
||||
|
||||
if (!result) {
|
||||
throw new Error('');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function convertProgramGroupingSearchResult(
|
||||
doc: ProgramGroupingSearchDocument,
|
||||
grouping: ProgramGroupingDao,
|
||||
childCounts: Maybe<ProgramGroupingChildCounts>,
|
||||
mediaSource: MediaSourceWithLibraries,
|
||||
mediaLibrary: MediaSourceLibrary,
|
||||
) {
|
||||
if (!grouping.canonicalId) {
|
||||
throw new Error('');
|
||||
}
|
||||
|
||||
const childCount = childCounts?.childCount;
|
||||
const grandchildCount = childCounts?.grandchildCount;
|
||||
|
||||
const identifiers = doc.externalIds.map((eid) => ({
|
||||
id: eid.id,
|
||||
sourceId: isNonEmptyString(eid.sourceId)
|
||||
? decodeCaseSensitiveId(eid.sourceId)
|
||||
: undefined,
|
||||
type: eid.source,
|
||||
}));
|
||||
|
||||
const uuid = doc.id;
|
||||
const studios = doc?.studio?.map(({ name }) => ({ name })) ?? [];
|
||||
|
||||
const externalId = doc.externalIds.find(
|
||||
(eid) => eid.source === mediaSource.type,
|
||||
)?.id;
|
||||
if (!externalId) {
|
||||
throw new Error('');
|
||||
}
|
||||
|
||||
const base = {
|
||||
mediaSourceId: mediaSource.uuid,
|
||||
libraryId: mediaLibrary.uuid,
|
||||
externalLibraryId: mediaLibrary.externalKey,
|
||||
releaseDate: doc.originalReleaseDate,
|
||||
releaseDateString: doc.originalReleaseDate
|
||||
? dayjs(doc.originalReleaseDate).format('YYYY-MM-DD')
|
||||
: null,
|
||||
externalId,
|
||||
sourceType: mediaSource.type,
|
||||
};
|
||||
|
||||
const result = match(doc)
|
||||
.returnType<ProgramGrouping>()
|
||||
.with(
|
||||
{ type: 'season' },
|
||||
(season) =>
|
||||
({
|
||||
...season,
|
||||
...base,
|
||||
identifiers,
|
||||
uuid,
|
||||
canonicalId: grouping.canonicalId!,
|
||||
studios,
|
||||
year: doc.originalReleaseYear,
|
||||
index: doc.index ?? 0,
|
||||
childCount,
|
||||
grandchildCount,
|
||||
}) satisfies Season,
|
||||
)
|
||||
.with(
|
||||
{ type: 'show' },
|
||||
(show) =>
|
||||
({
|
||||
...show,
|
||||
...base,
|
||||
identifiers,
|
||||
uuid,
|
||||
canonicalId: grouping.canonicalId!,
|
||||
studios,
|
||||
year: doc.originalReleaseYear,
|
||||
childCount,
|
||||
grandchildCount,
|
||||
}) satisfies Show,
|
||||
)
|
||||
.with(
|
||||
{ type: 'album' },
|
||||
(album) =>
|
||||
({
|
||||
...album,
|
||||
...base,
|
||||
identifiers,
|
||||
uuid,
|
||||
canonicalId: grouping.canonicalId!,
|
||||
// studios,
|
||||
year: doc.originalReleaseYear,
|
||||
childCount,
|
||||
grandchildCount,
|
||||
}) satisfies MusicAlbum,
|
||||
)
|
||||
.with(
|
||||
{ type: 'artist' },
|
||||
(artist) =>
|
||||
({
|
||||
...artist,
|
||||
...base,
|
||||
identifiers,
|
||||
uuid,
|
||||
canonicalId: grouping.canonicalId!,
|
||||
childCount,
|
||||
grandchildCount,
|
||||
}) satisfies MusicArtist,
|
||||
)
|
||||
.exhaustive();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
const logger = LoggerFactory.child({
|
||||
@@ -69,6 +353,237 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
className: 'ProgrammingApi',
|
||||
});
|
||||
|
||||
fastify.post(
|
||||
'/programs/search',
|
||||
{
|
||||
schema: {
|
||||
body: ProgramSearchRequest,
|
||||
response: {
|
||||
200: ProgramSearchResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
const result = await req.serverCtx.searchService.search('programs', {
|
||||
query: req.body.query.query,
|
||||
filter: req.body.query.filter,
|
||||
paging: {
|
||||
offset: req.body.page ?? 1,
|
||||
limit: req.body.limit ?? 20,
|
||||
},
|
||||
libraryId: req.body.libraryId,
|
||||
// TODO not a great cast...
|
||||
restrictSearchTo: req.body.query
|
||||
.restrictSearchTo as Path<ProgramSearchDocument>[],
|
||||
});
|
||||
|
||||
const [programIds, groupingIds] = result.hits.reduce(
|
||||
(acc, curr) => {
|
||||
const [programs, groupings] = acc;
|
||||
if (isProgramGroupingDocument(curr)) {
|
||||
groupings.push(curr.id);
|
||||
} else {
|
||||
programs.push(curr.id);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[[], []] as [string[], string[]],
|
||||
);
|
||||
|
||||
const allMediaSources = await req.serverCtx.mediaSourceDB.getAll();
|
||||
const allMediaSourcesById = groupByUniq(
|
||||
allMediaSources,
|
||||
(ms) => ms.uuid as string,
|
||||
);
|
||||
const allLibrariesById = groupByUniq(
|
||||
allMediaSources.flatMap((ms) => ms.libraries),
|
||||
(lib) => lib.uuid,
|
||||
);
|
||||
|
||||
const [programs, groupings, groupingCounts] = await Promise.all([
|
||||
req.serverCtx.programDB
|
||||
.getProgramsByIds(programIds)
|
||||
.then((res) => groupByUniq(res, (p) => p.uuid)),
|
||||
req.serverCtx.programDB.getProgramGroupings(groupingIds),
|
||||
req.serverCtx.programDB.getProgramGroupingChildCounts(groupingIds),
|
||||
]);
|
||||
|
||||
const results = seq.collect(result.hits, (program) => {
|
||||
const mediaSourceId = decodeCaseSensitiveId(program.mediaSourceId);
|
||||
const mediaSource = allMediaSourcesById[mediaSourceId];
|
||||
if (!mediaSource) {
|
||||
console.log('no media src');
|
||||
return;
|
||||
}
|
||||
const libraryId = decodeCaseSensitiveId(program.libraryId);
|
||||
const library = allLibrariesById[libraryId];
|
||||
if (!library) {
|
||||
console.log('no library');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isProgramGroupingDocument(program) && groupings[program.id]) {
|
||||
return convertProgramGroupingSearchResult(
|
||||
program,
|
||||
groupings[program.id],
|
||||
groupingCounts[program.id],
|
||||
mediaSource,
|
||||
library,
|
||||
);
|
||||
} else if (
|
||||
!isProgramGroupingDocument(program) &&
|
||||
programs[program.id]
|
||||
) {
|
||||
return convertProgramSearchResult(
|
||||
program,
|
||||
programs[program.id],
|
||||
mediaSource,
|
||||
library,
|
||||
);
|
||||
}
|
||||
|
||||
console.log('here');
|
||||
|
||||
return;
|
||||
});
|
||||
|
||||
return res.send({
|
||||
results,
|
||||
page: result.page,
|
||||
totalHits: result.totalHits,
|
||||
totalPages: result.totalPages,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/programs/:id/descendants',
|
||||
{
|
||||
schema: {
|
||||
params: z.object({
|
||||
id: z.uuid(),
|
||||
}),
|
||||
response: {
|
||||
200: z.array(ContentProgramSchema),
|
||||
404: z.void(),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
const grouping = await req.serverCtx.programDB.getProgramGrouping(
|
||||
req.params.id,
|
||||
);
|
||||
if (isNil(grouping)) {
|
||||
const program = await req.serverCtx.programDB.getProgramById(
|
||||
req.params.id,
|
||||
);
|
||||
if (program) {
|
||||
return res.send(
|
||||
compact([
|
||||
req.serverCtx.programConverter.convertProgramWithExternalIds(
|
||||
program,
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return res.status(404).send();
|
||||
}
|
||||
|
||||
const programs =
|
||||
await req.serverCtx.programDB.getProgramGroupingDescendants(
|
||||
req.params.id,
|
||||
grouping.type,
|
||||
);
|
||||
|
||||
const apiPrograms = seq.collect(programs, (program) =>
|
||||
req.serverCtx.programConverter.convertProgramWithExternalIds(program),
|
||||
);
|
||||
|
||||
return res.send(apiPrograms);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/programs/facets/:facetName',
|
||||
{
|
||||
schema: {
|
||||
params: z.object({
|
||||
facetName: z.string(),
|
||||
}),
|
||||
querystring: z.object({
|
||||
facetQuery: z.string().optional(),
|
||||
libraryId: z.string().uuid().optional(),
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
facetValues: z.record(z.string(), z.number()),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
const facetResult = await req.serverCtx.searchService.facetSearch(
|
||||
'programs',
|
||||
{
|
||||
facetQuery: req.query.facetQuery,
|
||||
facetName: req.params.facetName,
|
||||
libraryId: req.query.libraryId,
|
||||
},
|
||||
);
|
||||
|
||||
return res.send({
|
||||
facetValues: groupByUniqAndMap(
|
||||
facetResult.facetHits,
|
||||
'value',
|
||||
(hit) => hit.count,
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/programs/facets/:facetName',
|
||||
{
|
||||
schema: {
|
||||
params: z.object({
|
||||
facetName: z.string(),
|
||||
}),
|
||||
querystring: z.object({
|
||||
facetQuery: z.string().optional(),
|
||||
libraryId: z.string().uuid().optional(),
|
||||
}),
|
||||
body: z.object({
|
||||
filter: SearchFilterQuerySchema.optional(),
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
facetValues: z.record(z.string(), z.number()),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
const facetResult = await req.serverCtx.searchService.facetSearch(
|
||||
'programs',
|
||||
{
|
||||
facetQuery: req.query.facetQuery,
|
||||
facetName: req.params.facetName,
|
||||
libraryId: req.query.libraryId,
|
||||
filter: req.body.filter,
|
||||
},
|
||||
);
|
||||
|
||||
return res.send({
|
||||
facetValues: groupByUniqAndMap(
|
||||
facetResult.facetHits,
|
||||
'value',
|
||||
(hit) => hit.count,
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/programs/:id',
|
||||
{
|
||||
@@ -111,11 +626,15 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
|
||||
if (!program) {
|
||||
return res.status(404).send('Program not found');
|
||||
} else if (!program.mediaSourceId) {
|
||||
return res
|
||||
.status(404)
|
||||
.send('Program has no associated media source ID');
|
||||
}
|
||||
|
||||
const server = await req.serverCtx.mediaSourceDB.findByType(
|
||||
program.sourceType,
|
||||
program.mediaSourceId ?? program.externalSourceId,
|
||||
program.mediaSourceId,
|
||||
);
|
||||
|
||||
if (!server) {
|
||||
@@ -184,6 +703,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
}),
|
||||
response: {
|
||||
200: ProgramChildrenResult,
|
||||
400: z.void(),
|
||||
404: z.void(),
|
||||
},
|
||||
},
|
||||
@@ -202,7 +722,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
grouping.type,
|
||||
req.query,
|
||||
);
|
||||
const result = results.map((program) =>
|
||||
const result = seq.collect(results, (program) =>
|
||||
req.serverCtx.programConverter.programDaoToContentProgram(
|
||||
program,
|
||||
program.externalIds,
|
||||
@@ -215,6 +735,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
type: grouping.type === 'album' ? 'track' : 'episode',
|
||||
programs: result,
|
||||
},
|
||||
size: result.length,
|
||||
});
|
||||
} else if (grouping.type === 'artist') {
|
||||
const { total, results } = await req.serverCtx.programDB.getChildren(
|
||||
@@ -225,7 +746,11 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
const result = results.map((program) =>
|
||||
req.serverCtx.programConverter.programGroupingDaoToDto(program),
|
||||
);
|
||||
return res.send({ total, result: { type: 'album', programs: result } });
|
||||
return res.send({
|
||||
total,
|
||||
result: { type: 'album', programs: result },
|
||||
size: result.length,
|
||||
});
|
||||
} else if (grouping.type === 'show') {
|
||||
const { total, results } = await req.serverCtx.programDB.getChildren(
|
||||
req.params.id,
|
||||
@@ -238,6 +763,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
return res.send({
|
||||
total,
|
||||
result: { type: 'season', programs: result },
|
||||
size: result.length,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -283,6 +809,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
const handleResult = async (mediaSource: MediaSource, result: string) => {
|
||||
if (req.query.method === 'proxy') {
|
||||
try {
|
||||
logger.debug('Proxying response to %s', result);
|
||||
const proxyRes = await axios.request<stream.Readable>({
|
||||
url: result,
|
||||
responseType: 'stream',
|
||||
@@ -317,14 +844,17 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
return res.redirect(result, 302).send();
|
||||
};
|
||||
|
||||
if (!isNil(program)) {
|
||||
const mediaSource = await req.serverCtx.mediaSourceDB.getByExternalId(
|
||||
if (!isNil(program?.mediaSourceId)) {
|
||||
const mediaSource = await req.serverCtx.mediaSourceDB.findByType(
|
||||
program.sourceType,
|
||||
program.externalSourceId,
|
||||
program.mediaSourceId,
|
||||
);
|
||||
|
||||
if (isNil(mediaSource)) {
|
||||
return res.status(404).send();
|
||||
logger.error('No media source: %O', program);
|
||||
return res
|
||||
.status(404)
|
||||
.send(`No media source for id/name ${program.externalSourceId}`);
|
||||
}
|
||||
|
||||
let keyToUse = program.externalKey;
|
||||
@@ -418,14 +948,14 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
|
||||
const mediaSource = await (isNonEmptyString(source.mediaSourceId)
|
||||
? req.serverCtx.mediaSourceDB.getById(source.mediaSourceId)
|
||||
: req.serverCtx.mediaSourceDB.getByExternalId(
|
||||
// This was asserted above
|
||||
source.sourceType as 'plex' | 'jellyfin',
|
||||
source.externalSourceId,
|
||||
));
|
||||
: null);
|
||||
|
||||
if (isNil(mediaSource)) {
|
||||
return res.status(404).send();
|
||||
return res
|
||||
.status(404)
|
||||
.send(
|
||||
`Could not find media source with id ${source.externalSourceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
switch (mediaSource.type) {
|
||||
@@ -475,6 +1005,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
200: z.object({ url: z.string() }),
|
||||
302: z.void(),
|
||||
404: z.void(),
|
||||
405: z.void(),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -501,7 +1032,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
const server = find(
|
||||
mediaSources,
|
||||
(source) =>
|
||||
source.uuid === externalId.externalSourceId ||
|
||||
source.uuid === externalId.mediaSourceId ||
|
||||
source.name === externalId.externalSourceId,
|
||||
);
|
||||
|
||||
@@ -552,11 +1083,12 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
200: ContentProgramSchema,
|
||||
400: z.object({ message: z.string() }),
|
||||
404: z.void(),
|
||||
500: z.string(),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
const [sourceType, ,] = req.params.externalId;
|
||||
const [sourceType, rawServerId, id] = req.params.externalId;
|
||||
const sourceTypeParsed = programSourceTypeFromString(sourceType);
|
||||
if (isUndefined(sourceTypeParsed)) {
|
||||
return res
|
||||
@@ -565,7 +1097,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
}
|
||||
|
||||
const result = await req.serverCtx.programDB.lookupByExternalIds(
|
||||
new Set([req.params.externalId]),
|
||||
new Set([[sourceType, tag(rawServerId), id]]),
|
||||
);
|
||||
const program = first(values(result));
|
||||
|
||||
@@ -573,7 +1105,18 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
return res.status(404).send();
|
||||
}
|
||||
|
||||
return res.send(program);
|
||||
const converted =
|
||||
req.serverCtx.programConverter.programDaoToContentProgram(program);
|
||||
|
||||
if (!converted) {
|
||||
return res
|
||||
.status(500)
|
||||
.send(
|
||||
'Could not convert program. It might be missing a mediaSourceId',
|
||||
);
|
||||
}
|
||||
|
||||
return res.send(converted);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -590,8 +1133,24 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
const ids = req.body.externalIds
|
||||
.values()
|
||||
.map(
|
||||
([source, sourceId, id]) =>
|
||||
[source, tag<MediaSourceId>(sourceId), id] as const,
|
||||
)
|
||||
.toArray();
|
||||
const results = await req.serverCtx.programDB.lookupByExternalIds(
|
||||
new Set(ids),
|
||||
);
|
||||
|
||||
return res.send(
|
||||
await req.serverCtx.programDB.lookupByExternalIds(req.body.externalIds),
|
||||
groupByUniq(
|
||||
seq.collect(results, (p) =>
|
||||
req.serverCtx.programConverter.programDaoToContentProgram(p),
|
||||
),
|
||||
(p) => p.id,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -111,6 +111,7 @@ export const sessionApiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
||||
}),
|
||||
response: {
|
||||
200: ChannelSessionsResponseSchema,
|
||||
201: z.void(),
|
||||
404: z.string(),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { StreamConnectionDetails } from '@tunarr/types/api';
|
||||
import { ChannelStreamModeSchema } from '@tunarr/types/schemas';
|
||||
import dayjs from 'dayjs';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import { isNil, isNumber, isUndefined } from 'lodash-es';
|
||||
import { isArray, isNil, isNumber, isUndefined } from 'lodash-es';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { PassThrough } from 'node:stream';
|
||||
@@ -27,7 +27,14 @@ export const streamApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||
});
|
||||
|
||||
fastify.addHook('onError', (req, _, error, done) => {
|
||||
logger.error(error, '%s %s', req.routeOptions.method, req.routeOptions.url);
|
||||
logger.error(
|
||||
error,
|
||||
'%s %s',
|
||||
isArray(req.routeOptions.method)
|
||||
? req.routeOptions.method.join(', ')
|
||||
: req.routeOptions.method,
|
||||
req.routeOptions.url,
|
||||
);
|
||||
done();
|
||||
});
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ export const videoApiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
||||
});
|
||||
|
||||
ffmpeg.on('error', (err) => {
|
||||
logger.error('FFMPEG ERROR', err);
|
||||
logger.error(err, 'FFMPEG ERROR');
|
||||
buffer.push(null);
|
||||
void res.status(500).send('FFMPEG ERROR');
|
||||
return;
|
||||
@@ -114,7 +114,7 @@ export const videoApiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
||||
done();
|
||||
},
|
||||
onError(req, _, e) {
|
||||
logger.error(e, 'Error on /stream: %s. %O', req.raw.url);
|
||||
logger.error(e, 'Error on /stream: %s', req.raw.url);
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
@@ -155,9 +155,9 @@ export const videoApiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
||||
|
||||
if (rawStreamResult.type === 'error') {
|
||||
logger.error(
|
||||
'Error starting stream! Message: %s, Error: %O',
|
||||
rawStreamResult.message,
|
||||
rawStreamResult.error ?? null,
|
||||
'Error starting stream! Message: %s',
|
||||
rawStreamResult.message,
|
||||
);
|
||||
return res
|
||||
.status(rawStreamResult.httpStatus)
|
||||
|
||||
@@ -18,6 +18,7 @@ export type ServerArgsType = GlobalArgsType & {
|
||||
port: number;
|
||||
printRoutes: boolean;
|
||||
trustProxy: boolean;
|
||||
searchPort?: number;
|
||||
};
|
||||
|
||||
export const RunServerCommand: CommandModule<GlobalArgsType, ServerArgsType> = {
|
||||
@@ -39,6 +40,9 @@ export const RunServerCommand: CommandModule<GlobalArgsType, ServerArgsType> = {
|
||||
default: () =>
|
||||
getBooleanEnvVar(TUNARR_ENV_VARS.TRUST_PROXY_ENV_VAR, false),
|
||||
},
|
||||
searchPort: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
handler: async (
|
||||
opts: ArgumentsCamelCase<MarkOptional<ServerArgsType, 'port'>>,
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { isMainThread, parentPort } from 'node:worker_threads';
|
||||
import { isMainThread, parentPort, workerData } from 'node:worker_threads';
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { container } from '../container.ts';
|
||||
import type { ServerOptions } from '../globals.ts';
|
||||
import { setServerOptions } from '../globals.ts';
|
||||
import { StartupService } from '../services/StartupService.ts';
|
||||
import { TunarrWorker } from '../services/TunarrWorker.ts';
|
||||
import type { GenerateOpenApiCommandArgs } from './GenerateOpenApiCommand.ts';
|
||||
import type { GlobalArgsType } from './types.ts';
|
||||
|
||||
type WorkerData = {
|
||||
serverOptions: ServerOptions;
|
||||
};
|
||||
|
||||
export const StartWorkerCommand: CommandModule<
|
||||
GlobalArgsType,
|
||||
GenerateOpenApiCommandArgs
|
||||
> = {
|
||||
command: 'start-worker',
|
||||
describe: 'Starts a Tunarr worker (internal use only)',
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
handler: async () => {
|
||||
if (isMainThread) {
|
||||
console.error('This module is only meant to be run as a worker thread.');
|
||||
@@ -23,6 +29,11 @@ export const StartWorkerCommand: CommandModule<
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// TODO: parse
|
||||
const { serverOptions } = workerData as WorkerData;
|
||||
setServerOptions(serverOptions);
|
||||
|
||||
await container.get<StartupService>(StartupService).runStartupServices();
|
||||
container.get<TunarrWorker>(TunarrWorker).start();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -28,11 +28,16 @@ import { isMainThread } from 'node:worker_threads';
|
||||
import type { DeepPartial } from 'ts-essentials';
|
||||
import { App } from './App.ts';
|
||||
import { SettingsDBFactory } from './db/SettingsDBFactory.ts';
|
||||
import { ExternalApiModule } from './external/ExternalApiModule.ts';
|
||||
import { MediaSourceApiFactory } from './external/MediaSourceApiFactory.ts';
|
||||
import { FfmpegPipelineBuilderModule } from './ffmpeg/builder/pipeline/PipelineBuilderFactory.ts';
|
||||
import type { IWorkerPool } from './interfaces/IWorkerPool.ts';
|
||||
import { EntityMutex } from './services/EntityMutex.ts';
|
||||
import { FileSystemService } from './services/FileSystemService.ts';
|
||||
import { MediaSourceLibraryRefresher } from './services/MediaSourceLibraryRefresher.js';
|
||||
import { MeilisearchService } from './services/MeilisearchService.ts';
|
||||
import { NoopWorkerPool } from './services/NoopWorkerPool.ts';
|
||||
import { ServicesModule } from './services/ServicesModule.ts';
|
||||
import { StartupService } from './services/StartupService.ts';
|
||||
import { SystemDevicesService } from './services/SystemDevicesService.ts';
|
||||
import { TunarrWorkerPool } from './services/TunarrWorkerPool.ts';
|
||||
@@ -47,6 +52,7 @@ import { SeedFfmpegInfoCache } from './services/startup/SeedFfmpegInfoCache.ts';
|
||||
import { SeedSystemDevicesStartupTask } from './services/startup/SeedSystemDevicesStartupTask.ts';
|
||||
import { ChannelCache } from './stream/ChannelCache.ts';
|
||||
import { FixerRunner } from './tasks/fixers/FixerRunner.ts';
|
||||
import { ChildProcessHelper } from './util/ChildProcessHelper.ts';
|
||||
import { Timer } from './util/Timer.ts';
|
||||
import { getBooleanEnvVar, USE_WORKER_POOL_ENV_VAR } from './util/env.ts';
|
||||
|
||||
@@ -93,6 +99,7 @@ const RootModule = new ContainerModule((bind) => {
|
||||
>(() => (timeout?: number) => new MutexMap(timeout));
|
||||
|
||||
container.bind(MediaSourceApiFactory).toSelf().inSingletonScope();
|
||||
|
||||
// If we need lazy init...
|
||||
// container
|
||||
// .bind<MediaSourceApiFactory>(KEYS.MediaSourceApiFactory)
|
||||
@@ -108,6 +115,12 @@ const RootModule = new ContainerModule((bind) => {
|
||||
|
||||
bind(FixerRunner).toSelf().inSingletonScope();
|
||||
bind(StartupService).toSelf().inSingletonScope();
|
||||
container
|
||||
.bind<
|
||||
interfaces.Factory<MediaSourceLibraryRefresher>
|
||||
>(KEYS.MediaSourceLibraryRefresher)
|
||||
.toAutoFactory(MediaSourceLibraryRefresher);
|
||||
|
||||
bind(TVGuideService).toSelf().inSingletonScope();
|
||||
bind(EventService).toSelf().inSingletonScope();
|
||||
bind(HdhrService).toSelf().inSingletonScope();
|
||||
@@ -139,6 +152,11 @@ const RootModule = new ContainerModule((bind) => {
|
||||
bind<interfaces.AutoFactory<IWorkerPool>>(
|
||||
KEYS.WorkerPoolFactory,
|
||||
).toAutoFactory(KEYS.WorkerPool);
|
||||
bind(EntityMutex).toSelf().inSingletonScope();
|
||||
bind(MeilisearchService).toSelf().inSingletonScope();
|
||||
bind(KEYS.SearchService).toService(MeilisearchService);
|
||||
|
||||
bind(ChildProcessHelper).toSelf().inSingletonScope();
|
||||
|
||||
bind(App).toSelf().inSingletonScope();
|
||||
});
|
||||
@@ -152,5 +170,7 @@ container.load(FixerModule);
|
||||
container.load(FFmpegModule);
|
||||
container.load(FfmpegPipelineBuilderModule);
|
||||
container.load(DynamicChannelsModule);
|
||||
container.load(ServicesModule);
|
||||
container.load(ExternalApiModule);
|
||||
|
||||
export { container };
|
||||
|
||||
@@ -78,6 +78,7 @@ import {
|
||||
isDefined,
|
||||
isNonEmptyString,
|
||||
mapReduceAsyncSeq,
|
||||
programExternalIdString,
|
||||
run,
|
||||
} from '../util/index.ts';
|
||||
import { ProgramConverter } from './converters/ProgramConverter.ts';
|
||||
@@ -99,8 +100,6 @@ import {
|
||||
import { SchemaBackedDbAdapter } from './json/SchemaBackedJsonDBAdapter.ts';
|
||||
import { calculateStartTimeOffsets } from './lineupUtil.ts';
|
||||
import {
|
||||
AllProgramGroupingFields,
|
||||
MinimalProgramGroupingFields,
|
||||
withFallbackPrograms,
|
||||
withMusicArtistAlbums,
|
||||
withProgramExternalIds,
|
||||
@@ -119,8 +118,12 @@ import {
|
||||
NewChannelProgram,
|
||||
Channel as RawChannel,
|
||||
} from './schema/Channel.ts';
|
||||
import { programExternalIdString, ProgramType } from './schema/Program.ts';
|
||||
import { ProgramGroupingType } from './schema/ProgramGrouping.ts';
|
||||
import { ProgramType } from './schema/Program.ts';
|
||||
import {
|
||||
AllProgramGroupingFields,
|
||||
MinimalProgramGroupingFields,
|
||||
ProgramGroupingType,
|
||||
} from './schema/ProgramGrouping.ts';
|
||||
import {
|
||||
ChannelSubtitlePreferences,
|
||||
NewChannelSubtitlePreference,
|
||||
@@ -1389,7 +1392,9 @@ export class ChannelDB implements IChannelDB {
|
||||
externalIdsByProgramId[program.uuid] ?? [],
|
||||
);
|
||||
|
||||
ret[converted.id] = converted;
|
||||
if (converted) {
|
||||
ret[converted.id] = converted;
|
||||
}
|
||||
});
|
||||
|
||||
return ret;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { KEYS } from '@/types/inject.js';
|
||||
import { isNonEmptyString } from '@/util/index.js';
|
||||
import { isNonEmptyString, programExternalIdString } from '@/util/index.js';
|
||||
import { seq } from '@tunarr/shared/util';
|
||||
import { CustomProgram } from '@tunarr/types';
|
||||
import {
|
||||
CreateCustomShowRequest,
|
||||
@@ -21,7 +22,6 @@ import type {
|
||||
NewCustomShow,
|
||||
NewCustomShowContent,
|
||||
} from './schema/CustomShow.ts';
|
||||
import { programExternalIdString } from './schema/Program.ts';
|
||||
import { DB } from './schema/db.ts';
|
||||
|
||||
@injectable()
|
||||
@@ -53,15 +53,21 @@ export class CustomShowDB {
|
||||
.select((eb) => withCustomShowPrograms(eb, { joins: AllProgramJoins }))
|
||||
.executeTakeFirst();
|
||||
|
||||
return map(programs?.customShowContent, (csc) => ({
|
||||
type: 'custom' as const,
|
||||
persisted: true,
|
||||
duration: csc.duration,
|
||||
program: this.programConverter.programDaoToContentProgram(csc, []),
|
||||
customShowId: id,
|
||||
index: csc.index,
|
||||
id: csc.uuid,
|
||||
}));
|
||||
return seq.collect(programs?.customShowContent, (csc) => {
|
||||
const program = this.programConverter.programDaoToContentProgram(csc, []);
|
||||
if (!program) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
type: 'custom' as const,
|
||||
persisted: true,
|
||||
duration: csc.duration,
|
||||
program,
|
||||
customShowId: id,
|
||||
index: csc.index,
|
||||
id: csc.uuid,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async saveShow(id: string, updateRequest: UpdateCustomShowRequest) {
|
||||
|
||||
@@ -68,7 +68,7 @@ class Connection {
|
||||
case 'query':
|
||||
if (process.env['DATABASE_DEBUG_LOGGING']) {
|
||||
this.logger.debug(
|
||||
'Query: %O (%d ms)',
|
||||
'Query: %s (%d ms)',
|
||||
event.query.sql,
|
||||
event.queryDurationMillis,
|
||||
);
|
||||
@@ -77,7 +77,7 @@ class Connection {
|
||||
case 'error':
|
||||
this.logger.error(
|
||||
event.error,
|
||||
'Query error: %O\n%O',
|
||||
'Query error: %s\n%O',
|
||||
event.query.sql,
|
||||
event.query.parameters,
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ContainerModule } from 'inversify';
|
||||
import type { Kysely } from 'kysely';
|
||||
import { DBAccess } from './DBAccess.ts';
|
||||
import { FillerDB } from './FillerListDB.ts';
|
||||
import { ProgramDaoMinter } from './converters/ProgramMinter.ts';
|
||||
import type { DB } from './schema/db.ts';
|
||||
|
||||
const DBModule = new ContainerModule((bind) => {
|
||||
@@ -21,6 +22,11 @@ const DBModule = new ContainerModule((bind) => {
|
||||
KEYS.Database,
|
||||
);
|
||||
bind(KEYS.FillerListDB).to(FillerDB).inSingletonScope();
|
||||
|
||||
bind(ProgramDaoMinter).toSelf();
|
||||
bind<interfaces.AutoFactory<ProgramDaoMinter>>(
|
||||
KEYS.ProgramDaoMinterFactory,
|
||||
).toAutoFactory<ProgramDaoMinter>(ProgramDaoMinter);
|
||||
});
|
||||
|
||||
export { DBModule as dbContainer };
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { IProgramDB } from '@/db/interfaces/IProgramDB.js';
|
||||
import { ChannelCache } from '@/stream/ChannelCache.js';
|
||||
import { KEYS } from '@/types/inject.js';
|
||||
import { isNonEmptyString } from '@/util/index.js';
|
||||
import { isNonEmptyString, programExternalIdString } from '@/util/index.js';
|
||||
import { seq } from '@tunarr/shared/util';
|
||||
import { ContentProgram } from '@tunarr/types';
|
||||
import {
|
||||
CreateFillerListRequest,
|
||||
@@ -44,7 +45,6 @@ import type {
|
||||
NewFillerShow,
|
||||
NewFillerShowContent,
|
||||
} from './schema/FillerShow.ts';
|
||||
import { programExternalIdString } from './schema/Program.ts';
|
||||
import { DB } from './schema/db.ts';
|
||||
import type { ChannelFillerShowWithContent } from './schema/derivedTypes.ts';
|
||||
|
||||
@@ -356,7 +356,7 @@ export class FillerDB implements IFillerListDB {
|
||||
)
|
||||
.executeTakeFirst();
|
||||
|
||||
return map(programs?.fillerContent, (program) =>
|
||||
return seq.collect(programs?.fillerContent, (program) =>
|
||||
this.programConverter.programDaoToContentProgram(program, []),
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ import {
|
||||
SystemSettingsSchema,
|
||||
XmlTvSettings,
|
||||
defaultFfmpegSettings,
|
||||
defaultGlobalMediaSourceSettings,
|
||||
defaultHdhrSettings,
|
||||
defaultPlexStreamSettings,
|
||||
defaultXmlTvSettings as defaultXmlTvSettingsSchema,
|
||||
@@ -23,6 +24,8 @@ import {
|
||||
import {
|
||||
BackupSettings,
|
||||
FfmpegSettingsSchema,
|
||||
GlobalMediaSourceSettings,
|
||||
GlobalMediaSourceSettingsSchema,
|
||||
HdhrSettingsSchema,
|
||||
PlexStreamSettingsSchema,
|
||||
XmlTvSettingsSchema,
|
||||
@@ -56,6 +59,7 @@ export const SettingsSchema = z.object({
|
||||
xmltv: XmlTvSettingsSchema,
|
||||
plexStream: PlexStreamSettingsSchema,
|
||||
ffmpeg: FfmpegSettingsSchema,
|
||||
mediaSource: GlobalMediaSourceSettingsSchema,
|
||||
});
|
||||
|
||||
export type Settings = z.infer<typeof SettingsSchema>;
|
||||
@@ -96,6 +100,7 @@ export const defaultSettings = (dbBasePath: string): SettingsFile => ({
|
||||
xmltv: defaultXmlTvSettings(dbBasePath),
|
||||
plexStream: defaultPlexStreamSettings,
|
||||
ffmpeg: defaultFfmpegSettings,
|
||||
mediaSource: defaultGlobalMediaSourceSettings,
|
||||
},
|
||||
system: {
|
||||
backup: {
|
||||
@@ -175,6 +180,10 @@ export class SettingsDB extends ITypedEventEmitter implements ISettingsDB {
|
||||
return this.db.data.system;
|
||||
}
|
||||
|
||||
globalMediaSourceSettings(): DeepReadonly<GlobalMediaSourceSettings> {
|
||||
return this.db.data.settings.mediaSource;
|
||||
}
|
||||
|
||||
updateFfmpegSettings(ffmpegSettings: FfmpegSettings) {
|
||||
return this.updateSettings('ffmpeg', { ...ffmpegSettings });
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { inject, injectable } from 'inversify';
|
||||
import { Kysely } from 'kysely';
|
||||
import { omit } from 'lodash-es';
|
||||
import { v4 } from 'uuid';
|
||||
import { TranscodeConfigNotFoundError } from '../types/errors.ts';
|
||||
import { TranscodeConfigNotFoundError, WrappedError } from '../types/errors.ts';
|
||||
import { KEYS } from '../types/inject.ts';
|
||||
import { Result } from '../types/result.ts';
|
||||
import {
|
||||
@@ -88,7 +88,9 @@ export class TranscodeConfigDB {
|
||||
|
||||
async duplicateConfig(
|
||||
id: string,
|
||||
): Promise<Result<TranscodeConfigDAO, TranscodeConfigNotFoundError | Error>> {
|
||||
): Promise<
|
||||
Result<TranscodeConfigDAO, TranscodeConfigNotFoundError | WrappedError>
|
||||
> {
|
||||
const baseConfig = await this.getById(id);
|
||||
if (!baseConfig) {
|
||||
return Result.failure(new TranscodeConfigNotFoundError(id));
|
||||
|
||||
@@ -184,7 +184,7 @@ export class ArchiveDatabaseBackup extends DatabaseBackup<string> {
|
||||
)) {
|
||||
if (result.isFailure()) {
|
||||
this.logger.warn(
|
||||
'Unable to delete old backup file: %s',
|
||||
'Unable to delete old backup file: %O',
|
||||
result.error.input,
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Kysely } from 'kysely';
|
||||
import { find, isNil, omitBy } from 'lodash-es';
|
||||
import { isPromise } from 'node:util/types';
|
||||
import { DeepNullable, DeepPartial, MarkRequired } from 'ts-essentials';
|
||||
import { MarkNonNullable, Nullable } from '../../types/util.ts';
|
||||
import {
|
||||
LineupItem,
|
||||
OfflineItem,
|
||||
@@ -91,11 +92,12 @@ export class ProgramConverter {
|
||||
}
|
||||
return this.redirectLineupItemToProgram(item, redirectChannel);
|
||||
} else if (item.type === 'content') {
|
||||
console.log(channel.programs);
|
||||
const program =
|
||||
preMaterializedProgram && preMaterializedProgram.uuid === item.id
|
||||
? preMaterializedProgram
|
||||
: channel.programs.find((p) => p.uuid === item.id);
|
||||
if (isNil(program)) {
|
||||
if (isNil(program) || isNil(program.mediaSourceId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -108,10 +110,44 @@ export class ProgramConverter {
|
||||
return null;
|
||||
}
|
||||
|
||||
convertProgramWithExternalIds(
|
||||
program: MarkNonNullable<
|
||||
MarkRequired<ProgramWithRelations, 'externalIds'>,
|
||||
'mediaSourceId'
|
||||
>,
|
||||
): MarkRequired<ContentProgram, 'id'>;
|
||||
convertProgramWithExternalIds(
|
||||
program: MarkRequired<ProgramWithRelations, 'externalIds'>,
|
||||
): MarkRequired<ContentProgram, 'id'> | null;
|
||||
convertProgramWithExternalIds(
|
||||
program:
|
||||
| MarkRequired<ProgramWithRelations, 'externalIds'>
|
||||
| MarkRequired<
|
||||
MarkNonNullable<ProgramWithRelations, 'mediaSourceId'>,
|
||||
'externalIds'
|
||||
>,
|
||||
): Nullable<MarkRequired<ContentProgram, 'id'>> {
|
||||
return this.programDaoToContentProgram(program);
|
||||
}
|
||||
|
||||
programDaoToContentProgram(
|
||||
program: MarkNonNullable<ProgramWithRelations, 'mediaSourceId'>,
|
||||
externalIds?: MinimalProgramExternalId[],
|
||||
): MarkRequired<ContentProgram, 'id'>;
|
||||
programDaoToContentProgram(
|
||||
program: ProgramWithRelations,
|
||||
externalIds?: MinimalProgramExternalId[],
|
||||
): MarkRequired<ContentProgram, 'id'> | null;
|
||||
programDaoToContentProgram(
|
||||
program:
|
||||
| ProgramWithRelations
|
||||
| MarkNonNullable<ProgramWithRelations, 'mediaSourceId'>,
|
||||
externalIds: MinimalProgramExternalId[] = program.externalIds ?? [],
|
||||
): MarkRequired<ContentProgram, 'id'> {
|
||||
): MarkRequired<ContentProgram, 'id'> | null {
|
||||
if (!program.mediaSourceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let extraFields: Partial<ContentProgram> = {};
|
||||
if (program.type === ProgramType.Episode) {
|
||||
extraFields = {
|
||||
@@ -216,11 +252,14 @@ export class ProgramConverter {
|
||||
type: 'content',
|
||||
id: program.uuid,
|
||||
subtype: program.type,
|
||||
externalIds: seq.collect(externalIds, (eid) => this.toExternalId(eid)),
|
||||
externalIds: seq.collect(program.externalIds ?? externalIds, (eid) =>
|
||||
this.toExternalId(eid),
|
||||
),
|
||||
externalKey: program.externalKey,
|
||||
externalSourceId: program.externalSourceId,
|
||||
externalSourceId: program.mediaSourceId,
|
||||
externalSourceName: program.externalSourceId,
|
||||
externalSourceType: program.sourceType,
|
||||
canonicalId: nullToUndefined(program.canonicalId),
|
||||
...omitBy(extraFields, isNil),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,73 +1,47 @@
|
||||
import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
|
||||
import type { NewSingleOrMultiProgramGroupingExternalId } from '@/db/schema/ProgramGroupingExternalId.js';
|
||||
import { isNonEmptyString } from '@/util/index.js';
|
||||
import { seq } from '@tunarr/shared/util';
|
||||
import type { ContentProgram } from '@tunarr/types';
|
||||
import type { JellyfinItem } from '@tunarr/types/jellyfin';
|
||||
import type { PlexEpisode, PlexMusicTrack } from '@tunarr/types/plex';
|
||||
import {
|
||||
isValidMultiExternalIdType,
|
||||
isValidSingleExternalIdType,
|
||||
} from '@tunarr/types/schemas';
|
||||
import dayjs from 'dayjs';
|
||||
import { injectable } from 'inversify';
|
||||
import { first } from 'lodash-es';
|
||||
import type { MarkRequired } from 'ts-essentials';
|
||||
import { v4 } from 'uuid';
|
||||
import {
|
||||
MediaSourceMusicAlbum,
|
||||
MediaSourceMusicArtist,
|
||||
MediaSourceSeason,
|
||||
MediaSourceShow,
|
||||
} from '../../types/Media.ts';
|
||||
import type { Nullable } from '../../types/util.ts';
|
||||
import { MediaSourceId, MediaSourceName } from '../schema/base.ts';
|
||||
import {
|
||||
NewMusicAlbum,
|
||||
NewMusicArtist,
|
||||
NewProgramGroupingWithExternalIds,
|
||||
NewTvSeason,
|
||||
NewTvShow,
|
||||
} from '../schema/derivedTypes.js';
|
||||
import { MediaSource, MediaSourceLibrary } from '../schema/MediaSource.ts';
|
||||
import {
|
||||
ProgramGroupingType,
|
||||
type NewProgramGrouping,
|
||||
} from '../schema/ProgramGrouping.ts';
|
||||
|
||||
@injectable()
|
||||
export class ProgramGroupingMinter {
|
||||
static mintParentProgramGroupingForPlex(
|
||||
plexItem: PlexEpisode | PlexMusicTrack,
|
||||
): NewProgramGrouping {
|
||||
const now = +dayjs();
|
||||
|
||||
return {
|
||||
uuid: v4(),
|
||||
type:
|
||||
plexItem.type === 'episode'
|
||||
? ProgramGroupingType.Season
|
||||
: ProgramGroupingType.Album,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
index: plexItem.parentIndex ?? null,
|
||||
title: plexItem.parentTitle ?? '',
|
||||
summary: null,
|
||||
icon: null,
|
||||
artistUuid: null,
|
||||
showUuid: null,
|
||||
year: null,
|
||||
};
|
||||
}
|
||||
|
||||
static mintParentProgramGroupingForJellyfin(jellyfinItem: JellyfinItem) {
|
||||
if (jellyfinItem.Type !== 'Episode' && jellyfinItem.Type !== 'Audio') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = +dayjs();
|
||||
|
||||
return {
|
||||
uuid: v4(),
|
||||
type:
|
||||
jellyfinItem.Type === 'Episode'
|
||||
? ProgramGroupingType.Show
|
||||
: ProgramGroupingType.Album,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
index: jellyfinItem.ParentIndexNumber ?? null,
|
||||
title: jellyfinItem.SeasonName ?? jellyfinItem.Album ?? '',
|
||||
summary: null,
|
||||
icon: null,
|
||||
artistUuid: null,
|
||||
showUuid: null,
|
||||
year: jellyfinItem.ProductionYear,
|
||||
} satisfies NewProgramGrouping;
|
||||
}
|
||||
constructor() {}
|
||||
|
||||
static mintGroupingExternalIds(
|
||||
program: ContentProgram,
|
||||
groupingId: string,
|
||||
externalSourceId: string,
|
||||
mediaSourceId: string,
|
||||
externalSourceId: MediaSourceName,
|
||||
mediaSourceId: MediaSourceId,
|
||||
relationType: 'parent' | 'grandparent',
|
||||
): NewSingleOrMultiProgramGroupingExternalId[] {
|
||||
if (program.subtype === 'movie') {
|
||||
@@ -124,6 +98,10 @@ export class ProgramGroupingMinter {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!item.canonicalId || !item.libraryId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = +dayjs();
|
||||
return {
|
||||
uuid: v4(),
|
||||
@@ -140,6 +118,8 @@ export class ProgramGroupingMinter {
|
||||
artistUuid: null,
|
||||
showUuid: null,
|
||||
year: item.grandparent.year,
|
||||
canonicalId: item.canonicalId,
|
||||
libraryId: item.libraryId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -150,6 +130,10 @@ export class ProgramGroupingMinter {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!item.canonicalId || !item.libraryId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = +dayjs();
|
||||
return {
|
||||
uuid: v4(),
|
||||
@@ -166,6 +150,210 @@ export class ProgramGroupingMinter {
|
||||
artistUuid: null,
|
||||
showUuid: null,
|
||||
year: item.parent.year,
|
||||
canonicalId: item.canonicalId,
|
||||
libraryId: item.libraryId,
|
||||
} satisfies NewProgramGrouping;
|
||||
}
|
||||
|
||||
mintForMediaSourceShow(
|
||||
mediaSource: MediaSource,
|
||||
mediaSourceLibrary: MediaSourceLibrary,
|
||||
show: MediaSourceShow,
|
||||
): NewTvShow {
|
||||
const now = +dayjs();
|
||||
const groupingId = v4();
|
||||
|
||||
const externalIds = seq.collect(show.identifiers, (id) => {
|
||||
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
|
||||
return {
|
||||
type: 'single',
|
||||
externalKey: id.id,
|
||||
groupUuid: groupingId,
|
||||
sourceType: id.type,
|
||||
uuid: v4(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
} satisfies NewSingleOrMultiProgramGroupingExternalId;
|
||||
} else if (isValidMultiExternalIdType(id.type)) {
|
||||
return {
|
||||
type: 'multi',
|
||||
externalKey: id.id,
|
||||
groupUuid: groupingId,
|
||||
sourceType: id.type,
|
||||
uuid: v4(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
externalSourceId: mediaSource.name, // legacy
|
||||
mediaSourceId: mediaSource.uuid, // new
|
||||
} satisfies NewSingleOrMultiProgramGroupingExternalId;
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
|
||||
return {
|
||||
uuid: groupingId,
|
||||
type: ProgramGroupingType.Show,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
// index: show.index,
|
||||
title: show.title,
|
||||
summary: show.summary,
|
||||
year: show.year,
|
||||
libraryId: mediaSourceLibrary.uuid,
|
||||
canonicalId: show.canonicalId,
|
||||
externalIds,
|
||||
} satisfies NewProgramGroupingWithExternalIds;
|
||||
}
|
||||
|
||||
mintForMediaSourceArtist(
|
||||
mediaSource: MediaSource,
|
||||
mediaSourceLibrary: MediaSourceLibrary,
|
||||
artist: MediaSourceMusicArtist,
|
||||
): NewMusicArtist {
|
||||
const now = +dayjs();
|
||||
const groupingId = v4();
|
||||
|
||||
const externalIds = seq.collect(artist.identifiers, (id) => {
|
||||
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
|
||||
return {
|
||||
type: 'single',
|
||||
externalKey: id.id,
|
||||
groupUuid: groupingId,
|
||||
sourceType: id.type,
|
||||
uuid: v4(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
} satisfies NewSingleOrMultiProgramGroupingExternalId;
|
||||
} else if (isValidMultiExternalIdType(id.type)) {
|
||||
return {
|
||||
type: 'multi',
|
||||
externalKey: id.id,
|
||||
groupUuid: groupingId,
|
||||
sourceType: id.type,
|
||||
uuid: v4(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
externalSourceId: mediaSource.name, // legacy
|
||||
mediaSourceId: mediaSource.uuid, // new
|
||||
} satisfies NewSingleOrMultiProgramGroupingExternalId;
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
|
||||
return {
|
||||
uuid: groupingId,
|
||||
type: ProgramGroupingType.Artist,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
// index: show.index,
|
||||
title: artist.title,
|
||||
summary: artist.summary,
|
||||
year: null,
|
||||
libraryId: mediaSourceLibrary.uuid,
|
||||
canonicalId: artist.canonicalId,
|
||||
externalIds,
|
||||
} satisfies NewMusicArtist;
|
||||
}
|
||||
|
||||
mintSeason(
|
||||
mediaSource: MediaSource,
|
||||
mediaSourceLibrary: MediaSourceLibrary,
|
||||
season: MediaSourceSeason,
|
||||
): NewTvSeason {
|
||||
const now = +dayjs();
|
||||
const groupingId = v4();
|
||||
|
||||
const externalIds = seq.collect(season.identifiers, (id) => {
|
||||
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
|
||||
return {
|
||||
type: 'single',
|
||||
externalKey: id.id,
|
||||
groupUuid: groupingId,
|
||||
sourceType: id.type,
|
||||
uuid: v4(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
} satisfies NewSingleOrMultiProgramGroupingExternalId;
|
||||
} else if (isValidMultiExternalIdType(id.type)) {
|
||||
return {
|
||||
type: 'multi',
|
||||
externalKey: id.id,
|
||||
groupUuid: groupingId,
|
||||
sourceType: id.type,
|
||||
uuid: v4(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
externalSourceId: mediaSource.name, // legacy
|
||||
mediaSourceId: mediaSource.uuid, // new
|
||||
} satisfies NewSingleOrMultiProgramGroupingExternalId;
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
|
||||
return {
|
||||
uuid: groupingId,
|
||||
type: ProgramGroupingType.Season,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
index: season.index,
|
||||
title: season.title,
|
||||
summary: season.summary,
|
||||
libraryId: mediaSourceLibrary.uuid,
|
||||
canonicalId: season.canonicalId,
|
||||
externalIds,
|
||||
} satisfies NewProgramGroupingWithExternalIds;
|
||||
}
|
||||
|
||||
mintMusicAlbum(
|
||||
mediaSource: MediaSource,
|
||||
mediaSourceLibrary: MediaSourceLibrary,
|
||||
album: MediaSourceMusicAlbum,
|
||||
): NewMusicAlbum {
|
||||
const now = +dayjs();
|
||||
const groupingId = v4();
|
||||
|
||||
const externalIds = seq.collect(album.identifiers, (id) => {
|
||||
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
|
||||
return {
|
||||
type: 'single',
|
||||
externalKey: id.id,
|
||||
groupUuid: groupingId,
|
||||
sourceType: id.type,
|
||||
uuid: v4(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
} satisfies NewSingleOrMultiProgramGroupingExternalId;
|
||||
} else if (isValidMultiExternalIdType(id.type)) {
|
||||
return {
|
||||
type: 'multi',
|
||||
externalKey: id.id,
|
||||
groupUuid: groupingId,
|
||||
sourceType: id.type,
|
||||
uuid: v4(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
externalSourceId: mediaSource.name, // legacy
|
||||
mediaSourceId: mediaSource.uuid, // new
|
||||
} satisfies NewSingleOrMultiProgramGroupingExternalId;
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
|
||||
return {
|
||||
uuid: groupingId,
|
||||
type: ProgramGroupingType.Album,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
index: album.index,
|
||||
title: album.title,
|
||||
summary: album.summary,
|
||||
libraryId: mediaSourceLibrary.uuid,
|
||||
canonicalId: album.canonicalId,
|
||||
externalIds,
|
||||
} satisfies NewMusicAlbum;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,33 +5,89 @@ import type {
|
||||
NewSingleOrMultiExternalId,
|
||||
} from '@/db/schema/ProgramExternalId.js';
|
||||
import { seq } from '@tunarr/shared/util';
|
||||
import type { ContentProgram } from '@tunarr/types';
|
||||
import { tag, type ContentProgram } from '@tunarr/types';
|
||||
import type { JellyfinItem } from '@tunarr/types/jellyfin';
|
||||
import type {
|
||||
PlexMovie as ApiPlexMovie,
|
||||
PlexEpisode,
|
||||
PlexMovie,
|
||||
PlexMedia,
|
||||
PlexMusicTrack,
|
||||
PlexTerminalMedia,
|
||||
} from '@tunarr/types/plex';
|
||||
import type { ContentProgramOriginalProgram } from '@tunarr/types/schemas';
|
||||
import {
|
||||
isValidMultiExternalIdType,
|
||||
isValidSingleExternalIdType,
|
||||
type ContentProgramOriginalProgram,
|
||||
} from '@tunarr/types/schemas';
|
||||
import dayjs from 'dayjs';
|
||||
import { find, first, isError } from 'lodash-es';
|
||||
import { inject, injectable } from 'inversify';
|
||||
import { find, first, head, isError } from 'lodash-es';
|
||||
import { P, match } from 'ts-pattern';
|
||||
import { v4 } from 'uuid';
|
||||
import type { NewProgramDao as NewRawProgram } from '../schema/Program.ts';
|
||||
import { Canonicalizer } from '../../services/Canonicalizer.ts';
|
||||
import {
|
||||
MediaSourceEpisode,
|
||||
MediaSourceMovie,
|
||||
MediaSourceMusicTrack,
|
||||
} from '../../types/Media.ts';
|
||||
import { KEYS } from '../../types/inject.ts';
|
||||
import { Maybe } from '../../types/util.ts';
|
||||
import { parsePlexGuid } from '../../util/externalIds.ts';
|
||||
import { isNonEmptyString } from '../../util/index.ts';
|
||||
import { Logger } from '../../util/logging/LoggerFactory.ts';
|
||||
import { MediaSource, MediaSourceLibrary } from '../schema/MediaSource.ts';
|
||||
import type {
|
||||
NewProgramDao,
|
||||
NewProgramDao as NewRawProgram,
|
||||
} from '../schema/Program.ts';
|
||||
import { ProgramType } from '../schema/Program.ts';
|
||||
import { MediaSourceId, MediaSourceName } from '../schema/base.ts';
|
||||
import {
|
||||
NewEpisodeProgram,
|
||||
NewMovieProgram,
|
||||
NewMusicTrack,
|
||||
NewProgramWithExternalIds,
|
||||
} from '../schema/derivedTypes.js';
|
||||
|
||||
// type MovieMintRequest =
|
||||
// | { sourceType: 'plex'; program: PlexMovie }
|
||||
// | { sourceType: 'jellyfin'; program: SpecificJellyfinType<'Movie'> }
|
||||
// | { sourceType: 'emby'; program: SpecificEmbyType<'Movie'> };
|
||||
|
||||
// type EpisodeMintRequest =
|
||||
// | { sourceType: 'plex'; program: PlexEpisode }
|
||||
// | { sourceType: 'jellyfin'; program: SpecificJellyfinType<'Episode'> }
|
||||
// | { sourceType: 'emby'; program: SpecificEmbyType<'Episode'> };
|
||||
|
||||
/**
|
||||
* Generates Program DB entities for Plex media
|
||||
*/
|
||||
class ProgramDaoMinter {
|
||||
contentProgramDtoToDao(program: ContentProgram): NewRawProgram {
|
||||
@injectable()
|
||||
export class ProgramDaoMinter {
|
||||
constructor(
|
||||
@inject(KEYS.Logger) private logger: Logger,
|
||||
@inject(KEYS.PlexCanonicalizer)
|
||||
private plexProgramCanonicalizer: Canonicalizer<PlexMedia>,
|
||||
@inject(KEYS.JellyfinCanonicalizer)
|
||||
private jellyfinCanonicalizer: Canonicalizer<JellyfinItem>,
|
||||
) {}
|
||||
|
||||
contentProgramDtoToDao(program: ContentProgram): Maybe<NewRawProgram> {
|
||||
if (!isNonEmptyString(program.canonicalId)) {
|
||||
this.logger.warn('Program missing canonical ID on upsert: %O', program);
|
||||
return;
|
||||
} else if (!isNonEmptyString(program.libraryId)) {
|
||||
this.logger.warn('Program missing library ID on upsert: %O', program);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = +dayjs();
|
||||
return {
|
||||
uuid: v4(),
|
||||
sourceType: program.externalSourceType,
|
||||
// Deprecated
|
||||
externalSourceId: program.externalSourceName,
|
||||
mediaSourceId: program.externalSourceId,
|
||||
externalSourceId: tag(program.externalSourceName),
|
||||
mediaSourceId: tag(program.externalSourceId),
|
||||
externalKey: program.externalKey,
|
||||
originalAirDate: program.date ?? null,
|
||||
duration: program.duration,
|
||||
@@ -50,30 +106,40 @@ class ProgramDaoMinter {
|
||||
grandparentExternalKey: program.grandparent?.externalKey,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
canonicalId: program.canonicalId,
|
||||
libraryId: program.libraryId,
|
||||
};
|
||||
}
|
||||
|
||||
mint(
|
||||
serverName: string,
|
||||
serverId: string,
|
||||
mediaSource: MediaSource,
|
||||
library: MediaSourceLibrary,
|
||||
program: ContentProgramOriginalProgram,
|
||||
): NewRawProgram {
|
||||
): NewProgramWithExternalIds {
|
||||
const ret = match(program)
|
||||
.with(
|
||||
{ sourceType: 'plex', program: { type: 'movie' } },
|
||||
({ program: movie }) =>
|
||||
this.mintProgramForPlexMovie(serverName, serverId, movie),
|
||||
)
|
||||
.with(
|
||||
{ sourceType: 'plex', program: { type: 'episode' } },
|
||||
({ program: episode }) =>
|
||||
this.mintProgramForPlexEpisode(serverName, serverId, episode),
|
||||
)
|
||||
.with(
|
||||
{ sourceType: 'plex', program: { type: 'track' } },
|
||||
({ program: track }) =>
|
||||
this.mintProgramForPlexTrack(serverName, serverId, track),
|
||||
)
|
||||
.with({ sourceType: 'plex' }, ({ program }) => {
|
||||
const dao = match(program)
|
||||
.with({ type: 'movie' }, (movie) =>
|
||||
this.mintProgramForPlexMovie(mediaSource, library, movie),
|
||||
)
|
||||
.with({ type: 'episode' }, (ep) =>
|
||||
this.mintProgramForPlexEpisode(mediaSource, library, ep),
|
||||
)
|
||||
.with({ type: 'track' }, (track) =>
|
||||
this.mintProgramForPlexTrack(mediaSource, library, track),
|
||||
)
|
||||
.exhaustive();
|
||||
const externalIds = this.mintPlexExternalIdsFromApiItem(
|
||||
mediaSource.name,
|
||||
mediaSource.uuid,
|
||||
dao,
|
||||
program,
|
||||
);
|
||||
return {
|
||||
...dao,
|
||||
externalIds,
|
||||
} satisfies NewProgramWithExternalIds;
|
||||
})
|
||||
.with(
|
||||
{
|
||||
sourceType: 'jellyfin',
|
||||
@@ -88,8 +154,7 @@ class ProgramDaoMinter {
|
||||
),
|
||||
},
|
||||
},
|
||||
({ program }) =>
|
||||
this.mintProgramForJellyfinItem(serverName, serverId, program),
|
||||
({ program }) => this.mintProgramForJellyfinItem(mediaSource, program),
|
||||
)
|
||||
.otherwise(() => new Error('Unexpected program type'));
|
||||
if (isError(ret)) {
|
||||
@@ -98,11 +163,219 @@ class ProgramDaoMinter {
|
||||
return ret;
|
||||
}
|
||||
|
||||
mintMovie(
|
||||
mediaSource: MediaSource,
|
||||
mediaLibrary: MediaSourceLibrary,
|
||||
movie: MediaSourceMovie,
|
||||
): NewMovieProgram {
|
||||
const programId = v4();
|
||||
const now = +dayjs();
|
||||
|
||||
return {
|
||||
uuid: programId,
|
||||
sourceType: movie.sourceType,
|
||||
externalKey: movie.externalKey,
|
||||
originalAirDate: dayjs(movie.releaseDate)?.format(),
|
||||
duration: movie.duration,
|
||||
// filePath: file?.file ?? null,
|
||||
externalSourceId: mediaSource.name,
|
||||
mediaSourceId: mediaSource.uuid,
|
||||
libraryId: mediaLibrary.uuid,
|
||||
// plexRatingKey: plexMovie.ratingKey,
|
||||
// plexFilePath: file?.key ?? null,
|
||||
rating: movie.rating,
|
||||
summary: movie.summary,
|
||||
title: movie.title,
|
||||
type: ProgramType.Movie,
|
||||
year: movie.year,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
canonicalId: movie.canonicalId,
|
||||
externalIds: seq.collect(movie.identifiers, (id) => {
|
||||
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
|
||||
return {
|
||||
type: 'single',
|
||||
externalKey: id.id,
|
||||
programUuid: programId,
|
||||
sourceType: id.type,
|
||||
uuid: v4(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
} satisfies NewSingleOrMultiExternalId;
|
||||
} else if (isValidMultiExternalIdType(id.type)) {
|
||||
const isMediaSourceId = id.type === mediaSource.type;
|
||||
// This stinks
|
||||
const location = isMediaSourceId
|
||||
? find(movie.mediaItem?.locations, { sourceType: mediaSource.type })
|
||||
: null;
|
||||
return {
|
||||
type: 'multi',
|
||||
externalKey: id.id,
|
||||
programUuid: programId,
|
||||
sourceType: id.type,
|
||||
uuid: v4(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
externalSourceId: mediaSource.name, // legacy
|
||||
mediaSourceId: mediaSource.uuid, // new
|
||||
// TODO
|
||||
directFilePath: location?.path,
|
||||
externalFilePath:
|
||||
location?.type === 'remote' ? location.externalKey : null,
|
||||
// externalFilePath
|
||||
} satisfies NewSingleOrMultiExternalId;
|
||||
}
|
||||
|
||||
return;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
mintEpisode(
|
||||
mediaSource: MediaSource,
|
||||
mediaLibrary: MediaSourceLibrary,
|
||||
episode: MediaSourceEpisode,
|
||||
): NewEpisodeProgram {
|
||||
const programId = v4();
|
||||
const now = +dayjs();
|
||||
|
||||
return {
|
||||
uuid: programId,
|
||||
sourceType: episode.sourceType,
|
||||
externalKey: episode.externalKey,
|
||||
originalAirDate: dayjs(episode.releaseDate).format(),
|
||||
duration: episode.duration,
|
||||
// filePath: file?.file ?? null,
|
||||
externalSourceId: mediaSource.name,
|
||||
mediaSourceId: mediaSource.uuid,
|
||||
libraryId: mediaLibrary.uuid,
|
||||
// plexRatingKey: plexMovie.ratingKey,
|
||||
// plexFilePath: file?.key ?? null,
|
||||
rating: null,
|
||||
summary: episode.summary,
|
||||
title: episode.title,
|
||||
type: ProgramType.Episode,
|
||||
year: episode.year,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
canonicalId: episode.canonicalId,
|
||||
externalIds: seq.collect(episode.identifiers, (id) => {
|
||||
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
|
||||
return {
|
||||
type: 'single',
|
||||
externalKey: id.id,
|
||||
programUuid: programId,
|
||||
sourceType: id.type,
|
||||
uuid: v4(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
} satisfies NewSingleOrMultiExternalId;
|
||||
} else if (isValidMultiExternalIdType(id.type)) {
|
||||
const isMediaSourceId = id.type === mediaSource.type;
|
||||
// This stinks
|
||||
const location = isMediaSourceId
|
||||
? find(episode.mediaItem?.locations, {
|
||||
sourceType: mediaSource.type,
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
type: 'multi',
|
||||
externalKey: id.id,
|
||||
programUuid: programId,
|
||||
sourceType: id.type,
|
||||
uuid: v4(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
externalSourceId: mediaSource.name, // legacy
|
||||
mediaSourceId: mediaSource.uuid, // new
|
||||
// TODO
|
||||
directFilePath: location?.path,
|
||||
externalFilePath:
|
||||
location?.type === 'remote' ? location.externalKey : null,
|
||||
// externalFilePath
|
||||
} satisfies NewSingleOrMultiExternalId;
|
||||
}
|
||||
|
||||
return;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
mintMusicTrack(
|
||||
mediaSource: MediaSource,
|
||||
mediaLibrary: MediaSourceLibrary,
|
||||
track: MediaSourceMusicTrack,
|
||||
): NewMusicTrack {
|
||||
const programId = v4();
|
||||
const now = +dayjs();
|
||||
|
||||
return {
|
||||
uuid: programId,
|
||||
sourceType: track.sourceType,
|
||||
externalKey: track.externalKey,
|
||||
originalAirDate: dayjs(track.releaseDate)?.format(),
|
||||
duration: track.duration,
|
||||
// filePath: file?.file ?? null,
|
||||
externalSourceId: mediaSource.name,
|
||||
mediaSourceId: mediaSource.uuid,
|
||||
libraryId: mediaLibrary.uuid,
|
||||
// plexRatingKey: plexMovie.ratingKey,
|
||||
// plexFilePath: file?.key ?? null,
|
||||
rating: null,
|
||||
summary: null,
|
||||
title: track.title,
|
||||
type: ProgramType.Track,
|
||||
year: track.year,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
canonicalId: track.canonicalId,
|
||||
externalIds: seq.collect(track.identifiers, (id) => {
|
||||
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
|
||||
return {
|
||||
type: 'single',
|
||||
externalKey: id.id,
|
||||
programUuid: programId,
|
||||
sourceType: id.type,
|
||||
uuid: v4(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
} satisfies NewSingleOrMultiExternalId;
|
||||
} else if (isValidMultiExternalIdType(id.type)) {
|
||||
const isMediaSourceId = id.type === mediaSource.type;
|
||||
// This stinks
|
||||
const location = isMediaSourceId
|
||||
? find(track.mediaItem?.locations, {
|
||||
sourceType: mediaSource.type,
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
type: 'multi',
|
||||
externalKey: id.id,
|
||||
programUuid: programId,
|
||||
sourceType: id.type,
|
||||
uuid: v4(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
externalSourceId: mediaSource.name, // legacy
|
||||
mediaSourceId: mediaSource.uuid, // new
|
||||
// TODO
|
||||
directFilePath: location?.path,
|
||||
externalFilePath:
|
||||
location?.type === 'remote' ? location.externalKey : null,
|
||||
// externalFilePath
|
||||
} satisfies NewSingleOrMultiExternalId;
|
||||
}
|
||||
|
||||
return;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private mintProgramForPlexMovie(
|
||||
serverName: string,
|
||||
serverId: string,
|
||||
plexMovie: PlexMovie,
|
||||
): NewRawProgram {
|
||||
mediaSource: MediaSource,
|
||||
mediaLibrary: MediaSourceLibrary,
|
||||
plexMovie: ApiPlexMovie,
|
||||
): NewProgramDao {
|
||||
const file = first(first(plexMovie.Media)?.Part ?? []);
|
||||
return {
|
||||
uuid: v4(),
|
||||
@@ -110,8 +383,9 @@ class ProgramDaoMinter {
|
||||
originalAirDate: plexMovie.originallyAvailableAt ?? null,
|
||||
duration: plexMovie.duration ?? 0,
|
||||
filePath: file?.file ?? null,
|
||||
externalSourceId: serverName,
|
||||
mediaSourceId: serverId,
|
||||
externalSourceId: mediaSource.name,
|
||||
mediaSourceId: mediaSource.uuid,
|
||||
libraryId: mediaLibrary.uuid,
|
||||
externalKey: plexMovie.ratingKey,
|
||||
plexRatingKey: plexMovie.ratingKey,
|
||||
plexFilePath: file?.key ?? null,
|
||||
@@ -122,25 +396,26 @@ class ProgramDaoMinter {
|
||||
year: plexMovie.year ?? null,
|
||||
createdAt: +dayjs(),
|
||||
updatedAt: +dayjs(),
|
||||
canonicalId: this.plexProgramCanonicalizer.getCanonicalId(plexMovie),
|
||||
};
|
||||
}
|
||||
|
||||
private mintProgramForJellyfinItem(
|
||||
serverName: string,
|
||||
serverId: string,
|
||||
mediaSource: MediaSource,
|
||||
item: Omit<JellyfinItem, 'Type'> & {
|
||||
Type: 'Movie' | 'Episode' | 'Audio' | 'Video' | 'MusicVideo' | 'Trailer';
|
||||
},
|
||||
): NewRawProgram {
|
||||
return {
|
||||
uuid: v4(),
|
||||
): NewProgramWithExternalIds {
|
||||
const id = v4();
|
||||
const dao: NewProgramDao = {
|
||||
uuid: id,
|
||||
createdAt: +dayjs(),
|
||||
updatedAt: +dayjs(),
|
||||
sourceType: ProgramSourceType.JELLYFIN,
|
||||
originalAirDate: item.PremiereDate,
|
||||
duration: (item.RunTimeTicks ?? 0) / 10_000,
|
||||
externalSourceId: serverName,
|
||||
mediaSourceId: serverId,
|
||||
duration: Math.ceil((item.RunTimeTicks ?? 0) / 10_000),
|
||||
externalSourceId: mediaSource.name,
|
||||
mediaSourceId: mediaSource.uuid,
|
||||
externalKey: item.Id,
|
||||
rating: item.OfficialRating,
|
||||
summary: item.Overview,
|
||||
@@ -161,14 +436,27 @@ class ProgramDaoMinter {
|
||||
grandparentExternalKey:
|
||||
item.SeriesId ??
|
||||
find(item.AlbumArtists, { Name: item.AlbumArtist })?.Id,
|
||||
canonicalId: this.jellyfinCanonicalizer.getCanonicalId(item),
|
||||
};
|
||||
|
||||
const externalIds = this.mintAllJellyfinExternalIdForApiItem(
|
||||
mediaSource.name,
|
||||
mediaSource.uuid,
|
||||
dao,
|
||||
item,
|
||||
);
|
||||
|
||||
return {
|
||||
...dao,
|
||||
externalIds,
|
||||
} satisfies NewProgramWithExternalIds;
|
||||
}
|
||||
|
||||
private mintProgramForPlexEpisode(
|
||||
serverName: string,
|
||||
serverId: string,
|
||||
mediaSource: MediaSource,
|
||||
mediaLibrary: MediaSourceLibrary,
|
||||
plexEpisode: PlexEpisode,
|
||||
): NewRawProgram {
|
||||
): NewProgramDao {
|
||||
const file = first(first(plexEpisode.Media)?.Part ?? []);
|
||||
return {
|
||||
uuid: v4(),
|
||||
@@ -178,8 +466,9 @@ class ProgramDaoMinter {
|
||||
originalAirDate: plexEpisode.originallyAvailableAt,
|
||||
duration: plexEpisode.duration ?? 0,
|
||||
filePath: file?.file,
|
||||
externalSourceId: serverName,
|
||||
mediaSourceId: serverId,
|
||||
externalSourceId: mediaSource.name,
|
||||
mediaSourceId: mediaSource.uuid,
|
||||
libraryId: mediaLibrary.uuid,
|
||||
externalKey: plexEpisode.ratingKey,
|
||||
plexRatingKey: plexEpisode.ratingKey,
|
||||
plexFilePath: file?.key,
|
||||
@@ -194,12 +483,13 @@ class ProgramDaoMinter {
|
||||
episode: plexEpisode.index,
|
||||
parentExternalKey: plexEpisode.parentRatingKey,
|
||||
grandparentExternalKey: plexEpisode.grandparentRatingKey,
|
||||
canonicalId: this.plexProgramCanonicalizer.getCanonicalId(plexEpisode),
|
||||
};
|
||||
}
|
||||
|
||||
private mintProgramForPlexTrack(
|
||||
serverName: string,
|
||||
serverId: string,
|
||||
mediaSource: MediaSource,
|
||||
mediaLibrary: MediaSourceLibrary,
|
||||
plexTrack: PlexMusicTrack,
|
||||
): NewRawProgram {
|
||||
const file = first(first(plexTrack.Media)?.Part ?? []);
|
||||
@@ -210,8 +500,9 @@ class ProgramDaoMinter {
|
||||
sourceType: ProgramSourceType.PLEX,
|
||||
duration: plexTrack.duration ?? 0,
|
||||
filePath: file?.file,
|
||||
externalSourceId: serverName,
|
||||
mediaSourceId: serverId,
|
||||
externalSourceId: mediaSource.name,
|
||||
mediaSourceId: mediaSource.uuid,
|
||||
libraryId: mediaLibrary.uuid,
|
||||
externalKey: plexTrack.ratingKey,
|
||||
plexRatingKey: plexTrack.ratingKey,
|
||||
plexFilePath: file?.key,
|
||||
@@ -227,12 +518,13 @@ class ProgramDaoMinter {
|
||||
grandparentExternalKey: plexTrack.grandparentRatingKey,
|
||||
albumName: plexTrack.parentTitle,
|
||||
artistName: plexTrack.grandparentTitle,
|
||||
canonicalId: this.plexProgramCanonicalizer.getCanonicalId(plexTrack),
|
||||
};
|
||||
}
|
||||
|
||||
mintExternalIds(
|
||||
serverName: string,
|
||||
serverId: string,
|
||||
serverName: MediaSourceName,
|
||||
serverId: MediaSourceId,
|
||||
programId: string,
|
||||
program: ContentProgram,
|
||||
): NewSingleOrMultiExternalId[] {
|
||||
@@ -250,8 +542,8 @@ class ProgramDaoMinter {
|
||||
}
|
||||
|
||||
mintPlexExternalIds(
|
||||
serverName: string,
|
||||
serverId: string,
|
||||
serverName: MediaSourceName,
|
||||
serverId: MediaSourceId,
|
||||
programId: string,
|
||||
program: ContentProgram,
|
||||
): NewSingleOrMultiExternalId[] {
|
||||
@@ -310,25 +602,66 @@ class ProgramDaoMinter {
|
||||
return ids;
|
||||
}
|
||||
|
||||
mintJellyfinExternalIdForApiItem(
|
||||
serverName: string,
|
||||
programId: string,
|
||||
media: JellyfinItem,
|
||||
) {
|
||||
return {
|
||||
uuid: v4(),
|
||||
createdAt: +dayjs(),
|
||||
updatedAt: +dayjs(),
|
||||
externalKey: media.Id,
|
||||
sourceType: ProgramExternalIdType.JELLYFIN,
|
||||
programUuid: programId,
|
||||
externalSourceId: serverName,
|
||||
} satisfies NewProgramExternalId;
|
||||
mintPlexExternalIdsFromApiItem(
|
||||
serverName: MediaSourceName,
|
||||
serverId: MediaSourceId,
|
||||
program: NewProgramDao,
|
||||
plexEntity: PlexTerminalMedia,
|
||||
): NewSingleOrMultiExternalId[] {
|
||||
const now = +dayjs();
|
||||
const file = first(first(plexEntity.Media)?.Part ?? []);
|
||||
|
||||
const ids: NewSingleOrMultiExternalId[] = [
|
||||
{
|
||||
type: 'multi',
|
||||
uuid: v4(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
externalKey: program.externalKey,
|
||||
sourceType: ProgramExternalIdType.PLEX,
|
||||
programUuid: program.uuid,
|
||||
externalSourceId: serverName,
|
||||
mediaSourceId: serverId,
|
||||
externalFilePath: file?.key,
|
||||
directFilePath: file?.file,
|
||||
} satisfies NewSingleOrMultiExternalId,
|
||||
];
|
||||
|
||||
if (plexEntity.guid) {
|
||||
ids.push({
|
||||
type: 'single',
|
||||
uuid: v4(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
externalKey: plexEntity.guid,
|
||||
sourceType: ProgramExternalIdType.PLEX_GUID,
|
||||
programUuid: program.uuid,
|
||||
});
|
||||
}
|
||||
|
||||
ids.push(
|
||||
...seq
|
||||
.collect(plexEntity.Guid, ({ id }) => parsePlexGuid(id))
|
||||
.map(
|
||||
(eid) =>
|
||||
({
|
||||
type: 'single',
|
||||
uuid: v4(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
externalKey: eid.externalKey,
|
||||
sourceType: eid.sourceType,
|
||||
programUuid: program.uuid,
|
||||
}) satisfies NewSingleOrMultiExternalId,
|
||||
),
|
||||
);
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
mintJellyfinExternalIds(
|
||||
serverName: string,
|
||||
serverId: string,
|
||||
serverName: MediaSourceName,
|
||||
serverId: MediaSourceId,
|
||||
programId: string,
|
||||
program: ContentProgram,
|
||||
) {
|
||||
@@ -373,9 +706,75 @@ class ProgramDaoMinter {
|
||||
return ids;
|
||||
}
|
||||
|
||||
mintJellyfinExternalIdForApiItem(
|
||||
serverName: MediaSourceName,
|
||||
programId: string,
|
||||
media: JellyfinItem,
|
||||
) {
|
||||
return {
|
||||
uuid: v4(),
|
||||
createdAt: +dayjs(),
|
||||
updatedAt: +dayjs(),
|
||||
externalKey: media.Id,
|
||||
sourceType: ProgramExternalIdType.JELLYFIN,
|
||||
programUuid: programId,
|
||||
externalSourceId: serverName,
|
||||
} satisfies NewProgramExternalId;
|
||||
}
|
||||
|
||||
mintAllJellyfinExternalIdForApiItem(
|
||||
serverName: MediaSourceName,
|
||||
serverId: MediaSourceId,
|
||||
program: NewProgramDao,
|
||||
entity: JellyfinItem,
|
||||
) {
|
||||
const now = +dayjs();
|
||||
const ids: NewSingleOrMultiExternalId[] = [
|
||||
{
|
||||
type: 'multi',
|
||||
uuid: v4(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
externalKey: program.externalKey,
|
||||
sourceType: ProgramExternalIdType.JELLYFIN,
|
||||
programUuid: program.uuid,
|
||||
externalSourceId: serverName,
|
||||
mediaSourceId: serverId,
|
||||
directFilePath: head(entity.MediaSources)?.Path,
|
||||
},
|
||||
];
|
||||
|
||||
ids.push(
|
||||
...seq.collectMapValues(entity.ProviderIds, (value, source) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (source) {
|
||||
case 'tmdb':
|
||||
case 'imdb':
|
||||
case 'tvdb':
|
||||
return {
|
||||
type: 'single',
|
||||
uuid: v4(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
externalKey: value,
|
||||
sourceType: source,
|
||||
programUuid: program.uuid,
|
||||
} satisfies NewSingleOrMultiExternalId;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
mintEmbyExternalIds(
|
||||
serverName: string,
|
||||
serverId: string,
|
||||
serverName: MediaSourceName,
|
||||
serverId: MediaSourceId,
|
||||
programId: string,
|
||||
program: ContentProgram,
|
||||
) {
|
||||
@@ -420,9 +819,3 @@ class ProgramDaoMinter {
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
|
||||
export class ProgramMinterFactory {
|
||||
static create(): ProgramDaoMinter {
|
||||
return new ProgramDaoMinter();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
// active streaming session
|
||||
|
||||
import { MediaSourceType } from '@/db/schema/MediaSource.js';
|
||||
import { tag } from '@tunarr/types';
|
||||
import { ContentProgramTypeSchema } from '@tunarr/types/schemas';
|
||||
import type { StrictOmit } from 'ts-essentials';
|
||||
import { z } from 'zod/v4';
|
||||
import type { EmbyT, JellyfinT } from '../../types/internal.ts';
|
||||
import type { MediaSourceId } from '../schema/base.ts';
|
||||
import type { ProgramType } from '../schema/Program.ts';
|
||||
|
||||
const baseStreamLineupItemSchema = z.object({
|
||||
@@ -125,7 +127,7 @@ const BaseContentBackedStreamLineupItemSchema =
|
||||
programId: z.uuid(),
|
||||
// These are taken from the Program DB entity
|
||||
plexFilePath: z.string().optional(),
|
||||
externalSourceId: z.string(),
|
||||
externalSourceId: z.string().transform((s) => tag<MediaSourceId>(s)),
|
||||
filePath: z.string().optional(),
|
||||
externalKey: z.string(),
|
||||
programType: ContentProgramTypeSchema,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
|
||||
import type { ProgramSourceType } from '@/db/custom_types/ProgramSourceType.js';
|
||||
import type { ProgramDao } from '@/db/schema/Program.js';
|
||||
import type { ProgramDao, ProgramType } from '@/db/schema/Program.js';
|
||||
import type {
|
||||
MinimalProgramExternalId,
|
||||
NewProgramExternalId,
|
||||
@@ -10,15 +10,19 @@ import type {
|
||||
import type { ProgramExternalIdSourceType } from '@/db/schema/base.js';
|
||||
import type {
|
||||
MusicAlbumWithExternalIds,
|
||||
NewProgramGroupingWithExternalIds,
|
||||
NewProgramWithExternalIds,
|
||||
ProgramGroupingWithExternalIds,
|
||||
ProgramWithExternalIds,
|
||||
ProgramWithRelations,
|
||||
TvSeasonWithExternalIds,
|
||||
} from '@/db/schema/derivedTypes.js';
|
||||
import type { Maybe, PagedResult } from '@/types/util.js';
|
||||
import type { ChannelProgram, ContentProgram } from '@tunarr/types';
|
||||
import type { MarkOptional } from 'ts-essentials';
|
||||
import type { MarkNonNullable, Maybe, PagedResult } from '@/types/util.js';
|
||||
import type { ChannelProgram } from '@tunarr/types';
|
||||
import type { Dictionary, MarkOptional } from 'ts-essentials';
|
||||
import type { MediaSourceType } from '../schema/MediaSource.ts';
|
||||
import type { ProgramGroupingType } from '../schema/ProgramGrouping.ts';
|
||||
import type { MediaSourceId } from '../schema/base.ts';
|
||||
import type { PageParams } from './IChannelDB.ts';
|
||||
|
||||
export interface IProgramDB {
|
||||
@@ -35,13 +39,21 @@ export interface IProgramDB {
|
||||
|
||||
getProgramsByIds(
|
||||
ids: string[],
|
||||
batchSize: number,
|
||||
batchSize?: number,
|
||||
): Promise<ProgramWithRelations[]>;
|
||||
|
||||
getProgramGrouping(
|
||||
id: string,
|
||||
): Promise<Maybe<ProgramGroupingWithExternalIds>>;
|
||||
|
||||
getProgramGroupings(
|
||||
ids: string[],
|
||||
): Promise<Record<string, ProgramGroupingWithExternalIds>>;
|
||||
|
||||
getProgramGroupingByExternalId(
|
||||
eid: ProgramGroupingExternalIdLookup,
|
||||
): Promise<Maybe<ProgramGroupingWithExternalIds>>;
|
||||
|
||||
getProgramParent(
|
||||
programId: string,
|
||||
): Promise<Maybe<ProgramGroupingWithExternalIds>>;
|
||||
@@ -73,11 +85,21 @@ export interface IProgramDB {
|
||||
sourceType: ProgramSourceType;
|
||||
externalSourceId: string;
|
||||
externalKey: string;
|
||||
}): Promise<Maybe<ContentProgram>>;
|
||||
}): Promise<Maybe<ProgramWithRelations>>;
|
||||
|
||||
lookupByExternalIds(
|
||||
ids: Set<[string, string, string]>,
|
||||
): Promise<Record<string, ContentProgram>>;
|
||||
ids:
|
||||
| Set<[string, MediaSourceId, string]>
|
||||
| Set<readonly [string, MediaSourceId, string]>,
|
||||
chunkSize?: number,
|
||||
): Promise<ProgramWithRelations[]>;
|
||||
|
||||
lookupByMediaSource(
|
||||
sourceType: MediaSourceType,
|
||||
sourceId: MediaSourceId,
|
||||
mediaType?: ProgramType,
|
||||
chunkSize?: number,
|
||||
): Promise<ProgramDao[]>;
|
||||
|
||||
programIdsByExternalIds(
|
||||
ids: Set<[string, string, string]>,
|
||||
@@ -107,7 +129,12 @@ export interface IProgramDB {
|
||||
upsertContentPrograms(
|
||||
programs: ChannelProgram[],
|
||||
programUpsertBatchSize?: number,
|
||||
): Promise<ProgramDao[]>;
|
||||
): Promise<MarkNonNullable<ProgramDao, 'mediaSourceId'>[]>;
|
||||
|
||||
upsertPrograms(
|
||||
programs: NewProgramWithExternalIds[],
|
||||
programUpsertBatchSize?: number,
|
||||
): Promise<ProgramWithExternalIds[]>;
|
||||
|
||||
programIdsByExternalIds(
|
||||
ids: Set<[string, string, string]>,
|
||||
@@ -117,9 +144,81 @@ export interface IProgramDB {
|
||||
upsertProgramExternalIds(
|
||||
externalIds: NewSingleOrMultiExternalId[],
|
||||
chunkSize?: number,
|
||||
): Promise<void>;
|
||||
): Promise<Dictionary<ProgramExternalId[]>>;
|
||||
|
||||
getProgramsForMediaSource(
|
||||
mediaSourceId: string,
|
||||
type?: ProgramType,
|
||||
): Promise<ProgramDao[]>;
|
||||
|
||||
getMediaSourceLibraryPrograms(
|
||||
libraryId: string,
|
||||
): Promise<ProgramWithRelations[]>;
|
||||
|
||||
getProgramCanonicalIdsForMediaSource(
|
||||
mediaSourceLibraryId: string,
|
||||
type: ProgramType,
|
||||
): Promise<Dictionary<ProgramCanonicalIdLookupResult>>;
|
||||
|
||||
getProgramGroupingCanonicalIds(
|
||||
mediaSourceLibraryId: string,
|
||||
type: ProgramGroupingType,
|
||||
sourceType: MediaSourceType,
|
||||
): Promise<Dictionary<ProgramGroupingCanonicalIdLookupResult>>;
|
||||
|
||||
getOrInsertProgramGrouping(
|
||||
dao: NewProgramGroupingWithExternalIds,
|
||||
externalId: ProgramGroupingExternalIdLookup,
|
||||
forceUpdate?: boolean,
|
||||
): Promise<GetOrInsertResult<ProgramGroupingWithExternalIds>>;
|
||||
|
||||
getShowSeasons(showUuid: string): Promise<ProgramGroupingWithExternalIds[]>;
|
||||
|
||||
getArtistAlbums(
|
||||
artistUuid: string,
|
||||
): Promise<ProgramGroupingWithExternalIds[]>;
|
||||
|
||||
getProgramGroupingChildCounts(
|
||||
groupIds: string[],
|
||||
): Promise<Record<string, ProgramGroupingChildCounts>>;
|
||||
|
||||
getProgramGroupingDescendants(
|
||||
groupId: string,
|
||||
groupTypeHint?: ProgramGroupingType,
|
||||
): Promise<ProgramWithExternalIds[]>;
|
||||
}
|
||||
|
||||
export type WithChannelIdFilter<T> = T & {
|
||||
channelId?: string;
|
||||
};
|
||||
|
||||
export type ProgramCanonicalIdLookupResult = {
|
||||
uuid: string;
|
||||
canonicalId: string;
|
||||
libraryId: string;
|
||||
externalKey: string;
|
||||
};
|
||||
|
||||
export type ProgramGroupingCanonicalIdLookupResult = {
|
||||
uuid: string;
|
||||
canonicalId: string;
|
||||
libraryId: string;
|
||||
};
|
||||
|
||||
export type ProgramGroupingExternalIdLookup = {
|
||||
sourceType: ProgramExternalIdSourceType;
|
||||
externalKey: string;
|
||||
externalSourceId: MediaSourceId;
|
||||
};
|
||||
|
||||
export type GetOrInsertResult<Entity> = {
|
||||
wasInserted: boolean;
|
||||
wasUpdated: boolean;
|
||||
entity: Entity;
|
||||
};
|
||||
|
||||
export type ProgramGroupingChildCounts = {
|
||||
type: ProgramGroupingType;
|
||||
childCount?: number;
|
||||
grandchildCount?: number;
|
||||
};
|
||||
|
||||
@@ -10,7 +10,10 @@ import type {
|
||||
SystemSettings,
|
||||
XmlTvSettings,
|
||||
} from '@tunarr/types';
|
||||
import type { BackupSettings } from '@tunarr/types/schemas';
|
||||
import type {
|
||||
BackupSettings,
|
||||
GlobalMediaSourceSettings,
|
||||
} from '@tunarr/types/schemas';
|
||||
import type { DeepReadonly } from 'ts-essentials';
|
||||
import type { TypedEventEmitter } from '../../types/eventEmitter.ts';
|
||||
|
||||
@@ -32,6 +35,8 @@ export interface ISettingsDB extends TypedEventEmitter<SettingsChangeEvents> {
|
||||
|
||||
ffmpegSettings(): ReadableFfmpegSettings;
|
||||
|
||||
globalMediaSourceSettings(): DeepReadonly<GlobalMediaSourceSettings>;
|
||||
|
||||
ffprobePath: string;
|
||||
|
||||
systemSettings(): DeepReadonly<SystemSettings>;
|
||||
@@ -54,6 +59,7 @@ export interface ISettingsDB extends TypedEventEmitter<SettingsChangeEvents> {
|
||||
|
||||
flush(): Promise<void>;
|
||||
}
|
||||
|
||||
export type ReadableFfmpegSettings = DeepReadonly<FfmpegSettings>;
|
||||
export type SettingsChangeEvents = {
|
||||
change(): void;
|
||||
|
||||
@@ -34,7 +34,7 @@ export class SchemaBackedDbAdapter<T extends z.ZodTypeAny>
|
||||
});
|
||||
|
||||
if (data === null && this.defaultValue === null) {
|
||||
this.logger.debug('Unexpected null data at %s; %O', this.path, data);
|
||||
this.logger.debug('Unexpected null data at %s', this.path.toString());
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -55,8 +55,8 @@ export class SchemaBackedDbAdapter<T extends z.ZodTypeAny>
|
||||
}
|
||||
|
||||
this.logger.error(
|
||||
`Error while parsing schema-backed JSON file ${this.path.toString()}. Returning null. This could mean the DB got corrupted somehow`,
|
||||
parseResult.error,
|
||||
`Error while parsing schema-backed JSON file ${this.path.toString()}. Returning null. This could mean the DB got corrupted somehow`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export class SchemaBackedDbAdapter<T extends z.ZodTypeAny>
|
||||
}
|
||||
|
||||
// eslint can't seem to handle this but TS compiler gets it right.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
|
||||
return parseResult.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
import type { Nullable } from '@/types/util.js';
|
||||
import { isProduction } from '@/util/index.js';
|
||||
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
|
||||
@@ -51,8 +50,8 @@ export class SyncSchemaBackedDbAdapter<T extends z.ZodTypeAny>
|
||||
|
||||
if (!parseResult.success) {
|
||||
this.logger.error(
|
||||
`Error while parsing schema-backed JSON file ${this.path.toString()}. Returning null. This could mean the DB got corrupted somehow`,
|
||||
parseResult.error,
|
||||
`Error while parsing schema-backed JSON file ${this.path.toString()}. Returning null. This could mean the DB got corrupted somehow`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -64,8 +63,8 @@ export class SyncSchemaBackedDbAdapter<T extends z.ZodTypeAny>
|
||||
const parseResult = this.schema.safeParse(data);
|
||||
if (!parseResult.success) {
|
||||
this.logger.warn(
|
||||
'Could not verify schema before saving to DB - the given type does not match the expected schema.',
|
||||
parseResult.error,
|
||||
'Could not verify schema before saving to DB - the given type does not match the expected schema.',
|
||||
);
|
||||
throw new Error(
|
||||
'Could not verify schema before saving to DB - the given type does not match the expected schema.',
|
||||
|
||||
@@ -8,6 +8,7 @@ import dayjs from 'dayjs';
|
||||
import {
|
||||
chunk,
|
||||
first,
|
||||
isEmpty,
|
||||
isNil,
|
||||
isUndefined,
|
||||
keys,
|
||||
@@ -21,21 +22,34 @@ import { v4 } from 'uuid';
|
||||
import { type IChannelDB } from '@/db/interfaces/IChannelDB.js';
|
||||
import { KEYS } from '@/types/inject.js';
|
||||
import { booleanToNumber } from '@/util/sqliteUtil.js';
|
||||
import { inject, injectable } from 'inversify';
|
||||
import { retag, tag } from '@tunarr/types';
|
||||
import { inject, injectable, interfaces } from 'inversify';
|
||||
import { Kysely } from 'kysely';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/sqlite';
|
||||
import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory.ts';
|
||||
import { MediaSourceLibraryRefresher } from '../services/MediaSourceLibraryRefresher.ts';
|
||||
import { withLibraries } from './mediaSourceQueryHelpers.ts';
|
||||
import {
|
||||
withProgramChannels,
|
||||
withProgramCustomShows,
|
||||
withProgramFillerShows,
|
||||
} from './programQueryHelpers.ts';
|
||||
import { MediaSourceId, MediaSourceName } from './schema/base.ts';
|
||||
import { DB } from './schema/db.ts';
|
||||
import {
|
||||
EmbyMediaSource,
|
||||
JellyfinMediaSource,
|
||||
MediaSource,
|
||||
MediaSourceType,
|
||||
MediaSourceWithLibraries,
|
||||
PlexMediaSource,
|
||||
} from './schema/derivedTypes.js';
|
||||
import {
|
||||
MediaSource,
|
||||
MediaSourceFields,
|
||||
MediaSourceLibrary,
|
||||
MediaSourceLibraryUpdate,
|
||||
MediaSourceType,
|
||||
MediaSourceUpdate,
|
||||
NewMediaSourceLibrary,
|
||||
} from './schema/MediaSource.ts';
|
||||
|
||||
type Report = {
|
||||
@@ -59,68 +73,79 @@ export class MediaSourceDB {
|
||||
@inject(KEYS.MediaSourceApiFactory)
|
||||
private mediaSourceApiFactory: () => MediaSourceApiFactory,
|
||||
@inject(KEYS.Database) private db: Kysely<DB>,
|
||||
@inject(KEYS.MediaSourceLibraryRefresher)
|
||||
private mediaSourceLibraryRefresher: interfaces.AutoFactory<MediaSourceLibraryRefresher>,
|
||||
) {}
|
||||
|
||||
async getAll(): Promise<MediaSource[]> {
|
||||
return this.db.selectFrom('mediaSource').selectAll().execute();
|
||||
}
|
||||
|
||||
async getById(id: string) {
|
||||
async getAll(): Promise<MediaSourceWithLibraries[]> {
|
||||
return this.db
|
||||
.selectFrom('mediaSource')
|
||||
.select(withLibraries)
|
||||
.selectAll()
|
||||
.execute();
|
||||
}
|
||||
|
||||
async getById(id: MediaSourceId): Promise<Maybe<MediaSourceWithLibraries>> {
|
||||
return this.db
|
||||
.selectFrom('mediaSource')
|
||||
.select(withLibraries)
|
||||
.selectAll()
|
||||
.where('mediaSource.uuid', '=', id)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async getByName(name: string) {
|
||||
return this.db
|
||||
.selectFrom('mediaSource')
|
||||
.selectAll()
|
||||
.where('mediaSource.name', '=', name)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async getByIdOrName(id: string) {
|
||||
return this.db
|
||||
.selectFrom('mediaSource')
|
||||
.selectAll()
|
||||
.where((eb) => eb.or([eb('uuid', '=', id), eb('name', '=', id)]))
|
||||
.executeTakeFirst();
|
||||
async getLibrary(id: string) {
|
||||
return (
|
||||
this.db
|
||||
.selectFrom('mediaSourceLibrary')
|
||||
.where('uuid', '=', id)
|
||||
.select((eb) =>
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('mediaSource')
|
||||
.whereRef(
|
||||
'mediaSource.uuid',
|
||||
'=',
|
||||
'mediaSourceLibrary.mediaSourceId',
|
||||
)
|
||||
.select(MediaSourceFields),
|
||||
).as('mediaSource'),
|
||||
)
|
||||
.selectAll()
|
||||
// Should be safe before of referential integrity of foreign keys
|
||||
.$narrowType<{ mediaSource: MediaSource }>()
|
||||
.executeTakeFirst()
|
||||
);
|
||||
}
|
||||
|
||||
async findByType(
|
||||
type: typeof MediaSourceType.Plex,
|
||||
nameOrId: string,
|
||||
nameOrId: MediaSourceId,
|
||||
): Promise<PlexMediaSource | undefined>;
|
||||
async findByType(
|
||||
type: typeof MediaSourceType.Jellyfin,
|
||||
nameOrId: string,
|
||||
nameOrId: MediaSourceId,
|
||||
): Promise<JellyfinMediaSource | undefined>;
|
||||
async findByType(
|
||||
type: typeof MediaSourceType.Emby,
|
||||
nameOrId: string,
|
||||
nameOrId: MediaSourceId,
|
||||
): Promise<EmbyMediaSource | undefined>;
|
||||
async findByType(
|
||||
type: MediaSourceType,
|
||||
nameOrId: string,
|
||||
): Promise<MediaSource | undefined>;
|
||||
async findByType(type: MediaSourceType): Promise<MediaSource[]>;
|
||||
nameOrId: MediaSourceId,
|
||||
): Promise<MediaSourceWithLibraries | undefined>;
|
||||
async findByType(type: MediaSourceType): Promise<MediaSourceWithLibraries[]>;
|
||||
async findByType(
|
||||
type: MediaSourceType,
|
||||
nameOrId?: string,
|
||||
): Promise<MediaSource[] | Maybe<MediaSource>> {
|
||||
nameOrId?: MediaSourceId,
|
||||
): Promise<MediaSourceWithLibraries[] | Maybe<MediaSourceWithLibraries>> {
|
||||
const found = await this.db
|
||||
.selectFrom('mediaSource')
|
||||
.selectAll()
|
||||
.select(withLibraries)
|
||||
.where('mediaSource.type', '=', type)
|
||||
.$if(isNonEmptyString(nameOrId), (qb) =>
|
||||
qb.where((eb) =>
|
||||
eb.or([
|
||||
eb('mediaSource.name', '=', nameOrId!),
|
||||
eb('mediaSource.uuid', '=', nameOrId!),
|
||||
]),
|
||||
),
|
||||
qb.where('mediaSource.uuid', '=', retag<MediaSourceId>(nameOrId!)),
|
||||
)
|
||||
.execute();
|
||||
|
||||
@@ -131,26 +156,7 @@ export class MediaSourceDB {
|
||||
}
|
||||
}
|
||||
|
||||
async getByExternalId(
|
||||
sourceType: MediaSourceType,
|
||||
nameOrClientId: string,
|
||||
): Promise<Maybe<MediaSource>> {
|
||||
return this.db
|
||||
.selectFrom('mediaSource')
|
||||
.selectAll()
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
eb('type', '=', sourceType),
|
||||
eb.or([
|
||||
eb('name', '=', nameOrClientId),
|
||||
eb('clientIdentifier', '=', nameOrClientId),
|
||||
]),
|
||||
]),
|
||||
)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async deleteMediaSource(id: string) {
|
||||
async deleteMediaSource(id: MediaSourceId) {
|
||||
const deletedServer = await this.getById(id);
|
||||
if (isNil(deletedServer)) {
|
||||
throw new Error(`MediaSource not found: ${id}`);
|
||||
@@ -185,7 +191,7 @@ export class MediaSourceDB {
|
||||
async updateMediaSource(server: UpdateMediaSourceRequest) {
|
||||
const id = server.id;
|
||||
|
||||
const mediaSource = await this.getById(id);
|
||||
const mediaSource = await this.getById(tag(id));
|
||||
|
||||
if (isNil(mediaSource)) {
|
||||
throw new Error("Server doesn't exist.");
|
||||
@@ -199,7 +205,7 @@ export class MediaSourceDB {
|
||||
await this.db
|
||||
.updateTable('mediaSource')
|
||||
.set({
|
||||
name: server.name,
|
||||
name: tag<MediaSourceName>(server.name),
|
||||
uri: trimEnd(server.uri, '/'),
|
||||
accessToken: server.accessToken,
|
||||
sendGuideUpdates: booleanToNumber(sendGuideUpdates),
|
||||
@@ -208,8 +214,8 @@ export class MediaSourceDB {
|
||||
// This allows clearing the values
|
||||
userId: server.userId,
|
||||
username: server.username,
|
||||
})
|
||||
.where('uuid', '=', server.id)
|
||||
} satisfies MediaSourceUpdate)
|
||||
.where('uuid', '=', tag<MediaSourceId>(server.id))
|
||||
// TODO: Blocked on https://github.com/oven-sh/bun/issues/16909
|
||||
// .limit(1)
|
||||
.executeTakeFirst();
|
||||
@@ -217,7 +223,7 @@ export class MediaSourceDB {
|
||||
this.mediaSourceApiFactory().deleteCachedClient(mediaSource);
|
||||
|
||||
const report = await this.fixupProgramReferences(
|
||||
id,
|
||||
tag(id),
|
||||
mediaSource.type,
|
||||
mediaSource,
|
||||
);
|
||||
@@ -226,7 +232,7 @@ export class MediaSourceDB {
|
||||
}
|
||||
|
||||
async setMediaSourceUserInfo(
|
||||
mediaSourceId: string,
|
||||
mediaSourceId: MediaSourceId,
|
||||
info: MediaSourceUserInfo,
|
||||
) {
|
||||
if (isNonEmptyString(info.userId) && isNonEmptyString(info.username)) {
|
||||
@@ -244,7 +250,9 @@ export class MediaSourceDB {
|
||||
}
|
||||
|
||||
async addMediaSource(server: InsertMediaSourceRequest): Promise<string> {
|
||||
const name = isUndefined(server.name) ? 'plex' : server.name;
|
||||
const name = tag<MediaSourceName>(
|
||||
isUndefined(server.name) ? 'plex' : server.name,
|
||||
);
|
||||
const sendGuideUpdates =
|
||||
server.type === 'plex' ? (server.sendGuideUpdates ?? false) : false;
|
||||
const sendChannelUpdates =
|
||||
@@ -260,7 +268,7 @@ export class MediaSourceDB {
|
||||
.insertInto('mediaSource')
|
||||
.values({
|
||||
...server,
|
||||
uuid: v4(),
|
||||
uuid: tag<MediaSourceId>(v4()),
|
||||
name,
|
||||
uri: trimEnd(server.uri, '/'),
|
||||
sendChannelUpdates: sendChannelUpdates ? 1 : 0,
|
||||
@@ -275,11 +283,65 @@ export class MediaSourceDB {
|
||||
.returning('uuid')
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await this.mediaSourceLibraryRefresher().refreshMediaSource(newServer.uuid);
|
||||
|
||||
return newServer?.uuid;
|
||||
}
|
||||
|
||||
async updateLibraries(updates: MediaSourceLibrariesUpdate) {
|
||||
return this.db.transaction().execute(async (tx) => {
|
||||
if (!isEmpty(updates.addedLibraries)) {
|
||||
await tx
|
||||
.insertInto('mediaSourceLibrary')
|
||||
.values(updates.addedLibraries)
|
||||
.execute();
|
||||
}
|
||||
|
||||
if (updates.updatedLibraries.length) {
|
||||
// TODO;
|
||||
}
|
||||
|
||||
if (updates.deletedLibraries.length) {
|
||||
await tx
|
||||
.deleteFrom('mediaSourceLibrary')
|
||||
.where(
|
||||
'uuid',
|
||||
'in',
|
||||
updates.deletedLibraries.map((lib) => lib.uuid),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async setLibraryEnabled(
|
||||
mediaSourceId: MediaSourceId,
|
||||
libraryId: string,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return this.db
|
||||
.updateTable('mediaSourceLibrary')
|
||||
.set({
|
||||
enabled: booleanToNumber(enabled),
|
||||
})
|
||||
.where('mediaSourceLibrary.mediaSourceId', '=', mediaSourceId)
|
||||
.where('uuid', '=', libraryId)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
setLibraryLastScannedTime(libraryId: string, lastScannedAt: dayjs.Dayjs) {
|
||||
return this.db
|
||||
.updateTable('mediaSourceLibrary')
|
||||
.set({
|
||||
lastScannedAt: +lastScannedAt,
|
||||
})
|
||||
.where('uuid', '=', libraryId)
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
private async fixupProgramReferences(
|
||||
serverName: string,
|
||||
serverId: MediaSourceId,
|
||||
serverType: MediaSourceType,
|
||||
newServer?: MediaSource,
|
||||
) {
|
||||
@@ -292,7 +354,7 @@ export class MediaSourceDB {
|
||||
.selectFrom('program')
|
||||
.selectAll()
|
||||
.where('sourceType', '=', serverType)
|
||||
.where('externalSourceId', '=', serverName)
|
||||
.where('mediaSourceId', '=', serverId)
|
||||
.select(withProgramChannels)
|
||||
.select(withProgramFillerShows)
|
||||
.select(withProgramCustomShows)
|
||||
@@ -334,7 +396,7 @@ export class MediaSourceDB {
|
||||
.length,
|
||||
);
|
||||
|
||||
const isUpdate = newServer && newServer.uuid !== serverName;
|
||||
const isUpdate = newServer && newServer.uuid !== serverId;
|
||||
if (!isUpdate) {
|
||||
// Remove all associations of this program
|
||||
// TODO: See if we can just get this automatically with foreign keys...
|
||||
@@ -399,3 +461,9 @@ export class MediaSourceDB {
|
||||
return [...channelReports, ...fillerReports, ...customShowReports];
|
||||
}
|
||||
}
|
||||
|
||||
export type MediaSourceLibrariesUpdate = {
|
||||
addedLibraries: NewMediaSourceLibrary[];
|
||||
updatedLibraries: MediaSourceLibraryUpdate[];
|
||||
deletedLibraries: MediaSourceLibrary[];
|
||||
};
|
||||
|
||||
13
server/src/db/mediaSourceQueryHelpers.ts
Normal file
13
server/src/db/mediaSourceQueryHelpers.ts
Normal 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');
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { isNonEmptyString } from '@/util/index.js';
|
||||
import { createExternalId } from '@tunarr/shared';
|
||||
import type { ContentProgram, CustomProgram } from '@tunarr/types';
|
||||
import { isContentProgram, isCustomProgram } from '@tunarr/types';
|
||||
import { isContentProgram, isCustomProgram, tag } from '@tunarr/types';
|
||||
import { reduce } from 'lodash-es';
|
||||
|
||||
// Takes a listing of programs and makes a mapping of a unique identifier,
|
||||
@@ -21,14 +21,14 @@ export function createPendingProgramIndexMap(
|
||||
// TODO handle other types of programs
|
||||
} else if (
|
||||
isContentProgram(p) &&
|
||||
isNonEmptyString(p.externalSourceName) &&
|
||||
isNonEmptyString(p.externalSourceId) &&
|
||||
isNonEmptyString(p.externalSourceType) &&
|
||||
isNonEmptyString(p.externalKey)
|
||||
) {
|
||||
acc[
|
||||
createExternalId(
|
||||
p.externalSourceType,
|
||||
p.externalSourceName,
|
||||
tag(p.externalSourceId),
|
||||
p.externalKey,
|
||||
)
|
||||
] = idx++;
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/sqlite';
|
||||
import { identity, isBoolean, isEmpty, keys, merge, reduce } from 'lodash-es';
|
||||
import type { DeepPartial, DeepRequired, StrictExclude } from 'ts-essentials';
|
||||
import type { Replace } from '../types/util.ts';
|
||||
import type { FillerShowTable as RawFillerShow } from './schema/FillerShow.js';
|
||||
import type {
|
||||
ProgramDao,
|
||||
@@ -19,40 +20,16 @@ import type {
|
||||
import { ProgramType } from './schema/Program.ts';
|
||||
import type { ProgramExternalId } from './schema/ProgramExternalId.ts';
|
||||
import { ProgramExternalIdFieldsWithAlias } from './schema/ProgramExternalId.ts';
|
||||
import type { ProgramGroupingFields } from './schema/ProgramGrouping.ts';
|
||||
import {
|
||||
AllProgramGroupingFields,
|
||||
AllProgramGroupingFieldsAliased,
|
||||
ProgramGroupingType,
|
||||
type ProgramGroupingTable as RawProgramGrouping,
|
||||
} from './schema/ProgramGrouping.ts';
|
||||
import type { ProgramGroupingExternalId } from './schema/ProgramGroupingExternalId.ts';
|
||||
import { ProgramGroupingExternalIdFieldsWithAlias } from './schema/ProgramGroupingExternalId.ts';
|
||||
import type { DB } from './schema/db.ts';
|
||||
|
||||
type ProgramGroupingFields<Alias extends string = 'programGrouping'> =
|
||||
readonly `${Alias}.${keyof RawProgramGrouping}`[];
|
||||
|
||||
const ProgramGroupingKeys: (keyof RawProgramGrouping)[] = [
|
||||
'artistUuid',
|
||||
'createdAt',
|
||||
'icon',
|
||||
'index',
|
||||
'showUuid',
|
||||
'summary',
|
||||
'title',
|
||||
'type',
|
||||
'updatedAt',
|
||||
'uuid',
|
||||
'year',
|
||||
];
|
||||
|
||||
// TODO move this definition to the ProgramGrouping DAO file
|
||||
export const AllProgramGroupingFields: ProgramGroupingFields =
|
||||
ProgramGroupingKeys.map((key) => `programGrouping.${key}` as const);
|
||||
|
||||
export const AllProgramGroupingFieldsAliased = <Alias extends string>(
|
||||
alias: Alias,
|
||||
): ProgramGroupingFields<Alias> =>
|
||||
ProgramGroupingKeys.map((key) => `${alias}.${key}` as const);
|
||||
|
||||
type ProgramGroupingExternalIdFields<
|
||||
Alias extends string = 'programGroupingExternalId',
|
||||
> = readonly `${Alias}.${keyof ProgramGroupingExternalId}`[];
|
||||
@@ -209,6 +186,7 @@ export function withProgramExternalIds(
|
||||
'externalKey',
|
||||
'sourceType',
|
||||
'externalSourceId',
|
||||
'mediaSourceId',
|
||||
],
|
||||
) {
|
||||
return jsonArrayFrom(
|
||||
@@ -254,6 +232,7 @@ export function withProgramGroupingExternalIds(
|
||||
'sourceType',
|
||||
'externalSourceId',
|
||||
'mediaSourceId',
|
||||
'libraryId',
|
||||
],
|
||||
) {
|
||||
return jsonArrayFrom(
|
||||
@@ -288,34 +267,31 @@ export const AllProgramJoins: ProgramJoins = {
|
||||
customShows: true,
|
||||
};
|
||||
|
||||
type Replace<
|
||||
T extends string,
|
||||
S extends string,
|
||||
D extends string,
|
||||
A extends string = '',
|
||||
> = T extends `${infer L}${S}${infer R}`
|
||||
? Replace<R, S, D, `${A}${L}${D}`>
|
||||
: `${A}${T}`;
|
||||
|
||||
type ProgramField = `program.${keyof RawProgram}`;
|
||||
type ProgramFields = readonly ProgramField[];
|
||||
|
||||
// const ProgramUpsertMapping =
|
||||
|
||||
export const AllProgramFields: ProgramFields = [
|
||||
export const AllProgramFields = [
|
||||
'program.uuid',
|
||||
'program.createdAt',
|
||||
'program.updatedAt',
|
||||
'program.albumName',
|
||||
'program.canonicalId',
|
||||
'program.icon',
|
||||
'program.summary',
|
||||
'program.title',
|
||||
'program.type',
|
||||
'program.year',
|
||||
'program.artistUuid',
|
||||
'program.externalKey',
|
||||
'program.libraryId',
|
||||
'program.albumUuid',
|
||||
'program.artistName',
|
||||
'program.artistUuid',
|
||||
'program.createdAt',
|
||||
'program.duration',
|
||||
'program.episode',
|
||||
'program.episodeIcon',
|
||||
'program.externalKey',
|
||||
'program.externalSourceId',
|
||||
'program.filePath',
|
||||
'program.grandparentExternalKey',
|
||||
'program.icon',
|
||||
'program.originalAirDate',
|
||||
'program.parentExternalKey',
|
||||
'program.plexFilePath',
|
||||
@@ -327,14 +303,9 @@ export const AllProgramFields: ProgramFields = [
|
||||
'program.showIcon',
|
||||
'program.showTitle',
|
||||
'program.sourceType',
|
||||
'program.summary',
|
||||
'program.title',
|
||||
'program.tvShowUuid',
|
||||
'program.type',
|
||||
'program.updatedAt',
|
||||
'program.uuid',
|
||||
'program.year',
|
||||
];
|
||||
'program.mediaSourceId',
|
||||
] as const;
|
||||
|
||||
type ProgramUpsertFields = StrictExclude<
|
||||
Replace<ProgramField, 'program', 'excluded'>,
|
||||
@@ -348,6 +319,7 @@ const ProgramUpsertIgnoreFields = [
|
||||
'program.albumUuid',
|
||||
'program.artistUuid',
|
||||
'program.seasonUuid',
|
||||
// 'program.libraryId',
|
||||
] as const;
|
||||
|
||||
type KnownProgramUpsertFields = StrictExclude<
|
||||
@@ -371,11 +343,13 @@ export const ProgramUpsertFields: ProgramUpsertFields[] =
|
||||
export type WithProgramsOptions = {
|
||||
joins?: Partial<ProgramJoins>;
|
||||
fields?: ProgramFields;
|
||||
includeGroupingExternalIds?: boolean;
|
||||
};
|
||||
|
||||
export const defaultWithProgramOptions: DeepRequired<WithProgramsOptions> = {
|
||||
joins: defaultProgramJoins,
|
||||
fields: AllProgramFields,
|
||||
includeGroupingExternalIds: false,
|
||||
};
|
||||
|
||||
type BaseWithProgramsAvailableTables =
|
||||
@@ -403,6 +377,18 @@ function baseWithProgramsExpressionBuilder(
|
||||
ProgramDao
|
||||
> = identity,
|
||||
) {
|
||||
function getJoinFields(key: keyof ProgramJoins) {
|
||||
if (!opts.joins[key]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isBoolean(opts.joins[key])) {
|
||||
return opts.joins[key] ? AllProgramGroupingFields : [];
|
||||
}
|
||||
|
||||
return opts.joins[key];
|
||||
}
|
||||
|
||||
const builder = eb.selectFrom('program').select(opts.fields);
|
||||
|
||||
return builderFunc(builder)
|
||||
@@ -410,15 +396,38 @@ function baseWithProgramsExpressionBuilder(
|
||||
qb.select((eb) =>
|
||||
withTrackAlbum(
|
||||
eb,
|
||||
isBoolean(opts.joins.trackAlbum)
|
||||
? AllProgramGroupingFields
|
||||
: opts.joins.trackAlbum,
|
||||
getJoinFields('trackAlbum'),
|
||||
opts.includeGroupingExternalIds,
|
||||
),
|
||||
),
|
||||
)
|
||||
.$if(!!opts.joins.trackArtist, (qb) =>
|
||||
qb.select((eb) =>
|
||||
withTrackArtist(
|
||||
eb,
|
||||
getJoinFields('trackArtist'),
|
||||
opts.includeGroupingExternalIds,
|
||||
),
|
||||
),
|
||||
)
|
||||
.$if(!!opts.joins.tvSeason, (qb) =>
|
||||
qb.select((eb) =>
|
||||
withTvSeason(
|
||||
eb,
|
||||
getJoinFields('tvSeason'),
|
||||
opts.includeGroupingExternalIds,
|
||||
),
|
||||
),
|
||||
)
|
||||
.$if(!!opts.joins.tvShow, (qb) =>
|
||||
qb.select((eb) =>
|
||||
withTvShow(
|
||||
eb,
|
||||
getJoinFields('tvShow'),
|
||||
opts.includeGroupingExternalIds,
|
||||
),
|
||||
),
|
||||
)
|
||||
.$if(!!opts.joins.trackArtist, (qb) => qb.select(withTrackArtist))
|
||||
.$if(!!opts.joins.tvSeason, (qb) => qb.select(withTvSeason))
|
||||
.$if(!!opts.joins.tvSeason, (qb) => qb.select(withTvShow))
|
||||
.$if(!!opts.joins.customShows, (qb) => qb.select(withProgramCustomShows));
|
||||
}
|
||||
|
||||
@@ -484,10 +493,21 @@ export function withPrograms(
|
||||
export function withProgramByExternalId(
|
||||
eb: ExpressionBuilder<DB, 'programExternalId'>,
|
||||
options: WithProgramsOptions = defaultWithProgramOptions,
|
||||
builderFunc: (
|
||||
qb: SelectQueryBuilder<
|
||||
DB,
|
||||
BaseWithProgramsAvailableTables | 'program',
|
||||
ProgramDao
|
||||
>,
|
||||
) => SelectQueryBuilder<
|
||||
DB,
|
||||
BaseWithProgramsAvailableTables | 'program',
|
||||
ProgramDao
|
||||
> = identity,
|
||||
) {
|
||||
const mergedOpts = merge({}, defaultWithProgramOptions, options);
|
||||
return jsonObjectFrom(
|
||||
baseWithProgramsExpressionBuilder(eb, mergedOpts).whereRef(
|
||||
baseWithProgramsExpressionBuilder(eb, mergedOpts, builderFunc).whereRef(
|
||||
'programExternalId.programUuid',
|
||||
'=',
|
||||
'program.uuid',
|
||||
|
||||
@@ -28,6 +28,14 @@ type InferBool<
|
||||
T['_']['columns'][Key]['notNull'] extends true ? number : number | null
|
||||
>;
|
||||
|
||||
type InferDateMs<
|
||||
T extends Table,
|
||||
Key extends keyof T['_']['columns'] & string,
|
||||
> = ColumnType<
|
||||
number,
|
||||
T['_']['columns'][Key]['notNull'] extends true ? number : number | null
|
||||
>;
|
||||
|
||||
export type KyselifyBetter<T extends Table> = Simplify<{
|
||||
[Key in keyof T['_']['columns'] & string as MapColumnName<
|
||||
Key,
|
||||
@@ -37,46 +45,48 @@ export type KyselifyBetter<T extends Table> = Simplify<{
|
||||
? InferJson<T, Key>
|
||||
: T['_']['columns'][Key]['dataType'] extends 'boolean'
|
||||
? InferBool<T, Key>
|
||||
: ColumnType<
|
||||
InferSelectModel<
|
||||
T,
|
||||
{
|
||||
dbColumnNames: true;
|
||||
}
|
||||
>[MapColumnName<Key, T['_']['columns'][Key], true>],
|
||||
MapColumnName<
|
||||
Key,
|
||||
T['_']['columns'][Key],
|
||||
true
|
||||
> extends keyof InferInsertModel<
|
||||
T,
|
||||
{
|
||||
dbColumnNames: true;
|
||||
}
|
||||
>
|
||||
? InferInsertModel<
|
||||
T,
|
||||
{
|
||||
dbColumnNames: true;
|
||||
}
|
||||
>[MapColumnName<Key, T['_']['columns'][Key], true>]
|
||||
: never,
|
||||
MapColumnName<
|
||||
Key,
|
||||
T['_']['columns'][Key],
|
||||
true
|
||||
> extends keyof InferInsertModel<
|
||||
T,
|
||||
{
|
||||
dbColumnNames: true;
|
||||
}
|
||||
>
|
||||
? InferInsertModel<
|
||||
T,
|
||||
{
|
||||
dbColumnNames: true;
|
||||
}
|
||||
>[MapColumnName<Key, T['_']['columns'][Key], true>]
|
||||
: never
|
||||
>;
|
||||
: T['_']['columns'][Key]['dataType'] extends 'date'
|
||||
? InferDateMs<T, Key>
|
||||
: ColumnType<
|
||||
InferSelectModel<
|
||||
T,
|
||||
{
|
||||
dbColumnNames: true;
|
||||
}
|
||||
>[MapColumnName<Key, T['_']['columns'][Key], true>],
|
||||
MapColumnName<
|
||||
Key,
|
||||
T['_']['columns'][Key],
|
||||
true
|
||||
> extends keyof InferInsertModel<
|
||||
T,
|
||||
{
|
||||
dbColumnNames: true;
|
||||
}
|
||||
>
|
||||
? InferInsertModel<
|
||||
T,
|
||||
{
|
||||
dbColumnNames: true;
|
||||
}
|
||||
>[MapColumnName<Key, T['_']['columns'][Key], true>]
|
||||
: never,
|
||||
MapColumnName<
|
||||
Key,
|
||||
T['_']['columns'][Key],
|
||||
true
|
||||
> extends keyof InferInsertModel<
|
||||
T,
|
||||
{
|
||||
dbColumnNames: true;
|
||||
}
|
||||
>
|
||||
? InferInsertModel<
|
||||
T,
|
||||
{
|
||||
dbColumnNames: true;
|
||||
}
|
||||
>[MapColumnName<Key, T['_']['columns'][Key], true>]
|
||||
: never
|
||||
>;
|
||||
}>;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { TupleToUnion } from '@tunarr/types';
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import { check, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
import type { Updateable } from 'kysely';
|
||||
import { type Insertable, type Selectable } from 'kysely';
|
||||
import type { StrictOmit } from 'ts-essentials';
|
||||
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
||||
import type { MediaSourceName } from './base.ts';
|
||||
import { type MediaSourceId } from './base.ts';
|
||||
|
||||
export const MediaSourceTypes = ['plex', 'jellyfin', 'emby'] as const;
|
||||
|
||||
@@ -22,13 +24,13 @@ export const MediaSourceType: MediaSourceMap = {
|
||||
export const MediaSource = sqliteTable(
|
||||
'media_source',
|
||||
{
|
||||
uuid: text().primaryKey(),
|
||||
uuid: text().primaryKey().$type<MediaSourceId>(),
|
||||
createdAt: integer(),
|
||||
updatedAt: integer(),
|
||||
accessToken: text().notNull(),
|
||||
clientIdentifier: text(),
|
||||
index: integer().notNull(),
|
||||
name: text().notNull(),
|
||||
name: text().notNull().$type<MediaSourceName>(),
|
||||
sendChannelUpdates: integer({ mode: 'boolean' }).default(false),
|
||||
sendGuideUpdates: integer({ mode: 'boolean' }).default(false),
|
||||
type: text({ enum: MediaSourceTypes }).notNull(),
|
||||
@@ -63,20 +65,63 @@ export const MediaSourceFields: (keyof MediaSourceTable)[] = [
|
||||
export type MediaSourceTable = KyselifyBetter<typeof MediaSource>;
|
||||
export type MediaSource = Selectable<MediaSourceTable>;
|
||||
export type NewMediaSource = Insertable<MediaSourceTable>;
|
||||
export type MediaSourceUpdate = Updateable<MediaSourceTable>;
|
||||
|
||||
export type SpecificMediaSourceType<Typ extends MediaSourceType> = StrictOmit<
|
||||
MediaSource,
|
||||
'type'
|
||||
> & {
|
||||
type: Typ;
|
||||
};
|
||||
export const MediaLibraryTypes = [
|
||||
'movies',
|
||||
'shows',
|
||||
'music_videos',
|
||||
'other_videos',
|
||||
'tracks',
|
||||
] as const;
|
||||
|
||||
export type PlexMediaSource = SpecificMediaSourceType<
|
||||
typeof MediaSourceType.Plex
|
||||
>;
|
||||
export type JellyfinMediaSource = SpecificMediaSourceType<
|
||||
typeof MediaSourceType.Jellyfin
|
||||
>;
|
||||
export type EmbyMediaSource = SpecificMediaSourceType<
|
||||
typeof MediaSourceType.Emby
|
||||
>;
|
||||
export type MediaLibraryType = TupleToUnion<typeof MediaLibraryTypes>;
|
||||
|
||||
export const MediaSourceLibrary = sqliteTable(
|
||||
'media_source_library',
|
||||
{
|
||||
uuid: text().primaryKey().notNull(),
|
||||
name: text().notNull(),
|
||||
mediaType: text({ enum: MediaLibraryTypes }).notNull(),
|
||||
mediaSourceId: text()
|
||||
.references(() => MediaSource.uuid, { onDelete: 'cascade' })
|
||||
.notNull()
|
||||
.$type<MediaSourceId>(),
|
||||
lastScannedAt: integer({ mode: 'timestamp_ms' }),
|
||||
externalKey: text().notNull(),
|
||||
enabled: integer({ mode: 'boolean' }).default(false).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
check(
|
||||
'media_type_check',
|
||||
inArray(table.mediaType, table.mediaType.enumValues).inlineParams(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const MediaSourceLibraryColumns: (keyof MediaSourceLibraryTable)[] = [
|
||||
'enabled',
|
||||
'externalKey',
|
||||
'lastScannedAt',
|
||||
'mediaSourceId',
|
||||
'mediaType',
|
||||
'uuid',
|
||||
'name',
|
||||
];
|
||||
|
||||
export type MediaSourceLibraryTable = KyselifyBetter<typeof MediaSourceLibrary>;
|
||||
export type MediaSourceLibrary = Selectable<MediaSourceLibraryTable>;
|
||||
export type NewMediaSourceLibrary = Insertable<MediaSourceLibraryTable>;
|
||||
export type MediaSourceLibraryUpdate = Updateable<MediaSourceLibraryTable>;
|
||||
|
||||
export const MediaSourceLibraryReplacePath = sqliteTable(
|
||||
'media_source_library_replace_path',
|
||||
{
|
||||
uuid: text().primaryKey().notNull(),
|
||||
serverPath: text().notNull(),
|
||||
localPath: text().notNull(),
|
||||
mediaSourceId: text()
|
||||
.notNull()
|
||||
.references(() => MediaSource.uuid, { onDelete: 'cascade' }),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createExternalId } from '@tunarr/shared';
|
||||
import type { TupleToUnion } from '@tunarr/types';
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import {
|
||||
@@ -12,8 +11,14 @@ import {
|
||||
import type { Insertable, Selectable, Updateable } from 'kysely';
|
||||
import type { MarkNotNilable } from '../../types/util.ts';
|
||||
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
||||
import { MediaSource, MediaSourceTypes } from './MediaSource.ts';
|
||||
import {
|
||||
MediaSource,
|
||||
MediaSourceLibrary,
|
||||
MediaSourceTypes,
|
||||
} from './MediaSource.ts';
|
||||
import { ProgramGrouping } from './ProgramGrouping.ts';
|
||||
import type { MediaSourceName } from './base.ts';
|
||||
import { type MediaSourceId } from './base.ts';
|
||||
|
||||
export const ProgramTypes = [
|
||||
'movie',
|
||||
@@ -41,14 +46,18 @@ export const Program = sqliteTable(
|
||||
albumUuid: text().references(() => ProgramGrouping.uuid),
|
||||
artistName: text(),
|
||||
artistUuid: text().references(() => ProgramGrouping.uuid),
|
||||
canonicalId: text(),
|
||||
duration: integer().notNull(),
|
||||
episode: integer(),
|
||||
episodeIcon: text(),
|
||||
externalKey: text().notNull(),
|
||||
externalSourceId: text().notNull(),
|
||||
mediaSourceId: text().references(() => MediaSource.uuid, {
|
||||
onDelete: 'cascade',
|
||||
}),
|
||||
externalSourceId: text().notNull().$type<MediaSourceName>(),
|
||||
mediaSourceId: text()
|
||||
.references(() => MediaSource.uuid, {
|
||||
onDelete: 'cascade',
|
||||
})
|
||||
.$type<MediaSourceId>(),
|
||||
libraryId: text().references(() => MediaSourceLibrary.uuid),
|
||||
filePath: text(),
|
||||
grandparentExternalKey: text(),
|
||||
icon: text(),
|
||||
@@ -77,6 +86,11 @@ export const Program = sqliteTable(
|
||||
uniqueIndex(
|
||||
'program_source_type_external_source_id_external_key_unique',
|
||||
).on(table.sourceType, table.externalSourceId, table.externalKey),
|
||||
uniqueIndex('program_source_type_media_source_external_key_unique').on(
|
||||
table.sourceType,
|
||||
table.mediaSourceId,
|
||||
table.externalKey,
|
||||
),
|
||||
check(
|
||||
'program_type_check',
|
||||
inArray(table.type, table.type.enumValues).inlineParams(),
|
||||
@@ -85,17 +99,15 @@ export const Program = sqliteTable(
|
||||
'program_source_type_check',
|
||||
inArray(table.sourceType, table.sourceType.enumValues).inlineParams(),
|
||||
),
|
||||
index('program_canonical_id_index').on(table.canonicalId),
|
||||
],
|
||||
);
|
||||
|
||||
export type ProgramTable = KyselifyBetter<typeof Program>;
|
||||
export type ProgramDao = Selectable<ProgramTable>;
|
||||
// Make canonicalId required on insert.
|
||||
export type NewProgramDao = MarkNotNilable<
|
||||
Insertable<ProgramTable>,
|
||||
'mediaSourceId'
|
||||
'canonicalId' | 'mediaSourceId'
|
||||
>;
|
||||
export type ProgramDaoUpdate = Updateable<ProgramTable>;
|
||||
|
||||
export function programExternalIdString(p: ProgramDao | NewProgramDao) {
|
||||
return createExternalId(p.sourceType, p.externalSourceId, p.externalKey);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { Insertable, Selectable } from 'kysely';
|
||||
import { omit } from 'lodash-es';
|
||||
import type { MarkRequired, StrictOmit } from 'ts-essentials';
|
||||
import type { MarkNotNilable } from '../../types/util.ts';
|
||||
import type { MediaSourceId, MediaSourceName } from './base.ts';
|
||||
import { ProgramExternalIdSourceTypes } from './base.ts';
|
||||
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
||||
import { MediaSource } from './MediaSource.ts';
|
||||
@@ -25,10 +26,12 @@ export const ProgramExternalId = sqliteTable(
|
||||
directFilePath: text(),
|
||||
externalFilePath: text(),
|
||||
externalKey: text().notNull(),
|
||||
externalSourceId: text(),
|
||||
mediaSourceId: text().references(() => MediaSource.uuid, {
|
||||
onDelete: 'cascade',
|
||||
}),
|
||||
externalSourceId: text().$type<MediaSourceName>(),
|
||||
mediaSourceId: text()
|
||||
.references(() => MediaSource.uuid, {
|
||||
onDelete: 'cascade',
|
||||
})
|
||||
.$type<MediaSourceId>(),
|
||||
programUuid: text()
|
||||
.notNull()
|
||||
.references(() => Program.uuid, { onDelete: 'cascade' }),
|
||||
@@ -94,6 +97,7 @@ export const ProgramExternalIdKeys: (keyof ProgramExternalId)[] = [
|
||||
'externalSourceId',
|
||||
'programUuid',
|
||||
'sourceType',
|
||||
'mediaSourceId',
|
||||
// 'updatedAt',
|
||||
'uuid',
|
||||
];
|
||||
|
||||
@@ -9,11 +9,12 @@ import {
|
||||
text,
|
||||
} from 'drizzle-orm/sqlite-core';
|
||||
import type { Insertable, Selectable, Updateable } from 'kysely';
|
||||
import type { MarkRequiredNotNull } from '../../types/util.ts';
|
||||
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
||||
import { MediaSourceLibrary } from './MediaSource.ts';
|
||||
import type { ProgramGroupingTable as RawProgramGrouping } from './ProgramGrouping.ts';
|
||||
|
||||
export const ProgramGroupingType: Readonly<
|
||||
Record<Capitalize<ProgramGroupingType>, ProgramGroupingType>
|
||||
> = {
|
||||
export const ProgramGroupingType = {
|
||||
Show: 'show',
|
||||
Season: 'season',
|
||||
Artist: 'artist',
|
||||
@@ -33,16 +34,18 @@ export const ProgramGrouping = sqliteTable(
|
||||
'program_grouping',
|
||||
{
|
||||
uuid: text().primaryKey(),
|
||||
canonicalId: text(),
|
||||
createdAt: integer(),
|
||||
updatedAt: integer(),
|
||||
artistUuid: text().references((): AnySQLiteColumn => ProgramGrouping.uuid),
|
||||
icon: text(),
|
||||
index: integer(),
|
||||
showUuid: text().references((): AnySQLiteColumn => ProgramGrouping.uuid),
|
||||
summary: text(),
|
||||
title: text().notNull(),
|
||||
type: text({ enum: ProgramGroupingTypes }).notNull(),
|
||||
year: integer(),
|
||||
artistUuid: text().references((): AnySQLiteColumn => ProgramGrouping.uuid),
|
||||
showUuid: text().references((): AnySQLiteColumn => ProgramGrouping.uuid),
|
||||
libraryId: text().references(() => MediaSourceLibrary.uuid),
|
||||
},
|
||||
(table) => [
|
||||
index('program_grouping_show_uuid_index').on(table.showUuid),
|
||||
@@ -56,5 +59,40 @@ export const ProgramGrouping = sqliteTable(
|
||||
|
||||
export type ProgramGroupingTable = KyselifyBetter<typeof ProgramGrouping>;
|
||||
export type ProgramGrouping = Selectable<ProgramGroupingTable>;
|
||||
export type NewProgramGrouping = Insertable<ProgramGroupingTable>;
|
||||
export type NewProgramGrouping = MarkRequiredNotNull<
|
||||
Insertable<ProgramGroupingTable>,
|
||||
'canonicalId' | 'libraryId'
|
||||
>;
|
||||
export type ProgramGroupingUpdate = Updateable<ProgramGroupingTable>;
|
||||
|
||||
const ProgramGroupingKeys: (keyof RawProgramGrouping)[] = [
|
||||
'artistUuid',
|
||||
'createdAt',
|
||||
'icon',
|
||||
'index',
|
||||
'showUuid',
|
||||
'summary',
|
||||
'title',
|
||||
'type',
|
||||
'updatedAt',
|
||||
'uuid',
|
||||
'year',
|
||||
];
|
||||
// TODO move this definition to the ProgramGrouping DAO file
|
||||
|
||||
export const AllProgramGroupingFields: ProgramGroupingFields =
|
||||
ProgramGroupingKeys.map((key) => `programGrouping.${key}` as const);
|
||||
|
||||
export const AllProgramGroupingFieldsAliased = <Alias extends string>(
|
||||
alias: Alias,
|
||||
): ProgramGroupingFields<Alias> =>
|
||||
ProgramGroupingKeys.map((key) => `${alias}.${key}` as const);
|
||||
|
||||
export const MinimalProgramGroupingFields: ProgramGroupingFields = [
|
||||
'programGrouping.uuid',
|
||||
'programGrouping.title',
|
||||
'programGrouping.year',
|
||||
// 'programGrouping.index',
|
||||
];
|
||||
export type ProgramGroupingFields<Alias extends string = 'programGrouping'> =
|
||||
readonly `${Alias}.${keyof RawProgramGrouping}`[];
|
||||
|
||||
@@ -10,9 +10,10 @@ import type { Insertable, Selectable } from 'kysely';
|
||||
import { omit } from 'lodash-es';
|
||||
import type { StrictOmit } from 'ts-essentials';
|
||||
import type { MarkNotNilable } from '../../types/util.ts';
|
||||
import type { MediaSourceId, MediaSourceName } from './base.ts';
|
||||
import { ProgramExternalIdSourceTypes } from './base.ts';
|
||||
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
||||
import { MediaSource } from './MediaSource.ts';
|
||||
import { MediaSource, MediaSourceLibrary } from './MediaSource.ts';
|
||||
import { ProgramGrouping } from './ProgramGrouping.ts';
|
||||
|
||||
export const ProgramGroupingExternalId = sqliteTable(
|
||||
@@ -23,10 +24,12 @@ export const ProgramGroupingExternalId = sqliteTable(
|
||||
updatedAt: integer(),
|
||||
externalFilePath: text(),
|
||||
externalKey: text().notNull(),
|
||||
externalSourceId: text(),
|
||||
mediaSourceId: text().references(() => MediaSource.uuid, {
|
||||
onDelete: 'cascade',
|
||||
}),
|
||||
externalSourceId: text().$type<MediaSourceName>(),
|
||||
mediaSourceId: text()
|
||||
.references(() => MediaSource.uuid, {
|
||||
onDelete: 'cascade',
|
||||
})
|
||||
.$type<MediaSourceId>(),
|
||||
groupUuid: text()
|
||||
.notNull()
|
||||
.references(() => ProgramGrouping.uuid, {
|
||||
@@ -34,6 +37,9 @@ export const ProgramGroupingExternalId = sqliteTable(
|
||||
onUpdate: 'cascade',
|
||||
}),
|
||||
sourceType: text({ enum: ProgramExternalIdSourceTypes }).notNull(),
|
||||
libraryId: text().references(() => MediaSourceLibrary.uuid, {
|
||||
onDelete: 'cascade',
|
||||
}),
|
||||
},
|
||||
(table) => [
|
||||
index('program_grouping_group_uuid_index').on(table.groupUuid),
|
||||
@@ -63,7 +69,7 @@ export type NewSingleOrMultiProgramGroupingExternalId =
|
||||
> & { type: 'multi' });
|
||||
|
||||
export function toInsertableProgramGroupingExternalId(
|
||||
eid: NewSingleOrMultiProgramGroupingExternalId,
|
||||
eid: NewProgramGroupingExternalId | NewSingleOrMultiProgramGroupingExternalId,
|
||||
): NewProgramGroupingExternalId {
|
||||
return omit(eid, 'type') satisfies NewProgramGroupingExternalId;
|
||||
}
|
||||
@@ -74,13 +80,13 @@ export type ProgramGroupingExternalIdFields<
|
||||
|
||||
export const ProgramGroupingExternalIdKeys: (keyof ProgramGroupingExternalId)[] =
|
||||
[
|
||||
// 'createdAt',
|
||||
'createdAt',
|
||||
'externalFilePath',
|
||||
'externalKey',
|
||||
'externalSourceId',
|
||||
'sourceType',
|
||||
'groupUuid',
|
||||
// 'updatedAt',
|
||||
'updatedAt',
|
||||
'uuid',
|
||||
];
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type TupleToUnion } from '@tunarr/types';
|
||||
import { type Tag, type TupleToUnion } from '@tunarr/types';
|
||||
import {
|
||||
ContentProgramTypeSchema,
|
||||
ResolutionSchema,
|
||||
@@ -120,3 +120,6 @@ export const ChannelOfflineSettingsSchema = z.object({
|
||||
export type ChannelOfflineSettings = z.infer<
|
||||
typeof ChannelOfflineSettingsSchema
|
||||
>;
|
||||
|
||||
export type MediaSourceId = Tag<string, 'mediaSourceId'>;
|
||||
export type MediaSourceName = Tag<string, 'mediaSourceName'>;
|
||||
|
||||
@@ -8,7 +8,10 @@ import type {
|
||||
} from './Channel.ts';
|
||||
import type { CustomShowContentTable, CustomShowTable } from './CustomShow.js';
|
||||
import type { FillerShowContentTable, FillerShowTable } from './FillerShow.js';
|
||||
import type { MediaSourceTable } from './MediaSource.ts';
|
||||
import type {
|
||||
MediaSourceLibraryTable,
|
||||
MediaSourceTable,
|
||||
} from './MediaSource.ts';
|
||||
import type { MikroOrmMigrationsTable } from './MikroOrmMigrations.js';
|
||||
import type { ProgramTable } from './Program.ts';
|
||||
import type { ProgramExternalIdTable } from './ProgramExternalId.ts';
|
||||
@@ -34,6 +37,7 @@ export interface DB {
|
||||
fillerShow: FillerShowTable;
|
||||
fillerShowContent: FillerShowContentTable;
|
||||
mediaSource: MediaSourceTable;
|
||||
mediaSourceLibrary: MediaSourceLibraryTable;
|
||||
program: ProgramTable;
|
||||
programExternalId: ProgramExternalIdTable;
|
||||
programGrouping: ProgramGroupingTable;
|
||||
|
||||
123
server/src/db/schema/derivedTypes.d.ts
vendored
123
server/src/db/schema/derivedTypes.d.ts
vendored
@@ -3,10 +3,25 @@ import type { MarkNonNullable } from '@/types/util.js';
|
||||
import type { DeepNullable, MarkRequired, StrictOmit } from 'ts-essentials';
|
||||
import type { Channel, ChannelFillerShow } from './Channel.ts';
|
||||
import type { FillerShow } from './FillerShow.ts';
|
||||
import type { ProgramDao } from './Program.ts';
|
||||
import type { MinimalProgramExternalId } from './ProgramExternalId.ts';
|
||||
import type { ProgramGrouping } from './ProgramGrouping.ts';
|
||||
import type { ProgramGroupingExternalId } from './ProgramGroupingExternalId.ts';
|
||||
import type {
|
||||
MediaSource,
|
||||
MediaSourceLibrary,
|
||||
MediaSourceType,
|
||||
} from './MediaSource.ts';
|
||||
import type { NewProgramDao, ProgramDao, ProgramType } from './Program.ts';
|
||||
import type {
|
||||
MinimalProgramExternalId,
|
||||
NewSingleOrMultiExternalId,
|
||||
} from './ProgramExternalId.ts';
|
||||
import type {
|
||||
NewProgramGrouping,
|
||||
ProgramGrouping,
|
||||
ProgramGroupingType,
|
||||
} from './ProgramGrouping.ts';
|
||||
import type {
|
||||
NewSingleOrMultiProgramGroupingExternalId,
|
||||
ProgramGroupingExternalId,
|
||||
} from './ProgramGroupingExternalId.ts';
|
||||
import type { ChannelSubtitlePreferences } from './SubtitlePreferences.ts';
|
||||
|
||||
export type ProgramWithRelations = ProgramDao & {
|
||||
@@ -18,6 +33,39 @@ export type ProgramWithRelations = ProgramDao & {
|
||||
externalIds?: MinimalProgramExternalId[];
|
||||
};
|
||||
|
||||
export type SpecificProgramGroupingType<
|
||||
Typ extends ProgramGroupingType,
|
||||
ProgramGroupingT = ProgramGrouping,
|
||||
> = StrictOmit<ProgramGroupingT, 'type'> & { type: Typ };
|
||||
|
||||
export type SpecificProgramType<
|
||||
Typ extends ProgramType,
|
||||
ProgramT extends { type: ProgramType } = ProgramDao,
|
||||
> = StrictOmit<ProgramT, 'type'> & { type: Typ };
|
||||
|
||||
export type MovieProgram = SpecificProgramType<'movie'> & {
|
||||
externalIds: MinimalProgramExternalId[];
|
||||
};
|
||||
|
||||
export type TvSeason = SpecificProgramGroupingType<'season'> & {
|
||||
externalIds: ProgramGroupingExternalId[];
|
||||
};
|
||||
|
||||
export type TvShow = SpecificProgramGroupingType<'show'> & {
|
||||
externalIds: ProgramGroupingExternalId[];
|
||||
};
|
||||
|
||||
export type EpisodeProgram = SpecificProgramType<'episode'> & {
|
||||
tvSeason: TvSeason;
|
||||
tvShow: TvShow;
|
||||
externalIds: MinimalProgramExternalId[];
|
||||
};
|
||||
|
||||
export type EpisodeProgramWithRelations = EpisodeProgram & {
|
||||
tvShow: ProgramGroupingWithExternalIds;
|
||||
tvSeason: ProgramGroupingWithExternalIds;
|
||||
};
|
||||
|
||||
export type ChannelWithRelations = Channel & {
|
||||
programs?: ProgramWithRelations[];
|
||||
fillerContent?: ProgramWithRelations[];
|
||||
@@ -58,6 +106,21 @@ export type ProgramWithExternalIds = ProgramDao & {
|
||||
externalIds: MinimalProgramExternalId[];
|
||||
};
|
||||
|
||||
export type NewProgramWithExternalIds = NewProgramDao & {
|
||||
externalIds: NewSingleOrMultiExternalId[];
|
||||
};
|
||||
|
||||
export type NewMovieProgram = SpecificProgramType<'movie', NewProgramDao> & {
|
||||
externalIds: NewSingleOrMultiExternalId[];
|
||||
};
|
||||
|
||||
export type NewEpisodeProgram = SpecificProgramType<
|
||||
'episode',
|
||||
NewProgramDao
|
||||
> & {
|
||||
externalIds: NewSingleOrMultiExternalId[];
|
||||
};
|
||||
|
||||
export type ProgramGroupingWithExternalIds = ProgramGrouping & {
|
||||
externalIds: ProgramGroupingExternalId[];
|
||||
};
|
||||
@@ -96,3 +159,55 @@ export type GeneralizedProgramGroupingWithExternalIds =
|
||||
| TvSeasonWithExternalIds
|
||||
| MusicAlbumWithExternalIds
|
||||
| MusicArtistWithExternalIds;
|
||||
|
||||
type WithNewGroupingExternalIds = {
|
||||
externalIds: NewSingleOrMultiProgramGroupingExternalId[];
|
||||
};
|
||||
|
||||
export type NewProgramGroupingWithExternalIds = NewProgramGrouping &
|
||||
WithNewGroupingExternalIds;
|
||||
|
||||
export type NewTvShow = SpecificProgramGroupingType<
|
||||
'show',
|
||||
NewProgramGrouping
|
||||
> &
|
||||
WithNewGroupingExternalIds;
|
||||
export type NewTvSeason = SpecificProgramGroupingType<
|
||||
'season',
|
||||
NewProgramGrouping
|
||||
> &
|
||||
WithNewGroupingExternalIds;
|
||||
|
||||
export type NewMusicArtist = SpecificProgramGroupingType<
|
||||
'artist',
|
||||
NewProgramGrouping
|
||||
> &
|
||||
WithNewGroupingExternalIds;
|
||||
export type NewMusicAlbum = SpecificProgramGroupingType<
|
||||
'album',
|
||||
NewProgramGrouping
|
||||
> &
|
||||
WithNewGroupingExternalIds;
|
||||
export type NewMusicTrack = SpecificProgramType<'track', NewProgramDao> & {
|
||||
externalIds: NewSingleOrMultiExternalId[];
|
||||
};
|
||||
|
||||
export type MediaSourceWithLibraries = MediaSource & {
|
||||
libraries: MediaSourceLibrary[];
|
||||
};
|
||||
export type SpecificMediaSourceType<Typ extends MediaSourceType> = StrictOmit<
|
||||
MediaSourceWithLibraries,
|
||||
'type'
|
||||
> & {
|
||||
type: Typ;
|
||||
};
|
||||
|
||||
export type PlexMediaSource = SpecificMediaSourceType<
|
||||
typeof MediaSourceType.Plex
|
||||
>;
|
||||
export type JellyfinMediaSource = SpecificMediaSourceType<
|
||||
typeof MediaSourceType.Jellyfin
|
||||
>;
|
||||
export type EmbyMediaSource = SpecificMediaSourceType<
|
||||
typeof MediaSourceType.Emby
|
||||
>;
|
||||
|
||||
30
server/src/db/schema/schemaTypeGuards.ts
Normal file
30
server/src/db/schema/schemaTypeGuards.ts
Normal 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';
|
||||
}
|
||||
141
server/src/external/BaseApiClient.ts
vendored
141
server/src/external/BaseApiClient.ts
vendored
@@ -4,6 +4,7 @@ import { configureAxiosLogging } from '@/util/axios.js';
|
||||
import { isDefined, isNodeError } from '@/util/index.js';
|
||||
import type { Logger } from '@/util/logging/LoggerFactory.js';
|
||||
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
|
||||
import { type TupleToUnion } from '@tunarr/types';
|
||||
import type { MediaSourceUnhealthyStatus } from '@tunarr/types/api';
|
||||
import type {
|
||||
AxiosHeaderValue,
|
||||
@@ -11,54 +12,75 @@ import type {
|
||||
AxiosRequestConfig,
|
||||
} from 'axios';
|
||||
import axios, { isAxiosError } from 'axios';
|
||||
import { isError, isString } from 'lodash-es';
|
||||
import type { Duration } from 'dayjs/plugin/duration.js';
|
||||
import { has, isError, isString } from 'lodash-es';
|
||||
import PQueue from 'p-queue';
|
||||
import type { StrictOmit } from 'ts-essentials';
|
||||
import { z } from 'zod/v4';
|
||||
import type { MediaSourceWithLibraries } from '../db/schema/derivedTypes.js';
|
||||
import { WrappedError } from '../types/errors.ts';
|
||||
import { Result } from '../types/result.ts';
|
||||
|
||||
export type ApiClientOptions = {
|
||||
name: string;
|
||||
mediaSourceUuid?: string;
|
||||
accessToken: string;
|
||||
url: string;
|
||||
userId: string | null;
|
||||
username: string | null;
|
||||
mediaSource: StrictOmit<
|
||||
MediaSourceWithLibraries,
|
||||
| 'createdAt'
|
||||
| 'updatedAt'
|
||||
| 'clientIdentifier'
|
||||
| 'index'
|
||||
| 'sendChannelUpdates'
|
||||
| 'sendGuideUpdates'
|
||||
>;
|
||||
extraHeaders?: {
|
||||
[key: string]: AxiosHeaderValue;
|
||||
};
|
||||
enableRequestCache?: boolean;
|
||||
queueOpts?: {
|
||||
concurrency: number;
|
||||
interval: Duration;
|
||||
};
|
||||
};
|
||||
|
||||
export type QuerySuccessResult<T> = {
|
||||
type: 'success';
|
||||
data: T;
|
||||
export type RemoteMediaSourceOptions = ApiClientOptions & {
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
type QueryErrorCode =
|
||||
| 'not_found'
|
||||
| 'no_access_token'
|
||||
| 'parse_error'
|
||||
| 'generic_request_error';
|
||||
const QueryErrorCodes = [
|
||||
'not_found',
|
||||
'no_access_token',
|
||||
'parse_error',
|
||||
'generic_request_error',
|
||||
] as const;
|
||||
type QueryErrorCode = TupleToUnion<typeof QueryErrorCodes>;
|
||||
|
||||
export type QueryErrorResult = {
|
||||
type: 'error';
|
||||
code: QueryErrorCode;
|
||||
message?: string;
|
||||
};
|
||||
export abstract class QueryError extends WrappedError {
|
||||
readonly type: QueryErrorCode;
|
||||
|
||||
export type QueryResult<T> = QuerySuccessResult<T> | QueryErrorResult;
|
||||
static isQueryError(e: unknown): e is QueryError {
|
||||
return (
|
||||
has(e, 'type') &&
|
||||
isString(e.type) &&
|
||||
QueryErrorCodes.some((x) => x === e.type)
|
||||
);
|
||||
}
|
||||
|
||||
export function isQueryError(x: QueryResult<unknown>): x is QueryErrorResult {
|
||||
return x.type === 'error';
|
||||
static genericQueryError(message?: string): QueryError {
|
||||
return this.create('generic_request_error', message);
|
||||
}
|
||||
|
||||
static create(type: QueryErrorCode, message?: string): QueryError {
|
||||
return new (class extends QueryError {
|
||||
type = type;
|
||||
})(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function isQuerySuccess<T>(
|
||||
x: QueryResult<T>,
|
||||
): x is QuerySuccessResult<T> {
|
||||
return x.type === 'success';
|
||||
}
|
||||
export type QueryResult<T> = Result<T, QueryError>;
|
||||
|
||||
export abstract class BaseApiClient<
|
||||
OptionsType extends ApiClientOptions = ApiClientOptions,
|
||||
> {
|
||||
private queue?: PQueue;
|
||||
protected logger: Logger;
|
||||
protected axiosInstance: AxiosInstance;
|
||||
protected redacter?: AxiosRequestRedacter;
|
||||
@@ -66,12 +88,13 @@ export abstract class BaseApiClient<
|
||||
constructor(protected options: OptionsType) {
|
||||
this.logger = LoggerFactory.child({
|
||||
className: this.constructor.name,
|
||||
serverName: options.name,
|
||||
serverName: options.mediaSource.name,
|
||||
});
|
||||
|
||||
const url = options.url.endsWith('/')
|
||||
? options.url.slice(0, options.url.length - 1)
|
||||
: options.url;
|
||||
const url = options.mediaSource.uri.endsWith('/')
|
||||
? options.mediaSource.uri.slice(0, options.mediaSource.uri.length - 1)
|
||||
: options.mediaSource.uri;
|
||||
this.options.mediaSource.uri = url;
|
||||
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL: url,
|
||||
@@ -81,9 +104,20 @@ export abstract class BaseApiClient<
|
||||
},
|
||||
});
|
||||
|
||||
if (options.queueOpts) {
|
||||
this.queue = new PQueue({
|
||||
concurrency: options.queueOpts.concurrency,
|
||||
interval: options.queueOpts.interval.asMilliseconds(),
|
||||
});
|
||||
}
|
||||
|
||||
configureAxiosLogging(this.axiosInstance, this.logger);
|
||||
}
|
||||
|
||||
setApiClientOptions(opts: OptionsType) {
|
||||
this.options = opts;
|
||||
}
|
||||
|
||||
async doTypeCheckedGet<T extends z.ZodType, Out = z.infer<T>>(
|
||||
path: string,
|
||||
schema: T,
|
||||
@@ -120,28 +154,23 @@ export abstract class BaseApiClient<
|
||||
return this.makeErrorResult('parse_error');
|
||||
}
|
||||
|
||||
protected preRequestValidate(
|
||||
protected preRequestValidate<T>(
|
||||
_req: AxiosRequestConfig,
|
||||
): Maybe<QueryErrorResult> {
|
||||
): Maybe<QueryResult<T>> {
|
||||
return;
|
||||
}
|
||||
|
||||
protected makeErrorResult(
|
||||
code: QueryErrorCode,
|
||||
protected makeErrorResult<T>(
|
||||
type: QueryErrorCode,
|
||||
message?: string,
|
||||
): QueryErrorResult {
|
||||
return {
|
||||
type: 'error',
|
||||
code,
|
||||
message,
|
||||
};
|
||||
): QueryResult<T> {
|
||||
return Result.failure<T, QueryError>(
|
||||
QueryError.create(type, message ?? 'Unknown Error'),
|
||||
);
|
||||
}
|
||||
|
||||
protected makeSuccessResult<T>(data: T): QuerySuccessResult<T> {
|
||||
return {
|
||||
type: 'success',
|
||||
data,
|
||||
};
|
||||
protected makeSuccessResult<T>(data: T): QueryResult<T> {
|
||||
return Result.success<T, QueryError>(data);
|
||||
}
|
||||
|
||||
doGet<T>(req: Omit<AxiosRequestConfig, 'method'>) {
|
||||
@@ -162,13 +191,17 @@ export abstract class BaseApiClient<
|
||||
|
||||
getFullUrl(path: string): string {
|
||||
const sanitizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
const url = new URL(`${this.options.url}${sanitizedPath}`);
|
||||
const url = new URL(`${this.options.mediaSource.uri}${sanitizedPath}`);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
protected async doRequest<T>(req: AxiosRequestConfig): Promise<T> {
|
||||
try {
|
||||
const response = await this.axiosInstance.request<T>(req);
|
||||
const response = await (this.queue
|
||||
? this.queue.add(() => this.axiosInstance.request<T>(req), {
|
||||
throwOnTimeout: true,
|
||||
})
|
||||
: this.axiosInstance.request<T>(req));
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (isAxiosError(error)) {
|
||||
@@ -185,7 +218,7 @@ export abstract class BaseApiClient<
|
||||
// The request was made and the server responded with a status code
|
||||
// that falls out of the range of 2xx
|
||||
this.logger.warn(
|
||||
'API client response error: path: %O, status %d, params: %O, data: %O, headers: %O',
|
||||
'API client response error: path: %s, status %d, params: %O, data: %O, headers: %O',
|
||||
error.config?.url ?? '',
|
||||
status,
|
||||
error.config?.params ?? {},
|
||||
@@ -214,7 +247,7 @@ export abstract class BaseApiClient<
|
||||
// At this point we have no idea what the object is... attempt to log
|
||||
// and just return a generic error. Something is probably fatally wrong
|
||||
// at this point.
|
||||
this.logger.error('Unknown error type thrown: %O', error);
|
||||
this.logger.error(error, 'Unknown error type thrown: %O');
|
||||
throw new Error('Unknown error', { cause: error });
|
||||
}
|
||||
}
|
||||
@@ -245,4 +278,10 @@ export abstract class BaseApiClient<
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
protected findMatchingLibrary(externalLibraryId: string) {
|
||||
return this.options.mediaSource.libraries.find(
|
||||
(lib) => lib.externalKey === externalLibraryId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
52
server/src/external/ExternalApiModule.ts
vendored
Normal file
52
server/src/external/ExternalApiModule.ts
vendored
Normal 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
120
server/src/external/MediaSourceApiClient.ts
vendored
Normal file
120
server/src/external/MediaSourceApiClient.ts
vendored
Normal 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']>>;
|
||||
}
|
||||
226
server/src/external/MediaSourceApiFactory.ts
vendored
226
server/src/external/MediaSourceApiFactory.ts
vendored
@@ -8,20 +8,18 @@ import dayjs from 'dayjs';
|
||||
import { inject, injectable, LazyServiceIdentifier } from 'inversify';
|
||||
import { forEach, isBoolean, isEmpty, isNil } from 'lodash-es';
|
||||
import NodeCache from 'node-cache';
|
||||
import { MarkRequired } from 'ts-essentials';
|
||||
import type { ISettingsDB } from '../db/interfaces/ISettingsDB.ts';
|
||||
import { MediaSourceId } from '../db/schema/base.ts';
|
||||
import { MediaSourceWithLibraries } from '../db/schema/derivedTypes.js';
|
||||
import { KEYS } from '../types/inject.ts';
|
||||
import { Result } from '../types/result.ts';
|
||||
import { cacheGetOrSet } from '../util/cache.ts';
|
||||
import { Logger } from '../util/logging/LoggerFactory.ts';
|
||||
import {
|
||||
isQueryError,
|
||||
type ApiClientOptions,
|
||||
type BaseApiClient,
|
||||
} from './BaseApiClient.js';
|
||||
import { type ApiClientOptions } from './BaseApiClient.js';
|
||||
import { EmbyApiClient } from './emby/EmbyApiClient.ts';
|
||||
import { JellyfinApiClient } from './jellyfin/JellyfinApiClient.js';
|
||||
import { PlexApiClient } from './plex/PlexApiClient.js';
|
||||
import { MediaSourceApiClientFactory } from './MediaSourceApiClient.ts';
|
||||
import { PlexApiClient, PlexApiClientFactory } from './plex/PlexApiClient.js';
|
||||
|
||||
type TypeToClient = [
|
||||
[typeof MediaSourceType.Plex, PlexApiClient],
|
||||
@@ -45,6 +43,12 @@ export class MediaSourceApiFactory {
|
||||
@inject(new LazyServiceIdentifier(() => MediaSourceDB))
|
||||
private mediaSourceDB: MediaSourceDB,
|
||||
@inject(KEYS.SettingsDB) private settings: ISettingsDB,
|
||||
@inject(KEYS.PlexApiClientFactory)
|
||||
private plexApiClientFactory: PlexApiClientFactory,
|
||||
@inject(KEYS.JellyfinApiClientFactory)
|
||||
private jellyfinApiClientFactory: MediaSourceApiClientFactory<JellyfinApiClient>,
|
||||
@inject(KEYS.EmbyApiClientFactory)
|
||||
private embyApiClientFactory: MediaSourceApiClientFactory<EmbyApiClient>,
|
||||
) {
|
||||
this.#requestCacheEnabled =
|
||||
settings.systemSettings().cache?.enablePlexRequestCache ?? false;
|
||||
@@ -60,88 +64,95 @@ export class MediaSourceApiFactory {
|
||||
});
|
||||
}
|
||||
|
||||
getJellyfinApiClientForMediaSource(mediaSource: MediaSource) {
|
||||
return this.getJellyfinApiClient(mediaSourceToApiOptions(mediaSource));
|
||||
getJellyfinApiClientForMediaSource(mediaSource: MediaSourceWithLibraries) {
|
||||
return this.getJellyfinApiClient({ mediaSource });
|
||||
}
|
||||
|
||||
getJellyfinApiClient(opts: ApiClientOptions) {
|
||||
return this.getTyped(MediaSourceType.Jellyfin, opts, (opts) => {
|
||||
return Promise.resolve(new JellyfinApiClient(opts));
|
||||
});
|
||||
getJellyfinApiClient(opts: ApiClientOptions): Promise<JellyfinApiClient> {
|
||||
const client = this.jellyfinApiClientFactory(opts);
|
||||
client.setApiClientOptions(opts);
|
||||
return Promise.resolve(client);
|
||||
}
|
||||
|
||||
getEmbyApiClientForMediaSource(mediaSource: MediaSource) {
|
||||
return this.getEmbyApiClient(mediaSourceToApiOptions(mediaSource));
|
||||
getEmbyApiClientForMediaSource(mediaSource: MediaSourceWithLibraries) {
|
||||
return this.getEmbyApiClient({ mediaSource });
|
||||
}
|
||||
|
||||
getEmbyApiClient(opts: ApiClientOptions) {
|
||||
return this.getTyped(MediaSourceType.Jellyfin, opts, async (opts) => {
|
||||
let userId = opts.userId;
|
||||
let username: Maybe<string>;
|
||||
if (isEmpty(userId)) {
|
||||
this.logger.warn(
|
||||
'Emby connection does not have a user ID set. This could lead to errors. Please reconnect Emby.',
|
||||
);
|
||||
const adminResult = await Result.attemptAsync(() =>
|
||||
EmbyApiClient.findAdminUser(opts, opts.accessToken),
|
||||
);
|
||||
async getEmbyApiClient(opts: ApiClientOptions) {
|
||||
let userId = opts.mediaSource.userId;
|
||||
let username: Maybe<string>;
|
||||
if (isEmpty(userId)) {
|
||||
this.logger.warn(
|
||||
'Emby connection does not have a user ID set. This could lead to errors. Please reconnect Emby.',
|
||||
);
|
||||
const adminResult = await Result.attemptAsync(() =>
|
||||
EmbyApiClient.findAdminUser(opts, opts.mediaSource.accessToken),
|
||||
);
|
||||
|
||||
adminResult
|
||||
.filter((res) => isNonEmptyString(res?.Id))
|
||||
.forEach((adminUser) => {
|
||||
userId = adminUser!.Id!;
|
||||
username = adminUser!.Name ?? undefined;
|
||||
});
|
||||
}
|
||||
adminResult
|
||||
.filter((res) => isNonEmptyString(res?.Id))
|
||||
.forEach((adminUser) => {
|
||||
userId = adminUser!.Id!;
|
||||
username = adminUser!.Name ?? undefined;
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isNonEmptyString(opts.mediaSourceUuid) &&
|
||||
(isEmpty(opts.userId) ||
|
||||
opts.userId !== userId ||
|
||||
isEmpty(opts.username) ||
|
||||
opts.username != username)
|
||||
) {
|
||||
this.mediaSourceDB
|
||||
.setMediaSourceUserInfo(opts.mediaSourceUuid, {
|
||||
userId: userId ?? undefined,
|
||||
username,
|
||||
})
|
||||
.catch((e) => {
|
||||
this.logger.error(
|
||||
e,
|
||||
'Error updating Jellyfin media source user info',
|
||||
);
|
||||
});
|
||||
}
|
||||
if (
|
||||
isNonEmptyString(opts.mediaSource.uuid) &&
|
||||
(isEmpty(opts.mediaSource.userId) ||
|
||||
opts.mediaSource.userId !== userId ||
|
||||
isEmpty(opts.mediaSource.username) ||
|
||||
opts.mediaSource.username != username)
|
||||
) {
|
||||
this.mediaSourceDB
|
||||
.setMediaSourceUserInfo(opts.mediaSource.uuid, {
|
||||
userId: userId ?? undefined,
|
||||
username,
|
||||
})
|
||||
.catch((e) => {
|
||||
this.logger.error(
|
||||
e,
|
||||
'Error updating Jellyfin media source user info',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return new EmbyApiClient({ ...opts, userId });
|
||||
return this.embyApiClientFactory({
|
||||
...opts,
|
||||
mediaSource: { ...opts.mediaSource, userId },
|
||||
});
|
||||
}
|
||||
|
||||
getPlexApiClientForMediaSource(
|
||||
mediaSource: MediaSource,
|
||||
mediaSource: MediaSourceWithLibraries,
|
||||
): Promise<PlexApiClient> {
|
||||
const opts = mediaSourceToApiOptions(mediaSource);
|
||||
return this.getPlexApiClient(opts);
|
||||
// const opts = mediaSourceToApiOptions(mediaSource);
|
||||
return this.getPlexApiClient({ mediaSource });
|
||||
}
|
||||
|
||||
getPlexApiClient(opts: ApiClientOptions): Promise<PlexApiClient> {
|
||||
const key = `${opts.url}|${opts.accessToken}`;
|
||||
return cacheGetOrSet(MediaSourceApiFactory.cache, key, () => {
|
||||
return Promise.resolve(
|
||||
new PlexApiClient({
|
||||
...opts,
|
||||
enableRequestCache: this.requestCacheEnabledForServer(opts.name),
|
||||
}),
|
||||
);
|
||||
});
|
||||
// const key = `${opts.url}|${opts.accessToken}`;
|
||||
// const client = await cacheGetOrSet(MediaSourceApiFactory.cache, key, () => {
|
||||
// return Promise.resolve(
|
||||
// ,
|
||||
// );
|
||||
// });
|
||||
// client.setApiClientOptions(opts);
|
||||
// return client;
|
||||
return Promise.resolve(
|
||||
this.plexApiClientFactory({
|
||||
...opts,
|
||||
enableRequestCache: this.requestCacheEnabledForServer(
|
||||
opts.mediaSource.name,
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async getPlexApiClientByName(name: string) {
|
||||
async getPlexApiClientById(name: MediaSourceId) {
|
||||
return this.getTypedByName(MediaSourceType.Plex, name, (mediaSource) => {
|
||||
const client = new PlexApiClient({
|
||||
...mediaSource,
|
||||
url: mediaSource.uri,
|
||||
const client = this.plexApiClientFactory({
|
||||
mediaSource,
|
||||
enableRequestCache: this.requestCacheEnabledForServer(mediaSource.name),
|
||||
});
|
||||
|
||||
@@ -154,29 +165,25 @@ export class MediaSourceApiFactory {
|
||||
});
|
||||
}
|
||||
|
||||
async getJellyfinApiClientByName(name: string, userId?: string) {
|
||||
return this.getTypedByName(
|
||||
MediaSourceType.Jellyfin,
|
||||
name,
|
||||
(opts) =>
|
||||
new JellyfinApiClient({
|
||||
async getJellyfinApiClientById(name: MediaSourceId, userId?: string) {
|
||||
return this.getTypedByName(MediaSourceType.Jellyfin, name, (opts) =>
|
||||
this.jellyfinApiClientFactory({
|
||||
mediaSource: {
|
||||
...opts,
|
||||
url: opts.uri,
|
||||
userId: opts.userId ?? userId ?? null,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async getEmbyApiClientByName(name: string, userId?: string) {
|
||||
return this.getTypedByName(
|
||||
MediaSourceType.Emby,
|
||||
name,
|
||||
(opts) =>
|
||||
new EmbyApiClient({
|
||||
async getEmbyApiClientById(name: MediaSourceId, userId?: string) {
|
||||
return this.getTypedByName(MediaSourceType.Emby, name, (opts) =>
|
||||
this.embyApiClientFactory({
|
||||
mediaSource: {
|
||||
...opts,
|
||||
url: opts.uri,
|
||||
userId: opts.userId ?? userId ?? null,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,34 +192,13 @@ export class MediaSourceApiFactory {
|
||||
return MediaSourceApiFactory.cache.del(key) === 1;
|
||||
}
|
||||
|
||||
private async getTyped<
|
||||
Typ extends MediaSourceType,
|
||||
ApiClient = FindChild<Typ, TypeToClient>,
|
||||
ApiClientOptionsT extends
|
||||
ApiClientOptions = ApiClient extends BaseApiClient<infer Opts>
|
||||
? Opts extends ApiClientOptions
|
||||
? Opts
|
||||
: never
|
||||
: never,
|
||||
>(
|
||||
typ: Typ,
|
||||
opts: ApiClientOptionsT,
|
||||
factory: (opts: ApiClientOptionsT) => Promise<ApiClient>,
|
||||
): Promise<ApiClient> {
|
||||
return await cacheGetOrSet<ApiClient>(
|
||||
MediaSourceApiFactory.cache,
|
||||
this.getCacheKey(typ, opts.url, opts.accessToken),
|
||||
() => factory(opts),
|
||||
);
|
||||
}
|
||||
|
||||
private async getTypedByName<
|
||||
X extends MediaSourceType,
|
||||
ApiClient = FindChild<X, TypeToClient>,
|
||||
>(
|
||||
type: X,
|
||||
name: string,
|
||||
factory: (opts: MediaSource) => ApiClient,
|
||||
name: MediaSourceId,
|
||||
factory: (opts: MediaSourceWithLibraries) => ApiClient,
|
||||
): Promise<Maybe<ApiClient>> {
|
||||
const key = `${type}|${name}`;
|
||||
return cacheGetOrSet<Maybe<ApiClient>>(
|
||||
@@ -247,19 +233,21 @@ export class MediaSourceApiFactory {
|
||||
}
|
||||
|
||||
private async backfillPlexUserId(
|
||||
mediaSourceId: string,
|
||||
mediaSourceId: MediaSourceId,
|
||||
client: PlexApiClient,
|
||||
) {
|
||||
this.logger.debug('Attempting to backfill Plex user');
|
||||
const result = await Result.attemptAsync(async () => {
|
||||
const user = await client.getUser();
|
||||
if (isQueryError(user)) {
|
||||
throw new Error(user.message);
|
||||
const userResult = await client.getUser();
|
||||
if (userResult.isFailure()) {
|
||||
throw userResult.error;
|
||||
}
|
||||
|
||||
const user = userResult.get();
|
||||
|
||||
await this.mediaSourceDB.setMediaSourceUserInfo(mediaSourceId, {
|
||||
userId: user.data.id?.toString(),
|
||||
username: user.data.username,
|
||||
userId: user.id?.toString(),
|
||||
username: user.username,
|
||||
});
|
||||
});
|
||||
if (result.isFailure()) {
|
||||
@@ -270,13 +258,3 @@ export class MediaSourceApiFactory {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function mediaSourceToApiOptions(
|
||||
mediaSource: MediaSource,
|
||||
): MarkRequired<ApiClientOptions, 'mediaSourceUuid'> {
|
||||
return {
|
||||
...mediaSource,
|
||||
url: mediaSource.uri,
|
||||
mediaSourceUuid: mediaSource.uuid,
|
||||
};
|
||||
}
|
||||
|
||||
1276
server/src/external/emby/EmbyApiClient.ts
vendored
1276
server/src/external/emby/EmbyApiClient.ts
vendored
File diff suppressed because it is too large
Load Diff
1297
server/src/external/jellyfin/JellyfinApiClient.ts
vendored
1297
server/src/external/jellyfin/JellyfinApiClient.ts
vendored
File diff suppressed because it is too large
Load Diff
124
server/src/external/jellyfin/JellyfinItemFinder.ts
vendored
124
server/src/external/jellyfin/JellyfinItemFinder.ts
vendored
@@ -1,17 +1,21 @@
|
||||
import { ProgramMinterFactory } from '@/db/converters/ProgramMinter.js';
|
||||
import { ProgramDaoMinter } from '@/db/converters/ProgramMinter.js';
|
||||
import type { IProgramDB } from '@/db/interfaces/IProgramDB.js';
|
||||
import { ProgramType } from '@/db/schema/Program.js';
|
||||
import type { ProgramWithExternalIds } from '@/db/schema/derivedTypes.js';
|
||||
import { isQueryError } from '@/external/BaseApiClient.js';
|
||||
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
|
||||
import { GlobalScheduler } from '@/services/Scheduler.js';
|
||||
import { ReconcileProgramDurationsTask } from '@/tasks/ReconcileProgramDurationsTask.js';
|
||||
import { KEYS } from '@/types/inject.js';
|
||||
import { Maybe } from '@/types/util.js';
|
||||
import { groupByUniq, isDefined, run } from '@/util/index.js';
|
||||
import { groupByUniq, isDefined, isNonEmptyString, run } from '@/util/index.js';
|
||||
import { type Logger } from '@/util/logging/LoggerFactory.js';
|
||||
import { JellyfinItem, JellyfinItemKind } from '@tunarr/types/jellyfin';
|
||||
import { inject, injectable } from 'inversify';
|
||||
import {
|
||||
inject,
|
||||
injectable,
|
||||
interfaces,
|
||||
LazyServiceIdentifier,
|
||||
} from 'inversify';
|
||||
import { find, isUndefined, some } from 'lodash-es';
|
||||
import { match } from 'ts-pattern';
|
||||
import { container } from '../../container.ts';
|
||||
@@ -21,6 +25,7 @@ import {
|
||||
} from '../../db/custom_types/ProgramExternalIdType.ts';
|
||||
import { MediaSourceDB } from '../../db/mediaSourceDB.ts';
|
||||
import { MediaSourceType } from '../../db/schema/MediaSource.ts';
|
||||
import { MediaSourceId } from '../../db/schema/base.ts';
|
||||
import { ReconcileProgramDurationsTaskFactory } from '../../tasks/TasksModule.ts';
|
||||
import { JellyfinGetItemsQuery } from './JellyfinApiClient.ts';
|
||||
|
||||
@@ -29,9 +34,11 @@ export class JellyfinItemFinder {
|
||||
constructor(
|
||||
@inject(KEYS.ProgramDB) private programDB: IProgramDB,
|
||||
@inject(KEYS.Logger) private logger: Logger,
|
||||
@inject(MediaSourceApiFactory)
|
||||
@inject(new LazyServiceIdentifier(() => MediaSourceApiFactory))
|
||||
private mediaSourceApiFactory: MediaSourceApiFactory,
|
||||
@inject(MediaSourceDB) private mediaSourceDB: MediaSourceDB,
|
||||
@inject(KEYS.ProgramDaoMinterFactory)
|
||||
private programMinterFactory: interfaces.AutoFactory<ProgramDaoMinter>,
|
||||
) {}
|
||||
|
||||
async findForProgramAndUpdate(programId: string) {
|
||||
@@ -53,7 +60,7 @@ export class JellyfinItemFinder {
|
||||
(eid) => eid.sourceType === ProgramExternalIdType.JELLYFIN,
|
||||
);
|
||||
|
||||
const minter = ProgramMinterFactory.create();
|
||||
const minter = this.programMinterFactory();
|
||||
const newExternalId = minter.mintJellyfinExternalIdForApiItem(
|
||||
program.externalSourceId,
|
||||
program.uuid,
|
||||
@@ -69,25 +76,41 @@ export class JellyfinItemFinder {
|
||||
// Right now just check if the durations are different.
|
||||
// otherwise we might blow away details we already have, since
|
||||
// Jellyfin collects metadata asynchronously (sometimes)
|
||||
const mediaSourceId =
|
||||
program.mediaSourceId ??
|
||||
(await run(async () => {
|
||||
const ms = await this.findMediaSource(program.externalSourceId);
|
||||
if (!ms)
|
||||
throw new Error(
|
||||
`Could not find media source by name: ${program.externalSourceId}`,
|
||||
);
|
||||
return ms.uuid;
|
||||
}));
|
||||
const updatedProgram = minter.mint(
|
||||
program.externalSourceId,
|
||||
mediaSourceId,
|
||||
{
|
||||
sourceType: 'jellyfin',
|
||||
program: potentialApiMatch,
|
||||
},
|
||||
const mediaSource = await run(async () => {
|
||||
if (!isNonEmptyString(program.mediaSourceId)) {
|
||||
throw new Error(`Program ${program.uuid} has no media source ID`);
|
||||
}
|
||||
|
||||
const ms = await this.findMediaSource(program.mediaSourceId);
|
||||
|
||||
if (!ms)
|
||||
throw new Error(
|
||||
`Could not find media source by name: ${program.externalSourceId}`,
|
||||
);
|
||||
return ms;
|
||||
});
|
||||
|
||||
if (!program.libraryId) {
|
||||
throw new Error(
|
||||
'Cannot find JF item match without a library ID. Consider syncing the library the missing item belongs to.',
|
||||
);
|
||||
}
|
||||
|
||||
const library = mediaSource.libraries.find(
|
||||
(lib) => lib.uuid === program.libraryId,
|
||||
);
|
||||
|
||||
if (!library) {
|
||||
throw new Error(
|
||||
`Cannot find matching library for program. Library ID = ${program.libraryId}. Maybe the library was deleted?`,
|
||||
);
|
||||
}
|
||||
|
||||
const updatedProgram = minter.mint(mediaSource, library, {
|
||||
sourceType: 'jellyfin',
|
||||
program: potentialApiMatch,
|
||||
});
|
||||
|
||||
if (updatedProgram.duration !== program.duration) {
|
||||
await this.programDB.updateProgramDuration(
|
||||
program.uuid,
|
||||
@@ -121,22 +144,29 @@ export class JellyfinItemFinder {
|
||||
return;
|
||||
}
|
||||
|
||||
const jfClient =
|
||||
await this.mediaSourceApiFactory.getJellyfinApiClientByName(
|
||||
program.externalSourceId,
|
||||
if (!isNonEmptyString(program.mediaSourceId)) {
|
||||
this.logger.error(
|
||||
'Program %s does not have an associated media source ID',
|
||||
program.uuid,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const jfClient = await this.mediaSourceApiFactory.getJellyfinApiClientById(
|
||||
program.mediaSourceId,
|
||||
);
|
||||
|
||||
if (!jfClient) {
|
||||
this.logger.error(
|
||||
"Couldn't get jellyfin api client for id: %s",
|
||||
program.externalSourceId,
|
||||
program.mediaSourceId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we can locate the item on JF, there is no problem.
|
||||
const existingItem = await jfClient.getItem(program.externalKey);
|
||||
if (!isQueryError(existingItem) && isDefined(existingItem.data)) {
|
||||
if (existingItem.isSuccess() && isDefined(existingItem.get())) {
|
||||
this.logger.error(
|
||||
existingItem,
|
||||
'Item exists on Jellyfin - no need to find a new match',
|
||||
@@ -181,7 +211,7 @@ export class JellyfinItemFinder {
|
||||
.with(ProgramType.OtherVideo, () => 'Video')
|
||||
.exhaustive();
|
||||
|
||||
const queryResult = await jfClient.getItems(
|
||||
const queryResult = await jfClient.getRawItems(
|
||||
null,
|
||||
[jellyfinItemType],
|
||||
[],
|
||||
@@ -189,22 +219,24 @@ export class JellyfinItemFinder {
|
||||
opts,
|
||||
);
|
||||
|
||||
if (queryResult.type === 'success') {
|
||||
return find(queryResult.data.Items, (match) =>
|
||||
some(
|
||||
match.ProviderIds,
|
||||
(val, key) =>
|
||||
programExternalIdTypeFromJellyfinProvider(key) === type &&
|
||||
val === idsBySourceType[type].externalKey,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
this.logger.error(
|
||||
{ error: queryResult },
|
||||
'Error while querying items on Jellyfin',
|
||||
);
|
||||
}
|
||||
return queryResult.either(
|
||||
(data) => {
|
||||
return find(data.Items, (match) =>
|
||||
some(
|
||||
match.ProviderIds,
|
||||
(val, key) =>
|
||||
programExternalIdTypeFromJellyfinProvider(key) === type &&
|
||||
val === idsBySourceType[type].externalKey,
|
||||
),
|
||||
);
|
||||
},
|
||||
(err) => {
|
||||
this.logger.error(err, 'Error while querying items on Jellyfin');
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -231,10 +263,10 @@ export class JellyfinItemFinder {
|
||||
return possibleMatch;
|
||||
}
|
||||
|
||||
private findMediaSource(mediaSourceName: string) {
|
||||
private findMediaSource(mediaSourceId: MediaSourceId) {
|
||||
return this.mediaSourceDB.findByType(
|
||||
MediaSourceType.Jellyfin,
|
||||
mediaSourceName,
|
||||
mediaSourceId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
1641
server/src/external/plex/PlexApiClient.ts
vendored
1641
server/src/external/plex/PlexApiClient.ts
vendored
File diff suppressed because it is too large
Load Diff
3
server/src/external/plex/PlexQueryCache.ts
vendored
3
server/src/external/plex/PlexQueryCache.ts
vendored
@@ -1,5 +1,4 @@
|
||||
import type { QueryResult } from '@/external/BaseApiClient.js';
|
||||
import { isQueryError, isQuerySuccess } from '@/external/BaseApiClient.js';
|
||||
import { isDefined } from '@/util/index.js';
|
||||
import NodeCache from 'node-cache';
|
||||
|
||||
@@ -44,7 +43,7 @@ export class PlexQueryCache {
|
||||
}
|
||||
|
||||
const value = await getter();
|
||||
if (isQuerySuccess(value) || (isQueryError(value) && opts?.setOnError)) {
|
||||
if (value.isSuccess() || opts?.setOnError) {
|
||||
this.#cache.set(key, value);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import { exec, spawn } from 'node:child_process';
|
||||
import events from 'node:events';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { WritableOptions } from 'node:stream';
|
||||
import stream from 'node:stream';
|
||||
import type stream from 'node:stream';
|
||||
import { LastNBytesStream } from '../util/LastNBytesStream.ts';
|
||||
|
||||
export type FfmpegEvents = {
|
||||
// Emitted when the process ended with a code === 0, i.e. it exited
|
||||
@@ -258,52 +258,3 @@ export class FfmpegProcess extends (events.EventEmitter as new () => TypedEventE
|
||||
return this.ffmpegArgs;
|
||||
}
|
||||
}
|
||||
|
||||
type LastNBytesStreamOpts = WritableOptions & {
|
||||
bufSizeBytes?: number;
|
||||
};
|
||||
|
||||
class LastNBytesStream extends stream.Writable {
|
||||
public bufSizeBytes!: number;
|
||||
#bytesWritten = 0;
|
||||
#buf: Buffer;
|
||||
|
||||
constructor(options?: LastNBytesStreamOpts) {
|
||||
super(options);
|
||||
this.bufSizeBytes = options?.bufSizeBytes ?? 1024;
|
||||
this.#buf = Buffer.alloc(this.bufSizeBytes);
|
||||
}
|
||||
|
||||
_write(
|
||||
chunk: Buffer,
|
||||
_encoding: BufferEncoding,
|
||||
callback: (error?: Error | null) => void,
|
||||
): void {
|
||||
const chunkLength = chunk.length;
|
||||
if (chunkLength >= this.bufSizeBytes) {
|
||||
// If the chunk is larger than or equal to the buffer, just take the last 1KB
|
||||
chunk.copy(this.#buf, 0, chunkLength - this.bufSizeBytes, chunkLength);
|
||||
this.#bytesWritten = this.bufSizeBytes;
|
||||
} else {
|
||||
// If the chunk is smaller, shift existing buffer content and append
|
||||
const remainingSpace = this.bufSizeBytes - this.#bytesWritten;
|
||||
|
||||
if (chunkLength <= remainingSpace) {
|
||||
// Chunk fits in the remaining space
|
||||
chunk.copy(this.#buf, this.#bytesWritten);
|
||||
this.#bytesWritten += chunkLength;
|
||||
} else {
|
||||
// Chunk doesn't fit completely, overwrite from the beginning
|
||||
chunk.copy(this.#buf, this.#bytesWritten, 0, remainingSpace);
|
||||
chunk.copy(this.#buf, 0, remainingSpace, chunkLength);
|
||||
this.#bytesWritten = this.bufSizeBytes;
|
||||
}
|
||||
}
|
||||
|
||||
callback();
|
||||
}
|
||||
|
||||
getLastN() {
|
||||
return this.#buf.subarray(0, this.#bytesWritten);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export class SubtitleStreamPicker {
|
||||
if (stream.languageCodeISO6392 !== pref.languageCode) {
|
||||
this.logger.debug(
|
||||
'Skipping subtitle index %d, not a language match',
|
||||
stream.index,
|
||||
stream.index ?? -1,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -86,13 +86,13 @@ export class SubtitleStreamPicker {
|
||||
if (pref.filterType === 'forced' && !stream.forced) {
|
||||
this.logger.debug(
|
||||
'Skipping subtitle index %d, wanted forced',
|
||||
stream.index,
|
||||
stream.index ?? -1,
|
||||
);
|
||||
continue;
|
||||
} else if (pref.filterType === 'default' && !stream.default) {
|
||||
this.logger.debug(
|
||||
'Skipping subtitle index %d, wanted default',
|
||||
stream.index,
|
||||
stream.index ?? -1,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -101,7 +101,7 @@ export class SubtitleStreamPicker {
|
||||
if (!pref.allowExternal && stream.type === 'external') {
|
||||
this.logger.debug(
|
||||
'Skipping subtitle index %d, disallowed external',
|
||||
stream.index,
|
||||
stream.index ?? -1,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -109,7 +109,7 @@ export class SubtitleStreamPicker {
|
||||
if (!pref.allowImageBased && isImageBasedSubtitle(stream.codec)) {
|
||||
this.logger.debug(
|
||||
'Skipping subtitle index %d, disallowed image-based',
|
||||
stream.index,
|
||||
stream.index ?? -1,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -150,9 +150,9 @@ export class SubtitleStreamPicker {
|
||||
|
||||
if (!filePath) {
|
||||
this.logger.debug(
|
||||
'Unsupported subtitle codec at index %d: %s',
|
||||
stream.index,
|
||||
stream.codec,
|
||||
'Unsupported subtitle codec at index %d: codec = %s',
|
||||
stream.index ?? -1,
|
||||
stream.codec ?? 'unkonwn',
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -161,7 +161,7 @@ export class SubtitleStreamPicker {
|
||||
if (!(await fileExists(fullPath))) {
|
||||
this.logger.debug(
|
||||
'Subtitle stream at index %d has not been extracted yet.',
|
||||
stream.index,
|
||||
stream.index ?? -1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -158,10 +158,7 @@ export class PipelineBuilderContext {
|
||||
merge(this, props);
|
||||
}
|
||||
|
||||
isSubtitleOverlay(): this is MarkRequired<
|
||||
PipelineBuilderContext,
|
||||
'subtitleStream'
|
||||
> {
|
||||
isSubtitleOverlay(): boolean {
|
||||
return (
|
||||
(this.subtitleStream?.isImageBased &&
|
||||
this.subtitleStream?.method === SubtitleMethods.Burn) ??
|
||||
@@ -169,10 +166,7 @@ export class PipelineBuilderContext {
|
||||
);
|
||||
}
|
||||
|
||||
isSubtitleTextContext(): this is MarkRequired<
|
||||
PipelineBuilderContext,
|
||||
'subtitleStream'
|
||||
> {
|
||||
isSubtitleTextContext(): boolean {
|
||||
return (
|
||||
(this.subtitleStream &&
|
||||
!this.subtitleStream.isImageBased &&
|
||||
@@ -524,7 +518,9 @@ export abstract class BasePipelineBuilder implements PipelineBuilder {
|
||||
this.desiredState.videoFormat !== VideoFormats.Copy
|
||||
) {
|
||||
this.decoder = this.setupDecoder();
|
||||
this.logger.debug('Setup decoder: %O', this.decoder);
|
||||
if (this.decoder) {
|
||||
this.logger.debug('Setup decoder: %O', this.decoder);
|
||||
}
|
||||
}
|
||||
|
||||
this.setRealtime();
|
||||
|
||||
@@ -33,6 +33,8 @@ import Migration1746123876_ReworkSubtitleFilter from './db/Migration1746123876_R
|
||||
import Migration1746128022_FixSubtitlePriorityType from './db/Migration1746128022_FixSubtitlePriorityType.ts';
|
||||
import Migration1748345299_AddMoreProgramTypes from './db/Migration1748345299_AddMoreProgramTypes.ts';
|
||||
import Migration1756312561_InitialAdvancedTranscodeConfig from './db/Migration1756312561_InitialAdvancedTranscodeConfig.ts';
|
||||
import Migration1756381281_AddLibraries from './db/Migration1756381281_AddLibraries.ts';
|
||||
import Migration1757704591_AddProgramMediaSourceIndex from './db/Migration1757704591_AddProgramMediaSourceIndex.ts';
|
||||
|
||||
export const LegacyMigrationNameToNewMigrationName = [
|
||||
['Migration20240124115044', '_Legacy_Migration00'],
|
||||
@@ -113,6 +115,8 @@ export class DirectMigrationProvider implements MigrationProvider {
|
||||
migration1748345299: Migration1748345299_AddMoreProgramTypes,
|
||||
migration1756312561:
|
||||
Migration1756312561_InitialAdvancedTranscodeConfig,
|
||||
migration1756381281: Migration1756381281_AddLibraries,
|
||||
migration1757704591: Migration1757704591_AddProgramMediaSourceIndex,
|
||||
},
|
||||
wrapWithTransaction,
|
||||
),
|
||||
|
||||
14
server/src/migration/db/Migration1756381281_AddLibraries.ts
Normal file
14
server/src/migration/db/Migration1756381281_AddLibraries.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
26
server/src/migration/db/sql/0004_opposite_vivisector.sql
Normal file
26
server/src/migration/db/sql/0004_opposite_vivisector.sql
Normal 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);
|
||||
26
server/src/migration/db/sql/0010_lazy_nova.sql
Normal file
26
server/src/migration/db/sql/0010_lazy_nova.sql
Normal 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);
|
||||
@@ -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
Reference in New Issue
Block a user