diff --git a/backend/database/sql/queries/playlists.sql b/backend/database/sql/queries/playlists.sql index 3a55d32..6c094d8 100644 --- a/backend/database/sql/queries/playlists.sql +++ b/backend/database/sql/queries/playlists.sql @@ -47,12 +47,41 @@ FROM playlist_tracks pt JOIN audio_files af ON pt.audio_file_id = af.id LEFT JOIN recordings r ON af.recording_id = r.id LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id +LEFT JOIN ( + SELECT recording_id, MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id +) rgr ON r.id = rgr.recording_id LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id WHERE pt.playlist_id = ? ORDER BY pt.position; +-- name: GetAllPlaylistTracksWithMetadata :many +SELECT + pt.id, + pt.playlist_id, + pt.audio_file_id, + pt.position, + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist, + COALESCE(rg.name, '') AS album, + COALESCE(ca.file_path, '') AS cover_art_path +FROM playlist_tracks pt +JOIN audio_files af ON pt.audio_file_id = af.id +LEFT JOIN recordings r ON af.recording_id = r.id +LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN ( + SELECT recording_id, MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id +) rgr ON r.id = rgr.recording_id +LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id +LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id +ORDER BY pt.playlist_id, pt.position; + -- name: GetNextPlaylistTrackPosition :one SELECT COALESCE(MAX(position), -1) + 1 AS next_position FROM playlist_tracks WHERE playlist_id = ?; diff --git a/backend/database/sql/schemas/indexes.sql b/backend/database/sql/schemas/indexes.sql new file mode 100644 index 0000000..8450d89 --- /dev/null +++ b/backend/database/sql/schemas/indexes.sql @@ -0,0 +1,32 @@ +CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist_id + ON playlist_tracks(playlist_id); + +CREATE INDEX IF NOT EXISTS idx_playlist_tracks_audio_file_id + ON playlist_tracks(audio_file_id); + +CREATE INDEX IF NOT EXISTS idx_audio_files_recording_id + ON audio_files(recording_id); + +CREATE INDEX IF NOT EXISTS idx_recordings_artist_credit_id + ON recordings(artist_credit_id); + +CREATE INDEX IF NOT EXISTS idx_release_group_recordings_recording_id + ON release_group_recordings(recording_id); + +CREATE INDEX IF NOT EXISTS idx_release_group_recordings_release_group_id + ON release_group_recordings(release_group_id); + +CREATE INDEX IF NOT EXISTS idx_release_groups_cover_art_id + ON release_groups(cover_art_id); + +CREATE INDEX IF NOT EXISTS idx_release_groups_album_artist_credit_id + ON release_groups(album_artist_credit_id); + +CREATE INDEX IF NOT EXISTS idx_queue_tracks_audio_file_id + ON queue_tracks(audio_file_id); + +CREATE INDEX IF NOT EXISTS idx_artist_credit_artist_artist_id + ON artist_credit_artist(artist_id); + +CREATE INDEX IF NOT EXISTS idx_artist_credit_artist_credit_id + ON artist_credit_artist(credit_id); diff --git a/backend/database/sql/sqlcgen/playlists.sql.go b/backend/database/sql/sqlcgen/playlists.sql.go index 6588004..d1cc3db 100644 --- a/backend/database/sql/sqlcgen/playlists.sql.go +++ b/backend/database/sql/sqlcgen/playlists.sql.go @@ -67,6 +67,79 @@ func (q *Queries) DeletePlaylist(ctx context.Context, id int64) error { return err } +const getAllPlaylistTracksWithMetadata = `-- name: GetAllPlaylistTracksWithMetadata :many +SELECT + pt.id, + pt.playlist_id, + pt.audio_file_id, + pt.position, + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist, + COALESCE(rg.name, '') AS album, + COALESCE(ca.file_path, '') AS cover_art_path +FROM playlist_tracks pt +JOIN audio_files af ON pt.audio_file_id = af.id +LEFT JOIN recordings r ON af.recording_id = r.id +LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN ( + SELECT recording_id, MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id +) rgr ON r.id = rgr.recording_id +LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id +LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id +ORDER BY pt.playlist_id, pt.position +` + +type GetAllPlaylistTracksWithMetadataRow struct { + ID int64 + PlaylistID int64 + AudioFileID int64 + Position int64 + FilePath string + LengthMilliseconds int64 + Title string + Artist string + Album string + CoverArtPath string +} + +func (q *Queries) GetAllPlaylistTracksWithMetadata(ctx context.Context) ([]GetAllPlaylistTracksWithMetadataRow, error) { + rows, err := q.db.QueryContext(ctx, getAllPlaylistTracksWithMetadata) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAllPlaylistTracksWithMetadataRow + for rows.Next() { + var i GetAllPlaylistTracksWithMetadataRow + if err := rows.Scan( + &i.ID, + &i.PlaylistID, + &i.AudioFileID, + &i.Position, + &i.FilePath, + &i.LengthMilliseconds, + &i.Title, + &i.Artist, + &i.Album, + &i.CoverArtPath, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getAllPlaylists = `-- name: GetAllPlaylists :many SELECT id, name, created_at, updated_at FROM playlists ORDER BY updated_at DESC ` @@ -188,7 +261,11 @@ FROM playlist_tracks pt JOIN audio_files af ON pt.audio_file_id = af.id LEFT JOIN recordings r ON af.recording_id = r.id LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id +LEFT JOIN ( + SELECT recording_id, MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id +) rgr ON r.id = rgr.recording_id LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id WHERE pt.playlist_id = ? diff --git a/backend/playlist/playlist.go b/backend/playlist/playlist.go index 4bbc06a..f66ce06 100644 --- a/backend/playlist/playlist.go +++ b/backend/playlist/playlist.go @@ -40,6 +40,12 @@ type Track struct { Duration string `json:"Duration"` } +// WithTracks contains a playlist summary and all its tracks. +type WithTracks struct { + Summary Summary `json:"Summary"` + Tracks []Track `json:"Tracks"` +} + // Service manages playlist operations. type Service struct { ctx context.Context @@ -83,6 +89,71 @@ func (s *Service) GetAllPlaylists() ([]Summary, error) { return summaries, nil } +// GetAllPlaylistsWithTracks returns all playlists with their tracks in a single call. +func (s *Service) GetAllPlaylistsWithTracks() ( + []WithTracks, + error, +) { + playlists, err := s.db.Queries.GetAllPlaylists(s.db.Ctx) + if err != nil { + s.logger.Error("Failed to get playlists", "err", err) + + return nil, fmt.Errorf("failed to get playlists: %w", err) + } + + rows, err := s.db.Queries.GetAllPlaylistTracksWithMetadata( + s.db.Ctx, + ) + if err != nil { + s.logger.Error( + "Failed to get all playlist tracks", + "err", err, + ) + + return nil, fmt.Errorf( + "failed to get all playlist tracks: %w", + err, + ) + } + + // Group tracks by playlist ID. + tracksByPlaylist := make(map[int64][]Track) + + for _, row := range rows { + track := trackFromRow( + row.ID, + row.Position, + row.FilePath, + row.Title, + row.Artist, + row.Album, + row.LengthMilliseconds, + row.CoverArtPath, + ) + + tracksByPlaylist[row.PlaylistID] = append( + tracksByPlaylist[row.PlaylistID], + track, + ) + } + + result := make([]WithTracks, 0, len(playlists)) + + for _, p := range playlists { + tracks := tracksByPlaylist[p.ID] + if tracks == nil { + tracks = []Track{} + } + + result = append(result, WithTracks{ + Summary: Summary{ID: p.ID, Name: p.Name}, + Tracks: tracks, + }) + } + + return result, nil +} + // GetPlaylistTracks returns all tracks in a playlist with full metadata. func (s *Service) GetPlaylistTracks( playlistID int64, @@ -107,32 +178,48 @@ func (s *Service) GetPlaylistTracks( tracks := make([]Track, 0, len(rows)) for _, row := range rows { - track := Track{ - ID: row.ID, - Position: row.Position, - FilePath: row.FilePath, - Title: row.Title, - Artist: row.Artist, - Album: row.Album, - Duration: strconv.FormatInt( - row.LengthMilliseconds, - 10, - ), - } - - if row.CoverArtPath != "" { - base := filepath.Base(row.CoverArtPath) - track.CoverArtPath = "/covers/" + base - track.CoverArtThumbnailPath = "/covers/" + - library.ThumbnailFilename(base) - } - - tracks = append(tracks, track) + tracks = append(tracks, trackFromRow( + row.ID, + row.Position, + row.FilePath, + row.Title, + row.Artist, + row.Album, + row.LengthMilliseconds, + row.CoverArtPath, + )) } return tracks, nil } +// trackFromRow converts raw query row fields into a Track. +func trackFromRow( + id, position int64, + filePath, title, artist, album string, + lengthMilliseconds int64, + coverArtPath string, +) Track { + track := Track{ + ID: id, + Position: position, + FilePath: filePath, + Title: title, + Artist: artist, + Album: album, + Duration: strconv.FormatInt(lengthMilliseconds, 10), + } + + if coverArtPath != "" { + base := filepath.Base(coverArtPath) + track.CoverArtPath = "/covers/" + base + track.CoverArtThumbnailPath = "/covers/" + + library.ThumbnailFilename(base) + } + + return track +} + // CreatePlaylist creates a new empty playlist with the given name. func (s *Service) CreatePlaylist(name string) (Summary, error) { trimmed := strings.TrimSpace(name) diff --git a/frontend/src/components/playlist-view/playlist-view.ts b/frontend/src/components/playlist-view/playlist-view.ts index 67711b0..bc873e7 100644 --- a/frontend/src/components/playlist-view/playlist-view.ts +++ b/frontend/src/components/playlist-view/playlist-view.ts @@ -3,25 +3,26 @@ import { customElement, state } from 'lit/decorators.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; -import { - GetAllPlaylists, - CreatePlaylist, - GetPlaylistTracks, -} from '@go/playlist/Service'; +import { CreatePlaylist } from '@go/playlist/Service'; import type { playlist } from '@go/models'; import { QueueController } from '@store/controllers/queue-controller'; +import { PlaylistController } from '@store/controllers/playlist-controller'; import '@components/track-info/track-info'; +const SCROLL_DEBOUNCE_MS = 100; + interface PlaylistEntry { summary: playlist.Summary; expanded: boolean; - loading: boolean; - tracks: playlist.Track[] | null; + tracks: playlist.Track[]; } @customElement('playlist-view') export class PlaylistView extends LitElement { private queue = new QueueController(this); + private playlistCtrl = new PlaylistController(this); + private scrollDebounceTimer: ReturnType | null = + null; @state() private entries: PlaylistEntry[] = []; @state() private loading = true; @@ -225,12 +226,6 @@ export class PlaylistView extends LitElement { border-bottom: none; } - .tracks-loading { - padding: 12px 0; - color: #888; - font-size: 12px; - } - .tracks-empty { padding: 12px 0; color: #666; @@ -270,18 +265,57 @@ export class PlaylistView extends LitElement { this.loadPlaylists(); } + override disconnectedCallback() { + super.disconnectedCallback(); + + if (this.scrollDebounceTimer !== null) { + clearTimeout(this.scrollDebounceTimer); + this.scrollDebounceTimer = null; + } + } + + private get scrollContainer(): HTMLElement | null { + return ( + this.shadowRoot?.querySelector( + '.playlist-list', + ) ?? null + ); + } + + private restoreScrollPosition() { + const saved = + this.playlistCtrl.getScrollPosition(); + + if (saved > 0 && this.scrollContainer) { + this.scrollContainer.scrollTop = saved; + } + } + + private onScroll = () => { + if (this.scrollDebounceTimer !== null) { + clearTimeout(this.scrollDebounceTimer); + } + + this.scrollDebounceTimer = setTimeout(() => { + if (this.scrollContainer) { + this.playlistCtrl.setScrollPosition( + this.scrollContainer.scrollTop, + ); + } + }, SCROLL_DEBOUNCE_MS); + }; + private async loadPlaylists() { try { this.loading = true; - const result = await GetAllPlaylists(); - const summaries = result ?? []; + const playlists = + await this.playlistCtrl.getPlaylists(); - this.entries = summaries.map((s) => ({ - summary: s, + this.entries = playlists.map((p) => ({ + summary: p.Summary, expanded: false, - loading: false, - tracks: null, + tracks: p.Tracks ?? [], })); } catch (err) { console.error('Failed to load playlists:', err); @@ -289,64 +323,27 @@ export class PlaylistView extends LitElement { } finally { this.loading = false; } + + await this.updateComplete; + this.restoreScrollPosition(); } - private handleToggle = async (index: number) => { + private handleToggle = (index: number) => { const entry = this.entries[index]; if (!entry) return; - // Collapse if already expanded. - if (entry.expanded) { - this.entries = this.entries.map((e, i) => - i === index ? { ...e, expanded: false } : e, - ); - - return; - } - - // Expand and lazy-load tracks if not yet fetched. - if (entry.tracks === null) { - this.entries = this.entries.map((e, i) => - i === index - ? { ...e, expanded: true, loading: true } - : e, - ); - - try { - const tracks = await GetPlaylistTracks( - entry.summary.ID, - ); - - this.entries = this.entries.map((e, i) => - i === index - ? { - ...e, - loading: false, - tracks: tracks ?? [], - } - : e, - ); - } catch (err) { - console.error('Failed to load playlist tracks:', err); - - this.entries = this.entries.map((e, i) => - i === index - ? { ...e, loading: false, tracks: [] } - : e, - ); - } - } else { - this.entries = this.entries.map((e, i) => - i === index ? { ...e, expanded: true } : e, - ); - } + this.entries = this.entries.map((e, i) => + i === index + ? { ...e, expanded: !e.expanded } + : e, + ); }; private handlePlayAll = (index: number) => { const entry = this.entries[index]; - if (!entry?.tracks || entry.tracks.length === 0) return; + if (!entry || entry.tracks.length === 0) return; const filePaths = entry.tracks.map((t) => t.FilePath); this.queue.setQueue(filePaths, 0); @@ -379,6 +376,7 @@ export class PlaylistView extends LitElement { await CreatePlaylist(name); this.creating = false; this.newPlaylistName = ''; + this.playlistCtrl.invalidate(); await this.loadPlaylists(); } catch (err) { console.error('Failed to create playlist:', err); @@ -460,7 +458,7 @@ export class PlaylistView extends LitElement { } return html` -