From cc838336a742342d6f34f8c36d7f87814fb78d00 Mon Sep 17 00:00:00 2001 From: boldandbrad Date: Sat, 18 Apr 2026 11:36:08 -0400 Subject: [PATCH 1/2] feat: add album support for track scrobbling - Add --album flag to track command - Validate album via Last.fm album.getInfo - Show album in output for track and album commands - Use Last.fm metadata for name correction - Refactor TUI Input struct and clean up redundant code --- CHANGELOG.md | 46 +++++++++++++++++++++++++++++ README.md | 26 ++++++++-------- cmd/album.go | 38 +++++++++++++++--------- cmd/track.go | 59 +++++++++++++++++++++++++++---------- internal/api/lastfm.go | 56 ++++++++++++++++++++++++++++++----- tui/scrobble.go | 67 +++++++++++++++++++++++------------------- 6 files changed, 214 insertions(+), 78 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0fd2e6c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,46 @@ +# 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 + +## [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 \ No newline at end of file diff --git a/README.md b/README.md index 1803670..b171ab3 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 # scrobble track -spin album # scrobble album +spin track # scrobble track +spin 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 ``` diff --git a/cmd/album.go b/cmd/album.go index f3ecb26..ee4ddb0 100644 --- a/cmd/album.go +++ b/cmd/album.go @@ -16,7 +16,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 @@ -25,12 +31,16 @@ 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 @@ -65,7 +75,7 @@ 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 @@ -85,16 +95,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) } - 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 } @@ -104,9 +114,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, @@ -136,7 +146,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 @@ -151,15 +161,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) } diff --git a/cmd/track.go b/cmd/track.go index 14e5d51..0838340 100644 --- a/cmd/track.go +++ b/cmd/track.go @@ -24,9 +24,10 @@ 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 string + var artist, track, album string var timeMode scrobble.TimeMode var customDate, customTime string @@ -39,7 +40,8 @@ If artist and track are provided, scrobbles directly (CLI mode).`, return nil } artist = input.Artist - track = input.Name + track = input.Track + album = input.Album timeMode = input.TimeMode customDate = input.Date customTime = input.Time @@ -49,6 +51,7 @@ If artist and track are provided, scrobbles directly (CLI mode).`, } artist = args[0] track = args[1] + album = albumFlag if endNow { timeMode = scrobble.TimeModeEndNow @@ -61,17 +64,18 @@ If artist and track are provided, scrobbles directly (CLI mode).`, } } + client := api.NewClient() + trackMetadata, err := client.GetTrackInfo(artist, track) + if err != nil { + return fmt.Errorf("failed to get track info: %w", err) + } + totalDuration := 0 if timeMode == scrobble.TimeModeEndNow { - client := api.NewClient() - durationMs, err := client.GetTrackInfo(artist, track) - if err != nil { - return fmt.Errorf("failed to get track info: %w", err) - } - if durationMs == 0 { + if trackMetadata.Duration == 0 { return fmt.Errorf("track duration unknown, cannot use --end-now") } - totalDuration = durationMs / 1000 + totalDuration = trackMetadata.Duration / 1000 } timestamp, err := scrobble.ResolveTimestampFromMode(timeMode, customTime, customDate, totalDuration) @@ -79,11 +83,35 @@ If artist and track are provided, scrobbles directly (CLI mode).`, return err } - return scrobbleTrack(artist, track, timestamp, profileFlag, dryrun) + albumName := "" + if trackMetadata != nil { + artist = trackMetadata.Artist + track = trackMetadata.Track + + if album != "" { + if validatedAlbum, _ := client.ValidateAlbumForTrack(artist, track, album); validatedAlbum != "" { + albumName = validatedAlbum + } else { + albumName = trackMetadata.Album + } + } else { + albumName = trackMetadata.Album + } + } + + return scrobbleTrack(artist, track, albumName, timestamp, profileFlag, dryrun) }, } -func scrobbleTrack(artist, track string, timestamp time.Time, profileFlag string, dryrun bool) error { +func printTrack(artist, track, album, timestamp string) { + if album != "" { + fmt.Printf("%2d. %s - %s (%s) (%s)\n", 1, artist, track, album, timestamp) + } else { + fmt.Printf("%2d. %s - %s (%s)\n", 1, artist, track, timestamp) + } +} + +func scrobbleTrack(artist, track, album string, timestamp time.Time, profileFlag string, dryrun bool) error { ts := scrobble.FormatTimestamp(timestamp) tsFormatted := timestamp.Format("2006-01-02 15:04") @@ -94,7 +122,7 @@ func scrobbleTrack(artist, track string, timestamp time.Time, profileFlag string if dryrun { fmt.Printf("Would scrobble to %s:\n\n", username) - fmt.Printf("%2d. %s - %s (%s)\n", 1, artist, track, tsFormatted) + printTrack(artist, track, album, tsFormatted) return nil } @@ -104,12 +132,12 @@ func scrobbleTrack(artist, track string, timestamp time.Time, profileFlag string } client := api.NewClient() - if err := client.ScrobbleTrack(artist, track, ts, p.SessionKey); err != nil { - return fmt.Errorf("%2d. %s - %s: failed to scrobble: %w", 1, artist, track, err) + if err := client.ScrobbleTrack(artist, track, ts, p.SessionKey, album); err != nil { + return fmt.Errorf("%2d. %s - %s (%s): failed to scrobble: %w", 1, artist, track, album, err) } fmt.Printf("Scrobbled to %s:\n\n", username) - fmt.Printf("%2d. %s - %s (%s)\n", 1, artist, track, tsFormatted) + printTrack(artist, track, album, tsFormatted) return nil } @@ -119,5 +147,6 @@ func init() { trackCmd.Flags().String("date", "", "date of listen (YYYY-MM-DD)") trackCmd.Flags().String("timestamp", "", "time of listen (HH:MM)") trackCmd.Flags().StringP("profile", "p", "", "profile to scrobble with") + trackCmd.Flags().String("album", "", "album to scrobble with (optional)") trackCmd.Flags().Bool("dryrun", false, "show what would be scrobbled without submitting") } diff --git a/internal/api/lastfm.go b/internal/api/lastfm.go index 353b11f..e4bd4ec 100644 --- a/internal/api/lastfm.go +++ b/internal/api/lastfm.go @@ -267,15 +267,35 @@ func (c *Client) GetAlbumInfo(artist, album string) (*AlbumDetailResponse, error return &result, nil } -func (c *Client) ScrobbleTrack(artist, track, timestamp, sessionKey string) error { - signature := c.createSignature(map[string]string{ +func (c *Client) ValidateAlbumForTrack(artist, track, album string) (string, error) { + albumInfo, err := c.GetAlbumInfo(artist, album) + if err != nil { + return "", err + } + + for _, t := range albumInfo.Album.Tracks.Track { + if t.Name == track { + return albumInfo.Album.Name, nil + } + } + + return "", nil +} + +func (c *Client) ScrobbleTrack(artist, track, timestamp, sessionKey string, album string) error { + signatureParams := map[string]string{ "api_key": c.GetAPIKey(), "artist": artist, "method": "track.scrobble", "sk": sessionKey, "timestamp": timestamp, "track": track, - }) + } + if album != "" { + signatureParams["album"] = album + } + + signature := c.createSignature(signatureParams) params := url.Values{ "api_key": {c.GetAPIKey()}, @@ -287,6 +307,9 @@ func (c *Client) ScrobbleTrack(artist, track, timestamp, sessionKey string) erro "api_sig": {signature}, "format": {"json"}, } + if album != "" { + params.Add("album", album) + } data, _, err := c.doRequest(params) if err != nil { @@ -323,7 +346,14 @@ func (c *Client) GetRecentTracks(username string, limit int) ([]TrackInfo, error return result.RecentTracks.Track, nil } -func (c *Client) GetTrackInfo(artist, track string) (int, error) { +type TrackMetadata struct { + Duration int + Artist string + Track string + Album string +} + +func (c *Client) GetTrackInfo(artist, track string) (*TrackMetadata, error) { params := url.Values{ "method": {"track.getInfo"}, "api_key": {c.GetAPIKey()}, @@ -335,16 +365,23 @@ func (c *Client) GetTrackInfo(artist, track string) (int, error) { data, _, err := c.doRequest(params) if err != nil { - return 0, err + return nil, err } var result struct { Track struct { Duration string `json:"duration"` + Name string `json:"name"` + Artist struct { + Name string `json:"name"` + } `json:"artist"` + Album struct { + Title string `json:"title"` + } `json:"album"` } `json:"track"` } if err := json.Unmarshal([]byte(data), &result); err != nil { - return 0, err + return nil, err } duration := 0 @@ -352,7 +389,12 @@ func (c *Client) GetTrackInfo(artist, track string) (int, error) { fmt.Sscanf(result.Track.Duration, "%d", &duration) } - return duration, nil + return &TrackMetadata{ + Duration: duration, + Artist: result.Track.Artist.Name, + Track: result.Track.Name, + Album: result.Track.Album.Title, + }, nil } func (c *Client) createSignature(params map[string]string) string { diff --git a/tui/scrobble.go b/tui/scrobble.go index 5863196..66fe0d4 100644 --- a/tui/scrobble.go +++ b/tui/scrobble.go @@ -8,17 +8,19 @@ import ( "github.com/boldandbrad/spin/internal/scrobble" ) -type ScrobbleInput struct { +type Input struct { Artist string - Name string + Track string + Album string TimeMode scrobble.TimeMode Date string Time string } -func CollectInput(isAlbum bool) (*ScrobbleInput, error) { +func CollectInput(isAlbum bool) (*Input, error) { artist := "" - name := "" + track := "" + album := "" timeMode := scrobble.TimeModeEndNow artistField := huh.NewInput(). @@ -32,35 +34,39 @@ func CollectInput(isAlbum bool) (*ScrobbleInput, error) { return nil }) - nameField := huh.NewInput(). - Title(func() string { - if isAlbum { - return "Album" - } - return "Track" - }()). - Value(&name). - Placeholder(func() string { - if isAlbum { - return "e.g., OK Computer" - } - return "e.g., Paranoid Android" - }()). + trackField := huh.NewInput(). + Title("Track"). + Value(&track). + Placeholder("e.g., Paranoid Android"). Validate(func(s string) error { - if s == "" { - if isAlbum { - return fmt.Errorf("album is required") - } + if s == "" && !isAlbum { return fmt.Errorf("track is required") } return nil }) + albumField := huh.NewInput(). + Title("Album"). + Value(&album). + Placeholder("e.g., OK Computer"). + Validate(func(s string) error { + if s == "" && isAlbum { + return fmt.Errorf("album is required") + } + return nil + }) + + formFields := []huh.Field{artistField} + if isAlbum { + formFields = append(formFields, albumField) + } else { + formFields = append(formFields, trackField) + albumField.Title("Album (optional)") + formFields = append(formFields, albumField) + } + form := huh.NewForm( - huh.NewGroup( - artistField, - nameField, - ), + huh.NewGroup(formFields...), huh.NewGroup( huh.NewSelect[scrobble.TimeMode](). Title("When did you listen?"). @@ -96,19 +102,20 @@ func CollectInput(isAlbum bool) (*ScrobbleInput, error) { } } - return &ScrobbleInput{ + return &Input{ Artist: artist, - Name: name, + Track: track, + Album: album, TimeMode: timeMode, Date: date, Time: timeStr, }, nil } -func CollectTrackInput() (*ScrobbleInput, error) { +func CollectTrackInput() (*Input, error) { return CollectInput(false) } -func CollectAlbumInput() (*ScrobbleInput, error) { +func CollectAlbumInput() (*Input, error) { return CollectInput(true) } From f91afc51060ec2118715014da02a4c694fc2d0de Mon Sep 17 00:00:00 2001 From: boldandbrad Date: Sat, 18 Apr 2026 12:17:04 -0400 Subject: [PATCH 2/2] feat: improve error handling and messages - Show search terms in not found errors (e.g., "track 'x' by 'y' not found") - Fix duplicate error output - Consistent arg validation errors with usage display - Better date/time parsing errors with format hints --- CHANGELOG.md | 7 +++++++ cmd/album.go | 14 +++++++++----- cmd/track.go | 16 ++++++++++------ internal/api/lastfm.go | 18 ++++++++++++++++++ internal/scrobble/scrobble.go | 4 ++-- 5 files changed, 46 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fd2e6c..dde876d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 diff --git a/cmd/album.go b/cmd/album.go index ee4ddb0..ddea5da 100644 --- a/cmd/album.go +++ b/cmd/album.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "os" "time" "github.com/boldandbrad/spin/internal/api" @@ -48,13 +49,14 @@ type scrobbleInput struct { } 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") @@ -81,7 +83,9 @@ If artist and album are provided, scrobbles directly (CLI mode).`, 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] @@ -97,7 +101,7 @@ If artist and album are provided, scrobbles directly (CLI mode).`, albumMeta, err := getAlbumTracks(artist, name) if err != nil { - return fmt.Errorf("failed to get album info: %w", err) + return err } if len(albumMeta.Tracks) == 0 { return fmt.Errorf("no tracks found for %s - %s", artist, name) diff --git a/cmd/track.go b/cmd/track.go index 0838340..c0dc0b4 100644 --- a/cmd/track.go +++ b/cmd/track.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "os" "time" "github.com/boldandbrad/spin/internal/api" @@ -12,13 +13,14 @@ import ( ) var trackCmd = &cobra.Command{ - Use: "track [artist] [track]", - Short: "Scrobble a track", + Use: "track [artist] [track]", + Short: "Scrobble a track", + SilenceUsage: true, + SilenceErrors: true, Long: `Scrobble a track to last.fm. If no arguments are provided, launches TUI mode for interactive scrobbling. If artist and track 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") @@ -47,7 +49,9 @@ If artist and track are provided, scrobbles directly (CLI mode).`, customTime = input.Time } else { if len(args) != 2 { - return fmt.Errorf("requires artist and track arguments") + fmt.Fprintf(os.Stderr, "Error: requires artist and track arguments\n\n") + cmd.Usage() + return nil } artist = args[0] track = args[1] @@ -67,13 +71,13 @@ If artist and track are provided, scrobbles directly (CLI mode).`, client := api.NewClient() trackMetadata, err := client.GetTrackInfo(artist, track) if err != nil { - return fmt.Errorf("failed to get track info: %w", err) + return err } totalDuration := 0 if timeMode == scrobble.TimeModeEndNow { if trackMetadata.Duration == 0 { - return fmt.Errorf("track duration unknown, cannot use --end-now") + return fmt.Errorf("last.fm doesn't have duration for this track, cannot use --end-now") } totalDuration = trackMetadata.Duration / 1000 } diff --git a/internal/api/lastfm.go b/internal/api/lastfm.go index e4bd4ec..a3fd4f3 100644 --- a/internal/api/lastfm.go +++ b/internal/api/lastfm.go @@ -259,11 +259,20 @@ func (c *Client) GetAlbumInfo(artist, album string) (*AlbumDetailResponse, error return nil, err } + var errResp ErrorResponse + if err := json.Unmarshal([]byte(data), &errResp); err == nil && errResp.Error != 0 { + return nil, fmt.Errorf("last.fm: album '%s' by '%s' not found", album, artist) + } + var result AlbumDetailResponse if err := json.Unmarshal([]byte(data), &result); err != nil { return nil, err } + if result.Album.Name == "" { + return nil, fmt.Errorf("last.fm: album '%s' by '%s' not found", album, artist) + } + return &result, nil } @@ -368,6 +377,11 @@ func (c *Client) GetTrackInfo(artist, track string) (*TrackMetadata, error) { return nil, err } + var errResp ErrorResponse + if err := json.Unmarshal([]byte(data), &errResp); err == nil && errResp.Error != 0 { + return nil, fmt.Errorf("last.fm: track '%s' by '%s' not found", track, artist) + } + var result struct { Track struct { Duration string `json:"duration"` @@ -389,6 +403,10 @@ func (c *Client) GetTrackInfo(artist, track string) (*TrackMetadata, error) { fmt.Sscanf(result.Track.Duration, "%d", &duration) } + if result.Track.Name == "" { + return nil, fmt.Errorf("last.fm: track '%s' by '%s' not found", track, artist) + } + return &TrackMetadata{ Duration: duration, Artist: result.Track.Artist.Name, diff --git a/internal/scrobble/scrobble.go b/internal/scrobble/scrobble.go index 75d3e96..909adfa 100644 --- a/internal/scrobble/scrobble.go +++ b/internal/scrobble/scrobble.go @@ -24,12 +24,12 @@ func ResolveTimestampFromMode(mode TimeMode, customTime, customDate string, tota case TimeModeCustom: timestamp, err := parseTimeOfDay(customTime) if err != nil { - return time.Time{}, fmt.Errorf("invalid --timestamp: %w", err) + return time.Time{}, fmt.Errorf("invalid time format '%s', use HH:MM (e.g., 14:30)", customTime) } if customDate != "" { parsedDate, err := time.Parse("2006-01-02", customDate) if err != nil { - return time.Time{}, fmt.Errorf("invalid --date: %w", err) + return time.Time{}, fmt.Errorf("invalid date format '%s', use YYYY-MM-DD (e.g., 2026-04-18)", customDate) } timestamp = time.Date(parsedDate.Year(), parsedDate.Month(), parsedDate.Day(), timestamp.Hour(), timestamp.Minute(), timestamp.Second(), timestamp.Nanosecond(), timestamp.Location()) }