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

+26
View File
@@ -0,0 +1,26 @@
# Binaries
bin/
*.exe
# OS
.DS_Store
Thumbs.db
# IDE
.idea/
.vscode/
*.swp
*.swo
# Go
/vendor/
*.test
# Docker
.env
# Local config (may contain credentials)
config.yaml
# SQLite index
index.db
+91
View File
@@ -0,0 +1,91 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**ym-dl** — A Go service for monitoring and downloading tracks/albums from Yandex Music for configured artists.
- **Language**: Go (strict typing, idiomatic patterns)
- **Runtime**: Docker / Docker Compose
- **API**: Yandex Music (OpenAPI spec at `docs/yandex-music.yaml`)
- **Config**: YAML file listing artist IDs to monitor
## Architecture
```
ym-dl/
├── cmd/ym-dl/ # Main entry point
├── internal/
│ ├── config/ # Configuration loading (artist IDs, API credentials)
│ ├── yandex/ # Yandex Music API client (generated from OpenAPI)
│ ├── storage/ # Local file storage for downloaded tracks/albums
│ ├── monitor/ # Periodic check logic for new releases
│ └── downloader/ # Download orchestration
├── docker-compose.yml # Service definition
├── Dockerfile # Multi-stage build
└── config.yaml.example # Sample configuration
```
## Development Commands
```bash
# Build
go build -o bin/ym-dl ./cmd/ym-dl
# Run locally
go run ./cmd/ym-dl --config config.yaml
# Run with Docker Compose
docker compose up --build
# Run tests
go test ./...
# Run single test
go test -v -run TestName ./internal/...
# Lint
golangci-lint run
# Generate API client from OpenAPI
oapi-codegen -generate types,client -package yandex docs/yandex-music.yaml > internal/yandex/client.gen.go
```
## Configuration
`config.yaml` structure:
```yaml
yandex:
token: "oauth_token" # Or use username/password for token exchange
username: "login"
password: "password"
artists:
- id: 12345 # Artist ID from Yandex Music
- id: 67890
storage:
path: "/data/music" # Local path for downloads
organize: true # Organize into Artist/Album/Track structure
monitor:
interval: "24h" # Check interval (duration string)
```
## Key Implementation Notes
- **Authentication**: Support both OAuth token and username/password (token exchange via `/token` endpoint)
- **Rate limiting**: Respect Yandex API limits; implement exponential backoff
- **Idempotency**: Track downloaded files by Yandex track/album ID to avoid re-downloads
- **Progress**: Structured logging (JSON) for Docker environments
## Docker
Multi-stage build: builder (Go 1.23+) → runtime (distroless or alpine). Mount config at `/config.yaml`, storage at `/data`.
## Testing
- Unit tests for config, storage, monitor logic
- Integration tests against mocked Yandex API (use `net/http/httptest`)
- No real API calls in CI
+34
View File
@@ -0,0 +1,34 @@
# Build stage
FROM golang:1.24-alpine AS builder
WORKDIR /app
# Install build dependencies
RUN apk add --no-cache git make
# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download
# Copy source
COPY . .
# Tidy and build binary
RUN go mod tidy && CGO_ENABLED=0 GOOS=linux go build -o /ym-dl ./cmd/ym-dl
# Runtime stage
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
# Copy binary
COPY --from=builder /ym-dl /app/ym-dl
# Copy config example
COPY config.yaml.example /app/config.yaml.example
# Run as non-root user (distroless uses nonroot:nonroot by default)
USER nonroot:nonroot
ENTRYPOINT ["/app/ym-dl"]
CMD ["--config", "/config/config.yaml"]
+93
View File
@@ -0,0 +1,93 @@
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"os"
"os/signal"
"syscall"
"ym-dl/internal/config"
"ym-dl/internal/downloader"
"ym-dl/internal/monitor"
"ym-dl/internal/storage"
"ym-dl/internal/yandex"
)
func main() {
configPath := flag.String("config", "config.yaml", "Path to config file")
flag.Parse()
// Load configuration
cfg, err := config.Load(*configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err)
os.Exit(1)
}
// Setup logger
logLevel := slog.LevelInfo
if cfg.Debug {
logLevel = slog.LevelDebug
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: logLevel,
}))
slog.SetDefault(logger)
// Create Yandex client
yandexClient, err := yandex.NewYandexClient(&cfg.Yandex, cfg.Debug)
if err != nil {
logger.Error("Failed to create Yandex client", "error", err)
os.Exit(1)
}
// Create storage
store, err := storage.NewStorage(cfg.Storage.Path, cfg.Storage.Organize)
if err != nil {
logger.Error("Failed to create storage", "error", err)
os.Exit(1)
}
// Create downloader
dl := downloader.NewDownloader(yandexClient, store)
// Create monitor
mon := monitor.NewMonitor(cfg, yandexClient, store, dl, logger)
// Setup context with signal handling
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
logger.Info("Shutdown signal received")
cancel()
mon.Stop()
if err := store.Close(); err != nil {
logger.Error("Failed to close storage", "error", err)
}
}()
// Start monitor
if err := mon.Start(ctx); err != nil {
logger.Error("Failed to start monitor", "error", err)
os.Exit(1)
}
logger.Info("ym-dl started", "artists", len(cfg.Artists), "storage", cfg.Storage.Path)
// Wait for context cancellation
<-ctx.Done()
mon.Stop()
if err := store.Close(); err != nil {
logger.Error("Failed to close storage", "error", err)
}
logger.Info("ym-dl stopped")
}
+22
View File
@@ -0,0 +1,22 @@
# Yandex Music Downloader Configuration
# Copy to config.yaml and fill in your values
yandex:
# Option 1: OAuth token (preferred)
token: ""
# Option 2: Username/password for token exchange (not all accounts support this)
username: ""
password: ""
artists:
- id: 12345 # Example artist ID - replace with actual Yandex Music artist IDs
- id: 67890
storage:
path: "/data/music" # Local path for downloads
organize: true # Organize into Artist/Album/Track structure
monitor:
interval: "24h" # Check interval (duration string: 1h, 24h, 7d, etc.)
debug: false # Enable debug logging (HTTP requests/responses)
+24
View File
@@ -0,0 +1,24 @@
version: '3.8'
services:
ym-dl:
build:
context: .
dockerfile: Dockerfile
container_name: ym-dl
restart: unless-stopped
volumes:
- ./config.yaml:/config/config.yaml:ro
- ym-dl-data:/data/music
- ./logs:/app/logs
environment:
- TZ=UTC
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
volumes:
ym-dl-data:
driver: local
File diff suppressed because it is too large. Load diff
+16
View File
@@ -0,0 +1,16 @@
module ym-dl
go 1.27.2
require (
gitea.mrixs.me/Mrixs/yandex-music-api-go v0.0.0-20261014200451-02a6dabcfbea
github.com/bogem/id3v2 v1.2.0
github.com/mattn/go-sqlite3 v1.14.24
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/gorilla/websocket v1.5.3 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
)
+20
View File
@@ -0,0 +1,20 @@
gitea.mrixs.me/Mrixs/yandex-music-api-go v0.0.0-20261014200451-02a6dabcfbea h1:JOsFlBq/AdrAeyus0+KNSiISXodjyejRb6z5VOgk+n8=
gitea.mrixs.me/Mrixs/yandex-music-api-go v0.0.0-20261014200451-02a6dabcfbea/go.mod h1:L+GcqPRZiu0Srq6+YxfdLztltXV8DcxKjNUOXWJvR9o=
github.com/bogem/id3v2 v1.2.0 h1:hKDF+F1gOgQ5r1QmBCEZUk4MveJbKxCeIDSBU7CQ4oI=
github.com/bogem/id3v2 v1.2.0/go.mod h1:t78PK5AQ56Q47kizpYiV6gtjj3jfxlz87oFpty8DYs8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+92
View File
@@ -0,0 +1,92 @@
package config
import (
"errors"
"os"
"time"
"gopkg.in/yaml.v3"
)
// Config holds the application configuration
type Config struct {
Yandex YandexConfig `yaml:"yandex"`
Artists []Artist `yaml:"artists"`
Storage StorageConfig `yaml:"storage"`
Monitor MonitorConfig `yaml:"monitor"`
Debug bool `yaml:"debug"`
}
// YandexConfig holds Yandex Music API credentials
type YandexConfig struct {
Token string `yaml:"token"`
Username string `yaml:"username"`
Password string `yaml:"password"`
}
// Artist represents an artist to monitor
type Artist struct {
ID int `yaml:"id"`
}
// StorageConfig holds storage settings
type StorageConfig struct {
Path string `yaml:"path"`
Organize bool `yaml:"organize"`
}
// MonitorConfig holds monitor settings
type MonitorConfig struct {
Interval string `yaml:"interval"`
}
// Load loads configuration from a YAML file
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
if err := cfg.Validate(); err != nil {
return nil, err
}
return &cfg, nil
}
// Validate validates the configuration
func (c *Config) Validate() error {
if len(c.Artists) == 0 {
return errors.New("at least one artist must be configured")
}
for _, a := range c.Artists {
if a.ID <= 0 {
return errors.New("artist ID must be positive")
}
}
if c.Storage.Path == "" {
return errors.New("storage path is required")
}
if c.Yandex.Token == "" && (c.Yandex.Username == "" || c.Yandex.Password == "") {
return errors.New("either yandex.token or yandex.username/password must be provided")
}
if _, err := time.ParseDuration(c.Monitor.Interval); err != nil {
return errors.New("monitor.interval must be a valid duration (e.g., 24h, 1h, 7d)")
}
return nil
}
// GetMonitorInterval returns the parsed monitor interval
func (c *Config) GetMonitorInterval() (time.Duration, error) {
return time.ParseDuration(c.Monitor.Interval)
}
+199
View File
@@ -0,0 +1,199 @@
package config
import (
"os"
"testing"
"time"
)
func TestLoadValidConfig(t *testing.T) {
content := `
yandex:
token: "test-token"
artists:
- id: 12345
storage:
path: "/data/music"
organize: true
monitor:
interval: "24h"
`
tmpfile, err := os.CreateTemp("", "config-*.yaml")
if err != nil {
t.Fatal(err)
}
defer os.Remove(tmpfile.Name())
if _, err := tmpfile.WriteString(content); err != nil {
t.Fatal(err)
}
tmpfile.Close()
cfg, err := Load(tmpfile.Name())
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.Yandex.Token != "test-token" {
t.Errorf("expected token 'test-token', got '%s'", cfg.Yandex.Token)
}
if len(cfg.Artists) != 1 || cfg.Artists[0].ID != 12345 {
t.Errorf("expected artist ID 12345, got %v", cfg.Artists)
}
if cfg.Storage.Path != "/data/music" {
t.Errorf("expected path '/data/music', got '%s'", cfg.Storage.Path)
}
if !cfg.Storage.Organize {
t.Error("expected organize=true")
}
interval, err := cfg.GetMonitorInterval()
if err != nil {
t.Fatalf("GetMonitorInterval failed: %v", err)
}
if interval != 24*time.Hour {
t.Errorf("expected 24h, got %v", interval)
}
}
func TestLoadConfigWithUsernamePassword(t *testing.T) {
content := `
yandex:
username: "user"
password: "pass"
artists:
- id: 1
storage:
path: "/data"
monitor:
interval: "1h"
`
tmpfile, err := os.CreateTemp("", "config-*.yaml")
if err != nil {
t.Fatal(err)
}
defer os.Remove(tmpfile.Name())
if _, err := tmpfile.WriteString(content); err != nil {
t.Fatal(err)
}
tmpfile.Close()
cfg, err := Load(tmpfile.Name())
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.Yandex.Username != "user" || cfg.Yandex.Password != "pass" {
t.Errorf("expected username/password, got %v", cfg.Yandex)
}
}
func TestLoadConfigMissingAuth(t *testing.T) {
content := `
yandex: {}
artists:
- id: 1
storage:
path: "/data"
monitor:
interval: "1h"
`
tmpfile, err := os.CreateTemp("", "config-*.yaml")
if err != nil {
t.Fatal(err)
}
defer os.Remove(tmpfile.Name())
if _, err := tmpfile.WriteString(content); err != nil {
t.Fatal(err)
}
tmpfile.Close()
_, err = Load(tmpfile.Name())
if err == nil {
t.Error("expected error for missing auth")
}
}
func TestLoadConfigNoArtists(t *testing.T) {
content := `
yandex:
token: "test"
artists: []
storage:
path: "/data"
monitor:
interval: "1h"
`
tmpfile, err := os.CreateTemp("", "config-*.yaml")
if err != nil {
t.Fatal(err)
}
defer os.Remove(tmpfile.Name())
if _, err := tmpfile.WriteString(content); err != nil {
t.Fatal(err)
}
tmpfile.Close()
_, err = Load(tmpfile.Name())
if err == nil {
t.Error("expected error for no artists")
}
}
func TestLoadConfigInvalidInterval(t *testing.T) {
content := `
yandex:
token: "test"
artists:
- id: 1
storage:
path: "/data"
monitor:
interval: "invalid"
`
tmpfile, err := os.CreateTemp("", "config-*.yaml")
if err != nil {
t.Fatal(err)
}
defer os.Remove(tmpfile.Name())
if _, err := tmpfile.WriteString(content); err != nil {
t.Fatal(err)
}
tmpfile.Close()
_, err = Load(tmpfile.Name())
if err == nil {
t.Error("expected error for invalid interval")
}
}
func TestLoadConfigNegativeArtistID(t *testing.T) {
content := `
yandex:
token: "test"
artists:
- id: -1
storage:
path: "/data"
monitor:
interval: "1h"
`
tmpfile, err := os.CreateTemp("", "config-*.yaml")
if err != nil {
t.Fatal(err)
}
defer os.Remove(tmpfile.Name())
if _, err := tmpfile.WriteString(content); err != nil {
t.Fatal(err)
}
tmpfile.Close()
_, err = Load(tmpfile.Name())
if err == nil {
t.Error("expected error for negative artist ID")
}
}
+261
View File
@@ -0,0 +1,261 @@
package downloader
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"ym-dl/internal/storage"
"ym-dl/internal/yandex"
"gitea.mrixs.me/Mrixs/yandex-music-api-go/yandexmusic"
)
// DownloadResult represents the result of a download operation
type DownloadResult struct {
TrackID string
AlbumID string
Success bool
Error error
TrackMeta storage.TrackMeta
AlbumMeta storage.AlbumMeta
}
// Downloader orchestrates the download of tracks and albums
type Downloader struct {
yandexClient *yandex.YandexClient
storage *storage.Storage
httpClient *http.Client
}
// NewDownloader creates a new downloader
func NewDownloader(yandexClient *yandex.YandexClient, storage *storage.Storage) *Downloader {
return &Downloader{
yandexClient: yandexClient,
storage: storage,
httpClient: &http.Client{Timeout: 5 * 60 * time.Second}, // 5 min timeout for large files
}
}
// DownloadTrack downloads a single track
func (d *Downloader) DownloadTrack(ctx context.Context, trackID string) DownloadResult {
// Check if already downloaded
if d.storage.IsTrackDownloaded(trackID) {
meta, _ := d.storage.GetTrackMeta(trackID)
return DownloadResult{
TrackID: trackID,
Success: true,
TrackMeta: meta,
}
}
// Get track info from API with position
track, position, err := d.yandexClient.GetTrackInfoWithPosition(ctx, trackID)
if err != nil {
return DownloadResult{TrackID: trackID, Success: false, Error: fmt.Errorf("get track info: %w", err)}
}
// Get album info if available
var album *yandexmusic.Album
var coverData []byte
if len(track.Albums) > 0 {
album = &track.Albums[0]
// Fetch album cover
if album.CoverURI != "" {
coverURL := strings.Replace(album.CoverURI, "%%", "1000x1000", 1)
coverURL = "https://" + strings.TrimPrefix(coverURL, "//")
coverData, _ = d.downloadFile(ctx, coverURL)
}
}
// Determine track number and disc number from API response
trackNum := 1
discNum := 1
if position != nil {
trackNum = position.Index
discNum = position.Volume
}
// Get direct download URL (handles XML parsing and URL signing)
downloadURL, err := d.yandexClient.GetTrackDownloadURL(ctx, trackID)
if err != nil {
return DownloadResult{TrackID: trackID, Success: false, Error: fmt.Errorf("get download URL: %w", err)}
}
// Download the actual audio file
audioData, err := d.downloadFile(ctx, downloadURL)
if err != nil {
return DownloadResult{TrackID: trackID, Success: false, Error: fmt.Errorf("download audio: %w", err)}
}
// Get download info for metadata (codec, bitrate)
downloadInfos, err := d.yandexClient.GetTrackDownloadInfo(ctx, trackID)
if err != nil {
return DownloadResult{TrackID: trackID, Success: false, Error: fmt.Errorf("get download info: %w", err)}
}
// Find best quality MP3 for metadata
var bestInfo *yandexmusic.DownloadInfo
for _, info := range downloadInfos {
if info.Codec == "mp3" && info.DirectLink {
if bestInfo == nil || info.Bitrate > bestInfo.Bitrate {
bestInfo = &info
}
}
}
if bestInfo == nil {
for _, info := range downloadInfos {
if info.Codec == "mp3" {
if bestInfo == nil || info.Bitrate > bestInfo.Bitrate {
bestInfo = &info
}
}
}
}
if bestInfo == nil && len(downloadInfos) > 0 {
bestInfo = &downloadInfos[0]
}
// Build track meta with full metadata
trackMeta := storage.BuildTrackMetaFromYandex(track, album, trackNum, discNum, *bestInfo)
if err := d.storage.SaveTrack(trackMeta, audioData, coverData); err != nil {
return DownloadResult{TrackID: trackID, Success: false, Error: fmt.Errorf("save track: %w", err)}
}
savedMeta, _ := d.storage.GetTrackMeta(trackID)
return DownloadResult{
TrackID: trackID,
Success: true,
TrackMeta: savedMeta,
}
}
// DownloadAlbum downloads all tracks from an album
func (d *Downloader) DownloadAlbum(ctx context.Context, albumID string) []DownloadResult {
var results []DownloadResult
// Get album with tracks
album, err := d.yandexClient.GetAlbumWithTracks(ctx, albumID)
if err != nil {
return []DownloadResult{{AlbumID: albumID, Success: false, Error: fmt.Errorf("get album: %w", err)}}
}
// Get album cover
var coverData []byte
if album.CoverURI != "" {
coverURL := strings.Replace(album.CoverURI, "%%", "1000x1000", 1)
coverURL = "https://" + strings.TrimPrefix(coverURL, "//")
coverData, _ = d.downloadFile(ctx, coverURL)
}
// Build track metas
var trackMetas []storage.TrackMeta
if album.Volumes != nil {
for i, volume := range album.Volumes {
for j := range volume {
track := &volume[j]
if track.ID == "" || !track.Available {
continue
}
// Get download URL (handles XML parsing and URL signing)
downloadURL, err := d.yandexClient.GetTrackDownloadURL(ctx, track.ID)
if err != nil {
results = append(results, DownloadResult{TrackID: track.ID, Success: false, Error: fmt.Errorf("get download URL: %w", err)})
continue
}
// Get download info for metadata
downloadInfos, err := d.yandexClient.GetTrackDownloadInfo(ctx, track.ID)
if err != nil {
results = append(results, DownloadResult{TrackID: track.ID, Success: false, Error: fmt.Errorf("get download info: %w", err)})
continue
}
var bestInfo *yandexmusic.DownloadInfo
for _, info := range downloadInfos {
if info.Codec == "mp3" && info.DirectLink {
if bestInfo == nil || info.Bitrate > bestInfo.Bitrate {
bestInfo = &info
}
}
}
if bestInfo == nil {
for _, info := range downloadInfos {
if info.Codec == "mp3" {
if bestInfo == nil || info.Bitrate > bestInfo.Bitrate {
bestInfo = &info
}
}
}
}
if bestInfo == nil && len(downloadInfos) > 0 {
bestInfo = &downloadInfos[0]
}
trackMeta := storage.BuildTrackMetaFromYandex(track, album, j+1, i+1, *bestInfo)
trackMetas = append(trackMetas, trackMeta)
// Download audio
audioData, err := d.downloadFile(ctx, downloadURL)
if err != nil {
results = append(results, DownloadResult{TrackID: track.ID, Success: false, Error: fmt.Errorf("download audio: %w", err)})
continue
}
// Save track
if err := d.storage.SaveTrack(trackMeta, audioData, coverData); err != nil {
results = append(results, DownloadResult{TrackID: track.ID, Success: false, Error: fmt.Errorf("save track: %w", err)})
continue
}
savedMeta, _ := d.storage.GetTrackMeta(track.ID)
results = append(results, DownloadResult{
TrackID: track.ID,
AlbumID: albumID,
Success: true,
TrackMeta: savedMeta,
})
// Delay between downloads to be respectful to the API
select {
case <-ctx.Done():
return results
case <-time.After(1 * time.Second):
}
}
}
}
// Save album meta
albumMeta := storage.BuildAlbumMetaFromYandex(album, trackMetas)
if err := d.storage.SaveAlbum(albumMeta, coverData); err != nil {
results = append(results, DownloadResult{AlbumID: albumID, Success: false, Error: fmt.Errorf("save album: %w", err)})
}
return results
}
// downloadFile downloads a file from a URL
func (d *Downloader) downloadFile(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp, err := d.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
+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
}
+754
View File
@@ -0,0 +1,754 @@
package storage
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/bogem/id3v2"
_ "github.com/mattn/go-sqlite3"
"gitea.mrixs.me/Mrixs/yandex-music-api-go/yandexmusic"
)
// TrackMeta holds metadata for a downloaded track
type TrackMeta struct {
TrackID string `json:"track_id"`
AlbumID string `json:"album_id"`
ArtistID string `json:"artist_id"`
Title string `json:"title"`
ArtistName string `json:"artist_name"` // Primary artist (first)
ArtistNames []string `json:"artist_names"` // All artists
AlbumTitle string `json:"album_title"`
AlbumArtist string `json:"album_artist"` // Album artist (may differ from track artist)
AlbumArtistID string `json:"album_artist_id"` // Album artist ID
Year int `json:"year"` // Release year from album
TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"`
DurationMs int64 `json:"duration_ms"`
Bitrate int `json:"bitrate"`
Codec string `json:"codec"`
FilePath string `json:"file_path"`
FileSize int64 `json:"file_size"`
DownloadedAt string `json:"downloaded_at"`
// Extended metadata from Yandex Music API
Genre string `json:"genre"` // Track or album genre
Comment string `json:"comment"` // Album description / comments
Lyrics string `json:"lyrics"` // Track lyrics text
Publisher string `json:"publisher"` // Album label / publisher
EncodedBy string `json:"encoded_by"` // Encoder name
OriginalYear string `json:"original_year"` // Original release year
Composer string `json:"composer"` // Composer
ISRC string `json:"isrc"` // International Standard Recording Code
Copyright string `json:"copyright"` // Copyright information
}
// AlbumMeta holds metadata for a downloaded album
type AlbumMeta struct {
AlbumID string `json:"album_id"`
ArtistID string `json:"artist_id"`
Title string `json:"title"`
ArtistName string `json:"artist_name"`
Year int `json:"year"`
TrackCount int `json:"track_count"`
CoverPath string `json:"cover_path"`
Tracks []TrackMeta `json:"tracks"`
DownloadedAt string `json:"downloaded_at"`
}
// Storage manages local file storage for downloaded music
type Storage struct {
rootPath string
organize bool
mu sync.RWMutex
db *sql.DB
}
// NewStorage creates a new storage instance with SQLite backend
func NewStorage(rootPath string, organize bool) (*Storage, error) {
if err := os.MkdirAll(rootPath, 0755); err != nil {
return nil, fmt.Errorf("create root path: %w", err)
}
dbPath := filepath.Join(rootPath, "index.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("open sqlite db: %w", err)
}
// Configure connection pool for single-writer SQLite
db.SetMaxOpenConns(1)
if err := createSchema(db); err != nil {
db.Close()
return nil, fmt.Errorf("create schema: %w", err)
}
s := &Storage{
rootPath: rootPath,
organize: organize,
db: db,
}
// Migrate from old JSON index if it exists
if err := s.migrateFromJSON(); err != nil {
// Log but don't fail — migration is best-effort
fmt.Printf("Warning: JSON index migration: %v\n", err)
}
return s, nil
}
// createSchema creates the tracks table if it doesn't exist
func createSchema(db *sql.DB) error {
schema := `
CREATE TABLE IF NOT EXISTS tracks (
track_id TEXT PRIMARY KEY,
album_id TEXT,
artist_id TEXT,
title TEXT,
artist_name TEXT,
artist_names TEXT,
album_title TEXT,
album_artist TEXT,
album_artist_id TEXT,
year INTEGER,
track_number INTEGER,
disc_number INTEGER,
duration_ms INTEGER,
bitrate INTEGER,
codec TEXT,
file_path TEXT,
file_size INTEGER,
downloaded_at TEXT,
genre TEXT,
comment TEXT,
lyrics TEXT,
publisher TEXT,
encoded_by TEXT,
original_year TEXT,
composer TEXT,
isrc TEXT,
copyright TEXT
);
CREATE INDEX IF NOT EXISTS idx_tracks_album_id ON tracks(album_id);
`
_, err := db.Exec(schema)
return err
}
// migrateFromJSON imports the old .index.json into SQLite if it exists
func (s *Storage) migrateFromJSON() error {
jsonPath := filepath.Join(s.rootPath, ".index.json")
data, err := os.ReadFile(jsonPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil // No old index, nothing to migrate
}
return err
}
var oldIndex map[string]TrackMeta
if err := json.Unmarshal(data, &oldIndex); err != nil {
return fmt.Errorf("parse old index: %w", err)
}
if len(oldIndex) == 0 {
return nil
}
// Check if SQLite already has data (avoid double-migration)
var count int
err = s.db.QueryRow("SELECT COUNT(*) FROM tracks").Scan(&count)
if err != nil {
return err
}
if count > 0 {
return nil // Already migrated
}
tx, err := s.db.Begin()
if err != nil {
return err
}
stmt, err := tx.Prepare(insertTrackSQL())
if err != nil {
tx.Rollback()
return err
}
defer stmt.Close()
for _, meta := range oldIndex {
if _, err := stmt.Exec(
meta.TrackID, meta.AlbumID, meta.ArtistID, meta.Title, meta.ArtistName,
serializeStrings(meta.ArtistNames), meta.AlbumTitle, meta.AlbumArtist,
meta.AlbumArtistID, meta.Year, meta.TrackNumber, meta.DiscNumber,
meta.DurationMs, meta.Bitrate, meta.Codec, meta.FilePath, meta.FileSize,
meta.DownloadedAt, meta.Genre, meta.Comment, meta.Lyrics, meta.Publisher,
meta.EncodedBy, meta.OriginalYear, meta.Composer, meta.ISRC, meta.Copyright,
); err != nil {
tx.Rollback()
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
// Remove the old JSON file after successful migration
if err := os.Remove(jsonPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
// insertTrackSQL returns the parameterized INSERT OR REPLACE statement
func insertTrackSQL() string {
return `
INSERT OR REPLACE INTO tracks (
track_id, album_id, artist_id, title, artist_name, artist_names,
album_title, album_artist, album_artist_id, year, track_number,
disc_number, duration_ms, bitrate, codec, file_path, file_size,
downloaded_at, genre, comment, lyrics, publisher, encoded_by,
original_year, composer, isrc, copyright
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
}
// scanTrackMeta reads a TrackMeta from a sql.Row
func scanTrackMeta(row *sql.Row) (TrackMeta, error) {
var m TrackMeta
var artistNamesStr string
var downloadedAt sql.NullString
var genre sql.NullString
var comment sql.NullString
var lyrics sql.NullString
var publisher sql.NullString
var encodedBy sql.NullString
var originalYear sql.NullString
var composer sql.NullString
var isrc sql.NullString
var copyright sql.NullString
err := row.Scan(
&m.TrackID, &m.AlbumID, &m.ArtistID, &m.Title, &m.ArtistName,
&artistNamesStr, &m.AlbumTitle, &m.AlbumArtist, &m.AlbumArtistID,
&m.Year, &m.TrackNumber, &m.DiscNumber, &m.DurationMs, &m.Bitrate,
&m.Codec, &m.FilePath, &m.FileSize, &downloadedAt, &genre, &comment,
&lyrics, &publisher, &encodedBy, &originalYear, &composer, &isrc,
&copyright,
)
if err != nil {
return m, err
}
if downloadedAt.Valid {
m.DownloadedAt = downloadedAt.String
}
m.ArtistNames = deserializeStrings(artistNamesStr)
if genre.Valid {
m.Genre = genre.String
}
if comment.Valid {
m.Comment = comment.String
}
if lyrics.Valid {
m.Lyrics = lyrics.String
}
if publisher.Valid {
m.Publisher = publisher.String
}
if encodedBy.Valid {
m.EncodedBy = encodedBy.String
}
if originalYear.Valid {
m.OriginalYear = originalYear.String
}
if composer.Valid {
m.Composer = composer.String
}
if isrc.Valid {
m.ISRC = isrc.String
}
if copyright.Valid {
m.Copyright = copyright.String
}
return m, nil
}
// serializeStrings converts a []string to a JSON string for storage
func serializeStrings(strs []string) string {
if strs == nil {
return "[]"
}
b, _ := json.Marshal(strs)
return string(b)
}
// deserializeStrings converts a JSON string back to []string
func deserializeStrings(s string) []string {
if s == "" || s == "[]" {
return nil
}
var strs []string
if err := json.Unmarshal([]byte(s), &strs); err != nil {
return nil
}
return strs
}
// IsTrackDownloaded checks if a track has already been downloaded
func (s *Storage) IsTrackDownloaded(trackID string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
var count int
err := s.db.QueryRow("SELECT COUNT(*) FROM tracks WHERE track_id = ?", trackID).Scan(&count)
if err != nil {
return false
}
return count > 0
}
// GetTrackMeta returns metadata for a downloaded track
func (s *Storage) GetTrackMeta(trackID string) (TrackMeta, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
m, err := scanTrackMeta(s.db.QueryRow("SELECT * FROM tracks WHERE track_id = ?", trackID))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return TrackMeta{}, false
}
return TrackMeta{}, false
}
return m, true
}
// SaveTrack saves a track file and its metadata
func (s *Storage) SaveTrack(trackMeta TrackMeta, data []byte, coverData []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
// Determine file path
var filePath string
if s.organize {
// Artist/Album/TrackNumber - Title.ext
artistDir := sanitizeFilename(trackMeta.ArtistName)
albumDir := sanitizeFilename(trackMeta.AlbumTitle)
trackFilename := fmt.Sprintf("%02d - %s.%s", trackMeta.TrackNumber, sanitizeFilename(trackMeta.Title), trackMeta.Codec)
filePath = filepath.Join(s.rootPath, artistDir, albumDir, trackFilename)
} else {
// Flat structure: Artist - Title.ext
trackFilename := fmt.Sprintf("%s - %s.%s", sanitizeFilename(trackMeta.ArtistName), sanitizeFilename(trackMeta.Title), trackMeta.Codec)
filePath = filepath.Join(s.rootPath, trackFilename)
}
// Create directory
dir := filepath.Dir(filePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create directory: %w", err)
}
// Write file
if err := os.WriteFile(filePath, data, 0644); err != nil {
return fmt.Errorf("write track file: %w", err)
}
// Write ID3 tags (only for MP3)
if strings.EqualFold(trackMeta.Codec, "mp3") {
if err := writeID3Tags(filePath, trackMeta, coverData); err != nil {
// Log error but don't fail the download
fmt.Printf("Warning: failed to write ID3 tags: %v\n", err)
}
}
// Update metadata
trackMeta.FilePath = filePath
trackMeta.FileSize = int64(len(data))
trackMeta.DownloadedAt = time.Now().Format(time.RFC3339)
_, err := s.db.Exec(insertTrackSQL(),
trackMeta.TrackID, trackMeta.AlbumID, trackMeta.ArtistID, trackMeta.Title,
trackMeta.ArtistName, serializeStrings(trackMeta.ArtistNames),
trackMeta.AlbumTitle, trackMeta.AlbumArtist, trackMeta.AlbumArtistID,
trackMeta.Year, trackMeta.TrackNumber, trackMeta.DiscNumber,
trackMeta.DurationMs, trackMeta.Bitrate, trackMeta.Codec,
trackMeta.FilePath, trackMeta.FileSize, trackMeta.DownloadedAt,
trackMeta.Genre, trackMeta.Comment, trackMeta.Lyrics, trackMeta.Publisher,
trackMeta.EncodedBy, trackMeta.OriginalYear, trackMeta.Composer,
trackMeta.ISRC, trackMeta.Copyright,
)
if err != nil {
return fmt.Errorf("save track meta: %w", err)
}
return nil
}
// SaveAlbum saves album cover and metadata
func (s *Storage) SaveAlbum(albumMeta AlbumMeta, coverData []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
var coverPath string
if s.organize && len(albumMeta.Tracks) > 0 {
artistDir := sanitizeFilename(albumMeta.ArtistName)
albumDir := sanitizeFilename(albumMeta.Title)
coverPath = filepath.Join(s.rootPath, artistDir, albumDir, "cover.jpg")
} else {
coverPath = filepath.Join(s.rootPath, fmt.Sprintf("%s - %s_cover.jpg", sanitizeFilename(albumMeta.ArtistName), sanitizeFilename(albumMeta.Title)))
}
dir := filepath.Dir(coverPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create directory: %w", err)
}
if len(coverData) > 0 {
if err := os.WriteFile(coverPath, coverData, 0644); err != nil {
return fmt.Errorf("write cover file: %w", err)
}
}
albumMeta.CoverPath = coverPath
// Update track metas with album info
for i := range albumMeta.Tracks {
if _, err := s.db.Exec(insertTrackSQL(),
albumMeta.Tracks[i].TrackID, albumMeta.Tracks[i].AlbumID,
albumMeta.Tracks[i].ArtistID, albumMeta.Tracks[i].Title,
albumMeta.Tracks[i].ArtistName,
serializeStrings(albumMeta.Tracks[i].ArtistNames),
albumMeta.Tracks[i].AlbumTitle, albumMeta.Tracks[i].AlbumArtist,
albumMeta.Tracks[i].AlbumArtistID, albumMeta.Tracks[i].Year,
albumMeta.Tracks[i].TrackNumber, albumMeta.Tracks[i].DiscNumber,
albumMeta.Tracks[i].DurationMs, albumMeta.Tracks[i].Bitrate,
albumMeta.Tracks[i].Codec, albumMeta.Tracks[i].FilePath,
albumMeta.Tracks[i].FileSize, albumMeta.Tracks[i].DownloadedAt,
albumMeta.Tracks[i].Genre, albumMeta.Tracks[i].Comment,
albumMeta.Tracks[i].Lyrics, albumMeta.Tracks[i].Publisher,
albumMeta.Tracks[i].EncodedBy, albumMeta.Tracks[i].OriginalYear,
albumMeta.Tracks[i].Composer, albumMeta.Tracks[i].ISRC,
albumMeta.Tracks[i].Copyright,
); err != nil {
return fmt.Errorf("save album track meta: %w", err)
}
}
return nil
}
// GetDownloadedTracks returns all downloaded track IDs
func (s *Storage) GetDownloadedTracks() []string {
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query("SELECT track_id FROM tracks")
if err != nil {
return nil
}
defer rows.Close()
var tracks []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
continue
}
tracks = append(tracks, id)
}
return tracks
}
// GetDownloadedAlbums returns all downloaded album IDs
func (s *Storage) GetDownloadedAlbums() []string {
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query("SELECT DISTINCT album_id FROM tracks WHERE album_id != '' AND album_id IS NOT NULL")
if err != nil {
return nil
}
defer rows.Close()
var albums []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
continue
}
albums = append(albums, id)
}
return albums
}
// Close closes the database connection
func (s *Storage) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.db != nil {
return s.db.Close()
}
return nil
}
// sanitizeFilename removes invalid characters from filenames
func sanitizeFilename(name string) string {
replacer := strings.NewReplacer(
"/", "_",
"\\", "_",
":", "_",
"*", "_",
"?", "_",
"\"", "_",
"<", "_",
">", "_",
"|", "_",
"\n", "_",
"\r", "_",
)
return replacer.Replace(name)
}
// BuildTrackMetaFromYandex creates TrackMeta from Yandex API data
func BuildTrackMetaFromYandex(track *yandexmusic.Track, album *yandexmusic.Album, trackNum, discNum int, downloadInfo yandexmusic.DownloadInfo) TrackMeta {
artistNames := make([]string, 0, len(track.Artists))
for _, a := range track.Artists {
artistNames = append(artistNames, a.Name)
}
artistName := ""
if len(artistNames) > 0 {
artistName = artistNames[0]
}
albumTitle := ""
albumID := ""
year := 0
var albumGenre, albumLabel, albumDesc, albumReleaseDate, albumCopyright string
if album != nil {
albumTitle = album.Title
albumID = album.ID
year = album.Year
albumGenre = album.Genre
albumLabel = album.Label
albumDesc = album.Description
albumReleaseDate = album.ReleaseDate
if len(album.CatalogNumber) > 0 {
albumCopyright = album.CatalogNumber
}
} else if len(track.Albums) > 0 {
albumTitle = track.Albums[0].Title
albumID = track.Albums[0].ID
year = track.Albums[0].Year
}
artistID := ""
if len(track.Artists) > 0 {
artistID = track.Artists[0].ID
}
albumArtist := ""
albumArtistID := ""
if album != nil && len(album.Artists) > 0 {
albumArtist = album.Artists[0].Name
albumArtistID = album.Artists[0].ID
} else if len(track.Albums) > 0 && len(track.Albums[0].Artists) > 0 {
albumArtist = track.Albums[0].Artists[0].Name
albumArtistID = track.Albums[0].Artists[0].ID
}
// Genre: prefer track genre, fallback to album genre
genre := track.Genre
if genre == "" {
genre = albumGenre
}
// Publisher: album label
publisher := albumLabel
// Original year from release date
var originalYear string
if albumReleaseDate != "" {
if len(albumReleaseDate) >= 4 {
originalYear = albumReleaseDate[:4]
}
}
// Lyrics
var lyrics string
if track.Lyrics != nil && track.Lyrics.HasText {
lyrics = track.Lyrics.Text
}
return TrackMeta{
TrackID: track.ID,
AlbumID: albumID,
ArtistID: artistID,
Title: track.Title,
ArtistName: artistName,
ArtistNames: artistNames,
AlbumTitle: albumTitle,
AlbumArtist: albumArtist,
AlbumArtistID: albumArtistID,
Year: year,
TrackNumber: trackNum,
DiscNumber: discNum,
DurationMs: int64(track.DurationMs),
Bitrate: downloadInfo.Bitrate,
Codec: downloadInfo.Codec,
DownloadedAt: "",
// Extended metadata
Genre: genre,
Comment: albumDesc,
Lyrics: lyrics,
Publisher: publisher,
EncodedBy: "ym-dl",
OriginalYear: originalYear,
Composer: "",
ISRC: "",
Copyright: albumCopyright,
}
}
// BuildAlbumMetaFromYandex creates AlbumMeta from Yandex API data
func BuildAlbumMetaFromYandex(album *yandexmusic.Album, tracks []TrackMeta) AlbumMeta {
artistName := ""
artistID := ""
if len(album.Artists) > 0 {
artistName = album.Artists[0].Name
artistID = album.Artists[0].ID
}
return AlbumMeta{
AlbumID: album.ID,
ArtistID: artistID,
Title: album.Title,
ArtistName: artistName,
Year: album.Year,
TrackCount: len(tracks),
Tracks: tracks,
DownloadedAt: "",
}
}
// writeID3Tags writes ID3v2 tags to an MP3 file
func writeID3Tags(filePath string, meta TrackMeta, coverData []byte) error {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
// Create new tag if file doesn't have one
tag = id3v2.NewEmptyTag()
}
defer tag.Close()
// Join all artists with "; " for the artist tag
artistString := strings.Join(meta.ArtistNames, "; ")
// Set basic tags
tag.SetTitle(meta.Title)
tag.SetArtist(artistString)
tag.SetAlbum(meta.AlbumTitle)
// Album artist (TPE2 frame) - use album artist if different from track artist
if meta.AlbumArtist != "" && meta.AlbumArtist != meta.ArtistName {
tag.AddTextFrame("TPE2", id3v2.EncodingUTF8, meta.AlbumArtist)
}
// Use album year from metadata, fallback to current year
year := meta.Year
if year == 0 {
year = time.Now().Year()
}
tag.SetYear(fmt.Sprintf("%d", year))
if meta.TrackNumber > 0 {
// Track number via TRCK frame
tag.AddTextFrame("TRCK", id3v2.EncodingUTF8, fmt.Sprintf("%d", meta.TrackNumber))
}
if meta.DiscNumber > 0 {
// Disc number via TPOS frame
tag.AddTextFrame("TPOS", id3v2.EncodingUTF8, fmt.Sprintf("%d", meta.DiscNumber))
}
// Genre (TCON)
if meta.Genre != "" {
tag.SetGenre(meta.Genre)
}
// Composer (TCOM)
if meta.Composer != "" {
tag.AddTextFrame("TCOM", id3v2.EncodingUTF8, meta.Composer)
}
// Publisher (TPUB)
if meta.Publisher != "" {
tag.AddTextFrame("TPUB", id3v2.EncodingUTF8, meta.Publisher)
}
// Encoded by (TENC)
if meta.EncodedBy != "" {
tag.AddTextFrame("TENC", id3v2.EncodingUTF8, meta.EncodedBy)
}
// Copyright (TCOP)
if meta.Copyright != "" {
tag.AddTextFrame("TCOP", id3v2.EncodingUTF8, meta.Copyright)
}
// Original year (TORY)
if meta.OriginalYear != "" {
tag.AddTextFrame("TORY", id3v2.EncodingUTF8, meta.OriginalYear)
}
// ISRC (TSRC)
if meta.ISRC != "" {
tag.AddTextFrame("TSRC", id3v2.EncodingUTF8, meta.ISRC)
}
// Comment (COMM)
if meta.Comment != "" {
tag.AddCommentFrame(id3v2.CommentFrame{
Encoding: id3v2.EncodingUTF8,
Language: "eng",
Description: "Comment",
Text: meta.Comment,
})
}
// Lyrics (USLT)
if meta.Lyrics != "" {
tag.AddUnsynchronisedLyricsFrame(id3v2.UnsynchronisedLyricsFrame{
Encoding: id3v2.EncodingUTF8,
Language: "eng",
ContentDescriptor: "Lyrics",
Lyrics: meta.Lyrics,
})
}
// Add cover art (APIC frame)
if len(coverData) > 0 {
tag.AddAttachedPicture(id3v2.PictureFrame{
Encoding: id3v2.EncodingUTF8,
MimeType: "image/jpeg",
PictureType: id3v2.PTFrontCover,
Description: "Cover",
Picture: coverData,
})
}
// Add custom TXXX frames for Yandex IDs
tag.AddTextFrame("TXXX", id3v2.EncodingUTF8, "YANDEX_TRACK_ID: "+meta.TrackID)
tag.AddTextFrame("TXXX", id3v2.EncodingUTF8, "YANDEX_ALBUM_ID: "+meta.AlbumID)
tag.AddTextFrame("TXXX", id3v2.EncodingUTF8, "YANDEX_ARTIST_ID: "+meta.ArtistID)
// Save tag
return tag.Save()
}
+291
View File
@@ -0,0 +1,291 @@
package storage
import (
"path/filepath"
"os"
"testing"
"time"
"gitea.mrixs.me/Mrixs/yandex-music-api-go/yandexmusic"
)
func TestStorageNew(t *testing.T) {
tmpDir := t.TempDir()
s, err := NewStorage(tmpDir, true)
if err != nil {
t.Fatalf("NewStorage failed: %v", err)
}
if s == nil {
t.Fatal("expected non-nil storage")
}
}
func TestStorageSaveAndLoadTrack(t *testing.T) {
tmpDir := t.TempDir()
s, err := NewStorage(tmpDir, true)
if err != nil {
t.Fatalf("NewStorage failed: %v", err)
}
meta := TrackMeta{
TrackID: "123",
AlbumID: "456",
ArtistID: "789",
Title: "Test Track",
ArtistName: "Test Artist",
AlbumTitle: "Test Album",
TrackNumber: 1,
DiscNumber: 1,
DurationMs: 180000,
Bitrate: 320,
Codec: "mp3",
DownloadedAt: time.Now().Format(time.RFC3339),
}
data := []byte("fake audio data")
if err := s.SaveTrack(meta, data, nil); err != nil {
t.Fatalf("SaveTrack failed: %v", err)
}
// Check index
if !s.IsTrackDownloaded("123") {
t.Error("track not marked as downloaded")
}
loadedMeta, ok := s.GetTrackMeta("123")
if !ok {
t.Fatal("track not found in index")
}
if loadedMeta.Title != "Test Track" {
t.Errorf("expected title 'Test Track', got '%s'", loadedMeta.Title)
}
if loadedMeta.FileSize != int64(len(data)) {
t.Errorf("expected file size %d, got %d", len(data), loadedMeta.FileSize)
}
// Check file exists using the stored path
if _, err := os.Stat(loadedMeta.FilePath); os.IsNotExist(err) {
t.Error("track file not created")
}
}
func TestStorageIdempotent(t *testing.T) {
tmpDir := t.TempDir()
s, err := NewStorage(tmpDir, true)
if err != nil {
t.Fatalf("NewStorage failed: %v", err)
}
meta := TrackMeta{
TrackID: "123",
Title: "Test Track",
ArtistName: "Test Artist",
AlbumTitle: "Test Album",
TrackNumber: 1,
Codec: "mp3",
}
data := []byte("fake audio data")
// Save first time
if err := s.SaveTrack(meta, data, nil); err != nil {
t.Fatalf("first SaveTrack failed: %v", err)
}
firstPath := meta.FilePath
// Save second time (should not create duplicate)
if err := s.SaveTrack(meta, data, nil); err != nil {
t.Fatalf("second SaveTrack failed: %v", err)
}
if meta.FilePath != firstPath {
t.Errorf("file path changed on second save: %s vs %s", meta.FilePath, firstPath)
}
// Verify only one file exists
files, _ := os.ReadDir(filepath.Join(tmpDir, "Test Artist", "Test Album"))
count := 0
for _, f := range files {
if !f.IsDir() {
count++
}
}
if count != 1 {
t.Errorf("expected 1 file, found %d", count)
}
}
func TestStorageOrganizeFalse(t *testing.T) {
tmpDir := t.TempDir()
s, err := NewStorage(tmpDir, false)
if err != nil {
t.Fatalf("NewStorage failed: %v", err)
}
meta := TrackMeta{
TrackID: "123",
Title: "Test Track",
ArtistName: "Test Artist",
AlbumTitle: "Test Album",
TrackNumber: 1,
Codec: "mp3",
DownloadedAt: time.Now().Format(time.RFC3339),
}
data := []byte("fake audio data")
if err := s.SaveTrack(meta, data, nil); err != nil {
t.Fatalf("SaveTrack failed: %v", err)
}
// Should be flat structure - get the saved meta from index
loadedMeta, ok := s.GetTrackMeta("123")
if !ok {
t.Fatal("track not found in index")
}
expectedDir := tmpDir
if filepath.Dir(loadedMeta.FilePath) != expectedDir {
t.Errorf("expected flat structure in %s, got %s", expectedDir, filepath.Dir(loadedMeta.FilePath))
}
}
func TestStorageSanitizeFilename(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"normal", "normal"},
{"with/slash", "with_slash"},
{"with\\backslash", "with_backslash"},
{"with:colon", "with_colon"},
{"with*star", "with_star"},
{"with?question", "with_question"},
{"with\"quote", "with_quote"},
{"with<lt", "with_lt"},
{"with>gt", "with_gt"},
{"with|pipe", "with_pipe"},
}
for _, tc := range tests {
result := sanitizeFilename(tc.input)
if result != tc.expected {
t.Errorf("sanitizeFilename(%q) = %q, want %q", tc.input, result, tc.expected)
}
}
}
func TestStorageBuildTrackMetaFromYandex(t *testing.T) {
track := &yandexmusic.Track{
ID: "123",
Title: "Test Track",
Genre: "Pop",
DurationMs: 180000,
Artists: []yandexmusic.Artist{
{ID: "789", Name: "Test Artist"},
},
Albums: []yandexmusic.Album{
{ID: "456", Title: "Test Album"},
},
}
album := &yandexmusic.Album{
ID: "456",
Title: "Test Album",
Genre: "Album Pop",
Year: 2023,
ReleaseDate: "2023-05-15",
Label: "Test Label",
Description: "Test album description",
CatalogNumber: "TC-001",
Artists: []yandexmusic.Artist{
{ID: "789", Name: "Test Artist"},
},
}
downloadInfo := yandexmusic.DownloadInfo{
Bitrate: 320,
Codec: "mp3",
}
meta := BuildTrackMetaFromYandex(track, album, 1, 1, downloadInfo)
if meta.TrackID != "123" {
t.Errorf("expected track ID 123, got %s", meta.TrackID)
}
if meta.Title != "Test Track" {
t.Errorf("expected title 'Test Track', got '%s'", meta.Title)
}
if meta.ArtistName != "Test Artist" {
t.Errorf("expected artist 'Test Artist', got '%s'", meta.ArtistName)
}
if meta.AlbumTitle != "Test Album" {
t.Errorf("expected album 'Test Album', got '%s'", meta.AlbumTitle)
}
if meta.Bitrate != 320 {
t.Errorf("expected bitrate 320, got %d", meta.Bitrate)
}
if meta.Codec != "mp3" {
t.Errorf("expected codec mp3, got %s", meta.Codec)
}
// Extended metadata checks
if meta.Genre != "Pop" {
t.Errorf("expected genre 'Pop', got '%s'", meta.Genre)
}
if meta.Comment != "Test album description" {
t.Errorf("expected comment 'Test album description', got '%s'", meta.Comment)
}
if meta.Lyrics != "" {
t.Errorf("expected empty lyrics (no lyrics in track), got '%s'", meta.Lyrics)
}
if meta.Publisher != "Test Label" {
t.Errorf("expected publisher 'Test Label', got '%s'", meta.Publisher)
}
if meta.EncodedBy != "ym-dl" {
t.Errorf("expected encoded_by 'ym-dl', got '%s'", meta.EncodedBy)
}
if meta.OriginalYear != "2023" {
t.Errorf("expected original_year '2023', got '%s'", meta.OriginalYear)
}
if meta.Copyright != "TC-001" {
t.Errorf("expected copyright 'TC-001', got '%s'", meta.Copyright)
}
}
func TestStorageBuildAlbumMetaFromYandex(t *testing.T) {
album := &yandexmusic.Album{
ID: "456",
Title: "Test Album",
Artists: []yandexmusic.Artist{
{ID: "789", Name: "Test Artist"},
},
Year: 2023,
}
tracks := []TrackMeta{
{TrackID: "1", Title: "Track 1"},
{TrackID: "2", Title: "Track 2"},
}
meta := BuildAlbumMetaFromYandex(album, tracks)
if meta.AlbumID != "456" {
t.Errorf("expected album ID 456, got %s", meta.AlbumID)
}
if meta.Title != "Test Album" {
t.Errorf("expected title 'Test Album', got '%s'", meta.Title)
}
if meta.ArtistName != "Test Artist" {
t.Errorf("expected artist 'Test Artist', got '%s'", meta.ArtistName)
}
if meta.Year != 2023 {
t.Errorf("expected year 2023, got %d", meta.Year)
}
if meta.TrackCount != 2 {
t.Errorf("expected track count 2, got %d", meta.TrackCount)
}
}
+241
View File
@@ -0,0 +1,241 @@
package yandex
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/xml"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strings"
"time"
"ym-dl/internal/config"
"gitea.mrixs.me/Mrixs/yandex-music-api-go/yandexmusic"
)
const downloadSalt = "XGRlBW9FXlekgbPrRHuSiA" // Magic salt for URL signing
// TrackPosition represents track position in album
type TrackPosition struct {
Volume int `json:"volume"`
Index int `json:"index"`
}
// YandexClient wraps the external yandexmusic client
type YandexClient struct {
client *yandexmusic.Client
config *config.YandexConfig
httpClient *http.Client
debug bool
logger *slog.Logger
}
// NewYandexClient creates a new Yandex Music client wrapper
func NewYandexClient(cfg *config.YandexConfig, debug bool) (*YandexClient, error) {
if cfg.Token == "" {
return nil, errors.New("yandex.token is required")
}
httpClient := &http.Client{
Timeout: 30 * time.Second,
}
logLevel := slog.LevelInfo
if debug {
logLevel = slog.LevelDebug
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: logLevel,
}))
client := yandexmusic.NewClient(yandexmusic.ClientConfig{
Token: cfg.Token,
})
if err := client.Init(context.Background()); err != nil {
return nil, fmt.Errorf("init client: %w", err)
}
return &YandexClient{
client: client,
config: cfg,
httpClient: httpClient,
debug: debug,
logger: logger,
}, nil
}
// GetArtistTracks fetches tracks for an artist
func (c *YandexClient) GetArtistTracks(ctx context.Context, artistID int, page, pageSize int) ([]*yandexmusic.Track, error) {
return c.client.GetArtistTracks(ctx, fmt.Sprintf("%d", artistID), page, pageSize)
}
// GetArtistAlbums fetches albums for an artist
func (c *YandexClient) GetArtistAlbums(ctx context.Context, artistID int, page, pageSize int) ([]*yandexmusic.Album, error) {
return c.client.GetArtistAlbums(ctx, fmt.Sprintf("%d", artistID), page, pageSize)
}
// GetAlbumWithTracks fetches an album with its tracks
func (c *YandexClient) GetAlbumWithTracks(ctx context.Context, albumID string) (*yandexmusic.Album, error) {
albums, err := c.client.GetAlbumsWithTracks(ctx, []string{albumID})
if err != nil {
return nil, err
}
if len(albums) == 0 {
return nil, fmt.Errorf("album not found")
}
return albums[0], nil
}
// GetTrackInfo fetches a single track's info
func (c *YandexClient) GetTrackInfo(ctx context.Context, trackID string) (*yandexmusic.Track, error) {
return c.client.GetTrack(ctx, trackID)
}
// GetTrackInfoWithPosition fetches track info including track position in album
func (c *YandexClient) GetTrackInfoWithPosition(ctx context.Context, trackID string) (*yandexmusic.Track, *TrackPosition, error) {
track, err := c.client.GetTrack(ctx, trackID)
if err != nil {
return nil, nil, err
}
// Extract track position from first album
var position *TrackPosition
if len(track.Albums) > 0 {
albumID := track.Albums[0].ID
albums, err := c.client.GetAlbumsWithTracks(ctx, []string{albumID})
if err == nil && len(albums) > 0 {
album := albums[0]
// Find track in album.Volumes and get position
for volIdx, volume := range album.Volumes {
for trackIdx, t := range volume {
if t.ID == trackID {
position = &TrackPosition{
Volume: volIdx + 1,
Index: trackIdx + 1,
}
break
}
}
if position != nil {
break
}
}
}
}
return track, position, nil
}
// GetTrackDownloadInfo gets download info for a track
func (c *YandexClient) GetTrackDownloadInfo(ctx context.Context, trackID string) ([]yandexmusic.DownloadInfo, error) {
return c.client.GetTrackDownloadInfo(ctx, trackID, "")
}
// GetArtistInfo fetches artist info
func (c *YandexClient) GetArtistInfo(ctx context.Context, artistID int) (*yandexmusic.Artist, error) {
return c.client.GetArtist(ctx, fmt.Sprintf("%d", artistID))
}
// DownloadTrackXML downloads the XML with track download URLs
func (c *YandexClient) DownloadTrackXML(ctx context.Context, downloadInfoURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", downloadInfoURL, nil)
if err != nil {
return nil, fmt.Errorf("create download XML request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("download XML request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("download XML failed: %s: %s", resp.Status, string(body))
}
return io.ReadAll(resp.Body)
}
// DownloadInfoXML describes the structure of the XML file with download info
type DownloadInfoXML struct {
XMLName xml.Name `xml:"download-info"`
Host string `xml:"host"`
Path string `xml:"path"`
Ts string `xml:"ts"`
S string `xml:"s"`
}
// GetTrackDownloadURL gets the direct MP3 download URL for a track
func (c *YandexClient) GetTrackDownloadURL(ctx context.Context, trackID string) (string, error) {
// Get download info to find best MP3 quality
downloadInfos, err := c.GetTrackDownloadInfo(ctx, trackID)
if err != nil {
return "", fmt.Errorf("get download info: %w", err)
}
// Find best quality MP3
var bestInfo *yandexmusic.DownloadInfo
for _, info := range downloadInfos {
if info.Codec == "mp3" && info.DirectLink {
if bestInfo == nil || info.Bitrate > bestInfo.Bitrate {
bestInfo = &info
}
}
}
if bestInfo == nil {
for _, info := range downloadInfos {
if info.Codec == "mp3" {
if bestInfo == nil || info.Bitrate > bestInfo.Bitrate {
bestInfo = &info
}
}
}
}
if bestInfo == nil && len(downloadInfos) > 0 {
bestInfo = &downloadInfos[0]
}
if bestInfo == nil {
return "", fmt.Errorf("no suitable download info found")
}
// If we have a direct URL, use it
if bestInfo.URL != "" && bestInfo.DirectLink {
return bestInfo.URL, nil
}
// If we have a download info URL, use it to get the signed URL
if bestInfo.URL != "" {
// Download and parse XML
xmlData, err := c.DownloadTrackXML(ctx, bestInfo.URL)
if err != nil {
return "", fmt.Errorf("download XML: %w", err)
}
var infoXML DownloadInfoXML
if err := xml.Unmarshal(xmlData, &infoXML); err != nil {
return "", fmt.Errorf("parse XML: %w", err)
}
// Generate signed URL
signData := []byte(downloadSalt + strings.TrimPrefix(infoXML.Path, "/") + infoXML.S)
sign := md5.Sum(signData)
hexSign := hex.EncodeToString(sign[:])
finalURL := fmt.Sprintf("https://%s/get-mp3/%s/%s%s", infoXML.Host, hexSign, infoXML.Ts, infoXML.Path)
if c.debug {
c.logger.Debug("constructed download URL", "url", finalURL)
}
return finalURL, nil
}
return "", fmt.Errorf("no download URL available")
}