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:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
base_ref:
|
||||||
|
description: "Ref to build pre-release from"
|
||||||
|
required: true
|
||||||
|
default: 'dev'
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- media-scanner
|
- media-scanner
|
||||||
|
|||||||
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_major_version }}
|
||||||
echo ${{ steps.semantic.outputs.new_release_minor_version }}
|
echo ${{ steps.semantic.outputs.new_release_minor_version }}
|
||||||
echo ${{ steps.semantic.outputs.new_release_patch_version }}
|
echo ${{ steps.semantic.outputs.new_release_patch_version }}
|
||||||
echo ${{ steps.semantic.outputs.new_release_prerelease_version }}
|
echo ${{ steps.semantic.outputs.new_release_prerelease_version }}
|
||||||
|
echo ${{ steps.semantic.outputs.new_release_git_tag }}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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-import-resolver-typescript": "^3.7.0",
|
||||||
"eslint-plugin-import": "^2.31.0",
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"eslint-plugin-react": "^7.37.3",
|
"eslint-plugin-react": "^7.37.3",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.16",
|
"eslint-plugin-react-refresh": "^0.4.16",
|
||||||
"eslint-plugin-unused-imports": "^4.1.4",
|
"eslint-plugin-unused-imports": "^4.1.4",
|
||||||
"globals": "^15.0.0",
|
"globals": "^15.0.0",
|
||||||
@@ -48,7 +48,6 @@
|
|||||||
"packageManager": "pnpm@9.12.3",
|
"packageManager": "pnpm@9.12.3",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"ts-essentials@9.4.1": "patches/ts-essentials@9.4.1.patch",
|
|
||||||
"kysely": "patches/kysely.patch"
|
"kysely": "patches/kysely.patch"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|||||||
3347
pnpm-lock.yaml
generated
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({
|
export default defineConfig({
|
||||||
dialect: 'sqlite',
|
dialect: 'sqlite',
|
||||||
schema: './src/db/schema/**/*.ts',
|
schema: './src/db/schema/**/*.ts',
|
||||||
|
out: './src/migration/db/sql',
|
||||||
casing: 'snake_case',
|
casing: 'snake_case',
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.TUNARR_DATABASE_PATH,
|
url: process.env.TUNARR_DATABASE_PATH,
|
||||||
|
|||||||
@@ -3,3 +3,9 @@ export const __import_meta_url =
|
|||||||
? new (require('url'.replace('', '')).URL)('file:' + __filename).href
|
? new (require('url'.replace('', '')).URL)('file:' + __filename).href
|
||||||
: (document.currentScript && document.currentScript.src) ||
|
: (document.currentScript && document.currentScript.src) ||
|
||||||
new URL('main.js', document.baseURI).href;
|
new URL('main.js', document.baseURI).href;
|
||||||
|
|
||||||
|
export const __import_meta_dirname =
|
||||||
|
typeof document === 'undefined'
|
||||||
|
? new (require('url'.replace('', '')).URL)('file:' + __dirname).href
|
||||||
|
: (document.currentScript && document.currentScript.src) ||
|
||||||
|
new URL('main.js', document.baseURI).href;
|
||||||
|
|||||||
@@ -12,12 +12,13 @@
|
|||||||
"build-dev": "cross-env NODE_ENV=development tsc -p tsconfig.build.json --noEmit --watch",
|
"build-dev": "cross-env NODE_ENV=development tsc -p tsconfig.build.json --noEmit --watch",
|
||||||
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 tsc -p tsconfig.build.json",
|
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 tsc -p tsconfig.build.json",
|
||||||
"bundle": "dotenv -- tsx scripts/bundle.ts",
|
"bundle": "dotenv -- tsx scripts/bundle.ts",
|
||||||
"clean": "rimraf --glob ./build/ ./dist/ ./src/generated/web-imports.ts ./src/generated/web-imports.js",
|
"make-bin": "dotenv -- tsx scripts/make-bin.ts",
|
||||||
|
"clean": "rimraf --glob ./build/ ./dist/ ./bin/tunar*",
|
||||||
"debug": "dotenv -e .env.development -- tsx watch --trace-warnings --tsconfig ./tsconfig.build.json --ignore 'src/streams' --inspect-wait ./src",
|
"debug": "dotenv -e .env.development -- tsx watch --trace-warnings --tsconfig ./tsconfig.build.json --ignore 'src/streams' --inspect-wait ./src",
|
||||||
"dev": "dotenv -e .env.development -- tsx watch --trace-warnings --tsconfig ./tsconfig.build.json --ignore 'build' --ignore 'src/streams' --ignore 'src/**/*.test.ts' ./src/index.ts",
|
"dev": "dotenv -e .env.development -- tsx watch --trace-warnings --tsconfig ./tsconfig.build.json --ignore 'build' --ignore 'src/streams' --ignore 'src/**/*.test.ts' ./src/index.ts",
|
||||||
"generate-openapi": "tsx src/index.ts generate-openapi",
|
"generate-openapi": "tsx src/index.ts generate-openapi",
|
||||||
|
"install-meilisearch": "tsx scripts/download-meilisearch.ts",
|
||||||
"kysely": "dotenv -e .env.development -- kysely",
|
"kysely": "dotenv -e .env.development -- kysely",
|
||||||
"make-bin": "dotenv -- tsx scripts/make-bin.ts",
|
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"run-fixer": "dotenv -e .env.development -- tsx src/index.ts fixer",
|
"run-fixer": "dotenv -e .env.development -- tsx src/index.ts fixer",
|
||||||
"test:watch": "vitest --watch",
|
"test:watch": "vitest --watch",
|
||||||
@@ -26,99 +27,106 @@
|
|||||||
"typecheck": "tsc -p tsconfig.build.json --noEmit"
|
"typecheck": "tsc -p tsconfig.build.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dotenvx/dotenvx": "^1.45.1",
|
"@dotenvx/dotenvx": "^1.49.0",
|
||||||
"@fastify/cors": "^10.0.1",
|
"@fastify/cors": "^10.1.0",
|
||||||
"@fastify/error": "^4.1.0",
|
"@fastify/error": "^4.2.0",
|
||||||
"@fastify/multipart": "^9.0.1",
|
"@fastify/multipart": "^9.0.3",
|
||||||
"@fastify/static": "^8.0.1",
|
"@fastify/static": "^8.2.0",
|
||||||
"@fastify/swagger": "^9.5.1",
|
"@fastify/swagger": "^9.5.1",
|
||||||
"@iptv/xmltv": "^1.0.1",
|
"@iptv/xmltv": "^1.0.1",
|
||||||
"@logdna/tail-file": "^4.0.2",
|
"@logdna/tail-file": "^4.0.2",
|
||||||
"@scalar/fastify-api-reference": "^1.25.106",
|
"@scalar/fastify-api-reference": "^1.34.6",
|
||||||
"@tunarr/playlist": "^1.1.0",
|
"@tunarr/playlist": "^1.1.0",
|
||||||
"@tunarr/shared": "workspace:*",
|
"@tunarr/shared": "workspace:*",
|
||||||
"@tunarr/types": "workspace:*",
|
"@tunarr/types": "workspace:*",
|
||||||
"@types/better-sqlite3": "^7.6.12",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"async-mutex": "^0.5.0",
|
"async-mutex": "^0.5.0",
|
||||||
"async-retry": "^1.3.3",
|
"async-retry": "^1.3.3",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
|
"base32": "^0.0.7",
|
||||||
"better-sqlite3": "11.8.1",
|
"better-sqlite3": "11.8.1",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.6.0",
|
||||||
"cron-parser": "^4.9.0",
|
"cron-parser": "^4.9.0",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.14",
|
||||||
"drizzle-orm": "^0.39.3",
|
"drizzle-orm": "^0.39.3",
|
||||||
"fast-xml-parser": "^4.3.5",
|
"fast-xml-parser": "^4.5.3",
|
||||||
"fastify": "^5.0.0",
|
"fastify": "^5.5.0",
|
||||||
"fastify-graceful-shutdown": "^4.0.1",
|
"fastify-graceful-shutdown": "^4.0.1",
|
||||||
"fastify-plugin": "^5.0.1",
|
"fastify-plugin": "^5.0.1",
|
||||||
"fastify-print-routes": "^3.2.0",
|
"fastify-print-routes": "^3.2.0",
|
||||||
"fastify-type-provider-zod": "^5.0.3",
|
"fastify-type-provider-zod": "^5.0.3",
|
||||||
"file-type": "^19.6.0",
|
"file-type": "^19.6.0",
|
||||||
|
"find-process": "^2.0.0",
|
||||||
"graphology": "^0.26.0",
|
"graphology": "^0.26.0",
|
||||||
"graphology-dag": "^0.4.1",
|
"graphology-dag": "^0.4.1",
|
||||||
"inversify": "^6.2.1",
|
"inversify": "^6.2.2",
|
||||||
"jsonpath-plus": "^10.3.0",
|
"jsonpath-plus": "^10.3.0",
|
||||||
"kysely": "^0.27.4",
|
"kysely": "^0.27.6",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"lowdb": "^7.0.0",
|
"lowdb": "^7.0.1",
|
||||||
|
"meilisearch": "^0.49.0",
|
||||||
"node-cache": "^5.1.2",
|
"node-cache": "^5.1.2",
|
||||||
"node-schedule": "^2.1.1",
|
"node-schedule": "^2.1.1",
|
||||||
"node-ssdp": "^4.0.0",
|
"node-ssdp": "^4.0.1",
|
||||||
"p-queue": "^8.0.1",
|
"p-queue": "^8.1.0",
|
||||||
"pino": "^9.0.0",
|
"pino": "^9.9.1",
|
||||||
"pino-pretty": "^11.2.2",
|
"pino-pretty": "^11.3.0",
|
||||||
"pino-roll": "^1.1.0",
|
"pino-roll": "^1.3.0",
|
||||||
"random-js": "2.1.0",
|
"random-js": "2.1.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"retry": "^0.13.1",
|
"retry": "^0.13.1",
|
||||||
"split2": "^4.2.0",
|
"split2": "^4.2.0",
|
||||||
"ts-pattern": "^5.4.0",
|
"ts-pattern": "^5.8.0",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.8.1",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"yargs": "^17.7.2",
|
"yargs": "^17.7.2",
|
||||||
"zod": "^4.0.17"
|
"zod": "^4.1.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@faker-js/faker": "^9.9.0",
|
"@faker-js/faker": "^9.9.0",
|
||||||
|
"@octokit/types": "^13.10.0",
|
||||||
"@rollup/plugin-swc": "^0.4.0",
|
"@rollup/plugin-swc": "^0.4.0",
|
||||||
"@types/archiver": "^6.0.2",
|
"@types/archiver": "^6.0.3",
|
||||||
"@types/async-retry": "^1.4.8",
|
"@types/async-retry": "^1.4.9",
|
||||||
"@types/lodash-es": "4.17.9",
|
"@types/lodash-es": "4.17.9",
|
||||||
"@types/node": "22.10.7",
|
"@types/node": "22.10.7",
|
||||||
"@types/node-abi": "^3.0.3",
|
"@types/node-abi": "^3.0.3",
|
||||||
"@types/node-schedule": "^2.1.3",
|
"@types/node-schedule": "^2.1.8",
|
||||||
"@types/retry": "^0.12.5",
|
"@types/retry": "^0.12.5",
|
||||||
"@types/split2": "^4.2.3",
|
"@types/split2": "^4.2.3",
|
||||||
"@types/tmp": "^0.2.6",
|
"@types/tmp": "^0.2.6",
|
||||||
"@types/unzip-stream": "^0.3.4",
|
"@types/unzip-stream": "^0.3.4",
|
||||||
"@types/uuid": "^9.0.6",
|
"@types/uuid": "^9.0.8",
|
||||||
"@types/yargs": "^17.0.29",
|
"@types/yargs": "^17.0.33",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"@yao-pkg/pkg": "^6.5.1",
|
"@yao-pkg/pkg": "^6.6.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"del-cli": "^3.0.0",
|
"del-cli": "^3.0.1",
|
||||||
"dotenv-cli": "^7.4.1",
|
"dotenv-cli": "^7.4.4",
|
||||||
"drizzle-kit": "^0.30.4",
|
"drizzle-kit": "^0.30.6",
|
||||||
"esbuild-plugin-pino": "^2.2.1",
|
"esbuild-plugin-pino": "^2.3.3",
|
||||||
"fast-check": "^4.2.0",
|
"fast-check": "^4.2.0",
|
||||||
"fast-glob": "^3.3.2",
|
"fast-glob": "^3.3.3",
|
||||||
"globals": "^15.0.0",
|
"globals": "^15.15.0",
|
||||||
"kysely-ctl": "^0.9.0",
|
"kysely-ctl": "^0.9.0",
|
||||||
"node-abi": "^3.74.0",
|
"node-abi": "^3.75.0",
|
||||||
"prettier": "^3.5.1",
|
"prettier": "^3.6.2",
|
||||||
"rimraf": "^5.0.5",
|
"rimraf": "^5.0.10",
|
||||||
"tar": "^7.4.3",
|
"tar": "^7.4.3",
|
||||||
"thread-stream": "^3.1.0",
|
"thread-stream": "^3.1.0",
|
||||||
"tmp": "^0.2.1",
|
"tmp": "^0.2.5",
|
||||||
"tmp-promise": "^3.0.3",
|
"tmp-promise": "^3.0.3",
|
||||||
"ts-essentials": "^10.0.0",
|
"ts-essentials": "^10.1.1",
|
||||||
"ts-mockito": "^2.6.1",
|
"ts-mockito": "^2.6.1",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.20.5",
|
||||||
"typed-emitter": "^2.1.0",
|
"typed-emitter": "^2.1.0",
|
||||||
"typescript": "5.7.3",
|
"typescript": "5.7.3",
|
||||||
"typescript-eslint": "^8.19.0",
|
"typescript-eslint": "^8.41.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
|
},
|
||||||
|
"meilisearch": {
|
||||||
|
"version": "1.15.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"pkg": {
|
"pkg": {
|
||||||
"assets": ["./dist/**/*"],
|
"assets": ["./dist/**/*"]
|
||||||
"outputPath": "dist/bin"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 path from 'node:path';
|
||||||
import stream from 'node:stream';
|
import stream from 'node:stream';
|
||||||
import { format } from 'node:util';
|
import { format } from 'node:util';
|
||||||
|
import { rimraf } from 'rimraf';
|
||||||
import * as tar from 'tar';
|
import * as tar from 'tar';
|
||||||
import tmp from 'tmp-promise';
|
import tmp from 'tmp-promise';
|
||||||
import yargs from 'yargs';
|
import yargs from 'yargs';
|
||||||
import { hideBin } from 'yargs/helpers';
|
import { hideBin } from 'yargs/helpers';
|
||||||
import serverPackage from '../package.json' with { type: 'json' };
|
import serverPackage from '../package.json' with { type: 'json' };
|
||||||
import { fileExists } from '../src/util/fsUtil.ts';
|
import { fileExists } from '../src/util/fsUtil.ts';
|
||||||
|
import { grabMeilisearch } from './download-meilisearch.ts';
|
||||||
|
|
||||||
const betterSqlite3ReleaseFmt =
|
const betterSqlite3ReleaseFmt =
|
||||||
'https://github.com/WiseLibs/better-sqlite3/releases/download/v%s/better-sqlite3-v%s-node-v%s-%s-%s.tar.gz';
|
'https://github.com/WiseLibs/better-sqlite3/releases/download/v%s/better-sqlite3-v%s-node-v%s-%s-%s.tar.gz';
|
||||||
@@ -73,10 +75,18 @@ const args = await yargs(hideBin(process.argv))
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
default: true,
|
default: true,
|
||||||
})
|
})
|
||||||
|
.option('clean', {
|
||||||
|
type: 'boolean',
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
.parseAsync();
|
.parseAsync();
|
||||||
|
|
||||||
!(await fileExists('./bin')) && (await fs.mkdir('./bin'));
|
!(await fileExists('./bin')) && (await fs.mkdir('./bin'));
|
||||||
|
|
||||||
|
if (args.clean) {
|
||||||
|
await rimraf('./bin/tunarr*', { glob: true });
|
||||||
|
}
|
||||||
|
|
||||||
(await fileExists('./dist/web')) &&
|
(await fileExists('./dist/web')) &&
|
||||||
(await fs.rm('./dist/web', { recursive: true }));
|
(await fs.rm('./dist/web', { recursive: true }));
|
||||||
|
|
||||||
@@ -87,6 +97,12 @@ await fs.cp(path.resolve(process.cwd(), '../web/dist'), './dist/web', {
|
|||||||
recursive: true,
|
recursive: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await fs.cp(
|
||||||
|
path.resolve(process.cwd(), './src/migration/db/sql'),
|
||||||
|
'./dist/sql',
|
||||||
|
{ recursive: true },
|
||||||
|
);
|
||||||
|
|
||||||
const originalWorkingDir = process.cwd();
|
const originalWorkingDir = process.cwd();
|
||||||
|
|
||||||
console.log(`Going to build archs: ${args.target.join(' ')}`);
|
console.log(`Going to build archs: ${args.target.join(' ')}`);
|
||||||
@@ -108,6 +124,13 @@ for (const arch of args.target) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const meilisearchBinaryPath = await grabMeilisearch();
|
||||||
|
if (!meilisearchBinaryPath) {
|
||||||
|
throw new Error('Could not download Meilisearch binary');
|
||||||
|
} else {
|
||||||
|
console.log(`Meilisearch found at ${meilisearchBinaryPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Untar
|
// Untar
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const outstream = betterSqliteDlStream.data.pipe(
|
const outstream = betterSqliteDlStream.data.pipe(
|
||||||
@@ -164,6 +187,7 @@ for (const arch of args.target) {
|
|||||||
// Look into whether we want this sometimes...
|
// Look into whether we want this sometimes...
|
||||||
'--no-bytecode',
|
'--no-bytecode',
|
||||||
'--signature', // for macos arm64
|
'--signature', // for macos arm64
|
||||||
|
'--debug',
|
||||||
'-o',
|
'-o',
|
||||||
`dist/bin/${execName}`,
|
`dist/bin/${execName}`,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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);
|
const roundedTime = round(rep.elapsedTime, 4);
|
||||||
|
|
||||||
this.logger[req.routeOptions.config.logAtLevel ?? 'http'](
|
this.logger[req.routeOptions.config.logAtLevel ?? 'http'](
|
||||||
`${req.method} ${req.url} ${rep.statusCode} -${lengthStr}${roundedTime}ms`,
|
|
||||||
{
|
{
|
||||||
req: {
|
req: {
|
||||||
method: req.method,
|
method: req.method,
|
||||||
@@ -241,6 +240,7 @@ export class Server {
|
|||||||
elapsedTime: roundedTime,
|
elapsedTime: roundedTime,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
`${req.method} ${req.url} ${rep.statusCode} -${lengthStr}${roundedTime}ms`,
|
||||||
);
|
);
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
@@ -463,6 +463,8 @@ export class Server {
|
|||||||
this.logger.debug(e, 'Error sending shutdown signal to frontend');
|
this.logger.debug(e, 'Error sending shutdown signal to frontend');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.serverContext.searchService.stop();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.logger.debug('Pausing all on-demand channels');
|
this.logger.debug('Pausing all on-demand channels');
|
||||||
await this.serverContext.onDemandChannelService.pauseAllChannels();
|
await this.serverContext.onDemandChannelService.pauseAllChannels();
|
||||||
|
|||||||
@@ -21,9 +21,12 @@ import { FileCacheService } from './services/FileCacheService.ts';
|
|||||||
import { HdhrService } from './services/HDHRService.ts';
|
import { HdhrService } from './services/HDHRService.ts';
|
||||||
import { HealthCheckService } from './services/HealthCheckService.js';
|
import { HealthCheckService } from './services/HealthCheckService.js';
|
||||||
import { M3uService } from './services/M3UService.ts';
|
import { M3uService } from './services/M3UService.ts';
|
||||||
|
import { MediaSourceLibraryRefresher } from './services/MediaSourceLibraryRefresher.ts';
|
||||||
|
import { MeilisearchService } from './services/MeilisearchService.ts';
|
||||||
import { OnDemandChannelService } from './services/OnDemandChannelService.js';
|
import { OnDemandChannelService } from './services/OnDemandChannelService.js';
|
||||||
import { TVGuideService } from './services/TvGuideService.ts';
|
import { TVGuideService } from './services/TvGuideService.ts';
|
||||||
import { CacheImageService } from './services/cacheImageService.js';
|
import { CacheImageService } from './services/cacheImageService.js';
|
||||||
|
import { MediaSourceScanCoordinator } from './services/scanner/MediaSourceScanCoordinator.ts';
|
||||||
import { ChannelCache } from './stream/ChannelCache.js';
|
import { ChannelCache } from './stream/ChannelCache.js';
|
||||||
import { SessionManager } from './stream/SessionManager.js';
|
import { SessionManager } from './stream/SessionManager.js';
|
||||||
import { StreamProgramCalculator } from './stream/StreamProgramCalculator.js';
|
import { StreamProgramCalculator } from './stream/StreamProgramCalculator.js';
|
||||||
@@ -69,6 +72,15 @@ export class ServerContext {
|
|||||||
|
|
||||||
@inject(KEYS.WorkerPool)
|
@inject(KEYS.WorkerPool)
|
||||||
public readonly workerPool: IWorkerPool;
|
public readonly workerPool: IWorkerPool;
|
||||||
|
|
||||||
|
@inject(MeilisearchService)
|
||||||
|
public readonly searchService!: MeilisearchService;
|
||||||
|
|
||||||
|
@inject(MediaSourceScanCoordinator)
|
||||||
|
public readonly mediaSourceScanCoordinator: MediaSourceScanCoordinator;
|
||||||
|
|
||||||
|
@inject(MediaSourceLibraryRefresher)
|
||||||
|
public readonly mediaSourceLibraryRefresher: MediaSourceLibraryRefresher;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ServerRequestContext {
|
export class ServerRequestContext {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
|||||||
import { isDefined } from '@/util/index.js';
|
import { isDefined } from '@/util/index.js';
|
||||||
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
|
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
|
||||||
import { timeNamedAsync } from '@/util/perf.js';
|
import { timeNamedAsync } from '@/util/perf.js';
|
||||||
|
import { seq } from '@tunarr/shared/util';
|
||||||
import type { ChannelSession, CreateChannelRequest } from '@tunarr/types';
|
import type { ChannelSession, CreateChannelRequest } from '@tunarr/types';
|
||||||
import {
|
import {
|
||||||
BasicIdParamSchema,
|
BasicIdParamSchema,
|
||||||
@@ -66,7 +67,11 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
fastify.addHook('onError', (req, _, error, done) => {
|
fastify.addHook('onError', (req, _, error, done) => {
|
||||||
logger.error(error, '%s %s', req.routeOptions.method, req.routeOptions.url);
|
logger.error({
|
||||||
|
error,
|
||||||
|
method: req.routeOptions.method,
|
||||||
|
url: req.routeOptions.url,
|
||||||
|
});
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -247,6 +252,8 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
params: z.object({ id: z.string() }),
|
params: z.object({ id: z.string() }),
|
||||||
response: {
|
response: {
|
||||||
200: ChannelSchema,
|
200: ChannelSchema,
|
||||||
|
404: z.void(),
|
||||||
|
500: z.void(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -362,7 +369,7 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return res.send(
|
return res.send(
|
||||||
programs.map((program) =>
|
seq.collect(programs, (program) =>
|
||||||
req.serverCtx.programConverter.programDaoToContentProgram(
|
req.serverCtx.programConverter.programDaoToContentProgram(
|
||||||
program,
|
program,
|
||||||
program.externalIds ?? [],
|
program.externalIds ?? [],
|
||||||
@@ -395,6 +402,7 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
result: shows.map((show) =>
|
result: shows.map((show) =>
|
||||||
req.serverCtx.programConverter.tvShowDaoToDto(show),
|
req.serverCtx.programConverter.tvShowDaoToDto(show),
|
||||||
),
|
),
|
||||||
|
size: shows.length,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -422,6 +430,7 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
result: shows.map((show) =>
|
result: shows.map((show) =>
|
||||||
req.serverCtx.programConverter.musicArtistDaoToDto(show),
|
req.serverCtx.programConverter.musicArtistDaoToDto(show),
|
||||||
),
|
),
|
||||||
|
size: shows.length,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -566,7 +575,7 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const fallbacks =
|
const fallbacks =
|
||||||
await req.serverCtx.channelDB.getChannelFallbackPrograms(req.params.id);
|
await req.serverCtx.channelDB.getChannelFallbackPrograms(req.params.id);
|
||||||
const converted = map(fallbacks, (p) =>
|
const converted = seq.collect(fallbacks, (p) =>
|
||||||
req.serverCtx.programConverter.programDaoToContentProgram(p, []),
|
req.serverCtx.programConverter.programDaoToContentProgram(p, []),
|
||||||
);
|
);
|
||||||
return res.send(converted);
|
return res.send(converted);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { FfmpegStreamFactory } from '@/ffmpeg/FfmpegStreamFactory.js';
|
|||||||
import { MpegTsOutputFormat } from '@/ffmpeg/builder/constants.js';
|
import { MpegTsOutputFormat } from '@/ffmpeg/builder/constants.js';
|
||||||
import { FfprobeStreamDetails } from '@/stream/FfprobeStreamDetails.js';
|
import { FfprobeStreamDetails } from '@/stream/FfprobeStreamDetails.js';
|
||||||
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
||||||
|
import { tag } from '@tunarr/types';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
import { container } from '../../container.ts';
|
import { container } from '../../container.ts';
|
||||||
@@ -92,7 +93,7 @@ export const debugFfmpegApiRouter: RouterPluginAsyncCallback = async (
|
|||||||
streamDuration: +dayjs.duration({ seconds: 30 }),
|
streamDuration: +dayjs.duration({ seconds: 30 }),
|
||||||
externalKey: 'none',
|
externalKey: 'none',
|
||||||
externalSource: 'emby',
|
externalSource: 'emby',
|
||||||
externalSourceId: 'none',
|
externalSourceId: tag('none'),
|
||||||
programBeginMs: 0,
|
programBeginMs: 0,
|
||||||
programId: '',
|
programId: '',
|
||||||
programType: 'movie',
|
programType: 'movie',
|
||||||
@@ -115,7 +116,7 @@ export const debugFfmpegApiRouter: RouterPluginAsyncCallback = async (
|
|||||||
return res.status(500).send();
|
return res.status(500).send();
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = await req.serverCtx.mediaSourceDB.getByName(
|
const server = await req.serverCtx.mediaSourceDB.getById(
|
||||||
item.externalSourceId,
|
item.externalSourceId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { container } from '@/container.js';
|
import { container } from '@/container.js';
|
||||||
import { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
|
import type { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
|
||||||
import { JellyfinItemFinder } from '@/external/jellyfin/JellyfinItemFinder.js';
|
import { JellyfinItemFinder } from '@/external/jellyfin/JellyfinItemFinder.js';
|
||||||
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
||||||
import type { Nilable } from '@/types/util.js';
|
import type { Nilable } from '@/types/util.js';
|
||||||
|
import { tag } from '@tunarr/types';
|
||||||
import { isNil } from 'lodash-es';
|
import { isNil } from 'lodash-es';
|
||||||
|
import { v4 } from 'uuid';
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
|
import { MediaSourceType } from '../../db/schema/MediaSource.ts';
|
||||||
|
import type { MediaSourceApiClientFactory } from '../../external/MediaSourceApiClient.ts';
|
||||||
|
import { KEYS } from '../../types/inject.ts';
|
||||||
|
|
||||||
export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
|
export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
|
||||||
fastify,
|
fastify,
|
||||||
@@ -23,12 +28,19 @@ export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const client = new JellyfinApiClient({
|
const client = container.get<
|
||||||
url: req.query.uri,
|
MediaSourceApiClientFactory<JellyfinApiClient>
|
||||||
accessToken: req.query.apiKey,
|
>(KEYS.JellyfinApiClientFactory)({
|
||||||
userId: req.query.userId ?? null,
|
mediaSource: {
|
||||||
name: 'debug',
|
uri: req.query.uri,
|
||||||
username: null,
|
accessToken: req.query.apiKey,
|
||||||
|
userId: req.query.userId ?? null,
|
||||||
|
name: tag('debug'),
|
||||||
|
uuid: tag(v4()),
|
||||||
|
username: null,
|
||||||
|
libraries: [],
|
||||||
|
type: 'jellyfin',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await res.send(await client.getUserLibraries());
|
await res.send(await client.getUserLibraries());
|
||||||
@@ -54,12 +66,19 @@ export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const client = new JellyfinApiClient({
|
const client = container.get<
|
||||||
url: req.query.uri,
|
MediaSourceApiClientFactory<JellyfinApiClient>
|
||||||
accessToken: req.query.apiKey,
|
>(KEYS.JellyfinApiClientFactory)({
|
||||||
name: 'debug',
|
mediaSource: {
|
||||||
userId: null,
|
uri: req.query.uri,
|
||||||
username: null,
|
accessToken: req.query.apiKey,
|
||||||
|
name: tag('debug'),
|
||||||
|
uuid: tag(v4()),
|
||||||
|
userId: null,
|
||||||
|
username: null,
|
||||||
|
libraries: [],
|
||||||
|
type: 'jellyfin',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let pageParams: Nilable<{ offset: number; limit: number }> = null;
|
let pageParams: Nilable<{ offset: number; limit: number }> = null;
|
||||||
@@ -68,7 +87,7 @@ export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
await res.send(
|
await res.send(
|
||||||
await client.getItems(req.query.parentId, [], [], pageParams),
|
await client.getRawItems(req.query.parentId, [], [], pageParams),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -89,4 +108,54 @@ export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
|
|||||||
return res.status(match ? 200 : 404).send(match);
|
return res.status(match ? 200 : 404).send(match);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
fastify.get(
|
||||||
|
'/jellyfin/:libraryId/enumerate',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
libraryId: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (req, res) => {
|
||||||
|
const library = await req.serverCtx.mediaSourceDB.getLibrary(
|
||||||
|
req.params.libraryId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!library) {
|
||||||
|
return res.status(404).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (library.mediaSource.type !== MediaSourceType.Jellyfin) {
|
||||||
|
return res.status(400).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
const jfClient =
|
||||||
|
await req.serverCtx.mediaSourceApiFactory.getJellyfinApiClientForMediaSource(
|
||||||
|
{ ...library.mediaSource, libraries: [library] },
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (library.mediaType) {
|
||||||
|
case 'movies':
|
||||||
|
for await (const movie of jfClient.getMovieLibraryContents(
|
||||||
|
library.externalKey,
|
||||||
|
)) {
|
||||||
|
console.log(movie);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'shows': {
|
||||||
|
for await (const series of jfClient.getTvShowLibraryContents(
|
||||||
|
library.externalKey,
|
||||||
|
)) {
|
||||||
|
console.log(series);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return res.send();
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { container } from '@/container.js';
|
|||||||
import { ProgramSourceType } from '@/db/custom_types/ProgramSourceType.js';
|
import { ProgramSourceType } from '@/db/custom_types/ProgramSourceType.js';
|
||||||
import { PlexStreamDetails } from '@/stream/plex/PlexStreamDetails.js';
|
import { PlexStreamDetails } from '@/stream/plex/PlexStreamDetails.js';
|
||||||
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
||||||
|
import { tag } from '@tunarr/types';
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
|
|
||||||
export const DebugPlexApiRouter: RouterPluginAsyncCallback = async (
|
export const DebugPlexApiRouter: RouterPluginAsyncCallback = async (
|
||||||
@@ -22,14 +23,14 @@ export const DebugPlexApiRouter: RouterPluginAsyncCallback = async (
|
|||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const mediaSource = await req.serverCtx.mediaSourceDB.findByType(
|
const mediaSource = await req.serverCtx.mediaSourceDB.findByType(
|
||||||
'plex',
|
'plex',
|
||||||
req.query.mediaSource,
|
tag(req.query.mediaSource),
|
||||||
);
|
);
|
||||||
if (!mediaSource) {
|
if (!mediaSource) {
|
||||||
return res.status(400).send('No media source');
|
return res.status(400).send('No media source');
|
||||||
}
|
}
|
||||||
|
|
||||||
const program = await req.serverCtx.programDB.lookupByExternalId({
|
const program = await req.serverCtx.programDB.lookupByExternalId({
|
||||||
externalSourceId: mediaSource.name,
|
externalSourceId: mediaSource.uuid,
|
||||||
externalKey: req.query.key,
|
externalKey: req.query.key,
|
||||||
sourceType: ProgramSourceType.PLEX,
|
sourceType: ProgramSourceType.PLEX,
|
||||||
});
|
});
|
||||||
@@ -38,16 +39,23 @@ export const DebugPlexApiRouter: RouterPluginAsyncCallback = async (
|
|||||||
return res.status(400).send('No program');
|
return res.status(400).send('No program');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contentProgram =
|
||||||
|
req.serverCtx.programConverter.programDaoToContentProgram(program);
|
||||||
|
|
||||||
|
if (!contentProgram) {
|
||||||
|
return res.status(500).send();
|
||||||
|
}
|
||||||
|
|
||||||
const streamDetails = await container.get(PlexStreamDetails).getStream({
|
const streamDetails = await container.get(PlexStreamDetails).getStream({
|
||||||
server: mediaSource,
|
server: mediaSource,
|
||||||
lineupItem: {
|
lineupItem: {
|
||||||
...program,
|
...contentProgram,
|
||||||
programId: program.id!,
|
programId: contentProgram.id,
|
||||||
externalKey: req.query.key,
|
externalKey: req.query.key,
|
||||||
programType: program.subtype,
|
programType: contentProgram.subtype,
|
||||||
externalSource: 'plex',
|
externalSource: 'plex',
|
||||||
duration: program.duration,
|
duration: contentProgram.duration,
|
||||||
externalFilePath: program.serverFilePath,
|
externalFilePath: contentProgram.serverFilePath,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -325,5 +325,6 @@ function createStreamItemFromProgram(
|
|||||||
contentDuration: program.duration,
|
contentDuration: program.duration,
|
||||||
streamDuration: program.duration,
|
streamDuration: program.duration,
|
||||||
infiniteLoop: false,
|
infiniteLoop: false,
|
||||||
|
externalSourceId: program.mediaSourceId!,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { OpenDateTimeRange } from '@/types/OpenDateTimeRange.js';
|
|||||||
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
||||||
import { enumValues } from '@/util/enumUtil.js';
|
import { enumValues } from '@/util/enumUtil.js';
|
||||||
import { ifDefined } from '@/util/index.js';
|
import { ifDefined } from '@/util/index.js';
|
||||||
|
import { tag } from '@tunarr/types';
|
||||||
import { ChannelLineupQuery } from '@tunarr/types/api';
|
import { ChannelLineupQuery } from '@tunarr/types/api';
|
||||||
import { ChannelLineupSchema } from '@tunarr/types/schemas';
|
import { ChannelLineupSchema } from '@tunarr/types/schemas';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@@ -342,7 +343,7 @@ export const debugApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
},
|
},
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const mediaSource = (await req.serverCtx.mediaSourceDB.getById(
|
const mediaSource = (await req.serverCtx.mediaSourceDB.getById(
|
||||||
req.query.id,
|
tag(req.query.id),
|
||||||
))!;
|
))!;
|
||||||
|
|
||||||
const knownProgramIds = await req.serverCtx
|
const knownProgramIds = await req.serverCtx
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import type { MediaSource } from '@/db/schema/MediaSource.js';
|
|
||||||
import { MediaSourceType } from '@/db/schema/MediaSource.js';
|
import { MediaSourceType } from '@/db/schema/MediaSource.js';
|
||||||
import { isQueryError } from '@/external/BaseApiClient.js';
|
|
||||||
import { EmbyApiClient } from '@/external/emby/EmbyApiClient.js';
|
import { EmbyApiClient } from '@/external/emby/EmbyApiClient.js';
|
||||||
import { TruthyQueryParam } from '@/types/schemas.js';
|
import { TruthyQueryParam } from '@/types/schemas.js';
|
||||||
import { isDefined, nullToUndefined } from '@/util/index.js';
|
import { groupByUniq, isDefined, nullToUndefined } from '@/util/index.js';
|
||||||
import { EmbyLoginRequest } from '@tunarr/types/api';
|
import type { Library } from '@tunarr/types';
|
||||||
import type { EmbyCollectionType } from '@tunarr/types/emby';
|
import { tag } from '@tunarr/types';
|
||||||
|
import { EmbyLoginRequest, PagedResult } from '@tunarr/types/api';
|
||||||
import {
|
import {
|
||||||
EmbyItemFields,
|
EmbyItemFields,
|
||||||
EmbyItemKind,
|
EmbyItemKind,
|
||||||
EmbyItemSortBy,
|
EmbyItemSortBy,
|
||||||
EmbyLibraryItemsResponse,
|
|
||||||
type EmbyLibraryItemsResponse as EmbyLibraryItemsResponseType,
|
|
||||||
} from '@tunarr/types/emby';
|
} from '@tunarr/types/emby';
|
||||||
|
import { ItemOrFolder, Library as LibrarySchema } from '@tunarr/types/schemas';
|
||||||
import type { FastifyReply } from 'fastify/types/reply.js';
|
import type { FastifyReply } from 'fastify/types/reply.js';
|
||||||
import { filter, isEmpty, isNil, isUndefined, uniq } from 'lodash-es';
|
import { isEmpty, isNil, isUndefined, uniq } from 'lodash-es';
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
|
import type { MediaSourceWithLibraries } from '../db/schema/derivedTypes.js';
|
||||||
|
import { ServerRequestContext } from '../ServerContext.ts';
|
||||||
import type {
|
import type {
|
||||||
RouterPluginCallback,
|
RouterPluginCallback,
|
||||||
ZodFastifyRequest,
|
ZodFastifyRequest,
|
||||||
@@ -25,19 +25,6 @@ const mediaSourceParams = z.object({
|
|||||||
mediaSourceId: z.string(),
|
mediaSourceId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const ValidEmbyCollectionTypes: EmbyCollectionType[] = [
|
|
||||||
'movies',
|
|
||||||
'tvshows',
|
|
||||||
'music',
|
|
||||||
'trailers',
|
|
||||||
'musicvideos',
|
|
||||||
'homevideos',
|
|
||||||
'playlists',
|
|
||||||
'boxsets',
|
|
||||||
'folders',
|
|
||||||
'unknown',
|
|
||||||
];
|
|
||||||
|
|
||||||
function isNonEmptyTyped<T>(f: T[]): f is [T, ...T[]] {
|
function isNonEmptyTyped<T>(f: T[]): f is [T, ...T[]] {
|
||||||
return !isEmpty(f);
|
return !isEmpty(f);
|
||||||
}
|
}
|
||||||
@@ -83,7 +70,7 @@ export const embyApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
|||||||
schema: {
|
schema: {
|
||||||
params: mediaSourceParams,
|
params: mediaSourceParams,
|
||||||
response: {
|
response: {
|
||||||
200: EmbyLibraryItemsResponse,
|
200: z.array(LibrarySchema),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -94,27 +81,34 @@ export const embyApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
|||||||
mediaSource,
|
mediaSource,
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await api.getUserViews();
|
const response = await api.getUserLibraries();
|
||||||
|
|
||||||
if (isQueryError(response)) {
|
if (response.isFailure()) {
|
||||||
throw new Error(response.message);
|
throw response.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sanitizedResponse: EmbyLibraryItemsResponseType = {
|
// const sanitizedResponse: EmbyLibraryItemsResponseType = {
|
||||||
...response.data,
|
// ...response.get(),
|
||||||
Items: filter(response.data.Items, (library) => {
|
// Items: filter(response.get().Items, (library) => {
|
||||||
// Mixed collections don't have this set
|
// // Mixed collections don't have this set
|
||||||
if (!library.CollectionType) {
|
// if (!library.CollectionType) {
|
||||||
return true;
|
// return true;
|
||||||
}
|
// }
|
||||||
|
|
||||||
return ValidEmbyCollectionTypes.includes(
|
// return ValidEmbyCollectionTypes.includes(
|
||||||
library.CollectionType as EmbyCollectionType,
|
// library.CollectionType as EmbyCollectionType,
|
||||||
);
|
// );
|
||||||
}),
|
// }),
|
||||||
};
|
// };
|
||||||
|
|
||||||
return res.send(sanitizedResponse);
|
// await addTunarrLibraryIdsToResponse(
|
||||||
|
// sanitizedResponse.Items,
|
||||||
|
// mediaSource,
|
||||||
|
// );
|
||||||
|
|
||||||
|
await addTunarrLibraryIdsToResponse(response.get(), mediaSource);
|
||||||
|
|
||||||
|
return res.send(response.get());
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -164,7 +158,7 @@ export const embyApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
|||||||
.or(z.array(z.enum(['Artist', 'AlbumArtist'])).optional()),
|
.or(z.array(z.enum(['Artist', 'AlbumArtist'])).optional()),
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: EmbyLibraryItemsResponse,
|
200: PagedResult(ItemOrFolder.array()),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -201,11 +195,11 @@ export const embyApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
|||||||
: ['SortName', 'ProductionYear'],
|
: ['SortName', 'ProductionYear'],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isQueryError(response)) {
|
if (response.isFailure()) {
|
||||||
throw new Error(response.message);
|
throw response.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.send(response.data);
|
return res.send(response.get());
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -216,10 +210,10 @@ export const embyApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
|||||||
>(
|
>(
|
||||||
req: Req,
|
req: Req,
|
||||||
res: FastifyReply,
|
res: FastifyReply,
|
||||||
cb: (m: MediaSource) => Promise<FastifyReply>,
|
cb: (m: MediaSourceWithLibraries) => Promise<FastifyReply>,
|
||||||
) {
|
) {
|
||||||
const mediaSource = await req.serverCtx.mediaSourceDB.getById(
|
const mediaSource = await req.serverCtx.mediaSourceDB.getById(
|
||||||
req.params.mediaSourceId,
|
tag(req.params.mediaSourceId),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isNil(mediaSource)) {
|
if (isNil(mediaSource)) {
|
||||||
@@ -241,3 +235,42 @@ export const embyApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
|||||||
|
|
||||||
done();
|
done();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function addTunarrLibraryIdsToResponse(
|
||||||
|
response: Library[],
|
||||||
|
mediaSource: MediaSourceWithLibraries,
|
||||||
|
attempts: number = 1,
|
||||||
|
) {
|
||||||
|
if (attempts > 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const librariesByExternalId = groupByUniq(
|
||||||
|
mediaSource.libraries,
|
||||||
|
(lib) => lib.externalKey,
|
||||||
|
);
|
||||||
|
let needsRefresh = false;
|
||||||
|
for (const library of response) {
|
||||||
|
const tunarrLibrary = librariesByExternalId[library.externalId];
|
||||||
|
if (!tunarrLibrary) {
|
||||||
|
needsRefresh = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
library.uuid = tunarrLibrary.uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsRefresh) {
|
||||||
|
const ctx = ServerRequestContext.currentServerContext()!;
|
||||||
|
await ctx.mediaSourceLibraryRefresher.refreshMediaSource(mediaSource);
|
||||||
|
// This definitely exists...
|
||||||
|
const newMediaSource = await ctx.mediaSourceDB.getById(mediaSource.uuid);
|
||||||
|
return addTunarrLibraryIdsToResponse(
|
||||||
|
response,
|
||||||
|
newMediaSource!,
|
||||||
|
attempts + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|||||||
@@ -290,6 +290,7 @@ export const ffmpegSettingsRouter: RouterPluginCallback = (
|
|||||||
params: IdPathParamSchema,
|
params: IdPathParamSchema,
|
||||||
response: {
|
response: {
|
||||||
200: z.void(),
|
200: z.void(),
|
||||||
|
404: z.void(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export const fillerListsApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
body: UpdateFillerListRequestSchema,
|
body: UpdateFillerListRequestSchema,
|
||||||
response: {
|
response: {
|
||||||
200: FillerListSchema,
|
200: FillerListSchema,
|
||||||
|
404: z.void(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { MediaSourceType } from '@/db/schema/MediaSource.js';
|
|
||||||
import type { FfmpegEncoder } from '@/ffmpeg/ffmpegInfo.js';
|
import type { FfmpegEncoder } from '@/ffmpeg/ffmpegInfo.js';
|
||||||
import { FfmpegInfo } from '@/ffmpeg/ffmpegInfo.js';
|
import { FfmpegInfo } from '@/ffmpeg/ffmpegInfo.js';
|
||||||
import { serverOptions } from '@/globals.js';
|
import { serverOptions } from '@/globals.js';
|
||||||
@@ -10,7 +9,7 @@ import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
|
|||||||
import { getTunarrVersion } from '@/util/version.js';
|
import { getTunarrVersion } from '@/util/version.js';
|
||||||
import { VersionApiResponseSchema } from '@tunarr/types/api';
|
import { VersionApiResponseSchema } from '@tunarr/types/api';
|
||||||
import { fileTypeFromStream } from 'file-type';
|
import { fileTypeFromStream } from 'file-type';
|
||||||
import { isEmpty, isNil } from 'lodash-es';
|
import { isEmpty } from 'lodash-es';
|
||||||
import { createReadStream, promises as fsPromises } from 'node:fs';
|
import { createReadStream, promises as fsPromises } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
@@ -28,6 +27,7 @@ import { hdhrSettingsRouter } from './hdhrSettingsApi.js';
|
|||||||
import { jellyfinApiRouter } from './jellyfinApi.js';
|
import { jellyfinApiRouter } from './jellyfinApi.js';
|
||||||
import { mediaSourceRouter } from './mediaSourceApi.js';
|
import { mediaSourceRouter } from './mediaSourceApi.js';
|
||||||
import { metadataApiRouter } from './metadataApi.js';
|
import { metadataApiRouter } from './metadataApi.js';
|
||||||
|
import { plexApiRouter } from './plexApi.ts';
|
||||||
import { plexSettingsRouter } from './plexSettingsApi.js';
|
import { plexSettingsRouter } from './plexSettingsApi.js';
|
||||||
import { programmingApi } from './programmingApi.js';
|
import { programmingApi } from './programmingApi.js';
|
||||||
import { sessionApiRouter } from './sessionApi.js';
|
import { sessionApiRouter } from './sessionApi.js';
|
||||||
@@ -62,6 +62,7 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
.register(hdhrSettingsRouter)
|
.register(hdhrSettingsRouter)
|
||||||
.register(systemApiRouter)
|
.register(systemApiRouter)
|
||||||
.register(guideRouter)
|
.register(guideRouter)
|
||||||
|
.register(plexApiRouter)
|
||||||
.register(jellyfinApiRouter)
|
.register(jellyfinApiRouter)
|
||||||
.register(sessionApiRouter)
|
.register(sessionApiRouter)
|
||||||
.register(embyApiRouter);
|
.register(embyApiRouter);
|
||||||
@@ -142,6 +143,7 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
fileUrl: z.string(),
|
fileUrl: z.string(),
|
||||||
}),
|
}),
|
||||||
|
400: z.void(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -225,7 +227,7 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
.header('Content-Type', 'application/xml')
|
.header('Content-Type', 'application/xml')
|
||||||
.send(fileFinal);
|
.send(fileFinal);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('%O', err);
|
logger.error(err);
|
||||||
return res.status(500).send('error');
|
return res.status(500).send('error');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -286,33 +288,4 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
return res.status(204).send();
|
return res.status(204).send();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
fastify.get(
|
|
||||||
'/plex',
|
|
||||||
{
|
|
||||||
schema: {
|
|
||||||
querystring: z.object({ id: z.string(), path: z.string() }),
|
|
||||||
operationId: 'queryPlex',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async (req, res) => {
|
|
||||||
req.logRequestAtLevel = 'trace';
|
|
||||||
const server = await req.serverCtx.mediaSourceDB.findByType(
|
|
||||||
MediaSourceType.Plex,
|
|
||||||
req.query.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isNil(server)) {
|
|
||||||
return res
|
|
||||||
.status(404)
|
|
||||||
.send({ error: 'No server found with id: ' + req.query.id });
|
|
||||||
}
|
|
||||||
|
|
||||||
const plex =
|
|
||||||
await req.serverCtx.mediaSourceApiFactory.getPlexApiClientForMediaSource(
|
|
||||||
server,
|
|
||||||
);
|
|
||||||
return res.send(await plex.doGetPath(req.query.path));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import type { MediaSource } from '@/db/schema/MediaSource.js';
|
|
||||||
import { MediaSourceType } from '@/db/schema/MediaSource.js';
|
import { MediaSourceType } from '@/db/schema/MediaSource.js';
|
||||||
import { isQueryError } from '@/external/BaseApiClient.js';
|
|
||||||
import { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
|
import { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
|
||||||
import { TruthyQueryParam } from '@/types/schemas.js';
|
import { mediaSourceParamsSchema, TruthyQueryParam } from '@/types/schemas.js';
|
||||||
import { inConstArr, isDefined, nullToUndefined } from '@/util/index.js';
|
import { groupByUniq, isDefined, nullToUndefined } from '@/util/index.js';
|
||||||
import { JellyfinLoginRequest } from '@tunarr/types/api';
|
import { tag, type Library } from '@tunarr/types';
|
||||||
import type { JellyfinCollectionType } from '@tunarr/types/jellyfin';
|
import { JellyfinLoginRequest, PagedResult } from '@tunarr/types/api';
|
||||||
import {
|
import {
|
||||||
JellyfinItemFields,
|
JellyfinItemFields,
|
||||||
JellyfinItemKind,
|
JellyfinItemKind,
|
||||||
JellyfinItemSortBy,
|
JellyfinItemSortBy,
|
||||||
JellyfinLibraryItemsResponse,
|
JellyfinLibraryItemsResponse,
|
||||||
TunarrAmendedJellyfinVirtualFolder,
|
|
||||||
} from '@tunarr/types/jellyfin';
|
} from '@tunarr/types/jellyfin';
|
||||||
|
import { ItemOrFolder, Library as LibrarySchema } from '@tunarr/types/schemas';
|
||||||
import type { FastifyReply } from 'fastify/types/reply.js';
|
import type { FastifyReply } from 'fastify/types/reply.js';
|
||||||
import { isEmpty, isNil, uniq } from 'lodash-es';
|
import { isEmpty, isNil, uniq } from 'lodash-es';
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
|
import type { MediaSourceWithLibraries } from '../db/schema/derivedTypes.js';
|
||||||
|
import { ServerRequestContext } from '../ServerContext.ts';
|
||||||
import type {
|
import type {
|
||||||
RouterPluginCallback,
|
RouterPluginCallback,
|
||||||
ZodFastifyRequest,
|
ZodFastifyRequest,
|
||||||
@@ -25,19 +25,6 @@ const mediaSourceParams = z.object({
|
|||||||
mediaSourceId: z.string(),
|
mediaSourceId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const ValidJellyfinCollectionTypes = [
|
|
||||||
'movies',
|
|
||||||
'tvshows',
|
|
||||||
'music',
|
|
||||||
'trailers',
|
|
||||||
'musicvideos',
|
|
||||||
'homevideos',
|
|
||||||
'playlists',
|
|
||||||
'boxsets',
|
|
||||||
'folders',
|
|
||||||
'unknown',
|
|
||||||
] satisfies JellyfinCollectionType[];
|
|
||||||
|
|
||||||
function isNonEmptyTyped<T>(f: T[]): f is [T, ...T[]] {
|
function isNonEmptyTyped<T>(f: T[]): f is [T, ...T[]] {
|
||||||
return !isEmpty(f);
|
return !isEmpty(f);
|
||||||
}
|
}
|
||||||
@@ -84,8 +71,7 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
|||||||
schema: {
|
schema: {
|
||||||
params: mediaSourceParams,
|
params: mediaSourceParams,
|
||||||
response: {
|
response: {
|
||||||
// HACK
|
200: z.array(LibrarySchema),
|
||||||
200: z.array(TunarrAmendedJellyfinVirtualFolder),
|
|
||||||
},
|
},
|
||||||
operationId: 'getJellyfinLibraries',
|
operationId: 'getJellyfinLibraries',
|
||||||
},
|
},
|
||||||
@@ -99,28 +85,34 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
|||||||
|
|
||||||
const response = await api.getUserViews();
|
const response = await api.getUserViews();
|
||||||
|
|
||||||
if (isQueryError(response)) {
|
if (response.isFailure()) {
|
||||||
throw new Error(response.message);
|
throw response.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.send(
|
// const amendedResponse = response
|
||||||
response.data
|
// .get()
|
||||||
.filter((library) => {
|
// .filter((library) => {
|
||||||
// Mixed collections don't have this set
|
// // Mixed collections don't have this set
|
||||||
if (!library.CollectionType) {
|
// if (!library.CollectionType) {
|
||||||
return true;
|
// return true;
|
||||||
}
|
// }
|
||||||
|
|
||||||
return inConstArr(
|
// return inConstArr(
|
||||||
ValidJellyfinCollectionTypes,
|
// ValidJellyfinCollectionTypes,
|
||||||
library.CollectionType ?? '',
|
// library.CollectionType ?? '',
|
||||||
);
|
// );
|
||||||
})
|
// })
|
||||||
.map((lib) => ({
|
// .map(
|
||||||
...lib,
|
// (lib) =>
|
||||||
jellyfinType: 'VirtualFolder',
|
// ({
|
||||||
})),
|
// ...lib,
|
||||||
);
|
// jellyfinType: 'VirtualFolder',
|
||||||
|
// }) satisfies TunarrAmendedJellyfinVirtualFolder,
|
||||||
|
// );
|
||||||
|
|
||||||
|
await addTunarrLibraryIdsToResponse(response.get(), mediaSource);
|
||||||
|
|
||||||
|
return res.send(response.get());
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -128,7 +120,7 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
|||||||
'/jellyfin/:mediaSourceId/libraries/:libraryId/genres',
|
'/jellyfin/:mediaSourceId/libraries/:libraryId/genres',
|
||||||
{
|
{
|
||||||
schema: {
|
schema: {
|
||||||
params: mediaSourceParams.extend({
|
params: mediaSourceParamsSchema.extend({
|
||||||
libraryId: z.string(),
|
libraryId: z.string(),
|
||||||
}),
|
}),
|
||||||
querystring: z.object({
|
querystring: z.object({
|
||||||
@@ -152,11 +144,11 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
|||||||
req.query.includeItemTypes,
|
req.query.includeItemTypes,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isQueryError(response)) {
|
if (response.isFailure()) {
|
||||||
throw new Error(response.message);
|
throw response.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.send(response.data);
|
return res.send(response.get());
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -164,7 +156,8 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
|||||||
'/jellyfin/:mediaSourceId/libraries/:libraryId/items',
|
'/jellyfin/:mediaSourceId/libraries/:libraryId/items',
|
||||||
{
|
{
|
||||||
schema: {
|
schema: {
|
||||||
params: mediaSourceParams.extend({
|
operationId: 'getJellyfinLibraryItems',
|
||||||
|
params: mediaSourceParamsSchema.extend({
|
||||||
libraryId: z.string(),
|
libraryId: z.string(),
|
||||||
}),
|
}),
|
||||||
querystring: z.object({
|
querystring: z.object({
|
||||||
@@ -201,9 +194,8 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
|||||||
parentId: z.string().optional(),
|
parentId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: JellyfinLibraryItemsResponse,
|
200: PagedResult(ItemOrFolder.array()),
|
||||||
},
|
},
|
||||||
operationId: 'getJellyfinLibraryItems',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
(req, res) =>
|
(req, res) =>
|
||||||
@@ -239,25 +231,25 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
|||||||
: ['SortName', 'ProductionYear'],
|
: ['SortName', 'ProductionYear'],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isQueryError(response)) {
|
if (response.isFailure()) {
|
||||||
throw new Error(response.message);
|
throw response.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.send(response.data);
|
return res.send(response.get());
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
async function withJellyfinMediaSource<
|
async function withJellyfinMediaSource<
|
||||||
Req extends ZodFastifyRequest<{
|
Req extends ZodFastifyRequest<{
|
||||||
params: typeof mediaSourceParams;
|
params: typeof mediaSourceParamsSchema;
|
||||||
}>,
|
}>,
|
||||||
>(
|
>(
|
||||||
req: Req,
|
req: Req,
|
||||||
res: FastifyReply,
|
res: FastifyReply,
|
||||||
cb: (m: MediaSource) => Promise<FastifyReply>,
|
cb: (m: MediaSourceWithLibraries) => Promise<FastifyReply>,
|
||||||
) {
|
) {
|
||||||
const mediaSource = await req.serverCtx.mediaSourceDB.getById(
|
const mediaSource = await req.serverCtx.mediaSourceDB.getById(
|
||||||
req.params.mediaSourceId,
|
tag(req.params.mediaSourceId),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isNil(mediaSource)) {
|
if (isNil(mediaSource)) {
|
||||||
@@ -279,3 +271,42 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
|
|||||||
|
|
||||||
done();
|
done();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function addTunarrLibraryIdsToResponse(
|
||||||
|
response: Library[],
|
||||||
|
mediaSource: MediaSourceWithLibraries,
|
||||||
|
attempts: number = 1,
|
||||||
|
) {
|
||||||
|
if (attempts > 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const librariesByExternalId = groupByUniq(
|
||||||
|
mediaSource.libraries,
|
||||||
|
(lib) => lib.externalKey,
|
||||||
|
);
|
||||||
|
let needsRefresh = false;
|
||||||
|
for (const library of response) {
|
||||||
|
const tunarrLibrary = librariesByExternalId[library.externalId];
|
||||||
|
if (!tunarrLibrary) {
|
||||||
|
needsRefresh = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
library.uuid = tunarrLibrary.uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsRefresh) {
|
||||||
|
const ctx = ServerRequestContext.currentServerContext()!;
|
||||||
|
await ctx.mediaSourceLibraryRefresher.refreshMediaSource(mediaSource);
|
||||||
|
// This definitely exists...
|
||||||
|
const newMediaSource = await ctx.mediaSourceDB.getById(mediaSource.uuid);
|
||||||
|
return addTunarrLibraryIdsToResponse(
|
||||||
|
response,
|
||||||
|
newMediaSource!,
|
||||||
|
attempts + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,30 +1,45 @@
|
|||||||
import { MediaSourceType } from '@/db/schema/MediaSource.js';
|
|
||||||
import { GlobalScheduler } from '@/services/Scheduler.js';
|
import { GlobalScheduler } from '@/services/Scheduler.js';
|
||||||
import { UpdateXmlTvTask } from '@/tasks/UpdateXmlTvTask.js';
|
import { UpdateXmlTvTask } from '@/tasks/UpdateXmlTvTask.js';
|
||||||
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
||||||
import { nullToUndefined, wait } from '@/util/index.js';
|
import { nullToUndefined } from '@/util/index.js';
|
||||||
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
|
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
|
||||||
import { numberToBoolean } from '@/util/sqliteUtil.js';
|
import { numberToBoolean } from '@/util/sqliteUtil.js';
|
||||||
import { seq } from '@tunarr/shared/util';
|
import { seq } from '@tunarr/shared/util';
|
||||||
import type { MediaSourceSettings } from '@tunarr/types';
|
import {
|
||||||
|
tag,
|
||||||
|
type MediaSourceLibrary,
|
||||||
|
type MediaSourceSettings,
|
||||||
|
} from '@tunarr/types';
|
||||||
import type {
|
import type {
|
||||||
MediaSourceStatus,
|
MediaSourceStatus,
|
||||||
MediaSourceUnhealthyStatus,
|
MediaSourceUnhealthyStatus,
|
||||||
|
ScanProgress,
|
||||||
} from '@tunarr/types/api';
|
} from '@tunarr/types/api';
|
||||||
import {
|
import {
|
||||||
BaseErrorSchema,
|
|
||||||
BasicIdParamSchema,
|
BasicIdParamSchema,
|
||||||
InsertMediaSourceRequestSchema,
|
InsertMediaSourceRequestSchema,
|
||||||
MediaSourceStatusSchema,
|
MediaSourceStatusSchema,
|
||||||
|
ScanProgressSchema,
|
||||||
|
UpdateMediaSourceLibraryRequest,
|
||||||
UpdateMediaSourceRequestSchema,
|
UpdateMediaSourceRequestSchema,
|
||||||
} from '@tunarr/types/api';
|
} from '@tunarr/types/api';
|
||||||
import {
|
import {
|
||||||
|
ContentProgramSchema,
|
||||||
ExternalSourceTypeSchema,
|
ExternalSourceTypeSchema,
|
||||||
|
MediaSourceLibrarySchema,
|
||||||
MediaSourceSettingsSchema,
|
MediaSourceSettingsSchema,
|
||||||
} from '@tunarr/types/schemas';
|
} from '@tunarr/types/schemas';
|
||||||
import { isError, isNil } from 'lodash-es';
|
import { isEmpty, isError, isNil, isNull } from 'lodash-es';
|
||||||
|
import type { MarkOptional } from 'ts-essentials';
|
||||||
import { match, P } from 'ts-pattern';
|
import { match, P } from 'ts-pattern';
|
||||||
|
import { v4 } from 'uuid';
|
||||||
import z from 'zod/v4';
|
import z from 'zod/v4';
|
||||||
|
import { container } from '../container.ts';
|
||||||
|
import type { MediaSourceWithLibraries } from '../db/schema/derivedTypes.js';
|
||||||
|
import { EntityMutex } from '../services/EntityMutex.ts';
|
||||||
|
import { MediaSourceLibraryRefresher } from '../services/MediaSourceLibraryRefresher.ts';
|
||||||
|
import { MediaSourceProgressService } from '../services/scanner/MediaSourceProgressService.ts';
|
||||||
|
import { TruthyQueryParam } from '../types/schemas.ts';
|
||||||
|
|
||||||
export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
||||||
fastify,
|
fastify,
|
||||||
@@ -47,27 +62,13 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
|
const entityLocker = container.get<EntityMutex>(EntityMutex);
|
||||||
try {
|
try {
|
||||||
const sources = await req.serverCtx.mediaSourceDB.getAll();
|
const sources = await req.serverCtx.mediaSourceDB.getAll();
|
||||||
|
|
||||||
const dtos = seq.collect(sources, (source) => {
|
const dtos = seq.collect(sources, (source) =>
|
||||||
return match(source)
|
convertToApiMediaSource(entityLocker, source),
|
||||||
.returnType<MediaSourceSettings | null>()
|
);
|
||||||
.with({ type: P.union('plex', 'jellyfin', 'emby') }, (source) => ({
|
|
||||||
id: source.uuid,
|
|
||||||
index: source.index,
|
|
||||||
uri: source.uri,
|
|
||||||
type: source.type,
|
|
||||||
name: source.name,
|
|
||||||
accessToken: source.accessToken,
|
|
||||||
clientIdentifier: nullToUndefined(source.clientIdentifier),
|
|
||||||
sendChannelUpdates: numberToBoolean(source.sendChannelUpdates),
|
|
||||||
sendGuideUpdates: numberToBoolean(source.sendGuideUpdates),
|
|
||||||
userId: source.userId,
|
|
||||||
username: source.username,
|
|
||||||
}))
|
|
||||||
.otherwise(() => null);
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.send(dtos);
|
return res.send(dtos);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -77,6 +78,322 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
fastify.get(
|
||||||
|
'/media-sources/:id/libraries',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['Media Source'],
|
||||||
|
params: BasicIdParamSchema,
|
||||||
|
response: {
|
||||||
|
200: z.array(MediaSourceLibrarySchema),
|
||||||
|
404: z.void(),
|
||||||
|
500: z.string(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (req, res) => {
|
||||||
|
const mediaSource = await req.serverCtx.mediaSourceDB.getById(
|
||||||
|
tag(req.params.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mediaSource) {
|
||||||
|
return res.status(404).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityLocker = container.get<EntityMutex>(EntityMutex);
|
||||||
|
const apiMediaSource = convertToApiMediaSource(entityLocker, mediaSource);
|
||||||
|
if (isNull(apiMediaSource)) {
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.send('Invalid media source type: ' + mediaSource.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.send(
|
||||||
|
mediaSource.libraries.map(
|
||||||
|
(library) =>
|
||||||
|
({
|
||||||
|
...library,
|
||||||
|
id: library.uuid,
|
||||||
|
type: mediaSource.type,
|
||||||
|
enabled: numberToBoolean(library.enabled),
|
||||||
|
lastScannedAt: nullToUndefined(library.lastScannedAt),
|
||||||
|
isLocked: entityLocker.isLibraryLocked(library),
|
||||||
|
mediaSource: apiMediaSource,
|
||||||
|
}) satisfies MediaSourceLibrary,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.put(
|
||||||
|
'/media-sources/:id/libraries/:libraryId',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['Media Source'],
|
||||||
|
params: BasicIdParamSchema.extend({
|
||||||
|
libraryId: z.string(),
|
||||||
|
}),
|
||||||
|
body: UpdateMediaSourceLibraryRequest,
|
||||||
|
response: {
|
||||||
|
200: MediaSourceLibrarySchema,
|
||||||
|
404: z.void(),
|
||||||
|
500: z.string(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (req, res) => {
|
||||||
|
const mediaSource = await req.serverCtx.mediaSourceDB.getById(
|
||||||
|
tag(req.params.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mediaSource) {
|
||||||
|
return res.status(404).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityLocker = container.get(EntityMutex);
|
||||||
|
const apiMediaSource = convertToApiMediaSource(entityLocker, mediaSource);
|
||||||
|
if (isNull(apiMediaSource)) {
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.send('Invalid media source type: ' + mediaSource.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedLibrary =
|
||||||
|
await req.serverCtx.mediaSourceDB.setLibraryEnabled(
|
||||||
|
tag(req.params.id),
|
||||||
|
req.params.libraryId,
|
||||||
|
req.body.enabled,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (req.body.enabled) {
|
||||||
|
const result = await req.serverCtx.mediaSourceScanCoordinator.add({
|
||||||
|
libraryId: updatedLibrary.uuid,
|
||||||
|
forceScan: false,
|
||||||
|
});
|
||||||
|
if (!result) {
|
||||||
|
logger.error(
|
||||||
|
'Unable to schedule library ID %s for scanning',
|
||||||
|
updatedLibrary.uuid,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.send({
|
||||||
|
...updatedLibrary,
|
||||||
|
id: updatedLibrary.uuid,
|
||||||
|
type: mediaSource.type,
|
||||||
|
enabled: numberToBoolean(updatedLibrary.enabled),
|
||||||
|
lastScannedAt: nullToUndefined(updatedLibrary.lastScannedAt),
|
||||||
|
isLocked: entityLocker.isLibraryLocked(updatedLibrary),
|
||||||
|
mediaSource: apiMediaSource,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.get(
|
||||||
|
'/media-libraries/:libraryId',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['Media Library'],
|
||||||
|
params: z.object({
|
||||||
|
libraryId: z.string(),
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: MediaSourceLibrarySchema.extend({
|
||||||
|
mediaSource: MediaSourceSettingsSchema,
|
||||||
|
}),
|
||||||
|
404: z.void(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (req, res) => {
|
||||||
|
const library = await req.serverCtx.mediaSourceDB.getLibrary(
|
||||||
|
req.params.libraryId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!library) {
|
||||||
|
return res.status(404).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityLocker = container.get<EntityMutex>(EntityMutex);
|
||||||
|
|
||||||
|
return res.send({
|
||||||
|
...library,
|
||||||
|
id: library.uuid,
|
||||||
|
type: library.mediaSource.type,
|
||||||
|
enabled: numberToBoolean(library.enabled),
|
||||||
|
lastScannedAt: nullToUndefined(library.lastScannedAt),
|
||||||
|
isLocked: entityLocker.isLibraryLocked(library),
|
||||||
|
mediaSource: convertToApiMediaSource(
|
||||||
|
entityLocker,
|
||||||
|
library.mediaSource,
|
||||||
|
)!,
|
||||||
|
// TODO this is dumb
|
||||||
|
} satisfies MediaSourceLibrary & {
|
||||||
|
mediaSource: MediaSourceSettings;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.get(
|
||||||
|
'/media-libraries/:libraryId/programs',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['Media Library'],
|
||||||
|
params: z.object({
|
||||||
|
libraryId: z.string(),
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.array(ContentProgramSchema),
|
||||||
|
404: z.void(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (req, res) => {
|
||||||
|
const library = await req.serverCtx.mediaSourceDB.getLibrary(
|
||||||
|
req.params.libraryId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!library) {
|
||||||
|
return res.status(404).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
const programs =
|
||||||
|
await req.serverCtx.programDB.getMediaSourceLibraryPrograms(
|
||||||
|
req.params.libraryId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.send(
|
||||||
|
seq.collect(programs, (program) =>
|
||||||
|
req.serverCtx.programConverter.programDaoToContentProgram(
|
||||||
|
program,
|
||||||
|
program.externalIds ?? [],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.get(
|
||||||
|
'/media-libraries/:libraryId/status',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
libraryId: z.string(),
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: ScanProgressSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (req, res) => {
|
||||||
|
const progressService = container.get<MediaSourceProgressService>(
|
||||||
|
MediaSourceProgressService,
|
||||||
|
);
|
||||||
|
|
||||||
|
const progress = progressService.getScanProgress(req.params.libraryId);
|
||||||
|
|
||||||
|
const response = match(progress)
|
||||||
|
.returnType<ScanProgress>()
|
||||||
|
.with({ state: 'in_progress' }, (ip) => ({
|
||||||
|
...ip,
|
||||||
|
startedAt: +ip.startedAt,
|
||||||
|
}))
|
||||||
|
.with(P._, (p) => p)
|
||||||
|
.exhaustive();
|
||||||
|
|
||||||
|
return res.send(response);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
'/media-sources/:id/libraries/refresh',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['Media Source'],
|
||||||
|
params: BasicIdParamSchema,
|
||||||
|
response: {
|
||||||
|
200: z.void(),
|
||||||
|
404: z.void(),
|
||||||
|
501: z.void(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (req, res) => {
|
||||||
|
const mediaSource = await req.serverCtx.mediaSourceDB.getById(
|
||||||
|
tag(req.params.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mediaSource) {
|
||||||
|
return res.status(404).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresher = container.get<MediaSourceLibraryRefresher>(
|
||||||
|
MediaSourceLibraryRefresher,
|
||||||
|
);
|
||||||
|
|
||||||
|
await refresher.refreshAll();
|
||||||
|
|
||||||
|
return res.status(200).send();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
'/media-sources/:id/libraries/:libraryId/scan',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['Media Source'],
|
||||||
|
params: BasicIdParamSchema.extend({
|
||||||
|
libraryId: z.string(),
|
||||||
|
}),
|
||||||
|
querystring: z.object({
|
||||||
|
forceScan: TruthyQueryParam.optional(),
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
202: z.void(),
|
||||||
|
404: z.void(),
|
||||||
|
501: z.void(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (req, res) => {
|
||||||
|
const mediaSource = await req.serverCtx.mediaSourceDB.getById(
|
||||||
|
tag(req.params.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mediaSource) {
|
||||||
|
return res.status(404).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = req.params.libraryId === 'all';
|
||||||
|
|
||||||
|
const libraries = all
|
||||||
|
? mediaSource.libraries.filter((lib) => lib.enabled)
|
||||||
|
: mediaSource.libraries.filter(
|
||||||
|
(lib) => lib.uuid === req.params.libraryId && lib.enabled,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!libraries || isEmpty(libraries)) {
|
||||||
|
return res.status(501);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const library of libraries) {
|
||||||
|
const result = await req.serverCtx.mediaSourceScanCoordinator.add({
|
||||||
|
libraryId: library.uuid,
|
||||||
|
forceScan: !!req.query.forceScan,
|
||||||
|
});
|
||||||
|
if (!result) {
|
||||||
|
logger.error(
|
||||||
|
'Unable to schedule library ID %s for scanning',
|
||||||
|
library.uuid,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(202).send();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
fastify.get(
|
fastify.get(
|
||||||
'/media-sources/:id/status',
|
'/media-sources/:id/status',
|
||||||
{
|
{
|
||||||
@@ -94,7 +411,9 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
},
|
},
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const server = await req.serverCtx.mediaSourceDB.getById(req.params.id);
|
const server = await req.serverCtx.mediaSourceDB.getById(
|
||||||
|
tag(req.params.id),
|
||||||
|
);
|
||||||
|
|
||||||
if (isNil(server)) {
|
if (isNil(server)) {
|
||||||
return res.status(404).send();
|
return res.status(404).send();
|
||||||
@@ -166,11 +485,15 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
case 'plex': {
|
case 'plex': {
|
||||||
const plex =
|
const plex =
|
||||||
await req.serverCtx.mediaSourceApiFactory.getPlexApiClient({
|
await req.serverCtx.mediaSourceApiFactory.getPlexApiClient({
|
||||||
...req.body,
|
mediaSource: {
|
||||||
url: req.body.uri,
|
...req.body,
|
||||||
userId: null,
|
uri: req.body.uri,
|
||||||
username: null,
|
userId: null,
|
||||||
name: req.body.name ?? 'unknown',
|
username: null,
|
||||||
|
name: tag(req.body.name ?? 'unknown'),
|
||||||
|
uuid: tag(v4()),
|
||||||
|
libraries: [],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
healthyPromise = plex.checkServerStatus();
|
healthyPromise = plex.checkServerStatus();
|
||||||
@@ -179,11 +502,15 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
case 'jellyfin': {
|
case 'jellyfin': {
|
||||||
const jellyfin =
|
const jellyfin =
|
||||||
await req.serverCtx.mediaSourceApiFactory.getJellyfinApiClient({
|
await req.serverCtx.mediaSourceApiFactory.getJellyfinApiClient({
|
||||||
...req.body,
|
mediaSource: {
|
||||||
url: req.body.uri,
|
...req.body,
|
||||||
userId: null,
|
uri: req.body.uri,
|
||||||
username: null,
|
userId: null,
|
||||||
name: req.body.name ?? 'unknown',
|
username: null,
|
||||||
|
name: tag(req.body.name ?? 'unknown'),
|
||||||
|
uuid: tag(v4()),
|
||||||
|
libraries: [],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
healthyPromise = jellyfin.ping();
|
healthyPromise = jellyfin.ping();
|
||||||
@@ -192,11 +519,15 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
case 'emby': {
|
case 'emby': {
|
||||||
const emby =
|
const emby =
|
||||||
await req.serverCtx.mediaSourceApiFactory.getEmbyApiClient({
|
await req.serverCtx.mediaSourceApiFactory.getEmbyApiClient({
|
||||||
...req.body,
|
mediaSource: {
|
||||||
url: req.body.uri,
|
...req.body,
|
||||||
userId: null,
|
uri: req.body.uri,
|
||||||
username: null,
|
userId: null,
|
||||||
name: req.body.name ?? 'unknown',
|
username: null,
|
||||||
|
name: tag(req.body.name ?? 'unknown'),
|
||||||
|
uuid: tag(v4()),
|
||||||
|
libraries: [],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
healthyPromise = emby.ping();
|
healthyPromise = emby.ping();
|
||||||
@@ -233,7 +564,9 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { deletedServer } =
|
const { deletedServer } =
|
||||||
await req.serverCtx.mediaSourceDB.deleteMediaSource(req.params.id);
|
await req.serverCtx.mediaSourceDB.deleteMediaSource(
|
||||||
|
tag(req.params.id),
|
||||||
|
);
|
||||||
|
|
||||||
// Are these useful? What do they even do?
|
// Are these useful? What do they even do?
|
||||||
req.serverCtx.eventService.push({
|
req.serverCtx.eventService.push({
|
||||||
@@ -259,7 +592,7 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
|
|
||||||
return res.send();
|
return res.send();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Error %O', err);
|
logger.error(err);
|
||||||
req.serverCtx.eventService.push({
|
req.serverCtx.eventService.push({
|
||||||
type: 'settings-update',
|
type: 'settings-update',
|
||||||
message: 'Error deleting media-source.',
|
message: 'Error deleting media-source.',
|
||||||
@@ -343,6 +676,7 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
}),
|
}),
|
||||||
// TODO: Change this
|
// TODO: Change this
|
||||||
400: z.string(),
|
400: z.string(),
|
||||||
|
500: z.string(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -381,54 +715,38 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
fastify.get(
|
// TODO put this in its own class.
|
||||||
'/plex/status',
|
function convertToApiMediaSource(
|
||||||
{
|
entityLocker: EntityMutex,
|
||||||
schema: {
|
source: MarkOptional<MediaSourceWithLibraries, 'libraries'>,
|
||||||
tags: ['Media Source'],
|
): MediaSourceSettings | null {
|
||||||
querystring: z.object({
|
return match(source)
|
||||||
serverName: z.string(),
|
.returnType<MediaSourceSettings | null>()
|
||||||
}),
|
.with(
|
||||||
response: {
|
{ type: P.union('plex', 'jellyfin', 'emby') },
|
||||||
200: MediaSourceStatusSchema,
|
(source) =>
|
||||||
404: BaseErrorSchema,
|
({
|
||||||
500: BaseErrorSchema,
|
id: source.uuid,
|
||||||
},
|
index: source.index,
|
||||||
},
|
uri: source.uri,
|
||||||
},
|
type: source.type,
|
||||||
async (req, res) => {
|
name: source.name,
|
||||||
try {
|
accessToken: source.accessToken,
|
||||||
const server = await req.serverCtx.mediaSourceDB.findByType(
|
clientIdentifier: nullToUndefined(source.clientIdentifier),
|
||||||
MediaSourceType.Plex,
|
sendChannelUpdates: numberToBoolean(source.sendChannelUpdates),
|
||||||
req.query.serverName,
|
sendGuideUpdates: numberToBoolean(source.sendGuideUpdates),
|
||||||
);
|
libraries: (source.libraries ?? []).map((library) => ({
|
||||||
|
...library,
|
||||||
if (isNil(server)) {
|
id: library.uuid,
|
||||||
return res.status(404).send({ message: 'Plex server not found.' });
|
type: source.type,
|
||||||
}
|
enabled: numberToBoolean(library.enabled),
|
||||||
|
lastScannedAt: nullToUndefined(library.lastScannedAt),
|
||||||
const plex =
|
isLocked: entityLocker.isLibraryLocked(library),
|
||||||
await req.serverCtx.mediaSourceApiFactory.getPlexApiClientForMediaSource(
|
})),
|
||||||
server,
|
userId: source.userId,
|
||||||
);
|
username: source.username,
|
||||||
|
}) satisfies MediaSourceSettings,
|
||||||
const s: MediaSourceStatus = await Promise.race([
|
)
|
||||||
plex.checkServerStatus(),
|
.otherwise(() => null);
|
||||||
wait(15000).then(
|
}
|
||||||
() =>
|
|
||||||
({
|
|
||||||
healthy: false,
|
|
||||||
status: 'timeout',
|
|
||||||
}) satisfies MediaSourceUnhealthyStatus,
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return res.send(s);
|
|
||||||
} catch (err) {
|
|
||||||
return res.status(500).send({
|
|
||||||
message: isError(err) ? err.message : 'Unknown error occurred',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { TruthyQueryParam } from '@/types/schemas.js';
|
import { TruthyQueryParam } from '@/types/schemas.js';
|
||||||
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
||||||
import { isNonEmptyString } from '@/util/index.js';
|
import { isNonEmptyString } from '@/util/index.js';
|
||||||
|
import { tag } from '@tunarr/types';
|
||||||
import axios, { AxiosHeaders } from 'axios';
|
import axios, { AxiosHeaders } from 'axios';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import type { FastifyReply } from 'fastify';
|
import type { FastifyReply } from 'fastify';
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
ProgramSourceType,
|
ProgramSourceType,
|
||||||
programSourceTypeFromString,
|
programSourceTypeFromString,
|
||||||
} from '../db/custom_types/ProgramSourceType.ts';
|
} from '../db/custom_types/ProgramSourceType.ts';
|
||||||
|
import type { MediaSourceId } from '../db/schema/base.ts';
|
||||||
import { getServerContext } from '../ServerContext.ts';
|
import { getServerContext } from '../ServerContext.ts';
|
||||||
|
|
||||||
const externalIdSchema = z
|
const externalIdSchema = z
|
||||||
@@ -46,7 +48,7 @@ const externalIdSchema = z
|
|||||||
const [sourceType, sourceId, itemId] = val.split('|', 3);
|
const [sourceType, sourceId, itemId] = val.split('|', 3);
|
||||||
return {
|
return {
|
||||||
externalSourceType: programSourceTypeFromString(sourceType)!,
|
externalSourceType: programSourceTypeFromString(sourceType)!,
|
||||||
externalSourceId: sourceId,
|
externalSourceId: tag<MediaSourceId>(sourceId),
|
||||||
externalItemId: itemId,
|
externalItemId: itemId,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -58,7 +60,9 @@ const thumbOptsSchema = z.object({
|
|||||||
|
|
||||||
const ExternalMetadataQuerySchema = z.object({
|
const ExternalMetadataQuerySchema = z.object({
|
||||||
id: externalIdSchema,
|
id: externalIdSchema,
|
||||||
asset: z.enum(['thumb', 'external-link', 'image']),
|
asset: z.enum(['image', 'external-link', 'thumb']),
|
||||||
|
imageType: z.enum(['poster', 'background']).default('poster'),
|
||||||
|
|
||||||
mode: z.enum(['json', 'redirect', 'proxy']),
|
mode: z.enum(['json', 'redirect', 'proxy']),
|
||||||
cache: TruthyQueryParam.optional().default(true),
|
cache: TruthyQueryParam.optional().default(true),
|
||||||
thumbOptions: z
|
thumbOptions: z
|
||||||
@@ -66,7 +70,6 @@ const ExternalMetadataQuerySchema = z.object({
|
|||||||
.transform((s) => JSON.parse(s) as unknown)
|
.transform((s) => JSON.parse(s) as unknown)
|
||||||
.pipe(thumbOptsSchema)
|
.pipe(thumbOptsSchema)
|
||||||
.optional(),
|
.optional(),
|
||||||
imageType: z.enum(['poster', 'background']).default('poster'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type ExternalMetadataQuery = z.infer<typeof ExternalMetadataQuerySchema>;
|
type ExternalMetadataQuery = z.infer<typeof ExternalMetadataQuerySchema>;
|
||||||
@@ -192,7 +195,7 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
res: FastifyReply,
|
res: FastifyReply,
|
||||||
) {
|
) {
|
||||||
const plexApi =
|
const plexApi =
|
||||||
await getServerContext().mediaSourceApiFactory.getPlexApiClientByName(
|
await getServerContext().mediaSourceApiFactory.getPlexApiClientById(
|
||||||
query.id.externalSourceId,
|
query.id.externalSourceId,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -209,7 +212,7 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
imageType: query.imageType,
|
imageType: query.imageType,
|
||||||
});
|
});
|
||||||
} else if (query.asset === 'external-link') {
|
} else if (query.asset === 'external-link') {
|
||||||
const server = await getServerContext().mediaSourceDB.getByIdOrName(
|
const server = await getServerContext().mediaSourceDB.getById(
|
||||||
query.id.externalSourceId,
|
query.id.externalSourceId,
|
||||||
);
|
);
|
||||||
if (!server || isNil(server.clientIdentifier)) {
|
if (!server || isNil(server.clientIdentifier)) {
|
||||||
@@ -228,7 +231,7 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
|
|
||||||
async function handleJellyfinItem(query: ExternalMetadataQuery) {
|
async function handleJellyfinItem(query: ExternalMetadataQuery) {
|
||||||
const jellyfinClient =
|
const jellyfinClient =
|
||||||
await getServerContext().mediaSourceApiFactory.getJellyfinApiClientByName(
|
await getServerContext().mediaSourceApiFactory.getJellyfinApiClientById(
|
||||||
query.id.externalSourceId,
|
query.id.externalSourceId,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -237,7 +240,10 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (query.asset === 'thumb' || query.asset === 'image') {
|
if (query.asset === 'thumb' || query.asset === 'image') {
|
||||||
return jellyfinClient.getThumbUrl(query.id.externalItemId);
|
return jellyfinClient.getThumbUrl(
|
||||||
|
query.id.externalItemId,
|
||||||
|
query.imageType === 'poster' ? 'Primary' : 'Thumb',
|
||||||
|
);
|
||||||
} else if (query.asset === 'external-link') {
|
} else if (query.asset === 'external-link') {
|
||||||
return jellyfinClient.getExternalUrl(query.id.externalItemId);
|
return jellyfinClient.getExternalUrl(query.id.externalItemId);
|
||||||
}
|
}
|
||||||
@@ -247,7 +253,7 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
|
|
||||||
async function handleEmbyItem(query: ExternalMetadataQuery) {
|
async function handleEmbyItem(query: ExternalMetadataQuery) {
|
||||||
const embyClient =
|
const embyClient =
|
||||||
await getServerContext().mediaSourceApiFactory.getEmbyApiClientByName(
|
await getServerContext().mediaSourceApiFactory.getEmbyApiClientById(
|
||||||
query.id.externalSourceId,
|
query.id.externalSourceId,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -256,7 +262,10 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (query.asset === 'thumb' || query.asset === 'image') {
|
if (query.asset === 'thumb' || query.asset === 'image') {
|
||||||
return embyClient.getThumbUrl(query.id.externalItemId);
|
return embyClient.getThumbUrl(
|
||||||
|
query.id.externalItemId,
|
||||||
|
query.imageType === 'poster' ? 'Thumb' : 'Primary',
|
||||||
|
);
|
||||||
} else if (query.asset === 'external-link') {
|
} else if (query.asset === 'external-link') {
|
||||||
return embyClient.getExternalUrl(query.id.externalItemId);
|
return embyClient.getExternalUrl(query.id.externalItemId);
|
||||||
}
|
}
|
||||||
|
|||||||
419
server/src/api/plexApi.ts
Normal file
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 { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
|
||||||
import type { MediaSource } from '@/db/schema/MediaSource.js';
|
import type {
|
||||||
|
MediaSource,
|
||||||
|
MediaSourceLibrary,
|
||||||
|
} from '@/db/schema/MediaSource.js';
|
||||||
import { ProgramType } from '@/db/schema/Program.js';
|
import { ProgramType } from '@/db/schema/Program.js';
|
||||||
import { ProgramGroupingType } from '@/db/schema/ProgramGrouping.js';
|
import type { ProgramGrouping as ProgramGroupingDao } from '@/db/schema/ProgramGrouping.js';
|
||||||
|
import {
|
||||||
|
AllProgramGroupingFields,
|
||||||
|
ProgramGroupingType,
|
||||||
|
} from '@/db/schema/ProgramGrouping.js';
|
||||||
import { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
|
import { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
|
||||||
import { PlexApiClient } from '@/external/plex/PlexApiClient.js';
|
import { PlexApiClient } from '@/external/plex/PlexApiClient.js';
|
||||||
import { PagingParams, TruthyQueryParam } from '@/types/schemas.js';
|
import { PagingParams, TruthyQueryParam } from '@/types/schemas.js';
|
||||||
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
|
||||||
import { ifDefined, isNonEmptyString } from '@/util/index.js';
|
import {
|
||||||
|
groupByUniq,
|
||||||
|
groupByUniqAndMap,
|
||||||
|
ifDefined,
|
||||||
|
isNonEmptyString,
|
||||||
|
} from '@/util/index.js';
|
||||||
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
|
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
|
||||||
import { BasicIdParamSchema, ProgramChildrenResult } from '@tunarr/types/api';
|
import { seq } from '@tunarr/shared/util';
|
||||||
|
import {
|
||||||
|
tag,
|
||||||
|
type Episode,
|
||||||
|
type Movie,
|
||||||
|
type MusicAlbum,
|
||||||
|
type MusicArtist,
|
||||||
|
type MusicTrack,
|
||||||
|
type ProgramGrouping,
|
||||||
|
type Season,
|
||||||
|
type Show,
|
||||||
|
type TerminalProgram,
|
||||||
|
} from '@tunarr/types';
|
||||||
|
import {
|
||||||
|
BasicIdParamSchema,
|
||||||
|
ProgramChildrenResult,
|
||||||
|
ProgramSearchRequest,
|
||||||
|
ProgramSearchResponse,
|
||||||
|
SearchFilterQuerySchema,
|
||||||
|
} from '@tunarr/types/api';
|
||||||
import { ContentProgramSchema } from '@tunarr/types/schemas';
|
import { ContentProgramSchema } from '@tunarr/types/schemas';
|
||||||
import axios, { AxiosHeaders, isAxiosError } from 'axios';
|
import axios, { AxiosHeaders, isAxiosError } from 'axios';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import type { HttpHeader } from 'fastify/types/utils.js';
|
import type { HttpHeader } from 'fastify/types/utils.js';
|
||||||
import { jsonArrayFrom } from 'kysely/helpers/sqlite';
|
import { jsonArrayFrom } from 'kysely/helpers/sqlite';
|
||||||
import {
|
import {
|
||||||
|
compact,
|
||||||
every,
|
every,
|
||||||
find,
|
find,
|
||||||
first,
|
first,
|
||||||
@@ -25,19 +58,33 @@ import {
|
|||||||
values,
|
values,
|
||||||
} from 'lodash-es';
|
} from 'lodash-es';
|
||||||
import type stream from 'node:stream';
|
import type stream from 'node:stream';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
import z from 'zod/v4';
|
import z from 'zod/v4';
|
||||||
import { container } from '../container.ts';
|
import { container } from '../container.ts';
|
||||||
import {
|
import {
|
||||||
ProgramSourceType,
|
ProgramSourceType,
|
||||||
programSourceTypeFromString,
|
programSourceTypeFromString,
|
||||||
} from '../db/custom_types/ProgramSourceType.ts';
|
} from '../db/custom_types/ProgramSourceType.ts';
|
||||||
|
import type { ProgramGroupingChildCounts } from '../db/interfaces/IProgramDB.ts';
|
||||||
import {
|
import {
|
||||||
AllProgramFields,
|
AllProgramFields,
|
||||||
AllProgramGroupingFields,
|
|
||||||
selectProgramsBuilder,
|
selectProgramsBuilder,
|
||||||
} from '../db/programQueryHelpers.ts';
|
} from '../db/programQueryHelpers.ts';
|
||||||
|
import type { MediaSourceId } from '../db/schema/base.ts';
|
||||||
|
import type {
|
||||||
|
MediaSourceWithLibraries,
|
||||||
|
ProgramWithRelations,
|
||||||
|
} from '../db/schema/derivedTypes.js';
|
||||||
|
import type {
|
||||||
|
ProgramGroupingSearchDocument,
|
||||||
|
ProgramSearchDocument,
|
||||||
|
TerminalProgramSearchDocument,
|
||||||
|
} from '../services/MeilisearchService.ts';
|
||||||
|
import { decodeCaseSensitiveId } from '../services/MeilisearchService.ts';
|
||||||
import { FfprobeStreamDetails } from '../stream/FfprobeStreamDetails.ts';
|
import { FfprobeStreamDetails } from '../stream/FfprobeStreamDetails.ts';
|
||||||
import { ExternalStreamDetailsFetcherFactory } from '../stream/StreamDetailsFetcher.ts';
|
import { ExternalStreamDetailsFetcherFactory } from '../stream/StreamDetailsFetcher.ts';
|
||||||
|
import type { Path } from '../types/path.ts';
|
||||||
|
import type { Maybe } from '../types/util.ts';
|
||||||
|
|
||||||
const LookupExternalProgrammingSchema = z.object({
|
const LookupExternalProgrammingSchema = z.object({
|
||||||
externalId: z
|
externalId: z
|
||||||
@@ -62,6 +109,243 @@ const BatchLookupExternalProgrammingSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function isProgramGroupingDocument(
|
||||||
|
doc: ProgramSearchDocument,
|
||||||
|
): doc is ProgramGroupingSearchDocument {
|
||||||
|
switch (doc.type) {
|
||||||
|
case 'show':
|
||||||
|
case 'season':
|
||||||
|
case 'artist':
|
||||||
|
case 'album':
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertProgramSearchResult(
|
||||||
|
doc: TerminalProgramSearchDocument,
|
||||||
|
program: ProgramWithRelations,
|
||||||
|
mediaSource: MediaSourceWithLibraries,
|
||||||
|
mediaLibrary: MediaSourceLibrary,
|
||||||
|
): TerminalProgram {
|
||||||
|
if (!program.canonicalId) {
|
||||||
|
throw new Error('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalId = doc.externalIds.find(
|
||||||
|
(eid) => eid.source === mediaSource.type,
|
||||||
|
)?.id;
|
||||||
|
if (!externalId) {
|
||||||
|
throw new Error('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
mediaSourceId: mediaSource.uuid,
|
||||||
|
libraryId: mediaLibrary.uuid,
|
||||||
|
externalLibraryId: mediaLibrary.externalKey,
|
||||||
|
releaseDate: doc.originalReleaseDate,
|
||||||
|
releaseDateString: doc.originalReleaseDate
|
||||||
|
? dayjs(doc.originalReleaseDate).format('YYYY-MM-DD')
|
||||||
|
: null,
|
||||||
|
externalId,
|
||||||
|
sourceType: mediaSource.type,
|
||||||
|
};
|
||||||
|
|
||||||
|
const identifiers = doc.externalIds.map((eid) => ({
|
||||||
|
id: eid.id,
|
||||||
|
sourceId: isNonEmptyString(eid.sourceId)
|
||||||
|
? decodeCaseSensitiveId(eid.sourceId)
|
||||||
|
: undefined,
|
||||||
|
type: eid.source,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const uuid = doc.id;
|
||||||
|
const year =
|
||||||
|
doc.originalReleaseYear ??
|
||||||
|
(doc.originalReleaseDate && doc.originalReleaseDate > 0
|
||||||
|
? dayjs(doc.originalReleaseDate).year()
|
||||||
|
: null);
|
||||||
|
const releaseDate =
|
||||||
|
doc.originalReleaseDate && doc.originalReleaseDate > 0
|
||||||
|
? doc.originalReleaseDate
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const result = match(doc)
|
||||||
|
.returnType<TerminalProgram | null>()
|
||||||
|
.with(
|
||||||
|
{ type: 'episode' },
|
||||||
|
(ep) =>
|
||||||
|
({
|
||||||
|
...ep,
|
||||||
|
...base,
|
||||||
|
uuid,
|
||||||
|
originalTitle: null,
|
||||||
|
year,
|
||||||
|
releaseDate,
|
||||||
|
identifiers,
|
||||||
|
episodeNumber: ep.index ?? 0,
|
||||||
|
canonicalId: program.canonicalId!,
|
||||||
|
// mediaItem: {
|
||||||
|
// displayAspectRatio: '',
|
||||||
|
// duration: doc.duration,
|
||||||
|
// resolution: {
|
||||||
|
// widthPx: doc.videoWidth ?? 0,
|
||||||
|
// heightPx: doc.videoHeight ?? 0,
|
||||||
|
// },
|
||||||
|
// sampleAspectRatio: '',
|
||||||
|
|
||||||
|
// },
|
||||||
|
}) satisfies Episode,
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
{ type: 'movie' },
|
||||||
|
(movie) =>
|
||||||
|
({
|
||||||
|
...movie,
|
||||||
|
...base,
|
||||||
|
identifiers,
|
||||||
|
uuid,
|
||||||
|
originalTitle: null,
|
||||||
|
year,
|
||||||
|
releaseDate,
|
||||||
|
canonicalId: program.canonicalId!,
|
||||||
|
}) satisfies Movie,
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
{ type: 'track' },
|
||||||
|
(track) =>
|
||||||
|
({
|
||||||
|
...track,
|
||||||
|
...base,
|
||||||
|
identifiers,
|
||||||
|
uuid,
|
||||||
|
originalTitle: null,
|
||||||
|
year,
|
||||||
|
releaseDate,
|
||||||
|
canonicalId: program.canonicalId!,
|
||||||
|
trackNumber: doc.index ?? 0,
|
||||||
|
}) satisfies MusicTrack,
|
||||||
|
)
|
||||||
|
.otherwise(() => null);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new Error('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertProgramGroupingSearchResult(
|
||||||
|
doc: ProgramGroupingSearchDocument,
|
||||||
|
grouping: ProgramGroupingDao,
|
||||||
|
childCounts: Maybe<ProgramGroupingChildCounts>,
|
||||||
|
mediaSource: MediaSourceWithLibraries,
|
||||||
|
mediaLibrary: MediaSourceLibrary,
|
||||||
|
) {
|
||||||
|
if (!grouping.canonicalId) {
|
||||||
|
throw new Error('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const childCount = childCounts?.childCount;
|
||||||
|
const grandchildCount = childCounts?.grandchildCount;
|
||||||
|
|
||||||
|
const identifiers = doc.externalIds.map((eid) => ({
|
||||||
|
id: eid.id,
|
||||||
|
sourceId: isNonEmptyString(eid.sourceId)
|
||||||
|
? decodeCaseSensitiveId(eid.sourceId)
|
||||||
|
: undefined,
|
||||||
|
type: eid.source,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const uuid = doc.id;
|
||||||
|
const studios = doc?.studio?.map(({ name }) => ({ name })) ?? [];
|
||||||
|
|
||||||
|
const externalId = doc.externalIds.find(
|
||||||
|
(eid) => eid.source === mediaSource.type,
|
||||||
|
)?.id;
|
||||||
|
if (!externalId) {
|
||||||
|
throw new Error('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
mediaSourceId: mediaSource.uuid,
|
||||||
|
libraryId: mediaLibrary.uuid,
|
||||||
|
externalLibraryId: mediaLibrary.externalKey,
|
||||||
|
releaseDate: doc.originalReleaseDate,
|
||||||
|
releaseDateString: doc.originalReleaseDate
|
||||||
|
? dayjs(doc.originalReleaseDate).format('YYYY-MM-DD')
|
||||||
|
: null,
|
||||||
|
externalId,
|
||||||
|
sourceType: mediaSource.type,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = match(doc)
|
||||||
|
.returnType<ProgramGrouping>()
|
||||||
|
.with(
|
||||||
|
{ type: 'season' },
|
||||||
|
(season) =>
|
||||||
|
({
|
||||||
|
...season,
|
||||||
|
...base,
|
||||||
|
identifiers,
|
||||||
|
uuid,
|
||||||
|
canonicalId: grouping.canonicalId!,
|
||||||
|
studios,
|
||||||
|
year: doc.originalReleaseYear,
|
||||||
|
index: doc.index ?? 0,
|
||||||
|
childCount,
|
||||||
|
grandchildCount,
|
||||||
|
}) satisfies Season,
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
{ type: 'show' },
|
||||||
|
(show) =>
|
||||||
|
({
|
||||||
|
...show,
|
||||||
|
...base,
|
||||||
|
identifiers,
|
||||||
|
uuid,
|
||||||
|
canonicalId: grouping.canonicalId!,
|
||||||
|
studios,
|
||||||
|
year: doc.originalReleaseYear,
|
||||||
|
childCount,
|
||||||
|
grandchildCount,
|
||||||
|
}) satisfies Show,
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
{ type: 'album' },
|
||||||
|
(album) =>
|
||||||
|
({
|
||||||
|
...album,
|
||||||
|
...base,
|
||||||
|
identifiers,
|
||||||
|
uuid,
|
||||||
|
canonicalId: grouping.canonicalId!,
|
||||||
|
// studios,
|
||||||
|
year: doc.originalReleaseYear,
|
||||||
|
childCount,
|
||||||
|
grandchildCount,
|
||||||
|
}) satisfies MusicAlbum,
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
{ type: 'artist' },
|
||||||
|
(artist) =>
|
||||||
|
({
|
||||||
|
...artist,
|
||||||
|
...base,
|
||||||
|
identifiers,
|
||||||
|
uuid,
|
||||||
|
canonicalId: grouping.canonicalId!,
|
||||||
|
childCount,
|
||||||
|
grandchildCount,
|
||||||
|
}) satisfies MusicArtist,
|
||||||
|
)
|
||||||
|
.exhaustive();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/require-await
|
// eslint-disable-next-line @typescript-eslint/require-await
|
||||||
export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
||||||
const logger = LoggerFactory.child({
|
const logger = LoggerFactory.child({
|
||||||
@@ -69,6 +353,237 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
className: 'ProgrammingApi',
|
className: 'ProgrammingApi',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
'/programs/search',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
body: ProgramSearchRequest,
|
||||||
|
response: {
|
||||||
|
200: ProgramSearchResponse,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (req, res) => {
|
||||||
|
const result = await req.serverCtx.searchService.search('programs', {
|
||||||
|
query: req.body.query.query,
|
||||||
|
filter: req.body.query.filter,
|
||||||
|
paging: {
|
||||||
|
offset: req.body.page ?? 1,
|
||||||
|
limit: req.body.limit ?? 20,
|
||||||
|
},
|
||||||
|
libraryId: req.body.libraryId,
|
||||||
|
// TODO not a great cast...
|
||||||
|
restrictSearchTo: req.body.query
|
||||||
|
.restrictSearchTo as Path<ProgramSearchDocument>[],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [programIds, groupingIds] = result.hits.reduce(
|
||||||
|
(acc, curr) => {
|
||||||
|
const [programs, groupings] = acc;
|
||||||
|
if (isProgramGroupingDocument(curr)) {
|
||||||
|
groupings.push(curr.id);
|
||||||
|
} else {
|
||||||
|
programs.push(curr.id);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[[], []] as [string[], string[]],
|
||||||
|
);
|
||||||
|
|
||||||
|
const allMediaSources = await req.serverCtx.mediaSourceDB.getAll();
|
||||||
|
const allMediaSourcesById = groupByUniq(
|
||||||
|
allMediaSources,
|
||||||
|
(ms) => ms.uuid as string,
|
||||||
|
);
|
||||||
|
const allLibrariesById = groupByUniq(
|
||||||
|
allMediaSources.flatMap((ms) => ms.libraries),
|
||||||
|
(lib) => lib.uuid,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [programs, groupings, groupingCounts] = await Promise.all([
|
||||||
|
req.serverCtx.programDB
|
||||||
|
.getProgramsByIds(programIds)
|
||||||
|
.then((res) => groupByUniq(res, (p) => p.uuid)),
|
||||||
|
req.serverCtx.programDB.getProgramGroupings(groupingIds),
|
||||||
|
req.serverCtx.programDB.getProgramGroupingChildCounts(groupingIds),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const results = seq.collect(result.hits, (program) => {
|
||||||
|
const mediaSourceId = decodeCaseSensitiveId(program.mediaSourceId);
|
||||||
|
const mediaSource = allMediaSourcesById[mediaSourceId];
|
||||||
|
if (!mediaSource) {
|
||||||
|
console.log('no media src');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const libraryId = decodeCaseSensitiveId(program.libraryId);
|
||||||
|
const library = allLibrariesById[libraryId];
|
||||||
|
if (!library) {
|
||||||
|
console.log('no library');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isProgramGroupingDocument(program) && groupings[program.id]) {
|
||||||
|
return convertProgramGroupingSearchResult(
|
||||||
|
program,
|
||||||
|
groupings[program.id],
|
||||||
|
groupingCounts[program.id],
|
||||||
|
mediaSource,
|
||||||
|
library,
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
!isProgramGroupingDocument(program) &&
|
||||||
|
programs[program.id]
|
||||||
|
) {
|
||||||
|
return convertProgramSearchResult(
|
||||||
|
program,
|
||||||
|
programs[program.id],
|
||||||
|
mediaSource,
|
||||||
|
library,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('here');
|
||||||
|
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.send({
|
||||||
|
results,
|
||||||
|
page: result.page,
|
||||||
|
totalHits: result.totalHits,
|
||||||
|
totalPages: result.totalPages,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.get(
|
||||||
|
'/programs/:id/descendants',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
id: z.uuid(),
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.array(ContentProgramSchema),
|
||||||
|
404: z.void(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (req, res) => {
|
||||||
|
const grouping = await req.serverCtx.programDB.getProgramGrouping(
|
||||||
|
req.params.id,
|
||||||
|
);
|
||||||
|
if (isNil(grouping)) {
|
||||||
|
const program = await req.serverCtx.programDB.getProgramById(
|
||||||
|
req.params.id,
|
||||||
|
);
|
||||||
|
if (program) {
|
||||||
|
return res.send(
|
||||||
|
compact([
|
||||||
|
req.serverCtx.programConverter.convertProgramWithExternalIds(
|
||||||
|
program,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(404).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
const programs =
|
||||||
|
await req.serverCtx.programDB.getProgramGroupingDescendants(
|
||||||
|
req.params.id,
|
||||||
|
grouping.type,
|
||||||
|
);
|
||||||
|
|
||||||
|
const apiPrograms = seq.collect(programs, (program) =>
|
||||||
|
req.serverCtx.programConverter.convertProgramWithExternalIds(program),
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.send(apiPrograms);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.get(
|
||||||
|
'/programs/facets/:facetName',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
facetName: z.string(),
|
||||||
|
}),
|
||||||
|
querystring: z.object({
|
||||||
|
facetQuery: z.string().optional(),
|
||||||
|
libraryId: z.string().uuid().optional(),
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
facetValues: z.record(z.string(), z.number()),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (req, res) => {
|
||||||
|
const facetResult = await req.serverCtx.searchService.facetSearch(
|
||||||
|
'programs',
|
||||||
|
{
|
||||||
|
facetQuery: req.query.facetQuery,
|
||||||
|
facetName: req.params.facetName,
|
||||||
|
libraryId: req.query.libraryId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.send({
|
||||||
|
facetValues: groupByUniqAndMap(
|
||||||
|
facetResult.facetHits,
|
||||||
|
'value',
|
||||||
|
(hit) => hit.count,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
'/programs/facets/:facetName',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
facetName: z.string(),
|
||||||
|
}),
|
||||||
|
querystring: z.object({
|
||||||
|
facetQuery: z.string().optional(),
|
||||||
|
libraryId: z.string().uuid().optional(),
|
||||||
|
}),
|
||||||
|
body: z.object({
|
||||||
|
filter: SearchFilterQuerySchema.optional(),
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
facetValues: z.record(z.string(), z.number()),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (req, res) => {
|
||||||
|
const facetResult = await req.serverCtx.searchService.facetSearch(
|
||||||
|
'programs',
|
||||||
|
{
|
||||||
|
facetQuery: req.query.facetQuery,
|
||||||
|
facetName: req.params.facetName,
|
||||||
|
libraryId: req.query.libraryId,
|
||||||
|
filter: req.body.filter,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.send({
|
||||||
|
facetValues: groupByUniqAndMap(
|
||||||
|
facetResult.facetHits,
|
||||||
|
'value',
|
||||||
|
(hit) => hit.count,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
fastify.get(
|
fastify.get(
|
||||||
'/programs/:id',
|
'/programs/:id',
|
||||||
{
|
{
|
||||||
@@ -111,11 +626,15 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
|
|
||||||
if (!program) {
|
if (!program) {
|
||||||
return res.status(404).send('Program not found');
|
return res.status(404).send('Program not found');
|
||||||
|
} else if (!program.mediaSourceId) {
|
||||||
|
return res
|
||||||
|
.status(404)
|
||||||
|
.send('Program has no associated media source ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = await req.serverCtx.mediaSourceDB.findByType(
|
const server = await req.serverCtx.mediaSourceDB.findByType(
|
||||||
program.sourceType,
|
program.sourceType,
|
||||||
program.mediaSourceId ?? program.externalSourceId,
|
program.mediaSourceId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
@@ -184,6 +703,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: ProgramChildrenResult,
|
200: ProgramChildrenResult,
|
||||||
|
400: z.void(),
|
||||||
404: z.void(),
|
404: z.void(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -202,7 +722,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
grouping.type,
|
grouping.type,
|
||||||
req.query,
|
req.query,
|
||||||
);
|
);
|
||||||
const result = results.map((program) =>
|
const result = seq.collect(results, (program) =>
|
||||||
req.serverCtx.programConverter.programDaoToContentProgram(
|
req.serverCtx.programConverter.programDaoToContentProgram(
|
||||||
program,
|
program,
|
||||||
program.externalIds,
|
program.externalIds,
|
||||||
@@ -215,6 +735,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
type: grouping.type === 'album' ? 'track' : 'episode',
|
type: grouping.type === 'album' ? 'track' : 'episode',
|
||||||
programs: result,
|
programs: result,
|
||||||
},
|
},
|
||||||
|
size: result.length,
|
||||||
});
|
});
|
||||||
} else if (grouping.type === 'artist') {
|
} else if (grouping.type === 'artist') {
|
||||||
const { total, results } = await req.serverCtx.programDB.getChildren(
|
const { total, results } = await req.serverCtx.programDB.getChildren(
|
||||||
@@ -225,7 +746,11 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
const result = results.map((program) =>
|
const result = results.map((program) =>
|
||||||
req.serverCtx.programConverter.programGroupingDaoToDto(program),
|
req.serverCtx.programConverter.programGroupingDaoToDto(program),
|
||||||
);
|
);
|
||||||
return res.send({ total, result: { type: 'album', programs: result } });
|
return res.send({
|
||||||
|
total,
|
||||||
|
result: { type: 'album', programs: result },
|
||||||
|
size: result.length,
|
||||||
|
});
|
||||||
} else if (grouping.type === 'show') {
|
} else if (grouping.type === 'show') {
|
||||||
const { total, results } = await req.serverCtx.programDB.getChildren(
|
const { total, results } = await req.serverCtx.programDB.getChildren(
|
||||||
req.params.id,
|
req.params.id,
|
||||||
@@ -238,6 +763,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
return res.send({
|
return res.send({
|
||||||
total,
|
total,
|
||||||
result: { type: 'season', programs: result },
|
result: { type: 'season', programs: result },
|
||||||
|
size: result.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,6 +809,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
const handleResult = async (mediaSource: MediaSource, result: string) => {
|
const handleResult = async (mediaSource: MediaSource, result: string) => {
|
||||||
if (req.query.method === 'proxy') {
|
if (req.query.method === 'proxy') {
|
||||||
try {
|
try {
|
||||||
|
logger.debug('Proxying response to %s', result);
|
||||||
const proxyRes = await axios.request<stream.Readable>({
|
const proxyRes = await axios.request<stream.Readable>({
|
||||||
url: result,
|
url: result,
|
||||||
responseType: 'stream',
|
responseType: 'stream',
|
||||||
@@ -317,14 +844,17 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
return res.redirect(result, 302).send();
|
return res.redirect(result, 302).send();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isNil(program)) {
|
if (!isNil(program?.mediaSourceId)) {
|
||||||
const mediaSource = await req.serverCtx.mediaSourceDB.getByExternalId(
|
const mediaSource = await req.serverCtx.mediaSourceDB.findByType(
|
||||||
program.sourceType,
|
program.sourceType,
|
||||||
program.externalSourceId,
|
program.mediaSourceId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isNil(mediaSource)) {
|
if (isNil(mediaSource)) {
|
||||||
return res.status(404).send();
|
logger.error('No media source: %O', program);
|
||||||
|
return res
|
||||||
|
.status(404)
|
||||||
|
.send(`No media source for id/name ${program.externalSourceId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let keyToUse = program.externalKey;
|
let keyToUse = program.externalKey;
|
||||||
@@ -418,14 +948,14 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
|
|
||||||
const mediaSource = await (isNonEmptyString(source.mediaSourceId)
|
const mediaSource = await (isNonEmptyString(source.mediaSourceId)
|
||||||
? req.serverCtx.mediaSourceDB.getById(source.mediaSourceId)
|
? req.serverCtx.mediaSourceDB.getById(source.mediaSourceId)
|
||||||
: req.serverCtx.mediaSourceDB.getByExternalId(
|
: null);
|
||||||
// This was asserted above
|
|
||||||
source.sourceType as 'plex' | 'jellyfin',
|
|
||||||
source.externalSourceId,
|
|
||||||
));
|
|
||||||
|
|
||||||
if (isNil(mediaSource)) {
|
if (isNil(mediaSource)) {
|
||||||
return res.status(404).send();
|
return res
|
||||||
|
.status(404)
|
||||||
|
.send(
|
||||||
|
`Could not find media source with id ${source.externalSourceId}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (mediaSource.type) {
|
switch (mediaSource.type) {
|
||||||
@@ -475,6 +1005,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
200: z.object({ url: z.string() }),
|
200: z.object({ url: z.string() }),
|
||||||
302: z.void(),
|
302: z.void(),
|
||||||
404: z.void(),
|
404: z.void(),
|
||||||
|
405: z.void(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -501,7 +1032,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
const server = find(
|
const server = find(
|
||||||
mediaSources,
|
mediaSources,
|
||||||
(source) =>
|
(source) =>
|
||||||
source.uuid === externalId.externalSourceId ||
|
source.uuid === externalId.mediaSourceId ||
|
||||||
source.name === externalId.externalSourceId,
|
source.name === externalId.externalSourceId,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -552,11 +1083,12 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
200: ContentProgramSchema,
|
200: ContentProgramSchema,
|
||||||
400: z.object({ message: z.string() }),
|
400: z.object({ message: z.string() }),
|
||||||
404: z.void(),
|
404: z.void(),
|
||||||
|
500: z.string(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const [sourceType, ,] = req.params.externalId;
|
const [sourceType, rawServerId, id] = req.params.externalId;
|
||||||
const sourceTypeParsed = programSourceTypeFromString(sourceType);
|
const sourceTypeParsed = programSourceTypeFromString(sourceType);
|
||||||
if (isUndefined(sourceTypeParsed)) {
|
if (isUndefined(sourceTypeParsed)) {
|
||||||
return res
|
return res
|
||||||
@@ -565,7 +1097,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await req.serverCtx.programDB.lookupByExternalIds(
|
const result = await req.serverCtx.programDB.lookupByExternalIds(
|
||||||
new Set([req.params.externalId]),
|
new Set([[sourceType, tag(rawServerId), id]]),
|
||||||
);
|
);
|
||||||
const program = first(values(result));
|
const program = first(values(result));
|
||||||
|
|
||||||
@@ -573,7 +1105,18 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
return res.status(404).send();
|
return res.status(404).send();
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.send(program);
|
const converted =
|
||||||
|
req.serverCtx.programConverter.programDaoToContentProgram(program);
|
||||||
|
|
||||||
|
if (!converted) {
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.send(
|
||||||
|
'Could not convert program. It might be missing a mediaSourceId',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.send(converted);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -590,8 +1133,24 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
|
const ids = req.body.externalIds
|
||||||
|
.values()
|
||||||
|
.map(
|
||||||
|
([source, sourceId, id]) =>
|
||||||
|
[source, tag<MediaSourceId>(sourceId), id] as const,
|
||||||
|
)
|
||||||
|
.toArray();
|
||||||
|
const results = await req.serverCtx.programDB.lookupByExternalIds(
|
||||||
|
new Set(ids),
|
||||||
|
);
|
||||||
|
|
||||||
return res.send(
|
return res.send(
|
||||||
await req.serverCtx.programDB.lookupByExternalIds(req.body.externalIds),
|
groupByUniq(
|
||||||
|
seq.collect(results, (p) =>
|
||||||
|
req.serverCtx.programConverter.programDaoToContentProgram(p),
|
||||||
|
),
|
||||||
|
(p) => p.id,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ export const sessionApiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: ChannelSessionsResponseSchema,
|
200: ChannelSessionsResponseSchema,
|
||||||
|
201: z.void(),
|
||||||
404: z.string(),
|
404: z.string(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type { StreamConnectionDetails } from '@tunarr/types/api';
|
|||||||
import { ChannelStreamModeSchema } from '@tunarr/types/schemas';
|
import { ChannelStreamModeSchema } from '@tunarr/types/schemas';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import type { FastifyReply } from 'fastify';
|
import type { FastifyReply } from 'fastify';
|
||||||
import { isNil, isNumber, isUndefined } from 'lodash-es';
|
import { isArray, isNil, isNumber, isUndefined } from 'lodash-es';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { PassThrough } from 'node:stream';
|
import { PassThrough } from 'node:stream';
|
||||||
@@ -27,7 +27,14 @@ export const streamApi: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
fastify.addHook('onError', (req, _, error, done) => {
|
fastify.addHook('onError', (req, _, error, done) => {
|
||||||
logger.error(error, '%s %s', req.routeOptions.method, req.routeOptions.url);
|
logger.error(
|
||||||
|
error,
|
||||||
|
'%s %s',
|
||||||
|
isArray(req.routeOptions.method)
|
||||||
|
? req.routeOptions.method.join(', ')
|
||||||
|
: req.routeOptions.method,
|
||||||
|
req.routeOptions.url,
|
||||||
|
);
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export const videoApiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ffmpeg.on('error', (err) => {
|
ffmpeg.on('error', (err) => {
|
||||||
logger.error('FFMPEG ERROR', err);
|
logger.error(err, 'FFMPEG ERROR');
|
||||||
buffer.push(null);
|
buffer.push(null);
|
||||||
void res.status(500).send('FFMPEG ERROR');
|
void res.status(500).send('FFMPEG ERROR');
|
||||||
return;
|
return;
|
||||||
@@ -114,7 +114,7 @@ export const videoApiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
done();
|
done();
|
||||||
},
|
},
|
||||||
onError(req, _, e) {
|
onError(req, _, e) {
|
||||||
logger.error(e, 'Error on /stream: %s. %O', req.raw.url);
|
logger.error(e, 'Error on /stream: %s', req.raw.url);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -155,9 +155,9 @@ export const videoApiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
|||||||
|
|
||||||
if (rawStreamResult.type === 'error') {
|
if (rawStreamResult.type === 'error') {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Error starting stream! Message: %s, Error: %O',
|
|
||||||
rawStreamResult.message,
|
|
||||||
rawStreamResult.error ?? null,
|
rawStreamResult.error ?? null,
|
||||||
|
'Error starting stream! Message: %s',
|
||||||
|
rawStreamResult.message,
|
||||||
);
|
);
|
||||||
return res
|
return res
|
||||||
.status(rawStreamResult.httpStatus)
|
.status(rawStreamResult.httpStatus)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type ServerArgsType = GlobalArgsType & {
|
|||||||
port: number;
|
port: number;
|
||||||
printRoutes: boolean;
|
printRoutes: boolean;
|
||||||
trustProxy: boolean;
|
trustProxy: boolean;
|
||||||
|
searchPort?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RunServerCommand: CommandModule<GlobalArgsType, ServerArgsType> = {
|
export const RunServerCommand: CommandModule<GlobalArgsType, ServerArgsType> = {
|
||||||
@@ -39,6 +40,9 @@ export const RunServerCommand: CommandModule<GlobalArgsType, ServerArgsType> = {
|
|||||||
default: () =>
|
default: () =>
|
||||||
getBooleanEnvVar(TUNARR_ENV_VARS.TRUST_PROXY_ENV_VAR, false),
|
getBooleanEnvVar(TUNARR_ENV_VARS.TRUST_PROXY_ENV_VAR, false),
|
||||||
},
|
},
|
||||||
|
searchPort: {
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
handler: async (
|
handler: async (
|
||||||
opts: ArgumentsCamelCase<MarkOptional<ServerArgsType, 'port'>>,
|
opts: ArgumentsCamelCase<MarkOptional<ServerArgsType, 'port'>>,
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import { isMainThread, parentPort } from 'node:worker_threads';
|
import { isMainThread, parentPort, workerData } from 'node:worker_threads';
|
||||||
import type { CommandModule } from 'yargs';
|
import type { CommandModule } from 'yargs';
|
||||||
import { container } from '../container.ts';
|
import { container } from '../container.ts';
|
||||||
|
import type { ServerOptions } from '../globals.ts';
|
||||||
|
import { setServerOptions } from '../globals.ts';
|
||||||
|
import { StartupService } from '../services/StartupService.ts';
|
||||||
import { TunarrWorker } from '../services/TunarrWorker.ts';
|
import { TunarrWorker } from '../services/TunarrWorker.ts';
|
||||||
import type { GenerateOpenApiCommandArgs } from './GenerateOpenApiCommand.ts';
|
import type { GenerateOpenApiCommandArgs } from './GenerateOpenApiCommand.ts';
|
||||||
import type { GlobalArgsType } from './types.ts';
|
import type { GlobalArgsType } from './types.ts';
|
||||||
|
|
||||||
|
type WorkerData = {
|
||||||
|
serverOptions: ServerOptions;
|
||||||
|
};
|
||||||
|
|
||||||
export const StartWorkerCommand: CommandModule<
|
export const StartWorkerCommand: CommandModule<
|
||||||
GlobalArgsType,
|
GlobalArgsType,
|
||||||
GenerateOpenApiCommandArgs
|
GenerateOpenApiCommandArgs
|
||||||
> = {
|
> = {
|
||||||
command: 'start-worker',
|
command: 'start-worker',
|
||||||
describe: 'Starts a Tunarr worker (internal use only)',
|
describe: 'Starts a Tunarr worker (internal use only)',
|
||||||
// eslint-disable-next-line @typescript-eslint/require-await
|
|
||||||
handler: async () => {
|
handler: async () => {
|
||||||
if (isMainThread) {
|
if (isMainThread) {
|
||||||
console.error('This module is only meant to be run as a worker thread.');
|
console.error('This module is only meant to be run as a worker thread.');
|
||||||
@@ -23,6 +29,11 @@ export const StartWorkerCommand: CommandModule<
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: parse
|
||||||
|
const { serverOptions } = workerData as WorkerData;
|
||||||
|
setServerOptions(serverOptions);
|
||||||
|
|
||||||
|
await container.get<StartupService>(StartupService).runStartupServices();
|
||||||
container.get<TunarrWorker>(TunarrWorker).start();
|
container.get<TunarrWorker>(TunarrWorker).start();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,11 +28,16 @@ import { isMainThread } from 'node:worker_threads';
|
|||||||
import type { DeepPartial } from 'ts-essentials';
|
import type { DeepPartial } from 'ts-essentials';
|
||||||
import { App } from './App.ts';
|
import { App } from './App.ts';
|
||||||
import { SettingsDBFactory } from './db/SettingsDBFactory.ts';
|
import { SettingsDBFactory } from './db/SettingsDBFactory.ts';
|
||||||
|
import { ExternalApiModule } from './external/ExternalApiModule.ts';
|
||||||
import { MediaSourceApiFactory } from './external/MediaSourceApiFactory.ts';
|
import { MediaSourceApiFactory } from './external/MediaSourceApiFactory.ts';
|
||||||
import { FfmpegPipelineBuilderModule } from './ffmpeg/builder/pipeline/PipelineBuilderFactory.ts';
|
import { FfmpegPipelineBuilderModule } from './ffmpeg/builder/pipeline/PipelineBuilderFactory.ts';
|
||||||
import type { IWorkerPool } from './interfaces/IWorkerPool.ts';
|
import type { IWorkerPool } from './interfaces/IWorkerPool.ts';
|
||||||
|
import { EntityMutex } from './services/EntityMutex.ts';
|
||||||
import { FileSystemService } from './services/FileSystemService.ts';
|
import { FileSystemService } from './services/FileSystemService.ts';
|
||||||
|
import { MediaSourceLibraryRefresher } from './services/MediaSourceLibraryRefresher.js';
|
||||||
|
import { MeilisearchService } from './services/MeilisearchService.ts';
|
||||||
import { NoopWorkerPool } from './services/NoopWorkerPool.ts';
|
import { NoopWorkerPool } from './services/NoopWorkerPool.ts';
|
||||||
|
import { ServicesModule } from './services/ServicesModule.ts';
|
||||||
import { StartupService } from './services/StartupService.ts';
|
import { StartupService } from './services/StartupService.ts';
|
||||||
import { SystemDevicesService } from './services/SystemDevicesService.ts';
|
import { SystemDevicesService } from './services/SystemDevicesService.ts';
|
||||||
import { TunarrWorkerPool } from './services/TunarrWorkerPool.ts';
|
import { TunarrWorkerPool } from './services/TunarrWorkerPool.ts';
|
||||||
@@ -47,6 +52,7 @@ import { SeedFfmpegInfoCache } from './services/startup/SeedFfmpegInfoCache.ts';
|
|||||||
import { SeedSystemDevicesStartupTask } from './services/startup/SeedSystemDevicesStartupTask.ts';
|
import { SeedSystemDevicesStartupTask } from './services/startup/SeedSystemDevicesStartupTask.ts';
|
||||||
import { ChannelCache } from './stream/ChannelCache.ts';
|
import { ChannelCache } from './stream/ChannelCache.ts';
|
||||||
import { FixerRunner } from './tasks/fixers/FixerRunner.ts';
|
import { FixerRunner } from './tasks/fixers/FixerRunner.ts';
|
||||||
|
import { ChildProcessHelper } from './util/ChildProcessHelper.ts';
|
||||||
import { Timer } from './util/Timer.ts';
|
import { Timer } from './util/Timer.ts';
|
||||||
import { getBooleanEnvVar, USE_WORKER_POOL_ENV_VAR } from './util/env.ts';
|
import { getBooleanEnvVar, USE_WORKER_POOL_ENV_VAR } from './util/env.ts';
|
||||||
|
|
||||||
@@ -93,6 +99,7 @@ const RootModule = new ContainerModule((bind) => {
|
|||||||
>(() => (timeout?: number) => new MutexMap(timeout));
|
>(() => (timeout?: number) => new MutexMap(timeout));
|
||||||
|
|
||||||
container.bind(MediaSourceApiFactory).toSelf().inSingletonScope();
|
container.bind(MediaSourceApiFactory).toSelf().inSingletonScope();
|
||||||
|
|
||||||
// If we need lazy init...
|
// If we need lazy init...
|
||||||
// container
|
// container
|
||||||
// .bind<MediaSourceApiFactory>(KEYS.MediaSourceApiFactory)
|
// .bind<MediaSourceApiFactory>(KEYS.MediaSourceApiFactory)
|
||||||
@@ -108,6 +115,12 @@ const RootModule = new ContainerModule((bind) => {
|
|||||||
|
|
||||||
bind(FixerRunner).toSelf().inSingletonScope();
|
bind(FixerRunner).toSelf().inSingletonScope();
|
||||||
bind(StartupService).toSelf().inSingletonScope();
|
bind(StartupService).toSelf().inSingletonScope();
|
||||||
|
container
|
||||||
|
.bind<
|
||||||
|
interfaces.Factory<MediaSourceLibraryRefresher>
|
||||||
|
>(KEYS.MediaSourceLibraryRefresher)
|
||||||
|
.toAutoFactory(MediaSourceLibraryRefresher);
|
||||||
|
|
||||||
bind(TVGuideService).toSelf().inSingletonScope();
|
bind(TVGuideService).toSelf().inSingletonScope();
|
||||||
bind(EventService).toSelf().inSingletonScope();
|
bind(EventService).toSelf().inSingletonScope();
|
||||||
bind(HdhrService).toSelf().inSingletonScope();
|
bind(HdhrService).toSelf().inSingletonScope();
|
||||||
@@ -139,6 +152,11 @@ const RootModule = new ContainerModule((bind) => {
|
|||||||
bind<interfaces.AutoFactory<IWorkerPool>>(
|
bind<interfaces.AutoFactory<IWorkerPool>>(
|
||||||
KEYS.WorkerPoolFactory,
|
KEYS.WorkerPoolFactory,
|
||||||
).toAutoFactory(KEYS.WorkerPool);
|
).toAutoFactory(KEYS.WorkerPool);
|
||||||
|
bind(EntityMutex).toSelf().inSingletonScope();
|
||||||
|
bind(MeilisearchService).toSelf().inSingletonScope();
|
||||||
|
bind(KEYS.SearchService).toService(MeilisearchService);
|
||||||
|
|
||||||
|
bind(ChildProcessHelper).toSelf().inSingletonScope();
|
||||||
|
|
||||||
bind(App).toSelf().inSingletonScope();
|
bind(App).toSelf().inSingletonScope();
|
||||||
});
|
});
|
||||||
@@ -152,5 +170,7 @@ container.load(FixerModule);
|
|||||||
container.load(FFmpegModule);
|
container.load(FFmpegModule);
|
||||||
container.load(FfmpegPipelineBuilderModule);
|
container.load(FfmpegPipelineBuilderModule);
|
||||||
container.load(DynamicChannelsModule);
|
container.load(DynamicChannelsModule);
|
||||||
|
container.load(ServicesModule);
|
||||||
|
container.load(ExternalApiModule);
|
||||||
|
|
||||||
export { container };
|
export { container };
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ import {
|
|||||||
isDefined,
|
isDefined,
|
||||||
isNonEmptyString,
|
isNonEmptyString,
|
||||||
mapReduceAsyncSeq,
|
mapReduceAsyncSeq,
|
||||||
|
programExternalIdString,
|
||||||
run,
|
run,
|
||||||
} from '../util/index.ts';
|
} from '../util/index.ts';
|
||||||
import { ProgramConverter } from './converters/ProgramConverter.ts';
|
import { ProgramConverter } from './converters/ProgramConverter.ts';
|
||||||
@@ -99,8 +100,6 @@ import {
|
|||||||
import { SchemaBackedDbAdapter } from './json/SchemaBackedJsonDBAdapter.ts';
|
import { SchemaBackedDbAdapter } from './json/SchemaBackedJsonDBAdapter.ts';
|
||||||
import { calculateStartTimeOffsets } from './lineupUtil.ts';
|
import { calculateStartTimeOffsets } from './lineupUtil.ts';
|
||||||
import {
|
import {
|
||||||
AllProgramGroupingFields,
|
|
||||||
MinimalProgramGroupingFields,
|
|
||||||
withFallbackPrograms,
|
withFallbackPrograms,
|
||||||
withMusicArtistAlbums,
|
withMusicArtistAlbums,
|
||||||
withProgramExternalIds,
|
withProgramExternalIds,
|
||||||
@@ -119,8 +118,12 @@ import {
|
|||||||
NewChannelProgram,
|
NewChannelProgram,
|
||||||
Channel as RawChannel,
|
Channel as RawChannel,
|
||||||
} from './schema/Channel.ts';
|
} from './schema/Channel.ts';
|
||||||
import { programExternalIdString, ProgramType } from './schema/Program.ts';
|
import { ProgramType } from './schema/Program.ts';
|
||||||
import { ProgramGroupingType } from './schema/ProgramGrouping.ts';
|
import {
|
||||||
|
AllProgramGroupingFields,
|
||||||
|
MinimalProgramGroupingFields,
|
||||||
|
ProgramGroupingType,
|
||||||
|
} from './schema/ProgramGrouping.ts';
|
||||||
import {
|
import {
|
||||||
ChannelSubtitlePreferences,
|
ChannelSubtitlePreferences,
|
||||||
NewChannelSubtitlePreference,
|
NewChannelSubtitlePreference,
|
||||||
@@ -1389,7 +1392,9 @@ export class ChannelDB implements IChannelDB {
|
|||||||
externalIdsByProgramId[program.uuid] ?? [],
|
externalIdsByProgramId[program.uuid] ?? [],
|
||||||
);
|
);
|
||||||
|
|
||||||
ret[converted.id] = converted;
|
if (converted) {
|
||||||
|
ret[converted.id] = converted;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return ret;
|
return ret;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { KEYS } from '@/types/inject.js';
|
import { KEYS } from '@/types/inject.js';
|
||||||
import { isNonEmptyString } from '@/util/index.js';
|
import { isNonEmptyString, programExternalIdString } from '@/util/index.js';
|
||||||
|
import { seq } from '@tunarr/shared/util';
|
||||||
import { CustomProgram } from '@tunarr/types';
|
import { CustomProgram } from '@tunarr/types';
|
||||||
import {
|
import {
|
||||||
CreateCustomShowRequest,
|
CreateCustomShowRequest,
|
||||||
@@ -21,7 +22,6 @@ import type {
|
|||||||
NewCustomShow,
|
NewCustomShow,
|
||||||
NewCustomShowContent,
|
NewCustomShowContent,
|
||||||
} from './schema/CustomShow.ts';
|
} from './schema/CustomShow.ts';
|
||||||
import { programExternalIdString } from './schema/Program.ts';
|
|
||||||
import { DB } from './schema/db.ts';
|
import { DB } from './schema/db.ts';
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
@@ -53,15 +53,21 @@ export class CustomShowDB {
|
|||||||
.select((eb) => withCustomShowPrograms(eb, { joins: AllProgramJoins }))
|
.select((eb) => withCustomShowPrograms(eb, { joins: AllProgramJoins }))
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
return map(programs?.customShowContent, (csc) => ({
|
return seq.collect(programs?.customShowContent, (csc) => {
|
||||||
type: 'custom' as const,
|
const program = this.programConverter.programDaoToContentProgram(csc, []);
|
||||||
persisted: true,
|
if (!program) {
|
||||||
duration: csc.duration,
|
return;
|
||||||
program: this.programConverter.programDaoToContentProgram(csc, []),
|
}
|
||||||
customShowId: id,
|
return {
|
||||||
index: csc.index,
|
type: 'custom' as const,
|
||||||
id: csc.uuid,
|
persisted: true,
|
||||||
}));
|
duration: csc.duration,
|
||||||
|
program,
|
||||||
|
customShowId: id,
|
||||||
|
index: csc.index,
|
||||||
|
id: csc.uuid,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveShow(id: string, updateRequest: UpdateCustomShowRequest) {
|
async saveShow(id: string, updateRequest: UpdateCustomShowRequest) {
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class Connection {
|
|||||||
case 'query':
|
case 'query':
|
||||||
if (process.env['DATABASE_DEBUG_LOGGING']) {
|
if (process.env['DATABASE_DEBUG_LOGGING']) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
'Query: %O (%d ms)',
|
'Query: %s (%d ms)',
|
||||||
event.query.sql,
|
event.query.sql,
|
||||||
event.queryDurationMillis,
|
event.queryDurationMillis,
|
||||||
);
|
);
|
||||||
@@ -77,7 +77,7 @@ class Connection {
|
|||||||
case 'error':
|
case 'error':
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
event.error,
|
event.error,
|
||||||
'Query error: %O\n%O',
|
'Query error: %s\n%O',
|
||||||
event.query.sql,
|
event.query.sql,
|
||||||
event.query.parameters,
|
event.query.parameters,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ContainerModule } from 'inversify';
|
|||||||
import type { Kysely } from 'kysely';
|
import type { Kysely } from 'kysely';
|
||||||
import { DBAccess } from './DBAccess.ts';
|
import { DBAccess } from './DBAccess.ts';
|
||||||
import { FillerDB } from './FillerListDB.ts';
|
import { FillerDB } from './FillerListDB.ts';
|
||||||
|
import { ProgramDaoMinter } from './converters/ProgramMinter.ts';
|
||||||
import type { DB } from './schema/db.ts';
|
import type { DB } from './schema/db.ts';
|
||||||
|
|
||||||
const DBModule = new ContainerModule((bind) => {
|
const DBModule = new ContainerModule((bind) => {
|
||||||
@@ -21,6 +22,11 @@ const DBModule = new ContainerModule((bind) => {
|
|||||||
KEYS.Database,
|
KEYS.Database,
|
||||||
);
|
);
|
||||||
bind(KEYS.FillerListDB).to(FillerDB).inSingletonScope();
|
bind(KEYS.FillerListDB).to(FillerDB).inSingletonScope();
|
||||||
|
|
||||||
|
bind(ProgramDaoMinter).toSelf();
|
||||||
|
bind<interfaces.AutoFactory<ProgramDaoMinter>>(
|
||||||
|
KEYS.ProgramDaoMinterFactory,
|
||||||
|
).toAutoFactory<ProgramDaoMinter>(ProgramDaoMinter);
|
||||||
});
|
});
|
||||||
|
|
||||||
export { DBModule as dbContainer };
|
export { DBModule as dbContainer };
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { IProgramDB } from '@/db/interfaces/IProgramDB.js';
|
import type { IProgramDB } from '@/db/interfaces/IProgramDB.js';
|
||||||
import { ChannelCache } from '@/stream/ChannelCache.js';
|
import { ChannelCache } from '@/stream/ChannelCache.js';
|
||||||
import { KEYS } from '@/types/inject.js';
|
import { KEYS } from '@/types/inject.js';
|
||||||
import { isNonEmptyString } from '@/util/index.js';
|
import { isNonEmptyString, programExternalIdString } from '@/util/index.js';
|
||||||
|
import { seq } from '@tunarr/shared/util';
|
||||||
import { ContentProgram } from '@tunarr/types';
|
import { ContentProgram } from '@tunarr/types';
|
||||||
import {
|
import {
|
||||||
CreateFillerListRequest,
|
CreateFillerListRequest,
|
||||||
@@ -44,7 +45,6 @@ import type {
|
|||||||
NewFillerShow,
|
NewFillerShow,
|
||||||
NewFillerShowContent,
|
NewFillerShowContent,
|
||||||
} from './schema/FillerShow.ts';
|
} from './schema/FillerShow.ts';
|
||||||
import { programExternalIdString } from './schema/Program.ts';
|
|
||||||
import { DB } from './schema/db.ts';
|
import { DB } from './schema/db.ts';
|
||||||
import type { ChannelFillerShowWithContent } from './schema/derivedTypes.ts';
|
import type { ChannelFillerShowWithContent } from './schema/derivedTypes.ts';
|
||||||
|
|
||||||
@@ -356,7 +356,7 @@ export class FillerDB implements IFillerListDB {
|
|||||||
)
|
)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
return map(programs?.fillerContent, (program) =>
|
return seq.collect(programs?.fillerContent, (program) =>
|
||||||
this.programConverter.programDaoToContentProgram(program, []),
|
this.programConverter.programDaoToContentProgram(program, []),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ import {
|
|||||||
SystemSettingsSchema,
|
SystemSettingsSchema,
|
||||||
XmlTvSettings,
|
XmlTvSettings,
|
||||||
defaultFfmpegSettings,
|
defaultFfmpegSettings,
|
||||||
|
defaultGlobalMediaSourceSettings,
|
||||||
defaultHdhrSettings,
|
defaultHdhrSettings,
|
||||||
defaultPlexStreamSettings,
|
defaultPlexStreamSettings,
|
||||||
defaultXmlTvSettings as defaultXmlTvSettingsSchema,
|
defaultXmlTvSettings as defaultXmlTvSettingsSchema,
|
||||||
@@ -23,6 +24,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
BackupSettings,
|
BackupSettings,
|
||||||
FfmpegSettingsSchema,
|
FfmpegSettingsSchema,
|
||||||
|
GlobalMediaSourceSettings,
|
||||||
|
GlobalMediaSourceSettingsSchema,
|
||||||
HdhrSettingsSchema,
|
HdhrSettingsSchema,
|
||||||
PlexStreamSettingsSchema,
|
PlexStreamSettingsSchema,
|
||||||
XmlTvSettingsSchema,
|
XmlTvSettingsSchema,
|
||||||
@@ -56,6 +59,7 @@ export const SettingsSchema = z.object({
|
|||||||
xmltv: XmlTvSettingsSchema,
|
xmltv: XmlTvSettingsSchema,
|
||||||
plexStream: PlexStreamSettingsSchema,
|
plexStream: PlexStreamSettingsSchema,
|
||||||
ffmpeg: FfmpegSettingsSchema,
|
ffmpeg: FfmpegSettingsSchema,
|
||||||
|
mediaSource: GlobalMediaSourceSettingsSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Settings = z.infer<typeof SettingsSchema>;
|
export type Settings = z.infer<typeof SettingsSchema>;
|
||||||
@@ -96,6 +100,7 @@ export const defaultSettings = (dbBasePath: string): SettingsFile => ({
|
|||||||
xmltv: defaultXmlTvSettings(dbBasePath),
|
xmltv: defaultXmlTvSettings(dbBasePath),
|
||||||
plexStream: defaultPlexStreamSettings,
|
plexStream: defaultPlexStreamSettings,
|
||||||
ffmpeg: defaultFfmpegSettings,
|
ffmpeg: defaultFfmpegSettings,
|
||||||
|
mediaSource: defaultGlobalMediaSourceSettings,
|
||||||
},
|
},
|
||||||
system: {
|
system: {
|
||||||
backup: {
|
backup: {
|
||||||
@@ -175,6 +180,10 @@ export class SettingsDB extends ITypedEventEmitter implements ISettingsDB {
|
|||||||
return this.db.data.system;
|
return this.db.data.system;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
globalMediaSourceSettings(): DeepReadonly<GlobalMediaSourceSettings> {
|
||||||
|
return this.db.data.settings.mediaSource;
|
||||||
|
}
|
||||||
|
|
||||||
updateFfmpegSettings(ffmpegSettings: FfmpegSettings) {
|
updateFfmpegSettings(ffmpegSettings: FfmpegSettings) {
|
||||||
return this.updateSettings('ffmpeg', { ...ffmpegSettings });
|
return this.updateSettings('ffmpeg', { ...ffmpegSettings });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { inject, injectable } from 'inversify';
|
|||||||
import { Kysely } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
import { omit } from 'lodash-es';
|
import { omit } from 'lodash-es';
|
||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
import { TranscodeConfigNotFoundError } from '../types/errors.ts';
|
import { TranscodeConfigNotFoundError, WrappedError } from '../types/errors.ts';
|
||||||
import { KEYS } from '../types/inject.ts';
|
import { KEYS } from '../types/inject.ts';
|
||||||
import { Result } from '../types/result.ts';
|
import { Result } from '../types/result.ts';
|
||||||
import {
|
import {
|
||||||
@@ -88,7 +88,9 @@ export class TranscodeConfigDB {
|
|||||||
|
|
||||||
async duplicateConfig(
|
async duplicateConfig(
|
||||||
id: string,
|
id: string,
|
||||||
): Promise<Result<TranscodeConfigDAO, TranscodeConfigNotFoundError | Error>> {
|
): Promise<
|
||||||
|
Result<TranscodeConfigDAO, TranscodeConfigNotFoundError | WrappedError>
|
||||||
|
> {
|
||||||
const baseConfig = await this.getById(id);
|
const baseConfig = await this.getById(id);
|
||||||
if (!baseConfig) {
|
if (!baseConfig) {
|
||||||
return Result.failure(new TranscodeConfigNotFoundError(id));
|
return Result.failure(new TranscodeConfigNotFoundError(id));
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ export class ArchiveDatabaseBackup extends DatabaseBackup<string> {
|
|||||||
)) {
|
)) {
|
||||||
if (result.isFailure()) {
|
if (result.isFailure()) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
'Unable to delete old backup file: %s',
|
'Unable to delete old backup file: %O',
|
||||||
result.error.input,
|
result.error.input,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { Kysely } from 'kysely';
|
|||||||
import { find, isNil, omitBy } from 'lodash-es';
|
import { find, isNil, omitBy } from 'lodash-es';
|
||||||
import { isPromise } from 'node:util/types';
|
import { isPromise } from 'node:util/types';
|
||||||
import { DeepNullable, DeepPartial, MarkRequired } from 'ts-essentials';
|
import { DeepNullable, DeepPartial, MarkRequired } from 'ts-essentials';
|
||||||
|
import { MarkNonNullable, Nullable } from '../../types/util.ts';
|
||||||
import {
|
import {
|
||||||
LineupItem,
|
LineupItem,
|
||||||
OfflineItem,
|
OfflineItem,
|
||||||
@@ -91,11 +92,12 @@ export class ProgramConverter {
|
|||||||
}
|
}
|
||||||
return this.redirectLineupItemToProgram(item, redirectChannel);
|
return this.redirectLineupItemToProgram(item, redirectChannel);
|
||||||
} else if (item.type === 'content') {
|
} else if (item.type === 'content') {
|
||||||
|
console.log(channel.programs);
|
||||||
const program =
|
const program =
|
||||||
preMaterializedProgram && preMaterializedProgram.uuid === item.id
|
preMaterializedProgram && preMaterializedProgram.uuid === item.id
|
||||||
? preMaterializedProgram
|
? preMaterializedProgram
|
||||||
: channel.programs.find((p) => p.uuid === item.id);
|
: channel.programs.find((p) => p.uuid === item.id);
|
||||||
if (isNil(program)) {
|
if (isNil(program) || isNil(program.mediaSourceId)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,10 +110,44 @@ export class ProgramConverter {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
convertProgramWithExternalIds(
|
||||||
|
program: MarkNonNullable<
|
||||||
|
MarkRequired<ProgramWithRelations, 'externalIds'>,
|
||||||
|
'mediaSourceId'
|
||||||
|
>,
|
||||||
|
): MarkRequired<ContentProgram, 'id'>;
|
||||||
|
convertProgramWithExternalIds(
|
||||||
|
program: MarkRequired<ProgramWithRelations, 'externalIds'>,
|
||||||
|
): MarkRequired<ContentProgram, 'id'> | null;
|
||||||
|
convertProgramWithExternalIds(
|
||||||
|
program:
|
||||||
|
| MarkRequired<ProgramWithRelations, 'externalIds'>
|
||||||
|
| MarkRequired<
|
||||||
|
MarkNonNullable<ProgramWithRelations, 'mediaSourceId'>,
|
||||||
|
'externalIds'
|
||||||
|
>,
|
||||||
|
): Nullable<MarkRequired<ContentProgram, 'id'>> {
|
||||||
|
return this.programDaoToContentProgram(program);
|
||||||
|
}
|
||||||
|
|
||||||
|
programDaoToContentProgram(
|
||||||
|
program: MarkNonNullable<ProgramWithRelations, 'mediaSourceId'>,
|
||||||
|
externalIds?: MinimalProgramExternalId[],
|
||||||
|
): MarkRequired<ContentProgram, 'id'>;
|
||||||
programDaoToContentProgram(
|
programDaoToContentProgram(
|
||||||
program: ProgramWithRelations,
|
program: ProgramWithRelations,
|
||||||
|
externalIds?: MinimalProgramExternalId[],
|
||||||
|
): MarkRequired<ContentProgram, 'id'> | null;
|
||||||
|
programDaoToContentProgram(
|
||||||
|
program:
|
||||||
|
| ProgramWithRelations
|
||||||
|
| MarkNonNullable<ProgramWithRelations, 'mediaSourceId'>,
|
||||||
externalIds: MinimalProgramExternalId[] = program.externalIds ?? [],
|
externalIds: MinimalProgramExternalId[] = program.externalIds ?? [],
|
||||||
): MarkRequired<ContentProgram, 'id'> {
|
): MarkRequired<ContentProgram, 'id'> | null {
|
||||||
|
if (!program.mediaSourceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
let extraFields: Partial<ContentProgram> = {};
|
let extraFields: Partial<ContentProgram> = {};
|
||||||
if (program.type === ProgramType.Episode) {
|
if (program.type === ProgramType.Episode) {
|
||||||
extraFields = {
|
extraFields = {
|
||||||
@@ -216,11 +252,14 @@ export class ProgramConverter {
|
|||||||
type: 'content',
|
type: 'content',
|
||||||
id: program.uuid,
|
id: program.uuid,
|
||||||
subtype: program.type,
|
subtype: program.type,
|
||||||
externalIds: seq.collect(externalIds, (eid) => this.toExternalId(eid)),
|
externalIds: seq.collect(program.externalIds ?? externalIds, (eid) =>
|
||||||
|
this.toExternalId(eid),
|
||||||
|
),
|
||||||
externalKey: program.externalKey,
|
externalKey: program.externalKey,
|
||||||
externalSourceId: program.externalSourceId,
|
externalSourceId: program.mediaSourceId,
|
||||||
externalSourceName: program.externalSourceId,
|
externalSourceName: program.externalSourceId,
|
||||||
externalSourceType: program.sourceType,
|
externalSourceType: program.sourceType,
|
||||||
|
canonicalId: nullToUndefined(program.canonicalId),
|
||||||
...omitBy(extraFields, isNil),
|
...omitBy(extraFields, isNil),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,73 +1,47 @@
|
|||||||
import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
|
import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
|
||||||
import type { NewSingleOrMultiProgramGroupingExternalId } from '@/db/schema/ProgramGroupingExternalId.js';
|
import type { NewSingleOrMultiProgramGroupingExternalId } from '@/db/schema/ProgramGroupingExternalId.js';
|
||||||
import { isNonEmptyString } from '@/util/index.js';
|
import { isNonEmptyString } from '@/util/index.js';
|
||||||
|
import { seq } from '@tunarr/shared/util';
|
||||||
import type { ContentProgram } from '@tunarr/types';
|
import type { ContentProgram } from '@tunarr/types';
|
||||||
import type { JellyfinItem } from '@tunarr/types/jellyfin';
|
import {
|
||||||
import type { PlexEpisode, PlexMusicTrack } from '@tunarr/types/plex';
|
isValidMultiExternalIdType,
|
||||||
|
isValidSingleExternalIdType,
|
||||||
|
} from '@tunarr/types/schemas';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import { injectable } from 'inversify';
|
||||||
import { first } from 'lodash-es';
|
import { first } from 'lodash-es';
|
||||||
import type { MarkRequired } from 'ts-essentials';
|
import type { MarkRequired } from 'ts-essentials';
|
||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
|
import {
|
||||||
|
MediaSourceMusicAlbum,
|
||||||
|
MediaSourceMusicArtist,
|
||||||
|
MediaSourceSeason,
|
||||||
|
MediaSourceShow,
|
||||||
|
} from '../../types/Media.ts';
|
||||||
import type { Nullable } from '../../types/util.ts';
|
import type { Nullable } from '../../types/util.ts';
|
||||||
|
import { MediaSourceId, MediaSourceName } from '../schema/base.ts';
|
||||||
|
import {
|
||||||
|
NewMusicAlbum,
|
||||||
|
NewMusicArtist,
|
||||||
|
NewProgramGroupingWithExternalIds,
|
||||||
|
NewTvSeason,
|
||||||
|
NewTvShow,
|
||||||
|
} from '../schema/derivedTypes.js';
|
||||||
|
import { MediaSource, MediaSourceLibrary } from '../schema/MediaSource.ts';
|
||||||
import {
|
import {
|
||||||
ProgramGroupingType,
|
ProgramGroupingType,
|
||||||
type NewProgramGrouping,
|
type NewProgramGrouping,
|
||||||
} from '../schema/ProgramGrouping.ts';
|
} from '../schema/ProgramGrouping.ts';
|
||||||
|
|
||||||
|
@injectable()
|
||||||
export class ProgramGroupingMinter {
|
export class ProgramGroupingMinter {
|
||||||
static mintParentProgramGroupingForPlex(
|
constructor() {}
|
||||||
plexItem: PlexEpisode | PlexMusicTrack,
|
|
||||||
): NewProgramGrouping {
|
|
||||||
const now = +dayjs();
|
|
||||||
|
|
||||||
return {
|
|
||||||
uuid: v4(),
|
|
||||||
type:
|
|
||||||
plexItem.type === 'episode'
|
|
||||||
? ProgramGroupingType.Season
|
|
||||||
: ProgramGroupingType.Album,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
index: plexItem.parentIndex ?? null,
|
|
||||||
title: plexItem.parentTitle ?? '',
|
|
||||||
summary: null,
|
|
||||||
icon: null,
|
|
||||||
artistUuid: null,
|
|
||||||
showUuid: null,
|
|
||||||
year: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static mintParentProgramGroupingForJellyfin(jellyfinItem: JellyfinItem) {
|
|
||||||
if (jellyfinItem.Type !== 'Episode' && jellyfinItem.Type !== 'Audio') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = +dayjs();
|
|
||||||
|
|
||||||
return {
|
|
||||||
uuid: v4(),
|
|
||||||
type:
|
|
||||||
jellyfinItem.Type === 'Episode'
|
|
||||||
? ProgramGroupingType.Show
|
|
||||||
: ProgramGroupingType.Album,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
index: jellyfinItem.ParentIndexNumber ?? null,
|
|
||||||
title: jellyfinItem.SeasonName ?? jellyfinItem.Album ?? '',
|
|
||||||
summary: null,
|
|
||||||
icon: null,
|
|
||||||
artistUuid: null,
|
|
||||||
showUuid: null,
|
|
||||||
year: jellyfinItem.ProductionYear,
|
|
||||||
} satisfies NewProgramGrouping;
|
|
||||||
}
|
|
||||||
|
|
||||||
static mintGroupingExternalIds(
|
static mintGroupingExternalIds(
|
||||||
program: ContentProgram,
|
program: ContentProgram,
|
||||||
groupingId: string,
|
groupingId: string,
|
||||||
externalSourceId: string,
|
externalSourceId: MediaSourceName,
|
||||||
mediaSourceId: string,
|
mediaSourceId: MediaSourceId,
|
||||||
relationType: 'parent' | 'grandparent',
|
relationType: 'parent' | 'grandparent',
|
||||||
): NewSingleOrMultiProgramGroupingExternalId[] {
|
): NewSingleOrMultiProgramGroupingExternalId[] {
|
||||||
if (program.subtype === 'movie') {
|
if (program.subtype === 'movie') {
|
||||||
@@ -124,6 +98,10 @@ export class ProgramGroupingMinter {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!item.canonicalId || !item.libraryId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const now = +dayjs();
|
const now = +dayjs();
|
||||||
return {
|
return {
|
||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
@@ -140,6 +118,8 @@ export class ProgramGroupingMinter {
|
|||||||
artistUuid: null,
|
artistUuid: null,
|
||||||
showUuid: null,
|
showUuid: null,
|
||||||
year: item.grandparent.year,
|
year: item.grandparent.year,
|
||||||
|
canonicalId: item.canonicalId,
|
||||||
|
libraryId: item.libraryId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +130,10 @@ export class ProgramGroupingMinter {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!item.canonicalId || !item.libraryId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const now = +dayjs();
|
const now = +dayjs();
|
||||||
return {
|
return {
|
||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
@@ -166,6 +150,210 @@ export class ProgramGroupingMinter {
|
|||||||
artistUuid: null,
|
artistUuid: null,
|
||||||
showUuid: null,
|
showUuid: null,
|
||||||
year: item.parent.year,
|
year: item.parent.year,
|
||||||
|
canonicalId: item.canonicalId,
|
||||||
|
libraryId: item.libraryId,
|
||||||
} satisfies NewProgramGrouping;
|
} satisfies NewProgramGrouping;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mintForMediaSourceShow(
|
||||||
|
mediaSource: MediaSource,
|
||||||
|
mediaSourceLibrary: MediaSourceLibrary,
|
||||||
|
show: MediaSourceShow,
|
||||||
|
): NewTvShow {
|
||||||
|
const now = +dayjs();
|
||||||
|
const groupingId = v4();
|
||||||
|
|
||||||
|
const externalIds = seq.collect(show.identifiers, (id) => {
|
||||||
|
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
|
||||||
|
return {
|
||||||
|
type: 'single',
|
||||||
|
externalKey: id.id,
|
||||||
|
groupUuid: groupingId,
|
||||||
|
sourceType: id.type,
|
||||||
|
uuid: v4(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
} satisfies NewSingleOrMultiProgramGroupingExternalId;
|
||||||
|
} else if (isValidMultiExternalIdType(id.type)) {
|
||||||
|
return {
|
||||||
|
type: 'multi',
|
||||||
|
externalKey: id.id,
|
||||||
|
groupUuid: groupingId,
|
||||||
|
sourceType: id.type,
|
||||||
|
uuid: v4(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
externalSourceId: mediaSource.name, // legacy
|
||||||
|
mediaSourceId: mediaSource.uuid, // new
|
||||||
|
} satisfies NewSingleOrMultiProgramGroupingExternalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
uuid: groupingId,
|
||||||
|
type: ProgramGroupingType.Show,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
// index: show.index,
|
||||||
|
title: show.title,
|
||||||
|
summary: show.summary,
|
||||||
|
year: show.year,
|
||||||
|
libraryId: mediaSourceLibrary.uuid,
|
||||||
|
canonicalId: show.canonicalId,
|
||||||
|
externalIds,
|
||||||
|
} satisfies NewProgramGroupingWithExternalIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
mintForMediaSourceArtist(
|
||||||
|
mediaSource: MediaSource,
|
||||||
|
mediaSourceLibrary: MediaSourceLibrary,
|
||||||
|
artist: MediaSourceMusicArtist,
|
||||||
|
): NewMusicArtist {
|
||||||
|
const now = +dayjs();
|
||||||
|
const groupingId = v4();
|
||||||
|
|
||||||
|
const externalIds = seq.collect(artist.identifiers, (id) => {
|
||||||
|
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
|
||||||
|
return {
|
||||||
|
type: 'single',
|
||||||
|
externalKey: id.id,
|
||||||
|
groupUuid: groupingId,
|
||||||
|
sourceType: id.type,
|
||||||
|
uuid: v4(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
} satisfies NewSingleOrMultiProgramGroupingExternalId;
|
||||||
|
} else if (isValidMultiExternalIdType(id.type)) {
|
||||||
|
return {
|
||||||
|
type: 'multi',
|
||||||
|
externalKey: id.id,
|
||||||
|
groupUuid: groupingId,
|
||||||
|
sourceType: id.type,
|
||||||
|
uuid: v4(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
externalSourceId: mediaSource.name, // legacy
|
||||||
|
mediaSourceId: mediaSource.uuid, // new
|
||||||
|
} satisfies NewSingleOrMultiProgramGroupingExternalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
uuid: groupingId,
|
||||||
|
type: ProgramGroupingType.Artist,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
// index: show.index,
|
||||||
|
title: artist.title,
|
||||||
|
summary: artist.summary,
|
||||||
|
year: null,
|
||||||
|
libraryId: mediaSourceLibrary.uuid,
|
||||||
|
canonicalId: artist.canonicalId,
|
||||||
|
externalIds,
|
||||||
|
} satisfies NewMusicArtist;
|
||||||
|
}
|
||||||
|
|
||||||
|
mintSeason(
|
||||||
|
mediaSource: MediaSource,
|
||||||
|
mediaSourceLibrary: MediaSourceLibrary,
|
||||||
|
season: MediaSourceSeason,
|
||||||
|
): NewTvSeason {
|
||||||
|
const now = +dayjs();
|
||||||
|
const groupingId = v4();
|
||||||
|
|
||||||
|
const externalIds = seq.collect(season.identifiers, (id) => {
|
||||||
|
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
|
||||||
|
return {
|
||||||
|
type: 'single',
|
||||||
|
externalKey: id.id,
|
||||||
|
groupUuid: groupingId,
|
||||||
|
sourceType: id.type,
|
||||||
|
uuid: v4(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
} satisfies NewSingleOrMultiProgramGroupingExternalId;
|
||||||
|
} else if (isValidMultiExternalIdType(id.type)) {
|
||||||
|
return {
|
||||||
|
type: 'multi',
|
||||||
|
externalKey: id.id,
|
||||||
|
groupUuid: groupingId,
|
||||||
|
sourceType: id.type,
|
||||||
|
uuid: v4(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
externalSourceId: mediaSource.name, // legacy
|
||||||
|
mediaSourceId: mediaSource.uuid, // new
|
||||||
|
} satisfies NewSingleOrMultiProgramGroupingExternalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
uuid: groupingId,
|
||||||
|
type: ProgramGroupingType.Season,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
index: season.index,
|
||||||
|
title: season.title,
|
||||||
|
summary: season.summary,
|
||||||
|
libraryId: mediaSourceLibrary.uuid,
|
||||||
|
canonicalId: season.canonicalId,
|
||||||
|
externalIds,
|
||||||
|
} satisfies NewProgramGroupingWithExternalIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
mintMusicAlbum(
|
||||||
|
mediaSource: MediaSource,
|
||||||
|
mediaSourceLibrary: MediaSourceLibrary,
|
||||||
|
album: MediaSourceMusicAlbum,
|
||||||
|
): NewMusicAlbum {
|
||||||
|
const now = +dayjs();
|
||||||
|
const groupingId = v4();
|
||||||
|
|
||||||
|
const externalIds = seq.collect(album.identifiers, (id) => {
|
||||||
|
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
|
||||||
|
return {
|
||||||
|
type: 'single',
|
||||||
|
externalKey: id.id,
|
||||||
|
groupUuid: groupingId,
|
||||||
|
sourceType: id.type,
|
||||||
|
uuid: v4(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
} satisfies NewSingleOrMultiProgramGroupingExternalId;
|
||||||
|
} else if (isValidMultiExternalIdType(id.type)) {
|
||||||
|
return {
|
||||||
|
type: 'multi',
|
||||||
|
externalKey: id.id,
|
||||||
|
groupUuid: groupingId,
|
||||||
|
sourceType: id.type,
|
||||||
|
uuid: v4(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
externalSourceId: mediaSource.name, // legacy
|
||||||
|
mediaSourceId: mediaSource.uuid, // new
|
||||||
|
} satisfies NewSingleOrMultiProgramGroupingExternalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
uuid: groupingId,
|
||||||
|
type: ProgramGroupingType.Album,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
index: album.index,
|
||||||
|
title: album.title,
|
||||||
|
summary: album.summary,
|
||||||
|
libraryId: mediaSourceLibrary.uuid,
|
||||||
|
canonicalId: album.canonicalId,
|
||||||
|
externalIds,
|
||||||
|
} satisfies NewMusicAlbum;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,33 +5,89 @@ import type {
|
|||||||
NewSingleOrMultiExternalId,
|
NewSingleOrMultiExternalId,
|
||||||
} from '@/db/schema/ProgramExternalId.js';
|
} from '@/db/schema/ProgramExternalId.js';
|
||||||
import { seq } from '@tunarr/shared/util';
|
import { seq } from '@tunarr/shared/util';
|
||||||
import type { ContentProgram } from '@tunarr/types';
|
import { tag, type ContentProgram } from '@tunarr/types';
|
||||||
import type { JellyfinItem } from '@tunarr/types/jellyfin';
|
import type { JellyfinItem } from '@tunarr/types/jellyfin';
|
||||||
import type {
|
import type {
|
||||||
|
PlexMovie as ApiPlexMovie,
|
||||||
PlexEpisode,
|
PlexEpisode,
|
||||||
PlexMovie,
|
PlexMedia,
|
||||||
PlexMusicTrack,
|
PlexMusicTrack,
|
||||||
|
PlexTerminalMedia,
|
||||||
} from '@tunarr/types/plex';
|
} from '@tunarr/types/plex';
|
||||||
import type { ContentProgramOriginalProgram } from '@tunarr/types/schemas';
|
import {
|
||||||
|
isValidMultiExternalIdType,
|
||||||
|
isValidSingleExternalIdType,
|
||||||
|
type ContentProgramOriginalProgram,
|
||||||
|
} from '@tunarr/types/schemas';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { find, first, isError } from 'lodash-es';
|
import { inject, injectable } from 'inversify';
|
||||||
|
import { find, first, head, isError } from 'lodash-es';
|
||||||
import { P, match } from 'ts-pattern';
|
import { P, match } from 'ts-pattern';
|
||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
import type { NewProgramDao as NewRawProgram } from '../schema/Program.ts';
|
import { Canonicalizer } from '../../services/Canonicalizer.ts';
|
||||||
|
import {
|
||||||
|
MediaSourceEpisode,
|
||||||
|
MediaSourceMovie,
|
||||||
|
MediaSourceMusicTrack,
|
||||||
|
} from '../../types/Media.ts';
|
||||||
|
import { KEYS } from '../../types/inject.ts';
|
||||||
|
import { Maybe } from '../../types/util.ts';
|
||||||
|
import { parsePlexGuid } from '../../util/externalIds.ts';
|
||||||
|
import { isNonEmptyString } from '../../util/index.ts';
|
||||||
|
import { Logger } from '../../util/logging/LoggerFactory.ts';
|
||||||
|
import { MediaSource, MediaSourceLibrary } from '../schema/MediaSource.ts';
|
||||||
|
import type {
|
||||||
|
NewProgramDao,
|
||||||
|
NewProgramDao as NewRawProgram,
|
||||||
|
} from '../schema/Program.ts';
|
||||||
import { ProgramType } from '../schema/Program.ts';
|
import { ProgramType } from '../schema/Program.ts';
|
||||||
|
import { MediaSourceId, MediaSourceName } from '../schema/base.ts';
|
||||||
|
import {
|
||||||
|
NewEpisodeProgram,
|
||||||
|
NewMovieProgram,
|
||||||
|
NewMusicTrack,
|
||||||
|
NewProgramWithExternalIds,
|
||||||
|
} from '../schema/derivedTypes.js';
|
||||||
|
|
||||||
|
// type MovieMintRequest =
|
||||||
|
// | { sourceType: 'plex'; program: PlexMovie }
|
||||||
|
// | { sourceType: 'jellyfin'; program: SpecificJellyfinType<'Movie'> }
|
||||||
|
// | { sourceType: 'emby'; program: SpecificEmbyType<'Movie'> };
|
||||||
|
|
||||||
|
// type EpisodeMintRequest =
|
||||||
|
// | { sourceType: 'plex'; program: PlexEpisode }
|
||||||
|
// | { sourceType: 'jellyfin'; program: SpecificJellyfinType<'Episode'> }
|
||||||
|
// | { sourceType: 'emby'; program: SpecificEmbyType<'Episode'> };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates Program DB entities for Plex media
|
* Generates Program DB entities for Plex media
|
||||||
*/
|
*/
|
||||||
class ProgramDaoMinter {
|
@injectable()
|
||||||
contentProgramDtoToDao(program: ContentProgram): NewRawProgram {
|
export class ProgramDaoMinter {
|
||||||
|
constructor(
|
||||||
|
@inject(KEYS.Logger) private logger: Logger,
|
||||||
|
@inject(KEYS.PlexCanonicalizer)
|
||||||
|
private plexProgramCanonicalizer: Canonicalizer<PlexMedia>,
|
||||||
|
@inject(KEYS.JellyfinCanonicalizer)
|
||||||
|
private jellyfinCanonicalizer: Canonicalizer<JellyfinItem>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
contentProgramDtoToDao(program: ContentProgram): Maybe<NewRawProgram> {
|
||||||
|
if (!isNonEmptyString(program.canonicalId)) {
|
||||||
|
this.logger.warn('Program missing canonical ID on upsert: %O', program);
|
||||||
|
return;
|
||||||
|
} else if (!isNonEmptyString(program.libraryId)) {
|
||||||
|
this.logger.warn('Program missing library ID on upsert: %O', program);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const now = +dayjs();
|
const now = +dayjs();
|
||||||
return {
|
return {
|
||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
sourceType: program.externalSourceType,
|
sourceType: program.externalSourceType,
|
||||||
// Deprecated
|
// Deprecated
|
||||||
externalSourceId: program.externalSourceName,
|
externalSourceId: tag(program.externalSourceName),
|
||||||
mediaSourceId: program.externalSourceId,
|
mediaSourceId: tag(program.externalSourceId),
|
||||||
externalKey: program.externalKey,
|
externalKey: program.externalKey,
|
||||||
originalAirDate: program.date ?? null,
|
originalAirDate: program.date ?? null,
|
||||||
duration: program.duration,
|
duration: program.duration,
|
||||||
@@ -50,30 +106,40 @@ class ProgramDaoMinter {
|
|||||||
grandparentExternalKey: program.grandparent?.externalKey,
|
grandparentExternalKey: program.grandparent?.externalKey,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
canonicalId: program.canonicalId,
|
||||||
|
libraryId: program.libraryId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
mint(
|
mint(
|
||||||
serverName: string,
|
mediaSource: MediaSource,
|
||||||
serverId: string,
|
library: MediaSourceLibrary,
|
||||||
program: ContentProgramOriginalProgram,
|
program: ContentProgramOriginalProgram,
|
||||||
): NewRawProgram {
|
): NewProgramWithExternalIds {
|
||||||
const ret = match(program)
|
const ret = match(program)
|
||||||
.with(
|
.with({ sourceType: 'plex' }, ({ program }) => {
|
||||||
{ sourceType: 'plex', program: { type: 'movie' } },
|
const dao = match(program)
|
||||||
({ program: movie }) =>
|
.with({ type: 'movie' }, (movie) =>
|
||||||
this.mintProgramForPlexMovie(serverName, serverId, movie),
|
this.mintProgramForPlexMovie(mediaSource, library, movie),
|
||||||
)
|
)
|
||||||
.with(
|
.with({ type: 'episode' }, (ep) =>
|
||||||
{ sourceType: 'plex', program: { type: 'episode' } },
|
this.mintProgramForPlexEpisode(mediaSource, library, ep),
|
||||||
({ program: episode }) =>
|
)
|
||||||
this.mintProgramForPlexEpisode(serverName, serverId, episode),
|
.with({ type: 'track' }, (track) =>
|
||||||
)
|
this.mintProgramForPlexTrack(mediaSource, library, track),
|
||||||
.with(
|
)
|
||||||
{ sourceType: 'plex', program: { type: 'track' } },
|
.exhaustive();
|
||||||
({ program: track }) =>
|
const externalIds = this.mintPlexExternalIdsFromApiItem(
|
||||||
this.mintProgramForPlexTrack(serverName, serverId, track),
|
mediaSource.name,
|
||||||
)
|
mediaSource.uuid,
|
||||||
|
dao,
|
||||||
|
program,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...dao,
|
||||||
|
externalIds,
|
||||||
|
} satisfies NewProgramWithExternalIds;
|
||||||
|
})
|
||||||
.with(
|
.with(
|
||||||
{
|
{
|
||||||
sourceType: 'jellyfin',
|
sourceType: 'jellyfin',
|
||||||
@@ -88,8 +154,7 @@ class ProgramDaoMinter {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
({ program }) =>
|
({ program }) => this.mintProgramForJellyfinItem(mediaSource, program),
|
||||||
this.mintProgramForJellyfinItem(serverName, serverId, program),
|
|
||||||
)
|
)
|
||||||
.otherwise(() => new Error('Unexpected program type'));
|
.otherwise(() => new Error('Unexpected program type'));
|
||||||
if (isError(ret)) {
|
if (isError(ret)) {
|
||||||
@@ -98,11 +163,219 @@ class ProgramDaoMinter {
|
|||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mintMovie(
|
||||||
|
mediaSource: MediaSource,
|
||||||
|
mediaLibrary: MediaSourceLibrary,
|
||||||
|
movie: MediaSourceMovie,
|
||||||
|
): NewMovieProgram {
|
||||||
|
const programId = v4();
|
||||||
|
const now = +dayjs();
|
||||||
|
|
||||||
|
return {
|
||||||
|
uuid: programId,
|
||||||
|
sourceType: movie.sourceType,
|
||||||
|
externalKey: movie.externalKey,
|
||||||
|
originalAirDate: dayjs(movie.releaseDate)?.format(),
|
||||||
|
duration: movie.duration,
|
||||||
|
// filePath: file?.file ?? null,
|
||||||
|
externalSourceId: mediaSource.name,
|
||||||
|
mediaSourceId: mediaSource.uuid,
|
||||||
|
libraryId: mediaLibrary.uuid,
|
||||||
|
// plexRatingKey: plexMovie.ratingKey,
|
||||||
|
// plexFilePath: file?.key ?? null,
|
||||||
|
rating: movie.rating,
|
||||||
|
summary: movie.summary,
|
||||||
|
title: movie.title,
|
||||||
|
type: ProgramType.Movie,
|
||||||
|
year: movie.year,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
canonicalId: movie.canonicalId,
|
||||||
|
externalIds: seq.collect(movie.identifiers, (id) => {
|
||||||
|
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
|
||||||
|
return {
|
||||||
|
type: 'single',
|
||||||
|
externalKey: id.id,
|
||||||
|
programUuid: programId,
|
||||||
|
sourceType: id.type,
|
||||||
|
uuid: v4(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
} satisfies NewSingleOrMultiExternalId;
|
||||||
|
} else if (isValidMultiExternalIdType(id.type)) {
|
||||||
|
const isMediaSourceId = id.type === mediaSource.type;
|
||||||
|
// This stinks
|
||||||
|
const location = isMediaSourceId
|
||||||
|
? find(movie.mediaItem?.locations, { sourceType: mediaSource.type })
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
type: 'multi',
|
||||||
|
externalKey: id.id,
|
||||||
|
programUuid: programId,
|
||||||
|
sourceType: id.type,
|
||||||
|
uuid: v4(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
externalSourceId: mediaSource.name, // legacy
|
||||||
|
mediaSourceId: mediaSource.uuid, // new
|
||||||
|
// TODO
|
||||||
|
directFilePath: location?.path,
|
||||||
|
externalFilePath:
|
||||||
|
location?.type === 'remote' ? location.externalKey : null,
|
||||||
|
// externalFilePath
|
||||||
|
} satisfies NewSingleOrMultiExternalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
mintEpisode(
|
||||||
|
mediaSource: MediaSource,
|
||||||
|
mediaLibrary: MediaSourceLibrary,
|
||||||
|
episode: MediaSourceEpisode,
|
||||||
|
): NewEpisodeProgram {
|
||||||
|
const programId = v4();
|
||||||
|
const now = +dayjs();
|
||||||
|
|
||||||
|
return {
|
||||||
|
uuid: programId,
|
||||||
|
sourceType: episode.sourceType,
|
||||||
|
externalKey: episode.externalKey,
|
||||||
|
originalAirDate: dayjs(episode.releaseDate).format(),
|
||||||
|
duration: episode.duration,
|
||||||
|
// filePath: file?.file ?? null,
|
||||||
|
externalSourceId: mediaSource.name,
|
||||||
|
mediaSourceId: mediaSource.uuid,
|
||||||
|
libraryId: mediaLibrary.uuid,
|
||||||
|
// plexRatingKey: plexMovie.ratingKey,
|
||||||
|
// plexFilePath: file?.key ?? null,
|
||||||
|
rating: null,
|
||||||
|
summary: episode.summary,
|
||||||
|
title: episode.title,
|
||||||
|
type: ProgramType.Episode,
|
||||||
|
year: episode.year,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
canonicalId: episode.canonicalId,
|
||||||
|
externalIds: seq.collect(episode.identifiers, (id) => {
|
||||||
|
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
|
||||||
|
return {
|
||||||
|
type: 'single',
|
||||||
|
externalKey: id.id,
|
||||||
|
programUuid: programId,
|
||||||
|
sourceType: id.type,
|
||||||
|
uuid: v4(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
} satisfies NewSingleOrMultiExternalId;
|
||||||
|
} else if (isValidMultiExternalIdType(id.type)) {
|
||||||
|
const isMediaSourceId = id.type === mediaSource.type;
|
||||||
|
// This stinks
|
||||||
|
const location = isMediaSourceId
|
||||||
|
? find(episode.mediaItem?.locations, {
|
||||||
|
sourceType: mediaSource.type,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
type: 'multi',
|
||||||
|
externalKey: id.id,
|
||||||
|
programUuid: programId,
|
||||||
|
sourceType: id.type,
|
||||||
|
uuid: v4(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
externalSourceId: mediaSource.name, // legacy
|
||||||
|
mediaSourceId: mediaSource.uuid, // new
|
||||||
|
// TODO
|
||||||
|
directFilePath: location?.path,
|
||||||
|
externalFilePath:
|
||||||
|
location?.type === 'remote' ? location.externalKey : null,
|
||||||
|
// externalFilePath
|
||||||
|
} satisfies NewSingleOrMultiExternalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
mintMusicTrack(
|
||||||
|
mediaSource: MediaSource,
|
||||||
|
mediaLibrary: MediaSourceLibrary,
|
||||||
|
track: MediaSourceMusicTrack,
|
||||||
|
): NewMusicTrack {
|
||||||
|
const programId = v4();
|
||||||
|
const now = +dayjs();
|
||||||
|
|
||||||
|
return {
|
||||||
|
uuid: programId,
|
||||||
|
sourceType: track.sourceType,
|
||||||
|
externalKey: track.externalKey,
|
||||||
|
originalAirDate: dayjs(track.releaseDate)?.format(),
|
||||||
|
duration: track.duration,
|
||||||
|
// filePath: file?.file ?? null,
|
||||||
|
externalSourceId: mediaSource.name,
|
||||||
|
mediaSourceId: mediaSource.uuid,
|
||||||
|
libraryId: mediaLibrary.uuid,
|
||||||
|
// plexRatingKey: plexMovie.ratingKey,
|
||||||
|
// plexFilePath: file?.key ?? null,
|
||||||
|
rating: null,
|
||||||
|
summary: null,
|
||||||
|
title: track.title,
|
||||||
|
type: ProgramType.Track,
|
||||||
|
year: track.year,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
canonicalId: track.canonicalId,
|
||||||
|
externalIds: seq.collect(track.identifiers, (id) => {
|
||||||
|
if (isNonEmptyString(id.id) && isValidSingleExternalIdType(id.type)) {
|
||||||
|
return {
|
||||||
|
type: 'single',
|
||||||
|
externalKey: id.id,
|
||||||
|
programUuid: programId,
|
||||||
|
sourceType: id.type,
|
||||||
|
uuid: v4(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
} satisfies NewSingleOrMultiExternalId;
|
||||||
|
} else if (isValidMultiExternalIdType(id.type)) {
|
||||||
|
const isMediaSourceId = id.type === mediaSource.type;
|
||||||
|
// This stinks
|
||||||
|
const location = isMediaSourceId
|
||||||
|
? find(track.mediaItem?.locations, {
|
||||||
|
sourceType: mediaSource.type,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
type: 'multi',
|
||||||
|
externalKey: id.id,
|
||||||
|
programUuid: programId,
|
||||||
|
sourceType: id.type,
|
||||||
|
uuid: v4(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
externalSourceId: mediaSource.name, // legacy
|
||||||
|
mediaSourceId: mediaSource.uuid, // new
|
||||||
|
// TODO
|
||||||
|
directFilePath: location?.path,
|
||||||
|
externalFilePath:
|
||||||
|
location?.type === 'remote' ? location.externalKey : null,
|
||||||
|
// externalFilePath
|
||||||
|
} satisfies NewSingleOrMultiExternalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private mintProgramForPlexMovie(
|
private mintProgramForPlexMovie(
|
||||||
serverName: string,
|
mediaSource: MediaSource,
|
||||||
serverId: string,
|
mediaLibrary: MediaSourceLibrary,
|
||||||
plexMovie: PlexMovie,
|
plexMovie: ApiPlexMovie,
|
||||||
): NewRawProgram {
|
): NewProgramDao {
|
||||||
const file = first(first(plexMovie.Media)?.Part ?? []);
|
const file = first(first(plexMovie.Media)?.Part ?? []);
|
||||||
return {
|
return {
|
||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
@@ -110,8 +383,9 @@ class ProgramDaoMinter {
|
|||||||
originalAirDate: plexMovie.originallyAvailableAt ?? null,
|
originalAirDate: plexMovie.originallyAvailableAt ?? null,
|
||||||
duration: plexMovie.duration ?? 0,
|
duration: plexMovie.duration ?? 0,
|
||||||
filePath: file?.file ?? null,
|
filePath: file?.file ?? null,
|
||||||
externalSourceId: serverName,
|
externalSourceId: mediaSource.name,
|
||||||
mediaSourceId: serverId,
|
mediaSourceId: mediaSource.uuid,
|
||||||
|
libraryId: mediaLibrary.uuid,
|
||||||
externalKey: plexMovie.ratingKey,
|
externalKey: plexMovie.ratingKey,
|
||||||
plexRatingKey: plexMovie.ratingKey,
|
plexRatingKey: plexMovie.ratingKey,
|
||||||
plexFilePath: file?.key ?? null,
|
plexFilePath: file?.key ?? null,
|
||||||
@@ -122,25 +396,26 @@ class ProgramDaoMinter {
|
|||||||
year: plexMovie.year ?? null,
|
year: plexMovie.year ?? null,
|
||||||
createdAt: +dayjs(),
|
createdAt: +dayjs(),
|
||||||
updatedAt: +dayjs(),
|
updatedAt: +dayjs(),
|
||||||
|
canonicalId: this.plexProgramCanonicalizer.getCanonicalId(plexMovie),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private mintProgramForJellyfinItem(
|
private mintProgramForJellyfinItem(
|
||||||
serverName: string,
|
mediaSource: MediaSource,
|
||||||
serverId: string,
|
|
||||||
item: Omit<JellyfinItem, 'Type'> & {
|
item: Omit<JellyfinItem, 'Type'> & {
|
||||||
Type: 'Movie' | 'Episode' | 'Audio' | 'Video' | 'MusicVideo' | 'Trailer';
|
Type: 'Movie' | 'Episode' | 'Audio' | 'Video' | 'MusicVideo' | 'Trailer';
|
||||||
},
|
},
|
||||||
): NewRawProgram {
|
): NewProgramWithExternalIds {
|
||||||
return {
|
const id = v4();
|
||||||
uuid: v4(),
|
const dao: NewProgramDao = {
|
||||||
|
uuid: id,
|
||||||
createdAt: +dayjs(),
|
createdAt: +dayjs(),
|
||||||
updatedAt: +dayjs(),
|
updatedAt: +dayjs(),
|
||||||
sourceType: ProgramSourceType.JELLYFIN,
|
sourceType: ProgramSourceType.JELLYFIN,
|
||||||
originalAirDate: item.PremiereDate,
|
originalAirDate: item.PremiereDate,
|
||||||
duration: (item.RunTimeTicks ?? 0) / 10_000,
|
duration: Math.ceil((item.RunTimeTicks ?? 0) / 10_000),
|
||||||
externalSourceId: serverName,
|
externalSourceId: mediaSource.name,
|
||||||
mediaSourceId: serverId,
|
mediaSourceId: mediaSource.uuid,
|
||||||
externalKey: item.Id,
|
externalKey: item.Id,
|
||||||
rating: item.OfficialRating,
|
rating: item.OfficialRating,
|
||||||
summary: item.Overview,
|
summary: item.Overview,
|
||||||
@@ -161,14 +436,27 @@ class ProgramDaoMinter {
|
|||||||
grandparentExternalKey:
|
grandparentExternalKey:
|
||||||
item.SeriesId ??
|
item.SeriesId ??
|
||||||
find(item.AlbumArtists, { Name: item.AlbumArtist })?.Id,
|
find(item.AlbumArtists, { Name: item.AlbumArtist })?.Id,
|
||||||
|
canonicalId: this.jellyfinCanonicalizer.getCanonicalId(item),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const externalIds = this.mintAllJellyfinExternalIdForApiItem(
|
||||||
|
mediaSource.name,
|
||||||
|
mediaSource.uuid,
|
||||||
|
dao,
|
||||||
|
item,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...dao,
|
||||||
|
externalIds,
|
||||||
|
} satisfies NewProgramWithExternalIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
private mintProgramForPlexEpisode(
|
private mintProgramForPlexEpisode(
|
||||||
serverName: string,
|
mediaSource: MediaSource,
|
||||||
serverId: string,
|
mediaLibrary: MediaSourceLibrary,
|
||||||
plexEpisode: PlexEpisode,
|
plexEpisode: PlexEpisode,
|
||||||
): NewRawProgram {
|
): NewProgramDao {
|
||||||
const file = first(first(plexEpisode.Media)?.Part ?? []);
|
const file = first(first(plexEpisode.Media)?.Part ?? []);
|
||||||
return {
|
return {
|
||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
@@ -178,8 +466,9 @@ class ProgramDaoMinter {
|
|||||||
originalAirDate: plexEpisode.originallyAvailableAt,
|
originalAirDate: plexEpisode.originallyAvailableAt,
|
||||||
duration: plexEpisode.duration ?? 0,
|
duration: plexEpisode.duration ?? 0,
|
||||||
filePath: file?.file,
|
filePath: file?.file,
|
||||||
externalSourceId: serverName,
|
externalSourceId: mediaSource.name,
|
||||||
mediaSourceId: serverId,
|
mediaSourceId: mediaSource.uuid,
|
||||||
|
libraryId: mediaLibrary.uuid,
|
||||||
externalKey: plexEpisode.ratingKey,
|
externalKey: plexEpisode.ratingKey,
|
||||||
plexRatingKey: plexEpisode.ratingKey,
|
plexRatingKey: plexEpisode.ratingKey,
|
||||||
plexFilePath: file?.key,
|
plexFilePath: file?.key,
|
||||||
@@ -194,12 +483,13 @@ class ProgramDaoMinter {
|
|||||||
episode: plexEpisode.index,
|
episode: plexEpisode.index,
|
||||||
parentExternalKey: plexEpisode.parentRatingKey,
|
parentExternalKey: plexEpisode.parentRatingKey,
|
||||||
grandparentExternalKey: plexEpisode.grandparentRatingKey,
|
grandparentExternalKey: plexEpisode.grandparentRatingKey,
|
||||||
|
canonicalId: this.plexProgramCanonicalizer.getCanonicalId(plexEpisode),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private mintProgramForPlexTrack(
|
private mintProgramForPlexTrack(
|
||||||
serverName: string,
|
mediaSource: MediaSource,
|
||||||
serverId: string,
|
mediaLibrary: MediaSourceLibrary,
|
||||||
plexTrack: PlexMusicTrack,
|
plexTrack: PlexMusicTrack,
|
||||||
): NewRawProgram {
|
): NewRawProgram {
|
||||||
const file = first(first(plexTrack.Media)?.Part ?? []);
|
const file = first(first(plexTrack.Media)?.Part ?? []);
|
||||||
@@ -210,8 +500,9 @@ class ProgramDaoMinter {
|
|||||||
sourceType: ProgramSourceType.PLEX,
|
sourceType: ProgramSourceType.PLEX,
|
||||||
duration: plexTrack.duration ?? 0,
|
duration: plexTrack.duration ?? 0,
|
||||||
filePath: file?.file,
|
filePath: file?.file,
|
||||||
externalSourceId: serverName,
|
externalSourceId: mediaSource.name,
|
||||||
mediaSourceId: serverId,
|
mediaSourceId: mediaSource.uuid,
|
||||||
|
libraryId: mediaLibrary.uuid,
|
||||||
externalKey: plexTrack.ratingKey,
|
externalKey: plexTrack.ratingKey,
|
||||||
plexRatingKey: plexTrack.ratingKey,
|
plexRatingKey: plexTrack.ratingKey,
|
||||||
plexFilePath: file?.key,
|
plexFilePath: file?.key,
|
||||||
@@ -227,12 +518,13 @@ class ProgramDaoMinter {
|
|||||||
grandparentExternalKey: plexTrack.grandparentRatingKey,
|
grandparentExternalKey: plexTrack.grandparentRatingKey,
|
||||||
albumName: plexTrack.parentTitle,
|
albumName: plexTrack.parentTitle,
|
||||||
artistName: plexTrack.grandparentTitle,
|
artistName: plexTrack.grandparentTitle,
|
||||||
|
canonicalId: this.plexProgramCanonicalizer.getCanonicalId(plexTrack),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
mintExternalIds(
|
mintExternalIds(
|
||||||
serverName: string,
|
serverName: MediaSourceName,
|
||||||
serverId: string,
|
serverId: MediaSourceId,
|
||||||
programId: string,
|
programId: string,
|
||||||
program: ContentProgram,
|
program: ContentProgram,
|
||||||
): NewSingleOrMultiExternalId[] {
|
): NewSingleOrMultiExternalId[] {
|
||||||
@@ -250,8 +542,8 @@ class ProgramDaoMinter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mintPlexExternalIds(
|
mintPlexExternalIds(
|
||||||
serverName: string,
|
serverName: MediaSourceName,
|
||||||
serverId: string,
|
serverId: MediaSourceId,
|
||||||
programId: string,
|
programId: string,
|
||||||
program: ContentProgram,
|
program: ContentProgram,
|
||||||
): NewSingleOrMultiExternalId[] {
|
): NewSingleOrMultiExternalId[] {
|
||||||
@@ -310,25 +602,66 @@ class ProgramDaoMinter {
|
|||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
mintJellyfinExternalIdForApiItem(
|
mintPlexExternalIdsFromApiItem(
|
||||||
serverName: string,
|
serverName: MediaSourceName,
|
||||||
programId: string,
|
serverId: MediaSourceId,
|
||||||
media: JellyfinItem,
|
program: NewProgramDao,
|
||||||
) {
|
plexEntity: PlexTerminalMedia,
|
||||||
return {
|
): NewSingleOrMultiExternalId[] {
|
||||||
uuid: v4(),
|
const now = +dayjs();
|
||||||
createdAt: +dayjs(),
|
const file = first(first(plexEntity.Media)?.Part ?? []);
|
||||||
updatedAt: +dayjs(),
|
|
||||||
externalKey: media.Id,
|
const ids: NewSingleOrMultiExternalId[] = [
|
||||||
sourceType: ProgramExternalIdType.JELLYFIN,
|
{
|
||||||
programUuid: programId,
|
type: 'multi',
|
||||||
externalSourceId: serverName,
|
uuid: v4(),
|
||||||
} satisfies NewProgramExternalId;
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
externalKey: program.externalKey,
|
||||||
|
sourceType: ProgramExternalIdType.PLEX,
|
||||||
|
programUuid: program.uuid,
|
||||||
|
externalSourceId: serverName,
|
||||||
|
mediaSourceId: serverId,
|
||||||
|
externalFilePath: file?.key,
|
||||||
|
directFilePath: file?.file,
|
||||||
|
} satisfies NewSingleOrMultiExternalId,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (plexEntity.guid) {
|
||||||
|
ids.push({
|
||||||
|
type: 'single',
|
||||||
|
uuid: v4(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
externalKey: plexEntity.guid,
|
||||||
|
sourceType: ProgramExternalIdType.PLEX_GUID,
|
||||||
|
programUuid: program.uuid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ids.push(
|
||||||
|
...seq
|
||||||
|
.collect(plexEntity.Guid, ({ id }) => parsePlexGuid(id))
|
||||||
|
.map(
|
||||||
|
(eid) =>
|
||||||
|
({
|
||||||
|
type: 'single',
|
||||||
|
uuid: v4(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
externalKey: eid.externalKey,
|
||||||
|
sourceType: eid.sourceType,
|
||||||
|
programUuid: program.uuid,
|
||||||
|
}) satisfies NewSingleOrMultiExternalId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
mintJellyfinExternalIds(
|
mintJellyfinExternalIds(
|
||||||
serverName: string,
|
serverName: MediaSourceName,
|
||||||
serverId: string,
|
serverId: MediaSourceId,
|
||||||
programId: string,
|
programId: string,
|
||||||
program: ContentProgram,
|
program: ContentProgram,
|
||||||
) {
|
) {
|
||||||
@@ -373,9 +706,75 @@ class ProgramDaoMinter {
|
|||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mintJellyfinExternalIdForApiItem(
|
||||||
|
serverName: MediaSourceName,
|
||||||
|
programId: string,
|
||||||
|
media: JellyfinItem,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
uuid: v4(),
|
||||||
|
createdAt: +dayjs(),
|
||||||
|
updatedAt: +dayjs(),
|
||||||
|
externalKey: media.Id,
|
||||||
|
sourceType: ProgramExternalIdType.JELLYFIN,
|
||||||
|
programUuid: programId,
|
||||||
|
externalSourceId: serverName,
|
||||||
|
} satisfies NewProgramExternalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
mintAllJellyfinExternalIdForApiItem(
|
||||||
|
serverName: MediaSourceName,
|
||||||
|
serverId: MediaSourceId,
|
||||||
|
program: NewProgramDao,
|
||||||
|
entity: JellyfinItem,
|
||||||
|
) {
|
||||||
|
const now = +dayjs();
|
||||||
|
const ids: NewSingleOrMultiExternalId[] = [
|
||||||
|
{
|
||||||
|
type: 'multi',
|
||||||
|
uuid: v4(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
externalKey: program.externalKey,
|
||||||
|
sourceType: ProgramExternalIdType.JELLYFIN,
|
||||||
|
programUuid: program.uuid,
|
||||||
|
externalSourceId: serverName,
|
||||||
|
mediaSourceId: serverId,
|
||||||
|
directFilePath: head(entity.MediaSources)?.Path,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
ids.push(
|
||||||
|
...seq.collectMapValues(entity.ProviderIds, (value, source) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (source) {
|
||||||
|
case 'tmdb':
|
||||||
|
case 'imdb':
|
||||||
|
case 'tvdb':
|
||||||
|
return {
|
||||||
|
type: 'single',
|
||||||
|
uuid: v4(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
externalKey: value,
|
||||||
|
sourceType: source,
|
||||||
|
programUuid: program.uuid,
|
||||||
|
} satisfies NewSingleOrMultiExternalId;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
mintEmbyExternalIds(
|
mintEmbyExternalIds(
|
||||||
serverName: string,
|
serverName: MediaSourceName,
|
||||||
serverId: string,
|
serverId: MediaSourceId,
|
||||||
programId: string,
|
programId: string,
|
||||||
program: ContentProgram,
|
program: ContentProgram,
|
||||||
) {
|
) {
|
||||||
@@ -420,9 +819,3 @@ class ProgramDaoMinter {
|
|||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ProgramMinterFactory {
|
|
||||||
static create(): ProgramDaoMinter {
|
|
||||||
return new ProgramDaoMinter();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
// active streaming session
|
// active streaming session
|
||||||
|
|
||||||
import { MediaSourceType } from '@/db/schema/MediaSource.js';
|
import { MediaSourceType } from '@/db/schema/MediaSource.js';
|
||||||
|
import { tag } from '@tunarr/types';
|
||||||
import { ContentProgramTypeSchema } from '@tunarr/types/schemas';
|
import { ContentProgramTypeSchema } from '@tunarr/types/schemas';
|
||||||
import type { StrictOmit } from 'ts-essentials';
|
import type { StrictOmit } from 'ts-essentials';
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
import type { EmbyT, JellyfinT } from '../../types/internal.ts';
|
import type { EmbyT, JellyfinT } from '../../types/internal.ts';
|
||||||
|
import type { MediaSourceId } from '../schema/base.ts';
|
||||||
import type { ProgramType } from '../schema/Program.ts';
|
import type { ProgramType } from '../schema/Program.ts';
|
||||||
|
|
||||||
const baseStreamLineupItemSchema = z.object({
|
const baseStreamLineupItemSchema = z.object({
|
||||||
@@ -125,7 +127,7 @@ const BaseContentBackedStreamLineupItemSchema =
|
|||||||
programId: z.uuid(),
|
programId: z.uuid(),
|
||||||
// These are taken from the Program DB entity
|
// These are taken from the Program DB entity
|
||||||
plexFilePath: z.string().optional(),
|
plexFilePath: z.string().optional(),
|
||||||
externalSourceId: z.string(),
|
externalSourceId: z.string().transform((s) => tag<MediaSourceId>(s)),
|
||||||
filePath: z.string().optional(),
|
filePath: z.string().optional(),
|
||||||
externalKey: z.string(),
|
externalKey: z.string(),
|
||||||
programType: ContentProgramTypeSchema,
|
programType: ContentProgramTypeSchema,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
|
import type { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
|
||||||
import type { ProgramSourceType } from '@/db/custom_types/ProgramSourceType.js';
|
import type { ProgramSourceType } from '@/db/custom_types/ProgramSourceType.js';
|
||||||
import type { ProgramDao } from '@/db/schema/Program.js';
|
import type { ProgramDao, ProgramType } from '@/db/schema/Program.js';
|
||||||
import type {
|
import type {
|
||||||
MinimalProgramExternalId,
|
MinimalProgramExternalId,
|
||||||
NewProgramExternalId,
|
NewProgramExternalId,
|
||||||
@@ -10,15 +10,19 @@ import type {
|
|||||||
import type { ProgramExternalIdSourceType } from '@/db/schema/base.js';
|
import type { ProgramExternalIdSourceType } from '@/db/schema/base.js';
|
||||||
import type {
|
import type {
|
||||||
MusicAlbumWithExternalIds,
|
MusicAlbumWithExternalIds,
|
||||||
|
NewProgramGroupingWithExternalIds,
|
||||||
|
NewProgramWithExternalIds,
|
||||||
ProgramGroupingWithExternalIds,
|
ProgramGroupingWithExternalIds,
|
||||||
ProgramWithExternalIds,
|
ProgramWithExternalIds,
|
||||||
ProgramWithRelations,
|
ProgramWithRelations,
|
||||||
TvSeasonWithExternalIds,
|
TvSeasonWithExternalIds,
|
||||||
} from '@/db/schema/derivedTypes.js';
|
} from '@/db/schema/derivedTypes.js';
|
||||||
import type { Maybe, PagedResult } from '@/types/util.js';
|
import type { MarkNonNullable, Maybe, PagedResult } from '@/types/util.js';
|
||||||
import type { ChannelProgram, ContentProgram } from '@tunarr/types';
|
import type { ChannelProgram } from '@tunarr/types';
|
||||||
import type { MarkOptional } from 'ts-essentials';
|
import type { Dictionary, MarkOptional } from 'ts-essentials';
|
||||||
|
import type { MediaSourceType } from '../schema/MediaSource.ts';
|
||||||
import type { ProgramGroupingType } from '../schema/ProgramGrouping.ts';
|
import type { ProgramGroupingType } from '../schema/ProgramGrouping.ts';
|
||||||
|
import type { MediaSourceId } from '../schema/base.ts';
|
||||||
import type { PageParams } from './IChannelDB.ts';
|
import type { PageParams } from './IChannelDB.ts';
|
||||||
|
|
||||||
export interface IProgramDB {
|
export interface IProgramDB {
|
||||||
@@ -35,13 +39,21 @@ export interface IProgramDB {
|
|||||||
|
|
||||||
getProgramsByIds(
|
getProgramsByIds(
|
||||||
ids: string[],
|
ids: string[],
|
||||||
batchSize: number,
|
batchSize?: number,
|
||||||
): Promise<ProgramWithRelations[]>;
|
): Promise<ProgramWithRelations[]>;
|
||||||
|
|
||||||
getProgramGrouping(
|
getProgramGrouping(
|
||||||
id: string,
|
id: string,
|
||||||
): Promise<Maybe<ProgramGroupingWithExternalIds>>;
|
): Promise<Maybe<ProgramGroupingWithExternalIds>>;
|
||||||
|
|
||||||
|
getProgramGroupings(
|
||||||
|
ids: string[],
|
||||||
|
): Promise<Record<string, ProgramGroupingWithExternalIds>>;
|
||||||
|
|
||||||
|
getProgramGroupingByExternalId(
|
||||||
|
eid: ProgramGroupingExternalIdLookup,
|
||||||
|
): Promise<Maybe<ProgramGroupingWithExternalIds>>;
|
||||||
|
|
||||||
getProgramParent(
|
getProgramParent(
|
||||||
programId: string,
|
programId: string,
|
||||||
): Promise<Maybe<ProgramGroupingWithExternalIds>>;
|
): Promise<Maybe<ProgramGroupingWithExternalIds>>;
|
||||||
@@ -73,11 +85,21 @@ export interface IProgramDB {
|
|||||||
sourceType: ProgramSourceType;
|
sourceType: ProgramSourceType;
|
||||||
externalSourceId: string;
|
externalSourceId: string;
|
||||||
externalKey: string;
|
externalKey: string;
|
||||||
}): Promise<Maybe<ContentProgram>>;
|
}): Promise<Maybe<ProgramWithRelations>>;
|
||||||
|
|
||||||
lookupByExternalIds(
|
lookupByExternalIds(
|
||||||
ids: Set<[string, string, string]>,
|
ids:
|
||||||
): Promise<Record<string, ContentProgram>>;
|
| Set<[string, MediaSourceId, string]>
|
||||||
|
| Set<readonly [string, MediaSourceId, string]>,
|
||||||
|
chunkSize?: number,
|
||||||
|
): Promise<ProgramWithRelations[]>;
|
||||||
|
|
||||||
|
lookupByMediaSource(
|
||||||
|
sourceType: MediaSourceType,
|
||||||
|
sourceId: MediaSourceId,
|
||||||
|
mediaType?: ProgramType,
|
||||||
|
chunkSize?: number,
|
||||||
|
): Promise<ProgramDao[]>;
|
||||||
|
|
||||||
programIdsByExternalIds(
|
programIdsByExternalIds(
|
||||||
ids: Set<[string, string, string]>,
|
ids: Set<[string, string, string]>,
|
||||||
@@ -107,7 +129,12 @@ export interface IProgramDB {
|
|||||||
upsertContentPrograms(
|
upsertContentPrograms(
|
||||||
programs: ChannelProgram[],
|
programs: ChannelProgram[],
|
||||||
programUpsertBatchSize?: number,
|
programUpsertBatchSize?: number,
|
||||||
): Promise<ProgramDao[]>;
|
): Promise<MarkNonNullable<ProgramDao, 'mediaSourceId'>[]>;
|
||||||
|
|
||||||
|
upsertPrograms(
|
||||||
|
programs: NewProgramWithExternalIds[],
|
||||||
|
programUpsertBatchSize?: number,
|
||||||
|
): Promise<ProgramWithExternalIds[]>;
|
||||||
|
|
||||||
programIdsByExternalIds(
|
programIdsByExternalIds(
|
||||||
ids: Set<[string, string, string]>,
|
ids: Set<[string, string, string]>,
|
||||||
@@ -117,9 +144,81 @@ export interface IProgramDB {
|
|||||||
upsertProgramExternalIds(
|
upsertProgramExternalIds(
|
||||||
externalIds: NewSingleOrMultiExternalId[],
|
externalIds: NewSingleOrMultiExternalId[],
|
||||||
chunkSize?: number,
|
chunkSize?: number,
|
||||||
): Promise<void>;
|
): Promise<Dictionary<ProgramExternalId[]>>;
|
||||||
|
|
||||||
|
getProgramsForMediaSource(
|
||||||
|
mediaSourceId: string,
|
||||||
|
type?: ProgramType,
|
||||||
|
): Promise<ProgramDao[]>;
|
||||||
|
|
||||||
|
getMediaSourceLibraryPrograms(
|
||||||
|
libraryId: string,
|
||||||
|
): Promise<ProgramWithRelations[]>;
|
||||||
|
|
||||||
|
getProgramCanonicalIdsForMediaSource(
|
||||||
|
mediaSourceLibraryId: string,
|
||||||
|
type: ProgramType,
|
||||||
|
): Promise<Dictionary<ProgramCanonicalIdLookupResult>>;
|
||||||
|
|
||||||
|
getProgramGroupingCanonicalIds(
|
||||||
|
mediaSourceLibraryId: string,
|
||||||
|
type: ProgramGroupingType,
|
||||||
|
sourceType: MediaSourceType,
|
||||||
|
): Promise<Dictionary<ProgramGroupingCanonicalIdLookupResult>>;
|
||||||
|
|
||||||
|
getOrInsertProgramGrouping(
|
||||||
|
dao: NewProgramGroupingWithExternalIds,
|
||||||
|
externalId: ProgramGroupingExternalIdLookup,
|
||||||
|
forceUpdate?: boolean,
|
||||||
|
): Promise<GetOrInsertResult<ProgramGroupingWithExternalIds>>;
|
||||||
|
|
||||||
|
getShowSeasons(showUuid: string): Promise<ProgramGroupingWithExternalIds[]>;
|
||||||
|
|
||||||
|
getArtistAlbums(
|
||||||
|
artistUuid: string,
|
||||||
|
): Promise<ProgramGroupingWithExternalIds[]>;
|
||||||
|
|
||||||
|
getProgramGroupingChildCounts(
|
||||||
|
groupIds: string[],
|
||||||
|
): Promise<Record<string, ProgramGroupingChildCounts>>;
|
||||||
|
|
||||||
|
getProgramGroupingDescendants(
|
||||||
|
groupId: string,
|
||||||
|
groupTypeHint?: ProgramGroupingType,
|
||||||
|
): Promise<ProgramWithExternalIds[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WithChannelIdFilter<T> = T & {
|
export type WithChannelIdFilter<T> = T & {
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProgramCanonicalIdLookupResult = {
|
||||||
|
uuid: string;
|
||||||
|
canonicalId: string;
|
||||||
|
libraryId: string;
|
||||||
|
externalKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProgramGroupingCanonicalIdLookupResult = {
|
||||||
|
uuid: string;
|
||||||
|
canonicalId: string;
|
||||||
|
libraryId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProgramGroupingExternalIdLookup = {
|
||||||
|
sourceType: ProgramExternalIdSourceType;
|
||||||
|
externalKey: string;
|
||||||
|
externalSourceId: MediaSourceId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetOrInsertResult<Entity> = {
|
||||||
|
wasInserted: boolean;
|
||||||
|
wasUpdated: boolean;
|
||||||
|
entity: Entity;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProgramGroupingChildCounts = {
|
||||||
|
type: ProgramGroupingType;
|
||||||
|
childCount?: number;
|
||||||
|
grandchildCount?: number;
|
||||||
|
};
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import type {
|
|||||||
SystemSettings,
|
SystemSettings,
|
||||||
XmlTvSettings,
|
XmlTvSettings,
|
||||||
} from '@tunarr/types';
|
} from '@tunarr/types';
|
||||||
import type { BackupSettings } from '@tunarr/types/schemas';
|
import type {
|
||||||
|
BackupSettings,
|
||||||
|
GlobalMediaSourceSettings,
|
||||||
|
} from '@tunarr/types/schemas';
|
||||||
import type { DeepReadonly } from 'ts-essentials';
|
import type { DeepReadonly } from 'ts-essentials';
|
||||||
import type { TypedEventEmitter } from '../../types/eventEmitter.ts';
|
import type { TypedEventEmitter } from '../../types/eventEmitter.ts';
|
||||||
|
|
||||||
@@ -32,6 +35,8 @@ export interface ISettingsDB extends TypedEventEmitter<SettingsChangeEvents> {
|
|||||||
|
|
||||||
ffmpegSettings(): ReadableFfmpegSettings;
|
ffmpegSettings(): ReadableFfmpegSettings;
|
||||||
|
|
||||||
|
globalMediaSourceSettings(): DeepReadonly<GlobalMediaSourceSettings>;
|
||||||
|
|
||||||
ffprobePath: string;
|
ffprobePath: string;
|
||||||
|
|
||||||
systemSettings(): DeepReadonly<SystemSettings>;
|
systemSettings(): DeepReadonly<SystemSettings>;
|
||||||
@@ -54,6 +59,7 @@ export interface ISettingsDB extends TypedEventEmitter<SettingsChangeEvents> {
|
|||||||
|
|
||||||
flush(): Promise<void>;
|
flush(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReadableFfmpegSettings = DeepReadonly<FfmpegSettings>;
|
export type ReadableFfmpegSettings = DeepReadonly<FfmpegSettings>;
|
||||||
export type SettingsChangeEvents = {
|
export type SettingsChangeEvents = {
|
||||||
change(): void;
|
change(): void;
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export class SchemaBackedDbAdapter<T extends z.ZodTypeAny>
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (data === null && this.defaultValue === null) {
|
if (data === null && this.defaultValue === null) {
|
||||||
this.logger.debug('Unexpected null data at %s; %O', this.path, data);
|
this.logger.debug('Unexpected null data at %s', this.path.toString());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,8 +55,8 @@ export class SchemaBackedDbAdapter<T extends z.ZodTypeAny>
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Error while parsing schema-backed JSON file ${this.path.toString()}. Returning null. This could mean the DB got corrupted somehow`,
|
|
||||||
parseResult.error,
|
parseResult.error,
|
||||||
|
`Error while parsing schema-backed JSON file ${this.path.toString()}. Returning null. This could mean the DB got corrupted somehow`,
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@ export class SchemaBackedDbAdapter<T extends z.ZodTypeAny>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// eslint can't seem to handle this but TS compiler gets it right.
|
// eslint can't seem to handle this but TS compiler gets it right.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
|
||||||
return parseResult.data;
|
return parseResult.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
|
||||||
import type { Nullable } from '@/types/util.js';
|
import type { Nullable } from '@/types/util.js';
|
||||||
import { isProduction } from '@/util/index.js';
|
import { isProduction } from '@/util/index.js';
|
||||||
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
|
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
|
||||||
@@ -51,8 +50,8 @@ export class SyncSchemaBackedDbAdapter<T extends z.ZodTypeAny>
|
|||||||
|
|
||||||
if (!parseResult.success) {
|
if (!parseResult.success) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Error while parsing schema-backed JSON file ${this.path.toString()}. Returning null. This could mean the DB got corrupted somehow`,
|
|
||||||
parseResult.error,
|
parseResult.error,
|
||||||
|
`Error while parsing schema-backed JSON file ${this.path.toString()}. Returning null. This could mean the DB got corrupted somehow`,
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -64,8 +63,8 @@ export class SyncSchemaBackedDbAdapter<T extends z.ZodTypeAny>
|
|||||||
const parseResult = this.schema.safeParse(data);
|
const parseResult = this.schema.safeParse(data);
|
||||||
if (!parseResult.success) {
|
if (!parseResult.success) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
'Could not verify schema before saving to DB - the given type does not match the expected schema.',
|
|
||||||
parseResult.error,
|
parseResult.error,
|
||||||
|
'Could not verify schema before saving to DB - the given type does not match the expected schema.',
|
||||||
);
|
);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Could not verify schema before saving to DB - the given type does not match the expected schema.',
|
'Could not verify schema before saving to DB - the given type does not match the expected schema.',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import dayjs from 'dayjs';
|
|||||||
import {
|
import {
|
||||||
chunk,
|
chunk,
|
||||||
first,
|
first,
|
||||||
|
isEmpty,
|
||||||
isNil,
|
isNil,
|
||||||
isUndefined,
|
isUndefined,
|
||||||
keys,
|
keys,
|
||||||
@@ -21,21 +22,34 @@ import { v4 } from 'uuid';
|
|||||||
import { type IChannelDB } from '@/db/interfaces/IChannelDB.js';
|
import { type IChannelDB } from '@/db/interfaces/IChannelDB.js';
|
||||||
import { KEYS } from '@/types/inject.js';
|
import { KEYS } from '@/types/inject.js';
|
||||||
import { booleanToNumber } from '@/util/sqliteUtil.js';
|
import { booleanToNumber } from '@/util/sqliteUtil.js';
|
||||||
import { inject, injectable } from 'inversify';
|
import { retag, tag } from '@tunarr/types';
|
||||||
|
import { inject, injectable, interfaces } from 'inversify';
|
||||||
import { Kysely } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
|
import { jsonObjectFrom } from 'kysely/helpers/sqlite';
|
||||||
import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory.ts';
|
import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory.ts';
|
||||||
|
import { MediaSourceLibraryRefresher } from '../services/MediaSourceLibraryRefresher.ts';
|
||||||
|
import { withLibraries } from './mediaSourceQueryHelpers.ts';
|
||||||
import {
|
import {
|
||||||
withProgramChannels,
|
withProgramChannels,
|
||||||
withProgramCustomShows,
|
withProgramCustomShows,
|
||||||
withProgramFillerShows,
|
withProgramFillerShows,
|
||||||
} from './programQueryHelpers.ts';
|
} from './programQueryHelpers.ts';
|
||||||
|
import { MediaSourceId, MediaSourceName } from './schema/base.ts';
|
||||||
import { DB } from './schema/db.ts';
|
import { DB } from './schema/db.ts';
|
||||||
import {
|
import {
|
||||||
EmbyMediaSource,
|
EmbyMediaSource,
|
||||||
JellyfinMediaSource,
|
JellyfinMediaSource,
|
||||||
MediaSource,
|
MediaSourceWithLibraries,
|
||||||
MediaSourceType,
|
|
||||||
PlexMediaSource,
|
PlexMediaSource,
|
||||||
|
} from './schema/derivedTypes.js';
|
||||||
|
import {
|
||||||
|
MediaSource,
|
||||||
|
MediaSourceFields,
|
||||||
|
MediaSourceLibrary,
|
||||||
|
MediaSourceLibraryUpdate,
|
||||||
|
MediaSourceType,
|
||||||
|
MediaSourceUpdate,
|
||||||
|
NewMediaSourceLibrary,
|
||||||
} from './schema/MediaSource.ts';
|
} from './schema/MediaSource.ts';
|
||||||
|
|
||||||
type Report = {
|
type Report = {
|
||||||
@@ -59,68 +73,79 @@ export class MediaSourceDB {
|
|||||||
@inject(KEYS.MediaSourceApiFactory)
|
@inject(KEYS.MediaSourceApiFactory)
|
||||||
private mediaSourceApiFactory: () => MediaSourceApiFactory,
|
private mediaSourceApiFactory: () => MediaSourceApiFactory,
|
||||||
@inject(KEYS.Database) private db: Kysely<DB>,
|
@inject(KEYS.Database) private db: Kysely<DB>,
|
||||||
|
@inject(KEYS.MediaSourceLibraryRefresher)
|
||||||
|
private mediaSourceLibraryRefresher: interfaces.AutoFactory<MediaSourceLibraryRefresher>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getAll(): Promise<MediaSource[]> {
|
async getAll(): Promise<MediaSourceWithLibraries[]> {
|
||||||
return this.db.selectFrom('mediaSource').selectAll().execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
async getById(id: string) {
|
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('mediaSource')
|
.selectFrom('mediaSource')
|
||||||
|
.select(withLibraries)
|
||||||
|
.selectAll()
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(id: MediaSourceId): Promise<Maybe<MediaSourceWithLibraries>> {
|
||||||
|
return this.db
|
||||||
|
.selectFrom('mediaSource')
|
||||||
|
.select(withLibraries)
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.where('mediaSource.uuid', '=', id)
|
.where('mediaSource.uuid', '=', id)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getByName(name: string) {
|
async getLibrary(id: string) {
|
||||||
return this.db
|
return (
|
||||||
.selectFrom('mediaSource')
|
this.db
|
||||||
.selectAll()
|
.selectFrom('mediaSourceLibrary')
|
||||||
.where('mediaSource.name', '=', name)
|
.where('uuid', '=', id)
|
||||||
.executeTakeFirst();
|
.select((eb) =>
|
||||||
}
|
jsonObjectFrom(
|
||||||
|
eb
|
||||||
async getByIdOrName(id: string) {
|
.selectFrom('mediaSource')
|
||||||
return this.db
|
.whereRef(
|
||||||
.selectFrom('mediaSource')
|
'mediaSource.uuid',
|
||||||
.selectAll()
|
'=',
|
||||||
.where((eb) => eb.or([eb('uuid', '=', id), eb('name', '=', id)]))
|
'mediaSourceLibrary.mediaSourceId',
|
||||||
.executeTakeFirst();
|
)
|
||||||
|
.select(MediaSourceFields),
|
||||||
|
).as('mediaSource'),
|
||||||
|
)
|
||||||
|
.selectAll()
|
||||||
|
// Should be safe before of referential integrity of foreign keys
|
||||||
|
.$narrowType<{ mediaSource: MediaSource }>()
|
||||||
|
.executeTakeFirst()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByType(
|
async findByType(
|
||||||
type: typeof MediaSourceType.Plex,
|
type: typeof MediaSourceType.Plex,
|
||||||
nameOrId: string,
|
nameOrId: MediaSourceId,
|
||||||
): Promise<PlexMediaSource | undefined>;
|
): Promise<PlexMediaSource | undefined>;
|
||||||
async findByType(
|
async findByType(
|
||||||
type: typeof MediaSourceType.Jellyfin,
|
type: typeof MediaSourceType.Jellyfin,
|
||||||
nameOrId: string,
|
nameOrId: MediaSourceId,
|
||||||
): Promise<JellyfinMediaSource | undefined>;
|
): Promise<JellyfinMediaSource | undefined>;
|
||||||
async findByType(
|
async findByType(
|
||||||
type: typeof MediaSourceType.Emby,
|
type: typeof MediaSourceType.Emby,
|
||||||
nameOrId: string,
|
nameOrId: MediaSourceId,
|
||||||
): Promise<EmbyMediaSource | undefined>;
|
): Promise<EmbyMediaSource | undefined>;
|
||||||
async findByType(
|
async findByType(
|
||||||
type: MediaSourceType,
|
type: MediaSourceType,
|
||||||
nameOrId: string,
|
nameOrId: MediaSourceId,
|
||||||
): Promise<MediaSource | undefined>;
|
): Promise<MediaSourceWithLibraries | undefined>;
|
||||||
async findByType(type: MediaSourceType): Promise<MediaSource[]>;
|
async findByType(type: MediaSourceType): Promise<MediaSourceWithLibraries[]>;
|
||||||
async findByType(
|
async findByType(
|
||||||
type: MediaSourceType,
|
type: MediaSourceType,
|
||||||
nameOrId?: string,
|
nameOrId?: MediaSourceId,
|
||||||
): Promise<MediaSource[] | Maybe<MediaSource>> {
|
): Promise<MediaSourceWithLibraries[] | Maybe<MediaSourceWithLibraries>> {
|
||||||
const found = await this.db
|
const found = await this.db
|
||||||
.selectFrom('mediaSource')
|
.selectFrom('mediaSource')
|
||||||
.selectAll()
|
.selectAll()
|
||||||
|
.select(withLibraries)
|
||||||
.where('mediaSource.type', '=', type)
|
.where('mediaSource.type', '=', type)
|
||||||
.$if(isNonEmptyString(nameOrId), (qb) =>
|
.$if(isNonEmptyString(nameOrId), (qb) =>
|
||||||
qb.where((eb) =>
|
qb.where('mediaSource.uuid', '=', retag<MediaSourceId>(nameOrId!)),
|
||||||
eb.or([
|
|
||||||
eb('mediaSource.name', '=', nameOrId!),
|
|
||||||
eb('mediaSource.uuid', '=', nameOrId!),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
@@ -131,26 +156,7 @@ export class MediaSourceDB {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getByExternalId(
|
async deleteMediaSource(id: MediaSourceId) {
|
||||||
sourceType: MediaSourceType,
|
|
||||||
nameOrClientId: string,
|
|
||||||
): Promise<Maybe<MediaSource>> {
|
|
||||||
return this.db
|
|
||||||
.selectFrom('mediaSource')
|
|
||||||
.selectAll()
|
|
||||||
.where((eb) =>
|
|
||||||
eb.and([
|
|
||||||
eb('type', '=', sourceType),
|
|
||||||
eb.or([
|
|
||||||
eb('name', '=', nameOrClientId),
|
|
||||||
eb('clientIdentifier', '=', nameOrClientId),
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.executeTakeFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteMediaSource(id: string) {
|
|
||||||
const deletedServer = await this.getById(id);
|
const deletedServer = await this.getById(id);
|
||||||
if (isNil(deletedServer)) {
|
if (isNil(deletedServer)) {
|
||||||
throw new Error(`MediaSource not found: ${id}`);
|
throw new Error(`MediaSource not found: ${id}`);
|
||||||
@@ -185,7 +191,7 @@ export class MediaSourceDB {
|
|||||||
async updateMediaSource(server: UpdateMediaSourceRequest) {
|
async updateMediaSource(server: UpdateMediaSourceRequest) {
|
||||||
const id = server.id;
|
const id = server.id;
|
||||||
|
|
||||||
const mediaSource = await this.getById(id);
|
const mediaSource = await this.getById(tag(id));
|
||||||
|
|
||||||
if (isNil(mediaSource)) {
|
if (isNil(mediaSource)) {
|
||||||
throw new Error("Server doesn't exist.");
|
throw new Error("Server doesn't exist.");
|
||||||
@@ -199,7 +205,7 @@ export class MediaSourceDB {
|
|||||||
await this.db
|
await this.db
|
||||||
.updateTable('mediaSource')
|
.updateTable('mediaSource')
|
||||||
.set({
|
.set({
|
||||||
name: server.name,
|
name: tag<MediaSourceName>(server.name),
|
||||||
uri: trimEnd(server.uri, '/'),
|
uri: trimEnd(server.uri, '/'),
|
||||||
accessToken: server.accessToken,
|
accessToken: server.accessToken,
|
||||||
sendGuideUpdates: booleanToNumber(sendGuideUpdates),
|
sendGuideUpdates: booleanToNumber(sendGuideUpdates),
|
||||||
@@ -208,8 +214,8 @@ export class MediaSourceDB {
|
|||||||
// This allows clearing the values
|
// This allows clearing the values
|
||||||
userId: server.userId,
|
userId: server.userId,
|
||||||
username: server.username,
|
username: server.username,
|
||||||
})
|
} satisfies MediaSourceUpdate)
|
||||||
.where('uuid', '=', server.id)
|
.where('uuid', '=', tag<MediaSourceId>(server.id))
|
||||||
// TODO: Blocked on https://github.com/oven-sh/bun/issues/16909
|
// TODO: Blocked on https://github.com/oven-sh/bun/issues/16909
|
||||||
// .limit(1)
|
// .limit(1)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
@@ -217,7 +223,7 @@ export class MediaSourceDB {
|
|||||||
this.mediaSourceApiFactory().deleteCachedClient(mediaSource);
|
this.mediaSourceApiFactory().deleteCachedClient(mediaSource);
|
||||||
|
|
||||||
const report = await this.fixupProgramReferences(
|
const report = await this.fixupProgramReferences(
|
||||||
id,
|
tag(id),
|
||||||
mediaSource.type,
|
mediaSource.type,
|
||||||
mediaSource,
|
mediaSource,
|
||||||
);
|
);
|
||||||
@@ -226,7 +232,7 @@ export class MediaSourceDB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async setMediaSourceUserInfo(
|
async setMediaSourceUserInfo(
|
||||||
mediaSourceId: string,
|
mediaSourceId: MediaSourceId,
|
||||||
info: MediaSourceUserInfo,
|
info: MediaSourceUserInfo,
|
||||||
) {
|
) {
|
||||||
if (isNonEmptyString(info.userId) && isNonEmptyString(info.username)) {
|
if (isNonEmptyString(info.userId) && isNonEmptyString(info.username)) {
|
||||||
@@ -244,7 +250,9 @@ export class MediaSourceDB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async addMediaSource(server: InsertMediaSourceRequest): Promise<string> {
|
async addMediaSource(server: InsertMediaSourceRequest): Promise<string> {
|
||||||
const name = isUndefined(server.name) ? 'plex' : server.name;
|
const name = tag<MediaSourceName>(
|
||||||
|
isUndefined(server.name) ? 'plex' : server.name,
|
||||||
|
);
|
||||||
const sendGuideUpdates =
|
const sendGuideUpdates =
|
||||||
server.type === 'plex' ? (server.sendGuideUpdates ?? false) : false;
|
server.type === 'plex' ? (server.sendGuideUpdates ?? false) : false;
|
||||||
const sendChannelUpdates =
|
const sendChannelUpdates =
|
||||||
@@ -260,7 +268,7 @@ export class MediaSourceDB {
|
|||||||
.insertInto('mediaSource')
|
.insertInto('mediaSource')
|
||||||
.values({
|
.values({
|
||||||
...server,
|
...server,
|
||||||
uuid: v4(),
|
uuid: tag<MediaSourceId>(v4()),
|
||||||
name,
|
name,
|
||||||
uri: trimEnd(server.uri, '/'),
|
uri: trimEnd(server.uri, '/'),
|
||||||
sendChannelUpdates: sendChannelUpdates ? 1 : 0,
|
sendChannelUpdates: sendChannelUpdates ? 1 : 0,
|
||||||
@@ -275,11 +283,65 @@ export class MediaSourceDB {
|
|||||||
.returning('uuid')
|
.returning('uuid')
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
await this.mediaSourceLibraryRefresher().refreshMediaSource(newServer.uuid);
|
||||||
|
|
||||||
return newServer?.uuid;
|
return newServer?.uuid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateLibraries(updates: MediaSourceLibrariesUpdate) {
|
||||||
|
return this.db.transaction().execute(async (tx) => {
|
||||||
|
if (!isEmpty(updates.addedLibraries)) {
|
||||||
|
await tx
|
||||||
|
.insertInto('mediaSourceLibrary')
|
||||||
|
.values(updates.addedLibraries)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.updatedLibraries.length) {
|
||||||
|
// TODO;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.deletedLibraries.length) {
|
||||||
|
await tx
|
||||||
|
.deleteFrom('mediaSourceLibrary')
|
||||||
|
.where(
|
||||||
|
'uuid',
|
||||||
|
'in',
|
||||||
|
updates.deletedLibraries.map((lib) => lib.uuid),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async setLibraryEnabled(
|
||||||
|
mediaSourceId: MediaSourceId,
|
||||||
|
libraryId: string,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
return this.db
|
||||||
|
.updateTable('mediaSourceLibrary')
|
||||||
|
.set({
|
||||||
|
enabled: booleanToNumber(enabled),
|
||||||
|
})
|
||||||
|
.where('mediaSourceLibrary.mediaSourceId', '=', mediaSourceId)
|
||||||
|
.where('uuid', '=', libraryId)
|
||||||
|
.returningAll()
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
setLibraryLastScannedTime(libraryId: string, lastScannedAt: dayjs.Dayjs) {
|
||||||
|
return this.db
|
||||||
|
.updateTable('mediaSourceLibrary')
|
||||||
|
.set({
|
||||||
|
lastScannedAt: +lastScannedAt,
|
||||||
|
})
|
||||||
|
.where('uuid', '=', libraryId)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
}
|
||||||
|
|
||||||
private async fixupProgramReferences(
|
private async fixupProgramReferences(
|
||||||
serverName: string,
|
serverId: MediaSourceId,
|
||||||
serverType: MediaSourceType,
|
serverType: MediaSourceType,
|
||||||
newServer?: MediaSource,
|
newServer?: MediaSource,
|
||||||
) {
|
) {
|
||||||
@@ -292,7 +354,7 @@ export class MediaSourceDB {
|
|||||||
.selectFrom('program')
|
.selectFrom('program')
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.where('sourceType', '=', serverType)
|
.where('sourceType', '=', serverType)
|
||||||
.where('externalSourceId', '=', serverName)
|
.where('mediaSourceId', '=', serverId)
|
||||||
.select(withProgramChannels)
|
.select(withProgramChannels)
|
||||||
.select(withProgramFillerShows)
|
.select(withProgramFillerShows)
|
||||||
.select(withProgramCustomShows)
|
.select(withProgramCustomShows)
|
||||||
@@ -334,7 +396,7 @@ export class MediaSourceDB {
|
|||||||
.length,
|
.length,
|
||||||
);
|
);
|
||||||
|
|
||||||
const isUpdate = newServer && newServer.uuid !== serverName;
|
const isUpdate = newServer && newServer.uuid !== serverId;
|
||||||
if (!isUpdate) {
|
if (!isUpdate) {
|
||||||
// Remove all associations of this program
|
// Remove all associations of this program
|
||||||
// TODO: See if we can just get this automatically with foreign keys...
|
// TODO: See if we can just get this automatically with foreign keys...
|
||||||
@@ -399,3 +461,9 @@ export class MediaSourceDB {
|
|||||||
return [...channelReports, ...fillerReports, ...customShowReports];
|
return [...channelReports, ...fillerReports, ...customShowReports];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MediaSourceLibrariesUpdate = {
|
||||||
|
addedLibraries: NewMediaSourceLibrary[];
|
||||||
|
updatedLibraries: MediaSourceLibraryUpdate[];
|
||||||
|
deletedLibraries: MediaSourceLibrary[];
|
||||||
|
};
|
||||||
|
|||||||
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 { isNonEmptyString } from '@/util/index.js';
|
||||||
import { createExternalId } from '@tunarr/shared';
|
import { createExternalId } from '@tunarr/shared';
|
||||||
import type { ContentProgram, CustomProgram } from '@tunarr/types';
|
import type { ContentProgram, CustomProgram } from '@tunarr/types';
|
||||||
import { isContentProgram, isCustomProgram } from '@tunarr/types';
|
import { isContentProgram, isCustomProgram, tag } from '@tunarr/types';
|
||||||
import { reduce } from 'lodash-es';
|
import { reduce } from 'lodash-es';
|
||||||
|
|
||||||
// Takes a listing of programs and makes a mapping of a unique identifier,
|
// Takes a listing of programs and makes a mapping of a unique identifier,
|
||||||
@@ -21,14 +21,14 @@ export function createPendingProgramIndexMap(
|
|||||||
// TODO handle other types of programs
|
// TODO handle other types of programs
|
||||||
} else if (
|
} else if (
|
||||||
isContentProgram(p) &&
|
isContentProgram(p) &&
|
||||||
isNonEmptyString(p.externalSourceName) &&
|
isNonEmptyString(p.externalSourceId) &&
|
||||||
isNonEmptyString(p.externalSourceType) &&
|
isNonEmptyString(p.externalSourceType) &&
|
||||||
isNonEmptyString(p.externalKey)
|
isNonEmptyString(p.externalKey)
|
||||||
) {
|
) {
|
||||||
acc[
|
acc[
|
||||||
createExternalId(
|
createExternalId(
|
||||||
p.externalSourceType,
|
p.externalSourceType,
|
||||||
p.externalSourceName,
|
tag(p.externalSourceId),
|
||||||
p.externalKey,
|
p.externalKey,
|
||||||
)
|
)
|
||||||
] = idx++;
|
] = idx++;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/sqlite';
|
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/sqlite';
|
||||||
import { identity, isBoolean, isEmpty, keys, merge, reduce } from 'lodash-es';
|
import { identity, isBoolean, isEmpty, keys, merge, reduce } from 'lodash-es';
|
||||||
import type { DeepPartial, DeepRequired, StrictExclude } from 'ts-essentials';
|
import type { DeepPartial, DeepRequired, StrictExclude } from 'ts-essentials';
|
||||||
|
import type { Replace } from '../types/util.ts';
|
||||||
import type { FillerShowTable as RawFillerShow } from './schema/FillerShow.js';
|
import type { FillerShowTable as RawFillerShow } from './schema/FillerShow.js';
|
||||||
import type {
|
import type {
|
||||||
ProgramDao,
|
ProgramDao,
|
||||||
@@ -19,40 +20,16 @@ import type {
|
|||||||
import { ProgramType } from './schema/Program.ts';
|
import { ProgramType } from './schema/Program.ts';
|
||||||
import type { ProgramExternalId } from './schema/ProgramExternalId.ts';
|
import type { ProgramExternalId } from './schema/ProgramExternalId.ts';
|
||||||
import { ProgramExternalIdFieldsWithAlias } from './schema/ProgramExternalId.ts';
|
import { ProgramExternalIdFieldsWithAlias } from './schema/ProgramExternalId.ts';
|
||||||
|
import type { ProgramGroupingFields } from './schema/ProgramGrouping.ts';
|
||||||
import {
|
import {
|
||||||
|
AllProgramGroupingFields,
|
||||||
|
AllProgramGroupingFieldsAliased,
|
||||||
ProgramGroupingType,
|
ProgramGroupingType,
|
||||||
type ProgramGroupingTable as RawProgramGrouping,
|
|
||||||
} from './schema/ProgramGrouping.ts';
|
} from './schema/ProgramGrouping.ts';
|
||||||
import type { ProgramGroupingExternalId } from './schema/ProgramGroupingExternalId.ts';
|
import type { ProgramGroupingExternalId } from './schema/ProgramGroupingExternalId.ts';
|
||||||
import { ProgramGroupingExternalIdFieldsWithAlias } from './schema/ProgramGroupingExternalId.ts';
|
import { ProgramGroupingExternalIdFieldsWithAlias } from './schema/ProgramGroupingExternalId.ts';
|
||||||
import type { DB } from './schema/db.ts';
|
import type { DB } from './schema/db.ts';
|
||||||
|
|
||||||
type ProgramGroupingFields<Alias extends string = 'programGrouping'> =
|
|
||||||
readonly `${Alias}.${keyof RawProgramGrouping}`[];
|
|
||||||
|
|
||||||
const ProgramGroupingKeys: (keyof RawProgramGrouping)[] = [
|
|
||||||
'artistUuid',
|
|
||||||
'createdAt',
|
|
||||||
'icon',
|
|
||||||
'index',
|
|
||||||
'showUuid',
|
|
||||||
'summary',
|
|
||||||
'title',
|
|
||||||
'type',
|
|
||||||
'updatedAt',
|
|
||||||
'uuid',
|
|
||||||
'year',
|
|
||||||
];
|
|
||||||
|
|
||||||
// TODO move this definition to the ProgramGrouping DAO file
|
|
||||||
export const AllProgramGroupingFields: ProgramGroupingFields =
|
|
||||||
ProgramGroupingKeys.map((key) => `programGrouping.${key}` as const);
|
|
||||||
|
|
||||||
export const AllProgramGroupingFieldsAliased = <Alias extends string>(
|
|
||||||
alias: Alias,
|
|
||||||
): ProgramGroupingFields<Alias> =>
|
|
||||||
ProgramGroupingKeys.map((key) => `${alias}.${key}` as const);
|
|
||||||
|
|
||||||
type ProgramGroupingExternalIdFields<
|
type ProgramGroupingExternalIdFields<
|
||||||
Alias extends string = 'programGroupingExternalId',
|
Alias extends string = 'programGroupingExternalId',
|
||||||
> = readonly `${Alias}.${keyof ProgramGroupingExternalId}`[];
|
> = readonly `${Alias}.${keyof ProgramGroupingExternalId}`[];
|
||||||
@@ -209,6 +186,7 @@ export function withProgramExternalIds(
|
|||||||
'externalKey',
|
'externalKey',
|
||||||
'sourceType',
|
'sourceType',
|
||||||
'externalSourceId',
|
'externalSourceId',
|
||||||
|
'mediaSourceId',
|
||||||
],
|
],
|
||||||
) {
|
) {
|
||||||
return jsonArrayFrom(
|
return jsonArrayFrom(
|
||||||
@@ -254,6 +232,7 @@ export function withProgramGroupingExternalIds(
|
|||||||
'sourceType',
|
'sourceType',
|
||||||
'externalSourceId',
|
'externalSourceId',
|
||||||
'mediaSourceId',
|
'mediaSourceId',
|
||||||
|
'libraryId',
|
||||||
],
|
],
|
||||||
) {
|
) {
|
||||||
return jsonArrayFrom(
|
return jsonArrayFrom(
|
||||||
@@ -288,34 +267,31 @@ export const AllProgramJoins: ProgramJoins = {
|
|||||||
customShows: true,
|
customShows: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
type Replace<
|
|
||||||
T extends string,
|
|
||||||
S extends string,
|
|
||||||
D extends string,
|
|
||||||
A extends string = '',
|
|
||||||
> = T extends `${infer L}${S}${infer R}`
|
|
||||||
? Replace<R, S, D, `${A}${L}${D}`>
|
|
||||||
: `${A}${T}`;
|
|
||||||
|
|
||||||
type ProgramField = `program.${keyof RawProgram}`;
|
type ProgramField = `program.${keyof RawProgram}`;
|
||||||
type ProgramFields = readonly ProgramField[];
|
type ProgramFields = readonly ProgramField[];
|
||||||
|
|
||||||
// const ProgramUpsertMapping =
|
export const AllProgramFields = [
|
||||||
|
'program.uuid',
|
||||||
export const AllProgramFields: ProgramFields = [
|
'program.createdAt',
|
||||||
|
'program.updatedAt',
|
||||||
'program.albumName',
|
'program.albumName',
|
||||||
|
'program.canonicalId',
|
||||||
|
'program.icon',
|
||||||
|
'program.summary',
|
||||||
|
'program.title',
|
||||||
|
'program.type',
|
||||||
|
'program.year',
|
||||||
|
'program.artistUuid',
|
||||||
|
'program.externalKey',
|
||||||
|
'program.libraryId',
|
||||||
'program.albumUuid',
|
'program.albumUuid',
|
||||||
'program.artistName',
|
'program.artistName',
|
||||||
'program.artistUuid',
|
|
||||||
'program.createdAt',
|
|
||||||
'program.duration',
|
'program.duration',
|
||||||
'program.episode',
|
'program.episode',
|
||||||
'program.episodeIcon',
|
'program.episodeIcon',
|
||||||
'program.externalKey',
|
|
||||||
'program.externalSourceId',
|
'program.externalSourceId',
|
||||||
'program.filePath',
|
'program.filePath',
|
||||||
'program.grandparentExternalKey',
|
'program.grandparentExternalKey',
|
||||||
'program.icon',
|
|
||||||
'program.originalAirDate',
|
'program.originalAirDate',
|
||||||
'program.parentExternalKey',
|
'program.parentExternalKey',
|
||||||
'program.plexFilePath',
|
'program.plexFilePath',
|
||||||
@@ -327,14 +303,9 @@ export const AllProgramFields: ProgramFields = [
|
|||||||
'program.showIcon',
|
'program.showIcon',
|
||||||
'program.showTitle',
|
'program.showTitle',
|
||||||
'program.sourceType',
|
'program.sourceType',
|
||||||
'program.summary',
|
|
||||||
'program.title',
|
|
||||||
'program.tvShowUuid',
|
'program.tvShowUuid',
|
||||||
'program.type',
|
'program.mediaSourceId',
|
||||||
'program.updatedAt',
|
] as const;
|
||||||
'program.uuid',
|
|
||||||
'program.year',
|
|
||||||
];
|
|
||||||
|
|
||||||
type ProgramUpsertFields = StrictExclude<
|
type ProgramUpsertFields = StrictExclude<
|
||||||
Replace<ProgramField, 'program', 'excluded'>,
|
Replace<ProgramField, 'program', 'excluded'>,
|
||||||
@@ -348,6 +319,7 @@ const ProgramUpsertIgnoreFields = [
|
|||||||
'program.albumUuid',
|
'program.albumUuid',
|
||||||
'program.artistUuid',
|
'program.artistUuid',
|
||||||
'program.seasonUuid',
|
'program.seasonUuid',
|
||||||
|
// 'program.libraryId',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type KnownProgramUpsertFields = StrictExclude<
|
type KnownProgramUpsertFields = StrictExclude<
|
||||||
@@ -371,11 +343,13 @@ export const ProgramUpsertFields: ProgramUpsertFields[] =
|
|||||||
export type WithProgramsOptions = {
|
export type WithProgramsOptions = {
|
||||||
joins?: Partial<ProgramJoins>;
|
joins?: Partial<ProgramJoins>;
|
||||||
fields?: ProgramFields;
|
fields?: ProgramFields;
|
||||||
|
includeGroupingExternalIds?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultWithProgramOptions: DeepRequired<WithProgramsOptions> = {
|
export const defaultWithProgramOptions: DeepRequired<WithProgramsOptions> = {
|
||||||
joins: defaultProgramJoins,
|
joins: defaultProgramJoins,
|
||||||
fields: AllProgramFields,
|
fields: AllProgramFields,
|
||||||
|
includeGroupingExternalIds: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
type BaseWithProgramsAvailableTables =
|
type BaseWithProgramsAvailableTables =
|
||||||
@@ -403,6 +377,18 @@ function baseWithProgramsExpressionBuilder(
|
|||||||
ProgramDao
|
ProgramDao
|
||||||
> = identity,
|
> = identity,
|
||||||
) {
|
) {
|
||||||
|
function getJoinFields(key: keyof ProgramJoins) {
|
||||||
|
if (!opts.joins[key]) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBoolean(opts.joins[key])) {
|
||||||
|
return opts.joins[key] ? AllProgramGroupingFields : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts.joins[key];
|
||||||
|
}
|
||||||
|
|
||||||
const builder = eb.selectFrom('program').select(opts.fields);
|
const builder = eb.selectFrom('program').select(opts.fields);
|
||||||
|
|
||||||
return builderFunc(builder)
|
return builderFunc(builder)
|
||||||
@@ -410,15 +396,38 @@ function baseWithProgramsExpressionBuilder(
|
|||||||
qb.select((eb) =>
|
qb.select((eb) =>
|
||||||
withTrackAlbum(
|
withTrackAlbum(
|
||||||
eb,
|
eb,
|
||||||
isBoolean(opts.joins.trackAlbum)
|
getJoinFields('trackAlbum'),
|
||||||
? AllProgramGroupingFields
|
opts.includeGroupingExternalIds,
|
||||||
: opts.joins.trackAlbum,
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.$if(!!opts.joins.trackArtist, (qb) =>
|
||||||
|
qb.select((eb) =>
|
||||||
|
withTrackArtist(
|
||||||
|
eb,
|
||||||
|
getJoinFields('trackArtist'),
|
||||||
|
opts.includeGroupingExternalIds,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.$if(!!opts.joins.tvSeason, (qb) =>
|
||||||
|
qb.select((eb) =>
|
||||||
|
withTvSeason(
|
||||||
|
eb,
|
||||||
|
getJoinFields('tvSeason'),
|
||||||
|
opts.includeGroupingExternalIds,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.$if(!!opts.joins.tvShow, (qb) =>
|
||||||
|
qb.select((eb) =>
|
||||||
|
withTvShow(
|
||||||
|
eb,
|
||||||
|
getJoinFields('tvShow'),
|
||||||
|
opts.includeGroupingExternalIds,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.$if(!!opts.joins.trackArtist, (qb) => qb.select(withTrackArtist))
|
|
||||||
.$if(!!opts.joins.tvSeason, (qb) => qb.select(withTvSeason))
|
|
||||||
.$if(!!opts.joins.tvSeason, (qb) => qb.select(withTvShow))
|
|
||||||
.$if(!!opts.joins.customShows, (qb) => qb.select(withProgramCustomShows));
|
.$if(!!opts.joins.customShows, (qb) => qb.select(withProgramCustomShows));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,10 +493,21 @@ export function withPrograms(
|
|||||||
export function withProgramByExternalId(
|
export function withProgramByExternalId(
|
||||||
eb: ExpressionBuilder<DB, 'programExternalId'>,
|
eb: ExpressionBuilder<DB, 'programExternalId'>,
|
||||||
options: WithProgramsOptions = defaultWithProgramOptions,
|
options: WithProgramsOptions = defaultWithProgramOptions,
|
||||||
|
builderFunc: (
|
||||||
|
qb: SelectQueryBuilder<
|
||||||
|
DB,
|
||||||
|
BaseWithProgramsAvailableTables | 'program',
|
||||||
|
ProgramDao
|
||||||
|
>,
|
||||||
|
) => SelectQueryBuilder<
|
||||||
|
DB,
|
||||||
|
BaseWithProgramsAvailableTables | 'program',
|
||||||
|
ProgramDao
|
||||||
|
> = identity,
|
||||||
) {
|
) {
|
||||||
const mergedOpts = merge({}, defaultWithProgramOptions, options);
|
const mergedOpts = merge({}, defaultWithProgramOptions, options);
|
||||||
return jsonObjectFrom(
|
return jsonObjectFrom(
|
||||||
baseWithProgramsExpressionBuilder(eb, mergedOpts).whereRef(
|
baseWithProgramsExpressionBuilder(eb, mergedOpts, builderFunc).whereRef(
|
||||||
'programExternalId.programUuid',
|
'programExternalId.programUuid',
|
||||||
'=',
|
'=',
|
||||||
'program.uuid',
|
'program.uuid',
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ type InferBool<
|
|||||||
T['_']['columns'][Key]['notNull'] extends true ? number : number | null
|
T['_']['columns'][Key]['notNull'] extends true ? number : number | null
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
type InferDateMs<
|
||||||
|
T extends Table,
|
||||||
|
Key extends keyof T['_']['columns'] & string,
|
||||||
|
> = ColumnType<
|
||||||
|
number,
|
||||||
|
T['_']['columns'][Key]['notNull'] extends true ? number : number | null
|
||||||
|
>;
|
||||||
|
|
||||||
export type KyselifyBetter<T extends Table> = Simplify<{
|
export type KyselifyBetter<T extends Table> = Simplify<{
|
||||||
[Key in keyof T['_']['columns'] & string as MapColumnName<
|
[Key in keyof T['_']['columns'] & string as MapColumnName<
|
||||||
Key,
|
Key,
|
||||||
@@ -37,46 +45,48 @@ export type KyselifyBetter<T extends Table> = Simplify<{
|
|||||||
? InferJson<T, Key>
|
? InferJson<T, Key>
|
||||||
: T['_']['columns'][Key]['dataType'] extends 'boolean'
|
: T['_']['columns'][Key]['dataType'] extends 'boolean'
|
||||||
? InferBool<T, Key>
|
? InferBool<T, Key>
|
||||||
: ColumnType<
|
: T['_']['columns'][Key]['dataType'] extends 'date'
|
||||||
InferSelectModel<
|
? InferDateMs<T, Key>
|
||||||
T,
|
: ColumnType<
|
||||||
{
|
InferSelectModel<
|
||||||
dbColumnNames: true;
|
T,
|
||||||
}
|
{
|
||||||
>[MapColumnName<Key, T['_']['columns'][Key], true>],
|
dbColumnNames: true;
|
||||||
MapColumnName<
|
}
|
||||||
Key,
|
>[MapColumnName<Key, T['_']['columns'][Key], true>],
|
||||||
T['_']['columns'][Key],
|
MapColumnName<
|
||||||
true
|
Key,
|
||||||
> extends keyof InferInsertModel<
|
T['_']['columns'][Key],
|
||||||
T,
|
true
|
||||||
{
|
> extends keyof InferInsertModel<
|
||||||
dbColumnNames: true;
|
T,
|
||||||
}
|
{
|
||||||
>
|
dbColumnNames: true;
|
||||||
? InferInsertModel<
|
}
|
||||||
T,
|
>
|
||||||
{
|
? InferInsertModel<
|
||||||
dbColumnNames: true;
|
T,
|
||||||
}
|
{
|
||||||
>[MapColumnName<Key, T['_']['columns'][Key], true>]
|
dbColumnNames: true;
|
||||||
: never,
|
}
|
||||||
MapColumnName<
|
>[MapColumnName<Key, T['_']['columns'][Key], true>]
|
||||||
Key,
|
: never,
|
||||||
T['_']['columns'][Key],
|
MapColumnName<
|
||||||
true
|
Key,
|
||||||
> extends keyof InferInsertModel<
|
T['_']['columns'][Key],
|
||||||
T,
|
true
|
||||||
{
|
> extends keyof InferInsertModel<
|
||||||
dbColumnNames: true;
|
T,
|
||||||
}
|
{
|
||||||
>
|
dbColumnNames: true;
|
||||||
? InferInsertModel<
|
}
|
||||||
T,
|
>
|
||||||
{
|
? InferInsertModel<
|
||||||
dbColumnNames: true;
|
T,
|
||||||
}
|
{
|
||||||
>[MapColumnName<Key, T['_']['columns'][Key], true>]
|
dbColumnNames: true;
|
||||||
: never
|
}
|
||||||
>;
|
>[MapColumnName<Key, T['_']['columns'][Key], true>]
|
||||||
|
: never
|
||||||
|
>;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { TupleToUnion } from '@tunarr/types';
|
import type { TupleToUnion } from '@tunarr/types';
|
||||||
import { inArray } from 'drizzle-orm';
|
import { inArray } from 'drizzle-orm';
|
||||||
import { check, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
import { check, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||||
|
import type { Updateable } from 'kysely';
|
||||||
import { type Insertable, type Selectable } from 'kysely';
|
import { type Insertable, type Selectable } from 'kysely';
|
||||||
import type { StrictOmit } from 'ts-essentials';
|
|
||||||
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
||||||
|
import type { MediaSourceName } from './base.ts';
|
||||||
|
import { type MediaSourceId } from './base.ts';
|
||||||
|
|
||||||
export const MediaSourceTypes = ['plex', 'jellyfin', 'emby'] as const;
|
export const MediaSourceTypes = ['plex', 'jellyfin', 'emby'] as const;
|
||||||
|
|
||||||
@@ -22,13 +24,13 @@ export const MediaSourceType: MediaSourceMap = {
|
|||||||
export const MediaSource = sqliteTable(
|
export const MediaSource = sqliteTable(
|
||||||
'media_source',
|
'media_source',
|
||||||
{
|
{
|
||||||
uuid: text().primaryKey(),
|
uuid: text().primaryKey().$type<MediaSourceId>(),
|
||||||
createdAt: integer(),
|
createdAt: integer(),
|
||||||
updatedAt: integer(),
|
updatedAt: integer(),
|
||||||
accessToken: text().notNull(),
|
accessToken: text().notNull(),
|
||||||
clientIdentifier: text(),
|
clientIdentifier: text(),
|
||||||
index: integer().notNull(),
|
index: integer().notNull(),
|
||||||
name: text().notNull(),
|
name: text().notNull().$type<MediaSourceName>(),
|
||||||
sendChannelUpdates: integer({ mode: 'boolean' }).default(false),
|
sendChannelUpdates: integer({ mode: 'boolean' }).default(false),
|
||||||
sendGuideUpdates: integer({ mode: 'boolean' }).default(false),
|
sendGuideUpdates: integer({ mode: 'boolean' }).default(false),
|
||||||
type: text({ enum: MediaSourceTypes }).notNull(),
|
type: text({ enum: MediaSourceTypes }).notNull(),
|
||||||
@@ -63,20 +65,63 @@ export const MediaSourceFields: (keyof MediaSourceTable)[] = [
|
|||||||
export type MediaSourceTable = KyselifyBetter<typeof MediaSource>;
|
export type MediaSourceTable = KyselifyBetter<typeof MediaSource>;
|
||||||
export type MediaSource = Selectable<MediaSourceTable>;
|
export type MediaSource = Selectable<MediaSourceTable>;
|
||||||
export type NewMediaSource = Insertable<MediaSourceTable>;
|
export type NewMediaSource = Insertable<MediaSourceTable>;
|
||||||
|
export type MediaSourceUpdate = Updateable<MediaSourceTable>;
|
||||||
|
|
||||||
export type SpecificMediaSourceType<Typ extends MediaSourceType> = StrictOmit<
|
export const MediaLibraryTypes = [
|
||||||
MediaSource,
|
'movies',
|
||||||
'type'
|
'shows',
|
||||||
> & {
|
'music_videos',
|
||||||
type: Typ;
|
'other_videos',
|
||||||
};
|
'tracks',
|
||||||
|
] as const;
|
||||||
|
|
||||||
export type PlexMediaSource = SpecificMediaSourceType<
|
export type MediaLibraryType = TupleToUnion<typeof MediaLibraryTypes>;
|
||||||
typeof MediaSourceType.Plex
|
|
||||||
>;
|
export const MediaSourceLibrary = sqliteTable(
|
||||||
export type JellyfinMediaSource = SpecificMediaSourceType<
|
'media_source_library',
|
||||||
typeof MediaSourceType.Jellyfin
|
{
|
||||||
>;
|
uuid: text().primaryKey().notNull(),
|
||||||
export type EmbyMediaSource = SpecificMediaSourceType<
|
name: text().notNull(),
|
||||||
typeof MediaSourceType.Emby
|
mediaType: text({ enum: MediaLibraryTypes }).notNull(),
|
||||||
>;
|
mediaSourceId: text()
|
||||||
|
.references(() => MediaSource.uuid, { onDelete: 'cascade' })
|
||||||
|
.notNull()
|
||||||
|
.$type<MediaSourceId>(),
|
||||||
|
lastScannedAt: integer({ mode: 'timestamp_ms' }),
|
||||||
|
externalKey: text().notNull(),
|
||||||
|
enabled: integer({ mode: 'boolean' }).default(false).notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
check(
|
||||||
|
'media_type_check',
|
||||||
|
inArray(table.mediaType, table.mediaType.enumValues).inlineParams(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const MediaSourceLibraryColumns: (keyof MediaSourceLibraryTable)[] = [
|
||||||
|
'enabled',
|
||||||
|
'externalKey',
|
||||||
|
'lastScannedAt',
|
||||||
|
'mediaSourceId',
|
||||||
|
'mediaType',
|
||||||
|
'uuid',
|
||||||
|
'name',
|
||||||
|
];
|
||||||
|
|
||||||
|
export type MediaSourceLibraryTable = KyselifyBetter<typeof MediaSourceLibrary>;
|
||||||
|
export type MediaSourceLibrary = Selectable<MediaSourceLibraryTable>;
|
||||||
|
export type NewMediaSourceLibrary = Insertable<MediaSourceLibraryTable>;
|
||||||
|
export type MediaSourceLibraryUpdate = Updateable<MediaSourceLibraryTable>;
|
||||||
|
|
||||||
|
export const MediaSourceLibraryReplacePath = sqliteTable(
|
||||||
|
'media_source_library_replace_path',
|
||||||
|
{
|
||||||
|
uuid: text().primaryKey().notNull(),
|
||||||
|
serverPath: text().notNull(),
|
||||||
|
localPath: text().notNull(),
|
||||||
|
mediaSourceId: text()
|
||||||
|
.notNull()
|
||||||
|
.references(() => MediaSource.uuid, { onDelete: 'cascade' }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { createExternalId } from '@tunarr/shared';
|
|
||||||
import type { TupleToUnion } from '@tunarr/types';
|
import type { TupleToUnion } from '@tunarr/types';
|
||||||
import { inArray } from 'drizzle-orm';
|
import { inArray } from 'drizzle-orm';
|
||||||
import {
|
import {
|
||||||
@@ -12,8 +11,14 @@ import {
|
|||||||
import type { Insertable, Selectable, Updateable } from 'kysely';
|
import type { Insertable, Selectable, Updateable } from 'kysely';
|
||||||
import type { MarkNotNilable } from '../../types/util.ts';
|
import type { MarkNotNilable } from '../../types/util.ts';
|
||||||
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
||||||
import { MediaSource, MediaSourceTypes } from './MediaSource.ts';
|
import {
|
||||||
|
MediaSource,
|
||||||
|
MediaSourceLibrary,
|
||||||
|
MediaSourceTypes,
|
||||||
|
} from './MediaSource.ts';
|
||||||
import { ProgramGrouping } from './ProgramGrouping.ts';
|
import { ProgramGrouping } from './ProgramGrouping.ts';
|
||||||
|
import type { MediaSourceName } from './base.ts';
|
||||||
|
import { type MediaSourceId } from './base.ts';
|
||||||
|
|
||||||
export const ProgramTypes = [
|
export const ProgramTypes = [
|
||||||
'movie',
|
'movie',
|
||||||
@@ -41,14 +46,18 @@ export const Program = sqliteTable(
|
|||||||
albumUuid: text().references(() => ProgramGrouping.uuid),
|
albumUuid: text().references(() => ProgramGrouping.uuid),
|
||||||
artistName: text(),
|
artistName: text(),
|
||||||
artistUuid: text().references(() => ProgramGrouping.uuid),
|
artistUuid: text().references(() => ProgramGrouping.uuid),
|
||||||
|
canonicalId: text(),
|
||||||
duration: integer().notNull(),
|
duration: integer().notNull(),
|
||||||
episode: integer(),
|
episode: integer(),
|
||||||
episodeIcon: text(),
|
episodeIcon: text(),
|
||||||
externalKey: text().notNull(),
|
externalKey: text().notNull(),
|
||||||
externalSourceId: text().notNull(),
|
externalSourceId: text().notNull().$type<MediaSourceName>(),
|
||||||
mediaSourceId: text().references(() => MediaSource.uuid, {
|
mediaSourceId: text()
|
||||||
onDelete: 'cascade',
|
.references(() => MediaSource.uuid, {
|
||||||
}),
|
onDelete: 'cascade',
|
||||||
|
})
|
||||||
|
.$type<MediaSourceId>(),
|
||||||
|
libraryId: text().references(() => MediaSourceLibrary.uuid),
|
||||||
filePath: text(),
|
filePath: text(),
|
||||||
grandparentExternalKey: text(),
|
grandparentExternalKey: text(),
|
||||||
icon: text(),
|
icon: text(),
|
||||||
@@ -77,6 +86,11 @@ export const Program = sqliteTable(
|
|||||||
uniqueIndex(
|
uniqueIndex(
|
||||||
'program_source_type_external_source_id_external_key_unique',
|
'program_source_type_external_source_id_external_key_unique',
|
||||||
).on(table.sourceType, table.externalSourceId, table.externalKey),
|
).on(table.sourceType, table.externalSourceId, table.externalKey),
|
||||||
|
uniqueIndex('program_source_type_media_source_external_key_unique').on(
|
||||||
|
table.sourceType,
|
||||||
|
table.mediaSourceId,
|
||||||
|
table.externalKey,
|
||||||
|
),
|
||||||
check(
|
check(
|
||||||
'program_type_check',
|
'program_type_check',
|
||||||
inArray(table.type, table.type.enumValues).inlineParams(),
|
inArray(table.type, table.type.enumValues).inlineParams(),
|
||||||
@@ -85,17 +99,15 @@ export const Program = sqliteTable(
|
|||||||
'program_source_type_check',
|
'program_source_type_check',
|
||||||
inArray(table.sourceType, table.sourceType.enumValues).inlineParams(),
|
inArray(table.sourceType, table.sourceType.enumValues).inlineParams(),
|
||||||
),
|
),
|
||||||
|
index('program_canonical_id_index').on(table.canonicalId),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export type ProgramTable = KyselifyBetter<typeof Program>;
|
export type ProgramTable = KyselifyBetter<typeof Program>;
|
||||||
export type ProgramDao = Selectable<ProgramTable>;
|
export type ProgramDao = Selectable<ProgramTable>;
|
||||||
|
// Make canonicalId required on insert.
|
||||||
export type NewProgramDao = MarkNotNilable<
|
export type NewProgramDao = MarkNotNilable<
|
||||||
Insertable<ProgramTable>,
|
Insertable<ProgramTable>,
|
||||||
'mediaSourceId'
|
'canonicalId' | 'mediaSourceId'
|
||||||
>;
|
>;
|
||||||
export type ProgramDaoUpdate = Updateable<ProgramTable>;
|
export type ProgramDaoUpdate = Updateable<ProgramTable>;
|
||||||
|
|
||||||
export function programExternalIdString(p: ProgramDao | NewProgramDao) {
|
|
||||||
return createExternalId(p.sourceType, p.externalSourceId, p.externalKey);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { Insertable, Selectable } from 'kysely';
|
|||||||
import { omit } from 'lodash-es';
|
import { omit } from 'lodash-es';
|
||||||
import type { MarkRequired, StrictOmit } from 'ts-essentials';
|
import type { MarkRequired, StrictOmit } from 'ts-essentials';
|
||||||
import type { MarkNotNilable } from '../../types/util.ts';
|
import type { MarkNotNilable } from '../../types/util.ts';
|
||||||
|
import type { MediaSourceId, MediaSourceName } from './base.ts';
|
||||||
import { ProgramExternalIdSourceTypes } from './base.ts';
|
import { ProgramExternalIdSourceTypes } from './base.ts';
|
||||||
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
||||||
import { MediaSource } from './MediaSource.ts';
|
import { MediaSource } from './MediaSource.ts';
|
||||||
@@ -25,10 +26,12 @@ export const ProgramExternalId = sqliteTable(
|
|||||||
directFilePath: text(),
|
directFilePath: text(),
|
||||||
externalFilePath: text(),
|
externalFilePath: text(),
|
||||||
externalKey: text().notNull(),
|
externalKey: text().notNull(),
|
||||||
externalSourceId: text(),
|
externalSourceId: text().$type<MediaSourceName>(),
|
||||||
mediaSourceId: text().references(() => MediaSource.uuid, {
|
mediaSourceId: text()
|
||||||
onDelete: 'cascade',
|
.references(() => MediaSource.uuid, {
|
||||||
}),
|
onDelete: 'cascade',
|
||||||
|
})
|
||||||
|
.$type<MediaSourceId>(),
|
||||||
programUuid: text()
|
programUuid: text()
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => Program.uuid, { onDelete: 'cascade' }),
|
.references(() => Program.uuid, { onDelete: 'cascade' }),
|
||||||
@@ -94,6 +97,7 @@ export const ProgramExternalIdKeys: (keyof ProgramExternalId)[] = [
|
|||||||
'externalSourceId',
|
'externalSourceId',
|
||||||
'programUuid',
|
'programUuid',
|
||||||
'sourceType',
|
'sourceType',
|
||||||
|
'mediaSourceId',
|
||||||
// 'updatedAt',
|
// 'updatedAt',
|
||||||
'uuid',
|
'uuid',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import {
|
|||||||
text,
|
text,
|
||||||
} from 'drizzle-orm/sqlite-core';
|
} from 'drizzle-orm/sqlite-core';
|
||||||
import type { Insertable, Selectable, Updateable } from 'kysely';
|
import type { Insertable, Selectable, Updateable } from 'kysely';
|
||||||
|
import type { MarkRequiredNotNull } from '../../types/util.ts';
|
||||||
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
||||||
|
import { MediaSourceLibrary } from './MediaSource.ts';
|
||||||
|
import type { ProgramGroupingTable as RawProgramGrouping } from './ProgramGrouping.ts';
|
||||||
|
|
||||||
export const ProgramGroupingType: Readonly<
|
export const ProgramGroupingType = {
|
||||||
Record<Capitalize<ProgramGroupingType>, ProgramGroupingType>
|
|
||||||
> = {
|
|
||||||
Show: 'show',
|
Show: 'show',
|
||||||
Season: 'season',
|
Season: 'season',
|
||||||
Artist: 'artist',
|
Artist: 'artist',
|
||||||
@@ -33,16 +34,18 @@ export const ProgramGrouping = sqliteTable(
|
|||||||
'program_grouping',
|
'program_grouping',
|
||||||
{
|
{
|
||||||
uuid: text().primaryKey(),
|
uuid: text().primaryKey(),
|
||||||
|
canonicalId: text(),
|
||||||
createdAt: integer(),
|
createdAt: integer(),
|
||||||
updatedAt: integer(),
|
updatedAt: integer(),
|
||||||
artistUuid: text().references((): AnySQLiteColumn => ProgramGrouping.uuid),
|
|
||||||
icon: text(),
|
icon: text(),
|
||||||
index: integer(),
|
index: integer(),
|
||||||
showUuid: text().references((): AnySQLiteColumn => ProgramGrouping.uuid),
|
|
||||||
summary: text(),
|
summary: text(),
|
||||||
title: text().notNull(),
|
title: text().notNull(),
|
||||||
type: text({ enum: ProgramGroupingTypes }).notNull(),
|
type: text({ enum: ProgramGroupingTypes }).notNull(),
|
||||||
year: integer(),
|
year: integer(),
|
||||||
|
artistUuid: text().references((): AnySQLiteColumn => ProgramGrouping.uuid),
|
||||||
|
showUuid: text().references((): AnySQLiteColumn => ProgramGrouping.uuid),
|
||||||
|
libraryId: text().references(() => MediaSourceLibrary.uuid),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index('program_grouping_show_uuid_index').on(table.showUuid),
|
index('program_grouping_show_uuid_index').on(table.showUuid),
|
||||||
@@ -56,5 +59,40 @@ export const ProgramGrouping = sqliteTable(
|
|||||||
|
|
||||||
export type ProgramGroupingTable = KyselifyBetter<typeof ProgramGrouping>;
|
export type ProgramGroupingTable = KyselifyBetter<typeof ProgramGrouping>;
|
||||||
export type ProgramGrouping = Selectable<ProgramGroupingTable>;
|
export type ProgramGrouping = Selectable<ProgramGroupingTable>;
|
||||||
export type NewProgramGrouping = Insertable<ProgramGroupingTable>;
|
export type NewProgramGrouping = MarkRequiredNotNull<
|
||||||
|
Insertable<ProgramGroupingTable>,
|
||||||
|
'canonicalId' | 'libraryId'
|
||||||
|
>;
|
||||||
export type ProgramGroupingUpdate = Updateable<ProgramGroupingTable>;
|
export type ProgramGroupingUpdate = Updateable<ProgramGroupingTable>;
|
||||||
|
|
||||||
|
const ProgramGroupingKeys: (keyof RawProgramGrouping)[] = [
|
||||||
|
'artistUuid',
|
||||||
|
'createdAt',
|
||||||
|
'icon',
|
||||||
|
'index',
|
||||||
|
'showUuid',
|
||||||
|
'summary',
|
||||||
|
'title',
|
||||||
|
'type',
|
||||||
|
'updatedAt',
|
||||||
|
'uuid',
|
||||||
|
'year',
|
||||||
|
];
|
||||||
|
// TODO move this definition to the ProgramGrouping DAO file
|
||||||
|
|
||||||
|
export const AllProgramGroupingFields: ProgramGroupingFields =
|
||||||
|
ProgramGroupingKeys.map((key) => `programGrouping.${key}` as const);
|
||||||
|
|
||||||
|
export const AllProgramGroupingFieldsAliased = <Alias extends string>(
|
||||||
|
alias: Alias,
|
||||||
|
): ProgramGroupingFields<Alias> =>
|
||||||
|
ProgramGroupingKeys.map((key) => `${alias}.${key}` as const);
|
||||||
|
|
||||||
|
export const MinimalProgramGroupingFields: ProgramGroupingFields = [
|
||||||
|
'programGrouping.uuid',
|
||||||
|
'programGrouping.title',
|
||||||
|
'programGrouping.year',
|
||||||
|
// 'programGrouping.index',
|
||||||
|
];
|
||||||
|
export type ProgramGroupingFields<Alias extends string = 'programGrouping'> =
|
||||||
|
readonly `${Alias}.${keyof RawProgramGrouping}`[];
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import type { Insertable, Selectable } from 'kysely';
|
|||||||
import { omit } from 'lodash-es';
|
import { omit } from 'lodash-es';
|
||||||
import type { StrictOmit } from 'ts-essentials';
|
import type { StrictOmit } from 'ts-essentials';
|
||||||
import type { MarkNotNilable } from '../../types/util.ts';
|
import type { MarkNotNilable } from '../../types/util.ts';
|
||||||
|
import type { MediaSourceId, MediaSourceName } from './base.ts';
|
||||||
import { ProgramExternalIdSourceTypes } from './base.ts';
|
import { ProgramExternalIdSourceTypes } from './base.ts';
|
||||||
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
||||||
import { MediaSource } from './MediaSource.ts';
|
import { MediaSource, MediaSourceLibrary } from './MediaSource.ts';
|
||||||
import { ProgramGrouping } from './ProgramGrouping.ts';
|
import { ProgramGrouping } from './ProgramGrouping.ts';
|
||||||
|
|
||||||
export const ProgramGroupingExternalId = sqliteTable(
|
export const ProgramGroupingExternalId = sqliteTable(
|
||||||
@@ -23,10 +24,12 @@ export const ProgramGroupingExternalId = sqliteTable(
|
|||||||
updatedAt: integer(),
|
updatedAt: integer(),
|
||||||
externalFilePath: text(),
|
externalFilePath: text(),
|
||||||
externalKey: text().notNull(),
|
externalKey: text().notNull(),
|
||||||
externalSourceId: text(),
|
externalSourceId: text().$type<MediaSourceName>(),
|
||||||
mediaSourceId: text().references(() => MediaSource.uuid, {
|
mediaSourceId: text()
|
||||||
onDelete: 'cascade',
|
.references(() => MediaSource.uuid, {
|
||||||
}),
|
onDelete: 'cascade',
|
||||||
|
})
|
||||||
|
.$type<MediaSourceId>(),
|
||||||
groupUuid: text()
|
groupUuid: text()
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => ProgramGrouping.uuid, {
|
.references(() => ProgramGrouping.uuid, {
|
||||||
@@ -34,6 +37,9 @@ export const ProgramGroupingExternalId = sqliteTable(
|
|||||||
onUpdate: 'cascade',
|
onUpdate: 'cascade',
|
||||||
}),
|
}),
|
||||||
sourceType: text({ enum: ProgramExternalIdSourceTypes }).notNull(),
|
sourceType: text({ enum: ProgramExternalIdSourceTypes }).notNull(),
|
||||||
|
libraryId: text().references(() => MediaSourceLibrary.uuid, {
|
||||||
|
onDelete: 'cascade',
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index('program_grouping_group_uuid_index').on(table.groupUuid),
|
index('program_grouping_group_uuid_index').on(table.groupUuid),
|
||||||
@@ -63,7 +69,7 @@ export type NewSingleOrMultiProgramGroupingExternalId =
|
|||||||
> & { type: 'multi' });
|
> & { type: 'multi' });
|
||||||
|
|
||||||
export function toInsertableProgramGroupingExternalId(
|
export function toInsertableProgramGroupingExternalId(
|
||||||
eid: NewSingleOrMultiProgramGroupingExternalId,
|
eid: NewProgramGroupingExternalId | NewSingleOrMultiProgramGroupingExternalId,
|
||||||
): NewProgramGroupingExternalId {
|
): NewProgramGroupingExternalId {
|
||||||
return omit(eid, 'type') satisfies NewProgramGroupingExternalId;
|
return omit(eid, 'type') satisfies NewProgramGroupingExternalId;
|
||||||
}
|
}
|
||||||
@@ -74,13 +80,13 @@ export type ProgramGroupingExternalIdFields<
|
|||||||
|
|
||||||
export const ProgramGroupingExternalIdKeys: (keyof ProgramGroupingExternalId)[] =
|
export const ProgramGroupingExternalIdKeys: (keyof ProgramGroupingExternalId)[] =
|
||||||
[
|
[
|
||||||
// 'createdAt',
|
'createdAt',
|
||||||
'externalFilePath',
|
'externalFilePath',
|
||||||
'externalKey',
|
'externalKey',
|
||||||
'externalSourceId',
|
'externalSourceId',
|
||||||
'sourceType',
|
'sourceType',
|
||||||
'groupUuid',
|
'groupUuid',
|
||||||
// 'updatedAt',
|
'updatedAt',
|
||||||
'uuid',
|
'uuid',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type TupleToUnion } from '@tunarr/types';
|
import { type Tag, type TupleToUnion } from '@tunarr/types';
|
||||||
import {
|
import {
|
||||||
ContentProgramTypeSchema,
|
ContentProgramTypeSchema,
|
||||||
ResolutionSchema,
|
ResolutionSchema,
|
||||||
@@ -120,3 +120,6 @@ export const ChannelOfflineSettingsSchema = z.object({
|
|||||||
export type ChannelOfflineSettings = z.infer<
|
export type ChannelOfflineSettings = z.infer<
|
||||||
typeof ChannelOfflineSettingsSchema
|
typeof ChannelOfflineSettingsSchema
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type MediaSourceId = Tag<string, 'mediaSourceId'>;
|
||||||
|
export type MediaSourceName = Tag<string, 'mediaSourceName'>;
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import type {
|
|||||||
} from './Channel.ts';
|
} from './Channel.ts';
|
||||||
import type { CustomShowContentTable, CustomShowTable } from './CustomShow.js';
|
import type { CustomShowContentTable, CustomShowTable } from './CustomShow.js';
|
||||||
import type { FillerShowContentTable, FillerShowTable } from './FillerShow.js';
|
import type { FillerShowContentTable, FillerShowTable } from './FillerShow.js';
|
||||||
import type { MediaSourceTable } from './MediaSource.ts';
|
import type {
|
||||||
|
MediaSourceLibraryTable,
|
||||||
|
MediaSourceTable,
|
||||||
|
} from './MediaSource.ts';
|
||||||
import type { MikroOrmMigrationsTable } from './MikroOrmMigrations.js';
|
import type { MikroOrmMigrationsTable } from './MikroOrmMigrations.js';
|
||||||
import type { ProgramTable } from './Program.ts';
|
import type { ProgramTable } from './Program.ts';
|
||||||
import type { ProgramExternalIdTable } from './ProgramExternalId.ts';
|
import type { ProgramExternalIdTable } from './ProgramExternalId.ts';
|
||||||
@@ -34,6 +37,7 @@ export interface DB {
|
|||||||
fillerShow: FillerShowTable;
|
fillerShow: FillerShowTable;
|
||||||
fillerShowContent: FillerShowContentTable;
|
fillerShowContent: FillerShowContentTable;
|
||||||
mediaSource: MediaSourceTable;
|
mediaSource: MediaSourceTable;
|
||||||
|
mediaSourceLibrary: MediaSourceLibraryTable;
|
||||||
program: ProgramTable;
|
program: ProgramTable;
|
||||||
programExternalId: ProgramExternalIdTable;
|
programExternalId: ProgramExternalIdTable;
|
||||||
programGrouping: ProgramGroupingTable;
|
programGrouping: ProgramGroupingTable;
|
||||||
|
|||||||
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 { DeepNullable, MarkRequired, StrictOmit } from 'ts-essentials';
|
||||||
import type { Channel, ChannelFillerShow } from './Channel.ts';
|
import type { Channel, ChannelFillerShow } from './Channel.ts';
|
||||||
import type { FillerShow } from './FillerShow.ts';
|
import type { FillerShow } from './FillerShow.ts';
|
||||||
import type { ProgramDao } from './Program.ts';
|
import type {
|
||||||
import type { MinimalProgramExternalId } from './ProgramExternalId.ts';
|
MediaSource,
|
||||||
import type { ProgramGrouping } from './ProgramGrouping.ts';
|
MediaSourceLibrary,
|
||||||
import type { ProgramGroupingExternalId } from './ProgramGroupingExternalId.ts';
|
MediaSourceType,
|
||||||
|
} from './MediaSource.ts';
|
||||||
|
import type { NewProgramDao, ProgramDao, ProgramType } from './Program.ts';
|
||||||
|
import type {
|
||||||
|
MinimalProgramExternalId,
|
||||||
|
NewSingleOrMultiExternalId,
|
||||||
|
} from './ProgramExternalId.ts';
|
||||||
|
import type {
|
||||||
|
NewProgramGrouping,
|
||||||
|
ProgramGrouping,
|
||||||
|
ProgramGroupingType,
|
||||||
|
} from './ProgramGrouping.ts';
|
||||||
|
import type {
|
||||||
|
NewSingleOrMultiProgramGroupingExternalId,
|
||||||
|
ProgramGroupingExternalId,
|
||||||
|
} from './ProgramGroupingExternalId.ts';
|
||||||
import type { ChannelSubtitlePreferences } from './SubtitlePreferences.ts';
|
import type { ChannelSubtitlePreferences } from './SubtitlePreferences.ts';
|
||||||
|
|
||||||
export type ProgramWithRelations = ProgramDao & {
|
export type ProgramWithRelations = ProgramDao & {
|
||||||
@@ -18,6 +33,39 @@ export type ProgramWithRelations = ProgramDao & {
|
|||||||
externalIds?: MinimalProgramExternalId[];
|
externalIds?: MinimalProgramExternalId[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SpecificProgramGroupingType<
|
||||||
|
Typ extends ProgramGroupingType,
|
||||||
|
ProgramGroupingT = ProgramGrouping,
|
||||||
|
> = StrictOmit<ProgramGroupingT, 'type'> & { type: Typ };
|
||||||
|
|
||||||
|
export type SpecificProgramType<
|
||||||
|
Typ extends ProgramType,
|
||||||
|
ProgramT extends { type: ProgramType } = ProgramDao,
|
||||||
|
> = StrictOmit<ProgramT, 'type'> & { type: Typ };
|
||||||
|
|
||||||
|
export type MovieProgram = SpecificProgramType<'movie'> & {
|
||||||
|
externalIds: MinimalProgramExternalId[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TvSeason = SpecificProgramGroupingType<'season'> & {
|
||||||
|
externalIds: ProgramGroupingExternalId[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TvShow = SpecificProgramGroupingType<'show'> & {
|
||||||
|
externalIds: ProgramGroupingExternalId[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EpisodeProgram = SpecificProgramType<'episode'> & {
|
||||||
|
tvSeason: TvSeason;
|
||||||
|
tvShow: TvShow;
|
||||||
|
externalIds: MinimalProgramExternalId[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EpisodeProgramWithRelations = EpisodeProgram & {
|
||||||
|
tvShow: ProgramGroupingWithExternalIds;
|
||||||
|
tvSeason: ProgramGroupingWithExternalIds;
|
||||||
|
};
|
||||||
|
|
||||||
export type ChannelWithRelations = Channel & {
|
export type ChannelWithRelations = Channel & {
|
||||||
programs?: ProgramWithRelations[];
|
programs?: ProgramWithRelations[];
|
||||||
fillerContent?: ProgramWithRelations[];
|
fillerContent?: ProgramWithRelations[];
|
||||||
@@ -58,6 +106,21 @@ export type ProgramWithExternalIds = ProgramDao & {
|
|||||||
externalIds: MinimalProgramExternalId[];
|
externalIds: MinimalProgramExternalId[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NewProgramWithExternalIds = NewProgramDao & {
|
||||||
|
externalIds: NewSingleOrMultiExternalId[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NewMovieProgram = SpecificProgramType<'movie', NewProgramDao> & {
|
||||||
|
externalIds: NewSingleOrMultiExternalId[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NewEpisodeProgram = SpecificProgramType<
|
||||||
|
'episode',
|
||||||
|
NewProgramDao
|
||||||
|
> & {
|
||||||
|
externalIds: NewSingleOrMultiExternalId[];
|
||||||
|
};
|
||||||
|
|
||||||
export type ProgramGroupingWithExternalIds = ProgramGrouping & {
|
export type ProgramGroupingWithExternalIds = ProgramGrouping & {
|
||||||
externalIds: ProgramGroupingExternalId[];
|
externalIds: ProgramGroupingExternalId[];
|
||||||
};
|
};
|
||||||
@@ -96,3 +159,55 @@ export type GeneralizedProgramGroupingWithExternalIds =
|
|||||||
| TvSeasonWithExternalIds
|
| TvSeasonWithExternalIds
|
||||||
| MusicAlbumWithExternalIds
|
| MusicAlbumWithExternalIds
|
||||||
| MusicArtistWithExternalIds;
|
| MusicArtistWithExternalIds;
|
||||||
|
|
||||||
|
type WithNewGroupingExternalIds = {
|
||||||
|
externalIds: NewSingleOrMultiProgramGroupingExternalId[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NewProgramGroupingWithExternalIds = NewProgramGrouping &
|
||||||
|
WithNewGroupingExternalIds;
|
||||||
|
|
||||||
|
export type NewTvShow = SpecificProgramGroupingType<
|
||||||
|
'show',
|
||||||
|
NewProgramGrouping
|
||||||
|
> &
|
||||||
|
WithNewGroupingExternalIds;
|
||||||
|
export type NewTvSeason = SpecificProgramGroupingType<
|
||||||
|
'season',
|
||||||
|
NewProgramGrouping
|
||||||
|
> &
|
||||||
|
WithNewGroupingExternalIds;
|
||||||
|
|
||||||
|
export type NewMusicArtist = SpecificProgramGroupingType<
|
||||||
|
'artist',
|
||||||
|
NewProgramGrouping
|
||||||
|
> &
|
||||||
|
WithNewGroupingExternalIds;
|
||||||
|
export type NewMusicAlbum = SpecificProgramGroupingType<
|
||||||
|
'album',
|
||||||
|
NewProgramGrouping
|
||||||
|
> &
|
||||||
|
WithNewGroupingExternalIds;
|
||||||
|
export type NewMusicTrack = SpecificProgramType<'track', NewProgramDao> & {
|
||||||
|
externalIds: NewSingleOrMultiExternalId[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MediaSourceWithLibraries = MediaSource & {
|
||||||
|
libraries: MediaSourceLibrary[];
|
||||||
|
};
|
||||||
|
export type SpecificMediaSourceType<Typ extends MediaSourceType> = StrictOmit<
|
||||||
|
MediaSourceWithLibraries,
|
||||||
|
'type'
|
||||||
|
> & {
|
||||||
|
type: Typ;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlexMediaSource = SpecificMediaSourceType<
|
||||||
|
typeof MediaSourceType.Plex
|
||||||
|
>;
|
||||||
|
export type JellyfinMediaSource = SpecificMediaSourceType<
|
||||||
|
typeof MediaSourceType.Jellyfin
|
||||||
|
>;
|
||||||
|
export type EmbyMediaSource = SpecificMediaSourceType<
|
||||||
|
typeof MediaSourceType.Emby
|
||||||
|
>;
|
||||||
|
|||||||
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 { isDefined, isNodeError } from '@/util/index.js';
|
||||||
import type { Logger } from '@/util/logging/LoggerFactory.js';
|
import type { Logger } from '@/util/logging/LoggerFactory.js';
|
||||||
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
|
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
|
||||||
|
import { type TupleToUnion } from '@tunarr/types';
|
||||||
import type { MediaSourceUnhealthyStatus } from '@tunarr/types/api';
|
import type { MediaSourceUnhealthyStatus } from '@tunarr/types/api';
|
||||||
import type {
|
import type {
|
||||||
AxiosHeaderValue,
|
AxiosHeaderValue,
|
||||||
@@ -11,54 +12,75 @@ import type {
|
|||||||
AxiosRequestConfig,
|
AxiosRequestConfig,
|
||||||
} from 'axios';
|
} from 'axios';
|
||||||
import axios, { isAxiosError } from 'axios';
|
import axios, { isAxiosError } from 'axios';
|
||||||
import { isError, isString } from 'lodash-es';
|
import type { Duration } from 'dayjs/plugin/duration.js';
|
||||||
|
import { has, isError, isString } from 'lodash-es';
|
||||||
|
import PQueue from 'p-queue';
|
||||||
|
import type { StrictOmit } from 'ts-essentials';
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
|
import type { MediaSourceWithLibraries } from '../db/schema/derivedTypes.js';
|
||||||
|
import { WrappedError } from '../types/errors.ts';
|
||||||
|
import { Result } from '../types/result.ts';
|
||||||
|
|
||||||
export type ApiClientOptions = {
|
export type ApiClientOptions = {
|
||||||
name: string;
|
mediaSource: StrictOmit<
|
||||||
mediaSourceUuid?: string;
|
MediaSourceWithLibraries,
|
||||||
accessToken: string;
|
| 'createdAt'
|
||||||
url: string;
|
| 'updatedAt'
|
||||||
userId: string | null;
|
| 'clientIdentifier'
|
||||||
username: string | null;
|
| 'index'
|
||||||
|
| 'sendChannelUpdates'
|
||||||
|
| 'sendGuideUpdates'
|
||||||
|
>;
|
||||||
extraHeaders?: {
|
extraHeaders?: {
|
||||||
[key: string]: AxiosHeaderValue;
|
[key: string]: AxiosHeaderValue;
|
||||||
};
|
};
|
||||||
enableRequestCache?: boolean;
|
enableRequestCache?: boolean;
|
||||||
|
queueOpts?: {
|
||||||
|
concurrency: number;
|
||||||
|
interval: Duration;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type QuerySuccessResult<T> = {
|
export type RemoteMediaSourceOptions = ApiClientOptions & {
|
||||||
type: 'success';
|
apiKey: string;
|
||||||
data: T;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type QueryErrorCode =
|
const QueryErrorCodes = [
|
||||||
| 'not_found'
|
'not_found',
|
||||||
| 'no_access_token'
|
'no_access_token',
|
||||||
| 'parse_error'
|
'parse_error',
|
||||||
| 'generic_request_error';
|
'generic_request_error',
|
||||||
|
] as const;
|
||||||
|
type QueryErrorCode = TupleToUnion<typeof QueryErrorCodes>;
|
||||||
|
|
||||||
export type QueryErrorResult = {
|
export abstract class QueryError extends WrappedError {
|
||||||
type: 'error';
|
readonly type: QueryErrorCode;
|
||||||
code: QueryErrorCode;
|
|
||||||
message?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type QueryResult<T> = QuerySuccessResult<T> | QueryErrorResult;
|
static isQueryError(e: unknown): e is QueryError {
|
||||||
|
return (
|
||||||
|
has(e, 'type') &&
|
||||||
|
isString(e.type) &&
|
||||||
|
QueryErrorCodes.some((x) => x === e.type)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function isQueryError(x: QueryResult<unknown>): x is QueryErrorResult {
|
static genericQueryError(message?: string): QueryError {
|
||||||
return x.type === 'error';
|
return this.create('generic_request_error', message);
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(type: QueryErrorCode, message?: string): QueryError {
|
||||||
|
return new (class extends QueryError {
|
||||||
|
type = type;
|
||||||
|
})(message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isQuerySuccess<T>(
|
export type QueryResult<T> = Result<T, QueryError>;
|
||||||
x: QueryResult<T>,
|
|
||||||
): x is QuerySuccessResult<T> {
|
|
||||||
return x.type === 'success';
|
|
||||||
}
|
|
||||||
|
|
||||||
export abstract class BaseApiClient<
|
export abstract class BaseApiClient<
|
||||||
OptionsType extends ApiClientOptions = ApiClientOptions,
|
OptionsType extends ApiClientOptions = ApiClientOptions,
|
||||||
> {
|
> {
|
||||||
|
private queue?: PQueue;
|
||||||
protected logger: Logger;
|
protected logger: Logger;
|
||||||
protected axiosInstance: AxiosInstance;
|
protected axiosInstance: AxiosInstance;
|
||||||
protected redacter?: AxiosRequestRedacter;
|
protected redacter?: AxiosRequestRedacter;
|
||||||
@@ -66,12 +88,13 @@ export abstract class BaseApiClient<
|
|||||||
constructor(protected options: OptionsType) {
|
constructor(protected options: OptionsType) {
|
||||||
this.logger = LoggerFactory.child({
|
this.logger = LoggerFactory.child({
|
||||||
className: this.constructor.name,
|
className: this.constructor.name,
|
||||||
serverName: options.name,
|
serverName: options.mediaSource.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = options.url.endsWith('/')
|
const url = options.mediaSource.uri.endsWith('/')
|
||||||
? options.url.slice(0, options.url.length - 1)
|
? options.mediaSource.uri.slice(0, options.mediaSource.uri.length - 1)
|
||||||
: options.url;
|
: options.mediaSource.uri;
|
||||||
|
this.options.mediaSource.uri = url;
|
||||||
|
|
||||||
this.axiosInstance = axios.create({
|
this.axiosInstance = axios.create({
|
||||||
baseURL: url,
|
baseURL: url,
|
||||||
@@ -81,9 +104,20 @@ export abstract class BaseApiClient<
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (options.queueOpts) {
|
||||||
|
this.queue = new PQueue({
|
||||||
|
concurrency: options.queueOpts.concurrency,
|
||||||
|
interval: options.queueOpts.interval.asMilliseconds(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
configureAxiosLogging(this.axiosInstance, this.logger);
|
configureAxiosLogging(this.axiosInstance, this.logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setApiClientOptions(opts: OptionsType) {
|
||||||
|
this.options = opts;
|
||||||
|
}
|
||||||
|
|
||||||
async doTypeCheckedGet<T extends z.ZodType, Out = z.infer<T>>(
|
async doTypeCheckedGet<T extends z.ZodType, Out = z.infer<T>>(
|
||||||
path: string,
|
path: string,
|
||||||
schema: T,
|
schema: T,
|
||||||
@@ -120,28 +154,23 @@ export abstract class BaseApiClient<
|
|||||||
return this.makeErrorResult('parse_error');
|
return this.makeErrorResult('parse_error');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected preRequestValidate(
|
protected preRequestValidate<T>(
|
||||||
_req: AxiosRequestConfig,
|
_req: AxiosRequestConfig,
|
||||||
): Maybe<QueryErrorResult> {
|
): Maybe<QueryResult<T>> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected makeErrorResult(
|
protected makeErrorResult<T>(
|
||||||
code: QueryErrorCode,
|
type: QueryErrorCode,
|
||||||
message?: string,
|
message?: string,
|
||||||
): QueryErrorResult {
|
): QueryResult<T> {
|
||||||
return {
|
return Result.failure<T, QueryError>(
|
||||||
type: 'error',
|
QueryError.create(type, message ?? 'Unknown Error'),
|
||||||
code,
|
);
|
||||||
message,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected makeSuccessResult<T>(data: T): QuerySuccessResult<T> {
|
protected makeSuccessResult<T>(data: T): QueryResult<T> {
|
||||||
return {
|
return Result.success<T, QueryError>(data);
|
||||||
type: 'success',
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
doGet<T>(req: Omit<AxiosRequestConfig, 'method'>) {
|
doGet<T>(req: Omit<AxiosRequestConfig, 'method'>) {
|
||||||
@@ -162,13 +191,17 @@ export abstract class BaseApiClient<
|
|||||||
|
|
||||||
getFullUrl(path: string): string {
|
getFullUrl(path: string): string {
|
||||||
const sanitizedPath = path.startsWith('/') ? path : `/${path}`;
|
const sanitizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
const url = new URL(`${this.options.url}${sanitizedPath}`);
|
const url = new URL(`${this.options.mediaSource.uri}${sanitizedPath}`);
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async doRequest<T>(req: AxiosRequestConfig): Promise<T> {
|
protected async doRequest<T>(req: AxiosRequestConfig): Promise<T> {
|
||||||
try {
|
try {
|
||||||
const response = await this.axiosInstance.request<T>(req);
|
const response = await (this.queue
|
||||||
|
? this.queue.add(() => this.axiosInstance.request<T>(req), {
|
||||||
|
throwOnTimeout: true,
|
||||||
|
})
|
||||||
|
: this.axiosInstance.request<T>(req));
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isAxiosError(error)) {
|
if (isAxiosError(error)) {
|
||||||
@@ -185,7 +218,7 @@ export abstract class BaseApiClient<
|
|||||||
// The request was made and the server responded with a status code
|
// The request was made and the server responded with a status code
|
||||||
// that falls out of the range of 2xx
|
// that falls out of the range of 2xx
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
'API client response error: path: %O, status %d, params: %O, data: %O, headers: %O',
|
'API client response error: path: %s, status %d, params: %O, data: %O, headers: %O',
|
||||||
error.config?.url ?? '',
|
error.config?.url ?? '',
|
||||||
status,
|
status,
|
||||||
error.config?.params ?? {},
|
error.config?.params ?? {},
|
||||||
@@ -214,7 +247,7 @@ export abstract class BaseApiClient<
|
|||||||
// At this point we have no idea what the object is... attempt to log
|
// At this point we have no idea what the object is... attempt to log
|
||||||
// and just return a generic error. Something is probably fatally wrong
|
// and just return a generic error. Something is probably fatally wrong
|
||||||
// at this point.
|
// at this point.
|
||||||
this.logger.error('Unknown error type thrown: %O', error);
|
this.logger.error(error, 'Unknown error type thrown: %O');
|
||||||
throw new Error('Unknown error', { cause: error });
|
throw new Error('Unknown error', { cause: error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,4 +278,10 @@ export abstract class BaseApiClient<
|
|||||||
|
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected findMatchingLibrary(externalLibraryId: string) {
|
||||||
|
return this.options.mediaSource.libraries.find(
|
||||||
|
(lib) => lib.externalKey === externalLibraryId,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 { inject, injectable, LazyServiceIdentifier } from 'inversify';
|
||||||
import { forEach, isBoolean, isEmpty, isNil } from 'lodash-es';
|
import { forEach, isBoolean, isEmpty, isNil } from 'lodash-es';
|
||||||
import NodeCache from 'node-cache';
|
import NodeCache from 'node-cache';
|
||||||
import { MarkRequired } from 'ts-essentials';
|
|
||||||
import type { ISettingsDB } from '../db/interfaces/ISettingsDB.ts';
|
import type { ISettingsDB } from '../db/interfaces/ISettingsDB.ts';
|
||||||
|
import { MediaSourceId } from '../db/schema/base.ts';
|
||||||
|
import { MediaSourceWithLibraries } from '../db/schema/derivedTypes.js';
|
||||||
import { KEYS } from '../types/inject.ts';
|
import { KEYS } from '../types/inject.ts';
|
||||||
import { Result } from '../types/result.ts';
|
import { Result } from '../types/result.ts';
|
||||||
import { cacheGetOrSet } from '../util/cache.ts';
|
import { cacheGetOrSet } from '../util/cache.ts';
|
||||||
import { Logger } from '../util/logging/LoggerFactory.ts';
|
import { Logger } from '../util/logging/LoggerFactory.ts';
|
||||||
import {
|
import { type ApiClientOptions } from './BaseApiClient.js';
|
||||||
isQueryError,
|
|
||||||
type ApiClientOptions,
|
|
||||||
type BaseApiClient,
|
|
||||||
} from './BaseApiClient.js';
|
|
||||||
import { EmbyApiClient } from './emby/EmbyApiClient.ts';
|
import { EmbyApiClient } from './emby/EmbyApiClient.ts';
|
||||||
import { JellyfinApiClient } from './jellyfin/JellyfinApiClient.js';
|
import { JellyfinApiClient } from './jellyfin/JellyfinApiClient.js';
|
||||||
import { PlexApiClient } from './plex/PlexApiClient.js';
|
import { MediaSourceApiClientFactory } from './MediaSourceApiClient.ts';
|
||||||
|
import { PlexApiClient, PlexApiClientFactory } from './plex/PlexApiClient.js';
|
||||||
|
|
||||||
type TypeToClient = [
|
type TypeToClient = [
|
||||||
[typeof MediaSourceType.Plex, PlexApiClient],
|
[typeof MediaSourceType.Plex, PlexApiClient],
|
||||||
@@ -45,6 +43,12 @@ export class MediaSourceApiFactory {
|
|||||||
@inject(new LazyServiceIdentifier(() => MediaSourceDB))
|
@inject(new LazyServiceIdentifier(() => MediaSourceDB))
|
||||||
private mediaSourceDB: MediaSourceDB,
|
private mediaSourceDB: MediaSourceDB,
|
||||||
@inject(KEYS.SettingsDB) private settings: ISettingsDB,
|
@inject(KEYS.SettingsDB) private settings: ISettingsDB,
|
||||||
|
@inject(KEYS.PlexApiClientFactory)
|
||||||
|
private plexApiClientFactory: PlexApiClientFactory,
|
||||||
|
@inject(KEYS.JellyfinApiClientFactory)
|
||||||
|
private jellyfinApiClientFactory: MediaSourceApiClientFactory<JellyfinApiClient>,
|
||||||
|
@inject(KEYS.EmbyApiClientFactory)
|
||||||
|
private embyApiClientFactory: MediaSourceApiClientFactory<EmbyApiClient>,
|
||||||
) {
|
) {
|
||||||
this.#requestCacheEnabled =
|
this.#requestCacheEnabled =
|
||||||
settings.systemSettings().cache?.enablePlexRequestCache ?? false;
|
settings.systemSettings().cache?.enablePlexRequestCache ?? false;
|
||||||
@@ -60,88 +64,95 @@ export class MediaSourceApiFactory {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getJellyfinApiClientForMediaSource(mediaSource: MediaSource) {
|
getJellyfinApiClientForMediaSource(mediaSource: MediaSourceWithLibraries) {
|
||||||
return this.getJellyfinApiClient(mediaSourceToApiOptions(mediaSource));
|
return this.getJellyfinApiClient({ mediaSource });
|
||||||
}
|
}
|
||||||
|
|
||||||
getJellyfinApiClient(opts: ApiClientOptions) {
|
getJellyfinApiClient(opts: ApiClientOptions): Promise<JellyfinApiClient> {
|
||||||
return this.getTyped(MediaSourceType.Jellyfin, opts, (opts) => {
|
const client = this.jellyfinApiClientFactory(opts);
|
||||||
return Promise.resolve(new JellyfinApiClient(opts));
|
client.setApiClientOptions(opts);
|
||||||
});
|
return Promise.resolve(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
getEmbyApiClientForMediaSource(mediaSource: MediaSource) {
|
getEmbyApiClientForMediaSource(mediaSource: MediaSourceWithLibraries) {
|
||||||
return this.getEmbyApiClient(mediaSourceToApiOptions(mediaSource));
|
return this.getEmbyApiClient({ mediaSource });
|
||||||
}
|
}
|
||||||
|
|
||||||
getEmbyApiClient(opts: ApiClientOptions) {
|
async getEmbyApiClient(opts: ApiClientOptions) {
|
||||||
return this.getTyped(MediaSourceType.Jellyfin, opts, async (opts) => {
|
let userId = opts.mediaSource.userId;
|
||||||
let userId = opts.userId;
|
let username: Maybe<string>;
|
||||||
let username: Maybe<string>;
|
if (isEmpty(userId)) {
|
||||||
if (isEmpty(userId)) {
|
this.logger.warn(
|
||||||
this.logger.warn(
|
'Emby connection does not have a user ID set. This could lead to errors. Please reconnect Emby.',
|
||||||
'Emby connection does not have a user ID set. This could lead to errors. Please reconnect Emby.',
|
);
|
||||||
);
|
const adminResult = await Result.attemptAsync(() =>
|
||||||
const adminResult = await Result.attemptAsync(() =>
|
EmbyApiClient.findAdminUser(opts, opts.mediaSource.accessToken),
|
||||||
EmbyApiClient.findAdminUser(opts, opts.accessToken),
|
);
|
||||||
);
|
|
||||||
|
|
||||||
adminResult
|
adminResult
|
||||||
.filter((res) => isNonEmptyString(res?.Id))
|
.filter((res) => isNonEmptyString(res?.Id))
|
||||||
.forEach((adminUser) => {
|
.forEach((adminUser) => {
|
||||||
userId = adminUser!.Id!;
|
userId = adminUser!.Id!;
|
||||||
username = adminUser!.Name ?? undefined;
|
username = adminUser!.Name ?? undefined;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isNonEmptyString(opts.mediaSourceUuid) &&
|
isNonEmptyString(opts.mediaSource.uuid) &&
|
||||||
(isEmpty(opts.userId) ||
|
(isEmpty(opts.mediaSource.userId) ||
|
||||||
opts.userId !== userId ||
|
opts.mediaSource.userId !== userId ||
|
||||||
isEmpty(opts.username) ||
|
isEmpty(opts.mediaSource.username) ||
|
||||||
opts.username != username)
|
opts.mediaSource.username != username)
|
||||||
) {
|
) {
|
||||||
this.mediaSourceDB
|
this.mediaSourceDB
|
||||||
.setMediaSourceUserInfo(opts.mediaSourceUuid, {
|
.setMediaSourceUserInfo(opts.mediaSource.uuid, {
|
||||||
userId: userId ?? undefined,
|
userId: userId ?? undefined,
|
||||||
username,
|
username,
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
e,
|
e,
|
||||||
'Error updating Jellyfin media source user info',
|
'Error updating Jellyfin media source user info',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return new EmbyApiClient({ ...opts, userId });
|
return this.embyApiClientFactory({
|
||||||
|
...opts,
|
||||||
|
mediaSource: { ...opts.mediaSource, userId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getPlexApiClientForMediaSource(
|
getPlexApiClientForMediaSource(
|
||||||
mediaSource: MediaSource,
|
mediaSource: MediaSourceWithLibraries,
|
||||||
): Promise<PlexApiClient> {
|
): Promise<PlexApiClient> {
|
||||||
const opts = mediaSourceToApiOptions(mediaSource);
|
// const opts = mediaSourceToApiOptions(mediaSource);
|
||||||
return this.getPlexApiClient(opts);
|
return this.getPlexApiClient({ mediaSource });
|
||||||
}
|
}
|
||||||
|
|
||||||
getPlexApiClient(opts: ApiClientOptions): Promise<PlexApiClient> {
|
getPlexApiClient(opts: ApiClientOptions): Promise<PlexApiClient> {
|
||||||
const key = `${opts.url}|${opts.accessToken}`;
|
// const key = `${opts.url}|${opts.accessToken}`;
|
||||||
return cacheGetOrSet(MediaSourceApiFactory.cache, key, () => {
|
// const client = await cacheGetOrSet(MediaSourceApiFactory.cache, key, () => {
|
||||||
return Promise.resolve(
|
// return Promise.resolve(
|
||||||
new PlexApiClient({
|
// ,
|
||||||
...opts,
|
// );
|
||||||
enableRequestCache: this.requestCacheEnabledForServer(opts.name),
|
// });
|
||||||
}),
|
// client.setApiClientOptions(opts);
|
||||||
);
|
// return client;
|
||||||
});
|
return Promise.resolve(
|
||||||
|
this.plexApiClientFactory({
|
||||||
|
...opts,
|
||||||
|
enableRequestCache: this.requestCacheEnabledForServer(
|
||||||
|
opts.mediaSource.name,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPlexApiClientByName(name: string) {
|
async getPlexApiClientById(name: MediaSourceId) {
|
||||||
return this.getTypedByName(MediaSourceType.Plex, name, (mediaSource) => {
|
return this.getTypedByName(MediaSourceType.Plex, name, (mediaSource) => {
|
||||||
const client = new PlexApiClient({
|
const client = this.plexApiClientFactory({
|
||||||
...mediaSource,
|
mediaSource,
|
||||||
url: mediaSource.uri,
|
|
||||||
enableRequestCache: this.requestCacheEnabledForServer(mediaSource.name),
|
enableRequestCache: this.requestCacheEnabledForServer(mediaSource.name),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -154,29 +165,25 @@ export class MediaSourceApiFactory {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getJellyfinApiClientByName(name: string, userId?: string) {
|
async getJellyfinApiClientById(name: MediaSourceId, userId?: string) {
|
||||||
return this.getTypedByName(
|
return this.getTypedByName(MediaSourceType.Jellyfin, name, (opts) =>
|
||||||
MediaSourceType.Jellyfin,
|
this.jellyfinApiClientFactory({
|
||||||
name,
|
mediaSource: {
|
||||||
(opts) =>
|
|
||||||
new JellyfinApiClient({
|
|
||||||
...opts,
|
...opts,
|
||||||
url: opts.uri,
|
|
||||||
userId: opts.userId ?? userId ?? null,
|
userId: opts.userId ?? userId ?? null,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getEmbyApiClientByName(name: string, userId?: string) {
|
async getEmbyApiClientById(name: MediaSourceId, userId?: string) {
|
||||||
return this.getTypedByName(
|
return this.getTypedByName(MediaSourceType.Emby, name, (opts) =>
|
||||||
MediaSourceType.Emby,
|
this.embyApiClientFactory({
|
||||||
name,
|
mediaSource: {
|
||||||
(opts) =>
|
|
||||||
new EmbyApiClient({
|
|
||||||
...opts,
|
...opts,
|
||||||
url: opts.uri,
|
|
||||||
userId: opts.userId ?? userId ?? null,
|
userId: opts.userId ?? userId ?? null,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,34 +192,13 @@ export class MediaSourceApiFactory {
|
|||||||
return MediaSourceApiFactory.cache.del(key) === 1;
|
return MediaSourceApiFactory.cache.del(key) === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getTyped<
|
|
||||||
Typ extends MediaSourceType,
|
|
||||||
ApiClient = FindChild<Typ, TypeToClient>,
|
|
||||||
ApiClientOptionsT extends
|
|
||||||
ApiClientOptions = ApiClient extends BaseApiClient<infer Opts>
|
|
||||||
? Opts extends ApiClientOptions
|
|
||||||
? Opts
|
|
||||||
: never
|
|
||||||
: never,
|
|
||||||
>(
|
|
||||||
typ: Typ,
|
|
||||||
opts: ApiClientOptionsT,
|
|
||||||
factory: (opts: ApiClientOptionsT) => Promise<ApiClient>,
|
|
||||||
): Promise<ApiClient> {
|
|
||||||
return await cacheGetOrSet<ApiClient>(
|
|
||||||
MediaSourceApiFactory.cache,
|
|
||||||
this.getCacheKey(typ, opts.url, opts.accessToken),
|
|
||||||
() => factory(opts),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getTypedByName<
|
private async getTypedByName<
|
||||||
X extends MediaSourceType,
|
X extends MediaSourceType,
|
||||||
ApiClient = FindChild<X, TypeToClient>,
|
ApiClient = FindChild<X, TypeToClient>,
|
||||||
>(
|
>(
|
||||||
type: X,
|
type: X,
|
||||||
name: string,
|
name: MediaSourceId,
|
||||||
factory: (opts: MediaSource) => ApiClient,
|
factory: (opts: MediaSourceWithLibraries) => ApiClient,
|
||||||
): Promise<Maybe<ApiClient>> {
|
): Promise<Maybe<ApiClient>> {
|
||||||
const key = `${type}|${name}`;
|
const key = `${type}|${name}`;
|
||||||
return cacheGetOrSet<Maybe<ApiClient>>(
|
return cacheGetOrSet<Maybe<ApiClient>>(
|
||||||
@@ -247,19 +233,21 @@ export class MediaSourceApiFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async backfillPlexUserId(
|
private async backfillPlexUserId(
|
||||||
mediaSourceId: string,
|
mediaSourceId: MediaSourceId,
|
||||||
client: PlexApiClient,
|
client: PlexApiClient,
|
||||||
) {
|
) {
|
||||||
this.logger.debug('Attempting to backfill Plex user');
|
this.logger.debug('Attempting to backfill Plex user');
|
||||||
const result = await Result.attemptAsync(async () => {
|
const result = await Result.attemptAsync(async () => {
|
||||||
const user = await client.getUser();
|
const userResult = await client.getUser();
|
||||||
if (isQueryError(user)) {
|
if (userResult.isFailure()) {
|
||||||
throw new Error(user.message);
|
throw userResult.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const user = userResult.get();
|
||||||
|
|
||||||
await this.mediaSourceDB.setMediaSourceUserInfo(mediaSourceId, {
|
await this.mediaSourceDB.setMediaSourceUserInfo(mediaSourceId, {
|
||||||
userId: user.data.id?.toString(),
|
userId: user.id?.toString(),
|
||||||
username: user.data.username,
|
username: user.username,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
if (result.isFailure()) {
|
if (result.isFailure()) {
|
||||||
@@ -270,13 +258,3 @@ export class MediaSourceApiFactory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mediaSourceToApiOptions(
|
|
||||||
mediaSource: MediaSource,
|
|
||||||
): MarkRequired<ApiClientOptions, 'mediaSourceUuid'> {
|
|
||||||
return {
|
|
||||||
...mediaSource,
|
|
||||||
url: mediaSource.uri,
|
|
||||||
mediaSourceUuid: mediaSource.uuid,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
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 type { IProgramDB } from '@/db/interfaces/IProgramDB.js';
|
||||||
import { ProgramType } from '@/db/schema/Program.js';
|
import { ProgramType } from '@/db/schema/Program.js';
|
||||||
import type { ProgramWithExternalIds } from '@/db/schema/derivedTypes.js';
|
import type { ProgramWithExternalIds } from '@/db/schema/derivedTypes.js';
|
||||||
import { isQueryError } from '@/external/BaseApiClient.js';
|
|
||||||
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
|
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
|
||||||
import { GlobalScheduler } from '@/services/Scheduler.js';
|
import { GlobalScheduler } from '@/services/Scheduler.js';
|
||||||
import { ReconcileProgramDurationsTask } from '@/tasks/ReconcileProgramDurationsTask.js';
|
import { ReconcileProgramDurationsTask } from '@/tasks/ReconcileProgramDurationsTask.js';
|
||||||
import { KEYS } from '@/types/inject.js';
|
import { KEYS } from '@/types/inject.js';
|
||||||
import { Maybe } from '@/types/util.js';
|
import { Maybe } from '@/types/util.js';
|
||||||
import { groupByUniq, isDefined, run } from '@/util/index.js';
|
import { groupByUniq, isDefined, isNonEmptyString, run } from '@/util/index.js';
|
||||||
import { type Logger } from '@/util/logging/LoggerFactory.js';
|
import { type Logger } from '@/util/logging/LoggerFactory.js';
|
||||||
import { JellyfinItem, JellyfinItemKind } from '@tunarr/types/jellyfin';
|
import { JellyfinItem, JellyfinItemKind } from '@tunarr/types/jellyfin';
|
||||||
import { inject, injectable } from 'inversify';
|
import {
|
||||||
|
inject,
|
||||||
|
injectable,
|
||||||
|
interfaces,
|
||||||
|
LazyServiceIdentifier,
|
||||||
|
} from 'inversify';
|
||||||
import { find, isUndefined, some } from 'lodash-es';
|
import { find, isUndefined, some } from 'lodash-es';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
import { container } from '../../container.ts';
|
import { container } from '../../container.ts';
|
||||||
@@ -21,6 +25,7 @@ import {
|
|||||||
} from '../../db/custom_types/ProgramExternalIdType.ts';
|
} from '../../db/custom_types/ProgramExternalIdType.ts';
|
||||||
import { MediaSourceDB } from '../../db/mediaSourceDB.ts';
|
import { MediaSourceDB } from '../../db/mediaSourceDB.ts';
|
||||||
import { MediaSourceType } from '../../db/schema/MediaSource.ts';
|
import { MediaSourceType } from '../../db/schema/MediaSource.ts';
|
||||||
|
import { MediaSourceId } from '../../db/schema/base.ts';
|
||||||
import { ReconcileProgramDurationsTaskFactory } from '../../tasks/TasksModule.ts';
|
import { ReconcileProgramDurationsTaskFactory } from '../../tasks/TasksModule.ts';
|
||||||
import { JellyfinGetItemsQuery } from './JellyfinApiClient.ts';
|
import { JellyfinGetItemsQuery } from './JellyfinApiClient.ts';
|
||||||
|
|
||||||
@@ -29,9 +34,11 @@ export class JellyfinItemFinder {
|
|||||||
constructor(
|
constructor(
|
||||||
@inject(KEYS.ProgramDB) private programDB: IProgramDB,
|
@inject(KEYS.ProgramDB) private programDB: IProgramDB,
|
||||||
@inject(KEYS.Logger) private logger: Logger,
|
@inject(KEYS.Logger) private logger: Logger,
|
||||||
@inject(MediaSourceApiFactory)
|
@inject(new LazyServiceIdentifier(() => MediaSourceApiFactory))
|
||||||
private mediaSourceApiFactory: MediaSourceApiFactory,
|
private mediaSourceApiFactory: MediaSourceApiFactory,
|
||||||
@inject(MediaSourceDB) private mediaSourceDB: MediaSourceDB,
|
@inject(MediaSourceDB) private mediaSourceDB: MediaSourceDB,
|
||||||
|
@inject(KEYS.ProgramDaoMinterFactory)
|
||||||
|
private programMinterFactory: interfaces.AutoFactory<ProgramDaoMinter>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findForProgramAndUpdate(programId: string) {
|
async findForProgramAndUpdate(programId: string) {
|
||||||
@@ -53,7 +60,7 @@ export class JellyfinItemFinder {
|
|||||||
(eid) => eid.sourceType === ProgramExternalIdType.JELLYFIN,
|
(eid) => eid.sourceType === ProgramExternalIdType.JELLYFIN,
|
||||||
);
|
);
|
||||||
|
|
||||||
const minter = ProgramMinterFactory.create();
|
const minter = this.programMinterFactory();
|
||||||
const newExternalId = minter.mintJellyfinExternalIdForApiItem(
|
const newExternalId = minter.mintJellyfinExternalIdForApiItem(
|
||||||
program.externalSourceId,
|
program.externalSourceId,
|
||||||
program.uuid,
|
program.uuid,
|
||||||
@@ -69,25 +76,41 @@ export class JellyfinItemFinder {
|
|||||||
// Right now just check if the durations are different.
|
// Right now just check if the durations are different.
|
||||||
// otherwise we might blow away details we already have, since
|
// otherwise we might blow away details we already have, since
|
||||||
// Jellyfin collects metadata asynchronously (sometimes)
|
// Jellyfin collects metadata asynchronously (sometimes)
|
||||||
const mediaSourceId =
|
const mediaSource = await run(async () => {
|
||||||
program.mediaSourceId ??
|
if (!isNonEmptyString(program.mediaSourceId)) {
|
||||||
(await run(async () => {
|
throw new Error(`Program ${program.uuid} has no media source ID`);
|
||||||
const ms = await this.findMediaSource(program.externalSourceId);
|
}
|
||||||
if (!ms)
|
|
||||||
throw new Error(
|
const ms = await this.findMediaSource(program.mediaSourceId);
|
||||||
`Could not find media source by name: ${program.externalSourceId}`,
|
|
||||||
);
|
if (!ms)
|
||||||
return ms.uuid;
|
throw new Error(
|
||||||
}));
|
`Could not find media source by name: ${program.externalSourceId}`,
|
||||||
const updatedProgram = minter.mint(
|
);
|
||||||
program.externalSourceId,
|
return ms;
|
||||||
mediaSourceId,
|
});
|
||||||
{
|
|
||||||
sourceType: 'jellyfin',
|
if (!program.libraryId) {
|
||||||
program: potentialApiMatch,
|
throw new Error(
|
||||||
},
|
'Cannot find JF item match without a library ID. Consider syncing the library the missing item belongs to.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const library = mediaSource.libraries.find(
|
||||||
|
(lib) => lib.uuid === program.libraryId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!library) {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot find matching library for program. Library ID = ${program.libraryId}. Maybe the library was deleted?`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedProgram = minter.mint(mediaSource, library, {
|
||||||
|
sourceType: 'jellyfin',
|
||||||
|
program: potentialApiMatch,
|
||||||
|
});
|
||||||
|
|
||||||
if (updatedProgram.duration !== program.duration) {
|
if (updatedProgram.duration !== program.duration) {
|
||||||
await this.programDB.updateProgramDuration(
|
await this.programDB.updateProgramDuration(
|
||||||
program.uuid,
|
program.uuid,
|
||||||
@@ -121,22 +144,29 @@ export class JellyfinItemFinder {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const jfClient =
|
if (!isNonEmptyString(program.mediaSourceId)) {
|
||||||
await this.mediaSourceApiFactory.getJellyfinApiClientByName(
|
this.logger.error(
|
||||||
program.externalSourceId,
|
'Program %s does not have an associated media source ID',
|
||||||
|
program.uuid,
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jfClient = await this.mediaSourceApiFactory.getJellyfinApiClientById(
|
||||||
|
program.mediaSourceId,
|
||||||
|
);
|
||||||
|
|
||||||
if (!jfClient) {
|
if (!jfClient) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
"Couldn't get jellyfin api client for id: %s",
|
"Couldn't get jellyfin api client for id: %s",
|
||||||
program.externalSourceId,
|
program.mediaSourceId,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we can locate the item on JF, there is no problem.
|
// If we can locate the item on JF, there is no problem.
|
||||||
const existingItem = await jfClient.getItem(program.externalKey);
|
const existingItem = await jfClient.getItem(program.externalKey);
|
||||||
if (!isQueryError(existingItem) && isDefined(existingItem.data)) {
|
if (existingItem.isSuccess() && isDefined(existingItem.get())) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
existingItem,
|
existingItem,
|
||||||
'Item exists on Jellyfin - no need to find a new match',
|
'Item exists on Jellyfin - no need to find a new match',
|
||||||
@@ -181,7 +211,7 @@ export class JellyfinItemFinder {
|
|||||||
.with(ProgramType.OtherVideo, () => 'Video')
|
.with(ProgramType.OtherVideo, () => 'Video')
|
||||||
.exhaustive();
|
.exhaustive();
|
||||||
|
|
||||||
const queryResult = await jfClient.getItems(
|
const queryResult = await jfClient.getRawItems(
|
||||||
null,
|
null,
|
||||||
[jellyfinItemType],
|
[jellyfinItemType],
|
||||||
[],
|
[],
|
||||||
@@ -189,22 +219,24 @@ export class JellyfinItemFinder {
|
|||||||
opts,
|
opts,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (queryResult.type === 'success') {
|
return queryResult.either(
|
||||||
return find(queryResult.data.Items, (match) =>
|
(data) => {
|
||||||
some(
|
return find(data.Items, (match) =>
|
||||||
match.ProviderIds,
|
some(
|
||||||
(val, key) =>
|
match.ProviderIds,
|
||||||
programExternalIdTypeFromJellyfinProvider(key) === type &&
|
(val, key) =>
|
||||||
val === idsBySourceType[type].externalKey,
|
programExternalIdTypeFromJellyfinProvider(key) === type &&
|
||||||
),
|
val === idsBySourceType[type].externalKey,
|
||||||
);
|
),
|
||||||
} else {
|
);
|
||||||
this.logger.error(
|
},
|
||||||
{ error: queryResult },
|
(err) => {
|
||||||
'Error while querying items on Jellyfin',
|
this.logger.error(err, 'Error while querying items on Jellyfin');
|
||||||
);
|
return undefined;
|
||||||
}
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -231,10 +263,10 @@ export class JellyfinItemFinder {
|
|||||||
return possibleMatch;
|
return possibleMatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
private findMediaSource(mediaSourceName: string) {
|
private findMediaSource(mediaSourceId: MediaSourceId) {
|
||||||
return this.mediaSourceDB.findByType(
|
return this.mediaSourceDB.findByType(
|
||||||
MediaSourceType.Jellyfin,
|
MediaSourceType.Jellyfin,
|
||||||
mediaSourceName,
|
mediaSourceId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 type { QueryResult } from '@/external/BaseApiClient.js';
|
||||||
import { isQueryError, isQuerySuccess } from '@/external/BaseApiClient.js';
|
|
||||||
import { isDefined } from '@/util/index.js';
|
import { isDefined } from '@/util/index.js';
|
||||||
import NodeCache from 'node-cache';
|
import NodeCache from 'node-cache';
|
||||||
|
|
||||||
@@ -44,7 +43,7 @@ export class PlexQueryCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const value = await getter();
|
const value = await getter();
|
||||||
if (isQuerySuccess(value) || (isQueryError(value) && opts?.setOnError)) {
|
if (value.isSuccess() || opts?.setOnError) {
|
||||||
this.#cache.set(key, value);
|
this.#cache.set(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import { exec, spawn } from 'node:child_process';
|
|||||||
import events from 'node:events';
|
import events from 'node:events';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { WritableOptions } from 'node:stream';
|
import type stream from 'node:stream';
|
||||||
import stream from 'node:stream';
|
import { LastNBytesStream } from '../util/LastNBytesStream.ts';
|
||||||
|
|
||||||
export type FfmpegEvents = {
|
export type FfmpegEvents = {
|
||||||
// Emitted when the process ended with a code === 0, i.e. it exited
|
// Emitted when the process ended with a code === 0, i.e. it exited
|
||||||
@@ -258,52 +258,3 @@ export class FfmpegProcess extends (events.EventEmitter as new () => TypedEventE
|
|||||||
return this.ffmpegArgs;
|
return this.ffmpegArgs;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type LastNBytesStreamOpts = WritableOptions & {
|
|
||||||
bufSizeBytes?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
class LastNBytesStream extends stream.Writable {
|
|
||||||
public bufSizeBytes!: number;
|
|
||||||
#bytesWritten = 0;
|
|
||||||
#buf: Buffer;
|
|
||||||
|
|
||||||
constructor(options?: LastNBytesStreamOpts) {
|
|
||||||
super(options);
|
|
||||||
this.bufSizeBytes = options?.bufSizeBytes ?? 1024;
|
|
||||||
this.#buf = Buffer.alloc(this.bufSizeBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
_write(
|
|
||||||
chunk: Buffer,
|
|
||||||
_encoding: BufferEncoding,
|
|
||||||
callback: (error?: Error | null) => void,
|
|
||||||
): void {
|
|
||||||
const chunkLength = chunk.length;
|
|
||||||
if (chunkLength >= this.bufSizeBytes) {
|
|
||||||
// If the chunk is larger than or equal to the buffer, just take the last 1KB
|
|
||||||
chunk.copy(this.#buf, 0, chunkLength - this.bufSizeBytes, chunkLength);
|
|
||||||
this.#bytesWritten = this.bufSizeBytes;
|
|
||||||
} else {
|
|
||||||
// If the chunk is smaller, shift existing buffer content and append
|
|
||||||
const remainingSpace = this.bufSizeBytes - this.#bytesWritten;
|
|
||||||
|
|
||||||
if (chunkLength <= remainingSpace) {
|
|
||||||
// Chunk fits in the remaining space
|
|
||||||
chunk.copy(this.#buf, this.#bytesWritten);
|
|
||||||
this.#bytesWritten += chunkLength;
|
|
||||||
} else {
|
|
||||||
// Chunk doesn't fit completely, overwrite from the beginning
|
|
||||||
chunk.copy(this.#buf, this.#bytesWritten, 0, remainingSpace);
|
|
||||||
chunk.copy(this.#buf, 0, remainingSpace, chunkLength);
|
|
||||||
this.#bytesWritten = this.bufSizeBytes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
|
|
||||||
getLastN() {
|
|
||||||
return this.#buf.subarray(0, this.#bytesWritten);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export class SubtitleStreamPicker {
|
|||||||
if (stream.languageCodeISO6392 !== pref.languageCode) {
|
if (stream.languageCodeISO6392 !== pref.languageCode) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
'Skipping subtitle index %d, not a language match',
|
'Skipping subtitle index %d, not a language match',
|
||||||
stream.index,
|
stream.index ?? -1,
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -86,13 +86,13 @@ export class SubtitleStreamPicker {
|
|||||||
if (pref.filterType === 'forced' && !stream.forced) {
|
if (pref.filterType === 'forced' && !stream.forced) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
'Skipping subtitle index %d, wanted forced',
|
'Skipping subtitle index %d, wanted forced',
|
||||||
stream.index,
|
stream.index ?? -1,
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
} else if (pref.filterType === 'default' && !stream.default) {
|
} else if (pref.filterType === 'default' && !stream.default) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
'Skipping subtitle index %d, wanted default',
|
'Skipping subtitle index %d, wanted default',
|
||||||
stream.index,
|
stream.index ?? -1,
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,7 @@ export class SubtitleStreamPicker {
|
|||||||
if (!pref.allowExternal && stream.type === 'external') {
|
if (!pref.allowExternal && stream.type === 'external') {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
'Skipping subtitle index %d, disallowed external',
|
'Skipping subtitle index %d, disallowed external',
|
||||||
stream.index,
|
stream.index ?? -1,
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -109,7 +109,7 @@ export class SubtitleStreamPicker {
|
|||||||
if (!pref.allowImageBased && isImageBasedSubtitle(stream.codec)) {
|
if (!pref.allowImageBased && isImageBasedSubtitle(stream.codec)) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
'Skipping subtitle index %d, disallowed image-based',
|
'Skipping subtitle index %d, disallowed image-based',
|
||||||
stream.index,
|
stream.index ?? -1,
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -150,9 +150,9 @@ export class SubtitleStreamPicker {
|
|||||||
|
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
'Unsupported subtitle codec at index %d: %s',
|
'Unsupported subtitle codec at index %d: codec = %s',
|
||||||
stream.index,
|
stream.index ?? -1,
|
||||||
stream.codec,
|
stream.codec ?? 'unkonwn',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -161,7 +161,7 @@ export class SubtitleStreamPicker {
|
|||||||
if (!(await fileExists(fullPath))) {
|
if (!(await fileExists(fullPath))) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
'Subtitle stream at index %d has not been extracted yet.',
|
'Subtitle stream at index %d has not been extracted yet.',
|
||||||
stream.index,
|
stream.index ?? -1,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,10 +158,7 @@ export class PipelineBuilderContext {
|
|||||||
merge(this, props);
|
merge(this, props);
|
||||||
}
|
}
|
||||||
|
|
||||||
isSubtitleOverlay(): this is MarkRequired<
|
isSubtitleOverlay(): boolean {
|
||||||
PipelineBuilderContext,
|
|
||||||
'subtitleStream'
|
|
||||||
> {
|
|
||||||
return (
|
return (
|
||||||
(this.subtitleStream?.isImageBased &&
|
(this.subtitleStream?.isImageBased &&
|
||||||
this.subtitleStream?.method === SubtitleMethods.Burn) ??
|
this.subtitleStream?.method === SubtitleMethods.Burn) ??
|
||||||
@@ -169,10 +166,7 @@ export class PipelineBuilderContext {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
isSubtitleTextContext(): this is MarkRequired<
|
isSubtitleTextContext(): boolean {
|
||||||
PipelineBuilderContext,
|
|
||||||
'subtitleStream'
|
|
||||||
> {
|
|
||||||
return (
|
return (
|
||||||
(this.subtitleStream &&
|
(this.subtitleStream &&
|
||||||
!this.subtitleStream.isImageBased &&
|
!this.subtitleStream.isImageBased &&
|
||||||
@@ -524,7 +518,9 @@ export abstract class BasePipelineBuilder implements PipelineBuilder {
|
|||||||
this.desiredState.videoFormat !== VideoFormats.Copy
|
this.desiredState.videoFormat !== VideoFormats.Copy
|
||||||
) {
|
) {
|
||||||
this.decoder = this.setupDecoder();
|
this.decoder = this.setupDecoder();
|
||||||
this.logger.debug('Setup decoder: %O', this.decoder);
|
if (this.decoder) {
|
||||||
|
this.logger.debug('Setup decoder: %O', this.decoder);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setRealtime();
|
this.setRealtime();
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import Migration1746123876_ReworkSubtitleFilter from './db/Migration1746123876_R
|
|||||||
import Migration1746128022_FixSubtitlePriorityType from './db/Migration1746128022_FixSubtitlePriorityType.ts';
|
import Migration1746128022_FixSubtitlePriorityType from './db/Migration1746128022_FixSubtitlePriorityType.ts';
|
||||||
import Migration1748345299_AddMoreProgramTypes from './db/Migration1748345299_AddMoreProgramTypes.ts';
|
import Migration1748345299_AddMoreProgramTypes from './db/Migration1748345299_AddMoreProgramTypes.ts';
|
||||||
import Migration1756312561_InitialAdvancedTranscodeConfig from './db/Migration1756312561_InitialAdvancedTranscodeConfig.ts';
|
import Migration1756312561_InitialAdvancedTranscodeConfig from './db/Migration1756312561_InitialAdvancedTranscodeConfig.ts';
|
||||||
|
import Migration1756381281_AddLibraries from './db/Migration1756381281_AddLibraries.ts';
|
||||||
|
import Migration1757704591_AddProgramMediaSourceIndex from './db/Migration1757704591_AddProgramMediaSourceIndex.ts';
|
||||||
|
|
||||||
export const LegacyMigrationNameToNewMigrationName = [
|
export const LegacyMigrationNameToNewMigrationName = [
|
||||||
['Migration20240124115044', '_Legacy_Migration00'],
|
['Migration20240124115044', '_Legacy_Migration00'],
|
||||||
@@ -113,6 +115,8 @@ export class DirectMigrationProvider implements MigrationProvider {
|
|||||||
migration1748345299: Migration1748345299_AddMoreProgramTypes,
|
migration1748345299: Migration1748345299_AddMoreProgramTypes,
|
||||||
migration1756312561:
|
migration1756312561:
|
||||||
Migration1756312561_InitialAdvancedTranscodeConfig,
|
Migration1756312561_InitialAdvancedTranscodeConfig,
|
||||||
|
migration1756381281: Migration1756381281_AddLibraries,
|
||||||
|
migration1757704591: Migration1757704591_AddProgramMediaSourceIndex,
|
||||||
},
|
},
|
||||||
wrapWithTransaction,
|
wrapWithTransaction,
|
||||||
),
|
),
|
||||||
|
|||||||
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