From d2ec7cbc9d7d8c39747925b0fdeb8c225cb1d6e2 Mon Sep 17 00:00:00 2001 From: boldandbrad Date: Sat, 18 Apr 2026 14:55:53 -0400 Subject: [PATCH 1/4] feat: improve dryrun reproducibility - dryrun shows CLI command to reproduce scrobble - option to copy CLI command to clipboard in dryrun mode --- CHANGELOG.md | 2 ++ cmd/album.go | 12 +++++++++++ cmd/helpers.go | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++ cmd/track.go | 14 ++++++++++++- 4 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 cmd/helpers.go diff --git a/CHANGELOG.md b/CHANGELOG.md index dde876d..a2aa798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `--album` flag to track command in CLI mode - Optional album field in TUI mode for track scrobbling - Album validation - when user provides `--album`, validates it's a valid release for the track via Last.fm +- Dryrun now shows CLI command to reproduce scrobble +- Option to copy CLI command to clipboard in dryrun mode ### Changed - Output now displays album name in parentheses for both track and album scrobbles diff --git a/cmd/album.go b/cmd/album.go index ddea5da..5f6b356 100644 --- a/cmd/album.go +++ b/cmd/album.go @@ -145,6 +145,7 @@ func scrobbleAlbum(input *scrobbleInput) error { } if input.Dryrun { + cliCmd := buildAlbumCLICommand(input.Artist, input.Album, input.Timestamp) fmt.Printf("Would scrobble to %s:\n\n", username) currentTs := input.Timestamp for i, track := range input.Tracks { @@ -153,6 +154,17 @@ func scrobbleAlbum(input *scrobbleInput) error { fmt.Printf("%2d. %s - %s%*s (%s) (%s)\n", i+1, input.Artist, track.Name, maxWidth-nameWidth, "", input.Album, tsFormatted) currentTs = currentTs.Add(time.Duration(track.Duration) * time.Second) } + + fmt.Printf("\nRun this command to scrobble:\n %s\n\n", cliCmd) + + if askCopyToClipboard() { + if err := copyToClipboard(cliCmd); err != nil { + fmt.Fprintf(os.Stderr, "Failed to copy: %v\n", err) + } else { + fmt.Println("Command copied to clipboard!") + } + } + return nil } diff --git a/cmd/helpers.go b/cmd/helpers.go new file mode 100644 index 0000000..07d8e75 --- /dev/null +++ b/cmd/helpers.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "fmt" + "os/exec" + "runtime" + "strings" + "time" +) + +func buildTrackCLICommand(artist, track, album string, timestamp time.Time) string { + cmd := fmt.Sprintf("spin track %q %q", artist, track) + + if album != "" { + cmd += fmt.Sprintf(" --album %q", album) + } + + cmd += fmt.Sprintf(" --timestamp %s", timestamp.Format("15:04")) + cmd += fmt.Sprintf(" --date %s", timestamp.Format("2006-01-02")) + + return cmd +} + +func buildAlbumCLICommand(artist, album string, timestamp time.Time) string { + cmd := fmt.Sprintf("spin album %q %q", artist, album) + + cmd += fmt.Sprintf(" --timestamp %s", timestamp.Format("15:04")) + cmd += fmt.Sprintf(" --date %s", timestamp.Format("2006-01-02")) + + return cmd +} + +func askCopyToClipboard() bool { + fmt.Print("Copy command to clipboard? [y/N] ") + var response string + fmt.Scanln(&response) + return response == "y" || response == "Y" +} + +func copyToClipboard(text string) error { + var cmd *exec.Cmd + + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("pbcopy") + case "linux": + cmd = exec.Command("bash", "-c", "echo -n "+fmt.Sprintf("%q", text)+" | xclip -selection clipboard") + case "windows": + cmd = exec.Command("cmd", "/c", "echo "+text+"| clip") + default: + return fmt.Errorf("unsupported platform") + } + + cmd.Stdin = strings.NewReader(text) + return cmd.Run() +} diff --git a/cmd/track.go b/cmd/track.go index c0dc0b4..6d8e83b 100644 --- a/cmd/track.go +++ b/cmd/track.go @@ -26,7 +26,6 @@ If artist and track are provided, scrobbles directly (CLI mode).`, endNow, _ := cmd.Flags().GetBool("end-now") dateFlag, _ := cmd.Flags().GetString("date") timestampFlag, _ := cmd.Flags().GetString("timestamp") - albumFlag, _ := cmd.Flags().GetString("album") dryrun, _ := cmd.Flags().GetBool("dryrun") var artist, track, album string @@ -55,6 +54,7 @@ If artist and track are provided, scrobbles directly (CLI mode).`, } artist = args[0] track = args[1] + albumFlag, _ := cmd.Flags().GetString("album") album = albumFlag if endNow { @@ -125,8 +125,20 @@ func scrobbleTrack(artist, track, album string, timestamp time.Time, profileFlag } if dryrun { + cliCmd := buildTrackCLICommand(artist, track, album, timestamp) fmt.Printf("Would scrobble to %s:\n\n", username) printTrack(artist, track, album, tsFormatted) + + fmt.Printf("\nRun this command to scrobble:\n %s\n\n", cliCmd) + + if askCopyToClipboard() { + if err := copyToClipboard(cliCmd); err != nil { + fmt.Fprintf(os.Stderr, "Failed to copy: %v\n", err) + } else { + fmt.Println("Command copied to clipboard!") + } + } + return nil } From 32f73e8b6aa6ed0ae7c508d51c0c91ce8e4df448 Mon Sep 17 00:00:00 2001 From: boldandbrad Date: Sat, 18 Apr 2026 14:56:19 -0400 Subject: [PATCH 2/4] docs: add missing records to changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2aa798..abedbef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Duplicate error output fixed - Consistent usage display for arg validation errors +### Documentation +- Added CONTRIBUTING.md with contribution guidelines and release process +- Added note about main branch representing active development + ## [0.1.1] - 2024-05-15 ### Added From 242f65201f6886fb104441b05e6a0ea497d6e590 Mon Sep 17 00:00:00 2001 From: boldandbrad Date: Sat, 18 Apr 2026 15:31:57 -0400 Subject: [PATCH 3/4] feat: only trigger dryrun copy prompt in tui mode --- CHANGELOG.md | 4 ++-- cmd/album.go | 6 +++++- cmd/track.go | 8 +++++--- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abedbef..ae05a3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Optional album field in TUI mode for track scrobbling - Album validation - when user provides `--album`, validates it's a valid release for the track via Last.fm - Dryrun now shows CLI command to reproduce scrobble -- Option to copy CLI command to clipboard in dryrun mode +- Option to copy CLI command to clipboard in dryrun mode (TUI mode only) ### Changed - Output now displays album name in parentheses for both track and album scrobbles @@ -56,4 +56,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Form validation for empty fields ### Removed -- Debug flag and logging in release builds \ No newline at end of file +- Debug flag and logging in release builds diff --git a/cmd/album.go b/cmd/album.go index 5f6b356..e2042c4 100644 --- a/cmd/album.go +++ b/cmd/album.go @@ -46,6 +46,7 @@ type scrobbleInput struct { Timestamp time.Time Profile string Dryrun bool + TUIMode bool } var albumCmd = &cobra.Command{ @@ -67,8 +68,10 @@ If artist and album are provided, scrobbles directly (CLI mode).`, var artist, name string var timeMode scrobble.TimeMode var customDate, customTime string + tuiMode := false if len(args) == 0 { + tuiMode = true input, err := tui.CollectAlbumInput() if err != nil { return err @@ -124,6 +127,7 @@ If artist and album are provided, scrobbles directly (CLI mode).`, Timestamp: timestamp, Profile: profileFlag, Dryrun: dryrun, + TUIMode: tuiMode, } return scrobbleAlbum(input) @@ -157,7 +161,7 @@ func scrobbleAlbum(input *scrobbleInput) error { fmt.Printf("\nRun this command to scrobble:\n %s\n\n", cliCmd) - if askCopyToClipboard() { + if input.TUIMode && askCopyToClipboard() { if err := copyToClipboard(cliCmd); err != nil { fmt.Fprintf(os.Stderr, "Failed to copy: %v\n", err) } else { diff --git a/cmd/track.go b/cmd/track.go index 6d8e83b..d1ff1c5 100644 --- a/cmd/track.go +++ b/cmd/track.go @@ -31,8 +31,10 @@ If artist and track are provided, scrobbles directly (CLI mode).`, var artist, track, album string var timeMode scrobble.TimeMode var customDate, customTime string + tuiMode := false if len(args) == 0 { + tuiMode = true input, err := tui.CollectTrackInput() if err != nil { return err @@ -103,7 +105,7 @@ If artist and track are provided, scrobbles directly (CLI mode).`, } } - return scrobbleTrack(artist, track, albumName, timestamp, profileFlag, dryrun) + return scrobbleTrack(artist, track, albumName, timestamp, profileFlag, dryrun, tuiMode) }, } @@ -115,7 +117,7 @@ func printTrack(artist, track, album, timestamp string) { } } -func scrobbleTrack(artist, track, album string, timestamp time.Time, profileFlag string, dryrun bool) error { +func scrobbleTrack(artist, track, album string, timestamp time.Time, profileFlag string, dryrun bool, tuiMode bool) error { ts := scrobble.FormatTimestamp(timestamp) tsFormatted := timestamp.Format("2006-01-02 15:04") @@ -131,7 +133,7 @@ func scrobbleTrack(artist, track, album string, timestamp time.Time, profileFlag fmt.Printf("\nRun this command to scrobble:\n %s\n\n", cliCmd) - if askCopyToClipboard() { + if tuiMode && askCopyToClipboard() { if err := copyToClipboard(cliCmd); err != nil { fmt.Fprintf(os.Stderr, "Failed to copy: %v\n", err) } else { From fadf30412f86ed874ba09625d3a2c5d8ee7b0ce6 Mon Sep 17 00:00:00 2001 From: boldandbrad Date: Sat, 18 Apr 2026 15:43:51 -0400 Subject: [PATCH 4/4] docs: add clarification to tui mode dryrun flag, readme formatting --- README.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 2a0bac8..b301d49 100644 --- a/README.md +++ b/README.md @@ -42,13 +42,13 @@ Linux Secret Service). Profile metadata is stored at `~/.config/spin/`. ### Scrobble Modes -- 👤 **TUI mode**: An interactive mode that prompts for artist and release details, - searches last.fm for the best match, and scrobbles automatically. TUI mode closes - automatically when a scrobble is submitted, or can be closed with `Ctrl+C`. +- 👤 **TUI mode**: An interactive mode that prompts for artist and release + details to scrobble. TUI mode closes automatically when a scrobble is + submitted, or can be quit with `Ctrl+C`. - 🤖 **CLI mode**: An automation friendly mode that scrobbles tracks and albums - directly using details provided as command arguments. Scrobbles are submitted as - soon as the command is run. + directly using details provided as command arguments. Scrobbles are submitted + as soon as the command is run. By default, Spin uses the current time for scrobbles. However, both modes provide ways to set custom timestamps. @@ -122,15 +122,17 @@ spin track # interactively search for and scrobble a track spin album # interactively search for and scrobble an album ``` -In addition to prompting for the `artist`, `track`/`album`, and (for tracks) optionally the -`album`, TUI mode also allows you to specify the scrobble date and time: +In addition to prompting for the `artist`, `track`/`album`, and (for tracks) +optionally the `album`, TUI mode also allows you to specify the scrobble date +and time: - **Starting now**: Scrobble at the current time - **Ending now**: Calculate start time from track/album duration - **Custom start time**: Provide a specific date and time Available TUI mode options: - `-p|--profile`: profile to scrobble with (default: active profile) -- `--dryrun`: show what would be scrobbled without submitting +- `--dryrun`: show what would be scrobbled without submitting (prompts to copy + CLI command to easily reproduce) #### CLI mode @@ -185,8 +187,10 @@ spin history -n 50 # set the number of results Spin was inspired by these projects: -- [scrobbler](https://github.com/hauzer/scrobbler) - A simple CLI Last.fm scrobbler -- [OpenWebScrobbler](https://github.com/elamperti/OpenWebScrobbler) - A web-based scrobbler +- [scrobbler](https://github.com/hauzer/scrobbler) - A simple CLI Last.fm + scrobbler +- [OpenWebScrobbler](https://github.com/elamperti/OpenWebScrobbler) - A + web-based scrobbler ## License