Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/web/backend/defs/defs.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ package defs
import (
"regexp"
)
// place for confs/variables in use by the UI or backend

// place for confs/variables in use by the UI or backend

// Custom playlist regex validation
var CustomIDRe = regexp.MustCompile(`^custom-[a-z0-9]+$`)
Expand Down Expand Up @@ -181,5 +181,5 @@ var AllConfigKeys = []string{
"DOWNLOAD_DIR", "USE_SUBDIRECTORY", "PATH_TEMPLATE", "ENRICH_TRACK_METADATA",
"DOWNLOAD_SERVICES", "YOUTUBE_API_KEY", "TRACK_EXTENSION", "FILTER_LIST",
"SLSKD_URL", "SLSKD_API_KEY",
"WIZARD_COMPLETE", "MIGRATE_DOWNLOADS", "EXTENSIONS", "LISTENBRAINZ_USER_TOKEN",
}
"WIZARD_COMPLETE", "MIGRATE_DOWNLOADS", "EXTENSIONS", "LISTENBRAINZ_USER_TOKEN", "SUFFIX_REMOVAL",
}
20 changes: 16 additions & 4 deletions src/web/backend/playlist/apple_music.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ import (
"io"
"log/slog"
"net/http"
"regexp"
"strings"

"golang.org/x/net/html"
)

var albumSuffixRe = regexp.MustCompile(`(?i)\s*[-–]\s*(single|ep)\s*$`)

