diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml new file mode 100644 index 0000000..ccc168c --- /dev/null +++ b/.gitea/workflows/ci-cd.yml @@ -0,0 +1,116 @@ +name: CI/CD + +on: + push: + branches: ['**'] + pull_request: + branches: ['**'] + +concurrency: + group: production-deploy + cancel-in-progress: false + +jobs: + backend-tests: + name: Backend tests (.NET) + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore + run: dotnet restore Musify.slnx + + - name: Build + run: dotnet build Musify.slnx --no-restore -c Release + + - name: Test + run: dotnet test Musify.slnx --no-build -c Release + + web-player-checks: + name: web-player (check + lint) + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: web-player + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: web-player/package-lock.json + + - run: npm ci + + - name: Type-check (svelte-check) + run: npm run check + + - name: Lint (prettier + eslint + design tokens) + run: npm run lint + + # Runs on the production host itself, through the existing self-hosted + # Gitea Actions runner (act_runner), which also carries a "production" + # label registered in native (non-docker) mode so its steps see the host's + # real Docker daemon directly (Docker socket + a bind-mounted deploy + # directory — see deploy/README.md for how that runner is set up). + # + # DEPLOY_PATH is the persistent clone already on that host (the one whose + # deploy/.env.prod is filled in and matches SharedServices/.env.prod) — + # deploy reuses it instead of a fresh checkout, since .env.prod is + # untracked and only lives there. + deploy: + name: Deploy to production + needs: [backend-tests, web-player-checks] + if: github.ref == 'refs/heads/master' && github.event_name == 'push' + runs-on: [production] + timeout-minutes: 20 + env: + DEPLOY_PATH: /home/ubuntu/deploys/musify + COMPOSE: docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml + steps: + - name: Pull latest master + run: | + set -euo pipefail + cd "$DEPLOY_PATH" + git fetch origin master + git reset --hard origin/master + + - name: Migrate database, generate keys + run: | + set -euo pipefail + cd "$DEPLOY_PATH" + $COMPOSE up -d wait-for-postgres keys-init migrate + + # zitadel-init/seaweedfs-init verify themselves through the PUBLIC + # auth./s3. URLs (see deploy/compose.yml), so they only succeed once a + # real domain is registered and nginx is serving it (see the TODO + # below and deploy/README.md). Tolerated for now so a red step here + # doesn't block the rest of the deploy; drop the `|| true` once that's + # in place, so a real regression here fails the job again. + - name: Create OIDC app + S3 bucket (pending public domain) + run: | + cd "$DEPLOY_PATH" + $COMPOSE up -d seaweedfs-init zitadel-init || echo "expected to fail until the production domain + nginx edge exist" + + - name: Build and start apps + run: | + set -euo pipefail + cd "$DEPLOY_PATH" + $COMPOSE --profile apps up -d --build api worker gateway web-player + + # TODO once a domain is registered and pointed at this server: merge + # Musify's vhosts (deploy/nginx/templates) into the existing + # gitea-nginx config instead of running the `nginx` service from + # compose.prod.yml, since gitea-nginx already owns host ports 80/443 + # for gitea.ikerdemo.cat. See "Continuous deployment" in + # deploy/README.md. diff --git a/deploy/.env.prod.example b/deploy/.env.prod.example index 08029c1..e98fb5a 100644 --- a/deploy/.env.prod.example +++ b/deploy/.env.prod.example @@ -1,11 +1,14 @@ # Production configuration for deploy/compose.yml + compose.prod.yml. # Copy to deploy/.env.prod, then: # -# 1. replace every `example.com` with your domain, +# 1. replace every `*.example.com` with your actual domains (each service +# below has its own, independent domain — they don't need to share a +# root, e.g. for DNS providers that don't allow multi-level +# subdomains), # 2. replace every CHANGE_ME with a strong random value # (`openssl rand -hex 24`), -# 3. point these DNS records at the server, all proxied/TLS-terminated the -# same way: example.com, www, api, auth, stream, s3. +# 3. point each *_DOMAIN below at the server, proxied/TLS-terminated the +# same way. # # Requires ../../../Infrastructure running first, on the same Docker host, # with its own `infra-net` network already created. See @@ -14,12 +17,18 @@ DOTNET_ENVIRONMENT=Production -# --- Domain ------------------------------------------------------------------ -# ROOT_DOMAIN drives the nginx vhosts; the URLs below must agree with it. -ROOT_DOMAIN=example.com -PUBLIC_WEB_URL=https://example.com -PUBLIC_API_URL=https://api.example.com -PUBLIC_STREAM_URL=https://stream.example.com +# --- Domains ------------------------------------------------------------- +# Each *_DOMAIN drives its own nginx vhost (deploy/nginx/templates); the +# PUBLIC_*_URL/S3_PUBLIC_URL below must agree with it (https://). +WEB_DOMAIN=musify.example.com +API_DOMAIN=musifyapi.example.com +STREAM_DOMAIN=musifystream.example.com +AUTH_DOMAIN=auth.example.com +S3_DOMAIN=s3.example.com + +PUBLIC_WEB_URL=https://musify.example.com +PUBLIC_API_URL=https://musifyapi.example.com +PUBLIC_STREAM_URL=https://musifystream.example.com PUBLIC_AUTH_URL=https://auth.example.com S3_PUBLIC_URL=https://s3.example.com diff --git a/deploy/README.md b/deploy/README.md index 3bbd1c0..26522a2 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -125,14 +125,19 @@ cp deploy/.env.prod.example deploy/.env.prod Fill it in: -1. Replace every `example.com` with your domain and every `CHANGE_ME` with a - random value (`openssl rand -hex 24`). `chmod 600 deploy/.env.prod`. +1. Replace every `*.example.com` with your actual domains and every + `CHANGE_ME` with a random value (`openssl rand -hex 24`). Each of + `WEB_DOMAIN`/`API_DOMAIN`/`STREAM_DOMAIN`/`AUTH_DOMAIN`/`S3_DOMAIN` is + independent — they don't need to share a root domain (e.g. + `musify.example.com` + `musifyapi.example.com`, or sibling subdomains of + a domain whose DNS provider doesn't allow multi-level subdomains). + `chmod 600 deploy/.env.prod`. 2. Drop **`deploy/nginx/certs/origin.pem` and `origin.key`**, the origin certificate nginx serves. The stack assumes Cloudflare in *Full (strict)* mode in front of it, so a Cloudflare Origin Certificate is enough; any certificate valid for the five hostnames works. -3. Point `example.com`, `www`, `api`, `auth`, `stream` and `s3` at the server, - proxied and TLS-terminated the same way. +3. Point the five `*_DOMAIN` values at the server, proxied and + TLS-terminated the same way. Then: @@ -150,6 +155,60 @@ an update once the stack already exists). Only nginx publishes ports (80/443). Every other Musify service, and all of Infrastructure's, is reachable only on the internal `infra-net` network. +### Continuous deployment + +`.gitea/workflows/ci-cd.yml` runs the backend and web-player checks on every +push/PR, and on a successful push to `master` deploys automatically. The +deploy job runs on `gitea-runner`, the existing self-hosted Gitea Actions +runner on the production host itself (`gitea.ikerdemo.cat`, in +`~/gitea/docker-compose.yml`), which also carries a `production:host` +label registered directly in `~/gitea/runner-data/.runner` (act_runner +reads `labels` from that file on every start, not from +`GITEA_RUNNER_LABELS`, once a runner is already registered). Running in +native mode means the label's steps execute inside the runner container +itself rather than spinning up a fresh one — that container has +`/var/run/docker.sock` mounted, so `docker`/`docker compose` there still +control the host's real Docker daemon (sibling containers, "Docker outside +of Docker"). Its compose service also bind-mounts `/home/ubuntu/deploys` +into itself at the *same* path, which matters because compose's own +relative bind mounts (`./keys`, `./nginx/...`) are resolved by the host +daemon against that literal path — mounting it anywhere else would silently +bind the wrong (empty) directory. + +`DEPLOY_PATH` (hardcoded in the workflow as `/home/ubuntu/deploys/musify`) +is a persistent clone on that host, reused instead of a fresh checkout each +run since `deploy/.env.prod` is untracked and only lives there. +`/home/ubuntu/deploys/SharedServices` is the sibling clone of the +`Infrastructure`/`SharedServices` repo, already running (`infra-net` +created); its `.env.prod` shares the Postgres/RabbitMQ/S3 credentials with +Musify's own `deploy/.env.prod` by design (see that repo's README). + +**Pending DNS on the registered domains:** `deploy/.env.prod`'s five +`*_DOMAIN` values are set (`musify.ikerdemo.cat`, `musifyapi.ikerdemo.cat`, +`musifystream.ikerdemo.cat`, `auth.ikerdemo.cat`, `s3.ikerdemo.cat` — +siblings, not `api.musify.ikerdemo.cat`, because the DNS provider here +doesn't allow multi-level subdomains), but no DNS record points any of them +at the server yet, so: + +- `zitadel-init` and `seaweedfs-init` (they verify themselves through the + public `auth.`/`s3.` URLs) fail every deploy until those two resolve and + are actually reachable — the workflow tolerates this on purpose, see its + comments. Until then, `api`/`worker` build and start but can't do + anything OIDC- or storage-dependent (`AUTH_CLIENT_ID`/`WEB_CLIENT_ID`/ + `WEB_CLIENT_SECRET` in `.env.prod` stay blank); `gateway`/`web-player` + are unaffected. +- The `nginx` service from `compose.prod.yml` is intentionally **not** + brought up by the deploy job: it would try to publish host ports 80/443, + which `gitea-nginx` already owns for `gitea.ikerdemo.cat` (certbot). Once + the five DNS records above exist, merge Musify's vhosts + (`deploy/nginx/templates`) into `~/gitea/nginx/conf.d` instead — same + nginx, additional `server_name` blocks proxying to Musify's containers + over `infra-net` — rather than running a second nginx that would conflict + on those ports. That also needs a certificate valid for the 5 hostnames + (the existing setup uses certbot/Let's Encrypt, not the Cloudflare Origin + cert `compose.prod.yml`'s nginx assumes — reconcile whichever way when + that's set up). + --- ## Layout diff --git a/deploy/compose.prod.yml b/deploy/compose.prod.yml index f4fd18d..623a4e5 100644 --- a/deploy/compose.prod.yml +++ b/deploy/compose.prod.yml @@ -33,7 +33,11 @@ services: environment: # The entrypoint renders /etc/nginx/templates/*.template with envsubst. # The filter keeps it from eating nginx's own $host / $remote_addr. - MUSIFY_ROOT_DOMAIN: ${ROOT_DOMAIN} + MUSIFY_WEB_DOMAIN: ${WEB_DOMAIN} + MUSIFY_API_DOMAIN: ${API_DOMAIN} + MUSIFY_STREAM_DOMAIN: ${STREAM_DOMAIN} + MUSIFY_AUTH_DOMAIN: ${AUTH_DOMAIN} + MUSIFY_S3_DOMAIN: ${S3_DOMAIN} MUSIFY_MAX_UPLOAD_SIZE: ${MAX_UPLOAD_SIZE:-1024m} NGINX_ENVSUBST_FILTER: ^MUSIFY_ ports: diff --git a/deploy/nginx/templates/default.conf.template b/deploy/nginx/templates/default.conf.template index ed2ac8a..91d7ec8 100644 --- a/deploy/nginx/templates/default.conf.template +++ b/deploy/nginx/templates/default.conf.template @@ -1,6 +1,8 @@ # Musify edge. The nginx entrypoint renders this with envsubst into # /etc/nginx/conf.d/default.conf (replacing the image's stock file) using the -# MUSIFY_* variables set in compose.prod.yml. +# MUSIFY_* variables set in compose.prod.yml. Each vhost has its own, +# independent domain (see deploy/.env.prod.example) — they don't need to +# share a root domain. # # Cloudflare (Full strict) terminates the public edge; this box only needs to # serve the Cloudflare Origin Certificate in nginx/certs/. Each vhost proxies to @@ -17,9 +19,8 @@ map $http_upgrade $connection_upgrade { server { listen 80; listen [::]:80; - server_name ${MUSIFY_ROOT_DOMAIN} www.${MUSIFY_ROOT_DOMAIN} - api.${MUSIFY_ROOT_DOMAIN} auth.${MUSIFY_ROOT_DOMAIN} - stream.${MUSIFY_ROOT_DOMAIN} s3.${MUSIFY_ROOT_DOMAIN}; + server_name ${MUSIFY_WEB_DOMAIN} ${MUSIFY_API_DOMAIN} + ${MUSIFY_AUTH_DOMAIN} ${MUSIFY_STREAM_DOMAIN} ${MUSIFY_S3_DOMAIN}; return 301 https://$host$request_uri; } @@ -28,7 +29,7 @@ server { server { listen 443 ssl; http2 on; - server_name ${MUSIFY_ROOT_DOMAIN}; + server_name ${MUSIFY_WEB_DOMAIN}; client_max_body_size ${MUSIFY_MAX_UPLOAD_SIZE}; ssl_certificate /etc/nginx/certs/origin.pem; @@ -44,18 +45,7 @@ server { server { listen 443 ssl; http2 on; - server_name www.${MUSIFY_ROOT_DOMAIN}; - - ssl_certificate /etc/nginx/certs/origin.pem; - ssl_certificate_key /etc/nginx/certs/origin.key; - - return 301 https://${MUSIFY_ROOT_DOMAIN}$request_uri; -} - -server { - listen 443 ssl; - http2 on; - server_name api.${MUSIFY_ROOT_DOMAIN}; + server_name ${MUSIFY_API_DOMAIN}; ssl_certificate /etc/nginx/certs/origin.pem; ssl_certificate_key /etc/nginx/certs/origin.key; @@ -71,7 +61,7 @@ server { server { listen 443 ssl; http2 on; - server_name stream.${MUSIFY_ROOT_DOMAIN}; + server_name ${MUSIFY_STREAM_DOMAIN}; ssl_certificate /etc/nginx/certs/origin.pem; ssl_certificate_key /etc/nginx/certs/origin.key; @@ -87,7 +77,7 @@ server { server { listen 443 ssl; http2 on; - server_name auth.${MUSIFY_ROOT_DOMAIN}; + server_name ${MUSIFY_AUTH_DOMAIN}; ssl_certificate /etc/nginx/certs/origin.pem; ssl_certificate_key /etc/nginx/certs/origin.key; @@ -104,7 +94,7 @@ server { server { listen 443 ssl; http2 on; - server_name s3.${MUSIFY_ROOT_DOMAIN}; + server_name ${MUSIFY_S3_DOMAIN}; client_max_body_size ${MUSIFY_MAX_UPLOAD_SIZE}; ssl_certificate /etc/nginx/certs/origin.pem; diff --git a/web-player/docs/paginas.md b/web-player/docs/paginas.md index 74c11e6..095282c 100644 --- a/web-player/docs/paginas.md +++ b/web-player/docs/paginas.md @@ -9,12 +9,12 @@ Status legend: ✅ done · 🟡 partial / mockup · ⬜ pending. ## Home (app) `/` 🟡 - **Hero**: a greeting plus "Your music. No limits." with a color wash taken from the current track's hue. -- **Quick access, meaning recently played** (`player.recentlyPlayed`, client side for now). Empty state with an icon and an "Explore" CTA. +- **Quick access, meaning recently played** (`GET /users/{id}/listening-history` merged with `player.recentlyPlayed` via `mergeRecentlyPlayed`). Empty state with an icon and an "Explore" CTA. - **Your playlists**: mosaic cards for the user's playlists (sample data for now). Has an empty state. +- **Resume on load**: the root layout fetches `GET /users/{id}/last-listened-track` and hydrates the player (`player.hydrate`) with it, paused, so the player bar isn't empty on a fresh session. Only applies when nothing is already loaded (won't override an in-progress session). ### Pending backend work (ASP.NET) for Home -- **Recently played**: track plays per user (a history table with `userId`, `trackId`, `playedAt`) and expose `GET /me/recently-played?limit=` (or `/users/{id}/recently-played`), returning tracks ordered by `playedAt` descending, with no duplicates. The frontend would load this in a `load` function and pass it to the store instead of using `recentlyPlayed`. - **Playlists by last listen**: add `lastPlayedAt` to the playlist (the schema currently only has `createdAt`/`updatedAt`) and allow sorting `GET /playlists/users/{userId}` by that field (for example `?sort=lastPlayed`), to show "your most recently played" playlists. - Tracks will need an **artist** and a **color/hue** (or a real cover to extract one from) for the artwork and the dynamic accent. Right now `TrackApplicationResponse` carries neither artist nor color. diff --git a/web-player/package-lock.json b/web-player/package-lock.json index 29a198f..1e0fddf 100644 --- a/web-player/package-lock.json +++ b/web-player/package-lock.json @@ -39,6 +39,40 @@ "vite": "^8.0.16" } }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", @@ -1118,7 +1152,6 @@ "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz", "integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==", "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", @@ -1170,7 +1203,6 @@ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", "license": "MIT", - "peer": true, "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", @@ -1408,6 +1440,27 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "dev": true, @@ -1544,7 +1597,6 @@ "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1606,7 +1658,6 @@ "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.64.0", "@typescript-eslint/types": "8.64.0", @@ -1714,7 +1765,6 @@ "integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==", "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1798,7 +1848,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2033,7 +2082,6 @@ "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", "dev": true, "license": "MIT", - "peer": true, "workspaces": [ "packages/*" ], @@ -3112,7 +3160,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -3246,7 +3293,6 @@ "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -3263,7 +3309,6 @@ "integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=20" }, @@ -3434,7 +3479,6 @@ "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" }, @@ -3569,7 +3613,6 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -3781,7 +3824,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3843,7 +3885,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 3350e30..ce584cd 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -553,6 +553,23 @@ export interface paths { patch?: never; trace?: never; }; + "/users/{id}/last-listened-track": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + + get: operations["GetLastTrackListenedByUserId"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/users/{id}/listening-stats": { parameters: { query?: never; @@ -560,7 +577,7 @@ export interface paths { path?: never; cookie?: never; }; - + get: operations["GetListeningStats"]; put?: never; post?: never; @@ -2364,7 +2381,7 @@ export interface operations { }; requestBody?: never; responses: { - + 200: { headers: { [name: string]: unknown; @@ -2375,6 +2392,35 @@ export interface operations { }; }; }; + GetLastTrackListenedByUserId: { + parameters: { + query?: never; + header?: never; + path: { + id: number | string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TrackApplicationResponse"]; + }; + }; + + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; GetUserProfile: { parameters: { query?: never; diff --git a/web-player/src/lib/player/player.svelte.ts b/web-player/src/lib/player/player.svelte.ts index 41f3a50..e34af4c 100644 --- a/web-player/src/lib/player/player.svelte.ts +++ b/web-player/src/lib/player/player.svelte.ts @@ -315,6 +315,15 @@ class PlayerState { return `${url}${sep}t=${encodeURIComponent(ticket)}`; } + hydrate(item: ApiTrackLike) { + if (this.currentId !== null || this.tracks.length > 0) return; + const track = toTrack(toQueueItem(item)); + this.tracks = [track]; + this.currentId = track.id; + this.#applyAccent(track.id); + this.#syncMediaSessionMetadata(track); + } + playlistTracks(ids: (string | number)[]): PlayerTrack[] { return ids .map((id) => this.tracks.find((t) => t.id === id)) @@ -384,6 +393,10 @@ class PlayerState { toggle() { const audio = this.#audioEl(); if (!audio || this.currentId === null) return; + if (!audio.src) { + this.#loadCurrent(); + return; + } if (audio.paused) audio.play().catch(() => {}); else audio.pause(); } diff --git a/web-player/src/routes/+layout.server.ts b/web-player/src/routes/+layout.server.ts index 6bda544..caf325d 100644 --- a/web-player/src/routes/+layout.server.ts +++ b/web-player/src/routes/+layout.server.ts @@ -39,6 +39,22 @@ async function fetchLikedTracks(fetchFn: typeof fetch, accessToken: string | nul } } +async function fetchLastPlayedTrack( + fetchFn: typeof fetch, + accessToken: string | null, + userId: string +) { + try { + const api = createApiClient({ fetch: fetchFn, accessToken: accessToken ?? undefined }); + const { data } = await api.GET('/users/{id}/last-listened-track', { + params: { path: { id: userId } } + }); + return data ?? null; + } catch { + return null; + } +} + export const load: LayoutServerLoad = async ({ locals, url, fetch }) => { const allowAnonymousListening = await getAllowAnonymousListening(fetch); @@ -56,13 +72,15 @@ export const load: LayoutServerLoad = async ({ locals, url, fetch }) => { allowAnonymousListening, sidebarPlaylists: [], likedTracks: [], + lastPlayedTrack: null, accountUrl }; } - const [sidebarPlaylists, likedTracks] = await Promise.all([ + const [sidebarPlaylists, likedTracks, lastPlayedTrack] = await Promise.all([ fetchSidebarPlaylists(fetch, locals.accessToken, locals.user.sub), - fetchLikedTracks(fetch, locals.accessToken) + fetchLikedTracks(fetch, locals.accessToken), + fetchLastPlayedTrack(fetch, locals.accessToken, locals.user.sub) ]); return { @@ -70,6 +88,7 @@ export const load: LayoutServerLoad = async ({ locals, url, fetch }) => { allowAnonymousListening, sidebarPlaylists, likedTracks, + lastPlayedTrack, accountUrl }; }; diff --git a/web-player/src/routes/+layout.svelte b/web-player/src/routes/+layout.svelte index 9f41db5..169fcc7 100644 --- a/web-player/src/routes/+layout.svelte +++ b/web-player/src/routes/+layout.svelte @@ -30,15 +30,19 @@ if (data.likedTracks?.length) liked.hydrate(data.likedTracks); }); + $effect(() => { + if (data.lastPlayedTrack) player.hydrate(data.lastPlayedTrack); + }); + const isAuthPage = $derived(page.url.pathname === '/auth'); const hasTrack = $derived(player.currentId !== null); const initialThemeCss = `:root{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'dark'))}}:root[data-theme='light']{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'light'))}}`; - let hadTrack = false; + let wasPlaying = false; $effect(() => { - if (hasTrack && !hadTrack) queuePanel.show(); - hadTrack = hasTrack; + if (player.playing && !wasPlaying) queuePanel.show(); + wasPlaying = player.playing; }); function isTypingTarget(target: EventTarget | null): boolean {