From e13bbd4782daaf89582bb52fb3fd2a84ca6ce30f Mon Sep 17 00:00:00 2001 From: XShine555 Date: Sun, 5 Jul 2026 22:47:29 +0200 Subject: [PATCH 001/217] feat(frontend): scaffold SvelteKit + Tailwind with landing (part 1) New SvelteKit app in frontend/ (minimal template, TypeScript, TailwindCSS v4). Adds the app shell (dark music theme navbar + footer) and a landing page with hero and feature cards. CTAs point to /login and /explore (built in later parts). Verified: production build succeeds and the page renders with Tailwind styles applied (checked via preview). --- frontend/.gitignore | 23 + frontend/.npmrc | 1 + frontend/.vscode/extensions.json | 6 + frontend/.vscode/settings.json | 5 + frontend/README.md | 42 + frontend/package-lock.json | 1725 +++++++++++++++++++++ frontend/package.json | 25 + frontend/src/app.d.ts | 13 + frontend/src/app.html | 12 + frontend/src/lib/assets/favicon.svg | 1 + frontend/src/lib/components/Footer.svelte | 11 + frontend/src/lib/components/Navbar.svelte | 33 + frontend/src/lib/index.ts | 1 + frontend/src/routes/+layout.svelte | 16 + frontend/src/routes/+page.svelte | 75 + frontend/src/routes/layout.css | 9 + frontend/static/robots.txt | 3 + frontend/tsconfig.json | 20 + frontend/vite.config.ts | 21 + 19 files changed, 2042 insertions(+) create mode 100644 frontend/.gitignore create mode 100644 frontend/.npmrc create mode 100644 frontend/.vscode/extensions.json create mode 100644 frontend/.vscode/settings.json create mode 100644 frontend/README.md create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/app.d.ts create mode 100644 frontend/src/app.html create mode 100644 frontend/src/lib/assets/favicon.svg create mode 100644 frontend/src/lib/components/Footer.svelte create mode 100644 frontend/src/lib/components/Navbar.svelte create mode 100644 frontend/src/lib/index.ts create mode 100644 frontend/src/routes/+layout.svelte create mode 100644 frontend/src/routes/+page.svelte create mode 100644 frontend/src/routes/layout.css create mode 100644 frontend/static/robots.txt create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 0000000..5449017 --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "svelte.svelte-vscode", + "bradlc.vscode-tailwindcss" + ] +} diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json new file mode 100644 index 0000000..bc31e15 --- /dev/null +++ b/frontend/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "files.associations": { + "*.css": "tailwindcss" + } +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..83ba1d7 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,42 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project +npx sv create my-app +``` + +To recreate this project with the same configuration: + +```sh +# recreate this project +npx sv@0.16.2 create --template minimal --types ts --add tailwindcss="plugins:none" --no-download-check --install npm frontend +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..fb91dc1 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1725 @@ +{ + "name": "frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.1", + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/kit": "^2.63.0", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@tailwindcss/vite": "^4.3.0", + "svelte": "^5.56.1", + "svelte-check": "^4.6.0", + "tailwindcss": "^4.3.0", + "typescript": "^6.0.3", + "vite": "^8.0.16" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", + "integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.69.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz", + "integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz", + "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", + "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.13", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", + "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.1.tgz", + "integrity": "sha512-vM9SUhjsUYs6UeJUmygc5Ofm5eQGe85riob5ju6XCgFGJI5PLV4nrDAQpQjd+LkFBpAkADn5BQQpZ9EUNkyLuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.56.4", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", + "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.1.tgz", + "integrity": "sha512-FGUOmAqxXdN/H9Zm8slrqO7SLtFisXRB7rfOsHNJ3MLTD2po/+Stg8XyErkpumPHbuUiYTcqrEIzxpVWKTLqtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.0", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..11dcdf4 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/kit": "^2.63.0", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@tailwindcss/vite": "^4.3.0", + "svelte": "^5.56.1", + "svelte-check": "^4.6.0", + "tailwindcss": "^4.3.0", + "typescript": "^6.0.3", + "vite": "^8.0.16" + } +} diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/frontend/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/frontend/src/app.html b/frontend/src/app.html new file mode 100644 index 0000000..6a2bb58 --- /dev/null +++ b/frontend/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/frontend/src/lib/assets/favicon.svg b/frontend/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/frontend/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/frontend/src/lib/components/Footer.svelte b/frontend/src/lib/components/Footer.svelte new file mode 100644 index 0000000..b0344b7 --- /dev/null +++ b/frontend/src/lib/components/Footer.svelte @@ -0,0 +1,11 @@ + diff --git a/frontend/src/lib/components/Navbar.svelte b/frontend/src/lib/components/Navbar.svelte new file mode 100644 index 0000000..035bded --- /dev/null +++ b/frontend/src/lib/components/Navbar.svelte @@ -0,0 +1,33 @@ + + +
+ +
diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/frontend/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..262c35e --- /dev/null +++ b/frontend/src/routes/+layout.svelte @@ -0,0 +1,16 @@ + + + + +
+ +
{@render children()}
+
+
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte new file mode 100644 index 0000000..1136baa --- /dev/null +++ b/frontend/src/routes/+page.svelte @@ -0,0 +1,75 @@ + + + + Musify · Tu música, en streaming + + + +
+
+ +
+ + Ahora en beta + + +

+ Tu música, en streaming. +

+ +

+ Escucha, organiza y sube tus canciones. Musify es tu biblioteca musical, siempre contigo. +

+ + +
+
+ +
+
+ {#each features as feature (feature.title)} +
+
{feature.icon}
+

{feature.title}

+

{feature.body}

+
+ {/each} +
+
diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css new file mode 100644 index 0000000..ad122fe --- /dev/null +++ b/frontend/src/routes/layout.css @@ -0,0 +1,9 @@ +@import 'tailwindcss'; + +:root { + color-scheme: dark; +} + +html { + scroll-behavior: smooth; +} diff --git a/frontend/static/robots.txt b/frontend/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/frontend/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..ea92b35 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,21 @@ +import tailwindcss from '@tailwindcss/vite'; +import adapter from '@sveltejs/adapter-auto'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + tailwindcss(), + sveltekit({ + compilerOptions: { + // Force runes mode for the project, except for libraries. Can be removed in svelte 6. + runes: ({ filename }) => filename.split(/[/\\]/).includes('node_modules') ? undefined : true + }, + + // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. + // If your environment is not supported, or you settled on a specific environment, switch out the adapter. + // See https://svelte.dev/docs/kit/adapters for more information about adapters. + adapter: adapter() + }) + ] +}); From e7e854959ff0ced48219b1ad103b288281bf2115 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Sun, 5 Jul 2026 22:58:47 +0200 Subject: [PATCH 002/217] feat(frontend): server-side Zitadel OIDC auth (part 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Authorization Code + PKCE login/logout against Zitadel, handled entirely server-side with an httpOnly signed session cookie: - $lib/server/auth: openid-client discovery (config cached), jose-signed session encode/decode, cookie/name constants - routes: /login (builds the authorize URL with PKCE/state/nonce, ?mode=register -> prompt=create), /auth/callback (code exchange + session cookie), /logout (clears session + Zitadel end_session) - hooks.server populates locals.user/accessToken from the session - +layout.server exposes the user; Navbar shows the signed-in user + Salir, or Iniciar sesión / Registrarse - switch to adapter-node; env-driven config (.env gitignored, .env.example committed) with issuer http://host.docker.internal:8080 Verified: svelte-check + build clean; dev smoke test shows the landing in the logged-out state and /login issuing a 302 to Zitadel's authorize endpoint with PKCE/state/nonce (discovery reachable). Real client id/secret still needed to complete the token exchange. --- frontend/.env.example | 14 + frontend/package-lock.json | 781 ++++++++++++++++--- frontend/package.json | 5 + frontend/src/app.d.ts | 7 +- frontend/src/hooks.server.ts | 21 + frontend/src/lib/components/Navbar.svelte | 48 +- frontend/src/lib/server/auth.ts | 66 ++ frontend/src/lib/types.ts | 6 + frontend/src/routes/+layout.server.ts | 5 + frontend/src/routes/+layout.svelte | 4 +- frontend/src/routes/auth/callback/+server.ts | 69 ++ frontend/src/routes/login/+server.ts | 50 ++ frontend/src/routes/logout/+server.ts | 20 + frontend/vite.config.ts | 2 +- 14 files changed, 979 insertions(+), 119 deletions(-) create mode 100644 frontend/.env.example create mode 100644 frontend/src/hooks.server.ts create mode 100644 frontend/src/lib/server/auth.ts create mode 100644 frontend/src/lib/types.ts create mode 100644 frontend/src/routes/+layout.server.ts create mode 100644 frontend/src/routes/auth/callback/+server.ts create mode 100644 frontend/src/routes/login/+server.ts create mode 100644 frontend/src/routes/logout/+server.ts diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..c77a5dd --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,14 @@ +# Zitadel OIDC (server-side auth) +ZITADEL_ISSUER=http://host.docker.internal:8080 +ZITADEL_CLIENT_ID=your-web-app-client-id +ZITADEL_CLIENT_SECRET=your-web-app-client-secret + +# Where Zitadel redirects back after login (must be registered in the app) +AUTH_REDIRECT_URI=http://localhost:5173/auth/callback +AUTH_POST_LOGOUT_URI=http://localhost:5173/ + +# Secret used to sign the session cookie (any long random string) +SESSION_SECRET=change-me-to-a-long-random-string + +# Backend API base URL (used from later parts) +API_BASE_URL=http://localhost:5111 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fb91dc1..e022877 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,6 +7,11 @@ "": { "name": "frontend", "version": "0.0.1", + "dependencies": { + "@sveltejs/adapter-node": "^5.5.7", + "jose": "^6.2.3", + "openid-client": "^6.8.4" + }, "devDependencies": { "@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/kit": "^2.63.0", @@ -19,36 +24,10 @@ "vite": "^8.0.16" } }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -59,7 +38,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -70,7 +48,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -81,7 +58,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -91,14 +67,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -109,7 +83,6 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -128,7 +101,6 @@ "version": "0.138.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -138,7 +110,6 @@ "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { @@ -148,7 +119,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -165,7 +135,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -182,7 +151,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -199,7 +167,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -216,7 +183,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -233,7 +199,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -250,7 +215,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -267,7 +231,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -284,7 +247,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -301,7 +263,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -318,7 +279,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -335,7 +295,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -352,7 +311,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -364,6 +322,27 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", @@ -371,7 +350,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -388,7 +366,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -402,21 +379,465 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, "license": "MIT" }, + "node_modules/@rollup/plugin-commonjs": { + "version": "29.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.3.tgz", + "integrity": "sha512-ZaOxZceP7SOUW7Lqw5IRVweSQYWaeIPnXIGLiB690EBA3FGJTO40EEr2L5yZplJWsgTCogILRSpcAe7+U0Otdg==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-replace": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", + "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, "license": "MIT" }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", - "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -432,11 +853,26 @@ "@sveltejs/kit": "^2.0.0" } }, + "node_modules/@sveltejs/adapter-node": { + "version": "5.5.7", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.7.tgz", + "integrity": "sha512-uOfc9eVlI3A37RRSaKcgrheBYPrfJwC9VMqDp8x/O6tlKdcLLvHThSWD0KNIbjQ/d+7bwLGx3vx6aowAcRfd2g==", + "license": "MIT", + "dependencies": { + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "@rollup/plugin-replace": "^6.0.3", + "rollup": "^4.59.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.4.0" + } + }, "node_modules/@sveltejs/kit": { "version": "2.69.1", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz", "integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -489,7 +925,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -782,7 +1217,6 @@ "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -793,28 +1227,30 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true, "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, + "license": "MIT" + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "license": "MIT" }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, "license": "MIT" }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "dev": true, "license": "MIT", "peer": true, "bin": { @@ -828,7 +1264,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -838,7 +1273,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -864,17 +1298,21 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "license": "MIT" + }, "node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -884,7 +1322,6 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -894,7 +1331,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -904,7 +1340,6 @@ "version": "5.8.1", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", - "dev": true, "license": "MIT" }, "node_modules/enhanced-resolve": { @@ -921,18 +1356,25 @@ "node": ">=10.13.0" } }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esm-env": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, "license": "MIT" }, "node_modules/esrap": { "version": "2.2.13", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -946,11 +1388,16 @@ } } }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -968,7 +1415,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -979,6 +1425,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -986,11 +1441,43 @@ "dev": true, "license": "ISC" }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.6" @@ -1000,17 +1487,25 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -1020,7 +1515,6 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -1053,7 +1547,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1074,7 +1567,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1095,7 +1587,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1116,7 +1607,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1137,7 +1627,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1158,7 +1647,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1179,7 +1667,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1200,7 +1687,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1221,7 +1707,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1242,7 +1727,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1263,7 +1747,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1281,14 +1764,12 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, "license": "MIT" }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -1308,7 +1789,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -1318,7 +1798,6 @@ "version": "3.3.15", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", - "dev": true, "funding": [ { "type": "github", @@ -1333,11 +1812,19 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/oauth4webapi": { + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", + "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/obug": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", - "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" @@ -1347,20 +1834,36 @@ "node": ">=12.20.0" } }, + "node_modules/openid-client": { + "version": "6.8.4", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.4.tgz", + "integrity": "sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==", + "license": "MIT", + "dependencies": { + "jose": "^6.2.2", + "oauth4webapi": "^3.8.5" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "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==", - "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1372,7 +1875,6 @@ "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -1411,11 +1913,31 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/rolldown": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", - "dev": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.138.0", @@ -1445,6 +1967,51 @@ "@rolldown/binding-win32-x64-msvc": "1.1.4" } }, + "node_modules/rollup": { + "version": "4.62.2", + "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" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -1462,14 +2029,12 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.1.tgz", "integrity": "sha512-vM9SUhjsUYs6UeJUmygc5Ofm5eQGe85riob5ju6XCgFGJI5PLV4nrDAQpQjd+LkFBpAkADn5BQQpZ9EUNkyLuA==", - "dev": true, "license": "MIT" }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "dev": true, "license": "MIT", "dependencies": { "@polka/url": "^1.0.0-next.24", @@ -1484,17 +2049,27 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, + "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", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/svelte": { "version": "5.56.4", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -1569,7 +2144,6 @@ "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -1586,7 +2160,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -1596,7 +2169,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD", "optional": true }, @@ -1604,7 +2176,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "peer": true, "bin": { @@ -1619,7 +2191,6 @@ "version": "8.1.3", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -1698,7 +2269,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", - "dev": true, "license": "MIT", "workspaces": [ "tests/deps/*", @@ -1718,7 +2288,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "dev": true, "license": "MIT" } } diff --git a/frontend/package.json b/frontend/package.json index 11dcdf4..84f2b4f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,5 +21,10 @@ "tailwindcss": "^4.3.0", "typescript": "^6.0.3", "vite": "^8.0.16" + }, + "dependencies": { + "@sveltejs/adapter-node": "^5.5.7", + "jose": "^6.2.3", + "openid-client": "^6.8.4" } } diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index da08e6d..d16d09e 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -1,9 +1,14 @@ // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces +import type { SessionUser } from '$lib/types'; + declare global { namespace App { // interface Error {} - // interface Locals {} + interface Locals { + user: SessionUser | null; + accessToken: string | null; + } // interface PageData {} // interface PageState {} // interface Platform {} diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts new file mode 100644 index 0000000..c9c24b0 --- /dev/null +++ b/frontend/src/hooks.server.ts @@ -0,0 +1,21 @@ +import type { Handle } from '@sveltejs/kit'; +import { decodeSession, SESSION_COOKIE } from '$lib/server/auth'; + +export const handle: Handle = async ({ event, resolve }) => { + const session = await decodeSession(event.cookies.get(SESSION_COOKIE)); + + if (session && session.expiresAt * 1000 > Date.now()) { + event.locals.user = { + sub: session.sub, + name: session.name, + email: session.email, + picture: session.picture + }; + event.locals.accessToken = session.accessToken; + } else { + event.locals.user = null; + event.locals.accessToken = null; + } + + return resolve(event); +}; diff --git a/frontend/src/lib/components/Navbar.svelte b/frontend/src/lib/components/Navbar.svelte index 035bded..7384452 100644 --- a/frontend/src/lib/components/Navbar.svelte +++ b/frontend/src/lib/components/Navbar.svelte @@ -1,4 +1,12 @@
- +
{@render children()}
diff --git a/frontend/src/routes/auth/callback/+server.ts b/frontend/src/routes/auth/callback/+server.ts new file mode 100644 index 0000000..a3e06e2 --- /dev/null +++ b/frontend/src/routes/auth/callback/+server.ts @@ -0,0 +1,69 @@ +import { redirect, error } from '@sveltejs/kit'; +import * as client from 'openid-client'; +import type { RequestHandler } from './$types'; +import { + getOidcConfig, + encodeSession, + type Session, + SESSION_COOKIE, + STATE_COOKIE, + VERIFIER_COOKIE, + NONCE_COOKIE, + RETURN_COOKIE +} from '$lib/server/auth'; + +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) ?? '/explore'; + + if (!codeVerifier || !state) { + error(400, 'Estado de autenticación inválido. Vuelve a iniciar sesión.'); + } + + const config = await getOidcConfig(); + + let tokens; + try { + tokens = await client.authorizationCodeGrant(config, url, { + pkceCodeVerifier: codeVerifier, + expectedState: state, + expectedNonce: nonce, + idTokenExpected: true + }); + } catch (exception) { + console.error('Token exchange failed', exception); + error(401, 'La autenticación ha fallado.'); + } + + const claims = tokens.claims(); + if (!claims) { + error(401, 'El proveedor no devolvió el token de identidad.'); + } + + const session: Session = { + sub: String(claims.sub), + name: claims.name as string | undefined, + email: claims.email as string | undefined, + picture: claims.picture as string | undefined, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + idToken: tokens.id_token, + expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600) + }; + + cookies.set(SESSION_COOKIE, await encodeSession(session), { + httpOnly: true, + sameSite: 'lax', + secure: url.protocol === 'https:', + path: '/', + maxAge: 60 * 60 * 24 * 7 + }); + + for (const name of [STATE_COOKIE, VERIFIER_COOKIE, NONCE_COOKIE, RETURN_COOKIE]) { + cookies.delete(name, { path: '/' }); + } + + redirect(302, returnTo); +}; diff --git a/frontend/src/routes/login/+server.ts b/frontend/src/routes/login/+server.ts new file mode 100644 index 0000000..e21b548 --- /dev/null +++ b/frontend/src/routes/login/+server.ts @@ -0,0 +1,50 @@ +import { redirect } from '@sveltejs/kit'; +import * as client from 'openid-client'; +import type { RequestHandler } from './$types'; +import { + getOidcConfig, + authConfig, + OIDC_SCOPE, + STATE_COOKIE, + VERIFIER_COOKIE, + NONCE_COOKIE, + RETURN_COOKIE +} from '$lib/server/auth'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const config = await getOidcConfig(); + + const codeVerifier = client.randomPKCECodeVerifier(); + const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier); + const state = client.randomState(); + const nonce = client.randomNonce(); + + const register = url.searchParams.get('mode') === 'register'; + const returnTo = url.searchParams.get('returnTo') ?? '/explore'; + + const parameters: Record = { + redirect_uri: authConfig.redirectUri, + scope: OIDC_SCOPE, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + state, + nonce + }; + if (register) parameters.prompt = 'create'; + + const authorizationUrl = client.buildAuthorizationUrl(config, parameters); + + const cookieOptions = { + httpOnly: true, + sameSite: 'lax' as const, + secure: url.protocol === 'https:', + path: '/', + maxAge: 600 + }; + cookies.set(VERIFIER_COOKIE, codeVerifier, cookieOptions); + cookies.set(STATE_COOKIE, state, cookieOptions); + cookies.set(NONCE_COOKIE, nonce, cookieOptions); + cookies.set(RETURN_COOKIE, returnTo, cookieOptions); + + redirect(302, authorizationUrl.href); +}; diff --git a/frontend/src/routes/logout/+server.ts b/frontend/src/routes/logout/+server.ts new file mode 100644 index 0000000..659863d --- /dev/null +++ b/frontend/src/routes/logout/+server.ts @@ -0,0 +1,20 @@ +import { redirect } from '@sveltejs/kit'; +import * as client from 'openid-client'; +import type { RequestHandler } from './$types'; +import { getOidcConfig, authConfig, decodeSession, SESSION_COOKIE } from '$lib/server/auth'; + +const handleLogout: RequestHandler = async ({ cookies }) => { + const session = await decodeSession(cookies.get(SESSION_COOKIE)); + cookies.delete(SESSION_COOKIE, { path: '/' }); + + const config = await getOidcConfig(); + const endSessionUrl = client.buildEndSessionUrl(config, { + post_logout_redirect_uri: authConfig.postLogoutUri, + ...(session?.idToken ? { id_token_hint: session.idToken } : {}) + }); + + redirect(302, endSessionUrl.href); +}; + +export const GET = handleLogout; +export const POST = handleLogout; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index ea92b35..001085f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,5 +1,5 @@ import tailwindcss from '@tailwindcss/vite'; -import adapter from '@sveltejs/adapter-auto'; +import adapter from '@sveltejs/adapter-node'; import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; From b28ea023d252cd42447753195a47604f44768224 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Sun, 5 Jul 2026 23:01:44 +0200 Subject: [PATCH 003/217] feat(frontend): make Zitadel client secret optional (support PKCE apps) Configure openid-client with None auth (public client + PKCE) when no secret is provided, so both PKCE and confidential (Code) Zitadel web apps work. --- frontend/src/lib/server/auth.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/server/auth.ts b/frontend/src/lib/server/auth.ts index 79917f4..066f12c 100644 --- a/frontend/src/lib/server/auth.ts +++ b/frontend/src/lib/server/auth.ts @@ -25,13 +25,13 @@ export function getOidcConfig(): Promise { const issuer = new URL(env.ZITADEL_ISSUER); const options = issuer.protocol === 'http:' ? { execute: [client.allowInsecureRequests] } : undefined; - return client.discovery( - issuer, - env.ZITADEL_CLIENT_ID, - env.ZITADEL_CLIENT_SECRET, - undefined, - options - ); + + const secret = env.ZITADEL_CLIENT_SECRET; + const hasSecret = !!secret && !secret.startsWith('REPLACE_'); + + return hasSecret + ? client.discovery(issuer, env.ZITADEL_CLIENT_ID, secret, undefined, options) + : client.discovery(issuer, env.ZITADEL_CLIENT_ID, undefined, client.None(), options); })(); return configPromise; } From 5b2f402a9dabbf75a24765ed58b45db3fb6762bf Mon Sep 17 00:00:00 2001 From: XShine555 Date: Sun, 5 Jul 2026 23:08:27 +0200 Subject: [PATCH 004/217] fix(deploy): use Zitadel built-in login v1 (disable required Login UI v2) The 'latest' Zitadel image requires the Login UI v2, which runs as a separate 'login' container this stack doesn't deploy, so OIDC redirected to /ui/v2/login and returned {code:5, Not Found}. provision.ps1 now disables the loginV2 requirement via the feature API so OIDC uses the built-in /ui/login. Also applied to the running instance. --- deploy/zitadel/provision.ps1 | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/deploy/zitadel/provision.ps1 b/deploy/zitadel/provision.ps1 index fdddf5a..5fb9b75 100644 --- a/deploy/zitadel/provision.ps1 +++ b/deploy/zitadel/provision.ps1 @@ -49,6 +49,19 @@ do { } until ($ready -or (Get-Date) -gt $deadline) if (-not $ready) { throw "Zitadel did not respond in time." } +# Login UI (idempotent) ------------------------------------------------------ +# The 'latest' Zitadel image requires the Login UI v2 (a separate 'login' +# container that this stack does not run), so OIDC would redirect to +# /ui/v2/login and 404. Disable the requirement to use the built-in v1 login. +Write-Step "Disabling required Login UI v2 (use built-in /ui/login)" +try { + Invoke-RestMethod -Method PUT -Uri "$issuer/v2/features/instance" -Headers $headers ` + -ContentType "application/json" -Body (@{ loginV2 = @{ required = $false } } | ConvertTo-Json) -TimeoutSec 20 | Out-Null + Write-Host " loginV2.required = false" +} catch { + Write-Host " warning: could not update loginV2 feature: $($_.Exception.Message)" +} + # Project (idempotent) ------------------------------------------------------- Write-Step "Project '$ProjectName'" $project = (Invoke-Zitadel POST "/projects/_search" @{ From fe8c2c424403eac8b4a460caa7dfdc37ca2baa3b Mon Sep 17 00:00:00 2001 From: XShine555 Date: Sun, 5 Jul 2026 23:16:25 +0200 Subject: [PATCH 005/217] fix(frontend): full-page nav for auth links; land on home after login - Add data-sveltekit-reload to the /login links so the browser performs a full navigation (the client router intercepted them and aborted on the external Zitadel redirect) - Default post-login returnTo to / (home) since /explore is not built yet - 'Empezar gratis' now starts the register flow (/login?mode=register) --- frontend/src/lib/components/Footer.svelte | 2 +- frontend/src/lib/components/Navbar.svelte | 7 ++++++- frontend/src/routes/+page.svelte | 3 ++- frontend/src/routes/auth/callback/+server.ts | 2 +- frontend/src/routes/login/+server.ts | 2 +- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/components/Footer.svelte b/frontend/src/lib/components/Footer.svelte index b0344b7..967d27c 100644 --- a/frontend/src/lib/components/Footer.svelte +++ b/frontend/src/lib/components/Footer.svelte @@ -5,7 +5,7 @@

© {new Date().getFullYear()} Musify · Streaming de música.

diff --git a/frontend/src/lib/components/Navbar.svelte b/frontend/src/lib/components/Navbar.svelte index 7384452..af3d981 100644 --- a/frontend/src/lib/components/Navbar.svelte +++ b/frontend/src/lib/components/Navbar.svelte @@ -48,11 +48,16 @@ {:else} - + Iniciar sesión Registrarse diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 1136baa..578ca35 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -45,7 +45,8 @@
Empezar gratis diff --git a/frontend/src/routes/auth/callback/+server.ts b/frontend/src/routes/auth/callback/+server.ts index a3e06e2..1fed090 100644 --- a/frontend/src/routes/auth/callback/+server.ts +++ b/frontend/src/routes/auth/callback/+server.ts @@ -16,7 +16,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) ?? '/explore'; + const returnTo = cookies.get(RETURN_COOKIE) ?? '/'; if (!codeVerifier || !state) { error(400, 'Estado de autenticación inválido. Vuelve a iniciar sesión.'); diff --git a/frontend/src/routes/login/+server.ts b/frontend/src/routes/login/+server.ts index e21b548..050f39f 100644 --- a/frontend/src/routes/login/+server.ts +++ b/frontend/src/routes/login/+server.ts @@ -20,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') ?? '/explore'; + const returnTo = url.searchParams.get('returnTo') ?? '/'; const parameters: Record = { redirect_uri: authConfig.redirectUri, From e11db5263b4585b0adb5a9c5fe52b4b36b481a75 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Sun, 5 Jul 2026 23:41:42 +0200 Subject: [PATCH 006/217] chore(frontend): remove scaffold comments and add conventions doc --- frontend/CLAUDE.md | 32 ++++++++++++++++++++++++++++++++ frontend/src/app.d.ts | 6 ------ frontend/src/app.html | 1 - frontend/src/lib/index.ts | 1 - frontend/tsconfig.json | 5 ----- frontend/vite.config.ts | 8 ++------ 6 files changed, 34 insertions(+), 19 deletions(-) create mode 100644 frontend/CLAUDE.md delete mode 100644 frontend/src/lib/index.ts diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md new file mode 100644 index 0000000..6094c34 --- /dev/null +++ b/frontend/CLAUDE.md @@ -0,0 +1,32 @@ +# Musify — Frontend web + +Web pública de Musify. Para diseño y recetas de UI usa el skill **`musify-web`**. + +## Stack + +SvelteKit 2 (`adapter-node`) · Svelte 5 **runes** · Tailwind 4 · TypeScript strict · auth OIDC Zitadel (`openid-client` + `jose`). + +No hay `svelte.config.js` (config inline en `vite.config.ts`) ni `tailwind.config.js` (config en `src/routes/layout.css`). + +## Comandos + +```sh +npm run dev # servidor de desarrollo (puerto 5173) +npm run build +npm run check # svelte-check — debe salir 0 errores antes de cerrar un cambio +``` + +## Reglas + +- **Sin comentarios** en el código salvo que sean imprescindibles. +- **Svelte 5 runes siempre:** `$props()` con `interface Props`, `$state`, `$derived`, `$effect`. Nada de `export let` ni `$:`. `{#each}` siempre con clave. +- **Server-only bajo `$lib/server/`**; nunca importado desde cliente. +- Datos SSR vía `load` en `+page.server.ts`/`+layout.server.ts` → prop `data`. +- Enlaces a endpoints `+server.ts` (login/logout) llevan `data-sveltekit-reload`. +- Reutiliza los tokens/recetas del sistema de diseño; extrae a `$lib/components/ui/` lo que se repita. + +## Auth + +`hooks.server.ts` valida la cookie de sesión cifrada (`mf_session`), refresca el `access_token` y puebla `locals.user` / `locals.accessToken`. Para proteger una ruta, comprueba `locals.user` en su `load` y redirige a `/login?returnTo=…`. Para el backend, envía `locals.accessToken` como Bearer hacia `API_BASE_URL`. + +Variables de entorno en `.env` (ver `.env.example`): `ZITADEL_*`, `AUTH_REDIRECT_URI`, `AUTH_POST_LOGOUT_URI`, `SESSION_SECRET`, `API_BASE_URL`. diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index d16d09e..3640325 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -1,17 +1,11 @@ -// See https://svelte.dev/docs/kit/types#app.d.ts -// for information about these interfaces import type { SessionUser } from '$lib/types'; declare global { namespace App { - // interface Error {} interface Locals { user: SessionUser | null; accessToken: string | null; } - // interface PageData {} - // interface PageState {} - // interface Platform {} } } diff --git a/frontend/src/app.html b/frontend/src/app.html index 6a2bb58..f273cc5 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -3,7 +3,6 @@ - %sveltekit.head% diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts deleted file mode 100644 index 856f2b6..0000000 --- a/frontend/src/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -// place files you want to import through the `$lib` alias in this folder. diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 2c2ed3c..feea18b 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -12,9 +12,4 @@ "strict": true, "moduleResolution": "bundler" } - // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias - // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files - // - // To make changes to top-level options such as include and exclude, we recommend extending - // the generated config; see https://svelte.dev/docs/kit/configuration#typescript } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 001085f..dd21275 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -8,13 +8,9 @@ export default defineConfig({ tailwindcss(), sveltekit({ compilerOptions: { - // Force runes mode for the project, except for libraries. Can be removed in svelte 6. - runes: ({ filename }) => filename.split(/[/\\]/).includes('node_modules') ? undefined : true + runes: ({ filename }) => + filename.split(/[/\\]/).includes('node_modules') ? undefined : true }, - - // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. - // If your environment is not supported, or you settled on a specific environment, switch out the adapter. - // See https://svelte.dev/docs/kit/adapters for more information about adapters. adapter: adapter() }) ] From 46c7d5dbc7b1284e4eb993226756f52fd8d0dece Mon Sep 17 00:00:00 2001 From: XShine555 Date: Sun, 5 Jul 2026 23:41:49 +0200 Subject: [PATCH 007/217] feat(frontend): encrypt session cookie (JWE) and auto-refresh access token --- frontend/src/hooks.server.ts | 23 +++++++-- frontend/src/lib/server/auth.ts | 51 +++++++++++++++++--- frontend/src/routes/auth/callback/+server.ts | 9 +--- 3 files changed, 67 insertions(+), 16 deletions(-) diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index c9c24b0..27c25bf 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -1,10 +1,27 @@ import type { Handle } from '@sveltejs/kit'; -import { decodeSession, SESSION_COOKIE } from '$lib/server/auth'; +import { + decodeSession, + encodeSession, + refreshSession, + sessionCookieOptions, + SESSION_COOKIE +} from '$lib/server/auth'; + +const REFRESH_THRESHOLD_SECONDS = 30; export const handle: Handle = async ({ event, resolve }) => { - const session = await decodeSession(event.cookies.get(SESSION_COOKIE)); + let session = await decodeSession(event.cookies.get(SESSION_COOKIE)); - if (session && session.expiresAt * 1000 > Date.now()) { + if (session && session.expiresAt - REFRESH_THRESHOLD_SECONDS <= Math.floor(Date.now() / 1000)) { + session = await refreshSession(session); + if (session) { + event.cookies.set(SESSION_COOKIE, await encodeSession(session), sessionCookieOptions(event.url)); + } else { + event.cookies.delete(SESSION_COOKIE, { path: '/' }); + } + } + + if (session) { event.locals.user = { sub: session.sub, name: session.name, diff --git a/frontend/src/lib/server/auth.ts b/frontend/src/lib/server/auth.ts index 066f12c..594ba81 100644 --- a/frontend/src/lib/server/auth.ts +++ b/frontend/src/lib/server/auth.ts @@ -1,5 +1,5 @@ import * as client from 'openid-client'; -import { SignJWT, jwtVerify } from 'jose'; +import { EncryptJWT, jwtDecrypt } from 'jose'; import { env } from '$env/dynamic/private'; import type { SessionUser } from '$lib/types'; @@ -45,22 +45,61 @@ export const authConfig = { } }; -const sessionKey = () => new TextEncoder().encode(env.SESSION_SECRET); +let keyPromise: Promise | null = null; + +function sessionKey(): Promise { + keyPromise ??= crypto.subtle + .digest('SHA-256', new TextEncoder().encode(env.SESSION_SECRET)) + .then((digest) => new Uint8Array(digest)); + return keyPromise; +} + +export function sessionCookieOptions(url: URL) { + return { + httpOnly: true, + sameSite: 'lax' as const, + secure: url.protocol === 'https:', + path: '/', + maxAge: 60 * 60 * 24 * 7 + }; +} export async function encodeSession(session: Session): Promise { - return new SignJWT({ ...session }) - .setProtectedHeader({ alg: 'HS256' }) + return new EncryptJWT({ ...session }) + .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' }) .setIssuedAt() .setExpirationTime('7d') - .sign(sessionKey()); + .encrypt(await sessionKey()); } export async function decodeSession(token: string | undefined): Promise { if (!token) return null; try { - const { payload } = await jwtVerify(token, sessionKey()); + const { payload } = await jwtDecrypt(token, await sessionKey()); return payload as unknown as Session; } catch { return null; } } + +export async function refreshSession(session: Session): Promise { + if (!session.refreshToken) return null; + try { + const config = await getOidcConfig(); + const tokens = await client.refreshTokenGrant(config, session.refreshToken); + const claims = tokens.claims(); + return { + sub: session.sub, + name: (claims?.name as string | undefined) ?? session.name, + email: (claims?.email as string | undefined) ?? session.email, + picture: (claims?.picture as string | undefined) ?? session.picture, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? session.refreshToken, + idToken: tokens.id_token ?? session.idToken, + expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600) + }; + } catch (exception) { + console.error('Token refresh failed', exception); + return null; + } +} diff --git a/frontend/src/routes/auth/callback/+server.ts b/frontend/src/routes/auth/callback/+server.ts index 1fed090..2928a7b 100644 --- a/frontend/src/routes/auth/callback/+server.ts +++ b/frontend/src/routes/auth/callback/+server.ts @@ -4,6 +4,7 @@ import type { RequestHandler } from './$types'; import { getOidcConfig, encodeSession, + sessionCookieOptions, type Session, SESSION_COOKIE, STATE_COOKIE, @@ -53,13 +54,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => { expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600) }; - cookies.set(SESSION_COOKIE, await encodeSession(session), { - httpOnly: true, - sameSite: 'lax', - secure: url.protocol === 'https:', - path: '/', - maxAge: 60 * 60 * 24 * 7 - }); + cookies.set(SESSION_COOKIE, await encodeSession(session), sessionCookieOptions(url)); for (const name of [STATE_COOKIE, VERIFIER_COOKIE, NONCE_COOKIE, RETURN_COOKIE]) { cookies.delete(name, { path: '/' }); From 539fad1d2243676e756fa9fcfc53d64b4e978cf2 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Sun, 5 Jul 2026 23:43:50 +0200 Subject: [PATCH 008/217] refactor(frontend): centralize configurable auth/session values in config.ts --- frontend/.env.example | 8 ++++- frontend/src/hooks.server.ts | 5 ++- frontend/src/lib/server/auth.ts | 34 +++++++-------------- frontend/src/lib/server/config.ts | 44 +++++++++++++++++++++++++++ frontend/src/routes/login/+server.ts | 7 ++--- frontend/src/routes/logout/+server.ts | 3 +- 6 files changed, 69 insertions(+), 32 deletions(-) create mode 100644 frontend/src/lib/server/config.ts diff --git a/frontend/.env.example b/frontend/.env.example index c77a5dd..fc2ee33 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -7,8 +7,14 @@ 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 sign the session cookie (any long random string) +# Secret used to derive the session cookie encryption key (any long random string) SESSION_SECRET=change-me-to-a-long-random-string +# Optional session tuning (defaults shown) +# OIDC_SCOPE=openid profile email offline_access +# SESSION_TTL_SECONDS=604800 +# SESSION_REFRESH_THRESHOLD_SECONDS=30 +# AUTH_FLOW_TTL_SECONDS=600 + # Backend API base URL (used from later parts) API_BASE_URL=http://localhost:5111 diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 27c25bf..ca76255 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -6,13 +6,12 @@ import { sessionCookieOptions, SESSION_COOKIE } from '$lib/server/auth'; - -const REFRESH_THRESHOLD_SECONDS = 30; +import { authConfig } from '$lib/server/config'; export const handle: Handle = async ({ event, resolve }) => { let session = await decodeSession(event.cookies.get(SESSION_COOKIE)); - if (session && session.expiresAt - REFRESH_THRESHOLD_SECONDS <= Math.floor(Date.now() / 1000)) { + if (session && session.expiresAt - authConfig.refreshThresholdSeconds <= Math.floor(Date.now() / 1000)) { session = await refreshSession(session); if (session) { event.cookies.set(SESSION_COOKIE, await encodeSession(session), sessionCookieOptions(event.url)); diff --git a/frontend/src/lib/server/auth.ts b/frontend/src/lib/server/auth.ts index 594ba81..0dafe1a 100644 --- a/frontend/src/lib/server/auth.ts +++ b/frontend/src/lib/server/auth.ts @@ -1,6 +1,6 @@ import * as client from 'openid-client'; import { EncryptJWT, jwtDecrypt } from 'jose'; -import { env } from '$env/dynamic/private'; +import { authConfig } from '$lib/server/config'; import type { SessionUser } from '$lib/types'; export const SESSION_COOKIE = 'mf_session'; @@ -16,40 +16,27 @@ export interface Session extends SessionUser { expiresAt: number; } -export const OIDC_SCOPE = 'openid profile email offline_access'; - let configPromise: Promise | null = null; export function getOidcConfig(): Promise { configPromise ??= (async () => { - const issuer = new URL(env.ZITADEL_ISSUER); + const issuer = new URL(authConfig.issuer); const options = issuer.protocol === 'http:' ? { execute: [client.allowInsecureRequests] } : undefined; - const secret = env.ZITADEL_CLIENT_SECRET; - const hasSecret = !!secret && !secret.startsWith('REPLACE_'); - - return hasSecret - ? client.discovery(issuer, env.ZITADEL_CLIENT_ID, secret, undefined, options) - : client.discovery(issuer, env.ZITADEL_CLIENT_ID, undefined, client.None(), options); + const secret = authConfig.clientSecret; + return secret + ? client.discovery(issuer, authConfig.clientId, secret, undefined, options) + : client.discovery(issuer, authConfig.clientId, undefined, client.None(), options); })(); return configPromise; } -export const authConfig = { - get redirectUri() { - return env.AUTH_REDIRECT_URI; - }, - get postLogoutUri() { - return env.AUTH_POST_LOGOUT_URI; - } -}; - let keyPromise: Promise | null = null; function sessionKey(): Promise { keyPromise ??= crypto.subtle - .digest('SHA-256', new TextEncoder().encode(env.SESSION_SECRET)) + .digest('SHA-256', new TextEncoder().encode(authConfig.sessionSecret)) .then((digest) => new Uint8Array(digest)); return keyPromise; } @@ -60,15 +47,16 @@ export function sessionCookieOptions(url: URL) { sameSite: 'lax' as const, secure: url.protocol === 'https:', path: '/', - maxAge: 60 * 60 * 24 * 7 + maxAge: authConfig.sessionTtlSeconds }; } export async function encodeSession(session: Session): Promise { + const now = Math.floor(Date.now() / 1000); return new EncryptJWT({ ...session }) .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' }) - .setIssuedAt() - .setExpirationTime('7d') + .setIssuedAt(now) + .setExpirationTime(now + authConfig.sessionTtlSeconds) .encrypt(await sessionKey()); } diff --git a/frontend/src/lib/server/config.ts b/frontend/src/lib/server/config.ts new file mode 100644 index 0000000..3be521d --- /dev/null +++ b/frontend/src/lib/server/config.ts @@ -0,0 +1,44 @@ +import { env } from '$env/dynamic/private'; + +const DAY_SECONDS = 60 * 60 * 24; +const DEFAULT_OIDC_SCOPE = 'openid profile email offline_access'; + +function positiveInt(value: string | undefined, fallback: number): number { + if (value === undefined || value === '') return fallback; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +export const authConfig = { + get issuer() { + return env.ZITADEL_ISSUER; + }, + get clientId() { + return env.ZITADEL_CLIENT_ID; + }, + get clientSecret(): string | undefined { + const secret = env.ZITADEL_CLIENT_SECRET; + return secret && !secret.startsWith('REPLACE_') ? secret : undefined; + }, + get scope() { + return env.OIDC_SCOPE || DEFAULT_OIDC_SCOPE; + }, + get redirectUri() { + return env.AUTH_REDIRECT_URI; + }, + get postLogoutUri() { + return env.AUTH_POST_LOGOUT_URI; + }, + get sessionSecret() { + return env.SESSION_SECRET; + }, + get sessionTtlSeconds() { + return positiveInt(env.SESSION_TTL_SECONDS, 7 * DAY_SECONDS); + }, + get refreshThresholdSeconds() { + return positiveInt(env.SESSION_REFRESH_THRESHOLD_SECONDS, 30); + }, + get flowTtlSeconds() { + return positiveInt(env.AUTH_FLOW_TTL_SECONDS, 600); + } +}; diff --git a/frontend/src/routes/login/+server.ts b/frontend/src/routes/login/+server.ts index 050f39f..45632f5 100644 --- a/frontend/src/routes/login/+server.ts +++ b/frontend/src/routes/login/+server.ts @@ -3,13 +3,12 @@ import * as client from 'openid-client'; import type { RequestHandler } from './$types'; import { getOidcConfig, - authConfig, - OIDC_SCOPE, STATE_COOKIE, VERIFIER_COOKIE, NONCE_COOKIE, RETURN_COOKIE } from '$lib/server/auth'; +import { authConfig } from '$lib/server/config'; export const GET: RequestHandler = async ({ url, cookies }) => { const config = await getOidcConfig(); @@ -24,7 +23,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => { const parameters: Record = { redirect_uri: authConfig.redirectUri, - scope: OIDC_SCOPE, + scope: authConfig.scope, code_challenge: codeChallenge, code_challenge_method: 'S256', state, @@ -39,7 +38,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => { sameSite: 'lax' as const, secure: url.protocol === 'https:', path: '/', - maxAge: 600 + maxAge: authConfig.flowTtlSeconds }; cookies.set(VERIFIER_COOKIE, codeVerifier, cookieOptions); cookies.set(STATE_COOKIE, state, cookieOptions); diff --git a/frontend/src/routes/logout/+server.ts b/frontend/src/routes/logout/+server.ts index 659863d..4ebf70c 100644 --- a/frontend/src/routes/logout/+server.ts +++ b/frontend/src/routes/logout/+server.ts @@ -1,7 +1,8 @@ import { redirect } from '@sveltejs/kit'; import * as client from 'openid-client'; import type { RequestHandler } from './$types'; -import { getOidcConfig, authConfig, decodeSession, SESSION_COOKIE } from '$lib/server/auth'; +import { getOidcConfig, decodeSession, SESSION_COOKIE } from '$lib/server/auth'; +import { authConfig } from '$lib/server/config'; const handleLogout: RequestHandler = async ({ cookies }) => { const session = await decodeSession(cookies.get(SESSION_COOKIE)); From b9d14412e65b2044624c11c00e6ca3af28c882d6 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Sun, 5 Jul 2026 23:45:18 +0200 Subject: [PATCH 009/217] refactor(frontend): make SessionUser fields non-null and normalize claims at boundary --- frontend/src/lib/components/Navbar.svelte | 4 ++-- frontend/src/lib/server/auth.ts | 25 ++++++++++++++++---- frontend/src/lib/types.ts | 6 ++--- frontend/src/routes/auth/callback/+server.ts | 6 ++--- 4 files changed, 27 insertions(+), 14 deletions(-) diff --git a/frontend/src/lib/components/Navbar.svelte b/frontend/src/lib/components/Navbar.svelte index af3d981..a295b83 100644 --- a/frontend/src/lib/components/Navbar.svelte +++ b/frontend/src/lib/components/Navbar.svelte @@ -35,10 +35,10 @@ - {(user.name ?? user.email ?? '?').charAt(0)} + {user.name.charAt(0)} {/if} - +
+
+ +

+ {#if data.query} + {total} + {total === 1 ? 'resultado' : 'resultados'} para «{data.query}» + {:else} + {total} + {total === 1 ? 'canción' : 'canciones'} + {/if} +

+ + {#if tracks.items.length === 0} +
+
🎵
+

+ {#if data.query} + No hay canciones que coincidan con «{data.query}». + {:else} + Todavía no hay canciones. ¡Sé el primero en subir una! + {/if} +

+
+ {:else} +
    + {#each tracks.items as track (track.id)} +
  • +
    + 🎵 +
    +

    {track.title}

    +

    + {dateFormatter.format(new Date(track.createdAt))} +

    +
  • + {/each} +
+ + {#if tracks.hasPreviousPage || tracks.hasNextPage} + + {/if} + {/if} + From 1769df12d50563b5aaf578e0380b3d8366dfc699 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Mon, 6 Jul 2026 02:12:58 +0200 Subject: [PATCH 017/217] refactor(search): treat empty search strings as no filter (string.IsNullOrEmpty) --- backend/Musify.Application/PlayLists/GetPlayListsByUserId.cs | 2 +- backend/Musify.Application/Tracks/GetTracks.cs | 2 +- backend/Musify.Application/Tracks/GetTracksByUserId.cs | 2 +- backend/Musify.Application/Users/GetUsers.cs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/Musify.Application/PlayLists/GetPlayListsByUserId.cs b/backend/Musify.Application/PlayLists/GetPlayListsByUserId.cs index 4d21c1b..b0062da 100644 --- a/backend/Musify.Application/PlayLists/GetPlayListsByUserId.cs +++ b/backend/Musify.Application/PlayLists/GetPlayListsByUserId.cs @@ -24,7 +24,7 @@ namespace Musify.Application.PlayLists .AsNoTracking() .Where(p => p.UserId == request.UserId); - if (request.Name is not null) + if (!string.IsNullOrEmpty(request.Name)) { var normalizedName = request.Name.Trim().ToUpperInvariant(); playListsQuery = playListsQuery.Where(p => p.NormalizedName.Contains(normalizedName)); diff --git a/backend/Musify.Application/Tracks/GetTracks.cs b/backend/Musify.Application/Tracks/GetTracks.cs index 377cd91..f29a33e 100644 --- a/backend/Musify.Application/Tracks/GetTracks.cs +++ b/backend/Musify.Application/Tracks/GetTracks.cs @@ -24,7 +24,7 @@ namespace Musify.Application.Tracks .Include(ut => ut.Track) .AsQueryable(); - if (request.Name is not null) + if (!string.IsNullOrEmpty(request.Name)) { var normalizedName = request.Name.Trim().ToUpperInvariant(); tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName)); diff --git a/backend/Musify.Application/Tracks/GetTracksByUserId.cs b/backend/Musify.Application/Tracks/GetTracksByUserId.cs index 657aa2e..b6ae5fd 100644 --- a/backend/Musify.Application/Tracks/GetTracksByUserId.cs +++ b/backend/Musify.Application/Tracks/GetTracksByUserId.cs @@ -28,7 +28,7 @@ namespace Musify.Application.Tracks .Include(ut => ut.Track) .Where(p => p.UserId == request.UserId); - if (request.Name is not null) + if (!string.IsNullOrEmpty(request.Name)) { var normalizedName = request.Name.Trim().ToUpperInvariant(); tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName)); diff --git a/backend/Musify.Application/Users/GetUsers.cs b/backend/Musify.Application/Users/GetUsers.cs index 6f57f60..0504390 100644 --- a/backend/Musify.Application/Users/GetUsers.cs +++ b/backend/Musify.Application/Users/GetUsers.cs @@ -20,7 +20,7 @@ namespace Musify.Application.Users .AsNoTracking() .AsQueryable(); - if (request.UsernameSearch is not null) + if (!string.IsNullOrEmpty(request.UsernameSearch)) { var normalizedUsername = request.UsernameSearch.Trim().ToUpperInvariant(); user = user.Where(u => u.NormalizedName.Contains(normalizedUsername)); From 7aa53504842c010719a6007790558f294ca079d2 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Mon, 6 Jul 2026 02:20:47 +0200 Subject: [PATCH 018/217] fix(playlists): reject empty/whitespace name and description on update --- .../Validators/PlayLists/UpdatePlayListRequestValidator.cs | 2 ++ backend/Musify.Application/PlayLists/UpdatePlayList.cs | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs b/backend/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs index defc147..7d35d9b 100644 --- a/backend/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs +++ b/backend/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs @@ -8,10 +8,12 @@ public sealed class UpdatePlayListRequestValidator : AbstractValidator x.NewName) + .NotEmpty() .MaximumLength(50) .When(x => x.NewName is not null); RuleFor(x => x.NewDescription) + .NotEmpty() .MaximumLength(256) .When(x => x.NewDescription is not null); } diff --git a/backend/Musify.Application/PlayLists/UpdatePlayList.cs b/backend/Musify.Application/PlayLists/UpdatePlayList.cs index 40f2440..5586d3d 100644 --- a/backend/Musify.Application/PlayLists/UpdatePlayList.cs +++ b/backend/Musify.Application/PlayLists/UpdatePlayList.cs @@ -45,13 +45,13 @@ namespace Musify.Application.PlayLists return Error.Unauthorized(); } - if (request.NewName is not null) + if (!string.IsNullOrWhiteSpace(request.NewName)) { playListEntity.Name = request.NewName; playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant(); } - if (request.NewDescription is not null) + if (!string.IsNullOrWhiteSpace(request.NewDescription)) { playListEntity.Description = request.NewDescription; } From e34347e450145c69dd162b7297fd8f5b89d8746c Mon Sep 17 00:00:00 2001 From: XShine555 Date: Mon, 6 Jul 2026 02:23:35 +0200 Subject: [PATCH 019/217] refactor(application): drop redundant default values on query records --- backend/Musify.Application/PlayLists/GetPlayListTracks.cs | 4 ++-- backend/Musify.Application/PlayLists/GetPlayLists.cs | 2 +- .../Musify.Application/PlayLists/GetPlayListsByUserId.cs | 4 ++-- backend/Musify.Application/Tracks/GetTracks.cs | 2 +- backend/Musify.Application/Tracks/GetTracksByUserId.cs | 6 +++--- backend/Musify.Application/Users/GetUsers.cs | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/backend/Musify.Application/PlayLists/GetPlayListTracks.cs b/backend/Musify.Application/PlayLists/GetPlayListTracks.cs index 27d49f0..47e7bed 100644 --- a/backend/Musify.Application/PlayLists/GetPlayListTracks.cs +++ b/backend/Musify.Application/PlayLists/GetPlayListTracks.cs @@ -10,8 +10,8 @@ namespace Musify.Application.PlayLists { public record GetPlayListTracksQuery( Guid PlayListId, - int PageNumber = 1, - int PageSize = 10) + int PageNumber, + int PageSize) : IQuery>>; public class GetPlayListTracksQueryHandler(IDatabase database) diff --git a/backend/Musify.Application/PlayLists/GetPlayLists.cs b/backend/Musify.Application/PlayLists/GetPlayLists.cs index 70c1a8d..806dacc 100644 --- a/backend/Musify.Application/PlayLists/GetPlayLists.cs +++ b/backend/Musify.Application/PlayLists/GetPlayLists.cs @@ -8,7 +8,7 @@ using Musify.Application.Contracts; namespace Musify.Application.PlayLists { - public record GetPlayListsQuery(int PageNumber = 1, int PageSize = 10) + public record GetPlayListsQuery(int PageNumber, int PageSize) : IQuery> >; public class GetPlayListsQueryHandler(IDatabase database) diff --git a/backend/Musify.Application/PlayLists/GetPlayListsByUserId.cs b/backend/Musify.Application/PlayLists/GetPlayListsByUserId.cs index b0062da..d8ee3d8 100644 --- a/backend/Musify.Application/PlayLists/GetPlayListsByUserId.cs +++ b/backend/Musify.Application/PlayLists/GetPlayListsByUserId.cs @@ -11,8 +11,8 @@ namespace Musify.Application.PlayLists public record GetPlayListsByUserIdQuery( long UserId, string? Name, - int PageNumber = 1, - int PageSize = 10) + int PageNumber, + int PageSize) : IQuery> >; public class GetPlayListsByUserIdQueryHandler(IDatabase database) diff --git a/backend/Musify.Application/Tracks/GetTracks.cs b/backend/Musify.Application/Tracks/GetTracks.cs index f29a33e..dc08c15 100644 --- a/backend/Musify.Application/Tracks/GetTracks.cs +++ b/backend/Musify.Application/Tracks/GetTracks.cs @@ -8,7 +8,7 @@ using Musify.Application.Contracts; namespace Musify.Application.Tracks { - public record GetTracksQuery(string? Name = null, int PageNumber = 1, int PageSize = 10) + public record GetTracksQuery(string? Name, int PageNumber, int PageSize) : IQuery >>; public class GetTracksQueryHandler(IDatabase database) diff --git a/backend/Musify.Application/Tracks/GetTracksByUserId.cs b/backend/Musify.Application/Tracks/GetTracksByUserId.cs index b6ae5fd..48f1f6d 100644 --- a/backend/Musify.Application/Tracks/GetTracksByUserId.cs +++ b/backend/Musify.Application/Tracks/GetTracksByUserId.cs @@ -10,9 +10,9 @@ namespace Musify.Application.Tracks { public record GetTracksByUserIdQuery( long UserId, - string? Name = null, - int PageNumber = 1, - int PageSize = 10) + string? Name, + int PageNumber, + int PageSize) : IQuery > >; public class GetTracksByUserIdQueryHandler(IDatabase database) diff --git a/backend/Musify.Application/Users/GetUsers.cs b/backend/Musify.Application/Users/GetUsers.cs index 0504390..acc2317 100644 --- a/backend/Musify.Application/Users/GetUsers.cs +++ b/backend/Musify.Application/Users/GetUsers.cs @@ -8,7 +8,7 @@ using Musify.Application.Contracts; namespace Musify.Application.Users { - public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch = null) + public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch) : IQuery >>; public class GetUsersQueryHandler(IDatabase database) From c04d79beb0fe08f51934e148640457dbc43fddfd Mon Sep 17 00:00:00 2001 From: XShine555 Date: Mon, 6 Jul 2026 02:40:44 +0200 Subject: [PATCH 020/217] feat(frontend): music player component with separated controls and stream proxy --- .../lib/components/player/PlayerBar.svelte | 26 ++++ .../src/lib/components/player/SeekBar.svelte | 26 ++++ .../lib/components/player/TrackInfo.svelte | 19 +++ .../player/TransportControls.svelte | 54 ++++++++ .../components/player/VolumeControl.svelte | 34 +++++ frontend/src/lib/player/player.svelte.ts | 125 ++++++++++++++++++ frontend/src/routes/+layout.svelte | 4 + .../routes/api/tracks/[id]/stream/+server.ts | 20 +++ 8 files changed, 308 insertions(+) create mode 100644 frontend/src/lib/components/player/PlayerBar.svelte create mode 100644 frontend/src/lib/components/player/SeekBar.svelte create mode 100644 frontend/src/lib/components/player/TrackInfo.svelte create mode 100644 frontend/src/lib/components/player/TransportControls.svelte create mode 100644 frontend/src/lib/components/player/VolumeControl.svelte create mode 100644 frontend/src/lib/player/player.svelte.ts create mode 100644 frontend/src/routes/api/tracks/[id]/stream/+server.ts diff --git a/frontend/src/lib/components/player/PlayerBar.svelte b/frontend/src/lib/components/player/PlayerBar.svelte new file mode 100644 index 0000000..2103422 --- /dev/null +++ b/frontend/src/lib/components/player/PlayerBar.svelte @@ -0,0 +1,26 @@ + + +{#if player.current} +
+
+ + +
+ + +
+ + +
+
+{/if} diff --git a/frontend/src/lib/components/player/SeekBar.svelte b/frontend/src/lib/components/player/SeekBar.svelte new file mode 100644 index 0000000..a3f9509 --- /dev/null +++ b/frontend/src/lib/components/player/SeekBar.svelte @@ -0,0 +1,26 @@ + + +
+ {format(player.currentTime)} + player.seek(Number(event.currentTarget.value))} + aria-label="Progreso" + class="h-1 w-full cursor-pointer accent-emerald-500" + /> + {format(player.duration)} +
diff --git a/frontend/src/lib/components/player/TrackInfo.svelte b/frontend/src/lib/components/player/TrackInfo.svelte new file mode 100644 index 0000000..ffbd02c --- /dev/null +++ b/frontend/src/lib/components/player/TrackInfo.svelte @@ -0,0 +1,19 @@ + + +
+
+ 🎵 +
+
+

{player.current?.title}

+ {#if player.error} +

{player.error}

+ {:else} +

Musify

+ {/if} +
+
diff --git a/frontend/src/lib/components/player/TransportControls.svelte b/frontend/src/lib/components/player/TransportControls.svelte new file mode 100644 index 0000000..f1ca4ed --- /dev/null +++ b/frontend/src/lib/components/player/TransportControls.svelte @@ -0,0 +1,54 @@ + + +
+ + + + + +
diff --git a/frontend/src/lib/components/player/VolumeControl.svelte b/frontend/src/lib/components/player/VolumeControl.svelte new file mode 100644 index 0000000..5947c0a --- /dev/null +++ b/frontend/src/lib/components/player/VolumeControl.svelte @@ -0,0 +1,34 @@ + + +
+ + player.setVolume(Number(event.currentTarget.value))} + aria-label="Volumen" + class="h-1 w-24 cursor-pointer accent-emerald-500" + /> +
diff --git a/frontend/src/lib/player/player.svelte.ts b/frontend/src/lib/player/player.svelte.ts new file mode 100644 index 0000000..7286a6b --- /dev/null +++ b/frontend/src/lib/player/player.svelte.ts @@ -0,0 +1,125 @@ +import { browser } from '$app/environment'; + +export interface PlayerTrack { + id: string; + title: string; +} + +interface StreamResponse { + manifestUrl: string; + ticket: string; + expiresInSeconds: number; +} + +class PlayerState { + queue = $state([]); + index = $state(-1); + isPlaying = $state(false); + loading = $state(false); + currentTime = $state(0); + duration = $state(0); + volume = $state(1); + muted = $state(false); + error = $state(null); + + current = $derived(this.index >= 0 ? (this.queue[this.index] ?? null) : null); + hasNext = $derived(this.index < this.queue.length - 1); + hasPrevious = $derived(this.index > 0); + + #audio: HTMLAudioElement | null = null; + + #ensureAudio(): HTMLAudioElement { + if (this.#audio) return this.#audio; + + const audio = new Audio(); + audio.preload = 'metadata'; + audio.volume = this.volume; + + audio.addEventListener('timeupdate', () => (this.currentTime = audio.currentTime)); + audio.addEventListener( + 'durationchange', + () => (this.duration = Number.isFinite(audio.duration) ? audio.duration : 0) + ); + audio.addEventListener('play', () => (this.isPlaying = true)); + audio.addEventListener('pause', () => (this.isPlaying = false)); + audio.addEventListener('waiting', () => (this.loading = true)); + audio.addEventListener('playing', () => (this.loading = false)); + audio.addEventListener('canplay', () => (this.loading = false)); + audio.addEventListener('ended', () => this.next()); + audio.addEventListener('error', () => { + this.loading = false; + this.error = 'No se pudo reproducir la pista.'; + }); + + this.#audio = audio; + return audio; + } + + async playQueue(tracks: PlayerTrack[], startIndex = 0) { + this.queue = tracks; + await this.#load(startIndex); + } + + async #load(index: number) { + if (!browser || index < 0 || index >= this.queue.length) return; + + this.index = index; + this.error = null; + this.loading = true; + this.currentTime = 0; + this.duration = 0; + + const audio = this.#ensureAudio(); + try { + const response = await fetch(`/api/tracks/${this.queue[index].id}/stream`); + if (!response.ok) throw new Error(String(response.status)); + + const stream: StreamResponse = await response.json(); + audio.src = `${stream.manifestUrl}?t=${encodeURIComponent(stream.ticket)}`; + await audio.play(); + } catch { + this.loading = false; + this.error = 'No se pudo reproducir la pista.'; + } + } + + toggle() { + const audio = this.#audio; + if (!audio || !this.current) return; + if (audio.paused) audio.play(); + else audio.pause(); + } + + next() { + if (this.hasNext) this.#load(this.index + 1); + else if (this.#audio) this.#audio.pause(); + } + + previous() { + if (this.currentTime > 3 || !this.hasPrevious) this.seek(0); + else this.#load(this.index - 1); + } + + seek(seconds: number) { + const audio = this.#audio; + if (!audio) return; + audio.currentTime = seconds; + this.currentTime = seconds; + } + + setVolume(value: number) { + this.volume = value; + this.muted = value === 0; + if (this.#audio) { + this.#audio.volume = value; + this.#audio.muted = this.muted; + } + } + + toggleMute() { + this.muted = !this.muted; + if (this.#audio) this.#audio.muted = this.muted; + } +} + +export const player = new PlayerState(); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index da082b8..b8d7216 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -3,6 +3,8 @@ import favicon from '$lib/assets/favicon.svg'; import Navbar from '$lib/components/Navbar.svelte'; import Footer from '$lib/components/Footer.svelte'; + import PlayerBar from '$lib/components/player/PlayerBar.svelte'; + import { player } from '$lib/player/player.svelte'; let { children, data } = $props(); @@ -13,4 +15,6 @@
{@render children()}
+
+
diff --git a/frontend/src/routes/api/tracks/[id]/stream/+server.ts b/frontend/src/routes/api/tracks/[id]/stream/+server.ts new file mode 100644 index 0000000..68618cc --- /dev/null +++ b/frontend/src/routes/api/tracks/[id]/stream/+server.ts @@ -0,0 +1,20 @@ +import type { RequestHandler } from './$types'; +import { json, error } from '@sveltejs/kit'; +import { createApiClient } from '$lib/server/api'; + +export const GET: RequestHandler = async ({ params, locals, fetch }) => { + if (!locals.accessToken) { + error(401, 'Inicia sesión para reproducir.'); + } + + const api = createApiClient({ fetch, accessToken: locals.accessToken }); + const { data, error: err, response } = await api.GET('/tracks/{id}/stream', { + params: { path: { id: params.id } } + }); + + if (err || !data) { + error(response?.status ?? 502, 'No se pudo obtener el stream de la pista.'); + } + + return json(data); +}; From bbc115ca841ff550d05bb81c168f2686313a774d Mon Sep 17 00:00:00 2001 From: XShine555 Date: Mon, 6 Jul 2026 02:40:44 +0200 Subject: [PATCH 021/217] feat(frontend): play tracks from the explore page --- frontend/src/routes/explore/+page.svelte | 35 ++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/explore/+page.svelte b/frontend/src/routes/explore/+page.svelte index 280f986..0e0b7a2 100644 --- a/frontend/src/routes/explore/+page.svelte +++ b/frontend/src/routes/explore/+page.svelte @@ -1,4 +1,6 @@ -{#if player.current} -
-
- - -
- - -
- - +
+
+ +
+ +
+
-{/if} +
diff --git a/frontend/src/lib/components/player/SeekBar.svelte b/frontend/src/lib/components/player/SeekBar.svelte index a3f9509..deebf69 100644 --- a/frontend/src/lib/components/player/SeekBar.svelte +++ b/frontend/src/lib/components/player/SeekBar.svelte @@ -1,26 +1,33 @@ -
- {format(player.currentTime)} - player.seek(Number(event.currentTarget.value))} - aria-label="Progreso" - class="h-1 w-full cursor-pointer accent-emerald-500" - /> - {format(player.duration)} +
+ + {fmtTime(player.progress)} + + + + {fmtTime(player.current.duration)} +
diff --git a/frontend/src/lib/components/player/TrackInfo.svelte b/frontend/src/lib/components/player/TrackInfo.svelte index ffbd02c..3140637 100644 --- a/frontend/src/lib/components/player/TrackInfo.svelte +++ b/frontend/src/lib/components/player/TrackInfo.svelte @@ -1,19 +1,27 @@
-
- 🎵 -
+ {#if player.current.id} + + {:else} +
+ {/if}
-

{player.current?.title}

- {#if player.error} -

{player.error}

- {:else} -

Musify

- {/if} +
+ {player.current.title} +
+
{player.current.artist || '—'}
diff --git a/frontend/src/lib/components/player/TransportControls.svelte b/frontend/src/lib/components/player/TransportControls.svelte index f1ca4ed..d53fd7b 100644 --- a/frontend/src/lib/components/player/TransportControls.svelte +++ b/frontend/src/lib/components/player/TransportControls.svelte @@ -2,39 +2,33 @@ import { player } from '$lib/player/player.svelte'; -
+
@@ -42,13 +36,12 @@
diff --git a/frontend/src/lib/components/player/VolumeControl.svelte b/frontend/src/lib/components/player/VolumeControl.svelte index 5947c0a..ea2b3e7 100644 --- a/frontend/src/lib/components/player/VolumeControl.svelte +++ b/frontend/src/lib/components/player/VolumeControl.svelte @@ -2,33 +2,44 @@ import { player } from '$lib/player/player.svelte'; -
- +
player.setVolume(Number(event.currentTarget.value))} aria-label="Volumen" - class="h-1 w-24 cursor-pointer accent-emerald-500" + class="mf-volume" + style="background:linear-gradient(to right, var(--mf-accent) {player.volume}%, var(--mf-track) {player.volume}%)" />
+ + diff --git a/frontend/src/lib/components/ui/Cover.svelte b/frontend/src/lib/components/ui/Cover.svelte new file mode 100644 index 0000000..0601e1b --- /dev/null +++ b/frontend/src/lib/components/ui/Cover.svelte @@ -0,0 +1,35 @@ + + +
+ {#if !failed} + (loaded = true)} + onerror={() => (failed = true)} + class="h-full w-full object-cover" + /> + {/if} + {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/NowPlaying.svelte b/frontend/src/lib/components/ui/NowPlaying.svelte new file mode 100644 index 0000000..82a9d4b --- /dev/null +++ b/frontend/src/lib/components/ui/NowPlaying.svelte @@ -0,0 +1,25 @@ + + +
+ {#each bars as bar, i (i)} + + {/each} +
diff --git a/frontend/src/lib/components/ui/PlaylistArt.svelte b/frontend/src/lib/components/ui/PlaylistArt.svelte new file mode 100644 index 0000000..14535a4 --- /dev/null +++ b/frontend/src/lib/components/ui/PlaylistArt.svelte @@ -0,0 +1,34 @@ + + +{#if mosaic} +
+ {#each trackIds.slice(0, 4) as id (id)} + + {/each} +
+{:else if single !== null} + +{:else} +
+{/if} diff --git a/frontend/src/lib/player/player.svelte.ts b/frontend/src/lib/player/player.svelte.ts index 7286a6b..0601fe7 100644 --- a/frontend/src/lib/player/player.svelte.ts +++ b/frontend/src/lib/player/player.svelte.ts @@ -1,124 +1,254 @@ import { browser } from '$app/environment'; +import { HUES, accentForHue, gradientForHue } from '$lib/theme/color'; +import { extractAccent, type Accent } from '$lib/theme/palette'; export interface PlayerTrack { - id: string; + id: string | number; title: string; + artist: string; + duration: number; + hue: number; } -interface StreamResponse { - manifestUrl: string; - ticket: string; - expiresInSeconds: number; +export interface Playlist { + id: string | number; + name: string; + trackIds: (string | number)[]; +} + +export interface QueueItem { + id: string | number; + title: string; + artist?: string; +} + +const RECENT_LIMIT = 12; + +const EMPTY: PlayerTrack = { id: '', title: '', artist: '', duration: 0, hue: HUES[0] }; + +function hueFor(id: string | number): number { + const text = String(id); + let hash = 0; + for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0; + return HUES[hash % HUES.length]; +} + +function toTrack(item: QueueItem): PlayerTrack { + return { + id: item.id, + title: item.title, + artist: item.artist ?? '', + duration: 0, + hue: hueFor(item.id) + }; } class PlayerState { - queue = $state([]); - index = $state(-1); - isPlaying = $state(false); + tracks = $state([]); + currentId = $state(null); + playing = $state(false); + progress = $state(0); + volume = $state(70); loading = $state(false); - currentTime = $state(0); - duration = $state(0); - volume = $state(1); - muted = $state(false); - error = $state(null); + error = $state(''); - current = $derived(this.index >= 0 ? (this.queue[this.index] ?? null) : null); - hasNext = $derived(this.index < this.queue.length - 1); - hasPrevious = $derived(this.index > 0); + recentlyPlayed = $state([]); + playlists = $state([]); + + accentColor = $state(null); + gradientColor = $state(null); #audio: HTMLAudioElement | null = null; + #loadToken = 0; + #accentCache = new Map(); - #ensureAudio(): HTMLAudioElement { + current = $derived(this.tracks.find((t) => t.id === this.currentId) ?? EMPTY); + accent = $derived(this.accentColor ?? accentForHue(this.current.hue)); + artGradient = $derived(this.gradientColor ?? gradientForHue(this.current.hue)); + progressPercent = $derived( + 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; - const audio = new Audio(); - audio.preload = 'metadata'; - audio.volume = this.volume; - - audio.addEventListener('timeupdate', () => (this.currentTime = audio.currentTime)); - audio.addEventListener( - 'durationchange', - () => (this.duration = Number.isFinite(audio.duration) ? audio.duration : 0) - ); - audio.addEventListener('play', () => (this.isPlaying = true)); - audio.addEventListener('pause', () => (this.isPlaying = false)); - audio.addEventListener('waiting', () => (this.loading = true)); - audio.addEventListener('playing', () => (this.loading = false)); - audio.addEventListener('canplay', () => (this.loading = false)); + audio.preload = 'auto'; + audio.volume = this.volume / 100; + audio.addEventListener('timeupdate', () => { + this.progress = audio.currentTime; + }); + audio.addEventListener('durationchange', () => this.#syncDuration(audio.duration)); + audio.addEventListener('loadedmetadata', () => this.#syncDuration(audio.duration)); + audio.addEventListener('play', () => (this.playing = true)); + audio.addEventListener('pause', () => (this.playing = false)); audio.addEventListener('ended', () => this.next()); audio.addEventListener('error', () => { this.loading = false; + this.playing = false; this.error = 'No se pudo reproducir la pista.'; }); - this.#audio = audio; return audio; } - async playQueue(tracks: PlayerTrack[], startIndex = 0) { - this.queue = tracks; - await this.#load(startIndex); + #syncDuration(value: number) { + if (!Number.isFinite(value) || value <= 0) return; + const id = this.currentId; + this.tracks = this.tracks.map((t) => + t.id === id ? { ...t, duration: Math.round(value) } : t + ); } - async #load(index: number) { - if (!browser || index < 0 || index >= this.queue.length) return; + #index() { + return this.tracks.findIndex((t) => t.id === this.currentId); + } - this.index = index; - this.error = null; + #pushRecent(track: PlayerTrack) { + if (!track.id) return; + this.recentlyPlayed = [track, ...this.recentlyPlayed.filter((t) => t.id !== track.id)].slice( + 0, + RECENT_LIMIT + ); + } + + async #applyAccent(id: string | number) { + const key = String(id); + const cached = this.#accentCache.get(key); + if (cached) { + this.accentColor = cached.accent; + this.gradientColor = cached.gradient; + 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.gradientColor = result.gradient; + } else { + this.accentColor = null; + this.gradientColor = null; + } + } + + async #loadCurrent() { + const audio = this.#audioEl(); + if (!audio || this.currentId === null) return; + const id = this.currentId; + this.#applyAccent(id); + const token = ++this.#loadToken; this.loading = true; - this.currentTime = 0; - this.duration = 0; - - const audio = this.#ensureAudio(); + this.error = ''; + this.progress = 0; try { - const response = await fetch(`/api/tracks/${this.queue[index].id}/stream`); - if (!response.ok) throw new Error(String(response.status)); - - const stream: StreamResponse = await response.json(); - audio.src = `${stream.manifestUrl}?t=${encodeURIComponent(stream.ticket)}`; + const res = await fetch(`/api/tracks/${id}/stream`); + if (!res.ok) throw new Error(String(res.status)); + const { manifestUrl, ticket } = (await res.json()) as { + manifestUrl: string; + ticket: string; + }; + if (token !== this.#loadToken) return; + const sep = manifestUrl.includes('?') ? '&' : '?'; + audio.src = ticket ? `${manifestUrl}${sep}t=${encodeURIComponent(ticket)}` : manifestUrl; + audio.volume = this.volume / 100; await audio.play(); - } catch { this.loading = false; + this.#pushRecent(this.current); + } catch { + if (token !== this.#loadToken) return; + this.loading = false; + this.playing = false; this.error = 'No se pudo reproducir la pista.'; } } + playlistTracks(ids: (string | number)[]): PlayerTrack[] { + return ids + .map((id) => this.tracks.find((t) => t.id === id)) + .filter((t): t is PlayerTrack => Boolean(t)); + } + + mosaicFor(ids: (string | number)[]): string[] { + const gradients = this.playlistTracks(ids) + .slice(0, 4) + .map((t) => gradientForHue(t.hue)); + while (gradients.length < 4) gradients.push('var(--mf-surface)'); + return gradients; + } + + playQueue(list: QueueItem[], startIndex = 0) { + if (list.length === 0) return; + this.tracks = list.map(toTrack); + const start = this.tracks[startIndex] ?? this.tracks[0]; + this.currentId = start.id; + this.#loadCurrent(); + } + + playTrack(id: string | number) { + if (this.currentId === id) { + this.toggle(); + return; + } + const known = + this.tracks.find((t) => t.id === id) ?? this.recentlyPlayed.find((t) => t.id === id); + if (known && !this.tracks.some((t) => t.id === id)) { + this.tracks = [known]; + } + if (!this.tracks.some((t) => t.id === id)) return; + this.currentId = id; + 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.#audio; - if (!audio || !this.current) return; - if (audio.paused) audio.play(); + const audio = this.#audioEl(); + if (!audio || this.currentId === null) return; + if (audio.paused) audio.play().catch(() => {}); else audio.pause(); } next() { - if (this.hasNext) this.#load(this.index + 1); - else if (this.#audio) this.#audio.pause(); + if (this.tracks.length === 0) return; + const idx = this.#index(); + this.currentId = this.tracks[(idx + 1) % this.tracks.length].id; + this.#loadCurrent(); } previous() { - if (this.currentTime > 3 || !this.hasPrevious) this.seek(0); - else this.#load(this.index - 1); + if (this.tracks.length === 0) return; + const audio = this.#audioEl(); + if (audio && audio.currentTime > 3) { + audio.currentTime = 0; + return; + } + const idx = this.#index(); + this.currentId = this.tracks[(idx - 1 + this.tracks.length) % this.tracks.length].id; + this.#loadCurrent(); } - seek(seconds: number) { - const audio = this.#audio; - if (!audio) return; - audio.currentTime = seconds; - this.currentTime = seconds; + seekFraction(fraction: number) { + const audio = this.#audioEl(); + const clamped = Math.min(1, Math.max(0, fraction)); + const target = clamped * this.current.duration; + this.progress = target; + if (audio && Number.isFinite(audio.duration)) audio.currentTime = target; } setVolume(value: number) { - this.volume = value; - this.muted = value === 0; - if (this.#audio) { - this.#audio.volume = value; - this.#audio.muted = this.muted; - } - } - - toggleMute() { - this.muted = !this.muted; - if (this.#audio) this.#audio.muted = this.muted; + this.volume = Math.min(100, Math.max(0, Math.round(value))); + const audio = this.#audioEl(); + if (audio) audio.volume = this.volume / 100; } } diff --git a/frontend/src/routes/api/tracks/[id]/cover/+server.ts b/frontend/src/routes/api/tracks/[id]/cover/+server.ts new file mode 100644 index 0000000..8e12c44 --- /dev/null +++ b/frontend/src/routes/api/tracks/[id]/cover/+server.ts @@ -0,0 +1,25 @@ +import type { RequestHandler } from './$types'; +import { apiConfig } from '$lib/server/config'; + +const ALLOWED = new Set(['small', 'medium', 'large']); + +export const GET: RequestHandler = async ({ params, url, fetch }) => { + const size = url.searchParams.get('size') ?? 'medium'; + const safeSize = ALLOWED.has(size) ? size : 'medium'; + + const upstream = await fetch( + `${apiConfig.baseUrl}/tracks/${params.id}/cover?size=${safeSize}` + ); + + if (!upstream.ok) { + return new Response(null, { status: upstream.status }); + } + + return new Response(upstream.body, { + status: 200, + headers: { + 'Content-Type': upstream.headers.get('content-type') ?? 'image/webp', + 'Cache-Control': 'public, max-age=86400' + } + }); +}; From 0b4e61b3ab6dbfd52051b2f98b03f4b5e5c7e429 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Tue, 14 Jul 2026 04:55:04 +0200 Subject: [PATCH 026/217] feat(frontend): add (app) route group and rework auth routing Move authenticated pages under an (app) group with a Sidebar shell, add /auth/login and page-based /login, drop the old explore page and login +server endpoint, and add the server upload helper. --- frontend/src/lib/components/Sidebar.svelte | 150 ++++++++ frontend/src/lib/server/upload.ts | 51 +++ .../src/routes/(app)/downloads/+page.svelte | 25 ++ .../{ => (app)}/explore/+page.server.ts | 0 .../routes/{ => (app)}/explore/+page.svelte | 55 ++- frontend/src/routes/(app)/import/+page.svelte | 25 ++ .../src/routes/(app)/library/+page.server.ts | 18 + .../src/routes/(app)/library/+page.svelte | 96 +++++ .../routes/(app)/playlists/+page.server.ts | 50 +++ .../src/routes/(app)/playlists/+page.svelte | 181 ++++++++++ .../(app)/playlists/[id]/+page.server.ts | 86 +++++ .../routes/(app)/playlists/[id]/+page.svelte | 312 +++++++++++++++++ .../src/routes/(app)/upload/+page.server.ts | 105 ++++++ frontend/src/routes/(app)/upload/+page.svelte | 328 ++++++++++++++++++ frontend/src/routes/+layout.server.ts | 12 +- frontend/src/routes/+layout.svelte | 73 +++- .../src/routes/{ => auth}/login/+server.ts | 0 frontend/src/routes/login/+page.server.ts | 9 + frontend/src/routes/login/+page.svelte | 39 +++ 19 files changed, 1584 insertions(+), 31 deletions(-) create mode 100644 frontend/src/lib/components/Sidebar.svelte create mode 100644 frontend/src/lib/server/upload.ts create mode 100644 frontend/src/routes/(app)/downloads/+page.svelte rename frontend/src/routes/{ => (app)}/explore/+page.server.ts (100%) rename frontend/src/routes/{ => (app)}/explore/+page.svelte (57%) create mode 100644 frontend/src/routes/(app)/import/+page.svelte create mode 100644 frontend/src/routes/(app)/library/+page.server.ts create mode 100644 frontend/src/routes/(app)/library/+page.svelte create mode 100644 frontend/src/routes/(app)/playlists/+page.server.ts create mode 100644 frontend/src/routes/(app)/playlists/+page.svelte create mode 100644 frontend/src/routes/(app)/playlists/[id]/+page.server.ts create mode 100644 frontend/src/routes/(app)/playlists/[id]/+page.svelte create mode 100644 frontend/src/routes/(app)/upload/+page.server.ts create mode 100644 frontend/src/routes/(app)/upload/+page.svelte rename frontend/src/routes/{ => auth}/login/+server.ts (100%) create mode 100644 frontend/src/routes/login/+page.server.ts create mode 100644 frontend/src/routes/login/+page.svelte diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte new file mode 100644 index 0000000..320ae89 --- /dev/null +++ b/frontend/src/lib/components/Sidebar.svelte @@ -0,0 +1,150 @@ + + +{#snippet navLink(link: { href: string; label: string; icon: typeof Home })} + {@const active = isActive(link.href)} + + + {link.label} + +{/snippet} + + + + diff --git a/frontend/src/lib/server/upload.ts b/frontend/src/lib/server/upload.ts new file mode 100644 index 0000000..ea15106 --- /dev/null +++ b/frontend/src/lib/server/upload.ts @@ -0,0 +1,51 @@ +import http from 'node:http'; +import https from 'node:https'; +import { env } from '$env/dynamic/private'; + +const CONNECT_HOST = env.S3_UPLOAD_CONNECT_HOST || '127.0.0.1'; + +export async function putPresigned( + presignedUrl: string, + body: Buffer, + contentType: string +): Promise { + const url = new URL(presignedUrl); + const signed = new Set( + (url.searchParams.get('X-Amz-SignedHeaders') ?? 'host').toLowerCase().split(';') + ); + + const headers: Record = { + Host: url.host, + 'Content-Length': body.byteLength + }; + if (signed.has('content-type')) headers['Content-Type'] = contentType; + if (signed.has('if-none-match')) headers['If-None-Match'] = '*'; + + const secure = url.protocol === 'https:'; + const transport = secure ? https : http; + const port = url.port || (secure ? 443 : 80); + + await new Promise((resolve, reject) => { + const request = transport.request( + { + host: CONNECT_HOST, + port, + method: 'PUT', + path: url.pathname + url.search, + headers + }, + (response) => { + let raw = ''; + response.on('data', (chunk) => (raw += chunk)); + response.on('end', () => { + const status = response.statusCode ?? 0; + if (status >= 200 && status < 300) resolve(); + else reject(new Error(`S3 PUT ${status}: ${raw.slice(0, 200)}`)); + }); + } + ); + request.on('error', reject); + request.write(body); + request.end(); + }); +} diff --git a/frontend/src/routes/(app)/downloads/+page.svelte b/frontend/src/routes/(app)/downloads/+page.svelte new file mode 100644 index 0000000..eb12010 --- /dev/null +++ b/frontend/src/routes/(app)/downloads/+page.svelte @@ -0,0 +1,25 @@ + + + + Descargas · Musify + + + +
+

Descargas

+

+ Escucha sin conexión lo que descargues. +

+ +
+ +

Todavía no tienes descargas

+

+ Las canciones que descargues aparecerán aquí. +

+
+
diff --git a/frontend/src/routes/explore/+page.server.ts b/frontend/src/routes/(app)/explore/+page.server.ts similarity index 100% rename from frontend/src/routes/explore/+page.server.ts rename to frontend/src/routes/(app)/explore/+page.server.ts diff --git a/frontend/src/routes/explore/+page.svelte b/frontend/src/routes/(app)/explore/+page.svelte similarity index 57% rename from frontend/src/routes/explore/+page.svelte rename to frontend/src/routes/(app)/explore/+page.svelte index 0e0b7a2..bceeeda 100644 --- a/frontend/src/routes/explore/+page.svelte +++ b/frontend/src/routes/(app)/explore/+page.svelte @@ -1,5 +1,9 @@ + + + Importar · Musify + + + +
+

Importar

+

+ Trae tu música desde otros servicios. +

+ +
+ +

Próximamente

+

+ Podrás importar tu música desde otras plataformas. +

+
+
diff --git a/frontend/src/routes/(app)/library/+page.server.ts b/frontend/src/routes/(app)/library/+page.server.ts new file mode 100644 index 0000000..5f4bb59 --- /dev/null +++ b/frontend/src/routes/(app)/library/+page.server.ts @@ -0,0 +1,18 @@ +import type { PageServerLoad } from './$types'; +import { error } from '@sveltejs/kit'; +import { createApiClient } from '$lib/server/api'; + +const PAGE_SIZE = 50; + +export const load: PageServerLoad = async ({ locals, fetch }) => { + if (!locals.user) error(401, 'Inicia sesión.'); + + const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + const { data, error: err } = await api.GET('/tracks/users/{userId}', { + params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: PAGE_SIZE } } + }); + + if (err || !data) error(502, 'No se pudo cargar tu biblioteca.'); + + return { tracks: data }; +}; diff --git a/frontend/src/routes/(app)/library/+page.svelte b/frontend/src/routes/(app)/library/+page.svelte new file mode 100644 index 0000000..7540c1b --- /dev/null +++ b/frontend/src/routes/(app)/library/+page.svelte @@ -0,0 +1,96 @@ + + + + Tu biblioteca · Musify + + + +
+
+
+

Tu biblioteca

+

+ {total} + {total === 1 ? 'canción' : 'canciones'} en tu biblioteca. +

+
+ + + Subir música + +
+ + {#if items.length > 0} +
+ {#each items as track, i (track.id)} + + {/each} +
+ {:else} +
+

Todavía no tienes canciones

+ + + Sube la primera + +
+ {/if} +
diff --git a/frontend/src/routes/(app)/playlists/+page.server.ts b/frontend/src/routes/(app)/playlists/+page.server.ts new file mode 100644 index 0000000..f573652 --- /dev/null +++ b/frontend/src/routes/(app)/playlists/+page.server.ts @@ -0,0 +1,50 @@ +import type { PageServerLoad, Actions } from './$types'; +import { error, fail, redirect } from '@sveltejs/kit'; +import { createApiClient } from '$lib/server/api'; + +const MAX_NAME = 100; + +export const load: PageServerLoad = async ({ locals, fetch }) => { + if (!locals.user) error(401, 'Inicia sesión.'); + + const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + const { data, error: err } = await api.GET('/playlists/users/{userId}', { + params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: 50 } } + }); + + if (err || !data) error(502, 'No se pudieron cargar tus playlists.'); + + const covers = await Promise.all( + data.items.map(async (playlist) => { + const { data: tracks } = await api.GET('/playlists/{playlistId}/tracks', { + params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 4 } } + }); + return [playlist.id, tracks?.items.map((t) => t.id) ?? []] as const; + }) + ); + + return { playlists: data, trackIds: Object.fromEntries(covers) as Record }; +}; + +export const actions: Actions = { + create: async ({ request, locals, fetch }) => { + if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' }); + + const form = await request.formData(); + const name = String(form.get('name') ?? '').trim(); + const description = String(form.get('description') ?? '').trim(); + + if (name === '' || name.length > MAX_NAME) { + return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).', name, description }); + } + + const api = createApiClient({ fetch, accessToken: locals.accessToken }); + const { data, error: err } = await api.POST('/playlists', { + body: { name, description, pictureIntentId: null } + }); + + if (err || !data) return fail(502, { message: 'No se pudo crear la playlist.', name, description }); + + redirect(303, `/playlists/${data.id}`); + } +}; diff --git a/frontend/src/routes/(app)/playlists/+page.svelte b/frontend/src/routes/(app)/playlists/+page.svelte new file mode 100644 index 0000000..2f1b8de --- /dev/null +++ b/frontend/src/routes/(app)/playlists/+page.svelte @@ -0,0 +1,181 @@ + + + + Tus listas · Musify + + + +
+
+
+

Tus listas

+

Crea y organiza tus colecciones.

+
+ +
+ + {#if items.length > 0} + + {:else} +
+ + + +

Todavía no tienes listas

+

Crea la primera y añádele canciones de tu biblioteca.

+ +
+ {/if} +
+ +{#if creating} +
{ + if (e.target === e.currentTarget) creating = false; + }} + onkeydown={(e) => { + if (e.key === 'Escape') creating = false; + }} + > +
+
+

Nueva lista

+ +
+ +
{ + submitting = true; + return async ({ update }) => { + await update(); + submitting = false; + }; + }} + class="mt-5 space-y-4" + > +
+ + +
+
+ + +
+ + {#if form?.message} +

+ {form.message} +

+ {/if} + +
+ + +
+
+
+
+{/if} diff --git a/frontend/src/routes/(app)/playlists/[id]/+page.server.ts b/frontend/src/routes/(app)/playlists/[id]/+page.server.ts new file mode 100644 index 0000000..ba854f3 --- /dev/null +++ b/frontend/src/routes/(app)/playlists/[id]/+page.server.ts @@ -0,0 +1,86 @@ +import type { PageServerLoad, Actions } from './$types'; +import { error, fail, redirect } from '@sveltejs/kit'; +import { createApiClient } from '$lib/server/api'; + +const MAX_NAME = 100; + +export const load: PageServerLoad = async ({ params, locals, fetch }) => { + if (!locals.user) error(401, 'Inicia sesión.'); + + const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + + const [playlistRes, tracksRes, libraryRes] = 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 } } + }), + api.GET('/tracks/users/{userId}', { + params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: 200 } } + }) + ]); + + if (playlistRes.error || !playlistRes.data) error(404, 'Playlist no encontrada.'); + + const tracks = tracksRes.data?.items ?? []; + const inPlaylist = new Set(tracks.map((t) => t.id)); + const library = (libraryRes.data?.items ?? []).filter((t) => !inPlaylist.has(t.id)); + + return { playlist: playlistRes.data, tracks, library }; +}; + +export const actions: Actions = { + addTrack: async ({ request, params, locals, fetch }) => { + if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' }); + const trackId = String((await request.formData()).get('trackId') ?? ''); + if (!trackId) return fail(400, { message: 'Falta la canción.' }); + + const api = createApiClient({ fetch, accessToken: locals.accessToken }); + const { error: err } = await api.POST('/playlists/{playlistId}/tracks/{trackId}', { + params: { path: { playlistId: params.id, trackId } } + }); + if (err) return fail(502, { message: 'No se pudo añadir la canción.' }); + return { added: true }; + }, + + removeTrack: async ({ request, params, locals, fetch }) => { + if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' }); + const trackId = String((await request.formData()).get('trackId') ?? ''); + if (!trackId) return fail(400, { message: 'Falta la canción.' }); + + const api = createApiClient({ fetch, accessToken: locals.accessToken }); + const { error: err } = await api.DELETE('/playlists/{playlistId}/tracks/{trackId}', { + params: { path: { playlistId: params.id, trackId } } + }); + if (err) return fail(502, { message: 'No se pudo quitar la canción.' }); + return { removed: true }; + }, + + rename: async ({ request, params, locals, fetch }) => { + if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' }); + const form = await request.formData(); + const name = String(form.get('name') ?? '').trim(); + const description = String(form.get('description') ?? '').trim(); + if (name === '' || name.length > MAX_NAME) { + return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).' }); + } + + const api = createApiClient({ fetch, accessToken: locals.accessToken }); + const { error: err } = await api.PUT('/playlists/{playlistId}', { + params: { path: { playlistId: params.id } }, + body: { newName: name, newDescription: description, newPictureIntentId: null } + }); + if (err) return fail(502, { message: 'No se pudo actualizar la playlist.' }); + return { renamed: true }; + }, + + delete: async ({ params, locals, fetch }) => { + if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' }); + + const api = createApiClient({ fetch, accessToken: locals.accessToken }); + const { error: err } = await api.DELETE('/playlists/{playlistId}', { + params: { path: { playlistId: params.id } } + }); + if (err) return fail(502, { message: 'No se pudo eliminar la playlist.' }); + redirect(303, '/playlists'); + } +}; diff --git a/frontend/src/routes/(app)/playlists/[id]/+page.svelte b/frontend/src/routes/(app)/playlists/[id]/+page.svelte new file mode 100644 index 0000000..4c69ed4 --- /dev/null +++ b/frontend/src/routes/(app)/playlists/[id]/+page.svelte @@ -0,0 +1,312 @@ + + + + {playlist.name} · Musify + + + +
+ + + + + Tus listas + + +
+ t.id)} + hue={hueFor(playlist.id)} + class="h-44 w-44 flex-shrink-0 rounded-2xl shadow-[0_20px_45px_-15px_rgba(0,0,0,0.7)]" + /> +
+

Lista

+

{playlist.name}

+ {#if playlist.description} +

{playlist.description}

+ {/if} +

+ {tracks.length} + {tracks.length === 1 ? 'canción' : 'canciones'} +

+
+
+ +
+ + + +
+ + {#if tracks.length > 0} +
+ {#each tracks as track, i (track.id)} +
+ +
async ({ update }) => update()}> + + +
+
+ {/each} +
+ {:else} +
+ +

Esta playlist está vacía. Añade canciones de tu biblioteca.

+
+ {/if} + + {#if library.length > 0} +
+

Añadir de tu biblioteca

+
+ {#each library as track (track.id)} +
+ +
{track.title}
+
async ({ update }) => update()}> + + +
+
+ {/each} +
+
+ {/if} +
+ +{#if editing} +
{ + if (e.target === e.currentTarget) editing = false; + }} + onkeydown={(e) => { + if (e.key === 'Escape') editing = false; + }} + > +
+
+

Editar playlist

+ +
+
async ({ update, result }) => { + await update({ reset: false }); + if (result.type === 'success') editing = false; + }} + class="mt-5 space-y-4" + > +
+ + +
+
+ + +
+ {#if form?.message} +

+ {form.message} +

+ {/if} +
+ + +
+
+
+
+{/if} + +{#if confirmingDelete} +
{ + if (e.target === e.currentTarget) confirmingDelete = false; + }} + onkeydown={(e) => { + if (e.key === 'Escape') confirmingDelete = false; + }} + > +
+

¿Eliminar «{playlist.name}»?

+

+ Esta acción no se puede deshacer. Las canciones seguirán en tu biblioteca. +

+
+ +
+ +
+
+
+
+{/if} diff --git a/frontend/src/routes/(app)/upload/+page.server.ts b/frontend/src/routes/(app)/upload/+page.server.ts new file mode 100644 index 0000000..385505b --- /dev/null +++ b/frontend/src/routes/(app)/upload/+page.server.ts @@ -0,0 +1,105 @@ +import type { Actions } from './$types'; +import { fail } from '@sveltejs/kit'; +import { createApiClient } from '$lib/server/api'; +import { putPresigned } from '$lib/server/upload'; + +const MAX_TITLE = 100; + +const AUDIO_TYPES: Record = { + mp3: 'audio/mpeg', + m4a: 'audio/mp4', + aac: 'audio/aac', + flac: 'audio/flac', + wav: 'audio/wav', + ogg: 'audio/ogg', + opus: 'audio/opus' +}; + +const IMAGE_TYPES: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp' +}; + +function extOf(name: string): string { + const dot = name.lastIndexOf('.'); + return dot >= 0 ? name.slice(dot + 1).toLowerCase() : ''; +} + +function contentTypeOf(file: File, table: Record): string { + if (file.type) return file.type; + return table[extOf(file.name)] ?? 'application/octet-stream'; +} + +export const actions: Actions = { + default: async ({ request, locals, fetch }) => { + if (!locals.accessToken) { + return fail(401, { message: 'Inicia sesión para subir música.' }); + } + + const form = await request.formData(); + const title = String(form.get('title') ?? '').trim(); + const audio = form.get('audio'); + const cover = form.get('cover'); + + 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.' }); + } + + const audioContentType = contentTypeOf(audio, AUDIO_TYPES); + const pictureContentType = contentTypeOf(cover, IMAGE_TYPES); + const api = createApiClient({ fetch, accessToken: locals.accessToken }); + + const { data: urls, error: urlsError } = await api.POST('/tracks/upload-urls', { + body: { + pictureFileType: extOf(cover.name) || 'jpg', + pictureContentType, + audioFileType: extOf(audio.name) || 'mp3', + audioContentType, + expectedPictureSizeBytes: cover.size, + expectedAudioSizeBytes: audio.size + } + }); + + if (urlsError || !urls) { + return fail(502, { message: 'No se pudieron reservar las URLs de subida.' }); + } + + try { + const [audioBuffer, coverBuffer] = await Promise.all([ + audio.arrayBuffer(), + cover.arrayBuffer() + ]); + await Promise.all([ + putPresigned(urls.audioUploadUrl, Buffer.from(audioBuffer), urls.audioContentType), + putPresigned(urls.pictureUploadUrl, Buffer.from(coverBuffer), urls.pictureContentType) + ]); + } catch (err) { + return fail(502, { + message: 'Falló la subida del archivo al almacenamiento.', + detail: err instanceof Error ? err.message : undefined + }); + } + + const { data: track, error: createError } = await api.POST('/tracks', { + body: { + title, + pictureIntentId: urls.pictureIntentId, + audioIntentId: urls.audioIntentId + } + }); + + if (createError || !track) { + return fail(502, { message: 'No se pudo crear la pista tras la subida.' }); + } + + return { success: true, trackId: track.id, title: track.title }; + } +}; diff --git a/frontend/src/routes/(app)/upload/+page.svelte b/frontend/src/routes/(app)/upload/+page.svelte new file mode 100644 index 0000000..0e51502 --- /dev/null +++ b/frontend/src/routes/(app)/upload/+page.svelte @@ -0,0 +1,328 @@ + + + + Subir música · Musify + + + +
+ + + + + Volver a la biblioteca + +

Subir música

+

+ Añade una canción con su portada y título. Nosotros la procesamos para streaming. +

+ +
    + {#each steps as step, i (step.key)} +
  1. +
    + + {#if stepStates[i] === 'done'} + + {:else} + {i + 1} + {/if} + + {step.label} +
    +

    {step.hint}

    +
  2. + {/each} +
+ + {#if status === 'done'} +
+ + + +

¡Subida!

+

+ «{publishedTitle}» se está procesando. Aparecerá lista para reproducir en tu biblioteca en + unos momentos. +

+
+ + Ir a la biblioteca + + +
+
+ {:else} +
{ + status = 'uploading'; + errorMsg = ''; + return async ({ result }) => { + if (result.type === 'success') { + publishedTitle = title; + status = 'done'; + } else if (result.type === 'failure') { + status = 'error'; + errorMsg = (result.data?.message as string) ?? 'No se pudo subir la canción.'; + } else { + status = 'error'; + errorMsg = 'Error inesperado durante la subida.'; + } + }; + }} + class="mt-10 space-y-8" + > +
{ + e.preventDefault(); + dragging = true; + }} + ondragleave={() => (dragging = false)} + ondrop={onDrop} + class="relative flex min-h-72 flex-col items-center justify-center rounded-xl border border-dashed bg-neutral-950 p-10 text-center transition {dropClass}" + > + + + {#if audioName === ''} + + {:else} + + {/if} + + {#if audioName === ''} +

+ Arrastra tu audio aquí o haz clic para elegir +

+

MP3, FLAC, WAV…

+ {:else} +

{audioName}

+

{formatSize(audioSize)} · Listo para subir

+ {/if} +
+ +
+
+ Portada + +
+ +
+ + +
+

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

+ {title.length}/{MAX_TITLE} +
+
+
+ + + + {#if status === 'uploading'} +
+
+ Subiendo y creando la pista… + un momento +
+
+
+
+
+ {/if} + + {#if status === 'error'} +
+ {errorMsg} +
+ {/if} + +
+ + Cancelar + + +
+
+ {/if} +
diff --git a/frontend/src/routes/+layout.server.ts b/frontend/src/routes/+layout.server.ts index d2511ab..9e6807d 100644 --- a/frontend/src/routes/+layout.server.ts +++ b/frontend/src/routes/+layout.server.ts @@ -1,5 +1,11 @@ +import { redirect } from '@sveltejs/kit'; import type { LayoutServerLoad } from './$types'; -export const load: LayoutServerLoad = ({ locals }) => ({ - user: locals.user -}); +const PUBLIC_PATHS = new Set(['/login']); + +export const load: LayoutServerLoad = ({ locals, url }) => { + if (!locals.user && !PUBLIC_PATHS.has(url.pathname)) { + redirect(302, `/login?returnTo=${encodeURIComponent(url.pathname + url.search)}`); + } + return { user: locals.user }; +}; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index b8d7216..f1ea9b6 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,20 +1,75 @@ -
- -
{@render children()}
-
-
- -
+{#if isAuthPage} +
+ {@render children()} +
+{:else} +
+ +
+
+ {#key page.url.pathname} +
{@render children()}
+ {/key} +
+ {#if hasTrack} + + {/if} +
+
+{/if} diff --git a/frontend/src/routes/login/+server.ts b/frontend/src/routes/auth/login/+server.ts similarity index 100% rename from frontend/src/routes/login/+server.ts rename to frontend/src/routes/auth/login/+server.ts diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts new file mode 100644 index 0000000..13f45cd --- /dev/null +++ b/frontend/src/routes/login/+page.server.ts @@ -0,0 +1,9 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = ({ locals, url }) => { + if (locals.user) { + redirect(302, url.searchParams.get('returnTo') ?? '/'); + } + return { returnTo: url.searchParams.get('returnTo') ?? '/' }; +}; diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte new file mode 100644 index 0000000..b1ab69d --- /dev/null +++ b/frontend/src/routes/login/+page.svelte @@ -0,0 +1,39 @@ + + + + Iniciar sesión · Musify + + + +
+
+
Musify
+ +
+

Tu música. Sin límites.

+

Inicia sesión para continuar.

+
+ + +
+
From c544a6c78c66fd41ca4aa39bd87478384210b1c8 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Tue, 14 Jul 2026 04:55:12 +0200 Subject: [PATCH 027/217] feat(frontend): rework home page with recent, latest and playlists Add SSR data loading for the home page and rebuild its layout (greeting hero, recently played, new releases, user playlists). --- frontend/src/routes/+page.server.ts | 33 ++++ frontend/src/routes/+page.svelte | 224 ++++++++++++++++++++-------- 2 files changed, 195 insertions(+), 62 deletions(-) create mode 100644 frontend/src/routes/+page.server.ts diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts new file mode 100644 index 0000000..d6fa3c2 --- /dev/null +++ b/frontend/src/routes/+page.server.ts @@ -0,0 +1,33 @@ +import type { PageServerLoad } from './$types'; +import { createApiClient } from '$lib/server/api'; + +export const load: PageServerLoad = async ({ locals, fetch }) => { + const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + + const latestPromise = api.GET('/tracks', { + params: { query: { pageNumber: 1, pageSize: 12 } } + }); + const playlistsPromise = locals.user + ? api.GET('/playlists/users/{userId}', { + params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: 8 } } + }) + : Promise.resolve({ data: undefined }); + + const [latest, playlists] = await Promise.all([latestPromise, playlistsPromise]); + const playlistItems = playlists.data?.items ?? []; + + const covers = await Promise.all( + playlistItems.map(async (playlist) => { + const { data: tracks } = await api.GET('/playlists/{playlistId}/tracks', { + params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 4 } } + }); + return [playlist.id, tracks?.items.map((t) => t.id) ?? []] as const; + }) + ); + + return { + latest: latest.data?.items ?? [], + playlists: playlistItems, + trackIds: Object.fromEntries(covers) as Record + }; +}; diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 578ca35..6e26884 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,76 +1,176 @@ - Musify · Tu música, en streaming - + Musify + -
+
- -
- - Ahora en beta - - -

- Tu música, en streaming. +
+

{greeting}

+

+ Tu música. Sin límites.

+
+

-

- Escucha, organiza y sube tus canciones. Musify es tu biblioteca musical, siempre contigo. -

- -
- - Empezar gratis - - - Explorar canciones - +{#if player.recentlyPlayed.length > 0} +
+

Escuchado recientemente

+
+ {#each player.recentlyPlayed as track (track.id)} + + {/each}
-
+
+{/if} + +
+

Novedades

+ {#if latest.length > 0} +
+ {#each latest as track, i (track.id)} + + {/each} +
+ {:else} +
+ +

Todavía no hay música

+

+ Sube una canción + y empieza a escuchar. +

+
+ {/if}
-
-
- {#each features as feature (feature.title)} -
-
{feature.icon}
-

{feature.title}

-

{feature.body}

-
- {/each} -
+
+

Tus listas

+ {#if playlists.length > 0} +
+ {#each playlists as playlist, i (playlist.id)} + + +
+ {playlist.name} +
+
+ {/each} +
+ {:else} +
+

Todavía no tienes listas

+

+ Cuando crees listas, tus últimas aparecerán aquí. +

+
+ {/if}
From dd41d23144a394da8a3752f641abcedbcbdae104 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Tue, 14 Jul 2026 04:55:39 +0200 Subject: [PATCH 028/217] chore(frontend): add font/icon deps and dev config Add @fontsource-variable (manrope/sora/inter), @lucide/svelte and @types/node, mark @lucide/svelte as noExternal for SSR, and add the dev-server launch config. --- frontend/.claude/launch.json | 11 +++++++ frontend/package-lock.json | 59 ++++++++++++++++++++++++++++++++++++ frontend/package.json | 5 +++ frontend/vite.config.ts | 3 ++ 4 files changed, 78 insertions(+) create mode 100644 frontend/.claude/launch.json diff --git a/frontend/.claude/launch.json b/frontend/.claude/launch.json new file mode 100644 index 0000000..72eb748 --- /dev/null +++ b/frontend/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "musify-web", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 5173 + } + ] +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dc407b6..10ca183 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,10 @@ "name": "frontend", "version": "0.0.1", "dependencies": { + "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/manrope": "^5.2.8", + "@fontsource-variable/sora": "^5.2.8", + "@lucide/svelte": "^1.23.0", "@sveltejs/adapter-node": "^5.5.7", "jose": "^6.2.3", "openapi-fetch": "^0.17.0", @@ -18,6 +22,7 @@ "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", "@tailwindcss/vite": "^4.3.0", + "@types/node": "^22.20.1", "svelte": "^5.56.1", "svelte-check": "^4.6.0", "tailwindcss": "^4.3.0", @@ -35,6 +40,33 @@ "tslib": "^2.4.0" } }, + "node_modules/@fontsource-variable/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/manrope": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.2.8.tgz", + "integrity": "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/sora": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/sora/-/sora-5.2.8.tgz", + "integrity": "sha512-sP+ILTfi5r3cqmMBntI4ooQNxFI+LyHyD5hcPbWwwL4uh4kKFAkOz0w2veCXjz18fKRdva6OG0dY+5yQ02f30w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -80,6 +112,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lucide/svelte": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.23.0.tgz", + "integrity": "sha512-3LQbKXx9vId6Nx4E2Nu2qwgJfdmr5+CVeVJbxe5cy+HcnCRd9QVVtZXqvgBYAV1OJrPmQAf9/3gJWLCpASC/Ng==", + "license": "ISC", + "peerDependencies": { + "svelte": "^5" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -1236,6 +1277,17 @@ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -2203,6 +2255,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/vite": { "version": "8.1.3", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2857179..439c394 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", "@tailwindcss/vite": "^4.3.0", + "@types/node": "^22.20.1", "svelte": "^5.56.1", "svelte-check": "^4.6.0", "tailwindcss": "^4.3.0", @@ -24,6 +25,10 @@ "vite": "^8.0.16" }, "dependencies": { + "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/manrope": "^5.2.8", + "@fontsource-variable/sora": "^5.2.8", + "@lucide/svelte": "^1.23.0", "@sveltejs/adapter-node": "^5.5.7", "jose": "^6.2.3", "openapi-fetch": "^0.17.0", diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index dd21275..c1c5be5 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,6 +4,9 @@ import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; export default defineConfig({ + ssr: { + noExternal: ['@lucide/svelte'] + }, plugins: [ tailwindcss(), sveltekit({ From 0f776078f3cd1aac81fa49ea0d8b3109eb3120f3 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Tue, 14 Jul 2026 04:55:44 +0200 Subject: [PATCH 029/217] refactor(frontend): tidy Navbar, Footer and document shell Align Navbar and Footer with the new design tokens and enable stable scrollbar gutter on the document. --- frontend/src/app.html | 2 +- frontend/src/lib/components/Footer.svelte | 9 +++------ frontend/src/lib/components/Navbar.svelte | 24 ++++++----------------- 3 files changed, 10 insertions(+), 25 deletions(-) diff --git a/frontend/src/app.html b/frontend/src/app.html index f273cc5..04bc5f9 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -1,5 +1,5 @@ - + diff --git a/frontend/src/lib/components/Footer.svelte b/frontend/src/lib/components/Footer.svelte index 967d27c..fce1599 100644 --- a/frontend/src/lib/components/Footer.svelte +++ b/frontend/src/lib/components/Footer.svelte @@ -1,11 +1,8 @@ -
+

© {new Date().getFullYear()} Musify · Streaming de música.

- +

Tu música, siempre contigo.

diff --git a/frontend/src/lib/components/Navbar.svelte b/frontend/src/lib/components/Navbar.svelte index a295b83..48b6282 100644 --- a/frontend/src/lib/components/Navbar.svelte +++ b/frontend/src/lib/components/Navbar.svelte @@ -6,27 +6,15 @@ } let { user }: Props = $props(); - - const links = [ - { href: '/', label: 'Inicio' }, - { href: '/explore', label: 'Explorar' } - ]; -
-

{step.hint}

@@ -205,12 +205,12 @@ {/if} {#if audioName === ''} -

+

Arrastra tu audio aquí o haz clic para elegir

MP3, FLAC, WAV…

{:else} -

+

{audioName}

{formatSize(audioSize)} · Listo para subir

From dd3150e3d4c15f2ca4608b290ec3180162101633 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Mon, 20 Jul 2026 17:29:28 +0200 Subject: [PATCH 125/217] perf(backend): cache YouTube searches and shrink the thumbnail URLs Repeated searches and song lookups now reuse a single-flight memory cache, so concurrent or debounced queries hit YouTube once instead of on every keystroke. Thumbnails are resolved at a configurable size instead of the largest variant, which cuts the image payload the browser pulls from Google. --- backend/Musify.Api/AppSettings.json | 5 +- .../Configuration/YouTubeConfiguration.cs | 9 ++++ .../Services/SingleFlightCache.cs | 41 +++++++++++++++ .../Services/YouTubeMusicService.cs | 46 +++++++++++++--- .../Services/YouTubeThumbnail.cs | 52 +++++++++++++++++++ 5 files changed, 144 insertions(+), 9 deletions(-) create mode 100644 backend/Musify.Infrastructure/Services/SingleFlightCache.cs create mode 100644 backend/Musify.Infrastructure/Services/YouTubeThumbnail.cs diff --git a/backend/Musify.Api/AppSettings.json b/backend/Musify.Api/AppSettings.json index 5b7fe4b..93cef14 100644 --- a/backend/Musify.Api/AppSettings.json +++ b/backend/Musify.Api/AppSettings.json @@ -51,7 +51,10 @@ "YouTube": { "GeographicalLocation": "US", "StreamUrlCacheSeconds": 1800, - "SearchContinuationCacheSeconds": 600 + "SearchContinuationCacheSeconds": 600, + "SearchResultsCacheSeconds": 900, + "SongInfoCacheSeconds": 86400, + "ThumbnailSize": 240 }, "PlayList": { "Routes": { diff --git a/backend/Musify.Infrastructure/Configuration/YouTubeConfiguration.cs b/backend/Musify.Infrastructure/Configuration/YouTubeConfiguration.cs index bcff643..f2e81e3 100644 --- a/backend/Musify.Infrastructure/Configuration/YouTubeConfiguration.cs +++ b/backend/Musify.Infrastructure/Configuration/YouTubeConfiguration.cs @@ -14,5 +14,14 @@ namespace Musify.Infrastructure.Configuration [Range(60, 3600)] public int SearchContinuationCacheSeconds { get; set; } = 600; + + [Range(0, 86400)] + public int SearchResultsCacheSeconds { get; set; } = 900; + + [Range(0, 604800)] + public int SongInfoCacheSeconds { get; set; } = 86400; + + [Range(32, 720)] + public int ThumbnailSize { get; set; } = 240; } } diff --git a/backend/Musify.Infrastructure/Services/SingleFlightCache.cs b/backend/Musify.Infrastructure/Services/SingleFlightCache.cs new file mode 100644 index 0000000..bd34a1c --- /dev/null +++ b/backend/Musify.Infrastructure/Services/SingleFlightCache.cs @@ -0,0 +1,41 @@ +using System.Collections.Concurrent; +using ErrorOr; +using Microsoft.Extensions.Caching.Memory; + +namespace Musify.Infrastructure.Services +{ + public sealed class SingleFlightCache(IMemoryCache cache) + { + private readonly ConcurrentDictionary inFlight = new(); + + public async Task> GetOrCreateAsync( + string key, + TimeSpan duration, + Func>> factory) + { + if (duration <= TimeSpan.Zero) + return await factory(); + + if (cache.TryGetValue(key, out TValue? cached) && cached is not null) + return cached; + + var pending = (Lazy>>)inFlight.GetOrAdd( + key, + _ => new Lazy>>(factory)); + + try + { + var result = await pending.Value; + + if (!result.IsError) + cache.Set(key, result.Value, duration); + + return result; + } + finally + { + inFlight.TryRemove(key, out _); + } + } + } +} diff --git a/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs b/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs index caf4e08..d4b1628 100644 --- a/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs +++ b/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs @@ -13,10 +13,22 @@ namespace Musify.Infrastructure.Services public class YouTubeMusicService(YouTubeConfiguration configuration, IMemoryCache cache, ILogger logger) : IYouTubeMusicService { private readonly YouTubeMusicClient client = new(geographicalLocation: configuration.GeographicalLocation); + private readonly SingleFlightCache singleFlight = new(cache); private sealed record CachedStreamInfo(string Url, DateTime ExpiresAtUtc); public async Task> SearchSongsAsync(string query, string continuationToken, CancellationToken cancellationToken) + { + if (!string.IsNullOrEmpty(continuationToken)) + return await FetchSearchPageAsync(query, continuationToken, cancellationToken); + + return await singleFlight.GetOrCreateAsync( + SearchResultsCacheKey(query), + TimeSpan.FromSeconds(configuration.SearchResultsCacheSeconds), + () => FetchSearchPageAsync(query, continuationToken, cancellationToken)); + } + + private async Task> FetchSearchPageAsync(string query, string continuationToken, CancellationToken cancellationToken) { PaginatedAsyncEnumerable? paginator; if (string.IsNullOrEmpty(continuationToken)) @@ -47,10 +59,7 @@ namespace Musify.Infrastructure.Services string.Join(", ", song.Artists.Select(artist => artist.Name)), song.Album?.Name ?? string.Empty, (int)song.Duration.TotalSeconds, - song.Thumbnails - .OrderByDescending(thumbnail => thumbnail.Width) - .Select(thumbnail => thumbnail.Url) - .FirstOrDefault() ?? string.Empty, + PickThumbnail(song.Thumbnails), song.IsExplicit, song.Artists .Select(artist => new YouTubeArtistRef( @@ -117,6 +126,14 @@ namespace Musify.Infrastructure.Services } public async Task> GetSongAsync(string videoId, CancellationToken cancellationToken) + { + return await singleFlight.GetOrCreateAsync( + SongInfoCacheKey(videoId), + TimeSpan.FromSeconds(configuration.SongInfoCacheSeconds), + () => FetchSongAsync(videoId, cancellationToken)); + } + + private async Task> FetchSongAsync(string videoId, CancellationToken cancellationToken) { try { @@ -128,10 +145,7 @@ namespace Musify.Infrastructure.Services string.Join(", ", info.Artists.Select(artist => artist.Name)), string.Empty, (int)info.Duration.TotalSeconds, - info.Thumbnails - .OrderByDescending(thumbnail => thumbnail.Width) - .Select(thumbnail => thumbnail.Url) - .FirstOrDefault() ?? string.Empty, + PickThumbnail(info.Thumbnails), info.IsExplicit, info.Artists .Select(artist => new YouTubeArtistRef( @@ -146,8 +160,24 @@ namespace Musify.Infrastructure.Services } } + private string PickThumbnail(IEnumerable thumbnails) + { + var ordered = thumbnails.OrderBy(thumbnail => thumbnail.Width).ToList(); + + var chosen = ordered.FirstOrDefault(thumbnail => thumbnail.Width >= configuration.ThumbnailSize) + ?? ordered.LastOrDefault(); + + return chosen is null + ? string.Empty + : YouTubeThumbnail.WithSize(chosen.Url, configuration.ThumbnailSize); + } + private static string SearchCacheKey(string token) => $"yt-search:{token}"; + private static string SearchResultsCacheKey(string query) => $"yt-search-results:{query.Trim().ToLowerInvariant()}"; + + private static string SongInfoCacheKey(string videoId) => $"yt-song:{videoId}"; + private static string StreamCacheKey(string videoId) => $"yt-stream:{videoId}"; } } diff --git a/backend/Musify.Infrastructure/Services/YouTubeThumbnail.cs b/backend/Musify.Infrastructure/Services/YouTubeThumbnail.cs new file mode 100644 index 0000000..2eb94f7 --- /dev/null +++ b/backend/Musify.Infrastructure/Services/YouTubeThumbnail.cs @@ -0,0 +1,52 @@ +namespace Musify.Infrastructure.Services +{ + public static class YouTubeThumbnail + { + public static string WithSize(string url, int size) + { + if (string.IsNullOrEmpty(url)) + return string.Empty; + + if (url.Contains("googleusercontent.com", StringComparison.OrdinalIgnoreCase)) + return ResizeGoogleUserContent(url, size); + + if (url.Contains("ytimg.com", StringComparison.OrdinalIgnoreCase)) + return ResizeYtImg(url, size); + + return url; + } + + private static string ResizeGoogleUserContent(string url, int size) + { + var separator = url.LastIndexOf('='); + var baseUrl = separator > 0 && IsSizeSuffix(url.AsSpan(separator + 1)) + ? url[..separator] + : url; + + return $"{baseUrl}=w{size}-h{size}-l90-rj"; + } + + private static string ResizeYtImg(string url, int size) + { + var queryStart = url.IndexOf('?'); + var path = queryStart >= 0 ? url[..queryStart] : url; + + var lastSlash = path.LastIndexOf('/'); + if (lastSlash < 0) + return path; + + return $"{path[..lastSlash]}/{YtImgFileName(size)}"; + } + + private static string YtImgFileName(int size) => size switch + { + <= 120 => "default.jpg", + <= 320 => "mqdefault.jpg", + <= 480 => "hqdefault.jpg", + _ => "maxresdefault.jpg" + }; + + private static bool IsSizeSuffix(ReadOnlySpan suffix) => + suffix.Length > 1 && (suffix[0] is 'w' or 's' or 'h') && char.IsDigit(suffix[1]); + } +} From 2691c40da8468effd3a42178ee1222ed864f27a3 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Mon, 20 Jul 2026 17:36:41 +0200 Subject: [PATCH 126/217] perf(web-player): serve YouTube artwork through a caching proxy The browser used to pull every cover straight from Google, so a search grid plus infinite scroll fired dozens of cross-origin image requests and started coming back as 429. Covers now go through /api/youtube/thumbnail, which resizes the upstream URL, caches the bytes in memory, collapses duplicate in-flight fetches, caps the concurrency against Google and backs off when it answers 429. Responses are immutable for a month and revalidate with an ETag, and serving them same-origin also lets the accent extractor read the pixels. --- web-player/src/lib/components/ui/Cover.svelte | 7 +- web-player/src/lib/player/player.svelte.ts | 8 +- web-player/src/lib/server/config.ts | 27 +++ web-player/src/lib/server/thumbnails.ts | 173 ++++++++++++++++++ web-player/src/lib/thumbnails.ts | 19 ++ .../routes/api/youtube/thumbnail/+server.ts | 31 ++++ 6 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 web-player/src/lib/server/thumbnails.ts create mode 100644 web-player/src/lib/thumbnails.ts create mode 100644 web-player/src/routes/api/youtube/thumbnail/+server.ts diff --git a/web-player/src/lib/components/ui/Cover.svelte b/web-player/src/lib/components/ui/Cover.svelte index f32cfd5..8f799d6 100644 --- a/web-player/src/lib/components/ui/Cover.svelte +++ b/web-player/src/lib/components/ui/Cover.svelte @@ -1,6 +1,7 @@ + + + +
{title}
+
{meta}
+
diff --git a/web-player/src/lib/components/ui/AlbumForm.svelte b/web-player/src/lib/components/ui/AlbumForm.svelte new file mode 100644 index 0000000..f577607 --- /dev/null +++ b/web-player/src/lib/components/ui/AlbumForm.svelte @@ -0,0 +1,101 @@ + + +
{ + submitting = true; + return async ({ update, result }) => { + await update({ reset: false }); + submitting = false; + if (result.type === 'success') onSuccess?.(); + }; + }} + class="mt-4 w-full max-w-2xl space-y-4" +> + + + + + + + + + + diff --git a/web-player/src/lib/components/ui/TrackIndexCell.svelte b/web-player/src/lib/components/ui/TrackIndexCell.svelte index 772c755..f2bca7e 100644 --- a/web-player/src/lib/components/ui/TrackIndexCell.svelte +++ b/web-player/src/lib/components/ui/TrackIndexCell.svelte @@ -1,6 +1,6 @@ - + {@render children()} diff --git a/web-player/src/lib/components/ui/TrackRow.svelte b/web-player/src/lib/components/ui/TrackRow.svelte index 1eadaa7..aeee3da 100644 --- a/web-player/src/lib/components/ui/TrackRow.svelte +++ b/web-player/src/lib/components/ui/TrackRow.svelte @@ -4,19 +4,22 @@ import { trackTableKey, type TrackTableContext } from './TrackTable.svelte'; interface Props { + active?: boolean; class?: string; oncontextmenu?: (event: MouseEvent) => void; children: Snippet; } - let { class: klass = '', oncontextmenu, children }: Props = $props(); + let { active = false, class: klass = '', oncontextmenu, children }: Props = $props(); const table = getContext(trackTableKey);
diff --git a/web-player/src/lib/components/ui/TrackTable.svelte b/web-player/src/lib/components/ui/TrackTable.svelte index 7c745e7..d989d00 100644 --- a/web-player/src/lib/components/ui/TrackTable.svelte +++ b/web-player/src/lib/components/ui/TrackTable.svelte @@ -29,7 +29,7 @@ const columns = $derived( [ index ? '32px' : '', - '1fr', + 'minmax(180px,1fr)', ...meta.map((column) => column.width), '64px', action ? '36px' : '' @@ -39,7 +39,7 @@ ); const columnsMobile = $derived( - [index ? '28px' : '', '1fr', '52px', action ? '32px' : ''] + [index ? '28px' : '', 'minmax(120px,1fr)', '52px', action ? '32px' : ''] .filter((width) => width !== '') .join(' ') ); @@ -54,9 +54,9 @@ }); -
+
{#if index} @@ -71,7 +71,7 @@ {/if}
-
+
{@render children()}
diff --git a/web-player/src/lib/components/ui/TrackTitleCell.svelte b/web-player/src/lib/components/ui/TrackTitleCell.svelte index 393b5c8..81dcd73 100644 --- a/web-player/src/lib/components/ui/TrackTitleCell.svelte +++ b/web-player/src/lib/components/ui/TrackTitleCell.svelte @@ -2,14 +2,17 @@ import type { Snippet } from 'svelte'; import Cover from './Cover.svelte'; import ExplicitBadge from './ExplicitBadge.svelte'; + import ArtistLink from './ArtistLink.svelte'; interface Props { trackId: string | number; title: string; artist?: string | null; + ownerUserId?: string | number | null; coverSrc?: string | null; coverSize?: string; explicit?: boolean; + active?: boolean; titleClass?: string; onClick: () => void; overlay?: Snippet; @@ -20,17 +23,31 @@ trackId, title, artist, + ownerUserId, coverSrc, coverSize = 'h-[42px] w-[42px]', explicit = false, + active = false, titleClass = '', onClick, overlay, badge }: Props = $props(); + + function onKeydown(event: KeyboardEvent) { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + onClick(); + } -
- +
diff --git a/web-player/src/lib/config.ts b/web-player/src/lib/config.ts index 4b3098b..06e24b1 100644 --- a/web-player/src/lib/config.ts +++ b/web-player/src/lib/config.ts @@ -9,12 +9,13 @@ export const LIBRARY_PICKER_PAGE_SIZE = 50; export const EXPLORE_ALBUMS_PAGE_SIZE = 10; export const EXPLORE_ALBUMS_LIMIT = 18; export const HOME_LATEST_PAGE_SIZE = 10; -export const HOME_ALBUMS_LIMIT = 12; -export const HOME_MIXES_BENTO = 5; +export const HOME_MIXES_LIMIT = 6; export const HOME_SHELF_LIMIT = 6; +export const HOME_CONTINUE_LIMIT = 6; +export const HOME_SPOTLIGHT_TRACKS_LIMIT = 5; export const YOUTUBE_FILLER_LIMIT = 27; -export const HOME_TOP_MUSIC_FILLER_LIMIT = 10; export const HOME_POPULAR_FILLER_LIMIT = 10; -export const HOME_POPULAR_MAX = 5; -export const HOME_ALBUM_FILLER_LIMIT = 8; +export const HOME_POPULAR_MAX = 8; export const PLAYLIST_PICKER_PAGE_SIZE = 50; +export const LIKED_TRACKS_PAGE_SIZE = 500; +export const EXPLORE_USERS_LIMIT = 12; diff --git a/web-player/src/lib/format.ts b/web-player/src/lib/format.ts index 3dc9836..efa227d 100644 --- a/web-player/src/lib/format.ts +++ b/web-player/src/lib/format.ts @@ -5,6 +5,13 @@ export function fmtTime(seconds: number): string { return `${minutes}:${rest < 10 ? '0' : ''}${rest}`; } +export function fmtDurationLong(seconds: number): string { + const total = Math.max(0, Math.floor(seconds || 0)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + return hours > 0 ? `${hours} h ${minutes} min` : `${minutes} min`; +} + const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' }); export function fmtDate(value: string | number | Date): string { diff --git a/web-player/src/lib/player/liked.svelte.ts b/web-player/src/lib/player/liked.svelte.ts new file mode 100644 index 0000000..2729e12 --- /dev/null +++ b/web-player/src/lib/player/liked.svelte.ts @@ -0,0 +1,95 @@ +import { toQueueItems, type ApiTrackLike, type TrackSourceKind } from './player.svelte'; + +export interface LikedTrack { + id: string; + title: string; + artist?: string; + coverUrl?: string; + source?: TrackSourceKind; + explicit?: boolean; + ownerUserId?: string | number | null; + likedAt: number; +} + +type LikeToggleInput = { + id: string | number; + title: string; + artist?: string; + coverUrl?: string; + source?: TrackSourceKind; + explicit?: boolean; + ownerUserId?: string | number | null; +}; + +function toggleRequestBody(track: LikeToggleInput) { + return track.source === 'youtube' + ? { source: 'YouTube' as const, externalId: String(track.id) } + : { trackId: String(track.id) }; +} + +class LikedStore { + entries = $state>({}); + private hydrated = false; + + count = $derived(Object.keys(this.entries).length); + list = $derived(Object.values(this.entries).sort((a, b) => b.likedAt - a.likedAt)); + + hydrate(tracks: (ApiTrackLike & { createdAt: string })[]) { + if (this.hydrated) return; + this.hydrated = true; + + const items = toQueueItems(tracks); + const next: Record = {}; + items.forEach((item, i) => { + next[String(item.id)] = { + id: String(item.id), + title: item.title, + artist: item.artist, + coverUrl: item.coverUrl, + source: item.source, + explicit: item.explicit, + ownerUserId: item.ownerUserId, + likedAt: new Date(tracks[i].createdAt).getTime() + }; + }); + this.entries = next; + } + + isLiked(id: string | number): boolean { + return String(id) in this.entries; + } + + async toggle(track: LikeToggleInput) { + const id = String(track.id); + const before = this.entries; + const wasLiked = id in before; + const next = { ...before }; + if (wasLiked) delete next[id]; + else { + next[id] = { + id, + title: track.title, + artist: track.artist, + coverUrl: track.coverUrl, + source: track.source, + explicit: track.explicit, + ownerUserId: track.ownerUserId, + likedAt: Date.now() + }; + } + this.entries = next; + + try { + const res = await fetch('/api/likes', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(toggleRequestBody(track)) + }); + if (!res.ok) throw new Error('Failed to toggle like'); + } catch { + this.entries = before; + } + } +} + +export const liked = new LikedStore(); diff --git a/web-player/src/lib/player/player.svelte.ts b/web-player/src/lib/player/player.svelte.ts index e7b29fb..57e99bf 100644 --- a/web-player/src/lib/player/player.svelte.ts +++ b/web-player/src/lib/player/player.svelte.ts @@ -13,6 +13,7 @@ export interface PlayerTrack { source: TrackSourceKind; coverUrl?: string; explicit?: boolean; + ownerUserId?: string | number | null; } export interface Playlist { @@ -28,6 +29,7 @@ export interface QueueItem { source?: TrackSourceKind; coverUrl?: string; explicit?: boolean; + ownerUserId?: string | number | null; } export interface ApiTrackLike { @@ -38,6 +40,7 @@ export interface ApiTrackLike { externalId?: string | null; audioStatus?: 'Pending' | 'Processing' | 'Completed' | 'Failed'; isExplicit?: boolean; + ownerUserId?: string | number | null; } function isYouTubeTrack(track: ApiTrackLike): boolean { @@ -69,7 +72,8 @@ export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] { ? youTubeThumbnailUrl(track.externalId) : `/api/tracks/${track.id}/cover?size=small` : undefined, - explicit: track.isExplicit + explicit: track.isExplicit, + ownerUserId: youTube ? undefined : track.ownerUserId }; }); } @@ -101,7 +105,8 @@ function toTrack(item: QueueItem): PlayerTrack { duration: 0, explicit: item.explicit, source: item.source ?? 'local', - coverUrl: item.coverUrl + coverUrl: item.coverUrl, + ownerUserId: item.ownerUserId }; } @@ -115,6 +120,8 @@ class PlayerState { error = $state(''); recentlyPlayed = $state([]); playlists = $state([]); + shuffle = $state(false); + repeat = $state(false); accentColor = $state(null); @@ -154,6 +161,11 @@ class PlayerState { }); audio.addEventListener('ended', () => { this.#stopProgressLoop(); + if (this.repeat) { + audio.currentTime = 0; + audio.play().catch(() => {}); + return; + } this.next(); }); audio.addEventListener('error', () => { @@ -397,10 +409,39 @@ class PlayerState { next() { if (this.tracks.length === 0) return; const idx = this.#index(); - this.currentId = this.tracks[(idx + 1) % this.tracks.length].id; + 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.#loadCurrent(); } + toggleShuffle() { + this.shuffle = !this.shuffle; + } + + toggleRepeat() { + this.repeat = !this.repeat; + } + + playQueueIndex(index: number) { + const track = this.tracks[index]; + if (!track) return; + if (this.currentId === track.id) { + this.toggle(); + return; + } + this.currentId = track.id; + this.#loadCurrent(); + } + + clearUpcoming() { + this.tracks = this.currentId === null ? [] : [this.current]; + } + previous() { if (this.tracks.length === 0) return; const audio = this.#audioEl(); diff --git a/web-player/src/lib/player/queuePanel.svelte.ts b/web-player/src/lib/player/queuePanel.svelte.ts new file mode 100644 index 0000000..f8f5ef7 --- /dev/null +++ b/web-player/src/lib/player/queuePanel.svelte.ts @@ -0,0 +1,16 @@ +let open = $state(false); + +export const queuePanel = { + get open() { + return open; + }, + toggle() { + open = !open; + }, + close() { + open = false; + }, + show() { + open = true; + } +}; diff --git a/web-player/src/lib/playlists.svelte.ts b/web-player/src/lib/playlists.svelte.ts new file mode 100644 index 0000000..b2aa6c4 --- /dev/null +++ b/web-player/src/lib/playlists.svelte.ts @@ -0,0 +1,13 @@ +let open = $state(false); + +export const createPlaylistModal = { + get open() { + return open; + }, + show() { + open = true; + }, + close() { + open = false; + } +}; diff --git a/web-player/src/lib/recentlyPlayed.ts b/web-player/src/lib/recentlyPlayed.ts new file mode 100644 index 0000000..1e6cd5d --- /dev/null +++ b/web-player/src/lib/recentlyPlayed.ts @@ -0,0 +1,42 @@ +import { player, toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte'; + +/** + * Merges the in-memory session history (tracks played since the page loaded) + * with the server-side listening history, de-duplicated by id and capped at `limit`. + */ +export function mergeRecentlyPlayed(history: ApiTrackLike[], limit: number): QueueItem[] { + const seen = new Set(); + const merged: QueueItem[] = []; + + for (const t of player.recentlyPlayed) { + const id = String(t.id); + if (seen.has(id)) continue; + seen.add(id); + merged.push({ + id, + title: t.title, + artist: t.artist, + source: t.source, + coverUrl: t.coverUrl, + explicit: t.explicit, + ownerUserId: t.ownerUserId + }); + } + + for (const item of toQueueItems(history)) { + const id = String(item.id); + if (seen.has(id)) continue; + seen.add(id); + merged.push({ + id, + title: item.title, + artist: item.artist, + source: item.source ?? 'local', + coverUrl: item.coverUrl, + explicit: item.explicit, + ownerUserId: item.ownerUserId + }); + } + + return merged.slice(0, limit); +} diff --git a/web-player/src/lib/server/greeting.ts b/web-player/src/lib/server/greeting.ts index 5a5f115..0115d24 100644 --- a/web-player/src/lib/server/greeting.ts +++ b/web-player/src/lib/server/greeting.ts @@ -1,24 +1,107 @@ -const GREETINGS_BY_HOUR: { until: number; pool: string[] }[] = [ - { - until: 6, - pool: ['¿Aún despierto?', 'La noche también tiene banda sonora', 'Un ratito más, con música'] - }, - { - until: 12, - pool: ['Buenos días', 'Que empiece bien la mañana', 'Un nuevo día, una nueva lista'] - }, - { until: 14, pool: ['Buenos días', 'El mediodía sabe mejor con música'] }, - { - until: 19, - pool: ['Buenas tardes', 'La tarde pide su banda sonora', 'Un respiro, con algo de música'] - }, - { until: 23, pool: ['Buenas noches', 'Hora de bajar el ritmo', 'Que la noche te acompañe'] }, - { until: 24, pool: ['¿Aún despierto?', 'La noche también tiene banda sonora'] } +interface SpecialDate { + month: number; + day: number; + message: string; +} + +const SPECIAL_DATES: SpecialDate[] = [ + { month: 1, day: 1, message: 'Nuevo año, nueva música.' }, + { month: 2, day: 14, message: 'Hoy también hay canciones para eso.' }, + { month: 10, day: 31, message: 'Esta noche suena diferente.' }, + { month: 12, day: 24, message: 'Nochebuena. Que no pare la música.' }, + { month: 12, day: 25, message: 'Feliz Navidad. Pon tu banda sonora.' }, + { month: 12, day: 31, message: 'Último día del año. Haz que suene bien.' } ]; -export function pickGreeting(hour: number): string { - const pool = - GREETINGS_BY_HOUR.find((slot) => hour < slot.until)?.pool ?? GREETINGS_BY_HOUR[0].pool; - const greeting = pool[Math.floor(Math.random() * pool.length)]; - return /[.!?]$/.test(greeting) ? greeting : `${greeting}.`; +const BIRTHDAY_MESSAGE = 'Hoy toca celebrar. Tú eliges la música.'; + +const HOUR_SLOTS = [6, 9, 12, 15, 18, 21, 24]; + +const WEEKLY_SCHEDULE: string[][] = [ + [ + 'Domingo de madrugada.', + 'Buenos días. Tómate el domingo con calma.', + 'Domingo. Empieza despacio.', + 'Una mañana tranquila merece buena música.', + 'Domingo por la tarde. Hora de bajar el ritmo.', + 'La semana empieza a despedirse.', + 'Una última canción antes de volver a empezar.' + ], + [ + 'La semana acaba de empezar.', + 'Buenos días. Empezamos otra semana.', + 'Lunes. Dale una banda sonora al día.', + 'Una pausa musical para este lunes.', + 'El lunes todavía no ha terminado.', + 'El lunes ya empieza a quedar atrás.', + 'Sobrevive al lunes con una última canción.' + ], + [ + 'La noche sigue sonando.', + 'Buenos días. Martes en marcha.', + 'La semana ya está cogiendo ritmo.', + 'Martes al mediodía. ¿Qué escuchamos?', + 'Una tarde cualquiera necesita música.', + 'El día empieza a bajar el ritmo.', + 'Un martes más llega a su fin.' + ], + [ + 'Mitad de semana, mitad de noche.', + 'Buenos días. Ya es miércoles.', + 'Ya estamos en el ecuador de la semana.', + 'Miércoles al mediodía. Un poco de música.', + 'La semana ya va cuesta abajo.', + 'Unas canciones para terminar el miércoles.', + 'Mitad de semana superada.' + ], + [ + 'Una noche más antes del fin de semana.', + 'Buenos días. Ya casi estamos.', + 'Jueves. El fin de semana se acerca.', + 'Una pausa musical antes de seguir.', + 'Queda poco para desconectar.', + 'Ya se empieza a notar el viernes.', + 'Una noche más y llega el fin de semana.' + ], + [ + 'La semana ha terminado. La noche continúa.', + 'Buenos días. Es viernes.', + 'Viernes. Hoy suena diferente.', + 'Ya huele a fin de semana.', + 'Últimas horas de la semana.', + 'Se acabó la semana. Dale al play.', + 'Viernes por la noche. Tú eliges la banda sonora.' + ], + [ + 'La noche es joven. Que siga sonando.', + 'Buenos días. Hoy no hay prisa.', + 'Sábado. Empieza el día a tu ritmo.', + 'Mediodía de sábado. ¿Qué ponemos?', + 'Una tarde de sábado pide música.', + 'El sábado todavía tiene mucho por delante.', + 'Esta noche puede sonar cualquier cosa.' + ] +]; + +export interface Birthday { + month: number; + day: number; +} + +/** + * `birthday` is optional because the user's date of birth isn't part of the + * session/profile data yet. Wire it up here once that field exists upstream. + */ +export function pickGreeting(date: Date, birthday?: Birthday | null): string { + const month = date.getMonth() + 1; + const day = date.getDate(); + + if (birthday && birthday.month === month && birthday.day === day) return BIRTHDAY_MESSAGE; + + const special = SPECIAL_DATES.find((d) => d.month === month && d.day === day); + if (special) return special.message; + + const hour = date.getHours(); + const slot = HOUR_SLOTS.findIndex((until) => hour < until); + return WEEKLY_SCHEDULE[date.getDay()][slot === -1 ? 0 : slot]; } diff --git a/web-player/src/lib/server/userDirectory.ts b/web-player/src/lib/server/userDirectory.ts deleted file mode 100644 index 1b237d3..0000000 --- a/web-player/src/lib/server/userDirectory.ts +++ /dev/null @@ -1,24 +0,0 @@ -export interface DirectoryUser { - id: string; - name: string; - handle: string; -} - -const DIRECTORY: DirectoryUser[] = [ - { id: 'u1', name: 'Alex Rivera', handle: 'alexrivera' }, - { id: 'u2', name: 'Marina Cordero', handle: 'marinac' }, - { id: 'u3', name: 'Diego Fuentes', handle: 'dfuentes' }, - { id: 'u4', name: 'Lucía Marés', handle: 'luciamares' }, - { id: 'u5', name: 'Noah Bennett', handle: 'noahb' }, - { id: 'u6', name: 'Sara Ibáñez', handle: 'saraib' }, - { id: 'u7', name: 'Kairo', handle: 'kairo' }, - { id: 'u8', name: 'Valentina Ortiz', handle: 'valen' } -]; - -export function searchUsers(query: string, limit = 12): DirectoryUser[] { - const term = query.trim().toLowerCase(); - if (!term) return []; - return DIRECTORY.filter( - (user) => user.name.toLowerCase().includes(term) || user.handle.toLowerCase().includes(term) - ).slice(0, limit); -} diff --git a/web-player/src/lib/server/youtube.ts b/web-player/src/lib/server/youtube.ts index 503695f..ca9c902 100644 --- a/web-player/src/lib/server/youtube.ts +++ b/web-player/src/lib/server/youtube.ts @@ -1,7 +1,7 @@ import type { createApiClient } from '$lib/server/api'; import type { YouTubeAlbumResult, YouTubeSong } from '$lib/types'; -const YOUTUBE_FILLER_QUERIES = [ +export const YOUTUBE_FILLER_QUERIES = [ 'pop', 'rock', 'reggaeton', diff --git a/web-player/src/lib/theme/color.ts b/web-player/src/lib/theme/color.ts index 192b5c0..0186c23 100644 --- a/web-player/src/lib/theme/color.ts +++ b/web-player/src/lib/theme/color.ts @@ -1,5 +1,5 @@ -export const ACCENT_LIGHTNESS = 60; -export const ACCENT_CHROMA = 0.1; +export const ACCENT_LIGHTNESS = 74; +export const ACCENT_CHROMA = 0.145; export const ACCENT_HUE = 145; export const DEFAULT_ACCENT = `oklch(${ACCENT_LIGHTNESS}% ${ACCENT_CHROMA} ${ACCENT_HUE})`; diff --git a/web-player/src/lib/theme/mode.svelte.ts b/web-player/src/lib/theme/mode.svelte.ts new file mode 100644 index 0000000..fc76101 --- /dev/null +++ b/web-player/src/lib/theme/mode.svelte.ts @@ -0,0 +1,40 @@ +import { browser } from '$app/environment'; + +export type ThemeName = 'dark' | 'light'; + +const STORAGE_KEY = 'musify.theme'; + +function loadInitial(): ThemeName { + if (!browser) return 'dark'; + try { + return localStorage.getItem(STORAGE_KEY) === 'light' ? 'light' : 'dark'; + } catch { + return 'dark'; + } +} + +function applyTheme(theme: ThemeName) { + if (!browser) return; + document.documentElement.dataset.theme = theme; +} + +class ThemeModeStore { + current = $state(loadInitial()); + + constructor() { + applyTheme(this.current); + } + + toggle() { + 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 */ + } + } +} + +export const themeMode = new ThemeModeStore(); diff --git a/web-player/src/lib/theme/theme.css b/web-player/src/lib/theme/theme.css index 0201ff3..1c16796 100644 --- a/web-player/src/lib/theme/theme.css +++ b/web-player/src/lib/theme/theme.css @@ -1,39 +1,150 @@ :root { - --mf-bg: oklch(12.5% 0.004 95); - --mf-elevated: oklch(16% 0.005 95); + /* Dynamic tokens: overwritten at runtime from the playing track's hue. + These are the fallback values for the default accent (hue 145) so the + page never flashes unstyled before hydration. See theme/tokens.ts. */ + --mf-bg: oklch(0.068 0.008 145); + --mf-elevated: oklch(0.11 0.012 145); + --mf-sidebar-bg: linear-gradient(180deg, oklch(0.095 0.012 145), oklch(0.068 0.007 145)); + --mf-panel-bg: oklch(0.09 0.01 145 / 0.28); + --mf-bar-bg: oklch(0.105 0.012 145 / 0.9); + --mf-hairline: oklch(0.62 0.03 145 / 0.08); + --mf-ambient: + radial-gradient(52% 38% at 14% 0%, oklch(0.2611 0.0913 145 / 0.26), transparent 54%), + radial-gradient(46% 34% at 78% 0%, oklch(0.2347 0.085 145 / 0.2), transparent 56%); + --mf-hero-bg: linear-gradient( + 105deg, + oklch(0.2295 0.0838 145 / 0.34) 0%, + oklch(0.1558 0.0663 145 / 0.11) 37%, + oklch(0.1084 0.055 145 / 0.03) 65%, + rgba(6, 6, 9, 0) 92% + ); + --mf-spotlight-bg: linear-gradient( + 100deg, + oklch(0.2189 0.0813 145 / 0.28) 0%, + oklch(0.1453 0.0638 145 / 0.1) 40%, + oklch(0.1084 0.055 145 / 0.03) 69%, + rgba(6, 6, 9, 0) 96% + ); + --mf-modal-glow: linear-gradient( + 135deg, + oklch(0.2295 0.0838 145 / 0.34) 0%, + oklch(0.1558 0.0663 145 / 0.11) 28%, + oklch(0.1084 0.055 145 / 0.03) 50%, + rgba(6, 6, 9, 0) 70% + ); + --mf-logo-grad: linear-gradient(140deg, oklch(0.78 0.15 145), oklch(0.62 0.13 185) 92%); + --mf-logo-glow: 0 0 20px oklch(0.74 0.145 145 / 0.3); + --mf-cover-grad: linear-gradient(150deg, oklch(0.5 0.13 145), oklch(0.16 0.035 145)); - --mf-text: oklch(93% 0.004 95); - --mf-text-2: oklch(78% 0.006 95); - --mf-text-3: oklch(62% 0.007 95); - --mf-text-4: oklch(46% 0.006 95); - - --mf-surface: oklch(15.5% 0.005 95); - --mf-surface-2: rgba(255, 255, 255, 0.05); - --mf-surface-hover: rgba(255, 255, 255, 0.08); - - --mf-border: rgba(255, 255, 255, 0.07); - --mf-border-strong: rgba(255, 255, 255, 0.13); - --mf-track: rgba(255, 255, 255, 0.12); - - --mf-accent: oklch(60% 0.1 145); - --mf-accent-soft: color-mix(in oklch, var(--mf-accent) 80%, transparent); + --mf-accent: oklch(0.74 0.145 145); + --mf-accent-title: oklch(0.87 0.07 145); + --mf-accent-muted: oklch(0.79 0.035 145); + --mf-accent-soft-bg: oklch(0.74 0.145 145 / 0.08); + --mf-accent-line: oklch(0.74 0.145 145 / 0.2); + --mf-accent-hair: oklch(0.74 0.145 145 / 0.09); + --mf-accent-btn-bg: oklch(0.74 0.145 145 / 0.15); + --mf-accent-btn-bg-hover: oklch(0.74 0.145 145 / 0.24); --mf-accent-glow: color-mix(in oklch, var(--mf-accent) 45%, transparent); --mf-accent-dim: color-mix(in oklch, var(--mf-accent) 22%, transparent); + /* Fixed neutrals: deliberately hue-less, so only the accent carries color. */ + --mf-text: #d9d8dc; + --mf-text-2: #8a8a8e; + --mf-text-3: #7d7d81; + --mf-text-4: #646468; + + --mf-surface: rgba(255, 255, 255, 0.022); + --mf-surface-2: rgba(255, 255, 255, 0.045); + --mf-surface-hover: rgba(255, 255, 255, 0.06); + + --mf-border: rgba(255, 255, 255, 0.04); + --mf-border-strong: rgba(255, 255, 255, 0.09); + --mf-track: rgba(255, 255, 255, 0.09); + --mf-scrim: #000000; --mf-on-art: #fefdfb; + --mf-ink: #0a0a0d; + --mf-cta-strong-bg: #ffffff; --mf-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); - --mf-sidebar-w: 288px; - --mf-nav-h: 60px; - --mf-player-h: 64px; + --mf-sidebar-w: 254px; + --mf-queue-w: 304px; + --mf-nav-h: 66px; + --mf-player-h: 78px; --mf-safe-b: env(safe-area-inset-bottom, 0px); } -@media (width >= 48rem) { +/* PlayerBar switches from its compact row to the full layout at `sm` (640px). */ +@media (width >= 40rem) { :root { - --mf-nav-h: 0px; - --mf-player-h: 92px; + --mf-player-h: 100px; } } + +/* TabsBar (mobile/tablet nav) is replaced by the Sidebar at `lg` (1024px). */ +@media (width >= 64rem) { + :root { + --mf-nav-h: 0px; + } +} + +@media (width < 73.75rem) { + :root { + --mf-queue-w: 258px; + } +} + +/* "Modo blanco" — flips the neutral canvas/surface/text tokens to a light + palette. Components that reference these tokens re-theme correctly; + components hardcoding literal dark-mode hex colors do not. */ +:root[data-theme='light'] { + --mf-bg: oklch(0.97 0.006 90); + --mf-elevated: oklch(0.99 0.003 90); + --mf-sidebar-bg: linear-gradient(180deg, oklch(0.985 0.004 90), oklch(0.965 0.006 90)); + --mf-panel-bg: oklch(0.97 0.006 90 / 0.7); + --mf-bar-bg: oklch(0.97 0.006 90 / 0.9); + --mf-hairline: rgba(0, 0, 0, 0.08); + + --mf-text: #17171a; + --mf-text-2: #4a4a4e; + --mf-text-3: #6b6b6f; + --mf-text-4: #8c8c90; + + --mf-surface: rgba(0, 0, 0, 0.035); + --mf-surface-2: rgba(0, 0, 0, 0.06); + --mf-surface-hover: rgba(0, 0, 0, 0.08); + + --mf-border: rgba(0, 0, 0, 0.08); + --mf-border-strong: rgba(0, 0, 0, 0.14); + --mf-track: rgba(0, 0, 0, 0.12); + + --mf-scrim: #ffffff; + --mf-ink: #ffffff; + --mf-cta-strong-bg: #17171a; + + --mf-ambient: + radial-gradient(52% 38% at 14% 0%, oklch(0.9 0.06 145 / 0.26), transparent 54%), + radial-gradient(46% 34% at 78% 0%, oklch(0.9121 0.0497 145 / 0.2), transparent 56%); + --mf-hero-bg: linear-gradient( + 105deg, + oklch(0.9145 0.0476 145 / 0.34) 0%, + oklch(0.9483 0.0186 145 / 0.11) 37%, + oklch(0.97 0 145 / 0.03) 65%, + rgba(255, 255, 255, 0) 92% + ); + --mf-spotlight-bg: linear-gradient( + 100deg, + oklch(0.9193 0.0434 145 / 0.28) 0%, + oklch(0.9531 0.0145 145 / 0.1) 40%, + oklch(0.97 0 145 / 0.03) 69%, + rgba(255, 255, 255, 0) 96% + ); + --mf-modal-glow: linear-gradient( + 135deg, + oklch(0.9145 0.0476 145 / 0.34) 0%, + oklch(0.9483 0.0186 145 / 0.11) 28%, + oklch(0.97 0 145 / 0.03) 50%, + rgba(255, 255, 255, 0) 70% + ); +} diff --git a/web-player/src/lib/theme/tokens.ts b/web-player/src/lib/theme/tokens.ts new file mode 100644 index 0000000..d1c9386 --- /dev/null +++ b/web-player/src/lib/theme/tokens.ts @@ -0,0 +1,86 @@ +import { ACCENT_CHROMA, ACCENT_LIGHTNESS } from './color'; + +const L = ACCENT_LIGHTNESS / 100; +const C = ACCENT_CHROMA; + +function r(value: number, digits = 4): string { + return value.toFixed(digits); +} + +function tc(alpha: number, l: number, hue: number): string { + const lightness = 0.34 - l / 380; + const chroma = Math.max(0, 0.11 - l / 1600); + return `oklch(${r(lightness)} ${r(chroma)} ${r(hue, 1)} / ${alpha})`; +} + +// Light-mode counterpart of tc(): fades toward the light canvas (~0.97 +// lightness, 0 chroma at l=88) instead of toward black, so the "glow" +// gradients read as a soft tint instead of a dark smudge on a light bg. +function tcLight(alpha: number, l: number, hue: number): string { + const depth = (88 - l) / 58; + const lightness = 0.97 - 0.07 * depth; + const chroma = Math.max(0, 0.06 * depth); + return `oklch(${r(lightness)} ${r(chroma)} ${r(hue, 1)} / ${alpha})`; +} + +export interface ThemeTokens { + [cssVar: string]: string; +} + +/** + * Every color in the app derives from the hue of the track in playback. + * See design_handoff_musify_player/README.md § "Sistema de color dinámico". + * + * `mode` only affects the neutral canvas tokens (bg/elevated/panel/hairline): + * these are re-applied as inline styles on every hue change, so a light-mode + * override in theme.css alone can never win against them — the light values + * have to come from here instead. + */ +export function buildThemeTokens(hue: number, mode: 'dark' | 'light' = 'dark'): ThemeTokens { + const h = ((hue % 360) + 360) % 360; + const h2 = (h + 40) % 360; + const H = r(h, 1); + const H2 = r(h2, 1); + const light = mode === 'light'; + + return { + '--mf-accent': `oklch(${r(L)} ${r(C)} ${H})`, + '--mf-accent-title': `oklch(0.87 0.07 ${H})`, + '--mf-accent-muted': `oklch(0.79 0.035 ${H})`, + '--mf-accent-soft-bg': `oklch(${r(L)} ${r(C)} ${H} / 0.08)`, + '--mf-accent-line': `oklch(${r(L)} ${r(C)} ${H} / 0.2)`, + '--mf-accent-hair': `oklch(${r(L)} ${r(C)} ${H} / 0.09)`, + '--mf-accent-btn-bg': `oklch(${r(L)} ${r(C)} ${H} / 0.15)`, + '--mf-accent-btn-bg-hover': `oklch(${r(L)} ${r(C)} ${H} / 0.24)`, + '--mf-bg': light ? `oklch(0.97 0.006 ${H})` : `oklch(0.068 0.008 ${H})`, + '--mf-elevated': light ? `oklch(0.99 0.003 ${H})` : `oklch(0.11 0.012 ${H})`, + '--mf-sidebar-bg': light + ? `linear-gradient(180deg, oklch(0.985 0.004 ${H}), oklch(0.965 0.006 ${H}))` + : `linear-gradient(180deg, oklch(0.095 0.012 ${H}), oklch(0.068 0.007 ${H}))`, + '--mf-panel-bg': light ? `oklch(0.97 0.006 ${H} / 0.7)` : `oklch(0.09 0.01 ${H} / 0.28)`, + '--mf-bar-bg': light ? `oklch(0.97 0.006 ${H} / 0.9)` : `oklch(0.105 0.012 ${H} / 0.9)`, + '--mf-hairline': light ? `oklch(0.3 0.02 ${H} / 0.1)` : `oklch(0.62 0.03 ${H} / 0.08)`, + '--mf-ambient': light + ? `radial-gradient(52% 38% at 14% 0%, ${tcLight(0.26, 30, h)}, transparent 54%), radial-gradient(46% 34% at 78% 0%, ${tcLight(0.2, 40, h)}, transparent 56%)` + : `radial-gradient(52% 38% at 14% 0%, ${tc(0.26, 30, h)}, transparent 54%), radial-gradient(46% 34% at 78% 0%, ${tc(0.2, 40, h)}, transparent 56%)`, + '--mf-hero-bg': light + ? `linear-gradient(105deg, ${tcLight(0.34, 42, h)} 0%, ${tcLight(0.11, 70, h)} 37%, ${tcLight(0.03, 88, h)} 65%, rgba(255,255,255,0) 92%)` + : `linear-gradient(105deg, ${tc(0.34, 42, h)} 0%, ${tc(0.11, 70, h)} 37%, ${tc(0.03, 88, h)} 65%, rgba(6,6,9,0) 92%)`, + '--mf-modal-glow': light + ? `linear-gradient(135deg, ${tcLight(0.34, 42, h)} 0%, ${tcLight(0.11, 70, h)} 28%, ${tcLight(0.03, 88, h)} 50%, rgba(255,255,255,0) 70%)` + : `linear-gradient(135deg, ${tc(0.34, 42, h)} 0%, ${tc(0.11, 70, h)} 28%, ${tc(0.03, 88, h)} 50%, rgba(6,6,9,0) 70%)`, + '--mf-spotlight-bg': light + ? `linear-gradient(100deg, ${tcLight(0.28, 46, h)} 0%, ${tcLight(0.1, 74, h)} 40%, ${tcLight(0.03, 88, h)} 69%, rgba(255,255,255,0) 96%)` + : `linear-gradient(100deg, ${tc(0.28, 46, h)} 0%, ${tc(0.1, 74, h)} 40%, ${tc(0.03, 88, h)} 69%, rgba(6,6,9,0) 96%)`, + '--mf-logo-grad': `linear-gradient(140deg, oklch(0.78 0.15 ${H}), oklch(0.62 0.13 ${H2}) 92%)`, + '--mf-logo-glow': `0 0 20px oklch(${r(L)} ${r(C)} ${H} / 0.3)`, + '--mf-cover-grad': `linear-gradient(150deg, oklch(0.5 0.13 ${H}), oklch(0.16 0.035 ${H}))` + }; +} + +export function applyThemeTokens( + tokens: ThemeTokens, + target: HTMLElement = document.documentElement +) { + for (const [key, value] of Object.entries(tokens)) target.style.setProperty(key, value); +} diff --git a/web-player/src/lib/tracks.ts b/web-player/src/lib/tracks.ts index 54ad1c0..72794b2 100644 --- a/web-player/src/lib/tracks.ts +++ b/web-player/src/lib/tracks.ts @@ -22,6 +22,10 @@ export function targetArtist(target: TrackTarget): string | undefined { return (target.kind === 'youtube' ? target.song.artist : target.track.artist) ?? undefined; } +export function targetOwnerUserId(target: TrackTarget): string | number | undefined { + return target.kind === 'youtube' ? undefined : (target.track.ownerUserId ?? undefined); +} + export function targetExplicit(target: TrackTarget): boolean { return target.kind === 'youtube' ? target.song.isExplicit : (target.track.isExplicit ?? false); } @@ -76,7 +80,8 @@ export function targetForQueueItem(item: QueueItem): TrackTarget { title: item.title, artist: item.artist, source: 'Local', - isExplicit: item.explicit + isExplicit: item.explicit, + ownerUserId: item.ownerUserId } }; } diff --git a/web-player/src/routes/(app)/albums/+page.svelte b/web-player/src/routes/(app)/albums/+page.svelte index 459ae55..345b7bc 100644 --- a/web-player/src/routes/(app)/albums/+page.svelte +++ b/web-player/src/routes/(app)/albums/+page.svelte @@ -35,7 +35,7 @@ {#if items.length > 0} - + {#each items as album, i (album.id)} {#snippet actions()} - + {/snippet} {/if} 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 34a4558..f87940b 100644 --- a/web-player/src/routes/(app)/albums/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/albums/[id]/+page.server.ts @@ -42,7 +42,7 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent (track) => track.source === 'Local' && !inAlbum.has(track.id) ); - const isOwner = user !== null && Number(album.ownerUserId) === Number(user.sub); + const isOwner = user !== null && String(album.ownerUserId) === user.sub; return { album, tracks, library, isOwner, section: isOwner ? '/albums' : null }; }; diff --git a/web-player/src/routes/(app)/albums/[id]/components/AlbumHeader.svelte b/web-player/src/routes/(app)/albums/[id]/components/AlbumHeader.svelte index 103194b..3f9b4a8 100644 --- a/web-player/src/routes/(app)/albums/[id]/components/AlbumHeader.svelte +++ b/web-player/src/routes/(app)/albums/[id]/components/AlbumHeader.svelte @@ -48,17 +48,17 @@ label={isOwner ? 'Volver a tus álbumes' : 'Volver al inicio'} /> -
+
-

Álbum

+

Álbum

{title}

@@ -72,10 +72,10 @@
diff --git a/web-player/src/routes/(app)/albums/[id]/components/AlbumTrackTable.svelte b/web-player/src/routes/(app)/albums/[id]/components/AlbumTrackTable.svelte index 3c6d4fe..4e1a479 100644 --- a/web-player/src/routes/(app)/albums/[id]/components/AlbumTrackTable.svelte +++ b/web-player/src/routes/(app)/albums/[id]/components/AlbumTrackTable.svelte @@ -31,18 +31,16 @@ {#each tracks as track, i (track.id)} - - playFrom(i)} - /> + {@const active = player.current.id === queueIdForTrack(track)} + + playFrom(i)} /> playFrom(i)} /> {fmtTime(Number(track.duration))} diff --git a/web-player/src/routes/(app)/albums/[id]/components/LibraryPicker.svelte b/web-player/src/routes/(app)/albums/[id]/components/LibraryPicker.svelte index 9fd8a30..be8cdaa 100644 --- a/web-player/src/routes/(app)/albums/[id]/components/LibraryPicker.svelte +++ b/web-player/src/routes/(app)/albums/[id]/components/LibraryPicker.svelte @@ -28,12 +28,15 @@ {#each library as track, i (track.id)} - + {@const active = player.current.id === track.id} + playFrom(i)} > {#snippet overlay()} diff --git a/web-player/src/routes/(app)/albums/external/[source]/[externalId]/+page.svelte b/web-player/src/routes/(app)/albums/external/[source]/[externalId]/+page.svelte index 5fde7f0..2f9892c 100644 --- a/web-player/src/routes/(app)/albums/external/[source]/[externalId]/+page.svelte +++ b/web-player/src/routes/(app)/albums/external/[source]/[externalId]/+page.svelte @@ -79,21 +79,21 @@ -
+
(albumMenu = albumContextMenuStateFor(e, 'youtube', album.albumId))} />
-

+

{album.isSingle ? 'Single' : album.isEp ? 'EP' : 'Álbum'}

{album.title}

@@ -113,10 +113,10 @@
@@ -132,19 +132,19 @@ {#each tracks as track, i (track.videoId)} - (contextMenu = contextMenuStateFor(e, targetFor(i)))}> - playFrom(i)} - /> + {@const active = player.current.id === track.videoId} + (contextMenu = contextMenuStateFor(e, targetFor(i)))} + > + playFrom(i)} /> playFrom(i)} /> {fmtTime(Number(track.durationSeconds))} diff --git a/web-player/src/routes/(app)/explore/+page.server.ts b/web-player/src/routes/(app)/explore/+page.server.ts index 368114a..3556497 100644 --- a/web-player/src/routes/(app)/explore/+page.server.ts +++ b/web-player/src/routes/(app)/explore/+page.server.ts @@ -1,6 +1,6 @@ import type { PageServerLoad, Actions } from './$types'; import { createApiClient, unwrapOrError } from '$lib/server/api'; -import { fetchYoutubeFiller } from '$lib/server/youtube'; +import { YOUTUBE_FILLER_QUERIES } from '$lib/server/youtube'; import { addAlbumToPlaylistAction, addTrackAction, @@ -10,9 +10,8 @@ import { import { EXPLORE_ALBUMS_PAGE_SIZE, EXPLORE_PAGE_SIZE as PAGE_SIZE, - YOUTUBE_FILLER_LIMIT + EXPLORE_USERS_LIMIT } from '$lib/config'; -import { searchUsers } from '$lib/server/userDirectory'; export const load: PageServerLoad = async ({ url, locals, fetch }) => { const query = url.searchParams.get('q')?.trim() ?? ''; @@ -39,10 +38,17 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => { }) : Promise.resolve(null); - const [tracksRes, playlistsRes, albumsRes] = await Promise.all([ + const usersPromise = query + ? api.GET('/users', { + params: { query: { usernameSearch: query, pageNumber: 1, pageSize: EXPLORE_USERS_LIMIT } } + }) + : Promise.resolve(null); + + const [tracksRes, playlistsRes, albumsRes, usersRes] = await Promise.all([ tracksPromise, playlistsPromise, - albumsPromise + albumsPromise, + usersPromise ]); const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.'); @@ -54,10 +60,8 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => { tracks, albums: albumItems.flatMap((item) => (item.album ? [item.album] : [])), youtubeAlbums: albumItems.flatMap((item) => (item.youTubeAlbum ? [item.youTubeAlbum] : [])), - users: searchUsers(query), - youtubeFiller: query - ? Promise.resolve(null) - : fetchYoutubeFiller(api, locals.accessToken, YOUTUBE_FILLER_LIMIT), + users: usersRes?.data?.items ?? [], + genres: YOUTUBE_FILLER_QUERIES, playlists: playlistsRes?.data?.items ?? [] }; }; diff --git a/web-player/src/routes/(app)/explore/+page.svelte b/web-player/src/routes/(app)/explore/+page.svelte index bf82183..78e98da 100644 --- a/web-player/src/routes/(app)/explore/+page.svelte +++ b/web-player/src/routes/(app)/explore/+page.svelte @@ -1,8 +1,5 @@ - Buscar - + {data.query ? `Buscar «${data.query}»` : 'Descubrir'} + - - -
- -
+ {:else if !nothingFound} + + {/if} {#if form?.message} {form.message} {/if} {#if nothingFound} - +
+

+ Sin resultados para «{data.query}» +

+

+ Revisa la ortografía o prueba con un término más corto. También puedes buscar por álbum o + por playlist. +

+
+ {#each genreTiles.slice(0, 4) as genre (genre.query)} + + {genre.label} + + {/each} +
+
+ {/if} + + {#if !data.query} + +
+ {#each genreTiles as genre, i (genre.query)} + +
+ {genre.label} +
+
{genre.tagline}
+
+ {/each} +
{/if} {#if hasAlbums} -
+
+ {#each albumEntries as entry, i (entry.kind === 'local' ? entry.album.id : entry.album.albumId)}
  • {#if entry.kind === 'local'} - openAlbumMenu(e, 'local', entry.album.id)} - > -
    - -
    -
    - {entry.album.title} -
    -
    - {localAlbumCaption(entry.album)} -
    -
    + openAlbumMenu(e, 'local', entry.album.id)} + /> {:else} - openAlbumMenu(e, 'youtube', entry.album.albumId)} - > -
    - -
    -
    - {entry.album.title} -
    -
    - {youtubeAlbumCaption(entry.album)} -
    -
    + title={entry.album.title} + subtitle={entry.album.artist} + releaseYear={entry.album.releaseYear === null + ? undefined + : Number(entry.album.releaseYear)} + coverSrc={entry.album.thumbnailUrl} + index={i} + onContextMenu={(e) => openAlbumMenu(e, 'youtube', entry.album.albumId)} + /> {/if}
  • {/each} @@ -313,71 +310,78 @@ {/if} {#if hasUsers} -
    +
    {#each users as u, i (u.id)} -
    - -
    @{u.handle}
    -
    + + {/each}
    {/if} -
    - {#if songRows.length > 0} -
      - {#each songRows as { target: item, seconds }, i (targetId(item))} -
    • - -
    • - {/each} -
    + {fmtTime(seconds)} + + + {/each} + - {#if hasMore} - + {#if hasMore} + + {/if} + {:else if !ytUnavailable && !nothingFound} + {/if} - {:else if data.query && !ytUnavailable} - - {/if} -
    +
    + {/if} {#if ytUnavailable} {#each items as track, i (track.id)} - + {@const active = player.current.id === track.id} + togglePlay(i)} /> - togglePlay(i)} /> + togglePlay(i)} + /> + {fmtTime(Number(track.duration))} diff --git a/web-player/src/routes/(app)/liked/+page.server.ts b/web-player/src/routes/(app)/liked/+page.server.ts new file mode 100644 index 0000000..7624660 --- /dev/null +++ b/web-player/src/routes/(app)/liked/+page.server.ts @@ -0,0 +1,7 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = ({ locals }) => { + if (!locals.user) redirect(302, '/explore'); + return {}; +}; diff --git a/web-player/src/routes/(app)/liked/+page.svelte b/web-player/src/routes/(app)/liked/+page.svelte new file mode 100644 index 0000000..4c85f5a --- /dev/null +++ b/web-player/src/routes/(app)/liked/+page.svelte @@ -0,0 +1,136 @@ + + + + Me gusta + + + + +
    +
    + +
    +
    +

    Colección

    +

    + Me gusta +

    +

    + {tracks.length} + {tracks.length === 1 ? 'canción' : 'canciones'} +

    +
    +
    + +
    + +
    + + {#if tracks.length > 0} + + {#each tracks as track, i (track.id)} + {@const active = player.current.id === track.id} + + playFrom(i)} + /> + playFrom(i)} + /> + + + — + liked.toggle(track)} + > + + + + {/each} + + {:else} + + {#snippet actions()} + + {/snippet} + + {/if} +
    diff --git a/web-player/src/routes/(app)/mixes/[id]/+page.svelte b/web-player/src/routes/(app)/mixes/[id]/+page.svelte index 968113e..c09c0a2 100644 --- a/web-player/src/routes/(app)/mixes/[id]/+page.svelte +++ b/web-player/src/routes/(app)/mixes/[id]/+page.svelte @@ -67,12 +67,15 @@ -
    - +
    +
    -

    Mezcla

    +

    Mezcla

    {mix.title}

    @@ -89,10 +92,10 @@
    @@ -108,19 +111,16 @@ {#each items as item, i (mixItemKey(item))} - (contextMenu = contextMenuStateFor(e, targets[i]))}> - playFrom(i)} - /> + {@const active = isTargetCurrent(targets[i])} + (contextMenu = contextMenuStateFor(e, targets[i]))}> + playFrom(i)} /> playFrom(i)} /> {fmtTime(Number(item.durationSeconds))} diff --git a/web-player/src/routes/(app)/playlists/+page.server.ts b/web-player/src/routes/(app)/playlists/+page.server.ts index 8f05fcd..5349245 100644 --- a/web-player/src/routes/(app)/playlists/+page.server.ts +++ b/web-player/src/routes/(app)/playlists/+page.server.ts @@ -19,7 +19,30 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => { const data = unwrapOrError(result, 'No se pudieron cargar tus playlists.'); - return { playlists: data }; + const tracksByPlaylist = await Promise.all( + data.items.map((playlist) => + api + .GET('/playlists/{playlistId}/tracks', { + params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 500 } } + }) + .then((res) => res.data?.items ?? []) + ) + ); + + const items = data.items.map((playlist, i) => { + const tracks = tracksByPlaylist[i]; + const trackCount = tracks.length; + const durationSeconds = tracks.reduce((sum, t) => sum + Number(t.duration || 0), 0); + return { ...playlist, trackCount, durationSeconds }; + }); + + const totals = { + playlistCount: items.length, + trackCount: items.reduce((sum, p) => sum + p.trackCount, 0), + durationSeconds: items.reduce((sum, p) => sum + p.durationSeconds, 0) + }; + + return { playlists: { ...data, items }, totals }; }; export const actions: Actions = { @@ -30,6 +53,7 @@ export const actions: Actions = { 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'); if (name === '' || name.length > MAX_NAME) { @@ -55,7 +79,7 @@ export const actions: Actions = { } const { data, error: err } = await api.POST('/playlists', { - body: { name, description, pictureIntentId } + body: { name, description, pictureIntentId, visibility } }); if (err || !data) diff --git a/web-player/src/routes/(app)/playlists/+page.svelte b/web-player/src/routes/(app)/playlists/+page.svelte index 7d45190..6307557 100644 --- a/web-player/src/routes/(app)/playlists/+page.svelte +++ b/web-player/src/routes/(app)/playlists/+page.svelte @@ -1,43 +1,21 @@ @@ -46,24 +24,24 @@ - + 0 ? summary : 'Crea y organiza tus colecciones.'} + > {#snippet actions()} {#if items.length > 0} - + {/if} {/snippet} {#if items.length > 0} - + {#each items as playlist, i (playlist.id)} {#snippet actions()} - + {/snippet} {/if} - - (creating = false)} title="Nueva lista"> - (creating = false)} - /> - 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 05f71ba..fd32656 100644 --- a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts @@ -8,75 +8,28 @@ import { unwrapOrFail } from '$lib/server/api'; import { uploadPresignedImage } from '$lib/server/upload'; -import { fetchYoutubeFiller } from '$lib/server/youtube'; -import { shuffle } from '$lib/collections'; -import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions'; -import type { YouTubeSong } from '$lib/types'; const MAX_NAME = 100; -const SUGGESTIONS_LIMIT = 20; export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => { const { allowAnonymousListening } = await parent(); - const user = optionalUser(locals, url, allowAnonymousListening); + optionalUser(locals, url, allowAnonymousListening); const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); - const [playlistRes, tracksRes, libraryRes, playlistsRes] = await Promise.all([ + 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 } } - }), - user - ? api.GET('/tracks/users/{userId}', { - params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } } - }) - : Promise.resolve(null), - user - ? api.GET('/playlists/users/{userId}', { - params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } } - }) - : Promise.resolve(null) + }) ]); const playlist = unwrapOrError(playlistRes, 'Playlist no encontrada.', 404); - const tracks = tracksRes.data?.items ?? []; - const inPlaylist = new Set(tracks.map((t) => t.id)); - const library = (libraryRes?.data?.items ?? []).filter((t) => !inPlaylist.has(t.id)); - const alreadyLinked = new Set( - tracks.flatMap((t) => (t.source === 'YouTube' && t.externalId ? [t.externalId] : [])) - ); - const seeds = [...new Set(tracks.map((t) => t.artist).filter((a): a is string => !!a))]; - const seed = seeds.length > 0 ? shuffle(seeds)[0] : undefined; - - const youtube = fetchYoutubeFiller( - api, - locals.accessToken, - SUGGESTIONS_LIMIT + alreadyLinked.size, - seed - ) - .then((filler) => ({ - query: filler.query, - items: (filler.items as YouTubeSong[]) - .filter((song) => !alreadyLinked.has(song.videoId)) - .slice(0, SUGGESTIONS_LIMIT) - })) - .catch(() => ({ query: '', items: [] as YouTubeSong[] })); - - return { - playlist, - tracks, - library, - youtube, - playlists: playlistsRes?.data?.items ?? [] - }; + return { playlist, tracks }; }; export const actions: Actions = { - addTrack: addTrackAction, - addYouTubeToPlaylist: addYouTubeToPlaylistAction, - removeTrack: async ({ request, params, locals, fetch }) => { const accessToken = requireAccessTokenAction(locals); if (typeof accessToken !== 'string') return accessToken; @@ -98,6 +51,7 @@ export const actions: Actions = { 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'); if (name === '' || name.length > MAX_NAME) { return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).' }); @@ -119,7 +73,7 @@ export const actions: Actions = { const result = await api.PUT('/playlists/{playlistId}', { params: { path: { playlistId: params.id } }, - body: { newName: name, newDescription: description, newPictureIntentId } + body: { newName: name, newDescription: description, newPictureIntentId, newVisibility } }); const failure = unwrapOrFail(result, 'No se pudo actualizar la playlist.'); if (failure) return failure; diff --git a/web-player/src/routes/(app)/playlists/[id]/+page.svelte b/web-player/src/routes/(app)/playlists/[id]/+page.svelte index e11684c..92e6479 100644 --- a/web-player/src/routes/(app)/playlists/[id]/+page.svelte +++ b/web-player/src/routes/(app)/playlists/[id]/+page.svelte @@ -1,30 +1,21 @@ - - -
    +
    -

    Lista

    +

    Lista

    {name}

    {#if description}

    {description}

    {/if} -

    +

    {trackIds.length} - {trackIds.length === 1 ? 'canción' : 'canciones'} + {trackIds.length === 1 ? 'canción' : 'canciones'} · {visibility === 'Public' + ? 'Pública' + : 'Privada'}

    @@ -53,19 +63,15 @@
    diff --git a/web-player/src/routes/(app)/playlists/[id]/components/PlaylistTrackTable.svelte b/web-player/src/routes/(app)/playlists/[id]/components/PlaylistTrackTable.svelte index 68b20ee..f62d852 100644 --- a/web-player/src/routes/(app)/playlists/[id]/components/PlaylistTrackTable.svelte +++ b/web-player/src/routes/(app)/playlists/[id]/components/PlaylistTrackTable.svelte @@ -31,23 +31,29 @@ } - + {#each tracks as track, i (track.id)} - - playFrom(i)} - /> + {@const active = player.current.id === queueIdForTrack(track)} + + playFrom(i)} {active} /> playFrom(i)} > {#snippet badge()} @@ -58,6 +64,7 @@ {/if} {/snippet} + {fmtTime(Number(track.duration))} - import { enhance } from '$app/forms'; - import Plus from '@lucide/svelte/icons/plus'; - import { - player, - queueIdForTrack, - type ApiTrackLike, - type QueueItem - } from '$lib/player/player.svelte'; - import { fmtTime } from '$lib/format'; - import type { YouTubeSong } from '$lib/types'; - import SectionHeading from '$lib/components/ui/SectionHeading.svelte'; - import IconButton from '$lib/components/ui/IconButton.svelte'; - import NowPlaying from '$lib/components/ui/NowPlaying.svelte'; - import TrackTable from '$lib/components/ui/TrackTable.svelte'; - import TrackRow from '$lib/components/ui/TrackRow.svelte'; - import TrackMeta from '$lib/components/ui/TrackMeta.svelte'; - import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte'; - import TrackContextMenu, { - contextMenuStateFor, - type ContextMenuState - } from '$lib/components/TrackContextMenu.svelte'; - - type LibraryTrack = ApiTrackLike & { duration: number | string }; - type Suggestion = { kind: 'local'; track: LibraryTrack } | { kind: 'youtube'; song: YouTubeSong }; - - interface Props { - library: LibraryTrack[]; - youtube: YouTubeSong[]; - playlists: { id: string; name: string }[]; - } - - let { library, youtube, playlists }: Props = $props(); - - let contextMenu = $state(null); - - function openContextMenu(event: MouseEvent, item: Suggestion) { - contextMenu = contextMenuStateFor(event, item); - } - - function closeContextMenu() { - contextMenu = null; - } - - function addToQueue() { - if (!contextMenu) return; - player.addToQueue(queueItemFor(contextMenu as Suggestion)); - closeContextMenu(); - } - - const suggestions = $derived([ - ...library.map((track) => ({ kind: 'local' as const, track })), - ...youtube.map((song) => ({ kind: 'youtube' as const, song })) - ]); - - function idFor(item: Suggestion) { - return item.kind === 'youtube' ? item.song.videoId : queueIdForTrack(item.track); - } - - function queueItemFor(item: Suggestion): QueueItem { - return item.kind === 'youtube' - ? { - id: item.song.videoId, - title: item.song.title, - artist: item.song.artist, - source: 'youtube', - coverUrl: item.song.thumbnailUrl, - explicit: item.song.isExplicit - } - : { - id: queueIdForTrack(item.track), - title: item.track.title, - artist: item.track.artist ?? undefined, - source: 'local', - coverUrl: `/api/tracks/${item.track.id}/cover?size=small` - }; - } - - function playFrom(index: number) { - player.playOrToggle(suggestions.map(queueItemFor), index); - } - - -
    - - - {#each suggestions as item, i (idFor(item))} - openContextMenu(e, item)}> - playFrom(i)} - > - {#snippet overlay()} - {#if player.current.id === idFor(item)} - - {/if} - {/snippet} - - - {fmtTime( - item.kind === 'youtube' ? item.song.durationSeconds : Number(item.track.duration) - )} - - {#if item.kind === 'youtube'} - - async ({ update }) => - update()} - > - - - - - - - - - - {:else} -
    - async ({ update }) => - update()} - > - - - - -
    - {/if} -
    - {/each} -
    -
    - -{#if contextMenu} - -{/if} diff --git a/web-player/src/routes/(app)/u/[id]/+page.server.ts b/web-player/src/routes/(app)/u/[id]/+page.server.ts new file mode 100644 index 0000000..eff5b2c --- /dev/null +++ b/web-player/src/routes/(app)/u/[id]/+page.server.ts @@ -0,0 +1,75 @@ +import type { PageServerLoad, Actions } from './$types'; +import { + createApiClient, + optionalUser, + requireAccessTokenAction, + unwrapOrError +} from '$lib/server/api'; + +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 [profileRes, playlistsRes] = await Promise.all([ + api.GET('/users/{id}/profile', { params: { path: { id: params.id } } }), + api.GET('/playlists/users/{userId}', { + params: { + path: { userId: params.id }, + query: { pageNumber: 1, pageSize: PROFILE_PLAYLISTS_PAGE_SIZE, onlyPublic: true } + } + }) + ]); + + 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: 1 } } + }) + .then((res) => Number(res.data?.totalItemCount ?? 0)) + ) + ); + + const playlists = playlistItems.map((playlist, i) => ({ + ...playlist, + trackCount: tracksByPlaylist[i] + })); + + const isOwnProfile = viewer !== null && viewer.sub === params.id; + + const isFollowing = + viewer && !isOwnProfile + ? await api + .GET('/users/{id}/is-following', { params: { path: { id: params.id } } }) + .then((res) => res.data ?? false) + .catch(() => false) + : false; + + return { profile, playlists, isOwnProfile, isFollowing, isAnonymous: viewer === null }; +}; + +export const actions: Actions = { + follow: async ({ params, locals, fetch }) => { + const accessToken = requireAccessTokenAction(locals); + if (typeof accessToken !== 'string') return accessToken; + + const api = createApiClient({ fetch, accessToken }); + await api.POST('/users/{id}/follow', { params: { path: { id: params.id } } }); + return { following: true }; + }, + + unfollow: async ({ params, locals, fetch }) => { + const accessToken = requireAccessTokenAction(locals); + if (typeof accessToken !== 'string') return accessToken; + + const api = createApiClient({ fetch, accessToken }); + await api.DELETE('/users/{id}/follow', { params: { path: { id: params.id } } }); + return { following: false }; + } +}; diff --git a/web-player/src/routes/(app)/u/[id]/+page.svelte b/web-player/src/routes/(app)/u/[id]/+page.svelte new file mode 100644 index 0000000..c79a869 --- /dev/null +++ b/web-player/src/routes/(app)/u/[id]/+page.svelte @@ -0,0 +1,100 @@ + + + + {profile.name} + + + + +
    + +
    +

    Perfil

    +

    + {profile.name} +

    +

    + {profile.followersCount} + {profile.followersCount === 1 ? 'seguidor' : 'seguidores'} · {profile.followingCount} siguiendo +

    +
    +
    + + {#if !data.isOwnProfile && !data.isAnonymous} +
    +
    { + submitting = true; + return async ({ update }) => { + await update(); + submitting = false; + }; + }} + > + +
    +
    + {/if} + +
    +

    + Playlists públicas +

    + + {#if playlists.length > 0} + + {#each playlists as playlist, i (playlist.id)} + + {/each} + + {:else} + + {/if} +
    +
    diff --git a/web-player/src/routes/(app)/upload/+page.svelte b/web-player/src/routes/(app)/upload/+page.svelte index 6968d26..887faa4 100644 --- a/web-player/src/routes/(app)/upload/+page.svelte +++ b/web-player/src/routes/(app)/upload/+page.svelte @@ -30,9 +30,9 @@ let audioInput = $state(); const steps = [ - { key: 'upload', label: 'Subir', hint: 'Portada y audio al almacenamiento' }, - { key: 'process', label: 'Procesar', hint: 'Transcodificado para streaming' }, - { key: 'publish', label: 'Publicar', hint: 'Disponible en el catálogo' } + { key: 'upload', label: 'Subir', hint: 'Portada y audio.' }, + { key: 'process', label: 'Procesar', hint: 'Transcodificado para streaming.' }, + { key: 'publish', label: 'Publicar', hint: 'Disponible en el catálogo.' } ]; const busy = $derived(status === 'uploading'); @@ -47,14 +47,8 @@ return ['idle', 'idle', 'idle']; }); - const dropClass = $derived( - dragging || audioName !== '' ? 'border-accent/60' : 'border-line hover:border-accent/60' - ); - function stepClass(s: StepState) { - if (s === 'active') return 'border-accent/40 bg-accent/5'; - if (s === 'done') return 'border-accent/40'; - return ''; + return s === 'idle' ? '' : 'bg-accent-tint'; } function formatSize(bytes: number) { @@ -112,37 +106,39 @@ subtitle="Añade una canción con su portada y título. Nosotros la procesamos para streaming." /> -
      +
        {#each steps as step, i (step.key)}
      1. - +
        {#if stepStates[i] === 'done'} - + {:else} {i + 1} {/if} - {step.label} + {step.label}
        -

        {step.hint}

        +

        {step.hint}

      2. {/each}
      {#if status === 'done'} - - - + + + -

      ¡Subida!

      -

      +

      ¡Subida!

      +

      «{publishedTitle}» se está procesando. Aparecerá lista para reproducir en tu biblioteca en unos momentos.

      @@ -184,7 +180,9 @@ }} ondragleave={() => (dragging = false)} ondrop={onDrop} - class="relative flex min-h-56 flex-col items-center justify-center rounded-panel border border-dashed bg-surface p-6 text-center transition sm:min-h-72 sm:p-10 {dropClass}" + class="relative flex min-h-48 flex-col items-center justify-center rounded-panel p-6 text-center transition sm:min-h-64 sm:p-10 {dragging + ? 'bg-surface-2 ring-2 ring-accent/50' + : 'bg-surface'}" > - - {#if audioName === ''} - - {:else} - - {/if} - {#if audioName === ''} -

      Arrastra tu audio aquí o haz clic para elegir

      -

      MP3, FLAC, WAV…

      + {:else} -

      + + {/if} + {#if audioName === ''} +

      + Arrastra tu audio aquí o haz clic para elegir +

      +

      MP3, FLAC, WAV…

      + {:else} +

      {audioName}

      -

      {formatSize(audioSize)} · Listo para subir

      +

      {formatSize(audioSize)} · Listo para subir

      {/if}
    @@ -263,12 +260,12 @@ {#if status === 'uploading'} - -
    + +
    Subiendo y creando la pista… - un momento + un momento
    -
    +
    diff --git a/web-player/src/routes/+error.svelte b/web-player/src/routes/+error.svelte new file mode 100644 index 0000000..311b477 --- /dev/null +++ b/web-player/src/routes/+error.svelte @@ -0,0 +1,16 @@ + + +
    +

    + {page.status === 404 ? 'Página no encontrada' : 'No se pudo cargar tu música'} +

    +

    + {page.status === 404 + ? 'Comprueba el enlace o vuelve al inicio.' + : 'La conexión se interrumpió. Lo que tengas descargado seguirá disponible sin conexión.'} +

    + +
    diff --git a/web-player/src/routes/+layout.server.ts b/web-player/src/routes/+layout.server.ts index cbabb50..c2f9222 100644 --- a/web-player/src/routes/+layout.server.ts +++ b/web-player/src/routes/+layout.server.ts @@ -1,8 +1,44 @@ import { redirect } from '@sveltejs/kit'; 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'; const PUBLIC_PATHS = new Set(['/login']); +const SIDEBAR_PLAYLISTS_LIMIT = 8; + +async function fetchSidebarPlaylists( + 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: SIDEBAR_PLAYLISTS_LIMIT } + } + }); + return data?.items ?? []; + } catch { + // The sidebar shell must render even when the API is unreachable. + return []; + } +} + +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 ?? []; + } catch { + return []; + } +} export const load: LayoutServerLoad = async ({ locals, url, fetch }) => { const allowAnonymousListening = await getAllowAnonymousListening(fetch); @@ -10,5 +46,31 @@ export const load: LayoutServerLoad = async ({ locals, url, fetch }) => { if (!locals.user && !allowAnonymousListening && !PUBLIC_PATHS.has(url.pathname)) { redirect(302, `/login?returnTo=${encodeURIComponent(url.pathname + url.search)}`); } - return { user: locals.user, allowAnonymousListening }; + + const accountUrl = authConfig.issuer + ? `${authConfig.issuer.replace(/\/+$/, '')}/ui/console` + : null; + + if (!locals.user) { + return { + user: locals.user, + allowAnonymousListening, + sidebarPlaylists: [], + likedTracks: [], + accountUrl + }; + } + + const [sidebarPlaylists, likedTracks] = await Promise.all([ + fetchSidebarPlaylists(fetch, locals.accessToken, locals.user.sub), + fetchLikedTracks(fetch, locals.accessToken) + ]); + + return { + user: locals.user, + allowAnonymousListening, + sidebarPlaylists, + likedTracks, + accountUrl + }; }; diff --git a/web-player/src/routes/+layout.svelte b/web-player/src/routes/+layout.svelte index 266169b..2b1139f 100644 --- a/web-player/src/routes/+layout.svelte +++ b/web-player/src/routes/+layout.svelte @@ -1,16 +1,23 @@ @@ -227,399 +143,353 @@ -{#snippet railArrows(rail: ReturnType | undefined)} -
    - - -
    -{/snippet} - -
    -
    +
    +
    -
    -
    - -
    -
    -

    - {data.greeting} -

    -

    - Tu música,
    sin límites. -

    -

    - Sube lo tuyo, descubre lo nuevo y escúchalo todo en un solo sitio. -

    -
    - - {#if heroTrack} - {/if} +
    - - {#if heroPlaying} - - {:else} - - {/if} - - - {/if} +
    + + {#await data.listeningStats then stats} +
    +
    +
    + {stats.tracksThisWeek} +
    +
    Canciones nuevas escuchadas esta semana
    +
    +
    +
    + {fmtDurationLong(Number(stats.secondsThisWeek))} +
    +
    Tiempo de escucha esta semana
    +
    +
    +
    + {stats.streakDays} +
    +
    Días consecutivos escuchando
    +
    +
    + {/await} +
    -
    - {#if topMusicTargets.length > 0} -
    - - {#snippet actions()} - {@render railArrows(topMusicRail)} - {/snippet} - - - {#each topMusicTargets as target, i (targetId(target))} - + {/snippet} + + {/if} +
    + + + {#if spotlight} +
    +
    +
    +
    + +
    +

    + Playlist destacada +

    +

    + {spotlight.name} +

    +

    {spotlightMeta}

    +
    +
    +

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

    +
    + + +
    +
    +
    + {#each spotlightItems as target, i (targetId(target))} + {@const durationSeconds = targetDurationSeconds(target)} +
    playSpotlightTrack(i)} + onkeydown={(e) => { + if (e.key !== 'Enter' && e.key !== ' ') return; + e.preventDefault(); + playSpotlightTrack(i); + }} + class="flex items-center gap-3 rounded-[10px] p-2 text-left transition-colors hover:bg-white/[3%] {isTargetCurrent( + target + ) + ? 'bg-accent-tint' + : ''}" + > + {i + 1} +
    +
    + {targetTitle(target)} +
    + +
    + + {durationSeconds !== undefined ? fmtTime(durationSeconds) : '—'}
    - {#if targetArtist(target)} -
    {targetArtist(target)}
    - {/if} - - {/each} - + {:else} +

    Esta playlist todavía no tiene canciones.

    + {/each} +
    +
    {/if} -
    -
    - -
    + +
    + + {#if mixes.length > 0} +
    + {#each mixes as mix, i (mix.id)} + + {/each} +
    + {:else} + + {/if} +
    + + +
    + + {#if popularTargets.length > 0} +
    {#each popularTargets as target, i (targetId(target))} {@const isCurrent = isTargetCurrent(target)}
    playPopular(i)} + onkeydown={(e) => { + if (e.key !== 'Enter' && e.key !== ' ') return; + e.preventDefault(); + playPopular(i); + }} + oncontextmenu={(e) => openContextMenu(e, target)} + class="flex items-center gap-3.5 rounded-[11px] p-2 text-left transition-colors {isCurrent + ? 'bg-accent-tint' + : 'hover:bg-white/[3%]'}" > - - - + +
    {/each}
    -
    - -
    - + {:else} + {#snippet actions()} - {@render railArrows(recommendedRail)} + {/snippet} - - - {#each recommendedAlbums as entry, i (entry.kind === 'recent' ? entry.album.id : entry.album.albumId)} - {#if entry.kind === 'recent'} - {@const album = entry.album} - - openAlbumMenu(e, album.source === 'YouTube' ? 'youtube' : 'local', album.id)} - > - {#if album.source === 'YouTube' && album.thumbnailUrl} - - {:else} - - {/if} -
    - {album.title} -
    -
    {albumCaption(album)}
    -
    - {:else} - {@const album = entry.album} - openAlbumMenu(e, 'youtube', album.albumId)} - > - -
    - {album.title} -
    -
    {youtubeAlbumCaption(album)}
    -
    - {/if} - {/each} -
    -
    -
    + + {/if} + - {#if mixes.length > 0} -
    - -
    - {#each mixes as mix, i (mix.id)} - - {/each} -
    -
    - {/if} - - {#if playlists.length > 0} -
    - - + +
    + + {#snippet actions()} + {#if data.playlistsHasMore} + + Ver todas + + {/if} + {/snippet} + + {#if playlists.length > 0} +
    - {/if} - - {#if recTargets.length > 0} -
    - -
      - {#each recTargets as target, i (targetId(target))} -
    • - -
    • - {/each} -
    -
    - {/if} +
    + {:else} + + {#snippet actions()} + + {/snippet} + + {/if} +
    {#if contextMenu} @@ -630,13 +500,3 @@ onAddToQueue={addToQueue} /> {/if} - -{#if albumMenu} - (albumMenu = null)} - onPlayNext={albumPlayNext} - onAddToQueue={albumAddToQueue} - /> -{/if} diff --git a/web-player/src/routes/api/likes/+server.ts b/web-player/src/routes/api/likes/+server.ts new file mode 100644 index 0000000..6faba85 --- /dev/null +++ b/web-player/src/routes/api/likes/+server.ts @@ -0,0 +1,16 @@ +import type { RequestHandler } from './$types'; +import { json } from '@sveltejs/kit'; +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 api = createApiClient({ fetch, accessToken: locals.accessToken }); + const result = await api.POST('/likes/toggle', { body }); + + if (result.error || result.data === undefined) + return json({ message: 'No se pudo actualizar Me gusta.' }, { status: 502 }); + + return json({ liked: result.data }); +}; diff --git a/web-player/src/routes/layout.css b/web-player/src/routes/layout.css index 292033d..bc87a01 100644 --- a/web-player/src/routes/layout.css +++ b/web-player/src/routes/layout.css @@ -1,27 +1,36 @@ @import 'tailwindcss'; @theme { - --font-sans: 'Manrope Variable', ui-sans-serif, system-ui, sans-serif; - --font-display: 'Bricolage Grotesque Variable', ui-sans-serif, system-ui, sans-serif; + --font-sans: 'Plus Jakarta Sans Variable', ui-sans-serif, system-ui, sans-serif; + --font-display: 'Sora Variable', ui-sans-serif, system-ui, sans-serif; --color-bg: var(--mf-bg); --color-elevated: var(--mf-elevated); --color-surface: var(--mf-surface); --color-surface-2: var(--mf-surface-2); --color-surface-hover: var(--mf-surface-hover); - --color-hover: rgba(255, 255, 255, 0.05); + --color-hover: rgba(255, 255, 255, 0.045); --color-line: var(--mf-border); --color-line-strong: var(--mf-border-strong); + --color-hairline: var(--mf-hairline); --color-track: var(--mf-track); --color-fg: var(--mf-text); --color-fg-2: var(--mf-text-2); --color-fg-3: var(--mf-text-3); --color-muted: var(--mf-text-4); --color-accent: var(--mf-accent); - --color-accent-soft: var(--mf-accent-soft); + --color-accent-soft: var(--mf-accent-title); + --color-accent-muted: var(--mf-accent-muted); + --color-accent-tint: var(--mf-accent-soft-bg); + --color-accent-line: var(--mf-accent-line); + --color-accent-hair: var(--mf-accent-hair); + --color-accent-btn: var(--mf-accent-btn-bg); + --color-accent-btn-hover: var(--mf-accent-btn-bg-hover); --color-accent-glow: var(--mf-accent-glow); --color-accent-dim: var(--mf-accent-dim); --color-on-accent: var(--mf-on-art); + --color-ink: var(--mf-ink); + --color-cta-strong: var(--mf-cta-strong-bg); --color-scrim: var(--mf-scrim); --color-on-art: var(--mf-on-art); --color-danger: oklch(63.7% 0.15 25.331); @@ -29,13 +38,15 @@ --radius-control: 0.625rem; --radius-panel: 1.125rem; - --radius-art: 0.85rem; + --radius-panel-lg: 1.375rem; + --radius-art: 0.8125rem; --radius-art-lg: 1.125rem; --radius-art-round: 1.75rem; - --shadow-art: 0 12px 28px -10px rgba(0, 0, 0, 0.6); + --shadow-art: 0 14px 32px rgba(0, 0, 0, 0.45); --shadow-art-lg: 0 24px 50px -16px rgba(0, 0, 0, 0.75); --shadow-menu: 0 12px 28px -10px rgba(0, 0, 0, 0.6); + --shadow-player: 0 20px 56px rgba(0, 0, 0, 0.6); --shadow-glow: 0 0 0 1px var(--mf-border-strong), 0 20px 45px -20px var(--mf-accent-glow); } @@ -165,6 +176,14 @@ button:disabled { animation: fade-in 0.4s ease both; } +.glass-panel { + background-color: var(--mf-panel-bg); + background-image: var(--mf-modal-glow); + backdrop-filter: blur(22px); + -webkit-backdrop-filter: blur(22px); + transition: background 0.6s; +} + @media (prefers-reduced-motion: reduce) { *, *::before, diff --git a/web-player/src/routes/login/+page.svelte b/web-player/src/routes/login/+page.svelte index 7a0376f..18f2d44 100644 --- a/web-player/src/routes/login/+page.svelte +++ b/web-player/src/routes/login/+page.svelte @@ -12,14 +12,15 @@
    -
    +
    -
    - Musify +
    + + + Musify +
    @@ -39,7 +40,7 @@ - - {#if menuOpen} - - (menuOpen = false)} - /> - (menuOpen = false)} - /> - (menuOpen = false)} - /> - {#if accountUrl} - (menuOpen = false)} - /> - {/if} - themeMode.toggle()} - /> -
    - - -
    +
    + {#if user} +
    -
    + - + {#if menuOpen} + + (menuOpen = false)} + /> + (menuOpen = false)} + /> + (menuOpen = false)} + /> + {#if accountUrl} + (menuOpen = false)} + /> + {/if} + themeMode.toggle()} + /> +
    + + +
    + {/if} + {:else} + + + + {/if} +
    diff --git a/web-player/src/lib/components/SearchBox.svelte b/web-player/src/lib/components/SearchBox.svelte deleted file mode 100644 index 551566e..0000000 --- a/web-player/src/lib/components/SearchBox.svelte +++ /dev/null @@ -1,86 +0,0 @@ - - -
    -
    - -
    -
    diff --git a/web-player/src/lib/components/TopBar.svelte b/web-player/src/lib/components/TopBar.svelte index ed66c21..c59632e 100644 --- a/web-player/src/lib/components/TopBar.svelte +++ b/web-player/src/lib/components/TopBar.svelte @@ -1,5 +1,10 @@ + + + +{#snippet content()} + {@render art(artClass)} +
    +
    + {#if explicit} + + {/if} + {title} +
    + {#if subtitle} +
    {subtitle}
    + {/if} +
    + {#if meta} + {meta} + {/if} +{/snippet} + +{#if href} + + {@render content()} + +{:else} + +{/if} diff --git a/web-player/src/lib/components/ui/SectionHeading.svelte b/web-player/src/lib/components/ui/SectionHeading.svelte index d573cf8..d161890 100644 --- a/web-player/src/lib/components/ui/SectionHeading.svelte +++ b/web-player/src/lib/components/ui/SectionHeading.svelte @@ -12,7 +12,7 @@ {#snippet heading()} -

    +

    {title}

    {#if subtitle} diff --git a/web-player/src/routes/(app)/explore/+page.svelte b/web-player/src/routes/(app)/explore/+page.svelte index 78e98da..ab08ff2 100644 --- a/web-player/src/routes/(app)/explore/+page.svelte +++ b/web-player/src/routes/(app)/explore/+page.svelte @@ -1,20 +1,18 @@ + +
    + {@render trigger({ toggle, open })} + + {#if open} + + + {@render extraItems?.({ close })} + {#if accountUrl} + + {/if} + themeMode.toggle()} + /> +
    + + +
    + {/if} +
    + + diff --git a/web-player/src/lib/components/AlbumContextMenu.svelte b/web-player/src/lib/components/AlbumContextMenu.svelte index b29dc4b..1249bd4 100644 --- a/web-player/src/lib/components/AlbumContextMenu.svelte +++ b/web-player/src/lib/components/AlbumContextMenu.svelte @@ -60,7 +60,7 @@
    {:else} -

    No tienes playlists todavía.

    +

    Todavía no tienes playlists.

    {/if} diff --git a/web-player/src/lib/components/MobileHeader.svelte b/web-player/src/lib/components/MobileHeader.svelte index 6ec654b..e3e6b64 100644 --- a/web-player/src/lib/components/MobileHeader.svelte +++ b/web-player/src/lib/components/MobileHeader.svelte @@ -2,15 +2,9 @@ import type { SessionUser } from '$lib/types'; import Upload from '@lucide/svelte/icons/upload'; import Folder from '@lucide/svelte/icons/folder'; - import LogOut from '@lucide/svelte/icons/log-out'; import LogIn from '@lucide/svelte/icons/log-in'; - import User from '@lucide/svelte/icons/user'; - import Settings from '@lucide/svelte/icons/settings'; - import Sun from '@lucide/svelte/icons/sun'; - import Moon from '@lucide/svelte/icons/moon'; import MenuItem from './ui/MenuItem.svelte'; - import GlassMenu from './ui/GlassMenu.svelte'; - import { themeMode } from '$lib/theme/mode.svelte'; + import AccountMenu from './AccountMenu.svelte'; interface Props { user: SessionUser | null; @@ -18,13 +12,6 @@ } let { user, accountUrl }: Props = $props(); - - let menuOpen = $state(false); - let menuRef: HTMLDivElement | undefined = $state(); - - function onDocumentClick(event: MouseEvent) { - if (menuRef && !menuRef.contains(event.target as Node)) menuOpen = false; - }
    -
    - - diff --git a/web-player/src/lib/components/Sidebar.svelte b/web-player/src/lib/components/Sidebar.svelte index 15cb9c4..ed415dc 100644 --- a/web-player/src/lib/components/Sidebar.svelte +++ b/web-player/src/lib/components/Sidebar.svelte @@ -52,7 +52,7 @@ style="width:var(--mf-sidebar-w)" > - Musify + Musify {#if user} -
    +
    - Tus playlistsTus playlists {#if playlists.length > 0} @@ -87,7 +74,7 @@
    -
    +
    {#if user} - - {#if menuOpen} - - (menuOpen = false)} - /> - {#if accountUrl} - (menuOpen = false)} - /> - {/if} - themeMode.toggle()} - /> - - - - - {/if} + {#if user.picture} + + {:else} + + {user.name.charAt(0)} + + {/if} + + {/snippet} + {:else} - - diff --git a/web-player/src/lib/components/TrackContextMenu.svelte b/web-player/src/lib/components/TrackContextMenu.svelte index 43b4ad5..2e569a9 100644 --- a/web-player/src/lib/components/TrackContextMenu.svelte +++ b/web-player/src/lib/components/TrackContextMenu.svelte @@ -53,7 +53,7 @@
    {#if player.current.id} -

    +

    Reproduciendo

    @@ -45,13 +45,13 @@ class="h-10 w-10 shrink-0 rounded-[9px]" />
    -
    +
    {player.current.title}
    @@ -59,7 +59,7 @@
    A continuación @@ -67,7 +67,7 @@ @@ -96,15 +96,15 @@ class="h-9 w-9 shrink-0 rounded-lg opacity-90" />
    -
    {track.title}
    +
    {track.title}
    {#if track.duration} - {fmtTime(track.duration)} {/if} diff --git a/web-player/src/lib/components/player/SeekBar.svelte b/web-player/src/lib/components/player/SeekBar.svelte index ce8eb0b..5f36f89 100644 --- a/web-player/src/lib/components/player/SeekBar.svelte +++ b/web-player/src/lib/components/player/SeekBar.svelte @@ -28,7 +28,7 @@ {:else}
    - + {fmtTime(player.progress)}
    - + {fmtTime(player.current.duration)}
    diff --git a/web-player/src/lib/components/player/TrackInfo.svelte b/web-player/src/lib/components/player/TrackInfo.svelte index b360665..8d98a24 100644 --- a/web-player/src/lib/components/player/TrackInfo.svelte +++ b/web-player/src/lib/components/player/TrackInfo.svelte @@ -35,26 +35,24 @@ src={player.current.coverUrl} size="small" alt={player.current.title} - class="h-11 w-11 shrink-0 rounded-[11px] shadow-[0_8px_20px_rgba(0,0,0,0.5)] md:h-12 md:w-12" + class="h-11 w-11 shrink-0 rounded-[11px] shadow-cover-xs md:h-12 md:w-12" /> {:else} -
    +
    {/if}
    {#if player.current.explicit} {/if} - {player.current.title}
    {#if !compact} diff --git a/web-player/src/lib/components/ui/ArtistAvatar.svelte b/web-player/src/lib/components/ui/ArtistAvatar.svelte index a205e06..90f2c7d 100644 --- a/web-player/src/lib/components/ui/ArtistAvatar.svelte +++ b/web-player/src/lib/components/ui/ArtistAvatar.svelte @@ -27,9 +27,9 @@ {:else}
    - {initials} + {initials} {/if}
    {name}
    diff --git a/web-player/src/lib/components/ui/Button.svelte b/web-player/src/lib/components/ui/Button.svelte index ac627ed..f771fa2 100644 --- a/web-player/src/lib/components/ui/Button.svelte +++ b/web-player/src/lib/components/ui/Button.svelte @@ -29,12 +29,12 @@ }: Props = $props(); const sizes = { - sm: 'px-4 py-2.5 text-[12.5px]', - md: 'px-5 py-3 text-[13px]', + sm: 'px-4 py-2.5 text-xs', + md: 'px-5 py-3 text-sm', lg: 'px-6 py-3.5 text-sm' }; - const subtleButton = 'bg-white/[6%] text-fg-2 hover:bg-white/[9%] hover:text-fg'; + const subtleButton = 'bg-surface-2 text-fg-2 hover:bg-surface-hover hover:text-fg'; const variants = { primary: subtleButton, diff --git a/web-player/src/lib/components/ui/CollectionHeader.svelte b/web-player/src/lib/components/ui/CollectionHeader.svelte new file mode 100644 index 0000000..e040e84 --- /dev/null +++ b/web-player/src/lib/components/ui/CollectionHeader.svelte @@ -0,0 +1,43 @@ + + +
    + {@render cover()} +
    +

    {eyebrow}

    +

    + {title} +

    + {#if description} +

    {description}

    + {/if} + {#if meta} +

    {meta}

    + {/if} +
    +
    + +
    + {@render actions()} +
    diff --git a/web-player/src/lib/components/ui/EmptyState.svelte b/web-player/src/lib/components/ui/EmptyState.svelte index 7c9ccbd..8a38e5b 100644 --- a/web-player/src/lib/components/ui/EmptyState.svelte +++ b/web-player/src/lib/components/ui/EmptyState.svelte @@ -23,7 +23,7 @@ {/if} {#if description} -

    {description}

    +

    {description}

    {/if} {#if actions}
    {@render actions()}
    diff --git a/web-player/src/lib/components/ui/ExplicitBadge.svelte b/web-player/src/lib/components/ui/ExplicitBadge.svelte index 9b1ac4b..222f402 100644 --- a/web-player/src/lib/components/ui/ExplicitBadge.svelte +++ b/web-player/src/lib/components/ui/ExplicitBadge.svelte @@ -7,7 +7,7 @@ E diff --git a/web-player/src/lib/components/ui/ImageDropzone.svelte b/web-player/src/lib/components/ui/ImageDropzone.svelte index b7e8445..2673510 100644 --- a/web-player/src/lib/components/ui/ImageDropzone.svelte +++ b/web-player/src/lib/components/ui/ImageDropzone.svelte @@ -57,6 +57,6 @@ class="h-full w-full object-cover" /> {:else} - + {/if} diff --git a/web-player/src/lib/components/ui/MediaCard.svelte b/web-player/src/lib/components/ui/MediaCard.svelte index ea69595..14f022f 100644 --- a/web-player/src/lib/components/ui/MediaCard.svelte +++ b/web-player/src/lib/components/ui/MediaCard.svelte @@ -27,6 +27,6 @@ {@render art(mediaCardArt)}
    {title}
    {#if subtitle} -
    {subtitle}
    +
    {subtitle}
    {/if}
    diff --git a/web-player/src/lib/components/ui/MenuItem.svelte b/web-player/src/lib/components/ui/MenuItem.svelte index 91cfd6d..4325a9b 100644 --- a/web-player/src/lib/components/ui/MenuItem.svelte +++ b/web-player/src/lib/components/ui/MenuItem.svelte @@ -24,7 +24,7 @@ }: Props = $props(); const classes = $derived( - `flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg-2 transition hover:bg-hover ${klass}` + `flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg-2 transition hover:bg-hover ${klass}` ); diff --git a/web-player/src/lib/components/ui/MixTile.svelte b/web-player/src/lib/components/ui/MixTile.svelte index a1b7bb8..52d671c 100644 --- a/web-player/src/lib/components/ui/MixTile.svelte +++ b/web-player/src/lib/components/ui/MixTile.svelte @@ -37,15 +37,15 @@ type="button" onclick={play} aria-label="Reproducir {mix.title}" - class="absolute right-2.5 bottom-2.5 grid h-9.5 w-9.5 place-items-center rounded-full border border-white/12 bg-ink/76 text-fg opacity-0 backdrop-blur-[8px] transition-opacity duration-150 group-hover/tile:opacity-100" + class="absolute right-2.5 bottom-2.5 grid h-9.5 w-9.5 place-items-center rounded-full border border-on-art/12 bg-ink/76 text-fg opacity-0 backdrop-blur-[8px] transition-opacity duration-150 group-hover/tile:opacity-100" >
    -
    +
    {mix.title}
    {#if mix.subtitle} -
    {mix.subtitle}
    +
    {mix.subtitle}
    {/if} diff --git a/web-player/src/lib/components/ui/PageHeader.svelte b/web-player/src/lib/components/ui/PageHeader.svelte index dd9e70e..689df0a 100644 --- a/web-player/src/lib/components/ui/PageHeader.svelte +++ b/web-player/src/lib/components/ui/PageHeader.svelte @@ -13,11 +13,11 @@
    -

    +

    {title}

    {#if subtitle} -

    {subtitle}

    +

    {subtitle}

    {/if}
    {#if actions} diff --git a/web-player/src/lib/components/ui/SearchResultRow.svelte b/web-player/src/lib/components/ui/SearchResultRow.svelte index 22e97a4..6756f28 100644 --- a/web-player/src/lib/components/ui/SearchResultRow.svelte +++ b/web-player/src/lib/components/ui/SearchResultRow.svelte @@ -49,14 +49,14 @@ {#if explicit} {/if} - {title} + {title}
    {#if subtitle} -
    {subtitle}
    +
    {subtitle}
    {/if}
    {#if meta} - {meta} + {meta} {/if} {/snippet} diff --git a/web-player/src/lib/components/ui/SectionHeading.svelte b/web-player/src/lib/components/ui/SectionHeading.svelte index d161890..d3edae0 100644 --- a/web-player/src/lib/components/ui/SectionHeading.svelte +++ b/web-player/src/lib/components/ui/SectionHeading.svelte @@ -12,7 +12,7 @@ {#snippet heading()} -

    +

    {title}

    {#if subtitle} diff --git a/web-player/src/lib/components/ui/TrackMeta.svelte b/web-player/src/lib/components/ui/TrackMeta.svelte index 3cf10a2..f330e5d 100644 --- a/web-player/src/lib/components/ui/TrackMeta.svelte +++ b/web-player/src/lib/components/ui/TrackMeta.svelte @@ -9,6 +9,6 @@ let { class: klass = '', children }: Props = $props(); - + {@render children()} diff --git a/web-player/src/lib/components/ui/TrackRow.svelte b/web-player/src/lib/components/ui/TrackRow.svelte index aeee3da..444fa9d 100644 --- a/web-player/src/lib/components/ui/TrackRow.svelte +++ b/web-player/src/lib/components/ui/TrackRow.svelte @@ -19,7 +19,7 @@
    diff --git a/web-player/src/lib/components/ui/TrackTable.svelte b/web-player/src/lib/components/ui/TrackTable.svelte index d989d00..89e38ac 100644 --- a/web-player/src/lib/components/ui/TrackTable.svelte +++ b/web-player/src/lib/components/ui/TrackTable.svelte @@ -56,7 +56,7 @@
    {#if index} diff --git a/web-player/src/lib/components/ui/TrackTitleCell.svelte b/web-player/src/lib/components/ui/TrackTitleCell.svelte index 81dcd73..bbd525a 100644 --- a/web-player/src/lib/components/ui/TrackTitleCell.svelte +++ b/web-player/src/lib/components/ui/TrackTitleCell.svelte @@ -69,6 +69,6 @@ > {@render badge?.()}
    - +
    diff --git a/web-player/src/lib/theme/theme.css b/web-player/src/lib/theme/theme.css index 1c16796..c52730f 100644 --- a/web-player/src/lib/theme/theme.css +++ b/web-player/src/lib/theme/theme.css @@ -35,6 +35,7 @@ --mf-logo-grad: linear-gradient(140deg, oklch(0.78 0.15 145), oklch(0.62 0.13 185) 92%); --mf-logo-glow: 0 0 20px oklch(0.74 0.145 145 / 0.3); --mf-cover-grad: linear-gradient(150deg, oklch(0.5 0.13 145), oklch(0.16 0.035 145)); + --mf-liked-grad: linear-gradient(150deg, oklch(0.4 0.09 330), oklch(0.18 0.04 10)); --mf-accent: oklch(0.74 0.145 145); --mf-accent-title: oklch(0.87 0.07 145); diff --git a/web-player/src/routes/(app)/albums/[id]/components/AlbumHeader.svelte b/web-player/src/routes/(app)/albums/[id]/components/AlbumHeader.svelte index 3f9b4a8..bffdad3 100644 --- a/web-player/src/routes/(app)/albums/[id]/components/AlbumHeader.svelte +++ b/web-player/src/routes/(app)/albums/[id]/components/AlbumHeader.svelte @@ -6,6 +6,7 @@ import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte'; import BackLink from '$lib/components/ui/BackLink.svelte'; import Button from '$lib/components/ui/Button.svelte'; + import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte'; interface Props { title: string; @@ -48,45 +49,34 @@ label={isOwner ? 'Volver a tus álbumes' : 'Volver al inicio'} /> -
    - -
    -

    Álbum

    -

    - {title} -

    - {#if description} -

    {description}

    - {/if} -

    {meta}

    -
    -
    - -
    - - {#if isOwner} - - - {/if} -
    + {#if isOwner} + + + {/if} + {/snippet} + diff --git a/web-player/src/routes/(app)/albums/external/[source]/[externalId]/+page.svelte b/web-player/src/routes/(app)/albums/external/[source]/[externalId]/+page.svelte index 2f9892c..3916628 100644 --- a/web-player/src/routes/(app)/albums/external/[source]/[externalId]/+page.svelte +++ b/web-player/src/routes/(app)/albums/external/[source]/[externalId]/+page.svelte @@ -8,6 +8,7 @@ import Button from '$lib/components/ui/Button.svelte'; import Cover from '$lib/components/ui/Cover.svelte'; import Alert from '$lib/components/ui/Alert.svelte'; + import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte'; import TrackTable from '$lib/components/ui/TrackTable.svelte'; import TrackRow from '$lib/components/ui/TrackRow.svelte'; import TrackMeta from '$lib/components/ui/TrackMeta.svelte'; @@ -79,48 +80,40 @@ -
    - (albumMenu = albumContextMenuStateFor(e, 'youtube', album.albumId))} - /> -
    -

    - {album.isSingle ? 'Single' : album.isEp ? 'EP' : 'Álbum'} -

    -

    - {album.title} -

    -

    {album.artist}

    -

    - {[ - album.releaseYear ? String(album.releaseYear) : undefined, - `${tracks.length} ${tracks.length === 1 ? 'canción' : 'canciones'}`, - fmtTime(Number(data.album.totalDurationSeconds)) - ] - .filter((part) => !!part) - .join(' · ')} -

    -
    -
    - -
    - -
    + !!part) + .join(' · ')} + > + {#snippet cover()} + (albumMenu = albumContextMenuStateFor(e, 'youtube', album.albumId))} + /> + {/snippet} + {#snippet actions()} + + {/snippet} + {#if form?.message} {form.message} diff --git a/web-player/src/routes/(app)/explore/+page.svelte b/web-player/src/routes/(app)/explore/+page.svelte index ab08ff2..1a068fe 100644 --- a/web-player/src/routes/(app)/explore/+page.svelte +++ b/web-player/src/routes/(app)/explore/+page.svelte @@ -313,7 +313,7 @@ Resultados de búsqueda

    Resultados para «{data.query}»

    @@ -331,13 +331,11 @@ class="flex items-center gap-1.75 rounded-full px-3.5 py-1.75 text-xs font-medium transition {sfilter === chip.label ? 'bg-cta-strong text-ink' - : 'bg-white/[3.5%] text-fg-2 hover:bg-white/7 hover:text-fg'}" + : 'bg-surface-2 text-fg-2 hover:bg-surface-hover hover:text-fg'}" > {chip.label} - {chip.count}{chip.count} {/each} @@ -394,9 +392,7 @@ {/if}
    -
    +
    Mejor resultado · {TOP_RESULT_KIND_LABEL[topResult.kind]}
    Sin resultados para «{data.query}» -

    +

    Revisa la ortografía o prueba con un término más corto. También puedes buscar por álbum o por playlist.

    @@ -464,7 +460,7 @@ {#each genreTiles.slice(0, 4) as genre (genre.query)} {genre.label} @@ -481,12 +477,12 @@ href={buildHref(genre.query)} class="animate-enter group relative block h-28 overflow-hidden rounded-2xl p-4.25 transition-[filter] hover:brightness-125" style="animation-delay:{Math.min(i, 10) * - 40}ms; background:linear-gradient(140deg, oklch(0.24 0.07 {genre.hue}), rgba(9,9,12,.95))" + 40}ms; background:linear-gradient(140deg, oklch(0.24 0.07 {genre.hue}), var(--mf-ink))" > -
    +
    {genre.label}
    -
    {genre.tagline}
    +
    {genre.tagline}
    {/each}
    @@ -500,7 +496,7 @@ {#snippet actions()}
    - + {searchCounts.Canciones} {searchCounts.Canciones === 1 ? 'Resultado' : 'Resultados'} @@ -553,7 +549,7 @@ {#snippet actions()}
    - + {searchCounts.Álbumes} {searchCounts.Álbumes === 1 ? 'Resultado' : 'Resultados'} @@ -605,7 +601,7 @@ {#snippet actions()}
    - + {searchCounts.Usuarios} {searchCounts.Usuarios === 1 ? 'Resultado' : 'Resultados'} @@ -638,7 +634,7 @@ {#snippet actions()}
    - + {searchCounts.Playlists} {searchCounts.Playlists === 1 ? 'Resultado' : 'Resultados'} diff --git a/web-player/src/routes/(app)/liked/+page.svelte b/web-player/src/routes/(app)/liked/+page.svelte index 4c85f5a..dc9d6ad 100644 --- a/web-player/src/routes/(app)/liked/+page.svelte +++ b/web-player/src/routes/(app)/liked/+page.svelte @@ -7,6 +7,7 @@ import { liked } from '$lib/player/liked.svelte'; import { fmtDate } from '$lib/format'; import Page from '$lib/components/ui/Page.svelte'; + import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte'; import Button from '$lib/components/ui/Button.svelte'; import IconButton from '$lib/components/ui/IconButton.svelte'; import EmptyState from '$lib/components/ui/EmptyState.svelte'; @@ -46,38 +47,31 @@ -
    -
    - -
    -
    -

    Colección

    -

    + {#snippet cover()} +
    - Me gusta -

    -

    - {tracks.length} - {tracks.length === 1 ? 'canción' : 'canciones'} -

    -
    -
    - -
    - -
    + +
    + {/snippet} + {#snippet actions()} + + {/snippet} + {#if tracks.length > 0} -
    - -
    -

    Mezcla

    -

    - {mix.title} -

    - {#if mix.subtitle} -

    {mix.subtitle}

    - {/if} -

    - {items.length} - {items.length === 1 ? 'canción' : 'canciones'} · {fmtTime(totalSeconds)} -

    -
    -
    - -
    - - -
    + + {#snippet cover()} + + {/snippet} + {#snippet actions()} + + + {/snippet} + {#if form?.message} {form.message} diff --git a/web-player/src/routes/(app)/playlists/[id]/+page.svelte b/web-player/src/routes/(app)/playlists/[id]/+page.svelte index 92e6479..9ec5f94 100644 --- a/web-player/src/routes/(app)/playlists/[id]/+page.svelte +++ b/web-player/src/routes/(app)/playlists/[id]/+page.svelte @@ -82,7 +82,7 @@

    ¿Eliminar «{playlist.name}»?

    -

    +

    Esta acción no se puede deshacer, la playlist se eliminará de tu biblioteca y de todos los dispositivos donde la tengas guardada.

    diff --git a/web-player/src/routes/(app)/playlists/[id]/components/PlaylistHeader.svelte b/web-player/src/routes/(app)/playlists/[id]/components/PlaylistHeader.svelte index e9fc8a8..0f0f366 100644 --- a/web-player/src/routes/(app)/playlists/[id]/components/PlaylistHeader.svelte +++ b/web-player/src/routes/(app)/playlists/[id]/components/PlaylistHeader.svelte @@ -1,10 +1,7 @@ -
    - -
    -

    Lista

    -

    - {name} -

    - {#if description} -

    {description}

    - {/if} -

    - {trackIds.length} - {trackIds.length === 1 ? 'canción' : 'canciones'} · {visibility === 'Public' - ? 'Pública' - : 'Privada'} -

    -
    -
    - -
    - - - -
    + + {#snippet cover()} + + {/snippet} + {#snippet actions()} + + + + {/snippet} + diff --git a/web-player/src/routes/(app)/u/[id]/+page.svelte b/web-player/src/routes/(app)/u/[id]/+page.svelte index c79a869..b355f5b 100644 --- a/web-player/src/routes/(app)/u/[id]/+page.svelte +++ b/web-player/src/routes/(app)/u/[id]/+page.svelte @@ -32,13 +32,13 @@
    -

    Perfil

    +

    Perfil

    {profile.name}

    -

    +

    {profile.followersCount} {profile.followersCount === 1 ? 'seguidor' : 'seguidores'} · {profile.followingCount} siguiendo

    diff --git a/web-player/src/routes/(app)/upload/+page.svelte b/web-player/src/routes/(app)/upload/+page.svelte index 887faa4..e158b02 100644 --- a/web-player/src/routes/(app)/upload/+page.svelte +++ b/web-player/src/routes/(app)/upload/+page.svelte @@ -115,7 +115,7 @@ class="grid h-7 w-7 shrink-0 place-items-center rounded-full text-xs font-bold {stepStates[ i ] === 'idle' - ? 'bg-white/[3.5%] text-fg-2' + ? 'bg-surface-2 text-fg-2' : 'bg-accent-btn text-accent-soft'}" > {#if stepStates[i] === 'done'} @@ -228,7 +228,7 @@ {#if coverPreview !== ''} Portada {:else} - + {/if} diff --git a/web-player/src/routes/+error.svelte b/web-player/src/routes/+error.svelte index 311b477..b458b6e 100644 --- a/web-player/src/routes/+error.svelte +++ b/web-player/src/routes/+error.svelte @@ -7,7 +7,7 @@

    {page.status === 404 ? 'Página no encontrada' : 'No se pudo cargar tu música'}

    -

    +

    {page.status === 404 ? 'Comprueba el enlace o vuelve al inicio.' : 'La conexión se interrumpió. Lo que tengas descargado seguirá disponible sin conexión.'} diff --git a/web-player/src/routes/+page.svelte b/web-player/src/routes/+page.svelte index b84fc6f..89087ec 100644 --- a/web-player/src/routes/+page.svelte +++ b/web-player/src/routes/+page.svelte @@ -152,11 +152,11 @@ >

    {data.greeting}

    -

    +

    Sube lo tuyo, descubre lo nuevo y escúchalo todo en un solo sitio.

    @@ -177,22 +177,22 @@ {#await data.listeningStats then stats}
    -
    +
    {stats.tracksThisWeek}
    -
    Canciones nuevas escuchadas esta semana
    +
    Canciones nuevas escuchadas esta semana
    -
    +
    {fmtDurationLong(Number(stats.secondsThisWeek))}
    -
    Tiempo de escucha esta semana
    +
    Tiempo de escucha esta semana
    -
    +
    {stats.streakDays}
    -
    Días consecutivos escuchando
    +
    Días consecutivos escuchando
    {/await} @@ -220,7 +220,7 @@ oncontextmenu={(e) => openContextMenu(e, targetForQueueItem(item))} class="group/cont animate-enter flex items-center gap-3.25 rounded-[13px] p-2.25 pr-3.5 text-left transition-colors {isCurrent ? 'bg-accent-tint' - : 'hover:bg-white/[3%]'}" + : 'hover:bg-hover'}" style="animation-delay:{Math.min(i, 10) * 40}ms" >
    @@ -250,7 +250,7 @@ {:else} {#snippet actions()} @@ -273,18 +273,18 @@ trackIds={spotlight.coverTrackIds} version={spotlight.updatedAt} size="large" - class="h-28 w-28 shrink-0 rounded-[16px] shadow-[0_16px_40px_rgba(0,0,0,.5)]" + class="h-28 w-28 shrink-0 rounded-[16px] shadow-cover-md" />
    -

    +

    Playlist destacada

    {spotlight.name}

    -

    {spotlightMeta}

    +

    {spotlightMeta}

    @@ -312,18 +312,16 @@ e.preventDefault(); playSpotlightTrack(i); }} - class="flex items-center gap-3 rounded-[10px] p-2 text-left transition-colors hover:bg-white/[3%] {isTargetCurrent( + class="flex items-center gap-3 rounded-[10px] p-2 text-left transition-colors hover:bg-hover {isTargetCurrent( target ) ? 'bg-accent-tint' : ''}" > - {i + 1} + {i + 1}

    @@ -332,10 +330,10 @@
    - + {durationSeconds !== undefined ? fmtTime(durationSeconds) : '—'}
    @@ -359,7 +357,7 @@ {:else} {/if} @@ -387,7 +385,7 @@ oncontextmenu={(e) => openContextMenu(e, target)} class="flex items-center gap-3.5 rounded-[11px] p-2 text-left transition-colors {isCurrent ? 'bg-accent-tint' - : 'hover:bg-white/[3%]'}" + : 'hover:bg-hover'}" > {#if isCurrent} @@ -409,9 +407,7 @@ {/if} {targetTitle(target)} @@ -419,7 +415,7 @@
    @@ -428,7 +424,7 @@ {:else} {#snippet actions()} @@ -445,7 +441,7 @@ {#if data.playlistsHasMore} Ver todas @@ -457,7 +453,7 @@ {#each playlists as playlist, i (playlist.id)}
    -
    +
    {playlist.name}
    {#if playlist.description} -
    {playlist.description}
    +
    {playlist.description}
    {/if}
    diff --git a/web-player/src/routes/layout.css b/web-player/src/routes/layout.css index bc87a01..b74048d 100644 --- a/web-player/src/routes/layout.css +++ b/web-player/src/routes/layout.css @@ -9,7 +9,7 @@ --color-surface: var(--mf-surface); --color-surface-2: var(--mf-surface-2); --color-surface-hover: var(--mf-surface-hover); - --color-hover: rgba(255, 255, 255, 0.045); + --color-hover: var(--mf-surface-2); --color-line: var(--mf-border); --color-line-strong: var(--mf-border-strong); --color-hairline: var(--mf-hairline); @@ -48,6 +48,11 @@ --shadow-menu: 0 12px 28px -10px rgba(0, 0, 0, 0.6); --shadow-player: 0 20px 56px rgba(0, 0, 0, 0.6); --shadow-glow: 0 0 0 1px var(--mf-border-strong), 0 20px 45px -20px var(--mf-accent-glow); + + --shadow-cover-lg: 0 24px 56px rgba(0, 0, 0, 0.55); + --shadow-cover-md: 0 16px 40px rgba(0, 0, 0, 0.5); + --shadow-cover-sm: 0 10px 24px rgba(0, 0, 0, 0.42); + --shadow-cover-xs: 0 8px 20px rgba(0, 0, 0, 0.5); } @utility page-x { From 56966838597ff69fdad53d860360ad97729bf4cb Mon Sep 17 00:00:00 2001 From: XShine555 Date: Tue, 15 Sep 2026 18:06:37 +0200 Subject: [PATCH 193/217] Fix light-mode regression and finish design-system cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A code-review pass on the previous commit found a real bug: the explore genre tiles used var(--mf-ink) as a decorative gradient endpoint, but that token is meant for text-on-accent and flips to white in light mode, turning the tiles solid white. Replaces it with a new, deliberately theme-invariant --mf-tile-shade token (same category as --mf-cover-grad). Also: raises CollectionHeader's meta-line contrast in light mode (text-muted, ~3:1, to text-fg-2), makes its actions prop optional so pages without conditional actions don't render an empty wrapper, moves AccountMenu.svelte into $lib/components/ui/ per the repo's own componentization rule, adds the aria-expanded TopBar's trigger was missing relative to MobileHeader's, and migrates the profile page (u/[id]/+page.svelte) onto CollectionHeader — a sixth duplicate of the same header pattern that the previous commit touched but missed. --- .../docs/design-system-refactor-plan.md | 34 +++++--- .../src/lib/components/MobileHeader.svelte | 2 +- web-player/src/lib/components/TopBar.svelte | 5 +- .../components/{ => ui}/AccountMenu.svelte | 4 +- .../lib/components/ui/CollectionHeader.svelte | 12 +-- web-player/src/lib/theme/theme.css | 1 + .../src/routes/(app)/explore/+page.svelte | 2 +- .../src/routes/(app)/u/[id]/+page.svelte | 78 +++++++++---------- 8 files changed, 78 insertions(+), 60 deletions(-) rename web-player/src/lib/components/{ => ui}/AccountMenu.svelte (95%) diff --git a/web-player/docs/design-system-refactor-plan.md b/web-player/docs/design-system-refactor-plan.md index 6ada010..ce3d046 100644 --- a/web-player/docs/design-system-refactor-plan.md +++ b/web-player/docs/design-system-refactor-plan.md @@ -20,15 +20,31 @@ > sobre el "chrome" de la app o sobre arte/gradiente saturado, se extrajeron > `lib/components/ui/CollectionHeader.svelte` (portada + título + meta + acciones, usado > en `AlbumHeader`, `PlaylistHeader`, `liked/+page.svelte`, `mixes/[id]/+page.svelte`, -> `albums/external/.../+page.svelte`) y `lib/components/AccountMenu.svelte` (estado de -> apertura + panel del menú de cuenta, usado en `TopBar`/`MobileHeader`), y se corrigió -> `--color-hover` en `layout.css`, que estaba hardcodeado en vez de apuntar a -> `--mf-surface-2` (rompía el modo claro para cualquier `hover:bg-hover`). Pendiente de -> quien retome esto: verificación visual en el navegador con una sesión autenticada real -> (esta sesión no tenía credenciales del IdP de Zitadel ni acceso para activar -> `allowAnonymousListening`), y `lib/components/ui/Rail.svelte`, que el grep de -> huérfanos de §5 marca sin importadores — no se tocó porque no forma parte de este -> barrido, pero conviene confirmarlo a mano y borrarlo si procede. +> `albums/external/.../+page.svelte` y `u/[id]/+page.svelte`) y +> `lib/components/ui/AccountMenu.svelte` (estado de apertura + panel del menú de cuenta, +> usado en `TopBar`/`MobileHeader`), y se corrigió `--color-hover` en `layout.css`, que +> estaba hardcodeado en vez de apuntar a `--mf-surface-2` (rompía el modo claro para +> cualquier `hover:bg-hover`). +> +> **Repaso posterior a la verificación visual** (ya con sesión autenticada): se detectó +> que el fix de §2 anterior para la tarjeta de género de `explore/+page.svelte` estaba +> mal — usaba `var(--mf-ink)` como extremo oscuro del degradado, pero `--mf-ink` es el +> token de "texto sobre botón de acento" y se invierte a blanco en modo claro, rompiendo +> la tarjeta (fondo blanco en vez de degradado oscuro). Se creó un token nuevo, +> `--mf-tile-shade` (deliberadamente **sin** variante de modo claro, igual que +> `--mf-cover-grad`/`--mf-liked-grad`, porque es "arte" decorativo, no "chrome"), y se usa +> ahí en su lugar. También se cambió la línea de meta de `CollectionHeader` de +> `text-muted` a `text-fg-2` (mejor contraste en modo claro; `text-muted` rondaba ~3:1, +> por debajo de WCAG AA para texto normal) y se hizo `actions` opcional para poder migrar +> páginas sin acciones condicionales (como `u/[id]/+page.svelte`) sin renderizar un +> `
    ` vacío. +> +> Pendiente de quien retome esto: `lib/components/ui/Rail.svelte` no tiene importadores +> (confirmado con el mismo método de §5, no porque §5 lo listara — ese hallazgo es nuevo) +> — no se tocó porque no forma parte de este barrido, pero conviene confirmarlo a mano y +> borrarlo si procede. También queda pendiente repetir la verificación visual (§6) sobre +> los cambios de este repaso: tarjetas de género de Explorar, cabeceras de colección +> (contraste de la línea de meta) y `u/[id]/+page.svelte`. ## 0. Objetivo diff --git a/web-player/src/lib/components/MobileHeader.svelte b/web-player/src/lib/components/MobileHeader.svelte index e3e6b64..8f29005 100644 --- a/web-player/src/lib/components/MobileHeader.svelte +++ b/web-player/src/lib/components/MobileHeader.svelte @@ -4,7 +4,7 @@ import Folder from '@lucide/svelte/icons/folder'; import LogIn from '@lucide/svelte/icons/log-in'; import MenuItem from './ui/MenuItem.svelte'; - import AccountMenu from './AccountMenu.svelte'; + import AccountMenu from './ui/AccountMenu.svelte'; interface Props { user: SessionUser | null; diff --git a/web-player/src/lib/components/TopBar.svelte b/web-player/src/lib/components/TopBar.svelte index ec162db..ccf1ee5 100644 --- a/web-player/src/lib/components/TopBar.svelte +++ b/web-player/src/lib/components/TopBar.svelte @@ -7,7 +7,7 @@ import X from '@lucide/svelte/icons/x'; import ChevronLeft from '@lucide/svelte/icons/chevron-left'; import ChevronRight from '@lucide/svelte/icons/chevron-right'; - import AccountMenu from './AccountMenu.svelte'; + import AccountMenu from './ui/AccountMenu.svelte'; interface Props { user: SessionUser | null; @@ -107,11 +107,12 @@ {accountUrl} panelClass="absolute top-full right-0 z-20 mt-2 w-52 overflow-hidden" > - {#snippet trigger({ toggle })} + {#snippet trigger({ toggle, open })}
    -
    - {@render actions()} -
    +{#if actions} +
    + {@render actions()} +
    +{/if} diff --git a/web-player/src/lib/theme/theme.css b/web-player/src/lib/theme/theme.css index c52730f..a585420 100644 --- a/web-player/src/lib/theme/theme.css +++ b/web-player/src/lib/theme/theme.css @@ -36,6 +36,7 @@ --mf-logo-glow: 0 0 20px oklch(0.74 0.145 145 / 0.3); --mf-cover-grad: linear-gradient(150deg, oklch(0.5 0.13 145), oklch(0.16 0.035 145)); --mf-liked-grad: linear-gradient(150deg, oklch(0.4 0.09 330), oklch(0.18 0.04 10)); + --mf-tile-shade: rgba(9, 9, 12, 0.95); --mf-accent: oklch(0.74 0.145 145); --mf-accent-title: oklch(0.87 0.07 145); diff --git a/web-player/src/routes/(app)/explore/+page.svelte b/web-player/src/routes/(app)/explore/+page.svelte index 1a068fe..6c335ab 100644 --- a/web-player/src/routes/(app)/explore/+page.svelte +++ b/web-player/src/routes/(app)/explore/+page.svelte @@ -477,7 +477,7 @@ href={buildHref(genre.query)} class="animate-enter group relative block h-28 overflow-hidden rounded-2xl p-4.25 transition-[filter] hover:brightness-125" style="animation-delay:{Math.min(i, 10) * - 40}ms; background:linear-gradient(140deg, oklch(0.24 0.07 {genre.hue}), var(--mf-ink))" + 40}ms; background:linear-gradient(140deg, oklch(0.24 0.07 {genre.hue}), var(--mf-tile-shade))" >
    {genre.label} diff --git a/web-player/src/routes/(app)/u/[id]/+page.svelte b/web-player/src/routes/(app)/u/[id]/+page.svelte index b355f5b..373de0c 100644 --- a/web-player/src/routes/(app)/u/[id]/+page.svelte +++ b/web-player/src/routes/(app)/u/[id]/+page.svelte @@ -8,11 +8,13 @@ import EmptyState from '$lib/components/ui/EmptyState.svelte'; import Button from '$lib/components/ui/Button.svelte'; import ArtistAvatar from '$lib/components/ui/ArtistAvatar.svelte'; + import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte'; let { data, form } = $props(); const profile = $derived(data.profile); const playlists = $derived(data.playlists); + const canFollow = $derived(!data.isOwnProfile && !data.isAnonymous); let following = $state(untrack(() => data.isFollowing)); let submitting = $state(false); @@ -28,47 +30,43 @@ - -
    - -
    -

    Perfil

    -

    - {profile.name} -

    -

    - {profile.followersCount} - {profile.followersCount === 1 ? 'seguidor' : 'seguidores'} · {profile.followingCount} siguiendo -

    -
    -
    +{#snippet followAction()} +
    { + submitting = true; + return async ({ update }) => { + await update(); + submitting = false; + }; + }} + > + +
    +{/snippet} - {#if !data.isOwnProfile && !data.isAnonymous} -
    -
    { - submitting = true; - return async ({ update }) => { - await update(); - submitting = false; - }; - }} - > - -
    -
    - {/if} + + + {#snippet cover()} + + {/snippet} +

    From 31032bd8e876e1a970f9b3baa1c05c0421db44f5 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Tue, 15 Sep 2026 21:17:03 +0200 Subject: [PATCH 194/217] Remove YouTube integration from backend and deployment config Drops the YouTube-backed second catalog source entirely: the Local/External split on Track/Album collapses into single concrete entities, Artist/TrackArtist/ExternalTrack/ExternalAlbum and the YouTube service/download pipeline (yt-dlp, MassTransit consumer, routing slip) are deleted, and mixes now source discovery only from local listening history. Includes the RemoveYouTubeIntegration EF Core migration and matching Worker/deploy/compose cleanup (no more yt-dlp/python/deno in the Worker image, no thumbnail-cache/YtDlp config sections). --- README.md | 8 +- .../Albums/AddTrackToAlbum.cs | 13 +- .../Musify.Application/Albums/CreateAlbum.cs | 4 +- .../Musify.Application/Albums/DeleteAlbum.cs | 4 +- .../Musify.Application/Albums/GetAlbumById.cs | 2 +- .../Albums/GetAlbumTracks.cs | 5 +- .../Musify.Application/Albums/GetAlbums.cs | 65 +- .../Albums/GetAlbumsByUserId.cs | 2 +- .../Albums/RemoveTrackFromAlbum.cs | 2 +- .../Responses/AlbumApplicationResponse.cs | 24 +- .../Responses/AlbumSearchItemResponse.cs | 19 +- .../Albums/Responses/AlbumsSearchResponse.cs | 30 +- .../ExternalAlbumApplicationResponse.cs | 47 - .../Musify.Application/Albums/UpdateAlbum.cs | 4 +- .../ApplicationDependencyInjection.cs | 1 - .../Configuration/MixConfiguration.cs | 9 - .../Musify.Application/Contracts/IDatabase.cs | 12 - .../Contracts/IYouTubeDownloaderService.cs | 9 - .../Contracts/IYouTubeMusicService.cs | 64 - .../Events/DownloadYouTubeTrackEvent.cs | 14 - .../Likes/GetLikedTracks.cs | 5 +- .../Likes/ToggleTrackLike.cs | 51 +- .../Mixes/GenerateMixesForUser.cs | 276 +--- .../Musify.Application/Mixes/GetMixById.cs | 5 +- .../Mixes/GetMixesByUserId.cs | 26 +- .../Musify.Application/Mixes/MixItemMapper.cs | 36 + .../Mixes/Responses/MixApplicationResponse.cs | 13 - .../Responses/MixItemApplicationResponse.cs | 23 +- .../PlayLists/AddYouTubeTrackToPlayList.cs | 149 --- .../PlayLists/GetPlayListTracks.cs | 5 +- .../Services/YouTubeTrackProvisioner.cs | 265 ---- .../Musify.Application/Tracks/CreateTrack.cs | 4 +- .../Musify.Application/Tracks/DeleteTrack.cs | 2 +- .../Tracks/GetListeningHistory.cs | 5 +- .../Musify.Application/Tracks/GetTrackById.cs | 5 +- .../Musify.Application/Tracks/GetTracks.cs | 70 +- .../Tracks/GetTracksByUserId.cs | 5 +- .../ExternalTrackApplicationResponse.cs | 30 - .../LocalTrackApplicationResponse.cs | 32 - .../Responses/TrackApplicationResponse.cs | 48 +- .../Responses/TrackSearchItemResponse.cs | 19 +- .../Tracks/Responses/TracksSearchResponse.cs | 30 +- .../YouTube/GetYouTubeAlbum.cs | 36 - .../YouTube/ResolveYouTubeTrackStream.cs | 85 -- .../Responses/YouTubeStreamResponse.cs | 15 - backend/Core/Musify.Domain/Entities/Album.cs | 9 +- backend/Core/Musify.Domain/Entities/Artist.cs | 32 - .../Musify.Domain/Entities/ExternalAlbum.cs | 18 - .../Musify.Domain/Entities/ExternalTrack.cs | 17 - .../Core/Musify.Domain/Entities/LocalTrack.cs | 14 - .../Core/Musify.Domain/Entities/MixItem.cs | 26 +- backend/Core/Musify.Domain/Entities/Track.cs | 9 +- .../Musify.Domain/Entities/TrackArtist.cs | 25 - .../Core/Musify.Domain/Entities/UserAlbum.cs | 15 - .../ValueObjects/MixItemSource.cs | 8 - .../Musify.Domain/ValueObjects/TrackSource.cs | 8 - .../Configuration/YouTubeConfiguration.cs | 35 - .../Configuration/YtDlpConfiguration.cs | 20 - .../Helpers/YouTubeThumbNailHelper.cs | 55 - .../Infrastructure.csproj | 1 - .../Audio/DownloadYouTubeAudioActivity.cs | 94 -- .../DownloadYouTubeAudioArguments.cs | 7 - .../Consumers/DownloadYouTubeTrackConsumer.cs | 26 - ...TransitDependencyInjection.Registration.cs | 3 - .../Logs/DownloadYouTubeAudioLog.cs | 6 - .../MassTransit/RoutingSlip/ActivityNames.cs | 1 - .../YouTubeTrackRoutingSlipBuilder.cs | 166 --- .../Sagas/TrackProcessingStateMachine.cs | 26 +- .../Persistence/Database.cs | 56 +- ...65436_RemoveYouTubeIntegration.Designer.cs | 1133 +++++++++++++++++ ...20260915165436_RemoveYouTubeIntegration.cs | 381 ++++++ .../Migrations/DatabaseModelSnapshot.cs | 258 +--- .../Services/DisabledYouTubeMusicService.cs | 37 - .../Services/ServicesDependencyInjection.cs | 28 - .../Services/YouTubeDownloaderService.cs | 89 -- .../Services/YouTubeMusicService.cs | 310 ----- backend/Directory.Packages.props | 1 - .../Musify.Api/AppSettings.Development.json | 3 - backend/Hosts/Musify.Api/AppSettings.json | 15 +- .../Likes/ToggleLikeRequest.cs | 7 +- .../PlayLists/AddPlayListTrackRequest.cs | 7 +- .../Musify.Api/Endpoints/AlbumEndpoints.cs | 28 +- .../Musify.Api/Endpoints/LikeEndpoints.cs | 6 +- .../Musify.Api/Endpoints/PlayListEndpoints.cs | 28 +- .../Musify.Api/Endpoints/TrackEndpoints.cs | 32 +- .../Extensions/ServiceCollectionExtensions.cs | 1 - .../Likes/ToggleLikeRequestValidator.cs | 27 - .../AddPlayListTrackRequestValidator.cs | 27 - .../AppSettings.Development.json | 3 - backend/Hosts/Musify.Worker/AppSettings.json | 14 +- backend/Hosts/Musify.Worker/Program.cs | 4 - backend/Musify.Worker.Dockerfile | 15 +- .../TestSupport/ApiTestFixture.cs | 3 - .../AddTrackToAlbumCommandHandlerTests.cs | 26 +- .../Albums/CreateAlbumCommandHandlerTests.cs | 2 +- .../Albums/DeleteAlbumCommandHandlerTests.cs | 8 +- .../Albums/GetAlbumByIdQueryHandlerTests.cs | 4 +- .../Albums/GetAlbumCoverQueryHandlerTests.cs | 2 +- .../Albums/GetAlbumTracksQueryHandlerTests.cs | 6 +- .../GetAlbumsByUserIdQueryHandlerTests.cs | 2 +- .../Albums/GetAlbumsQueryHandlerTests.cs | 69 +- ...RecentlyListenedAlbumsQueryHandlerTests.cs | 12 +- ...RemoveTrackFromAlbumCommandHandlerTests.cs | 12 +- .../Albums/UpdateAlbumCommandHandlerTests.cs | 10 +- .../Likes/GetLikedTracksQueryHandlerTests.cs | 6 +- .../ToggleTrackLikeCommandHandlerTests.cs | 65 +- ...GenerateMixesForUserCommandHandlerTests.cs | 78 +- .../Mixes/GetMixByIdQueryHandlerTests.cs | 8 +- .../AddTrackToPlayListCommandHandlerTests.cs | 8 +- ...uTubeTrackToPlayListCommandHandlerTests.cs | 105 -- .../GetPlayListByIdQueryHandlerTests.cs | 2 +- .../GetPlayListTracksQueryHandlerTests.cs | 4 +- ...oveTrackFromPlayListCommandHandlerTests.cs | 2 +- .../Services/TrackStreamIssuerTests.cs | 10 +- .../Services/YouTubeTrackProvisionerTests.cs | 104 -- .../Shared/TrackApplicationResponseTests.cs | 35 +- .../TestSupport/TestDatabase.cs | 52 +- .../TestSupport/TestEntities.cs | 66 +- .../Tracks/CreateTrackCommandHandlerTests.cs | 4 +- .../Tracks/DeleteTrackCommandHandlerTests.cs | 17 +- .../GetListeningHistoryQueryHandlerTests.cs | 4 +- .../Tracks/GetTrackByIdQueryHandlerTests.cs | 4 +- .../Tracks/GetTrackCoverQueryHandlerTests.cs | 4 +- .../Tracks/GetTrackStreamQueryHandlerTests.cs | 8 +- .../GetTracksByUserIdQueryHandlerTests.cs | 6 +- .../Tracks/GetTracksQueryHandlerTests.cs | 80 +- .../GetListeningStatsQueryHandlerTests.cs | 10 +- .../GetYouTubeAlbumQueryHandlerTests.cs | 62 - ...veYouTubeTrackStreamCommandHandlerTests.cs | 125 -- .../Persistence/DatabaseTests.cs | 43 +- .../DisabledYouTubeMusicServiceTests.cs | 59 - deploy/.env.prod.example | 5 - deploy/README.md | 18 +- deploy/compose.prod.yml | 6 - deploy/compose.yml | 1 - deploy/nginx/templates/default.conf.template | 21 - docs/development.md | 15 +- 137 files changed, 1933 insertions(+), 3969 deletions(-) delete mode 100644 backend/Core/Musify.Application/Albums/Responses/ExternalAlbumApplicationResponse.cs delete mode 100644 backend/Core/Musify.Application/Contracts/IYouTubeDownloaderService.cs delete mode 100644 backend/Core/Musify.Application/Contracts/IYouTubeMusicService.cs delete mode 100644 backend/Core/Musify.Application/Events/DownloadYouTubeTrackEvent.cs create mode 100644 backend/Core/Musify.Application/Mixes/MixItemMapper.cs delete mode 100644 backend/Core/Musify.Application/PlayLists/AddYouTubeTrackToPlayList.cs delete mode 100644 backend/Core/Musify.Application/Services/YouTubeTrackProvisioner.cs delete mode 100644 backend/Core/Musify.Application/Tracks/Responses/ExternalTrackApplicationResponse.cs delete mode 100644 backend/Core/Musify.Application/Tracks/Responses/LocalTrackApplicationResponse.cs delete mode 100644 backend/Core/Musify.Application/YouTube/GetYouTubeAlbum.cs delete mode 100644 backend/Core/Musify.Application/YouTube/ResolveYouTubeTrackStream.cs delete mode 100644 backend/Core/Musify.Application/YouTube/Responses/YouTubeStreamResponse.cs delete mode 100644 backend/Core/Musify.Domain/Entities/Artist.cs delete mode 100644 backend/Core/Musify.Domain/Entities/ExternalAlbum.cs delete mode 100644 backend/Core/Musify.Domain/Entities/ExternalTrack.cs delete mode 100644 backend/Core/Musify.Domain/Entities/LocalTrack.cs delete mode 100644 backend/Core/Musify.Domain/Entities/TrackArtist.cs delete mode 100644 backend/Core/Musify.Domain/Entities/UserAlbum.cs delete mode 100644 backend/Core/Musify.Domain/ValueObjects/MixItemSource.cs delete mode 100644 backend/Core/Musify.Domain/ValueObjects/TrackSource.cs delete mode 100644 backend/Core/Musify.Infrastructure/Configuration/YouTubeConfiguration.cs delete mode 100644 backend/Core/Musify.Infrastructure/Configuration/YtDlpConfiguration.cs delete mode 100644 backend/Core/Musify.Infrastructure/Helpers/YouTubeThumbNailHelper.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/DownloadYouTubeAudioActivity.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadYouTubeAudioArguments.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Consumers/DownloadYouTubeTrackConsumer.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadYouTubeAudioLog.cs delete mode 100644 backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/YouTubeTrackRoutingSlipBuilder.cs create mode 100644 backend/Core/Musify.Infrastructure/Persistence/Migrations/20260915165436_RemoveYouTubeIntegration.Designer.cs create mode 100644 backend/Core/Musify.Infrastructure/Persistence/Migrations/20260915165436_RemoveYouTubeIntegration.cs delete mode 100644 backend/Core/Musify.Infrastructure/Services/DisabledYouTubeMusicService.cs delete mode 100644 backend/Core/Musify.Infrastructure/Services/YouTubeDownloaderService.cs delete mode 100644 backend/Core/Musify.Infrastructure/Services/YouTubeMusicService.cs delete mode 100644 backend/Hosts/Musify.Api/Validators/Likes/ToggleLikeRequestValidator.cs delete mode 100644 backend/Hosts/Musify.Api/Validators/PlayLists/AddPlayListTrackRequestValidator.cs delete mode 100644 backend/Tests/Musify.Application.Tests/PlayLists/AddYouTubeTrackToPlayListCommandHandlerTests.cs delete mode 100644 backend/Tests/Musify.Application.Tests/Services/YouTubeTrackProvisionerTests.cs delete mode 100644 backend/Tests/Musify.Application.Tests/YouTube/GetYouTubeAlbumQueryHandlerTests.cs delete mode 100644 backend/Tests/Musify.Application.Tests/YouTube/ResolveYouTubeTrackStreamCommandHandlerTests.cs delete mode 100644 backend/Tests/Musify.Infrastructure.Tests/Services/DisabledYouTubeMusicServiceTests.cs diff --git a/README.md b/README.md index 1cd6ff8..9a319a7 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Musify -Music streaming platform: upload your own tracks (or import them from YouTube), -process them asynchronously (audio transcoded to `.m4a`, thumbnails generated) -and stream them without the bytes ever going through the API. +Music streaming platform: upload your own tracks, process them asynchronously +(audio transcoded to `.m4a`, thumbnails generated) and stream them without the +bytes ever going through the API. ``` backend/ .NET 10 — Domain / Application / Infrastructure / Api / Worker / StreamingGateway / Tests @@ -46,7 +46,7 @@ production prerequisites. |---|---|---| | `Musify.Api` | 5111 | REST API, issues presigned upload URLs and stream tickets | | `Musify.StreamingGateway` | 8081 | YARP proxy that validates the stream ticket and serves the audio | -| `Musify.Worker` | — | MassTransit consumers: transcoding, thumbnails, YouTube downloads | +| `Musify.Worker` | — | MassTransit consumers: transcoding, thumbnails | | `web-player` | 5173 / 3000 | Web client (SvelteKit, server-side OIDC session) | | Zitadel | 8080 | Identity (OIDC/OAuth2) | | PostgreSQL / RabbitMQ / SeaweedFS / Jaeger | 59000 / 5672 / 8333 / 16686 | Metadata, messaging, object storage, traces | diff --git a/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs b/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs index 340fe1b..ac4fceb 100644 --- a/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs +++ b/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs @@ -17,7 +17,7 @@ namespace Musify.Application.Albums { public async ValueTask> Handle(AddTrackToAlbumCommand request, CancellationToken cancellationToken) { - var album = await database.UserAlbums + var album = await database.Albums .AsNoTracking() .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); if (album is null) @@ -32,19 +32,16 @@ namespace Musify.Application.Albums return Error.Unauthorized(); } - var trackExists = await database.Tracks + var track = await database.Tracks .AsNoTracking() - .AnyAsync(t => t.Id == request.TrackId, cancellationToken); - if (!trackExists) + .SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken); + if (track is null) { logger.LogInformation("Track {TrackId} not found", request.TrackId); return Error.NotFound(description: $"Track {request.TrackId} not found"); } - var localTrack = await database.LocalTracks - .AsNoTracking() - .SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken); - if (localTrack is not null && localTrack.OwnerUserId != request.UserId) + if (track.OwnerUserId != request.UserId) { logger.LogWarning("User {UserId} does not own track {TrackId}", request.UserId, request.TrackId); return Error.Unauthorized(); diff --git a/backend/Core/Musify.Application/Albums/CreateAlbum.cs b/backend/Core/Musify.Application/Albums/CreateAlbum.cs index 81108a9..842f97c 100644 --- a/backend/Core/Musify.Application/Albums/CreateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/CreateAlbum.cs @@ -50,7 +50,7 @@ namespace Musify.Application.Albums var intent = pictureIntent.Value; var title = request.Title.Trim(); - var album = new UserAlbum + var album = new Album { OwnerUserId = request.UserId, Title = title, @@ -66,7 +66,7 @@ namespace Musify.Application.Albums } }; - await database.UserAlbums.AddAsync(album, cancellationToken); + await database.Albums.AddAsync(album, cancellationToken); var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, intent.ObjectName); var publishResult = await PublishCreateAlbumEventAsync(album.Id, intent, finalPictureKey, cancellationToken); diff --git a/backend/Core/Musify.Application/Albums/DeleteAlbum.cs b/backend/Core/Musify.Application/Albums/DeleteAlbum.cs index 3817398..fa8786b 100644 --- a/backend/Core/Musify.Application/Albums/DeleteAlbum.cs +++ b/backend/Core/Musify.Application/Albums/DeleteAlbum.cs @@ -16,7 +16,7 @@ namespace Musify.Application.Albums { public async ValueTask> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken) { - var album = await database.UserAlbums + var album = await database.Albums .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); if (album is null) { @@ -30,7 +30,7 @@ namespace Musify.Application.Albums return Error.Unauthorized(); } - database.UserAlbums.Remove(album); + database.Albums.Remove(album); try { diff --git a/backend/Core/Musify.Application/Albums/GetAlbumById.cs b/backend/Core/Musify.Application/Albums/GetAlbumById.cs index ccf9e51..7f18ebd 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumById.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumById.cs @@ -14,7 +14,7 @@ namespace Musify.Application.Albums { public async ValueTask> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken) { - var album = await database.UserAlbums + var album = await database.Albums .AsNoTracking() .Where(a => a.Id == request.Id) .Select(a => new diff --git a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs index f70c35e..cd315f5 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs @@ -4,7 +4,6 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -using Musify.Domain.Entities; using X.PagedList; using X.PagedList.EF; @@ -29,9 +28,7 @@ namespace Musify.Application.Albums var tracksQuery = database.AlbumHasTracks .AsNoTracking() - .Include(albumTrack => ((ExternalTrack)albumTrack.Track).TrackArtists) - .ThenInclude(trackArtist => trackArtist.Artist) - .Include(albumTrack => ((LocalTrack)albumTrack.Track).Owner) + .Include(albumTrack => albumTrack.Track.Owner) .Where(albumTrack => albumTrack.AlbumId == request.AlbumId); var totalCount = await tracksQuery.CountAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/Albums/GetAlbums.cs b/backend/Core/Musify.Application/Albums/GetAlbums.cs index 6e594c1..f149d35 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbums.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbums.cs @@ -1,29 +1,19 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; -using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; using X.PagedList; using X.PagedList.EF; namespace Musify.Application.Albums { - public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize, string? YoutubeContinuationToken = null) + public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize) : IQuery>; - public class GetAlbumsQueryHandler( - IDatabase database, - IYouTubeMusicService youTubeMusicService, - ILogger logger) + public class GetAlbumsQueryHandler(IDatabase database) : IQueryHandler> { - // Mirrors GetTracksQueryHandler's threshold: a title shorter than this is too likely to - // still be mid-keystroke to justify a live call to the external search API. - private const int MinTitleLengthForYouTubeSearch = 2; - public async ValueTask> Handle(GetAlbumsQuery request, CancellationToken cancellationToken) { var albumsQuery = database.Albums @@ -52,62 +42,19 @@ namespace Musify.Application.Albums }) .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - var localItems = pagedEntities + var items = pagedEntities .Select(entry => AlbumSearchItemResponse.FromAlbum( AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds))) .ToList(); - var knownExternalIds = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var entry in pagedEntities) - { - if (entry.Album is ExternalAlbum { Source: TrackSource.YouTube } external) - knownExternalIds.Add(external.ExternalId); - } - - var youtubeItems = new List(); - string? nextContinuationToken = null; - var youtubeUnavailable = false; - - if (request.Title?.Trim().Length >= MinTitleLengthForYouTubeSearch) - (youtubeItems, nextContinuationToken, youtubeUnavailable) = - await SearchYouTubeAsync(request.Title, request.YoutubeContinuationToken, knownExternalIds, cancellationToken); - - return AlbumsSearchResponse.Create( - [.. localItems, .. youtubeItems], + return new AlbumsSearchResponse( + items, pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.PageCount, pagedEntities.TotalItemCount, pagedEntities.HasPreviousPage, - pagedEntities.HasNextPage, - nextContinuationToken, - youtubeUnavailable); - } - - private async Task<(List Items, string? NextContinuationToken, bool Unavailable)> SearchYouTubeAsync( - string title, - string? continuationToken, - HashSet knownExternalIds, - CancellationToken cancellationToken) - { - var searchResult = await youTubeMusicService.SearchAlbumsAsync(title, continuationToken ?? string.Empty, cancellationToken); - if (searchResult.IsError) - { - logger.LogInformation( - "YouTube album search unavailable while combining album results: {Error}", searchResult.FirstError.Description); - return ([], null, true); - } - - var items = searchResult.Value.Items - .Where(album => knownExternalIds.Add(album.AlbumId)) - .Select(AlbumSearchItemResponse.FromYouTubeAlbum) - .ToList(); - - var nextToken = string.IsNullOrEmpty(searchResult.Value.ContinuationToken) - ? null - : searchResult.Value.ContinuationToken; - - return (items, nextToken, false); + pagedEntities.HasNextPage); } } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs b/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs index 068c996..8b7b5b2 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs @@ -23,7 +23,7 @@ namespace Musify.Application.Albums if (!userExists) return Error.NotFound(description: $"User {request.UserId} not found"); - var albumsQuery = database.UserAlbums + var albumsQuery = database.Albums .AsNoTracking() .Where(album => album.OwnerUserId == request.UserId); diff --git a/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs b/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs index b35d505..b295e61 100644 --- a/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs +++ b/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs @@ -16,7 +16,7 @@ namespace Musify.Application.Albums { public async ValueTask> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken) { - var album = await database.UserAlbums + var album = await database.Albums .AsNoTracking() .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); if (album is null) diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs index 1cd4fa9..ea03647 100644 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs @@ -4,15 +4,6 @@ using Musify.Application.Serialization; namespace Musify.Application.Albums.Responses { - // [JsonPolymorphic]/[JsonDerivedType] are required here, not optional: without them - // System.Text.Json serializes strictly by the declared CLR type at each call site - // (AlbumApplicationResponse, both as a bare return type and nested inside - // AlbumSearchItemResponse.Album), so an ExternalAlbumApplicationResponse instance would - // silently lose ExternalId/ThumbnailUrl on the wire. This also gives every response a - // "source" discriminator field for free, matching AlbumSearchItemResponse.Source. - [JsonPolymorphic(TypeDiscriminatorPropertyName = "source")] - [JsonDerivedType(typeof(AlbumApplicationResponse), typeDiscriminator: "Local")] - [JsonDerivedType(typeof(ExternalAlbumApplicationResponse), typeDiscriminator: "YouTube")] public record AlbumApplicationResponse( Guid Id, string Title, @@ -30,7 +21,7 @@ namespace Musify.Application.Albums.Responses public const int CoverTrackCount = 4; public static AlbumApplicationResponse FromEntity( - UserAlbum album, + Album album, int trackCount, IReadOnlyList? coverTrackIds = null) { @@ -48,18 +39,5 @@ namespace Musify.Application.Albums.Responses album.UpdatedAt, coverTrackIds ?? []); } - - public static AlbumApplicationResponse FromEntity( - Album album, - int trackCount, - IReadOnlyList? coverTrackIds = null) - { - return album switch - { - ExternalAlbum externalAlbum => ExternalAlbumApplicationResponse.FromEntity(externalAlbum, trackCount, coverTrackIds), - UserAlbum userAlbum => FromEntity(userAlbum, trackCount, coverTrackIds), - _ => throw new NotSupportedException($"Unsupported album entity type '{album.GetType().Name}'.") - }; - } } } diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs index 40acd5a..b587b80 100644 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs @@ -1,22 +1,7 @@ -using Musify.Application.Contracts; -using Musify.Domain.ValueObjects; - namespace Musify.Application.Albums.Responses { - /// One item of a combined local/external album listing. Exactly one of - /// or is populated: for - /// anything already in the catalog (locally created albums and external albums materialized - /// earlier), for a live external search hit that hasn't been - /// materialized yet, so it has no album id. - public record AlbumSearchItemResponse( - TrackSource Source, - AlbumApplicationResponse? Album, - YouTubeAlbumResult? YouTubeAlbum) + public record AlbumSearchItemResponse(AlbumApplicationResponse Album) { - public static AlbumSearchItemResponse FromAlbum(AlbumApplicationResponse album) => - new(album is ExternalAlbumApplicationResponse ? TrackSource.YouTube : TrackSource.Local, album, null); - - public static AlbumSearchItemResponse FromYouTubeAlbum(YouTubeAlbumResult album) => - new(TrackSource.YouTube, null, album); + public static AlbumSearchItemResponse FromAlbum(AlbumApplicationResponse album) => new(album); } } diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumsSearchResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumsSearchResponse.cs index ce8f424..f6b6f66 100644 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumsSearchResponse.cs +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumsSearchResponse.cs @@ -1,11 +1,6 @@ namespace Musify.Application.Albums.Responses { - /// Result of a combined albums listing/search: a page of local catalog albums - /// (user-created and previously materialized external albums), plus, when a search term was - /// given, live external hits appended after them. Pagination metadata (, - /// , , ) describes only - /// the local page — the external source has no stable total, so its continuation is tracked - /// separately via . + /// A page of the user's catalog albums (their own). public record AlbumsSearchResponse( IReadOnlyList Items, int PageNumber, @@ -13,26 +8,5 @@ namespace Musify.Application.Albums.Responses int PageCount, int TotalItemCount, bool HasPreviousPage, - bool HasNextLocalPage, - string? NextYoutubeContinuationToken, - bool YoutubeUnavailable, - bool HasNextPage) - { - // HasNextPage is a real field (not a computed property) so it shows up as a required member - // of the generated OpenAPI schema, same as every other field here — a get-only property added - // outside the primary constructor gets reflected as optional instead, which is never true here. - public static AlbumsSearchResponse Create( - IReadOnlyList items, - int pageNumber, - int pageSize, - int pageCount, - int totalItemCount, - bool hasPreviousPage, - bool hasNextLocalPage, - string? nextYoutubeContinuationToken, - bool youtubeUnavailable) => - new(items, pageNumber, pageSize, pageCount, totalItemCount, hasPreviousPage, hasNextLocalPage, - nextYoutubeContinuationToken, youtubeUnavailable, - HasNextPage: hasNextLocalPage || !string.IsNullOrEmpty(nextYoutubeContinuationToken)); - } + bool HasNextPage); } diff --git a/backend/Core/Musify.Application/Albums/Responses/ExternalAlbumApplicationResponse.cs b/backend/Core/Musify.Application/Albums/Responses/ExternalAlbumApplicationResponse.cs deleted file mode 100644 index a9f12a3..0000000 --- a/backend/Core/Musify.Application/Albums/Responses/ExternalAlbumApplicationResponse.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Musify.Domain.Entities; - -namespace Musify.Application.Albums.Responses -{ - public record ExternalAlbumApplicationResponse( - Guid Id, - string Title, - string? Description, - int? ReleaseYear, - long OwnerUserId, - int TrackCount, - string? SmallImageKeyName, - string? MediumImageKeyName, - string? LargeImageKeyName, - DateTime CreatedAt, - DateTime UpdatedAt, - IReadOnlyList CoverTrackIds, - string ExternalId, - string? ThumbnailUrl) - : AlbumApplicationResponse( - Id, Title, Description, ReleaseYear, OwnerUserId, TrackCount, - SmallImageKeyName, MediumImageKeyName, LargeImageKeyName, - CreatedAt, UpdatedAt, CoverTrackIds) - { - public static ExternalAlbumApplicationResponse FromEntity( - ExternalAlbum album, - int trackCount, - IReadOnlyList? coverTrackIds = null) - { - return new ExternalAlbumApplicationResponse( - album.Id, - album.Title, - album.Description, - album.ReleaseYear, - 0, - trackCount, - album.Pictures?.SmallName, - album.Pictures?.MediumName, - album.Pictures?.LargeName, - album.CreatedAt, - album.UpdatedAt, - coverTrackIds ?? [], - album.ExternalId, - album.ThumbnailUrl); - } - } -} diff --git a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs index 458d7fb..4794ce6 100644 --- a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs @@ -34,7 +34,7 @@ namespace Musify.Application.Albums { public async ValueTask> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken) { - var album = await database.UserAlbums + var album = await database.Albums .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); if (album is null) { @@ -77,7 +77,7 @@ namespace Musify.Application.Albums finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); } - database.UserAlbums.Update(album); + database.Albums.Update(album); if (pictureIntent != null && finalPictureKey != null) { diff --git a/backend/Core/Musify.Application/ApplicationDependencyInjection.cs b/backend/Core/Musify.Application/ApplicationDependencyInjection.cs index 296432c..176157c 100644 --- a/backend/Core/Musify.Application/ApplicationDependencyInjection.cs +++ b/backend/Core/Musify.Application/ApplicationDependencyInjection.cs @@ -12,7 +12,6 @@ public static class ApplicationDependencyInjection services.AddMediator(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddValidatedOptions(configuration, ApplicationStorageConfiguration.SectionName); services.AddValidatedOptions(configuration, PlayListConfiguration.SectionName); diff --git a/backend/Core/Musify.Application/Configuration/MixConfiguration.cs b/backend/Core/Musify.Application/Configuration/MixConfiguration.cs index 64d1dff..5c99baf 100644 --- a/backend/Core/Musify.Application/Configuration/MixConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/MixConfiguration.cs @@ -6,16 +6,7 @@ namespace Musify.Application.Configuration { public const string SectionName = "Mix"; - [Range(1, 20)] - public int ArtistMixCount { get; set; } = 3; - [Range(4, 100)] public int ItemsPerMix { get; set; } = 12; - - [Range(1, 10)] - public int SeedArtistCount { get; set; } = 3; - - [Range(1, 100)] - public int YouTubeResultsPerSeed { get; set; } = 12; } } diff --git a/backend/Core/Musify.Application/Contracts/IDatabase.cs b/backend/Core/Musify.Application/Contracts/IDatabase.cs index ef100ae..71bcda0 100644 --- a/backend/Core/Musify.Application/Contracts/IDatabase.cs +++ b/backend/Core/Musify.Application/Contracts/IDatabase.cs @@ -11,22 +11,10 @@ namespace Musify.Application.Contracts DbSet Albums { get; } - DbSet UserAlbums { get; } - - DbSet ExternalAlbums { get; } - DbSet AlbumHasTracks { get; } DbSet Tracks { get; } - DbSet LocalTracks { get; } - - DbSet ExternalTracks { get; } - - DbSet Artists { get; } - - DbSet TrackArtists { get; } - DbSet UserHasTracks { get; } DbSet PlayListHasTracks { get; } diff --git a/backend/Core/Musify.Application/Contracts/IYouTubeDownloaderService.cs b/backend/Core/Musify.Application/Contracts/IYouTubeDownloaderService.cs deleted file mode 100644 index 8d3a98d..0000000 --- a/backend/Core/Musify.Application/Contracts/IYouTubeDownloaderService.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Musify.Application.Contracts -{ - public record YouTubeDownloadResult(int ExitCode, string StandardError); - - public interface IYouTubeDownloaderService - { - Task DownloadAudioAsync(string videoId, string destinationFilePath, CancellationToken cancellationToken); - } -} diff --git a/backend/Core/Musify.Application/Contracts/IYouTubeMusicService.cs b/backend/Core/Musify.Application/Contracts/IYouTubeMusicService.cs deleted file mode 100644 index b01cf3f..0000000 --- a/backend/Core/Musify.Application/Contracts/IYouTubeMusicService.cs +++ /dev/null @@ -1,64 +0,0 @@ -using ErrorOr; - -namespace Musify.Application.Contracts -{ - public record YouTubeArtistRef(string? Id, string Name); - - public record YouTubeSongResult( - string VideoId, - string Title, - string Artist, - string Album, - int DurationSeconds, - string ThumbnailUrl, - bool IsExplicit, - IReadOnlyList Artists); - - public record YouTubeSearchResult( - IReadOnlyList Items, - string ContinuationToken); - - public record YouTubeAlbumResult( - string AlbumId, - string Title, - string Artist, - string ThumbnailUrl, - int? ReleaseYear, - bool IsSingle, - bool IsEp, - IReadOnlyList Artists); - - public record YouTubeAlbumSearchResult( - IReadOnlyList Items, - string ContinuationToken); - - public record YouTubeAlbumTrack( - string VideoId, - string Title, - int DurationSeconds, - int TrackNumber, - bool IsExplicit); - - public record YouTubeAlbumDetail( - YouTubeAlbumResult Album, - string Description, - int TotalDurationSeconds, - IReadOnlyList Tracks); - - public record YouTubeStreamInfo(string Url, int ExpiresInSeconds); - - public interface IYouTubeMusicService - { - Task> SearchSongsAsync(string query, string continuationToken, CancellationToken cancellationToken); - - Task> SearchAlbumsAsync(string query, string continuationToken, CancellationToken cancellationToken); - - Task> GetAlbumAsync(string albumId, CancellationToken cancellationToken); - - Task> GetAudioStreamAsync(string videoId, CancellationToken cancellationToken); - - Task> GetSongAsync(string videoId, CancellationToken cancellationToken); - - string ResolveArtworkUrl(string thumbnailUrl); - } -} diff --git a/backend/Core/Musify.Application/Events/DownloadYouTubeTrackEvent.cs b/backend/Core/Musify.Application/Events/DownloadYouTubeTrackEvent.cs deleted file mode 100644 index dd880a6..0000000 --- a/backend/Core/Musify.Application/Events/DownloadYouTubeTrackEvent.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Musify.Application.Shared; - -namespace Musify.Application.Events -{ - public record DownloadYouTubeTrackEvent( - Guid TrackId, - string VideoId, - string ThumbnailUrl, - string Bucket, - string AudioProcessedFolderKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); -} diff --git a/backend/Core/Musify.Application/Likes/GetLikedTracks.cs b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs index 10f5abb..4981c3a 100644 --- a/backend/Core/Musify.Application/Likes/GetLikedTracks.cs +++ b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs @@ -4,7 +4,6 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -using Musify.Domain.Entities; using X.PagedList; using X.PagedList.EF; @@ -23,9 +22,7 @@ namespace Musify.Application.Likes { var likesQuery = database.TrackLikes .AsNoTracking() - .Include(like => ((ExternalTrack)like.Track).TrackArtists) - .ThenInclude(trackArtist => trackArtist.Artist) - .Include(like => ((LocalTrack)like.Track).Owner) + .Include(like => like.Track.Owner) .Where(like => like.UserId == request.UserId); var totalCount = await likesQuery.CountAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs b/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs index 3c84f0c..a2ae08a 100644 --- a/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs +++ b/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs @@ -3,35 +3,31 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; -using Musify.Application.Services; using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; namespace Musify.Application.Likes { - public record ToggleTrackLikeCommand( - long UserId, - Guid? TrackId, - TrackSource? Source, - string? ExternalId) + public record ToggleTrackLikeCommand(long UserId, Guid TrackId) : ICommand>; public class ToggleTrackLikeCommandHandler( IDatabase database, - YouTubeTrackProvisioner provisioner, ILogger logger) : ICommandHandler> { public async ValueTask> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken) { - var trackIdResult = await ResolveTrackIdAsync(request, cancellationToken); - if (trackIdResult.IsError) - return trackIdResult.Errors; - - var trackId = trackIdResult.Value; + var trackExists = await database.Tracks + .AsNoTracking() + .AnyAsync(t => t.Id == request.TrackId, cancellationToken); + if (!trackExists) + { + logger.LogInformation("Track {TrackId} not found", request.TrackId); + return Error.NotFound(description: $"Track {request.TrackId} not found"); + } var existingLike = await database.TrackLikes - .SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == trackId, cancellationToken); + .SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken); bool nowLiked; if (existingLike is not null) @@ -44,7 +40,7 @@ namespace Musify.Application.Likes await database.TrackLikes.AddAsync(new TrackLike { UserId = request.UserId, - TrackId = trackId + TrackId = request.TrackId }, cancellationToken); nowLiked = true; } @@ -52,32 +48,9 @@ namespace Musify.Application.Likes await database.SaveChangesAsync(cancellationToken); logger.LogInformation("User {UserId} {Action} track {TrackId}", - request.UserId, nowLiked ? "liked" : "unliked", trackId); + request.UserId, nowLiked ? "liked" : "unliked", request.TrackId); return nowLiked; } - - private async ValueTask> ResolveTrackIdAsync(ToggleTrackLikeCommand request, CancellationToken cancellationToken) - { - if (request.TrackId.HasValue) - { - var trackExists = await database.Tracks - .AsNoTracking() - .AnyAsync(t => t.Id == request.TrackId.Value, cancellationToken); - if (!trackExists) - { - logger.LogInformation("Track {TrackId} not found", request.TrackId); - return Error.NotFound(description: $"Track {request.TrackId} not found"); - } - - return request.TrackId.Value; - } - - var provisionResult = await provisioner.GetOrCreateAsync(request.ExternalId!, cancellationToken); - if (provisionResult.IsError) - return provisionResult.Errors; - - return provisionResult.Value.Track.Id; - } } } diff --git a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs index 0977c53..bff28f7 100644 --- a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs +++ b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs @@ -4,7 +4,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Configuration; using Musify.Application.Contracts; -using Musify.Application.Tracks.Responses; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; @@ -13,28 +12,35 @@ namespace Musify.Application.Mixes public record GenerateMixesForUserCommand(long UserId) : ICommand>; + /// Builds each user's mixes purely from their own library: a "Descubrimiento" mix of + /// tracks they own but haven't listened to yet, and a "Tu mezcla diaria" mix blending recently + /// listened tracks with unheard ones. There's no artist/genre metadata for local uploads today, + /// so mixes can't be seeded by "similar artist" the way the old YouTube-backed discovery did — + /// once a tagging system exists, this is the place to seed mixes by tag instead. public class GenerateMixesForUserCommandHandler( IDatabase database, - IYouTubeMusicService youTubeMusicService, MixConfiguration mixConfiguration, ILogger logger) : ICommandHandler> { - private sealed record SeedArtist(Guid Id, string Name); - - private sealed record LocalCandidate(Guid TrackId, string Title, string? Artist, double DurationSeconds); - - private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList Items); - - private const int TitleLength = 300; - private const int ArtistLength = 300; - private const int ThumbnailUrlLength = 512; - private const int MixTitleLength = 100; + private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList TrackIds); public async ValueTask> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken) { var userId = request.UserId; + var libraryTrackIds = await database.UserHasTracks + .AsNoTracking() + .Where(userTrack => userTrack.UserId == userId && userTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) + .Select(userTrack => userTrack.TrackId) + .ToListAsync(cancellationToken); + + if (libraryTrackIds.Count == 0) + { + logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId); + return new Success(); + } + var historyTrackIds = await database.ListeningHistories .AsNoTracking() .Where(history => history.UserId == userId) @@ -42,33 +48,29 @@ namespace Musify.Application.Mixes .Distinct() .ToListAsync(cancellationToken); - if (historyTrackIds.Count == 0) + var historySet = new HashSet(historyTrackIds); + var unheardTrackIds = libraryTrackIds.Where(id => !historySet.Contains(id)).ToList(); + + var drafts = new List(); + + var discovery = Take(Shuffle(unheardTrackIds)); + if (discovery.Count > 0) { - logger.LogInformation("User {UserId} has no listening history yet, skipping mix generation", userId); - return new Success(); + drafts.Add(new MixDraft( + "Descubrimiento", + "Canciones de tu biblioteca que todavía no has escuchado.", + discovery)); } - var seedArtists = await GetSeedArtistsAsync(historyTrackIds, cancellationToken); - - if (seedArtists.Count == 0) + var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds))); + if (daily.Count > 0) { - logger.LogInformation("User {UserId} has no artists to seed mixes from, skipping mix generation", userId); - return new Success(); + drafts.Add(new MixDraft( + "Tu mezcla diaria", + "Lo que más escuchas, con alguna sorpresa de tu biblioteca.", + daily)); } - var knownVideoIds = await database.ExternalTracks - .AsNoTracking() - .Where(track => track.Source == TrackSource.YouTube && historyTrackIds.Contains(track.Id)) - .Select(track => track.ExternalId) - .ToListAsync(cancellationToken); - - var excludedVideoIds = new HashSet(knownVideoIds, StringComparer.OrdinalIgnoreCase); - - var youTubeByArtist = await SearchYouTubeAsync(seedArtists, excludedVideoIds, cancellationToken); - var localByArtist = await GetLocalCandidatesAsync(seedArtists, historyTrackIds, cancellationToken); - - var drafts = BuildDrafts(seedArtists, localByArtist, youTubeByArtist); - if (drafts.Count == 0) { logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId); @@ -91,197 +93,21 @@ namespace Musify.Application.Mixes return new Success(); } - private async Task> GetSeedArtistsAsync( - IReadOnlyList historyTrackIds, - CancellationToken cancellationToken) + private List Take(IEnumerable trackIds) => + [.. trackIds.Take(mixConfiguration.ItemsPerMix)]; + + private static List Shuffle(IEnumerable trackIds) => + [.. trackIds.OrderBy(_ => Guid.NewGuid())]; + + private static List Interleave(IReadOnlyList first, IReadOnlyList second) { - var ranked = await database.TrackArtists - .AsNoTracking() - .Where(trackArtist => historyTrackIds.Contains(trackArtist.TrackId)) - .GroupBy(trackArtist => trackArtist.ArtistId) - .Select(group => new { ArtistId = group.Key, Listens = group.Count() } ) - .OrderByDescending(entry => entry.Listens) - .Take(mixConfiguration.SeedArtistCount) - .ToListAsync(cancellationToken); - - var artistIds = ranked.Select(entry => entry.ArtistId).ToList(); - - var names = await database.Artists - .AsNoTracking() - .Where(artist => artistIds.Contains(artist.Id)) - .ToDictionaryAsync(artist => artist.Id, artist => artist.Name, cancellationToken); - - return [.. ranked - .Where(entry => names.ContainsKey(entry.ArtistId)) - .Select(entry => new SeedArtist(entry.ArtistId, names[entry.ArtistId])) ]; - } - - private async Task> > SearchYouTubeAsync( - IReadOnlyList seedArtists, - HashSet excludedVideoIds, - CancellationToken cancellationToken) - { - var byArtist = new Dictionary>(); - - foreach (var seed in seedArtists) - { - var searchResult = await youTubeMusicService.SearchSongsAsync(seed.Name, string.Empty, cancellationToken); - if (searchResult.IsError) - { - logger.LogWarning("YouTube Music search failed for seed artist {Artist}: {Error}", - seed.Name, searchResult.FirstError.Description); - byArtist[seed.Id] = []; - continue; - } - - var songs = new List(); - foreach (var song in searchResult.Value.Items.Take(mixConfiguration.YouTubeResultsPerSeed)) - { - if (!excludedVideoIds.Add(song.VideoId)) - continue; - - songs.Add(new MixItem - { - MixId = Guid.Empty, - Source = MixItemSource.YouTube, - VideoId = song.VideoId, - Title = Truncate(song.Title, TitleLength), - Artist = Truncate(song.Artist, ArtistLength), - ThumbnailUrl = Truncate(youTubeMusicService.ResolveArtworkUrl(song.ThumbnailUrl), ThumbnailUrlLength), - DurationSeconds = song.DurationSeconds, - IsExplicit = song.IsExplicit - } ); - } - - byArtist[seed.Id] = songs; - } - - return byArtist; - } - - private async Task> > GetLocalCandidatesAsync( - IReadOnlyList seedArtists, - IReadOnlyList historyTrackIds, - CancellationToken cancellationToken) - { - var artistIds = seedArtists.Select(seed => seed.Id).ToList(); - - var links = await database.TrackArtists - .AsNoTracking() - .Where(trackArtist => artistIds.Contains(trackArtist.ArtistId) && !historyTrackIds.Contains(trackArtist.TrackId)) - .Select(trackArtist => new { trackArtist.ArtistId, trackArtist.TrackId } ) - .ToListAsync(cancellationToken); - - var trackIds = links.Select(link => link.TrackId).Distinct().ToList(); - - var candidates = await LoadCandidatesAsync(trackIds, cancellationToken); - - var byArtist = new Dictionary>(); - foreach (var seed in seedArtists) - { - byArtist[seed.Id] = [.. links - .Where(link => link.ArtistId == seed.Id) - .Select(link => candidates.GetValueOrDefault(link.TrackId)) - .Where(candidate => candidate is not null) - .Select(candidate => ToMixItem(candidate!))]; - } - - return byArtist; - } - - private async Task> LoadCandidatesAsync( - IReadOnlyList trackIds, - CancellationToken cancellationToken) - { - if (trackIds.Count == 0) - return []; - - var tracks = await database.Tracks - .AsNoTracking() - .Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) - .Include(track => ((ExternalTrack)track).TrackArtists) - .ThenInclude(trackArtist => trackArtist.Artist) - .Include(track => ((LocalTrack)track).Owner) - .ToListAsync(cancellationToken); - - return tracks - .Select(track => TrackApplicationResponse.FromEntity(track, 0)) - .ToDictionary( - response => response.Id, - response => new LocalCandidate(response.Id, response.Title, response.Artist, response.Duration)); - } - - private static MixItem ToMixItem(LocalCandidate candidate) => new() - { - MixId = Guid.Empty, - Source = MixItemSource.Musify, - TrackId = candidate.TrackId, - Title = candidate.Title, - Artist = candidate.Artist, - DurationSeconds = candidate.DurationSeconds - }; - - private List BuildDrafts( - IReadOnlyList seedArtists, - IReadOnlyDictionary> localByArtist, - IReadOnlyDictionary> youTubeByArtist) - { - var drafts = new List(); - - var everyYouTube = seedArtists.SelectMany(seed => youTubeByArtist[seed.Id]).ToList(); - var everyLocal = seedArtists.SelectMany(seed => localByArtist[seed.Id]).ToList(); - - var discovery = Take(Shuffle(everyYouTube)); - if (discovery.Count > 0) - { - drafts.Add(new MixDraft( - "Descubrimiento", - "Canciones nuevas que todavía no has escuchado.", - discovery)); - } - - var daily = Take(Interleave(Shuffle(everyLocal), Shuffle(everyYouTube))); - if (daily.Count > 0) - { - drafts.Add(new MixDraft( - "Tu mezcla diaria", - "Lo que más escuchas, con alguna sorpresa nueva.", - daily)); - } - - foreach (var seed in seedArtists.Take(mixConfiguration.ArtistMixCount)) - { - var items = Take(Interleave(Shuffle(localByArtist[seed.Id]), Shuffle(youTubeByArtist[seed.Id]))); - if (items.Count == 0) - continue; - - drafts.Add(new MixDraft( - Truncate(seed.Name, MixTitleLength), - $"Canciones de {seed.Name} y de artistas parecidos.", - items)); - } - - return drafts; - } - - private List Take(IEnumerable items) => - [.. items.Take(mixConfiguration.ItemsPerMix)]; - - private static string Truncate(string value, int maxLength) => - value.Length <= maxLength ? value : value[..maxLength]; - - private static List Shuffle(IEnumerable items) => - [.. items.OrderBy(_ => Guid.NewGuid())]; - - private static List Interleave(IReadOnlyList first, IReadOnlyList second) - { - var merged = new List(first.Count + second.Count); + var merged = new List(first.Count + second.Count); for (var index = 0; index < Math.Max(first.Count, second.Count); index++) { if (index < first.Count) - merged.Add(first[index] ); + merged.Add(first[index]); if (index < second.Count) - merged.Add(second[index] ); + merged.Add(second[index]); } return merged; @@ -317,21 +143,13 @@ namespace Musify.Application.Mixes await database.Mixes.AddAsync(mix, cancellationToken); - for (var itemPosition = 0; itemPosition < draft.Items.Count; itemPosition++) + for (var itemPosition = 0; itemPosition < draft.TrackIds.Count; itemPosition++) { - var item = draft.Items[itemPosition]; await database.MixItems.AddAsync(new MixItem { MixId = mix.Id, Position = itemPosition, - Source = item.Source, - TrackId = item.TrackId, - VideoId = item.VideoId, - Title = item.Title, - Artist = item.Artist, - ThumbnailUrl = item.ThumbnailUrl, - DurationSeconds = item.DurationSeconds, - IsExplicit = item.IsExplicit + TrackId = draft.TrackIds[itemPosition] }, cancellationToken); } } diff --git a/backend/Core/Musify.Application/Mixes/GetMixById.cs b/backend/Core/Musify.Application/Mixes/GetMixById.cs index a841fd5..e10c5c1 100644 --- a/backend/Core/Musify.Application/Mixes/GetMixById.cs +++ b/backend/Core/Musify.Application/Mixes/GetMixById.cs @@ -22,9 +22,10 @@ namespace Musify.Application.Mixes if (mix is null) return Error.NotFound(description: $"Mix {request.MixId} was not found"); - var items = mix.Items.OrderBy(item => item.Position).ToList(); + var orderedItems = mix.Items.OrderBy(item => item.Position).ToList(); + var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken); - return MixApplicationResponse.FromEntity(mix, items.Count, items); + return new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, items.Count, items); } } } diff --git a/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs index a2d1526..570aa6e 100644 --- a/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs +++ b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs @@ -14,24 +14,24 @@ namespace Musify.Application.Mixes { public async ValueTask>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken) { - var entries = await database.Mixes + var mixes = await database.Mixes .AsNoTracking() .Where(mix => mix.UserId == request.UserId) + .Include(mix => mix.Items) .OrderBy(mix => mix.Position) - .Select(mix => new - { - Mix = mix, - ItemCount = mix.Items.Count, - CoverItems = mix.Items - .OrderBy(item => item.Position) - .Take(MixApplicationResponse.CoverItemCount) - .ToList() - }) .ToListAsync(cancellationToken); - return entries - .Select(entry => MixApplicationResponse.FromEntity(entry.Mix, entry.ItemCount, entry.CoverItems)) - .ToList(); + var responses = new List(mixes.Count); + foreach (var mix in mixes) + { + var orderedItems = mix.Items.OrderBy(item => item.Position).ToList(); + var coverItems = await MixItemMapper.ToResponsesAsync( + database, orderedItems.Take(MixApplicationResponse.CoverItemCount).ToList(), cancellationToken); + + responses.Add(new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, orderedItems.Count, coverItems)); + } + + return responses; } } } diff --git a/backend/Core/Musify.Application/Mixes/MixItemMapper.cs b/backend/Core/Musify.Application/Mixes/MixItemMapper.cs new file mode 100644 index 0000000..3017594 --- /dev/null +++ b/backend/Core/Musify.Application/Mixes/MixItemMapper.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Mixes.Responses; +using Musify.Domain.Entities; + +namespace Musify.Application.Mixes +{ + /// Resolves MixItem rows to their MixItemApplicationResponse shape via a single batched + /// track lookup, instead of an EF Include chain deep enough (Mix -> Items -> Track -> Owner) to + /// force a correlated-subquery translation some providers (SQLite, notably) can't produce. + internal static class MixItemMapper + { + public static async Task> ToResponsesAsync( + IDatabase database, + IReadOnlyList orderedItems, + CancellationToken cancellationToken) + { + var trackIds = orderedItems.Select(item => item.TrackId).Distinct().ToList(); + + var tracksById = await database.Tracks + .AsNoTracking() + .Include(track => track.Owner) + .Where(track => trackIds.Contains(track.Id)) + .ToDictionaryAsync(track => track.Id, cancellationToken); + + return orderedItems + .Where(item => tracksById.ContainsKey(item.TrackId)) + .Select(item => + { + var track = tracksById[item.TrackId]; + return new MixItemApplicationResponse(item.TrackId, track.Title, track.Owner.Name, track.DurationSeconds); + }) + .ToList(); + } + } +} diff --git a/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs b/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs index 4f7da59..6af9494 100644 --- a/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs +++ b/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs @@ -1,5 +1,3 @@ -using Musify.Domain.Entities; - namespace Musify.Application.Mixes.Responses { public record MixApplicationResponse( @@ -10,16 +8,5 @@ namespace Musify.Application.Mixes.Responses IReadOnlyList Items) { public const int CoverItemCount = 4; - - public static MixApplicationResponse FromEntity( - Mix mix, - int itemCount, - IReadOnlyList items) => - new( - mix.Id, - mix.Title, - mix.Subtitle, - itemCount, - [.. items.Select(MixItemApplicationResponse.FromEntity)]); } } diff --git a/backend/Core/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs b/backend/Core/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs index 8ef4a24..409315a 100644 --- a/backend/Core/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs +++ b/backend/Core/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs @@ -1,27 +1,8 @@ -using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; - namespace Musify.Application.Mixes.Responses { public record MixItemApplicationResponse( - MixItemSource Source, - Guid? TrackId, - string? VideoId, + Guid TrackId, string Title, string? Artist, - string? ThumbnailUrl, - double DurationSeconds, - bool IsExplicit) - { - public static MixItemApplicationResponse FromEntity(MixItem item) => - new( - item.Source, - item.TrackId, - item.VideoId, - item.Title ?? string.Empty, - item.Artist, - item.ThumbnailUrl, - item.DurationSeconds, - item.IsExplicit); - } + double DurationSeconds); } diff --git a/backend/Core/Musify.Application/PlayLists/AddYouTubeTrackToPlayList.cs b/backend/Core/Musify.Application/PlayLists/AddYouTubeTrackToPlayList.cs deleted file mode 100644 index 09b049d..0000000 --- a/backend/Core/Musify.Application/PlayLists/AddYouTubeTrackToPlayList.cs +++ /dev/null @@ -1,149 +0,0 @@ -using ErrorOr; -using Mediator; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Configuration; -using Musify.Application.Contracts; -using Musify.Application.Events; -using Musify.Application.Services; -using Musify.Application.Shared; -using Musify.Application.Tracks.Responses; -using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; - -namespace Musify.Application.PlayLists -{ - public record AddYouTubeTrackToPlayListCommand( - long UserId, - Guid PlayListId, - string VideoId) - : ICommand>; - - public class AddYouTubeTrackToPlayListCommandHandler( - IDatabase database, - IEventBus eventBus, - IYouTubeMusicService youTubeMusicService, - YouTubeTrackProvisioner provisioner, - ApplicationStorageConfiguration storageConfiguration, - TrackConfiguration trackConfiguration, - ILogger logger) - : ICommandHandler> - { - public async ValueTask> Handle(AddYouTubeTrackToPlayListCommand request, CancellationToken cancellationToken) - { - var playList = await database.PlayLists.AsNoTracking() - .SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); - if (playList is null) - { - logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId); - return Error.NotFound(); - } - - if (playList.UserId != request.UserId) - { - logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); - return Error.Unauthorized(); - } - - var provisionResult = await provisioner.GetOrCreateAsync(request.VideoId, cancellationToken); - if (provisionResult.IsError) - return provisionResult.Errors; - - var track = provisionResult.Value.Track; - - var failed = track.Audio.IsFailed; - var neverQueued = !track.Audio.DownloadRequested && !track.Audio.IsProcessed; - if (failed || neverQueued) - { - if (failed) - { - track.Audio.TranscodeStatus = ProcessingStatus.Pending; - track.Pictures.ProcessingStatus = ProcessingStatus.Pending; - track.LifeCycleStatus = LifeCycleStatus.Active; - track.Audio.RetryCount += 1; - track.Audio.LastRetryAt = DateTime.UtcNow; - } - track.Audio.DownloadRequested = true; - - var thumbnailUrl = await ResolveThumbnailUrlAsync(request.VideoId, provisionResult.Value.Song, cancellationToken); - if (thumbnailUrl.IsError) - return thumbnailUrl.Errors; - - await PublishDownloadEventAsync(track.Id, request.VideoId, thumbnailUrl.Value, cancellationToken); - await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Queued YouTube track {VideoId} for download", request.VideoId); - } - - var alreadyAdded = await database.PlayListHasTracks.AsNoTracking() - .AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == track.Id, cancellationToken); - if (alreadyAdded) - { - logger.LogInformation("Track {TrackId} already in playlist {PlayListId}", track.Id, request.PlayListId); - return Error.Conflict(description: "Track is already in the playlist."); - } - - var playListTracksQuery = database.PlayListHasTracks - .Where(plt => plt.PlayListId == request.PlayListId); - - var hasTracks = await playListTracksQuery.AnyAsync(cancellationToken); - var nextPosition = hasTracks - ? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1 - : 0; - - await database.PlayListHasTracks.AddAsync(new PlayListHasTrack - { - PlayListId = request.PlayListId, - TrackId = track.Id, - Position = nextPosition - }, cancellationToken); - - await database.SaveChangesAsync(cancellationToken); - - logger.LogInformation("Added YouTube track {VideoId} ({TrackId}) to playlist {PlayListId} at position {Position}", - request.VideoId, track.Id, request.PlayListId, nextPosition); - - var listensCount = await database.ListeningHistories.AsNoTracking() - .CountAsync(lh => lh.TrackId == track.Id, cancellationToken); - - return TrackApplicationResponse.FromEntity(track, listensCount); - } - - private async Task> ResolveThumbnailUrlAsync(string videoId, YouTubeSongResult? song, CancellationToken cancellationToken) - { - if (!string.IsNullOrEmpty(song?.ThumbnailUrl)) - return youTubeMusicService.ResolveArtworkUrl(song.ThumbnailUrl); - - var songResult = await youTubeMusicService.GetSongAsync(videoId, cancellationToken); - if (songResult.IsError) - return songResult.Errors; - - return youTubeMusicService.ResolveArtworkUrl(songResult.Value.ThumbnailUrl); - } - - private async Task PublishDownloadEventAsync(Guid trackId, string videoId, string thumbnailUrl, CancellationToken cancellationToken) - { - var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString()); - - await eventBus.PublishAsync( - new DownloadYouTubeTrackEvent( - trackId, - videoId, - thumbnailUrl, - storageConfiguration.Bucket, - audioProcessedFolderKey, - new ImageSize( - trackConfiguration.Routes.SmallPicturesPath, - trackConfiguration.PicturesSizes.SmallPictureWidth, - trackConfiguration.PicturesSizes.SmallPictureHeight), - new ImageSize( - trackConfiguration.Routes.MediumPicturesPath, - trackConfiguration.PicturesSizes.MediumPictureWidth, - trackConfiguration.PicturesSizes.MediumPictureHeight), - new ImageSize( - trackConfiguration.Routes.LargePicturesPath, - trackConfiguration.PicturesSizes.LargePictureWidth, - trackConfiguration.PicturesSizes.LargePictureHeight)), - cancellationToken); - } - } -} diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs index 147700a..6f04d1a 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs @@ -4,7 +4,6 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -using Musify.Domain.Entities; using X.PagedList; using X.PagedList.EF; @@ -29,9 +28,7 @@ namespace Musify.Application.PlayLists var tracksQuery = database.PlayListHasTracks .AsNoTracking() - .Include(plt => ((ExternalTrack)plt.Track).TrackArtists) - .ThenInclude(trackArtist => trackArtist.Artist) - .Include(plt => ((LocalTrack)plt.Track).Owner) + .Include(plt => plt.Track.Owner) .Where(plt => plt.PlayListId == request.PlayListId); var totalCount = await tracksQuery.CountAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/Services/YouTubeTrackProvisioner.cs b/backend/Core/Musify.Application/Services/YouTubeTrackProvisioner.cs deleted file mode 100644 index b29b09a..0000000 --- a/backend/Core/Musify.Application/Services/YouTubeTrackProvisioner.cs +++ /dev/null @@ -1,265 +0,0 @@ -using ErrorOr; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Configuration; -using Musify.Application.Contracts; -using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; - -namespace Musify.Application.Services; - -public sealed record YouTubeTrackProvision(ExternalTrack Track, YouTubeSongResult? Song); - -public sealed class YouTubeTrackProvisioner( - IDatabase database, - IYouTubeMusicService youTubeMusicService, - TrackConfiguration trackConfiguration, - ILogger logger) -{ - public Task FindAsync(string videoId, CancellationToken cancellationToken) => - database.ExternalTracks - .Include(track => track.TrackArtists) - .ThenInclude(trackArtist => trackArtist.Artist) - .SingleOrDefaultAsync(t => t.Source == TrackSource.YouTube && t.ExternalId == videoId, cancellationToken); - - public async Task> GetOrCreateAsync(string videoId, CancellationToken cancellationToken) - { - var existing = await FindAsync(videoId, cancellationToken); - if (existing is not null) - return new YouTubeTrackProvision(existing, Song: null); - - var songResult = await youTubeMusicService.GetSongAsync(videoId, cancellationToken); - if (songResult.IsError) - return songResult.Errors; - - var song = songResult.Value; - - var artistRefs = song.Artists - .GroupBy(artistRef => artistRef.Id ?? artistRef.Name.ToUpperInvariant()) - .Select(group => group.First()) - .ToList(); - - var artists = new List(); - foreach (var artistRef in artistRefs) - artists.Add(await GetOrCreateArtistAsync(artistRef, cancellationToken)); - - var title = Truncate(song.Title, 200); - var track = new ExternalTrack - { - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - Source = TrackSource.YouTube, - ExternalId = videoId, - DurationSeconds = song.DurationSeconds, - Pictures = new TrackPictures - { - SmallName = trackConfiguration.Routes.PresetSmallPicture, - MediumName = trackConfiguration.Routes.PresetMediumPicture, - LargeName = trackConfiguration.Routes.PresetLargePicture, - ProcessingStatus = ProcessingStatus.Pending - }, - Audio = new TrackAudio - { - DownloadRequested = false, - TranscodeStatus = ProcessingStatus.Pending - } - }; - - await database.ExternalTracks.AddAsync(track, cancellationToken); - - for (var position = 0; position < artists.Count; position++) - { - await database.TrackArtists.AddAsync(new TrackArtist - { - TrackId = track.Id, - ArtistId = artists[position].Id, - Position = position - }, cancellationToken); - } - - try - { - await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Created YouTube track {VideoId} ({TrackId})", videoId, track.Id); - return new YouTubeTrackProvision(track, Song: song); - } - catch (DbUpdateException) - { - database.ExternalTracks.Remove(track); - var refetched = await FindAsync(videoId, cancellationToken); - if (refetched is null) - { - logger.LogWarning("YouTube track {VideoId} creation raced but could not be refetched", videoId); - return Error.Conflict(description: "The track was just created by another request. Retry the operation."); - } - return new YouTubeTrackProvision(refetched, Song: null); - } - } - - public async Task MaterializeAlbumAsync(string albumExternalId, YouTubeAlbumDetail detail, CancellationToken cancellationToken) - { - var validTracks = detail.Tracks - .Where(track => !string.IsNullOrEmpty(track.VideoId)) - .ToList(); - if (validTracks.Count == 0) - return; - - var album = await GetOrCreateExternalAlbumAsync(albumExternalId, detail.Album.Title, detail.Album.ThumbnailUrl, cancellationToken); - - var videoIds = validTracks.Select(track => track.VideoId).ToList(); - - var tracksByVideoId = await database.ExternalTracks - .Where(track => track.Source == TrackSource.YouTube && videoIds.Contains(track.ExternalId)) - .ToDictionaryAsync(track => track.ExternalId, cancellationToken); - - var linkedTrackIds = (await database.AlbumHasTracks - .Where(albumTrack => albumTrack.AlbumId == album.Id) - .Select(albumTrack => albumTrack.TrackId) - .ToListAsync(cancellationToken)) - .ToHashSet(); - - if (tracksByVideoId.Count == validTracks.Count && linkedTrackIds.Count >= validTracks.Count) - return; - - var artistRefs = detail.Album.Artists - .GroupBy(artistRef => artistRef.Id ?? artistRef.Name.ToUpperInvariant()) - .Select(group => group.First()) - .ToList(); - - var albumArtists = new List(); - foreach (var artistRef in artistRefs) - albumArtists.Add(await GetOrCreateArtistAsync(artistRef, cancellationToken)); - - foreach (var albumTrack in validTracks) - { - if (!tracksByVideoId.TryGetValue(albumTrack.VideoId, out var track)) - { - track = BuildStubTrack(albumTrack); - await database.ExternalTracks.AddAsync(track, cancellationToken); - - for (var position = 0; position < albumArtists.Count; position++) - { - await database.TrackArtists.AddAsync(new TrackArtist - { - TrackId = track.Id, - ArtistId = albumArtists[position].Id, - Position = position - }, cancellationToken); - } - - tracksByVideoId[albumTrack.VideoId] = track; - } - - if (linkedTrackIds.Contains(track.Id)) - continue; - - await database.AlbumHasTracks.AddAsync(new AlbumHasTrack - { - AlbumId = album.Id, - TrackId = track.Id, - TrackNumber = albumTrack.TrackNumber - }, cancellationToken); - linkedTrackIds.Add(track.Id); - } - - try - { - await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Materialized YouTube album {AlbumId} ({TrackCount} tracks)", albumExternalId, validTracks.Count); - } - catch (DbUpdateException exception) - { - logger.LogWarning(exception, "Race while materializing YouTube album {AlbumId}", albumExternalId); - } - } - - private ExternalTrack BuildStubTrack(YouTubeAlbumTrack albumTrack) - { - var title = Truncate(albumTrack.Title, 200); - return new ExternalTrack - { - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - Source = TrackSource.YouTube, - ExternalId = albumTrack.VideoId, - DurationSeconds = albumTrack.DurationSeconds, - Pictures = new TrackPictures - { - SmallName = trackConfiguration.Routes.PresetSmallPicture, - MediumName = trackConfiguration.Routes.PresetMediumPicture, - LargeName = trackConfiguration.Routes.PresetLargePicture, - ProcessingStatus = ProcessingStatus.Pending - }, - Audio = new TrackAudio - { - DownloadRequested = false, - TranscodeStatus = ProcessingStatus.Pending - } - }; - } - - private async Task GetOrCreateArtistAsync(YouTubeArtistRef reference, CancellationToken cancellationToken) - { - var name = Truncate(reference.Name, 200); - var normalized = name.ToUpperInvariant(); - var externalId = reference.Id ?? $"name:{normalized}"; - - var existing = await database.Artists.SingleOrDefaultAsync(a => a.ExternalId == externalId, cancellationToken); - if (existing is not null) - return existing; - - var artist = new Artist - { - Name = name, - NormalizedName = normalized, - ExternalId = externalId - }; - - await database.Artists.AddAsync(artist, cancellationToken); - - try - { - await database.SaveChangesAsync(cancellationToken); - return artist; - } - catch (DbUpdateException) - { - database.Artists.Remove(artist); - return await database.Artists.SingleAsync(a => a.ExternalId == externalId, cancellationToken); - } - } - - private async Task GetOrCreateExternalAlbumAsync(string externalId, string name, string? thumbnailUrl, CancellationToken cancellationToken) - { - var existing = await database.ExternalAlbums - .SingleOrDefaultAsync(album => album.Source == TrackSource.YouTube && album.ExternalId == externalId, cancellationToken); - if (existing is not null) - return existing; - - var title = Truncate(name, 200); - var album = new ExternalAlbum - { - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - Source = TrackSource.YouTube, - ExternalId = externalId, - ThumbnailUrl = string.IsNullOrEmpty(thumbnailUrl) ? null : Truncate(thumbnailUrl, 512) - }; - - await database.ExternalAlbums.AddAsync(album, cancellationToken); - - try - { - await database.SaveChangesAsync(cancellationToken); - return album; - } - catch (DbUpdateException) - { - database.ExternalAlbums.Remove(album); - return await database.ExternalAlbums.SingleAsync(a => a.Source == TrackSource.YouTube && a.ExternalId == externalId, cancellationToken); - } - } - - private static string Truncate(string value, int maxLength) => - value.Length <= maxLength ? value : value[..maxLength]; -} diff --git a/backend/Core/Musify.Application/Tracks/CreateTrack.cs b/backend/Core/Musify.Application/Tracks/CreateTrack.cs index 87c0976..48db68b 100644 --- a/backend/Core/Musify.Application/Tracks/CreateTrack.cs +++ b/backend/Core/Musify.Application/Tracks/CreateTrack.cs @@ -59,7 +59,7 @@ namespace Musify.Application.Tracks var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName); var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString()); - var trackEntity = new LocalTrack + var trackEntity = new Track { Title = request.Title, NormalizedTitle = request.Title.ToUpperInvariant(), @@ -80,7 +80,7 @@ namespace Musify.Application.Tracks } }; - await database.LocalTracks.AddAsync(trackEntity, cancellationToken); + await database.Tracks.AddAsync(trackEntity, cancellationToken); await database.UserHasTracks.AddAsync(new UserHasTrack { diff --git a/backend/Core/Musify.Application/Tracks/DeleteTrack.cs b/backend/Core/Musify.Application/Tracks/DeleteTrack.cs index 8e00bda..3a712d3 100644 --- a/backend/Core/Musify.Application/Tracks/DeleteTrack.cs +++ b/backend/Core/Musify.Application/Tracks/DeleteTrack.cs @@ -29,7 +29,7 @@ namespace Musify.Application.Tracks return Error.NotFound(); } - if (track is not LocalTrack localTrack || localTrack.OwnerUserId != request.UserId) + if (track.OwnerUserId != request.UserId) { logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId); return Error.Unauthorized(); diff --git a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs index 5b660f9..080b9db 100644 --- a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs +++ b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs @@ -2,7 +2,6 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; -using Musify.Domain.Entities; namespace Musify.Application.Tracks { @@ -25,9 +24,7 @@ namespace Musify.Application.Tracks .ToListAsync(cancellationToken); var tracksById = await database.Tracks - .Include(t => ((ExternalTrack)t).TrackArtists) - .ThenInclude(trackArtist => trackArtist.Artist) - .Include(t => ((LocalTrack)t).Owner) + .Include(t => t.Owner) .Where(t => recentTrackIds.Contains(t.Id)) .Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count } ) .ToDictionaryAsync(t => t.Id, cancellationToken); diff --git a/backend/Core/Musify.Application/Tracks/GetTrackById.cs b/backend/Core/Musify.Application/Tracks/GetTrackById.cs index 565628b..9c4c10a 100644 --- a/backend/Core/Musify.Application/Tracks/GetTrackById.cs +++ b/backend/Core/Musify.Application/Tracks/GetTrackById.cs @@ -3,7 +3,6 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; -using Musify.Domain.Entities; namespace Musify.Application.Tracks { @@ -16,9 +15,7 @@ namespace Musify.Application.Tracks public async ValueTask> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken) { var entity = await database.Tracks.AsNoTracking() - .Include(t => ((ExternalTrack)t).TrackArtists) - .ThenInclude(trackArtist => trackArtist.Artist) - .Include(t => ((LocalTrack)t).Owner) + .Include(t => t.Owner) .Where(t => t.Id == request.TrackId) .Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count }) .SingleOrDefaultAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/Tracks/GetTracks.cs b/backend/Core/Musify.Application/Tracks/GetTracks.cs index 7add78c..9233a70 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracks.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracks.cs @@ -1,37 +1,24 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; using Musify.Application.Tracks.Responses; using X.PagedList; using X.PagedList.EF; using Musify.Application.Contracts; -using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks { - public record GetTracksQuery(string? Name, int PageNumber, int PageSize, string? YoutubeContinuationToken = null) + public record GetTracksQuery(string? Name, int PageNumber, int PageSize) : IQuery>; - public class GetTracksQueryHandler( - IDatabase database, - IYouTubeMusicService youTubeMusicService, - ILogger logger) + public class GetTracksQueryHandler(IDatabase database) : IQueryHandler> { - // Mirrors the web client's search-box debounce threshold: a query shorter than this is too - // likely to still be mid-keystroke to justify a live call to the YouTube Music API for every - // client that hits this endpoint. - private const int MinNameLengthForYouTubeSearch = 2; - public async ValueTask> Handle(GetTracksQuery request, CancellationToken cancellationToken) { var tracksQuery = database.UserHasTracks .AsNoTracking() - .Include(ut => ((ExternalTrack)ut.Track).TrackArtists) - .ThenInclude(trackArtist => trackArtist.Artist) - .Include(ut => ((LocalTrack)ut.Track).Owner) + .Include(ut => ut.Track.Owner) .AsQueryable(); if (!string.IsNullOrEmpty(request.Name)) @@ -48,61 +35,18 @@ namespace Musify.Application.Tracks .Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count }) .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - var localItems = pagedEntities + var items = pagedEntities .Select(x => TrackSearchItemResponse.FromTrack(TrackApplicationResponse.FromEntity(x.Track, x.ListensCount))) .ToList(); - var knownVideoIds = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var entry in pagedEntities) - { - if (entry.Track is ExternalTrack { Source: TrackSource.YouTube } external) - knownVideoIds.Add(external.ExternalId); - } - - var youtubeItems = new List(); - string? nextContinuationToken = null; - var youtubeUnavailable = false; - - if (request.Name?.Trim().Length >= MinNameLengthForYouTubeSearch) - (youtubeItems, nextContinuationToken, youtubeUnavailable) = - await SearchYouTubeAsync(request.Name, request.YoutubeContinuationToken, knownVideoIds, cancellationToken); - - return TracksSearchResponse.Create( - [.. localItems, .. youtubeItems], + return new TracksSearchResponse( + items, pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.PageCount, pagedEntities.TotalItemCount, pagedEntities.HasPreviousPage, - pagedEntities.HasNextPage, - nextContinuationToken, - youtubeUnavailable); - } - - private async Task<(List Items, string? NextContinuationToken, bool Unavailable)> SearchYouTubeAsync( - string name, - string? continuationToken, - HashSet knownVideoIds, - CancellationToken cancellationToken) - { - var searchResult = await youTubeMusicService.SearchSongsAsync(name, continuationToken ?? string.Empty, cancellationToken); - if (searchResult.IsError) - { - logger.LogInformation( - "YouTube search unavailable while combining track results: {Error}", searchResult.FirstError.Description); - return ([], null, true); - } - - var items = searchResult.Value.Items - .Where(song => knownVideoIds.Add(song.VideoId)) - .Select(TrackSearchItemResponse.FromYouTubeSong) - .ToList(); - - var nextToken = string.IsNullOrEmpty(searchResult.Value.ContinuationToken) - ? null - : searchResult.Value.ContinuationToken; - - return (items, nextToken, false); + pagedEntities.HasNextPage); } } } diff --git a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs index 2e38b10..9babb03 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs @@ -6,7 +6,6 @@ using Musify.Application.Tracks.Responses; using X.PagedList; using X.PagedList.EF; using Musify.Application.Contracts; -using Musify.Domain.Entities; namespace Musify.Application.Tracks { @@ -27,9 +26,7 @@ namespace Musify.Application.Tracks var tracksQuery = database.UserHasTracks .AsNoTracking() - .Include(ut => ((ExternalTrack)ut.Track).TrackArtists) - .ThenInclude(trackArtist => trackArtist.Artist) - .Include(ut => ((LocalTrack)ut.Track).Owner) + .Include(ut => ut.Track.Owner) .Where(p => p.UserId == request.UserId); if (!string.IsNullOrEmpty(request.Name)) diff --git a/backend/Core/Musify.Application/Tracks/Responses/ExternalTrackApplicationResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/ExternalTrackApplicationResponse.cs deleted file mode 100644 index 9e9e850..0000000 --- a/backend/Core/Musify.Application/Tracks/Responses/ExternalTrackApplicationResponse.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; - -namespace Musify.Application.Tracks.Responses -{ - public record ExternalTrackApplicationResponse( - Guid Id, - string Title, - string? Artist, - ProcessingStatus AudioStatus, - double Duration, - int ListensCount, - DateTime CreatedAt, - DateTime UpdatedAt, - string ExternalId) - : TrackApplicationResponse(Id, Title, Artist, AudioStatus, Duration, ListensCount, CreatedAt, UpdatedAt) - { - public static ExternalTrackApplicationResponse FromEntity(ExternalTrack track, int listensCount) => - new( - track.Id, - track.Title, - FormatArtist(track), - track.Audio.TranscodeStatus, - track.DurationSeconds, - listensCount, - track.CreatedAt, - track.UpdatedAt, - track.ExternalId); - } -} diff --git a/backend/Core/Musify.Application/Tracks/Responses/LocalTrackApplicationResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/LocalTrackApplicationResponse.cs deleted file mode 100644 index 09580d3..0000000 --- a/backend/Core/Musify.Application/Tracks/Responses/LocalTrackApplicationResponse.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Text.Json.Serialization; -using Musify.Application.Serialization; -using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; - -namespace Musify.Application.Tracks.Responses -{ - public record LocalTrackApplicationResponse( - Guid Id, - string Title, - string? Artist, - ProcessingStatus AudioStatus, - double Duration, - int ListensCount, - DateTime CreatedAt, - DateTime UpdatedAt, - [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId) - : TrackApplicationResponse(Id, Title, Artist, AudioStatus, Duration, ListensCount, CreatedAt, UpdatedAt) - { - public static LocalTrackApplicationResponse FromEntity(LocalTrack track, int listensCount) => - new( - track.Id, - track.Title, - FormatArtist(track), - track.Audio.TranscodeStatus, - track.DurationSeconds, - listensCount, - track.CreatedAt, - track.UpdatedAt, - track.OwnerUserId); - } -} diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs index a8b5a53..8895b63 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs @@ -1,24 +1,11 @@ -using System.Text.Json.Serialization; +using Musify.Application.Serialization; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; +using System.Text.Json.Serialization; namespace Musify.Application.Tracks.Responses { - // See AlbumApplicationResponse for why [JsonPolymorphic]/[JsonDerivedType] are required - // (not optional) whenever a base type carries subtype-only data across the wire. Track is a - // cleaner case than Album: both OwnerUserId (local-only) and ExternalId (external-only) are - // genuinely exclusive to one side, so the base has no "meaningless sentinel" field at all — - // every real value is one of the two derived types, hence base is abstract. - // - // No explicit Source property here (unlike the domain entities): STJ's polymorphism support - // already injects a "source" discriminator property with the same "Local"/"YouTube" values a - // hand-written TrackSource field would have, and a real property with that exact name would - // collide with it ("contains property 'source' that conflicts with an existing metadata - // property name"). Use the discriminator (or a type check) instead of a Source field. - [JsonPolymorphic(TypeDiscriminatorPropertyName = "source")] - [JsonDerivedType(typeof(LocalTrackApplicationResponse), typeDiscriminator: "Local")] - [JsonDerivedType(typeof(ExternalTrackApplicationResponse), typeDiscriminator: "YouTube")] - public abstract record TrackApplicationResponse( + public record TrackApplicationResponse( Guid Id, string Title, string? Artist, @@ -26,24 +13,19 @@ namespace Musify.Application.Tracks.Responses double Duration, int ListensCount, DateTime CreatedAt, - DateTime UpdatedAt) + DateTime UpdatedAt, + [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId) { public static TrackApplicationResponse FromEntity(Track track, int listensCount) => - track switch - { - LocalTrack local => LocalTrackApplicationResponse.FromEntity(local, listensCount), - ExternalTrack external => ExternalTrackApplicationResponse.FromEntity(external, listensCount), - _ => throw new NotSupportedException($"Unsupported track entity type '{track.GetType().Name}'.") - }; - - protected static string? FormatArtist(Track track) => track switch - { - LocalTrack local => local.Owner.Name, - ExternalTrack { TrackArtists.Count: > 0 } external => - string.Join(", ", external.TrackArtists - .OrderBy(trackArtist => trackArtist.Position) - .Select(trackArtist => trackArtist.Artist.Name)), - _ => null - }; + new( + track.Id, + track.Title, + track.Owner.Name, + track.Audio.TranscodeStatus, + track.DurationSeconds, + listensCount, + track.CreatedAt, + track.UpdatedAt, + track.OwnerUserId); } } diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs index 793fc85..1477ec4 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs @@ -1,22 +1,7 @@ -using Musify.Application.Contracts; -using Musify.Domain.ValueObjects; - namespace Musify.Application.Tracks.Responses { - /// One item of a combined local/YouTube track listing. Exactly one of - /// or is populated: for - /// anything already in the catalog (local uploads and YouTube tracks provisioned earlier), - /// for a live YouTube Music search hit that has not been - /// provisioned yet, so it has no track id. - public record TrackSearchItemResponse( - TrackSource Source, - TrackApplicationResponse? Track, - YouTubeSongResult? YouTubeSong) + public record TrackSearchItemResponse(TrackApplicationResponse Track) { - public static TrackSearchItemResponse FromTrack(TrackApplicationResponse track) => - new(track is ExternalTrackApplicationResponse ? TrackSource.YouTube : TrackSource.Local, track, null); - - public static TrackSearchItemResponse FromYouTubeSong(YouTubeSongResult song) => - new(TrackSource.YouTube, null, song); + public static TrackSearchItemResponse FromTrack(TrackApplicationResponse track) => new(track); } } diff --git a/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs index 2861bc1..caee538 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs @@ -1,11 +1,6 @@ namespace Musify.Application.Tracks.Responses { - /// Result of a combined tracks listing/search: a page of local catalog tracks - /// (uploads and previously provisioned YouTube tracks), plus, when a search term was given, - /// live YouTube Music hits appended after them. Pagination metadata (, - /// , , ) describes only - /// the local page — YouTube has no stable total, so its continuation is tracked separately via - /// . + /// A page of the user's catalog tracks (their own uploads). public record TracksSearchResponse( IReadOnlyList Items, int PageNumber, @@ -13,26 +8,5 @@ namespace Musify.Application.Tracks.Responses int PageCount, int TotalItemCount, bool HasPreviousPage, - bool HasNextLocalPage, - string? NextYoutubeContinuationToken, - bool YoutubeUnavailable, - bool HasNextPage) - { - // HasNextPage is a real field (not a computed property) so it shows up as a required member - // of the generated OpenAPI schema, same as every other field here — a get-only property added - // outside the primary constructor gets reflected as optional instead, which is never true here. - public static TracksSearchResponse Create( - IReadOnlyList items, - int pageNumber, - int pageSize, - int pageCount, - int totalItemCount, - bool hasPreviousPage, - bool hasNextLocalPage, - string? nextYoutubeContinuationToken, - bool youtubeUnavailable) => - new(items, pageNumber, pageSize, pageCount, totalItemCount, hasPreviousPage, hasNextLocalPage, - nextYoutubeContinuationToken, youtubeUnavailable, - HasNextPage: hasNextLocalPage || !string.IsNullOrEmpty(nextYoutubeContinuationToken)); - } + bool HasNextPage); } diff --git a/backend/Core/Musify.Application/YouTube/GetYouTubeAlbum.cs b/backend/Core/Musify.Application/YouTube/GetYouTubeAlbum.cs deleted file mode 100644 index f92b1fd..0000000 --- a/backend/Core/Musify.Application/YouTube/GetYouTubeAlbum.cs +++ /dev/null @@ -1,36 +0,0 @@ -using ErrorOr; -using Mediator; -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Application.Services; - -namespace Musify.Application.YouTube -{ - public record GetYouTubeAlbumQuery(string AlbumId) - : IQuery>; - - public class GetYouTubeAlbumQueryHandler( - IYouTubeMusicService youTubeMusicService, - YouTubeTrackProvisioner provisioner, - ILogger logger) - : IQueryHandler> - { - public async ValueTask> Handle(GetYouTubeAlbumQuery request, CancellationToken cancellationToken) - { - var result = await youTubeMusicService.GetAlbumAsync(request.AlbumId, cancellationToken); - if (result.IsError) - return result; - - try - { - await provisioner.MaterializeAlbumAsync(request.AlbumId, result.Value, cancellationToken); - } - catch (Exception exception) - { - logger.LogWarning(exception, "Failed to materialize YouTube album {AlbumId}", request.AlbumId); - } - - return result; - } - } -} diff --git a/backend/Core/Musify.Application/YouTube/ResolveYouTubeTrackStream.cs b/backend/Core/Musify.Application/YouTube/ResolveYouTubeTrackStream.cs deleted file mode 100644 index 0af4f4e..0000000 --- a/backend/Core/Musify.Application/YouTube/ResolveYouTubeTrackStream.cs +++ /dev/null @@ -1,85 +0,0 @@ -using ErrorOr; -using Mediator; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Configuration; -using Musify.Application.Contracts; -using Musify.Application.Services; -using Musify.Application.YouTube.Responses; -using Musify.Domain.Entities; - -namespace Musify.Application.YouTube -{ - public record ResolveYouTubeTrackStreamCommand(string VideoId, long? UserId) - : ICommand>; - - public class ResolveYouTubeTrackStreamCommandHandler( - IDatabase database, - IYouTubeMusicService youTubeMusicService, - YouTubeTrackProvisioner provisioner, - TrackStreamIssuer streamIssuer, - PlaybackConfiguration playbackConfiguration, - ILogger logger) - : ICommandHandler> - { - public async ValueTask> Handle(ResolveYouTubeTrackStreamCommand request, CancellationToken cancellationToken) - { - var isAnonymous = !request.UserId.HasValue; - if (isAnonymous && !playbackConfiguration.AllowAnonymousListening) - return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening."); - - var track = await provisioner.FindAsync(request.VideoId, cancellationToken); - - if (track != null && track.Audio.IsProcessed) - { - var serverStream = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken); - return new YouTubeStreamResponse( - track.Id, - YouTubeStreamMode.Server, - serverStream.ManifestUrl, - serverStream.Ticket, - serverStream.ExpiresInSeconds); - } - - if (isAnonymous && playbackConfiguration.AnonymousFragmentSeconds > 0) - return Error.Conflict(description: "This track isn't available for anonymous preview yet. Try again in a moment."); - - var streamInfo = await youTubeMusicService.GetAudioStreamAsync(request.VideoId, cancellationToken); - if (streamInfo.IsError) - return streamInfo.Errors; - - if (track == null) - { - var provisionResult = await provisioner.GetOrCreateAsync(request.VideoId, cancellationToken); - if (!provisionResult.IsError) - track = provisionResult.Value.Track; - else - logger.LogWarning("Could not provision YouTube track {VideoId}: {Error}", request.VideoId, provisionResult.FirstError.Description); - } - - if (track != null&& request.UserId != null) - { - await database.ListeningHistories.AddAsync(new ListeningHistory - { - UserId = request.UserId.Value, - TrackId = track.Id, - }, cancellationToken); - - try - { - await database.SaveChangesAsync(cancellationToken); - } - catch (DbUpdateException exception) - { - logger.LogWarning(exception, "Failed to record listening history for YouTube video {VideoId}", request.VideoId); - } - } - - return new YouTubeStreamResponse( - track?.Id, - YouTubeStreamMode.YouTube, - streamInfo.Value.Url, null, - streamInfo.Value.ExpiresInSeconds); - } - } -} diff --git a/backend/Core/Musify.Application/YouTube/Responses/YouTubeStreamResponse.cs b/backend/Core/Musify.Application/YouTube/Responses/YouTubeStreamResponse.cs deleted file mode 100644 index 2f32a8a..0000000 --- a/backend/Core/Musify.Application/YouTube/Responses/YouTubeStreamResponse.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Musify.Application.YouTube.Responses -{ - public enum YouTubeStreamMode - { - Server = 0, - YouTube = 1 - } - - public record YouTubeStreamResponse( - Guid? TrackId, - YouTubeStreamMode Mode, - string StreamUrl, - string? Ticket, - int ExpiresInSeconds); -} diff --git a/backend/Core/Musify.Domain/Entities/Album.cs b/backend/Core/Musify.Domain/Entities/Album.cs index 8a63ab8..bf3f866 100644 --- a/backend/Core/Musify.Domain/Entities/Album.cs +++ b/backend/Core/Musify.Domain/Entities/Album.cs @@ -1,11 +1,12 @@ using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.Abstractions; using Musify.Domain.ValueObjects; namespace Musify.Domain.Entities { #pragma warning disable CS8618 - public abstract class Album : IAuditable + public class Album : IAuditable { [Key] public Guid Id { get; set; } = Guid.NewGuid(); @@ -25,6 +26,12 @@ namespace Musify.Domain.Entities public AlbumPictures? Pictures { get; set; } + [Required] + public required long OwnerUserId { get; set; } + + [ForeignKey(nameof(OwnerUserId))] + public User Owner { get; set; } + [Required] public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; diff --git a/backend/Core/Musify.Domain/Entities/Artist.cs b/backend/Core/Musify.Domain/Entities/Artist.cs deleted file mode 100644 index bca0f98..0000000 --- a/backend/Core/Musify.Domain/Entities/Artist.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Musify.Domain.Abstractions; - -namespace Musify.Domain.Entities -{ -#pragma warning disable CS8618 - public class Artist : IAuditable - { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); - - [Required] - [MaxLength(200)] - public required string Name { get; set; } - - [Required] - [MaxLength(200)] - public required string NormalizedName { get; set; } - - [Required] - [MaxLength(64)] - public required string ExternalId { get; set; } - - [Required] - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - - [Required] - public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - - public ICollection TrackArtists { get; set; } = new List(); - } -} diff --git a/backend/Core/Musify.Domain/Entities/ExternalAlbum.cs b/backend/Core/Musify.Domain/Entities/ExternalAlbum.cs deleted file mode 100644 index 9609154..0000000 --- a/backend/Core/Musify.Domain/Entities/ExternalAlbum.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Musify.Domain.ValueObjects; - -namespace Musify.Domain.Entities -{ - public class ExternalAlbum : Album - { - [Required] - public required TrackSource Source { get; set; } - - [Required] - [MaxLength(64)] - public required string ExternalId { get; set; } - - [MaxLength(512)] - public string? ThumbnailUrl { get; set; } - } -} diff --git a/backend/Core/Musify.Domain/Entities/ExternalTrack.cs b/backend/Core/Musify.Domain/Entities/ExternalTrack.cs deleted file mode 100644 index 623d05b..0000000 --- a/backend/Core/Musify.Domain/Entities/ExternalTrack.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Musify.Domain.ValueObjects; - -namespace Musify.Domain.Entities -{ - public class ExternalTrack : Track - { - [Required] - public required TrackSource Source { get; set; } - - [Required] - [MaxLength(64)] - public required string ExternalId { get; set; } - - public ICollection TrackArtists { get; set; } = new List(); - } -} diff --git a/backend/Core/Musify.Domain/Entities/LocalTrack.cs b/backend/Core/Musify.Domain/Entities/LocalTrack.cs deleted file mode 100644 index 9ae6847..0000000 --- a/backend/Core/Musify.Domain/Entities/LocalTrack.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Musify.Domain.Entities -{ - public class LocalTrack : Track - { - [Required] - public required long OwnerUserId { get; set; } - - [ForeignKey(nameof(OwnerUserId)) ] - public required User Owner { get; set; } - } -} diff --git a/backend/Core/Musify.Domain/Entities/MixItem.cs b/backend/Core/Musify.Domain/Entities/MixItem.cs index 6a151c2..2269e2d 100644 --- a/backend/Core/Musify.Domain/Entities/MixItem.cs +++ b/backend/Core/Musify.Domain/Entities/MixItem.cs @@ -1,8 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using Musify.Domain.ValueObjects; - namespace Musify.Domain.Entities { #pragma warning disable CS8618 @@ -19,32 +17,12 @@ namespace Musify.Domain.Entities public int Position { get; set; } = 0; [Required] - public required MixItemSource Source { get; set; } - - public Guid? TrackId { get; set; } - - [MaxLength(64)] - public string? VideoId { get; set; } - - [MaxLength(300)] - public string? Title { get; set; } - - [MaxLength(300)] - public string? Artist { get; set; } - - [MaxLength(512)] - public string? ThumbnailUrl { get; set; } - - [Required] - public double DurationSeconds { get; set; } - - [Required] - public bool IsExplicit { get; set; } + public required Guid TrackId { get; set; } [ForeignKey(nameof(MixId))] public Mix Mix { get; set; } [ForeignKey(nameof(TrackId))] - public Track? Track { get; set; } + public Track Track { get; set; } } } diff --git a/backend/Core/Musify.Domain/Entities/Track.cs b/backend/Core/Musify.Domain/Entities/Track.cs index a7953a4..4d6a6d1 100644 --- a/backend/Core/Musify.Domain/Entities/Track.cs +++ b/backend/Core/Musify.Domain/Entities/Track.cs @@ -1,11 +1,12 @@ using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.Abstractions; using Musify.Domain.ValueObjects; namespace Musify.Domain.Entities { #pragma warning disable CS8618 - public abstract class Track : IAuditable + public class Track : IAuditable { [Key] public Guid Id { get; set; } = Guid.NewGuid(); @@ -21,6 +22,12 @@ namespace Musify.Domain.Entities [Required] public double DurationSeconds { get; set; } + [Required] + public required long OwnerUserId { get; set; } + + [ForeignKey(nameof(OwnerUserId))] + public required User Owner { get; set; } + [Required] public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; diff --git a/backend/Core/Musify.Domain/Entities/TrackArtist.cs b/backend/Core/Musify.Domain/Entities/TrackArtist.cs deleted file mode 100644 index c637f84..0000000 --- a/backend/Core/Musify.Domain/Entities/TrackArtist.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Musify.Domain.Entities -{ -#pragma warning disable CS8618 - [Table("TrackArtist")] - public class TrackArtist - { - [Required] - public required Guid TrackId { get; set; } - - [Required] - public required Guid ArtistId { get; set; } - - [Required] - public int Position { get; set; } = 0; - - [ForeignKey(nameof(TrackId)) ] - public ExternalTrack Track { get; set; } - - [ForeignKey(nameof(ArtistId)) ] - public Artist Artist { get; set; } - } -} diff --git a/backend/Core/Musify.Domain/Entities/UserAlbum.cs b/backend/Core/Musify.Domain/Entities/UserAlbum.cs deleted file mode 100644 index dbd5132..0000000 --- a/backend/Core/Musify.Domain/Entities/UserAlbum.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Musify.Domain.Entities -{ -#pragma warning disable CS8618 - public class UserAlbum : Album - { - [Required] - public required long OwnerUserId { get; set; } - - [ForeignKey(nameof(OwnerUserId)) ] - public User Owner { get; set; } - } -} diff --git a/backend/Core/Musify.Domain/ValueObjects/MixItemSource.cs b/backend/Core/Musify.Domain/ValueObjects/MixItemSource.cs deleted file mode 100644 index b143c8d..0000000 --- a/backend/Core/Musify.Domain/ValueObjects/MixItemSource.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Musify.Domain.ValueObjects -{ - public enum MixItemSource - { - Musify = 0, - YouTube = 1 - } -} diff --git a/backend/Core/Musify.Domain/ValueObjects/TrackSource.cs b/backend/Core/Musify.Domain/ValueObjects/TrackSource.cs deleted file mode 100644 index 1cafc94..0000000 --- a/backend/Core/Musify.Domain/ValueObjects/TrackSource.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Musify.Domain.ValueObjects -{ - public enum TrackSource - { - Local = 0, - YouTube = 1 - } -} diff --git a/backend/Core/Musify.Infrastructure/Configuration/YouTubeConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/YouTubeConfiguration.cs deleted file mode 100644 index fbc66a5..0000000 --- a/backend/Core/Musify.Infrastructure/Configuration/YouTubeConfiguration.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Musify.Infrastructure.Configuration -{ - public class YouTubeConfiguration - { - public const string SectionName = "YouTube"; - - /// Turns the whole YouTube Music source on or off: search, albums, streaming - /// and adding new YouTube tracks all fail with a clear error while this is false, - /// and mix generation silently skips YouTube candidates instead of failing. - public bool Enabled { get; set; } = true; - - [Required] - public string GeographicalLocation { get; set; } = "US"; - - [Range(60, 21600)] - public int StreamUrlCacheSeconds { get; set; } = 1800; - - [Range(60, 3600)] - public int SearchContinuationCacheSeconds { get; set; } = 600; - - [Range(0, 86400)] - public int SearchResultsCacheSeconds { get; set; } = 900; - - [Range(0, 604800)] - public int SongInfoCacheSeconds { get; set; } = 86400; - - [Range(32, 720)] - public int ThumbnailSize { get; set; } = 240; - - [Range(120, 1080)] - public int ArtworkSize { get; set; } = 544; - } -} diff --git a/backend/Core/Musify.Infrastructure/Configuration/YtDlpConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/YtDlpConfiguration.cs deleted file mode 100644 index 2f9c597..0000000 --- a/backend/Core/Musify.Infrastructure/Configuration/YtDlpConfiguration.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Musify.Infrastructure.Configuration -{ - public class YtDlpConfiguration - { - public const string SectionName = "YtDlp"; - - [Required] - public string ExecutableName { get; set; } = "yt-dlp"; - - [Required] - public string Format { get; set; } = "bestaudio"; - - public string AdditionalArguments { get; set; } = string.Empty; - - [Required] - public TimeSpan DownloadTimeout { get; set; } = TimeSpan.FromMinutes(5); - } -} diff --git a/backend/Core/Musify.Infrastructure/Helpers/YouTubeThumbNailHelper.cs b/backend/Core/Musify.Infrastructure/Helpers/YouTubeThumbNailHelper.cs deleted file mode 100644 index 8203545..0000000 --- a/backend/Core/Musify.Infrastructure/Helpers/YouTubeThumbNailHelper.cs +++ /dev/null @@ -1,55 +0,0 @@ -namespace Musify.Infrastructure.Helpers -{ - public static class YouTubeThumbNailHelper - { - public static bool IsSquare(string url) => - url.Contains("googleusercontent.com", StringComparison.OrdinalIgnoreCase); - - public static string WithSize(string url, int size) - { - if (string.IsNullOrEmpty(url)) - return string.Empty; - - if (url.Contains("googleusercontent.com", StringComparison.OrdinalIgnoreCase)) - return ResizeGoogleUserContent(url, size); - - if (url.Contains("ytimg.com", StringComparison.OrdinalIgnoreCase)) - return ResizeYtImg(url, size); - - return url; - } - - private static string ResizeGoogleUserContent(string url, int size) - { - var separator = url.LastIndexOf('='); - var baseUrl = separator > 0 && IsSizeSuffix(url.AsSpan(separator + 1)) - ? url[..separator] - : url; - - return $"{baseUrl}=w{size}-h{size}-l90-rj"; - } - - private static string ResizeYtImg(string url, int size) - { - var queryStart = url.IndexOf('?'); - var path = queryStart >= 0 ? url[..queryStart] : url; - - var lastSlash = path.LastIndexOf('/'); - if (lastSlash < 0) - return path; - - return $"{path[..lastSlash] }/{YtImgFileName(size) }"; - } - - private static string YtImgFileName(int size) => size switch - { - <= 90 => "default.jpg", - <= 180 => "mqdefault.jpg", - <= 360 => "hqdefault.jpg", - _ => "maxresdefault.jpg" - }; - - private static bool IsSizeSuffix(ReadOnlySpan suffix) => - suffix.Length > 1 && (suffix[0] is 'w' or 's' or 'h') && char.IsDigit(suffix[1] ); - } -} diff --git a/backend/Core/Musify.Infrastructure/Infrastructure.csproj b/backend/Core/Musify.Infrastructure/Infrastructure.csproj index f5e3b78..5b85a9b 100644 --- a/backend/Core/Musify.Infrastructure/Infrastructure.csproj +++ b/backend/Core/Musify.Infrastructure/Infrastructure.csproj @@ -33,7 +33,6 @@ - diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/DownloadYouTubeAudioActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/DownloadYouTubeAudioActivity.cs deleted file mode 100644 index 2badb33..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/DownloadYouTubeAudioActivity.cs +++ /dev/null @@ -1,94 +0,0 @@ -using MassTransit; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; -using Musify.Infrastructure.MassTransit.Arguments; -using Musify.Infrastructure.MassTransit.Logs; - -namespace Musify.Infrastructure.MassTransit.Activities.Audio -{ - internal class DownloadYouTubeAudioActivity( - IDatabase database, - IYouTubeDownloaderService youTubeDownloader, - ILogger logger) - : IActivity - { - public const string ExecuteEndpointName = "download-you-tube-audio"; - - public async Task Execute(ExecuteContext executeContext) - { - var destinationPath = executeContext.GetVariable(executeContext.Arguments.DestinationFilePathVariable); - ArgumentNullException.ThrowIfNull(destinationPath, nameof(destinationPath)); - - Track track; - try - { - var getTrack = await database.Tracks.SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); - track = getTrack ?? throw new InvalidOperationException($"Track with Id {executeContext.Arguments.TrackId} not found"); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to retrieve track with Id {TrackId} from database", executeContext.Arguments.TrackId); - throw; - } - - try - { - var downloadResult = await youTubeDownloader.DownloadAudioAsync( - executeContext.Arguments.VideoId, - destinationPath, - executeContext.CancellationToken); - - if (downloadResult.ExitCode != 0) - throw new InvalidOperationException( - $"YouTube audio download failed: {downloadResult.StandardError}"); - - if (!File.Exists(destinationPath)) - throw new InvalidOperationException($"YouTube audio download did not produce the expected file {destinationPath}."); - - logger.LogInformation("Downloaded YouTube audio for video {VideoId} to {DestinationPath}", - executeContext.Arguments.VideoId, destinationPath); - - return executeContext.Completed(new DownloadYouTubeAudioLog( - executeContext.Arguments.VideoId, - destinationPath)); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to download YouTube audio for video {VideoId}", - executeContext.Arguments.VideoId); - - try - { - track.Audio.TranscodeStatus = ProcessingStatus.Failed; - await database.SaveChangesAsync(executeContext.CancellationToken); - } - catch (Exception dbException) - { - logger.LogError(dbException, "Failed to update ProcessingStatus to Failed for track {TrackId}", - executeContext.Arguments.TrackId); - } - - throw; - } - } - - public Task Compensate(CompensateContext compensateContext) - { - try - { - if (File.Exists(compensateContext.Log.DestinationFilePath)) - File.Delete(compensateContext.Log.DestinationFilePath); - return Task.FromResult(compensateContext.Compensated()); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to compensate downloaded YouTube audio {DestinationFilePath}", - compensateContext.Log.DestinationFilePath); - return Task.FromResult(compensateContext.Failed(exception)); - } - } - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadYouTubeAudioArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadYouTubeAudioArguments.cs deleted file mode 100644 index abe62bb..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadYouTubeAudioArguments.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - public record DownloadYouTubeAudioArguments( - Guid TrackId, - string VideoId, - string DestinationFilePathVariable); -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DownloadYouTubeTrackConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DownloadYouTubeTrackConsumer.cs deleted file mode 100644 index f724e7c..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DownloadYouTubeTrackConsumer.cs +++ /dev/null @@ -1,26 +0,0 @@ -using MassTransit; -using Musify.Application.Events; -using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; - -namespace Musify.Infrastructure.MassTransit.Consumers -{ - public class DownloadYouTubeTrackConsumer( - IBus bus, - YouTubeTrackRoutingSlipBuilder routingSlipBuilder) - : IConsumer - { - public async Task Consume(ConsumeContext consumeContext) - { - var audioRoutingSlip = routingSlipBuilder - .BuildAudioWorkflow(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - var pictureRoutingSlip = routingSlipBuilder - .BuildPictureWorkflow(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(audioRoutingSlip); - await bus.Execute(pictureRoutingSlip); - } - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs index 3ec84ad..cae468b 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs @@ -25,7 +25,6 @@ namespace Musify.Infrastructure.MassTransit services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); } static void RegisterConsumersAndActivities(IBusRegistrationConfigurator options) @@ -42,7 +41,6 @@ namespace Musify.Infrastructure.MassTransit options.AddConsumer(); options.AddConsumer(); options.AddConsumer(); - options.AddConsumer(); options.AddConsumer(); options.AddConsumer(); options.AddConsumer(); @@ -73,7 +71,6 @@ namespace Musify.Infrastructure.MassTransit options.AddActivity(); options.AddActivity(); options.AddActivity(); - options.AddActivity(); options.AddActivity(); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadYouTubeAudioLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadYouTubeAudioLog.cs deleted file mode 100644 index 2623e8b..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadYouTubeAudioLog.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Musify.Infrastructure.MassTransit.Logs -{ - public record DownloadYouTubeAudioLog( - string VideoId, - string DestinationFilePath); -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs index 58ec272..544388c 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs @@ -14,7 +14,6 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths"; internal const string DownloadFile = "DownloadFile"; - internal const string DownloadYouTubeAudio = "DownloadYouTubeAudio"; internal const string DownloadThumbnail = "DownloadThumbnail"; internal const string TranscodeAudio = "TranscodeAudio"; diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/YouTubeTrackRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/YouTubeTrackRoutingSlipBuilder.cs deleted file mode 100644 index 91c0f0c..0000000 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/YouTubeTrackRoutingSlipBuilder.cs +++ /dev/null @@ -1,166 +0,0 @@ -using MassTransit; -using MassTransit.Courier.Contracts; -using Musify.Application.Events; -using Musify.Application.Shared; -using Musify.Infrastructure.Configuration; -using Musify.Infrastructure.MassTransit.Activities.Audio; -using Musify.Infrastructure.MassTransit.Activities.Files; -using Musify.Infrastructure.MassTransit.Activities.Pictures; -using Musify.Infrastructure.MassTransit.Arguments; -using Musify.Infrastructure.MassTransit.Consumers; - -namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders -{ - public class YouTubeTrackRoutingSlipBuilder(WorkerConfiguration workerConfiguration) - { - public RoutingSlipBuilder BuildAudioWorkflow(DownloadYouTubeTrackEvent message, Guid? correlationId) - { - var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); - - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), - RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); - - routingSlipBuilder.AddActivity( - ActivityNames.GenerateAudioWorkflowPaths, - EndpointHelper.BuildExecuteActivityUri(GenerateAudioWorkflowPathsActivity.ExecuteEndpointName), - new GenerateAudioWorkflowPathsArguments( - message.TrackId, - workerConfiguration.Routes.TemporaryFilesDirectory, - $"{message.VideoId}.m4a")); - - routingSlipBuilder.AddActivity( - ActivityNames.DownloadYouTubeAudio, - EndpointHelper.BuildExecuteActivityUri(DownloadYouTubeAudioActivity.ExecuteEndpointName), - new DownloadYouTubeAudioArguments( - message.TrackId, - message.VideoId, - RoutingSlipVariableNames.Audio.SourceFilePath)); - - routingSlipBuilder.AddActivity( - ActivityNames.TranscodeAudio, - EndpointHelper.BuildExecuteActivityUri(TranscodeAudioActivity.ExecuteEndpointName), - new TranscodeAudioArguments( - message.TrackId, - RoutingSlipVariableNames.Audio.SourceFilePath, - RoutingSlipVariableNames.Workflow.TemporalDirectory)); - - routingSlipBuilder.AddActivity( - ActivityNames.TransferFiles, - EndpointHelper.BuildExecuteActivityUri(TransferFilesToBucketActivity.ExecuteEndpointName), - new TransferFilesToBucketArguments( - message.Bucket, - RoutingSlipVariableNames.Audio.TranscodedDirectory, - message.AudioProcessedFolderKey)); - - routingSlipBuilder.AddActivity( - ActivityNames.UpdateTrackAudio, - EndpointHelper.BuildExecuteActivityUri(UpdateTrackAudioActivity.ExecuteEndpointName), - new UpdateTrackAudioArguments( - message.TrackId, - message.AudioProcessedFolderKey, - RoutingSlipVariableNames.Audio.DurationSeconds)); - - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.TrackId); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.TrackAudio); - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName), - RoutingSlipEvents.Faulted); - - return routingSlipBuilder; - } - - public RoutingSlipBuilder BuildPictureWorkflow(DownloadYouTubeTrackEvent message, Guid? correlationId) - { - var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); - - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), - RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); - - routingSlipBuilder.AddActivity( - ActivityNames.GeneratePictureWorkflowPaths, - EndpointHelper.BuildExecuteActivityUri(GeneratePictureWorkflowPathsActivity.ExecuteEndpointName), - new GeneratePictureWorkflowPathsArguments( - workerConfiguration.Routes.TemporaryFilesDirectory, - $"{message.VideoId}.jpg")); - - routingSlipBuilder.AddActivity( - ActivityNames.DownloadThumbnail, - EndpointHelper.BuildExecuteActivityUri(DownloadFileFromUrlActivity.ExecuteEndpointName), - new DownloadFileFromUrlArguments( - message.ThumbnailUrl, - RoutingSlipVariableNames.Picture.OriginalFilePath)); - - AddResizeActivity( - routingSlipBuilder, - ActivityNames.ResizeSmall, - ActivityNames.UploadSmall, - message.Bucket, - RoutingSlipVariableNames.Picture.SmallResizedFilePath, - message.Small); - - AddResizeActivity( - routingSlipBuilder, - ActivityNames.ResizeMedium, - ActivityNames.UploadMedium, - message.Bucket, - RoutingSlipVariableNames.Picture.MediumResizedFilePath, - message.Medium); - - AddResizeActivity( - routingSlipBuilder, - ActivityNames.ResizeLarge, - ActivityNames.UploadLarge, - message.Bucket, - RoutingSlipVariableNames.Picture.LargeResizedFilePath, - message.Large); - - routingSlipBuilder.AddActivity( - ActivityNames.UpdateTrackPicture, - EndpointHelper.BuildExecuteActivityUri(UpdateTrackPictureActivity.ExecuteEndpointName), - new UpdateTrackPictureArguments( - message.TrackId, - string.Empty, - RoutingSlipVariableNames.Picture.SmallResizedFilePath, - RoutingSlipVariableNames.Picture.MediumResizedFilePath, - RoutingSlipVariableNames.Picture.LargeResizedFilePath)); - - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.TrackId); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.TrackPicture); - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName), - RoutingSlipEvents.Faulted); - - return routingSlipBuilder; - } - - static void AddResizeActivity( - RoutingSlipBuilder routingSlipBuilder, - string resizeActivityName, - string uploadActivityName, - string bucketName, - string destinationFilePathVariableName, - ImageSize imageSize) - { - routingSlipBuilder.AddActivity( - resizeActivityName, - EndpointHelper.BuildExecuteActivityUri(ResizePictureActivity.ExecuteEndpointName), - new ResizePictureLocalArguments( - RoutingSlipVariableNames.Picture.OriginalFilePath, - destinationFilePathVariableName, - imageSize.Width, - imageSize.Height)); - - routingSlipBuilder.AddActivity( - uploadActivityName, - EndpointHelper.BuildExecuteActivityUri(UploadFileToBucketActivity.ExecuteEndpointName), - new UploadFileToBucketArguments( - destinationFilePathVariableName, - bucketName, - imageSize.SavePath)); - } - } -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs index 7cd8c47..9b157fb 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs @@ -9,7 +9,6 @@ namespace Musify.Infrastructure.MassTransit.Sagas public State Failed { get; private set; } = null!; public Event ProcessingStarted { get; private set; } = null!; - public Event YouTubeDownloadStarted { get; private set; } = null!; public Event PictureProcessed { get; private set; } = null!; public Event AudioProcessed { get; private set; } = null!; public Event PictureFailed { get; private set; } = null!; @@ -20,7 +19,6 @@ namespace Musify.Infrastructure.MassTransit.Sagas InstanceState(state => state.CurrentState); Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.TrackId)); - Event(() => YouTubeDownloadStarted, config => config.CorrelateById(context => context.Message.TrackId)); Event(() => PictureProcessed, config => config.CorrelateById(context => context.Message.TrackId)); Event(() => AudioProcessed, config => config.CorrelateById(context => context.Message.TrackId)); Event(() => PictureFailed, config => config.CorrelateById(context => context.Message.TrackId)); @@ -36,20 +34,9 @@ namespace Musify.Infrastructure.MassTransit.Sagas context.Saga.PictureKey = context.Message.PictureDestinationKey; context.Saga.AudioKey = context.Message.AudioDestinationKey; } ) - .TransitionTo(Processing), - When(YouTubeDownloadStarted) - .Then(context => - { - context.Saga.CreatedAt = DateTime.UtcNow; - context.Saga.UpdatedAt = DateTime.UtcNow; - context.Saga.Bucket = context.Message.Bucket; - context.Saga.PictureKey = string.Empty; - context.Saga.AudioKey = string.Empty; - } ) .TransitionTo(Processing)); During(Processing, - Ignore(YouTubeDownloadStarted), When(PictureProcessed) .Then(context => { @@ -85,18 +72,7 @@ namespace Musify.Infrastructure.MassTransit.Sagas Ignore(PictureProcessed), Ignore(AudioProcessed), Ignore(PictureFailed), - Ignore(AudioFailed), - When(YouTubeDownloadStarted) - .Then(context => - { - context.Saga.UpdatedAt = DateTime.UtcNow; - context.Saga.PictureProcessed = false; - context.Saga.AudioProcessed = false; - context.Saga.Bucket = context.Message.Bucket; - context.Saga.PictureKey = string.Empty; - context.Saga.AudioKey = string.Empty; - } ) - .TransitionTo(Processing)); + Ignore(AudioFailed)); SetCompletedWhenFinalized(); } diff --git a/backend/Core/Musify.Infrastructure/Persistence/Database.cs b/backend/Core/Musify.Infrastructure/Persistence/Database.cs index 607bb59..229e156 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Database.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Database.cs @@ -54,41 +54,12 @@ namespace Musify.Infrastructure.Persistence }); modelBuilder.Entity().Navigation(track => track.Audio).IsRequired(); - modelBuilder.Entity() - .ToTable("LocalTracks"); - - modelBuilder.Entity() + modelBuilder.Entity() .HasOne(track => track.Owner) .WithMany() .HasForeignKey(track => track.OwnerUserId) .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity() - .ToTable("ExternalTracks"); - - modelBuilder.Entity() - .HasIndex(track => new { track.Source, track.ExternalId }) - .IsUnique(); - - modelBuilder.Entity() - .HasKey(trackArtist => new { trackArtist.TrackId, trackArtist.ArtistId }); - - modelBuilder.Entity() - .HasOne(trackArtist => trackArtist.Track) - .WithMany(track => track.TrackArtists) - .HasForeignKey(trackArtist => trackArtist.TrackId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasOne(trackArtist => trackArtist.Artist) - .WithMany(artist => artist.TrackArtists) - .HasForeignKey(trackArtist => trackArtist.ArtistId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasIndex(artist => artist.ExternalId) - .IsUnique(); - modelBuilder.Entity() .Property(playList => playList.Visibility) .HasDefaultValue(PlaylistVisibility.Private); @@ -113,22 +84,12 @@ namespace Musify.Infrastructure.Persistence }); modelBuilder.Entity().Navigation(album => album.Pictures).IsRequired(false); - modelBuilder.Entity() - .ToTable("UserAlbums"); - - modelBuilder.Entity() + modelBuilder.Entity() .HasOne(album => album.Owner) .WithMany() .HasForeignKey(album => album.OwnerUserId) .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity() - .ToTable("ExternalAlbums"); - - modelBuilder.Entity() - .HasIndex(album => new { album.Source, album.ExternalId }) - .IsUnique(); - modelBuilder.Entity() .HasOne(albumTrack => albumTrack.Album) .WithMany(album => album.AlbumTracks) @@ -158,7 +119,6 @@ namespace Musify.Infrastructure.Persistence .HasOne(item => item.Track) .WithMany() .HasForeignKey(item => item.TrackId) - .IsRequired(false) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() @@ -199,22 +159,10 @@ namespace Musify.Infrastructure.Persistence public DbSet Tracks => Set(); - public DbSet LocalTracks => Set(); - - public DbSet ExternalTracks => Set(); - - public DbSet Artists => Set(); - - public DbSet TrackArtists => Set(); - public DbSet PlayLists => Set(); public DbSet Albums => Set(); - public DbSet UserAlbums => Set(); - - public DbSet ExternalAlbums => Set(); - public DbSet AlbumHasTracks => Set(); public DbSet UserHasTracks => Set(); diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260915165436_RemoveYouTubeIntegration.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260915165436_RemoveYouTubeIntegration.Designer.cs new file mode 100644 index 0000000..7fb80d2 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260915165436_RemoveYouTubeIntegration.Designer.cs @@ -0,0 +1,1133 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260915165436_RemoveYouTubeIntegration")] + partial class RemoveYouTubeIntegration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("ReleaseYear") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Albums"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AlbumId") + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("TrackNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("AlbumId", "TrackId") + .IsUnique(); + + b.ToTable("AlbumHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ListenedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("ListeningHistory"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Subtitle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Position"); + + b.ToTable("Mixes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MixId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("MixId", "Position"); + + b.ToTable("MixItems"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Visibility") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PlayListId"); + + b.HasIndex("TrackId"); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Tracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId", "TrackId") + .IsUnique(); + + b.ToTable("TrackLikes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Upload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Upload"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedSizeBytes") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UploadIntents"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("ProfilePictureUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FollowedId") + .HasColumnType("bigint"); + + b.Property("FollowerId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("FollowedId"); + + b.HasIndex("FollowerId", "FollowedId") + .IsUnique(); + + b.ToTable("UserFollows"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("UserHasTrack"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("AlbumProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("PlayListProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("AudioKey") + .HasColumnType("text"); + + b.Property("AudioProcessed") + .HasColumnType("boolean"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("PictureProcessed") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("TrackProcessingState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + b.Navigation("Owner"); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Album", "Album") + .WithMany("AlbumTracks") + .HasForeignKey("AlbumId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("AlbumTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Album"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("ListeningHistories") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("ListeningHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.HasOne("Musify.Domain.Entities.Mix", "Mix") + .WithMany("Items") + .HasForeignKey("MixId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Mix"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.PlayListPictures", "Pictures", b1 => + { + b1.Property("PlayListId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("PlayListId"); + + b1.ToTable("PlayLists"); + + b1.WithOwner() + .HasForeignKey("PlayListId"); + }); + + b.Navigation("Pictures"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackAudio", "Audio", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("DownloadRequested") + .HasColumnType("boolean") + .HasColumnName("DownloadRequested"); + + b1.Property("FolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("AudioFolderName"); + + b1.Property("LastRetryAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("LastRetryAt"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalAudioName"); + + b1.Property("RetryCount") + .HasColumnType("integer") + .HasColumnName("RetryCount"); + + b1.Property("TranscodeStatus") + .HasColumnType("integer") + .HasColumnName("AudioTranscodeProcessingStatus"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackPictures", "Pictures", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("ProcessingStatus") + .HasColumnType("integer") + .HasColumnName("PicturesProcessingStatus"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.Navigation("Audio") + .IsRequired(); + + b.Navigation("Owner"); + + b.Navigation("Pictures") + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.HasOne("Musify.Domain.Entities.User", "Followed") + .WithMany() + .HasForeignKey("FollowedId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Followed"); + + b.Navigation("Follower"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("UserTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("UserTracks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Navigation("AlbumTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("AlbumTracks"); + + b.Navigation("ListeningHistories"); + + b.Navigation("PlayListTracks"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayLists"); + + b.Navigation("UserTracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260915165436_RemoveYouTubeIntegration.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260915165436_RemoveYouTubeIntegration.cs new file mode 100644 index 0000000..e5c3c54 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260915165436_RemoveYouTubeIntegration.cs @@ -0,0 +1,381 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class RemoveYouTubeIntegration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Data cleanup first, while the External*/Local*/UserAlbums tables (and the loose + // MixItem columns) still exist to query/cascade from. Deleting the base Tracks/Albums + // rows for anything that only ever existed as an ExternalTrack/ExternalAlbum cascades + // (FKs are ON DELETE CASCADE) to their own ExternalTracks/ExternalAlbums row, TrackArtist, + // PlayListHasTrack, AlbumHasTrack, TrackLike, ListeningHistory, UserHasTrack and MixItems + // rows that referenced them — this is deliberately lossy: those rows only existed to back + // the YouTube integration being removed here. + migrationBuilder.Sql("""DELETE FROM "Tracks" WHERE "Id" IN (SELECT "Id" FROM "ExternalTracks");"""); + migrationBuilder.Sql("""DELETE FROM "Albums" WHERE "Id" IN (SELECT "Id" FROM "ExternalAlbums");"""); + + // Live YouTube search hits that were seeded into a mix but never materialized into a + // real track (MixItemSource.YouTube, no TrackId) — MixItem.TrackId is about to become + // required, so these rows can't be kept. + migrationBuilder.Sql("""DELETE FROM "MixItems" WHERE "TrackId" IS NULL;"""); + + migrationBuilder.AddColumn( + name: "OwnerUserId", + table: "Tracks", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.Sql(""" + UPDATE "Tracks" t SET "OwnerUserId" = lt."OwnerUserId" + FROM "LocalTracks" lt WHERE lt."Id" = t."Id"; + """); + + migrationBuilder.AddColumn( + name: "OwnerUserId", + table: "Albums", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.Sql(""" + UPDATE "Albums" a SET "OwnerUserId" = ua."OwnerUserId" + FROM "UserAlbums" ua WHERE ua."Id" = a."Id"; + """); + + migrationBuilder.AlterColumn( + name: "TrackId", + table: "MixItems", + type: "uuid", + nullable: false, + oldClrType: typeof(Guid), + oldType: "uuid", + oldNullable: true); + + migrationBuilder.DropColumn( + name: "Artist", + table: "MixItems"); + + migrationBuilder.DropColumn( + name: "DurationSeconds", + table: "MixItems"); + + migrationBuilder.DropColumn( + name: "IsExplicit", + table: "MixItems"); + + migrationBuilder.DropColumn( + name: "Source", + table: "MixItems"); + + migrationBuilder.DropColumn( + name: "ThumbnailUrl", + table: "MixItems"); + + migrationBuilder.DropColumn( + name: "Title", + table: "MixItems"); + + migrationBuilder.DropColumn( + name: "VideoId", + table: "MixItems"); + + migrationBuilder.DropTable( + name: "TrackArtist"); + + migrationBuilder.DropTable( + name: "ExternalTracks"); + + migrationBuilder.DropTable( + name: "LocalTracks"); + + migrationBuilder.DropTable( + name: "ExternalAlbums"); + + migrationBuilder.DropTable( + name: "UserAlbums"); + + migrationBuilder.DropTable( + name: "Artists"); + + migrationBuilder.CreateIndex( + name: "IX_Tracks_OwnerUserId", + table: "Tracks", + column: "OwnerUserId"); + + migrationBuilder.CreateIndex( + name: "IX_Albums_OwnerUserId", + table: "Albums", + column: "OwnerUserId"); + + migrationBuilder.AddForeignKey( + name: "FK_Albums_User_OwnerUserId", + table: "Albums", + column: "OwnerUserId", + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_Tracks_User_OwnerUserId", + table: "Tracks", + column: "OwnerUserId", + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Albums_User_OwnerUserId", + table: "Albums"); + + migrationBuilder.DropForeignKey( + name: "FK_Tracks_User_OwnerUserId", + table: "Tracks"); + + migrationBuilder.DropIndex( + name: "IX_Tracks_OwnerUserId", + table: "Tracks"); + + migrationBuilder.DropIndex( + name: "IX_Albums_OwnerUserId", + table: "Albums"); + + migrationBuilder.DropColumn( + name: "OwnerUserId", + table: "Tracks"); + + migrationBuilder.DropColumn( + name: "OwnerUserId", + table: "Albums"); + + migrationBuilder.AlterColumn( + name: "TrackId", + table: "MixItems", + type: "uuid", + nullable: true, + oldClrType: typeof(Guid), + oldType: "uuid"); + + migrationBuilder.AddColumn( + name: "Artist", + table: "MixItems", + type: "character varying(300)", + maxLength: 300, + nullable: true); + + migrationBuilder.AddColumn( + name: "DurationSeconds", + table: "MixItems", + type: "double precision", + nullable: false, + defaultValue: 0.0); + + migrationBuilder.AddColumn( + name: "IsExplicit", + table: "MixItems", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "Source", + table: "MixItems", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "ThumbnailUrl", + table: "MixItems", + type: "character varying(512)", + maxLength: 512, + nullable: true); + + migrationBuilder.AddColumn( + name: "Title", + table: "MixItems", + type: "character varying(300)", + maxLength: 300, + nullable: true); + + migrationBuilder.AddColumn( + name: "VideoId", + table: "MixItems", + type: "character varying(64)", + maxLength: 64, + nullable: true); + + migrationBuilder.CreateTable( + name: "Artists", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ExternalId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + NormalizedName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Artists", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ExternalAlbums", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ExternalId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Source = table.Column(type: "integer", nullable: false), + ThumbnailUrl = table.Column(type: "character varying(512)", maxLength: 512, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ExternalAlbums", x => x.Id); + table.ForeignKey( + name: "FK_ExternalAlbums_Albums_Id", + column: x => x.Id, + principalTable: "Albums", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ExternalTracks", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ExternalId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Source = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ExternalTracks", x => x.Id); + table.ForeignKey( + name: "FK_ExternalTracks_Tracks_Id", + column: x => x.Id, + principalTable: "Tracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "LocalTracks", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OwnerUserId = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LocalTracks", x => x.Id); + table.ForeignKey( + name: "FK_LocalTracks_Tracks_Id", + column: x => x.Id, + principalTable: "Tracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_LocalTracks_User_OwnerUserId", + column: x => x.OwnerUserId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "UserAlbums", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OwnerUserId = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserAlbums", x => x.Id); + table.ForeignKey( + name: "FK_UserAlbums_Albums_Id", + column: x => x.Id, + principalTable: "Albums", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserAlbums_User_OwnerUserId", + column: x => x.OwnerUserId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "TrackArtist", + columns: table => new + { + TrackId = table.Column(type: "uuid", nullable: false), + ArtistId = table.Column(type: "uuid", nullable: false), + Position = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TrackArtist", x => new { x.TrackId, x.ArtistId }); + table.ForeignKey( + name: "FK_TrackArtist_Artists_ArtistId", + column: x => x.ArtistId, + principalTable: "Artists", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_TrackArtist_ExternalTracks_TrackId", + column: x => x.TrackId, + principalTable: "ExternalTracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Artists_ExternalId", + table: "Artists", + column: "ExternalId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ExternalAlbums_Source_ExternalId", + table: "ExternalAlbums", + columns: new[] { "Source", "ExternalId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ExternalTracks_Source_ExternalId", + table: "ExternalTracks", + columns: new[] { "Source", "ExternalId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_LocalTracks_OwnerUserId", + table: "LocalTracks", + column: "OwnerUserId"); + + migrationBuilder.CreateIndex( + name: "IX_TrackArtist_ArtistId", + table: "TrackArtist", + column: "ArtistId"); + + migrationBuilder.CreateIndex( + name: "IX_UserAlbums_OwnerUserId", + table: "UserAlbums", + column: "OwnerUserId"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index 001debd..9c9c158 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -211,6 +211,9 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasMaxLength(200) .HasColumnType("character varying(200)"); + b.Property("OwnerUserId") + .HasColumnType("bigint"); + b.Property("ReleaseYear") .HasColumnType("integer"); @@ -224,9 +227,9 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasKey("Id"); - b.ToTable("Albums"); + b.HasIndex("OwnerUserId"); - b.UseTptMappingStrategy(); + b.ToTable("Albums"); }); modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => @@ -257,41 +260,6 @@ namespace Musify.Infrastructure.Persistence.Migrations b.ToTable("AlbumHasTrack"); }); - modelBuilder.Entity("Musify.Domain.Entities.Artist", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ExternalId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("NormalizedName") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("ExternalId") - .IsUnique(); - - b.ToTable("Artists"); - }); - modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => { b.Property("Id") @@ -356,40 +324,15 @@ namespace Musify.Infrastructure.Persistence.Migrations .ValueGeneratedOnAdd() .HasColumnType("uuid"); - b.Property("Artist") - .HasMaxLength(300) - .HasColumnType("character varying(300)"); - - b.Property("DurationSeconds") - .HasColumnType("double precision"); - - b.Property("IsExplicit") - .HasColumnType("boolean"); - b.Property("MixId") .HasColumnType("uuid"); b.Property("Position") .HasColumnType("integer"); - b.Property("Source") - .HasColumnType("integer"); - - b.Property("ThumbnailUrl") - .HasMaxLength(512) - .HasColumnType("character varying(512)"); - - b.Property("Title") - .HasMaxLength(300) - .HasColumnType("character varying(300)"); - - b.Property("TrackId") + b.Property("TrackId") .HasColumnType("uuid"); - b.Property("VideoId") - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - b.HasKey("Id"); b.HasIndex("TrackId"); @@ -490,6 +433,9 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasMaxLength(200) .HasColumnType("character varying(200)"); + b.Property("OwnerUserId") + .HasColumnType("bigint"); + b.Property("Title") .IsRequired() .HasMaxLength(200) @@ -500,27 +446,9 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasKey("Id"); + b.HasIndex("OwnerUserId"); + b.ToTable("Tracks"); - - b.UseTptMappingStrategy(); - }); - - modelBuilder.Entity("Musify.Domain.Entities.TrackArtist", b => - { - b.Property("TrackId") - .HasColumnType("uuid"); - - b.Property("ArtistId") - .HasColumnType("uuid"); - - b.Property("Position") - .HasColumnType("integer"); - - b.HasKey("TrackId", "ArtistId"); - - b.HasIndex("ArtistId"); - - b.ToTable("TrackArtist"); }); modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => @@ -802,70 +730,6 @@ namespace Musify.Infrastructure.Persistence.Migrations b.ToTable("TrackProcessingState"); }); - modelBuilder.Entity("Musify.Domain.Entities.ExternalAlbum", b => - { - b.HasBaseType("Musify.Domain.Entities.Album"); - - b.Property("ExternalId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("Source") - .HasColumnType("integer"); - - b.Property("ThumbnailUrl") - .HasMaxLength(512) - .HasColumnType("character varying(512)"); - - b.HasIndex("Source", "ExternalId") - .IsUnique(); - - b.ToTable("ExternalAlbums", (string)null); - }); - - modelBuilder.Entity("Musify.Domain.Entities.UserAlbum", b => - { - b.HasBaseType("Musify.Domain.Entities.Album"); - - b.Property("OwnerUserId") - .HasColumnType("bigint"); - - b.HasIndex("OwnerUserId"); - - b.ToTable("UserAlbums", (string)null); - }); - - modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => - { - b.HasBaseType("Musify.Domain.Entities.Track"); - - b.Property("ExternalId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("Source") - .HasColumnType("integer"); - - b.HasIndex("Source", "ExternalId") - .IsUnique(); - - b.ToTable("ExternalTracks", (string)null); - }); - - modelBuilder.Entity("Musify.Domain.Entities.LocalTrack", b => - { - b.HasBaseType("Musify.Domain.Entities.Track"); - - b.Property("OwnerUserId") - .HasColumnType("bigint"); - - b.HasIndex("OwnerUserId"); - - b.ToTable("LocalTracks", (string)null); - }); - modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => { b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) @@ -880,6 +744,12 @@ namespace Musify.Infrastructure.Persistence.Migrations modelBuilder.Entity("Musify.Domain.Entities.Album", b => { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 => { b1.Property("AlbumId") @@ -917,6 +787,8 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasForeignKey("AlbumId"); }); + b.Navigation("Owner"); + b.Navigation("Pictures"); }); @@ -980,7 +852,8 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasOne("Musify.Domain.Entities.Track", "Track") .WithMany() .HasForeignKey("TrackId") - .OnDelete(DeleteBehavior.Cascade); + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); b.Navigation("Mix"); @@ -1058,6 +931,12 @@ namespace Musify.Infrastructure.Persistence.Migrations modelBuilder.Entity("Musify.Domain.Entities.Track", b => { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + b.OwnsOne("Musify.Domain.ValueObjects.TrackAudio", "Audio", b1 => { b1.Property("TrackId") @@ -1137,29 +1016,12 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Navigation("Audio") .IsRequired(); + b.Navigation("Owner"); + b.Navigation("Pictures") .IsRequired(); }); - modelBuilder.Entity("Musify.Domain.Entities.TrackArtist", b => - { - b.HasOne("Musify.Domain.Entities.Artist", "Artist") - .WithMany("TrackArtists") - .HasForeignKey("ArtistId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Musify.Domain.Entities.ExternalTrack", "Track") - .WithMany("TrackArtists") - .HasForeignKey("TrackId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Artist"); - - b.Navigation("Track"); - }); - modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => { b.HasOne("Musify.Domain.Entities.Track", "Track") @@ -1228,68 +1090,11 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Navigation("User"); }); - modelBuilder.Entity("Musify.Domain.Entities.ExternalAlbum", b => - { - b.HasOne("Musify.Domain.Entities.Album", null) - .WithOne() - .HasForeignKey("Musify.Domain.Entities.ExternalAlbum", "Id") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Musify.Domain.Entities.UserAlbum", b => - { - b.HasOne("Musify.Domain.Entities.Album", null) - .WithOne() - .HasForeignKey("Musify.Domain.Entities.UserAlbum", "Id") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Musify.Domain.Entities.User", "Owner") - .WithMany() - .HasForeignKey("OwnerUserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Owner"); - }); - - modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => - { - b.HasOne("Musify.Domain.Entities.Track", null) - .WithOne() - .HasForeignKey("Musify.Domain.Entities.ExternalTrack", "Id") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Musify.Domain.Entities.LocalTrack", b => - { - b.HasOne("Musify.Domain.Entities.Track", null) - .WithOne() - .HasForeignKey("Musify.Domain.Entities.LocalTrack", "Id") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Musify.Domain.Entities.User", "Owner") - .WithMany() - .HasForeignKey("OwnerUserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Owner"); - }); - modelBuilder.Entity("Musify.Domain.Entities.Album", b => { b.Navigation("AlbumTracks"); }); - modelBuilder.Entity("Musify.Domain.Entities.Artist", b => - { - b.Navigation("TrackArtists"); - }); - modelBuilder.Entity("Musify.Domain.Entities.Mix", b => { b.Navigation("Items"); @@ -1319,11 +1124,6 @@ namespace Musify.Infrastructure.Persistence.Migrations b.Navigation("UserTracks"); }); - - modelBuilder.Entity("Musify.Domain.Entities.ExternalTrack", b => - { - b.Navigation("TrackArtists"); - }); #pragma warning restore 612, 618 } } diff --git a/backend/Core/Musify.Infrastructure/Services/DisabledYouTubeMusicService.cs b/backend/Core/Musify.Infrastructure/Services/DisabledYouTubeMusicService.cs deleted file mode 100644 index 783bc0d..0000000 --- a/backend/Core/Musify.Infrastructure/Services/DisabledYouTubeMusicService.cs +++ /dev/null @@ -1,37 +0,0 @@ -using ErrorOr; -using Musify.Application.Contracts; - -namespace Musify.Infrastructure.Services -{ - /// Stands in for when - /// is false, - /// so the source can be turned off without a client ever being constructed. Every read fails - /// with the same error; callers already treat a failed - /// YouTube call as "unavailable" (an HTTP error for direct requests, a skipped candidate for - /// mix generation), so nothing downstream needs to know the source is disabled specifically. - public sealed class DisabledYouTubeMusicService : IYouTubeMusicService - { - private static readonly Error DisabledError = - Error.Forbidden(description: "The YouTube Music source is disabled."); - - public Task> SearchSongsAsync(string query, string continuationToken, CancellationToken cancellationToken) => - Task.FromResult>(DisabledError); - - public Task> SearchAlbumsAsync(string query, string continuationToken, CancellationToken cancellationToken) => - Task.FromResult>(DisabledError); - - public Task> GetAlbumAsync(string albumId, CancellationToken cancellationToken) => - Task.FromResult>(DisabledError); - - public Task> GetAudioStreamAsync(string videoId, CancellationToken cancellationToken) => - Task.FromResult>(DisabledError); - - public Task> GetSongAsync(string videoId, CancellationToken cancellationToken) => - Task.FromResult>(DisabledError); - - // Never reached in practice: every caller only reaches this after a successful search or - // song lookup above, neither of which can succeed while disabled. Passing the URL through - // unchanged is the safest thing to do if it's ever called anyway. - public string ResolveArtworkUrl(string thumbnailUrl) => thumbnailUrl; - } -} diff --git a/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs index 641b45d..6151b16 100644 --- a/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs @@ -1,7 +1,6 @@ using Amazon.S3; using Hangfire; using Hangfire.PostgreSql; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -99,33 +98,6 @@ namespace Musify.Infrastructure.Services return serviceDescriptors; } - public static IServiceCollection AddYouTubeMusicService(this IServiceCollection serviceDescriptors, IConfiguration configuration) - { - serviceDescriptors.AddValidatedOptions(configuration, YouTubeConfiguration.SectionName); - - serviceDescriptors.AddMemoryCache(); - serviceDescriptors.AddSingleton(serviceProvider => - { - var youTubeConfiguration = serviceProvider.GetRequiredService(); - if (!youTubeConfiguration.Enabled) - return new DisabledYouTubeMusicService(); - - return new YouTubeMusicService( - youTubeConfiguration, - serviceProvider.GetRequiredService(), - serviceProvider.GetRequiredService>()); - } ); - return serviceDescriptors; - } - - public static IServiceCollection AddYouTubeDownloader(this IServiceCollection serviceDescriptors, IConfiguration configuration) - { - serviceDescriptors.AddValidatedOptions(configuration, YtDlpConfiguration.SectionName); - - serviceDescriptors.AddScoped(); - return serviceDescriptors; - } - public static IServiceCollection AddAudioTranscoder(this IServiceCollection serviceDescriptors, IConfiguration configuration) { serviceDescriptors diff --git a/backend/Core/Musify.Infrastructure/Services/YouTubeDownloaderService.cs b/backend/Core/Musify.Infrastructure/Services/YouTubeDownloaderService.cs deleted file mode 100644 index ea1c70f..0000000 --- a/backend/Core/Musify.Infrastructure/Services/YouTubeDownloaderService.cs +++ /dev/null @@ -1,89 +0,0 @@ -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Infrastructure.Configuration; -using System.Diagnostics; - -namespace Musify.Infrastructure.Services -{ - public class YouTubeDownloaderService( - ILogger logger, - YtDlpConfiguration configuration) - : IYouTubeDownloaderService - { - public async Task DownloadAudioAsync(string videoId, string destinationFilePath, CancellationToken cancellationToken) - { - var destinationDirectory = Path.GetDirectoryName(destinationFilePath); - if (!string.IsNullOrWhiteSpace(destinationDirectory)) - Directory.CreateDirectory(destinationDirectory); - - var additionalArguments = string.IsNullOrWhiteSpace(configuration.AdditionalArguments) - ? string.Empty - : $" {configuration.AdditionalArguments.Trim()}"; - - var arguments = - $"-f {configuration.Format} " + - "--no-playlist " + - $"-o \"{destinationFilePath}\"" + - additionalArguments + - $" -- {videoId}"; - - using var process = new Process - { - StartInfo = new ProcessStartInfo - { - FileName = configuration.ExecutableName, - WorkingDirectory = destinationDirectory ?? Environment.CurrentDirectory, - Arguments = arguments, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - } - }; - - process.Start(); - - logger.LogInformation("yt-dlp started (PID: {ProcessId} ) for video {VideoId}", process.Id, videoId); - - var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); - - using var linkedCancellationTokens = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - linkedCancellationTokens.CancelAfter(configuration.DownloadTimeout); - - try - { - await process.WaitForExitAsync(linkedCancellationTokens.Token); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - logger.LogError("yt-dlp timed out after {Timeout} for video {VideoId} (PID: {ProcessId} )", - configuration.DownloadTimeout, videoId, process.Id); - - TryKillProcess(process); - throw new TimeoutException($"yt-dlp execution exceeded timeout {configuration.DownloadTimeout}."); - } - - var standardError = await errorTask; - var standardOutput = await outputTask; - - logger.LogDebug("yt-dlp finished (PID: {ProcessId} ). Exit code: {ExitCode}, Output: {OutputLength}B, Error: {ErrorLength}B", - process.Id, process.ExitCode, standardOutput.Length, standardError.Length); - - return new YouTubeDownloadResult(process.ExitCode, standardError); - } - - void TryKillProcess(Process process) - { - try - { - if (!process.HasExited) - process.Kill(); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to kill yt-dlp process (PID: {ProcessId} )", process.Id); - } - } - } -} diff --git a/backend/Core/Musify.Infrastructure/Services/YouTubeMusicService.cs b/backend/Core/Musify.Infrastructure/Services/YouTubeMusicService.cs deleted file mode 100644 index 3444a7f..0000000 --- a/backend/Core/Musify.Infrastructure/Services/YouTubeMusicService.cs +++ /dev/null @@ -1,310 +0,0 @@ -using ErrorOr; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Infrastructure.Configuration; -using Musify.Infrastructure.Helpers; -using YouTubeMusicAPI.Client; -using YouTubeMusicAPI.Models.Search; -using YouTubeMusicAPI.Models.Streaming; -using YouTubeMusicAPI.Pagination; - -namespace Musify.Infrastructure.Services -{ - public class YouTubeMusicService(YouTubeConfiguration configuration, IMemoryCache cache, ILogger logger) : IYouTubeMusicService - { - private readonly YouTubeMusicClient client = new YouTubeMusicClient(geographicalLocation: configuration.GeographicalLocation); - private readonly SingleFlightCache singleFlight = new SingleFlightCache(cache); - - private sealed record CachedStreamInfo(string Url, DateTime ExpiresAtUtc); - - public async Task> SearchSongsAsync(string query, string continuationToken, CancellationToken cancellationToken) - { - if (!string.IsNullOrEmpty(continuationToken)) - return await FetchSearchPageAsync(query, continuationToken, cancellationToken); - - return await singleFlight.GetOrCreateAsync( - SearchResultsCacheKey(query), - TimeSpan.FromSeconds(configuration.SearchResultsCacheSeconds), - () => FetchSearchPageAsync(query, continuationToken, cancellationToken)); - } - - private sealed record SearchPage(IReadOnlyList Results, string NextToken); - - private async Task> FetchPageAsync( - string query, - SearchCategory category, - string continuationToken, - CancellationToken cancellationToken) - { - PaginatedAsyncEnumerable? paginator; - if (string.IsNullOrEmpty(continuationToken)) - { - paginator = client.SearchAsync(query, category); - } - else if (!cache.TryGetValue(SearchCacheKey(continuationToken), out paginator) || paginator is null) - { - return Error.NotFound(description: "The search continuation has expired."); - } - - IReadOnlyList results; - try - { - results = await paginator.FetchNextPageAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "YouTube Music {Category} search failed for query '{Query}'", category, query); - return Error.Failure(description: $"YouTube Music search failed: {exception.Message}"); - } - - var nextToken = string.Empty; - if (paginator.HasMore) - { - nextToken = Guid.NewGuid().ToString("N"); - cache.Set( - SearchCacheKey(nextToken), - paginator, - TimeSpan.FromSeconds(configuration.SearchContinuationCacheSeconds)); - } - - return new SearchPage(results, nextToken); - } - - private async Task> FetchSearchPageAsync(string query, string continuationToken, CancellationToken cancellationToken) - { - var page = await FetchPageAsync(query, SearchCategory.Songs, continuationToken, cancellationToken); - if (page.IsError) - return page.Errors; - - var items = page.Value.Results - .OfType() - .Select(song => new YouTubeSongResult( - song.Id, - song.Name, - string.Join(", ", song.Artists.Select(artist => artist.Name)), - song.Album?.Name ?? string.Empty, - (int)song.Duration.TotalSeconds, - PickThumbnail(song.Thumbnails), - song.IsExplicit, - song.Artists - .Select(artist => new YouTubeArtistRef( - string.IsNullOrEmpty(artist.Id)? null: artist.Id, - artist.Name)) - .ToList())) - .ToList(); - - return new YouTubeSearchResult(items, page.Value.NextToken); - } - - public async Task> SearchAlbumsAsync(string query, string continuationToken, CancellationToken cancellationToken) - { - if (!string.IsNullOrEmpty(continuationToken)) - return await FetchAlbumSearchPageAsync(query, continuationToken, cancellationToken); - - return await singleFlight.GetOrCreateAsync( - AlbumSearchResultsCacheKey(query), - TimeSpan.FromSeconds(configuration.SearchResultsCacheSeconds), - () => FetchAlbumSearchPageAsync(query, continuationToken, cancellationToken)); - } - - private async Task> FetchAlbumSearchPageAsync(string query, string continuationToken, CancellationToken cancellationToken) - { - var page = await FetchPageAsync(query, SearchCategory.Albums, continuationToken, cancellationToken); - if (page.IsError) - return page.Errors; - - var items = page.Value.Results - .OfType() - .Select(ToAlbumResult) - .ToList(); - - return new YouTubeAlbumSearchResult(items, page.Value.NextToken); - } - - public async Task> GetAlbumAsync(string albumId, CancellationToken cancellationToken) - { - return await singleFlight.GetOrCreateAsync( - AlbumCacheKey(albumId), - TimeSpan.FromSeconds(configuration.SongInfoCacheSeconds), - () => FetchAlbumAsync(albumId, cancellationToken)); - } - - private async Task> FetchAlbumAsync(string albumId, CancellationToken cancellationToken) - { - try - { - var browseId = await client.GetAlbumBrowseIdAsync(albumId, cancellationToken); - var info = await client.GetAlbumInfoAsync(browseId, cancellationToken); - - var album = new YouTubeAlbumResult( - albumId, - info.Name, - string.Join(", ", info.Artists.Select(artist => artist.Name)), - PickThumbnail(info.Thumbnails), - info.ReleaseYear, - info.IsSingle, - info.IsEp, - info.Artists - .Select(artist => new YouTubeArtistRef( - string.IsNullOrEmpty(artist.Id)? null: artist.Id, - artist.Name)) - .ToList()); - - var tracks = info.Songs - .Select((song, index) => new YouTubeAlbumTrack( - song.Id ?? string.Empty, - song.Name, - (int)song.Duration.TotalSeconds, - song.SongNumber ?? index + 1, - song.IsExplicit)) - .Where(track => !string.IsNullOrEmpty(track.VideoId)) - .ToList(); - - return new YouTubeAlbumDetail( - album, - StripWikipediaAttribution(info.Description), - (int)info.Duration.TotalSeconds, - tracks); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to load YouTube album '{AlbumId}'", albumId); - return Error.Failure(description: $"Failed to load YouTube album '{albumId}': {exception.Message}"); - } - } - - private static string StripWikipediaAttribution(string? description) - { - if (string.IsNullOrEmpty(description)) - return string.Empty; - - var markerIndex = description.IndexOf("From Wikipedia", StringComparison.OrdinalIgnoreCase); - var cleaned = markerIndex >= 0 ? description[..markerIndex] : description; - return cleaned.TrimEnd(); - } - - private YouTubeAlbumResult ToAlbumResult(AlbumSearchResult album) => - new( - album.Id, - album.Name, - string.Join(", ", album.Artists.Select(artist => artist.Name)), - PickThumbnail(album.Thumbnails), - album.ReleaseYear, - album.IsSingle, - album.IsEp, - album.Artists - .Select(artist => new YouTubeArtistRef( - string.IsNullOrEmpty(artist.Id) ? null : artist.Id, - artist.Name)) - .ToList()); - - public async Task> GetAudioStreamAsync(string videoId, CancellationToken cancellationToken) - { - var now = DateTime.UtcNow; - if (cache.TryGetValue(StreamCacheKey(videoId), out CachedStreamInfo? cached) - && cached != null - && cached.ExpiresAtUtc > now) - { - return new YouTubeStreamInfo(cached.Url, (int)(cached.ExpiresAtUtc - now).TotalSeconds); - } - - StreamingData streamingData; - try - { - streamingData = await client.GetStreamingDataAsync(videoId, cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to resolve YouTube stream for '{VideoId}'", videoId); - return Error.Failure(description: $"Failed to resolve YouTube stream for '{videoId}': {exception.Message}"); - } - - var audioStream = streamingData.StreamInfo - .OfType() - .OrderByDescending(stream => stream.Bitrate) - .FirstOrDefault(); - - if (audioStream is null || string.IsNullOrEmpty(audioStream.Url)) - return Error.NotFound(description: $"No audio stream available for '{videoId}'."); - - var expiresInSeconds = Math.Min( - configuration.StreamUrlCacheSeconds, - (int)streamingData.ExpiresIn.TotalSeconds); - - if (expiresInSeconds > 0) - { - cache.Set( - StreamCacheKey(videoId), - new CachedStreamInfo(audioStream.Url, now.AddSeconds(expiresInSeconds)), - TimeSpan.FromSeconds(expiresInSeconds)); - } - - return new YouTubeStreamInfo(audioStream.Url, expiresInSeconds); - } - - public async Task> GetSongAsync(string videoId, CancellationToken cancellationToken) - { - return await singleFlight.GetOrCreateAsync( - SongInfoCacheKey(videoId), - TimeSpan.FromSeconds(configuration.SongInfoCacheSeconds), - () => FetchSongAsync(videoId, cancellationToken)); - } - - private async Task> FetchSongAsync(string videoId, CancellationToken cancellationToken) - { - try - { - var info = await client.GetSongVideoInfoAsync(videoId, cancellationToken); - - return new YouTubeSongResult( - videoId, - info.Name, - string.Join(", ", info.Artists.Select(artist => artist.Name)), - info.Album?.Name ?? string.Empty, - (int)info.Duration.TotalSeconds, - PickThumbnail(info.Thumbnails), - info.IsExplicit, - info.Artists - .Select(artist => new YouTubeArtistRef( - string.IsNullOrEmpty(artist.Id)? null: artist.Id, - artist.Name)) - .ToList()); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to load YouTube song info for '{VideoId}'", videoId); - return Error.Failure(description: $"Failed to load YouTube song info for '{videoId}': {exception.Message}"); - } - } - - public string ResolveArtworkUrl(string thumbnailUrl) => - YouTubeThumbNailHelper.WithSize(thumbnailUrl, configuration.ArtworkSize); - - private string PickThumbnail(IEnumerable thumbnails) - { - var ordered = thumbnails.OrderBy(thumbnail => thumbnail.Width).ToList(); - - var square = ordered.Where(thumbnail => YouTubeThumbNailHelper.IsSquare(thumbnail.Url)).ToList(); - var candidates = square.Count > 0 ? square : ordered; - - var chosen = candidates.FirstOrDefault(thumbnail => thumbnail.Width >= configuration.ThumbnailSize) - ?? candidates.LastOrDefault(); - - return chosen ==null? string.Empty - : YouTubeThumbNailHelper.WithSize(chosen.Url, configuration.ThumbnailSize); - } - - private static string SearchCacheKey(string token) => $"yt-search:{token}"; - - private static string SearchResultsCacheKey(string query) => $"yt-search-results:{query.Trim().ToLowerInvariant()}"; - - private static string AlbumSearchResultsCacheKey(string query) => $"yt-album-search:{query.Trim().ToLowerInvariant()}"; - - private static string AlbumCacheKey(string albumId) => $"yt-album:{albumId}"; - - private static string SongInfoCacheKey(string videoId) => $"yt-song:{videoId}"; - - private static string StreamCacheKey(string videoId) => $"yt-stream:{videoId}"; - } -} diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 50b1fe8..55b7a8b 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -49,7 +49,6 @@ - diff --git a/backend/Hosts/Musify.Api/AppSettings.Development.json b/backend/Hosts/Musify.Api/AppSettings.Development.json index e81392b..9ddf45d 100644 --- a/backend/Hosts/Musify.Api/AppSettings.Development.json +++ b/backend/Hosts/Musify.Api/AppSettings.Development.json @@ -36,9 +36,6 @@ "OpenTelemetry": { "OtlpEndpoint": "http://localhost:4317" }, - "YouTube": { - "GeographicalLocation": "ES" - }, "Workers": { "Routes": { "TemporaryFilesDirectory": "../../../deploy/.tmp" diff --git a/backend/Hosts/Musify.Api/AppSettings.json b/backend/Hosts/Musify.Api/AppSettings.json index b26e236..4c0c5aa 100644 --- a/backend/Hosts/Musify.Api/AppSettings.json +++ b/backend/Hosts/Musify.Api/AppSettings.json @@ -53,21 +53,8 @@ "AnonymousFragmentSeconds": 0, "EstimatedAudioBytesPerSecond": 16000 }, - "YouTube": { - "Enabled": true, - "GeographicalLocation": "US", - "StreamUrlCacheSeconds": 1800, - "SearchContinuationCacheSeconds": 600, - "SearchResultsCacheSeconds": 900, - "SongInfoCacheSeconds": 86400, - "ThumbnailSize": 240, - "ArtworkSize": 544 - }, "Mix": { - "ArtistMixCount": 3, - "ItemsPerMix": 12, - "SeedArtistCount": 3, - "YouTubeResultsPerSeed": 12 + "ItemsPerMix": 12 }, "PlayList": { "Routes": { diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Likes/ToggleLikeRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Likes/ToggleLikeRequest.cs index 90f8f85..2ce34e2 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Likes/ToggleLikeRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Likes/ToggleLikeRequest.cs @@ -1,8 +1,3 @@ -using Musify.Domain.ValueObjects; - namespace Musify.Api.DataTransferObjects.Likes; -/// Toggle a like on a track already in the catalog by , or one from an -/// external source by + (provisioning it in the -/// background if needed) — exactly one of the two must be given. -public record ToggleLikeRequest(Guid? TrackId, TrackSource? Source, string? ExternalId); +public record ToggleLikeRequest(Guid TrackId); diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddPlayListTrackRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddPlayListTrackRequest.cs index 67b29d4..d000ed6 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddPlayListTrackRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/AddPlayListTrackRequest.cs @@ -1,8 +1,3 @@ -using Musify.Domain.ValueObjects; - namespace Musify.Api.DataTransferObjects.PlayLists; -/// Add a track already in the catalog by , or one from an external -/// source by + (provisioning it in the background if -/// needed) — exactly one of the two must be given. -public record AddPlayListTrackRequest(Guid? TrackId, TrackSource? Source, string? ExternalId); +public record AddPlayListTrackRequest(Guid TrackId); diff --git a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs index e4d6385..fb02cb4 100644 --- a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs @@ -8,8 +8,6 @@ using Musify.Application.Albums.Responses; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -using Musify.Application.YouTube; -using Musify.Domain.ValueObjects; namespace Musify.Api.Endpoints; @@ -22,7 +20,7 @@ public static class AlbumEndpoints group.MapGet("/", GetAlbums) .WithName("GetAlbums") - .WithSummary("Get Paginated Albums, Combined With Live YouTube Music Results When A Title Filter Is Given.") + .WithSummary("Get Paginated Albums, Optionally Filtered By Title.") .Produces(); group.MapGet("/{id}", GetAlbumById) @@ -31,12 +29,6 @@ public static class AlbumEndpoints .Produces() .Produces(StatusCodes.Status404NotFound); - group.MapGet("/external/{source}/{externalId}", GetExternalAlbum) - .WithName("GetExternalAlbum") - .WithSummary("Get An Album From An External Source (Currently Only YouTube Music) With Its Tracklist.") - .Produces() - .Produces(StatusCodes.Status404NotFound); - group.MapGet("/users/{userId}", GetAlbumsByUserId) .WithName("GetAlbumsByUserId") .WithSummary("Get Paginated Albums For A User.") @@ -125,10 +117,9 @@ public static class AlbumEndpoints CancellationToken cancellationToken, string? title, int pageNumber = 1, - int pageSize = 10, - string? youtubeContinuationToken = null) + int pageSize = 10) { - var result = await mediator.Send(new GetAlbumsQuery(title, pageNumber, pageSize, youtubeContinuationToken), cancellationToken); + var result = await mediator.Send(new GetAlbumsQuery(title, pageNumber, pageSize), cancellationToken); return result.ToHttpResult(); } @@ -141,19 +132,6 @@ public static class AlbumEndpoints return result.ToHttpResult(); } - private static async Task GetExternalAlbum( - IMediator mediator, - string source, - string externalId, - CancellationToken cancellationToken) - { - if (!Enum.TryParse(source, ignoreCase: true, out var trackSource) || trackSource != TrackSource.YouTube) - return Results.NotFound($"Unknown or unsupported source '{source}'."); - - var result = await mediator.Send(new GetYouTubeAlbumQuery(externalId), cancellationToken); - return result.ToHttpResult(); - } - private static async Task GetAlbumsByUserId( IMediator mediator, long userId, diff --git a/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs index c707cd9..ac74df6 100644 --- a/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs @@ -25,10 +25,8 @@ public static class LikeEndpoints group.MapPost("/toggle", ToggleTrackLike) .WithName("ToggleTrackLike") - .WithSummary("Like Or Unlike A Track, By Id Or By External Source, Provisioning It In The Background If Needed.") - .AddEndpointFilter>() + .WithSummary("Like Or Unlike A Track By Id.") .Produces() - .ProducesValidationProblem() .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); @@ -53,7 +51,7 @@ public static class LikeEndpoints CancellationToken cancellationToken) { var result = await mediator.Send( - new ToggleTrackLikeCommand(currentUser.RequiredId, request.TrackId, request.Source, request.ExternalId), + new ToggleTrackLikeCommand(currentUser.RequiredId, request.TrackId), cancellationToken); return result.ToHttpResult(); diff --git a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs index 3004fbd..8ab6dc2 100644 --- a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs @@ -5,7 +5,6 @@ using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; using Musify.Application.Shared; using Musify.Application.Contracts; -using Musify.Domain.ValueObjects; using Musify.Api.Authentication; using Musify.Api.DataTransferObjects.PlayLists; using Musify.Api.Extensions; @@ -88,11 +87,9 @@ public static class PlayListEndpoints group.MapPost("/{playlistId}/tracks", AddPlayListTrack) .WithName("AddPlayListTrack") - .WithSummary("Add A Track To A PlayList, By Id Or By External Source, Provisioning It In The Background If Needed.") - .AddEndpointFilter>() + .WithSummary("Add One Of Your Tracks To A PlayList.") .RequireAuthorization() .Produces(StatusCodes.Status201Created) - .ProducesValidationProblem() .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict); @@ -227,25 +224,14 @@ public static class PlayListEndpoints AddPlayListTrackRequest request, CancellationToken cancellationToken) { - if (request.TrackId is { } trackId) - { - var addResult = await mediator.Send( - new AddTrackToPlayListCommand(currentUser.RequiredId, playlistId, trackId), - cancellationToken); - if (addResult.IsError) - return addResult.ToHttpResult(); - - var trackResult = await mediator.Send(new GetTrackByIdQuery(trackId), cancellationToken); - return trackResult.ToCreatedResult(track => $"/tracks/{track.Id}"); - } - - // The validator guarantees Source/ExternalId are set whenever TrackId isn't, and that Source - // is one of the currently supported external sources (only YouTube for now). - var result = await mediator.Send( - new AddYouTubeTrackToPlayListCommand(currentUser.RequiredId, playlistId, request.ExternalId!), + var addResult = await mediator.Send( + new AddTrackToPlayListCommand(currentUser.RequiredId, playlistId, request.TrackId), cancellationToken); + if (addResult.IsError) + return addResult.ToHttpResult(); - return result.ToCreatedResult(track => $"/tracks/{track.Id}"); + var trackResult = await mediator.Send(new GetTrackByIdQuery(request.TrackId), cancellationToken); + return trackResult.ToCreatedResult(track => $"/tracks/{track.Id}"); } private static async Task RemoveTrackFromPlayList( diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index 0b356ca..3a44359 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -3,9 +3,6 @@ using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; using Musify.Application.Shared; using Musify.Application.Contracts; -using Musify.Application.YouTube; -using Musify.Application.YouTube.Responses; -using Musify.Domain.ValueObjects; using Musify.Api.Authentication; using Musify.Api.DataTransferObjects.Tracks; using Musify.Api.Extensions; @@ -22,7 +19,7 @@ public static class TrackEndpoints group.MapGet("/", GetTracks) .WithName("GetTracks") - .WithSummary("Get Paginated Tracks, Combined With Live YouTube Music Results When A Name Filter Is Given.") + .WithSummary("Get Paginated Tracks, Optionally Filtered By Name.") .Produces(); group.MapGet("/{id}", GetTrackById) @@ -44,14 +41,6 @@ public static class TrackEndpoints .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); - group.MapGet("/external/{source}/{externalId}/stream", GetExternalTrackStream) - .WithName("GetExternalTrackStream") - .WithSummary("Resolve Playback For A Track From An External Source (Currently Only YouTube Music): Server Stream If Downloaded, Direct Stream From The Source Otherwise. Works Anonymously When The Playback Configuration Allows It.") - .Produces() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound) - .Produces(StatusCodes.Status409Conflict); - group.MapGet("/users/{userId}", GetTracksByUserId) .WithName("GetTracksByUserId") .WithSummary("Get Paginated Tracks For A User.") @@ -96,10 +85,9 @@ public static class TrackEndpoints CancellationToken cancellationToken, string? name, int pageNumber = 1, - int pageSize = 10, - string? youtubeContinuationToken = null) + int pageSize = 10) { - var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize, youtubeContinuationToken), cancellationToken); + var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize), cancellationToken); return result.ToHttpResult(); } @@ -140,20 +128,6 @@ public static class TrackEndpoints return result.ToHttpResult(); } - private static async Task GetExternalTrackStream( - IMediator mediator, - CurrentUser currentUser, - string source, - string externalId, - CancellationToken cancellationToken) - { - if (!Enum.TryParse(source, ignoreCase: true, out var trackSource) || trackSource != TrackSource.YouTube) - return Results.NotFound($"Unknown or unsupported source '{source}'."); - - var result = await mediator.Send(new ResolveYouTubeTrackStreamCommand(externalId, currentUser.Id), cancellationToken); - return result.ToHttpResult(); - } - private static async Task GetTracksByUserId( IMediator mediator, long userId, diff --git a/backend/Hosts/Musify.Api/Extensions/ServiceCollectionExtensions.cs b/backend/Hosts/Musify.Api/Extensions/ServiceCollectionExtensions.cs index 78b93dd..1ceb89e 100644 --- a/backend/Hosts/Musify.Api/Extensions/ServiceCollectionExtensions.cs +++ b/backend/Hosts/Musify.Api/Extensions/ServiceCollectionExtensions.cs @@ -18,7 +18,6 @@ public static class ServiceCollectionExtensions services.AddDatabase(configuration); services.AddStorageService(configuration); services.AddStreamTicketService(configuration); - services.AddYouTubeMusicService(configuration); services.AddMassTransitClient(configuration); services.AddAuthenticationConfiguration(configuration); services.AddOpenApiConfiguration(); diff --git a/backend/Hosts/Musify.Api/Validators/Likes/ToggleLikeRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Likes/ToggleLikeRequestValidator.cs deleted file mode 100644 index 13fec0c..0000000 --- a/backend/Hosts/Musify.Api/Validators/Likes/ToggleLikeRequestValidator.cs +++ /dev/null @@ -1,27 +0,0 @@ -using FluentValidation; -using Musify.Api.DataTransferObjects.Likes; -using Musify.Domain.ValueObjects; - -namespace Musify.Api.Validators.Likes; - -public sealed class ToggleLikeRequestValidator : AbstractValidator -{ - public ToggleLikeRequestValidator() - { - RuleFor(x => x) - .Must(x => x.TrackId.HasValue ^ (x.Source.HasValue && !string.IsNullOrEmpty(x.ExternalId))) - .WithMessage("Provide either trackId, or source and externalId, but not both."); - - When(x => x.Source.HasValue, () => - { - RuleFor(x => x.Source) - .Must(source => source == TrackSource.YouTube) - .WithMessage("Unsupported source."); - - RuleFor(x => x.ExternalId) - .NotEmpty() - .Matches("^[A-Za-z0-9_-]{11}$") - .WithMessage("externalId must be a valid YouTube video id."); - }); - } -} diff --git a/backend/Hosts/Musify.Api/Validators/PlayLists/AddPlayListTrackRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PlayLists/AddPlayListTrackRequestValidator.cs deleted file mode 100644 index eb5b0da..0000000 --- a/backend/Hosts/Musify.Api/Validators/PlayLists/AddPlayListTrackRequestValidator.cs +++ /dev/null @@ -1,27 +0,0 @@ -using FluentValidation; -using Musify.Api.DataTransferObjects.PlayLists; -using Musify.Domain.ValueObjects; - -namespace Musify.Api.Validators.PlayLists; - -public sealed class AddPlayListTrackRequestValidator : AbstractValidator -{ - public AddPlayListTrackRequestValidator() - { - RuleFor(x => x) - .Must(x => x.TrackId.HasValue ^ (x.Source.HasValue && !string.IsNullOrEmpty(x.ExternalId))) - .WithMessage("Provide either trackId, or source and externalId, but not both."); - - When(x => x.Source.HasValue, () => - { - RuleFor(x => x.Source) - .Must(source => source == TrackSource.YouTube) - .WithMessage("Unsupported source."); - - RuleFor(x => x.ExternalId) - .NotEmpty() - .Matches("^[A-Za-z0-9_-]{11}$") - .WithMessage("externalId must be a valid YouTube video id."); - }); - } -} diff --git a/backend/Hosts/Musify.Worker/AppSettings.Development.json b/backend/Hosts/Musify.Worker/AppSettings.Development.json index 4b42d48..3b8dee5 100644 --- a/backend/Hosts/Musify.Worker/AppSettings.Development.json +++ b/backend/Hosts/Musify.Worker/AppSettings.Development.json @@ -16,9 +16,6 @@ "OpenTelemetry": { "OtlpEndpoint": "http://localhost:4317" }, - "YouTube": { - "GeographicalLocation": "ES" - }, "InfrastructureStorage": { "Address": "http://localhost:8333", "AccessKey": "admin_access_key", diff --git a/backend/Hosts/Musify.Worker/AppSettings.json b/backend/Hosts/Musify.Worker/AppSettings.json index ec01d0a..724563d 100644 --- a/backend/Hosts/Musify.Worker/AppSettings.json +++ b/backend/Hosts/Musify.Worker/AppSettings.json @@ -26,14 +26,8 @@ "ApplicationStorage": { "Bucket": "" }, - "YouTube": { - "Enabled": true - }, "Mix": { - "ArtistMixCount": 3, - "ItemsPerMix": 12, - "SeedArtistCount": 3, - "YouTubeResultsPerSeed": 12 + "ItemsPerMix": 12 }, "PlayList": { "Routes": { @@ -83,12 +77,6 @@ }, "TranscodingTimeout": "00:05:00" }, - "YtDlp": { - "ExecutableName": "yt-dlp", - "Format": "bestaudio", - "AdditionalArguments": "", - "DownloadTimeout": "00:05:00" - }, "Workers": { "Routes": { "TemporaryFilesDirectory": "" diff --git a/backend/Hosts/Musify.Worker/Program.cs b/backend/Hosts/Musify.Worker/Program.cs index af4d9a1..d5cf800 100644 --- a/backend/Hosts/Musify.Worker/Program.cs +++ b/backend/Hosts/Musify.Worker/Program.cs @@ -1,6 +1,5 @@ using Hangfire; using Musify.Application.Configuration; -using Musify.Application.Services; using Musify.Infrastructure.Jobs; using Musify.Infrastructure.MassTransit; using Musify.Infrastructure.Persistence; @@ -29,7 +28,6 @@ services.AddValidatedOptions(configuration, TrackConfigurati services.AddValidatedOptions(configuration, MixConfiguration.SectionName); services.AddStorageService(configuration); services.AddAudioTranscoder(configuration); -services.AddYouTubeDownloader(configuration); services.AddHttpClient(); services.AddPictureService(configuration); services.AddPlayListPresetSeeder(configuration); @@ -37,8 +35,6 @@ services.AddDatabase(configuration); services.AddMassTransitConsumers(configuration); services.AddUploadIntentJobs(configuration); services.AddMediator(); -services.AddScoped(); -services.AddYouTubeMusicService(configuration); services.AddDailyMixGenerationJob(configuration); var host = builder.Build(); diff --git a/backend/Musify.Worker.Dockerfile b/backend/Musify.Worker.Dockerfile index c21c71f..b254969 100644 --- a/backend/Musify.Worker.Dockerfile +++ b/backend/Musify.Worker.Dockerfile @@ -17,18 +17,9 @@ RUN cp /app/AppSettings.json /app/appsettings.json \ FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime WORKDIR /app -# ffmpeg: the Worker transcodes audio to .m4a. yt-dlp: downloads YouTube audio. -# yt-dlp is installed via pip in a venv rather than the standalone PyInstaller -# binary: the binary adds a ~1s unpack cost per invocation and extracts slower, -# so pip roughly halves resolution latency. deno solves nsig challenges. +# ffmpeg: the Worker transcodes uploaded audio to .m4a. RUN apt-get update \ - && apt-get install -y --no-install-recommends ffmpeg python3 python3-venv ca-certificates curl unzip \ - && python3 -m venv /opt/ytdlp \ - && /opt/ytdlp/bin/pip install --no-cache-dir -U yt-dlp \ - && ln -s /opt/ytdlp/bin/yt-dlp /usr/local/bin/yt-dlp \ - && curl -fsSL https://deno.land/install.sh | sh -s -- -y \ - && mv /root/.deno/bin/deno /usr/local/bin/deno \ - && apt-get purge -y curl unzip \ - && rm -rf /var/lib/apt/lists/* /root/.deno + && apt-get install -y --no-install-recommends ffmpeg ca-certificates \ + && rm -rf /var/lib/apt/lists/* COPY --from=build /app ./ ENTRYPOINT ["dotnet", "Musify.Worker.dll"] diff --git a/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs b/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs index 00bc5e7..d9697cb 100644 --- a/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs +++ b/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs @@ -25,8 +25,6 @@ namespace Musify.Api.Tests.TestSupport public IEventBus EventBus { get; } = Substitute.For(); - public IYouTubeMusicService YouTubeMusicService { get; } = Substitute.For(); - public IStreamTicketService StreamTicketService { get; } = Substitute.For(); public async ValueTask InitializeAsync() @@ -88,7 +86,6 @@ namespace Musify.Api.Tests.TestSupport { services.Replace(ServiceDescriptor.Singleton(StorageService)); services.Replace(ServiceDescriptor.Scoped(_ => EventBus)); - services.Replace(ServiceDescriptor.Singleton(YouTubeMusicService)); services.Replace(ServiceDescriptor.Singleton(StreamTicketService)); services diff --git a/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs index 3f9d8cd..3d78fd9 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs @@ -15,8 +15,8 @@ namespace Musify.Application.Tests.Albums public async Task Handle_FirstTrack_AddsItAtNumberOne() { var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id); - var track = TestEntities.LocalTrack(owner); + var album = TestEntities.Album(owner.Id); + var track = TestEntities.Track(owner); await SeedAsync(owner, album, track); var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, track.Id), TestContext.Current.CancellationToken); @@ -30,7 +30,7 @@ namespace Musify.Application.Tests.Albums public async Task Handle_AlbumMissing_ReturnsNotFound() { var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track); var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, Guid.NewGuid(), track.Id), TestContext.Current.CancellationToken); @@ -43,8 +43,8 @@ namespace Musify.Application.Tests.Albums { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); - var album = TestEntities.UserAlbum(owner.Id); - var track = TestEntities.LocalTrack(owner); + var album = TestEntities.Album(owner.Id); + var track = TestEntities.Track(owner); await SeedAsync(owner, stranger, album, track); var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(stranger.Id, album.Id, track.Id), TestContext.Current.CancellationToken); @@ -56,7 +56,7 @@ namespace Musify.Application.Tests.Albums public async Task Handle_TrackMissing_ReturnsNotFound() { var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id); + var album = TestEntities.Album(owner.Id); await SeedAsync(owner, album); var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); @@ -69,8 +69,8 @@ namespace Musify.Application.Tests.Albums { var owner = TestEntities.User(1, "owner"); var otherOwner = TestEntities.User(2, "other"); - var album = TestEntities.UserAlbum(owner.Id); - var foreignTrack = TestEntities.LocalTrack(otherOwner); + var album = TestEntities.Album(owner.Id); + var foreignTrack = TestEntities.Track(otherOwner); await SeedAsync(owner, otherOwner, album, foreignTrack); var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, foreignTrack.Id), TestContext.Current.CancellationToken); @@ -82,8 +82,8 @@ namespace Musify.Application.Tests.Albums public async Task Handle_AlreadyInAlbum_ReturnsConflict() { var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id); - var track = TestEntities.LocalTrack(owner); + var album = TestEntities.Album(owner.Id); + var track = TestEntities.Track(owner); var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }; await SeedAsync(owner, album, track, link); @@ -96,9 +96,9 @@ namespace Musify.Application.Tests.Albums public async Task Handle_AlbumAlreadyHasTracks_AppendsAtNextNumber() { var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id); - var firstTrack = TestEntities.LocalTrack(owner, "First"); - var secondTrack = TestEntities.LocalTrack(owner, "Second"); + var album = TestEntities.Album(owner.Id); + var firstTrack = TestEntities.Track(owner, "First"); + var secondTrack = TestEntities.Track(owner, "Second"); var existingLink = new AlbumHasTrack { AlbumId = album.Id, TrackId = firstTrack.Id, TrackNumber = 3 }; await SeedAsync(owner, album, firstTrack, secondTrack, existingLink); diff --git a/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs index 91b3664..eeb6cea 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs @@ -44,7 +44,7 @@ namespace Musify.Application.Tests.Albums Assert.Equal(owner.Id, result.Value.OwnerUserId); Assert.Equal(0, result.Value.TrackCount); - var stored = await Database.UserAlbums.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); + var stored = await Database.Albums.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); Assert.NotNull(stored); Assert.Equal("MY ALBUM", stored.NormalizedTitle); Assert.Equal("cover.webp", stored.Pictures.OriginalName); diff --git a/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs index a9f7fe5..6d2a371 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs @@ -13,13 +13,13 @@ namespace Musify.Application.Tests.Albums public async Task Handle_Owner_RemovesAlbum() { var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id); + var album = TestEntities.Album(owner.Id); await SeedAsync(owner, album); var result = await CreateHandler().Handle(new DeleteAlbumCommand(owner.Id, album.Id), TestContext.Current.CancellationToken); Assert.False(result.IsError); - Assert.Null(await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken)); + Assert.Null(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken)); } [Fact] @@ -35,13 +35,13 @@ namespace Musify.Application.Tests.Albums { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); - var album = TestEntities.UserAlbum(owner.Id); + var album = TestEntities.Album(owner.Id); await SeedAsync(owner, stranger, album); var result = await CreateHandler().Handle(new DeleteAlbumCommand(stranger.Id, album.Id), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); - Assert.NotNull(await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken)); + Assert.NotNull(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken)); } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumByIdQueryHandlerTests.cs index 56bca64..bb21b98 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumByIdQueryHandlerTests.cs @@ -14,8 +14,8 @@ namespace Musify.Application.Tests.Albums public async Task Handle_ExistingAlbum_ReturnsItWithTrackCountAndCovers() { var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id); - var track = TestEntities.LocalTrack(owner); + var album = TestEntities.Album(owner.Id); + var track = TestEntities.Track(owner); await SeedAsync(owner, album, track, new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }); var result = await CreateHandler().Handle(new GetAlbumByIdQuery(album.Id), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs index f5eff01..4a7610a 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs @@ -17,7 +17,7 @@ namespace Musify.Application.Tests.Albums public async Task Handle_ExistingAlbum_ReturnsRequestedSize(string size, string expectedFileName) { var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id); + var album = TestEntities.Album(owner.Id); await SeedAsync(owner, album); var result = await CreateHandler().Handle(new GetAlbumCoverQuery(album.Id, size), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTracksQueryHandlerTests.cs index 6c412d9..955d7ab 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTracksQueryHandlerTests.cs @@ -14,9 +14,9 @@ namespace Musify.Application.Tests.Albums public async Task Handle_AlbumWithTracks_ReturnsThemOrderedByTrackNumber() { var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id); - var second = TestEntities.LocalTrack(owner, "Second"); - var first = TestEntities.LocalTrack(owner, "First"); + var album = TestEntities.Album(owner.Id); + var second = TestEntities.Track(owner, "Second"); + var first = TestEntities.Track(owner, "First"); await SeedAsync( owner, album, second, first, new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 }, diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsByUserIdQueryHandlerTests.cs index 1bf6a66..93be0ed 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsByUserIdQueryHandlerTests.cs @@ -14,7 +14,7 @@ namespace Musify.Application.Tests.Albums { var owner = TestEntities.User(1, "owner"); var other = TestEntities.User(2, "other"); - await SeedAsync(owner, other, TestEntities.UserAlbum(owner.Id, "Mine"), TestEntities.UserAlbum(other.Id, "Theirs")); + await SeedAsync(owner, other, TestEntities.Album(owner.Id, "Mine"), TestEntities.Album(other.Id, "Theirs")); var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(owner.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs index 1d565c5..42f2484 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs @@ -1,99 +1,44 @@ -using ErrorOr; using Musify.Application.Albums; -using Musify.Application.Contracts; using Musify.Application.Tests.TestSupport; -using Musify.Domain.ValueObjects; -using NSubstitute; using Xunit; namespace Musify.Application.Tests.Albums { public sealed class GetAlbumsQueryHandlerTests : HandlerTestBase { - private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - - private GetAlbumsQueryHandler CreateHandler() => - new(Database, youTubeMusicService, NoOpLogger()); + private GetAlbumsQueryHandler CreateHandler() => new(Database); [Fact] - public async Task Handle_NoTitleFilter_ReturnsAllAlbumsPaged_AndDoesNotCallYouTube() + public async Task Handle_NoTitleFilter_ReturnsAllAlbumsPaged() { var owner = TestEntities.User(); - await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "Alpha"), TestEntities.UserAlbum(owner.Id, "Beta")); + await SeedAsync(owner, TestEntities.Album(owner.Id, "Alpha"), TestEntities.Album(owner.Id, "Beta")); var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); Assert.False(result.IsError); Assert.Equal(2, result.Value.TotalItemCount); Assert.All(result.Value.Items, item => Assert.NotNull(item.Album)); - await youTubeMusicService.DidNotReceiveWithAnyArgs() - .SearchAlbumsAsync(default!, default!, TestContext.Current.CancellationToken); } [Fact] - public async Task Handle_TitleFilter_ReturnsMatchingLocalAlbums_FollowedByYouTubeResults() + public async Task Handle_TitleFilter_ReturnsMatchingAlbumsOnly() { var owner = TestEntities.User(); - await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "Rock Classics"), TestEntities.UserAlbum(owner.Id, "Jazz Favorites")); - - var album = new YouTubeAlbumResult("album-1", "Rock Classics Reissue", "Various Artists", "thumb.jpg", 2020, false, false, []); - youTubeMusicService.SearchAlbumsAsync("rock", string.Empty, Arg.Any()) - .Returns(new YouTubeAlbumSearchResult([album], "next-token")); - - var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(2, result.Value.Items.Count); - Assert.NotNull(result.Value.Items[0].Album); - Assert.Equal("Rock Classics", result.Value.Items[0].Album!.Title); - Assert.Null(result.Value.Items[1].Album); - Assert.NotNull(result.Value.Items[1].YouTubeAlbum); - Assert.Equal("album-1", result.Value.Items[1].YouTubeAlbum!.AlbumId); - Assert.Equal("next-token", result.Value.NextYoutubeContinuationToken); - Assert.False(result.Value.YoutubeUnavailable); - } - - [Fact] - public async Task Handle_TitleFilter_SkipsYouTubeResultAlreadyMaterializedLocally() - { - var externalAlbum = TestEntities.ExternalAlbum(externalId: "album-1", title: "Rock Classics", source: TrackSource.YouTube); - await SeedAsync(externalAlbum); - - var album = new YouTubeAlbumResult("album-1", "Rock Classics", "Various Artists", "thumb.jpg", 2020, false, false, []); - youTubeMusicService.SearchAlbumsAsync("rock", string.Empty, Arg.Any()) - .Returns(new YouTubeAlbumSearchResult([album], string.Empty)); + await SeedAsync(owner, TestEntities.Album(owner.Id, "Rock Classics"), TestEntities.Album(owner.Id, "Jazz Favorites")); var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); Assert.False(result.IsError); var item = Assert.Single(result.Value.Items); - Assert.NotNull(item.Album); - Assert.Null(item.YouTubeAlbum); - } - - [Fact] - public async Task Handle_TitleFilter_YouTubeUnavailable_StillReturnsLocalResults() - { - var owner = TestEntities.User(); - await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "Rock Classics")); - - youTubeMusicService.SearchAlbumsAsync("rock", string.Empty, Arg.Any()) - .Returns(Error.Forbidden(description: "The YouTube Music source is disabled.")); - - var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var item = Assert.Single(result.Value.Items); - Assert.NotNull(item.Album); - Assert.True(result.Value.YoutubeUnavailable); - Assert.Null(result.Value.NextYoutubeContinuationToken); + Assert.Equal("Rock Classics", item.Album.Title); } [Fact] public async Task Handle_PageSizeSmallerThanTotal_ReportsHasNextPage() { var owner = TestEntities.User(); - await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "One"), TestEntities.UserAlbum(owner.Id, "Two"), TestEntities.UserAlbum(owner.Id, "Three")); + await SeedAsync(owner, TestEntities.Album(owner.Id, "One"), TestEntities.Album(owner.Id, "Two"), TestEntities.Album(owner.Id, "Three")); var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 2), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs index 28c0c0b..8898711 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs @@ -13,10 +13,10 @@ namespace Musify.Application.Tests.Albums public async Task Handle_ListenedTracks_ReturnsTheirAlbumsMostRecentFirst() { var owner = TestEntities.User(); - var olderAlbum = TestEntities.UserAlbum(owner.Id, "Older"); - var newerAlbum = TestEntities.UserAlbum(owner.Id, "Newer"); - var olderTrack = TestEntities.LocalTrack(owner, "In older album"); - var newerTrack = TestEntities.LocalTrack(owner, "In newer album"); + var olderAlbum = TestEntities.Album(owner.Id, "Older"); + var newerAlbum = TestEntities.Album(owner.Id, "Newer"); + var olderTrack = TestEntities.Track(owner, "In older album"); + var newerTrack = TestEntities.Track(owner, "In newer album"); await SeedAsync( owner, olderAlbum, newerAlbum, olderTrack, newerTrack, new AlbumHasTrack { AlbumId = olderAlbum.Id, TrackId = olderTrack.Id, TrackNumber = 1 }, @@ -46,8 +46,8 @@ namespace Musify.Application.Tests.Albums public async Task Handle_LimitLowerThanCandidates_ReturnsOnlyThatManyAlbums() { var owner = TestEntities.User(); - var albums = Enumerable.Range(1, 3).Select(i => TestEntities.UserAlbum(owner.Id, $"Album {i}")).ToList(); - var tracks = albums.Select((_, i) => TestEntities.LocalTrack(owner, $"Track {i}")).ToList(); + var albums = Enumerable.Range(1, 3).Select(i => TestEntities.Album(owner.Id, $"Album {i}")).ToList(); + var tracks = albums.Select((_, i) => TestEntities.Track(owner, $"Track {i}")).ToList(); var links = albums.Zip(tracks, (album, track) => new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }).ToList(); var histories = tracks.Select((track, i) => TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddMinutes(-i))).ToList(); await SeedAsync([owner, .. albums, .. tracks, .. links, .. histories]); diff --git a/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs index c51431a..62516f2 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs @@ -16,10 +16,10 @@ namespace Musify.Application.Tests.Albums public async Task Handle_TrackInAlbum_RemovesItAndRenumbersTheRest() { var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id); - var first = TestEntities.LocalTrack(owner, "First"); - var second = TestEntities.LocalTrack(owner, "Second"); - var third = TestEntities.LocalTrack(owner, "Third"); + var album = TestEntities.Album(owner.Id); + var first = TestEntities.Track(owner, "First"); + var second = TestEntities.Track(owner, "Second"); + var third = TestEntities.Track(owner, "Third"); await SeedAsync( owner, album, first, second, third, new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 }, @@ -50,7 +50,7 @@ namespace Musify.Application.Tests.Albums { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); - var album = TestEntities.UserAlbum(owner.Id); + var album = TestEntities.Album(owner.Id); await SeedAsync(owner, stranger, album); var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(stranger.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); @@ -62,7 +62,7 @@ namespace Musify.Application.Tests.Albums public async Task Handle_TrackNotInAlbum_ReturnsNotFound() { var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id); + var album = TestEntities.Album(owner.Id); await SeedAsync(owner, album); var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs index c82a160..930d33f 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs @@ -26,7 +26,7 @@ namespace Musify.Application.Tests.Albums public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear() { var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id, title: "Old Title", description: "Old", releaseYear: 2020); + var album = TestEntities.Album(owner.Id, title: "Old Title", description: "Old", releaseYear: 2020); await SeedAsync(owner, album); var command = new UpdateAlbumCommand(owner.Id, album.Id, " New Title ", "New description", 2025, null); @@ -38,7 +38,7 @@ namespace Musify.Application.Tests.Albums Assert.Equal("New description", result.Value.Description); Assert.Equal(2025, result.Value.ReleaseYear); - var stored = await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken); + var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken); Assert.NotNull(stored); Assert.Equal("NEW TITLE", stored.NormalizedTitle); } @@ -51,7 +51,7 @@ namespace Musify.Application.Tests.Albums .Returns(new ObjectMetaData("image/webp", 1024)); var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id, title: "Old Title"); + var album = TestEntities.Album(owner.Id, title: "Old Title"); var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.AlbumPicture, objectName: "new-cover.webp"); await SeedAsync(owner, album, intent); @@ -60,7 +60,7 @@ namespace Musify.Application.Tests.Albums var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); Assert.False(result.IsError); - var stored = await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken); + var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken); Assert.NotNull(stored); Assert.Equal("new-cover.webp", stored.Pictures.OriginalName); await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); @@ -81,7 +81,7 @@ namespace Musify.Application.Tests.Albums { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); - var album = TestEntities.UserAlbum(owner.Id); + var album = TestEntities.Album(owner.Id); await SeedAsync(owner, stranger, album); var command = new UpdateAlbumCommand(stranger.Id, album.Id, "Hijacked", null, null, null); diff --git a/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs index 9be76bb..158ee08 100644 --- a/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs @@ -13,8 +13,8 @@ namespace Musify.Application.Tests.Likes public async Task Handle_MultipleLikes_ReturnsMostRecentFirst() { var owner = TestEntities.User(); - var older = TestEntities.LocalTrack(owner, "Older like"); - var newer = TestEntities.LocalTrack(owner, "Newer like"); + var older = TestEntities.Track(owner, "Older like"); + var newer = TestEntities.Track(owner, "Newer like"); await SeedAsync( owner, older, newer, new TrackLike { UserId = owner.Id, TrackId = older.Id, CreatedAt = DateTime.UtcNow.AddMinutes(-10) }, @@ -43,7 +43,7 @@ namespace Musify.Application.Tests.Likes { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, stranger, track, new TrackLike { UserId = stranger.Id, TrackId = track.Id }); var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Likes/ToggleTrackLikeCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Likes/ToggleTrackLikeCommandHandlerTests.cs index a0ba867..f03b174 100644 --- a/backend/Tests/Musify.Application.Tests/Likes/ToggleTrackLikeCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Likes/ToggleTrackLikeCommandHandlerTests.cs @@ -1,40 +1,25 @@ using ErrorOr; using Microsoft.EntityFrameworkCore; -using Musify.Application.Contracts; using Musify.Application.Likes; -using Musify.Application.Services; using Musify.Application.Tests.TestSupport; -using Musify.Domain.ValueObjects; -using NSubstitute; using Xunit; namespace Musify.Application.Tests.Likes { public sealed class ToggleTrackLikeCommandHandlerTests : HandlerTestBase { - private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - - private ToggleTrackLikeCommandHandler CreateHandler() - { - var provisioner = new YouTubeTrackProvisioner( - Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger()); - - return new ToggleTrackLikeCommandHandler(Database, provisioner, NoOpLogger()); - } - - private static YouTubeSongResult BuildSong(string videoId) => new( - videoId, "Song Title", "Song Artist", "Song Album", 200, "https://img.example/thumb.jpg", false, - [new YouTubeArtistRef("artist-id", "Song Artist")]); + private ToggleTrackLikeCommandHandler CreateHandler() => + new(Database, NoOpLogger()); [Fact] - public async Task Handle_LocalTrackNotLiked_LikesIt() + public async Task Handle_TrackNotLiked_LikesIt() { var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track); var result = await CreateHandler().Handle( - new ToggleTrackLikeCommand(owner.Id, track.Id, null, null), TestContext.Current.CancellationToken); + new ToggleTrackLikeCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); Assert.False(result.IsError); Assert.True(result.Value); @@ -42,14 +27,14 @@ namespace Musify.Application.Tests.Likes } [Fact] - public async Task Handle_LocalTrackAlreadyLiked_UnlikesIt() + public async Task Handle_TrackAlreadyLiked_UnlikesIt() { var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track, new Musify.Domain.Entities.TrackLike { UserId = owner.Id, TrackId = track.Id }); var result = await CreateHandler().Handle( - new ToggleTrackLikeCommand(owner.Id, track.Id, null, null), TestContext.Current.CancellationToken); + new ToggleTrackLikeCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); Assert.False(result.IsError); Assert.False(result.Value); @@ -63,41 +48,9 @@ namespace Musify.Application.Tests.Likes await SeedAsync(owner); var result = await CreateHandler().Handle( - new ToggleTrackLikeCommand(owner.Id, Guid.NewGuid(), null, null), TestContext.Current.CancellationToken); + new ToggleTrackLikeCommand(owner.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } - - [Fact] - public async Task Handle_UnknownExternalTrack_ProvisionsAndLikesIt() - { - youTubeMusicService.GetSongAsync("new-video", Arg.Any()).Returns(BuildSong("new-video")); - - var owner = TestEntities.User(); - await SeedAsync(owner); - - var result = await CreateHandler().Handle( - new ToggleTrackLikeCommand(owner.Id, null, TrackSource.YouTube, "new-video"), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.True(result.Value); - var track = await Database.ExternalTracks.SingleAsync(t => t.ExternalId == "new-video", TestContext.Current.CancellationToken); - Assert.True(await Database.TrackLikes.AnyAsync(like => like.TrackId == track.Id, TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task Handle_ExistingExternalTrack_LikesItWithoutReprovisioning() - { - var owner = TestEntities.User(); - var track = TestEntities.ExternalTrack(externalId: "abc123"); - await SeedAsync(owner, track); - - var result = await CreateHandler().Handle( - new ToggleTrackLikeCommand(owner.Id, null, TrackSource.YouTube, "abc123"), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.True(result.Value); - await youTubeMusicService.DidNotReceive().GetSongAsync(Arg.Any(), Arg.Any()); - } } } diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs index 5d0405c..7a3a8a4 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs @@ -1,24 +1,18 @@ -using ErrorOr; using Microsoft.EntityFrameworkCore; -using Musify.Application.Contracts; using Musify.Application.Mixes; using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; -using NSubstitute; using Xunit; namespace Musify.Application.Tests.Mixes { public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase { - private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - private GenerateMixesForUserCommandHandler CreateHandler() => new( - Database, youTubeMusicService, TestConfigurations.Mix(), NoOpLogger()); + Database, TestConfigurations.Mix(), NoOpLogger()); [Fact] - public async Task Handle_NoListeningHistory_DoesNotCreateAnyMix() + public async Task Handle_NoTracks_DoesNotCreateAnyMix() { var user = TestEntities.User(); await SeedAsync(user); @@ -30,75 +24,53 @@ namespace Musify.Application.Tests.Mixes } [Fact] - public async Task Handle_HistoryWithNoKnownArtists_DoesNotCreateAnyMix() + public async Task Handle_AllTracksAlreadyListenedTo_CreatesOnlyTheDailyMix() { var user = TestEntities.User(); - var track = TestEntities.LocalTrack(user); - await SeedAsync(user, track, TestEntities.ListeningHistory(user.Id, track.Id)); + var track = TestEntities.Track(user); + await SeedAsync( + user, track, + new UserHasTrack { UserId = user.Id, TrackId = track.Id }, + TestEntities.ListeningHistory(user.Id, track.Id)); var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); Assert.False(result.IsError); - Assert.Empty(await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken)); + var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); + Assert.Single(mixes); + Assert.Equal("Tu mezcla diaria", mixes[0].Title); } [Fact] - public async Task Handle_SeedArtistWithYouTubeAndLocalCandidates_ReplacesPreviousMixes() + public async Task Handle_UnheardTracksInLibrary_CreatesDiscoveryAndDailyMixes_AndReplacesPreviousMixes() { - youTubeMusicService - .SearchSongsAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new YouTubeSearchResult( - [new YouTubeSongResult("yt-1", "YouTube Song", "Seed Artist", "Album", 200, "https://img/thumb.jpg", IsExplicit: false, [] )], - ContinuationToken: string.Empty)); - youTubeMusicService.ResolveArtworkUrl(Arg.Any()).Returns(callInfo => callInfo.Arg()); - var user = TestEntities.User(); - var artist = TestEntities.Artist("Seed Artist"); - var listenedTrack = TestEntities.ExternalTrack(externalId: "listened", title: "Listened Track"); - var otherTrackByArtist = TestEntities.ExternalTrack(externalId: "not-listened", title: "Other Track"); + var listenedTrack = TestEntities.Track(user, "Listened Track"); + var unheardTrack = TestEntities.Track(user, "Unheard Track"); var staleMix = TestEntities.Mix(user.Id, "Stale Mix"); - var staleItem = TestEntities.MixItem(staleMix.Id, title: "Stale Item"); + var staleItem = TestEntities.MixItem(staleMix.Id, listenedTrack.Id); await SeedAsync( - user, artist, listenedTrack, otherTrackByArtist, staleMix, staleItem, - new TrackArtist { TrackId = listenedTrack.Id, ArtistId = artist.Id, Position = 0 }, - new TrackArtist { TrackId = otherTrackByArtist.Id, ArtistId = artist.Id, Position = 0 }, + user, listenedTrack, unheardTrack, staleMix, staleItem, + new UserHasTrack { UserId = user.Id, TrackId = listenedTrack.Id }, + new UserHasTrack { UserId = user.Id, TrackId = unheardTrack.Id }, TestEntities.ListeningHistory(user.Id, listenedTrack.Id)); var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); Assert.False(result.IsError); var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); - Assert.NotEmpty(mixes); Assert.DoesNotContain(mixes, mix => mix.Title == "Stale Mix"); + Assert.Contains(mixes, mix => mix.Title == "Descubrimiento"); + Assert.Contains(mixes, mix => mix.Title == "Tu mezcla diaria"); Assert.Empty(await Database.MixItems.Where(item => item.MixId == staleMix.Id).ToListAsync(TestContext.Current.CancellationToken)); - } - [Fact] - public async Task Handle_YouTubeSourceUnavailable_StillCreatesMixesFromLocalCandidatesOnly() - { - youTubeMusicService - .SearchSongsAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Error.Forbidden(description: "The YouTube Music source is disabled.")); - - var user = TestEntities.User(); - var artist = TestEntities.Artist("Seed Artist"); - var listenedTrack = TestEntities.ExternalTrack(externalId: "listened", title: "Listened Track"); - var otherTrackByArtist = TestEntities.ExternalTrack(externalId: "not-listened", title: "Other Track"); - - await SeedAsync( - user, artist, listenedTrack, otherTrackByArtist, - new TrackArtist { TrackId = listenedTrack.Id, ArtistId = artist.Id, Position = 0 }, - new TrackArtist { TrackId = otherTrackByArtist.Id, ArtistId = artist.Id, Position = 0 }, - TestEntities.ListeningHistory(user.Id, listenedTrack.Id)); - - var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var items = await Database.MixItems.ToListAsync(TestContext.Current.CancellationToken); - Assert.NotEmpty(items); - Assert.All(items, item => Assert.Equal(MixItemSource.Musify, item.Source)); + var discoveryMix = mixes.Single(mix => mix.Title == "Descubrimiento"); + var discoveryItems = await Database.MixItems + .Where(item => item.MixId == discoveryMix.Id) + .ToListAsync(TestContext.Current.CancellationToken); + Assert.All(discoveryItems, item => Assert.Equal(unheardTrack.Id, item.TrackId)); } } } diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs index a25ceda..97ba344 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs @@ -13,10 +13,12 @@ namespace Musify.Application.Tests.Mixes public async Task Handle_OwnedMix_ReturnsItWithOrderedItems() { var owner = TestEntities.User(); + var firstTrack = TestEntities.Track(owner, "First"); + var secondTrack = TestEntities.Track(owner, "Second"); var mix = TestEntities.Mix(owner.Id, "Discovery"); - var second = TestEntities.MixItem(mix.Id, position: 1, title: "Second"); - var first = TestEntities.MixItem(mix.Id, position: 0, title: "First"); - await SeedAsync(owner, mix, second, first); + var second = TestEntities.MixItem(mix.Id, secondTrack.Id, position: 1); + var first = TestEntities.MixItem(mix.Id, firstTrack.Id, position: 0); + await SeedAsync(owner, firstTrack, secondTrack, mix, second, first); var result = await CreateHandler().Handle(new GetMixByIdQuery(owner.Id, mix.Id), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs index df88fc1..2e1fad1 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs @@ -16,7 +16,7 @@ namespace Musify.Application.Tests.PlayLists { var owner = TestEntities.User(); var playList = TestEntities.PlayList(owner.Id); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, playList, track); var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken); @@ -31,8 +31,8 @@ namespace Musify.Application.Tests.PlayLists { var owner = TestEntities.User(); var playList = TestEntities.PlayList(owner.Id); - var existingTrack = TestEntities.LocalTrack(owner, "Existing"); - var newTrack = TestEntities.LocalTrack(owner, "New"); + var existingTrack = TestEntities.Track(owner, "Existing"); + var newTrack = TestEntities.Track(owner, "New"); await SeedAsync( owner, playList, existingTrack, newTrack, new PlayListHasTrack { PlayListId = playList.Id, TrackId = existingTrack.Id, Position = 0 }); @@ -82,7 +82,7 @@ namespace Musify.Application.Tests.PlayLists { var owner = TestEntities.User(); var playList = TestEntities.PlayList(owner.Id); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/AddYouTubeTrackToPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/AddYouTubeTrackToPlayListCommandHandlerTests.cs deleted file mode 100644 index eeb832b..0000000 --- a/backend/Tests/Musify.Application.Tests/PlayLists/AddYouTubeTrackToPlayListCommandHandlerTests.cs +++ /dev/null @@ -1,105 +0,0 @@ -using ErrorOr; -using Microsoft.EntityFrameworkCore; -using Musify.Application.Contracts; -using Musify.Application.PlayLists; -using Musify.Application.Services; -using Musify.Application.Tests.TestSupport; -using Musify.Domain.Entities; -using NSubstitute; -using Xunit; - -namespace Musify.Application.Tests.PlayLists -{ - public sealed class AddYouTubeTrackToPlayListCommandHandlerTests : HandlerTestBase - { - private readonly IEventBus eventBus = Substitute.For(); - private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - - private AddYouTubeTrackToPlayListCommandHandler CreateHandler() - { - var provisioner = new YouTubeTrackProvisioner( - Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger()); - - return new AddYouTubeTrackToPlayListCommandHandler( - Database, - eventBus, - youTubeMusicService, - provisioner, - TestConfigurations.Storage(), - TestConfigurations.Track(), - NoOpLogger()); - } - - private static YouTubeSongResult BuildSong(string videoId) => new( - videoId, "Song Title", "Song Artist", "Song Album", 200, "https://img.example/thumb.jpg", false, - [new YouTubeArtistRef("artist-id", "Song Artist")]); - - [Fact] - public async Task Handle_TrackAlreadyProcessedAndNotLinked_AddsItWithoutQueuingDownload() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - var track = TestEntities.ExternalTrack(externalId: "abc123"); - await SeedAsync(owner, playList, track); - - var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(owner.Id, playList.Id, "abc123"), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken)); - await eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task Handle_UnknownVideo_ProvisionsTrackAndQueuesDownload() - { - youTubeMusicService.GetSongAsync("new-video", Arg.Any()).Returns(BuildSong("new-video")); - youTubeMusicService.ResolveArtworkUrl(Arg.Any()).Returns(callInfo => callInfo.Arg()); - - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, playList); - - var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(owner.Id, playList.Id, "new-video"), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal("Song Title", result.Value.Title); - var track = await Database.ExternalTracks.SingleAsync(t => t.ExternalId == "new-video", TestContext.Current.CancellationToken); - Assert.True(track.Audio.DownloadRequested); - await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task Handle_PlayListMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(1, Guid.NewGuid(), "abc123"), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } - - [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() - { - var owner = TestEntities.User(1, "owner"); - var stranger = TestEntities.User(2, "stranger"); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, stranger, playList); - - var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(stranger.Id, playList.Id, "abc123"), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); - } - - [Fact] - public async Task Handle_TrackAlreadyLinked_ReturnsConflict() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - var track = TestEntities.ExternalTrack(externalId: "abc123"); - await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); - - var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(owner.Id, playList.Id, "abc123"), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Conflict, result.FirstError.Type); - } - } -} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs index 45c5483..0610a17 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs @@ -15,7 +15,7 @@ namespace Musify.Application.Tests.PlayLists { var owner = TestEntities.User(); var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs index 5642616..bedf50f 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs @@ -15,8 +15,8 @@ namespace Musify.Application.Tests.PlayLists { var owner = TestEntities.User(); var playList = TestEntities.PlayList(owner.Id); - var second = TestEntities.LocalTrack(owner, "Second"); - var first = TestEntities.LocalTrack(owner, "First"); + var second = TestEntities.Track(owner, "Second"); + var first = TestEntities.Track(owner, "First"); await SeedAsync( owner, playList, second, first, new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 }, diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs index 81e1d59..67bab6e 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs @@ -15,7 +15,7 @@ namespace Musify.Application.Tests.PlayLists { var owner = TestEntities.User(); var playList = TestEntities.PlayList(owner.Id); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs b/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs index b421bb9..23e4924 100644 --- a/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs @@ -24,7 +24,7 @@ namespace Musify.Application.Tests.Services { ticketService.IssueTicket(7, Arg.Any(), Arg.Any()).Returns(new StreamTicket("signed-token", 120)); var owner = TestEntities.User(7); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track); var response = await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 7, TestContext.Current.CancellationToken); @@ -45,7 +45,7 @@ namespace Musify.Application.Tests.Services { ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); var owner = TestEntities.User(1); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track); await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 1, TestContext.Current.CancellationToken); @@ -58,7 +58,7 @@ namespace Musify.Application.Tests.Services { ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); var owner = TestEntities.User(1); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track); var response = await CreateIssuer(TestConfigurations.Playback(allowAnonymousListening: true)) @@ -73,7 +73,7 @@ namespace Musify.Application.Tests.Services { ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); var owner = TestEntities.User(1); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track); var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); @@ -87,7 +87,7 @@ namespace Musify.Application.Tests.Services { ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); var owner = TestEntities.User(1); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track); var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); diff --git a/backend/Tests/Musify.Application.Tests/Services/YouTubeTrackProvisionerTests.cs b/backend/Tests/Musify.Application.Tests/Services/YouTubeTrackProvisionerTests.cs deleted file mode 100644 index 0613315..0000000 --- a/backend/Tests/Musify.Application.Tests/Services/YouTubeTrackProvisionerTests.cs +++ /dev/null @@ -1,104 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Musify.Application.Contracts; -using Musify.Application.Services; -using Musify.Application.Tests.TestSupport; -using NSubstitute; -using Xunit; - -namespace Musify.Application.Tests.Services -{ - public sealed class YouTubeTrackProvisionerTests : HandlerTestBase - { - private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - - private YouTubeTrackProvisioner CreateProvisioner() => - new(Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger()); - - [Fact] - public async Task FindAsync_UnknownVideo_ReturnsNull() - { - var track = await CreateProvisioner().FindAsync("missing-video", TestContext.Current.CancellationToken); - - Assert.Null(track); - } - - [Fact] - public async Task GetOrCreateAsync_KnownVideo_ReturnsExistingTrackWithoutCallingTheService() - { - var existing = TestEntities.ExternalTrack(externalId: "known-video"); - await SeedAsync(existing); - - var result = await CreateProvisioner().GetOrCreateAsync("known-video", TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(existing.Id, result.Value.Track.Id); - await youTubeMusicService.DidNotReceiveWithAnyArgs().GetSongAsync(default!, TestContext.Current.CancellationToken); - } - - [Fact] - public async Task GetOrCreateAsync_NewVideo_CreatesTrackAndDeduplicatesArtistsByName() - { - youTubeMusicService.GetSongAsync("new-video", Arg.Any()).Returns( - new YouTubeSongResult( - "new-video", "New Song", "New Artist", "Album", 210, "https://img/thumb.jpg", false, - [new YouTubeArtistRef(null, "New Artist"), new YouTubeArtistRef(null, "new artist")])); - - var result = await CreateProvisioner().GetOrCreateAsync("new-video", TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal("New Song", result.Value.Track.Title); - Assert.Single(await Database.Artists.ToListAsync(TestContext.Current.CancellationToken)); - Assert.Single(await Database.TrackArtists.ToListAsync(TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task GetOrCreateAsync_ServiceFails_ReturnsError() - { - youTubeMusicService.GetSongAsync("bad-video", Arg.Any()) - .Returns(ErrorOr.Error.NotFound(description: "Video not found")); - - var result = await CreateProvisioner().GetOrCreateAsync("bad-video", TestContext.Current.CancellationToken); - - Assert.True(result.IsError); - } - - [Fact] - public async Task MaterializeAlbumAsync_NewAlbum_CreatesAlbumTracksAndLinks() - { - var detail = new YouTubeAlbumDetail( - new YouTubeAlbumResult("album-1", "Album Title", "Album Artist", "https://img/thumb.jpg", 2024, false, false, - [new YouTubeArtistRef(null, "Album Artist")]), - Description: "desc", - TotalDurationSeconds: 400, - Tracks: - [ - new YouTubeAlbumTrack("track-1", "Track One", 200, 1, false), - new YouTubeAlbumTrack("track-2", "Track Two", 200, 2, false) - ]); - - await CreateProvisioner().MaterializeAlbumAsync("album-1", detail, TestContext.Current.CancellationToken); - - Assert.Equal(2, await Database.ExternalTracks.CountAsync(TestContext.Current.CancellationToken)); - Assert.Equal(2, await Database.AlbumHasTracks.CountAsync(TestContext.Current.CancellationToken)); - Assert.Single(await Database.ExternalAlbums.ToListAsync(TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task MaterializeAlbumAsync_CalledTwice_DoesNotDuplicateTracksOrLinks() - { - var detail = new YouTubeAlbumDetail( - new YouTubeAlbumResult("album-1", "Album Title", "Album Artist", "https://img/thumb.jpg", 2024, false, false, []), - Description: "desc", - TotalDurationSeconds: 200, - Tracks: [new YouTubeAlbumTrack("track-1", "Track One", 200, 1, false)]); - - var provisioner = CreateProvisioner(); - await provisioner.MaterializeAlbumAsync("album-1", detail, TestContext.Current.CancellationToken); - await provisioner.MaterializeAlbumAsync("album-1", detail, TestContext.Current.CancellationToken); - - Assert.Single(await Database.ExternalTracks.ToListAsync(TestContext.Current.CancellationToken)); - Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken)); - Assert.Single(await Database.ExternalAlbums.ToListAsync(TestContext.Current.CancellationToken)); - } - } -} diff --git a/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs b/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs index 6eda042..a708efa 100644 --- a/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs +++ b/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs @@ -1,6 +1,5 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks.Responses; -using Musify.Domain.Entities; using Xunit; namespace Musify.Application.Tests.Shared @@ -8,44 +7,16 @@ namespace Musify.Application.Tests.Shared public sealed class TrackApplicationResponseTests { [Fact] - public void FromEntity_LocalTrack_UsesOwnerNameAsArtist() + public void FromEntity_UsesOwnerNameAsArtist() { var owner = TestEntities.User(1, "The Owner"); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); - var response = Assert.IsType(TrackApplicationResponse.FromEntity(track, listensCount: 3)); + var response = TrackApplicationResponse.FromEntity(track, listensCount: 3); Assert.Equal("The Owner", response.Artist); Assert.Equal(owner.Id, response.OwnerUserId); Assert.Equal(3, response.ListensCount); } - - [Fact] - public void FromEntity_ExternalTrackWithArtists_JoinsThemInPositionOrder() - { - var track = TestEntities.ExternalTrack(); - var second = TestEntities.Artist("Second Artist", externalId: "second"); - var first = TestEntities.Artist("First Artist", externalId: "first"); - track.TrackArtists = - [ - new TrackArtist { TrackId = track.Id, ArtistId = second.Id, Position = 1, Artist = second, Track = track }, - new TrackArtist { TrackId = track.Id, ArtistId = first.Id, Position = 0, Artist = first, Track = track } - ]; - - var response = Assert.IsType(TrackApplicationResponse.FromEntity(track, listensCount: 0)); - - Assert.Equal("First Artist, Second Artist", response.Artist); - Assert.Equal(track.ExternalId, response.ExternalId); - } - - [Fact] - public void FromEntity_ExternalTrackWithNoArtists_HasNullArtist() - { - var track = TestEntities.ExternalTrack(); - - var response = TrackApplicationResponse.FromEntity(track, listensCount: 0); - - Assert.Null(response.Artist); - } } } diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs index a689c5f..b273b02 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs @@ -39,59 +39,24 @@ namespace Musify.Application.Tests.TestSupport modelBuilder.Entity().OwnsOne(track => track.Audio); modelBuilder.Entity().Navigation(track => track.Audio).IsRequired(); - modelBuilder.Entity().ToTable("LocalTracks"); - - modelBuilder.Entity() + modelBuilder.Entity() .HasOne(track => track.Owner) .WithMany() .HasForeignKey(track => track.OwnerUserId) .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity().ToTable("ExternalTracks"); - - modelBuilder.Entity() - .HasIndex(track => new { track.Source, track.ExternalId }) - .IsUnique(); - - modelBuilder.Entity() - .HasKey(trackArtist => new { trackArtist.TrackId, trackArtist.ArtistId }); - - modelBuilder.Entity() - .HasOne(trackArtist => trackArtist.Track) - .WithMany(track => track.TrackArtists) - .HasForeignKey(trackArtist => trackArtist.TrackId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasOne(trackArtist => trackArtist.Artist) - .WithMany(artist => artist.TrackArtists) - .HasForeignKey(trackArtist => trackArtist.ArtistId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasIndex(artist => artist.ExternalId) - .IsUnique(); - modelBuilder.Entity().OwnsOne(playList => playList.Pictures); modelBuilder.Entity().Navigation(playList => playList.Pictures).IsRequired(); modelBuilder.Entity().OwnsOne(album => album.Pictures); modelBuilder.Entity().Navigation(album => album.Pictures).IsRequired(false); - modelBuilder.Entity().ToTable("UserAlbums"); - - modelBuilder.Entity() + modelBuilder.Entity() .HasOne(album => album.Owner) .WithMany() .HasForeignKey(album => album.OwnerUserId) .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity().ToTable("ExternalAlbums"); - - modelBuilder.Entity() - .HasIndex(album => new { album.Source, album.ExternalId }) - .IsUnique(); - modelBuilder.Entity() .HasOne(albumTrack => albumTrack.Album) .WithMany(album => album.AlbumTracks) @@ -121,7 +86,6 @@ namespace Musify.Application.Tests.TestSupport .HasOne(item => item.Track) .WithMany() .HasForeignKey(item => item.TrackId) - .IsRequired(false) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() @@ -154,22 +118,10 @@ namespace Musify.Application.Tests.TestSupport public DbSet Albums => Set(); - public DbSet UserAlbums => Set(); - - public DbSet ExternalAlbums => Set(); - public DbSet AlbumHasTracks => Set(); public DbSet Tracks => Set(); - public DbSet LocalTracks => Set(); - - public DbSet ExternalTracks => Set(); - - public DbSet Artists => Set(); - - public DbSet TrackArtists => Set(); - public DbSet UserHasTracks => Set(); public DbSet PlayListHasTracks => Set(); diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs index 4e16da1..1716c54 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs @@ -49,7 +49,7 @@ namespace Musify.Application.Tests.TestSupport TranscodeStatus = ProcessingStatus.Pending }; - public static LocalTrack LocalTrack( + public static Track Track( User owner, string title = "Test Track", double durationSeconds = 180, @@ -68,36 +68,6 @@ namespace Musify.Application.Tests.TestSupport Audio = audio ?? ProcessedAudio() }; - public static ExternalTrack ExternalTrack( - string externalId = "video-id", - string title = "Test External Track", - double durationSeconds = 180, - TrackSource source = TrackSource.YouTube, - TrackPictures? pictures = null, - TrackAudio? audio = null, - LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) => - new() - { - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - DurationSeconds = durationSeconds, - LifeCycleStatus = lifeCycleStatus, - Source = source, - ExternalId = externalId, - Pictures = pictures ?? ProcessedPictures(), - Audio = audio ?? ProcessedAudio() - }; - - public static Artist Artist( - string name = "Test Artist", - string externalId = "artist-id") => - new() - { - Name = name, - NormalizedName = name.ToUpperInvariant(), - ExternalId = externalId - }; - public static PlayListPictures PlayListPictures() => new() { OriginalName = "original.webp", @@ -132,7 +102,7 @@ namespace Musify.Application.Tests.TestSupport LargeName = "large.webp" }; - public static UserAlbum UserAlbum( + public static Album Album( long ownerUserId, string title = "Test Album", string? description = null, @@ -148,20 +118,6 @@ namespace Musify.Application.Tests.TestSupport Pictures = pictures ?? AlbumPictures() }; - public static ExternalAlbum ExternalAlbum( - string externalId = "album-id", - string title = "Test External Album", - TrackSource source = TrackSource.YouTube, - string? thumbnailUrl = null) => - new() - { - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - Source = source, - ExternalId = externalId, - ThumbnailUrl = thumbnailUrl - }; - public static UploadIntent UploadIntent( long userId, UploadIntentPurpose purpose = UploadIntentPurpose.TrackPicture, @@ -200,25 +156,13 @@ namespace Musify.Application.Tests.TestSupport public static MixItem MixItem( Guid mixId, - int position = 0, - MixItemSource source = MixItemSource.Musify, - Guid? trackId = null, - string? videoId = null, - string title = "Test Mix Item", - string? artist = "Test Artist", - double durationSeconds = 180, - bool isExplicit = false) => + Guid trackId, + int position = 0) => new() { MixId = mixId, Position = position, - Source = source, - TrackId = trackId, - VideoId = videoId, - Title = title, - Artist = artist, - DurationSeconds = durationSeconds, - IsExplicit = isExplicit + TrackId = trackId }; public static ListeningHistory ListeningHistory( diff --git a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs index a86a0d5..55898d1 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs @@ -48,11 +48,11 @@ namespace Musify.Application.Tests.Tracks Assert.False(result.IsError); Assert.Equal("My Song", result.Value.Title); - var localResponse = Assert.IsType(result.Value); + var localResponse = Assert.IsType(result.Value); Assert.Equal(user.Id, localResponse.OwnerUserId); await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); - var stored = await Database.LocalTracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); + var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); Assert.NotNull(stored); Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus); } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs index df70129..da76df3 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs @@ -18,7 +18,7 @@ namespace Musify.Application.Tests.Tracks public async Task Handle_Owner_MarksTrackAsRemovingAndPublishesEvent() { var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track); var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); @@ -43,7 +43,7 @@ namespace Musify.Application.Tests.Tracks { var owner = TestEntities.User(1, "owner"); var stranger = TestEntities.User(2, "stranger"); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, stranger, track); var result = await CreateHandler().Handle(new DeleteTrackCommand(stranger.Id, track.Id), TestContext.Current.CancellationToken); @@ -51,22 +51,11 @@ namespace Musify.Application.Tests.Tracks Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); } - [Fact] - public async Task Handle_ExternalTrack_ReturnsUnauthorized() - { - var externalTrack = TestEntities.ExternalTrack(); - await SeedAsync(externalTrack); - - var result = await CreateHandler().Handle(new DeleteTrackCommand(1, externalTrack.Id), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); - } - [Fact] public async Task Handle_AudioStillProcessing_ReturnsConflict() { var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner, audio: TestEntities.PendingAudio()); + var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio()); await SeedAsync(owner, track); var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs index 97178d0..c52a703 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs @@ -12,8 +12,8 @@ namespace Musify.Application.Tests.Tracks public async Task Handle_MultipleListens_ReturnsDistinctTracksMostRecentFirst() { var owner = TestEntities.User(); - var older = TestEntities.LocalTrack(owner, "Older listen"); - var newer = TestEntities.LocalTrack(owner, "Newer listen"); + var older = TestEntities.Track(owner, "Older listen"); + var newer = TestEntities.Track(owner, "Newer listen"); await SeedAsync( owner, older, newer, TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-10)), diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs index 3d21c08..9805b3d 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs @@ -14,7 +14,7 @@ namespace Musify.Application.Tests.Tracks public async Task Handle_LocalTrack_ReturnsItWithOwnerAsArtist() { var owner = TestEntities.User(1, "artist-name"); - var track = TestEntities.LocalTrack(owner, "My Song"); + var track = TestEntities.Track(owner, "My Song"); await SeedAsync(owner, track); var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken); @@ -22,7 +22,7 @@ namespace Musify.Application.Tests.Tracks Assert.False(result.IsError); Assert.Equal("My Song", result.Value.Title); Assert.Equal(owner.Name, result.Value.Artist); - var localResponse = Assert.IsType(result.Value); + var localResponse = Assert.IsType(result.Value); Assert.Equal(owner.Id, localResponse.OwnerUserId); } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs index c1144a1..cd3b6fd 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs @@ -18,7 +18,7 @@ namespace Musify.Application.Tests.Tracks public async Task Handle_ProcessedTrack_ReturnsRequestedSize(string size, string expectedFileName) { var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track); var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, size), TestContext.Current.CancellationToken); @@ -41,7 +41,7 @@ namespace Musify.Application.Tests.Tracks public async Task Handle_PictureNotProcessedYet_ReturnsNotFound() { var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner, pictures: TestEntities.PendingPictures()); + var track = TestEntities.Track(owner, pictures: TestEntities.PendingPictures()); await SeedAsync(owner, track); var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, "small"), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs index b3f30de..d69cc17 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs @@ -32,7 +32,7 @@ namespace Musify.Application.Tests.Tracks public async Task Handle_ProcessedTrack_IssuesTicketAndRecordsListen() { var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track); var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), TestContext.Current.CancellationToken); @@ -59,7 +59,7 @@ namespace Musify.Application.Tests.Tracks public async Task Handle_AudioNotProcessedYet_ReturnsConflict() { var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner, audio: TestEntities.PendingAudio()); + var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio()); await SeedAsync(owner, track); var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), TestContext.Current.CancellationToken); @@ -71,7 +71,7 @@ namespace Musify.Application.Tests.Tracks public async Task Handle_AnonymousUser_AnonymousListeningDisabled_ReturnsUnauthorized() { var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track); var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: false)); @@ -85,7 +85,7 @@ namespace Musify.Application.Tests.Tracks public async Task Handle_AnonymousUser_AnonymousListeningEnabled_IssuesTicketWithoutRecordingHistory() { var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track); var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true)); diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs index 1a24965..1e6f20e 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs @@ -14,8 +14,8 @@ namespace Musify.Application.Tests.Tracks { var owner = TestEntities.User(1, "owner"); var other = TestEntities.User(2, "other"); - var mine = TestEntities.LocalTrack(owner, "Mine"); - var theirs = TestEntities.LocalTrack(other, "Theirs"); + var mine = TestEntities.Track(owner, "Mine"); + var theirs = TestEntities.Track(other, "Theirs"); await SeedAsync( owner, other, mine, theirs, new UserHasTrack { UserId = owner.Id, TrackId = mine.Id }, @@ -34,7 +34,7 @@ namespace Musify.Application.Tests.Tracks public async Task Handle_NonPositivePageNumber_FallsBackToFirstPage(int requestedPageNumber, int expectedPageNumber) { var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner); + var track = TestEntities.Track(owner); await SeedAsync(owner, track, new UserHasTrack { UserId = owner.Id, TrackId = track.Id }); var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: requestedPageNumber, PageSize: 10), TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs index df94c4f..a22dd53 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs @@ -1,26 +1,19 @@ -using ErrorOr; -using Musify.Application.Contracts; using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; -using Musify.Domain.ValueObjects; -using NSubstitute; using Xunit; namespace Musify.Application.Tests.Tracks { public sealed class GetTracksQueryHandlerTests : HandlerTestBase { - private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - - private GetTracksQueryHandler CreateHandler() => - new(Database, youTubeMusicService, NoOpLogger()); + private GetTracksQueryHandler CreateHandler() => new(Database); [Fact] - public async Task Handle_NoNameFilter_ReturnsEveryTrackAUserOwns_AndDoesNotCallYouTube() + public async Task Handle_NoNameFilter_ReturnsEveryTrackAUserOwns() { var owner = TestEntities.User(); - var trackA = TestEntities.LocalTrack(owner, "Alpha"); - var trackB = TestEntities.LocalTrack(owner, "Beta"); + var trackA = TestEntities.Track(owner, "Alpha"); + var trackB = TestEntities.Track(owner, "Beta"); await SeedAsync( owner, trackA, trackB, new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = trackA.Id }, @@ -31,81 +24,24 @@ namespace Musify.Application.Tests.Tracks Assert.False(result.IsError); Assert.Equal(2, result.Value.TotalItemCount); Assert.Equal(2, result.Value.Items.Count); - Assert.All(result.Value.Items, item => Assert.NotNull(item.Track)); - await youTubeMusicService.DidNotReceiveWithAnyArgs() - .SearchSongsAsync(default!, default!, TestContext.Current.CancellationToken); } [Fact] - public async Task Handle_NameFilter_ReturnsMatchingLocalTracks_FollowedByYouTubeResults() + public async Task Handle_NameFilter_ReturnsOnlyMatchingTracks() { var owner = TestEntities.User(); - var match = TestEntities.LocalTrack(owner, "Bohemian Rhapsody"); - var other = TestEntities.LocalTrack(owner, "Imagine"); + var match = TestEntities.Track(owner, "Bohemian Rhapsody"); + var other = TestEntities.Track(owner, "Imagine"); await SeedAsync( owner, match, other, new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = match.Id }, new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = other.Id }); - var song = new YouTubeSongResult("video-1", "Bohemian Rhapsody (Live)", "Queen", "A Night at the Opera", - 354, "thumb.jpg", false, []); - youTubeMusicService.SearchSongsAsync("rhapsody", string.Empty, Arg.Any()) - .Returns(new YouTubeSearchResult([song], "next-token")); - - var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(2, result.Value.Items.Count); - Assert.NotNull(result.Value.Items[0].Track); - Assert.Equal("Bohemian Rhapsody", result.Value.Items[0].Track!.Title); - Assert.Null(result.Value.Items[1].Track); - Assert.NotNull(result.Value.Items[1].YouTubeSong); - Assert.Equal("video-1", result.Value.Items[1].YouTubeSong!.VideoId); - Assert.Equal("next-token", result.Value.NextYoutubeContinuationToken); - Assert.False(result.Value.YoutubeUnavailable); - } - - [Fact] - public async Task Handle_NameFilter_SkipsYouTubeResultAlreadyProvisionedLocally() - { - var owner = TestEntities.User(); - var provisioned = TestEntities.ExternalTrack(externalId: "video-1", title: "Bohemian Rhapsody", source: TrackSource.YouTube); - await SeedAsync( - owner, provisioned, - new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = provisioned.Id }); - - var song = new YouTubeSongResult("video-1", "Bohemian Rhapsody", "Queen", "A Night at the Opera", - 354, "thumb.jpg", false, []); - youTubeMusicService.SearchSongsAsync("rhapsody", string.Empty, Arg.Any()) - .Returns(new YouTubeSearchResult([song], string.Empty)); - var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); Assert.False(result.IsError); var item = Assert.Single(result.Value.Items); - Assert.NotNull(item.Track); - Assert.Null(item.YouTubeSong); - } - - [Fact] - public async Task Handle_NameFilter_YouTubeUnavailable_StillReturnsLocalResults() - { - var owner = TestEntities.User(); - var match = TestEntities.LocalTrack(owner, "Bohemian Rhapsody"); - await SeedAsync( - owner, match, - new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = match.Id }); - - youTubeMusicService.SearchSongsAsync("rhapsody", string.Empty, Arg.Any()) - .Returns(Error.Forbidden(description: "The YouTube Music source is disabled.")); - - var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var item = Assert.Single(result.Value.Items); - Assert.NotNull(item.Track); - Assert.True(result.Value.YoutubeUnavailable); - Assert.Null(result.Value.NextYoutubeContinuationToken); + Assert.Equal("Bohemian Rhapsody", item.Track.Title); } } } diff --git a/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs index bd3a725..1061cae 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs @@ -25,8 +25,8 @@ namespace Musify.Application.Tests.Users public async Task Handle_ListensThisWeek_CountsDistinctTracksAndTotalSeconds() { var user = TestEntities.User(); - var trackA = TestEntities.LocalTrack(user, "Track A", durationSeconds: 200); - var trackB = TestEntities.LocalTrack(user, "Track B", durationSeconds: 100); + var trackA = TestEntities.Track(user, "Track A", durationSeconds: 200); + var trackB = TestEntities.Track(user, "Track B", durationSeconds: 100); await SeedAsync( user, trackA, trackB, TestEntities.ListeningHistory(user.Id, trackA.Id, DateTime.UtcNow.AddDays(-1)), @@ -44,7 +44,7 @@ namespace Musify.Application.Tests.Users public async Task Handle_ListenedTodayAndYesterday_StreakIsTwo() { var user = TestEntities.User(); - var track = TestEntities.LocalTrack(user); + var track = TestEntities.Track(user); await SeedAsync( user, track, TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow), @@ -59,7 +59,7 @@ namespace Musify.Application.Tests.Users public async Task Handle_GapBeforeToday_StopsStreakAtGap() { var user = TestEntities.User(); - var track = TestEntities.LocalTrack(user); + var track = TestEntities.Track(user); await SeedAsync( user, track, TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow), @@ -74,7 +74,7 @@ namespace Musify.Application.Tests.Users public async Task Handle_NoListenToday_StreakIsZero() { var user = TestEntities.User(); - var track = TestEntities.LocalTrack(user); + var track = TestEntities.Track(user); await SeedAsync( user, track, TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-1))); diff --git a/backend/Tests/Musify.Application.Tests/YouTube/GetYouTubeAlbumQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/YouTube/GetYouTubeAlbumQueryHandlerTests.cs deleted file mode 100644 index ff13a7d..0000000 --- a/backend/Tests/Musify.Application.Tests/YouTube/GetYouTubeAlbumQueryHandlerTests.cs +++ /dev/null @@ -1,62 +0,0 @@ -using ErrorOr; -using Microsoft.EntityFrameworkCore; -using Musify.Application.Contracts; -using Musify.Application.Services; -using Musify.Application.Tests.TestSupport; -using Musify.Application.YouTube; -using NSubstitute; -using Xunit; - -namespace Musify.Application.Tests.YouTube -{ - public sealed class GetYouTubeAlbumQueryHandlerTests : HandlerTestBase - { - private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - - private GetYouTubeAlbumQueryHandler CreateHandler() - { - var provisioner = new YouTubeTrackProvisioner( - Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger()); - - return new GetYouTubeAlbumQueryHandler(youTubeMusicService, provisioner, NoOpLogger()); - } - - private static YouTubeAlbumDetail BuildDetail() => new( - new YouTubeAlbumResult("album-1", "Album Title", "Album Artist", "https://img/thumb.jpg", 2024, false, false, []), - Description: "An album", - TotalDurationSeconds: 600, - Tracks: [new YouTubeAlbumTrack("track-1", "Track One", 200, 1, false)]); - - [Fact] - public async Task Handle_EmptyAlbumId_ReturnsValidationError() - { - var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery(string.Empty), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - } - - [Fact] - public async Task Handle_ServiceSucceeds_MaterializesAlbumAndReturnsDetail() - { - var detail = BuildDetail(); - youTubeMusicService.GetAlbumAsync("album-1", Arg.Any()).Returns(detail); - - var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery("album-1"), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Same(detail, result.Value); - Assert.NotEmpty(await Database.ExternalAlbums.ToListAsync(TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task Handle_ServiceFails_ReturnsError() - { - youTubeMusicService.GetAlbumAsync("missing", Arg.Any()) - .Returns(Error.NotFound(description: "Album not found")); - - var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery("missing"), TestContext.Current.CancellationToken); - - Assert.True(result.IsError); - } - } -} diff --git a/backend/Tests/Musify.Application.Tests/YouTube/ResolveYouTubeTrackStreamCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/YouTube/ResolveYouTubeTrackStreamCommandHandlerTests.cs deleted file mode 100644 index b47cbc5..0000000 --- a/backend/Tests/Musify.Application.Tests/YouTube/ResolveYouTubeTrackStreamCommandHandlerTests.cs +++ /dev/null @@ -1,125 +0,0 @@ -using ErrorOr; -using Microsoft.EntityFrameworkCore; -using Musify.Application.Configuration; -using Musify.Application.Contracts; -using Musify.Application.Services; -using Musify.Application.Tests.TestSupport; -using Musify.Application.YouTube; -using Musify.Application.YouTube.Responses; -using NSubstitute; -using Xunit; - -namespace Musify.Application.Tests.YouTube -{ - public sealed class ResolveYouTubeTrackStreamCommandHandlerTests : HandlerTestBase - { - private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - private readonly IStreamTicketService ticketService = Substitute.For(); - - private ResolveYouTubeTrackStreamCommandHandler CreateHandler(PlaybackConfiguration? playback = null) - { - playback ??= TestConfigurations.Playback(); - var provisioner = new YouTubeTrackProvisioner( - Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger()); - var issuer = new TrackStreamIssuer( - Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway(), playback); - - return new ResolveYouTubeTrackStreamCommandHandler( - Database, youTubeMusicService, provisioner, issuer, playback, NoOpLogger()); - } - - [Fact] - public async Task Handle_AlreadyProcessedTrack_IssuesServerStreamWithoutCallingYouTubeForAudio() - { - ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); - - var user = TestEntities.User(1); - var track = TestEntities.ExternalTrack(externalId: "abc123"); - await SeedAsync(user, track); - - var result = await CreateHandler().Handle(new ResolveYouTubeTrackStreamCommand("abc123", 1), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(YouTubeStreamMode.Server, result.Value.Mode); - Assert.Equal("token", result.Value.Ticket); - await youTubeMusicService.DidNotReceiveWithAnyArgs().GetAudioStreamAsync(default!, TestContext.Current.CancellationToken); - } - - [Fact] - public async Task Handle_UnknownTrack_ProvisionsItAndRecordsListeningHistory() - { - youTubeMusicService.GetAudioStreamAsync("new-video", Arg.Any()) - .Returns(new YouTubeStreamInfo("https://youtube.example/stream", 300)); - youTubeMusicService.GetSongAsync("new-video", Arg.Any()) - .Returns(new YouTubeSongResult("new-video", "Title", "Artist", "Album", 200, "https://img/thumb.jpg", false, [])); - youTubeMusicService.ResolveArtworkUrl(Arg.Any()).Returns(callInfo => callInfo.Arg()); - await SeedAsync(TestEntities.User(42)); - - var result = await CreateHandler().Handle(new ResolveYouTubeTrackStreamCommand("new-video", 42), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(YouTubeStreamMode.YouTube, result.Value.Mode); - Assert.Equal("https://youtube.example/stream", result.Value.StreamUrl); - Assert.NotNull(result.Value.TrackId); - - var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); - Assert.Equal(42, history.UserId); - } - - [Fact] - public async Task Handle_AnonymousUser_AnonymousListeningDisabled_ReturnsUnauthorized() - { - var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: false)); - - var result = await handler.Handle(new ResolveYouTubeTrackStreamCommand("abc123", UserId: null), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); - await youTubeMusicService.DidNotReceiveWithAnyArgs().GetAudioStreamAsync(default!, TestContext.Current.CancellationToken); - } - - [Fact] - public async Task Handle_AnonymousUser_AlreadyProcessedTrack_IssuesServerStreamWithoutRecordingHistory() - { - ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); - var track = TestEntities.ExternalTrack(externalId: "abc123"); - await SeedAsync(track); - - var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true)); - - var result = await handler.Handle(new ResolveYouTubeTrackStreamCommand("abc123", UserId: null), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(YouTubeStreamMode.Server, result.Value.Mode); - Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task Handle_AnonymousUser_FragmentCapConfigured_UnprovisionedTrack_ReturnsConflictWithoutCallingYouTube() - { - var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30)); - - var result = await handler.Handle(new ResolveYouTubeTrackStreamCommand("not-provisioned-yet", UserId: null), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Conflict, result.FirstError.Type); - await youTubeMusicService.DidNotReceiveWithAnyArgs().GetAudioStreamAsync(default!, TestContext.Current.CancellationToken); - } - - [Fact] - public async Task Handle_AnonymousUser_NoFragmentCap_UnprovisionedTrack_FallsBackToDirectYouTubeStream() - { - youTubeMusicService.GetAudioStreamAsync("new-video", Arg.Any()) - .Returns(new YouTubeStreamInfo("https://youtube.example/stream", 300)); - youTubeMusicService.GetSongAsync("new-video", Arg.Any()) - .Returns(new YouTubeSongResult("new-video", "Title", "Artist", "Album", 200, "https://img/thumb.jpg", false, [])); - youTubeMusicService.ResolveArtworkUrl(Arg.Any()).Returns(callInfo => callInfo.Arg()); - - var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 0)); - - var result = await handler.Handle(new ResolveYouTubeTrackStreamCommand("new-video", UserId: null), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(YouTubeStreamMode.YouTube, result.Value.Mode); - Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); - } - } -} diff --git a/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs index 62afb43..730b9b4 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs @@ -10,7 +10,7 @@ namespace Musify.Infrastructure.Tests.Persistence public sealed class DatabaseTests(InfrastructureTestFixture fixture) { [Fact] - public async Task SaveChangesAsync_LocalTrackWithOwnedTypes_RoundTripsThroughPostgres() + public async Task SaveChangesAsync_TrackWithOwnedTypes_RoundTripsThroughPostgres() { await using var database = fixture.CreateDatabase(); @@ -19,7 +19,7 @@ namespace Musify.Infrastructure.Tests.Persistence await database.Users.AddAsync(user, TestContext.Current.CancellationToken); var trackTitle = "Round Trip Track"; - var track = new LocalTrack + var track = new Track { Title = trackTitle, NormalizedTitle = trackTitle.ToUpperInvariant(), @@ -29,32 +29,16 @@ namespace Musify.Infrastructure.Tests.Persistence Pictures = new TrackPictures { OriginalName = "cover.webp", ProcessingStatus = ProcessingStatus.Pending }, Audio = new TrackAudio { OriginalName = "song.mp3", TranscodeStatus = ProcessingStatus.Pending } }; - await database.LocalTracks.AddAsync(track, TestContext.Current.CancellationToken); + await database.Tracks.AddAsync(track, TestContext.Current.CancellationToken); await database.SaveChangesAsync(TestContext.Current.CancellationToken); await using var reloaded = fixture.CreateDatabase(); var stored = await reloaded.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken); - Assert.IsType(stored); Assert.Equal(trackTitle, stored.Title); Assert.Equal("cover.webp", stored.Pictures.OriginalName); Assert.Equal("song.mp3", stored.Audio.OriginalName); - Assert.Equal(user.Id, ((LocalTrack)stored).OwnerUserId); - } - - [Fact] - public async Task ExternalTracks_SourceAndExternalIdUniqueIndex_RejectsDuplicates() - { - await using var database = fixture.CreateDatabase(); - - var videoId = $"dup-{Guid.NewGuid():N}"; - await database.ExternalTracks.AddAsync(BuildExternalTrack(videoId), TestContext.Current.CancellationToken); - await database.SaveChangesAsync(TestContext.Current.CancellationToken); - - await using var second = fixture.CreateDatabase(); - await second.ExternalTracks.AddAsync(BuildExternalTrack(videoId), TestContext.Current.CancellationToken); - - await Assert.ThrowsAsync(() => second.SaveChangesAsync(TestContext.Current.CancellationToken)); + Assert.Equal(user.Id, stored.OwnerUserId); } [Fact] @@ -65,33 +49,32 @@ namespace Musify.Infrastructure.Tests.Persistence var userName = "cascade-user"; var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() }; var albumTitle = "Cascade Album"; - var album = new UserAlbum { Title = albumTitle, NormalizedTitle = albumTitle.ToUpperInvariant(), OwnerUserId = user.Id }; - var track = BuildExternalTrack($"cascade-{Guid.NewGuid():N}"); + var album = new Album { Title = albumTitle, NormalizedTitle = albumTitle.ToUpperInvariant(), OwnerUserId = user.Id }; + var track = BuildTrack(user, $"cascade-{Guid.NewGuid():N}"); var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }; await database.AddRangeAsync(user, album, track, link); await database.SaveChangesAsync(TestContext.Current.CancellationToken); await using var deleter = fixture.CreateDatabase(); - var albumToDelete = await deleter.UserAlbums.SingleAsync(a => a.Id == album.Id, TestContext.Current.CancellationToken); - deleter.UserAlbums.Remove(albumToDelete); + var albumToDelete = await deleter.Albums.SingleAsync(a => a.Id == album.Id, TestContext.Current.CancellationToken); + deleter.Albums.Remove(albumToDelete); await deleter.SaveChangesAsync(TestContext.Current.CancellationToken); await using var verifier = fixture.CreateDatabase(); Assert.False(await verifier.AlbumHasTracks.AnyAsync(l => l.AlbumId == album.Id, TestContext.Current.CancellationToken)); - Assert.True(await verifier.ExternalTracks.AnyAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken)); + Assert.True(await verifier.Tracks.AnyAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken)); } - private static ExternalTrack BuildExternalTrack(string externalId) + private static Track BuildTrack(User owner, string title) { - var title = "External"; - return new ExternalTrack + return new Track { Title = title, NormalizedTitle = title.ToUpperInvariant(), DurationSeconds = 100, - Source = TrackSource.YouTube, - ExternalId = externalId, + OwnerUserId = owner.Id, + Owner = owner, Pictures = new TrackPictures(), Audio = new TrackAudio() }; diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/DisabledYouTubeMusicServiceTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/DisabledYouTubeMusicServiceTests.cs deleted file mode 100644 index 632b041..0000000 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/DisabledYouTubeMusicServiceTests.cs +++ /dev/null @@ -1,59 +0,0 @@ -using ErrorOr; -using Musify.Infrastructure.Services; -using Xunit; - -namespace Musify.Infrastructure.Tests.Services -{ - public sealed class DisabledYouTubeMusicServiceTests - { - private readonly DisabledYouTubeMusicService service = new(); - - [Fact] - public async Task SearchSongsAsync_ReturnsForbidden() - { - var result = await service.SearchSongsAsync("query", string.Empty, TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - } - - [Fact] - public async Task SearchAlbumsAsync_ReturnsForbidden() - { - var result = await service.SearchAlbumsAsync("query", string.Empty, TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - } - - [Fact] - public async Task GetAlbumAsync_ReturnsForbidden() - { - var result = await service.GetAlbumAsync("album-1", TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - } - - [Fact] - public async Task GetAudioStreamAsync_ReturnsForbidden() - { - var result = await service.GetAudioStreamAsync("video-1", TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - } - - [Fact] - public async Task GetSongAsync_ReturnsForbidden() - { - var result = await service.GetSongAsync("video-1", TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - } - - [Fact] - public void ResolveArtworkUrl_ReturnsTheUrlUnchanged() - { - var result = service.ResolveArtworkUrl("https://img.example/thumb.jpg"); - - Assert.Equal("https://img.example/thumb.jpg", result); - } - } -} diff --git a/deploy/.env.prod.example b/deploy/.env.prod.example index 7bcb394..b6befc8 100644 --- a/deploy/.env.prod.example +++ b/deploy/.env.prod.example @@ -63,8 +63,3 @@ AUTH_ALLOW_INSECURE_HTTP=false AUTH_REQUIRE_HTTPS=true # Largest request body nginx accepts (audio uploads go through the web player). MAX_UPLOAD_SIZE=1024m -# Disk nginx gives to the cached YouTube artwork it serves for the web player. -THUMBNAIL_CACHE_SIZE=2g -# Extra yt-dlp flags for the Worker. The cookie jar avoids YouTube's bot checks; -# drop it here if you do not have one: deploy/secrets/ is mounted at /secrets. -YTDLP_ADDITIONAL_ARGUMENTS=--cookies /secrets/youtube_cookies.txt --extractor-retries 1 --socket-timeout 15 -N 4 diff --git a/deploy/README.md b/deploy/README.md index bcc2a5f..03e6b89 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -7,7 +7,7 @@ same set of compose files — no wrapper scripts, just `docker compose`. |---|---| | `compose.yml` | Every service, including the one-shot bootstrap jobs. No published ports, no domains, no environment-specific values. | | `compose.dev.yml` | Development overlay: publishes the ports to the host, adds pgAdmin. | -| `compose.prod.yml` | Production overlay: adds the nginx edge and the mounted secrets. | +| `compose.prod.yml` | Production overlay: adds the nginx edge. | | `.env` / `.env.prod` | The values compose interpolates. Not versioned; templates in `.env.example` / `.env.prod.example`. | The four applications (`api`, `worker`, `gateway`, `web-player`) sit behind the @@ -76,9 +76,9 @@ Add `--profile tools` to also start pgAdmin. | pgAdmin (`--profile tools`) | `5050` | Postgres UI | | API, gateway, web player (`--profile apps`) | `5111` / `8081` / `3000` | | -Host requirements: .NET 10 SDK, Node 22, Docker Desktop. (ffmpeg and yt-dlp -are only needed on the host if you run the Worker outside Docker — inside -Docker its image already has them.) +Host requirements: .NET 10 SDK, Node 22, Docker Desktop. (ffmpeg is only +needed on the host if you run the Worker outside Docker — inside Docker its +image already has it.) ### Why `host.docker.internal` @@ -136,13 +136,6 @@ infra containers and only rebuilds and restarts the apps). Only nginx publishes ports (80/443); everything else is reachable only on the internal compose network. -### Optional secrets - -`deploy/secrets/` is mounted read-only at `/secrets` in the Worker. Drop a -`youtube_cookies.txt` there if you want yt-dlp to use a cookie jar — it is -referenced from `YTDLP_ADDITIONAL_ARGUMENTS` in `.env.prod`, which you can -empty if you do not have one. - --- ## Layout @@ -151,10 +144,9 @@ empty if you do not have one. deploy/ ├─ compose.yml # all services, incl. keys-init / migrate / zitadel-init ├─ compose.dev.yml # dev overlay: host ports, pgAdmin -├─ compose.prod.yml # prod overlay: nginx edge, secrets +├─ compose.prod.yml # prod overlay: nginx edge ├─ .env.example / .env.prod.example ├─ keys/ # RS256 stream-ticket key pair (generated) -├─ secrets/ # mounted into the Worker (prod, optional) ├─ nginx/ │ ├─ templates/default.conf.template # vhosts, rendered with the domain by envsubst │ ├─ snippets/proxy.conf # proxy headers shared by every vhost diff --git a/deploy/compose.prod.yml b/deploy/compose.prod.yml index b00ac3f..344b226 100644 --- a/deploy/compose.prod.yml +++ b/deploy/compose.prod.yml @@ -13,11 +13,6 @@ name: musify-prod # the first command just wrote into deploy/.env.prod. See deploy/README.md. services: - worker: - # yt-dlp reads the YouTube cookie jar from here (see YTDLP_ADDITIONAL_ARGUMENTS). - volumes: - - ./secrets:/secrets:ro - # zitadel-init (compose.yml) edits whichever env file is mounted here. zitadel-init: volumes: @@ -40,7 +35,6 @@ services: # The filter keeps it from eating nginx's own $host / $remote_addr. MUSIFY_ROOT_DOMAIN: ${ROOT_DOMAIN} MUSIFY_MAX_UPLOAD_SIZE: ${MAX_UPLOAD_SIZE:-1024m} - MUSIFY_THUMBNAIL_CACHE_SIZE: ${THUMBNAIL_CACHE_SIZE:-2g} NGINX_ENVSUBST_FILTER: ^MUSIFY_ ports: - "80:80" diff --git a/deploy/compose.yml b/deploy/compose.yml index 5773ed2..cdb5b6e 100644 --- a/deploy/compose.yml +++ b/deploy/compose.yml @@ -447,7 +447,6 @@ services: DOTNET_ENVIRONMENT: ${DOTNET_ENVIRONMENT} AudioTranscoder__Routes__WorkingDirectory: /tmp/musify Workers__Routes__TemporaryFilesDirectory: /tmp/musify - YtDlp__AdditionalArguments: ${YTDLP_ADDITIONAL_ARGUMENTS:-} restart: unless-stopped # YARP in front of the SeaweedFS filer: validates the RS256 stream ticket and diff --git a/deploy/nginx/templates/default.conf.template b/deploy/nginx/templates/default.conf.template index 2493e89..ed2ac8a 100644 --- a/deploy/nginx/templates/default.conf.template +++ b/deploy/nginx/templates/default.conf.template @@ -9,12 +9,6 @@ resolver 127.0.0.11 valid=10s; -# YouTube artwork the web player proxies from Google. Caching it on disk keeps -# repeated searches off both the node process and Google, which answers 429 -# once a client pulls a whole grid of covers at a time. -proxy_cache_path /var/cache/nginx/thumbnails levels=1:2 keys_zone=thumbnails:10m - max_size=${MUSIFY_THUMBNAIL_CACHE_SIZE} inactive=30d use_temp_path=off; - map $http_upgrade $connection_upgrade { default upgrade; '' close; @@ -40,21 +34,6 @@ server { ssl_certificate /etc/nginx/certs/origin.pem; ssl_certificate_key /etc/nginx/certs/origin.key; - location = /api/youtube/thumbnail { - set $upstream http://web-player:3000; - proxy_pass $upstream; - include /etc/nginx/snippets/proxy.conf; - - proxy_cache thumbnails; - proxy_cache_key $uri$is_args$args; - proxy_cache_valid 200 30d; - proxy_cache_valid 400 404 5m; - proxy_cache_lock on; - proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; - proxy_cache_background_update on; - add_header X-Cache-Status $upstream_cache_status; - } - location / { set $upstream http://web-player:3000; proxy_pass $upstream; diff --git a/docs/development.md b/docs/development.md index 33c0809..1acbc55 100644 --- a/docs/development.md +++ b/docs/development.md @@ -83,7 +83,7 @@ dotnet test backend/Musify.slnx | Proyecto | Cubre | Docker | |---|---|---| | `Musify.Domain.Tests` | Lógica de los value objects (`TrackAudio`/`TrackPictures`: `IsProcessed`, `IsFailed`, `IsInProgress`). Las entidades son en su mayoría anémicas — sin comportamiento propio, nada más que testear ahí. | No | -| `Musify.Application.Tests` | Los ~45 handlers y servicios de `Musify.Application` (Albums, Tracks, PlayLists, Users, Mixes, YouTube). | No | +| `Musify.Application.Tests` | Los ~45 handlers y servicios de `Musify.Application` (Albums, Tracks, PlayLists, Users, Mixes). | No | | `Musify.Infrastructure.Tests` | `Database`/migraciones, `StorageService`, `StreamTicketService`, `AuditableEntityInterceptor`, `PictureService`, `SingleFlightCache`, `PlayListPresetSeeder`. | **Sí** | | `Musify.Api.Tests` | Endpoints HTTP reales (routing, auth, `ValidationFilter`, mapeo `ErrorOr`→HTTP) vía `WebApplicationFactory`, más `ErrorOrHttpExtensions`/`CurrentUser`/validators FluentValidation en aislado. | **Sí** | | `Musify.StreamingGateway.Tests` | `TicketValidator` (RS256) y `TicketValidationMiddleware` (traversal, límites de prefijo, extracción de ticket). | No | @@ -102,8 +102,8 @@ NSubstitute, no hay `IQueryable` detrás de un mock. En su lugar, del proveedor InMemory de EF Core, SQLite además soporta transacciones, que dos handlers usan). `TestSupport/TestEntities.cs` y `TestConfigurations.cs` construyen entidades y configuración con valores por defecto sensatos. -`IStorageService`, `IEventBus`, `IYouTubeMusicService`, etc. sí se mockean con -NSubstitute, al ser interfaces normales. +`IStorageService`, `IEventBus`, etc. sí se mockean con NSubstitute, al ser +interfaces normales. ### Infrastructure.Tests y Api.Tests: Testcontainers @@ -117,8 +117,8 @@ migraciones EF Core reales aplicadas contra el Postgres efímero. `Api.Tests` usa `WebApplicationFactory` contra ese mismo Postgres — routing, auth y validación reales — pero sustituye por fakes los servicios que -hablan por red (`IStorageService`, `IEventBus`, `IYouTubeMusicService`, -`IStreamTicketService`) y el esquema de autenticación JWT real por un handler +hablan por red (`IStorageService`, `IEventBus`, `IStreamTicketService`) y el +esquema de autenticación JWT real por un handler de prueba (ver `TestSupport/FakeAuthenticationHandler.cs`) que autentica según un header, sin necesitar un Zitadel real. @@ -142,11 +142,10 @@ se sobreescribe con variables de entorno (`Seccion__Clave`) desde Secciones clave: `Authentication` (Zitadel), `Database`, `MassTransit`, `InfrastructureStorage` (S3), `ApplicationStorage` (bucket), `Track`, `PlayList`, -`UploadIntent`, `AudioTranscoder`, `YtDlp`, `Workers`, `StreamGateway` / -`StreamTicket`. +`UploadIntent`, `AudioTranscoder`, `Workers`, `StreamGateway` / `StreamTicket`. ## Requisitos del host - .NET 10 SDK y Node 22. -- **ffmpeg** y **yt-dlp** en el PATH (transcode y descarga de audio del Worker). +- **ffmpeg** en el PATH (transcode de audio del Worker). - Docker Desktop. From 5ae9955bd05e841533dbb3f289dae38ddd363a8f Mon Sep 17 00:00:00 2001 From: XShine555 Date: Tue, 15 Sep 2026 21:17:14 +0200 Subject: [PATCH 195/217] Remove YouTube integration from web-player; unify Home track rows Drops every YouTube-backed route, proxy and type (external albums/tracks, thumbnail proxy, youtube.ts) and simplifies player/track/mix types to the local-only catalog now that the backend contract dropped Source/ExternalId. Genre tiles on Explore and "Populares esta semana" on Home stay as deliberate client-only placeholders per product decision, just without the YouTube backing. Also finishes the design-system unification pass: Home's three hand-rolled track rows (Continuar escuchando, Playlist destacada, Populares esta semana) now reuse the shared TrackTitleCell component instead of duplicating cover+title+artist markup, and the orphaned Rail.svelte (no importers) is removed. --- web-player/.env.example | 11 - web-player/src/lib/albums.ts | 30 +- web-player/src/lib/api/schema.d.ts | 272 +----------------- .../lib/components/AlbumContextMenu.svelte | 15 +- .../lib/components/TrackContextMenu.svelte | 12 +- .../src/lib/components/player/Queue.svelte | 6 +- .../lib/components/player/TrackInfo.svelte | 3 - web-player/src/lib/components/ui/Cover.svelte | 5 +- .../src/lib/components/ui/MixArt.svelte | 14 +- web-player/src/lib/components/ui/Rail.svelte | 29 -- web-player/src/lib/config.ts | 2 - web-player/src/lib/mixes.ts | 24 +- web-player/src/lib/navigation.svelte.ts | 1 - web-player/src/lib/player/liked.svelte.ts | 18 +- web-player/src/lib/player/player.svelte.ts | 93 +----- web-player/src/lib/recentlyPlayed.ts | 4 - web-player/src/lib/server/config.ts | 27 -- web-player/src/lib/server/playlistActions.ts | 70 +---- web-player/src/lib/server/thumbnails.ts | 173 ----------- web-player/src/lib/server/youtube.ts | 92 ------ web-player/src/lib/thumbnails.ts | 19 -- web-player/src/lib/tracks.ts | 73 ++--- web-player/src/lib/types.ts | 18 -- .../routes/(app)/albums/[id]/+page.server.ts | 4 +- .../src/routes/(app)/albums/[id]/+page.svelte | 6 +- .../[id]/components/AlbumTrackTable.svelte | 9 +- .../[source]/[externalId]/+page.server.ts | 32 --- .../[source]/[externalId]/+page.svelte | 179 ------------ .../src/routes/(app)/explore/+page.server.ts | 18 +- .../src/routes/(app)/explore/+page.svelte | 191 ++++-------- .../src/routes/(app)/liked/+page.svelte | 3 - .../routes/(app)/mixes/[id]/+page.server.ts | 5 +- .../src/routes/(app)/mixes/[id]/+page.svelte | 3 - .../routes/(app)/playlists/[id]/+page.svelte | 4 +- .../[id]/components/PlaylistTrackTable.svelte | 25 +- web-player/src/routes/+page.server.ts | 21 +- web-player/src/routes/+page.svelte | 137 +++------ .../[source]/[externalId]/tracks/+server.ts | 12 - .../src/routes/api/thumbnail/+server.ts | 31 -- web-player/src/routes/api/tracks/+server.ts | 3 +- .../[source]/[externalId]/stream/+server.ts | 14 - 41 files changed, 167 insertions(+), 1541 deletions(-) delete mode 100644 web-player/src/lib/components/ui/Rail.svelte delete mode 100644 web-player/src/lib/server/thumbnails.ts delete mode 100644 web-player/src/lib/server/youtube.ts delete mode 100644 web-player/src/lib/thumbnails.ts delete mode 100644 web-player/src/routes/(app)/albums/external/[source]/[externalId]/+page.server.ts delete mode 100644 web-player/src/routes/(app)/albums/external/[source]/[externalId]/+page.svelte delete mode 100644 web-player/src/routes/api/albums/external/[source]/[externalId]/tracks/+server.ts delete mode 100644 web-player/src/routes/api/thumbnail/+server.ts delete mode 100644 web-player/src/routes/api/tracks/external/[source]/[externalId]/stream/+server.ts diff --git a/web-player/.env.example b/web-player/.env.example index 8efa0a2..75b940b 100644 --- a/web-player/.env.example +++ b/web-player/.env.example @@ -26,17 +26,6 @@ API_BASE_URL=http://localhost:5111 # Dev already allows a plain-HTTP issuer. Set this only to allow it in a production build (never with a public issuer). # AUTH_ALLOW_INSECURE_HTTP=true -# Optional tuning for the YouTube artwork proxy (/api/youtube/thumbnail), -# which caches the covers in memory so the browser never hits Google directly. -# THUMBNAIL_CACHE_MAX_AGE_SECONDS=2592000 -# THUMBNAIL_FAILURE_CACHE_SECONDS=300 -# THUMBNAIL_CACHE_BYTES=134217728 -# THUMBNAIL_MAX_CONCURRENT_FETCHES=4 -# THUMBNAIL_FETCH_TIMEOUT_MS=8000 -# THUMBNAIL_COOLDOWN_SECONDS=30 -# THUMBNAIL_MAX_COOLDOWN_SECONDS=300 -# THUMBNAIL_MAX_SIZE=720 - # Where to send presigned S3 PUTs when the public S3 host is not reachable from # the server (in Docker: http://seaweedfs:8333). The signed Host header is kept # either way. Defaults to the host in the presigned URL. diff --git a/web-player/src/lib/albums.ts b/web-player/src/lib/albums.ts index 1ce1b86..9d85015 100644 --- a/web-player/src/lib/albums.ts +++ b/web-player/src/lib/albums.ts @@ -1,30 +1,8 @@ import { toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte'; -interface YouTubeAlbumTracksResponse { - album: { artist: string; thumbnailUrl: string }; - tracks: { videoId: string; title: string; isExplicit: boolean }[]; -} - -export async function fetchAlbumQueueItems( - kind: 'local' | 'youtube', - albumId: string -): Promise { - if (kind === 'local') { - const res = await fetch(`/api/albums/${albumId}/tracks`); - if (!res.ok) return []; - const data = (await res.json()) as { items: ApiTrackLike[] }; - return toQueueItems(data.items ?? []); - } - - const res = await fetch(`/api/albums/external/${kind}/${albumId}/tracks`); +export async function fetchAlbumQueueItems(albumId: string): Promise { + const res = await fetch(`/api/albums/${albumId}/tracks`); if (!res.ok) return []; - const detail = (await res.json()) as YouTubeAlbumTracksResponse; - return detail.tracks.map((track) => ({ - id: track.videoId, - title: track.title, - artist: detail.album.artist, - source: 'youtube' as const, - coverUrl: detail.album.thumbnailUrl, - explicit: track.isExplicit - })); + const data = (await res.json()) as { items: ApiTrackLike[] }; + return toQueueItems(data.items ?? []); } diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index bccb1cb..8d19371 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -11,7 +11,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get Paginated Albums, Combined With Live YouTube Music Results When A Title Filter Is Given. */ + /** Get Paginated Albums, Optionally Filtered By Title. */ get: operations["GetAlbums"]; put?: never; /** Create A New Album. */ @@ -39,23 +39,6 @@ export interface paths { patch?: never; trace?: never; }; - "/albums/external/{source}/{externalId}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get An Album From An External Source (Currently Only YouTube Music) With Its Tracklist. */ - get: operations["GetExternalAlbum"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/albums/users/{userId}": { parameters: { query?: never; @@ -220,7 +203,7 @@ export interface paths { }; get?: never; put?: never; - /** Like Or Unlike A Track, By Id Or By External Source, Provisioning It In The Background If Needed. */ + /** Like Or Unlike A Track By Id. */ post: operations["ToggleTrackLike"]; delete?: never; options?: never; @@ -324,7 +307,7 @@ export interface paths { /** Get Paginated Tracks Of A PlayList. */ get: operations["GetPlayListTracks"]; put?: never; - /** Add A Track To A PlayList, By Id Or By External Source, Provisioning It In The Background If Needed. */ + /** Add One Of Your Tracks To A PlayList. */ post: operations["AddPlayListTrack"]; delete?: never; options?: never; @@ -408,7 +391,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get Paginated Tracks, Combined With Live YouTube Music Results When A Name Filter Is Given. */ + /** Get Paginated Tracks, Optionally Filtered By Name. */ get: operations["GetTracks"]; put?: never; /** Create A New Track. */ @@ -470,23 +453,6 @@ export interface paths { patch?: never; trace?: never; }; - "/tracks/external/{source}/{externalId}/stream": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Resolve Playback For A Track From An External Source (Currently Only YouTube Music): Server Stream If Downloaded, Direct Stream From The Source Otherwise. Works Anonymously When The Playback Configuration Allows It. */ - get: operations["GetExternalTrackStream"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/tracks/users/{userId}": { parameters: { query?: never; @@ -665,37 +631,9 @@ export interface components { schemas: { AddPlayListTrackRequest: { /** Format: uuid */ - trackId: null | string; - source: null | components["schemas"]["TrackSource"]; - externalId: null | string; + trackId: string; }; - AlbumApplicationResponse: components["schemas"]["AlbumApplicationResponseAlbumApplicationResponse"] | components["schemas"]["AlbumApplicationResponseExternalAlbumApplicationResponse"]; - AlbumApplicationResponseAlbumApplicationResponse: { - /** @enum {string} */ - source?: "Local"; - /** Format: uuid */ - id: string; - title: string; - description: null | string; - /** Format: int32 */ - releaseYear: null | number | string; - ownerUserId: string; - /** Format: int32 */ - trackCount: number | string; - smallImageKeyName: null | string; - mediumImageKeyName: null | string; - largeImageKeyName: null | string; - /** Format: date-time */ - createdAt: string; - /** Format: date-time */ - updatedAt: string; - coverTrackIds: string[]; - }; - AlbumApplicationResponseExternalAlbumApplicationResponse: { - /** @enum {string} */ - source?: "YouTube"; - externalId: string; - thumbnailUrl: null | string; + AlbumApplicationResponse: { /** Format: uuid */ id: string; title: string; @@ -726,9 +664,7 @@ export interface components { uploadUrl: string; }; AlbumSearchItemResponse: { - source: components["schemas"]["TrackSource"]; - album: null | components["schemas"]["AlbumApplicationResponse"]; - youTubeAlbum: null | components["schemas"]["YouTubeAlbumResult"]; + album: components["schemas"]["AlbumApplicationResponse"]; }; AlbumsSearchResponse: { items: components["schemas"]["AlbumSearchItemResponse"][]; @@ -741,9 +677,6 @@ export interface components { /** Format: int32 */ totalItemCount: number | string; hasPreviousPage: boolean; - hasNextLocalPage: boolean; - nextYoutubeContinuationToken: null | string; - youtubeUnavailable: boolean; hasNextPage: boolean; }; CreateAlbumRequest: { @@ -804,19 +737,13 @@ export interface components { items: components["schemas"]["MixItemApplicationResponse"][]; }; MixItemApplicationResponse: { - source: components["schemas"]["MixItemSource"]; /** Format: uuid */ - trackId: null | string; - videoId: null | string; + trackId: string; title: string; artist: null | string; - thumbnailUrl: null | string; /** Format: double */ durationSeconds: number | string; - isExplicit: boolean; }; - /** @enum {unknown} */ - MixItemSource: "Musify" | "YouTube"; PaginatedResponseOfAlbumApplicationResponse: { items: components["schemas"]["AlbumApplicationResponse"][]; /** Format: int32 */ @@ -928,15 +855,9 @@ export interface components { }; ToggleLikeRequest: { /** Format: uuid */ - trackId: null | string; - source: null | components["schemas"]["TrackSource"]; - externalId: null | string; + trackId: string; }; - TrackApplicationResponse: components["schemas"]["TrackApplicationResponseLocalTrackApplicationResponse"] | components["schemas"]["TrackApplicationResponseExternalTrackApplicationResponse"]; - TrackApplicationResponseExternalTrackApplicationResponse: { - /** @enum {string} */ - source?: "YouTube"; - externalId: string; + TrackApplicationResponse: { /** Format: uuid */ id: string; title: string; @@ -950,32 +871,11 @@ export interface components { createdAt: string; /** Format: date-time */ updatedAt: string; - }; - TrackApplicationResponseLocalTrackApplicationResponse: { - /** @enum {string} */ - source?: "Local"; ownerUserId: string; - /** Format: uuid */ - id: string; - title: string; - artist: null | string; - audioStatus: components["schemas"]["ProcessingStatus"]; - /** Format: double */ - duration: number | string; - /** Format: int32 */ - listensCount: number | string; - /** Format: date-time */ - createdAt: string; - /** Format: date-time */ - updatedAt: string; }; TrackSearchItemResponse: { - source: components["schemas"]["TrackSource"]; - track: null | components["schemas"]["TrackApplicationResponse"]; - youTubeSong: null | components["schemas"]["YouTubeSongResult"]; + track: components["schemas"]["TrackApplicationResponse"]; }; - /** @enum {unknown} */ - TrackSource: "Local" | "YouTube"; TracksSearchResponse: { items: components["schemas"]["TrackSearchItemResponse"][]; /** Format: int32 */ @@ -987,9 +887,6 @@ export interface components { /** Format: int32 */ totalItemCount: number | string; hasPreviousPage: boolean; - hasNextLocalPage: boolean; - nextYoutubeContinuationToken: null | string; - youtubeUnavailable: boolean; hasNextPage: boolean; }; TrackStreamResponse: { @@ -1054,59 +951,6 @@ export interface components { /** Format: int32 */ followingCount: number | string; }; - YouTubeAlbumDetail: { - album: components["schemas"]["YouTubeAlbumResult"]; - description: string; - /** Format: int32 */ - totalDurationSeconds: number | string; - tracks: components["schemas"]["YouTubeAlbumTrack"][]; - }; - YouTubeAlbumResult: { - albumId: string; - title: string; - artist: string; - thumbnailUrl: string; - /** Format: int32 */ - releaseYear: null | number | string; - isSingle: boolean; - isEp: boolean; - artists: components["schemas"]["YouTubeArtistRef"][]; - }; - YouTubeAlbumTrack: { - videoId: string; - title: string; - /** Format: int32 */ - durationSeconds: number | string; - /** Format: int32 */ - trackNumber: number | string; - isExplicit: boolean; - }; - YouTubeArtistRef: { - id: null | string; - name: string; - }; - YouTubeSongResult: { - videoId: string; - title: string; - artist: string; - album: string; - /** Format: int32 */ - durationSeconds: number | string; - thumbnailUrl: string; - isExplicit: boolean; - artists: components["schemas"]["YouTubeArtistRef"][]; - }; - /** @enum {unknown} */ - YouTubeStreamMode: "Server" | "YouTube"; - YouTubeStreamResponse: { - /** Format: uuid */ - trackId: null | string; - mode: components["schemas"]["YouTubeStreamMode"]; - streamUrl: string; - ticket: null | string; - /** Format: int32 */ - expiresInSeconds: number | string; - }; }; responses: never; parameters: never; @@ -1122,7 +966,6 @@ export interface operations { title?: string; pageNumber?: number | string; pageSize?: number | string; - youtubeContinuationToken?: string; }; header?: never; path?: never; @@ -1217,36 +1060,6 @@ export interface operations { }; }; }; - GetExternalAlbum: { - parameters: { - query?: never; - header?: never; - path: { - source: string; - externalId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["YouTubeAlbumDetail"]; - }; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; GetAlbumsByUserId: { parameters: { query?: { @@ -1648,15 +1461,6 @@ export interface operations { "application/json": boolean; }; }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; - }; - }; /** @description Unauthorized */ 401: { headers: { @@ -1918,15 +1722,6 @@ export interface operations { "application/json": components["schemas"]["TrackApplicationResponse"]; }; }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; - }; - }; /** @description Unauthorized */ 401: { headers: { @@ -2150,7 +1945,6 @@ export interface operations { name?: string; pageNumber?: number | string; pageSize?: number | string; - youtubeContinuationToken?: string; }; header?: never; path?: never; @@ -2317,50 +2111,6 @@ export interface operations { }; }; }; - GetExternalTrackStream: { - parameters: { - query?: never; - header?: never; - path: { - source: string; - externalId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["YouTubeStreamResponse"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; GetTracksByUserId: { parameters: { query?: { diff --git a/web-player/src/lib/components/AlbumContextMenu.svelte b/web-player/src/lib/components/AlbumContextMenu.svelte index 1249bd4..b7a769e 100644 --- a/web-player/src/lib/components/AlbumContextMenu.svelte +++ b/web-player/src/lib/components/AlbumContextMenu.svelte @@ -2,18 +2,11 @@ const MENU_WIDTH = 240; const SUBMENU_WIDTH = 256; - export type AlbumMenuState = { x: number; y: number; openLeft: boolean } & ( - { kind: 'local'; albumId: string } | { kind: 'youtube'; albumId: string } - ); + export type AlbumMenuState = { x: number; y: number; openLeft: boolean; albumId: string }; - export function albumContextMenuStateFor( - event: MouseEvent, - kind: 'local' | 'youtube', - albumId: string - ): AlbumMenuState { + export function albumContextMenuStateFor(event: MouseEvent, albumId: string): AlbumMenuState { event.preventDefault(); return { - kind, albumId, x: Math.max(8, Math.min(event.clientX, window.innerWidth - MENU_WIDTH - 8)), y: Math.max(8, Math.min(event.clientY, window.innerHeight - 60)), @@ -93,9 +86,7 @@ {#each playlists as playlist (playlist.id)}
    ({ update }) => { onClose(); diff --git a/web-player/src/lib/components/TrackContextMenu.svelte b/web-player/src/lib/components/TrackContextMenu.svelte index 2e569a9..06f9807 100644 --- a/web-player/src/lib/components/TrackContextMenu.svelte +++ b/web-player/src/lib/components/TrackContextMenu.svelte @@ -78,7 +78,7 @@ {#each playlists as playlist (playlist.id)} ({ update }) => { onClose(); @@ -86,15 +86,7 @@ }} > - {#if menu.kind === 'youtube'} - - - - - - {:else} - - {/if} +

    {#if track.duration} - {fmtTime(track.duration)} + {fmtTime(track.duration)} {/if}
    {:else} diff --git a/web-player/src/lib/components/player/TrackInfo.svelte b/web-player/src/lib/components/player/TrackInfo.svelte index 8d98a24..f4812ae 100644 --- a/web-player/src/lib/components/player/TrackInfo.svelte +++ b/web-player/src/lib/components/player/TrackInfo.svelte @@ -20,8 +20,6 @@ id: player.current.id, title: player.current.title, artist: player.current.artist, - coverUrl: player.current.coverUrl, - source: player.current.source, explicit: player.current.explicit, ownerUserId: player.current.ownerUserId }); @@ -32,7 +30,6 @@ {#if player.current.id} import type { Snippet } from 'svelte'; - import { thumbnailSrc } from '$lib/thumbnails'; interface Props { trackId: string | number; @@ -24,9 +23,7 @@ let failed = $state(false); - const imageSrc = $derived( - src ? (thumbnailSrc(src, size) ?? src) : `/api/tracks/${trackId}/cover?size=${size}` - ); + const imageSrc = $derived(src ?? `/api/tracks/${trackId}/cover?size=${size}`); $effect(() => { imageSrc; diff --git a/web-player/src/lib/components/ui/MixArt.svelte b/web-player/src/lib/components/ui/MixArt.svelte index 397e429..f931d93 100644 --- a/web-player/src/lib/components/ui/MixArt.svelte +++ b/web-player/src/lib/components/ui/MixArt.svelte @@ -15,21 +15,11 @@ {#if tiles.length >= 4}
    {#each tiles as item (mixItemKey(item))} - + {/each}
    {:else if tiles.length > 0} - + {:else}
    {/if} diff --git a/web-player/src/lib/components/ui/Rail.svelte b/web-player/src/lib/components/ui/Rail.svelte deleted file mode 100644 index f3138e1..0000000 --- a/web-player/src/lib/components/ui/Rail.svelte +++ /dev/null @@ -1,29 +0,0 @@ - - -
    - {@render children()} -
    - - diff --git a/web-player/src/lib/config.ts b/web-player/src/lib/config.ts index 06e24b1..b9c7322 100644 --- a/web-player/src/lib/config.ts +++ b/web-player/src/lib/config.ts @@ -13,8 +13,6 @@ export const HOME_MIXES_LIMIT = 6; export const HOME_SHELF_LIMIT = 6; export const HOME_CONTINUE_LIMIT = 6; export const HOME_SPOTLIGHT_TRACKS_LIMIT = 5; -export const YOUTUBE_FILLER_LIMIT = 27; -export const HOME_POPULAR_FILLER_LIMIT = 10; export const HOME_POPULAR_MAX = 8; export const PLAYLIST_PICKER_PAGE_SIZE = 50; export const LIKED_TRACKS_PAGE_SIZE = 500; diff --git a/web-player/src/lib/mixes.ts b/web-player/src/lib/mixes.ts index 3ad5f97..22abf24 100644 --- a/web-player/src/lib/mixes.ts +++ b/web-player/src/lib/mixes.ts @@ -5,33 +5,15 @@ export type Mix = components['schemas']['MixApplicationResponse']; export type MixItem = components['schemas']['MixItemApplicationResponse']; export function mixItemKey(item: MixItem): string { - return item.source === 'YouTube' ? `yt:${item.videoId}` : `mf:${item.trackId}`; + return `mf:${item.trackId}`; } export function mixItemTarget(item: MixItem): TrackTarget { - if (item.source === 'YouTube') { - return { - kind: 'youtube', - song: { - videoId: item.videoId ?? '', - title: item.title, - artist: item.artist ?? '', - album: '', - durationSeconds: Number(item.durationSeconds), - thumbnailUrl: item.thumbnailUrl ?? '', - isExplicit: item.isExplicit - } - }; - } - return { - kind: 'local', track: { - id: item.trackId ?? '', + id: item.trackId, title: item.title, - artist: item.artist, - source: 'Local', - isExplicit: item.isExplicit + artist: item.artist } }; } diff --git a/web-player/src/lib/navigation.svelte.ts b/web-player/src/lib/navigation.svelte.ts index dc11f74..ad456db 100644 --- a/web-player/src/lib/navigation.svelte.ts +++ b/web-player/src/lib/navigation.svelte.ts @@ -7,7 +7,6 @@ const LABELS: [RegExp, string][] = [ [/^\/explore\/?$/, 'Volver a explorar'], [/^\/playlists\/[^/]+/, 'Volver a la lista'], [/^\/playlists\/?$/, 'Volver a tus listas'], - [/^\/albums\/youtube\/[^/]+/, 'Volver al álbum'], [/^\/albums\/[^/]+/, 'Volver al álbum'], [/^\/albums\/?$/, 'Volver a tus álbumes'], [/^\/library\/?$/, 'Volver a tus canciones'], diff --git a/web-player/src/lib/player/liked.svelte.ts b/web-player/src/lib/player/liked.svelte.ts index 2729e12..27e82bb 100644 --- a/web-player/src/lib/player/liked.svelte.ts +++ b/web-player/src/lib/player/liked.svelte.ts @@ -1,11 +1,9 @@ -import { toQueueItems, type ApiTrackLike, type TrackSourceKind } from './player.svelte'; +import { toQueueItems, type ApiTrackLike } from './player.svelte'; export interface LikedTrack { id: string; title: string; artist?: string; - coverUrl?: string; - source?: TrackSourceKind; explicit?: boolean; ownerUserId?: string | number | null; likedAt: number; @@ -15,18 +13,10 @@ type LikeToggleInput = { id: string | number; title: string; artist?: string; - coverUrl?: string; - source?: TrackSourceKind; explicit?: boolean; ownerUserId?: string | number | null; }; -function toggleRequestBody(track: LikeToggleInput) { - return track.source === 'youtube' - ? { source: 'YouTube' as const, externalId: String(track.id) } - : { trackId: String(track.id) }; -} - class LikedStore { entries = $state>({}); private hydrated = false; @@ -45,8 +35,6 @@ class LikedStore { id: String(item.id), title: item.title, artist: item.artist, - coverUrl: item.coverUrl, - source: item.source, explicit: item.explicit, ownerUserId: item.ownerUserId, likedAt: new Date(tracks[i].createdAt).getTime() @@ -70,8 +58,6 @@ class LikedStore { id, title: track.title, artist: track.artist, - coverUrl: track.coverUrl, - source: track.source, explicit: track.explicit, ownerUserId: track.ownerUserId, likedAt: Date.now() @@ -83,7 +69,7 @@ class LikedStore { const res = await fetch('/api/likes', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify(toggleRequestBody(track)) + body: JSON.stringify({ trackId: id }) }); if (!res.ok) throw new Error('Failed to toggle like'); } catch { diff --git a/web-player/src/lib/player/player.svelte.ts b/web-player/src/lib/player/player.svelte.ts index 57e99bf..18fda23 100644 --- a/web-player/src/lib/player/player.svelte.ts +++ b/web-player/src/lib/player/player.svelte.ts @@ -1,17 +1,12 @@ import { browser } from '$app/environment'; import { DEFAULT_ACCENT } from '$lib/theme/color'; import { extractAccent, type Accent } from '$lib/theme/palette'; -import { thumbnailSrc } from '$lib/thumbnails'; - -export type TrackSourceKind = 'local' | 'youtube'; export interface PlayerTrack { id: string | number; title: string; artist: string; duration: number; - source: TrackSourceKind; - coverUrl?: string; explicit?: boolean; ownerUserId?: string | number | null; } @@ -26,8 +21,6 @@ export interface QueueItem { id: string | number; title: string; artist?: string; - source?: TrackSourceKind; - coverUrl?: string; explicit?: boolean; ownerUserId?: string | number | null; } @@ -36,46 +29,18 @@ export interface ApiTrackLike { id: string | number; title: string; artist?: string | null; - source?: 'Local' | 'YouTube'; - externalId?: string | null; - audioStatus?: 'Pending' | 'Processing' | 'Completed' | 'Failed'; isExplicit?: boolean; ownerUserId?: string | number | null; } -function isYouTubeTrack(track: ApiTrackLike): boolean { - return track.source === 'YouTube'; -} - -export function isPendingYouTubeTrack(track: ApiTrackLike): boolean { - return isYouTubeTrack(track) && track.audioStatus !== 'Completed'; -} - -export function youTubeThumbnailUrl(videoId: string): string { - return `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`; -} - -export function queueIdForTrack(track: ApiTrackLike): string | number { - return isYouTubeTrack(track) && track.externalId ? track.externalId : track.id; -} - export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] { - return tracks.map((track) => { - const youTube = isYouTubeTrack(track); - return { - id: queueIdForTrack(track), - title: track.title, - artist: track.artist ?? undefined, - source: youTube ? 'youtube' : 'local', - coverUrl: youTube - ? isPendingYouTubeTrack(track) && track.externalId - ? youTubeThumbnailUrl(track.externalId) - : `/api/tracks/${track.id}/cover?size=small` - : undefined, - explicit: track.isExplicit, - ownerUserId: youTube ? undefined : track.ownerUserId - }; - }); + return tracks.map((track) => ({ + id: track.id, + title: track.title, + artist: track.artist ?? undefined, + explicit: track.isExplicit, + ownerUserId: track.ownerUserId + })); } async function readErrorMessage(res: Response): Promise { @@ -93,8 +58,7 @@ const EMPTY: PlayerTrack = { id: '', title: '', artist: '', - duration: 0, - source: 'local' + duration: 0 }; function toTrack(item: QueueItem): PlayerTrack { @@ -104,8 +68,6 @@ function toTrack(item: QueueItem): PlayerTrack { artist: item.artist ?? '', duration: 0, explicit: item.explicit, - source: item.source ?? 'local', - coverUrl: item.coverUrl, ownerUserId: item.ownerUserId }; } @@ -127,7 +89,6 @@ class PlayerState { #audio: HTMLAudioElement | null = null; #loadToken = 0; - #retriedId: string | number | null = null; #accentCache = new Map(); #rafId: number | null = null; @@ -170,15 +131,6 @@ class PlayerState { }); audio.addEventListener('error', () => { this.#stopProgressLoop(); - if ( - this.currentId !== null && - this.current.source === 'youtube' && - this.#retriedId !== this.currentId - ) { - this.#retriedId = this.currentId; - this.#loadCurrent(); - return; - } this.loading = false; this.playing = false; this.error = 'No se pudo reproducir la pista.'; @@ -207,12 +159,10 @@ class PlayerState { navigator.mediaSession.metadata = null; return; } - const artwork = - thumbnailSrc(track.coverUrl, 'medium') ?? `/api/tracks/${track.id}/cover?size=small`; navigator.mediaSession.metadata = new MediaMetadata({ title: track.title, artist: track.artist, - artwork: [{ src: artwork, sizes: '256x256' }] + artwork: [{ src: `/api/tracks/${track.id}/cover?size=small`, sizes: '256x256' }] }); } @@ -252,16 +202,14 @@ class PlayerState { ); } - async #applyAccent(id: string | number, coverUrl?: string) { + async #applyAccent(id: string | number) { const key = String(id); const cached = this.#accentCache.get(key); if (cached) { this.accentColor = cached.accent; return; } - const result = await extractAccent( - thumbnailSrc(coverUrl, 'small') ?? `/api/tracks/${id}/cover?size=small` - ); + const result = await extractAccent(`/api/tracks/${id}/cover?size=small`); if (this.currentId !== id) return; if (result) { this.#accentCache.set(key, result); @@ -276,23 +224,19 @@ class PlayerState { if (!audio || this.currentId === null) return; const id = this.currentId; const track = this.tracks.find((t) => t.id === id); - this.#applyAccent(id, track?.coverUrl); + this.#applyAccent(id); if (track) this.#syncMediaSessionMetadata(track); const token = ++this.#loadToken; this.loading = true; this.error = ''; this.progress = 0; try { - const src = - track?.source === 'youtube' - ? await this.#resolveYouTubeSrc(String(id)) - : await this.#resolveLocalSrc(String(id)); + const src = await this.#resolveLocalSrc(String(id)); if (token !== this.#loadToken) return; audio.src = src; audio.volume = this.volume / 100; await audio.play(); this.loading = false; - this.#retriedId = null; this.#pushRecent(this.current); } catch (exception) { console.error('playback failed', exception); @@ -316,17 +260,6 @@ class PlayerState { return this.#withTicket(manifestUrl, ticket); } - async #resolveYouTubeSrc(videoId: string): Promise { - const res = await fetch(`/api/tracks/external/youtube/${videoId}/stream`); - if (!res.ok) throw new Error(await readErrorMessage(res)); - const { mode, streamUrl, ticket } = (await res.json()) as { - mode: string; - streamUrl: string; - ticket: string; - }; - return mode === 'Server' ? this.#withTicket(streamUrl, ticket) : streamUrl; - } - #withTicket(url: string, ticket: string): string { if (!ticket) return url; const sep = url.includes('?') ? '&' : '?'; diff --git a/web-player/src/lib/recentlyPlayed.ts b/web-player/src/lib/recentlyPlayed.ts index 1e6cd5d..837e7f3 100644 --- a/web-player/src/lib/recentlyPlayed.ts +++ b/web-player/src/lib/recentlyPlayed.ts @@ -16,8 +16,6 @@ export function mergeRecentlyPlayed(history: ApiTrackLike[], limit: number): Que id, title: t.title, artist: t.artist, - source: t.source, - coverUrl: t.coverUrl, explicit: t.explicit, ownerUserId: t.ownerUserId }); @@ -31,8 +29,6 @@ export function mergeRecentlyPlayed(history: ApiTrackLike[], limit: number): Que id, title: item.title, artist: item.artist, - source: item.source ?? 'local', - coverUrl: item.coverUrl, explicit: item.explicit, ownerUserId: item.ownerUserId }); diff --git a/web-player/src/lib/server/config.ts b/web-player/src/lib/server/config.ts index dcd3a4d..75863b2 100644 --- a/web-player/src/lib/server/config.ts +++ b/web-player/src/lib/server/config.ts @@ -51,30 +51,3 @@ export const apiConfig = { return (env.API_BASE_URL ?? '').replace(/\/+$/, ''); } }; - -export const thumbnailConfig = { - get maxAgeSeconds() { - return positiveInt(env.THUMBNAIL_CACHE_MAX_AGE_SECONDS, 30 * DAY_SECONDS); - }, - get failureCacheSeconds() { - return positiveInt(env.THUMBNAIL_FAILURE_CACHE_SECONDS, 300); - }, - get memoryCacheBytes() { - return positiveInt(env.THUMBNAIL_CACHE_BYTES, 128 * 1024 * 1024); - }, - get maxConcurrentFetches() { - return positiveInt(env.THUMBNAIL_MAX_CONCURRENT_FETCHES, 4); - }, - get fetchTimeoutMs() { - return positiveInt(env.THUMBNAIL_FETCH_TIMEOUT_MS, 8000); - }, - get cooldownSeconds() { - return positiveInt(env.THUMBNAIL_COOLDOWN_SECONDS, 30); - }, - get maxCooldownSeconds() { - return positiveInt(env.THUMBNAIL_MAX_COOLDOWN_SECONDS, 300); - }, - get maxSize() { - return positiveInt(env.THUMBNAIL_MAX_SIZE, 720); - } -}; diff --git a/web-player/src/lib/server/playlistActions.ts b/web-player/src/lib/server/playlistActions.ts index 51822d9..318b4ef 100644 --- a/web-player/src/lib/server/playlistActions.ts +++ b/web-player/src/lib/server/playlistActions.ts @@ -17,7 +17,7 @@ export async function addTrackAction({ request, params, locals, fetch }: Request const api = createApiClient({ fetch, accessToken }); const { error: err, response } = await api.POST('/playlists/{playlistId}/tracks', { params: { path: { playlistId } }, - body: { trackId, source: null, externalId: null } + body: { trackId } }); if (err) { @@ -30,34 +30,6 @@ export async function addTrackAction({ request, params, locals, fetch }: Request return { added: true, trackId, playlistId }; } -export async function addYouTubeToPlaylistAction({ 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 videoId = String(form.get('videoId') ?? ''); - - if (!playlistId || !videoId) { - 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', { - params: { path: { playlistId } }, - body: { trackId: null, source: 'YouTube', externalId: videoId } - }); - - if (err) { - if (response?.status === 409) { - return fail(409, { message: 'La canción ya está en esa playlist.', videoId }); - } - return fail(502, { message: 'No se pudo añadir la canción.', videoId }); - } - - return { added: true, videoId, playlistId }; -} - export async function addAlbumToPlaylistAction({ request, params, locals, fetch }: RequestEvent) { const accessToken = requireAccessTokenAction(locals); if (typeof accessToken !== 'string') return accessToken; @@ -83,45 +55,7 @@ export async function addAlbumToPlaylistAction({ request, params, locals, fetch for (const track of tracks.items) { const { error: err } = await api.POST('/playlists/{playlistId}/tracks', { params: { path: { playlistId } }, - body: { trackId: String(track.id), source: null, externalId: null } - }); - if (!err) added++; - } - - return { addedAlbum: true, albumId, playlistId, added }; -} - -export async function addYoutubeAlbumToPlaylistAction({ - 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') ?? ''); - - if (!playlistId || !albumId) { - return fail(400, { message: 'Faltan datos del álbum.' }); - } - - const api = createApiClient({ fetch, accessToken }); - const { data: detail, error: detailErr } = await api.GET('/albums/external/{source}/{externalId}', { - params: { path: { source: 'YouTube', externalId: albumId } } - }); - - if (detailErr || !detail) { - return fail(502, { message: 'No se pudo cargar el álbum de YouTube.', albumId }); - } - - let added = 0; - for (const track of detail.tracks) { - const { error: err } = await api.POST('/playlists/{playlistId}/tracks', { - params: { path: { playlistId } }, - body: { trackId: null, source: 'YouTube', externalId: track.videoId } + body: { trackId: String(track.id) } }); if (!err) added++; } diff --git a/web-player/src/lib/server/thumbnails.ts b/web-player/src/lib/server/thumbnails.ts deleted file mode 100644 index ad64b81..0000000 --- a/web-player/src/lib/server/thumbnails.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { thumbnailConfig } from '$lib/server/config'; - -const ALLOWED_HOST_SUFFIXES = ['.googleusercontent.com', '.ytimg.com', '.ggpht.com']; - -export interface ThumbnailImage { - body: ArrayBuffer; - contentType: string; - etag: string; -} - -interface CacheEntry { - image: ThumbnailImage | null; - expiresAt: number; -} - -const cache = new Map(); -const inFlight = new Map>(); - -let cachedBytes = 0; -let cooldownUntil = 0; -let active = 0; -const waiting: (() => void)[] = []; - -export function parseThumbnailUrl(rawUrl: string): URL | null { - let url: URL; - try { - url = new URL(rawUrl); - } catch { - return null; - } - - if (url.protocol !== 'https:') return null; - if (!ALLOWED_HOST_SUFFIXES.some((suffix) => url.hostname.endsWith(suffix))) return null; - - return url; -} - -export function resizeThumbnailUrl(url: URL, size: number): string { - if (url.hostname.endsWith('.googleusercontent.com')) { - const raw = url.toString(); - const separator = raw.lastIndexOf('='); - const base = - separator > 0 && /^[wsh]\d/.test(raw.slice(separator + 1)) ? raw.slice(0, separator) : raw; - return `${base}=w${size}-h${size}-l90-rj`; - } - - if (url.hostname.endsWith('.ytimg.com')) { - const path = url.origin + url.pathname; - const lastSlash = path.lastIndexOf('/'); - if (lastSlash < 0) return path; - return `${path.slice(0, lastSlash)}/${ytImgFileName(size)}`; - } - - return url.toString(); -} - -function ytImgFileName(size: number): string { - if (size <= 90) return 'default.jpg'; - if (size <= 180) return 'mqdefault.jpg'; - if (size <= 360) return 'hqdefault.jpg'; - return 'maxresdefault.jpg'; -} - -export async function loadThumbnail(url: URL, size: number): Promise { - const target = resizeThumbnailUrl(url, size); - - const cached = cache.get(target); - if (cached && cached.expiresAt > Date.now()) { - touch(target, cached); - return cached.image; - } - - const pending = inFlight.get(target); - if (pending) return pending; - - const request = fetchThumbnail(target).finally(() => inFlight.delete(target)); - inFlight.set(target, request); - return request; -} - -async function fetchThumbnail(target: string): Promise { - if (Date.now() < cooldownUntil) return null; - - await acquire(); - try { - const response = await fetch(target, { - headers: { Accept: 'image/webp,image/avif,image/*;q=0.8' }, - signal: AbortSignal.timeout(thumbnailConfig.fetchTimeoutMs) - }); - - if (response.status === 429 || response.status === 503) { - cooldownUntil = Date.now() + retryAfterMs(response); - store(target, null, thumbnailConfig.failureCacheSeconds); - return null; - } - - if (!response.ok) { - store(target, null, thumbnailConfig.failureCacheSeconds); - return null; - } - - const buffer = await response.arrayBuffer(); - const image: ThumbnailImage = { - body: buffer, - contentType: response.headers.get('content-type') ?? 'image/jpeg', - etag: `W/"${buffer.byteLength.toString(16)}-${hash(target).toString(16)}"` - }; - - store(target, image, thumbnailConfig.maxAgeSeconds); - return image; - } catch { - store(target, null, thumbnailConfig.failureCacheSeconds); - return null; - } finally { - release(); - } -} - -function retryAfterMs(response: Response): number { - const header = Number(response.headers.get('retry-after')); - const seconds = Number.isFinite(header) && header > 0 ? header : thumbnailConfig.cooldownSeconds; - return Math.min(seconds, thumbnailConfig.maxCooldownSeconds) * 1000; -} - -function store(target: string, image: ThumbnailImage | null, ttlSeconds: number) { - const previous = cache.get(target); - if (previous?.image) cachedBytes -= previous.image.body.byteLength; - - cache.delete(target); - cache.set(target, { image, expiresAt: Date.now() + ttlSeconds * 1000 }); - if (image) cachedBytes += image.body.byteLength; - - evict(); -} - -function touch(target: string, entry: CacheEntry) { - cache.delete(target); - cache.set(target, entry); -} - -function evict() { - while (cachedBytes > thumbnailConfig.memoryCacheBytes) { - const oldest = cache.keys().next(); - if (oldest.done) return; - - const entry = cache.get(oldest.value); - if (entry?.image) cachedBytes -= entry.image.body.byteLength; - cache.delete(oldest.value); - } -} - -function acquire(): Promise { - if (active < thumbnailConfig.maxConcurrentFetches) { - active++; - return Promise.resolve(); - } - return new Promise((resolve) => waiting.push(resolve)); -} - -function release() { - const next = waiting.shift(); - if (next) next(); - else active--; -} - -function hash(value: string): number { - let result = 2166136261; - for (let i = 0; i < value.length; i++) { - result ^= value.charCodeAt(i); - result = Math.imul(result, 16777619); - } - return result >>> 0; -} diff --git a/web-player/src/lib/server/youtube.ts b/web-player/src/lib/server/youtube.ts deleted file mode 100644 index ca9c902..0000000 --- a/web-player/src/lib/server/youtube.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { createApiClient } from '$lib/server/api'; -import type { YouTubeAlbumResult, YouTubeSong } from '$lib/types'; - -export const YOUTUBE_FILLER_QUERIES = [ - 'pop', - 'rock', - 'reggaeton', - 'lo-fi', - 'indie', - 'salsa', - 'k-pop', - 'electrónica', - 'jazz', - 'baladas', - 'trap', - 'cumbia', - 'hip hop', - 'música clásica' -]; - -export interface YoutubeFiller { - query: string; - items: YouTubeSong[]; - continuationToken: string; -} - -export interface YoutubeAlbumFiller { - query: string; - items: YouTubeAlbumResult[]; -} - -function pickRandomFillerQuery(): string { - return YOUTUBE_FILLER_QUERIES[Math.floor(Math.random() * YOUTUBE_FILLER_QUERIES.length)]; -} - -function hash(text: string): number { - let h = 0; - for (let i = 0; i < text.length; i++) { - h = (h * 31 + text.charCodeAt(i)) | 0; - } - return Math.abs(h); -} - -function pickFillerQueryForUser(userId: string, date: Date, variant = 0): string { - const dayKey = date.toISOString().slice(0, 10); - const index = (hash(`${userId}:${dayKey}`) + variant) % YOUTUBE_FILLER_QUERIES.length; - return YOUTUBE_FILLER_QUERIES[index]; -} - -export async function fetchYoutubeFiller( - api: ReturnType, - accessToken: string | null | undefined, - limit: number, - seedQuery?: string, - userId?: string, - variant = 0 -): Promise { - if (!accessToken) return { query: '', items: [], continuationToken: '' }; - const query = - seedQuery?.trim() || - (userId ? pickFillerQueryForUser(userId, new Date(), variant) : pickRandomFillerQuery()); - const { data } = await api.GET('/tracks', { params: { query: { name: query, pageSize: 1 } } }); - const items = (data?.items ?? []) - .map((item) => item.youTubeSong) - .filter((song): song is NonNullable => song !== null && song !== undefined); - return { - query, - items: (items as YouTubeSong[]).slice(0, limit), - continuationToken: data?.nextYoutubeContinuationToken ?? '' - }; -} - -export async function fetchYoutubeAlbumFiller( - api: ReturnType, - accessToken: string | null | undefined, - limit: number, - seedQuery?: string, - userId?: string, - variant = 0 -): Promise { - if (!accessToken) return { query: '', items: [] }; - const query = - seedQuery?.trim() || - (userId ? pickFillerQueryForUser(userId, new Date(), variant) : pickRandomFillerQuery()); - const { data } = await api.GET('/albums', { - params: { query: { title: query, pageNumber: 1, pageSize: limit + 5 } } - }); - const items = (data?.items ?? []) - .map((item) => item.youTubeAlbum) - .filter((album): album is NonNullable => album !== null && album !== undefined); - return { query, items: (items as YouTubeAlbumResult[]).slice(0, limit) }; -} diff --git a/web-player/src/lib/thumbnails.ts b/web-player/src/lib/thumbnails.ts deleted file mode 100644 index 494c993..0000000 --- a/web-player/src/lib/thumbnails.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const THUMBNAIL_SIZES = { - small: 128, - medium: 192, - large: 360 -} as const; - -export type ThumbnailSize = keyof typeof THUMBNAIL_SIZES; - -const PROXY_PATH = '/api/thumbnail'; - -export function thumbnailSrc( - url: string | undefined, - size: ThumbnailSize = 'large' -): string | undefined { - if (!url) return undefined; - if (!url.startsWith('http')) return url; - - return `${PROXY_PATH}?u=${encodeURIComponent(url)}&size=${THUMBNAIL_SIZES[size]}`; -} diff --git a/web-player/src/lib/tracks.ts b/web-player/src/lib/tracks.ts index 72794b2..eb102b4 100644 --- a/web-player/src/lib/tracks.ts +++ b/web-player/src/lib/tracks.ts @@ -1,87 +1,48 @@ import { player, - queueIdForTrack, toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte'; -import type { YouTubeSong } from '$lib/types'; -export type TrackTarget = - { kind: 'youtube'; song: YouTubeSong } | { kind: 'local'; track: ApiTrackLike }; +export type TrackTarget = { track: ApiTrackLike }; export function targetId(target: TrackTarget): string | number { - return target.kind === 'youtube' ? target.song.videoId : target.track.id; + return target.track.id; } export function targetTitle(target: TrackTarget): string { - return target.kind === 'youtube' ? target.song.title : target.track.title; + return target.track.title; } export function targetArtist(target: TrackTarget): string | undefined { - return (target.kind === 'youtube' ? target.song.artist : target.track.artist) ?? undefined; + return target.track.artist ?? undefined; } export function targetOwnerUserId(target: TrackTarget): string | number | undefined { - return target.kind === 'youtube' ? undefined : (target.track.ownerUserId ?? undefined); + return target.track.ownerUserId ?? undefined; } export function targetExplicit(target: TrackTarget): boolean { - return target.kind === 'youtube' ? target.song.isExplicit : (target.track.isExplicit ?? false); -} - -export function targetDurationSeconds(target: TrackTarget): number | undefined { - return target.kind === 'youtube' ? target.song.durationSeconds : undefined; -} - -export function targetCoverSrc(target: TrackTarget): string | undefined { - return target.kind === 'youtube' ? target.song.thumbnailUrl : undefined; -} - -function queueIdForTarget(target: TrackTarget): string | number { - return target.kind === 'youtube' ? target.song.videoId : queueIdForTrack(target.track); + return target.track.isExplicit ?? false; } export function isTargetCurrent(target: TrackTarget): boolean { - return player.current.id === queueIdForTarget(target); + return player.current.id === target.track.id; } export function queueItemForTarget(target: TrackTarget): QueueItem { - return target.kind === 'youtube' - ? { - id: target.song.videoId, - title: target.song.title, - artist: target.song.artist, - source: 'youtube', - coverUrl: target.song.thumbnailUrl, - explicit: target.song.isExplicit - } - : toQueueItems([target.track])[0]; + return toQueueItems([target.track])[0]; } export function targetForQueueItem(item: QueueItem): TrackTarget { - return item.source === 'youtube' - ? { - kind: 'youtube', - song: { - videoId: String(item.id), - title: item.title, - artist: item.artist ?? '', - album: '', - durationSeconds: 0, - thumbnailUrl: item.coverUrl ?? '', - isExplicit: item.explicit ?? false - } - } - : { - kind: 'local', - track: { - id: item.id, - title: item.title, - artist: item.artist, - source: 'Local', - isExplicit: item.explicit, - ownerUserId: item.ownerUserId - } - }; + return { + track: { + id: item.id, + title: item.title, + artist: item.artist, + isExplicit: item.explicit, + ownerUserId: item.ownerUserId + } + }; } diff --git a/web-player/src/lib/types.ts b/web-player/src/lib/types.ts index 00306a7..bb2191e 100644 --- a/web-player/src/lib/types.ts +++ b/web-player/src/lib/types.ts @@ -4,21 +4,3 @@ export interface SessionUser { email: string; picture: string; } - -export interface YouTubeSong { - videoId: string; - title: string; - artist: string; - album: string; - durationSeconds: number; - thumbnailUrl: string; - isExplicit: boolean; -} - -export interface YouTubeAlbumResult { - albumId: string; - title: string; - artist: string; - thumbnailUrl: string; - releaseYear: number | string | null; -} 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 f87940b..f77c3dc 100644 --- a/web-player/src/routes/(app)/albums/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/albums/[id]/+page.server.ts @@ -38,9 +38,7 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent const tracks = tracksRes.data?.items ?? []; const inAlbum = new Set(tracks.map((track) => track.id)); - const library = (libraryRes?.data?.items ?? []).filter( - (track) => track.source === 'Local' && !inAlbum.has(track.id) - ); + const library = (libraryRes?.data?.items ?? []).filter((track) => !inAlbum.has(track.id)); const isOwner = user !== null && String(album.ownerUserId) === user.sub; diff --git a/web-player/src/routes/(app)/albums/[id]/+page.svelte b/web-player/src/routes/(app)/albums/[id]/+page.svelte index f7d33ce..c2c7889 100644 --- a/web-player/src/routes/(app)/albums/[id]/+page.svelte +++ b/web-player/src/routes/(app)/albums/[id]/+page.svelte @@ -1,5 +1,5 @@ - - - {album.title} - - - - - - - !!part) - .join(' · ')} - > - {#snippet cover()} - (albumMenu = albumContextMenuStateFor(e, 'youtube', album.albumId))} - /> - {/snippet} - {#snippet actions()} - - {/snippet} - - - {#if form?.message} - {form.message} - {/if} - - {#if data.album.description} -

    {data.album.description}

    - {/if} - - - {#each tracks as track, i (track.videoId)} - {@const active = player.current.id === track.videoId} - (contextMenu = contextMenuStateFor(e, targetFor(i)))} - > - playFrom(i)} /> - playFrom(i)} - /> - {fmtTime(Number(track.durationSeconds))} - - {/each} - -
    - -{#if contextMenu} - (contextMenu = null)} - onAddToQueue={() => { - const menu = contextMenu; - if (menu?.kind === 'youtube') { - const index = tracks.findIndex((track) => track.videoId === menu.song.videoId); - if (index >= 0) player.addToQueue(queue[index]); - } - contextMenu = null; - }} - /> -{/if} - -{#if albumMenu} - (albumMenu = null)} - onPlayNext={() => { - player.playNext(queue); - albumMenu = null; - }} - onAddToQueue={() => { - player.appendToQueue(queue); - albumMenu = null; - }} - /> -{/if} diff --git a/web-player/src/routes/(app)/explore/+page.server.ts b/web-player/src/routes/(app)/explore/+page.server.ts index 3556497..00cfb0e 100644 --- a/web-player/src/routes/(app)/explore/+page.server.ts +++ b/web-player/src/routes/(app)/explore/+page.server.ts @@ -1,12 +1,6 @@ import type { PageServerLoad, Actions } from './$types'; import { createApiClient, unwrapOrError } from '$lib/server/api'; -import { YOUTUBE_FILLER_QUERIES } from '$lib/server/youtube'; -import { - addAlbumToPlaylistAction, - addTrackAction, - addYouTubeToPlaylistAction, - addYoutubeAlbumToPlaylistAction -} from '$lib/server/playlistActions'; +import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions'; import { EXPLORE_ALBUMS_PAGE_SIZE, EXPLORE_PAGE_SIZE as PAGE_SIZE, @@ -53,22 +47,16 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => { const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.'); - const albumItems = albumsRes?.data?.items ?? []; - return { query, tracks, - albums: albumItems.flatMap((item) => (item.album ? [item.album] : [])), - youtubeAlbums: albumItems.flatMap((item) => (item.youTubeAlbum ? [item.youTubeAlbum] : [])), + albums: (albumsRes?.data?.items ?? []).map((item) => item.album), users: usersRes?.data?.items ?? [], - genres: YOUTUBE_FILLER_QUERIES, playlists: playlistsRes?.data?.items ?? [] }; }; export const actions: Actions = { addTrack: addTrackAction, - addYouTubeToPlaylist: addYouTubeToPlaylistAction, - addAlbumToPlaylist: addAlbumToPlaylistAction, - addYoutubeAlbumToPlaylist: addYoutubeAlbumToPlaylistAction + addAlbumToPlaylist: addAlbumToPlaylistAction }; diff --git a/web-player/src/routes/(app)/explore/+page.svelte b/web-player/src/routes/(app)/explore/+page.svelte index 6c335ab..ea8c60b 100644 --- a/web-player/src/routes/(app)/explore/+page.svelte +++ b/web-player/src/routes/(app)/explore/+page.svelte @@ -14,8 +14,7 @@ import NowPlaying from '$lib/components/ui/NowPlaying.svelte'; import SearchResultRow from '$lib/components/ui/SearchResultRow.svelte'; import SectionHeading from '$lib/components/ui/SectionHeading.svelte'; - import { EXPLORE_ALBUMS_LIMIT, EXPLORE_PAGE_SIZE } from '$lib/config'; - import type { YouTubeSong } from '$lib/types'; + import { EXPLORE_PAGE_SIZE } from '$lib/config'; import TrackContextMenu, { contextMenuStateFor, type ContextMenuState @@ -29,7 +28,6 @@ isTargetCurrent, queueItemForTarget, targetArtist, - targetCoverSrc, targetExplicit, targetId, targetTitle, @@ -43,6 +41,8 @@ user: 'Usuario' }; + // No hay sistema de tags/géneros todavía: esta lista es un placeholder de + // cliente y cada tile enlaza a una búsqueda normal por ese término. const GENRE_INFO: Record = { pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' }, rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' }, @@ -60,23 +60,26 @@ 'música clásica': { label: 'Clásica', tagline: 'Siglos de composición' } }; + const genreTiles = Object.entries(GENRE_INFO).map(([query, info], i, all) => ({ + query, + hue: Math.round((i * 360) / all.length), + ...info + })); + let { data, form } = $props(); const SEARCH_FILTERS = ['Todo', 'Canciones', 'Álbumes', 'Playlists', 'Usuarios'] as const; type SearchFilter = (typeof SEARCH_FILTERS)[number]; const SEARCH_GROUP_PREVIEW = 4; - let ytItems = $state([]); - let ytUnavailable = $state(false); let contextMenu = $state(null); let albumMenu = $state(null); let sfilter = $state('Todo'); - type LocalTrack = NonNullable<(typeof data.tracks.items)[number]['track']>; + type LocalTrack = (typeof data.tracks.items)[number]['track']; let localItems = $state([]); let localPage = $state(1); - let ytContinuation = $state(''); let hasMore = $state(false); let loadingMore = $state(false); @@ -84,63 +87,24 @@ contextMenu = null; albumMenu = null; sfilter = 'Todo'; - localItems = data.tracks.items.flatMap((item) => (item.track ? [item.track] : [])); + localItems = data.tracks.items.map((item) => item.track); localPage = Number(data.tracks.pageNumber); - ytContinuation = data.tracks.nextYoutubeContinuationToken ?? ''; hasMore = data.tracks.hasNextPage; - - if (data.query) { - ytItems = appendUnique( - [], - data.tracks.items.flatMap((item) => - item.youTubeSong ? [item.youTubeSong as YouTubeSong] : [] - ), - (i) => i.videoId - ); - ytUnavailable = data.tracks.youtubeUnavailable; - } else { - ytItems = []; - ytUnavailable = false; - } }); - const genreTiles = $derived( - data.genres.map((query, i) => ({ - query, - hue: Math.round((i * 360) / data.genres.length), - ...(GENRE_INFO[query] ?? { label: query, tagline: 'Explora este estilo' }) + const songRows = $derived( + localItems.map((track) => ({ + target: { track } satisfies TrackTarget, + seconds: Number(track.duration) })) ); - const songRows = $derived([ - ...localItems.map((track) => ({ - target: { kind: 'local' as const, track }, - seconds: Number(track.duration) - })), - ...ytItems.map((song) => ({ - target: { kind: 'youtube' as const, song }, - seconds: Number(song.durationSeconds) - })) - ] satisfies { target: TrackTarget; seconds: number }[]); - const items = $derived(songRows.map((row) => row.target)); const albums = $derived(data.albums); - const youtubeAlbums = $derived(data.youtubeAlbums); const users = $derived(data.users); - type LocalAlbum = (typeof data.albums)[number]; - type YouTubeAlbum = (typeof data.youtubeAlbums)[number]; - type AlbumEntry = { kind: 'local'; album: LocalAlbum } | { kind: 'youtube'; album: YouTubeAlbum }; - - const albumEntries = $derived( - [ - ...albums.map((album) => ({ kind: 'local' as const, album })), - ...youtubeAlbums.map((album) => ({ kind: 'youtube' as const, album })) - ].slice(0, EXPLORE_ALBUMS_LIMIT) - ); - - const hasAlbums = $derived(albumEntries.length > 0); + const hasAlbums = $derived(albums.length > 0); const hasUsers = $derived(users.length > 0); const playlistMatches = $derived( @@ -151,12 +115,12 @@ const hasPlaylists = $derived(playlistMatches.length > 0); const nothingFound = $derived( - !!data.query && !ytUnavailable && items.length === 0 && !hasAlbums && !hasUsers && !hasPlaylists + !!data.query && items.length === 0 && !hasAlbums && !hasUsers && !hasPlaylists ); const searchCounts = $derived({ Canciones: songRows.length, - Álbumes: albumEntries.length, + Álbumes: albums.length, Playlists: playlistMatches.length, Usuarios: users.length }); @@ -172,7 +136,7 @@ type TopResult = | { kind: 'track'; target: TrackTarget } - | { kind: 'album'; entry: AlbumEntry } + | { kind: 'album'; album: (typeof albums)[number] } | { kind: 'playlist'; playlist: (typeof playlistMatches)[number] } | { kind: 'user'; user: (typeof users)[number] }; @@ -185,14 +149,14 @@ if (userHit) return { kind: 'user', user: userHit }; const trackHit = items.find((target) => startsWithQuery(targetTitle(target))); if (trackHit) return { kind: 'track', target: trackHit }; - const albumHit = albumEntries.find((entry) => startsWithQuery(entry.album.title)); - if (albumHit) return { kind: 'album', entry: albumHit }; + const albumHit = albums.find((album) => startsWithQuery(album.title)); + if (albumHit) return { kind: 'album', album: albumHit }; const playlistHit = playlistMatches.find((p) => startsWithQuery(p.name)); if (playlistHit) return { kind: 'playlist', playlist: playlistHit }; if (users.length) return { kind: 'user', user: users[0] }; if (items.length) return { kind: 'track', target: items[0] }; - if (albumEntries.length) return { kind: 'album', entry: albumEntries[0] }; + if (albums.length) return { kind: 'album', album: albums[0] }; if (playlistMatches.length) return { kind: 'playlist', playlist: playlistMatches[0] }; return null; }); @@ -216,11 +180,7 @@ const topResultHref = $derived.by(() => { if (!topResult || topResult.kind === 'track') return undefined; - if (topResult.kind === 'album') { - return topResult.entry.kind === 'local' - ? `/albums/${topResult.entry.album.id}` - : `/albums/external/youtube/${topResult.entry.album.albumId}`; - } + if (topResult.kind === 'album') return `/albums/${topResult.album.id}`; if (topResult.kind === 'playlist') return `/playlists/${topResult.playlist.id}`; return `/u/${topResult.user.id}`; }); @@ -233,22 +193,22 @@ contextMenu = contextMenuStateFor(event, target); } - function openAlbumMenu(event: MouseEvent, kind: 'local' | 'youtube', albumId: string) { - albumMenu = albumContextMenuStateFor(event, kind, albumId); + function openAlbumMenu(event: MouseEvent, albumId: string) { + albumMenu = albumContextMenuStateFor(event, albumId); } async function albumPlayNext() { if (!albumMenu) return; - const { kind, albumId } = albumMenu; + const { albumId } = albumMenu; albumMenu = null; - player.playNext(await fetchAlbumQueueItems(kind, albumId)); + player.playNext(await fetchAlbumQueueItems(albumId)); } async function albumAddToQueue() { if (!albumMenu) return; - const { kind, albumId } = albumMenu; + const { albumId } = albumMenu; albumMenu = null; - player.appendToQueue(await fetchAlbumQueueItems(kind, albumId)); + player.appendToQueue(await fetchAlbumQueueItems(albumId)); } function addToQueue() { @@ -265,25 +225,16 @@ pageNumber: String(localPage + 1), pageSize: String(EXPLORE_PAGE_SIZE) }); - if (data.query) { - params.set('name', data.query); - if (ytContinuation) params.set('youtubeContinuationToken', ytContinuation); - } + if (data.query) params.set('name', data.query); const res = await fetch(`/api/tracks?${params}`); if (!res.ok) throw new Error(String(res.status)); const next = (await res.json()) as typeof data.tracks; localItems = appendUnique( localItems, - next.items.flatMap((item) => (item.track ? [item.track] : [])), + next.items.map((item) => item.track), (track) => track.id ); - ytItems = appendUnique( - ytItems, - next.items.flatMap((item) => (item.youTubeSong ? [item.youTubeSong as YouTubeSong] : [])), - (song) => song.videoId - ); localPage = Number(next.pageNumber); - ytContinuation = next.nextYoutubeContinuationToken ?? ''; hasMore = next.hasNextPage; } catch { hasMore = false; @@ -346,26 +297,15 @@ {#if topResult.kind === 'track'} {:else if topResult.kind === 'album'} - {#if topResult.entry.kind === 'local'} - - {:else} - - {/if} + {:else if topResult.kind === 'playlist'} {targetArtist(topResult.target)}
    - {:else if topResult.kind === 'album' && topResult.entry.kind === 'youtube' && topResult.entry.album.artist} -
    - {topResult.entry.album.artist} -
    {/if}
    @@ -517,7 +453,6 @@ {#snippet art(artClass)} {/if} - {:else if !ytUnavailable} + {:else}
      - {#each capped(albumEntries) as entry (entry.kind === 'local' ? entry.album.id : entry.album.albumId)} + {#each capped(albums) as album (album.id)}
    • - {#if entry.kind === 'local'} - openAlbumMenu(e, 'local', entry.album.id)} - > - {#snippet art(artClass)} - - {/snippet} - - {:else} - openAlbumMenu(e, 'youtube', entry.album.albumId)} - > - {#snippet art(artClass)} - - {/snippet} - - {/if} + openAlbumMenu(e, album.id)} + > + {#snippet art(artClass)} + + {/snippet} +
    • {/each}
    @@ -665,13 +579,6 @@ {/if}
    {/if} - - {#if ytUnavailable} - - {/if} {#if contextMenu} diff --git a/web-player/src/routes/(app)/liked/+page.svelte b/web-player/src/routes/(app)/liked/+page.svelte index dc9d6ad..1184d01 100644 --- a/web-player/src/routes/(app)/liked/+page.svelte +++ b/web-player/src/routes/(app)/liked/+page.svelte @@ -23,8 +23,6 @@ id: t.id, title: t.title, artist: t.artist, - source: t.source, - coverUrl: t.coverUrl, explicit: t.explicit })) ); @@ -97,7 +95,6 @@ title={track.title} artist={track.artist} ownerUserId={track.ownerUserId} - coverSrc={track.coverUrl} explicit={track.explicit} {active} onClick={() => playFrom(i)} 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 2243490..5312412 100644 --- a/web-player/src/routes/(app)/mixes/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/mixes/[id]/+page.server.ts @@ -1,6 +1,6 @@ import type { PageServerLoad, Actions } from './$types'; import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api'; -import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions'; +import { addTrackAction } from '$lib/server/playlistActions'; import { PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config'; export const load: PageServerLoad = async ({ params, locals, url, fetch }) => { @@ -23,6 +23,5 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch }) => { }; export const actions: Actions = { - addTrack: addTrackAction, - addYouTubeToPlaylist: addYouTubeToPlaylistAction + addTrack: addTrackAction }; diff --git a/web-player/src/routes/(app)/mixes/[id]/+page.svelte b/web-player/src/routes/(app)/mixes/[id]/+page.svelte index 44dffc2..f129525 100644 --- a/web-player/src/routes/(app)/mixes/[id]/+page.svelte +++ b/web-player/src/routes/(app)/mixes/[id]/+page.svelte @@ -25,7 +25,6 @@ isTargetCurrent, queueItemForTarget, targetArtist, - targetCoverSrc, targetId, targetTitle } from '$lib/tracks'; @@ -107,8 +106,6 @@ trackId={targetId(targets[i])} title={targetTitle(targets[i])} artist={targetArtist(targets[i])} - coverSrc={targetCoverSrc(targets[i])} - explicit={item.isExplicit} {active} onClick={() => playFrom(i)} /> diff --git a/web-player/src/routes/(app)/playlists/[id]/+page.svelte b/web-player/src/routes/(app)/playlists/[id]/+page.svelte index 9ec5f94..abb8846 100644 --- a/web-player/src/routes/(app)/playlists/[id]/+page.svelte +++ b/web-player/src/routes/(app)/playlists/[id]/+page.svelte @@ -1,6 +1,6 @@
    - +