├── .env.example ├── .github ├── FUNDING.yml ├── actions │ └── pnpm-install │ │ └── action.yml ├── assets │ ├── trakt-black.svg │ └── trakt-white.svg └── workflows │ └── unit-testing.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── eslint.config.js ├── netlify.toml ├── package.json ├── pnpm-lock.yaml ├── postcss.config.cjs ├── src ├── app.d.ts ├── app.html ├── assets │ ├── color-modes.png │ ├── default-preview.png │ ├── noise.png │ └── screenshot-mode.png ├── auth.ts ├── const.ts ├── hooks.server.ts ├── lib │ ├── Image.svelte │ ├── InfiniteLoading.svelte │ ├── Meta.svelte │ ├── PlausibleAnalytics.svelte │ ├── Spacer.svelte │ ├── Svg.svelte │ ├── Switch.svelte │ ├── __tests__ │ │ └── actions.ts │ ├── actions.ts │ ├── button │ │ ├── Primary.svelte │ │ └── Secondary.svelte │ ├── grid │ │ ├── Grid.svelte │ │ └── Item.svelte │ ├── server │ │ └── const.ts │ ├── skip-to-content │ │ ├── Content.svelte │ │ └── Nav.svelte │ ├── store │ │ ├── persisted.ts │ │ ├── plausible.ts │ │ ├── settings.ts │ │ └── stats.ts │ ├── types.ts │ └── utils │ │ ├── __tests__ │ │ ├── __fixtures__ │ │ │ ├── history.shows.ts │ │ │ └── normalize.ts │ │ ├── index.ts │ │ ├── tmdb.ts │ │ └── trakt.ts │ │ ├── index.ts │ │ ├── tmdb.ts │ │ └── trakt.ts ├── routes │ ├── +error.svelte │ ├── +layout.server.ts │ ├── +layout.svelte │ ├── +page.svelte │ ├── Footer.svelte │ ├── Header.svelte │ ├── about │ │ └── +page.svelte │ ├── api │ │ ├── history │ │ │ └── [type] │ │ │ │ └── +server.ts │ │ ├── tmdb-image │ │ │ └── +server.ts │ │ ├── user-stats │ │ │ └── [id] │ │ │ │ └── +server.ts │ │ └── watched │ │ │ └── [type] │ │ │ └── +server.ts │ ├── dashboard │ │ ├── +layout.server.ts │ │ ├── +layout.svelte │ │ ├── +page.svelte │ │ └── [type] │ │ │ └── [year] │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ ├── sign-in │ │ └── +page.svelte │ └── style.css └── styles │ ├── base.css │ ├── custom-media-queries.css │ ├── reset.css │ ├── utilities.css │ └── variables.css ├── static ├── _redirects ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── icons.svg ├── og-image.png ├── robots.txt └── site.webmanifest ├── svelte.config.js ├── tsconfig.json └── vite.config.ts /.env.example: -------------------------------------------------------------------------------- 1 | PRIVATE_TRAKT_CLIENT_ID= 2 | PRIVATE_TRAKT_CLIENT_SECRET= 3 | PRIVATE_AUTH_SECRET= 4 | PRIVATE_TMDB_API_KEY= 5 | NEXTAUTH_URL=http://localhost:4173 -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [LekoArts] 4 | patreon: # Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: lekoarts 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | custom: # Replace with a single custom sponsorship URL 9 | -------------------------------------------------------------------------------- /.github/actions/pnpm-install/action.yml: -------------------------------------------------------------------------------- 1 | # Based on https://gist.github.com/belgattitude/838b2eba30c324f1f0033a797bab2e31 2 | 3 | name: PNPM install 4 | description: Run pnpm install with cache enabled 5 | 6 | inputs: 7 | enable-corepack: 8 | description: Enable corepack 9 | required: false 10 | default: 'true' 11 | 12 | runs: 13 | using: composite 14 | 15 | steps: 16 | - name: Install Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: lts/* 20 | - name: ⚙️ Enable Corepack 21 | if: ${{ inputs.enable-corepack == 'true' }} 22 | shell: bash 23 | run: | 24 | corepack enable 25 | echo "Corepack enabled" 26 | 27 | - uses: pnpm/action-setup@v3.0.0 28 | if: ${{ inputs.enable-corepack == 'false' }} 29 | with: 30 | run_install: false 31 | 32 | - name: Expose pnpm config(s) through "$GITHUB_OUTPUT" 33 | id: pnpm-config 34 | shell: bash 35 | run: | 36 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 37 | 38 | - name: Cache rotation keys 39 | id: cache-rotation 40 | shell: bash 41 | run: | 42 | echo "YEAR_MONTH=$(/bin/date -u "+%Y%m")" >> $GITHUB_OUTPUT 43 | 44 | - uses: actions/cache@v4 45 | name: Setup pnpm cache 46 | with: 47 | path: ${{ steps.pnpm-config.outputs.STORE_PATH }} 48 | key: ${{ runner.os }}-pnpm-store-cache-${{ steps.cache-rotation.outputs.YEAR_MONTH }}-${{ hashFiles('**/pnpm-lock.yaml') }} 49 | restore-keys: | 50 | ${{ runner.os }}-pnpm-store-cache-${{ steps.cache-rotation.outputs.YEAR_MONTH }}- 51 | 52 | - name: Install dependencies 53 | shell: bash 54 | run: pnpm install --frozen-lockfile --prefer-offline 55 | env: 56 | HUSKY: '0' # By default do not run HUSKY install 57 | -------------------------------------------------------------------------------- /.github/assets/trakt-black.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /.github/assets/trakt-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /.github/workflows/unit-testing.yml: -------------------------------------------------------------------------------- 1 | name: Unit Testing 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | unit-testing: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | - name: Install dependencies 22 | uses: ./.github/actions/pnpm-install 23 | - name: Run Vitest 24 | run: pnpm run test:ci 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | vite.config.js.timestamp-* 10 | vite.config.ts.timestamp-* 11 | .netlify 12 | .vercel 13 | coverage 14 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 LekoArts 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

annum

2 | 3 |

4 | 5 | Visualize Your Trakt.tv History 6 | 7 |

8 | 9 |

10 | Display your watched movies and shows in a poster grid. Easily switch between years and get an overview of all your history. Powered by: 11 |

12 | 13 |

14 | 15 | 16 | Trakt.tv Logo 17 | 18 |

19 | 20 |

21 | 🍿 Website 22 |

