refactor: move shared packages up a level

This commit is contained in:
sentriz
2022-04-13 00:09:10 +01:00
parent 165904c2bb
commit 8b803ecf20
53 changed files with 65 additions and 68 deletions

View File

@@ -23,8 +23,8 @@ import (
"go.senan.xyz/gonic"
"go.senan.xyz/gonic/server/assets"
"go.senan.xyz/gonic/server/ctrlbase"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/server/podcasts"
"go.senan.xyz/gonic/db"
"go.senan.xyz/gonic/podcasts"
)
type CtxKey int

View File

@@ -9,11 +9,11 @@ import (
"github.com/mmcdole/gofeed"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/server/scanner"
"go.senan.xyz/gonic/server/scrobble/lastfm"
"go.senan.xyz/gonic/server/scrobble/listenbrainz"
"go.senan.xyz/gonic/server/transcode"
"go.senan.xyz/gonic/db"
"go.senan.xyz/gonic/scanner"
"go.senan.xyz/gonic/scrobble/lastfm"
"go.senan.xyz/gonic/scrobble/listenbrainz"
"go.senan.xyz/gonic/transcode"
)
func doScan(scanner *scanner.Scanner, opts scanner.ScanOptions) {

View File

@@ -11,7 +11,7 @@ import (
"github.com/jinzhu/gorm"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/db"
)
var (

View File

@@ -8,7 +8,7 @@ import (
"github.com/gorilla/sessions"
"go.senan.xyz/gonic"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/db"
)
func (c *Controller) WithSession(next http.Handler) http.Handler {

View File

@@ -6,8 +6,8 @@ import (
"net/http"
"path"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/server/scanner"
"go.senan.xyz/gonic/db"
"go.senan.xyz/gonic/scanner"
)
type statusWriter struct {

View File

@@ -12,10 +12,10 @@ import (
"go.senan.xyz/gonic/server/ctrlbase"
"go.senan.xyz/gonic/server/ctrlsubsonic/params"
"go.senan.xyz/gonic/server/ctrlsubsonic/spec"
"go.senan.xyz/gonic/server/jukebox"
"go.senan.xyz/gonic/server/podcasts"
"go.senan.xyz/gonic/server/scrobble"
"go.senan.xyz/gonic/server/transcode"
"go.senan.xyz/gonic/jukebox"
"go.senan.xyz/gonic/podcasts"
"go.senan.xyz/gonic/scrobble"
"go.senan.xyz/gonic/transcode"
)
type CtxKey int

View File

@@ -18,9 +18,9 @@ import (
"go.senan.xyz/gonic/server/ctrlbase"
"go.senan.xyz/gonic/server/ctrlsubsonic/params"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/server/mockfs"
"go.senan.xyz/gonic/server/transcode"
"go.senan.xyz/gonic/db"
"go.senan.xyz/gonic/mockfs"
"go.senan.xyz/gonic/transcode"
)
var testCamelExpr = regexp.MustCompile("([a-z0-9])([A-Z])")

View File

@@ -9,7 +9,7 @@ import (
"go.senan.xyz/gonic/server/ctrlsubsonic/params"
"go.senan.xyz/gonic/server/ctrlsubsonic/spec"
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/db"
)
func (c *Controller) ServeGetBookmarks(r *http.Request) *spec.Response {

View File

@@ -9,7 +9,7 @@ import (
"go.senan.xyz/gonic/server/ctrlsubsonic/params"
"go.senan.xyz/gonic/server/ctrlsubsonic/spec"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/db"
)
// the subsonic spec mentions "artist" a lot when talking about the

View File

@@ -13,8 +13,8 @@ import (
"go.senan.xyz/gonic/server/ctrlsubsonic/params"
"go.senan.xyz/gonic/server/ctrlsubsonic/spec"
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/server/scrobble/lastfm"
"go.senan.xyz/gonic/db"
"go.senan.xyz/gonic/scrobble/lastfm"
)
func (c *Controller) ServeGetArtists(r *http.Request) *spec.Response {

View File

@@ -14,8 +14,8 @@ import (
"go.senan.xyz/gonic/server/ctrlsubsonic/params"
"go.senan.xyz/gonic/server/ctrlsubsonic/spec"
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/server/scanner"
"go.senan.xyz/gonic/db"
"go.senan.xyz/gonic/scanner"
)
func lowerUDecOrHash(in string) string {

View File

@@ -10,7 +10,7 @@ import (
"go.senan.xyz/gonic/server/ctrlsubsonic/params"
"go.senan.xyz/gonic/server/ctrlsubsonic/spec"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/db"
)
func playlistRender(c *Controller, playlist *db.Playlist) *spec.Playlist {

View File

@@ -8,7 +8,7 @@ import (
"go.senan.xyz/gonic/server/ctrlsubsonic/params"
"go.senan.xyz/gonic/server/ctrlsubsonic/spec"
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/db"
)
func (c *Controller) ServeGetPodcasts(r *http.Request) *spec.Response {

View File

@@ -17,8 +17,8 @@ import (
"go.senan.xyz/gonic/server/ctrlsubsonic/params"
"go.senan.xyz/gonic/server/ctrlsubsonic/spec"
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/server/transcode"
"go.senan.xyz/gonic/db"
"go.senan.xyz/gonic/transcode"
)
// "raw" handlers are ones that don't always return a spec response.

View File

@@ -12,8 +12,8 @@ import (
"time"
"github.com/matryer/is"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/server/transcode"
"go.senan.xyz/gonic/db"
"go.senan.xyz/gonic/transcode"
)
func TestServeStreamRaw(t *testing.T) {

View File

@@ -3,7 +3,7 @@ package spec
import (
"path"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/db"
)
func NewAlbumByFolder(f *db.Album) *Album {

View File

@@ -4,7 +4,7 @@ import (
"path"
"strings"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/db"
)
func NewAlbumByTags(a *db.Album, artist *db.Artist) *Album {

View File

@@ -1,6 +1,6 @@
package spec
import "go.senan.xyz/gonic/server/db"
import "go.senan.xyz/gonic/db"
func NewPodcastChannel(p *db.Podcast) *PodcastChannel {
ret := &PodcastChannel{

View File

@@ -1,139 +0,0 @@
package db
import (
"errors"
"fmt"
"log"
"net/url"
"os"
"strings"
"github.com/jinzhu/gorm"
)
func DefaultOptions() url.Values {
return url.Values{
// with this, multiple connections share a single data and schema cache.
// see https://www.sqlite.org/sharedcache.html
"cache": {"shared"},
// with this, the db sleeps for a little while when locked. can prevent
// a SQLITE_BUSY. see https://www.sqlite.org/c3ref/busy_timeout.html
"_busy_timeout": {"30000"},
"_journal_mode": {"WAL"},
"_foreign_keys": {"true"},
}
}
func mockOptions() url.Values {
return url.Values{
"_foreign_keys": {"true"},
}
}
type DB struct {
*gorm.DB
}
func New(path string, options url.Values) (*DB, error) {
// https://github.com/mattn/go-sqlite3#connection-string
url := url.URL{
Scheme: "file",
Opaque: path,
}
url.RawQuery = options.Encode()
db, err := gorm.Open("sqlite3", url.String())
if err != nil {
return nil, fmt.Errorf("with gorm: %w", err)
}
db.SetLogger(log.New(os.Stdout, "gorm ", 0))
db.DB().SetMaxOpenConns(1)
return &DB{DB: db}, nil
}
func NewMock() (*DB, error) {
return New(":memory:", mockOptions())
}
func (db *DB) GetSetting(key string) (string, error) {
setting := &Setting{}
if err := db.Where("key=?", key).First(setting).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return "", err
}
return setting.Value, nil
}
func (db *DB) SetSetting(key, value string) error {
return db.
Where(Setting{Key: key}).
Assign(Setting{Value: value}).
FirstOrCreate(&Setting{}).
Error
}
func (db *DB) InsertBulkLeftMany(table string, head []string, left int, col []int) error {
if len(col) == 0 {
return nil
}
var rows []string
var values []interface{}
for _, c := range col {
rows = append(rows, "(?, ?)")
values = append(values, left, c)
}
q := fmt.Sprintf("INSERT OR IGNORE INTO %q (%s) VALUES %s",
table,
strings.Join(head, ", "),
strings.Join(rows, ", "),
)
return db.Exec(q, values...).Error
}
func (db *DB) GetUserByID(id int) *User {
user := &User{}
err := db.
Where("id=?", id).
First(user).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return user
}
func (db *DB) GetUserByName(name string) *User {
user := &User{}
err := db.
Where("name=?", name).
First(user).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return user
}
func (db *DB) Begin() *DB {
return &DB{DB: db.DB.Begin()}
}
type ChunkFunc func(*gorm.DB, []int64) error
func (db *DB) TransactionChunked(data []int64, cb ChunkFunc) error {
if len(data) == 0 {
return nil
}
// https://sqlite.org/limits.html
const size = 999
return db.Transaction(func(tx *gorm.DB) error {
for i := 0; i < len(data); i += size {
end := i + size
if end > len(data) {
end = len(data)
}
if err := cb(tx, data[i:end]); err != nil {
return err
}
}
return nil
})
}

View File

@@ -1,52 +0,0 @@
package db
import (
"io"
"log"
"math/rand"
"os"
"testing"
_ "github.com/jinzhu/gorm/dialects/sqlite"
"github.com/matryer/is"
)
func randKey() string {
letters := []rune("abcdef0123456789")
b := make([]rune, 16)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}
func TestGetSetting(t *testing.T) {
key := randKey()
value := "howdy"
is := is.New(t)
testDB, err := NewMock()
if err != nil {
t.Fatalf("error creating db: %v", err)
}
if err := testDB.Migrate(MigrationContext{}); err != nil {
t.Fatalf("error migrating db: %v", err)
}
is.NoErr(testDB.SetSetting(key, value))
actual, err := testDB.GetSetting(key)
is.NoErr(err)
is.Equal(actual, value)
is.NoErr(testDB.SetSetting(key, value))
actual, err = testDB.GetSetting(key)
is.NoErr(err)
is.Equal(actual, value)
}
func TestMain(m *testing.M) {
log.SetOutput(io.Discard)
os.Exit(m.Run())
}

View File

@@ -1,334 +0,0 @@
package db
import (
"errors"
"fmt"
"log"
"github.com/jinzhu/gorm"
"gopkg.in/gormigrate.v1"
)
type MigrationContext struct {
OriginalMusicPath string
}
func (db *DB) Migrate(ctx MigrationContext) error {
options := &gormigrate.Options{
TableName: "migrations",
IDColumnName: "id",
IDColumnSize: 255,
UseTransaction: false,
}
// $ date '+%Y%m%d%H%M'
migrations := []*gormigrate.Migration{
construct(ctx, "202002192100", migrateInitSchema),
construct(ctx, "202002192019", migrateCreateInitUser),
construct(ctx, "202002192222", migrateMergePlaylist),
construct(ctx, "202003111222", migrateCreateTranscode),
construct(ctx, "202003121330", migrateAddGenre),
construct(ctx, "202003241509", migrateUpdateTranscodePrefIDX),
construct(ctx, "202004302006", migrateAddAlbumIDX),
construct(ctx, "202012151806", migrateMultiGenre),
construct(ctx, "202101081149", migrateListenBrainz),
construct(ctx, "202101111537", migratePodcast),
construct(ctx, "202102032210", migrateBookmarks),
construct(ctx, "202102191448", migratePodcastAutoDownload),
construct(ctx, "202110041330", migrateAlbumCreatedAt),
construct(ctx, "202111021951", migrateAlbumRootDir),
construct(ctx, "202201042236", migrateArtistGuessedFolder),
construct(ctx, "202202092013", migrateArtistCover),
construct(ctx, "202202121809", migrateAlbumRootDirAgain),
construct(ctx, "202202241218", migratePublicPlaylist),
}
return gormigrate.
New(db.DB, options, migrations).
Migrate()
}
func construct(ctx MigrationContext, id string, f func(*gorm.DB, MigrationContext) error) *gormigrate.Migration {
return &gormigrate.Migration{
ID: id,
Migrate: func(db *gorm.DB) error {
tx := db.Begin()
defer tx.Commit()
if err := f(tx, ctx); err != nil {
return fmt.Errorf("%q: %w", id, err)
}
log.Printf("migration '%s' finished", id)
return nil
},
Rollback: func(*gorm.DB) error {
return nil
},
}
}
func migrateInitSchema(tx *gorm.DB, _ MigrationContext) error {
return tx.AutoMigrate(
Genre{},
TrackGenre{},
AlbumGenre{},
Track{},
Artist{},
User{},
Setting{},
Play{},
Album{},
Playlist{},
PlayQueue{},
).
Error
}
func migrateCreateInitUser(tx *gorm.DB, _ MigrationContext) error {
const (
initUsername = "admin"
initPassword = "admin"
)
err := tx.
Where("name=?", initUsername).
First(&User{}).
Error
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return tx.Create(&User{
Name: initUsername,
Password: initPassword,
IsAdmin: true,
}).
Error
}
func migrateMergePlaylist(tx *gorm.DB, _ MigrationContext) error {
if !tx.HasTable("playlist_items") {
return nil
}
return tx.Exec(`
UPDATE playlists
SET items=( SELECT group_concat(track_id) FROM (
SELECT track_id
FROM playlist_items
WHERE playlist_items.playlist_id=playlists.id
ORDER BY created_at
) );
DROP TABLE playlist_items;`,
).
Error
}
func migrateCreateTranscode(tx *gorm.DB, _ MigrationContext) error {
return tx.AutoMigrate(
TranscodePreference{},
).
Error
}
func migrateAddGenre(tx *gorm.DB, _ MigrationContext) error {
return tx.AutoMigrate(
Genre{},
Album{},
Track{},
).
Error
}
func migrateUpdateTranscodePrefIDX(tx *gorm.DB, _ MigrationContext) error {
var hasIDX int
tx.
Select("1").
Table("sqlite_master").
Where("type = ?", "index").
Where("name = ?", "idx_user_id_client").
Count(&hasIDX)
if hasIDX == 1 {
// index already exists
return nil
}
step := tx.Exec(`
ALTER TABLE transcode_preferences RENAME TO transcode_preferences_orig;
`)
if err := step.Error; err != nil {
return fmt.Errorf("step rename: %w", err)
}
step = tx.AutoMigrate(
TranscodePreference{},
)
if err := step.Error; err != nil {
return fmt.Errorf("step create: %w", err)
}
step = tx.Exec(`
INSERT INTO transcode_preferences (user_id, client, profile)
SELECT user_id, client, profile
FROM transcode_preferences_orig;
DROP TABLE transcode_preferences_orig;
`)
if err := step.Error; err != nil {
return fmt.Errorf("step copy: %w", err)
}
return nil
}
func migrateAddAlbumIDX(tx *gorm.DB, _ MigrationContext) error {
return tx.AutoMigrate(
Album{},
).
Error
}
func migrateMultiGenre(tx *gorm.DB, _ MigrationContext) error {
step := tx.AutoMigrate(
Genre{},
TrackGenre{},
AlbumGenre{},
Track{},
Album{},
)
if err := step.Error; err != nil {
return fmt.Errorf("step auto migrate: %w", err)
}
var genreCount int
tx.
Model(Genre{}).
Count(&genreCount)
if genreCount == 0 {
return nil
}
step = tx.Exec(`
INSERT INTO track_genres (track_id, genre_id)
SELECT id, tag_genre_id
FROM tracks
WHERE tag_genre_id IS NOT NULL;
UPDATE tracks SET tag_genre_id=NULL;
`)
if err := step.Error; err != nil {
return fmt.Errorf("step migrate track genres: %w", err)
}
step = tx.Exec(`
INSERT INTO album_genres (album_id, genre_id)
SELECT id, tag_genre_id
FROM albums
WHERE tag_genre_id IS NOT NULL;
UPDATE albums SET tag_genre_id=NULL;
`)
if err := step.Error; err != nil {
return fmt.Errorf("step migrate album genres: %w", err)
}
return nil
}
func migrateListenBrainz(tx *gorm.DB, _ MigrationContext) error {
step := tx.AutoMigrate(
User{},
)
if err := step.Error; err != nil {
return fmt.Errorf("step auto migrate: %w", err)
}
return nil
}
func migratePodcast(tx *gorm.DB, _ MigrationContext) error {
return tx.AutoMigrate(
Podcast{},
PodcastEpisode{},
).
Error
}
func migrateBookmarks(tx *gorm.DB, _ MigrationContext) error {
return tx.AutoMigrate(
Bookmark{},
).
Error
}
func migratePodcastAutoDownload(tx *gorm.DB, _ MigrationContext) error {
return tx.AutoMigrate(
Podcast{},
).
Error
}
func migrateAlbumCreatedAt(tx *gorm.DB, _ MigrationContext) error {
step := tx.AutoMigrate(
Album{},
)
if err := step.Error; err != nil {
return fmt.Errorf("step auto migrate: %w", err)
}
step = tx.Exec(`
UPDATE albums SET created_at=modified_at;
`)
if err := step.Error; err != nil {
return fmt.Errorf("step migrate album created_at: %w", err)
}
return nil
}
func migrateAlbumRootDir(tx *gorm.DB, ctx MigrationContext) error {
step := tx.AutoMigrate(
Album{},
)
if err := step.Error; err != nil {
return fmt.Errorf("step auto migrate: %w", err)
}
step = tx.Exec(`
DROP INDEX IF EXISTS idx_left_path_right_path;
`)
if err := step.Error; err != nil {
return fmt.Errorf("step drop idx: %w", err)
}
step = tx.Exec(`
UPDATE albums SET root_dir=? WHERE root_dir IS NULL
`, ctx.OriginalMusicPath)
if err := step.Error; err != nil {
return fmt.Errorf("step drop idx: %w", err)
}
return nil
}
func migrateArtistGuessedFolder(tx *gorm.DB, ctx MigrationContext) error {
return tx.AutoMigrate(Artist{}).Error
}
func migrateArtistCover(tx *gorm.DB, ctx MigrationContext) error {
step := tx.AutoMigrate(
Artist{},
)
if err := step.Error; err != nil {
return fmt.Errorf("step auto migrate: %w", err)
}
if !tx.Dialect().HasColumn("artists", "guessed_folder_id") {
return nil
}
step = tx.Exec(`
ALTER TABLE artists DROP COLUMN guessed_folder_id
`)
if err := step.Error; err != nil {
return fmt.Errorf("step drop column: %w", err)
}
return nil
}
// there was an issue with that migration, try it again since it's updated
func migrateAlbumRootDirAgain(tx *gorm.DB, ctx MigrationContext) error {
return migrateAlbumRootDir(tx, ctx)
}
func migratePublicPlaylist(tx *gorm.DB, ctx MigrationContext) error {
return tx.AutoMigrate(Playlist{}).Error
}

View File

@@ -1,401 +0,0 @@
// Package db provides database helpers and models
//nolint:lll // struct tags get very long and can't be split
package db
// see this db fiddle to mess around with the schema
// https://www.db-fiddle.com/f/wJ7z8L7mu6ZKaYmWk1xr1p/5
import (
"path"
"path/filepath"
"strconv"
"strings"
"time"
// TODO: remove this dep
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
"go.senan.xyz/gonic/server/mime"
)
func splitInt(in, sep string) []int {
if in == "" {
return []int{}
}
parts := strings.Split(in, sep)
ret := make([]int, 0, len(parts))
for _, p := range parts {
i, _ := strconv.Atoi(p)
ret = append(ret, i)
}
return ret
}
func joinInt(in []int, sep string) string {
if in == nil {
return ""
}
strs := make([]string, 0, len(in))
for _, i := range in {
strs = append(strs, strconv.Itoa(i))
}
return strings.Join(strs, sep)
}
type Artist struct {
ID int `gorm:"primary_key"`
Name string `gorm:"not null; unique_index"`
NameUDec string `sql:"default: null"`
Albums []*Album `gorm:"foreignkey:TagArtistID"`
AlbumCount int `sql:"-"`
Cover string `sql:"default: null"`
}
func (a *Artist) SID() *specid.ID {
return &specid.ID{Type: specid.Artist, Value: a.ID}
}
func (a *Artist) IndexName() string {
if len(a.NameUDec) > 0 {
return a.NameUDec
}
return a.Name
}
type Genre struct {
ID int `gorm:"primary_key"`
Name string `gorm:"not null; unique_index"`
AlbumCount int `sql:"-"`
TrackCount int `sql:"-"`
}
// AudioFile is used to avoid some duplication in handlers_raw.go
// between Track and Podcast
type AudioFile interface {
Ext() string
MIME() string
AudioFilename() string
AudioBitrate() int
AudioLength() int
}
type Track struct {
ID int `gorm:"primary_key"`
CreatedAt time.Time
UpdatedAt time.Time
Filename string `gorm:"not null; unique_index:idx_folder_filename" sql:"default: null"`
FilenameUDec string `sql:"default: null"`
Album *Album
AlbumID int `gorm:"not null; unique_index:idx_folder_filename" sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"`
Artist *Artist
ArtistID int `gorm:"not null" sql:"default: null; type:int REFERENCES artists(id) ON DELETE CASCADE"`
Genres []*Genre `gorm:"many2many:track_genres"`
Size int `sql:"default: null"`
Length int `sql:"default: null"`
Bitrate int `sql:"default: null"`
TagTitle string `sql:"default: null"`
TagTitleUDec string `sql:"default: null"`
TagTrackArtist string `sql:"default: null"`
TagTrackNumber int `sql:"default: null"`
TagDiscNumber int `sql:"default: null"`
TagBrainzID string `sql:"default: null"`
}
func (t *Track) AudioLength() int { return t.Length }
func (t *Track) AudioBitrate() int { return t.Bitrate }
func (t *Track) SID() *specid.ID {
return &specid.ID{Type: specid.Track, Value: t.ID}
}
func (t *Track) AlbumSID() *specid.ID {
return &specid.ID{Type: specid.Album, Value: t.AlbumID}
}
func (t *Track) ArtistSID() *specid.ID {
return &specid.ID{Type: specid.Artist, Value: t.ArtistID}
}
func (t *Track) Ext() string {
longExt := path.Ext(t.Filename)
if len(longExt) < 1 {
return ""
}
return longExt[1:]
}
func (t *Track) AudioFilename() string {
return t.Filename
}
func (t *Track) MIME() string {
v, _ := mime.FromExtension(t.Ext())
return v
}
func (t *Track) AbsPath() string {
if t.Album == nil {
return ""
}
return path.Join(
t.Album.RootDir,
t.Album.LeftPath,
t.Album.RightPath,
t.Filename,
)
}
func (t *Track) RelPath() string {
if t.Album == nil {
return ""
}
return path.Join(
t.Album.LeftPath,
t.Album.RightPath,
t.Filename,
)
}
func (t *Track) GenreStrings() []string {
strs := make([]string, 0, len(t.Genres))
for _, genre := range t.Genres {
strs = append(strs, genre.Name)
}
return strs
}
type User struct {
ID int `gorm:"primary_key"`
CreatedAt time.Time
Name string `gorm:"not null; unique_index" sql:"default: null"`
Password string `gorm:"not null" sql:"default: null"`
LastFMSession string `sql:"default: null"`
ListenBrainzURL string `sql:"default: null"`
ListenBrainzToken string `sql:"default: null"`
IsAdmin bool `sql:"default: null"`
}
type Setting struct {
Key string `gorm:"not null; primary_key; auto_increment:false" sql:"default: null"`
Value string `sql:"default: null"`
}
type Play struct {
ID int `gorm:"primary_key"`
User *User
UserID int `gorm:"not null; index" sql:"default: null; type:int REFERENCES users(id) ON DELETE CASCADE"`
Album *Album
AlbumID int `gorm:"not null; index" sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"`
Time time.Time `sql:"default: null"`
Count int
}
type Album struct {
ID int `gorm:"primary_key"`
CreatedAt time.Time
UpdatedAt time.Time
ModifiedAt time.Time
LeftPath string `gorm:"unique_index:idx_album_abs_path"`
RightPath string `gorm:"not null; unique_index:idx_album_abs_path" sql:"default: null"`
RightPathUDec string `sql:"default: null"`
Parent *Album
ParentID int `sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"`
RootDir string `gorm:"unique_index:idx_album_abs_path" sql:"default: null"`
Genres []*Genre `gorm:"many2many:album_genres"`
Cover string `sql:"default: null"`
TagArtist *Artist
TagArtistID int `gorm:"index" sql:"default: null; type:int REFERENCES artists(id) ON DELETE CASCADE"`
TagTitle string `sql:"default: null"`
TagTitleUDec string `sql:"default: null"`
TagBrainzID string `sql:"default: null"`
TagYear int `sql:"default: null"`
Tracks []*Track
ChildCount int `sql:"-"`
Duration int `sql:"-"`
}
func (a *Album) SID() *specid.ID {
return &specid.ID{Type: specid.Album, Value: a.ID}
}
func (a *Album) ParentSID() *specid.ID {
return &specid.ID{Type: specid.Album, Value: a.ParentID}
}
func (a *Album) IndexRightPath() string {
if len(a.RightPathUDec) > 0 {
return a.RightPathUDec
}
return a.RightPath
}
func (a *Album) GenreStrings() []string {
strs := make([]string, 0, len(a.Genres))
for _, genre := range a.Genres {
strs = append(strs, genre.Name)
}
return strs
}
type Playlist struct {
ID int `gorm:"primary_key"`
CreatedAt time.Time
UpdatedAt time.Time
User *User
UserID int `sql:"default: null; type:int REFERENCES users(id) ON DELETE CASCADE"`
Name string
Comment string
TrackCount int
Items string
IsPublic bool `sql:"default: null"`
}
func (p *Playlist) GetItems() []int {
return splitInt(p.Items, ",")
}
func (p *Playlist) SetItems(items []int) {
p.Items = joinInt(items, ",")
p.TrackCount = len(items)
}
type PlayQueue struct {
ID int `gorm:"primary_key"`
CreatedAt time.Time
UpdatedAt time.Time
User *User
UserID int `sql:"default: null; type:int REFERENCES users(id) ON DELETE CASCADE"`
Current int
Position int
ChangedBy string
Items string
}
func (p *PlayQueue) CurrentSID() *specid.ID {
return &specid.ID{Type: specid.Track, Value: p.Current}
}
func (p *PlayQueue) GetItems() []int {
return splitInt(p.Items, ",")
}
func (p *PlayQueue) SetItems(items []int) {
p.Items = joinInt(items, ",")
}
type TranscodePreference struct {
User *User
UserID int `gorm:"not null; unique_index:idx_user_id_client" sql:"default: null; type:int REFERENCES users(id) ON DELETE CASCADE"`
Client string `gorm:"not null; unique_index:idx_user_id_client" sql:"default: null"`
Profile string `gorm:"not null" sql:"default: null"`
}
type TrackGenre struct {
Track *Track
TrackID int `gorm:"not null; unique_index:idx_track_id_genre_id" sql:"default: null; type:int REFERENCES tracks(id) ON DELETE CASCADE"`
Genre *Genre
GenreID int `gorm:"not null; unique_index:idx_track_id_genre_id" sql:"default: null; type:int REFERENCES genres(id) ON DELETE CASCADE"`
}
type AlbumGenre struct {
Album *Album
AlbumID int `gorm:"not null; unique_index:idx_album_id_genre_id" sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"`
Genre *Genre
GenreID int `gorm:"not null; unique_index:idx_album_id_genre_id" sql:"default: null; type:int REFERENCES genres(id) ON DELETE CASCADE"`
}
type PodcastAutoDownload string
const (
PodcastAutoDownloadLatest PodcastAutoDownload = "latest"
PodcastAutoDownloadNone PodcastAutoDownload = "none"
)
type Podcast struct {
ID int `gorm:"primary_key"`
UpdatedAt time.Time
ModifiedAt time.Time
UserID int `sql:"default: null; type:int REFERENCES users(id) ON DELETE CASCADE"`
URL string
Title string
Description string
ImageURL string
ImagePath string
Error string
Episodes []*PodcastEpisode
AutoDownload PodcastAutoDownload
}
func (p *Podcast) Fullpath(podcastPath string) string {
sanitizedTitle := strings.ReplaceAll(p.Title, "/", "_")
return filepath.Join(podcastPath, filepath.Clean(sanitizedTitle))
}
func (p *Podcast) SID() *specid.ID {
return &specid.ID{Type: specid.Podcast, Value: p.ID}
}
type PodcastEpisodeStatus string
const (
PodcastEpisodeStatusDownloading PodcastEpisodeStatus = "downloading"
PodcastEpisodeStatusSkipped PodcastEpisodeStatus = "skipped"
PodcastEpisodeStatusDeleted PodcastEpisodeStatus = "deleted"
PodcastEpisodeStatusCompleted PodcastEpisodeStatus = "completed"
PodcastEpisodeStatusError PodcastEpisodeStatus = "error"
)
type PodcastEpisode struct {
ID int `gorm:"primary_key"`
CreatedAt time.Time
UpdatedAt time.Time
ModifiedAt time.Time
PodcastID int `gorm:"not null" sql:"default: null; type:int REFERENCES podcasts(id) ON DELETE CASCADE"`
Title string
Description string
PublishDate *time.Time
AudioURL string
Bitrate int
Length int
Size int
Path string
Filename string
Status PodcastEpisodeStatus
Error string
}
func (pe *PodcastEpisode) AudioLength() int { return pe.Length }
func (pe *PodcastEpisode) AudioBitrate() int { return pe.Bitrate }
func (pe *PodcastEpisode) SID() *specid.ID {
return &specid.ID{Type: specid.PodcastEpisode, Value: pe.ID}
}
func (pe *PodcastEpisode) AudioFilename() string {
return pe.Filename
}
func (pe *PodcastEpisode) Ext() string {
longExt := path.Ext(pe.Filename)
if len(longExt) < 1 {
return ""
}
return longExt[1:]
}
func (pe *PodcastEpisode) MIME() string {
v, _ := mime.FromExtension(pe.Ext())
return v
}
type Bookmark struct {
ID int `gorm:"primary_key"`
User *User
UserID int `sql:"default: null; type:int REFERENCES users(id) ON DELETE CASCADE"`
Position int
Comment string
EntryIDType string
EntryID int
CreatedAt time.Time
UpdatedAt time.Time
}

View File

@@ -1,205 +0,0 @@
// author: AlexKraak (https://github.com/alexkraak/)
package jukebox
import (
"fmt"
"log"
"os"
"sync"
"time"
"github.com/faiface/beep"
"github.com/faiface/beep/flac"
"github.com/faiface/beep/mp3"
"github.com/faiface/beep/speaker"
"go.senan.xyz/gonic/server/db"
)
type Status struct {
CurrentIndex int
Playing bool
Gain float64
Position int
}
type Jukebox struct {
playlist []*db.Track
index int
playing bool
sr beep.SampleRate
// used to notify the player to re read the members
quit chan struct{}
done chan bool
info *strmInfo
speaker chan updateSpeaker
sync.Mutex
}
type strmInfo struct {
ctrlStrmr beep.Ctrl
strm beep.StreamSeekCloser
format beep.Format
}
type updateSpeaker struct {
index int
offset int
}
func New() *Jukebox {
return &Jukebox{
sr: beep.SampleRate(48000),
speaker: make(chan updateSpeaker, 1),
done: make(chan bool),
quit: make(chan struct{}),
}
}
func (j *Jukebox) Listen() error {
if err := speaker.Init(j.sr, j.sr.N(time.Second/2)); err != nil {
return fmt.Errorf("initing speaker: %w", err)
}
for {
select {
case <-j.quit:
return nil
case speaker := <-j.speaker:
if err := j.doUpdateSpeaker(speaker); err != nil {
log.Printf("error in jukebox: %v", err)
}
}
}
}
func (j *Jukebox) Quit() {
j.quit <- struct{}{}
}
func (j *Jukebox) doUpdateSpeaker(su updateSpeaker) error {
j.Lock()
defer j.Unlock()
if su.index >= len(j.playlist) {
j.playing = false
speaker.Clear()
return nil
}
j.index = su.index
f, err := os.Open(j.playlist[su.index].AbsPath())
if err != nil {
return err
}
var streamer beep.Streamer
var format beep.Format
switch j.playlist[su.index].Ext() {
case "mp3":
streamer, format, err = mp3.Decode(f)
case "flac":
streamer, format, err = flac.Decode(f)
}
if err != nil {
return err
}
j.info = &strmInfo{}
j.info.strm = streamer.(beep.StreamSeekCloser)
if su.offset != 0 {
samples := format.SampleRate.N(time.Second * time.Duration(su.offset))
if err := j.info.strm.Seek(samples); err != nil {
return err
}
}
j.info.ctrlStrmr.Streamer = beep.Resample(
4, format.SampleRate,
j.sr, j.info.strm,
)
j.info.format = format
speaker.Play(beep.Seq(&j.info.ctrlStrmr, beep.Callback(func() {
j.speaker <- updateSpeaker{index: su.index + 1}
})))
return nil
}
func (j *Jukebox) SetTracks(tracks []*db.Track) {
j.Lock()
defer j.Unlock()
j.playlist = tracks
}
func (j *Jukebox) AddTracks(tracks []*db.Track) {
j.Lock()
if len(j.playlist) == 0 {
j.playlist = tracks
j.playing = true
j.index = 0
j.Unlock()
j.speaker <- updateSpeaker{index: 0}
return
}
j.playlist = append(j.playlist, tracks...)
j.Unlock()
}
func (j *Jukebox) RemoveTrack(i int) {
j.Lock()
defer j.Unlock()
if i < 0 || i >= len(j.playlist) {
return
}
j.playlist = append(j.playlist[:i], j.playlist[i+1:]...)
}
func (j *Jukebox) Skip(i int, offset int) {
speaker.Clear()
j.Lock()
j.index = i
j.playing = true
j.Unlock()
j.speaker <- updateSpeaker{index: j.index, offset: offset}
}
func (j *Jukebox) ClearTracks() {
speaker.Clear()
j.Lock()
defer j.Unlock()
j.playing = false
j.playlist = []*db.Track{}
}
func (j *Jukebox) Stop() {
j.Lock()
defer j.Unlock()
if j.info != nil {
j.playing = false
j.info.ctrlStrmr.Paused = true
}
}
func (j *Jukebox) Start() {
if j.info != nil {
j.playing = true
j.info.ctrlStrmr.Paused = false
}
}
func (j *Jukebox) GetStatus() Status {
j.Lock()
defer j.Unlock()
position := 0
if j.info != nil {
length := j.info.format.SampleRate.D(j.info.strm.Position())
position = int(length.Round(time.Millisecond).Seconds())
}
return Status{
CurrentIndex: j.index,
Playing: j.playing,
Gain: 0.9,
Position: position,
}
}
func (j *Jukebox) GetTracks() []*db.Track {
j.Lock()
defer j.Unlock()
return j.playlist
}

View File

@@ -1,19 +0,0 @@
package mime
// this package is at such a high level in the hierarchy because
// it's used by both `server/db` and `server/scanner`
func FromExtension(ext string) (string, bool) {
types := map[string]string{
"mp3": "audio/mpeg",
"flac": "audio/x-flac",
"aac": "audio/x-aac",
"m4a": "audio/m4a",
"m4b": "audio/m4b",
"ogg": "audio/ogg",
"opus": "audio/ogg",
"wma": "audio/x-ms-wma",
}
v, ok := types[ext]
return v, ok
}

View File

@@ -1,398 +0,0 @@
package mockfs
import (
"context"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/mattn/go-sqlite3"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/server/scanner"
"go.senan.xyz/gonic/server/scanner/tags"
)
var ErrPathNotFound = errors.New("path not found")
type MockFS struct {
t testing.TB
scanner *scanner.Scanner
dir string
tagReader *tagReader
db *db.DB
}
func New(t testing.TB) *MockFS { return new(t, []string{""}) }
func NewWithDirs(t testing.TB, dirs []string) *MockFS { return new(t, dirs) }
func new(t testing.TB, dirs []string) *MockFS {
dbc, err := db.NewMock()
if err != nil {
t.Fatalf("create db: %v", err)
}
t.Cleanup(func() {
if err := dbc.Close(); err != nil {
t.Fatalf("close db: %v", err)
}
})
if err := dbc.Migrate(db.MigrationContext{}); err != nil {
t.Fatalf("migrate db db: %v", err)
}
dbc.LogMode(false)
tmpDir := t.TempDir()
var absDirs []string
for _, dir := range dirs {
absDirs = append(absDirs, filepath.Join(tmpDir, dir))
}
for _, absDir := range absDirs {
if err := os.MkdirAll(absDir, os.ModePerm); err != nil {
t.Fatalf("mk abs dir: %v", err)
}
}
tagReader := &tagReader{paths: map[string]*tagReaderResult{}}
scanner := scanner.New(absDirs, dbc, ";", tagReader)
return &MockFS{
t: t,
scanner: scanner,
dir: tmpDir,
tagReader: tagReader,
db: dbc,
}
}
func (m *MockFS) DB() *db.DB { return m.db }
func (m *MockFS) TmpDir() string { return m.dir }
func (m *MockFS) ScanAndClean() *scanner.Context {
ctx, err := m.scanner.ScanAndClean(scanner.ScanOptions{})
if err != nil {
m.t.Fatalf("error scan and cleaning: %v", err)
}
return ctx
}
func (m *MockFS) ScanAndCleanErr() (*scanner.Context, error) {
return m.scanner.ScanAndClean(scanner.ScanOptions{})
}
func (m *MockFS) ResetDates() {
t := time.Date(2020, 0, 0, 0, 0, 0, 0, time.UTC)
if err := m.db.Model(db.Album{}).Updates(db.Album{CreatedAt: t, UpdatedAt: t, ModifiedAt: t}).Error; err != nil {
m.t.Fatalf("reset album times: %v", err)
}
if err := m.db.Model(db.Track{}).Updates(db.Track{CreatedAt: t, UpdatedAt: t}).Error; err != nil {
m.t.Fatalf("reset track times: %v", err)
}
}
func (m *MockFS) AddItems() { m.addItems("", false) }
func (m *MockFS) AddItemsPrefix(prefix string) { m.addItems(prefix, false) }
func (m *MockFS) AddItemsWithCovers() { m.addItems("", true) }
func (m *MockFS) AddItemsPrefixWithCovers(prefix string) { m.addItems(prefix, true) }
func (m *MockFS) addItems(prefix string, covers bool) {
p := func(format string, a ...interface{}) string {
return filepath.Join(prefix, fmt.Sprintf(format, a...))
}
for ar := 0; ar < 3; ar++ {
for al := 0; al < 3; al++ {
for tr := 0; tr < 3; tr++ {
m.AddTrack(p("artist-%d/album-%d/track-%d.flac", ar, al, tr))
m.SetTags(p("artist-%d/album-%d/track-%d.flac", ar, al, tr), func(tags *Tags) error {
tags.RawArtist = fmt.Sprintf("artist-%d", ar)
tags.RawAlbumArtist = fmt.Sprintf("artist-%d", ar)
tags.RawAlbum = fmt.Sprintf("album-%d", al)
tags.RawTitle = fmt.Sprintf("title-%d", tr)
return nil
})
}
if covers {
m.AddCover(p("artist-%d/album-%d/cover.png", ar, al))
}
}
}
}
func (m *MockFS) NumTracks() int {
return len(m.tagReader.paths)
}
func (m *MockFS) RemoveAll(path string) {
abspath := filepath.Join(m.dir, path)
if err := os.RemoveAll(abspath); err != nil {
m.t.Fatalf("remove all: %v", err)
}
}
func (m *MockFS) Symlink(src, dest string) {
if err := os.MkdirAll(filepath.Dir(dest), os.ModePerm); err != nil {
m.t.Fatalf("mkdir: %v", err)
}
if err := os.Symlink(src, dest); err != nil {
m.t.Fatalf("symlink: %v", err)
}
src = filepath.Clean(src)
dest = filepath.Clean(dest)
for k, v := range m.tagReader.paths {
m.tagReader.paths[strings.Replace(k, src, dest, 1)] = v
}
}
func (m *MockFS) SetRealAudio(path string, length int, audioPath string) {
abspath := filepath.Join(m.dir, path)
if err := os.Remove(abspath); err != nil {
m.t.Fatalf("remove all: %v", err)
}
wd, _ := os.Getwd()
if err := os.Symlink(filepath.Join(wd, audioPath), abspath); err != nil {
m.t.Fatalf("symlink: %v", err)
}
m.SetTags(path, func(tags *Tags) error {
tags.RawLength = length
tags.RawBitrate = 0
return nil
})
}
func (m *MockFS) LogItems() {
m.t.Logf("\nitems")
var items int
err := filepath.WalkDir(m.dir, func(path string, info os.DirEntry, err error) error {
if err != nil {
return err
}
switch info.Type() {
case os.ModeSymlink:
m.t.Logf("item %q [sym]", path)
default:
m.t.Logf("item %q", path)
}
items++
return nil
})
if err != nil {
m.t.Fatalf("error logging items: %v", err)
}
m.t.Logf("total %d", items)
}
func (m *MockFS) LogAlbums() {
var albums []*db.Album
if err := m.db.Find(&albums).Error; err != nil {
m.t.Fatalf("error logging items: %v", err)
}
m.t.Logf("\nalbums")
for _, album := range albums {
m.t.Logf("id %-3d root %-3s lr %-15s %-10s pid %-3d aid %-3d cov %-10s",
album.ID, album.RootDir, album.LeftPath, album.RightPath, album.ParentID, album.TagArtistID, album.Cover)
}
m.t.Logf("total %d", len(albums))
}
func (m *MockFS) LogArtists() {
var artists []*db.Artist
if err := m.db.Find(&artists).Error; err != nil {
m.t.Fatalf("error logging items: %v", err)
}
m.t.Logf("\nartists")
for _, artist := range artists {
m.t.Logf("id %-3d %-10s", artist.ID, artist.Name)
}
m.t.Logf("total %d", len(artists))
}
func (m *MockFS) LogTracks() {
var tracks []*db.Track
if err := m.db.Find(&tracks).Error; err != nil {
m.t.Fatalf("error logging items: %v", err)
}
m.t.Logf("\ntracks")
for _, track := range tracks {
m.t.Logf("id %-3d aid %-3d filename %-10s tagtitle %-10s",
track.ID, track.AlbumID, track.Filename, track.TagTitle)
}
m.t.Logf("total %d", len(tracks))
}
func (m *MockFS) LogTrackGenres() {
var tgs []*db.TrackGenre
if err := m.db.Find(&tgs).Error; err != nil {
m.t.Fatalf("error logging items: %v", err)
}
m.t.Logf("\ntrack genres")
for _, tg := range tgs {
m.t.Logf("tid %-3d gid %-3d", tg.TrackID, tg.GenreID)
}
m.t.Logf("total %d", len(tgs))
}
func (m *MockFS) AddTrack(path string) {
abspath := filepath.Join(m.dir, path)
dir := filepath.Dir(abspath)
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
m.t.Fatalf("mkdir: %v", err)
}
f, err := os.Create(abspath)
if err != nil {
m.t.Fatalf("create track: %v", err)
}
defer f.Close()
}
func (m *MockFS) AddCover(path string) {
abspath := filepath.Join(m.dir, path)
if err := os.MkdirAll(filepath.Dir(abspath), os.ModePerm); err != nil {
m.t.Fatalf("mkdir: %v", err)
}
f, err := os.Create(abspath)
if err != nil {
m.t.Fatalf("create cover: %v", err)
}
defer f.Close()
}
func (m *MockFS) SetTags(path string, cb func(*Tags) error) {
abspath := filepath.Join(m.dir, path)
if err := os.Chtimes(abspath, time.Time{}, time.Now()); err != nil {
m.t.Fatalf("touch track: %v", err)
}
r := m.tagReader
if _, ok := r.paths[abspath]; !ok {
r.paths[abspath] = &tagReaderResult{tags: &Tags{}}
}
if err := cb(r.paths[abspath].tags); err != nil {
r.paths[abspath].err = err
}
}
func (m *MockFS) DumpDB(suffix ...string) {
var p []string
p = append(p,
"gonic", "dump",
strings.ReplaceAll(m.t.Name(), string(filepath.Separator), "-"),
fmt.Sprint(time.Now().UnixNano()),
)
p = append(p, suffix...)
destPath := filepath.Join(os.TempDir(), strings.Join(p, "-"))
dest, err := db.New(destPath, url.Values{})
if err != nil {
m.t.Fatalf("create dest db: %v", err)
}
defer dest.Close()
connSrc, err := m.db.DB.DB().Conn(context.Background())
if err != nil {
m.t.Fatalf("getting src raw conn: %v", err)
}
defer connSrc.Close()
connDest, err := dest.DB.DB().Conn(context.Background())
if err != nil {
m.t.Fatalf("getting dest raw conn: %v", err)
}
defer connDest.Close()
err = connDest.Raw(func(connDest interface{}) error {
return connSrc.Raw(func(connSrc interface{}) error {
connDestq := connDest.(*sqlite3.SQLiteConn)
connSrcq := connSrc.(*sqlite3.SQLiteConn)
bk, err := connDestq.Backup("main", connSrcq, "main")
if err != nil {
return fmt.Errorf("create backup db: %w", err)
}
for done, _ := bk.Step(-1); !done; {
m.t.Logf("dumping db...")
}
if err := bk.Finish(); err != nil {
return fmt.Errorf("finishing dump: %w", err)
}
return nil
})
})
if err != nil {
m.t.Fatalf("backing up: %v", err)
}
}
type tagReaderResult struct {
tags *Tags
err error
}
type tagReader struct {
paths map[string]*tagReaderResult
}
func (m *tagReader) Read(abspath string) (tags.Parser, error) {
p, ok := m.paths[abspath]
if !ok {
return nil, ErrPathNotFound
}
return p.tags, p.err
}
var _ tags.Reader = (*tagReader)(nil)
type Tags struct {
RawTitle string
RawArtist string
RawAlbum string
RawAlbumArtist string
RawGenre string
RawBitrate int
RawLength int
}
func (m *Tags) Title() string { return m.RawTitle }
func (m *Tags) BrainzID() string { return "" }
func (m *Tags) Artist() string { return m.RawArtist }
func (m *Tags) Album() string { return m.RawAlbum }
func (m *Tags) AlbumArtist() string { return m.RawAlbumArtist }
func (m *Tags) AlbumBrainzID() string { return "" }
func (m *Tags) Genre() string { return m.RawGenre }
func (m *Tags) TrackNumber() int { return 1 }
func (m *Tags) DiscNumber() int { return 1 }
func (m *Tags) Year() int { return 2021 }
func (m *Tags) Length() int { return firstInt(100, m.RawLength) }
func (m *Tags) Bitrate() int { return firstInt(100, m.RawBitrate) }
func (m *Tags) SomeAlbum() string { return first("Unknown Album", m.Album()) }
func (m *Tags) SomeArtist() string { return first("Unknown Artist", m.Artist()) }
func (m *Tags) SomeAlbumArtist() string { return first("Unknown Artist", m.AlbumArtist(), m.Artist()) }
func (m *Tags) SomeGenre() string { return first("Unknown Genre", m.Genre()) }
var _ tags.Parser = (*Tags)(nil)
func first(or string, strs ...string) string {
for _, str := range strs {
if str != "" {
return str
}
}
return or
}
func firstInt(or int, ints ...int) int {
for _, int := range ints {
if int > 0 {
return int
}
}
return or
}

View File

@@ -1,524 +0,0 @@
package podcasts
import (
"errors"
"fmt"
"io"
"log"
"mime"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/jinzhu/gorm"
"github.com/mmcdole/gofeed"
"go.senan.xyz/gonic/multierr"
"go.senan.xyz/gonic/server/db"
gmime "go.senan.xyz/gonic/server/mime"
"go.senan.xyz/gonic/server/scanner/tags"
)
const downloadAllWaitInterval = 3 * time.Second
type Podcasts struct {
db *db.DB
baseDir string
tagger tags.Reader
}
func New(db *db.DB, base string, tagger tags.Reader) *Podcasts {
return &Podcasts{
db: db,
baseDir: base,
tagger: tagger,
}
}
func (p *Podcasts) GetPodcastOrAll(userID int, id int, includeEpisodes bool) ([]*db.Podcast, error) {
podcasts := []*db.Podcast{}
q := p.db.Where("user_id=?", userID)
if id != 0 {
q = q.Where("id=?", id)
}
if err := q.Find(&podcasts).Error; err != nil {
return nil, fmt.Errorf("finding podcasts: %w", err)
}
if !includeEpisodes {
return podcasts, nil
}
for _, c := range podcasts {
episodes, err := p.GetPodcastEpisodes(c.ID)
if err != nil {
return nil, fmt.Errorf("finding podcast episodes: %w", err)
}
c.Episodes = episodes
}
return podcasts, nil
}
func (p *Podcasts) GetPodcastEpisodes(podcastID int) ([]*db.PodcastEpisode, error) {
episodes := []*db.PodcastEpisode{}
err := p.db.
Where("podcast_id=?", podcastID).
Order("publish_date DESC").
Find(&episodes).
Error
if err != nil {
return nil, fmt.Errorf("find episodes by podcast id: %w", err)
}
return episodes, nil
}
func (p *Podcasts) AddNewPodcast(rssURL string, feed *gofeed.Feed,
userID int) (*db.Podcast, error) {
podcast := db.Podcast{
Description: feed.Description,
ImageURL: feed.Image.URL,
UserID: userID,
Title: feed.Title,
URL: rssURL,
}
podPath := podcast.Fullpath(p.baseDir)
err := os.Mkdir(podPath, 0755)
if err != nil && !os.IsExist(err) {
return nil, err
}
if err := p.db.Save(&podcast).Error; err != nil {
return &podcast, err
}
if err := p.AddNewEpisodes(&podcast, feed.Items); err != nil {
return nil, err
}
go func() {
if err := p.downloadPodcastCover(podPath, &podcast); err != nil {
log.Printf("error downloading podcast cover: %v", err)
}
}()
return &podcast, nil
}
func (p *Podcasts) SetAutoDownload(podcastID int, setting db.PodcastAutoDownload) error {
podcast := db.Podcast{}
err := p.db.
Where("id=?", podcastID).
First(&podcast).
Error
if err != nil {
return err
}
podcast.AutoDownload = setting
if err := p.db.Save(&podcast).Error; err != nil {
return fmt.Errorf("save setting: %w", err)
}
return nil
}
func getEntriesAfterDate(feed []*gofeed.Item, after time.Time) []*gofeed.Item {
items := []*gofeed.Item{}
for _, item := range feed {
if item.PublishedParsed.Before(after) || item.PublishedParsed.Equal(after) {
continue
}
items = append(items, item)
}
return items
}
func (p *Podcasts) AddNewEpisodes(podcast *db.Podcast, items []*gofeed.Item) error {
podcastEpisode := db.PodcastEpisode{}
err := p.db.
Where("podcast_id=?", podcast.ID).
Order("publish_date DESC").
First(&podcastEpisode).Error
itemFound := true
if errors.Is(err, gorm.ErrRecordNotFound) {
itemFound = false
} else if err != nil {
return err
}
if !itemFound {
for _, item := range items {
if _, err := p.AddEpisode(podcast.ID, item); err != nil {
return err
}
}
return nil
}
for _, item := range getEntriesAfterDate(items, *podcastEpisode.PublishDate) {
episode, err := p.AddEpisode(podcast.ID, item)
if err != nil {
return err
}
if podcast.AutoDownload == db.PodcastAutoDownloadLatest &&
(episode.Status != db.PodcastEpisodeStatusCompleted && episode.Status != db.PodcastEpisodeStatusDownloading) {
if err := p.DownloadEpisode(episode.ID); err != nil {
return err
}
}
}
return nil
}
func getSecondsFromString(time string) int {
duration, err := strconv.Atoi(time)
if err == nil {
return duration
}
splitTime := strings.Split(time, ":")
if len(splitTime) == 3 {
hours, _ := strconv.Atoi(splitTime[0])
minutes, _ := strconv.Atoi(splitTime[1])
seconds, _ := strconv.Atoi(splitTime[2])
return (3600 * hours) + (60 * minutes) + seconds
}
if len(splitTime) == 2 {
minutes, _ := strconv.Atoi(splitTime[0])
seconds, _ := strconv.Atoi(splitTime[1])
return (60 * minutes) + seconds
}
return 0
}
func (p *Podcasts) AddEpisode(podcastID int, item *gofeed.Item) (*db.PodcastEpisode, error) {
duration := 0
// if it has the media extension use it
for _, content := range item.Extensions["media"]["content"] {
durationExt := content.Attrs["duration"]
duration = getSecondsFromString(durationExt)
if duration != 0 {
break
}
}
// if the itunes extension is available, use AddEpisode
if duration == 0 && item.ITunesExt != nil {
duration = getSecondsFromString(item.ITunesExt.Duration)
}
if episode, ok := p.findEnclosureAudio(podcastID, duration, item); ok {
if err := p.db.Save(episode).Error; err != nil {
return nil, err
}
return episode, nil
}
if episode, ok := p.findMediaAudio(podcastID, duration, item); ok {
if err := p.db.Save(episode).Error; err != nil {
return nil, err
}
return episode, nil
}
// hopefully shouldnt reach here
log.Println("failed to find audio in feed item, skipping")
return nil, nil
}
func isAudio(mediaType, url string) bool {
if mediaType != "" && strings.HasPrefix(mediaType, "audio") {
return true
}
_, ok := gmime.FromExtension(filepath.Ext(url)[1:])
return ok
}
func itemToEpisode(podcastID, size, duration int, audio string,
item *gofeed.Item) *db.PodcastEpisode {
return &db.PodcastEpisode{
PodcastID: podcastID,
Description: item.Description,
Title: item.Title,
Length: duration,
Size: size,
PublishDate: item.PublishedParsed,
AudioURL: audio,
Status: db.PodcastEpisodeStatusSkipped,
}
}
func (p *Podcasts) findEnclosureAudio(podcastID, duration int,
item *gofeed.Item) (*db.PodcastEpisode, bool) {
for _, enc := range item.Enclosures {
if !isAudio(enc.Type, enc.URL) {
continue
}
size, _ := strconv.Atoi(enc.Length)
return itemToEpisode(podcastID, size, duration, enc.URL, item), true
}
return nil, false
}
func (p *Podcasts) findMediaAudio(podcastID, duration int,
item *gofeed.Item) (*db.PodcastEpisode, bool) {
extensions, ok := item.Extensions["media"]["content"]
if !ok {
return nil, false
}
for _, ext := range extensions {
if !isAudio(ext.Attrs["type"], ext.Attrs["url"]) {
continue
}
return itemToEpisode(podcastID, 0, duration, ext.Attrs["url"], item), true
}
return nil, false
}
func (p *Podcasts) RefreshPodcasts() error {
podcasts := []*db.Podcast{}
if err := p.db.Find(&podcasts).Error; err != nil {
return fmt.Errorf("find podcasts: %w", err)
}
var errs *multierr.Err
if errors.As(p.refreshPodcasts(podcasts), &errs) && errs.Len() > 0 {
return fmt.Errorf("refresh podcasts: %w", errs)
}
return nil
}
func (p *Podcasts) RefreshPodcastsForUser(userID int) error {
podcasts := []*db.Podcast{}
err := p.db.
Where("user_id=?", userID).
Find(&podcasts).
Error
if err != nil {
return fmt.Errorf("find podcasts: %w", err)
}
var errs *multierr.Err
if errors.As(p.refreshPodcasts(podcasts), &errs) && errs.Len() > 0 {
return fmt.Errorf("refresh podcasts: %w", errs)
}
return nil
}
func (p *Podcasts) refreshPodcasts(podcasts []*db.Podcast) error {
errs := &multierr.Err{}
for _, podcast := range podcasts {
fp := gofeed.NewParser()
feed, err := fp.ParseURL(podcast.URL)
if err != nil {
errs.Add(fmt.Errorf("refreshing podcast with url %q: %w", podcast.URL, err))
continue
}
if err = p.AddNewEpisodes(podcast, feed.Items); err != nil {
errs.Add(fmt.Errorf("adding episodes: %w", err))
continue
}
}
return errs
}
func (p *Podcasts) DownloadPodcastAll(podcastID int) error {
podcastEpisodes := []db.PodcastEpisode{}
err := p.db.
Where("podcast_id=?", podcastID).
Find(&podcastEpisodes).
Error
if err != nil {
return fmt.Errorf("get episodes by podcast id: %w", err)
}
go func() {
for _, episode := range podcastEpisodes {
if episode.Status == db.PodcastEpisodeStatusDownloading || episode.Status == db.PodcastEpisodeStatusCompleted {
log.Println("skipping episode is in progress or already downloaded")
continue
}
if err := p.DownloadEpisode(episode.ID); err != nil {
log.Printf("error downloading episode: %v", err)
continue
}
log.Printf("finished downloading episode: %q", episode.Title)
time.Sleep(downloadAllWaitInterval)
}
}()
return nil
}
func (p *Podcasts) DownloadEpisode(episodeID int) error {
podcastEpisode := db.PodcastEpisode{}
podcast := db.Podcast{}
err := p.db.
Where("id=?", episodeID).
First(&podcastEpisode).
Error
if err != nil {
return fmt.Errorf("get podcast episode by id: %w", err)
}
err = p.db.
Where("id=?", podcastEpisode.PodcastID).
First(&podcast).
Error
if err != nil {
return fmt.Errorf("get podcast by id: %w", err)
}
if podcastEpisode.Status == db.PodcastEpisodeStatusDownloading {
log.Printf("Already downloading podcast episode with id %d", episodeID)
return nil
}
podcastEpisode.Status = db.PodcastEpisodeStatusDownloading
p.db.Save(&podcastEpisode)
// nolint: bodyclose
resp, err := http.Get(podcastEpisode.AudioURL)
if err != nil {
return fmt.Errorf("fetch podcast audio: %w", err)
}
filename, ok := getContentDispositionFilename(resp.Header.Get("content-disposition"))
if !ok {
audioURL, err := url.Parse(podcastEpisode.AudioURL)
if err != nil {
return fmt.Errorf("parse podcast audio url: %w", err)
}
filename = path.Base(audioURL.Path)
}
filename = p.findUniqueEpisodeName(&podcast, &podcastEpisode, filename)
audioFile, err := os.Create(path.Join(podcast.Fullpath(p.baseDir), filename))
if err != nil {
return fmt.Errorf("create audio file: %w", err)
}
podcastEpisode.Filename = filename
sanTitle := strings.ReplaceAll(podcast.Title, "/", "_")
podcastEpisode.Path = path.Join(sanTitle, filename)
p.db.Save(&podcastEpisode)
go func() {
if err := p.doPodcastDownload(&podcastEpisode, audioFile, resp.Body); err != nil {
log.Printf("error downloading podcast: %v", err)
}
}()
return nil
}
func (p *Podcasts) findUniqueEpisodeName(
podcast *db.Podcast,
podcastEpisode *db.PodcastEpisode,
filename string) string {
podcastPath := path.Join(podcast.Fullpath(p.baseDir), filename)
if _, err := os.Stat(podcastPath); os.IsNotExist(err) {
return filename
}
sanitizedTitle := strings.ReplaceAll(podcastEpisode.Title, "/", "_")
titlePath := fmt.Sprintf("%s%s", sanitizedTitle, filepath.Ext(filename))
podcastPath = path.Join(podcast.Fullpath(p.baseDir), titlePath)
if _, err := os.Stat(podcastPath); os.IsNotExist(err) {
return titlePath
}
// try to find a filename like FILENAME (1).mp3 incrementing
return findEpisode(podcast.Fullpath(p.baseDir), filename, 1)
}
func findEpisode(base, filename string, count int) string {
noExt := strings.TrimSuffix(filename, filepath.Ext(filename))
testFile := fmt.Sprintf("%s (%d)%s", noExt, count, filepath.Ext(filename))
podcastPath := path.Join(base, testFile)
if _, err := os.Stat(podcastPath); os.IsNotExist(err) {
return testFile
}
return findEpisode(base, filename, count+1)
}
func getContentDispositionFilename(header string) (string, bool) {
_, params, _ := mime.ParseMediaType(header)
filename, ok := params["filename"]
return filename, ok
}
func (p *Podcasts) downloadPodcastCover(podPath string, podcast *db.Podcast) error {
imageURL, err := url.Parse(podcast.ImageURL)
if err != nil {
return fmt.Errorf("parse image url: %w", err)
}
ext := path.Ext(imageURL.Path)
resp, err := http.Get(podcast.ImageURL)
if err != nil {
return fmt.Errorf("fetch image url: %w", err)
}
defer resp.Body.Close()
if ext == "" {
contentHeader := resp.Header.Get("content-disposition")
filename, _ := getContentDispositionFilename(contentHeader)
ext = path.Ext(filename)
}
coverPath := path.Join(podPath, "cover"+ext)
coverFile, err := os.Create(coverPath)
if err != nil {
return fmt.Errorf("creating podcast cover: %w", err)
}
defer coverFile.Close()
if _, err := io.Copy(coverFile, resp.Body); err != nil {
return fmt.Errorf("writing podcast cover: %w", err)
}
podcastPath := filepath.Clean(strings.ReplaceAll(podcast.Title, "/", "_"))
podcastFilename := fmt.Sprintf("cover%s", ext)
podcast.ImagePath = path.Join(podcastPath, podcastFilename)
if err := p.db.Save(podcast).Error; err != nil {
return fmt.Errorf("save podcast: %w", err)
}
return nil
}
func (p *Podcasts) doPodcastDownload(podcastEpisode *db.PodcastEpisode, file *os.File, src io.Reader) error {
if _, err := io.Copy(file, src); err != nil {
return fmt.Errorf("writing podcast episode: %w", err)
}
defer file.Close()
stat, _ := file.Stat()
podcastPath := path.Join(p.baseDir, podcastEpisode.Path)
podcastTags, err := p.tagger.Read(podcastPath)
if err != nil {
log.Printf("error parsing podcast audio: %e", err)
podcastEpisode.Status = db.PodcastEpisodeStatusError
p.db.Save(podcastEpisode)
return nil
}
podcastEpisode.Bitrate = podcastTags.Bitrate()
podcastEpisode.Status = db.PodcastEpisodeStatusCompleted
podcastEpisode.Length = podcastTags.Length()
podcastEpisode.Size = int(stat.Size())
return p.db.Save(podcastEpisode).Error
}
func (p *Podcasts) DeletePodcast(userID, podcastID int) error {
podcast := db.Podcast{}
err := p.db.
Where("id=? AND user_id=?", podcastID, userID).
First(&podcast).
Error
if err != nil {
return err
}
var userCount int
p.db.
Model(&db.Podcast{}).
Where("title=?", podcast.Title).
Count(&userCount)
if userCount == 1 {
// only delete the folder if there are not multiple listeners
if err = os.RemoveAll(podcast.Fullpath(p.baseDir)); err != nil {
return fmt.Errorf("delete podcast directory: %w", err)
}
}
err = p.db.
Where("id=? AND user_id=?", podcastID, userID).
Delete(db.Podcast{}).
Error
if err != nil {
return fmt.Errorf("delete podcast row: %w", err)
}
return nil
}
func (p *Podcasts) DeletePodcastEpisode(podcastEpisodeID int) error {
episode := db.PodcastEpisode{}
err := p.db.First(&episode, podcastEpisodeID).Error
if err != nil {
return err
}
episode.Status = db.PodcastEpisodeStatusDeleted
p.db.Save(&episode)
if err := os.Remove(filepath.Join(p.baseDir, episode.Path)); err != nil {
return err
}
return err
}

View File

@@ -1,29 +0,0 @@
package podcasts
import (
"os"
"testing"
"time"
"github.com/mmcdole/gofeed"
)
func TestGetMoreRecentEpisodes(t *testing.T) {
fp := gofeed.NewParser()
newFile, err := os.Open("testdata/rss.new")
if err != nil {
t.Fatalf("open test data: %v", err)
}
newFeed, err := fp.Parse(newFile)
if err != nil {
t.Fatalf("parse test data: %v", err)
}
after, err := time.Parse(time.RFC1123, "Mon, 27 Jun 2016 06:33:43 +0000")
if err != nil {
t.Fatalf("parse time: %v", err)
}
entries := getEntriesAfterDate(newFeed.Items, after)
if len(entries) != 2 {
t.Errorf("expected 2 entries, got %d", len(entries))
}
}

View File

@@ -1,83 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:cc="http://web.resource.org/cc/" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:media="http://search.yahoo.com/mrss/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<channel>
<atom:link href="https://internetbox.libsyn.com/rss" rel="self" type="application/rss+xml"/>
<title>Internet Box</title>
<pubDate>Mon, 01 Apr 2019 04:35:43 +0000</pubDate>
<lastBuildDate>Sun, 10 Jan 2021 00:07:33 +0000</lastBuildDate>
<generator>Libsyn WebEngine 2.0</generator>
<link>https://internetboxpodcast.com</link>
<language>en</language>
<copyright><![CDATA[]]></copyright>
<docs>https://internetboxpodcast.com</docs>
<managingEditor>admin@internetboxpodcast.com (admin@internetboxpodcast.com)</managingEditor>
<itunes:summary><![CDATA[Michael, Mike, Barbara, Ray, Andrew, Dylon, Lindsay, and Kerry from the AH community talk about various subjects.]]></itunes:summary>
<image>
<url>https://ssl-static.libsyn.com/p/assets/d/d/3/3/dd338b309838f617/iTunes.png</url>
<title>Internet Box</title>
<link><![CDATA[https://internetboxpodcast.com]]></link>
</image>
<itunes:author>Internet Box Crew</itunes:author>
<itunes:keywords>achievement,box,friendship,hunter,internet,is,little,magic,mlp,my,pony,rooster,teeth</itunes:keywords>
<itunes:category text="Leisure">
<itunes:category text="Video Games"/>
</itunes:category>
<itunes:category text="Comedy"/>
<itunes:image href="https://ssl-static.libsyn.com/p/assets/d/d/3/3/dd338b309838f617/iTunes.png" />
<itunes:explicit>yes</itunes:explicit>
<itunes:owner>
<itunes:name><![CDATA[Mike]]></itunes:name>
<itunes:email>admin@internetboxpodcast.com</itunes:email>
</itunes:owner>
<description><![CDATA[Michael, Mike, Barbara, Ray, Andrew, Dylon, Lindsay, and Kerry from the AH community talk about various subjects.]]></description>
<itunes:subtitle><![CDATA[Various members from the AH community discuss games, ponies, and life in general.]]></itunes:subtitle>
<itunes:type>episodic</itunes:type>
<item>
<title>Episode 128</title>
<pubDate>Mon, 01 Apr 2019 04:35:43 +0000</pubDate>
<guid isPermaLink="false"><![CDATA[c60f174610d44b408901d6a8d98366b4]]></guid>
<link><![CDATA[https://internetboxpodcast.com/episode-128/]]></link>
<itunes:image href="https://ssl-static.libsyn.com/p/assets/d/d/3/3/dd338b309838f617/iTunes.png" />
<description><![CDATA[<p> </p> <p>The Internet Box is fooling around this week!</p> <p> </p>]]></description>
<content:encoded><![CDATA[<p> </p> <p>The Internet Box is fooling around this week!</p> <p> </p>]]></content:encoded>
<enclosure length="14637384" type="audio/mpeg" url="https://traffic.libsyn.com/secure/internetbox/InternetBoxEpisode128.mp3?dest-id=79492" />
<itunes:duration>44:41</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<itunes:keywords>box,meme,fools,april,reddit,copypaste,inernet</itunes:keywords>
<itunes:subtitle><![CDATA[  The Internet Box is fooling around this week!  ]]></itunes:subtitle>
<itunes:episodeType>full</itunes:episodeType>
</item>
<item>
<title>Episode 127</title>
<pubDate>Sat, 06 Aug 2016 04:46:28 +0000</pubDate>
<guid isPermaLink="false"><![CDATA[99b830789a90b2b6a712382fe4bffc6b]]></guid>
<link><![CDATA[http://internetboxpodcast.com/episode-127/]]></link>
<itunes:image href="https://ssl-static.libsyn.com/p/assets/d/d/3/3/dd338b309838f617/iTunes.png" />
<description><![CDATA[<p>The Internet Box is a national treasure this week!</p>]]></description>
<content:encoded><![CDATA[<p>The Internet Box is a national treasure this week!</p>]]></content:encoded>
<enclosure length="152364335" type="audio/mpeg" url="https://traffic.libsyn.com/secure/internetbox/InternetBoxEpisode127.mp3?dest-id=79492" />
<itunes:duration>01:45:48</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<itunes:keywords>box,2,internet,cage,movies,national,go,season,treasure,nic,bane,pokemon</itunes:keywords>
<itunes:subtitle><![CDATA[The Internet Box is a national treasure this week!]]></itunes:subtitle>
</item>
<item>
<title>Episode 126</title>
<pubDate>Mon, 27 Jun 2016 06:33:43 +0000</pubDate>
<guid isPermaLink="false"><![CDATA[d5113fc98b7baf005bf66b225b166ee0]]></guid>
<link><![CDATA[http://internetboxpodcast.com/episode-126/]]></link>
<itunes:image href="https://ssl-static.libsyn.com/p/assets/d/d/3/3/dd338b309838f617/iTunes.png" />
<description><![CDATA[<p>The Internet Box is clicking this week!</p>]]></description>
<content:encoded><![CDATA[<p>The Internet Box is clicking this week!</p>]]></content:encoded>
<enclosure length="66139165" type="audio/mpeg" url="https://traffic.libsyn.com/secure/internetbox/InternetBoxEpisode126.mp3?dest-id=79492" />
<itunes:duration>01:20:42</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<itunes:keywords>box,2,factory,internet,pizza,future,robots,season,farts,cake,via,reddit,313,vorarephilia</itunes:keywords>
<itunes:subtitle><![CDATA[The Internet Box is clicking this week!]]></itunes:subtitle>
</item>
</channel>
</rss>

View File

@@ -1,531 +0,0 @@
package scanner
import (
"errors"
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync/atomic"
"syscall"
"time"
"github.com/jinzhu/gorm"
"github.com/rainycape/unidecode"
"go.senan.xyz/gonic/multierr"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/server/mime"
"go.senan.xyz/gonic/server/scanner/tags"
)
var (
ErrAlreadyScanning = errors.New("already scanning")
ErrReadingTags = errors.New("could not read tags")
)
type Scanner struct {
db *db.DB
musicDirs []string
genreSplit string
tagger tags.Reader
scanning *int32
}
func New(musicDirs []string, db *db.DB, genreSplit string, tagger tags.Reader) *Scanner {
return &Scanner{
db: db,
musicDirs: musicDirs,
genreSplit: genreSplit,
tagger: tagger,
scanning: new(int32),
}
}
func (s *Scanner) IsScanning() bool {
return atomic.LoadInt32(s.scanning) == 1
}
type ScanOptions struct {
IsFull bool
}
func (s *Scanner) ScanAndClean(opts ScanOptions) (*Context, error) {
if s.IsScanning() {
return nil, ErrAlreadyScanning
}
atomic.StoreInt32(s.scanning, 1)
defer atomic.StoreInt32(s.scanning, 0)
start := time.Now()
c := &Context{
errs: &multierr.Err{},
seenTracks: map[int]struct{}{},
seenAlbums: map[int]struct{}{},
isFull: opts.IsFull,
}
log.Println("starting scan")
defer func() {
log.Printf("finished scan in %s, +%d/%d tracks (%d err)\n",
durSince(start), c.SeenTracksNew(), c.SeenTracks(), c.errs.Len())
}()
for _, dir := range s.musicDirs {
err := filepath.WalkDir(dir, func(absPath string, d fs.DirEntry, err error) error {
return s.scanCallback(c, dir, absPath, d, err)
})
if err != nil {
return nil, fmt.Errorf("walk: %w", err)
}
}
if err := s.cleanTracks(c); err != nil {
return nil, fmt.Errorf("clean tracks: %w", err)
}
if err := s.cleanAlbums(c); err != nil {
return nil, fmt.Errorf("clean albums: %w", err)
}
if err := s.cleanArtists(c); err != nil {
return nil, fmt.Errorf("clean artists: %w", err)
}
if err := s.cleanGenres(c); err != nil {
return nil, fmt.Errorf("clean genres: %w", err)
}
if err := s.db.SetSetting("last_scan_time", strconv.FormatInt(time.Now().Unix(), 10)); err != nil {
return nil, fmt.Errorf("set scan time: %w", err)
}
if c.errs.Len() > 0 {
return c, c.errs
}
return c, nil
}
func (s *Scanner) scanCallback(c *Context, dir string, absPath string, d fs.DirEntry, err error) error {
if err != nil {
c.errs.Add(err)
return nil
}
if dir == absPath {
return nil
}
switch d.Type() {
case os.ModeDir:
case os.ModeSymlink:
eval, _ := filepath.EvalSymlinks(absPath)
return filepath.WalkDir(eval, func(subAbs string, d fs.DirEntry, err error) error {
subAbs = strings.Replace(subAbs, eval, absPath, 1)
return s.scanCallback(c, dir, subAbs, d, err)
})
default:
return nil
}
log.Printf("processing folder `%s`", absPath)
tx := s.db.Begin()
if err := s.scanDir(tx, c, dir, absPath); err != nil {
c.errs.Add(fmt.Errorf("%q: %w", absPath, err))
tx.Rollback()
return nil
}
if err := tx.Commit().Error; err != nil {
return fmt.Errorf("commit tx: %w", err)
}
return nil
}
func (s *Scanner) scanDir(tx *db.DB, c *Context, musicDir string, absPath string) error {
items, err := os.ReadDir(absPath)
if err != nil {
return err
}
var tracks []string
var cover string
for _, item := range items {
if isCover(item.Name()) {
cover = item.Name()
continue
}
if _, ok := mime.FromExtension(ext(item.Name())); ok {
tracks = append(tracks, item.Name())
continue
}
}
relPath, _ := filepath.Rel(musicDir, absPath)
pdir, pbasename := filepath.Split(filepath.Dir(relPath))
var parent db.Album
if err := tx.Where(db.Album{RootDir: musicDir, LeftPath: pdir, RightPath: pbasename}).FirstOrCreate(&parent).Error; err != nil {
return fmt.Errorf("first or create parent: %w", err)
}
c.seenAlbums[parent.ID] = struct{}{}
dir, basename := filepath.Split(relPath)
var album db.Album
if err := populateAlbumBasics(tx, musicDir, &parent, &album, dir, basename, cover); err != nil {
return fmt.Errorf("populate album basics: %w", err)
}
c.seenAlbums[album.ID] = struct{}{}
sort.Strings(tracks)
for i, basename := range tracks {
absPath := filepath.Join(musicDir, relPath, basename)
if err := s.populateTrackAndAlbumArtists(tx, c, i, &parent, &album, basename, absPath); err != nil {
return fmt.Errorf("populate track %q: %w", basename, err)
}
}
return nil
}
func (s *Scanner) populateTrackAndAlbumArtists(tx *db.DB, c *Context, i int, parent, album *db.Album, basename string, absPath string) error {
stat, err := os.Stat(absPath)
if err != nil {
return fmt.Errorf("stating %q: %w", basename, err)
}
track := &db.Track{AlbumID: album.ID, Filename: filepath.Base(basename)}
if err := tx.Where(track).First(track).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("query track: %w", err)
}
if !c.isFull && track.ID != 0 && stat.ModTime().Before(track.UpdatedAt) {
c.seenTracks[track.ID] = struct{}{}
return nil
}
trags, err := s.tagger.Read(absPath)
if err != nil {
return fmt.Errorf("%v: %w", err, ErrReadingTags)
}
genreNames := strings.Split(trags.SomeGenre(), s.genreSplit)
genreIDs, err := populateGenres(tx, track, genreNames)
if err != nil {
return fmt.Errorf("populate genres: %w", err)
}
// metadata for the album table comes only from the the first track's tags
if i == 0 || album.TagArtist == nil {
albumArtist, err := populateAlbumArtist(tx, album, parent, trags.SomeAlbumArtist())
if err != nil {
return fmt.Errorf("populate album artist: %w", err)
}
if err := populateAlbum(tx, album, albumArtist, trags, genreIDs, stat.ModTime(), statCreateTime(stat)); err != nil {
return fmt.Errorf("populate album: %w", err)
}
if err := populateAlbumGenres(tx, album, genreIDs); err != nil {
return fmt.Errorf("populate album genres: %w", err)
}
}
if err := populateTrack(tx, album, track, trags, basename, int(stat.Size())); err != nil {
return fmt.Errorf("process %q: %w", basename, err)
}
if err := populateTrackGenres(tx, track, genreIDs); err != nil {
return fmt.Errorf("populate track genres: %w", err)
}
c.seenTracks[track.ID] = struct{}{}
c.seenTracksNew++
return nil
}
func populateAlbum(tx *db.DB, album *db.Album, albumArtist *db.Artist, trags tags.Parser, genreIDs []int, modTime, createTime time.Time) error {
albumName := trags.SomeAlbum()
album.TagTitle = albumName
album.TagTitleUDec = decoded(albumName)
album.TagBrainzID = trags.AlbumBrainzID()
album.TagYear = trags.Year()
album.TagArtist = albumArtist
album.ModifiedAt = modTime
if !createTime.IsZero() {
album.CreatedAt = createTime
}
if err := tx.Save(&album).Error; err != nil {
return fmt.Errorf("saving album: %w", err)
}
return nil
}
func populateAlbumBasics(tx *db.DB, musicDir string, parent, album *db.Album, dir, basename string, cover string) error {
if err := tx.Where(db.Album{RootDir: musicDir, LeftPath: dir, RightPath: basename}).First(album).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("find album: %w", err)
}
// see if we can save ourselves from an extra write if it's found and nothing has changed
if album.ID != 0 && album.Cover == cover && album.ParentID == parent.ID {
return nil
}
album.RootDir = musicDir
album.LeftPath = dir
album.RightPath = basename
album.Cover = cover
album.RightPathUDec = decoded(basename)
album.ParentID = parent.ID
if err := tx.Save(&album).Error; err != nil {
return fmt.Errorf("saving album: %w", err)
}
return nil
}
func populateTrack(tx *db.DB, album *db.Album, track *db.Track, trags tags.Parser, absPath string, size int) error {
basename := filepath.Base(absPath)
track.Filename = basename
track.FilenameUDec = decoded(basename)
track.Size = size
track.AlbumID = album.ID
track.ArtistID = album.TagArtist.ID
track.TagTitle = trags.Title()
track.TagTitleUDec = decoded(trags.Title())
track.TagTrackArtist = trags.Artist()
track.TagTrackNumber = trags.TrackNumber()
track.TagDiscNumber = trags.DiscNumber()
track.TagBrainzID = trags.BrainzID()
track.Length = trags.Length() // these two should be calculated
track.Bitrate = trags.Bitrate() // ...from the file instead of tags
if err := tx.Save(&track).Error; err != nil {
return fmt.Errorf("saving track: %w", err)
}
return nil
}
func populateAlbumArtist(tx *db.DB, album, parent *db.Album, artistName string) (*db.Artist, error) {
var update db.Artist
update.Name = artistName
update.NameUDec = decoded(artistName)
if parent.Cover != "" {
update.Cover = parent.Cover
}
var artist db.Artist
if err := tx.Where("name=?", artistName).Assign(update).FirstOrCreate(&artist).Error; err != nil {
return nil, fmt.Errorf("find or create artist: %w", err)
}
return &artist, nil
}
func populateGenres(tx *db.DB, track *db.Track, names []string) ([]int, error) {
var filteredNames []string
for _, name := range names {
if clean := strings.TrimSpace(name); clean != "" {
filteredNames = append(filteredNames, clean)
}
}
if len(filteredNames) == 0 {
return []int{}, nil
}
var ids []int
for _, name := range filteredNames {
var genre db.Genre
if err := tx.FirstOrCreate(&genre, db.Genre{Name: name}).Error; err != nil {
return nil, fmt.Errorf("find or create genre: %w", err)
}
ids = append(ids, genre.ID)
}
return ids, nil
}
func populateTrackGenres(tx *db.DB, track *db.Track, genreIDs []int) error {
if err := tx.Where("track_id=?", track.ID).Delete(db.TrackGenre{}).Error; err != nil {
return fmt.Errorf("delete old track genre records: %w", err)
}
if err := tx.InsertBulkLeftMany("track_genres", []string{"track_id", "genre_id"}, track.ID, genreIDs); err != nil {
return fmt.Errorf("insert bulk track genres: %w", err)
}
return nil
}
func populateAlbumGenres(tx *db.DB, album *db.Album, genreIDs []int) error {
if err := tx.Where("album_id=?", album.ID).Delete(db.AlbumGenre{}).Error; err != nil {
return fmt.Errorf("delete old album genre records: %w", err)
}
if err := tx.InsertBulkLeftMany("album_genres", []string{"album_id", "genre_id"}, album.ID, genreIDs); err != nil {
return fmt.Errorf("insert bulk album genres: %w", err)
}
return nil
}
func (s *Scanner) cleanTracks(c *Context) error {
start := time.Now()
defer func() { log.Printf("finished clean tracks in %s, %d removed", durSince(start), c.TracksMissing()) }()
var all []int
err := s.db.
Model(&db.Track{}).
Pluck("id", &all).
Error
if err != nil {
return fmt.Errorf("plucking ids: %w", err)
}
for _, a := range all {
if _, ok := c.seenTracks[a]; !ok {
c.tracksMissing = append(c.tracksMissing, int64(a))
}
}
return s.db.TransactionChunked(c.tracksMissing, func(tx *gorm.DB, chunk []int64) error {
return tx.Where(chunk).Delete(&db.Track{}).Error
})
}
func (s *Scanner) cleanAlbums(c *Context) error {
start := time.Now()
defer func() { log.Printf("finished clean albums in %s, %d removed", durSince(start), c.AlbumsMissing()) }()
var all []int
err := s.db.
Model(&db.Album{}).
Pluck("id", &all).
Error
if err != nil {
return fmt.Errorf("plucking ids: %w", err)
}
for _, a := range all {
if _, ok := c.seenAlbums[a]; !ok {
c.albumsMissing = append(c.albumsMissing, int64(a))
}
}
return s.db.TransactionChunked(c.albumsMissing, func(tx *gorm.DB, chunk []int64) error {
return tx.Where(chunk).Delete(&db.Album{}).Error
})
}
func (s *Scanner) cleanArtists(c *Context) error {
start := time.Now()
defer func() { log.Printf("finished clean artists in %s, %d removed", durSince(start), c.ArtistsMissing()) }()
sub := s.db.
Select("artists.id").
Model(&db.Artist{}).
Joins("LEFT JOIN albums ON albums.tag_artist_id=artists.id").
Where("albums.id IS NULL").
SubQuery()
q := s.db.
Where("artists.id IN ?", sub).
Delete(&db.Artist{})
if err := q.Error; err != nil {
return err
}
c.artistsMissing = int(q.RowsAffected)
return nil
}
func (s *Scanner) cleanGenres(c *Context) error {
start := time.Now()
defer func() { log.Printf("finished clean genres in %s, %d removed", durSince(start), c.GenresMissing()) }()
subTrack := s.db.
Select("genres.id").
Model(&db.Genre{}).
Joins("LEFT JOIN track_genres ON track_genres.genre_id=genres.id").
Where("track_genres.genre_id IS NULL").
SubQuery()
subAlbum := s.db.
Select("genres.id").
Model(&db.Genre{}).
Joins("LEFT JOIN album_genres ON album_genres.genre_id=genres.id").
Where("album_genres.genre_id IS NULL").
SubQuery()
q := s.db.
Where("genres.id IN ? AND genres.id IN ?", subTrack, subAlbum).
Delete(&db.Genre{})
c.genresMissing = int(q.RowsAffected)
return nil
}
func ext(name string) string {
if ext := filepath.Ext(name); len(ext) > 0 {
return ext[1:]
}
return ""
}
func isCover(name string) bool {
switch path := strings.ToLower(name); path {
case
"cover.png", "cover.jpg", "cover.jpeg",
"folder.png", "folder.jpg", "folder.jpeg",
"album.png", "album.jpg", "album.jpeg",
"albumart.png", "albumart.jpg", "albumart.jpeg",
"front.png", "front.jpg", "front.jpeg",
"artist.png", "artist.jpg", "artist.jpeg":
return true
default:
return false
}
}
// decoded converts a string to it's latin equivalent.
// it will be used by the model's *UDec fields, and is only set if it
// differs from the original. the fields are used for searching.
func decoded(in string) string {
if u := unidecode.Unidecode(in); u != in {
return u
}
return ""
}
func durSince(t time.Time) time.Duration {
return time.Since(t).Truncate(10 * time.Microsecond)
}
type Context struct {
errs *multierr.Err
isFull bool
seenTracks map[int]struct{}
seenAlbums map[int]struct{}
seenTracksNew int
tracksMissing []int64
albumsMissing []int64
artistsMissing int
genresMissing int
}
func (c *Context) SeenTracks() int { return len(c.seenTracks) }
func (c *Context) SeenAlbums() int { return len(c.seenAlbums) }
func (c *Context) SeenTracksNew() int { return c.seenTracksNew }
func (c *Context) TracksMissing() int { return len(c.tracksMissing) }
func (c *Context) AlbumsMissing() int { return len(c.albumsMissing) }
func (c *Context) ArtistsMissing() int { return c.artistsMissing }
func (c *Context) GenresMissing() int { return c.genresMissing }
func statCreateTime(info fs.FileInfo) time.Time {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return time.Time{}
}
if stat.Ctim.Sec == 0 {
return time.Time{}
}
//nolint:unconvert // Ctim.Sec/Nsec is int32 on arm/386, etc
return time.Unix(int64(stat.Ctim.Sec), int64(stat.Ctim.Nsec))
}

View File

@@ -1,33 +0,0 @@
package scanner_test
import (
"fmt"
"testing"
"go.senan.xyz/gonic/server/mockfs"
)
func BenchmarkScanIncremental(b *testing.B) {
m := mockfs.New(b)
for i := 0; i < 5; i++ {
m.AddItemsPrefix(fmt.Sprintf("t-%d", i))
}
m.ScanAndClean()
b.ResetTimer()
for i := 0; i < b.N; i++ {
m.ScanAndClean()
}
}
func BenchmarkScanFull(b *testing.B) {
for i := 0; i < b.N; i++ {
m := mockfs.New(b)
for i := 0; i < 5; i++ {
m.AddItemsPrefix(fmt.Sprintf("t-%d", i))
}
b.StartTimer()
m.ScanAndClean()
b.StopTimer()
}
}

View File

@@ -1,83 +0,0 @@
//go:build go1.18
// +build go1.18
package scanner_test
import (
"fmt"
"math/rand"
"reflect"
"testing"
_ "github.com/jinzhu/gorm/dialects/sqlite"
"github.com/matryer/is"
"go.senan.xyz/gonic/server/mockfs"
)
func FuzzScanner(f *testing.F) {
checkDelta := func(is *is.I, m *mockfs.MockFS, expSeen, expNew int) {
is.Helper()
ctx := m.ScanAndClean()
is.Equal(ctx.SeenTracks(), expSeen)
is.Equal(ctx.SeenTracksNew(), expNew)
is.Equal(ctx.TracksMissing(), 0)
is.Equal(ctx.AlbumsMissing(), 0)
is.Equal(ctx.ArtistsMissing(), 0)
is.Equal(ctx.GenresMissing(), 0)
}
f.Fuzz(func(t *testing.T, data []byte, seed int64) {
is := is.NewRelaxed(t)
m := mockfs.New(t)
const toAdd = 1000
for i := 0; i < toAdd; i++ {
path := fmt.Sprintf("artist-%d/album-%d/track-%d.flac", i/6, i/3, i)
m.AddTrack(path)
m.SetTags(path, func(tags *mockfs.Tags) error {
fuzzStruct(i, data, seed, tags)
return nil
})
}
checkDelta(is, m, toAdd, toAdd) // we added all tracks, 0 delta
checkDelta(is, m, toAdd, 0) // we added 0 tracks, 0 delta
})
}
func fuzzStruct(taken int, data []byte, seed int64, dest interface{}) {
if len(data) == 0 {
return
}
r := rand.New(rand.NewSource(seed))
v := reflect.ValueOf(dest)
for i := 0; i < v.Elem().NumField(); i++ {
if r.Float64() < 0.1 {
continue
}
take := int(r.Float64() * 12)
b := make([]byte, take)
for i := range b {
b[i] = data[(i+taken)%len(data)]
}
taken += take
switch f := v.Elem().Field(i); f.Kind() {
case reflect.Bool:
f.SetBool(b[0] < 128)
case reflect.String:
f.SetString(string(b))
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
f.SetInt(int64(b[0]))
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
f.SetUint(uint64(b[0]))
case reflect.Float32, reflect.Float64:
f.SetFloat(float64(b[0]))
case reflect.Struct:
fuzzStruct(taken, data, seed, f.Addr().Interface())
}
}
}

View File

@@ -1,557 +0,0 @@
package scanner_test
import (
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
"testing"
"github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/sqlite"
"github.com/matryer/is"
"go.senan.xyz/gonic/multierr"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/server/mockfs"
"go.senan.xyz/gonic/server/scanner"
)
func TestMain(m *testing.M) {
log.SetOutput(io.Discard)
os.Exit(m.Run())
}
func TestTableCounts(t *testing.T) {
t.Parallel()
is := is.NewRelaxed(t)
m := mockfs.New(t)
m.AddItems()
m.ScanAndClean()
var tracks int
is.NoErr(m.DB().Model(&db.Track{}).Count(&tracks).Error) // not all tracks
is.Equal(tracks, m.NumTracks())
var albums int
is.NoErr(m.DB().Model(&db.Album{}).Count(&albums).Error) // not all albums
is.Equal(albums, 13) // not all albums
var artists int
is.NoErr(m.DB().Model(&db.Artist{}).Count(&artists).Error) // not all artists
is.Equal(artists, 3) // not all artists
}
func TestParentID(t *testing.T) {
t.Parallel()
is := is.New(t)
m := mockfs.New(t)
m.AddItems()
m.ScanAndClean()
var nullParentAlbums []*db.Album
is.NoErr(m.DB().Where("parent_id IS NULL").Find(&nullParentAlbums).Error) // one parent_id=NULL which is root folder
is.Equal(len(nullParentAlbums), 1) // one parent_id=NULL which is root folder
is.Equal(nullParentAlbums[0].LeftPath, "")
is.Equal(nullParentAlbums[0].RightPath, ".")
is.Equal(m.DB().Where("id=parent_id").Find(&db.Album{}).Error, gorm.ErrRecordNotFound) // no self-referencing albums
var album db.Album
var parent db.Album
is.NoErr(m.DB().Find(&album, "left_path=? AND right_path=?", "artist-0/", "album-0").Error) // album has parent ID
is.NoErr(m.DB().Find(&parent, "right_path=?", "artist-0").Error) // album has parent ID
is.Equal(album.ParentID, parent.ID) // album has parent ID
}
func TestUpdatedCover(t *testing.T) {
t.Parallel()
is := is.NewRelaxed(t)
m := mockfs.New(t)
m.AddItems()
m.ScanAndClean()
m.AddCover("artist-0/album-0/cover.jpg")
m.ScanAndClean()
var album db.Album
is.NoErr(m.DB().Where("left_path=? AND right_path=?", "artist-0/", "album-0").Find(&album).Error) // album has cover
is.Equal(album.Cover, "cover.jpg") // album has cover
}
func TestCoverBeforeTracks(t *testing.T) {
t.Parallel()
is := is.New(t)
m := mockfs.New(t)
m.AddCover("artist-2/album-2/cover.jpg")
m.ScanAndClean()
m.AddItems()
m.ScanAndClean()
var album db.Album
is.NoErr(m.DB().Preload("TagArtist").Where("left_path=? AND right_path=?", "artist-2/", "album-2").Find(&album).Error) // album has cover
is.Equal(album.Cover, "cover.jpg") // album has cover
is.Equal(album.TagArtist.Name, "artist-2") // album artist
var tracks []*db.Track
is.NoErr(m.DB().Where("album_id=?", album.ID).Find(&tracks).Error) // album has tracks
is.Equal(len(tracks), 3) // album has tracks
}
func TestUpdatedTags(t *testing.T) {
t.Parallel()
is := is.New(t)
m := mockfs.New(t)
m.AddTrack("artist-10/album-10/track-10.flac")
m.SetTags("artist-10/album-10/track-10.flac", func(tags *mockfs.Tags) error {
tags.RawArtist = "artist"
tags.RawAlbumArtist = "album-artist"
tags.RawAlbum = "album"
tags.RawTitle = "title"
return nil
})
m.ScanAndClean()
var track db.Track
is.NoErr(m.DB().Preload("Album").Preload("Artist").Where("filename=?", "track-10.flac").Find(&track).Error) // track has tags
is.Equal(track.TagTrackArtist, "artist") // track has tags
is.Equal(track.Artist.Name, "album-artist") // track has tags
is.Equal(track.Album.TagTitle, "album") // track has tags
is.Equal(track.TagTitle, "title") // track has tags
m.SetTags("artist-10/album-10/track-10.flac", func(tags *mockfs.Tags) error {
tags.RawArtist = "artist-upd"
tags.RawAlbumArtist = "album-artist-upd"
tags.RawAlbum = "album-upd"
tags.RawTitle = "title-upd"
return nil
})
m.ScanAndClean()
var updated db.Track
is.NoErr(m.DB().Preload("Album").Preload("Artist").Where("filename=?", "track-10.flac").Find(&updated).Error) // updated has tags
is.Equal(updated.ID, track.ID) // updated has tags
is.Equal(updated.TagTrackArtist, "artist-upd") // updated has tags
is.Equal(updated.Artist.Name, "album-artist-upd") // updated has tags
is.Equal(updated.Album.TagTitle, "album-upd") // updated has tags
is.Equal(updated.TagTitle, "title-upd") // updated has tags
}
func TestDeleteAlbum(t *testing.T) {
t.Parallel()
is := is.NewRelaxed(t)
m := mockfs.New(t)
m.AddItems()
m.ScanAndClean()
is.NoErr(m.DB().Where("left_path=? AND right_path=?", "artist-2/", "album-2").Find(&db.Album{}).Error) // album exists
m.RemoveAll("artist-2/album-2")
m.ScanAndClean()
is.Equal(m.DB().Where("left_path=? AND right_path=?", "artist-2/", "album-2").Find(&db.Album{}).Error, gorm.ErrRecordNotFound) // album doesn't exist
}
func TestDeleteArtist(t *testing.T) {
t.Parallel()
is := is.NewRelaxed(t)
m := mockfs.New(t)
m.AddItems()
m.ScanAndClean()
is.NoErr(m.DB().Where("left_path=? AND right_path=?", "artist-2/", "album-2").Find(&db.Album{}).Error) // album exists
m.RemoveAll("artist-2")
m.ScanAndClean()
is.Equal(m.DB().Where("left_path=? AND right_path=?", "artist-2/", "album-2").Find(&db.Album{}).Error, gorm.ErrRecordNotFound) // album doesn't exist
is.Equal(m.DB().Where("name=?", "artist-2").Find(&db.Artist{}).Error, gorm.ErrRecordNotFound) // artist doesn't exist
}
func TestGenres(t *testing.T) {
t.Parallel()
is := is.New(t)
m := mockfs.New(t)
albumGenre := func(artist, album, genre string) error {
return m.DB().
Where("albums.left_path=? AND albums.right_path=? AND genres.name=?", artist, album, genre).
Joins("JOIN albums ON albums.id=album_genres.album_id").
Joins("JOIN genres ON genres.id=album_genres.genre_id").
Find(&db.AlbumGenre{}).
Error
}
isAlbumGenre := func(artist, album, genreName string) {
is.Helper()
is.NoErr(albumGenre(artist, album, genreName))
}
isAlbumGenreMissing := func(artist, album, genreName string) {
is.Helper()
is.Equal(albumGenre(artist, album, genreName), gorm.ErrRecordNotFound)
}
trackGenre := func(artist, album, filename, genreName string) error {
return m.DB().
Where("albums.left_path=? AND albums.right_path=? AND tracks.filename=? AND genres.name=?", artist, album, filename, genreName).
Joins("JOIN tracks ON tracks.id=track_genres.track_id").
Joins("JOIN genres ON genres.id=track_genres.genre_id").
Joins("JOIN albums ON albums.id=tracks.album_id").
Find(&db.TrackGenre{}).
Error
}
isTrackGenre := func(artist, album, filename, genreName string) {
is.Helper()
is.NoErr(trackGenre(artist, album, filename, genreName))
}
isTrackGenreMissing := func(artist, album, filename, genreName string) {
is.Helper()
is.Equal(trackGenre(artist, album, filename, genreName), gorm.ErrRecordNotFound)
}
genre := func(genre string) error {
return m.DB().Where("name=?", genre).Find(&db.Genre{}).Error
}
isGenre := func(genreName string) {
is.Helper()
is.NoErr(genre(genreName))
}
isGenreMissing := func(genreName string) {
is.Helper()
is.Equal(genre(genreName), gorm.ErrRecordNotFound)
}
m.AddItems()
m.SetTags("artist-0/album-0/track-0.flac", func(tags *mockfs.Tags) error { tags.RawGenre = "genre-a;genre-b"; return nil })
m.SetTags("artist-0/album-0/track-1.flac", func(tags *mockfs.Tags) error { tags.RawGenre = "genre-c;genre-d"; return nil })
m.SetTags("artist-1/album-2/track-0.flac", func(tags *mockfs.Tags) error { tags.RawGenre = "genre-e;genre-f"; return nil })
m.SetTags("artist-1/album-2/track-1.flac", func(tags *mockfs.Tags) error { tags.RawGenre = "genre-g;genre-h"; return nil })
m.ScanAndClean()
isGenre("genre-a") // genre exists
isGenre("genre-b") // genre exists
isGenre("genre-c") // genre exists
isGenre("genre-d") // genre exists
isTrackGenre("artist-0/", "album-0", "track-0.flac", "genre-a") // track genre exists
isTrackGenre("artist-0/", "album-0", "track-0.flac", "genre-b") // track genre exists
isTrackGenre("artist-0/", "album-0", "track-1.flac", "genre-c") // track genre exists
isTrackGenre("artist-0/", "album-0", "track-1.flac", "genre-d") // track genre exists
isTrackGenre("artist-1/", "album-2", "track-0.flac", "genre-e") // track genre exists
isTrackGenre("artist-1/", "album-2", "track-0.flac", "genre-f") // track genre exists
isTrackGenre("artist-1/", "album-2", "track-1.flac", "genre-g") // track genre exists
isTrackGenre("artist-1/", "album-2", "track-1.flac", "genre-h") // track genre exists
isAlbumGenre("artist-0/", "album-0", "genre-a") // album genre exists
isAlbumGenre("artist-0/", "album-0", "genre-b") // album genre exists
m.SetTags("artist-0/album-0/track-0.flac", func(tags *mockfs.Tags) error { tags.RawGenre = "genre-aa;genre-bb"; return nil })
m.ScanAndClean()
isTrackGenre("artist-0/", "album-0", "track-0.flac", "genre-aa") // updated track genre exists
isTrackGenre("artist-0/", "album-0", "track-0.flac", "genre-bb") // updated track genre exists
isTrackGenreMissing("artist-0/", "album-0", "track-0.flac", "genre-a") // old track genre missing
isTrackGenreMissing("artist-0/", "album-0", "track-0.flac", "genre-b") // old track genre missing
isAlbumGenreMissing("artist-0/", "album-0", "genre-a") // old album genre missing
isAlbumGenreMissing("artist-0/", "album-0", "genre-b") // old album genre missing
isGenreMissing("genre-a") // old genre missing
isGenreMissing("genre-b") // old genre missing
}
func TestMultiFolders(t *testing.T) {
t.Parallel()
is := is.New(t)
m := mockfs.NewWithDirs(t, []string{"m-1", "m-2", "m-3"})
m.AddItemsPrefix("m-1")
m.AddItemsPrefix("m-2")
m.AddItemsPrefix("m-3")
m.ScanAndClean()
var rootDirs []*db.Album
is.NoErr(m.DB().Where("parent_id IS NULL").Find(&rootDirs).Error)
is.Equal(len(rootDirs), 3)
for i, r := range rootDirs {
is.Equal(r.RootDir, filepath.Join(m.TmpDir(), fmt.Sprintf("m-%d", i+1)))
is.Equal(r.ParentID, 0)
is.Equal(r.LeftPath, "")
is.Equal(r.RightPath, ".")
}
m.AddCover("m-3/artist-0/album-0/cover.jpg")
m.ScanAndClean()
m.LogItems()
checkCover := func(root string, q string) {
is.Helper()
is.NoErr(m.DB().Where(q, filepath.Join(m.TmpDir(), root)).Find(&db.Album{}).Error)
}
checkCover("m-1", "root_dir=? AND cover IS NULL") // mf 1 no cover
checkCover("m-2", "root_dir=? AND cover IS NULL") // mf 2 no cover
checkCover("m-3", "root_dir=? AND cover='cover.jpg'") // mf 3 has cover
}
func TestNewAlbumForExistingArtist(t *testing.T) {
t.Parallel()
is := is.New(t)
m := mockfs.New(t)
m.AddItems()
m.ScanAndClean()
m.LogAlbums()
m.LogArtists()
var artist db.Artist
is.NoErr(m.DB().Where("name=?", "artist-2").Find(&artist).Error) // find orig artist
is.True(artist.ID > 0)
for tr := 0; tr < 3; tr++ {
m.AddTrack(fmt.Sprintf("artist-2/new-album/track-%d.mp3", tr))
m.SetTags(fmt.Sprintf("artist-2/new-album/track-%d.mp3", tr), func(tags *mockfs.Tags) error {
tags.RawArtist = "artist-2"
tags.RawAlbumArtist = "artist-2"
tags.RawAlbum = "new-album"
tags.RawTitle = fmt.Sprintf("title-%d", tr)
return nil
})
}
var updated db.Artist
is.NoErr(m.DB().Where("name=?", "artist-2").Find(&updated).Error) // find updated artist
is.Equal(artist.ID, updated.ID) // find updated artist
var all []*db.Artist
is.NoErr(m.DB().Find(&all).Error) // still only 3?
is.Equal(len(all), 3) // still only 3?
}
func TestMultiFolderWithSharedArtist(t *testing.T) {
t.Parallel()
is := is.New(t)
m := mockfs.NewWithDirs(t, []string{"m-0", "m-1"})
const artistName = "artist-a"
m.AddTrack(fmt.Sprintf("m-0/%s/album-a/track-1.flac", artistName))
m.SetTags(fmt.Sprintf("m-0/%s/album-a/track-1.flac", artistName), func(tags *mockfs.Tags) error {
tags.RawArtist = artistName
tags.RawAlbumArtist = artistName
tags.RawAlbum = "album-a"
tags.RawTitle = "track-1"
return nil
})
m.ScanAndClean()
m.AddTrack(fmt.Sprintf("m-1/%s/album-a/track-1.flac", artistName))
m.SetTags(fmt.Sprintf("m-1/%s/album-a/track-1.flac", artistName), func(tags *mockfs.Tags) error {
tags.RawArtist = artistName
tags.RawAlbumArtist = artistName
tags.RawAlbum = "album-a"
tags.RawTitle = "track-1"
return nil
})
m.ScanAndClean()
sq := func(db *gorm.DB) *gorm.DB {
return db.
Select("*, count(sub.id) child_count, sum(sub.length) duration").
Joins("LEFT JOIN tracks sub ON albums.id=sub.album_id").
Group("albums.id")
}
var artist db.Artist
is.NoErr(m.DB().Where("name=?", artistName).Preload("Albums", sq).First(&artist).Error)
is.Equal(artist.Name, artistName)
is.Equal(len(artist.Albums), 2)
for _, album := range artist.Albums {
is.True(album.TagYear > 0)
is.Equal(album.TagArtistID, artist.ID)
is.True(album.ChildCount > 0)
is.True(album.Duration > 0)
}
}
func TestSymlinkedAlbum(t *testing.T) {
t.Parallel()
is := is.New(t)
m := mockfs.NewWithDirs(t, []string{"scan"})
m.AddItemsPrefixWithCovers("temp")
tempAlbum0 := filepath.Join(m.TmpDir(), "temp", "artist-0", "album-0")
scanAlbum0 := filepath.Join(m.TmpDir(), "scan", "artist-sym", "album-0")
m.Symlink(tempAlbum0, scanAlbum0)
m.ScanAndClean()
m.LogTracks()
m.LogAlbums()
var track db.Track
is.NoErr(m.DB().Preload("Album.Parent").Find(&track).Error) // track exists
is.True(track.Album != nil) // track has album
is.True(track.Album.Cover != "") // album has cover
is.Equal(track.Album.Parent.RightPath, "artist-sym") // artist is sym
info, err := os.Stat(track.AbsPath())
is.NoErr(err) // track resolves
is.True(!info.IsDir()) // track resolves
is.True(!info.ModTime().IsZero()) // track resolves
}
func TestSymlinkedSubdiscs(t *testing.T) {
t.Parallel()
is := is.New(t)
m := mockfs.NewWithDirs(t, []string{"scan"})
addItem := func(prefix, artist, album, disc, track string) {
p := fmt.Sprintf("%s/%s/%s/%s/%s", prefix, artist, album, disc, track)
m.AddTrack(p)
m.SetTags(p, func(tags *mockfs.Tags) error {
tags.RawArtist = artist
tags.RawAlbumArtist = artist
tags.RawAlbum = album
tags.RawTitle = track
return nil
})
}
addItem("temp", "artist-a", "album-a", "disc-1", "track-1.flac")
addItem("temp", "artist-a", "album-a", "disc-1", "track-2.flac")
addItem("temp", "artist-a", "album-a", "disc-1", "track-3.flac")
addItem("temp", "artist-a", "album-a", "disc-2", "track-1.flac")
addItem("temp", "artist-a", "album-a", "disc-2", "track-2.flac")
addItem("temp", "artist-a", "album-a", "disc-2", "track-3.flac")
tempAlbum0 := filepath.Join(m.TmpDir(), "temp", "artist-a", "album-a")
scanAlbum0 := filepath.Join(m.TmpDir(), "scan", "artist-a", "album-sym")
m.Symlink(tempAlbum0, scanAlbum0)
m.ScanAndClean()
m.LogTracks()
m.LogAlbums()
var track db.Track
is.NoErr(m.DB().Preload("Album.Parent").Find(&track).Error) // track exists
is.True(track.Album != nil) // track has album
is.Equal(track.Album.Parent.RightPath, "album-sym") // artist is sym
info, err := os.Stat(track.AbsPath())
is.NoErr(err) // track resolves
is.True(!info.IsDir()) // track resolves
is.True(!info.ModTime().IsZero()) // track resolves
}
func TestArtistHasCover(t *testing.T) {
t.Parallel()
is := is.New(t)
m := mockfs.New(t)
m.AddItemsWithCovers()
m.AddCover("artist-2/artist.png")
m.ScanAndClean()
var artistWith db.Artist
is.NoErr(m.DB().Where("name=?", "artist-2").First(&artistWith).Error)
is.Equal(artistWith.Cover, "artist.png")
var artistWithout db.Artist
is.NoErr(m.DB().Where("name=?", "artist-0").First(&artistWithout).Error)
is.Equal(artistWithout.Cover, "")
}
func TestTagErrors(t *testing.T) {
t.Parallel()
is := is.New(t)
m := mockfs.New(t)
m.AddItemsWithCovers()
m.SetTags("artist-1/album-0/track-0.flac", func(tags *mockfs.Tags) error {
return scanner.ErrReadingTags
})
m.SetTags("artist-1/album-1/track-0.flac", func(tags *mockfs.Tags) error {
return scanner.ErrReadingTags
})
var errs *multierr.Err
ctx, err := m.ScanAndCleanErr()
is.True(errors.As(err, &errs))
is.Equal(errs.Len(), 2) // we have 2 dir errors
is.Equal(ctx.SeenTracks(), m.NumTracks()-(3*2)) // we saw all tracks bar 2 album contents
is.Equal(ctx.SeenTracksNew(), m.NumTracks()-(3*2)) // we have all tracks bar 2 album contents
ctx, err = m.ScanAndCleanErr()
is.True(errors.As(err, &errs))
is.Equal(errs.Len(), 2) // we have 2 dir errors
is.Equal(ctx.SeenTracks(), m.NumTracks()-(3*2)) // we saw all tracks bar 2 album contents
is.Equal(ctx.SeenTracksNew(), 0) // we have no new tracks
}
// https://github.com/sentriz/gonic/issues/185#issuecomment-1050092128
func TestCompilationAlbumWithoutAlbumArtist(t *testing.T) {
t.Parallel()
is := is.New(t)
m := mockfs.New(t)
const pathArtist = "various-artists"
const pathAlbum = "my-compilation"
const toAdd = 5
// add tracks to one folder with random artists and no album artist tag
for i := 0; i < toAdd; i++ {
p := fmt.Sprintf("%s/%s/track-%d.flac", pathArtist, pathAlbum, i)
m.AddTrack(p)
m.SetTags(p, func(tags *mockfs.Tags) error {
// don't set an album artist
tags.RawTitle = fmt.Sprintf("track %d", i)
tags.RawArtist = fmt.Sprintf("artist %d", i)
tags.RawAlbum = pathArtist
return nil
})
}
m.ScanAndClean()
var trackCount int
is.NoErr(m.DB().Model(&db.Track{}).Count(&trackCount).Error)
is.Equal(trackCount, 5)
var artists []*db.Artist
is.NoErr(m.DB().Preload("Albums").Find(&artists).Error)
is.Equal(len(artists), 1) // we only have one album artist
is.Equal(artists[0].Name, "artist 0") // it came from the first track's fallback to artist tag
is.Equal(len(artists[0].Albums), 1) // the artist has one album
is.Equal(artists[0].Albums[0].RightPath, pathAlbum)
is.Equal(artists[0].Albums[0].LeftPath, pathArtist+"/")
}
func TestIncrementalScanNoChangeNoUpdatedAt(t *testing.T) {
t.Parallel()
is := is.New(t)
m := mockfs.New(t)
m.AddItems()
m.ScanAndClean()
var albumA db.Album
is.NoErr(m.DB().Where("tag_artist_id NOT NULL").Order("updated_at DESC").Find(&albumA).Error)
m.ScanAndClean()
var albumB db.Album
is.NoErr(m.DB().Where("tag_artist_id NOT NULL").Order("updated_at DESC").Find(&albumB).Error)
is.Equal(albumA.UpdatedAt, albumB.UpdatedAt)
}

View File

@@ -1,94 +0,0 @@
package tags
import (
"strconv"
"strings"
"github.com/nicksellen/audiotags"
)
type TagReader struct{}
func (*TagReader) Read(abspath string) (Parser, error) {
raw, props, err := audiotags.Read(abspath)
return &Tagger{raw, props}, err
}
type Tagger struct {
raw map[string]string
props *audiotags.AudioProperties
}
func (t *Tagger) first(keys ...string) string {
for _, key := range keys {
if val, ok := t.raw[key]; ok {
return val
}
}
return ""
}
func (t *Tagger) Title() string { return t.first("title") }
func (t *Tagger) BrainzID() string { return t.first("musicbrainz_trackid") }
func (t *Tagger) Artist() string { return t.first("artist") }
func (t *Tagger) Album() string { return t.first("album") }
func (t *Tagger) AlbumArtist() string { return t.first("albumartist", "album artist") }
func (t *Tagger) AlbumBrainzID() string { return t.first("musicbrainz_albumid") }
func (t *Tagger) Genre() string { return t.first("genre") }
func (t *Tagger) TrackNumber() int { return intSep(t.first("tracknumber"), "/") } // eg. 5/12
func (t *Tagger) DiscNumber() int { return intSep(t.first("discnumber"), "/") } // eg. 1/2
func (t *Tagger) Length() int { return t.props.Length }
func (t *Tagger) Bitrate() int { return t.props.Bitrate }
func (t *Tagger) Year() int { return intSep(t.first("originaldate", "date", "year"), "-") }
func (t *Tagger) SomeAlbum() string { return first("Unknown Album", t.Album()) }
func (t *Tagger) SomeArtist() string { return first("Unknown Artist", t.Artist()) }
func (t *Tagger) SomeAlbumArtist() string {
return first("Unknown Artist", t.AlbumArtist(), t.Artist())
}
func (t *Tagger) SomeGenre() string { return first("Unknown Genre", t.Genre()) }
func first(or string, strs ...string) string {
for _, str := range strs {
if str != "" {
return str
}
}
return or
}
func intSep(in, sep string) int {
if in == "" {
return 0
}
start := strings.SplitN(in, sep, 2)[0]
out, err := strconv.Atoi(start)
if err != nil {
return 0
}
return out
}
type Reader interface {
Read(abspath string) (Parser, error)
}
type Parser interface {
Title() string
BrainzID() string
Artist() string
Album() string
AlbumArtist() string
AlbumBrainzID() string
Genre() string
TrackNumber() int
DiscNumber() int
Length() int
Bitrate() int
Year() int
SomeAlbum() string
SomeArtist() string
SomeAlbumArtist() string
SomeGenre() string
}

View File

@@ -1,3 +0,0 @@
go test fuzz v1
[]byte("001")
int64(3472329395739373616)

View File

@@ -1,3 +0,0 @@
go test fuzz v1
[]byte("001")
int64(3472329395739373616)

View File

@@ -1,3 +0,0 @@
go test fuzz v1
[]byte("")
int64(0)

View File

@@ -1,268 +0,0 @@
package lastfm
import (
"crypto/md5"
"encoding/hex"
"encoding/xml"
"errors"
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"time"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/server/scrobble"
)
const (
baseURL = "https://ws.audioscrobbler.com/2.0/"
)
var (
ErrLastFM = errors.New("last.fm error")
)
type LastFM struct {
XMLName xml.Name `xml:"lfm"`
Status string `xml:"status,attr"`
Session Session `xml:"session"`
Error Error `xml:"error"`
Artist Artist `xml:"artist"`
TopTracks TopTracks `xml:"toptracks"`
SimilarTracks SimilarTracks `xml:"similartracks"`
SimilarArtists SimilarArtists `xml:"similarartists"`
}
type Session struct {
Name string `xml:"name"`
Key string `xml:"key"`
Subscriber uint `xml:"subscriber"`
}
type Error struct {
Code uint `xml:"code,attr"`
Value string `xml:",chardata"`
}
type SimilarArtist struct {
XMLName xml.Name `xml:"artist"`
Name string `xml:"name"`
MBID string `xml:"mbid"`
URL string `xml:"url"`
Image []struct {
Text string `xml:",chardata"`
Size string `xml:"size,attr"`
} `xml:"image"`
Streamable string `xml:"streamable"`
}
type Artist struct {
XMLName xml.Name `xml:"artist"`
Name string `xml:"name"`
MBID string `xml:"mbid"`
URL string `xml:"url"`
Image []struct {
Text string `xml:",chardata"`
Size string `xml:"size,attr"`
} `xml:"image"`
Streamable string `xml:"streamable"`
Stats struct {
Listeners string `xml:"listeners"`
Plays string `xml:"plays"`
} `xml:"stats"`
Similar struct {
Artists []Artist `xml:"artist"`
} `xml:"similar"`
Tags struct {
Tag []ArtistTag `xml:"tag"`
} `xml:"tags"`
Bio ArtistBio `xml:"bio"`
}
type ArtistTag struct {
Name string `xml:"name"`
URL string `xml:"url"`
}
type ArtistBio struct {
Published string `xml:"published"`
Summary string `xml:"summary"`
Content string `xml:"content"`
}
type TopTracks struct {
XMLName xml.Name `xml:"toptracks"`
Artist string `xml:"artist,attr"`
Tracks []Track `xml:"track"`
}
type SimilarTracks struct {
XMLName xml.Name `xml:"similartracks"`
Artist string `xml:"artist,attr"`
Track string `xml:"track,attr"`
Tracks []Track `xml:"track"`
}
type SimilarArtists struct {
XMLName xml.Name `xml:"similarartists"`
Artist string `xml:"artist,attr"`
Artists []Artist `xml:"artist"`
}
type Track struct {
Rank int `xml:"rank,attr"`
Tracks []Track `xml:"track"`
Name string `xml:"name"`
MBID string `xml:"mbid"`
PlayCount int `xml:"playcount"`
Listeners int `xml:"listeners"`
URL string `xml:"url"`
Image []struct {
Text string `xml:",chardata"`
Size string `xml:"size,attr"`
} `xml:"image"`
}
func getParamSignature(params url.Values, secret string) string {
// the parameters must be in order before hashing
paramKeys := make([]string, 0, len(params))
for k := range params {
paramKeys = append(paramKeys, k)
}
sort.Strings(paramKeys)
toHash := ""
for _, k := range paramKeys {
toHash += k
toHash += params[k][0]
}
toHash += secret
hash := md5.Sum([]byte(toHash))
return hex.EncodeToString(hash[:])
}
func makeRequest(method string, params url.Values) (LastFM, error) {
req, _ := http.NewRequest(method, baseURL, nil)
req.URL.RawQuery = params.Encode()
resp, err := http.DefaultClient.Do(req)
if err != nil {
return LastFM{}, fmt.Errorf("get: %w", err)
}
defer resp.Body.Close()
decoder := xml.NewDecoder(resp.Body)
lastfm := LastFM{}
if err = decoder.Decode(&lastfm); err != nil {
return LastFM{}, fmt.Errorf("decoding: %w", err)
}
if lastfm.Error.Code != 0 {
return LastFM{}, fmt.Errorf("%v: %w", lastfm.Error.Value, ErrLastFM)
}
return lastfm, nil
}
func ArtistGetInfo(apiKey string, artistName string) (Artist, error) {
params := url.Values{}
params.Add("method", "artist.getInfo")
params.Add("api_key", apiKey)
params.Add("artist", artistName)
resp, err := makeRequest("GET", params)
if err != nil {
return Artist{}, fmt.Errorf("making artist GET: %w", err)
}
return resp.Artist, nil
}
func ArtistGetTopTracks(apiKey, artistName string) (TopTracks, error) {
params := url.Values{}
params.Add("method", "artist.getTopTracks")
params.Add("api_key", apiKey)
params.Add("artist", artistName)
resp, err := makeRequest("GET", params)
if err != nil {
return TopTracks{}, fmt.Errorf("making track GET: %w", err)
}
return resp.TopTracks, nil
}
func TrackGetSimilarTracks(apiKey string, artistName, trackName string) (SimilarTracks, error) {
params := url.Values{}
params.Add("method", "track.getSimilar")
params.Add("api_key", apiKey)
params.Add("track", trackName)
params.Add("artist", artistName)
resp, err := makeRequest("GET", params)
if err != nil {
return SimilarTracks{}, fmt.Errorf("making track GET: %w", err)
}
return resp.SimilarTracks, nil
}
func ArtistGetSimilar(apiKey string, artistName string) (SimilarArtists, error) {
params := url.Values{}
params.Add("method", "artist.getSimilar")
params.Add("api_key", apiKey)
params.Add("artist", artistName)
resp, err := makeRequest("GET", params)
if err != nil {
return SimilarArtists{}, fmt.Errorf("making similar artists GET: %w", err)
}
return resp.SimilarArtists, nil
}
func GetSession(apiKey, secret, token string) (string, error) {
params := url.Values{}
params.Add("method", "auth.getSession")
params.Add("api_key", apiKey)
params.Add("token", token)
params.Add("api_sig", getParamSignature(params, secret))
resp, err := makeRequest("GET", params)
if err != nil {
return "", fmt.Errorf("making session GET: %w", err)
}
return resp.Session.Key, nil
}
type Scrobbler struct {
DB *db.DB
}
func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stamp time.Time, submission bool) error {
if user.LastFMSession == "" {
return nil
}
apiKey, err := s.DB.GetSetting("lastfm_api_key")
if err != nil {
return fmt.Errorf("get api key: %w", err)
}
secret, err := s.DB.GetSetting("lastfm_secret")
if err != nil {
return fmt.Errorf("get secret: %w", err)
}
// fetch user to get lastfm session
if user.LastFMSession == "" {
return fmt.Errorf("you don't have a last.fm session: %w", ErrLastFM)
}
params := url.Values{}
if submission {
params.Add("method", "track.Scrobble")
// last.fm wants the timestamp in seconds
params.Add("timestamp", strconv.Itoa(int(stamp.Unix())))
} else {
params.Add("method", "track.updateNowPlaying")
}
params.Add("api_key", apiKey)
params.Add("sk", user.LastFMSession)
params.Add("artist", track.TagTrackArtist)
params.Add("track", track.TagTitle)
params.Add("trackNumber", strconv.Itoa(track.TagTrackNumber))
params.Add("album", track.Album.TagTitle)
params.Add("mbid", track.TagBrainzID)
params.Add("albumArtist", track.Artist.Name)
params.Add("api_sig", getParamSignature(params, secret))
_, err = makeRequest("POST", params)
return err
}
var _ scrobble.Scrobbler = (*Scrobbler)(nil)

View File

@@ -1,23 +0,0 @@
package lastfm
import (
"crypto/md5"
"fmt"
"net/url"
"testing"
)
func TestGetParamSignature(t *testing.T) {
params := url.Values{}
params.Add("ccc", "CCC")
params.Add("bbb", "BBB")
params.Add("aaa", "AAA")
params.Add("ddd", "DDD")
actual := getParamSignature(params, "secret")
expected := fmt.Sprintf("%x", md5.Sum([]byte(
"aaaAAAbbbBBBcccCCCdddDDDsecret",
)))
if actual != expected {
t.Errorf("expected %x, got %s", expected, actual)
}
}

View File

@@ -1,104 +0,0 @@
package listenbrainz
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/http/httputil"
"time"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/server/scrobble"
)
const (
BaseURL = "https://api.listenbrainz.org"
submitPath = "/1/submit-listens"
listenTypeSingle = "single"
listenTypePlayingNow = "playing_now"
)
var (
ErrListenBrainz = errors.New("listenbrainz error")
)
type AdditionalInfo struct {
TrackNumber int `json:"tracknumber,omitempty"`
TrackMBID string `json:"track_mbid,omitempty"`
TrackLength int `json:"track_length,omitempty"`
}
type TrackMetadata struct {
AdditionalInfo *AdditionalInfo `json:"additional_info"`
ArtistName string `json:"artist_name,omitempty"`
TrackName string `json:"track_name,omitempty"`
ReleaseName string `json:"release_name,omitempty"`
}
type Payload struct {
ListenedAt int `json:"listened_at,omitempty"`
TrackMetadata *TrackMetadata `json:"track_metadata"`
}
type Scrobble struct {
ListenType string `json:"listen_type,omitempty"`
Payload []*Payload `json:"payload"`
}
type Scrobbler struct{}
func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stamp time.Time, submission bool) error {
if user.ListenBrainzURL == "" || user.ListenBrainzToken == "" {
return nil
}
payload := &Payload{
TrackMetadata: &TrackMetadata{
AdditionalInfo: &AdditionalInfo{
TrackNumber: track.TagTrackNumber,
TrackMBID: track.TagBrainzID,
TrackLength: track.Length,
},
ArtistName: track.TagTrackArtist,
TrackName: track.TagTitle,
ReleaseName: track.Album.TagTitle,
},
}
scrobble := Scrobble{
Payload: []*Payload{payload},
}
if submission && len(scrobble.Payload) > 0 {
scrobble.ListenType = listenTypeSingle
scrobble.Payload[0].ListenedAt = int(stamp.Unix())
} else {
scrobble.ListenType = listenTypePlayingNow
}
payloadBuf := bytes.Buffer{}
if err := json.NewEncoder(&payloadBuf).Encode(scrobble); err != nil {
return err
}
submitURL := fmt.Sprintf("%s%s", user.ListenBrainzURL, submitPath)
authHeader := fmt.Sprintf("Token %s", user.ListenBrainzToken)
req, _ := http.NewRequest(http.MethodPost, submitURL, &payloadBuf)
req.Header.Add("Authorization", authHeader)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("http post: %w", err)
}
defer resp.Body.Close()
respBytes, _ := httputil.DumpResponse(resp, true)
switch {
case resp.StatusCode == http.StatusUnauthorized:
return fmt.Errorf("unathorized: %w", ErrListenBrainz)
case resp.StatusCode >= 400:
log.Println("received listenbrainz response")
log.Println(string(respBytes))
return fmt.Errorf(">= 400: %d: %w", resp.StatusCode, ErrListenBrainz)
}
return nil
}
var _ scrobble.Scrobbler = (*Scrobbler)(nil)

View File

@@ -1,11 +0,0 @@
package scrobble
import (
"time"
"go.senan.xyz/gonic/server/db"
)
type Scrobbler interface {
Scrobble(user *db.User, track *db.Track, stamp time.Time, submission bool) error
}

View File

@@ -15,15 +15,15 @@ import (
"go.senan.xyz/gonic/server/ctrladmin"
"go.senan.xyz/gonic/server/ctrlbase"
"go.senan.xyz/gonic/server/ctrlsubsonic"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/server/jukebox"
"go.senan.xyz/gonic/server/podcasts"
"go.senan.xyz/gonic/server/scanner"
"go.senan.xyz/gonic/server/scanner/tags"
"go.senan.xyz/gonic/server/scrobble"
"go.senan.xyz/gonic/server/scrobble/lastfm"
"go.senan.xyz/gonic/server/scrobble/listenbrainz"
"go.senan.xyz/gonic/server/transcode"
"go.senan.xyz/gonic/db"
"go.senan.xyz/gonic/jukebox"
"go.senan.xyz/gonic/podcasts"
"go.senan.xyz/gonic/scanner"
"go.senan.xyz/gonic/scanner/tags"
"go.senan.xyz/gonic/scrobble"
"go.senan.xyz/gonic/scrobble/lastfm"
"go.senan.xyz/gonic/scrobble/listenbrainz"
"go.senan.xyz/gonic/transcode"
)
type Options struct {

Binary file not shown.

Binary file not shown.

View File

@@ -1,3 +0,0 @@
go test fuzz v1
byte('Y')
byte('\x05')

View File

@@ -1,3 +0,0 @@
go test fuzz v1
byte('\x15')
byte('}')

View File

@@ -1,3 +0,0 @@
go test fuzz v1
byte('\a')
byte('\x02')

View File

@@ -1,129 +0,0 @@
// author: spijet (https://github.com/spijet/)
// author: sentriz (https://github.com/sentriz/)
//nolint:gochecknoglobals
package transcode
import (
"context"
"fmt"
"io"
"os/exec"
"time"
"github.com/google/shlex"
)
type Transcoder interface {
Transcode(ctx context.Context, profile Profile, in string) (io.ReadCloser, error)
}
var UserProfiles = map[string]Profile{
"mp3": MP3,
"mp3_rg": MP3RG,
"opus_car": OpusCar,
"opus": Opus,
"opus_rg": OpusRG,
}
// Store as simple strings, since we may let the user provide their own profiles soon
var (
MP3 = NewProfile("audio/mpeg", 128, `ffmpeg -v 0 -i <file> -ss <seek> -map 0:a:0 -vn -b:a <bitrate> -c:a libmp3lame -af "volume=replaygain=track:replaygain_preamp=6dB:replaygain_noclip=0, alimiter=level=disabled, asidedata=mode=delete:type=REPLAYGAIN" -metadata replaygain_album_gain= -metadata replaygain_album_peak= -metadata replaygain_track_gain= -metadata replaygain_track_peak= -metadata r128_album_gain= -metadata r128_track_gain= -f mp3 -`)
MP3RG = NewProfile("audio/mpeg", 128, `ffmpeg -v 0 -i <file> -ss <seek> -map 0:a:0 -vn -b:a <bitrate> -c:a libmp3lame -af "volume=replaygain=track:replaygain_preamp=6dB:replaygain_noclip=0, alimiter=level=disabled, asidedata=mode=delete:type=REPLAYGAIN" -metadata replaygain_album_gain= -metadata replaygain_album_peak= -metadata replaygain_track_gain= -metadata replaygain_track_peak= -metadata r128_album_gain= -metadata r128_track_gain= -f mp3 -`)
// this sets a baseline gain which results in the final track being +3~5dB louder than
// Foobar2000's default ReplayGain target volume.
// this makes it easier to listen to music in a car, where all other
// sources are usually ten thousand times louder than RG-adjusted music.
//
// opus always forces output to 48kHz sampling rate, but we can still use upsampling
// to increase RG and alimiter's peak limiting precision, which is desirable in some
// cases. ffmpeg's `soxr` resampler is quite fast on x86-64: it takes around 5 seconds
// on my Ryzen 3600 to transcode an 8-minute FLAC with 2x upsample and RG applied.
//
// -- @spijet
OpusCar = NewProfile("audio/ogg", 96, `ffmpeg -v 0 -i <file> -ss <seek> -map 0:a:0 -vn -b:a <bitrate> -c:a libopus -vbr on -af "aresample=96000:resampler=soxr, volume=replaygain=track:replaygain_preamp=15dB:replaygain_noclip=0, alimiter=level=disabled, asidedata=mode=delete:type=REPLAYGAIN" -f opus -`)
Opus = NewProfile("audio/ogg", 96, `ffmpeg -v 0 -i <file> -ss <seek> -map 0:a:0 -vn -b:a <bitrate> -c:a libopus -vbr on -af "volume=replaygain=track:replaygain_preamp=6dB:replaygain_noclip=0, alimiter=level=disabled, asidedata=mode=delete:type=REPLAYGAIN" -metadata replaygain_album_gain= -metadata replaygain_album_peak= -metadata replaygain_track_gain= -metadata replaygain_track_peak= -metadata r128_album_gain= -metadata r128_track_gain= -f opus -`)
OpusRG = NewProfile("audio/ogg", 96, `ffmpeg -v 0 -i <file> -ss <seek> -map 0:a:0 -vn -b:a <bitrate> -c:a libopus -vbr on -af "volume=replaygain=track:replaygain_preamp=6dB:replaygain_noclip=0, alimiter=level=disabled, asidedata=mode=delete:type=REPLAYGAIN" -metadata replaygain_album_gain= -metadata replaygain_album_peak= -metadata replaygain_track_gain= -metadata replaygain_track_peak= -metadata r128_album_gain= -metadata r128_track_gain= -f opus -`)
PCM16le = NewProfile("audio/wav", 0, `ffmpeg -v 0 -i <file> -ss <seek> -c:a pcm_s16le -ac 2 -f s16le -`)
)
type BitRate int // kb/s
type Profile struct {
bitrate BitRate // the default bitrate, but the user can request a different one
seek time.Duration
mime string
exec string
}
func (p *Profile) BitRate() BitRate { return p.bitrate }
func (p *Profile) Seek() time.Duration { return p.seek }
func (p *Profile) MIME() string { return p.mime }
func NewProfile(mime string, bitrate BitRate, exec string) Profile {
return Profile{mime: mime, bitrate: bitrate, exec: exec}
}
func WithBitrate(p Profile, bitRate BitRate) Profile {
p.bitrate = bitRate
return p
}
func WithSeek(p Profile, seek time.Duration) Profile {
p.seek = seek
return p
}
var ErrNoProfileParts = fmt.Errorf("not enough profile parts")
func parseProfile(profile Profile, in string) (string, []string, error) {
parts, err := shlex.Split(profile.exec)
if err != nil {
return "", nil, fmt.Errorf("split command: %w", err)
}
if len(parts) == 0 {
return "", nil, ErrNoProfileParts
}
name, err := exec.LookPath(parts[0])
if err != nil {
return "", nil, fmt.Errorf("find name: %w", err)
}
var args []string
for _, p := range parts[1:] {
switch p {
case "<file>":
args = append(args, in)
case "<seek>":
args = append(args, fmt.Sprintf("%dus", profile.Seek().Microseconds()))
case "<bitrate>":
args = append(args, fmt.Sprintf("%dk", profile.BitRate()))
default:
args = append(args, p)
}
}
return name, args, nil
}
// GuessExpectedSize guesses how big the transcoded file will be in bytes.
// Handy if we want to send a Content-Length header to the client before
// the transcode has finished. This way, clients like DSub can render their
// scrub bar and duration as the track is streaming.
//
// The estimate should overshoot a bit (2s in this case) otherwise some HTTP
// clients will shit their trousers given some unexpected bytes.
func GuessExpectedSize(profile Profile, length time.Duration) int {
if length == 0 {
return 0
}
bytesPerSec := int(profile.BitRate() * 1000 / 8)
var guess int
guess += bytesPerSec * int(length.Seconds()-profile.seek.Seconds())
guess += bytesPerSec * 2 // 2s pading
guess += 10000 // 10kb byte padding
return guess
}

View File

@@ -1,47 +0,0 @@
//go:build go1.18
// +build go1.18
package transcode_test
import (
"context"
"io"
"testing"
"time"
"github.com/matryer/is"
"go.senan.xyz/gonic/server/transcode"
)
// FuzzGuessExpectedSize makes sure all of our profile's estimated transcode
// file sizes are slightly bigger than the real thing.
func FuzzGuessExpectedSize(f *testing.F) {
var profiles []transcode.Profile
for _, v := range transcode.UserProfiles {
profiles = append(profiles, v)
}
type track struct {
path string
length time.Duration
}
var tracks []track
tracks = append(tracks, track{"testdata/5s.mp3", 5 * time.Second})
tracks = append(tracks, track{"testdata/10s.mp3", 10 * time.Second})
tr := transcode.NewFFmpegTranscoder()
f.Fuzz(func(t *testing.T, pseed uint8, tseed uint8) {
is := is.New(t)
profile := profiles[int(pseed)%len(profiles)]
track := tracks[int(tseed)%len(tracks)]
sizeGuess := transcode.GuessExpectedSize(profile, track.length)
reader, err := tr.Transcode(context.Background(), profile, track.path)
is.NoErr(err)
actual, err := io.ReadAll(reader)
is.NoErr(err)
is.True(sizeGuess > len(actual))
})
}

View File

@@ -1,65 +0,0 @@
package transcode
import (
"context"
"crypto/md5"
"fmt"
"io"
"os"
"path/filepath"
"go.senan.xyz/gonic/iout"
)
const perm = 0644
type CachingTranscoder struct {
cachePath string
transcoder Transcoder
}
var _ Transcoder = (*CachingTranscoder)(nil)
func NewCachingTranscoder(t Transcoder, cachePath string) *CachingTranscoder {
return &CachingTranscoder{transcoder: t, cachePath: cachePath}
}
func (t *CachingTranscoder) Transcode(ctx context.Context, profile Profile, in string) (io.ReadCloser, error) {
if err := os.MkdirAll(t.cachePath, perm^0111); err != nil {
return nil, fmt.Errorf("make cache path: %w", err)
}
name, args, err := parseProfile(profile, in)
if err != nil {
return nil, fmt.Errorf("split command: %w", err)
}
key := cacheKey(name, args)
path := filepath.Join(t.cachePath, key)
cf, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0644)
if err != nil {
return nil, fmt.Errorf("open cache file: %w", err)
}
if i, err := cf.Stat(); err == nil && i.Size() > 0 {
return cf, nil
}
out, err := t.transcoder.Transcode(ctx, profile, in)
if err != nil {
return nil, fmt.Errorf("internal transcode: %w", err)
}
return iout.NewTeeCloser(out, cf), nil
}
func cacheKey(cmd string, args []string) string {
// the cache is invalid whenever transcode command (which includes the
// absolute filepath, bit rate args, replay gain args, etc.) changes
sum := md5.New()
_, _ = io.WriteString(sum, cmd)
for _, arg := range args {
_, _ = io.WriteString(sum, arg)
}
return fmt.Sprintf("%x", sum.Sum(nil))
}

View File

@@ -1,39 +0,0 @@
package transcode
import (
"context"
"fmt"
"io"
"os/exec"
)
type FFmpegTranscoder struct{}
var _ Transcoder = (*FFmpegTranscoder)(nil)
func NewFFmpegTranscoder() *FFmpegTranscoder {
return &FFmpegTranscoder{}
}
var ErrFFmpegExit = fmt.Errorf("ffmpeg exited with non 0 status code")
func (*FFmpegTranscoder) Transcode(ctx context.Context, profile Profile, in string) (io.ReadCloser, error) {
name, args, err := parseProfile(profile, in)
if err != nil {
return nil, fmt.Errorf("split command: %w", err)
}
preader, pwriter := io.Pipe()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdout = pwriter
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("starting cmd: %w", err)
}
go func() {
_ = pwriter.CloseWithError(cmd.Wait())
}()
return preader, nil
}

View File

@@ -1,19 +0,0 @@
package transcode
import (
"context"
"io"
"os"
)
type NoneTranscoder struct{}
var _ Transcoder = (*NoneTranscoder)(nil)
func NewNoneTranscoder() *NoneTranscoder {
return &NoneTranscoder{}
}
func (*NoneTranscoder) Transcode(ctx context.Context, _ Profile, in string) (io.ReadCloser, error) {
return os.Open(in)
}