From 536eb9b8c0941d76a8165682e376adac50a8311f Mon Sep 17 00:00:00 2001 From: nironics Date: Thu, 30 Apr 2026 09:28:13 -0400 Subject: [PATCH 1/4] Set up automatic Lidarr downloading system based on Plex track ratings --- src/client/lidarr.go | 233 ++++++++++++ src/client/plex.go | 112 ++++++ src/config/config.go | 14 + src/web/frontend/src/components/Wizard.jsx | 224 ++++++++++- src/web/frontend/src/lib/api.js | 34 ++ src/web/lidarr_state.go | 165 ++++++++ src/web/lidarr_sync.go | 422 +++++++++++++++++++++ src/web/sample.env | 23 ++ src/web/server.go | 311 ++++++++++++++- 9 files changed, 1530 insertions(+), 8 deletions(-) create mode 100644 src/client/lidarr.go create mode 100644 src/web/lidarr_state.go create mode 100644 src/web/lidarr_sync.go diff --git a/src/client/lidarr.go b/src/client/lidarr.go new file mode 100644 index 0000000..68fe4c7 --- /dev/null +++ b/src/client/lidarr.go @@ -0,0 +1,233 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "strings" + + "explo/src/config" + "explo/src/util" +) + +type Lidarr struct { + Cfg config.LidarrConfig + HttpClient *util.HttpClient + Headers map[string]string +} + +type LidarrSystemStatus struct { + Version string `json:"version"` + AppName string `json:"appName"` + InstanceID string `json:"instanceName"` +} + +type LidarrAddOptions struct { + Monitor string `json:"monitor"` + SearchForMissingAlbums bool `json:"searchForMissingAlbums"` +} + +type LidarrArtist struct { + ID int `json:"id,omitempty"` + ForeignArtistID string `json:"foreignArtistId"` + ArtistName string `json:"artistName"` + Monitored bool `json:"monitored"` + MonitorNewItems string `json:"monitorNewItems,omitempty"` + QualityProfileID int `json:"qualityProfileId,omitempty"` + MetadataProfileID int `json:"metadataProfileId,omitempty"` + RootFolderPath string `json:"rootFolderPath,omitempty"` + AddOptions *LidarrAddOptions `json:"addOptions,omitempty"` +} + +type LidarrAlbum struct { + ID int `json:"id"` + ForeignAlbumID string `json:"foreignAlbumId"` + Title string `json:"title"` + ArtistID int `json:"artistId"` + Monitored bool `json:"monitored"` +} + +type LidarrCommand struct { + Name string `json:"name"` + AlbumIDs []int `json:"albumIds,omitempty"` + ArtistID int `json:"artistId,omitempty"` +} + +type LidarrRootFolder struct { + ID int `json:"id"` + Path string `json:"path"` + Accessible bool `json:"accessible"` +} + +type LidarrQualityProfile struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type LidarrMetadataProfile struct { + ID int `json:"id"` + Name string `json:"name"` +} + +func NewLidarr(cfg config.LidarrConfig, httpClient *util.HttpClient) *Lidarr { + return &Lidarr{ + Cfg: cfg, + HttpClient: httpClient, + Headers: map[string]string{ + "X-Api-Key": cfg.APIKey, + }, + } +} + +func (c *Lidarr) endpoint(path string) string { + return strings.TrimRight(c.Cfg.URL, "/") + path +} + +func (c *Lidarr) TestConnection() (string, error) { + body, err := c.HttpClient.MakeRequest("GET", c.endpoint("/api/v1/system/status"), nil, c.Headers) + if err != nil { + return "", err + } + var status LidarrSystemStatus + if err := util.ParseResp(body, &status); err != nil { + return "", err + } + return status.Version, nil +} + +func (c *Lidarr) LookupArtist(mbid string) ([]LidarrArtist, error) { + if mbid == "" { + return nil, fmt.Errorf("empty MBID") + } + params := "/api/v1/artist/lookup?term=" + url.QueryEscape("lidarr:"+mbid) + body, err := c.HttpClient.MakeRequest("GET", c.endpoint(params), nil, c.Headers) + if err != nil { + return nil, err + } + var results []LidarrArtist + if err := util.ParseResp(body, &results); err != nil { + return nil, err + } + return results, nil +} + +func (c *Lidarr) LookupArtistByName(name string) ([]LidarrArtist, error) { + params := "/api/v1/artist/lookup?term=" + url.QueryEscape(name) + body, err := c.HttpClient.MakeRequest("GET", c.endpoint(params), nil, c.Headers) + if err != nil { + return nil, err + } + var results []LidarrArtist + if err := util.ParseResp(body, &results); err != nil { + return nil, err + } + return results, nil +} + +func (c *Lidarr) GetArtists() ([]LidarrArtist, error) { + body, err := c.HttpClient.MakeRequest("GET", c.endpoint("/api/v1/artist"), nil, c.Headers) + if err != nil { + return nil, err + } + var results []LidarrArtist + if err := util.ParseResp(body, &results); err != nil { + return nil, err + } + return results, nil +} + +func (c *Lidarr) AddArtist(artist LidarrArtist) (*LidarrArtist, error) { + payload, err := json.Marshal(artist) + if err != nil { + return nil, fmt.Errorf("failed to marshal artist: %s", err.Error()) + } + body, err := c.HttpClient.MakeRequest("POST", c.endpoint("/api/v1/artist"), bytes.NewBuffer(payload), c.Headers) + if err != nil { + return nil, err + } + var created LidarrArtist + if err := util.ParseResp(body, &created); err != nil { + return nil, err + } + return &created, nil +} + +func (c *Lidarr) RefreshArtist(artistID int) error { + cmd := LidarrCommand{Name: "RefreshArtist", ArtistID: artistID} + payload, err := json.Marshal(cmd) + if err != nil { + return fmt.Errorf("failed to marshal command: %s", err.Error()) + } + _, err = c.HttpClient.MakeRequest("POST", c.endpoint("/api/v1/command"), bytes.NewBuffer(payload), c.Headers) + return err +} + +func (c *Lidarr) GetAlbumsByArtist(artistID int) ([]LidarrAlbum, error) { + params := fmt.Sprintf("/api/v1/album?artistId=%d", artistID) + body, err := c.HttpClient.MakeRequest("GET", c.endpoint(params), nil, c.Headers) + if err != nil { + return nil, err + } + var albums []LidarrAlbum + if err := util.ParseResp(body, &albums); err != nil { + return nil, err + } + return albums, nil +} + +func (c *Lidarr) MonitorAlbum(album LidarrAlbum) error { + album.Monitored = true + payload, err := json.Marshal(album) + if err != nil { + return fmt.Errorf("failed to marshal album: %s", err.Error()) + } + _, err = c.HttpClient.MakeRequest("PUT", c.endpoint(fmt.Sprintf("/api/v1/album/%d", album.ID)), bytes.NewBuffer(payload), c.Headers) + return err +} + +func (c *Lidarr) SearchAlbum(albumID int) error { + cmd := LidarrCommand{Name: "AlbumSearch", AlbumIDs: []int{albumID}} + payload, err := json.Marshal(cmd) + if err != nil { + return fmt.Errorf("failed to marshal command: %s", err.Error()) + } + _, err = c.HttpClient.MakeRequest("POST", c.endpoint("/api/v1/command"), bytes.NewBuffer(payload), c.Headers) + return err +} + +func (c *Lidarr) GetRootFolders() ([]LidarrRootFolder, error) { + body, err := c.HttpClient.MakeRequest("GET", c.endpoint("/api/v1/rootfolder"), nil, c.Headers) + if err != nil { + return nil, err + } + var folders []LidarrRootFolder + if err := util.ParseResp(body, &folders); err != nil { + return nil, err + } + return folders, nil +} + +func (c *Lidarr) GetQualityProfiles() ([]LidarrQualityProfile, error) { + body, err := c.HttpClient.MakeRequest("GET", c.endpoint("/api/v1/qualityprofile"), nil, c.Headers) + if err != nil { + return nil, err + } + var profiles []LidarrQualityProfile + if err := util.ParseResp(body, &profiles); err != nil { + return nil, err + } + return profiles, nil +} + +func (c *Lidarr) GetMetadataProfiles() ([]LidarrMetadataProfile, error) { + body, err := c.HttpClient.MakeRequest("GET", c.endpoint("/api/v1/metadataprofile"), nil, c.Headers) + if err != nil { + return nil, err + } + var profiles []LidarrMetadataProfile + if err := util.ParseResp(body, &profiles); err != nil { + return nil, err + } + return profiles, nil +} diff --git a/src/client/plex.go b/src/client/plex.go index 4a0b982..e22a823 100644 --- a/src/client/plex.go +++ b/src/client/plex.go @@ -44,6 +44,72 @@ type Libraries struct { } `json:"MediaContainer"` } +type PlexGuid struct { + ID string `json:"id"` +} + +// PlexTrackMetadata describes a track entity returned by Plex. +// Used by both /library/search results and /library/sections/{id}/all listings. +type PlexTrackMetadata struct { + LibrarySectionTitle string `json:"librarySectionTitle"` + RatingKey string `json:"ratingKey"` + Key string `json:"key"` + Type string `json:"type"` + Title string `json:"title"` // Track + GrandparentTitle string `json:"grandparentTitle"` // Artist + GrandparentRatingKey string `json:"grandparentRatingKey"` + GrandparentGUID string `json:"grandparentGuid"` + ParentTitle string `json:"parentTitle"` // Album + ParentRatingKey string `json:"parentRatingKey"` + ParentGUID string `json:"parentGuid"` + OriginalTitle string `json:"originalTitle"` + Summary string `json:"summary"` + Duration int `json:"duration"` + UserRating float64 `json:"userRating"` + AddedAt int `json:"addedAt"` + UpdatedAt int `json:"updatedAt"` + LastRatedAt int `json:"lastRatedAt"` + GUID string `json:"guid"` + Guid []PlexGuid `json:"Guid"` + Media []struct { + ID int `json:"id"` + Duration int `json:"duration"` + Part []struct { + ID int `json:"id"` + Key string `json:"key"` + Duration int `json:"duration"` + File string `json:"file"` + Size int `json:"size"` + } `json:"Part"` + AudioChannels int `json:"audioChannels"` + AudioCodec string `json:"audioCodec"` + Container string `json:"container"` + } `json:"Media"` +} + +// PlexLibraryItems is the response shape for /library/sections/{id}/all. +type PlexLibraryItems struct { + MediaContainer struct { + Size int `json:"size"` + Metadata []PlexTrackMetadata `json:"Metadata"` + } `json:"MediaContainer"` +} + +// PlexMetadataResponse is the response shape for /library/metadata/{ratingKey}. +type PlexMetadataResponse struct { + MediaContainer struct { + Size int `json:"size"` + Metadata []struct { + RatingKey string `json:"ratingKey"` + Key string `json:"key"` + Type string `json:"type"` + Title string `json:"title"` + GUID string `json:"guid"` + Guid []PlexGuid `json:"Guid"` + } `json:"Metadata"` + } `json:"MediaContainer"` +} + type PlexSearch struct { MediaContainer struct { Size int `json:"size"` @@ -367,6 +433,52 @@ func getPlexSong(track *models.Track, searchResults PlexSearch) (string, error) return "", fmt.Errorf("failed to find '%s' by '%s' in '%s'", track.Title, track.Artist, track.Album) } +// GetRatedTracks returns all tracks in the configured library that have a userRating > 0. +// Plex's filter operator syntax is finicky across versions, so this fetches all tracks +// in the library section and filters in Go. The Explo library is small (typically <200 tracks) +// so the cost is trivial. +func (c *Plex) GetRatedTracks() ([]PlexTrackMetadata, error) { + if c.LibraryID == "" { + return nil, fmt.Errorf("library ID not set; call GetLibrary first") + } + params := fmt.Sprintf("/library/sections/%s/all?type=10&includeGuids=1", c.LibraryID) + + body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+params, nil, c.Cfg.Creds.Headers) + if err != nil { + return nil, fmt.Errorf("failed to fetch library tracks: %s", err.Error()) + } + + var items PlexLibraryItems + if err = util.ParseResp(body, &items); err != nil { + return nil, fmt.Errorf("failed to parse library tracks: %s", err.Error()) + } + + rated := make([]PlexTrackMetadata, 0) + for _, t := range items.MediaContainer.Metadata { + if t.UserRating > 0 { + rated = append(rated, t) + } + } + return rated, nil +} + +// GetArtistMetadata fetches a single metadata entry by ratingKey, used to resolve +// the artist-level MBID (Plex track Guid[] only contains the recording MBID). +func (c *Plex) GetArtistMetadata(ratingKey string) (*PlexMetadataResponse, error) { + params := fmt.Sprintf("/library/metadata/%s?includeGuids=1", url.PathEscape(ratingKey)) + + body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+params, nil, c.Cfg.Creds.Headers) + if err != nil { + return nil, fmt.Errorf("failed to fetch metadata for %s: %s", ratingKey, err.Error()) + } + + var resp PlexMetadataResponse + if err = util.ParseResp(body, &resp); err != nil { + return nil, fmt.Errorf("failed to parse metadata for %s: %s", ratingKey, err.Error()) + } + return &resp, nil +} + func (c *Plex) addtoPlaylist(tracks []*models.Track) { for _, track := range tracks { if track.ID != "" { diff --git a/src/config/config.go b/src/config/config.go index 73eda41..6aaf1e2 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -19,6 +19,7 @@ type Config struct { DiscoveryCfg DiscoveryConfig ClientCfg ClientConfig NotifyCfg NotifyConfig + LidarrCfg LidarrConfig Flags Flags PersistENV bool `env:"PERSIST" env-default:"true"` Persist bool @@ -27,6 +28,18 @@ type Config struct { LogLevel string `env:"LOG_LEVEL" env-default:"INFO"` } +type LidarrConfig struct { + Enabled bool `env:"LIDARR_ENABLED" env-default:"false"` + URL string `env:"LIDARR_URL"` + APIKey string `env:"LIDARR_API_KEY"` + QualityProfileID int `env:"LIDARR_QUALITY_PROFILE_ID"` + MetadataProfileID int `env:"LIDARR_METADATA_PROFILE_ID"` + RootFolderPath string `env:"LIDARR_ROOT_FOLDER"` + PollInterval time.Duration `env:"LIDARR_POLL_INTERVAL" env-default:"15m"` + WebhookEnabled bool `env:"LIDARR_WEBHOOK_ENABLED" env-default:"true"` + HTTPTimeout int `env:"LIDARR_HTTP_TIMEOUT" env-default:"30"` +} + type Flags struct { CfgPath string Playlist string @@ -182,6 +195,7 @@ func (cfg *Config) CommonFixes() { cfg.DownloadCfg.Youtube.FileExtension = strings.TrimPrefix(cfg.DownloadCfg.Youtube.FileExtension, ".") cfg.ClientCfg.URL = fixBaseURL(cfg.ClientCfg.URL) cfg.DownloadCfg.Slskd.URL = fixBaseURL(cfg.DownloadCfg.Slskd.URL) + cfg.LidarrCfg.URL = fixBaseURL(cfg.LidarrCfg.URL) cfg.NormalizeDir() } diff --git a/src/web/frontend/src/components/Wizard.jsx b/src/web/frontend/src/components/Wizard.jsx index d5ea2ed..c5e7bcf 100644 --- a/src/web/frontend/src/components/Wizard.jsx +++ b/src/web/frontend/src/components/Wizard.jsx @@ -8,8 +8,8 @@ * Receives existing config/envSources from App to pre-populate fields. */ -import { useState } from 'react' -import { wizardStep1, wizardStep2, wizardStep3 } from '../lib/api' +import { useEffect, useState } from 'react' +import { wizardStep1, wizardStep2, wizardStep3, wizardStep4, testLidarr, fetchLidarrProfiles, fetchLidarrWebhookUrl } from '../lib/api' import { ToggleRow } from './ui/Toggle' import { DirInput } from './ui/DirInput' import { TextField } from './ui/common' @@ -52,7 +52,7 @@ function Step1({ fields, setField, envSources, onNext, saving }) { return (
-
Step 1 of 3 — Discovery
+
Step 1 of 4 — Discovery

Explo uses your ListenBrainz listening history to find music recommendations.

@@ -145,7 +145,7 @@ function Step2({ fields, setField, envSources, onBack, onNext, saving }) { return (
-
Step 2 of 3 — Media System
+
Step 2 of 4 — Media System

Explo will add discovered tracks to your library and create playlists automatically. It needs access to your media server to do this.

@@ -240,7 +240,7 @@ function Step2({ fields, setField, envSources, onBack, onNext, saving }) { // Collects download service selection (YouTube, Slskd) and their respective // credentials, download directory, and file format preferences. -function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { +function Step3({ fields, setField, envSources, onBack, onNext, saving }) { const { downloadDir, useSubdirectory, migrateDownloads, dlServices, youtubeApiKey, trackExtension, filterList, slskdUrl, slskdApiKey } = fields const isLocked = key => envSources[key] === 'env' @@ -255,7 +255,7 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { return (
-
Step 3 of 3 — Downloader
+
Step 3 of 4 — Downloader

Explo downloads tracks using one or both services. Enable what you have access to — if both are enabled, YouTube is tried first.

@@ -336,6 +336,179 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { )}
+
+ + +
+
+ ) +} + +// ── Step 4: Lidarr (optional) ───────────────────────────────────────────────── +// Optional integration that adds the Artist + Album to Lidarr when you rate a +// track in your Plex library. Skippable. + +function Step4({ fields, setField, envSources, onBack, onFinish, saving }) { + const { lidarrEnabled, lidarrUrl, lidarrApiKey, lidarrRootFolder, + lidarrQualityProfileId, lidarrMetadataProfileId, lidarrPollInterval, lidarrWebhookEnabled } = fields + const isLocked = key => envSources[key] === 'env' + + const [testing, setTesting] = useState(false) + const [testResult, setTestResult] = useState(null) // { ok, version|error } + const [profiles, setProfiles] = useState(null) // { root_folders, quality_profiles, metadata_profiles } + const [profilesError, setProfilesError] = useState('') + const [webhookPath, setWebhookPath] = useState('') + const [copied, setCopied] = useState(false) + + useEffect(() => { + if (!lidarrEnabled) return + fetchLidarrWebhookUrl().then(r => setWebhookPath(r.path)).catch(() => {}) + }, [lidarrEnabled]) + + const handleTest = async () => { + setTesting(true); setTestResult(null); setProfiles(null); setProfilesError('') + try { + const r = await testLidarr(lidarrUrl.trim(), lidarrApiKey.trim()) + setTestResult(r) + if (r.ok) { + try { + const p = await fetchLidarrProfiles(lidarrUrl.trim(), lidarrApiKey.trim()) + setProfiles(p) + } catch (e) { + setProfilesError(e.message) + } + } + } catch (e) { + setTestResult({ ok: false, error: e.message }) + } finally { + setTesting(false) + } + } + + const copyWebhook = async () => { + if (!webhookPath) return + const fullUrl = window.location.origin + webhookPath + try { + await navigator.clipboard.writeText(fullUrl) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch {} + } + + const valid = () => { + if (!lidarrEnabled) return true + if (!lidarrUrl.trim() || !lidarrApiKey.trim()) return false + if (!lidarrRootFolder || !lidarrQualityProfileId || !lidarrMetadataProfileId) return false + return true + } + + return ( +
+
Step 4 of 4 — Lidarr (optional)
+

