This commit is contained in:
Aleksi Lassila
2024-10-19 00:55:48 +03:00
parent fc7f2d5312
commit f935da60b1
38 changed files with 1447 additions and 426 deletions

View File

@@ -1,32 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddPlayStateAndMyList1721052160110 implements MigrationInterface {
name = 'AddPlayStateAndMyList1721052160110'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "play_state" ("id" varchar PRIMARY KEY NOT NULL, "tmdbId" integer NOT NULL, "seasonNumber" integer, "episodeNumber" integer, "progress" double NOT NULL, "watched" boolean NOT NULL DEFAULT (0), "showInUpNext" boolean NOT NULL DEFAULT (1), "userId" varchar)`);
await queryRunner.query(`CREATE TABLE "my_list_item" ("id" varchar PRIMARY KEY NOT NULL, "tmdbId" integer NOT NULL, "userId" varchar, CONSTRAINT "UQ_a14dd1370f05c5feffe21f043e9" UNIQUE ("tmdbId"))`);
await queryRunner.query(`CREATE TABLE "temporary_play_state" ("id" varchar PRIMARY KEY NOT NULL, "tmdbId" integer NOT NULL, "seasonNumber" integer, "episodeNumber" integer, "progress" double NOT NULL, "watched" boolean NOT NULL DEFAULT (0), "showInUpNext" boolean NOT NULL DEFAULT (1), "userId" varchar, CONSTRAINT "FK_76a6e68bbf655b2a2bc54916803" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`);
await queryRunner.query(`INSERT INTO "temporary_play_state"("id", "tmdbId", "seasonNumber", "episodeNumber", "progress", "watched", "showInUpNext", "userId") SELECT "id", "tmdbId", "seasonNumber", "episodeNumber", "progress", "watched", "showInUpNext", "userId" FROM "play_state"`);
await queryRunner.query(`DROP TABLE "play_state"`);
await queryRunner.query(`ALTER TABLE "temporary_play_state" RENAME TO "play_state"`);
await queryRunner.query(`CREATE TABLE "temporary_my_list_item" ("id" varchar PRIMARY KEY NOT NULL, "tmdbId" integer NOT NULL, "userId" varchar, CONSTRAINT "UQ_a14dd1370f05c5feffe21f043e9" UNIQUE ("tmdbId"), CONSTRAINT "FK_3657174b1c5a1bbcb4e75a237bd" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`);
await queryRunner.query(`INSERT INTO "temporary_my_list_item"("id", "tmdbId", "userId") SELECT "id", "tmdbId", "userId" FROM "my_list_item"`);
await queryRunner.query(`DROP TABLE "my_list_item"`);
await queryRunner.query(`ALTER TABLE "temporary_my_list_item" RENAME TO "my_list_item"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "my_list_item" RENAME TO "temporary_my_list_item"`);
await queryRunner.query(`CREATE TABLE "my_list_item" ("id" varchar PRIMARY KEY NOT NULL, "tmdbId" integer NOT NULL, "userId" varchar, CONSTRAINT "UQ_a14dd1370f05c5feffe21f043e9" UNIQUE ("tmdbId"))`);
await queryRunner.query(`INSERT INTO "my_list_item"("id", "tmdbId", "userId") SELECT "id", "tmdbId", "userId" FROM "temporary_my_list_item"`);
await queryRunner.query(`DROP TABLE "temporary_my_list_item"`);
await queryRunner.query(`ALTER TABLE "play_state" RENAME TO "temporary_play_state"`);
await queryRunner.query(`CREATE TABLE "play_state" ("id" varchar PRIMARY KEY NOT NULL, "tmdbId" integer NOT NULL, "seasonNumber" integer, "episodeNumber" integer, "progress" double NOT NULL, "watched" boolean NOT NULL DEFAULT (0), "showInUpNext" boolean NOT NULL DEFAULT (1), "userId" varchar)`);
await queryRunner.query(`INSERT INTO "play_state"("id", "tmdbId", "seasonNumber", "episodeNumber", "progress", "watched", "showInUpNext", "userId") SELECT "id", "tmdbId", "seasonNumber", "episodeNumber", "progress", "watched", "showInUpNext", "userId" FROM "temporary_play_state"`);
await queryRunner.query(`DROP TABLE "temporary_play_state"`);
await queryRunner.query(`DROP TABLE "my_list_item"`);
await queryRunner.query(`DROP TABLE "play_state"`);
}
}

View File

@@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddTitles1721337734378 implements MigrationInterface {
name = 'AddTitles1721337734378'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "my_list_item" ("id" varchar PRIMARY KEY NOT NULL, "tmdbId" integer NOT NULL, "userId" varchar, CONSTRAINT "UQ_a14dd1370f05c5feffe21f043e9" UNIQUE ("tmdbId"))`);
await queryRunner.query(`CREATE TABLE "media" ("id" varchar PRIMARY KEY NOT NULL, "progress" integer NOT NULL DEFAULT (0), "watched" boolean NOT NULL DEFAULT (0), "seasonNumber" integer, "episodeNumber" integer, "titleId" varchar)`);
await queryRunner.query(`CREATE TABLE "title" ("id" varchar PRIMARY KEY NOT NULL, "tmdbId" integer NOT NULL, "upNext" boolean NOT NULL DEFAULT (0), "isInLibrary" boolean NOT NULL DEFAULT (0), "userId" varchar)`);
await queryRunner.query(`CREATE TABLE "temporary_my_list_item" ("id" varchar PRIMARY KEY NOT NULL, "tmdbId" integer NOT NULL, "userId" varchar, CONSTRAINT "UQ_a14dd1370f05c5feffe21f043e9" UNIQUE ("tmdbId"), CONSTRAINT "FK_3657174b1c5a1bbcb4e75a237bd" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`);
await queryRunner.query(`INSERT INTO "temporary_my_list_item"("id", "tmdbId", "userId") SELECT "id", "tmdbId", "userId" FROM "my_list_item"`);
await queryRunner.query(`DROP TABLE "my_list_item"`);
await queryRunner.query(`ALTER TABLE "temporary_my_list_item" RENAME TO "my_list_item"`);
await queryRunner.query(`CREATE TABLE "temporary_media" ("id" varchar PRIMARY KEY NOT NULL, "progress" integer NOT NULL DEFAULT (0), "watched" boolean NOT NULL DEFAULT (0), "seasonNumber" integer, "episodeNumber" integer, "titleId" varchar, CONSTRAINT "FK_7dec680069dc12a77e96c78e27e" FOREIGN KEY ("titleId") REFERENCES "title" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`);
await queryRunner.query(`INSERT INTO "temporary_media"("id", "progress", "watched", "seasonNumber", "episodeNumber", "titleId") SELECT "id", "progress", "watched", "seasonNumber", "episodeNumber", "titleId" FROM "media"`);
await queryRunner.query(`DROP TABLE "media"`);
await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`);
await queryRunner.query(`CREATE TABLE "temporary_title" ("id" varchar PRIMARY KEY NOT NULL, "tmdbId" integer NOT NULL, "upNext" boolean NOT NULL DEFAULT (0), "isInLibrary" boolean NOT NULL DEFAULT (0), "userId" varchar, CONSTRAINT "FK_feed27b6c765803cc09d45dd6d0" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`);
await queryRunner.query(`INSERT INTO "temporary_title"("id", "tmdbId", "upNext", "isInLibrary", "userId") SELECT "id", "tmdbId", "upNext", "isInLibrary", "userId" FROM "title"`);
await queryRunner.query(`DROP TABLE "title"`);
await queryRunner.query(`ALTER TABLE "temporary_title" RENAME TO "title"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "title" RENAME TO "temporary_title"`);
await queryRunner.query(`CREATE TABLE "title" ("id" varchar PRIMARY KEY NOT NULL, "tmdbId" integer NOT NULL, "upNext" boolean NOT NULL DEFAULT (0), "isInLibrary" boolean NOT NULL DEFAULT (0), "userId" varchar)`);
await queryRunner.query(`INSERT INTO "title"("id", "tmdbId", "upNext", "isInLibrary", "userId") SELECT "id", "tmdbId", "upNext", "isInLibrary", "userId" FROM "temporary_title"`);
await queryRunner.query(`DROP TABLE "temporary_title"`);
await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`);
await queryRunner.query(`CREATE TABLE "media" ("id" varchar PRIMARY KEY NOT NULL, "progress" integer NOT NULL DEFAULT (0), "watched" boolean NOT NULL DEFAULT (0), "seasonNumber" integer, "episodeNumber" integer, "titleId" varchar)`);
await queryRunner.query(`INSERT INTO "media"("id", "progress", "watched", "seasonNumber", "episodeNumber", "titleId") SELECT "id", "progress", "watched", "seasonNumber", "episodeNumber", "titleId" FROM "temporary_media"`);
await queryRunner.query(`DROP TABLE "temporary_media"`);
await queryRunner.query(`ALTER TABLE "my_list_item" RENAME TO "temporary_my_list_item"`);
await queryRunner.query(`CREATE TABLE "my_list_item" ("id" varchar PRIMARY KEY NOT NULL, "tmdbId" integer NOT NULL, "userId" varchar, CONSTRAINT "UQ_a14dd1370f05c5feffe21f043e9" UNIQUE ("tmdbId"))`);
await queryRunner.query(`INSERT INTO "my_list_item"("id", "tmdbId", "userId") SELECT "id", "tmdbId", "userId" FROM "temporary_my_list_item"`);
await queryRunner.query(`DROP TABLE "temporary_my_list_item"`);
await queryRunner.query(`DROP TABLE "title"`);
await queryRunner.query(`DROP TABLE "media"`);
await queryRunner.query(`DROP TABLE "my_list_item"`);
}
}

