refactor: add media_source_id to relevant entities (#1106)

We should be referencing media_sources by their ID on programs,
external_ids, etc. This enables us to use proper foreign keys for
referential integrity at the DB level, not worry about unique names for
media sources, and simplifies a lot of the code relating to media source
deletion and the cleanup thereafter.

This change also introduces the DBContext, which should allow for
arbitrarily calling other DB accessor functions when within transactions
and not deadlocking the connection to the DB.
This commit is contained in:
Christian Benincasa
2025-02-28 12:53:29 -08:00
committed by GitHub
parent 9b0385e577
commit 769b05d201
34 changed files with 1315 additions and 331 deletions

View File

@@ -45,7 +45,8 @@
"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" "ts-essentials@9.4.1": "patches/ts-essentials@9.4.1.patch",
"kysely": "patches/kysely.patch"
}, },
"overrides": { "overrides": {
"eslint": "9.17.0", "eslint": "9.17.0",

467
patches/kysely.patch Normal file
View File

@@ -0,0 +1,467 @@
diff --git a/dist/esm/index.d.ts b/dist/esm/index.d.ts
index d556ad76e2a23d600e3ee3024bad9d7dd8beccf7..5f25e02adfed39f2cbebb7db31d04e9615b1a544 100644
--- a/dist/esm/index.d.ts
+++ b/dist/esm/index.d.ts
@@ -1,95 +1,48 @@
-export * from './kysely.js';
-export * from './query-creator.js';
-export * from './expression/expression.js';
-export { ExpressionBuilder, expressionBuilder, } from './expression/expression-builder.js';
-export * from './expression/expression-wrapper.js';
-export * from './query-builder/where-interface.js';
-export * from './query-builder/returning-interface.js';
-export * from './query-builder/output-interface.js';
-export * from './query-builder/having-interface.js';
-export * from './query-builder/select-query-builder.js';
-export * from './query-builder/insert-query-builder.js';
-export * from './query-builder/update-query-builder.js';
-export * from './query-builder/delete-query-builder.js';
-export * from './query-builder/no-result-error.js';
-export * from './query-builder/join-builder.js';
-export * from './query-builder/function-module.js';
-export * from './query-builder/insert-result.js';
-export * from './query-builder/delete-result.js';
-export * from './query-builder/update-result.js';
-export * from './query-builder/on-conflict-builder.js';
-export * from './query-builder/aggregate-function-builder.js';
-export * from './query-builder/case-builder.js';
-export * from './query-builder/json-path-builder.js';
-export * from './query-builder/merge-query-builder.js';
-export * from './query-builder/merge-result.js';
-export * from './raw-builder/raw-builder.js';
-export * from './raw-builder/sql.js';
-export * from './query-executor/query-executor.js';
-export * from './query-executor/default-query-executor.js';
-export * from './query-executor/noop-query-executor.js';
-export * from './query-executor/query-executor-provider.js';
-export * from './query-compiler/default-query-compiler.js';
-export * from './query-compiler/compiled-query.js';
-export * from './schema/schema.js';
-export * from './schema/create-table-builder.js';
-export * from './schema/create-type-builder.js';
-export * from './schema/drop-table-builder.js';
-export * from './schema/drop-type-builder.js';
-export * from './schema/create-index-builder.js';
-export * from './schema/drop-index-builder.js';
-export * from './schema/create-schema-builder.js';
-export * from './schema/drop-schema-builder.js';
-export * from './schema/column-definition-builder.js';
-export * from './schema/foreign-key-constraint-builder.js';
-export * from './schema/alter-table-builder.js';
-export * from './schema/create-view-builder.js';
-export * from './schema/drop-view-builder.js';
-export * from './schema/alter-column-builder.js';
-export * from './dynamic/dynamic.js';
-export * from './driver/driver.js';
-export * from './driver/database-connection.js';
-export * from './driver/connection-provider.js';
-export * from './driver/default-connection-provider.js';
-export * from './driver/single-connection-provider.js';
-export * from './driver/dummy-driver.js';
-export * from './dialect/dialect.js';
-export * from './dialect/dialect-adapter.js';
-export * from './dialect/dialect-adapter-base.js';
export * from './dialect/database-introspector.js';
-export * from './dialect/sqlite/sqlite-dialect.js';
-export * from './dialect/sqlite/sqlite-dialect-config.js';
-export * from './dialect/sqlite/sqlite-driver.js';
-export * from './dialect/postgres/postgres-query-compiler.js';
-export * from './dialect/postgres/postgres-introspector.js';
-export * from './dialect/postgres/postgres-adapter.js';
-export * from './dialect/mysql/mysql-dialect.js';
-export * from './dialect/mysql/mysql-dialect-config.js';
-export * from './dialect/mysql/mysql-driver.js';
-export * from './dialect/mysql/mysql-query-compiler.js';
-export * from './dialect/mysql/mysql-introspector.js';
-export * from './dialect/mysql/mysql-adapter.js';
-export * from './dialect/postgres/postgres-driver.js';
-export * from './dialect/postgres/postgres-dialect-config.js';
-export * from './dialect/postgres/postgres-dialect.js';
-export * from './dialect/sqlite/sqlite-query-compiler.js';
-export * from './dialect/sqlite/sqlite-introspector.js';
-export * from './dialect/sqlite/sqlite-adapter.js';
+export * from './dialect/dialect-adapter-base.js';
+export * from './dialect/dialect-adapter.js';
+export * from './dialect/dialect.js';
export * from './dialect/mssql/mssql-adapter.js';
export * from './dialect/mssql/mssql-dialect-config.js';
export * from './dialect/mssql/mssql-dialect.js';
export * from './dialect/mssql/mssql-driver.js';
export * from './dialect/mssql/mssql-introspector.js';
export * from './dialect/mssql/mssql-query-compiler.js';
-export * from './query-compiler/default-query-compiler.js';
-export * from './query-compiler/query-compiler.js';
-export * from './migration/migrator.js';
+export * from './dialect/mysql/mysql-adapter.js';
+export * from './dialect/mysql/mysql-dialect-config.js';
+export * from './dialect/mysql/mysql-dialect.js';
+export * from './dialect/mysql/mysql-driver.js';
+export * from './dialect/mysql/mysql-introspector.js';
+export * from './dialect/mysql/mysql-query-compiler.js';
+export * from './dialect/postgres/postgres-adapter.js';
+export * from './dialect/postgres/postgres-dialect-config.js';
+export * from './dialect/postgres/postgres-dialect.js';
+export * from './dialect/postgres/postgres-driver.js';
+export * from './dialect/postgres/postgres-introspector.js';
+export * from './dialect/postgres/postgres-query-compiler.js';
+export * from './dialect/sqlite/sqlite-adapter.js';
+export * from './dialect/sqlite/sqlite-dialect-config.js';
+export * from './dialect/sqlite/sqlite-dialect.js';
+export * from './dialect/sqlite/sqlite-driver.js';
+export * from './dialect/sqlite/sqlite-introspector.js';
+export * from './dialect/sqlite/sqlite-query-compiler.js';
+export * from './driver/connection-provider.js';
+export * from './driver/database-connection.js';
+export * from './driver/default-connection-provider.js';
+export * from './driver/driver.js';
+export * from './driver/dummy-driver.js';
+export * from './driver/runtime-driver.js';
+export * from './driver/single-connection-provider.js';
+export * from './dynamic/dynamic.js';
+export {
+ ExpressionBuilder,
+ expressionBuilder,
+} from './expression/expression-builder.js';
+export * from './expression/expression-wrapper.js';
+export * from './expression/expression.js';
+export * from './kysely.js';
export * from './migration/file-migration-provider.js';
-export * from './plugin/kysely-plugin.js';
-export * from './plugin/camel-case/camel-case-plugin.js';
-export * from './plugin/deduplicate-joins/deduplicate-joins-plugin.js';
-export * from './plugin/with-schema/with-schema-plugin.js';
-export * from './plugin/parse-json-results/parse-json-results-plugin.js';
+export * from './migration/migrator.js';
export * from './operation-node/add-column-node.js';
export * from './operation-node/add-constraint-node.js';
export * from './operation-node/add-index-node.js';
@@ -190,22 +143,117 @@ export * from './operation-node/values-node.js';
export * from './operation-node/when-node.js';
export * from './operation-node/where-node.js';
export * from './operation-node/with-node.js';
+export {
+ ComparisonOperatorExpression,
+ FilterObject,
+ OperandValueExpression,
+ OperandValueExpressionOrList,
+} from './parser/binary-operation-parser.js';
+export {
+ ExpressionOrFactory,
+ OperandExpression,
+} from './parser/expression-parser.js';
+export { InsertObject } from './parser/insert-values-parser.js';
+export {
+ JoinCallbackExpression,
+ JoinReferenceExpression,
+} from './parser/join-parser.js';
+export {
+ OrderByDirectionExpression,
+ OrderByExpression,
+} from './parser/order-by-parser.js';
+export {
+ ExtractTypeFromReferenceExpression,
+ ExtractTypeFromStringReference,
+ ReferenceExpression,
+ ReferenceExpressionOrList,
+ SimpleReferenceExpression,
+ StringReference,
+} from './parser/reference-parser.js';
+export {
+ CallbackSelection,
+ SelectArg,
+ SelectCallback,
+ SelectExpression,
+ Selection,
+} from './parser/select-parser.js';
+export {
+ SimpleTableReference,
+ TableExpression,
+ TableExpressionOrList,
+} from './parser/table-parser.js';
+export { ExistsExpression } from './parser/unary-operation-parser.js';
+export { UpdateObject } from './parser/update-set-parser.js';
+export {
+ ValueExpression,
+ ValueExpressionOrList,
+} from './parser/value-parser.js';
+export * from './plugin/camel-case/camel-case-plugin.js';
+export * from './plugin/deduplicate-joins/deduplicate-joins-plugin.js';
+export * from './plugin/kysely-plugin.js';
+export * from './plugin/parse-json-results/parse-json-results-plugin.js';
+export * from './plugin/with-schema/with-schema-plugin.js';
+export * from './query-builder/aggregate-function-builder.js';
+export * from './query-builder/case-builder.js';
+export * from './query-builder/delete-query-builder.js';
+export * from './query-builder/delete-result.js';
+export * from './query-builder/function-module.js';
+export * from './query-builder/having-interface.js';
+export * from './query-builder/insert-query-builder.js';
+export * from './query-builder/insert-result.js';
+export * from './query-builder/join-builder.js';
+export * from './query-builder/json-path-builder.js';
+export * from './query-builder/merge-query-builder.js';
+export * from './query-builder/merge-result.js';
+export * from './query-builder/no-result-error.js';
+export * from './query-builder/on-conflict-builder.js';
+export * from './query-builder/output-interface.js';
+export * from './query-builder/returning-interface.js';
+export * from './query-builder/select-query-builder.js';
+export * from './query-builder/update-query-builder.js';
+export * from './query-builder/update-result.js';
+export * from './query-builder/where-interface.js';
+export * from './query-compiler/compiled-query.js';
+export * from './query-compiler/default-query-compiler.js';
+export * from './query-compiler/query-compiler.js';
+export * from './query-creator.js';
+export * from './query-executor/default-query-executor.js';
+export * from './query-executor/noop-query-executor.js';
+export * from './query-executor/query-executor-provider.js';
+export * from './query-executor/query-executor.js';
+export * from './raw-builder/raw-builder.js';
+export * from './raw-builder/sql.js';
+export * from './schema/alter-column-builder.js';
+export * from './schema/alter-table-builder.js';
+export * from './schema/column-definition-builder.js';
+export * from './schema/create-index-builder.js';
+export * from './schema/create-schema-builder.js';
+export * from './schema/create-table-builder.js';
+export * from './schema/create-type-builder.js';
+export * from './schema/create-view-builder.js';
+export * from './schema/drop-index-builder.js';
+export * from './schema/drop-schema-builder.js';
+export * from './schema/drop-table-builder.js';
+export * from './schema/drop-type-builder.js';
+export * from './schema/drop-view-builder.js';
+export * from './schema/foreign-key-constraint-builder.js';
+export * from './schema/schema.js';
export * from './util/column-type.js';
export * from './util/compilable.js';
export * from './util/explainable.js';
-export * from './util/streamable.js';
-export * from './util/log.js';
-export { AnyAliasedColumn, AnyAliasedColumnWithTable, AnyColumn, AnyColumnWithTable, Equals, UnknownRow, Simplify, SqlBool, Nullable, NotNull, } from './util/type-utils.js';
export * from './util/infer-result.js';
export { logOnce } from './util/log-once.js';
-export { SelectExpression, SelectCallback, SelectArg, Selection, CallbackSelection, } from './parser/select-parser.js';
-export { ReferenceExpression, ReferenceExpressionOrList, SimpleReferenceExpression, StringReference, ExtractTypeFromStringReference, ExtractTypeFromReferenceExpression, } from './parser/reference-parser.js';
-export { ValueExpression, ValueExpressionOrList, } from './parser/value-parser.js';
-export { SimpleTableReference, TableExpression, TableExpressionOrList, } from './parser/table-parser.js';
-export { JoinReferenceExpression, JoinCallbackExpression, } from './parser/join-parser.js';
-export { InsertObject } from './parser/insert-values-parser.js';
-export { UpdateObject } from './parser/update-set-parser.js';
-export { OrderByExpression, OrderByDirectionExpression, } from './parser/order-by-parser.js';
-export { ComparisonOperatorExpression, OperandValueExpression, OperandValueExpressionOrList, FilterObject, } from './parser/binary-operation-parser.js';
-export { ExistsExpression } from './parser/unary-operation-parser.js';
-export { OperandExpression, ExpressionOrFactory, } from './parser/expression-parser.js';
+export * from './util/log.js';
+export * from './util/streamable.js';
+export {
+ AnyAliasedColumn,
+ AnyAliasedColumnWithTable,
+ AnyColumn,
+ AnyColumnWithTable,
+ Equals,
+ NotNull,
+ Nullable,
+ Simplify,
+ SqlBool,
+ UnknownRow,
+} from './util/type-utils.js';
diff --git a/dist/esm/index.js b/dist/esm/index.js
index c85949e9080a6e78f1aef5561ed050cb6d924b15..6ddadd439b6f5aa4b45749e3297035a7ad0b3496 100644
--- a/dist/esm/index.js
+++ b/dist/esm/index.js
@@ -1,96 +1,46 @@
/// <reference types="./index.d.ts" />
-export * from './kysely.js';
-export * from './query-creator.js';
-export * from './expression/expression.js';
-export { expressionBuilder, } from './expression/expression-builder.js';
-export * from './expression/expression-wrapper.js';
-export * from './query-builder/where-interface.js';
-export * from './query-builder/returning-interface.js';
-export * from './query-builder/output-interface.js';
-export * from './query-builder/having-interface.js';
-export * from './query-builder/select-query-builder.js';
-export * from './query-builder/insert-query-builder.js';
-export * from './query-builder/update-query-builder.js';
-export * from './query-builder/delete-query-builder.js';
-export * from './query-builder/no-result-error.js';
-export * from './query-builder/join-builder.js';
-export * from './query-builder/function-module.js';
-export * from './query-builder/insert-result.js';
-export * from './query-builder/delete-result.js';
-export * from './query-builder/update-result.js';
-export * from './query-builder/on-conflict-builder.js';
-export * from './query-builder/aggregate-function-builder.js';
-export * from './query-builder/case-builder.js';
-export * from './query-builder/json-path-builder.js';
-export * from './query-builder/merge-query-builder.js';
-export * from './query-builder/merge-result.js';
-export * from './raw-builder/raw-builder.js';
-export * from './raw-builder/sql.js';
-export * from './query-executor/query-executor.js';
-export * from './query-executor/default-query-executor.js';
-export * from './query-executor/noop-query-executor.js';
-export * from './query-executor/query-executor-provider.js';
-export * from './query-compiler/default-query-compiler.js';
-export * from './query-compiler/compiled-query.js';
-export * from './schema/schema.js';
-export * from './schema/create-table-builder.js';
-export * from './schema/create-type-builder.js';
-export * from './schema/drop-table-builder.js';
-export * from './schema/drop-type-builder.js';
-export * from './schema/create-index-builder.js';
-export * from './schema/drop-index-builder.js';
-export * from './schema/create-schema-builder.js';
-export * from './schema/drop-schema-builder.js';
-export * from './schema/column-definition-builder.js';
-export * from './schema/foreign-key-constraint-builder.js';
-export * from './schema/alter-table-builder.js';
-export * from './schema/create-view-builder.js';
-export * from './schema/drop-view-builder.js';
-export * from './schema/alter-column-builder.js';
-export * from './dynamic/dynamic.js';
-export * from './driver/driver.js';
-export * from './driver/database-connection.js';
-export * from './driver/connection-provider.js';
-export * from './driver/default-connection-provider.js';
-export * from './driver/single-connection-provider.js';
-export * from './driver/dummy-driver.js';
-export * from './dialect/dialect.js';
-export * from './dialect/dialect-adapter.js';
-export * from './dialect/dialect-adapter-base.js';
export * from './dialect/database-introspector.js';
-export * from './dialect/sqlite/sqlite-dialect.js';
-export * from './dialect/sqlite/sqlite-dialect-config.js';
-export * from './dialect/sqlite/sqlite-driver.js';
-export * from './dialect/postgres/postgres-query-compiler.js';
-export * from './dialect/postgres/postgres-introspector.js';
-export * from './dialect/postgres/postgres-adapter.js';
-export * from './dialect/mysql/mysql-dialect.js';
-export * from './dialect/mysql/mysql-dialect-config.js';
-export * from './dialect/mysql/mysql-driver.js';
-export * from './dialect/mysql/mysql-query-compiler.js';
-export * from './dialect/mysql/mysql-introspector.js';
-export * from './dialect/mysql/mysql-adapter.js';
-export * from './dialect/postgres/postgres-driver.js';
-export * from './dialect/postgres/postgres-dialect-config.js';
-export * from './dialect/postgres/postgres-dialect.js';
-export * from './dialect/sqlite/sqlite-query-compiler.js';
-export * from './dialect/sqlite/sqlite-introspector.js';
-export * from './dialect/sqlite/sqlite-adapter.js';
+export * from './dialect/dialect-adapter-base.js';
+export * from './dialect/dialect-adapter.js';
+export * from './dialect/dialect.js';
export * from './dialect/mssql/mssql-adapter.js';
export * from './dialect/mssql/mssql-dialect-config.js';
export * from './dialect/mssql/mssql-dialect.js';
export * from './dialect/mssql/mssql-driver.js';
export * from './dialect/mssql/mssql-introspector.js';
export * from './dialect/mssql/mssql-query-compiler.js';
-export * from './query-compiler/default-query-compiler.js';
-export * from './query-compiler/query-compiler.js';
-export * from './migration/migrator.js';
+export * from './dialect/mysql/mysql-adapter.js';
+export * from './dialect/mysql/mysql-dialect-config.js';
+export * from './dialect/mysql/mysql-dialect.js';
+export * from './dialect/mysql/mysql-driver.js';
+export * from './dialect/mysql/mysql-introspector.js';
+export * from './dialect/mysql/mysql-query-compiler.js';
+export * from './dialect/postgres/postgres-adapter.js';
+export * from './dialect/postgres/postgres-dialect-config.js';
+export * from './dialect/postgres/postgres-dialect.js';
+export * from './dialect/postgres/postgres-driver.js';
+export * from './dialect/postgres/postgres-introspector.js';
+export * from './dialect/postgres/postgres-query-compiler.js';
+export * from './dialect/sqlite/sqlite-adapter.js';
+export * from './dialect/sqlite/sqlite-dialect-config.js';
+export * from './dialect/sqlite/sqlite-dialect.js';
+export * from './dialect/sqlite/sqlite-driver.js';
+export * from './dialect/sqlite/sqlite-introspector.js';
+export * from './dialect/sqlite/sqlite-query-compiler.js';
+export * from './driver/connection-provider.js';
+export * from './driver/database-connection.js';
+export * from './driver/default-connection-provider.js';
+export * from './driver/driver.js';
+export * from './driver/dummy-driver.js';
+export * from './driver/runtime-driver.js';
+export * from './driver/single-connection-provider.js';
+export * from './dynamic/dynamic.js';
+export { expressionBuilder } from './expression/expression-builder.js';
+export * from './expression/expression-wrapper.js';
+export * from './expression/expression.js';
+export * from './kysely.js';
export * from './migration/file-migration-provider.js';
-export * from './plugin/kysely-plugin.js';
-export * from './plugin/camel-case/camel-case-plugin.js';
-export * from './plugin/deduplicate-joins/deduplicate-joins-plugin.js';
-export * from './plugin/with-schema/with-schema-plugin.js';
-export * from './plugin/parse-json-results/parse-json-results-plugin.js';
+export * from './migration/migrator.js';
export * from './operation-node/add-column-node.js';
export * from './operation-node/add-constraint-node.js';
export * from './operation-node/add-index-node.js';
@@ -191,10 +141,60 @@ export * from './operation-node/values-node.js';
export * from './operation-node/when-node.js';
export * from './operation-node/where-node.js';
export * from './operation-node/with-node.js';
+export * from './plugin/camel-case/camel-case-plugin.js';
+export * from './plugin/deduplicate-joins/deduplicate-joins-plugin.js';
+export * from './plugin/kysely-plugin.js';
+export * from './plugin/parse-json-results/parse-json-results-plugin.js';
+export * from './plugin/with-schema/with-schema-plugin.js';
+export * from './query-builder/aggregate-function-builder.js';
+export * from './query-builder/case-builder.js';
+export * from './query-builder/delete-query-builder.js';
+export * from './query-builder/delete-result.js';
+export * from './query-builder/function-module.js';
+export * from './query-builder/having-interface.js';
+export * from './query-builder/insert-query-builder.js';
+export * from './query-builder/insert-result.js';
+export * from './query-builder/join-builder.js';
+export * from './query-builder/json-path-builder.js';
+export * from './query-builder/merge-query-builder.js';
+export * from './query-builder/merge-result.js';
+export * from './query-builder/no-result-error.js';
+export * from './query-builder/on-conflict-builder.js';
+export * from './query-builder/output-interface.js';
+export * from './query-builder/returning-interface.js';
+export * from './query-builder/select-query-builder.js';
+export * from './query-builder/update-query-builder.js';
+export * from './query-builder/update-result.js';
+export * from './query-builder/where-interface.js';
+export * from './query-compiler/compiled-query.js';
+export * from './query-compiler/default-query-compiler.js';
+export * from './query-compiler/query-compiler.js';
+export * from './query-creator.js';
+export * from './query-executor/default-query-executor.js';
+export * from './query-executor/noop-query-executor.js';
+export * from './query-executor/query-executor-provider.js';
+export * from './query-executor/query-executor.js';
+export * from './raw-builder/raw-builder.js';
+export * from './raw-builder/sql.js';
+export * from './schema/alter-column-builder.js';
+export * from './schema/alter-table-builder.js';
+export * from './schema/column-definition-builder.js';
+export * from './schema/create-index-builder.js';
+export * from './schema/create-schema-builder.js';
+export * from './schema/create-table-builder.js';
+export * from './schema/create-type-builder.js';
+export * from './schema/create-view-builder.js';
+export * from './schema/drop-index-builder.js';
+export * from './schema/drop-schema-builder.js';
+export * from './schema/drop-table-builder.js';
+export * from './schema/drop-type-builder.js';
+export * from './schema/drop-view-builder.js';
+export * from './schema/foreign-key-constraint-builder.js';
+export * from './schema/schema.js';
export * from './util/column-type.js';
export * from './util/compilable.js';
export * from './util/explainable.js';
-export * from './util/streamable.js';
-export * from './util/log.js';
export * from './util/infer-result.js';
export { logOnce } from './util/log-once.js';
+export * from './util/log.js';
+export * from './util/streamable.js';

19
pnpm-lock.yaml generated
View File

@@ -9,6 +9,9 @@ overrides:
'@types/node': 22.10.7 '@types/node': 22.10.7
patchedDependencies: patchedDependencies:
kysely:
hash: 5ewjqngd4oyjgaqa3fcufrviwq
path: patches/kysely.patch
ts-essentials@9.4.1: ts-essentials@9.4.1:
hash: 254pvnpgpcwoswa4ncfq4tq6su hash: 254pvnpgpcwoswa4ncfq4tq6su
path: patches/ts-essentials@9.4.1.patch path: patches/ts-essentials@9.4.1.patch
@@ -145,7 +148,7 @@ importers:
version: 1.11.10 version: 1.11.10
drizzle-orm: drizzle-orm:
specifier: ^0.39.3 specifier: ^0.39.3
version: 0.39.3(@opentelemetry/api@1.4.1)(@types/better-sqlite3@7.6.12)(better-sqlite3@11.8.1)(bun-types@1.2.1)(kysely@0.27.4) version: 0.39.3(@opentelemetry/api@1.4.1)(@types/better-sqlite3@7.6.12)(better-sqlite3@11.8.1)(bun-types@1.2.1)(kysely@0.27.4(patch_hash=5ewjqngd4oyjgaqa3fcufrviwq))
fast-xml-parser: fast-xml-parser:
specifier: ^4.3.5 specifier: ^4.3.5
version: 4.3.5 version: 4.3.5
@@ -172,7 +175,7 @@ importers:
version: 6.2.1(reflect-metadata@0.2.2) version: 6.2.1(reflect-metadata@0.2.2)
kysely: kysely:
specifier: ^0.27.4 specifier: ^0.27.4
version: 0.27.4 version: 0.27.4(patch_hash=5ewjqngd4oyjgaqa3fcufrviwq)
lodash-es: lodash-es:
specifier: ^4.17.21 specifier: ^4.17.21
version: 4.17.21 version: 4.17.21
@@ -293,7 +296,7 @@ importers:
version: 15.0.0 version: 15.0.0
kysely-ctl: kysely-ctl:
specifier: ^0.9.0 specifier: ^0.9.0
version: 0.9.0(kysely@0.27.4)(magicast@0.3.5) version: 0.9.0(kysely@0.27.4(patch_hash=5ewjqngd4oyjgaqa3fcufrviwq))(magicast@0.3.5)
make-vfs: make-vfs:
specifier: ^1.0.15 specifier: ^1.0.15
version: 1.0.15 version: 1.0.15
@@ -11087,13 +11090,13 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
drizzle-orm@0.39.3(@opentelemetry/api@1.4.1)(@types/better-sqlite3@7.6.12)(better-sqlite3@11.8.1)(bun-types@1.2.1)(kysely@0.27.4): drizzle-orm@0.39.3(@opentelemetry/api@1.4.1)(@types/better-sqlite3@7.6.12)(better-sqlite3@11.8.1)(bun-types@1.2.1)(kysely@0.27.4(patch_hash=5ewjqngd4oyjgaqa3fcufrviwq)):
optionalDependencies: optionalDependencies:
'@opentelemetry/api': 1.4.1 '@opentelemetry/api': 1.4.1
'@types/better-sqlite3': 7.6.12 '@types/better-sqlite3': 7.6.12
better-sqlite3: 11.8.1 better-sqlite3: 11.8.1
bun-types: 1.2.1 bun-types: 1.2.1
kysely: 0.27.4 kysely: 0.27.4(patch_hash=5ewjqngd4oyjgaqa3fcufrviwq)
dunder-proto@1.0.1: dunder-proto@1.0.1:
dependencies: dependencies:
@@ -12565,12 +12568,12 @@ snapshots:
dependencies: dependencies:
json-buffer: 3.0.1 json-buffer: 3.0.1
kysely-ctl@0.9.0(kysely@0.27.4)(magicast@0.3.5): kysely-ctl@0.9.0(kysely@0.27.4(patch_hash=5ewjqngd4oyjgaqa3fcufrviwq))(magicast@0.3.5):
dependencies: dependencies:
c12: 1.11.2(magicast@0.3.5) c12: 1.11.2(magicast@0.3.5)
citty: 0.1.6 citty: 0.1.6
consola: 3.2.3 consola: 3.2.3
kysely: 0.27.4 kysely: 0.27.4(patch_hash=5ewjqngd4oyjgaqa3fcufrviwq)
nypm: 0.3.12 nypm: 0.3.12
ofetch: 1.4.1 ofetch: 1.4.1
pathe: 1.1.2 pathe: 1.1.2
@@ -12580,7 +12583,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- magicast - magicast
kysely@0.27.4: {} kysely@0.27.4(patch_hash=5ewjqngd4oyjgaqa3fcufrviwq): {}
lazystream@1.0.1: lazystream@1.0.1:
dependencies: dependencies:

View File

@@ -35,6 +35,7 @@ import { HdhrApiRouter } from './api/hdhrApi.js';
import { apiRouter } from './api/index.js'; import { apiRouter } from './api/index.js';
import { streamApi } from './api/streamApi.js'; import { streamApi } from './api/streamApi.js';
import { videoApiRouter } from './api/videoApi.js'; import { videoApiRouter } from './api/videoApi.js';
import { DBContext, makeDatabaseConnection } from './db/DBAccess.ts';
import { FfmpegInfo } from './ffmpeg/ffmpegInfo.js'; import { FfmpegInfo } from './ffmpeg/ffmpegInfo.js';
import { import {
type ServerOptions, type ServerOptions,
@@ -439,6 +440,7 @@ export class Server {
this.app.after(() => { this.app.after(() => {
this.app.gracefulShutdown(async (signal) => { this.app.gracefulShutdown(async (signal) => {
DBContext.enter(makeDatabaseConnection());
this.logger.info( this.logger.info(
'Received exit signal %s, attempting graceful shutdown', 'Received exit signal %s, attempting graceful shutdown',
signal, signal,

View File

@@ -4,7 +4,9 @@ import path from 'node:path';
import type { DeepPartial } from 'ts-essentials'; import type { DeepPartial } from 'ts-essentials';
import { import {
databaseNeedsMigration, databaseNeedsMigration,
DBContext,
getDatabase, getDatabase,
makeDatabaseConnection,
migrateExistingDatabase, migrateExistingDatabase,
runDBMigrations, runDBMigrations,
syncMigrationTablesIfNecessary, syncMigrationTablesIfNecessary,
@@ -74,18 +76,20 @@ export async function bootstrapTunarr(
} }
await initDbDirectories(opts); await initDbDirectories(opts);
const db = getDatabase(); // Initialize the DB await DBContext.create(makeDatabaseConnection(), async () => {
const db = getDatabase(); // Initialize the DB
// not the first run, use the copy migrator // not the first run, use the copy migrator
if (hasTunarrDb) { if (hasTunarrDb) {
const migrationNecessary = await databaseNeedsMigration(db); const migrationNecessary = await databaseNeedsMigration(db);
if (migrationNecessary) { if (migrationNecessary) {
await migrateExistingDatabase(getDefaultDatabaseName()); await migrateExistingDatabase(getDefaultDatabaseName());
}
} else {
await syncMigrationTablesIfNecessary(db);
await runDBMigrations(db);
} }
} else { });
await syncMigrationTablesIfNecessary(db);
await runDBMigrations(db);
}
LoggerFactory.initialize(settingsDb); LoggerFactory.initialize(settingsDb);
} }

View File

@@ -2,11 +2,13 @@ import { container } from '@/container.js';
import { isProduction } from '@/util/index.js'; import { isProduction } from '@/util/index.js';
import { type MarkOptional } from 'ts-essentials'; import { type MarkOptional } from 'ts-essentials';
import type { ArgumentsCamelCase, CommandModule } from 'yargs'; import type { ArgumentsCamelCase, CommandModule } from 'yargs';
import { DBContext } from '../db/DBAccess.ts';
import { type ISettingsDB } from '../db/interfaces/ISettingsDB.ts'; import { type ISettingsDB } from '../db/interfaces/ISettingsDB.ts';
import { setServerOptions } from '../globals.ts'; import { setServerOptions } from '../globals.ts';
import { Server } from '../Server.ts'; import { Server } from '../Server.ts';
import { StartupService } from '../services/StartupService.ts'; import { StartupService } from '../services/StartupService.ts';
import { KEYS } from '../types/inject.ts'; import { KEYS } from '../types/inject.ts';
import { getDefaultDatabaseName } from '../util/defaults.ts';
import { import {
getBooleanEnvVar, getBooleanEnvVar,
getNumericEnvVar, getNumericEnvVar,
@@ -52,7 +54,10 @@ export const RunServerCommand: CommandModule<GlobalArgsType, ServerArgsType> = {
portSetting; portSetting;
// port precedence - env var -> argument -> settings // port precedence - env var -> argument -> settings
setServerOptions({ ...opts, port: portToUse }); setServerOptions({ ...opts, port: portToUse });
await container.get(StartupService).runStartupServices();
await container.get(Server).initAndRun(); await DBContext.createForName(getDefaultDatabaseName(), async () => {
await container.get(StartupService).runStartupServices();
await container.get(Server).initAndRun();
});
}, },
}; };

View File

@@ -1172,6 +1172,39 @@ export class ChannelDB implements IChannelDB {
await this.saveLineup(channelId, lineup); await this.saveLineup(channelId, lineup);
} }
async removeProgramsFromAllLineups(programIds: string[]): Promise<void> {
if (isEmpty(programIds)) {
return;
}
const lineups = await this.loadAllLineups();
const programsToRemove = new Set(programIds);
for (const [channelId, { lineup }] of Object.entries(lineups)) {
const newLineupItems: LineupItem[] = lineup.items.map((item) => {
switch (item.type) {
case 'content': {
if (programsToRemove.has(item.id)) {
return {
type: 'offline',
durationMs: item.durationMs,
};
}
return item;
}
case 'offline':
case 'redirect':
return item;
}
});
await this.saveLineup(channelId, {
...lineup,
items: newLineupItems,
});
}
}
private async createLineup(channelId: string) { private async createLineup(channelId: string) {
const db = await this.getFileDb(channelId); const db = await this.getFileDb(channelId);
await db.write(); await db.write();

View File

@@ -7,19 +7,32 @@ import {
drizzle, drizzle,
type BetterSQLite3Database, type BetterSQLite3Database,
} from 'drizzle-orm/better-sqlite3'; } from 'drizzle-orm/better-sqlite3';
import type {
IsolationLevel,
KyselyConfig,
KyselyProps,
Transaction,
} from 'kysely';
import { import {
CamelCasePlugin, CamelCasePlugin,
DefaultConnectionProvider,
DefaultQueryExecutor,
Kysely, Kysely,
Log,
Migrator, Migrator,
ParseJSONResultsPlugin, ParseJSONResultsPlugin,
RuntimeDriver,
SqliteDialect, SqliteDialect,
TransactionBuilder,
} from 'kysely'; } from 'kysely';
import { findIndex, has, isError, last, map, once, slice } from 'lodash-es'; import { findIndex, has, isError, last, map, once, slice } from 'lodash-es';
import { AsyncLocalStorage } from 'node:async_hooks';
import { DatabaseCopyMigrator } from '../migration/db/DatabaseCopyMigrator.ts'; import { DatabaseCopyMigrator } from '../migration/db/DatabaseCopyMigrator.ts';
import { import {
DirectMigrationProvider, DirectMigrationProvider,
LegacyMigrationNameToNewMigrationName, LegacyMigrationNameToNewMigrationName,
} from '../migration/DirectMigrationProvider.ts'; } from '../migration/DirectMigrationProvider.ts';
import type { Maybe } from '../types/util.ts';
import { getDefaultDatabaseName } from '../util/defaults.ts'; import { getDefaultDatabaseName } from '../util/defaults.ts';
import type { DB } from './schema/db.ts'; import type { DB } from './schema/db.ts';
@@ -30,6 +43,7 @@ export const MigrationLockTableName = 'migration_lock';
// let _directDbAccess: Kysely<DB>; // let _directDbAccess: Kysely<DB>;
type Conn = { type Conn = {
name: string;
kysely: Kysely<DB>; kysely: Kysely<DB>;
drizzle: BetterSQLite3Database; drizzle: BetterSQLite3Database;
}; };
@@ -38,55 +52,176 @@ const connections = new Map<string, Conn>();
const logger = once(() => LoggerFactory.child({ className: 'DirectDBAccess' })); const logger = once(() => LoggerFactory.child({ className: 'DirectDBAccess' }));
export class DBContext {
private static storage = new AsyncLocalStorage<DBContext>();
constructor(private connections: Map<string, Conn> = new Map()) {}
get db(): Maybe<Kysely<DB>> {
return this.getKyselyDatabase();
}
getConnection(name: string = getDefaultDatabaseName()): Maybe<Conn> {
return this.connections.get(name);
}
getKyselyDatabase(
name: string = getDefaultDatabaseName(),
): Maybe<Kysely<DB>> {
return this.getConnection(name)?.kysely;
}
getOrCreateKyselyDatabase(name: string): Kysely<DB> {
return this.getOrCreateConnection(name)?.kysely;
}
getOrCreateConnection(name: string): Conn {
const existing = this.connections.get(name);
if (existing) {
return existing;
}
const conn = makeDatabaseConnection(name);
this.connections.set(name, conn);
return conn;
}
setConnection(name: string) {
this.connections.set(name, makeDatabaseConnection(name));
}
static create<T>(context: Conn, next: (...args: unknown[]) => T) {
const m = new Map<string, Conn>([[context.name, context]]);
return this.storage.run(new DBContext(m), next);
}
static createForName<T>(name: string, next: (...args: unknown[]) => T) {
if (connections.has(name)) {
return this.create(connections.get(name)!, next);
}
return this.create(makeDatabaseConnection(name), next);
}
static enter(context: Conn) {
const m = new Map<string, Conn>([[context.name, context]]);
this.storage.enterWith(new DBContext(m));
}
static currentDBContext(): Maybe<DBContext> {
return this.storage.getStore();
}
}
class TransactionBuilderWrapper extends TransactionBuilder<DB> {
constructor(
private dbName: string,
props: KyselyProps & { isolationLevel?: IsolationLevel },
) {
super(props);
}
execute<T>(callback: (trx: Transaction<DB>) => Promise<T>): Promise<T> {
return super.execute((tx) => {
const curr = DBContext.currentDBContext()?.getConnection(this.dbName);
if (!curr) {
throw new Error('no DB context');
}
return DBContext.create({ ...curr, kysely: tx }, () => callback(tx));
});
}
}
class KyselyWrapper extends Kysely<DB> {
constructor(
private dbName: string,
private config: KyselyConfig,
) {
super(config);
}
transaction(): TransactionBuilder<DB> {
const driver = new RuntimeDriver(
this.config.dialect.createDriver(),
new Log(this.config.log ?? []),
);
return new TransactionBuilderWrapper(this.dbName, {
config: this.config,
dialect: this.config.dialect,
driver,
executor: new DefaultQueryExecutor(
this.config.dialect.createQueryCompiler(),
this.config.dialect.createAdapter(),
new DefaultConnectionProvider(driver),
this.config.plugins ?? [],
),
});
}
}
export function makeDatabaseConnection(
dbName: string = getDefaultDatabaseName(),
): Conn {
const dbConn = new Sqlite(dbName, {
timeout: 5000,
});
const kysely = new KyselyWrapper(dbName, {
dialect: new SqliteDialect({
database: dbConn,
}),
log: (event) => {
switch (event.level) {
case 'query':
if (
process.env['DATABASE_DEBUG_LOGGING'] ||
process.env['DIRECT_DATABASE_DEBUG_LOGGING']
) {
logger().setBindings({ db: dbName });
logger().debug(
'Query: %O (%d ms)',
event.query.sql,
event.queryDurationMillis,
);
}
return;
case 'error':
logger().setBindings({ db: dbName });
logger().error(
event.error,
'Query error: %O\n%O',
event.query.sql,
event.query.parameters,
);
return;
}
},
plugins: [new ParseJSONResultsPlugin(), new CamelCasePlugin()],
});
const drizzleConn = drizzle({
client: dbConn,
casing: 'snake_case',
});
const connection = { name: dbName, kysely, drizzle: drizzleConn };
connections.set(dbName, connection);
return connection;
}
export function getDatabaseContext() {
return DBContext.currentDBContext();
}
export const getDatabase = ( export const getDatabase = (
dbName: string = getDefaultDatabaseName(), dbName: string = getDefaultDatabaseName(),
forceInit: boolean = false, forceInit: boolean = false,
) => { ) => {
let conn = connections.get(dbName); if (forceInit) {
if (!conn || forceInit) { DBContext.enter(makeDatabaseConnection(dbName));
const dbConn = new Sqlite(dbName, {
timeout: 5000,
});
const kysely = new Kysely<DB>({
dialect: new SqliteDialect({
database: dbConn,
}),
log: (event) => {
switch (event.level) {
case 'query':
if (
process.env['DATABASE_DEBUG_LOGGING'] ||
process.env['DIRECT_DATABASE_DEBUG_LOGGING']
) {
logger().debug(
'Query: %O (%d ms)',
event.query.sql,
event.queryDurationMillis,
);
}
return;
case 'error':
logger().error(
event.error,
'Query error: %O\n%O',
event.query.sql,
event.query.parameters,
);
return;
}
},
plugins: [new ParseJSONResultsPlugin(), new CamelCasePlugin()],
});
const drizzleConn = drizzle({
client: dbConn,
casing: 'snake_case',
});
conn = { kysely, drizzle: drizzleConn };
} }
connections.set(dbName, conn); return DBContext.currentDBContext()!.getKyselyDatabase(dbName)!;
return conn.kysely;
}; };
export function getMigrator(db: Kysely<DB> = getDatabase()) { export function getMigrator(db: Kysely<DB> = getDatabase()) {
@@ -98,9 +233,7 @@ export function getMigrator(db: Kysely<DB> = getDatabase()) {
}); });
} }
export async function pendingDatabaseMigrations( export async function pendingDatabaseMigrations(db: Kysely<DB>) {
db: Kysely<DB> = getDatabase(),
) {
return lock.runExclusive(async () => { return lock.runExclusive(async () => {
const tables = await db.introspection.getTables({ const tables = await db.introspection.getTables({
withInternalKyselyTables: true, withInternalKyselyTables: true,
@@ -127,7 +260,7 @@ export async function pendingDatabaseMigrations(
}); });
} }
export async function databaseNeedsMigration(db: Kysely<DB> = getDatabase()) { export async function databaseNeedsMigration(db: Kysely<DB>) {
return (await pendingDatabaseMigrations(db)).length > 0; return (await pendingDatabaseMigrations(db)).length > 0;
} }
@@ -221,10 +354,7 @@ export async function syncMigrationTablesIfNecessary(
} }
} }
export async function runDBMigrations( export async function runDBMigrations(db: Kysely<DB>, migrateTo?: string) {
db: Kysely<DB> = getDatabase(),
migrateTo?: string,
) {
const _logger = logger(); const _logger = logger();
const migrator = getMigrator(db); const migrator = getMigrator(db);
const { error, results } = await (isNonEmptyString(migrateTo) const { error, results } = await (isNonEmptyString(migrateTo)
@@ -245,9 +375,7 @@ export async function runDBMigrations(
} }
// Runs through pending migrations, using the DB copier if necessary // Runs through pending migrations, using the DB copier if necessary
export async function migrateExistingDatabase( export async function migrateExistingDatabase(dbPath: string) {
dbPath: string = getDefaultDatabaseName(),
) {
const db = getDatabase(dbPath); const db = getDatabase(dbPath);
const pendingMigrations = await pendingDatabaseMigrations(db); const pendingMigrations = await pendingDatabaseMigrations(db);

View File

@@ -12,7 +12,7 @@ import {
type SavePlexProgramExternalIdsTaskFactory, type SavePlexProgramExternalIdsTaskFactory,
} from '@/tasks/plex/SavePlexProgramExternalIdsTask.js'; } from '@/tasks/plex/SavePlexProgramExternalIdsTask.js';
import { KEYS } from '@/types/inject.js'; import { KEYS } from '@/types/inject.js';
import { Maybe } from '@/types/util.js'; import { MarkNonNullable, Maybe } from '@/types/util.js';
import { Timer } from '@/util/Timer.js'; import { Timer } from '@/util/Timer.js';
import { devAssert } from '@/util/debug.js'; import { devAssert } from '@/util/debug.js';
import { type Logger } from '@/util/logging/LoggerFactory.js'; import { type Logger } from '@/util/logging/LoggerFactory.js';
@@ -25,7 +25,7 @@ import {
} from '@tunarr/types'; } from '@tunarr/types';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { inject, injectable } from 'inversify'; import { inject, injectable } from 'inversify';
import { CaseWhenBuilder, UpdateResult } from 'kysely'; import { CaseWhenBuilder, NotNull, UpdateResult } from 'kysely';
import { import {
chunk, chunk,
concat, concat,
@@ -35,6 +35,7 @@ import {
flatMap, flatMap,
forEach, forEach,
groupBy, groupBy,
head,
isEmpty, isEmpty,
isNil, isNil,
isNull, isNull,
@@ -52,6 +53,7 @@ import {
} from 'lodash-es'; } from 'lodash-es';
import { MarkOptional, MarkRequired } from 'ts-essentials'; import { MarkOptional, MarkRequired } from 'ts-essentials';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import { typedProperty } from '../types/path.ts';
import { getNumericEnvVar, TUNARR_ENV_VARS } from '../util/env.ts'; import { getNumericEnvVar, TUNARR_ENV_VARS } from '../util/env.ts';
import { import {
flatMapAsyncSeq, flatMapAsyncSeq,
@@ -70,7 +72,7 @@ import {
ProgramSourceType, ProgramSourceType,
programSourceTypeFromString, programSourceTypeFromString,
} from './custom_types/ProgramSourceType.ts'; } from './custom_types/ProgramSourceType.ts';
import { upsertRawProgramExternalIds } from './programExternalIdHelpers.ts'; import { upsertProgramExternalIds } from './programExternalIdHelpers.ts';
import { import {
AllProgramJoins, AllProgramJoins,
ProgramUpsertFields, ProgramUpsertFields,
@@ -84,7 +86,8 @@ import {
withTvShow, withTvShow,
} from './programQueryHelpers.ts'; } from './programQueryHelpers.ts';
import { import {
NewProgramDao as NewRawProgram, NewProgramDao,
ProgramDao,
programExternalIdString, programExternalIdString,
ProgramType, ProgramType,
ProgramDao as RawProgram, ProgramDao as RawProgram,
@@ -92,7 +95,7 @@ import {
import { import {
MinimalProgramExternalId, MinimalProgramExternalId,
NewProgramExternalId, NewProgramExternalId,
NewProgramExternalId as NewRawProgramExternalId, NewSingleOrMultiExternalId,
ProgramExternalId, ProgramExternalId,
ProgramExternalIdKeys, ProgramExternalIdKeys,
} from './schema/ProgramExternalId.ts'; } from './schema/ProgramExternalId.ts';
@@ -100,7 +103,10 @@ import {
NewProgramGrouping, NewProgramGrouping,
ProgramGroupingType, ProgramGroupingType,
} from './schema/ProgramGrouping.ts'; } from './schema/ProgramGrouping.ts';
import { NewProgramGroupingExternalId } from './schema/ProgramGroupingExternalId.ts'; import {
NewProgramGroupingExternalId,
toInsertableProgramGroupingExternalId,
} from './schema/ProgramGroupingExternalId.ts';
import { DB } from './schema/db.ts'; import { DB } from './schema/db.ts';
import type { import type {
ProgramGroupingWithExternalIds, ProgramGroupingWithExternalIds,
@@ -112,9 +118,9 @@ type ValidatedContentProgram = MarkRequired<
'externalSourceName' | 'externalSourceType' 'externalSourceName' | 'externalSourceType'
>; >;
type MintedRawProgramInfo = { type MintedNewProgramInfo = {
program: NewRawProgram; program: NewProgramDao;
externalIds: NewRawProgramExternalId[]; externalIds: NewSingleOrMultiExternalId[];
apiProgram: ValidatedContentProgram; apiProgram: ValidatedContentProgram;
}; };
@@ -549,12 +555,13 @@ export class ProgramDB implements IProgramDB {
// ); // );
// TODO: handle custom shows // TODO: handle custom shows
const programsToPersist: MintedRawProgramInfo[] = map( const programsToPersist: MintedNewProgramInfo[] = map(
contentPrograms, contentPrograms,
(p) => { (p) => {
const program = minter.contentProgramDtoToDao(p); const program = minter.contentProgramDtoToDao(p);
const externalIds = minter.mintExternalIds( const externalIds = minter.mintExternalIds(
p.externalSourceName, p.externalSourceName,
p.externalSourceId,
program.uuid, program.uuid,
p, p,
); );
@@ -572,7 +579,7 @@ export class ProgramDB implements IProgramDB {
// TODO: The way we deal with uniqueness right now makes a Program entity // TODO: The way we deal with uniqueness right now makes a Program entity
// exist 1:1 with its "external" entity, i.e. the same logical movie will // exist 1:1 with its "external" entity, i.e. the same logical movie will
// have duplicate entries in the DB across different servers and sources. // have duplicate entries in the DB across different servers and sources.
const upsertedPrograms: RawProgram[] = []; const upsertedPrograms: MarkNonNullable<ProgramDao, 'mediaSourceId'>[] = [];
await this.timer.timeAsync('programUpsert', async () => { await this.timer.timeAsync('programUpsert', async () => {
for (const c of chunk(programsToPersist, programUpsertBatchSize)) { for (const c of chunk(programsToPersist, programUpsertBatchSize)) {
upsertedPrograms.push( upsertedPrograms.push(
@@ -591,7 +598,17 @@ export class ProgramDB implements IProgramDB {
})), })),
), ),
) )
.onConflict((oc) =>
oc
.columns(['sourceType', 'mediaSourceId', 'externalKey'])
.doUpdateSet((eb) =>
mapToObj(ProgramUpsertFields, (f) => ({
[f.replace('excluded.', '')]: eb.ref(f),
})),
),
)
.returningAll() .returningAll()
.$narrowType<{ mediaSourceId: NotNull }>()
.execute(), .execute(),
)), )),
); );
@@ -624,7 +641,7 @@ export class ProgramDB implements IProgramDB {
// TODO: We could optimize further here by only saving IDs necessary for streaming // TODO: We could optimize further here by only saving IDs necessary for streaming
await this.timer.timeAsync( await this.timer.timeAsync(
`upsert ${requiredExternalIds.length} external ids`, `upsert ${requiredExternalIds.length} external ids`,
() => upsertRawProgramExternalIds(requiredExternalIds, 200), () => upsertProgramExternalIds(requiredExternalIds, 200),
// upsertProgramExternalIds_deprecated(requiredExternalIds), // upsertProgramExternalIds_deprecated(requiredExternalIds),
); );
@@ -650,7 +667,7 @@ export class ProgramDB implements IProgramDB {
AnonymousTask('UpsertExternalIds', () => AnonymousTask('UpsertExternalIds', () =>
this.timer.timeAsync( this.timer.timeAsync(
`background external ID upsert (${backgroundExternalIds.length} ids)`, `background external ID upsert (${backgroundExternalIds.length} ids)`,
() => upsertRawProgramExternalIds(backgroundExternalIds), () => upsertProgramExternalIds(backgroundExternalIds),
), ),
), ),
); );
@@ -667,18 +684,21 @@ export class ProgramDB implements IProgramDB {
} }
private async handleProgramGroupings( private async handleProgramGroupings(
upsertedPrograms: RawProgram[], upsertedPrograms: MarkNonNullable<ProgramDao, 'mediaSourceId'>[],
programInfos: Record<string, MintedRawProgramInfo>, programInfos: Record<string, MintedNewProgramInfo>,
) { ) {
const programsBySourceAndServer = mapValues( const programsBySourceAndServer = mapValues(
groupBy(upsertedPrograms, 'sourceType'), groupBy(upsertedPrograms, 'sourceType'),
(ps) => groupBy(ps, 'externalSourceId'), (ps) => groupBy(ps, typedProperty('mediaSourceId')),
); );
for (const [sourceType, byServerName] of Object.entries( for (const [sourceType, byServerId] of Object.entries(
programsBySourceAndServer, programsBySourceAndServer,
)) { )) {
for (const [serverName, programs] of Object.entries(byServerName)) { for (const [serverId, programs] of Object.entries(byServerId)) {
// Making an assumption that these are all the same... this field will
// go away soon anyway
const serverName = head(programs)!.externalSourceId;
// This is just extra safety because lodash erases the type in groupBy // This is just extra safety because lodash erases the type in groupBy
const typ = programSourceTypeFromString(sourceType); const typ = programSourceTypeFromString(sourceType);
if (!typ) { if (!typ) {
@@ -690,6 +710,7 @@ export class ProgramDB implements IProgramDB {
programInfos, programInfos,
typ, typ,
serverName, serverName,
serverId,
); );
} }
} }
@@ -697,8 +718,9 @@ export class ProgramDB implements IProgramDB {
private async handleSingleSourceProgramGroupings( private async handleSingleSourceProgramGroupings(
upsertedPrograms: RawProgram[], upsertedPrograms: RawProgram[],
programInfos: Record<string, MintedRawProgramInfo>, programInfos: Record<string, MintedNewProgramInfo>,
mediaSourceType: ProgramSourceType, mediaSourceType: ProgramSourceType,
mediaSourceName: string,
mediaSourceId: string, mediaSourceId: string,
) { ) {
const grandparentRatingKeyToParentRatingKey: Record< const grandparentRatingKeyToParentRatingKey: Record<
@@ -790,7 +812,7 @@ export class ProgramDB implements IProgramDB {
eb( eb(
'programGroupingExternalId.externalSourceId', 'programGroupingExternalId.externalSourceId',
'=', '=',
mediaSourceId, mediaSourceName,
), ),
eb( eb(
'programGroupingExternalId.externalKey', 'programGroupingExternalId.externalKey',
@@ -954,6 +976,7 @@ export class ProgramDB implements IProgramDB {
...ProgramGroupingMinter.mintGroupingExternalIds( ...ProgramGroupingMinter.mintGroupingExternalIds(
programs[0][1], programs[0][1],
parentGrouping.uuid, parentGrouping.uuid,
mediaSourceName,
mediaSourceId, mediaSourceId,
'parent', 'parent',
), ),
@@ -965,6 +988,7 @@ export class ProgramDB implements IProgramDB {
...ProgramGroupingMinter.mintGroupingExternalIds( ...ProgramGroupingMinter.mintGroupingExternalIds(
matchingPrograms[0][1], matchingPrograms[0][1],
grandparentGrouping.uuid, grandparentGrouping.uuid,
mediaSourceName,
mediaSourceId, mediaSourceId,
'grandparent', 'grandparent',
), ),
@@ -986,14 +1010,41 @@ export class ProgramDB implements IProgramDB {
if (!isEmpty(externalIds)) { if (!isEmpty(externalIds)) {
await this.timer.timeAsync('upsert program_grouping external ids', () => await this.timer.timeAsync('upsert program_grouping external ids', () =>
getDatabase() Promise.all(
.transaction() chunk(
.execute((tx) => externalIds.map(toInsertableProgramGroupingExternalId),
tx 100,
.insertInto('programGroupingExternalId') ).map((externalIds) =>
.values(externalIds) getDatabase()
.executeTakeFirstOrThrow(), .transaction()
.execute((tx) =>
tx
.insertInto('programGroupingExternalId')
.values(externalIds)
.onConflict((oc) =>
oc
.columns(['groupUuid', 'sourceType'])
.where('mediaSourceId', 'is', null)
.doUpdateSet((eb) => ({
updatedAt: eb.ref('excluded.updatedAt'),
externalFilePath: eb.ref('excluded.externalFilePath'),
groupUuid: eb.ref('excluded.groupUuid'),
})),
)
.onConflict((oc) =>
oc
.columns(['groupUuid', 'sourceType', 'mediaSourceId'])
.where('mediaSourceId', 'is not', null)
.doUpdateSet((eb) => ({
updatedAt: eb.ref('excluded.updatedAt'),
externalFilePath: eb.ref('excluded.externalFilePath'),
groupUuid: eb.ref('excluded.groupUuid'),
})),
)
.executeTakeFirstOrThrow(),
),
), ),
),
); );
} }
@@ -1138,7 +1189,7 @@ export class ProgramDB implements IProgramDB {
} }
} }
private schedulePlexExternalIdsTask(upsertedPrograms: NewRawProgram[]) { private schedulePlexExternalIdsTask(upsertedPrograms: ProgramDao[]) {
PlexTaskQueue.pause(); PlexTaskQueue.pause();
this.timer.timeSync('schedule Plex external IDs tasks', () => { this.timer.timeSync('schedule Plex external IDs tasks', () => {
forEach( forEach(
@@ -1168,7 +1219,7 @@ export class ProgramDB implements IProgramDB {
}); });
} }
private scheduleJellyfinExternalIdsTask(upsertedPrograms: NewRawProgram[]) { private scheduleJellyfinExternalIdsTask(upsertedPrograms: ProgramDao[]) {
JellyfinTaskQueue.pause(); JellyfinTaskQueue.pause();
this.timer.timeSync('Schedule Jellyfin external IDs tasks', () => { this.timer.timeSync('Schedule Jellyfin external IDs tasks', () => {
forEach( forEach(

View File

@@ -1,13 +1,12 @@
import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js'; import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
import type { NewProgramGroupingExternalId } 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 type { ContentProgram } from '@tunarr/types'; import type { ContentProgram } from '@tunarr/types';
import type { JellyfinItem } from '@tunarr/types/jellyfin'; import type { JellyfinItem } from '@tunarr/types/jellyfin';
import type { PlexEpisode, PlexMusicTrack } from '@tunarr/types/plex'; import type { PlexEpisode, PlexMusicTrack } from '@tunarr/types/plex';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { find, first } from 'lodash-es'; import { first } from 'lodash-es';
import type { MarkRequired } from 'ts-essentials'; import type { MarkRequired } from 'ts-essentials';
import { P, match } from 'ts-pattern';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import type { Nullable } from '../../types/util.ts'; import type { Nullable } from '../../types/util.ts';
import { import {
@@ -68,14 +67,15 @@ export class ProgramGroupingMinter {
program: ContentProgram, program: ContentProgram,
groupingId: string, groupingId: string,
externalSourceId: string, externalSourceId: string,
mediaSourceId: string,
relationType: 'parent' | 'grandparent', relationType: 'parent' | 'grandparent',
): NewProgramGroupingExternalId[] { ): NewSingleOrMultiProgramGroupingExternalId[] {
if (program.subtype === 'movie') { if (program.subtype === 'movie') {
return []; return [];
} }
const now = +dayjs(); const now = +dayjs();
const parentExternalIds: NewProgramGroupingExternalId[] = []; const parentExternalIds: NewSingleOrMultiProgramGroupingExternalId[] = [];
const ratingKey = const ratingKey =
relationType === 'grandparent' relationType === 'grandparent'
@@ -83,6 +83,7 @@ export class ProgramGroupingMinter {
: program.parent?.externalKey; : program.parent?.externalKey;
if (isNonEmptyString(ratingKey)) { if (isNonEmptyString(ratingKey)) {
parentExternalIds.push({ parentExternalIds.push({
type: 'multi',
uuid: v4(), uuid: v4(),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -90,6 +91,7 @@ export class ProgramGroupingMinter {
externalKey: ratingKey, externalKey: ratingKey,
sourceType: ProgramExternalIdType.PLEX, sourceType: ProgramExternalIdType.PLEX,
externalSourceId, externalSourceId,
mediaSourceId,
groupUuid: groupingId, groupUuid: groupingId,
}); });
} }
@@ -101,89 +103,13 @@ export class ProgramGroupingMinter {
); );
if (isNonEmptyString(guid)) { if (isNonEmptyString(guid)) {
parentExternalIds.push({ parentExternalIds.push({
type: 'single',
uuid: v4(), uuid: v4(),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
externalFilePath: null, externalFilePath: null,
externalKey: guid, externalKey: guid,
sourceType: ProgramExternalIdType.PLEX_GUID, sourceType: ProgramExternalIdType.PLEX_GUID,
externalSourceId: null,
groupUuid: groupingId,
});
}
return parentExternalIds;
}
static mintGroupingExternalIdsForPlex(
plexItem: PlexEpisode | PlexMusicTrack,
groupingId: string,
externalSourceId: string,
relationType: 'parent' | 'grandparent',
): NewProgramGroupingExternalId[] {
const now = +dayjs();
const parentExternalIds: NewProgramGroupingExternalId[] = [];
const ratingKey = plexItem[`${relationType}RatingKey`];
if (isNonEmptyString(ratingKey)) {
parentExternalIds.push({
uuid: v4(),
createdAt: now,
updatedAt: now,
externalFilePath: null,
externalKey: ratingKey,
sourceType: ProgramExternalIdType.PLEX,
externalSourceId,
groupUuid: groupingId,
});
}
const guid = plexItem[`${relationType}Guid`];
if (isNonEmptyString(guid)) {
parentExternalIds.push({
uuid: v4(),
createdAt: now,
updatedAt: now,
externalFilePath: null,
externalKey: guid,
sourceType: ProgramExternalIdType.PLEX_GUID,
externalSourceId: null,
groupUuid: groupingId,
});
}
return parentExternalIds;
}
static mintGroupingExternalIdsForJellyfin(
jellyfinItem: JellyfinItem,
groupingId: string,
externalSourceId: string,
relationType: 'parent' | 'grandparent',
): NewProgramGroupingExternalId[] {
const now = +dayjs();
const parentExternalIds: NewProgramGroupingExternalId[] = [];
const jellyfinId = match([jellyfinItem, relationType] as const)
.with([{ Type: 'Episode' }, 'grandparent'], () => jellyfinItem.SeriesId)
.with(
[{ Type: 'Audio' }, 'parent'],
() =>
find(jellyfinItem.AlbumArtists, { Name: jellyfinItem.AlbumArtist })
?.Id,
)
.with([P._, 'parent'], () => jellyfinItem.ParentId)
.otherwise(() => null);
if (isNonEmptyString(jellyfinId)) {
parentExternalIds.push({
uuid: v4(),
createdAt: now,
updatedAt: now,
externalFilePath: null,
externalKey: jellyfinId,
sourceType: ProgramExternalIdType.JELLYFIN,
externalSourceId,
groupUuid: groupingId, groupUuid: groupingId,
}); });
} }

View File

@@ -1,6 +1,9 @@
import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js'; import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
import { ProgramSourceType } from '@/db/custom_types/ProgramSourceType.js'; import { ProgramSourceType } from '@/db/custom_types/ProgramSourceType.js';
import type { NewProgramExternalId } from '@/db/schema/ProgramExternalId.js'; import type {
NewProgramExternalId,
NewSingleOrMultiExternalId,
} from '@/db/schema/ProgramExternalId.js';
import { seq } from '@tunarr/shared/util'; 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 type { JellyfinItem } from '@tunarr/types/jellyfin';
@@ -26,7 +29,9 @@ class ProgramDaoMinter {
return { return {
uuid: v4(), uuid: v4(),
sourceType: program.externalSourceType, sourceType: program.externalSourceType,
externalSourceId: program.externalSourceId ?? program.externalSourceName, // Deprecated
externalSourceId: program.externalSourceName,
mediaSourceId: program.externalSourceId,
externalKey: program.externalKey, externalKey: program.externalKey,
originalAirDate: program.date ?? null, originalAirDate: program.date ?? null,
duration: program.duration, duration: program.duration,
@@ -50,21 +55,24 @@ class ProgramDaoMinter {
mint( mint(
serverName: string, serverName: string,
serverId: string,
program: ContentProgramOriginalProgram, program: ContentProgramOriginalProgram,
): NewRawProgram { ): NewRawProgram {
const ret = match(program) const ret = match(program)
.with( .with(
{ sourceType: 'plex', program: { type: 'movie' } }, { sourceType: 'plex', program: { type: 'movie' } },
({ program: movie }) => this.mintProgramForPlexMovie(serverName, movie), ({ program: movie }) =>
this.mintProgramForPlexMovie(serverName, serverId, movie),
) )
.with( .with(
{ sourceType: 'plex', program: { type: 'episode' } }, { sourceType: 'plex', program: { type: 'episode' } },
({ program: episode }) => ({ program: episode }) =>
this.mintProgramForPlexEpisode(serverName, episode), this.mintProgramForPlexEpisode(serverName, serverId, episode),
) )
.with( .with(
{ sourceType: 'plex', program: { type: 'track' } }, { sourceType: 'plex', program: { type: 'track' } },
({ program: track }) => this.mintProgramForPlexTrack(serverName, track), ({ program: track }) =>
this.mintProgramForPlexTrack(serverName, serverId, track),
) )
.with( .with(
{ {
@@ -80,7 +88,8 @@ class ProgramDaoMinter {
), ),
}, },
}, },
({ program }) => this.mintProgramForJellyfinItem(serverName, program), ({ program }) =>
this.mintProgramForJellyfinItem(serverName, serverId, program),
) )
.otherwise(() => new Error('Unexpected program type')); .otherwise(() => new Error('Unexpected program type'));
if (isError(ret)) { if (isError(ret)) {
@@ -91,6 +100,7 @@ class ProgramDaoMinter {
private mintProgramForPlexMovie( private mintProgramForPlexMovie(
serverName: string, serverName: string,
serverId: string,
plexMovie: PlexMovie, plexMovie: PlexMovie,
): NewRawProgram { ): NewRawProgram {
const file = first(first(plexMovie.Media)?.Part ?? []); const file = first(first(plexMovie.Media)?.Part ?? []);
@@ -101,6 +111,7 @@ class ProgramDaoMinter {
duration: plexMovie.duration ?? 0, duration: plexMovie.duration ?? 0,
filePath: file?.file ?? null, filePath: file?.file ?? null,
externalSourceId: serverName, externalSourceId: serverName,
mediaSourceId: serverId,
externalKey: plexMovie.ratingKey, externalKey: plexMovie.ratingKey,
plexRatingKey: plexMovie.ratingKey, plexRatingKey: plexMovie.ratingKey,
plexFilePath: file?.key ?? null, plexFilePath: file?.key ?? null,
@@ -116,6 +127,7 @@ class ProgramDaoMinter {
private mintProgramForJellyfinItem( private mintProgramForJellyfinItem(
serverName: string, serverName: string,
serverId: string,
item: Omit<JellyfinItem, 'Type'> & { item: Omit<JellyfinItem, 'Type'> & {
Type: 'Movie' | 'Episode' | 'Audio' | 'Video' | 'MusicVideo' | 'Trailer'; Type: 'Movie' | 'Episode' | 'Audio' | 'Video' | 'MusicVideo' | 'Trailer';
}, },
@@ -128,6 +140,7 @@ class ProgramDaoMinter {
originalAirDate: item.PremiereDate, originalAirDate: item.PremiereDate,
duration: (item.RunTimeTicks ?? 0) / 10_000, duration: (item.RunTimeTicks ?? 0) / 10_000,
externalSourceId: serverName, externalSourceId: serverName,
mediaSourceId: serverId,
externalKey: item.Id, externalKey: item.Id,
rating: item.OfficialRating, rating: item.OfficialRating,
summary: item.Overview, summary: item.Overview,
@@ -154,6 +167,7 @@ class ProgramDaoMinter {
private mintProgramForPlexEpisode( private mintProgramForPlexEpisode(
serverName: string, serverName: string,
serverId: string,
plexEpisode: PlexEpisode, plexEpisode: PlexEpisode,
): NewRawProgram { ): NewRawProgram {
const file = first(first(plexEpisode.Media)?.Part ?? []); const file = first(first(plexEpisode.Media)?.Part ?? []);
@@ -166,6 +180,7 @@ class ProgramDaoMinter {
duration: plexEpisode.duration ?? 0, duration: plexEpisode.duration ?? 0,
filePath: file?.file, filePath: file?.file,
externalSourceId: serverName, externalSourceId: serverName,
mediaSourceId: serverId,
externalKey: plexEpisode.ratingKey, externalKey: plexEpisode.ratingKey,
plexRatingKey: plexEpisode.ratingKey, plexRatingKey: plexEpisode.ratingKey,
plexFilePath: file?.key, plexFilePath: file?.key,
@@ -185,6 +200,7 @@ class ProgramDaoMinter {
private mintProgramForPlexTrack( private mintProgramForPlexTrack(
serverName: string, serverName: string,
serverId: string,
plexTrack: PlexMusicTrack, plexTrack: PlexMusicTrack,
): NewRawProgram { ): NewRawProgram {
const file = first(first(plexTrack.Media)?.Part ?? []); const file = first(first(plexTrack.Media)?.Part ?? []);
@@ -196,6 +212,7 @@ class ProgramDaoMinter {
duration: plexTrack.duration ?? 0, duration: plexTrack.duration ?? 0,
filePath: file?.file, filePath: file?.file,
externalSourceId: serverName, externalSourceId: serverName,
mediaSourceId: serverId,
externalKey: plexTrack.ratingKey, externalKey: plexTrack.ratingKey,
plexRatingKey: plexTrack.ratingKey, plexRatingKey: plexTrack.ratingKey,
plexFilePath: file?.key, plexFilePath: file?.key,
@@ -216,32 +233,34 @@ class ProgramDaoMinter {
mintExternalIds( mintExternalIds(
serverName: string, serverName: string,
serverId: string,
programId: string, programId: string,
program: ContentProgram, program: ContentProgram,
// originalProgram: ContentProgramOriginalProgram, ): NewSingleOrMultiExternalId[] {
) {
return match(program) return match(program)
.with({ externalSourceType: 'plex' }, () => .with({ externalSourceType: 'plex' }, () =>
this.mintPlexExternalIds(serverName, programId, program), this.mintPlexExternalIds(serverName, serverId, programId, program),
) )
.with({ externalSourceType: 'jellyfin' }, () => .with({ externalSourceType: 'jellyfin' }, () =>
this.mintJellyfinExternalIds(serverName, programId, program), this.mintJellyfinExternalIds(serverName, serverId, programId, program),
) )
.with({ externalSourceType: 'emby' }, () => .with({ externalSourceType: 'emby' }, () =>
this.mintEmbyExternalIds(serverName, programId, program), this.mintEmbyExternalIds(serverName, serverId, programId, program),
) )
.exhaustive(); .exhaustive();
} }
mintPlexExternalIds( mintPlexExternalIds(
serverName: string, serverName: string,
serverId: string,
programId: string, programId: string,
program: ContentProgram, program: ContentProgram,
): NewProgramExternalId[] { ): NewSingleOrMultiExternalId[] {
const now = +dayjs(); const now = +dayjs();
const ids: NewProgramExternalId[] = [ const ids: NewSingleOrMultiExternalId[] = [
{ {
type: 'multi',
uuid: v4(), uuid: v4(),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -249,6 +268,7 @@ class ProgramDaoMinter {
sourceType: ProgramExternalIdType.PLEX, sourceType: ProgramExternalIdType.PLEX,
programUuid: programId, programUuid: programId,
externalSourceId: serverName, externalSourceId: serverName,
mediaSourceId: serverId,
externalFilePath: program.serverFileKey, externalFilePath: program.serverFileKey,
directFilePath: program.serverFilePath, directFilePath: program.serverFilePath,
}, },
@@ -257,6 +277,7 @@ class ProgramDaoMinter {
const plexGuid = find(program.externalIds, { source: 'plex-guid' }); const plexGuid = find(program.externalIds, { source: 'plex-guid' });
if (plexGuid) { if (plexGuid) {
ids.push({ ids.push({
type: 'single',
uuid: v4(), uuid: v4(),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -273,13 +294,14 @@ class ProgramDaoMinter {
case 'imdb': case 'imdb':
case 'tvdb': case 'tvdb':
return { return {
type: 'single',
uuid: v4(), uuid: v4(),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
externalKey: eid.id, externalKey: eid.id,
sourceType: eid.source, sourceType: eid.source,
programUuid: programId, programUuid: programId,
} satisfies NewProgramExternalId; } satisfies NewSingleOrMultiExternalId;
default: default:
return null; return null;
} }
@@ -307,12 +329,14 @@ class ProgramDaoMinter {
mintJellyfinExternalIds( mintJellyfinExternalIds(
serverName: string, serverName: string,
serverId: string,
programId: string, programId: string,
program: ContentProgram, program: ContentProgram,
) { ) {
const now = +dayjs(); const now = +dayjs();
const ids: NewProgramExternalId[] = [ const ids: NewSingleOrMultiExternalId[] = [
{ {
type: 'multi',
uuid: v4(), uuid: v4(),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -320,6 +344,7 @@ class ProgramDaoMinter {
sourceType: ProgramExternalIdType.JELLYFIN, sourceType: ProgramExternalIdType.JELLYFIN,
programUuid: programId, programUuid: programId,
externalSourceId: serverName, externalSourceId: serverName,
mediaSourceId: serverId,
externalFilePath: program.serverFileKey, externalFilePath: program.serverFileKey,
directFilePath: program.serverFilePath, directFilePath: program.serverFilePath,
}, },
@@ -332,13 +357,14 @@ class ProgramDaoMinter {
case 'imdb': case 'imdb':
case 'tvdb': case 'tvdb':
return { return {
type: 'single',
uuid: v4(), uuid: v4(),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
externalKey: eid.id, externalKey: eid.id,
sourceType: eid.source, sourceType: eid.source,
programUuid: programId, programUuid: programId,
} satisfies NewProgramExternalId; } satisfies NewSingleOrMultiExternalId;
default: default:
return null; return null;
} }
@@ -350,12 +376,14 @@ class ProgramDaoMinter {
mintEmbyExternalIds( mintEmbyExternalIds(
serverName: string, serverName: string,
serverId: string,
programId: string, programId: string,
program: ContentProgram, program: ContentProgram,
) { ) {
const now = +dayjs(); const now = +dayjs();
const ids: NewProgramExternalId[] = [ const ids: NewSingleOrMultiExternalId[] = [
{ {
type: 'multi',
uuid: v4(), uuid: v4(),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -363,6 +391,7 @@ class ProgramDaoMinter {
sourceType: ProgramExternalIdType.EMBY, sourceType: ProgramExternalIdType.EMBY,
programUuid: programId, programUuid: programId,
externalSourceId: serverName, externalSourceId: serverName,
mediaSourceId: serverId,
externalFilePath: program.serverFileKey, externalFilePath: program.serverFileKey,
directFilePath: program.serverFilePath, directFilePath: program.serverFilePath,
}, },
@@ -375,13 +404,14 @@ class ProgramDaoMinter {
case 'imdb': case 'imdb':
case 'tvdb': case 'tvdb':
return { return {
type: 'single',
uuid: v4(), uuid: v4(),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
externalKey: eid.id, externalKey: eid.id,
sourceType: eid.source, sourceType: eid.source,
programUuid: programId, programUuid: programId,
} satisfies NewProgramExternalId; } satisfies NewSingleOrMultiExternalId;
default: default:
return null; return null;
} }

View File

@@ -93,6 +93,8 @@ export interface IChannelDB {
programIds: string[], programIds: string[],
): Promise<void>; ): Promise<void>;
removeProgramsFromAllLineups(programIds: string[]): Promise<void>;
loadAllLineupConfigs( loadAllLineupConfigs(
forceRead?: boolean, forceRead?: boolean,
): Promise<Record<string, ChannnelAndLineup>>; ): Promise<Record<string, ChannnelAndLineup>>;

View File

@@ -117,32 +117,36 @@ export class MediaSourceDB {
.executeTakeFirst(); .executeTakeFirst();
} }
async deleteMediaSource(id: string, removePrograms: boolean = true) { 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}`);
} }
// This should cascade all relevant deletes across the DB
await getDatabase() await getDatabase()
.deleteFrom('mediaSource') .transaction()
.where('uuid', '=', id) .execute(async (tx) => {
// TODO: Blocked on https://github.com/oven-sh/bun/issues/16909 const relatedProgramIds = await tx
// .limit(1) .selectFrom('program')
.execute(); .where('program.mediaSourceId', '=', id)
.select('uuid')
.execute()
.then((_) => _.map(({ uuid }) => uuid));
let reports: Report[]; await tx
if (!removePrograms) { .deleteFrom('mediaSource')
reports = []; .where('uuid', '=', id)
} else { .limit(1)
reports = await this.fixupProgramReferences( .execute();
deletedServer.name, // TODO: Update lineups
deletedServer.type,
); await this.channelDb.removeProgramsFromAllLineups(relatedProgramIds);
} });
this.mediaSourceApiFactory().deleteCachedClient(deletedServer); this.mediaSourceApiFactory().deleteCachedClient(deletedServer);
return { deletedServer, reports }; return { deletedServer };
} }
async updateMediaSource(server: UpdateMediaSourceRequest) { async updateMediaSource(server: UpdateMediaSourceRequest) {
@@ -155,9 +159,9 @@ export class MediaSourceDB {
} }
const sendGuideUpdates = const sendGuideUpdates =
server.type === 'plex' ? server.sendGuideUpdates ?? false : false; server.type === 'plex' ? (server.sendGuideUpdates ?? false) : false;
const sendChannelUpdates = const sendChannelUpdates =
server.type === 'plex' ? server.sendChannelUpdates ?? false : false; server.type === 'plex' ? (server.sendChannelUpdates ?? false) : false;
await getDatabase() await getDatabase()
.updateTable('mediaSource') .updateTable('mediaSource')
@@ -188,9 +192,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 = 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 =
server.type === 'plex' ? server.sendChannelUpdates ?? false : false; server.type === 'plex' ? (server.sendChannelUpdates ?? false) : false;
const index = await getDatabase() const index = await getDatabase()
.selectFrom('mediaSource') .selectFrom('mediaSource')
.select((eb) => eb.fn.count<number>('uuid').as('count')) .select((eb) => eb.fn.count<number>('uuid').as('count'))
@@ -218,38 +222,6 @@ export class MediaSourceDB {
return newServer?.uuid; return newServer?.uuid;
} }
// private async removeDanglingPrograms(mediaSource: MediaSource) {
// const knownProgramIds = await directDbAccess()
// .selectFrom('programExternalId as p1')
// .where(({ eb, and }) =>
// and([
// eb('p1.externalSourceId', '=', mediaSource.name),
// eb('p1.sourceType', '=', mediaSource.type),
// ]),
// )
// .selectAll('p1')
// .select((eb) =>
// jsonArrayFrom(
// eb
// .selectFrom('programExternalId as p2')
// .whereRef('p2.programUuid', '=', 'p1.programUuid')
// .whereRef('p2.uuid', '!=', 'p1.uuid')
// .select(['p2.sourceType', 'p2.externalSourceId', 'p2.externalKey']),
// ).as('otherExternalIds'),
// )
// .groupBy('p1.uuid')
// .execute();
// const mediaSourceTypes = map(enumValues(MediaSourceType), (typ) =>
// typ.toString(),
// );
// const danglingPrograms = reject(knownProgramIds, (program) => {
// some(program.otherExternalIds, (eid) =>
// mediaSourceTypes.includes(eid.sourceType),
// );
// });
// }
private async fixupProgramReferences( private async fixupProgramReferences(
serverName: string, serverName: string,
serverType: MediaSourceType, serverType: MediaSourceType,
@@ -350,8 +322,8 @@ export class MediaSourceDB {
id, id,
channelNumber: number, channelNumber: number,
channelName: name, channelName: name,
destroyedPrograms: isUpdate ? 0 : channelToProgramCount[id] ?? 0, destroyedPrograms: isUpdate ? 0 : (channelToProgramCount[id] ?? 0),
modifiedPrograms: isUpdate ? channelToProgramCount[id] ?? 0 : 0, modifiedPrograms: isUpdate ? (channelToProgramCount[id] ?? 0) : 0,
} as Report; } as Report;
}, },
); );
@@ -359,15 +331,15 @@ export class MediaSourceDB {
const fillerReports: Report[] = map(fillersById, ({ uuid }) => ({ const fillerReports: Report[] = map(fillersById, ({ uuid }) => ({
type: 'filler', type: 'filler',
id: uuid, id: uuid,
destroyedPrograms: isUpdate ? 0 : fillerToProgramCount[uuid] ?? 0, destroyedPrograms: isUpdate ? 0 : (fillerToProgramCount[uuid] ?? 0),
modifiedPrograms: isUpdate ? fillerToProgramCount[uuid] ?? 0 : 0, modifiedPrograms: isUpdate ? (fillerToProgramCount[uuid] ?? 0) : 0,
})); }));
const customShowReports: Report[] = map(customShowById, ({ uuid }) => ({ const customShowReports: Report[] = map(customShowById, ({ uuid }) => ({
type: 'custom-show', type: 'custom-show',
id: uuid, id: uuid,
destroyedPrograms: isUpdate ? 0 : customShowToProgramCount[uuid] ?? 0, destroyedPrograms: isUpdate ? 0 : (customShowToProgramCount[uuid] ?? 0),
modifiedPrograms: isUpdate ? customShowToProgramCount[uuid] ?? 0 : 0, modifiedPrograms: isUpdate ? (customShowToProgramCount[uuid] ?? 0) : 0,
})); }));
return [...channelReports, ...fillerReports, ...customShowReports]; return [...channelReports, ...fillerReports, ...customShowReports];

View File

@@ -1,12 +1,14 @@
import { mapAsyncSeq } from '@/util/index.js'; import { mapAsyncSeq } from '@/util/index.js';
import { LoggerFactory } from '@/util/logging/LoggerFactory.js'; import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
import { isValidSingleExternalIdType } from '@tunarr/types/schemas'; import { chunk, flatten, isEmpty, partition } from 'lodash-es';
import { chunk, flatten, isEmpty, isUndefined, partition } from 'lodash-es';
import { getDatabase } from './DBAccess.ts'; import { getDatabase } from './DBAccess.ts';
import type { NewProgramExternalId as NewRawProgramExternalId } from './schema/ProgramExternalId.ts'; import {
toInsertableProgramExternalId,
type NewSingleOrMultiExternalId,
} from './schema/ProgramExternalId.ts';
export const upsertRawProgramExternalIds = async ( export const upsertProgramExternalIds = async (
externalIds: NewRawProgramExternalId[], externalIds: NewSingleOrMultiExternalId[],
chunkSize: number = 100, chunkSize: number = 100,
) => { ) => {
if (isEmpty(externalIds)) { if (isEmpty(externalIds)) {
@@ -17,9 +19,7 @@ export const upsertRawProgramExternalIds = async (
const [singles, multiples] = partition( const [singles, multiples] = partition(
externalIds, externalIds,
(id) => (id) => id.type === 'single',
isValidSingleExternalIdType(id.sourceType) &&
isUndefined(id.externalSourceId),
); );
let singleIdPromise: Promise<{ uuid: string }[]>; let singleIdPromise: Promise<{ uuid: string }[]>;
@@ -30,7 +30,7 @@ export const upsertRawProgramExternalIds = async (
.execute((tx) => .execute((tx) =>
tx tx
.insertInto('programExternalId') .insertInto('programExternalId')
.values(singleChunk) .values(singleChunk.map(toInsertableProgramExternalId))
.onConflict((oc) => .onConflict((oc) =>
oc oc
.columns(['programUuid', 'sourceType']) .columns(['programUuid', 'sourceType'])
@@ -42,6 +42,17 @@ export const upsertRawProgramExternalIds = async (
programUuid: eb.ref('excluded.programUuid'), programUuid: eb.ref('excluded.programUuid'),
})), })),
) )
.onConflict((oc) =>
oc
.columns(['programUuid', 'sourceType'])
.where('mediaSourceId', 'is', null)
.doUpdateSet((eb) => ({
updatedAt: eb.ref('excluded.updatedAt'),
externalFilePath: eb.ref('excluded.externalFilePath'),
directFilePath: eb.ref('excluded.directFilePath'),
programUuid: eb.ref('excluded.programUuid'),
})),
)
.returning('uuid as uuid') .returning('uuid as uuid')
.execute(), .execute(),
); );
@@ -58,7 +69,7 @@ export const upsertRawProgramExternalIds = async (
.execute((tx) => .execute((tx) =>
tx tx
.insertInto('programExternalId') .insertInto('programExternalId')
.values(multiChunk) .values(multiChunk.map(toInsertableProgramExternalId))
.onConflict((oc) => .onConflict((oc) =>
oc oc
.columns(['programUuid', 'sourceType', 'externalSourceId']) .columns(['programUuid', 'sourceType', 'externalSourceId'])
@@ -70,6 +81,17 @@ export const upsertRawProgramExternalIds = async (
programUuid: eb.ref('excluded.programUuid'), programUuid: eb.ref('excluded.programUuid'),
})), })),
) )
.onConflict((oc) =>
oc
.columns(['programUuid', 'sourceType', 'mediaSourceId'])
.where('mediaSourceId', 'is not', null)
.doUpdateSet((eb) => ({
updatedAt: eb.ref('excluded.updatedAt'),
externalFilePath: eb.ref('excluded.externalFilePath'),
directFilePath: eb.ref('excluded.directFilePath'),
programUuid: eb.ref('excluded.programUuid'),
})),
)
.returning('uuid as uuid') .returning('uuid as uuid')
.execute(), .execute(),
); );

View File

@@ -10,8 +10,9 @@ import {
uniqueIndex, uniqueIndex,
} 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 { MarkNotNilable } from '../../types/util.ts';
import { type KyselifyBetter } from './KyselifyBetter.ts'; import { type KyselifyBetter } from './KyselifyBetter.ts';
import { MediaSourceTypes } from './MediaSource.ts'; import { MediaSource, MediaSourceTypes } from './MediaSource.ts';
import { ProgramGrouping } from './ProgramGrouping.ts'; import { ProgramGrouping } from './ProgramGrouping.ts';
export const ProgramTypes = ['movie', 'episode', 'track'] as const; export const ProgramTypes = ['movie', 'episode', 'track'] as const;
@@ -37,6 +38,9 @@ export const Program = sqliteTable(
episodeIcon: text(), episodeIcon: text(),
externalKey: text().notNull(), externalKey: text().notNull(),
externalSourceId: text().notNull(), externalSourceId: text().notNull(),
mediaSourceId: text().references(() => MediaSource.uuid, {
onDelete: 'cascade',
}),
filePath: text(), filePath: text(),
grandparentExternalKey: text(), grandparentExternalKey: text(),
icon: text(), icon: text(),
@@ -78,7 +82,10 @@ export const Program = sqliteTable(
export type ProgramTable = KyselifyBetter<typeof Program>; export type ProgramTable = KyselifyBetter<typeof Program>;
export type ProgramDao = Selectable<ProgramTable>; export type ProgramDao = Selectable<ProgramTable>;
export type NewProgramDao = Insertable<ProgramTable>; export type NewProgramDao = MarkNotNilable<
Insertable<ProgramTable>,
'mediaSourceId'
>;
export type ProgramDaoUpdate = Updateable<ProgramTable>; export type ProgramDaoUpdate = Updateable<ProgramTable>;
export function programExternalIdString(p: ProgramDao | NewProgramDao) { export function programExternalIdString(p: ProgramDao | NewProgramDao) {

View File

@@ -8,9 +8,12 @@ import {
uniqueIndex, uniqueIndex,
} from 'drizzle-orm/sqlite-core'; } from 'drizzle-orm/sqlite-core';
import type { Insertable, Selectable } from 'kysely'; import type { Insertable, Selectable } from 'kysely';
import type { MarkRequired } from 'ts-essentials'; import { omit } from 'lodash-es';
import type { MarkRequired, StrictOmit } from 'ts-essentials';
import type { MarkNotNilable } from '../../types/util.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 { Program } from './Program.ts'; import { Program } from './Program.ts';
export const ProgramExternalId = sqliteTable( export const ProgramExternalId = sqliteTable(
@@ -23,9 +26,12 @@ export const ProgramExternalId = sqliteTable(
externalFilePath: text(), externalFilePath: text(),
externalKey: text().notNull(), externalKey: text().notNull(),
externalSourceId: text(), externalSourceId: text(),
mediaSourceId: text().references(() => MediaSource.uuid, {
onDelete: 'cascade',
}),
programUuid: text() programUuid: text()
.notNull() .notNull()
.references(() => Program.uuid), .references(() => Program.uuid, { onDelete: 'cascade' }),
sourceType: text({ enum: ProgramExternalIdSourceTypes }).notNull(), sourceType: text({ enum: ProgramExternalIdSourceTypes }).notNull(),
}, },
(table) => [ (table) => [
@@ -46,6 +52,24 @@ export const ProgramExternalId = sqliteTable(
export type ProgramExternalIdTable = KyselifyBetter<typeof ProgramExternalId>; export type ProgramExternalIdTable = KyselifyBetter<typeof ProgramExternalId>;
export type ProgramExternalId = Selectable<ProgramExternalIdTable>; export type ProgramExternalId = Selectable<ProgramExternalIdTable>;
export type NewProgramExternalId = Insertable<ProgramExternalIdTable>; export type NewProgramExternalId = Insertable<ProgramExternalIdTable>;
export type NewSingleOrMultiExternalId =
| (StrictOmit<
Insertable<ProgramExternalIdTable>,
'mediaSourceId' | 'externalSourceId'
> & { type: 'single' })
| (MarkNotNilable<
Insertable<ProgramExternalIdTable>,
'mediaSourceId' | 'externalSourceId'
> & { type: 'multi' });
export function toInsertableProgramExternalId(
extraInfo: NewSingleOrMultiExternalId,
): NewProgramExternalId {
return omit(extraInfo, 'type') satisfies Omit<
NewSingleOrMultiExternalId,
'type'
>;
}
export type MinimalProgramExternalId = MarkRequired< export type MinimalProgramExternalId = MarkRequired<
Partial<ProgramExternalId>, Partial<ProgramExternalId>,

View File

@@ -7,8 +7,12 @@ import {
text, text,
} from 'drizzle-orm/sqlite-core'; } from 'drizzle-orm/sqlite-core';
import type { Insertable, Selectable } from 'kysely'; import type { Insertable, Selectable } from 'kysely';
import { omit } from 'lodash-es';
import type { StrictOmit } from 'ts-essentials';
import type { MarkNotNilable } from '../../types/util.ts';
import { 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 { ProgramGrouping } from './ProgramGrouping.ts'; import { ProgramGrouping } from './ProgramGrouping.ts';
export const ProgramGroupingExternalId = sqliteTable( export const ProgramGroupingExternalId = sqliteTable(
@@ -20,6 +24,9 @@ export const ProgramGroupingExternalId = sqliteTable(
externalFilePath: text(), externalFilePath: text(),
externalKey: text().notNull(), externalKey: text().notNull(),
externalSourceId: text(), externalSourceId: text(),
mediaSourceId: text().references(() => MediaSource.uuid, {
onDelete: 'cascade',
}),
groupUuid: text() groupUuid: text()
.notNull() .notNull()
.references(() => ProgramGrouping.uuid, { .references(() => ProgramGrouping.uuid, {
@@ -45,6 +52,21 @@ export type ProgramGroupingExternalId =
Selectable<ProgramGroupingExternalIdTable>; Selectable<ProgramGroupingExternalIdTable>;
export type NewProgramGroupingExternalId = export type NewProgramGroupingExternalId =
Insertable<ProgramGroupingExternalIdTable>; Insertable<ProgramGroupingExternalIdTable>;
export type NewSingleOrMultiProgramGroupingExternalId =
| (StrictOmit<
Insertable<ProgramGroupingExternalIdTable>,
'externalSourceId' | 'mediaSourceId'
> & { type: 'single' })
| (MarkNotNilable<
Insertable<ProgramGroupingExternalIdTable>,
'externalSourceId' | 'mediaSourceId'
> & { type: 'multi' });
export function toInsertableProgramGroupingExternalId(
eid: NewSingleOrMultiProgramGroupingExternalId,
): NewProgramGroupingExternalId {
return omit(eid, 'type') satisfies NewProgramGroupingExternalId;
}
export type ProgramGroupingExternalIdFields< export type ProgramGroupingExternalIdFields<
Alias extends string = 'ProgramGroupingExternalId', Alias extends string = 'ProgramGroupingExternalId',

View File

@@ -8,7 +8,7 @@ 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 } from '@/util/index.js'; import { groupByUniq, isDefined, 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 dayjs from 'dayjs'; import dayjs from 'dayjs';
@@ -19,6 +19,8 @@ import {
ProgramExternalIdType, ProgramExternalIdType,
programExternalIdTypeFromJellyfinProvider, programExternalIdTypeFromJellyfinProvider,
} from '../../db/custom_types/ProgramExternalIdType.ts'; } from '../../db/custom_types/ProgramExternalIdType.ts';
import { MediaSourceDB } from '../../db/mediaSourceDB.ts';
import { MediaSourceType } from '../../db/schema/MediaSource.ts';
import { JellyfinGetItemsQuery } from './JellyfinApiClient.ts'; import { JellyfinGetItemsQuery } from './JellyfinApiClient.ts';
@injectable() @injectable()
@@ -28,6 +30,7 @@ export class JellyfinItemFinder {
@inject(KEYS.Logger) private logger: Logger, @inject(KEYS.Logger) private logger: Logger,
@inject(MediaSourceApiFactory) @inject(MediaSourceApiFactory)
private mediaSourceApiFactory: MediaSourceApiFactory, private mediaSourceApiFactory: MediaSourceApiFactory,
@inject(MediaSourceDB) private mediaSourceDB: MediaSourceDB,
) {} ) {}
async findForProgramAndUpdate(programId: string) { async findForProgramAndUpdate(programId: string) {
@@ -65,10 +68,24 @@ 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 updatedProgram = minter.mint(program.externalSourceId, { const mediaSourceId =
sourceType: 'jellyfin', program.mediaSourceId ??
program: potentialApiMatch, (await run(async () => {
}); const ms = await this.findMediaSource(program.externalSourceId);
if (!ms)
throw new Error(
`Could not find media source by name: ${program.externalSourceId}`,
);
return ms.uuid;
}));
const updatedProgram = minter.mint(
program.externalSourceId,
mediaSourceId,
{
sourceType: 'jellyfin',
program: potentialApiMatch,
},
);
if (updatedProgram.duration !== program.duration) { if (updatedProgram.duration !== program.duration) {
await this.programDB.updateProgramDuration( await this.programDB.updateProgramDuration(
@@ -207,4 +224,11 @@ export class JellyfinItemFinder {
return possibleMatch; return possibleMatch;
} }
private findMediaSource(mediaSourceName: string) {
return this.mediaSourceDB.findByType(
MediaSourceType.Jellyfin,
mediaSourceName,
);
}
} }

View File

@@ -69,6 +69,10 @@ export class PlexApiClient extends BaseApiClient {
return this.opts.name; return this.opts.name;
} }
get serverId() {
return this.opts.uuid;
}
getFullUrl(path: string): string { getFullUrl(path: string): string {
const url = super.getFullUrl(path); const url = super.getFullUrl(path);
const parsed = new URL(url); const parsed = new URL(url);

View File

@@ -23,6 +23,7 @@ import Migration1730806741 from './db/Migration1730806741.ts';
import Migration1731982492 from './db/Migration1731982492.ts'; import Migration1731982492 from './db/Migration1731982492.ts';
import Migration1732969335_AddTranscodeConfig from './db/Migration1732969335_AddTranscodeConfig.ts'; import Migration1732969335_AddTranscodeConfig from './db/Migration1732969335_AddTranscodeConfig.ts';
import Migration1738604866_AddEmby from './db/Migration1738604866_AddEmby.ts'; import Migration1738604866_AddEmby from './db/Migration1738604866_AddEmby.ts';
import Migration1740691984_ProgramMediaSourceId from './db/Migration1740691984_ProgramMediaSourceId.ts';
export const LegacyMigrationNameToNewMigrationName = [ export const LegacyMigrationNameToNewMigrationName = [
['Migration20240124115044', '_Legacy_Migration00'], ['Migration20240124115044', '_Legacy_Migration00'],
@@ -90,6 +91,7 @@ export class DirectMigrationProvider implements MigrationProvider {
migration1732969335: Migration1732969335_AddTranscodeConfig, migration1732969335: Migration1732969335_AddTranscodeConfig,
migration1735044379: Migration1735044379_AddHlsDirect, migration1735044379: Migration1735044379_AddHlsDirect,
migration1738604866: Migration1738604866_AddEmby, migration1738604866: Migration1738604866_AddEmby,
migration1740691984: Migration1740691984_ProgramMediaSourceId,
}, },
wrapWithTransaction, wrapWithTransaction,
), ),

View File

@@ -1,10 +1,10 @@
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { type Kysely, sql } from 'kysely'; import { type Kysely, sql } from 'kysely';
import { trimEnd } from 'lodash-es'; import { replace } from 'lodash-es';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import tmp from 'tmp-promise'; import tmp from 'tmp-promise';
import { import {
getDatabase, getDatabaseContext,
MigrationLockTableName, MigrationLockTableName,
MigrationTableName, MigrationTableName,
runDBMigrations, runDBMigrations,
@@ -23,10 +23,11 @@ export class DatabaseCopyMigrator {
async migrate(currentDbPath: string, migrateTo?: string) { async migrate(currentDbPath: string, migrateTo?: string) {
const { path: tmpPath } = await tmp.file({ keep: false }); const { path: tmpPath } = await tmp.file({ keep: false });
this.logger.debug('Migrating to temp DB %s', tmpPath); this.logger.debug('Migrating to temp DB %s', tmpPath);
const tempDB = getDatabase(tmpPath); const tempDB = getDatabaseContext()!.getOrCreateKyselyDatabase(tmpPath);
await runDBMigrations(tempDB, migrateTo); await runDBMigrations(tempDB, migrateTo);
const oldDB = getDatabase(currentDbPath); const oldDB =
getDatabaseContext()!.getOrCreateKyselyDatabase(currentDbPath);
const oldTables = await this.getTables(oldDB); const oldTables = await this.getTables(oldDB);
const newTables = await this.getTables(tempDB); const newTables = await this.getTables(tempDB);
// Prepare for copy. // Prepare for copy.
@@ -48,11 +49,11 @@ export class DatabaseCopyMigrator {
continue; continue;
} }
const columnUnion = new Set(table.columns.map((col) => col.name)).union( const columnIntersection = new Set(
new Set(newTable.columns.map((col) => col.name)), table.columns.map((col) => col.name),
); ).intersection(new Set(newTable.columns.map((col) => col.name)));
const colNames = [...columnUnion].sort(); const colNames = [...columnIntersection].sort();
await sql`INSERT INTO ${sql.table(table.name)} (${sql.join(colNames.map((n) => sql.ref(n)))}) SELECT ${sql.join(colNames.map((n) => sql.ref(n)))} FROM ${sql.ref('old')}.${sql.table(table.name)} WHERE true ON CONFLICT DO NOTHING;`.execute( await sql`INSERT INTO ${sql.table(table.name)} (${sql.join(colNames.map((n) => sql.ref(n)))}) SELECT ${sql.join(colNames.map((n) => sql.ref(n)))} FROM ${sql.ref('old')}.${sql.table(table.name)} WHERE true ON CONFLICT DO NOTHING;`.execute(
tempDB, tempDB,
); );
@@ -63,11 +64,11 @@ export class DatabaseCopyMigrator {
await fs.copyFile( await fs.copyFile(
currentDbPath, currentDbPath,
`${trimEnd(currentDbPath, '.db')}-${+dayjs()}.bak`, `${replace(currentDbPath, '.db', '')}-${+dayjs()}.bak`,
); );
await fs.cp(tmpPath, currentDbPath); await fs.cp(tmpPath, currentDbPath);
// Force reinit at the new path // Force reinit at the new path
getDatabase(currentDbPath, true); getDatabaseContext()!.setConnection(currentDbPath);
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@@ -0,0 +1,137 @@
import { type Kysely, CompiledQuery, sql } from 'kysely';
export default {
fullCopy: true,
async up(db: Kysely<unknown>) {
await db.executeQuery(CompiledQuery.raw('PRAGMA foreign_keys = OFF'));
await db.executeQuery(CompiledQuery.raw('PRAGMA defer_foreign_keys = ON'));
const createProgramTableTemp = sql`
CREATE TABLE IF NOT EXISTS "program_tmp" (
"uuid" text not null primary key,
"created_at" datetime,
"updated_at" datetime,
"source_type" text not null check ((\`source_type\` in ('plex', 'jellyfin', 'emby', 'local'))),
"original_air_date" text,
"duration" integer not null,
"episode" integer,
"episode_icon" text,
"file_path" text,
"icon" text,
"external_source_id" text not null,
"media_source_id" text references "media_source" ("uuid") on delete cascade,
"external_key" text not null,
"plex_rating_key" text,
"plex_file_path" text,
"parent_external_key" text,
"grandparent_external_key" text,
"rating" text,
"season_number" integer,
"season_icon" text,
"show_icon" text,
"show_title" text,
"summary" text,
"title" text not null,
"type" text not null check ((\`type\` in ('movie', 'episode', 'track'))),
"year" integer,
"artist_name" text,
"album_name" text,
"season_uuid" text,
"album_uuid" text,
"artist_uuid" text,
"tv_show_uuid" text,
constraint "program_season_uuid_foreign" foreign key ("season_uuid") references "program_grouping" ("uuid") on update cascade,
constraint "program_album_uuid_foreign" foreign key ("album_uuid") references "program_grouping" ("uuid") on update cascade,
constraint "program_artist_uuid_foreign" foreign key ("artist_uuid") references "program_grouping" ("uuid") on update cascade,
constraint "program_tv_show_uuid_foreign" foreign key ("tv_show_uuid") references "program_grouping" ("uuid") on update cascade);
`;
const createProgramGroupingExternalIdTemp = sql`
CREATE TABLE IF NOT EXISTS "program_external_id_temp" (
"uuid" text not null primary key,
"created_at" datetime,
"updated_at" datetime,
"source_type" text not null check ((\`source_type\` in ('plex', 'plex-guid', 'tmdb', 'imdb', 'tvdb', 'jellyfin', 'emby', 'local'))),
"external_source_id" text,
"media_source_id" text references "media_source" ("uuid") on delete cascade,
"external_key" text not null,
"external_file_path" text,
"direct_file_path" text,
"program_uuid" text not null,
constraint "program_external_id_program_uuid_foreign" foreign key ("program_uuid") references "program" ("uuid") on delete cascade
);
`;
await db.executeQuery(createProgramGroupingExternalIdTemp.compile(db));
const createProgramGroupExternalIdTemp = sql`
CREATE TABLE IF NOT EXISTS "program_grouping_external_id_tmp" (
"uuid" text not null primary key,
"created_at" datetime,
"updated_at" datetime,
"source_type" text not null check ((\`source_type\` in ('plex', 'plex-guid', 'tmdb', 'imdb', 'tvdb', 'jellyfin', 'emby', 'local'))),
"external_source_id" text,
"media_source_id" text references "media_source" ("uuid") on delete cascade,
"external_key" text not null,
"group_uuid" text not null,
"external_file_path" text,
constraint "program_grouping_external_id_group_uuid_foreign" foreign key ("group_uuid") references "program_grouping" ("uuid") on delete cascade on update cascade
);
`;
await db.executeQuery(createProgramGroupExternalIdTemp.compile(db));
const indexes = [
// Programs
'DROP INDEX IF EXISTS "program_season_uuid_index"',
'CREATE INDEX "program_season_uuid_index" on "program_tmp" ("season_uuid")',
'DROP INDEX IF EXISTS "program_tv_show_uuid_index"',
'CREATE INDEX "program_tv_show_uuid_index" on "program_tmp" ("tv_show_uuid")',
'DROP INDEX IF EXISTS "program_album_uuid_index"',
'CREATE INDEX "program_album_uuid_index" on "program_tmp" ("album_uuid")',
'DROP INDEX IF EXISTS "program_artist_uuid_index"',
'CREATE INDEX "program_artist_uuid_index" on "program_tmp" ("artist_uuid")',
'DROP INDEX IF EXISTS "program_source_type_external_source_id_external_key_unique"',
'CREATE UNIQUE INDEX "program_source_type_external_source_id_external_key_unique" on "program_tmp" ("source_type", "external_source_id", "external_key")',
// New one
'CREATE UNIQUE INDEX "program_media_source_uniq" on "program_tmp" ("source_type", "media_source_id", "external_key")',
// Program external IDs
'DROP INDEX IF EXISTS "unique_program_multiple_external_id"',
'DROP INDEX IF EXISTS "unique_program_single_external_id"',
`CREATE UNIQUE INDEX "unique_program_multiple_external_id" on "program_external_id_temp" ("program_uuid", "source_type", "external_source_id") where \`external_source_id\` is not null`,
`CREATE UNIQUE INDEX "unique_program_single_external_id" on "program_external_id_temp" ("program_uuid", "source_type") where \`external_source_id\` is null`,
// New indexes on program external ids
`CREATE UNIQUE INDEX "unique_program_multiple_external_id_media_source" on "program_external_id_temp" ("program_uuid", "source_type", "media_source_id") where \`media_source_id\` is not null`,
`CREATE UNIQUE INDEX "unique_program_single_external_id_media_source" on "program_external_id_temp" ("program_uuid", "source_type") where \`media_source_id\` is null`,
// Program grouping external IDs
// 'DROP INDEX IF EXISTS "unique_program_multiple_external_id"',
// 'DROP INDEX IF EXISTS "unique_program_single_external_id"',
// `CREATE UNIQUE INDEX "unique_program_multiple_external_id" on "program_grouping_external_id_tmp" ("program_uuid", "source_type", "external_source_id") where \`external_source_id\` is not null`,
// `CREATE UNIQUE INDEX "unique_program_single_external_id" on "program_grouping_external_id_tmp" ("program_uuid", "source_type") where \`external_source_id\` is null`,
// New indexes on program external ids
`CREATE UNIQUE INDEX "unique_program_grouping_multiple_external_id_media_source" on "program_grouping_external_id_tmp" ("group_uuid", "source_type", "media_source_id") where \`media_source_id\` is not null`,
`CREATE UNIQUE INDEX "unique_program_grouping_single_external_id_media_source" on "program_grouping_external_id_tmp" ("group_uuid", "source_type") where \`media_source_id\` is null`,
];
await db.executeQuery(createProgramTableTemp.compile(db));
for (const idx of indexes) {
await db.executeQuery(CompiledQuery.raw(idx));
}
await db.schema.dropTable('program').execute();
await db.schema.alterTable('program_tmp').renameTo('program').execute();
await db.schema.dropTable('program_external_id').execute();
await db.schema
.alterTable('program_external_id_temp')
.renameTo('program_external_id')
.execute();
await db.schema.dropTable('program_grouping_external_id').execute();
await db.schema
.alterTable('program_grouping_external_id_tmp')
.renameTo('program_grouping_external_id')
.execute();
await db.executeQuery(CompiledQuery.raw('PRAGMA foreign_keys = ON'));
await db.executeQuery(CompiledQuery.raw('PRAGMA defer_foreign_keys = OFF'));
},
};

View File

@@ -176,9 +176,19 @@ export class LegacyChannelMigrator {
isNonEmptyString(p.key), isNonEmptyString(p.key),
); );
const mediaSources = await getDatabase()
.selectFrom('mediaSource')
.selectAll()
.execute();
const mediaSourcesByName = groupByUniqPropAndMap(
mediaSources,
'name',
(ms) => ms.uuid,
);
const programEntities = seq.collect( const programEntities = seq.collect(
uniqBy(programs, uniqueProgramId), uniqBy<LegacyProgram>(programs, uniqueProgramId),
createProgramEntity, (program) => createProgramEntity(program, mediaSourcesByName),
); );
this.logger.debug( this.logger.debug(

View File

@@ -27,10 +27,12 @@ import {
import { import {
groupByUniq, groupByUniq,
groupByUniqProp, groupByUniqProp,
groupByUniqPropAndMap,
isNonEmptyString, isNonEmptyString,
mapAsyncSeq, mapAsyncSeq,
mapToObj, mapToObj,
} from '../../util/index.ts'; } from '../../util/index.ts';
import type { LegacyProgram } from './LegacyChannelMigrator.ts';
import type { CustomShow } from './legacyDbMigration.ts'; import type { CustomShow } from './legacyDbMigration.ts';
import type { JSONArray, JSONObject } from './migrationUtil.ts'; import type { JSONArray, JSONObject } from './migrationUtil.ts';
import { import {
@@ -88,7 +90,7 @@ export class LegacyLibraryMigrator {
Promise.resolve([] as CustomShow[]), Promise.resolve([] as CustomShow[]),
); );
const uniquePrograms = uniqBy( const uniquePrograms = uniqBy<LegacyProgram>(
filter( filter(
flatMap(newCustomShows, (cs) => cs.content), flatMap(newCustomShows, (cs) => cs.content),
(p) => (p) =>
@@ -99,7 +101,15 @@ export class LegacyLibraryMigrator {
uniqueProgramId, uniqueProgramId,
); );
const programEntities = seq.collect(uniquePrograms, createProgramEntity); const mediaSourcesByName = await getDatabase()
.selectFrom('mediaSource')
.selectAll()
.execute()
.then((_) => groupByUniqPropAndMap(_, 'name', (ms) => ms.uuid));
const programEntities = seq.collect(uniquePrograms, (program) =>
createProgramEntity(program, mediaSourcesByName),
);
this.logger.debug( this.logger.debug(
'Upserting %d programs from legacy DB', 'Upserting %d programs from legacy DB',

View File

@@ -12,8 +12,11 @@ import type {
import type { LegacyProgram } from './LegacyChannelMigrator.ts'; import type { LegacyProgram } from './LegacyChannelMigrator.ts';
// JSON representation for easier parsing of legacy db files // JSON representation for easier parsing of legacy db files
export interface JSONArray extends Array<JSONValue> {} export type JSONArray = Array<JSONValue>;
export interface JSONObject extends Record<string, JSONValue> {} export interface JSONObject {
[x: string]: JSONValue;
}
export type JSONValue = export type JSONValue =
| string | string
| number | number
@@ -68,7 +71,7 @@ export function convertRawProgram(program: JSONObject): LegacyProgram {
const programType = program['type'] as string | undefined; const programType = program['type'] as string | undefined;
const isMovie = programType === 'movie'; const isMovie = programType === 'movie';
const id = v4(); const id = v4();
const outProgram: LegacyProgram = { return {
id, id,
duration: program['duration'] as number, duration: program['duration'] as number,
episodeIcon: program['episodeIcon'] as Maybe<string>, episodeIcon: program['episodeIcon'] as Maybe<string>,
@@ -96,15 +99,18 @@ export function convertRawProgram(program: JSONObject): LegacyProgram {
customShowId: program['customShowId'] as Maybe<string>, customShowId: program['customShowId'] as Maybe<string>,
customShowName: program['customShowName'] as Maybe<string>, customShowName: program['customShowName'] as Maybe<string>,
sourceType: 'plex', sourceType: 'plex',
}; } satisfies LegacyProgram;
return outProgram;
} }
export function createProgramEntity( export function createProgramEntity(
program: LegacyProgram, program: LegacyProgram,
mediaSourceIdByName: Record<string, string>,
): NewProgramDao | undefined { ): NewProgramDao | undefined {
const now = +dayjs(); const now = +dayjs();
if (!mediaSourceIdByName[program.serverKey ?? '']) {
return;
}
if ( if (
['movie', 'episode', 'track'].includes(program.type ?? '') && ['movie', 'episode', 'track'].includes(program.type ?? '') &&
every([program.ratingKey, program.serverKey, program.key], isNonEmptyString) every([program.ratingKey, program.serverKey, program.key], isNonEmptyString)
@@ -122,6 +128,7 @@ export function createProgramEntity(
plexRatingKey: program.key!, plexRatingKey: program.key!,
plexFilePath: program.plexFile, plexFilePath: program.plexFile,
externalSourceId: program.serverKey!, externalSourceId: program.serverKey!,
mediaSourceId: mediaSourceIdByName[program.serverKey ?? ''],
showTitle: program.showTitle, showTitle: program.showTitle,
summary: program.summary, summary: program.summary,
title: program.title!, title: program.title!,

View File

@@ -0,0 +1,76 @@
import { inject, injectable } from 'inversify';
import { getDatabase } from '../../db/DBAccess.ts';
import { KEYS } from '../../types/inject.ts';
import { Logger } from '../../util/logging/LoggerFactory.ts';
import Fixer from './fixer.ts';
@injectable()
export class BackfillMediaSourceIdFixer extends Fixer {
constructor(@inject(KEYS.Logger) protected logger: Logger) {
super();
}
protected async runInternal(): Promise<void> {
const db = getDatabase();
await db
.updateTable('program')
.set({
mediaSourceId: (eb) =>
eb
.selectFrom('mediaSource')
.whereRef('mediaSource.name', '=', 'program.externalSourceId')
.whereRef('mediaSource.type', '=', 'program.sourceType')
.select('mediaSource.uuid')
.limit(1),
})
.where('program.mediaSourceId', 'is', null)
.execute();
await db
.updateTable('programExternalId')
.set({
mediaSourceId: (eb) =>
eb
.selectFrom('mediaSource')
.whereRef(
'mediaSource.name',
'=',
'programExternalId.externalSourceId',
)
.whereRef('mediaSource.type', '=', 'programExternalId.sourceType')
.select('mediaSource.uuid')
.limit(1),
})
.where('programExternalId.mediaSourceId', 'is', null)
.where('programExternalId.sourceType', 'in', ['plex', 'emby', 'jellyfin'])
.execute();
await db
.updateTable('programGroupingExternalId')
.set({
mediaSourceId: (eb) =>
eb
.selectFrom('mediaSource')
.whereRef(
'mediaSource.name',
'=',
'programGroupingExternalId.externalSourceId',
)
.whereRef(
'mediaSource.type',
'=',
'programGroupingExternalId.sourceType',
)
.select('mediaSource.uuid')
.limit(1),
})
.where('programGroupingExternalId.mediaSourceId', 'is', null)
.where('programGroupingExternalId.sourceType', 'in', [
'plex',
'emby',
'jellyfin',
])
.execute();
}
}

View File

@@ -1,10 +1,10 @@
import { getDatabase } from '@/db/DBAccess.js'; import { getDatabase } from '@/db/DBAccess.js';
import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js'; import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
import { ProgramSourceType } from '@/db/custom_types/ProgramSourceType.js'; import { ProgramSourceType } from '@/db/custom_types/ProgramSourceType.js';
import { upsertRawProgramExternalIds } from '@/db/programExternalIdHelpers.js'; import { upsertProgramExternalIds } from '@/db/programExternalIdHelpers.js';
import { withProgramExternalIds } from '@/db/programQueryHelpers.js'; import { withProgramExternalIds } from '@/db/programQueryHelpers.js';
import { ProgramDao } from '@/db/schema/Program.js'; import { ProgramDao } from '@/db/schema/Program.js';
import { NewProgramExternalId } from '@/db/schema/ProgramExternalId.js'; import { NewSingleOrMultiExternalId } from '@/db/schema/ProgramExternalId.js';
import { isQueryError } from '@/external/BaseApiClient.js'; import { isQueryError } from '@/external/BaseApiClient.js';
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js'; import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
import { PlexApiClient } from '@/external/plex/PlexApiClient.js'; import { PlexApiClient } from '@/external/plex/PlexApiClient.js';
@@ -118,7 +118,7 @@ export class BackfillProgramExternalIds extends Fixer {
); );
} else { } else {
const upsertResult = await attempt(() => const upsertResult = await attempt(() =>
upsertRawProgramExternalIds(result.result), upsertProgramExternalIds(result.result),
); );
if (isError(upsertResult)) { if (isError(upsertResult)) {
this.logger.warn( this.logger.warn(
@@ -147,6 +147,10 @@ export class BackfillProgramExternalIds extends Fixer {
); );
} }
if (isUndefined(plex.serverId)) {
throw new Error('Plex server is not a saved media source');
}
const metadataResult = await plex.getItemMetadata(program.externalKey); const metadataResult = await plex.getItemMetadata(program.externalKey);
if (isQueryError(metadataResult)) { if (isQueryError(metadataResult)) {
@@ -160,12 +164,14 @@ export class BackfillProgramExternalIds extends Fixer {
// We're here, might as well use the real thing. // We're here, might as well use the real thing.
const firstPart = first(first(metadata.Media)?.Part); const firstPart = first(first(metadata.Media)?.Part);
const entities: NewProgramExternalId[] = [ const entities: NewSingleOrMultiExternalId[] = [
{ {
type: 'multi',
externalFilePath: firstPart?.key ?? program.plexFilePath, externalFilePath: firstPart?.key ?? program.plexFilePath,
directFilePath: firstPart?.file ?? program.filePath, directFilePath: firstPart?.file ?? program.filePath,
externalKey: metadata.ratingKey, externalKey: metadata.ratingKey,
externalSourceId: plex.serverName, externalSourceId: plex.serverName,
mediaSourceId: plex.serverId,
programUuid: program.uuid, programUuid: program.uuid,
sourceType: ProgramExternalIdType.PLEX, sourceType: ProgramExternalIdType.PLEX,
uuid: v4(), uuid: v4(),
@@ -183,6 +189,7 @@ export class BackfillProgramExternalIds extends Fixer {
} }
entities.push({ entities.push({
type: 'single',
uuid: v4(), uuid: v4(),
createdAt: +dayjs(), createdAt: +dayjs(),
updatedAt: +dayjs(), updatedAt: +dayjs(),

View File

@@ -6,6 +6,7 @@ import type Fixer from '@/tasks/fixers/fixer.js';
import { MissingSeasonNumbersFixer } from '@/tasks/fixers/missingSeasonNumbersFixer.js'; import { MissingSeasonNumbersFixer } from '@/tasks/fixers/missingSeasonNumbersFixer.js';
import { KEYS } from '@/types/inject.js'; import { KEYS } from '@/types/inject.js';
import { ContainerModule } from 'inversify'; import { ContainerModule } from 'inversify';
import { BackfillMediaSourceIdFixer } from './BackfillMediaSourceIdFixer.ts';
const FixerModule = new ContainerModule((bind) => { const FixerModule = new ContainerModule((bind) => {
bind<Fixer>(KEYS.Fixer).to(BackfillProgramExternalIds); bind<Fixer>(KEYS.Fixer).to(BackfillProgramExternalIds);
@@ -13,6 +14,7 @@ const FixerModule = new ContainerModule((bind) => {
bind<Fixer>(KEYS.Fixer).to(AddPlexServerIdsFixer); bind<Fixer>(KEYS.Fixer).to(AddPlexServerIdsFixer);
bind<Fixer>(KEYS.Fixer).to(BackfillProgramGroupings); bind<Fixer>(KEYS.Fixer).to(BackfillProgramGroupings);
bind<Fixer>(KEYS.Fixer).to(MissingSeasonNumbersFixer); bind<Fixer>(KEYS.Fixer).to(MissingSeasonNumbersFixer);
bind<Fixer>(KEYS.Fixer).to(BackfillMediaSourceIdFixer);
}); });
export { FixerModule }; export { FixerModule };

View File

@@ -18,6 +18,7 @@ export class BackfillProgramGroupings extends Fixer {
// This clears out mismatches that might have happened on bugged earlier versions // This clears out mismatches that might have happened on bugged earlier versions
// There was a bug where we were setting the season ID to the show ID. // There was a bug where we were setting the season ID to the show ID.
// This should only affect seasons since the music album stuff had the fix // This should only affect seasons since the music album stuff had the fix
console.log('backfill', getDatabase().transaction());
const clearedSeasons = await getDatabase() const clearedSeasons = await getDatabase()
.transaction() .transaction()
.execute((tx) => .execute((tx) =>

View File

@@ -1,5 +1,5 @@
import type { IProgramDB } from '@/db/interfaces/IProgramDB.js'; import type { IProgramDB } from '@/db/interfaces/IProgramDB.js';
import { upsertRawProgramExternalIds } from '@/db/programExternalIdHelpers.js'; import { upsertProgramExternalIds } from '@/db/programExternalIdHelpers.js';
import { isQueryError } from '@/external/BaseApiClient.js'; import { isQueryError } from '@/external/BaseApiClient.js';
import { type MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js'; import { type MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
import type { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js'; import type { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
@@ -15,7 +15,7 @@ import {
} from '../../db/custom_types/ProgramExternalIdType.ts'; } from '../../db/custom_types/ProgramExternalIdType.ts';
import type { import type {
MinimalProgramExternalId, MinimalProgramExternalId,
NewProgramExternalId, NewSingleOrMultiExternalId,
} from '../../db/schema/ProgramExternalId.ts'; } from '../../db/schema/ProgramExternalId.ts';
export type SaveJellyfinProgramExternalIdsTaskFactory = ( export type SaveJellyfinProgramExternalIdsTaskFactory = (
@@ -96,17 +96,18 @@ export class SaveJellyfinProgramExternalIdsTask extends Task {
} }
return { return {
type: 'single',
uuid: v4(), uuid: v4(),
createdAt: +dayjs(), createdAt: +dayjs(),
updatedAt: +dayjs(), updatedAt: +dayjs(),
externalKey: id, externalKey: id,
sourceType: type, sourceType: type,
programUuid: program.uuid, programUuid: program.uuid,
} satisfies NewProgramExternalId; } satisfies NewSingleOrMultiExternalId;
}), }),
); );
return await upsertRawProgramExternalIds(eids); return await upsertProgramExternalIds(eids);
} }
get taskName() { get taskName() {

View File

@@ -1,5 +1,5 @@
import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js'; import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
import { upsertRawProgramExternalIds } from '@/db/programExternalIdHelpers.js'; import { upsertProgramExternalIds } from '@/db/programExternalIdHelpers.js';
import type { MinimalProgramExternalId } from '@/db/schema/ProgramExternalId.js'; import type { MinimalProgramExternalId } from '@/db/schema/ProgramExternalId.js';
import { isQueryError } from '@/external/BaseApiClient.js'; import { isQueryError } from '@/external/BaseApiClient.js';
import { type MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js'; import { type MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
@@ -8,8 +8,9 @@ import { Task } from '@/tasks/Task.js';
import type { Maybe } from '@/types/util.js'; import type { Maybe } from '@/types/util.js';
import { mintExternalIdForPlexGuid } from '@/util/externalIds.js'; import { mintExternalIdForPlexGuid } from '@/util/externalIds.js';
import { isDefined, isNonEmptyString } from '@/util/index.js'; import { isDefined, isNonEmptyString } from '@/util/index.js';
import { seq } from '@tunarr/shared/util';
import type { PlexTerminalMedia } from '@tunarr/types/plex'; import type { PlexTerminalMedia } from '@tunarr/types/plex';
import { compact, isEmpty, isNil, isUndefined, map } from 'lodash-es'; import { isEmpty, isNil, isUndefined } from 'lodash-es';
import type { IProgramDB } from '../../db/interfaces/IProgramDB.ts'; import type { IProgramDB } from '../../db/interfaces/IProgramDB.ts';
export type SavePlexProgramExternalIdsTaskFactory = ( export type SavePlexProgramExternalIdsTaskFactory = (
@@ -78,19 +79,11 @@ export class SavePlexProgramExternalIdsTask extends Task {
const metadata = metadataResult.data as PlexTerminalMedia; const metadata = metadataResult.data as PlexTerminalMedia;
const eids = compact( const eids = seq.collect(metadata.Guid, (guid) =>
map(metadata.Guid, (guid) => { mintExternalIdForPlexGuid(guid.id, program.uuid),
const parsed = mintExternalIdForPlexGuid(guid.id, program.uuid);
if (parsed) {
parsed.externalSourceId = undefined;
return parsed;
}
return;
}),
); );
return await upsertRawProgramExternalIds(eids); return await upsertProgramExternalIds(eids);
} }
get taskName() { get taskName() {

View File

@@ -1,4 +1,8 @@
import type { DeepNonNullable, StrictExclude } from 'ts-essentials'; import type {
DeepNonNullable,
MarkRequired,
StrictExclude,
} from 'ts-essentials';
export type Maybe<T> = T | undefined; export type Maybe<T> = T | undefined;
@@ -31,3 +35,8 @@ export type MarkNullable<Type, Keys extends keyof Type = keyof Type> = {
}; };
export type NonEmptyArray<T> = [T, ...T[]]; export type NonEmptyArray<T> = [T, ...T[]];
export type MarkNotNilable<Type, Keys extends keyof Type> = MarkNonNullable<
MarkRequired<Type, Keys>,
Keys
>;

View File

@@ -1,5 +1,5 @@
import { programExternalIdTypeFromExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js'; import { programExternalIdTypeFromExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
import type { NewProgramExternalId } from '@/db/schema/ProgramExternalId.js'; import type { NewSingleOrMultiExternalId } from '@/db/schema/ProgramExternalId.js';
import type { Nullable } from '@/types/util.js'; import type { Nullable } from '@/types/util.js';
import type { MultiExternalId } from '@tunarr/types'; import type { MultiExternalId } from '@tunarr/types';
import { isValidSingleExternalIdType } from '@tunarr/types/schemas'; import { isValidSingleExternalIdType } from '@tunarr/types/schemas';
@@ -29,10 +29,11 @@ export const createPlexExternalId = (
export const mintExternalIdForPlexGuid = ( export const mintExternalIdForPlexGuid = (
guid: string, guid: string,
programId: string, programId: string,
): Nullable<NewProgramExternalId> => { ): Nullable<NewSingleOrMultiExternalId> => {
const parsed = parsePlexGuid(guid); const parsed = parsePlexGuid(guid);
if (parsed) { if (parsed) {
return { return {
type: 'single',
uuid: v4(), uuid: v4(),
createdAt: +dayjs(), createdAt: +dayjs(),
updatedAt: +dayjs(), updatedAt: +dayjs(),

View File

@@ -83,6 +83,7 @@ export class ApiProgramMinter {
return { return {
type: 'content', type: 'content',
externalSourceType: 'plex', externalSourceType: 'plex',
externalSourceId: server.id,
externalSourceName: server.name, externalSourceName: server.name,
date: plexMovie.originallyAvailableAt, date: plexMovie.originallyAvailableAt,
duration: plexMovie.duration ?? 0, duration: plexMovie.duration ?? 0,
@@ -95,7 +96,6 @@ export class ApiProgramMinter {
subtype: 'movie', subtype: 'movie',
persisted: false, persisted: false,
externalIds: this.mintExternalIdsForPlex(server.name, plexMovie), externalIds: this.mintExternalIdsForPlex(server.name, plexMovie),
externalSourceId: server.name,
uniqueId: id, uniqueId: id,
id, id,
}; };
@@ -113,7 +113,7 @@ export class ApiProgramMinter {
index: plexEpisode.index, index: plexEpisode.index,
externalKey: plexEpisode.ratingKey, externalKey: plexEpisode.ratingKey,
externalSourceName: server.name, externalSourceName: server.name,
externalSourceId: server.name, externalSourceId: server.id,
externalSourceType: ExternalSourceTypeSchema.enum.plex, externalSourceType: ExternalSourceTypeSchema.enum.plex,
parent: { parent: {
title: plexEpisode.parentTitle, title: plexEpisode.parentTitle,
@@ -242,7 +242,7 @@ export class ApiProgramMinter {
persisted: false, persisted: false,
uniqueId: id, uniqueId: id,
id, id,
externalSourceId: server.name, externalSourceId: server.id,
}; };
} }
@@ -257,7 +257,7 @@ export class ApiProgramMinter {
externalSourceType: ExternalSourceTypeSchema.enum.jellyfin, externalSourceType: ExternalSourceTypeSchema.enum.jellyfin,
date: nullToUndefined(item.PremiereDate), date: nullToUndefined(item.PremiereDate),
duration: (item.RunTimeTicks ?? 0) / 10_000, duration: (item.RunTimeTicks ?? 0) / 10_000,
externalSourceId: server.name, externalSourceId: server.id,
externalKey: item.Id, externalKey: item.Id,
rating: nullToUndefined(item.OfficialRating), rating: nullToUndefined(item.OfficialRating),
summary: nullToUndefined(item.Overview), summary: nullToUndefined(item.Overview),
@@ -322,7 +322,7 @@ export class ApiProgramMinter {
externalSourceType: ExternalSourceTypeSchema.enum.emby, externalSourceType: ExternalSourceTypeSchema.enum.emby,
date: nullToUndefined(item.PremiereDate), date: nullToUndefined(item.PremiereDate),
duration: (item.RunTimeTicks ?? 0) / 10_000, duration: (item.RunTimeTicks ?? 0) / 10_000,
externalSourceId: server.name, externalSourceId: server.id,
externalKey: item.Id, externalKey: item.Id,
rating: nullToUndefined(item.OfficialRating), rating: nullToUndefined(item.OfficialRating),
summary: nullToUndefined(item.Overview), summary: nullToUndefined(item.Overview),