mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
refactor: add media_source_id to relevant entities (#1106)
We should be referencing media_sources by their ID on programs, external_ids, etc. This enables us to use proper foreign keys for referential integrity at the DB level, not worry about unique names for media sources, and simplifies a lot of the code relating to media source deletion and the cleanup thereafter. This change also introduces the DBContext, which should allow for arbitrarily calling other DB accessor functions when within transactions and not deadlocking the connection to the DB.
This commit is contained in:
committed by
GitHub
parent
9b0385e577
commit
769b05d201
@@ -45,7 +45,8 @@
|
|||||||
"packageManager": "pnpm@9.12.3",
|
"packageManager": "pnpm@9.12.3",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"ts-essentials@9.4.1": "patches/ts-essentials@9.4.1.patch"
|
"ts-essentials@9.4.1": "patches/ts-essentials@9.4.1.patch",
|
||||||
|
"kysely": "patches/kysely.patch"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"eslint": "9.17.0",
|
"eslint": "9.17.0",
|
||||||
|
|||||||
467
patches/kysely.patch
Normal file
467
patches/kysely.patch
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
diff --git a/dist/esm/index.d.ts b/dist/esm/index.d.ts
|
||||||
|
index d556ad76e2a23d600e3ee3024bad9d7dd8beccf7..5f25e02adfed39f2cbebb7db31d04e9615b1a544 100644
|
||||||
|
--- a/dist/esm/index.d.ts
|
||||||
|
+++ b/dist/esm/index.d.ts
|
||||||
|
@@ -1,95 +1,48 @@
|
||||||
|
-export * from './kysely.js';
|
||||||
|
-export * from './query-creator.js';
|
||||||
|
-export * from './expression/expression.js';
|
||||||
|
-export { ExpressionBuilder, expressionBuilder, } from './expression/expression-builder.js';
|
||||||
|
-export * from './expression/expression-wrapper.js';
|
||||||
|
-export * from './query-builder/where-interface.js';
|
||||||
|
-export * from './query-builder/returning-interface.js';
|
||||||
|
-export * from './query-builder/output-interface.js';
|
||||||
|
-export * from './query-builder/having-interface.js';
|
||||||
|
-export * from './query-builder/select-query-builder.js';
|
||||||
|
-export * from './query-builder/insert-query-builder.js';
|
||||||
|
-export * from './query-builder/update-query-builder.js';
|
||||||
|
-export * from './query-builder/delete-query-builder.js';
|
||||||
|
-export * from './query-builder/no-result-error.js';
|
||||||
|
-export * from './query-builder/join-builder.js';
|
||||||
|
-export * from './query-builder/function-module.js';
|
||||||
|
-export * from './query-builder/insert-result.js';
|
||||||
|
-export * from './query-builder/delete-result.js';
|
||||||
|
-export * from './query-builder/update-result.js';
|
||||||
|
-export * from './query-builder/on-conflict-builder.js';
|
||||||
|
-export * from './query-builder/aggregate-function-builder.js';
|
||||||
|
-export * from './query-builder/case-builder.js';
|
||||||
|
-export * from './query-builder/json-path-builder.js';
|
||||||
|
-export * from './query-builder/merge-query-builder.js';
|
||||||
|
-export * from './query-builder/merge-result.js';
|
||||||
|
-export * from './raw-builder/raw-builder.js';
|
||||||
|
-export * from './raw-builder/sql.js';
|
||||||
|
-export * from './query-executor/query-executor.js';
|
||||||
|
-export * from './query-executor/default-query-executor.js';
|
||||||
|
-export * from './query-executor/noop-query-executor.js';
|
||||||
|
-export * from './query-executor/query-executor-provider.js';
|
||||||
|
-export * from './query-compiler/default-query-compiler.js';
|
||||||
|
-export * from './query-compiler/compiled-query.js';
|
||||||
|
-export * from './schema/schema.js';
|
||||||
|
-export * from './schema/create-table-builder.js';
|
||||||
|
-export * from './schema/create-type-builder.js';
|
||||||
|
-export * from './schema/drop-table-builder.js';
|
||||||
|
-export * from './schema/drop-type-builder.js';
|
||||||
|
-export * from './schema/create-index-builder.js';
|
||||||
|
-export * from './schema/drop-index-builder.js';
|
||||||
|
-export * from './schema/create-schema-builder.js';
|
||||||
|
-export * from './schema/drop-schema-builder.js';
|
||||||
|
-export * from './schema/column-definition-builder.js';
|
||||||
|
-export * from './schema/foreign-key-constraint-builder.js';
|
||||||
|
-export * from './schema/alter-table-builder.js';
|
||||||
|
-export * from './schema/create-view-builder.js';
|
||||||
|
-export * from './schema/drop-view-builder.js';
|
||||||
|
-export * from './schema/alter-column-builder.js';
|
||||||
|
-export * from './dynamic/dynamic.js';
|
||||||
|
-export * from './driver/driver.js';
|
||||||
|
-export * from './driver/database-connection.js';
|
||||||
|
-export * from './driver/connection-provider.js';
|
||||||
|
-export * from './driver/default-connection-provider.js';
|
||||||
|
-export * from './driver/single-connection-provider.js';
|
||||||
|
-export * from './driver/dummy-driver.js';
|
||||||
|
-export * from './dialect/dialect.js';
|
||||||
|
-export * from './dialect/dialect-adapter.js';
|
||||||
|
-export * from './dialect/dialect-adapter-base.js';
|
||||||
|
export * from './dialect/database-introspector.js';
|
||||||
|
-export * from './dialect/sqlite/sqlite-dialect.js';
|
||||||
|
-export * from './dialect/sqlite/sqlite-dialect-config.js';
|
||||||
|
-export * from './dialect/sqlite/sqlite-driver.js';
|
||||||
|
-export * from './dialect/postgres/postgres-query-compiler.js';
|
||||||
|
-export * from './dialect/postgres/postgres-introspector.js';
|
||||||
|
-export * from './dialect/postgres/postgres-adapter.js';
|
||||||
|
-export * from './dialect/mysql/mysql-dialect.js';
|
||||||
|
-export * from './dialect/mysql/mysql-dialect-config.js';
|
||||||
|
-export * from './dialect/mysql/mysql-driver.js';
|
||||||
|
-export * from './dialect/mysql/mysql-query-compiler.js';
|
||||||
|
-export * from './dialect/mysql/mysql-introspector.js';
|
||||||
|
-export * from './dialect/mysql/mysql-adapter.js';
|
||||||
|
-export * from './dialect/postgres/postgres-driver.js';
|
||||||
|
-export * from './dialect/postgres/postgres-dialect-config.js';
|
||||||
|
-export * from './dialect/postgres/postgres-dialect.js';
|
||||||
|
-export * from './dialect/sqlite/sqlite-query-compiler.js';
|
||||||
|
-export * from './dialect/sqlite/sqlite-introspector.js';
|
||||||
|
-export * from './dialect/sqlite/sqlite-adapter.js';
|
||||||
|
+export * from './dialect/dialect-adapter-base.js';
|
||||||
|
+export * from './dialect/dialect-adapter.js';
|
||||||
|
+export * from './dialect/dialect.js';
|
||||||
|
export * from './dialect/mssql/mssql-adapter.js';
|
||||||
|
export * from './dialect/mssql/mssql-dialect-config.js';
|
||||||
|
export * from './dialect/mssql/mssql-dialect.js';
|
||||||
|
export * from './dialect/mssql/mssql-driver.js';
|
||||||
|
export * from './dialect/mssql/mssql-introspector.js';
|
||||||
|
export * from './dialect/mssql/mssql-query-compiler.js';
|
||||||
|
-export * from './query-compiler/default-query-compiler.js';
|
||||||
|
-export * from './query-compiler/query-compiler.js';
|
||||||
|
-export * from './migration/migrator.js';
|
||||||
|
+export * from './dialect/mysql/mysql-adapter.js';
|
||||||
|
+export * from './dialect/mysql/mysql-dialect-config.js';
|
||||||
|
+export * from './dialect/mysql/mysql-dialect.js';
|
||||||
|
+export * from './dialect/mysql/mysql-driver.js';
|
||||||
|
+export * from './dialect/mysql/mysql-introspector.js';
|
||||||
|
+export * from './dialect/mysql/mysql-query-compiler.js';
|
||||||
|
+export * from './dialect/postgres/postgres-adapter.js';
|
||||||
|
+export * from './dialect/postgres/postgres-dialect-config.js';
|
||||||
|
+export * from './dialect/postgres/postgres-dialect.js';
|
||||||
|
+export * from './dialect/postgres/postgres-driver.js';
|
||||||
|
+export * from './dialect/postgres/postgres-introspector.js';
|
||||||
|
+export * from './dialect/postgres/postgres-query-compiler.js';
|
||||||
|
+export * from './dialect/sqlite/sqlite-adapter.js';
|
||||||
|
+export * from './dialect/sqlite/sqlite-dialect-config.js';
|
||||||
|
+export * from './dialect/sqlite/sqlite-dialect.js';
|
||||||
|
+export * from './dialect/sqlite/sqlite-driver.js';
|
||||||
|
+export * from './dialect/sqlite/sqlite-introspector.js';
|
||||||
|
+export * from './dialect/sqlite/sqlite-query-compiler.js';
|
||||||
|
+export * from './driver/connection-provider.js';
|
||||||
|
+export * from './driver/database-connection.js';
|
||||||
|
+export * from './driver/default-connection-provider.js';
|
||||||
|
+export * from './driver/driver.js';
|
||||||
|
+export * from './driver/dummy-driver.js';
|
||||||
|
+export * from './driver/runtime-driver.js';
|
||||||
|
+export * from './driver/single-connection-provider.js';
|
||||||
|
+export * from './dynamic/dynamic.js';
|
||||||
|
+export {
|
||||||
|
+ ExpressionBuilder,
|
||||||
|
+ expressionBuilder,
|
||||||
|
+} from './expression/expression-builder.js';
|
||||||
|
+export * from './expression/expression-wrapper.js';
|
||||||
|
+export * from './expression/expression.js';
|
||||||
|
+export * from './kysely.js';
|
||||||
|
export * from './migration/file-migration-provider.js';
|
||||||
|
-export * from './plugin/kysely-plugin.js';
|
||||||
|
-export * from './plugin/camel-case/camel-case-plugin.js';
|
||||||
|
-export * from './plugin/deduplicate-joins/deduplicate-joins-plugin.js';
|
||||||
|
-export * from './plugin/with-schema/with-schema-plugin.js';
|
||||||
|
-export * from './plugin/parse-json-results/parse-json-results-plugin.js';
|
||||||
|
+export * from './migration/migrator.js';
|
||||||
|
export * from './operation-node/add-column-node.js';
|
||||||
|
export * from './operation-node/add-constraint-node.js';
|
||||||
|
export * from './operation-node/add-index-node.js';
|
||||||
|
@@ -190,22 +143,117 @@ export * from './operation-node/values-node.js';
|
||||||
|
export * from './operation-node/when-node.js';
|
||||||
|
export * from './operation-node/where-node.js';
|
||||||
|
export * from './operation-node/with-node.js';
|
||||||
|
+export {
|
||||||
|
+ ComparisonOperatorExpression,
|
||||||
|
+ FilterObject,
|
||||||
|
+ OperandValueExpression,
|
||||||
|
+ OperandValueExpressionOrList,
|
||||||
|
+} from './parser/binary-operation-parser.js';
|
||||||
|
+export {
|
||||||
|
+ ExpressionOrFactory,
|
||||||
|
+ OperandExpression,
|
||||||
|
+} from './parser/expression-parser.js';
|
||||||
|
+export { InsertObject } from './parser/insert-values-parser.js';
|
||||||
|
+export {
|
||||||
|
+ JoinCallbackExpression,
|
||||||
|
+ JoinReferenceExpression,
|
||||||
|
+} from './parser/join-parser.js';
|
||||||
|
+export {
|
||||||
|
+ OrderByDirectionExpression,
|
||||||
|
+ OrderByExpression,
|
||||||
|
+} from './parser/order-by-parser.js';
|
||||||
|
+export {
|
||||||
|
+ ExtractTypeFromReferenceExpression,
|
||||||
|
+ ExtractTypeFromStringReference,
|
||||||
|
+ ReferenceExpression,
|
||||||
|
+ ReferenceExpressionOrList,
|
||||||
|
+ SimpleReferenceExpression,
|
||||||
|
+ StringReference,
|
||||||
|
+} from './parser/reference-parser.js';
|
||||||
|
+export {
|
||||||
|
+ CallbackSelection,
|
||||||
|
+ SelectArg,
|
||||||
|
+ SelectCallback,
|
||||||
|
+ SelectExpression,
|
||||||
|
+ Selection,
|
||||||
|
+} from './parser/select-parser.js';
|
||||||
|
+export {
|
||||||
|
+ SimpleTableReference,
|
||||||
|
+ TableExpression,
|
||||||
|
+ TableExpressionOrList,
|
||||||
|
+} from './parser/table-parser.js';
|
||||||
|
+export { ExistsExpression } from './parser/unary-operation-parser.js';
|
||||||
|
+export { UpdateObject } from './parser/update-set-parser.js';
|
||||||
|
+export {
|
||||||
|
+ ValueExpression,
|
||||||
|
+ ValueExpressionOrList,
|
||||||
|
+} from './parser/value-parser.js';
|
||||||
|
+export * from './plugin/camel-case/camel-case-plugin.js';
|
||||||
|
+export * from './plugin/deduplicate-joins/deduplicate-joins-plugin.js';
|
||||||
|
+export * from './plugin/kysely-plugin.js';
|
||||||
|
+export * from './plugin/parse-json-results/parse-json-results-plugin.js';
|
||||||
|
+export * from './plugin/with-schema/with-schema-plugin.js';
|
||||||
|
+export * from './query-builder/aggregate-function-builder.js';
|
||||||
|
+export * from './query-builder/case-builder.js';
|
||||||
|
+export * from './query-builder/delete-query-builder.js';
|
||||||
|
+export * from './query-builder/delete-result.js';
|
||||||
|
+export * from './query-builder/function-module.js';
|
||||||
|
+export * from './query-builder/having-interface.js';
|
||||||
|
+export * from './query-builder/insert-query-builder.js';
|
||||||
|
+export * from './query-builder/insert-result.js';
|
||||||
|
+export * from './query-builder/join-builder.js';
|
||||||
|
+export * from './query-builder/json-path-builder.js';
|
||||||
|
+export * from './query-builder/merge-query-builder.js';
|
||||||
|
+export * from './query-builder/merge-result.js';
|
||||||
|
+export * from './query-builder/no-result-error.js';
|
||||||
|
+export * from './query-builder/on-conflict-builder.js';
|
||||||
|
+export * from './query-builder/output-interface.js';
|
||||||
|
+export * from './query-builder/returning-interface.js';
|
||||||
|
+export * from './query-builder/select-query-builder.js';
|
||||||
|
+export * from './query-builder/update-query-builder.js';
|
||||||
|
+export * from './query-builder/update-result.js';
|
||||||
|
+export * from './query-builder/where-interface.js';
|
||||||
|
+export * from './query-compiler/compiled-query.js';
|
||||||
|
+export * from './query-compiler/default-query-compiler.js';
|
||||||
|
+export * from './query-compiler/query-compiler.js';
|
||||||
|
+export * from './query-creator.js';
|
||||||
|
+export * from './query-executor/default-query-executor.js';
|
||||||
|
+export * from './query-executor/noop-query-executor.js';
|
||||||
|
+export * from './query-executor/query-executor-provider.js';
|
||||||
|
+export * from './query-executor/query-executor.js';
|
||||||
|
+export * from './raw-builder/raw-builder.js';
|
||||||
|
+export * from './raw-builder/sql.js';
|
||||||
|
+export * from './schema/alter-column-builder.js';
|
||||||
|
+export * from './schema/alter-table-builder.js';
|
||||||
|
+export * from './schema/column-definition-builder.js';
|
||||||
|
+export * from './schema/create-index-builder.js';
|
||||||
|
+export * from './schema/create-schema-builder.js';
|
||||||
|
+export * from './schema/create-table-builder.js';
|
||||||
|
+export * from './schema/create-type-builder.js';
|
||||||
|
+export * from './schema/create-view-builder.js';
|
||||||
|
+export * from './schema/drop-index-builder.js';
|
||||||
|
+export * from './schema/drop-schema-builder.js';
|
||||||
|
+export * from './schema/drop-table-builder.js';
|
||||||
|
+export * from './schema/drop-type-builder.js';
|
||||||
|
+export * from './schema/drop-view-builder.js';
|
||||||
|
+export * from './schema/foreign-key-constraint-builder.js';
|
||||||
|
+export * from './schema/schema.js';
|
||||||
|
export * from './util/column-type.js';
|
||||||
|
export * from './util/compilable.js';
|
||||||
|
export * from './util/explainable.js';
|
||||||
|
-export * from './util/streamable.js';
|
||||||
|
-export * from './util/log.js';
|
||||||
|
-export { AnyAliasedColumn, AnyAliasedColumnWithTable, AnyColumn, AnyColumnWithTable, Equals, UnknownRow, Simplify, SqlBool, Nullable, NotNull, } from './util/type-utils.js';
|
||||||
|
export * from './util/infer-result.js';
|
||||||
|
export { logOnce } from './util/log-once.js';
|
||||||
|
-export { SelectExpression, SelectCallback, SelectArg, Selection, CallbackSelection, } from './parser/select-parser.js';
|
||||||
|
-export { ReferenceExpression, ReferenceExpressionOrList, SimpleReferenceExpression, StringReference, ExtractTypeFromStringReference, ExtractTypeFromReferenceExpression, } from './parser/reference-parser.js';
|
||||||
|
-export { ValueExpression, ValueExpressionOrList, } from './parser/value-parser.js';
|
||||||
|
-export { SimpleTableReference, TableExpression, TableExpressionOrList, } from './parser/table-parser.js';
|
||||||
|
-export { JoinReferenceExpression, JoinCallbackExpression, } from './parser/join-parser.js';
|
||||||
|
-export { InsertObject } from './parser/insert-values-parser.js';
|
||||||
|
-export { UpdateObject } from './parser/update-set-parser.js';
|
||||||
|
-export { OrderByExpression, OrderByDirectionExpression, } from './parser/order-by-parser.js';
|
||||||
|
-export { ComparisonOperatorExpression, OperandValueExpression, OperandValueExpressionOrList, FilterObject, } from './parser/binary-operation-parser.js';
|
||||||
|
-export { ExistsExpression } from './parser/unary-operation-parser.js';
|
||||||
|
-export { OperandExpression, ExpressionOrFactory, } from './parser/expression-parser.js';
|
||||||
|
+export * from './util/log.js';
|
||||||
|
+export * from './util/streamable.js';
|
||||||
|
+export {
|
||||||
|
+ AnyAliasedColumn,
|
||||||
|
+ AnyAliasedColumnWithTable,
|
||||||
|
+ AnyColumn,
|
||||||
|
+ AnyColumnWithTable,
|
||||||
|
+ Equals,
|
||||||
|
+ NotNull,
|
||||||
|
+ Nullable,
|
||||||
|
+ Simplify,
|
||||||
|
+ SqlBool,
|
||||||
|
+ UnknownRow,
|
||||||
|
+} from './util/type-utils.js';
|
||||||
|
diff --git a/dist/esm/index.js b/dist/esm/index.js
|
||||||
|
index c85949e9080a6e78f1aef5561ed050cb6d924b15..6ddadd439b6f5aa4b45749e3297035a7ad0b3496 100644
|
||||||
|
--- a/dist/esm/index.js
|
||||||
|
+++ b/dist/esm/index.js
|
||||||
|
@@ -1,96 +1,46 @@
|
||||||
|
/// <reference types="./index.d.ts" />
|
||||||
|
-export * from './kysely.js';
|
||||||
|
-export * from './query-creator.js';
|
||||||
|
-export * from './expression/expression.js';
|
||||||
|
-export { expressionBuilder, } from './expression/expression-builder.js';
|
||||||
|
-export * from './expression/expression-wrapper.js';
|
||||||
|
-export * from './query-builder/where-interface.js';
|
||||||
|
-export * from './query-builder/returning-interface.js';
|
||||||
|
-export * from './query-builder/output-interface.js';
|
||||||
|
-export * from './query-builder/having-interface.js';
|
||||||
|
-export * from './query-builder/select-query-builder.js';
|
||||||
|
-export * from './query-builder/insert-query-builder.js';
|
||||||
|
-export * from './query-builder/update-query-builder.js';
|
||||||
|
-export * from './query-builder/delete-query-builder.js';
|
||||||
|
-export * from './query-builder/no-result-error.js';
|
||||||
|
-export * from './query-builder/join-builder.js';
|
||||||
|
-export * from './query-builder/function-module.js';
|
||||||
|
-export * from './query-builder/insert-result.js';
|
||||||
|
-export * from './query-builder/delete-result.js';
|
||||||
|
-export * from './query-builder/update-result.js';
|
||||||
|
-export * from './query-builder/on-conflict-builder.js';
|
||||||
|
-export * from './query-builder/aggregate-function-builder.js';
|
||||||
|
-export * from './query-builder/case-builder.js';
|
||||||
|
-export * from './query-builder/json-path-builder.js';
|
||||||
|
-export * from './query-builder/merge-query-builder.js';
|
||||||
|
-export * from './query-builder/merge-result.js';
|
||||||
|
-export * from './raw-builder/raw-builder.js';
|
||||||
|
-export * from './raw-builder/sql.js';
|
||||||
|
-export * from './query-executor/query-executor.js';
|
||||||
|
-export * from './query-executor/default-query-executor.js';
|
||||||
|
-export * from './query-executor/noop-query-executor.js';
|
||||||
|
-export * from './query-executor/query-executor-provider.js';
|
||||||
|
-export * from './query-compiler/default-query-compiler.js';
|
||||||
|
-export * from './query-compiler/compiled-query.js';
|
||||||
|
-export * from './schema/schema.js';
|
||||||
|
-export * from './schema/create-table-builder.js';
|
||||||
|
-export * from './schema/create-type-builder.js';
|
||||||
|
-export * from './schema/drop-table-builder.js';
|
||||||
|
-export * from './schema/drop-type-builder.js';
|
||||||
|
-export * from './schema/create-index-builder.js';
|
||||||
|
-export * from './schema/drop-index-builder.js';
|
||||||
|
-export * from './schema/create-schema-builder.js';
|
||||||
|
-export * from './schema/drop-schema-builder.js';
|
||||||
|
-export * from './schema/column-definition-builder.js';
|
||||||
|
-export * from './schema/foreign-key-constraint-builder.js';
|
||||||
|
-export * from './schema/alter-table-builder.js';
|
||||||
|
-export * from './schema/create-view-builder.js';
|
||||||
|
-export * from './schema/drop-view-builder.js';
|
||||||
|
-export * from './schema/alter-column-builder.js';
|
||||||
|
-export * from './dynamic/dynamic.js';
|
||||||
|
-export * from './driver/driver.js';
|
||||||
|
-export * from './driver/database-connection.js';
|
||||||
|
-export * from './driver/connection-provider.js';
|
||||||
|
-export * from './driver/default-connection-provider.js';
|
||||||
|
-export * from './driver/single-connection-provider.js';
|
||||||
|
-export * from './driver/dummy-driver.js';
|
||||||
|
-export * from './dialect/dialect.js';
|
||||||
|
-export * from './dialect/dialect-adapter.js';
|
||||||
|
-export * from './dialect/dialect-adapter-base.js';
|
||||||
|
export * from './dialect/database-introspector.js';
|
||||||
|
-export * from './dialect/sqlite/sqlite-dialect.js';
|
||||||
|
-export * from './dialect/sqlite/sqlite-dialect-config.js';
|
||||||
|
-export * from './dialect/sqlite/sqlite-driver.js';
|
||||||
|
-export * from './dialect/postgres/postgres-query-compiler.js';
|
||||||
|
-export * from './dialect/postgres/postgres-introspector.js';
|
||||||
|
-export * from './dialect/postgres/postgres-adapter.js';
|
||||||
|
-export * from './dialect/mysql/mysql-dialect.js';
|
||||||
|
-export * from './dialect/mysql/mysql-dialect-config.js';
|
||||||
|
-export * from './dialect/mysql/mysql-driver.js';
|
||||||
|
-export * from './dialect/mysql/mysql-query-compiler.js';
|
||||||
|
-export * from './dialect/mysql/mysql-introspector.js';
|
||||||
|
-export * from './dialect/mysql/mysql-adapter.js';
|
||||||
|
-export * from './dialect/postgres/postgres-driver.js';
|
||||||
|
-export * from './dialect/postgres/postgres-dialect-config.js';
|
||||||
|
-export * from './dialect/postgres/postgres-dialect.js';
|
||||||
|
-export * from './dialect/sqlite/sqlite-query-compiler.js';
|
||||||
|
-export * from './dialect/sqlite/sqlite-introspector.js';
|
||||||
|
-export * from './dialect/sqlite/sqlite-adapter.js';
|
||||||
|
+export * from './dialect/dialect-adapter-base.js';
|
||||||
|
+export * from './dialect/dialect-adapter.js';
|
||||||
|
+export * from './dialect/dialect.js';
|
||||||
|
export * from './dialect/mssql/mssql-adapter.js';
|
||||||
|
export * from './dialect/mssql/mssql-dialect-config.js';
|
||||||
|
export * from './dialect/mssql/mssql-dialect.js';
|
||||||
|
export * from './dialect/mssql/mssql-driver.js';
|
||||||
|
export * from './dialect/mssql/mssql-introspector.js';
|
||||||
|
export * from './dialect/mssql/mssql-query-compiler.js';
|
||||||
|
-export * from './query-compiler/default-query-compiler.js';
|
||||||
|
-export * from './query-compiler/query-compiler.js';
|
||||||
|
-export * from './migration/migrator.js';
|
||||||
|
+export * from './dialect/mysql/mysql-adapter.js';
|
||||||
|
+export * from './dialect/mysql/mysql-dialect-config.js';
|
||||||
|
+export * from './dialect/mysql/mysql-dialect.js';
|
||||||
|
+export * from './dialect/mysql/mysql-driver.js';
|
||||||
|
+export * from './dialect/mysql/mysql-introspector.js';
|
||||||
|
+export * from './dialect/mysql/mysql-query-compiler.js';
|
||||||
|
+export * from './dialect/postgres/postgres-adapter.js';
|
||||||
|
+export * from './dialect/postgres/postgres-dialect-config.js';
|
||||||
|
+export * from './dialect/postgres/postgres-dialect.js';
|
||||||
|
+export * from './dialect/postgres/postgres-driver.js';
|
||||||
|
+export * from './dialect/postgres/postgres-introspector.js';
|
||||||
|
+export * from './dialect/postgres/postgres-query-compiler.js';
|
||||||
|
+export * from './dialect/sqlite/sqlite-adapter.js';
|
||||||
|
+export * from './dialect/sqlite/sqlite-dialect-config.js';
|
||||||
|
+export * from './dialect/sqlite/sqlite-dialect.js';
|
||||||
|
+export * from './dialect/sqlite/sqlite-driver.js';
|
||||||
|
+export * from './dialect/sqlite/sqlite-introspector.js';
|
||||||
|
+export * from './dialect/sqlite/sqlite-query-compiler.js';
|
||||||
|
+export * from './driver/connection-provider.js';
|
||||||
|
+export * from './driver/database-connection.js';
|
||||||
|
+export * from './driver/default-connection-provider.js';
|
||||||
|
+export * from './driver/driver.js';
|
||||||
|
+export * from './driver/dummy-driver.js';
|
||||||
|
+export * from './driver/runtime-driver.js';
|
||||||
|
+export * from './driver/single-connection-provider.js';
|
||||||
|
+export * from './dynamic/dynamic.js';
|
||||||
|
+export { expressionBuilder } from './expression/expression-builder.js';
|
||||||
|
+export * from './expression/expression-wrapper.js';
|
||||||
|
+export * from './expression/expression.js';
|
||||||
|
+export * from './kysely.js';
|
||||||
|
export * from './migration/file-migration-provider.js';
|
||||||
|
-export * from './plugin/kysely-plugin.js';
|
||||||
|
-export * from './plugin/camel-case/camel-case-plugin.js';
|
||||||
|
-export * from './plugin/deduplicate-joins/deduplicate-joins-plugin.js';
|
||||||
|
-export * from './plugin/with-schema/with-schema-plugin.js';
|
||||||
|
-export * from './plugin/parse-json-results/parse-json-results-plugin.js';
|
||||||
|
+export * from './migration/migrator.js';
|
||||||
|
export * from './operation-node/add-column-node.js';
|
||||||
|
export * from './operation-node/add-constraint-node.js';
|
||||||
|
export * from './operation-node/add-index-node.js';
|
||||||
|
@@ -191,10 +141,60 @@ export * from './operation-node/values-node.js';
|
||||||
|
export * from './operation-node/when-node.js';
|
||||||
|
export * from './operation-node/where-node.js';
|
||||||
|
export * from './operation-node/with-node.js';
|
||||||
|
+export * from './plugin/camel-case/camel-case-plugin.js';
|
||||||
|
+export * from './plugin/deduplicate-joins/deduplicate-joins-plugin.js';
|
||||||
|
+export * from './plugin/kysely-plugin.js';
|
||||||
|
+export * from './plugin/parse-json-results/parse-json-results-plugin.js';
|
||||||
|
+export * from './plugin/with-schema/with-schema-plugin.js';
|
||||||
|
+export * from './query-builder/aggregate-function-builder.js';
|
||||||
|
+export * from './query-builder/case-builder.js';
|
||||||
|
+export * from './query-builder/delete-query-builder.js';
|
||||||
|
+export * from './query-builder/delete-result.js';
|
||||||
|
+export * from './query-builder/function-module.js';
|
||||||
|
+export * from './query-builder/having-interface.js';
|
||||||
|
+export * from './query-builder/insert-query-builder.js';
|
||||||
|
+export * from './query-builder/insert-result.js';
|
||||||
|
+export * from './query-builder/join-builder.js';
|
||||||
|
+export * from './query-builder/json-path-builder.js';
|
||||||
|
+export * from './query-builder/merge-query-builder.js';
|
||||||
|
+export * from './query-builder/merge-result.js';
|
||||||
|
+export * from './query-builder/no-result-error.js';
|
||||||
|
+export * from './query-builder/on-conflict-builder.js';
|
||||||
|
+export * from './query-builder/output-interface.js';
|
||||||
|
+export * from './query-builder/returning-interface.js';
|
||||||
|
+export * from './query-builder/select-query-builder.js';
|
||||||
|
+export * from './query-builder/update-query-builder.js';
|
||||||
|
+export * from './query-builder/update-result.js';
|
||||||
|
+export * from './query-builder/where-interface.js';
|
||||||
|
+export * from './query-compiler/compiled-query.js';
|
||||||
|
+export * from './query-compiler/default-query-compiler.js';
|
||||||
|
+export * from './query-compiler/query-compiler.js';
|
||||||
|
+export * from './query-creator.js';
|
||||||
|
+export * from './query-executor/default-query-executor.js';
|
||||||
|
+export * from './query-executor/noop-query-executor.js';
|
||||||
|
+export * from './query-executor/query-executor-provider.js';
|
||||||
|
+export * from './query-executor/query-executor.js';
|
||||||
|
+export * from './raw-builder/raw-builder.js';
|
||||||
|
+export * from './raw-builder/sql.js';
|
||||||
|
+export * from './schema/alter-column-builder.js';
|
||||||
|
+export * from './schema/alter-table-builder.js';
|
||||||
|
+export * from './schema/column-definition-builder.js';
|
||||||
|
+export * from './schema/create-index-builder.js';
|
||||||
|
+export * from './schema/create-schema-builder.js';
|
||||||
|
+export * from './schema/create-table-builder.js';
|
||||||
|
+export * from './schema/create-type-builder.js';
|
||||||
|
+export * from './schema/create-view-builder.js';
|
||||||
|
+export * from './schema/drop-index-builder.js';
|
||||||
|
+export * from './schema/drop-schema-builder.js';
|
||||||
|
+export * from './schema/drop-table-builder.js';
|
||||||
|
+export * from './schema/drop-type-builder.js';
|
||||||
|
+export * from './schema/drop-view-builder.js';
|
||||||
|
+export * from './schema/foreign-key-constraint-builder.js';
|
||||||
|
+export * from './schema/schema.js';
|
||||||
|
export * from './util/column-type.js';
|
||||||
|
export * from './util/compilable.js';
|
||||||
|
export * from './util/explainable.js';
|
||||||
|
-export * from './util/streamable.js';
|
||||||
|
-export * from './util/log.js';
|
||||||
|
export * from './util/infer-result.js';
|
||||||
|
export { logOnce } from './util/log-once.js';
|
||||||
|
+export * from './util/log.js';
|
||||||
|
+export * from './util/streamable.js';
|
||||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@@ -9,6 +9,9 @@ overrides:
|
|||||||
'@types/node': 22.10.7
|
'@types/node': 22.10.7
|
||||||
|
|
||||||
patchedDependencies:
|
patchedDependencies:
|
||||||
|
kysely:
|
||||||
|
hash: 5ewjqngd4oyjgaqa3fcufrviwq
|
||||||
|
path: patches/kysely.patch
|
||||||
ts-essentials@9.4.1:
|
ts-essentials@9.4.1:
|
||||||
hash: 254pvnpgpcwoswa4ncfq4tq6su
|
hash: 254pvnpgpcwoswa4ncfq4tq6su
|
||||||
path: patches/ts-essentials@9.4.1.patch
|
path: patches/ts-essentials@9.4.1.patch
|
||||||
@@ -145,7 +148,7 @@ importers:
|
|||||||
version: 1.11.10
|
version: 1.11.10
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.39.3
|
specifier: ^0.39.3
|
||||||
version: 0.39.3(@opentelemetry/api@1.4.1)(@types/better-sqlite3@7.6.12)(better-sqlite3@11.8.1)(bun-types@1.2.1)(kysely@0.27.4)
|
version: 0.39.3(@opentelemetry/api@1.4.1)(@types/better-sqlite3@7.6.12)(better-sqlite3@11.8.1)(bun-types@1.2.1)(kysely@0.27.4(patch_hash=5ewjqngd4oyjgaqa3fcufrviwq))
|
||||||
fast-xml-parser:
|
fast-xml-parser:
|
||||||
specifier: ^4.3.5
|
specifier: ^4.3.5
|
||||||
version: 4.3.5
|
version: 4.3.5
|
||||||
@@ -172,7 +175,7 @@ importers:
|
|||||||
version: 6.2.1(reflect-metadata@0.2.2)
|
version: 6.2.1(reflect-metadata@0.2.2)
|
||||||
kysely:
|
kysely:
|
||||||
specifier: ^0.27.4
|
specifier: ^0.27.4
|
||||||
version: 0.27.4
|
version: 0.27.4(patch_hash=5ewjqngd4oyjgaqa3fcufrviwq)
|
||||||
lodash-es:
|
lodash-es:
|
||||||
specifier: ^4.17.21
|
specifier: ^4.17.21
|
||||||
version: 4.17.21
|
version: 4.17.21
|
||||||
@@ -293,7 +296,7 @@ importers:
|
|||||||
version: 15.0.0
|
version: 15.0.0
|
||||||
kysely-ctl:
|
kysely-ctl:
|
||||||
specifier: ^0.9.0
|
specifier: ^0.9.0
|
||||||
version: 0.9.0(kysely@0.27.4)(magicast@0.3.5)
|
version: 0.9.0(kysely@0.27.4(patch_hash=5ewjqngd4oyjgaqa3fcufrviwq))(magicast@0.3.5)
|
||||||
make-vfs:
|
make-vfs:
|
||||||
specifier: ^1.0.15
|
specifier: ^1.0.15
|
||||||
version: 1.0.15
|
version: 1.0.15
|
||||||
@@ -11087,13 +11090,13 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
drizzle-orm@0.39.3(@opentelemetry/api@1.4.1)(@types/better-sqlite3@7.6.12)(better-sqlite3@11.8.1)(bun-types@1.2.1)(kysely@0.27.4):
|
drizzle-orm@0.39.3(@opentelemetry/api@1.4.1)(@types/better-sqlite3@7.6.12)(better-sqlite3@11.8.1)(bun-types@1.2.1)(kysely@0.27.4(patch_hash=5ewjqngd4oyjgaqa3fcufrviwq)):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@opentelemetry/api': 1.4.1
|
'@opentelemetry/api': 1.4.1
|
||||||
'@types/better-sqlite3': 7.6.12
|
'@types/better-sqlite3': 7.6.12
|
||||||
better-sqlite3: 11.8.1
|
better-sqlite3: 11.8.1
|
||||||
bun-types: 1.2.1
|
bun-types: 1.2.1
|
||||||
kysely: 0.27.4
|
kysely: 0.27.4(patch_hash=5ewjqngd4oyjgaqa3fcufrviwq)
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -12565,12 +12568,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
|
|
||||||
kysely-ctl@0.9.0(kysely@0.27.4)(magicast@0.3.5):
|
kysely-ctl@0.9.0(kysely@0.27.4(patch_hash=5ewjqngd4oyjgaqa3fcufrviwq))(magicast@0.3.5):
|
||||||
dependencies:
|
dependencies:
|
||||||
c12: 1.11.2(magicast@0.3.5)
|
c12: 1.11.2(magicast@0.3.5)
|
||||||
citty: 0.1.6
|
citty: 0.1.6
|
||||||
consola: 3.2.3
|
consola: 3.2.3
|
||||||
kysely: 0.27.4
|
kysely: 0.27.4(patch_hash=5ewjqngd4oyjgaqa3fcufrviwq)
|
||||||
nypm: 0.3.12
|
nypm: 0.3.12
|
||||||
ofetch: 1.4.1
|
ofetch: 1.4.1
|
||||||
pathe: 1.1.2
|
pathe: 1.1.2
|
||||||
@@ -12580,7 +12583,7 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- magicast
|
- magicast
|
||||||
|
|
||||||
kysely@0.27.4: {}
|
kysely@0.27.4(patch_hash=5ewjqngd4oyjgaqa3fcufrviwq): {}
|
||||||
|
|
||||||
lazystream@1.0.1:
|
lazystream@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { HdhrApiRouter } from './api/hdhrApi.js';
|
|||||||
import { apiRouter } from './api/index.js';
|
import { apiRouter } from './api/index.js';
|
||||||
import { streamApi } from './api/streamApi.js';
|
import { streamApi } from './api/streamApi.js';
|
||||||
import { videoApiRouter } from './api/videoApi.js';
|
import { videoApiRouter } from './api/videoApi.js';
|
||||||
|
import { DBContext, makeDatabaseConnection } from './db/DBAccess.ts';
|
||||||
import { FfmpegInfo } from './ffmpeg/ffmpegInfo.js';
|
import { FfmpegInfo } from './ffmpeg/ffmpegInfo.js';
|
||||||
import {
|
import {
|
||||||
type ServerOptions,
|
type ServerOptions,
|
||||||
@@ -439,6 +440,7 @@ export class Server {
|
|||||||
|
|
||||||
this.app.after(() => {
|
this.app.after(() => {
|
||||||
this.app.gracefulShutdown(async (signal) => {
|
this.app.gracefulShutdown(async (signal) => {
|
||||||
|
DBContext.enter(makeDatabaseConnection());
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
'Received exit signal %s, attempting graceful shutdown',
|
'Received exit signal %s, attempting graceful shutdown',
|
||||||
signal,
|
signal,
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import path from 'node:path';
|
|||||||
import type { DeepPartial } from 'ts-essentials';
|
import type { DeepPartial } from 'ts-essentials';
|
||||||
import {
|
import {
|
||||||
databaseNeedsMigration,
|
databaseNeedsMigration,
|
||||||
|
DBContext,
|
||||||
getDatabase,
|
getDatabase,
|
||||||
|
makeDatabaseConnection,
|
||||||
migrateExistingDatabase,
|
migrateExistingDatabase,
|
||||||
runDBMigrations,
|
runDBMigrations,
|
||||||
syncMigrationTablesIfNecessary,
|
syncMigrationTablesIfNecessary,
|
||||||
@@ -74,18 +76,20 @@ export async function bootstrapTunarr(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await initDbDirectories(opts);
|
await initDbDirectories(opts);
|
||||||
const db = getDatabase(); // Initialize the DB
|
await DBContext.create(makeDatabaseConnection(), async () => {
|
||||||
|
const db = getDatabase(); // Initialize the DB
|
||||||
|
|
||||||
// not the first run, use the copy migrator
|
// not the first run, use the copy migrator
|
||||||
if (hasTunarrDb) {
|
if (hasTunarrDb) {
|
||||||
const migrationNecessary = await databaseNeedsMigration(db);
|
const migrationNecessary = await databaseNeedsMigration(db);
|
||||||
if (migrationNecessary) {
|
if (migrationNecessary) {
|
||||||
await migrateExistingDatabase(getDefaultDatabaseName());
|
await migrateExistingDatabase(getDefaultDatabaseName());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await syncMigrationTablesIfNecessary(db);
|
||||||
|
await runDBMigrations(db);
|
||||||
}
|
}
|
||||||
} else {
|
});
|
||||||
await syncMigrationTablesIfNecessary(db);
|
|
||||||
await runDBMigrations(db);
|
|
||||||
}
|
|
||||||
|
|
||||||
LoggerFactory.initialize(settingsDb);
|
LoggerFactory.initialize(settingsDb);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { container } from '@/container.js';
|
|||||||
import { isProduction } from '@/util/index.js';
|
import { isProduction } from '@/util/index.js';
|
||||||
import { type MarkOptional } from 'ts-essentials';
|
import { type MarkOptional } from 'ts-essentials';
|
||||||
import type { ArgumentsCamelCase, CommandModule } from 'yargs';
|
import type { ArgumentsCamelCase, CommandModule } from 'yargs';
|
||||||
|
import { DBContext } from '../db/DBAccess.ts';
|
||||||
import { type ISettingsDB } from '../db/interfaces/ISettingsDB.ts';
|
import { type ISettingsDB } from '../db/interfaces/ISettingsDB.ts';
|
||||||
import { setServerOptions } from '../globals.ts';
|
import { setServerOptions } from '../globals.ts';
|
||||||
import { Server } from '../Server.ts';
|
import { Server } from '../Server.ts';
|
||||||
import { StartupService } from '../services/StartupService.ts';
|
import { StartupService } from '../services/StartupService.ts';
|
||||||
import { KEYS } from '../types/inject.ts';
|
import { KEYS } from '../types/inject.ts';
|
||||||
|
import { getDefaultDatabaseName } from '../util/defaults.ts';
|
||||||
import {
|
import {
|
||||||
getBooleanEnvVar,
|
getBooleanEnvVar,
|
||||||
getNumericEnvVar,
|
getNumericEnvVar,
|
||||||
@@ -52,7 +54,10 @@ export const RunServerCommand: CommandModule<GlobalArgsType, ServerArgsType> = {
|
|||||||
portSetting;
|
portSetting;
|
||||||
// port precedence - env var -> argument -> settings
|
// port precedence - env var -> argument -> settings
|
||||||
setServerOptions({ ...opts, port: portToUse });
|
setServerOptions({ ...opts, port: portToUse });
|
||||||
await container.get(StartupService).runStartupServices();
|
|
||||||
await container.get(Server).initAndRun();
|
await DBContext.createForName(getDefaultDatabaseName(), async () => {
|
||||||
|
await container.get(StartupService).runStartupServices();
|
||||||
|
await container.get(Server).initAndRun();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1172,6 +1172,39 @@ export class ChannelDB implements IChannelDB {
|
|||||||
await this.saveLineup(channelId, lineup);
|
await this.saveLineup(channelId, lineup);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async removeProgramsFromAllLineups(programIds: string[]): Promise<void> {
|
||||||
|
if (isEmpty(programIds)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineups = await this.loadAllLineups();
|
||||||
|
|
||||||
|
const programsToRemove = new Set(programIds);
|
||||||
|
for (const [channelId, { lineup }] of Object.entries(lineups)) {
|
||||||
|
const newLineupItems: LineupItem[] = lineup.items.map((item) => {
|
||||||
|
switch (item.type) {
|
||||||
|
case 'content': {
|
||||||
|
if (programsToRemove.has(item.id)) {
|
||||||
|
return {
|
||||||
|
type: 'offline',
|
||||||
|
durationMs: item.durationMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
case 'offline':
|
||||||
|
case 'redirect':
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.saveLineup(channelId, {
|
||||||
|
...lineup,
|
||||||
|
items: newLineupItems,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async createLineup(channelId: string) {
|
private async createLineup(channelId: string) {
|
||||||
const db = await this.getFileDb(channelId);
|
const db = await this.getFileDb(channelId);
|
||||||
await db.write();
|
await db.write();
|
||||||
|
|||||||
@@ -7,19 +7,32 @@ import {
|
|||||||
drizzle,
|
drizzle,
|
||||||
type BetterSQLite3Database,
|
type BetterSQLite3Database,
|
||||||
} from 'drizzle-orm/better-sqlite3';
|
} from 'drizzle-orm/better-sqlite3';
|
||||||
|
import type {
|
||||||
|
IsolationLevel,
|
||||||
|
KyselyConfig,
|
||||||
|
KyselyProps,
|
||||||
|
Transaction,
|
||||||
|
} from 'kysely';
|
||||||
import {
|
import {
|
||||||
CamelCasePlugin,
|
CamelCasePlugin,
|
||||||
|
DefaultConnectionProvider,
|
||||||
|
DefaultQueryExecutor,
|
||||||
Kysely,
|
Kysely,
|
||||||
|
Log,
|
||||||
Migrator,
|
Migrator,
|
||||||
ParseJSONResultsPlugin,
|
ParseJSONResultsPlugin,
|
||||||
|
RuntimeDriver,
|
||||||
SqliteDialect,
|
SqliteDialect,
|
||||||
|
TransactionBuilder,
|
||||||
} from 'kysely';
|
} from 'kysely';
|
||||||
import { findIndex, has, isError, last, map, once, slice } from 'lodash-es';
|
import { findIndex, has, isError, last, map, once, slice } from 'lodash-es';
|
||||||
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||||
import { DatabaseCopyMigrator } from '../migration/db/DatabaseCopyMigrator.ts';
|
import { DatabaseCopyMigrator } from '../migration/db/DatabaseCopyMigrator.ts';
|
||||||
import {
|
import {
|
||||||
DirectMigrationProvider,
|
DirectMigrationProvider,
|
||||||
LegacyMigrationNameToNewMigrationName,
|
LegacyMigrationNameToNewMigrationName,
|
||||||
} from '../migration/DirectMigrationProvider.ts';
|
} from '../migration/DirectMigrationProvider.ts';
|
||||||
|
import type { Maybe } from '../types/util.ts';
|
||||||
import { getDefaultDatabaseName } from '../util/defaults.ts';
|
import { getDefaultDatabaseName } from '../util/defaults.ts';
|
||||||
import type { DB } from './schema/db.ts';
|
import type { DB } from './schema/db.ts';
|
||||||
|
|
||||||
@@ -30,6 +43,7 @@ export const MigrationLockTableName = 'migration_lock';
|
|||||||
|
|
||||||
// let _directDbAccess: Kysely<DB>;
|
// let _directDbAccess: Kysely<DB>;
|
||||||
type Conn = {
|
type Conn = {
|
||||||
|
name: string;
|
||||||
kysely: Kysely<DB>;
|
kysely: Kysely<DB>;
|
||||||
drizzle: BetterSQLite3Database;
|
drizzle: BetterSQLite3Database;
|
||||||
};
|
};
|
||||||
@@ -38,55 +52,176 @@ const connections = new Map<string, Conn>();
|
|||||||
|
|
||||||
const logger = once(() => LoggerFactory.child({ className: 'DirectDBAccess' }));
|
const logger = once(() => LoggerFactory.child({ className: 'DirectDBAccess' }));
|
||||||
|
|
||||||
|
export class DBContext {
|
||||||
|
private static storage = new AsyncLocalStorage<DBContext>();
|
||||||
|
|
||||||
|
constructor(private connections: Map<string, Conn> = new Map()) {}
|
||||||
|
|
||||||
|
get db(): Maybe<Kysely<DB>> {
|
||||||
|
return this.getKyselyDatabase();
|
||||||
|
}
|
||||||
|
|
||||||
|
getConnection(name: string = getDefaultDatabaseName()): Maybe<Conn> {
|
||||||
|
return this.connections.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
getKyselyDatabase(
|
||||||
|
name: string = getDefaultDatabaseName(),
|
||||||
|
): Maybe<Kysely<DB>> {
|
||||||
|
return this.getConnection(name)?.kysely;
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrCreateKyselyDatabase(name: string): Kysely<DB> {
|
||||||
|
return this.getOrCreateConnection(name)?.kysely;
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrCreateConnection(name: string): Conn {
|
||||||
|
const existing = this.connections.get(name);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const conn = makeDatabaseConnection(name);
|
||||||
|
this.connections.set(name, conn);
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
setConnection(name: string) {
|
||||||
|
this.connections.set(name, makeDatabaseConnection(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
static create<T>(context: Conn, next: (...args: unknown[]) => T) {
|
||||||
|
const m = new Map<string, Conn>([[context.name, context]]);
|
||||||
|
return this.storage.run(new DBContext(m), next);
|
||||||
|
}
|
||||||
|
|
||||||
|
static createForName<T>(name: string, next: (...args: unknown[]) => T) {
|
||||||
|
if (connections.has(name)) {
|
||||||
|
return this.create(connections.get(name)!, next);
|
||||||
|
}
|
||||||
|
return this.create(makeDatabaseConnection(name), next);
|
||||||
|
}
|
||||||
|
|
||||||
|
static enter(context: Conn) {
|
||||||
|
const m = new Map<string, Conn>([[context.name, context]]);
|
||||||
|
this.storage.enterWith(new DBContext(m));
|
||||||
|
}
|
||||||
|
|
||||||
|
static currentDBContext(): Maybe<DBContext> {
|
||||||
|
return this.storage.getStore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TransactionBuilderWrapper extends TransactionBuilder<DB> {
|
||||||
|
constructor(
|
||||||
|
private dbName: string,
|
||||||
|
props: KyselyProps & { isolationLevel?: IsolationLevel },
|
||||||
|
) {
|
||||||
|
super(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
execute<T>(callback: (trx: Transaction<DB>) => Promise<T>): Promise<T> {
|
||||||
|
return super.execute((tx) => {
|
||||||
|
const curr = DBContext.currentDBContext()?.getConnection(this.dbName);
|
||||||
|
if (!curr) {
|
||||||
|
throw new Error('no DB context');
|
||||||
|
}
|
||||||
|
|
||||||
|
return DBContext.create({ ...curr, kysely: tx }, () => callback(tx));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class KyselyWrapper extends Kysely<DB> {
|
||||||
|
constructor(
|
||||||
|
private dbName: string,
|
||||||
|
private config: KyselyConfig,
|
||||||
|
) {
|
||||||
|
super(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction(): TransactionBuilder<DB> {
|
||||||
|
const driver = new RuntimeDriver(
|
||||||
|
this.config.dialect.createDriver(),
|
||||||
|
new Log(this.config.log ?? []),
|
||||||
|
);
|
||||||
|
return new TransactionBuilderWrapper(this.dbName, {
|
||||||
|
config: this.config,
|
||||||
|
dialect: this.config.dialect,
|
||||||
|
driver,
|
||||||
|
executor: new DefaultQueryExecutor(
|
||||||
|
this.config.dialect.createQueryCompiler(),
|
||||||
|
this.config.dialect.createAdapter(),
|
||||||
|
new DefaultConnectionProvider(driver),
|
||||||
|
this.config.plugins ?? [],
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeDatabaseConnection(
|
||||||
|
dbName: string = getDefaultDatabaseName(),
|
||||||
|
): Conn {
|
||||||
|
const dbConn = new Sqlite(dbName, {
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const kysely = new KyselyWrapper(dbName, {
|
||||||
|
dialect: new SqliteDialect({
|
||||||
|
database: dbConn,
|
||||||
|
}),
|
||||||
|
log: (event) => {
|
||||||
|
switch (event.level) {
|
||||||
|
case 'query':
|
||||||
|
if (
|
||||||
|
process.env['DATABASE_DEBUG_LOGGING'] ||
|
||||||
|
process.env['DIRECT_DATABASE_DEBUG_LOGGING']
|
||||||
|
) {
|
||||||
|
logger().setBindings({ db: dbName });
|
||||||
|
logger().debug(
|
||||||
|
'Query: %O (%d ms)',
|
||||||
|
event.query.sql,
|
||||||
|
event.queryDurationMillis,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case 'error':
|
||||||
|
logger().setBindings({ db: dbName });
|
||||||
|
logger().error(
|
||||||
|
event.error,
|
||||||
|
'Query error: %O\n%O',
|
||||||
|
event.query.sql,
|
||||||
|
event.query.parameters,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [new ParseJSONResultsPlugin(), new CamelCasePlugin()],
|
||||||
|
});
|
||||||
|
|
||||||
|
const drizzleConn = drizzle({
|
||||||
|
client: dbConn,
|
||||||
|
casing: 'snake_case',
|
||||||
|
});
|
||||||
|
|
||||||
|
const connection = { name: dbName, kysely, drizzle: drizzleConn };
|
||||||
|
|
||||||
|
connections.set(dbName, connection);
|
||||||
|
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDatabaseContext() {
|
||||||
|
return DBContext.currentDBContext();
|
||||||
|
}
|
||||||
|
|
||||||
export const getDatabase = (
|
export const getDatabase = (
|
||||||
dbName: string = getDefaultDatabaseName(),
|
dbName: string = getDefaultDatabaseName(),
|
||||||
forceInit: boolean = false,
|
forceInit: boolean = false,
|
||||||
) => {
|
) => {
|
||||||
let conn = connections.get(dbName);
|
if (forceInit) {
|
||||||
if (!conn || forceInit) {
|
DBContext.enter(makeDatabaseConnection(dbName));
|
||||||
const dbConn = new Sqlite(dbName, {
|
|
||||||
timeout: 5000,
|
|
||||||
});
|
|
||||||
const kysely = new Kysely<DB>({
|
|
||||||
dialect: new SqliteDialect({
|
|
||||||
database: dbConn,
|
|
||||||
}),
|
|
||||||
log: (event) => {
|
|
||||||
switch (event.level) {
|
|
||||||
case 'query':
|
|
||||||
if (
|
|
||||||
process.env['DATABASE_DEBUG_LOGGING'] ||
|
|
||||||
process.env['DIRECT_DATABASE_DEBUG_LOGGING']
|
|
||||||
) {
|
|
||||||
logger().debug(
|
|
||||||
'Query: %O (%d ms)',
|
|
||||||
event.query.sql,
|
|
||||||
event.queryDurationMillis,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
case 'error':
|
|
||||||
logger().error(
|
|
||||||
event.error,
|
|
||||||
'Query error: %O\n%O',
|
|
||||||
event.query.sql,
|
|
||||||
event.query.parameters,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
plugins: [new ParseJSONResultsPlugin(), new CamelCasePlugin()],
|
|
||||||
});
|
|
||||||
|
|
||||||
const drizzleConn = drizzle({
|
|
||||||
client: dbConn,
|
|
||||||
casing: 'snake_case',
|
|
||||||
});
|
|
||||||
|
|
||||||
conn = { kysely, drizzle: drizzleConn };
|
|
||||||
}
|
}
|
||||||
connections.set(dbName, conn);
|
return DBContext.currentDBContext()!.getKyselyDatabase(dbName)!;
|
||||||
return conn.kysely;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getMigrator(db: Kysely<DB> = getDatabase()) {
|
export function getMigrator(db: Kysely<DB> = getDatabase()) {
|
||||||
@@ -98,9 +233,7 @@ export function getMigrator(db: Kysely<DB> = getDatabase()) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pendingDatabaseMigrations(
|
export async function pendingDatabaseMigrations(db: Kysely<DB>) {
|
||||||
db: Kysely<DB> = getDatabase(),
|
|
||||||
) {
|
|
||||||
return lock.runExclusive(async () => {
|
return lock.runExclusive(async () => {
|
||||||
const tables = await db.introspection.getTables({
|
const tables = await db.introspection.getTables({
|
||||||
withInternalKyselyTables: true,
|
withInternalKyselyTables: true,
|
||||||
@@ -127,7 +260,7 @@ export async function pendingDatabaseMigrations(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function databaseNeedsMigration(db: Kysely<DB> = getDatabase()) {
|
export async function databaseNeedsMigration(db: Kysely<DB>) {
|
||||||
return (await pendingDatabaseMigrations(db)).length > 0;
|
return (await pendingDatabaseMigrations(db)).length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,10 +354,7 @@ export async function syncMigrationTablesIfNecessary(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runDBMigrations(
|
export async function runDBMigrations(db: Kysely<DB>, migrateTo?: string) {
|
||||||
db: Kysely<DB> = getDatabase(),
|
|
||||||
migrateTo?: string,
|
|
||||||
) {
|
|
||||||
const _logger = logger();
|
const _logger = logger();
|
||||||
const migrator = getMigrator(db);
|
const migrator = getMigrator(db);
|
||||||
const { error, results } = await (isNonEmptyString(migrateTo)
|
const { error, results } = await (isNonEmptyString(migrateTo)
|
||||||
@@ -245,9 +375,7 @@ export async function runDBMigrations(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Runs through pending migrations, using the DB copier if necessary
|
// Runs through pending migrations, using the DB copier if necessary
|
||||||
export async function migrateExistingDatabase(
|
export async function migrateExistingDatabase(dbPath: string) {
|
||||||
dbPath: string = getDefaultDatabaseName(),
|
|
||||||
) {
|
|
||||||
const db = getDatabase(dbPath);
|
const db = getDatabase(dbPath);
|
||||||
const pendingMigrations = await pendingDatabaseMigrations(db);
|
const pendingMigrations = await pendingDatabaseMigrations(db);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
type SavePlexProgramExternalIdsTaskFactory,
|
type SavePlexProgramExternalIdsTaskFactory,
|
||||||
} from '@/tasks/plex/SavePlexProgramExternalIdsTask.js';
|
} from '@/tasks/plex/SavePlexProgramExternalIdsTask.js';
|
||||||
import { KEYS } from '@/types/inject.js';
|
import { KEYS } from '@/types/inject.js';
|
||||||
import { Maybe } from '@/types/util.js';
|
import { MarkNonNullable, Maybe } from '@/types/util.js';
|
||||||
import { Timer } from '@/util/Timer.js';
|
import { Timer } from '@/util/Timer.js';
|
||||||
import { devAssert } from '@/util/debug.js';
|
import { devAssert } from '@/util/debug.js';
|
||||||
import { type Logger } from '@/util/logging/LoggerFactory.js';
|
import { type Logger } from '@/util/logging/LoggerFactory.js';
|
||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
} from '@tunarr/types';
|
} from '@tunarr/types';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { inject, injectable } from 'inversify';
|
import { inject, injectable } from 'inversify';
|
||||||
import { CaseWhenBuilder, UpdateResult } from 'kysely';
|
import { CaseWhenBuilder, NotNull, UpdateResult } from 'kysely';
|
||||||
import {
|
import {
|
||||||
chunk,
|
chunk,
|
||||||
concat,
|
concat,
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
flatMap,
|
flatMap,
|
||||||
forEach,
|
forEach,
|
||||||
groupBy,
|
groupBy,
|
||||||
|
head,
|
||||||
isEmpty,
|
isEmpty,
|
||||||
isNil,
|
isNil,
|
||||||
isNull,
|
isNull,
|
||||||
@@ -52,6 +53,7 @@ import {
|
|||||||
} from 'lodash-es';
|
} from 'lodash-es';
|
||||||
import { MarkOptional, MarkRequired } from 'ts-essentials';
|
import { MarkOptional, MarkRequired } from 'ts-essentials';
|
||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
|
import { typedProperty } from '../types/path.ts';
|
||||||
import { getNumericEnvVar, TUNARR_ENV_VARS } from '../util/env.ts';
|
import { getNumericEnvVar, TUNARR_ENV_VARS } from '../util/env.ts';
|
||||||
import {
|
import {
|
||||||
flatMapAsyncSeq,
|
flatMapAsyncSeq,
|
||||||
@@ -70,7 +72,7 @@ import {
|
|||||||
ProgramSourceType,
|
ProgramSourceType,
|
||||||
programSourceTypeFromString,
|
programSourceTypeFromString,
|
||||||
} from './custom_types/ProgramSourceType.ts';
|
} from './custom_types/ProgramSourceType.ts';
|
||||||
import { upsertRawProgramExternalIds } from './programExternalIdHelpers.ts';
|
import { upsertProgramExternalIds } from './programExternalIdHelpers.ts';
|
||||||
import {
|
import {
|
||||||
AllProgramJoins,
|
AllProgramJoins,
|
||||||
ProgramUpsertFields,
|
ProgramUpsertFields,
|
||||||
@@ -84,7 +86,8 @@ import {
|
|||||||
withTvShow,
|
withTvShow,
|
||||||
} from './programQueryHelpers.ts';
|
} from './programQueryHelpers.ts';
|
||||||
import {
|
import {
|
||||||
NewProgramDao as NewRawProgram,
|
NewProgramDao,
|
||||||
|
ProgramDao,
|
||||||
programExternalIdString,
|
programExternalIdString,
|
||||||
ProgramType,
|
ProgramType,
|
||||||
ProgramDao as RawProgram,
|
ProgramDao as RawProgram,
|
||||||
@@ -92,7 +95,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
MinimalProgramExternalId,
|
MinimalProgramExternalId,
|
||||||
NewProgramExternalId,
|
NewProgramExternalId,
|
||||||
NewProgramExternalId as NewRawProgramExternalId,
|
NewSingleOrMultiExternalId,
|
||||||
ProgramExternalId,
|
ProgramExternalId,
|
||||||
ProgramExternalIdKeys,
|
ProgramExternalIdKeys,
|
||||||
} from './schema/ProgramExternalId.ts';
|
} from './schema/ProgramExternalId.ts';
|
||||||
@@ -100,7 +103,10 @@ import {
|
|||||||
NewProgramGrouping,
|
NewProgramGrouping,
|
||||||
ProgramGroupingType,
|
ProgramGroupingType,
|
||||||
} from './schema/ProgramGrouping.ts';
|
} from './schema/ProgramGrouping.ts';
|
||||||
import { NewProgramGroupingExternalId } from './schema/ProgramGroupingExternalId.ts';
|
import {
|
||||||
|
NewProgramGroupingExternalId,
|
||||||
|
toInsertableProgramGroupingExternalId,
|
||||||
|
} from './schema/ProgramGroupingExternalId.ts';
|
||||||
import { DB } from './schema/db.ts';
|
import { DB } from './schema/db.ts';
|
||||||
import type {
|
import type {
|
||||||
ProgramGroupingWithExternalIds,
|
ProgramGroupingWithExternalIds,
|
||||||
@@ -112,9 +118,9 @@ type ValidatedContentProgram = MarkRequired<
|
|||||||
'externalSourceName' | 'externalSourceType'
|
'externalSourceName' | 'externalSourceType'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type MintedRawProgramInfo = {
|
type MintedNewProgramInfo = {
|
||||||
program: NewRawProgram;
|
program: NewProgramDao;
|
||||||
externalIds: NewRawProgramExternalId[];
|
externalIds: NewSingleOrMultiExternalId[];
|
||||||
apiProgram: ValidatedContentProgram;
|
apiProgram: ValidatedContentProgram;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -549,12 +555,13 @@ export class ProgramDB implements IProgramDB {
|
|||||||
// );
|
// );
|
||||||
|
|
||||||
// TODO: handle custom shows
|
// TODO: handle custom shows
|
||||||
const programsToPersist: MintedRawProgramInfo[] = map(
|
const programsToPersist: MintedNewProgramInfo[] = map(
|
||||||
contentPrograms,
|
contentPrograms,
|
||||||
(p) => {
|
(p) => {
|
||||||
const program = minter.contentProgramDtoToDao(p);
|
const program = minter.contentProgramDtoToDao(p);
|
||||||
const externalIds = minter.mintExternalIds(
|
const externalIds = minter.mintExternalIds(
|
||||||
p.externalSourceName,
|
p.externalSourceName,
|
||||||
|
p.externalSourceId,
|
||||||
program.uuid,
|
program.uuid,
|
||||||
p,
|
p,
|
||||||
);
|
);
|
||||||
@@ -572,7 +579,7 @@ export class ProgramDB implements IProgramDB {
|
|||||||
// TODO: The way we deal with uniqueness right now makes a Program entity
|
// TODO: The way we deal with uniqueness right now makes a Program entity
|
||||||
// exist 1:1 with its "external" entity, i.e. the same logical movie will
|
// exist 1:1 with its "external" entity, i.e. the same logical movie will
|
||||||
// have duplicate entries in the DB across different servers and sources.
|
// have duplicate entries in the DB across different servers and sources.
|
||||||
const upsertedPrograms: RawProgram[] = [];
|
const upsertedPrograms: MarkNonNullable<ProgramDao, 'mediaSourceId'>[] = [];
|
||||||
await this.timer.timeAsync('programUpsert', async () => {
|
await this.timer.timeAsync('programUpsert', async () => {
|
||||||
for (const c of chunk(programsToPersist, programUpsertBatchSize)) {
|
for (const c of chunk(programsToPersist, programUpsertBatchSize)) {
|
||||||
upsertedPrograms.push(
|
upsertedPrograms.push(
|
||||||
@@ -591,7 +598,17 @@ export class ProgramDB implements IProgramDB {
|
|||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.onConflict((oc) =>
|
||||||
|
oc
|
||||||
|
.columns(['sourceType', 'mediaSourceId', 'externalKey'])
|
||||||
|
.doUpdateSet((eb) =>
|
||||||
|
mapToObj(ProgramUpsertFields, (f) => ({
|
||||||
|
[f.replace('excluded.', '')]: eb.ref(f),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
.returningAll()
|
.returningAll()
|
||||||
|
.$narrowType<{ mediaSourceId: NotNull }>()
|
||||||
.execute(),
|
.execute(),
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
@@ -624,7 +641,7 @@ export class ProgramDB implements IProgramDB {
|
|||||||
// TODO: We could optimize further here by only saving IDs necessary for streaming
|
// TODO: We could optimize further here by only saving IDs necessary for streaming
|
||||||
await this.timer.timeAsync(
|
await this.timer.timeAsync(
|
||||||
`upsert ${requiredExternalIds.length} external ids`,
|
`upsert ${requiredExternalIds.length} external ids`,
|
||||||
() => upsertRawProgramExternalIds(requiredExternalIds, 200),
|
() => upsertProgramExternalIds(requiredExternalIds, 200),
|
||||||
// upsertProgramExternalIds_deprecated(requiredExternalIds),
|
// upsertProgramExternalIds_deprecated(requiredExternalIds),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -650,7 +667,7 @@ export class ProgramDB implements IProgramDB {
|
|||||||
AnonymousTask('UpsertExternalIds', () =>
|
AnonymousTask('UpsertExternalIds', () =>
|
||||||
this.timer.timeAsync(
|
this.timer.timeAsync(
|
||||||
`background external ID upsert (${backgroundExternalIds.length} ids)`,
|
`background external ID upsert (${backgroundExternalIds.length} ids)`,
|
||||||
() => upsertRawProgramExternalIds(backgroundExternalIds),
|
() => upsertProgramExternalIds(backgroundExternalIds),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -667,18 +684,21 @@ export class ProgramDB implements IProgramDB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async handleProgramGroupings(
|
private async handleProgramGroupings(
|
||||||
upsertedPrograms: RawProgram[],
|
upsertedPrograms: MarkNonNullable<ProgramDao, 'mediaSourceId'>[],
|
||||||
programInfos: Record<string, MintedRawProgramInfo>,
|
programInfos: Record<string, MintedNewProgramInfo>,
|
||||||
) {
|
) {
|
||||||
const programsBySourceAndServer = mapValues(
|
const programsBySourceAndServer = mapValues(
|
||||||
groupBy(upsertedPrograms, 'sourceType'),
|
groupBy(upsertedPrograms, 'sourceType'),
|
||||||
(ps) => groupBy(ps, 'externalSourceId'),
|
(ps) => groupBy(ps, typedProperty('mediaSourceId')),
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const [sourceType, byServerName] of Object.entries(
|
for (const [sourceType, byServerId] of Object.entries(
|
||||||
programsBySourceAndServer,
|
programsBySourceAndServer,
|
||||||
)) {
|
)) {
|
||||||
for (const [serverName, programs] of Object.entries(byServerName)) {
|
for (const [serverId, programs] of Object.entries(byServerId)) {
|
||||||
|
// Making an assumption that these are all the same... this field will
|
||||||
|
// go away soon anyway
|
||||||
|
const serverName = head(programs)!.externalSourceId;
|
||||||
// This is just extra safety because lodash erases the type in groupBy
|
// This is just extra safety because lodash erases the type in groupBy
|
||||||
const typ = programSourceTypeFromString(sourceType);
|
const typ = programSourceTypeFromString(sourceType);
|
||||||
if (!typ) {
|
if (!typ) {
|
||||||
@@ -690,6 +710,7 @@ export class ProgramDB implements IProgramDB {
|
|||||||
programInfos,
|
programInfos,
|
||||||
typ,
|
typ,
|
||||||
serverName,
|
serverName,
|
||||||
|
serverId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -697,8 +718,9 @@ export class ProgramDB implements IProgramDB {
|
|||||||
|
|
||||||
private async handleSingleSourceProgramGroupings(
|
private async handleSingleSourceProgramGroupings(
|
||||||
upsertedPrograms: RawProgram[],
|
upsertedPrograms: RawProgram[],
|
||||||
programInfos: Record<string, MintedRawProgramInfo>,
|
programInfos: Record<string, MintedNewProgramInfo>,
|
||||||
mediaSourceType: ProgramSourceType,
|
mediaSourceType: ProgramSourceType,
|
||||||
|
mediaSourceName: string,
|
||||||
mediaSourceId: string,
|
mediaSourceId: string,
|
||||||
) {
|
) {
|
||||||
const grandparentRatingKeyToParentRatingKey: Record<
|
const grandparentRatingKeyToParentRatingKey: Record<
|
||||||
@@ -790,7 +812,7 @@ export class ProgramDB implements IProgramDB {
|
|||||||
eb(
|
eb(
|
||||||
'programGroupingExternalId.externalSourceId',
|
'programGroupingExternalId.externalSourceId',
|
||||||
'=',
|
'=',
|
||||||
mediaSourceId,
|
mediaSourceName,
|
||||||
),
|
),
|
||||||
eb(
|
eb(
|
||||||
'programGroupingExternalId.externalKey',
|
'programGroupingExternalId.externalKey',
|
||||||
@@ -954,6 +976,7 @@ export class ProgramDB implements IProgramDB {
|
|||||||
...ProgramGroupingMinter.mintGroupingExternalIds(
|
...ProgramGroupingMinter.mintGroupingExternalIds(
|
||||||
programs[0][1],
|
programs[0][1],
|
||||||
parentGrouping.uuid,
|
parentGrouping.uuid,
|
||||||
|
mediaSourceName,
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
'parent',
|
'parent',
|
||||||
),
|
),
|
||||||
@@ -965,6 +988,7 @@ export class ProgramDB implements IProgramDB {
|
|||||||
...ProgramGroupingMinter.mintGroupingExternalIds(
|
...ProgramGroupingMinter.mintGroupingExternalIds(
|
||||||
matchingPrograms[0][1],
|
matchingPrograms[0][1],
|
||||||
grandparentGrouping.uuid,
|
grandparentGrouping.uuid,
|
||||||
|
mediaSourceName,
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
'grandparent',
|
'grandparent',
|
||||||
),
|
),
|
||||||
@@ -986,14 +1010,41 @@ export class ProgramDB implements IProgramDB {
|
|||||||
|
|
||||||
if (!isEmpty(externalIds)) {
|
if (!isEmpty(externalIds)) {
|
||||||
await this.timer.timeAsync('upsert program_grouping external ids', () =>
|
await this.timer.timeAsync('upsert program_grouping external ids', () =>
|
||||||
getDatabase()
|
Promise.all(
|
||||||
.transaction()
|
chunk(
|
||||||
.execute((tx) =>
|
externalIds.map(toInsertableProgramGroupingExternalId),
|
||||||
tx
|
100,
|
||||||
.insertInto('programGroupingExternalId')
|
).map((externalIds) =>
|
||||||
.values(externalIds)
|
getDatabase()
|
||||||
.executeTakeFirstOrThrow(),
|
.transaction()
|
||||||
|
.execute((tx) =>
|
||||||
|
tx
|
||||||
|
.insertInto('programGroupingExternalId')
|
||||||
|
.values(externalIds)
|
||||||
|
.onConflict((oc) =>
|
||||||
|
oc
|
||||||
|
.columns(['groupUuid', 'sourceType'])
|
||||||
|
.where('mediaSourceId', 'is', null)
|
||||||
|
.doUpdateSet((eb) => ({
|
||||||
|
updatedAt: eb.ref('excluded.updatedAt'),
|
||||||
|
externalFilePath: eb.ref('excluded.externalFilePath'),
|
||||||
|
groupUuid: eb.ref('excluded.groupUuid'),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.onConflict((oc) =>
|
||||||
|
oc
|
||||||
|
.columns(['groupUuid', 'sourceType', 'mediaSourceId'])
|
||||||
|
.where('mediaSourceId', 'is not', null)
|
||||||
|
.doUpdateSet((eb) => ({
|
||||||
|
updatedAt: eb.ref('excluded.updatedAt'),
|
||||||
|
externalFilePath: eb.ref('excluded.externalFilePath'),
|
||||||
|
groupUuid: eb.ref('excluded.groupUuid'),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.executeTakeFirstOrThrow(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1138,7 +1189,7 @@ export class ProgramDB implements IProgramDB {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private schedulePlexExternalIdsTask(upsertedPrograms: NewRawProgram[]) {
|
private schedulePlexExternalIdsTask(upsertedPrograms: ProgramDao[]) {
|
||||||
PlexTaskQueue.pause();
|
PlexTaskQueue.pause();
|
||||||
this.timer.timeSync('schedule Plex external IDs tasks', () => {
|
this.timer.timeSync('schedule Plex external IDs tasks', () => {
|
||||||
forEach(
|
forEach(
|
||||||
@@ -1168,7 +1219,7 @@ export class ProgramDB implements IProgramDB {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleJellyfinExternalIdsTask(upsertedPrograms: NewRawProgram[]) {
|
private scheduleJellyfinExternalIdsTask(upsertedPrograms: ProgramDao[]) {
|
||||||
JellyfinTaskQueue.pause();
|
JellyfinTaskQueue.pause();
|
||||||
this.timer.timeSync('Schedule Jellyfin external IDs tasks', () => {
|
this.timer.timeSync('Schedule Jellyfin external IDs tasks', () => {
|
||||||
forEach(
|
forEach(
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
|
import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
|
||||||
import type { NewProgramGroupingExternalId } from '@/db/schema/ProgramGroupingExternalId.js';
|
import type { NewSingleOrMultiProgramGroupingExternalId } from '@/db/schema/ProgramGroupingExternalId.js';
|
||||||
import { isNonEmptyString } from '@/util/index.js';
|
import { isNonEmptyString } from '@/util/index.js';
|
||||||
import type { ContentProgram } from '@tunarr/types';
|
import type { ContentProgram } from '@tunarr/types';
|
||||||
import type { JellyfinItem } from '@tunarr/types/jellyfin';
|
import type { JellyfinItem } from '@tunarr/types/jellyfin';
|
||||||
import type { PlexEpisode, PlexMusicTrack } from '@tunarr/types/plex';
|
import type { PlexEpisode, PlexMusicTrack } from '@tunarr/types/plex';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { find, first } from 'lodash-es';
|
import { first } from 'lodash-es';
|
||||||
import type { MarkRequired } from 'ts-essentials';
|
import type { MarkRequired } from 'ts-essentials';
|
||||||
import { P, match } from 'ts-pattern';
|
|
||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
import type { Nullable } from '../../types/util.ts';
|
import type { Nullable } from '../../types/util.ts';
|
||||||
import {
|
import {
|
||||||
@@ -68,14 +67,15 @@ export class ProgramGroupingMinter {
|
|||||||
program: ContentProgram,
|
program: ContentProgram,
|
||||||
groupingId: string,
|
groupingId: string,
|
||||||
externalSourceId: string,
|
externalSourceId: string,
|
||||||
|
mediaSourceId: string,
|
||||||
relationType: 'parent' | 'grandparent',
|
relationType: 'parent' | 'grandparent',
|
||||||
): NewProgramGroupingExternalId[] {
|
): NewSingleOrMultiProgramGroupingExternalId[] {
|
||||||
if (program.subtype === 'movie') {
|
if (program.subtype === 'movie') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = +dayjs();
|
const now = +dayjs();
|
||||||
const parentExternalIds: NewProgramGroupingExternalId[] = [];
|
const parentExternalIds: NewSingleOrMultiProgramGroupingExternalId[] = [];
|
||||||
|
|
||||||
const ratingKey =
|
const ratingKey =
|
||||||
relationType === 'grandparent'
|
relationType === 'grandparent'
|
||||||
@@ -83,6 +83,7 @@ export class ProgramGroupingMinter {
|
|||||||
: program.parent?.externalKey;
|
: program.parent?.externalKey;
|
||||||
if (isNonEmptyString(ratingKey)) {
|
if (isNonEmptyString(ratingKey)) {
|
||||||
parentExternalIds.push({
|
parentExternalIds.push({
|
||||||
|
type: 'multi',
|
||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -90,6 +91,7 @@ export class ProgramGroupingMinter {
|
|||||||
externalKey: ratingKey,
|
externalKey: ratingKey,
|
||||||
sourceType: ProgramExternalIdType.PLEX,
|
sourceType: ProgramExternalIdType.PLEX,
|
||||||
externalSourceId,
|
externalSourceId,
|
||||||
|
mediaSourceId,
|
||||||
groupUuid: groupingId,
|
groupUuid: groupingId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -101,89 +103,13 @@ export class ProgramGroupingMinter {
|
|||||||
);
|
);
|
||||||
if (isNonEmptyString(guid)) {
|
if (isNonEmptyString(guid)) {
|
||||||
parentExternalIds.push({
|
parentExternalIds.push({
|
||||||
|
type: 'single',
|
||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
externalFilePath: null,
|
externalFilePath: null,
|
||||||
externalKey: guid,
|
externalKey: guid,
|
||||||
sourceType: ProgramExternalIdType.PLEX_GUID,
|
sourceType: ProgramExternalIdType.PLEX_GUID,
|
||||||
externalSourceId: null,
|
|
||||||
groupUuid: groupingId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return parentExternalIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
static mintGroupingExternalIdsForPlex(
|
|
||||||
plexItem: PlexEpisode | PlexMusicTrack,
|
|
||||||
groupingId: string,
|
|
||||||
externalSourceId: string,
|
|
||||||
relationType: 'parent' | 'grandparent',
|
|
||||||
): NewProgramGroupingExternalId[] {
|
|
||||||
const now = +dayjs();
|
|
||||||
const parentExternalIds: NewProgramGroupingExternalId[] = [];
|
|
||||||
|
|
||||||
const ratingKey = plexItem[`${relationType}RatingKey`];
|
|
||||||
if (isNonEmptyString(ratingKey)) {
|
|
||||||
parentExternalIds.push({
|
|
||||||
uuid: v4(),
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
externalFilePath: null,
|
|
||||||
externalKey: ratingKey,
|
|
||||||
sourceType: ProgramExternalIdType.PLEX,
|
|
||||||
externalSourceId,
|
|
||||||
groupUuid: groupingId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const guid = plexItem[`${relationType}Guid`];
|
|
||||||
if (isNonEmptyString(guid)) {
|
|
||||||
parentExternalIds.push({
|
|
||||||
uuid: v4(),
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
externalFilePath: null,
|
|
||||||
externalKey: guid,
|
|
||||||
sourceType: ProgramExternalIdType.PLEX_GUID,
|
|
||||||
externalSourceId: null,
|
|
||||||
groupUuid: groupingId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return parentExternalIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
static mintGroupingExternalIdsForJellyfin(
|
|
||||||
jellyfinItem: JellyfinItem,
|
|
||||||
groupingId: string,
|
|
||||||
externalSourceId: string,
|
|
||||||
relationType: 'parent' | 'grandparent',
|
|
||||||
): NewProgramGroupingExternalId[] {
|
|
||||||
const now = +dayjs();
|
|
||||||
const parentExternalIds: NewProgramGroupingExternalId[] = [];
|
|
||||||
|
|
||||||
const jellyfinId = match([jellyfinItem, relationType] as const)
|
|
||||||
.with([{ Type: 'Episode' }, 'grandparent'], () => jellyfinItem.SeriesId)
|
|
||||||
.with(
|
|
||||||
[{ Type: 'Audio' }, 'parent'],
|
|
||||||
() =>
|
|
||||||
find(jellyfinItem.AlbumArtists, { Name: jellyfinItem.AlbumArtist })
|
|
||||||
?.Id,
|
|
||||||
)
|
|
||||||
.with([P._, 'parent'], () => jellyfinItem.ParentId)
|
|
||||||
.otherwise(() => null);
|
|
||||||
|
|
||||||
if (isNonEmptyString(jellyfinId)) {
|
|
||||||
parentExternalIds.push({
|
|
||||||
uuid: v4(),
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
externalFilePath: null,
|
|
||||||
externalKey: jellyfinId,
|
|
||||||
sourceType: ProgramExternalIdType.JELLYFIN,
|
|
||||||
externalSourceId,
|
|
||||||
groupUuid: groupingId,
|
groupUuid: groupingId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
|
import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
|
||||||
import { ProgramSourceType } from '@/db/custom_types/ProgramSourceType.js';
|
import { ProgramSourceType } from '@/db/custom_types/ProgramSourceType.js';
|
||||||
import type { NewProgramExternalId } from '@/db/schema/ProgramExternalId.js';
|
import type {
|
||||||
|
NewProgramExternalId,
|
||||||
|
NewSingleOrMultiExternalId,
|
||||||
|
} from '@/db/schema/ProgramExternalId.js';
|
||||||
import { seq } from '@tunarr/shared/util';
|
import { seq } from '@tunarr/shared/util';
|
||||||
import type { ContentProgram } from '@tunarr/types';
|
import type { ContentProgram } from '@tunarr/types';
|
||||||
import type { JellyfinItem } from '@tunarr/types/jellyfin';
|
import type { JellyfinItem } from '@tunarr/types/jellyfin';
|
||||||
@@ -26,7 +29,9 @@ class ProgramDaoMinter {
|
|||||||
return {
|
return {
|
||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
sourceType: program.externalSourceType,
|
sourceType: program.externalSourceType,
|
||||||
externalSourceId: program.externalSourceId ?? program.externalSourceName,
|
// Deprecated
|
||||||
|
externalSourceId: program.externalSourceName,
|
||||||
|
mediaSourceId: program.externalSourceId,
|
||||||
externalKey: program.externalKey,
|
externalKey: program.externalKey,
|
||||||
originalAirDate: program.date ?? null,
|
originalAirDate: program.date ?? null,
|
||||||
duration: program.duration,
|
duration: program.duration,
|
||||||
@@ -50,21 +55,24 @@ class ProgramDaoMinter {
|
|||||||
|
|
||||||
mint(
|
mint(
|
||||||
serverName: string,
|
serverName: string,
|
||||||
|
serverId: string,
|
||||||
program: ContentProgramOriginalProgram,
|
program: ContentProgramOriginalProgram,
|
||||||
): NewRawProgram {
|
): NewRawProgram {
|
||||||
const ret = match(program)
|
const ret = match(program)
|
||||||
.with(
|
.with(
|
||||||
{ sourceType: 'plex', program: { type: 'movie' } },
|
{ sourceType: 'plex', program: { type: 'movie' } },
|
||||||
({ program: movie }) => this.mintProgramForPlexMovie(serverName, movie),
|
({ program: movie }) =>
|
||||||
|
this.mintProgramForPlexMovie(serverName, serverId, movie),
|
||||||
)
|
)
|
||||||
.with(
|
.with(
|
||||||
{ sourceType: 'plex', program: { type: 'episode' } },
|
{ sourceType: 'plex', program: { type: 'episode' } },
|
||||||
({ program: episode }) =>
|
({ program: episode }) =>
|
||||||
this.mintProgramForPlexEpisode(serverName, episode),
|
this.mintProgramForPlexEpisode(serverName, serverId, episode),
|
||||||
)
|
)
|
||||||
.with(
|
.with(
|
||||||
{ sourceType: 'plex', program: { type: 'track' } },
|
{ sourceType: 'plex', program: { type: 'track' } },
|
||||||
({ program: track }) => this.mintProgramForPlexTrack(serverName, track),
|
({ program: track }) =>
|
||||||
|
this.mintProgramForPlexTrack(serverName, serverId, track),
|
||||||
)
|
)
|
||||||
.with(
|
.with(
|
||||||
{
|
{
|
||||||
@@ -80,7 +88,8 @@ class ProgramDaoMinter {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
({ program }) => this.mintProgramForJellyfinItem(serverName, program),
|
({ program }) =>
|
||||||
|
this.mintProgramForJellyfinItem(serverName, serverId, program),
|
||||||
)
|
)
|
||||||
.otherwise(() => new Error('Unexpected program type'));
|
.otherwise(() => new Error('Unexpected program type'));
|
||||||
if (isError(ret)) {
|
if (isError(ret)) {
|
||||||
@@ -91,6 +100,7 @@ class ProgramDaoMinter {
|
|||||||
|
|
||||||
private mintProgramForPlexMovie(
|
private mintProgramForPlexMovie(
|
||||||
serverName: string,
|
serverName: string,
|
||||||
|
serverId: string,
|
||||||
plexMovie: PlexMovie,
|
plexMovie: PlexMovie,
|
||||||
): NewRawProgram {
|
): NewRawProgram {
|
||||||
const file = first(first(plexMovie.Media)?.Part ?? []);
|
const file = first(first(plexMovie.Media)?.Part ?? []);
|
||||||
@@ -101,6 +111,7 @@ class ProgramDaoMinter {
|
|||||||
duration: plexMovie.duration ?? 0,
|
duration: plexMovie.duration ?? 0,
|
||||||
filePath: file?.file ?? null,
|
filePath: file?.file ?? null,
|
||||||
externalSourceId: serverName,
|
externalSourceId: serverName,
|
||||||
|
mediaSourceId: serverId,
|
||||||
externalKey: plexMovie.ratingKey,
|
externalKey: plexMovie.ratingKey,
|
||||||
plexRatingKey: plexMovie.ratingKey,
|
plexRatingKey: plexMovie.ratingKey,
|
||||||
plexFilePath: file?.key ?? null,
|
plexFilePath: file?.key ?? null,
|
||||||
@@ -116,6 +127,7 @@ class ProgramDaoMinter {
|
|||||||
|
|
||||||
private mintProgramForJellyfinItem(
|
private mintProgramForJellyfinItem(
|
||||||
serverName: string,
|
serverName: string,
|
||||||
|
serverId: string,
|
||||||
item: Omit<JellyfinItem, 'Type'> & {
|
item: Omit<JellyfinItem, 'Type'> & {
|
||||||
Type: 'Movie' | 'Episode' | 'Audio' | 'Video' | 'MusicVideo' | 'Trailer';
|
Type: 'Movie' | 'Episode' | 'Audio' | 'Video' | 'MusicVideo' | 'Trailer';
|
||||||
},
|
},
|
||||||
@@ -128,6 +140,7 @@ class ProgramDaoMinter {
|
|||||||
originalAirDate: item.PremiereDate,
|
originalAirDate: item.PremiereDate,
|
||||||
duration: (item.RunTimeTicks ?? 0) / 10_000,
|
duration: (item.RunTimeTicks ?? 0) / 10_000,
|
||||||
externalSourceId: serverName,
|
externalSourceId: serverName,
|
||||||
|
mediaSourceId: serverId,
|
||||||
externalKey: item.Id,
|
externalKey: item.Id,
|
||||||
rating: item.OfficialRating,
|
rating: item.OfficialRating,
|
||||||
summary: item.Overview,
|
summary: item.Overview,
|
||||||
@@ -154,6 +167,7 @@ class ProgramDaoMinter {
|
|||||||
|
|
||||||
private mintProgramForPlexEpisode(
|
private mintProgramForPlexEpisode(
|
||||||
serverName: string,
|
serverName: string,
|
||||||
|
serverId: string,
|
||||||
plexEpisode: PlexEpisode,
|
plexEpisode: PlexEpisode,
|
||||||
): NewRawProgram {
|
): NewRawProgram {
|
||||||
const file = first(first(plexEpisode.Media)?.Part ?? []);
|
const file = first(first(plexEpisode.Media)?.Part ?? []);
|
||||||
@@ -166,6 +180,7 @@ class ProgramDaoMinter {
|
|||||||
duration: plexEpisode.duration ?? 0,
|
duration: plexEpisode.duration ?? 0,
|
||||||
filePath: file?.file,
|
filePath: file?.file,
|
||||||
externalSourceId: serverName,
|
externalSourceId: serverName,
|
||||||
|
mediaSourceId: serverId,
|
||||||
externalKey: plexEpisode.ratingKey,
|
externalKey: plexEpisode.ratingKey,
|
||||||
plexRatingKey: plexEpisode.ratingKey,
|
plexRatingKey: plexEpisode.ratingKey,
|
||||||
plexFilePath: file?.key,
|
plexFilePath: file?.key,
|
||||||
@@ -185,6 +200,7 @@ class ProgramDaoMinter {
|
|||||||
|
|
||||||
private mintProgramForPlexTrack(
|
private mintProgramForPlexTrack(
|
||||||
serverName: string,
|
serverName: string,
|
||||||
|
serverId: string,
|
||||||
plexTrack: PlexMusicTrack,
|
plexTrack: PlexMusicTrack,
|
||||||
): NewRawProgram {
|
): NewRawProgram {
|
||||||
const file = first(first(plexTrack.Media)?.Part ?? []);
|
const file = first(first(plexTrack.Media)?.Part ?? []);
|
||||||
@@ -196,6 +212,7 @@ class ProgramDaoMinter {
|
|||||||
duration: plexTrack.duration ?? 0,
|
duration: plexTrack.duration ?? 0,
|
||||||
filePath: file?.file,
|
filePath: file?.file,
|
||||||
externalSourceId: serverName,
|
externalSourceId: serverName,
|
||||||
|
mediaSourceId: serverId,
|
||||||
externalKey: plexTrack.ratingKey,
|
externalKey: plexTrack.ratingKey,
|
||||||
plexRatingKey: plexTrack.ratingKey,
|
plexRatingKey: plexTrack.ratingKey,
|
||||||
plexFilePath: file?.key,
|
plexFilePath: file?.key,
|
||||||
@@ -216,32 +233,34 @@ class ProgramDaoMinter {
|
|||||||
|
|
||||||
mintExternalIds(
|
mintExternalIds(
|
||||||
serverName: string,
|
serverName: string,
|
||||||
|
serverId: string,
|
||||||
programId: string,
|
programId: string,
|
||||||
program: ContentProgram,
|
program: ContentProgram,
|
||||||
// originalProgram: ContentProgramOriginalProgram,
|
): NewSingleOrMultiExternalId[] {
|
||||||
) {
|
|
||||||
return match(program)
|
return match(program)
|
||||||
.with({ externalSourceType: 'plex' }, () =>
|
.with({ externalSourceType: 'plex' }, () =>
|
||||||
this.mintPlexExternalIds(serverName, programId, program),
|
this.mintPlexExternalIds(serverName, serverId, programId, program),
|
||||||
)
|
)
|
||||||
.with({ externalSourceType: 'jellyfin' }, () =>
|
.with({ externalSourceType: 'jellyfin' }, () =>
|
||||||
this.mintJellyfinExternalIds(serverName, programId, program),
|
this.mintJellyfinExternalIds(serverName, serverId, programId, program),
|
||||||
)
|
)
|
||||||
.with({ externalSourceType: 'emby' }, () =>
|
.with({ externalSourceType: 'emby' }, () =>
|
||||||
this.mintEmbyExternalIds(serverName, programId, program),
|
this.mintEmbyExternalIds(serverName, serverId, programId, program),
|
||||||
)
|
)
|
||||||
.exhaustive();
|
.exhaustive();
|
||||||
}
|
}
|
||||||
|
|
||||||
mintPlexExternalIds(
|
mintPlexExternalIds(
|
||||||
serverName: string,
|
serverName: string,
|
||||||
|
serverId: string,
|
||||||
programId: string,
|
programId: string,
|
||||||
program: ContentProgram,
|
program: ContentProgram,
|
||||||
): NewProgramExternalId[] {
|
): NewSingleOrMultiExternalId[] {
|
||||||
const now = +dayjs();
|
const now = +dayjs();
|
||||||
|
|
||||||
const ids: NewProgramExternalId[] = [
|
const ids: NewSingleOrMultiExternalId[] = [
|
||||||
{
|
{
|
||||||
|
type: 'multi',
|
||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -249,6 +268,7 @@ class ProgramDaoMinter {
|
|||||||
sourceType: ProgramExternalIdType.PLEX,
|
sourceType: ProgramExternalIdType.PLEX,
|
||||||
programUuid: programId,
|
programUuid: programId,
|
||||||
externalSourceId: serverName,
|
externalSourceId: serverName,
|
||||||
|
mediaSourceId: serverId,
|
||||||
externalFilePath: program.serverFileKey,
|
externalFilePath: program.serverFileKey,
|
||||||
directFilePath: program.serverFilePath,
|
directFilePath: program.serverFilePath,
|
||||||
},
|
},
|
||||||
@@ -257,6 +277,7 @@ class ProgramDaoMinter {
|
|||||||
const plexGuid = find(program.externalIds, { source: 'plex-guid' });
|
const plexGuid = find(program.externalIds, { source: 'plex-guid' });
|
||||||
if (plexGuid) {
|
if (plexGuid) {
|
||||||
ids.push({
|
ids.push({
|
||||||
|
type: 'single',
|
||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -273,13 +294,14 @@ class ProgramDaoMinter {
|
|||||||
case 'imdb':
|
case 'imdb':
|
||||||
case 'tvdb':
|
case 'tvdb':
|
||||||
return {
|
return {
|
||||||
|
type: 'single',
|
||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
externalKey: eid.id,
|
externalKey: eid.id,
|
||||||
sourceType: eid.source,
|
sourceType: eid.source,
|
||||||
programUuid: programId,
|
programUuid: programId,
|
||||||
} satisfies NewProgramExternalId;
|
} satisfies NewSingleOrMultiExternalId;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -307,12 +329,14 @@ class ProgramDaoMinter {
|
|||||||
|
|
||||||
mintJellyfinExternalIds(
|
mintJellyfinExternalIds(
|
||||||
serverName: string,
|
serverName: string,
|
||||||
|
serverId: string,
|
||||||
programId: string,
|
programId: string,
|
||||||
program: ContentProgram,
|
program: ContentProgram,
|
||||||
) {
|
) {
|
||||||
const now = +dayjs();
|
const now = +dayjs();
|
||||||
const ids: NewProgramExternalId[] = [
|
const ids: NewSingleOrMultiExternalId[] = [
|
||||||
{
|
{
|
||||||
|
type: 'multi',
|
||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -320,6 +344,7 @@ class ProgramDaoMinter {
|
|||||||
sourceType: ProgramExternalIdType.JELLYFIN,
|
sourceType: ProgramExternalIdType.JELLYFIN,
|
||||||
programUuid: programId,
|
programUuid: programId,
|
||||||
externalSourceId: serverName,
|
externalSourceId: serverName,
|
||||||
|
mediaSourceId: serverId,
|
||||||
externalFilePath: program.serverFileKey,
|
externalFilePath: program.serverFileKey,
|
||||||
directFilePath: program.serverFilePath,
|
directFilePath: program.serverFilePath,
|
||||||
},
|
},
|
||||||
@@ -332,13 +357,14 @@ class ProgramDaoMinter {
|
|||||||
case 'imdb':
|
case 'imdb':
|
||||||
case 'tvdb':
|
case 'tvdb':
|
||||||
return {
|
return {
|
||||||
|
type: 'single',
|
||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
externalKey: eid.id,
|
externalKey: eid.id,
|
||||||
sourceType: eid.source,
|
sourceType: eid.source,
|
||||||
programUuid: programId,
|
programUuid: programId,
|
||||||
} satisfies NewProgramExternalId;
|
} satisfies NewSingleOrMultiExternalId;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -350,12 +376,14 @@ class ProgramDaoMinter {
|
|||||||
|
|
||||||
mintEmbyExternalIds(
|
mintEmbyExternalIds(
|
||||||
serverName: string,
|
serverName: string,
|
||||||
|
serverId: string,
|
||||||
programId: string,
|
programId: string,
|
||||||
program: ContentProgram,
|
program: ContentProgram,
|
||||||
) {
|
) {
|
||||||
const now = +dayjs();
|
const now = +dayjs();
|
||||||
const ids: NewProgramExternalId[] = [
|
const ids: NewSingleOrMultiExternalId[] = [
|
||||||
{
|
{
|
||||||
|
type: 'multi',
|
||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -363,6 +391,7 @@ class ProgramDaoMinter {
|
|||||||
sourceType: ProgramExternalIdType.EMBY,
|
sourceType: ProgramExternalIdType.EMBY,
|
||||||
programUuid: programId,
|
programUuid: programId,
|
||||||
externalSourceId: serverName,
|
externalSourceId: serverName,
|
||||||
|
mediaSourceId: serverId,
|
||||||
externalFilePath: program.serverFileKey,
|
externalFilePath: program.serverFileKey,
|
||||||
directFilePath: program.serverFilePath,
|
directFilePath: program.serverFilePath,
|
||||||
},
|
},
|
||||||
@@ -375,13 +404,14 @@ class ProgramDaoMinter {
|
|||||||
case 'imdb':
|
case 'imdb':
|
||||||
case 'tvdb':
|
case 'tvdb':
|
||||||
return {
|
return {
|
||||||
|
type: 'single',
|
||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
externalKey: eid.id,
|
externalKey: eid.id,
|
||||||
sourceType: eid.source,
|
sourceType: eid.source,
|
||||||
programUuid: programId,
|
programUuid: programId,
|
||||||
} satisfies NewProgramExternalId;
|
} satisfies NewSingleOrMultiExternalId;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ export interface IChannelDB {
|
|||||||
programIds: string[],
|
programIds: string[],
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
|
|
||||||
|
removeProgramsFromAllLineups(programIds: string[]): Promise<void>;
|
||||||
|
|
||||||
loadAllLineupConfigs(
|
loadAllLineupConfigs(
|
||||||
forceRead?: boolean,
|
forceRead?: boolean,
|
||||||
): Promise<Record<string, ChannnelAndLineup>>;
|
): Promise<Record<string, ChannnelAndLineup>>;
|
||||||
|
|||||||
@@ -117,32 +117,36 @@ export class MediaSourceDB {
|
|||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteMediaSource(id: string, removePrograms: boolean = true) {
|
async deleteMediaSource(id: string) {
|
||||||
const deletedServer = await this.getById(id);
|
const deletedServer = await this.getById(id);
|
||||||
if (isNil(deletedServer)) {
|
if (isNil(deletedServer)) {
|
||||||
throw new Error(`MediaSource not found: ${id}`);
|
throw new Error(`MediaSource not found: ${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This should cascade all relevant deletes across the DB
|
||||||
await getDatabase()
|
await getDatabase()
|
||||||
.deleteFrom('mediaSource')
|
.transaction()
|
||||||
.where('uuid', '=', id)
|
.execute(async (tx) => {
|
||||||
// TODO: Blocked on https://github.com/oven-sh/bun/issues/16909
|
const relatedProgramIds = await tx
|
||||||
// .limit(1)
|
.selectFrom('program')
|
||||||
.execute();
|
.where('program.mediaSourceId', '=', id)
|
||||||
|
.select('uuid')
|
||||||
|
.execute()
|
||||||
|
.then((_) => _.map(({ uuid }) => uuid));
|
||||||
|
|
||||||
let reports: Report[];
|
await tx
|
||||||
if (!removePrograms) {
|
.deleteFrom('mediaSource')
|
||||||
reports = [];
|
.where('uuid', '=', id)
|
||||||
} else {
|
.limit(1)
|
||||||
reports = await this.fixupProgramReferences(
|
.execute();
|
||||||
deletedServer.name,
|
// TODO: Update lineups
|
||||||
deletedServer.type,
|
|
||||||
);
|
await this.channelDb.removeProgramsFromAllLineups(relatedProgramIds);
|
||||||
}
|
});
|
||||||
|
|
||||||
this.mediaSourceApiFactory().deleteCachedClient(deletedServer);
|
this.mediaSourceApiFactory().deleteCachedClient(deletedServer);
|
||||||
|
|
||||||
return { deletedServer, reports };
|
return { deletedServer };
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateMediaSource(server: UpdateMediaSourceRequest) {
|
async updateMediaSource(server: UpdateMediaSourceRequest) {
|
||||||
@@ -155,9 +159,9 @@ export class MediaSourceDB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sendGuideUpdates =
|
const sendGuideUpdates =
|
||||||
server.type === 'plex' ? server.sendGuideUpdates ?? false : false;
|
server.type === 'plex' ? (server.sendGuideUpdates ?? false) : false;
|
||||||
const sendChannelUpdates =
|
const sendChannelUpdates =
|
||||||
server.type === 'plex' ? server.sendChannelUpdates ?? false : false;
|
server.type === 'plex' ? (server.sendChannelUpdates ?? false) : false;
|
||||||
|
|
||||||
await getDatabase()
|
await getDatabase()
|
||||||
.updateTable('mediaSource')
|
.updateTable('mediaSource')
|
||||||
@@ -188,9 +192,9 @@ export class MediaSourceDB {
|
|||||||
async addMediaSource(server: InsertMediaSourceRequest): Promise<string> {
|
async addMediaSource(server: InsertMediaSourceRequest): Promise<string> {
|
||||||
const name = isUndefined(server.name) ? 'plex' : server.name;
|
const name = isUndefined(server.name) ? 'plex' : server.name;
|
||||||
const sendGuideUpdates =
|
const sendGuideUpdates =
|
||||||
server.type === 'plex' ? server.sendGuideUpdates ?? false : false;
|
server.type === 'plex' ? (server.sendGuideUpdates ?? false) : false;
|
||||||
const sendChannelUpdates =
|
const sendChannelUpdates =
|
||||||
server.type === 'plex' ? server.sendChannelUpdates ?? false : false;
|
server.type === 'plex' ? (server.sendChannelUpdates ?? false) : false;
|
||||||
const index = await getDatabase()
|
const index = await getDatabase()
|
||||||
.selectFrom('mediaSource')
|
.selectFrom('mediaSource')
|
||||||
.select((eb) => eb.fn.count<number>('uuid').as('count'))
|
.select((eb) => eb.fn.count<number>('uuid').as('count'))
|
||||||
@@ -218,38 +222,6 @@ export class MediaSourceDB {
|
|||||||
return newServer?.uuid;
|
return newServer?.uuid;
|
||||||
}
|
}
|
||||||
|
|
||||||
// private async removeDanglingPrograms(mediaSource: MediaSource) {
|
|
||||||
// const knownProgramIds = await directDbAccess()
|
|
||||||
// .selectFrom('programExternalId as p1')
|
|
||||||
// .where(({ eb, and }) =>
|
|
||||||
// and([
|
|
||||||
// eb('p1.externalSourceId', '=', mediaSource.name),
|
|
||||||
// eb('p1.sourceType', '=', mediaSource.type),
|
|
||||||
// ]),
|
|
||||||
// )
|
|
||||||
// .selectAll('p1')
|
|
||||||
// .select((eb) =>
|
|
||||||
// jsonArrayFrom(
|
|
||||||
// eb
|
|
||||||
// .selectFrom('programExternalId as p2')
|
|
||||||
// .whereRef('p2.programUuid', '=', 'p1.programUuid')
|
|
||||||
// .whereRef('p2.uuid', '!=', 'p1.uuid')
|
|
||||||
// .select(['p2.sourceType', 'p2.externalSourceId', 'p2.externalKey']),
|
|
||||||
// ).as('otherExternalIds'),
|
|
||||||
// )
|
|
||||||
// .groupBy('p1.uuid')
|
|
||||||
// .execute();
|
|
||||||
|
|
||||||
// const mediaSourceTypes = map(enumValues(MediaSourceType), (typ) =>
|
|
||||||
// typ.toString(),
|
|
||||||
// );
|
|
||||||
// const danglingPrograms = reject(knownProgramIds, (program) => {
|
|
||||||
// some(program.otherExternalIds, (eid) =>
|
|
||||||
// mediaSourceTypes.includes(eid.sourceType),
|
|
||||||
// );
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
private async fixupProgramReferences(
|
private async fixupProgramReferences(
|
||||||
serverName: string,
|
serverName: string,
|
||||||
serverType: MediaSourceType,
|
serverType: MediaSourceType,
|
||||||
@@ -350,8 +322,8 @@ export class MediaSourceDB {
|
|||||||
id,
|
id,
|
||||||
channelNumber: number,
|
channelNumber: number,
|
||||||
channelName: name,
|
channelName: name,
|
||||||
destroyedPrograms: isUpdate ? 0 : channelToProgramCount[id] ?? 0,
|
destroyedPrograms: isUpdate ? 0 : (channelToProgramCount[id] ?? 0),
|
||||||
modifiedPrograms: isUpdate ? channelToProgramCount[id] ?? 0 : 0,
|
modifiedPrograms: isUpdate ? (channelToProgramCount[id] ?? 0) : 0,
|
||||||
} as Report;
|
} as Report;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -359,15 +331,15 @@ export class MediaSourceDB {
|
|||||||
const fillerReports: Report[] = map(fillersById, ({ uuid }) => ({
|
const fillerReports: Report[] = map(fillersById, ({ uuid }) => ({
|
||||||
type: 'filler',
|
type: 'filler',
|
||||||
id: uuid,
|
id: uuid,
|
||||||
destroyedPrograms: isUpdate ? 0 : fillerToProgramCount[uuid] ?? 0,
|
destroyedPrograms: isUpdate ? 0 : (fillerToProgramCount[uuid] ?? 0),
|
||||||
modifiedPrograms: isUpdate ? fillerToProgramCount[uuid] ?? 0 : 0,
|
modifiedPrograms: isUpdate ? (fillerToProgramCount[uuid] ?? 0) : 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const customShowReports: Report[] = map(customShowById, ({ uuid }) => ({
|
const customShowReports: Report[] = map(customShowById, ({ uuid }) => ({
|
||||||
type: 'custom-show',
|
type: 'custom-show',
|
||||||
id: uuid,
|
id: uuid,
|
||||||
destroyedPrograms: isUpdate ? 0 : customShowToProgramCount[uuid] ?? 0,
|
destroyedPrograms: isUpdate ? 0 : (customShowToProgramCount[uuid] ?? 0),
|
||||||
modifiedPrograms: isUpdate ? customShowToProgramCount[uuid] ?? 0 : 0,
|
modifiedPrograms: isUpdate ? (customShowToProgramCount[uuid] ?? 0) : 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return [...channelReports, ...fillerReports, ...customShowReports];
|
return [...channelReports, ...fillerReports, ...customShowReports];
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { mapAsyncSeq } from '@/util/index.js';
|
import { mapAsyncSeq } from '@/util/index.js';
|
||||||
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
|
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
|
||||||
import { isValidSingleExternalIdType } from '@tunarr/types/schemas';
|
import { chunk, flatten, isEmpty, partition } from 'lodash-es';
|
||||||
import { chunk, flatten, isEmpty, isUndefined, partition } from 'lodash-es';
|
|
||||||
import { getDatabase } from './DBAccess.ts';
|
import { getDatabase } from './DBAccess.ts';
|
||||||
import type { NewProgramExternalId as NewRawProgramExternalId } from './schema/ProgramExternalId.ts';
|
import {
|
||||||
|
toInsertableProgramExternalId,
|
||||||
|
type NewSingleOrMultiExternalId,
|
||||||
|
} from './schema/ProgramExternalId.ts';
|
||||||
|
|
||||||
export const upsertRawProgramExternalIds = async (
|
export const upsertProgramExternalIds = async (
|
||||||
externalIds: NewRawProgramExternalId[],
|
externalIds: NewSingleOrMultiExternalId[],
|
||||||
chunkSize: number = 100,
|
chunkSize: number = 100,
|
||||||
) => {
|
) => {
|
||||||
if (isEmpty(externalIds)) {
|
if (isEmpty(externalIds)) {
|
||||||
@@ -17,9 +19,7 @@ export const upsertRawProgramExternalIds = async (
|
|||||||
|
|
||||||
const [singles, multiples] = partition(
|
const [singles, multiples] = partition(
|
||||||
externalIds,
|
externalIds,
|
||||||
(id) =>
|
(id) => id.type === 'single',
|
||||||
isValidSingleExternalIdType(id.sourceType) &&
|
|
||||||
isUndefined(id.externalSourceId),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let singleIdPromise: Promise<{ uuid: string }[]>;
|
let singleIdPromise: Promise<{ uuid: string }[]>;
|
||||||
@@ -30,7 +30,7 @@ export const upsertRawProgramExternalIds = async (
|
|||||||
.execute((tx) =>
|
.execute((tx) =>
|
||||||
tx
|
tx
|
||||||
.insertInto('programExternalId')
|
.insertInto('programExternalId')
|
||||||
.values(singleChunk)
|
.values(singleChunk.map(toInsertableProgramExternalId))
|
||||||
.onConflict((oc) =>
|
.onConflict((oc) =>
|
||||||
oc
|
oc
|
||||||
.columns(['programUuid', 'sourceType'])
|
.columns(['programUuid', 'sourceType'])
|
||||||
@@ -42,6 +42,17 @@ export const upsertRawProgramExternalIds = async (
|
|||||||
programUuid: eb.ref('excluded.programUuid'),
|
programUuid: eb.ref('excluded.programUuid'),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
.onConflict((oc) =>
|
||||||
|
oc
|
||||||
|
.columns(['programUuid', 'sourceType'])
|
||||||
|
.where('mediaSourceId', 'is', null)
|
||||||
|
.doUpdateSet((eb) => ({
|
||||||
|
updatedAt: eb.ref('excluded.updatedAt'),
|
||||||
|
externalFilePath: eb.ref('excluded.externalFilePath'),
|
||||||
|
directFilePath: eb.ref('excluded.directFilePath'),
|
||||||
|
programUuid: eb.ref('excluded.programUuid'),
|
||||||
|
})),
|
||||||
|
)
|
||||||
.returning('uuid as uuid')
|
.returning('uuid as uuid')
|
||||||
.execute(),
|
.execute(),
|
||||||
);
|
);
|
||||||
@@ -58,7 +69,7 @@ export const upsertRawProgramExternalIds = async (
|
|||||||
.execute((tx) =>
|
.execute((tx) =>
|
||||||
tx
|
tx
|
||||||
.insertInto('programExternalId')
|
.insertInto('programExternalId')
|
||||||
.values(multiChunk)
|
.values(multiChunk.map(toInsertableProgramExternalId))
|
||||||
.onConflict((oc) =>
|
.onConflict((oc) =>
|
||||||
oc
|
oc
|
||||||
.columns(['programUuid', 'sourceType', 'externalSourceId'])
|
.columns(['programUuid', 'sourceType', 'externalSourceId'])
|
||||||
@@ -70,6 +81,17 @@ export const upsertRawProgramExternalIds = async (
|
|||||||
programUuid: eb.ref('excluded.programUuid'),
|
programUuid: eb.ref('excluded.programUuid'),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
.onConflict((oc) =>
|
||||||
|
oc
|
||||||
|
.columns(['programUuid', 'sourceType', 'mediaSourceId'])
|
||||||
|
.where('mediaSourceId', 'is not', null)
|
||||||
|
.doUpdateSet((eb) => ({
|
||||||
|
updatedAt: eb.ref('excluded.updatedAt'),
|
||||||
|
externalFilePath: eb.ref('excluded.externalFilePath'),
|
||||||
|
directFilePath: eb.ref('excluded.directFilePath'),
|
||||||
|
programUuid: eb.ref('excluded.programUuid'),
|
||||||
|
})),
|
||||||
|
)
|
||||||
.returning('uuid as uuid')
|
.returning('uuid as uuid')
|
||||||
.execute(),
|
.execute(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import {
|
|||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
} from 'drizzle-orm/sqlite-core';
|
} from 'drizzle-orm/sqlite-core';
|
||||||
import type { Insertable, Selectable, Updateable } from 'kysely';
|
import type { Insertable, Selectable, Updateable } from 'kysely';
|
||||||
|
import type { MarkNotNilable } from '../../types/util.ts';
|
||||||
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
||||||
import { MediaSourceTypes } from './MediaSource.ts';
|
import { MediaSource, MediaSourceTypes } from './MediaSource.ts';
|
||||||
import { ProgramGrouping } from './ProgramGrouping.ts';
|
import { ProgramGrouping } from './ProgramGrouping.ts';
|
||||||
|
|
||||||
export const ProgramTypes = ['movie', 'episode', 'track'] as const;
|
export const ProgramTypes = ['movie', 'episode', 'track'] as const;
|
||||||
@@ -37,6 +38,9 @@ export const Program = sqliteTable(
|
|||||||
episodeIcon: text(),
|
episodeIcon: text(),
|
||||||
externalKey: text().notNull(),
|
externalKey: text().notNull(),
|
||||||
externalSourceId: text().notNull(),
|
externalSourceId: text().notNull(),
|
||||||
|
mediaSourceId: text().references(() => MediaSource.uuid, {
|
||||||
|
onDelete: 'cascade',
|
||||||
|
}),
|
||||||
filePath: text(),
|
filePath: text(),
|
||||||
grandparentExternalKey: text(),
|
grandparentExternalKey: text(),
|
||||||
icon: text(),
|
icon: text(),
|
||||||
@@ -78,7 +82,10 @@ export const Program = sqliteTable(
|
|||||||
|
|
||||||
export type ProgramTable = KyselifyBetter<typeof Program>;
|
export type ProgramTable = KyselifyBetter<typeof Program>;
|
||||||
export type ProgramDao = Selectable<ProgramTable>;
|
export type ProgramDao = Selectable<ProgramTable>;
|
||||||
export type NewProgramDao = Insertable<ProgramTable>;
|
export type NewProgramDao = MarkNotNilable<
|
||||||
|
Insertable<ProgramTable>,
|
||||||
|
'mediaSourceId'
|
||||||
|
>;
|
||||||
export type ProgramDaoUpdate = Updateable<ProgramTable>;
|
export type ProgramDaoUpdate = Updateable<ProgramTable>;
|
||||||
|
|
||||||
export function programExternalIdString(p: ProgramDao | NewProgramDao) {
|
export function programExternalIdString(p: ProgramDao | NewProgramDao) {
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import {
|
|||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
} from 'drizzle-orm/sqlite-core';
|
} from 'drizzle-orm/sqlite-core';
|
||||||
import type { Insertable, Selectable } from 'kysely';
|
import type { Insertable, Selectable } from 'kysely';
|
||||||
import type { MarkRequired } from 'ts-essentials';
|
import { omit } from 'lodash-es';
|
||||||
|
import type { MarkRequired, StrictOmit } from 'ts-essentials';
|
||||||
|
import type { MarkNotNilable } from '../../types/util.ts';
|
||||||
import { ProgramExternalIdSourceTypes } from './base.ts';
|
import { ProgramExternalIdSourceTypes } from './base.ts';
|
||||||
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
||||||
|
import { MediaSource } from './MediaSource.ts';
|
||||||
import { Program } from './Program.ts';
|
import { Program } from './Program.ts';
|
||||||
|
|
||||||
export const ProgramExternalId = sqliteTable(
|
export const ProgramExternalId = sqliteTable(
|
||||||
@@ -23,9 +26,12 @@ export const ProgramExternalId = sqliteTable(
|
|||||||
externalFilePath: text(),
|
externalFilePath: text(),
|
||||||
externalKey: text().notNull(),
|
externalKey: text().notNull(),
|
||||||
externalSourceId: text(),
|
externalSourceId: text(),
|
||||||
|
mediaSourceId: text().references(() => MediaSource.uuid, {
|
||||||
|
onDelete: 'cascade',
|
||||||
|
}),
|
||||||
programUuid: text()
|
programUuid: text()
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => Program.uuid),
|
.references(() => Program.uuid, { onDelete: 'cascade' }),
|
||||||
sourceType: text({ enum: ProgramExternalIdSourceTypes }).notNull(),
|
sourceType: text({ enum: ProgramExternalIdSourceTypes }).notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
@@ -46,6 +52,24 @@ export const ProgramExternalId = sqliteTable(
|
|||||||
export type ProgramExternalIdTable = KyselifyBetter<typeof ProgramExternalId>;
|
export type ProgramExternalIdTable = KyselifyBetter<typeof ProgramExternalId>;
|
||||||
export type ProgramExternalId = Selectable<ProgramExternalIdTable>;
|
export type ProgramExternalId = Selectable<ProgramExternalIdTable>;
|
||||||
export type NewProgramExternalId = Insertable<ProgramExternalIdTable>;
|
export type NewProgramExternalId = Insertable<ProgramExternalIdTable>;
|
||||||
|
export type NewSingleOrMultiExternalId =
|
||||||
|
| (StrictOmit<
|
||||||
|
Insertable<ProgramExternalIdTable>,
|
||||||
|
'mediaSourceId' | 'externalSourceId'
|
||||||
|
> & { type: 'single' })
|
||||||
|
| (MarkNotNilable<
|
||||||
|
Insertable<ProgramExternalIdTable>,
|
||||||
|
'mediaSourceId' | 'externalSourceId'
|
||||||
|
> & { type: 'multi' });
|
||||||
|
|
||||||
|
export function toInsertableProgramExternalId(
|
||||||
|
extraInfo: NewSingleOrMultiExternalId,
|
||||||
|
): NewProgramExternalId {
|
||||||
|
return omit(extraInfo, 'type') satisfies Omit<
|
||||||
|
NewSingleOrMultiExternalId,
|
||||||
|
'type'
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
export type MinimalProgramExternalId = MarkRequired<
|
export type MinimalProgramExternalId = MarkRequired<
|
||||||
Partial<ProgramExternalId>,
|
Partial<ProgramExternalId>,
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ import {
|
|||||||
text,
|
text,
|
||||||
} from 'drizzle-orm/sqlite-core';
|
} from 'drizzle-orm/sqlite-core';
|
||||||
import type { Insertable, Selectable } from 'kysely';
|
import type { Insertable, Selectable } from 'kysely';
|
||||||
|
import { omit } from 'lodash-es';
|
||||||
|
import type { StrictOmit } from 'ts-essentials';
|
||||||
|
import type { MarkNotNilable } from '../../types/util.ts';
|
||||||
import { ProgramExternalIdSourceTypes } from './base.ts';
|
import { ProgramExternalIdSourceTypes } from './base.ts';
|
||||||
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
import { type KyselifyBetter } from './KyselifyBetter.ts';
|
||||||
|
import { MediaSource } from './MediaSource.ts';
|
||||||
import { ProgramGrouping } from './ProgramGrouping.ts';
|
import { ProgramGrouping } from './ProgramGrouping.ts';
|
||||||
|
|
||||||
export const ProgramGroupingExternalId = sqliteTable(
|
export const ProgramGroupingExternalId = sqliteTable(
|
||||||
@@ -20,6 +24,9 @@ export const ProgramGroupingExternalId = sqliteTable(
|
|||||||
externalFilePath: text(),
|
externalFilePath: text(),
|
||||||
externalKey: text().notNull(),
|
externalKey: text().notNull(),
|
||||||
externalSourceId: text(),
|
externalSourceId: text(),
|
||||||
|
mediaSourceId: text().references(() => MediaSource.uuid, {
|
||||||
|
onDelete: 'cascade',
|
||||||
|
}),
|
||||||
groupUuid: text()
|
groupUuid: text()
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => ProgramGrouping.uuid, {
|
.references(() => ProgramGrouping.uuid, {
|
||||||
@@ -45,6 +52,21 @@ export type ProgramGroupingExternalId =
|
|||||||
Selectable<ProgramGroupingExternalIdTable>;
|
Selectable<ProgramGroupingExternalIdTable>;
|
||||||
export type NewProgramGroupingExternalId =
|
export type NewProgramGroupingExternalId =
|
||||||
Insertable<ProgramGroupingExternalIdTable>;
|
Insertable<ProgramGroupingExternalIdTable>;
|
||||||
|
export type NewSingleOrMultiProgramGroupingExternalId =
|
||||||
|
| (StrictOmit<
|
||||||
|
Insertable<ProgramGroupingExternalIdTable>,
|
||||||
|
'externalSourceId' | 'mediaSourceId'
|
||||||
|
> & { type: 'single' })
|
||||||
|
| (MarkNotNilable<
|
||||||
|
Insertable<ProgramGroupingExternalIdTable>,
|
||||||
|
'externalSourceId' | 'mediaSourceId'
|
||||||
|
> & { type: 'multi' });
|
||||||
|
|
||||||
|
export function toInsertableProgramGroupingExternalId(
|
||||||
|
eid: NewSingleOrMultiProgramGroupingExternalId,
|
||||||
|
): NewProgramGroupingExternalId {
|
||||||
|
return omit(eid, 'type') satisfies NewProgramGroupingExternalId;
|
||||||
|
}
|
||||||
|
|
||||||
export type ProgramGroupingExternalIdFields<
|
export type ProgramGroupingExternalIdFields<
|
||||||
Alias extends string = 'ProgramGroupingExternalId',
|
Alias extends string = 'ProgramGroupingExternalId',
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { GlobalScheduler } from '@/services/Scheduler.js';
|
|||||||
import { ReconcileProgramDurationsTask } from '@/tasks/ReconcileProgramDurationsTask.js';
|
import { ReconcileProgramDurationsTask } from '@/tasks/ReconcileProgramDurationsTask.js';
|
||||||
import { KEYS } from '@/types/inject.js';
|
import { KEYS } from '@/types/inject.js';
|
||||||
import { Maybe } from '@/types/util.js';
|
import { Maybe } from '@/types/util.js';
|
||||||
import { groupByUniq, isDefined } from '@/util/index.js';
|
import { groupByUniq, isDefined, run } from '@/util/index.js';
|
||||||
import { type Logger } from '@/util/logging/LoggerFactory.js';
|
import { type Logger } from '@/util/logging/LoggerFactory.js';
|
||||||
import { JellyfinItem, JellyfinItemKind } from '@tunarr/types/jellyfin';
|
import { JellyfinItem, JellyfinItemKind } from '@tunarr/types/jellyfin';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
ProgramExternalIdType,
|
ProgramExternalIdType,
|
||||||
programExternalIdTypeFromJellyfinProvider,
|
programExternalIdTypeFromJellyfinProvider,
|
||||||
} from '../../db/custom_types/ProgramExternalIdType.ts';
|
} from '../../db/custom_types/ProgramExternalIdType.ts';
|
||||||
|
import { MediaSourceDB } from '../../db/mediaSourceDB.ts';
|
||||||
|
import { MediaSourceType } from '../../db/schema/MediaSource.ts';
|
||||||
import { JellyfinGetItemsQuery } from './JellyfinApiClient.ts';
|
import { JellyfinGetItemsQuery } from './JellyfinApiClient.ts';
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
@@ -28,6 +30,7 @@ export class JellyfinItemFinder {
|
|||||||
@inject(KEYS.Logger) private logger: Logger,
|
@inject(KEYS.Logger) private logger: Logger,
|
||||||
@inject(MediaSourceApiFactory)
|
@inject(MediaSourceApiFactory)
|
||||||
private mediaSourceApiFactory: MediaSourceApiFactory,
|
private mediaSourceApiFactory: MediaSourceApiFactory,
|
||||||
|
@inject(MediaSourceDB) private mediaSourceDB: MediaSourceDB,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findForProgramAndUpdate(programId: string) {
|
async findForProgramAndUpdate(programId: string) {
|
||||||
@@ -65,10 +68,24 @@ export class JellyfinItemFinder {
|
|||||||
// Right now just check if the durations are different.
|
// Right now just check if the durations are different.
|
||||||
// otherwise we might blow away details we already have, since
|
// otherwise we might blow away details we already have, since
|
||||||
// Jellyfin collects metadata asynchronously (sometimes)
|
// Jellyfin collects metadata asynchronously (sometimes)
|
||||||
const updatedProgram = minter.mint(program.externalSourceId, {
|
const mediaSourceId =
|
||||||
sourceType: 'jellyfin',
|
program.mediaSourceId ??
|
||||||
program: potentialApiMatch,
|
(await run(async () => {
|
||||||
});
|
const ms = await this.findMediaSource(program.externalSourceId);
|
||||||
|
if (!ms)
|
||||||
|
throw new Error(
|
||||||
|
`Could not find media source by name: ${program.externalSourceId}`,
|
||||||
|
);
|
||||||
|
return ms.uuid;
|
||||||
|
}));
|
||||||
|
const updatedProgram = minter.mint(
|
||||||
|
program.externalSourceId,
|
||||||
|
mediaSourceId,
|
||||||
|
{
|
||||||
|
sourceType: 'jellyfin',
|
||||||
|
program: potentialApiMatch,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (updatedProgram.duration !== program.duration) {
|
if (updatedProgram.duration !== program.duration) {
|
||||||
await this.programDB.updateProgramDuration(
|
await this.programDB.updateProgramDuration(
|
||||||
@@ -207,4 +224,11 @@ export class JellyfinItemFinder {
|
|||||||
|
|
||||||
return possibleMatch;
|
return possibleMatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private findMediaSource(mediaSourceName: string) {
|
||||||
|
return this.mediaSourceDB.findByType(
|
||||||
|
MediaSourceType.Jellyfin,
|
||||||
|
mediaSourceName,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
server/src/external/plex/PlexApiClient.ts
vendored
4
server/src/external/plex/PlexApiClient.ts
vendored
@@ -69,6 +69,10 @@ export class PlexApiClient extends BaseApiClient {
|
|||||||
return this.opts.name;
|
return this.opts.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get serverId() {
|
||||||
|
return this.opts.uuid;
|
||||||
|
}
|
||||||
|
|
||||||
getFullUrl(path: string): string {
|
getFullUrl(path: string): string {
|
||||||
const url = super.getFullUrl(path);
|
const url = super.getFullUrl(path);
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import Migration1730806741 from './db/Migration1730806741.ts';
|
|||||||
import Migration1731982492 from './db/Migration1731982492.ts';
|
import Migration1731982492 from './db/Migration1731982492.ts';
|
||||||
import Migration1732969335_AddTranscodeConfig from './db/Migration1732969335_AddTranscodeConfig.ts';
|
import Migration1732969335_AddTranscodeConfig from './db/Migration1732969335_AddTranscodeConfig.ts';
|
||||||
import Migration1738604866_AddEmby from './db/Migration1738604866_AddEmby.ts';
|
import Migration1738604866_AddEmby from './db/Migration1738604866_AddEmby.ts';
|
||||||
|
import Migration1740691984_ProgramMediaSourceId from './db/Migration1740691984_ProgramMediaSourceId.ts';
|
||||||
|
|
||||||
export const LegacyMigrationNameToNewMigrationName = [
|
export const LegacyMigrationNameToNewMigrationName = [
|
||||||
['Migration20240124115044', '_Legacy_Migration00'],
|
['Migration20240124115044', '_Legacy_Migration00'],
|
||||||
@@ -90,6 +91,7 @@ export class DirectMigrationProvider implements MigrationProvider {
|
|||||||
migration1732969335: Migration1732969335_AddTranscodeConfig,
|
migration1732969335: Migration1732969335_AddTranscodeConfig,
|
||||||
migration1735044379: Migration1735044379_AddHlsDirect,
|
migration1735044379: Migration1735044379_AddHlsDirect,
|
||||||
migration1738604866: Migration1738604866_AddEmby,
|
migration1738604866: Migration1738604866_AddEmby,
|
||||||
|
migration1740691984: Migration1740691984_ProgramMediaSourceId,
|
||||||
},
|
},
|
||||||
wrapWithTransaction,
|
wrapWithTransaction,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { type Kysely, sql } from 'kysely';
|
import { type Kysely, sql } from 'kysely';
|
||||||
import { trimEnd } from 'lodash-es';
|
import { replace } from 'lodash-es';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import tmp from 'tmp-promise';
|
import tmp from 'tmp-promise';
|
||||||
import {
|
import {
|
||||||
getDatabase,
|
getDatabaseContext,
|
||||||
MigrationLockTableName,
|
MigrationLockTableName,
|
||||||
MigrationTableName,
|
MigrationTableName,
|
||||||
runDBMigrations,
|
runDBMigrations,
|
||||||
@@ -23,10 +23,11 @@ export class DatabaseCopyMigrator {
|
|||||||
async migrate(currentDbPath: string, migrateTo?: string) {
|
async migrate(currentDbPath: string, migrateTo?: string) {
|
||||||
const { path: tmpPath } = await tmp.file({ keep: false });
|
const { path: tmpPath } = await tmp.file({ keep: false });
|
||||||
this.logger.debug('Migrating to temp DB %s', tmpPath);
|
this.logger.debug('Migrating to temp DB %s', tmpPath);
|
||||||
const tempDB = getDatabase(tmpPath);
|
const tempDB = getDatabaseContext()!.getOrCreateKyselyDatabase(tmpPath);
|
||||||
await runDBMigrations(tempDB, migrateTo);
|
await runDBMigrations(tempDB, migrateTo);
|
||||||
|
|
||||||
const oldDB = getDatabase(currentDbPath);
|
const oldDB =
|
||||||
|
getDatabaseContext()!.getOrCreateKyselyDatabase(currentDbPath);
|
||||||
const oldTables = await this.getTables(oldDB);
|
const oldTables = await this.getTables(oldDB);
|
||||||
const newTables = await this.getTables(tempDB);
|
const newTables = await this.getTables(tempDB);
|
||||||
// Prepare for copy.
|
// Prepare for copy.
|
||||||
@@ -48,11 +49,11 @@ export class DatabaseCopyMigrator {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const columnUnion = new Set(table.columns.map((col) => col.name)).union(
|
const columnIntersection = new Set(
|
||||||
new Set(newTable.columns.map((col) => col.name)),
|
table.columns.map((col) => col.name),
|
||||||
);
|
).intersection(new Set(newTable.columns.map((col) => col.name)));
|
||||||
|
|
||||||
const colNames = [...columnUnion].sort();
|
const colNames = [...columnIntersection].sort();
|
||||||
await sql`INSERT INTO ${sql.table(table.name)} (${sql.join(colNames.map((n) => sql.ref(n)))}) SELECT ${sql.join(colNames.map((n) => sql.ref(n)))} FROM ${sql.ref('old')}.${sql.table(table.name)} WHERE true ON CONFLICT DO NOTHING;`.execute(
|
await sql`INSERT INTO ${sql.table(table.name)} (${sql.join(colNames.map((n) => sql.ref(n)))}) SELECT ${sql.join(colNames.map((n) => sql.ref(n)))} FROM ${sql.ref('old')}.${sql.table(table.name)} WHERE true ON CONFLICT DO NOTHING;`.execute(
|
||||||
tempDB,
|
tempDB,
|
||||||
);
|
);
|
||||||
@@ -63,11 +64,11 @@ export class DatabaseCopyMigrator {
|
|||||||
|
|
||||||
await fs.copyFile(
|
await fs.copyFile(
|
||||||
currentDbPath,
|
currentDbPath,
|
||||||
`${trimEnd(currentDbPath, '.db')}-${+dayjs()}.bak`,
|
`${replace(currentDbPath, '.db', '')}-${+dayjs()}.bak`,
|
||||||
);
|
);
|
||||||
await fs.cp(tmpPath, currentDbPath);
|
await fs.cp(tmpPath, currentDbPath);
|
||||||
// Force reinit at the new path
|
// Force reinit at the new path
|
||||||
getDatabase(currentDbPath, true);
|
getDatabaseContext()!.setConnection(currentDbPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { type Kysely, CompiledQuery, sql } from 'kysely';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
fullCopy: true,
|
||||||
|
async up(db: Kysely<unknown>) {
|
||||||
|
await db.executeQuery(CompiledQuery.raw('PRAGMA foreign_keys = OFF'));
|
||||||
|
await db.executeQuery(CompiledQuery.raw('PRAGMA defer_foreign_keys = ON'));
|
||||||
|
|
||||||
|
const createProgramTableTemp = sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS "program_tmp" (
|
||||||
|
"uuid" text not null primary key,
|
||||||
|
"created_at" datetime,
|
||||||
|
"updated_at" datetime,
|
||||||
|
"source_type" text not null check ((\`source_type\` in ('plex', 'jellyfin', 'emby', 'local'))),
|
||||||
|
"original_air_date" text,
|
||||||
|
"duration" integer not null,
|
||||||
|
"episode" integer,
|
||||||
|
"episode_icon" text,
|
||||||
|
"file_path" text,
|
||||||
|
"icon" text,
|
||||||
|
"external_source_id" text not null,
|
||||||
|
"media_source_id" text references "media_source" ("uuid") on delete cascade,
|
||||||
|
"external_key" text not null,
|
||||||
|
"plex_rating_key" text,
|
||||||
|
"plex_file_path" text,
|
||||||
|
"parent_external_key" text,
|
||||||
|
"grandparent_external_key" text,
|
||||||
|
"rating" text,
|
||||||
|
"season_number" integer,
|
||||||
|
"season_icon" text,
|
||||||
|
"show_icon" text,
|
||||||
|
"show_title" text,
|
||||||
|
"summary" text,
|
||||||
|
"title" text not null,
|
||||||
|
"type" text not null check ((\`type\` in ('movie', 'episode', 'track'))),
|
||||||
|
"year" integer,
|
||||||
|
"artist_name" text,
|
||||||
|
"album_name" text,
|
||||||
|
"season_uuid" text,
|
||||||
|
"album_uuid" text,
|
||||||
|
"artist_uuid" text,
|
||||||
|
"tv_show_uuid" text,
|
||||||
|
constraint "program_season_uuid_foreign" foreign key ("season_uuid") references "program_grouping" ("uuid") on update cascade,
|
||||||
|
constraint "program_album_uuid_foreign" foreign key ("album_uuid") references "program_grouping" ("uuid") on update cascade,
|
||||||
|
constraint "program_artist_uuid_foreign" foreign key ("artist_uuid") references "program_grouping" ("uuid") on update cascade,
|
||||||
|
constraint "program_tv_show_uuid_foreign" foreign key ("tv_show_uuid") references "program_grouping" ("uuid") on update cascade);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const createProgramGroupingExternalIdTemp = sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS "program_external_id_temp" (
|
||||||
|
"uuid" text not null primary key,
|
||||||
|
"created_at" datetime,
|
||||||
|
"updated_at" datetime,
|
||||||
|
"source_type" text not null check ((\`source_type\` in ('plex', 'plex-guid', 'tmdb', 'imdb', 'tvdb', 'jellyfin', 'emby', 'local'))),
|
||||||
|
"external_source_id" text,
|
||||||
|
"media_source_id" text references "media_source" ("uuid") on delete cascade,
|
||||||
|
"external_key" text not null,
|
||||||
|
"external_file_path" text,
|
||||||
|
"direct_file_path" text,
|
||||||
|
"program_uuid" text not null,
|
||||||
|
constraint "program_external_id_program_uuid_foreign" foreign key ("program_uuid") references "program" ("uuid") on delete cascade
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
await db.executeQuery(createProgramGroupingExternalIdTemp.compile(db));
|
||||||
|
|
||||||
|
const createProgramGroupExternalIdTemp = sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS "program_grouping_external_id_tmp" (
|
||||||
|
"uuid" text not null primary key,
|
||||||
|
"created_at" datetime,
|
||||||
|
"updated_at" datetime,
|
||||||
|
"source_type" text not null check ((\`source_type\` in ('plex', 'plex-guid', 'tmdb', 'imdb', 'tvdb', 'jellyfin', 'emby', 'local'))),
|
||||||
|
"external_source_id" text,
|
||||||
|
"media_source_id" text references "media_source" ("uuid") on delete cascade,
|
||||||
|
"external_key" text not null,
|
||||||
|
"group_uuid" text not null,
|
||||||
|
"external_file_path" text,
|
||||||
|
constraint "program_grouping_external_id_group_uuid_foreign" foreign key ("group_uuid") references "program_grouping" ("uuid") on delete cascade on update cascade
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
await db.executeQuery(createProgramGroupExternalIdTemp.compile(db));
|
||||||
|
|
||||||
|
const indexes = [
|
||||||
|
// Programs
|
||||||
|
'DROP INDEX IF EXISTS "program_season_uuid_index"',
|
||||||
|
'CREATE INDEX "program_season_uuid_index" on "program_tmp" ("season_uuid")',
|
||||||
|
'DROP INDEX IF EXISTS "program_tv_show_uuid_index"',
|
||||||
|
'CREATE INDEX "program_tv_show_uuid_index" on "program_tmp" ("tv_show_uuid")',
|
||||||
|
'DROP INDEX IF EXISTS "program_album_uuid_index"',
|
||||||
|
'CREATE INDEX "program_album_uuid_index" on "program_tmp" ("album_uuid")',
|
||||||
|
'DROP INDEX IF EXISTS "program_artist_uuid_index"',
|
||||||
|
'CREATE INDEX "program_artist_uuid_index" on "program_tmp" ("artist_uuid")',
|
||||||
|
'DROP INDEX IF EXISTS "program_source_type_external_source_id_external_key_unique"',
|
||||||
|
'CREATE UNIQUE INDEX "program_source_type_external_source_id_external_key_unique" on "program_tmp" ("source_type", "external_source_id", "external_key")',
|
||||||
|
// New one
|
||||||
|
'CREATE UNIQUE INDEX "program_media_source_uniq" on "program_tmp" ("source_type", "media_source_id", "external_key")',
|
||||||
|
// Program external IDs
|
||||||
|
'DROP INDEX IF EXISTS "unique_program_multiple_external_id"',
|
||||||
|
'DROP INDEX IF EXISTS "unique_program_single_external_id"',
|
||||||
|
`CREATE UNIQUE INDEX "unique_program_multiple_external_id" on "program_external_id_temp" ("program_uuid", "source_type", "external_source_id") where \`external_source_id\` is not null`,
|
||||||
|
`CREATE UNIQUE INDEX "unique_program_single_external_id" on "program_external_id_temp" ("program_uuid", "source_type") where \`external_source_id\` is null`,
|
||||||
|
// New indexes on program external ids
|
||||||
|
`CREATE UNIQUE INDEX "unique_program_multiple_external_id_media_source" on "program_external_id_temp" ("program_uuid", "source_type", "media_source_id") where \`media_source_id\` is not null`,
|
||||||
|
`CREATE UNIQUE INDEX "unique_program_single_external_id_media_source" on "program_external_id_temp" ("program_uuid", "source_type") where \`media_source_id\` is null`,
|
||||||
|
// Program grouping external IDs
|
||||||
|
// 'DROP INDEX IF EXISTS "unique_program_multiple_external_id"',
|
||||||
|
// 'DROP INDEX IF EXISTS "unique_program_single_external_id"',
|
||||||
|
// `CREATE UNIQUE INDEX "unique_program_multiple_external_id" on "program_grouping_external_id_tmp" ("program_uuid", "source_type", "external_source_id") where \`external_source_id\` is not null`,
|
||||||
|
// `CREATE UNIQUE INDEX "unique_program_single_external_id" on "program_grouping_external_id_tmp" ("program_uuid", "source_type") where \`external_source_id\` is null`,
|
||||||
|
// New indexes on program external ids
|
||||||
|
`CREATE UNIQUE INDEX "unique_program_grouping_multiple_external_id_media_source" on "program_grouping_external_id_tmp" ("group_uuid", "source_type", "media_source_id") where \`media_source_id\` is not null`,
|
||||||
|
`CREATE UNIQUE INDEX "unique_program_grouping_single_external_id_media_source" on "program_grouping_external_id_tmp" ("group_uuid", "source_type") where \`media_source_id\` is null`,
|
||||||
|
];
|
||||||
|
|
||||||
|
await db.executeQuery(createProgramTableTemp.compile(db));
|
||||||
|
|
||||||
|
for (const idx of indexes) {
|
||||||
|
await db.executeQuery(CompiledQuery.raw(idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.schema.dropTable('program').execute();
|
||||||
|
await db.schema.alterTable('program_tmp').renameTo('program').execute();
|
||||||
|
await db.schema.dropTable('program_external_id').execute();
|
||||||
|
await db.schema
|
||||||
|
.alterTable('program_external_id_temp')
|
||||||
|
.renameTo('program_external_id')
|
||||||
|
.execute();
|
||||||
|
await db.schema.dropTable('program_grouping_external_id').execute();
|
||||||
|
await db.schema
|
||||||
|
.alterTable('program_grouping_external_id_tmp')
|
||||||
|
.renameTo('program_grouping_external_id')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.executeQuery(CompiledQuery.raw('PRAGMA foreign_keys = ON'));
|
||||||
|
await db.executeQuery(CompiledQuery.raw('PRAGMA defer_foreign_keys = OFF'));
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -176,9 +176,19 @@ export class LegacyChannelMigrator {
|
|||||||
isNonEmptyString(p.key),
|
isNonEmptyString(p.key),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const mediaSources = await getDatabase()
|
||||||
|
.selectFrom('mediaSource')
|
||||||
|
.selectAll()
|
||||||
|
.execute();
|
||||||
|
const mediaSourcesByName = groupByUniqPropAndMap(
|
||||||
|
mediaSources,
|
||||||
|
'name',
|
||||||
|
(ms) => ms.uuid,
|
||||||
|
);
|
||||||
|
|
||||||
const programEntities = seq.collect(
|
const programEntities = seq.collect(
|
||||||
uniqBy(programs, uniqueProgramId),
|
uniqBy<LegacyProgram>(programs, uniqueProgramId),
|
||||||
createProgramEntity,
|
(program) => createProgramEntity(program, mediaSourcesByName),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
|
|||||||
@@ -27,10 +27,12 @@ import {
|
|||||||
import {
|
import {
|
||||||
groupByUniq,
|
groupByUniq,
|
||||||
groupByUniqProp,
|
groupByUniqProp,
|
||||||
|
groupByUniqPropAndMap,
|
||||||
isNonEmptyString,
|
isNonEmptyString,
|
||||||
mapAsyncSeq,
|
mapAsyncSeq,
|
||||||
mapToObj,
|
mapToObj,
|
||||||
} from '../../util/index.ts';
|
} from '../../util/index.ts';
|
||||||
|
import type { LegacyProgram } from './LegacyChannelMigrator.ts';
|
||||||
import type { CustomShow } from './legacyDbMigration.ts';
|
import type { CustomShow } from './legacyDbMigration.ts';
|
||||||
import type { JSONArray, JSONObject } from './migrationUtil.ts';
|
import type { JSONArray, JSONObject } from './migrationUtil.ts';
|
||||||
import {
|
import {
|
||||||
@@ -88,7 +90,7 @@ export class LegacyLibraryMigrator {
|
|||||||
Promise.resolve([] as CustomShow[]),
|
Promise.resolve([] as CustomShow[]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const uniquePrograms = uniqBy(
|
const uniquePrograms = uniqBy<LegacyProgram>(
|
||||||
filter(
|
filter(
|
||||||
flatMap(newCustomShows, (cs) => cs.content),
|
flatMap(newCustomShows, (cs) => cs.content),
|
||||||
(p) =>
|
(p) =>
|
||||||
@@ -99,7 +101,15 @@ export class LegacyLibraryMigrator {
|
|||||||
uniqueProgramId,
|
uniqueProgramId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const programEntities = seq.collect(uniquePrograms, createProgramEntity);
|
const mediaSourcesByName = await getDatabase()
|
||||||
|
.selectFrom('mediaSource')
|
||||||
|
.selectAll()
|
||||||
|
.execute()
|
||||||
|
.then((_) => groupByUniqPropAndMap(_, 'name', (ms) => ms.uuid));
|
||||||
|
|
||||||
|
const programEntities = seq.collect(uniquePrograms, (program) =>
|
||||||
|
createProgramEntity(program, mediaSourcesByName),
|
||||||
|
);
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
'Upserting %d programs from legacy DB',
|
'Upserting %d programs from legacy DB',
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ import type {
|
|||||||
import type { LegacyProgram } from './LegacyChannelMigrator.ts';
|
import type { LegacyProgram } from './LegacyChannelMigrator.ts';
|
||||||
|
|
||||||
// JSON representation for easier parsing of legacy db files
|
// JSON representation for easier parsing of legacy db files
|
||||||
export interface JSONArray extends Array<JSONValue> {}
|
export type JSONArray = Array<JSONValue>;
|
||||||
export interface JSONObject extends Record<string, JSONValue> {}
|
export interface JSONObject {
|
||||||
|
[x: string]: JSONValue;
|
||||||
|
}
|
||||||
|
|
||||||
export type JSONValue =
|
export type JSONValue =
|
||||||
| string
|
| string
|
||||||
| number
|
| number
|
||||||
@@ -68,7 +71,7 @@ export function convertRawProgram(program: JSONObject): LegacyProgram {
|
|||||||
const programType = program['type'] as string | undefined;
|
const programType = program['type'] as string | undefined;
|
||||||
const isMovie = programType === 'movie';
|
const isMovie = programType === 'movie';
|
||||||
const id = v4();
|
const id = v4();
|
||||||
const outProgram: LegacyProgram = {
|
return {
|
||||||
id,
|
id,
|
||||||
duration: program['duration'] as number,
|
duration: program['duration'] as number,
|
||||||
episodeIcon: program['episodeIcon'] as Maybe<string>,
|
episodeIcon: program['episodeIcon'] as Maybe<string>,
|
||||||
@@ -96,15 +99,18 @@ export function convertRawProgram(program: JSONObject): LegacyProgram {
|
|||||||
customShowId: program['customShowId'] as Maybe<string>,
|
customShowId: program['customShowId'] as Maybe<string>,
|
||||||
customShowName: program['customShowName'] as Maybe<string>,
|
customShowName: program['customShowName'] as Maybe<string>,
|
||||||
sourceType: 'plex',
|
sourceType: 'plex',
|
||||||
};
|
} satisfies LegacyProgram;
|
||||||
|
|
||||||
return outProgram;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createProgramEntity(
|
export function createProgramEntity(
|
||||||
program: LegacyProgram,
|
program: LegacyProgram,
|
||||||
|
mediaSourceIdByName: Record<string, string>,
|
||||||
): NewProgramDao | undefined {
|
): NewProgramDao | undefined {
|
||||||
const now = +dayjs();
|
const now = +dayjs();
|
||||||
|
if (!mediaSourceIdByName[program.serverKey ?? '']) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
['movie', 'episode', 'track'].includes(program.type ?? '') &&
|
['movie', 'episode', 'track'].includes(program.type ?? '') &&
|
||||||
every([program.ratingKey, program.serverKey, program.key], isNonEmptyString)
|
every([program.ratingKey, program.serverKey, program.key], isNonEmptyString)
|
||||||
@@ -122,6 +128,7 @@ export function createProgramEntity(
|
|||||||
plexRatingKey: program.key!,
|
plexRatingKey: program.key!,
|
||||||
plexFilePath: program.plexFile,
|
plexFilePath: program.plexFile,
|
||||||
externalSourceId: program.serverKey!,
|
externalSourceId: program.serverKey!,
|
||||||
|
mediaSourceId: mediaSourceIdByName[program.serverKey ?? ''],
|
||||||
showTitle: program.showTitle,
|
showTitle: program.showTitle,
|
||||||
summary: program.summary,
|
summary: program.summary,
|
||||||
title: program.title!,
|
title: program.title!,
|
||||||
|
|||||||
76
server/src/tasks/fixers/BackfillMediaSourceIdFixer.ts
Normal file
76
server/src/tasks/fixers/BackfillMediaSourceIdFixer.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { inject, injectable } from 'inversify';
|
||||||
|
import { getDatabase } from '../../db/DBAccess.ts';
|
||||||
|
import { KEYS } from '../../types/inject.ts';
|
||||||
|
import { Logger } from '../../util/logging/LoggerFactory.ts';
|
||||||
|
import Fixer from './fixer.ts';
|
||||||
|
|
||||||
|
@injectable()
|
||||||
|
export class BackfillMediaSourceIdFixer extends Fixer {
|
||||||
|
constructor(@inject(KEYS.Logger) protected logger: Logger) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async runInternal(): Promise<void> {
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.updateTable('program')
|
||||||
|
.set({
|
||||||
|
mediaSourceId: (eb) =>
|
||||||
|
eb
|
||||||
|
.selectFrom('mediaSource')
|
||||||
|
.whereRef('mediaSource.name', '=', 'program.externalSourceId')
|
||||||
|
.whereRef('mediaSource.type', '=', 'program.sourceType')
|
||||||
|
.select('mediaSource.uuid')
|
||||||
|
.limit(1),
|
||||||
|
})
|
||||||
|
.where('program.mediaSourceId', 'is', null)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.updateTable('programExternalId')
|
||||||
|
.set({
|
||||||
|
mediaSourceId: (eb) =>
|
||||||
|
eb
|
||||||
|
.selectFrom('mediaSource')
|
||||||
|
.whereRef(
|
||||||
|
'mediaSource.name',
|
||||||
|
'=',
|
||||||
|
'programExternalId.externalSourceId',
|
||||||
|
)
|
||||||
|
.whereRef('mediaSource.type', '=', 'programExternalId.sourceType')
|
||||||
|
.select('mediaSource.uuid')
|
||||||
|
.limit(1),
|
||||||
|
})
|
||||||
|
.where('programExternalId.mediaSourceId', 'is', null)
|
||||||
|
.where('programExternalId.sourceType', 'in', ['plex', 'emby', 'jellyfin'])
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.updateTable('programGroupingExternalId')
|
||||||
|
.set({
|
||||||
|
mediaSourceId: (eb) =>
|
||||||
|
eb
|
||||||
|
.selectFrom('mediaSource')
|
||||||
|
.whereRef(
|
||||||
|
'mediaSource.name',
|
||||||
|
'=',
|
||||||
|
'programGroupingExternalId.externalSourceId',
|
||||||
|
)
|
||||||
|
.whereRef(
|
||||||
|
'mediaSource.type',
|
||||||
|
'=',
|
||||||
|
'programGroupingExternalId.sourceType',
|
||||||
|
)
|
||||||
|
.select('mediaSource.uuid')
|
||||||
|
.limit(1),
|
||||||
|
})
|
||||||
|
.where('programGroupingExternalId.mediaSourceId', 'is', null)
|
||||||
|
.where('programGroupingExternalId.sourceType', 'in', [
|
||||||
|
'plex',
|
||||||
|
'emby',
|
||||||
|
'jellyfin',
|
||||||
|
])
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { getDatabase } from '@/db/DBAccess.js';
|
import { getDatabase } from '@/db/DBAccess.js';
|
||||||
import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
|
import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
|
||||||
import { ProgramSourceType } from '@/db/custom_types/ProgramSourceType.js';
|
import { ProgramSourceType } from '@/db/custom_types/ProgramSourceType.js';
|
||||||
import { upsertRawProgramExternalIds } from '@/db/programExternalIdHelpers.js';
|
import { upsertProgramExternalIds } from '@/db/programExternalIdHelpers.js';
|
||||||
import { withProgramExternalIds } from '@/db/programQueryHelpers.js';
|
import { withProgramExternalIds } from '@/db/programQueryHelpers.js';
|
||||||
import { ProgramDao } from '@/db/schema/Program.js';
|
import { ProgramDao } from '@/db/schema/Program.js';
|
||||||
import { NewProgramExternalId } from '@/db/schema/ProgramExternalId.js';
|
import { NewSingleOrMultiExternalId } from '@/db/schema/ProgramExternalId.js';
|
||||||
import { isQueryError } from '@/external/BaseApiClient.js';
|
import { isQueryError } from '@/external/BaseApiClient.js';
|
||||||
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
|
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
|
||||||
import { PlexApiClient } from '@/external/plex/PlexApiClient.js';
|
import { PlexApiClient } from '@/external/plex/PlexApiClient.js';
|
||||||
@@ -118,7 +118,7 @@ export class BackfillProgramExternalIds extends Fixer {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const upsertResult = await attempt(() =>
|
const upsertResult = await attempt(() =>
|
||||||
upsertRawProgramExternalIds(result.result),
|
upsertProgramExternalIds(result.result),
|
||||||
);
|
);
|
||||||
if (isError(upsertResult)) {
|
if (isError(upsertResult)) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -147,6 +147,10 @@ export class BackfillProgramExternalIds extends Fixer {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isUndefined(plex.serverId)) {
|
||||||
|
throw new Error('Plex server is not a saved media source');
|
||||||
|
}
|
||||||
|
|
||||||
const metadataResult = await plex.getItemMetadata(program.externalKey);
|
const metadataResult = await plex.getItemMetadata(program.externalKey);
|
||||||
|
|
||||||
if (isQueryError(metadataResult)) {
|
if (isQueryError(metadataResult)) {
|
||||||
@@ -160,12 +164,14 @@ export class BackfillProgramExternalIds extends Fixer {
|
|||||||
// We're here, might as well use the real thing.
|
// We're here, might as well use the real thing.
|
||||||
const firstPart = first(first(metadata.Media)?.Part);
|
const firstPart = first(first(metadata.Media)?.Part);
|
||||||
|
|
||||||
const entities: NewProgramExternalId[] = [
|
const entities: NewSingleOrMultiExternalId[] = [
|
||||||
{
|
{
|
||||||
|
type: 'multi',
|
||||||
externalFilePath: firstPart?.key ?? program.plexFilePath,
|
externalFilePath: firstPart?.key ?? program.plexFilePath,
|
||||||
directFilePath: firstPart?.file ?? program.filePath,
|
directFilePath: firstPart?.file ?? program.filePath,
|
||||||
externalKey: metadata.ratingKey,
|
externalKey: metadata.ratingKey,
|
||||||
externalSourceId: plex.serverName,
|
externalSourceId: plex.serverName,
|
||||||
|
mediaSourceId: plex.serverId,
|
||||||
programUuid: program.uuid,
|
programUuid: program.uuid,
|
||||||
sourceType: ProgramExternalIdType.PLEX,
|
sourceType: ProgramExternalIdType.PLEX,
|
||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
@@ -183,6 +189,7 @@ export class BackfillProgramExternalIds extends Fixer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
entities.push({
|
entities.push({
|
||||||
|
type: 'single',
|
||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
createdAt: +dayjs(),
|
createdAt: +dayjs(),
|
||||||
updatedAt: +dayjs(),
|
updatedAt: +dayjs(),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type Fixer from '@/tasks/fixers/fixer.js';
|
|||||||
import { MissingSeasonNumbersFixer } from '@/tasks/fixers/missingSeasonNumbersFixer.js';
|
import { MissingSeasonNumbersFixer } from '@/tasks/fixers/missingSeasonNumbersFixer.js';
|
||||||
import { KEYS } from '@/types/inject.js';
|
import { KEYS } from '@/types/inject.js';
|
||||||
import { ContainerModule } from 'inversify';
|
import { ContainerModule } from 'inversify';
|
||||||
|
import { BackfillMediaSourceIdFixer } from './BackfillMediaSourceIdFixer.ts';
|
||||||
|
|
||||||
const FixerModule = new ContainerModule((bind) => {
|
const FixerModule = new ContainerModule((bind) => {
|
||||||
bind<Fixer>(KEYS.Fixer).to(BackfillProgramExternalIds);
|
bind<Fixer>(KEYS.Fixer).to(BackfillProgramExternalIds);
|
||||||
@@ -13,6 +14,7 @@ const FixerModule = new ContainerModule((bind) => {
|
|||||||
bind<Fixer>(KEYS.Fixer).to(AddPlexServerIdsFixer);
|
bind<Fixer>(KEYS.Fixer).to(AddPlexServerIdsFixer);
|
||||||
bind<Fixer>(KEYS.Fixer).to(BackfillProgramGroupings);
|
bind<Fixer>(KEYS.Fixer).to(BackfillProgramGroupings);
|
||||||
bind<Fixer>(KEYS.Fixer).to(MissingSeasonNumbersFixer);
|
bind<Fixer>(KEYS.Fixer).to(MissingSeasonNumbersFixer);
|
||||||
|
bind<Fixer>(KEYS.Fixer).to(BackfillMediaSourceIdFixer);
|
||||||
});
|
});
|
||||||
|
|
||||||
export { FixerModule };
|
export { FixerModule };
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export class BackfillProgramGroupings extends Fixer {
|
|||||||
// This clears out mismatches that might have happened on bugged earlier versions
|
// This clears out mismatches that might have happened on bugged earlier versions
|
||||||
// There was a bug where we were setting the season ID to the show ID.
|
// There was a bug where we were setting the season ID to the show ID.
|
||||||
// This should only affect seasons since the music album stuff had the fix
|
// This should only affect seasons since the music album stuff had the fix
|
||||||
|
console.log('backfill', getDatabase().transaction());
|
||||||
const clearedSeasons = await getDatabase()
|
const clearedSeasons = await getDatabase()
|
||||||
.transaction()
|
.transaction()
|
||||||
.execute((tx) =>
|
.execute((tx) =>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { IProgramDB } from '@/db/interfaces/IProgramDB.js';
|
import type { IProgramDB } from '@/db/interfaces/IProgramDB.js';
|
||||||
import { upsertRawProgramExternalIds } from '@/db/programExternalIdHelpers.js';
|
import { upsertProgramExternalIds } from '@/db/programExternalIdHelpers.js';
|
||||||
import { isQueryError } from '@/external/BaseApiClient.js';
|
import { isQueryError } from '@/external/BaseApiClient.js';
|
||||||
import { type MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
|
import { type MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
|
||||||
import type { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
|
import type { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from '../../db/custom_types/ProgramExternalIdType.ts';
|
} from '../../db/custom_types/ProgramExternalIdType.ts';
|
||||||
import type {
|
import type {
|
||||||
MinimalProgramExternalId,
|
MinimalProgramExternalId,
|
||||||
NewProgramExternalId,
|
NewSingleOrMultiExternalId,
|
||||||
} from '../../db/schema/ProgramExternalId.ts';
|
} from '../../db/schema/ProgramExternalId.ts';
|
||||||
|
|
||||||
export type SaveJellyfinProgramExternalIdsTaskFactory = (
|
export type SaveJellyfinProgramExternalIdsTaskFactory = (
|
||||||
@@ -96,17 +96,18 @@ export class SaveJellyfinProgramExternalIdsTask extends Task {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
type: 'single',
|
||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
createdAt: +dayjs(),
|
createdAt: +dayjs(),
|
||||||
updatedAt: +dayjs(),
|
updatedAt: +dayjs(),
|
||||||
externalKey: id,
|
externalKey: id,
|
||||||
sourceType: type,
|
sourceType: type,
|
||||||
programUuid: program.uuid,
|
programUuid: program.uuid,
|
||||||
} satisfies NewProgramExternalId;
|
} satisfies NewSingleOrMultiExternalId;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return await upsertRawProgramExternalIds(eids);
|
return await upsertProgramExternalIds(eids);
|
||||||
}
|
}
|
||||||
|
|
||||||
get taskName() {
|
get taskName() {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
|
import { ProgramExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
|
||||||
import { upsertRawProgramExternalIds } from '@/db/programExternalIdHelpers.js';
|
import { upsertProgramExternalIds } from '@/db/programExternalIdHelpers.js';
|
||||||
import type { MinimalProgramExternalId } from '@/db/schema/ProgramExternalId.js';
|
import type { MinimalProgramExternalId } from '@/db/schema/ProgramExternalId.js';
|
||||||
import { isQueryError } from '@/external/BaseApiClient.js';
|
import { isQueryError } from '@/external/BaseApiClient.js';
|
||||||
import { type MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
|
import { type MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
|
||||||
@@ -8,8 +8,9 @@ import { Task } from '@/tasks/Task.js';
|
|||||||
import type { Maybe } from '@/types/util.js';
|
import type { Maybe } from '@/types/util.js';
|
||||||
import { mintExternalIdForPlexGuid } from '@/util/externalIds.js';
|
import { mintExternalIdForPlexGuid } from '@/util/externalIds.js';
|
||||||
import { isDefined, isNonEmptyString } from '@/util/index.js';
|
import { isDefined, isNonEmptyString } from '@/util/index.js';
|
||||||
|
import { seq } from '@tunarr/shared/util';
|
||||||
import type { PlexTerminalMedia } from '@tunarr/types/plex';
|
import type { PlexTerminalMedia } from '@tunarr/types/plex';
|
||||||
import { compact, isEmpty, isNil, isUndefined, map } from 'lodash-es';
|
import { isEmpty, isNil, isUndefined } from 'lodash-es';
|
||||||
import type { IProgramDB } from '../../db/interfaces/IProgramDB.ts';
|
import type { IProgramDB } from '../../db/interfaces/IProgramDB.ts';
|
||||||
|
|
||||||
export type SavePlexProgramExternalIdsTaskFactory = (
|
export type SavePlexProgramExternalIdsTaskFactory = (
|
||||||
@@ -78,19 +79,11 @@ export class SavePlexProgramExternalIdsTask extends Task {
|
|||||||
|
|
||||||
const metadata = metadataResult.data as PlexTerminalMedia;
|
const metadata = metadataResult.data as PlexTerminalMedia;
|
||||||
|
|
||||||
const eids = compact(
|
const eids = seq.collect(metadata.Guid, (guid) =>
|
||||||
map(metadata.Guid, (guid) => {
|
mintExternalIdForPlexGuid(guid.id, program.uuid),
|
||||||
const parsed = mintExternalIdForPlexGuid(guid.id, program.uuid);
|
|
||||||
if (parsed) {
|
|
||||||
parsed.externalSourceId = undefined;
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return await upsertRawProgramExternalIds(eids);
|
return await upsertProgramExternalIds(eids);
|
||||||
}
|
}
|
||||||
|
|
||||||
get taskName() {
|
get taskName() {
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { DeepNonNullable, StrictExclude } from 'ts-essentials';
|
import type {
|
||||||
|
DeepNonNullable,
|
||||||
|
MarkRequired,
|
||||||
|
StrictExclude,
|
||||||
|
} from 'ts-essentials';
|
||||||
|
|
||||||
export type Maybe<T> = T | undefined;
|
export type Maybe<T> = T | undefined;
|
||||||
|
|
||||||
@@ -31,3 +35,8 @@ export type MarkNullable<Type, Keys extends keyof Type = keyof Type> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type NonEmptyArray<T> = [T, ...T[]];
|
export type NonEmptyArray<T> = [T, ...T[]];
|
||||||
|
|
||||||
|
export type MarkNotNilable<Type, Keys extends keyof Type> = MarkNonNullable<
|
||||||
|
MarkRequired<Type, Keys>,
|
||||||
|
Keys
|
||||||
|
>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { programExternalIdTypeFromExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
|
import { programExternalIdTypeFromExternalIdType } from '@/db/custom_types/ProgramExternalIdType.js';
|
||||||
import type { NewProgramExternalId } from '@/db/schema/ProgramExternalId.js';
|
import type { NewSingleOrMultiExternalId } from '@/db/schema/ProgramExternalId.js';
|
||||||
import type { Nullable } from '@/types/util.js';
|
import type { Nullable } from '@/types/util.js';
|
||||||
import type { MultiExternalId } from '@tunarr/types';
|
import type { MultiExternalId } from '@tunarr/types';
|
||||||
import { isValidSingleExternalIdType } from '@tunarr/types/schemas';
|
import { isValidSingleExternalIdType } from '@tunarr/types/schemas';
|
||||||
@@ -29,10 +29,11 @@ export const createPlexExternalId = (
|
|||||||
export const mintExternalIdForPlexGuid = (
|
export const mintExternalIdForPlexGuid = (
|
||||||
guid: string,
|
guid: string,
|
||||||
programId: string,
|
programId: string,
|
||||||
): Nullable<NewProgramExternalId> => {
|
): Nullable<NewSingleOrMultiExternalId> => {
|
||||||
const parsed = parsePlexGuid(guid);
|
const parsed = parsePlexGuid(guid);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
return {
|
return {
|
||||||
|
type: 'single',
|
||||||
uuid: v4(),
|
uuid: v4(),
|
||||||
createdAt: +dayjs(),
|
createdAt: +dayjs(),
|
||||||
updatedAt: +dayjs(),
|
updatedAt: +dayjs(),
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export class ApiProgramMinter {
|
|||||||
return {
|
return {
|
||||||
type: 'content',
|
type: 'content',
|
||||||
externalSourceType: 'plex',
|
externalSourceType: 'plex',
|
||||||
|
externalSourceId: server.id,
|
||||||
externalSourceName: server.name,
|
externalSourceName: server.name,
|
||||||
date: plexMovie.originallyAvailableAt,
|
date: plexMovie.originallyAvailableAt,
|
||||||
duration: plexMovie.duration ?? 0,
|
duration: plexMovie.duration ?? 0,
|
||||||
@@ -95,7 +96,6 @@ export class ApiProgramMinter {
|
|||||||
subtype: 'movie',
|
subtype: 'movie',
|
||||||
persisted: false,
|
persisted: false,
|
||||||
externalIds: this.mintExternalIdsForPlex(server.name, plexMovie),
|
externalIds: this.mintExternalIdsForPlex(server.name, plexMovie),
|
||||||
externalSourceId: server.name,
|
|
||||||
uniqueId: id,
|
uniqueId: id,
|
||||||
id,
|
id,
|
||||||
};
|
};
|
||||||
@@ -113,7 +113,7 @@ export class ApiProgramMinter {
|
|||||||
index: plexEpisode.index,
|
index: plexEpisode.index,
|
||||||
externalKey: plexEpisode.ratingKey,
|
externalKey: plexEpisode.ratingKey,
|
||||||
externalSourceName: server.name,
|
externalSourceName: server.name,
|
||||||
externalSourceId: server.name,
|
externalSourceId: server.id,
|
||||||
externalSourceType: ExternalSourceTypeSchema.enum.plex,
|
externalSourceType: ExternalSourceTypeSchema.enum.plex,
|
||||||
parent: {
|
parent: {
|
||||||
title: plexEpisode.parentTitle,
|
title: plexEpisode.parentTitle,
|
||||||
@@ -242,7 +242,7 @@ export class ApiProgramMinter {
|
|||||||
persisted: false,
|
persisted: false,
|
||||||
uniqueId: id,
|
uniqueId: id,
|
||||||
id,
|
id,
|
||||||
externalSourceId: server.name,
|
externalSourceId: server.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +257,7 @@ export class ApiProgramMinter {
|
|||||||
externalSourceType: ExternalSourceTypeSchema.enum.jellyfin,
|
externalSourceType: ExternalSourceTypeSchema.enum.jellyfin,
|
||||||
date: nullToUndefined(item.PremiereDate),
|
date: nullToUndefined(item.PremiereDate),
|
||||||
duration: (item.RunTimeTicks ?? 0) / 10_000,
|
duration: (item.RunTimeTicks ?? 0) / 10_000,
|
||||||
externalSourceId: server.name,
|
externalSourceId: server.id,
|
||||||
externalKey: item.Id,
|
externalKey: item.Id,
|
||||||
rating: nullToUndefined(item.OfficialRating),
|
rating: nullToUndefined(item.OfficialRating),
|
||||||
summary: nullToUndefined(item.Overview),
|
summary: nullToUndefined(item.Overview),
|
||||||
@@ -322,7 +322,7 @@ export class ApiProgramMinter {
|
|||||||
externalSourceType: ExternalSourceTypeSchema.enum.emby,
|
externalSourceType: ExternalSourceTypeSchema.enum.emby,
|
||||||
date: nullToUndefined(item.PremiereDate),
|
date: nullToUndefined(item.PremiereDate),
|
||||||
duration: (item.RunTimeTicks ?? 0) / 10_000,
|
duration: (item.RunTimeTicks ?? 0) / 10_000,
|
||||||
externalSourceId: server.name,
|
externalSourceId: server.id,
|
||||||
externalKey: item.Id,
|
externalKey: item.Id,
|
||||||
rating: nullToUndefined(item.OfficialRating),
|
rating: nullToUndefined(item.OfficialRating),
|
||||||
summary: nullToUndefined(item.Overview),
|
summary: nullToUndefined(item.Overview),
|
||||||
|
|||||||
Reference in New Issue
Block a user