Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
858a75a
Fix per-user Last.fm integration
Darep Aug 9, 2026
49282de
refactor(auth): defer user synchronization
Darep Aug 29, 2026
87077c4
refactor(frontend): simplify Last.fm scrobbler
Darep Aug 29, 2026
5521df8
fix(lastfm): accept fractional track durations
Darep Aug 29, 2026
f8c0bc3
fix(lastfm): open authorization popup synchronously
Darep Aug 29, 2026
4b95c2b
fix(lastfm): persist connection changes atomically
Darep Aug 29, 2026
ab45b43
fix(frontend): distinguish repeated track plays
Darep Aug 29, 2026
3ba31c0
fix(frontend): scrobble by elapsed play time
Darep Aug 29, 2026
71fe2f0
fix(frontend): skip plays already in progress
Darep Aug 29, 2026
3e66284
fix(lastfm): report disconnect failures
Darep Aug 29, 2026
f0e8d71
fix(lastfm): explain non-JSON HTTP failures
Darep Aug 29, 2026
3072eb1
fix(lastfm): preserve pending authorization
Darep Aug 29, 2026
0ce4e11
fix(lastfm): handle failed submissions
Darep Aug 29, 2026
6037cc9
fix(lastfm): report ignored submissions
Darep Aug 29, 2026
d54a70e
fix(frontend): count restored playback as a new instance
Darep Aug 29, 2026
6e75f1d
fix(frontend): count actual playback progress
Darep Aug 29, 2026
6da29e1
fix(lastfm): require tracks longer than thirty seconds
Darep Aug 29, 2026
95bf1b4
fix(lastfm): align mutation responses
Darep Aug 30, 2026
23a4a8a
fix(frontend): improve Last.fm playback tracking
Darep Aug 30, 2026
5874ef6
fix: add some newlines
Darep Aug 30, 2026
cd4eb11
refactor(lastfm): clarify playback counter
Darep Aug 30, 2026
4ec597d
Apply suggestion from @Darep
Darep Aug 30, 2026
ba61c48
refactor(lastfm): simplify scrobble state
Darep Aug 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ MUSIC_PATH=~/Music
# Directory for users.json and songs.json (defaults to the working directory):
# DATA_PATH=./data

# Last.fm API application credentials:
# LASTFM_API_KEY=
# LASTFM_API_SECRET=

# For development:
ENV=dev

Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ vet:

test:
go test ./...
cd frontend && npm test

check: vet test
test -z "$$(gofmt -l $$(git ls-files '*.go'))"
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ For reproducible deployments, replace `latest` with an exact release version fro

Open http://0.0.0.0:8080 on your browser. Log in and wait when indexing ends, refresh page and happy listening!

To enable per-user Last.fm connections, create a Last.fm API application and pass its credentials to Beatstream:

```bash
docker run -d -p 8080:8080 -v /path/to/your/music:/music \
-e LASTFM_API_KEY=your-key -e LASTFM_API_SECRET=your-secret \
darep/beatstream:latest
```

Each Beatstream user can then connect and disconnect their own Last.fm account from Settings.

### Manual Install