View File

@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddTitleWatched1721338340748 implements MigrationInterface {
name = 'AddTitleWatched1721338340748'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "temporary_title" ("id" varchar PRIMARY KEY NOT NULL, "tmdbId" integer NOT NULL, "upNext" boolean NOT NULL DEFAULT (0), "isInLibrary" boolean NOT NULL DEFAULT (0), "userId" varchar, "watched" boolean NOT NULL DEFAULT (0), CONSTRAINT "FK_feed27b6c765803cc09d45dd6d0" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`);
await queryRunner.query(`INSERT INTO "temporary_title"("id", "tmdbId", "upNext", "isInLibrary", "userId") SELECT "id", "tmdbId", "upNext", "isInLibrary", "userId" FROM "title"`);
await queryRunner.query(`DROP TABLE "title"`);
await queryRunner.query(`ALTER TABLE "temporary_title" RENAME TO "title"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "title" RENAME TO "temporary_title"`);
await queryRunner.query(`CREATE TABLE "title" ("id" varchar PRIMARY KEY NOT NULL, "tmdbId" integer NOT NULL, "upNext" boolean NOT NULL DEFAULT (0), "isInLibrary" boolean NOT NULL DEFAULT (0), "userId" varchar, CONSTRAINT "FK_feed27b6c765803cc09d45dd6d0" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`);
await queryRunner.query(`INSERT INTO "title"("id", "tmdbId", "upNext", "isInLibrary", "userId") SELECT "id", "tmdbId", "upNext", "isInLibrary", "userId" FROM "temporary_title"`);
await queryRunner.query(`DROP TABLE "temporary_title"`);
}
}

View File

@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddTitleType1721342594807 implements MigrationInterface {
name = 'AddTitleType1721342594807'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "temporary_title" ("id" varchar PRIMARY KEY NOT NULL, "tmdbId" integer NOT NULL, "upNext" boolean NOT NULL DEFAULT (0), "isInLibrary" boolean NOT NULL DEFAULT (0), "userId" varchar, "watched" boolean NOT NULL DEFAULT (0), "type" varchar CHECK( "type" IN ('movie','series') ) NOT NULL DEFAULT ('movie'), CONSTRAINT "FK_feed27b6c765803cc09d45dd6d0" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`);
await queryRunner.query(`INSERT INTO "temporary_title"("id", "tmdbId", "upNext", "isInLibrary", "userId", "watched") SELECT "id", "tmdbId", "upNext", "isInLibrary", "userId", "watched" FROM "title"`);
await queryRunner.query(`DROP TABLE "title"`);
await queryRunner.query(`ALTER TABLE "temporary_title" RENAME TO "title"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "title" RENAME TO "temporary_title"`);
await queryRunner.query(`CREATE TABLE "title" ("id" varchar PRIMARY KEY NOT NULL, "tmdbId" integer NOT NULL, "upNext" boolean NOT NULL DEFAULT (0), "isInLibrary" boolean NOT NULL DEFAULT (0), "userId" varchar, "watched" boolean NOT NULL DEFAULT (0), CONSTRAINT "FK_feed27b6c765803cc09d45dd6d0" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`);
await queryRunner.query(`INSERT INTO "title"("id", "tmdbId", "upNext", "isInLibrary", "userId", "watched") SELECT "id", "tmdbId", "upNext", "isInLibrary", "userId", "watched" FROM "temporary_title"`);
await queryRunner.query(`DROP TABLE "temporary_title"`);
}
}

View File

@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddTmdbListId1721377870204 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`UPDATE "user"
SET "settings" = json_set("settings", '$.tmdb.libraryListId', json('""'));`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`UPDATE "user"
SET "settings" = json_remove("settings", '$.tmdb.libraryListId');`);
}
}

View File

@@ -10,13 +10,17 @@
"license": "UNLICENSED",
"dependencies": {
"@nanogiants/nestjs-swagger-api-exception-decorator": "^1.6.11",
"@nestjs/cache-manager": "^2.2.2",
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/serve-static": "^4.0.1",
"@nestjs/swagger": "^7.3.0",
"@types/axios": "^0.14.0",
"@types/express-http-proxy": "^1.6.6",
"axios": "^1.7.2",
"cache-manager": "^5.7.3",
"express-http-proxy": "^2.0.0",
"reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1",
@@ -1622,6 +1626,17 @@
"@nestjs/swagger": "^4.8.1 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@nestjs/cache-manager": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-2.2.2.tgz",
"integrity": "sha512-+n7rpU1QABeW2WV17Dl1vZCG3vWjJU1MaamWgZvbGxYE9EeCM0lVLfw3z7acgDTNwOy+K68xuQPoIMxD0bhjlA==",
"peerDependencies": {
"@nestjs/common": "^9.0.0 || ^10.0.0",
"@nestjs/core": "^9.0.0 || ^10.0.0",
"cache-manager": "<=5",
"rxjs": "^7.0.0"
}
},
"node_modules/@nestjs/cli": {
"version": "10.3.2",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.3.2.tgz",
@@ -1849,15 +1864,15 @@
}
},
"node_modules/@nestjs/platform-express": {
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.3.5.tgz",
"integrity": "sha512-IhVomwLvdLlv4zCdQK2ROT/nInk1i8m4K48lAUHJV5UVktgVmg0WbQga2/9KywaTjNbx+eWhZXXFii+vtFRAOw==",
"version": "10.3.10",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.3.10.tgz",
"integrity": "sha512-wK2ow3CZI2KFqWeEpPmoR300OB6BcBLxARV1EiClJLCj4S1mZsoCmS0YWgpk3j1j6mo0SI8vNLi/cC2iZPEPQA==",
"dependencies": {
"body-parser": "1.20.2",
"cors": "2.8.5",
"express": "4.18.3",
"express": "4.19.2",
"multer": "1.4.4-lts.1",
"tslib": "2.6.2"
"tslib": "2.6.3"
},
"funding": {
"type": "opencollective",
@@ -1868,6 +1883,11 @@
"@nestjs/core": "^10.0.0"
}
},
"node_modules/@nestjs/platform-express/node_modules/tslib": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz",
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ=="
},
"node_modules/@nestjs/schematics": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.1.1.tgz",
@@ -2208,6 +2228,15 @@
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"devOptional": true
},
"node_modules/@types/axios": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@types/axios/-/axios-0.14.0.tgz",
"integrity": "sha512-KqQnQbdYE54D7oa/UmYVMZKq7CO4l8DEENzOKc4aBRwxCXSlJXGz83flFx5L7AWrOQnmuN3kVsRdt+GZPPjiVQ==",
"deprecated": "This is a stub types definition for axios (https://github.com/mzabriskie/axios). axios provides its own type definitions, so you don't need @types/axios installed!",
"dependencies": {
"axios": "*"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -3141,8 +3170,17 @@
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz",
"integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-jest": {
"version": "29.7.0",
@@ -3372,12 +3410,12 @@
}
},
"node_modules/braces": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"dependencies": {
"fill-range": "^7.0.1"
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
@@ -3616,6 +3654,25 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"optional": true
},
"node_modules/cache-manager": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-5.7.3.tgz",
"integrity": "sha512-Vp2gd2aDm/MXdEWD0FLdOflvcVj4rdJ1FFmPUeOKq+fuL7MEUcezbTWxQmVB1TTN5Ig92CabMfi5z+HyQwVg9A==",
"dependencies": {
"eventemitter3": "^5.0.1",
"lodash.clonedeep": "^4.5.0",
"lru-cache": "^10.2.2",
"promise-coalesce": "^1.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/cache-manager/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
},
"node_modules/call-bind": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
@@ -3975,7 +4032,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
@@ -4074,9 +4130,9 @@
"dev": true
},
"node_modules/cookie": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==",
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"engines": {
"node": ">= 0.6"
}
@@ -4279,7 +4335,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
@@ -4818,6 +4873,11 @@
"node": ">= 0.6"
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -4890,16 +4950,16 @@
}
},
"node_modules/express": {
"version": "4.18.3",
"resolved": "https://registry.npmjs.org/express/-/express-4.18.3.tgz",
"integrity": "sha512-6VyCijWQ+9O7WuVMTRBTl+cjNNIzD5cY5mQ1WM8r/LEkI2u8EYpOotESNwzNlyCn3g+dmjKYI6BmNneSr/FSRw==",
"version": "4.19.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz",
"integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "1.20.2",
"content-disposition": "0.5.4",
"content-type": "~1.0.4",
"cookie": "0.5.0",
"cookie": "0.6.0",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
@@ -5088,9 +5148,9 @@
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="
},
"node_modules/fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -5222,6 +5282,25 @@
"integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
"dev": true
},
"node_modules/follow-redirects": {
"version": "1.15.6",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
"integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/foreground-child": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz",
@@ -5291,7 +5370,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dev": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
@@ -7040,6 +7118,11 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ=="
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -8293,6 +8376,14 @@
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
},
"node_modules/promise-coalesce": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/promise-coalesce/-/promise-coalesce-1.1.2.tgz",
"integrity": "sha512-zLaJ9b8hnC564fnJH6NFSOGZYYdzrAJn2JUUIwzoQb32fG2QAakpDNM+CZo1km6keXkRXRM+hml1BFAPVnPkxg==",
"engines": {
"node": ">=16"
}
},
"node_modules/promise-inflight": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
@@ -8337,6 +8428,11 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/pump": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",

