Subsonic: browsing endpoints (getMusicFolders, getIndexes, getMusicDirectory, getGenres, getArtists/Artist, getAlbum, getSong, getVideos/VideoInfo, getArtistInfo/2, getAlbumInfo/2, getSimilarSongs/2, getTopSongs)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user