diff --git a/src/client/lidarr.go b/src/client/lidarr.go new file mode 100644 index 0000000..79fa78b --- /dev/null +++ b/src/client/lidarr.go @@ -0,0 +1,239 @@ +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"` + Statistics *LidarrAlbumStatistics `json:"statistics,omitempty"` +} + +type LidarrAlbumStatistics struct { + TrackFileCount int `json:"trackFileCount"` + TotalTrackCount int `json:"totalTrackCount"` +} + +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..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"` @@ -44,43 +45,73 @@ type Libraries struct { } `json:"MediaContainer"` } -type PlexSearch struct { +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"` - 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"` + 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 PlexServer struct { MediaContainer struct { Size int `json:"size"` @@ -111,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 { @@ -184,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) @@ -227,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 @@ -253,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" @@ -331,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 { @@ -359,12 +402,57 @@ 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. +// 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 with persist off (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) { 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 30a20b7..3c59192 100644 --- a/src/web/frontend/src/components/Wizard.jsx +++ b/src/web/frontend/src/components/Wizard.jsx @@ -9,8 +9,8 @@ */ import { AnimatePresence, motion, useReducedMotion } from 'motion/react' -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' @@ -53,7 +53,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.

@@ -146,7 +146,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.

@@ -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, onFinish, 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' @@ -256,7 +256,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.

@@ -349,6 +349,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 && ( + +
+ + +
+
+ )} + + )} +
+
@@ -412,6 +585,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', } }) @@ -465,6 +647,31 @@ 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, }) + if (fields.system === 'plex') { + setStep(4) + } else { + onComplete() + } + } 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) @@ -513,7 +720,15 @@ export default function Wizard({ config, envSources, onComplete }) { goToStep(2)} onFinish={handleStep3} saving={saving} + onBack={() => goToStep(2)} onNext={handleStep3} saving={saving} + isLastStep={fields.system !== 'plex'} + /> + )} + {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..d119083 --- /dev/null +++ b/src/web/lidarr_state.go @@ -0,0 +1,185 @@ +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"` + Bootstrapped bool `json:"bootstrapped,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" || 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() + 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..d016ba3 --- /dev/null +++ b/src/web/lidarr_sync.go @@ -0,0 +1,479 @@ +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. 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) + 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) { + 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) + } + } + complete := albumComplete(album) + if !complete { + 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()) + } + 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 { + 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 29273b8..e833589 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 === # WIZARD_COMPLETE=false diff --git a/src/web/server.go b/src/web/server.go index 4b0c04c..f8e5b31 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 @@ -87,6 +93,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", "WIZARD_COMPLETE", } @@ -204,6 +213,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. @@ -236,6 +296,10 @@ type Server struct { exploPath string mux *http.ServeMux manualRun manualRunState + + lidarrSync *LidarrSync + lidarrCancel context.CancelFunc + lidarrMu sync.Mutex } func NewServer(configPath, exploPath string) *Server { @@ -287,12 +351,17 @@ 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) s.mux.HandleFunc("GET /api/playlists", s.handleGetPlaylist) coversDir := filepath.Join(filepath.Dir(s.configPath), "cache", "covers") @@ -301,10 +370,108 @@ func (s *Server) registerRoutes() { 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 +} + +// 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. @@ -412,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) } @@ -965,6 +1135,169 @@ 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 + } + if err := s.restartLidarrSync(); err != nil { + slog.Warn("Lidarr sync restart failed", "err", err.Error()) + } + 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 + } + if err := s.restartLidarrSync(); err != nil { + slog.Warn("Lidarr sync restart failed", "err", err.Error()) + } + w.WriteHeader(http.StatusOK) +} + // ── Helpers ──────────────────────────────────────────────────────────────── func buildArgs(playlist, downloadMode string, noPersist, excludeLocal bool, cfgPath string) []string {