Files
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

123 lines
3.8 KiB
Go

package spotify
import (
"fmt"
"strconv"
"strings"
"github.com/bjarneo/cliamp/playlist"
)
// maxResponseBody limits JSON API responses to 10 MB.
const maxResponseBody = 10 << 20
// Pagination limits for the Spotify Web API.
const (
spotifyPlaylistPageSize = 50
// spotifyTrackPageSize is capped at 50 because /v1/playlists/{id}/items
// silently truncates larger limits; requesting more would cause the loop
// to skip items when offset advances by the requested limit.
spotifyTrackPageSize = 50
)
// spotifyPlaylistItem is the raw playlist object returned by /v1/me/playlists.
type spotifyPlaylistItem struct {
ID string `json:"id"`
Name string `json:"name"`
SnapshotID string `json:"snapshot_id"`
Collaborative bool `json:"collaborative"`
Owner struct {
ID string `json:"id"`
} `json:"owner"`
Items *struct {
Total int `json:"total"`
} `json:"items"`
}
// playlistAccessible reports whether the playlist should be shown to the user.
// Playlists saved from other users (not owned, not collaborative) are excluded
// because the Spotify API returns 403 when listing their tracks.
// When userID is empty (fetch failed), all playlists are included as a fallback.
func playlistAccessible(item spotifyPlaylistItem, userID string) bool {
if userID == "" {
return true
}
return item.Owner.ID == userID || item.Collaborative
}
type spotifyArtist struct {
Name string `json:"name"`
}
// spotifyItem is a track or podcast episode object from the Spotify Web API.
// Playlists can hold both; episodes carry a show instead of artists/album.
type spotifyItem struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // "track" or "episode"
URI string `json:"uri"` // canonical spotify:track:... / spotify:episode:...
Artists []spotifyArtist `json:"artists"`
Album struct {
Name string `json:"name"`
ReleaseDate string `json:"release_date"`
} `json:"album"`
Show struct {
Name string `json:"name"`
} `json:"show"`
ReleaseDate string `json:"release_date"` // episodes carry this at top level
DurationMs int `json:"duration_ms"`
TrackNumber int `json:"track_number"`
IsPlayable *bool `json:"is_playable"`
Restrictions struct {
Reason string `json:"reason"`
} `json:"restrictions"`
}
// trackFromItem converts a Spotify playlist/library item into a playlist.Track,
// handling both music tracks and podcast episodes. It uses the canonical uri
// the API returns (spotify:track:... or spotify:episode:...) as the path, so
// the player routes episodes to go-librespot's episode metadata path; building
// "spotify:track:<id>" for an episode makes go-librespot request track metadata
// for an episode id, which 404s. Episodes carry no artists/album, so the show
// name fills those slots for display.
func trackFromItem(t *spotifyItem) playlist.Track {
artists := make([]string, len(t.Artists))
for i, a := range t.Artists {
artists[i] = a.Name
}
artist := strings.Join(artists, ", ")
album := t.Album.Name
if t.Type == "episode" {
artist = t.Show.Name
album = t.Show.Name
}
releaseDate := t.Album.ReleaseDate
if releaseDate == "" {
releaseDate = t.ReleaseDate
}
var year int
if len(releaseDate) >= 4 {
if y, err := strconv.Atoi(releaseDate[:4]); err == nil {
year = y
}
}
path := t.URI
if path == "" {
path = fmt.Sprintf("spotify:track:%s", t.ID) // fallback if uri is absent
}
return playlist.Track{
Path: path,
Title: t.Name,
Artist: artist,
Album: album,
Year: year,
Stream: false, // must be false: true causes togglePlayPause to stop+restart instead of pause/resume
DurationSecs: t.DurationMs / 1000,
TrackNumber: t.TrackNumber,
Unplayable: (t.IsPlayable != nil && !*t.IsPlayable) || t.Restrictions.Reason != "",
}
}