diff --git a/backend/migrations/1721052160110-add-play-state-and-my-list.ts b/backend/migrations/1721052160110-add-play-state-and-my-list.ts deleted file mode 100644 index 103958e..0000000 --- a/backend/migrations/1721052160110-add-play-state-and-my-list.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; - -export class AddPlayStateAndMyList1721052160110 implements MigrationInterface { - name = 'AddPlayStateAndMyList1721052160110' - - public async up(queryRunner: QueryRunner): Promise { - 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 { - 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"`); - } - -} diff --git a/backend/migrations/1721337734378-add-titles.ts b/backend/migrations/1721337734378-add-titles.ts new file mode 100644 index 0000000..be65c94 --- /dev/null +++ b/backend/migrations/1721337734378-add-titles.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddTitles1721337734378 implements MigrationInterface { + name = 'AddTitles1721337734378' + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } + +} diff --git a/backend/migrations/1721338340748-add-title-watched.ts b/backend/migrations/1721338340748-add-title-watched.ts new file mode 100644 index 0000000..6a43949 --- /dev/null +++ b/backend/migrations/1721338340748-add-title-watched.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddTitleWatched1721338340748 implements MigrationInterface { + name = 'AddTitleWatched1721338340748' + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } + +} diff --git a/backend/migrations/1721342594807-add-title-type.ts b/backend/migrations/1721342594807-add-title-type.ts new file mode 100644 index 0000000..d7af93a --- /dev/null +++ b/backend/migrations/1721342594807-add-title-type.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddTitleType1721342594807 implements MigrationInterface { + name = 'AddTitleType1721342594807' + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } + +} diff --git a/backend/migrations/1721377870204-add-tmdb-list-id.ts b/backend/migrations/1721377870204-add-tmdb-list-id.ts new file mode 100644 index 0000000..90b9840 --- /dev/null +++ b/backend/migrations/1721377870204-add-tmdb-list-id.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTmdbListId1721377870204 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`UPDATE "user" + SET "settings" = json_set("settings", '$.tmdb.libraryListId', json('""'));`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`UPDATE "user" + SET "settings" = json_remove("settings", '$.tmdb.libraryListId');`); + } +} diff --git a/backend/package-lock.json b/backend/package-lock.json index ab0c4bc..88ffbc1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index c577b7b..6f47c83 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 1f70766..983782e 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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], diff --git a/backend/src/consts.ts b/backend/src/consts.ts index 4b5c05e..472597e 100644 --- a/backend/src/consts.ts +++ b/backend/src/consts.ts @@ -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; diff --git a/backend/src/proxy/proxy.module.ts b/backend/src/proxy/proxy.module.ts new file mode 100644 index 0000000..446bd3c --- /dev/null +++ b/backend/src/proxy/proxy.module.ts @@ -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 {} diff --git a/backend/src/proxy/tmdb/tmdb.controller.ts b/backend/src/proxy/tmdb/tmdb.controller.ts new file mode 100644 index 0000000..57192a0 --- /dev/null +++ b/backend/src/proxy/tmdb/tmdb.controller.ts @@ -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); + } + } +} diff --git a/backend/src/proxy/tmdb/tmdb.module.ts b/backend/src/proxy/tmdb/tmdb.module.ts new file mode 100644 index 0000000..e5fd503 --- /dev/null +++ b/backend/src/proxy/tmdb/tmdb.module.ts @@ -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 {} diff --git a/backend/src/users/play-state/play-state.controller.ts b/backend/src/users/play-state/play-state.controller.ts deleted file mode 100644 index 8abde59..0000000 --- a/backend/src/users/play-state/play-state.controller.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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, - }); - } -} diff --git a/backend/src/users/play-state/play-state.dtos.ts b/backend/src/users/play-state/play-state.dtos.ts deleted file mode 100644 index 0deb32f..0000000 --- a/backend/src/users/play-state/play-state.dtos.ts +++ /dev/null @@ -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', - ]), -) {} diff --git a/backend/src/users/play-state/play-state.module.ts b/backend/src/users/play-state/play-state.module.ts deleted file mode 100644 index 368dcdb..0000000 --- a/backend/src/users/play-state/play-state.module.ts +++ /dev/null @@ -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 {} diff --git a/backend/src/users/play-state/play-state.providers.ts b/backend/src/users/play-state/play-state.providers.ts deleted file mode 100644 index b7e5741..0000000 --- a/backend/src/users/play-state/play-state.providers.ts +++ /dev/null @@ -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], - }, -]; diff --git a/backend/src/users/play-state/play-state.service.ts b/backend/src/users/play-state/play-state.service.ts deleted file mode 100644 index 366c633..0000000 --- a/backend/src/users/play-state/play-state.service.ts +++ /dev/null @@ -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, - ) {} - - 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); - } -} diff --git a/backend/src/users/play-state/play-state.entity.ts b/backend/src/users/titles/media.entity.ts similarity index 53% rename from backend/src/users/play-state/play-state.entity.ts rename to backend/src/users/titles/media.entity.ts index dd96e30..e8e8a50 100644 --- a/backend/src/users/play-state/play-state.entity.ts +++ b/backend/src/users/titles/media.entity.ts @@ -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; } diff --git a/backend/src/users/titles/title.dtos.ts b/backend/src/users/titles/title.dtos.ts new file mode 100644 index 0000000..32bed0f --- /dev/null +++ b/backend/src/users/titles/title.dtos.ts @@ -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; +} diff --git a/backend/src/users/titles/title.entity.ts b/backend/src/users/titles/title.entity.ts new file mode 100644 index 0000000..89c768d --- /dev/null +++ b/backend/src/users/titles/title.entity.ts @@ -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; +// } diff --git a/backend/src/users/titles/title.module.ts b/backend/src/users/titles/title.module.ts new file mode 100644 index 0000000..405d333 --- /dev/null +++ b/backend/src/users/titles/title.module.ts @@ -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 {} diff --git a/backend/src/users/titles/title.providers.ts b/backend/src/users/titles/title.providers.ts new file mode 100644 index 0000000..1f63aa9 --- /dev/null +++ b/backend/src/users/titles/title.providers.ts @@ -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], + }, +]; diff --git a/backend/src/users/titles/title.service.ts b/backend/src/users/titles/title.service.ts new file mode 100644 index 0000000..bff8ec2 --- /dev/null +++ b/backend/src/users/titles/title.service.ts @@ -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, + @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); + } +} diff --git a/backend/src/users/titles/titles.controller.ts b/backend/src/users/titles/titles.controller.ts new file mode 100644 index 0000000..f3ccd5e --- /dev/null +++ b/backend/src/users/titles/titles.controller.ts @@ -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, + // }); + // } +} diff --git a/backend/src/users/user.dtos.ts b/backend/src/users/user.dtos.ts index e55c2e0..deee13d 100644 --- a/backend/src/users/user.dtos.ts +++ b/backend/src/users/user.dtos.ts @@ -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)), }; } } diff --git a/backend/src/users/user.entity.ts b/backend/src/users/user.entity.ts index 8934142..6d8afbf 100644 --- a/backend/src/users/user.entity.ts +++ b/backend/src/users/user.entity.ts @@ -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[]; diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index bfaf3f3..60b8620 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -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> { diff --git a/package-lock.json b/package-lock.json index 4b8d9a4..eb7432c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e117158..91893dc 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ ] }, "dependencies": { + "class-transformer": "^0.5.1", "gsap": "^3.12.5" } } diff --git a/src/lib/apis/reiverr/reiverr-api.ts b/src/lib/apis/reiverr/reiverr-api.ts index 26cc181..0d06016 100644 --- a/src/lib/apis/reiverr/reiverr-api.ts +++ b/src/lib/apis/reiverr/reiverr-api.ts @@ -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(); diff --git a/src/lib/apis/reiverr/reiverr.generated.d.ts b/src/lib/apis/reiverr/reiverr.generated.d.ts index ae89447..3f7bffb 100644 --- a/src/lib/apis/reiverr/reiverr.generated.d.ts +++ b/src/lib/apis/reiverr/reiverr.generated.d.ts @@ -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"]; }; }; }; diff --git a/src/lib/apis/sonarr/sonarr-api.ts b/src/lib/apis/sonarr/sonarr-api.ts index 55ca3d8..6dfc422 100644 --- a/src/lib/apis/sonarr/sonarr-api.ts +++ b/src/lib/apis/sonarr/sonarr-api.ts @@ -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; diff --git a/src/lib/apis/tmdb/tmdb-api.ts b/src/lib/apis/tmdb/tmdb-api.ts index 6a6a3ae..a05bb0b 100644 --- a/src/lib/apis/tmdb/tmdb-api.ts +++ b/src/lib/apis/tmdb/tmdb-api.ts @@ -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(); diff --git a/src/lib/components/SeriesPage/SeriesPage.svelte b/src/lib/components/SeriesPage/SeriesPage.svelte index ea4ceeb..bc026e3 100644 --- a/src/lib/components/SeriesPage/SeriesPage.svelte +++ b/src/lib/components/SeriesPage/SeriesPage.svelte @@ -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> diff --git a/src/lib/components/VideoPlayer/TorrentVideoPlayerModal.svelte b/src/lib/components/VideoPlayer/TorrentVideoPlayerModal.svelte index 9501cde..2d2a955 100644 --- a/src/lib/components/VideoPlayer/TorrentVideoPlayerModal.svelte +++ b/src/lib/components/VideoPlayer/TorrentVideoPlayerModal.svelte @@ -55,7 +55,8 @@ audioStreamIndex: 0, audioTracks: [], selectAudioTrack: () => {}, - startTime: playState?.progress || 0 + // startTime: playState?.progress || 0 + startTime: 0 }; console.log(tmdbId, seasonNumber, episodeNumber); diff --git a/src/lib/pages/LibraryPage.svelte b/src/lib/pages/LibraryPage.svelte index 3ccd0f2..db80ff6 100644 --- a/src/lib/pages/LibraryPage.svelte +++ b/src/lib/pages/LibraryPage.svelte @@ -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> diff --git a/src/lib/pages/MoviePage.svelte b/src/lib/pages/MoviePage.svelte index aee1cda..7dc22af 100644 --- a/src/lib/pages/MoviePage.svelte +++ b/src/lib/pages/MoviePage.svelte @@ -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), diff --git a/src/lib/stores/library.store.ts b/src/lib/stores/library.store.ts new file mode 100644 index 0000000..530ca3a --- /dev/null +++ b/src/lib/stores/library.store.ts @@ -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();