Add a filesystem scanner that ingests supported audio files from MEDIA_ROOT into SQLite using embedded tags with filename fallbacks. Wire startup scanning, manual rescan, and authenticated audio streaming into the backend, then connect the web player to the real stream endpoint.
194 lines
4.6 KiB
Go
194 lines
4.6 KiB
Go
package library
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
)
|
|
|
|
type Artist struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
AlbumCount int `json:"albumCount"`
|
|
}
|
|
|
|
type Album struct {
|
|
ID string `json:"id"`
|
|
ArtistID string `json:"artistId"`
|
|
ArtistName string `json:"artistName"`
|
|
Title string `json:"title"`
|
|
Year int `json:"year"`
|
|
TrackCount int `json:"trackCount"`
|
|
}
|
|
|
|
type Track struct {
|
|
ID string `json:"id"`
|
|
AlbumID string `json:"albumId"`
|
|
ArtistID string `json:"artistId"`
|
|
Title string `json:"title"`
|
|
ArtistName string `json:"artistName"`
|
|
AlbumTitle string `json:"albumTitle"`
|
|
TrackNumber int `json:"trackNumber"`
|
|
DurationSecs int `json:"durationSeconds"`
|
|
FilePath string `json:"filePath"`
|
|
ContentType string `json:"contentType"`
|
|
}
|
|
|
|
type HomePayload struct {
|
|
RecentAlbums []Album `json:"recentAlbums"`
|
|
Artists []Artist `json:"artists"`
|
|
}
|
|
|
|
type Service struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func NewService(db *sql.DB) *Service {
|
|
return &Service{db: db}
|
|
}
|
|
|
|
func (s *Service) Home(ctx context.Context) (HomePayload, error) {
|
|
albums, err := s.RecentAlbums(ctx, 6)
|
|
if err != nil {
|
|
return HomePayload{}, err
|
|
}
|
|
|
|
artists, err := s.Artists(ctx, 12)
|
|
if err != nil {
|
|
return HomePayload{}, err
|
|
}
|
|
|
|
return HomePayload{
|
|
RecentAlbums: albums,
|
|
Artists: artists,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) Artists(ctx context.Context, limit int) ([]Artist, error) {
|
|
rows, err := s.db.QueryContext(
|
|
ctx,
|
|
`SELECT a.id, a.name, COUNT(al.id) AS album_count
|
|
FROM artists a
|
|
LEFT JOIN albums al ON al.artist_id = a.id
|
|
GROUP BY a.id, a.name
|
|
ORDER BY a.name ASC
|
|
LIMIT ?`,
|
|
limit,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query artists: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var artists []Artist
|
|
for rows.Next() {
|
|
var artist Artist
|
|
if err := rows.Scan(&artist.ID, &artist.Name, &artist.AlbumCount); err != nil {
|
|
return nil, fmt.Errorf("scan artist: %w", err)
|
|
}
|
|
artists = append(artists, artist)
|
|
}
|
|
|
|
return artists, rows.Err()
|
|
}
|
|
|
|
func (s *Service) RecentAlbums(ctx context.Context, limit int) ([]Album, error) {
|
|
rows, err := s.db.QueryContext(
|
|
ctx,
|
|
`SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id) AS track_count
|
|
FROM albums al
|
|
JOIN artists a ON a.id = al.artist_id
|
|
LEFT JOIN tracks t ON t.album_id = al.id
|
|
GROUP BY al.id, al.artist_id, a.name, al.title, al.year
|
|
ORDER BY al.updated_at DESC, al.title ASC
|
|
LIMIT ?`,
|
|
limit,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query albums: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var albums []Album
|
|
for rows.Next() {
|
|
var album Album
|
|
if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount); err != nil {
|
|
return nil, fmt.Errorf("scan album: %w", err)
|
|
}
|
|
albums = append(albums, album)
|
|
}
|
|
|
|
return albums, rows.Err()
|
|
}
|
|
|
|
func (s *Service) Tracks(ctx context.Context, limit int) ([]Track, error) {
|
|
rows, err := s.db.QueryContext(
|
|
ctx,
|
|
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0),
|
|
COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, '')
|
|
FROM tracks t
|
|
JOIN artists a ON a.id = t.artist_id
|
|
JOIN albums al ON al.id = t.album_id
|
|
ORDER BY a.name ASC, al.year DESC, al.title ASC, t.disc_number ASC, t.track_number ASC
|
|
LIMIT ?`,
|
|
limit,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query tracks: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var tracks []Track
|
|
for rows.Next() {
|
|
var track Track
|
|
if err := rows.Scan(
|
|
&track.ID,
|
|
&track.AlbumID,
|
|
&track.ArtistID,
|
|
&track.Title,
|
|
&track.ArtistName,
|
|
&track.AlbumTitle,
|
|
&track.TrackNumber,
|
|
&track.DurationSecs,
|
|
&track.FilePath,
|
|
&track.ContentType,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scan track: %w", err)
|
|
}
|
|
tracks = append(tracks, track)
|
|
}
|
|
|
|
return tracks, rows.Err()
|
|
}
|
|
|
|
func (s *Service) TrackByID(ctx context.Context, id string) (Track, error) {
|
|
var track Track
|
|
|
|
err := s.db.QueryRowContext(
|
|
ctx,
|
|
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0),
|
|
COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, '')
|
|
FROM tracks t
|
|
JOIN artists a ON a.id = t.artist_id
|
|
JOIN albums al ON al.id = t.album_id
|
|
WHERE t.id = ?`,
|
|
id,
|
|
).Scan(
|
|
&track.ID,
|
|
&track.AlbumID,
|
|
&track.ArtistID,
|
|
&track.Title,
|
|
&track.ArtistName,
|
|
&track.AlbumTitle,
|
|
&track.TrackNumber,
|
|
&track.DurationSecs,
|
|
&track.FilePath,
|
|
&track.ContentType,
|
|
)
|
|
if err != nil {
|
|
return Track{}, fmt.Errorf("query track by id: %w", err)
|
|
}
|
|
|
|
return track, nil
|
|
}
|