Unlock everything, add a donation link, credit Last.fm, nudge re-linking

- Every feature is free for everyone (the plan system stays for later);
  remaining limits are abuse guards
- Optional website donation link (NEXT_PUBLIC_DONATION_URL). Not shown in
  the mobile apps, where tips to the developer generally need in-app
  purchase
- Last.fm/ListenBrainz-sourced plays link to the user's profile, as
  Last.fm's API terms require
- Connections track when they last saw activity; a scrobbler silent for
  over a week prompts re-linking Spotify (its link expires every 6 months)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
2026-10-04 08:13:54 -04:00
co-authored by Claude Opus 5.5
parent 91a78a8ad4
commit 63d618f0e3
33 changed files with 1277 additions and 38 deletions
+2
View File
@@ -1 +1,3 @@
NEXT_PUBLIC_API_URL=http://localhost:4000
# Optional donation link shown on the website (e.g. https://ko-fi.com/yourname)
NEXT_PUBLIC_DONATION_URL=
+2
View File
@@ -18,7 +18,9 @@ COPY --from=prepare /app/tsconfig.base.json ./tsconfig.base.json
# NEXT_PUBLIC_* values are inlined into the browser bundle at build time,
# so the API URL is a build argument, not a runtime setting.
ARG NEXT_PUBLIC_API_URL=http://localhost:4000
ARG NEXT_PUBLIC_DONATION_URL=
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
NEXT_PUBLIC_DONATION_URL=$NEXT_PUBLIC_DONATION_URL \
NEXT_TELEMETRY_DISABLED=1
RUN npx turbo run build --filter=@musicshare/web
+3
View File
@@ -54,6 +54,8 @@ input:focus, textarea:focus, select:focus { outline: 2px solid var(--accent); ou
.nav-links a { text-decoration: none; color: var(--muted); }
.nav-links a:hover { color: var(--text); }
.nav-links a.btn-primary { color: var(--accent-text); }
.nav-links a.support-link { color: #ff7aa8; }
@media (max-width: 480px) { .nav-links a.support-link { display: none; } }
.btn {
display: inline-flex; align-items: center; justify-content: center;
@@ -117,6 +119,7 @@ a.recent-row:hover .recent-title { color: var(--accent); }
.recent-text { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.recent-text > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.recent-title { font-weight: 600; }
.credit { margin: 0.6rem 0 0; font-size: 0.8rem; }
.recent-time { font-size: 0.8rem; white-space: nowrap; }
.artwork-sm.artwork-empty { display: grid; place-items: center; background: var(--surface-2); color: var(--muted); }
.picked { display: flex; gap: 0.75rem; align-items: center; }
+14
View File
@@ -5,13 +5,16 @@ import { Suspense, useState } from 'react';
import useSWR from 'swr';
import {
SERVICE_LABELS,
SPOTIFY_LINK_PAGES,
STREAMING_SERVICES,
isQuietScrobbler,
type ConnectionDTO,
type MeDTO,
type NowPlayingProvider,
type TrackSearchResultDTO,
} from '@musicshare/shared';
import { SharingControls } from '@/components/SharingControls';
import { SupportCard } from '@/components/SupportLink';
import { api, fetcher, useMe } from '@/lib/api';
import { timeAgo } from '@musicshare/shared';
import { useDebounced } from '@/lib/useDebounced';
@@ -36,6 +39,7 @@ function Settings() {
<SharingControls />
<Connections />
<FeaturedPicker />
<SupportCard />
<DangerZone />
</div>
);
@@ -189,6 +193,16 @@ function Connections() {
)}
</div>
{connections.filter((c) => isQuietScrobbler(c)).map((c) => (
<div className="notice" key={`quiet-${c.provider}`}>
Nothing new from {SERVICE_LABELS[c.provider]} in over a week. If you listen on Spotify, its link to{' '}
{SERVICE_LABELS[c.provider]} expires every 6 months.{' '}
<a href={SPOTIFY_LINK_PAGES[c.provider as 'lastfm' | 'listenbrainz']} target="_blank" rel="noreferrer">
Re-link Spotify
</a>
.
</div>
))}
{spotifyLimited && (
<div className="notice">
<strong>Spotify sign-in is limited during beta.</strong> Spotify only lets a few accounts sign in to new apps
+1 -1
View File
@@ -98,7 +98,7 @@ export default function ProfilePage() {
<p className="muted">Nothing playing right now.</p>
)}
<RecentPlays plays={profile.recent} preferred={preferred} />
<RecentPlays plays={profile.recent} preferred={preferred} attribution={profile.attribution} />
</div>
);
}
+2
View File
@@ -3,6 +3,7 @@
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { api, useMe } from '@/lib/api';
import { SupportLink } from './SupportLink';
export function Nav() {
const { me, mutate } = useMe();
@@ -18,6 +19,7 @@ export function Nav() {
musicshare
</Link>
<div className="nav-links">
<SupportLink />
{me ? (
<>
<Link href="/">Feed</Link>
+10 -1
View File
@@ -31,7 +31,16 @@ export function NowPlayingCard({
<span className="row">
<span className={`status ${np.isPlaying ? 'status-live' : ''}`}>
{np.isPlaying ? 'Listening now' : `Played ${timeAgo(np.updatedAt)}`}
<span className="muted"> via {np.sourceApp ?? SERVICE_LABELS[np.source]}</span>
<span className="muted">
{' via '}
{np.sourceUrl ? (
<a href={np.sourceUrl} target="_blank" rel="noreferrer">
{np.sourceApp ?? SERVICE_LABELS[np.source]}
</a>
) : (
(np.sourceApp ?? SERVICE_LABELS[np.source])
)}
</span>
</span>
{onTogglePin && (
<button
+19 -2
View File
@@ -1,8 +1,17 @@
'use client';
import { SERVICE_LABELS, openUrl, timeAgo, type RecentPlayDTO, type StreamingService } from '@musicshare/shared';
import { SERVICE_LABELS, openUrl, timeAgo, type ProfileDTO, type RecentPlayDTO, type StreamingService } from '@musicshare/shared';
export function RecentPlays({ plays, preferred }: { plays: RecentPlayDTO[]; preferred: StreamingService | null | undefined }) {
export function RecentPlays({
plays,
preferred,
attribution = {},
}: {
plays: RecentPlayDTO[];
preferred: StreamingService | null | undefined;
attribution?: ProfileDTO['attribution'];
}) {
const credits = (['lastfm', 'listenbrainz'] as const).filter((s) => attribution[s] && plays.some((p) => p.source === s));
if (plays.length === 0) return null;
return (
<section className="card">
@@ -39,6 +48,14 @@ export function RecentPlays({ plays, preferred }: { plays: RecentPlayDTO[]; pref
);
})}
</ul>
{credits.map((s) => (
<p key={s} className="muted credit">
Some plays via{' '}
<a href={attribution[s]} target="_blank" rel="noreferrer">
{s === 'lastfm' ? 'Last.fm' : 'ListenBrainz'}
</a>
</p>
))}
</section>
);
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Donation link (Ko-fi, Buy Me a Coffee, GitHub Sponsors…). Web only: in the
* iOS and Android apps, tips to the developer must go through in-app
* purchase in most countries, so the apps don't show this.
*/
export const DONATION_URL = process.env.NEXT_PUBLIC_DONATION_URL || null;
export function SupportLink({ className = 'support-link' }: { className?: string }) {
if (!DONATION_URL) return null;
return (
<a className={className} href={DONATION_URL} target="_blank" rel="noreferrer">
♥ Support musicshare
</a>
);
}
export function SupportCard() {
if (!DONATION_URL) return null;
return (
<section className="card">
<h2>Support musicshare</h2>
<p className="muted">
musicshare is free, with every feature unlocked for everyone. If you enjoy it, a donation helps cover servers and
keeps it that way.
</p>
<SupportLink className="btn btn-primary" />
</section>
);
}