├── .github ├── dependabot.yml └── workflows │ ├── backend-ci.yml │ └── frontend-ci.yml ├── .gitignore ├── Dockerfile.backend ├── README.md ├── backend ├── .env.example ├── .gitignore ├── eslint.config.js ├── package-lock.json ├── package.json ├── src │ ├── cache.ts │ ├── controllers │ │ └── catalogController.ts │ ├── encryption.ts │ ├── generateManifest.ts │ ├── index.ts │ ├── lib │ │ ├── config.ts │ │ └── mediaTypes.ts │ ├── metrics.ts │ ├── routes │ │ ├── catalog.ts │ │ ├── configure.ts │ │ ├── generateLink.ts │ │ └── manifest.ts │ ├── rpdb.ts │ ├── sentry.ts │ ├── simkl.ts │ ├── tmdb.ts │ ├── types.ts │ └── utils.ts └── tsconfig.json ├── docker-compose-dev.yaml ├── frontend ├── .env.example ├── .gitignore ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── public │ ├── shai-pal-unsplash.webp │ ├── simkl-logo.webp │ └── stremio-logo.svg ├── src │ ├── App.module.scss │ ├── App.tsx │ ├── components │ │ ├── GenerateLink │ │ │ └── GenerateLink.tsx │ │ ├── LinkBtn │ │ │ ├── LinkBtn.module.scss │ │ │ └── LinkBtn.tsx │ │ ├── SelectCatalogs │ │ │ ├── CatalogItem.module.scss │ │ │ ├── CatalogItem.tsx │ │ │ ├── SelectCatalogs.module.scss │ │ │ └── SelectCatalogs.tsx │ │ └── SimklAuth │ │ │ └── SimklAuth.tsx │ ├── index.scss │ ├── lib │ │ ├── appStore.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── main.tsx │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── package-lock.json ├── package.json └── shared └── catalogs └── index.ts /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" 9 | directory: "/backend" 10 | schedule: 11 | interval: "monthly" 12 | - package-ecosystem: "npm" 13 | directory: "/frontend" 14 | schedule: 15 | interval: "monthly" 16 | -------------------------------------------------------------------------------- /.github/workflows/backend-ci.yml: -------------------------------------------------------------------------------- 1 | name: Backend CI 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | paths: 7 | - backend/** 8 | 9 | jobs: 10 | verify: 11 | runs-on: ubuntu-latest 12 | defaults: 13 | run: 14 | working-directory: ./backend 15 | strategy: 16 | matrix: 17 | task: [lint, prettier, build] 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Use Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: "22.x" 25 | 26 | - name: Cache dependencies 27 | uses: actions/cache@v4 28 | with: 29 | path: ~/.npm 30 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 31 | restore-keys: | 32 | ${{ runner.OS }}-node- 33 | 34 | - name: Install dependencies 35 | run: npm i 36 | 37 | - name: Run linter 38 | if: matrix.task == 'lint' 39 | run: npm run lint 40 | 41 | - name: Run Build 42 | if: matrix.task == 'build' 43 | run: npm run build 44 | 45 | - name: Run Prettier 46 | if: matrix.task == 'prettier' 47 | run: npx prettier "src/**/*.{ts,js,json}" --check 48 | -------------------------------------------------------------------------------- /.github/workflows/frontend-ci.yml: -------------------------------------------------------------------------------- 1 | name: Frontend CI 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | paths: 7 | - frontend/** 8 | 9 | jobs: 10 | verify: 11 | runs-on: ubuntu-latest 12 | defaults: 13 | run: 14 | working-directory: ./frontend 15 | strategy: 16 | matrix: 17 | task: [lint, prettier, build] 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Use Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: "22.x" 25 | 26 | - name: Cache dependencies 27 | uses: actions/cache@v4 28 | with: 29 | path: ~/.npm 30 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 31 | restore-keys: | 32 | ${{ runner.OS }}-node- 33 | 34 | - name: Install dependencies 35 | run: npm i 36 | 37 | - name: Run linter 38 | if: matrix.task == 'lint' 39 | run: npm run lint 40 | 41 | - name: Run Build 42 | if: matrix.task == 'build' 43 | run: npm run build 44 | 45 | - name: Run Prettier 46 | if: matrix.task == 'prettier' 47 | run: npx prettier "src/**/*.{ts,js,tsx,json}" --check 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #--------------------------------------------------# 2 | # The following was generated with gitignore.nvim: # 3 | #--------------------------------------------------# 4 | # Gitignore for the following technologies: Node 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | .pnpm-debug.log* 14 | 15 | # Diagnostic reports (https://nodejs.org/api/report.html) 16 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 17 | 18 | # Runtime data 19 | pids 20 | *.pid 21 | *.seed 22 | *.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | lib-cov 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage 29 | *.lcov 30 | 31 | # nyc test coverage 32 | .nyc_output 33 | 34 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 35 | .grunt 36 | 37 | # Bower dependency directory (https://bower.io/) 38 | bower_components 39 | 40 | # node-waf configuration 41 | .lock-wscript 42 | 43 | # Compiled binary addons (https://nodejs.org/api/addons.html) 44 | build/Release 45 | 46 | # Dependency directories 47 | node_modules/ 48 | jspm_packages/ 49 | 50 | # Snowpack dependency directory (https://snowpack.dev/) 51 | web_modules/ 52 | 53 | # TypeScript cache 54 | *.tsbuildinfo 55 | 56 | # Optional npm cache directory 57 | .npm 58 | 59 | # Optional eslint cache 60 | .eslintcache 61 | 62 | # Optional stylelint cache 63 | .stylelintcache 64 | 65 | # Microbundle cache 66 | .rpt2_cache/ 67 | .rts2_cache_cjs/ 68 | .rts2_cache_es/ 69 | .rts2_cache_umd/ 70 | 71 | # Optional REPL history 72 | .node_repl_history 73 | 74 | # Output of 'npm pack' 75 | *.tgz 76 | 77 | # Yarn Integrity file 78 | .yarn-integrity 79 | 80 | # dotenv environment variable files 81 | .env 82 | .env.development.local 83 | .env.test.local 84 | .env.production.local 85 | .env.local 86 | 87 | # parcel-bundler cache (https://parceljs.org/) 88 | .cache 89 | .parcel-cache 90 | 91 | # Next.js build output 92 | .next 93 | out 94 | 95 | # Nuxt.js build / generate output 96 | .nuxt 97 | dist 98 | 99 | # Gatsby files 100 | .cache/ 101 | # Comment in the public line in if your project uses Gatsby and not Next.js 102 | # https://nextjs.org/blog/next-9-1#public-directory-support 103 | # public 104 | 105 | # vuepress build output 106 | .vuepress/dist 107 | 108 | # vuepress v2.x temp and cache directory 109 | .temp 110 | 111 | # Docusaurus cache and generated files 112 | .docusaurus 113 | 114 | # Serverless directories 115 | .serverless/ 116 | 117 | # FuseBox cache 118 | .fusebox/ 119 | 120 | # DynamoDB Local files 121 | .dynamodb/ 122 | 123 | # TernJS port file 124 | .tern-port 125 | 126 | # Stores VSCode versions used for testing VSCode extensions 127 | .vscode-test 128 | 129 | # yarn v2 130 | .yarn/cache 131 | .yarn/unplugged 132 | .yarn/build-state.yml 133 | .yarn/install-state.gz 134 | .pnp.* 135 | 136 | # Serverless Webpack directories 137 | .webpack/ 138 | 139 | # SvelteKit build / generate output 140 | .svelte-kit 141 | 142 | -------------------------------------------------------------------------------- /Dockerfile.backend: -------------------------------------------------------------------------------- 1 | 2 | ARG NODE_VERSION=22 3 | 4 | FROM node:${NODE_VERSION}-alpine 5 | 6 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 7 | RUN apk add --no-cache libc6-compat 8 | WORKDIR /app/backend 9 | 10 | COPY ./backend/package*.json ./ 11 | COPY ./shared /app/shared 12 | 13 | RUN npm ci 14 | 15 | COPY ./backend ./ 16 | 17 | RUN npm run build 18 | 19 | ENV NODE_ENV production 20 | 21 | CMD ["npm", "start"] 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stremio Simkl Watchlists Addon 2 | Stremio addon to display your Simkl Watchlists. 3 | 4 | ## Install addon 5 | 6 | [Install addon](https://stremio-simkl.malachi.io) 7 | 8 | 9 | ## Development 10 | 11 | 1. Create `.env` files inside the `backend` and `frontend` folders based on the `.env.example` files. 12 | 13 | 2. You will need a TMDB API key, an RPDB API key (optional), and a Simkl app. 14 | 15 | - To create a Simkl app, visit: [Simkl Developer Settings](https://simkl.com/settings/developer/). 16 | 17 | 3. Install dependencies for both the frontend and backend: 18 | 19 | ```sh 20 | npm run install 21 | ``` 22 | 23 | 4. Start the development environment, which includes the Redis server, frontend, and backend: 24 | 25 | ```sh 26 | npm run dev 27 | ``` 28 | 29 | This will run the following commands concurrently: 30 | - `start:redis`: Starts the Redis server using Docker Compose. 31 | - `dev:frontend`: Starts the frontend development server. 32 | - `dev:backend`: Starts the backend development server. 33 | 34 | 35 | 36 | ## Tech Stack 37 | 38 | The backend is a simple and stateless express server that uses redis to cache the TMDB API responses. 39 | 40 | The user configuration for the addon (Simkl user token) is encrypted using aes-192-cbc. 41 | 42 | 43 | ### Backend 44 | 45 | - Typescript 46 | - Node.js 47 | - Express 48 | - Redis (for caching) 49 | - Prometheus (for metrics) 50 | 51 | ### Frontend 52 | 53 | - Typescript 54 | - React 55 | - Vite 56 | - Zustand (state management) 57 | 58 | 59 | ## Contributing 60 | 61 | Contributions are welcome! 62 | if you have any suggestions or issues please open an issue or a pull request. 63 | -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | 2 | SIMKL_CLIENT_ID= 3 | SIMKL_CLIENT_SECRET= 4 | 5 | ENCRYPTION_KEY= 6 | ENCRYPTION_SALT= 7 | 8 | TMDB_API_KEY= 9 | RPDB_API_KEY= 10 | 11 | REDIS_USERNAME=default 12 | REDIS_PASSWORD=redis_password 13 | REDIS_PORT=6379 14 | REDIS_HOST=localhost 15 | 16 | BACKEND_HOST=localhost:43001 17 | FRONTEND_URL=http://localhost:5173 18 | 19 | USE_RPDB=true 20 | DISABLE_REDIS= 21 | 22 | ENABLE_METRICS= 23 | ENABLE_SENTRY= 24 | SENTRY_DSN= 25 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | -------------------------------------------------------------------------------- /backend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import tseslint from "typescript-eslint"; 3 | import prettier from "eslint-config-prettier"; 4 | 5 | export default tseslint.config( 6 | { ignores: ["dist"] }, 7 | { 8 | extends: [ 9 | js.configs.recommended, 10 | ...tseslint.configs.recommended, 11 | prettier, 12 | ], 13 | files: ["**/*.ts"], 14 | languageOptions: { 15 | ecmaVersion: 2020, 16 | globals: { 17 | NodeJS: true, 18 | process: true, 19 | }, 20 | }, 21 | rules: { 22 | "@typescript-eslint/no-unused-vars": [ 23 | "warn", 24 | { argsIgnorePattern: "^_" }, 25 | ], 26 | "@typescript-eslint/no-explicit-any": "off", 27 | }, 28 | } 29 | ); 30 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stremio-simkl-backend", 3 | "version": "0.0.0", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "tsx watch ./src/index.ts", 7 | "build": "tsc && tsc-alias", 8 | "start": "tsx ./dist/backend/src/index.js", 9 | "lint": "eslint src --report-unused-disable-directives --max-warnings 0", 10 | "format": "prettier --write \"src/**/*.{ts,js,json}\"" 11 | }, 12 | "dependencies": { 13 | "@sentry/node": "^9.7.0", 14 | "axios": "^1.8.4", 15 | "cors": "^2.8.5", 16 | "dotenv": "^16.4.7", 17 | "express": "^4.21.2", 18 | "prom-client": "^15.1.3", 19 | "redis": "^4.7.0", 20 | "shared": "file:../shared", 21 | "tsconfig-paths": "^4.2.0", 22 | "tsx": "^4.19.3" 23 | }, 24 | "devDependencies": { 25 | "@eslint/js": "^9.22.0", 26 | "@types/cors": "^2.8.17", 27 | "@types/express": "^4.17.21", 28 | "@types/node": "^22.13.10", 29 | "@types/stremio-addon-sdk": "^1.6.11", 30 | "eslint-config-prettier": "^10.1.1", 31 | "prettier": "^3.5.3", 32 | "tsc-alias": "^1.8.11", 33 | "typescript": "^5.8.2", 34 | "typescript-eslint": "^8.27.0" 35 | } 36 | } -------------------------------------------------------------------------------- /backend/src/cache.ts: -------------------------------------------------------------------------------- 1 | import { createClient, RedisClientType } from "redis"; 2 | import { getConfig } from "./lib/config"; 3 | 4 | let client: RedisClientType; 5 | 6 | export async function connectToRedis() { 7 | console.log("Connecting to Redis..."); 8 | 9 | const config = getConfig(); 10 | 11 | client = createClient({ 12 | username: config.redis.username, 13 | password: config.redis.password, 14 | socket: { 15 | port: config.redis.port, 16 | host: config.redis.host, 17 | reconnectStrategy: function (retries) { 18 | if (retries > 20) { 19 | console.log( 20 | "Too many attempts to reconnect. Redis connection was terminated", 21 | ); 22 | return new Error("Too many retries."); 23 | } else { 24 | return retries * 500; 25 | } 26 | }, 27 | }, 28 | }); 29 | 30 | client.on("error", (error: any) => { 31 | console.error("REDIS ERROR"); 32 | console.error(error.message || error); 33 | }); 34 | client.on("connect", () => console.log("Connected to Redis!")); 35 | 36 | client.connect(); 37 | } 38 | 39 | export default function getClient() { 40 | return client; 41 | } 42 | -------------------------------------------------------------------------------- /backend/src/controllers/catalogController.ts: -------------------------------------------------------------------------------- 1 | import { decryptConfig } from "@/encryption"; 2 | import { getConfig } from "@/lib/config"; 3 | import { 4 | StremioMediaType, 5 | convertStremioMediaTypeToSimkl, 6 | } from "@/lib/mediaTypes"; 7 | import { generateRPDBPosterUrl, mediaHasRPDBPoster } from "@/rpdb"; 8 | import { getSimklUserWatchList } from "@/simkl"; 9 | import { getTMDBMeta } from "@/tmdb"; 10 | import { SimklMovie, SimklShow } from "@/types"; 11 | import { createReleaseInfo, generatePosterUrl } from "@/utils"; 12 | 13 | export type SimklCatalogItem = { 14 | id: string; 15 | type: StremioMediaType; 16 | name: string; 17 | poster: string; 18 | description: string; 19 | links: { 20 | name: string; 21 | category: string; 22 | url: string; 23 | }[]; 24 | genres: string[]; 25 | releaseInfo: string; 26 | }; 27 | 28 | export const generateCatalog = async ( 29 | config: string, 30 | stremioMediaType: StremioMediaType, 31 | catalogName: string, 32 | skip: number, 33 | maxItems: number, 34 | ): Promise< 35 | | SimklCatalogItem[] 36 | | { 37 | status: number; 38 | error: string; 39 | } 40 | > => { 41 | console.log("Generating catalog", catalogName); 42 | const decryptedConfig = decryptConfig(config); 43 | if (!decryptedConfig.simklToken) { 44 | return { 45 | status: 400, 46 | error: "Invalid config", 47 | }; 48 | } 49 | 50 | const simklMediaType = convertStremioMediaTypeToSimkl(stremioMediaType); 51 | if (!simklMediaType) { 52 | return { 53 | status: 400, 54 | error: "Invalid media type", 55 | }; 56 | } 57 | 58 | const listType = (() => { 59 | switch (catalogName.split("-")[1]) { 60 | case "plan": 61 | return "plantowatch"; 62 | case "watching": 63 | return "watching"; 64 | case "completed": 65 | return "completed"; 66 | default: 67 | return "watching"; 68 | } 69 | })(); 70 | 71 | const userHistory = await getSimklUserWatchList( 72 | decryptedConfig.simklToken, 73 | simklMediaType, 74 | listType, 75 | ); 76 | 77 | if (!userHistory) { 78 | return { 79 | status: 500, 80 | error: "Error fetching user history", 81 | }; 82 | } 83 | 84 | let items = userHistory[simklMediaType]; 85 | if (!items || items.length == 0) { 86 | return []; 87 | } 88 | 89 | const stremioItems: SimklCatalogItem[] = []; 90 | 91 | if (listType == "plantowatch") { 92 | items.sort((a, b) => { 93 | const yearA = 94 | (a as SimklShow).show?.year || (a as SimklMovie).movie?.year || 0; 95 | const yearB = 96 | (b as SimklShow).show?.year || (b as SimklMovie).movie?.year || 0; 97 | return yearB - yearA; 98 | }); 99 | } else { 100 | items.sort( 101 | (a, b) => 102 | new Date(b.last_watched_at!).getTime() - 103 | new Date(a.last_watched_at!).getTime(), 104 | ); 105 | } 106 | 107 | // Skip items 108 | items = items.slice(skip); 109 | 110 | // Limit items 111 | items = items.slice(0, maxItems); 112 | 113 | for (const simklItem of items) { 114 | const itemMeta = 115 | (simklItem as SimklMovie).movie || (simklItem as SimklShow).show; 116 | 117 | // Dont display shows that the user finished watching 118 | if ( 119 | stremioMediaType == StremioMediaType.Series && 120 | listType == "watching" && 121 | simklItem.watched_episodes_count != 0 && 122 | !(simklItem as SimklShow).next_to_watch 123 | ) { 124 | continue; 125 | } 126 | 127 | const tmdbMeta = itemMeta.ids.tmdb 128 | ? await getTMDBMeta(itemMeta.ids.tmdb, stremioMediaType) 129 | : null; 130 | 131 | const showNextEpisodeText = 132 | (stremioMediaType == StremioMediaType.Series || 133 | stremioMediaType == StremioMediaType.Anime) && 134 | listType == "watching" && 135 | (simklItem as SimklShow).next_to_watch; 136 | const nextEpisodeDescription = showNextEpisodeText 137 | ? `Next episode to watch: ${(simklItem as SimklShow).next_to_watch}\n\n` 138 | : ""; 139 | 140 | const overview = tmdbMeta ? tmdbMeta.overview : ""; 141 | const tmdbCredit = tmdbMeta ? "\n\nData by TMDB." : ""; 142 | 143 | const unsupportedText = 144 | stremioMediaType == StremioMediaType.Anime && !itemMeta.ids.imdb 145 | ? "This item is not supported.\nPlease add the base show to your list (season 1)." 146 | : ""; 147 | 148 | const description = `${nextEpisodeDescription}${overview}${tmdbCredit}${unsupportedText}`; 149 | 150 | const genres = tmdbMeta ? tmdbMeta.genres.map((genre) => genre.name) : []; 151 | 152 | const posterUrl = 153 | getConfig().rpdb.enabled && mediaHasRPDBPoster(stremioMediaType, tmdbMeta) 154 | ? generateRPDBPosterUrl(itemMeta.ids.imdb) 155 | : generatePosterUrl(itemMeta.poster); 156 | 157 | stremioItems.push({ 158 | id: itemMeta.ids.imdb, 159 | type: 160 | stremioMediaType == StremioMediaType.Anime 161 | ? StremioMediaType.Series 162 | : stremioMediaType, 163 | name: itemMeta.title, 164 | poster: posterUrl, 165 | description, 166 | links: [ 167 | { 168 | name: "Simkl", 169 | category: "Simkl", 170 | url: `https://simkl.com/${stremioMediaType == StremioMediaType.Movie ? "movies" : stremioMediaType == StremioMediaType.Anime ? "anime" : "tv"}/${ 171 | itemMeta.ids.simkl 172 | }`, 173 | }, 174 | ], 175 | genres, 176 | releaseInfo: createReleaseInfo(stremioMediaType, tmdbMeta), 177 | }); 178 | } 179 | 180 | return stremioItems; 181 | }; 182 | -------------------------------------------------------------------------------- /backend/src/encryption.ts: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | import { CatalogType, allCatalogs, catalogToInt } from "@shared/catalogs"; 3 | import { getConfig } from "./lib/config"; 4 | 5 | const algorithm = "aes-192-cbc"; 6 | let key: Buffer; 7 | 8 | type ConfigData = { 9 | simklToken: string; 10 | selectedCatalogs: CatalogType[]; 11 | }; 12 | 13 | type EncryptedConfig = { 14 | simklToken: string; 15 | selectedCatalogs: string; 16 | }; 17 | 18 | export function initEncryption() { 19 | const envKey = getConfig().encryption.key; 20 | const envSalt = getConfig().encryption.salt; 21 | 22 | if (!envKey || !envSalt) { 23 | throw new Error("Encryption key or salt not found!"); 24 | } 25 | 26 | key = crypto.scryptSync(envKey, envSalt, 24); 27 | } 28 | 29 | export function encrypt(data: EncryptedConfig): string { 30 | try { 31 | const dataStr = JSON.stringify(data); 32 | const iv = crypto.randomBytes(16); 33 | const cipher = crypto.createCipheriv(algorithm, key, iv); 34 | const encrypted = Buffer.concat([cipher.update(dataStr), cipher.final()]); 35 | return `${iv.toString("hex")}:${encrypted.toString("hex")}`; 36 | } catch (error) { 37 | console.log("Error encrypting data", error); 38 | return ""; 39 | } 40 | } 41 | 42 | export function decrypt(data: string): EncryptedConfig | string { 43 | try { 44 | const [iv, encrypted] = data.split(":"); 45 | const decipher = crypto.createDecipheriv( 46 | algorithm, 47 | key, 48 | Buffer.from(iv, "hex"), 49 | ); 50 | const decrypted = Buffer.concat([ 51 | decipher.update(Buffer.from(encrypted, "hex")), 52 | decipher.final(), 53 | ]); 54 | 55 | // For backwards compatibility 56 | try { 57 | const parsed = JSON.parse(decrypted.toString()); 58 | if ( 59 | typeof parsed === "object" && 60 | parsed !== null && 61 | "simklToken" in parsed && 62 | "selectedCatalogs" in parsed 63 | ) { 64 | return parsed; 65 | } 66 | } catch { 67 | // Do nothing 68 | } 69 | 70 | return decrypted.toString(); 71 | } catch (error) { 72 | console.log("Error decrypting data", error); 73 | return { simklToken: "", selectedCatalogs: "" }; 74 | } 75 | } 76 | 77 | export function generateEncryptedConfig( 78 | simklToken: string, 79 | selectedCatalogs: CatalogType[], 80 | ): string { 81 | const selectedCatalogsMinimized = Array.from( 82 | new Set(selectedCatalogs.map(catalogToInt)), 83 | ).join(""); 84 | 85 | const encryptedData = encrypt({ 86 | simklToken, 87 | selectedCatalogs: selectedCatalogsMinimized, 88 | }); 89 | 90 | return encryptedData; 91 | } 92 | 93 | export function decryptConfig(encryptedData: string): ConfigData { 94 | const data = decrypt(encryptedData); 95 | 96 | if (typeof data === "string") { 97 | return { simklToken: data, selectedCatalogs: allCatalogs }; 98 | } 99 | 100 | const selectedCatalogsParsed = data.selectedCatalogs 101 | .split("") 102 | .map((c) => allCatalogs[parseInt(c)]); 103 | 104 | return { 105 | simklToken: data.simklToken, 106 | selectedCatalogs: selectedCatalogsParsed, 107 | }; 108 | } 109 | 110 | // function generateEncKey() { 111 | // return generateKeySync("aes", { length: 256 }).export().toString("hex"); 112 | // } 113 | -------------------------------------------------------------------------------- /backend/src/generateManifest.ts: -------------------------------------------------------------------------------- 1 | import { slugify } from "@/utils"; 2 | import { CatalogType, allCatalogs, catalogsData } from "@shared/catalogs"; 3 | 4 | export default function generateManifest( 5 | user: string, 6 | selectedCatalogs: CatalogType[] | undefined, 7 | configured: boolean = true, 8 | ) { 9 | let description = `Unofficial addon to display your Simkl Watchlists in Stremio, by @nktfh100`; 10 | 11 | if (!user) { 12 | user = "unknown"; 13 | } 14 | 15 | if (configured) { 16 | description += ` (SIMKL user - ${user})`; 17 | } 18 | 19 | let id = "com.nktfh100.stremiosimkl"; 20 | 21 | if (configured) { 22 | id += "." + slugify(user); 23 | } 24 | const catalogs = (selectedCatalogs || allCatalogs) 25 | .map((catalog) => catalogsData[catalog]) 26 | .filter((catalog) => !!catalog); 27 | 28 | return { 29 | id, 30 | version: "0.2.7", 31 | name: "Simkl Watchlists", 32 | description, 33 | logo: "https://eu.simkl.in/img_favicon/v2/favicon-192x192.png", 34 | catalogs, 35 | resources: ["catalog"], 36 | types: ["movie", "series", "anime"], 37 | behaviorHints: { 38 | configurable: !configured, 39 | configurationRequired: !configured, 40 | }, 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /backend/src/index.ts: -------------------------------------------------------------------------------- 1 | import cors from "cors"; 2 | import express from "express"; 3 | 4 | import { connectToRedis } from "@/cache"; 5 | import { initEncryption } from "@/encryption"; 6 | import registerCatalogRoute from "@/routes/catalog"; 7 | import registerGenerateLinkRoute from "@/routes/generateLink"; 8 | import registerManifestRoute from "@/routes/manifest"; 9 | 10 | import registerConfigureRoute from "./routes/configure"; 11 | import { initMetrics, metricsEndpoint, metricsMiddleware } from "./metrics"; 12 | 13 | // import { publishToCentral } from "stremio-addon-sdk"; 14 | import { initSentry, setupSentryRequestHandler } from "./sentry"; 15 | import { getConfig } from "./lib/config"; 16 | 17 | const config = getConfig(); 18 | 19 | if (config.sentry.enabled) { 20 | initSentry(); 21 | } 22 | 23 | initEncryption(); 24 | 25 | const app = express(); 26 | 27 | app.use(cors()); 28 | app.use(express.json()); 29 | 30 | if (config.enableMetrics) { 31 | initMetrics(); 32 | app.use(metricsMiddleware); 33 | app.get("/metrics", metricsEndpoint); 34 | } 35 | 36 | app.get("/", (_req, res) => { 37 | res.redirect(config.frontendUrl); 38 | }); 39 | 40 | app.get("/health", (_req, res) => { 41 | res.send("OK"); 42 | }); 43 | 44 | registerManifestRoute(app); 45 | registerConfigureRoute(app); 46 | registerGenerateLinkRoute(app); 47 | registerCatalogRoute(app); 48 | 49 | if (config.sentry.enabled) { 50 | setupSentryRequestHandler(app); 51 | } 52 | 53 | app.listen(config.port, async () => { 54 | console.log(`Server listening on port ${config.port}`); 55 | if (config.redis.enabled) { 56 | connectToRedis(); 57 | } 58 | 59 | // if (process.env.NODE_ENV == "production") { 60 | // console.log("Publishing to central..."); 61 | // try { 62 | // publishToCentral( 63 | // `https://${process.env.BACKEND_HOST}/manifest.json` 64 | // ); 65 | // } catch (error) { 66 | // console.error("Failed to publish to central", error); 67 | // } 68 | // } 69 | }); 70 | -------------------------------------------------------------------------------- /backend/src/lib/config.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | 3 | export type Config = { 4 | simkl: { 5 | clientId: string; 6 | clientSecret: string; 7 | }; 8 | encryption: { 9 | key: string; 10 | salt: string; 11 | }; 12 | redis: { 13 | enabled: boolean; 14 | username: string; 15 | password: string; 16 | port: number; 17 | host: string; 18 | }; 19 | rpdb: { 20 | enabled: boolean; 21 | apiKey: string; 22 | }; 23 | sentry: { 24 | enabled: boolean; 25 | dsn: string; 26 | }; 27 | env: string; 28 | port: number; 29 | tmdbApiKey: string; 30 | backendHost: string; 31 | frontendUrl: string; 32 | enableMetrics: boolean; 33 | }; 34 | 35 | let config: Config | null = null; 36 | 37 | export const loadConfig = (): Config => { 38 | dotenv.config(); 39 | 40 | return { 41 | simkl: { 42 | clientId: process.env.SIMKL_CLIENT_ID || "", 43 | clientSecret: process.env.SIMKL_CLIENT_SECRET || "", 44 | }, 45 | encryption: { 46 | key: process.env.ENCRYPTION_KEY || "", 47 | salt: process.env.ENCRYPTION_SALT || "", 48 | }, 49 | tmdbApiKey: process.env.TMDB_API_KEY || "", 50 | redis: { 51 | enabled: process.env.DISABLE_REDIS !== "true", 52 | username: process.env.REDIS_USERNAME || "", 53 | password: process.env.REDIS_PASSWORD || "", 54 | port: parseInt(process.env.REDIS_PORT || "6379"), 55 | host: process.env.REDIS_HOST || "", 56 | }, 57 | sentry: { 58 | enabled: process.env.ENABLE_SENTRY === "true", 59 | dsn: process.env.SENTRY_DSN || "", 60 | }, 61 | rpdb: { 62 | enabled: process.env.USE_RPDB === "true", 63 | apiKey: process.env.RPDB_API_KEY || "", 64 | }, 65 | env: process.env.NODE_ENV || "development", 66 | port: parseInt(process.env.PORT || "43001"), 67 | backendHost: process.env.BACKEND_HOST || "", 68 | frontendUrl: process.env.FRONTEND_URL || "", 69 | enableMetrics: process.env.ENABLE_METRICS === "true", 70 | }; 71 | }; 72 | 73 | export const getConfig = (): Config => { 74 | if (config === null) { 75 | config = loadConfig(); 76 | } 77 | 78 | return config; 79 | }; 80 | -------------------------------------------------------------------------------- /backend/src/lib/mediaTypes.ts: -------------------------------------------------------------------------------- 1 | export enum SimklMediaType { 2 | Movie = "movies", 3 | Show = "shows", 4 | Anime = "anime", 5 | } 6 | 7 | export enum StremioMediaType { 8 | Movie = "movie", 9 | Series = "series", 10 | Anime = "anime", 11 | } 12 | 13 | export const convertStremioMediaTypeToSimkl = ( 14 | type: StremioMediaType, 15 | ): SimklMediaType | null => { 16 | switch (type) { 17 | case StremioMediaType.Movie: 18 | return SimklMediaType.Movie; 19 | case StremioMediaType.Series: 20 | return SimklMediaType.Show; 21 | case StremioMediaType.Anime: 22 | return SimklMediaType.Anime; 23 | default: 24 | return null; 25 | } 26 | }; 27 | 28 | export const validateStremioMediaType = (type: string): boolean => { 29 | return Object.values(StremioMediaType).includes(type as StremioMediaType); 30 | }; 31 | -------------------------------------------------------------------------------- /backend/src/metrics.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from "express"; 2 | import client from "prom-client"; 3 | 4 | let register: client.Registry; 5 | 6 | export const getRegister = () => register; 7 | 8 | const httpRequestDurationMicroseconds = new client.Histogram({ 9 | name: "http_request_duration_ms", 10 | help: "Duration of HTTP requests in ms", 11 | labelNames: ["method", "route", "code"], 12 | buckets: [0.1, 5, 15, 50, 100, 300, 500, 1000, 3000, 5000, 10000], 13 | }); 14 | 15 | const httpRequestCounter = new client.Counter({ 16 | name: "http_requests_total", 17 | help: "Total number of HTTP requests", 18 | labelNames: ["method", "route", "code"], 19 | }); 20 | 21 | export const initMetrics = () => { 22 | register = new client.Registry(); 23 | 24 | register.registerMetric(httpRequestDurationMicroseconds); 25 | register.registerMetric(httpRequestCounter); 26 | }; 27 | 28 | export const metricsEndpoint: RequestHandler = async (_req, res) => { 29 | res.setHeader("Content-Type", getRegister().contentType); 30 | res.send(await getRegister().metrics()); 31 | }; 32 | 33 | export const metricsMiddleware: RequestHandler = (req, res, next) => { 34 | const end = httpRequestDurationMicroseconds.startTimer(); 35 | res.on("finish", () => { 36 | if (!req.route) { 37 | return; 38 | } 39 | 40 | end({ 41 | route: req.route.path, 42 | code: res.statusCode, 43 | method: req.method, 44 | }); 45 | 46 | httpRequestCounter.inc({ 47 | method: req.method, 48 | route: req.route.path, 49 | code: res.statusCode, 50 | }); 51 | }); 52 | next(); 53 | }; 54 | -------------------------------------------------------------------------------- /backend/src/routes/catalog.ts: -------------------------------------------------------------------------------- 1 | import { Express, Response } from "express"; 2 | 3 | import { generateCatalog } from "@/controllers/catalogController"; 4 | import { getConfig } from "@/lib/config"; 5 | import { StremioMediaType, validateStremioMediaType } from "@/lib/mediaTypes"; 6 | 7 | // Cache for 5 minute 8 | const setCacheControl = (res: Response) => { 9 | if (getConfig().env === "production") { 10 | res.set("Cache-Control", `public, max-age=${60 * 5}`); 11 | } 12 | }; 13 | 14 | export default async function registerCatalogRoute(app: Express) { 15 | app.get("/:config/catalog/:type/:list/:skip.json", async (req, res) => { 16 | try { 17 | const skipStr = req.params.skip; // "skip=25" 18 | const skipNumParsed = parseInt(skipStr.split("=")[1]); 19 | 20 | if (!validateStremioMediaType(req.params.type)) { 21 | res.status(400).send({ error: "Invalid list type" }); 22 | return; 23 | } 24 | 25 | const stremioItems = await generateCatalog( 26 | req.params.config, 27 | req.params.type as StremioMediaType, 28 | req.params.list, 29 | skipNumParsed, 30 | 50, 31 | ); 32 | 33 | setCacheControl(res); 34 | 35 | res.send({ 36 | metas: stremioItems, 37 | }); 38 | } catch (err: any) { 39 | console.error(err); 40 | res.status(500).send({ error: "Internal Server Error" }); 41 | } 42 | }); 43 | 44 | app.get("/:config/catalog/:type/:list.json", async (req, res) => { 45 | try { 46 | if (!validateStremioMediaType(req.params.type)) { 47 | res.status(400).send({ error: "Invalid list type" }); 48 | return; 49 | } 50 | 51 | const stremioItems = await generateCatalog( 52 | req.params.config, 53 | req.params.type as StremioMediaType, 54 | req.params.list, 55 | 0, 56 | 50, 57 | ); 58 | 59 | setCacheControl(res); 60 | 61 | res.send({ 62 | metas: stremioItems, 63 | }); 64 | } catch (err: any) { 65 | console.error(err); 66 | res.status(500).send({ error: "Internal Server Error" }); 67 | } 68 | }); 69 | } 70 | -------------------------------------------------------------------------------- /backend/src/routes/configure.ts: -------------------------------------------------------------------------------- 1 | import { getConfig } from "@/lib/config"; 2 | import { Express } from "express"; 3 | 4 | export default function registerConfigureRoute(app: Express) { 5 | const handleFunc = (_req: any, res: any) => { 6 | res.redirect(getConfig().frontendUrl); 7 | }; 8 | 9 | app.get("/:config/configure", handleFunc); 10 | app.get("/configure", handleFunc); 11 | } 12 | -------------------------------------------------------------------------------- /backend/src/routes/generateLink.ts: -------------------------------------------------------------------------------- 1 | import { Express } from "express"; 2 | 3 | import { generateEncryptedConfig } from "@/encryption"; 4 | import { getSimklAccessToken } from "@/simkl"; 5 | import { validateCatalogs } from "@/utils"; 6 | import { getConfig } from "@/lib/config"; 7 | 8 | export default async function registerGenerateLinkRoute(app: Express) { 9 | app.post("/gen-link", async (req, res) => { 10 | const data = req.body as { code: string; selectedCatalogs?: string[] }; 11 | 12 | if (!data || !data.code) { 13 | res.status(400).send({ error: "No data provided!" }); 14 | } 15 | 16 | const simklToken = await getSimklAccessToken(data.code); 17 | if (!simklToken) { 18 | res.status(400).send({ error: "Invalid simkl code!" }); 19 | return; 20 | } 21 | 22 | const selectedCatalogs = validateCatalogs(data.selectedCatalogs); 23 | 24 | const encryptedConfig = generateEncryptedConfig( 25 | simklToken, 26 | selectedCatalogs, 27 | ); 28 | 29 | res.send({ 30 | link: `stremio://${getConfig().backendHost}/${encryptedConfig}/manifest.json`, 31 | }); 32 | 33 | console.log(`Generated install link`); 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /backend/src/routes/manifest.ts: -------------------------------------------------------------------------------- 1 | import { Express } from "express"; 2 | 3 | import { decryptConfig } from "@/encryption"; 4 | import generateManifest from "@/generateManifest"; 5 | import { getSimklUsername } from "@/simkl"; 6 | import { defaultCatalogs } from "@shared/catalogs"; 7 | 8 | export default async function registerManifestRoute(app: Express) { 9 | app.get("/manifest.json", async (_req, res) => { 10 | res.send(generateManifest("", defaultCatalogs, false)); 11 | }); 12 | 13 | app.get("/:config/manifest.json", async (req, res) => { 14 | const config = decryptConfig(req.params.config); 15 | if (!config.simklToken) { 16 | res.status(400).send("Invalid config"); 17 | return; 18 | } 19 | 20 | const username = await getSimklUsername(config.simklToken); 21 | 22 | const manifest = generateManifest(username, config.selectedCatalogs); 23 | console.log(`Generated manifest for ${username}`); 24 | 25 | res.send(manifest); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /backend/src/rpdb.ts: -------------------------------------------------------------------------------- 1 | import { getConfig } from "./lib/config"; 2 | import { StremioMediaType } from "./lib/mediaTypes"; 3 | import { CleanedTMDBMovie, CleanedTMDBShow } from "./types"; 4 | 5 | const RPDB_API = "https://api.ratingposterdb.com"; 6 | 7 | export function generateRPDBPosterUrl(imdbId: string): string { 8 | return `${RPDB_API}/${getConfig().rpdb.apiKey}/imdb/poster-default/${imdbId}.jpg`; 9 | } 10 | 11 | // RPDB only has released movies and series 12 | export function mediaHasRPDBPoster( 13 | mediaType: StremioMediaType, 14 | tmdbMeta: CleanedTMDBMovie | CleanedTMDBShow | null, 15 | ): boolean { 16 | if (mediaType == StremioMediaType.Anime) return false; 17 | 18 | if (!tmdbMeta) return false; 19 | 20 | if (tmdbMeta.status == "Released") return true; 21 | 22 | if (tmdbMeta.status == "In Production") return false; 23 | 24 | if (mediaType == "series" && (tmdbMeta as CleanedTMDBShow)?.last_air_date) { 25 | return true; 26 | } 27 | 28 | return false; 29 | } 30 | -------------------------------------------------------------------------------- /backend/src/sentry.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/node"; 2 | import { Express } from "express"; 3 | import { getConfig } from "./lib/config"; 4 | 5 | export const initSentry = () => { 6 | Sentry.init({ 7 | dsn: getConfig().sentry.dsn, 8 | integrations: [ 9 | Sentry.consoleIntegration(), 10 | Sentry.captureConsoleIntegration(), 11 | ], 12 | }); 13 | }; 14 | 15 | export const setupSentryRequestHandler = (app: Express) => { 16 | Sentry.setupExpressErrorHandler(app); 17 | }; 18 | -------------------------------------------------------------------------------- /backend/src/simkl.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | import { SimklHistoryResponse } from "@/types"; 4 | import { getConfig } from "./lib/config"; 5 | import { SimklMediaType } from "./lib/mediaTypes"; 6 | 7 | const SIMKL_API = "https://api.simkl.com"; 8 | 9 | async function simklApiGetRequest(url: string, token?: string) { 10 | try { 11 | return await axios.get(`${SIMKL_API}/${url}`, { 12 | headers: { 13 | "simkl-api-key": getConfig().simkl.clientId, 14 | Authorization: token ? `Bearer ${token}` : "", 15 | }, 16 | }); 17 | } catch (error: any) { 18 | console.error("SIMKL API ERROR", url); 19 | 20 | if (error.response) console.error(error.response.data); 21 | if (error.message) console.error(error.message); 22 | return null; 23 | } 24 | } 25 | 26 | async function simklApiPostRequest(url: string, data: any, token?: string) { 27 | try { 28 | return await axios.post(`${SIMKL_API}/${url}`, data, { 29 | headers: { 30 | "Content-Type": "application/json", 31 | "simkl-api-key": getConfig().simkl.clientId, 32 | Authorization: token ? `Bearer ${token}` : "", 33 | }, 34 | }); 35 | } catch (error: any) { 36 | console.error("SIMKL API ERROR", url); 37 | 38 | if (error.response) console.error(error.response.data); 39 | if (error.message) console.error(error.message); 40 | return null; 41 | } 42 | } 43 | 44 | export async function getSimklAccessToken( 45 | code: string, 46 | ): Promise { 47 | const result = await simklApiPostRequest("oauth/token", { 48 | grant_type: "authorization_code", 49 | code, 50 | client_id: getConfig().simkl.clientId, 51 | client_secret: getConfig().simkl.clientSecret, 52 | redirect_uri: "http://localhost:5173", 53 | }); 54 | 55 | if (!result) return null; 56 | 57 | return result.data.access_token; 58 | } 59 | 60 | export async function getSimklUserWatchList( 61 | token: string, 62 | type: SimklMediaType, 63 | status: "watching" | "plantowatch" | "hold" | "completed" | "dropped", 64 | ): Promise { 65 | const result = await simklApiGetRequest( 66 | `sync/all-items/${type}/${status}`, 67 | token, 68 | ); 69 | 70 | if (!result) return null; 71 | 72 | return result.data; 73 | } 74 | 75 | export async function getSimklUsername(token: string) { 76 | const result = await simklApiGetRequest("users/settings", token); 77 | 78 | if (!result || !result.data.user) return null; 79 | 80 | return result.data.user.name; 81 | } 82 | -------------------------------------------------------------------------------- /backend/src/tmdb.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | import getClient from "@/cache"; 4 | import { CleanedTMDBMovie, CleanedTMDBShow } from "@/types"; 5 | import { cleanTMDBMovieMeta, cleanTMDBShowMeta } from "@/utils"; 6 | import { getConfig } from "./lib/config"; 7 | import { StremioMediaType } from "./lib/mediaTypes"; 8 | 9 | const TMDB_API = "https://api.themoviedb.org/3"; 10 | 11 | const tmdbAxios = axios.create({ 12 | baseURL: TMDB_API, 13 | headers: { 14 | Authorization: `Bearer ${getConfig().tmdbApiKey}`, 15 | }, 16 | }); 17 | 18 | export async function getTMDBMeta( 19 | tmdbId: string, 20 | type: StremioMediaType, 21 | ): Promise { 22 | switch (type) { 23 | case StremioMediaType.Series: 24 | case String(StremioMediaType.Anime): 25 | return getTMDBShowMeta(tmdbId); 26 | case StremioMediaType.Movie: 27 | return getTMDBMovieMeta(tmdbId); 28 | default: 29 | return null; 30 | } 31 | } 32 | 33 | export async function getTMDBMovieMeta( 34 | tmdbId: string, 35 | ): Promise { 36 | try { 37 | const cached = await getCachedTMDBMovieMeta(tmdbId); 38 | if (cached) return cached; 39 | 40 | const result = await tmdbAxios.get(`/movie/${tmdbId}`); 41 | 42 | const cleanedMeta = cleanTMDBMovieMeta(result.data); 43 | 44 | await cacheTMDBMovieMeta(tmdbId, cleanedMeta); 45 | 46 | return cleanedMeta; 47 | } catch (error: any) { 48 | console.error("TMDB MOVIE API ERROR", tmdbId); 49 | 50 | if (error.message) console.error(error.message); 51 | 52 | return null; 53 | } 54 | } 55 | 56 | export async function getTMDBShowMeta( 57 | tmdbId: string, 58 | ): Promise { 59 | try { 60 | const cached = await getCachedTMDBShowMeta(tmdbId); 61 | if (cached) return cached; 62 | 63 | const result = await tmdbAxios.get(`/tv/${tmdbId}`); 64 | 65 | const cleanedMeta = cleanTMDBShowMeta(result.data); 66 | 67 | await cacheTMDBShowMeta(tmdbId, cleanedMeta); 68 | 69 | return cleanedMeta; 70 | } catch (error: any) { 71 | console.error("TMDB SHOW API ERROR", tmdbId); 72 | 73 | if (error.message) console.error(error.message); 74 | 75 | return null; 76 | } 77 | } 78 | 79 | // 30 days 80 | const cacheTTL = 60 * 60 * 24 * 30; 81 | 82 | async function getCachedTMDBMovieMeta( 83 | tmdbId: string, 84 | ): Promise { 85 | try { 86 | if (!tmdbId) return null; 87 | 88 | const redisClient = getClient(); 89 | if (!redisClient) return null; 90 | 91 | const dataStr = await redisClient.get(tmdbId); 92 | if (!dataStr) return null; 93 | 94 | const data = JSON.parse(dataStr); 95 | return data; 96 | } catch (error) { 97 | console.error(error); 98 | return null; 99 | } 100 | } 101 | 102 | async function getCachedTMDBShowMeta( 103 | tmdbId: string, 104 | ): Promise { 105 | try { 106 | if (!tmdbId) return null; 107 | 108 | const redisClient = getClient(); 109 | if (!redisClient) return null; 110 | 111 | const dataStr = await redisClient.get(tmdbId); 112 | if (!dataStr) return null; 113 | 114 | const data = JSON.parse(dataStr); 115 | return data; 116 | } catch (error) { 117 | console.error(error); 118 | return null; 119 | } 120 | } 121 | 122 | async function cacheTMDBMovieMeta( 123 | tmdbId: string, 124 | meta: CleanedTMDBMovie, 125 | ): Promise { 126 | try { 127 | const redisClient = getClient(); 128 | if (!redisClient) return; 129 | 130 | await redisClient.set(tmdbId, JSON.stringify(meta), { 131 | EX: cacheTTL, 132 | }); 133 | } catch (error) { 134 | console.error(error); 135 | } 136 | } 137 | 138 | async function cacheTMDBShowMeta( 139 | tmdbId: string, 140 | meta: CleanedTMDBShow, 141 | ): Promise { 142 | try { 143 | const redisClient = getClient(); 144 | if (!redisClient) return; 145 | 146 | await redisClient.set(tmdbId, JSON.stringify(meta), { 147 | EX: cacheTTL, 148 | }); 149 | } catch (error) { 150 | console.error(error); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /backend/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface TMDBMovieResponse { 2 | adult: boolean; 3 | backdrop_path: string; 4 | belongs_to_collection: null | { 5 | id: number; 6 | name: string; 7 | poster_path: string; 8 | backdrop_path: string; 9 | }; 10 | budget: number; 11 | genres: { 12 | id: number; 13 | name: string; 14 | }[]; 15 | homepage: string; 16 | id: number; 17 | imdb_id: string; 18 | original_language: string; 19 | original_title: string; 20 | overview: string; 21 | popularity: number; 22 | poster_path: string; 23 | production_companies: { 24 | id: number; 25 | logo_path: string | null; 26 | name: string; 27 | origin_country: string; 28 | }[]; 29 | production_countries: { 30 | iso_3166_1: string; 31 | name: string; 32 | }[]; 33 | release_date: string; 34 | revenue: number; 35 | runtime: number; 36 | spoken_languages: { 37 | english_name: string; 38 | iso_639_1: string; 39 | name: string; 40 | }[]; 41 | status: string; 42 | tagline: string; 43 | title: string; 44 | video: boolean; 45 | vote_average: number; 46 | vote_count: number; 47 | } 48 | 49 | export interface TMDBShowEpisodeResponse { 50 | air_date: string; 51 | episode_count: number; 52 | id: number; 53 | name: string; 54 | overview: string; 55 | poster_path: string; 56 | season_number: number; 57 | vote_average: number; 58 | } 59 | 60 | export interface TMDBShowResponse { 61 | backdrop_path: string; 62 | created_by: string[]; 63 | episode_run_time: number[]; 64 | first_air_date: string; 65 | genres: { id: number; name: string }[]; 66 | homepage: string; 67 | id: number; 68 | in_production: boolean; 69 | languages: string[]; 70 | last_air_date: string; 71 | last_episode_to_air: TMDBShowEpisodeResponse; 72 | name: string; 73 | networks: { id: number; name: string }[]; 74 | next_episode_to_air: TMDBShowEpisodeResponse; 75 | number_of_episodes: number; 76 | number_of_seasons: number; 77 | origin_country: string[]; 78 | original_language: string; 79 | original_name: string; 80 | overview: string; 81 | popularity: number; 82 | poster_path: string; 83 | production_companies: { id: number; name: string }[]; 84 | seasons: { 85 | air_date: string; 86 | episode_count: number; 87 | id: number; 88 | name: string; 89 | overview: string; 90 | poster_path: string; 91 | season_number: number; 92 | vote_average: number; 93 | }[]; 94 | spoken_languages: { 95 | english_name: string; 96 | iso_639_1: string; 97 | name: string; 98 | }[]; 99 | status: string; 100 | tagline: string; 101 | type: string; 102 | vote_average: number; 103 | vote_count: number; 104 | } 105 | 106 | export interface CleanedTMDBShow { 107 | id: number; 108 | first_air_date: string; 109 | last_air_date: string; 110 | genres: { id: number; name: string }[]; 111 | in_production: boolean; 112 | overview: string; 113 | status: string; 114 | } 115 | 116 | export interface CleanedTMDBMovie { 117 | id: number; 118 | release_date: string; 119 | genres: { id: number; name: string }[]; 120 | overview: string; 121 | status: string; 122 | } 123 | 124 | export interface SimklIds { 125 | simkl: number; 126 | slug: string; 127 | imdb: string; 128 | tvdbmslug: string; 129 | fb: string; 130 | instagram: string; 131 | tw: string; 132 | traktslug: string; 133 | letterslug: string; 134 | tmdb: string; 135 | } 136 | 137 | export interface SimklMeta { 138 | title: string; 139 | poster: string; 140 | year: number; 141 | ids: SimklIds; 142 | } 143 | 144 | export interface SimklShow { 145 | last_watched_at: string; 146 | status: string; 147 | user_rating: null | number; 148 | last_watched: string; 149 | next_to_watch: null | string; 150 | watched_episodes_count: number; 151 | total_episodes_count: number; 152 | not_aired_episodes_count: number; 153 | show: SimklMeta; 154 | } 155 | 156 | export interface SimklMovie { 157 | last_watched_at: null | string; 158 | status: string; 159 | user_rating: null | number; 160 | watched_episodes_count: number; 161 | total_episodes_count: number; 162 | not_aired_episodes_count: number; 163 | movie: SimklMeta; 164 | } 165 | 166 | export interface SimklHistoryResponse { 167 | movies?: SimklMovie[]; 168 | shows?: SimklShow[]; 169 | anime?: SimklShow[]; 170 | } 171 | -------------------------------------------------------------------------------- /backend/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CleanedTMDBMovie, 3 | CleanedTMDBShow, 4 | TMDBMovieResponse, 5 | TMDBShowResponse, 6 | } from "@/types"; 7 | import { CatalogType, allCatalogs, defaultCatalogs } from "@shared/catalogs"; 8 | import { StremioMediaType } from "./lib/mediaTypes"; 9 | 10 | const simklCacheUrl = "https://wsrv.nl/?url=https://simkl.in"; 11 | export function generatePosterUrl(poster: string): string { 12 | if (!poster) { 13 | return `${simklCacheUrl}/poster_no_pic.png`; 14 | } 15 | 16 | return `${simklCacheUrl}/posters/${poster}_m.webp`; 17 | } 18 | 19 | export function slugify(str: string | undefined): string { 20 | if (!str) return ""; 21 | 22 | return str 23 | .toLowerCase() 24 | .replace(/[^\w ]+/g, "") 25 | .replace(/ +/g, "_"); 26 | } 27 | 28 | export function createReleaseInfo( 29 | mediaType: StremioMediaType, 30 | tmdbMeta: CleanedTMDBMovie | CleanedTMDBShow | null, 31 | ): string { 32 | if (!tmdbMeta) return ""; 33 | 34 | if (mediaType === StremioMediaType.Movie) { 35 | return extractYear((tmdbMeta as CleanedTMDBMovie).release_date); 36 | } 37 | 38 | const firstAirDate = (tmdbMeta as CleanedTMDBShow).first_air_date; 39 | const lastAirDate = (tmdbMeta as CleanedTMDBShow).last_air_date; 40 | 41 | if (!firstAirDate) return ""; 42 | 43 | const firstAirYear = extractYear(firstAirDate); 44 | const lastAirYear = extractYear(lastAirDate); 45 | 46 | if ((tmdbMeta as CleanedTMDBShow).in_production) { 47 | return firstAirYear + " - Present"; 48 | } 49 | 50 | if (firstAirYear === lastAirYear) { 51 | return firstAirYear; 52 | } 53 | 54 | return `${firstAirYear} - ${lastAirYear}`; 55 | } 56 | 57 | function extractYear(releaseInfo: string): string { 58 | if (!releaseInfo) return ""; 59 | 60 | return releaseInfo.split("-")[0]; 61 | } 62 | 63 | export function cleanTMDBShowMeta(meta: TMDBShowResponse): CleanedTMDBShow { 64 | return { 65 | id: meta.id, 66 | first_air_date: meta.first_air_date, 67 | last_air_date: meta.last_air_date, 68 | genres: meta.genres, 69 | in_production: meta.in_production, 70 | overview: meta.overview, 71 | status: meta.status, 72 | }; 73 | } 74 | 75 | export function cleanTMDBMovieMeta(meta: TMDBMovieResponse): CleanedTMDBMovie { 76 | return { 77 | id: meta.id, 78 | release_date: meta.release_date, 79 | genres: meta.genres, 80 | overview: meta.overview, 81 | status: meta.status, 82 | }; 83 | } 84 | 85 | export function validateCatalogs( 86 | catalogs: string[] | undefined, 87 | ): CatalogType[] { 88 | if (!catalogs) return defaultCatalogs; 89 | 90 | return catalogs.filter((catalog) => 91 | allCatalogs.includes(catalog as any), 92 | ) as CatalogType[]; 93 | } 94 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | /* Projects */ 5 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 6 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 7 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 8 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 9 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 10 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 11 | /* Language and Environment */ 12 | "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 13 | "lib": [ 14 | "ESNext" 15 | ], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | /* Modules */ 27 | "module": "ESNext", /* Specify what module code is generated. */ 28 | // "rootDir": "./", /* Specify the root folder within your source files. */ 29 | "moduleResolution": "Node", /* Specify how TypeScript looks up a file from a given module specifier. */ 30 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 31 | "paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */ 32 | "@/*": [ 33 | "./src/*" 34 | ], 35 | "@shared/*": [ 36 | "../shared/*" 37 | ] 38 | }, 39 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 40 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 41 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 42 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 43 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 44 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 45 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 46 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 47 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 48 | // "resolveJsonModule": true, /* Enable importing .json files. */ 49 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 50 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 51 | /* JavaScript Support */ 52 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 53 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 54 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 55 | /* Emit */ 56 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 57 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 58 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 59 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 60 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 61 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 62 | "outDir": "./dist", /* Specify an output folder for all emitted files. */ 63 | // "removeComments": true, /* Disable emitting comments. */ 64 | // "noEmit": true, /* Disable emitting files from a compilation. */ 65 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 66 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 67 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 68 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 69 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 70 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 71 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 72 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 73 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 74 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 75 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 76 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 77 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 78 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 79 | /* Interop Constraints */ 80 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 81 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 82 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 83 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 84 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 85 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 86 | /* Type Checking */ 87 | "strict": true, /* Enable all strict type-checking options. */ 88 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 89 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 90 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 91 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 92 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 93 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 94 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 95 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 96 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 97 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 98 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 99 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 100 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 101 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 102 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 103 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 104 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 105 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 106 | /* Completeness */ 107 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 108 | "skipLibCheck": true, /* Skip type checking all .d.ts files. */ 109 | }, 110 | "ts-node": { 111 | "require": [ 112 | "tsconfig-paths/register" 113 | ] 114 | }, 115 | "exclude": [ 116 | "node_modules", 117 | "dist" 118 | ] 119 | } -------------------------------------------------------------------------------- /docker-compose-dev.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | redis: 3 | image: redis:alpine 4 | container_name: redis 5 | ports: 6 | - "6379:6379" 7 | environment: 8 | - REDIS_PASSWORD=redis_password 9 | -------------------------------------------------------------------------------- /frontend/.env.example: -------------------------------------------------------------------------------- 1 | VITE_BACKEND_URL=http://localhost:43001 2 | VITE_SIMKL_CLIENT_ID= 3 | VITE_SIMKL_REDIRECT_URL=http://localhost:5173 -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .env -------------------------------------------------------------------------------- /frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import reactHooks from "eslint-plugin-react-hooks"; 4 | import reactRefresh from "eslint-plugin-react-refresh"; 5 | import tseslint from "typescript-eslint"; 6 | 7 | export default tseslint.config( 8 | { ignores: ["dist"] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ["**/*.{ts,tsx}"], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | "react-hooks": reactHooks, 18 | "react-refresh": reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | "react-refresh/only-export-components": [ 23 | "warn", 24 | { allowConstantExport: true }, 25 | ], 26 | "@typescript-eslint/no-explicit-any": ["off"], 27 | }, 28 | } 29 | ); 30 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Stremio Simkl Watchlists 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stremio-simkl-frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint src --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview", 11 | "format": "prettier --write \"src/**/*.{ts,tsx,js,json}\"" 12 | }, 13 | "dependencies": { 14 | "@fontsource/roboto": "^5.0.8", 15 | "@uiball/loaders": "^1.3.1", 16 | "rdndmb-html5-to-touch": "^9.0.0", 17 | "react": "^18.2.0", 18 | "react-dnd": "^16.0.1", 19 | "react-dnd-multi-backend": "^9.0.0", 20 | "react-dom": "^18.2.0", 21 | "react-wrap-balancer": "^1.1.1", 22 | "sass": "^1.85.1", 23 | "shared": "file:../shared", 24 | "zustand": "^5.0.3" 25 | }, 26 | "devDependencies": { 27 | "@eslint/js": "^9.22.0", 28 | "@types/node": "^22.13.10", 29 | "@types/react": "^18.2.43", 30 | "@types/react-dom": "^18.2.17", 31 | "@vitejs/plugin-react": "^4.3.4", 32 | "eslint": "^9.22.0", 33 | "eslint-plugin-react-hooks": "^5.2.0", 34 | "eslint-plugin-react-refresh": "^0.4.19", 35 | "globals": "^16.0.0", 36 | "prettier": "^3.5.3", 37 | "typescript": "^5.2.2", 38 | "typescript-eslint": "^8.27.0", 39 | "vite": "^6.2.0" 40 | } 41 | } -------------------------------------------------------------------------------- /frontend/public/shai-pal-unsplash.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nktfh100/stremio-simkl/091ca239784e97e9adc1e3917fef6f5980149937/frontend/public/shai-pal-unsplash.webp -------------------------------------------------------------------------------- /frontend/public/simkl-logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nktfh100/stremio-simkl/091ca239784e97e9adc1e3917fef6f5980149937/frontend/public/simkl-logo.webp -------------------------------------------------------------------------------- /frontend/public/stremio-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /frontend/src/App.module.scss: -------------------------------------------------------------------------------- 1 | .app { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | height: 100%; 6 | } 7 | 8 | .content { 9 | display: flex; 10 | flex-direction: column; 11 | align-items: center; 12 | text-align: center; 13 | background-color: rgb(255, 255, 255); 14 | width: 35rem; 15 | margin-top: 12rem; 16 | box-shadow: 17 | rgba(0, 0, 0, 0.09) 0px 2px 1px, 18 | rgba(0, 0, 0, 0.09) 0px 4px 2px, 19 | rgba(0, 0, 0, 0.09) 0px 8px 4px, 20 | rgba(0, 0, 0, 0.09) 0px 16px 8px, 21 | rgba(0, 0, 0, 0.09) 0px 32px 16px; 22 | border-radius: 0.8rem; 23 | padding: 3rem; 24 | } 25 | 26 | .content--expanded { 27 | margin-top: 4.5rem; 28 | } 29 | 30 | .logos { 31 | display: flex; 32 | flex-direction: row; 33 | align-items: center; 34 | gap: 1rem; 35 | 36 | img { 37 | width: 5rem; 38 | height: 5rem; 39 | border-radius: 0.5rem; 40 | } 41 | 42 | p { 43 | font-size: 2.8rem; 44 | } 45 | } 46 | 47 | .title { 48 | margin-top: 1rem; 49 | } 50 | 51 | .version { 52 | margin-top: 0.4rem; 53 | } 54 | 55 | .description { 56 | margin-top: 1.5rem; 57 | font-size: 1.3rem; 58 | font-weight: 400; 59 | color: #2b2b2b; 60 | width: 80%; 61 | } 62 | 63 | .button-container { 64 | margin-top: 2rem; 65 | } 66 | 67 | @media (max-width: 900px) { 68 | .app { 69 | justify-content: center; 70 | } 71 | 72 | .content { 73 | margin-top: 0; 74 | width: 96%; 75 | min-height: 65%; 76 | justify-content: center; 77 | padding: 1rem; 78 | } 79 | 80 | .logos { 81 | img { 82 | width: 4rem; 83 | height: 4rem; 84 | } 85 | 86 | p { 87 | font-size: 2.4rem; 88 | } 89 | } 90 | 91 | .title { 92 | font-size: 1.8rem; 93 | } 94 | 95 | .description { 96 | font-size: 1.4rem; 97 | margin-top: 0.5rem; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import Balancer from "react-wrap-balancer"; 2 | import GenerateLink from "@/components/GenerateLink/GenerateLink"; 3 | import LinkBtn from "./components/LinkBtn/LinkBtn"; 4 | import SimklAuth from "@/components/SimklAuth/SimklAuth"; 5 | import styles from "@/App.module.scss"; 6 | import useAppStore from "@/lib/appStore"; 7 | import { SelectCatalogs } from "./components/SelectCatalogs/SelectCatalogs"; 8 | 9 | export default function App() { 10 | const simklCode = useAppStore((state) => state.code); 11 | const installLink = useAppStore((state) => state.installLink); 12 | 13 | return ( 14 |
15 |
20 |
21 | Stremio Logo 22 |

+

23 | Simkl Logo 24 |
25 |

Stremio Simkl Watchlists

26 |

v0.2.7

27 | 28 |

29 | 30 | Unofficial Stremio addon to display your Simkl Watchlists in Stremio 31 | 32 |

33 | 34 | {simklCode && !installLink && } 35 | 36 |
37 | {!simklCode && } 38 | 39 | {simklCode && !installLink ? : null} 40 | 41 | {simklCode && installLink ? ( 42 | 43 | Install 44 | 45 | ) : null} 46 |
47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /frontend/src/components/GenerateLink/GenerateLink.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | import useAppStore, { setInstallLink, setSimklAuthCode } from "@/lib/appStore"; 4 | import { removeUrlParam } from "@/lib/utils"; 5 | import { Ring } from "@uiball/loaders"; 6 | 7 | import LinkBtn from "../LinkBtn/LinkBtn"; 8 | 9 | export default function GenerateLink() { 10 | const simklCode = useAppStore((state) => state.code); 11 | const installLink = useAppStore((state) => state.installLink); 12 | const selectedCatalogs = useAppStore((state) => state.selectedCatalogs); 13 | const [isLinkLoading, setIsLinkLoading] = useState(false); 14 | 15 | const handleGenLinkBtn = async () => { 16 | if (installLink || isLinkLoading) { 17 | return; 18 | } 19 | 20 | try { 21 | setIsLinkLoading(true); 22 | const response = await fetch( 23 | `${import.meta.env.VITE_BACKEND_URL}/gen-link`, 24 | { 25 | method: "POST", 26 | headers: { 27 | "Content-Type": "application/json", 28 | }, 29 | body: JSON.stringify({ 30 | code: simklCode, 31 | selectedCatalogs, 32 | }), 33 | }, 34 | ); 35 | const data = await response.json(); 36 | 37 | setIsLinkLoading(false); 38 | 39 | if (data.error || !data.link) { 40 | alert( 41 | data.error 42 | ? "Error: " + data.error 43 | : "Unknown error generating install link", 44 | ); 45 | console.error(data.error); 46 | setSimklAuthCode(undefined); 47 | removeUrlParam("code"); 48 | return; 49 | } 50 | 51 | setInstallLink(data.link); 52 | } catch (err) { 53 | console.error(err); 54 | setIsLinkLoading(false); 55 | alert("Could not reach backend server, please try again later."); 56 | } 57 | }; 58 | 59 | return ( 60 | 61 | {isLinkLoading ? ( 62 | 67 | 68 | 69 | ) : ( 70 | "Generate Install Link" 71 | )} 72 | 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /frontend/src/components/LinkBtn/LinkBtn.module.scss: -------------------------------------------------------------------------------- 1 | .link-btn { 2 | background-color: black; 3 | color: #fff; 4 | font-weight: bold; 5 | font-size: 1.3rem; 6 | padding: 0.7rem 1.2rem; 7 | border-radius: 0.25rem; 8 | transition: background-color 0.2s; 9 | cursor: pointer; 10 | border: none; 11 | outline: none; 12 | &:hover { 13 | background-color: #2255DE; 14 | } 15 | } 16 | 17 | .link-btn--reversed { 18 | background-color: #2255DE; 19 | &:hover { 20 | background-color: black; 21 | } 22 | } -------------------------------------------------------------------------------- /frontend/src/components/LinkBtn/LinkBtn.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./LinkBtn.module.scss"; 2 | 3 | export default function LinkBtn({ 4 | href, 5 | children, 6 | onClick, 7 | colorsReversed, 8 | }: { 9 | href?: string; 10 | onClick?: () => void; 11 | children: React.ReactNode; 12 | colorsReversed?: boolean; 13 | }) { 14 | const className = `${styles["link-btn"]} ${ 15 | colorsReversed ? styles["link-btn--reversed"] : "" 16 | }`; 17 | if (href) { 18 | return ( 19 | 20 | {children} 21 | 22 | ); 23 | } 24 | 25 | return ( 26 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /frontend/src/components/SelectCatalogs/CatalogItem.module.scss: -------------------------------------------------------------------------------- 1 | .catalog-item { 2 | display: flex; 3 | align-items: center; 4 | gap: 0.5rem; 5 | padding: 0.5rem; 6 | background: white; 7 | border-radius: 0.5rem; 8 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); 9 | cursor: grab; 10 | transition: 0.2s; 11 | margin-bottom: 0.5rem; 12 | user-select: none; 13 | } 14 | 15 | .catalog-item:last-child { 16 | margin-bottom: 0; 17 | } 18 | 19 | .catalog-item:hover { 20 | background: #f0f0f0; 21 | } 22 | 23 | .catalog-checkbox-container { 24 | width: 2rem; 25 | height: 2rem; 26 | display: flex; 27 | justify-content: center; 28 | align-items: center; 29 | } 30 | 31 | .catalog-checkbox { 32 | width: 1rem; 33 | height: 1rem; 34 | } 35 | 36 | .drag-handle { 37 | margin-bottom: 0.2rem; 38 | } 39 | -------------------------------------------------------------------------------- /frontend/src/components/SelectCatalogs/CatalogItem.tsx: -------------------------------------------------------------------------------- 1 | import { useDrag, useDrop } from "react-dnd"; 2 | import styles from "./CatalogItem.module.scss"; 3 | import { CatalogType, frontendCatalogNames } from "@shared/catalogs"; 4 | import { useRef } from "react"; 5 | import type { Identifier, XYCoord } from "dnd-core"; 6 | import { isMobileDevice } from "@/lib/utils"; 7 | 8 | type CatalogItemProps = { 9 | catalog: CatalogType; 10 | index: number; 11 | moveCatalog: (dragIndex: number, hoverIndex: number) => void; 12 | toggleSelect: (catalog: CatalogType) => void; 13 | isSelected: boolean; 14 | }; 15 | 16 | type DragItem = { 17 | index: number; 18 | type: "CATALOG"; 19 | catalog: CatalogType; 20 | }; 21 | 22 | const isMobile = isMobileDevice(); 23 | 24 | export const CatalogItem = ({ 25 | catalog, 26 | index, 27 | moveCatalog, 28 | toggleSelect, 29 | isSelected, 30 | }: CatalogItemProps) => { 31 | const ref = useRef(null); 32 | 33 | const [{ handlerId }, drop] = useDrop< 34 | DragItem, 35 | void, 36 | { handlerId: Identifier | null } 37 | >({ 38 | accept: "CATALOG", 39 | collect(monitor) { 40 | return { 41 | handlerId: monitor.getHandlerId(), 42 | }; 43 | }, 44 | hover(item: DragItem, monitor) { 45 | if (!ref.current) { 46 | return; 47 | } 48 | const dragIndex = item.index; 49 | const hoverIndex = index; 50 | 51 | if (dragIndex === hoverIndex) { 52 | return; 53 | } 54 | 55 | const hoverBoundingRect = ref.current?.getBoundingClientRect(); 56 | 57 | const hoverMiddleY = 58 | (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; 59 | 60 | const clientOffset = monitor.getClientOffset(); 61 | 62 | const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top; 63 | 64 | if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) { 65 | return; 66 | } 67 | 68 | if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) { 69 | return; 70 | } 71 | 72 | moveCatalog(dragIndex, hoverIndex); 73 | 74 | item.index = hoverIndex; 75 | }, 76 | }); 77 | 78 | const [{ isDragging }, drag] = useDrag({ 79 | type: "CATALOG", 80 | item: () => { 81 | return { index, catalog, type: "CATALOG" }; 82 | }, 83 | collect: (monitor: any) => ({ 84 | isDragging: monitor.isDragging(), 85 | }), 86 | }); 87 | 88 | const draggingOpacity = isMobile ? 1 : 0.3; 89 | const opacity = isDragging ? draggingOpacity : isSelected ? 1 : 0.3; 90 | 91 | drag(drop(ref)); 92 | 93 | return ( 94 |
103 |
toggleSelect(catalog)} 106 | > 107 | toggleSelect(catalog)} 111 | className={styles["catalog-checkbox"]} 112 | /> 113 |
114 | 115 | {frontendCatalogNames[catalog]} 116 |
117 | ); 118 | }; 119 | -------------------------------------------------------------------------------- /frontend/src/components/SelectCatalogs/SelectCatalogs.module.scss: -------------------------------------------------------------------------------- 1 | .catalog-container { 2 | width: 95%; 3 | text-align: center; 4 | margin-top: 0.8rem; 5 | } 6 | 7 | .catalog-list { 8 | background: #fff; 9 | padding: 0.9rem; 10 | border-radius: 0.5rem; 11 | margin-top: 0.8rem; 12 | box-shadow: 0 0 0.4rem rgba(0, 0, 0, 0.1); 13 | } 14 | 15 | @media (max-width: 900px) { 16 | .catalog-container { 17 | margin-top: 0.3rem; 18 | } 19 | 20 | .catalog-list { 21 | margin-top: 0.4rem; 22 | padding: 0.5rem; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/components/SelectCatalogs/SelectCatalogs.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import styles from "./SelectCatalogs.module.scss"; 3 | import { CatalogType, allCatalogs, defaultCatalogs } from "@shared/catalogs"; 4 | import { CatalogItem } from "./CatalogItem"; 5 | import { setSelectedCatalogs } from "@/lib/appStore"; 6 | 7 | import { DndProvider } from "react-dnd-multi-backend"; 8 | import { HTML5toTouch } from "rdndmb-html5-to-touch"; 9 | 10 | export const SelectCatalogs = () => { 11 | const [orderedCatalogs, setOrderedCatalogs] = useState( 12 | allCatalogs.map((c) => ({ 13 | name: c, 14 | selected: defaultCatalogs.includes(c), 15 | })), 16 | ); 17 | 18 | const toggleSelect = (catalog: CatalogType) => { 19 | const newOrderedCatalogs = orderedCatalogs.map((c) => 20 | c.name === catalog ? { ...c, selected: !c.selected } : c, 21 | ); 22 | 23 | setOrderedCatalogs(newOrderedCatalogs); 24 | setSelectedCatalogs( 25 | newOrderedCatalogs.filter((c) => c.selected).map((c) => c.name), 26 | ); 27 | }; 28 | 29 | const moveCatalog = (fromIndex: number, toIndex: number) => { 30 | const updatedCatalogs = [...orderedCatalogs]; 31 | const [movedItem] = updatedCatalogs.splice(fromIndex, 1); 32 | updatedCatalogs.splice(toIndex, 0, movedItem); 33 | 34 | setOrderedCatalogs(updatedCatalogs); 35 | setSelectedCatalogs( 36 | updatedCatalogs.filter((c) => c.selected).map((c) => c.name), 37 | ); 38 | }; 39 | 40 | return ( 41 |
42 |

Catalogs:

43 | 44 |
45 | 46 | {orderedCatalogs.map(({ name, selected }, index) => ( 47 | 55 | ))} 56 | 57 |
58 |
59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /frontend/src/components/SimklAuth/SimklAuth.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | import { setSimklAuthCode } from "@/lib/appStore"; 4 | 5 | import LinkBtn from "../LinkBtn/LinkBtn"; 6 | 7 | const clientId = import.meta.env.VITE_SIMKL_CLIENT_ID; 8 | const redirectUrl = import.meta.env.VITE_SIMKL_REDIRECT_URL; 9 | 10 | export default function SimklAuth() { 11 | useEffect(() => { 12 | const urlParams = new URLSearchParams(window.location.search); 13 | const codeParam = urlParams.get("code"); 14 | 15 | if (codeParam) { 16 | setSimklAuthCode(codeParam); 17 | } 18 | }, []); 19 | 20 | const authLink = `https://simkl.com/oauth/authorize?response_type=code&client_id=${clientId}&redirect_uri=${redirectUrl}`; 21 | 22 | return Login with Simkl; 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/index.scss: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | padding: 0; 4 | margin: 0; 5 | } 6 | 7 | html, body, #root { 8 | height: 100%; 9 | } 10 | 11 | body { 12 | color: #1d1d1d; 13 | background-color: #4e4e4e; 14 | font-family: "Roboto"; 15 | background-image: url("/shai-pal-unsplash.webp"); 16 | background-repeat: no-repeat; 17 | background-size: cover; 18 | background-position: center; 19 | } 20 | 21 | body::after { 22 | content: "Photo by Shai Pal on Unsplash"; 23 | position: absolute; 24 | left: 1rem; 25 | bottom: 1rem; 26 | font-size: 0.8rem; 27 | padding: 0.5rem; 28 | color: #fff; 29 | background-color: rgba(0, 0, 0, 0.5); 30 | border-radius: 0.5rem; 31 | width: max-content; 32 | } 33 | 34 | a { 35 | text-decoration: none; 36 | color: inherit; 37 | } 38 | 39 | @media (max-width: 900px) { 40 | body::after { 41 | content: none; 42 | } 43 | } -------------------------------------------------------------------------------- /frontend/src/lib/appStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | import { AppStoreState } from "@/lib/types"; 4 | import { CatalogType } from "@shared/catalogs"; 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 7 | const useAppStore = create()((_set) => ({ 8 | code: undefined, 9 | installLink: undefined, 10 | selectedCatalogs: [ 11 | CatalogType.MOVIE_PLAN_TO_WATCH, 12 | CatalogType.SERIES_WATCHING, 13 | CatalogType.SERIES_PLAN_TO_WATCH, 14 | ], 15 | })); 16 | 17 | export const setSimklAuthCode = (code: string | undefined) => 18 | useAppStore.setState({ code }); 19 | 20 | export const setInstallLink = (installLink: string | undefined) => 21 | useAppStore.setState({ installLink }); 22 | 23 | export const setSelectedCatalogs = (selectedCatalogs: CatalogType[]) => 24 | useAppStore.setState({ selectedCatalogs }); 25 | 26 | export default useAppStore; 27 | -------------------------------------------------------------------------------- /frontend/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import { CatalogType } from "@shared/catalogs"; 2 | 3 | export interface AppStoreState { 4 | code: string | undefined; 5 | installLink: string | undefined; 6 | selectedCatalogs: CatalogType[]; 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | export function removeUrlParam(param: string) { 2 | const url = new URL(window.location.href); 3 | url.searchParams.delete(param); 4 | window.history.replaceState({}, "", url.toString()); 5 | } 6 | export const isMobileDevice = () => { 7 | /* eslint-disable */ 8 | let check = false; 9 | (function (a) { 10 | if ( 11 | /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test( 12 | a, 13 | ) || 14 | /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test( 15 | a.substr(0, 4), 16 | ) 17 | ) 18 | check = true; 19 | })(navigator.userAgent || navigator.vendor || (window as any).opera); 20 | /* eslint-enable */ 21 | return check; 22 | }; 23 | -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import "@/index.scss"; 2 | import "@fontsource/roboto/400.css"; 3 | import "@fontsource/roboto/700.css"; 4 | 5 | import React from "react"; 6 | import ReactDOM from "react-dom/client"; 7 | 8 | import App from "@/App.tsx"; 9 | 10 | ReactDOM.createRoot(document.getElementById("root")!).render( 11 | 12 | 13 | , 14 | ); 15 | -------------------------------------------------------------------------------- /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": [ 6 | "ES2020", 7 | "DOM", 8 | "DOM.Iterable" 9 | ], 10 | "module": "ESNext", 11 | "skipLibCheck": true, 12 | /* Bundler mode */ 13 | "moduleResolution": "bundler", 14 | "allowImportingTsExtensions": true, 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "baseUrl": ".", 25 | "paths": { 26 | "@/*": [ 27 | "src/*" 28 | ], 29 | "@shared/*": [ 30 | "../shared/*" 31 | ] 32 | } 33 | }, 34 | "include": [ 35 | "src" 36 | ], 37 | "references": [ 38 | { 39 | "path": "./tsconfig.node.json" 40 | } 41 | ] 42 | } -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { defineConfig } from "vite"; 3 | import react from "@vitejs/plugin-react"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | resolve: { 9 | alias: { 10 | "@": path.resolve(__dirname, "./src"), 11 | "@shared": path.resolve(__dirname, "../shared"), 12 | }, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stremio-simkl", 3 | "version": "0.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "stremio-simkl", 9 | "version": "0.0.0", 10 | "hasInstallScript": true, 11 | "license": "ISC", 12 | "dependencies": { 13 | "concurrently": "^9.1.2" 14 | } 15 | }, 16 | "node_modules/ansi-regex": { 17 | "version": "5.0.1", 18 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", 19 | "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 20 | "license": "MIT", 21 | "engines": { 22 | "node": ">=8" 23 | } 24 | }, 25 | "node_modules/ansi-styles": { 26 | "version": "4.3.0", 27 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", 28 | "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 29 | "license": "MIT", 30 | "dependencies": { 31 | "color-convert": "^2.0.1" 32 | }, 33 | "engines": { 34 | "node": ">=8" 35 | }, 36 | "funding": { 37 | "url": "https://github.com/chalk/ansi-styles?sponsor=1" 38 | } 39 | }, 40 | "node_modules/chalk": { 41 | "version": "4.1.2", 42 | "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", 43 | "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", 44 | "license": "MIT", 45 | "dependencies": { 46 | "ansi-styles": "^4.1.0", 47 | "supports-color": "^7.1.0" 48 | }, 49 | "engines": { 50 | "node": ">=10" 51 | }, 52 | "funding": { 53 | "url": "https://github.com/chalk/chalk?sponsor=1" 54 | } 55 | }, 56 | "node_modules/chalk/node_modules/supports-color": { 57 | "version": "7.2.0", 58 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", 59 | "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", 60 | "license": "MIT", 61 | "dependencies": { 62 | "has-flag": "^4.0.0" 63 | }, 64 | "engines": { 65 | "node": ">=8" 66 | } 67 | }, 68 | "node_modules/cliui": { 69 | "version": "8.0.1", 70 | "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", 71 | "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", 72 | "license": "ISC", 73 | "dependencies": { 74 | "string-width": "^4.2.0", 75 | "strip-ansi": "^6.0.1", 76 | "wrap-ansi": "^7.0.0" 77 | }, 78 | "engines": { 79 | "node": ">=12" 80 | } 81 | }, 82 | "node_modules/color-convert": { 83 | "version": "2.0.1", 84 | "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", 85 | "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", 86 | "license": "MIT", 87 | "dependencies": { 88 | "color-name": "~1.1.4" 89 | }, 90 | "engines": { 91 | "node": ">=7.0.0" 92 | } 93 | }, 94 | "node_modules/color-name": { 95 | "version": "1.1.4", 96 | "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 97 | "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", 98 | "license": "MIT" 99 | }, 100 | "node_modules/concurrently": { 101 | "version": "9.1.2", 102 | "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.1.2.tgz", 103 | "integrity": "sha512-H9MWcoPsYddwbOGM6difjVwVZHl63nwMEwDJG/L7VGtuaJhb12h2caPG2tVPWs7emuYix252iGfqOyrz1GczTQ==", 104 | "license": "MIT", 105 | "dependencies": { 106 | "chalk": "^4.1.2", 107 | "lodash": "^4.17.21", 108 | "rxjs": "^7.8.1", 109 | "shell-quote": "^1.8.1", 110 | "supports-color": "^8.1.1", 111 | "tree-kill": "^1.2.2", 112 | "yargs": "^17.7.2" 113 | }, 114 | "bin": { 115 | "conc": "dist/bin/concurrently.js", 116 | "concurrently": "dist/bin/concurrently.js" 117 | }, 118 | "engines": { 119 | "node": ">=18" 120 | }, 121 | "funding": { 122 | "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" 123 | } 124 | }, 125 | "node_modules/emoji-regex": { 126 | "version": "8.0.0", 127 | "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", 128 | "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", 129 | "license": "MIT" 130 | }, 131 | "node_modules/escalade": { 132 | "version": "3.2.0", 133 | "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", 134 | "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", 135 | "license": "MIT", 136 | "engines": { 137 | "node": ">=6" 138 | } 139 | }, 140 | "node_modules/get-caller-file": { 141 | "version": "2.0.5", 142 | "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", 143 | "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", 144 | "license": "ISC", 145 | "engines": { 146 | "node": "6.* || 8.* || >= 10.*" 147 | } 148 | }, 149 | "node_modules/has-flag": { 150 | "version": "4.0.0", 151 | "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", 152 | "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", 153 | "license": "MIT", 154 | "engines": { 155 | "node": ">=8" 156 | } 157 | }, 158 | "node_modules/is-fullwidth-code-point": { 159 | "version": "3.0.0", 160 | "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", 161 | "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", 162 | "license": "MIT", 163 | "engines": { 164 | "node": ">=8" 165 | } 166 | }, 167 | "node_modules/lodash": { 168 | "version": "4.17.21", 169 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", 170 | "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", 171 | "license": "MIT" 172 | }, 173 | "node_modules/require-directory": { 174 | "version": "2.1.1", 175 | "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", 176 | "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", 177 | "license": "MIT", 178 | "engines": { 179 | "node": ">=0.10.0" 180 | } 181 | }, 182 | "node_modules/rxjs": { 183 | "version": "7.8.1", 184 | "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", 185 | "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", 186 | "license": "Apache-2.0", 187 | "dependencies": { 188 | "tslib": "^2.1.0" 189 | } 190 | }, 191 | "node_modules/shell-quote": { 192 | "version": "1.8.2", 193 | "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", 194 | "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", 195 | "license": "MIT", 196 | "engines": { 197 | "node": ">= 0.4" 198 | }, 199 | "funding": { 200 | "url": "https://github.com/sponsors/ljharb" 201 | } 202 | }, 203 | "node_modules/string-width": { 204 | "version": "4.2.3", 205 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", 206 | "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", 207 | "license": "MIT", 208 | "dependencies": { 209 | "emoji-regex": "^8.0.0", 210 | "is-fullwidth-code-point": "^3.0.0", 211 | "strip-ansi": "^6.0.1" 212 | }, 213 | "engines": { 214 | "node": ">=8" 215 | } 216 | }, 217 | "node_modules/strip-ansi": { 218 | "version": "6.0.1", 219 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", 220 | "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 221 | "license": "MIT", 222 | "dependencies": { 223 | "ansi-regex": "^5.0.1" 224 | }, 225 | "engines": { 226 | "node": ">=8" 227 | } 228 | }, 229 | "node_modules/supports-color": { 230 | "version": "8.1.1", 231 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", 232 | "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", 233 | "license": "MIT", 234 | "dependencies": { 235 | "has-flag": "^4.0.0" 236 | }, 237 | "engines": { 238 | "node": ">=10" 239 | }, 240 | "funding": { 241 | "url": "https://github.com/chalk/supports-color?sponsor=1" 242 | } 243 | }, 244 | "node_modules/tree-kill": { 245 | "version": "1.2.2", 246 | "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", 247 | "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", 248 | "license": "MIT", 249 | "bin": { 250 | "tree-kill": "cli.js" 251 | } 252 | }, 253 | "node_modules/tslib": { 254 | "version": "2.8.1", 255 | "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", 256 | "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", 257 | "license": "0BSD" 258 | }, 259 | "node_modules/wrap-ansi": { 260 | "version": "7.0.0", 261 | "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", 262 | "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", 263 | "license": "MIT", 264 | "dependencies": { 265 | "ansi-styles": "^4.0.0", 266 | "string-width": "^4.1.0", 267 | "strip-ansi": "^6.0.0" 268 | }, 269 | "engines": { 270 | "node": ">=10" 271 | }, 272 | "funding": { 273 | "url": "https://github.com/chalk/wrap-ansi?sponsor=1" 274 | } 275 | }, 276 | "node_modules/y18n": { 277 | "version": "5.0.8", 278 | "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", 279 | "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", 280 | "license": "ISC", 281 | "engines": { 282 | "node": ">=10" 283 | } 284 | }, 285 | "node_modules/yargs": { 286 | "version": "17.7.2", 287 | "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", 288 | "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", 289 | "license": "MIT", 290 | "dependencies": { 291 | "cliui": "^8.0.1", 292 | "escalade": "^3.1.1", 293 | "get-caller-file": "^2.0.5", 294 | "require-directory": "^2.1.1", 295 | "string-width": "^4.2.3", 296 | "y18n": "^5.0.5", 297 | "yargs-parser": "^21.1.1" 298 | }, 299 | "engines": { 300 | "node": ">=12" 301 | } 302 | }, 303 | "node_modules/yargs-parser": { 304 | "version": "21.1.1", 305 | "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", 306 | "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", 307 | "license": "ISC", 308 | "engines": { 309 | "node": ">=12" 310 | } 311 | } 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stremio-simkl", 3 | "version": "0.0.0", 4 | "description": "Stremio addon to display your Simkl Watchlists", 5 | "main": "index.js", 6 | "scripts": { 7 | "install:frontend": "cd frontend && npm install", 8 | "install:backend": "cd backend && npm install", 9 | "install": "concurrently \"npm run install:frontend\" \"npm run install:backend\"", 10 | "start:redis": "docker compose -f docker-compose-dev.yaml up -d", 11 | "dev:frontend": "cd frontend && npm run dev", 12 | "dev:backend": "cd backend && npm run dev", 13 | "dev": "concurrently \"npm run start:redis\" \"npm run dev:frontend\" \"npm run dev:backend\"" 14 | }, 15 | "author": "", 16 | "license": "ISC", 17 | "dependencies": { 18 | "concurrently": "^9.1.2" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /shared/catalogs/index.ts: -------------------------------------------------------------------------------- 1 | const catalogExtra = [ 2 | { 3 | name: "skip", 4 | isRequired: false, 5 | }, 6 | ]; 7 | 8 | export enum CatalogType { 9 | MOVIE_PLAN_TO_WATCH = "simkl-plan-to-watch-movie", 10 | MOVIE_COMPLETED = "simkl-completed-movie", 11 | SERIES_WATCHING = "simkl-watching", 12 | SERIES_PLAN_TO_WATCH = "simkl-plan-to-watch-series", 13 | SERIES_COMPLETED = "simkl-completed-series", 14 | ANIME_WATCHING = "simkl-watching-anime", 15 | ANIME_PLAN_TO_WATCH = "simkl-plan-to-watch-anime", 16 | ANIME_COMPLETED = "simkl-completed-anime", 17 | } 18 | 19 | export const allCatalogs = Object.values(CatalogType); 20 | 21 | export const defaultCatalogs = [ 22 | CatalogType.MOVIE_PLAN_TO_WATCH, 23 | CatalogType.SERIES_WATCHING, 24 | CatalogType.SERIES_PLAN_TO_WATCH, 25 | ]; 26 | 27 | export const frontendCatalogNames: Record = { 28 | [CatalogType.MOVIE_PLAN_TO_WATCH]: "Movies Plan To Watch", 29 | [CatalogType.MOVIE_COMPLETED]: "Movies Completed", 30 | [CatalogType.SERIES_WATCHING]: "Series Watching", 31 | [CatalogType.SERIES_PLAN_TO_WATCH]: "Series Plan To Watch", 32 | [CatalogType.SERIES_COMPLETED]: "Series Completed", 33 | [CatalogType.ANIME_WATCHING]: "Anime Watching", 34 | [CatalogType.ANIME_PLAN_TO_WATCH]: "Anime Plan To Watch", 35 | [CatalogType.ANIME_COMPLETED]: "Anime Completed", 36 | }; 37 | 38 | export const catalogToInt = (catalog: CatalogType) => { 39 | return allCatalogs.indexOf(catalog); 40 | }; 41 | 42 | export const catalogsData = { 43 | [CatalogType.MOVIE_PLAN_TO_WATCH]: { 44 | id: CatalogType.MOVIE_PLAN_TO_WATCH, 45 | type: "movie", 46 | name: "SIMKL Plan To Watch", 47 | extra: catalogExtra, 48 | }, 49 | [CatalogType.MOVIE_COMPLETED]: { 50 | id: CatalogType.MOVIE_COMPLETED, 51 | type: "movie", 52 | name: "SIMKL Completed", 53 | extra: catalogExtra, 54 | }, 55 | [CatalogType.SERIES_WATCHING]: { 56 | id: CatalogType.SERIES_WATCHING, 57 | type: "series", 58 | name: "SIMKL Watching", 59 | extra: catalogExtra, 60 | }, 61 | [CatalogType.SERIES_PLAN_TO_WATCH]: { 62 | id: CatalogType.SERIES_PLAN_TO_WATCH, 63 | type: "series", 64 | name: "SIMKL Plan To Watch", 65 | extra: catalogExtra, 66 | }, 67 | [CatalogType.SERIES_COMPLETED]: { 68 | id: CatalogType.SERIES_COMPLETED, 69 | type: "series", 70 | name: "SIMKL Completed", 71 | extra: catalogExtra, 72 | }, 73 | [CatalogType.ANIME_WATCHING]: { 74 | id: CatalogType.ANIME_WATCHING, 75 | type: "anime", 76 | name: "SIMKL Watching", 77 | extra: catalogExtra, 78 | }, 79 | [CatalogType.ANIME_PLAN_TO_WATCH]: { 80 | id: CatalogType.ANIME_PLAN_TO_WATCH, 81 | type: "anime", 82 | name: "SIMKL Plan To Watch", 83 | extra: catalogExtra, 84 | }, 85 | [CatalogType.ANIME_COMPLETED]: { 86 | id: CatalogType.ANIME_COMPLETED, 87 | type: "anime", 88 | name: "SIMKL Completed", 89 | extra: catalogExtra, 90 | }, 91 | }; 92 | --------------------------------------------------------------------------------