Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- Support for passing album parameter to Last.fm when scrobbling tracks (both CLI and TUI modes)
- `--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

### Changed
- Output now displays album name in parentheses for both track and album scrobbles
- Artist, track, and album names are now corrected using Last.fm metadata for proper casing
- Track command always fetches metadata to get corrected names from Last.fm
- Improved error messages - shows user input when track/album not found
- Better error messages for date/time parsing with format examples
- TUI refactored with cleaner Input struct

### Fixed
- Duplicate error output fixed
- Consistent usage display for arg validation errors

## [0.1.1] - 2024-05-15

### Added
- Profile management (add, list, set, get, delete, open)
- Track scrobbling (CLI and TUI modes)
- Album scrobbling (CLI and TUI modes)
- TUI mode with interactive forms for artist/track/album and timestamp selection
- Last.fm API integration (search, scrobble, history)
- Session key storage in system keychain
- `--end-now` flag to calculate start time from track/album duration
- `--date` and `--timestamp` flags for custom scrobble times
- `--dryrun` flag to preview scrobbles without submitting
- `-p/--profile` flag to specify profile
- History command to review recent scrobbles

### Changed
- Reformatted output for consistent display
- Track timestamps are calculated relative to duration rather than all at once for albums

### Fixed
- Track command parsing error
- Date and time fields in album TUI
- Form validation for empty fields

### Removed
- Debug flag and logging in release builds
26 changes: 14 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ 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` and `track`/`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
Expand All @@ -129,30 +129,32 @@ Available TUI mode options:

#### CLI mode

CLI mode scrobbles directly using provided arguments. Both commands require two
positional arguments: `artist`, and then `track` or `album` respectively.
CLI mode scrobbles directly using provided arguments. The track command accepts
an optional third argument for album.

```sh
spin track <artist> <track> # scrobble track
spin album <artist> <album> # scrobble album
spin track <artist> <track> # scrobble track
spin album <artist> <album> # scrobble album
```

Available CLI mode options:
- `--end-now`: calculate start time from track/album duration
- `--date`: date of listen (YYYY-MM-DD)
- `--timestamp`: time of listen (HH:MM)
- `--album`: album to scrobble with (optional, for track command)
- `-p|--profile`: profile to scrobble with (default: active profile)
- `--dryrun`: show what would be scrobbled without submitting

CLI mode examples:

```sh
spin track "Best Frenz" "Replay" # scrobble track now
spin track "Joywave" "Nice House" --end-now # calculate start from duration
spin track "Joywave" "Nice House" --date 2026-04-10 # scrobble with specific date
spin track "Joywave" "Nice House" --timestamp 12:46 # scrobble at specific time
spin track "Joywave" "Nice House" --dryrun # preview without scrobbling
spin album "Coldplay" "X&Y" --end-now # album ending now
spin track "Best Frenz" "Replay" # scrobble track now
spin track "Metric" "Gold Guns Girls" --end-now # calculate start from duration
spin track "The Strokes" "Eternal Summer" --date 2026-04-10 # scrobble with specific date
spin track "Phoenix" "After Midnight" --timestamp 12:46 # scrobble at specific time
spin track "Joywave" "Blank Slate" --album "Possession" # scrobble track with album
spin track "MGMT" "Little Dark Age" --dryrun # preview without scrobbling
spin album "Coldplay" "X&Y" --end-now # album ending now
spin album "Electric Guest" "Mondo" --date 2026-01-31 --timestamp 01:14 # specific date and time
```

Expand Down
52 changes: 33 additions & 19 deletions cmd/album.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"fmt"
"os"
"time"

"github.com/boldandbrad/spin/internal/api"
Expand All @@ -16,7 +17,13 @@ type trackResult struct {
Duration int
}

func getAlbumTracks(artist, album string) ([]trackResult, error) {
type albumMetadata struct {
Artist string
Album string
Tracks []trackResult
}

func getAlbumTracks(artist, album string) (*albumMetadata, error) {
albumInfo, err := api.NewClient().GetAlbumInfo(artist, album)
if err != nil {
return nil, err
Expand All @@ -25,26 +32,31 @@ func getAlbumTracks(artist, album string) ([]trackResult, error) {
for i, t := range albumInfo.Album.Tracks.Track {
tracks[i] = trackResult{Name: t.Name, Duration: t.Duration}
}
return tracks, nil
return &albumMetadata{
Artist: albumInfo.Album.Artist,
Album: albumInfo.Album.Name,
Tracks: tracks,
}, nil
}

type scrobbleInput struct {
Artist string
Name string
Album string
Tracks []trackResult
Timestamp time.Time
Profile string
Dryrun bool
}

var albumCmd = &cobra.Command{
Use: "album [artist] [album]",
Short: "Scrobble an album",
Use: "album [artist] [album]",
Short: "Scrobble an album",
SilenceUsage: true,
SilenceErrors: true,
Long: `Scrobble an album to last.fm.