View File

@@ -22,18 +22,22 @@
"typeorm": "ts-node ./node_modules/typeorm/cli",
"typeorm:run-migrations": "npm run build && npm run typeorm migration:run -- -d ./dist/data-source.js",
"typeorm:generate-migration": "npm run build && npm run typeorm -- -d ./dist/data-source.js migration:generate",
"typeorm:create-migration": "npm run build && npm run typeorm -- migration:create ./migrations/$npm_config_name",
"typeorm:create-migration": "npm run build && npm run typeorm -- migration:create",
"typeorm:revert-migration": "npm run build && npm run typeorm -- -d ./dist/data-source.js migration:revert"
},
"dependencies": {
"@nanogiants/nestjs-swagger-api-exception-decorator": "^1.6.11",
"@nestjs/cache-manager": "^2.2.2",
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/serve-static": "^4.0.1",
"@nestjs/swagger": "^7.3.0",
"@types/axios": "^0.14.0",
"@types/express-http-proxy": "^1.6.6",
"axios": "^1.7.2",
"cache-manager": "^5.7.3",
"express-http-proxy": "^2.0.0",
"reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1",

View File

@@ -7,7 +7,8 @@ import { AuthModule } from './auth/auth.module';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import { MyListModule } from './users/my-list/my-list.module';
import { PlayStateModule } from './users/play-state/play-state.module';
import { TitleModule } from './users/titles/title.module';
import { ProxyModule } from './proxy/proxy.module';
@Module({
imports: [
@@ -15,10 +16,11 @@ import { PlayStateModule } from './users/play-state/play-state.module';
UsersModule,
AuthModule,
MyListModule,
PlayStateModule,
TitleModule,
ServeStaticModule.forRoot({
rootPath: join(__dirname, '../dist'),
}),
ProxyModule,
],
controllers: [AppController],
providers: [AppService],

View File

@@ -1,3 +1,5 @@
export const TMDB_API_KEY =
'eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0YTZiMDIxZTE5Y2YxOTljMTM1NGFhMGRiMDZiOTkzMiIsInN1YiI6IjY0ODYzYWRmMDI4ZjE0MDExZTU1MDkwMiIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.yyMkZlhGOGBHtw1yvpBVUUHhu7IKVYho49MvNNKt_wY';
export const JWT_SECRET =
process.env.SECRET || Math.random().toString(36).substring(2, 15);
export const ADMIN_USERNAME = process.env.ADMIN_USERNAME;

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { TmdbModule } from './tmdb/tmdb.module';
import { CacheModule } from '@nestjs/cache-manager';
@Module({
imports: [TmdbModule],
})
export class ProxyModule {}

View File

@@ -0,0 +1,62 @@
import { All, Controller, Inject, Query, Req, Res } from '@nestjs/common';
import { Cache, CACHE_MANAGER } from '@nestjs/cache-manager';
import axios from 'axios';
import { Request, Response } from 'express';
import { TMDB_API_KEY } from '../../consts';
import * as url from 'node:url';
@Controller('proxy/tmdb')
export class TmdbController {
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
@All('*')
async proxyRequest(
@Req() req: Request,
@Res() res: Response,
@Query() queryParams,
) {
// const externalApiUrl = 'https://external.api'; // Base URL of the external API
// const url = `${externalApiUrl}${req.url}`;
// const method = req.method;
//
// let data;
// if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
// data = req.body;
// }
//
// const headers = { ...req.headers };
// const uri = Reflect.getMetadata(
// PATH_METADATA,
// TmdbController.prototype.staticServe,
// );
const uri = url.parse(req.url).pathname.replace('/api/proxy/tmdb', '');
const cacheValue = await this.cacheManager.get(uri);
if (!cacheValue) {
console.log('not cached', uri);
const r = await axios(uri, {
method: req.method,
params: queryParams,
// data: req.body,
baseURL: 'https://api.themoviedb.org',
// @ts-ignore
headers: {
// ...req.headers,
Authorization: `Bearer ${TMDB_API_KEY}`,
},
})
.then((r) => r.data)
.catch((e) => {
console.error(e);
return 'err';
// return 'err';
});
await this.cacheManager.set(uri, r);
res.send(r);
} else {
res.send(cacheValue);
}
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TmdbController } from './tmdb.controller';
import { CacheModule } from '@nestjs/cache-manager';
@Module({
imports: [CacheModule.register({ ttl: 1000 * 60 * 60 * 36 })],
controllers: [TmdbController],
})
export class TmdbModule {}

View File

@@ -1,114 +0,0 @@
import {
Body,
Controller,
Get,
InternalServerErrorException,
NotFoundException,
Param,
Put,
UseGuards,
} from '@nestjs/common';
import { AuthGuard, GetUser } from '../../auth/auth.guard';
import { User } from '../user.entity';
import { PlayStateDto, UpdatePlayStateDto } from './play-state.dtos';
import { PlayStateService } from './play-state.service';
import { ApiOkResponse } from '@nestjs/swagger';
@Controller('play-state')
@UseGuards(AuthGuard)
export class PlayStateController {
constructor(private playStateService: PlayStateService) {}
@Get(':tmdbId')
@ApiOkResponse({ type: PlayStateDto, isArray: true })
async getPlayState(
@GetUser() user: User,
@Param('tmdbId') tmdbId: number,
): Promise<PlayStateDto[]> {
const tmdbIdNumber = Number(tmdbId);
if (isNaN(tmdbIdNumber)) {
throw new Error('Invalid tmdbId');
}
return this.playStateService.getPlayState(user, tmdbIdNumber);
}
@Get(':tmdbId/season/:seasonNumber/episode/:episodeNumber')
@ApiOkResponse({ type: PlayStateDto })
async getEpisodePlayState(
@GetUser() user: User,
@Param('tmdbId') tmdbId: number,
@Param('seasonNumber') seasonNumber: number,
@Param('episodeNumber') episodeNumber: number,
): Promise<PlayStateDto> {
const tmdbIdNumber = Number(tmdbId);
const seasonNumberNumber = Number(seasonNumber);
const episodeNumberNumber = Number(episodeNumber);
if (
isNaN(tmdbIdNumber) ||
isNaN(seasonNumberNumber) ||
isNaN(episodeNumberNumber)
) {
throw new NotFoundException();
}
return this.playStateService.getEpisodePlayState(
user,
tmdbIdNumber,
seasonNumberNumber,
episodeNumberNumber,
);
}
@Put(':tmdbId')
@ApiOkResponse({ type: PlayStateDto })
async updatePlayState(
@GetUser() user: User,
@Param('tmdbId') tmdbId: number,
@Body() playStateDto: UpdatePlayStateDto,
): Promise<PlayStateDto> {
const tmdbIdNumber = Number(tmdbId);
if (isNaN(tmdbIdNumber)) {
throw new Error('Invalid tmdbId');
}
return this.playStateService.updateOrCreatePlayState({
user,
tmdbId: tmdbIdNumber,
updatePlayStateDto: playStateDto,
});
}
@Put(':tmdbId/season/:seasonNumber/episode/:episodeNumber')
@ApiOkResponse({ type: PlayStateDto })
async updateEpisodePlayState(
@GetUser() user: User,
@Param('tmdbId') tmdbId: number,
@Param('seasonNumber') seasonNumber: number,
@Param('episodeNumber') episodeNumber: number,
@Body() playStateDto: UpdatePlayStateDto,
): Promise<PlayStateDto> {
const tmdbIdNumber = Number(tmdbId);
const seasonNumberNumber = Number(seasonNumber);
const episodeNumberNumber = Number(episodeNumber);
if (
isNaN(tmdbIdNumber) ||
isNaN(seasonNumberNumber) ||
isNaN(episodeNumberNumber)
) {
throw new Error('Invalid tmdbId, seasonNumber, or episodeNumber');
}
return this.playStateService.updateOrCreatePlayState({
user,
tmdbId: tmdbIdNumber,
seasonNumber: seasonNumberNumber,
episodeNumber: episodeNumberNumber,
updatePlayStateDto: playStateDto,
});
}
}

View File

@@ -1,14 +0,0 @@
import { PlayState } from './play-state.entity';
import { OmitType, PartialType } from '@nestjs/swagger';
export class PlayStateDto extends PlayState {}
export class UpdatePlayStateDto extends PartialType(
OmitType(PlayState, [
'user',
'id',
'episodeNumber',
'tmdbId',
'seasonNumber',
]),
) {}

View File

@@ -1,13 +0,0 @@
import { Module } from '@nestjs/common';
import { PlayStateService } from './play-state.service';
import { playStateProviders } from './play-state.providers';
import { PlayStateController } from './play-state.controller';
import { DatabaseModule } from '../../database/database.module';
import { UsersModule } from '../users.module';
@Module({
imports: [DatabaseModule, UsersModule],
providers: [PlayStateService, ...playStateProviders],
controllers: [PlayStateController],
})
export class PlayStateModule {}

View File

@@ -1,13 +0,0 @@
import { DataSource } from 'typeorm';
import { DATA_SOURCE } from '../../database/database.providers';
import { PlayState } from './play-state.entity';
export const PLAY_STATE_REPOSITORY = 'PLAY_STATE_REPOSITORY';
export const playStateProviders = [
{
provide: PLAY_STATE_REPOSITORY,
useFactory: (dataSource: DataSource) => dataSource.getRepository(PlayState),
inject: [DATA_SOURCE],
},
];

View File

@@ -1,70 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import { User } from '../user.entity';
import { PLAY_STATE_REPOSITORY } from './play-state.providers';
import { Repository } from 'typeorm';
import { PlayState } from './play-state.entity';
import { UpdatePlayStateDto } from './play-state.dtos';
@Injectable()
export class PlayStateService {
constructor(
@Inject(PLAY_STATE_REPOSITORY)
private readonly playStateRepository: Repository<PlayState>,
) {}
getPlayState(user: User, tmdbId: number) {
return this.playStateRepository.find({
where: { user: { id: user.id }, tmdbId },
});
}
getEpisodePlayState(
user: User,
tmdbId: number,
seasonNumber: number,
episodeNumber: number,
) {
return this.playStateRepository.findOne({
where: { user: { id: user.id }, tmdbId, seasonNumber, episodeNumber },
});
}
async updateOrCreatePlayState({
user,
tmdbId,
seasonNumber,
episodeNumber,
updatePlayStateDto,
}: {
user: User;
tmdbId: number;
seasonNumber?: number;
episodeNumber?: number;
updatePlayStateDto: UpdatePlayStateDto;
}) {
let playState = await this.playStateRepository.findOne({
where: { user: { id: user.id }, tmdbId, seasonNumber, episodeNumber },
});
console.log('playState:', playState);
if (playState) {
playState.showInUpNext =
updatePlayStateDto.showInUpNext ?? playState.showInUpNext;
playState.progress = updatePlayStateDto.progress ?? playState.progress;
playState.watched = updatePlayStateDto.watched ?? playState.watched;
} else {
playState = this.playStateRepository.create({
tmdbId,
seasonNumber,
episodeNumber,
progress: updatePlayStateDto.progress,
watched: updatePlayStateDto.watched,
showInUpNext: updatePlayStateDto.showInUpNext,
user,
});
}
return this.playStateRepository.save(playState);
}
}

View File

@@ -1,37 +1,30 @@
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { ApiProperty } from '@nestjs/swagger';
import { User } from '../user.entity';
import { Title } from './title.entity';
@Entity()
export class PlayState {
@ApiProperty({ required: true })
export class Media {
@ApiProperty({ required: false })
@PrimaryGeneratedColumn('uuid')
id: string;
@ApiProperty({ required: true })
@Column()
tmdbId: number;
@ApiProperty({ required: false })
@Column({ nullable: true })
seasonNumber?: number;
@ApiProperty({ required: false })
@Column({ nullable: true })
seasonNumber: number;
episodeNumber?: number;
@ApiProperty({ required: false })
@Column({ nullable: true })
episodeNumber: number;
@ApiProperty({ required: true })
@Column({ type: 'double' })
@Column({ default: 0 })
progress: number;
@ApiProperty({ required: false })
@Column({ default: false })
watched: boolean;
@ApiProperty({ required: false })
@Column({ default: true })
showInUpNext: boolean;
@ManyToOne(() => User, (user) => user.playStates)
user: User;
@ApiProperty({ required: true, type: () => Title })
@ManyToOne(() => Title, (title) => title.media)
title: Title;
}

View File

@@ -0,0 +1,29 @@
import { Title } from './title.entity';
import { ApiProperty, OmitType, PartialType } from '@nestjs/swagger';
import { Media } from './media.entity';
export class TitleDto extends Title {
static fromEntity(title: Title): TitleDto {
return title;
}
}
export class UpdateTitleDto extends PartialType(
OmitType(Title, ['user', 'id', 'tmdbId']),
) {}
export class UpdateProgressDto {
@ApiProperty({ required: false })
progress?: number;
@ApiProperty({ required: false })
watched?: boolean;
}
export class ContinueWatchingDto {
@ApiProperty({ required: false, type: Media, nullable: true })
nextEpisode?: Media;
@ApiProperty({ type: Title })
title: Title;
}

View File

@@ -0,0 +1,83 @@
import {
Column,
Entity,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
} from 'typeorm';
import { ApiProperty } from '@nestjs/swagger';
import { User } from '../user.entity';
import { Media } from './media.entity';
export enum TitleType {
movie = 'movie',
series = 'series',
}
@Entity()
export class Title {
@ApiProperty({ required: false })
@PrimaryGeneratedColumn('uuid')
id: string;
@ApiProperty({ required: true })
@Column()
tmdbId: number;
@ApiProperty({ required: true, enum: TitleType })
@Column({ type: 'simple-enum', enum: TitleType, default: TitleType.movie })
type: TitleType;
@ApiProperty({ required: false })
@Column({ default: false })
upNext: boolean; // TODO: Rename to continueWatching
@ApiProperty({ required: false })
@Column({ default: false })
isInLibrary: boolean = false; // TODO: Remove
@ApiProperty({ required: false })
@Column({ default: false })
watched: boolean = false;
@ApiProperty({ required: false, type: () => Media, isArray: true })
@OneToMany(() => Media, (media) => media.title)
media: Media[];
@ManyToOne(() => User, (user) => user.titles)
user: User;
}
// @Entity()
// export class PlayState {
// @ApiProperty({ required: true })
// @PrimaryGeneratedColumn('uuid')
// id: string;
//
// @ApiProperty({ required: true })
// @Column()
// tmdbId: number;
//
// @ApiProperty({ required: false })
// @Column({ nullable: true })
// seasonNumber: number;
//
// @ApiProperty({ required: false })
// @Column({ nullable: true })
// episodeNumber: number;
//
// @ApiProperty({ required: true })
// @Column({ type: 'double' })
// progress: number;
//
// @ApiProperty({ required: false })
// @Column({ default: false })
// watched: boolean;
//
// @ApiProperty({ required: false })
// @Column({ default: true })
// showInUpNext: boolean;
//
// @ManyToOne(() => User, (user) => user.playStates)
// user: User;
// }

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TitleService } from './title.service';
import { titleProviders } from './title.providers';
import { TitlesController } from './titles.controller';
import { DatabaseModule } from '../../database/database.module';
import { UsersModule } from '../users.module';
@Module({
imports: [DatabaseModule, UsersModule],
providers: [TitleService, ...titleProviders],
controllers: [TitlesController],
})
export class TitleModule {}

View File

@@ -0,0 +1,20 @@
import { DataSource } from 'typeorm';
import { DATA_SOURCE } from '../../database/database.providers';
import { Title } from './title.entity';
import { Media } from './media.entity';
export const TITLE_REPOSITORY = 'PLAY_STATE_REPOSITORY';
export const MEDIA_REPOSITORY = 'MEDIA_REPOSITORY';
export const titleProviders = [
{
provide: TITLE_REPOSITORY,
useFactory: (dataSource: DataSource) => dataSource.getRepository(Title),
inject: [DATA_SOURCE],
},
{
provide: MEDIA_REPOSITORY,
useFactory: (dataSource: DataSource) => dataSource.getRepository(Media),
inject: [DATA_SOURCE],
},
];

View File

@@ -0,0 +1,185 @@
import { Inject, Injectable } from '@nestjs/common';
import { User } from '../user.entity';
import { MEDIA_REPOSITORY, TITLE_REPOSITORY } from './title.providers';
import { Repository } from 'typeorm';
import { Title, TitleType } from './title.entity';
import { UpdateProgressDto, UpdateTitleDto } from './title.dtos';
import { Media } from './media.entity';
@Injectable()
export class TitleService {
constructor(
@Inject(TITLE_REPOSITORY)
private readonly titleRepository: Repository<Title>,
@Inject(MEDIA_REPOSITORY)
private readonly mediaRepository: Repository<Media>,
) {}
// getPlayState(user: User, tmdbId: number) {
// return this.playStateRepository.find({
// where: { user: { id: user.id }, tmdbId },
// });
// }
//
// getEpisodePlayState(
// user: User,
// tmdbId: number,
// seasonNumber: number,
// episodeNumber: number,
// ) {
// return this.playStateRepository.findOne({
// where: { user: { id: user.id }, tmdbId, seasonNumber, episodeNumber },
// });
// }
//
// async updateOrCreatePlayState({
// user,
// tmdbId,
// seasonNumber,
// episodeNumber,
// updatePlayStateDto,
// }: {
// user: User;
// tmdbId: number;
// seasonNumber?: number;
// episodeNumber?: number;
// updatePlayStateDto: UpdateTitleDto;
// }) {
// let playState = await this.playStateRepository.findOne({
// where: { user: { id: user.id }, tmdbId, seasonNumber, episodeNumber },
// });
//
// console.log('playState:', playState);
//
// if (playState) {
// playState.showInUpNext =
// updatePlayStateDto.showInUpNext ?? playState.showInUpNext;
// playState.progress = updatePlayStateDto.progress ?? playState.progress;
// playState.watched = updatePlayStateDto.watched ?? playState.watched;
// } else {
// playState = this.playStateRepository.create({
// tmdbId,
// seasonNumber,
// episodeNumber,
// progress: updatePlayStateDto.progress,
// watched: updatePlayStateDto.watched,
// showInUpNext: updatePlayStateDto.showInUpNext,
// user,
// });
// }
//
// return this.playStateRepository.save(playState);
// }
getLibrary(user: User) {
return this.titleRepository.find({
where: { user: { id: user.id }, isInLibrary: true },
relations: ['media'],
});
}
getContinueWatching(user: User) {
return this.titleRepository.find({
where: { user: { id: user.id }, upNext: true },
relations: ['media'],
});
}
getTitle(
user: User,
tmdbId: number,
type: TitleType,
): Promise<Title | undefined> {
return this.titleRepository.findOne({
where: { user: { id: user.id }, tmdbId, type },
relations: ['media'],
});
}
async updateTitle(
user: User,
tmdbId: number,
type: TitleType,
updateTitleDto: UpdateTitleDto,
) {
const title = await this.getOrCreateTitle(user, tmdbId, type);
title.upNext = updateTitleDto.upNext ?? title.upNext;
title.isInLibrary = updateTitleDto.isInLibrary ?? title.isInLibrary;
await this.titleRepository.save(title);
return this.getTitle(user, tmdbId, type);
}
private async getOrCreateTitle(user: User, tmdbId: number, type: TitleType) {
let title = await this.getTitle(user, tmdbId, type);
if (title) {
return title;
}
title = this.titleRepository.create({
tmdbId,
user,
type,
});
await this.titleRepository.save(title);
return this.getTitle(user, tmdbId, type);
}
async updateProgress(
user: User,
tmdbId: number,
type: TitleType,
season: number | undefined,
episode: number | undefined,
updateTitleDto: UpdateProgressDto,
) {
const media = await this.getOrCreateMedia(
user,
tmdbId,
type,
season,
episode,
);
media.progress = updateTitleDto.progress ?? media.progress;
media.watched = updateTitleDto.watched ?? media.watched;
await this.mediaRepository.save(media);
return this.getTitle(user, tmdbId, type);
}
private async getOrCreateMedia(
user: User,
tmdbId: number,
type: TitleType,
season?: number,
episode?: number,
) {
const title = await this.getOrCreateTitle(user, tmdbId, type);
let media = await this.mediaRepository.findOne({
where: {
title: { id: title.id },
seasonNumber: season,
episodeNumber: episode,
},
});
if (media) {
return media;
}
media = this.mediaRepository.create({
title,
seasonNumber: season,
episodeNumber: episode,
});
return this.mediaRepository.save(media);
}
}

View File

@@ -0,0 +1,238 @@
import {
Body,
Controller,
Get,
NotFoundException,
Param,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthGuard, GetUser } from '../../auth/auth.guard';
import { User } from '../user.entity';
import {
ContinueWatchingDto,
TitleDto,
UpdateProgressDto,
UpdateTitleDto,
} from './title.dtos';
import { TitleService } from './title.service';
import { ApiOkResponse, ApiQuery } from '@nestjs/swagger';
import { TitleType } from './title.entity';
@Controller('titles')
@UseGuards(AuthGuard)
export class TitlesController {
constructor(private titleService: TitleService) {}
/*
GET /library
GET /continue-watching
GET /:tmdbId
PUT /:tmdbId 1. Add to library -> Title created
2. Update progress -> Media created
3. Set title.upNext = true (and title.watched if necessary)
4. Set media.watched = calculate
5. If watched = true, set progress to 0 for next episode (create)
PUT /progress/:tmdbId
PUT /progress/:tmdbId/season/:season/episode/:episode
*/
@Get('library')
@ApiOkResponse({ type: TitleDto, isArray: true })
async getLibrary(@GetUser() user: User): Promise<TitleDto[]> {
return this.titleService
.getLibrary(user)
.then((r) => r.map(TitleDto.fromEntity));
}
@Get('continue-watching')
@ApiOkResponse({ type: ContinueWatchingDto, isArray: true })
async getContinueWatching(
@GetUser() user: User,
): Promise<ContinueWatchingDto[]> {
const titles = await this.titleService.getContinueWatching(user);
return titles.map((title) => {
title.media.sort((a, b) => {
if (a.seasonNumber === b.seasonNumber) {
return a.episodeNumber - b.episodeNumber;
}
return a.seasonNumber - b.seasonNumber;
});
const nextEpisode = title.media.find((media) => !media.watched);
return {
title: TitleDto.fromEntity(title),
nextEpisode,
};
});
}
@Get(':tmdbId')
@ApiOkResponse({ type: TitleDto })
@ApiQuery({ name: 'type', enum: TitleType })
async getTitle(
@GetUser() user: User,
@Query('type') type: TitleType,
@Param('tmdbId') tmdbId: number,
): Promise<TitleDto> {
const tmdbIdNumber = Number(tmdbId);
if (isNaN(tmdbIdNumber)) {
throw new NotFoundException();
}
return this.titleService.getTitle(user, tmdbIdNumber, type);
}
@Put(':tmdbId')
@ApiOkResponse({ type: TitleDto })
@ApiQuery({ name: 'type', enum: TitleType })
async updateTitle(
@GetUser() user: User,
@Param('tmdbId') tmdbId: number,
@Query('type') type: TitleType,
@Body() updateTitleDto: UpdateTitleDto,
): Promise<TitleDto> {
const tmdbIdNumber = Number(tmdbId);
if (isNaN(tmdbIdNumber)) {
throw new NotFoundException();
}
return this.titleService.updateTitle(
user,
tmdbIdNumber,
type,
updateTitleDto,
);
}
@Put('progress/:tmdbId')
@Put('progress/:tmdbId/season/:season/episode/:episode')
@ApiOkResponse({ type: TitleDto })
@ApiQuery({ name: 'type', enum: TitleType })
async updateProgress(
@GetUser() user: User,
@Param('tmdbId') tmdbId: number,
@Param('season') season: number | undefined,
@Param('episode') episode: number | undefined,
@Query('type') type: TitleType,
@Body() updateTitleDto: UpdateProgressDto,
): Promise<TitleDto> {
const tmdbIdNumber = Number(tmdbId);
const seasonNumber = Number(season) || undefined;
const episodeNumber = Number(episode) || undefined;
if (isNaN(tmdbIdNumber)) {
throw new NotFoundException();
}
return this.titleService.updateProgress(
user,
tmdbIdNumber,
type,
seasonNumber,
episodeNumber,
updateTitleDto,
);
}
// @Get(':tmdbId')
// @ApiOkResponse({ type: TitleDto, isArray: true })
// async getPlayState(
// @GetUser() user: User,
// @Param('tmdbId') tmdbId: number,
// ): Promise<TitleDto[]> {
// const tmdbIdNumber = Number(tmdbId);
//
// if (isNaN(tmdbIdNumber)) {
// throw new Error('Invalid tmdbId');
// }
//
// return this.playStateService.getPlayState(user, tmdbIdNumber);
// }
//
// @Get(':tmdbId/season/:seasonNumber/episode/:episodeNumber')
// @ApiOkResponse({ type: TitleDto })
// async getEpisodePlayState(
// @GetUser() user: User,
// @Param('tmdbId') tmdbId: number,
// @Param('seasonNumber') seasonNumber: number,
// @Param('episodeNumber') episodeNumber: number,
// ): Promise<TitleDto> {
// const tmdbIdNumber = Number(tmdbId);
// const seasonNumberNumber = Number(seasonNumber);
// const episodeNumberNumber = Number(episodeNumber);
//
// if (
// isNaN(tmdbIdNumber) ||
// isNaN(seasonNumberNumber) ||
// isNaN(episodeNumberNumber)
// ) {
// throw new NotFoundException();
// }
//
// return this.playStateService.getEpisodePlayState(
// user,
// tmdbIdNumber,
// seasonNumberNumber,
// episodeNumberNumber,
// );
// }
//
// @Put(':tmdbId')
// @ApiOkResponse({ type: TitleDto })
// async updatePlayState(
// @GetUser() user: User,
// @Param('tmdbId') tmdbId: number,
// @Body() playStateDto: UpdateTitleDto,
// ): Promise<TitleDto> {
// const tmdbIdNumber = Number(tmdbId);
//
// if (isNaN(tmdbIdNumber)) {
// throw new Error('Invalid tmdbId');
// }
//
// return this.playStateService.updateOrCreatePlayState({
// user,
// tmdbId: tmdbIdNumber,
// updatePlayStateDto: playStateDto,
// });
// }
//
// @Put(':tmdbId/season/:seasonNumber/episode/:episodeNumber')
// @ApiOkResponse({ type: TitleDto })
// async updateEpisodePlayState(
// @GetUser() user: User,
// @Param('tmdbId') tmdbId: number,
// @Param('seasonNumber') seasonNumber: number,
// @Param('episodeNumber') episodeNumber: number,
// @Body() playStateDto: UpdateTitleDto,
// ): Promise<TitleDto> {
// const tmdbIdNumber = Number(tmdbId);
// const seasonNumberNumber = Number(seasonNumber);
// const episodeNumberNumber = Number(episodeNumber);
//
// if (
// isNaN(tmdbIdNumber) ||
// isNaN(seasonNumberNumber) ||
// isNaN(episodeNumberNumber)
// ) {
// throw new Error('Invalid tmdbId, seasonNumber, or episodeNumber');
// }
//
// return this.playStateService.updateOrCreatePlayState({
// user,
// tmdbId: tmdbIdNumber,
// seasonNumber: seasonNumberNumber,
// episodeNumber: episodeNumberNumber,
// updatePlayStateDto: playStateDto,
// });
// }
}

View File

@@ -1,15 +1,20 @@
import { ApiProperty, OmitType, PartialType, PickType } from '@nestjs/swagger';
import { User } from './user.entity';
import { MyListItemDto } from './my-list/my-list.dtos';
import { plainToInstance } from 'class-transformer';
export class UserDto extends OmitType(User, [
'password',
'profilePicture',
'playStates',
'titles',
'myListItems',
] as const) {
@ApiProperty({ type: 'string' })
profilePicture: string | null;
@ApiProperty({ type: MyListItemDto, isArray: true })
myList: MyListItemDto[];
static fromEntity(entity: User): UserDto {
return {
id: entity.id,
@@ -19,6 +24,7 @@ export class UserDto extends OmitType(User, [
onboardingDone: entity.onboardingDone,
profilePicture:
'data:image;base64,' + entity.profilePicture?.toString('base64'),
myList: entity.myListItems?.map((i) => plainToInstance(MyListItemDto, i)),
};
}
}

View File

@@ -1,6 +1,6 @@
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { ApiProperty } from '@nestjs/swagger';
import { PlayState } from './play-state/play-state.entity';
import { Title } from './titles/title.entity';
import { MyListItem } from './my-list/my-list-item.entity';
export class SonarrSettings {
@@ -47,6 +47,9 @@ export class TmdbSettings {
@ApiProperty({ required: true })
userId: string;
@ApiProperty({ required: true })
libraryListId: string; // TODO: Change to number?
}
export class Settings {
@@ -106,6 +109,7 @@ const DEFAULT_SETTINGS: Settings = {
tmdb: {
sessionId: '',
userId: '',
libraryListId: '',
},
};
@@ -140,8 +144,8 @@ export class User {
@Column('json', { default: JSON.stringify(DEFAULT_SETTINGS) })
settings = DEFAULT_SETTINGS;
@OneToMany(() => PlayState, (playState) => playState.user)
playStates: PlayState[];
@OneToMany(() => Title, (playState) => playState.user)
titles: Title[];
@OneToMany(() => MyListItem, (myListItem) => myListItem.user)
myListItems: MyListItem[];

View File

@@ -18,15 +18,21 @@ export class UsersService {
) {}
async findAll(): Promise<User[]> {
return this.userRepository.find();
return this.userRepository.find({ relations: ['myListItems'] });
}
async findOne(id: string): Promise<User> {
return this.userRepository.findOne({ where: { id } });
return this.userRepository.findOne({
where: { id },
relations: ['myListItems'],
});
}
async findOneByName(name: string): Promise<User> {
return this.userRepository.findOne({ where: { name } });
return this.userRepository.findOne({
where: { name },
relations: ['myListItems'],
});
}
async create(userCreateDto: CreateUserDto): Promise<User> {

6
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "reiverr",
"version": "2.0.0-alpha.6",
"dependencies": {
"class-transformer": "^0.5.1",
"gsap": "^3.12.5"
},
"devDependencies": {
@@ -3391,6 +3392,11 @@
"node": ">= 6"
}
},
"node_modules/class-transformer": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw=="
},
"node_modules/classnames": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.4.0.tgz",

View File

@@ -70,6 +70,7 @@
]
},
"dependencies": {
"class-transformer": "^0.5.1",
"gsap": "^3.12.5"
}
}

View File

@@ -9,8 +9,12 @@ export type CreateReiverrUser = components['schemas']['CreateUserDto'];
export type UpdateReiverrUser = components['schemas']['UpdateUserDto'];
export type ReiverrSettings = ReiverrUser['settings'];
export type PlayStateDto = components['schemas']['PlayStateDto'];
export type UpdatePlayStateDto = components['schemas']['UpdatePlayStateDto'];
// export type PlayStateDto = components['schemas']['PlayStateDto'];
// export type UpdatePlayStateDto = components['schemas']['UpdatePlayStateDto'];
export type MyListDto = components['schemas']['MyListItemDto'];
export type TitleDto = components['schemas']['TitleDto'];
export type TitleType = 'movie' | 'series';
export class ReiverrApi implements Api<paths> {
getClient(basePath?: string, _token?: string) {
@@ -62,65 +66,90 @@ export class ReiverrApi implements Api<paths> {
})
.then((res) => ({ user: res.data, error: res.error?.message }));
getMyList = () =>
getLibrary = () =>
this.getClient()
?.GET('/my-list')
?.GET('/titles/library')
.then((r) => r.data || []) || Promise.resolve([]);
addToMyList = (tmdbId: number) =>
addToLibrary = (tmdbId: number, type: TitleType) =>
this.getClient()
?.POST('/my-list/{tmdbId}', { params: { path: { tmdbId } } })
.then((r) => r.data || []) || Promise.resolve([]);
removeFromMyList = (tmdbId: number) =>
this.getClient()
?.DELETE('/my-list/{tmdbId}', { params: { path: { tmdbId } } })
.then((r) => r.data || []) || Promise.resolve([]);
getPlayState = (tmdbId: number, seasonNumber?: number, episodeNumber?: number) => {
if (seasonNumber !== undefined && episodeNumber !== undefined) {
return this.getClient()
?.GET('/play-state/{tmdbId}/season/{seasonNumber}/episode/{episodeNumber}', {
params: {
path: {
tmdbId,
seasonNumber,
episodeNumber
}
?.PUT('fin', {
params: {
path: { tmdbId },
query: {
type
}
})
.then((r) => r.data);
} else {
return this.getClient()
?.GET('/play-state/{tmdbId}', { params: { path: { tmdbId } } })
.then((r) => r.data);
}
};
},
body: {
isInLibrary: true
}
})
.then((r) => r.data);
setPlayState = (
tmdbId: number,
seasonNumber: number | undefined,
episodeNumber: number | undefined,
playState: UpdatePlayStateDto
) => {
if (seasonNumber !== undefined && episodeNumber !== undefined) {
return this.getClient()
?.PUT('/play-state/{tmdbId}/season/{seasonNumber}/episode/{episodeNumber}', {
body: playState,
params: {
path: { tmdbId, seasonNumber, episodeNumber }
removeFromLibrary = (tmdbId: number, type: TitleType) =>
this.getClient()
?.PUT('/titles/{tmdbId}', {
params: {
path: { tmdbId },
query: {
type
}
})
.then((r) => r.data);
} else {
return this.getClient()
?.PUT('/play-state/{tmdbId}', {
body: playState,
params: { path: { tmdbId } }
})
.then((r) => r.data);
}
};
},
body: {
isInLibrary: false
}
})
.then((r) => r.data);
getContinueWatching = () =>
this.getClient()
?.GET('/titles/continue-watching')
.then((r) => r.data || []) || Promise.resolve([]);
// getPlayState = (tmdbId: number, seasonNumber?: number, episodeNumber?: number) => {
// if (seasonNumber !== undefined && episodeNumber !== undefined) {
// return this.getClient()
// ?.GET('/play-state/{tmdbId}/season/{seasonNumber}/episode/{episodeNumber}', {
// params: {
// path: {
// tmdbId,
// seasonNumber,
// episodeNumber
// }
// }
// })
// .then((r) => r.data);
// } else {
// return this.getClient()
// ?.GET('/play-state/{tmdbId}', { params: { path: { tmdbId } } })
// .then((r) => r.data);
// }
// };
//
// setPlayState = (
// tmdbId: number,
// seasonNumber: number | undefined,
// episodeNumber: number | undefined,
// playState: UpdatePlayStateDto
// ) => {
// if (seasonNumber !== undefined && episodeNumber !== undefined) {
// return this.getClient()
// ?.PUT('/play-state/{tmdbId}/season/{seasonNumber}/episode/{episodeNumber}', {
// body: playState,
// params: {
// path: { tmdbId, seasonNumber, episodeNumber }
// }
// })
// .then((r) => r.data);
// } else {
// return this.getClient()
// ?.PUT('/play-state/{tmdbId}', {
// body: playState,
// params: { path: { tmdbId } }
// })
// .then((r) => r.data);
// }
// };
}
export const reiverrApi = new ReiverrApi();

View File

@@ -24,13 +24,18 @@ export interface paths {
post: operations["MyListController_addToMyList"];
delete: operations["MyListController_removeFromMyList"];
};
"/play-state/{tmdbId}": {
get: operations["PlayStateController_getPlayState"];
put: operations["PlayStateController_updatePlayState"];
"/titles/library": {
get: operations["TitlesController_getLibrary"];
};
"/play-state/{tmdbId}/season/{seasonNumber}/episode/{episodeNumber}": {
get: operations["PlayStateController_getEpisodePlayState"];
put: operations["PlayStateController_updateEpisodePlayState"];
"/titles/continue-watching": {
get: operations["TitlesController_getContinueWatching"];
};
"/titles/{tmdbId}": {
get: operations["TitlesController_getTitle"];
put: operations["TitlesController_updateTitle"];
};
"/titles/progress/{tmdbId}": {
put: operations["TitlesController_updateProgress"];
};
"/": {
get: operations["AppController_getHello"];
@@ -65,6 +70,7 @@ export interface components {
TmdbSettings: {
sessionId: string;
userId: string;
libraryListId: string;
};
Settings: {
autoplayTrailers: boolean;
@@ -76,6 +82,10 @@ export interface components {
peerflix: components["schemas"]["PeerflixSettings"];
tmdb: components["schemas"]["TmdbSettings"];
};
MyListItemDto: {
id: string;
tmdbId: number;
};
UserDto: {
id: string;
name: string;
@@ -83,6 +93,7 @@ export interface components {
onboardingDone?: boolean;
settings: components["schemas"]["Settings"];
profilePicture: string;
myList: components["schemas"]["MyListItemDto"][];
};
CreateUserDto: {
name: string;
@@ -107,23 +118,49 @@ export interface components {
accessToken: string;
user: components["schemas"]["UserDto"];
};
MyListItemDto: {
id: string;
Title: {
id?: string;
tmdbId: number;
/** @enum {string} */
type: "movie" | "series";
upNext?: boolean;
isInLibrary?: boolean;
watched?: boolean;
media?: components["schemas"]["Media"][];
};
PlayStateDto: {
id: string;
tmdbId: number;
Media: {
id?: string;
seasonNumber?: number;
episodeNumber?: number;
progress: number;
watched?: boolean;
showInUpNext?: boolean;
};
UpdatePlayStateDto: {
progress?: number;
watched?: boolean;
showInUpNext?: boolean;
title: components["schemas"]["Title"];
};
TitleDto: {
id?: string;
tmdbId: number;
/** @enum {string} */
type: "movie" | "series";
upNext?: boolean;
isInLibrary?: boolean;
watched?: boolean;
media?: components["schemas"]["Media"][];
};
ContinueWatchingDto: {
nextEpisode?: components["schemas"]["Media"] | null;
title: components["schemas"]["Title"];
};
UpdateTitleDto: {
/** @enum {string} */
type?: "movie" | "series";
upNext?: boolean;
isInLibrary?: boolean;
watched?: boolean;
media?: components["schemas"]["Media"][];
};
UpdateProgressDto: {
progress?: number;
watched?: boolean;
};
};
responses: never;
@@ -336,8 +373,29 @@ export interface operations {
};
};
};
PlayStateController_getPlayState: {
TitlesController_getLibrary: {
responses: {
200: {
content: {
"application/json": components["schemas"]["TitleDto"][];
};
};
};
};
TitlesController_getContinueWatching: {
responses: {
200: {
content: {
"application/json": components["schemas"]["ContinueWatchingDto"][];
};
};
};
};
TitlesController_getTitle: {
parameters: {
query: {
type: "movie" | "series";
};
path: {
tmdbId: number;
};
@@ -345,63 +403,53 @@ export interface operations {
responses: {
200: {
content: {
"application/json": components["schemas"]["PlayStateDto"];
"application/json": components["schemas"]["TitleDto"];
};
};
};
};
PlayStateController_updatePlayState: {
TitlesController_updateTitle: {
parameters: {
query: {
type: "movie" | "series";
};
path: {
tmdbId: number;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["UpdatePlayStateDto"];
"application/json": components["schemas"]["UpdateTitleDto"];
};
};
responses: {
200: {
content: {
"application/json": components["schemas"]["PlayStateDto"];
"application/json": components["schemas"]["TitleDto"];
};
};
};
};
PlayStateController_getEpisodePlayState: {
TitlesController_updateProgress: {
parameters: {
query: {
type: "movie" | "series";
};
path: {
tmdbId: number;
seasonNumber: number;
episodeNumber: number;
};
};
responses: {
200: {
content: {
"application/json": components["schemas"]["PlayStateDto"];
};
};
};
};
PlayStateController_updateEpisodePlayState: {
parameters: {
path: {
tmdbId: number;
seasonNumber: number;
episodeNumber: number;
season: number;
episode: number;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["UpdatePlayStateDto"];
"application/json": components["schemas"]["UpdateProgressDto"];
};
};
responses: {
200: {
content: {
"application/json": components["schemas"]["PlayStateDto"];
"application/json": components["schemas"]["TitleDto"];
};
};
};

View File

@@ -80,7 +80,7 @@ export class SonarrApi implements ApiAsync<paths> {
tmdbToTvdb = async (tmdbId: number) => {
if (!get(tmdbToTvdbCache)[tmdbId]) {
const tvdbId = await tmdbApi
.getTmdbSeries(tmdbId)
.getSeries(tmdbId)
.then((series) => series?.external_ids.tvdb_id || 0);
tmdbToTvdbCache.update((prev) => ({ ...prev, [tmdbId]: tvdbId }));
return tvdbId;

View File

@@ -7,6 +7,7 @@ import { settings } from '../../stores/settings.store';
import type { TitleType } from '../../types';
import type { Api } from '../api.interface';
import { user } from '../../stores/user.store';
import { sessions } from '../../stores/session.store';
const CACHE_ONE_DAY = 'max-age=86400';
const CACHE_FOUR_DAYS = 'max-age=345600';
@@ -63,6 +64,23 @@ export class TmdbApi implements Api<paths> {
});
}
static getProxyClient() {
const session = get(sessions).activeSession;
const token = session?.token;
return createClient<paths>({
baseUrl: session?.baseUrl + '/api/proxy/tmdb',
headers: {
Authorization: `Bearer ${TMDB_API_KEY}`
}
// ...(token && {
// headers: {
// Authorization: 'Bearer ' + token
// }
// })
});
}
static getClient4() {
return createClient<paths4>({
baseUrl: 'https://api.themoviedb.org',
@@ -95,6 +113,10 @@ export class TmdbApi implements Api<paths> {
return TmdbApi.getClient4l();
}
getProxyClient() {
return TmdbApi.getProxyClient();
}
getSessionId() {
return get(user)?.settings.tmdb.sessionId;
}
@@ -105,8 +127,8 @@ export class TmdbApi implements Api<paths> {
// MOVIES
getTmdbMovie = async (tmdbId: number) => {
return this.getClient()
getMovie = async (tmdbId: number) => {
return this.getProxyClient()
?.GET('/3/movie/{movie_id}', {
params: {
path: {
@@ -159,8 +181,8 @@ export class TmdbApi implements Api<paths> {
return id;
});
getTmdbSeries = async (tmdbId: number): Promise<TmdbSeriesFull2 | undefined> =>
await this.getClient()
getSeries = async (tmdbId: number): Promise<TmdbSeriesFull2 | undefined> =>
await this.getProxyClient()
?.GET('/3/tv/{series_id}', {
params: {
path: {
@@ -491,6 +513,98 @@ export class TmdbApi implements Api<paths> {
.then((res) => res.data)
);
};
getLibraryListId = async () => {
let listId = get(user)?.settings.tmdb.libraryListId || '';
if (!listId) {
listId = await this.getClient4l()
?.POST('/4/list', {
body: {
// @ts-ignore
description: 'Items added to library in Reiverr',
name: 'reiverr-library',
iso_3166_1: 'US',
iso_639_1: 'en',
public: false,
private: true
}
})
.then((r) => String(r.data?.id || ''));
await user.updateUser((u) => ({
...u,
settings: { ...u.settings, tmdb: { ...u.settings.tmdb, libraryListId: listId } }
}));
console.assert(!!listId, 'Failed to create library list');
}
return listId;
};
getLibrary = async () => {
const listId = await this.getLibraryListId();
return (
this.getClient4l()
?.GET('/4/list/{list_id}', {
params: {
path: {
list_id: Number(listId)
}
}
})
.then((res) => (res.data?.results as (TmdbMovie2 | TmdbSeries2)[]) || []) ||
Promise.resolve([])
);
};
addToLibrary = async (tmdbId: number, type: TitleType) => {
const listId = await this.getLibraryListId();
return this.getClient4l()
.POST('/4/list/{list_id}/items', {
params: {
path: {
list_id: Number(listId)
}
},
body: {
// @ts-ignore
items: [
{
media_type: type === 'movie' ? 'movie' : 'tv',
media_id: tmdbId
}
]
}
})
.then((r) => r.data);
};
removeFromLibrary = async (tmdbId: number, type: TitleType) => {
const listId = await this.getLibraryListId();
return this.getClient4l()
.DELETE('/4/list/{list_id}/items', {
params: {
path: {
list_id: Number(listId)
}
},
body: {
// @ts-ignore
items: [
{
media_type: type === 'movie' ? 'movie' : 'tv',
media_id: tmdbId
}
]
}
})
.then((r) => r.data);
};
}
export const tmdbApi = new TmdbApi();

View File

@@ -6,7 +6,16 @@
import { tmdbApi } from '../../apis/tmdb/tmdb-api';
import { PLATFORM_WEB, TMDB_IMAGES_ORIGINAL } from '../../constants';
import classNames from 'classnames';
import { Cross1, DotFilled, ExternalLink, Play, Plus, Trash } from 'radix-icons-svelte';
import {
Check,
Cross1,
DotFilled,
ExternalLink,
Minus,
Play,
Plus,
Trash
} from 'radix-icons-svelte';
import { jellyfinApi } from '../../apis/jellyfin/jellyfin-api';
import {
type EpisodeDownload,
@@ -29,13 +38,11 @@
import MMAddToSonarrDialog from '../MediaManagerModal/MMAddToSonarrDialog.svelte';
import ConfirmDialog from '../Dialog/ConfirmDialog.svelte';
import DownloadDetailsDialog from './DownloadDetailsDialog.svelte';
import { myList } from '../../stores/library.store';
export let id: string;
const { promise: tmdbSeries, data: tmdbSeriesData } = useRequest(
tmdbApi.getTmdbSeries,
Number(id)
);
const { promise: tmdbSeries, data: tmdbSeriesData } = useRequest(tmdbApi.getSeries, Number(id));
let sonarrItem = sonarrApi.getSeriesByTmdbId(Number(id));
const { promise: recommendations } = useRequest(tmdbApi.getSeriesRecommendations, Number(id));
@@ -211,14 +218,13 @@
{#await nextJellyfinEpisode then nextJellyfinEpisode}
<Container
direction="horizontal"
class="flex mt-8"
class="flex mt-8 space-x-4"
focusOnMount
on:back={handleGoBack}
on:mount={registrar}
>
{#if nextJellyfinEpisode}
<Button
class="mr-4"
on:clickOrSelect={() =>
nextJellyfinEpisode?.Id && playerState.streamJellyfinId(nextJellyfinEpisode.Id)}
>
@@ -227,18 +233,28 @@
<Play size={19} slot="icon" />
</Button>
{:else}
<Button class="mr-4" action={() => handleRequestSeason(1)}>
<Button action={() => handleRequestSeason(1)}>
Request
<Plus size={19} slot="icon" />
</Button>
{/if}
{#if $myList?.series[id]}
<Button icon={Check} action={() => myList.remove(Number(id), 'series')}>
On My List
</Button>
{:else if $myList}
<Button icon={Plus} action={() => myList.add(Number(id), 'series')}>
Add to My List
</Button>
{/if}
{#if PLATFORM_WEB}
<Button class="mr-4">
<Button>
Open In TMDB
<ExternalLink size={19} slot="icon-after" />
</Button>
<Button class="mr-4">
<Button>
Open In Jellyfin
<ExternalLink size={19} slot="icon-after" />
</Button>

View File

@@ -55,7 +55,8 @@
audioStreamIndex: 0,
audioTracks: [],
selectAudioTrack: () => {},
startTime: playState?.progress || 0
// startTime: playState?.progress || 0
startTime: 0
};
console.log(tmdbId, seasonNumber, episodeNumber);

View File

@@ -13,6 +13,32 @@
import type { ComponentProps } from 'svelte';
import TmdbCard from '../components/Card/TmdbCard.svelte';
import { tmdbApi, type TmdbMovie2, type TmdbSeries2 } from '../apis/tmdb/tmdb-api';
import { reiverrApi } from '../apis/reiverr/reiverr-api';
import { myList } from '../stores/library.store';
let tmdbLibraryItems: (TmdbSeries2 | TmdbMovie2)[] | undefined = undefined;
myList.subscribe((v) => {
if (v) {
tmdbLibraryItems = [
...(Object.values(v.series) as TmdbSeries2[]),
...(Object.values(v.movies) as TmdbMovie2[])
];
}
});
// const reiverrLibraryItemsP = reiverrApi
// .getLibrary()
// .then(async (items) => {
// return items.slice(0, 20);
// })
// .then((items) =>
// Promise.all(
// items.map((i) =>
// i.type === 'movie' ? tmdbApi.getTmdbMovie(i.tmdbId) : tmdbApi.getTmdbSeries(i.tmdbId)
// )
// ).then((i) => i.filter((i) => !!i) as (TmdbMovie2 | TmdbSeries2)[])
// );
const libraryItemsP = jellyfinApi.getLibraryItems();
const sonarrDownloads: Promise<TmdbSeries2[]> = sonarrApi
@@ -29,7 +55,7 @@
let radarrDownloads: Promise<TmdbMovie2[]> = radarrApi
.getDownloads()
.then((items) =>
Promise.all(items.map((i) => tmdbApi.getTmdbMovie(i.movie.tmdbId || -1))).then(
Promise.all(items.map((i) => tmdbApi.getMovie(i.movie.tmdbId || -1))).then(
(i) => i.filter((i) => !!i) as TmdbMovie2[]
)
);
@@ -61,9 +87,9 @@
</Carousel>
{/if}
{/await}
<div class="px-32">
<div class="mb-6">
<div class="header2">Library</div>
<div class="px-32 space-y-8">
<div class="">
<div class="header2">On Deck</div>
</div>
<CardGrid>
{#await libraryItemsP}
@@ -79,5 +105,25 @@
{/each}
{/await}
</CardGrid>
<div class="">
<div class="header2">Your Library</div>
</div>
<CardGrid>
{#if tmdbLibraryItems === undefined}
<CarouselPlaceholderItems />
{:else}
{#each tmdbLibraryItems as item}
<TmdbCard {item} on:enter={scrollIntoView({ all: 64 })} size="dynamic" navigateWithType />
{/each}
{/if}
<!--{#await reiverrLibraryItemsP}-->
<!-- <CarouselPlaceholderItems />-->
<!--{:then items}-->
<!-- {#each items as item}-->
<!-- <TmdbCard {item} on:enter={scrollIntoView({ all: 64 })} size="dynamic" navigateWithType />-->
<!-- {/each}-->
<!--{/await}-->
</CardGrid>
</div>
</DetachedPage>

View File

@@ -36,7 +36,7 @@
export let id: string;
const tmdbId = Number(id);
const tmdbMovie = tmdbApi.getTmdbMovie(tmdbId);
const tmdbMovie = tmdbApi.getMovie(tmdbId);
$: recommendations = tmdbApi.getMovieRecommendations(tmdbId);
const { promise: jellyfinItemP } = useRequest(
(id: string) => jellyfinApi.getLibraryItemFromTmdbId(id),

View File

@@ -0,0 +1,161 @@
import { writable } from 'svelte/store';
import { reiverrApi, type TitleDto, type TitleType } from '../apis/reiverr/reiverr-api';
import type { MyListDto } from '../apis/reiverr/reiverr-api';
import {
tmdbApi,
type TmdbMovie2,
type TmdbMovieFull2,
type TmdbSeries2,
type TmdbSeriesFull2
} from '../apis/tmdb/tmdb-api';
// type TmdbList = {
// movies: {
// [key: string]: TmdbMovie2;
// };
// series: {
// [key: string]: TmdbSeries2;
// };
// };
type MyList = {
movies: {
[key: string]: TitleDto;
};
series: {
[key: string]: TitleDto;
};
};
function useLibrary() {
const store = writable<MyList | undefined>(undefined);
// reiverrApi.getLibrary().then((myList) => {
// store.set(listToObject(myList));
// });
setTimeout(refreshLibrary, 2000); //TODO: Fix this
// refreshLibrary().then();
async function refreshLibrary() {
// return tmdbApi.getLibrary().then((myList) => {
// const obj: TmdbList = {
// movies: {},
// series: {}
// };
//
// myList.forEach((item) => {
// if (item?.type === 'movie') {
// obj.movies[String(item.id) || ''] = item;
// } else {
// obj.series[String(item.id) || ''] = item;
// }
// });
//
// store.set(obj);
// });
return reiverrApi.getLibrary().then(async (myList) => {
const obj: MyList = {
movies: {},
series: {}
};
// const movies: Promise<TmdbMovieFull2 | undefined>[] = [];
// const series: Promise<TmdbSeriesFull2 | undefined>[] = [];
myList.forEach((item) => {
if (item?.type === 'movie') {
// movies.push(tmdbApi.getMovie(item.tmdbId));
// obj.movies[String(item.id) || ''] = item;
obj.movies[String(item.id) || ''] = item;
} else {
// series.push(tmdbApi.getSeries(item.tmdbId));
// obj.series[String(item.id) || ''] = item;
obj.series[String(item.id) || ''] = item;
}
});
// await Promise.all(movies).then((movies) => {
// movies.forEach((m) => {
// if (m) obj.movies[String(m.id) || ''] = m;
// });
// });
// await Promise.all(series).then((series) => {
// series.forEach((s) => {
// if (s) obj.series[String(s.id) || ''] = s;
// });
// });
store.set(obj);
});
}
// function listToObject(list: TitleDto[]) {
// const obj: MyList = {};
// list.forEach((item) => {
// obj[item.tmdbId] = true;
// });
// return obj;
// }
async function add(tmdbId: number, type: TitleType) {
// const res = await tmdbApi.addToLibrary(tmdbId, type);
//
// if (res?.success) {
// return refreshLibrary();
// }
const res = await reiverrApi.addToLibrary(tmdbId, type);
if (res !== undefined) {
if (type === 'movie') {
// const m = await tmdbApi.getMovie(tmdbId);
store.update((obj) => {
// if (obj && m) obj.movies[tmdbId] = m;
if (obj) obj.movies[tmdbId] = res;
return obj;
});
} else {
// const s = await tmdbApi.getSeries(tmdbId);
store.update((obj) => {
// if (obj && s) obj.series[tmdbId] = s;
if (obj) obj.series[tmdbId] = res;
return obj;
});
}
}
}
async function remove(tmdbId: number, type: TitleType) {
// const res = await tmdbApi.removeFromLibrary(tmdbId, type);
//
// if (res?.success) {
// return refreshLibrary();
// }
const res = await reiverrApi.removeFromLibrary(tmdbId, type);
if (res !== undefined) {
if (type === 'movie') {
store.update((obj) => {
if (obj) delete obj.movies[tmdbId];
return obj;
});
} else {
store.update((obj) => {
if (obj) delete obj.series[tmdbId];
return obj;
});
}
}
}
return {
subscribe: store.subscribe,
add,
remove
};
}
export const myList = useLibrary();