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

+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")
}
}