feat(backend,frontend): copy liked songs into a shareable playlist
This commit is contained in:
1 parent
09ca301d51
commit
bf2d4f846a
20 files changed
+1192
-17
No files matched your search
@@ -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=
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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)]
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
Generated
+51
-1
@@ -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" },
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport">
|
||||
<meta content="scale" name="text-scale">
|
||||
<meta content="#0b0b0a" name="theme-color">
|
||||
<link href="%sveltekit.assets%/favicon.svg" rel="icon" type="image/svg+xml">
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
@@ -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>
|
||||
@@ -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`;
|
||||
};
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
<title>spotigrab</title>
|
||||
<title>{title}</title>
|
||||
</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
|
||||
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>
|
||||
<p class="max-w-prose text-base text-neutral-500">
|
||||
Edit
|
||||
<code class="font-mono">src/routes/+page.svelte</code>.
|
||||
<header class="flex h-14 shrink-0 items-center justify-between gap-4">
|
||||
{#if session.me}
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
{#if session.me.avatar}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
@@ -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 {
|
||||
@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%;
|
||||
}
|
||||
@@ -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 |
Reference in new issue
Block a user