diff --git a/.gitignore b/.gitignore index 49cffa89..28a16e55 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,6 @@ explo src/web/dist/ src/web/frontend/node_modules/ /cache -data/ +/data/ +/.data/ .zed/ diff --git a/go.mod b/go.mod index 5b3bc9e0..98ca6664 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/u2takey/ffmpeg-go v0.5.0 github.com/wader/goutubedl v0.0.0-20250417150709-083444e4ab87 golang.org/x/crypto v0.46.0 + golang.org/x/net v0.48.0 golang.org/x/sync v0.19.0 golang.org/x/text v0.32.0 maunium.net/go/mautrix v0.26.0 @@ -43,7 +44,6 @@ require ( github.com/u2takey/go-utils v0.3.1 // indirect go.mau.fi/util v0.9.3 // indirect golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect - golang.org/x/net v0.48.0 // indirect golang.org/x/sys v0.39.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect diff --git a/src/client/client.go b/src/client/client.go index 3fa2875b..ee4ee841 100644 --- a/src/client/client.go +++ b/src/client/client.go @@ -1,8 +1,12 @@ package client import ( + "bytes" "fmt" + "io" "log/slog" + "net/http" + "os" "time" "explo/src/config" @@ -10,6 +14,39 @@ import ( "explo/src/util" ) +// uploadPlaylistArtwork POSTs raw image bytes to a music app's artwork endpoint. +// Plex, Jellyfin, and Emby all accept the same format — POST + Content-Type: image/jpeg + raw body. +// The only per-client difference is the URL path, which each caller builds before invoking. +func uploadPlaylistArtwork(hc *util.HttpClient, endpoint, localPath string, headers map[string]string) error { + data, err := os.ReadFile(localPath) + if err != nil { + return fmt.Errorf("read artwork: %w", err) + } + req, err := http.NewRequest("POST", endpoint, bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "image/jpeg") + req.ContentLength = int64(len(data)) + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := hc.Client.Do(req) + if err != nil { + return fmt.Errorf("post: %w", err) + } + defer func() { + if cerr := resp.Body.Close(); cerr != nil { + slog.Warn("artwork upload: response body close failed", "err", cerr.Error()) + } + }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("upload artwork: status %d, body: %s", resp.StatusCode, string(body)) + } + return nil +} + // Client manages interactions with the selected music system type Client struct { System string @@ -31,6 +68,12 @@ type APIClient interface { DeletePlaylist() error } +// ArtworkUploader is an optional capability for clients that support setting +// playlist artwork. Use a type assertion: if u, ok := c.API.(client.ArtworkUploader); ok {...}. +type ArtworkUploader interface { + SetPlaylistArtwork(localPath string) error +} + // NewClient initializes a client and sets up authentication func NewClient(cfg *config.Config) (*Client, error) { c := &Client{ @@ -70,6 +113,16 @@ func NewClient(cfg *config.Config) (*Client, error) { return c, nil } +// TriggerRefresh Runs a trigger to refresh the users app music library +// Useful for one-shot operations +func TriggerRefresh(cfg *config.Config) error { + c, err := NewClient(cfg) + if err != nil { + return fmt.Errorf("client setup: %w", err) + } + return c.API.RefreshLibrary() +} + // systemSetup checks needed credentials and initializes the selected system func (c *Client) systemSetup() error { switch c.System { @@ -111,7 +164,7 @@ func (c *Client) systemSetup() error { } } - + if err := c.API.AddHeader(); err != nil { return err } diff --git a/src/client/emby.go b/src/client/emby.go index 42f782f9..07af57b4 100644 --- a/src/client/emby.go +++ b/src/client/emby.go @@ -126,7 +126,7 @@ func (c *Emby) CheckRefreshState() bool { func (c *Emby) SearchSongs(tracks []*models.Track) error { for _, track := range tracks { - reqParam := fmt.Sprintf("/emby/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Fields=Path", url.QueryEscape(track.CleanTitle)) + reqParam := fmt.Sprintf("/emby/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Fields=Path", url.QueryEscape(util.CleanSearchTitle(track.CleanTitle))) body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+reqParam, nil, c.Cfg.Creds.Headers) if err != nil { @@ -162,7 +162,7 @@ func (c *Emby) SearchSongs(tracks []*models.Track) error { } func (c *Emby) SearchPlaylist() error { - params := fmt.Sprintf("/emby/Items?SearchTerm=%s&Recursive=true&IncludeItemTypes=Playlist", c.Cfg.PlaylistName) + params := fmt.Sprintf("/emby/Items?SearchTerm=%s&Recursive=true&IncludeItemTypes=Playlist", url.QueryEscape(c.Cfg.PlaylistName)) body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+params, nil, c.Cfg.Creds.Headers) if err != nil { @@ -185,7 +185,7 @@ func (c *Emby) SearchPlaylist() error { func (c *Emby) CreatePlaylist(tracks []*models.Track) error { songIDs := formatEmbySongs(tracks) - reqParam := fmt.Sprintf("/emby/Playlists?Name=%s&Ids=%s&MediaType=Music", c.Cfg.PlaylistName, songIDs) + reqParam := fmt.Sprintf("/emby/Playlists?Name=%s&Ids=%s&MediaType=Music", url.QueryEscape(c.Cfg.PlaylistName), songIDs) body, err := c.HttpClient.MakeRequest("POST", c.Cfg.URL+reqParam, nil, c.Cfg.Creds.Headers) @@ -227,6 +227,14 @@ func (c *Emby) DeletePlaylist() error { // Doesn't currently work due to a bug i return nil } +// SetPlaylistArtwork uploads a JPEG as the playlist's primary image. +func (c *Emby) SetPlaylistArtwork(localPath string) error { + if c.Cfg.PlaylistID == "" { + return fmt.Errorf("emby: no PlaylistID set") + } + return uploadPlaylistArtwork(c.HttpClient, c.Cfg.URL+"/emby/Items/"+c.Cfg.PlaylistID+"/Images/Primary", localPath, c.Cfg.Creds.Headers) +} + func formatEmbySongs(tracks []*models.Track) string { songIDs := make([]string, 0, len(tracks)) for _, track := range tracks { diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go index 03762a94..c268260f 100644 --- a/src/client/jellyfin.go +++ b/src/client/jellyfin.go @@ -141,7 +141,7 @@ func (c *Jellyfin) CheckRefreshState() bool { func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { for _, track := range tracks { - reqParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Fields=Path", url.QueryEscape(track.CleanTitle)) + reqParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Fields=Path", url.QueryEscape(util.CleanSearchTitle(track.CleanTitle))) body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+reqParam, nil, c.Cfg.Creds.Headers) if err != nil { @@ -177,7 +177,7 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { } func (c *Jellyfin) SearchPlaylist() error { - queryParams := fmt.Sprintf("/Search/Hints?IncludeItemTypes=Playlist&SearchTerm=%s", c.Cfg.PlaylistName) + queryParams := fmt.Sprintf("/Search/Hints?IncludeItemTypes=Playlist&SearchTerm=%s", url.QueryEscape(c.Cfg.PlaylistName)) body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+queryParams, nil, c.Cfg.Creds.Headers) if err != nil { return err @@ -259,6 +259,14 @@ func (c *Jellyfin) DeletePlaylist() error { return nil } +// SetPlaylistArtwork uploads a JPEG as the playlist's primary image. +func (c *Jellyfin) SetPlaylistArtwork(localPath string) error { + if c.Cfg.PlaylistID == "" { + return fmt.Errorf("jellyfin: no PlaylistID set") + } + return uploadPlaylistArtwork(c.HttpClient, c.Cfg.URL+"/Items/"+c.Cfg.PlaylistID+"/Images/Primary", localPath, c.Cfg.Creds.Headers) +} + func formatJFSongs(tracks []*models.Track) ([]byte, error) { // marshal track IDs songIDs := make([]string, 0, len(tracks)) for _, track := range tracks { diff --git a/src/client/plex.go b/src/client/plex.go index 7c201ec3..f5231c0b 100644 --- a/src/client/plex.go +++ b/src/client/plex.go @@ -367,7 +367,7 @@ func (c *Plex) SearchSongs(tracks []*models.Track) error { for _, track := range tracks { params := fmt.Sprintf( "/hubs/search?query=%s&limit=10", - url.QueryEscape(track.CleanTitle), + url.QueryEscape(util.CleanSearchTitle(track.CleanTitle)), ) var body []byte @@ -524,6 +524,14 @@ func (c *Plex) DeletePlaylist() error { return nil } +// SetPlaylistArtwork uploads an image as the playlist's poster. +func (c *Plex) SetPlaylistArtwork(localPath string) error { + if c.Cfg.PlaylistID == "" { + return fmt.Errorf("plex: no PlaylistID set") + } + return uploadPlaylistArtwork(c.HttpClient, c.Cfg.URL+"/library/metadata/"+c.Cfg.PlaylistID+"/posters", localPath, c.Cfg.Creds.Headers) +} + func (c *Plex) getServer() error { params := "/identity" @@ -542,7 +550,7 @@ func (c *Plex) getServer() error { } func getPlexSong(track *models.Track, metadata []SongMetadata) (string, error) { - loweredArtist := strings.ToLower(track.MainArtist) + normArtist := util.AlnumOnly(strings.ToLower(track.MainArtist)) for _, md := range metadata { if md.Type != "track" { @@ -550,8 +558,8 @@ func getPlexSong(track *models.Track, metadata []SongMetadata) (string, error) { } titleMatch := util.NormalizeTitle(md.Title) == util.NormalizeTitle(track.Title) - albumMatch := strings.EqualFold(md.ParentTitle, track.Album) - artistMatch := strings.Contains(strings.ToLower(md.OriginalTitle), loweredArtist) || strings.Contains(strings.ToLower(md.GrandparentTitle), loweredArtist) + albumMatch := util.AlnumOnly(strings.ToLower(md.ParentTitle)) == util.AlnumOnly(strings.ToLower(track.Album)) + artistMatch := strings.Contains(util.AlnumOnly(strings.ToLower(md.OriginalTitle)), normArtist) || strings.Contains(util.AlnumOnly(strings.ToLower(md.GrandparentTitle)), normArtist) if titleMatch && (albumMatch || artistMatch) { slog.Debug(fmt.Sprintf("matched track via metadata: %s by %s", track.Title, track.Artist)) diff --git a/src/client/subsonic.go b/src/client/subsonic.go index 55662eda..08c17898 100644 --- a/src/client/subsonic.go +++ b/src/client/subsonic.go @@ -125,7 +125,7 @@ func (c *Subsonic) AddLibrary() error { func (c *Subsonic) SearchSongs(tracks []*models.Track) error { for _, track := range tracks { - searchQuery := fmt.Sprintf("%s %s", track.CleanTitle, track.MainArtist) + searchQuery := fmt.Sprintf("%s %s", util.CleanSearchTitle(track.CleanTitle), track.MainArtist) reqParam := fmt.Sprintf("search3?query=%s&f=json", url.QueryEscape(searchQuery)) body, err := c.subsonicRequest(reqParam) @@ -221,7 +221,7 @@ func (c *Subsonic) CreatePlaylist(tracks []*models.Track) error { fmt.Fprintf(&trackIDs, "&songId=%s", track.ID) } - reqParam := fmt.Sprintf("createPlaylist?name=%s%s&f=json", c.Cfg.PlaylistName, trackIDs.String()) + reqParam := fmt.Sprintf("createPlaylist?name=%s%s&f=json", url.QueryEscape(c.Cfg.PlaylistName), trackIDs.String()) body, err := c.subsonicRequest(reqParam) if err != nil { diff --git a/src/config/config.go b/src/config/config.go index c60cb433..3b336681 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -38,6 +38,7 @@ type Flags struct { ExcludeLocal bool Persist bool PersistSet bool + RefreshOnly bool } type ServerConfig struct { diff --git a/src/config/flags.go b/src/config/flags.go index 890f1993..6c03826c 100644 --- a/src/config/flags.go +++ b/src/config/flags.go @@ -4,9 +4,10 @@ import ( "fmt" "os" - flag "github.com/spf13/pflag" "slices" "strings" + + flag "github.com/spf13/pflag" ) var ( @@ -21,6 +22,7 @@ func (cfg *Config) GetFlags() error { var excludeLocal bool var persist bool var showVersion bool + var refreshOnly bool // Long flags flag.StringVarP(&configPath, "config", "c", ".env", "Path of the configuration file") flag.StringVarP(&playlist, "playlist", "p", "weekly-exploration", "Playlist where to get tracks. Supported: weekly-exploration, weekly-jams, daily-jams, on-repeat") @@ -28,6 +30,7 @@ func (cfg *Config) GetFlags() error { flag.BoolVarP(&excludeLocal, "exclude-local", "e", false, "Exclude locally found tracks from the imported playlist") flag.BoolVar(&persist, "persist", true, "Keep playlists between generations") flag.BoolVarP(&showVersion, "version", "v", false, "Print version and exit") + flag.BoolVar(&refreshOnly, "refresh-only", false, "Trigger alibrary rescan and exit; skips discovery and downloads") flag.Parse() @@ -38,9 +41,9 @@ func (cfg *Config) GetFlags() error { persistSet := flag.Lookup("persist").Changed cfgSet := flag.Lookup("config").Changed - // Validation for playlist - if !contains(validPlaylists, playlist) { - return fmt.Errorf("flag validation error: invalid playlist %s (must be one of: %s)", + // Validation for playlist — built-in types or user-imported custom-* IDs + if !contains(validPlaylists, playlist) && !strings.HasPrefix(playlist, "custom-") { + return fmt.Errorf("flag validation error: invalid playlist %s (must be one of: %s, or a custom-* id)", playlist, strings.Join(validPlaylists, ", ")) } @@ -56,6 +59,7 @@ func (cfg *Config) GetFlags() error { cfg.Flags.DownloadMode = downloadMode cfg.Flags.ExcludeLocal = excludeLocal cfg.Flags.Persist = persist + cfg.Flags.RefreshOnly = refreshOnly // for deprecation purposes (can be removed at a later date) cfg.Flags.PersistSet = persistSet @@ -68,7 +72,7 @@ func (cfg *Config) MergeFlags() { cfg.DownloadCfg.ExcludeLocal = cfg.Flags.ExcludeLocal if cfg.Flags.CfgSet { - cfg.ServerCfg.WebEnvPath = cfg.Flags.CfgPath + cfg.ServerCfg.WebEnvPath = cfg.Flags.CfgPath } if cfg.Flags.PersistSet { diff --git a/src/discovery/listenbrainz.go b/src/discovery/listenbrainz.go index 98252b66..a4436b91 100644 --- a/src/discovery/listenbrainz.go +++ b/src/discovery/listenbrainz.go @@ -206,7 +206,7 @@ func (c *ListenBrainz) QueryTracks() ([]*models.Track, error) { if err != nil { return nil, err } - tracks, err = c.parsePlaylist(id, c.cfg.SingleArtist) + _, tracks, err = c.parsePlaylist(id, c.cfg.SingleArtist) if err != nil { return nil, err } @@ -615,19 +615,43 @@ func (c *ListenBrainz) getImportPlaylist(user string) (string, error) { return bestID, nil } -func (c *ListenBrainz) parsePlaylist(identifier string, singleArtist bool) ([]*models.Track, error) { + +// FetchPlaylistByMBID fetches a LB playlist by MBID. For use outside the discovery flow. +func FetchPlaylistByMBID(httpClient *util.HttpClient, mbid string) (string, []*models.Track, error) { + lb := &ListenBrainz{HttpClient: httpClient} + return lb.parsePlaylist(mbid, false) +} + +// FetchTopRecordings returns the user's top recordings for the current month. +func FetchTopRecordings(httpClient *util.HttpClient, user string) ([]*models.Track, error) { + lb := &ListenBrainz{HttpClient: httpClient} + return lb.getTopRecordings(user) +} + +// FetchMostRecentPlaylistByType finds and fetches the most recent LB-generated playlist of the given type for the user. +func FetchMostRecentPlaylistByType(httpClient *util.HttpClient, user, playlistType string) ([]*models.Track, error) { + lb := &ListenBrainz{HttpClient: httpClient, cfg: cfg.Listenbrainz{ImportPlaylist: playlistType}} + id, err := lb.getImportPlaylist(user) + if err != nil { + return nil, err + } + _, tracks, err := lb.parsePlaylist(id, false) + return tracks, err +} + +func (c *ListenBrainz) parsePlaylist(identifier string, singleArtist bool) (string, []*models.Track, error) { body, err := c.lbRequest(fmt.Sprintf("playlist/%s", identifier)) if err != nil { - return nil, fmt.Errorf("parsePlaylist: %s", err.Error()) + return "", nil, fmt.Errorf("parsePlaylist: %s", err.Error()) } var exploration Exploration err = util.ParseResp(body, &exploration) if err != nil { - return nil, fmt.Errorf("parsePlaylist: %s", err.Error()) + return "", nil, fmt.Errorf("parsePlaylist: %s", err.Error()) } srcTracks := exploration.Playlist.Tracks if len(srcTracks) == 0 { - return nil, fmt.Errorf("no tracks found in playlist %s", identifier) + return "", nil, fmt.Errorf("no tracks found in playlist %s", identifier) } tracks := make([]*models.Track, 0, len(srcTracks)) @@ -687,7 +711,7 @@ func (c *ListenBrainz) parsePlaylist(identifier string, singleArtist bool) ([]*m }) } - return tracks, nil + return exploration.Playlist.Title, tracks, nil } diff --git a/src/downloader/monitor.go b/src/downloader/monitor.go index 364cd150..beb87a8b 100644 --- a/src/downloader/monitor.go +++ b/src/downloader/monitor.go @@ -131,7 +131,7 @@ func tracksProcessed(tracks []*models.Track, progressMap map[string]*DownloadMon key := fmt.Sprintf("%s|%s", track.ID, track.File) tracker, exists := progressMap[key] if !track.Present && exists && !tracker.Skipped { - slog.Info("file still present", "file", track.File) + slog.Info("[monitor] track download still in progress", "title", track.CleanTitle, "artist", track.MainArtist, "file", track.File) return false } } diff --git a/src/main/main.go b/src/main/main.go index 8354a347..1bad8d2e 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -1,12 +1,16 @@ package main import ( + "encoding/json" "explo/src/logging" "explo/src/models" "explo/src/web/backend" + "fmt" "log" "log/slog" "os" + "path/filepath" + "strings" "explo/src/client" "explo/src/config" @@ -21,6 +25,65 @@ type Song struct { Album string } +// loadCustomTracks reads a custom playlist's track cache and returns them as +// models.Track slices, bypassing the LB discovery step entirely. +func loadCustomTracks(dataDir, playlistID string) ([]*models.Track, string, error) { + type cachedTrack struct { + Title string `json:"title"` + Artist string `json:"artist"` + MainArtist string `json:"mainArtist"` + Release string `json:"release"` + CoverURL string `json:"coverUrl"` + } + type cacheFile struct { + Tracks []cachedTrack `json:"tracks"` + } + type customPlaylist struct { + ID string `json:"id"` + Name string `json:"name"` + } + + data, err := os.ReadFile(filepath.Join(dataDir, "cache", playlistID+".json")) + if err != nil { + return nil, "", fmt.Errorf("custom playlist %q not found in cache: %w", playlistID, err) + } + var c cacheFile + if err := json.Unmarshal(data, &c); err != nil { + return nil, "", fmt.Errorf("failed to parse custom playlist cache: %w", err) + } + + // Look up the human-readable name from metadata + name := playlistID + if meta, err := os.ReadFile(filepath.Join(dataDir, "custom-playlists.json")); err == nil { + var all []customPlaylist + if json.Unmarshal(meta, &all) == nil { + for _, p := range all { + if p.ID == playlistID { + name = p.Name + break + } + } + } + } + + tracks := make([]*models.Track, len(c.Tracks)) + for i, t := range c.Tracks { + mainArtist := t.MainArtist + if mainArtist == "" { + mainArtist = t.Artist + } + tracks[i] = &models.Track{ + CleanTitle: t.Title, + Title: t.Title, + Artist: t.Artist, + MainArtist: mainArtist, + Album: t.Release, + CoverURL: t.CoverURL, + } + } + return tracks, name, nil +} + func initHttpClient() *util.HttpClient { return util.NewHttp(util.HttpClientConfig{ Timeout: 10, @@ -60,9 +123,30 @@ func main() { srv := backend.NewServer(cfg.ServerCfg) log.Fatal(srv.Start()) } + + if cfg.Flags.RefreshOnly { + if err := client.TriggerRefresh(&cfg); err != nil { + slog.Error("refresh-only failed", "err", err.Error()) + os.Exit(1) + } + slog.Info("library refresh triggered") + return + } + httpClient := initHttpClient() - discovery := discovery.NewDiscoverer(cfg.DiscoveryCfg, httpClient) - tracks, err := discovery.Discover() + + var tracks []*models.Track + var err error + if strings.HasPrefix(cfg.Flags.Playlist, "custom-") { + var playlistName string + tracks, playlistName, err = loadCustomTracks(cfg.ServerCfg.WebDataDir, cfg.Flags.Playlist) + if err == nil { + cfg.ClientCfg.PlaylistName = playlistName + } + } else { + disc := discovery.NewDiscoverer(cfg.DiscoveryCfg, httpClient) + tracks, err = disc.Discover() + } if err != nil { slog.Error(err.Error(), "notify", true) os.Exit(1) @@ -114,5 +198,37 @@ func main() { slog.Warn(err.Error()) } else { slog.Info("playlist created successfully", "system", cfg.System, "playlistName", cfg.ClientCfg.PlaylistName, "notify", true) + uploadCustomPlaylistArtwork(&cfg, client) + } +} + +// uploadCustomPlaylistArtwork pushes a custom playlist's cached artwork to the music app +// after first successful creation. No-op for non-custom playlists, playlists without +// artwork, or clients that don't support artwork upload (Subsonic, MPD). +func uploadCustomPlaylistArtwork(cfg *config.Config, c *client.Client) { + if !strings.HasPrefix(cfg.Flags.Playlist, "custom-") { + return + } + cp := backend.GetCustomPlaylist(cfg.ServerCfg.WebDataDir, cfg.Flags.Playlist) + if cp == nil || cp.ArtworkURL == "" || cp.ArtworkUploaded { + return + } + uploader, ok := c.API.(client.ArtworkUploader) + if !ok { + return + } + path := backend.CustomPlaylistArtworkPath(cfg.ServerCfg.WebDataDir, cp.ID) + if _, err := os.Stat(path); err != nil { + slog.Warn("custom-playlists: artwork not cached locally, skipping upload", "id", cp.ID, "path", path) + return + } + if err := uploader.SetPlaylistArtwork(path); err != nil { + slog.Warn("custom-playlists: failed to upload playlist artwork", "id", cp.ID, "err", err.Error()) + return + } + if err := backend.MarkCustomPlaylistArtworkUploaded(cfg.ServerCfg.WebDataDir, cp.ID); err != nil { + slog.Warn("custom-playlists: artwork upload succeeded but flag not persisted", "id", cp.ID, "err", err.Error()) + return } + slog.Info("custom-playlists: playlist artwork uploaded", "id", cp.ID, "system", cfg.System) } diff --git a/src/util/filesystem.go b/src/util/filesystem.go new file mode 100644 index 00000000..f4512773 --- /dev/null +++ b/src/util/filesystem.go @@ -0,0 +1,31 @@ +package util + +import ( + "log/slog" + "os" + "path/filepath" +) + +// RemoveDirsByPrefix deletes every immediate subdirectory of parentDir whose +// name starts with prefix. Returns the number of directories successfully removed. +// Non-directory matches and missing parents are ignored; per-entry removal errors +// are logged but do not abort the operation. +func RemoveDirsByPrefix(parentDir, prefix string) (int, error) { + matches, err := filepath.Glob(filepath.Join(parentDir, prefix+"*")) + if err != nil { + return 0, err + } + removed := 0 + for _, m := range matches { + info, err := os.Stat(m) + if err != nil || !info.IsDir() { + continue + } + if err := os.RemoveAll(m); err != nil { + slog.Warn("failed to remove directory", "path", m, "err", err.Error()) + continue + } + removed++ + } + return removed, nil +} diff --git a/src/util/http.go b/src/util/http.go index ca751651..39d029e6 100644 --- a/src/util/http.go +++ b/src/util/http.go @@ -78,15 +78,56 @@ func ParseResp[T any](body []byte, target *T) error { return nil } -// DownloadCover downloads coverURL into coversDir and returns "/api/covers/.jpg". -// Returns "" if url is empty. +// DownloadFile downloads a URL to destPath, creating parent directories as needed. +// No-op if destPath already exists. Returns the resolved local path on success. +func DownloadFile(url, destPath string) (string, error) { + if url == "" { + return "", fmt.Errorf("empty url") + } + if _, err := os.Stat(destPath); err == nil { + return destPath, nil + } + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + return "", fmt.Errorf("mkdir: %w", err) + } + resp, err := http.Get(url) //nolint:noctx + if err != nil { + return "", fmt.Errorf("get: %w", err) + } + defer func() { + if cerr := resp.Body.Close(); cerr != nil { + slog.Warn("DownloadFile: close failed", "err", cerr.Error()) + } + }() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("status %d from %s", resp.StatusCode, url) + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read body: %w", err) + } + if err := os.WriteFile(destPath, data, 0644); err != nil { + return "", fmt.Errorf("write: %w", err) + } + return destPath, nil +} + +// DownloadCover downloads coverURL into coversDir and returns "/api/covers/.jpg". +// For CoverArtArchive URLs the id is the MusicBrainz release MBID (second-to-last +// path segment). For Spotify CDN URLs (i.scdn.co) the id is the image hash (last +// path segment). Returns "" if url is empty. func DownloadCover(url, coversDir string) string { if url == "" { return "" } parts := strings.Split(strings.TrimRight(url, "/"), "/") - mbid := parts[len(parts)-2] - destPath := filepath.Join(coversDir, mbid+".jpg") + // Spotify CDN: https://i.scdn.co/image/ → use last segment + // CAA: https://coverartarchive.org/release//front-250 → use second-to-last + id := parts[len(parts)-2] + if strings.Contains(url, "scdn.co") || strings.Contains(url, "spotifycdn.com") { + id = parts[len(parts)-1] + } + destPath := filepath.Join(coversDir, id+".jpg") if _, err := os.Stat(destPath); os.IsNotExist(err) { resp, err := http.Get(url) //nolint:noctx if err == nil { @@ -106,5 +147,5 @@ func DownloadCover(url, coversDir string) string { }() } } - return "/api/covers/" + mbid + ".jpg" + return "/api/covers/" + id + ".jpg" } diff --git a/src/util/sanitize.go b/src/util/sanitize.go index 1bdf06a9..de04d69f 100644 --- a/src/util/sanitize.go +++ b/src/util/sanitize.go @@ -6,15 +6,25 @@ import ( ) var ( - filenameRe = regexp.MustCompile(`[^\p{L}\d._,\-]+`) - alnumRe = regexp.MustCompile(`[^\p{L}\d]+`) - featTailRe = regexp.MustCompile(`(?i)\s*[\(\[\{]\s*(feat\.?|featuring|ft\.?|with)\s[^\)\]\}]*[\)\]\}]\s*$`) + filenameRe = regexp.MustCompile(`[^\p{L}\d._,\-]+`) + alnumRe = regexp.MustCompile(`[^\p{L}\d]+`) + featTailRe = regexp.MustCompile(`(?i)\s*[\(\[\{]\s*(feat\.?|featuring|ft\.?|with)\s[^\)\]\}]*[\)\]\}]\s*$`) + remasterTailRe = regexp.MustCompile(`(?i)\s*[-–—]\s*\d{4}\s*remaster(ed)?\s*$`) ) +// CleanSearchTitle strips trailing (feat. …) and "- 2011 Remaster" suffixes +// but keeps the title human-readable for use in search API queries. +func CleanSearchTitle(s string) string { + s = featTailRe.ReplaceAllString(s, "") + s = remasterTailRe.ReplaceAllString(s, "") + return strings.TrimSpace(s) +} + // NormalizeTitle strips trailing (feat. …) annotations, lowercases, // and reduces to alphanumeric-only for fuzzy title comparison. func NormalizeTitle(s string) string { s = featTailRe.ReplaceAllString(s, "") + s = remasterTailRe.ReplaceAllString(s, "") return AlnumOnly(strings.ToLower(s)) } @@ -26,4 +36,4 @@ func FilenameSafe(s string) string { // AlnumOnly removes everything except letters and digits func AlnumOnly(s string) string { return alnumRe.ReplaceAllString(s, "") -} \ No newline at end of file +} diff --git a/src/web/backend/apple_music.go b/src/web/backend/apple_music.go new file mode 100644 index 00000000..7f7f59ed --- /dev/null +++ b/src/web/backend/apple_music.go @@ -0,0 +1,254 @@ +package backend + +import ( + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + + "golang.org/x/net/html" +) + +// appleServerData mirrors the top-level shape of the +// ` + startIdx := strings.Index(html, cfgStart) + if startIdx < 0 { + return "", fmt.Errorf("appServerConfig not found in page") + } + startIdx += len(cfgStart) + endIdx := strings.Index(html[startIdx:], cfgEnd) + if endIdx < 0 { + return "", fmt.Errorf("appServerConfig closing tag not found") + } + + cfgJSON, err := base64.StdEncoding.DecodeString(strings.TrimSpace(html[startIdx : startIdx+endIdx])) + if err != nil { + cfgJSON, err = base64.RawStdEncoding.DecodeString(strings.TrimSpace(html[startIdx : startIdx+endIdx])) + if err != nil { + return "", fmt.Errorf("failed to decode appServerConfig: %w", err) + } + } + + var serverCfg struct { + ClientVersion string `json:"clientVersion"` + } + if err := json.Unmarshal(cfgJSON, &serverCfg); err != nil { + return "", fmt.Errorf("failed to parse appServerConfig: %w", err) + } + s.clientVersion = serverCfg.ClientVersion + + // Device ID from sp_t cookie + u, _ := url.Parse("https://open.spotify.com") + for _, c := range s.client.Jar.Cookies(u) { + if c.Name == "sp_t" { + s.deviceID = c.Value + break + } + } + + // Find the web-player JS bundle + var jsPack string + for _, link := range extractJSLinks(html) { + if strings.Contains(link, "web-player/web-player") && strings.HasSuffix(link, ".js") { + jsPack = link + break + } + } + if jsPack == "" { + return "", fmt.Errorf("web-player JS pack not found in page") + } + + return jsPack, nil +} + +// fetchAccessToken obtains a bearer token via TOTP. +func (s *spotifySession) fetchAccessToken() error { + totp, version := generateSpotifyTOTP() + + tokenURL := "https://open.spotify.com/api/token?" + url.Values{ + "reason": {"init"}, + "productType": {"web-player"}, + "totp": {totp}, + "totpVer": {strconv.Itoa(version)}, + "totpServer": {totp}, + }.Encode() + + req, err := http.NewRequest("GET", tokenURL, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", spotifyUA) + req.Header.Set("Accept", "application/json") + req.Header.Set("Referer", "https://open.spotify.com/") + + resp, err := s.client.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(b)) + } + + var tok struct { + AccessToken string `json:"accessToken"` + ClientID string `json:"clientId"` + } + if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil { + return fmt.Errorf("failed to parse token response: %w", err) + } + if tok.AccessToken == "" { + return fmt.Errorf("received empty access token") + } + + s.accessToken = tok.AccessToken + s.clientID = tok.ClientID + return nil +} + +// fetchClientToken obtains a client token from clienttoken.spotify.com. +func (s *spotifySession) fetchClientToken() error { + payload, err := json.Marshal(map[string]any{ + "client_data": map[string]any{ + "client_version": s.clientVersion, + "client_id": s.clientID, + "js_sdk_data": map[string]any{ + "device_brand": "unknown", + "device_model": "unknown", + "os": "windows", + "os_version": "NT 10.0", + "device_id": s.deviceID, + "device_type": "computer", + }, + }, + }) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", "https://clienttoken.spotify.com/v1/clienttoken", bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", spotifyUA) + + resp, err := s.client.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("client token endpoint returned %d: %s", resp.StatusCode, string(b)) + } + + var result struct { + ResponseType string `json:"response_type"` + GrantedToken struct { + Token string `json:"token"` + } `json:"granted_token"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("failed to parse client token response: %w", err) + } + if result.ResponseType != "RESPONSE_GRANTED_TOKEN_RESPONSE" { + return fmt.Errorf("unexpected client token response type: %s", result.ResponseType) + } + if result.GrantedToken.Token == "" { + return fmt.Errorf("received empty client token") + } + + s.clientToken = result.GrantedToken.Token + return nil +} + +// extractPlaylistHash fetches the web-player JS bundle and its webpack chunks +// to find the SHA256 hash for the "fetchPlaylist" persisted GraphQL query. +func (s *spotifySession) extractPlaylistHash(jsPackURL string) error { + req, err := http.NewRequest("GET", jsPackURL, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", spotifyUA) + + resp, err := s.client.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + packBody, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) + if err != nil { + return err + } + jsContent := string(packBody) + + // Check the main pack first + if hash := findPartHash(jsContent, "fetchPlaylist"); hash != "" { + s.playlistHash = hash + return nil + } + + // Fall back to scanning webpack chunks + nameMap, hashMap, err := extractMappings(jsContent) + if err != nil { + return fmt.Errorf("failed to extract chunk mappings: %w", err) + } + + chunkFiles := combineChunks(nameMap, hashMap) + slog.Debug("spotify: scanning JS chunks for hash", "count", len(chunkFiles)) + + type chunkResult struct { + content string + err error + } + + sem := make(chan struct{}, 10) + results := make(chan chunkResult, len(chunkFiles)) + + for _, file := range chunkFiles { + chunkURL := "https://open.spotifycdn.com/cdn/build/web-player/" + file + go func(u string) { + sem <- struct{}{} + defer func() { <-sem }() + + r, err := http.NewRequest("GET", u, nil) + if err != nil { + results <- chunkResult{err: err} + return + } + r.Header.Set("User-Agent", spotifyUA) + + resp, err := s.client.Do(r) + if err != nil { + results <- chunkResult{err: err} + return + } + defer func() { _ = resp.Body.Close() }() + + b, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024)) + if err != nil { + results <- chunkResult{err: err} + return + } + results <- chunkResult{content: string(b)} + }(chunkURL) + } + + for i := 0; i < len(chunkFiles); i++ { + r := <-results + if r.err != nil { + continue + } + if hash := findPartHash(r.content, "fetchPlaylist"); hash != "" { + s.playlistHash = hash + for j := i + 1; j < len(chunkFiles); j++ { + <-results + } + return nil + } + } + + return fmt.Errorf("fetchPlaylist hash not found in any JS bundle") +} + +// ── Partner API ───────────────────────────────────────────────────────────── + +type spotifyImageSource struct { + URL string `json:"url"` + Width int `json:"width"` + Height int `json:"height"` +} + +type partnerPlaylistResp struct { + Data struct { + PlaylistV2 struct { + Name string `json:"name"` + Images struct { + Items []struct { + Sources []spotifyImageSource `json:"sources"` + } `json:"items"` + } `json:"images"` + Content partnerContent `json:"content"` + } `json:"playlistV2"` + } `json:"data"` +} + +type partnerContent struct { + TotalCount int `json:"totalCount"` + Items []partnerItem `json:"items"` +} + +type partnerItem struct { + ItemV2 struct { + Data partnerTrackData `json:"data"` + } `json:"itemV2"` +} + +type partnerTrackData struct { + Typename string `json:"__typename"` + Name string `json:"name"` + Artists struct { + Items []struct { + Profile struct { + Name string `json:"name"` + } `json:"profile"` + } `json:"items"` + } `json:"artists"` + AlbumOfTrack struct { + Name string `json:"name"` + CoverArt struct { + Sources []spotifyImageSource `json:"sources"` + } `json:"coverArt"` + } `json:"albumOfTrack"` +} + +// ── Playlist fetching ─────────────────────────────────────────────────────── + +// fetchSpotifyPlaylist fetches a public Spotify playlist via the internal +// partner API (api-partner.spotify.com). Retries once with a fresh session +// on failure. Returns playlist name, artwork URL, and normalized tracks. +func fetchSpotifyPlaylist(playlistURL string) (string, string, []PlaylistTrack, error) { + id, err := extractSpotifyID(playlistURL) + if err != nil { + return "", "", nil, err + } + + name, artwork, tracks, err := fetchPlaylistByID(id) + if err != nil { + slog.Debug("spotify: retrying with fresh session", "err", err) + spSession.invalidate() + name, artwork, tracks, err = fetchPlaylistByID(id) + } + if err != nil { + return "", "", nil, err + } + if len(tracks) == 0 { + return "", "", nil, fmt.Errorf("spotify: playlist %q has no tracks", id) + } + + slog.Info("spotify: fetched playlist", "name", name, "tracks", len(tracks)) + return name, artwork, tracks, nil +} + +func fetchPlaylistByID(id string) (string, string, []PlaylistTrack, error) { + if err := spSession.ensure(); err != nil { + return "", "", nil, err + } + + const pageSize = 343 + + resp, err := queryPartnerAPI(id, 0, pageSize) + if err != nil { + return "", "", nil, err + } + + pl := resp.Data.PlaylistV2 + name := pl.Name + if name == "" { + name = "Spotify Playlist" + } + + artworkURL := "" + if len(pl.Images.Items) > 0 && len(pl.Images.Items[0].Sources) > 0 { + artworkURL = pickBestSource(pl.Images.Items[0].Sources, 300) + } + + tracks := extractTracks(pl.Content.Items) + + // Paginate + for offset := pageSize; offset < pl.Content.TotalCount; offset += pageSize { + page, err := queryPartnerAPI(id, offset, pageSize) + if err != nil { + slog.Warn("spotify: pagination failed, returning partial results", "offset", offset, "err", err) + break + } + tracks = append(tracks, extractTracks(page.Data.PlaylistV2.Content.Items)...) + } + + return name, artworkURL, tracks, nil +} + +// queryPartnerAPI sends a persisted GraphQL query to api-partner.spotify.com. +func queryPartnerAPI(playlistID string, offset, limit int) (*partnerPlaylistResp, error) { + variables, _ := json.Marshal(map[string]any{ + "uri": "spotify:playlist:" + playlistID, + "offset": offset, + "limit": limit, + "enableWatchFeedEntrypoint": false, + }) + extensions, _ := json.Marshal(map[string]any{ + "persistedQuery": map[string]any{ + "version": 1, + "sha256Hash": spSession.playlistHash, + }, + }) + + reqURL := "https://api-partner.spotify.com/pathfinder/v1/query?" + url.Values{ + "operationName": {"fetchPlaylist"}, + "variables": {string(variables)}, + "extensions": {string(extensions)}, + }.Encode() + + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+spSession.accessToken) + req.Header.Set("Client-Token", spSession.clientToken) + req.Header.Set("Spotify-App-Version", spSession.clientVersion) + req.Header.Set("Accept", "application/json") + req.Header.Set("Accept-Language", "en") + req.Header.Set("User-Agent", spotifyUA) + + resp, err := spSession.client.Do(req) + if err != nil { + return nil, fmt.Errorf("partner API request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, fmt.Errorf("partner API returned %d: %s", resp.StatusCode, string(b)) + } + + var result partnerPlaylistResp + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to parse partner API response: %w", err) + } + return &result, nil +} + +// ── Track extraction helpers ──────────────────────────────────────────────── + +func extractTracks(items []partnerItem) []PlaylistTrack { + tracks := make([]PlaylistTrack, 0, len(items)) + for _, item := range items { + t := item.ItemV2.Data + if t.Name == "" { + continue + } + + artists := make([]string, 0, len(t.Artists.Items)) + for _, a := range t.Artists.Items { + if a.Profile.Name != "" { + artists = append(artists, a.Profile.Name) + } + } + + fullArtist := strings.Join(artists, ", ") + mainArtist := fullArtist + if len(artists) > 0 { + mainArtist = artists[0] + } + + coverURL := "" + if len(t.AlbumOfTrack.CoverArt.Sources) > 0 { + coverURL = pickBestSource(t.AlbumOfTrack.CoverArt.Sources, 300) + } + + tracks = append(tracks, PlaylistTrack{ + Title: t.Name, + Artist: fullArtist, + MainArtist: mainArtist, + Album: t.AlbumOfTrack.Name, + CoverURL: coverURL, + }) + } + return tracks +} + +// pickBestSource returns the image URL closest to targetSize pixels. +func pickBestSource(sources []spotifyImageSource, targetSize int) string { + if len(sources) == 0 { + return "" + } + best := sources[0].URL + bestDiff := abs(sources[0].Height - targetSize) + for _, s := range sources[1:] { + if s.Height == 0 { + continue + } + if diff := abs(s.Height - targetSize); diff < bestDiff { + best = s.URL + bestDiff = diff + } + } + return best +} + +func abs(n int) int { + if n < 0 { + return -n + } + return n +} + +// ── JS bundle parsing ─────────────────────────────────────────────────────── +// +// Spotify's web player uses webpack. The persisted GraphQL query hashes are +// embedded in JS chunks. These helpers extract