Dynamic channels - phase 1 (#231)

* Checkpoint - dynamic channels

* Yet another checkpoint

* Bump versions - makes a ton of stuff work magically; needed a patch for ts-essentials because of https://github.com/ts-essentials/ts-essentials/issues/381

* Checkpointing

* Checkpoint
This commit is contained in:
Christian Benincasa
2024-04-03 15:08:31 -04:00
committed by GitHub
parent bf3f1a0781
commit 67f2d144e2
59 changed files with 1371 additions and 711 deletions

View File

@@ -17,7 +17,12 @@
"esbuild": "^0.19.5",
"eslint": "^8.45.0",
"turbo": "^1.13.0",
"typescript": "^5.2.2"
"typescript": "^5.4.3"
},
"packageManager": "pnpm@8.15.4+sha256.cea6d0bdf2de3a0549582da3983c70c92ffc577ff4410cbf190817ddc35137c2"
"packageManager": "pnpm@8.15.4+sha256.cea6d0bdf2de3a0549582da3983c70c92ffc577ff4410cbf190817ddc35137c2",
"pnpm": {
"patchedDependencies": {
"ts-essentials@9.4.1": "patches/ts-essentials@9.4.1.patch"
}
}
}

View File

@@ -0,0 +1,97 @@
diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index 0dc097738ae073897eb86b1fbb5324d34b3d9813..0000000000000000000000000000000000000000
diff --git a/dist/deep-nullable/index.d.ts b/dist/deep-nullable/index.d.ts
index ba3852eaa05020cb79af42b530916625ab2efaa9..3f6b0e5cf7ef22e76cc7712b72271967ab7cfe1a 100644
--- a/dist/deep-nullable/index.d.ts
+++ b/dist/deep-nullable/index.d.ts
@@ -1,7 +1,31 @@
import { Builtin } from "../built-in";
import { IsTuple } from "../is-tuple";
-export declare type DeepNullable<Type> = Type extends Builtin ? Type | null : Type extends Map<infer Keys, infer Values> ? Map<DeepNullable<Keys>, DeepNullable<Values>> : Type extends ReadonlyMap<infer Keys, infer Values> ? ReadonlyMap<DeepNullable<Keys>, DeepNullable<Values>> : Type extends WeakMap<infer Keys, infer Values> ? WeakMap<DeepNullable<Keys>, DeepNullable<Values>> : Type extends Set<infer Values> ? Set<DeepNullable<Values>> : Type extends ReadonlySet<infer Values> ? ReadonlySet<DeepNullable<Values>> : Type extends WeakSet<infer Values> ? WeakSet<DeepNullable<Values>> : Type extends ReadonlyArray<infer Values> ? Type extends IsTuple<Type> ? {
- [Key in keyof Type]: DeepNullable<Type[Key]> | null;
-} : Type extends Array<Values> ? Array<DeepNullable<Values>> : ReadonlyArray<DeepNullable<Values>> : Type extends Promise<infer Value> ? Promise<DeepNullable<Value>> : Type extends {} ? {
- [Key in keyof Type]: DeepNullable<Type[Key]>;
-} : Type | null;
+export declare type DeepNullable<Type> = Type extends Builtin
+ ? Type | null
+ : Type extends Map<infer Keys, infer Values>
+ ? Map<DeepNullable<Keys>, DeepNullable<Values>>
+ : Type extends ReadonlyMap<infer Keys, infer Values>
+ ? ReadonlyMap<DeepNullable<Keys>, DeepNullable<Values>>
+ : Type extends WeakMap<infer Keys, infer Values>
+ ? WeakMap<Keys, DeepNullable<Values>>
+ : Type extends Set<infer Values>
+ ? Set<DeepNullable<Values>>
+ : Type extends ReadonlySet<infer Values>
+ ? ReadonlySet<DeepNullable<Values>>
+ : Type extends WeakSet<infer Values>
+ ? WeakSet<Values>
+ : Type extends ReadonlyArray<infer Values>
+ ? Type extends IsTuple<Type>
+ ? {
+ [Key in keyof Type]: DeepNullable<Type[Key]> | null;
+ }
+ : Type extends Array<Values>
+ ? Array<DeepNullable<Values>>
+ : ReadonlyArray<DeepNullable<Values>>
+ : Type extends Promise<infer Value>
+ ? Promise<DeepNullable<Value>>
+ : Type extends {}
+ ? {
+ [Key in keyof Type]: DeepNullable<Type[Key]>;
+ }
+ : Type | null;
diff --git a/dist/deep-undefinable/index.d.ts b/dist/deep-undefinable/index.d.ts
index 9e157cd4b59fbb33c36c82bfc8967ba62e6e9888..04e3026545352b984d686723edf90779b79c7791 100644
--- a/dist/deep-undefinable/index.d.ts
+++ b/dist/deep-undefinable/index.d.ts
@@ -1,7 +1,31 @@
import { Builtin } from "../built-in";
import { IsTuple } from "../is-tuple";
-export declare type DeepUndefinable<Type> = Type extends Builtin ? Type | undefined : Type extends Map<infer Keys, infer Values> ? Map<DeepUndefinable<Keys>, DeepUndefinable<Values>> : Type extends ReadonlyMap<infer Keys, infer Values> ? ReadonlyMap<DeepUndefinable<Keys>, DeepUndefinable<Values>> : Type extends WeakMap<infer Keys, infer Values> ? WeakMap<DeepUndefinable<Keys>, DeepUndefinable<Values>> : Type extends Set<infer Values> ? Set<DeepUndefinable<Values>> : Type extends ReadonlySet<infer Values> ? ReadonlySet<DeepUndefinable<Values>> : Type extends WeakSet<infer Values> ? WeakSet<DeepUndefinable<Values>> : Type extends ReadonlyArray<infer Values> ? Type extends IsTuple<Type> ? {
- [Key in keyof Type]: DeepUndefinable<Type[Key]> | undefined;
-} : Type extends Array<Values> ? Array<DeepUndefinable<Values>> : ReadonlyArray<DeepUndefinable<Values>> : Type extends Promise<infer Value> ? Promise<DeepUndefinable<Value>> : Type extends {} ? {
- [Key in keyof Type]: DeepUndefinable<Type[Key]>;
-} : Type | undefined;
+export declare type DeepUndefinable<Type> = Type extends Builtin
+ ? Type | undefined
+ : Type extends Map<infer Keys, infer Values>
+ ? Map<DeepUndefinable<Keys>, DeepUndefinable<Values>>
+ : Type extends ReadonlyMap<infer Keys, infer Values>
+ ? ReadonlyMap<DeepUndefinable<Keys>, DeepUndefinable<Values>>
+ : Type extends WeakMap<infer Keys, infer Values>
+ ? WeakMap<Keys, DeepUndefinable<Values>>
+ : Type extends Set<infer Values>
+ ? Set<DeepUndefinable<Values>>
+ : Type extends ReadonlySet<infer Values>
+ ? ReadonlySet<DeepUndefinable<Values>>
+ : Type extends WeakSet<infer Values>
+ ? WeakSet<Values>
+ : Type extends ReadonlyArray<infer Values>
+ ? Type extends IsTuple<Type>
+ ? {
+ [Key in keyof Type]: DeepUndefinable<Type[Key]> | undefined;
+ }
+ : Type extends Array<Values>
+ ? Array<DeepUndefinable<Values>>
+ : ReadonlyArray<DeepUndefinable<Values>>
+ : Type extends Promise<infer Value>
+ ? Promise<DeepUndefinable<Value>>
+ : Type extends {}
+ ? {
+ [Key in keyof Type]: DeepUndefinable<Type[Key]>;
+ }
+ : Type | undefined;
diff --git a/package.json b/package.json
index da3fa96d66333522b05e46b178ff3a2e98808d0a..263d52cd58edf499b6b91e3b17ce83607d862379 100644
--- a/package.json
+++ b/package.json
@@ -45,6 +45,6 @@
"conditional-type-checks": "^1.0.4",
"prettier": "^2.0.0",
"rimraf": "^3.0.2",
- "typescript": "^4.1.0"
+ "typescript": "^5.4.3"
}
}

412
pnpm-lock.yaml generated
View File