23 | 24 | This website was created by [LekoArts](https://www.lekoarts.de?utm_source=annum_readme) as a christmas project to try out SvelteKit. LekoArts loves watching movies and shows ⸺ so why not have a great overview? You can also follow me on [Trakt](https://trakt.tv/users/arsaurea) if you want. 25 | 26 | ## Development 27 | 28 | This [SvelteKit](https://kit.svelte.dev/) project was bootstrapped with [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte). 29 | 30 | ### Prerequisites 31 | 32 | 1. [Install Node.js](https://nodejs.org/en/learn/getting-started/how-to-install-nodejs) 20 or later 33 | 1. [Install pnpm](https://pnpm.io/installation) 34 | 35 | ### Repository setup 36 | 37 | 1. Install dependencies 38 | 39 | ```shell 40 | pnpm install 41 | ``` 42 | 43 | 1. Create a duplicate of `.env.example` and name it `.env` 44 | 45 | 1. Retrieve the necessary secrets: 46 | 47 | 1. `PRIVATE_TRAKT_CLIENT_ID` and `PRIVATE_TRAKT_CLIENT_SECRET`: Login to [Trakt.tv](https://trakt.tv) and inside your settings go to your [Your API Apps](https://trakt.tv/oauth/applications) section. Create a new application. Set `http://localhost:5173/auth/callback/trakt` as one of the **Redirect URI** and set `http://localhost:5173` as one of the **JavaScript (CORS) Origins**. Copy over the **Client ID** and **Client Secret** to the `.env` file. 48 | 49 | 1. `PRIVATE_AUTH_SECRET`: Generate a random string which is used to encrypt tokens. Run `openssl rand -base64 32` in your terminal and copy the value over to the `.env` file. 50 | 51 | 1. `PRIVATE_TMDB_API_KEY`: Login to your [TMDB](https://www.themoviedb.org/) account and inside your settings go to [API section](https://www.themoviedb.org/settings/api). Copy the **API Key** over to the `.env` file. 52 | 53 | ### Commands 54 | 55 | Starting the development server: 56 | 57 | ```shell 58 | pnpm dev 59 | ``` 60 | 61 | Create a production build locally: 62 | 63 | ```shell 64 | pnpm build 65 | ``` 66 | 67 | Preview that build locally with `pnpm preview`. 68 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu({ 4 | stylistic: { 5 | indent: 'tab', 6 | quotes: 'single', 7 | semi: false, 8 | }, 9 | svelte: true, 10 | typescript: true, 11 | rules: { 12 | 'ts/no-restricted-types': ['error', { 13 | types: { 14 | '{}': { 15 | fixWith: 'Record', 16 | }, 17 | 'object': { 18 | fixWith: 'Record', 19 | }, 20 | }, 21 | }], 22 | 'ts/no-empty-object-type': ['error', { allowInterfaces: 'with-single-extends' }], 23 | 'ts/no-unsafe-function-type': 'error', 24 | 'ts/no-wrapper-object-types': 'error', 25 | 'ts/array-type': ['error', { default: 'generic' }], 26 | 'svelte/valid-compile': 'warn', 27 | 'svelte/mustache-spacing': 'off', 28 | 'no-unused-vars': 'off', 29 | 'prefer-const': 'off', 30 | 'ts/no-unused-vars': ['error', { 31 | argsIgnorePattern: '^_[^_].*$|^_$', 32 | varsIgnorePattern: '^_[^_].*$|^_$', 33 | caughtErrorsIgnorePattern: '^_[^_].*$|^_$', 34 | }], 35 | 'unused-imports/no-unused-vars': 'off', 36 | 'style/quotes': ['error', 'single', { avoidEscape: true }], 37 | }, 38 | ignores: ['**/.DS_Store', '**/node_modules', '/build', '/.svelte-kit', '/package', 'pnpm-lock.yaml', 'package-lock.json', 'yarn.lock'], 39 | }) 40 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "pnpm run build" 3 | publish = "build" 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "annum", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "private": true, 6 | "packageManager": "pnpm@9.15.1", 7 | "license": "MIT", 8 | "scripts": { 9 | "dev": "vite dev", 10 | "build": "vite build", 11 | "preview": "vite preview", 12 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 13 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 14 | "test": "vitest watch", 15 | "test:ci": "vitest run", 16 | "test:coverage": "vitest run --coverage", 17 | "lint": "eslint .", 18 | "lint:fix": "eslint . --fix", 19 | "typecheck": "tsc --noEmit" 20 | }, 21 | "devDependencies": { 22 | "@antfu/eslint-config": "^4.1.0", 23 | "@auth/core": "^0.37.4", 24 | "@auth/sveltekit": "^1.7.4", 25 | "@csstools/postcss-global-data": "^3.0.0", 26 | "@sveltejs/adapter-netlify": "^4.4.1", 27 | "@sveltejs/enhanced-img": "^0.4.4", 28 | "@sveltejs/kit": "^2.16.1", 29 | "@sveltejs/vite-plugin-svelte": "^5.0.3", 30 | "@types/lodash-es": "^4.17.12", 31 | "@vitest/coverage-v8": "^3.0.4", 32 | "eslint": "^9.19.0", 33 | "eslint-plugin-svelte": "^2.46.1", 34 | "happy-dom": "^16.8.1", 35 | "lodash-es": "^4.17.21", 36 | "postcss-preset-env": "^10.1.3", 37 | "svelte": "^5.19.6", 38 | "svelte-check": "^4.1.4", 39 | "tslib": "^2.8.1", 40 | "typescript": "^5.7.3", 41 | "vite": "^6.0.11", 42 | "vitest": "^3.0.4" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | const postCssGlobalData = require('@csstools/postcss-global-data') 2 | const postCssPresetEnv = require('postcss-preset-env') 3 | 4 | const config = { 5 | plugins: [ 6 | postCssGlobalData({ 7 | files: ['src/styles/custom-media-queries.css'], 8 | }), 9 | postCssPresetEnv({}), 10 | ], 11 | } 12 | 13 | module.exports = config 14 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace App { 3 | // interface Error {} 4 | interface PageData { 5 | year?: string 6 | meta?: { 7 | title: string 8 | description: string 9 | } 10 | } 11 | // interface Session {} 12 | // interface PageState {} 13 | // interface Platform {} 14 | } 15 | } 16 | 17 | export {} 18 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | %sveltekit.head% 12 | 13 | 14 |
%sveltekit.body%
15 | 16 | 17 | -------------------------------------------------------------------------------- /src/assets/color-modes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LekoArts/annum/265c954e7a5451a4063e9841137a0ecacc7cff46/src/assets/color-modes.png -------------------------------------------------------------------------------- /src/assets/default-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LekoArts/annum/265c954e7a5451a4063e9841137a0ecacc7cff46/src/assets/default-preview.png -------------------------------------------------------------------------------- /src/assets/noise.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LekoArts/annum/265c954e7a5451a4063e9841137a0ecacc7cff46/src/assets/noise.png -------------------------------------------------------------------------------- /src/assets/screenshot-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LekoArts/annum/265c954e7a5451a4063e9841137a0ecacc7cff46/src/assets/screenshot-mode.png -------------------------------------------------------------------------------- /src/auth.ts: -------------------------------------------------------------------------------- 1 | import type { TraktProfile } from '$lib/types' 2 | import type { DefaultSession } from '@auth/sveltekit' 3 | import { PRIVATE_AUTH_SECRET, PRIVATE_TRAKT_CLIENT_ID, PRIVATE_TRAKT_CLIENT_SECRET } from '$env/static/private' 4 | import { SvelteKitAuth } from '@auth/sveltekit' 5 | import Trakt from '@auth/sveltekit/providers/trakt' 6 | 7 | declare module '@auth/sveltekit' { 8 | interface Session { 9 | user: { 10 | id: string 11 | name: string 12 | email?: string | null 13 | image: string 14 | username: string 15 | } & DefaultSession['user'] 16 | } 17 | } 18 | 19 | declare module '@auth/core/jwt' { 20 | interface JWT { 21 | id: string 22 | username: string 23 | } 24 | } 25 | 26 | export const { handle, signIn, signOut } = SvelteKitAuth({ 27 | providers: [Trakt({ clientId: PRIVATE_TRAKT_CLIENT_ID, clientSecret: PRIVATE_TRAKT_CLIENT_SECRET })], 28 | secret: PRIVATE_AUTH_SECRET, 29 | trustHost: true, 30 | callbacks: { 31 | jwt: async ({ token, profile }) => { 32 | if (profile) { 33 | const { ids, username } = profile as unknown as TraktProfile 34 | token.id = ids.slug 35 | token.username = username 36 | } 37 | 38 | return token 39 | }, 40 | session: async ({ session, token }) => { 41 | if (session.user) { 42 | const { id, username } = token 43 | session.user.id = id 44 | session.user.username = username 45 | } 46 | 47 | return session 48 | }, 49 | }, 50 | }) 51 | -------------------------------------------------------------------------------- /src/const.ts: -------------------------------------------------------------------------------- 1 | export const TRAKT_BASE_URL = 'https://api.trakt.tv' 2 | export const TMDB_BASE_URL = 'https://api.themoviedb.org/3/' 3 | export const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p/' 4 | export const GITHUB_REPO_URL = 'https://github.com/LekoArts/annum' 5 | export const CURRENT_YEAR = new Date().getFullYear() 6 | export const SKIP_TO_CONTENT_ID = 'skip-to-content' 7 | export const TITLE = 'annum' 8 | 9 | export const TYPE_NAMES = { 10 | movie: 'Movie', 11 | show: 'Show', 12 | } as const 13 | 14 | export const TMDB_POSTER_SIZES = { 15 | w92: 'w92', 16 | w154: 'w154', 17 | w185: 'w185', 18 | w342: 'w342', 19 | w500: 'w500', 20 | w780: 'w780', 21 | } as const 22 | 23 | export const TMDB_FETCH_DEFAULTS = { 24 | method: 'GET', 25 | headers: { 26 | 'user-agent': 'annum', 27 | }, 28 | } satisfies RequestInit 29 | 30 | export const DEFAULT_CACHE_HEADER = { 31 | 'cache-control': 'max-age=3600', 32 | } as const 33 | 34 | export const PAGINATION_LIMIT = 15 35 | 36 | export const LANGUAGES = [ 37 | { 38 | id: 'ar', 39 | name: 'العربية', 40 | }, 41 | { 42 | id: 'cn', 43 | name: '广州话 / 廣州話', 44 | }, 45 | { 46 | id: 'de', 47 | name: 'Deutsch', 48 | }, 49 | { 50 | id: 'el', 51 | name: 'ελληνικά', 52 | }, 53 | { 54 | id: 'en', 55 | name: 'English', 56 | }, 57 | { 58 | id: 'es', 59 | name: 'Español', 60 | }, 61 | { 62 | id: 'fr', 63 | name: 'Français', 64 | }, 65 | { 66 | id: 'it', 67 | name: 'Italiano', 68 | }, 69 | { 70 | id: 'ja', 71 | name: '日本語', 72 | }, 73 | { 74 | id: 'ko', 75 | name: '한국어/조선말', 76 | }, 77 | { 78 | id: 'pl', 79 | name: 'Polski', 80 | }, 81 | { 82 | id: 'pt', 83 | name: 'Português', 84 | }, 85 | { 86 | id: 'ru', 87 | name: 'Pусский', 88 | }, 89 | { 90 | id: 'uk', 91 | name: 'Український', 92 | }, 93 | { 94 | id: 'zh', 95 | name: '普通话', 96 | }, 97 | ] as const 98 | -------------------------------------------------------------------------------- /src/hooks.server.ts: -------------------------------------------------------------------------------- 1 | import type { Handle, MaybePromise, RequestEvent, ResolveOptions } from '@sveltejs/kit' 2 | import { redirect } from '@sveltejs/kit' 3 | import { sequence } from '@sveltejs/kit/hooks' 4 | import { handle as authenticationHandle } from './auth' 5 | 6 | async function authorization({ event, resolve }: { event: RequestEvent, resolve: (event: RequestEvent, opts?: ResolveOptions) => MaybePromise }) { 7 | // Protect all routes under /dashboard 8 | if (event.url.pathname.startsWith('/dashboard')) { 9 | const session = await event.locals.auth() 10 | if (!session) 11 | throw redirect(303, '/sign-in') 12 | } 13 | 14 | return resolve(event) 15 | } 16 | 17 | /** 18 | * First handle authentication, then authorization. 19 | * Each function acts as a middleware, receiving the request handle. And returning a handle which gets passed to the next function. 20 | */ 21 | export const handle: Handle = sequence( 22 | authenticationHandle, 23 | authorization, 24 | ) 25 | -------------------------------------------------------------------------------- /src/lib/Image.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/lib/InfiniteLoading.svelte: -------------------------------------------------------------------------------- 1 | 138 | 139 | 360 | 361 |
362 | {#if showSpinner} 363 |
364 | {#if spinner}{@render spinner()}{:else} 365 | Loader 366 | {/if} 367 |
368 | {/if} 369 | 370 | {#if showNoResults} 371 |
372 | {#if noResults}{@render noResults()}{:else} 373 | No results :( 374 | {/if} 375 |
376 | {/if} 377 | 378 | {#if showNoMore} 379 |
380 | {#if noMore}{@render noMore()}{:else} 381 | No more data :) 382 | {/if} 383 |
384 | {/if} 385 | 386 | {#if showError} 387 |
388 | {#if error}{@render error({ attemptLoad })}{:else} 389 | Oops, something went wrong :( 390 |
391 | 394 | {/if} 395 |
396 | {/if} 397 |
398 | 399 | 422 | -------------------------------------------------------------------------------- /src/lib/Meta.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | 22 | {generateTitle(meta.title)} 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/lib/PlausibleAnalytics.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 | 76 | 77 | 78 | {#if enabled} 79 | 80 | 87 | {/if} 88 | 89 | -------------------------------------------------------------------------------- /src/lib/Spacer.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/lib/Svg.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 13 | 14 | 23 | -------------------------------------------------------------------------------- /src/lib/Switch.svelte: -------------------------------------------------------------------------------- 1 | 25 | 26 |
27 | {label} 28 | 34 |
35 | 36 | 101 | -------------------------------------------------------------------------------- /src/lib/__tests__/actions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment happy-dom 3 | */ 4 | 5 | import { afterEach, beforeEach, describe, expect, it } from 'vitest' 6 | import { classList, style } from '../actions' 7 | 8 | describe('classList', () => { 9 | let element: HTMLElement 10 | 11 | beforeEach(() => { 12 | element = document.createElement('div') 13 | }) 14 | 15 | afterEach(() => { 16 | element.remove() 17 | }) 18 | 19 | it('should add single class to element', () => { 20 | classList(element, 'class1') 21 | 22 | expect(element.classList.contains('class1')).toBe(true) 23 | }) 24 | 25 | it('should add multiple classes to element', () => { 26 | classList(element, ['class1', 'class2']) 27 | 28 | expect(element.classList.contains('class1')).toBe(true) 29 | expect(element.classList.contains('class2')).toBe(true) 30 | }) 31 | 32 | it('should remove classes when destroy is called', () => { 33 | const node = classList(element, ['class1', 'class2']) 34 | 35 | // @ts-expect-error - Weird types 36 | node.destroy() 37 | 38 | expect(element.classList.contains('class1')).toBe(false) 39 | expect(element.classList.contains('class2')).toBe(false) 40 | }) 41 | }) 42 | 43 | describe('style', () => { 44 | let node: HTMLElement 45 | 46 | beforeEach(() => { 47 | node = document.createElement('div') 48 | }) 49 | 50 | afterEach(() => { 51 | node.remove() 52 | }) 53 | 54 | it('should set the style of the node', () => { 55 | style(node, 'color: red;') 56 | 57 | expect(node.style.cssText).toBe('color: red;') 58 | }) 59 | 60 | it('should update the style of the node', () => { 61 | const action = style(node, '') 62 | // @ts-expect-error - Weird types 63 | action.update('color: red;') 64 | 65 | expect(node.style.cssText).toBe('color: red;') 66 | }) 67 | 68 | it('should unset the style of the node', () => { 69 | const action = style(node, 'color: red;') 70 | // @ts-expect-error - Weird types 71 | action.destroy() 72 | 73 | expect(node.style.cssText).toBe('') 74 | }) 75 | 76 | it('should update the style of the node multiple times', () => { 77 | const action = style(node, '') 78 | // @ts-expect-error - Weird types 79 | action.update('color: red;') 80 | // @ts-expect-error - Weird types 81 | action.update('font-size: 16px;') 82 | 83 | expect(node.style.cssText).toBe('font-size: 16px;') 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /src/lib/actions.ts: -------------------------------------------------------------------------------- 1 | import type { Action } from 'svelte/action' 2 | 3 | // Copied from https://github.com/ghostdevv/svelte-body 4 | // License: MIT 5 | 6 | /** 7 | * Svelte action to add style on `body`. style can either be a string or an object. 8 | * 9 | * @example 10 | * 11 | *```svelte 12 | * 15 | * 16 | * 17 | *``` 18 | */ 19 | export const style: Action = ( 20 | node, 21 | styleData = '', 22 | ) => { 23 | // Pseudo Element for style parsing and keeping track of styles 24 | const pseudoElement = document.createElement('div') 25 | 26 | const update = (styleData = '') => { 27 | pseudoElement.style.cssText = styleData 28 | 29 | // Combine body's existing styles with computed ones 30 | node.style.cssText = ` 31 | ${node.style.cssText}; 32 | ${pseudoElement.style.cssText}; 33 | ` 34 | } 35 | 36 | // Initial Update 37 | update(styleData) 38 | 39 | const unset = () => { 40 | // Remove the pseudoElements styles on the body 41 | node.style.cssText = node.style.cssText.replace( 42 | pseudoElement.style.cssText, 43 | '', 44 | ) 45 | 46 | // Clear pseudoElement 47 | pseudoElement.style.cssText = '' 48 | } 49 | 50 | return { 51 | update: (styleData) => { 52 | unset() 53 | update(styleData) 54 | }, 55 | 56 | destroy: unset, 57 | } 58 | } 59 | 60 | export const classList: Action> = (node, classes) => { 61 | const tokens = Array.isArray(classes) ? classes : [classes] 62 | node.classList.add(...tokens) 63 | 64 | return { 65 | destroy() { 66 | node.classList.remove(...tokens) 67 | }, 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/lib/button/Primary.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 | {#if type === 'text'} 28 | 31 | {:else} 32 | 33 | {@render children?.()} 34 | 35 | {/if} 36 | 37 | 88 | -------------------------------------------------------------------------------- /src/lib/button/Secondary.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 | {#if type === 'text'} 28 | 31 | {:else} 32 | 33 | {@render children?.()} 34 | 35 | {/if} 36 | 37 | 65 | -------------------------------------------------------------------------------- /src/lib/grid/Grid.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 | {@render children?.()} 13 |
14 | 15 | 31 | -------------------------------------------------------------------------------- /src/lib/grid/Item.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
11 | {@render children?.()} 12 |
13 | 14 | 24 | -------------------------------------------------------------------------------- /src/lib/server/const.ts: -------------------------------------------------------------------------------- 1 | import { PRIVATE_TMDB_API_KEY, PRIVATE_TRAKT_CLIENT_ID } from '$env/static/private' 2 | 3 | export const TRAKT_FETCH_DEFAULTS = { 4 | method: 'GET', 5 | headers: { 6 | 'user-agent': 'annum', 7 | 'Content-Type': 'application/json', 8 | 'trakt-api-key': PRIVATE_TRAKT_CLIENT_ID, 9 | 'trakt-api-version': '2', 10 | }, 11 | } satisfies RequestInit 12 | 13 | export const TMDB_QUERY_DEFAULTS = new URLSearchParams({ 14 | language: 'en-US', 15 | api_key: PRIVATE_TMDB_API_KEY, 16 | }).toString() 17 | -------------------------------------------------------------------------------- /src/lib/skip-to-content/Content.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
14 | {@render children?.()} 15 |
16 | -------------------------------------------------------------------------------- /src/lib/skip-to-content/Nav.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | {#if text}{@render text()}{:else} 15 | Skip to content 16 | {/if} 17 | 18 | 19 | 47 | -------------------------------------------------------------------------------- /src/lib/store/persisted.ts: -------------------------------------------------------------------------------- 1 | import type { Writable } from 'svelte/store' 2 | import { writable as internal } from 'svelte/store' 3 | 4 | // Adapted from https://github.com/joshnuss/svelte-persisted-store 5 | // License: MIT 6 | 7 | declare type Updater = (value: T) => T 8 | declare interface StoreDict { [key: string]: Writable } 9 | 10 | interface Stores { 11 | local: StoreDict 12 | } 13 | 14 | const stores: Stores = { 15 | local: {}, 16 | } 17 | 18 | export interface Serializer { 19 | parse: (text: string) => T 20 | stringify: (object: T) => string 21 | } 22 | 23 | export type StorageType = 'local' | 'session' 24 | 25 | export interface Options { 26 | onError?: (e: unknown) => void 27 | } 28 | 29 | export function persisted(key: string, initialValue: T, options?: Options): Writable { 30 | const serializer = JSON 31 | const storageType = 'local' 32 | const syncTabs = true 33 | const onError = options?.onError ?? (e => console.error(`Error when writing value from persisted store "${key}" to ${storageType}`, e)) 34 | const browser = typeof (window) !== 'undefined' && typeof (document) !== 'undefined' 35 | const storage = browser ? localStorage : null 36 | 37 | function updateStorage(key: string, value: T) { 38 | try { 39 | storage?.setItem(key, serializer.stringify(value)) 40 | } 41 | catch (e) { 42 | onError(e) 43 | } 44 | } 45 | 46 | if (!stores[storageType][key]) { 47 | const store = internal(initialValue, (set) => { 48 | const json = storage?.getItem(key) 49 | 50 | if (json) 51 | set(serializer.parse(json)) 52 | 53 | if (browser && storageType === 'local' && syncTabs) { 54 | const handleStorage = (event: StorageEvent) => { 55 | if (event.key === key) 56 | set(event.newValue ? serializer.parse(event.newValue) : null) 57 | } 58 | 59 | window.addEventListener('storage', handleStorage) 60 | 61 | return () => window.removeEventListener('storage', handleStorage) 62 | } 63 | }) 64 | 65 | const { subscribe, set } = store 66 | 67 | stores[storageType][key] = { 68 | set(value: T) { 69 | set(value) 70 | updateStorage(key, value) 71 | }, 72 | update(callback: Updater) { 73 | return store.update((last) => { 74 | const value = callback(last) 75 | 76 | updateStorage(key, value) 77 | 78 | return value 79 | }) 80 | }, 81 | subscribe, 82 | } 83 | } 84 | 85 | return stores[storageType][key] 86 | } 87 | -------------------------------------------------------------------------------- /src/lib/store/plausible.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Adjusted from https://github.com/accuser/svelte-plausible-analytics/blob/main/src/lib/store.ts 3 | * LICENSE: MIT 4 | */ 5 | 6 | import { writable } from 'svelte/store' 7 | 8 | interface Data { 9 | props?: Record 10 | } 11 | 12 | interface PlausibleEvent { 13 | event: string 14 | data?: Data 15 | } 16 | 17 | /** 18 | * Plausible Analytics event store. 19 | */ 20 | export const pa = (() => { 21 | const { subscribe, update } = writable>([]) 22 | 23 | const addEvent = (event: string, data?: Data) => { 24 | update(events => [...events, { event, data }]) 25 | } 26 | 27 | return { 28 | subscribe, 29 | addEvent, 30 | } 31 | })() 32 | -------------------------------------------------------------------------------- /src/lib/store/settings.ts: -------------------------------------------------------------------------------- 1 | import type { Language } from '$lib/types' 2 | import { persisted } from './persisted' 3 | 4 | interface Settings { 5 | hue: number 6 | screenshotMode: boolean 7 | columns: number 8 | lang: Language 9 | grayscaleMode: boolean 10 | groupByMonth: boolean 11 | } 12 | 13 | export const settings = persisted('annum-settings', { 14 | hue: 240, 15 | screenshotMode: false, 16 | columns: 5, 17 | lang: 'en', 18 | grayscaleMode: false, 19 | groupByMonth: false, 20 | }) 21 | -------------------------------------------------------------------------------- /src/lib/store/stats.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store' 2 | 3 | interface StatsEvent { 4 | movies: number 5 | shows: number 6 | } 7 | 8 | export const stats = (() => { 9 | const { subscribe, update } = writable({ movies: 0, shows: 0 }) 10 | 11 | const setMovies = (movies: number) => { 12 | update(stats => ({ ...stats, movies })) 13 | } 14 | const setShows = (shows: number) => { 15 | update(stats => ({ ...stats, shows })) 16 | } 17 | 18 | return { 19 | subscribe, 20 | setMovies, 21 | setShows, 22 | } 23 | })() 24 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { LANGUAGES, TMDB_POSTER_SIZES } from '$const' 2 | 3 | export type TmdbPosterSize = keyof typeof TMDB_POSTER_SIZES 4 | export type Language = typeof LANGUAGES[number]['id'] 5 | export type TraktMediaType = 'movies' | 'shows' 6 | 7 | export interface TraktProfile { 8 | username: string 9 | private: boolean 10 | name: string 11 | vip: boolean 12 | vip_ep: boolean 13 | ids: { 14 | slug: string 15 | } 16 | joined_at: string 17 | location: string 18 | about: string 19 | gender: string 20 | age?: number 21 | images: { 22 | avatar: { 23 | full: string 24 | } 25 | } 26 | } 27 | 28 | export interface TraktStats { 29 | movies: { 30 | plays: number 31 | watched: number 32 | minutes: number 33 | collected: number 34 | ratings: number 35 | comments: number 36 | } 37 | shows: { 38 | watched: number 39 | collected: number 40 | ratings: number 41 | comments: number 42 | } 43 | seasons: { 44 | ratings: number 45 | comments: number 46 | } 47 | episodes: { 48 | plays: number 49 | watched: number 50 | minutes: number 51 | collected: number 52 | ratings: number 53 | comments: number 54 | } 55 | network: { 56 | friends: number 57 | followers: number 58 | following: number 59 | } 60 | ratings: { 61 | total: number 62 | distribution: { 63 | 1: number 64 | 2: number 65 | 3: number 66 | 4: number 67 | 5: number 68 | 6: number 69 | 7: number 70 | 8: number 71 | 9: number 72 | 10: number 73 | } 74 | } 75 | } 76 | 77 | interface ItemMeta { 78 | title: string 79 | year: number 80 | ids: { 81 | trakt: number 82 | slug: string 83 | imdb?: string 84 | tmdb?: number 85 | } 86 | } 87 | 88 | interface EpisodeMeta { 89 | season: number 90 | number: number 91 | title: string 92 | ids: { 93 | trakt: number 94 | tvdb?: number 95 | imdb?: string 96 | tmdb?: number 97 | tvrage?: number 98 | } 99 | } 100 | 101 | interface TraktWatchedItemBase { 102 | plays: number 103 | last_watched_at: string 104 | last_updated_at: string 105 | } 106 | 107 | export interface TraktWatchedMovieItem extends TraktWatchedItemBase { 108 | movie: ItemMeta 109 | } 110 | 111 | export interface TraktWatchedShowItem extends TraktWatchedItemBase { 112 | show: ItemMeta 113 | } 114 | 115 | export type TraktWatchedItem = TraktWatchedMovieItem | TraktWatchedShowItem 116 | 117 | interface TraktHistoryItemBase { 118 | id: number 119 | watched_at: string 120 | action: 'watch' | 'scrobble' | 'checkin' 121 | } 122 | 123 | export interface TraktHistoryMovieItem extends TraktHistoryItemBase { 124 | type: 'movie' 125 | movie: ItemMeta 126 | } 127 | 128 | export interface TraktHistoryEpisodeItem extends TraktHistoryItemBase { 129 | type: 'episode' 130 | episode: EpisodeMeta 131 | show: ItemMeta 132 | } 133 | 134 | export type TraktHistoryItem = TraktHistoryMovieItem | TraktHistoryEpisodeItem 135 | 136 | export interface TmdbImagesDetail { 137 | aspect_ratio: number 138 | height: number 139 | iso_639_1: string 140 | file_path: string 141 | vote_average: number 142 | vote_count: number 143 | width: number 144 | } 145 | export interface TmdbImagesResponse { 146 | backdrops: Array 147 | posters: Array 148 | logos: Array 149 | } 150 | 151 | export interface TmdbItemDetails { 152 | id: number 153 | poster_path?: string 154 | images?: TmdbImagesResponse 155 | } 156 | 157 | export interface NormalizedItemResponse { 158 | last_watched_at: string 159 | last_watched_at_year: number 160 | last_wathed_at_month: string 161 | title: string 162 | release_year: number 163 | trakt_id: number 164 | tmdb_id?: number 165 | plays?: number 166 | } 167 | 168 | export type TmdbImageUrlsWithDimensions = Record 169 | 170 | export interface Item extends NormalizedItemResponse { 171 | images: TmdbImageUrlsWithDimensions 172 | } 173 | 174 | export interface ApiHistoryResponse { 175 | page: string 176 | total_pages: string 177 | page_limit: string 178 | item_count: string 179 | items: Array 180 | } 181 | 182 | export interface StateChanger { 183 | /** 184 | * Inform the component that this loading has been successful. The infinite event will be fired again if the first screen was not be 185 | * filled up, otherwise, the component will hide the loading animation and continue to listen to scroll events. 186 | */ 187 | loaded: () => void 188 | 189 | /** 190 | * Inform the component that all the data has been loaded successfully. If the InfiniteEvent.details.loaded method has not 191 | * been called before this, the content of the noResults slot will be 192 | * displayed, otherwise, the content of the noMore slot will be displayed. 193 | */ 194 | complete: () => void 195 | 196 | /** 197 | * Inform the component that loading the data has failed. The content of the error slot will be displayed. 198 | */ 199 | error: () => void 200 | 201 | /** 202 | * Reset the component. Same as changing the identifier property. 203 | */ 204 | reset: () => void 205 | } 206 | export interface InfiniteEvent extends CustomEvent { 207 | } 208 | -------------------------------------------------------------------------------- /src/lib/utils/__tests__/__fixtures__/history.shows.ts: -------------------------------------------------------------------------------- 1 | export const showsPageOneNames = [ 2 | 'Frieren: Beyond Journey\'s End', 3 | 'Tengoku Daimakyo', 4 | 'PLUTO', 5 | 'Jet Lag: The Game', 6 | 'SPY x FAMILY', 7 | 'Invincible', 8 | ] 9 | 10 | export const showsPageTwoNames = [ 11 | 'Vinland Saga', 12 | 'Invincible', 13 | 'SPY x FAMILY', 14 | ] 15 | 16 | export const mergedNames = [ 17 | ...showsPageOneNames, 18 | 'Vinland Saga', 19 | ] 20 | 21 | export const showsPageOne = [ 22 | { 23 | id: 9434810476, 24 | watched_at: '2023-12-30T19:13:09.000Z', 25 | action: 'watch', 26 | type: 'episode', 27 | episode: { 28 | season: 1, 29 | number: 4, 30 | title: 'The Land Where Souls Rest', 31 | ids: { 32 | trakt: 10860108, 33 | tvdb: 9993739, 34 | imdb: 'tt29277817', 35 | tmdb: 4698539, 36 | tvrage: null, 37 | }, 38 | }, 39 | show: { 40 | title: 'Frieren: Beyond Journey\'s End', 41 | year: 2023, 42 | ids: { 43 | trakt: 198225, 44 | slug: 'frieren-beyond-journey-s-end', 45 | tvdb: 424536, 46 | imdb: 'tt22248376', 47 | tmdb: 209867, 48 | tvrage: null, 49 | }, 50 | }, 51 | }, 52 | { 53 | id: 9415804220, 54 | watched_at: '2023-12-22T18:45:11.000Z', 55 | action: 'watch', 56 | type: 'episode', 57 | episode: { 58 | season: 1, 59 | number: 13, 60 | title: 'The Journey Continues and Begins', 61 | ids: { 62 | trakt: 7431149, 63 | tvdb: 9687688, 64 | imdb: 'tt28102569', 65 | tmdb: 4317018, 66 | tvrage: null, 67 | }, 68 | }, 69 | show: { 70 | title: 'Tengoku Daimakyo', 71 | year: 2023, 72 | ids: { 73 | trakt: 199064, 74 | slug: 'tengoku-daimakyo', 75 | tvdb: 426165, 76 | imdb: 'tt22817632', 77 | tmdb: 208891, 78 | tvrage: null, 79 | }, 80 | }, 81 | }, 82 | { 83 | id: 9434757692, 84 | watched_at: '2023-12-30T18:44:49.000Z', 85 | action: 'watch', 86 | type: 'episode', 87 | episode: { 88 | season: 1, 89 | number: 3, 90 | title: 'Killing Magic', 91 | ids: { 92 | trakt: 10860104, 93 | tvdb: 9993738, 94 | imdb: 'tt29277815', 95 | tmdb: 4698538, 96 | tvrage: null, 97 | }, 98 | }, 99 | show: { 100 | title: 'Frieren: Beyond Journey\'s End', 101 | year: 2023, 102 | ids: { 103 | trakt: 198225, 104 | slug: 'frieren-beyond-journey-s-end', 105 | tvdb: 424536, 106 | imdb: 'tt22248376', 107 | tmdb: 209867, 108 | tvrage: null, 109 | }, 110 | }, 111 | }, 112 | { 113 | id: 9430203576, 114 | watched_at: '2023-12-28T20:57:08.000Z', 115 | action: 'watch', 116 | type: 'episode', 117 | episode: { 118 | season: 1, 119 | number: 8, 120 | title: 'Episode 8', 121 | ids: { 122 | trakt: 10744235, 123 | tvdb: 9876574, 124 | imdb: 'tt28306755', 125 | tmdb: 4560461, 126 | tvrage: null, 127 | }, 128 | }, 129 | show: { 130 | title: 'PLUTO', 131 | year: 2023, 132 | ids: { 133 | trakt: 202543, 134 | slug: 'pluto', 135 | tvdb: 431093, 136 | imdb: 'tt26737616', 137 | tmdb: 91997, 138 | tvrage: null, 139 | }, 140 | }, 141 | }, 142 | { 143 | id: 9429905932, 144 | watched_at: '2023-12-28T17:49:48.000Z', 145 | action: 'watch', 146 | type: 'episode', 147 | episode: { 148 | season: 8, 149 | number: 3, 150 | title: 'Episode 3', 151 | ids: { 152 | trakt: 11177327, 153 | tvdb: 10181797, 154 | imdb: null, 155 | tmdb: null, 156 | tvrage: null, 157 | }, 158 | }, 159 | show: { 160 | title: 'Jet Lag: The Game', 161 | year: 2022, 162 | ids: { 163 | trakt: 202731, 164 | slug: 'jet-lag-the-game', 165 | tvdb: 425498, 166 | imdb: 'tt23030000', 167 | tmdb: 237761, 168 | tvrage: null, 169 | }, 170 | }, 171 | }, 172 | { 173 | id: 9430103290, 174 | watched_at: '2023-12-28T20:10:43.000Z', 175 | action: 'watch', 176 | type: 'episode', 177 | episode: { 178 | season: 1, 179 | number: 7, 180 | title: 'Episode 7', 181 | ids: { 182 | trakt: 10744234, 183 | tvdb: 9876573, 184 | imdb: 'tt28306754', 185 | tmdb: 4560460, 186 | tvrage: null, 187 | }, 188 | }, 189 | show: { 190 | title: 'PLUTO', 191 | year: 2023, 192 | ids: { 193 | trakt: 202543, 194 | slug: 'pluto', 195 | tvdb: 431093, 196 | imdb: 'tt26737616', 197 | tmdb: 91997, 198 | tvrage: null, 199 | }, 200 | }, 201 | }, 202 | { 203 | id: 9422462647, 204 | watched_at: '2023-12-25T14:45:50.000Z', 205 | action: 'watch', 206 | type: 'episode', 207 | episode: { 208 | season: 2, 209 | number: 12, 210 | title: 'PART OF THE FAMILY', 211 | ids: { 212 | trakt: 10870132, 213 | tvdb: 10075720, 214 | imdb: 'tt29702476', 215 | tmdb: 4698660, 216 | tvrage: null, 217 | }, 218 | }, 219 | show: { 220 | title: 'SPY x FAMILY', 221 | year: 2022, 222 | ids: { 223 | trakt: 190742, 224 | slug: 'spy-x-family', 225 | tvdb: 405920, 226 | imdb: 'tt13706018', 227 | tmdb: 120089, 228 | tvrage: null, 229 | }, 230 | }, 231 | }, 232 | { 233 | id: 9415754867, 234 | watched_at: '2023-12-22T18:25:29.000Z', 235 | action: 'watch', 236 | type: 'episode', 237 | episode: { 238 | season: 1, 239 | number: 12, 240 | title: 'Outside of the Outside', 241 | ids: { 242 | trakt: 7431136, 243 | tvdb: 9679344, 244 | imdb: 'tt28023723', 245 | tmdb: 4317017, 246 | tvrage: null, 247 | }, 248 | }, 249 | show: { 250 | title: 'Tengoku Daimakyo', 251 | year: 2023, 252 | ids: { 253 | trakt: 199064, 254 | slug: 'tengoku-daimakyo', 255 | tvdb: 426165, 256 | imdb: 'tt22817632', 257 | tmdb: 208891, 258 | tvrage: null, 259 | }, 260 | }, 261 | }, 262 | { 263 | id: 9415715950, 264 | watched_at: '2023-12-22T18:08:35.000Z', 265 | action: 'watch', 266 | type: 'episode', 267 | episode: { 268 | season: 1, 269 | number: 11, 270 | title: 'The Test Begins', 271 | ids: { 272 | trakt: 7431120, 273 | tvdb: 9679343, 274 | imdb: 'tt27933316', 275 | tmdb: 4317016, 276 | tvrage: null, 277 | }, 278 | }, 279 | show: { 280 | title: 'Tengoku Daimakyo', 281 | year: 2023, 282 | ids: { 283 | trakt: 199064, 284 | slug: 'tengoku-daimakyo', 285 | tvdb: 426165, 286 | imdb: 'tt22817632', 287 | tmdb: 208891, 288 | tvrage: null, 289 | }, 290 | }, 291 | }, 292 | { 293 | id: 9376866585, 294 | watched_at: '2023-12-04T19:41:26.000Z', 295 | action: 'watch', 296 | type: 'episode', 297 | episode: { 298 | season: 2, 299 | number: 3, 300 | title: 'THIS MISSIVE, THIS MACHINATION!', 301 | ids: { 302 | trakt: 10784230, 303 | tvdb: 9911060, 304 | imdb: 'tt15057062', 305 | tmdb: 4590788, 306 | tvrage: null, 307 | }, 308 | }, 309 | show: { 310 | title: 'Invincible', 311 | year: 2021, 312 | ids: { 313 | trakt: 172648, 314 | slug: 'invincible-2021', 315 | tvdb: 368207, 316 | imdb: 'tt6741278', 317 | tmdb: 95557, 318 | tvrage: null, 319 | }, 320 | }, 321 | }, 322 | ] 323 | 324 | export const showsPageTwo = [ 325 | { 326 | id: 9329050702, 327 | watched_at: '2023-11-10T18:49:55.000Z', 328 | action: 'watch', 329 | type: 'episode', 330 | episode: { 331 | season: 2, 332 | number: 13, 333 | title: 'Dark Clouds', 334 | ids: { 335 | trakt: 7182555, 336 | tvdb: 9672479, 337 | imdb: 'tt26422238', 338 | tmdb: 4149903, 339 | tvrage: null, 340 | }, 341 | }, 342 | show: { 343 | title: 'Vinland Saga', 344 | year: 2019, 345 | ids: { 346 | trakt: 143851, 347 | slug: 'vinland-saga', 348 | tvdb: 359274, 349 | imdb: 'tt10233448', 350 | tmdb: 88803, 351 | tvrage: null, 352 | }, 353 | }, 354 | }, 355 | { 356 | id: 9329027834, 357 | watched_at: '2023-11-10T18:25:02.000Z', 358 | action: 'watch', 359 | type: 'episode', 360 | episode: { 361 | season: 2, 362 | number: 12, 363 | title: 'For Lost Love', 364 | ids: { 365 | trakt: 7174970, 366 | tvdb: 9524431, 367 | imdb: 'tt26420423', 368 | tmdb: 4148010, 369 | tvrage: null, 370 | }, 371 | }, 372 | show: { 373 | title: 'Vinland Saga', 374 | year: 2019, 375 | ids: { 376 | trakt: 143851, 377 | slug: 'vinland-saga', 378 | tvdb: 359274, 379 | imdb: 'tt10233448', 380 | tmdb: 88803, 381 | tvrage: null, 382 | }, 383 | }, 384 | }, 385 | { 386 | id: 9328976954, 387 | watched_at: '2023-11-10T17:58:56.000Z', 388 | action: 'watch', 389 | type: 'episode', 390 | episode: { 391 | season: 2, 392 | number: 2, 393 | title: 'IN ABOUT SIX HOURS I LOSE MY VIRGINITY TO A FISH', 394 | ids: { 395 | trakt: 10784228, 396 | tvdb: 9911059, 397 | imdb: 'tt14799482', 398 | tmdb: 4590787, 399 | tvrage: null, 400 | }, 401 | }, 402 | show: { 403 | title: 'Invincible', 404 | year: 2021, 405 | ids: { 406 | trakt: 172648, 407 | slug: 'invincible-2021', 408 | tvdb: 368207, 409 | imdb: 'tt6741278', 410 | tmdb: 95557, 411 | tvrage: null, 412 | }, 413 | }, 414 | }, 415 | { 416 | id: 9323190153, 417 | watched_at: '2023-11-04T14:24:00.000Z', 418 | action: 'watch', 419 | type: 'episode', 420 | episode: { 421 | season: 2, 422 | number: 5, 423 | title: 'PLAN TO CROSS THE BORDER', 424 | ids: { 425 | trakt: 10870118, 426 | tvdb: 10075713, 427 | imdb: 'tt29669850', 428 | tmdb: 4698649, 429 | tvrage: null, 430 | }, 431 | }, 432 | show: { 433 | title: 'SPY x FAMILY', 434 | year: 2022, 435 | ids: { 436 | trakt: 190742, 437 | slug: 'spy-x-family', 438 | tvdb: 405920, 439 | imdb: 'tt13706018', 440 | tmdb: 120089, 441 | tvrage: null, 442 | }, 443 | }, 444 | }, 445 | ] 446 | -------------------------------------------------------------------------------- /src/lib/utils/__tests__/__fixtures__/normalize.ts: -------------------------------------------------------------------------------- 1 | export const historyShows = { 2 | id: 9434810476, 3 | watched_at: '2023-12-30T19:13:09.000Z', 4 | action: 'watch' as 'watch' | 'scrobble' | 'checkin', 5 | type: 'episode' as const, 6 | episode: { 7 | season: 1, 8 | number: 4, 9 | title: 'The Land Where Souls Rest', 10 | ids: { 11 | trakt: 10860108, 12 | tvdb: 9993739, 13 | imdb: 'tt29277817', 14 | tmdb: 4698539, 15 | tvrage: undefined, 16 | }, 17 | }, 18 | show: { 19 | title: 'Frieren: Beyond Journey\'s End', 20 | year: 2023, 21 | ids: { 22 | trakt: 198225, 23 | slug: 'frieren-beyond-journey-s-end', 24 | tvdb: 424536, 25 | imdb: 'tt22248376', 26 | tmdb: 209867, 27 | tvrage: undefined, 28 | }, 29 | }, 30 | } 31 | 32 | export const historyMovies = { 33 | id: 9435074612, 34 | watched_at: '2023-12-30T21:42:56.000Z', 35 | action: 'watch' as 'watch' | 'scrobble' | 'checkin', 36 | type: 'movie' as const, 37 | movie: { 38 | title: 'Scarface', 39 | year: 1983, 40 | ids: { 41 | trakt: 80, 42 | slug: 'scarface-1983', 43 | imdb: 'tt0086250', 44 | tmdb: 111, 45 | }, 46 | }, 47 | } 48 | 49 | export const watchedShows = { 50 | plays: 45, 51 | last_watched_at: '2024-01-04T10:51:27.000Z', 52 | last_updated_at: '2024-01-04T10:51:28.000Z', 53 | reset_at: null, 54 | show: { 55 | title: 'Jet Lag: The Game', 56 | year: 2022, 57 | ids: { 58 | trakt: 202731, 59 | slug: 'jet-lag-the-game', 60 | tvdb: 425498, 61 | imdb: 'tt23030000', 62 | tmdb: 237761, 63 | tvrage: null, 64 | }, 65 | }, 66 | } 67 | 68 | export const watchedMovies = { 69 | plays: 3, 70 | last_watched_at: '2019-12-22T13:25:00.000Z', 71 | last_updated_at: '2022-12-22T13:25:55.000Z', 72 | movie: { 73 | title: 'Harry Potter and the Philosopher\'s Stone', 74 | year: 2001, 75 | ids: { 76 | trakt: 545, 77 | slug: 'harry-potter-and-the-philosopher-s-stone-2001', 78 | imdb: 'tt0241527', 79 | tmdb: 671, 80 | }, 81 | }, 82 | } 83 | -------------------------------------------------------------------------------- /src/lib/utils/__tests__/index.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { capitalize, chunks, filterForYear, getStartAndEndOfYear, groupBy, lastWatchedMonth, lastWatchedYear, normalizeItem } from '../index' 3 | import { historyMovies, historyShows, watchedMovies, watchedShows } from './__fixtures__/normalize' 4 | 5 | describe('chunks', () => { 6 | it('should split array into chunks', () => { 7 | const array = [1, 2, 3, 4, 5, 6] 8 | const n = 2 9 | const result = chunks(array, n) 10 | expect(result).toEqual([[1, 2], [3, 4], [5, 6]]) 11 | }) 12 | it('should split odd array into chunks', () => { 13 | const array = [1, 2, 3, 4, 5] 14 | const n = 2 15 | const result = chunks(array, n) 16 | expect(result).toEqual([[1, 2], [3, 4], [5]]) 17 | }) 18 | it('should handle empty array', () => { 19 | const array: [] = [] 20 | const n = 2 21 | const result = chunks(array, n) 22 | expect(result).toEqual([]) 23 | }) 24 | it('should handle n as string', () => { 25 | const array = [1, 2, 3, 4, 5] 26 | const n = '2' 27 | const result = chunks(array, n) 28 | expect(result).toEqual([[1, 2], [3, 4], [5]]) 29 | }) 30 | }) 31 | 32 | describe('getStartAndEndOfYear', () => { 33 | it('should return start and end of year', () => { 34 | const result = getStartAndEndOfYear(2023) 35 | expect(result).toMatchInlineSnapshot(` 36 | { 37 | "end": "2023-12-31T00:00:00.000Z", 38 | "start": "2023-01-01T00:00:00.000Z", 39 | } 40 | `) 41 | }) 42 | it('handles year as string', () => { 43 | const result = getStartAndEndOfYear('2023') 44 | expect(result).toMatchInlineSnapshot(` 45 | { 46 | "end": "2023-12-31T00:00:00.000Z", 47 | "start": "2023-01-01T00:00:00.000Z", 48 | } 49 | `) 50 | }) 51 | }) 52 | 53 | describe('filterForYear', () => { 54 | it('should return true if item matches year', () => { 55 | const item: any = { 56 | last_watched_at_year: 2020, 57 | } 58 | const year = 2020 59 | const result = filterForYear(item, year) 60 | expect(result).toBe(true) 61 | }) 62 | it('should return false if item does not match year', () => { 63 | const item: any = { 64 | last_watched_at_year: 2020, 65 | } 66 | const year = 2021 67 | const result = filterForYear(item, year) 68 | expect(result).toBe(false) 69 | }) 70 | it('should handle year as string', () => { 71 | const item: any = { 72 | last_watched_at_year: 2020, 73 | } 74 | const year = '2020' 75 | const result = filterForYear(item, year) 76 | expect(result).toBe(true) 77 | }) 78 | }) 79 | 80 | describe('normalizeItem', () => { 81 | it('returns an empty object if the input is invalid', () => { 82 | const result = normalizeItem({} as any) 83 | expect(result).toEqual({}) 84 | }) 85 | it('transforms a /watched/shows response', () => { 86 | const result = normalizeItem(watchedShows) 87 | expect(result).toMatchInlineSnapshot(` 88 | { 89 | "last_watched_at": "2024-01-04T10:51:27.000Z", 90 | "last_watched_at_year": 2024, 91 | "last_wathed_at_month": "January", 92 | "plays": 45, 93 | "release_year": 2022, 94 | "title": "Jet Lag: The Game", 95 | "tmdb_id": 237761, 96 | "trakt_id": 202731, 97 | } 98 | `) 99 | expect(result.title).toBe(watchedShows.show.title) 100 | }) 101 | it('transforms a /watched/movies response', () => { 102 | const result = normalizeItem(watchedMovies) 103 | expect(result).toMatchInlineSnapshot(` 104 | { 105 | "last_watched_at": "2019-12-22T13:25:00.000Z", 106 | "last_watched_at_year": 2019, 107 | "last_wathed_at_month": "December", 108 | "release_year": 2001, 109 | "title": "Harry Potter and the Philosopher's Stone", 110 | "tmdb_id": 671, 111 | "trakt_id": 545, 112 | } 113 | `) 114 | expect(result.title).toBe(watchedMovies.movie.title) 115 | }) 116 | it('transforms a /history/movies response', () => { 117 | const result = normalizeItem(historyMovies) 118 | expect(result).toMatchInlineSnapshot(` 119 | { 120 | "last_watched_at": "2023-12-30T21:42:56.000Z", 121 | "last_watched_at_year": 2023, 122 | "last_wathed_at_month": "December", 123 | "release_year": 1983, 124 | "title": "Scarface", 125 | "tmdb_id": 111, 126 | "trakt_id": 80, 127 | } 128 | `) 129 | expect(result.title).toBe(historyMovies.movie.title) 130 | }) 131 | it('transforms a /history/shows response', () => { 132 | const result = normalizeItem(historyShows) 133 | expect(result).toMatchInlineSnapshot(` 134 | { 135 | "last_watched_at": "2023-12-30T19:13:09.000Z", 136 | "last_watched_at_year": 2023, 137 | "last_wathed_at_month": "December", 138 | "release_year": 2023, 139 | "title": "Frieren: Beyond Journey's End", 140 | "tmdb_id": 209867, 141 | "trakt_id": 198225, 142 | } 143 | `) 144 | expect(result.title).toBe(historyShows.show.title) 145 | }) 146 | }) 147 | 148 | describe('capitalize', () => { 149 | it('should capitalize a string', () => { 150 | const result = capitalize('hello') 151 | expect(result).toBe('Hello') 152 | }) 153 | it('should handle empty string', () => { 154 | const result = capitalize('') 155 | expect(result).toBe('') 156 | }) 157 | it('should leave rest of string untouched', () => { 158 | const result = capitalize('hello world') 159 | expect(result).toBe('Hello world') 160 | }) 161 | }) 162 | 163 | describe('lastWatchedYear', () => { 164 | it('should return the correct year', () => { 165 | const year = lastWatchedYear('2022-01-01') 166 | expect(year).toBe(2022) 167 | }) 168 | it('should handle different date formats', () => { 169 | const year = lastWatchedYear('12/31/2021') 170 | expect(year).toBe(2021) 171 | 172 | const year2 = lastWatchedYear('2023-11-30T21:42:56.000Z') 173 | expect(year2).toBe(2023) 174 | }) 175 | it('should handle invalid dates', () => { 176 | const year = lastWatchedYear('invalid date') 177 | expect(year).toBeNaN() 178 | }) 179 | }) 180 | 181 | describe('lastWatchedMonth', () => { 182 | it('should return the correct year', () => { 183 | const year = lastWatchedMonth('2022-01-01') 184 | expect(year).toBe('January') 185 | }) 186 | it('should handle different date formats', () => { 187 | const year = lastWatchedMonth('12/31/2021') 188 | expect(year).toBe('December') 189 | 190 | const year2 = lastWatchedMonth('2023-11-30T21:42:56.000Z') 191 | expect(year2).toBe('November') 192 | }) 193 | it('should handle invalid dates', () => { 194 | const year = lastWatchedMonth('invalid date') 195 | expect(year).toBe('Invalid Date') 196 | }) 197 | }) 198 | 199 | describe('groupBy', () => { 200 | it('should group array elements by the specified key', () => { 201 | const arr = [ 202 | { id: 1, name: 'John' }, 203 | { id: 2, name: 'Jane' }, 204 | { id: 3, name: 'John' }, 205 | { id: 4, name: 'Jane' }, 206 | ] 207 | 208 | const result = groupBy(arr, 'name') 209 | 210 | expect(result).toEqual({ 211 | John: [ 212 | { id: 1, name: 'John' }, 213 | { id: 3, name: 'John' }, 214 | ], 215 | Jane: [ 216 | { id: 2, name: 'Jane' }, 217 | { id: 4, name: 'Jane' }, 218 | ], 219 | }) 220 | }) 221 | it('should return an empty object if the array is empty', () => { 222 | const arr: Array = [] 223 | const result = groupBy(arr, 'name') 224 | expect(result).toEqual({}) 225 | }) 226 | it('should handle arrays with duplicate keys', () => { 227 | const arr = [ 228 | { id: 1, name: 'John' }, 229 | { id: 2, name: 'John' }, 230 | { id: 3, name: 'John' }, 231 | ] 232 | 233 | const result = groupBy(arr, 'name') 234 | 235 | expect(result).toEqual({ 236 | John: [ 237 | { id: 1, name: 'John' }, 238 | { id: 2, name: 'John' }, 239 | { id: 3, name: 'John' }, 240 | ], 241 | }) 242 | }) 243 | }) 244 | -------------------------------------------------------------------------------- /src/lib/utils/__tests__/tmdb.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { calculateSizeWithRatio, tmdbImageUrl, tmdbImageUrlsWithDimensions, tmdbItemDetailsUrl } from '../tmdb' 3 | 4 | describe('calculateSizeWithRatio', () => { 5 | it('should calculate size with ratio', () => { 6 | const result = calculateSizeWithRatio('w780', 1.5) 7 | expect(result).toEqual({ 8 | width: 780, 9 | height: 1170, 10 | }) 11 | }) 12 | }) 13 | 14 | describe('tmdbImageUrlsWithDimensions', () => { 15 | it('should return image URLs with dimensions', () => { 16 | const result = tmdbImageUrlsWithDimensions('/abc.jpg') 17 | expect(result).toEqual({ 18 | w92: { 19 | url: 'https://image.tmdb.org/t/p/w92/abc.jpg', 20 | width: 92, 21 | height: 138, 22 | }, 23 | w154: { 24 | url: 'https://image.tmdb.org/t/p/w154/abc.jpg', 25 | width: 154, 26 | height: 231, 27 | }, 28 | w185: { 29 | url: 'https://image.tmdb.org/t/p/w185/abc.jpg', 30 | width: 185, 31 | height: 277.5, 32 | }, 33 | w342: { 34 | url: 'https://image.tmdb.org/t/p/w342/abc.jpg', 35 | width: 342, 36 | height: 513, 37 | }, 38 | w500: { 39 | url: 'https://image.tmdb.org/t/p/w500/abc.jpg', 40 | width: 500, 41 | height: 750, 42 | }, 43 | w780: { 44 | url: 'https://image.tmdb.org/t/p/w780/abc.jpg', 45 | width: 780, 46 | height: 1170, 47 | }, 48 | }) 49 | }) 50 | }) 51 | 52 | describe('tmdbImageUrl', () => { 53 | it('should return image URL', () => { 54 | const result = tmdbImageUrl('/abc.jpg', 'w780') 55 | expect(result).toBe('https://image.tmdb.org/t/p/w780/abc.jpg') 56 | }) 57 | }) 58 | 59 | describe('tmdbItemDetailsUrl', () => { 60 | it('should return item details URL for movies', () => { 61 | const result = tmdbItemDetailsUrl('movies', '123') 62 | expect(result).toBe('https://api.themoviedb.org/3/movie/123') 63 | }) 64 | it('should return item details URL for shows', () => { 65 | const result = tmdbItemDetailsUrl('shows', '123') 66 | expect(result).toBe('https://api.themoviedb.org/3/tv/123') 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /src/lib/utils/__tests__/trakt.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { normalizeItem } from '..' 3 | import { filterUniqueShowsFromHistory, traktHistoryUrl, traktStatsUrl, traktUserUrl, traktWatchedUrl } from '../trakt' 4 | import { mergedNames, showsPageOne, showsPageOneNames, showsPageTwo, showsPageTwoNames } from './__fixtures__/history.shows' 5 | 6 | const normalizedShowsPageOne = showsPageOne.map(normalizeItem as any) 7 | const normalizedShowsPageTwo = showsPageTwo.map(normalizeItem as any) 8 | 9 | describe('traktUserUrl', () => { 10 | it('should return URL with user prefix', () => { 11 | expect(traktUserUrl('arsaurea')).toBe('/users/arsaurea') 12 | }) 13 | }) 14 | 15 | describe('traktHistoryUrl', () => { 16 | it('should return URL for movies', () => { 17 | expect(traktHistoryUrl('arsaurea', 'movies')).toBe('/users/arsaurea/history/movies') 18 | }) 19 | it('should return URL for shows', () => { 20 | expect(traktHistoryUrl('arsaurea', 'shows')).toBe('/users/arsaurea/history/shows') 21 | }) 22 | }) 23 | 24 | describe('traktStatsUrl', () => { 25 | it('should return URL with stats prefix', () => { 26 | expect(traktStatsUrl('arsaurea')).toBe('/users/arsaurea/stats') 27 | }) 28 | }) 29 | 30 | describe('traktWatchedUrl', () => { 31 | it('should return URL for movies', () => { 32 | expect(traktWatchedUrl('arsaurea', 'movies')).toBe('/users/arsaurea/watched/movies') 33 | }) 34 | it('should return URL for shows', () => { 35 | expect(traktWatchedUrl('arsaurea', 'shows')).toBe('/users/arsaurea/watched/shows?extended=noseasons') 36 | }) 37 | }) 38 | 39 | describe('filterUniqueShowsFromHistory', () => { 40 | describe('error handling', () => { 41 | it('should return empty array if no history is passed', () => { 42 | expect(filterUniqueShowsFromHistory(undefined as any)).toEqual([]) 43 | }) 44 | it('should return empty array if history is not an array', () => { 45 | expect(filterUniqueShowsFromHistory({} as any)).toEqual([]) 46 | }) 47 | it('should return empty array if history is empty', () => { 48 | expect(filterUniqueShowsFromHistory([])).toEqual([]) 49 | }) 50 | }) 51 | describe('functionality', () => { 52 | it('should return unique shows from history', () => { 53 | const o = filterUniqueShowsFromHistory(normalizedShowsPageOne as any) 54 | expect(o.map(i => i.title)).toEqual(expect.arrayContaining([...showsPageOneNames])) 55 | 56 | const v = filterUniqueShowsFromHistory(normalizedShowsPageTwo as any) 57 | expect(v.map(i => i.title)).toEqual(expect.arrayContaining([...showsPageTwoNames])) 58 | }) 59 | it('should return unique shows in correct order', () => { 60 | const o = filterUniqueShowsFromHistory(normalizedShowsPageOne as any) 61 | expect(o.map(i => i.title)).toEqual(showsPageOneNames) 62 | 63 | const v = filterUniqueShowsFromHistory(normalizedShowsPageTwo as any) 64 | expect(v.map(i => i.title)).toEqual(showsPageTwoNames) 65 | }) 66 | it('should return unique shows from merged history', () => { 67 | const o = filterUniqueShowsFromHistory([...normalizedShowsPageOne, ...normalizedShowsPageTwo] as any) 68 | expect(o.map(i => i.title)).toEqual(mergedNames) 69 | }) 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /src/lib/utils/index.ts: -------------------------------------------------------------------------------- 1 | import type { NormalizedItemResponse, TraktHistoryItem, TraktWatchedItem } from '../types' 2 | 3 | /** 4 | * Translate Trakt media type to TMDB media type 5 | * @example Trakt: shows => TMDB: tv 6 | */ 7 | export const traktTmdbMediaMap = { 8 | movies: 'movie', 9 | shows: 'tv', 10 | } as const 11 | 12 | /** 13 | * Check if the item does not have the 'type' property, which means it's a TraktWatchedItem 14 | * @example isTraktWatchedItem({ movie: { ... }}) => true 15 | */ 16 | function isTraktWatchedItem(item: TraktHistoryItem | TraktWatchedItem): item is TraktWatchedItem { 17 | return !('type' in item) 18 | } 19 | 20 | /** 21 | * Extract the full year from a date string 22 | * @example lastWatchedYear('2020-01-01') => 2020 23 | */ 24 | export function lastWatchedYear(date: string): number { 25 | return new Date(date).getFullYear() 26 | } 27 | 28 | /** 29 | * Extract the month from a date string and return the name 30 | * @example lastWatchedMonth('2020-01-01') => 'January' 31 | */ 32 | export function lastWatchedMonth(date: string): string { 33 | return new Date(date).toLocaleString('en-US', { month: 'long' }) 34 | } 35 | 36 | /** 37 | * Normalize an incoming item (either from /history or /watched endpoint) to a common format since they are slightly different in what data they hold. 38 | * This function's response is used on the frontend. 39 | * @example normalizeItem({ movie: { ... }}) => { title: '...', ... } 40 | */ 41 | export function normalizeItem(item: TraktHistoryItem | TraktWatchedItem): NormalizedItemResponse { 42 | const result = {} as NormalizedItemResponse 43 | 44 | if (isTraktWatchedItem(item)) { 45 | // /watched endpoint 46 | if ('movie' in item) { 47 | result.title = item.movie.title 48 | result.release_year = item.movie.year 49 | result.trakt_id = item.movie.ids.trakt 50 | result.tmdb_id = item.movie.ids?.tmdb 51 | result.last_watched_at = item.last_watched_at 52 | result.last_watched_at_year = lastWatchedYear(item.last_watched_at) 53 | result.last_wathed_at_month = lastWatchedMonth(item.last_watched_at) 54 | } 55 | 56 | if ('show' in item) { 57 | result.title = item.show.title 58 | result.release_year = item.show.year 59 | result.trakt_id = item.show.ids.trakt 60 | result.tmdb_id = item.show.ids?.tmdb 61 | result.last_watched_at = item.last_watched_at 62 | result.last_watched_at_year = lastWatchedYear(item.last_watched_at) 63 | result.last_wathed_at_month = lastWatchedMonth(item.last_watched_at) 64 | result.plays = item.plays 65 | } 66 | } 67 | else { 68 | // /history endpoint 69 | switch (item.type) { 70 | case 'movie': { 71 | result.title = item.movie.title 72 | result.release_year = item.movie.year 73 | result.trakt_id = item.movie.ids.trakt 74 | result.tmdb_id = item.movie.ids?.tmdb 75 | result.last_watched_at = item.watched_at 76 | result.last_watched_at_year = lastWatchedYear(item.watched_at) 77 | result.last_wathed_at_month = lastWatchedMonth(item.watched_at) 78 | 79 | break 80 | } 81 | case 'episode': { 82 | result.title = item.show.title 83 | result.release_year = item.show.year 84 | result.trakt_id = item.show.ids.trakt 85 | result.tmdb_id = item.show.ids?.tmdb 86 | result.last_watched_at = item.watched_at 87 | result.last_watched_at_year = lastWatchedYear(item.watched_at) 88 | result.last_wathed_at_month = lastWatchedMonth(item.watched_at) 89 | 90 | break 91 | } 92 | } 93 | } 94 | 95 | return result 96 | } 97 | 98 | /** 99 | * Split an array into chunks of a given size 100 | * @example chunks([1, 2, 3, 4, 5], 2) => [[1, 2], [3, 4], [5]] 101 | */ 102 | export function chunks(array: Array, number: number | string): Array> { 103 | const n = typeof number === 'string' ? Number.parseInt(number) : number 104 | const result = [] 105 | for (let i = 0; i < array.length; i += n) 106 | result.push(array.slice(i, i + n)) 107 | 108 | return result 109 | } 110 | 111 | /** 112 | * Given a year, return the start and end date of that year in ISO format 113 | * @example getStartAndEndOfYear(2023) => { start: '2022-12-31T23:00:00.000Z', end: '2023-12-30T23:00:00.000Z' } 114 | */ 115 | export function getStartAndEndOfYear(year: number | string) { 116 | const y = typeof year === 'string' ? Number.parseInt(year) : year 117 | return { 118 | start: new Date(Date.UTC(y, 0, 1)).toISOString(), 119 | end: new Date(Date.UTC(y, 11, 31)).toISOString(), 120 | } 121 | } 122 | 123 | /** 124 | * Parse the last_watched_at_year property and check if it matches the given year 125 | * Should be used with Array.filter() 126 | * @example filterForYear({ last_watched_at_year: 2020 }, 2020) => true 127 | * @example arr.filter(item => filterForYear(item, 2020) 128 | */ 129 | export function filterForYear(item: NormalizedItemResponse, year: number | string) { 130 | const y = typeof year === 'string' ? Number.parseInt(year) : year 131 | return item.last_watched_at_year === y 132 | } 133 | 134 | export function capitalize(str: string): string { 135 | return str.slice(0, 1).toUpperCase() + str.slice(1) 136 | } 137 | 138 | type MapValuesToKeysIfAllowed = { 139 | [K in keyof T]: T[K] extends PropertyKey ? K : never; 140 | } 141 | type Filter = MapValuesToKeysIfAllowed[keyof T] 142 | 143 | export function groupBy, Key extends Filter>( 144 | arr: Array, 145 | key: Key, 146 | ): Record> { 147 | return arr.reduce((accumulator, val) => { 148 | const groupedKey = val[key] 149 | if (!accumulator[groupedKey]) 150 | accumulator[groupedKey] = [] 151 | 152 | accumulator[groupedKey].push(val) 153 | return accumulator 154 | }, {} as Record>) 155 | } 156 | -------------------------------------------------------------------------------- /src/lib/utils/tmdb.ts: -------------------------------------------------------------------------------- 1 | import type { TmdbPosterSize, TraktMediaType } from '../types' 2 | import { TMDB_BASE_URL, TMDB_IMAGE_BASE_URL, TMDB_POSTER_SIZES } from '$const' 3 | import { traktTmdbMediaMap } from './index' 4 | 5 | export function tmdbItemDetailsUrl(type: TraktMediaType, tmdb_id: string) { 6 | return `${TMDB_BASE_URL}${traktTmdbMediaMap[type]}/${tmdb_id}` 7 | } 8 | 9 | /** 10 | * Calculate the width and height given a TMDB poster size + ratio 11 | */ 12 | export function calculateSizeWithRatio(width: TmdbPosterSize, ratio = 1.5) { 13 | const w = Number.parseInt(width.slice(1)) 14 | const h = w * ratio 15 | 16 | return { 17 | width: w, 18 | height: h, 19 | } 20 | } 21 | 22 | export function tmdbImageUrl(poster_path: string, size: TmdbPosterSize = 'w780') { 23 | return `${TMDB_IMAGE_BASE_URL}${TMDB_POSTER_SIZES[size]}${poster_path}` 24 | } 25 | 26 | export function tmdbImageUrlsWithDimensions(poster_path: string) { 27 | return Object.values(TMDB_POSTER_SIZES).reduce((acc, size) => { 28 | const { width, height } = calculateSizeWithRatio(size) 29 | acc[size] = { 30 | url: tmdbImageUrl(poster_path, size), 31 | width, 32 | height, 33 | } 34 | return acc 35 | }, {} as Record) 36 | } 37 | -------------------------------------------------------------------------------- /src/lib/utils/trakt.ts: -------------------------------------------------------------------------------- 1 | import type { NormalizedItemResponse, TraktMediaType } from '../types' 2 | import { uniqBy } from 'lodash-es' 3 | 4 | export function traktUserUrl(id: string) { 5 | return `/users/${id}` 6 | } 7 | 8 | export function traktWatchedUrl(id: string, type: TraktMediaType) { 9 | return `${traktUserUrl(id)}/watched/${type}${type === 'shows' ? '?extended=noseasons' : ''}` 10 | } 11 | 12 | export function traktStatsUrl(id: string) { 13 | return `${traktUserUrl(id)}/stats` 14 | } 15 | 16 | export function traktHistoryUrl(id: string, type: TraktMediaType) { 17 | return `${traktUserUrl(id)}/history/${type}` 18 | } 19 | 20 | /** 21 | * Filter the response of /history/shows to unique shows since the endpoint returns episodes, not only shows. The returned result only contains unique values (so no duplicated) and is ordered by the order they originally appeared in the response. 22 | * The order will be by the last watched episode of each show. 23 | */ 24 | export function filterUniqueShowsFromHistory(history: Array): Array { 25 | if (!history) 26 | return [] 27 | if (!Array.isArray(history)) 28 | return [] 29 | 30 | return uniqBy(history, 'trakt_id') 31 | } 32 | -------------------------------------------------------------------------------- /src/routes/+error.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 |
16 | {#if page.error?.message} 17 |

{page.status}: {page.error.message}

18 | {#if page.status === 404} 19 |

Sorry, the page you are looking for does not exist.

20 | {:else if page.status === 401} 21 |

Sorry, you are not authorized to view this page.

22 | {:else if page.status === 403} 23 |

Sorry, you are forbidden to view this page.

24 | {:else if page.status === 500} 25 |

Sorry, something went wrong on our end. Please try again later.

26 | {:else} 27 |

Something went wrong. Please try again, and if that doesn't help please create a bug report on GitHub. Thanks!

28 | {/if} 29 | {:else} 30 |

Something went wrong. Please try again, and if that doesn't help please create a bug report on GitHub. Thanks!

31 | {/if} 32 |
33 | 34 | 39 | -------------------------------------------------------------------------------- /src/routes/+layout.server.ts: -------------------------------------------------------------------------------- 1 | import type { LayoutServerLoad } from './$types' 2 | 3 | export const load: LayoutServerLoad = async (event) => { 4 | return { 5 | session: await event.locals.auth(), 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | {@render children?.()} 26 | 27 | 28 |