mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
Refactor: comprehensive cleanup and modularization
- Extracted common JSON parsing helpers into internal/jsonutil - Removed duplicated helper functions from provider packages - Removed dead code in internal/app/app.go and downloader.go - Replaced deprecated strings.Title with jsonutil.TitleCase - Added graceful shutdown with signal handling in main.go - Split monolithic cmd/rip/main.go into args.go, helpers.go, lastfm.go, search.go
This commit is contained in:
234
cmd/rip/helpers.go
Normal file
234
cmd/rip/helpers.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"streamrip-go/internal/app"
|
||||
"streamrip-go/internal/urlparse"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type resolvedLastFMTrack struct {
|
||||
Source string
|
||||
ID string
|
||||
Query string
|
||||
}
|
||||
|
||||
var (
|
||||
lastFMTitleTagsRe = regexp.MustCompile(`<a\b[^>]*\btitle=(?:"([^"]+)"|'([^']+)')`)
|
||||
lastFMDataTrackArtistRe = regexp.MustCompile(`data-track-name=(?:"([^"]+)"|'([^']+)')[^>]*data-artist-name=(?:"([^"]+)"|'([^']+)')`)
|
||||
lastFMTotalTracksRe = regexp.MustCompile(`data-playlisting-entry-count="(\d+)"`)
|
||||
lastFMPlaylistTitleRe = regexp.MustCompile(`<h1[^>]*class="[^"]*playlisting-playlist-header-title[^"]*"[^>]*>([^<]+)</h1>`)
|
||||
lastFMMirrorTitleRe = regexp.MustCompile(`^Title:\s*(.+?)\s+\|`)
|
||||
lastFMMirrorLinkTextRe = 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 && parsed.Kind != urlparse.KindSoundcloud {
|
||||
fmt.Printf("not yet supported: %s (kind=%s)\n", raw, parsed.Kind)
|
||||
return false
|
||||
}
|
||||
if parsed.Source != "qobuz" && parsed.Source != "tidal" && parsed.Source != "deezer" && parsed.Source != "soundcloud" {
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user