+ Connect Lidarr so that any track you rate in your Plex library gets its artist and album auto-added for permanent download. Skip this step if you don't use Lidarr. +

+ +
+ setField('lidarrEnabled', v)} + disabled={isLocked('LIDARR_ENABLED')} + name="Enable Lidarr sync" + desc="When on, rating any track in your Plex library triggers a Lidarr add" + /> + + {lidarrEnabled && ( + <> + + setField('lidarrUrl', e.target.value)} + placeholder="e.g. http://localhost:8686" disabled={isLocked('LIDARR_URL')} /> + + + setField('lidarrApiKey', e.target.value)} + autoComplete="off" spellCheck={false} disabled={isLocked('LIDARR_API_KEY')} /> + + +
+ + {testResult && testResult.ok && ( + ✓ Connected (Lidarr v{testResult.version}) + )} + {testResult && !testResult.ok && ( + ✗ {testResult.error || 'failed'} + )} +
+ + {profiles && ( + <> + + + + + + + + + + + )} + {profilesError && ( +
Profiles load failed: {profilesError}
+ )} + + + setField('lidarrPollInterval', e.target.value)} + placeholder="15m" disabled={isLocked('LIDARR_POLL_INTERVAL')} /> + + + setField('lidarrWebhookEnabled', v)} + disabled={isLocked('LIDARR_WEBHOOK_ENABLED')} + name="Plex webhook listener" + desc="Real-time rating events (requires Plex Pass)" + /> + + {lidarrWebhookEnabled && webhookPath && ( + +
+ + +
+
+ )} + + )} +
+
@@ -383,6 +556,15 @@ export default function Wizard({ config, envSources, onComplete }) { filterList: config.FILTER_LIST || '', slskdUrl: config.SLSKD_URL || '', slskdApiKey: config.SLSKD_API_KEY || '', + // Step 4 + lidarrEnabled: config.LIDARR_ENABLED === 'true', + lidarrUrl: config.LIDARR_URL || '', + lidarrApiKey: config.LIDARR_API_KEY || '', + lidarrRootFolder: config.LIDARR_ROOT_FOLDER || '', + lidarrQualityProfileId: config.LIDARR_QUALITY_PROFILE_ID || '', + lidarrMetadataProfileId: config.LIDARR_METADATA_PROFILE_ID || '', + lidarrPollInterval: config.LIDARR_POLL_INTERVAL || '15m', + lidarrWebhookEnabled: config.LIDARR_WEBHOOK_ENABLED !== 'false', } }) @@ -432,6 +614,27 @@ export default function Wizard({ config, envSources, onComplete }) { youtube_api_key: fields.youtubeApiKey, track_extension: fields.trackExtension, filter_list: fields.filterList, slskd_url: fields.slskdUrl, slskd_api_key: fields.slskdApiKey, }) + setStep(4) + } catch (e) { + alert('Error saving: ' + e.message) + } finally { + setSaving(false) + } + } + + async function handleStep4() { + setSaving(true) + try { + await wizardStep4({ + enabled: fields.lidarrEnabled, + url: fields.lidarrUrl.trim(), + api_key: fields.lidarrApiKey.trim(), + root_folder: fields.lidarrRootFolder, + quality_profile_id: Number(fields.lidarrQualityProfileId) || 0, + metadata_profile_id: Number(fields.lidarrMetadataProfileId) || 0, + poll_interval: fields.lidarrPollInterval || '15m', + webhook_enabled: fields.lidarrWebhookEnabled, + }) onComplete() } catch (e) { alert('Error saving: ' + e.message) @@ -470,7 +673,14 @@ export default function Wizard({ config, envSources, onComplete }) { setStep(2)} onFinish={handleStep3} saving={saving} + onBack={() => setStep(2)} onNext={handleStep3} saving={saving} + /> + )} + {step === 4 && ( + setStep(3)} onFinish={handleStep4} saving={saving} /> )}
diff --git a/src/web/frontend/src/lib/api.js b/src/web/frontend/src/lib/api.js index 4f44416..b0a8f91 100644 --- a/src/web/frontend/src/lib/api.js +++ b/src/web/frontend/src/lib/api.js @@ -58,6 +58,40 @@ export async function wizardStep3(body) { if (!res.ok) throw new Error(await res.text()) } +export async function wizardStep4(body) { + const res = await fetch('/api/wizard/step4', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) throw new Error(await res.text()) +} + +export async function testLidarr(url, api_key) { + const res = await fetch('/api/lidarr/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, api_key }), + }) + return res.json() +} + +export async function fetchLidarrProfiles(url, api_key) { + const res = await fetch('/api/lidarr/profiles', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, api_key }), + }) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function fetchLidarrWebhookUrl() { + const res = await fetch('/api/lidarr/webhook-url') + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + export async function fetchBrowse(path) { const res = await fetch('/api/browse?path=' + encodeURIComponent(path || '/')) return res.json() diff --git a/src/web/lidarr_state.go b/src/web/lidarr_state.go new file mode 100644 index 0000000..1195545 --- /dev/null +++ b/src/web/lidarr_state.go @@ -0,0 +1,165 @@ +package web + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +const ratingStateFileVersion = 1 +const maxRatingRetries = 3 + +type RatingStateEntry struct { + RatedAt string `json:"rated_at,omitempty"` + SyncedAt string `json:"synced_at,omitempty"` + Artist string `json:"artist,omitempty"` + Album string `json:"album,omitempty"` + LidarrArtistID int `json:"lidarr_artist_id,omitempty"` + LidarrAlbumID int `json:"lidarr_album_id,omitempty"` + Status string `json:"status,omitempty"` + RetryCount int `json:"retry_count,omitempty"` +} + +type ratingStateFile struct { + Version int `json:"version"` + WebhookToken string `json:"webhook_token,omitempty"` + Entries map[string]RatingStateEntry `json:"entries"` +} + +// RatingState persists the set of Plex ratingKeys we've already processed (or +// permanently failed on) so that webhook + poll paths don't double-process. +type RatingState struct { + mu sync.Mutex + path string + data ratingStateFile +} + +func NewRatingState(path string) *RatingState { + return &RatingState{ + path: path, + data: ratingStateFile{Version: ratingStateFileVersion, Entries: map[string]RatingStateEntry{}}, + } +} + +func (s *RatingState) Load() error { + s.mu.Lock() + defer s.mu.Unlock() + + raw, err := os.ReadFile(s.path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("read rating state: %w", err) + } + if len(raw) == 0 { + return nil + } + if err := json.Unmarshal(raw, &s.data); err != nil { + return fmt.Errorf("parse rating state: %w", err) + } + if s.data.Entries == nil { + s.data.Entries = map[string]RatingStateEntry{} + } + return nil +} + +// Has reports whether a ratingKey is "done" — either successfully synced or +// permanently failed (retry count exceeded). Returns false for entries that +// should still be retried. +func (s *RatingState) Has(ratingKey string) bool { + s.mu.Lock() + defer s.mu.Unlock() + + entry, ok := s.data.Entries[ratingKey] + if !ok { + return false + } + if entry.Status == "ok" { + return true + } + return entry.RetryCount >= maxRatingRetries +} + +func (s *RatingState) Get(ratingKey string) (RatingStateEntry, bool) { + s.mu.Lock() + defer s.mu.Unlock() + entry, ok := s.data.Entries[ratingKey] + return entry, ok +} + +// Mark records a successful sync. Persists immediately. +func (s *RatingState) Mark(ratingKey string, entry RatingStateEntry) error { + s.mu.Lock() + defer s.mu.Unlock() + s.data.Entries[ratingKey] = entry + return s.flushLocked() +} + +// IncrementRetry bumps the retry counter for a failed event and persists. +func (s *RatingState) IncrementRetry(ratingKey, reason string) error { + s.mu.Lock() + defer s.mu.Unlock() + + entry := s.data.Entries[ratingKey] + entry.RetryCount++ + entry.Status = "failed:" + reason + if entry.SyncedAt == "" { + entry.SyncedAt = time.Now().UTC().Format(time.RFC3339) + } + s.data.Entries[ratingKey] = entry + return s.flushLocked() +} + +// WebhookToken returns the persisted webhook secret, generating it on first use. +func (s *RatingState) WebhookToken() (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.data.WebhookToken != "" { + return s.data.WebhookToken, nil + } + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("generate webhook token: %w", err) + } + s.data.WebhookToken = base64.RawURLEncoding.EncodeToString(buf) + if err := s.flushLocked(); err != nil { + return "", err + } + return s.data.WebhookToken, nil +} + +func (s *RatingState) flushLocked() error { + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return fmt.Errorf("create state dir: %w", err) + } + + payload, err := json.MarshalIndent(&s.data, "", " ") + if err != nil { + return fmt.Errorf("encode state: %w", err) + } + + // Atomic write via temp-file + rename. Falls back to a direct write when + // rename fails (e.g. the file is a Docker bind mount, where renaming over + // the inode returns EBUSY). + tmp, err := os.CreateTemp(filepath.Dir(s.path), "lidarr_synced.*.tmp") + if err == nil { + tmpName := tmp.Name() + _, writeErr := tmp.Write(payload) + closeErr := tmp.Close() + if writeErr == nil && closeErr == nil { + if err := os.Rename(tmpName, s.path); err == nil { + return nil + } + } + _ = os.Remove(tmpName) + } + + return os.WriteFile(s.path, payload, 0o644) +} diff --git a/src/web/lidarr_sync.go b/src/web/lidarr_sync.go new file mode 100644 index 0000000..50ec3aa --- /dev/null +++ b/src/web/lidarr_sync.go @@ -0,0 +1,422 @@ +package web + +import ( + "context" + "crypto/subtle" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strings" + "time" + + "explo/src/client" + "explo/src/config" +) + +const ( + plexEventRate = "media.rate" + plexTrackType = "track" + mbidPrefix = "mbid://" + musicBrainzGUIDPrefix = "com.plexapp.agents.musicbrainz://" + albumRefreshTimeout = 30 * time.Second + albumRefreshPoll = 2 * time.Second +) + +// PlexWebhookPayload mirrors the JSON Plex POSTs for webhook events. +// Only the fields we use are declared. +type PlexWebhookPayload struct { + Event string `json:"event"` + Account struct { + ID int `json:"id"` + Title string `json:"title"` + } `json:"Account"` + Metadata client.PlexTrackMetadata `json:"Metadata"` +} + +type ratingEvent struct { + RatingKey string + ArtistName string + ArtistMBID string + AlbumName string + AlbumMBID string + GrandparentRatingKey string + UserRating float64 + AccountTitle string + Source string // "webhook" | "poll" +} + +type LidarrSync struct { + plex *client.Plex + lidarr *client.Lidarr + state *RatingState + cfg config.LidarrConfig + expectedUser string + libraryName string + events chan ratingEvent + webhookToken string +} + +func NewLidarrSync(cfg config.LidarrConfig, plex *client.Plex, lidarr *client.Lidarr, state *RatingState, clientCfg config.ClientConfig) (*LidarrSync, error) { + token, err := state.WebhookToken() + if err != nil { + return nil, err + } + return &LidarrSync{ + plex: plex, + lidarr: lidarr, + state: state, + cfg: cfg, + expectedUser: clientCfg.Creds.User, + libraryName: clientCfg.LibraryName, + events: make(chan ratingEvent, 64), + webhookToken: token, + }, nil +} + +func (s *LidarrSync) WebhookToken() string { return s.webhookToken } + +// Start launches the worker goroutine and (if poll interval > 0) the poll-ticker goroutine. +// Both exit when ctx is canceled. +func (s *LidarrSync) Start(ctx context.Context) { + go s.worker(ctx) + if s.cfg.PollInterval > 0 { + go s.poller(ctx) + } +} + +func (s *LidarrSync) worker(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case ev := <-s.events: + if s.state.Has(ev.RatingKey) { + continue + } + if err := s.processEvent(ctx, ev); err != nil { + slog.Warn("lidarr sync failed", "ratingKey", ev.RatingKey, "artist", ev.ArtistName, "album", ev.AlbumName, "err", err.Error()) + if perr := s.state.IncrementRetry(ev.RatingKey, err.Error()); perr != nil { + slog.Warn("failed to persist rating state", "err", perr.Error()) + } + } + } + } +} + +func (s *LidarrSync) poller(ctx context.Context) { + // Run once shortly after startup so a fresh container catches up before the first tick. + timer := time.NewTimer(10 * time.Second) + defer timer.Stop() + select { + case <-ctx.Done(): + return + case <-timer.C: + if err := s.pollOnce(); err != nil { + slog.Warn("initial lidarr poll failed", "err", err.Error()) + } + } + + t := time.NewTicker(s.cfg.PollInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if err := s.pollOnce(); err != nil { + slog.Warn("lidarr poll failed", "err", err.Error()) + } + } + } +} + +func (s *LidarrSync) pollOnce() error { + tracks, err := s.plex.GetRatedTracks() + if err != nil { + return err + } + for _, t := range tracks { + if t.Type != plexTrackType { + continue + } + if s.state.Has(t.RatingKey) { + continue + } + ev := ratingEvent{ + RatingKey: t.RatingKey, + ArtistName: t.GrandparentTitle, + ArtistMBID: extractArtistMBID(t.Guid, t.GrandparentGUID), + AlbumName: t.ParentTitle, + AlbumMBID: mbidFromGUID(t.ParentGUID), + GrandparentRatingKey: t.GrandparentRatingKey, + UserRating: t.UserRating, + Source: "poll", + } + s.enqueue(ev) + } + return nil +} + +// HandleWebhook is the POST /api/plex/webhook handler. +// Plex POSTs multipart/form-data with a single "payload" form field containing JSON. +func (s *LidarrSync) HandleWebhook(w http.ResponseWriter, r *http.Request) { + got := r.URL.Query().Get("token") + if subtle.ConstantTimeCompare([]byte(got), []byte(s.webhookToken)) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + if err := r.ParseMultipartForm(1 << 20); err != nil { + // Plex always sends multipart, but tolerate alternative encodings just in case. + if perr := r.ParseForm(); perr != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + } + raw := r.FormValue("payload") + if raw == "" { + http.Error(w, "missing payload", http.StatusBadRequest) + return + } + + var payload PlexWebhookPayload + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusOK) + + if !s.shouldHandle(payload) { + return + } + + ev := ratingEvent{ + RatingKey: payload.Metadata.RatingKey, + ArtistName: payload.Metadata.GrandparentTitle, + ArtistMBID: extractArtistMBID(payload.Metadata.Guid, payload.Metadata.GrandparentGUID), + AlbumName: payload.Metadata.ParentTitle, + AlbumMBID: mbidFromGUID(payload.Metadata.ParentGUID), + GrandparentRatingKey: payload.Metadata.GrandparentRatingKey, + UserRating: payload.Metadata.UserRating, + AccountTitle: payload.Account.Title, + Source: "webhook", + } + s.enqueue(ev) +} + +func (s *LidarrSync) shouldHandle(p PlexWebhookPayload) bool { + if p.Event != plexEventRate { + return false + } + if p.Metadata.Type != plexTrackType { + return false + } + if p.Metadata.UserRating <= 0 { + return false + } + if !strings.EqualFold(p.Metadata.LibrarySectionTitle, s.libraryName) { + slog.Debug("ignoring webhook from non-Explo library", "library", p.Metadata.LibrarySectionTitle) + return false + } + if s.expectedUser != "" && p.Account.Title != "" && !strings.EqualFold(p.Account.Title, s.expectedUser) { + slog.Debug("ignoring webhook from non-configured user", "user", p.Account.Title) + return false + } + return true +} + +func (s *LidarrSync) enqueue(ev ratingEvent) { + select { + case s.events <- ev: + default: + slog.Warn("lidarr sync queue full, dropping event", "ratingKey", ev.RatingKey) + } +} + +func (s *LidarrSync) processEvent(ctx context.Context, ev ratingEvent) error { + slog.Info("processing rating", "source", ev.Source, "artist", ev.ArtistName, "album", ev.AlbumName, "rating", ev.UserRating) + + mbid := ev.ArtistMBID + if mbid == "" && ev.GrandparentRatingKey != "" { + resolved, err := s.resolveArtistMBID(ev.GrandparentRatingKey) + if err != nil { + slog.Debug("failed to resolve artist MBID, will fall back to name lookup", "err", err.Error()) + } + mbid = resolved + } + + artistID, err := s.ensureArtist(mbid, ev.ArtistName) + if err != nil { + return fmt.Errorf("ensure artist: %w", err) + } + + if err := s.lidarr.RefreshArtist(artistID); err != nil { + slog.Debug("RefreshArtist failed (non-fatal)", "err", err.Error()) + } + + album, err := s.findAlbum(ctx, artistID, ev.AlbumName, ev.AlbumMBID) + if err != nil { + return fmt.Errorf("find album: %w", err) + } + + if !album.Monitored { + if err := s.lidarr.MonitorAlbum(*album); err != nil { + return fmt.Errorf("monitor album: %w", err) + } + } + if err := s.lidarr.SearchAlbum(album.ID); err != nil { + return fmt.Errorf("search album: %w", err) + } + + entry := RatingStateEntry{ + SyncedAt: time.Now().UTC().Format(time.RFC3339), + Artist: ev.ArtistName, + Album: ev.AlbumName, + LidarrArtistID: artistID, + LidarrAlbumID: album.ID, + Status: "ok", + } + if err := s.state.Mark(ev.RatingKey, entry); err != nil { + slog.Warn("failed to persist successful rating state", "err", err.Error()) + } + slog.Info("queued album for download in Lidarr", "artist", ev.ArtistName, "album", ev.AlbumName, "albumId", album.ID) + return nil +} + +func (s *LidarrSync) resolveArtistMBID(grandparentRatingKey string) (string, error) { + resp, err := s.plex.GetArtistMetadata(grandparentRatingKey) + if err != nil { + return "", err + } + for _, m := range resp.MediaContainer.Metadata { + if mbid := extractArtistMBID(m.Guid, m.GUID); mbid != "" { + return mbid, nil + } + } + return "", fmt.Errorf("no MBID in artist metadata") +} + +// ensureArtist returns the Lidarr artist ID for the given MBID/name, adding it +// to Lidarr if absent. Existing artists are not mutated. +func (s *LidarrSync) ensureArtist(mbid, name string) (int, error) { + existing, err := s.lidarr.GetArtists() + if err != nil { + return 0, fmt.Errorf("list artists: %w", err) + } + for _, a := range existing { + if mbid != "" && strings.EqualFold(a.ForeignArtistID, mbid) { + return a.ID, nil + } + if mbid == "" && strings.EqualFold(a.ArtistName, name) { + return a.ID, nil + } + } + + var lookups []client.LidarrArtist + if mbid != "" { + lookups, err = s.lidarr.LookupArtist(mbid) + if err != nil { + return 0, fmt.Errorf("lookup by MBID: %w", err) + } + } + if len(lookups) == 0 { + lookups, err = s.lidarr.LookupArtistByName(name) + if err != nil { + return 0, fmt.Errorf("lookup by name: %w", err) + } + } + if len(lookups) == 0 { + return 0, fmt.Errorf("artist %q not found in Lidarr lookup", name) + } + + chosen := lookups[0] + if mbid != "" { + for _, l := range lookups { + if strings.EqualFold(l.ForeignArtistID, mbid) { + chosen = l + break + } + } + } + + chosen.Monitored = true + chosen.MonitorNewItems = "none" + chosen.QualityProfileID = s.cfg.QualityProfileID + chosen.MetadataProfileID = s.cfg.MetadataProfileID + chosen.RootFolderPath = s.cfg.RootFolderPath + chosen.AddOptions = &client.LidarrAddOptions{ + Monitor: "none", + SearchForMissingAlbums: false, + } + + created, err := s.lidarr.AddArtist(chosen) + if err != nil { + return 0, fmt.Errorf("add artist: %w", err) + } + return created.ID, nil +} + +func (s *LidarrSync) findAlbum(ctx context.Context, artistID int, title, mbid string) (*client.LidarrAlbum, error) { + deadline := time.Now().Add(albumRefreshTimeout) + for { + albums, err := s.lidarr.GetAlbumsByArtist(artistID) + if err != nil { + return nil, err + } + if match := matchAlbum(albums, title, mbid); match != nil { + return match, nil + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("album %q did not appear in Lidarr after %s", title, albumRefreshTimeout) + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(albumRefreshPoll): + } + } +} + +func matchAlbum(albums []client.LidarrAlbum, title, mbid string) *client.LidarrAlbum { + if mbid != "" { + for i := range albums { + if strings.EqualFold(albums[i].ForeignAlbumID, mbid) { + return &albums[i] + } + } + } + for i := range albums { + if strings.EqualFold(albums[i].Title, title) { + return &albums[i] + } + } + return nil +} + +func extractArtistMBID(guids []client.PlexGuid, fallback string) string { + for _, g := range guids { + if mbid := mbidFromGUID(g.ID); mbid != "" { + return mbid + } + } + return mbidFromGUID(fallback) +} + +func mbidFromGUID(g string) string { + if g == "" { + return "" + } + if strings.HasPrefix(g, mbidPrefix) { + return strings.TrimPrefix(g, mbidPrefix) + } + if strings.HasPrefix(g, musicBrainzGUIDPrefix) { + rest := strings.TrimPrefix(g, musicBrainzGUIDPrefix) + if i := strings.IndexAny(rest, "?#"); i >= 0 { + rest = rest[:i] + } + return rest + } + return "" +} diff --git a/src/web/sample.env b/src/web/sample.env index bf10362..a4bda23 100644 --- a/src/web/sample.env +++ b/src/web/sample.env @@ -120,6 +120,29 @@ YOUTUBE_API_KEY= # MATRIX_ACCESSTOKEN= +# === Lidarr Integration (rate-to-download) === + +# Enable syncing Plex track ratings into Lidarr. +# When enabled, rating any track in your configured LIBRARY_NAME (e.g. "Explo") +# triggers Explo to add the artist to Lidarr and start a search for that album. +# LIDARR_ENABLED=false +# Lidarr base URL +# LIDARR_URL=http://localhost:8686 +# Lidarr API key (Settings -> General in the Lidarr UI) +# LIDARR_API_KEY= +# Path Lidarr should put new music under (must already be configured in Lidarr) +# LIDARR_ROOT_FOLDER=/music/ +# Lidarr Quality Profile ID (the wizard auto-fills this) +# LIDARR_QUALITY_PROFILE_ID=1 +# Lidarr Metadata Profile ID (the wizard auto-fills this) +# LIDARR_METADATA_PROFILE_ID=1 +# Webhook fallback poll cadence — Go duration string (default: 15m) +# LIDARR_POLL_INTERVAL=15m +# Listen for Plex webhooks at /api/plex/webhook (Plex Pass required) +# LIDARR_WEBHOOK_ENABLED=true +# HTTP timeout for Lidarr requests, in seconds (default: 30) +# LIDARR_HTTP_TIMEOUT=30 + # === Misc === # Minutes to sleep between library scans (default: 2) diff --git a/src/web/server.go b/src/web/server.go index 3656d2c..16a10a6 100644 --- a/src/web/server.go +++ b/src/web/server.go @@ -18,6 +18,12 @@ import ( "sync" "syscall" "time" + + "explo/src/client" + "explo/src/config" + "explo/src/util" + + "github.com/ilyakaznacheev/cleanenv" ) //go:embed dist @@ -70,6 +76,9 @@ var allConfigKeys = []string{ "DOWNLOAD_DIR", "USE_SUBDIRECTORY", "DOWNLOAD_SERVICES", "YOUTUBE_API_KEY", "TRACK_EXTENSION", "FILTER_LIST", "SLSKD_URL", "SLSKD_API_KEY", + "LIDARR_ENABLED", "LIDARR_URL", "LIDARR_API_KEY", + "LIDARR_QUALITY_PROFILE_ID", "LIDARR_METADATA_PROFILE_ID", "LIDARR_ROOT_FOLDER", + "LIDARR_POLL_INTERVAL", "LIDARR_WEBHOOK_ENABLED", } // ConfigResponse is returned by GET /api/config. @@ -186,6 +195,57 @@ var configFields = []FieldDef{ VisibleWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "slskd"}, RequiredWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "slskd"}, }, + + // ── Lidarr (rate-to-download) ────────────────────────────────── + { + Key: "LIDARR_ENABLED", Label: "Enable Lidarr Sync", + Type: "text", Section: "lidarr", + Hint: "When 'true', rated tracks in your Plex library are auto-added to Lidarr.", + }, + { + Key: "LIDARR_URL", Label: "Lidarr URL", + Type: "url", Section: "lidarr", + Placeholder: "e.g. http://localhost:8686", + VisibleWhen: &Condition{Field: "LIDARR_ENABLED", Eq: "true"}, + RequiredWhen: &Condition{Field: "LIDARR_ENABLED", Eq: "true"}, + }, + { + Key: "LIDARR_API_KEY", Label: "Lidarr API Key", + Type: "password", Section: "lidarr", + VisibleWhen: &Condition{Field: "LIDARR_ENABLED", Eq: "true"}, + RequiredWhen: &Condition{Field: "LIDARR_ENABLED", Eq: "true"}, + }, + { + Key: "LIDARR_ROOT_FOLDER", Label: "Lidarr Root Folder", + Type: "text", Section: "lidarr", + VisibleWhen: &Condition{Field: "LIDARR_ENABLED", Eq: "true"}, + RequiredWhen: &Condition{Field: "LIDARR_ENABLED", Eq: "true"}, + }, + { + Key: "LIDARR_QUALITY_PROFILE_ID", Label: "Lidarr Quality Profile ID", + Type: "text", Section: "lidarr", + VisibleWhen: &Condition{Field: "LIDARR_ENABLED", Eq: "true"}, + RequiredWhen: &Condition{Field: "LIDARR_ENABLED", Eq: "true"}, + }, + { + Key: "LIDARR_METADATA_PROFILE_ID", Label: "Lidarr Metadata Profile ID", + Type: "text", Section: "lidarr", + VisibleWhen: &Condition{Field: "LIDARR_ENABLED", Eq: "true"}, + RequiredWhen: &Condition{Field: "LIDARR_ENABLED", Eq: "true"}, + }, + { + Key: "LIDARR_POLL_INTERVAL", Label: "Polling Interval", + Type: "text", Section: "lidarr", + Placeholder: "15m", + Hint: "Webhook fallback poll cadence (Go duration string, e.g. 5m, 15m, 1h).", + VisibleWhen: &Condition{Field: "LIDARR_ENABLED", Eq: "true"}, + }, + { + Key: "LIDARR_WEBHOOK_ENABLED", Label: "Webhook Listener", + Type: "text", Section: "lidarr", + Hint: "Set to 'true' to accept Plex webhook events (Plex Pass required).", + VisibleWhen: &Condition{Field: "LIDARR_ENABLED", Eq: "true"}, + }, } // runEvent is an SSE event sent to connected browser clients. @@ -218,6 +278,9 @@ type Server struct { exploPath string mux *http.ServeMux manualRun manualRunState + + lidarrSync *LidarrSync + lidarrCancel context.CancelFunc } func NewServer(configPath, exploPath string) *Server { @@ -269,20 +332,109 @@ func (s *Server) registerRoutes() { s.mux.HandleFunc("POST /api/wizard/step1", s.handleWizardStep1) s.mux.HandleFunc("POST /api/wizard/step2", s.handleWizardStep2) s.mux.HandleFunc("POST /api/wizard/step3", s.handleWizardStep3) + s.mux.HandleFunc("POST /api/wizard/step4", s.handleWizardStep4) s.mux.HandleFunc("GET /api/browse", s.handleBrowse) s.mux.HandleFunc("POST /api/run", s.handleRun) s.mux.HandleFunc("GET /api/run/events", s.handleRunEvents) s.mux.HandleFunc("POST /api/run/stop", s.handleStopRun) s.mux.HandleFunc("GET /api/run/status", s.handleRunStatus) s.mux.HandleFunc("GET /api/logs", s.handleGetLog) + s.mux.HandleFunc("POST /api/lidarr/test", s.handleLidarrTest) + s.mux.HandleFunc("POST /api/lidarr/profiles", s.handleLidarrProfiles) + s.mux.HandleFunc("GET /api/lidarr/webhook-url", s.handleLidarrWebhookURL) + s.mux.HandleFunc("POST /api/plex/webhook", s.handlePlexWebhook) } func (s *Server) Start(addr string) error { s.initServerLog() + if err := s.initLidarrSync(); err != nil { + slog.Warn("Lidarr sync disabled", "err", err.Error()) + } slog.Info("Explo web UI started", "addr", addr) return http.ListenAndServe(addr, s.mux) } +// initLidarrSync reads the persisted .env, and if Lidarr is enabled, builds a +// Plex client + Lidarr client + state store and starts the background workers. +// Errors here are non-fatal — the server boots even if Lidarr is misconfigured. +func (s *Server) initLidarrSync() error { + cfg := &config.Config{} + cfg.Flags.CfgPath = s.configPath + + // cleanenv's .env parser calls os.Setenv on every key in the file, which + // would make handleGetConfig later report all of them as source="env" and + // make the wizard lock the fields. Snapshot the real environment first and + // unset whatever cleanenv added. + preExisting := make(map[string]struct{}, len(os.Environ())) + for _, kv := range os.Environ() { + if i := strings.IndexByte(kv, '='); i >= 0 { + preExisting[kv[:i]] = struct{}{} + } + } + defer func() { + for _, kv := range os.Environ() { + i := strings.IndexByte(kv, '=') + if i < 0 { + continue + } + if _, was := preExisting[kv[:i]]; !was { + os.Unsetenv(kv[:i]) + } + } + }() + + if err := cleanenv.ReadConfig(s.configPath, cfg); err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("read config: %w", err) + } + // no env file yet — that's fine, just don't enable Lidarr + return nil + } + cfg.CommonFixes() + + if !cfg.LidarrCfg.Enabled { + return nil + } + if cfg.LidarrCfg.URL == "" || cfg.LidarrCfg.APIKey == "" { + return fmt.Errorf("LIDARR_URL and LIDARR_API_KEY are required") + } + if cfg.LidarrCfg.RootFolderPath == "" || cfg.LidarrCfg.QualityProfileID == 0 || cfg.LidarrCfg.MetadataProfileID == 0 { + return fmt.Errorf("LIDARR_ROOT_FOLDER, LIDARR_QUALITY_PROFILE_ID, and LIDARR_METADATA_PROFILE_ID are required") + } + if cfg.System != "plex" { + return fmt.Errorf("Lidarr sync currently only supports Plex") + } + + mediaClient, err := client.NewClient(cfg) + if err != nil { + return fmt.Errorf("plex setup: %w", err) + } + plexClient, ok := mediaClient.API.(*client.Plex) + if !ok { + return fmt.Errorf("expected Plex client, got %T", mediaClient.API) + } + + lidarrClient := client.NewLidarr(cfg.LidarrCfg, util.NewHttp(util.HttpClientConfig{Timeout: cfg.LidarrCfg.HTTPTimeout})) + + statePath := filepath.Join(filepath.Dir(s.configPath), "lidarr_synced.json") + state := NewRatingState(statePath) + if err := state.Load(); err != nil { + return fmt.Errorf("load state: %w", err) + } + + sync, err := NewLidarrSync(cfg.LidarrCfg, plexClient, lidarrClient, state, cfg.ClientCfg) + if err != nil { + return fmt.Errorf("init sync: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + s.lidarrSync = sync + s.lidarrCancel = cancel + sync.Start(ctx) + slog.Info("Lidarr sync enabled", "poll_interval", cfg.LidarrCfg.PollInterval, "webhook_enabled", cfg.LidarrCfg.WebhookEnabled) + return nil +} + // ── Logging ──────────────────────────────────────────────────────────────── // logPath returns the path to the single rolling log file. @@ -704,7 +856,7 @@ var errRunAlreadyStarted = errors.New("run already in progress") // handleRun starts an explo run in the background. Clients follow output via /api/run/events. func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { + if err := r.ParseMultipartForm(1 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) { http.Error(w, "bad form data", http.StatusBadRequest) return } @@ -960,6 +1112,163 @@ func (s *Server) unsubscribeRun(ch chan runEvent) { s.manualRun.mu.Unlock() } +// ── Lidarr handlers ──────────────────────────────────────────────────────── + +// handleLidarrTest validates a URL/API-key pair against Lidarr's /system/status. +// Used by the wizard before the user has saved Lidarr config to .env. +func (s *Server) handleLidarrTest(w http.ResponseWriter, r *http.Request) { + var body struct { + URL string `json:"url"` + APIKey string `json:"api_key"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + if body.URL == "" || body.APIKey == "" { + http.Error(w, "url and api_key are required", http.StatusBadRequest) + return + } + c := client.NewLidarr( + config.LidarrConfig{URL: body.URL, APIKey: body.APIKey}, + util.NewHttp(util.HttpClientConfig{Timeout: 15}), + ) + version, err := c.TestConnection() + if err != nil { + w.WriteHeader(http.StatusBadGateway) + json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": err.Error()}) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"ok": true, "version": version}) +} + +// handleLidarrProfiles returns root folders, quality profiles, and metadata profiles. +// Body: {url, api_key}. POST so credentials aren't logged in URLs. +func (s *Server) handleLidarrProfiles(w http.ResponseWriter, r *http.Request) { + var body struct { + URL string `json:"url"` + APIKey string `json:"api_key"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + if body.URL == "" || body.APIKey == "" { + http.Error(w, "url and api_key are required", http.StatusBadRequest) + return + } + c := client.NewLidarr( + config.LidarrConfig{URL: body.URL, APIKey: body.APIKey}, + util.NewHttp(util.HttpClientConfig{Timeout: 15}), + ) + roots, err := c.GetRootFolders() + if err != nil { + http.Error(w, "rootfolders: "+err.Error(), http.StatusBadGateway) + return + } + quality, err := c.GetQualityProfiles() + if err != nil { + http.Error(w, "qualityprofiles: "+err.Error(), http.StatusBadGateway) + return + } + metadata, err := c.GetMetadataProfiles() + if err != nil { + http.Error(w, "metadataprofiles: "+err.Error(), http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "root_folders": roots, + "quality_profiles": quality, + "metadata_profiles": metadata, + }) +} + +// handleLidarrWebhookURL returns the persisted webhook URL the user should paste into Plex. +// Returns the path (with token query); the host is whatever Plex can reach the web server at. +func (s *Server) handleLidarrWebhookURL(w http.ResponseWriter, r *http.Request) { + statePath := filepath.Join(filepath.Dir(s.configPath), "lidarr_synced.json") + state := NewRatingState(statePath) + if err := state.Load(); err != nil { + http.Error(w, "load state: "+err.Error(), http.StatusInternalServerError) + return + } + token, err := state.WebhookToken() + if err != nil { + http.Error(w, "token: "+err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "path": "/api/plex/webhook?token=" + token, + }) +} + +// handlePlexWebhook delegates to the LidarrSync if enabled, otherwise 404. +func (s *Server) handlePlexWebhook(w http.ResponseWriter, r *http.Request) { + if s.lidarrSync == nil { + http.Error(w, "lidarr sync disabled", http.StatusNotFound) + return + } + s.lidarrSync.HandleWebhook(w, r) +} + +// handleWizardStep4 saves Lidarr settings. +func (s *Server) handleWizardStep4(w http.ResponseWriter, r *http.Request) { + var body struct { + Enabled bool `json:"enabled"` + URL string `json:"url"` + APIKey string `json:"api_key"` + RootFolder string `json:"root_folder"` + QualityProfileID int `json:"quality_profile_id"` + MetadataProfileID int `json:"metadata_profile_id"` + PollInterval string `json:"poll_interval"` + WebhookEnabled bool `json:"webhook_enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) + return + } + + if !body.Enabled { + updates := map[string]string{"LIDARR_ENABLED": "false"} + if err := updateEnvKeys(s.configPath, updates, sampleEnv); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + return + } + + if body.URL == "" || body.APIKey == "" || body.RootFolder == "" || body.QualityProfileID == 0 || body.MetadataProfileID == 0 { + http.Error(w, "url, api_key, root_folder, quality_profile_id, metadata_profile_id are required", http.StatusBadRequest) + return + } + if body.PollInterval == "" { + body.PollInterval = "15m" + } + webhook := "false" + if body.WebhookEnabled { + webhook = "true" + } + updates := map[string]string{ + "LIDARR_ENABLED": "true", + "LIDARR_URL": body.URL, + "LIDARR_API_KEY": body.APIKey, + "LIDARR_ROOT_FOLDER": body.RootFolder, + "LIDARR_QUALITY_PROFILE_ID": fmt.Sprintf("%d", body.QualityProfileID), + "LIDARR_METADATA_PROFILE_ID": fmt.Sprintf("%d", body.MetadataProfileID), + "LIDARR_POLL_INTERVAL": body.PollInterval, + "LIDARR_WEBHOOK_ENABLED": webhook, + } + if err := updateEnvKeys(s.configPath, updates, sampleEnv); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + // ── Helpers ──────────────────────────────────────────────────────────────── func buildArgs(playlist, downloadMode string, noPersist, excludeLocal bool, cfgPath string) []string { From 1f6ce2ae9149ca536c4bf0cb184de1258901aba7 Mon Sep 17 00:00:00 2001 From: nironics Date: Thu, 30 Apr 2026 09:47:48 -0400 Subject: [PATCH 2/4] Merge latest upstream changes into the fork --- .gitignore | 1 + README.md | 11 +- docker-compose.yaml | 1 + sample.env | 1 + src/client/emby.go | 2 +- src/client/jellyfin.go | 2 +- src/client/subsonic.go | 6 - src/config/flags.go | 4 +- src/discovery/listenbrainz.go | 122 +++-- src/downloader/downloader.go | 17 +- src/main/main.go | 76 +++ src/models/types.go | 1 + src/web/frontend/index.html | 3 + src/web/frontend/package-lock.json | 75 +++ src/web/frontend/package.json | 1 + src/web/frontend/src/App.jsx | 2 +- src/web/frontend/src/components/Settings.jsx | 304 ++++++------ src/web/frontend/src/components/Wizard.jsx | 243 ++++++---- .../src/components/ui/PlaylistCard.jsx | 437 ++++++++++++++++++ src/web/frontend/src/components/ui/Toggle.jsx | 28 +- src/web/frontend/src/components/ui/common.jsx | 11 +- src/web/frontend/src/index.css | 9 + src/web/frontend/src/lib/listenbrainz.js | 19 + src/web/frontend/src/lib/utils.js | 20 +- src/web/playlists.go | 235 ++++++++++ src/web/sample.env | 1 + src/web/server.go | 81 ++-- 27 files changed, 1372 insertions(+), 341 deletions(-) create mode 100644 src/web/frontend/src/components/ui/PlaylistCard.jsx create mode 100644 src/web/frontend/src/lib/listenbrainz.js create mode 100644 src/web/playlists.go diff --git a/.gitignore b/.gitignore index 974e4a1..d62515b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ logs/ explo src/web/dist/ src/web/frontend/node_modules/ +/cache \ No newline at end of file diff --git a/README.md b/README.md index c1a1c69..2364509 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,12 @@ # Explo - Music Discovery for Self-Hosted Music Systems + +[![Discord](https://img.shields.io/discord/1497141529696014409?style=flat&logo=Discord&labelColor=white&color=black&link=https%3A%2F%2Fdiscord.gg%2FuFWWPaN2zk)](https://discord.gg/uFWWPaN2zk) + + **Explo** bridges the gap between music discovery and self-hosted music systems. Its main function is to act as a self-hosted alternative to Spotify’s *Discover Weekly*, automating music discovery based on your listening history. -Explo uses the [ListenBrainz](https://listenbrainz.org/) recommendation engine to retrieve personalized tracks and downloads them directly into your music library. +Explo uses the [ListenBrainz](https://listenbrainz.org/) recommendation engine to retrieve personalized tracks and requests them directly into your music library. --- @@ -12,7 +16,7 @@ Explo uses the [ListenBrainz](https://listenbrainz.org/) recommendation engine t - Weekly Exploration - Weekly Jams - Daily Jams -- Download tracks from YouTube, Soulseek, or both +- Request tracks from YouTube, Soulseek, or both - Add metadata (title, artist, album) to YouTube downloads - Create playlists in your music system - Keep previous playlists for later listening @@ -29,6 +33,7 @@ Or jump directly to: - [System Notes](https://github.com/LumePart/Explo/wiki/4.-System-Notes) – Known issues and system-specific tips - [FAQ](https://github.com/LumePart/Explo/wiki/6.-FAQ) – Common questions + ## Acknowledgements Explo uses the following 3rd-party libraries: @@ -46,3 +51,5 @@ Explo uses the following 3rd-party libraries: ## Contributing Contributions are always welcome! If you have any suggestions, bug reports, or feature requests, please open an issue or submit a pull request. + +For discussion regarding development or help, join our [Discord!](https://discord.gg/uFWWPaN2zk) \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index a780efd..c46fc7e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -9,6 +9,7 @@ services: - "host.docker.internal:host-gateway" volumes: - /path/to/.env:/opt/explo/.env + - /path/to/explo/config:/opt/explo/config # required — stores playlist cache and cover art - /path/to/musiclibrary/explo:/data/ # has to be in the same path you have your music system pointed to (it's recommended to put explo under a subfolder) # - /path/to/slskd/downloads:/slskd/ # if using slskd and MIGRATE_DOWNLOADS is set to true in .env # - $PLAYLIST_DIR:$PLAYLIST_DIR # for MPD. Both paths should be as defined in .env (e.g /my/playlists/:/my/playlists/) diff --git a/sample.env b/sample.env index bf10362..29273b8 100644 --- a/sample.env +++ b/sample.env @@ -122,6 +122,7 @@ YOUTUBE_API_KEY= # === Misc === +# WIZARD_COMPLETE=false # Minutes to sleep between library scans (default: 2) # SLEEP=2 # Set the log level (DEBUG, INFO, WARN, ERROR) (default: INFO) diff --git a/src/client/emby.go b/src/client/emby.go index 3d9ad3a..e3fdd2e 100644 --- a/src/client/emby.go +++ b/src/client/emby.go @@ -139,7 +139,7 @@ func (c *Emby) SearchSongs(tracks []*models.Track) error { } for _, item := range results.Items { - if strings.EqualFold(track.MainArtist, item.AlbumArtist) && (strings.EqualFold(item.Name, track.CleanTitle) || strings.Contains(strings.ToLower(item.Path), strings.ToLower(track.File))) { + if strings.EqualFold(track.MainArtist, item.AlbumArtist) && (strings.EqualFold(item.Name, track.CleanTitle) || (track.File != "" && strings.Contains(strings.ToLower(item.Path), strings.ToLower(track.File)))) { track.ID = item.ID track.Present = true break diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go index b6a32b5..f70fd01 100644 --- a/src/client/jellyfin.go +++ b/src/client/jellyfin.go @@ -149,7 +149,7 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error { } for _, item := range results.Items { - if strings.EqualFold(track.MainArtist, item.AlbumArtist) && (strings.EqualFold(item.Name, track.CleanTitle) || strings.Contains(strings.ToLower(item.Path), strings.ToLower(track.File))) { + if strings.EqualFold(track.MainArtist, item.AlbumArtist) && (strings.EqualFold(item.Name, track.CleanTitle) || (track.File != "" && strings.Contains(strings.ToLower(item.Path), strings.ToLower(track.File)))) { track.ID = item.ID track.Present = true break diff --git a/src/client/subsonic.go b/src/client/subsonic.go index aa8d196..01d6689 100644 --- a/src/client/subsonic.go +++ b/src/client/subsonic.go @@ -144,12 +144,6 @@ func (c *Subsonic) SearchSongs(tracks []*models.Track) error { continue } - if len(songs) == 1 { - track.ID = songs[0].ID - track.Present = true - continue - } - for _, song := range songs { artistMatch := strings.Contains(strings.ToLower(song.Artist), strings.ToLower(track.MainArtist)) titleMatch := strings.EqualFold(song.Title, track.Title) || strings.EqualFold(song.Title, track.CleanTitle) diff --git a/src/config/flags.go b/src/config/flags.go index 58cf0a5..ca363a5 100644 --- a/src/config/flags.go +++ b/src/config/flags.go @@ -8,7 +8,7 @@ import ( ) var ( - validPlaylists = []string{"weekly-exploration", "weekly-jams", "daily-jams"} + validPlaylists = []string{"weekly-exploration", "weekly-jams", "daily-jams", "on-repeat"} validDownloadMode = []string{"normal", "skip", "force"} ) @@ -20,7 +20,7 @@ func (cfg *Config) GetFlags() error { var persist 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") + flag.StringVarP(&playlist, "playlist", "p", "weekly-exploration", "Playlist where to get tracks. Supported: weekly-exploration, weekly-jams, daily-jams, on-repeat") flag.StringVarP(&downloadMode, "download-mode", "d", "normal", "Download mode: 'normal' (download only when track is not found locally), 'skip' (skip downloading, only use tracks already found locally), 'force' (always download, don't check for local tracks)") flag.BoolVarP(&excludeLocal, "exclude-local", "e", false, "Exclude locally found tracks from the imported playlist") flag.BoolVar(&persist, "persist", true, "Keep playlists between generations") diff --git a/src/discovery/listenbrainz.go b/src/discovery/listenbrainz.go index 3151e8c..f4c0654 100644 --- a/src/discovery/listenbrainz.go +++ b/src/discovery/listenbrainz.go @@ -1,7 +1,6 @@ package discovery import ( - "errors" "fmt" "log/slog" "strings" @@ -113,6 +112,17 @@ type Exploration struct { } `json:"playlist"` } +type TopRecordings struct { + Payload struct { + Recordings []struct { + ArtistName string `json:"artist_name"` + ReleaseMbid string `json:"release_mbid"` + ReleaseName string `json:"release_name"` + TrackName string `json:"track_name"` + } `json:"recordings"` + } `json:"payload"` +} + type ListenBrainz struct { HttpClient *util.HttpClient cfg cfg.Listenbrainz @@ -126,6 +136,11 @@ func NewListenBrainz(cfg cfg.DiscoveryConfig, httpClient *util.HttpClient) *List } } func (c *ListenBrainz) QueryTracks() ([]*models.Track, error) { + // Stats-based playlists bypass the discovery mode switch + if c.cfg.ImportPlaylist == "on-repeat" { + return c.getTopRecordings(c.cfg.User) + } + var tracks []*models.Track switch c.cfg.Discovery { @@ -176,6 +191,40 @@ func (c *ListenBrainz) getAPIRecommendations(user string) ([]string, error) { return mbids, nil } +func (c *ListenBrainz) getTopRecordings(user string) ([]*models.Track, error) { + body, err := c.lbRequest(fmt.Sprintf("stats/user/%s/recordings?count=30&range=month", user)) + if err != nil { + return nil, fmt.Errorf("getTopRecordings(): %s", err.Error()) + } + + var resp TopRecordings + if err := util.ParseResp(body, &resp); err != nil { + return nil, fmt.Errorf("getTopRecordings(): %s", err.Error()) + } + + if len(resp.Payload.Recordings) == 0 { + return nil, fmt.Errorf("no top recordings found for user %s", user) + } + + tracks := make([]*models.Track, 0, len(resp.Payload.Recordings)) + for _, rec := range resp.Payload.Recordings { + var coverURL string + if rec.ReleaseMbid != "" { + coverURL = fmt.Sprintf("https://coverartarchive.org/release/%s/front-250", rec.ReleaseMbid) + } + tracks = append(tracks, &models.Track{ + Title: rec.TrackName, + CleanTitle: rec.TrackName, + Artist: rec.ArtistName, + MainArtist: rec.ArtistName, + Album: rec.ReleaseName, + CoverURL: coverURL, + }) + } + + return tracks, nil +} + func (c *ListenBrainz) getTracks(mbids []string, singleArtist bool) ([]*models.Track, error) { strMbids := strings.Join(mbids, ",") @@ -238,6 +287,9 @@ func (c *ListenBrainz) getTracks(mbids []string, singleArtist bool) ([]*models.T // Get user LB playlists and find wanted playlists ID func (c *ListenBrainz) getImportPlaylist(user string) (string, error) { var offset int + var bestDate time.Time + var bestID string + for { var body []byte var err error @@ -247,7 +299,6 @@ func (c *ListenBrainz) getImportPlaylist(user string) (string, error) { if err == nil { break } - slog.Warn( "failed getting response from ListenBrainz, retrying in 5 minutes", "retry", retries+1, @@ -261,63 +312,35 @@ func (c *ListenBrainz) getImportPlaylist(user string) (string, error) { } var playlists CreatedFor - err = util.ParseResp(body, &playlists) - if err != nil { + if err = util.ParseResp(body, &playlists); err != nil { return "", fmt.Errorf("getImportPlaylist(): %s", err.Error()) } - if id, err := c.parseCreatedFor(playlists); err == nil { - return id, nil + for _, p := range playlists.Playlists { + meta := p.Playlist.Extension.HTTPSJspfPlaylist.AdditionalMetadata + if meta.AlgorithmMetadata.SourcePatch != c.cfg.ImportPlaylist { + continue + } + if bestID == "" || p.Playlist.Date.After(bestDate) { + bestDate = p.Playlist.Date + parts := strings.Split(p.Playlist.Identifier, "/") + bestID = parts[len(parts)-1] + } } - if playlists.Count+playlists.Offset >= playlists.PlaylistCount { + if playlists.Count+playlists.Offset >= playlists.PlaylistCount || playlists.Count == 0 { break } - offset += playlists.Count } - return "", fmt.Errorf("failed to get %s playlist, check if ListenBrainz has generated one", c.cfg.ImportPlaylist) -} -func (c *ListenBrainz) parseCreatedFor(playlists CreatedFor) (string, error) { - var currentWeek, currentDay int - now := time.Now().Local() - isDaily := c.cfg.ImportPlaylist == "daily-jams" - if isDaily { - currentDay = now.YearDay() - } else { - _, currentWeek = now.ISOWeek() + if bestID == "" { + return "", fmt.Errorf("failed to get %s playlist, check if ListenBrainz has generated one", c.cfg.ImportPlaylist) } - - for _, p := range playlists.Playlists { - meta := p.Playlist.Extension.HTTPSJspfPlaylist.AdditionalMetadata - - if meta.AlgorithmMetadata.SourcePatch != c.cfg.ImportPlaylist { - continue - } - - created := p.Playlist.Date.Local() - var timeMatch bool - - if isDaily { - timeMatch = created.YearDay() == currentDay - } else { - _, w := created.ISOWeek() - timeMatch = w == currentWeek - } - - if !timeMatch { - continue - } - - parts := strings.Split(p.Playlist.Identifier, "/") - return parts[len(parts)-1], nil - } - - slog.Debug("playlist output", "playlists", playlists) - return "", errors.New("playlist not found in this page") + return bestID, nil } + func (c *ListenBrainz) parsePlaylist(identifier string, singleArtist bool) ([]*models.Track, error) { body, err := c.lbRequest(fmt.Sprintf("playlist/%s", identifier)) if err != nil { @@ -342,6 +365,12 @@ func (c *ListenBrainz) parsePlaylist(identifier string, singleArtist bool) ([]*m trackMeta := track.Extension.HTTPSJspfTrack.AdditionalMetadata trackArtists := trackMeta.Artists + var coverURL string + if trackMeta.CaaReleaseMbid != "" && trackMeta.CaaID != 0 { + coverURL = fmt.Sprintf("https://coverartarchive.org/release/%s/%d-250.jpg", + trackMeta.CaaReleaseMbid, trackMeta.CaaID) + } + if len(trackMeta.Artists) > 1 { mainArtist = trackMeta.Artists[0].ArtistCreditName if singleArtist { @@ -366,6 +395,7 @@ func (c *ListenBrainz) parsePlaylist(identifier string, singleArtist bool) ([]*m CleanTitle: track.Title, Title: title, Duration: track.Duration, + CoverURL: coverURL, }) } diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index c979265..0235c16 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -52,9 +52,11 @@ func (c *DownloadClient) StartDownload(tracks *[]*models.Track) { if c.Cfg.ExcludeLocal { // remove locally found tracks, so they can't be added to playlist filterLocalTracks(tracks, true) } - if err := os.MkdirAll(c.Cfg.DownloadDir, 0755); err != nil { - slog.Error(err.Error()) - return + if c.needsDownloadDir() { + if err := os.MkdirAll(c.Cfg.DownloadDir, 0755); err != nil { + slog.Error(err.Error()) + return + } } for _, d := range c.Downloaders { @@ -93,6 +95,15 @@ func (c *DownloadClient) StartDownload(tracks *[]*models.Track) { filterLocalTracks(tracks, false) } +func (c *DownloadClient) needsDownloadDir() bool { + for _, svc := range c.Cfg.Services { + if svc == "youtube" || svc == "youtube-music" { + return true + } + } + return c.Cfg.Slskd.MigrateDL +} + func (c *DownloadClient) DeleteSongs() { entries, err := os.ReadDir(c.Cfg.DownloadDir) if err != nil { diff --git a/src/main/main.go b/src/main/main.go index 164a217..172a5b5 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -1,11 +1,17 @@ package main import ( + "encoding/json" "explo/src/logging" + "explo/src/models" "explo/src/web" + "io" "log" "log/slog" + "net/http" "os" + "path/filepath" + "strings" "explo/src/client" "explo/src/config" @@ -26,6 +32,69 @@ func initHttpClient() *util.HttpClient { }) } +// writePlaylistCache downloads cover art and writes a tracklist JSON for the web UI. +// added maps "CleanTitle|Artist" → true for tracks that made it into the playlist; nil means status unknown. +func writePlaylistCache(cfgPath, playlist string, tracks []*models.Track, added map[string]bool) { + type cachedTrack struct { + Rank int `json:"rank"` + Title string `json:"title"` + Artist string `json:"artist"` + Release string `json:"release"` + CoverURL string `json:"coverUrl,omitempty"` + InLibrary *bool `json:"inLibrary,omitempty"` + } + type cache struct { + Tracks []cachedTrack `json:"tracks"` + } + + cfgDir := filepath.Dir(cfgPath) + coversDir := filepath.Join(cfgDir, "cache", "covers") + os.MkdirAll(coversDir, 0755) + + ct := make([]cachedTrack, len(tracks)) + for i, t := range tracks { + localCover := "" + if t.CoverURL != "" { + // Use the CAA release MBID (second-to-last path segment) as filename. + parts := strings.Split(strings.TrimRight(t.CoverURL, "/"), "/") + mbid := parts[len(parts)-2] + destPath := filepath.Join(coversDir, mbid+".jpg") + if _, err := os.Stat(destPath); os.IsNotExist(err) { + if resp, err := http.Get(t.CoverURL); err == nil { //nolint:noctx + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + if data, err := io.ReadAll(resp.Body); err == nil { + os.WriteFile(destPath, data, 0644) + } + } + } + } + localCover = "/api/covers/" + mbid + ".jpg" + } + var inLibrary *bool + if added != nil { + v := added[t.CleanTitle+"|"+t.Artist] + inLibrary = &v + } + ct[i] = cachedTrack{ + Rank: i + 1, + Title: t.CleanTitle, + Artist: t.Artist, + Release: t.Album, + CoverURL: localCover, + InLibrary: inLibrary, + } + } + + raw, err := json.Marshal(cache{Tracks: ct}) + if err != nil { + return + } + cacheDir := filepath.Join(cfgDir, "cache") + os.MkdirAll(cacheDir, 0755) + os.WriteFile(filepath.Join(cacheDir, playlist+".json"), raw, 0644) +} + // Inits debug, gets playlist name, if needed, handles deprecation func setup(cfg *config.Config) { cfg.HandleDeprecation() @@ -68,6 +137,7 @@ func main() { slog.Error(err.Error(), "notify", true) os.Exit(1) } + allTracks := append([]*models.Track(nil), tracks...) client, err := client.NewClient(&cfg) if err != nil { @@ -102,6 +172,12 @@ func main() { } } + added := make(map[string]bool) + for _, t := range tracks { + added[t.CleanTitle+"|"+t.Artist] = true + } + writePlaylistCache(cfg.Flags.CfgPath, cfg.Flags.Playlist, allTracks, added) + if err := client.CreatePlaylist(tracks); err != nil { slog.Warn(err.Error()) } else { diff --git a/src/models/types.go b/src/models/types.go index 18ef47e..e4e8c20 100644 --- a/src/models/types.go +++ b/src/models/types.go @@ -14,4 +14,5 @@ type Track struct { Size int // File size Present bool // is track present in the system or not Duration int // Track duration in milliseconds (not available for every track) + CoverURL string // External cover art URL (Cover Art Archive), used at run-time to download art } diff --git a/src/web/frontend/index.html b/src/web/frontend/index.html index 9fdd16a..1031598 100644 --- a/src/web/frontend/index.html +++ b/src/web/frontend/index.html @@ -4,6 +4,9 @@ Explo + + +
diff --git a/src/web/frontend/package-lock.json b/src/web/frontend/package-lock.json index 9ffa4ba..4dcf04e 100644 --- a/src/web/frontend/package-lock.json +++ b/src/web/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "explo-frontend", "version": "0.0.1", "dependencies": { + "motion": "^12.38.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, @@ -1688,6 +1689,33 @@ } } }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2044,6 +2072,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz", + "integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.38.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2266,6 +2335,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", diff --git a/src/web/frontend/package.json b/src/web/frontend/package.json index fd89b72..6ac2cf2 100644 --- a/src/web/frontend/package.json +++ b/src/web/frontend/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "motion": "^12.38.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/src/web/frontend/src/App.jsx b/src/web/frontend/src/App.jsx index 3312bc9..c428fbf 100644 --- a/src/web/frontend/src/App.jsx +++ b/src/web/frontend/src/App.jsx @@ -12,7 +12,7 @@ export default function App() { fetchConfig().then(({ values, sources }) => { setConfig(values) setEnvSources(sources || {}) - setView(values.LISTENBRAINZ_USER ? 'settings' : 'wizard') + setView(values.WIZARD_COMPLETE === 'true' ? 'settings' : 'wizard') }) }, []) diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx index 62c78a8..9518738 100644 --- a/src/web/frontend/src/components/Settings.jsx +++ b/src/web/frontend/src/components/Settings.jsx @@ -16,12 +16,15 @@ import { saveSchedule, startRun, stopRun, fetchRunStatus, fetchLogs, } from '../lib/api' import { parseSlogLine, cronToFields, highlightEnv } from '../lib/utils' +import { fetchPlaylistTracks } from '../lib/listenbrainz' +import { motion, AnimatePresence } from 'motion/react' import { Toggle } from './ui/Toggle' import { Button, SectionLabel, Panel, LogRow } from './ui/common' +import { PlaylistCard, TracklistDropdown } from './ui/PlaylistCard' const tabBtnCls = active => - `bg-transparent border-none border-b-2 pb-2 px-3.5 text-[13px] cursor-pointer transition-colors relative top-px - ${active ? 'text-white border-accent' : 'text-muted border-transparent hover:text-white'}` + `bg-transparent border-none border-b-2 pb-2 px-3.5 text-[13px] leading-none cursor-pointer transition-colors + ${active ? 'text-accent border-accent' : 'text-muted border-transparent hover:text-white'}` // ── Home Tab ────────────────────────────────────────────────────────────────── // Manages scheduled playlists, manual runs, and live run output. @@ -73,40 +76,33 @@ function useSSE({ onLine, onDone }) { } const PLAYLISTS = [ - { value: 'weekly-exploration', name: 'Weekly Exploration' }, - { value: 'weekly-jams', name: 'Weekly Jams' }, - { value: 'daily-jams', name: 'Daily Jams' }, + { value: 'weekly-exploration', name: 'Weekly Exploration', scheduleKey: 'WEEKLY_EXPLORATION_SCHEDULE', defaultDay: 2, defaultHour: 0, defaultMinute: 15 }, + { value: 'weekly-jams', name: 'Weekly Jams', scheduleKey: 'WEEKLY_JAMS_SCHEDULE', defaultDay: 1, defaultHour: 0, defaultMinute: 30 }, + { value: 'daily-jams', name: 'Daily Jams', scheduleKey: 'DAILY_JAMS_SCHEDULE', defaultDay: -1, defaultHour: 1, defaultMinute: 15 }, + { value: 'on-repeat', name: 'On Repeat', scheduleKey: 'ON_REPEAT_SCHEDULE', defaultDay: 100, defaultHour: 12, defaultMinute: 0, fixedSchedule: true }, ] -const SCHEDULE_KEYS = { - 'weekly-exploration': 'WEEKLY_EXPLORATION_SCHEDULE', - 'weekly-jams': 'WEEKLY_JAMS_SCHEDULE', - 'daily-jams': 'DAILY_JAMS_SCHEDULE', -} - const SCHEDULE_DAYS = [ - { value: -1, label: 'Every day', summary: 'Daily' }, - { value: 0, label: 'Sunday', summary: 'Every Sunday' }, - { value: 1, label: 'Monday', summary: 'Every Monday' }, - { value: 2, label: 'Tuesday', summary: 'Every Tuesday' }, - { value: 3, label: 'Wednesday', summary: 'Every Wednesday' }, - { value: 4, label: 'Thursday', summary: 'Every Thursday' }, - { value: 5, label: 'Friday', summary: 'Every Friday' }, - { value: 6, label: 'Saturday', summary: 'Every Saturday' }, + { value: -1, label: 'Every day', summary: '' }, + { value: 0, label: 'Sunday', summary: 'Every Sunday' }, + { value: 1, label: 'Monday', summary: 'Every Monday' }, + { value: 2, label: 'Tuesday', summary: 'Every Tuesday' }, + { value: 3, label: 'Wednesday', summary: 'Every Wednesday' }, + { value: 4, label: 'Thursday', summary: 'Every Thursday' }, + { value: 5, label: 'Friday', summary: 'Every Friday' }, + { value: 6, label: 'Saturday', summary: 'Every Saturday' }, + { value: 100, label: 'Monthly (1st)', summary: 'Every 1st of the month' }, ] const selectCls = 'bg-surface border border-ui-border text-white rounded-[6px] px-2.5 py-1.5 text-[13px] cursor-pointer outline-none focus:border-accent' function initSchedules(config) { - const defaults = { - 'weekly-exploration': { enabled: false, day: 2, hour: 0, minute: 15, editing: false }, - 'weekly-jams': { enabled: false, day: 1, hour: 0, minute: 30, editing: false }, - 'daily-jams': { enabled: false, day: -1, hour: 1, minute: 15, editing: false }, - } const out = {} - for (const [name, def] of Object.entries(defaults)) { - const cron = config[SCHEDULE_KEYS[name]] - out[name] = cron ? { enabled: true, editing: false, ...cronToFields(cron) } : def + for (const p of PLAYLISTS) { + const cron = config[p.scheduleKey] + out[p.value] = cron + ? { enabled: true, editing: false, ...cronToFields(cron) } + : { enabled: false, day: p.defaultDay, hour: p.defaultHour, minute: p.defaultMinute, editing: false } } return out } @@ -115,6 +111,8 @@ function HomeSection() { const [schedules, setSchedules] = useState(null) const [envSources, setEnvSources] = useState({}) const [scheduleSaveStatus, setScheduleSaveStatus] = useState({}) + const [lbUser, setLbUser] = useState('') + const [openTracklist, setOpenTracklist] = useState(null) const [playlist, setPlaylist] = useState('weekly-exploration') const [dlmode, setDlmode] = useState('normal') @@ -125,17 +123,13 @@ function HomeSection() { const [status, setStatus] = useState('') const [logEntries, setLogEntries] = useState([]) const [rawLog, setRawLog] = useState(false) - const [recentTracks, setRecentTracks] = useState([]) const logRef = useRef(null) useEffect(() => { fetchConfig().then(({ values, sources }) => { setSchedules(initSchedules(values)) setEnvSources(sources || {}) - }) - fetchLogs().then(text => { - const entries = text.split('\n').filter(l => l.trim()).map(l => ({ raw: l, ...parseSlogLine(l) })) - setRecentTracks(entries.filter(e => e.track && e.level === 'INFO').reverse()) + setLbUser(values.LISTENBRAINZ_USER || '') }) }, []) @@ -165,19 +159,22 @@ function HomeSection() { return () => disconnect() }, [connect, disconnect]) - const isScheduleLocked = name => envSources[SCHEDULE_KEYS[name]] === 'env' + const isScheduleLocked = name => { + const p = PLAYLISTS.find(p => p.value === name) + return p ? envSources[p.scheduleKey] === 'env' : false + } const scheduleTime = name => { const s = schedules[name] return `${String(s.hour).padStart(2, '0')}:${String(s.minute).padStart(2, '0')}` } - const scheduleSummary = day => SCHEDULE_DAYS.find(d => d.value === day)?.summary || 'Daily' + const scheduleSummary = day => SCHEDULE_DAYS.find(d => d.value === day)?.summary ?? '' const nextRunText = name => { const s = schedules[name] if (!s.enabled) return 'Disabled' - return `${scheduleSummary(s.day)} at ${String(s.hour).padStart(2, '0')}:${String(s.minute).padStart(2, '0')}` + return scheduleSummary(s.day) } const updateScheduleTime = (name, val) => { @@ -227,88 +224,97 @@ function HomeSection() { {/* Scheduled Playlists */}
Scheduled Playlists - {PLAYLISTS.map(p => { - const s = schedules[p.value] - const locked = isScheduleLocked(p.value) - return ( -
-
- - - {locked ? 'Set via Docker' : (scheduleSaveStatus[p.value] || '')} -
- - {s.editing && s.enabled && !locked && ( -
-
- Runs - - at - updateScheduleTime(p.value, e.target.value)} - className="bg-surface border border-ui-border text-white rounded-[6px] px-2 py-1.5 text-[13px] outline-none focus:border-accent" - /> -
-
- - -
-
- )} -
- ) - })} +
+ {PLAYLISTS.map((p, i) => { + const s = schedules[p.value] + const locked = isScheduleLocked(p.value) + return ( + setOpenTracklist(v => v === p.value ? null : p.value)} + onToggle={v => { + // Read current schedule synchronously — avoids stale-closure bug where + // handleSaveSchedule would see the pre-toggle enabled value. + const cur = schedules[p.value] + setSchedules(prev => ({ ...prev, [p.value]: { ...prev[p.value], enabled: v } })) + saveSchedule(p.value, v, cur.day, cur.hour, cur.minute) + .then(() => { + setScheduleSaveStatus(prev => ({ ...prev, [p.value]: 'Saved.' })) + setTimeout(() => setScheduleSaveStatus(prev => ({ ...prev, [p.value]: '' })), 2000) + }) + .catch(() => setScheduleSaveStatus(prev => ({ ...prev, [p.value]: 'Error saving.' }))) + }} + onToggleEdit={() => setSchedules(prev => ({ ...prev, [p.value]: { ...prev[p.value], editing: !prev[p.value].editing } }))} + onSave={() => { handleSaveSchedule(p.value); setSchedules(prev => ({ ...prev, [p.value]: { ...prev[p.value], editing: false } })) }} + onCancelEdit={() => setSchedules(prev => ({ ...prev, [p.value]: { ...prev[p.value], editing: false } }))} + onDayChange={day => setSchedules(prev => ({ ...prev, [p.value]: { ...prev[p.value], day } }))} + onTimeChange={val => updateScheduleTime(p.value, val)} + /> + ) + })} +
+ + {openTracklist && ( + + + + )} +

Schedule changes take effect after restarting the container.

{/* Manual Run */}
Manual run +
+ +
+ {[ + { value: 'normal', name: 'Normal', desc: "Download only if the track isn't found locally" }, + { value: 'skip', name: 'Skip', desc: 'No downloads — builds a playlist from tracks already in your library. Good for testing.' }, + { value: 'force', name: 'Force', desc: 'Always download, ignoring local tracks' }, + ].map(m => ( + + ))} +
+

+ {({ normal: "Download only if the track isn't found locally", skip: 'No downloads — builds a playlist from tracks already in your library. Good for testing.', force: 'Always download, ignoring local tracks' })[dlmode]} +

+
- - -
@@ -326,21 +332,6 @@ function HomeSection() {
- {/* Recent Tracks */} - {recentTracks.length > 0 && ( -
- Recent tracks - - {recentTracks.slice(0, 50).map((e, i) => ( -
- {e.time} - {e.track} -
- ))} -
-
- )} - {/* Output */}
@@ -494,24 +485,69 @@ function LogsSection() { // ── Settings ────────────────────────────────────────────────────────────────── // Tab shell. Routes between Home, Settings, and Logs sections. +// Module-level cache so the picked cover survives component remounts. +let _bgCoverCache = null + export default function Settings({ onWizard }) { const [activeTab, setActiveTab] = useState('run') + const [bgCover, setBgCover] = useState(_bgCoverCache) + + useEffect(() => { + if (_bgCoverCache) return + Promise.all(['weekly-exploration', 'weekly-jams', 'daily-jams', 'on-repeat'].map( + t => fetchPlaylistTracks(t).catch(() => ({ tracks: [] })) + )).then(results => { + const covers = results.flatMap(r => (r.tracks ?? []).map(t => t.coverUrl).filter(Boolean)) + if (covers.length) { + _bgCoverCache = covers[Math.floor(Math.random() * covers.length)] + setBgCover(_bgCoverCache) + } + }) + }, []) return ( -
-
-
- Explo - -
- - {activeTab === 'run' && } - {activeTab === 'config' && } - {activeTab === 'logs' && } +
+ {/* Page background art */} +
+ + {bgCover && ( + + )} + +
+ + {/* Content */} +
+
+
+ Explo + +
+ + {activeTab === 'run' && } + {activeTab === 'config' && } + {activeTab === 'logs' && } +
) diff --git a/src/web/frontend/src/components/Wizard.jsx b/src/web/frontend/src/components/Wizard.jsx index c5e7bcf..c3114d8 100644 --- a/src/web/frontend/src/components/Wizard.jsx +++ b/src/web/frontend/src/components/Wizard.jsx @@ -8,6 +8,7 @@ * Receives existing config/envSources from App to pre-populate fields. */ +import { AnimatePresence, motion, useReducedMotion } from 'motion/react' import { useEffect, useState } from 'react' import { wizardStep1, wizardStep2, wizardStep3, wizardStep4, testLidarr, fetchLidarrProfiles, fetchLidarrWebhookUrl } from '../lib/api' import { ToggleRow } from './ui/Toggle' @@ -260,80 +261,92 @@ function Step3({ fields, setField, envSources, onBack, onNext, saving }) { Explo downloads tracks using one or both services. Enable what you have access to — if both are enabled, YouTube is tried first.

-
-
- -
- setField('dlServices', { ...dlServices, youtube: v })} - name="YouTube" - desc="Downloads via yt-dlp · falls back to ytmusicapi if no API key is set" - /> - setField('dlServices', { ...dlServices, slskd: v })} - name="Slskd" - desc="Downloads from the Soulseek P2P network · requires a running Slskd instance" - /> -
+
+
+ setField('dlServices', { ...dlServices, youtube: v })} + name="YouTube" + desc="Downloads via yt-dlp · falls back to ytmusicapi if no API key is set" + /> + {dlServices.youtube && ( +
+ YouTube API Key (optional)} + hint={<>If set, uses the official YouTube Data API. Otherwise falls back to ytmusicapi.{' '} + Get an API key.}> + setField('youtubeApiKey', e.target.value)} + autoComplete="off" spellCheck={false} placeholder="AIza…" disabled={isLocked('YOUTUBE_API_KEY')} /> + + File format yt-dlp converts to. Default is opus — use mp3 for broader device compatibility.}> + setField('trackExtension', e.target.value)} + placeholder="opus" autoComplete="off" spellCheck={false} disabled={isLocked('TRACK_EXTENSION')} /> + + + setField('filterList', e.target.value)} + placeholder="live,remix,instrumental,extended,clean,acapella" autoComplete="off" spellCheck={false} disabled={isLocked('FILTER_LIST')} /> + + + setField('downloadDir', v)} disabled={isLocked('DOWNLOAD_DIR')} + placeholder="e.g. /data/music/" /> + + setField('useSubdirectory', v)} + disabled={isLocked('USE_SUBDIRECTORY')} + name="Use playlist subfolders" + desc="Create a subfolder per playlist inside the download directory" + /> +
+ )}
- {dlServices.youtube && ( - <> - YouTube API Key (optional)} - hint={<>If set, uses the official YouTube Data API. Otherwise falls back to ytmusicapi.{' '} - Get an API key.}> - setField('youtubeApiKey', e.target.value)} - autoComplete="off" spellCheck={false} placeholder="AIza…" disabled={isLocked('YOUTUBE_API_KEY')} /> - - File format yt-dlp converts to. Default is opus — use mp3 for broader device compatibility.}> - setField('trackExtension', e.target.value)} - placeholder="opus" autoComplete="off" spellCheck={false} disabled={isLocked('TRACK_EXTENSION')} /> - - - setField('filterList', e.target.value)} - placeholder="live,remix,instrumental,extended,clean,acapella" autoComplete="off" spellCheck={false} disabled={isLocked('FILTER_LIST')} /> - - - )} - - {dlServices.slskd && ( - <> - - setField('slskdUrl', e.target.value)} - placeholder="e.g. http://192.168.1.100:5030" disabled={isLocked('SLSKD_URL')} /> - - - setField('slskdApiKey', e.target.value)} - autoComplete="off" spellCheck={false} disabled={isLocked('SLSKD_API_KEY')} /> - - setField('migrateDownloads', v)} - disabled={isLocked('MIGRATE_DOWNLOADS')} - desc="Move completed downloads to a separate directory" - /> - - )} - - {showDownloadDir && ( - <> - - setField('downloadDir', v)} disabled={isLocked('DOWNLOAD_DIR')} - placeholder="e.g. /data/music/" /> - - setField('useSubdirectory', v)} - disabled={isLocked('USE_SUBDIRECTORY')} - name="Use playlist subfolders" - desc="Create a subfolder per playlist inside the download directory" - /> - - )} +
+ setField('dlServices', { ...dlServices, slskd: v })} + name="Slskd" + desc="Downloads from the Soulseek P2P network · requires a running Slskd instance" + /> + {dlServices.slskd && ( +
+ + setField('slskdUrl', e.target.value)} + placeholder="e.g. http://192.168.1.100:5030" disabled={isLocked('SLSKD_URL')} /> + + + setField('slskdApiKey', e.target.value)} + autoComplete="off" spellCheck={false} disabled={isLocked('SLSKD_API_KEY')} /> + +