@@ -4,16 +4,21 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
patchedDependencies:
ts-essentials@9.4.1:
hash: 254pvnpgpcwoswa4ncfq4tq6su
path: patches/ts-essentials@9.4.1.patch
importers:
.:
devDependencies:
'@typescript-eslint/eslint-plugin':
specifier: ^6.0.0
version: 6.0.0(@typescript-eslint/parser@6.0.0)(eslint@8.45.0)(typescript@5.2.2)
version: 6.0.0(@typescript-eslint/parser@6.0.0)(eslint@8.45.0)(typescript@5.4.3)
'@typescript-eslint/parser':
specifier: ^6.0.0
version: 6.0.0(eslint@8.45.0)(typescript@5.2.2)
version: 6.0.0(eslint@8.45.0)(typescript@5.4.3)
esbuild:
specifier: ^0.19.5
version: 0.19.5
@@ -24,8 +29,8 @@ importers:
specifier: ^1.13.0
version: 1.13.0
typescript:
specifier: ^5.2.2
version: 5.2.2
specifier: ^5.4.3
version: 5.4.3
server:
dependencies:
@@ -48,14 +53,14 @@ importers:
specifier: ^2.0.1
version: 2.0.1
'@mikro-orm/better-sqlite':
specifier: ^6.0.4
version: 6.0.4(@mikro-orm/core@6.0.4)
specifier: ^6.1.12
version: 6.1.12(@mikro-orm/core@6.1.12)
'@mikro-orm/core':
specifier: ^6.0.4
version: 6.0.4
specifier: ^6.1.12
version: 6.1.12
'@mikro-orm/migrations':
specifier: 6.0.4
version: 6.0.4(@mikro-orm/core@6.0.4)(better-sqlite3@9.1.1)
specifier: 6.1.12
version: 6.1.12(@mikro-orm/core@6.1.12)(better-sqlite3@9.1.1)
'@tunarr/shared':
specifier: workspace:*
version: link:../shared
@@ -65,6 +70,9 @@ importers:
JSONStream:
specifier: 1.0.5
version: 1.0.5
async-mutex:
specifier: ^0.5.0
version: 0.5.0
async-retry:
specifier: ^1.3.3
version: 1.3.3
@@ -135,8 +143,8 @@ importers:
specifier: 2.1.0
version: 2.1.0
reflect-metadata:
specifier: ^0.1.13
version: 0.1.13
specifier: ^0.2.2
version: 0.2.2
retry:
specifier: ^0.13.1
version: 0.13.1
@@ -163,11 +171,11 @@ importers:
version: 3.22.4
devDependencies:
'@mikro-orm/cli':
specifier: ^6.0.4
version: 6.0.4(better-sqlite3@9.1.1)
specifier: ^6.1.12
version: 6.1.12(better-sqlite3@9.1.1)
'@mikro-orm/reflection':
specifier: ^6.0.4
version: 6.0.4(@mikro-orm/core@6.0.4)
specifier: ^6.1.12
version: 6.1.12(@mikro-orm/core@6.1.12)
'@types/async-retry':
specifier: ^1.4.8
version: 1.4.8
@@ -251,19 +259,19 @@ importers:
version: 3.0.3
ts-essentials:
specifier: ^9.4.1
version: 9.4.1(typescript@5.3.3)
version: 9.4.1(patch_hash=254pvnpgpcwoswa4ncfq4tq6su)(typescript@5.4.3)
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@20.8.9)(typescript@5.3.3)
version: 10.9.2(@types/node@20.8.9)(typescript@5.4.3)
tsconfig-paths:
specifier: ^4.2.0
version: 4.2.0
tsify:
specifier: ^5.0.4
version: 5.0.4(browserify@17.0.0)(typescript@5.3.3)
version: 5.0.4(browserify@17.0.0)(typescript@5.4.3)
tsup:
specifier: ^8.0.2
version: 8.0.2(ts-node@10.9.2)(typescript@5.3.3)
version: 8.0.2(ts-node@10.9.2)(typescript@5.4.3)
tsx:
specifier: ^4.7.1
version: 4.7.1
@@ -271,8 +279,8 @@ importers:
specifier: ^2.1.0
version: 2.1.0
typescript:
specifier: ^5.3.3
version: 5.3.3
specifier: ^5.4.3
version: 5.4.3
vitest:
specifier: ^1.2.0
version: 1.2.0(@types/node@20.8.9)
@@ -306,13 +314,13 @@ importers:
version: 5.0.5
ts-essentials:
specifier: ^9.4.1
version: 9.4.1(typescript@5.2.2)
version: 9.4.1(patch_hash=254pvnpgpcwoswa4ncfq4tq6su)(typescript@5.4.3)
tsup:
specifier: ^8.0.2
version: 8.0.2(@microsoft/api-extractor@7.43.0)(typescript@5.2.2)
version: 8.0.2(@microsoft/api-extractor@7.43.0)(typescript@5.4.3)
typescript:
specifier: 5.2.2
version: 5.2.2
specifier: ^5.4.3
version: 5.4.3
types:
dependencies:
@@ -325,10 +333,10 @@ importers:
version: 7.43.0
'@typescript-eslint/eslint-plugin':
specifier: 6.0.0
version: 6.0.0(@typescript-eslint/parser@6.0.0)(eslint@8.45.0)(typescript@5.2.2)
version: 6.0.0(@typescript-eslint/parser@6.0.0)(eslint@8.45.0)(typescript@5.4.3)
'@typescript-eslint/parser':
specifier: 6.0.0
version: 6.0.0(eslint@8.45.0)(typescript@5.2.2)
version: 6.0.0(eslint@8.45.0)(typescript@5.4.3)
eslint:
specifier: 8.45.0
version: 8.45.0
@@ -337,10 +345,10 @@ importers:
version: 5.0.5
tsup:
specifier: ^8.0.2
version: 8.0.2(@microsoft/api-extractor@7.43.0)(typescript@5.2.2)
version: 8.0.2(@microsoft/api-extractor@7.43.0)(typescript@5.4.3)
typescript:
specifier: 5.2.2
version: 5.2.2
specifier: ^5.4.3
version: 5.4.3
web:
dependencies:
@@ -461,10 +469,10 @@ importers:
version: 9.0.6
'@typescript-eslint/eslint-plugin':
specifier: ^6.0.0
version: 6.0.0(@typescript-eslint/parser@6.0.0)(eslint@8.45.0)(typescript@5.3.3)
version: 6.0.0(@typescript-eslint/parser@6.0.0)(eslint@8.45.0)(typescript@5.4.3)
'@typescript-eslint/parser':
specifier: ^6.0.0
version: 6.0.0(eslint@8.45.0)(typescript@5.3.3)
version: 6.0.0(eslint@8.45.0)(typescript@5.4.3)
'@vitejs/plugin-react-swc':
specifier: ^3.3.2
version: 3.3.2(vite@4.4.5)
@@ -485,10 +493,10 @@ importers:
version: 1.14.0(react@18.2.0)
ts-essentials:
specifier: ^9.4.1
version: 9.4.1(typescript@5.3.3)
version: 9.4.1(patch_hash=254pvnpgpcwoswa4ncfq4tq6su)(typescript@5.4.3)
typescript:
specifier: ^5.2.2
version: 5.3.3
specifier: ^5.4.3
version: 5.4.3
vite:
specifier: ^4.4.5
version: 4.4.5
@@ -1893,14 +1901,14 @@ packages:
resolution: {integrity: sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug==}
dev: true
/@mikro-orm/better-sqlite@6.0.4(@mikro-orm/core@6.0.4):
resolution: {integrity: sha512-SZjIEcdv2D3dhyBTFAwZ0nITZKf1GxfF3pIBsyu1gSLFE5C9pRILDo2Fo8X5i7orP2vZjGycGFDIDrOEtdBzDg==}
/@mikro-orm/better-sqlite@6.1.12(@mikro-orm/core@6.1.12):
resolution: {integrity: sha512-7uupNjdoi8FgCfZ8sacC1Pau0xkOO+oRcOtQpa1Ddyh/8em/CMuPbjCzY04HrfAU0JPCL9KGDdL+4hleB3GH5Q==}
engines: {node: '>= 18.12.0'}
peerDependencies:
'@mikro-orm/core': ^6.0.0
dependencies:
'@mikro-orm/core': 6.0.4
'@mikro-orm/knex': 6.0.4(@mikro-orm/core@6.0.4)(better-sqlite3@8.7.0)
'@mikro-orm/core': 6.1.12
'@mikro-orm/knex': 6.1.12(@mikro-orm/core@6.1.12)(better-sqlite3@8.7.0)
better-sqlite3: 8.7.0
fs-extra: 11.2.0
sqlstring-sqlite: 0.1.1
@@ -1914,14 +1922,14 @@ packages:
- tedious
dev: false
/@mikro-orm/cli@6.0.4(better-sqlite3@9.1.1):
resolution: {integrity: sha512-uqDM66Hg+a/f/InUzNwAh836wJn0ZLMqbc/8M9dAmgx0l5xY271zm5TRe+hogvYUyy/6mzQEx8Di5aN8+FJHhw==}
/@mikro-orm/cli@6.1.12(better-sqlite3@9.1.1):
resolution: {integrity: sha512-9Jf33ZLfEnX1cRwgBdImgpBDesILjm9IPeZxOGa9KI1hQHZ+MrWN9gRl3PFHwQvElHXEt3aU2IBiMO3h6iVXaQ==}
engines: {node: '>= 18.12.0'}
hasBin: true
dependencies:
'@jercle/yargonaut': 1.1.5
'@mikro-orm/core': 6.0.4
'@mikro-orm/knex': 6.0.4(@mikro-orm/core@6.0.4)(better-sqlite3@9.1.1)
'@mikro-orm/core': 6.1.12
'@mikro-orm/knex': 6.1.12(@mikro-orm/core@6.1.12)(better-sqlite3@9.1.1)
fs-extra: 11.2.0
tsconfig-paths: 4.2.0
yargs: 17.7.2
@@ -1936,25 +1944,25 @@ packages:
- tedious
dev: true
/@mikro-orm/core@6.0.4:
resolution: {integrity: sha512-Kjrs5TPHEhh5sOjOa7kAy56aury03Xw6pPU/gE81gipGq4fNNUJAjYn3aC/9JVxVsZ/oB2eiqKsBSs4Gyvu9iw==}
/@mikro-orm/core@6.1.12:
resolution: {integrity: sha512-51/1iBdXoF+bODJMpW8cUsr1TsieIJobiAX4g9A6CgBU6v95vwzyEQRo9v73i+YuPfrjH4YrrSbRaAr1tKe38A==}
engines: {node: '>= 18.12.0'}
dependencies:
dataloader: 2.2.2
dotenv: 16.3.1
dotenv: 16.4.5
esprima: 4.0.1
fs-extra: 11.2.0
globby: 11.1.0
mikro-orm: 6.0.4
mikro-orm: 6.1.12
reflect-metadata: 0.2.1
/@mikro-orm/knex@6.0.4(@mikro-orm/core@6.0.4)(better-sqlite3@8.7.0):
resolution: {integrity: sha512-OoNk6M4S7ZK+h1uOe6dk6dbdCxxOXnVcwRr9a+zspn/yz/zOxt3dqQlburEXevhbNDl4U/h99BWYQ9Jw1zPrjQ==}
/@mikro-orm/knex@6.1.12(@mikro-orm/core@6.1.12)(better-sqlite3@8.7.0):
resolution: {integrity: sha512-bGRDTM13ASYcmte8BglikDwfoYmCo8YUW5LY4Mn5GUCyzjLV7XP23SrTaerLIxXlNiTnJhTO+On3cOyndFwHpw==}
engines: {node: '>= 18.12.0'}
peerDependencies:
'@mikro-orm/core': ^6.0.0
dependencies:
'@mikro-orm/core': 6.0.4
'@mikro-orm/core': 6.1.12
fs-extra: 11.2.0
knex: 3.1.0(better-sqlite3@8.7.0)
sqlstring: 2.3.3
@@ -1969,13 +1977,13 @@ packages:
- tedious
dev: false
/@mikro-orm/knex@6.0.4(@mikro-orm/core@6.0.4)(better-sqlite3@9.1.1):
resolution: {integrity: sha512-OoNk6M4S7ZK+h1uOe6dk6dbdCxxOXnVcwRr9a+zspn/yz/zOxt3dqQlburEXevhbNDl4U/h99BWYQ9Jw1zPrjQ==}
/@mikro-orm/knex@6.1.12(@mikro-orm/core@6.1.12)(better-sqlite3@9.1.1):
resolution: {integrity: sha512-bGRDTM13ASYcmte8BglikDwfoYmCo8YUW5LY4Mn5GUCyzjLV7XP23SrTaerLIxXlNiTnJhTO+On3cOyndFwHpw==}
engines: {node: '>= 18.12.0'}
peerDependencies:
'@mikro-orm/core': ^6.0.0
dependencies:
'@mikro-orm/core': 6.0.4
'@mikro-orm/core': 6.1.12
fs-extra: 11.2.0
knex: 3.1.0(better-sqlite3@9.1.1)
sqlstring: 2.3.3
@@ -1989,16 +1997,16 @@ packages:
- supports-color
- tedious
/@mikro-orm/migrations@6.0.4(@mikro-orm/core@6.0.4)(better-sqlite3@9.1.1):
resolution: {integrity: sha512-HBb3COjeTbeS6JuAxEVTz06DXFaGojjWmBhxWw0GFVsZPkIhTa4oSuDK48qY21+LOSKWVA/9A9Wdhh8Xk4QbLA==}
/@mikro-orm/migrations@6.1.12(@mikro-orm/core@6.1.12)(better-sqlite3@9.1.1):
resolution: {integrity: sha512-CpKXU/3NEIWnuSYR9t+8IRcGpFj4pI8OTmAgnVp2BihgvaIh1JdnKUkQnmLt9i/0VupAIffTIa9J5rYofsgddQ==}
engines: {node: '>= 18.12.0'}
peerDependencies:
'@mikro-orm/core': ^6.0.0
dependencies:
'@mikro-orm/core': 6.0.4
'@mikro-orm/knex': 6.0.4(@mikro-orm/core@6.0.4)(better-sqlite3@9.1.1)
'@mikro-orm/core': 6.1.12
'@mikro-orm/knex': 6.1.12(@mikro-orm/core@6.1.12)(better-sqlite3@9.1.1)
fs-extra: 11.2.0
umzug: 3.5.0
umzug: 3.7.0
transitivePeerDependencies:
- better-sqlite3
- mysql
@@ -2010,15 +2018,15 @@ packages:
- tedious
dev: false
/@mikro-orm/reflection@6.0.4(@mikro-orm/core@6.0.4):
resolution: {integrity: sha512-eDAVoSUrAQVsTVGB7IUmD3fHZrsk3fJbdk/9mAvCDCJrzG+G/Ng9TfHOdUCgH35Wzj1u6zxuftmYV7dnogKvaA==}
/@mikro-orm/reflection@6.1.12(@mikro-orm/core@6.1.12):
resolution: {integrity: sha512-mm9rOeSqE4lqn/vNAG2G7Yws8DjH+YYoTE81GGkO8BIUCjT+F+cTKzW871Qj0JfGSuW4spsdDW10gKmXywrveQ==}
engines: {node: '>= 18.12.0'}
peerDependencies:
'@mikro-orm/core': ^6.0.0
dependencies:
'@mikro-orm/core': 6.0.4
'@mikro-orm/core': 6.1.12
globby: 11.1.0
ts-morph: 21.0.1
ts-morph: 22.0.0
dev: true
/@mui/base@5.0.0-beta.23(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0):
@@ -2718,8 +2726,8 @@ packages:
react: 18.2.0
dev: false
/@ts-morph/common@0.22.0:
resolution: {integrity: sha512-HqNBuV/oIlMKdkLshXd1zKBqNQCsuPEsgQOkfFQ/eUKjRlwndXW1AjN9LVkBEIukm00gGXSRmfkl0Wv5VXLnlw==}
/@ts-morph/common@0.23.0:
resolution: {integrity: sha512-m7Lllj9n/S6sOkCkRftpM7L24uvmfXQFedlW/4hENcuJH1HHm9u5EgxZb9uVjQSCGrbBWBkOGgcTxNg36r6ywA==}
dependencies:
fast-glob: 3.3.2
minimatch: 9.0.3
@@ -3032,7 +3040,7 @@ packages:
'@types/yargs-parser': 21.0.3
dev: true
/@typescript-eslint/eslint-plugin@6.0.0(@typescript-eslint/parser@6.0.0)(eslint@8.45.0)(typescript@5.2.2):
/@typescript-eslint/eslint-plugin@6.0.0(@typescript-eslint/parser@6.0.0)(eslint@8.45.0)(typescript@5.4.3):
resolution: {integrity: sha512-xuv6ghKGoiq856Bww/yVYnXGsKa588kY3M0XK7uUW/3fJNNULKRfZfSBkMTSpqGG/8ZCXCadfh8G/z/B4aqS/A==}
engines: {node: ^16.0.0 || >=18.0.0}
peerDependencies:
@@ -3044,10 +3052,10 @@ packages:
optional: true
dependencies:
'@eslint-community/regexpp': 4.10.0
'@typescript-eslint/parser': 6.0.0(eslint@8.45.0)(typescript@5.2.2)
'@typescript-eslint/parser': 6.0.0(eslint@8.45.0)(typescript@5.4.3)
'@typescript-eslint/scope-manager': 6.0.0
'@typescript-eslint/type-utils': 6.0.0(eslint@8.45.0)(typescript@5.2.2)
'@typescript-eslint/utils': 6.0.0(eslint@8.45.0)(typescript@5.2.2)
'@typescript-eslint/type-utils': 6.0.0(eslint@8.45.0)(typescript@5.4.3)
'@typescript-eslint/utils': 6.0.0(eslint@8.45.0)(typescript@5.4.3)
'@typescript-eslint/visitor-keys': 6.0.0
debug: 4.3.4(supports-color@5.5.0)
eslint: 8.45.0
@@ -3057,44 +3065,13 @@ packages:
natural-compare: 1.4.0
natural-compare-lite: 1.4.0
semver: 7.5.4
ts-api-utils: 1.0.3(typescript@5.2.2)
typescript: 5.2.2
ts-api-utils: 1.0.3(typescript@5.4.3)
typescript: 5.4.3
transitivePeerDependencies:
- supports-color
dev: true
/@typescript-eslint/eslint-plugin@6.0.0(@typescript-eslint/parser@6.0.0)(eslint@8.45.0)(typescript@5.3.3):
resolution: {integrity: sha512-xuv6ghKGoiq856Bww/yVYnXGsKa588kY3M0XK7uUW/3fJNNULKRfZfSBkMTSpqGG/8ZCXCadfh8G/z/B4aqS/A==}
engines: {node: ^16.0.0 || >=18.0.0}
peerDependencies:
'@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha
eslint: ^7.0.0 || ^8.0.0
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
'@eslint-community/regexpp': 4.10.0
'@typescript-eslint/parser': 6.0.0(eslint@8.45.0)(typescript@5.3.3)
'@typescript-eslint/scope-manager': 6.0.0
'@typescript-eslint/type-utils': 6.0.0(eslint@8.45.0)(typescript@5.3.3)
'@typescript-eslint/utils': 6.0.0(eslint@8.45.0)(typescript@5.3.3)
'@typescript-eslint/visitor-keys': 6.0.0
debug: 4.3.4(supports-color@5.5.0)
eslint: 8.45.0
grapheme-splitter: 1.0.4
graphemer: 1.4.0
ignore: 5.2.4
natural-compare: 1.4.0
natural-compare-lite: 1.4.0
semver: 7.5.4
ts-api-utils: 1.0.3(typescript@5.3.3)
typescript: 5.3.3
transitivePeerDependencies:
- supports-color
dev: true
/@typescript-eslint/parser@6.0.0(eslint@8.45.0)(typescript@5.2.2):
/@typescript-eslint/parser@6.0.0(eslint@8.45.0)(typescript@5.4.3):
resolution: {integrity: sha512-TNaufYSPrr1U8n+3xN+Yp9g31vQDJqhXzzPSHfQDLcaO4tU+mCfODPxCwf4H530zo7aUBE3QIdxCXamEnG04Tg==}
engines: {node: ^16.0.0 || >=18.0.0}
peerDependencies:
@@ -3106,32 +3083,11 @@ packages:
dependencies:
'@typescript-eslint/scope-manager': 6.0.0
'@typescript-eslint/types': 6.0.0
'@typescript-eslint/typescript-estree': 6.0.0(typescript@5.2.2)
'@typescript-eslint/typescript-estree': 6.0.0(typescript@5.4.3)
'@typescript-eslint/visitor-keys': 6.0.0
debug: 4.3.4(supports-color@5.5.0)
eslint: 8.45.0
typescript: 5.2.2
transitivePeerDependencies:
- supports-color
dev: true
/@typescript-eslint/parser@6.0.0(eslint@8.45.0)(typescript@5.3.3):
resolution: {integrity: sha512-TNaufYSPrr1U8n+3xN+Yp9g31vQDJqhXzzPSHfQDLcaO4tU+mCfODPxCwf4H530zo7aUBE3QIdxCXamEnG04Tg==}
engines: {node: ^16.0.0 || >=18.0.0}
peerDependencies:
eslint: ^7.0.0 || ^8.0.0
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
'@typescript-eslint/scope-manager': 6.0.0
'@typescript-eslint/types': 6.0.0
'@typescript-eslint/typescript-estree': 6.0.0(typescript@5.3.3)
'@typescript-eslint/visitor-keys': 6.0.0
debug: 4.3.4(supports-color@5.5.0)
eslint: 8.45.0
typescript: 5.3.3
typescript: 5.4.3
transitivePeerDependencies:
- supports-color
dev: true
@@ -3144,7 +3100,7 @@ packages:
'@typescript-eslint/visitor-keys': 6.0.0
dev: true
/@typescript-eslint/type-utils@6.0.0(eslint@8.45.0)(typescript@5.2.2):
/@typescript-eslint/type-utils@6.0.0(eslint@8.45.0)(typescript@5.4.3):
resolution: {integrity: sha512-ah6LJvLgkoZ/pyJ9GAdFkzeuMZ8goV6BH7eC9FPmojrnX9yNCIsfjB+zYcnex28YO3RFvBkV6rMV6WpIqkPvoQ==}
engines: {node: ^16.0.0 || >=18.0.0}
peerDependencies:
@@ -3154,32 +3110,12 @@ packages:
typescript:
optional: true
dependencies:
'@typescript-eslint/typescript-estree': 6.0.0(typescript@5.2.2)
'@typescript-eslint/utils': 6.0.0(eslint@8.45.0)(typescript@5.2.2)
'@typescript-eslint/typescript-estree': 6.0.0(typescript@5.4.3)
'@typescript-eslint/utils': 6.0.0(eslint@8.45.0)(typescript@5.4.3)
debug: 4.3.4(supports-color@5.5.0)
eslint: 8.45.0
ts-api-utils: 1.0.3(typescript@5.2.2)
typescript: 5.2.2
transitivePeerDependencies:
- supports-color
dev: true
/@typescript-eslint/type-utils@6.0.0(eslint@8.45.0)(typescript@5.3.3):
resolution: {integrity: sha512-ah6LJvLgkoZ/pyJ9GAdFkzeuMZ8goV6BH7eC9FPmojrnX9yNCIsfjB+zYcnex28YO3RFvBkV6rMV6WpIqkPvoQ==}
engines: {node: ^16.0.0 || >=18.0.0}
peerDependencies:
eslint: ^7.0.0 || ^8.0.0
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
'@typescript-eslint/typescript-estree': 6.0.0(typescript@5.3.3)
'@typescript-eslint/utils': 6.0.0(eslint@8.45.0)(typescript@5.3.3)
debug: 4.3.4(supports-color@5.5.0)
eslint: 8.45.0
ts-api-utils: 1.0.3(typescript@5.3.3)
typescript: 5.3.3
ts-api-utils: 1.0.3(typescript@5.4.3)
typescript: 5.4.3
transitivePeerDependencies:
- supports-color
dev: true
@@ -3189,7 +3125,7 @@ packages:
engines: {node: ^16.0.0 || >=18.0.0}
dev: true
/@typescript-eslint/typescript-estree@6.0.0(typescript@5.2.2):
/@typescript-eslint/typescript-estree@6.0.0(typescript@5.4.3):
resolution: {integrity: sha512-2zq4O7P6YCQADfmJ5OTDQTP3ktajnXIRrYAtHM9ofto/CJZV3QfJ89GEaM2BNGeSr1KgmBuLhEkz5FBkS2RQhQ==}
engines: {node: ^16.0.0 || >=18.0.0}
peerDependencies:
@@ -3204,34 +3140,13 @@ packages:
globby: 11.1.0
is-glob: 4.0.3
semver: 7.5.4
ts-api-utils: 1.0.3(typescript@5.2.2)
typescript: 5.2.2
ts-api-utils: 1.0.3(typescript@5.4.3)
typescript: 5.4.3
transitivePeerDependencies:
- supports-color
dev: true
/@typescript-eslint/typescript-estree@6.0.0(typescript@5.3.3):
resolution: {integrity: sha512-2zq4O7P6YCQADfmJ5OTDQTP3ktajnXIRrYAtHM9ofto/CJZV3QfJ89GEaM2BNGeSr1KgmBuLhEkz5FBkS2RQhQ==}
engines: {node: ^16.0.0 || >=18.0.0}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
'@typescript-eslint/types': 6.0.0
'@typescript-eslint/visitor-keys': 6.0.0
debug: 4.3.4(supports-color@5.5.0)
globby: 11.1.0
is-glob: 4.0.3
semver: 7.5.4
ts-api-utils: 1.0.3(typescript@5.3.3)
typescript: 5.3.3
transitivePeerDependencies:
- supports-color
dev: true
/@typescript-eslint/utils@6.0.0(eslint@8.45.0)(typescript@5.2.2):
/@typescript-eslint/utils@6.0.0(eslint@8.45.0)(typescript@5.4.3):
resolution: {integrity: sha512-SOr6l4NB6HE4H/ktz0JVVWNXqCJTOo/mHnvIte1ZhBQ0Cvd04x5uKZa3zT6tiodL06zf5xxdK8COiDvPnQ27JQ==}
engines: {node: ^16.0.0 || >=18.0.0}
peerDependencies:
@@ -3242,27 +3157,7 @@ packages:
'@types/semver': 7.5.4
'@typescript-eslint/scope-manager': 6.0.0
'@typescript-eslint/types': 6.0.0
'@typescript-eslint/typescript-estree': 6.0.0(typescript@5.2.2)
eslint: 8.45.0
eslint-scope: 5.1.1
semver: 7.5.4
transitivePeerDependencies:
- supports-color
- typescript
dev: true
/@typescript-eslint/utils@6.0.0(eslint@8.45.0)(typescript@5.3.3):
resolution: {integrity: sha512-SOr6l4NB6HE4H/ktz0JVVWNXqCJTOo/mHnvIte1ZhBQ0Cvd04x5uKZa3zT6tiodL06zf5xxdK8COiDvPnQ27JQ==}
engines: {node: ^16.0.0 || >=18.0.0}
peerDependencies:
eslint: ^7.0.0 || ^8.0.0
dependencies:
'@eslint-community/eslint-utils': 4.4.0(eslint@8.45.0)
'@types/json-schema': 7.0.14
'@types/semver': 7.5.4
'@typescript-eslint/scope-manager': 6.0.0
'@typescript-eslint/types': 6.0.0
'@typescript-eslint/typescript-estree': 6.0.0(typescript@5.3.3)
'@typescript-eslint/typescript-estree': 6.0.0(typescript@5.4.3)
eslint: 8.45.0
eslint-scope: 5.1.1
semver: 7.5.4
@@ -3865,6 +3760,12 @@ packages:
engines: {node: '>=8'}
dev: false
/async-mutex@0.5.0:
resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==}
dependencies:
tslib: 2.6.2
dev: false
/async-retry@1.3.3:
resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==}
dependencies:
@@ -4480,8 +4381,8 @@ packages:
engines: {node: '>=6'}
dev: false
/code-block-writer@12.0.0:
resolution: {integrity: sha512-q4dMFMlXtKR3XNBHyMHt/3pwYNA69EDk00lloMOaaUMKPUXBw6lpXtbu3MMVG6/uOihGnRDOlkyqsONEUj60+w==}
/code-block-writer@13.0.1:
resolution: {integrity: sha512-c5or4P6erEA69TxaxTNcHUNcIn+oyxSRTOWV+pSYF+z4epXqNvwvJ70XPGjPNgue83oAFAPBRQYwpAJ/Hpe/Sg==}
dev: true
/color-convert@1.9.3:
@@ -5047,8 +4948,8 @@ packages:
engines: {node: '>=0.4', npm: '>=1.2'}
dev: true
/dotenv@16.3.1:
resolution: {integrity: sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==}
/dotenv@16.4.5:
resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==}
engines: {node: '>=12'}
/download@8.0.0:
@@ -7182,8 +7083,8 @@ packages:
braces: 3.0.2
picomatch: 2.3.1
/mikro-orm@6.0.4:
resolution: {integrity: sha512-rhC9PJ6rhfOoBQgnVpC+3GeeILQf0cfU1mSZHA+74FjpurfrznS5AZmuTxSKBnfjpMzrPoTOOwMwQC6DE7aVaQ==}
/mikro-orm@6.1.12:
resolution: {integrity: sha512-pXpZ5dGMM0BBqYouU5EPuWkjWX/xnNzRVxsOTrKyyrm1ICTN7pawHn3UCVAggi+z1qVhpwxThGfZj+ZWD54duw==}
engines: {node: '>= 18.12.0'}
/miller-rabin@4.0.1:
@@ -8167,7 +8068,7 @@ packages:
optional: true
dependencies:
lilconfig: 3.1.1
ts-node: 10.9.2(@types/node@20.8.9)(typescript@5.3.3)
ts-node: 10.9.2(@types/node@20.8.9)(typescript@5.4.3)
yaml: 2.3.4
dev: true
@@ -8601,12 +8502,13 @@ packages:
'@babel/runtime': 7.23.9
dev: false
/reflect-metadata@0.1.13:
resolution: {integrity: sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==}
dev: false
/reflect-metadata@0.2.1:
resolution: {integrity: sha512-i5lLI6iw9AU3Uu4szRNPPEkomnkjRTaVt9hy/bn5g/oSzekBSMeLZblcjP74AW0vBabqERLLIrz+gR8QYR54Tw==}
deprecated: This version has a critical bug in fallback handling. Please upgrade to reflect-metadata@0.2.2 or newer.
/reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
dev: false
/regenerator-runtime@0.14.1:
resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
@@ -9602,25 +9504,16 @@ packages:
engines: {node: '>= 14.0.0'}
dev: false
/ts-api-utils@1.0.3(typescript@5.2.2):
/ts-api-utils@1.0.3(typescript@5.4.3):
resolution: {integrity: sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==}
engines: {node: '>=16.13.0'}
peerDependencies:
typescript: '>=4.2.0'
dependencies:
typescript: 5.2.2
typescript: 5.4.3
dev: true
/ts-api-utils@1.0.3(typescript@5.3.3):
resolution: {integrity: sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==}
engines: {node: '>=16.13.0'}
peerDependencies:
typescript: '>=4.2.0'
dependencies:
typescript: 5.3.3
dev: true
/ts-essentials@9.4.1(typescript@5.2.2):
/ts-essentials@9.4.1(patch_hash=254pvnpgpcwoswa4ncfq4tq6su)(typescript@5.4.3):
resolution: {integrity: sha512-oke0rI2EN9pzHsesdmrOrnqv1eQODmJpd/noJjwj2ZPC3Z4N2wbjrOEqnsEgmvlO2+4fBb0a794DCna2elEVIQ==}
peerDependencies:
typescript: '>=4.1.0'
@@ -9628,25 +9521,15 @@ packages:
typescript:
optional: true
dependencies:
typescript: 5.2.2
dev: true
/ts-essentials@9.4.1(typescript@5.3.3):
resolution: {integrity: sha512-oke0rI2EN9pzHsesdmrOrnqv1eQODmJpd/noJjwj2ZPC3Z4N2wbjrOEqnsEgmvlO2+4fBb0a794DCna2elEVIQ==}
peerDependencies:
typescript: '>=4.1.0'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
typescript: 5.3.3
typescript: 5.4.3
dev: true
patched: true
/ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
dev: true
/ts-loader@9.5.1(typescript@5.4.2)(webpack@5.91.0):
/ts-loader@9.5.1(typescript@5.4.3)(webpack@5.91.0):
resolution: {integrity: sha512-rNH3sK9kGZcH9dYzC7CewQm4NtxJTjSEVRJ2DyBZR7f8/wcta+iV44UPCXc5+nzDzivKtlzV6c9P4e+oFhDLYg==}
engines: {node: '>=12.0.0'}
peerDependencies:
@@ -9654,22 +9537,22 @@ packages:
webpack: ^5.0.0
dependencies:
chalk: 4.1.2
enhanced-resolve: 5.15.1
enhanced-resolve: 5.16.0
micromatch: 4.0.5
semver: 7.5.4
source-map: 0.7.4
typescript: 5.4.2
typescript: 5.4.3
webpack: 5.91.0(esbuild@0.19.5)(webpack-cli@5.1.4)
dev: true
/ts-morph@21.0.1:
resolution: {integrity: sha512-dbDtVdEAncKctzrVZ+Nr7kHpHkv+0JDJb2MjjpBaj8bFeCkePU9rHfMklmhuLFnpeq/EJZk2IhStY6NzqgjOkg==}
/ts-morph@22.0.0:
resolution: {integrity: sha512-M9MqFGZREyeb5fTl6gNHKZLqBQA0TjA1lea+CR48R8EBTDuWrNqW6ccC5QvjNR4s6wDumD3LTCjOFSp9iwlzaw==}
dependencies:
'@ts-morph/common': 0.22.0
code-block-writer: 12.0.0
'@ts-morph/common': 0.23.0
code-block-writer: 13.0.1
dev: true
/ts-node@10.9.2(@types/node@20.8.9)(typescript@5.3.3):
/ts-node@10.9.2(@types/node@20.8.9)(typescript@5.4.3):
resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==}
hasBin: true
peerDependencies:
@@ -9695,7 +9578,7 @@ packages:
create-require: 1.1.1
diff: 4.0.2
make-error: 1.3.6
typescript: 5.3.3
typescript: 5.4.3
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
dev: true
@@ -9726,7 +9609,7 @@ packages:
strip-json-comments: 2.0.1
dev: true
/tsify@5.0.4(browserify@17.0.0)(typescript@5.3.3):
/tsify@5.0.4(browserify@17.0.0)(typescript@5.4.3):
resolution: {integrity: sha512-XAZtQ5OMPsJFclkZ9xMZWkSNyMhMxEPsz3D2zu79yoKorH9j/DT4xCloJeXk5+cDhosEibu4bseMVjyPOAyLJA==}
engines: {node: '>=0.12'}
peerDependencies:
@@ -9740,13 +9623,13 @@ packages:
semver: 6.3.1
through2: 2.0.5
tsconfig: 5.0.3
typescript: 5.3.3
typescript: 5.4.3
dev: true
/tslib@2.6.2:
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
/tsup@8.0.2(@microsoft/api-extractor@7.43.0)(typescript@5.2.2):
/tsup@8.0.2(@microsoft/api-extractor@7.43.0)(typescript@5.4.3):
resolution: {integrity: sha512-NY8xtQXdH7hDUAZwcQdY/Vzlw9johQsaqf7iwZ6g1DOUlFYQ5/AtVAjTvihhEyeRlGo4dLRVHtrRaL35M1daqQ==}
engines: {node: '>=18'}
hasBin: true
@@ -9780,13 +9663,13 @@ packages:
source-map: 0.8.0-beta.0
sucrase: 3.35.0
tree-kill: 1.2.2
typescript: 5.2.2
typescript: 5.4.3
transitivePeerDependencies:
- supports-color
- ts-node
dev: true
/tsup@8.0.2(ts-node@10.9.2)(typescript@5.3.3):
/tsup@8.0.2(ts-node@10.9.2)(typescript@5.4.3):
resolution: {integrity: sha512-NY8xtQXdH7hDUAZwcQdY/Vzlw9johQsaqf7iwZ6g1DOUlFYQ5/AtVAjTvihhEyeRlGo4dLRVHtrRaL35M1daqQ==}
engines: {node: '>=18'}
hasBin: true
@@ -9805,6 +9688,7 @@ packages:
typescript:
optional: true
dependencies:
'@microsoft/api-extractor': 7.43.0
bundle-require: 4.0.2(esbuild@0.19.12)
cac: 6.7.14
chokidar: 3.5.3
@@ -9819,7 +9703,7 @@ packages:
source-map: 0.8.0-beta.0
sucrase: 3.35.0
tree-kill: 1.2.2
typescript: 5.3.3
typescript: 5.4.3
transitivePeerDependencies:
- supports-color
- ts-node
@@ -9925,6 +9809,12 @@ packages:
/type-fest@3.13.1:
resolution: {integrity: sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==}
engines: {node: '>=14.16'}
dev: true
/type-fest@4.14.0:
resolution: {integrity: sha512-on5/Cw89wwqGZQu+yWO0gGMGu8VNxsaW9SB2HE8yJjllEk7IDTwnSN1dUVldYILhYPN5HzD7WAaw2cc/jBfn0Q==}
engines: {node: '>=16'}
dev: false
/type-is@1.6.18:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
@@ -9950,24 +9840,18 @@ packages:
hasBin: true
dev: true
/typescript@5.2.2:
resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==}
engines: {node: '>=14.17'}
hasBin: true
dev: true
/typescript@5.3.3:
resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==}
engines: {node: '>=14.17'}
hasBin: true
dev: true
/typescript@5.4.2:
resolution: {integrity: sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==}
engines: {node: '>=14.17'}
hasBin: true
dev: true
/typescript@5.4.3:
resolution: {integrity: sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==}
engines: {node: '>=14.17'}
hasBin: true
dev: true
/ufo@1.3.1:
resolution: {integrity: sha512-uY/99gMLIOlJPwATcMVYfqDSxUR9//AUcgZMzwfSTJPDKzA1S8mX4VLqa+fiAtveraQUBCz4FFcwVZBGbwBXIw==}
dev: true
@@ -9985,15 +9869,15 @@ packages:
hasBin: true
dev: true
/umzug@3.5.0:
resolution: {integrity: sha512-bL6JjH716l0kg7V2Acrw5UmUgeLxdAZv3drMhKrJCXxEfK/qyM+B5s3ai1BjG1NyEGeXTOkhFIUgkMFo6zqVBg==}
/umzug@3.7.0:
resolution: {integrity: sha512-r/L2Zlilgv3SKhmP2nkA9x2Xi1PKtu2K34/i/s7AYJ2mLjEO+IxETJAK7CKf6l3QOvoy5/ChykeX9qt6ykRz6Q==}
engines: {node: '>=12'}
dependencies:
'@rushstack/ts-command-line': 4.17.1
emittery: 0.13.1
glob: 8.1.0
pony-cause: 2.1.10
type-fest: 3.13.1
type-fest: 4.14.0
dev: false
/unbzip2-stream@1.4.3:
@@ -10350,9 +10234,9 @@ packages:
lodash: 4.17.21
source-map-loader: 5.0.0(webpack@5.91.0)
string-replace-loader: 3.1.0(webpack@5.91.0)
ts-loader: 9.5.1(typescript@5.4.2)(webpack@5.91.0)
ts-loader: 9.5.1(typescript@5.4.3)(webpack@5.91.0)
tslib: 2.6.2
typescript: 5.4.2
typescript: 5.4.3
webpack: 5.91.0(esbuild@0.19.5)(webpack-cli@5.1.4)
webpack-cli: 5.1.4(webpack@5.91.0)
transitivePeerDependencies:

View File

@@ -29,12 +29,13 @@
"@fastify/static": "^6.12.0",
"@fastify/swagger": "^8.12.1",
"@fastify/swagger-ui": "^2.0.1",
"@mikro-orm/better-sqlite": "^6.0.4",
"@mikro-orm/core": "^6.0.4",
"@mikro-orm/migrations": "6.0.4",
"@mikro-orm/better-sqlite": "^6.1.12",
"@mikro-orm/core": "^6.1.12",
"@mikro-orm/migrations": "6.1.12",
"@tunarr/shared": "workspace:*",
"@tunarr/types": "workspace:*",
"JSONStream": "1.0.5",
"async-mutex": "^0.5.0",
"async-retry": "^1.3.3",
"axios": "^1.6.0",
"better-sqlite3": "^9.1.1",
@@ -58,7 +59,7 @@
"node-ssdp": "^4.0.0",
"p-queue": "^8.0.1",
"random-js": "2.1.0",
"reflect-metadata": "^0.1.13",
"reflect-metadata": "^0.2.2",
"retry": "^0.13.1",
"tslib": "^2.6.2",
"uuid": "^9.0.1",
@@ -69,8 +70,8 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@mikro-orm/cli": "^6.0.4",
"@mikro-orm/reflection": "^6.0.4",
"@mikro-orm/cli": "^6.1.12",
"@mikro-orm/reflection": "^6.1.12",
"@types/async-retry": "^1.4.8",
"@types/better-sqlite3": "^7.6.8",
"@types/express": "^4.17.20",
@@ -105,7 +106,7 @@
"tsup": "^8.0.2",
"tsx": "^4.7.1",
"typed-emitter": "^2.1.0",
"typescript": "^5.3.3",
"typescript": "^5.4.3",
"vitest": "^1.2.0"
},
"mikro-orm": {

View File

@@ -18,7 +18,7 @@ import duration from 'dayjs/plugin/duration.js';
import { compact, isError, isNil, omit, sortBy } from 'lodash-es';
import z from 'zod';
import createLogger from '../logger.js';
import { scheduledJobsById } from '../services/scheduler.js';
import { GlobalScheduler } from '../services/scheduler.js';
import { UpdateXmlTvTask } from '../tasks/updateXmlTvTask.js';
import { RouterPluginAsyncCallback } from '../types/serverType.js';
import { attempt, mapAsyncSeq } from '../util.js';
@@ -115,6 +115,9 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
const inserted = await attempt(() =>
req.serverCtx.channelDB.saveChannel(req.body),
);
GlobalScheduler.getScheduledJob(UpdateXmlTvTask.ID)
.runNow(true)
.catch((err) => logger.error('Error regenerating guide: %O', err));
if (isError(inserted)) {
return res.status(500).send(inserted);
}
@@ -183,9 +186,9 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
await req.serverCtx.channelDB.deleteChannel(channel.uuid);
try {
scheduledJobsById[UpdateXmlTvTask.ID]
?.runNow(true)
.catch(console.error);
GlobalScheduler.getScheduledJob(UpdateXmlTvTask.ID)
.runNow()
.catch((err) => logger.error('Error regenerating guide: %O', err));
} catch (e) {
logger.error('Unable to update guide after lineup update %O', e);
}
@@ -297,9 +300,9 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
}
try {
scheduledJobsById[UpdateXmlTvTask.ID]
?.runNow(true)
.catch(console.error);
GlobalScheduler.getScheduledJob(UpdateXmlTvTask.ID)
.runNow(true)
.catch((err) => logger.error('Error regenerating guide: %O', err));
} catch (e) {
logger.error('Unable to update guide after lineup update %O', e);
}

