feat: watch playlists and download newly added tracks
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var watchCmd = &cobra.Command{
|
||||||
|
Use: "watch",
|
||||||
|
Short: "Watch playlists and auto-download new tracks",
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RootCmd.AddCommand(watchCmd)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mathismqn/godeez/internal/watcher"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var watchRunCmd = &cobra.Command{
|
||||||
|
Use: "run",
|
||||||
|
Short: "Start the background playlist watcher",
|
||||||
|
Hidden: true,
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
ctx := cmd.Context()
|
||||||
|
w := watcher.New(appConfig)
|
||||||
|
w.Run(ctx, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
watchCmd.AddCommand(watchRunCmd)
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/mathismqn/godeez/internal/crypto"
|
"github.com/mathismqn/godeez/internal/crypto"
|
||||||
"github.com/mathismqn/godeez/internal/deezer"
|
"github.com/mathismqn/godeez/internal/deezer"
|
||||||
"github.com/mathismqn/godeez/internal/fileutil"
|
"github.com/mathismqn/godeez/internal/fileutil"
|
||||||
|
"github.com/mathismqn/godeez/internal/logger"
|
||||||
"github.com/mathismqn/godeez/internal/store"
|
"github.com/mathismqn/godeez/internal/store"
|
||||||
"github.com/mathismqn/godeez/internal/tags"
|
"github.com/mathismqn/godeez/internal/tags"
|
||||||
)
|
)
|
||||||
@@ -26,6 +27,7 @@ type Client struct {
|
|||||||
appConfig *config.Config
|
appConfig *config.Config
|
||||||
resourceType string
|
resourceType string
|
||||||
deezerClient *deezer.Client
|
deezerClient *deezer.Client
|
||||||
|
Logger *logger.Logger
|
||||||
|
|
||||||
hashIndexOnce sync.Once
|
hashIndexOnce sync.Once
|
||||||
hashIndex *fileutil.HashIndex
|
hashIndex *fileutil.HashIndex
|
||||||
@@ -117,7 +119,9 @@ func (c *Client) Run(ctx context.Context, opts Options, id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
failed++
|
failed++
|
||||||
|
c.Logger.Errorf("Failed to download %s - %s: %v\n", song.Artist, song.Title, err)
|
||||||
fmt.Printf("%s ✖ Failed: %s - %s:\n Error: %v\n", trackProgress, song.Artist, song.Title, err)
|
fmt.Printf("%s ✖ Failed: %s - %s:\n Error: %v\n", trackProgress, song.Artist, song.Title, err)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,12 +131,18 @@ func (c *Client) Run(ctx context.Context, opts Options, id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
downloaded++
|
downloaded++
|
||||||
|
c.Logger.Infof("Downloaded %s - %s\n", song.Artist, song.Title)
|
||||||
fmt.Printf("%s %s Downloaded: %s - %s\n", trackProgress, symbol, song.Artist, song.Title)
|
fmt.Printf("%s %s Downloaded: %s - %s\n", trackProgress, symbol, song.Artist, song.Title)
|
||||||
|
|
||||||
for _, w := range warnings {
|
for _, w := range warnings {
|
||||||
|
c.Logger.Warnf("Warning: %s\n", w)
|
||||||
fmt.Printf(" Warning: %s\n", w)
|
fmt.Printf(" Warning: %s\n", w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if downloaded > 0 || failed > 0 {
|
||||||
|
c.Logger.Infof("Playlist %s (%s): %d downloaded, %d skipped, %d failed\n", resource.GetTitle(), id, downloaded, skipped, failed)
|
||||||
|
}
|
||||||
fmt.Printf(`
|
fmt.Printf(`
|
||||||
================== [ Summary ] ==================
|
================== [ Summary ] ==================
|
||||||
Downloaded: %d
|
Downloaded: %d
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package logger
|
||||||
|
|
||||||
|
import "log"
|
||||||
|
|
||||||
|
type Logger struct {
|
||||||
|
l *log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(l *log.Logger) *Logger {
|
||||||
|
return &Logger{l: l}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Infof(format string, args ...any) {
|
||||||
|
if l.l != nil {
|
||||||
|
l.l.Printf("[INFO] "+format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Warnf(format string, args ...any) {
|
||||||
|
if l.l != nil {
|
||||||
|
l.l.Printf("[WARN] "+format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Errorf(format string, args ...any) {
|
||||||
|
if l.l != nil {
|
||||||
|
l.l.Printf("[ERROR] "+format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ type DownloadInfo struct {
|
|||||||
Downloaded time.Time `json:"downloaded_at"`
|
Downloaded time.Time `json:"downloaded_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var trackBucket = []byte("tracks")
|
||||||
|
|
||||||
func GetDownloadInfo(songID string) (*DownloadInfo, error) {
|
func GetDownloadInfo(songID string) (*DownloadInfo, error) {
|
||||||
var info DownloadInfo
|
var info DownloadInfo
|
||||||
|
|
||||||
@@ -39,9 +41,9 @@ func GetDownloadInfo(songID string) (*DownloadInfo, error) {
|
|||||||
|
|
||||||
func (d *DownloadInfo) Save() error {
|
func (d *DownloadInfo) Save() error {
|
||||||
return db.Update(func(tx *bbolt.Tx) error {
|
return db.Update(func(tx *bbolt.Tx) error {
|
||||||
b := tx.Bucket(trackBucket)
|
b, err := tx.CreateBucketIfNotExists(trackBucket)
|
||||||
if b == nil {
|
if err != nil {
|
||||||
return fmt.Errorf("bucket not found")
|
return fmt.Errorf("failed to create bucket: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := json.Marshal(d)
|
data, err := json.Marshal(d)
|
||||||
@@ -49,11 +51,6 @@ func (d *DownloadInfo) Save() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = b.Put([]byte(d.SongID), data)
|
return b.Put([]byte(d.SongID), data)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-11
@@ -7,10 +7,7 @@ import (
|
|||||||
bolt "go.etcd.io/bbolt"
|
bolt "go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var db *bolt.DB
|
||||||
db *bolt.DB
|
|
||||||
trackBucket = []byte("tracks")
|
|
||||||
)
|
|
||||||
|
|
||||||
func OpenDB(cfgDir string) error {
|
func OpenDB(cfgDir string) error {
|
||||||
var err error
|
var err error
|
||||||
@@ -21,12 +18,5 @@ func OpenDB(cfgDir string) error {
|
|||||||
return fmt.Errorf("failed to open database: %w", err)
|
return fmt.Errorf("failed to open database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Update(func(tx *bolt.Tx) error {
|
|
||||||
_, err := tx.CreateBucketIfNotExists(trackBucket)
|
|
||||||
return err
|
|
||||||
}); err != nil {
|
|
||||||
return fmt.Errorf("failed to create bucket: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
bolt "go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WatchedPlaylist struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Quality string `json:"quality"`
|
||||||
|
BPM bool `json:"bpm"`
|
||||||
|
Timeout time.Duration `json:"timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var watchedBucket = []byte("watched")
|
||||||
|
|
||||||
|
func ListWatchedPlaylists() ([]*WatchedPlaylist, error) {
|
||||||
|
var playlists []*WatchedPlaylist
|
||||||
|
if err := db.View(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket(watchedBucket)
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.ForEach(func(k, v []byte) error {
|
||||||
|
var p WatchedPlaylist
|
||||||
|
if err := json.Unmarshal(v, &p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
playlists = append(playlists, &p)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return playlists, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WatchedPlaylist) Save() error {
|
||||||
|
return db.Update(func(tx *bolt.Tx) error {
|
||||||
|
b, err := tx.CreateBucketIfNotExists(watchedBucket)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.Put([]byte(p.ID), data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveWatchedPlaylist(playlistID string) error {
|
||||||
|
return db.Update(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket(watchedBucket)
|
||||||
|
if b == nil {
|
||||||
|
return fmt.Errorf("bucket not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.Delete([]byte(playlistID))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsWatched(playlistID string) (bool, error) {
|
||||||
|
var found bool
|
||||||
|
err := db.View(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket(watchedBucket)
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
found = b.Get([]byte(playlistID)) != nil
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return found, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package watcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathismqn/godeez/internal/config"
|
||||||
|
"github.com/mathismqn/godeez/internal/downloader"
|
||||||
|
"github.com/mathismqn/godeez/internal/logger"
|
||||||
|
"github.com/mathismqn/godeez/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Watcher struct {
|
||||||
|
appConfig *config.Config
|
||||||
|
logger *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(appConfig *config.Config) *Watcher {
|
||||||
|
homeDir, _ := os.UserHomeDir()
|
||||||
|
logFile := filepath.Join(homeDir, ".godeez", "watcher.log")
|
||||||
|
file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to open log file: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
base := log.New(file, "", log.LstdFlags)
|
||||||
|
log := logger.New(base)
|
||||||
|
|
||||||
|
return &Watcher{
|
||||||
|
appConfig: appConfig,
|
||||||
|
logger: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Watcher) Run(ctx context.Context, opts downloader.Options) {
|
||||||
|
w.logger.Infof("Starting watcher...")
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
playlists, err := store.ListWatchedPlaylists()
|
||||||
|
if err != nil {
|
||||||
|
w.logger.Errorf("Failed to list watched playlists: %v\n", err)
|
||||||
|
} else {
|
||||||
|
for _, playlist := range playlists {
|
||||||
|
dl := downloader.New(w.appConfig, "playlist")
|
||||||
|
dl.Logger = w.logger
|
||||||
|
if err := dl.Run(ctx, opts, playlist.ID); err != nil {
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.logger.Errorf("Playlist %s: %v\n", playlist.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(15 * time.Minute):
|
||||||
|
// Continue to the next iteration to check for updates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user