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 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.
+
+
+
+
@@ -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 {