From b2f450dcf78c4564e44c08510bd5c30aca29248a Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 04:52:18 +0200 Subject: [PATCH 01/34] web-player: add Vitest, unit tests and pure queue operations --- web-player/package-lock.json | 354 ++++++++++++++++---- web-player/package.json | 7 +- web-player/src/lib/data/collections.test.ts | 23 ++ web-player/src/lib/data/genres.test.ts | 28 ++ web-player/src/lib/data/search.test.ts | 28 ++ web-player/src/lib/player/player.svelte.ts | 25 +- web-player/src/lib/player/queue.test.ts | 60 ++++ web-player/src/lib/player/queue.ts | 39 +++ web-player/src/lib/server/albumForm.test.ts | 36 ++ web-player/src/lib/server/greeting.test.ts | 22 ++ web-player/src/lib/utils/format.test.ts | 46 +++ web-player/vite.config.ts | 5 +- 12 files changed, 586 insertions(+), 87 deletions(-) create mode 100644 web-player/src/lib/data/collections.test.ts create mode 100644 web-player/src/lib/data/genres.test.ts create mode 100644 web-player/src/lib/data/search.test.ts create mode 100644 web-player/src/lib/player/queue.test.ts create mode 100644 web-player/src/lib/player/queue.ts create mode 100644 web-player/src/lib/server/albumForm.test.ts create mode 100644 web-player/src/lib/server/greeting.test.ts create mode 100644 web-player/src/lib/utils/format.test.ts diff --git a/web-player/package-lock.json b/web-player/package-lock.json index 1e0fddf..377c7e2 100644 --- a/web-player/package-lock.json +++ b/web-player/package-lock.json @@ -36,41 +36,8 @@ "tailwindcss": "^4.3.0", "typescript": "^6.0.3", "typescript-eslint": "^8.64.0", - "vite": "^8.0.16" - } - }, - "node_modules/@emnapi/core": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", - "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.3", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", - "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", - "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" + "vite": "^8.0.16", + "vitest": "^5.0.1" } }, "node_modules/@emnapi/wasi-threads": { @@ -343,9 +310,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -1152,6 +1119,7 @@ "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz", "integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==", "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", @@ -1203,6 +1171,7 @@ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", "license": "MIT", + "peer": true, "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", @@ -1440,27 +1409,6 @@ "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "dev": true, @@ -1565,12 +1513,30 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1597,6 +1563,7 @@ "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1658,6 +1625,7 @@ "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.64.0", "@typescript-eslint/types": "8.64.0", @@ -1765,6 +1733,7 @@ "integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1843,11 +1812,70 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vitest/mocker": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", + "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.1", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz", + "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1891,6 +1919,16 @@ "node": ">= 0.4" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -1923,6 +1961,16 @@ "node": "18 || 20 || >=22" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2063,6 +2111,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2082,6 +2137,7 @@ "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", "dev": true, "license": "MIT", + "peer": true, "workspaces": [ "packages/*" ], @@ -2323,6 +2379,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3007,9 +3073,9 @@ } }, "node_modules/obug": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", - "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" @@ -3130,9 +3196,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", "engines": { "node": ">=12" @@ -3160,6 +3226,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -3293,6 +3360,7 @@ "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -3309,6 +3377,7 @@ "integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=20" }, @@ -3479,6 +3548,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.9" }, @@ -3573,6 +3643,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -3596,6 +3673,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -3613,6 +3704,7 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -3760,6 +3852,26 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -3824,6 +3936,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3885,6 +3998,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -3976,6 +4090,99 @@ } } }, + "node_modules/vitest": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz", + "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.1", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.1", + "@vitest/browser-preview": "5.0.1", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.1", + "@vitest/coverage-v8": "5.0.1", + "@vitest/ui": "5.0.1", + "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 + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3992,6 +4199,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/web-player/package.json b/web-player/package.json index 669ebd9..7d72333 100644 --- a/web-player/package.json +++ b/web-player/package.json @@ -11,9 +11,11 @@ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "gen:api": "npx -y openapi-typescript@7 http://localhost:5111/openapi/v1.json -o src/lib/api/schema.d.ts", + "test": "vitest run", + "test:watch": "vitest", "format": "prettier --write .", "lint:tokens": "node scripts/check-tokens.mjs", - "lint": "prettier --check . && eslint . && npm run lint:tokens" + "lint": "prettier --check . && eslint . && npm run lint:tokens && npm run test" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -33,7 +35,8 @@ "tailwindcss": "^4.3.0", "typescript": "^6.0.3", "typescript-eslint": "^8.64.0", - "vite": "^8.0.16" + "vite": "^8.0.16", + "vitest": "^5.0.1" }, "dependencies": { "@fontsource-variable/bricolage-grotesque": "^5.3.0", diff --git a/web-player/src/lib/data/collections.test.ts b/web-player/src/lib/data/collections.test.ts new file mode 100644 index 0000000..ee369f6 --- /dev/null +++ b/web-player/src/lib/data/collections.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { appendUnique, shuffle } from './collections'; + +describe('appendUnique', () => { + const keyOf = (item: { id: number }) => item.id; + it('appends only unseen keys', () => { + const result = appendUnique([{ id: 1 }], [{ id: 1 }, { id: 2 }, { id: 2 }, { id: 3 }], keyOf); + expect(result).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]); + }); + it('returns the same array when nothing is new', () => { + const current = [{ id: 1 }]; + expect(appendUnique(current, [{ id: 1 }], keyOf)).toBe(current); + }); +}); + +describe('shuffle', () => { + it('keeps the same elements and does not mutate the input', () => { + const input = [1, 2, 3, 4, 5, 6]; + const result = shuffle(input); + expect([...result].sort()).toEqual(input); + expect(input).toEqual([1, 2, 3, 4, 5, 6]); + }); +}); diff --git a/web-player/src/lib/data/genres.test.ts b/web-player/src/lib/data/genres.test.ts new file mode 100644 index 0000000..132f671 --- /dev/null +++ b/web-player/src/lib/data/genres.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { findConflict, genreHue } from './genres'; + +describe('genreHue', () => { + it('is stable and within 0-359', () => { + for (const genre of ['rock', 'something-unknown', 'ñandú']) { + const hue = genreHue(genre); + expect(hue).toBe(genreHue(genre)); + expect(hue).toBeGreaterThanOrEqual(0); + expect(hue).toBeLessThan(360); + } + }); +}); + +describe('findConflict', () => { + const options = [ + { genre: 'a', incompatibleWith: ['b'] }, + { genre: 'b', incompatibleWith: ['a'] }, + { genre: 'c', incompatibleWith: [] } + ]; + it('returns the first incompatible pair', () => { + expect(findConflict(['c', 'a', 'b'], options)).toEqual(['a', 'b']); + }); + it('returns undefined when compatible or unknown', () => { + expect(findConflict(['a', 'c'], options)).toBeUndefined(); + expect(findConflict(['x', 'y'], options)).toBeUndefined(); + }); +}); diff --git a/web-player/src/lib/data/search.test.ts b/web-player/src/lib/data/search.test.ts new file mode 100644 index 0000000..d5f4373 --- /dev/null +++ b/web-player/src/lib/data/search.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { findTopResult } from './search'; + +const empty = { tracks: [], albums: [], playlists: [], users: [] }; + +describe('findTopResult', () => { + it('prefers prefix matches by kind: user, track, album, playlist', () => { + const result = findTopResult({ + query: 'Ab', + users: [{ name: 'Zed' }, { name: 'abel' }], + tracks: [{ title: 'Abc' }], + albums: [{ title: 'Abd' }], + playlists: [{ name: 'Abe' }] + }); + expect(result).toEqual({ kind: 'user', user: { name: 'abel' } }); + }); + it('matches case-insensitively and ignores surrounding spaces', () => { + const result = findTopResult({ ...empty, query: ' SO ', tracks: [{ title: 'song' }] }); + expect(result).toEqual({ kind: 'track', track: { title: 'song' } }); + }); + it('falls back to the first available result', () => { + const result = findTopResult({ ...empty, query: 'zzz', albums: [{ title: 'Other' }] }); + expect(result).toEqual({ kind: 'album', album: { title: 'Other' } }); + }); + it('returns null without results', () => { + expect(findTopResult({ ...empty, query: 'x' })).toBeNull(); + }); +}); diff --git a/web-player/src/lib/player/player.svelte.ts b/web-player/src/lib/player/player.svelte.ts index c56dc7b..6b34c37 100644 --- a/web-player/src/lib/player/player.svelte.ts +++ b/web-player/src/lib/player/player.svelte.ts @@ -4,6 +4,7 @@ import { extractAccent, type Accent } from '$lib/theme/palette'; import { shuffle } from '$lib/data/collections'; import { dialog } from '$lib/state/dialog.svelte'; import { ListenTracker } from './listenTracker'; +import { append, insertNext, move, nextIndex, previousIndex } from './queue'; export interface PlayerTrack { id: string | number; @@ -398,9 +399,7 @@ class PlayerState { this.playQueue(items, 0); return; } - const tracks = [...this.tracks]; - tracks.splice(this.#index() + 1, 0, ...items.map(toTrack)); - this.tracks = tracks; + this.tracks = insertNext(this.tracks, this.#index(), items.map(toTrack)); } appendToQueue(items: QueueItem[]) { @@ -409,7 +408,7 @@ class PlayerState { this.playQueue(items, 0); return; } - this.tracks = [...this.tracks, ...items.map(toTrack)]; + this.tracks = append(this.tracks, items.map(toTrack)); } playTrack(id: string | number) { @@ -460,13 +459,7 @@ class PlayerState { next() { if (this.tracks.length === 0) return; const idx = this.#index(); - let nextIdx = (idx + 1) % this.tracks.length; - if (this.shuffle && this.tracks.length > 1) { - do { - nextIdx = Math.floor(Math.random() * this.tracks.length); - } while (nextIdx === idx); - } - this.currentId = this.tracks[nextIdx].id; + this.currentId = this.tracks[nextIndex(this.tracks.length, idx, this.shuffle)].id; this.#loadCurrent(); } @@ -490,13 +483,7 @@ class PlayerState { } moveQueueItem(from: number, insertAt: number) { - if (from < 0 || from >= this.tracks.length) return; - const target = insertAt > from ? insertAt - 1 : insertAt; - if (target === from) return; - const tracks = [...this.tracks]; - const [item] = tracks.splice(from, 1); - tracks.splice(target, 0, item); - this.tracks = tracks; + this.tracks = move(this.tracks, from, insertAt); } clearUpcoming() { @@ -511,7 +498,7 @@ class PlayerState { return; } const idx = this.#index(); - this.currentId = this.tracks[(idx - 1 + this.tracks.length) % this.tracks.length].id; + this.currentId = this.tracks[previousIndex(this.tracks.length, idx)].id; this.#loadCurrent(); } diff --git a/web-player/src/lib/player/queue.test.ts b/web-player/src/lib/player/queue.test.ts new file mode 100644 index 0000000..b6ac598 --- /dev/null +++ b/web-player/src/lib/player/queue.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { append, insertNext, move, nextIndex, previousIndex } from './queue'; + +describe('insertNext', () => { + it('inserts right after the given index without mutating', () => { + const queue = ['a', 'b', 'c']; + expect(insertNext(queue, 0, ['x', 'y'])).toEqual(['a', 'x', 'y', 'b', 'c']); + expect(queue).toEqual(['a', 'b', 'c']); + }); + it('inserts at the start when index is -1', () => { + expect(insertNext(['a'], -1, ['x'])).toEqual(['x', 'a']); + }); +}); + +describe('append', () => { + it('adds items at the end', () => { + expect(append(['a'], ['b', 'c'])).toEqual(['a', 'b', 'c']); + }); +}); + +describe('move', () => { + const queue = ['a', 'b', 'c', 'd']; + it('moves an item forward using insert position semantics', () => { + expect(move(queue, 0, 3)).toEqual(['b', 'c', 'a', 'd']); + }); + it('moves an item backward', () => { + expect(move(queue, 3, 1)).toEqual(['a', 'd', 'b', 'c']); + }); + it('is a no-op when the target equals the origin', () => { + expect(move(queue, 1, 1)).toBe(queue); + expect(move(queue, 1, 2)).toBe(queue); + }); + it('ignores out-of-range origins', () => { + expect(move(queue, -1, 0)).toBe(queue); + expect(move(queue, 4, 0)).toBe(queue); + }); +}); + +describe('nextIndex', () => { + it('advances and wraps around', () => { + expect(nextIndex(3, 0, false)).toBe(1); + expect(nextIndex(3, 2, false)).toBe(0); + }); + it('never repeats the current index when shuffling', () => { + const values = [0.4, 0.5, 0.9]; + let call = 0; + expect(nextIndex(3, 1, true, () => values[call++])).toBe(2); + expect(call).toBe(3); + }); + it('ignores shuffle with a single track', () => { + expect(nextIndex(1, 0, true)).toBe(0); + }); +}); + +describe('previousIndex', () => { + it('steps back and wraps around', () => { + expect(previousIndex(3, 2)).toBe(1); + expect(previousIndex(3, 0)).toBe(2); + }); +}); diff --git a/web-player/src/lib/player/queue.ts b/web-player/src/lib/player/queue.ts new file mode 100644 index 0000000..cee8b9a --- /dev/null +++ b/web-player/src/lib/player/queue.ts @@ -0,0 +1,39 @@ +export function insertNext(queue: T[], afterIndex: number, items: T[]): T[] { + const result = [...queue]; + result.splice(afterIndex + 1, 0, ...items); + return result; +} + +export function append(queue: T[], items: T[]): T[] { + return [...queue, ...items]; +} + +export function move(queue: T[], from: number, insertAt: number): T[] { + if (from < 0 || from >= queue.length) return queue; + const target = insertAt > from ? insertAt - 1 : insertAt; + if (target === from) return queue; + const result = [...queue]; + const [item] = result.splice(from, 1); + result.splice(target, 0, item); + return result; +} + +export function nextIndex( + length: number, + index: number, + shuffle: boolean, + random: () => number = Math.random +): number { + if (shuffle && length > 1) { + let next: number; + do { + next = Math.floor(random() * length); + } while (next === index); + return next; + } + return (index + 1) % length; +} + +export function previousIndex(length: number, index: number): number { + return (index - 1 + length) % length; +} diff --git a/web-player/src/lib/server/albumForm.test.ts b/web-player/src/lib/server/albumForm.test.ts new file mode 100644 index 0000000..a6dd02c --- /dev/null +++ b/web-player/src/lib/server/albumForm.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { ALBUM_EARLIEST_YEAR } from '$lib/config'; +import { parseAlbumForm } from './albumForm'; + +function form(fields: Record): FormData { + const data = new FormData(); + for (const [key, value] of Object.entries(fields)) data.set(key, value); + return data; +} + +describe('parseAlbumForm', () => { + it('parses a valid form and trims values', () => { + expect( + parseAlbumForm(form({ title: ' Title ', description: ' d ', releaseYear: '2020' })) + ).toEqual({ body: { title: 'Title', description: 'd', releaseYear: 2020 } }); + }); + it('turns empty optional fields into null', () => { + expect(parseAlbumForm(form({ title: 'T' }))).toEqual({ + body: { title: 'T', description: null, releaseYear: null } + }); + }); + it('rejects empty or too long titles', () => { + expect(parseAlbumForm(form({ title: ' ' }))).toHaveProperty('failMessage'); + expect(parseAlbumForm(form({ title: 'x'.repeat(201) }))).toHaveProperty('failMessage'); + }); + it('rejects too long descriptions', () => { + expect(parseAlbumForm(form({ title: 'T', description: 'x'.repeat(257) }))).toHaveProperty( + 'failMessage' + ); + }); + it('rejects invalid years', () => { + for (const releaseYear of ['abc', '2020.5', String(ALBUM_EARLIEST_YEAR - 1), '9999']) { + expect(parseAlbumForm(form({ title: 'T', releaseYear }))).toHaveProperty('failMessage'); + } + }); +}); diff --git a/web-player/src/lib/server/greeting.test.ts b/web-player/src/lib/server/greeting.test.ts new file mode 100644 index 0000000..8779eb0 --- /dev/null +++ b/web-player/src/lib/server/greeting.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { pickGreeting } from './greeting'; + +const BIRTHDAY = 'Hoy toca celebrar. Tú eliges la música.'; + +describe('pickGreeting', () => { + it('uses the birthday message over special dates', () => { + const date = new Date(2026, 11, 25, 10); + expect(pickGreeting(date, { month: 12, day: 25 })).toBe(BIRTHDAY); + }); + it('uses the special date message', () => { + expect(pickGreeting(new Date(2026, 11, 25, 10))).toBe('Feliz Navidad. Pon tu banda sonora.'); + }); + it('returns a non-empty schedule message at every hour', () => { + for (let hour = 0; hour < 24; hour++) { + expect(pickGreeting(new Date(2026, 2, 10, hour)).length).toBeGreaterThan(0); + } + }); + it('ignores a birthday on another day', () => { + expect(pickGreeting(new Date(2026, 2, 10, 10), { month: 1, day: 2 })).not.toBe(BIRTHDAY); + }); +}); diff --git a/web-player/src/lib/utils/format.test.ts b/web-player/src/lib/utils/format.test.ts new file mode 100644 index 0000000..106ae7a --- /dev/null +++ b/web-player/src/lib/utils/format.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { albumMeta, fmtDurationLong, fmtTime, plural } from './format'; + +describe('fmtTime', () => { + it('formats minutes and padded seconds', () => { + expect(fmtTime(0)).toBe('0:00'); + expect(fmtTime(65)).toBe('1:05'); + expect(fmtTime(600)).toBe('10:00'); + }); + it('floors fractions and clamps invalid input', () => { + expect(fmtTime(59.9)).toBe('0:59'); + expect(fmtTime(-5)).toBe('0:00'); + expect(fmtTime(NaN)).toBe('0:00'); + }); +}); + +describe('fmtDurationLong', () => { + it('shows minutes only under an hour', () => { + expect(fmtDurationLong(59)).toBe('0 Min'); + expect(fmtDurationLong(125)).toBe('2 Min'); + }); + it('shows hours and minutes', () => { + expect(fmtDurationLong(3600)).toBe('1 H 0 Min'); + expect(fmtDurationLong(3600 * 2 + 60 * 5)).toBe('2 H 5 Min'); + }); +}); + +describe('plural', () => { + it('uses the singular only for 1', () => { + expect(plural(1, 'canción', 'canciones')).toBe('1 canción'); + expect(plural(0, 'canción', 'canciones')).toBe('0 canciones'); + expect(plural(2, 'canción', 'canciones')).toBe('2 canciones'); + }); +}); + +describe('albumMeta', () => { + it('joins year and track count', () => { + expect(albumMeta(2020, 1)).toBe('2020 · 1 canción'); + expect(albumMeta(2020, 12)).toBe('2020 · 12 canciones'); + }); + it('omits missing parts', () => { + expect(albumMeta(undefined, 3)).toBe('3 canciones'); + expect(albumMeta(2020)).toBe('2020'); + expect(albumMeta()).toBe(''); + }); +}); diff --git a/web-player/vite.config.ts b/web-player/vite.config.ts index ed6aabd..6327189 100644 --- a/web-player/vite.config.ts +++ b/web-player/vite.config.ts @@ -1,9 +1,12 @@ import tailwindcss from '@tailwindcss/vite'; import adapter from '@sveltejs/adapter-node'; import { sveltekit } from '@sveltejs/kit/vite'; -import { defineConfig } from 'vite'; +import { defineConfig } from 'vitest/config'; export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + }, server: { port: process.env.PORT ? Number(process.env.PORT) : 5173 }, From 3e546d4a1e312d320c2b9f518cb36de5f5da2e40 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 04:58:04 +0200 Subject: [PATCH 02/34] web-player: fix open redirect via returnTo, require SESSION_SECRET and validate API inputs --- web-player/.env.example | 5 +++-- web-player/src/lib/config.ts | 1 + web-player/src/lib/server/auth.test.ts | 21 +++++++++++++++++++ web-player/src/lib/server/auth.ts | 7 +++++++ web-player/src/lib/server/config.ts | 9 +++++++- web-player/src/lib/server/image.ts | 4 +++- web-player/src/routes/api/likes/+server.ts | 8 +++++-- .../api/listens/[id]/progress/+server.ts | 3 ++- web-player/src/routes/api/tracks/+server.ts | 7 +++++-- web-player/src/routes/auth/+page.server.ts | 6 ++++-- .../src/routes/auth/callback/+server.ts | 3 ++- web-player/src/routes/auth/login/+server.ts | 3 ++- 12 files changed, 64 insertions(+), 13 deletions(-) create mode 100644 web-player/src/lib/server/auth.test.ts diff --git a/web-player/.env.example b/web-player/.env.example index 75b940b..d1f1cca 100644 --- a/web-player/.env.example +++ b/web-player/.env.example @@ -11,8 +11,9 @@ ZITADEL_CLIENT_SECRET=your-web-app-client-secret AUTH_REDIRECT_URI=http://localhost:5173/auth/callback AUTH_POST_LOGOUT_URI=http://localhost:5173/ -# Secret used to derive the session cookie encryption key (any long random string) -SESSION_SECRET=change-me-to-a-long-random-string +# Secret used to derive the session cookie encryption key. Required, at least +# 32 characters (e.g. `openssl rand -base64 48`). +SESSION_SECRET=change-me-to-a-long-random-string-of-32-chars-or-more # Backend API base URL API_BASE_URL=http://localhost:5111 diff --git a/web-player/src/lib/config.ts b/web-player/src/lib/config.ts index 8b94487..1369cf9 100644 --- a/web-player/src/lib/config.ts +++ b/web-player/src/lib/config.ts @@ -1,4 +1,5 @@ export const EXPLORE_PAGE_SIZE = 27; +export const MAX_PAGE_SIZE = 100; export const SEARCH_DEBOUNCE_MS = 450; export const SEARCH_MIN_LENGTH = 2; export const INFINITE_SCROLL_ROOT_MARGIN = '300px'; diff --git a/web-player/src/lib/server/auth.test.ts b/web-player/src/lib/server/auth.test.ts new file mode 100644 index 0000000..2a203ee --- /dev/null +++ b/web-player/src/lib/server/auth.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { safeReturnTo } from './auth'; + +describe('safeReturnTo', () => { + it('keeps local paths', () => { + expect(safeReturnTo('/')).toBe('/'); + expect(safeReturnTo('/playlists/1?tab=x#top')).toBe('/playlists/1?tab=x#top'); + }); + it('falls back to / for missing values', () => { + expect(safeReturnTo(null)).toBe('/'); + expect(safeReturnTo(undefined)).toBe('/'); + expect(safeReturnTo('')).toBe('/'); + }); + it('rejects absolute and protocol-relative URLs', () => { + expect(safeReturnTo('https://example.com')).toBe('/'); + expect(safeReturnTo('//example.com')).toBe('/'); + expect(safeReturnTo('/\\example.com')).toBe('/'); + expect(safeReturnTo('javascript:alert(1)')).toBe('/'); + expect(safeReturnTo('relative/path')).toBe('/'); + }); +}); diff --git a/web-player/src/lib/server/auth.ts b/web-player/src/lib/server/auth.ts index 58f4a7a..7df84c1 100644 --- a/web-player/src/lib/server/auth.ts +++ b/web-player/src/lib/server/auth.ts @@ -18,6 +18,13 @@ export interface Session extends SessionUser { expiresAt: number; } +export function safeReturnTo(value: string | null | undefined): string { + if (!value || !value.startsWith('/') || value.startsWith('//') || value.startsWith('/\\')) { + return '/'; + } + return value; +} + let configPromise: Promise | undefined; export function getOidcConfig(): Promise { configPromise ??= (async () => { diff --git a/web-player/src/lib/server/config.ts b/web-player/src/lib/server/config.ts index 6f1420c..76ec9c6 100644 --- a/web-player/src/lib/server/config.ts +++ b/web-player/src/lib/server/config.ts @@ -1,6 +1,7 @@ import { env } from '$env/dynamic/private'; const DAY_SECONDS = 60 * 60 * 24; +const MIN_SESSION_SECRET_LENGTH = 32; const DEFAULT_OIDC_SCOPE = 'openid profile email offline_access'; function positiveInt(value: string | undefined, fallback: number): number { @@ -35,7 +36,13 @@ export const authConfig = { return required('AUTH_POST_LOGOUT_URI', env.AUTH_POST_LOGOUT_URI); }, get sessionSecret() { - return env.SESSION_SECRET; + const secret = required('SESSION_SECRET', env.SESSION_SECRET); + if (secret.length < MIN_SESSION_SECRET_LENGTH) { + throw new Error( + `SESSION_SECRET must be at least ${MIN_SESSION_SECRET_LENGTH} characters long.` + ); + } + return secret; }, get sessionTtlSeconds() { return positiveInt(env.SESSION_TTL_SECONDS, 7 * DAY_SECONDS); diff --git a/web-player/src/lib/server/image.ts b/web-player/src/lib/server/image.ts index 1f09eca..95d1900 100644 --- a/web-player/src/lib/server/image.ts +++ b/web-player/src/lib/server/image.ts @@ -10,7 +10,9 @@ export async function proxyCoverImage( ): Promise { const safeSize = size && ALLOWED_SIZES.has(size) ? size : 'medium'; - const upstream = await fetch(`${apiConfig.baseUrl}/${kind}/${id}/cover?size=${safeSize}`); + const upstream = await fetch( + `${apiConfig.baseUrl}/${kind}/${encodeURIComponent(id)}/cover?size=${safeSize}` + ); if (!upstream.ok) { return new Response(null, { status: upstream.status }); diff --git a/web-player/src/routes/api/likes/+server.ts b/web-player/src/routes/api/likes/+server.ts index 6faba85..836c30c 100644 --- a/web-player/src/routes/api/likes/+server.ts +++ b/web-player/src/routes/api/likes/+server.ts @@ -5,9 +5,13 @@ import { createApiClient } from '$lib/server/api'; export const POST: RequestHandler = async ({ request, locals, fetch }) => { if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 }); - const body = await request.json(); + const payload = await request.json().catch(() => null); + const trackId = payload?.trackId; + if (typeof trackId !== 'string' || trackId === '') + return json({ message: 'Canción inválida.' }, { status: 400 }); + const api = createApiClient({ fetch, accessToken: locals.accessToken }); - const result = await api.POST('/likes/toggle', { body }); + const result = await api.POST('/likes/toggle', { body: { trackId } }); if (result.error || result.data === undefined) return json({ message: 'No se pudo actualizar Me gusta.' }, { status: 502 }); diff --git a/web-player/src/routes/api/listens/[id]/progress/+server.ts b/web-player/src/routes/api/listens/[id]/progress/+server.ts index 78e4c9b..7cf9e67 100644 --- a/web-player/src/routes/api/listens/[id]/progress/+server.ts +++ b/web-player/src/routes/api/listens/[id]/progress/+server.ts @@ -5,7 +5,8 @@ import { createApiClient } from '$lib/server/api'; export const PUT: RequestHandler = async ({ params, request, locals, fetch }) => { if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 }); - const { playedSeconds } = (await request.json()) as { playedSeconds?: number }; + const payload = (await request.json().catch(() => null)) as { playedSeconds?: unknown } | null; + const playedSeconds = payload?.playedSeconds; if (typeof playedSeconds !== 'number' || !Number.isFinite(playedSeconds) || playedSeconds < 0) return json({ message: 'Tiempo inválido.' }, { status: 400 }); diff --git a/web-player/src/routes/api/tracks/+server.ts b/web-player/src/routes/api/tracks/+server.ts index a460898..786796b 100644 --- a/web-player/src/routes/api/tracks/+server.ts +++ b/web-player/src/routes/api/tracks/+server.ts @@ -1,13 +1,16 @@ import type { RequestHandler } from './$types'; import { json } from '@sveltejs/kit'; import { createApiClient, unwrapOrError } from '$lib/server/api'; -import { EXPLORE_PAGE_SIZE } from '$lib/config'; +import { EXPLORE_PAGE_SIZE, MAX_PAGE_SIZE } from '$lib/config'; export const GET: RequestHandler = async ({ url, locals, fetch }) => { const name = url.searchParams.get('name')?.trim() || undefined; const genre = url.searchParams.get('genre')?.trim() || undefined; const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1); - const pageSize = Math.max(1, Number(url.searchParams.get('pageSize')) || EXPLORE_PAGE_SIZE); + const pageSize = Math.min( + MAX_PAGE_SIZE, + Math.max(1, Number(url.searchParams.get('pageSize')) || EXPLORE_PAGE_SIZE) + ); const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const result = await api.GET('/tracks', { diff --git a/web-player/src/routes/auth/+page.server.ts b/web-player/src/routes/auth/+page.server.ts index 13f45cd..6b37d11 100644 --- a/web-player/src/routes/auth/+page.server.ts +++ b/web-player/src/routes/auth/+page.server.ts @@ -1,9 +1,11 @@ import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; +import { safeReturnTo } from '$lib/server/auth'; export const load: PageServerLoad = ({ locals, url }) => { + const returnTo = safeReturnTo(url.searchParams.get('returnTo')); if (locals.user) { - redirect(302, url.searchParams.get('returnTo') ?? '/'); + redirect(302, returnTo); } - return { returnTo: url.searchParams.get('returnTo') ?? '/' }; + return { returnTo }; }; diff --git a/web-player/src/routes/auth/callback/+server.ts b/web-player/src/routes/auth/callback/+server.ts index ea7c909..97ac6f7 100644 --- a/web-player/src/routes/auth/callback/+server.ts +++ b/web-player/src/routes/auth/callback/+server.ts @@ -5,6 +5,7 @@ import { getOidcConfig, encodeSession, toSessionUser, + safeReturnTo, writeSessionCookie, type Session, STATE_COOKIE, @@ -17,7 +18,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => { const codeVerifier = cookies.get(VERIFIER_COOKIE); const state = cookies.get(STATE_COOKIE); const nonce = cookies.get(NONCE_COOKIE); - const returnTo = cookies.get(RETURN_COOKIE) ?? '/'; + const returnTo = safeReturnTo(cookies.get(RETURN_COOKIE)); if (!codeVerifier || !state) { error(400, 'Estado de autenticación inválido. Vuelve a iniciar sesión.'); diff --git a/web-player/src/routes/auth/login/+server.ts b/web-player/src/routes/auth/login/+server.ts index 45632f5..f56c614 100644 --- a/web-player/src/routes/auth/login/+server.ts +++ b/web-player/src/routes/auth/login/+server.ts @@ -3,6 +3,7 @@ import * as client from 'openid-client'; import type { RequestHandler } from './$types'; import { getOidcConfig, + safeReturnTo, STATE_COOKIE, VERIFIER_COOKIE, NONCE_COOKIE, @@ -19,7 +20,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => { const nonce = client.randomNonce(); const register = url.searchParams.get('mode') === 'register'; - const returnTo = url.searchParams.get('returnTo') ?? '/'; + const returnTo = safeReturnTo(url.searchParams.get('returnTo')); const parameters: Record = { redirect_uri: authConfig.redirectUri, From 906f38140a9d3aa63cf8bd11f533f023428b2ba1 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 04:59:28 +0200 Subject: [PATCH 03/34] web-player: dedupe queue, stop at end of queue, open queue once and harden localStorage --- web-player/src/lib/player/liked.svelte.ts | 5 ++- web-player/src/lib/player/player.svelte.ts | 42 ++++++++++++++++----- web-player/src/lib/player/queue.test.ts | 43 +++++++++++++++++----- web-player/src/lib/player/queue.ts | 33 ++++++++++++++--- web-player/src/lib/theme/mode.svelte.ts | 13 ++----- web-player/src/lib/utils/storage.ts | 15 ++++++++ web-player/src/routes/+layout.svelte | 7 ++-- 7 files changed, 120 insertions(+), 38 deletions(-) create mode 100644 web-player/src/lib/utils/storage.ts diff --git a/web-player/src/lib/player/liked.svelte.ts b/web-player/src/lib/player/liked.svelte.ts index 54a2f00..29e1b8c 100644 --- a/web-player/src/lib/player/liked.svelte.ts +++ b/web-player/src/lib/player/liked.svelte.ts @@ -66,7 +66,10 @@ class LikedStore { }); if (!res.ok) throw new Error('Failed to toggle like'); } catch { - this.entries = before; + const reverted = { ...this.entries }; + if (wasLiked) reverted[id] = before[id]; + else delete reverted[id]; + this.entries = reverted; } } } diff --git a/web-player/src/lib/player/player.svelte.ts b/web-player/src/lib/player/player.svelte.ts index 6b34c37..bb71d35 100644 --- a/web-player/src/lib/player/player.svelte.ts +++ b/web-player/src/lib/player/player.svelte.ts @@ -4,7 +4,8 @@ import { extractAccent, type Accent } from '$lib/theme/palette'; import { shuffle } from '$lib/data/collections'; import { dialog } from '$lib/state/dialog.svelte'; import { ListenTracker } from './listenTracker'; -import { append, insertNext, move, nextIndex, previousIndex } from './queue'; +import { readStorage, writeStorage } from '$lib/utils/storage'; +import { append, insertNext, isLastIndex, move, nextIndex, previousIndex } from './queue'; export interface PlayerTrack { id: string | number; @@ -95,6 +96,20 @@ async function readErrorMessage(res: Response): Promise { } const RECENT_LIMIT = 15; +const DEFAULT_VOLUME = 100; +const VOLUME_KEY = 'musify.player.volume'; +const MUTED_KEY = 'musify.player.muted'; + +function clampVolume(value: number): number { + return Math.min(100, Math.max(0, Math.round(value))); +} + +function loadVolume(): number { + const stored = readStorage(VOLUME_KEY); + if (stored === null || stored.trim() === '') return DEFAULT_VOLUME; + const parsed = Number(stored); + return Number.isFinite(parsed) ? clampVolume(parsed) : DEFAULT_VOLUME; +} const EMPTY: PlayerTrack = { id: '', @@ -120,8 +135,8 @@ class PlayerState { currentId = $state(null); playing = $state(false); progress = $state(0); - volume = $state(browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100); - muted = $state(browser ? localStorage.getItem('player.muted') === 'true' : false); + volume = $state(browser ? loadVolume() : DEFAULT_VOLUME); + muted = $state(browser ? readStorage(MUTED_KEY) === 'true' : false); loading = $state(false); recentlyPlayed = $state([]); playlists = $state([]); @@ -183,6 +198,10 @@ class PlayerState { audio.play().catch(() => {}); return; } + if (!this.shuffle && isLastIndex(this.tracks.length, this.#index())) { + this.#stopAtEnd(audio); + return; + } this.next(); }); audio.addEventListener('error', () => { @@ -200,6 +219,13 @@ class PlayerState { return audio; } + #stopAtEnd(audio: HTMLAudioElement) { + audio.currentTime = 0; + this.progress = 0; + this.playing = false; + this.#syncMediaSessionPlaybackState(); + } + #setupMediaSession(audio: HTMLAudioElement) { if (!('mediaSession' in navigator)) return; navigator.mediaSession.setActionHandler('play', () => audio.play().catch(() => {})); @@ -453,7 +479,7 @@ class PlayerState { this.muted = value; const audio = this.#audioEl(); if (audio) audio.muted = value; - if (browser) localStorage.setItem('player.muted', String(value)); + if (browser) writeStorage(MUTED_KEY, String(value)); } next() { @@ -511,13 +537,11 @@ class PlayerState { } setVolume(value: number) { - this.volume = Math.min(100, Math.max(0, Math.round(value))); + this.volume = Number.isFinite(value) ? clampVolume(value) : this.volume; if (this.muted && this.volume > 0) this.#applyMuted(false); const audio = this.#audioEl(); - if (audio) { - audio.volume = this.volume / 100; - localStorage.setItem('player.volume', String(this.volume)); - } + if (audio) audio.volume = this.volume / 100; + if (browser) writeStorage(VOLUME_KEY, String(this.volume)); } } diff --git a/web-player/src/lib/player/queue.test.ts b/web-player/src/lib/player/queue.test.ts index b6ac598..85b1169 100644 --- a/web-player/src/lib/player/queue.test.ts +++ b/web-player/src/lib/player/queue.test.ts @@ -1,20 +1,38 @@ import { describe, expect, it } from 'vitest'; -import { append, insertNext, move, nextIndex, previousIndex } from './queue'; +import { append, insertNext, isLastIndex, move, nextIndex, previousIndex } from './queue'; + +const t = (id: string) => ({ id }); +const ids = (queue: { id: string | number }[]) => queue.map((item) => item.id); describe('insertNext', () => { - it('inserts right after the given index without mutating', () => { - const queue = ['a', 'b', 'c']; - expect(insertNext(queue, 0, ['x', 'y'])).toEqual(['a', 'x', 'y', 'b', 'c']); - expect(queue).toEqual(['a', 'b', 'c']); + it('inserts right after the current track without mutating', () => { + const queue = ['a', 'b', 'c'].map(t); + expect(ids(insertNext(queue, 0, ['x', 'y'].map(t)))).toEqual(['a', 'x', 'y', 'b', 'c']); + expect(ids(queue)).toEqual(['a', 'b', 'c']); }); - it('inserts at the start when index is -1', () => { - expect(insertNext(['a'], -1, ['x'])).toEqual(['x', 'a']); + it('inserts at the start when there is no current track', () => { + expect(ids(insertNext([t('a')], -1, [t('x')]))).toEqual(['x', 'a']); + }); + it('moves tracks already queued instead of duplicating them', () => { + const queue = ['a', 'b', 'c', 'd'].map(t); + expect(ids(insertNext(queue, 1, [t('d'), t('a')]))).toEqual(['b', 'd', 'a', 'c']); + }); + it('ignores the current track', () => { + const queue = ['a', 'b'].map(t); + expect(ids(insertNext(queue, 0, [t('a')]))).toEqual(['a', 'b']); }); }); describe('append', () => { - it('adds items at the end', () => { - expect(append(['a'], ['b', 'c'])).toEqual(['a', 'b', 'c']); + it('adds new items at the end', () => { + expect(ids(append([t('a')], ['b', 'c'].map(t)))).toEqual(['a', 'b', 'c']); + }); + it('skips tracks already in the queue and repeated ones', () => { + expect(ids(append(['a', 'b'].map(t), ['b', 'c', 'c'].map(t)))).toEqual(['a', 'b', 'c']); + }); + it('returns the same queue when nothing is added', () => { + const queue = [t('a')]; + expect(append(queue, [t('a')])).toBe(queue); }); }); @@ -58,3 +76,10 @@ describe('previousIndex', () => { expect(previousIndex(3, 0)).toBe(2); }); }); + +describe('isLastIndex', () => { + it('detects the end of the queue', () => { + expect(isLastIndex(3, 2)).toBe(true); + expect(isLastIndex(3, 1)).toBe(false); + }); +}); diff --git a/web-player/src/lib/player/queue.ts b/web-player/src/lib/player/queue.ts index cee8b9a..b83c933 100644 --- a/web-player/src/lib/player/queue.ts +++ b/web-player/src/lib/player/queue.ts @@ -1,11 +1,28 @@ -export function insertNext(queue: T[], afterIndex: number, items: T[]): T[] { - const result = [...queue]; - result.splice(afterIndex + 1, 0, ...items); - return result; +interface Identified { + id: string | number; } -export function append(queue: T[], items: T[]): T[] { - return [...queue, ...items]; +export function insertNext( + queue: T[], + currentIndex: number, + items: T[] +): T[] { + const currentId = queue[currentIndex]?.id; + const incoming = items.filter((item) => item.id !== currentId); + const moving = new Set(incoming.map((item) => item.id)); + const rest = queue.filter((item) => !moving.has(item.id)); + const anchor = currentId === undefined ? -1 : rest.findIndex((item) => item.id === currentId); + return [...rest.slice(0, anchor + 1), ...incoming, ...rest.slice(anchor + 1)]; +} + +export function append(queue: T[], items: T[]): T[] { + const present = new Set(queue.map((item) => item.id)); + const added = items.filter((item) => { + if (present.has(item.id)) return false; + present.add(item.id); + return true; + }); + return added.length > 0 ? [...queue, ...added] : queue; } export function move(queue: T[], from: number, insertAt: number): T[] { @@ -18,6 +35,10 @@ export function move(queue: T[], from: number, insertAt: number): T[] { return result; } +export function isLastIndex(length: number, index: number): boolean { + return index >= length - 1; +} + export function nextIndex( length: number, index: number, diff --git a/web-player/src/lib/theme/mode.svelte.ts b/web-player/src/lib/theme/mode.svelte.ts index fc76101..9104034 100644 --- a/web-player/src/lib/theme/mode.svelte.ts +++ b/web-player/src/lib/theme/mode.svelte.ts @@ -1,4 +1,5 @@ import { browser } from '$app/environment'; +import { readStorage, writeStorage } from '$lib/utils/storage'; export type ThemeName = 'dark' | 'light'; @@ -6,11 +7,7 @@ const STORAGE_KEY = 'musify.theme'; function loadInitial(): ThemeName { if (!browser) return 'dark'; - try { - return localStorage.getItem(STORAGE_KEY) === 'light' ? 'light' : 'dark'; - } catch { - return 'dark'; - } + return readStorage(STORAGE_KEY) === 'light' ? 'light' : 'dark'; } function applyTheme(theme: ThemeName) { @@ -29,11 +26,7 @@ class ThemeModeStore { this.current = this.current === 'dark' ? 'light' : 'dark'; applyTheme(this.current); if (!browser) return; - try { - localStorage.setItem(STORAGE_KEY, this.current); - } catch { - /* localStorage unavailable (private mode, quota) — preference just won't persist */ - } + writeStorage(STORAGE_KEY, this.current); } } diff --git a/web-player/src/lib/utils/storage.ts b/web-player/src/lib/utils/storage.ts new file mode 100644 index 0000000..a4a5624 --- /dev/null +++ b/web-player/src/lib/utils/storage.ts @@ -0,0 +1,15 @@ +export function readStorage(key: string, fallback: string | null = null): string | null { + try { + return localStorage.getItem(key) ?? fallback; + } catch { + return fallback; + } +} + +export function writeStorage(key: string, value: string): void { + try { + localStorage.setItem(key, value); + } catch { + /* localStorage unavailable (private mode, quota) — the value just won't persist */ + } +} diff --git a/web-player/src/routes/+layout.svelte b/web-player/src/routes/+layout.svelte index a39df73..71250f5 100644 --- a/web-player/src/routes/+layout.svelte +++ b/web-player/src/routes/+layout.svelte @@ -41,10 +41,11 @@ const initialThemeCss = `:root{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'dark'))}}:root[data-theme='light']{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'light'))}}`; - let wasPlaying = false; + let queueAutoOpened = false; $effect(() => { - if (player.playing && !wasPlaying) queuePanel.show(); - wasPlaying = player.playing; + if (!player.playing || queueAutoOpened) return; + queueAutoOpened = true; + queuePanel.show(); }); function isTypingTarget(target: EventTarget | null): boolean { From dda8c30d459d9d546e46da2583cd9b1a9b011ef7 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 04:59:50 +0200 Subject: [PATCH 04/34] backend: expose owner user id on playlist responses --- .../PlayLists/Responses/PlayListApplicationResponse.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs index 225c5d2..a53c82d 100644 --- a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs +++ b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; +using Musify.Application.Serialization; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; @@ -10,6 +12,7 @@ namespace Musify.Application.PlayLists.Responses string? SmallImageKeyName, string? MediumImageKeyName, string? LargeImageKeyName, + [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId, PlaylistVisibility Visibility, DateTime CreatedAt, DateTime UpdatedAt, @@ -28,6 +31,7 @@ namespace Musify.Application.PlayLists.Responses playList.Pictures?.SmallName, playList.Pictures?.MediumName, playList.Pictures?.LargeName, + playList.UserId, playList.Visibility, playList.CreatedAt, playList.UpdatedAt, From bc43b4b6eb2e61138a790266a31784aeb558a22f Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 05:02:26 +0200 Subject: [PATCH 05/34] web-player: fix playlist ownership, sidebar counts, home spotlight and assorted page bugs --- web-player/src/lib/api/schema.d.ts | 1 + .../src/lib/components/layout/Sidebar.svelte | 13 +- .../components/ui/forms/ImageDropzone.svelte | 7 + .../components/ui/overlay/AccountMenu.svelte | 2 +- .../ui/overlay/ConfirmDialog.svelte | 6 +- web-player/src/lib/config.ts | 2 +- web-player/src/lib/data/albums.ts | 7 + web-player/src/lib/server/playbackConfig.ts | 3 +- .../src/routes/(app)/explore/+page.server.ts | 14 +- .../src/routes/(app)/explore/+page.svelte | 14 +- .../src/routes/(app)/library/+page.svelte | 19 +- .../routes/(app)/mixes/[id]/+page.server.ts | 17 +- .../src/routes/(app)/mixes/[id]/+page.svelte | 2 +- .../(app)/playlists/[id]/+page.server.ts | 6 +- .../routes/(app)/playlists/[id]/+page.svelte | 23 +- .../src/routes/(app)/upload/+page.svelte | 11 +- web-player/src/routes/+error.svelte | 2 +- web-player/src/routes/+layout.server.ts | 21 +- web-player/src/routes/+layout.svelte | 6 +- web-player/src/routes/+page.svelte | 256 +++++++----------- .../api/playlists/[id]/tracks/+server.ts | 16 ++ 21 files changed, 216 insertions(+), 232 deletions(-) create mode 100644 web-player/src/routes/api/playlists/[id]/tracks/+server.ts diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 064a333..863dbff 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -940,6 +940,7 @@ export interface components { smallImageKeyName: null | string; mediumImageKeyName: null | string; largeImageKeyName: null | string; + ownerUserId: string; visibility: components["schemas"]["PlaylistVisibility"]; /** Format: date-time */ createdAt: string; diff --git a/web-player/src/lib/components/layout/Sidebar.svelte b/web-player/src/lib/components/layout/Sidebar.svelte index 7649cf9..dfdc201 100644 --- a/web-player/src/lib/components/layout/Sidebar.svelte +++ b/web-player/src/lib/components/layout/Sidebar.svelte @@ -9,6 +9,8 @@ import Plus from '@lucide/svelte/icons/plus'; import Logo from '../ui/primitives/Logo.svelte'; + const SIDEBAR_PLAYLISTS_LIMIT = 8; + interface SidebarPlaylist { id: string; name: string; @@ -19,11 +21,14 @@ interface Props { user: SessionUser | null; playlists: SidebarPlaylist[]; + playlistCount: number; } - let { user, playlists }: Props = $props(); + let { user, playlists, playlistCount }: Props = $props(); - const navLinks = $derived(appNavLinks(user, { playlists: playlists.length, liked: liked.count })); + const visiblePlaylists = $derived(playlists.slice(0, SIDEBAR_PLAYLISTS_LIMIT)); + + const navLinks = $derived(appNavLinks(user, { playlists: playlistCount, liked: liked.count })); function isActive(href: string) { return isSectionActive(href, page.url.pathname, page.data.section); @@ -61,7 +66,7 @@
Tus playlists - {#if playlists.length > 0} + {#if visiblePlaylists.length > 0}
- {#each playlists as playlist (playlist.id)} + {#each visiblePlaylists as playlist (playlist.id)} {@const active = page.url.pathname === `/playlists/${playlist.id}`} releasePreview); @@ -462,7 +462,7 @@ fields: { trackId: String(trackMenu.state.track.id) }, label: 'Añadir a una playlist' }} - playlists={data.playlists} + playlists={data.userPlaylists} /> {/if} @@ -481,6 +481,6 @@ fields: { albumId: albumMenu.albumId }, label: 'Añadir álbum a una playlist' }} - playlists={data.playlists} + playlists={data.userPlaylists} /> {/if} diff --git a/web-player/src/routes/(app)/library/+page.svelte b/web-player/src/routes/(app)/library/+page.svelte index 1dc9858..fcd1705 100644 --- a/web-player/src/routes/(app)/library/+page.svelte +++ b/web-player/src/routes/(app)/library/+page.svelte @@ -8,6 +8,7 @@ import PageHeader from '$lib/components/ui/layout/PageHeader.svelte'; import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte'; import Button from '$lib/components/ui/primitives/Button.svelte'; + import ConfirmDialog from '$lib/components/ui/overlay/ConfirmDialog.svelte'; import TrackList from '$lib/components/ui/media/TrackList.svelte'; let { data } = $props(); @@ -16,6 +17,8 @@ const total = $derived(Number(data.tracks.totalItemCount)); const listTracks = $derived(items.map((track) => ({ ...track, uploadedAt: track.createdAt }))); + let deleting = $state<(typeof items)[number] | null>(null); + function togglePlay(index: number) { player.playOrToggle(toQueueItems(items), index); } @@ -46,7 +49,11 @@ tracks={listTracks} columns={['uploaded', 'plays']} onPlay={togglePlay} - rowAction={{ action: '?/deleteTrack', icon: X, label: 'Borrar canción' }} + rowAction={{ + onclick: (track) => (deleting = items.find((item) => item.id === track.id) ?? null), + icon: X, + label: 'Borrar canción' + }} /> {:else} {/if} + + (deleting = null)} + title="¿Borrar «{deleting?.title ?? ''}»?" + description="Esta acción no se puede deshacer, la canción se eliminará de tu biblioteca, de tus álbumes y de las playlists donde esté." + confirmLabel="Borrar" + action="?/deleteTrack" + fields={{ trackId: deleting?.id ?? '' }} +/> diff --git a/web-player/src/routes/(app)/mixes/[id]/+page.server.ts b/web-player/src/routes/(app)/mixes/[id]/+page.server.ts index 5312412..51ffa46 100644 --- a/web-player/src/routes/(app)/mixes/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/mixes/[id]/+page.server.ts @@ -1,25 +1,14 @@ import type { PageServerLoad, Actions } from './$types'; import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api'; import { addTrackAction } from '$lib/server/playlistActions'; -import { PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config'; export const load: PageServerLoad = async ({ params, locals, url, fetch }) => { - const user = requireUser(locals, url); + requireUser(locals, url); const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); - const [mixRes, playlistsRes] = await Promise.all([ - api.GET('/mixes/{id}', { params: { path: { id: params.id } } }), - api.GET('/playlists/users/{userId}', { - params: { - path: { userId: user.sub }, - query: { pageNumber: 1, pageSize: PLAYLIST_PICKER_PAGE_SIZE } - } - }) - ]); + const mixRes = await api.GET('/mixes/{id}', { params: { path: { id: params.id } } }); - const mix = unwrapOrError(mixRes, 'Mezcla no encontrada.', 404); - - return { mix, playlists: playlistsRes.data?.items ?? [] }; + return { mix: unwrapOrError(mixRes, 'Mezcla no encontrada.', 404) }; }; export const actions: Actions = { diff --git a/web-player/src/routes/(app)/mixes/[id]/+page.svelte b/web-player/src/routes/(app)/mixes/[id]/+page.svelte index 6f1c7a2..92ee433 100644 --- a/web-player/src/routes/(app)/mixes/[id]/+page.svelte +++ b/web-player/src/routes/(app)/mixes/[id]/+page.svelte @@ -116,6 +116,6 @@ fields: { trackId: String(trackMenu.state.track.id) }, label: 'Añadir a una playlist' }} - playlists={data.playlists} + playlists={data.userPlaylists} /> {/if} diff --git a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts index fd32656..e119baa 100644 --- a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts @@ -13,7 +13,7 @@ const MAX_NAME = 100; export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => { const { allowAnonymousListening } = await parent(); - optionalUser(locals, url, allowAnonymousListening); + const user = optionalUser(locals, url, allowAnonymousListening); const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const [playlistRes, tracksRes] = await Promise.all([ @@ -26,7 +26,9 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent const playlist = unwrapOrError(playlistRes, 'Playlist no encontrada.', 404); const tracks = tracksRes.data?.items ?? []; - return { playlist, tracks }; + const isOwner = user !== null && String(playlist.ownerUserId) === user.sub; + + return { playlist, tracks, isOwner, section: isOwner ? '/playlists' : null }; }; export const actions: Actions = { diff --git a/web-player/src/routes/(app)/playlists/[id]/+page.svelte b/web-player/src/routes/(app)/playlists/[id]/+page.svelte index a466830..c59b5b7 100644 --- a/web-player/src/routes/(app)/playlists/[id]/+page.svelte +++ b/web-player/src/routes/(app)/playlists/[id]/+page.svelte @@ -9,6 +9,7 @@ import Artwork from '$lib/components/ui/media/Artwork.svelte'; import PlaylistForm from '$lib/components/ui/forms/PlaylistForm.svelte'; import Button from '$lib/components/ui/primitives/Button.svelte'; + import BackLink from '$lib/components/ui/primitives/BackLink.svelte'; import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte'; import TrackList from '$lib/components/ui/media/TrackList.svelte'; import { playlistMeta } from '$lib/utils/format'; @@ -17,6 +18,7 @@ const playlist = $derived(data.playlist); const tracks = $derived(data.tracks); + const isOwner = $derived(data.isOwner); const listTracks = $derived(tracks.map((track) => ({ ...track, addedAt: track.createdAt }))); let editing = $state(false); @@ -39,8 +41,13 @@ + + (editing = true)}>Editar - + {#if isOwner} + + + {/if} {/snippet} @@ -79,7 +88,9 @@ tracks={listTracks} columns={['added', 'plays']} onPlay={playFrom} - rowAction={{ action: '?/removeTrack', icon: X, label: 'Quitar de la playlist' }} + rowAction={isOwner + ? { action: '?/removeTrack', icon: X, label: 'Quitar de la playlist' } + : undefined} /> {:else} - Acepto los - - términos y condiciones - - al subir {title.trim() !== '' ? `«${title}»` : 'esta canción'}. + Acepto los términos y condiciones al subir {title.trim() !== '' + ? `«${title}»` + : 'esta canción'}. {#if status === 'uploading'} diff --git a/web-player/src/routes/+error.svelte b/web-player/src/routes/+error.svelte index 6597497..80a31da 100644 --- a/web-player/src/routes/+error.svelte +++ b/web-player/src/routes/+error.svelte @@ -10,7 +10,7 @@

{page.status === 404 ? 'Comprueba el enlace o vuelve al inicio.' - : 'La conexión se interrumpió. Lo que tengas descargado seguirá disponible sin conexión.'} + : 'Algo salió mal al cargar la página. Inténtalo de nuevo en unos momentos.'}

diff --git a/web-player/src/routes/+layout.server.ts b/web-player/src/routes/+layout.server.ts index caf325d..944581e 100644 --- a/web-player/src/routes/+layout.server.ts +++ b/web-player/src/routes/+layout.server.ts @@ -3,12 +3,11 @@ import type { LayoutServerLoad } from './$types'; import { getAllowAnonymousListening } from '$lib/server/playbackConfig'; import { createApiClient } from '$lib/server/api'; import { authConfig } from '$lib/server/config'; -import { LIKED_TRACKS_PAGE_SIZE } from '$lib/config'; +import { LIKED_TRACKS_PAGE_SIZE, PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config'; const PUBLIC_PATHS = new Set(['/auth']); -const SIDEBAR_PLAYLISTS_LIMIT = 8; -async function fetchSidebarPlaylists( +async function fetchUserPlaylists( fetchFn: typeof fetch, accessToken: string | null, userId: string @@ -18,12 +17,12 @@ async function fetchSidebarPlaylists( const { data } = await api.GET('/playlists/users/{userId}', { params: { path: { userId }, - query: { pageNumber: 1, pageSize: SIDEBAR_PLAYLISTS_LIMIT } + query: { pageNumber: 1, pageSize: PLAYLIST_PICKER_PAGE_SIZE } } }); - return data?.items ?? []; + return { items: data?.items ?? [], total: Number(data?.totalItemCount ?? 0) }; } catch { - return []; + return { items: [], total: 0 }; } } @@ -70,15 +69,16 @@ export const load: LayoutServerLoad = async ({ locals, url, fetch }) => { return { user: locals.user, allowAnonymousListening, - sidebarPlaylists: [], + userPlaylists: [], + userPlaylistsTotal: 0, likedTracks: [], lastPlayedTrack: null, accountUrl }; } - const [sidebarPlaylists, likedTracks, lastPlayedTrack] = await Promise.all([ - fetchSidebarPlaylists(fetch, locals.accessToken, locals.user.sub), + const [userPlaylists, likedTracks, lastPlayedTrack] = await Promise.all([ + fetchUserPlaylists(fetch, locals.accessToken, locals.user.sub), fetchLikedTracks(fetch, locals.accessToken), fetchLastPlayedTrack(fetch, locals.accessToken, locals.user.sub) ]); @@ -86,7 +86,8 @@ export const load: LayoutServerLoad = async ({ locals, url, fetch }) => { return { user: locals.user, allowAnonymousListening, - sidebarPlaylists, + userPlaylists: userPlaylists.items, + userPlaylistsTotal: userPlaylists.total, likedTracks, lastPlayedTrack, accountUrl diff --git a/web-player/src/routes/+layout.svelte b/web-player/src/routes/+layout.svelte index 71250f5..f842c89 100644 --- a/web-player/src/routes/+layout.svelte +++ b/web-player/src/routes/+layout.svelte @@ -108,7 +108,11 @@
- +
diff --git a/web-player/src/routes/+page.svelte b/web-player/src/routes/+page.svelte index 906c6e6..56aedbb 100644 --- a/web-player/src/routes/+page.svelte +++ b/web-player/src/routes/+page.svelte @@ -9,17 +9,18 @@ playAllOrToggle, playShuffled, toQueueItem, + toQueueItems, trackFromQueueItem, - type ApiTrackLike + type ApiTrackLike, + type QueueItem } from '$lib/player/player.svelte'; - import { pressable } from '$lib/actions/pressable'; import { mergeRecentlyPlayed } from '$lib/data/recentlyPlayed'; import { mixItemTrack } from '$lib/data/mixes'; - import { fmtTime, fmtDurationLong, fmtPlays, plural } from '$lib/utils/format'; + import { fetchPlaylistQueueItems } from '$lib/data/albums'; + import { fmtTime, fmtDurationLong, plural } from '$lib/utils/format'; import Page from '$lib/components/ui/layout/Page.svelte'; import Artwork from '$lib/components/ui/media/Artwork.svelte'; import MediaCard from '$lib/components/ui/media/MediaCard.svelte'; - import EqBars from '$lib/components/ui/media/EqBars.svelte'; import ListRow from '$lib/components/ui/media/ListRow.svelte'; import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte'; import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte'; @@ -27,80 +28,35 @@ import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte'; import ListPlus from '@lucide/svelte/icons/list-plus'; import { createTrackMenu } from '$lib/state/menus.svelte'; - import { HOME_CONTINUE_LIMIT, HOME_POPULAR_MAX } from '$lib/config'; + import { HOME_CONTINUE_LIMIT } from '$lib/config'; let { data } = $props(); const trackMenu = createTrackMenu(); - type SpotlightTrack = Awaited['items'][number]; - type SpotlightRow = { track: SpotlightTrack; seconds: number }; - let spotlightRows = $state([]); - let spotlightTotal = $state(0); - - $effect(() => { - spotlightRows = []; - spotlightTotal = 0; - let cancelled = false; - data.spotlightTracks.then(({ items, totalCount }) => { - if (cancelled) return; - spotlightRows = items.map((track) => ({ - track, - seconds: Number(track.duration) - })); - spotlightTotal = totalCount; - }); - return () => { - cancelled = true; - }; - }); - const mixes = $derived(data.mixes); const playlists = $derived(data.playlists); const spotlight = $derived(data.spotlightPlaylist); - const spotlightItems = $derived(spotlightRows.map((row) => row.track)); - const spotlightDurationSeconds = $derived( - spotlightRows.reduce((sum, row) => sum + row.seconds, 0) - ); - const spotlightTrackCount = $derived(spotlightTotal || spotlightItems.length); - const spotlightVisibilityLabel = $derived( - spotlight?.visibility === 'Public' ? 'Pública' : 'Privada' - ); - const spotlightMeta = $derived( - spotlightTrackCount === 0 - ? spotlightVisibilityLabel - : [ - plural(spotlightTrackCount, 'canción', 'canciones'), - fmtDurationLong(spotlightDurationSeconds), - spotlightVisibilityLabel - ].join(' · ') - ); const continueItems = $derived(mergeRecentlyPlayed(data.recentlyPlayed, HOME_CONTINUE_LIMIT)); - const recentPool = $derived(mergeRecentlyPlayed(data.recentlyPlayed, 24)); - - const popularTracks = $derived( - recentPool.map(trackFromQueueItem).slice(0, HOME_POPULAR_MAX) - ); - - const spotlightQueue = $derived(spotlightItems.map(toQueueItem)); - const spotlightIsCurrent = $derived(isQueueCurrent(spotlightQueue)); - const heroPlaying = $derived(spotlightIsCurrent && player.playing); function playContinue(index: number) { player.playOrToggle(continueItems, index); } - function playPopular(index: number) { - player.playOrToggle(popularTracks.map(toQueueItem), index); + function playSpotlightTrack(items: QueueItem[], index: number) { + player.playOrToggle(items, index); } - function playSpotlightTrack(index: number) { - player.playOrToggle(spotlightQueue, index); + async function playSpotlight(previewItems: QueueItem[]) { + if (!spotlight) return; + if (isQueueCurrent(previewItems)) { + player.toggle(); + return; + } + playAllOrToggle(await fetchPlaylistQueueItems(spotlight.id)); } - function playSpotlight() { - playAllOrToggle(spotlightQueue); - } - function shuffleSpotlight() { - playShuffled(spotlightQueue); + async function shuffleSpotlight() { + if (!spotlight) return; + playShuffled(await fetchPlaylistQueueItems(spotlight.id)); } function openContextMenu(event: MouseEvent, track: ApiTrackLike) { @@ -149,10 +105,13 @@

{#if spotlight} - + {#await data.spotlightTracks then { items }} + {@const previewItems = toQueueItems(items)} + + {/await} {/if}
@@ -181,7 +140,7 @@
- + {#if continueItems.length > 0}
{#each continueItems as item, i (item.id)} @@ -209,66 +168,76 @@ {#if spotlight}
-
-
-
goto(`/playlists/${spotlight.id}`)} - class="flex w-fit items-center gap-5 self-start sm:gap-6" - > - -
-

Playlist destacada

-

- {spotlight.name} -

-

{spotlightMeta}

+ {#await data.spotlightTracks then { items, totalCount }} + {@const previewItems = toQueueItems(items)} + {@const visibilityLabel = spotlight.visibility === 'Public' ? 'Pública' : 'Privada'} + {@const meta = + totalCount === 0 + ? visibilityLabel + : `${plural(totalCount, 'canción', 'canciones')} · ${visibilityLabel}`} +
+
+ + +
+

Playlist destacada

+

+ {spotlight.name} +

+

{meta}

+
+
+

+ {spotlight.description || 'Tu colección, siempre a mano.'} +

+
+ +
-

- {spotlight.description || 'Tu colección, siempre a mano.'} -

-
- - +
+ {#each items as track, i (track.id)} + playSpotlightTrack(previewItems, i)} + active={player.current.id === track.id} + size="sm" + title={track.title} + subtitle={track.artist} + subtitleHref={track.ownerUserId} + explicit={track.isExplicit} + > + {#snippet leading()} + {i + 1} + {/snippet} + {#snippet trailing()} + {fmtTime(Number(track.duration))} + {/snippet} + + {:else} +

Esta playlist todavía no tiene canciones.

+ {/each}
-
- {#each spotlightRows as { track, seconds }, i (track.id)} - playSpotlightTrack(i)} - active={player.current.id === track.id} - size="sm" - title={track.title} - subtitle={track.artist} - subtitleHref={track.ownerUserId} - explicit={track.isExplicit} - > - {#snippet leading()} - {i + 1} - {/snippet} - {#snippet trailing()} - {fmtTime(seconds)} - {/snippet} - - {:else} -

Esta playlist todavía no tiene canciones.

- {/each} -
-
+ {/await}
{/if} @@ -296,45 +265,6 @@ {/if}
-
- - {#if popularTracks.length > 0} -
- {#each popularTracks as track, i (track.id)} - {@const isCurrent = player.current.id === track.id} - playPopular(i)} - oncontextmenu={(e) => openContextMenu(e, track)} - active={isCurrent} - size="sm" - title={track.title} - subtitle={track.artist} - subtitleHref={track.ownerUserId} - explicit={track.isExplicit} - trackId={track.id} - > - {#snippet leading()} - - {#if isCurrent} - - {:else} - {i + 1} - {/if} - - {/snippet} - {#snippet trailing()} - - {/snippet} - - {/each} -
- {:else} - {@render noListeningHistory()} - {/if} -
-
{/if} diff --git a/web-player/src/routes/api/playlists/[id]/tracks/+server.ts b/web-player/src/routes/api/playlists/[id]/tracks/+server.ts new file mode 100644 index 0000000..3f8618d --- /dev/null +++ b/web-player/src/routes/api/playlists/[id]/tracks/+server.ts @@ -0,0 +1,16 @@ +import type { RequestHandler } from './$types'; +import { json } from '@sveltejs/kit'; +import { createApiClient, unwrapOrError } from '$lib/server/api'; +import { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config'; + +export const GET: RequestHandler = async ({ params, locals, fetch }) => { + const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + const result = await api.GET('/playlists/{playlistId}/tracks', { + params: { + path: { playlistId: params.id }, + query: { pageNumber: 1, pageSize: PLAYLIST_TRACKS_PAGE_SIZE } + } + }); + + return json(unwrapOrError(result, 'No se pudo cargar la playlist.')); +}; From 37fc229bf60798d0f460d1eda5e37a021598811b Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 05:04:09 +0200 Subject: [PATCH 06/34] web-player: remove dead code and unused props --- web-player/CLAUDE.md | 2 +- .../src/lib/components/layout/Sidebar.svelte | 4 +-- .../src/lib/components/layout/TopBar.svelte | 13 ++----- .../lib/components/player/PlayerBar.svelte | 6 ++-- .../src/lib/components/player/Queue.svelte | 2 +- .../lib/components/player/TrackInfo.svelte | 2 +- .../lib/components/ui/forms/CoverForm.svelte | 4 +-- .../lib/components/ui/media/ArtistLink.svelte | 2 +- .../lib/components/ui/media/Artwork.svelte | 8 +---- .../src/lib/components/ui/media/EqBars.svelte | 16 +++------ .../components/ui/media/FollowButton.svelte | 6 +--- .../components/ui/media/MediaIdentity.svelte | 5 +-- .../lib/components/ui/media/TrackList.svelte | 10 +----- .../ui/primitives/IconButton.svelte | 14 +++----- .../ui/primitives/SegmentedControl.svelte | 2 +- web-player/src/lib/player/player.svelte.ts | 36 +++---------------- web-player/src/lib/server/auth.ts | 2 +- web-player/src/lib/state/menus.svelte.ts | 2 +- web-player/src/lib/theme/palette.ts | 14 ++------ .../src/routes/(app)/explore/+page.server.ts | 5 +-- .../[id]/[list=followList]/+page.server.ts | 8 +---- .../user/[id]/[list=followList]/+page.svelte | 7 +--- web-player/src/routes/+layout.server.ts | 4 +-- web-player/src/routes/layout.css | 12 ------- 24 files changed, 37 insertions(+), 149 deletions(-) diff --git a/web-player/CLAUDE.md b/web-player/CLAUDE.md index 444ea62..4b8700d 100644 --- a/web-player/CLAUDE.md +++ b/web-player/CLAUDE.md @@ -50,7 +50,7 @@ The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utili - **Radii:** always `rounded-tag/thumb/control/art/art-lg/panel/panel-lg` (or `rounded-full` for pills and circles). Never `rounded-sm/md/lg/xl/2xl/3xl` or a bare `rounded`. - **Square sizes:** use `size-*` instead of repeating `h-N w-N` (icons: `size-icon-xs/sm/md/lg/xl`; covers: `size-cover-xs` through `size-cover-hero`). -- **Typography:** use the recipes `text-display-1/2/3/4`, `text-eyebrow`, `text-body`, `text-count`, with tracking through `tracking-display`/`tracking-eyebrow` rather than a loose `tracking-[…]`. +- **Typography:** use the recipes `text-display-1/2/3/4`, `text-eyebrow`, `text-body`, with tracking through `tracking-display`/`tracking-eyebrow` rather than a loose `tracking-[…]`. - **Spacing:** stick to the Tailwind scale in `.5` steps and avoid quarters like `.25`/`.75`, unless the semantic token calls for it (for example `mb-4.5` in `SectionHeading`). - **Colors:** always use `--mf-*`/`text-*`/`bg-*`/`border-*` from `theme.css`/`tokens.ts`. Never a literal hex or `rgba()` inside a `.svelte` file; those literals only belong in `theme.css`/`tokens.ts` as the source of truth. - **Inline `style="…"` and arbitrary `-[…]` values:** only the ones whitelisted in `scripts/check-tokens.mjs` (the stagger variable `--i`, tile hue, menu position, progress width, `text-[clamp(…)]`, `leading-[…]` on login's display type, `grid-cols-[auto_1fr]`, `max-h-[85dvh]`, `my-`/`py-[Ndvh]` for vertical centering, `transition-[filter]` on genre tiles, and `bg-[image:var(--mf-*)]`). Everything else should become a token. diff --git a/web-player/src/lib/components/layout/Sidebar.svelte b/web-player/src/lib/components/layout/Sidebar.svelte index dfdc201..144970c 100644 --- a/web-player/src/lib/components/layout/Sidebar.svelte +++ b/web-player/src/lib/components/layout/Sidebar.svelte @@ -69,7 +69,7 @@ {#if visiblePlaylists.length > 0} createPlaylistModal.show()} > @@ -96,7 +96,7 @@
diff --git a/web-player/src/lib/components/ui/media/EqBars.svelte b/web-player/src/lib/components/ui/media/EqBars.svelte index 8c76e0b..cb9d2e5 100644 --- a/web-player/src/lib/components/ui/media/EqBars.svelte +++ b/web-player/src/lib/components/ui/media/EqBars.svelte @@ -1,19 +1,11 @@ @@ -30,11 +22,11 @@ {/each}
{:else} -
+
{#each delays as delay (delay)} diff --git a/web-player/src/lib/components/ui/media/FollowButton.svelte b/web-player/src/lib/components/ui/media/FollowButton.svelte index e477343..0132418 100644 --- a/web-player/src/lib/components/ui/media/FollowButton.svelte +++ b/web-player/src/lib/components/ui/media/FollowButton.svelte @@ -5,12 +5,11 @@ interface Props { following: boolean; - userId?: string; invalidateAll?: boolean; class?: string; } - let { following: initial, userId, invalidateAll = false, class: klass = '' }: Props = $props(); + let { following: initial, invalidateAll = false, class: klass = '' }: Props = $props(); let following = $state(untrack(() => initial)); let submitting = $state(false); @@ -30,9 +29,6 @@ }; }} > - {#if userId} - - {/if}
diff --git a/web-player/src/lib/components/ui/media/TrackList.svelte b/web-player/src/lib/components/ui/media/TrackList.svelte index 24d53c5..11e5461 100644 --- a/web-player/src/lib/components/ui/media/TrackList.svelte +++ b/web-player/src/lib/components/ui/media/TrackList.svelte @@ -138,8 +138,6 @@ role="button" tabindex="0" use:pressable={() => onPlay(i)} - onclick={(event) => event.stopPropagation()} - onkeydown={(event) => event.stopPropagation()} class="flex min-w-0 flex-1 text-left" > {#if rowAction} {#if rowAction.action} -
- async ({ update }) => - update()} - > + diff --git a/web-player/src/lib/components/ui/primitives/IconButton.svelte b/web-player/src/lib/components/ui/primitives/IconButton.svelte index c3d9be5..f4883bf 100644 --- a/web-player/src/lib/components/ui/primitives/IconButton.svelte +++ b/web-player/src/lib/components/ui/primitives/IconButton.svelte @@ -6,7 +6,7 @@ type?: 'button' | 'submit'; size?: 'xs' | 'sm' | 'md' | 'lg'; shape?: 'square' | 'round'; - tone?: 'muted' | 'subtle' | 'plain'; + plain?: boolean; pressed?: boolean; surface?: boolean; revealOnHover?: boolean; @@ -22,7 +22,7 @@ type = 'button', size = 'sm', shape = 'square', - tone = 'muted', + plain = false, pressed, surface = false, revealOnHover = false, @@ -40,18 +40,12 @@ lg: 'size-10' }; - const TEXT: Record<'muted' | 'subtle' | 'plain', string> = { - muted: 'text-fg-2 hover:text-fg', - subtle: 'text-fg-2 hover:text-fg', - plain: 'text-fg-2 hover:text-fg' - }; - const background = $derived( - surface ? 'bg-surface-2 hover:bg-surface-hover' : tone === 'plain' ? '' : 'hover:bg-hover' + surface ? 'bg-surface-2 hover:bg-surface-hover' : plain ? '' : 'hover:bg-hover' ); const classes = $derived( - `grid place-items-center transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none ${SIZE[size]} ${shape === 'round' ? 'rounded-full' : 'rounded-control'} ${pressed ? 'text-accent' : TEXT[tone]} ${background} ${ + `grid place-items-center transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none ${SIZE[size]} ${shape === 'round' ? 'rounded-full' : 'rounded-control'} ${pressed ? 'text-accent' : 'text-fg-2 hover:text-fg'} ${background} ${ revealOnHover ? 'sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100' : '' } ${klass}` ); diff --git a/web-player/src/lib/components/ui/primitives/SegmentedControl.svelte b/web-player/src/lib/components/ui/primitives/SegmentedControl.svelte index 7625ebd..44004e4 100644 --- a/web-player/src/lib/components/ui/primitives/SegmentedControl.svelte +++ b/web-player/src/lib/components/ui/primitives/SegmentedControl.svelte @@ -26,7 +26,7 @@ class="relative z-(--z-raised) flex-1 px-4 py-1.5 text-center text-xs font-medium transition-colors {value === option.value ? 'text-ink' - : 'text-fg-2 hover:text-fg-2'}" + : 'text-fg-2'}" > {option.label} diff --git a/web-player/src/lib/player/player.svelte.ts b/web-player/src/lib/player/player.svelte.ts index bb71d35..8869725 100644 --- a/web-player/src/lib/player/player.svelte.ts +++ b/web-player/src/lib/player/player.svelte.ts @@ -1,6 +1,6 @@ import { browser } from '$app/environment'; import { DEFAULT_ACCENT } from '$lib/theme/color'; -import { extractAccent, type Accent } from '$lib/theme/palette'; +import { extractAccent } from '$lib/theme/palette'; import { shuffle } from '$lib/data/collections'; import { dialog } from '$lib/state/dialog.svelte'; import { ListenTracker } from './listenTracker'; @@ -17,12 +17,6 @@ export interface PlayerTrack { listensCount?: number | string; } -export interface Playlist { - id: string | number; - name: string; - trackIds: (string | number)[]; -} - export interface QueueItem { id: string | number; title: string; @@ -139,7 +133,6 @@ class PlayerState { muted = $state(browser ? readStorage(MUTED_KEY) === 'true' : false); loading = $state(false); recentlyPlayed = $state([]); - playlists = $state([]); shuffle = $state(false); repeat = $state(false); @@ -147,7 +140,7 @@ class PlayerState { #audio: HTMLAudioElement | null = null; #loadToken = 0; - #accentCache = new Map(); + #accentCache = new Map(); #rafId: number | null = null; #listen = new ListenTracker(); @@ -157,10 +150,6 @@ class PlayerState { this.current.duration > 0 ? Math.min(100, (this.progress / this.current.duration) * 100) : 0 ); - get isPlaying() { - return this.playing; - } - #audioEl(): HTMLAudioElement | null { if (!browser) return null; if (this.#audio) return this.#audio; @@ -301,14 +290,14 @@ class PlayerState { const key = String(id); const cached = this.#accentCache.get(key); if (cached) { - this.accentColor = cached.accent; + this.accentColor = cached; return; } const result = await extractAccent(`/api/tracks/${id}/cover?size=small`); if (this.currentId !== id) return; if (result) { this.#accentCache.set(key, result); - this.accentColor = result.accent; + this.accentColor = result; } else { this.accentColor = null; } @@ -395,12 +384,6 @@ class PlayerState { this.#syncMediaSessionMetadata(track); } - playlistTracks(ids: (string | number)[]): PlayerTrack[] { - return ids - .map((id) => this.tracks.find((t) => t.id === id)) - .filter((t): t is PlayerTrack => Boolean(t)); - } - playQueue(list: QueueItem[], startIndex = 0) { if (list.length === 0) return; this.tracks = list.map(toTrack); @@ -415,10 +398,6 @@ class PlayerState { else this.playQueue(list, index); } - playNextItem(item: QueueItem) { - this.playNext([item]); - } - playNext(items: QueueItem[]) { if (items.length === 0) return; if (this.currentId === null) { @@ -452,13 +431,6 @@ class PlayerState { this.#loadCurrent(); } - playPlaylist(id: string | number) { - const playlist = this.playlists.find((p) => p.id === id); - if (!playlist) return; - const items = this.playlistTracks(playlist.trackIds); - if (items.length > 0) this.playQueue(items, 0); - } - toggle() { const audio = this.#audioEl(); if (!audio || this.currentId === null) return; diff --git a/web-player/src/lib/server/auth.ts b/web-player/src/lib/server/auth.ts index 7df84c1..b17178e 100644 --- a/web-player/src/lib/server/auth.ts +++ b/web-player/src/lib/server/auth.ts @@ -51,7 +51,7 @@ function sessionKey(): Promise { return keyPromise; } -export function sessionCookieOptions(url: URL) { +function sessionCookieOptions(url: URL) { return { httpOnly: true, sameSite: 'lax' as const, diff --git a/web-player/src/lib/state/menus.svelte.ts b/web-player/src/lib/state/menus.svelte.ts index af9000f..5a34e31 100644 --- a/web-player/src/lib/state/menus.svelte.ts +++ b/web-player/src/lib/state/menus.svelte.ts @@ -21,7 +21,7 @@ export function createTrackMenu() { }, playNext() { if (!state) return; - player.playNextItem(toQueueItem(state.track)); + player.playNext([toQueueItem(state.track)]); state = null; } }; diff --git a/web-player/src/lib/theme/palette.ts b/web-player/src/lib/theme/palette.ts index b3a7381..39c1f00 100644 --- a/web-player/src/lib/theme/palette.ts +++ b/web-player/src/lib/theme/palette.ts @@ -1,11 +1,6 @@ import { browser } from '$app/environment'; import { ACCENT_LIGHTNESS } from './color'; -export interface Accent { - accent: string; - gradient: string; -} - function srgbToLinear(channel: number): number { const c = channel / 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); @@ -45,7 +40,7 @@ function loadImage(url: string): Promise { const BUCKETS = 36; -export async function extractAccent(url: string): Promise { +export async function extractAccent(url: string): Promise { if (!browser) return null; let img: HTMLImageElement; @@ -104,11 +99,6 @@ export async function extractAccent(url: string): Promise { const h = hue.toFixed(1); const c = chroma.toFixed(3); - const c2 = (chroma * 0.85).toFixed(3); - const h2 = ((hue + 25) % 360).toFixed(1); - return { - accent: `oklch(${ACCENT_LIGHTNESS}% ${c} ${h})`, - gradient: `linear-gradient(135deg, oklch(74% ${c} ${h}), oklch(56% ${c2} ${h2}))` - }; + return `oklch(${ACCENT_LIGHTNESS}% ${c} ${h})`; } diff --git a/web-player/src/routes/(app)/explore/+page.server.ts b/web-player/src/routes/(app)/explore/+page.server.ts index d7904c4..ee7609c 100644 --- a/web-player/src/routes/(app)/explore/+page.server.ts +++ b/web-player/src/routes/(app)/explore/+page.server.ts @@ -1,7 +1,6 @@ import type { PageServerLoad, Actions } from './$types'; import { createApiClient, unwrapOrError } from '$lib/server/api'; import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions'; -import { followUserAction, unfollowUserAction } from '$lib/server/followActions'; import { EXPLORE_ALBUMS_PAGE_SIZE, EXPLORE_PAGE_SIZE as PAGE_SIZE, @@ -66,7 +65,5 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => { export const actions: Actions = { addTrack: addTrackAction, - addAlbumToPlaylist: addAlbumToPlaylistAction, - follow: followUserAction, - unfollow: unfollowUserAction + addAlbumToPlaylist: addAlbumToPlaylistAction }; diff --git a/web-player/src/routes/(app)/user/[id]/[list=followList]/+page.server.ts b/web-player/src/routes/(app)/user/[id]/[list=followList]/+page.server.ts index 66dd166..82c6eb7 100644 --- a/web-player/src/routes/(app)/user/[id]/[list=followList]/+page.server.ts +++ b/web-player/src/routes/(app)/user/[id]/[list=followList]/+page.server.ts @@ -1,7 +1,6 @@ -import type { PageServerLoad, Actions } from './$types'; +import type { PageServerLoad } from './$types'; import { createApiClient, optionalUser, unwrapOrError } from '$lib/server/api'; import { fetchFollowList } from '$lib/server/follows'; -import { followUserAction, unfollowUserAction } from '$lib/server/followActions'; import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config'; export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => { @@ -25,8 +24,3 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent viewerId: viewer?.sub ?? null }; }; - -export const actions: Actions = { - follow: followUserAction, - unfollow: unfollowUserAction -}; diff --git a/web-player/src/routes/(app)/user/[id]/[list=followList]/+page.svelte b/web-player/src/routes/(app)/user/[id]/[list=followList]/+page.svelte index 7dde364..505103b 100644 --- a/web-player/src/routes/(app)/user/[id]/[list=followList]/+page.svelte +++ b/web-player/src/routes/(app)/user/[id]/[list=followList]/+page.svelte @@ -5,12 +5,11 @@ import PageHeader from '$lib/components/ui/layout/PageHeader.svelte'; import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte'; import InfiniteScroll from '$lib/components/ui/primitives/InfiniteScroll.svelte'; - import Alert from '$lib/components/ui/primitives/Alert.svelte'; import UserRow from '$lib/components/ui/media/UserRow.svelte'; import { appendUnique } from '$lib/data/collections'; import { plural } from '$lib/utils/format'; - let { data, form } = $props(); + let { data } = $props(); type ListUser = NonNullable['items'][number]; @@ -62,10 +61,6 @@ : plural(total, 'usuario', 'usuarios')} /> - {#if form?.message} - {form.message} - {/if} - {#if data.forbidden} { redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`); } - const accountUrl = authConfig.issuer - ? `${authConfig.issuer.replace(/\/+$/, '')}/ui/console` - : null; + const accountUrl = `${authConfig.issuer.replace(/\/+$/, '')}/ui/console`; if (!locals.user) { return { diff --git a/web-player/src/routes/layout.css b/web-player/src/routes/layout.css index 23d1ddf..dc6de81 100644 --- a/web-player/src/routes/layout.css +++ b/web-player/src/routes/layout.css @@ -82,14 +82,6 @@ } } -@utility media-grid { - grid-template-columns: repeat(auto-fill, minmax(var(--mf-grid-min-mobile), 1fr)); - - @media (width >= 40rem) { - grid-template-columns: repeat(auto-fill, minmax(var(--mf-grid-min), 1fr)); - } -} - @utility track-grid { grid-template-columns: var(--mf-track-cols-mobile); @@ -116,10 +108,6 @@ @utility text-body { @apply text-sm leading-body; } -@utility text-count { - @apply text-xs tabular-nums; -} - @utility btn-transition { transition-property: color, background-color, border-color, outline-color, opacity, box-shadow, filter; From 5b2a0e4d46966acbdffeab860859b7daeae5a4a8 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 05:09:37 +0200 Subject: [PATCH 07/34] web-player: normalize tracks, albums and playlists into single domain types at the server boundary --- .../lib/components/player/PlayerBar.svelte | 6 +- .../src/lib/components/player/Queue.svelte | 2 +- .../lib/components/player/TrackInfo.svelte | 27 ++-- .../lib/components/ui/media/TrackList.svelte | 29 ++-- web-player/src/lib/config.ts | 1 - web-player/src/lib/data/albums.ts | 19 +-- web-player/src/lib/data/mixes.ts | 19 --- web-player/src/lib/data/recentlyPlayed.ts | 23 +-- web-player/src/lib/player/liked.svelte.ts | 48 +----- web-player/src/lib/player/player.svelte.ts | 153 ++++-------------- web-player/src/lib/server/mappers.test.ts | 139 ++++++++++++++++ web-player/src/lib/server/mappers.ts | 94 +++++++++++ web-player/src/lib/state/menus.svelte.ts | 9 +- web-player/src/lib/types.ts | 53 ++++++ web-player/src/lib/utils/format.ts | 6 +- .../src/routes/(app)/albums/+page.server.ts | 3 +- .../src/routes/(app)/albums/+page.svelte | 5 +- .../routes/(app)/albums/[id]/+page.server.ts | 9 +- .../src/routes/(app)/albums/[id]/+page.svelte | 15 +- .../src/routes/(app)/explore/+page.server.ts | 7 +- .../src/routes/(app)/explore/+page.svelte | 64 +++----- .../src/routes/(app)/library/+page.server.ts | 5 +- .../src/routes/(app)/library/+page.svelte | 9 +- .../src/routes/(app)/liked/+page.svelte | 4 +- .../routes/(app)/mixes/[id]/+page.server.ts | 3 +- .../src/routes/(app)/mixes/[id]/+page.svelte | 44 ++--- .../routes/(app)/playlists/+page.server.ts | 18 +-- .../src/routes/(app)/playlists/+page.svelte | 4 +- .../(app)/playlists/[id]/+page.server.ts | 5 +- .../routes/(app)/playlists/[id]/+page.svelte | 9 +- .../routes/(app)/user/[id]/+page.server.ts | 16 +- .../src/routes/(app)/user/[id]/+page.svelte | 4 +- .../[id]/[list=followList]/+page.server.ts | 5 +- .../user/[id]/[list=followList]/+page.svelte | 6 +- web-player/src/routes/+layout.server.ts | 7 +- web-player/src/routes/+page.server.ts | 30 ++-- web-player/src/routes/+page.svelte | 57 +++---- .../routes/api/albums/[id]/tracks/+server.ts | 3 +- .../api/playlists/[id]/tracks/+server.ts | 3 +- web-player/src/routes/api/tracks/+server.ts | 7 +- .../users/[id]/[list=followList]/+server.ts | 8 +- 41 files changed, 526 insertions(+), 452 deletions(-) delete mode 100644 web-player/src/lib/data/mixes.ts create mode 100644 web-player/src/lib/server/mappers.test.ts create mode 100644 web-player/src/lib/server/mappers.ts diff --git a/web-player/src/lib/components/player/PlayerBar.svelte b/web-player/src/lib/components/player/PlayerBar.svelte index 879d43b..1082bb8 100644 --- a/web-player/src/lib/components/player/PlayerBar.svelte +++ b/web-player/src/lib/components/player/PlayerBar.svelte @@ -12,7 +12,7 @@ import ListMusic from '@lucide/svelte/icons/list-music'; function seek(value: number) { - if (player.current.duration > 0) player.seekFraction(value / player.current.duration); + if (player.duration > 0) player.seekFraction(value / player.duration); } @@ -39,13 +39,13 @@ - {fmtTime(player.current.duration)} + {fmtTime(player.duration)}
diff --git a/web-player/src/lib/components/player/Queue.svelte b/web-player/src/lib/components/player/Queue.svelte index 9b7b2fa..5be19bd 100644 --- a/web-player/src/lib/components/player/Queue.svelte +++ b/web-player/src/lib/components/player/Queue.svelte @@ -53,7 +53,7 @@ - {#if player.current.id} + {#if player.current}

Reproduciendo

- {#if player.current.id} + {#if current} {:else} @@ -41,14 +34,14 @@ {/if}
- {#if player.current.explicit} + {#if current?.explicit} {/if} - {player.current.title} + {current?.title ?? ''}
diff --git a/web-player/src/lib/components/ui/media/TrackList.svelte b/web-player/src/lib/components/ui/media/TrackList.svelte index 11e5461..5b88784 100644 --- a/web-player/src/lib/components/ui/media/TrackList.svelte +++ b/web-player/src/lib/components/ui/media/TrackList.svelte @@ -1,18 +1,9 @@ @@ -55,10 +55,7 @@ eyebrow="Álbum" title={album.title} description={album.description ?? undefined} - meta={albumMeta( - album.releaseYear === null ? undefined : Number(album.releaseYear), - tracks.length - )} + meta={albumMeta(album.releaseYear, tracks.length)} > {#snippet cover()} { usersPromise ]); - const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.'); + const tracks = toPage(unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.'), (item) => + toTrack(item.track) + ); return { query, genre, genres, tracks, - albums: (albumsRes?.data?.items ?? []).map((item) => item.album), + albums: (albumsRes?.data?.items ?? []).map((item) => toAlbum(item.album)), albumsTotal: Number(albumsRes?.data?.totalItemCount ?? 0), users: usersRes?.data?.items ?? [], usersTotal: Number(usersRes?.data?.totalItemCount ?? 0), diff --git a/web-player/src/routes/(app)/explore/+page.svelte b/web-player/src/routes/(app)/explore/+page.svelte index d3fb114..6d91b07 100644 --- a/web-player/src/routes/(app)/explore/+page.svelte +++ b/web-player/src/routes/(app)/explore/+page.svelte @@ -1,7 +1,8 @@ @@ -46,7 +45,7 @@ {#if items.length > 0} ({ ...track, addedAt: track.likedAt }))); + const listTracks = $derived(tracks.map((track) => ({ ...track, date: track.likedAt }))); const isCurrentQueue = $derived(isQueueCurrent(tracks)); function playFrom(index: number) { @@ -55,7 +55,7 @@ columns={['added', 'plays']} onPlay={playFrom} rowAction={{ - onclick: (track) => liked.toggle({ ...track, artist: track.artist ?? undefined }), + onclick: (track) => liked.toggle(track), icon: X, label: 'Quitar de Me gusta' }} diff --git a/web-player/src/routes/(app)/mixes/[id]/+page.server.ts b/web-player/src/routes/(app)/mixes/[id]/+page.server.ts index 51ffa46..59bb8b2 100644 --- a/web-player/src/routes/(app)/mixes/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/mixes/[id]/+page.server.ts @@ -1,5 +1,6 @@ import type { PageServerLoad, Actions } from './$types'; import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api'; +import { toMix } from '$lib/server/mappers'; import { addTrackAction } from '$lib/server/playlistActions'; export const load: PageServerLoad = async ({ params, locals, url, fetch }) => { @@ -8,7 +9,7 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch }) => { const mixRes = await api.GET('/mixes/{id}', { params: { path: { id: params.id } } }); - return { mix: unwrapOrError(mixRes, 'Mezcla no encontrada.', 404) }; + return { mix: toMix(unwrapOrError(mixRes, 'Mezcla no encontrada.', 404)) }; }; export const actions: Actions = { diff --git a/web-player/src/routes/(app)/mixes/[id]/+page.svelte b/web-player/src/routes/(app)/mixes/[id]/+page.svelte index 92ee433..10b573e 100644 --- a/web-player/src/routes/(app)/mixes/[id]/+page.svelte +++ b/web-player/src/routes/(app)/mixes/[id]/+page.svelte @@ -1,12 +1,6 @@ @@ -60,18 +40,18 @@ eyebrow="Mezcla" title={mix.title} description={mix.subtitle ?? undefined} - meta="{plural(items.length, 'canción', 'canciones')} · {fmtTime(totalSeconds)}" + meta="{plural(tracks.length, 'canción', 'canciones')} · {fmtTime(totalSeconds)}" > {#snippet cover()} item.trackId)} + trackIds={tracks.map((track) => track.id)} size="hero" alt={mix.title} class="shrink-0" /> {/snippet} {#snippet actions()} -
- {#each items as track, i (track.id)} + {#each previewItems as track, i (track.id)} playSpotlightTrack(previewItems, i)} - active={player.current.id === track.id} + active={player.currentId === track.id} size="sm" title={track.title} subtitle={track.artist} subtitleHref={track.ownerUserId} - explicit={track.isExplicit} + explicit={track.explicit} > {#snippet leading()} {fmtTime(Number(track.duration))}{fmtTime(track.duration)} {/snippet} @@ -250,7 +231,7 @@ href="/mixes/{mix.id}" title={mix.title} subtitle={mix.subtitle} - trackIds={mix.items.map((item) => item.trackId)} + trackIds={mix.tracks.map((track) => track.id)} onPlay={(event) => playMix(mix, event)} index={i} /> @@ -314,7 +295,7 @@ ]} playlistAction={{ action: '?/addTrack', - fields: { trackId: String(trackMenu.state.track.id) }, + fields: { trackId: trackMenu.state.track.id }, label: 'Añadir a una playlist' }} playlists={data.userPlaylists} diff --git a/web-player/src/routes/api/albums/[id]/tracks/+server.ts b/web-player/src/routes/api/albums/[id]/tracks/+server.ts index e356c9c..8df1853 100644 --- a/web-player/src/routes/api/albums/[id]/tracks/+server.ts +++ b/web-player/src/routes/api/albums/[id]/tracks/+server.ts @@ -1,6 +1,7 @@ import type { RequestHandler } from './$types'; import { json } from '@sveltejs/kit'; import { createApiClient, unwrapOrError } from '$lib/server/api'; +import { toPage, toTrack } from '$lib/server/mappers'; import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config'; export const GET: RequestHandler = async ({ params, locals, fetch }) => { @@ -12,5 +13,5 @@ export const GET: RequestHandler = async ({ params, locals, fetch }) => { } }); - return json(unwrapOrError(result, 'No se pudo cargar el álbum.')); + return json(toPage(unwrapOrError(result, 'No se pudo cargar el álbum.'), toTrack)); }; diff --git a/web-player/src/routes/api/playlists/[id]/tracks/+server.ts b/web-player/src/routes/api/playlists/[id]/tracks/+server.ts index 3f8618d..29b103d 100644 --- a/web-player/src/routes/api/playlists/[id]/tracks/+server.ts +++ b/web-player/src/routes/api/playlists/[id]/tracks/+server.ts @@ -1,6 +1,7 @@ import type { RequestHandler } from './$types'; import { json } from '@sveltejs/kit'; import { createApiClient, unwrapOrError } from '$lib/server/api'; +import { toPage, toTrack } from '$lib/server/mappers'; import { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config'; export const GET: RequestHandler = async ({ params, locals, fetch }) => { @@ -12,5 +13,5 @@ export const GET: RequestHandler = async ({ params, locals, fetch }) => { } }); - return json(unwrapOrError(result, 'No se pudo cargar la playlist.')); + return json(toPage(unwrapOrError(result, 'No se pudo cargar la playlist.'), toTrack)); }; diff --git a/web-player/src/routes/api/tracks/+server.ts b/web-player/src/routes/api/tracks/+server.ts index 786796b..6cd7c1d 100644 --- a/web-player/src/routes/api/tracks/+server.ts +++ b/web-player/src/routes/api/tracks/+server.ts @@ -1,6 +1,7 @@ import type { RequestHandler } from './$types'; import { json } from '@sveltejs/kit'; import { createApiClient, unwrapOrError } from '$lib/server/api'; +import { toPage, toTrack } from '$lib/server/mappers'; import { EXPLORE_PAGE_SIZE, MAX_PAGE_SIZE } from '$lib/config'; export const GET: RequestHandler = async ({ url, locals, fetch }) => { @@ -17,5 +18,9 @@ export const GET: RequestHandler = async ({ url, locals, fetch }) => { params: { query: { name, genre, pageNumber, pageSize } } }); - return json(unwrapOrError(result, 'No se pudieron cargar las canciones.')); + return json( + toPage(unwrapOrError(result, 'No se pudieron cargar las canciones.'), (item) => + toTrack(item.track) + ) + ); }; diff --git a/web-player/src/routes/api/users/[id]/[list=followList]/+server.ts b/web-player/src/routes/api/users/[id]/[list=followList]/+server.ts index b90b4a8..6ccd838 100644 --- a/web-player/src/routes/api/users/[id]/[list=followList]/+server.ts +++ b/web-player/src/routes/api/users/[id]/[list=followList]/+server.ts @@ -1,6 +1,7 @@ import type { RequestHandler } from './$types'; import { json } from '@sveltejs/kit'; import { createApiClient, unwrapOrError } from '$lib/server/api'; +import { toPage } from '$lib/server/mappers'; import { fetchFollowList } from '$lib/server/follows'; import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config'; @@ -15,5 +16,10 @@ export const GET: RequestHandler = async ({ params, url, locals, fetch }) => { FOLLOW_LIST_PAGE_SIZE ); - return json(unwrapOrError(result, 'No se pudo cargar la lista.', result.response?.status)); + return json( + toPage( + unwrapOrError(result, 'No se pudo cargar la lista.', result.response?.status), + (user) => user + ) + ); }; From 2915737f034ea0d72825edce58b4949aea247beb Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 05:13:51 +0200 Subject: [PATCH 08/34] web-player: centralize server helpers (apiFor, authedAction, form readers) and unify page sizes --- web-player/src/lib/config.ts | 5 ++ web-player/src/lib/player/player.svelte.ts | 12 +-- web-player/src/lib/server/albumForm.ts | 7 +- web-player/src/lib/server/api.ts | 72 +++++++++++++--- web-player/src/lib/server/auth.ts | 8 +- web-player/src/lib/server/followActions.ts | 42 ++++----- web-player/src/lib/server/follows.ts | 4 +- web-player/src/lib/server/playlistActions.ts | 44 ++++------ .../src/routes/(app)/albums/+page.server.ts | 29 ++----- .../routes/(app)/albums/[id]/+page.server.ts | 68 +++++---------- .../src/routes/(app)/explore/+page.server.ts | 12 +-- .../src/routes/(app)/library/+page.server.ts | 29 +++---- .../routes/(app)/mixes/[id]/+page.server.ts | 4 +- .../routes/(app)/playlists/+page.server.ts | 40 ++++----- .../(app)/playlists/[id]/+page.server.ts | 64 ++++++-------- .../routes/(app)/playlists/[id]/+page.svelte | 2 +- .../src/routes/(app)/upload/+page.server.ts | 47 +++++----- .../routes/(app)/user/[id]/+page.server.ts | 12 +-- .../[id]/[list=followList]/+page.server.ts | 4 +- web-player/src/routes/+layout.server.ts | 86 ++++++++----------- web-player/src/routes/+page.server.ts | 4 +- .../routes/api/albums/[id]/tracks/+server.ts | 4 +- web-player/src/routes/api/likes/+server.ts | 4 +- .../api/listens/[id]/progress/+server.ts | 4 +- .../api/playlists/[id]/tracks/+server.ts | 4 +- web-player/src/routes/api/tracks/+server.ts | 4 +- .../routes/api/tracks/[id]/stream/+server.ts | 4 +- .../users/[id]/[list=followList]/+server.ts | 4 +- .../src/routes/auth/callback/+server.ts | 4 +- 29 files changed, 293 insertions(+), 334 deletions(-) diff --git a/web-player/src/lib/config.ts b/web-player/src/lib/config.ts index 7701d3f..af3366e 100644 --- a/web-player/src/lib/config.ts +++ b/web-player/src/lib/config.ts @@ -8,6 +8,11 @@ export const ALBUM_TRACKS_PAGE_SIZE = 200; export const PLAYLIST_TRACKS_PAGE_SIZE = 200; export const ALBUM_EARLIEST_YEAR = 1877; export const LIBRARY_PICKER_PAGE_SIZE = 50; +export const LIBRARY_PAGE_SIZE = 50; +export const PLAYLISTS_PAGE_SIZE = 50; +export const PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE = 500; +export const PROFILE_PLAYLISTS_PAGE_SIZE = 50; +export const COUNT_ONLY_PAGE_SIZE = 1; export const EXPLORE_ALBUMS_PAGE_SIZE = 10; export const EXPLORE_ALBUMS_LIMIT = 18; export const HOME_MIXES_LIMIT = 6; diff --git a/web-player/src/lib/player/player.svelte.ts b/web-player/src/lib/player/player.svelte.ts index 8b73775..d0c856d 100644 --- a/web-player/src/lib/player/player.svelte.ts +++ b/web-player/src/lib/player/player.svelte.ts @@ -242,17 +242,13 @@ class PlayerState { await audio.play(); this.loading = false; if (track) this.#pushRecent(track); - } catch (exception) { - console.error('playback failed', exception); + } catch (err) { + console.error('playback failed', err); if (token !== this.#loadToken) return; this.loading = false; this.playing = false; - if (exception instanceof DOMException && exception.name === 'AbortError') return; - this.#fail( - exception instanceof Error && !(exception instanceof DOMException) - ? exception.message - : undefined - ); + if (err instanceof DOMException && err.name === 'AbortError') return; + this.#fail(err instanceof Error && !(err instanceof DOMException) ? err.message : undefined); } } diff --git a/web-player/src/lib/server/albumForm.ts b/web-player/src/lib/server/albumForm.ts index bed1fa4..e3dfdf0 100644 --- a/web-player/src/lib/server/albumForm.ts +++ b/web-player/src/lib/server/albumForm.ts @@ -1,3 +1,4 @@ +import { formString } from '$lib/server/api'; import { ALBUM_EARLIEST_YEAR } from '$lib/config'; interface AlbumFormBody { @@ -7,17 +8,17 @@ interface AlbumFormBody { } export function parseAlbumForm(form: FormData): { body: AlbumFormBody } | { failMessage: string } { - const title = String(form.get('title') ?? '').trim(); + const title = formString(form, 'title').trim(); if (title === '' || title.length > 200) { return { failMessage: 'El título es obligatorio (máx. 200 caracteres).' }; } - const description = String(form.get('description') ?? '').trim(); + const description = formString(form, 'description').trim(); if (description.length > 256) { return { failMessage: 'La descripción no puede pasar de 256 caracteres.' }; } - const rawYear = String(form.get('releaseYear') ?? '').trim(); + const rawYear = formString(form, 'releaseYear').trim(); const latestYear = new Date().getFullYear() + 1; let releaseYear: number | null = null; diff --git a/web-player/src/lib/server/api.ts b/web-player/src/lib/server/api.ts index 722a5c7..fa2f878 100644 --- a/web-player/src/lib/server/api.ts +++ b/web-player/src/lib/server/api.ts @@ -1,5 +1,5 @@ import createClient from 'openapi-fetch'; -import { error, fail, redirect, type ActionFailure } from '@sveltejs/kit'; +import { error, fail, redirect, type ActionFailure, type RequestEvent } from '@sveltejs/kit'; import type { paths } from '$lib/api/schema'; import type { SessionUser } from '$lib/types'; import { apiConfig } from '$lib/server/config'; @@ -17,9 +17,18 @@ export function createApiClient({ fetch, accessToken }: ApiClientOptions) { }); } +export type ApiClient = ReturnType; + +export function apiFor({ fetch, locals }: Pick): ApiClient { + return createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); +} + +export function loginRedirect(url: URL): never { + redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`); +} + export function requireUser(locals: App.Locals, url: URL): SessionUser { - if (!locals.user) - redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`); + if (!locals.user) loginRedirect(url); return locals.user; } @@ -29,16 +38,42 @@ export function optionalUser( allowAnonymousListening: boolean ): SessionUser | null { if (locals.user) return locals.user; - if (!allowAnonymousListening) - redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`); + if (!allowAnonymousListening) loginRedirect(url); return null; } -export function requireAccessTokenAction( - locals: App.Locals, - message = 'Inicia sesión.' -): string | ActionFailure<{ message: string }> { - return locals.accessToken ?? fail(401, { message }); +export function formString(form: FormData, key: string): string { + return String(form.get(key) ?? ''); +} + +export function formFile(form: FormData, key: string): File | null { + const value = form.get(key); + return value instanceof File && value.size > 0 ? value : null; +} + +interface ActionContext { + api: ApiClient; + form: FormData; + params: Record; + locals: App.Locals; + url: URL; +} + +export function authedAction( + handler: (context: ActionContext) => R | Promise, + unauthorizedMessage = 'Inicia sesión.' +) { + return async (event: RequestEvent) => { + if (!event.locals.accessToken) return fail(401, { message: unauthorizedMessage }); + const form = await event.request.formData(); + return handler({ + api: apiFor(event), + form, + params: event.params as Record, + locals: event.locals, + url: event.url + }); + }; } export function unwrapOrError( @@ -70,9 +105,18 @@ export function apiErrorDetail(error: unknown): string | undefined { return undefined; } -export function unwrapOrFail( - result: { error?: unknown }, - message: string -): ActionFailure<{ message: string; detail?: string }> | undefined { +type Failure = ActionFailure<{ message: string; detail?: string }>; + +export function failOnError(result: { error?: unknown }, message: string): Failure | undefined { return result.error ? fail(502, { message, detail: apiErrorDetail(result.error) }) : undefined; } + +export function requireData( + result: { data?: T; error?: unknown }, + message: string +): { data: T; failure?: undefined } | { data?: undefined; failure: Failure } { + if (result.error || result.data === undefined) { + return { failure: fail(502, { message, detail: apiErrorDetail(result.error) }) }; + } + return { data: result.data }; +} diff --git a/web-player/src/lib/server/auth.ts b/web-player/src/lib/server/auth.ts index b17178e..cce923c 100644 --- a/web-player/src/lib/server/auth.ts +++ b/web-player/src/lib/server/auth.ts @@ -36,9 +36,9 @@ export function getOidcConfig(): Promise { return secret ? client.discovery(issuer, authConfig.clientId, secret, undefined, options) : client.discovery(issuer, authConfig.clientId, undefined, client.None(), options); - })().catch((exception) => { + })().catch((err) => { configPromise = undefined; - throw exception; + throw err; }); return configPromise; } @@ -151,8 +151,8 @@ export async function refreshSession(session: Session): Promise idToken: tokens.id_token ?? session.idToken, expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600) }; - } catch (exception) { - console.error('Token refresh failed', exception); + } catch (err) { + console.error('Token refresh failed', err); return null; } } diff --git a/web-player/src/lib/server/followActions.ts b/web-player/src/lib/server/followActions.ts index 9a7e8df..93b81b3 100644 --- a/web-player/src/lib/server/followActions.ts +++ b/web-player/src/lib/server/followActions.ts @@ -1,28 +1,24 @@ -import { fail, type RequestEvent } from '@sveltejs/kit'; -import { createApiClient, requireAccessTokenAction } from '$lib/server/api'; +import { fail } from '@sveltejs/kit'; +import { authedAction } from '$lib/server/api'; -async function setFollowing({ request, params, locals, fetch }: RequestEvent, follow: boolean) { - const accessToken = requireAccessTokenAction(locals); - if (typeof accessToken !== 'string') return accessToken; +const setFollowing = (follow: boolean) => + authedAction(async ({ api, params }) => { + const id = params.id ?? ''; + if (!id) return fail(400, { message: 'Falta el usuario.' }); - const form = await request.formData(); - const id = String(form.get('userId') ?? '') || String(params.id ?? ''); - if (!id) return fail(400, { message: 'Falta el usuario.' }); + const options = { params: { path: { id } } }; + const { error } = follow + ? await api.POST('/users/{id}/follow', options) + : await api.DELETE('/users/{id}/follow', options); - const api = createApiClient({ fetch, accessToken }); - const options = { params: { path: { id } } }; - const { error } = follow - ? await api.POST('/users/{id}/follow', options) - : await api.DELETE('/users/{id}/follow', options); + if (error) { + return fail(502, { + message: follow ? 'No se pudo seguir al usuario.' : 'No se pudo dejar de seguir al usuario.' + }); + } - if (error) { - return fail(502, { - message: follow ? 'No se pudo seguir al usuario.' : 'No se pudo dejar de seguir al usuario.' - }); - } + return { following: follow, userId: id }; + }); - return { following: follow, userId: id }; -} - -export const followUserAction = (event: RequestEvent) => setFollowing(event, true); -export const unfollowUserAction = (event: RequestEvent) => setFollowing(event, false); +export const followUserAction = setFollowing(true); +export const unfollowUserAction = setFollowing(false); diff --git a/web-player/src/lib/server/follows.ts b/web-player/src/lib/server/follows.ts index c51719c..0a8eb50 100644 --- a/web-player/src/lib/server/follows.ts +++ b/web-player/src/lib/server/follows.ts @@ -1,8 +1,6 @@ -import type { createApiClient } from '$lib/server/api'; +import type { ApiClient } from '$lib/server/api'; import type { FollowList } from '$lib/types'; -type ApiClient = ReturnType; - export function fetchFollowList( api: ApiClient, list: FollowList, diff --git a/web-player/src/lib/server/playlistActions.ts b/web-player/src/lib/server/playlistActions.ts index 318b4ef..9213f5b 100644 --- a/web-player/src/lib/server/playlistActions.ts +++ b/web-player/src/lib/server/playlistActions.ts @@ -1,26 +1,21 @@ -import { fail, type RequestEvent } from '@sveltejs/kit'; -import { createApiClient, requireAccessTokenAction } from '$lib/server/api'; +import { fail } from '@sveltejs/kit'; +import { authedAction, formString } from '$lib/server/api'; import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config'; -export async function addTrackAction({ request, params, locals, fetch }: RequestEvent) { - const accessToken = requireAccessTokenAction(locals); - if (typeof accessToken !== 'string') return accessToken; - - const form = await request.formData(); - const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? ''); - const trackId = String(form.get('trackId') ?? ''); +export const addTrackAction = authedAction(async ({ api, form, params }) => { + const playlistId = formString(form, 'playlistId') || (params.id ?? ''); + const trackId = formString(form, 'trackId'); if (!playlistId || !trackId) { return fail(400, { message: 'Faltan datos de la canción.' }); } - const api = createApiClient({ fetch, accessToken }); - const { error: err, response } = await api.POST('/playlists/{playlistId}/tracks', { + const { error, response } = await api.POST('/playlists/{playlistId}/tracks', { params: { path: { playlistId } }, body: { trackId } }); - if (err) { + if (error) { if (response?.status === 409) { return fail(409, { message: 'La canción ya está en esa playlist.', trackId }); } @@ -28,37 +23,32 @@ export async function addTrackAction({ request, params, locals, fetch }: Request } return { added: true, trackId, playlistId }; -} +}); -export async function addAlbumToPlaylistAction({ request, params, locals, fetch }: RequestEvent) { - const accessToken = requireAccessTokenAction(locals); - if (typeof accessToken !== 'string') return accessToken; - - const form = await request.formData(); - const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? ''); - const albumId = String(form.get('albumId') ?? ''); +export const addAlbumToPlaylistAction = authedAction(async ({ api, form, params }) => { + const playlistId = formString(form, 'playlistId') || (params.id ?? ''); + const albumId = formString(form, 'albumId'); if (!playlistId || !albumId) { return fail(400, { message: 'Faltan datos del álbum.' }); } - const api = createApiClient({ fetch, accessToken }); - const { data: tracks, error: tracksErr } = await api.GET('/albums/{albumId}/tracks', { + const { data: tracks, error: tracksError } = await api.GET('/albums/{albumId}/tracks', { params: { path: { albumId }, query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE } } }); - if (tracksErr || !tracks) { + if (tracksError || !tracks) { return fail(502, { message: 'No se pudo cargar el álbum.', albumId }); } let added = 0; for (const track of tracks.items) { - const { error: err } = await api.POST('/playlists/{playlistId}/tracks', { + const { error } = await api.POST('/playlists/{playlistId}/tracks', { params: { path: { playlistId } }, - body: { trackId: String(track.id) } + body: { trackId: track.id } }); - if (!err) added++; + if (!error) added++; } return { addedAlbum: true, albumId, playlistId, added }; -} +}); diff --git a/web-player/src/routes/(app)/albums/+page.server.ts b/web-player/src/routes/(app)/albums/+page.server.ts index 9855829..692b1e3 100644 --- a/web-player/src/routes/(app)/albums/+page.server.ts +++ b/web-player/src/routes/(app)/albums/+page.server.ts @@ -1,11 +1,6 @@ import type { PageServerLoad, Actions } from './$types'; import { fail, redirect } from '@sveltejs/kit'; -import { - createApiClient, - requireAccessTokenAction, - requireUser, - unwrapOrError -} from '$lib/server/api'; +import { apiFor, authedAction, formFile, requireUser, unwrapOrError } from '$lib/server/api'; import { toAlbum, toPage } from '$lib/server/mappers'; import { ALBUMS_PAGE_SIZE } from '$lib/config'; import { parseAlbumForm } from '$lib/server/albumForm'; @@ -13,7 +8,7 @@ import { uploadPresignedImage } from '$lib/server/upload'; export const load: PageServerLoad = async ({ locals, url, fetch }) => { const user = requireUser(locals, url); - const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + const api = apiFor({ fetch, locals }); const result = await api.GET('/albums/users/{userId}', { params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: ALBUMS_PAGE_SIZE } } @@ -25,20 +20,12 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => { }; export const actions: Actions = { - create: async ({ request, locals, fetch }) => { - const accessToken = requireAccessTokenAction(locals); - if (typeof accessToken !== 'string') return accessToken; - - const form = await request.formData(); + create: authedAction(async ({ api, form }) => { const parsed = parseAlbumForm(form); if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage }); - const cover = form.get('cover'); - if (!(cover instanceof File) || cover.size === 0) { - return fail(400, { message: 'La portada es obligatoria.' }); - } - - const api = createApiClient({ fetch, accessToken }); + const cover = formFile(form, 'cover'); + if (!cover) return fail(400, { message: 'La portada es obligatoria.' }); const uploaded = await uploadPresignedImage( (args) => api.POST('/albums/upload-picture', { body: args }), @@ -48,12 +35,12 @@ export const actions: Actions = { return fail(502, { message: uploaded.failMessage, detail: uploaded.detail }); } - const { data, error: err } = await api.POST('/albums', { + const { data, error } = await api.POST('/albums', { body: { ...parsed.body, pictureIntentId: uploaded.intentId } }); - if (err || !data) return fail(502, { message: 'No se pudo crear el álbum.' }); + if (error || !data) return fail(502, { message: 'No se pudo crear el álbum.' }); redirect(303, `/albums/${data.id}`); - } + }) }; diff --git a/web-player/src/routes/(app)/albums/[id]/+page.server.ts b/web-player/src/routes/(app)/albums/[id]/+page.server.ts index 0e07eb2..8b494e6 100644 --- a/web-player/src/routes/(app)/albums/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/albums/[id]/+page.server.ts @@ -1,11 +1,13 @@ import type { PageServerLoad, Actions } from './$types'; import { fail, redirect } from '@sveltejs/kit'; import { - createApiClient, + apiFor, + authedAction, + failOnError, + formFile, + formString, optionalUser, - requireAccessTokenAction, - unwrapOrError, - unwrapOrFail + unwrapOrError } from '$lib/server/api'; import { toAlbum, toTrack } from '$lib/server/mappers'; import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config'; @@ -15,7 +17,7 @@ import { uploadPresignedImage } from '$lib/server/upload'; export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => { const { allowAnonymousListening } = await parent(); const user = optionalUser(locals, url, allowAnonymousListening); - const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + const api = apiFor({ fetch, locals }); const [albumRes, tracksRes, libraryRes] = await Promise.all([ api.GET('/albums/{id}', { params: { path: { id: params.id } } }), @@ -49,51 +51,33 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }; export const actions: Actions = { - addTrack: async ({ request, params, locals, fetch }) => { - const accessToken = requireAccessTokenAction(locals); - if (typeof accessToken !== 'string') return accessToken; - - const trackId = String((await request.formData()).get('trackId') ?? ''); + addTrack: authedAction(async ({ api, form, params }) => { + const trackId = formString(form, 'trackId'); if (!trackId) return fail(400, { message: 'Falta la canción.' }); - const api = createApiClient({ fetch, accessToken }); const result = await api.POST('/albums/{albumId}/tracks/{trackId}', { params: { path: { albumId: params.id, trackId } } }); - const failure = unwrapOrFail(result, 'No se pudo añadir la canción.'); - if (failure) return failure; - return { added: true }; - }, + return failOnError(result, 'No se pudo añadir la canción.') ?? { added: true }; + }), - removeTrack: async ({ request, params, locals, fetch }) => { - const accessToken = requireAccessTokenAction(locals); - if (typeof accessToken !== 'string') return accessToken; - - const trackId = String((await request.formData()).get('trackId') ?? ''); + removeTrack: authedAction(async ({ api, form, params }) => { + const trackId = formString(form, 'trackId'); if (!trackId) return fail(400, { message: 'Falta la canción.' }); - const api = createApiClient({ fetch, accessToken }); const result = await api.DELETE('/albums/{albumId}/tracks/{trackId}', { params: { path: { albumId: params.id, trackId } } }); - const failure = unwrapOrFail(result, 'No se pudo quitar la canción.'); - if (failure) return failure; - return { removed: true }; - }, + return failOnError(result, 'No se pudo quitar la canción.') ?? { removed: true }; + }), - edit: async ({ request, params, locals, fetch }) => { - const accessToken = requireAccessTokenAction(locals); - if (typeof accessToken !== 'string') return accessToken; - - const form = await request.formData(); + edit: authedAction(async ({ api, form, params }) => { const parsed = parseAlbumForm(form); if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage }); - const api = createApiClient({ fetch, accessToken }); - let newPictureIntentId: string | null = null; - const cover = form.get('cover'); - if (cover instanceof File && cover.size > 0) { + const cover = formFile(form, 'cover'); + if (cover) { const uploaded = await uploadPresignedImage( (args) => api.POST('/albums/upload-picture', { body: args }), cover @@ -113,21 +97,15 @@ export const actions: Actions = { newPictureIntentId } }); - const failure = unwrapOrFail(result, 'No se pudo actualizar el álbum.'); - if (failure) return failure; - return { edited: true }; - }, + return failOnError(result, 'No se pudo actualizar el álbum.') ?? { edited: true }; + }), - delete: async ({ params, locals, fetch }) => { - const accessToken = requireAccessTokenAction(locals); - if (typeof accessToken !== 'string') return accessToken; - - const api = createApiClient({ fetch, accessToken }); + delete: authedAction(async ({ api, params }) => { const result = await api.DELETE('/albums/{albumId}', { params: { path: { albumId: params.id } } }); - const failure = unwrapOrFail(result, 'No se pudo eliminar el álbum.'); + const failure = failOnError(result, 'No se pudo eliminar el álbum.'); if (failure) return failure; redirect(303, '/albums'); - } + }) }; diff --git a/web-player/src/routes/(app)/explore/+page.server.ts b/web-player/src/routes/(app)/explore/+page.server.ts index 9fc7f80..05d1de5 100644 --- a/web-player/src/routes/(app)/explore/+page.server.ts +++ b/web-player/src/routes/(app)/explore/+page.server.ts @@ -1,12 +1,8 @@ import type { PageServerLoad, Actions } from './$types'; -import { createApiClient, unwrapOrError } from '$lib/server/api'; +import { apiFor, unwrapOrError } from '$lib/server/api'; import { toAlbum, toPage, toTrack } from '$lib/server/mappers'; import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions'; -import { - EXPLORE_ALBUMS_PAGE_SIZE, - EXPLORE_PAGE_SIZE as PAGE_SIZE, - EXPLORE_USERS_LIMIT -} from '$lib/config'; +import { EXPLORE_ALBUMS_PAGE_SIZE, EXPLORE_PAGE_SIZE, EXPLORE_USERS_LIMIT } from '$lib/config'; export const load: PageServerLoad = async ({ url, locals, fetch }) => { const query = url.searchParams.get('q')?.trim() ?? ''; @@ -14,7 +10,7 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => { const page = Math.max(1, Number(url.searchParams.get('page')) || 1); const user = locals.user; - const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + const api = apiFor({ fetch, locals }); const genresRes = await api.GET('/genres'); const genres = genresRes.data ?? []; @@ -26,7 +22,7 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => { name: query || undefined, genre: genre ?? undefined, pageNumber: page, - pageSize: PAGE_SIZE + pageSize: EXPLORE_PAGE_SIZE } } }); diff --git a/web-player/src/routes/(app)/library/+page.server.ts b/web-player/src/routes/(app)/library/+page.server.ts index 4550fd7..d3eeaa7 100644 --- a/web-player/src/routes/(app)/library/+page.server.ts +++ b/web-player/src/routes/(app)/library/+page.server.ts @@ -2,20 +2,20 @@ import type { PageServerLoad, Actions } from './$types'; import { fail } from '@sveltejs/kit'; import { toDatedTrack, toPage } from '$lib/server/mappers'; import { - createApiClient, - requireAccessTokenAction, + apiFor, + authedAction, + failOnError, + formString, requireUser, - unwrapOrError, - unwrapOrFail + unwrapOrError } from '$lib/server/api'; - -const PAGE_SIZE = 50; +import { LIBRARY_PAGE_SIZE } from '$lib/config'; export const load: PageServerLoad = async ({ locals, url, fetch }) => { const user = requireUser(locals, url); - const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + const api = apiFor({ fetch, locals }); const result = await api.GET('/tracks/users/{userId}', { - params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: PAGE_SIZE } } + params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: LIBRARY_PAGE_SIZE } } }); return { @@ -24,18 +24,13 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => { }; export const actions: Actions = { - deleteTrack: async ({ request, locals, fetch }) => { - const accessToken = requireAccessTokenAction(locals); - if (typeof accessToken !== 'string') return accessToken; - const trackId = String((await request.formData()).get('trackId') ?? ''); + deleteTrack: authedAction(async ({ api, form }) => { + const trackId = formString(form, 'trackId'); if (!trackId) return fail(400, { message: 'Falta la canción.' }); - const api = createApiClient({ fetch, accessToken }); const result = await api.DELETE('/tracks/{trackId}', { params: { path: { trackId } } }); - const failure = unwrapOrFail(result, 'No se pudo borrar la canción.'); - if (failure) return failure; - return { deleted: true }; - } + return failOnError(result, 'No se pudo borrar la canción.') ?? { deleted: true }; + }) }; diff --git a/web-player/src/routes/(app)/mixes/[id]/+page.server.ts b/web-player/src/routes/(app)/mixes/[id]/+page.server.ts index 59bb8b2..c9e0518 100644 --- a/web-player/src/routes/(app)/mixes/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/mixes/[id]/+page.server.ts @@ -1,11 +1,11 @@ import type { PageServerLoad, Actions } from './$types'; -import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api'; +import { apiFor, requireUser, unwrapOrError } from '$lib/server/api'; import { toMix } from '$lib/server/mappers'; import { addTrackAction } from '$lib/server/playlistActions'; export const load: PageServerLoad = async ({ params, locals, url, fetch }) => { requireUser(locals, url); - const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + const api = apiFor({ fetch, locals }); const mixRes = await api.GET('/mixes/{id}', { params: { path: { id: params.id } } }); diff --git a/web-player/src/routes/(app)/playlists/+page.server.ts b/web-player/src/routes/(app)/playlists/+page.server.ts index 3502758..54c9f25 100644 --- a/web-player/src/routes/(app)/playlists/+page.server.ts +++ b/web-player/src/routes/(app)/playlists/+page.server.ts @@ -1,11 +1,14 @@ import type { PageServerLoad, Actions } from './$types'; import { fail, redirect } from '@sveltejs/kit'; import { - createApiClient, - requireAccessTokenAction, + apiFor, + authedAction, + formFile, + formString, requireUser, unwrapOrError } from '$lib/server/api'; +import { PLAYLISTS_PAGE_SIZE, PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE } from '$lib/config'; import { toPlaylistSummary, toTrack } from '$lib/server/mappers'; import { uploadPresignedImage } from '$lib/server/upload'; @@ -13,9 +16,9 @@ const MAX_NAME = 100; export const load: PageServerLoad = async ({ locals, url, fetch }) => { const user = requireUser(locals, url); - const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + const api = apiFor({ fetch, locals }); const result = await api.GET('/playlists/users/{userId}', { - params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } } + params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: PLAYLISTS_PAGE_SIZE } } }); const data = unwrapOrError(result, 'No se pudieron cargar tus playlists.'); @@ -24,7 +27,10 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => { data.items.map((playlist) => api .GET('/playlists/{playlistId}/tracks', { - params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 500 } } + params: { + path: { playlistId: playlist.id }, + query: { pageNumber: 1, pageSize: PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE } + } }) .then((res) => (res.data?.items ?? []).map(toTrack)) ) @@ -44,15 +50,10 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => { }; export const actions: Actions = { - create: async ({ request, locals, fetch }) => { - const accessToken = requireAccessTokenAction(locals); - if (typeof accessToken !== 'string') return accessToken; - - const form = await request.formData(); - const name = String(form.get('name') ?? '').trim(); - const description = String(form.get('description') ?? '').trim(); - const visibility = form.get('visibility') === 'public' ? 'Public' : 'Private'; - const cover = form.get('cover'); + create: authedAction(async ({ api, form }) => { + const name = formString(form, 'name').trim(); + const description = formString(form, 'description').trim(); + const visibility = formString(form, 'visibility') === 'public' ? 'Public' : 'Private'; if (name === '' || name.length > MAX_NAME) { return fail(400, { @@ -62,10 +63,9 @@ export const actions: Actions = { }); } - const api = createApiClient({ fetch, accessToken }); - let pictureIntentId: string | null = null; - if (cover instanceof File && cover.size > 0) { + const cover = formFile(form, 'cover'); + if (cover) { const result = await uploadPresignedImage( (args) => api.POST('/playlists/upload-picture', { body: args }), cover @@ -76,13 +76,13 @@ export const actions: Actions = { pictureIntentId = result.intentId; } - const { data, error: err } = await api.POST('/playlists', { + const { data, error } = await api.POST('/playlists', { body: { name, description, pictureIntentId, visibility } }); - if (err || !data) + if (error || !data) return fail(502, { message: 'No se pudo crear la playlist.', name, description }); redirect(303, `/playlists/${data.id}`); - } + }) }; diff --git a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts index 1cdedfd..797d354 100644 --- a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts @@ -1,12 +1,15 @@ import type { PageServerLoad, Actions } from './$types'; import { fail, redirect } from '@sveltejs/kit'; import { - createApiClient, + apiFor, + authedAction, + failOnError, + formFile, + formString, optionalUser, - requireAccessTokenAction, - unwrapOrError, - unwrapOrFail + unwrapOrError } from '$lib/server/api'; +import { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config'; import { toDatedTrack, toPlaylist } from '$lib/server/mappers'; import { uploadPresignedImage } from '$lib/server/upload'; @@ -15,12 +18,15 @@ const MAX_NAME = 100; export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => { const { allowAnonymousListening } = await parent(); const user = optionalUser(locals, url, allowAnonymousListening); - const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + const api = apiFor({ fetch, locals }); const [playlistRes, tracksRes] = await Promise.all([ api.GET('/playlists/{id}', { params: { path: { id: params.id } } }), api.GET('/playlists/{playlistId}/tracks', { - params: { path: { playlistId: params.id }, query: { pageNumber: 1, pageSize: 200 } } + params: { + path: { playlistId: params.id }, + query: { pageNumber: 1, pageSize: PLAYLIST_TRACKS_PAGE_SIZE } + } }) ]); @@ -33,37 +39,27 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }; export const actions: Actions = { - removeTrack: async ({ request, params, locals, fetch }) => { - const accessToken = requireAccessTokenAction(locals); - if (typeof accessToken !== 'string') return accessToken; - const trackId = String((await request.formData()).get('trackId') ?? ''); + removeTrack: authedAction(async ({ api, form, params }) => { + const trackId = formString(form, 'trackId'); if (!trackId) return fail(400, { message: 'Falta la canción.' }); - const api = createApiClient({ fetch, accessToken }); const result = await api.DELETE('/playlists/{playlistId}/tracks/{trackId}', { params: { path: { playlistId: params.id, trackId } } }); - const failure = unwrapOrFail(result, 'No se pudo quitar la canción.'); - if (failure) return failure; - return { removed: true }; - }, + return failOnError(result, 'No se pudo quitar la canción.') ?? { removed: true }; + }), - rename: async ({ request, params, locals, fetch }) => { - const accessToken = requireAccessTokenAction(locals); - if (typeof accessToken !== 'string') return accessToken; - const form = await request.formData(); - const name = String(form.get('name') ?? '').trim(); - const description = String(form.get('description') ?? '').trim(); - const newVisibility = form.get('visibility') === 'public' ? 'Public' : 'Private'; - const cover = form.get('cover'); + edit: authedAction(async ({ api, form, params }) => { + const name = formString(form, 'name').trim(); + const description = formString(form, 'description').trim(); + const newVisibility = formString(form, 'visibility') === 'public' ? 'Public' : 'Private'; if (name === '' || name.length > MAX_NAME) { return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).' }); } - const api = createApiClient({ fetch, accessToken }); - let newPictureIntentId: string | null = null; - if (cover instanceof File && cover.size > 0) { + const cover = formFile(form, 'cover'); + if (cover) { const result = await uploadPresignedImage( (args) => api.POST('/playlists/upload-picture', { body: args }), cover @@ -78,21 +74,15 @@ export const actions: Actions = { params: { path: { playlistId: params.id } }, body: { newName: name, newDescription: description, newPictureIntentId, newVisibility } }); - const failure = unwrapOrFail(result, 'No se pudo actualizar la playlist.'); - if (failure) return failure; - return { renamed: true }; - }, + return failOnError(result, 'No se pudo actualizar la playlist.') ?? { edited: true }; + }), - delete: async ({ params, locals, fetch }) => { - const accessToken = requireAccessTokenAction(locals); - if (typeof accessToken !== 'string') return accessToken; - - const api = createApiClient({ fetch, accessToken }); + delete: authedAction(async ({ api, params }) => { const result = await api.DELETE('/playlists/{playlistId}', { params: { path: { playlistId: params.id } } }); - const failure = unwrapOrFail(result, 'No se pudo eliminar la playlist.'); + const failure = failOnError(result, 'No se pudo eliminar la playlist.'); if (failure) return failure; redirect(303, '/playlists'); - } + }) }; diff --git a/web-player/src/routes/(app)/playlists/[id]/+page.svelte b/web-player/src/routes/(app)/playlists/[id]/+page.svelte index 3685cdd..a485dd4 100644 --- a/web-player/src/routes/(app)/playlists/[id]/+page.svelte +++ b/web-player/src/routes/(app)/playlists/[id]/+page.svelte @@ -106,7 +106,7 @@ (editing = false)} title="Editar playlist"> { requireUser(locals, url); - const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + const api = apiFor({ fetch, locals }); const { data } = await api.GET('/genres/available'); return { genres: data ?? [] }; }; export const actions: Actions = { - default: async ({ request, locals, fetch }) => { - const accessToken = requireAccessTokenAction(locals, 'Inicia sesión para subir música.'); - if (typeof accessToken !== 'string') return accessToken; - - const form = await request.formData(); - const title = String(form.get('title') ?? '').trim(); - const audio = form.get('audio'); - const cover = form.get('cover'); + default: authedAction(async ({ api, form }) => { + const title = formString(form, 'title').trim(); + const audio = formFile(form, 'audio'); + const cover = formFile(form, 'cover'); const rawTags = form.getAll('tags').map(String); const isExplicit = form.get('isExplicit') === 'on'; if (title === '' || title.length > MAX_TITLE) { return fail(400, { message: 'El título es obligatorio (máx. 100 caracteres).' }); } - if (!(audio instanceof File) || audio.size === 0) { - return fail(400, { message: 'Selecciona un archivo de audio.' }); - } - if (!(cover instanceof File) || cover.size === 0) { - return fail(400, { message: 'Selecciona una portada.' }); - } + if (!audio) return fail(400, { message: 'Selecciona un archivo de audio.' }); + if (!cover) return fail(400, { message: 'Selecciona una portada.' }); if (rawTags.length === 0) { return fail(400, { message: 'Elige al menos un género.' }); } - const api = createApiClient({ fetch, accessToken }); const { data: available } = await api.GET('/genres/available'); const options = available ?? []; const known = new Set(options.map((option) => option.genre)); @@ -75,9 +68,11 @@ export const actions: Actions = { } }); - const urlsFailure = unwrapOrFail(urlsResult, 'No se pudieron reservar las URLs de subida.'); + const { data: urls, failure: urlsFailure } = requireData( + urlsResult, + 'No se pudieron reservar las URLs de subida.' + ); if (urlsFailure) return urlsFailure; - const urls = urlsResult.data!; try { const [audioBuffer, coverBuffer] = await Promise.all([ @@ -105,10 +100,12 @@ export const actions: Actions = { } }); - const trackFailure = unwrapOrFail(trackResult, 'No se pudo crear la pista tras la subida.'); + const { data: track, failure: trackFailure } = requireData( + trackResult, + 'No se pudo crear la pista tras la subida.' + ); if (trackFailure) return trackFailure; - const track = trackResult.data!; return { success: true, trackId: track.id, title: track.title }; - } + }, 'Inicia sesión para subir música.') }; diff --git a/web-player/src/routes/(app)/user/[id]/+page.server.ts b/web-player/src/routes/(app)/user/[id]/+page.server.ts index 5e278c8..4133a25 100644 --- a/web-player/src/routes/(app)/user/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/user/[id]/+page.server.ts @@ -1,14 +1,13 @@ import type { PageServerLoad, Actions } from './$types'; -import { createApiClient, optionalUser, unwrapOrError } from '$lib/server/api'; +import { apiFor, optionalUser, unwrapOrError } from '$lib/server/api'; import { toPlaylistSummary } from '$lib/server/mappers'; +import { COUNT_ONLY_PAGE_SIZE, PROFILE_PLAYLISTS_PAGE_SIZE } from '$lib/config'; import { followUserAction, unfollowUserAction } from '$lib/server/followActions'; -const PROFILE_PLAYLISTS_PAGE_SIZE = 50; - export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => { const { allowAnonymousListening } = await parent(); const viewer = optionalUser(locals, url, allowAnonymousListening); - const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + const api = apiFor({ fetch, locals }); const [profileRes, playlistsRes] = await Promise.all([ api.GET('/users/{id}/profile', { params: { path: { id: params.id } } }), @@ -27,7 +26,10 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent playlistItems.map((playlist) => api .GET('/playlists/{playlistId}/tracks', { - params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 1 } } + params: { + path: { playlistId: playlist.id }, + query: { pageNumber: 1, pageSize: COUNT_ONLY_PAGE_SIZE } + } }) .then((res) => Number(res.data?.totalItemCount ?? 0)) ) diff --git a/web-player/src/routes/(app)/user/[id]/[list=followList]/+page.server.ts b/web-player/src/routes/(app)/user/[id]/[list=followList]/+page.server.ts index 1af94c9..d7befb2 100644 --- a/web-player/src/routes/(app)/user/[id]/[list=followList]/+page.server.ts +++ b/web-player/src/routes/(app)/user/[id]/[list=followList]/+page.server.ts @@ -1,5 +1,5 @@ import type { PageServerLoad } from './$types'; -import { createApiClient, optionalUser, unwrapOrError } from '$lib/server/api'; +import { apiFor, optionalUser, unwrapOrError } from '$lib/server/api'; import { toPage } from '$lib/server/mappers'; import { fetchFollowList } from '$lib/server/follows'; import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config'; @@ -7,7 +7,7 @@ import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config'; export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => { const { allowAnonymousListening } = await parent(); const viewer = optionalUser(locals, url, allowAnonymousListening); - const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + const api = apiFor({ fetch, locals }); const [profileRes, listRes] = await Promise.all([ api.GET('/users/{id}/profile', { params: { path: { id: params.id } } }), diff --git a/web-player/src/routes/+layout.server.ts b/web-player/src/routes/+layout.server.ts index 26879de..dcb70b2 100644 --- a/web-player/src/routes/+layout.server.ts +++ b/web-player/src/routes/+layout.server.ts @@ -1,65 +1,21 @@ -import { redirect } from '@sveltejs/kit'; import type { LayoutServerLoad } from './$types'; import { getAllowAnonymousListening } from '$lib/server/playbackConfig'; -import { createApiClient } from '$lib/server/api'; +import { apiFor, loginRedirect } from '$lib/server/api'; import { toLikedTrack, toPlaylist, toTrack } from '$lib/server/mappers'; import { authConfig } from '$lib/server/config'; import { LIKED_TRACKS_PAGE_SIZE, PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config'; const PUBLIC_PATHS = new Set(['/auth']); -async function fetchUserPlaylists( - fetchFn: typeof fetch, - accessToken: string | null, - userId: string -) { - try { - const api = createApiClient({ fetch: fetchFn, accessToken: accessToken ?? undefined }); - const { data } = await api.GET('/playlists/users/{userId}', { - params: { - path: { userId }, - query: { pageNumber: 1, pageSize: PLAYLIST_PICKER_PAGE_SIZE } - } - }); - return { items: (data?.items ?? []).map(toPlaylist), total: Number(data?.totalItemCount ?? 0) }; - } catch { - return { items: [], total: 0 }; - } -} - -async function fetchLikedTracks(fetchFn: typeof fetch, accessToken: string | null) { - try { - const api = createApiClient({ fetch: fetchFn, accessToken: accessToken ?? undefined }); - const { data } = await api.GET('/likes', { - params: { query: { pageNumber: 1, pageSize: LIKED_TRACKS_PAGE_SIZE } } - }); - return (data?.items ?? []).map(toLikedTrack); - } catch { - return []; - } -} - -async function fetchLastPlayedTrack( - fetchFn: typeof fetch, - accessToken: string | null, - userId: string -) { - try { - const api = createApiClient({ fetch: fetchFn, accessToken: accessToken ?? undefined }); - const { data } = await api.GET('/users/{id}/last-listened-track', { - params: { path: { id: userId } } - }); - return data ? toTrack(data) : null; - } catch { - return null; - } +function orFallback(load: Promise, fallback: T): Promise { + return load.catch(() => fallback); } export const load: LayoutServerLoad = async ({ locals, url, fetch }) => { const allowAnonymousListening = await getAllowAnonymousListening(fetch); if (!locals.user && !allowAnonymousListening && !PUBLIC_PATHS.has(url.pathname)) { - redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`); + loginRedirect(url); } const accountUrl = `${authConfig.issuer.replace(/\/+$/, '')}/ui/console`; @@ -76,10 +32,38 @@ export const load: LayoutServerLoad = async ({ locals, url, fetch }) => { }; } + const api = apiFor({ fetch, locals }); + const userId = locals.user.sub; + const [userPlaylists, likedTracks, lastPlayedTrack] = await Promise.all([ - fetchUserPlaylists(fetch, locals.accessToken, locals.user.sub), - fetchLikedTracks(fetch, locals.accessToken), - fetchLastPlayedTrack(fetch, locals.accessToken, locals.user.sub) + orFallback( + api + .GET('/playlists/users/{userId}', { + params: { + path: { userId }, + query: { pageNumber: 1, pageSize: PLAYLIST_PICKER_PAGE_SIZE } + } + }) + .then(({ data }) => ({ + items: (data?.items ?? []).map(toPlaylist), + total: Number(data?.totalItemCount ?? 0) + })), + { items: [], total: 0 } + ), + orFallback( + api + .GET('/likes', { + params: { query: { pageNumber: 1, pageSize: LIKED_TRACKS_PAGE_SIZE } } + }) + .then(({ data }) => (data?.items ?? []).map(toLikedTrack)), + [] + ), + orFallback( + api + .GET('/users/{id}/last-listened-track', { params: { path: { id: userId } } }) + .then(({ data }) => (data ? toTrack(data) : null)), + null + ) ]); return { diff --git a/web-player/src/routes/+page.server.ts b/web-player/src/routes/+page.server.ts index 98d5a0f..ed82526 100644 --- a/web-player/src/routes/+page.server.ts +++ b/web-player/src/routes/+page.server.ts @@ -1,6 +1,6 @@ import { redirect } from '@sveltejs/kit'; import type { PageServerLoad, Actions } from './$types'; -import { createApiClient, requireUser } from '$lib/server/api'; +import { apiFor, requireUser } from '$lib/server/api'; import { toMix, toPlaylist, toTrack } from '$lib/server/mappers'; import { addTrackAction } from '$lib/server/playlistActions'; import { HOME_MIXES_LIMIT, HOME_SHELF_LIMIT, HOME_SPOTLIGHT_TRACKS_LIMIT } from '$lib/config'; @@ -9,7 +9,7 @@ import { pickGreeting } from '$lib/server/greeting'; export const load: PageServerLoad = async ({ locals, url, fetch }) => { if (!locals.user) redirect(302, '/explore'); const user = requireUser(locals, url); - const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + const api = apiFor({ fetch, locals }); const playlistsPromise = api.GET('/playlists/users/{userId}', { params: { diff --git a/web-player/src/routes/api/albums/[id]/tracks/+server.ts b/web-player/src/routes/api/albums/[id]/tracks/+server.ts index 8df1853..853006d 100644 --- a/web-player/src/routes/api/albums/[id]/tracks/+server.ts +++ b/web-player/src/routes/api/albums/[id]/tracks/+server.ts @@ -1,11 +1,11 @@ import type { RequestHandler } from './$types'; import { json } from '@sveltejs/kit'; -import { createApiClient, unwrapOrError } from '$lib/server/api'; +import { apiFor, unwrapOrError } from '$lib/server/api'; import { toPage, toTrack } from '$lib/server/mappers'; import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config'; export const GET: RequestHandler = async ({ params, locals, fetch }) => { - const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + const api = apiFor({ fetch, locals }); const result = await api.GET('/albums/{albumId}/tracks', { params: { path: { albumId: params.id }, diff --git a/web-player/src/routes/api/likes/+server.ts b/web-player/src/routes/api/likes/+server.ts index 836c30c..bb2659c 100644 --- a/web-player/src/routes/api/likes/+server.ts +++ b/web-player/src/routes/api/likes/+server.ts @@ -1,6 +1,6 @@ import type { RequestHandler } from './$types'; import { json } from '@sveltejs/kit'; -import { createApiClient } from '$lib/server/api'; +import { apiFor } from '$lib/server/api'; export const POST: RequestHandler = async ({ request, locals, fetch }) => { if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 }); @@ -10,7 +10,7 @@ export const POST: RequestHandler = async ({ request, locals, fetch }) => { if (typeof trackId !== 'string' || trackId === '') return json({ message: 'Canción inválida.' }, { status: 400 }); - const api = createApiClient({ fetch, accessToken: locals.accessToken }); + const api = apiFor({ fetch, locals }); const result = await api.POST('/likes/toggle', { body: { trackId } }); if (result.error || result.data === undefined) diff --git a/web-player/src/routes/api/listens/[id]/progress/+server.ts b/web-player/src/routes/api/listens/[id]/progress/+server.ts index 7cf9e67..fc83bad 100644 --- a/web-player/src/routes/api/listens/[id]/progress/+server.ts +++ b/web-player/src/routes/api/listens/[id]/progress/+server.ts @@ -1,6 +1,6 @@ import type { RequestHandler } from './$types'; import { json } from '@sveltejs/kit'; -import { createApiClient } from '$lib/server/api'; +import { apiFor } from '$lib/server/api'; export const PUT: RequestHandler = async ({ params, request, locals, fetch }) => { if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 }); @@ -10,7 +10,7 @@ export const PUT: RequestHandler = async ({ params, request, locals, fetch }) => if (typeof playedSeconds !== 'number' || !Number.isFinite(playedSeconds) || playedSeconds < 0) return json({ message: 'Tiempo inválido.' }, { status: 400 }); - const api = createApiClient({ fetch, accessToken: locals.accessToken }); + const api = apiFor({ fetch, locals }); const result = await api.PUT('/tracks/listens/{listenId}/progress', { params: { path: { listenId: params.id } }, body: { playedSeconds } diff --git a/web-player/src/routes/api/playlists/[id]/tracks/+server.ts b/web-player/src/routes/api/playlists/[id]/tracks/+server.ts index 29b103d..666e35f 100644 --- a/web-player/src/routes/api/playlists/[id]/tracks/+server.ts +++ b/web-player/src/routes/api/playlists/[id]/tracks/+server.ts @@ -1,11 +1,11 @@ import type { RequestHandler } from './$types'; import { json } from '@sveltejs/kit'; -import { createApiClient, unwrapOrError } from '$lib/server/api'; +import { apiFor, unwrapOrError } from '$lib/server/api'; import { toPage, toTrack } from '$lib/server/mappers'; import { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config'; export const GET: RequestHandler = async ({ params, locals, fetch }) => { - const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + const api = apiFor({ fetch, locals }); const result = await api.GET('/playlists/{playlistId}/tracks', { params: { path: { playlistId: params.id }, diff --git a/web-player/src/routes/api/tracks/+server.ts b/web-player/src/routes/api/tracks/+server.ts index 6cd7c1d..f90eeed 100644 --- a/web-player/src/routes/api/tracks/+server.ts +++ b/web-player/src/routes/api/tracks/+server.ts @@ -1,6 +1,6 @@ import type { RequestHandler } from './$types'; import { json } from '@sveltejs/kit'; -import { createApiClient, unwrapOrError } from '$lib/server/api'; +import { apiFor, unwrapOrError } from '$lib/server/api'; import { toPage, toTrack } from '$lib/server/mappers'; import { EXPLORE_PAGE_SIZE, MAX_PAGE_SIZE } from '$lib/config'; @@ -13,7 +13,7 @@ export const GET: RequestHandler = async ({ url, locals, fetch }) => { Math.max(1, Number(url.searchParams.get('pageSize')) || EXPLORE_PAGE_SIZE) ); - const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + const api = apiFor({ fetch, locals }); const result = await api.GET('/tracks', { params: { query: { name, genre, pageNumber, pageSize } } }); diff --git a/web-player/src/routes/api/tracks/[id]/stream/+server.ts b/web-player/src/routes/api/tracks/[id]/stream/+server.ts index 84ec1bd..b219b20 100644 --- a/web-player/src/routes/api/tracks/[id]/stream/+server.ts +++ b/web-player/src/routes/api/tracks/[id]/stream/+server.ts @@ -1,9 +1,9 @@ import type { RequestHandler } from './$types'; import { json } from '@sveltejs/kit'; -import { createApiClient, unwrapOrError } from '$lib/server/api'; +import { apiFor, unwrapOrError } from '$lib/server/api'; export const GET: RequestHandler = async ({ params, locals, fetch }) => { - const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + const api = apiFor({ fetch, locals }); const result = await api.GET('/tracks/{id}/stream', { params: { path: { id: params.id } } }); diff --git a/web-player/src/routes/api/users/[id]/[list=followList]/+server.ts b/web-player/src/routes/api/users/[id]/[list=followList]/+server.ts index 6ccd838..6fc519a 100644 --- a/web-player/src/routes/api/users/[id]/[list=followList]/+server.ts +++ b/web-player/src/routes/api/users/[id]/[list=followList]/+server.ts @@ -1,13 +1,13 @@ import type { RequestHandler } from './$types'; import { json } from '@sveltejs/kit'; -import { createApiClient, unwrapOrError } from '$lib/server/api'; +import { apiFor, unwrapOrError } from '$lib/server/api'; import { toPage } from '$lib/server/mappers'; import { fetchFollowList } from '$lib/server/follows'; import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config'; export const GET: RequestHandler = async ({ params, url, locals, fetch }) => { const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1); - const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + const api = apiFor({ fetch, locals }); const result = await fetchFollowList( api, params.list, diff --git a/web-player/src/routes/auth/callback/+server.ts b/web-player/src/routes/auth/callback/+server.ts index 97ac6f7..b15abea 100644 --- a/web-player/src/routes/auth/callback/+server.ts +++ b/web-player/src/routes/auth/callback/+server.ts @@ -34,8 +34,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => { expectedNonce: nonce, idTokenExpected: true }); - } catch (exception) { - console.error('Token exchange failed', exception); + } catch (err) { + console.error('Token exchange failed', err); error(401, 'La autenticación ha fallado.'); } From 1df7f6ccf62e9f5416817cabc1e2b40f2ac16866 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 05:19:41 +0200 Subject: [PATCH 09/34] web-player: share form limits between client and server and extract form parsers --- .../lib/components/ui/forms/AlbumForm.svelte | 9 ++-- .../components/ui/forms/PlaylistForm.svelte | 9 ++-- web-player/src/lib/config.ts | 1 - .../lib/server/{ => forms}/albumForm.test.ts | 2 +- .../src/lib/server/{ => forms}/albumForm.ts | 12 +++-- .../src/lib/server/forms/playlistForm.test.ts | 34 +++++++++++++ .../src/lib/server/forms/playlistForm.ts | 42 ++++++++++++++++ .../lib/server/forms/trackUploadForm.test.ts | 36 ++++++++++++++ .../src/lib/server/forms/trackUploadForm.ts | 30 ++++++++++++ web-player/src/lib/server/upload.ts | 17 +++++++ web-player/src/lib/utils/format.ts | 6 +++ web-player/src/lib/validation.ts | 9 ++++ .../src/routes/(app)/albums/+page.server.ts | 2 +- .../routes/(app)/albums/[id]/+page.server.ts | 20 +++----- .../routes/(app)/playlists/+page.server.ts | 48 +++++++------------ .../(app)/playlists/[id]/+page.server.ts | 36 ++++++-------- .../src/routes/(app)/upload/+page.server.ts | 31 +++--------- .../src/routes/(app)/upload/+page.svelte | 14 ++---- 18 files changed, 239 insertions(+), 119 deletions(-) rename web-player/src/lib/server/{ => forms}/albumForm.test.ts (96%) rename web-player/src/lib/server/{ => forms}/albumForm.ts (70%) create mode 100644 web-player/src/lib/server/forms/playlistForm.test.ts create mode 100644 web-player/src/lib/server/forms/playlistForm.ts create mode 100644 web-player/src/lib/server/forms/trackUploadForm.test.ts create mode 100644 web-player/src/lib/server/forms/trackUploadForm.ts create mode 100644 web-player/src/lib/validation.ts diff --git a/web-player/src/lib/components/ui/forms/AlbumForm.svelte b/web-player/src/lib/components/ui/forms/AlbumForm.svelte index 3442c45..9da82b3 100644 --- a/web-player/src/lib/components/ui/forms/AlbumForm.svelte +++ b/web-player/src/lib/components/ui/forms/AlbumForm.svelte @@ -5,7 +5,7 @@ import Field from '../primitives/Field.svelte'; import Input from '../primitives/Input.svelte'; import Textarea from '../primitives/Textarea.svelte'; - import { ALBUM_EARLIEST_YEAR } from '$lib/config'; + import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation'; interface Props { action: string; @@ -58,10 +58,13 @@ > {#snippet fields()} + {#snippet hint()} + {title.length}/{LIMITS.albumTitle} + {/snippet} diff --git a/web-player/src/lib/components/ui/forms/PlaylistForm.svelte b/web-player/src/lib/components/ui/forms/PlaylistForm.svelte index e298e74..e0b8663 100644 --- a/web-player/src/lib/components/ui/forms/PlaylistForm.svelte +++ b/web-player/src/lib/components/ui/forms/PlaylistForm.svelte @@ -7,8 +7,7 @@ import Textarea from '../primitives/Textarea.svelte'; import SegmentedControl from '../primitives/SegmentedControl.svelte'; - const NAME_MAX_LENGTH = 60; - const DESCRIPTION_MAX_LENGTH = 300; + import { LIMITS } from '$lib/validation'; interface Props { action: string; @@ -61,12 +60,12 @@ {#snippet hint()} - {name.length}/{NAME_MAX_LENGTH} + {name.length}/{LIMITS.playlistName} {/snippet} ): FormData { diff --git a/web-player/src/lib/server/albumForm.ts b/web-player/src/lib/server/forms/albumForm.ts similarity index 70% rename from web-player/src/lib/server/albumForm.ts rename to web-player/src/lib/server/forms/albumForm.ts index e3dfdf0..b4776a4 100644 --- a/web-player/src/lib/server/albumForm.ts +++ b/web-player/src/lib/server/forms/albumForm.ts @@ -1,5 +1,5 @@ import { formString } from '$lib/server/api'; -import { ALBUM_EARLIEST_YEAR } from '$lib/config'; +import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation'; interface AlbumFormBody { title: string; @@ -9,13 +9,15 @@ interface AlbumFormBody { export function parseAlbumForm(form: FormData): { body: AlbumFormBody } | { failMessage: string } { const title = formString(form, 'title').trim(); - if (title === '' || title.length > 200) { - return { failMessage: 'El título es obligatorio (máx. 200 caracteres).' }; + if (title === '' || title.length > LIMITS.albumTitle) { + return { failMessage: `El título es obligatorio (máx. ${LIMITS.albumTitle} caracteres).` }; } const description = formString(form, 'description').trim(); - if (description.length > 256) { - return { failMessage: 'La descripción no puede pasar de 256 caracteres.' }; + if (description.length > LIMITS.albumDescription) { + return { + failMessage: `La descripción no puede pasar de ${LIMITS.albumDescription} caracteres.` + }; } const rawYear = formString(form, 'releaseYear').trim(); diff --git a/web-player/src/lib/server/forms/playlistForm.test.ts b/web-player/src/lib/server/forms/playlistForm.test.ts new file mode 100644 index 0000000..ff11961 --- /dev/null +++ b/web-player/src/lib/server/forms/playlistForm.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { LIMITS } from '$lib/validation'; +import { parsePlaylistForm } from './playlistForm'; + +function form(fields: Record): FormData { + const data = new FormData(); + for (const [key, value] of Object.entries(fields)) data.set(key, value); + return data; +} + +describe('parsePlaylistForm', () => { + it('parses a valid form', () => { + expect( + parsePlaylistForm(form({ name: ' Mix ', description: ' d ', visibility: 'public' })) + ).toEqual({ body: { name: 'Mix', description: 'd', visibility: 'Public', cover: null } }); + }); + it('defaults to private', () => { + const parsed = parsePlaylistForm(form({ name: 'Mix' })); + expect(parsed).toMatchObject({ body: { visibility: 'Private', description: '' } }); + }); + it('rejects empty or too long names keeping the typed values', () => { + expect(parsePlaylistForm(form({ name: ' ', description: 'd' }))).toMatchObject({ + failMessage: expect.any(String), + description: 'd' + }); + expect(parsePlaylistForm(form({ name: 'x'.repeat(LIMITS.playlistName + 1) }))).toHaveProperty( + 'failMessage' + ); + }); + it('rejects too long descriptions', () => { + const description = 'x'.repeat(LIMITS.playlistDescription + 1); + expect(parsePlaylistForm(form({ name: 'Mix', description }))).toHaveProperty('failMessage'); + }); +}); diff --git a/web-player/src/lib/server/forms/playlistForm.ts b/web-player/src/lib/server/forms/playlistForm.ts new file mode 100644 index 0000000..f89ee5e --- /dev/null +++ b/web-player/src/lib/server/forms/playlistForm.ts @@ -0,0 +1,42 @@ +import { formFile, formString } from '$lib/server/api'; +import { LIMITS } from '$lib/validation'; + +interface PlaylistFormBody { + name: string; + description: string; + visibility: 'Public' | 'Private'; + cover: File | null; +} + +export type PlaylistFormFailure = { failMessage: string; name: string; description: string }; + +export function parsePlaylistForm( + form: FormData +): { body: PlaylistFormBody } | PlaylistFormFailure { + const name = formString(form, 'name').trim(); + const description = formString(form, 'description').trim(); + + if (name === '' || name.length > LIMITS.playlistName) { + return { + failMessage: `El nombre es obligatorio (máx. ${LIMITS.playlistName} caracteres).`, + name, + description + }; + } + if (description.length > LIMITS.playlistDescription) { + return { + failMessage: `La descripción no puede pasar de ${LIMITS.playlistDescription} caracteres.`, + name, + description + }; + } + + return { + body: { + name, + description, + visibility: formString(form, 'visibility') === 'public' ? 'Public' : 'Private', + cover: formFile(form, 'cover') + } + }; +} diff --git a/web-player/src/lib/server/forms/trackUploadForm.test.ts b/web-player/src/lib/server/forms/trackUploadForm.test.ts new file mode 100644 index 0000000..a8fc0cd --- /dev/null +++ b/web-player/src/lib/server/forms/trackUploadForm.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { LIMITS } from '$lib/validation'; +import { parseTrackUploadForm } from './trackUploadForm'; + +function form(fields: Record): FormData { + const data = new FormData(); + for (const [key, value] of Object.entries(fields)) { + if (Array.isArray(value)) value.forEach((item) => data.append(key, item)); + else data.set(key, value); + } + return data; +} + +const audio = new File(['a'], 'a.mp3'); +const cover = new File(['c'], 'c.png'); + +describe('parseTrackUploadForm', () => { + it('parses a valid form and deduplicates tags', () => { + const parsed = parseTrackUploadForm( + form({ title: ' Song ', audio, cover, tags: ['rock', 'rock', 'pop'], isExplicit: 'on' }) + ); + expect(parsed).toMatchObject({ + body: { title: 'Song', tags: ['rock', 'pop'], isExplicit: true } + }); + }); + it('validates each field', () => { + const ok = { title: 'Song', audio, cover, tags: ['rock'] }; + expect(parseTrackUploadForm(form({ ...ok, title: '' }))).toHaveProperty('failMessage'); + expect( + parseTrackUploadForm(form({ ...ok, title: 'x'.repeat(LIMITS.trackTitle + 1) })) + ).toHaveProperty('failMessage'); + expect(parseTrackUploadForm(form({ ...ok, audio: '' }))).toHaveProperty('failMessage'); + expect(parseTrackUploadForm(form({ ...ok, cover: '' }))).toHaveProperty('failMessage'); + expect(parseTrackUploadForm(form({ ...ok, tags: [] }))).toHaveProperty('failMessage'); + }); +}); diff --git a/web-player/src/lib/server/forms/trackUploadForm.ts b/web-player/src/lib/server/forms/trackUploadForm.ts new file mode 100644 index 0000000..f1ef7e9 --- /dev/null +++ b/web-player/src/lib/server/forms/trackUploadForm.ts @@ -0,0 +1,30 @@ +import { formFile, formString } from '$lib/server/api'; +import { LIMITS } from '$lib/validation'; + +interface TrackUploadBody { + title: string; + audio: File; + cover: File; + tags: string[]; + isExplicit: boolean; +} + +export function parseTrackUploadForm( + form: FormData +): { body: TrackUploadBody } | { failMessage: string } { + const title = formString(form, 'title').trim(); + if (title === '' || title.length > LIMITS.trackTitle) { + return { failMessage: `El título es obligatorio (máx. ${LIMITS.trackTitle} caracteres).` }; + } + + const audio = formFile(form, 'audio'); + if (!audio) return { failMessage: 'Selecciona un archivo de audio.' }; + + const cover = formFile(form, 'cover'); + if (!cover) return { failMessage: 'Selecciona una portada.' }; + + const tags = [...new Set(form.getAll('tags').map(String))]; + if (tags.length === 0) return { failMessage: 'Elige al menos un género.' }; + + return { body: { title, audio, cover, tags, isExplicit: form.get('isExplicit') === 'on' } }; +} diff --git a/web-player/src/lib/server/upload.ts b/web-player/src/lib/server/upload.ts index c21ce52..797a8b2 100644 --- a/web-player/src/lib/server/upload.ts +++ b/web-player/src/lib/server/upload.ts @@ -78,6 +78,8 @@ export async function putPresigned( }); } +import type { ApiClient } from '$lib/server/api'; + export type UploadImageResult = { intentId: string } | { failMessage: string; detail?: string }; export async function uploadPresignedImage( @@ -109,3 +111,18 @@ export async function uploadPresignedImage( return { intentId: reserved.intentId }; } + +export async function uploadOptionalCover( + api: ApiClient, + endpoint: '/albums/upload-picture' | '/playlists/upload-picture', + file: File | null +): Promise<{ intentId: string | null } | { failMessage: string; detail?: string }> { + if (!file) return { intentId: null }; + return uploadPresignedImage( + (args) => + endpoint === '/albums/upload-picture' + ? api.POST('/albums/upload-picture', { body: args }) + : api.POST('/playlists/upload-picture', { body: args }), + file + ); +} diff --git a/web-player/src/lib/utils/format.ts b/web-player/src/lib/utils/format.ts index c983da3..fc82c36 100644 --- a/web-player/src/lib/utils/format.ts +++ b/web-player/src/lib/utils/format.ts @@ -36,6 +36,12 @@ export function albumMeta(releaseYear?: number | null, trackCount?: number): str .join(' · '); } +export function formatSize(bytes: number): string { + if (bytes === 0) return ''; + const mb = bytes / (1024 * 1024); + return mb >= 1 ? `${mb.toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`; +} + export function playlistMeta(trackCount: number): string { return plural(trackCount, 'canción', 'canciones'); } diff --git a/web-player/src/lib/validation.ts b/web-player/src/lib/validation.ts new file mode 100644 index 0000000..f4cd8e0 --- /dev/null +++ b/web-player/src/lib/validation.ts @@ -0,0 +1,9 @@ +export const ALBUM_EARLIEST_YEAR = 1877; + +export const LIMITS = { + playlistName: 50, + playlistDescription: 256, + albumTitle: 200, + albumDescription: 256, + trackTitle: 200 +} as const; diff --git a/web-player/src/routes/(app)/albums/+page.server.ts b/web-player/src/routes/(app)/albums/+page.server.ts index 692b1e3..9dd0d7d 100644 --- a/web-player/src/routes/(app)/albums/+page.server.ts +++ b/web-player/src/routes/(app)/albums/+page.server.ts @@ -3,7 +3,7 @@ import { fail, redirect } from '@sveltejs/kit'; import { apiFor, authedAction, formFile, requireUser, unwrapOrError } from '$lib/server/api'; import { toAlbum, toPage } from '$lib/server/mappers'; import { ALBUMS_PAGE_SIZE } from '$lib/config'; -import { parseAlbumForm } from '$lib/server/albumForm'; +import { parseAlbumForm } from '$lib/server/forms/albumForm'; import { uploadPresignedImage } from '$lib/server/upload'; export const load: PageServerLoad = async ({ locals, url, fetch }) => { diff --git a/web-player/src/routes/(app)/albums/[id]/+page.server.ts b/web-player/src/routes/(app)/albums/[id]/+page.server.ts index 8b494e6..4e6864e 100644 --- a/web-player/src/routes/(app)/albums/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/albums/[id]/+page.server.ts @@ -11,8 +11,8 @@ import { } from '$lib/server/api'; import { toAlbum, toTrack } from '$lib/server/mappers'; import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config'; -import { parseAlbumForm } from '$lib/server/albumForm'; -import { uploadPresignedImage } from '$lib/server/upload'; +import { parseAlbumForm } from '$lib/server/forms/albumForm'; +import { uploadOptionalCover } from '$lib/server/upload'; export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => { const { allowAnonymousListening } = await parent(); @@ -75,17 +75,9 @@ export const actions: Actions = { const parsed = parseAlbumForm(form); if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage }); - let newPictureIntentId: string | null = null; - const cover = formFile(form, 'cover'); - if (cover) { - const uploaded = await uploadPresignedImage( - (args) => api.POST('/albums/upload-picture', { body: args }), - cover - ); - if ('failMessage' in uploaded) { - return fail(502, { message: uploaded.failMessage, detail: uploaded.detail }); - } - newPictureIntentId = uploaded.intentId; + const cover = await uploadOptionalCover(api, '/albums/upload-picture', formFile(form, 'cover')); + if ('failMessage' in cover) { + return fail(502, { message: cover.failMessage, detail: cover.detail }); } const result = await api.PUT('/albums/{albumId}', { @@ -94,7 +86,7 @@ export const actions: Actions = { newTitle: parsed.body.title, newDescription: parsed.body.description, newReleaseYear: parsed.body.releaseYear, - newPictureIntentId + newPictureIntentId: cover.intentId } }); return failOnError(result, 'No se pudo actualizar el álbum.') ?? { edited: true }; diff --git a/web-player/src/routes/(app)/playlists/+page.server.ts b/web-player/src/routes/(app)/playlists/+page.server.ts index 54c9f25..6538e5e 100644 --- a/web-player/src/routes/(app)/playlists/+page.server.ts +++ b/web-player/src/routes/(app)/playlists/+page.server.ts @@ -1,18 +1,10 @@ import type { PageServerLoad, Actions } from './$types'; import { fail, redirect } from '@sveltejs/kit'; -import { - apiFor, - authedAction, - formFile, - formString, - requireUser, - unwrapOrError -} from '$lib/server/api'; +import { apiFor, authedAction, requireUser, unwrapOrError } from '$lib/server/api'; import { PLAYLISTS_PAGE_SIZE, PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE } from '$lib/config'; import { toPlaylistSummary, toTrack } from '$lib/server/mappers'; -import { uploadPresignedImage } from '$lib/server/upload'; - -const MAX_NAME = 100; +import { parsePlaylistForm } from '$lib/server/forms/playlistForm'; +import { uploadOptionalCover } from '$lib/server/upload'; export const load: PageServerLoad = async ({ locals, url, fetch }) => { const user = requireUser(locals, url); @@ -51,33 +43,25 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => { export const actions: Actions = { create: authedAction(async ({ api, form }) => { - const name = formString(form, 'name').trim(); - const description = formString(form, 'description').trim(); - const visibility = formString(form, 'visibility') === 'public' ? 'Public' : 'Private'; + const parsed = parsePlaylistForm(form); + if ('failMessage' in parsed) { + const { failMessage: message, name, description } = parsed; + return fail(400, { message, name, description }); + } + const { name, description, visibility, cover } = parsed.body; - if (name === '' || name.length > MAX_NAME) { - return fail(400, { - message: 'El nombre es obligatorio (máx. 100 caracteres).', + const uploaded = await uploadOptionalCover(api, '/playlists/upload-picture', cover); + if ('failMessage' in uploaded) { + return fail(502, { + message: uploaded.failMessage, name, - description + description, + detail: uploaded.detail }); } - let pictureIntentId: string | null = null; - const cover = formFile(form, 'cover'); - if (cover) { - const result = await uploadPresignedImage( - (args) => api.POST('/playlists/upload-picture', { body: args }), - cover - ); - if ('failMessage' in result) { - return fail(502, { message: result.failMessage, name, description, detail: result.detail }); - } - pictureIntentId = result.intentId; - } - const { data, error } = await api.POST('/playlists', { - body: { name, description, pictureIntentId, visibility } + body: { name, description, pictureIntentId: uploaded.intentId, visibility } }); if (error || !data) diff --git a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts index 797d354..44f11ba 100644 --- a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts @@ -4,16 +4,14 @@ import { apiFor, authedAction, failOnError, - formFile, formString, optionalUser, unwrapOrError } from '$lib/server/api'; import { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config'; import { toDatedTrack, toPlaylist } from '$lib/server/mappers'; -import { uploadPresignedImage } from '$lib/server/upload'; - -const MAX_NAME = 100; +import { parsePlaylistForm } from '$lib/server/forms/playlistForm'; +import { uploadOptionalCover } from '$lib/server/upload'; export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => { const { allowAnonymousListening } = await parent(); @@ -50,29 +48,23 @@ export const actions: Actions = { }), edit: authedAction(async ({ api, form, params }) => { - const name = formString(form, 'name').trim(); - const description = formString(form, 'description').trim(); - const newVisibility = formString(form, 'visibility') === 'public' ? 'Public' : 'Private'; - if (name === '' || name.length > MAX_NAME) { - return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).' }); - } + const parsed = parsePlaylistForm(form); + if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage }); + const { name, description, visibility, cover } = parsed.body; - let newPictureIntentId: string | null = null; - const cover = formFile(form, 'cover'); - if (cover) { - const result = await uploadPresignedImage( - (args) => api.POST('/playlists/upload-picture', { body: args }), - cover - ); - if ('failMessage' in result) { - return fail(502, { message: result.failMessage, detail: result.detail }); - } - newPictureIntentId = result.intentId; + const uploaded = await uploadOptionalCover(api, '/playlists/upload-picture', cover); + if ('failMessage' in uploaded) { + return fail(502, { message: uploaded.failMessage, detail: uploaded.detail }); } const result = await api.PUT('/playlists/{playlistId}', { params: { path: { playlistId: params.id } }, - body: { newName: name, newDescription: description, newPictureIntentId, newVisibility } + body: { + newName: name, + newDescription: description, + newPictureIntentId: uploaded.intentId, + newVisibility: visibility + } }); return failOnError(result, 'No se pudo actualizar la playlist.') ?? { edited: true }; }), diff --git a/web-player/src/routes/(app)/upload/+page.server.ts b/web-player/src/routes/(app)/upload/+page.server.ts index 5454749..2c53046 100644 --- a/web-player/src/routes/(app)/upload/+page.server.ts +++ b/web-player/src/routes/(app)/upload/+page.server.ts @@ -1,21 +1,13 @@ import type { Actions, PageServerLoad } from './$types'; import { fail } from '@sveltejs/kit'; -import { - apiFor, - authedAction, - formFile, - formString, - requireData, - requireUser -} from '$lib/server/api'; +import { apiFor, authedAction, requireData, requireUser } from '$lib/server/api'; +import { parseTrackUploadForm } from '$lib/server/forms/trackUploadForm'; import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload'; import { findConflict, genreInfo } from '$lib/data/genres'; import type { components } from '$lib/api/schema'; type Genre = components['schemas']['Genre']; -const MAX_TITLE = 100; - export const load: PageServerLoad = async ({ locals, url, fetch }) => { requireUser(locals, url); const api = apiFor({ fetch, locals }); @@ -25,28 +17,17 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => { export const actions: Actions = { default: authedAction(async ({ api, form }) => { - const title = formString(form, 'title').trim(); - const audio = formFile(form, 'audio'); - const cover = formFile(form, 'cover'); - const rawTags = form.getAll('tags').map(String); - const isExplicit = form.get('isExplicit') === 'on'; + const parsed = parseTrackUploadForm(form); + if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage }); + const { title, audio, cover, tags: rawTags, isExplicit } = parsed.body; - if (title === '' || title.length > MAX_TITLE) { - return fail(400, { message: 'El título es obligatorio (máx. 100 caracteres).' }); - } - if (!audio) return fail(400, { message: 'Selecciona un archivo de audio.' }); - if (!cover) return fail(400, { message: 'Selecciona una portada.' }); - - if (rawTags.length === 0) { - return fail(400, { message: 'Elige al menos un género.' }); - } const { data: available } = await api.GET('/genres/available'); const options = available ?? []; const known = new Set(options.map((option) => option.genre)); if (!rawTags.every((tag) => known.has(tag))) { return fail(400, { message: 'Alguno de los géneros no es válido.' }); } - const tags = [...new Set(rawTags)] as Genre[]; + const tags = rawTags as Genre[]; const conflict = findConflict(tags, options); if (conflict) { return fail(400, { diff --git a/web-player/src/routes/(app)/upload/+page.svelte b/web-player/src/routes/(app)/upload/+page.svelte index 89ecc0a..a5eedd2 100644 --- a/web-player/src/routes/(app)/upload/+page.svelte +++ b/web-player/src/routes/(app)/upload/+page.svelte @@ -15,13 +15,13 @@ import Button from '$lib/components/ui/primitives/Button.svelte'; import ExplicitBadge from '$lib/components/ui/media/ExplicitBadge.svelte'; import Chip from '$lib/components/ui/primitives/Chip.svelte'; + import { LIMITS } from '$lib/validation'; + import { formatSize } from '$lib/utils/format'; import { genreOptions } from '$lib/data/genres'; type Status = 'idle' | 'uploading' | 'done' | 'error'; type StepState = 'idle' | 'active' | 'done'; - const MAX_TITLE = 100; - let { data } = $props(); let title = $state(''); @@ -70,12 +70,6 @@ return s === 'idle' ? '' : 'bg-accent-tint'; } - function formatSize(bytes: number) { - if (bytes === 0) return ''; - const mb = bytes / (1024 * 1024); - return mb >= 1 ? `${mb.toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`; - } - function pickAudio(file: File) { audioName = file.name; audioSize = file.size; @@ -256,12 +250,12 @@ id="title" name="title" bind:value={title} - maxlength={MAX_TITLE} + maxlength={LIMITS.trackTitle} placeholder="Nombre de la canción" /> {#snippet hint()}

Se usa como nombre público de la pista en el catálogo.

- {title.length}/{MAX_TITLE} + {title.length}/{LIMITS.trackTitle} {/snippet}
From b011ccdd5bd9ac099aff055cd3cfa40c5c89d62e Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 05:20:20 +0200 Subject: [PATCH 10/34] backend: expose track count and duration on playlist responses --- .../PlayLists/GetPlayListById.cs | 4 +++- .../PlayLists/GetPlayLists.cs | 4 +++- .../PlayLists/GetPlayListsByUserId.cs | 4 +++- .../Responses/PlayListApplicationResponse.cs | 6 ++++++ .../PlayLists/UpdatePlayList.cs | 17 ++++++++++++++++- 5 files changed, 31 insertions(+), 4 deletions(-) diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs index 0fc11b7..ef55024 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs @@ -21,6 +21,8 @@ namespace Musify.Application.PlayLists .Select(p => new { PlayList = p, + TrackCount = p.PlayListTracks.Count, + DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0, CoverTrackIds = p.PlayListTracks .OrderBy(plt => plt.Position) .Take(PlayListApplicationResponse.CoverTrackCount) @@ -36,7 +38,7 @@ namespace Musify.Application.PlayLists && entry.PlayList.UserId != request.RequestingUserId) return Error.NotFound(); - return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds); + return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds); } } } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayLists.cs b/backend/Core/Musify.Application/PlayLists/GetPlayLists.cs index 07d8874..50df7ce 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayLists.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayLists.cs @@ -25,6 +25,8 @@ namespace Musify.Application.PlayLists .Select(p => new { PlayList = p, + TrackCount = p.PlayListTracks.Count, + DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0, CoverTrackIds = p.PlayListTracks .OrderBy(plt => plt.Position) .Take(PlayListApplicationResponse.CoverTrackCount) @@ -34,7 +36,7 @@ namespace Musify.Application.PlayLists .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); var pagedPlayLists = new StaticPagedList( - pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)), + pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds)), pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); return PaginatedResponse.FromPagedList(pagedPlayLists); diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs index f877f7e..92d24f7 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs @@ -46,6 +46,8 @@ namespace Musify.Application.PlayLists .Select(p => new { PlayList = p, + TrackCount = p.PlayListTracks.Count, + DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0, CoverTrackIds = p.PlayListTracks .OrderBy(plt => plt.Position) .Take(PlayListApplicationResponse.CoverTrackCount) @@ -55,7 +57,7 @@ namespace Musify.Application.PlayLists .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); var pagedPlayLists = new StaticPagedList( - pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)), + pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds)), pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); return PaginatedResponse.FromPagedList(pagedPlayLists); diff --git a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs index a53c82d..1402ab7 100644 --- a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs +++ b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs @@ -14,6 +14,8 @@ namespace Musify.Application.PlayLists.Responses string? LargeImageKeyName, [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId, PlaylistVisibility Visibility, + int TrackCount, + double DurationSeconds, DateTime CreatedAt, DateTime UpdatedAt, IReadOnlyList CoverTrackIds) @@ -22,6 +24,8 @@ namespace Musify.Application.PlayLists.Responses public static PlayListApplicationResponse FromEntity( PlayList playList, + int trackCount = 0, + double durationSeconds = 0, IReadOnlyList? coverTrackIds = null) { return new PlayListApplicationResponse( @@ -33,6 +37,8 @@ namespace Musify.Application.PlayLists.Responses playList.Pictures?.LargeName, playList.UserId, playList.Visibility, + trackCount, + durationSeconds, playList.CreatedAt, playList.UpdatedAt, coverTrackIds ?? []); diff --git a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs index 46cc87a..600f629 100644 --- a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs @@ -112,7 +112,22 @@ namespace Musify.Application.PlayLists .ToListAsync(cancellationToken); logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id); - return PlayListApplicationResponse.FromEntity(playListEntity, coverTrackIds); + var totals = await database.PlayListHasTracks + .AsNoTracking() + .Where(plt => plt.PlayListId == playListEntity.Id) + .GroupBy(plt => plt.PlayListId) + .Select(group => new + { + TrackCount = group.Count(), + DurationSeconds = group.Sum(plt => plt.Track.DurationSeconds) + }) + .SingleOrDefaultAsync(cancellationToken); + + return PlayListApplicationResponse.FromEntity( + playListEntity, + totals?.TrackCount ?? 0, + totals?.DurationSeconds ?? 0, + coverTrackIds); } private async Task> PublishUpdatePlayListPictureSourceEventAsync( From e89463fb8331c4bf54c082381cab5c99f42fe767 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 05:21:29 +0200 Subject: [PATCH 11/34] web-player: use playlist totals from the API, cache genres and parallelize explore requests --- web-player/src/lib/api/schema.d.ts | 4 +++ web-player/src/lib/config.ts | 2 -- web-player/src/lib/server/genres.ts | 18 +++++++++++ web-player/src/lib/server/mappers.test.ts | 20 ++++-------- web-player/src/lib/server/mappers.ts | 8 ++--- web-player/src/lib/types.ts | 6 ++-- .../src/routes/(app)/explore/+page.server.ts | 31 ++++++++++--------- .../routes/(app)/playlists/+page.server.ts | 31 +++++-------------- .../routes/(app)/user/[id]/+page.server.ts | 23 ++------------ web-player/src/routes/+page.svelte | 2 +- 10 files changed, 61 insertions(+), 84 deletions(-) create mode 100644 web-player/src/lib/server/genres.ts diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 863dbff..1192919 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -942,6 +942,10 @@ export interface components { largeImageKeyName: null | string; ownerUserId: string; visibility: components["schemas"]["PlaylistVisibility"]; + /** Format: int32 */ + trackCount: number | string; + /** Format: double */ + durationSeconds: number | string; /** Format: date-time */ createdAt: string; /** Format: date-time */ diff --git a/web-player/src/lib/config.ts b/web-player/src/lib/config.ts index ae7f588..0cf4150 100644 --- a/web-player/src/lib/config.ts +++ b/web-player/src/lib/config.ts @@ -9,9 +9,7 @@ export const PLAYLIST_TRACKS_PAGE_SIZE = 200; export const LIBRARY_PICKER_PAGE_SIZE = 50; export const LIBRARY_PAGE_SIZE = 50; export const PLAYLISTS_PAGE_SIZE = 50; -export const PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE = 500; export const PROFILE_PLAYLISTS_PAGE_SIZE = 50; -export const COUNT_ONLY_PAGE_SIZE = 1; export const EXPLORE_ALBUMS_PAGE_SIZE = 10; export const EXPLORE_ALBUMS_LIMIT = 18; export const HOME_MIXES_LIMIT = 6; diff --git a/web-player/src/lib/server/genres.ts b/web-player/src/lib/server/genres.ts new file mode 100644 index 0000000..a05412c --- /dev/null +++ b/web-player/src/lib/server/genres.ts @@ -0,0 +1,18 @@ +import type { ApiClient } from '$lib/server/api'; +import type { components } from '$lib/api/schema'; + +type GenreInfo = components['schemas']['GenreResponse']; + +const CACHE_TTL_MS = 5 * 60_000; + +let cached: { genres: GenreInfo[]; expiresAt: number } | null = null; + +export async function getGenres(api: ApiClient): Promise { + if (cached && cached.expiresAt > Date.now()) return cached.genres; + + const { data } = await api.GET('/genres'); + if (!data) return cached?.genres ?? []; + + cached = { genres: data, expiresAt: Date.now() + CACHE_TTL_MS }; + return data; +} diff --git a/web-player/src/lib/server/mappers.test.ts b/web-player/src/lib/server/mappers.test.ts index 5e1f0dc..93ec898 100644 --- a/web-player/src/lib/server/mappers.test.ts +++ b/web-player/src/lib/server/mappers.test.ts @@ -1,15 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { components } from '$lib/api/schema'; -import { - toAlbum, - toDatedTrack, - toLikedTrack, - toMix, - toPage, - toPlaylist, - toPlaylistSummary, - toTrack -} from './mappers'; +import { toAlbum, toDatedTrack, toLikedTrack, toMix, toPage, toPlaylist, toTrack } from './mappers'; const trackDto: components['schemas']['TrackApplicationResponse'] = { id: 't1', @@ -107,24 +98,25 @@ describe('toPlaylist', () => { largeImageKeyName: null, ownerUserId: '42', visibility: 'Public', + trackCount: '7', + durationSeconds: '90.5', createdAt: '', updatedAt: 'u', coverTrackIds: [] }; - it('drops storage keys', () => { + it('drops storage keys and normalizes totals', () => { expect(toPlaylist(dto)).toEqual({ id: 'p1', name: 'List', description: 'd', visibility: 'Public', ownerUserId: '42', + trackCount: 7, + durationSeconds: 90.5, coverTrackIds: [], updatedAt: 'u' }); }); - it('adds the track count in summaries', () => { - expect(toPlaylistSummary(dto, 7).trackCount).toBe(7); - }); }); describe('toPage', () => { diff --git a/web-player/src/lib/server/mappers.ts b/web-player/src/lib/server/mappers.ts index d94585b..cfd1175 100644 --- a/web-player/src/lib/server/mappers.ts +++ b/web-player/src/lib/server/mappers.ts @@ -1,5 +1,5 @@ import type { components } from '$lib/api/schema'; -import type { Album, LikedTrack, Mix, Paged, Playlist, PlaylistSummary, Track } from '$lib/types'; +import type { Album, LikedTrack, Mix, Paged, Playlist, Track } from '$lib/types'; type TrackDto = components['schemas']['TrackApplicationResponse']; type AlbumDto = components['schemas']['AlbumApplicationResponse']; @@ -75,15 +75,13 @@ export function toPlaylist(dto: PlaylistDto): Playlist { description: dto.description, visibility: dto.visibility, ownerUserId: dto.ownerUserId, + trackCount: Number(dto.trackCount) || 0, + durationSeconds: Number(dto.durationSeconds) || 0, coverTrackIds: dto.coverTrackIds, updatedAt: dto.updatedAt }; } -export function toPlaylistSummary(dto: PlaylistDto, trackCount: number): PlaylistSummary { - return { ...toPlaylist(dto), trackCount }; -} - export function toPage(dto: PageDto, map: (item: D) => T): Paged { return { items: dto.items.map(map), diff --git a/web-player/src/lib/types.ts b/web-player/src/lib/types.ts index e086f7f..fe43b2c 100644 --- a/web-player/src/lib/types.ts +++ b/web-player/src/lib/types.ts @@ -38,14 +38,12 @@ export interface Playlist { description: string | null; visibility: 'Public' | 'Private'; ownerUserId: string; + trackCount: number; + durationSeconds: number; coverTrackIds: string[]; updatedAt: string; } -export interface PlaylistSummary extends Playlist { - trackCount: number; -} - export interface Mix { id: string; title: string; diff --git a/web-player/src/routes/(app)/explore/+page.server.ts b/web-player/src/routes/(app)/explore/+page.server.ts index 05d1de5..4ea07e4 100644 --- a/web-player/src/routes/(app)/explore/+page.server.ts +++ b/web-player/src/routes/(app)/explore/+page.server.ts @@ -1,5 +1,6 @@ import type { PageServerLoad, Actions } from './$types'; import { apiFor, unwrapOrError } from '$lib/server/api'; +import { getGenres } from '$lib/server/genres'; import { toAlbum, toPage, toTrack } from '$lib/server/mappers'; import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions'; import { EXPLORE_ALBUMS_PAGE_SIZE, EXPLORE_PAGE_SIZE, EXPLORE_USERS_LIMIT } from '$lib/config'; @@ -12,20 +13,17 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => { const user = locals.user; const api = apiFor({ fetch, locals }); - const genresRes = await api.GET('/genres'); - const genres = genresRes.data ?? []; - const genre = genres.find((item) => item.genre === genreParam)?.genre ?? null; - - const tracksPromise = api.GET('/tracks', { - params: { - query: { - name: query || undefined, - genre: genre ?? undefined, - pageNumber: page, - pageSize: EXPLORE_PAGE_SIZE + const tracksFor = (genre: string | null) => + api.GET('/tracks', { + params: { + query: { + name: query || undefined, + genre: genre ?? undefined, + pageNumber: page, + pageSize: EXPLORE_PAGE_SIZE + } } - } - }); + }); const albumsPromise = query ? api.GET('/albums', { @@ -39,11 +37,14 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => { }) : Promise.resolve(null); - const [tracksRes, albumsRes, usersRes] = await Promise.all([ - tracksPromise, + const [genres, firstTracksRes, albumsRes, usersRes] = await Promise.all([ + getGenres(api), + tracksFor(genreParam), albumsPromise, usersPromise ]); + const genre = genres.find((item) => item.genre === genreParam)?.genre ?? null; + const tracksRes = genre === genreParam ? firstTracksRes : await tracksFor(genre); const tracks = toPage(unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.'), (item) => toTrack(item.track) diff --git a/web-player/src/routes/(app)/playlists/+page.server.ts b/web-player/src/routes/(app)/playlists/+page.server.ts index 6538e5e..f3ad2e9 100644 --- a/web-player/src/routes/(app)/playlists/+page.server.ts +++ b/web-player/src/routes/(app)/playlists/+page.server.ts @@ -1,8 +1,8 @@ import type { PageServerLoad, Actions } from './$types'; import { fail, redirect } from '@sveltejs/kit'; import { apiFor, authedAction, requireUser, unwrapOrError } from '$lib/server/api'; -import { PLAYLISTS_PAGE_SIZE, PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE } from '$lib/config'; -import { toPlaylistSummary, toTrack } from '$lib/server/mappers'; +import { PLAYLISTS_PAGE_SIZE } from '$lib/config'; +import { toPlaylist } from '$lib/server/mappers'; import { parsePlaylistForm } from '$lib/server/forms/playlistForm'; import { uploadOptionalCover } from '$lib/server/upload'; @@ -13,32 +13,17 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => { params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: PLAYLISTS_PAGE_SIZE } } }); - const data = unwrapOrError(result, 'No se pudieron cargar tus playlists.'); - - const tracksByPlaylist = await Promise.all( - data.items.map((playlist) => - api - .GET('/playlists/{playlistId}/tracks', { - params: { - path: { playlistId: playlist.id }, - query: { pageNumber: 1, pageSize: PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE } - } - }) - .then((res) => (res.data?.items ?? []).map(toTrack)) - ) - ); - - const items = data.items.map((playlist, i) => - toPlaylistSummary(playlist, tracksByPlaylist[i].length) + const playlists = unwrapOrError(result, 'No se pudieron cargar tus playlists.').items.map( + toPlaylist ); const totals = { - playlistCount: items.length, - trackCount: items.reduce((sum, playlist) => sum + playlist.trackCount, 0), - durationSeconds: tracksByPlaylist.flat().reduce((sum, track) => sum + track.duration, 0) + playlistCount: playlists.length, + trackCount: playlists.reduce((sum, playlist) => sum + playlist.trackCount, 0), + durationSeconds: playlists.reduce((sum, playlist) => sum + playlist.durationSeconds, 0) }; - return { playlists: items, totals }; + return { playlists, totals }; }; export const actions: Actions = { diff --git a/web-player/src/routes/(app)/user/[id]/+page.server.ts b/web-player/src/routes/(app)/user/[id]/+page.server.ts index 4133a25..90947bf 100644 --- a/web-player/src/routes/(app)/user/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/user/[id]/+page.server.ts @@ -1,7 +1,7 @@ import type { PageServerLoad, Actions } from './$types'; import { apiFor, optionalUser, unwrapOrError } from '$lib/server/api'; -import { toPlaylistSummary } from '$lib/server/mappers'; -import { COUNT_ONLY_PAGE_SIZE, PROFILE_PLAYLISTS_PAGE_SIZE } from '$lib/config'; +import { toPlaylist } from '$lib/server/mappers'; +import { PROFILE_PLAYLISTS_PAGE_SIZE } from '$lib/config'; import { followUserAction, unfollowUserAction } from '$lib/server/followActions'; export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => { @@ -20,24 +20,7 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent ]); const profile = unwrapOrError(profileRes, 'Usuario no encontrado.', 404); - const playlistItems = playlistsRes.data?.items ?? []; - - const tracksByPlaylist = await Promise.all( - playlistItems.map((playlist) => - api - .GET('/playlists/{playlistId}/tracks', { - params: { - path: { playlistId: playlist.id }, - query: { pageNumber: 1, pageSize: COUNT_ONLY_PAGE_SIZE } - } - }) - .then((res) => Number(res.data?.totalItemCount ?? 0)) - ) - ); - - const playlists = playlistItems.map((playlist, i) => - toPlaylistSummary(playlist, tracksByPlaylist[i]) - ); + const playlists = (playlistsRes.data?.items ?? []).map(toPlaylist); const isOwnProfile = viewer !== null && viewer.sub === params.id; diff --git a/web-player/src/routes/+page.svelte b/web-player/src/routes/+page.svelte index ac6d985..93f3a83 100644 --- a/web-player/src/routes/+page.svelte +++ b/web-player/src/routes/+page.svelte @@ -155,7 +155,7 @@ {@const meta = totalCount === 0 ? visibilityLabel - : `${plural(totalCount, 'canción', 'canciones')} · ${visibilityLabel}`} + : `${plural(totalCount, 'canción', 'canciones')} · ${fmtDurationLong(spotlight.durationSeconds)} · ${visibilityLabel}`}
From 5cda7cb4c1402390311f2a042e7696aece605697 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 05:25:35 +0200 Subject: [PATCH 12/34] web-player: split player, navigation and accent state into focused modules --- .../src/lib/components/layout/Sidebar.svelte | 17 +- .../src/lib/components/layout/TabsBar.svelte | 9 +- .../src/lib/components/layout/TopBar.svelte | 2 +- .../src/lib/components/layout/navLinks.ts | 48 ++++++ .../lib/components/player/PlayerBar.svelte | 2 +- .../src/lib/components/player/Queue.svelte | 2 +- .../lib/components/ui/media/ArtistLink.svelte | 2 +- .../lib/components/ui/media/Artwork.svelte | 10 +- .../lib/components/ui/media/ListRow.svelte | 6 +- .../lib/components/ui/media/MediaCard.svelte | 2 +- .../components/ui/media/MediaIdentity.svelte | 6 +- .../components/ui/overlay/ContextMenu.svelte | 24 +-- .../components/ui/primitives/BackLink.svelte | 2 +- web-player/src/lib/data/search.test.ts | 16 +- web-player/src/lib/data/search.ts | 46 ++--- web-player/src/lib/player/actions.ts | 18 ++ web-player/src/lib/player/mediaSession.ts | 36 ++++ web-player/src/lib/player/player.svelte.ts | 163 ++++-------------- web-player/src/lib/player/progressClock.ts | 36 ++++ web-player/src/lib/player/queue.test.ts | 2 +- web-player/src/lib/player/queue.ts | 2 +- .../src/lib/player/queuePanel.svelte.ts | 3 - web-player/src/lib/player/stream.ts | 25 +++ web-player/src/lib/state/history.svelte.ts | 51 ++++++ web-player/src/lib/state/menus.svelte.ts | 6 +- web-player/src/lib/state/navigation.svelte.ts | 125 -------------- .../{playlists.svelte.ts => panels.svelte.ts} | 1 + web-player/src/lib/state/scroll.ts | 22 +++ web-player/src/lib/theme/accent.svelte.ts | 49 ++++++ web-player/src/lib/theme/color.ts | 4 - web-player/src/lib/theme/palette.ts | 13 +- .../lib/{data => utils}/collections.test.ts | 0 .../src/lib/{data => utils}/collections.ts | 0 web-player/src/lib/utils/hrefs.test.ts | 17 ++ web-player/src/lib/utils/hrefs.ts | 24 +++ web-player/src/lib/utils/menuPosition.ts | 15 ++ .../src/routes/(app)/albums/+page.svelte | 3 +- .../src/routes/(app)/albums/[id]/+page.svelte | 8 +- .../src/routes/(app)/explore/+page.svelte | 67 ++++--- .../src/routes/(app)/liked/+page.svelte | 3 +- .../src/routes/(app)/mixes/[id]/+page.svelte | 3 +- .../src/routes/(app)/playlists/+page.svelte | 7 +- .../routes/(app)/playlists/[id]/+page.svelte | 12 +- .../src/routes/(app)/user/[id]/+page.svelte | 5 +- .../user/[id]/[list=followList]/+page.svelte | 2 +- web-player/src/routes/+layout.svelte | 49 ++---- web-player/src/routes/+page.svelte | 12 +- 47 files changed, 520 insertions(+), 457 deletions(-) create mode 100644 web-player/src/lib/components/layout/navLinks.ts create mode 100644 web-player/src/lib/player/actions.ts create mode 100644 web-player/src/lib/player/mediaSession.ts create mode 100644 web-player/src/lib/player/progressClock.ts delete mode 100644 web-player/src/lib/player/queuePanel.svelte.ts create mode 100644 web-player/src/lib/player/stream.ts create mode 100644 web-player/src/lib/state/history.svelte.ts delete mode 100644 web-player/src/lib/state/navigation.svelte.ts rename web-player/src/lib/state/{playlists.svelte.ts => panels.svelte.ts} (70%) create mode 100644 web-player/src/lib/state/scroll.ts create mode 100644 web-player/src/lib/theme/accent.svelte.ts rename web-player/src/lib/{data => utils}/collections.test.ts (100%) rename web-player/src/lib/{data => utils}/collections.ts (100%) create mode 100644 web-player/src/lib/utils/hrefs.test.ts create mode 100644 web-player/src/lib/utils/hrefs.ts create mode 100644 web-player/src/lib/utils/menuPosition.ts diff --git a/web-player/src/lib/components/layout/Sidebar.svelte b/web-player/src/lib/components/layout/Sidebar.svelte index 144970c..86da1bb 100644 --- a/web-player/src/lib/components/layout/Sidebar.svelte +++ b/web-player/src/lib/components/layout/Sidebar.svelte @@ -1,8 +1,9 @@