Initial commit: ym-dl with yandex-music-api-go library migration

Migrate ym-dl from custom oapi-codegen Yandex Music API client to external
gitea.mrixs.me/Mrixs/yandex-music-api-go library with token-only authentication.

Key changes:
- Replace custom Yandex API client with yandexmusic library wrapper
- Token-based OAuth authentication only (no username/password fallback)
- SQLite storage index with JSON index migration path
- Extended ID3 tag support: genre, comment, lyrics, publisher, ISRC, copyright
- High-resolution album covers (1000x1000)
- Fixed album download check (requires all tracks, not just one)
This commit is contained in:
Mrixs committed 2026-10-17 17:09:42 +03:00
commit fe62910311
16 files changed
+6688

No files matched your search

+240
View File
@@ -0,0 +1,240 @@
package monitor
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"ym-dl/internal/config"
"ym-dl/internal/downloader"
"ym-dl/internal/storage"
"ym-dl/internal/yandex"
)
// Monitor periodically checks for new releases from configured artists
type Monitor struct {
cfg *config.Config
yandexClient *yandex.YandexClient
storage *storage.Storage
downloader *downloader.Downloader
logger *slog.Logger
mu sync.Mutex
stopCh chan struct{}
wg sync.WaitGroup
running bool
}
// NewMonitor creates a new monitor
func NewMonitor(cfg *config.Config, yandexClient *yandex.YandexClient, storage *storage.Storage, downloader *downloader.Downloader, logger *slog.Logger) *Monitor {
return &Monitor{
cfg: cfg,
yandexClient: yandexClient,
storage: storage,
downloader: downloader,
logger: logger,
stopCh: make(chan struct{}),
}
}
// Start starts the monitor loop
func (m *Monitor) Start(ctx context.Context) error {
m.mu.Lock()
if m.running {
m.mu.Unlock()
return fmt.Errorf("monitor already running")
}
m.running = true
m.mu.Unlock()
m.wg.Add(1)
go m.runLoop(ctx)
return nil
}
// Stop stops the monitor
func (m *Monitor) Stop() {
m.mu.Lock()
if !m.running {
m.mu.Unlock()
return
}
m.running = false
close(m.stopCh)
m.mu.Unlock()
m.wg.Wait()
}
// runLoop is the main monitoring loop
func (m *Monitor) runLoop(ctx context.Context) {
defer m.wg.Done()
interval, err := m.cfg.GetMonitorInterval()
if err != nil {
m.logger.Error("invalid monitor interval", "error", err)
return
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
// Run initial check
m.checkAllArtists(ctx)
for {
select {
case <-ctx.Done():
return
case <-m.stopCh:
return
case <-ticker.C:
m.checkAllArtists(ctx)
}
}
}
// checkAllArtists checks all configured artists for new releases
func (m *Monitor) checkAllArtists(ctx context.Context) {
m.logger.Info("starting artist check", "artist_count", len(m.cfg.Artists))
for _, artist := range m.cfg.Artists {
select {
case <-ctx.Done():
return
case <-m.stopCh:
return
default:
}
m.checkArtist(ctx, artist.ID)
}
}
// checkArtist checks a single artist for new tracks and albums
func (m *Monitor) checkArtist(ctx context.Context, artistID int) {
m.logger.Info("checking artist", "artist_id", artistID)
// Get artist info
artist, err := m.yandexClient.GetArtistInfo(ctx, artistID)
if err != nil {
m.logger.Error("failed to get artist info", "artist_id", artistID, "error", err)
return
}
m.logger.Info("artist info", "artist_id", artistID, "name", artist.Name)
// Check for new tracks
m.checkArtistTracks(ctx, artistID, artist.Name)
// Check for new albums
m.checkArtistAlbums(ctx, artistID, artist.Name)
}
// checkArtistTracks checks for new tracks from an artist
func (m *Monitor) checkArtistTracks(ctx context.Context, artistID int, artistName string) {
page := 0
pageSize := 50
for {
tracks, err := m.yandexClient.GetArtistTracks(ctx, artistID, page, pageSize)
if err != nil {
m.logger.Error("failed to get artist tracks", "artist_id", artistID, "page", page, "error", err)
return
}
if len(tracks) == 0 {
break
}
newTracks := 0
for _, track := range tracks {
if !m.storage.IsTrackDownloaded(track.ID) {
m.logger.Info("new track found", "track_id", track.ID, "artist", artistName)
result := m.downloader.DownloadTrack(ctx, track.ID)
if result.Success {
newTracks++
} else {
m.logger.Error("failed to download track", "track_id", track.ID, "error", result.Error)
}
}
}
m.logger.Info("track check complete", "artist_id", artistID, "new_tracks", newTracks, "page", page)
if len(tracks) < pageSize {
break
}
page++
}
}
// checkArtistAlbums checks for new albums from an artist
func (m *Monitor) checkArtistAlbums(ctx context.Context, artistID int, artistName string) {
page := 0
pageSize := 50
for {
albums, err := m.yandexClient.GetArtistAlbums(ctx, artistID, page, pageSize)
if err != nil {
m.logger.Error("failed to get artist albums", "artist_id", artistID, "page", page, "error", err)
return
}
if len(albums) == 0 {
break
}
newAlbums := 0
for _, album := range albums {
albumIDStr := album.ID
if !m.isAlbumDownloaded(albumIDStr, album.TrackCount) {
m.logger.Info("new album found", "album_id", albumIDStr, "title", album.Title, "artist", artistName)
results := m.downloader.DownloadAlbum(ctx, album.ID)
success := false
for _, r := range results {
if r.Success {
success = true
break
}
}
if success {
newAlbums++
} else {
m.logger.Error("failed to download album", "album_id", albumIDStr, "title", album.Title)
}
}
}
m.logger.Info("album check complete", "artist_id", artistID, "new_albums", newAlbums, "page", page)
if len(albums) < pageSize {
break
}
page++
}
}
// isAlbumDownloaded checks if an album has been fully downloaded.
// An album is considered downloaded only if all its expected tracks (by TrackCount)
// are present in the storage index. If TrackCount is 0 (unknown), the album is
// never considered pre-downloaded to avoid skipping compilation albums
// where only some tracks were fetched individually.
func (m *Monitor) isAlbumDownloaded(albumID string, expectedTrackCount int) bool {
if expectedTrackCount <= 0 {
return false // Unknown track count — download anyway to be safe
}
downloadedCount := 0
for _, trackID := range m.storage.GetDownloadedTracks() {
meta, ok := m.storage.GetTrackMeta(trackID)
if ok && meta.AlbumID == albumID {
downloadedCount++
}
}
return downloadedCount >= expectedTrackCount
}