feat(backend,frontend): copy liked songs into a shareable playlist

This commit is contained in:
hh committed 2026-10-15 13:19:38 +00:00
1 parent 09ca301d51
commit bf2d4f846a
20 files changed
+1194 -19

No files matched your search

+5
View File
@@ -11,3 +11,8 @@ API__HOST=0.0.0.0
API__PORT=8080 API__PORT=8080
API__WORKERS=1 API__WORKERS=1
API__DOCS=false API__DOCS=false
PUBLIC_URL=http://127.0.0.1:5173
SPOTIFY__CLIENT_ID=
SPOTIFY__CLIENT_SECRET=
SESSION__SECRET=
+2
View File
@@ -8,6 +8,8 @@ authors = [
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"fastapi>=0.139.0", "fastapi>=0.139.0",
"httpx>=0.28.1",
"itsdangerous>=2.2.0",
"pydantic-settings>=2.14.2", "pydantic-settings>=2.14.2",
"rich>=15.0.0", "rich>=15.0.0",
"uvicorn[standard]>=0.50.0", "uvicorn[standard]>=0.50.0",
+9
View File
@@ -5,11 +5,13 @@ from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from starlette.middleware.sessions import SessionMiddleware
from api import routers from api import routers
from utils.env import env from utils.env import env
from utils.logging import setup_logging from utils.logging import setup_logging
SESSION_DAYS = 30
IMMUTABLE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"} IMMUTABLE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
NO_CACHE_HEADERS = {"Cache-Control": "no-cache"} NO_CACHE_HEADERS = {"Cache-Control": "no-cache"}
@@ -30,6 +32,13 @@ app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"] 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) app.include_router(routers.router)
spa_dir = Path(env.api.static_dir).resolve() spa_dir = Path(env.api.static_dir).resolve()
+152
View File
@@ -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"],
}
+30
View File
@@ -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)]
+96
View File
@@ -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()
+3 -1
View File
@@ -1,6 +1,8 @@
from fastapi import APIRouter from fastapi import APIRouter
from . import health from . import auth, copy, health
router = APIRouter() router = APIRouter()
router.include_router(health.router, prefix="/health") router.include_router(health.router, prefix="/health")
router.include_router(auth.router)
router.include_router(copy.router, prefix="/copy")
+61
View File
@@ -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,
}
+21
View File
@@ -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)
+13 -1
View File
@@ -1,4 +1,4 @@
from pydantic import Field from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -22,9 +22,21 @@ class ApiSettings(Section):
static_dir: str = "static" static_dir: str = "static"
class SpotifySettings(Section):
client_id: str = ""
client_secret: SecretStr = SecretStr("")
class SessionSettings(Section):
secret: SecretStr = SecretStr("dev")
class Settings(BaseSettings): class Settings(BaseSettings):
log: LogSettings = Field(default_factory=LogSettings) log: LogSettings = Field(default_factory=LogSettings)
api: ApiSettings = Field(default_factory=ApiSettings) 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( model_config = SettingsConfigDict(
case_sensitive=False, case_sensitive=False,
+51 -1
View File
@@ -1,5 +1,5 @@
version = 1 version = 1
revision = 3 revision = 5
requires-python = ">=3.13" requires-python = ">=3.13"
[[package]] [[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" }, { 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]] [[package]]
name = "click" name = "click"
version = "8.5.0" 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" }, { 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]] [[package]]
name = "httptools" name = "httptools"
version = "0.9.0" 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" }, { 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]] [[package]]
name = "idna" name = "idna"
version = "3.20" 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" }, { 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]] [[package]]
name = "markdown-it-py" name = "markdown-it-py"
version = "4.2.0" version = "4.2.0"
@@ -398,6 +444,8 @@ version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" },
{ name = "itsdangerous" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "rich" }, { name = "rich" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
@@ -412,6 +460,8 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "fastapi", specifier = ">=0.139.0" }, { 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 = "pydantic-settings", specifier = ">=2.14.2" },
{ name = "rich", specifier = ">=15.0.0" }, { name = "rich", specifier = ">=15.0.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.50.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.50.0" },
+6
View File
@@ -6,6 +6,8 @@
"name": "spotigrab", "name": "spotigrab",
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.5.15", "@biomejs/biome": "^2.5.15",
"@fontsource-variable/archivo": "^5.3.0",
"@fontsource-variable/commissioner": "^5.3.0",
"@sveltejs/adapter-static": "^4.0.0", "@sveltejs/adapter-static": "^4.0.0",
"@sveltejs/kit": "^3.0.1", "@sveltejs/kit": "^3.0.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@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=="], "@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/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=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
+2
View File
@@ -19,6 +19,8 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.5.15", "@biomejs/biome": "^2.5.15",
"@fontsource-variable/archivo": "^5.3.0",
"@fontsource-variable/commissioner": "^5.3.0",
"@sveltejs/adapter-static": "^4.0.0", "@sveltejs/adapter-static": "^4.0.0",
"@sveltejs/kit": "^3.0.1", "@sveltejs/kit": "^3.0.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
+2 -1
View File
@@ -1,9 +1,10 @@
<!doctype html> <!doctype html>
<html lang="ru"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta content="width=device-width, initial-scale=1" name="viewport"> <meta content="width=device-width, initial-scale=1" name="viewport">
<meta content="scale" name="text-scale"> <meta content="scale" name="text-scale">
<meta content="#0b0b0a" name="theme-color">
<link href="%sveltekit.assets%/favicon.svg" rel="icon" type="image/svg+xml"> <link href="%sveltekit.assets%/favicon.svg" rel="icon" type="image/svg+xml">
%sveltekit.head% %sveltekit.head%
</head> </head>
+71
View File
@@ -0,0 +1,71 @@
<script lang="ts">
import { cubicOut } from "svelte/easing";
import { prefersReducedMotion, Tween } from "svelte/motion";
import { FLAT, fill, line, shape, VIEWBOX } from "../pulsar.ts";
type Mode = "flat" | "live" | "full";
interface Props {
bins: number[][] | null;
mode: Mode;
row: number;
}
const { bins, mode, row }: Props = $props();
const target = $derived(bins && mode !== "flat" ? shape(bins) : FLAT);
const values = Tween.of(() => target, {
duration: prefersReducedMotion.current ? 0 : 700,
easing: cubicOut,
});
const ridges = $derived(
values.current.map((points, index) => ({
fill: fill(points, index),
line: line(points, index),
}))
);
const tone = (index: number): string => {
if (mode === "full" || (mode === "live" && index < row)) {
return "on";
}
return mode === "live" && index === row ? "now" : "off";
};
</script>
<svg
aria-label="Your likes over time"
class="block aspect-square w-full"
role="img"
viewBox={VIEWBOX}
>
{#each ridges as ridge, index (index)}
<path class="ground" d={ridge.fill} />
<path class={["ridge", tone(index)]} d={ridge.line} />
{/each}
</svg>
<style>
.ground {
fill: var(--color-ground);
}
.ridge {
fill: none;
stroke: var(--color-ink);
stroke-width: 1;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
transition:
stroke-opacity 0.6s cubic-bezier(0.16, 1, 0.3, 1),
stroke-width 0.3s;
}
.off {
stroke-opacity: 0.26;
}
.now {
stroke-width: 2;
}
</style>
+69
View File
@@ -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`;
};
+173
View File
@@ -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<Stage>("loading");
me = $state.raw<Me | null>(null);
total = $state(0);
progress = $state(0);
added = $state(0);
bins = $state.raw<number[][] | null>(null);
row = $state(0);
shown = $state.raw<Track[]>([]);
playlist = $state.raw<Playlist | null>(null);
#since = 0;
#queue: Track[] = [];
#timer: ReturnType<typeof setInterval> | undefined;
async load(): Promise<void> {
if (new URLSearchParams(location.search).has("denied")) {
history.replaceState(null, "", "/");
this.stage = "denied";
return;
}
try {
this.me = await api.get<Me>("/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<void> {
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<Job>("/copy"));
} catch {
this.stage = "stopped";
}
}
async logout(): Promise<void> {
await api.post("/logout");
this.me = null;
this.stage = "out";
}
async #follow(job: Job): Promise<void> {
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<Job> {
if (job.state !== "running") {
return job;
}
await pause(POLL_MS);
let next = job;
try {
next = await api.get<Job>(`/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 = [];
}
}
+370 -7
View File
@@ -1,13 +1,376 @@
<script lang="ts">
import { onMount } from "svelte";
import { cubicOut } from "svelte/easing";
import { prefersReducedMotion, Tween } from "svelte/motion";
import { slide } from "svelte/transition";
import Sleeve from "#lib/components/Sleeve.svelte";
import { Session, type Stage } from "#lib/session.svelte.js";
const CODES = ["bg-ready", "bg-busy", "bg-ink", "bg-stop"];
const CODE: Partial<Record<Stage, number>> = {
copying: 1,
denied: 3,
done: 2,
ready: 0,
stopped: 3,
};
const FADE = [1, 0.62, 0.4, 0.22, 0];
const COPIED_MS = 1600;
const SPOTIFY =
"M12 0a12 12 0 1 0 0 24 12 12 0 0 0 0-24Zm5.5 17.3a.75.75 0 0 1-1 .25c-2.85-1.74-6.43-2.13-10.66-1.17a.75.75 0 1 1-.33-1.46c4.62-1.06 8.6-.6 11.74 1.33.35.21.46.67.25 1.05Zm1.47-3.27a.94.94 0 0 1-1.29.31c-3.26-2-8.23-2.58-12.09-1.41a.94.94 0 0 1-.54-1.8c4.41-1.34 9.89-.69 13.62 1.6.44.27.58.85.3 1.3Zm.13-3.4C15.18 8.3 8.73 8.08 5 9.22a1.13 1.13 0 1 1-.65-2.16c4.28-1.3 11.4-1.05 15.9 1.62a1.13 1.13 0 0 1-1.15 1.94Z";
const session = new Session();
const { format } = new Intl.NumberFormat("en-US");
const motion = prefersReducedMotion.current ? 0 : 1;
let copied = $state(false);
let field = $state<HTMLAnchorElement>();
const liked = $derived(session.me?.liked ?? 0);
const headline = $derived.by(() => {
switch (session.stage) {
case "ready":
return liked;
case "copying":
case "stopped":
return session.progress;
case "done":
return session.added;
default:
return null;
}
});
const caption = $derived.by(() => {
switch (session.stage) {
case "ready":
return "Liked songs";
case "copying":
return session.total ? `of ${format(session.total)}` : "";
case "done":
return session.playlist?.name ?? "";
case "stopped":
return `of ${format(session.total)}, stopped`;
case "denied":
return "Not on the list";
default:
return "";
}
});
const title = $derived.by(() => {
switch (session.stage) {
case "ready":
return `${format(liked)} liked songs`;
case "copying":
return `${format(session.progress)} / ${format(session.total)}`;
case "done":
return session.playlist?.name ?? "Liked songs";
default:
return "Liked songs";
}
});
const mode = $derived.by(() => {
if (session.stage === "done") {
return "full";
}
return session.stage === "copying" || session.stage === "stopped"
? "live"
: "flat";
});
const count = Tween.of(() => headline ?? 0, {
duration: 500 * motion,
easing: cubicOut,
});
const copy = async (url: string) => {
try {
await navigator.clipboard.writeText(url);
} catch {
if (field) {
getSelection()?.selectAllChildren(field);
}
return;
}
copied = true;
setTimeout(() => {
copied = false;
}, COPIED_MS);
};
onMount(() => {
session.load();
});
</script>
<svelte:head> <svelte:head>
<title>spotigrab</title> <title>{title}</title>
</svelte:head> </svelte:head>
{#snippet spotify(
size: string
)}
<svg aria-hidden="true" class={["shrink-0", size]} viewBox="0 0 24 24">
<path d={SPOTIFY} fill="currentColor" />
</svg>
{/snippet}
<main <main
class="flex min-h-dvh flex-col items-center justify-center gap-4 px-6 text-center bg-white text-neutral-900 dark:bg-neutral-950 dark:text-neutral-50" class="mx-auto flex min-h-dvh max-w-[1180px] flex-col px-6 pt-[max(8px,env(safe-area-inset-top))] pb-[max(20px,env(safe-area-inset-bottom))] lg:px-12"
> >
<h1 class="font-bold text-4xl tracking-tight sm:text-5xl">spotigrab</h1> <header class="flex h-14 shrink-0 items-center justify-between gap-4">
<p class="max-w-prose text-base text-neutral-500"> {#if session.me}
Edit <div class="flex min-w-0 items-center gap-3">
<code class="font-mono">src/routes/+page.svelte</code>. {#if session.me.avatar}
</p> <img
alt=""
class="size-7 rounded-full border border-rule object-cover"
height="28"
src={session.me.avatar}
width="28"
>
{:else}
<span
class="grid size-7 place-items-center rounded-full border border-mute text-[12px]"
>
{session.me.name.charAt(0).toUpperCase()}
</span>
{/if}
<span class="truncate text-[14px]">{session.me.name}</span>
</div>
<button
class="caps -mr-2 px-2 py-3 text-mute transition-colors hover:text-ink"
onclick={() => session.logout()}
type="button"
>
Log out
</button>
{/if}
</header>
<div
class="flex flex-1 flex-col gap-6 lg:grid lg:grid-cols-[minmax(0,420px)_1fr] lg:items-center lg:gap-20"
>
<div
class="mx-auto w-full max-w-[min(100%,40dvh)] border border-rule lg:order-2 lg:max-w-[min(72vh,600px)]"
>
<Sleeve bins={session.bins} {mode} row={session.row} />
</div>
<section class="flex flex-1 flex-col lg:order-1 lg:flex-none">
<div class="flex h-5 items-center justify-between">
<div
aria-hidden="true"
class={[
"flex gap-1",
CODE[session.stage] === undefined && "invisible",
]}
>
{#each CODES as code, index (code)}
<span
class={[
"h-2 w-5 transition-colors duration-500",
CODE[session.stage] === index ? code : "bg-rule",
]}
></span>
{/each}
</div>
{#if session.stage === "copying"}
<span title="Spotify">{@render spotify("size-[22px]")}</span>
{/if}
</div>
{#if headline !== null}
<p
class="wide mt-4 text-[clamp(56px,16vw,88px)] leading-[0.9] font-[450] tracking-[-0.02em]"
>
{format(Math.round(count.current))}
</p>
{/if}
{#if caption}
<p class="caps mt-3" role="status">{caption}</p>
{/if}
{#if session.stage === "copying"}
<ol class="mt-6 h-[164px] overflow-hidden">
{#each session.shown as track, index (track)}
<li
class="pb-2.5 transition-opacity duration-300"
style:opacity={FADE[index]}
in:slide={{ duration: 170 * motion }}
>
<a
class="flex min-w-0 items-center gap-3"
href={track.url}
rel="noopener"
target="_blank"
>
{#if track.cover}
<img
alt=""
class="size-8 shrink-0 bg-rule"
height="32"
src={track.cover}
width="32"
>
{/if}
<span class="truncate text-[15px]">
{track.title}
<span class="text-mute">· {track.artists}</span>
</span>
</a>
</li>
{/each}
</ol>
{/if}
{#if session.stage === "done" && session.playlist}
<a
class="mt-5 block truncate border border-rule px-4 py-3 text-[13px] text-mute transition-colors hover:border-mute hover:text-ink"
href={session.playlist.url}
rel="noopener"
target="_blank"
bind:this={field}
>
{session.playlist.url.replace("https://", "")}
</a>
{/if}
{#if session.stage === "stopped"}
<p class="mt-5 text-[14px] text-mute">Spotify keeps what was added.</p>
{/if}
{#if session.stage === "denied"}
<p class="mt-5 text-[14px] text-mute">
Ask to be added, or switch accounts.
</p>
{/if}
<div class="mt-auto flex flex-col gap-2.5 pt-6 lg:mt-8 lg:pt-0">
{#if session.stage === "out" || session.stage === "denied"}
<a
class="action"
data-sveltekit-reload
href={session.stage === "denied"
? "/api/login?switch=true"
: "/api/login"}
>
<span class="caps label">
{session.stage === "denied"
? "Use another account"
: "Continue with Spotify"}
</span>
<span class="end bg-ink text-ground">
{@render spotify("size-[22px]")}
</span>
</a>
{/if}
{#if session.stage === "ready" || session.stage === "stopped"}
<button
class="action"
disabled={liked === 0}
onclick={() => session.start()}
type="button"
>
<span class="caps label">
{session.stage === "stopped" ? "Try again" : "Make playlist"}
</span>
<span
class={[
"end",
session.stage === "stopped" ? "bg-stop" : "bg-ready",
]}
></span>
</button>
{/if}
{#if session.stage === "done" && session.playlist}
{@const url = session.playlist.url}
<button class="action" onclick={() => copy(url)} type="button">
<span class="caps label">{copied ? "Copied" : "Copy link"}</span>
<span class="end bg-ink text-ground">
<svg
aria-hidden="true"
class="size-4"
fill="none"
stroke="currentColor"
stroke-width="1.6"
viewBox="0 0 16 16"
>
{#if copied}
<path d="m3 8.5 3 3 7-7" />
{:else}
<path d="M5.5 5.5h8v8h-8z M10.5 5.5v-3h-8v8h3" />
{/if}
</svg>
</span>
</button>
<a class="action quiet" href={url} rel="noopener" target="_blank">
<span class="caps label">Open in Spotify</span>
<span class="end">{@render spotify("size-[22px]")}</span>
</a>
<button
class="caps mx-auto px-3 py-2.5 text-mute transition-colors hover:text-ink"
onclick={() => session.start()}
type="button"
>
Make another
</button>
{/if}
</div>
</section>
</div>
</main> </main>
<style>
.action {
display: flex;
align-items: stretch;
width: 100%;
height: 52px;
color: var(--color-ink);
border: 1px solid var(--color-ink);
transition:
border-color 0.2s,
opacity 0.2s;
}
.label {
display: flex;
flex: 1;
align-items: center;
padding-inline: 18px;
transition: background-color 0.2s;
}
.end {
display: grid;
place-items: center;
width: 52px;
}
.action:active:not(:disabled) {
transform: translateY(1px);
}
.action:disabled {
cursor: default;
opacity: 0.35;
}
.quiet {
border-color: var(--color-rule);
}
.quiet .end {
border-left: 1px solid var(--color-rule);
}
@media (hover: hover) {
.action:hover:not(:disabled) .label {
background: color-mix(in srgb, var(--color-ink) 9%, transparent);
}
.quiet:hover {
border-color: var(--color-mute);
}
}
</style>
+50 -8
View File
@@ -1,17 +1,59 @@
@import "@fontsource-variable/archivo/wdth.css";
@import "@fontsource-variable/commissioner";
@import "tailwindcss"; @import "tailwindcss";
@theme { @theme {
--font-sans: --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, @layer base {
body { html {
font-family: var(--font-sans); 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), @utility caps {
[role="button"]:not(:disabled), font-family: var(--font-wide);
a { font-size: 11px;
cursor: pointer; 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%;
} }
+8
View File
@@ -0,0 +1,8 @@
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<rect fill="#0b0b0a" height="32" width="32" />
<g fill="#0b0b0a" stroke="#ecebe6" stroke-linejoin="round" stroke-width="1.6">
<path d="M4 12h7l2-5 2 3 2-6 2 8h9v20H4Z" />
<path d="M4 19h8l2-3 2 2 2-7 2 8h8v13H4Z" />
<path d="M4 26h9l2-4 2 3 1-3 2 4h8v6H4Z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 351 B