尚未收集儲存庫資訊。
尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
製作者的介紹僅有上面的一句話。 更多詳情請閱讀儲存庫 README。 儲存庫 README ↗
正在載入完整 README。
檢視完整 README ↗A personalized music discovery + update service. Not a streaming app, not a
recommendation feed: you tell it which artists and categories you care
about, and it builds a transparent, deterministic digest — releases,
concerts, tours, videos, interviews, collaborations and discovery — each
with a visible "why am I seeing this?" explanation. The distribution
channel is email (rendered at /newsletter-preview); WhatsApp is
architecturally reserved for later (see ConnectedAccount/NewsletterSubscription
schema — a channel field already models it) but not implemented in V1.
Next.js (App Router) · TypeScript · Tailwind CSS v4 · Prisma + PostgreSQL · Zod · React Hook Form (available, forms currently use plain controlled inputs since the flows are simple) · lightweight cookie-based dev session.
Needs a Postgres database — any of these work: a free one from Neon or Supabase, a Postgres database created from your Vercel project's Storage tab, or a local Postgres server.
npm install
cp .env.example .env # then fill in DATABASE_POSTGRES_URL
npx prisma migrate dev # applies the schema
npm run seed # 60+ artists, 30+ events, sources, relations, demo user
npm run dev
Open http://localhost:3000. /radar, /artists, /preferences,
/settings and /newsletter-preview all work immediately after seeding,
even before you complete onboarding — they fall back to a seeded demo
account ([email protected]) when no session cookie is set, so the app
is reviewable straight away. Complete /onboarding to create your own
account (it gets its own cookie and takes over from the demo user).
Other useful commands:
npm run build # production build (also applies pending migrations)
npm run start # run the production build
npm run db:reset # drop + recreate the database, re-run migrations + seed
npx tsc --noEmit # typecheck
npx eslint . # lint
DATABASE_... group of
variables automatically (the exact names depend on the provider).
This app reads DATABASE_POSTGRES_URL specifically:
DATABASE_URL (a prisma+postgres:// Accelerate URL this app
doesn't use — ignore it) — no extra steps needed.DATABASE_POSTGRES_URL yourself under
Settings → Environment Variables, with a normal
postgresql://... connection string as its value.APP_SESSION_SECRET (any random string) and NEXT_PUBLIC_APP_URL
(your production URL, e.g. https://your-app.vercel.app) as environment
variables. SPOTIFY_CLIENT_ID/SPOTIFY_CLIENT_SECRET/RESEND_API_KEY
are optional — the app runs in mock mode without them.prisma generate && prisma migrate deploy && next build) applies the schema to your new database automatically.DATABASE_POSTGRES_URL="<the same URL you set in Vercel>" npm run seed
(Re-running the seed script wipes and recreates all data — including any
real accounts — so only run it once, right after the first deploy.)/) — editorial three-step pitch + category preview./onboarding) — a 9-step wizard (method → artists →
review → preferences → concerts → frequency → email → confirm → live
preview → subscribe), persisted to localStorage so it survives the
full-page redirect to Spotify and back.src/lib/spotify/real.ts),
behind a swappable adapter (SpotifyAdapter) with a realistic mock
(src/lib/spotify/mock.ts) used automatically when
SPOTIFY_CLIENT_ID/SPOTIFY_CLIENT_SECRET aren't set. Only
user-follow-read, user-top-read, user-library-read — no email
scope, no listening history stored, no Spotify recommendations used./api/artists/search), with on-the-fly creation of
artists that aren't in it./login) — passwordless: enter the email your subscription
is under, get a single-use magic link (15 min expiry) via the same
EmailProvider the newsletter uses. No password anywhere in the schema.
Session is still just the lightweight cookie from src/lib/session.ts —
this only adds a way back in in on a browser that never onboarded./artists.src/lib/radar/) — no ML, no
collaborative filtering, no opaque scoring. Every point in the score is a
named constant (SCORE_* in scoring.ts) and every item carries a
plain-language reason. Source credibility gating (credibilityScore >= 60), concert radius via a small city-distance table, discovery via
explicit ArtistRelation edges or genre overlap./radar), artist management (/artists),
preferences (/preferences), settings (/settings) — all backed
by Server Actions, editable immediately, no page reloads.Consent model) — append-only; granting and
withdrawing both write new timestamped rows rather than mutating one
field. Newsletter checkbox starts unchecked.SPOTIFY_CLIENT_ID/SPOTIFY_CLIENT_SECRET are unset
(the default) — src/lib/spotify/mock.ts returns a realistic
followed/top/saved artist library so the full flow works with zero
configuration.prisma/seed.ts):
61 artists across indie/pop/electronic/hip-hop/alternative/rock, 32
events across all 10 supported types, 17 sources with credibility
weights, 14 explicit artist relations for discovery, 6 cities.RESEND_API_KEY
is set (src/lib/email/provider.ts).src/lib/radar/geo.ts), not a geocoding API.http://localhost:3000/api/spotify/callback as a Redirect URI.SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, SPOTIFY_REDIRECT_URI
in .env.http://localhost:3000/api/spotify/playlist/callback as a second
Redirect URI on the same app, and set SPOTIFY_PLAYLIST_REDIRECT_URI.The seeded catalog covers a fixed set of ~60 artists. To pull real
content — for whichever artists people actually follow, not just the seed
list — src/lib/sources/ has four independent adapters, each normalizing
into the same MusicEvent shape the rest of the app already reads from:
spotifyReleases.ts — new singles/albums, via Spotify's Client
Credentials flow (public catalog data, no extra account setup — reuses
SPOTIFY_CLIENT_ID/SPOTIFY_CLIENT_SECRET). Only artists with a
spotifyId (set automatically once they're imported via onboarding's
Spotify connect) are checked; releases older than 90 days are skipped so
a first sync doesn't dump an artist's whole back catalog. Also records
the release's cover art (MusicEvent.imageUrl, a Spotify CDN URL) —
shown as a thumbnail on both /radar and in the newsletter.ticketmaster.ts — upcoming shows, via Ticketmaster's Discovery API —
free, self-serve, no partner approval needed (unlike Bandsintown/
Songkick's event APIs, which now require one). Needs
TICKETMASTER_API_KEY. Only keeps results where the artist is an actual
listed attraction, since Ticketmaster's keyword search can otherwise
surface loosely-related events. Presale windows are collapsed to one
"tour presale" row per artist (whichever opens soonest) rather than one
per venue, since a tour's presale is almost always the same window
across every date.youtube.ts — new uploads, via the YouTube Data API. Needs
YOUTUBE_API_KEY. Resolves each artist's channel once (the expensive
search call) and caches it on Artist.youtubeChannelId, then reads new
uploads via the channel's uploads playlist (1 quota unit instead of 100)
on every later sync.blogNews.ts — coverage from ~20 music blogs/magazines' public RSS/Atom
feeds, no API key needed — genre-diverse (indie, hip-hop, electronic,
metal, pop) and UK/US/DE/FR/Nordic (Pitchfork, NME, Clash, Rolling
Stone, Billboard, Mixmag, DJ Mag, Loudwire, Metal Hammer, Consequence,
BrooklynVegan, Rap-Up, Okayplayer, DIY Magazine, The Line of Best Fit,
Stereogum, Les Inrockuptibles, GAFFA; German: Musikexpress, Rolling
Stone DE). This is the only source that can catch things no structured
API tracks, like merch or album-cycle news. Each feed is fetched once
per sync (not once per artist), with bounded concurrency
(FEED_CONCURRENCY), and matched against followed artists' names in the
title; items older than 14 days are skipped. Recorded as a "fact"
MusicEvent (subtype "interesting_fact") rather than its own type — it
rides along under the existing "Interesting facts" category/section
instead of a separate "Blog coverage" one, since blog matching is
looser than the other three (title substring match, no structured
"this is about artist X" field to key off) and didn't earn its own
bucket. A headline that looks tour/concert-flavored (looksLikeTourNews:
keyword match, English + German) is recorded as "tour" instead — with
no self-serve concert API available (Ticketmaster only, Bandsintown is
partner-only), this is currently the only real source Tour
announcements has at all; Ticketmaster itself only ever produces
per-venue "concert"/"presale" rows, never a tour-level announcement.Each adapter is independent and simply no-ops (or, for blogNews, just logs a per-feed error and keeps going) if its own env var isn't set — you don't need all four. All are dedup-safe (skip an event that already exists for that artist/type/title) and never touch any User-related table.
Setup:
TICKETMASTER_API_KEY (instant, free — see the comment in
.env.example for the signup link) and/or YOUTUBE_API_KEY (free
Google Cloud API key) as environment variables — locally in .env,
and in Vercel under Settings → Environment Variables for production.CRON_SECRET (any random string) the same way. It's required to
call any /api/cron/sync-* route at all — Vercel automatically attaches
it as a Bearer token to its own scheduled requests once it's set.vercel.json already schedules a daily sync for each source, staggered
a few minutes apart (sync-spotify 06:00 UTC, sync-ticketmaster
06:10, sync-youtube 06:20, sync-blognews 06:30) — Vercel picks this
up automatically on deploy, no dashboard configuration needed. They're
separate routes/invocations, not one combined route, because combining
all four sources across 100+ followed artists in a single Vercel
function call reliably exceeds its execution time limit; each source
alone comfortably fits.curl -X POST -H "Authorization: Bearer <CRON_SECRET>" \
https://<your-domain>/api/cron/sync-spotify
(sync-ticketmaster, sync-youtube, sync-blognews work the same
way.) Each returns a JSON summary (items created, artists processed,
any per-artist errors) so you can see it actually pulled something.
/api/cron/sync-content also still exists and runs all four at once —
fine for a small test account, but expect it to time out at real
scale; prefer the per-source routes above.No other code changes needed — the adapter switches automatically.
Separate, explicit opt-in from Settings — not part of onboarding's
read-only Spotify connect. src/lib/sources/playlistSync.ts maintains one
private Spotify playlist per user containing new releases from the
artists on their radar.
/api/spotify/playlist/authorize → /api/spotify/playlist/callback,
own PKCE cookie, own redirect URI — see "To use real Spotify" above),
requesting the base read scopes again plus one new write scope,
playlist-modify-private. This is additive, not a replacement: a user
who already connected Spotify for taste import during onboarding still
needs to click this once to grant the extra scope: a previously-issued
token can't retroactively gain it. The callback creates the playlist
and runs a first sync immediately, rather than waiting for tomorrow's
cron, so it's not empty when the user lands back on Settings./api/cron/sync-playlist (daily, 06:40 UTC in
vercel.json, after the content syncs) finds every "release" MusicEvent
matching the user's followed artists and enabled categories
(new_singles/albums_eps, via the same buildCategoryMatcher the
radar uses) that hasn't been added yet (PlaylistTrackItem, mirrors how
SentDigestItem dedups the newsletter), resolves one track per release
(the album's first track — Spotify's public catalog API, no extra user
scope needed for that read), and adds them. The very first sync and
every sync after it are the same code path: whatever's new gets added,
whether that's "everything currently on the radar" (first run) or "the
one single that came out today" (every run after).getFreshAccessToken in
playlistSync.ts always refreshes from the stored refresh token before
use rather than tracking exact expiry, since the daily cron cadence
means a stored token is essentially always stale by sync time anyway.MusicEvent.externalId (the Spotify album/single id) set on a
release to find a track for it — populated automatically by
spotifyReleases.ts going forward; older rows synced before this
feature existed are covered by the same backfill-release-artwork
admin route the missing-cover-art fix uses (it backfills both fields).The "new_artists" content category ("Discovery Mode" in the UI) used to
rely on a handful of hand-curated ArtistRelation rows in the seed data —
three artists, total, forever. src/lib/sources/discoverySync.ts replaces
that with an ongoing sync: for every followed artist with a spotifyId, it
calls suggestSimilarArtists (same related-artists-then-genre-search logic
onboarding's "because you like X" row uses — see "To get around Spotify's
related-artists restriction" above) and records up to 3 results each as a
discovery MusicEvent, plus the ArtistRelation edge (reasonType: "spotify_related") generateRadar.ts already reads to build the "You like
X — …" reason text. No changes needed to the scoring/radar code itself —
it already supported Spotify-sourced discovery edges, there just weren't
any being generated.
/api/cron/sync-discovery (daily, 06:50 UTC in
vercel.json, after the other content syncs), same sequential-with-delay
pacing as spotifyReleases.ts since it hits the same rate-limited
Client Credentials endpoints.related-artists 403s
for this app, falls back to genre search; if an artist has neither a
spotifyId nor genres, it's simply skipped that run.By default the app runs in dev mode: email "sends" just log to the server
console (src/lib/email/provider.ts), and nothing goes out on a schedule —
/newsletter-preview's "Send test email" button is the only way anything
gets sent, and only to the currently signed-in user.
1. Get real delivery working (Resend):
re_... value.RESEND_API_KEY (the key) and EMAIL_FROM as environment
variables — locally in .env, in Vercel under Settings → Environment
Variables for production.EMAIL_FROM must be
Barking Beaver <[email protected]> (Resend's fixed sandbox
sender) — and Resend will only actually deliver to the email address
your Resend account itself is registered with, not arbitrary
recipients. To send to real subscribers you'll eventually need to
verify a domain you control (Resend → Domains → Add Domain, then add
the DNS records it gives you), after which EMAIL_FROM can be any
address @your-domain.emailProvider in src/lib/email/provider.ts picks Resend up
automatically once RESEND_API_KEY is set — no other code changes
needed. Test via /newsletter-preview → "Send test email".2. Turn on scheduled sending:
vercel.json already schedules /api/cron/send-newsletters daily at
07:00 UTC (after the content-sync crons, so the digest reflects freshly
synced content) — picked up automatically on deploy, needs the same
CRON_SECRET as the sync routes. It sends to every active subscriber
whose newsletterFrequency preference makes them due that day (weekly
→ Mondays, twice_weekly → Mondays and Thursdays, daily → every day),
tracked via NewsletterSubscription.lastSentAt so re-running it the same
day is a no-op. Each send excludes anything already delivered to that
user in an earlier digest (SentDigestItem) — an item otherwise stays in
scoring range and would just get resent every cycle until it aged out or
got crowded out by newer items; if that leaves nothing new, the send is
skipped rather than mailing an empty digest. Only the real scheduled send
does this — /newsletter-preview and its "Send test email" button always
render the current, unfiltered radar, and never count toward a user's
dedup history. To trigger it manually:
curl -X POST -H "Authorization: Bearer <CRON_SECRET>" \
https://<your-domain>/api/cron/send-newsletters
Returns a JSON summary (candidates considered, sent, skipped as not due, any per-subscriber errors).
prisma/schema.prisma # User, Artist, UserArtistPreference, UserPreference,
# MusicEvent, MusicSource, ArtistRelation, Consent,
# ConnectedAccount, NewsletterSubscription
prisma/seed.ts # seed data
src/lib/radar/ # scoring rules + generatePersonalizedRadar/generateDraftRadar
src/lib/spotify/ # OAuth+PKCE, mock adapter, artist import/reconciliation
src/lib/email/ # HTML email renderer + EmailProvider adapter
src/lib/consent.ts # consent ledger writes
src/app/onboarding/ # wizard route + submit Server Action
src/app/{radar,artists,preferences,settings,newsletter-preview}/
src/components/ # ui/, onboarding/, radar/, artists/, preferences/, settings/, layout/
generatePersonalizedRadar(userId) → structured JSON →
renderNewsletterHtml() → /newsletter-preview (also sendable via a
swappable EmailProvider; defaults to a console-logging dev adapter,
switches to Resend if RESEND_API_KEY is set).RESEND_API_KEY) — a daily
Vercel Cron (/api/cron/send-newsletters, see below) sends the digest to
every active subscriber whose chosen frequency (weekly/twice-weekly/
daily) makes them due that day./privacy and /unsubscribe placeholders./library) — every past scheduled-send radar,
browsable; /radar itself mirrors the latest one rather than
recomputing live, so the site never drifts from what actually landed in
the inbox (see RadarEdition / src/lib/radar/editions.ts).src/lib/i18n/) covering every page's UI copy; the underlying content
(release titles, "why this is here" reasons, the emails) stays English,
sourced in English regardless of locale.