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

+6
View File
@@ -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=="],
+2
View File
@@ -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",
+2 -1
View File
@@ -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>
+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>
<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>.
</p>
<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>
+50 -8
View File
@@ -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%;
}
+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