refactor: move shared packages up a level
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
|
||||
"go.senan.xyz/gonic/server/db"
|
||||
"go.senan.xyz/gonic/db"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
139
server/db/db.go
139
server/db/db.go
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
83
server/podcasts/testdata/rss.new
vendored
83
server/podcasts/testdata/rss.new
vendored
@@ -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>
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,3 +0,0 @@
|
||||
go test fuzz v1
|
||||
[]byte("001")
|
||||
int64(3472329395739373616)
|
||||
@@ -1,3 +0,0 @@
|
||||
go test fuzz v1
|
||||
[]byte("001")
|
||||
int64(3472329395739373616)
|
||||
@@ -1,3 +0,0 @@
|
||||
go test fuzz v1
|
||||
[]byte("")
|
||||
int64(0)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
BIN
server/transcode/testdata/10s.mp3
vendored
BIN
server/transcode/testdata/10s.mp3
vendored
Binary file not shown.
BIN
server/transcode/testdata/5s.mp3
vendored
BIN
server/transcode/testdata/5s.mp3
vendored
Binary file not shown.
@@ -1,3 +0,0 @@
|
||||
go test fuzz v1
|
||||
byte('Y')
|
||||
byte('\x05')
|
||||
@@ -1,3 +0,0 @@
|
||||
go test fuzz v1
|
||||
byte('\x15')
|
||||
byte('}')
|
||||
@@ -1,3 +0,0 @@
|
||||
go test fuzz v1
|
||||
byte('\a')
|
||||
byte('\x02')
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user