451 lines
10 KiB
Go
451 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
|
|
"gorm.io/driver/mysql"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/dhowden/tag"
|
|
"github.com/fsnotify/fsnotify"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/spf13/viper"
|
|
|
|
"github.com/icedream/livestream-tools/icedreammusic/metacollector"
|
|
)
|
|
|
|
const (
|
|
appID = "metacollector"
|
|
appName = "Metadata collector"
|
|
appEnvPrefix = appID
|
|
|
|
configDatabase = "Database"
|
|
configDatabaseType = configDatabase + ".Type"
|
|
configDatabaseURL = configDatabase + ".URL"
|
|
|
|
configLibrary = "Library"
|
|
configLibraryPaths = configLibrary + ".Paths"
|
|
|
|
configServer = "Server"
|
|
configServerAddress = configServer + ".Address"
|
|
)
|
|
|
|
type track struct {
|
|
gorm.Model
|
|
Artist string `gorm:"index:idx_artist_title"`
|
|
Title string `gorm:"index:idx_artist_title"`
|
|
Publisher string
|
|
CoverFile *file `gorm:"ForeignKey:CoverFileID;"`
|
|
CoverFileID *uint
|
|
Path string `gorm:"index:idx_path"`
|
|
}
|
|
|
|
type file struct {
|
|
gorm.Model
|
|
Data []byte
|
|
ContentType string
|
|
}
|
|
|
|
type manager struct {
|
|
database *gorm.DB
|
|
}
|
|
|
|
func newManager(appDatabase *gorm.DB) *manager {
|
|
m := &manager{
|
|
database: appDatabase.Debug(),
|
|
}
|
|
|
|
return m
|
|
}
|
|
|
|
func (m *manager) Migrate() error {
|
|
if err := m.database.AutoMigrate(&file{}); err != nil {
|
|
return err
|
|
}
|
|
if err := m.database.AutoMigrate(&track{}); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *manager) GetFile(id uint) (result *file, err error) {
|
|
result = new(file)
|
|
err = m.database.Find(result, id).Error
|
|
return
|
|
}
|
|
|
|
func (m *manager) UploadFile(file *file) (err error) {
|
|
err = m.database.Save(file).Error
|
|
return
|
|
}
|
|
|
|
func (m *manager) DeleteFile(file *file) (err error) {
|
|
err = m.database.Delete(file).Error
|
|
return
|
|
}
|
|
|
|
func getPublisherFromTags(tags tag.Metadata) string {
|
|
for _, tagName := range []string{
|
|
// ID3v2
|
|
"PUBLISHER",
|
|
"GROUPING",
|
|
"TPUB",
|
|
// VORBIS
|
|
"organization",
|
|
"publisher",
|
|
"grouping",
|
|
} {
|
|
if value, ok := tags.Raw()[tagName]; ok {
|
|
if valueString, ok := value.(string); ok && len(strings.TrimSpace(valueString)) > 0 {
|
|
return valueString
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (m *manager) UpdateFileFromFilesystem(filePath string) (err error) {
|
|
f, err := os.Open(filePath)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
tags, err := tag.ReadFrom(f)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Try to find old information we can reuse
|
|
trackObj, err := m.GetTrackByArtistAndTitle(tags.Artist(), tags.Title(), false)
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
trackObj = new(track)
|
|
} else if err != nil {
|
|
return // something went terribly wrong
|
|
}
|
|
|
|
trackObj.Artist = tags.Artist()
|
|
trackObj.Title = tags.Title()
|
|
trackObj.Path = filePath
|
|
|
|
// Cover
|
|
if tags.Picture() != nil {
|
|
trackObj.CoverFile = new(file)
|
|
if trackObj.CoverFileID != nil {
|
|
trackObj.CoverFile.ID = *trackObj.CoverFileID
|
|
}
|
|
trackObj.CoverFile.ContentType = tags.Picture().MIMEType
|
|
trackObj.CoverFile.Data = tags.Picture().Data
|
|
}
|
|
|
|
// Publisher
|
|
publisher := getPublisherFromTags(tags)
|
|
if len(publisher) > 0 {
|
|
trackObj.Publisher = publisher
|
|
}
|
|
|
|
m.database.Save(trackObj)
|
|
|
|
return
|
|
}
|
|
|
|
func (m *manager) WriteTrack(track *track) (err error) {
|
|
// If we got no ID, try to find out first whether we already have an entry with same title and artist
|
|
result, err := m.GetTrackByArtistAndTitle(track.Artist, track.Title, false)
|
|
if err == nil {
|
|
// Found one!
|
|
track.ID = result.ID
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
// Something went really wrong here…
|
|
return
|
|
} else {
|
|
err = nil
|
|
}
|
|
|
|
err = m.database.Save(track).Error
|
|
return
|
|
}
|
|
|
|
func (m *manager) GetTrackByPath(path string, withCoverFile bool) (result *track, err error) {
|
|
result = new(track)
|
|
db := m.database
|
|
if withCoverFile {
|
|
db = db.Preload("CoverFile")
|
|
}
|
|
err = db.
|
|
Where("path = @path",
|
|
sql.Named("path", path)).
|
|
Find(result).Error
|
|
return
|
|
}
|
|
|
|
func (m *manager) GetTrackByArtistAndTitle(artist, title string, withCoverFile bool) (result *track, err error) {
|
|
result = new(track)
|
|
db := m.database
|
|
if withCoverFile {
|
|
db = db.Preload("CoverFile")
|
|
}
|
|
err = db.
|
|
Where("artist = @artist AND title COLLATE UTF8_GENERAL_CI LIKE @title",
|
|
sql.Named("artist", artist),
|
|
sql.Named("title", title+"%")).
|
|
Find(result).Error
|
|
return
|
|
}
|
|
|
|
func main() {
|
|
var err error
|
|
defer func() {
|
|
if err != nil {
|
|
log.Panic(err)
|
|
}
|
|
}()
|
|
|
|
viper.AddConfigPath("/etc/" + appID + "/")
|
|
viper.AddConfigPath("$HOME/.config/" + appID + "/")
|
|
viper.AddConfigPath("$HOME/." + appID + "/")
|
|
viper.AddConfigPath(".")
|
|
viper.AutomaticEnv()
|
|
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
|
|
viper.SetEnvPrefix(appEnvPrefix)
|
|
err = viper.ReadInConfig()
|
|
if err != nil {
|
|
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
|
|
// Config file not found; ignore error if desired
|
|
err = nil
|
|
log.Println("No configuration file found, ignoring")
|
|
} else {
|
|
// Config file was found but another error was produced
|
|
log.Panicf("Failed to read configuration: %s", err.Error())
|
|
}
|
|
}
|
|
|
|
viper.SetDefault(configDatabaseType, "sqlite")
|
|
viper.SetDefault(configDatabaseURL, "app.db")
|
|
viper.SetDefault(configLibraryPaths, []string{})
|
|
|
|
viper.Debug()
|
|
|
|
var dialector gorm.Dialector
|
|
switch viper.GetString(configDatabaseType) {
|
|
case "sqlite":
|
|
dialector = sqlite.Open(viper.GetString(configDatabaseURL))
|
|
case "mysql":
|
|
dialector = mysql.New(
|
|
mysql.Config{
|
|
DSN: viper.GetString(configDatabaseURL),
|
|
})
|
|
default:
|
|
err = fmt.Errorf("Unsupported config database type: %s", viper.GetString(configDatabaseType))
|
|
return
|
|
}
|
|
gormDatabase, err := gorm.Open(dialector, &gorm.Config{})
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
m := newManager(gormDatabase)
|
|
if err = m.Migrate(); err != nil {
|
|
return
|
|
}
|
|
|
|
// Handle shutdown gracefully
|
|
ctx := context.Background() // TODO - Timeouts and fancy stuff
|
|
signalChannel := make(chan os.Signal, 1)
|
|
cond := sync.NewCond(&sync.Mutex{})
|
|
go signal.Notify(signalChannel, syscall.SIGINT, syscall.SIGTERM)
|
|
go func() {
|
|
<-signalChannel
|
|
cond.L.Lock()
|
|
cond.Broadcast()
|
|
cond.L.Unlock()
|
|
}()
|
|
|
|
wg := new(sync.WaitGroup)
|
|
|
|
// Set up HTTP server
|
|
r := gin.Default()
|
|
r.GET("/file/:fileID", func(c *gin.Context) {
|
|
fileID, err := strconv.ParseUint(c.Param("fileID"), 10, 32)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, "invalid file ID")
|
|
return
|
|
}
|
|
file, err := m.GetFile(uint(fileID))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
c.Header("Content-type", file.ContentType)
|
|
c.Writer.Write(file.Data)
|
|
})
|
|
r.POST("/track/find", func(c *gin.Context) {
|
|
var form struct {
|
|
Artist string
|
|
Title string
|
|
}
|
|
if err := c.ShouldBind(&form); err != nil {
|
|
c.JSON(http.StatusBadRequest, "missing POST data")
|
|
return
|
|
}
|
|
if len(form.Artist) <= 0 {
|
|
c.JSON(http.StatusBadRequest, "artist must not be empty")
|
|
return
|
|
}
|
|
if len(form.Title) <= 0 {
|
|
c.JSON(http.StatusBadRequest, "title must not be empty")
|
|
return
|
|
}
|
|
track, err := m.GetTrackByArtistAndTitle(form.Artist, form.Title, false)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
response := metacollector.MetaCollectorResponse{
|
|
Artist: track.Artist,
|
|
Title: track.Title,
|
|
Publisher: track.Publisher,
|
|
}
|
|
if track.CoverFileID != nil {
|
|
coverURL := fmt.Sprintf("/file/%d", *track.CoverFileID)
|
|
response.CoverURL = &coverURL
|
|
}
|
|
c.JSON(http.StatusOK, response)
|
|
})
|
|
server := new(http.Server)
|
|
server.Addr = viper.GetString(configServerAddress)
|
|
server.Handler = r
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
if err := server.ListenAndServe(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}()
|
|
go func() {
|
|
cond.L.Lock()
|
|
cond.Wait()
|
|
cond.L.Unlock()
|
|
if err := server.Shutdown(ctx); err != nil {
|
|
log.Print("During server shutdown:", err)
|
|
}
|
|
}()
|
|
|
|
// Watch library paths
|
|
watcher, err := fsnotify.NewWatcher()
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
defer watcher.Close()
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
quitSignalChannel := make(chan interface{}, 1)
|
|
go func() {
|
|
cond.L.Lock()
|
|
cond.Wait()
|
|
cond.L.Unlock()
|
|
quitSignalChannel <- nil
|
|
}()
|
|
for {
|
|
select {
|
|
case <-quitSignalChannel:
|
|
watcher.Close()
|
|
return
|
|
case event, ok := <-watcher.Events:
|
|
if !ok {
|
|
return
|
|
}
|
|
// log.Println("event:", event)
|
|
if event.Op&fsnotify.Write == fsnotify.Write {
|
|
log.Println("modified file:", event.Name)
|
|
err = m.UpdateFileFromFilesystem(event.Name)
|
|
if err != nil {
|
|
log.Printf("Failed to update tags from file system for %s: %s", event.Name, err)
|
|
}
|
|
}
|
|
case err, ok := <-watcher.Errors:
|
|
if !ok {
|
|
return
|
|
}
|
|
log.Println("error:", err)
|
|
}
|
|
}
|
|
}()
|
|
|
|
firstScanQuitSignalChannel := make(chan interface{}, 1)
|
|
go func() {
|
|
cond.L.Lock()
|
|
cond.Wait()
|
|
cond.L.Unlock()
|
|
firstScanQuitSignalChannel <- nil
|
|
}()
|
|
for _, watchPath := range viper.GetStringSlice(configLibraryPaths) {
|
|
if len(strings.TrimSpace(watchPath)) <= 0 {
|
|
continue // ignore empty path entries
|
|
}
|
|
|
|
log.Println("Adding path to watcher:", watchPath)
|
|
err = watcher.Add(watchPath)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Force first scan
|
|
filepath.WalkDir(watchPath, func(filePath string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
select {
|
|
case <-firstScanQuitSignalChannel:
|
|
return nil // just quit out asap
|
|
default:
|
|
}
|
|
|
|
// skip directory entries
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
switch strings.ToLower(filepath.Ext(filePath)) {
|
|
case ".ogg", ".mp3", ".m4a", ".aac", ".flac", ".wav", ".wma", ".wv":
|
|
// check if we already have an entry in the database that is up to date
|
|
entry, err := m.GetTrackByPath(filePath, false)
|
|
if err == nil {
|
|
stat, err := d.Info()
|
|
if err == nil {
|
|
// entry.UpdatedAt same or newer than file mod time, skip
|
|
if entry.UpdatedAt.Sub(stat.ModTime()) >= 0 {
|
|
log.Println("skipping:", filePath)
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
log.Println("updating:", filePath)
|
|
err = m.UpdateFileFromFilesystem(filePath)
|
|
if err != nil {
|
|
log.Printf("Failed to update tags from file system for %s: %s", filePath, err)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
wg.Wait()
|
|
}
|