+ By default, tracks are saved to wherever Slskd is configured to download files. +

+ setField('migrateDownloads', v)} + disabled={isLocked('MIGRATE_DOWNLOADS')} + name="Migrate downloads" + desc="Move completed downloads to a separate directory instead" + /> + {migrateDownloads && !dlServices.youtube && ( + <> + + setField('downloadDir', v)} disabled={isLocked('DOWNLOAD_DIR')} + placeholder="e.g. /data/music/" /> + + setField('useSubdirectory', v)} + disabled={isLocked('USE_SUBDIRECTORY')} + name="Use playlist subfolders" + desc="Create a subfolder per playlist inside the download directory" + /> + + )} +
+ )} +
@@ -521,9 +534,25 @@ function Step4({ fields, setField, envSources, onBack, onFinish, saving }) { // Owns all wizard state and calls wizardStep1/2/3 APIs to save each step. // Receives existing config/envSources from App to pre-populate fields. +const SPRING = { type: 'spring', stiffness: 280, damping: 28, mass: 0.9, opacity: { duration: 0.2, ease: 'easeInOut' } } +const SLIDE_VARIANTS = { + enter: dir => ({ opacity: 0, x: dir * 180, scale: 0.96 }), + center: { opacity: 1, x: 0 }, + exit: dir => ({ opacity: 0, x: dir * -180, scale: 0.96 }), +} +const FADE_VARIANTS = { + enter: { opacity: 0 }, + center: { opacity: 1 }, + exit: { opacity: 0 }, +} + export default function Wizard({ config, envSources, onComplete }) { const [step, setStep] = useState(1) + const [stepDirection, setStepDirection] = useState(1) const [saving, setSaving] = useState(false) + const reduceMotion = useReducedMotion() + const variants = reduceMotion ? FADE_VARIANTS : SLIDE_VARIANTS + const transition = reduceMotion ? { duration: 0.18 } : SPRING const [fields, setFields] = useState(() => { const s = (config.DOWNLOAD_SERVICES || '').split(',') @@ -569,6 +598,10 @@ export default function Wizard({ config, envSources, onComplete }) { }) const setField = (key, val) => setFields(prev => ({ ...prev, [key]: val })) + const goToStep = nextStep => { + setStepDirection(nextStep > step ? 1 : -1) + setStep(nextStep) + } const lockedKeys = Object.entries(envSources) .filter(([k, s]) => s === 'env' && !k.endsWith('_SCHEDULE') && !k.endsWith('_FLAGS')) @@ -579,7 +612,7 @@ export default function Wizard({ config, envSources, onComplete }) { try { const playlists = Object.entries(fields.checked).filter(([, v]) => v).map(([k]) => k) await wizardStep1(fields.user.trim(), playlists, fields.discoveryMode) - setStep(2) + goToStep(2) } catch (e) { alert('Error saving: ' + e.message) } finally { @@ -596,7 +629,7 @@ export default function Wizard({ config, envSources, onComplete }) { password: fields.systemPassword, playlist_dir: fields.playlistDir, sleep: fields.sleepMinutes, public_playlist: fields.publicPlaylist, }) - setStep(3) + goToStep(3) } catch (e) { alert('Error saving: ' + e.message) } finally { @@ -644,10 +677,10 @@ export default function Wizard({ config, envSources, onComplete }) { } return ( -
-
-
Explo
+
+
Explo
+
{lockedKeys.length > 0 && (
You've set the following in your Docker environment, so they can't be changed here:{' '} @@ -655,34 +688,46 @@ export default function Wizard({ config, envSources, onComplete }) {
)} - {step === 1 && ( - - )} - {step === 2 && ( - setStep(1)} onNext={handleStep2} saving={saving} - /> - )} - {step === 3 && ( - setStep(2)} onNext={handleStep3} saving={saving} - /> - )} - {step === 4 && ( + + + {step === 1 && ( + + )} + {step === 2 && ( + goToStep(1)} onNext={handleStep2} saving={saving} + /> + )} + {step === 3 && ( + goToStep(2)} onFinish={handleStep3} saving={saving} + /> + )} + {step === 4 && ( setStep(3)} onFinish={handleStep4} saving={saving} /> )} + +
) diff --git a/src/web/frontend/src/components/ui/PlaylistCard.jsx b/src/web/frontend/src/components/ui/PlaylistCard.jsx new file mode 100644 index 0000000..8d48372 --- /dev/null +++ b/src/web/frontend/src/components/ui/PlaylistCard.jsx @@ -0,0 +1,437 @@ +import { useState, useEffect } from 'react' +import { motion, AnimatePresence } from 'motion/react' +import { Toggle } from './Toggle' +import { Button } from './common' +import { fetchPlaylistTracks } from '../../lib/listenbrainz' + +// ── TrackRow ────────────────────────────────────────────────────────────────── + +function TrackRow({ track }) { + const [imgFailed, setImgFailed] = useState(false) + + return ( +
+ + {track.rank} + + +
+ {track.coverUrl && !imgFailed ? ( + setImgFailed(true)} + /> + ) : ( + + )} +
+ +
+
+ {track.title} +
+
+ {track.artist}{track.release ? ` — ${track.release}` : ''} +
+
+ + {track.inLibrary != null && ( + + {track.inLibrary ? '✓' : '✕'} + + )} + +
+ ) +} + +// ── TracklistDropdown ───────────────────────────────────────────────────────── + +function nextUpdateLabel(playlistType) { + const now = new Date() + if (playlistType === 'on-repeat') { + return 'Updates as you listen' + } + if (playlistType === 'daily-jams') { + const tomorrow = new Date(now) + tomorrow.setDate(tomorrow.getDate() + 1) + return `Next update tomorrow (${tomorrow.toLocaleDateString([], { weekday: 'long' })})` + } + // Weekly playlists: LB generates on Mondays + const daysUntilMonday = (8 - now.getDay()) % 7 || 7 + const nextMonday = new Date(now) + nextMonday.setDate(now.getDate() + daysUntilMonday) + return `Next update ${nextMonday.toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' })}` +} + +export function TracklistDropdown({ playlist }) { + const [tracks, setTracks] = useState([]) + const [generatedAt, setGeneratedAt] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + if (!playlist) return + let cancelled = false + setLoading(true) + setError(null) + fetchPlaylistTracks(playlist) + .then(({ tracks: t, generatedAt: g }) => { + if (!cancelled) { setTracks(t); setGeneratedAt(g); setLoading(false) } + }) + .catch(e => { if (!cancelled) { setError(e.message); setLoading(false) } }) + return () => { cancelled = true } + }, [playlist]) + + const genDate = generatedAt ? new Date(generatedAt) : null + + return ( +
+ {/* Header */} +
+ + {!loading && tracks.length ? `${tracks.length} Tracks` : 'Tracks'} + + {!loading && genDate && ( + + Generated {genDate.toLocaleDateString([], { month: 'short', day: 'numeric' })} + + )} +
+ + {/* Track list */} +
+ {loading ? ( +
Loading…
+ ) : error ? ( +
{error}
+ ) : tracks.length === 0 ? ( +
+ No playlist found yet. {nextUpdateLabel(playlist)}. +
+ ) : ( + tracks.map(t => ( + + )) + )} +
+
+ ) +} + +// ── PlaylistCard ────────────────────────────────────────────────────────────── + +const withAlpha = (hex, alpha) => `${hex}${Math.round(alpha * 255).toString(16).padStart(2, '0')}` + +const cardGradient = (shadow, midtone, highlight, base = shadow) => ` + linear-gradient(135deg, ${withAlpha(shadow, 0.36)} 0%, ${withAlpha(midtone, 0.28)} 48%, ${withAlpha(highlight, 0.18)} 100%), + ${base}` + +const PRESETS = { + 'weekly-exploration': { + background: cardGradient('#2979ff', '#7c3aed', '#0ea5e9'), + accent: '#818cf8', + label: 'WEEKLY', + }, + 'weekly-jams': { + background: cardGradient('#fb923c', '#ef4444', '#f472b6'), + accent: '#fb923c', + label: 'WEEKLY', + }, + 'daily-jams': { + background: cardGradient('#10b981', '#06b6d4', '#22c55e'), + accent: '#34d399', + label: 'DAILY', + }, + 'on-repeat': { + background: cardGradient('#e11d48', '#9f1239', '#fb7185'), + accent: '#fb7185', + label: 'MONTHLY', + }, +} + +const FALLBACK = { + background: cardGradient('#646478', '#50506e', '#828296'), + accent: '#b3b3b3', + label: 'PLAYLIST', +} + +const SCHEDULE_DAYS = [ + { value: -1, label: 'Every day' }, + { value: 0, label: 'Sunday' }, + { value: 1, label: 'Monday' }, + { value: 2, label: 'Tuesday' }, + { value: 3, label: 'Wednesday' }, + { value: 4, label: 'Thursday' }, + { value: 5, label: 'Friday' }, + { value: 6, label: 'Saturday' }, + { value: 100, label: 'Monthly (1st)' }, +] + +// Inline SVG noise — subtle film-grain texture +const NOISE = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='256' height='256'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")` + +export function PlaylistCard({ + playlist, + schedule: s, + locked, + fixedSchedule = false, + index = 0, + nextRunText, + scheduleSaveStatus, + onToggle, + onToggleEdit, + onSave, + onCancelEdit, + onDayChange, + onTimeChange, + gradient: gradientOverride, + tracklistOpen, + onTracklistToggle, +}) { + const { value, name } = playlist + const preset = PRESETS[value] ?? FALLBACK + const bg = gradientOverride ?? preset.background + const { accent, label } = preset + + // Split gradient string into radial layers + solid base color so album art can sit between them. + const bgLines = bg.trim().split('\n').map(l => l.trim()).filter(Boolean) + const lastLine = bgLines[bgLines.length - 1] + const isBase = /^#[0-9a-fA-F]{3,8}$/.test(lastLine) || /^rgba?\(/.test(lastLine) + const gradientLayers = (isBase ? bgLines.slice(0, -1) : bgLines) + .map(l => l.replace(/,\s*$/, '')) + .join(',\n') + const baseColor = isBase ? lastLine : '#000' + + // Album art slideshow + const [bgCovers, setBgCovers] = useState([]) + const [coverIdx, setCoverIdx] = useState(0) + + useEffect(() => { + if (!s.enabled) return + let cancelled = false + fetchPlaylistTracks(value) + .then(({ tracks }) => { + if (cancelled) return + setBgCovers(tracks.map(t => t.coverUrl).filter(Boolean)) + }) + .catch(() => {}) + return () => { cancelled = true } + }, [value, s.enabled]) + + useEffect(() => { + if (bgCovers.length < 2) return + const id = setInterval(() => setCoverIdx(i => (i + 1) % bgCovers.length), 5000) + return () => clearInterval(id) + }, [bgCovers.length]) + + const [line1, ...rest] = name.split(' ') + const line2 = rest.join(' ') + const timeStr = `${String(s.hour).padStart(2, '0')}:${String(s.minute).padStart(2, '0')}` + + return ( + +
+ {/* Gradient map color field */} +
+ + {/* Album art luminosity — gives the gradient field cover-art detail */} + + {bgCovers[coverIdx] && ( + setBgCovers(prev => prev.filter((_, i) => i !== coverIdx))} + style={{ + position: 'absolute', inset: 0, + width: '100%', height: '100%', + objectFit: 'cover', display: 'block', + filter: 'grayscale(1) contrast(1) brightness(0.5)', + mixBlendMode: 'luminosity', + }} + /> + )} + + + {/* Black wash — tune opacity to control gradient-map visibility */} +
+ + {/* Noise overlay */} +
+ + {/* Bottom vignette — keeps controls legible */} +
+ + {label && ( +
+ {label} +
+ )} + + + {/* Name + schedule — bottom left */} +
+
+
{line1}
+ {line2 &&
{line2}
} +
+ { e.stopPropagation(); if (!locked && !fixedSchedule) onToggleEdit() }} + style={{ + display: 'block', marginTop: 4, + fontSize: 8, fontWeight: 300, + color: 'rgba(255,255,255,0.55)', + mixBlendMode: 'hard-light', + cursor: (locked || fixedSchedule) ? 'default' : 'pointer', + letterSpacing: '0.02em', + whiteSpace: 'nowrap', + transition: 'color 0.3s', + }} + > + {nextRunText} + +
+ + {/* Toggle — bottom right */} + + + {locked && ( + ENV + )} +
+ + {/* Inline schedule editor */} + + {s.editing && s.enabled && !locked && !fixedSchedule && ( + +
+
+ Runs + + at + onTimeChange(e.target.value)} + style={{ + background: '#1f1f1f', border: '1px solid #333', color: 'white', + borderRadius: 6, padding: '5px 8px', fontSize: 13, outline: 'none', + }} + /> +
+
+ + +
+
+
+ )} +
+ + ) +} diff --git a/src/web/frontend/src/components/ui/Toggle.jsx b/src/web/frontend/src/components/ui/Toggle.jsx index 2252d74..2108958 100644 --- a/src/web/frontend/src/components/ui/Toggle.jsx +++ b/src/web/frontend/src/components/ui/Toggle.jsx @@ -1,4 +1,30 @@ -export function Toggle({ checked, onChange, disabled }) { +export function Toggle({ checked, onChange, disabled, small, tiny }) { + if (tiny) return ( +
!disabled && onChange(!checked)} + > + +
+ ) + if (small) return ( +
!disabled && onChange(!checked)} + > + +
+ ) return (
+
{entry.time} {entry.level !== 'INFO' && ( )} {entry.msg} - {entry.track && {entry.track}} - {entry.system && {entry.system}} + {displayTrack && ( + + {displayTrack}{entry.artist && — {entry.artist}} + + )} + {entry.system && {entry.system}}
) } diff --git a/src/web/frontend/src/index.css b/src/web/frontend/src/index.css index 2ffef7b..03f8734 100644 --- a/src/web/frontend/src/index.css +++ b/src/web/frontend/src/index.css @@ -17,6 +17,15 @@ } } +/* Hide scrollbar in WebKit (Chrome/Safari) while keeping scroll */ +.no-scrollbar::-webkit-scrollbar { display: none; } + +/* Playlist card aspect ratio: landscape in 3-col grid, resized to be smaller on mobile devices*/ +.playlist-card { aspect-ratio: 1 / 1; } +@media (max-width: 419px) { + .playlist-card { aspect-ratio: 4 / 1; } +} + /* Syntax highlight classes used in the config editor */ .env-comment, .env-unset, .env-eq { color: var(--color-muted); } .env-key, .env-val { color: white; } diff --git a/src/web/frontend/src/lib/listenbrainz.js b/src/web/frontend/src/lib/listenbrainz.js new file mode 100644 index 0000000..d2d5b21 --- /dev/null +++ b/src/web/frontend/src/lib/listenbrainz.js @@ -0,0 +1,19 @@ +// Session-level cache — avoids repeat fetches on open/close within the same page load. +const memCache = new Map() + +export async function fetchPlaylistTracks(playlistType) { + const key = playlistType + if (memCache.has(key)) return memCache.get(key) + + const res = await fetch(`/api/playlists?type=${encodeURIComponent(playlistType)}`) + if (res.status === 404) { + const result = { tracks: [], generatedAt: null } + memCache.set(key, result) + return result + } + if (!res.ok) throw new Error(`Server returned ${res.status}`) + const data = await res.json() + const result = { tracks: data.tracks ?? [], generatedAt: data.generatedAt ?? null } + memCache.set(key, result) + return result +} diff --git a/src/web/frontend/src/lib/utils.js b/src/web/frontend/src/lib/utils.js index 1f15110..87d4409 100644 --- a/src/web/frontend/src/lib/utils.js +++ b/src/web/frontend/src/lib/utils.js @@ -20,10 +20,12 @@ export function highlightEnv(text) { export function parseSlogLine(line) { const kv = {} - const re = /(\w+)=("(?:[^"\\]|\\.)*"|[^ ]+)/g + // Match both plain keys (word chars) and quoted keys ("track title") + const re = /(\w+|"[^"]+")=("(?:[^"\\]|\\.)*"|[^ ]+)/g let m while ((m = re.exec(line)) !== null) { - const [, k, v] = m + let [, k, v] = m + if (k.startsWith('"')) k = k.slice(1, -1) // strip key quotes kv[k] = v.startsWith('"') ? v.slice(1, -1).replace(/\\"/g, '"') : v } if (!kv.msg && !kv.time) return { time: '', level: 'INFO', msg: line } @@ -32,14 +34,24 @@ export function parseSlogLine(line) { try { time = new Date(kv.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }) } catch { time = kv.time } } - return { time, level: (kv.level || 'INFO').toUpperCase(), msg: kv.msg || line, track: kv.track || '', system: kv.system || '' } + return { + time, + level: (kv.level || 'INFO').toUpperCase(), + msg: kv.msg || line, + track: kv['track title'] || kv.track || '', + artist: kv['track artist'] || '', + file: kv.file || '', + system: kv.system || kv.service || '', + } } export function cronToFields(cron) { const parts = cron.trim().split(/\s+/) + // Monthly cron: "m h 1 * *" — day-of-month=1, day-of-week=* + const isMonthly = parts[2] !== '*' && parts[4] === '*' return { minute: parseInt(parts[0]) || 0, hour: parseInt(parts[1]) || 0, - day: parts[4] === '*' ? -1 : (parseInt(parts[4]) || 0), + day: isMonthly ? 100 : (parts[4] === '*' ? -1 : (parseInt(parts[4]) || 0)), } } diff --git a/src/web/playlists.go b/src/web/playlists.go new file mode 100644 index 0000000..533e8dd --- /dev/null +++ b/src/web/playlists.go @@ -0,0 +1,235 @@ +package web + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +const lbAPIBase = "https://api.listenbrainz.org/1" + +// validPlaylistTypes is derived from playlistDefs — no manual sync needed. +var validPlaylistTypes = func() map[string]bool { + m := make(map[string]bool, len(playlistDefs)) + for k := range playlistDefs { + m[k] = true + } + return m +}() + +// handleGetPlaylist serves the tracklist cache written by explo during its last run. +// Falls back to fetching the most recent playlist from ListenBrainz if no cache exists. +func (s *Server) handleGetPlaylist(w http.ResponseWriter, r *http.Request) { + playlistType := r.URL.Query().Get("type") + if !validPlaylistTypes[playlistType] { + http.Error(w, "unknown playlist type", http.StatusBadRequest) + return + } + + cachePath := filepath.Join(filepath.Dir(s.configPath), "cache", playlistType+".json") + if raw, err := os.ReadFile(cachePath); err == nil { + w.Header().Set("Content-Type", "application/json") + w.Write(raw) + return + } + + // No cache yet — fall back to the most recent LB playlist (any date). + username := os.Getenv("LISTENBRAINZ_USER") + if username == "" { + if data, err := os.ReadFile(s.configPath); err == nil { + username = parseEnvText(string(data))["LISTENBRAINZ_USER"] + } + } + if username == "" { + http.Error(w, "LISTENBRAINZ_USER not configured", http.StatusBadRequest) + return + } + + var tracks [][4]string + var generatedAt time.Time + var err error + + if playlistType == "on-repeat" { + tracks, err = fetchTopRecordingsLB(username) + } else { + tracks, generatedAt, err = fetchMostRecentLBPlaylist(username, playlistType) + } + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + + type cachedTrack struct { + Rank int `json:"rank"` + Title string `json:"title"` + Artist string `json:"artist"` + Release string `json:"release"` + CoverURL string `json:"coverUrl,omitempty"` + } + type response struct { + Tracks []cachedTrack `json:"tracks"` + GeneratedAt *time.Time `json:"generatedAt,omitempty"` + } + + ct := make([]cachedTrack, len(tracks)) + for i, t := range tracks { + ct[i] = cachedTrack{Rank: i + 1, Title: t[0], Artist: t[1], Release: t[2], CoverURL: t[3]} + } + + var gen *time.Time + if !generatedAt.IsZero() { + gen = &generatedAt + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response{Tracks: ct, GeneratedAt: gen}) +} + +// ── LB fallback ────────────────────────────────────────────────────────────── + +type lbCreatedForResp struct { + Count int `json:"count"` + Offset int `json:"offset"` + PlaylistCount int `json:"playlist_count"` + Playlists []struct { + Playlist struct { + Date time.Time `json:"date"` + Identifier string `json:"identifier"` + Extension struct { + JspfPlaylist struct { + AdditionalMetadata struct { + AlgorithmMetadata struct { + SourcePatch string `json:"source_patch"` + } `json:"algorithm_metadata"` + } `json:"additional_metadata"` + } `json:"https://musicbrainz.org/doc/jspf#playlist"` + } `json:"extension"` + } `json:"playlist"` + } `json:"playlists"` +} + +type lbPlaylistResp struct { + Playlist struct { + Track []struct { + Title string `json:"title"` + Creator string `json:"creator"` + Album string `json:"album"` + Extension struct { + JspfTrack struct { + AdditionalMetadata struct { + CaaID int64 `json:"caa_id"` + CaaReleaseMbid string `json:"caa_release_mbid"` + } `json:"additional_metadata"` + } `json:"https://musicbrainz.org/doc/jspf#track"` + } `json:"extension"` + } `json:"track"` + } `json:"playlist"` +} + +type lbStatsResp struct { + Payload struct { + Recordings []struct { + ArtistName string `json:"artist_name"` + ReleaseMbid string `json:"release_mbid"` + ReleaseName string `json:"release_name"` + TrackName string `json:"track_name"` + } `json:"recordings"` + } `json:"payload"` +} + +func fetchTopRecordingsLB(username string) ([][4]string, error) { + url := fmt.Sprintf("%s/stats/user/%s/recordings?count=30&range=month", lbAPIBase, username) + body, err := lbGet(url) + if err != nil { + return nil, fmt.Errorf("stats fetch: %w", err) + } + var resp lbStatsResp + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("stats parse: %w", err) + } + out := make([][4]string, 0, len(resp.Payload.Recordings)) + for _, r := range resp.Payload.Recordings { + var cover string + if r.ReleaseMbid != "" { + cover = fmt.Sprintf("https://coverartarchive.org/release/%s/front-250", r.ReleaseMbid) + } + out = append(out, [4]string{r.TrackName, r.ArtistName, r.ReleaseName, cover}) + } + return out, nil +} + +func fetchMostRecentLBPlaylist(username, playlistType string) ([][4]string, time.Time, error) { + var offset int + var bestDate time.Time + var bestID string + + for { + url := fmt.Sprintf("%s/user/%s/playlists/createdfor?offset=%d", lbAPIBase, username, offset) + body, err := lbGet(url) + if err != nil { + return nil, time.Time{}, fmt.Errorf("createdfor fetch: %w", err) + } + var resp lbCreatedForResp + if err := json.Unmarshal(body, &resp); err != nil { + return nil, time.Time{}, fmt.Errorf("createdfor parse: %w", err) + } + for _, p := range resp.Playlists { + patch := p.Playlist.Extension.JspfPlaylist.AdditionalMetadata.AlgorithmMetadata.SourcePatch + if patch != playlistType { + continue + } + if bestID == "" || p.Playlist.Date.After(bestDate) { + bestDate = p.Playlist.Date + parts := strings.Split(p.Playlist.Identifier, "/") + bestID = parts[len(parts)-1] + } + } + fetched := resp.Count + resp.Offset + if fetched >= resp.PlaylistCount || resp.Count == 0 { + break + } + offset += resp.Count + } + + if bestID == "" { + return nil, time.Time{}, nil + } + + body, err := lbGet(fmt.Sprintf("%s/playlist/%s", lbAPIBase, bestID)) + if err != nil { + return nil, time.Time{}, fmt.Errorf("playlist fetch: %w", err) + } + var resp lbPlaylistResp + if err := json.Unmarshal(body, &resp); err != nil { + return nil, time.Time{}, fmt.Errorf("playlist parse: %w", err) + } + + out := make([][4]string, 0, len(resp.Playlist.Track)) + for _, t := range resp.Playlist.Track { + meta := t.Extension.JspfTrack.AdditionalMetadata + var cover string + if meta.CaaReleaseMbid != "" && meta.CaaID != 0 { + cover = fmt.Sprintf("https://coverartarchive.org/release/%s/%d-250.jpg", + meta.CaaReleaseMbid, meta.CaaID) + } + out = append(out, [4]string{t.Title, t.Creator, t.Album, cover}) + } + return out, bestDate, nil +} + +func lbGet(url string) ([]byte, error) { + resp, err := http.Get(url) //nolint:noctx + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("LB returned %d", resp.StatusCode) + } + return io.ReadAll(resp.Body) +} diff --git a/src/web/sample.env b/src/web/sample.env index a4bda23..e833589 100644 --- a/src/web/sample.env +++ b/src/web/sample.env @@ -145,6 +145,7 @@ YOUTUBE_API_KEY= # === Misc === +# WIZARD_COMPLETE=false # Minutes to sleep between library scans (default: 2) # SLEEP=2 # Set the log level (DEBUG, INFO, WARN, ERROR) (default: INFO) diff --git a/src/web/server.go b/src/web/server.go index 16a10a6..8af6bd9 100644 --- a/src/web/server.go +++ b/src/web/server.go @@ -65,12 +65,29 @@ type FieldDef struct { var netSystems = []string{"jellyfin", "emby", "plex", "subsonic"} var apiKeySystems = []string{"jellyfin", "emby", "plex"} +// playlistDef is the single source of truth for a supported playlist type. +// To add a new playlist: append one entry here and add the matching entry in +// PLAYLISTS in the frontend Settings.jsx. +type playlistDef struct { + EnvPrefix string // e.g. "WEEKLY_EXPLORATION" + DefaultSchedule string // cron expression + DefaultFlags string // CLI flags for the run +} + +var playlistDefs = map[string]playlistDef{ + "weekly-exploration": {"WEEKLY_EXPLORATION", "15 00 * * 2", "--playlist weekly-exploration"}, + "weekly-jams": {"WEEKLY_JAMS", "30 00 * * 1", "--playlist weekly-jams"}, + "daily-jams": {"DAILY_JAMS", "15 01 * * *", "--playlist daily-jams"}, + "on-repeat": {"ON_REPEAT", "0 12 1 * *", "--playlist on-repeat"}, +} + // allConfigKeys is the complete set of env keys the web UI reads and writes. var allConfigKeys = []string{ "LISTENBRAINZ_USER", "LISTENBRAINZ_DISCOVERY", "WEEKLY_EXPLORATION_SCHEDULE", "WEEKLY_EXPLORATION_FLAGS", "WEEKLY_JAMS_SCHEDULE", "WEEKLY_JAMS_FLAGS", "DAILY_JAMS_SCHEDULE", "DAILY_JAMS_FLAGS", + "ON_REPEAT_SCHEDULE", "ON_REPEAT_FLAGS", "EXPLO_SYSTEM", "SYSTEM_URL", "API_KEY", "LIBRARY_NAME", "SYSTEM_USERNAME", "SYSTEM_PASSWORD", "PLAYLIST_DIR", "SLEEP", "PUBLIC_PLAYLIST", "DOWNLOAD_DIR", "USE_SUBDIRECTORY", @@ -79,6 +96,7 @@ var allConfigKeys = []string{ "LIDARR_ENABLED", "LIDARR_URL", "LIDARR_API_KEY", "LIDARR_QUALITY_PROFILE_ID", "LIDARR_METADATA_PROFILE_ID", "LIDARR_ROOT_FOLDER", "LIDARR_POLL_INTERVAL", "LIDARR_WEBHOOK_ENABLED", + "WIZARD_COMPLETE", } // ConfigResponse is returned by GET /api/config. @@ -343,6 +361,10 @@ func (s *Server) registerRoutes() { s.mux.HandleFunc("POST /api/lidarr/profiles", s.handleLidarrProfiles) s.mux.HandleFunc("GET /api/lidarr/webhook-url", s.handleLidarrWebhookURL) s.mux.HandleFunc("POST /api/plex/webhook", s.handlePlexWebhook) + s.mux.HandleFunc("GET /api/playlists", s.handleGetPlaylist) + + coversDir := filepath.Join(filepath.Dir(s.configPath), "cache", "covers") + s.mux.Handle("/api/covers/", http.StripPrefix("/api/covers/", http.FileServer(http.Dir(coversDir)))) } func (s *Server) Start(addr string) error { @@ -572,18 +594,7 @@ func (s *Server) handleSaveSchedule(w http.ResponseWriter, r *http.Request) { return } - envPrefixes := map[string]string{ - "weekly-exploration": "WEEKLY_EXPLORATION", - "weekly-jams": "WEEKLY_JAMS", - "daily-jams": "DAILY_JAMS", - } - flagDefaults := map[string]string{ - "weekly-exploration": "--playlist weekly-exploration", - "weekly-jams": "--playlist weekly-jams", - "daily-jams": "--playlist daily-jams", - } - - prefix, ok := envPrefixes[body.Name] + def, ok := playlistDefs[body.Name] if !ok { http.Error(w, "unknown playlist name", http.StatusBadRequest) return @@ -591,15 +602,21 @@ func (s *Server) handleSaveSchedule(w http.ResponseWriter, r *http.Request) { updates := map[string]string{} if body.Enabled { - dow := "*" - if body.Day >= 0 { - dow = fmt.Sprintf("%d", body.Day) + var cron string + if body.Day == 100 { // monthly: 1st of each month + cron = fmt.Sprintf("%d %d 1 * *", body.Minute, body.Hour) + } else { + dow := "*" + if body.Day >= 0 { + dow = fmt.Sprintf("%d", body.Day) + } + cron = fmt.Sprintf("%d %d * * %s", body.Minute, body.Hour, dow) } - updates[prefix+"_SCHEDULE"] = fmt.Sprintf("%d %d * * %s", body.Minute, body.Hour, dow) - updates[prefix+"_FLAGS"] = flagDefaults[body.Name] + updates[def.EnvPrefix+"_SCHEDULE"] = cron + updates[def.EnvPrefix+"_FLAGS"] = def.DefaultFlags } else { - updates[prefix+"_SCHEDULE"] = "" - updates[prefix+"_FLAGS"] = "" + updates[def.EnvPrefix+"_SCHEDULE"] = "" + updates[def.EnvPrefix+"_FLAGS"] = "" } if err := updateEnvKeys(s.configPath, updates, sampleEnv); err != nil { @@ -682,18 +699,6 @@ func (s *Server) handleWizardStep1(w http.ResponseWriter, r *http.Request) { return } - type schedDef struct{ schedule, flags string } - defaults := map[string]schedDef{ - "weekly-exploration": {"15 00 * * 2", "--playlist weekly-exploration"}, - "weekly-jams": {"30 00 * * 1", "--playlist weekly-jams"}, - "daily-jams": {"15 01 * * *", "--playlist daily-jams"}, - } - envPrefixes := map[string]string{ - "weekly-exploration": "WEEKLY_EXPLORATION", - "weekly-jams": "WEEKLY_JAMS", - "daily-jams": "DAILY_JAMS", - } - enabled := make(map[string]bool, len(body.Playlists)) for _, p := range body.Playlists { enabled[p] = true @@ -703,14 +708,13 @@ func (s *Server) handleWizardStep1(w http.ResponseWriter, r *http.Request) { "LISTENBRAINZ_USER": body.User, "LISTENBRAINZ_DISCOVERY": body.DiscoveryMode, } - for playlist, prefix := range envPrefixes { - if enabled[playlist] { - d := defaults[playlist] - updates[prefix+"_SCHEDULE"] = d.schedule - updates[prefix+"_FLAGS"] = d.flags + for name, def := range playlistDefs { + if enabled[name] { + updates[def.EnvPrefix+"_SCHEDULE"] = def.DefaultSchedule + updates[def.EnvPrefix+"_FLAGS"] = def.DefaultFlags } else { - updates[prefix+"_SCHEDULE"] = "" - updates[prefix+"_FLAGS"] = "" + updates[def.EnvPrefix+"_SCHEDULE"] = "" + updates[def.EnvPrefix+"_FLAGS"] = "" } } @@ -813,6 +817,7 @@ func (s *Server) handleWizardStep3(w http.ResponseWriter, r *http.Request) { "FILTER_LIST": body.FilterList, "SLSKD_URL": body.SlskdURL, "SLSKD_API_KEY": body.SlskdAPIKey, + "WIZARD_COMPLETE": "true", } if err := updateEnvKeys(s.configPath, updates, sampleEnv); err != nil { From e499e467245b4c4034bb7b731eeb812f4a5ce2f7 Mon Sep 17 00:00:00 2001 From: nironics Date: Thu, 30 Apr 2026 11:14:59 -0400 Subject: [PATCH 3/4] Handle logic for already-present ratings --- src/client/lidarr.go | 16 +++-- src/client/plex.go | 2 +- src/web/frontend/src/components/Wizard.jsx | 11 +++- src/web/lidarr_state.go | 22 ++++++- src/web/lidarr_sync.go | 69 ++++++++++++++++++++-- src/web/server.go | 24 ++++++++ 6 files changed, 128 insertions(+), 16 deletions(-) diff --git a/src/client/lidarr.go b/src/client/lidarr.go index 68fe4c7..79fa78b 100644 --- a/src/client/lidarr.go +++ b/src/client/lidarr.go @@ -41,11 +41,17 @@ type LidarrArtist struct { } type LidarrAlbum struct { - ID int `json:"id"` - ForeignAlbumID string `json:"foreignAlbumId"` - Title string `json:"title"` - ArtistID int `json:"artistId"` - Monitored bool `json:"monitored"` + ID int `json:"id"` + ForeignAlbumID string `json:"foreignAlbumId"` + Title string `json:"title"` + ArtistID int `json:"artistId"` + Monitored bool `json:"monitored"` + Statistics *LidarrAlbumStatistics `json:"statistics,omitempty"` +} + +type LidarrAlbumStatistics struct { + TrackFileCount int `json:"trackFileCount"` + TotalTrackCount int `json:"totalTrackCount"` } type LidarrCommand struct { diff --git a/src/client/plex.go b/src/client/plex.go index e22a823..cb9fde1 100644 --- a/src/client/plex.go +++ b/src/client/plex.go @@ -435,7 +435,7 @@ func getPlexSong(track *models.Track, searchResults PlexSearch) (string, error) // GetRatedTracks returns all tracks in the configured library that have a userRating > 0. // Plex's filter operator syntax is finicky across versions, so this fetches all tracks -// in the library section and filters in Go. The Explo library is small (typically <200 tracks) +// in the library section and filters in Go. The Explo library is small with persist off (typically <200 tracks) // so the cost is trivial. func (c *Plex) GetRatedTracks() ([]PlexTrackMetadata, error) { if c.LibraryID == "" { diff --git a/src/web/frontend/src/components/Wizard.jsx b/src/web/frontend/src/components/Wizard.jsx index 9b63c2d..3c59192 100644 --- a/src/web/frontend/src/components/Wizard.jsx +++ b/src/web/frontend/src/components/Wizard.jsx @@ -241,7 +241,7 @@ function Step2({ fields, setField, envSources, onBack, onNext, saving }) { // Collects download service selection (YouTube, Slskd) and their respective // credentials, download directory, and file format preferences. -function Step3({ fields, setField, envSources, onBack, onNext, saving }) { +function Step3({ fields, setField, envSources, onBack, onNext, saving, isLastStep }) { const { downloadDir, useSubdirectory, migrateDownloads, dlServices, youtubeApiKey, trackExtension, filterList, slskdUrl, slskdApiKey } = fields const isLocked = key => envSources[key] === 'env' @@ -351,7 +351,7 @@ function Step3({ fields, setField, envSources, onBack, onNext, saving }) {
- +
) @@ -647,7 +647,11 @@ export default function Wizard({ config, envSources, onComplete }) { youtube_api_key: fields.youtubeApiKey, track_extension: fields.trackExtension, filter_list: fields.filterList, slskd_url: fields.slskdUrl, slskd_api_key: fields.slskdApiKey, }) - setStep(4) + if (fields.system === 'plex') { + setStep(4) + } else { + onComplete() + } } catch (e) { alert('Error saving: ' + e.message) } finally { @@ -717,6 +721,7 @@ export default function Wizard({ config, envSources, onComplete }) { fields={fields} setField={setField} envSources={envSources} onBack={() => goToStep(2)} onNext={handleStep3} saving={saving} + isLastStep={fields.system !== 'plex'} /> )} {step === 4 && ( diff --git a/src/web/lidarr_state.go b/src/web/lidarr_state.go index 1195545..d119083 100644 --- a/src/web/lidarr_state.go +++ b/src/web/lidarr_state.go @@ -28,6 +28,7 @@ type RatingStateEntry struct { type ratingStateFile struct { Version int `json:"version"` WebhookToken string `json:"webhook_token,omitempty"` + Bootstrapped bool `json:"bootstrapped,omitempty"` Entries map[string]RatingStateEntry `json:"entries"` } @@ -80,12 +81,31 @@ func (s *RatingState) Has(ratingKey string) bool { if !ok { return false } - if entry.Status == "ok" { + if entry.Status == "ok" || entry.Status == "bootstrap" { return true } return entry.RetryCount >= maxRatingRetries } +// IsBootstrapped reports whether the initial-snapshot pass has run. +func (s *RatingState) IsBootstrapped() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.data.Bootstrapped +} + +// MarkBootstrapped records existing ratings in bulk and sets the bootstrap flag. +// One flush at the end so we don't write to disk per-entry. +func (s *RatingState) MarkBootstrapped(entries map[string]RatingStateEntry) error { + s.mu.Lock() + defer s.mu.Unlock() + for k, v := range entries { + s.data.Entries[k] = v + } + s.data.Bootstrapped = true + return s.flushLocked() +} + func (s *RatingState) Get(ratingKey string) (RatingStateEntry, bool) { s.mu.Lock() defer s.mu.Unlock() diff --git a/src/web/lidarr_sync.go b/src/web/lidarr_sync.go index 50ec3aa..d016ba3 100644 --- a/src/web/lidarr_sync.go +++ b/src/web/lidarr_sync.go @@ -77,12 +77,53 @@ func NewLidarrSync(cfg config.LidarrConfig, plex *client.Plex, lidarr *client.Li func (s *LidarrSync) WebhookToken() string { return s.webhookToken } // Start launches the worker goroutine and (if poll interval > 0) the poll-ticker goroutine. -// Both exit when ctx is canceled. +// Both exit when ctx is canceled. On first run, ratings that already exist in Plex are +// snapshotted (marked synced without enqueuing) so enabling Lidarr doesn't trigger a +// flood of historical adds — only new ratings going forward are processed. func (s *LidarrSync) Start(ctx context.Context) { go s.worker(ctx) - if s.cfg.PollInterval > 0 { - go s.poller(ctx) + go func() { + if !s.state.IsBootstrapped() { + if err := s.bootstrap(ctx); err != nil { + slog.Warn("lidarr bootstrap failed; will retry on next start", "err", err.Error()) + return + } + } + if s.cfg.PollInterval > 0 { + s.poller(ctx) + } + }() +} + +// bootstrap snapshots all currently-rated tracks in Plex and marks them as +// already-synced, so the first poll won't enqueue every historical rating. +// Called once per state-file lifetime — clearing lidarr_synced.json re-bootstraps. +func (s *LidarrSync) bootstrap(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err } + tracks, err := s.plex.GetRatedTracks() + if err != nil { + return fmt.Errorf("snapshot rated tracks: %w", err) + } + now := time.Now().UTC().Format(time.RFC3339) + entries := make(map[string]RatingStateEntry, len(tracks)) + for _, t := range tracks { + if t.Type != plexTrackType { + continue + } + entries[t.RatingKey] = RatingStateEntry{ + SyncedAt: now, + Artist: t.GrandparentTitle, + Album: t.ParentTitle, + Status: "bootstrap", + } + } + if err := s.state.MarkBootstrapped(entries); err != nil { + return fmt.Errorf("persist bootstrap: %w", err) + } + slog.Info("lidarr sync bootstrapped — existing ratings snapshotted, only new ratings will be processed", "count", len(entries)) + return nil } func (s *LidarrSync) worker(ctx context.Context) { @@ -266,8 +307,11 @@ func (s *LidarrSync) processEvent(ctx context.Context, ev ratingEvent) error { return fmt.Errorf("monitor album: %w", err) } } - if err := s.lidarr.SearchAlbum(album.ID); err != nil { - return fmt.Errorf("search album: %w", err) + complete := albumComplete(album) + if !complete { + if err := s.lidarr.SearchAlbum(album.ID); err != nil { + return fmt.Errorf("search album: %w", err) + } } entry := RatingStateEntry{ @@ -281,10 +325,23 @@ func (s *LidarrSync) processEvent(ctx context.Context, ev ratingEvent) error { if err := s.state.Mark(ev.RatingKey, entry); err != nil { slog.Warn("failed to persist successful rating state", "err", err.Error()) } - slog.Info("queued album for download in Lidarr", "artist", ev.ArtistName, "album", ev.AlbumName, "albumId", album.ID) + if complete { + slog.Info("album already present in Lidarr, marked synced", "artist", ev.ArtistName, "album", ev.AlbumName, "albumId", album.ID) + } else { + slog.Info("queued album for download in Lidarr", "artist", ev.ArtistName, "album", ev.AlbumName, "albumId", album.ID) + } return nil } +// albumComplete reports whether Lidarr already has every track for the album on disk. +// A nil/zero Statistics block is treated as "unknown" → not complete, so we still search. +func albumComplete(a *client.LidarrAlbum) bool { + if a == nil || a.Statistics == nil { + return false + } + return a.Statistics.TotalTrackCount > 0 && a.Statistics.TrackFileCount >= a.Statistics.TotalTrackCount +} + func (s *LidarrSync) resolveArtistMBID(grandparentRatingKey string) (string, error) { resp, err := s.plex.GetArtistMetadata(grandparentRatingKey) if err != nil { diff --git a/src/web/server.go b/src/web/server.go index 8af6bd9..f8e5b31 100644 --- a/src/web/server.go +++ b/src/web/server.go @@ -299,6 +299,7 @@ type Server struct { lidarrSync *LidarrSync lidarrCancel context.CancelFunc + lidarrMu sync.Mutex } func NewServer(configPath, exploPath string) *Server { @@ -457,6 +458,20 @@ func (s *Server) initLidarrSync() error { return nil } +// restartLidarrSync tears down any running sync and re-reads the .env. Called +// after the wizard or settings page writes new LIDARR_* values so the change +// takes effect without a container restart. +func (s *Server) restartLidarrSync() error { + s.lidarrMu.Lock() + defer s.lidarrMu.Unlock() + if s.lidarrCancel != nil { + s.lidarrCancel() + s.lidarrCancel = nil + s.lidarrSync = nil + } + return s.initLidarrSync() +} + // ── Logging ──────────────────────────────────────────────────────────────── // logPath returns the path to the single rolling log file. @@ -564,6 +579,9 @@ func (s *Server) handleSaveConfig(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } + if err := s.restartLidarrSync(); err != nil { + slog.Warn("Lidarr sync restart failed", "err", err.Error()) + } w.WriteHeader(http.StatusOK) } @@ -1242,6 +1260,9 @@ func (s *Server) handleWizardStep4(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } + if err := s.restartLidarrSync(); err != nil { + slog.Warn("Lidarr sync restart failed", "err", err.Error()) + } w.WriteHeader(http.StatusOK) return } @@ -1271,6 +1292,9 @@ func (s *Server) handleWizardStep4(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } + if err := s.restartLidarrSync(); err != nil { + slog.Warn("Lidarr sync restart failed", "err", err.Error()) + } w.WriteHeader(http.StatusOK) } From 3818f9515f696343016211543ad6053d5df580ca Mon Sep 17 00:00:00 2001 From: nironics Date: Thu, 30 Apr 2026 12:10:12 -0400 Subject: [PATCH 4/4] Limit searching to music libraries only, clean up code a bit --- src/client/plex.go | 108 ++++++++++++++++++--------------------------- 1 file changed, 42 insertions(+), 66 deletions(-) diff --git a/src/client/plex.go b/src/client/plex.go index cb9fde1..9c6a57c 100644 --- a/src/client/plex.go +++ b/src/client/plex.go @@ -36,6 +36,7 @@ type Libraries struct { Library []struct { Title string `json:"title"` Key string `json:"key"` + Type string `json:"type"` Location []struct { ID int `json:"id"` Path string `json:"path"` @@ -110,42 +111,6 @@ type PlexMetadataResponse struct { } `json:"MediaContainer"` } -type PlexSearch struct { - MediaContainer struct { - Size int `json:"size"` - SearchResult []struct { - Score float64 `json:"score"` - Metadata struct { - LibrarySectionTitle string `json:"librarySectionTitle"` - RatingKey string `json:"ratingKey"` - Key string `json:"key"` - Type string `json:"type"` - Title string `json:"title"` // Track - GrandparentTitle string `json:"grandparentTitle"` // Artist - ParentTitle string `json:"parentTitle"` // Album - OriginalTitle string `json:"originalTitle"` - Summary string `json:"summary"` - Duration int `json:"duration"` - AddedAt int `json:"addedAt"` - UpdatedAt int `json:"updatedAt"` - Media []struct { - ID int `json:"id"` - Duration int `json:"duration"` - Part []struct { - ID int `json:"id"` - Key string `json:"key"` - Duration int `json:"duration"` - File string `json:"file"` - Size int `json:"size"` - } `json:"Part"` - AudioChannels int `json:"audioChannels"` - AudioCodec string `json:"audioCodec"` - Container string `json:"container"` - } `json:"Media"` - } `json:"Metadata"` - } `json:"SearchResult"` - } `json:"MediaContainer"` -} type PlexServer struct { MediaContainer struct { @@ -177,10 +142,11 @@ type PlexPlaylist struct { } type Plex struct { - machineID string - LibraryID string - HttpClient *util.HttpClient - Cfg config.ClientConfig + machineID string + LibraryID string + musicSectionIDs []string // all music-type library sections, for cross-library track search + HttpClient *util.HttpClient + Cfg config.ClientConfig } func NewPlex(cfg config.ClientConfig, httpClient *util.HttpClient) *Plex { @@ -250,11 +216,16 @@ func (c *Plex) GetLibrary() error { } for _, library := range libraries.MediaContainer.Library { + if library.Type == "artist" { + c.musicSectionIDs = append(c.musicSectionIDs, library.Key) + } if c.Cfg.LibraryName == library.Title { c.LibraryID = library.Key - return nil } } + if c.LibraryID != "" { + return nil + } if err = c.AddLibrary(); err != nil { slog.Debug(err.Error()) return fmt.Errorf("library named %s not found and cannot be added, please create it manually and ensure 'Prefer local metadata' is checked", c.Cfg.LibraryName) @@ -293,20 +264,7 @@ func (c *Plex) CheckRefreshState() bool { func (c *Plex) SearchSongs(tracks []*models.Track) error { for _, track := range tracks { - params := fmt.Sprintf("/library/search?query=%s", url.QueryEscape(track.CleanTitle)) - - body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+params, nil, c.Cfg.Creds.Headers) - if err != nil { - slog.Warn("search request failed for '%s': %s", track.Title, err.Error()) - continue - } - - var searchResults PlexSearch - if err = util.ParseResp(body, &searchResults); err != nil { - slog.Warn("failed to parse response for '%s': %s", track.Title, err.Error()) - continue - } - key, err := getPlexSong(track, searchResults) + key, err := c.findTrackAcrossSections(track) if err != nil { slog.Debug(err.Error()) continue @@ -319,6 +277,30 @@ func (c *Plex) SearchSongs(tracks []*models.Track) error { return nil } +// findTrackAcrossSections searches every music library section for the given track, +// returning the Plex key of the first match. Using per-section /all?type=10 avoids +// the global search endpoint, which ignores the type filter and floods results with +// TV/movie episodes that happen to share the track title. +func (c *Plex) findTrackAcrossSections(track *models.Track) (string, error) { + for _, sectionID := range c.musicSectionIDs { + params := fmt.Sprintf("/library/sections/%s/all?type=10&title=%s", sectionID, url.QueryEscape(track.CleanTitle)) + body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+params, nil, c.Cfg.Creds.Headers) + if err != nil { + slog.Warn("search request failed", "section", sectionID, "track", track.Title, "err", err.Error()) + continue + } + var items PlexLibraryItems + if err = util.ParseResp(body, &items); err != nil { + slog.Warn("failed to parse search response", "section", sectionID, "track", track.Title, "err", err.Error()) + continue + } + if key := getPlexSong(track, items.MediaContainer.Metadata); key != "" { + return key, nil + } + } + return "", fmt.Errorf("failed to find '%s' by '%s' in '%s'", track.Title, track.Artist, track.Album) +} + func (c *Plex) SearchPlaylist() error { params := "/playlists" @@ -397,22 +379,17 @@ func (c *Plex) getServer() error { return nil } -func getPlexSong(track *models.Track, searchResults PlexSearch) (string, error) { +func getPlexSong(track *models.Track, candidates []PlexTrackMetadata) string { loweredArtist := strings.ToLower(track.MainArtist) - for _, result := range searchResults.MediaContainer.SearchResult { - md := result.Metadata - if md.Type != "track" { - continue - } - + for _, md := range candidates { titleMatch := strings.EqualFold(md.Title, track.Title) || strings.EqualFold(md.Title, track.CleanTitle) albumMatch := strings.EqualFold(md.ParentTitle, track.Album) artistMatch := strings.Contains(strings.ToLower(md.OriginalTitle), loweredArtist) || strings.Contains(strings.ToLower(md.GrandparentTitle), loweredArtist) if titleMatch && (albumMatch || artistMatch) { slog.Debug(fmt.Sprintf("matched track via metadata: %s by %s", track.Title, track.Artist)) - return md.Key, nil + return md.Key } if track.File == "" || len(md.Media) == 0 || len(md.Media[0].Part) == 0 { @@ -425,12 +402,11 @@ func getPlexSong(track *models.Track, searchResults PlexSearch) (string, error) if durationMatch && pathMatch { slog.Debug(fmt.Sprintf("matched track via path: %s by %s", track.Title, track.Artist)) - return md.Key, nil + return md.Key } } - slog.Debug(fmt.Sprintf("full search result: %v", searchResults.MediaContainer.SearchResult)) - return "", fmt.Errorf("failed to find '%s' by '%s' in '%s'", track.Title, track.Artist, track.Album) + return "" } // GetRatedTracks returns all tracks in the configured library that have a userRating > 0.