refactor: major project restructure
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
package downloader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mathismqn/godeez/internal/app"
|
||||
"github.com/mathismqn/godeez/internal/bpm"
|
||||
"github.com/mathismqn/godeez/internal/crypto"
|
||||
"github.com/mathismqn/godeez/internal/deezer"
|
||||
"github.com/mathismqn/godeez/internal/fileutil"
|
||||
"github.com/mathismqn/godeez/internal/store"
|
||||
"github.com/mathismqn/godeez/internal/tags"
|
||||
)
|
||||
|
||||
const ChunkSize = 2048
|
||||
|
||||
type Client struct {
|
||||
appCtx *app.Context
|
||||
resourceType string
|
||||
deezerClient *deezer.Client
|
||||
|
||||
hashIndexOnce sync.Once
|
||||
hashIndex *fileutil.HashIndex
|
||||
hashIndexErr error
|
||||
}
|
||||
|
||||
func New(appCtx *app.Context, resourceType string) *Client {
|
||||
return &Client{
|
||||
appCtx: appCtx,
|
||||
resourceType: resourceType,
|
||||
deezerClient: nil,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Run(ctx context.Context, opts Options, ids []string) error {
|
||||
var err error
|
||||
c.deezerClient, err = deezer.NewClient(ctx, c.appCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
var resource deezer.Resource
|
||||
|
||||
switch c.resourceType {
|
||||
case "album":
|
||||
resource = &deezer.Album{}
|
||||
case "playlist":
|
||||
resource = &deezer.Playlist{}
|
||||
default:
|
||||
return fmt.Errorf("unsupported resource type: %s", c.resourceType)
|
||||
}
|
||||
|
||||
if err := c.deezerClient.FetchResource(ctx, resource, id); err != nil {
|
||||
return fmt.Errorf("failed to fetch resource: %w", err)
|
||||
}
|
||||
|
||||
songs := resource.GetSongs()
|
||||
if len(songs) == 0 {
|
||||
return fmt.Errorf("%s has no songs", c.resourceType)
|
||||
}
|
||||
|
||||
outputDir := resource.GetOutputDir(opts.OutputDir)
|
||||
if err := fileutil.EnsureDir(outputDir); err != nil {
|
||||
return fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
|
||||
for _, song := range songs {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
if err := c.downloadSong(ctx, resource, song, opts, outputDir); err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to download %s: %v\n", song.Title, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) downloadSong(ctx context.Context, resource deezer.Resource, song *deezer.Song, opts Options, outputDir string) error {
|
||||
media, err := c.deezerClient.FetchMedia(ctx, song, opts.Quality)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileName := song.GetFileName(c.resourceType, song, media)
|
||||
outputPath := path.Join(outputDir, fileName)
|
||||
|
||||
mediaFormat, err := media.GetFormat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, skip := c.shouldSkipDownload(ctx, song.ID, mediaFormat); skip {
|
||||
return nil
|
||||
}
|
||||
|
||||
metricsChan := make(chan *bpm.Metrics, 1)
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
metrics, err := bpm.FetchMetrics(ctx, c.deezerClient.Session.HttpClient, song.Artist, song.GetTitle(), song.Duration)
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
|
||||
metricsChan <- metrics
|
||||
}()
|
||||
|
||||
stream, err := c.deezerClient.GetMediaStream(ctx, media, song.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("media stream unavailable: %w", err)
|
||||
}
|
||||
|
||||
dlCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := c.streamToFile(dlCtx, stream, outputPath, song.ID); err != nil {
|
||||
fileutil.DeleteFile(outputPath)
|
||||
|
||||
return fmt.Errorf("unable to write to file: %w", err)
|
||||
}
|
||||
|
||||
metrics := &bpm.Metrics{}
|
||||
select {
|
||||
case metrics = <-metricsChan:
|
||||
fmt.Printf("BPM: %s, Key: %s\n", metrics.BPM, metrics.Key)
|
||||
case err := <-errChan:
|
||||
|
||||
fmt.Printf("Warning: failed to fetch BPM and key: %v\n", err)
|
||||
}
|
||||
|
||||
cover, err := c.deezerClient.FetchCoverImage(ctx, song)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to fetch cover image: %v\n", err)
|
||||
}
|
||||
|
||||
c.finalizeDownload(resource, song, outputPath, mediaFormat, cover, metrics)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) shouldSkipDownload(ctx context.Context, songID, mediaFormat string) (string, bool) {
|
||||
if existing, err := store.GetDownloadInfo(songID); err == nil && existing.Quality == mediaFormat {
|
||||
if fileutil.FileExists(existing.Path) {
|
||||
return existing.Path, true
|
||||
}
|
||||
if existing.Hash != "" {
|
||||
if err := c.initHashIndex(ctx); err == nil {
|
||||
if foundPath, ok := c.hashIndex.Find(existing.Hash); ok {
|
||||
existing.Path = foundPath
|
||||
_ = existing.Save()
|
||||
|
||||
return foundPath, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (c *Client) streamToFile(ctx context.Context, stream io.ReadCloser, outputPath, songID string) error {
|
||||
defer stream.Close()
|
||||
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
key := crypto.GetKey(c.appCtx.Config.SecretKey, songID)
|
||||
iv, err := hex.DecodeString(c.appCtx.Config.IV)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
buffer := make([]byte, ChunkSize)
|
||||
for chunk := 0; ; chunk++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
// continue
|
||||
}
|
||||
|
||||
totalRead := 0
|
||||
for totalRead < ChunkSize {
|
||||
n, err := stream.Read(buffer[totalRead:])
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if n > 0 {
|
||||
totalRead += n
|
||||
}
|
||||
}
|
||||
|
||||
if totalRead == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
if chunk%3 == 0 && totalRead == ChunkSize {
|
||||
buffer, err = crypto.Decrypt(buffer, key, iv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err = file.Write(buffer[:totalRead])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if totalRead < ChunkSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) finalizeDownload(resource deezer.Resource, song *deezer.Song, outputPath, mediaFormat string, cover []byte, metrics *bpm.Metrics) {
|
||||
if err := tags.AddTags(resource, song, cover, outputPath, metrics.BPM, metrics.Key); err != nil {
|
||||
fmt.Printf("Warning: failed to add tags: %v\n", err)
|
||||
}
|
||||
|
||||
hash, err := fileutil.GetFileHash(outputPath)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to get file hash: %v\n", err)
|
||||
}
|
||||
|
||||
info := &store.DownloadInfo{
|
||||
SongID: song.ID,
|
||||
Quality: mediaFormat,
|
||||
Path: outputPath,
|
||||
Hash: hash,
|
||||
Downloaded: time.Now(),
|
||||
}
|
||||
|
||||
if err := info.Save(); err != nil {
|
||||
fmt.Printf("Warning: failed to save download info: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) initHashIndex(ctx context.Context) error {
|
||||
c.hashIndexOnce.Do(func() {
|
||||
c.hashIndex, c.hashIndexErr = fileutil.NewHashIndex(ctx, c.appCtx.AppDir)
|
||||
})
|
||||
|
||||
return c.hashIndexErr
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package downloader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var validQualities = map[string]bool{
|
||||
"mp3_128": true,
|
||||
"mp3_320": true,
|
||||
"flac": true,
|
||||
"best": true,
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
OutputDir string
|
||||
Quality string
|
||||
}
|
||||
|
||||
func (o *Options) Validate(appDir string) error {
|
||||
if o.OutputDir == "" {
|
||||
o.OutputDir = appDir
|
||||
}
|
||||
|
||||
if o.Quality == "" {
|
||||
o.Quality = "best"
|
||||
}
|
||||
|
||||
if !validQualities[o.Quality] {
|
||||
return fmt.Errorf("invalid quality option: %s", o.Quality)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user