From 60110fe8f23c53c837aff82d77a21ad8af4b5bb2 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sun, 1 Mar 2026 14:39:20 -0800 Subject: fix(anime): unify due classification and harden subtitle matching --- package.json | 4 +- pnpm-lock.yaml | 729 +++++++++++++++++++++++++- src/lib/Data/AniList/media.ts | 1 + src/lib/List/Anime/DueAnimeList.svelte | 12 +- src/lib/List/Anime/UpcomingAnimeList.svelte | 6 +- src/lib/Media/Anime/Airing/Subtitled/match.ts | 159 +++++- src/lib/Media/Anime/Airing/classify.test.ts | 153 ++++++ src/lib/Media/Anime/Airing/classify.ts | 53 ++ 8 files changed, 1076 insertions(+), 41 deletions(-) create mode 100644 src/lib/Media/Anime/Airing/classify.test.ts create mode 100644 src/lib/Media/Anime/Airing/classify.ts diff --git a/package.json b/package.json index 8f2a2da3..53b8399f 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dev": "vite dev", "build": "npx sveltekit-graphql generate && vite build", "preview": "vite preview", + "test:unit": "vitest run", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "prettier --check . && eslint .", @@ -40,7 +41,8 @@ "sveltekit-rate-limiter": "^0.4.2", "tslib": "^2.4.1", "typescript": "^5.5.0", - "vite": "^5.4.4" + "vite": "^5.4.4", + "vitest": "^4.0.18" }, "type": "module", "dependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8bd995fb..ebd3dc28 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,6 +152,9 @@ importers: vite: specifier: ^5.4.4 version: 5.4.21(@types/node@25.0.10)(sass@1.97.3) + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jsdom@23.2.0)(sass@1.97.3) packages: '@ardatan/relay-compiler@12.0.3': @@ -459,6 +462,15 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.3': + resolution: + { + integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg== + } + engines: { node: '>=18' } + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.18.20': resolution: { @@ -486,6 +498,15 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.3': + resolution: + { + integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg== + } + engines: { node: '>=18' } + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.18.20': resolution: { @@ -513,6 +534,15 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.3': + resolution: + { + integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA== + } + engines: { node: '>=18' } + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.18.20': resolution: { @@ -540,6 +570,15 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.3': + resolution: + { + integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ== + } + engines: { node: '>=18' } + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.18.20': resolution: { @@ -567,6 +606,15 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.3': + resolution: + { + integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg== + } + engines: { node: '>=18' } + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.18.20': resolution: { @@ -594,6 +642,15 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.3': + resolution: + { + integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg== + } + engines: { node: '>=18' } + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.18.20': resolution: { @@ -621,6 +678,15 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.3': + resolution: + { + integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w== + } + engines: { node: '>=18' } + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.18.20': resolution: { @@ -648,6 +714,15 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.3': + resolution: + { + integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA== + } + engines: { node: '>=18' } + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.18.20': resolution: { @@ -675,6 +750,15 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.3': + resolution: + { + integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg== + } + engines: { node: '>=18' } + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.18.20': resolution: { @@ -702,6 +786,15 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.3': + resolution: + { + integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw== + } + engines: { node: '>=18' } + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.18.20': resolution: { @@ -729,6 +822,15 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.3': + resolution: + { + integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg== + } + engines: { node: '>=18' } + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.18.20': resolution: { @@ -756,6 +858,15 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.3': + resolution: + { + integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA== + } + engines: { node: '>=18' } + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.18.20': resolution: { @@ -783,6 +894,15 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.3': + resolution: + { + integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw== + } + engines: { node: '>=18' } + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.18.20': resolution: { @@ -810,6 +930,15 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.3': + resolution: + { + integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA== + } + engines: { node: '>=18' } + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.18.20': resolution: { @@ -837,6 +966,15 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.3': + resolution: + { + integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ== + } + engines: { node: '>=18' } + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.18.20': resolution: { @@ -864,6 +1002,15 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.3': + resolution: + { + integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw== + } + engines: { node: '>=18' } + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.18.20': resolution: { @@ -891,6 +1038,24 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.3': + resolution: + { + integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA== + } + engines: { node: '>=18' } + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: + { + integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA== + } + engines: { node: '>=18' } + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.18.20': resolution: { @@ -918,6 +1083,24 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.3': + resolution: + { + integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA== + } + engines: { node: '>=18' } + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: + { + integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw== + } + engines: { node: '>=18' } + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.18.20': resolution: { @@ -945,6 +1128,24 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.3': + resolution: + { + integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ== + } + engines: { node: '>=18' } + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: + { + integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g== + } + engines: { node: '>=18' } + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.18.20': resolution: { @@ -972,6 +1173,15 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.3': + resolution: + { + integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA== + } + engines: { node: '>=18' } + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.18.20': resolution: { @@ -999,6 +1209,15 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.3': + resolution: + { + integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA== + } + engines: { node: '>=18' } + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.18.20': resolution: { @@ -1026,6 +1245,15 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.3': + resolution: + { + integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q== + } + engines: { node: '>=18' } + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.18.20': resolution: { @@ -1053,6 +1281,15 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.3': + resolution: + { + integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA== + } + engines: { node: '>=18' } + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: { @@ -2338,6 +2575,12 @@ packages: integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA== } + '@standard-schema/spec@1.1.0': + resolution: + { + integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== + } + '@supabase/auth-js@2.91.0': resolution: { @@ -2507,6 +2750,12 @@ packages: integrity: sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w== } + '@types/chai@5.2.3': + resolution: + { + integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA== + } + '@types/cookie@0.5.4': resolution: { @@ -2519,6 +2768,12 @@ packages: integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA== } + '@types/deep-eql@4.0.2': + resolution: + { + integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== + } + '@types/estree@1.0.8': resolution: { @@ -2784,6 +3039,56 @@ packages: vue-router: optional: true + '@vitest/expect@4.0.18': + resolution: + { + integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ== + } + + '@vitest/mocker@4.0.18': + resolution: + { + integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ== + } + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.18': + resolution: + { + integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw== + } + + '@vitest/runner@4.0.18': + resolution: + { + integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw== + } + + '@vitest/snapshot@4.0.18': + resolution: + { + integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA== + } + + '@vitest/spy@4.0.18': + resolution: + { + integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw== + } + + '@vitest/utils@4.0.18': + resolution: + { + integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA== + } + '@whatwg-node/disposablestack@0.0.6': resolution: { @@ -3007,6 +3312,13 @@ packages: integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA== } + assertion-error@2.0.1: + resolution: + { + integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== + } + engines: { node: '>=12' } + ast-types@0.16.1: resolution: { @@ -3186,6 +3498,13 @@ packages: integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A== } + chai@6.2.2: + resolution: + { + integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg== + } + engines: { node: '>=18' } + chalk@4.1.2: resolution: { @@ -3688,6 +4007,12 @@ packages: } engines: { node: '>= 0.4' } + es-module-lexer@1.7.0: + resolution: + { + integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== + } + es-object-atoms@1.1.1: resolution: { @@ -3752,6 +4077,14 @@ packages: engines: { node: '>=12' } hasBin: true + esbuild@0.27.3: + resolution: + { + integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg== + } + engines: { node: '>=18' } + hasBin: true + escalade@3.2.0: resolution: { @@ -3933,6 +4266,13 @@ packages: } engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } + expect-type@1.3.0: + resolution: + { + integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA== + } + engines: { node: '>=12.0.0' } + ext@1.7.0: resolution: { @@ -5356,6 +5696,12 @@ packages: } engines: { node: '>=0.10.0' } + obug@2.1.1: + resolution: + { + integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ== + } + once@1.4.0: resolution: { @@ -5567,6 +5913,12 @@ packages: } engines: { node: '>=8' } + pathe@2.0.3: + resolution: + { + integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== + } + picocolors@1.1.1: resolution: { @@ -5992,6 +6344,12 @@ packages: integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw== } + siginfo@2.0.0: + resolution: + { + integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== + } + signal-exit@3.0.7: resolution: { @@ -6095,6 +6453,12 @@ packages: integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== } + stackback@0.0.2: + resolution: + { + integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== + } + statuses@2.0.2: resolution: { @@ -6102,6 +6466,12 @@ packages: } engines: { node: '>= 0.8' } + std-env@3.10.0: + resolution: + { + integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg== + } + streamsearch@1.1.0: resolution: { @@ -6337,6 +6707,33 @@ packages: integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg== } + tinybench@2.9.0: + resolution: + { + integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== + } + + tinyexec@1.0.2: + resolution: + { + integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg== + } + engines: { node: '>=18' } + + tinyglobby@0.2.15: + resolution: + { + integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== + } + engines: { node: '>=12.0.0' } + + tinyrainbow@3.0.3: + resolution: + { + integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q== + } + engines: { node: '>=14.0.0' } + title-case@3.0.3: resolution: { @@ -6679,6 +7076,49 @@ packages: terser: optional: true + vite@7.3.1: + resolution: + { + integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA== + } + engines: { node: ^20.19.0 || >=22.12.0 } + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vitefu@0.2.5: resolution: { @@ -6701,6 +7141,43 @@ packages: vite: optional: true + vitest@4.0.18: + resolution: + { + integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ== + } + engines: { node: ^20.0.0 || ^22.0.0 || >=24.0.0 } + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + w3c-xmlserializer@5.0.0: resolution: { @@ -6779,6 +7256,14 @@ packages: engines: { node: '>= 8' } hasBin: true + why-is-node-running@2.3.0: + resolution: + { + integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== + } + engines: { node: '>=8' } + hasBin: true + wide-align@1.1.5: resolution: { @@ -7234,6 +7719,9 @@ snapshots: '@esbuild/aix-ppc64@0.21.5': optional: true + '@esbuild/aix-ppc64@0.27.3': + optional: true + '@esbuild/android-arm64@0.18.20': optional: true @@ -7243,6 +7731,9 @@ snapshots: '@esbuild/android-arm64@0.21.5': optional: true + '@esbuild/android-arm64@0.27.3': + optional: true + '@esbuild/android-arm@0.18.20': optional: true @@ -7252,6 +7743,9 @@ snapshots: '@esbuild/android-arm@0.21.5': optional: true + '@esbuild/android-arm@0.27.3': + optional: true + '@esbuild/android-x64@0.18.20': optional: true @@ -7261,6 +7755,9 @@ snapshots: '@esbuild/android-x64@0.21.5': optional: true + '@esbuild/android-x64@0.27.3': + optional: true + '@esbuild/darwin-arm64@0.18.20': optional: true @@ -7270,6 +7767,9 @@ snapshots: '@esbuild/darwin-arm64@0.21.5': optional: true + '@esbuild/darwin-arm64@0.27.3': + optional: true + '@esbuild/darwin-x64@0.18.20': optional: true @@ -7279,6 +7779,9 @@ snapshots: '@esbuild/darwin-x64@0.21.5': optional: true + '@esbuild/darwin-x64@0.27.3': + optional: true + '@esbuild/freebsd-arm64@0.18.20': optional: true @@ -7288,6 +7791,9 @@ snapshots: '@esbuild/freebsd-arm64@0.21.5': optional: true + '@esbuild/freebsd-arm64@0.27.3': + optional: true + '@esbuild/freebsd-x64@0.18.20': optional: true @@ -7297,6 +7803,9 @@ snapshots: '@esbuild/freebsd-x64@0.21.5': optional: true + '@esbuild/freebsd-x64@0.27.3': + optional: true + '@esbuild/linux-arm64@0.18.20': optional: true @@ -7306,6 +7815,9 @@ snapshots: '@esbuild/linux-arm64@0.21.5': optional: true + '@esbuild/linux-arm64@0.27.3': + optional: true + '@esbuild/linux-arm@0.18.20': optional: true @@ -7315,6 +7827,9 @@ snapshots: '@esbuild/linux-arm@0.21.5': optional: true + '@esbuild/linux-arm@0.27.3': + optional: true + '@esbuild/linux-ia32@0.18.20': optional: true @@ -7324,6 +7839,9 @@ snapshots: '@esbuild/linux-ia32@0.21.5': optional: true + '@esbuild/linux-ia32@0.27.3': + optional: true + '@esbuild/linux-loong64@0.18.20': optional: true @@ -7333,6 +7851,9 @@ snapshots: '@esbuild/linux-loong64@0.21.5': optional: true + '@esbuild/linux-loong64@0.27.3': + optional: true + '@esbuild/linux-mips64el@0.18.20': optional: true @@ -7342,6 +7863,9 @@ snapshots: '@esbuild/linux-mips64el@0.21.5': optional: true + '@esbuild/linux-mips64el@0.27.3': + optional: true + '@esbuild/linux-ppc64@0.18.20': optional: true @@ -7351,6 +7875,9 @@ snapshots: '@esbuild/linux-ppc64@0.21.5': optional: true + '@esbuild/linux-ppc64@0.27.3': + optional: true + '@esbuild/linux-riscv64@0.18.20': optional: true @@ -7360,6 +7887,9 @@ snapshots: '@esbuild/linux-riscv64@0.21.5': optional: true + '@esbuild/linux-riscv64@0.27.3': + optional: true + '@esbuild/linux-s390x@0.18.20': optional: true @@ -7369,6 +7899,9 @@ snapshots: '@esbuild/linux-s390x@0.21.5': optional: true + '@esbuild/linux-s390x@0.27.3': + optional: true + '@esbuild/linux-x64@0.18.20': optional: true @@ -7378,6 +7911,12 @@ snapshots: '@esbuild/linux-x64@0.21.5': optional: true + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + '@esbuild/netbsd-x64@0.18.20': optional: true @@ -7387,6 +7926,12 @@ snapshots: '@esbuild/netbsd-x64@0.21.5': optional: true + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + '@esbuild/openbsd-x64@0.18.20': optional: true @@ -7396,6 +7941,12 @@ snapshots: '@esbuild/openbsd-x64@0.21.5': optional: true + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + '@esbuild/sunos-x64@0.18.20': optional: true @@ -7405,6 +7956,9 @@ snapshots: '@esbuild/sunos-x64@0.21.5': optional: true + '@esbuild/sunos-x64@0.27.3': + optional: true + '@esbuild/win32-arm64@0.18.20': optional: true @@ -7414,6 +7968,9 @@ snapshots: '@esbuild/win32-arm64@0.21.5': optional: true + '@esbuild/win32-arm64@0.27.3': + optional: true + '@esbuild/win32-ia32@0.18.20': optional: true @@ -7423,6 +7980,9 @@ snapshots: '@esbuild/win32-ia32@0.21.5': optional: true + '@esbuild/win32-ia32@0.27.3': + optional: true + '@esbuild/win32-x64@0.18.20': optional: true @@ -7432,6 +7992,9 @@ snapshots: '@esbuild/win32-x64@0.21.5': optional: true + '@esbuild/win32-x64@0.27.3': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': dependencies: eslint: 8.57.1 @@ -8360,6 +8923,8 @@ snapshots: '@socket.io/component-emitter@3.1.2': {} + '@standard-schema/spec@1.1.0': {} + '@supabase/auth-js@2.91.0': dependencies: tslib: 2.8.1 @@ -8570,10 +9135,17 @@ snapshots: '@types/braces@3.0.5': {} + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/cookie@0.5.4': {} '@types/cookie@0.6.0': {} + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/fast-levenshtein@0.0.4': {} @@ -8744,6 +9316,45 @@ snapshots: '@sveltejs/kit': 2.5.27(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.48.0)(vite@5.4.21(@types/node@25.0.10)(sass@1.97.3)))(svelte@5.48.0)(vite@5.4.21(@types/node@25.0.10)(sass@1.97.3)) svelte: 5.48.0 + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.0.10)(sass@1.97.3))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@25.0.10)(sass@1.97.3) + + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.18': {} + + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 + '@whatwg-node/disposablestack@0.0.6': dependencies: '@whatwg-node/promise-helpers': 1.3.2 @@ -8873,6 +9484,8 @@ snapshots: minimalistic-assert: 1.0.1 safer-buffer: 2.1.2 + assertion-error@2.0.1: {} + ast-types@0.16.1: dependencies: tslib: 2.8.1 @@ -8969,6 +9582,8 @@ snapshots: tslib: 2.8.1 upper-case-first: 2.0.2 + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -9228,6 +9843,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -9341,6 +9958,35 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + escalade@3.2.0: {} escape-string-regexp@4.0.0: {} @@ -9494,6 +10140,8 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 3.0.0 + expect-type@1.3.0: {} + ext@1.7.0: dependencies: type: 2.7.3 @@ -10419,6 +11067,8 @@ snapshots: object-assign@4.1.1: {} + obug@2.1.1: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -10528,12 +11178,13 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} - picomatch@4.0.3: - optional: true + picomatch@4.0.3: {} pify@4.0.1: {} @@ -10788,6 +11439,8 @@ snapshots: shimmer@1.2.1: {} + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -10846,8 +11499,12 @@ snapshots: sprintf-js@1.0.3: {} + stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@3.10.0: {} + streamsearch@1.1.0: {} strict-event-emitter@0.5.1: {} @@ -11031,6 +11688,17 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + + tinyexec@1.0.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyrainbow@3.0.3: {} + title-case@3.0.3: dependencies: tslib: 2.8.1 @@ -11173,6 +11841,19 @@ snapshots: fsevents: 2.3.3 sass: 1.97.3 + vite@7.3.1(@types/node@25.0.10)(sass@1.97.3): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.56.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.0.10 + fsevents: 2.3.3 + sass: 1.97.3 + vitefu@0.2.5(vite@4.5.14(@types/node@25.0.10)(sass@1.97.3)): optionalDependencies: vite: 4.5.14(@types/node@25.0.10)(sass@1.97.3) @@ -11181,6 +11862,45 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@25.0.10)(sass@1.97.3) + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jsdom@23.2.0)(sass@1.97.3): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.0.10)(sass@1.97.3)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@25.0.10)(sass@1.97.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 25.0.10 + jsdom: 23.2.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 @@ -11223,6 +11943,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wide-align@1.1.5: dependencies: string-width: 4.2.3 diff --git a/src/lib/Data/AniList/media.ts b/src/lib/Data/AniList/media.ts index bf43ef1d..68f9fc9b 100644 --- a/src/lib/Data/AniList/media.ts +++ b/src/lib/Data/AniList/media.ts @@ -55,6 +55,7 @@ export interface Media { episode: number; airingAt?: number; nativeAiringAt?: number; + nativeEpisode?: number; }; synonyms: string[]; mediaListEntry?: { diff --git a/src/lib/List/Anime/DueAnimeList.svelte b/src/lib/List/Anime/DueAnimeList.svelte index 0c1e128a..da1f6c48 100644 --- a/src/lib/List/Anime/DueAnimeList.svelte +++ b/src/lib/List/Anime/DueAnimeList.svelte @@ -8,6 +8,7 @@ import AnimeList from './AnimeListTemplate.svelte'; import type { SubsPlease } from '$lib/Media/Anime/Airing/Subtitled/subsPlease'; import { injectAiringTime } from '$lib/Media/Anime/Airing/Subtitled/match'; + import { hasDueEpisodes, hasNoAiredEpisodes } from '$lib/Media/Anime/Airing/classify'; import { addNotification } from '$lib/Notification/store'; import locale from '$stores/locale'; import identity from '$stores/identity'; @@ -78,15 +79,12 @@ ($settings.displayNotStarted === true ? 0 : 1) && (media.mediaListEntry || { status: 'DROPPED' }).status !== 'DROPPED' ) - .filter( - (media: Media) => - // Outdated media - (media.nextAiringEpisode || { episode: 0 }).episode - 1 > - (media.mediaListEntry || { progress: 0 }).progress + .filter((media: Media) => + // Outdated media + hasDueEpisodes(media) ) .map((media: Media) => { - if ((media.nextAiringEpisode || { episode: 0 }).episode - 1 <= 0) - media.nextAiringEpisode = { episode: -1 }; + if (hasNoAiredEpisodes(media)) media.nextAiringEpisode = { episode: -1 }; return media; }); diff --git a/src/lib/List/Anime/UpcomingAnimeList.svelte b/src/lib/List/Anime/UpcomingAnimeList.svelte index 4f285b47..a2cc963d 100644 --- a/src/lib/List/Anime/UpcomingAnimeList.svelte +++ b/src/lib/List/Anime/UpcomingAnimeList.svelte @@ -12,6 +12,7 @@ import locale from '$stores/locale'; import identity from '$stores/identity'; import { injectAiringTime } from '$lib/Media/Anime/Airing/Subtitled/match'; + import { hasDueEpisodes, hasNoAiredEpisodes } from '$lib/Media/Anime/Airing/classify'; import revalidateAnime from '$stores/revalidateAnime'; export let user: AniListAuthorisation; @@ -43,14 +44,13 @@ (media: Media) => // Outdated media ($settings.displayPlannedAnime ? media.mediaListEntry?.status === 'PLANNING' : false) || - (media.nextAiringEpisode || { episode: 0 }).episode - 1 <= - (media.mediaListEntry || { progress: 0 }).progress + !hasDueEpisodes(media) ) .map((media: Media) => { // Adjust for planned anime if ( ($settings.displayPlannedAnime ? media.episodes !== 1 : true) && - (media.nextAiringEpisode || { episode: 0 }).episode - 1 <= 0 + hasNoAiredEpisodes(media) ) media.nextAiringEpisode = { episode: -1 }; diff --git a/src/lib/Media/Anime/Airing/Subtitled/match.ts b/src/lib/Media/Anime/Airing/Subtitled/match.ts index eb9c9b70..e83c30b6 100644 --- a/src/lib/Media/Anime/Airing/Subtitled/match.ts +++ b/src/lib/Media/Anime/Airing/Subtitled/match.ts @@ -85,6 +85,12 @@ const isMeaningfulToken = (token: string): boolean => const MIN_MATCH_SCORE = 0.3; const MIN_TOKEN_OVERLAP = 2; const MIN_MATCH_MARGIN = 0.08; +const FALLBACK_MIN_SCORE = 0.82; +const FALLBACK_MIN_MARGIN = 0.08; +const MAX_MATCH_CACHE_ENTRIES = 10_000; +const MAX_INJECT_CACHE_ENTRIES = 10_000; +const STALE_AIRING_GRACE_SECONDS = 5 * 60; +const MAX_EPISODE_SHIFT_WINDOW_SECONDS = 36 * 60 * 60; interface SimilarityAnalysis { score: number; @@ -107,6 +113,8 @@ const calculateWeightedSimilarity = (title1: string, title2: string): Similarity let score = 0; let tokenOverlap = 0; let numericTokenOverlap = 0; + const numericTokens1 = tokens1.filter((token) => /^\d+$/.test(token)); + const numericTokens2 = tokens2.filter((token) => /^\d+$/.test(token)); tokens1.forEach((token) => { if (set2.has(token)) { @@ -118,8 +126,15 @@ const calculateWeightedSimilarity = (title1: string, title2: string): Similarity } }); + let finalScore = + (score / ((Math.max(tokens1.length, tokens2.length) || 1) * 2)) * 0.7 + + stringSimilarity.compareTwoStrings(title1, title2) * 0.3; + + if (numericTokens1.length > 0 && numericTokens2.length > 0 && numericTokenOverlap === 0) + finalScore *= 0.5; + return { - score: score / ((Math.max(tokens1.length, tokens2.length) || 1) * 2), + score: finalScore, tokenOverlap, numericTokenOverlap }; @@ -134,6 +149,56 @@ const indexPush = (index: Map, key: string, entryIndex: number const scheduleIndexCache = new WeakMap(); const closestMatchCache = new Map(); +const injectAiringTimeCache = new Map(); + +const setBoundedCacheValue = ( + cache: Map, + key: string, + value: T, + maxEntries: number +) => { + if (cache.size >= maxEntries) cache.clear(); + + cache.set(key, value); +}; + +const animeTitleFingerprint = (anime: Media) => + [anime.title.romaji, anime.title.english, ...anime.synonyms] + .filter(Boolean) + .map(preprocessTitle) + .join('|'); + +const fallbackClosestMatch = (dayIndex: DayScheduleIndex, searchTitles: string[]): Time | null => { + let bestMatch: Time | null = null; + let bestScore = 0; + let secondBestScore = 0; + + for (const searchTitle of searchTitles) { + if (searchTitle.includes('OVA') || searchTitle.includes('Special')) continue; + + const normalizedSearchTitle = preprocessTitle(searchTitle); + + for (const candidateEntry of dayIndex.entries) { + const score = stringSimilarity.compareTwoStrings( + normalizedSearchTitle, + candidateEntry.normalizedTitle + ); + + if (score > bestScore) { + secondBestScore = bestScore; + bestScore = score; + bestMatch = candidateEntry.time; + } else if (score > secondBestScore) { + secondBestScore = score; + } + } + } + + if (bestScore < FALLBACK_MIN_SCORE) return null; + if (bestScore - secondBestScore < FALLBACK_MIN_MARGIN) return null; + + return bestMatch; +}; const buildScheduleIndex = (subsPlease: SubsPlease): ScheduleIndex => { const byDay = new Map(); @@ -187,12 +252,14 @@ const buildScheduleIndex = (subsPlease: SubsPlease): ScheduleIndex => { export const findClosestMatch = (scheduleIndex: ScheduleIndex, anime: Media): Time | null => { if (excludeMatch.includes(anime.id)) { - closestMatchCache.set(`${anime.id}:excluded`, null); - + setBoundedCacheValue(closestMatchCache, `${anime.id}:excluded`, null, MAX_MATCH_CACHE_ENTRIES); + return null; } - const cacheKey = `${anime.id}:${anime.nextAiringEpisode?.airingAt || 0}:${scheduleIndex.version}`; + const cacheKey = `${anime.id}:${anime.nextAiringEpisode?.airingAt || 0}:${animeTitleFingerprint( + anime + )}:${scheduleIndex.version}`; const cached = closestMatchCache.get(cacheKey); if (cached !== undefined) return cached; @@ -204,8 +271,8 @@ export const findClosestMatch = (scheduleIndex: ScheduleIndex, anime: Media): Ti const dayIndex = scheduleIndex.byDay.get(airingDay); if (!dayIndex || dayIndex.entries.length === 0) { - closestMatchCache.set(cacheKey, null); - + setBoundedCacheValue(closestMatchCache, cacheKey, null, MAX_MATCH_CACHE_ENTRIES); + return null; } @@ -226,7 +293,8 @@ export const findClosestMatch = (scheduleIndex: ScheduleIndex, anime: Media): Ti const exactMatch = dayIndex.entries[exactMatchIndexes[0]]; if (exactMatch) { - closestMatchCache.set(cacheKey, exactMatch.time); + setBoundedCacheValue(closestMatchCache, cacheKey, exactMatch.time, MAX_MATCH_CACHE_ENTRIES); + return exactMatch.time; } } @@ -264,25 +332,31 @@ export const findClosestMatch = (scheduleIndex: ScheduleIndex, anime: Media): Ti } if (bestScore < MIN_MATCH_SCORE) { - closestMatchCache.set(cacheKey, null); - - return null; + const fallbackMatch = fallbackClosestMatch(dayIndex, searchTitles); + + setBoundedCacheValue(closestMatchCache, cacheKey, fallbackMatch, MAX_MATCH_CACHE_ENTRIES); + + return fallbackMatch; } if (bestScore - secondBestScore < MIN_MATCH_MARGIN) { - closestMatchCache.set(cacheKey, null); - - return null; + const fallbackMatch = fallbackClosestMatch(dayIndex, searchTitles); + + setBoundedCacheValue(closestMatchCache, cacheKey, fallbackMatch, MAX_MATCH_CACHE_ENTRIES); + + return fallbackMatch; } if (bestNumericTokenOverlap === 0 && bestTokenOverlap < MIN_TOKEN_OVERLAP) { - closestMatchCache.set(cacheKey, null); - - return null; + const fallbackMatch = fallbackClosestMatch(dayIndex, searchTitles); + + setBoundedCacheValue(closestMatchCache, cacheKey, fallbackMatch, MAX_MATCH_CACHE_ENTRIES); + + return fallbackMatch; } - closestMatchCache.set(cacheKey, bestMatch); - + setBoundedCacheValue(closestMatchCache, cacheKey, bestMatch, MAX_MATCH_CACHE_ENTRIES); + return bestMatch; }; @@ -352,15 +426,37 @@ const getScheduleIndex = (subsPlease: SubsPlease): ScheduleIndex => { if (cached) return cached; const built = buildScheduleIndex(subsPlease); - + scheduleIndexCache.set(subsPlease, built); return built; }; +const buildInjectAiringTimeCacheKey = ( + anime: Media, + scheduleVersion: string, + displayNativeCountdown: boolean +) => + [ + anime.id, + anime.status, + anime.nextAiringEpisode?.episode || 0, + anime.nextAiringEpisode?.airingAt || 0, + displayNativeCountdown ? 1 : 0, + scheduleVersion, + animeTitleFingerprint(anime) + ].join(':'); + export const injectAiringTime = (anime: Media, subsPlease: SubsPlease | null) => { if (season() !== anime.season) return anime; + const displayNativeCountdown = get(settings).displayNativeCountdown; + const scheduleVersion = subsPlease ? getScheduleIndex(subsPlease).version : 'native-only'; + const cacheKey = buildInjectAiringTimeCacheKey(anime, scheduleVersion, displayNativeCountdown); + const cached = injectAiringTimeCache.get(cacheKey); + + if (cached) return cached; + const airingAt = anime.nextAiringEpisode?.airingAt; const now = new Date(); // const nativeUntilAiring = airingAt @@ -371,12 +467,7 @@ export const injectAiringTime = (anime: Media, subsPlease: SubsPlease | null) => let time = new Date(airingAt ? airingAt * 1000 : 0); let nextEpisode = anime.nextAiringEpisode?.episode || 0; - if ( - !( - (get(settings).displayNativeCountdown || !subsPlease) - // || !(nativeUntilAiring !== undefined && nativeUntilAiring < 24 * 60 * 60) - ) - ) { + if (!(displayNativeCountdown || !subsPlease)) { const scheduleIndex = getScheduleIndex(subsPlease); if ((anime.nextAiringEpisode?.episode || 0) > 1) { @@ -391,7 +482,14 @@ export const injectAiringTime = (anime: Media, subsPlease: SubsPlease | null) => const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000; - if (nativeTime > time) { + const nowEpochSeconds = Date.now() / 1000; + const nativeAheadSeconds = nativeTime.getTime() / 1000 - time.getTime() / 1000; + + if ( + nativeAheadSeconds > 0 && + nativeAheadSeconds <= MAX_EPISODE_SHIFT_WINDOW_SECONDS && + nativeTime.getTime() / 1000 > nowEpochSeconds + STALE_AIRING_GRACE_SECONDS + ) { nextEpisode -= 1; } @@ -404,12 +502,17 @@ export const injectAiringTime = (anime: Media, subsPlease: SubsPlease | null) => time.setMinutes(beforeTime.getMinutes()); } - return { + const injected = { ...anime, nextAiringEpisode: { episode: nextEpisode, airingAt: time.getTime() / 1000, - nativeAiringAt: nativeTime.getTime() / 1000 + nativeAiringAt: nativeTime.getTime() / 1000, + nativeEpisode: anime.nextAiringEpisode?.episode || 0 } } as Media; + + setBoundedCacheValue(injectAiringTimeCache, cacheKey, injected, MAX_INJECT_CACHE_ENTRIES); + + return injected; }; diff --git a/src/lib/Media/Anime/Airing/classify.test.ts b/src/lib/Media/Anime/Airing/classify.test.ts new file mode 100644 index 00000000..cad08b43 --- /dev/null +++ b/src/lib/Media/Anime/Airing/classify.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest'; +import settings from '$stores/settings'; +import type { Media } from '$lib/Data/AniList/media'; +import { season } from '$lib/Media/Anime/season'; +import { hasDueEpisodes, getAnimeEpisodeState } from '$lib/Media/Anime/Airing/classify'; +import { injectAiringTime } from '$lib/Media/Anime/Airing/Subtitled/match'; +import type { SubsPlease } from '$lib/Media/Anime/Airing/Subtitled/subsPlease'; + +const toScheduleTime = (epochSeconds: number) => { + const date = new Date(epochSeconds * 1000); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + + return `${hours}:${minutes}`; +}; + +const baseMedia = (id: number): Media => + ({ + id, + idMal: id, + status: 'RELEASING', + type: 'ANIME', + episodes: 12, + chapters: 0, + volumes: 0, + duration: 24, + format: 'TV', + title: { + romaji: `Fixture Show ${id}`, + english: `Fixture Show ${id}`, + native: `Fixture Show ${id}` + }, + nextAiringEpisode: { + episode: 8, + airingAt: Math.floor(Date.now() / 1000) + 24 * 60 * 60 + }, + synonyms: [], + mediaListEntry: { + progress: 6, + progressVolumes: 0, + status: 'CURRENT', + score: 0, + repeat: 0, + startedAt: { + year: 2025, + month: 1, + day: 1 + }, + completedAt: { + year: 0, + month: 0, + day: 0 + }, + createdAt: 0, + updatedAt: 0, + customLists: {} + }, + startDate: { + year: 2025, + month: 1 + }, + endDate: { + year: 2025, + month: 12 + }, + coverImage: { + extraLarge: 'https://example.com/cover-xl.jpg', + medium: 'https://example.com/cover-md.jpg' + }, + tags: [], + genres: [], + season: season(), + isAdult: false, + relations: { + edges: [] + } + }) as Media; + +const regressionIds = [192507, 189259, 198767]; + +describe('anime episode classification', () => { + it('prefers nativeEpisode for due/upcoming classification', () => { + const media = baseMedia(192507); + + media.nextAiringEpisode = { + episode: 7, + nativeEpisode: 8, + airingAt: Math.floor(Date.now() / 1000) + 6 * 60 * 60, + nativeAiringAt: Math.floor(Date.now() / 1000) + 18 * 60 * 60 + }; + + const state = getAnimeEpisodeState(media); + + expect(state.airedEpisodes).toBe(7); + expect(hasDueEpisodes(media)).toBe(true); + }); + + it('treats stale native release data as aired for due detection', () => { + const media = baseMedia(189259); + + media.nextAiringEpisode = { + episode: 9, + airingAt: Math.floor(Date.now() / 1000) + 72 * 60 * 60, + nativeAiringAt: Math.floor(Date.now() / 1000) - 60 * 60 + }; + + const state = getAnimeEpisodeState(media); + + expect(state.airedEpisodes).toBe(9); + expect(hasDueEpisodes(media)).toBe(true); + }); +}); + +describe('native countdown toggle parity', () => { + for (const id of regressionIds) { + it(`keeps media ${id} due with native countdown on/off`, () => { + const media = baseMedia(id); + const subtitledAiringAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60; + const nativeAiringAt = subtitledAiringAt + 12 * 60 * 60; + const nativeAiringDate = new Date(nativeAiringAt * 1000); + const airingDay = nativeAiringDate.toLocaleString('en-US', { weekday: 'long' }); + const subsPlease = { + tz: 'America/Los_Angeles', + schedule: { + [airingDay]: [ + { + title: media.title.romaji, + page: '', + image_url: '', + time: toScheduleTime(subtitledAiringAt) + } + ] + } + } as unknown as SubsPlease; + + media.nextAiringEpisode = { + episode: 8, + airingAt: nativeAiringAt + }; + + settings.setKey('displayNativeCountdown', true); + + const nativeOnly = injectAiringTime(media, subsPlease); + + settings.setKey('displayNativeCountdown', false); + + const subtitled = injectAiringTime(media, subsPlease); + + expect(hasDueEpisodes(nativeOnly)).toBe(true); + expect(hasDueEpisodes(subtitled)).toBe(true); + }); + } +}); diff --git a/src/lib/Media/Anime/Airing/classify.ts b/src/lib/Media/Anime/Airing/classify.ts new file mode 100644 index 00000000..9b7487d2 --- /dev/null +++ b/src/lib/Media/Anime/Airing/classify.ts @@ -0,0 +1,53 @@ +import type { Media } from '$lib/Data/AniList/media'; + +export interface AnimeEpisodeState { + progress: number; + nextEpisode: number; + airedEpisodes: number; +} + +const hasAired = (airingAt: number | undefined, nowEpochSeconds: number) => + typeof airingAt === 'number' && airingAt <= nowEpochSeconds; + +export const getAnimeEpisodeState = ( + media: Media, + nowEpochSeconds = Date.now() / 1000 +): AnimeEpisodeState => { + const progress = media.mediaListEntry?.progress || 0; + const nextEpisode = + media.nextAiringEpisode?.nativeEpisode || media.nextAiringEpisode?.episode || 0; + + if (nextEpisode <= 0) { + return { + progress, + nextEpisode, + airedEpisodes: 0 + }; + } + + let airedEpisodes = Math.max(0, nextEpisode - 1); + const airingAt = media.nextAiringEpisode?.airingAt; + const nativeAiringAt = media.nextAiringEpisode?.nativeAiringAt; + + // If either source says the "next" episode already aired, treat it as released. + if (hasAired(airingAt, nowEpochSeconds) || hasAired(nativeAiringAt, nowEpochSeconds)) + airedEpisodes = Math.max(airedEpisodes, nextEpisode); + + return { + progress, + nextEpisode, + airedEpisodes + }; +}; + +export const hasDueEpisodes = (media: Media, nowEpochSeconds = Date.now() / 1000) => { + const episodeState = getAnimeEpisodeState(media, nowEpochSeconds); + + return episodeState.airedEpisodes > episodeState.progress; +}; + +export const hasNoAiredEpisodes = (media: Media, nowEpochSeconds = Date.now() / 1000) => { + const episodeState = getAnimeEpisodeState(media, nowEpochSeconds); + + return episodeState.airedEpisodes <= 0; +}; -- cgit v1.2.3