diff --git a/package.json b/package.json
index 50686d7e..9512e54c 100644
--- a/package.json
+++ b/package.json
@@ -45,7 +45,8 @@
"packageManager": "pnpm@9.12.3",
"pnpm": {
"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": {
"eslint": "9.17.0",
diff --git a/patches/kysely.patch b/patches/kysely.patch
new file mode 100644
index 00000000..92bb1301
--- /dev/null
+++ b/patches/kysely.patch
@@ -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 @@
+ ///
+-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';
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 283f10e2..b51ad54b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -9,6 +9,9 @@ overrides:
'@types/node': 22.10.7
patchedDependencies:
+ kysely:
+ hash: 5ewjqngd4oyjgaqa3fcufrviwq
+ path: patches/kysely.patch
ts-essentials@9.4.1:
hash: 254pvnpgpcwoswa4ncfq4tq6su
path: patches/ts-essentials@9.4.1.patch
@@ -145,7 +148,7 @@ importers:
version: 1.11.10
drizzle-orm:
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:
specifier: ^4.3.5
version: 4.3.5
@@ -172,7 +175,7 @@ importers:
version: 6.2.1(reflect-metadata@0.2.2)
kysely:
specifier: ^0.27.4
- version: 0.27.4
+ version: 0.27.4(patch_hash=5ewjqngd4oyjgaqa3fcufrviwq)
lodash-es:
specifier: ^4.17.21
version: 4.17.21
@@ -293,7 +296,7 @@ importers:
version: 15.0.0
kysely-ctl:
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:
specifier: ^1.0.15
version: 1.0.15
@@ -11087,13 +11090,13 @@ snapshots:
transitivePeerDependencies:
- 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:
'@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
+ kysely: 0.27.4(patch_hash=5ewjqngd4oyjgaqa3fcufrviwq)
dunder-proto@1.0.1:
dependencies:
@@ -12565,12 +12568,12 @@ snapshots:
dependencies:
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:
c12: 1.11.2(magicast@0.3.5)
citty: 0.1.6
consola: 3.2.3
- kysely: 0.27.4
+ kysely: 0.27.4(patch_hash=5ewjqngd4oyjgaqa3fcufrviwq)
nypm: 0.3.12
ofetch: 1.4.1
pathe: 1.1.2
@@ -12580,7 +12583,7 @@ snapshots:
transitivePeerDependencies:
- magicast
- kysely@0.27.4: {}
+ kysely@0.27.4(patch_hash=5ewjqngd4oyjgaqa3fcufrviwq): {}
lazystream@1.0.1:
dependencies:
diff --git a/server/src/Server.ts b/server/src/Server.ts
index 7d6735d6..4babfe01 100644
--- a/server/src/Server.ts
+++ b/server/src/Server.ts
@@ -35,6 +35,7 @@ import { HdhrApiRouter } from './api/hdhrApi.js';
import { apiRouter } from './api/index.js';
import { streamApi } from './api/streamApi.js';
import { videoApiRouter } from './api/videoApi.js';
+import { DBContext, makeDatabaseConnection } from './db/DBAccess.ts';
import { FfmpegInfo } from './ffmpeg/ffmpegInfo.js';
import {
type ServerOptions,
@@ -439,6 +440,7 @@ export class Server {
this.app.after(() => {
this.app.gracefulShutdown(async (signal) => {
+ DBContext.enter(makeDatabaseConnection());
this.logger.info(
'Received exit signal %s, attempting graceful shutdown',
signal,
diff --git a/server/src/bootstrap.ts b/server/src/bootstrap.ts
index 501e2ca2..8ec07165 100644
--- a/server/src/bootstrap.ts
+++ b/server/src/bootstrap.ts
@@ -4,7 +4,9 @@ import path from 'node:path';
import type { DeepPartial } from 'ts-essentials';
import {
databaseNeedsMigration,
+ DBContext,
getDatabase,
+ makeDatabaseConnection,
migrateExistingDatabase,
runDBMigrations,
syncMigrationTablesIfNecessary,
@@ -74,18 +76,20 @@ export async function bootstrapTunarr(
}
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
- if (hasTunarrDb) {
- const migrationNecessary = await databaseNeedsMigration(db);
- if (migrationNecessary) {
- await migrateExistingDatabase(getDefaultDatabaseName());
+ // not the first run, use the copy migrator
+ if (hasTunarrDb) {
+ const migrationNecessary = await databaseNeedsMigration(db);
+ if (migrationNecessary) {
+ await migrateExistingDatabase(getDefaultDatabaseName());
+ }
+ } else {
+ await syncMigrationTablesIfNecessary(db);
+ await runDBMigrations(db);
}
- } else {
- await syncMigrationTablesIfNecessary(db);
- await runDBMigrations(db);
- }
+ });
LoggerFactory.initialize(settingsDb);
}
diff --git a/server/src/cli/RunServerCommand.ts b/server/src/cli/RunServerCommand.ts
index 31aac963..2d016342 100644
--- a/server/src/cli/RunServerCommand.ts
+++ b/server/src/cli/RunServerCommand.ts
@@ -2,11 +2,13 @@ import { container } from '@/container.js';
import { isProduction } from '@/util/index.js';
import { type MarkOptional } from 'ts-essentials';
import type { ArgumentsCamelCase, CommandModule } from 'yargs';
+import { DBContext } from '../db/DBAccess.ts';
import { type ISettingsDB } from '../db/interfaces/ISettingsDB.ts';
import { setServerOptions } from '../globals.ts';
import { Server } from '../Server.ts';
import { StartupService } from '../services/StartupService.ts';
import { KEYS } from '../types/inject.ts';
+import { getDefaultDatabaseName } from '../util/defaults.ts';
import {
getBooleanEnvVar,
getNumericEnvVar,
@@ -52,7 +54,10 @@ export const RunServerCommand: CommandModule = {
portSetting;
// port precedence - env var -> argument -> settings
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();
+ });
},
};
diff --git a/server/src/db/ChannelDB.ts b/server/src/db/ChannelDB.ts
index 7caf2cff..46340852 100644
--- a/server/src/db/ChannelDB.ts
+++ b/server/src/db/ChannelDB.ts
@@ -1172,6 +1172,39 @@ export class ChannelDB implements IChannelDB {
await this.saveLineup(channelId, lineup);
}
+ async removeProgramsFromAllLineups(programIds: string[]): Promise {
+ 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) {
const db = await this.getFileDb(channelId);
await db.write();
diff --git a/server/src/db/DBAccess.ts b/server/src/db/DBAccess.ts
index 32e1372a..d78bebe5 100644
--- a/server/src/db/DBAccess.ts
+++ b/server/src/db/DBAccess.ts
@@ -7,19 +7,32 @@ import {
drizzle,
type BetterSQLite3Database,
} from 'drizzle-orm/better-sqlite3';
+import type {
+ IsolationLevel,
+ KyselyConfig,
+ KyselyProps,
+ Transaction,
+} from 'kysely';
import {
CamelCasePlugin,
+ DefaultConnectionProvider,
+ DefaultQueryExecutor,
Kysely,
+ Log,
Migrator,
ParseJSONResultsPlugin,
+ RuntimeDriver,
SqliteDialect,
+ TransactionBuilder,
} from 'kysely';
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 {
DirectMigrationProvider,
LegacyMigrationNameToNewMigrationName,
} from '../migration/DirectMigrationProvider.ts';
+import type { Maybe } from '../types/util.ts';
import { getDefaultDatabaseName } from '../util/defaults.ts';
import type { DB } from './schema/db.ts';
@@ -30,6 +43,7 @@ export const MigrationLockTableName = 'migration_lock';
// let _directDbAccess: Kysely;
type Conn = {
+ name: string;
kysely: Kysely;
drizzle: BetterSQLite3Database;
};
@@ -38,55 +52,176 @@ const connections = new Map();
const logger = once(() => LoggerFactory.child({ className: 'DirectDBAccess' }));
+export class DBContext {
+ private static storage = new AsyncLocalStorage();
+
+ constructor(private connections: Map = new Map()) {}
+
+ get db(): Maybe> {
+ return this.getKyselyDatabase();
+ }
+
+ getConnection(name: string = getDefaultDatabaseName()): Maybe {
+ return this.connections.get(name);
+ }
+
+ getKyselyDatabase(
+ name: string = getDefaultDatabaseName(),
+ ): Maybe> {
+ return this.getConnection(name)?.kysely;
+ }
+
+ getOrCreateKyselyDatabase(name: string): Kysely {
+ 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(context: Conn, next: (...args: unknown[]) => T) {
+ const m = new Map([[context.name, context]]);
+ return this.storage.run(new DBContext(m), next);
+ }
+
+ static createForName(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([[context.name, context]]);
+ this.storage.enterWith(new DBContext(m));
+ }
+
+ static currentDBContext(): Maybe {
+ return this.storage.getStore();
+ }
+}
+
+class TransactionBuilderWrapper extends TransactionBuilder {
+ constructor(
+ private dbName: string,
+ props: KyselyProps & { isolationLevel?: IsolationLevel },
+ ) {
+ super(props);
+ }
+
+ execute(callback: (trx: Transaction) => Promise): Promise {
+ 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 {
+ constructor(
+ private dbName: string,
+ private config: KyselyConfig,
+ ) {
+ super(config);
+ }
+
+ transaction(): TransactionBuilder {
+ 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 = (
dbName: string = getDefaultDatabaseName(),
forceInit: boolean = false,
) => {
- let conn = connections.get(dbName);
- if (!conn || forceInit) {
- const dbConn = new Sqlite(dbName, {
- timeout: 5000,
- });
- const kysely = new Kysely({
- 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 };
+ if (forceInit) {
+ DBContext.enter(makeDatabaseConnection(dbName));
}
- connections.set(dbName, conn);
- return conn.kysely;
+ return DBContext.currentDBContext()!.getKyselyDatabase(dbName)!;
};
export function getMigrator(db: Kysely = getDatabase()) {
@@ -98,9 +233,7 @@ export function getMigrator(db: Kysely = getDatabase()) {
});
}
-export async function pendingDatabaseMigrations(
- db: Kysely = getDatabase(),
-) {
+export async function pendingDatabaseMigrations(db: Kysely) {
return lock.runExclusive(async () => {
const tables = await db.introspection.getTables({
withInternalKyselyTables: true,
@@ -127,7 +260,7 @@ export async function pendingDatabaseMigrations(
});
}
-export async function databaseNeedsMigration(db: Kysely = getDatabase()) {
+export async function databaseNeedsMigration(db: Kysely) {
return (await pendingDatabaseMigrations(db)).length > 0;
}
@@ -221,10 +354,7 @@ export async function syncMigrationTablesIfNecessary(
}
}
-export async function runDBMigrations(
- db: Kysely = getDatabase(),
- migrateTo?: string,
-) {
+export async function runDBMigrations(db: Kysely, migrateTo?: string) {
const _logger = logger();
const migrator = getMigrator(db);
const { error, results } = await (isNonEmptyString(migrateTo)
@@ -245,9 +375,7 @@ export async function runDBMigrations(
}
// Runs through pending migrations, using the DB copier if necessary
-export async function migrateExistingDatabase(
- dbPath: string = getDefaultDatabaseName(),
-) {
+export async function migrateExistingDatabase(dbPath: string) {
const db = getDatabase(dbPath);
const pendingMigrations = await pendingDatabaseMigrations(db);
diff --git a/server/src/db/ProgramDB.ts b/server/src/db/ProgramDB.ts
index 2972f42d..44a5bb86 100644
--- a/server/src/db/ProgramDB.ts
+++ b/server/src/db/ProgramDB.ts
@@ -12,7 +12,7 @@ import {
type SavePlexProgramExternalIdsTaskFactory,
} from '@/tasks/plex/SavePlexProgramExternalIdsTask.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 { devAssert } from '@/util/debug.js';
import { type Logger } from '@/util/logging/LoggerFactory.js';
@@ -25,7 +25,7 @@ import {
} from '@tunarr/types';
import dayjs from 'dayjs';
import { inject, injectable } from 'inversify';
-import { CaseWhenBuilder, UpdateResult } from 'kysely';
+import { CaseWhenBuilder, NotNull, UpdateResult } from 'kysely';
import {
chunk,
concat,
@@ -35,6 +35,7 @@ import {
flatMap,
forEach,
groupBy,
+ head,
isEmpty,
isNil,
isNull,
@@ -52,6 +53,7 @@ import {
} from 'lodash-es';
import { MarkOptional, MarkRequired } from 'ts-essentials';
import { v4 } from 'uuid';
+import { typedProperty } from '../types/path.ts';
import { getNumericEnvVar, TUNARR_ENV_VARS } from '../util/env.ts';
import {
flatMapAsyncSeq,
@@ -70,7 +72,7 @@ import {
ProgramSourceType,
programSourceTypeFromString,
} from './custom_types/ProgramSourceType.ts';
-import { upsertRawProgramExternalIds } from './programExternalIdHelpers.ts';
+import { upsertProgramExternalIds } from './programExternalIdHelpers.ts';
import {
AllProgramJoins,
ProgramUpsertFields,
@@ -84,7 +86,8 @@ import {
withTvShow,
} from './programQueryHelpers.ts';
import {
- NewProgramDao as NewRawProgram,
+ NewProgramDao,
+ ProgramDao,
programExternalIdString,
ProgramType,
ProgramDao as RawProgram,
@@ -92,7 +95,7 @@ import {
import {
MinimalProgramExternalId,
NewProgramExternalId,
- NewProgramExternalId as NewRawProgramExternalId,
+ NewSingleOrMultiExternalId,
ProgramExternalId,
ProgramExternalIdKeys,
} from './schema/ProgramExternalId.ts';
@@ -100,7 +103,10 @@ import {
NewProgramGrouping,
ProgramGroupingType,
} from './schema/ProgramGrouping.ts';
-import { NewProgramGroupingExternalId } from './schema/ProgramGroupingExternalId.ts';
+import {
+ NewProgramGroupingExternalId,
+ toInsertableProgramGroupingExternalId,
+} from './schema/ProgramGroupingExternalId.ts';
import { DB } from './schema/db.ts';
import type {
ProgramGroupingWithExternalIds,
@@ -112,9 +118,9 @@ type ValidatedContentProgram = MarkRequired<
'externalSourceName' | 'externalSourceType'
>;
-type MintedRawProgramInfo = {
- program: NewRawProgram;
- externalIds: NewRawProgramExternalId[];
+type MintedNewProgramInfo = {
+ program: NewProgramDao;
+ externalIds: NewSingleOrMultiExternalId[];
apiProgram: ValidatedContentProgram;
};
@@ -549,12 +555,13 @@ export class ProgramDB implements IProgramDB {
// );
// TODO: handle custom shows
- const programsToPersist: MintedRawProgramInfo[] = map(
+ const programsToPersist: MintedNewProgramInfo[] = map(
contentPrograms,
(p) => {
const program = minter.contentProgramDtoToDao(p);
const externalIds = minter.mintExternalIds(
p.externalSourceName,
+ p.externalSourceId,
program.uuid,
p,
);
@@ -572,7 +579,7 @@ export class ProgramDB implements IProgramDB {
// 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
// have duplicate entries in the DB across different servers and sources.
- const upsertedPrograms: RawProgram[] = [];
+ const upsertedPrograms: MarkNonNullable[] = [];
await this.timer.timeAsync('programUpsert', async () => {
for (const c of chunk(programsToPersist, programUpsertBatchSize)) {
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()
+ .$narrowType<{ mediaSourceId: NotNull }>()
.execute(),
)),
);
@@ -624,7 +641,7 @@ export class ProgramDB implements IProgramDB {
// TODO: We could optimize further here by only saving IDs necessary for streaming
await this.timer.timeAsync(
`upsert ${requiredExternalIds.length} external ids`,
- () => upsertRawProgramExternalIds(requiredExternalIds, 200),
+ () => upsertProgramExternalIds(requiredExternalIds, 200),
// upsertProgramExternalIds_deprecated(requiredExternalIds),
);
@@ -650,7 +667,7 @@ export class ProgramDB implements IProgramDB {
AnonymousTask('UpsertExternalIds', () =>
this.timer.timeAsync(
`background external ID upsert (${backgroundExternalIds.length} ids)`,
- () => upsertRawProgramExternalIds(backgroundExternalIds),
+ () => upsertProgramExternalIds(backgroundExternalIds),
),
),
);
@@ -667,18 +684,21 @@ export class ProgramDB implements IProgramDB {
}
private async handleProgramGroupings(
- upsertedPrograms: RawProgram[],
- programInfos: Record,
+ upsertedPrograms: MarkNonNullable[],
+ programInfos: Record,
) {
const programsBySourceAndServer = mapValues(
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,
)) {
- 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
const typ = programSourceTypeFromString(sourceType);
if (!typ) {
@@ -690,6 +710,7 @@ export class ProgramDB implements IProgramDB {
programInfos,
typ,
serverName,
+ serverId,
);
}
}
@@ -697,8 +718,9 @@ export class ProgramDB implements IProgramDB {
private async handleSingleSourceProgramGroupings(
upsertedPrograms: RawProgram[],
- programInfos: Record,
+ programInfos: Record,
mediaSourceType: ProgramSourceType,
+ mediaSourceName: string,
mediaSourceId: string,
) {
const grandparentRatingKeyToParentRatingKey: Record<
@@ -790,7 +812,7 @@ export class ProgramDB implements IProgramDB {
eb(
'programGroupingExternalId.externalSourceId',
'=',
- mediaSourceId,
+ mediaSourceName,
),
eb(
'programGroupingExternalId.externalKey',
@@ -954,6 +976,7 @@ export class ProgramDB implements IProgramDB {
...ProgramGroupingMinter.mintGroupingExternalIds(
programs[0][1],
parentGrouping.uuid,
+ mediaSourceName,
mediaSourceId,
'parent',
),
@@ -965,6 +988,7 @@ export class ProgramDB implements IProgramDB {
...ProgramGroupingMinter.mintGroupingExternalIds(
matchingPrograms[0][1],
grandparentGrouping.uuid,
+ mediaSourceName,
mediaSourceId,
'grandparent',
),
@@ -986,14 +1010,41 @@ export class ProgramDB implements IProgramDB {
if (!isEmpty(externalIds)) {
await this.timer.timeAsync('upsert program_grouping external ids', () =>
- getDatabase()
- .transaction()
- .execute((tx) =>
- tx
- .insertInto('programGroupingExternalId')
- .values(externalIds)
- .executeTakeFirstOrThrow(),
+ Promise.all(
+ chunk(
+ externalIds.map(toInsertableProgramGroupingExternalId),
+ 100,
+ ).map((externalIds) =>
+ getDatabase()
+ .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();
this.timer.timeSync('schedule Plex external IDs tasks', () => {
forEach(
@@ -1168,7 +1219,7 @@ export class ProgramDB implements IProgramDB {
});
}
- private scheduleJellyfinExternalIdsTask(upsertedPrograms: NewRawProgram[]) {
+ private scheduleJellyfinExternalIdsTask(upsertedPrograms: ProgramDao[]) {
JellyfinTaskQueue.pause();
this.timer.timeSync('Schedule Jellyfin external IDs tasks', () => {
forEach(
diff --git a/server/src/db/converters/ProgramGroupingMinter.ts b/server/src/db/converters/ProgramGroupingMinter.ts
index 7372c29a..7048bdc2 100644
--- a/server/src/db/converters/ProgramGroupingMinter.ts
+++ b/server/src/db/converters/ProgramGroupingMinter.ts
@@ -1,13 +1,12 @@
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 type { ContentProgram } from '@tunarr/types';
import type { JellyfinItem } from '@tunarr/types/jellyfin';
import type { PlexEpisode, PlexMusicTrack } from '@tunarr/types/plex';
import dayjs from 'dayjs';
-import { find, first } from 'lodash-es';
+import { first } from 'lodash-es';
import type { MarkRequired } from 'ts-essentials';
-import { P, match } from 'ts-pattern';
import { v4 } from 'uuid';
import type { Nullable } from '../../types/util.ts';
import {
@@ -68,14 +67,15 @@ export class ProgramGroupingMinter {
program: ContentProgram,
groupingId: string,
externalSourceId: string,
+ mediaSourceId: string,
relationType: 'parent' | 'grandparent',
- ): NewProgramGroupingExternalId[] {
+ ): NewSingleOrMultiProgramGroupingExternalId[] {
if (program.subtype === 'movie') {
return [];
}
const now = +dayjs();
- const parentExternalIds: NewProgramGroupingExternalId[] = [];
+ const parentExternalIds: NewSingleOrMultiProgramGroupingExternalId[] = [];
const ratingKey =
relationType === 'grandparent'
@@ -83,6 +83,7 @@ export class ProgramGroupingMinter {
: program.parent?.externalKey;
if (isNonEmptyString(ratingKey)) {
parentExternalIds.push({
+ type: 'multi',
uuid: v4(),
createdAt: now,
updatedAt: now,
@@ -90,6 +91,7 @@ export class ProgramGroupingMinter {
externalKey: ratingKey,
sourceType: ProgramExternalIdType.PLEX,
externalSourceId,
+ mediaSourceId,
groupUuid: groupingId,
});
}
@@ -101,89 +103,13 @@ export class ProgramGroupingMinter {
);
if (isNonEmptyString(guid)) {
parentExternalIds.push({
+ type: 'single',
uuid: v4(),
createdAt: now,
updatedAt: now,
externalFilePath: null,
externalKey: 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,
});
}
diff --git a/server/src/db/converters/ProgramMinter.ts b/server/src/db/converters/ProgramMinter.ts
index c8ff7314..bf6a954b 100644
--- a/server/src/db/converters/ProgramMinter.ts
+++ b/server/src/db/converters/ProgramMinter.ts
@@ -1,6 +1,9 @@
import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.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 type { ContentProgram } from '@tunarr/types';
import type { JellyfinItem } from '@tunarr/types/jellyfin';
@@ -26,7 +29,9 @@ class ProgramDaoMinter {
return {
uuid: v4(),
sourceType: program.externalSourceType,
- externalSourceId: program.externalSourceId ?? program.externalSourceName,
+ // Deprecated
+ externalSourceId: program.externalSourceName,
+ mediaSourceId: program.externalSourceId,
externalKey: program.externalKey,
originalAirDate: program.date ?? null,
duration: program.duration,
@@ -50,21 +55,24 @@ class ProgramDaoMinter {
mint(
serverName: string,
+ serverId: string,
program: ContentProgramOriginalProgram,
): NewRawProgram {
const ret = match(program)
.with(
{ sourceType: 'plex', program: { type: 'movie' } },
- ({ program: movie }) => this.mintProgramForPlexMovie(serverName, movie),
+ ({ program: movie }) =>
+ this.mintProgramForPlexMovie(serverName, serverId, movie),
)
.with(
{ sourceType: 'plex', program: { type: 'episode' } },
({ program: episode }) =>
- this.mintProgramForPlexEpisode(serverName, episode),
+ this.mintProgramForPlexEpisode(serverName, serverId, episode),
)
.with(
{ sourceType: 'plex', program: { type: 'track' } },
- ({ program: track }) => this.mintProgramForPlexTrack(serverName, track),
+ ({ program: track }) =>
+ this.mintProgramForPlexTrack(serverName, serverId, track),
)
.with(
{
@@ -80,7 +88,8 @@ class ProgramDaoMinter {
),
},
},
- ({ program }) => this.mintProgramForJellyfinItem(serverName, program),
+ ({ program }) =>
+ this.mintProgramForJellyfinItem(serverName, serverId, program),
)
.otherwise(() => new Error('Unexpected program type'));
if (isError(ret)) {
@@ -91,6 +100,7 @@ class ProgramDaoMinter {
private mintProgramForPlexMovie(
serverName: string,
+ serverId: string,
plexMovie: PlexMovie,
): NewRawProgram {
const file = first(first(plexMovie.Media)?.Part ?? []);
@@ -101,6 +111,7 @@ class ProgramDaoMinter {
duration: plexMovie.duration ?? 0,
filePath: file?.file ?? null,
externalSourceId: serverName,
+ mediaSourceId: serverId,
externalKey: plexMovie.ratingKey,
plexRatingKey: plexMovie.ratingKey,
plexFilePath: file?.key ?? null,
@@ -116,6 +127,7 @@ class ProgramDaoMinter {
private mintProgramForJellyfinItem(
serverName: string,
+ serverId: string,
item: Omit & {
Type: 'Movie' | 'Episode' | 'Audio' | 'Video' | 'MusicVideo' | 'Trailer';
},
@@ -128,6 +140,7 @@ class ProgramDaoMinter {
originalAirDate: item.PremiereDate,
duration: (item.RunTimeTicks ?? 0) / 10_000,
externalSourceId: serverName,
+ mediaSourceId: serverId,
externalKey: item.Id,
rating: item.OfficialRating,
summary: item.Overview,
@@ -154,6 +167,7 @@ class ProgramDaoMinter {
private mintProgramForPlexEpisode(
serverName: string,
+ serverId: string,
plexEpisode: PlexEpisode,
): NewRawProgram {
const file = first(first(plexEpisode.Media)?.Part ?? []);
@@ -166,6 +180,7 @@ class ProgramDaoMinter {
duration: plexEpisode.duration ?? 0,
filePath: file?.file,
externalSourceId: serverName,
+ mediaSourceId: serverId,
externalKey: plexEpisode.ratingKey,
plexRatingKey: plexEpisode.ratingKey,
plexFilePath: file?.key,
@@ -185,6 +200,7 @@ class ProgramDaoMinter {
private mintProgramForPlexTrack(
serverName: string,
+ serverId: string,
plexTrack: PlexMusicTrack,
): NewRawProgram {
const file = first(first(plexTrack.Media)?.Part ?? []);
@@ -196,6 +212,7 @@ class ProgramDaoMinter {
duration: plexTrack.duration ?? 0,
filePath: file?.file,
externalSourceId: serverName,
+ mediaSourceId: serverId,
externalKey: plexTrack.ratingKey,
plexRatingKey: plexTrack.ratingKey,
plexFilePath: file?.key,
@@ -216,32 +233,34 @@ class ProgramDaoMinter {
mintExternalIds(
serverName: string,
+ serverId: string,
programId: string,
program: ContentProgram,
- // originalProgram: ContentProgramOriginalProgram,
- ) {
+ ): NewSingleOrMultiExternalId[] {
return match(program)
.with({ externalSourceType: 'plex' }, () =>
- this.mintPlexExternalIds(serverName, programId, program),
+ this.mintPlexExternalIds(serverName, serverId, programId, program),
)
.with({ externalSourceType: 'jellyfin' }, () =>
- this.mintJellyfinExternalIds(serverName, programId, program),
+ this.mintJellyfinExternalIds(serverName, serverId, programId, program),
)
.with({ externalSourceType: 'emby' }, () =>
- this.mintEmbyExternalIds(serverName, programId, program),
+ this.mintEmbyExternalIds(serverName, serverId, programId, program),
)
.exhaustive();
}
mintPlexExternalIds(
serverName: string,
+ serverId: string,
programId: string,
program: ContentProgram,
- ): NewProgramExternalId[] {
+ ): NewSingleOrMultiExternalId[] {
const now = +dayjs();
- const ids: NewProgramExternalId[] = [
+ const ids: NewSingleOrMultiExternalId[] = [
{
+ type: 'multi',
uuid: v4(),
createdAt: now,
updatedAt: now,
@@ -249,6 +268,7 @@ class ProgramDaoMinter {
sourceType: ProgramExternalIdType.PLEX,
programUuid: programId,
externalSourceId: serverName,
+ mediaSourceId: serverId,
externalFilePath: program.serverFileKey,
directFilePath: program.serverFilePath,
},
@@ -257,6 +277,7 @@ class ProgramDaoMinter {
const plexGuid = find(program.externalIds, { source: 'plex-guid' });
if (plexGuid) {
ids.push({
+ type: 'single',
uuid: v4(),
createdAt: now,
updatedAt: now,
@@ -273,13 +294,14 @@ class ProgramDaoMinter {
case 'imdb':
case 'tvdb':
return {
+ type: 'single',
uuid: v4(),
createdAt: now,
updatedAt: now,
externalKey: eid.id,
sourceType: eid.source,
programUuid: programId,
- } satisfies NewProgramExternalId;
+ } satisfies NewSingleOrMultiExternalId;
default:
return null;
}
@@ -307,12 +329,14 @@ class ProgramDaoMinter {
mintJellyfinExternalIds(
serverName: string,
+ serverId: string,
programId: string,
program: ContentProgram,
) {
const now = +dayjs();
- const ids: NewProgramExternalId[] = [
+ const ids: NewSingleOrMultiExternalId[] = [
{
+ type: 'multi',
uuid: v4(),
createdAt: now,
updatedAt: now,
@@ -320,6 +344,7 @@ class ProgramDaoMinter {
sourceType: ProgramExternalIdType.JELLYFIN,
programUuid: programId,
externalSourceId: serverName,
+ mediaSourceId: serverId,
externalFilePath: program.serverFileKey,
directFilePath: program.serverFilePath,
},
@@ -332,13 +357,14 @@ class ProgramDaoMinter {
case 'imdb':
case 'tvdb':
return {
+ type: 'single',
uuid: v4(),
createdAt: now,
updatedAt: now,
externalKey: eid.id,
sourceType: eid.source,
programUuid: programId,
- } satisfies NewProgramExternalId;
+ } satisfies NewSingleOrMultiExternalId;
default:
return null;
}
@@ -350,12 +376,14 @@ class ProgramDaoMinter {
mintEmbyExternalIds(
serverName: string,
+ serverId: string,
programId: string,
program: ContentProgram,
) {
const now = +dayjs();
- const ids: NewProgramExternalId[] = [
+ const ids: NewSingleOrMultiExternalId[] = [
{
+ type: 'multi',
uuid: v4(),
createdAt: now,
updatedAt: now,
@@ -363,6 +391,7 @@ class ProgramDaoMinter {
sourceType: ProgramExternalIdType.EMBY,
programUuid: programId,
externalSourceId: serverName,
+ mediaSourceId: serverId,
externalFilePath: program.serverFileKey,
directFilePath: program.serverFilePath,
},
@@ -375,13 +404,14 @@ class ProgramDaoMinter {
case 'imdb':
case 'tvdb':
return {
+ type: 'single',
uuid: v4(),
createdAt: now,
updatedAt: now,
externalKey: eid.id,
sourceType: eid.source,
programUuid: programId,
- } satisfies NewProgramExternalId;
+ } satisfies NewSingleOrMultiExternalId;
default:
return null;
}
diff --git a/server/src/db/interfaces/IChannelDB.ts b/server/src/db/interfaces/IChannelDB.ts
index 47a8314c..f31ed45c 100644
--- a/server/src/db/interfaces/IChannelDB.ts
+++ b/server/src/db/interfaces/IChannelDB.ts
@@ -93,6 +93,8 @@ export interface IChannelDB {
programIds: string[],
): Promise;
+ removeProgramsFromAllLineups(programIds: string[]): Promise;
+
loadAllLineupConfigs(
forceRead?: boolean,
): Promise>;
diff --git a/server/src/db/mediaSourceDB.ts b/server/src/db/mediaSourceDB.ts
index cbd4b084..887139af 100644
--- a/server/src/db/mediaSourceDB.ts
+++ b/server/src/db/mediaSourceDB.ts
@@ -117,32 +117,36 @@ export class MediaSourceDB {
.executeTakeFirst();
}
- async deleteMediaSource(id: string, removePrograms: boolean = true) {
+ async deleteMediaSource(id: string) {
const deletedServer = await this.getById(id);
if (isNil(deletedServer)) {
throw new Error(`MediaSource not found: ${id}`);
}
+ // This should cascade all relevant deletes across the DB
await getDatabase()
- .deleteFrom('mediaSource')
- .where('uuid', '=', id)
- // TODO: Blocked on https://github.com/oven-sh/bun/issues/16909
- // .limit(1)
- .execute();
+ .transaction()
+ .execute(async (tx) => {
+ const relatedProgramIds = await tx
+ .selectFrom('program')
+ .where('program.mediaSourceId', '=', id)
+ .select('uuid')
+ .execute()
+ .then((_) => _.map(({ uuid }) => uuid));
- let reports: Report[];
- if (!removePrograms) {
- reports = [];
- } else {
- reports = await this.fixupProgramReferences(
- deletedServer.name,
- deletedServer.type,
- );
- }
+ await tx
+ .deleteFrom('mediaSource')
+ .where('uuid', '=', id)
+ .limit(1)
+ .execute();
+ // TODO: Update lineups
+
+ await this.channelDb.removeProgramsFromAllLineups(relatedProgramIds);
+ });
this.mediaSourceApiFactory().deleteCachedClient(deletedServer);
- return { deletedServer, reports };
+ return { deletedServer };
}
async updateMediaSource(server: UpdateMediaSourceRequest) {
@@ -155,9 +159,9 @@ export class MediaSourceDB {
}
const sendGuideUpdates =
- server.type === 'plex' ? server.sendGuideUpdates ?? false : false;
+ server.type === 'plex' ? (server.sendGuideUpdates ?? false) : false;
const sendChannelUpdates =
- server.type === 'plex' ? server.sendChannelUpdates ?? false : false;
+ server.type === 'plex' ? (server.sendChannelUpdates ?? false) : false;
await getDatabase()
.updateTable('mediaSource')
@@ -188,9 +192,9 @@ export class MediaSourceDB {
async addMediaSource(server: InsertMediaSourceRequest): Promise {
const name = isUndefined(server.name) ? 'plex' : server.name;
const sendGuideUpdates =
- server.type === 'plex' ? server.sendGuideUpdates ?? false : false;
+ server.type === 'plex' ? (server.sendGuideUpdates ?? false) : false;
const sendChannelUpdates =
- server.type === 'plex' ? server.sendChannelUpdates ?? false : false;
+ server.type === 'plex' ? (server.sendChannelUpdates ?? false) : false;
const index = await getDatabase()
.selectFrom('mediaSource')
.select((eb) => eb.fn.count('uuid').as('count'))
@@ -218,38 +222,6 @@ export class MediaSourceDB {
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(
serverName: string,
serverType: MediaSourceType,
@@ -350,8 +322,8 @@ export class MediaSourceDB {
id,
channelNumber: number,
channelName: name,
- destroyedPrograms: isUpdate ? 0 : channelToProgramCount[id] ?? 0,
- modifiedPrograms: isUpdate ? channelToProgramCount[id] ?? 0 : 0,
+ destroyedPrograms: isUpdate ? 0 : (channelToProgramCount[id] ?? 0),
+ modifiedPrograms: isUpdate ? (channelToProgramCount[id] ?? 0) : 0,
} as Report;
},
);
@@ -359,15 +331,15 @@ export class MediaSourceDB {
const fillerReports: Report[] = map(fillersById, ({ uuid }) => ({
type: 'filler',
id: uuid,
- destroyedPrograms: isUpdate ? 0 : fillerToProgramCount[uuid] ?? 0,
- modifiedPrograms: isUpdate ? fillerToProgramCount[uuid] ?? 0 : 0,
+ destroyedPrograms: isUpdate ? 0 : (fillerToProgramCount[uuid] ?? 0),
+ modifiedPrograms: isUpdate ? (fillerToProgramCount[uuid] ?? 0) : 0,
}));
const customShowReports: Report[] = map(customShowById, ({ uuid }) => ({
type: 'custom-show',
id: uuid,
- destroyedPrograms: isUpdate ? 0 : customShowToProgramCount[uuid] ?? 0,
- modifiedPrograms: isUpdate ? customShowToProgramCount[uuid] ?? 0 : 0,
+ destroyedPrograms: isUpdate ? 0 : (customShowToProgramCount[uuid] ?? 0),
+ modifiedPrograms: isUpdate ? (customShowToProgramCount[uuid] ?? 0) : 0,
}));
return [...channelReports, ...fillerReports, ...customShowReports];
diff --git a/server/src/db/programExternalIdHelpers.ts b/server/src/db/programExternalIdHelpers.ts
index 5fac52f7..cc667fc7 100644
--- a/server/src/db/programExternalIdHelpers.ts
+++ b/server/src/db/programExternalIdHelpers.ts
@@ -1,12 +1,14 @@
import { mapAsyncSeq } from '@/util/index.js';
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
-import { isValidSingleExternalIdType } from '@tunarr/types/schemas';
-import { chunk, flatten, isEmpty, isUndefined, partition } from 'lodash-es';
+import { chunk, flatten, isEmpty, partition } from 'lodash-es';
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 (
- externalIds: NewRawProgramExternalId[],
+export const upsertProgramExternalIds = async (
+ externalIds: NewSingleOrMultiExternalId[],
chunkSize: number = 100,
) => {
if (isEmpty(externalIds)) {
@@ -17,9 +19,7 @@ export const upsertRawProgramExternalIds = async (
const [singles, multiples] = partition(
externalIds,
- (id) =>
- isValidSingleExternalIdType(id.sourceType) &&
- isUndefined(id.externalSourceId),
+ (id) => id.type === 'single',
);
let singleIdPromise: Promise<{ uuid: string }[]>;
@@ -30,7 +30,7 @@ export const upsertRawProgramExternalIds = async (
.execute((tx) =>
tx
.insertInto('programExternalId')
- .values(singleChunk)
+ .values(singleChunk.map(toInsertableProgramExternalId))
.onConflict((oc) =>
oc
.columns(['programUuid', 'sourceType'])
@@ -42,6 +42,17 @@ export const upsertRawProgramExternalIds = async (
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')
.execute(),
);
@@ -58,7 +69,7 @@ export const upsertRawProgramExternalIds = async (
.execute((tx) =>
tx
.insertInto('programExternalId')
- .values(multiChunk)
+ .values(multiChunk.map(toInsertableProgramExternalId))
.onConflict((oc) =>
oc
.columns(['programUuid', 'sourceType', 'externalSourceId'])
@@ -70,6 +81,17 @@ export const upsertRawProgramExternalIds = async (
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')
.execute(),
);
diff --git a/server/src/db/schema/Program.ts b/server/src/db/schema/Program.ts
index 5692bc14..270e1b11 100644
--- a/server/src/db/schema/Program.ts
+++ b/server/src/db/schema/Program.ts
@@ -10,8 +10,9 @@ import {
uniqueIndex,
} from 'drizzle-orm/sqlite-core';
import type { Insertable, Selectable, Updateable } from 'kysely';
+import type { MarkNotNilable } from '../../types/util.ts';
import { type KyselifyBetter } from './KyselifyBetter.ts';
-import { MediaSourceTypes } from './MediaSource.ts';
+import { MediaSource, MediaSourceTypes } from './MediaSource.ts';
import { ProgramGrouping } from './ProgramGrouping.ts';
export const ProgramTypes = ['movie', 'episode', 'track'] as const;
@@ -37,6 +38,9 @@ export const Program = sqliteTable(
episodeIcon: text(),
externalKey: text().notNull(),
externalSourceId: text().notNull(),
+ mediaSourceId: text().references(() => MediaSource.uuid, {
+ onDelete: 'cascade',
+ }),
filePath: text(),
grandparentExternalKey: text(),
icon: text(),
@@ -78,7 +82,10 @@ export const Program = sqliteTable(
export type ProgramTable = KyselifyBetter;
export type ProgramDao = Selectable;
-export type NewProgramDao = Insertable;
+export type NewProgramDao = MarkNotNilable<
+ Insertable,
+ 'mediaSourceId'
+>;
export type ProgramDaoUpdate = Updateable;
export function programExternalIdString(p: ProgramDao | NewProgramDao) {
diff --git a/server/src/db/schema/ProgramExternalId.ts b/server/src/db/schema/ProgramExternalId.ts
index c8f52130..f2525009 100644
--- a/server/src/db/schema/ProgramExternalId.ts
+++ b/server/src/db/schema/ProgramExternalId.ts
@@ -8,9 +8,12 @@ import {
uniqueIndex,
} from 'drizzle-orm/sqlite-core';
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 { type KyselifyBetter } from './KyselifyBetter.ts';
+import { MediaSource } from './MediaSource.ts';
import { Program } from './Program.ts';
export const ProgramExternalId = sqliteTable(
@@ -23,9 +26,12 @@ export const ProgramExternalId = sqliteTable(
externalFilePath: text(),
externalKey: text().notNull(),
externalSourceId: text(),
+ mediaSourceId: text().references(() => MediaSource.uuid, {
+ onDelete: 'cascade',
+ }),
programUuid: text()
.notNull()
- .references(() => Program.uuid),
+ .references(() => Program.uuid, { onDelete: 'cascade' }),
sourceType: text({ enum: ProgramExternalIdSourceTypes }).notNull(),
},
(table) => [
@@ -46,6 +52,24 @@ export const ProgramExternalId = sqliteTable(
export type ProgramExternalIdTable = KyselifyBetter;
export type ProgramExternalId = Selectable;
export type NewProgramExternalId = Insertable;
+export type NewSingleOrMultiExternalId =
+ | (StrictOmit<
+ Insertable,
+ 'mediaSourceId' | 'externalSourceId'
+ > & { type: 'single' })
+ | (MarkNotNilable<
+ Insertable,
+ 'mediaSourceId' | 'externalSourceId'
+ > & { type: 'multi' });
+
+export function toInsertableProgramExternalId(
+ extraInfo: NewSingleOrMultiExternalId,
+): NewProgramExternalId {
+ return omit(extraInfo, 'type') satisfies Omit<
+ NewSingleOrMultiExternalId,
+ 'type'
+ >;
+}
export type MinimalProgramExternalId = MarkRequired<
Partial,
diff --git a/server/src/db/schema/ProgramGroupingExternalId.ts b/server/src/db/schema/ProgramGroupingExternalId.ts
index 67cea68b..821ecb7b 100644
--- a/server/src/db/schema/ProgramGroupingExternalId.ts
+++ b/server/src/db/schema/ProgramGroupingExternalId.ts
@@ -7,8 +7,12 @@ import {
text,
} from 'drizzle-orm/sqlite-core';
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 { type KyselifyBetter } from './KyselifyBetter.ts';
+import { MediaSource } from './MediaSource.ts';
import { ProgramGrouping } from './ProgramGrouping.ts';
export const ProgramGroupingExternalId = sqliteTable(
@@ -20,6 +24,9 @@ export const ProgramGroupingExternalId = sqliteTable(
externalFilePath: text(),
externalKey: text().notNull(),
externalSourceId: text(),
+ mediaSourceId: text().references(() => MediaSource.uuid, {
+ onDelete: 'cascade',
+ }),
groupUuid: text()
.notNull()
.references(() => ProgramGrouping.uuid, {
@@ -45,6 +52,21 @@ export type ProgramGroupingExternalId =
Selectable;
export type NewProgramGroupingExternalId =
Insertable;
+export type NewSingleOrMultiProgramGroupingExternalId =
+ | (StrictOmit<
+ Insertable,
+ 'externalSourceId' | 'mediaSourceId'
+ > & { type: 'single' })
+ | (MarkNotNilable<
+ Insertable,
+ 'externalSourceId' | 'mediaSourceId'
+ > & { type: 'multi' });
+
+export function toInsertableProgramGroupingExternalId(
+ eid: NewSingleOrMultiProgramGroupingExternalId,
+): NewProgramGroupingExternalId {
+ return omit(eid, 'type') satisfies NewProgramGroupingExternalId;
+}
export type ProgramGroupingExternalIdFields<
Alias extends string = 'ProgramGroupingExternalId',
diff --git a/server/src/external/jellyfin/JellyfinItemFinder.ts b/server/src/external/jellyfin/JellyfinItemFinder.ts
index 6c854455..18202d23 100644
--- a/server/src/external/jellyfin/JellyfinItemFinder.ts
+++ b/server/src/external/jellyfin/JellyfinItemFinder.ts
@@ -8,7 +8,7 @@ import { GlobalScheduler } from '@/services/Scheduler.js';
import { ReconcileProgramDurationsTask } from '@/tasks/ReconcileProgramDurationsTask.js';
import { KEYS } from '@/types/inject.js';
import { Maybe } from '@/types/util.js';
-import { groupByUniq, isDefined } from '@/util/index.js';
+import { groupByUniq, isDefined, run } from '@/util/index.js';
import { type Logger } from '@/util/logging/LoggerFactory.js';
import { JellyfinItem, JellyfinItemKind } from '@tunarr/types/jellyfin';
import dayjs from 'dayjs';
@@ -19,6 +19,8 @@ import {
ProgramExternalIdType,
programExternalIdTypeFromJellyfinProvider,
} from '../../db/custom_types/ProgramExternalIdType.ts';
+import { MediaSourceDB } from '../../db/mediaSourceDB.ts';
+import { MediaSourceType } from '../../db/schema/MediaSource.ts';
import { JellyfinGetItemsQuery } from './JellyfinApiClient.ts';
@injectable()
@@ -28,6 +30,7 @@ export class JellyfinItemFinder {
@inject(KEYS.Logger) private logger: Logger,
@inject(MediaSourceApiFactory)
private mediaSourceApiFactory: MediaSourceApiFactory,
+ @inject(MediaSourceDB) private mediaSourceDB: MediaSourceDB,
) {}
async findForProgramAndUpdate(programId: string) {
@@ -65,10 +68,24 @@ export class JellyfinItemFinder {
// Right now just check if the durations are different.
// otherwise we might blow away details we already have, since
// Jellyfin collects metadata asynchronously (sometimes)
- const updatedProgram = minter.mint(program.externalSourceId, {
- sourceType: 'jellyfin',
- program: potentialApiMatch,
- });
+ const mediaSourceId =
+ program.mediaSourceId ??
+ (await run(async () => {
+ const ms = await this.findMediaSource(program.externalSourceId);
+ if (!ms)
+ throw new Error(
+ `Could not find media source by name: ${program.externalSourceId}`,
+ );
+ return ms.uuid;
+ }));
+ const updatedProgram = minter.mint(
+ program.externalSourceId,
+ mediaSourceId,
+ {
+ sourceType: 'jellyfin',
+ program: potentialApiMatch,
+ },
+ );
if (updatedProgram.duration !== program.duration) {
await this.programDB.updateProgramDuration(
@@ -207,4 +224,11 @@ export class JellyfinItemFinder {
return possibleMatch;
}
+
+ private findMediaSource(mediaSourceName: string) {
+ return this.mediaSourceDB.findByType(
+ MediaSourceType.Jellyfin,
+ mediaSourceName,
+ );
+ }
}
diff --git a/server/src/external/plex/PlexApiClient.ts b/server/src/external/plex/PlexApiClient.ts
index 3a9d8d5f..4da14df5 100644
--- a/server/src/external/plex/PlexApiClient.ts
+++ b/server/src/external/plex/PlexApiClient.ts
@@ -69,6 +69,10 @@ export class PlexApiClient extends BaseApiClient {
return this.opts.name;
}
+ get serverId() {
+ return this.opts.uuid;
+ }
+
getFullUrl(path: string): string {
const url = super.getFullUrl(path);
const parsed = new URL(url);
diff --git a/server/src/migration/DirectMigrationProvider.ts b/server/src/migration/DirectMigrationProvider.ts
index 9154b80f..1d7a63a3 100644
--- a/server/src/migration/DirectMigrationProvider.ts
+++ b/server/src/migration/DirectMigrationProvider.ts
@@ -23,6 +23,7 @@ import Migration1730806741 from './db/Migration1730806741.ts';
import Migration1731982492 from './db/Migration1731982492.ts';
import Migration1732969335_AddTranscodeConfig from './db/Migration1732969335_AddTranscodeConfig.ts';
import Migration1738604866_AddEmby from './db/Migration1738604866_AddEmby.ts';
+import Migration1740691984_ProgramMediaSourceId from './db/Migration1740691984_ProgramMediaSourceId.ts';
export const LegacyMigrationNameToNewMigrationName = [
['Migration20240124115044', '_Legacy_Migration00'],
@@ -90,6 +91,7 @@ export class DirectMigrationProvider implements MigrationProvider {
migration1732969335: Migration1732969335_AddTranscodeConfig,
migration1735044379: Migration1735044379_AddHlsDirect,
migration1738604866: Migration1738604866_AddEmby,
+ migration1740691984: Migration1740691984_ProgramMediaSourceId,
},
wrapWithTransaction,
),
diff --git a/server/src/migration/db/DatabaseCopyMigrator.ts b/server/src/migration/db/DatabaseCopyMigrator.ts
index fc1ebad4..0121f285 100644
--- a/server/src/migration/db/DatabaseCopyMigrator.ts
+++ b/server/src/migration/db/DatabaseCopyMigrator.ts
@@ -1,10 +1,10 @@
import dayjs from 'dayjs';
import { type Kysely, sql } from 'kysely';
-import { trimEnd } from 'lodash-es';
+import { replace } from 'lodash-es';
import fs from 'node:fs/promises';
import tmp from 'tmp-promise';
import {
- getDatabase,
+ getDatabaseContext,
MigrationLockTableName,
MigrationTableName,
runDBMigrations,
@@ -23,10 +23,11 @@ export class DatabaseCopyMigrator {
async migrate(currentDbPath: string, migrateTo?: string) {
const { path: tmpPath } = await tmp.file({ keep: false });
this.logger.debug('Migrating to temp DB %s', tmpPath);
- const tempDB = getDatabase(tmpPath);
+ const tempDB = getDatabaseContext()!.getOrCreateKyselyDatabase(tmpPath);
await runDBMigrations(tempDB, migrateTo);
- const oldDB = getDatabase(currentDbPath);
+ const oldDB =
+ getDatabaseContext()!.getOrCreateKyselyDatabase(currentDbPath);
const oldTables = await this.getTables(oldDB);
const newTables = await this.getTables(tempDB);
// Prepare for copy.
@@ -48,11 +49,11 @@ export class DatabaseCopyMigrator {
continue;
}
- const columnUnion = new Set(table.columns.map((col) => col.name)).union(
- new Set(newTable.columns.map((col) => col.name)),
- );
+ const columnIntersection = new Set(
+ 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(
tempDB,
);
@@ -63,11 +64,11 @@ export class DatabaseCopyMigrator {
await fs.copyFile(
currentDbPath,
- `${trimEnd(currentDbPath, '.db')}-${+dayjs()}.bak`,
+ `${replace(currentDbPath, '.db', '')}-${+dayjs()}.bak`,
);
await fs.cp(tmpPath, currentDbPath);
// Force reinit at the new path
- getDatabase(currentDbPath, true);
+ getDatabaseContext()!.setConnection(currentDbPath);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/server/src/migration/db/Migration1740691984_ProgramMediaSourceId.ts b/server/src/migration/db/Migration1740691984_ProgramMediaSourceId.ts
new file mode 100644
index 00000000..8082d731
--- /dev/null
+++ b/server/src/migration/db/Migration1740691984_ProgramMediaSourceId.ts
@@ -0,0 +1,137 @@
+import { type Kysely, CompiledQuery, sql } from 'kysely';
+
+export default {
+ fullCopy: true,
+ async up(db: Kysely) {
+ 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'));
+ },
+};
diff --git a/server/src/migration/legacy_migration/LegacyChannelMigrator.ts b/server/src/migration/legacy_migration/LegacyChannelMigrator.ts
index fa6638bf..330c6553 100644
--- a/server/src/migration/legacy_migration/LegacyChannelMigrator.ts
+++ b/server/src/migration/legacy_migration/LegacyChannelMigrator.ts
@@ -176,9 +176,19 @@ export class LegacyChannelMigrator {
isNonEmptyString(p.key),
);
+ const mediaSources = await getDatabase()
+ .selectFrom('mediaSource')
+ .selectAll()
+ .execute();
+ const mediaSourcesByName = groupByUniqPropAndMap(
+ mediaSources,
+ 'name',
+ (ms) => ms.uuid,
+ );
+
const programEntities = seq.collect(
- uniqBy(programs, uniqueProgramId),
- createProgramEntity,
+ uniqBy(programs, uniqueProgramId),
+ (program) => createProgramEntity(program, mediaSourcesByName),
);
this.logger.debug(
diff --git a/server/src/migration/legacy_migration/libraryMigrator.ts b/server/src/migration/legacy_migration/libraryMigrator.ts
index 2a662b88..8b6bfd53 100644
--- a/server/src/migration/legacy_migration/libraryMigrator.ts
+++ b/server/src/migration/legacy_migration/libraryMigrator.ts
@@ -27,10 +27,12 @@ import {
import {
groupByUniq,
groupByUniqProp,
+ groupByUniqPropAndMap,
isNonEmptyString,
mapAsyncSeq,
mapToObj,
} from '../../util/index.ts';
+import type { LegacyProgram } from './LegacyChannelMigrator.ts';
import type { CustomShow } from './legacyDbMigration.ts';
import type { JSONArray, JSONObject } from './migrationUtil.ts';
import {
@@ -88,7 +90,7 @@ export class LegacyLibraryMigrator {
Promise.resolve([] as CustomShow[]),
);
- const uniquePrograms = uniqBy(
+ const uniquePrograms = uniqBy(
filter(
flatMap(newCustomShows, (cs) => cs.content),
(p) =>
@@ -99,7 +101,15 @@ export class LegacyLibraryMigrator {
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(
'Upserting %d programs from legacy DB',
diff --git a/server/src/migration/legacy_migration/migrationUtil.ts b/server/src/migration/legacy_migration/migrationUtil.ts
index 527a5122..5da9b362 100644
--- a/server/src/migration/legacy_migration/migrationUtil.ts
+++ b/server/src/migration/legacy_migration/migrationUtil.ts
@@ -12,8 +12,11 @@ import type {
import type { LegacyProgram } from './LegacyChannelMigrator.ts';
// JSON representation for easier parsing of legacy db files
-export interface JSONArray extends Array {}
-export interface JSONObject extends Record {}
+export type JSONArray = Array;
+export interface JSONObject {
+ [x: string]: JSONValue;
+}
+
export type JSONValue =
| string
| number
@@ -68,7 +71,7 @@ export function convertRawProgram(program: JSONObject): LegacyProgram {
const programType = program['type'] as string | undefined;
const isMovie = programType === 'movie';
const id = v4();
- const outProgram: LegacyProgram = {
+ return {
id,
duration: program['duration'] as number,
episodeIcon: program['episodeIcon'] as Maybe,
@@ -96,15 +99,18 @@ export function convertRawProgram(program: JSONObject): LegacyProgram {
customShowId: program['customShowId'] as Maybe,
customShowName: program['customShowName'] as Maybe,
sourceType: 'plex',
- };
-
- return outProgram;
+ } satisfies LegacyProgram;
}
export function createProgramEntity(
program: LegacyProgram,
+ mediaSourceIdByName: Record,
): NewProgramDao | undefined {
const now = +dayjs();
+ if (!mediaSourceIdByName[program.serverKey ?? '']) {
+ return;
+ }
+
if (
['movie', 'episode', 'track'].includes(program.type ?? '') &&
every([program.ratingKey, program.serverKey, program.key], isNonEmptyString)
@@ -122,6 +128,7 @@ export function createProgramEntity(
plexRatingKey: program.key!,
plexFilePath: program.plexFile,
externalSourceId: program.serverKey!,
+ mediaSourceId: mediaSourceIdByName[program.serverKey ?? ''],
showTitle: program.showTitle,
summary: program.summary,
title: program.title!,
diff --git a/server/src/tasks/fixers/BackfillMediaSourceIdFixer.ts b/server/src/tasks/fixers/BackfillMediaSourceIdFixer.ts
new file mode 100644
index 00000000..588ad27c
--- /dev/null
+++ b/server/src/tasks/fixers/BackfillMediaSourceIdFixer.ts
@@ -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 {
+ 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();
+ }
+}
diff --git a/server/src/tasks/fixers/BackfillProgramExternalIds.ts b/server/src/tasks/fixers/BackfillProgramExternalIds.ts
index e0dd3f72..28dcb5bf 100644
--- a/server/src/tasks/fixers/BackfillProgramExternalIds.ts
+++ b/server/src/tasks/fixers/BackfillProgramExternalIds.ts
@@ -1,10 +1,10 @@
import { getDatabase } from '@/db/DBAccess.js';
import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.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 { 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 { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
import { PlexApiClient } from '@/external/plex/PlexApiClient.js';
@@ -118,7 +118,7 @@ export class BackfillProgramExternalIds extends Fixer {
);
} else {
const upsertResult = await attempt(() =>
- upsertRawProgramExternalIds(result.result),
+ upsertProgramExternalIds(result.result),
);
if (isError(upsertResult)) {
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);
if (isQueryError(metadataResult)) {
@@ -160,12 +164,14 @@ export class BackfillProgramExternalIds extends Fixer {
// We're here, might as well use the real thing.
const firstPart = first(first(metadata.Media)?.Part);
- const entities: NewProgramExternalId[] = [
+ const entities: NewSingleOrMultiExternalId[] = [
{
+ type: 'multi',
externalFilePath: firstPart?.key ?? program.plexFilePath,
directFilePath: firstPart?.file ?? program.filePath,
externalKey: metadata.ratingKey,
externalSourceId: plex.serverName,
+ mediaSourceId: plex.serverId,
programUuid: program.uuid,
sourceType: ProgramExternalIdType.PLEX,
uuid: v4(),
@@ -183,6 +189,7 @@ export class BackfillProgramExternalIds extends Fixer {
}
entities.push({
+ type: 'single',
uuid: v4(),
createdAt: +dayjs(),
updatedAt: +dayjs(),
diff --git a/server/src/tasks/fixers/FixerModule.ts b/server/src/tasks/fixers/FixerModule.ts
index 0c4fe52f..a3e0df5f 100644
--- a/server/src/tasks/fixers/FixerModule.ts
+++ b/server/src/tasks/fixers/FixerModule.ts
@@ -6,6 +6,7 @@ import type Fixer from '@/tasks/fixers/fixer.js';
import { MissingSeasonNumbersFixer } from '@/tasks/fixers/missingSeasonNumbersFixer.js';
import { KEYS } from '@/types/inject.js';
import { ContainerModule } from 'inversify';
+import { BackfillMediaSourceIdFixer } from './BackfillMediaSourceIdFixer.ts';
const FixerModule = new ContainerModule((bind) => {
bind(KEYS.Fixer).to(BackfillProgramExternalIds);
@@ -13,6 +14,7 @@ const FixerModule = new ContainerModule((bind) => {
bind(KEYS.Fixer).to(AddPlexServerIdsFixer);
bind(KEYS.Fixer).to(BackfillProgramGroupings);
bind(KEYS.Fixer).to(MissingSeasonNumbersFixer);
+ bind(KEYS.Fixer).to(BackfillMediaSourceIdFixer);
});
export { FixerModule };
diff --git a/server/src/tasks/fixers/backfillProgramGroupings.ts b/server/src/tasks/fixers/backfillProgramGroupings.ts
index cad75d4e..ce0402ea 100644
--- a/server/src/tasks/fixers/backfillProgramGroupings.ts
+++ b/server/src/tasks/fixers/backfillProgramGroupings.ts
@@ -18,6 +18,7 @@ export class BackfillProgramGroupings extends Fixer {
// 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.
// This should only affect seasons since the music album stuff had the fix
+ console.log('backfill', getDatabase().transaction());
const clearedSeasons = await getDatabase()
.transaction()
.execute((tx) =>
diff --git a/server/src/tasks/jellyfin/SaveJellyfinProgramExternalIdsTask.ts b/server/src/tasks/jellyfin/SaveJellyfinProgramExternalIdsTask.ts
index 141483d1..8364e5b9 100644
--- a/server/src/tasks/jellyfin/SaveJellyfinProgramExternalIdsTask.ts
+++ b/server/src/tasks/jellyfin/SaveJellyfinProgramExternalIdsTask.ts
@@ -1,5 +1,5 @@
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 { type MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
import type { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
@@ -15,7 +15,7 @@ import {
} from '../../db/custom_types/ProgramExternalIdType.ts';
import type {
MinimalProgramExternalId,
- NewProgramExternalId,
+ NewSingleOrMultiExternalId,
} from '../../db/schema/ProgramExternalId.ts';
export type SaveJellyfinProgramExternalIdsTaskFactory = (
@@ -96,17 +96,18 @@ export class SaveJellyfinProgramExternalIdsTask extends Task {
}
return {
+ type: 'single',
uuid: v4(),
createdAt: +dayjs(),
updatedAt: +dayjs(),
externalKey: id,
sourceType: type,
programUuid: program.uuid,
- } satisfies NewProgramExternalId;
+ } satisfies NewSingleOrMultiExternalId;
}),
);
- return await upsertRawProgramExternalIds(eids);
+ return await upsertProgramExternalIds(eids);
}
get taskName() {
diff --git a/server/src/tasks/plex/SavePlexProgramExternalIdsTask.ts b/server/src/tasks/plex/SavePlexProgramExternalIdsTask.ts
index fb24d0cd..256bdeae 100644
--- a/server/src/tasks/plex/SavePlexProgramExternalIdsTask.ts
+++ b/server/src/tasks/plex/SavePlexProgramExternalIdsTask.ts
@@ -1,5 +1,5 @@
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 { isQueryError } from '@/external/BaseApiClient.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 { mintExternalIdForPlexGuid } from '@/util/externalIds.js';
import { isDefined, isNonEmptyString } from '@/util/index.js';
+import { seq } from '@tunarr/shared/util';
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';
export type SavePlexProgramExternalIdsTaskFactory = (
@@ -78,19 +79,11 @@ export class SavePlexProgramExternalIdsTask extends Task {
const metadata = metadataResult.data as PlexTerminalMedia;
- const eids = compact(
- map(metadata.Guid, (guid) => {
- const parsed = mintExternalIdForPlexGuid(guid.id, program.uuid);
- if (parsed) {
- parsed.externalSourceId = undefined;
- return parsed;
- }
-
- return;
- }),
+ const eids = seq.collect(metadata.Guid, (guid) =>
+ mintExternalIdForPlexGuid(guid.id, program.uuid),
);
- return await upsertRawProgramExternalIds(eids);
+ return await upsertProgramExternalIds(eids);
}
get taskName() {
diff --git a/server/src/types/util.ts b/server/src/types/util.ts
index e61fd03a..fa828e2f 100644
--- a/server/src/types/util.ts
+++ b/server/src/types/util.ts
@@ -1,4 +1,8 @@
-import type { DeepNonNullable, StrictExclude } from 'ts-essentials';
+import type {
+ DeepNonNullable,
+ MarkRequired,
+ StrictExclude,
+} from 'ts-essentials';
export type Maybe = T | undefined;
@@ -31,3 +35,8 @@ export type MarkNullable = {
};
export type NonEmptyArray = [T, ...T[]];
+
+export type MarkNotNilable = MarkNonNullable<
+ MarkRequired,
+ Keys
+>;
diff --git a/server/src/util/externalIds.ts b/server/src/util/externalIds.ts
index babc3f1c..22dafd95 100644
--- a/server/src/util/externalIds.ts
+++ b/server/src/util/externalIds.ts
@@ -1,5 +1,5 @@
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 { MultiExternalId } from '@tunarr/types';
import { isValidSingleExternalIdType } from '@tunarr/types/schemas';
@@ -29,10 +29,11 @@ export const createPlexExternalId = (
export const mintExternalIdForPlexGuid = (
guid: string,
programId: string,
-): Nullable => {
+): Nullable => {
const parsed = parsePlexGuid(guid);
if (parsed) {
return {
+ type: 'single',
uuid: v4(),
createdAt: +dayjs(),
updatedAt: +dayjs(),
diff --git a/shared/src/services/ApiProgramMinter.ts b/shared/src/services/ApiProgramMinter.ts
index 63e31790..f3a84dee 100644
--- a/shared/src/services/ApiProgramMinter.ts
+++ b/shared/src/services/ApiProgramMinter.ts
@@ -83,6 +83,7 @@ export class ApiProgramMinter {
return {
type: 'content',
externalSourceType: 'plex',
+ externalSourceId: server.id,
externalSourceName: server.name,
date: plexMovie.originallyAvailableAt,
duration: plexMovie.duration ?? 0,
@@ -95,7 +96,6 @@ export class ApiProgramMinter {
subtype: 'movie',
persisted: false,
externalIds: this.mintExternalIdsForPlex(server.name, plexMovie),
- externalSourceId: server.name,
uniqueId: id,
id,
};
@@ -113,7 +113,7 @@ export class ApiProgramMinter {
index: plexEpisode.index,
externalKey: plexEpisode.ratingKey,
externalSourceName: server.name,
- externalSourceId: server.name,
+ externalSourceId: server.id,
externalSourceType: ExternalSourceTypeSchema.enum.plex,
parent: {
title: plexEpisode.parentTitle,
@@ -242,7 +242,7 @@ export class ApiProgramMinter {
persisted: false,
uniqueId: id,
id,
- externalSourceId: server.name,
+ externalSourceId: server.id,
};
}
@@ -257,7 +257,7 @@ export class ApiProgramMinter {
externalSourceType: ExternalSourceTypeSchema.enum.jellyfin,
date: nullToUndefined(item.PremiereDate),
duration: (item.RunTimeTicks ?? 0) / 10_000,
- externalSourceId: server.name,
+ externalSourceId: server.id,
externalKey: item.Id,
rating: nullToUndefined(item.OfficialRating),
summary: nullToUndefined(item.Overview),
@@ -322,7 +322,7 @@ export class ApiProgramMinter {
externalSourceType: ExternalSourceTypeSchema.enum.emby,
date: nullToUndefined(item.PremiereDate),
duration: (item.RunTimeTicks ?? 0) / 10_000,
- externalSourceId: server.name,
+ externalSourceId: server.id,
externalKey: item.Id,
rating: nullToUndefined(item.OfficialRating),
summary: nullToUndefined(item.Overview),