From bf2d4f846a601a51e7f8ae38ab6b1ba57b9ac5de Mon Sep 17 00:00:00 2001 From: h Date: Thu, 15 Oct 2026 13:19:38 +0000 Subject: [PATCH] feat(backend,frontend): copy liked songs into a shareable playlist --- backend/.env.example | 5 + backend/pyproject.toml | 2 + backend/src/api/app.py | 9 + backend/src/api/modules/copier.py | 152 +++++++++ backend/src/api/modules/session.py | 30 ++ backend/src/api/modules/spotify.py | 96 ++++++ backend/src/api/routers/api/__init__.py | 4 +- backend/src/api/routers/api/auth.py | 61 ++++ backend/src/api/routers/api/copy.py | 21 ++ backend/src/utils/env.py | 14 +- backend/uv.lock | 52 ++- frontend/bun.lock | 6 + frontend/package.json | 2 + frontend/src/app.html | 3 +- frontend/src/lib/components/Sleeve.svelte | 71 ++++ frontend/src/lib/pulsar.ts | 69 ++++ frontend/src/lib/session.svelte.ts | 173 ++++++++++ frontend/src/routes/+page.svelte | 377 +++++++++++++++++++++- frontend/src/routes/layout.css | 58 +++- frontend/static/favicon.svg | 8 + 20 files changed, 1194 insertions(+), 19 deletions(-) create mode 100644 backend/src/api/modules/copier.py create mode 100644 backend/src/api/modules/session.py create mode 100644 backend/src/api/modules/spotify.py create mode 100644 backend/src/api/routers/api/auth.py create mode 100644 backend/src/api/routers/api/copy.py create mode 100644 frontend/src/lib/components/Sleeve.svelte create mode 100644 frontend/src/lib/pulsar.ts create mode 100644 frontend/src/lib/session.svelte.ts create mode 100644 frontend/static/favicon.svg diff --git a/backend/.env.example b/backend/.env.example index f306f92..cf195a0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -11,3 +11,8 @@ API__HOST=0.0.0.0 API__PORT=8080 API__WORKERS=1 API__DOCS=false + +PUBLIC_URL=http://127.0.0.1:5173 +SPOTIFY__CLIENT_ID= +SPOTIFY__CLIENT_SECRET= +SESSION__SECRET= diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7ccf266..bdee8fa 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -8,6 +8,8 @@ authors = [ requires-python = ">=3.13" dependencies = [ "fastapi>=0.139.0", + "httpx>=0.28.1", + "itsdangerous>=2.2.0", "pydantic-settings>=2.14.2", "rich>=15.0.0", "uvicorn[standard]>=0.50.0", diff --git a/backend/src/api/app.py b/backend/src/api/app.py index 7356f0d..477c4df 100644 --- a/backend/src/api/app.py +++ b/backend/src/api/app.py @@ -5,11 +5,13 @@ from pathlib import Path from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse +from starlette.middleware.sessions import SessionMiddleware from api import routers from utils.env import env from utils.logging import setup_logging +SESSION_DAYS = 30 IMMUTABLE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"} NO_CACHE_HEADERS = {"Cache-Control": "no-cache"} @@ -30,6 +32,13 @@ app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"] ) +app.add_middleware( + SessionMiddleware, + secret_key=env.session.secret.get_secret_value(), + max_age=SESSION_DAYS * 86400, + https_only=env.public_url.startswith("https"), +) + app.include_router(routers.router) spa_dir = Path(env.api.static_dir).resolve() diff --git a/backend/src/api/modules/copier.py b/backend/src/api/modules/copier.py new file mode 100644 index 0000000..49dab48 --- /dev/null +++ b/backend/src/api/modules/copier.py @@ -0,0 +1,152 @@ +import asyncio +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any + +from utils.logging import logger + +from .spotify import Spotify + +PAGE = 50 +CHUNK = 100 +LIMIT = 10_000 +PARALLEL = 4 +WINDOW = 200 +ROWS = 30 +COLS = 60 + + +def moment(item: dict[str, Any]) -> float: + return datetime.fromisoformat(item["added_at"]).timestamp() + + +@dataclass +class Job: + total: int = 0 + progress: int = 0 + tracks: list[dict[str, Any]] = field(default_factory=list) + bins: list[list[int]] = field( + default_factory=lambda: [[0] * COLS for _ in range(ROWS)] + ) + row: int = 0 + newest: float = 0 + oldest: float = 0 + playlist: dict[str, str] | None = None + state: str = "running" + task: asyncio.Task[None] | None = None + + def place(self, item: dict[str, Any]) -> None: + span = max(self.newest - self.oldest, 1) + share = min(max((self.newest - moment(item)) / span, 0), 1) + self.row, offset = divmod(min(int(share * ROWS * COLS), ROWS * COLS - 1), COLS) + self.bins[self.row][COLS - 1 - offset] += 1 + + def view(self, since: int = 0) -> dict[str, Any]: + since = max(since, len(self.tracks) - WINDOW) + return { + "state": self.state, + "total": self.total, + "progress": self.progress, + "added": len(self.tracks), + "since": since, + "tracks": self.tracks[since:], + "bins": self.bins, + "row": self.row, + "playlist": self.playlist if self.state == "done" else None, + } + + +jobs: dict[str, Job] = {} + + +def playlist_name() -> str: + today = datetime.now(UTC) + return f"Liked · {today:%b} {today.day}, {today.year}" + + +def card(track: dict[str, Any]) -> dict[str, Any]: + images = track["album"]["images"] + return { + "title": track["name"], + "artists": ", ".join(artist["name"] for artist in track["artists"]), + "cover": images[-1]["url"] if images else None, + "url": track["external_urls"].get("spotify"), + } + + +def start(user_id: str, access_token: str) -> Job: + job = jobs.get(user_id) + if job and job.state == "running": + return job + job = Job() + job.task = asyncio.create_task(run(job, access_token)) + jobs[user_id] = job + return job + + +async def run(job: Job, access_token: str) -> None: + try: + async with Spotify(access_token) as spotify: + await copy(job, spotify) + except Exception: + logger.exception("copy failed") + job.state = "error" + else: + job.state = "done" + + +async def copy(job: Job, spotify: Spotify) -> None: + head = await spotify.call("GET", "/me/tracks", params={"limit": 1}) + job.total = min(head["total"], LIMIT) + if not job.total: + return + tail = await spotify.call( + "GET", "/me/tracks", params={"limit": 1, "offset": job.total - 1} + ) + job.newest = moment(head["items"][0]) + job.oldest = moment((tail["items"] or head["items"])[0]) + playlist = await spotify.call( + "POST", "/me/playlists", json={"name": playlist_name(), "public": True} + ) + gate = asyncio.Semaphore(PARALLEL) + + async def fetch(offset: int) -> list[dict[str, Any]]: + async with gate: + page = await spotify.call( + "GET", "/me/tracks", params={"limit": PAGE, "offset": offset} + ) + return page["items"] + + async def add(items: list[dict[str, Any]]) -> None: + playable = [ + item + for item in items + if item["track"] and not item["track"].get("is_local") + ] + if playable: + await spotify.call( + "POST", + f"/playlists/{playlist['id']}/items", + json={"uris": [item["track"]["uri"] for item in playable]}, + ) + for item in playable: + job.place(item) + job.tracks.append(card(item["track"])) + job.progress += len(items) + + async with asyncio.TaskGroup() as group: + pages = [ + group.create_task(fetch(offset)) for offset in range(0, job.total, PAGE) + ] + buffer: list[dict[str, Any]] = [] + for page in pages: + buffer += await page + while len(buffer) >= CHUNK: + await add(buffer[:CHUNK]) + buffer = buffer[CHUNK:] + await add(buffer) + + job.playlist = { + "name": playlist["name"], + "url": playlist["external_urls"]["spotify"], + } diff --git a/backend/src/api/modules/session.py b/backend/src/api/modules/session.py new file mode 100644 index 0000000..d9a7dd0 --- /dev/null +++ b/backend/src/api/modules/session.py @@ -0,0 +1,30 @@ +from typing import Annotated + +import httpx +from fastapi import Depends, HTTPException, Request, status + +from . import spotify + + +async def access_token(request: Request) -> str: + token = request.session.get("token") + if not token: + raise HTTPException(status.HTTP_401_UNAUTHORIZED) + try: + token = await spotify.fresh_token(token) + except httpx.HTTPStatusError as error: + request.session.clear() + raise HTTPException(status.HTTP_401_UNAUTHORIZED) from error + request.session["token"] = token + return token["access"] + + +def user_id(request: Request) -> str: + user = request.session.get("user") + if not user: + raise HTTPException(status.HTTP_401_UNAUTHORIZED) + return user["id"] + + +AccessToken = Annotated[str, Depends(access_token)] +UserId = Annotated[str, Depends(user_id)] diff --git a/backend/src/api/modules/spotify.py b/backend/src/api/modules/spotify.py new file mode 100644 index 0000000..11226dc --- /dev/null +++ b/backend/src/api/modules/spotify.py @@ -0,0 +1,96 @@ +import asyncio +import time +from types import TracebackType +from typing import Any, Self +from urllib.parse import urlencode + +import httpx + +from utils.env import env + +ACCOUNTS = "https://accounts.spotify.com" +API = "https://api.spotify.com/v1" +SCOPES = "user-library-read playlist-modify-public playlist-modify-private" +TOKEN_MARGIN = 600 +ATTEMPTS = 5 +MAX_WAIT = 30 + + +def redirect_uri() -> str: + return f"{env.public_url}/api/callback" + + +def authorize_url(state: str, *, show_dialog: bool) -> str: + query = urlencode( + { + "client_id": env.spotify.client_id, + "response_type": "code", + "redirect_uri": redirect_uri(), + "scope": SCOPES, + "state": state, + "show_dialog": str(show_dialog).lower(), + } + ) + return f"{ACCOUNTS}/authorize?{query}" + + +async def request_token(data: dict[str, str]) -> dict[str, Any]: + auth = (env.spotify.client_id, env.spotify.client_secret.get_secret_value()) + async with httpx.AsyncClient(timeout=30) as client: + response = await client.post(f"{ACCOUNTS}/api/token", data=data, auth=auth) + response.raise_for_status() + token = response.json() + return { + "access": token["access_token"], + "refresh": token.get("refresh_token") or data.get("refresh_token"), + "expires": time.time() + token["expires_in"], + } + + +async def exchange_code(code: str) -> dict[str, Any]: + return await request_token( + { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri(), + } + ) + + +async def fresh_token(token: dict[str, Any]) -> dict[str, Any]: + if token["expires"] - time.time() > TOKEN_MARGIN: + return token + return await request_token( + {"grant_type": "refresh_token", "refresh_token": token["refresh"]} + ) + + +class Spotify: + def __init__(self, access_token: str) -> None: + self.http = httpx.AsyncClient( + base_url=API, + headers={"Authorization": f"Bearer {access_token}"}, + timeout=30, + ) + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + await self.http.aclose() + + async def call(self, method: str, path: str, **kwargs: Any) -> Any: # noqa: ANN401 + for attempt in range(ATTEMPTS): + response = await self.http.request(method, path, **kwargs) + retryable = response.status_code == httpx.codes.TOO_MANY_REQUESTS + if not retryable and not response.is_server_error: + break + wait = float(response.headers.get("retry-after", 2**attempt)) + await asyncio.sleep(min(wait, MAX_WAIT)) + response.raise_for_status() + return response.json() diff --git a/backend/src/api/routers/api/__init__.py b/backend/src/api/routers/api/__init__.py index 2448413..a3a25ac 100644 --- a/backend/src/api/routers/api/__init__.py +++ b/backend/src/api/routers/api/__init__.py @@ -1,6 +1,8 @@ from fastapi import APIRouter -from . import health +from . import auth, copy, health router = APIRouter() router.include_router(health.router, prefix="/health") +router.include_router(auth.router) +router.include_router(copy.router, prefix="/copy") diff --git a/backend/src/api/routers/api/auth.py b/backend/src/api/routers/api/auth.py new file mode 100644 index 0000000..debc665 --- /dev/null +++ b/backend/src/api/routers/api/auth.py @@ -0,0 +1,61 @@ +import secrets +from typing import Any + +import httpx +from fastapi import APIRouter, Request, Response, status +from fastapi.responses import RedirectResponse + +from api.modules import copier, spotify +from api.modules.session import AccessToken, UserId + +router = APIRouter(tags=["auth"]) + + +@router.get("/login") +async def login(request: Request, *, switch: bool = False) -> RedirectResponse: + state = secrets.token_urlsafe(16) + request.session["state"] = state + return RedirectResponse(spotify.authorize_url(state, show_dialog=switch)) + + +@router.get("/callback") +async def callback( + request: Request, code: str | None = None, state: str | None = None +) -> RedirectResponse: + expected = request.session.pop("state", None) + if not code or not state or state != expected: + return RedirectResponse("/") + token = await spotify.exchange_code(code) + try: + async with spotify.Spotify(token["access"]) as client: + me = await client.call("GET", "/me") + except httpx.HTTPStatusError as error: + if error.response.status_code != status.HTTP_403_FORBIDDEN: + raise + return RedirectResponse("/?denied") + images = me.get("images") or [] + request.session["token"] = token + request.session["user"] = { + "id": me["id"], + "name": me.get("display_name") or me["id"], + "avatar": images[0]["url"] if images else None, + } + return RedirectResponse("/") + + +@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +async def logout(request: Request) -> Response: + request.session.clear() + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/me") +async def me(request: Request, token: AccessToken, uid: UserId) -> dict[str, Any]: + async with spotify.Spotify(token) as client: + liked = await client.call("GET", "/me/tracks", params={"limit": 1}) + job = copier.jobs.get(uid) + return { + **request.session["user"], + "liked": liked["total"], + "job": job.view() if job else None, + } diff --git a/backend/src/api/routers/api/copy.py b/backend/src/api/routers/api/copy.py new file mode 100644 index 0000000..f8d4a8c --- /dev/null +++ b/backend/src/api/routers/api/copy.py @@ -0,0 +1,21 @@ +from typing import Any + +from fastapi import APIRouter, HTTPException, status + +from api.modules import copier +from api.modules.session import AccessToken, UserId + +router = APIRouter(tags=["copy"]) + + +@router.post("") +async def start(token: AccessToken, uid: UserId) -> dict[str, Any]: + return copier.start(uid, token).view() + + +@router.get("") +async def progress(uid: UserId, since: int = 0) -> dict[str, Any]: + job = copier.jobs.get(uid) + if not job: + raise HTTPException(status.HTTP_404_NOT_FOUND) + return job.view(since) diff --git a/backend/src/utils/env.py b/backend/src/utils/env.py index 7850cdd..d12039e 100644 --- a/backend/src/utils/env.py +++ b/backend/src/utils/env.py @@ -1,4 +1,4 @@ -from pydantic import Field +from pydantic import Field, SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict @@ -22,9 +22,21 @@ class ApiSettings(Section): static_dir: str = "static" +class SpotifySettings(Section): + client_id: str = "" + client_secret: SecretStr = SecretStr("") + + +class SessionSettings(Section): + secret: SecretStr = SecretStr("dev") + + class Settings(BaseSettings): log: LogSettings = Field(default_factory=LogSettings) api: ApiSettings = Field(default_factory=ApiSettings) + spotify: SpotifySettings = Field(default_factory=SpotifySettings) + session: SessionSettings = Field(default_factory=SessionSettings) + public_url: str = "http://127.0.0.1:5173" model_config = SettingsConfigDict( case_sensitive=False, diff --git a/backend/uv.lock b/backend/uv.lock index e6b041d..f5341f8 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.13" [[package]] @@ -33,6 +33,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + [[package]] name = "click" version = "8.5.0" @@ -68,6 +77,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + [[package]] name = "httptools" version = "0.9.0" @@ -141,6 +163,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/4b/5e96c4e0d171f959a0064971c3fced9cea5a19e5fab7a8e7d57aceb80506/httptools-0.9.0-cp315-cp315t-win_arm64.whl", hash = "sha256:4a4d8c2c7e73ba5967be74d7c3a5ff81fde815ee1b48d9c5c0f14de8463a847b", size = 95947, upload-time = "2026-10-09T19:56:40.562Z" }, ] +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.20" @@ -150,6 +187,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + [[package]] name = "markdown-it-py" version = "4.2.0" @@ -398,6 +444,8 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "fastapi" }, + { name = "httpx" }, + { name = "itsdangerous" }, { name = "pydantic-settings" }, { name = "rich" }, { name = "uvicorn", extra = ["standard"] }, @@ -412,6 +460,8 @@ dev = [ [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.139.0" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "itsdangerous", specifier = ">=2.2.0" }, { name = "pydantic-settings", specifier = ">=2.14.2" }, { name = "rich", specifier = ">=15.0.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.50.0" }, diff --git a/frontend/bun.lock b/frontend/bun.lock index 7de924f..562430b 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -6,6 +6,8 @@ "name": "spotigrab", "devDependencies": { "@biomejs/biome": "^2.5.15", + "@fontsource-variable/archivo": "^5.3.0", + "@fontsource-variable/commissioner": "^5.3.0", "@sveltejs/adapter-static": "^4.0.0", "@sveltejs/kit": "^3.0.1", "@sveltejs/vite-plugin-svelte": "^7.1.2", @@ -51,6 +53,10 @@ "@clack/prompts": ["@clack/prompts@1.8.1", "", { "dependencies": { "@clack/core": "1.5.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ=="], + "@fontsource-variable/archivo": ["@fontsource-variable/archivo@5.3.0", "", {}, "sha512-HogK8FJelrD1o7TlZlkIVtHgc20bO5PZRWE7mUeUTdMN055alznQV6/00J00IBeu8FQAH4s3zW9UJNvKExXf+g=="], + + "@fontsource-variable/commissioner": ["@fontsource-variable/commissioner@5.3.0", "", {}, "sha512-0IEvi8SO1slc5Pc9tA4OtA01I8UiCcEq/uh12oNjfHuABU9Lnz61l2V8o2cMPUUo3BAo5MbISVK2I/DSpBRw7Q=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], diff --git a/frontend/package.json b/frontend/package.json index 2543a37..ae49cda 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,8 @@ }, "devDependencies": { "@biomejs/biome": "^2.5.15", + "@fontsource-variable/archivo": "^5.3.0", + "@fontsource-variable/commissioner": "^5.3.0", "@sveltejs/adapter-static": "^4.0.0", "@sveltejs/kit": "^3.0.1", "@sveltejs/vite-plugin-svelte": "^7.1.2", diff --git a/frontend/src/app.html b/frontend/src/app.html index f498274..9bb7f3c 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -1,9 +1,10 @@ - + + %sveltekit.head% diff --git a/frontend/src/lib/components/Sleeve.svelte b/frontend/src/lib/components/Sleeve.svelte new file mode 100644 index 0000000..c0b6c3e --- /dev/null +++ b/frontend/src/lib/components/Sleeve.svelte @@ -0,0 +1,71 @@ + + + + {#each ridges as ridge, index (index)} + + + {/each} + + + diff --git a/frontend/src/lib/pulsar.ts b/frontend/src/lib/pulsar.ts new file mode 100644 index 0000000..3ca520c --- /dev/null +++ b/frontend/src/lib/pulsar.ts @@ -0,0 +1,69 @@ +export const ROWS = 30; +const COLS = 60; +const PAD = 6; +const KERNEL = [0.04, 0.1, 0.2, 0.32, 0.2, 0.1, 0.04]; +const GAMMA = 0.75; +const WIDTH = COLS + 2 * PAD; +const TAPER = Array.from( + { length: WIDTH }, + (_, index) => Math.sin((Math.PI * (index + 0.5)) / WIDTH) ** 1.5 +); +const REACH = Math.floor(KERNEL.length / 2); + +const SIZE = 600; +const LEFT = 40; +const RIGHT = 560; +const DATA_LEFT = 130; +const DATA_RIGHT = 470; +const TOP = 120; +const BOTTOM = 560; +const STEP = (BOTTOM - TOP) / (ROWS - 1); +const AMPLITUDE = STEP * 5.5; +const BLEED = 10; + +export const VIEWBOX = `0 0 ${SIZE} ${SIZE}`; +export const FLAT: number[][] = Array.from({ length: ROWS }, () => + new Array(WIDTH).fill(0) +); + +const smooth = (row: number[]): number[] => { + const padded = [...new Array(PAD).fill(0), ...row, ...new Array(PAD).fill(0)]; + return padded.map( + (_, index) => + TAPER[index] * + KERNEL.reduce( + (sum, weight, offset) => + sum + weight * (padded[index + offset - REACH] ?? 0), + 0 + ) ** + GAMMA + ); +}; + +export const shape = (bins: number[][]): number[][] => { + const rows = bins.map(smooth); + const peak = Math.max(Number.EPSILON, ...rows.flat()); + return rows.map((row) => row.map((value) => value / peak)); +}; + +const trace = (values: number[], row: number) => { + const y = TOP + row * STEP; + const dx = (DATA_RIGHT - DATA_LEFT) / Math.max(values.length - 1, 1); + const points = values + .map( + (value, index) => + `L${(DATA_LEFT + index * dx).toFixed(1)} ${(y - value * AMPLITUDE).toFixed(1)}` + ) + .join(""); + return { body: `L${DATA_LEFT} ${y}${points}L${RIGHT} ${y}`, y }; +}; + +export const line = (values: number[], row: number): string => { + const { body, y } = trace(values, row); + return `M${LEFT} ${y}${body}`; +}; + +export const fill = (values: number[], row: number): string => { + const { body, y } = trace(values, row); + return `M${-BLEED} ${y}L${LEFT} ${y}${body}L${SIZE + BLEED} ${y}V${SIZE + BLEED}H${-BLEED}Z`; +}; diff --git a/frontend/src/lib/session.svelte.ts b/frontend/src/lib/session.svelte.ts new file mode 100644 index 0000000..6ffba49 --- /dev/null +++ b/frontend/src/lib/session.svelte.ts @@ -0,0 +1,173 @@ +import { ApiError, api } from "./api/client.ts"; + +export interface Track { + artists: string; + cover: string | null; + title: string; + url: string | null; +} + +export interface Playlist { + name: string; + url: string; +} + +interface Job { + added: number; + bins: number[][]; + playlist: Playlist | null; + progress: number; + row: number; + since: number; + state: "running" | "done" | "error"; + total: number; + tracks: Track[]; +} + +interface Me { + avatar: string | null; + id: string; + job: Job | null; + liked: number; + name: string; +} + +export type Stage = + | "loading" + | "out" + | "denied" + | "ready" + | "copying" + | "done" + | "stopped"; + +const POLL_MS = 600; +const TICK_MS = 180; +const SHOWN = 5; +const QUEUE = 24; +const CLIENT_ERROR = 500; + +const pause = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +export class Session { + stage = $state("loading"); + me = $state.raw(null); + total = $state(0); + progress = $state(0); + added = $state(0); + bins = $state.raw(null); + row = $state(0); + shown = $state.raw([]); + playlist = $state.raw(null); + #since = 0; + #queue: Track[] = []; + #timer: ReturnType | undefined; + + async load(): Promise { + if (new URLSearchParams(location.search).has("denied")) { + history.replaceState(null, "", "/"); + this.stage = "denied"; + return; + } + try { + this.me = await api.get("/me"); + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + this.stage = "out"; + return; + } + throw error; + } + const { job } = this.me; + if (!job) { + this.stage = "ready"; + return; + } + if (job.state === "running") { + await this.#follow(job); + return; + } + this.#apply(job); + this.#flush(); + this.stage = job.state === "done" ? "done" : "stopped"; + } + + async start(): Promise { + this.#since = 0; + this.#queue = []; + this.shown = []; + this.bins = null; + this.row = 0; + this.progress = 0; + this.added = 0; + this.playlist = null; + this.stage = "copying"; + try { + await this.#follow(await api.post("/copy")); + } catch { + this.stage = "stopped"; + } + } + + async logout(): Promise { + await api.post("/logout"); + this.me = null; + this.stage = "out"; + } + + async #follow(job: Job): Promise { + this.stage = "copying"; + this.#apply(job); + this.#timer = setInterval(this.#tick, TICK_MS); + const last = await this.#poll(job); + clearInterval(this.#timer); + this.#flush(); + this.stage = last.state === "done" ? "done" : "stopped"; + } + + async #poll(job: Job): Promise { + if (job.state !== "running") { + return job; + } + await pause(POLL_MS); + let next = job; + try { + next = await api.get(`/copy?since=${this.#since}`); + this.#apply(next); + } catch (error) { + if (error instanceof ApiError && error.status < CLIENT_ERROR) { + return { ...job, state: "error" }; + } + } + return this.#poll(next); + } + + #apply(job: Job): void { + this.total = job.total; + this.progress = job.progress; + this.added = job.added; + this.bins = job.bins; + this.row = job.row; + this.playlist = job.playlist; + this.#queue = [...this.#queue, ...job.tracks].slice(-QUEUE); + this.#since = job.since + job.tracks.length; + } + + readonly #tick = (): void => { + const next = this.#queue.shift(); + if (next) { + this.shown = [next, ...this.shown].slice(0, SHOWN); + } + }; + + #flush(): void { + this.shown = [...this.#queue.slice(-SHOWN).reverse(), ...this.shown].slice( + 0, + SHOWN + ); + this.#queue = []; + } +} diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index fae64ea..80eacd1 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,13 +1,376 @@ + + - spotigrab + {title} +{#snippet spotify( + size: string +)} + +{/snippet} +
-

