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:
commit
fe62910311
16 files changed
+6688
No files matched your search
+26
@@ -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
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
©right,
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
Reference in new issue
Block a user