feat: watch playlists and download newly added tracks

This commit is contained in:
Mathis Maquenne
2025-06-22 17:35:16 -04:00
parent 99bdc22a25
commit b19c92f227
8 changed files with 237 additions and 20 deletions
+14
View File
@@ -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)
}
+21
View File
@@ -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)
}
+10
View File
@@ -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
+29
View File
@@ -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...)
}
}
+6 -9
View File
@@ -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)
})
}
+1 -11
View File
@@ -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
}
+84
View File
@@ -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
}
+72
View File
@@ -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
}
}
}
}