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
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user