diff --git a/internal/database/library.go b/internal/database/library.go index ae754c7..e4efbbe 100644 --- a/internal/database/library.go +++ b/internal/database/library.go @@ -3,6 +3,7 @@ package database import ( "database/sql" "errors" + "time" "git.noroutes.net/NoRoutes/vibetunes/internal/idgen" ) @@ -128,6 +129,7 @@ type Album struct { MusicBrainzID string PrimaryAlbumArtistID sql.NullString CoverArtID sql.NullString + CreatedAt time.Time } // FindOrCreateAlbum resolves an album by (title, primary album artist), @@ -167,7 +169,7 @@ func FindOrCreateAlbum(db *sql.DB, title string, primaryArtistID sql.NullString, return GetAlbum(db, id) } -const albumSelect = `SELECT id, title, sort_title, year, original_year, record_label, musicbrainz_id, primary_album_artist_id, cover_art_id FROM albums` +const albumSelect = `SELECT id, title, sort_title, year, original_year, record_label, musicbrainz_id, primary_album_artist_id, cover_art_id, created_at FROM albums` func GetAlbum(db *sql.DB, id string) (*Album, error) { return scanAlbum(db.QueryRow(albumSelect+` WHERE id = ?`, id)) @@ -175,13 +177,15 @@ func GetAlbum(db *sql.DB, id string) (*Album, error) { func scanAlbum(row *sql.Row) (*Album, error) { var a Album - err := row.Scan(&a.ID, &a.Title, &a.SortTitle, &a.Year, &a.OriginalYear, &a.RecordLabel, &a.MusicBrainzID, &a.PrimaryAlbumArtistID, &a.CoverArtID) + var createdAt string + err := row.Scan(&a.ID, &a.Title, &a.SortTitle, &a.Year, &a.OriginalYear, &a.RecordLabel, &a.MusicBrainzID, &a.PrimaryAlbumArtistID, &a.CoverArtID, &createdAt) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } + a.CreatedAt, _ = parseTime(createdAt) return &a, nil } @@ -195,7 +199,7 @@ func ListAlbums(db *sql.DB, limit, offset int) ([]*Album, error) { } func ListAlbumsByArtist(db *sql.DB, artistID string) ([]*Album, error) { - rows, err := db.Query(`SELECT DISTINCT al.id, al.title, al.sort_title, al.year, al.original_year, al.record_label, al.musicbrainz_id, al.primary_album_artist_id, al.cover_art_id + rows, err := db.Query(`SELECT DISTINCT al.id, al.title, al.sort_title, al.year, al.original_year, al.record_label, al.musicbrainz_id, al.primary_album_artist_id, al.cover_art_id, al.created_at FROM albums al JOIN album_artists aa ON aa.album_id = al.id WHERE aa.artist_id = ? ORDER BY al.year, al.sort_title COLLATE NOCASE`, artistID) if err != nil { @@ -209,9 +213,11 @@ func scanAlbums(rows *sql.Rows) ([]*Album, error) { var out []*Album for rows.Next() { var a Album - if err := rows.Scan(&a.ID, &a.Title, &a.SortTitle, &a.Year, &a.OriginalYear, &a.RecordLabel, &a.MusicBrainzID, &a.PrimaryAlbumArtistID, &a.CoverArtID); err != nil { + var createdAt string + if err := rows.Scan(&a.ID, &a.Title, &a.SortTitle, &a.Year, &a.OriginalYear, &a.RecordLabel, &a.MusicBrainzID, &a.PrimaryAlbumArtistID, &a.CoverArtID, &createdAt); err != nil { return nil, err } + a.CreatedAt, _ = parseTime(createdAt) out = append(out, &a) } return out, rows.Err() diff --git a/internal/database/subsonic_helpers.go b/internal/database/subsonic_helpers.go new file mode 100644 index 0000000..5ec189d --- /dev/null +++ b/internal/database/subsonic_helpers.go @@ -0,0 +1,241 @@ +package database + +import "database/sql" + +// This file collects small aggregate/lookup queries specific to shaping +// data for the Subsonic REST API (internal/subsonic), which needs +// things like song counts and "primary genre" that the native web UI +// does not. + +// AlbumStats returns an album's song count and total duration in +// seconds. +func AlbumStats(db *sql.DB, albumID string) (songCount int, totalDuration float64, err error) { + err = db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(mf.duration_seconds), 0) + FROM songs s JOIN media_files mf ON mf.id = s.media_file_id + WHERE s.album_id = ?`, albumID).Scan(&songCount, &totalDuration) + return +} + +// ArtistAlbumCount returns how many albums an artist is credited on. +func ArtistAlbumCount(db *sql.DB, artistID string) (int, error) { + var n int + err := db.QueryRow(`SELECT COUNT(*) FROM album_artists WHERE artist_id = ?`, artistID).Scan(&n) + return n, err +} + +// ArtistCoverArtID returns the cover_art id of any one album by the +// artist that has cover art, or "" if none do. +func ArtistCoverArtID(db *sql.DB, artistID string) (string, error) { + var id string + err := db.QueryRow(`SELECT al.cover_art_id FROM albums al + JOIN album_artists aa ON aa.album_id = al.id + WHERE aa.artist_id = ? AND al.cover_art_id IS NOT NULL LIMIT 1`, artistID).Scan(&id) + if err == sql.ErrNoRows { + return "", nil + } + return id, err +} + +// FirstGenreForSong returns one genre name for a song, or "" if it has +// none. +func FirstGenreForSong(db *sql.DB, songID string) (string, error) { + var name string + err := db.QueryRow(`SELECT g.name FROM song_genres sg JOIN genres g ON g.id = sg.genre_id + WHERE sg.song_id = ? ORDER BY g.name LIMIT 1`, songID).Scan(&name) + if err == sql.ErrNoRows { + return "", nil + } + return name, err +} + +// FirstGenreForAlbum returns one genre name for an album, or "" if it +// has none directly tagged (callers typically fall back to a song's +// genre). +func FirstGenreForAlbum(db *sql.DB, albumID string) (string, error) { + var name string + err := db.QueryRow(`SELECT g.name FROM album_genres ag JOIN genres g ON g.id = ag.genre_id + WHERE ag.album_id = ? ORDER BY g.name LIMIT 1`, albumID).Scan(&name) + if err == sql.ErrNoRows { + return "", nil + } + return name, err +} + +// AlbumArtistName returns the display name for an album: its primary +// album artist if set, otherwise "" (a "Various Artists"-style album +// with no single primary artist). +func AlbumArtistName(db *sql.DB, album *Album) (string, error) { + if !album.PrimaryAlbumArtistID.Valid { + return "", nil + } + artist, err := GetArtist(db, album.PrimaryAlbumArtistID.String) + if err != nil { + if err == ErrNotFound { + return "", nil + } + return "", err + } + return artist.Name, nil +} + +// GenreStats aggregates per-genre song and album counts for +// getGenres. +type GenreStat struct { + Name string + SongCount int + AlbumCount int +} + +func ListGenreStats(db *sql.DB) ([]GenreStat, error) { + rows, err := db.Query(`SELECT g.name, + (SELECT COUNT(*) FROM song_genres sg WHERE sg.genre_id = g.id) AS song_count, + (SELECT COUNT(*) FROM album_genres ag WHERE ag.genre_id = g.id) AS album_count + FROM genres g ORDER BY g.name COLLATE NOCASE`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []GenreStat + for rows.Next() { + var g GenreStat + if err := rows.Scan(&g.Name, &g.SongCount, &g.AlbumCount); err != nil { + return nil, err + } + out = append(out, g) + } + return out, rows.Err() +} + +// SongsByGenre returns up to limit songs tagged with the given genre +// name, offset for pagination. +func SongsByGenre(db *sql.DB, genreName string, limit, offset int) ([]*Song, error) { + rows, err := db.Query(`SELECT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total, + s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id, + s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak + FROM songs s + JOIN song_genres sg ON sg.song_id = s.id + JOIN genres g ON g.id = sg.genre_id + WHERE g.name = ? ORDER BY s.id LIMIT ? OFFSET ?`, genreName, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + return scanSongs(rows) +} + +// RandomSongIDs returns up to limit random song IDs, optionally +// restricted to a genre and/or year range. +func RandomSongIDs(db *sql.DB, limit int, genre string, fromYear, toYear int) ([]string, error) { + query := `SELECT DISTINCT s.id FROM songs s` + var args []any + var where []string + if genre != "" { + query += ` JOIN song_genres sg ON sg.song_id = s.id JOIN genres g ON g.id = sg.genre_id` + where = append(where, `g.name = ?`) + args = append(args, genre) + } + if fromYear != 0 || toYear != 0 { + query += ` JOIN albums al ON al.id = s.album_id` + if fromYear != 0 { + where = append(where, `al.year >= ?`) + args = append(args, fromYear) + } + if toYear != 0 { + where = append(where, `al.year <= ?`) + args = append(args, toYear) + } + } + if len(where) > 0 { + query += ` WHERE ` + for i, w := range where { + if i > 0 { + query += ` AND ` + } + query += w + } + } + query += ` ORDER BY RANDOM() LIMIT ?` + args = append(args, limit) + + rows, err := db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} + +// AlbumsByYearRange lists albums sorted by year for getAlbumList's +// byYear type. +func AlbumsByYearRange(db *sql.DB, fromYear, toYear, limit, offset int) ([]*Album, error) { + order := "ASC" + from, to := fromYear, toYear + if from > to { + from, to = to, from + order = "DESC" + } + rows, err := db.Query(albumSelect+` WHERE year >= ? AND year <= ? ORDER BY year `+order+`, sort_title LIMIT ? OFFSET ?`, + from, to, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + return scanAlbums(rows) +} + +// AlbumsByGenre lists albums tagged with the given genre. +func AlbumsByGenre(db *sql.DB, genre string, limit, offset int) ([]*Album, error) { + rows, err := db.Query(`SELECT DISTINCT al.id, al.title, al.sort_title, al.year, al.original_year, al.record_label, al.musicbrainz_id, al.primary_album_artist_id, al.cover_art_id, al.created_at + FROM albums al JOIN album_genres ag ON ag.album_id = al.id JOIN genres g ON g.id = ag.genre_id + WHERE g.name = ? ORDER BY al.sort_title COLLATE NOCASE LIMIT ? OFFSET ?`, genre, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + return scanAlbums(rows) +} + +// RandomAlbums returns up to limit random albums. +func RandomAlbums(db *sql.DB, limit int) ([]*Album, error) { + rows, err := db.Query(albumSelect+` ORDER BY RANDOM() LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + return scanAlbums(rows) +} + +// RecentlyAddedAlbums lists albums by creation order (newest first). +func RecentlyAddedAlbums(db *sql.DB, limit, offset int) ([]*Album, error) { + rows, err := db.Query(albumSelect+` ORDER BY created_at DESC LIMIT ? OFFSET ?`, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + return scanAlbums(rows) +} + +// AlbumsAlphabetical lists albums by title or artist name. +func AlbumsAlphabetical(db *sql.DB, byArtist bool, limit, offset int) ([]*Album, error) { + order := "al.sort_title COLLATE NOCASE" + join := "" + if byArtist { + join = "LEFT JOIN artists ar ON ar.id = al.primary_album_artist_id" + order = "COALESCE(ar.sort_name, ar.name, al.sort_title) COLLATE NOCASE, al.sort_title COLLATE NOCASE" + } + rows, err := db.Query(`SELECT al.id, al.title, al.sort_title, al.year, al.original_year, al.record_label, al.musicbrainz_id, al.primary_album_artist_id, al.cover_art_id, al.created_at + FROM albums al `+join+` ORDER BY `+order+` LIMIT ? OFFSET ?`, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + return scanAlbums(rows) +} diff --git a/internal/database/subsonic_helpers2.go b/internal/database/subsonic_helpers2.go new file mode 100644 index 0000000..dfcf7f4 --- /dev/null +++ b/internal/database/subsonic_helpers2.go @@ -0,0 +1,91 @@ +package database + +import "database/sql" + +// ListVideoMediaFiles returns every media file the scanner identified as +// a video (as opposed to audio) track. +func ListVideoMediaFiles(db *sql.DB) ([]*MediaFile, error) { + rows, err := db.Query(mediaFileSelect + ` WHERE kind = 'video' AND missing = 0 ORDER BY relative_path`) + if err != nil { + return nil, err + } + defer rows.Close() + return scanMediaFiles(rows) +} + +// FindArtistByName looks up an artist by exact, case-insensitive name — +// used by endpoints that take an artist name rather than ID (getTopSongs). +func FindArtistByName(db *sql.DB, name string) (*Artist, error) { + var id string + err := db.QueryRow(`SELECT id FROM artists WHERE name = ? COLLATE NOCASE LIMIT 1`, name).Scan(&id) + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return GetArtist(db, id) +} + +// TopSongsByArtist returns an artist's songs ordered by total play count +// (across all users) descending, falling back to any order for songs +// that have never been played, up to limit. +func TopSongsByArtist(db *sql.DB, artistID string, limit int) ([]*Song, error) { + rows, err := db.Query(`SELECT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total, + s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id, + s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak + FROM songs s + JOIN song_artists sa ON sa.song_id = s.id + LEFT JOIN (SELECT song_id, COUNT(*) AS plays FROM playback_history GROUP BY song_id) ph ON ph.song_id = s.id + WHERE sa.artist_id = ? + GROUP BY s.id + ORDER BY COALESCE(MAX(ph.plays), 0) DESC, s.title COLLATE NOCASE + LIMIT ?`, artistID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + return scanSongs(rows) +} + +// SimilarSongsByGenre returns up to limit random songs sharing at least +// one genre with seedSongID, excluding the seed itself. A simple, +// honest local substitute for an external recommendation service. +func SimilarSongsByGenre(db *sql.DB, seedSongID string, limit int) ([]*Song, error) { + rows, err := db.Query(`SELECT DISTINCT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total, + s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id, + s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak + FROM songs s + JOIN song_genres sg ON sg.song_id = s.id + WHERE sg.genre_id IN (SELECT genre_id FROM song_genres WHERE song_id = ?) + AND s.id != ? + ORDER BY RANDOM() LIMIT ?`, seedSongID, seedSongID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + return scanSongs(rows) +} + +// SimilarSongsByArtist returns up to limit random songs by the given +// artist (or sharing a genre with them if that artist has few songs), +// excluding none in particular — used for getSimilarSongs2's artist +// seed. +func SimilarSongsByArtist(db *sql.DB, artistID string, limit int) ([]*Song, error) { + rows, err := db.Query(`SELECT DISTINCT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total, + s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id, + s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak + FROM songs s + JOIN song_genres sg ON sg.song_id = s.id + WHERE sg.genre_id IN ( + SELECT DISTINCT sg2.genre_id FROM song_artists sa2 + JOIN song_genres sg2 ON sg2.song_id = sa2.song_id + WHERE sa2.artist_id = ? + ) + ORDER BY RANDOM() LIMIT ?`, artistID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + return scanSongs(rows) +} diff --git a/internal/database/subsonic_helpers_test.go b/internal/database/subsonic_helpers_test.go new file mode 100644 index 0000000..0f1d9cb --- /dev/null +++ b/internal/database/subsonic_helpers_test.go @@ -0,0 +1,164 @@ +package database + +import ( + "database/sql" + "fmt" + "path/filepath" + "testing" +) + +func setupGenreFixture(t *testing.T) *sql.DB { + t.Helper() + db, err := Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { db.Close() }) + + folder, err := CreateMusicFolder(db, "Music", "/music", "music") + if err != nil { + t.Fatal(err) + } + artist, err := FindOrCreateArtist(db, "Test Artist", "", "") + if err != nil { + t.Fatal(err) + } + album, err := FindOrCreateAlbum(db, "Test Album", nullStr2(artist.ID), "", nullInt2(2020), nullInt2(0), "", "") + if err != nil { + t.Fatal(err) + } + if err := LinkAlbumArtist(db, album.ID, artist.ID, 0); err != nil { + t.Fatal(err) + } + genre, err := FindOrCreateGenre(db, "Rock") + if err != nil { + t.Fatal(err) + } + if err := LinkAlbumGenre(db, album.ID, genre.ID); err != nil { + t.Fatal(err) + } + + for i := 1; i <= 3; i++ { + mf, err := CreateMediaFile(db, folder.ID, fmt.Sprintf("song%d.mp3", i), 1000, 0, fmt.Sprintf("hash%d", i), "mp3", "song", 180, 320, 44100, 2) + if err != nil { + t.Fatal(err) + } + song, err := UpsertSong(db, UpsertSongInput{MediaFileID: mf.ID, AlbumID: nullStr2(album.ID), Title: "Song"}) + if err != nil { + t.Fatal(err) + } + if err := LinkSongGenre(db, song.ID, genre.ID); err != nil { + t.Fatal(err) + } + } + return db +} + +func nullStr2(s string) sql.NullString { return sql.NullString{String: s, Valid: s != ""} } +func nullInt2(n int64) sql.NullInt64 { + if n == 0 { + return sql.NullInt64{} + } + return sql.NullInt64{Int64: n, Valid: true} +} + +func TestAlbumStats(t *testing.T) { + db := setupGenreFixture(t) + albums, err := ListAlbums(db, 10, 0) + if err != nil || len(albums) != 1 { + t.Fatalf("ListAlbums: %v, %v", albums, err) + } + count, dur, err := AlbumStats(db, albums[0].ID) + if err != nil { + t.Fatalf("AlbumStats: %v", err) + } + if count != 3 { + t.Errorf("songCount = %d, want 3", count) + } + if dur != 540 { + t.Errorf("duration = %v, want 540", dur) + } +} + +func TestListGenreStats(t *testing.T) { + db := setupGenreFixture(t) + stats, err := ListGenreStats(db) + if err != nil { + t.Fatalf("ListGenreStats: %v", err) + } + if len(stats) != 1 { + t.Fatalf("stats = %v, want 1 genre", stats) + } + if stats[0].Name != "Rock" || stats[0].SongCount != 3 || stats[0].AlbumCount != 1 { + t.Errorf("stats[0] = %+v, want Rock/3/1", stats[0]) + } +} + +func TestSongsByGenre(t *testing.T) { + db := setupGenreFixture(t) + songs, err := SongsByGenre(db, "Rock", 10, 0) + if err != nil { + t.Fatalf("SongsByGenre: %v", err) + } + if len(songs) != 3 { + t.Fatalf("songs = %v, want 3", songs) + } +} + +func TestRandomSongIDsFilteredByGenre(t *testing.T) { + db := setupGenreFixture(t) + ids, err := RandomSongIDs(db, 10, "Rock", 0, 0) + if err != nil { + t.Fatalf("RandomSongIDs: %v", err) + } + if len(ids) != 3 { + t.Fatalf("ids = %v, want 3", ids) + } + ids2, err := RandomSongIDs(db, 10, "Jazz", 0, 0) + if err != nil { + t.Fatalf("RandomSongIDs(Jazz): %v", err) + } + if len(ids2) != 0 { + t.Fatalf("ids2 = %v, want 0 for nonexistent genre", ids2) + } +} + +func TestArtistAlbumCountAndCoverArt(t *testing.T) { + db := setupGenreFixture(t) + artists, err := ListArtists(db) + if err != nil || len(artists) != 1 { + t.Fatalf("ListArtists: %v, %v", artists, err) + } + n, err := ArtistAlbumCount(db, artists[0].ID) + if err != nil { + t.Fatalf("ArtistAlbumCount: %v", err) + } + if n != 1 { + t.Errorf("ArtistAlbumCount = %d, want 1", n) + } + cover, err := ArtistCoverArtID(db, artists[0].ID) + if err != nil { + t.Fatalf("ArtistCoverArtID: %v", err) + } + if cover != "" { + t.Errorf("ArtistCoverArtID = %q, want empty (no cover art set in fixture)", cover) + } +} + +func TestAlbumsByYearRange(t *testing.T) { + db := setupGenreFixture(t) + albums, err := AlbumsByYearRange(db, 2019, 2021, 10, 0) + if err != nil { + t.Fatalf("AlbumsByYearRange: %v", err) + } + if len(albums) != 1 { + t.Fatalf("albums = %v, want 1", albums) + } + none, err := AlbumsByYearRange(db, 1990, 1991, 10, 0) + if err != nil { + t.Fatalf("AlbumsByYearRange: %v", err) + } + if len(none) != 0 { + t.Fatalf("albums = %v, want 0 outside range", none) + } +} diff --git a/internal/subsonic/browsing.go b/internal/subsonic/browsing.go new file mode 100644 index 0000000..060fc65 --- /dev/null +++ b/internal/subsonic/browsing.go @@ -0,0 +1,359 @@ +package subsonic + +import ( + "database/sql" + "encoding/base64" + "sort" + "strings" + "unicode" + + "git.noroutes.net/NoRoutes/vibetunes/internal/database" + "git.noroutes.net/NoRoutes/vibetunes/internal/library" +) + +func init() { + register("getMusicFolders", handleGetMusicFolders) + register("getIndexes", handleGetIndexes) + register("getMusicDirectory", handleGetMusicDirectory) + register("getGenres", handleGetGenres) + register("getArtists", handleGetArtists) + register("getArtist", handleGetArtist) + register("getAlbum", handleGetAlbum) + register("getSong", handleGetSong) + register("getVideos", handleGetVideos) + register("getVideoInfo", handleGetVideoInfo) + register("getArtistInfo", handleGetArtistInfo) + register("getArtistInfo2", handleGetArtistInfo2) + register("getAlbumInfo", handleGetAlbumInfo) + register("getAlbumInfo2", handleGetAlbumInfo2) + register("getSimilarSongs", handleGetSimilarSongs) + register("getSimilarSongs2", handleGetSimilarSongs2) + register("getTopSongs", handleGetTopSongs) +} + +func handleGetMusicFolders(rc *Context) *apiError { + fm, err := loadFolderMap(rc.Server.DB) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + allowed, err := fm.Allowed(rc.Server.DB, rc.User) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + out := &MusicFolders{} + for _, f := range allowed { + out.MusicFolder = append(out.MusicFolder, MusicFolder{ID: fm.IntID(f.ID), Name: f.Name}) + } + rc.Response = newResponse() + rc.Response.MusicFolders = out + return nil +} + +// --- Directory IDs for filesystem-style browsing --- +// +// A directory (or the folder root) is addressed as "d-" + base64(folderID +// + "\x00" + relativePath). Songs keep using their real database ID +// (songs.id), so the same identifier works whether a client got there via +// getIndexes/getMusicDirectory or getArtists/getAlbum. + +func encodeDirID(folderID, relPath string) string { + return "d-" + base64.RawURLEncoding.EncodeToString([]byte(folderID+"\x00"+relPath)) +} + +func decodeDirID(id string) (folderID, relPath string, ok bool) { + if !strings.HasPrefix(id, "d-") { + return "", "", false + } + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(id, "d-")) + if err != nil { + return "", "", false + } + parts := strings.SplitN(string(raw), "\x00", 2) + if len(parts) != 2 { + return "", "", false + } + return parts[0], parts[1], true +} + +func indexLetter(name string) string { + name = strings.TrimSpace(name) + for _, prefix := range []string{"The ", "A ", "An "} { + if strings.HasPrefix(name, prefix) { + name = name[len(prefix):] + break + } + } + if name == "" { + return "#" + } + r := []rune(strings.ToUpper(name))[0] + if unicode.IsLetter(r) { + return string(r) + } + return "#" +} + +func handleGetIndexes(rc *Context) *apiError { + db := rc.Server.DB + fm, err := loadFolderMap(db) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + folders, aerr := resolveFoldersParam(rc, fm) + if aerr != nil { + return aerr + } + + groups := map[string][]Artist{} + var loose []Child + for _, folder := range folders { + entries, lerr := library.ListDirectory(db, folder.ID, "") + if lerr != nil { + return newError(ErrGeneric, lerr.Error()) + } + for _, e := range entries { + if e.Dir { + a := Artist{ID: encodeDirID(folder.ID, e.Name), Name: e.Name} + letter := indexLetter(e.Name) + groups[letter] = append(groups[letter], a) + } else { + song, serr := database.GetSongByMediaFileID(db, e.File.ID) + if serr != nil { + continue + } + loose = append(loose, childFromSong(db, song, e.File, albumOfSong(db, song), rc.User.ID)) + } + } + } + + var letters []string + for l := range groups { + letters = append(letters, l) + } + sort.Strings(letters) + + idx := &Indexes{IgnoredArticles: "The El La Los Las Le Les"} + for _, l := range letters { + artists := groups[l] + sort.Slice(artists, func(i, j int) bool { return strings.ToLower(artists[i].Name) < strings.ToLower(artists[j].Name) }) + idx.Index = append(idx.Index, Index{Name: l, Artist: artists}) + } + idx.Child = loose + + rc.Response = newResponse() + rc.Response.Indexes = idx + return nil +} + +// resolveFoldersParam returns the folders a request should operate over: +// the single folder named by musicFolderId if present (and permitted), +// otherwise every folder the user may see. +func resolveFoldersParam(rc *Context, fm *folderMap) ([]*database.MusicFolder, *apiError) { + allowed, err := fm.Allowed(rc.Server.DB, rc.User) + if err != nil { + return nil, newError(ErrGeneric, err.Error()) + } + folderParam := rc.Params.Get("musicFolderId") + if folderParam == "" { + return allowed, nil + } + f := fm.ByIntID(folderParam) + if f == nil { + return nil, errNotFound("Music folder") + } + for _, a := range allowed { + if a.ID == f.ID { + return []*database.MusicFolder{f}, nil + } + } + return nil, errUnauthorized("music folder") +} + +func handleGetMusicDirectory(rc *Context) *apiError { + id, aerr := rc.Params.Required("id") + if aerr != nil { + return aerr + } + db := rc.Server.DB + + folderID, relPath, ok := decodeDirID(id) + if !ok { + return errNotFound("Directory") + } + folder, ferr := database.GetMusicFolder(db, folderID) + if ferr != nil { + return errNotFound("Directory") + } + if canSee, cerr := UserCanSeeFolder(db, rc.User, folder.ID); cerr != nil { + return newError(ErrGeneric, cerr.Error()) + } else if !canSee { + return errUnauthorized("music folder") + } + + entries, lerr := library.ListDirectory(db, folder.ID, relPath) + if lerr != nil { + return newError(ErrGeneric, lerr.Error()) + } + + name := folder.Name + if relPath != "" { + parts := strings.Split(relPath, "/") + name = parts[len(parts)-1] + } + + dir := &Directory{ID: id, Name: name} + for _, e := range entries { + if e.Dir { + sub := relPath + if sub == "" { + sub = e.Name + } else { + sub = sub + "/" + e.Name + } + dir.Child = append(dir.Child, Child{ID: encodeDirID(folder.ID, sub), Parent: id, IsDir: true, Title: e.Name}) + continue + } + song, serr := database.GetSongByMediaFileID(db, e.File.ID) + if serr != nil { + continue + } + c := childFromSong(db, song, e.File, albumOfSong(db, song), rc.User.ID) + c.Parent = id + dir.Child = append(dir.Child, c) + } + + rc.Response = newResponse() + rc.Response.Directory = dir + return nil +} + +func handleGetGenres(rc *Context) *apiError { + stats, err := database.ListGenreStats(rc.Server.DB) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + out := &Genres{} + for _, g := range stats { + out.Genre = append(out.Genre, Genre{Name: g.Name, SongCount: g.SongCount, AlbumCount: g.AlbumCount}) + } + rc.Response = newResponse() + rc.Response.Genres = out + return nil +} + +func handleGetArtists(rc *Context) *apiError { + db := rc.Server.DB + artists, err := database.ListArtists(db) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + groups := map[string][]ArtistID3{} + for _, a := range artists { + letter := indexLetter(a.Name) + groups[letter] = append(groups[letter], artistToID3(db, a, rc.User.ID)) + } + var letters []string + for l := range groups { + letters = append(letters, l) + } + sort.Strings(letters) + + out := &ArtistsID3{IgnoredArticles: "The El La Los Las Le Les"} + for _, l := range letters { + out.Index = append(out.Index, IndexID3{Name: l, Artist: groups[l]}) + } + rc.Response = newResponse() + rc.Response.Artists = out + return nil +} + +func handleGetArtist(rc *Context) *apiError { + id, aerr := rc.Params.Required("id") + if aerr != nil { + return aerr + } + db := rc.Server.DB + artist, err := database.GetArtist(db, id) + if err != nil { + return errNotFound("Artist") + } + albums, err := database.ListAlbumsByArtist(db, id) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + out := &ArtistWithAlbumsID3{ArtistID3: artistToID3(db, artist, rc.User.ID)} + for _, al := range albums { + out.Album = append(out.Album, albumToID3(db, al, rc.User.ID)) + } + rc.Response = newResponse() + rc.Response.Artist = out + return nil +} + +func handleGetAlbum(rc *Context) *apiError { + id, aerr := rc.Params.Required("id") + if aerr != nil { + return aerr + } + db := rc.Server.DB + album, err := database.GetAlbum(db, id) + if err != nil { + return errNotFound("Album") + } + songs, err := database.ListSongsByAlbum(db, id) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + out := &AlbumWithSongsID3{AlbumID3: albumToID3(db, album, rc.User.ID)} + for _, s := range songs { + mf, merr := database.GetMediaFile(db, s.MediaFileID) + if merr != nil { + continue + } + if canSee, cerr := UserCanSeeMediaFile(db, rc.User, mf); cerr == nil && !canSee { + continue + } + out.Song = append(out.Song, childFromSong(db, s, mf, album, rc.User.ID)) + } + rc.Response = newResponse() + rc.Response.Album = out + return nil +} + +func handleGetSong(rc *Context) *apiError { + id, aerr := rc.Params.Required("id") + if aerr != nil { + return aerr + } + db := rc.Server.DB + song, err := database.GetSong(db, id) + if err != nil { + return errNotFound("Song") + } + mf, err := database.GetMediaFile(db, song.MediaFileID) + if err != nil { + return errNotFound("Song") + } + if canSee, cerr := UserCanSeeMediaFile(db, rc.User, mf); cerr != nil { + return newError(ErrGeneric, cerr.Error()) + } else if !canSee { + return errUnauthorized("song") + } + album := albumOfSong(db, song) + c := childFromSong(db, song, mf, album, rc.User.ID) + rc.Response = newResponse() + rc.Response.Song = &c + return nil +} + +// albumOfSong loads a song's album, tolerating albumless songs. +func albumOfSong(db *sql.DB, song *database.Song) *database.Album { + if !song.AlbumID.Valid { + return nil + } + album, err := database.GetAlbum(db, song.AlbumID.String) + if err != nil { + return nil + } + return album +} diff --git a/internal/subsonic/browsing_extra.go b/internal/subsonic/browsing_extra.go new file mode 100644 index 0000000..8190601 --- /dev/null +++ b/internal/subsonic/browsing_extra.go @@ -0,0 +1,233 @@ +package subsonic + +import ( + "database/sql" + "os" + "path/filepath" + "strconv" + "strings" + + "git.noroutes.net/NoRoutes/vibetunes/internal/database" + "git.noroutes.net/NoRoutes/vibetunes/internal/streaming" +) + +func handleGetVideos(rc *Context) *apiError { + db := rc.Server.DB + files, err := database.ListVideoMediaFiles(db) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + out := &Videos{} + for _, mf := range files { + if canSee, cerr := UserCanSeeMediaFile(db, rc.User, mf); cerr != nil || !canSee { + continue + } + song, serr := database.GetSongByMediaFileID(db, mf.ID) + if serr != nil { + continue + } + out.Video = append(out.Video, childFromSong(db, song, mf, albumOfSong(db, song), rc.User.ID)) + } + rc.Response = newResponse() + rc.Response.Videos = out + return nil +} + +// sidecarCaptionExtensions are checked next to a video file for a +// same-basename subtitle track — the common convention most media +// managers and players use (movie.mp4 + movie.srt/.vtt/.ass). +var sidecarCaptionExtensions = []string{".srt", ".vtt", ".ass"} + +func findSidecarCaptions(videoAbsPath string) []string { + dir := filepath.Dir(videoAbsPath) + base := strings.TrimSuffix(filepath.Base(videoAbsPath), filepath.Ext(videoAbsPath)) + var found []string + for _, ext := range sidecarCaptionExtensions { + p := filepath.Join(dir, base+ext) + if _, err := os.Stat(p); err == nil { + found = append(found, p) + } + } + return found +} + +func handleGetVideoInfo(rc *Context) *apiError { + id, aerr := rc.Params.Required("id") + if aerr != nil { + return aerr + } + db := rc.Server.DB + song, err := database.GetSong(db, id) + if err != nil { + return errNotFound("Video") + } + mf, err := database.GetMediaFile(db, song.MediaFileID) + if err != nil { + return errNotFound("Video") + } + if canSee, cerr := UserCanSeeMediaFile(db, rc.User, mf); cerr != nil || !canSee { + return errUnauthorized("video") + } + folder, err := database.GetMusicFolder(db, mf.MusicFolderID) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + absPath, perr := streaming.ResolvePath(folder.Path, mf.RelativePath) + if perr != nil { + return newError(ErrGeneric, perr.Error()) + } + + info := &VideoInfo{ID: id, AudioTrack: []AudioTrack{{ID: id, Name: "Default"}}} + for i, capPath := range findSidecarCaptions(absPath) { + info.Captions = append(info.Captions, Captions{ID: id + ":cap:" + strconv.Itoa(i), Name: filepath.Base(capPath)}) + } + rc.Response = newResponse() + rc.Response.VideoInfo = info + return nil +} + +// --- Artist/album info: VibeSonic has no external metadata provider +// (last.fm etc.) in Phase 2, so these honestly return only what's known +// locally — which for biography/images/similar-artists is nothing — +// rather than fabricating placeholder content. --- + +func handleGetArtistInfo(rc *Context) *apiError { + if _, aerr := rc.Params.Required("id"); aerr != nil { + return aerr + } + rc.Response = newResponse() + rc.Response.ArtistInfo = &ArtistInfo{} + return nil +} + +func handleGetArtistInfo2(rc *Context) *apiError { + if _, aerr := rc.Params.Required("id"); aerr != nil { + return aerr + } + rc.Response = newResponse() + rc.Response.ArtistInfo2 = &ArtistInfo2{} + return nil +} + +func handleGetAlbumInfo(rc *Context) *apiError { + if _, aerr := rc.Params.Required("id"); aerr != nil { + return aerr + } + rc.Response = newResponse() + rc.Response.AlbumInfo = &AlbumInfo{} + return nil +} + +func handleGetAlbumInfo2(rc *Context) *apiError { + if _, aerr := rc.Params.Required("id"); aerr != nil { + return aerr + } + rc.Response = newResponse() + rc.Response.AlbumInfo = &AlbumInfo{} + return nil +} + +// --- Similar/top songs: a real, local, genre-based heuristic rather +// than a fabricated or hardcoded list. --- + +func handleGetSimilarSongs(rc *Context) *apiError { + id, aerr := rc.Params.Required("id") + if aerr != nil { + return aerr + } + count := rc.Params.Int("count", 50) + db := rc.Server.DB + + songs, serr := similarSongsFor(db, id, count) + if serr != nil { + return errNotFound("Item") + } + out := &SimilarSongs{} + for _, s := range songs { + mf, merr := database.GetMediaFile(db, s.MediaFileID) + if merr != nil { + continue + } + out.Song = append(out.Song, childFromSong(db, s, mf, albumOfSong(db, s), rc.User.ID)) + } + rc.Response = newResponse() + rc.Response.SimilarSongs = out + return nil +} + +func handleGetSimilarSongs2(rc *Context) *apiError { + id, aerr := rc.Params.Required("id") + if aerr != nil { + return aerr + } + count := rc.Params.Int("count", 50) + db := rc.Server.DB + + // id here names an artist (ID3 context). + songs, err := database.SimilarSongsByArtist(db, id, count) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + out := &SimilarSongs2{} + for _, s := range songs { + mf, merr := database.GetMediaFile(db, s.MediaFileID) + if merr != nil { + continue + } + out.Song = append(out.Song, childFromSong(db, s, mf, albumOfSong(db, s), rc.User.ID)) + } + rc.Response = newResponse() + rc.Response.SimilarSongs2 = out + return nil +} + +// similarSongsFor resolves id as a song, album, or artist (getSimilarSongs's +// "id" can be any of the three in real Subsonic usage) and finds +// genre-similar songs for it. +func similarSongsFor(db *sql.DB, id string, count int) ([]*database.Song, error) { + if song, err := database.GetSong(db, id); err == nil { + return database.SimilarSongsByGenre(db, song.ID, count) + } + if _, err := database.GetAlbum(db, id); err == nil { + songs, err := database.ListSongsByAlbum(db, id) + if err != nil || len(songs) == 0 { + return nil, err + } + return database.SimilarSongsByGenre(db, songs[0].ID, count) + } + if _, err := database.GetArtist(db, id); err == nil { + return database.SimilarSongsByArtist(db, id, count) + } + return nil, database.ErrNotFound +} + +func handleGetTopSongs(rc *Context) *apiError { + artistName, aerr := rc.Params.Required("artist") + if aerr != nil { + return aerr + } + count := rc.Params.Int("count", 50) + db := rc.Server.DB + + artist, err := database.FindArtistByName(db, artistName) + if err != nil { + rc.Response = newResponse() + rc.Response.TopSongs = &TopSongs{} + return nil + } + songs, err := database.TopSongsByArtist(db, artist.ID, count) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + out := &TopSongs{} + for _, s := range songs { + mf, merr := database.GetMediaFile(db, s.MediaFileID) + if merr != nil { + continue + } + out.Song = append(out.Song, childFromSong(db, s, mf, albumOfSong(db, s), rc.User.ID)) + } + rc.Response = newResponse() + rc.Response.TopSongs = out + return nil +} diff --git a/internal/subsonic/browsing_test.go b/internal/subsonic/browsing_test.go new file mode 100644 index 0000000..4b71dd8 --- /dev/null +++ b/internal/subsonic/browsing_test.go @@ -0,0 +1,257 @@ +package subsonic + +import ( + "context" + "encoding/xml" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + "time" + + "git.noroutes.net/NoRoutes/vibetunes/internal/database" +) + +// seedLibrary scans a small real (ffmpeg-generated) library into s so +// browsing endpoints have real rows to return. Skips the test if +// ffmpeg/ffprobe aren't installed. +func seedLibrary(t *testing.T, s *Server) (folder *database.MusicFolder) { + t.Helper() + if _, err := exec.LookPath("ffmpeg"); err != nil { + t.Skip("ffmpeg not installed") + } + if _, err := exec.LookPath("ffprobe"); err != nil { + t.Skip("ffprobe not installed") + } + + dir := t.TempDir() + songDir := filepath.Join(dir, "Test Artist", "Test Album") + if err := os.MkdirAll(songDir, 0o755); err != nil { + t.Fatal(err) + } + for i, title := range []string{"First Song", "Second Song"} { + out := filepath.Join(songDir, title+".mp3") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + cmd := exec.CommandContext(ctx, "ffmpeg", + "-f", "lavfi", "-i", "sine=frequency=440:duration=1", + "-metadata", "title="+title, + "-metadata", "artist=Test Artist", + "-metadata", "album=Test Album", + "-metadata", "genre=Rock", + "-metadata", "track="+strconv.Itoa(i+1), + "-metadata", "date=2021", + "-codec:a", "libmp3lame", "-b:a", "64k", + out) + if combined, err := cmd.CombinedOutput(); err != nil { + cancel() + t.Fatalf("ffmpeg: %v\n%s", err, combined) + } + cancel() + } + + f, err := database.CreateMusicFolder(s.DB, "Test Music", dir, "music") + if err != nil { + t.Fatal(err) + } + if err := s.Scanner.ScanFolder(context.Background(), f.ID); err != nil { + t.Fatalf("ScanFolder: %v", err) + } + return f +} + +func TestGetMusicFoldersAndGenresAndArtists(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + seedLibrary(t, s) + + q := baseParams("alice", "password123") + q.Set("f", "json") + + resp := getPath(t, srv.URL, "/rest/getMusicFolders.view", q) + j := decodeJSON(t, resp) + folders := j["musicFolders"].(map[string]any)["musicFolder"].([]any) + if len(folders) != 1 { + t.Fatalf("musicFolders = %v, want 1", folders) + } + + resp = getPath(t, srv.URL, "/rest/getGenres.view", q) + j = decodeJSON(t, resp) + genres := j["genres"].(map[string]any)["genre"].([]any) + if len(genres) != 1 { + t.Fatalf("genres = %v, want 1", genres) + } + + resp = getPath(t, srv.URL, "/rest/getArtists.view", q) + j = decodeJSON(t, resp) + indexes := j["artists"].(map[string]any)["index"].([]any) + if len(indexes) == 0 { + t.Fatalf("artists index = %v, want at least 1 letter group", indexes) + } +} + +func TestGetArtistAndAlbumAndSong(t *testing.T) { + s, db := newTestServer(t) + createTestUser(t, s, "alice", "password123", false) + seedLibrary(t, s) + srv := httptest.NewServer(s.Router()) + defer srv.Close() + + artists, err := database.ListArtists(db) + if err != nil || len(artists) == 0 { + t.Fatalf("ListArtists: %v %v", artists, err) + } + artistID := artists[0].ID + + q := baseParams("alice", "password123") + q.Set("f", "json") + q.Set("id", artistID) + resp := getPath(t, srv.URL, "/rest/getArtist.view", q) + j := decodeJSON(t, resp) + artist := j["artist"].(map[string]any) + albums := artist["album"].([]any) + if len(albums) != 1 { + t.Fatalf("albums = %v, want 1", albums) + } + albumID := albums[0].(map[string]any)["id"].(string) + + q.Set("id", albumID) + resp = getPath(t, srv.URL, "/rest/getAlbum.view", q) + j = decodeJSON(t, resp) + album := j["album"].(map[string]any) + songs := album["song"].([]any) + if len(songs) != 2 { + t.Fatalf("songs = %v, want 2", songs) + } + songID := songs[0].(map[string]any)["id"].(string) + + q.Set("id", songID) + resp = getPath(t, srv.URL, "/rest/getSong.view", q) + j = decodeJSON(t, resp) + song := j["song"].(map[string]any) + if song["title"] == nil { + t.Fatalf("song missing title: %v", song) + } +} + +func TestGetAlbumNotFound(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + + q := baseParams("alice", "password123") + q.Set("id", "al_doesnotexist") + resp := getPath(t, srv.URL, "/rest/getAlbum.view", q) + x := decodeXML(t, resp) + if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrNotFound { + t.Fatalf("expected error 70, got %+v", x) + } +} + +func TestGetAlbumMissingID(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + + resp := getPath(t, srv.URL, "/rest/getAlbum.view", baseParams("alice", "password123")) + x := decodeXML(t, resp) + if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrMissingParameter { + t.Fatalf("expected error 10, got %+v", x) + } +} + +func TestGetIndexesAndMusicDirectory(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + seedLibrary(t, s) + + q := baseParams("alice", "password123") + resp := getPath(t, srv.URL, "/rest/getIndexes.view", q) + defer resp.Body.Close() + var out struct { + Indexes struct { + Index []struct { + Artist []struct { + ID string `xml:"id,attr"` + Name string `xml:"name,attr"` + } `xml:"artist"` + } `xml:"index"` + } `xml:"indexes"` + } + if err := xml.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode: %v", err) + } + if len(out.Indexes.Index) == 0 || len(out.Indexes.Index[0].Artist) == 0 { + t.Fatalf("expected at least one artist directory, got %+v", out) + } + dirID := out.Indexes.Index[0].Artist[0].ID + + q2 := baseParams("alice", "password123") + q2.Set("id", dirID) + resp2 := getPath(t, srv.URL, "/rest/getMusicDirectory.view", q2) + defer resp2.Body.Close() + var dirOut struct { + Directory struct { + Child []struct { + IsDir bool `xml:"isDir,attr"` + Title string `xml:"title,attr"` + } `xml:"child"` + } `xml:"directory"` + } + if err := xml.NewDecoder(resp2.Body).Decode(&dirOut); err != nil { + t.Fatalf("decode: %v", err) + } + if len(dirOut.Directory.Child) != 1 || !dirOut.Directory.Child[0].IsDir { + t.Fatalf("expected one subdirectory (the album), got %+v", dirOut) + } +} + +func TestGetMusicDirectoryUnauthorizedFolder(t *testing.T) { + s, db := newTestServer(t) + u := createTestUser(t, s, "alice", "password123", false) + folder := seedLibrary(t, s) + srv := httptest.NewServer(s.Router()) + defer srv.Close() + + // Restrict alice to a different (nonexistent) folder set, i.e. no access to `folder`. + other, err := database.CreateMusicFolder(db, "Other", t.TempDir(), "music") + if err != nil { + t.Fatal(err) + } + if err := database.SetAllowedMusicFolders(db, u.ID, []string{other.ID}); err != nil { + t.Fatal(err) + } + + q := baseParams("alice", "password123") + q.Set("id", encodeDirID(folder.ID, "Test Artist")) + resp := getPath(t, srv.URL, "/rest/getMusicDirectory.view", q) + x := decodeXML(t, resp) + if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrUnauthorized { + t.Fatalf("expected error 50 for restricted folder, got %+v", x) + } +} + +func TestGetVideosEmpty(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + + q := baseParams("alice", "password123") + q.Set("f", "json") + resp := getPath(t, srv.URL, "/rest/getVideos.view", q) + j := decodeJSON(t, resp) + if j["status"] != "ok" { + t.Fatalf("getVideos status = %v", j["status"]) + } +} + +func TestGetTopSongsUnknownArtist(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + + q := baseParams("alice", "password123") + q.Set("artist", "Nonexistent Artist XYZ") + resp := getPath(t, srv.URL, "/rest/getTopSongs.view", q) + x := decodeXML(t, resp) + if x.Status != "ok" { + t.Fatalf("getTopSongs for unknown artist should return ok with empty list, got %+v", x) + } +} diff --git a/internal/subsonic/convert.go b/internal/subsonic/convert.go new file mode 100644 index 0000000..6776cbe --- /dev/null +++ b/internal/subsonic/convert.go @@ -0,0 +1,279 @@ +package subsonic + +import ( + "database/sql" + "strings" + "time" + + "git.noroutes.net/NoRoutes/vibetunes/internal/database" +) + +// This file is the single place database rows become Subsonic wire +// types. Every handler goes through these functions rather than +// building Child/AlbumID3/ArtistID3 literals itself, which is what +// keeps XML and JSON output (and every endpoint that happens to return +// the same entity type) semantically identical. + +func isoTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format("2006-01-02T15:04:05.000Z") +} + +func nullTimeToISO(t sql.NullTime) string { + if !t.Valid { + return "" + } + return isoTime(t.Time) +} + +// songArtistName returns the display artist string for a song: its own +// credited performers if any, otherwise falls back to the album's +// artist. +func songArtistName(db *sql.DB, song *database.Song, albumArtist string) string { + names, err := database.ListSongArtistNames(db, song.ID) + if err == nil && len(names) > 0 { + return strings.Join(names, ", ") + } + return albumArtist +} + +// songCoverArt resolves the coverArt id to advertise for a song: the +// owning album's cover art row if it has one, else "". +func songCoverArt(album *database.Album) string { + if album != nil && album.CoverArtID.Valid { + return album.CoverArtID.String + } + return "" +} + +// childFromSong builds the polymorphic Child element for a song, given +// its media file and (possibly nil, for albumless tracks) album. userID +// is used to fill in the per-user starred/rating/playCount fields; pass +// "" to omit them (e.g. for anonymous/system contexts, which don't +// exist in practice since every /rest/* call is authenticated). +func childFromSong(db *sql.DB, song *database.Song, mf *database.MediaFile, album *database.Album, userID string) Child { + var albumTitle, artistName, artistID, genre string + var year int + if album != nil { + albumTitle = album.Title + if album.PrimaryAlbumArtistID.Valid { + artistID = album.PrimaryAlbumArtistID.String + } + if album.Year.Valid { + year = int(album.Year.Int64) + } + if name, err := database.AlbumArtistName(db, album); err == nil { + artistName = name + } + } + artistName = songArtistName(db, song, artistName) + if g, err := database.FirstGenreForSong(db, song.ID); err == nil { + genre = g + } + + c := Child{ + ID: song.ID, + IsDir: false, + Title: song.Title, + Album: albumTitle, + Artist: artistName, + Genre: genre, + Year: year, + CoverArt: songCoverArt(album), + Size: mf.SizeBytes, + ContentType: contentTypeForFormat(mf.Format), + Suffix: suffixForFormat(mf.Format), + Duration: int(mf.DurationSeconds + 0.5), + BitRate: mf.BitrateKbps, + Path: mf.RelativePath, + Created: isoTime(mf.FirstSeenAt), + Type: "music", + } + if song.TrackNumber.Valid { + c.Track = int(song.TrackNumber.Int64) + } + if song.DiscNumber.Valid { + c.DiscNumber = int(song.DiscNumber.Int64) + } + if album != nil { + c.AlbumID = album.ID + } + if artistID != "" { + c.ArtistID = artistID + } + if mf.Kind == "video" { + c.IsVideo = true + } + + if userID != "" { + if starredAt, err := starredTime(db, userID, "song", song.ID); err == nil && starredAt != "" { + c.Starred = starredAt + } + if rating, err := userRating(db, userID, "song", song.ID); err == nil { + c.UserRating = rating + } + if count, err := playCount(db, userID, song.ID); err == nil { + c.PlayCount = count + } + } + return c +} + +func starredTime(db *sql.DB, userID, itemType, itemID string) (string, error) { + var createdAt string + err := db.QueryRow(`SELECT created_at FROM stars WHERE user_id=? AND item_type=? AND item_id=?`, userID, itemType, itemID).Scan(&createdAt) + if err == sql.ErrNoRows { + return "", nil + } + if err != nil { + return "", err + } + t, _ := parseISOTime(createdAt) + return isoTime(t), nil +} + +func userRating(db *sql.DB, userID, itemType, itemID string) (int, error) { + var rating int + err := db.QueryRow(`SELECT rating FROM ratings WHERE user_id=? AND item_type=? AND item_id=?`, userID, itemType, itemID).Scan(&rating) + if err == sql.ErrNoRows { + return 0, nil + } + return rating, err +} + +func playCount(db *sql.DB, userID, songID string) (int64, error) { + var n int64 + err := db.QueryRow(`SELECT COUNT(*) FROM playback_history WHERE user_id=? AND song_id=?`, userID, songID).Scan(&n) + return n, err +} + +func parseISOTime(s string) (time.Time, error) { + return time.Parse("2006-01-02T15:04:05.999999999Z", s) +} + +// albumToID3 builds the ID3-browsing AlbumID3 element. +func albumToID3(db *sql.DB, album *database.Album, userID string) AlbumID3 { + songCount, duration, _ := database.AlbumStats(db, album.ID) + artistName, _ := database.AlbumArtistName(db, album) + genre, _ := database.FirstGenreForAlbum(db, album.ID) + + a := AlbumID3{ + ID: album.ID, + Name: album.Title, + Artist: artistName, + SongCount: songCount, + Duration: int(duration + 0.5), + Created: isoTime(album.CreatedAt), + Genre: genre, + } + if album.PrimaryAlbumArtistID.Valid { + a.ArtistID = album.PrimaryAlbumArtistID.String + } + if album.CoverArtID.Valid { + a.CoverArt = album.CoverArtID.String + } + if album.Year.Valid { + a.Year = int(album.Year.Int64) + } + if userID != "" { + if s, err := starredTime(db, userID, "album", album.ID); err == nil { + a.Starred = s + } + } + return a +} + +// childFromAlbum represents an album as a Child (used by getAlbumList, +// the filesystem-style AlbumList, and search2's album matches). +func childFromAlbum(db *sql.DB, album *database.Album, userID string) Child { + _, duration, _ := database.AlbumStats(db, album.ID) + artistName, _ := database.AlbumArtistName(db, album) + genre, _ := database.FirstGenreForAlbum(db, album.ID) + + c := Child{ + ID: album.ID, + IsDir: true, + Title: album.Title, + Album: album.Title, + Artist: artistName, + Genre: genre, + Duration: int(duration + 0.5), + Created: isoTime(album.CreatedAt), + } + if album.PrimaryAlbumArtistID.Valid { + c.ArtistID = album.PrimaryAlbumArtistID.String + } + if album.CoverArtID.Valid { + c.CoverArt = album.CoverArtID.String + } + if album.Year.Valid { + c.Year = int(album.Year.Int64) + } + if userID != "" { + if s, err := starredTime(db, userID, "album", album.ID); err == nil { + c.Starred = s + } + } + return c +} + +// artistToID3 builds the ID3-browsing ArtistID3 element. +func artistToID3(db *sql.DB, artist *database.Artist, userID string) ArtistID3 { + albumCount, _ := database.ArtistAlbumCount(db, artist.ID) + coverArt, _ := database.ArtistCoverArtID(db, artist.ID) + a := ArtistID3{ID: artist.ID, Name: artist.Name, AlbumCount: albumCount, CoverArt: coverArt} + if userID != "" { + if s, err := starredTime(db, userID, "artist", artist.ID); err == nil { + a.Starred = s + } + } + return a +} + +// artistToFSArtist builds the filesystem-browsing Artist element (used +// by getIndexes), keyed by music folder directory name rather than an +// ID3 tag identity. +func artistToFSArtist(id, name string) Artist { + return Artist{ID: id, Name: name} +} + +// contentTypeForFormat maps a probed container/codec name to a MIME +// type for the Subsonic contentType attribute. +func contentTypeForFormat(format string) string { + switch strings.ToLower(format) { + case "mp3": + return "audio/mpeg" + case "flac": + return "audio/flac" + case "ogg", "oga": + return "audio/ogg" + case "opus": + return "audio/opus" + case "aac", "adts": + return "audio/aac" + case "mov", "mp4", "m4a", "m4b", "3gp", "3g2", "mj2": + return "audio/mp4" + case "wav": + return "audio/wav" + case "aiff", "aif": + return "audio/aiff" + case "asf", "wma": + return "audio/x-ms-wma" + default: + return "application/octet-stream" + } +} + +// suffixForFormat maps a probed format name to a plain file extension +// for the Subsonic suffix attribute. +func suffixForFormat(format string) string { + f := strings.ToLower(format) + switch f { + case "mov", "mp4", "3gp", "3g2", "mj2": + return "m4a" + default: + return f + } +} diff --git a/internal/subsonic/folders.go b/internal/subsonic/folders.go new file mode 100644 index 0000000..96b06ab --- /dev/null +++ b/internal/subsonic/folders.go @@ -0,0 +1,98 @@ +package subsonic + +import ( + "database/sql" + "strconv" + + "git.noroutes.net/NoRoutes/vibetunes/internal/database" +) + +// folderMap resolves between VibeSonic's opaque music-folder IDs and the +// small sequential integers the Subsonic protocol expects for +// musicFolderId (the official XSD types it as xs:int, and some clients +// build folder-picker UIs assuming small integers). The mapping is +// derived fresh per request from folder creation order, which is stable +// as long as folders aren't deleted/recreated — acceptable for how +// rarely music folders change. +type folderMap struct { + folders []*database.MusicFolder + toInt map[string]int + toFolder map[int]*database.MusicFolder +} + +func loadFolderMap(db *sql.DB) (*folderMap, error) { + folders, err := database.ListMusicFolders(db) + if err != nil { + return nil, err + } + fm := &folderMap{ + folders: folders, + toInt: make(map[string]int, len(folders)), + toFolder: make(map[int]*database.MusicFolder, len(folders)), + } + for i, f := range folders { + id := i + 1 + fm.toInt[f.ID] = id + fm.toFolder[id] = f + } + return fm, nil +} + +func (fm *folderMap) IntID(folderID string) string { + return strconv.Itoa(fm.toInt[folderID]) +} + +func (fm *folderMap) ByIntID(s string) *database.MusicFolder { + n, err := strconv.Atoi(s) + if err != nil { + return nil + } + return fm.toFolder[n] +} + +// Allowed returns the folders a user may see: every folder, unless the +// user has an explicit restriction list configured. +func (fm *folderMap) Allowed(db *sql.DB, user *database.User) ([]*database.MusicFolder, error) { + allowedIDs, err := database.AllowedMusicFolderIDs(db, user.ID) + if err != nil { + return nil, err + } + if len(allowedIDs) == 0 { + return fm.folders, nil + } + set := make(map[string]bool, len(allowedIDs)) + for _, id := range allowedIDs { + set[id] = true + } + var out []*database.MusicFolder + for _, f := range fm.folders { + if set[f.ID] { + out = append(out, f) + } + } + return out, nil +} + +// UserCanSeeFolder reports whether user is allowed to see folderID, +// given their restriction list (empty = unrestricted). +func UserCanSeeFolder(db *sql.DB, user *database.User, folderID string) (bool, error) { + allowedIDs, err := database.AllowedMusicFolderIDs(db, user.ID) + if err != nil { + return false, err + } + if len(allowedIDs) == 0 { + return true, nil + } + for _, id := range allowedIDs { + if id == folderID { + return true, nil + } + } + return false, nil +} + +// UserCanSeeMediaFile reports whether user is allowed to see a song +// belonging to the given music folder (per their folder restriction). +func UserCanSeeMediaFile(db *sql.DB, user *database.User, mediaFile *database.MediaFile) (bool, error) { + return UserCanSeeFolder(db, user, mediaFile.MusicFolderID) +}