aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-03-01 14:39:20 -0800
committerFuwn <[email protected]>2026-03-01 15:24:05 -0800
commit60110fe8f23c53c837aff82d77a21ad8af4b5bb2 (patch)
tree4fa6f42cc1f352c3a55165cc2a7735d0b897c841
parentperf: optimise list hot paths and shared timers (diff)
downloaddue.moe-60110fe8f23c53c837aff82d77a21ad8af4b5bb2.tar.xz
due.moe-60110fe8f23c53c837aff82d77a21ad8af4b5bb2.zip
fix(anime): unify due classification and harden subtitle matching
-rw-r--r--package.json4
-rw-r--r--pnpm-lock.yaml729
-rw-r--r--src/lib/Data/AniList/media.ts1
-rw-r--r--src/lib/List/Anime/DueAnimeList.svelte12
-rw-r--r--src/lib/List/Anime/UpcomingAnimeList.svelte6
-rw-r--r--src/lib/Media/Anime/Airing/Subtitled/match.ts159
-rw-r--r--src/lib/Media/Anime/Airing/classify.test.ts153
-rw-r--r--src/lib/Media/Anime/Airing/classify.ts53
8 files changed, 1076 insertions, 41 deletions
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/[email protected])([email protected])
+ vitest:
+ specifier: ^4.0.18
packages:
'@ardatan/[email protected]':
@@ -459,6 +462,15 @@ packages:
cpu: [ppc64]
os: [aix]
+ '@esbuild/[email protected]':
+ resolution:
+ {
+ integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==
+ }
+ engines: { node: '>=18' }
+ cpu: [ppc64]
+ os: [aix]
+
'@esbuild/[email protected]':
resolution:
{
@@ -486,6 +498,15 @@ packages:
cpu: [arm64]
os: [android]
+ '@esbuild/[email protected]':
+ resolution:
+ {
+ integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==
+ }
+ engines: { node: '>=18' }
+ cpu: [arm64]
+ os: [android]
+
'@esbuild/[email protected]':
resolution:
{
@@ -513,6 +534,15 @@ packages:
cpu: [arm]
os: [android]
+ '@esbuild/[email protected]':
+ resolution:
+ {
+ integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==
+ }
+ engines: { node: '>=18' }
+ cpu: [arm]
+ os: [android]
+
'@esbuild/[email protected]':
resolution:
{
@@ -540,6 +570,15 @@ packages:
cpu: [x64]
os: [android]
+ '@esbuild/[email protected]':
+ resolution:
+ {
+ integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==
+ }
+ engines: { node: '>=18' }
+ cpu: [x64]
+ os: [android]
+
'@esbuild/[email protected]':
resolution:
{
@@ -567,6 +606,15 @@ packages:
cpu: [arm64]
os: [darwin]
+ '@esbuild/[email protected]':
+ resolution:
+ {
+ integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==
+ }
+ engines: { node: '>=18' }
+ cpu: [arm64]
+ os: [darwin]
+
'@esbuild/[email protected]':
resolution:
{
@@ -594,6 +642,15 @@ packages:
cpu: [x64]
os: [darwin]
+ '@esbuild/[email protected]':
+ resolution:
+ {
+ integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==
+ }
+ engines: { node: '>=18' }
+ cpu: [x64]
+ os: [darwin]
+
'@esbuild/[email protected]':
resolution:
{
@@ -621,6 +678,15 @@ packages:
cpu: [arm64]
os: [freebsd]
+ '@esbuild/[email protected]':
+ resolution:
+ {
+ integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==
+ }
+ engines: { node: '>=18' }
+ cpu: [arm64]
+ os: [freebsd]
+
'@esbuild/[email protected]':
resolution:
{
@@ -648,6 +714,15 @@ packages:
cpu: [x64]
os: [freebsd]
+ '@esbuild/[email protected]':
+ resolution:
+ {
+ integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==
+ }
+ engines: { node: '>=18' }
+ cpu: [x64]
+ os: [freebsd]
+
'@esbuild/[email protected]':
resolution:
{
@@ -675,6 +750,15 @@ packages:
cpu: [arm64]
os: [linux]
+ '@esbuild/[email protected]':
+ resolution:
+ {
+ integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==
+ }
+ engines: { node: '>=18' }
+ cpu: [arm64]
+ os: [linux]
+
'@esbuild/[email protected]':
resolution:
{
@@ -702,6 +786,15 @@ packages:
cpu: [arm]
os: [linux]
+ '@esbuild/[email protected]':
+ resolution:
+ {
+ integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==
+ }
+ engines: { node: '>=18' }
+ cpu: [arm]
+ os: [linux]
+
'@esbuild/[email protected]':
resolution:
{
@@ -729,6 +822,15 @@ packages:
cpu: [ia32]
os: [linux]
+ '@esbuild/[email protected]':
+ resolution:
+ {
+ integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==
+ }
+ engines: { node: '>=18' }
+ cpu: [ia32]
+ os: [linux]
+
'@esbuild/[email protected]':
resolution:
{
@@ -756,6 +858,15 @@ packages:
cpu: [loong64]
os: [linux]
+ '@esbuild/[email protected]':
+ resolution:
+ {
+ integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==
+ }
+ engines: { node: '>=18' }
+ cpu: [loong64]
+ os: [linux]
+
'@esbuild/[email protected]':
resolution:
{
@@ -783,6 +894,15 @@ packages:
cpu: [mips64el]
os: [linux]
+ '@esbuild/[email protected]':
+ resolution:
+ {
+ integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==
+ }
+ engines: { node: '>=18' }
+ cpu: [mips64el]
+ os: [linux]
+
'@esbuild/[email protected]':
resolution:
{
@@ -810,6 +930,15 @@ packages:
cpu: [ppc64]
os: [linux]
+ '@esbuild/[email protected]':
+ resolution:
+ {
+ integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==
+ }
+ engines: { node: '>=18' }
+ cpu: [ppc64]
+ os: [linux]
+
'@esbuild/[email protected]':
resolution:
{
@@ -837,6 +966,15 @@ packages:
cpu: [riscv64]
os: [linux]
+ '@esbuild/[email protected]':
+ resolution:
+ {
+ integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==
+ }
+ engines: { node: '>=18' }
+ cpu: [riscv64]
+ os: [linux]
+
'@esbuild/[email protected]':
resolution:
{
@@ -864,6 +1002,15 @@ packages:
cpu: [s390x]
os: [linux]
+ '@esbuild/[email protected]':
+ resolution:
+ {
+ integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==
+ }
+ engines: { node: '>=18' }
+ cpu: [s390x]
+ os: [linux]
+
'@esbuild/[email protected]':
resolution:
{
@@ -891,6 +1038,24 @@ packages:
cpu: [x64]
os: [linux]
+ '@esbuild/[email protected]':
+ resolution:
+ {
+ integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==
+ }
+ engines: { node: '>=18' }
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/[email protected]':
+ resolution:
+ {
+ integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==
+ }
+ engines: { node: '>=18' }
+ cpu: [arm64]
+ os: [netbsd]
+
'@esbuild/[email protected]':
resolution:
{
@@ -918,6 +1083,24 @@ packages:
cpu: [x64]
os: [netbsd]
+ '@esbuild/[email protected]':
+ resolution:
+ {
+ integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==
+ }
+ engines: { node: '>=18' }
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/[email protected]':
+ resolution:
+ {
+ integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==
+ }
+ engines: { node: '>=18' }
+ cpu: [arm64]
+ os: [openbsd]
+
'@esbuild/[email protected]':
resolution:
{
@@ -945,6 +1128,24 @@ packages:
cpu: [x64]
os: [openbsd]
+ '@esbuild/[email protected]':
+ resolution:
+ {
+ integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==
+ }
+ engines: { node: '>=18' }
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/[email protected]':
+ resolution:
+ {
+ integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==
+ }
+ engines: { node: '>=18' }
+ cpu: [arm64]
+ os: [openharmony]
+
'@esbuild/[email protected]':
resolution:
{
@@ -972,6 +1173,15 @@ packages:
cpu: [x64]
os: [sunos]
+ '@esbuild/[email protected]':
+ resolution:
+ {
+ integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==
+ }
+ engines: { node: '>=18' }
+ cpu: [x64]
+ os: [sunos]
+
'@esbuild/[email protected]':
resolution:
{
@@ -999,6 +1209,15 @@ packages:
cpu: [arm64]
os: [win32]
+ '@esbuild/[email protected]':
+ resolution:
+ {
+ integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==
+ }
+ engines: { node: '>=18' }
+ cpu: [arm64]
+ os: [win32]
+
'@esbuild/[email protected]':
resolution:
{
@@ -1026,6 +1245,15 @@ packages:
cpu: [ia32]
os: [win32]
+ '@esbuild/[email protected]':
+ resolution:
+ {
+ integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==
+ }
+ engines: { node: '>=18' }
+ cpu: [ia32]
+ os: [win32]
+
'@esbuild/[email protected]':
resolution:
{
@@ -1053,6 +1281,15 @@ packages:
cpu: [x64]
os: [win32]
+ '@esbuild/[email protected]':
+ resolution:
+ {
+ integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==
+ }
+ engines: { node: '>=18' }
+ cpu: [x64]
+ os: [win32]
+
'@eslint-community/[email protected]':
resolution:
{
@@ -2338,6 +2575,12 @@ packages:
integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==
}
+ '@standard-schema/[email protected]':
+ resolution:
+ {
+ integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==
+ }
+
'@supabase/[email protected]':
resolution:
{
@@ -2507,6 +2750,12 @@ packages:
integrity: sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==
}
+ '@types/[email protected]':
+ resolution:
+ {
+ integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==
+ }
+
resolution:
{
@@ -2519,6 +2768,12 @@ packages:
integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==
}
+ '@types/[email protected]':
+ resolution:
+ {
+ integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==
+ }
+
resolution:
{
@@ -2784,6 +3039,56 @@ packages:
vue-router:
optional: true
+ '@vitest/[email protected]':
+ resolution:
+ {
+ integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==
+ }
+
+ '@vitest/[email protected]':
+ 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/[email protected]':
+ resolution:
+ {
+ integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==
+ }
+
+ '@vitest/[email protected]':
+ resolution:
+ {
+ integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==
+ }
+
+ '@vitest/[email protected]':
+ resolution:
+ {
+ integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==
+ }
+
+ '@vitest/[email protected]':
+ resolution:
+ {
+ integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==
+ }
+
+ '@vitest/[email protected]':
+ resolution:
+ {
+ integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==
+ }
+
'@whatwg-node/[email protected]':
resolution:
{
@@ -3007,6 +3312,13 @@ packages:
integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==
}
+ resolution:
+ {
+ integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==
+ }
+ engines: { node: '>=12' }
+
resolution:
{
@@ -3186,6 +3498,13 @@ packages:
integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==
}
+ resolution:
+ {
+ integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==
+ }
+ engines: { node: '>=18' }
+
resolution:
{
@@ -3688,6 +4007,12 @@ packages:
}
engines: { node: '>= 0.4' }
+ resolution:
+ {
+ integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==
+ }
+
resolution:
{
@@ -3752,6 +4077,14 @@ packages:
engines: { node: '>=12' }
hasBin: true
+ resolution:
+ {
+ integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==
+ }
+ engines: { node: '>=18' }
+ hasBin: true
+
resolution:
{
@@ -3933,6 +4266,13 @@ packages:
}
engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 }
+ resolution:
+ {
+ integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==
+ }
+ engines: { node: '>=12.0.0' }
+
resolution:
{
@@ -5356,6 +5696,12 @@ packages:
}
engines: { node: '>=0.10.0' }
+ resolution:
+ {
+ integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==
+ }
+
resolution:
{
@@ -5567,6 +5913,12 @@ packages:
}
engines: { node: '>=8' }
+ resolution:
+ {
+ integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
+ }
+
resolution:
{
@@ -5992,6 +6344,12 @@ packages:
integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==
}
+ resolution:
+ {
+ integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==
+ }
+
resolution:
{
@@ -6095,6 +6453,12 @@ packages:
integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==
}
+ resolution:
+ {
+ integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==
+ }
+
resolution:
{
@@ -6102,6 +6466,12 @@ packages:
}
engines: { node: '>= 0.8' }
+ resolution:
+ {
+ integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==
+ }
+
resolution:
{
@@ -6337,6 +6707,33 @@ packages:
integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==
}
+ resolution:
+ {
+ integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==
+ }
+
+ resolution:
+ {
+ integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==
+ }
+ engines: { node: '>=18' }
+
+ resolution:
+ {
+ integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==
+ }
+ engines: { node: '>=12.0.0' }
+
+ resolution:
+ {
+ integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==
+ }
+ engines: { node: '>=14.0.0' }
+
resolution:
{
@@ -6679,6 +7076,49 @@ packages:
terser:
optional: true
+ 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
+
resolution:
{
@@ -6701,6 +7141,43 @@ packages:
vite:
optional: true
+ 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
+
resolution:
{
@@ -6779,6 +7256,14 @@ packages:
engines: { node: '>= 8' }
hasBin: true
+ resolution:
+ {
+ integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==
+ }
+ engines: { node: '>=8' }
+ hasBin: true
+
resolution:
{
@@ -7234,6 +7719,9 @@ snapshots:
'@esbuild/[email protected]':
optional: true
+ '@esbuild/[email protected]':
+ optional: true
+
'@esbuild/[email protected]':
optional: true
@@ -7243,6 +7731,9 @@ snapshots:
'@esbuild/[email protected]':
optional: true
+ '@esbuild/[email protected]':
+ optional: true
+
'@esbuild/[email protected]':
optional: true
@@ -7252,6 +7743,9 @@ snapshots:
'@esbuild/[email protected]':
optional: true
+ '@esbuild/[email protected]':
+ optional: true
+
'@esbuild/[email protected]':
optional: true
@@ -7261,6 +7755,9 @@ snapshots:
'@esbuild/[email protected]':
optional: true
+ '@esbuild/[email protected]':
+ optional: true
+
'@esbuild/[email protected]':
optional: true
@@ -7270,6 +7767,9 @@ snapshots:
'@esbuild/[email protected]':
optional: true
+ '@esbuild/[email protected]':
+ optional: true
+
'@esbuild/[email protected]':
optional: true
@@ -7279,6 +7779,9 @@ snapshots:
'@esbuild/[email protected]':
optional: true
+ '@esbuild/[email protected]':
+ optional: true
+
'@esbuild/[email protected]':
optional: true
@@ -7288,6 +7791,9 @@ snapshots:
'@esbuild/[email protected]':
optional: true
+ '@esbuild/[email protected]':
+ optional: true
+
'@esbuild/[email protected]':
optional: true
@@ -7297,6 +7803,9 @@ snapshots:
'@esbuild/[email protected]':
optional: true
+ '@esbuild/[email protected]':
+ optional: true
+
'@esbuild/[email protected]':
optional: true
@@ -7306,6 +7815,9 @@ snapshots:
'@esbuild/[email protected]':
optional: true
+ '@esbuild/[email protected]':
+ optional: true
+
'@esbuild/[email protected]':
optional: true
@@ -7315,6 +7827,9 @@ snapshots:
'@esbuild/[email protected]':
optional: true
+ '@esbuild/[email protected]':
+ optional: true
+
'@esbuild/[email protected]':
optional: true
@@ -7324,6 +7839,9 @@ snapshots:
'@esbuild/[email protected]':
optional: true
+ '@esbuild/[email protected]':
+ optional: true
+
'@esbuild/[email protected]':
optional: true
@@ -7333,6 +7851,9 @@ snapshots:
'@esbuild/[email protected]':
optional: true
+ '@esbuild/[email protected]':
+ optional: true
+
'@esbuild/[email protected]':
optional: true
@@ -7342,6 +7863,9 @@ snapshots:
'@esbuild/[email protected]':
optional: true
+ '@esbuild/[email protected]':
+ optional: true
+
'@esbuild/[email protected]':
optional: true
@@ -7351,6 +7875,9 @@ snapshots:
'@esbuild/[email protected]':
optional: true
+ '@esbuild/[email protected]':
+ optional: true
+
'@esbuild/[email protected]':
optional: true
@@ -7360,6 +7887,9 @@ snapshots:
'@esbuild/[email protected]':
optional: true
+ '@esbuild/[email protected]':
+ optional: true
+
'@esbuild/[email protected]':
optional: true
@@ -7369,6 +7899,9 @@ snapshots:
'@esbuild/[email protected]':
optional: true
+ '@esbuild/[email protected]':
+ optional: true
+
'@esbuild/[email protected]':
optional: true
@@ -7378,6 +7911,12 @@ snapshots:
'@esbuild/[email protected]':
optional: true
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
'@esbuild/[email protected]':
optional: true
@@ -7387,6 +7926,12 @@ snapshots:
'@esbuild/[email protected]':
optional: true
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
'@esbuild/[email protected]':
optional: true
@@ -7396,6 +7941,12 @@ snapshots:
'@esbuild/[email protected]':
optional: true
+ '@esbuild/[email protected]':
+ optional: true
+
+ '@esbuild/[email protected]':
+ optional: true
+
'@esbuild/[email protected]':
optional: true
@@ -7405,6 +7956,9 @@ snapshots:
'@esbuild/[email protected]':
optional: true
+ '@esbuild/[email protected]':
+ optional: true
+
'@esbuild/[email protected]':
optional: true
@@ -7414,6 +7968,9 @@ snapshots:
'@esbuild/[email protected]':
optional: true
+ '@esbuild/[email protected]':
+ optional: true
+
'@esbuild/[email protected]':
optional: true
@@ -7423,6 +7980,9 @@ snapshots:
'@esbuild/[email protected]':
optional: true
+ '@esbuild/[email protected]':
+ optional: true
+
'@esbuild/[email protected]':
optional: true
@@ -7432,6 +7992,9 @@ snapshots:
'@esbuild/[email protected]':
optional: true
+ '@esbuild/[email protected]':
+ optional: true
+
'@eslint-community/[email protected]([email protected])':
dependencies:
eslint: 8.57.1
@@ -8360,6 +8923,8 @@ snapshots:
'@socket.io/[email protected]': {}
+ '@standard-schema/[email protected]': {}
+
'@supabase/[email protected]':
dependencies:
tslib: 2.8.1
@@ -8570,10 +9135,17 @@ snapshots:
'@types/[email protected]': {}
+ '@types/[email protected]':
+ dependencies:
+ '@types/deep-eql': 4.0.2
+ assertion-error: 2.0.1
+
'@types/[email protected]': {}
'@types/[email protected]': {}
+ '@types/[email protected]': {}
+
'@types/[email protected]': {}
'@types/[email protected]': {}
@@ -8744,6 +9316,45 @@ snapshots:
svelte: 5.48.0
+ '@vitest/[email protected]':
+ 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
+
+ dependencies:
+ '@vitest/spy': 4.0.18
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ vite: 7.3.1(@types/[email protected])([email protected])
+
+ '@vitest/[email protected]':
+ dependencies:
+ tinyrainbow: 3.0.3
+
+ '@vitest/[email protected]':
+ dependencies:
+ '@vitest/utils': 4.0.18
+ pathe: 2.0.3
+
+ '@vitest/[email protected]':
+ dependencies:
+ '@vitest/pretty-format': 4.0.18
+ magic-string: 0.30.21
+ pathe: 2.0.3
+
+ '@vitest/[email protected]': {}
+
+ '@vitest/[email protected]':
+ dependencies:
+ '@vitest/pretty-format': 4.0.18
+ tinyrainbow: 3.0.3
+
'@whatwg-node/[email protected]':
dependencies:
'@whatwg-node/promise-helpers': 1.3.2
@@ -8873,6 +9484,8 @@ snapshots:
minimalistic-assert: 1.0.1
safer-buffer: 2.1.2
+
dependencies:
tslib: 2.8.1
@@ -8969,6 +9582,8 @@ snapshots:
tslib: 2.8.1
upper-case-first: 2.0.2
+
dependencies:
ansi-styles: 4.3.0
@@ -9228,6 +9843,8 @@ snapshots:
+
dependencies:
es-errors: 1.3.0
@@ -9341,6 +9958,35 @@ snapshots:
'@esbuild/win32-ia32': 0.21.5
'@esbuild/win32-x64': 0.21.5
+ 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
+
@@ -9494,6 +10140,8 @@ snapshots:
signal-exit: 3.0.7
strip-final-newline: 3.0.0
+
dependencies:
type: 2.7.3
@@ -10419,6 +11067,8 @@ snapshots:
+
dependencies:
wrappy: 1.0.2
@@ -10528,12 +11178,13 @@ snapshots:
+
- optional: true
@@ -10788,6 +11439,8 @@ snapshots:
+
@@ -10846,8 +11499,12 @@ snapshots:
+
+
@@ -11031,6 +11688,17 @@ snapshots:
+
+
+ dependencies:
+ fdir: 6.5.0([email protected])
+ picomatch: 4.0.3
+
+
dependencies:
tslib: 2.8.1
@@ -11173,6 +11841,19 @@ snapshots:
fsevents: 2.3.3
sass: 1.97.3
+ dependencies:
+ esbuild: 0.27.3
+ fdir: 6.5.0([email protected])
+ 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
+
optionalDependencies:
vite: 4.5.14(@types/[email protected])([email protected])
@@ -11181,6 +11862,45 @@ snapshots:
optionalDependencies:
vite: 5.4.21(@types/[email protected])([email protected])
+ dependencies:
+ '@vitest/expect': 4.0.18
+ '@vitest/mocker': 4.0.18([email protected](@types/[email protected])([email protected]))
+ '@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/[email protected])([email protected])
+ 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
+
dependencies:
xml-name-validator: 5.0.0
@@ -11223,6 +11943,11 @@ snapshots:
dependencies:
isexe: 2.0.0
+ dependencies:
+ siginfo: 2.0.0
+ stackback: 0.0.2
+
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<string, number[]>, key: string, entryIndex: number
const scheduleIndexCache = new WeakMap<SubsPlease, ScheduleIndex>();
const closestMatchCache = new Map<string, Time | null>();
+const injectAiringTimeCache = new Map<string, Media>();
+
+const setBoundedCacheValue = <T>(
+ cache: Map<string, T>,
+ 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<string, DayScheduleIndex>();
@@ -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;
+};