View File

@@ -10,7 +10,8 @@ import { FFMPEGInfo } from '../ffmpegInfo.js';
import { serverOptions } from '../globals.js';
import createLogger from '../logger.js';
import { Plex } from '../plex.js';
import { scheduledJobsById } from '../services/scheduler.js';
import { GlobalScheduler } from '../services/scheduler.js';
import { UpdateXmlTvTask } from '../tasks/updateXmlTvTask.js';
import { RouterPluginAsyncCallback } from '../types/serverType.js';
import { fileExists } from '../util/fsUtil.js';
import { channelsApi } from './channelsApi.js';
@@ -108,7 +109,9 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
fastify.get('/xmltv-last-refresh', (_req, res) => {
try {
return res.send({
value: scheduledJobsById['update-xmltv']?.lastExecution?.valueOf(),
value: GlobalScheduler.getScheduledJob(
UpdateXmlTvTask.ID,
).lastExecution?.valueOf(),
});
} catch (err) {
logger.error(err);
@@ -139,7 +142,7 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
// Force an XMLTV refresh
fastify.post('/xmltv/refresh', async (_, res) => {
await scheduledJobsById['update-xmltv']?.runNow();
await GlobalScheduler.getScheduledJob(UpdateXmlTvTask.ID).runNow(false);
return res.status(200);
});

View File

@@ -10,7 +10,7 @@ import z from 'zod';
import { PlexServerSettings } from '../dao/entities/PlexServerSettings.js';
import createLogger from '../logger.js';
import { Plex, PlexApiFactory } from '../plex.js';
import { scheduledJobsById } from '../services/scheduler.js';
import { GlobalScheduler } from '../services/scheduler.js';
import { UpdateXmlTvTask } from '../tasks/updateXmlTvTask.js';
import { RouterPluginAsyncCallback } from '../types/serverType.js';
import { firstDefined, wait } from '../util.js';
@@ -164,8 +164,8 @@ export const plexServersRouter: RouterPluginAsyncCallback = async (
// Regenerate guides
try {
scheduledJobsById[UpdateXmlTvTask.ID]
?.runNow(true)
GlobalScheduler.getScheduledJob(UpdateXmlTvTask.ID)
.runNow(true)
.catch(console.error);
} catch (e) {
logger.error('Unable to update guide after lineup update %O', e);

View File

@@ -1,6 +1,6 @@
import { BasicIdParamSchema } from '@tunarr/types/api';
import { ProgramSchema } from '@tunarr/types/schemas';
import { chunk, every, find, isNil, isUndefined, reduce } from 'lodash-es';
import { every, find, isNil, isUndefined } from 'lodash-es';
import z from 'zod';
import { getEm } from '../dao/dataSource.js';
import {
@@ -10,7 +10,6 @@ import {
} from '../dao/entities/Program.js';
import { Plex } from '../plex.js';
import { RouterPluginAsyncCallback } from '../types/serverType.js';
import { flatMapAsyncSeq, groupByFunc } from '../util.js';
const LookupExternalProgrammingSchema = z.object({
externalId: z
@@ -195,38 +194,9 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
},
},
async (req, res) => {
const em = getEm();
const allIds = [...req.body.externalIds];
const results = await flatMapAsyncSeq(
chunk(allIds, 25),
async (idChunk) => {
return await reduce(
idChunk,
(acc, [ps, es, ek]) => {
return acc.orWhere({
sourceType: programSourceTypeFromString(ps)!,
externalSourceId: es,
externalKey: ek,
});
},
em
.qb(Program)
.select([
'uuid',
'sourceType',
'externalSourceId',
'externalKey',
]),
);
},
return res.send(
await req.serverCtx.programDB.lookupByExternalIds(req.body.externalIds),
);
const all = groupByFunc(
results,
(r) => r.uniqueId(),
(r) => r.toDTO(),
);
return res.send(all);
},
);
};

View File

@@ -1,11 +1,10 @@
import { BaseErrorSchema } from '@tunarr/types/api';
import { TaskSchema } from '@tunarr/types/schemas';
import dayjs from 'dayjs';
import { chain, hasIn, isNil } from 'lodash-es';
import { chain, isNil } from 'lodash-es';
import { z } from 'zod';
import createLogger from '../logger.js';
import { scheduledJobsById } from '../services/scheduler.js';
import { TaskId } from '../tasks/task.js';
import { GlobalScheduler } from '../services/scheduler.js';
import { RouterPluginAsyncCallback } from '../types/serverType.js';
const logger = createLogger(import.meta);
@@ -22,7 +21,7 @@ export const tasksApiRouter: RouterPluginAsyncCallback = async (fastify) => {
},
},
async (_, res) => {
const result = chain(scheduledJobsById)
const result = chain(GlobalScheduler.scheduledJobsById)
.map((task, id) => {
if (isNil(task)) {
return;
@@ -58,21 +57,23 @@ export const tasksApiRouter: RouterPluginAsyncCallback = async (fastify) => {
schema: {
params: z.object({
id: z.string(),
background: z.boolean().default(true),
}),
response: {
200: z.any(),
202: z.void(),
400: BaseErrorSchema,
404: z.void(),
500: z.void(),
},
},
},
async (req, res) => {
if (!hasIn(scheduledJobsById, req.params.id)) {
const task = GlobalScheduler.getScheduledJob(req.params.id);
if (isNil(task)) {
return res.status(404).send();
}
const task = scheduledJobsById[req.params.id as TaskId];
if (isNil(task)) {
return res.status(404).send();
}
@@ -81,11 +82,17 @@ export const tasksApiRouter: RouterPluginAsyncCallback = async (fastify) => {
return res.status(400).send({ message: 'Task already running' });
}
task.runNow(true).catch((e) => {
const taskPromise = task.runNow(req.params.background).catch((e) => {
logger.error('Async task triggered by API failed: %O', e);
});
return res.status(202).send();
if (!req.params.background) {
return taskPromise
.then((result) => res.status(200).send(result))
.catch(() => res.status(500).send());
} else {
return res.status(202).send();
}
},
);
};

View File

@@ -6,7 +6,8 @@ import { z } from 'zod';
import { defaultXmlTvSettings } from '../dao/settings.js';
import { serverOptions } from '../globals.js';
import createLogger from '../logger.js';
import { scheduledJobsById } from '../services/scheduler.js';
import { GlobalScheduler } from '../services/scheduler.js';
import { UpdateXmlTvTask } from '../tasks/updateXmlTvTask.js';
import { RouterPluginCallback } from '../types/serverType.js';
import { firstDefined } from '../util.js';
@@ -67,7 +68,7 @@ export const xmlTvSettingsRouter: RouterPluginCallback = (
},
level: 'success',
});
await updateXmltv();
await GlobalScheduler.getScheduledJob(UpdateXmlTvTask.ID).runNow(false);
return res.send(xmltv);
} catch (err) {
logger.error(err);
@@ -113,7 +114,7 @@ export const xmlTvSettingsRouter: RouterPluginCallback = (
level: 'warning',
});
await updateXmltv();
await GlobalScheduler.getScheduledJob(UpdateXmlTvTask.ID).runNow(false);
return res.send(xmltv);
} catch (err) {
logger.error(err);
@@ -134,7 +135,3 @@ export const xmlTvSettingsRouter: RouterPluginCallback = (
done();
};
async function updateXmltv() {
await scheduledJobsById['update-xmltv']?.runNow();
}

View File

@@ -30,8 +30,9 @@ import {
sumBy,
take,
} from 'lodash-es';
import { Low } from 'lowdb';
import { DataFile } from 'lowdb/node';
import { Adapter, Low } from 'lowdb';
import { TextFile } from 'lowdb/node';
import { PathLike } from 'node:fs';
import fs from 'node:fs/promises';
import { join } from 'path';
import { globalOptions } from '../globals.js';
@@ -45,6 +46,7 @@ import { getEm } from './dataSource.js';
import {
Lineup,
LineupItem,
LineupSchema,
OfflineItem,
RedirectItem,
isContentItem,
@@ -88,9 +90,7 @@ function updateRequestToChannel(
},
duration: updateReq.duration,
stealth: updateReq.stealth,
fillerRepeatCooldown: updateReq.fillerRepeatCooldown
? dayjs.duration({ seconds: updateReq.fillerRepeatCooldown })
: undefined,
fillerRepeatCooldown: updateReq.fillerRepeatCooldown,
},
isNil,
);
@@ -123,16 +123,14 @@ function createRequestToChannel(
},
duration: saveReq.duration,
stealth: saveReq.stealth,
fillerRepeatCooldown: saveReq.fillerRepeatCooldown
? dayjs.duration({ seconds: saveReq.fillerRepeatCooldown })
: undefined,
fillerRepeatCooldown: saveReq.fillerRepeatCooldown,
};
return c;
}
// Let's see if this works... in so we can have many ChannelDb objects flying around.
const fileDbCache: Record<string | number, Low<Lineup>> = {};
export class ChannelDB {
private fileDbCache: Record<string | number, Low<Lineup>> = {};
getChannelByNumber(channelNumber: number): Promise<Nullable<Channel>> {
return getEm().repo(Channel).findOne({ number: channelNumber });
}
@@ -488,23 +486,17 @@ export class ChannelDB {
}
private async getFileDb(channelId: string) {
if (!this.fileDbCache[channelId]) {
this.fileDbCache[channelId] = new Low<Lineup>(
new DataFile(
if (!fileDbCache[channelId]) {
fileDbCache[channelId] = new Low<Lineup>(
new LineupDbAdapter(
join(globalOptions().database, `channel-lineups/${channelId}.json`),
{
parse: JSON.parse,
stringify(data) {
return JSON.stringify(data);
},
},
),
{ items: [], startTimeOffsets: [] },
);
await this.fileDbCache[channelId].read();
await fileDbCache[channelId].read();
}
return this.fileDbCache[channelId];
return fileDbCache[channelId];
}
private async markFileDbForDeletion(
@@ -521,7 +513,7 @@ export class ChannelDB {
await fs.rename(path, newPath);
}
if (isDelete) {
delete this.fileDbCache[channelId];
delete fileDbCache[channelId];
} else {
// Reload the file into the DB cache
await this.getFileDb(channelId);
@@ -538,6 +530,43 @@ export class ChannelDB {
}
}
class LineupDbAdapter implements Adapter<Lineup> {
#path: PathLike;
#adapter: TextFile;
constructor(filename: PathLike) {
this.#path = filename;
this.#adapter = new TextFile(filename);
}
async read(): Promise<Lineup | null> {
const data = await this.#adapter.read();
if (data === null) {
return null;
}
const parseResult = await LineupSchema.safeParseAsync(JSON.parse(data));
if (!parseResult.success) {
console.error(
`Error while trying to load lineup file ${this.#path.toString()}`,
parseResult.error,
);
return null;
}
return parseResult.data;
}
async write(data: Lineup): Promise<void> {
const parseResult = await LineupSchema.safeParseAsync(data);
if (!parseResult.success) {
console.warn(
'Could not parse lineup before saving to DB',
parseResult.error,
);
}
return this.#adapter.write(JSON.stringify(data));
}
}
export function buildApiLineup(
channel: Loaded<Channel, 'programs' | 'programs.customShows.uuid'>,
lineup: LineupItem[],

View File

@@ -1,44 +1,51 @@
import { LineupSchedule } from '@tunarr/types/api';
import {
DynamicContentConfigSchema,
LineupScheduleSchema,
} from '@tunarr/types/api';
import { z } from 'zod';
export type Lineup = {
items: LineupItem[];
// Unsure if we want this DB type to reference the
// API type, but for now it will work.
schedule?: LineupSchedule;
// These are precalculated offsets in milliseconds. The
// array is a list of the running 'total' duration sum
// of each of the lineup items. It can be used to quickly
// determine a start timestamp for a given program by
// pulling the offset at a given index and adding it to
// a "start" time timestamp.
startTimeOffsets?: number[];
};
const BaseLineupItemSchema = z.object({
durationMs: z.number().positive(), // Add a max
});
type BaseLineupItem = {
durationMs: number;
};
export const ContentLineupItemSchema = z
.object({
type: z.literal('content'),
id: z.string().min(1),
// If this lineup item was a part of a custom show
// this is a pointer to that show.
// TODO: If a custom show is deleted, we have to remove
// references to these content items in the lineup
customShowId: z.string().uuid().optional(),
})
.merge(BaseLineupItemSchema);
// This item has to be hydrated from the DB
export type ContentItem = BaseLineupItem & {
type: 'content';
id: string;
// If this lineup item was a part of a custom show
// this is a pointer to that show.
// TODO: If a custom show is deleted, we have to remove
// references to these content items in the lineup
customShowId?: string;
};
export type ContentItem = z.infer<typeof ContentLineupItemSchema>;
export type OfflineItem = BaseLineupItem & {
type: 'offline';
};
export const OfflineLineupItemSchema = z
.object({
type: z.literal('offline'),
})
.merge(BaseLineupItemSchema);
export type RedirectItem = BaseLineupItem & {
type: 'redirect';
channel: string;
};
export type OfflineItem = z.infer<typeof OfflineLineupItemSchema>;
export type LineupItem = ContentItem | OfflineItem | RedirectItem;
export const RedirectLineupItemSchema = z
.object({
type: z.literal('redirect'),
channel: z.string().uuid(),
})
.merge(BaseLineupItemSchema);
export type RedirectItem = z.infer<typeof RedirectLineupItemSchema>;
export const LineupItemSchema = z.discriminatedUnion('type', [
ContentLineupItemSchema,
OfflineLineupItemSchema,
RedirectLineupItemSchema,
]);
export type LineupItem = z.infer<typeof LineupItemSchema>;
function isItemOfType<T extends LineupItem>(discrim: string) {
return function (t: LineupItem | undefined): t is T {
@@ -49,3 +56,27 @@ function isItemOfType<T extends LineupItem>(discrim: string) {
export const isContentItem = isItemOfType<ContentItem>('content');
export const isOfflineItem = isItemOfType<OfflineItem>('offline');
export const isRedirectItem = isItemOfType<RedirectItem>('redirect');
export const LineupSchema = z.object({
// The current lineup of a single cycle of this channel
items: LineupItemSchema.array(),
// Defines rules for how to schedule content in the channel
// Currently time-based and random-slot-based rulesets are
// supported.
// Unsure if we want this DB type to reference the
// API type, but for now it will work.
schedule: LineupScheduleSchema.optional(),
// These are precalculated offsets in milliseconds. The
// array is a list of the running 'total' duration sum
// of each of the lineup items. It can be used to quickly
// determine a start timestamp for a given program by
// pulling the offset at a given index and adding it to
// a "start" time timestamp.
startTimeOffsets: z.array(z.number()).optional(),
dynamicContentConfig: DynamicContentConfigSchema.optional(),
});
export type Lineup = z.infer<typeof LineupSchema>;

View File

@@ -6,7 +6,6 @@ import {
Unique,
} from '@mikro-orm/core';
import { Channel as ChannelDTO, Resolution } from '@tunarr/types';
import type { Duration } from 'dayjs/plugin/duration.js';
import { nilToUndefined } from '../../util.js';
import { DurationType } from '../custom_types/DurationType.js';
import { BaseEntity } from './BaseEntity.js';
@@ -117,7 +116,7 @@ export class Channel extends BaseEntity {
fillers = new Collection<FillerShow>(this);
@Property({ nullable: true, type: DurationType })
fillerRepeatCooldown?: Duration;
fillerRepeatCooldown?: number; // Seconds
@ManyToMany(() => CustomShow)
customShows = new Collection<CustomShow>(this);

View File

@@ -0,0 +1,36 @@
import { chunk, reduce } from 'lodash-es';
import { flatMapAsyncSeq, groupByFunc } from '../util';
import { getEm } from './dataSource';
import { Program, programSourceTypeFromString } from './entities/Program';
export class ProgramDB {
async lookupByExternalIds(
ids: Set<[string, string, string]>,
chunkSize: number = 25,
) {
const em = getEm();
const results = await flatMapAsyncSeq(
chunk([...ids], chunkSize),
async (idChunk) => {
return await reduce(
idChunk,
(acc, [ps, es, ek]) => {
return acc.orWhere({
sourceType: programSourceTypeFromString(ps)!,
externalSourceId: es,
externalKey: ek,
});
},
em
.qb(Program)
.select(['uuid', 'sourceType', 'externalSourceId', 'externalKey']),
);
},
);
return groupByFunc(
results,
(r) => r.uniqueId(),
(r) => r.toDTO(),
);
}
}

View File

@@ -12,6 +12,7 @@ import { XMLParser } from 'fast-xml-parser';
import { isNil, isUndefined } from 'lodash-es';
import NodeCache from 'node-cache';
import querystring, { ParsedUrlQueryInput } from 'querystring';
import { MarkOptional } from 'ts-essentials';
import { PlexServerSettings } from './dao/entities/PlexServerSettings.js';
import createLogger from './logger.js';
import { Maybe } from './types.js';
@@ -28,9 +29,12 @@ type AxiosConfigWithMetadata = InternalAxiosRequestConfig & {
const logger = createLogger(import.meta);
type PlexApiOptions = Pick<
EntityDTO<PlexServerSettings>,
'accessToken' | 'uri'
type PlexApiOptions = MarkOptional<
Pick<
EntityDTO<PlexServerSettings>,
'accessToken' | 'uri' | 'name' | 'clientIdentifier'
>,
'clientIdentifier'
>;
class PlexApiFactoryImpl {
@@ -51,10 +55,12 @@ class PlexApiFactoryImpl {
export const PlexApiFactory = new PlexApiFactoryImpl();
export class Plex {
#opts: PlexApiOptions;
private axiosInstance: AxiosInstance;
private _accessToken: string;
constructor(opts: PlexApiOptions) {
this.#opts = opts;
this._accessToken = opts.accessToken;
const uri = opts.uri.endsWith('/')
? opts.uri.slice(0, opts.uri.length - 1)
@@ -106,6 +112,10 @@ export class Plex {
);
}
get serverName() {
return this.#opts.name;
}
private async doRequest<T>(req: AxiosRequestConfig): Promise<Maybe<T>> {
try {
const response = await this.axiosInstance.request<T>(req);

View File

@@ -79,7 +79,7 @@ export class PlexTranscoder {
constructor(
clientId: string,
server: DeepReadonly<PlexServerSettings>,
server: PlexServerSettings,
settings: DeepReadonly<PlexStreamSettings>,
channel: ContextChannel,
lineupItem: ContentBackedStreamLineupItem,

View File

@@ -33,7 +33,7 @@ import { getSettingsRawDb } from './dao/settings.js';
import { serverOptions } from './globals.js';
import createLogger from './logger.js';
import { ServerRequestContext, serverContext } from './serverContext.js';
import { scheduleJobs, scheduledJobsById } from './services/scheduler.js';
import { GlobalScheduler, scheduleJobs } from './services/scheduler.js';
import { runFixers } from './tasks/fixers/index.js';
import { UpdateXmlTvTask } from './tasks/updateXmlTvTask.js';
import { ServerOptions } from './types.js';
@@ -113,7 +113,9 @@ export async function initServer(opts: ServerOptions) {
scheduleJobs(ctx);
await runFixers();
const updateXMLPromise = scheduledJobsById[UpdateXmlTvTask.ID]!.runNow();
const updateXMLPromise = GlobalScheduler.getScheduledJob(
UpdateXmlTvTask.ID,
).runNow();
const app = fastify({ logger: false, bodyLimit: 50 * 1024 * 1024 })
.setValidatorCompiler(validatorCompiler)

View File

@@ -6,6 +6,7 @@ import { ChannelDB } from './dao/channelDb.js';
import { CustomShowDB } from './dao/customShowDb.js';
import { FillerDB } from './dao/fillerDb.js';
import { PlexServerDB } from './dao/plexServerDb.js';
import { ProgramDB } from './dao/programDB.js';
import { Settings, getSettings } from './dao/settings.js';
import { serverOptions } from './globals.js';
import { HdhrService } from './hdhr.js';
@@ -30,6 +31,7 @@ export type ServerContext = {
xmltv: XmlTvWriter;
plexServerDB: PlexServerDB;
settings: Settings;
programDB: ProgramDB;
};
export const serverContext: () => Promise<ServerContext> = once(async () => {
@@ -77,6 +79,7 @@ export const serverContext: () => Promise<ServerContext> = once(async () => {
xmltv,
plexServerDB: new PlexServerDB(channelDB),
settings,
programDB: new ProgramDB(),
};
});

View File

@@ -0,0 +1,89 @@
import { createExternalId } from '@tunarr/shared';
import {
PlexChildMediaViewType,
PlexLibrarySection,
PlexMedia,
PlexTerminalMedia,
isPlexDirectory,
isTerminalItem,
} from '@tunarr/types/plex';
import { isNil, uniqBy } from 'lodash-es';
import map from 'lodash-es/map';
import { ProgramDB } from '../dao/programDB';
import { Plex } from '../plex';
import { typedProperty } from '../types/path';
import { flatMapAsyncSeq } from '../util';
type EnrichedPlexTerminalMedia = PlexTerminalMedia & {
id?: string;
};
export class PlexItemEnumerator {
#plex: Plex;
#programDB: ProgramDB;
constructor(plex: Plex, programDB: ProgramDB) {
this.#plex = plex;
this.#programDB = programDB;
}
async enumerateItems(initialItems: (PlexMedia | PlexLibrarySection)[]) {
const allItems = await flatMapAsyncSeq(initialItems, (item) =>
this.enumerateItem(item),
);
return uniqBy(allItems, typedProperty('key'));
}
async enumerateItem(
initialItem: PlexMedia | PlexLibrarySection,
): Promise<EnrichedPlexTerminalMedia[]> {
const loopInner = async (
item: PlexMedia | PlexLibrarySection,
): Promise<PlexTerminalMedia[]> => {
if (isTerminalItem(item)) {
return [item];
} else if (isPlexDirectory(item)) {
return [];
} else {
const plexResult = await this.#plex.doGet<PlexChildMediaViewType>(
item.key,
);
if (isNil(plexResult)) {
// TODO Log
return [];
}
const results: EnrichedPlexTerminalMedia[] = [];
for (const listing of plexResult.Metadata) {
results.push(...(await loopInner(listing)));
}
return results;
}
};
const res = await loopInner(initialItem);
const externalIds: [string, string, string][] = res.map(
(m) => ['plex', this.#plex.serverName, m.key] as const,
);
// This is best effort - if the user saves these IDs later, the upsert
// logic should figure out what is new/existing
try {
const existingIdsByExternalId = await this.#programDB.lookupByExternalIds(
new Set(externalIds),
);
return map(res, (media) => ({
...media,
id: existingIdsByExternalId[
createExternalId('plex', this.#plex.serverName, media.key)
]?.id,
}));
} catch (e) {
console.error('Unable to retrieve IDs in batch', e);
}
return res;
}
}

View File

@@ -0,0 +1,88 @@
import schedule from 'node-schedule';
import { withDb } from '../dao/dataSource.js';
import { Task } from '../tasks/task.js';
import { Maybe } from '../types.js';
import { logger } from './scheduler.js';
export type TaskFactoryFn<T> = () => Task<T>;
type ScheduledTaskOptions = {
visible?: boolean;
runOnSchedule?: boolean;
};
export class ScheduledTask<OutType = unknown> {
#factory: TaskFactoryFn<OutType>;
#scheduledJob: schedule.Job;
#running: boolean = false;
#schedule: string;
public visible: boolean = true;
public lastExecution?: Date;
constructor(
jobName: string,
cronSchedule: string,
taskFactory: TaskFactoryFn<OutType>,
options?: ScheduledTaskOptions,
) {
this.#schedule = cronSchedule;
this.#factory = taskFactory;
this.#scheduledJob = schedule.scheduleJob(jobName, cronSchedule, () =>
this.jobInternal(),
);
this.visible = options?.visible ?? true;
if (options?.runOnSchedule) {
this.runNow(true).catch(console.error);
}
}
get name() {
return this.#scheduledJob.name;
}
running() {
return this.#running;
}
// Runs an instance of this task now, cancels the next invocation
// and reschedules the job on the original schedule.
// If background=true, this function will not return the underlying
// Promise generated by the running job and all errors will be swallowed.
async runNow(background: boolean = true) {
this.#scheduledJob.cancelNext(false);
const rescheduleCb = () => this.#scheduledJob.reschedule(this.#schedule);
if (background) {
return new Promise<Maybe<OutType>>((resolve, reject) => {
this.jobInternal().then(resolve).catch(reject).finally(rescheduleCb);
});
} else {
return this.jobInternal(true).finally(rescheduleCb);
}
}
cancel() {
this.#scheduledJob.cancel();
}
nextExecution() {
return this.#scheduledJob.nextInvocation();
}
private async jobInternal(rethrow: boolean = false) {
this.#running = true;
const instance = this.#factory();
try {
return withDb(async () => await instance.run());
} catch (e) {
logger.error('Error while running job: %s; %O', instance.taskName, e);
if (rethrow) throw e;
return;
} finally {
this.#running = false;
this.lastExecution = new Date();
}
}
}

View File

@@ -0,0 +1,51 @@
import { Loaded } from '@mikro-orm/core';
import { DynamicContentConfigSource } from '@tunarr/types/api';
import { Mutex, withTimeout } from 'async-mutex';
import { EntityManager, withDb } from '../../dao/dataSource';
import { Channel } from '../../dao/entities/Channel';
const locks: Record<DynamicContentConfigSource['type'], Mutex> = {
plex: new Mutex(),
};
export abstract class ContentSourceUpdater<
T extends DynamicContentConfigSource,
> {
protected initialized: boolean = false;
protected channel: Loaded<Channel>;
protected config: T;
constructor(channel: Loaded<Channel>, config: T) {
this.channel = channel;
this.config = config;
}
public update(): Promise<void> {
return this.runInternal();
}
private async runInternal() {
return withTimeout(locks[this.config.type], 60 * 1000).runExclusive(
async () => {
return await withDb(async (em) => {
await this.prepare(em);
return await this.run();
});
},
);
}
/**
* Implementations should use this function to prepare any
* depedencies or internal resources before the updater runs.
* This is run in a DB request context and an entity manager is
* provided.
*/
protected abstract prepare(em: EntityManager): Promise<void>;
/**
* Update the content...
* TODO figure out if we can generalize enough to just return programs here
*/
protected abstract run(): Promise<void>;
}

View File

@@ -0,0 +1,17 @@
import { Loaded } from '@mikro-orm/core';
import { DynamicContentConfigSource } from '@tunarr/types/api';
import { Channel } from '../../dao/entities/Channel';
import { ContentSourceUpdater } from './ContentSourceUpdater';
import { PlexContentSourceUpdater } from './PlexContentSourceUpdater';
export class ContentSourceUpdaterFactory {
static getUpdater(
channel: Loaded<Channel>,
config: DynamicContentConfigSource,
): ContentSourceUpdater<DynamicContentConfigSource> {
switch (config.type) {
case 'plex':
return new PlexContentSourceUpdater(channel, config);
}
}
}

View File

@@ -0,0 +1,86 @@
import { Loaded } from '@mikro-orm/core';
import { createExternalId } from '@tunarr/shared';
import { buildPlexFilterKey } from '@tunarr/shared/util';
import { ContentProgram } from '@tunarr/types';
import { DynamicContentConfigPlexSource } from '@tunarr/types/api';
import { PlexLibraryListing } from '@tunarr/types/plex';
import { isNil, map } from 'lodash-es';
import { ChannelDB } from '../../dao/channelDb.js';
import { EntityManager } from '../../dao/dataSource.js';
import { Channel } from '../../dao/entities/Channel.js';
import { PlexServerSettings } from '../../dao/entities/PlexServerSettings.js';
import { ProgramDB } from '../../dao/programDB.js';
import { Plex } from '../../plex.js';
import { PlexItemEnumerator } from '../PlexItemEnumerator.js';
import { ContentSourceUpdater } from './ContentSourceUpdater.js';
export class PlexContentSourceUpdater extends ContentSourceUpdater<DynamicContentConfigPlexSource> {
#plex: Plex;
#channelDB: ChannelDB;
constructor(
channel: Loaded<Channel>,
config: DynamicContentConfigPlexSource,
) {
super(channel, config);
this.#channelDB = new ChannelDB();
}
protected async prepare(em: EntityManager) {
const server = await em.repo(PlexServerSettings).findOneOrFail({
$or: [
{ name: this.config.plexServerId },
{ clientIdentifier: this.config.plexServerId },
],
});
this.#plex = new Plex(server);
}
protected async run() {
const filter = buildPlexFilterKey(this.config.search?.filter);
// TODO page through the results
const plexResult = await this.#plex.doGet<PlexLibraryListing>(
`/library/sections/${this.config.plexLibraryKey}/all?${filter.join('&')}`,
);
const enumerator = new PlexItemEnumerator(this.#plex, new ProgramDB());
const enumeratedItems = await enumerator.enumerateItems(
plexResult?.Metadata ?? [],
);
const programs: ContentProgram[] = map(enumeratedItems, (media) => {
const uniqueId = createExternalId(
'plex',
this.#plex.serverName,
media.key,
);
return {
id: media.id ?? uniqueId,
persisted: !isNil(media.id),
originalProgram: media,
duration: media.duration,
externalSourceName: this.#plex.serverName,
externalSourceType: 'plex',
externalKey: media.key,
uniqueId: uniqueId,
type: 'content',
subtype: media.type,
title: media.type === 'episode' ? media.grandparentTitle : media.title,
episodeTitle: media.type === 'episode' ? media.title : undefined,
episodeNumber: media.type === 'episode' ? media.index : undefined,
seasonNumber: media.type === 'episode' ? media.parentIndex : undefined,
};
});
console.log(programs.length);
await this.#channelDB.updateLineup(this.channel.uuid, {
type: 'manual',
lineup: [],
programs: [],
});
}
}

View File

@@ -1,105 +1,93 @@
import { once } from 'lodash-es';
import schedule from 'node-schedule';
import { withDb } from '../dao/dataSource.js';
import { isString, once, pickBy } from 'lodash-es';
import createLogger from '../logger.js';
import { ServerContext } from '../serverContext.js';
import { CleanupSessionsTask } from '../tasks/cleanupSessionsTask.js';
import { ScheduleDynamicChannelsTask } from '../tasks/scheduleDynamicChannelsTask.js';
import { Task, TaskId } from '../tasks/task.js';
import { UpdateXmlTvTask } from '../tasks/updateXmlTvTask.js';
import { Maybe } from '../types.js';
import { typedProperty } from '../types/path.js';
import { Tag } from '../types/util.js';
import { ScheduledTask } from './ScheduledTask.js';
const logger = createLogger(import.meta);
export const logger = createLogger(import.meta);
type TaskFactoryFn<Data> = () => Task<Data>;
class Scheduler {
#scheduledJobsById: Record<string, ScheduledTask> = {};
class ScheduledTask<Data> {
#factory: TaskFactoryFn<Data>;
#scheduledJob: schedule.Job;
#running: boolean = false;
#schedule: string;
public lastExecution?: Date;
constructor(
jobName: string,
cronSchedule: string,
taskFactory: TaskFactoryFn<Data>,
) {
this.#schedule = cronSchedule;
this.#factory = taskFactory;
this.#scheduledJob = schedule.scheduleJob(jobName, cronSchedule, () =>
this.jobInternal(),
);
}
get name() {
return this.#scheduledJob.name;
}
running() {
return this.#running;
}
// Runs an instance of this task now, cancels the next invocation
// and reschedules the job on the original schedule.
// If background=true, this function will not return the underlying
// Promise generated by the running job and all errors will be swallowed.
async runNow(background: boolean = true) {
this.#scheduledJob.cancelNext(false);
const rescheduleCb = () => this.#scheduledJob.reschedule(this.#schedule);
if (background) {
await this.jobInternal().finally(rescheduleCb);
return;
// TaskId values always have an associated task (after server startup)
getScheduledJob<
Id extends TaskId,
OutType = Id extends Tag<TaskId, infer Out> ? Out : unknown,
>(id: TaskId): ScheduledTask<OutType>;
getScheduledJob<OutType = unknown>(id: string): Maybe<ScheduledTask<OutType>>;
getScheduledJob<OutType = unknown>(
id: Task<OutType> | string,
): Maybe<ScheduledTask<OutType>> {
if (isString(id)) {
return this.#scheduledJobsById[id] as Maybe<ScheduledTask<OutType>>;
} else {
return this.jobInternal(true).finally(rescheduleCb);
return this.getScheduledJob(id.ID);
}
}
cancel() {
this.#scheduledJob.cancel();
}
nextExecution() {
return this.#scheduledJob.nextInvocation();
}
private async jobInternal(rethrow: boolean = false) {
this.#running = true;
const instance = this.#factory();
try {
return withDb(async () => await instance.run());
} catch (e) {
logger.error('Error while running job: %s; %O', instance.name, e);
if (rethrow) throw e;
return;
} finally {
this.#running = false;
this.lastExecution = new Date();
scheduleTask(
id: string,
task: ScheduledTask,
overwrite: boolean = true,
): boolean {
if (!overwrite && this.#scheduledJobsById[id]) {
return false;
}
this.#scheduledJobsById[id] = task;
return true;
}
get scheduledJobsById(): Record<string, ScheduledTask> {
return pickBy(this.#scheduledJobsById, typedProperty('visible'));
}
}
// TODO: It is annoying that this has to be partial
export const scheduledJobsById: Partial<
Record<TaskId, ScheduledTask<unknown>>
> = {};
export const GlobalScheduler = new Scheduler();
export const scheduleJobs = once((serverContext: ServerContext) => {
const xmlTvSettings = serverContext.settings.xmlTvSettings();
scheduledJobsById[UpdateXmlTvTask.ID] = new ScheduledTask(
UpdateXmlTvTask.name,
hoursCrontab(xmlTvSettings.refreshHours),
() => UpdateXmlTvTask.create(serverContext),
GlobalScheduler.scheduleTask(
UpdateXmlTvTask.ID,
new ScheduledTask(
UpdateXmlTvTask.name,
hoursCrontab(xmlTvSettings.refreshHours),
() => UpdateXmlTvTask.create(serverContext),
),
);
scheduledJobsById[CleanupSessionsTask.ID] = new ScheduledTask(
CleanupSessionsTask.name,
minutesCrontab(30),
() => new CleanupSessionsTask(),
GlobalScheduler.scheduleTask(
CleanupSessionsTask.ID,
new ScheduledTask(
CleanupSessionsTask.name,
minutesCrontab(30),
() => new CleanupSessionsTask(),
),
);
GlobalScheduler.scheduleTask(
ScheduleDynamicChannelsTask.ID,
new ScheduledTask(
ScheduleDynamicChannelsTask.name,
// Temporary
hoursCrontab(1),
() => ScheduleDynamicChannelsTask.create(serverContext.channelDB),
{
runOnSchedule: true,
},
),
);
});
function hoursCrontab(hours: number): string {
return `0 0 0/${hours} * * *`;
return `0 0 */${hours} * * *`;
}
function minutesCrontab(mins: number): string {

View File

@@ -156,7 +156,7 @@ export class TVGuideService {
* @returns The current cached guide
*/
async get() {
if (this.cachedGuide !== null) {
if (!isNil(this.cachedGuide)) {
return this.cachedGuide;
}
@@ -232,11 +232,13 @@ export class TVGuideService {
);
}
const { channel, programs } = this.cachedGuide[channelId];
if (isNil(channel)) {
const channelAndLineup = this.cachedGuide[channelId];
if (isNil(channelAndLineup)) {
return;
}
const { channel, programs } = channelAndLineup;
const result: ChannelLineup = {
icon: channel.icon,
name: channel.name,

View File

@@ -48,7 +48,7 @@ export class CleanupSessionsTask extends Task<void> {
});
}
get name() {
get taskName() {
return CleanupSessionsTask.name;
}
}

View File

@@ -0,0 +1,79 @@
import { Loaded } from '@mikro-orm/core';
import { DynamicContentConfigSource } from '@tunarr/types/api';
import { isUndefined } from 'lodash-es';
import filter from 'lodash-es/filter';
import { ChannelDB } from '../dao/channelDb';
import { Channel } from '../dao/entities/Channel';
import { ScheduledTask } from '../services/ScheduledTask';
import { ContentSourceUpdaterFactory } from '../services/dynamic_channels/ContentSourceUpdaterFactory';
import { GlobalScheduler } from '../services/scheduler';
import { Maybe } from '../types';
import { Task, TaskId } from './task';
export class ScheduleDynamicChannelsTask extends Task<void> {
public static ID: TaskId = 'schedule-dynamic-channels';
#channelsDb: ChannelDB;
#taskFactory: DynamicChannelUpdaterFactory;
public ID = ScheduleDynamicChannelsTask.ID;
public taskName = ScheduleDynamicChannelsTask.name;
static create(channelsDb: ChannelDB) {
return new ScheduleDynamicChannelsTask(channelsDb);
}
private constructor(channelsDb: ChannelDB) {
super();
this.#channelsDb = channelsDb;
this.#taskFactory = new DynamicChannelUpdaterFactory();
}
// eslint-disable-next-line @typescript-eslint/require-await
protected async runInternal(): Promise<Maybe<void>> {
const lineups = await this.#channelsDb.loadAllLineups();
const dynamicLineups = filter(
lineups,
({ lineup }) =>
!isUndefined(lineup.dynamicContentConfig) &&
lineup.dynamicContentConfig.enabled,
);
for (const { channel, lineup } of dynamicLineups) {
for (const source of lineup.dynamicContentConfig!.contentSources) {
if (!source.enabled) {
continue;
}
const scheduled = GlobalScheduler.scheduleTask(
source.updater._id,
new ScheduledTask(
'UpdateDynamicChannel',
source.updater.schedule,
() => this.#taskFactory.getTask(channel, source),
),
);
console.log('scheduling task = ' + scheduled);
}
}
}
}
class DynamicChannelUpdaterFactory {
getTask(
channel: Loaded<Channel>,
contentSourceDef: DynamicContentConfigSource,
): Task<unknown> {
// This won't always be anonymous
return new (class extends Task<unknown> {
public ID = contentSourceDef.updater._id;
public taskName = `AnonymousTest_` + contentSourceDef.updater._id;
// eslint-disable-next-line @typescript-eslint/require-await
protected async runInternal() {
return ContentSourceUpdaterFactory.getUpdater(
channel,
contentSourceDef,
).update();
}
})();
}
}

View File

@@ -1,11 +1,15 @@
import { isError, isString, round } from 'lodash-es';
import createLogger from '../logger.js';
import { Maybe } from '../types.js';
import { Tag } from '../types/util.js';
const logger = createLogger(import.meta);
// Set of all of the possible Task IDs
export type TaskId = 'update-xmltv' | 'cleanup-sessions';
export type TaskId =
| 'update-xmltv'
| 'cleanup-sessions'
| 'schedule-dynamic-channels';
export abstract class Task<Data> {
private running_ = false;
@@ -13,7 +17,7 @@ export abstract class Task<Data> {
protected hasRun: boolean = false;
protected result: Maybe<Data>;
public abstract ID: TaskId;
public abstract ID: string | Tag<TaskId, Data>;
protected abstract runInternal(): Promise<Maybe<Data>>;
@@ -50,5 +54,16 @@ export abstract class Task<Data> {
return this.running_;
}
abstract get name(): string;
abstract get taskName(): string;
}
export function AnonymousTask(id: string): Task<unknown> {
return new (class extends Task<unknown> {
public ID = id;
public taskName = `AnonymousTest_` + id;
// eslint-disable-next-line @typescript-eslint/require-await
protected async runInternal() {
console.log('hello girly');
}
})();
}

View File

@@ -1,4 +1,5 @@
import { Loaded } from '@mikro-orm/core';
import { PlexDvr } from '@tunarr/types/plex';
import dayjs from 'dayjs';
import { ChannelCache } from '../channelCache.js';
import { withDb } from '../dao/dataSource.js';
@@ -10,8 +11,9 @@ import { Plex } from '../plex.js';
import { ServerContext } from '../serverContext.js';
import { TVGuideService } from '../services/tvGuideService.js';
import { Maybe } from '../types.js';
import { Tag } from '../types/util.js';
import { mapAsyncSeq } from '../util.js';
import { Task, TaskId } from './task.js';
import { Task } from './task.js';
const logger = createLogger(import.meta);
@@ -20,9 +22,8 @@ export class UpdateXmlTvTask extends Task<void> {
private dbAccess: Settings;
private guideService: TVGuideService;
public static ID: TaskId = 'update-xmltv';
public ID: TaskId = UpdateXmlTvTask.ID;
public static name = 'Update XMLTV';
public static ID = 'update-xmltv' as Tag<'update-xmltv', void>;
public ID = UpdateXmlTvTask.ID;
static create(serverContext: ServerContext): UpdateXmlTvTask {
return new UpdateXmlTvTask(
@@ -43,7 +44,7 @@ export class UpdateXmlTvTask extends Task<void> {
this.guideService = guideService;
}
get name() {
get taskName() {
return UpdateXmlTvTask.name;
}
@@ -62,7 +63,6 @@ export class UpdateXmlTvTask extends Task<void> {
await this.guideService.refreshGuide(
dayjs.duration({ hours: xmltvSettings.programmingHours }),
);
// xmltvInterval.lastRefresh = new Date();
logger.info('XMLTV Updated at ' + new Date().toLocaleString());
} catch (err) {
@@ -78,12 +78,13 @@ export class UpdateXmlTvTask extends Task<void> {
await mapAsyncSeq(allPlexServers, undefined, async (plexServer) => {
const plex = new Plex(plexServer);
let dvrs;
let dvrs: PlexDvr[] = [];
if (!plexServer.sendGuideUpdates && !plexServer.sendChannelUpdates) {
return;
}
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
dvrs = await plex.getDvrs(); // Refresh guide and channel mappings
} catch (err) {
logger.error(
@@ -92,6 +93,11 @@ export class UpdateXmlTvTask extends Task<void> {
);
return;
}
if (dvrs.length === 0) {
return;
}
if (plexServer.sendGuideUpdates) {
try {
await plex.refreshGuide(dvrs);
@@ -102,6 +108,7 @@ export class UpdateXmlTvTask extends Task<void> {
);
}
}
if (plexServer.sendChannelUpdates && channels.length !== 0) {
try {
await plex.refreshChannels(channels, dvrs);

2
server/src/types/util.ts Normal file
View File

@@ -0,0 +1,2 @@
declare const tagSymbol: unique symbol;
export type Tag<Typ, T> = Typ & { [tagSymbol]: T };

View File

@@ -96,10 +96,14 @@ export function groupByUniqAndMap<
}
export async function mapAsyncSeq<T, U>(
seq: ReadonlyArray<T>,
seq: T[] | null | undefined,
ms: number | undefined,
itemFn: (item: T) => Promise<U>,
): Promise<U[]> {
if (isNil(seq)) {
return [];
}
const all = await seq.reduce(
async (prev, item) => {
const last = await prev;
@@ -175,7 +179,7 @@ Array.prototype.mapAsyncSeq = async function <T, U>(
itemFn: (item: T) => Promise<U>,
ms: number | undefined,
) {
return mapAsyncSeq(this as ReadonlyArray<T>, ms, itemFn);
return mapAsyncSeq(this as T[], ms, itemFn);
};
export function time<T>(

View File

@@ -1,37 +0,0 @@
import inst, { Dayjs, ManipulateType, PluginFunc, isDuration } from 'dayjs';
import duration, { Duration } from 'dayjs/plugin/duration.js';
declare module 'dayjs' {
interface Dayjs {
mod(value: number, unit?: inst.ManipulateType): duration.Duration;
mod(value: duration.Duration): duration.Duration;
}
}
export const mod: PluginFunc = (_opts, dayjsClass, dayjsFactory) => {
dayjsFactory.extend(duration);
dayjsClass.prototype['mod'] = function (
value: number | Duration,
unit?: ManipulateType,
) {
let dur: Duration;
if (isDuration(value)) {
dur = value;
} else {
dur = dayjsFactory.duration(value, unit ?? 'milliseconds');
}
const djs = this as Dayjs;
return dayjsFactory.duration((djs.unix() * 1000) % dur.asMilliseconds());
};
};
export const min = (l: duration.Duration, r: duration.Duration) => {
return l.asMilliseconds() < r.asMilliseconds() ? l : r;
};
export const max = (l: duration.Duration, r: duration.Duration) => {
return l.asMilliseconds() >= r.asMilliseconds() ? l : r;
};

View File

@@ -31,16 +31,18 @@
"noErrorTruncation": true
},
"files": [
"src/types/fastify.d.ts"
"src/types/fastify.d.ts",
],
"include": [
"./src/**/*.ts",
"./scripts/**/*.ts",
"__tests__/**/*",
"mikro-orm.prod.config.ts"
"mikro-orm.prod.config.ts",
"../types/src/**/*.ts",
],
"exclude": [
"./build/**/*",
"../types/build/**/*",
"./**/*.ignore.ts",
"./streams/**/*.ts"
]

View File

@@ -41,7 +41,7 @@
"@types/node": "18.18.0",
"rimraf": "^5.0.5",
"tsup": "^8.0.2",
"typescript": "5.2.2",
"typescript": "^5.4.3",
"ts-essentials": "^9.4.1"
}
}

View File

@@ -1,5 +1,5 @@
import inst, { Dayjs, ManipulateType, PluginFunc } from 'dayjs';
import duration, { type Duration } from 'dayjs/plugin/duration.js';
import duration from 'dayjs/plugin/duration.js';
declare module 'dayjs' {
interface Dayjs {
@@ -19,10 +19,10 @@ export const mod: PluginFunc = (_opts, dayjsClass, dayjsFactory) => {
// be timezone adjusted (i.e. duration - new Date().getTimezoneOffset())
// before being used with new timestamps.
dayjsClass.prototype['mod'] = function (
value: number | Duration,
value: number | duration.Duration,
unit?: ManipulateType,
) {
let dur: Duration;
let dur: duration.Duration;
if (dayjsFactory.isDuration(value)) {
dur = value;
} else {

View File

@@ -1,3 +1,4 @@
export * from './plexSearchUtil.js';
import { ChannelProgram } from '@tunarr/types';
import isFunction from 'lodash-es/isFunction.js';
import { MarkRequired } from 'ts-essentials';

View File

@@ -0,0 +1,56 @@
import { PlexFilter, PlexSort } from '@tunarr/types/api';
import { isUndefined } from 'lodash-es';
// Commenting this out for now because it breaks the build but we will need it
// later.
//const PlexFilterFieldPattern = /(?:([a-zA-Z]*)\.)?([a-zA-Z]+)([!<>=&]*)/;
// TODO make this a hook
export function buildPlexFilterKey(query: PlexFilter | undefined): string[] {
if (isUndefined(query)) {
return [];
}
const filters: string[] = [];
switch (query.type) {
case 'op': {
if (query.children.length === 0) {
break; // Ignore this node for now
} else if (query.children.length === 1) {
filters.push(...buildPlexFilterKey(query.children[0]));
} else {
filters.push('push=1');
for (const child of query.children) {
filters.push(...buildPlexFilterKey(child));
filters.push(`${query.op}=1`);
}
filters.push('pop=1');
}
break;
}
case 'value': {
// Need to validate
const operator = query.op.substring(0, query.op.length - 1);
filters.push(`${query.field}${operator}=${query.value}`);
break;
}
}
return filters;
}
export function buildPlexSortKey(sort: PlexSort | undefined): string[] {
if (isUndefined(sort)) {
return [];
}
let key: string;
if (sort.direction === 'asc') {
key = sort.field;
} else {
key = `${sort.field}:desc`;
}
return ['sort=' + key];
}

View File

@@ -3,7 +3,11 @@
"pipeline": {
"clean": {},
"build": {
"dependsOn": ["clean", "^build"],
"dependsOn": ["^build"],
"outputs": ["build/**", "dist/**"]
},
"clean-build": {
"dependsOn": ["clean", "build"],
"outputs": ["build/**", "dist/**"]
},
"bundle": {

View File

@@ -21,7 +21,7 @@
"eslint": "8.45.0",
"rimraf": "^5.0.5",
"tsup": "^8.0.2",
"typescript": "5.2.2"
"typescript": "^5.4.3"
},
"dependencies": {
"zod": "^3.22.4"

View File

@@ -1,28 +1,33 @@
import { z } from 'zod';
import { PlexSearchSchema } from './plexSearch.js';
type Alias<T> = T & { _?: never };
//
// Time slots
//
export const MovieProgrammingTimeSlotSchema = z.object({
type: z.literal('movie'),
});
export const ShowProgrammingTimeSlotSchema = z.object({
type: z.literal('show'),
showId: z.string(),
});
export const FlexProgrammingTimeSlotSchema = z.object({
type: z.literal('flex'),
});
export type MovieProgrammingTimeSlot = Alias<
z.infer<typeof MovieProgrammingTimeSlotSchema>
export type MovieProgrammingTimeSlot = z.infer<
typeof MovieProgrammingTimeSlotSchema
>;
export type ShowProgrammingTimeSlot = Alias<
z.infer<typeof ShowProgrammingTimeSlotSchema>
export type ShowProgrammingTimeSlot = z.infer<
typeof ShowProgrammingTimeSlotSchema
>;
export type FlexProgrammingTimeSlot = Alias<
z.infer<typeof FlexProgrammingTimeSlotSchema>
export type FlexProgrammingTimeSlot = z.infer<
typeof FlexProgrammingTimeSlotSchema
>;
export function slotProgrammingId(slot: TimeSlotProgramming) {
@@ -39,9 +44,7 @@ export const TimeSlotProgrammingSchema = z.discriminatedUnion('type', [
FlexProgrammingTimeSlotSchema,
]);
export type TimeSlotProgramming = Alias<
z.infer<typeof TimeSlotProgrammingSchema>
>;
export type TimeSlotProgramming = z.infer<typeof TimeSlotProgrammingSchema>;
export const TimeSlotSchema = z.object({
order: z.union([z.literal('next'), z.literal('shuffle')]),
@@ -49,7 +52,7 @@ export const TimeSlotSchema = z.object({
startTime: z.number(), // Offset from midnight in millis
});
export type TimeSlot = Alias<z.infer<typeof TimeSlotSchema>>;
export type TimeSlot = z.infer<typeof TimeSlotSchema>;
export const TimeSlotScheduleSchema = z.object({
type: z.literal('time'),
@@ -63,14 +66,18 @@ export const TimeSlotScheduleSchema = z.object({
startTomorrow: z.boolean().optional(),
});
export type TimeSlotSchedule = Alias<z.infer<typeof TimeSlotScheduleSchema>>;
export type TimeSlotSchedule = z.infer<typeof TimeSlotScheduleSchema>;
//
// Random slots
//
export const MovieProgrammingRandomSlotSchema = z.object({
type: z.literal('movie'),
});
export type MovieProgrammingRandomSlot = Alias<
z.infer<typeof MovieProgrammingRandomSlotSchema>
export type MovieProgrammingRandomSlot = z.infer<
typeof MovieProgrammingRandomSlotSchema
>;
export const ShowProgrammingRandomSlotSchema = z.object({
@@ -78,16 +85,16 @@ export const ShowProgrammingRandomSlotSchema = z.object({
showId: z.string(),
});
export type ShowProgrammingRandomSlot = Alias<
z.infer<typeof ShowProgrammingRandomSlotSchema>
export type ShowProgrammingRandomSlot = z.infer<
typeof ShowProgrammingRandomSlotSchema
>;
export const FlexProgrammingRandomSlotSchema = z.object({
type: z.literal('flex'),
});
export type FlexProgrammingRandomSlot = Alias<
z.infer<typeof FlexProgrammingRandomSlotSchema>
export type FlexProgrammingRandomSlot = z.infer<
typeof FlexProgrammingRandomSlotSchema
>;
export const RandomSlotProgrammingSchema = z.discriminatedUnion('type', [
@@ -96,9 +103,7 @@ export const RandomSlotProgrammingSchema = z.discriminatedUnion('type', [
FlexProgrammingRandomSlotSchema,
]);
export type RandomSlotProgramming = Alias<
z.infer<typeof RandomSlotProgrammingSchema>
>;
export type RandomSlotProgramming = z.infer<typeof RandomSlotProgrammingSchema>;
export const RandomSlotSchema = z.object({
order: z.string().optional(), // Present for show slots only
@@ -110,7 +115,7 @@ export const RandomSlotSchema = z.object({
programming: RandomSlotProgrammingSchema,
});
export type RandomSlot = Alias<z.infer<typeof RandomSlotSchema>>;
export type RandomSlot = z.infer<typeof RandomSlotSchema>;
export const RandomSlotScheduleSchema = z.object({
type: z.literal('random'),
@@ -124,15 +129,71 @@ export const RandomSlotScheduleSchema = z.object({
periodMs: z.number().optional(),
});
// This is used on the frontend too, we will move common
// types eventually.
export type RandomSlotSchedule = Alias<
z.infer<typeof RandomSlotScheduleSchema>
export type RandomSlotSchedule = z.infer<typeof RandomSlotScheduleSchema>;
//
// Dynamic content
//
export const DynamicContentCronUpdaterConfigSchema = z.object({
// Unique ID to track scheduling. Not for use outside of bookkeeping
_id: z.string(),
type: z.literal('cron'),
// Cron schedule string compatible with node-schedule
schedule: z.string(),
});
export type DynamicContentCronUpdaterConfig = z.infer<
typeof DynamicContentCronUpdaterConfigSchema
>;
export const DynamicContentUpdaterConfigSchema = z.discriminatedUnion('type', [
DynamicContentCronUpdaterConfigSchema,
]);
export type DynamicContentUpdaterConfig = z.infer<
typeof DynamicContentUpdaterConfigSchema
>;
const WithEnabledSchema = z.object({
enabled: z.boolean().default(true),
});
export const DynamicContentConfigPlexSourceSchema = z
.object({
type: z.literal('plex'),
plexServerId: z.string().min(1), // server name or unique ID
plexLibraryKey: z.string().min(1),
search: PlexSearchSchema.optional(),
updater: DynamicContentUpdaterConfigSchema,
})
.merge(WithEnabledSchema);
export type DynamicContentConfigPlexSource = z.infer<
typeof DynamicContentConfigPlexSourceSchema
>;
export const DynamicContentConfigSourceSchema = z.discriminatedUnion('type', [
DynamicContentConfigPlexSourceSchema,
]);
export type DynamicContentConfigSource = z.infer<
typeof DynamicContentConfigSourceSchema
>;
export const DynamicContentConfigSchema = z
.object({
contentSources: z.array(DynamicContentConfigSourceSchema).nonempty(),
})
.merge(WithEnabledSchema);
export type DynamicContentConfig = z.infer<typeof DynamicContentConfigSchema>;
//
// Lineups
//
export const LineupScheduleSchema = z.discriminatedUnion('type', [
TimeSlotScheduleSchema,
RandomSlotScheduleSchema,
]);
export type LineupSchedule = Alias<z.infer<typeof LineupScheduleSchema>>;
export type LineupSchedule = z.infer<typeof LineupScheduleSchema>;

View File

@@ -11,8 +11,7 @@ import {
} from './Scheduling.js';
export * from './Scheduling.js';
type Alias<T> = T & { _?: never };
export * from './plexSearch.js';
export const IdPathParamSchema = z.object({
id: z.string(),
@@ -45,8 +44,8 @@ export const CreateCustomShowRequestSchema = z.object({
),
});
export type CreateCustomShowRequest = Alias<
z.infer<typeof CreateCustomShowRequestSchema>
export type CreateCustomShowRequest = z.infer<
typeof CreateCustomShowRequestSchema
>;
export const CreateFillerListRequestSchema = z.object({
@@ -56,15 +55,15 @@ export const CreateFillerListRequestSchema = z.object({
),
});
export type CreateFillerListRequest = Alias<
z.infer<typeof CreateFillerListRequestSchema>
export type CreateFillerListRequest = z.infer<
typeof CreateFillerListRequestSchema
>;
export const UpdateFillerListRequestSchema =
CreateFillerListRequestSchema.partial();
export type UpdateFillerListRequest = Alias<
z.infer<typeof UpdateFillerListRequestSchema>
export type UpdateFillerListRequest = z.infer<
typeof UpdateFillerListRequestSchema
>;
export const BasicIdParamSchema = z.object({
@@ -112,8 +111,8 @@ export const UpdateChannelProgrammingRequestSchema = z.discriminatedUnion(
],
);
export type UpdateChannelProgrammingRequest = Alias<
z.infer<typeof UpdateChannelProgrammingRequestSchema>
export type UpdateChannelProgrammingRequest = z.infer<
typeof UpdateChannelProgrammingRequestSchema
>;
export const UpdatePlexServerRequestSchema = PlexServerSettingsSchema.partial({
@@ -123,8 +122,8 @@ export const UpdatePlexServerRequestSchema = PlexServerSettingsSchema.partial({
id: true,
});
export type UpdatePlexServerRequest = Alias<
z.infer<typeof UpdatePlexServerRequestSchema>
export type UpdatePlexServerRequest = z.infer<
typeof UpdatePlexServerRequestSchema
>;
export const InsertPlexServerRequestSchema = PlexServerSettingsSchema.partial({
@@ -136,8 +135,8 @@ export const InsertPlexServerRequestSchema = PlexServerSettingsSchema.partial({
id: true,
});
export type InsertPlexServerRequest = Alias<
z.infer<typeof InsertPlexServerRequestSchema>
export type InsertPlexServerRequest = z.infer<
typeof InsertPlexServerRequestSchema
>;
export const VersionApiResponseSchema = z.object({

View File

@@ -0,0 +1,61 @@
import { z } from 'zod';
export const PlexFilterValueNodeSchema = z.object({
type: z.literal('value'),
field: z.string(),
// We should start populating this to know how to parse out the value, if necessary
fieldType: z.string().optional(),
op: z.string(),
value: z.string(),
});
export type PlexFilterValueNode = {
type: 'value';
field: string;
fieldType?: string;
op: string;
value: string;
};
export type PlexFilterOperatorNode = {
type: 'op';
op: 'or' | 'and';
children: PlexFilter[];
};
// Hack to get recursive types working in zod
export const PlexFilterOperatorNodeSchema: z.ZodType<PlexFilterOperatorNode> =
z.lazy(() =>
z.object({
type: z.literal('op'),
op: z.union([z.literal('or'), z.literal('and')]),
children: PlexFilterSchema.array(),
}),
);
export const PlexFilterSchema = PlexFilterOperatorNodeSchema.or(
PlexFilterValueNodeSchema,
);
export type PlexFilter = PlexFilterOperatorNode | PlexFilterValueNode;
export const PlexSortSchema = z.object({
field: z.string(),
direction: z.union([z.literal('asc'), z.literal('desc')]),
});
export type PlexSort = z.infer<typeof PlexSortSchema>;
export const PlexSearchSchema = z.object({
filter: PlexFilterSchema.optional(),
sort: PlexSortSchema.optional(),
});
export type PlexSearch = z.infer<typeof PlexSearchSchema>;
// A PlexSearch but with a reference to the
// library it is for.
export type ScopedPlexSearch = {
search: PlexSearch;
libraryKey: string;
};

View File

@@ -1,6 +1,7 @@
export * from './dvr.js';
import z from 'zod';
export * from './dvr.js';
type Alias<t> = t & { _?: never };
// Marker field used to allow directories and non-directories both have
@@ -606,6 +607,14 @@ export type PlexMedia = Alias<
| PlexMusicTrack
>;
export type PlexTerminalMedia = PlexMovie | PlexEpisode | PlexMusicTrack; // Media that has no children
// Results you might get by looking up the children of a parent node type
export type PlexChildMediaViewType =
| PlexSeasonView
| PlexEpisodeView
| PlexMusicAlbumView
| PlexMusicTrackView;
export type PlexParentMediaType =
| PlexTvShow
| PlexTvSeason

View File

@@ -1,5 +1,8 @@
import z from 'zod';
import { LineupScheduleSchema } from '../api/Scheduling.js';
import {
DynamicContentConfigSchema,
LineupScheduleSchema,
} from '../api/Scheduling.js';
import {
PlexEpisodeSchema,
PlexMovieSchema,
@@ -173,4 +176,5 @@ export const CondensedChannelProgrammingSchema = z.object({
lineup: z.array(CondensedChannelProgramSchema),
startTimeOffsets,
schedule: LineupScheduleSchema.optional(),
dynamicContentConfig: DynamicContentConfigSchema.optional(),
});

View File

@@ -6,13 +6,15 @@
"ES2022"
],
"declaration": true,
"declarationMap": true,
"moduleResolution": "node16",
"rootDir": ".",
"outDir": "build",
"allowSyntheticDefaultImports": true,
"importHelpers": true,
"alwaysStrict": true,
"sourceMap": true,
"sourceMap": false,
"inlineSourceMap": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,

View File

@@ -11,4 +11,6 @@ export default defineConfig({
dts: true,
outDir: 'build',
splitting: false,
sourcemap: false,
target: 'esnext',
});

View File

@@ -62,7 +62,7 @@
"nodemon": "^3.0.3",
"openapi-zod-client": "^1.14.0",
"ts-essentials": "^9.4.1",
"typescript": "^5.2.2",
"typescript": "^5.4.3",
"vite": "^4.4.5"
}
}

View File

@@ -1,4 +1,5 @@
import {
Bolt,
FreeBreakfast as BreaksIcon,
Expand as FlexIcon,
KeyboardArrowDown as KeyboardArrowDownIcon,
@@ -17,6 +18,7 @@ import {
alpha,
styled,
} from '@mui/material';
import { isNull } from 'lodash-es';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import AddBreaksModal from '../programming_controls/AddBreaksModal';
@@ -77,7 +79,7 @@ export default function AddProgrammingButton() {
const [addBreaksModalOpen, setAddBreaksModalOpen] = useState(false);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const open = !isNull(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
@@ -117,14 +119,13 @@ export default function AddProgrammingButton() {
to="add"
startIcon={<MediaIcon />}
>
Add Media
Schedule
</Button>
<Button onClick={handleClick}>
<KeyboardArrowDownIcon />
</Button>
</ButtonGroup>
<StyledMenu
id="demo-customized-menu"
MenuListProps={{
'aria-labelledby': 'demo-customized-button',
}}
@@ -132,6 +133,9 @@ export default function AddProgrammingButton() {
open={open}
onClose={handleClose}
>
<MenuItem disableRipple>
<Bolt /> Make Dynamic
</MenuItem>
<Tooltip
title="Add TV Shows or Movies to programming list."
placement="right"

View File

@@ -1,4 +1,4 @@
import { Box, Button, Paper, Stack, Typography } from '@mui/material';
import { Box, Button, Paper, Stack } from '@mui/material';
import { DateTimePicker } from '@mui/x-date-pickers';
import dayjs, { Dayjs } from 'dayjs';
import { createContext, useState } from 'react';
@@ -25,7 +25,7 @@ export const ScheduleControlsContext = createContext<ScheduleControlsType>({
});
export function ChannelProgrammingConfig() {
const { currentEntity: channel, programList } = usePreloadedChannelEdit();
const { currentEntity: channel } = usePreloadedChannelEdit();
const programsDirty = useStore((s) => s.channelEditor.dirty.programs);
@@ -36,7 +36,6 @@ export function ChannelProgrammingConfig() {
};
const startTime = channel ? dayjs(channel.startTime) : dayjs();
const endTime = startTime.add(channel?.duration ?? 0, 'milliseconds');
const [showScheduleControls, setShowScheduleControls] =
useState<boolean>(false);
@@ -50,32 +49,11 @@ export function ChannelProgrammingConfig() {
>
<Box display="flex" flexDirection="column">
<Paper sx={{ p: 2 }}>
<Box display="flex" justifyContent={'flex-start'}>
<DateTimePicker
label="Programming Start"
value={startTime}
onChange={(newDateTime) => handleStartTimeChange(newDateTime)}
sx={{ mr: 2 }}
/>
<DateTimePicker label="Programming End" value={endTime} disabled />
<Stack
direction={{ xs: 'column', sm: 'row' }}
sx={{
display: 'flex',
pt: 1,
mb: 2,
columnGap: 1,
alignItems: 'center',
justifyContent: 'flex-end',
flexGrow: 1,
}}
>
<AdjustScheduleControls />
</Stack>
</Box>
<Box display="flex" justifyContent={'flex-start'}></Box>
<Stack
direction={{ xs: 'column', sm: 'row' }}
gap={{ xs: 1 }}
sx={{
display: 'flex',
pt: 1,
@@ -84,9 +62,30 @@ export function ChannelProgrammingConfig() {
alignItems: 'center',
}}
>
<Typography variant="caption" sx={{ flexGrow: 1 }}>
{programList.length} program{programList.length === 1 ? '' : 's'}
</Typography>
<Box sx={{ mr: { sm: 2 }, flexGrow: 1 }}>
<DateTimePicker
label="Programming Start"
value={startTime}
onChange={(newDateTime) => handleStartTimeChange(newDateTime)}
slotProps={{ textField: { size: 'small' } }}
/>
</Box>
{showScheduleControls && (
<Stack
direction={{ xs: 'column', sm: 'row' }}
sx={{
display: 'flex',
pt: 1,
mb: 2,
columnGap: 1,
alignItems: 'center',
justifyContent: 'flex-end',
flexGrow: 1,
}}
>
<AdjustScheduleControls />
</Stack>
)}
<Button
variant="contained"
onClick={() => resetLineup()}
@@ -100,7 +99,7 @@ export function ChannelProgrammingConfig() {
</Stack>
<ChannelProgrammingList
virtualListProps={{ width: '100%', height: 400, itemSize: 35 }}
virtualListProps={{ width: '100%', height: 600, itemSize: 35 }}
/>
</Paper>
</Box>

View File

@@ -30,6 +30,7 @@ import {
FixedSizeListProps,
ListChildComponentProps,
} from 'react-window';
import { betterHumanize } from '../../helpers/dayjs.ts';
import { alternateColors, channelProgramUniqueId } from '../../helpers/util.ts';
import {
deleteProgram,
@@ -49,11 +50,13 @@ type Props = {
// If given, the list will be rendered using react-window
virtualListProps?: Omit<FixedSizeListProps, 'itemCount' | 'children'>;
enableDnd?: boolean;
showProgramCount?: boolean;
};
const defaultProps: Props = {
programListSelector: materializedProgramListSelector,
enableDnd: true,
showProgramCount: true,
};
type ListItemProps = {
@@ -100,7 +103,9 @@ const programListItemTitleFormatter = (() => {
return (program: ChannelProgram) => {
const title = itemTitle(program);
const dur = dayjs.duration({ milliseconds: program.duration }).humanize();
const dur = betterHumanize(
dayjs.duration({ milliseconds: program.duration }),
);
return `${title} - (${dur})`;
};
})();
@@ -248,6 +253,7 @@ export default function ChannelProgrammingList({
programList: passedProgramList,
programListSelector = defaultProps.programListSelector,
virtualListProps,
showProgramCount = defaultProps.showProgramCount,
enableDnd = defaultProps.enableDnd,
}: Props) {
const channel = useStore((s) => s.channelEditor.currentEntity);
@@ -335,12 +341,8 @@ export default function ChannelProgrammingList({
if (programList.length === 0) {
return (
<Box>
<Typography
align="center"
width={'100%'}
sx={{ my: 4, fontStyle: 'italic' }}
>
<Box width={'100%'}>
<Typography align="center" sx={{ my: 4, fontStyle: 'italic' }}>
No programming added yet
</Typography>
</Box>
@@ -348,7 +350,19 @@ export default function ChannelProgrammingList({
}
return (
<>
<Box width="100%">
{showProgramCount && (
<Box sx={{ width: '100%', mb: 1, textAlign: 'right' }}>
<Typography variant="caption" sx={{ flexGrow: 1, mr: 2 }}>
{programList.length} program{programList.length === 1 ? '' : 's'}
</Typography>
{channel?.duration && (
<Typography variant="caption">
{dayjs.duration(channel.duration).humanize()}
</Typography>
)}
</Box>
)}
{virtualListProps ? (
<FixedSizeList
{...virtualListProps}
@@ -359,7 +373,7 @@ export default function ChannelProgrammingList({
{ProgramRow}
</FixedSizeList>
) : (
<Box ref={drop} sx={{ flex: 1, maxHeight: 400, overflowY: 'auto' }}>
<Box ref={drop} sx={{ flex: 1, maxHeight: 600, overflowY: 'auto' }}>
<List dense>{renderPrograms()}</List>
</Box>
)}
@@ -368,7 +382,7 @@ export default function ChannelProgrammingList({
onClose={() => setFocusedProgramDetails(undefined)}
program={focusedProgramDetails}
/>
</>
</Box>
);
};

View File

@@ -16,6 +16,11 @@ import {
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import { DatePicker } from '@mui/x-date-pickers';
import {
PlexFilter,
PlexFilterOperatorNode,
PlexFilterValueNode,
} from '@tunarr/types/api';
import { PlexFilterResponseMeta, PlexFilterType } from '@tunarr/types/plex';
import dayjs from 'dayjs';
import { find, first, isUndefined, map, size } from 'lodash-es';
@@ -35,11 +40,6 @@ import {
useForm,
useFormContext,
} from 'react-hook-form';
import {
PlexFilter,
PlexFilterOperatorNode,
PlexFilterValueNode,
} from '../../helpers/plexSearchUtil.ts';
import {
usePlexTags,
useSelectedLibraryPlexFilters,

View File

@@ -1,6 +1,22 @@
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import { padStart } from 'lodash-es';
dayjs.extend(duration);
dayjs.extend(relativeTime);
export function betterHumanize(dur: duration.Duration) {
const hrs = Math.floor(dur.asHours());
const mins = Math.floor(dur.asMinutes() % 60);
if (hrs >= 1) {
const s = hrs >= 2 ? 's' : '';
const ms = mins === 1 ? '' : 's';
return `${hrs} hour${s} ${padStart(mins.toString(), 2, '0')} min${ms}`;
} else if (mins >= 1) {
const ms = mins === 1 ? '' : 's';
return `${padStart(mins.toString(), 2, '0')} min${ms}`;
} else {
return dur.humanize();
}
}

View File

@@ -1,36 +1,10 @@
import { PlexFilter, PlexSort } from '@tunarr/types/api';
import { isUndefined } from 'lodash-es';
// Commenting this out for now because it breaks the build but we will need it
// later.
//const PlexFilterFieldPattern = /(?:([a-zA-Z]*)\.)?([a-zA-Z]+)([!<>=&]*)/;
export type PlexFilterValueNode = {
type: 'value';
field: string;
op: string;
value: string;
};
export type PlexFilterAndNode = {
type: 'op';
op: 'and';
children: PlexFilter[];
};
export type PlexFilterOrNode = {
type: 'op';
op: 'or';
children: PlexFilter[];
};
export type PlexFilterOperatorNode = PlexFilterAndNode | PlexFilterOrNode;
export type PlexFilter = PlexFilterOperatorNode | PlexFilterValueNode;
export type PlexSort = { field: string; direction: 'asc' | 'desc' };
export type PlexSearch = {
filter?: PlexFilter;
sort?: PlexSort;
};
// TODO make this a hook
export function buildPlexFilterKey(query: PlexFilter | undefined): string[] {
if (isUndefined(query)) {

View File

@@ -13,6 +13,7 @@ import { styled } from '@mui/material/styles';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Task } from '@tunarr/types';
import dayjs from 'dayjs';
import { map } from 'lodash-es';
import { apiClient } from '../../external/api.ts';
const StyledLoopIcon = styled(Loop)({
@@ -40,14 +41,29 @@ function TaskRow({ task }: { task: Task }) {
queryKey: ['channels', 'all', 'guide'],
});
},
onMutate: async (id: string) => {
await queryClient.cancelQueries({ queryKey: ['jobs'] });
const prevJobs = queryClient.getQueryData<Task[]>(['jobs']);
const now = new Date();
queryClient.setQueryData(
['jobs'],
map(prevJobs, (j) => {
return j.id === id
? {
...j,
lastExecution: now,
lastExecutionEpoch: now.getTime() / 1000,
}
: j;
}),
);
},
});
const runJobWithId = (id: string) => {
runJobMutation.mutate(id);
};
console.log(task);
return (
<TableRow key={task.id}>
<TableCell>{task.name}</TableCell>

View File

@@ -12,7 +12,6 @@ import {
FillerList,
FillerListProgramming,
} from '@tunarr/types';
import { isPlexEpisode, isPlexMusicTrack } from '@tunarr/types/plex';
import { Draft } from 'immer';
import {
chain,
@@ -236,53 +235,22 @@ const plexMediaToContentProgram = (
media: EnrichedPlexMedia,
): ContentProgram => {
const uniqueId = createExternalId('plex', media.serverName, media.key);
// TODO Handle music tracks
if (isPlexEpisode(media)) {
return {
id: media.id ?? uniqueId,
persisted: !isNil(media.id),
originalProgram: media,
duration: media.duration,
externalSourceName: media.serverName,
externalSourceType: 'plex',
externalKey: media.key,
uniqueId: uniqueId,
type: 'content',
subtype: 'episode',
title: media.grandparentTitle,
episodeTitle: media.title,
episodeNumber: media.index,
seasonNumber: media.parentIndex,
};
} else if (isPlexMusicTrack(media)) {
return {
id: media.id ?? uniqueId,
persisted: !isNil(media.id),
originalProgram: media,
duration: media.duration,
externalSourceName: media.serverName,
externalSourceType: 'plex',
uniqueId: uniqueId,
type: 'content',
subtype: 'track',
title: media.title,
artistName: media.grandparentTitle,
albumName: media.parentTitle,
};
} else {
return {
id: media.id ?? uniqueId,
persisted: !isNil(media.id),
originalProgram: media,
duration: media.duration,
externalSourceName: media.serverName,
externalSourceType: 'plex',
uniqueId: uniqueId,
type: 'content',
subtype: 'movie',
title: media.title,
};
}
return {
id: media.id ?? uniqueId,
persisted: !isNil(media.id),
originalProgram: media,
duration: media.duration,
externalSourceName: media.serverName,
externalSourceType: 'plex',
externalKey: media.key,
uniqueId: uniqueId,
type: 'content',
subtype: media.type,
title: media.type === 'episode' ? media.grandparentTitle : media.title,
episodeTitle: media.type === 'episode' ? media.title : undefined,
episodeNumber: media.type === 'episode' ? media.index : undefined,
seasonNumber: media.type === 'episode' ? media.parentIndex : undefined,
};
};
export const addMediaToCurrentChannel = (programs: AddedMedia[]) =>

View File

@@ -5,9 +5,9 @@ import {
CustomShow,
FillerList,
} from '@tunarr/types';
import { DynamicContentConfig, LineupSchedule } from '@tunarr/types/api';
import { StateCreator } from 'zustand';
import { UICondensedChannelProgram, UIIndex } from '../../types/index.ts';
import { LineupSchedule } from '@tunarr/types/api';
// Represents a program listing in the editor
export interface ProgrammingEditorState<EntityType, ProgramType> {
@@ -35,6 +35,7 @@ export type ChannelEditorState = ProgrammingEditorState<
// the lookup record for programs by ID
programLookup: Record<string, ContentProgram>;
schedule?: LineupSchedule;
dynamicContentConfiguration?: DynamicContentConfig;
};
export interface EditorsState {

View File

@@ -1,4 +1,5 @@
import { PlexServerSettings } from '@tunarr/types';
import { PlexFilter, PlexSort } from '@tunarr/types/api';
import {
PlexLibrarySection,
PlexMedia,
@@ -8,8 +9,6 @@ import {
import { map, reject, some } from 'lodash-es';
import useStore from '..';
import {
PlexFilter,
PlexSort,
buildPlexFilterKey,
buildPlexSortKey,
} from '../../helpers/plexSearchUtil.ts';

View File

@@ -1,7 +1,7 @@
import { CustomProgram, CustomShow, PlexServerSettings } from '@tunarr/types';
import { PlexSearch } from '@tunarr/types/api';
import { PlexLibrarySection, PlexMedia } from '@tunarr/types/plex';
import { StateCreator } from 'zustand';
import { PlexSearch } from '../../helpers/plexSearchUtil';
type ServerName = string;
type PlexItemGuid = string;