Requirements: Go 1.26 or newer. Node.js 20 or newer. TagLib (C bindings) e.g. libtagc
Expand Down
10 changes: 6 additions & 4 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,12 @@ func registerRoutes() *chi.Mux {
r.Get("/songs/refresh", refreshStatusHandler)
r.Post("/songs/refresh", refreshHandler)

// r.Put("/lastfm", lastfmHandler)
// r.Post("/lastfm/scrobble", scrobbleHandler)
// r.Post("/lastfm/now-playing", nowPlayingHandler)
r.Get("/lastfm", lastFMStatusHandler)
r.Post("/lastfm/connect", lastFMConnectHandler)
r.Post("/lastfm/complete", lastFMCompleteHandler)
r.Delete("/lastfm", lastFMDisconnectHandler)
r.Post("/lastfm/scrobble", lastFMScrobbleHandler)
r.Post("/lastfm/now-playing", lastFMNowPlayingHandler)

// r.Get("/playlists", playlistsHandler)
// r.Post("/playlists", createPlaylistHandler)
Expand All @@ -94,7 +97,6 @@ func passwordHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid password", http.StatusBadRequest)
return
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Undo this removal of a newline.

username := r.Context().Value("username").(string)
updated := slices.Clone(users)
for i := range updated {
Expand Down
14 changes: 14 additions & 0 deletions auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import (
type User struct {
Username string `json:"username"`
Password string `json:"password"`

LastFMUsername string `json:"lastfm_username,omitempty"`
LastFMSession string `json:"lastfm_session,omitempty"`
LastFMToken string `json:"lastfm_token,omitempty"`
}

// holds all users in memory
Expand Down Expand Up @@ -62,6 +66,16 @@ func loadUsers() error {
return nil
}

func currentUser(r *http.Request) *User {
username, _ := r.Context().Value("username").(string)
for i := range users {
if users[i].Username == username {
return &users[i]
}
}
return nil
}

type Session struct {
Token string
Username string
Expand Down
3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"lint": "biome check",
"lint:fix": "biome check --write",
"preview": "vite preview",
"test:e2e": "playwright test",
"test": "playwright test unit",
"test:e2e": "playwright test e2e",
"test:e2e:install": "playwright install --with-deps chromium",
"typecheck": "tsc --noEmit"
},
Expand Down
2 changes: 1 addition & 1 deletion frontend/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
testDir: './e2e',
testDir: '.',
fullyParallel: false,
forbidOnly: Boolean(process.env.CI),
retries: 0,
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AppLoader } from 'components/AppLoader';
import { AppMain } from 'components/AppMain';
import { AppNav } from 'components/AppNav/AppNav';
import { AppTop } from 'components/AppTop';
import { LastFMScrobbler } from 'components/LastFMScrobbler';
import { LoginModal } from 'components/LoginModal';
import { MediaSession } from 'components/MediaSession';
import { useSession } from 'hooks/swr';
Expand Down Expand Up @@ -36,6 +37,7 @@ export const App = () => {
<AppLoader />

<MediaSession />
{isAuthenticated ? <LastFMScrobbler /> : null}
</div>
);
};
83 changes: 83 additions & 0 deletions frontend/src/components/LastFMScrobbler.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { useLastFM } from 'hooks/swr';
import { useEffect, useRef } from 'react';
import { usePlayerStore } from 'store';
import { mutate } from 'swr';
import { ApiError, request } from 'utils/api';
import { createLastFMPlaybackTracker } from 'utils/LastFMPlayback';

export const LastFMScrobbler = () => {
const { data: lastFM } = useLastFM();
const scrobbledPlaybackCount = useRef<number | undefined>(undefined);

useEffect(() => {
if (!lastFM?.connected) return;

const trackPlayback = createLastFMPlaybackTracker();
let startedAt = 0;

const sync = (player: ReturnType<typeof usePlayerStore.getState>) => {
const { song, state, position, parsedDuration, playbackCount } = player;

if (!song?.artist || !song.title) return;

const duration = parsedDuration || song.length || 0;

const playback = trackPlayback({
duration,
playbackCount,
now: performance.now(),
position,
state,
});

if (playback.started) {
Comment thread
Darep marked this conversation as resolved.
startedAt = Math.floor(Date.now() / 1000);
void request('/api/lastfm/now-playing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist: song.artist, track: song.title, album: song.album, duration }),
}).catch((error: unknown) => {
console.error(`Could not update Last.fm now playing for ${song.artist} — ${song.title}`, error);

if (error instanceof ApiError && error.status === 409) {
// Refresh connection state when the Last.fm session expires.
void mutate('/api/lastfm');
}
});
}

if (!playback.shouldScrobble || scrobbledPlaybackCount.current === playbackCount) {
return;
}

scrobbledPlaybackCount.current = playbackCount;

void request('/api/lastfm/scrobble', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
artist: song.artist,
track: song.title,
album: song.album,
duration,
timestamp: startedAt,
}),
}).catch((error: unknown) => {
console.error(`Could not scrobble ${song.artist} — ${song.title}`, error);

if (error instanceof ApiError && error.status === 409) {
// Refresh connection state when the Last.fm session expires.
void mutate('/api/lastfm');
}
});
};

// Set initial state
sync(usePlayerStore.getState());

// Continuously sync playback state when state in store updates
return usePlayerStore.subscribe(sync);
Comment thread
Darep marked this conversation as resolved.
}, [lastFM?.connected]);

return null;
};
111 changes: 94 additions & 17 deletions frontend/src/components/SettingsModal.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,63 @@
import { useLastFM, useSession } from 'hooks/swr';
import { useState } from 'react';

import { useSession } from 'hooks/swr';

import { PasswordChangeModal } from './PasswordChangeModal';
import { mutate } from 'swr';
import { request } from 'utils/api';
import { Button } from './common/Button';
import { Modal } from './common/Modal';
import { PasswordChangeModal } from './PasswordChangeModal';

