diff --git a/cmd/watch.go b/cmd/watch.go new file mode 100644 index 0000000..b92be07 --- /dev/null +++ b/cmd/watch.go @@ -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) +} diff --git a/cmd/watch_run.go b/cmd/watch_run.go new file mode 100644 index 0000000..b3177db --- /dev/null +++ b/cmd/watch_run.go @@ -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) +} diff --git a/internal/downloader/client.go b/internal/downloader/client.go index c0daab2..59d5cf1 100644 --- a/internal/downloader/client.go +++ b/internal/downloader/client.go @@ -16,6 +16,7 @@ import ( "github.com/mathismqn/godeez/internal/crypto" "github.com/mathismqn/godeez/internal/deezer" "github.com/mathismqn/godeez/internal/fileutil" + "github.com/mathismqn/godeez/internal/logger" "github.com/mathismqn/godeez/internal/store" "github.com/mathismqn/godeez/internal/tags" ) @@ -26,6 +27,7 @@ type Client struct { appConfig *config.Config resourceType string deezerClient *deezer.Client + Logger *logger.Logger hashIndexOnce sync.Once hashIndex *fileutil.HashIndex @@ -117,7 +119,9 @@ func (c *Client) Run(ctx context.Context, opts Options, id string) error { } 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) + continue } @@ -127,12 +131,18 @@ func (c *Client) Run(ctx context.Context, opts Options, id string) error { } 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) + for _, w := range warnings { + c.Logger.Warnf("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(` ================== [ Summary ] ================== Downloaded: %d diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..1a11aaa --- /dev/null +++ b/internal/logger/logger.go @@ -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...) + } +} diff --git a/internal/store/download_info.go b/internal/store/download_info.go index 8c217fa..70588b6 100644 --- a/internal/store/download_info.go +++ b/internal/store/download_info.go @@ -16,6 +16,8 @@ type DownloadInfo struct { Downloaded time.Time `json:"downloaded_at"` } +var trackBucket = []byte("tracks") + func GetDownloadInfo(songID string) (*DownloadInfo, error) { var info DownloadInfo @@ -39,9 +41,9 @@ func GetDownloadInfo(songID string) (*DownloadInfo, error) { func (d *DownloadInfo) Save() error { return db.Update(func(tx *bbolt.Tx) error { - b := tx.Bucket(trackBucket) - if b == nil { - return fmt.Errorf("bucket not found") + b, err := tx.CreateBucketIfNotExists(trackBucket) + if err != nil { + return fmt.Errorf("failed to create bucket: %w", err) } data, err := json.Marshal(d) @@ -49,11 +51,6 @@ func (d *DownloadInfo) Save() error { return err } - err = b.Put([]byte(d.SongID), data) - if err != nil { - return err - } - - return nil + return b.Put([]byte(d.SongID), data) }) } diff --git a/internal/store/store.go b/internal/store/store.go index 14339a4..6f2d1bc 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -7,10 +7,7 @@ import ( bolt "go.etcd.io/bbolt" ) -var ( - db *bolt.DB - trackBucket = []byte("tracks") -) +var db *bolt.DB func OpenDB(cfgDir string) error { var err error @@ -21,12 +18,5 @@ func OpenDB(cfgDir string) error { 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 } diff --git a/internal/store/watched.go b/internal/store/watched.go new file mode 100644 index 0000000..8dd876b --- /dev/null +++ b/internal/store/watched.go @@ -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 +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go new file mode 100644 index 0000000..b922a18 --- /dev/null +++ b/internal/watcher/watcher.go @@ -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 + } + } + } +}