commit 97e8b758b34818c2f26a8f54558c460e36e7b61d Author: Joren Date: Sun Apr 19 21:11:38 2026 +0200 initial Go port of streamrip diff --git a/cmd/rip/main.go b/cmd/rip/main.go new file mode 100644 index 0000000..acb2c15 --- /dev/null +++ b/cmd/rip/main.go @@ -0,0 +1,1967 @@ +package main + +import ( + "bufio" + "context" + "database/sql" + "encoding/json" + "fmt" + "html" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "regexp" + "runtime" + "strconv" + "strings" + "time" + + "github.com/AlecAivazis/survey/v2" + "golang.org/x/term" + + "streamrip-go/internal/app" + "streamrip-go/internal/config" + "streamrip-go/internal/netutil" + "streamrip-go/internal/provider" + "streamrip-go/internal/urlparse" + + _ "modernc.org/sqlite" +) + +func main() { + if len(os.Args) < 2 { + fmt.Println("usage: rip ") + fmt.Println("commands: url, file, config, database, id, search, lastfm, qobuz-smoke, qobuz-rip-smoke, qobuz-convert-rip-smoke, qobuz-album-rip-smoke, qobuz-playlist-rip-smoke, qobuz-artist-rip-smoke, qobuz-label-rip-smoke, qobuz-search-smoke, tidal-search-smoke, tidal-metadata-smoke, tidal-rip-smoke, tidal-album-rip-smoke, tidal-playlist-rip-smoke, tidal-artist-rip-smoke") + os.Exit(2) + } + + cfg, err := config.Load("") + if err != nil { + fmt.Fprintf(os.Stderr, "config error: %v\n", err) + os.Exit(1) + } + + ctx := context.Background() + + switch os.Args[1] { + case "url": + if len(os.Args) < 3 { + fmt.Println("usage: rip url [--force|--ignore-db]") + os.Exit(2) + } + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + os.Exit(1) + } + defer func() { _ = mainApp.Close() }() + + rawArgs := make([]string, 0, len(os.Args[2:])) + ignoreDB := false + for _, arg := range os.Args[2:] { + if arg == "--force" || arg == "--ignore-db" { + ignoreDB = true + continue + } + rawArgs = append(rawArgs, arg) + } + mainApp.IgnoreDB = ignoreDB + + added := 0 + for _, raw := range rawArgs { + if addURLToQueue(ctx, mainApp, raw) { + added++ + } + } + + if added == 0 { + fmt.Println("nothing to rip") + return + } + + if err = mainApp.Resolve(ctx); err != nil { + fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Rip(ctx); err != nil { + fmt.Fprintf(os.Stderr, "rip error: %v\n", err) + os.Exit(1) + } + fmt.Printf("url rip complete (%d item(s))\n", added) + case "file": + if len(os.Args) < 3 { + fmt.Println("usage: rip file [--force|--ignore-db]") + os.Exit(2) + } + + ignoreDB := false + for _, arg := range os.Args[3:] { + switch arg { + case "--force", "--ignore-db": + ignoreDB = true + default: + fmt.Fprintf(os.Stderr, "option error: unknown option %q\n", arg) + os.Exit(2) + } + } + + content, err := os.ReadFile(os.Args[2]) + if err != nil { + fmt.Fprintf(os.Stderr, "read file error: %v\n", err) + os.Exit(1) + } + + idItems, urls, repeated, jsonInput, err := parseFileInput(content) + if err != nil { + fmt.Fprintf(os.Stderr, "file parse error: %v\n", err) + os.Exit(2) + } + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + os.Exit(1) + } + defer func() { _ = mainApp.Close() }() + mainApp.IgnoreDB = ignoreDB + + added := 0 + if jsonInput { + fmt.Printf("detected json file. loading %d item(s)\n", len(idItems)) + for _, item := range idItems { + if err = mainApp.AddByID(ctx, item.Source, item.MediaType, item.ID); err != nil { + fmt.Printf("add failed: source=%s type=%s id=%s err=%v\n", item.Source, item.MediaType, item.ID, err) + continue + } + added++ + } + } else { + if repeated > 0 { + fmt.Printf("found %d repeated url(s)\n", repeated) + } + fmt.Printf("detected list of urls. loading %d item(s)\n", len(urls)) + for _, raw := range urls { + if addURLToQueue(ctx, mainApp, raw) { + added++ + } + } + } + + if added == 0 { + fmt.Println("nothing to rip") + return + } + + if err = mainApp.Resolve(ctx); err != nil { + fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Rip(ctx); err != nil { + fmt.Fprintf(os.Stderr, "rip error: %v\n", err) + os.Exit(1) + } + fmt.Printf("file rip complete (%d item(s))\n", added) + case "config": + if len(os.Args) < 3 { + fmt.Println("usage: rip config [options]") + os.Exit(2) + } + switch os.Args[2] { + case "open": + vim := false + for _, arg := range os.Args[3:] { + switch arg { + case "-v", "--vim": + vim = true + default: + fmt.Fprintf(os.Stderr, "option error: unknown option %q\n", arg) + os.Exit(2) + } + } + fmt.Printf("opening file at %s\n", cfg.Path) + if err = openConfigInEditor(cfg.Path, vim); err != nil { + fmt.Fprintf(os.Stderr, "open config error: %v\n", err) + os.Exit(1) + } + case "reset": + yes := false + for _, arg := range os.Args[3:] { + switch arg { + case "-y", "--yes": + yes = true + default: + fmt.Fprintf(os.Stderr, "option error: unknown option %q\n", arg) + os.Exit(2) + } + } + if !yes { + if !term.IsTerminal(int(os.Stdin.Fd())) { + fmt.Fprintln(os.Stderr, "reset requires --yes in non-interactive mode") + os.Exit(2) + } + ok, askErr := promptYesNo(fmt.Sprintf("Are you sure you want to reset the config file at %s? [y/N]: ", cfg.Path)) + if askErr != nil { + fmt.Fprintf(os.Stderr, "prompt error: %v\n", askErr) + os.Exit(1) + } + if !ok { + fmt.Println("reset aborted") + return + } + } + def := config.DefaultConfigData() + cfg.File = def + cfg.Session = def + if err = cfg.SaveFile(); err != nil { + fmt.Fprintf(os.Stderr, "reset config error: %v\n", err) + os.Exit(1) + } + fmt.Printf("reset the config file at %s\n", cfg.Path) + case "path": + if len(os.Args) > 3 { + fmt.Fprintf(os.Stderr, "option error: unexpected argument %q\n", os.Args[3]) + os.Exit(2) + } + fmt.Printf("config path: '%s'\n", cfg.Path) + default: + fmt.Fprintf(os.Stderr, "unknown config command: %s\n", os.Args[2]) + os.Exit(2) + } + case "database": + if len(os.Args) < 4 || os.Args[2] != "browse" { + fmt.Println("usage: rip database browse ") + os.Exit(2) + } + table := strings.ToLower(strings.TrimSpace(os.Args[3])) + switch table { + case "downloads": + rows, listErr := listDownloadsRows(cfg.Session.Database.DownloadsPath) + if listErr != nil { + fmt.Fprintf(os.Stderr, "database browse error: %v\n", listErr) + os.Exit(1) + } + fmt.Println("downloads database") + fmt.Println("row id") + for i, id := range rows { + fmt.Printf("%02d %s\n", i, id) + } + case "failed": + rows, listErr := listFailedRows(cfg.Session.Database.FailedDownloadsPath) + if listErr != nil && isNoSuchTableErr(listErr) && cfg.Session.Database.FailedDownloadsPath != cfg.Session.Database.DownloadsPath { + rows, listErr = listFailedRows(cfg.Session.Database.DownloadsPath) + } + if listErr != nil { + fmt.Fprintf(os.Stderr, "database browse error: %v\n", listErr) + os.Exit(1) + } + fmt.Println("failed downloads database") + fmt.Println("row source media_type id") + for i, row := range rows { + fmt.Printf("%02d %s %s %s\n", i, row.Source, row.MediaType, row.ID) + } + default: + fmt.Fprintf(os.Stderr, "invalid database %q. choose downloads or failed\n", table) + os.Exit(2) + } + case "id": + if len(os.Args) < 5 { + fmt.Println("usage: rip id [quality] [--force|--ignore-db]") + os.Exit(2) + } + + source := strings.ToLower(strings.TrimSpace(os.Args[2])) + mediaType := strings.ToLower(strings.TrimSpace(os.Args[3])) + itemID := strings.TrimSpace(os.Args[4]) + + opts, err := parseSmokeOptions(os.Args[5:], 0, 4) + if err != nil { + fmt.Fprintf(os.Stderr, "option error: %v\n", err) + os.Exit(2) + } + if opts.qualitySet { + switch source { + case "qobuz": + if opts.quality < 1 { + fmt.Fprintf(os.Stderr, "quality error: qobuz quality must be 1-4\n") + os.Exit(2) + } + cfg.Session.Qobuz.Quality = opts.quality + case "tidal": + cfg.Session.Tidal.Quality = opts.quality + } + } + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + os.Exit(1) + } + defer func() { _ = mainApp.Close() }() + mainApp.IgnoreDB = opts.ignoreDB + + if err = mainApp.AddByID(ctx, source, mediaType, itemID); err != nil { + fmt.Fprintf(os.Stderr, "add error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Resolve(ctx); err != nil { + fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Rip(ctx); err != nil { + fmt.Fprintf(os.Stderr, "rip error: %v\n", err) + os.Exit(1) + } + fmt.Printf("id rip complete: source=%s type=%s id=%s\n", source, mediaType, itemID) + case "search": + var source, mediaType string + var sopts searchOptions + if len(os.Args) < 5 { + if !term.IsTerminal(int(os.Stdin.Fd())) { + fmt.Println("usage: rip search [--limit N] [--force|--ignore-db] [--no-download]") + os.Exit(2) + } + source, mediaType, sopts, err = promptSearchInteractive(cfg.Session.CLI.MaxSearchResults) + if err != nil { + fmt.Fprintf(os.Stderr, "search prompt error: %v\n", err) + os.Exit(2) + } + } else { + source = strings.ToLower(strings.TrimSpace(os.Args[2])) + mediaType = strings.ToLower(strings.TrimSpace(os.Args[3])) + sopts, err = parseSearchArgs(os.Args[4:], cfg.Session.CLI.MaxSearchResults) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "search option error: %v\n", err) + os.Exit(2) + } + if !isAllowedSearchSource(source) { + fmt.Fprintf(os.Stderr, "unsupported search source %q\n", source) + os.Exit(2) + } + if !isAllowedMediaType(mediaType) { + fmt.Fprintf(os.Stderr, "unsupported media type %q\n", mediaType) + os.Exit(2) + } + if sopts.query == "" { + fmt.Fprintln(os.Stderr, "search query cannot be empty") + os.Exit(2) + } + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + os.Exit(1) + } + defer func() { _ = mainApp.Close() }() + + provider, err := mainApp.GetLoggedInProvider(ctx, source) + if err != nil { + fmt.Fprintf(os.Stderr, "%s login error: %v\n", source, err) + os.Exit(1) + } + + pages, err := provider.Search(ctx, mediaType, sopts.query, sopts.limit) + if err != nil { + fmt.Fprintf(os.Stderr, "search error: %v\n", err) + os.Exit(1) + } + + results := normalizeSearchResults(source, mediaType, pages) + if len(results) == 0 { + fmt.Println("no results") + return + } + + fmt.Printf("results: %d\n", len(results)) + for i, result := range results { + fmt.Printf("%2d. id=%s | %s\n", i+1, result.ID, result.Title) + } + if sopts.outputFile != "" { + if err = writeSearchResultsToFile(source, mediaType, results, sopts.outputFile); err != nil { + fmt.Fprintf(os.Stderr, "write results error: %v\n", err) + os.Exit(1) + } + fmt.Printf("wrote %d results to %s\n", len(results), sopts.outputFile) + return + } + if sopts.first { + results = results[:1] + } + if sopts.noDownload { + return + } + + if sopts.first { + selection := []int{0} + mainApp.IgnoreDB = sopts.ignoreDB + skippedDownloaded := 0 + added := 0 + for _, idx := range selection { + item := results[idx] + if !sopts.ignoreDB { + already, checkErr := mainApp.Store.IsDownloaded(ctx, item.ID) + if checkErr == nil && already { + skippedDownloaded++ + fmt.Printf("skip (already downloaded): id=%s | %s\n", item.ID, item.Title) + continue + } + } + if err = mainApp.AddByID(ctx, source, mediaType, item.ID); err != nil { + fmt.Printf("add failed: id=%s err=%v\n", item.ID, err) + continue + } + added++ + } + if added == 0 { + if skippedDownloaded > 0 { + fmt.Println("selected item was already downloaded (use --force to redownload)") + } else { + fmt.Println("nothing selected to download") + } + return + } + if err = mainApp.Resolve(ctx); err != nil { + fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Rip(ctx); err != nil { + fmt.Fprintf(os.Stderr, "rip error: %v\n", err) + os.Exit(1) + } + fmt.Printf("search download complete (%d item(s))\n", added) + return + } + + if !term.IsTerminal(int(os.Stdin.Fd())) { + fmt.Println("non-interactive input; use `rip id` to download specific results") + return + } + + selection, err := promptSearchSelectionMenu(source, mediaType, sopts.query, results) + if err != nil { + fmt.Fprintf(os.Stderr, "selection error: %v\n", err) + os.Exit(2) + } + if len(selection) == 0 { + fmt.Println("download cancelled") + return + } + + mainApp.IgnoreDB = sopts.ignoreDB + skippedDownloaded := 0 + added := 0 + for _, idx := range selection { + item := results[idx] + if !sopts.ignoreDB { + already, checkErr := mainApp.Store.IsDownloaded(ctx, item.ID) + if checkErr == nil && already { + skippedDownloaded++ + fmt.Printf("skip (already downloaded): id=%s | %s\n", item.ID, item.Title) + continue + } + } + if err = mainApp.AddByID(ctx, source, mediaType, item.ID); err != nil { + fmt.Printf("add failed: id=%s err=%v\n", item.ID, err) + continue + } + added++ + } + if added == 0 { + if skippedDownloaded > 0 { + fmt.Println("all selected items were already downloaded (use --force to redownload)") + } else { + fmt.Println("nothing selected to download") + } + return + } + if err = mainApp.Resolve(ctx); err != nil { + fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Rip(ctx); err != nil { + fmt.Fprintf(os.Stderr, "rip error: %v\n", err) + os.Exit(1) + } + fmt.Printf("search download complete (%d item(s))\n", added) + case "lastfm": + opts, parseErr := parseLastFMArgs(os.Args[2:], cfg.Session.LastFM.Source, cfg.Session.LastFM.FallbackSource) + if parseErr != nil { + fmt.Fprintf(os.Stderr, "lastfm option error: %v\n", parseErr) + fmt.Println("usage: rip lastfm [--source SOURCE] [--fallback-source SOURCE] ") + os.Exit(2) + } + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + os.Exit(1) + } + defer func() { _ = mainApp.Close() }() + + title, tracks, err := fetchLastFMPlaylist(ctx, cfg.Session.Downloads.VerifySSL, opts.PlaylistURL) + if err != nil { + fmt.Fprintf(os.Stderr, "lastfm parse error: %v\n", err) + os.Exit(1) + } + if len(tracks) == 0 { + fmt.Println("no tracks found in playlist") + return + } + fmt.Printf("lastfm playlist: %s (%d tracks)\n", title, len(tracks)) + + if err = queueLastFMTracks(ctx, mainApp, opts, tracks); err != nil { + fmt.Fprintf(os.Stderr, "lastfm resolve error: %v\n", err) + os.Exit(1) + } + if len(mainApp.Pending) == 0 { + fmt.Println("no lastfm tracks resolved") + return + } + if err = mainApp.Resolve(ctx); err != nil { + fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Rip(ctx); err != nil { + fmt.Fprintf(os.Stderr, "rip error: %v\n", err) + os.Exit(1) + } + fmt.Printf("lastfm rip complete (%d track(s))\n", len(mainApp.Pending)) + case "qobuz-smoke": + if len(os.Args) < 3 { + fmt.Println("usage: rip qobuz-smoke [quality]") + os.Exit(2) + } + opts, err := parseSmokeOptions(os.Args[3:], 1, 4) + if err != nil { + fmt.Fprintf(os.Stderr, "option error: %v\n", err) + os.Exit(2) + } + if opts.qualitySet { + cfg.Session.Qobuz.Quality = opts.quality + } + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + os.Exit(1) + } + defer func() { _ = mainApp.Close() }() + + provider, err := mainApp.GetLoggedInProvider(ctx, "qobuz") + if err != nil { + fmt.Fprintf(os.Stderr, "qobuz login error: %v\n", err) + os.Exit(1) + } + + trackID := os.Args[2] + meta, err := provider.GetMetadata(ctx, trackID, "track") + if err != nil { + fmt.Fprintf(os.Stderr, "metadata error: %v\n", err) + os.Exit(1) + } + + title, _ := meta["title"].(string) + d, err := provider.GetDownloadable(ctx, trackID, cfg.Session.Qobuz.Quality) + if err != nil { + fmt.Fprintf(os.Stderr, "downloadable error: %v\n", err) + os.Exit(1) + } + + fmt.Printf("qobuz ok: title=%q quality=%d ext=%s\n", title, cfg.Session.Qobuz.Quality, d.Extension) + fmt.Printf("stream_url=%s\n", d.URL) + case "qobuz-rip-smoke": + if len(os.Args) < 3 { + fmt.Println("usage: rip qobuz-rip-smoke [quality] [--force|--ignore-db]") + os.Exit(2) + } + opts, err := parseSmokeOptions(os.Args[3:], 1, 4) + if err != nil { + fmt.Fprintf(os.Stderr, "option error: %v\n", err) + os.Exit(2) + } + if opts.qualitySet { + cfg.Session.Qobuz.Quality = opts.quality + } + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + os.Exit(1) + } + defer func() { _ = mainApp.Close() }() + mainApp.IgnoreDB = opts.ignoreDB + + trackID := os.Args[2] + if err = mainApp.AddByID(ctx, "qobuz", "track", trackID); err != nil { + fmt.Fprintf(os.Stderr, "add error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Resolve(ctx); err != nil { + fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Rip(ctx); err != nil { + fmt.Fprintf(os.Stderr, "rip error: %v\n", err) + os.Exit(1) + } + + fmt.Println("qobuz rip smoke complete") + case "qobuz-convert-rip-smoke": + if len(os.Args) < 4 { + fmt.Println("usage: rip qobuz-convert-rip-smoke [quality] [--force|--ignore-db]") + os.Exit(2) + } + opts, err := parseSmokeOptions(os.Args[4:], 1, 4) + if err != nil { + fmt.Fprintf(os.Stderr, "option error: %v\n", err) + os.Exit(2) + } + if opts.qualitySet { + cfg.Session.Qobuz.Quality = opts.quality + } + cfg.Session.Conversion.Enabled = true + cfg.Session.Conversion.Codec = strings.ToUpper(strings.TrimSpace(os.Args[3])) + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + os.Exit(1) + } + defer func() { _ = mainApp.Close() }() + mainApp.IgnoreDB = opts.ignoreDB + + trackID := os.Args[2] + if err = mainApp.AddByID(ctx, "qobuz", "track", trackID); err != nil { + fmt.Fprintf(os.Stderr, "add error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Resolve(ctx); err != nil { + fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Rip(ctx); err != nil { + fmt.Fprintf(os.Stderr, "rip error: %v\n", err) + os.Exit(1) + } + fmt.Printf("qobuz convert rip smoke complete (codec=%s)\n", cfg.Session.Conversion.Codec) + case "qobuz-album-rip-smoke": + if len(os.Args) < 3 { + fmt.Println("usage: rip qobuz-album-rip-smoke [quality] [--force|--ignore-db]") + os.Exit(2) + } + opts, err := parseSmokeOptions(os.Args[3:], 1, 4) + if err != nil { + fmt.Fprintf(os.Stderr, "option error: %v\n", err) + os.Exit(2) + } + if opts.qualitySet { + cfg.Session.Qobuz.Quality = opts.quality + } + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + os.Exit(1) + } + defer func() { _ = mainApp.Close() }() + mainApp.IgnoreDB = opts.ignoreDB + + albumID := os.Args[2] + if err = mainApp.AddByID(ctx, "qobuz", "album", albumID); err != nil { + fmt.Fprintf(os.Stderr, "add error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Resolve(ctx); err != nil { + fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Rip(ctx); err != nil { + fmt.Fprintf(os.Stderr, "rip error: %v\n", err) + os.Exit(1) + } + + fmt.Println("qobuz album rip smoke complete") + case "qobuz-playlist-rip-smoke": + if len(os.Args) < 3 { + fmt.Println("usage: rip qobuz-playlist-rip-smoke [quality] [--force|--ignore-db]") + os.Exit(2) + } + opts, err := parseSmokeOptions(os.Args[3:], 1, 4) + if err != nil { + fmt.Fprintf(os.Stderr, "option error: %v\n", err) + os.Exit(2) + } + if opts.qualitySet { + cfg.Session.Qobuz.Quality = opts.quality + } + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + os.Exit(1) + } + defer func() { _ = mainApp.Close() }() + mainApp.IgnoreDB = opts.ignoreDB + + playlistID := os.Args[2] + if err = mainApp.AddByID(ctx, "qobuz", "playlist", playlistID); err != nil { + fmt.Fprintf(os.Stderr, "add error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Resolve(ctx); err != nil { + fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Rip(ctx); err != nil { + fmt.Fprintf(os.Stderr, "rip error: %v\n", err) + os.Exit(1) + } + fmt.Println("qobuz playlist rip smoke complete") + case "qobuz-artist-rip-smoke": + if len(os.Args) < 3 { + fmt.Println("usage: rip qobuz-artist-rip-smoke [quality] [--force|--ignore-db]") + os.Exit(2) + } + opts, err := parseSmokeOptions(os.Args[3:], 1, 4) + if err != nil { + fmt.Fprintf(os.Stderr, "option error: %v\n", err) + os.Exit(2) + } + if opts.qualitySet { + cfg.Session.Qobuz.Quality = opts.quality + } + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + os.Exit(1) + } + defer func() { _ = mainApp.Close() }() + mainApp.IgnoreDB = opts.ignoreDB + artistID := os.Args[2] + if err = mainApp.AddByID(ctx, "qobuz", "artist", artistID); err != nil { + fmt.Fprintf(os.Stderr, "add error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Resolve(ctx); err != nil { + fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Rip(ctx); err != nil { + fmt.Fprintf(os.Stderr, "rip error: %v\n", err) + os.Exit(1) + } + fmt.Println("qobuz artist rip smoke complete") + case "qobuz-label-rip-smoke": + if len(os.Args) < 3 { + fmt.Println("usage: rip qobuz-label-rip-smoke [quality] [--force|--ignore-db]") + os.Exit(2) + } + opts, err := parseSmokeOptions(os.Args[3:], 1, 4) + if err != nil { + fmt.Fprintf(os.Stderr, "option error: %v\n", err) + os.Exit(2) + } + if opts.qualitySet { + cfg.Session.Qobuz.Quality = opts.quality + } + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + os.Exit(1) + } + defer func() { _ = mainApp.Close() }() + mainApp.IgnoreDB = opts.ignoreDB + labelID := os.Args[2] + if err = mainApp.AddByID(ctx, "qobuz", "label", labelID); err != nil { + fmt.Fprintf(os.Stderr, "add error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Resolve(ctx); err != nil { + fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Rip(ctx); err != nil { + fmt.Fprintf(os.Stderr, "rip error: %v\n", err) + os.Exit(1) + } + fmt.Println("qobuz label rip smoke complete") + case "qobuz-search-smoke": + if len(os.Args) < 3 { + fmt.Println("usage: rip qobuz-search-smoke ") + os.Exit(2) + } + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + os.Exit(1) + } + defer func() { _ = mainApp.Close() }() + + provider, err := mainApp.GetLoggedInProvider(ctx, "qobuz") + if err != nil { + fmt.Fprintf(os.Stderr, "qobuz login error: %v\n", err) + os.Exit(1) + } + + query := strings.Join(os.Args[2:], " ") + pages, err := provider.Search(ctx, "album", query, 10) + if err != nil { + fmt.Fprintf(os.Stderr, "search error: %v\n", err) + os.Exit(1) + } + for _, page := range pages { + albums, ok := page["albums"].(map[string]any) + if !ok { + continue + } + items, ok := albums["items"].([]any) + if !ok { + continue + } + for _, raw := range items { + item, ok := raw.(map[string]any) + if !ok { + continue + } + id := asString(item["id"]) + title := asString(item["title"]) + version := asString(item["version"]) + bitDepth := asString(item["maximum_bit_depth"]) + sr := asString(item["maximum_sampling_rate"]) + if version != "" { + title = title + " (" + version + ")" + } + fmt.Printf("album_id=%s | %s | %sB-%skHz\n", id, title, bitDepth, sr) + } + } + case "tidal-search-smoke": + if len(os.Args) < 3 { + fmt.Println("usage: rip tidal-search-smoke ") + os.Exit(2) + } + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + os.Exit(1) + } + defer func() { _ = mainApp.Close() }() + + provider, err := mainApp.GetLoggedInProvider(ctx, "tidal") + if err != nil { + fmt.Fprintf(os.Stderr, "tidal login error: %v\n", err) + os.Exit(1) + } + + query := strings.Join(os.Args[2:], " ") + pages, err := provider.Search(ctx, "album", query, 10) + if err != nil { + fmt.Fprintf(os.Stderr, "search error: %v\n", err) + os.Exit(1) + } + for _, page := range pages { + items, ok := page["items"].([]any) + if !ok { + continue + } + for _, raw := range items { + wrapper, ok := raw.(map[string]any) + if !ok { + continue + } + item, ok := wrapper["item"].(map[string]any) + if !ok { + item = wrapper + } + fmt.Printf("album_id=%s | %s\n", asString(item["id"]), asString(item["title"])) + } + } + case "tidal-metadata-smoke": + if len(os.Args) < 4 { + fmt.Println("usage: rip tidal-metadata-smoke ") + os.Exit(2) + } + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + os.Exit(1) + } + defer func() { _ = mainApp.Close() }() + + provider, err := mainApp.GetLoggedInProvider(ctx, "tidal") + if err != nil { + fmt.Fprintf(os.Stderr, "tidal login error: %v\n", err) + os.Exit(1) + } + + mediaType := os.Args[2] + itemID := os.Args[3] + meta, err := provider.GetMetadata(ctx, itemID, mediaType) + if err != nil { + fmt.Fprintf(os.Stderr, "metadata error: %v\n", err) + os.Exit(1) + } + title := asString(meta["title"]) + if title == "" { + title = asString(meta["name"]) + } + trackCount := 0 + if tracksMap, ok := meta["tracks"].(map[string]any); ok { + if items, ok := tracksMap["items"].([]map[string]any); ok { + trackCount = len(items) + } else if anyItems, ok := tracksMap["items"].([]any); ok { + trackCount = len(anyItems) + } + } + fmt.Printf("tidal metadata ok: type=%s id=%s title=%q tracks=%d\n", mediaType, itemID, title, trackCount) + case "tidal-rip-smoke": + if len(os.Args) < 3 { + fmt.Println("usage: rip tidal-rip-smoke [quality] [--force|--ignore-db]") + os.Exit(2) + } + opts, err := parseSmokeOptions(os.Args[3:], 0, 4) + if err != nil { + fmt.Fprintf(os.Stderr, "option error: %v\n", err) + os.Exit(2) + } + if opts.qualitySet { + cfg.Session.Tidal.Quality = opts.quality + } + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + os.Exit(1) + } + defer func() { _ = mainApp.Close() }() + mainApp.IgnoreDB = opts.ignoreDB + + trackID := os.Args[2] + if err = mainApp.AddByID(ctx, "tidal", "track", trackID); err != nil { + fmt.Fprintf(os.Stderr, "add error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Resolve(ctx); err != nil { + fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Rip(ctx); err != nil { + fmt.Fprintf(os.Stderr, "rip error: %v\n", err) + os.Exit(1) + } + fmt.Println("tidal rip smoke complete") + case "tidal-album-rip-smoke": + if len(os.Args) < 3 { + fmt.Println("usage: rip tidal-album-rip-smoke [quality] [--force|--ignore-db]") + os.Exit(2) + } + opts, err := parseSmokeOptions(os.Args[3:], 0, 4) + if err != nil { + fmt.Fprintf(os.Stderr, "option error: %v\n", err) + os.Exit(2) + } + if opts.qualitySet { + cfg.Session.Tidal.Quality = opts.quality + } + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + os.Exit(1) + } + defer func() { _ = mainApp.Close() }() + mainApp.IgnoreDB = opts.ignoreDB + + albumID := os.Args[2] + if err = mainApp.AddByID(ctx, "tidal", "album", albumID); err != nil { + fmt.Fprintf(os.Stderr, "add error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Resolve(ctx); err != nil { + fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Rip(ctx); err != nil { + fmt.Fprintf(os.Stderr, "rip error: %v\n", err) + os.Exit(1) + } + fmt.Println("tidal album rip smoke complete") + case "tidal-playlist-rip-smoke": + if len(os.Args) < 3 { + fmt.Println("usage: rip tidal-playlist-rip-smoke [quality] [--force|--ignore-db]") + os.Exit(2) + } + opts, err := parseSmokeOptions(os.Args[3:], 0, 4) + if err != nil { + fmt.Fprintf(os.Stderr, "option error: %v\n", err) + os.Exit(2) + } + if opts.qualitySet { + cfg.Session.Tidal.Quality = opts.quality + } + + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + os.Exit(1) + } + defer func() { _ = mainApp.Close() }() + mainApp.IgnoreDB = opts.ignoreDB + + playlistID := os.Args[2] + if err = mainApp.AddByID(ctx, "tidal", "playlist", playlistID); err != nil { + fmt.Fprintf(os.Stderr, "add error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Resolve(ctx); err != nil { + fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Rip(ctx); err != nil { + fmt.Fprintf(os.Stderr, "rip error: %v\n", err) + os.Exit(1) + } + fmt.Println("tidal playlist rip smoke complete") + case "tidal-artist-rip-smoke": + if len(os.Args) < 3 { + fmt.Println("usage: rip tidal-artist-rip-smoke [quality] [--force|--ignore-db]") + os.Exit(2) + } + opts, err := parseSmokeOptions(os.Args[3:], 0, 4) + if err != nil { + fmt.Fprintf(os.Stderr, "option error: %v\n", err) + os.Exit(2) + } + if opts.qualitySet { + cfg.Session.Tidal.Quality = opts.quality + } + mainApp, err := app.New(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "app init error: %v\n", err) + os.Exit(1) + } + defer func() { _ = mainApp.Close() }() + mainApp.IgnoreDB = opts.ignoreDB + artistID := os.Args[2] + if err = mainApp.AddByID(ctx, "tidal", "artist", artistID); err != nil { + fmt.Fprintf(os.Stderr, "add error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Resolve(ctx); err != nil { + fmt.Fprintf(os.Stderr, "resolve error: %v\n", err) + os.Exit(1) + } + if err = mainApp.Rip(ctx); err != nil { + fmt.Fprintf(os.Stderr, "rip error: %v\n", err) + os.Exit(1) + } + fmt.Println("tidal artist rip smoke complete") + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1]) + os.Exit(2) + } +} + +type smokeOptions struct { + qualitySet bool + quality int + ignoreDB bool +} + +func parseSmokeOptions(args []string, minQuality int, maxQuality int) (smokeOptions, error) { + opts := smokeOptions{} + for _, arg := range args { + switch arg { + case "--force", "--ignore-db": + opts.ignoreDB = true + default: + q, err := parseQuality(arg, minQuality, maxQuality) + if err != nil { + return smokeOptions{}, fmt.Errorf("unknown option %q", arg) + } + opts.quality = q + opts.qualitySet = true + } + } + return opts, nil +} + +func parseQuality(raw string, min int, max int) (int, error) { + q, err := strconv.Atoi(raw) + if err != nil { + return 0, err + } + if q < min || q > max { + return 0, fmt.Errorf("quality must be %d-%d, got %d", min, max, q) + } + return q, nil +} + +func asString(v any) string { + switch t := v.(type) { + case string: + return t + case int: + return strconv.Itoa(t) + case int64: + return strconv.FormatInt(t, 10) + case float64: + return strconv.FormatFloat(t, 'f', -1, 64) + default: + return "" + } +} + +type fileIDItem struct { + Source string + MediaType string + ID string +} + +type failedRow struct { + Source string + MediaType string + ID string +} + +type lastFMOptions struct { + Source string + FallbackSource string + PlaylistURL string +} + +type lastFMTrack struct { + Title string + Artist string +} + +var ( + lastFMTitleTagsRe = regexp.MustCompile(`([^<]+)`) + errLastFMInvalidSource = "unsupported source" +) + +func addURLToQueue(ctx context.Context, mainApp *app.Main, raw string) bool { + parsed := urlparse.Parse(raw) + if parsed == nil { + fmt.Printf("invalid: %s\n", raw) + return false + } + if parsed.Kind != urlparse.KindGeneric { + fmt.Printf("not yet supported: %s (kind=%s)\n", raw, parsed.Kind) + return false + } + if parsed.Source != "qobuz" && parsed.Source != "tidal" { + fmt.Printf("provider not yet implemented: source=%s url=%s\n", parsed.Source, raw) + return false + } + if err := mainApp.AddByID(ctx, parsed.Source, parsed.MediaType, parsed.ID); err != nil { + fmt.Printf("add failed: source=%s type=%s id=%s err=%v\n", parsed.Source, parsed.MediaType, parsed.ID, err) + return false + } + return true +} + +func parseFileInput(content []byte) ([]fileIDItem, []string, int, bool, error) { + trimmed := strings.TrimSpace(string(content)) + if trimmed == "" { + return nil, nil, 0, false, nil + } + + var parsed any + if err := json.Unmarshal([]byte(trimmed), &parsed); err == nil { + arr, ok := parsed.([]any) + if !ok { + return nil, nil, 0, true, fmt.Errorf("json input must be an array of objects") + } + items := make([]fileIDItem, 0, len(arr)) + for i, raw := range arr { + entry, ok := raw.(map[string]any) + if !ok { + return nil, nil, 0, true, fmt.Errorf("json item %d must be an object", i+1) + } + source := strings.ToLower(strings.TrimSpace(asString(entry["source"]))) + mediaType := strings.ToLower(strings.TrimSpace(asString(entry["media_type"]))) + if mediaType == "" { + mediaType = strings.ToLower(strings.TrimSpace(asString(entry["mediaType"]))) + } + id := strings.TrimSpace(asString(entry["id"])) + if source == "" || mediaType == "" || id == "" { + return nil, nil, 0, true, fmt.Errorf("json item %d missing source/media_type/id", i+1) + } + items = append(items, fileIDItem{Source: source, MediaType: mediaType, ID: id}) + } + return items, nil, 0, true, nil + } + + parts := strings.Fields(trimmed) + if len(parts) == 0 { + return nil, nil, 0, false, nil + } + seen := make(map[string]struct{}, len(parts)) + urls := make([]string, 0, len(parts)) + repeated := 0 + for _, raw := range parts { + if _, ok := seen[raw]; ok { + repeated++ + continue + } + seen[raw] = struct{}{} + urls = append(urls, raw) + } + return nil, urls, repeated, false, nil +} + +func promptYesNo(prompt string) (bool, error) { + reader := bufio.NewReader(os.Stdin) + fmt.Print(prompt) + line, err := reader.ReadString('\n') + if err != nil { + return false, err + } + line = strings.ToLower(strings.TrimSpace(line)) + return line == "y" || line == "yes", nil +} + +func openConfigInEditor(path string, vim bool) error { + launch := func(name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + } + + if vim { + if p, err := exec.LookPath("nvim"); err == nil { + return launch(p, path) + } + if p, err := exec.LookPath("vim"); err == nil { + return launch(p, path) + } + } + + if editor := strings.TrimSpace(os.Getenv("EDITOR")); editor != "" { + parts := strings.Fields(editor) + if len(parts) > 0 { + return launch(parts[0], append(parts[1:], path)...) + } + } + + switch runtime.GOOS { + case "darwin": + return launch("open", path) + case "windows": + return launch("cmd", "/c", "start", "", path) + default: + if p, err := exec.LookPath("xdg-open"); err == nil { + return launch(p, path) + } + return fmt.Errorf("could not find an editor (set $EDITOR or install xdg-open)") + } +} + +func listDownloadsRows(path string) ([]string, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + defer func() { _ = db.Close() }() + + rows, err := db.Query(`SELECT id FROM downloads ORDER BY rowid`) + if err != nil { + if isNoSuchTableErr(err) { + return []string{}, nil + } + return nil, err + } + defer func() { _ = rows.Close() }() + + out := []string{} + for rows.Next() { + var id string + if err = rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} + +func listFailedRows(path string) ([]failedRow, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + defer func() { _ = db.Close() }() + + rows, err := db.Query(`SELECT source, media_type, id FROM failed_downloads ORDER BY rowid`) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + out := []failedRow{} + for rows.Next() { + var r failedRow + if err = rows.Scan(&r.Source, &r.MediaType, &r.ID); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +func isNoSuchTableErr(err error) bool { + if err == nil { + return false + } + return strings.Contains(strings.ToLower(err.Error()), "no such table") +} + +func parseLastFMArgs(args []string, defaultSource, defaultFallback string) (lastFMOptions, error) { + opts := lastFMOptions{Source: strings.ToLower(strings.TrimSpace(defaultSource)), FallbackSource: strings.ToLower(strings.TrimSpace(defaultFallback))} + for i := 0; i < len(args); i++ { + switch args[i] { + case "-s", "--source": + if i+1 >= len(args) { + return lastFMOptions{}, fmt.Errorf("--source requires a value") + } + opts.Source = strings.ToLower(strings.TrimSpace(args[i+1])) + i++ + case "-fs", "--fallback-source": + if i+1 >= len(args) { + return lastFMOptions{}, fmt.Errorf("--fallback-source requires a value") + } + opts.FallbackSource = strings.ToLower(strings.TrimSpace(args[i+1])) + i++ + default: + if strings.HasPrefix(args[i], "-") { + return lastFMOptions{}, fmt.Errorf("unknown option %q", args[i]) + } + if opts.PlaylistURL != "" { + return lastFMOptions{}, fmt.Errorf("unexpected extra argument %q", args[i]) + } + opts.PlaylistURL = strings.TrimSpace(args[i]) + } + } + if opts.Source == "" { + opts.Source = "qobuz" + } + if opts.PlaylistURL == "" { + return lastFMOptions{}, fmt.Errorf("missing playlist url") + } + if !isAllowedSearchSource(opts.Source) { + return lastFMOptions{}, fmt.Errorf("%s %q", errLastFMInvalidSource, opts.Source) + } + if opts.FallbackSource != "" && !isAllowedSearchSource(opts.FallbackSource) { + return lastFMOptions{}, fmt.Errorf("%s %q", errLastFMInvalidSource, opts.FallbackSource) + } + return opts, nil +} + +func fetchLastFMPlaylist(ctx context.Context, verifySSL bool, playlistURL string) (string, []lastFMTrack, error) { + parsed, err := url.Parse(playlistURL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "", nil, fmt.Errorf("invalid playlist url") + } + client := netutil.NewHTTPClient(30*time.Second, verifySSL) + + page1, err := fetchLastFMPlaylistPage(ctx, client, parsed, 1) + if err != nil { + return "", nil, err + } + title, total, err := extractLastFMPlaylistInfo(page1) + if err != nil { + return "", nil, err + } + tracks := extractLastFMTitleArtistPairs(page1) + if total <= len(tracks) || total <= 50 { + if len(tracks) > total && total > 0 { + tracks = tracks[:total] + } + return title, tracks, nil + } + + remaining := total - 50 + lastPage := 1 + remaining/50 + if remaining%50 != 0 { + lastPage++ + } + for page := 2; page <= lastPage; page++ { + body, fetchErr := fetchLastFMPlaylistPage(ctx, client, parsed, page) + if fetchErr != nil { + return "", nil, fetchErr + } + tracks = append(tracks, extractLastFMTitleArtistPairs(body)...) + } + if len(tracks) > total { + tracks = tracks[:total] + } + return title, tracks, nil +} + +func fetchLastFMPlaylistPage(ctx context.Context, client *http.Client, parsed *url.URL, page int) (string, error) { + u := *parsed + if page > 1 { + q := u.Query() + q.Set("page", strconv.Itoa(page)) + u.RawQuery = q.Encode() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "streamrip-go/0") + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("lastfm request failed: status %d", resp.StatusCode) + } + b, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(b), nil +} + +func extractLastFMPlaylistInfo(page string) (string, int, error) { + titleMatch := lastFMPlaylistTitleRe.FindStringSubmatch(page) + if len(titleMatch) < 2 { + return "", 0, fmt.Errorf("could not parse playlist title") + } + totalMatch := lastFMTotalTracksRe.FindStringSubmatch(page) + if len(totalMatch) < 2 { + return "", 0, fmt.Errorf("could not parse total track count") + } + total, err := strconv.Atoi(totalMatch[1]) + if err != nil { + return "", 0, fmt.Errorf("invalid total track count") + } + return html.UnescapeString(strings.TrimSpace(titleMatch[1])), total, nil +} + +func extractLastFMTitleArtistPairs(page string) []lastFMTrack { + titles := lastFMTitleTagsRe.FindAllStringSubmatch(page, -1) + out := make([]lastFMTrack, 0, len(titles)/2) + for i := 0; i+1 < len(titles); i += 2 { + title := html.UnescapeString(strings.TrimSpace(titles[i][1])) + artist := html.UnescapeString(strings.TrimSpace(titles[i+1][1])) + if title == "" || artist == "" { + continue + } + out = append(out, lastFMTrack{Title: title, Artist: artist}) + } + return out +} + +func queueLastFMTracks(ctx context.Context, mainApp *app.Main, opts lastFMOptions, tracks []lastFMTrack) error { + primary, err := mainApp.GetLoggedInProvider(ctx, opts.Source) + if err != nil { + return fmt.Errorf("%s login error: %w", opts.Source, err) + } + var fallback provider.Client + if opts.FallbackSource != "" && opts.FallbackSource != opts.Source { + fallback, err = mainApp.GetLoggedInProvider(ctx, opts.FallbackSource) + if err != nil { + return fmt.Errorf("%s login error: %w", opts.FallbackSource, err) + } + } + + found := 0 + failed := 0 + for i, tr := range tracks { + query := strings.TrimSpace(tr.Title + " " + tr.Artist) + id, source, searchErr := searchLastFMTrack(ctx, opts, primary, fallback, query) + if searchErr != nil { + failed++ + fmt.Printf("[%d/%d] search failed: %s (%v)\n", i+1, len(tracks), query, searchErr) + continue + } + if id == "" { + failed++ + fmt.Printf("[%d/%d] no result: %s\n", i+1, len(tracks), query) + continue + } + if err = mainApp.AddByID(ctx, source, "track", id); err != nil { + failed++ + fmt.Printf("[%d/%d] add failed: %s (%v)\n", i+1, len(tracks), query, err) + continue + } + found++ + fmt.Printf("[%d/%d] found: %s (%s)\n", i+1, len(tracks), query, source) + } + fmt.Printf("lastfm resolve complete: %d found, %d failed\n", found, failed) + return nil +} + +func searchLastFMTrack(ctx context.Context, opts lastFMOptions, primary provider.Client, fallback provider.Client, query string) (string, string, error) { + pages, err := primary.Search(ctx, "track", query, 1) + if err == nil { + results := normalizeSearchResults(opts.Source, "track", pages) + if len(results) > 0 { + return results[0].ID, opts.Source, nil + } + } + if fallback != nil { + pages, fbErr := fallback.Search(ctx, "track", query, 1) + if fbErr != nil { + if err != nil { + return "", "", fmt.Errorf("primary=%v fallback=%v", err, fbErr) + } + return "", "", fbErr + } + results := normalizeSearchResults(opts.FallbackSource, "track", pages) + if len(results) > 0 { + return results[0].ID, opts.FallbackSource, nil + } + } + if err != nil { + return "", "", err + } + return "", "", nil +} + +type searchResult struct { + ID string + Title string + Artist string + Album string + TrackCount int + Explicit bool +} + +type searchOptions struct { + query string + limit int + ignoreDB bool + noDownload bool + first bool + outputFile string +} + +func parseSearchArgs(args []string, defaultLimit int) (searchOptions, error) { + if defaultLimit <= 0 { + defaultLimit = 20 + } + limit := defaultLimit + parts := make([]string, 0, len(args)) + ignoreDB := false + noDownload := false + first := false + outputFile := "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--force", "--ignore-db": + ignoreDB = true + continue + case "--no-download": + noDownload = true + continue + case "--first": + first = true + continue + case "--output-file": + if i+1 >= len(args) { + return searchOptions{}, fmt.Errorf("--output-file requires a path") + } + outputFile = strings.TrimSpace(args[i+1]) + i++ + continue + case "--num-results": + if i+1 >= len(args) { + return searchOptions{}, fmt.Errorf("--num-results requires a value") + } + v, err := strconv.Atoi(args[i+1]) + if err != nil || v <= 0 { + return searchOptions{}, fmt.Errorf("invalid --num-results value %q", args[i+1]) + } + limit = v + i++ + continue + } + if args[i] == "--limit" { + if i+1 >= len(args) { + return searchOptions{}, fmt.Errorf("--limit requires a value") + } + v, err := strconv.Atoi(args[i+1]) + if err != nil || v <= 0 { + return searchOptions{}, fmt.Errorf("invalid --limit value %q", args[i+1]) + } + limit = v + i++ + continue + } + parts = append(parts, args[i]) + } + return searchOptions{ + query: strings.TrimSpace(strings.Join(parts, " ")), + limit: limit, + ignoreDB: ignoreDB, + noDownload: noDownload, + first: first, + outputFile: outputFile, + }, nil +} + +func promptSearchSelection(results []searchResult) ([]int, error) { + reader := bufio.NewReader(os.Stdin) + for { + fmt.Print("Select results to download (e.g. 1,3-5; a=all; q=cancel): ") + line, err := reader.ReadString('\n') + if err != nil { + return nil, err + } + line = strings.TrimSpace(line) + if line == "" || strings.EqualFold(line, "q") || strings.EqualFold(line, "quit") { + return nil, nil + } + if strings.EqualFold(line, "a") || strings.EqualFold(line, "all") { + out := make([]int, 0, len(results)) + for i := range results { + out = append(out, i) + } + return out, nil + } + + selected := map[int]struct{}{} + chunks := strings.Split(line, ",") + ok := true + for _, raw := range chunks { + part := strings.TrimSpace(raw) + if part == "" { + continue + } + if strings.Contains(part, "-") { + bounds := strings.SplitN(part, "-", 2) + if len(bounds) != 2 { + ok = false + break + } + start, err1 := strconv.Atoi(strings.TrimSpace(bounds[0])) + end, err2 := strconv.Atoi(strings.TrimSpace(bounds[1])) + if err1 != nil || err2 != nil || start <= 0 || end <= 0 || start > end { + ok = false + break + } + for i := start; i <= end; i++ { + if i > len(results) { + ok = false + break + } + selected[i-1] = struct{}{} + } + if !ok { + break + } + continue + } + idx, err := strconv.Atoi(part) + if err != nil || idx <= 0 || idx > len(results) { + ok = false + break + } + selected[idx-1] = struct{}{} + } + + if !ok || len(selected) == 0 { + fmt.Println("Invalid selection, try again.") + continue + } + + out := make([]int, 0, len(selected)) + for idx := range selected { + out = append(out, idx) + } + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j] < out[j-1]; j-- { + out[j], out[j-1] = out[j-1], out[j] + } + } + return out, nil + } +} + +func promptSearchSelectionMenu(source, mediaType, query string, results []searchResult) ([]int, error) { + if len(results) == 0 { + return nil, nil + } + + labels := make([]string, 0, len(results)) + labelToIndex := map[string]int{} + for i, r := range results { + label := fmt.Sprintf("%2d. %s", i+1, r.Title) + labels = append(labels, label) + labelToIndex[label] = i + } + + selected := []string{} + prompt := &survey.MultiSelect{ + Message: fmt.Sprintf("Results for %s '%s' from %s", mediaType, query, strings.Title(source)), + Help: "SPACE: select ENTER: download /: filter ESC: cancel", + Options: labels, + Description: func(value string, index int) string { + if index < 0 || index >= len(results) { + return "" + } + return formatSearchDetails(results[index]) + }, + PageSize: 15, + } + if err := survey.AskOne(prompt, &selected); err != nil { + if strings.Contains(strings.ToLower(err.Error()), "interrupt") { + return nil, nil + } + return nil, err + } + + if len(selected) == 0 { + return nil, nil + } + out := make([]int, 0, len(selected)) + for _, label := range selected { + if idx, ok := labelToIndex[label]; ok { + out = append(out, idx) + } + } + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j] < out[j-1]; j-- { + out[j], out[j-1] = out[j-1], out[j] + } + } + return out, nil +} + +func writeSearchResultsToFile(source, mediaType string, results []searchResult, path string) error { + type outItem struct { + Source string `json:"source"` + MediaType string `json:"media_type"` + ID string `json:"id"` + Title string `json:"title"` + } + out := make([]outItem, 0, len(results)) + for _, r := range results { + out = append(out, outItem{Source: source, MediaType: mediaType, ID: r.ID, Title: r.Title}) + } + b, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, b, 0o644) +} + +func isAllowedSearchSource(source string) bool { + return source == "qobuz" || source == "tidal" +} + +func isAllowedMediaType(mediaType string) bool { + switch mediaType { + case "track", "album", "playlist", "artist", "label": + return true + default: + return false + } +} + +func promptSearchInteractive(defaultLimit int) (string, string, searchOptions, error) { + reader := bufio.NewReader(os.Stdin) + + read := func(prompt string) (string, error) { + fmt.Print(prompt) + line, err := reader.ReadString('\n') + if err != nil { + return "", err + } + return strings.TrimSpace(line), nil + } + + for { + source, err := read("Source [qobuz/tidal]: ") + if err != nil { + return "", "", searchOptions{}, err + } + source = strings.ToLower(source) + if !isAllowedSearchSource(source) { + fmt.Println("Invalid source.") + continue + } + + mediaType, err := read("Type [track/album/playlist/artist/label]: ") + if err != nil { + return "", "", searchOptions{}, err + } + mediaType = strings.ToLower(mediaType) + if !isAllowedMediaType(mediaType) { + fmt.Println("Invalid media type.") + continue + } + + query, err := read("Query: ") + if err != nil { + return "", "", searchOptions{}, err + } + if strings.TrimSpace(query) == "" { + fmt.Println("Query cannot be empty.") + continue + } + + limitRaw, err := read(fmt.Sprintf("Limit [%d]: ", defaultLimit)) + if err != nil { + return "", "", searchOptions{}, err + } + limit := defaultLimit + if strings.TrimSpace(limitRaw) != "" { + v, convErr := strconv.Atoi(limitRaw) + if convErr != nil || v <= 0 { + fmt.Println("Invalid limit.") + continue + } + limit = v + } + + return source, mediaType, searchOptions{query: query, limit: limit}, nil + } +} + +func normalizeSearchResults(source, mediaType string, pages []map[string]any) []searchResult { + results := make([]searchResult, 0) + for _, page := range pages { + switch source { + case "qobuz": + key := mediaType + "s" + bucket, ok := page[key].(map[string]any) + if !ok { + continue + } + items, ok := bucket["items"].([]any) + if !ok { + continue + } + for _, raw := range items { + itm, ok := raw.(map[string]any) + if !ok { + continue + } + id := asString(itm["id"]) + title := asString(itm["title"]) + if title == "" { + title = asString(itm["name"]) + } + if version := asString(itm["version"]); version != "" { + title += " (" + version + ")" + } + artist := nestedSearchString(itm, "artist", "name") + if artist == "" { + artist = nestedSearchString(itm, "performer", "name") + } + album := nestedSearchString(itm, "album", "title") + trackCount := searchInt(itm["tracks_count"]) + if trackCount == 0 { + trackCount = searchInt(itm["track_count"]) + } + explicit := searchBool(itm["parental_warning"]) + if id != "" && title != "" { + results = append(results, searchResult{ID: id, Title: title, Artist: artist, Album: album, TrackCount: trackCount, Explicit: explicit}) + } + } + case "tidal": + items, ok := page["items"].([]any) + if !ok { + continue + } + for _, raw := range items { + itm, ok := raw.(map[string]any) + if !ok { + continue + } + if wrapped, ok := itm["item"].(map[string]any); ok { + itm = wrapped + } + id := asString(itm["id"]) + title := asString(itm["title"]) + if title == "" { + title = asString(itm["name"]) + } + artist := nestedSearchString(itm, "artist", "name") + if artist == "" { + if artists, ok := itm["artists"].([]any); ok && len(artists) > 0 { + if a0, ok := artists[0].(map[string]any); ok { + artist = asString(a0["name"]) + } + } + } + album := nestedSearchString(itm, "album", "title") + trackCount := searchInt(itm["numberOfTracks"]) + if trackCount == 0 { + trackCount = searchInt(itm["tracks_count"]) + } + explicit := searchBool(itm["explicit"]) + if id != "" && title != "" { + results = append(results, searchResult{ID: id, Title: title, Artist: artist, Album: album, TrackCount: trackCount, Explicit: explicit}) + } + } + } + } + return results +} + +func formatSearchDetails(r searchResult) string { + lines := []string{fmt.Sprintf("ID: %s", r.ID), fmt.Sprintf("Title: %s", r.Title)} + if strings.TrimSpace(r.Artist) != "" { + lines = append(lines, fmt.Sprintf("Artist: %s", r.Artist)) + } + if strings.TrimSpace(r.Album) != "" { + lines = append(lines, fmt.Sprintf("Album: %s", r.Album)) + } + if r.TrackCount > 0 { + lines = append(lines, fmt.Sprintf("Tracks: %d", r.TrackCount)) + } + if r.Explicit { + lines = append(lines, "Explicit: yes") + } + return strings.Join(lines, "\n") +} + +func nestedSearchString(v map[string]any, keys ...string) string { + cur := any(v) + for _, key := range keys { + m, ok := cur.(map[string]any) + if !ok { + return "" + } + cur = m[key] + } + return asString(cur) +} + +func searchInt(v any) int { + switch t := v.(type) { + case int: + return t + case int64: + return int(t) + case float64: + return int(t) + case string: + i, _ := strconv.Atoi(t) + return i + default: + return 0 + } +} + +func searchBool(v any) bool { + b, ok := v.(bool) + return ok && b +} diff --git a/cmd/rip/main_test.go b/cmd/rip/main_test.go new file mode 100644 index 0000000..78a47d5 --- /dev/null +++ b/cmd/rip/main_test.go @@ -0,0 +1,117 @@ +package main + +import "testing" + +func TestParseFileInputJSONItems(t *testing.T) { + content := []byte(`[ + {"source":"qobuz","media_type":"album","id":"0066991040005"}, + {"source":"tidal","media_type":"track","id":3083287} + ]`) + + items, urls, repeated, jsonInput, err := parseFileInput(content) + if err != nil { + t.Fatalf("parseFileInput() error = %v", err) + } + if !jsonInput { + t.Fatalf("jsonInput = false, want true") + } + if len(urls) != 0 { + t.Fatalf("urls len = %d, want 0", len(urls)) + } + if repeated != 0 { + t.Fatalf("repeated = %d, want 0", repeated) + } + if len(items) != 2 { + t.Fatalf("items len = %d, want 2", len(items)) + } + if items[0].Source != "qobuz" || items[0].MediaType != "album" || items[0].ID != "0066991040005" { + t.Fatalf("unexpected first item: %+v", items[0]) + } + if items[1].Source != "tidal" || items[1].MediaType != "track" || items[1].ID != "3083287" { + t.Fatalf("unexpected second item: %+v", items[1]) + } +} + +func TestParseFileInputTextURLsDedupes(t *testing.T) { + content := []byte("https://tidal.com/browse/track/3083287\nhttps://tidal.com/browse/track/3083287\nhttps://www.qobuz.com/fr-fr/album/example/0066991040005\n") + + items, urls, repeated, jsonInput, err := parseFileInput(content) + if err != nil { + t.Fatalf("parseFileInput() error = %v", err) + } + if jsonInput { + t.Fatalf("jsonInput = true, want false") + } + if len(items) != 0 { + t.Fatalf("items len = %d, want 0", len(items)) + } + if repeated != 1 { + t.Fatalf("repeated = %d, want 1", repeated) + } + if len(urls) != 2 { + t.Fatalf("urls len = %d, want 2", len(urls)) + } +} + +func TestParseFileInputRejectsInvalidJSONShape(t *testing.T) { + content := []byte(`{"source":"qobuz","media_type":"track","id":"1"}`) + + _, _, _, jsonInput, err := parseFileInput(content) + if err == nil { + t.Fatalf("expected error for non-array json") + } + if !jsonInput { + t.Fatalf("jsonInput = false, want true") + } +} + +func TestParseLastFMArgsDefaults(t *testing.T) { + opts, err := parseLastFMArgs([]string{"https://www.last.fm/user/x/playlists/123"}, "qobuz", "") + if err != nil { + t.Fatalf("parseLastFMArgs() error = %v", err) + } + if opts.Source != "qobuz" { + t.Fatalf("source = %q, want qobuz", opts.Source) + } + if opts.PlaylistURL == "" { + t.Fatalf("playlist url should not be empty") + } +} + +func TestParseLastFMArgsOptions(t *testing.T) { + opts, err := parseLastFMArgs([]string{"--source", "tidal", "--fallback-source", "qobuz", "https://www.last.fm/user/x/playlists/123"}, "qobuz", "") + if err != nil { + t.Fatalf("parseLastFMArgs() error = %v", err) + } + if opts.Source != "tidal" || opts.FallbackSource != "qobuz" { + t.Fatalf("unexpected options: %+v", opts) + } +} + +func TestExtractLastFMPlaylistInfoAndPairs(t *testing.T) { + html := `

Road & Rain

+
+ + + +` + + title, total, err := extractLastFMPlaylistInfo(html) + if err != nil { + t.Fatalf("extractLastFMPlaylistInfo() error = %v", err) + } + if title != "Road & Rain" { + t.Fatalf("title = %q, want %q", title, "Road & Rain") + } + if total != 2 { + t.Fatalf("total = %d, want 2", total) + } + + pairs := extractLastFMTitleArtistPairs(html) + if len(pairs) != 2 { + t.Fatalf("pairs len = %d, want 2", len(pairs)) + } + if pairs[0].Title != "Dreams" || pairs[0].Artist != "Fleetwood Mac" { + t.Fatalf("unexpected first pair: %+v", pairs[0]) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..693649b --- /dev/null +++ b/go.mod @@ -0,0 +1,33 @@ +module streamrip-go + +go 1.25.0 + +require ( + github.com/AlecAivazis/survey/v2 v2.3.7 + github.com/pelletier/go-toml/v2 v2.2.3 + github.com/vbauerster/mpb/v8 v8.12.0 + golang.org/x/image v0.39.0 + golang.org/x/term v0.42.0 + modernc.org/sqlite v1.39.1 +) + +require ( + github.com/VividCortex/ewma v1.2.0 // indirect + github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/mattn/go-colorable v0.1.2 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.20 // indirect + github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..21e734e --- /dev/null +++ b/go.sum @@ -0,0 +1,120 @@ +github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= +github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= +github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= +github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= +github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= +github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= +github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/vbauerster/mpb/v8 v8.12.0 h1:+gneY3ifzc88tKDzOtfG8k8gfngCx615S2ZmFM4liWg= +github.com/vbauerster/mpb/v8 v8.12.0/go.mod h1:V02YIuMVo301Y1VE9VtZlD8s84OMsk+EKN6mwvf/588= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= +golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= +modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4= +modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..c25c3e6 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,1016 @@ +package app + +import ( + "context" + "fmt" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "sync" + + "streamrip-go/internal/artwork" + "streamrip-go/internal/audio/convert" + "streamrip-go/internal/audio/tag" + "streamrip-go/internal/config" + "streamrip-go/internal/domain/media" + "streamrip-go/internal/download" + "streamrip-go/internal/naming" + "streamrip-go/internal/provider" + qobuzprovider "streamrip-go/internal/provider/qobuz" + tidalprovider "streamrip-go/internal/provider/tidal" + "streamrip-go/internal/store" +) + +type Main struct { + Config *config.Config + Providers map[string]provider.Client + Store store.Database + DL *download.Downloader + Tagger trackTagger + IgnoreDB bool + Pending []media.Pending + Media []media.Media +} + +type ripTrackOptions struct { + albumFolder string + albumEmbedCover string + index int + total int + albumDiscTotal int + forPlaylist bool + playlistName string + playlistPos int +} + +type collectionAlbum struct { + ID string + Meta map[string]any + Title string + AlbumArtist string + BitDepth int + Sampling float64 + Explicit bool + TrackCount int +} + +var ( + qobuzEssenceRe = regexp.MustCompile(`(?i)^([^\(\[]+)`) + qobuzExtraRe = regexp.MustCompile(`(?i)(anniversary|deluxe|live|collector|demo|expanded|remix)`) + qobuzRemasterRe = regexp.MustCompile(`(?i)(re)?master(ed)?`) +) + +type trackTagger interface { + TagFLAC(path string, meta tag.Metadata, coverPath string) error +} + +func New(cfg *config.Config) (*Main, error) { + var db store.Database + if cfg.Session.Database.DownloadsEnabled || cfg.Session.Database.FailedDownloadsEnabled { + s, err := store.NewSQLite(cfg.Session.Database.DownloadsPath) + if err != nil { + return nil, err + } + db = s + } else { + db = store.NewDummy() + } + + providers := map[string]provider.Client{ + "qobuz": qobuzprovider.New(cfg), + "tidal": tidalprovider.New(cfg), + } + + return &Main{ + Config: cfg, + Providers: providers, + Store: db, + DL: download.NewWithOptions(cfg.Session.Downloads.VerifySSL, cfg.Session.CLI.ProgressBars), + Tagger: tag.New(), + Pending: []media.Pending{}, + Media: []media.Media{}, + }, nil +} + +func (m *Main) Close() error { + m.DL.Close() + artwork.CleanupTempDirs() + for _, p := range m.Providers { + _ = p.Close() + } + return m.Store.Close() +} + +func (m *Main) logf(format string, args ...any) { + if m != nil && m.DL != nil { + m.DL.Logf(format, args...) + return + } + fmt.Printf(format, args...) +} + +func (m *Main) GetLoggedInProvider(ctx context.Context, source string) (provider.Client, error) { + p, ok := m.Providers[source] + if !ok { + return nil, fmt.Errorf("provider %q not registered", source) + } + if !p.LoggedIn() { + if err := p.Login(ctx); err != nil { + return nil, err + } + } + return p, nil +} + +func (m *Main) AddByID(ctx context.Context, source, mediaType, id string) error { + p, err := m.GetLoggedInProvider(ctx, source) + if err != nil { + return err + } + + pending := media.PendingFunc{ + ResolveFn: func(ctx context.Context) (media.Media, error) { + meta, err := p.GetMetadata(ctx, id, mediaType) + if err != nil { + _ = m.Store.MarkFailed(ctx, source, mediaType, id) + return nil, err + } + + displayTitle := titleFromMetadata(meta, id) + + return media.MediaFunc{RipFn: func(ctx context.Context) error { + if m.Config.Session.CLI.TextOutput { + m.logf("Downloading %s: %s\n", mediaType, displayTitle) + } + switch mediaType { + case "track": + title := titleFromMetadata(meta, id) + return m.ripTrack(ctx, p, source, id, title, ripTrackOptions{}) + case "album": + return m.ripAlbum(ctx, p, source, id, meta) + case "playlist": + return m.ripPlaylist(ctx, p, source, id, meta) + case "artist": + return m.ripCollection(ctx, p, source, "Artist", id, meta) + case "label": + return m.ripCollection(ctx, p, source, "Label", id, meta) + default: + return nil + } + }}, nil + }, + } + + m.Pending = append(m.Pending, pending) + return nil +} + +func (m *Main) ripCollection(ctx context.Context, p provider.Client, source, kind, id string, meta map[string]any) error { + name := titleFromMetadata(meta, id) + if n := stringFromAny(meta["name"]); n != "" { + name = n + } + + albumIDs := extractAlbumIDs(meta) + m.logf("%s: %s (%d albums)\n", kind, name, len(albumIDs)) + failures := 0 + albums := make([]collectionAlbum, 0, len(albumIDs)) + for i, albumID := range albumIDs { + albumMeta, err := p.GetMetadata(ctx, albumID, "album") + if err != nil { + failures++ + m.logf("album failed [%d/%d]: id=%s reason=%v\n", i+1, len(albumIDs), albumID, err) + continue + } + albums = append(albums, buildCollectionAlbum(albumID, albumMeta)) + } + + if source == "qobuz" && kind == "Artist" { + before := len(albums) + albums = applyQobuzArtistFilters(name, albums, m.Config.Session.QobuzFilters) + m.logf("Artist filters applied: %d -> %d albums\n", before, len(albums)) + } + + for i, album := range albums { + if err := m.ripAlbum(ctx, p, source, album.ID, album.Meta); err != nil { + failures++ + m.logf("album failed [%d/%d]: id=%s reason=%v\n", i+1, len(albums), album.ID, err) + } + } + if failures > 0 { + m.logf("%s done with %d failed album(s)\n", kind, failures) + } + return nil +} + +func buildCollectionAlbum(id string, meta map[string]any) collectionAlbum { + trackCount := intFromAny(meta["tracks_count"]) + if trackCount == 0 { + trackCount = intFromAny(meta["numberOfTracks"]) + } + return collectionAlbum{ + ID: id, + Meta: meta, + Title: titleFromMetadata(meta, id), + AlbumArtist: nestedString(meta, "artist", "name"), + BitDepth: intFromAny(meta["maximum_bit_depth"]), + Sampling: floatFromAny(meta["maximum_sampling_rate"]), + Explicit: boolFromAny(meta["parental_warning"]), + TrackCount: trackCount, + } +} + +func applyQobuzArtistFilters(artistName string, albums []collectionAlbum, filt config.QobuzDiscographyFilterConfig) []collectionAlbum { + out := append([]collectionAlbum(nil), albums...) + + if filt.Repeats { + out = filterQobuzRepeats(out) + } + if filt.Extras { + tmp := out[:0] + for _, a := range out { + if !qobuzExtraRe.MatchString(a.Title) { + tmp = append(tmp, a) + } + } + out = tmp + } + if filt.Features { + tmp := out[:0] + for _, a := range out { + if a.AlbumArtist == artistName { + tmp = append(tmp, a) + } + } + out = tmp + } + if filt.NonStudioAlbums { + tmp := out[:0] + for _, a := range out { + if a.AlbumArtist != "Various Artists" && !qobuzExtraRe.MatchString(a.Title) { + tmp = append(tmp, a) + } + } + out = tmp + } + if filt.NonRemaster { + tmp := out[:0] + for _, a := range out { + if qobuzRemasterRe.MatchString(a.Title) { + tmp = append(tmp, a) + } + } + out = tmp + } + if filt.NonAlbums { + tmp := out[:0] + for _, a := range out { + if a.TrackCount > 1 { + tmp = append(tmp, a) + } + } + out = tmp + } + + return out +} + +func filterQobuzRepeats(albums []collectionAlbum) []collectionAlbum { + groups := map[string][]collectionAlbum{} + for _, a := range albums { + title := strings.TrimSpace(strings.ToLower(a.Title)) + if m := qobuzEssenceRe.FindStringSubmatch(title); len(m) >= 2 { + title = strings.TrimSpace(m[1]) + } + groups[title] = append(groups[title], a) + } + + out := make([]collectionAlbum, 0, len(groups)) + for _, g := range groups { + sort.SliceStable(g, func(i, j int) bool { + if g[i].BitDepth != g[j].BitDepth { + return g[i].BitDepth > g[j].BitDepth + } + if g[i].Sampling != g[j].Sampling { + return g[i].Sampling > g[j].Sampling + } + if g[i].Explicit != g[j].Explicit { + return g[i].Explicit + } + return g[i].Title < g[j].Title + }) + out = append(out, g[0]) + } + return out +} + +func extractAlbumIDs(meta map[string]any) []string { + albumsObj, ok := meta["albums"] + if !ok { + return nil + } + + items := make([]any, 0) + switch a := albumsObj.(type) { + case map[string]any: + switch v := a["items"].(type) { + case []any: + items = v + case []map[string]any: + for _, it := range v { + items = append(items, it) + } + } + case []any: + items = a + case []map[string]any: + for _, it := range a { + items = append(items, it) + } + } + + out := make([]string, 0, len(items)) + seen := map[string]struct{}{} + for _, raw := range items { + itm, ok := raw.(map[string]any) + if !ok { + continue + } + id := stringFromAny(itm["id"]) + if id == "" { + if nested, ok := itm["album"].(map[string]any); ok { + id = stringFromAny(nested["id"]) + } + } + if id == "" { + continue + } + if _, dup := seen[id]; dup { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func (m *Main) Resolve(ctx context.Context) error { + resolved := make([]media.Media, 0, len(m.Pending)) + for _, item := range m.Pending { + med, err := item.Resolve(ctx) + if err != nil { + continue + } + resolved = append(resolved, med) + } + m.Media = append(m.Media, resolved...) + m.Pending = m.Pending[:0] + return nil +} + +func (m *Main) Rip(ctx context.Context) error { + m.logf("Ripping %d media item(s)\n", len(m.Media)) + failures := 0 + for _, item := range m.Media { + if err := item.Rip(ctx); err != nil { + failures++ + m.logf("media item failed: %v\n", err) + } + } + if failures > 0 { + return fmt.Errorf("%d media item(s) failed", failures) + } + return nil +} + +func (m *Main) ripAlbum(ctx context.Context, p provider.Client, source, albumID string, albumMeta map[string]any) error { + albumTitle := titleFromMetadata(albumMeta, albumID) + albumArtist := nestedString(albumMeta, "artist", "name") + if albumArtist == "" { + albumArtist = "Unknown" + } + releaseDate := stringFromAny(albumMeta["release_date_original"]) + if releaseDate == "" { + releaseDate = stringFromAny(albumMeta["release_date"]) + } + if releaseDate == "" { + releaseDate = stringFromAny(albumMeta["releaseDate"]) + } + if releaseDate == "" { + releaseDate = stringFromAny(albumMeta["streamStartDate"]) + } + year := naming.YearFromDate(releaseDate) + bitDepth := intFromAny(albumMeta["maximum_bit_depth"]) + sampling := stringFromAny(albumMeta["maximum_sampling_rate"]) + if bitDepth == 0 || sampling == "" { + fallbackBitDepth, fallbackSampling := m.qualityProfileForSource(source) + if bitDepth == 0 { + bitDepth = fallbackBitDepth + } + if sampling == "" { + sampling = fallbackSampling + } + } + + tracksMap, ok := albumMeta["tracks"].(map[string]any) + if !ok { + return fmt.Errorf("album missing tracks data") + } + rawItems := make([]any, 0) + if itemsAny, ok := tracksMap["items"].([]any); ok { + rawItems = itemsAny + } else if itemsMap, ok := tracksMap["items"].([]map[string]any); ok { + for _, item := range itemsMap { + rawItems = append(rawItems, item) + } + } else { + return fmt.Errorf("album tracks missing items") + } + trackIDs := make([]string, 0, len(rawItems)) + for _, item := range rawItems { + itm, ok := item.(map[string]any) + if !ok { + continue + } + id := stringFromAny(itm["id"]) + if id != "" { + trackIDs = append(trackIDs, id) + } + } + + folder := m.albumFolderPath(source, albumID, albumTitle, albumArtist, year, bitDepth, sampling) + artRes, _ := artwork.Prepare(ctx, m.DL, folder, albumMeta, m.Config.Session.Artwork, false) + total := len(trackIDs) + discTotal := intFromAny(albumMeta["media_count"]) + if discTotal == 0 { + discTotal = intFromAny(albumMeta["numberOfVolumes"]) + } + m.logf("Album: %s (%d tracks)\n", albumTitle, total) + failures := 0 + + if !m.Config.Session.Downloads.Concurrency || m.Config.Session.Downloads.MaxConnections == 1 { + for i, trackID := range trackIDs { + opts := ripTrackOptions{albumFolder: folder, albumEmbedCover: artRes.EmbedPath, index: i + 1, total: total, albumDiscTotal: discTotal} + if err := m.ripTrack(ctx, p, source, trackID, "", opts); err != nil { + failures++ + m.logf("track failed: id=%s reason=%v\n", trackID, err) + } + } + if failures > 0 { + m.logf("Album done with %d failed track(s)\n", failures) + } + return nil + } + + maxWorkers := m.Config.Session.Downloads.MaxConnections + if maxWorkers <= 0 { + maxWorkers = 6 + } + sem := make(chan struct{}, maxWorkers) + var wg sync.WaitGroup + errCh := make(chan error, len(trackIDs)) + for i, trackID := range trackIDs { + wg.Add(1) + sem <- struct{}{} + go func(idx int, tid string) { + defer wg.Done() + defer func() { <-sem }() + opts := ripTrackOptions{albumFolder: folder, albumEmbedCover: artRes.EmbedPath, index: idx, total: total, albumDiscTotal: discTotal} + if err := m.ripTrack(ctx, p, source, tid, "", opts); err != nil { + errCh <- err + } + }(i+1, trackID) + } + wg.Wait() + close(errCh) + for err := range errCh { + failures++ + m.logf("track failed: reason=%v\n", err) + } + if failures > 0 { + m.logf("Album done with %d failed track(s)\n", failures) + } + return nil +} + +func (m *Main) ripPlaylist(ctx context.Context, p provider.Client, source, playlistID string, playlistMeta map[string]any) error { + name := titleFromMetadata(playlistMeta, playlistID) + if n := stringFromAny(playlistMeta["name"]); n != "" { + name = n + } + folder := filepath.Join(m.Config.Session.Downloads.Folder, naming.CleanName(name, naming.Config{ + RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters, + TruncateTo: m.Config.Session.Filepaths.TruncateTo, + })) + + tracksMap, ok := playlistMeta["tracks"].(map[string]any) + if !ok { + return fmt.Errorf("playlist missing tracks data") + } + rawItems, ok := tracksMap["items"].([]any) + if !ok { + if itemsMap, ok2 := tracksMap["items"].([]map[string]any); ok2 { + rawItems = make([]any, 0, len(itemsMap)) + for _, it := range itemsMap { + rawItems = append(rawItems, it) + } + } else { + return fmt.Errorf("playlist tracks missing items") + } + } + + ids := make([]string, 0, len(rawItems)) + for _, raw := range rawItems { + itm, ok := raw.(map[string]any) + if !ok { + continue + } + id := stringFromAny(itm["id"]) + if id == "" { + id = stringFromAny(itm["track_id"]) + } + if id != "" { + ids = append(ids, id) + } + } + + total := len(ids) + m.logf("Playlist: %s (%d tracks)\n", name, total) + failures := 0 + + runOne := func(i int, id string) { + opts := ripTrackOptions{ + albumFolder: folder, + index: i, + total: total, + forPlaylist: true, + playlistName: name, + playlistPos: i, + } + if err := m.ripTrack(ctx, p, source, id, "", opts); err != nil { + failures++ + m.logf("track failed: id=%s reason=%v\n", id, err) + } + } + + if !m.Config.Session.Downloads.Concurrency || m.Config.Session.Downloads.MaxConnections == 1 { + for i, id := range ids { + runOne(i+1, id) + } + } else { + maxWorkers := m.Config.Session.Downloads.MaxConnections + if maxWorkers <= 0 { + maxWorkers = 6 + } + sem := make(chan struct{}, maxWorkers) + var wg sync.WaitGroup + var mu sync.Mutex + for i, id := range ids { + wg.Add(1) + sem <- struct{}{} + go func(pos int, tid string) { + defer wg.Done() + defer func() { <-sem }() + opts := ripTrackOptions{albumFolder: folder, index: pos, total: total, forPlaylist: true, playlistName: name, playlistPos: pos} + if err := m.ripTrack(ctx, p, source, tid, "", opts); err != nil { + mu.Lock() + failures++ + m.logf("track failed: id=%s reason=%v\n", tid, err) + mu.Unlock() + } + }(i+1, id) + } + wg.Wait() + } + + if failures > 0 { + m.logf("Playlist done with %d failed track(s)\n", failures) + } + return nil +} + +func (m *Main) ripTrack(ctx context.Context, p provider.Client, source, id, fallbackTitle string, opts ripTrackOptions) error { + alreadyDownloaded, err := m.Store.IsDownloaded(ctx, id) + if err == nil && alreadyDownloaded { + if m.IgnoreDB { + alreadyDownloaded = false + } else { + if opts.total > 0 { + m.logf("[%d/%d] skip (already downloaded) id=%s\n", opts.index, opts.total, id) + } else { + m.logf("skip (already downloaded) id=%s\n", id) + } + return nil + } + } + + if m.IgnoreDB { + alreadyDownloaded = false + } + + if alreadyDownloaded { + if opts.total > 0 { + m.logf("[%d/%d] skip (already downloaded) id=%s\n", opts.index, opts.total, id) + } else { + m.logf("skip (already downloaded) id=%s\n", id) + } + return nil + } + + meta, err := p.GetMetadata(ctx, id, "track") + if err != nil { + _ = m.Store.MarkFailed(ctx, source, "track", id) + return fmt.Errorf("id=%s metadata: %w", id, err) + } + + title := titleFromMetadata(meta, id) + if title == id && fallbackTitle != "" { + title = fallbackTitle + } + + if opts.forPlaylist { + applyPlaylistMetadataOverrides(meta, m.Config.Session.Metadata, opts.playlistName, opts.playlistPos) + } + + d, err := p.GetDownloadable(ctx, id, m.qualityForSource(source)) + if err != nil { + _ = m.Store.MarkFailed(ctx, source, "track", id) + return fmt.Errorf("id=%s title=%q get_downloadable: %w", id, title, err) + } + + outPath := m.trackOutputPath(source, id, title, d.Extension, meta, opts.albumFolder, opts.albumDiscTotal) + if opts.total > 0 && (!m.Config.Session.CLI.ProgressBars || !m.Config.Session.CLI.TextOutput || !m.DL.ProgressEnabled()) { + m.logf("[%d/%d] %s\n", opts.index, opts.total, filepath.Base(outPath)) + } + if err = m.DL.File(ctx, d.URL, outPath); err != nil { + m.logf("retry: %s (%v)\n", filepath.Base(outPath), err) + if err = m.DL.File(ctx, d.URL, outPath); err != nil { + _ = m.Store.MarkFailed(ctx, source, "track", id) + return fmt.Errorf("id=%s title=%q download: %w", id, title, err) + } + } + + embedCoverPath := opts.albumEmbedCover + if opts.forPlaylist { + parent := opts.albumFolder + if parent == "" { + parent = filepath.Dir(outPath) + } + if res, prepErr := artwork.Prepare(ctx, m.DL, parent, trackMetaAlbum(meta), m.Config.Session.Artwork, true); prepErr == nil { + if res.EmbedPath != "" { + embedCoverPath = res.EmbedPath + } + } + } else if opts.albumFolder == "" { + parent := filepath.Dir(outPath) + if res, prepErr := artwork.Prepare(ctx, m.DL, parent, trackMetaAlbum(meta), m.Config.Session.Artwork, false); prepErr == nil { + if res.EmbedPath != "" { + embedCoverPath = res.EmbedPath + } + } + } + + tagMeta := buildTagMetadata(meta, title, source, id, opts) + coverPath := "" + if m.Config.Session.Artwork.Embed { + coverPath = embedCoverPath + if coverPath == "" { + coverPath = tag.CoverPathForTrack(outPath, opts.albumFolder) + } + } + if err = m.Tagger.TagFLAC(outPath, tagMeta, coverPath); err != nil { + m.logf("warning: tag failed for %s: %v\n", filepath.Base(outPath), err) + } + + if m.Config.Session.Conversion.Enabled { + convertedPath, convErr := convert.Convert(outPath, m.Config.Session.Conversion) + if convErr != nil { + _ = m.Store.MarkFailed(ctx, source, "track", id) + return fmt.Errorf("id=%s title=%q convert: %w", id, title, convErr) + } + outPath = convertedPath + } + + return m.Store.MarkDownloaded(ctx, id) +} + +func (m *Main) qualityForSource(source string) int { + switch source { + case "qobuz": + return m.Config.Session.Qobuz.Quality + case "tidal": + return m.Config.Session.Tidal.Quality + case "deezer": + return m.Config.Session.Deezer.Quality + case "soundcloud": + return m.Config.Session.Soundcloud.Quality + default: + return 0 + } +} + +func (m *Main) qualityProfileForSource(source string) (int, string) { + q := m.qualityForSource(source) + switch source { + case "qobuz": + switch { + case q >= 4: + return 24, "192" + case q >= 3: + return 24, "96" + case q >= 2: + return 16, "44.1" + default: + return 16, "44.1" + } + case "tidal": + switch { + case q >= 3: + return 24, "96" + case q >= 2: + return 16, "44.1" + default: + return 16, "44.1" + } + default: + return 16, "44.1" + } +} + +func (m *Main) albumFolderPath(source, albumID, albumTitle, albumArtist, year string, bitDepth int, samplingRate string) string { + base := m.Config.Session.Downloads.Folder + if m.Config.Session.Downloads.SourceSubdirectories { + base = filepath.Join(base, strings.Title(source)) + } + + vals := map[string]string{ + "albumartist": albumArtist, + "title": albumTitle, + "year": year, + "bit_depth": strconv.Itoa(bitDepth), + "sampling_rate": samplingRate, + "id": albumID, + "container": "FLAC", + "albumcomposer": "Unknown", + } + folderName := naming.FormatTemplate(m.Config.Session.Filepaths.FolderFormat, vals) + folderName = naming.CleanName(folderName, naming.Config{ + RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters, + TruncateTo: m.Config.Session.Filepaths.TruncateTo, + }) + + return filepath.Join(base, folderName) +} + +func (m *Main) trackOutputPath(source, id, title, ext string, trackMeta map[string]any, albumFolder string, albumDiscTotal int) string { + base := m.Config.Session.Downloads.Folder + if m.Config.Session.Downloads.SourceSubdirectories { + base = filepath.Join(base, strings.Title(source)) + } + + if albumFolder == "" && m.Config.Session.Filepaths.AddSinglesToFolder { + albumTitle := nestedString(trackMeta, "album", "title") + albumArtist := nestedString(trackMeta, "album", "artist", "name") + if albumArtist == "" { + albumArtist = nestedString(trackMeta, "performer", "name") + } + albumYear := naming.YearFromDate(stringFromAny(trackMeta["release_date_original"])) + if albumYear == "Unknown" { + albumYear = naming.YearFromDate(stringFromAny(trackMeta["release_date"])) + } + albumFolder = m.albumFolderPath(source, id, albumTitle, albumArtist, albumYear, intFromAny(trackMeta["maximum_bit_depth"]), stringFromAny(trackMeta["maximum_sampling_rate"])) + } + if albumFolder != "" { + base = albumFolder + if m.Config.Session.Downloads.DiscSubdirectories && albumDiscTotal > 1 { + discNumber := intFromAny(trackMeta["media_number"]) + if discNumber > 0 { + base = filepath.Join(base, "Disc "+strconv.Itoa(discNumber)) + } + } + } + + trackNumber := intFromAny(trackMeta["track_number"]) + if trackNumber == 0 { + trackNumber = intFromAny(trackMeta["trackNumber"]) + } + explicit := "" + if boolFromAny(trackMeta["parental_warning"]) || boolFromAny(trackMeta["explicit"]) { + explicit = " (Explicit)" + } + artist := nestedString(trackMeta, "performer", "name") + if artist == "" { + artist = nestedString(trackMeta, "artist", "name") + } + albumArtist := nestedString(trackMeta, "album", "artist", "name") + if albumArtist == "" { + albumArtist = artist + } + values := map[string]string{ + "id": id, + "tracknumber": strconv.Itoa(trackNumber), + "artist": artist, + "albumartist": albumArtist, + "composer": "Unknown", + "title": title, + "albumcomposer": "Unknown", + "explicit": explicit, + } + fileName := naming.FormatTemplate(m.Config.Session.Filepaths.TrackFormat, values) + fileName = naming.CleanName(fileName, naming.Config{ + RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters, + TruncateTo: m.Config.Session.Filepaths.TruncateTo, + }) + return filepath.Join(base, fileName+"."+ext) +} + +func titleFromMetadata(meta map[string]any, fallback string) string { + if title, ok := meta["title"].(string); ok { + title = strings.TrimSpace(title) + version := strings.TrimSpace(stringFromAny(meta["version"])) + if version != "" { + return title + " (" + version + ")" + } + if title != "" { + return title + } + } + return fallback +} + +func nestedString(v map[string]any, keys ...string) string { + cur := any(v) + for _, key := range keys { + m, ok := cur.(map[string]any) + if !ok { + return "" + } + cur = m[key] + } + return stringFromAny(cur) +} + +func stringFromAny(v any) string { + switch t := v.(type) { + case string: + return t + case float64: + return strconv.FormatFloat(t, 'f', -1, 64) + case int64: + return strconv.FormatInt(t, 10) + case int: + return strconv.Itoa(t) + default: + return "" + } +} + +func intFromAny(v any) int { + switch t := v.(type) { + case int: + return t + case int64: + return int(t) + case float64: + return int(t) + default: + return 0 + } +} + +func floatFromAny(v any) float64 { + switch t := v.(type) { + case float64: + return t + case int: + return float64(t) + case int64: + return float64(t) + default: + return 0 + } +} + +func boolFromAny(v any) bool { + b, _ := v.(bool) + return b +} + +func trackMetaAlbum(trackMeta map[string]any) map[string]any { + album, ok := trackMeta["album"].(map[string]any) + if !ok { + return map[string]any{} + } + return album +} + +func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, opts ripTrackOptions) tag.Metadata { + artist := nestedString(trackMeta, "performer", "name") + if artist == "" { + artist = nestedString(trackMeta, "artist", "name") + } + albumArtist := nestedString(trackMeta, "album", "artist", "name") + if albumArtist == "" { + albumArtist = artist + } + trackNumber := intFromAny(trackMeta["track_number"]) + if trackNumber == 0 { + trackNumber = intFromAny(trackMeta["trackNumber"]) + } + discNumber := intFromAny(trackMeta["media_number"]) + if discNumber == 0 { + discNumber = intFromAny(trackMeta["volumeNumber"]) + } + date := stringFromAny(trackMeta["release_date_original"]) + if date == "" { + date = stringFromAny(trackMeta["release_date"]) + } + if date == "" { + date = stringFromAny(trackMeta["streamStartDate"]) + } + album := nestedString(trackMeta, "album", "title") + if album == "" { + album = stringFromAny(trackMeta["title"]) + } + trackTotal := intFromAny(trackMeta["tracks_count"]) + if trackTotal == 0 { + trackTotal = intFromAny(trackMeta["numberOfTracks"]) + } + if trackTotal == 0 { + trackTotal = intFromAny(trackMeta["track_total"]) + } + if opts.forPlaylist && opts.total > 0 { + trackTotal = opts.total + } + + discTotal := intFromAny(trackMeta["media_count"]) + if discTotal == 0 { + discTotal = intFromAny(trackMeta["numberOfVolumes"]) + } + if opts.forPlaylist { + discTotal = 1 + } + + genre := nestedString(trackMeta, "genre", "name") + if genre == "" { + genre = stringFromAny(trackMeta["genre"]) + } + + comment := stringFromAny(trackMeta["comment"]) + description := stringFromAny(trackMeta["description"]) + lyrics := stringFromAny(trackMeta["lyrics"]) + + sourceAlbumID := nestedString(trackMeta, "album", "id") + sourceArtistID := nestedString(trackMeta, "artist", "id") + if sourceArtistID == "" { + sourceArtistID = nestedString(trackMeta, "performer", "id") + } + + return tag.Metadata{ + Title: title, + Album: album, + Artist: artist, + AlbumArtist: albumArtist, + TrackNumber: trackNumber, + DiscNumber: discNumber, + TrackTotal: trackTotal, + DiscTotal: discTotal, + Date: date, + Genre: genre, + Comment: comment, + Description: description, + Lyrics: lyrics, + Copyright: stringFromAny(trackMeta["copyright"]), + ISRC: stringFromAny(trackMeta["isrc"]), + ReplaygainTrackGain: stringFromAny(trackMeta["replaygain_track_gain"]), + ReplaygainAlbumGain: stringFromAny(trackMeta["replaygain_album_gain"]), + SourcePlatform: source, + SourceTrackID: trackID, + SourceAlbumID: sourceAlbumID, + SourceArtistID: sourceArtistID, + } +} + +func applyPlaylistMetadataOverrides(meta map[string]any, cfg config.MetadataConfig, playlistName string, position int) { + if cfg.RenumberPlaylistTracks && position > 0 { + meta["track_number"] = position + meta["trackNumber"] = position + } + if !cfg.SetPlaylistToAlbum { + return + } + album, ok := meta["album"].(map[string]any) + if !ok { + album = map[string]any{} + meta["album"] = album + } + album["title"] = playlistName + artist, ok := album["artist"].(map[string]any) + if !ok { + artist = map[string]any{} + album["artist"] = artist + } + artist["name"] = "Various Artists" +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 0000000..ae426c0 --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,318 @@ +package app + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "streamrip-go/internal/audio/tag" + "streamrip-go/internal/config" + "streamrip-go/internal/download" + "streamrip-go/internal/provider" + "streamrip-go/internal/store" +) + +type noopTagger struct{} + +func (n noopTagger) TagFLAC(string, tag.Metadata, string) error { return nil } + +type fakeProvider struct { + url string +} + +func (f *fakeProvider) Source() string { return "qobuz" } +func (f *fakeProvider) Login(context.Context) error { return nil } +func (f *fakeProvider) LoggedIn() bool { return true } +func (f *fakeProvider) Close() error { return nil } +func (f *fakeProvider) Search(context.Context, string, string, int) ([]map[string]any, error) { + return nil, nil +} +func (f *fakeProvider) GetMetadata(context.Context, string, string) (map[string]any, error) { + return map[string]any{ + "title": "Dreams/Live", + "track_number": float64(3), + "performer": map[string]any{ + "name": "Fleetwood Mac", + }, + "album": map[string]any{ + "artist": map[string]any{"name": "Fleetwood Mac"}, + }, + }, nil +} + +type fakeAlbumProvider struct { + url string +} + +type fakePlaylistProvider struct { + url string +} + +func (f *fakeAlbumProvider) Source() string { return "qobuz" } +func (f *fakePlaylistProvider) Source() string { return "qobuz" } +func (f *fakeAlbumProvider) Login(context.Context) error { return nil } +func (f *fakePlaylistProvider) Login(context.Context) error { + return nil +} +func (f *fakeAlbumProvider) LoggedIn() bool { return true } +func (f *fakePlaylistProvider) LoggedIn() bool { return true } +func (f *fakeAlbumProvider) Close() error { return nil } +func (f *fakePlaylistProvider) Close() error { return nil } +func (f *fakeAlbumProvider) Search(context.Context, string, string, int) ([]map[string]any, error) { + return nil, nil +} +func (f *fakePlaylistProvider) Search(context.Context, string, string, int) ([]map[string]any, error) { + return nil, nil +} +func (f *fakeAlbumProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) { + if mediaType == "album" { + return map[string]any{ + "title": "Rumours", + "release_date_original": "1977-02-04", + "media_count": float64(2), + "maximum_bit_depth": float64(24), + "maximum_sampling_rate": float64(96), + "artist": map[string]any{"name": "Fleetwood Mac"}, + "tracks": map[string]any{"items": []any{ + map[string]any{"id": "t1"}, + map[string]any{"id": "t2"}, + }}, + }, nil + } + + tn := float64(1) + disc := float64(1) + title := "Dreams" + if id == "t2" { + tn = 2 + disc = 2 + title = "Go Your Own Way" + } + return map[string]any{ + "title": title, + "track_number": tn, + "media_number": disc, + "performer": map[string]any{ + "name": "Fleetwood Mac", + }, + "album": map[string]any{ + "title": "Rumours", + "artist": map[string]any{"name": "Fleetwood Mac"}, + }, + }, nil +} + +func (f *fakePlaylistProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) { + if mediaType == "playlist" { + return map[string]any{ + "name": "Road Trip", + "tracks": map[string]any{ + "items": []any{map[string]any{"id": "p1"}, map[string]any{"id": "p2"}}, + }, + }, nil + } + + trackNum := float64(7) + title := "Track One" + if id == "p2" { + trackNum = 9 + title = "Track Two" + } + return map[string]any{ + "title": title, + "track_number": trackNum, + "performer": map[string]any{ + "name": "Artist", + }, + "album": map[string]any{ + "title": "Original Album", + "artist": map[string]any{"name": "Artist"}, + }, + }, nil +} +func (f *fakeProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) { + return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil +} +func (f *fakeAlbumProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) { + return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil +} +func (f *fakePlaylistProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) { + return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil +} + +func TestTrackRipPipeline(t *testing.T) { + tmp := t.TempDir() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("audio-bytes")) + })) + defer ts.Close() + + d := config.DefaultConfigData() + d.Downloads.Folder = tmp + d.Downloads.SourceSubdirectories = false + cfg := &config.Config{File: d, Session: d} + + sqlite, err := store.NewSQLite(filepath.Join(tmp, "db.sqlite")) + if err != nil { + t.Fatalf("NewSQLite() error = %v", err) + } + defer func() { _ = sqlite.Close() }() + + m := &Main{ + Config: cfg, + Providers: map[string]provider.Client{ + "qobuz": &fakeProvider{url: ts.URL}, + }, + Store: sqlite, + DL: download.New(), + Tagger: noopTagger{}, + Pending: nil, + Media: nil, + } + + ctx := context.Background() + if err = m.AddByID(ctx, "qobuz", "track", "19512574"); err != nil { + t.Fatalf("AddByID() error = %v", err) + } + if err = m.Resolve(ctx); err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if err = m.Rip(ctx); err != nil { + t.Fatalf("Rip() error = %v", err) + } + + if _, err = os.Stat(filepath.Join(tmp, "03. Fleetwood Mac - Dreams_Live.flac")); err != nil { + t.Fatalf("expected downloaded file: %v", err) + } + + ok, err := sqlite.IsDownloaded(ctx, "19512574") + if err != nil { + t.Fatalf("IsDownloaded() error = %v", err) + } + if !ok { + t.Fatalf("expected track marked downloaded") + } +} + +func TestAlbumRipPipeline(t *testing.T) { + tmp := t.TempDir() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("audio-bytes")) + })) + defer ts.Close() + + d := config.DefaultConfigData() + d.Downloads.Folder = tmp + d.Downloads.SourceSubdirectories = false + d.Downloads.Concurrency = false + cfg := &config.Config{File: d, Session: d} + + sqlite, err := store.NewSQLite(filepath.Join(tmp, "db.sqlite")) + if err != nil { + t.Fatalf("NewSQLite() error = %v", err) + } + defer func() { _ = sqlite.Close() }() + + m := &Main{ + Config: cfg, + Providers: map[string]provider.Client{ + "qobuz": &fakeAlbumProvider{url: ts.URL}, + }, + Store: sqlite, + DL: download.New(), + Tagger: noopTagger{}, + } + + ctx := context.Background() + if err = m.AddByID(ctx, "qobuz", "album", "a1"); err != nil { + t.Fatalf("AddByID() error = %v", err) + } + if err = m.Resolve(ctx); err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if err = m.Rip(ctx); err != nil { + t.Fatalf("Rip() error = %v", err) + } + + folder := filepath.Join(tmp, "Fleetwood Mac - Rumours (1977) [FLAC] [24B-96kHz]") + if _, err = os.Stat(filepath.Join(folder, "Disc 1", "01. Fleetwood Mac - Dreams.flac")); err != nil { + t.Fatalf("missing first album track: %v", err) + } + if _, err = os.Stat(filepath.Join(folder, "Disc 2", "02. Fleetwood Mac - Go Your Own Way.flac")); err != nil { + t.Fatalf("missing second album track: %v", err) + } +} + +func TestPlaylistRipPipeline(t *testing.T) { + tmp := t.TempDir() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("audio-bytes")) + })) + defer ts.Close() + + d := config.DefaultConfigData() + d.Downloads.Folder = tmp + d.Downloads.Concurrency = false + d.Filepaths.RestrictCharacters = false + cfg := &config.Config{File: d, Session: d} + + sqlite, err := store.NewSQLite(filepath.Join(tmp, "db.sqlite")) + if err != nil { + t.Fatalf("NewSQLite() error = %v", err) + } + defer func() { _ = sqlite.Close() }() + + m := &Main{ + Config: cfg, + Providers: map[string]provider.Client{ + "qobuz": &fakePlaylistProvider{url: ts.URL}, + }, + Store: sqlite, + DL: download.NewWithOptions(true, false), + Tagger: noopTagger{}, + } + + ctx := context.Background() + if err = m.AddByID(ctx, "qobuz", "playlist", "pl1"); err != nil { + t.Fatalf("AddByID() error = %v", err) + } + if err = m.Resolve(ctx); err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if err = m.Rip(ctx); err != nil { + t.Fatalf("Rip() error = %v", err) + } + + folder := filepath.Join(tmp, "Road Trip") + if _, err = os.Stat(filepath.Join(folder, "01. Artist - Track One.flac")); err != nil { + t.Fatalf("missing first playlist track: %v", err) + } + if _, err = os.Stat(filepath.Join(folder, "02. Artist - Track Two.flac")); err != nil { + t.Fatalf("missing second playlist track: %v", err) + } +} + +func TestApplyQobuzArtistFiltersRepeats(t *testing.T) { + albums := []collectionAlbum{ + {ID: "a1", Title: "Album X", BitDepth: 16, Sampling: 44.1, Explicit: false}, + {ID: "a2", Title: "Album X (Deluxe)", BitDepth: 24, Sampling: 96, Explicit: false}, + {ID: "b1", Title: "Album B", BitDepth: 16, Sampling: 44.1, Explicit: false}, + } + filtered := applyQobuzArtistFilters("artist", albums, config.QobuzDiscographyFilterConfig{Repeats: true}) + if len(filtered) != 2 { + t.Fatalf("len(filtered)=%d want 2", len(filtered)) + } + ids := map[string]bool{} + for _, a := range filtered { + ids[a.ID] = true + } + if !ids["a2"] || !ids["b1"] { + t.Fatalf("unexpected winners: %+v", ids) + } +} diff --git a/internal/artwork/artwork.go b/internal/artwork/artwork.go new file mode 100644 index 0000000..4499408 --- /dev/null +++ b/internal/artwork/artwork.go @@ -0,0 +1,186 @@ +package artwork + +import ( + "context" + "crypto/sha1" + "fmt" + "image" + "image/jpeg" + "os" + "path/filepath" + "strings" + "sync" + + "golang.org/x/image/draw" + + "streamrip-go/internal/config" +) + +type Downloader interface { + File(ctx context.Context, sourceURL, outputPath string) error + FileNoProgress(ctx context.Context, sourceURL, outputPath string) error +} + +type Result struct { + EmbedPath string + SavedPath string +} + +var ( + tempDirsMu sync.Mutex + tempDirs = map[string]struct{}{} +) + +func Prepare(ctx context.Context, dl Downloader, folder string, albumMeta map[string]any, cfg config.ArtworkConfig, forPlaylist bool) (Result, error) { + saveArtwork := cfg.SaveArtwork + if forPlaylist { + saveArtwork = false + } + if !(cfg.Embed || saveArtwork) { + return Result{}, nil + } + + imageMap, ok := albumMeta["image"].(map[string]any) + if !ok { + return Result{}, nil + } + + largestURL := pickLargestURL(imageMap) + embedURL := pickEmbedURL(imageMap, cfg.EmbedSize) + if embedURL == "" { + embedURL = largestURL + } + + result := Result{} + if saveArtwork && largestURL != "" { + savedPath := filepath.Join(folder, "cover.jpg") + if fileExists(savedPath) { + result.SavedPath = savedPath + } else if err := dl.FileNoProgress(ctx, largestURL, savedPath); err == nil { + if cfg.SavedMaxWidth > 0 { + _ = downscaleImage(savedPath, cfg.SavedMaxWidth) + } + result.SavedPath = savedPath + } + } + + if cfg.Embed && embedURL != "" { + embedDir := filepath.Join(folder, "__artwork") + if err := os.MkdirAll(embedDir, 0o755); err == nil { + registerTempDir(embedDir) + embedPath := filepath.Join(embedDir, embedFilename(embedURL)) + if fileExists(embedPath) { + result.EmbedPath = embedPath + } else if err := dl.FileNoProgress(ctx, embedURL, embedPath); err == nil { + if cfg.EmbedMaxWidth > 0 { + _ = downscaleImage(embedPath, cfg.EmbedMaxWidth) + } + result.EmbedPath = embedPath + } + } + } + + return result, nil +} + +func CleanupTempDirs() { + tempDirsMu.Lock() + defer tempDirsMu.Unlock() + + for dir := range tempDirs { + _ = os.RemoveAll(dir) + delete(tempDirs, dir) + } +} + +func registerTempDir(path string) { + tempDirsMu.Lock() + defer tempDirsMu.Unlock() + tempDirs[path] = struct{}{} +} + +func fileExists(path string) bool { + st, err := os.Stat(path) + if err != nil { + return false + } + return !st.IsDir() +} + +func pickLargestURL(image map[string]any) string { + for _, key := range []string{"original", "mega", "extralarge", "large", "small", "thumbnail"} { + if v := stringAny(image[key]); v != "" { + return v + } + } + return "" +} + +func pickEmbedURL(image map[string]any, size string) string { + size = strings.ToLower(strings.TrimSpace(size)) + if size == "" { + size = "large" + } + for _, key := range []string{size, "large", "extralarge", "small", "thumbnail", "original"} { + if v := stringAny(image[key]); v != "" { + return v + } + } + return "" +} + +func embedFilename(url string) string { + s := sha1.Sum([]byte(url)) + return fmt.Sprintf("cover%x.jpg", s[:8]) +} + +func stringAny(v any) string { + s, _ := v.(string) + return s +} + +func downscaleImage(path string, maxDimension int) error { + f, err := os.Open(path) + if err != nil { + return err + } + img, _, err := image.Decode(f) + _ = f.Close() + if err != nil { + return err + } + + b := img.Bounds() + w, h := b.Dx(), b.Dy() + if w <= 0 || h <= 0 || maxDimension <= 0 { + return nil + } + if w <= maxDimension && h <= maxDimension { + return nil + } + + newW, newH := w, h + if w > h { + newW = maxDimension + newH = int(float64(h) * (float64(maxDimension) / float64(w))) + } else { + newH = maxDimension + newW = int(float64(w) * (float64(maxDimension) / float64(h))) + } + if newW <= 0 { + newW = 1 + } + if newH <= 0 { + newH = 1 + } + + dst := image.NewRGBA(image.Rect(0, 0, newW, newH)) + draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil) + + out, err := os.Create(path) + if err != nil { + return err + } + defer func() { _ = out.Close() }() + return jpeg.Encode(out, dst, &jpeg.Options{Quality: 92}) +} diff --git a/internal/audio/convert/convert.go b/internal/audio/convert/convert.go new file mode 100644 index 0000000..cc97a6e --- /dev/null +++ b/internal/audio/convert/convert.go @@ -0,0 +1,123 @@ +package convert + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + + "streamrip-go/internal/config" +) + +type profile struct { + codecLib string + ext string + lossless bool +} + +var profiles = map[string]profile{ + "FLAC": {codecLib: "flac", ext: "flac", lossless: true}, + "ALAC": {codecLib: "alac", ext: "m4a", lossless: true}, + "OPUS": {codecLib: "libopus", ext: "opus", lossless: false}, + "MP3": {codecLib: "libmp3lame", ext: "mp3", lossless: false}, + "VORBIS": {codecLib: "libvorbis", ext: "ogg", lossless: false}, + "AAC": {codecLib: "aac", ext: "m4a", lossless: false}, +} + +func Convert(path string, cfg config.ConversionConfig) (string, error) { + if !cfg.Enabled { + return path, nil + } + if _, err := exec.LookPath("ffmpeg"); err != nil { + return path, fmt.Errorf("ffmpeg not found: %w", err) + } + + p, ok := profiles[strings.ToUpper(strings.TrimSpace(cfg.Codec))] + if !ok { + return path, fmt.Errorf("unsupported conversion codec: %s", cfg.Codec) + } + + base := strings.TrimSuffix(path, filepath.Ext(path)) + finalPath := base + "." + p.ext + tmpPath := finalPath + ".tmp." + p.ext + + args := buildFFmpegArgs(path, tmpPath, p, cfg) + cmd := exec.Command("ffmpeg", args...) + output, err := cmd.CombinedOutput() + if err != nil { + _ = os.Remove(tmpPath) + return path, fmt.Errorf("conversion failed: %w: %s", err, string(output)) + } + + if path != finalPath { + _ = os.Remove(path) + } + if err = os.Rename(tmpPath, finalPath); err != nil { + _ = os.Remove(tmpPath) + return path, err + } + + return finalPath, nil +} + +func buildFFmpegArgs(inputPath, outputPath string, p profile, cfg config.ConversionConfig) []string { + args := []string{ + "-y", + "-i", inputPath, + "-map", "0:a:0", + "-map_metadata", "0", + "-c:a", p.codecLib, + } + + if p.lossless { + filter := buildLosslessFilter(cfg) + if filter != "" { + args = append(args, "-af", filter) + } + } else { + if cfg.LossyBitrate > 0 { + args = append(args, "-b:a", strconv.Itoa(cfg.LossyBitrate)+"k") + } + } + + args = append(args, outputPath) + return args +} + +func buildLosslessFilter(cfg config.ConversionConfig) string { + parts := make([]string, 0, 2) + if cfg.SamplingRate > 0 { + rates := allowedSampleRates(cfg.SamplingRate) + if len(rates) > 0 { + parts = append(parts, "sample_rates="+strings.Join(rates, "|")) + } + } + if cfg.BitDepth == 16 { + parts = append(parts, "sample_fmts=s16p|s16") + } else if cfg.BitDepth == 24 || cfg.BitDepth == 32 { + parts = append(parts, "sample_fmts=s16p|s16|s32p|s32") + } + if len(parts) == 0 { + return "" + } + return "aformat=" + strings.Join(parts, ":") +} + +func allowedSampleRates(max int) []string { + all := []int{44100, 48000, 88200, 96000, 176400, 192000} + out := make([]int, 0, len(all)) + for _, r := range all { + if r <= max { + out = append(out, r) + } + } + sort.Ints(out) + str := make([]string, 0, len(out)) + for _, r := range out { + str = append(str, strconv.Itoa(r)) + } + return str +} diff --git a/internal/audio/convert/convert_test.go b/internal/audio/convert/convert_test.go new file mode 100644 index 0000000..474dc32 --- /dev/null +++ b/internal/audio/convert/convert_test.go @@ -0,0 +1,43 @@ +package convert + +import ( + "strings" + "testing" + + "streamrip-go/internal/config" +) + +func TestAllowedSampleRates(t *testing.T) { + got := allowedSampleRates(96000) + want := []string{"44100", "48000", "88200", "96000"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("rates=%v want=%v", got, want) + } +} + +func TestBuildFFmpegArgsLossless(t *testing.T) { + cfg := config.ConversionConfig{Enabled: true, Codec: "FLAC", SamplingRate: 48000, BitDepth: 16} + args := buildFFmpegArgs("in.flac", "out.flac", profiles["FLAC"], cfg) + joined := strings.Join(args, " ") + if !strings.Contains(joined, "-c:a flac") { + t.Fatalf("missing flac codec args=%s", joined) + } + if !strings.Contains(joined, "sample_rates=44100|48000") { + t.Fatalf("missing sample rate filter args=%s", joined) + } + if !strings.Contains(joined, "sample_fmts=s16p|s16") { + t.Fatalf("missing bit depth filter args=%s", joined) + } +} + +func TestBuildFFmpegArgsLossy(t *testing.T) { + cfg := config.ConversionConfig{Enabled: true, Codec: "MP3", LossyBitrate: 320} + args := buildFFmpegArgs("in.flac", "out.mp3", profiles["MP3"], cfg) + joined := strings.Join(args, " ") + if !strings.Contains(joined, "-c:a libmp3lame") { + t.Fatalf("missing mp3 codec args=%s", joined) + } + if !strings.Contains(joined, "-b:a 320k") { + t.Fatalf("missing bitrate args=%s", joined) + } +} diff --git a/internal/audio/tag/tagger.go b/internal/audio/tag/tagger.go new file mode 100644 index 0000000..33f0000 --- /dev/null +++ b/internal/audio/tag/tagger.go @@ -0,0 +1,169 @@ +package tag + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +type Metadata struct { + Title string + Album string + Artist string + AlbumArtist string + TrackNumber int + DiscNumber int + TrackTotal int + DiscTotal int + Date string + Genre string + Comment string + Description string + Lyrics string + Copyright string + ISRC string + ReplaygainTrackGain string + ReplaygainAlbumGain string + SourcePlatform string + SourceTrackID string + SourceAlbumID string + SourceArtistID string +} + +type Tagger struct{} + +func New() *Tagger { + return &Tagger{} +} + +func (t *Tagger) TagFLAC(path string, meta Metadata, coverPath string) error { + if _, err := exec.LookPath("ffmpeg"); err != nil { + return fmt.Errorf("ffmpeg not found: %w", err) + } + + tmpPath := path + ".tmp.flac" + args := buildFFmpegArgs(path, tmpPath, meta, coverPath) + + cmd := exec.Command("ffmpeg", args...) + output, err := cmd.CombinedOutput() + if err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("ffmpeg tag failed: %w: %s", err, string(output)) + } + + if err = os.Rename(tmpPath, path); err != nil { + _ = os.Remove(tmpPath) + return err + } + + return nil +} + +func buildFFmpegArgs(inputPath, outputPath string, meta Metadata, coverPath string) []string { + args := []string{"-y", "-i", inputPath} + withCover := coverPath != "" && fileExists(coverPath) + if withCover { + args = append(args, "-i", coverPath) + } + + args = append(args, + "-map", "0:a", + "-c:a", "copy", + ) + if withCover { + args = append(args, + "-map", "1:v:0", + "-c:v", "mjpeg", + "-disposition:v:0", "attached_pic", + ) + } + + for k, v := range toTags(meta) { + if strings.TrimSpace(v) == "" { + continue + } + args = append(args, "-metadata", k+"="+v) + } + + args = append(args, outputPath) + return args +} + +func toTags(meta Metadata) map[string]string { + tags := map[string]string{ + "title": meta.Title, + "album": meta.Album, + "artist": meta.Artist, + "album_artist": meta.AlbumArtist, + "date": meta.Date, + "genre": meta.Genre, + "comment": meta.Comment, + "description": meta.Description, + "lyrics": meta.Lyrics, + "copyright": normalizeCopyright(meta.Copyright), + "isrc": meta.ISRC, + "replaygain_track_gain": meta.ReplaygainTrackGain, + "replaygain_album_gain": meta.ReplaygainAlbumGain, + "source_platform": strings.ToUpper(strings.TrimSpace(meta.SourcePlatform)), + "source_track_id": meta.SourceTrackID, + "source_album_id": meta.SourceAlbumID, + "source_artist_id": meta.SourceArtistID, + } + if meta.TrackNumber > 0 { + if meta.TrackTotal > 0 { + tags["track"] = fmt.Sprintf("%02d/%02d", meta.TrackNumber, meta.TrackTotal) + } else { + tags["track"] = fmt.Sprintf("%02d", meta.TrackNumber) + } + } + if meta.TrackTotal > 0 { + tags["tracktotal"] = strconv.Itoa(meta.TrackTotal) + } + if meta.DiscNumber > 0 { + if meta.DiscTotal > 0 { + tags["disc"] = fmt.Sprintf("%d/%d", meta.DiscNumber, meta.DiscTotal) + } else { + tags["disc"] = strconv.Itoa(meta.DiscNumber) + } + } + if meta.DiscTotal > 0 { + tags["disctotal"] = strconv.Itoa(meta.DiscTotal) + } + return tags +} + +func normalizeCopyright(in string) string { + out := strings.ReplaceAll(in, "(c)", "©") + out = strings.ReplaceAll(out, "(C)", "©") + out = strings.ReplaceAll(out, "(p)", "℗") + out = strings.ReplaceAll(out, "(P)", "℗") + return out +} + +func fileExists(path string) bool { + if path == "" { + return false + } + st, err := os.Stat(path) + if err != nil { + return false + } + return !st.IsDir() +} + +func CoverPathForTrack(trackPath string, albumFolder string) string { + if albumFolder != "" { + p := filepath.Join(albumFolder, "cover.jpg") + if fileExists(p) { + return p + } + } + p := filepath.Join(filepath.Dir(trackPath), "cover.jpg") + if fileExists(p) { + return p + } + return "" +} diff --git a/internal/audio/tag/tagger_test.go b/internal/audio/tag/tagger_test.go new file mode 100644 index 0000000..f4f080b --- /dev/null +++ b/internal/audio/tag/tagger_test.go @@ -0,0 +1,73 @@ +package tag + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNormalizeCopyright(t *testing.T) { + got := normalizeCopyright("(c) test (P) other") + if got != "© test ℗ other" { + t.Fatalf("got %q", got) + } +} + +func TestToTagsTrackDiscFormatting(t *testing.T) { + tags := toTags(Metadata{TrackNumber: 3, DiscNumber: 2}) + if tags["track"] != "03" { + t.Fatalf("track tag = %q", tags["track"]) + } + if tags["disc"] != "2" { + t.Fatalf("disc tag = %q", tags["disc"]) + } +} + +func TestToTagsTotalsAndSourceFields(t *testing.T) { + tags := toTags(Metadata{ + TrackNumber: 3, + TrackTotal: 12, + DiscNumber: 1, + DiscTotal: 2, + ISRC: "USABC1234567", + SourcePlatform: "qobuz", + SourceTrackID: "t1", + }) + if tags["track"] != "03/12" { + t.Fatalf("track tag = %q", tags["track"]) + } + if tags["disc"] != "1/2" { + t.Fatalf("disc tag = %q", tags["disc"]) + } + if tags["tracktotal"] != "12" || tags["disctotal"] != "2" { + t.Fatalf("totals missing: %+v", tags) + } + if tags["isrc"] != "USABC1234567" { + t.Fatalf("isrc missing: %+v", tags) + } + if tags["source_platform"] != "QOBUZ" || tags["source_track_id"] != "t1" { + t.Fatalf("source tags missing: %+v", tags) + } +} + +func TestBuildFFmpegArgsWithCover(t *testing.T) { + tmp := t.TempDir() + cover := filepath.Join(tmp, "cover.jpg") + if err := os.WriteFile(cover, []byte("x"), 0o644); err != nil { + t.Fatalf("write cover: %v", err) + } + args := buildFFmpegArgs("in.flac", "out.flac", Metadata{Title: "x"}, cover) + foundInput2 := false + foundAttach := false + for i := 0; i < len(args)-1; i++ { + if args[i] == "-i" && args[i+1] == cover { + foundInput2 = true + } + if args[i] == "-disposition:v:0" && args[i+1] == "attached_pic" { + foundAttach = true + } + } + if !foundInput2 || !foundAttach { + t.Fatalf("missing cover args: %v", args) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..60d2fa8 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,331 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/pelletier/go-toml/v2" +) + +const CurrentConfigVersion = "2.2.0" + +var ErrOutdatedConfig = errors.New("config version mismatch") + +type Config struct { + Path string + File ConfigData + Session ConfigData +} + +type ConfigData struct { + Downloads DownloadsConfig `toml:"downloads"` + Qobuz QobuzConfig `toml:"qobuz"` + Tidal TidalConfig `toml:"tidal"` + Deezer DeezerConfig `toml:"deezer"` + Soundcloud SoundcloudConfig `toml:"soundcloud"` + Youtube YoutubeConfig `toml:"youtube"` + Database DatabaseConfig `toml:"database"` + Conversion ConversionConfig `toml:"conversion"` + QobuzFilters QobuzDiscographyFilterConfig `toml:"qobuz_filters"` + Artwork ArtworkConfig `toml:"artwork"` + Metadata MetadataConfig `toml:"metadata"` + Filepaths FilepathsConfig `toml:"filepaths"` + LastFM LastFMConfig `toml:"lastfm"` + CLI CLIConfig `toml:"cli"` + Misc MiscConfig `toml:"misc"` +} + +type DownloadsConfig struct { + Folder string `toml:"folder"` + SourceSubdirectories bool `toml:"source_subdirectories"` + DiscSubdirectories bool `toml:"disc_subdirectories"` + Concurrency bool `toml:"concurrency"` + MaxConnections int `toml:"max_connections"` + RequestsPerMinute int `toml:"requests_per_minute"` + VerifySSL bool `toml:"verify_ssl"` +} + +type QobuzConfig struct { + Quality int `toml:"quality"` + DownloadBooklets bool `toml:"download_booklets"` + UseAuthToken bool `toml:"use_auth_token"` + EmailOrUserID string `toml:"email_or_userid"` + PasswordOrToken string `toml:"password_or_token"` + AppID string `toml:"app_id"` + Secrets []string `toml:"secrets"` +} + +type TidalConfig struct { + Quality int `toml:"quality"` + DownloadVideos bool `toml:"download_videos"` + UserID string `toml:"user_id"` + CountryCode string `toml:"country_code"` + AccessToken string `toml:"access_token"` + RefreshToken string `toml:"refresh_token"` + TokenExpiry int64 `toml:"token_expiry"` +} + +type DeezerConfig struct { + Quality int `toml:"quality"` + LowerQualityIfNotAvailable bool `toml:"lower_quality_if_not_available"` + ARL string `toml:"arl"` + UseDeezloader bool `toml:"use_deezloader"` + DeezloaderWarnings bool `toml:"deezloader_warnings"` +} + +type SoundcloudConfig struct { + Quality int `toml:"quality"` + ClientID string `toml:"client_id"` + AppVersion string `toml:"app_version"` +} + +type YoutubeConfig struct { + Quality int `toml:"quality"` + DownloadVideos bool `toml:"download_videos"` + VideoDownloadsFolder string `toml:"video_downloads_folder"` +} + +type DatabaseConfig struct { + DownloadsEnabled bool `toml:"downloads_enabled"` + DownloadsPath string `toml:"downloads_path"` + FailedDownloadsEnabled bool `toml:"failed_downloads_enabled"` + FailedDownloadsPath string `toml:"failed_downloads_path"` +} + +type ConversionConfig struct { + Enabled bool `toml:"enabled"` + Codec string `toml:"codec"` + SamplingRate int `toml:"sampling_rate"` + BitDepth int `toml:"bit_depth"` + LossyBitrate int `toml:"lossy_bitrate"` +} + +type QobuzDiscographyFilterConfig struct { + Extras bool `toml:"extras"` + Repeats bool `toml:"repeats"` + NonAlbums bool `toml:"non_albums"` + Features bool `toml:"features"` + NonStudioAlbums bool `toml:"non_studio_albums"` + NonRemaster bool `toml:"non_remaster"` +} + +type ArtworkConfig struct { + Embed bool `toml:"embed"` + EmbedSize string `toml:"embed_size"` + EmbedMaxWidth int `toml:"embed_max_width"` + SaveArtwork bool `toml:"save_artwork"` + SavedMaxWidth int `toml:"saved_max_width"` +} + +type MetadataConfig struct { + SetPlaylistToAlbum bool `toml:"set_playlist_to_album"` + RenumberPlaylistTracks bool `toml:"renumber_playlist_tracks"` + Exclude []string `toml:"exclude"` +} + +type FilepathsConfig struct { + AddSinglesToFolder bool `toml:"add_singles_to_folder"` + FolderFormat string `toml:"folder_format"` + TrackFormat string `toml:"track_format"` + RestrictCharacters bool `toml:"restrict_characters"` + TruncateTo int `toml:"truncate_to"` +} + +type LastFMConfig struct { + Source string `toml:"source"` + FallbackSource string `toml:"fallback_source"` +} + +type CLIConfig struct { + TextOutput bool `toml:"text_output"` + ProgressBars bool `toml:"progress_bars"` + MaxSearchResults int `toml:"max_search_results"` +} + +type MiscConfig struct { + Version string `toml:"version"` + CheckForUpdates bool `toml:"check_for_updates"` +} + +func Load(path string) (*Config, error) { + resolvedPath, err := resolvePath(path) + if err != nil { + return nil, err + } + + if _, err = os.Stat(resolvedPath); errors.Is(err, os.ErrNotExist) { + cfg := DefaultConfigData() + if err = saveConfigData(resolvedPath, cfg); err != nil { + return nil, err + } + return &Config{Path: resolvedPath, File: cfg, Session: cloneConfigData(cfg)}, nil + } + if err != nil { + return nil, err + } + + raw, err := os.ReadFile(resolvedPath) + if err != nil { + return nil, err + } + + var data ConfigData + if err = toml.Unmarshal(raw, &data); err != nil { + return nil, err + } + applyRuntimeDefaults(&data) + + if data.Misc.Version != CurrentConfigVersion { + return nil, fmt.Errorf("%w: need to update from %q to %q", ErrOutdatedConfig, data.Misc.Version, CurrentConfigVersion) + } + + return &Config{Path: resolvedPath, File: data, Session: cloneConfigData(data)}, nil +} + +func (c *Config) SaveFile() error { + return saveConfigData(c.Path, c.File) +} + +func DefaultConfigData() ConfigData { + home, _ := os.UserHomeDir() + appDir := defaultAppDir() + downloadsFolder := filepath.Join(home, "StreamripDownloads") + + data := ConfigData{ + Downloads: DownloadsConfig{ + Folder: downloadsFolder, + SourceSubdirectories: false, + DiscSubdirectories: true, + Concurrency: true, + MaxConnections: 6, + RequestsPerMinute: 60, + VerifySSL: true, + }, + Qobuz: QobuzConfig{ + Quality: 3, + DownloadBooklets: true, + UseAuthToken: false, + }, + Tidal: TidalConfig{ + Quality: 3, + DownloadVideos: true, + }, + Deezer: DeezerConfig{ + Quality: 2, + LowerQualityIfNotAvailable: true, + UseDeezloader: true, + DeezloaderWarnings: true, + }, + Soundcloud: SoundcloudConfig{ + Quality: 0, + }, + Youtube: YoutubeConfig{ + Quality: 0, + DownloadVideos: false, + VideoDownloadsFolder: filepath.Join(downloadsFolder, "YouTubeVideos"), + }, + Database: DatabaseConfig{ + DownloadsEnabled: true, + DownloadsPath: filepath.Join(appDir, "downloads.db"), + FailedDownloadsEnabled: true, + FailedDownloadsPath: filepath.Join(appDir, "failed_downloads.db"), + }, + Conversion: ConversionConfig{ + Enabled: false, + Codec: "ALAC", + SamplingRate: 48000, + BitDepth: 24, + LossyBitrate: 320, + }, + QobuzFilters: QobuzDiscographyFilterConfig{}, + Artwork: ArtworkConfig{ + Embed: true, + EmbedSize: "large", + EmbedMaxWidth: -1, + SaveArtwork: true, + SavedMaxWidth: -1, + }, + Metadata: MetadataConfig{ + SetPlaylistToAlbum: true, + RenumberPlaylistTracks: true, + Exclude: []string{}, + }, + Filepaths: FilepathsConfig{ + AddSinglesToFolder: false, + FolderFormat: "{albumartist} - {title} ({year}) [{container}] [{bit_depth}B-{sampling_rate}kHz]", + TrackFormat: "{tracknumber:02}. {artist} - {title}{explicit}", + RestrictCharacters: false, + TruncateTo: 120, + }, + LastFM: LastFMConfig{ + Source: "qobuz", + }, + CLI: CLIConfig{ + TextOutput: true, + ProgressBars: true, + MaxSearchResults: 100, + }, + Misc: MiscConfig{ + Version: CurrentConfigVersion, + CheckForUpdates: true, + }, + } + + return data +} + +func resolvePath(path string) (string, error) { + if path != "" { + return path, os.MkdirAll(filepath.Dir(path), 0o755) + } + appDir := defaultAppDir() + if err := os.MkdirAll(appDir, 0o755); err != nil { + return "", err + } + return filepath.Join(appDir, "config.toml"), nil +} + +func defaultAppDir() string { + base, err := os.UserConfigDir() + if err != nil { + return "." + } + return filepath.Join(base, "streamrip") +} + +func saveConfigData(path string, data ConfigData) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + b, err := toml.Marshal(data) + if err != nil { + return err + } + return os.WriteFile(path, b, 0o644) +} + +func applyRuntimeDefaults(data *ConfigData) { + home, _ := os.UserHomeDir() + appDir := defaultAppDir() + if data.Downloads.Folder == "" { + data.Downloads.Folder = filepath.Join(home, "StreamripDownloads") + } + if data.Database.DownloadsPath == "" { + data.Database.DownloadsPath = filepath.Join(appDir, "downloads.db") + } + if data.Database.FailedDownloadsPath == "" { + data.Database.FailedDownloadsPath = filepath.Join(appDir, "failed_downloads.db") + } + if data.Youtube.VideoDownloadsFolder == "" { + data.Youtube.VideoDownloadsFolder = filepath.Join(data.Downloads.Folder, "YouTubeVideos") + } +} + +func cloneConfigData(in ConfigData) ConfigData { + out := in + out.Qobuz.Secrets = append([]string(nil), in.Qobuz.Secrets...) + out.Metadata.Exclude = append([]string(nil), in.Metadata.Exclude...) + return out +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..c5df28e --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,81 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestDefaultConfigData(t *testing.T) { + data := DefaultConfigData() + if data.Misc.Version != CurrentConfigVersion { + t.Fatalf("version = %q, want %q", data.Misc.Version, CurrentConfigVersion) + } + if data.Downloads.Folder == "" { + t.Fatalf("downloads folder should not be empty") + } + if data.Database.DownloadsPath == "" || data.Database.FailedDownloadsPath == "" { + t.Fatalf("database paths should not be empty") + } +} + +func TestLoadCreatesDefaultConfigWhenMissing(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "config.toml") + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.Path != path { + t.Fatalf("path = %q, want %q", cfg.Path, path) + } + if _, err = os.Stat(path); err != nil { + t.Fatalf("expected created config file: %v", err) + } +} + +func TestLoadOutdatedConfig(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "config.toml") + + data := DefaultConfigData() + data.Misc.Version = "1.0.0" + if err := saveConfigData(path, data); err != nil { + t.Fatalf("saveConfigData() error = %v", err) + } + + _, err := Load(path) + if !errors.Is(err, ErrOutdatedConfig) { + t.Fatalf("Load() error = %v, want ErrOutdatedConfig", err) + } +} + +func TestSessionCloneDoesNotAliasSlices(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "config.toml") + + data := DefaultConfigData() + data.Metadata.Exclude = []string{"lyrics"} + data.Qobuz.Secrets = []string{"s1"} + if err := saveConfigData(path, data); err != nil { + t.Fatalf("saveConfigData() error = %v", err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + cfg.Session.Metadata.Exclude[0] = "comment" + cfg.Session.Qobuz.Secrets[0] = "s2" + + if cfg.File.Metadata.Exclude[0] != "lyrics" { + t.Fatalf("file metadata exclude unexpectedly mutated") + } + if cfg.File.Qobuz.Secrets[0] != "s1" { + t.Fatalf("file qobuz secrets unexpectedly mutated") + } +} diff --git a/internal/domain/media/media.go b/internal/domain/media/media.go new file mode 100644 index 0000000..62a18aa --- /dev/null +++ b/internal/domain/media/media.go @@ -0,0 +1,33 @@ +package media + +import "context" + +type Media interface { + Rip(ctx context.Context) error +} + +type Pending interface { + Resolve(ctx context.Context) (Media, error) +} + +type MediaFunc struct { + RipFn func(ctx context.Context) error +} + +func (m MediaFunc) Rip(ctx context.Context) error { + if m.RipFn == nil { + return nil + } + return m.RipFn(ctx) +} + +type PendingFunc struct { + ResolveFn func(ctx context.Context) (Media, error) +} + +func (p PendingFunc) Resolve(ctx context.Context) (Media, error) { + if p.ResolveFn == nil { + return MediaFunc{}, nil + } + return p.ResolveFn(ctx) +} diff --git a/internal/download/downloader.go b/internal/download/downloader.go new file mode 100644 index 0000000..402f410 --- /dev/null +++ b/internal/download/downloader.go @@ -0,0 +1,200 @@ +package download + +import ( + "bufio" + "context" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "time" + + "github.com/vbauerster/mpb/v8" + "github.com/vbauerster/mpb/v8/decor" + "golang.org/x/term" + + "streamrip-go/internal/netutil" +) + +type Downloader struct { + http *http.Client + showProgress bool + progress *mpb.Progress + barStarted atomic.Int32 +} + +func New() *Downloader { + return NewWithOptions(true, true) +} + +func NewWithVerifySSL(verifySSL bool) *Downloader { + return NewWithOptions(verifySSL, true) +} + +func NewWithOptions(verifySSL bool, showProgress bool) *Downloader { + forceProgress := strings.EqualFold(os.Getenv("STREAMRIP_GO_FORCE_PROGRESS"), "1") || strings.EqualFold(os.Getenv("STREAMRIP_GO_FORCE_PROGRESS"), "true") + interactive := showProgress && (forceProgress || (term.IsTerminal(int(os.Stderr.Fd())) && strings.ToLower(os.Getenv("TERM")) != "dumb")) + d := &Downloader{http: netutil.NewHTTPClient(2*time.Minute, verifySSL), showProgress: interactive} + if interactive { + d.progress = mpb.New(mpb.WithWidth(40), mpb.WithOutput(os.Stderr)) + } + return d +} + +func (d *Downloader) File(ctx context.Context, sourceURL, outputPath string) error { + return d.file(ctx, sourceURL, outputPath, true) +} + +func (d *Downloader) FileNoProgress(ctx context.Context, sourceURL, outputPath string) error { + return d.file(ctx, sourceURL, outputPath, false) +} + +func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, allowProgress bool) error { + if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil) + if err != nil { + return err + } + + resp, err := d.http.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download failed: status=%d", resp.StatusCode) + } + + reader := bufio.NewReader(resp.Body) + peek, _ := reader.Peek(1024) + if isManifestResponse(resp.Header.Get("Content-Type"), peek) { + _ = resp.Body.Close() + return d.streamManifestWithFFmpeg(ctx, sourceURL, outputPath) + } + + out, err := os.Create(outputPath) + if err != nil { + return err + } + defer func() { _ = out.Close() }() + + if d.ProgressEnabled() && allowProgress && resp.ContentLength > 0 { + d.barStarted.Store(1) + desc := shortenName(filepath.Base(outputPath), 54) + bar := d.progress.AddBar( + resp.ContentLength, + mpb.PrependDecorators( + decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}), + decor.Percentage(decor.WCSyncWidthR), + ), + mpb.AppendDecorators( + decor.CountersKibiByte("% .1f / % .1f", decor.WCSyncWidthR), + decor.Name(" | ", decor.WCSyncWidth), + decor.AverageSpeed(decor.SizeB1024(0), "% .1f", decor.WCSyncWidthR), + decor.Name(" | ETA ", decor.WCSyncWidth), + decor.AverageETA(decor.ET_STYLE_GO, decor.WCSyncWidthR), + ), + mpb.BarRemoveOnComplete(), + ) + buf := make([]byte, 256*1024) + for { + n, readErr := reader.Read(buf) + if n > 0 { + if _, writeErr := out.Write(buf[:n]); writeErr != nil { + return writeErr + } + bar.IncrBy(n) + } + if readErr != nil { + if readErr == io.EOF { + break + } + return readErr + } + } + } else { + if _, err = io.Copy(out, reader); err != nil { + return err + } + } + + return nil +} + +func (d *Downloader) Close() { + if d.progress != nil { + d.progress.Wait() + } +} + +func (d *Downloader) ProgressEnabled() bool { + return d.showProgress && d.progress != nil +} + +func (d *Downloader) Logf(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + if d.ProgressEnabled() && d.barStarted.Load() == 1 { + _, _ = d.progress.Write([]byte(msg)) + return + } + fmt.Print(msg) +} + +func shortenName(name string, max int) string { + if max <= 0 { + return name + } + r := []rune(name) + if len(r) <= max { + return name + } + if max <= 3 { + return string(r[:max]) + } + return string(r[:max-3]) + "..." +} + +func (d *Downloader) streamManifestWithFFmpeg(ctx context.Context, sourceURL, outputPath string) error { + if _, err := exec.LookPath("ffmpeg"); err != nil { + return fmt.Errorf("ffmpeg not found for manifest stream: %w", err) + } + + args := []string{ + "-y", + "-protocol_whitelist", "file,http,https,tcp,tls,crypto,data", + "-i", sourceURL, + "-map", "0:a:0", + "-c", "copy", + outputPath, + } + + cmd := exec.CommandContext(ctx, "ffmpeg", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("ffmpeg stream copy failed: %w: %s", err, string(output)) + } + return nil +} + +func isManifestResponse(contentType string, peek []byte) bool { + ct := strings.ToLower(contentType) + if strings.Contains(ct, "dash+xml") || strings.Contains(ct, "mpegurl") || strings.Contains(ct, "vnd.apple.mpegurl") { + return true + } + s := strings.TrimSpace(strings.ToLower(string(peek))) + if strings.HasPrefix(s, "")) { + t.Fatalf("expected MPD XML body to be manifest") + } + if !isManifestResponse("text/plain", []byte("#EXTM3U\n#EXT-X-VERSION:3")) { + t.Fatalf("expected HLS body to be manifest") + } + if isManifestResponse("audio/flac", []byte("fLaC")) { + t.Fatalf("did not expect flac to be manifest") + } +} diff --git a/internal/naming/naming.go b/internal/naming/naming.go new file mode 100644 index 0000000..411a61e --- /dev/null +++ b/internal/naming/naming.go @@ -0,0 +1,74 @@ +package naming + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "unicode" +) + +type Config struct { + RestrictCharacters bool + TruncateTo int +} + +var tokenRe = regexp.MustCompile(`\{([a-z_]+)(?::0?(\d+))?\}`) +var invalidPathRe = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1F]`) + +func FormatTemplate(template string, values map[string]string) string { + return tokenRe.ReplaceAllStringFunc(template, func(m string) string { + groups := tokenRe.FindStringSubmatch(m) + if len(groups) < 2 { + return m + } + key := groups[1] + val := values[key] + if len(groups) >= 3 && groups[2] != "" { + if n, err := strconv.Atoi(val); err == nil { + if width, widthErr := strconv.Atoi(groups[2]); widthErr == nil { + return fmt.Sprintf("%0*d", width, n) + } + } + } + return val + }) +} + +func CleanName(in string, cfg Config) string { + s := strings.TrimSpace(in) + s = invalidPathRe.ReplaceAllString(s, "_") + if cfg.RestrictCharacters { + r := make([]rune, 0, len(s)) + for _, ch := range s { + if ch >= 32 && ch <= 126 { + r = append(r, ch) + } + } + s = string(r) + } + if cfg.TruncateTo > 0 { + runes := []rune(s) + if len(runes) > cfg.TruncateTo { + s = string(runes[:cfg.TruncateTo]) + } + } + s = strings.TrimSpace(s) + if s == "" { + return "Unknown" + } + return s +} + +func YearFromDate(date string) string { + if len(date) >= 4 { + prefix := date[:4] + for _, ch := range prefix { + if !unicode.IsDigit(ch) { + return "Unknown" + } + } + return prefix + } + return "Unknown" +} diff --git a/internal/naming/naming_test.go b/internal/naming/naming_test.go new file mode 100644 index 0000000..a665a65 --- /dev/null +++ b/internal/naming/naming_test.go @@ -0,0 +1,22 @@ +package naming + +import "testing" + +func TestFormatTemplate(t *testing.T) { + got := FormatTemplate("{tracknumber:02}. {artist} - {title}{explicit}", map[string]string{ + "tracknumber": "3", + "artist": "Fleetwood Mac", + "title": "Dreams", + "explicit": "", + }) + if got != "03. Fleetwood Mac - Dreams" { + t.Fatalf("got %q", got) + } +} + +func TestCleanName(t *testing.T) { + got := CleanName(" Dreams/Live ", Config{RestrictCharacters: false, TruncateTo: 120}) + if got != "Dreams_Live" { + t.Fatalf("got %q", got) + } +} diff --git a/internal/netutil/http.go b/internal/netutil/http.go new file mode 100644 index 0000000..54436b0 --- /dev/null +++ b/internal/netutil/http.go @@ -0,0 +1,20 @@ +package netutil + +import ( + "crypto/tls" + "net/http" + "time" +) + +func NewHTTPClient(timeout time.Duration, verifySSL bool) *http.Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + if transport.TLSClientConfig == nil { + transport.TLSClientConfig = &tls.Config{} + } + transport.TLSClientConfig.InsecureSkipVerify = !verifySSL + + return &http.Client{ + Timeout: timeout, + Transport: transport, + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..f6c6cfa --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,19 @@ +package provider + +import "context" + +type Downloadable struct { + URL string + Extension string + Source string +} + +type Client interface { + Source() string + Login(ctx context.Context) error + LoggedIn() bool + GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error) + Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) + GetDownloadable(ctx context.Context, item string, quality int) (*Downloadable, error) + Close() error +} diff --git a/internal/provider/qobuz/client.go b/internal/provider/qobuz/client.go new file mode 100644 index 0000000..a8da79b --- /dev/null +++ b/internal/provider/qobuz/client.go @@ -0,0 +1,586 @@ +package qobuz + +import ( + "context" + "crypto/md5" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "streamrip-go/internal/config" + "streamrip-go/internal/netutil" + "streamrip-go/internal/provider" + "streamrip-go/internal/ratelimit" +) + +const baseURL = "https://www.qobuz.com/api.json/0.2" + +var ( + errMissingCredentials = errors.New("missing qobuz credentials") + errNotLoggedIn = errors.New("qobuz client not logged in") +) + +type Client struct { + cfg *config.Config + http *http.Client + limiter *ratelimit.Limiter + baseURL string + loggedIn bool + secret string + uat string +} + +func New(cfg *config.Config) *Client { + return &Client{ + cfg: cfg, + http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL), + limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute), + baseURL: baseURL, + } +} + +func (c *Client) Source() string { + return "qobuz" +} + +func (c *Client) LoggedIn() bool { + return c.loggedIn +} + +func (c *Client) Login(ctx context.Context) error { + q := &c.cfg.Session.Qobuz + if q.EmailOrUserID == "" || q.PasswordOrToken == "" { + return errMissingCredentials + } + + if q.AppID == "" || len(q.Secrets) == 0 { + appID, secrets, err := c.fetchAppIDAndSecrets(ctx) + if err != nil { + return err + } + q.AppID = appID + q.Secrets = secrets + c.cfg.File.Qobuz.AppID = appID + c.cfg.File.Qobuz.Secrets = append([]string(nil), secrets...) + _ = c.cfg.SaveFile() + } + + headers := map[string]string{"X-App-Id": q.AppID} + params := url.Values{} + params.Set("app_id", q.AppID) + if q.UseAuthToken { + params.Set("user_id", q.EmailOrUserID) + params.Set("user_auth_token", q.PasswordOrToken) + } else { + params.Set("email", q.EmailOrUserID) + params.Set("password", q.PasswordOrToken) + } + + resp, status, err := c.apiRequest(ctx, "user/login", params, headers) + if err != nil { + return err + } + if status != http.StatusOK { + return fmt.Errorf("qobuz login failed: status=%d body=%v", status, resp) + } + + uat, _ := resp["user_auth_token"].(string) + if uat == "" { + return fmt.Errorf("qobuz login missing user_auth_token") + } + + headers["X-User-Auth-Token"] = uat + validSecret, err := c.getValidSecret(ctx, q.Secrets, headers) + if err != nil { + return err + } + + c.secret = validSecret + c.uat = uat + c.loggedIn = true + + return nil +} + +func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error) { + if !c.loggedIn { + return nil, errNotLoggedIn + } + if mediaType == "playlist" { + return c.getPlaylist(ctx, item) + } + if mediaType == "label" { + return c.getLabel(ctx, item) + } + + params := url.Values{} + params.Set("app_id", c.cfg.Session.Qobuz.AppID) + params.Set(mediaType+"_id", item) + params.Set("limit", "500") + params.Set("offset", "0") + + switch mediaType { + case "artist": + params.Set("extra", "albums") + case "playlist": + params.Set("extra", "tracks") + case "label": + params.Set("extra", "albums") + } + + resp, status, err := c.apiRequest(ctx, mediaType+"/get", params, c.authHeaders()) + if err != nil { + return nil, err + } + if status != http.StatusOK { + msg, _ := resp["message"].(string) + if msg == "" { + msg = "non-streamable" + } + return nil, fmt.Errorf("metadata error: %s", msg) + } + + return resp, nil +} + +func (c *Client) GetTrackMetadata(ctx context.Context, id string) (*TrackMetadata, error) { + raw, err := c.GetMetadata(ctx, id, "track") + if err != nil { + return nil, err + } + return ParseTrackMetadata(raw) +} + +func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) { + if !c.loggedIn { + return nil, errNotLoggedIn + } + if limit <= 0 { + limit = 100 + } + + params := url.Values{} + params.Set("query", query) + params.Set("limit", strconv.Itoa(limit)) + + resp, status, err := c.apiRequest(ctx, mediaType+"/search", params, c.authHeaders()) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("search failed: status=%d", status) + } + + return []map[string]any{resp}, nil +} + +func (c *Client) GetDownloadable(ctx context.Context, item string, quality int) (*provider.Downloadable, error) { + if !c.loggedIn { + return nil, errNotLoggedIn + } + if quality < 1 || quality > 4 { + quality = c.cfg.Session.Qobuz.Quality + } + + formatID := qualityMap(quality) + requestTS := strconv.FormatInt(time.Now().Unix(), 10) + sigRaw := "trackgetFileUrlformat_id" + strconv.Itoa(formatID) + "intentstreamtrack_id" + item + requestTS + c.secret + hash := md5.Sum([]byte(sigRaw)) + requestSig := hex.EncodeToString(hash[:]) + + params := url.Values{} + params.Set("request_ts", requestTS) + params.Set("request_sig", requestSig) + params.Set("track_id", item) + params.Set("format_id", strconv.Itoa(formatID)) + params.Set("intent", "stream") + + resp, status, err := c.apiRequest(ctx, "track/getFileUrl", params, c.authHeaders()) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("downloadable lookup failed: status=%d body=%v", status, resp) + } + + streamURL, _ := resp["url"].(string) + if streamURL == "" { + return nil, fmt.Errorf("track is not streamable") + } + + ext := "mp3" + if quality > 1 { + ext = "flac" + } + + return &provider.Downloadable{ + URL: streamURL, + Extension: ext, + Source: "qobuz", + }, nil +} + +func (c *Client) Close() error { + return nil +} + +func (c *Client) getPlaylist(ctx context.Context, playlistID string) (map[string]any, error) { + pageLimit := 500 + params := url.Values{} + params.Set("app_id", c.cfg.Session.Qobuz.AppID) + params.Set("playlist_id", playlistID) + params.Set("limit", strconv.Itoa(pageLimit)) + params.Set("offset", "0") + params.Set("extra", "tracks") + + resp, status, err := c.apiRequest(ctx, "playlist/get", params, c.authHeaders()) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("playlist/get failed: status=%d", status) + } + + total, _ := intValue(resp["tracks_count"]) + if total <= pageLimit { + return resp, nil + } + + tracksObj, ok := mapValue(resp["tracks"]) + if !ok { + return resp, nil + } + items, ok := tracksObj["items"].([]any) + if !ok { + return resp, nil + } + + for offset := pageLimit; offset < total; offset += pageLimit { + pageParams := url.Values{} + pageParams.Set("app_id", c.cfg.Session.Qobuz.AppID) + pageParams.Set("playlist_id", playlistID) + pageParams.Set("limit", strconv.Itoa(pageLimit)) + pageParams.Set("offset", strconv.Itoa(offset)) + pageParams.Set("extra", "tracks") + + pageResp, pageStatus, pageErr := c.apiRequest(ctx, "playlist/get", pageParams, c.authHeaders()) + if pageErr != nil { + return nil, pageErr + } + if pageStatus != http.StatusOK { + return nil, fmt.Errorf("playlist/get pagination failed: status=%d offset=%d", pageStatus, offset) + } + pageTracks, ok := mapValue(pageResp["tracks"]) + if !ok { + continue + } + pageItems, ok := pageTracks["items"].([]any) + if !ok { + continue + } + items = append(items, pageItems...) + } + + tracksObj["items"] = items + resp["tracks"] = tracksObj + return resp, nil +} + +func (c *Client) getLabel(ctx context.Context, labelID string) (map[string]any, error) { + pageLimit := 500 + params := url.Values{} + params.Set("app_id", c.cfg.Session.Qobuz.AppID) + params.Set("label_id", labelID) + params.Set("limit", strconv.Itoa(pageLimit)) + params.Set("offset", "0") + params.Set("extra", "albums") + + resp, status, err := c.apiRequest(ctx, "label/get", params, c.authHeaders()) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("label/get failed: status=%d", status) + } + + total, _ := intValue(resp["albums_count"]) + if total <= pageLimit { + return resp, nil + } + + albumsObj, ok := mapValue(resp["albums"]) + if !ok { + return resp, nil + } + items, ok := albumsObj["items"].([]any) + if !ok { + return resp, nil + } + + for offset := pageLimit; offset < total; offset += pageLimit { + pageParams := url.Values{} + pageParams.Set("app_id", c.cfg.Session.Qobuz.AppID) + pageParams.Set("label_id", labelID) + pageParams.Set("limit", strconv.Itoa(pageLimit)) + pageParams.Set("offset", strconv.Itoa(offset)) + pageParams.Set("extra", "albums") + + pageResp, pageStatus, pageErr := c.apiRequest(ctx, "label/get", pageParams, c.authHeaders()) + if pageErr != nil { + return nil, pageErr + } + if pageStatus != http.StatusOK { + return nil, fmt.Errorf("label/get pagination failed: status=%d offset=%d", pageStatus, offset) + } + pageAlbums, ok := mapValue(pageResp["albums"]) + if !ok { + continue + } + pageItems, ok := pageAlbums["items"].([]any) + if !ok { + continue + } + items = append(items, pageItems...) + } + + albumsObj["items"] = items + resp["albums"] = albumsObj + return resp, nil +} + +func (c *Client) authHeaders() map[string]string { + headers := map[string]string{"X-App-Id": c.cfg.Session.Qobuz.AppID} + if c.uat != "" { + headers["X-User-Auth-Token"] = c.uat + } else if c.cfg.Session.Qobuz.PasswordOrToken != "" && c.cfg.Session.Qobuz.UseAuthToken { + headers["X-User-Auth-Token"] = c.cfg.Session.Qobuz.PasswordOrToken + } + return headers +} + +func (c *Client) getValidSecret(ctx context.Context, secrets []string, headers map[string]string) (string, error) { + type candidate struct { + secret string + valid bool + } + + results := make([]candidate, 0, len(secrets)) + for _, secret := range secrets { + ok := c.testSecret(ctx, secret, headers) + results = append(results, candidate{secret: secret, valid: ok}) + } + + for _, result := range results { + if result.valid { + return result.secret, nil + } + } + + return "", fmt.Errorf("no valid qobuz app secret") +} + +func (c *Client) testSecret(ctx context.Context, secret string, headers map[string]string) bool { + formatID := qualityMap(4) + requestTS := strconv.FormatInt(time.Now().Unix(), 10) + sigRaw := "trackgetFileUrlformat_id" + strconv.Itoa(formatID) + "intentstreamtrack_id19512574" + requestTS + secret + hash := md5.Sum([]byte(sigRaw)) + + params := url.Values{} + params.Set("request_ts", requestTS) + params.Set("request_sig", hex.EncodeToString(hash[:])) + params.Set("track_id", "19512574") + params.Set("format_id", strconv.Itoa(formatID)) + params.Set("intent", "stream") + + _, status, err := c.apiRequest(ctx, "track/getFileUrl", params, headers) + if err != nil { + return false + } + return status == http.StatusOK || status == http.StatusUnauthorized +} + +func (c *Client) apiRequest(ctx context.Context, endpoint string, params url.Values, headers map[string]string) (map[string]any, int, error) { + if err := c.limiter.Wait(ctx); err != nil { + return nil, 0, err + } + + reqURL := baseURL + "/" + endpoint + if c.baseURL != "" { + reqURL = c.baseURL + "/" + endpoint + } + if len(params) > 0 { + reqURL += "?" + params.Encode() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, 0, err + } + for k, v := range headers { + req.Header.Set(k, v) + } + req.Header.Set("User-Agent", "streamrip-go/0.1") + + resp, err := c.http.Do(req) + if err != nil { + return nil, 0, err + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, err + } + + parsed := map[string]any{} + if len(body) > 0 { + if err = json.Unmarshal(body, &parsed); err != nil { + return nil, resp.StatusCode, err + } + } + + return parsed, resp.StatusCode, nil +} + +func qualityMap(quality int) int { + mapVals := []int{5, 6, 7, 27} + if quality < 1 || quality > 4 { + return mapVals[2] + } + return mapVals[quality-1] +} + +func (c *Client) fetchAppIDAndSecrets(ctx context.Context) (string, []string, error) { + loginReq, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://play.qobuz.com/login", nil) + if err != nil { + return "", nil, err + } + loginResp, err := c.http.Do(loginReq) + if err != nil { + return "", nil, err + } + defer func() { _ = loginResp.Body.Close() }() + + loginBody, err := io.ReadAll(loginResp.Body) + if err != nil { + return "", nil, err + } + + bundleRe := regexp.MustCompile(``) + bundleMatch := bundleRe.FindStringSubmatch(string(loginBody)) + if len(bundleMatch) < 2 { + return "", nil, fmt.Errorf("could not find qobuz bundle js") + } + + bundleURL := "https://play.qobuz.com" + bundleMatch[1] + bundleReq, err := http.NewRequestWithContext(ctx, http.MethodGet, bundleURL, nil) + if err != nil { + return "", nil, err + } + bundleResp, err := c.http.Do(bundleReq) + if err != nil { + return "", nil, err + } + defer func() { _ = bundleResp.Body.Close() }() + bundleBody, err := io.ReadAll(bundleResp.Body) + if err != nil { + return "", nil, err + } + + bundle := string(bundleBody) + appIDRe := regexp.MustCompile(`production:{api:{appId:"(?P\d{9})",appSecret:"(\w{32})`) + appIDMatch := appIDRe.FindStringSubmatch(bundle) + if len(appIDMatch) < 2 { + return "", nil, fmt.Errorf("could not parse qobuz app id") + } + appID := appIDMatch[1] + + seedTZRe := regexp.MustCompile(`[a-z]\.initialSeed\("(?P[\w=]+)",window\.utimezone\.(?P[a-z]+)\)`) + infoExtrasTemplate := `name:"\w+/(?P%s)",info:"(?P[\w=]+)",extras:"(?P[\w=]+)"` + + type seedParts struct { + timezone string + parts []string + } + + matches := seedTZRe.FindAllStringSubmatch(bundle, -1) + idxSeed := seedTZRe.SubexpIndex("seed") + idxTZ := seedTZRe.SubexpIndex("timezone") + if len(matches) < 2 { + return appID, nil, fmt.Errorf("could not parse qobuz secrets seeds") + } + + ordered := make([]seedParts, 0, len(matches)) + seen := map[string]bool{} + for _, m := range matches { + tz := m[idxTZ] + seed := m[idxSeed] + if !seen[tz] { + ordered = append(ordered, seedParts{timezone: tz, parts: []string{seed}}) + seen[tz] = true + } + } + if len(ordered) >= 2 { + ordered[0], ordered[1] = ordered[1], ordered[0] + } + + tzNames := make([]string, 0, len(ordered)) + for _, o := range ordered { + tzNames = append(tzNames, strings.Title(o.timezone)) + } + infoRe := regexp.MustCompile(fmt.Sprintf(infoExtrasTemplate, strings.Join(tzNames, "|"))) + idxInfo := infoRe.SubexpIndex("info") + idxExtras := infoRe.SubexpIndex("extras") + idxInfoTZ := infoRe.SubexpIndex("timezone") + + byTZ := map[string][]string{} + for _, o := range ordered { + byTZ[o.timezone] = append([]string(nil), o.parts...) + } + + for _, m := range infoRe.FindAllStringSubmatch(bundle, -1) { + tz := strings.ToLower(m[idxInfoTZ]) + byTZ[tz] = append(byTZ[tz], m[idxInfo], m[idxExtras]) + } + + final := make([]string, 0, len(byTZ)) + for _, tz := range sortedKeys(byTZ) { + joined := strings.Join(byTZ[tz], "") + if len(joined) < 44 { + continue + } + dec, err := base64.StdEncoding.DecodeString(joined[:len(joined)-44]) + if err != nil { + continue + } + secret := string(dec) + if secret != "" { + final = append(final, secret) + } + } + + if len(final) == 0 { + return appID, nil, fmt.Errorf("could not decode qobuz secrets") + } + + return appID, final, nil +} + +func sortedKeys(m map[string][]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/provider/qobuz/client_test.go b/internal/provider/qobuz/client_test.go new file mode 100644 index 0000000..5aa7fe3 --- /dev/null +++ b/internal/provider/qobuz/client_test.go @@ -0,0 +1,174 @@ +package qobuz + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "streamrip-go/internal/config" +) + +func TestQualityMap(t *testing.T) { + tests := []struct { + in int + want int + }{ + {1, 5}, + {2, 6}, + {3, 7}, + {4, 27}, + {0, 7}, + {99, 7}, + } + + for _, tt := range tests { + got := qualityMap(tt.in) + if got != tt.want { + t.Fatalf("qualityMap(%d)=%d want %d", tt.in, got, tt.want) + } + } +} + +func TestParseTrackMetadata(t *testing.T) { + resp := map[string]any{ + "id": "19512574", + "title": "Dreams", + "version": "Remastered", + "track_number": float64(2), + "media_number": float64(1), + "parental_warning": false, + "maximum_bit_depth": float64(24), + "maximum_sampling_rate": float64(96), + "performer": map[string]any{ + "name": "Fleetwood Mac", + }, + "album": map[string]any{ + "title": "Rumours", + }, + } + + m, err := ParseTrackMetadata(resp) + if err != nil { + t.Fatalf("ParseTrackMetadata() error = %v", err) + } + if m.ID != "19512574" || m.Title != "Dreams" || m.Album != "Rumours" || m.Artist != "Fleetwood Mac" { + t.Fatalf("unexpected metadata: %+v", m) + } + if m.Quality != 3 { + t.Fatalf("quality = %d, want 3", m.Quality) + } +} + +func TestGetPlaylistPagination(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + offset := r.URL.Query().Get("offset") + if offset == "" { + offset = "0" + } + + resp := map[string]any{} + switch offset { + case "0": + resp = map[string]any{ + "tracks_count": 1200, + "tracks": map[string]any{"items": makeItems(0, 500)}, + } + case "500": + resp = map[string]any{"tracks": map[string]any{"items": makeItems(500, 1000)}} + case "1000": + resp = map[string]any{"tracks": map[string]any{"items": makeItems(1000, 1200)}} + default: + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{"message": "not found"}) + return + } + + _ = json.NewEncoder(w).Encode(resp) + })) + defer ts.Close() + + c := newTestClient(t) + c.loggedIn = true + c.baseURL = ts.URL + + raw, err := c.GetMetadata(context.Background(), "playlist-id", "playlist") + if err != nil { + t.Fatalf("GetMetadata() error = %v", err) + } + tracks, ok := mapValue(raw["tracks"]) + if !ok { + t.Fatalf("tracks missing") + } + items, ok := tracks["items"].([]any) + if !ok { + t.Fatalf("items missing") + } + if len(items) != 1200 { + t.Fatalf("len(items) = %d, want 1200", len(items)) + } +} + +func TestGetLabelPagination(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + offset := r.URL.Query().Get("offset") + if offset == "" { + offset = "0" + } + + resp := map[string]any{} + switch offset { + case "0": + resp = map[string]any{ + "albums_count": 700, + "albums": map[string]any{"items": makeItems(0, 500)}, + } + case "500": + resp = map[string]any{"albums": map[string]any{"items": makeItems(500, 700)}} + default: + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{"message": "not found"}) + return + } + + _ = json.NewEncoder(w).Encode(resp) + })) + defer ts.Close() + + c := newTestClient(t) + c.loggedIn = true + c.baseURL = ts.URL + + raw, err := c.GetMetadata(context.Background(), "label-id", "label") + if err != nil { + t.Fatalf("GetMetadata() error = %v", err) + } + albums, ok := mapValue(raw["albums"]) + if !ok { + t.Fatalf("albums missing") + } + items, ok := albums["items"].([]any) + if !ok { + t.Fatalf("items missing") + } + if len(items) != 700 { + t.Fatalf("len(items) = %d, want 700", len(items)) + } +} + +func newTestClient(t *testing.T) *Client { + t.Helper() + d := config.DefaultConfigData() + d.Qobuz.AppID = "12345" + cfg := &config.Config{File: d, Session: d} + return New(cfg) +} + +func makeItems(start, end int) []map[string]any { + items := make([]map[string]any, 0, end-start) + for i := start; i < end; i++ { + items = append(items, map[string]any{"id": i}) + } + return items +} diff --git a/internal/provider/qobuz/model.go b/internal/provider/qobuz/model.go new file mode 100644 index 0000000..6eee65b --- /dev/null +++ b/internal/provider/qobuz/model.go @@ -0,0 +1,102 @@ +package qobuz + +import "fmt" + +type TrackMetadata struct { + ID string + Title string + Version string + Artist string + Album string + TrackNumber int + DiscNumber int + Explicit bool + BitDepth int + SamplingRate float64 + Quality int +} + +func ParseTrackMetadata(resp map[string]any) (*TrackMetadata, error) { + id, ok := stringValue(resp["id"]) + if !ok || id == "" { + return nil, fmt.Errorf("missing track id") + } + + title, _ := stringValue(resp["title"]) + version, _ := stringValue(resp["version"]) + trackNumber, _ := intValue(resp["track_number"]) + discNumber, _ := intValue(resp["media_number"]) + explicit, _ := boolValue(resp["parental_warning"]) + + performer, _ := mapValue(resp["performer"]) + artist, _ := stringValue(performer["name"]) + + albumObj, _ := mapValue(resp["album"]) + album, _ := stringValue(albumObj["title"]) + + bitDepth, _ := intValue(resp["maximum_bit_depth"]) + samplingRate, _ := floatValue(resp["maximum_sampling_rate"]) + quality := qualityFrom(bitDepth, samplingRate) + + return &TrackMetadata{ + ID: id, + Title: title, + Version: version, + Artist: artist, + Album: album, + TrackNumber: trackNumber, + DiscNumber: discNumber, + Explicit: explicit, + BitDepth: bitDepth, + SamplingRate: samplingRate, + Quality: quality, + }, nil +} + +func qualityFrom(bitDepth int, samplingRate float64) int { + if bitDepth >= 24 { + if samplingRate > 96 { + return 4 + } + return 3 + } + if bitDepth >= 16 { + return 2 + } + return 1 +} + +func stringValue(v any) (string, bool) { + s, ok := v.(string) + return s, ok +} + +func mapValue(v any) (map[string]any, bool) { + m, ok := v.(map[string]any) + return m, ok +} + +func intValue(v any) (int, bool) { + switch t := v.(type) { + case int: + return t, true + case int32: + return int(t), true + case int64: + return int(t), true + case float64: + return int(t), true + default: + return 0, false + } +} + +func floatValue(v any) (float64, bool) { + f, ok := v.(float64) + return f, ok +} + +func boolValue(v any) (bool, bool) { + b, ok := v.(bool) + return b, ok +} diff --git a/internal/provider/tidal/client.go b/internal/provider/tidal/client.go new file mode 100644 index 0000000..c3e3fa5 --- /dev/null +++ b/internal/provider/tidal/client.go @@ -0,0 +1,549 @@ +package tidal + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "streamrip-go/internal/config" + "streamrip-go/internal/netutil" + "streamrip-go/internal/provider" + "streamrip-go/internal/ratelimit" +) + +const ( + baseURL = "https://api.tidalhifi.com/v1" + openAPIV2 = "https://openapi.tidal.com/v2" + authURL = "https://auth.tidal.com/v1/oauth2" + clientID = "fX2JxdmntZWK0ixT" + clientSec = "1Nm5AfDAjxrgJFJbKNWLeAyKGVGmINuXPPLHVXAvxAg=" +) + +var qualityMap = map[int]string{ + 0: "LOW", + 1: "HIGH", + 2: "LOSSLESS", + 3: "HI_RES", + 4: "HI_RES_LOSSLESS", +} + +var qualityToFormat = map[int]string{ + 0: "HEAACV1", + 1: "AACLC", + 2: "FLAC", + 3: "FLAC_HIRES", + 4: "FLAC_HIRES", +} + +var ErrMissingTidalToken = errors.New("missing tidal access_token") + +type Client struct { + cfg *config.Config + http *http.Client + limiter *ratelimit.Limiter + baseURL string + loggedIn bool +} + +func New(cfg *config.Config) *Client { + return &Client{ + cfg: cfg, + http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL), + limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute), + baseURL: baseURL, + } +} + +func (c *Client) Source() string { + return "tidal" +} + +func (c *Client) LoggedIn() bool { + return c.loggedIn +} + +func (c *Client) Login(ctx context.Context) error { + if strings.TrimSpace(c.cfg.Session.Tidal.AccessToken) == "" { + return ErrMissingTidalToken + } + if strings.TrimSpace(c.cfg.Session.Tidal.CountryCode) == "" { + c.cfg.Session.Tidal.CountryCode = "US" + } + + if c.tokenNeedsRefresh() { + if err := c.refreshAccessToken(ctx); err != nil { + return err + } + } + + resp, status, err := c.apiRequest(ctx, "sessions", url.Values{}, c.baseURL) + if err != nil { + return err + } + if status == http.StatusUnauthorized && strings.TrimSpace(c.cfg.Session.Tidal.RefreshToken) != "" { + if err = c.refreshAccessToken(ctx); err != nil { + return fmt.Errorf("tidal login failed and refresh failed: %w", err) + } + resp, status, err = c.apiRequest(ctx, "sessions", url.Values{}, c.baseURL) + if err != nil { + return err + } + } + if status != http.StatusOK { + return fmt.Errorf("tidal login failed: status=%d body=%v", status, resp) + } + + if v := stringify(resp["countryCode"]); v != "" { + c.cfg.Session.Tidal.CountryCode = v + } + if v := stringify(resp["userId"]); v != "" { + c.cfg.Session.Tidal.UserID = v + } + + c.loggedIn = true + return nil +} + +func (c *Client) tokenNeedsRefresh() bool { + expiry := c.cfg.Session.Tidal.TokenExpiry + if expiry <= 0 { + return false + } + return time.Until(time.Unix(expiry, 0)) < 24*time.Hour +} + +func (c *Client) refreshAccessToken(ctx context.Context) error { + refresh := strings.TrimSpace(c.cfg.Session.Tidal.RefreshToken) + if refresh == "" { + return errors.New("tidal refresh token missing") + } + + form := url.Values{} + form.Set("client_id", clientID) + form.Set("refresh_token", refresh) + form.Set("grant_type", "refresh_token") + form.Set("scope", "r_usr+w_usr+w_sub") + + resp, status, err := c.apiPost(ctx, authURL+"/token", form, true) + if err != nil { + return err + } + if status != http.StatusOK { + return fmt.Errorf("tidal token refresh failed: status=%d body=%v", status, resp) + } + + newToken := stringify(resp["access_token"]) + if newToken == "" { + return errors.New("tidal token refresh missing access_token") + } + + newRefresh := stringify(resp["refresh_token"]) + expiresIn := int64(intFromAny(resp["expires_in"])) + if expiresIn <= 0 { + expiresIn = 7 * 24 * 3600 + } + + c.cfg.Session.Tidal.AccessToken = newToken + c.cfg.File.Tidal.AccessToken = newToken + if newRefresh != "" { + c.cfg.Session.Tidal.RefreshToken = newRefresh + c.cfg.File.Tidal.RefreshToken = newRefresh + } + expiry := time.Now().Unix() + expiresIn + c.cfg.Session.Tidal.TokenExpiry = expiry + c.cfg.File.Tidal.TokenExpiry = expiry + _ = c.cfg.SaveFile() + + return nil +} + +func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error) { + if !c.loggedIn { + return nil, errors.New("tidal client not logged in") + } + + path := mediaType + "s/" + item + resp, status, err := c.apiRequest(ctx, path, url.Values{}, c.baseURL) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("tidal metadata failed: status=%d", status) + } + + if mediaType == "album" || mediaType == "playlist" { + itemsResp, itemErr := c.fetchAllItems(ctx, path+"/items") + if itemErr != nil { + return nil, fmt.Errorf("tidal fetch %s items failed: %w", mediaType, itemErr) + } + resp["tracks"] = map[string]any{"items": itemsResp} + } + + if mediaType == "artist" { + albums, err := c.fetchArtistAlbums(ctx, item) + if err != nil { + return nil, err + } + resp["albums"] = map[string]any{"items": albums} + } + + enrichTidalImage(resp) + if mediaType == "track" { + if album, ok := resp["album"].(map[string]any); ok { + enrichTidalImage(album) + } + } + + return resp, nil +} + +func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) { + if !c.loggedIn { + return nil, errors.New("tidal client not logged in") + } + if limit <= 0 { + limit = 25 + } + + params := url.Values{} + params.Set("query", query) + params.Set("limit", strconv.Itoa(limit)) + + resp, status, err := c.apiRequest(ctx, "search/"+mediaType+"s", params, c.baseURL) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("tidal search failed: status=%d", status) + } + + items, ok := resp["items"].([]any) + if !ok || len(items) == 0 { + return []map[string]any{}, nil + } + return []map[string]any{resp}, nil +} + +func (c *Client) GetDownloadable(ctx context.Context, trackID string, quality int) (*provider.Downloadable, error) { + if !c.loggedIn { + return nil, errors.New("tidal client not logged in") + } + if quality < 0 || quality > 4 { + quality = c.cfg.Session.Tidal.Quality + } + + params := url.Values{} + params.Set("audioquality", qualityMap[quality]) + params.Set("playbackmode", "STREAM") + params.Set("assetpresentation", "FULL") + + resp, status, err := c.apiRequest(ctx, "tracks/"+trackID+"/playbackinfopostpaywall", params, c.baseURL) + if err != nil { + return nil, err + } + if status == http.StatusOK { + if d := downloadableFromPlaybackManifest(resp); d != nil { + return d, nil + } + } + + return c.getDownloadableFromTrackManifest(ctx, trackID, quality) +} + +func (c *Client) Close() error { + return nil +} + +func (c *Client) fetchAllItems(ctx context.Context, path string) ([]map[string]any, error) { + offset := 0 + all := make([]map[string]any, 0) + for { + params := url.Values{} + params.Set("offset", strconv.Itoa(offset)) + resp, status, err := c.apiRequest(ctx, path, params, c.baseURL) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("tidal items failed: status=%d", status) + } + itemsRaw, ok := resp["items"].([]any) + if !ok || len(itemsRaw) == 0 { + break + } + for _, raw := range itemsRaw { + itemMap, ok := raw.(map[string]any) + if ok { + if wrapped, ok := itemMap["item"].(map[string]any); ok { + all = append(all, wrapped) + } else { + all = append(all, itemMap) + } + } + } + if len(itemsRaw) < 100 { + break + } + offset += 100 + } + return all, nil +} + +func (c *Client) fetchArtistAlbums(ctx context.Context, artistID string) ([]map[string]any, error) { + paths := []struct { + path string + params url.Values + }{ + {path: "artists/" + artistID + "/albums", params: url.Values{}}, + {path: "artists/" + artistID + "/albums", params: url.Values{"filter": []string{"EPSANDSINGLES"}}}, + } + + out := make([]map[string]any, 0) + seen := map[string]struct{}{} + for _, p := range paths { + resp, status, err := c.apiRequest(ctx, p.path, p.params, c.baseURL) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("tidal artist albums failed: status=%d", status) + } + items, _ := resp["items"].([]any) + for _, raw := range items { + itm, ok := raw.(map[string]any) + if !ok { + continue + } + if wrapped, ok := itm["item"].(map[string]any); ok { + itm = wrapped + } + id := stringify(itm["id"]) + if id == "" { + continue + } + if _, dup := seen[id]; dup { + continue + } + seen[id] = struct{}{} + out = append(out, itm) + } + } + return out, nil +} + +func (c *Client) getDownloadableFromTrackManifest(ctx context.Context, trackID string, quality int) (*provider.Downloadable, error) { + format := qualityToFormat[quality] + params := url.Values{} + params.Set("manifestType", "MPEG_DASH") + params.Set("formats", format) + params.Set("uriScheme", "HTTPS") + params.Set("usage", "PLAYBACK") + params.Set("adaptive", "false") + + resp, status, err := c.apiRequest(ctx, "trackManifests/"+trackID, params, openAPIV2) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("tidal trackManifests failed: status=%d body=%v", status, resp) + } + + data, ok := resp["data"].(map[string]any) + if !ok { + return nil, errors.New("tidal trackManifests missing data") + } + attrs, ok := data["attributes"].(map[string]any) + if !ok { + return nil, errors.New("tidal trackManifests missing attributes") + } + uri := stringify(attrs["uri"]) + if uri == "" { + return nil, errors.New("tidal trackManifests missing uri") + } + formats, _ := attrs["formats"].([]any) + ext := "m4a" + for _, f := range formats { + if strings.Contains(strings.ToUpper(stringify(f)), "FLAC") { + ext = "flac" + break + } + } + + return &provider.Downloadable{URL: uri, Extension: ext, Source: "tidal"}, nil +} + +func downloadableFromPlaybackManifest(resp map[string]any) *provider.Downloadable { + manifestB64 := stringify(resp["manifest"]) + if manifestB64 == "" { + return nil + } + b, err := base64.StdEncoding.DecodeString(manifestB64) + if err != nil { + return nil + } + manifest := map[string]any{} + if err = json.Unmarshal(b, &manifest); err != nil { + return nil + } + urls, ok := manifest["urls"].([]any) + if !ok || len(urls) == 0 { + return nil + } + streamURL := stringify(urls[0]) + if streamURL == "" { + return nil + } + codec := strings.ToLower(stringify(manifest["codecs"])) + ext := "m4a" + if strings.Contains(codec, "flac") { + ext = "flac" + } + return &provider.Downloadable{URL: streamURL, Extension: ext, Source: "tidal"} +} + +func (c *Client) apiRequest(ctx context.Context, path string, params url.Values, base string) (map[string]any, int, error) { + if err := c.limiter.Wait(ctx); err != nil { + return nil, 0, err + } + + if params == nil { + params = url.Values{} + } + if params.Get("countryCode") == "" { + params.Set("countryCode", c.cfg.Session.Tidal.CountryCode) + } + if params.Get("limit") == "" { + params.Set("limit", "100") + } + + reqURL := strings.TrimSuffix(base, "/") + "/" + strings.TrimPrefix(path, "/") + if len(params) > 0 { + reqURL += "?" + params.Encode() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, 0, err + } + req.Header.Set("Authorization", "Bearer "+c.cfg.Session.Tidal.AccessToken) + req.Header.Set("User-Agent", "streamrip-go/0.1") + + resp, err := c.http.Do(req) + if err != nil { + return nil, 0, err + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, err + } + parsed := map[string]any{} + if len(body) > 0 { + if err = json.Unmarshal(body, &parsed); err != nil { + return nil, resp.StatusCode, err + } + } + + return parsed, resp.StatusCode, nil +} + +func (c *Client) apiPost(ctx context.Context, endpoint string, form url.Values, basicAuth bool) (map[string]any, int, error) { + if err := c.limiter.Wait(ctx); err != nil { + return nil, 0, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(form.Encode())) + if err != nil { + return nil, 0, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "streamrip-go/0.1") + if basicAuth { + auth := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSec)) + req.Header.Set("Authorization", "Basic "+auth) + } + + resp, err := c.http.Do(req) + if err != nil { + return nil, 0, err + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, err + } + out := map[string]any{} + if len(body) > 0 { + if err = json.Unmarshal(body, &out); err != nil { + return nil, resp.StatusCode, err + } + } + return out, resp.StatusCode, nil +} + +func stringify(v any) string { + switch t := v.(type) { + case string: + return t + case int: + return strconv.Itoa(t) + case int64: + return strconv.FormatInt(t, 10) + case float64: + return strconv.FormatFloat(t, 'f', -1, 64) + default: + return "" + } +} + +func enrichTidalImage(meta map[string]any) { + if _, ok := meta["image"].(map[string]any); ok { + return + } + cover := stringify(meta["cover"]) + if cover == "" { + cover = stringify(meta["squareImage"]) + } + if cover == "" { + return + } + meta["image"] = tidalImageMap(cover) +} + +func tidalImageMap(cover string) map[string]any { + parts := strings.ReplaceAll(cover, "-", "/") + base := "https://resources.tidal.com/images/" + parts + return map[string]any{ + "thumbnail": base + "/80x80.jpg", + "small": base + "/160x160.jpg", + "large": base + "/640x640.jpg", + "extralarge": base + "/1280x1280.jpg", + "original": base + "/1280x1280.jpg", + } +} + +func intFromAny(v any) int { + switch t := v.(type) { + case int: + return t + case int64: + return int(t) + case float64: + return int(t) + case string: + i, _ := strconv.Atoi(t) + return i + default: + return 0 + } +} diff --git a/internal/provider/tidal/client_test.go b/internal/provider/tidal/client_test.go new file mode 100644 index 0000000..796034e --- /dev/null +++ b/internal/provider/tidal/client_test.go @@ -0,0 +1,52 @@ +package tidal + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "streamrip-go/internal/config" +) + +func TestLoginMissingToken(t *testing.T) { + cfgData := config.DefaultConfigData() + cfgData.Tidal.AccessToken = "" + c := New(&config.Config{File: cfgData, Session: cfgData}) + err := c.Login(context.Background()) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestSearch(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/sessions": + _ = json.NewEncoder(w).Encode(map[string]any{"countryCode": "US", "userId": 123}) + case "/v1/search/albums": + _ = json.NewEncoder(w).Encode(map[string]any{"items": []any{map[string]any{"id": 1, "title": "x"}}}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + cfgData := config.DefaultConfigData() + cfgData.Tidal.AccessToken = "token" + cfgData.Tidal.CountryCode = "US" + c := New(&config.Config{File: cfgData, Session: cfgData}) + c.baseURL = ts.URL + "/v1" + + if err := c.Login(context.Background()); err != nil { + t.Fatalf("login err = %v", err) + } + pages, err := c.Search(context.Background(), "album", "x", 10) + if err != nil { + t.Fatalf("search err = %v", err) + } + if len(pages) != 1 { + t.Fatalf("pages = %d", len(pages)) + } +} diff --git a/internal/ratelimit/limiter.go b/internal/ratelimit/limiter.go new file mode 100644 index 0000000..460dbcc --- /dev/null +++ b/internal/ratelimit/limiter.go @@ -0,0 +1,54 @@ +package ratelimit + +import ( + "context" + "sync" + "time" +) + +type Limiter struct { + interval time.Duration + + mu sync.Mutex + next time.Time +} + +func New(requestsPerMinute int) *Limiter { + if requestsPerMinute <= 0 { + return &Limiter{} + } + return &Limiter{interval: time.Minute / time.Duration(requestsPerMinute)} +} + +func (l *Limiter) Wait(ctx context.Context) error { + if l.interval <= 0 { + return nil + } + + l.mu.Lock() + now := time.Now() + if l.next.IsZero() { + l.next = now.Add(l.interval) + l.mu.Unlock() + return nil + } + + wake := l.next + if now.After(wake) { + l.next = now.Add(l.interval) + l.mu.Unlock() + return nil + } + l.next = l.next.Add(l.interval) + l.mu.Unlock() + + timer := time.NewTimer(time.Until(wake)) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/internal/ratelimit/limiter_test.go b/internal/ratelimit/limiter_test.go new file mode 100644 index 0000000..d995545 --- /dev/null +++ b/internal/ratelimit/limiter_test.go @@ -0,0 +1,28 @@ +package ratelimit + +import ( + "context" + "testing" + "time" +) + +func TestLimiterDisabled(t *testing.T) { + l := New(-1) + if err := l.Wait(context.Background()); err != nil { + t.Fatalf("Wait() error = %v", err) + } +} + +func TestLimiterContextCancel(t *testing.T) { + l := New(1) + if err := l.Wait(context.Background()); err != nil { + t.Fatalf("first Wait() error = %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond) + defer cancel() + + if err := l.Wait(ctx); err == nil { + t.Fatalf("expected context error") + } +} diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go new file mode 100644 index 0000000..6781faa --- /dev/null +++ b/internal/store/sqlite.go @@ -0,0 +1,89 @@ +package store + +import ( + "context" + "database/sql" + "sync" + + _ "modernc.org/sqlite" +) + +type SQLite struct { + db *sql.DB + mu sync.Mutex +} + +func NewSQLite(path string) (*SQLite, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + + s := &SQLite{db: db} + if err = s.init(); err != nil { + _ = db.Close() + return nil, err + } + return s, nil +} + +func (s *SQLite) init() error { + queries := []string{ + `CREATE TABLE IF NOT EXISTS downloads (id TEXT PRIMARY KEY)`, + `CREATE TABLE IF NOT EXISTS failed_downloads ( + source TEXT NOT NULL, + media_type TEXT NOT NULL, + id TEXT NOT NULL, + PRIMARY KEY (source, media_type, id) + )`, + } + + for _, q := range queries { + if _, err := s.db.Exec(q); err != nil { + return err + } + } + + return nil +} + +func (s *SQLite) IsDownloaded(ctx context.Context, id string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + var count int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM downloads WHERE id = ?`, id).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + +func (s *SQLite) MarkDownloaded(ctx context.Context, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + _, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO downloads(id) VALUES (?)`, id) + return err +} + +func (s *SQLite) MarkFailed(ctx context.Context, source, mediaType, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + _, err := s.db.ExecContext( + ctx, + `INSERT OR IGNORE INTO failed_downloads(source, media_type, id) VALUES (?, ?, ?)`, + source, + mediaType, + id, + ) + return err +} + +func (s *SQLite) Close() error { + if s.db == nil { + return nil + } + return s.db.Close() +} diff --git a/internal/store/sqlite_test.go b/internal/store/sqlite_test.go new file mode 100644 index 0000000..6398179 --- /dev/null +++ b/internal/store/sqlite_test.go @@ -0,0 +1,42 @@ +package store + +import ( + "context" + "path/filepath" + "testing" +) + +func TestSQLiteStore(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "test.db") + + s, err := NewSQLite(path) + if err != nil { + t.Fatalf("NewSQLite() error = %v", err) + } + defer func() { _ = s.Close() }() + + ok, err := s.IsDownloaded(ctx, "a") + if err != nil { + t.Fatalf("IsDownloaded() error = %v", err) + } + if ok { + t.Fatalf("expected not downloaded") + } + + if err = s.MarkDownloaded(ctx, "a"); err != nil { + t.Fatalf("MarkDownloaded() error = %v", err) + } + + ok, err = s.IsDownloaded(ctx, "a") + if err != nil { + t.Fatalf("IsDownloaded() error = %v", err) + } + if !ok { + t.Fatalf("expected downloaded") + } + + if err = s.MarkFailed(ctx, "qobuz", "track", "1"); err != nil { + t.Fatalf("MarkFailed() error = %v", err) + } +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..dbd7ec4 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,32 @@ +package store + +import "context" + +type Database interface { + IsDownloaded(ctx context.Context, id string) (bool, error) + MarkDownloaded(ctx context.Context, id string) error + MarkFailed(ctx context.Context, source, mediaType, id string) error + Close() error +} + +type Dummy struct{} + +func NewDummy() *Dummy { + return &Dummy{} +} + +func (d *Dummy) IsDownloaded(context.Context, string) (bool, error) { + return false, nil +} + +func (d *Dummy) MarkDownloaded(context.Context, string) error { + return nil +} + +func (d *Dummy) MarkFailed(context.Context, string, string, string) error { + return nil +} + +func (d *Dummy) Close() error { + return nil +} diff --git a/internal/urlparse/parse.go b/internal/urlparse/parse.go new file mode 100644 index 0000000..04ce42f --- /dev/null +++ b/internal/urlparse/parse.go @@ -0,0 +1,202 @@ +package urlparse + +import ( + "net/url" + "regexp" + "strings" +) + +type URLKind string + +const ( + KindGeneric URLKind = "generic" + KindDeezerDynamic URLKind = "deezer_dynamic" + KindSoundcloud URLKind = "soundcloud" +) + +type ParsedURL struct { + OriginalURL string + Source string + MediaType string + ID string + Kind URLKind +} + +var deezerDynamicRe = regexp.MustCompile(`^https?://dzr\.page\.link/`) + +func Parse(raw string) *ParsedURL { + if deezerDynamicRe.MatchString(raw) { + return &ParsedURL{ + OriginalURL: raw, + Source: "deezer", + Kind: KindDeezerDynamic, + } + } + + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return nil + } + + host := normalizeHost(u.Host) + path := strings.Trim(u.EscapedPath(), "/") + parts := splitParts(path) + + switch { + case isQobuzHost(host): + return parseQobuz(raw, parts) + case isTidalHost(host): + return parseTidal(raw, parts) + case isDeezerHost(host): + return parseDeezer(raw, parts) + case host == "soundcloud.com": + return parseSoundcloud(raw, parts) + default: + return nil + } +} + +func parseQobuz(raw string, parts []string) *ParsedURL { + if len(parts) < 2 { + return nil + } + + if isLocaleToken(parts[0]) { + parts = parts[1:] + } + if len(parts) < 2 { + return nil + } + + mediaType := parts[0] + if !isSupportedMedia(mediaType) { + return nil + } + id := parts[len(parts)-1] + if id == "" { + return nil + } + + return &ParsedURL{OriginalURL: raw, Source: "qobuz", MediaType: mediaType, ID: id, Kind: KindGeneric} +} + +func parseTidal(raw string, parts []string) *ParsedURL { + if len(parts) < 2 { + return nil + } + + if parts[0] == "browse" { + parts = parts[1:] + } + if len(parts) < 2 { + return nil + } + + mediaType := parts[0] + if !isSupportedMedia(mediaType) { + return nil + } + id := parts[1] + if id == "" { + return nil + } + + return &ParsedURL{OriginalURL: raw, Source: "tidal", MediaType: mediaType, ID: id, Kind: KindGeneric} +} + +func parseDeezer(raw string, parts []string) *ParsedURL { + if len(parts) < 2 { + return nil + } + + if isLangToken(parts[0]) { + parts = parts[1:] + } + if len(parts) < 2 { + return nil + } + + mediaType := parts[0] + if !isSupportedMedia(mediaType) { + return nil + } + id := parts[1] + if id == "" { + return nil + } + + return &ParsedURL{OriginalURL: raw, Source: "deezer", MediaType: mediaType, ID: id, Kind: KindGeneric} +} + +func parseSoundcloud(raw string, parts []string) *ParsedURL { + if len(parts) < 2 { + return nil + } + + mediaType := "track" + if len(parts) >= 3 && parts[1] == "sets" { + mediaType = "playlist" + } + + return &ParsedURL{OriginalURL: raw, Source: "soundcloud", MediaType: mediaType, ID: raw, Kind: KindSoundcloud} +} + +func splitParts(path string) []string { + if path == "" { + return nil + } + raw := strings.Split(path, "/") + parts := make([]string, 0, len(raw)) + for _, p := range raw { + if p != "" { + parts = append(parts, p) + } + } + return parts +} + +func normalizeHost(host string) string { + h := strings.ToLower(host) + return strings.TrimPrefix(h, "www.") +} + +func isQobuzHost(host string) bool { + return host == "qobuz.com" || host == "open.qobuz.com" || host == "play.qobuz.com" +} + +func isTidalHost(host string) bool { + return host == "tidal.com" || host == "open.tidal.com" || host == "listen.tidal.com" +} + +func isDeezerHost(host string) bool { + return host == "deezer.com" +} + +func isSupportedMedia(mediaType string) bool { + switch mediaType { + case "album", "track", "playlist", "artist", "label": + return true + default: + return false + } +} + +func isLocaleToken(s string) bool { + if len(s) != 5 { + return false + } + return s[2] == '-' && isAlpha(s[:2]) && isAlpha(s[3:]) +} + +func isLangToken(s string) bool { + return len(s) == 2 && isAlpha(s) +} + +func isAlpha(s string) bool { + for _, r := range s { + if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') { + return false + } + } + return true +} diff --git a/internal/urlparse/parse_test.go b/internal/urlparse/parse_test.go new file mode 100644 index 0000000..bce8f8e --- /dev/null +++ b/internal/urlparse/parse_test.go @@ -0,0 +1,107 @@ +package urlparse + +import "testing" + +func TestDeezerDynamicURL(t *testing.T) { + url := "https://dzr.page.link/SnV6hCyHihkmCCwUA" + result := Parse(url) + if result == nil { + t.Fatalf("expected parsed url") + } + if result.Source != "deezer" { + t.Fatalf("source = %q, want deezer", result.Source) + } + if result.Kind != KindDeezerDynamic { + t.Fatalf("kind = %q, want %q", result.Kind, KindDeezerDynamic) + } +} + +func TestQobuzAlbumURL(t *testing.T) { + url := "https://www.qobuz.com/fr-fr/album/bizarre-ride-ii-the-pharcyde-the-pharcyde/0066991040005" + result := Parse(url) + if result == nil { + t.Fatalf("expected parsed url") + } + if result.Source != "qobuz" || result.MediaType != "album" || result.ID != "0066991040005" { + t.Fatalf("unexpected parse result: %+v", result) + } +} + +func TestTidalTrackURL(t *testing.T) { + url := "https://tidal.com/browse/track/3083287" + result := Parse(url) + if result == nil { + t.Fatalf("expected parsed url") + } + if result.Source != "tidal" || result.MediaType != "track" || result.ID != "3083287" { + t.Fatalf("unexpected parse result: %+v", result) + } +} + +func TestDeezerTrackURL(t *testing.T) { + url := "https://www.deezer.com/track/4195713" + result := Parse(url) + if result == nil { + t.Fatalf("expected parsed url") + } + if result.Source != "deezer" || result.MediaType != "track" || result.ID != "4195713" { + t.Fatalf("unexpected parse result: %+v", result) + } +} + +func TestInvalidURL(t *testing.T) { + inputs := []string{ + "https://example.com", + "not a url", + "https://spotify.com/track/123456", + "https://tidal.com/invalid/3083287", + } + for _, input := range inputs { + if result := Parse(input); result != nil { + t.Fatalf("expected nil for %q, got %+v", input, result) + } + } +} + +func TestAlternateURLFormats(t *testing.T) { + inputs := []string{ + "https://open.tidal.com/track/3083287", + "https://play.qobuz.com/album/0066991040005", + "https://listen.tidal.com/track/3083287", + } + for _, input := range inputs { + if result := Parse(input); result == nil { + t.Fatalf("expected parse for %q", input) + } + } +} + +func TestURLWithLanguageCode(t *testing.T) { + inputs := []string{ + "https://www.qobuz.com/us-en/album/name/id123456", + "https://www.qobuz.com/gb-en/album/name/id123456", + "https://www.deezer.com/en/track/4195713", + "https://www.deezer.com/fr/track/4195713", + } + for _, input := range inputs { + if result := Parse(input); result == nil { + t.Fatalf("expected parse for %q", input) + } + } +} + +func TestSoundcloudURL(t *testing.T) { + inputs := []string{ + "https://soundcloud.com/artist-name/track-name", + "https://soundcloud.com/artist-name/sets/playlist-name", + } + for _, input := range inputs { + result := Parse(input) + if result == nil { + t.Fatalf("expected parse for %q", input) + } + if result.Source != "soundcloud" || result.Kind != KindSoundcloud { + t.Fatalf("unexpected parse result: %+v", result) + } + } +}