Replace github.com/mattn/go-sqlite3 (CGO required) with modernc.org/sqlite (pure Go, works with CGO_ENABLED=0). Binary built with CGO_ENABLED=0 in Dockerfile can now load SQLite storage without cgo errors.
755 lines
21 KiB
Go
755 lines
21 KiB
Go
package storage
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/bogem/id3v2"
|
|
_ "modernc.org/sqlite"
|
|
"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("sqlite", 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()
|
|
}
|