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(`]*\btitle=(?:"([^"]+)"|'([^']+)')`) lastFMDataTrackArtistRe = regexp.MustCompile(`data-track-name=(?:"([^"]+)"|'([^']+)')[^>]*data-artist-name=(?:"([^"]+)"|'([^']+)')`) lastFMTotalTracksRe = regexp.MustCompile(`data-playlisting-entry-count="(\d+)"`) lastFMPlaylistTitleRe = regexp.MustCompile(`]*class="[^"]*playlisting-playlist-header-title[^"]*"[^>]*>([^<]+)`) 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 != "yandex" && 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") }