spotigrab

-

- Edit - src/routes/+page.svelte. -

+
+ {#if session.me} +
+ {#if session.me.avatar} + + {:else} + + {session.me.name.charAt(0).toUpperCase()} + + {/if} + {session.me.name} +
+ + {/if} +
+ +
+
+ +
+ +
+
+ + {#if session.stage === "copying"} + {@render spotify("size-[22px]")} + {/if} +
+ + {#if headline !== null} +

+ {format(Math.round(count.current))} +

+ {/if} + {#if caption} +

{caption}

+ {/if} + + {#if session.stage === "copying"} +
    + {#each session.shown as track, index (track)} +
  1. + + {#if track.cover} + + {/if} + + {track.title} + · {track.artists} + + +
  2. + {/each} +
+ {/if} + + {#if session.stage === "done" && session.playlist} + + {session.playlist.url.replace("https://", "")} + + {/if} + + {#if session.stage === "stopped"} +

Spotify keeps what was added.

+ {/if} + + {#if session.stage === "denied"} +

+ Ask to be added, or switch accounts. +

+ {/if} + +
+ {#if session.stage === "out" || session.stage === "denied"} + + + {session.stage === "denied" + ? "Use another account" + : "Continue with Spotify"} + + + {@render spotify("size-[22px]")} + + + {/if} + + {#if session.stage === "ready" || session.stage === "stopped"} + + {/if} + + {#if session.stage === "done" && session.playlist} + {@const url = session.playlist.url} + + + Open in Spotify + {@render spotify("size-[22px]")} + + + {/if} +
+
+
+ + diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css index ad18ed2..802a43a 100644 --- a/frontend/src/routes/layout.css +++ b/frontend/src/routes/layout.css @@ -1,17 +1,59 @@ +@import "@fontsource-variable/archivo/wdth.css"; +@import "@fontsource-variable/commissioner"; @import "tailwindcss"; @theme { --font-sans: - system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + "Commissioner Variable", system-ui, -apple-system, "Segoe UI", sans-serif; + --font-wide: "Archivo Variable", "Commissioner Variable", sans-serif; + --color-ground: #0b0b0a; + --color-ink: #ecebe6; + --color-mute: #93928c; + --color-rule: #2c2c2a; + --color-ready: #2f5bd3; + --color-busy: #e8c21a; + --color-stop: #d42a2a; } -html, -body { - font-family: var(--font-sans); +@layer base { + html { + color-scheme: dark; + background: var(--color-ground); + color: var(--color-ink); + font-family: var(--font-sans); + font-variant-numeric: tabular-nums; + -webkit-font-smoothing: antialiased; + -webkit-tap-highlight-color: transparent; + overscroll-behavior: none; + } + + ::selection { + color: var(--color-ground); + background: var(--color-ink); + } + + :focus-visible { + outline: 1px solid var(--color-ink); + outline-offset: 3px; + } + + button:not(:disabled), + a { + cursor: pointer; + } } -button:not(:disabled), -[role="button"]:not(:disabled), -a { - cursor: pointer; +@utility caps { + font-family: var(--font-wide); + font-size: 11px; + font-weight: 500; + font-stretch: 125%; + line-height: 16px; + text-transform: uppercase; + letter-spacing: 0.28em; +} + +@utility wide { + font-family: var(--font-wide); + font-stretch: 125%; } diff --git a/frontend/static/favicon.svg b/frontend/static/favicon.svg new file mode 100644 index 0000000..e012b7b --- /dev/null +++ b/frontend/static/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + +