From 0ff7f21958ad33432123f76a2fa7830655329cbb Mon Sep 17 00:00:00 2001 From: ElPouki Date: Fri, 11 Sep 2026 22:42:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(import):=20catalogue=20unifi=C3=A9,=20reno?= =?UTF-8?q?mmage=20de=20dossier=20s=C3=A9rie=20et=20nettoyage=20des=20orph?= =?UTF-8?q?elins=20corection=20d=C3=A9doublonnage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 7 +- pnpm-lock.yaml | 228 ++++++++++++++++++++ server/index.ts | 9 +- server/lib/organized-store.ts | 16 ++ server/lib/rss-sync.ts | 37 +++- server/lib/serie-helpers.ts | 59 ++++- server/logger.ts | 20 +- server/organize.ts | 42 ++-- server/routes/import.ts | 194 +++++++++++++++-- server/routes/organize.ts | 6 +- server/routes/rss-sync.ts | 9 +- server/routes/system.ts | 17 +- src/components/EpisodeRow.vue | 2 +- src/components/ManualImportModal.vue | 41 +--- src/components/nav/ThemeSwitcher.vue | 4 +- src/components/serie/SerieSeasonCard.vue | 20 +- src/composables/useToast.ts | 5 +- src/layouts/AppLayout.vue | 4 +- src/stores/series.ts | 18 +- src/utils/episode-match.ts | 46 ++++ src/views/RequestsView.vue | 4 +- src/views/SerieView.vue | 52 ++++- src/views/settings/CatalogueView.vue | 44 ++++ src/views/settings/DownloadClientView.vue | 2 +- src/views/settings/ImportManagementView.vue | 55 +++++ src/views/settings/JellyfinView.vue | 10 - src/views/settings/UsersView.vue | 4 +- tsconfig.app.json | 3 + 28 files changed, 806 insertions(+), 152 deletions(-) create mode 100644 src/utils/episode-match.ts diff --git a/package.json b/package.json index 453595b..9e15e87 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "dev": "concurrently \"vite\" \"tsx watch server/index.ts\"", "dev:server": "tsx watch server/index.ts", "dev:client": "vite", - "build": "vite build && tsc -p tsconfig.server.json", + "build": "vue-tsc -p tsconfig.app.json --noEmit && vite build && tsc -p tsconfig.server.json", + "typecheck": "vue-tsc -p tsconfig.app.json --noEmit && tsc -p tsconfig.server.json --noEmit", + "test": "vitest run", "build:bun:linux": "bun build server/index.ts --compile --target bun-linux-x64 --outfile binaries/fankarr-linux-x64 && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js", "build:bun:windows": "bun build server/index.ts --compile --target bun-windows-x64 --outfile binaries/fankarr-windows-x64.exe && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js", "build:bun:macos": "bun build server/index.ts --compile --target bun-darwin-arm64 --outfile binaries/fankarr-macos && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js", @@ -44,6 +46,7 @@ "tsx": "^4.21.0", "typescript": "~5.9.3", "vite": "^7.3.1", + "vitest": "^5.0.0", "vue-tsc": "^3.1.5" } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a60c9d6..9edb441 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: vite: specifier: ^7.3.1 version: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: + specifier: ^5.0.0 + version: 5.0.0(@types/node@24.12.0)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) vue-tsc: specifier: ^3.1.5 version: 3.2.5(typescript@5.9.3) @@ -283,6 +286,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -323,66 +329,79 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -452,24 +471,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.1': resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.1': resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.1': resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.1': resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} @@ -510,6 +533,9 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -521,6 +547,9 @@ packages: '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -564,6 +593,20 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 vue: ^3.2.25 + '@vitest/mocker@5.0.0': + resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.0': + resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==} + '@volar/language-core@2.4.28': resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} @@ -676,6 +719,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-kit@2.2.0: resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} engines: {node: '>=20.19.0'} @@ -710,6 +757,10 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -828,6 +879,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -847,10 +901,17 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -1017,24 +1078,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -1089,6 +1154,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.3.1: + resolution: {integrity: sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1147,6 +1215,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -1180,6 +1252,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + pinia@3.0.4: resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==} peerDependencies: @@ -1289,6 +1365,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1297,10 +1376,16 @@ packages: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -1328,10 +1413,22 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tinybench@6.1.4: + resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -1423,6 +1520,47 @@ packages: yaml: optional: true + vitest@5.0.0: + resolution: {integrity: sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.0 + '@vitest/browser-preview': 5.0.0 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.0 + '@vitest/coverage-v8': 5.0.0 + '@vitest/ui': 5.0.0 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + 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/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} @@ -1462,6 +1600,11 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -1601,6 +1744,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -1760,6 +1905,11 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 24.12.0 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/connect@3.4.38': dependencies: '@types/node': 24.12.0 @@ -1772,6 +1922,8 @@ snapshots: dependencies: '@types/node': 24.12.0 + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/express-serve-static-core@5.1.1': @@ -1821,6 +1973,17 @@ snapshots: vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) vue: 3.5.29(typescript@5.9.3) + '@vitest/mocker@5.0.0(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.0 + estree-walker: 3.0.3 + magic-string: 1.3.1 + optionalDependencies: + vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + + '@vitest/spy@5.0.0': {} + '@volar/language-core@2.4.28': dependencies: '@volar/source-map': 2.4.28 @@ -1971,6 +2134,8 @@ snapshots: dependencies: color-convert: 2.0.1 + assertion-error@2.0.1: {} + ast-kit@2.2.0: dependencies: '@babel/parser': 7.29.0 @@ -2013,6 +2178,8 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -2110,6 +2277,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.3.2: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -2149,8 +2318,14 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + etag@1.8.1: {} + expect-type@1.4.0: {} + express@5.2.1: dependencies: accepts: 2.0.0 @@ -2190,6 +2365,10 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + fetch-blob@3.2.0: dependencies: node-domexception: 1.0.0 @@ -2390,6 +2569,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magic-string@1.3.1: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + math-intrinsics@1.1.0: {} media-typer@1.1.0: {} @@ -2431,6 +2614,8 @@ snapshots: object-inspect@1.13.4: {} + obug@2.2.1: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -2455,6 +2640,8 @@ snapshots: picomatch@4.0.3: {} + picomatch@4.0.7: {} + pinia@3.0.4(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3)): dependencies: '@vue/devtools-api': 7.7.9 @@ -2618,12 +2805,18 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + source-map-js@1.2.1: {} speakingurl@14.0.1: {} + stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.2.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -2650,11 +2843,20 @@ snapshots: tapable@2.3.0: {} + tinybench@6.1.4: {} + + tinyexec@1.3.0: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + toidentifier@1.0.1: {} tree-kill@1.2.2: {} @@ -2713,6 +2915,27 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 + vitest@5.0.0(@types/node@24.12.0)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.0(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) + chai: 6.2.2 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.3.1 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 6.1.4 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.12.0 + transitivePeerDependencies: + - msw + vscode-uri@3.1.0: {} vue-router@5.0.3(@vue/compiler-sfc@3.5.29)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3)): @@ -2759,6 +2982,11 @@ snapshots: webpack-virtual-modules@0.6.2: {} + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 diff --git a/server/index.ts b/server/index.ts index 1d47697..f15be25 100644 --- a/server/index.ts +++ b/server/index.ts @@ -17,7 +17,8 @@ import delugeDriver from './torrent-clients/deluge.js' import { autoOrganizeAll, scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds, dedupeOrganizedEpisodes } from './organize.js' import { logger } from './logger.js' import { DATA_DIR, BASE_DIR } from './config.js' -import { readAvailable, readInfohashMap, loadEnrichedSeriesData } from './lib/github-cache.js' +import { readAvailable, readInfohashMap } from './lib/github-cache.js' +import { loadCatalog } from './lib/serie-helpers.js' import { ORGANIZED_PATH, readOrganized, writeOrganized } from './lib/organized-store.js' import { pushNotif } from './lib/notifs.js' import { readRequests, completeRequest } from './requests.js' @@ -175,7 +176,7 @@ server.listen(PORT, async () => { logger.warn('api', `Migration dest_dir échouée : ${err instanceof Error ? err.message : err}`) } - loadEnrichedSeriesData() + loadCatalog() .then(async seriesData => { await migrateOrganizedEpisodeIds(ORGANIZED_PATH, seriesData) dedupeOrganizedEpisodes(ORGANIZED_PATH) @@ -187,7 +188,7 @@ server.listen(PORT, async () => { try { const { category } = readSettings() const infohashMap = await readInfohashMap() - const seriesData = await loadEnrichedSeriesData() + const seriesData = await loadCatalog() await autoOrganizeAll( () => dispatchList(category ?? 'fankai', infohashMap), seriesData, @@ -242,7 +243,7 @@ server.listen(PORT, async () => { // Le TTL du cache GitHub est de 1h — ce setInterval tire toujours sur des données fraîches. setInterval(async () => { try { - const seriesData = await loadEnrichedSeriesData() + const seriesData = await loadCatalog() const organizedPath = path.join(DATA_DIR, 'organized.json') const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData) if (updated > 0) diff --git a/server/lib/organized-store.ts b/server/lib/organized-store.ts index 31fc0bf..0ddaee8 100644 --- a/server/lib/organized-store.ts +++ b/server/lib/organized-store.ts @@ -26,6 +26,22 @@ export function writeOrganized(data: Organized, file = ORGANIZED_PATH): void { renameWithRetry(tmp, file) } +// Répercute des déplacements de fichiers sur toutes les entrées qui les suivent, tous hashes confondus +export function retargetEntries(data: Organized, moves: Map): number { + let updated = 0 + for (const eps of Object.values(data)) { + for (const entry of Object.values(eps)) { + const to = entry?.dest_path ? moves.get(entry.dest_path) : undefined + if (!to) continue + entry.dest_path = to + entry.dest_filename = path.basename(to) + entry.dest_dir = path.dirname(to) + updated++ + } + } + return updated +} + // Lecture-modification-écriture synchrone ; fn renvoie false pour ne rien écrire export function updateOrganized(fn: (data: Organized) => boolean | void, file = ORGANIZED_PATH): void { const data = readOrganized(file) diff --git a/server/lib/rss-sync.ts b/server/lib/rss-sync.ts index c41dae8..1010b83 100644 --- a/server/lib/rss-sync.ts +++ b/server/lib/rss-sync.ts @@ -11,7 +11,8 @@ import path from 'path' import fs from 'fs' import { DATA_DIR } from '../config.js' import { logger } from '../logger.js' -import { loadEnrichedSeriesData, readInfohashMap } from './github-cache.js' +import { readInfohashMap } from './github-cache.js' +import { loadCatalogStatus } from './serie-helpers.js' import { dispatchDownload, dispatchList } from '../torrent-clients/index.js' import { readSettings } from '../settings.js' @@ -69,6 +70,32 @@ export function isSynced(serieId: number): boolean { return !!loadSynced()[serieId] } +const normalizeName = (name: string) => + name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim() + +// Série recréée sous un nouvel ID avec le même nom : la surveillance suit. +// Renvoie les surveillances dont la série n'existe plus dans le catalogue. +export function reconcileSynced(catalog: any[]): SyncedSerie[] { + const map = loadSynced() + const ids = new Set(catalog.map(sd => sd.id)) + const orphans: SyncedSerie[] = [] + let changed = false + for (const entry of Object.values(map)) { + if (ids.has(entry.serieId)) continue + const matches = catalog.filter(sd => normalizeName(sd.title ?? '') === normalizeName(entry.serieName)) + if (matches.length === 1 && !map[matches[0].id]) { + delete map[entry.serieId] + map[matches[0].id] = { ...entry, serieId: matches[0].id, serieName: matches[0].title } + logger.info('rss-sync', `Surveillance de "${entry.serieName}" déplacée de la série ${entry.serieId} vers ${matches[0].id}`) + changed = true + } else { + orphans.push(entry) + } + } + if (changed) saveSynced(map) + return orphans +} + // ── Helpers ─────────────────────────────────────────────────── function readOrganized(): Record> { @@ -119,14 +146,16 @@ function getOrganizedEpisodeIds(sd: any, organized: Record { + if (Object.keys(loadSynced()).length === 0) return { sent: 0, skipped: 0, errors: 0 } + + const { series: seriesData, complete } = await loadCatalogStatus() + if (complete) reconcileSynced(seriesData) const synced = loadSynced() const ids = Object.keys(synced).map(Number) - if (ids.length === 0) return { sent: 0, skipped: 0, errors: 0 } logger.info('rss-sync', `Démarrage sync — ${ids.length} série(s) surveillée(s)`) const organized = readOrganized() - const seriesData = await loadEnrichedSeriesData() const activeHashes = new Set() try { @@ -146,7 +175,7 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err const syncedEntry = synced[serieId] const sd = seriesData.find((s: any) => s.id === serieId) if (!sd) { - logger.warn('rss-sync', `Série ${serieId} introuvable dans le cache`) + logger.warn('rss-sync', `Série ${serieId} ("${syncedEntry.serieName}") introuvable dans le catalogue — surveillance à retirer depuis Paramètres › Catalogue`) continue } diff --git a/server/lib/serie-helpers.ts b/server/lib/serie-helpers.ts index f4c460b..6dbeda4 100644 --- a/server/lib/serie-helpers.ts +++ b/server/lib/serie-helpers.ts @@ -1,6 +1,6 @@ import path from 'path' import { logger } from '../logger.js' -import { githubGet } from './github-cache.js' +import { githubGet, loadEnrichedSeriesData, readAvailable } from './github-cache.js' const FANKAI_API = 'https://metadata.fankai.fr' @@ -26,25 +26,64 @@ export function serieHasEpisodes(sd: any): boolean { return (sd?.seasons ?? []).some((s: any) => (s.episodes ?? []).length > 0) } -// Repli sur l'API Fankai quand le scraper n'a pas (encore) la série +export function serieFolderName(title: string): string { + return title.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim() +} + +// Même règle que seasonFolder() du worker +export function seasonFolderName(seasonNumber: number, englishDirectory: boolean): string { + return seasonNumber === 0 ? 'Specials' : (englishDirectory ? `Season ${String(seasonNumber).padStart(2, '0')}` : `Saison ${seasonNumber}`) +} + +const errorMessage = (err: unknown) => err instanceof Error ? err.message : String(err) + +// Données scraper avec le titre à jour de l'API, ou repli complet sur l'API quand le scraper n'a pas (encore) la série export async function resolveSerieData(serieId: number, action?: string): Promise { - let scraperError = 'aucun épisode' - try { - const sd = await githubGet(`series/${serieId}.json`) - if (serieHasEpisodes(sd)) return sd - } catch (err) { - scraperError = err instanceof Error ? err.message : String(err) - } + const [scraper, api] = await Promise.allSettled([githubGet(`series/${serieId}.json`), fankaiGet(`/series/${serieId}`)]) + if (scraper.status === 'fulfilled' && serieHasEpisodes(scraper.value)) + return api.status === 'fulfilled' && api.value?.title ? { ...scraper.value, title: api.value.title } : scraper.value + const scraperError = scraper.status === 'rejected' ? errorMessage(scraper.reason) : 'aucun épisode' try { const sd = await fetchSerieFromApi(serieId) if (action) logger.warn('api', `${action} : série ${serieId} absente du scraper (${scraperError}), données reprises de l'API Fankai`) return sd } catch (err) { - logger.error('api', `${action ?? 'Lecture série'} : série ${serieId} introuvable — scraper (${scraperError}), API Fankai (${err instanceof Error ? err.message : err})`) + logger.error('api', `${action ?? 'Lecture série'} : série ${serieId} introuvable — scraper (${scraperError}), API Fankai (${errorMessage(err)})`) return null } } +let lastCatalogWarn = 0 + +// Catalogue du scraper complété par l'API Fankai : titres à jour et séries pas encore scrapées. +// complete = false si une source a échoué : ne rien supprimer sur la base d'une absence dans ce cas. +export async function loadCatalogStatus(force = false): Promise<{ series: any[]; complete: boolean }> { + const [seriesData, availableIds] = await Promise.all([loadEnrichedSeriesData(force), readAvailable(force)]) + const scraperComplete = availableIds.length > 0 && seriesData.length === availableIds.length + let apiSeries: any[] + try { + const data = await fankaiGet('/series') + apiSeries = Array.isArray(data) ? data : (data.series ?? []) + } catch (err) { + if (Date.now() - lastCatalogWarn > 60 * 60_000) { + lastCatalogWarn = Date.now() + logger.warn('api', `Catalogue : API Fankai injoignable, titres du scraper conservés (${errorMessage(err)})`) + } + return { series: seriesData, complete: false } + } + const titles = new Map(apiSeries.filter(s => s.title).map(s => [s.id, s.title])) + const catalog = seriesData.map(sd => titles.has(sd.id) ? { ...sd, title: titles.get(sd.id) } : sd) + // Scraper vide = injoignable : ne pas interroger l'API série par série + if (seriesData.length === 0) return { series: catalog, complete: false } + const known = new Set(seriesData.map(sd => sd.id)) + const missing = await Promise.all(apiSeries.filter(s => !known.has(s.id)).map(s => fetchSerieFromApi(s.id).catch(() => null))) + return { series: [...catalog, ...missing.filter(Boolean)], complete: scraperComplete && missing.every(Boolean) } +} + +export async function loadCatalog(force = false): Promise { + return (await loadCatalogStatus(force)).series +} + export function imgProxy(url: string | null | undefined, w: number, q = 70): string | null { if (!url) return null const stripped = url.replace(/^https?:\/\//, '') diff --git a/server/logger.ts b/server/logger.ts index 00f90cc..c969165 100644 --- a/server/logger.ts +++ b/server/logger.ts @@ -22,18 +22,26 @@ export interface LogEntry { const LOGS_PATH = path.join(DATA_DIR, 'logs.jsonl') const MAX_LINES = 2000 // rotation auto au-delà +const ROTATE_SLACK = 200 // marge avant rotation, pour ne pas réécrire le fichier à chaque ligne const IS_PROD = process.env.NODE_ENV === 'production' // ── Écriture ────────────────────────────────────────────────── +let lineCount: number | null = null + +function countLines(): number { + if (!fs.existsSync(LOGS_PATH)) return 0 + return fs.readFileSync(LOGS_PATH, 'utf-8').split('\n').filter(Boolean).length +} + function writeLine(entry: LogEntry) { try { + lineCount ??= countLines() fs.appendFileSync(LOGS_PATH, JSON.stringify(entry) + '\n', 'utf-8') - // Rotation si trop grand - const content = fs.readFileSync(LOGS_PATH, 'utf-8') - const lines = content.split('\n').filter(Boolean) - if (lines.length > MAX_LINES) { - fs.writeFileSync(LOGS_PATH, lines.slice(-MAX_LINES).join('\n') + '\n', 'utf-8') + if (++lineCount > MAX_LINES + ROTATE_SLACK) { + const lines = fs.readFileSync(LOGS_PATH, 'utf-8').split('\n').filter(Boolean).slice(-MAX_LINES) + fs.writeFileSync(LOGS_PATH, lines.join('\n') + '\n', 'utf-8') + lineCount = lines.length } } catch {} } @@ -98,7 +106,7 @@ export function readLogs(opts: LogsReadOptions = {}): LogEntry[] { } export function clearLogs(): void { - try { fs.writeFileSync(LOGS_PATH, '', 'utf-8') } catch {} + try { fs.writeFileSync(LOGS_PATH, '', 'utf-8'); lineCount = 0 } catch {} } export function logsFileSize(): number { diff --git a/server/organize.ts b/server/organize.ts index fec2935..3a1daad 100644 --- a/server/organize.ts +++ b/server/organize.ts @@ -9,7 +9,7 @@ import path from 'path' import { Worker } from 'worker_threads' import { logger } from './logger.js' import { readSettings } from './settings.js' -import { readOrganized, writeOrganized, updateOrganized, type Organized } from './lib/organized-store.js' +import { readOrganized, writeOrganized, updateOrganized, retargetEntries, type Organized } from './lib/organized-store.js' export let workerRunning = false const _prevStates = new Map() @@ -171,6 +171,16 @@ export async function scanMediaPath( const presentFiles = new Set() let noMatch = 0 + const trackedByPath = new Map() + for (const [hash, episodes] of Object.entries(organized)) { + for (const [episodeId, entry] of Object.entries(episodes)) { + if (!entry?.dest_path) continue + const keys = trackedByPath.get(entry.dest_path) ?? [] + keys.push(`${hash}:${episodeId}`) + trackedByPath.set(entry.dest_path, keys) + } + } + function walk(dir: string) { let entries: fs.Dirent[] try { entries = fs.readdirSync(dir, { withFileTypes: true }) } @@ -181,6 +191,14 @@ export async function scanMediaPath( walk(full) } else if (entry.isFile() && /\.(mkv|mp4|avi|m4v|mov|wmv)$/i.test(entry.name)) { result.found++ + + // Fichier déjà suivi (sous n'importe quel hash) : ne pas créer d'entrée en double + const tracked = trackedByPath.get(full) + if (tracked) { + for (const key of tracked) presentFiles.add(key) + continue + } + const nameWithoutExt = entry.name.replace(/\.[^.]+$/, '') let match = filenameIndex.get(nameWithoutExt) @@ -460,8 +478,7 @@ export async function migrateOrganizedEpisodeIds( return { updated, orphaned } } -// ── Déduplication : un seul enregistrement par épisode ──────── - +// ── Déduplication ───────────────────────────────────────────── export function dedupeOrganizedEpisodes( organizedPath: string, ): { removed: number } { @@ -485,22 +502,14 @@ export function dedupeOrganizedEpisodes( for (const [episodeId, candidates] of byEpisode) { if (candidates.length < 2) continue - const score = (c: { entry: any }) => { - const exists = c.entry?.dest_path ? fs.existsSync(c.entry.dest_path) : false - const at = Date.parse(c.entry?.at ?? '') || 0 - return { exists, at } - } - const sorted = [...candidates].sort((a, b) => { - const sa = score(a), sb = score(b) - if (sa.exists !== sb.exists) return sa.exists ? -1 : 1 - return sb.at - sa.at - }) + const present = candidates.filter(c => c.entry?.dest_path && fs.existsSync(c.entry.dest_path)) + if (present.length === 0) continue - const keep = sorted[0] - for (const c of sorted.slice(1)) { + for (const c of candidates) { + if (present.includes(c)) continue delete (organized[c.hash] as any)[episodeId] removed++ - logger.info('organize', `Dédoublonnage : ep ${episodeId} retiré de ${c.hash.slice(0, 8)}… (conservé sous ${keep.hash.slice(0, 8)}…)`) + logger.info('organize', `Dédoublonnage : ep ${episodeId} retiré de ${c.hash.slice(0, 8)}… (fichier absent, conservé sous ${present[0].hash.slice(0, 8)}…)`) } } @@ -576,6 +585,7 @@ export async function syncFilenameChanges( dest_path : newPath, at : new Date().toISOString(), } + retargetEntries(organized, new Map([[oldPath, newPath]])) renamed++ changed = true logger.info('organize', `Sync rename : "${currentName}" → "${expectedName}"`) diff --git a/server/routes/import.ts b/server/routes/import.ts index 2e0aa69..4e5ea6a 100644 --- a/server/routes/import.ts +++ b/server/routes/import.ts @@ -5,24 +5,82 @@ import { requireAuth, requireAdmin } from '../auth.js' import { logger } from '../logger.js' import { readSettings } from '../settings.js' import { getGitlabTitle } from '../gitlab-map.js' -import { loadEnrichedSeriesData } from '../lib/github-cache.js' -import { resolveEpNaming, computeExpectedName, resolveSerieData } from '../lib/serie-helpers.js' +import { + resolveEpNaming, computeExpectedName, resolveSerieData, loadCatalog, loadCatalogStatus, + serieFolderName, seasonFolderName, +} from '../lib/serie-helpers.js' import { GITLAB_API_NFO, GITLAB_RAW_NFO } from '../lib/nfo.js' import { dispatchRemove } from '../torrent-clients/index.js' import { readRequests, deleteRequest } from '../requests.js' -import { readOrganized, writeOrganized, updateOrganized, type Organized } from '../lib/organized-store.js' +import { readOrganized, writeOrganized, updateOrganized, retargetEntries, type Organized } from '../lib/organized-store.js' const router = Router() const serieNotFound = (id: unknown) => `Série ${id} introuvable dans le scraper et sur l'API Fankai (voir les logs)` +// Entrée d'un épisode sous n'importe quel hash (torrent absent des paths du scraper) +function findEntry(organized: Organized, episodeId: number): { hash: string; entry: any } | null { + for (const [hash, eps] of Object.entries(organized)) + if (eps[String(episodeId)]) return { hash, entry: eps[String(episodeId)] } + return null +} + +// Entrées dont l'épisode n'existe plus dans le catalogue +function findOrphans(organized: Organized, catalog: any[]) { + const known = new Set() + for (const sd of catalog) + for (const season of sd.seasons ?? []) + for (const ep of season.episodes ?? []) known.add(String(ep.id)) + const orphans: { hash: string; episode_id: number; season: number | null; episode: number | null; dest_path: string | null }[] = [] + for (const [hash, eps] of Object.entries(organized)) + for (const [episodeId, entry] of Object.entries(eps)) + if (!known.has(episodeId)) + orphans.push({ hash, episode_id: Number(episodeId), season: entry?.season ?? null, episode: entry?.episode ?? null, dest_path: entry?.dest_path ?? null }) + return orphans +} + +// Dossiers série (enfants directs de la médiathèque) contenant les fichiers suivis d'une série +function serieFolders(sd: any, organized: Organized, mediaPath: string): Map { + const ids = new Set() + for (const season of sd.seasons ?? []) + for (const ep of season.episodes ?? []) ids.add(String(ep.id)) + const folders = new Map() + if (!mediaPath) return folders + for (const eps of Object.values(organized)) { + for (const [episodeId, entry] of Object.entries(eps)) { + if (!ids.has(episodeId) || !entry?.dest_path) continue + const parts = path.relative(mediaPath, entry.dest_path).split(path.sep) + if (parts.length < 2 || parts[0] === '..' || path.isAbsolute(parts[0])) continue + const folder = path.join(mediaPath, parts[0]) + folders.set(folder, (folders.get(folder) ?? 0) + 1) + } + } + return folders +} + +function listFiles(dir: string): string[] { + const files: string[] = [] + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) files.push(...listFiles(full)) + else files.push(full) + } + return files +} + +function removeEmptyDirs(dir: string): void { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) + if (entry.isDirectory()) removeEmptyDirs(path.join(dir, entry.name)) + if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir) +} + // ── Import manuel ────────────────────────────────────────────── router.post('/manual-import', requireAuth, async (req, res) => { const { serie_id, items } = req.body if (!serie_id || !Array.isArray(items) || items.length === 0) { res.status(400).json({ error: 'serie_id et items requis' }); return } - const { mediaPath, organizeMode, nfoSupport } = readSettings() + const { mediaPath, organizeMode, nfoSupport, englishDirectory } = readSettings() let organized: Organized try { organized = readOrganized() } catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return } @@ -31,8 +89,7 @@ router.post('/manual-import', requireAuth, async (req, res) => { const apply = (op: (data: Organized) => void) => { op(organized); ops.push(op) } const sd = await resolveSerieData(Number(serie_id), 'Import manuel') if (!sd) { res.status(404).json({ error: serieNotFound(serie_id) }); return } - const rawTitle = sd.title ?? sd.show_title ?? '' - const serieTitle = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim() + const serieTitle = serieFolderName(sd.title ?? sd.show_title ?? '') const episodeIndex = new Map() for (const season of sd.seasons ?? []) { for (const ep of season.episodes ?? []) { @@ -58,8 +115,7 @@ router.post('/manual-import', requireAuth, async (req, res) => { const destFilename = resolvedExt && resolvedExt !== srcExt ? resolvedName.slice(0, -resolvedExt.length) + srcExt : resolvedName - const seasonFolder = season.season_number === 0 ? 'Specials' : `Saison ${season.season_number}` - const destDir = path.join(mediaPath, serieTitle, seasonFolder) + const destDir = path.join(mediaPath, serieTitle, seasonFolderName(season.season_number, englishDirectory)) const destPath = path.join(destDir, destFilename) try { if (!fs.existsSync(file_path)) throw new Error('Fichier source introuvable') @@ -102,6 +158,7 @@ router.post('/manual-import', requireAuth, async (req, res) => { } if (isInSeriePath && !fs.existsSync(destPath)) { fs.renameSync(file_path, destPath) + apply(data => { retargetEntries(data, new Map([[file_path, destPath]])) }) logger.info('api', `Import manuel (rename) : "${srcFilename}" → "${destFilename}"`) } else if (!isInSeriePath && !fs.existsSync(destPath)) { if (organizeMode === 'hardlink') { @@ -110,6 +167,7 @@ router.post('/manual-import', requireAuth, async (req, res) => { } else if (organizeMode === 'move') { try { fs.renameSync(file_path, destPath) } catch { await fs.promises.copyFile(file_path, destPath); await fs.promises.unlink(file_path) } + apply(data => { retargetEntries(data, new Map([[file_path, destPath]])) }) } else { await fs.promises.copyFile(file_path, destPath) } @@ -191,6 +249,10 @@ router.get('/organized/:serieId', requireAuth, async (req, res) => { if (!entry && organized['manual']?.[String(ep.id)]) { entry = organized['manual'][String(ep.id)] } + if (!entry) { + const found = findEntry(organized, ep.id) + if (found) { entry = found.entry; entryHash = found.hash } + } if (!entry) continue const { needsRename } = computeExpectedName(ep, entry, entryHash, nfoSupport) @@ -226,9 +288,8 @@ router.post('/rename-episode', requireAuth, async (req, res) => { let orgEntry = organized[hash]?.[String(episode_id)] ?? organized['manual']?.[String(episode_id)] let entryHash = organized[hash]?.[String(episode_id)] ? hash : 'manual' if (!orgEntry) { - for (const [h, eps] of Object.entries(organized)) { - if (eps[String(episode_id)]) { orgEntry = eps[String(episode_id)]; entryHash = h; break } - } + const found = findEntry(organized, Number(episode_id)) + if (found) { orgEntry = found.entry; entryHash = found.hash } } if (!orgEntry) { res.status(404).json({ error: 'Épisode non importé' }); return } const { currentName, expectedName: newName, needsRename } = computeExpectedName(ep, orgEntry, entryHash, nfoSupport) @@ -240,6 +301,7 @@ router.post('/rename-episode', requireAuth, async (req, res) => { if (fs.existsSync(newPath)) throw new Error(`Un fichier avec ce nom existe déjà : ${newName}`) fs.renameSync(oldPath, newPath) organized[entryHash][String(episode_id)] = { ...orgEntry, dest_filename: newName, dest_path: newPath, at: new Date().toISOString() } + retargetEntries(organized, new Map([[oldPath, newPath]])) writeOrganized(organized) logger.info('api', `Rename : "${orgEntry.dest_filename}" → "${newName}"`) res.json({ ok: true, renamed: true, old_name: orgEntry.dest_filename, new_name: newName }) @@ -400,12 +462,12 @@ router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) => router.get('/organized-summary', requireAuth, async (_req, res) => { try { const { nfoSupport } = readSettings() + const { series: seriesData, complete } = await loadCatalogStatus() const organized = readOrganized() - const seriesData = await loadEnrichedSeriesData() const result: any[] = [] for (const sd of seriesData) { const rawTitle = sd.title ?? sd.show_title ?? '' - const serieTitle = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim() + const serieTitle = serieFolderName(rawTitle) const episodes: any[] = [] for (const season of sd.seasons ?? []) { for (const ep of season.episodes ?? []) { @@ -422,6 +484,10 @@ router.get('/organized-summary', requireAuth, async (_req, res) => { if (!orgEntry && organized['manual']?.[String(ep.id)]) { orgEntry = organized['manual'][String(ep.id)]; orgHash = 'manual' } + if (!orgEntry) { + const found = findEntry(organized, ep.id) + if (found) { orgEntry = found.entry; orgHash = found.hash } + } if (!orgEntry) continue const { currentName, expectedName, needsRename } = computeExpectedName(ep, orgEntry, orgHash, nfoSupport) const fileExists = orgEntry.dest_path ? fs.existsSync(orgEntry.dest_path) : false @@ -432,24 +498,111 @@ router.get('/organized-summary', requireAuth, async (_req, res) => { result.push({ serie_id: sd.id, serie_title: rawTitle, serie_title_clean: serieTitle, total: episodes.length, needs_rename: episodes.filter(e => e.needs_rename).length, episodes }) } } - res.json({ series: result, nfo_support: nfoSupport }) + const orphans = complete + ? findOrphans(organized, seriesData).map(o => ({ ...o, file_exists: !!o.dest_path && fs.existsSync(o.dest_path) })) + : [] + res.json({ series: result, nfo_support: nfoSupport, orphans, orphans_checked: complete }) } catch (err) { logger.error('api', `organized-summary échoué : ${err instanceof Error ? err.message : err}`) res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) } }) +// ── Retrait des entrées orphelines ───────────────────────────── +router.delete('/organized-summary/orphans', requireAdmin, async (req, res) => { + const only = Array.isArray(req.body?.episode_ids) ? new Set(req.body.episode_ids.map(String)) : null + const { series, complete } = await loadCatalogStatus() + if (!complete) { res.status(503).json({ error: 'Catalogue incomplet (scraper ou API Fankai injoignable), réessayez plus tard' }); return } + let removed = 0 + try { + updateOrganized(data => { + for (const o of findOrphans(data, series)) { + if (only && !only.has(String(o.episode_id))) continue + delete data[o.hash][String(o.episode_id)] + removed++ + } + return removed > 0 + }) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return + } + logger.info('api', `Entrées orphelines retirées du suivi : ${removed}`) + res.json({ ok: true, removed }) +}) + +// ── Dossier série ────────────────────────────────────────────── +router.get('/organized/:serieId/folder', requireAuth, async (req, res) => { + const serieId = Number(req.params.serieId) + const sd = await resolveSerieData(serieId) + if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return } + const { mediaPath } = readSettings() + let organized: Organized + try { organized = readOrganized() } + catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return } + const expected = path.join(mediaPath, serieFolderName(sd.title ?? sd.show_title ?? '')) + const current = [...serieFolders(sd, organized, mediaPath)].map(([folder, entries]) => ({ path: folder, entries, exists: fs.existsSync(folder) })) + res.json({ expected, current, needs_rename: current.some(f => f.exists && f.path !== expected) }) +}) + +router.post('/organized/:serieId/folder', requireAdmin, async (req, res) => { + const serieId = Number(req.params.serieId) + const sd = await resolveSerieData(serieId, 'Renommage dossier') + if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return } + const { mediaPath } = readSettings() + if (!mediaPath) { res.status(400).json({ error: 'Chemin médiathèque non configuré' }); return } + let organized: Organized + try { organized = readOrganized() } + catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return } + const expected = path.join(mediaPath, serieFolderName(sd.title ?? sd.show_title ?? '')) + const sources = [...serieFolders(sd, organized, mediaPath).keys()].filter(f => f !== expected && fs.existsSync(f)) + if (sources.length === 0) { res.json({ ok: true, moved: 0, updated: 0 }); return } + + // Tout le contenu (vidéos, NFO, images…) est fusionné dans le dossier attendu, sans jamais écraser + const moves = new Map() + for (const src of sources) + for (const file of listFiles(src)) moves.set(file, path.join(expected, path.relative(src, file))) + const targets = [...moves.values()] + const conflicts = targets.filter((to, i) => fs.existsSync(to) || targets.indexOf(to) !== i) + if (conflicts.length > 0) { + res.status(409).json({ error: `${conflicts.length} fichier(s) existent déjà dans « ${path.basename(expected)} »`, conflicts: conflicts.slice(0, 10) }); return + } + + const done = new Map() + let failure: string | null = null + for (const [from, to] of moves) { + try { + fs.mkdirSync(path.dirname(to), { recursive: true }) + fs.renameSync(from, to) + done.set(from, to) + } catch (err) { + failure = `${path.basename(from)} : ${err instanceof Error ? err.message : err}` + break + } + } + for (const src of sources) { + try { removeEmptyDirs(src) } catch {} + } + let updated = 0 + try { updateOrganized(data => { updated = retargetEntries(data, done); return updated > 0 }) } + catch (err) { + logger.error('api', `Renommage dossier série ${serieId} : fichiers déplacés mais suivi non mis à jour : ${err instanceof Error ? err.message : err}`) + res.status(500).json({ error: 'Fichiers déplacés mais suivi non mis à jour, lancez un scan de la médiathèque' }); return + } + const label = `"${sources.map(s => path.basename(s)).join('", "')}" → "${path.basename(expected)}"` + if (failure) { + logger.error('api', `Renommage dossier ${label} interrompu après ${done.size} fichier(s) : ${failure}`) + res.status(500).json({ error: `Interrompu après ${done.size} fichier(s) : ${failure}`, moved: done.size, updated }); return + } + logger.info('api', `Dossier série renommé : ${label} (${done.size} fichier(s), ${updated} entrée(s))`) + res.json({ ok: true, moved: done.size, updated }) +}) + // ── Rename en masse ──────────────────────────────────────────── router.post('/rename-all', requireAdmin, async (req, res) => { const { serie_id, serie_ids } = req.body const onlyIds = Array.isArray(serie_ids) ? new Set(serie_ids.map(Number)) : serie_id ? new Set([Number(serie_id)]) : null const { nfoSupport } = readSettings() - const seriesData = await loadEnrichedSeriesData() - if (onlyIds) { - const known = new Set(seriesData.map((sd: any) => sd.id)) - const missing = await Promise.all([...onlyIds].filter(id => !known.has(id)).map(id => resolveSerieData(id, 'Rename masse'))) - seriesData.push(...missing.filter(Boolean)) - } + const seriesData = await loadCatalog() let organized: Organized try { organized = readOrganized() } catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return } @@ -482,6 +635,7 @@ router.post('/rename-all', requireAdmin, async (req, res) => { } fs.renameSync(oldPath, newPath) organized[orgHash][String(ep.id)] = { ...orgEntry, dest_filename: expectedName, dest_path: newPath, at: new Date().toISOString() } + retargetEntries(organized, new Map([[oldPath, newPath]])) done.push(ep.id) logger.info('api', `Rename masse : "${currentName}" → "${expectedName}"`) } catch (err) { diff --git a/server/routes/organize.ts b/server/routes/organize.ts index 3d3b46f..23fe835 100644 --- a/server/routes/organize.ts +++ b/server/routes/organize.ts @@ -2,7 +2,7 @@ import { Router } from 'express' import { requireAuth } from '../auth.js' import { logger } from '../logger.js' import { organizeTorrent, migrateOrganizedEpisodeIds } from '../organize.js' -import { loadEnrichedSeriesData } from '../lib/github-cache.js' +import { loadCatalog } from '../lib/serie-helpers.js' import { recentOrganized, pushNotif } from '../lib/notifs.js' import { dispatchGetFiles } from '../torrent-clients/index.js' import { DATA_DIR } from '../config.js' @@ -12,7 +12,7 @@ const router = Router() router.post('/organize/migrate-ids', requireAuth, async (_req, res) => { try { - const seriesData = await loadEnrichedSeriesData() + const seriesData = await loadCatalog() const organizedPath = path.join(DATA_DIR, 'organized.json') const result = await migrateOrganizedEpisodeIds(organizedPath, seriesData) res.json({ ok: true, ...result }) @@ -36,7 +36,7 @@ router.post('/organize', requireAuth, async (req, res) => { const { hash, name, save_path } = req.body if (!hash || !name || !save_path) { res.status(400).json({ error: 'hash, name et save_path requis' }); return } try { - const seriesData = await loadEnrichedSeriesData() + const seriesData = await loadCatalog() // Récupère la progression par fichier depuis le client torrent. // Permet au worker de sauter les fichiers encore en téléchargement (EBUSY sur Windows). diff --git a/server/routes/rss-sync.ts b/server/routes/rss-sync.ts index d7ab674..a086b74 100644 --- a/server/routes/rss-sync.ts +++ b/server/routes/rss-sync.ts @@ -1,7 +1,8 @@ import { Router } from 'express' import { requireAuth, requireAdmin } from '../auth.js' import { logger } from '../logger.js' -import { loadSynced, setSync, setSyncBulk, isSynced, runRssSync } from '../lib/rss-sync.js' +import { loadSynced, setSync, setSyncBulk, isSynced, runRssSync, reconcileSynced } from '../lib/rss-sync.js' +import { loadCatalogStatus } from '../lib/serie-helpers.js' const router = Router() @@ -21,6 +22,12 @@ router.post('/rss-sync/run', requireAuth, async (_req, res) => { } }) +/** GET /api/rss-sync/orphans → surveillances dont la série n'existe plus (avant /:id) */ +router.get('/rss-sync/orphans', requireAdmin, async (_req, res) => { + const { series, complete } = await loadCatalogStatus() + res.json({ checked: complete, orphans: complete ? reconcileSynced(series) : [] }) +}) + /** POST /api/rss-sync/bulk → activer/désactiver la surveillance de plusieurs séries (avant /:id) */ router.post('/rss-sync/bulk', requireAdmin, (req, res) => { const enabled = req.body?.enabled === true diff --git a/server/routes/system.ts b/server/routes/system.ts index aabba76..4ec1fe1 100644 --- a/server/routes/system.ts +++ b/server/routes/system.ts @@ -7,7 +7,8 @@ import { DATA_DIR, BASE_DIR } from '../config.js' import { readSettings } from '../settings.js' import { systemInfo } from '../system.js' import { scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds, dedupeOrganizedEpisodes } from '../organize.js' -import { readAvailable, loadEnrichedSeriesData, cacheClear } from '../lib/github-cache.js' +import { readAvailable, cacheClear } from '../lib/github-cache.js' +import { loadCatalog } from '../lib/serie-helpers.js' import { cacheSize } from '../lib/github-cache.js' import { recentOrganized } from '../lib/notifs.js' import { workerRunning } from '../organize.js' @@ -87,10 +88,8 @@ router.get('/browse-files', requireAuth, (req, res) => { const dirPath = (req.query.path as string) || '/' const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.avi', '.m4v', '.mov', '.wmv']) try { - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }) - logger.info('api', `Dossier créé : ${dirPath}`) - } + // Lecture seule : le dossier est créé au moment de l'import + if (!fs.existsSync(dirPath)) { res.json({ path: dirPath, files: [], exists: false }); return } const files: { name: string; path: string; size: number }[] = [] function walk(dir: string) { let entries: fs.Dirent[] @@ -111,7 +110,7 @@ router.get('/browse-files', requireAuth, (req, res) => { } walk(dirPath) files.sort((a, b) => a.name.localeCompare(b.name, 'fr')) - res.json({ path: dirPath, files }) + res.json({ path: dirPath, files, exists: true }) } catch (err) { logger.warn('api', `browse-files "${dirPath}" : ${err instanceof Error ? err.message : err}`) res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' }) @@ -140,7 +139,7 @@ router.post('/update', requireAuth, async (req, res) => { logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`) if (force) { - const seriesData = await loadEnrichedSeriesData(true) + const seriesData = await loadCatalog(true) const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData) const { removed } = dedupeOrganizedEpisodes(organizedPath) const { renamed, errors } = await syncFilenameChanges(seriesData, organizedPath) @@ -152,7 +151,7 @@ router.post('/update', requireAuth, async (req, res) => { res.json({ ok: true, count: availableIds.length }) ;(async () => { try { - const seriesData = await loadEnrichedSeriesData() + const seriesData = await loadCatalog() const { renamed } = await syncFilenameChanges(seriesData, organizedPath) if (renamed > 0) logger.info('api', `Sync noms scraper après mise à jour — ${renamed} fichier(s) renommé(s)`) @@ -170,7 +169,7 @@ router.post('/scan', requireAuth, async (_req, res) => { try { const { mediaPath } = readSettings() logger.info('api', 'Scan médiathèque manuel lancé') - const seriesData = await loadEnrichedSeriesData() + const seriesData = await loadCatalog() const result = await scanMediaPath(mediaPath, path.join(DATA_DIR, 'organized.json'), seriesData) res.json({ ok: true, ...result }) } catch (err) { diff --git a/src/components/EpisodeRow.vue b/src/components/EpisodeRow.vue index ce3ed86..9e4e6f4 100644 --- a/src/components/EpisodeRow.vue +++ b/src/components/EpisodeRow.vue @@ -3,7 +3,7 @@
diff --git a/src/components/ManualImportModal.vue b/src/components/ManualImportModal.vue index a9c762c..06215cc 100644 --- a/src/components/ManualImportModal.vue +++ b/src/components/ManualImportModal.vue @@ -20,8 +20,10 @@
-

Aucun fichier vidéo trouvé dans ce dossier.

-

Placez vos fichiers dans {{ serieFolder }} puis réessayez.

+

{{ folderMissing ? 'Ce dossier n\'existe pas encore.' : 'Aucun fichier vidéo trouvé dans ce dossier.' }}

+

+ {{ folderMissing ? 'Créez' : 'Placez vos fichiers dans' }} {{ serieFolder }}{{ folderMissing ? ', placez-y vos fichiers' : '' }} puis réessayez. +

@@ -77,7 +79,7 @@