If no arguments are provided, launches TUI mode for interactive scrobbling.
If artist and album are provided, scrobbles directly (CLI mode).`,
Args: cobra.RangeArgs(0, 2),
RunE: func(cmd *cobra.Command, args []string) error {
profileFlag, _ := cmd.Flags().GetString("profile")
endNow, _ := cmd.Flags().GetBool("end-now")
Expand All @@ -65,13 +77,15 @@ If artist and album are provided, scrobbles directly (CLI mode).`,
return nil
}
artist = input.Artist
name = input.Name
name = input.Album
timeMode = input.TimeMode
customDate = input.Date
customTime = input.Time
} else {
if len(args) != 2 {
return fmt.Errorf("requires artist and album arguments")
fmt.Fprintf(os.Stderr, "Error: requires artist and album arguments\n\n")
cmd.Usage()
return nil
}
artist = args[0]
name = args[1]
Expand All @@ -85,16 +99,16 @@ If artist and album are provided, scrobbles directly (CLI mode).`,
}
}

tracks, err := getAlbumTracks(artist, name)
albumMeta, err := getAlbumTracks(artist, name)
if err != nil {
return fmt.Errorf("failed to get album info: %w", err)
return err
}
if len(tracks) == 0 {
if len(albumMeta.Tracks) == 0 {
return fmt.Errorf("no tracks found for %s - %s", artist, name)
}

var totalDuration int
for _, t := range tracks {
for _, t := range albumMeta.Tracks {
totalDuration += t.Duration
}

Expand All @@ -104,9 +118,9 @@ If artist and album are provided, scrobbles directly (CLI mode).`,
}

input := &scrobbleInput{
Artist: artist,
Name: name,
Tracks: tracks,
Artist: albumMeta.Artist,
Album: albumMeta.Album,
Tracks: albumMeta.Tracks,
Timestamp: timestamp,
Profile: profileFlag,
Dryrun: dryrun,
Expand Down Expand Up @@ -136,7 +150,7 @@ func scrobbleAlbum(input *scrobbleInput) error {
for i, track := range input.Tracks {
tsFormatted := currentTs.Format("2006-01-02 15:04")
nameWidth := len(input.Artist) + 3 + len(track.Name)
fmt.Printf("%2d. %s - %s%*s (%s)\n", i+1, input.Artist, track.Name, maxWidth-nameWidth, "", tsFormatted)
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)
}
return nil
Expand All @@ -151,15 +165,15 @@ func scrobbleAlbum(input *scrobbleInput) error {
currentTs := input.Timestamp
for i, track := range input.Tracks {
ts := scrobble.FormatTimestamp(currentTs)
if err := client.ScrobbleTrack(input.Artist, track.Name, ts, p.SessionKey); err != nil {
if err := client.ScrobbleTrack(input.Artist, track.Name, ts, p.SessionKey, input.Album); err != nil {
nameWidth := len(input.Artist) + 3 + len(track.Name)
fmt.Printf("%2d. %s - %s%*s: failed to scrobble: %v\n", i+1, input.Artist, track.Name, maxWidth-nameWidth, "", err)
fmt.Printf("%2d. %s - %s (%s)%*s: failed to scrobble: %v\n", i+1, input.Artist, track.Name, input.Album, maxWidth-nameWidth, "", err)
currentTs = currentTs.Add(time.Duration(track.Duration) * time.Second)
continue
}
tsFormatted := currentTs.Format("2006-01-02 15:04")
nameWidth := len(input.Artist) + 3 + len(track.Name)
fmt.Printf("%2d. %s - %s%*s (%s)\n", i+1, input.Artist, track.Name, maxWidth-nameWidth, "", tsFormatted)
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)
}

Expand Down
Loading
Loading