export const SettingsModal = ({ onClose }: { onClose: () => void }) => {
const { user } = useSession();
const [changingPassword, setChangingPassword] = useState(false);
const { data: lastFM } = useLastFM();
const [busy, setBusy] = useState(false);
const [lastFMError, setLastFMError] = useState('');

const connect = async () => {
const popup = window.open('', 'lastfm-auth', 'popup,width=900,height=700');
if (!popup) {
setLastFMError('Allow popups to connect to Last.fm');
return;
}
setBusy(true);
setLastFMError('');
try {
const { url } = await request<{ url: string }>('/api/lastfm/connect', { method: 'POST' });
popup.location.href = url;
await mutate('/api/lastfm');
} catch (err) {
popup.close();
setLastFMError(err instanceof Error ? err.message : 'Could not connect to Last.fm');
} finally {
setBusy(false);
}
};

const complete = async () => {
setBusy(true);
setLastFMError('');
try {
await request('/api/lastfm/complete', { method: 'POST' });
await mutate('/api/lastfm');
} catch (err) {
setLastFMError(err instanceof Error ? err.message : 'Could not finish the Last.fm connection');
} finally {
setBusy(false);
}
};

const disconnect = async () => {
setBusy(true);
setLastFMError('');
try {
await request('/api/lastfm', { method: 'DELETE' });
await mutate('/api/lastfm');
} catch (err) {
setLastFMError(err instanceof Error ? err.message : 'Could not disconnect from Last.fm');
} finally {
setBusy(false);
}
};

if (changingPassword) {
return <PasswordChangeModal onClose={onClose} />;
Expand Down Expand Up @@ -41,19 +90,47 @@ export const SettingsModal = ({ onClose }: { onClose: () => void }) => {
<section>
<label>Last.fm</label>
<div className="form-field">
<button className="btn btn-lastfm not-ok" id="lastfm-connect" tabIndex={4}>
Connect to Last.fm
</button>
<p className="connecting" style={{ display: 'none' }}>
<i className="icon-loading" />
Connecting&hellip;
</p>
<p className="ok" style={{ display: 'none' }}>
Connected
<button className="btn" id="lastfm-disconnect" tabIndex={4}>
Remove connection
</button>
</p>
{!lastFM?.configured ? <p>Set LASTFM_API_KEY and LASTFM_API_SECRET to enable Last.fm.</p> : null}
{lastFM?.configured && !lastFM.connected && !lastFM.pending ? (
<Button
variant="secondary"
className="btn btn-lastfm not-ok"
id="lastfm-connect"
tabIndex={4}
disabled={busy}
onClick={connect}
>
Connect to Last.fm
</Button>
) : null}
{lastFM?.pending ? (
<p>
Authorize Beatstream in the opened window, then{' '}
<Button variant="secondary" className="btn" tabIndex={4} disabled={busy} onClick={complete}>
Finish connection
</Button>{' '}
or{' '}
<Button variant="secondary" className="btn" tabIndex={4} disabled={busy} onClick={connect}>
Restart authorization
</Button>
</p>
) : null}
{lastFM?.connected ? (
<p className="ok">
Connected as {lastFM.username}{' '}
<Button
variant="secondary"
className="btn"
id="lastfm-disconnect"
tabIndex={4}
disabled={busy}
onClick={disconnect}
>
Remove connection
</Button>
</p>
) : null}
{lastFMError ? <p>{lastFMError}</p> : null}
</div>
</section>
<div className="right">
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/hooks/swr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ export const useRefreshStatus = () =>
useApi<{ refreshing: boolean }>('/api/songs/refresh', undefined, {
refreshInterval: (status) => (status?.refreshing ? 1000 : 0),
});

export const useLastFM = () =>
useApi<{ configured: boolean; connected: boolean; pending: boolean; username: string }>('/api/lastfm');
7 changes: 7 additions & 0 deletions frontend/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ interface PlayerState {
/** Current song position in seconds */
position: number;

/** Increments whenever a new track playback starts, including repeats. Used by LastFM to track unique plays. */
playbackCount: number;

/** Repeat & shuffle states */
repeat: boolean;
shuffle: boolean;
Expand Down Expand Up @@ -103,6 +106,7 @@ export const usePlayerStore = create<PlayerState>()(
(set) => ({
appNavWidth: DEFAULT_APP_NAV_WIDTH,
parsedDuration: 0,
playbackCount: 0,
playlist: [] as Song[],
position: 0,
repeat: false,
Expand Down Expand Up @@ -171,6 +175,7 @@ export const usePlayerStore = create<PlayerState>()(

return {
parsedDuration: 0,
playbackCount: state.playbackCount + 1,
position: 0,
song,
state: 'playing',
Expand Down Expand Up @@ -201,6 +206,7 @@ export const usePlayerStore = create<PlayerState>()(
}

return {
playbackCount: state.state === 'paused' ? state.playbackCount : state.playbackCount + 1,
song,
state: 'playing',
};
Expand Down Expand Up @@ -330,6 +336,7 @@ function changeSong(direction: -1 | 1, { force } = { force: false }): (state: Pl

return {
parsedDuration: 0,
playbackCount: state.playbackCount + 1,
position: 0,
song: nextSong,
...newHistory,
Expand Down
Loading
Loading