// appleServerData mirrors the top-level shape of the
// <script id="serialized-server-data"> JSON blob on Apple Music pages.
type appleServerData struct {
Expand Down Expand Up @@ -53,7 +56,7 @@ func resolveArtworkURL(tpl string) string {
// fetchAppleMusicPlaylist scrapes a public Apple Music playlist page and extracts
// track info from the embedded server data.
// Returns (playlistName, artworkURL, tracks, error) where tracks are [title, artist, album, coverURL].
func fetchAppleMusicPlaylist(pageURL string) (string, string, []PlaylistTrack, error) {
func fetchAppleMusicPlaylist(pageURL string, enabled bool) (string, string, []PlaylistTrack, error) {
req, err := http.NewRequest("GET", pageURL, nil)
if err != nil {
return "", "", nil, fmt.Errorf("apple music: invalid URL: %w", err)
Expand Down Expand Up @@ -85,7 +88,7 @@ func fetchAppleMusicPlaylist(pageURL string) (string, string, []PlaylistTrack, e
htmlStr := string(body)

// Parse the serialized-server-data for everything: playlist name, artwork, tracks.
playlistName, artworkURL, tracks, err := extractServerData(htmlStr)
playlistName, artworkURL, tracks, err := extractServerData(htmlStr, enabled)
if err != nil {
return "", "", nil, err
}
Expand All @@ -104,7 +107,7 @@ func fetchAppleMusicPlaylist(pageURL string) (string, string, []PlaylistTrack, e

// extractServerData parses the <script id="serialized-server-data"> blob for
// the playlist name, playlist artwork URL (from the header section), and tracks with artwork.
func extractServerData(htmlStr string) (string, string, []PlaylistTrack, error) {
func extractServerData(htmlStr string, enabled bool) (string, string, []PlaylistTrack, error) {
scripts := extractScriptByID(htmlStr, "serialized-server-data")
if len(scripts) == 0 {
return "", "", nil, fmt.Errorf("apple music: no serialized-server-data found in page")
Expand Down Expand Up @@ -148,7 +151,7 @@ func extractServerData(htmlStr string) (string, string, []PlaylistTrack, error)
for _, item := range sec.Items {
album := ""
if len(item.TertiaryLinks) > 0 {
album = item.TertiaryLinks[0].Title
album = suffixRegexRemoval(item.TertiaryLinks[0].Title, enabled)
}
coverURL := ""
if item.Artwork != nil {
Expand All @@ -171,6 +174,15 @@ func extractServerData(htmlStr string) (string, string, []PlaylistTrack, error)
return playlistName, artworkURL, tracks, nil
}

// suffixRegexRemoval checks .env and removes the suffixes created my Apple Music using regex.
func suffixRegexRemoval(albumName string, enabled bool) string {
if enabled {
var newAlbumName = albumSuffixRe.ReplaceAllString(albumName, "")
return newAlbumName
}
return albumName
}

// extractPlaylistNameFromJSONLD pulls the playlist title from the JSON-LD MusicPlaylist block.
func extractPlaylistNameFromJSONLD(htmlStr string) string {
for _, raw := range extractScriptByType(htmlStr, "application/ld+json") {
Expand Down
7 changes: 3 additions & 4 deletions src/web/backend/playlist/custom_playlists.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (

"explo/src/discovery"
"explo/src/util"

)

// CustomPlaylist holds the metadata for a user-imported playlist.
Expand Down Expand Up @@ -121,10 +120,10 @@ type FetchResult struct {

// fetchCustomPlaylistTracks dispatches to the appropriate source fetcher.
// This is the single point where source-specific logic lives for fetching.
func fetchCustomPlaylistTracks(p CustomPlaylist) (FetchResult, error) {
func fetchCustomPlaylistTracks(p CustomPlaylist, enabled bool) (FetchResult, error) {
switch p.Source {
case "apple_music":
name, art, tracks, err := fetchAppleMusicPlaylist(p.SourceURL)
name, art, tracks, err := fetchAppleMusicPlaylist(p.SourceURL, enabled)
return FetchResult{name, art, tracks}, err
case "spotify":
name, art, tracks, err := fetchSpotifyPlaylist(p.SourceURL)
Expand Down Expand Up @@ -208,4 +207,4 @@ func customPlaylistTrackCount(cfgDir, id string) int {
return 0
}
return len(m.Tracks)
}
}
38 changes: 30 additions & 8 deletions src/web/backend/playlist/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,22 @@ package playlist

import (
"encoding/json"
"net/http"
"fmt"
"os"
"log/slog"
"path/filepath"
"math/rand/v2"
"net/http"
"os"
"path/filepath"
"time"

"explo/src/util"
"explo/src/web/backend/defs"
"explo/src/web"
"explo/src/web/backend/defs"

"golang.org/x/text/cases"
"golang.org/x/text/language"
)


// handleGetCustomPlaylists returns all saved custom playlists with a track_count
// derived from their cache file (if present) and the current sync schedule from .env.
func (p *Playlist) HandleGetCustomPlaylists(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -85,7 +84,19 @@ func (p *Playlist) HandleImportCustomPlaylist(w http.ResponseWriter, r *http.Req
return
}

result, err := fetchCustomPlaylistTracks(CustomPlaylist{Source: body.Source, SourceURL: body.URL})
// Read .env
var envValues map[string]string
var enabled = false
if data, err := os.ReadFile(p.cfg.WebEnvPath); err == nil {
envValues = p.settings.ParseEnvText(string(data))
} else {
envValues = map[string]string{}
}
if envValues["SUFFIX_REMOVAL"] == "true" {
enabled = true
}

result, err := fetchCustomPlaylistTracks(CustomPlaylist{Source: body.Source, SourceURL: body.URL}, enabled)
if err != nil {
slog.Error("custom-playlists: fetch failed", "source", body.Source, "err", err)
http.Error(w, "failed to fetch playlist: "+err.Error(), http.StatusBadGateway)
Expand Down Expand Up @@ -218,10 +229,21 @@ func (p *Playlist) HandleRefreshCustomPlaylist(w http.ResponseWriter, r *http.Re
return
}

var envValues map[string]string
var AMSuffixRe = false
if data, err := os.ReadFile(p.cfg.WebEnvPath); err == nil {
envValues = p.settings.ParseEnvText(string(data))
} else {
envValues = map[string]string{}
}
if envValues["SUFFIX_REMOVAL"] == "true" {
AMSuffixRe = true
}

plist := playlists[idx]
slog.Info("custom-playlists: manual refresh", "id", id, "source", plist.Source)

result, err := fetchCustomPlaylistTracks(plist)
result, err := fetchCustomPlaylistTracks(plist, AMSuffixRe)
if err != nil {
slog.Error("custom-playlists: refresh fetch failed", "id", id, "err", err)
http.Error(w, "failed to fetch playlist: "+err.Error(), http.StatusBadGateway)
Expand Down Expand Up @@ -410,4 +432,4 @@ func (p *Playlist) HandleBackgroundArt(w http.ResponseWriter, r *http.Request) {
if err := json.NewEncoder(w).Encode(map[string]string{"url": url}); err != nil {
slog.Error("background-art: failed to write response", "err", err.Error())
}
}
}
12 changes: 8 additions & 4 deletions src/web/backend/playlist/jobs.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
package playlist

import (
"explo/src/util"
"explo/src/web/backend/jobs"
"log/slog"
"os"
"time"
"explo/src/util"
"explo/src/web/backend/jobs"

"github.com/go-co-op/gocron/v2"
)
Expand All @@ -19,11 +19,15 @@ func (p *Playlist) RegisterCustomPlaylistRefresh(j *jobs.Jobs) error {
}

var envValues map[string]string
var AMSuffixRe = false
if data, err := os.ReadFile(p.cfg.WebEnvPath); err == nil {
envValues = p.settings.ParseEnvText(string(data))
} else {
envValues = map[string]string{}
}
if envValues["SUFFIX_REMOVAL"] == "true" {
AMSuffixRe = true
}

for _, plist := range playlists {
plist := plist
Expand All @@ -46,7 +50,7 @@ func (p *Playlist) RegisterCustomPlaylistRefresh(j *jobs.Jobs) error {
return
}
slog.Info("custom-playlists: refreshing", "id", plist.ID, "name", plist.Name, "source", plist.Source)
result, err := fetchCustomPlaylistTracks(plist)
result, err := fetchCustomPlaylistTracks(plist, AMSuffixRe)
if err != nil {
slog.Warn("custom-playlists: refresh fetch failed", "id", plist.ID, "err", err)
return
Expand All @@ -69,4 +73,4 @@ func (p *Playlist) RegisterCustomPlaylistRefresh(j *jobs.Jobs) error {
}
}
return nil
}
}
7 changes: 4 additions & 3 deletions src/web/backend/routes.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
package backend

import (
"io/fs"
"log/slog"
"net/http"
"strings"
"io/fs"
"path/filepath"
"strings"
)

func (s *Server) registerRoutes() {
Expand Down Expand Up @@ -55,6 +55,7 @@ func (s *Server) registerSettingRoutes() {
s.mux.Handle("POST /api/ui/config/enrich-metadata", s.auth(s.settings.HandleSaveEnrichMetadata))
s.mux.Handle("POST /api/ui/config/replace-playlist", s.auth(s.settings.HandleSaveReplacePlaylist))
s.mux.Handle("POST /api/ui/config/clean-downloads", s.auth(s.settings.HandleSaveCleanDownloads))
s.mux.Handle("POST /api/ui/config/suffix-removal", s.auth(s.settings.HandleSaveSuffixRemoval))

// Path template presets: GET list, POST add; DELETE per name under prefix
s.mux.Handle("api/ui/path-templates", s.auth(s.settings.HandlePathTemplates))
Expand Down Expand Up @@ -103,4 +104,4 @@ func (s *Server) registerMiscRoutes() {
// small helper func for auth routing
func (s *Server) auth(h http.HandlerFunc) http.Handler {
return s.authStore.RequireAuth(h)
}
}
26 changes: 26 additions & 0 deletions src/web/backend/settings/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -560,3 +560,29 @@ func (s *Settings) HandleDeletePathTemplate(w http.ResponseWriter, r *http.Reque
}
w.WriteHeader(http.StatusOK)
}

// handleSaveSuffixRemoval handles toggling Suffix Removal for Apple Music.
func (s *Settings) HandleSaveSuffixRemoval(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
Enabled bool `json:"enabled"`
}

if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}

val := "false"
if body.Enabled {
val = "true"
}
if err := s.UpdateEnvKeys(map[string]string{"SUFFIX_REMOVAL": val}, web.SampleEnv); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
29 changes: 28 additions & 1 deletion src/web/frontend/src/components/Settings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
fetchConfig, fetchConfigRaw, saveConfig, resetConfig,
saveSchedule, startRun, stopRun, fetchRunStatus, fetchLogs,
fetchCustomPlaylists, deleteCustomPlaylist, savePathTemplate, saveEnrichMetadata,
saveReplacePlaylist, saveCleanDownloads,
saveReplacePlaylist, saveCleanDownloads, saveSuffixRemoval,
fetchPathTemplatePresets, addPathTemplatePreset, deletePathTemplatePreset,
} from '../lib/api'
import { parseSlogLine, cronToFields, highlightEnv } from '../lib/utils'
Expand Down Expand Up @@ -525,6 +525,7 @@ function DownloadPathSection() {
const [enrichEnabled, setEnrichEnabled] = useState(false)
const [cleanDownloads, setCleanDownloads] = useState(false)
const [templateEnabled, setTemplateEnabled] = useState(false)
const [suffixRemoval, setSuffixRemoval] = useState(false)

useEffect(() => {
Promise.all([
Expand All @@ -535,6 +536,7 @@ function DownloadPathSection() {
...SEED_PRESETS.map(p => ({ ...p, seed: true })),
...jsonPresets,
]
setSuffixRemoval(values.SUFFIX_REMOVAL === 'true')
setEnrichEnabled(values.ENRICH_TRACK_METADATA === 'true')
const anyFlags = values.WEEKLY_EXPLORATION_FLAGS || values.WEEKLY_JAMS_FLAGS || values.DAILY_JAMS_FLAGS || values.ON_REPEAT_FLAGS || ''
setCleanDownloads(anyFlags.includes('--clean-downloads'))
Expand Down Expand Up @@ -583,6 +585,12 @@ function DownloadPathSection() {
}
}

const handleSuffixToggle = async () => {
const next = !suffixRemoval
setSuffixRemoval(next)
try { await saveSuffixRemoval(next) } catch { setSuffixRemoval(!next) }
}

useEffect(() => {
if (!showModal) return
const handle = e => { e.preventDefault(); e.returnValue = '' }
Expand Down Expand Up @@ -688,6 +696,22 @@ function DownloadPathSection() {
</button>
</div>

{/* Remove suffixes from album names for Apple Music*/}
<div className="flex items-start justify-between mt-3 mb-1 gap-4">
<div className="flex flex-col gap-0.5">
<span className="text-[13px] text-white">Remove suffixes from album names from Apple Music</span>
<span className="text-[13px] text-muted">Removes the additional suffixes (e.g " - EP", " - Single") Apple Music generates when importing playlists.</span>
</div>
<button
role="switch"
aria-checked={suffixRemoval}
onClick={handleSuffixToggle}
className={`relative inline-flex h-[22px] w-10 shrink-0 cursor-pointer rounded-full transition-colors duration-200 ${suffixRemoval ? 'bg-accent' : 'bg-[#383838]'}`}
>
<span className={`inline-block h-[18px] w-[18px] my-[2px] rounded-full bg-white shadow transition-transform duration-200 ${suffixRemoval ? 'translate-x-[20px]' : 'translate-x-[2px]'}`} />
</button>
</div>

{templateEnabled && (<>
{/* Current / pending path readout */}
<div className="flex items-baseline gap-2.5 overflow-x-auto py-1 mt-6">
Expand All @@ -699,6 +723,9 @@ function DownloadPathSection() {
</div>
</div>




{/* Profile card grid */}
<div className="grid grid-cols-1 min-[520px]:grid-cols-2 min-[720px]:grid-cols-4 gap-3 mt-2">
{profiles.map((profile, i) => {
Expand Down
9 changes: 9 additions & 0 deletions src/web/frontend/src/lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,15 @@ export async function saveCleanDownloads(enabled) {
if (!res.ok) throw new Error(await res.text())
}

export async function saveSuffixRemoval(enabled) {
const res = await apiFetch('/api/ui/config/suffix-removal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
})
if (!res.ok) throw new Error(await res.text())
}

export async function fetchBackgroundArt() {
try {
const res = await fetch('/api/ui/background-art')
Expand Down