Skip to content
Merged
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
45 changes: 44 additions & 1 deletion apps/Mac/Views/MusicBrainzSearchSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ struct MusicBrainzSearchSheet: View {

@State private var searchResults: [MusicBrainzWork] = []
@State private var isSearching = false
/// The search itself failed, as opposed to returning no matches.
@State private var searchFailed = false
@State private var selectedWork: MusicBrainzWork?
@State private var isSubmitting = false
@State private var resultTitle: String?
Expand Down Expand Up @@ -45,6 +47,8 @@ struct MusicBrainzSearchSheet: View {
// Content
if isSearching {
loadingView
} else if searchFailed {
unavailableView
} else if searchResults.isEmpty {
emptyView
} else {
Expand Down Expand Up @@ -101,6 +105,38 @@ struct MusicBrainzSearchSheet: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

private var unavailableView: some View {
VStack(spacing: ApproachNoteTheme.spacingMD) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 50))
.foregroundColor(ApproachNoteTheme.textSecondary.opacity(0.5))

Text("Search Unavailable")
.font(ApproachNoteTheme.headline())
.foregroundColor(ApproachNoteTheme.textPrimary)

Text("We couldn't reach MusicBrainz just now. This is usually temporary.")
.font(ApproachNoteTheme.subheadline())
.foregroundColor(ApproachNoteTheme.textSecondary)
.multilineTextAlignment(.center)

Button {
Task {
await performSearch()
}
} label: {
HStack {
Image(systemName: "arrow.clockwise")
Text("Try Again")
}
}
.buttonStyle(.bordered)
.padding(.top, ApproachNoteTheme.spacingXS)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

private var emptyView: some View {
VStack(spacing: ApproachNoteTheme.spacingMD) {
Image(systemName: "magnifyingglass")
Expand Down Expand Up @@ -240,7 +276,14 @@ struct MusicBrainzSearchSheet: View {

private func performSearch() async {
isSearching = true
searchResults = await musicBrainzService.searchMusicBrainzWorks(query: searchQuery)
switch await musicBrainzService.searchMusicBrainzWorks(query: searchQuery) {
case .results(let works):
searchResults = works
searchFailed = false
case .unavailable:
searchResults = []
searchFailed = true
}
isSearching = false
}

Expand Down
13 changes: 13 additions & 0 deletions apps/Shared/Models/MusicBrainz.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,19 @@ struct SongRequestErrorResponse: Codable {
let error: String
}

/// Outcome of a MusicBrainz work search, surfaced to the UI.
///
/// The two cases must stay distinct: an empty `results` array means
/// MusicBrainz answered and had no matching work, while `unavailable` means
/// MusicBrainz never answered. Telling a user "no results" when the service
/// was down sends them off to add a song that is already there.
enum MusicBrainzSearchResult {
/// MusicBrainz answered. The array may be empty, meaning no matches.
case results([MusicBrainzWork])
/// MusicBrainz could not be reached, or the API call failed.
case unavailable
}

/// Outcome of submitting a song request, surfaced to the UI.
enum SongRequestResult {
/// The request was recorded and is awaiting admin review.
Expand Down
19 changes: 13 additions & 6 deletions apps/Shared/Services/MusicBrainzService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ import os
@MainActor
class MusicBrainzService: ObservableObject {

/// Search MusicBrainz for works (songs) by title
func searchMusicBrainzWorks(query: String) async -> [MusicBrainzWork] {
/// Search MusicBrainz for works (songs) by title.
///
/// Returns `.unavailable` rather than an empty result set when the search
/// itself failed, so the UI can say "try again" instead of wrongly telling
/// the user MusicBrainz has no such song.
func searchMusicBrainzWorks(query: String) async -> MusicBrainzSearchResult {
let startTime = Date()

let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query
Expand All @@ -18,7 +22,8 @@ class MusicBrainzService: ObservableObject {
let (data, response) = try await URLSession.shared.data(from: url)

guard let httpResponse = response as? HTTPURLResponse else {
return []
Log.network.error("Error searching MusicBrainz: non-HTTP response")
return .unavailable
}

APIClient.logRequest("GET /musicbrainz/works/search", startTime: startTime)
Expand All @@ -28,14 +33,16 @@ class MusicBrainzService: ObservableObject {
if APIClient.diagnosticsEnabled {
Log.network.debug("Found \(searchResponse.results.count, privacy: .public) MusicBrainz works")
}
return searchResponse.results
return .results(searchResponse.results)
} else {
// 503 is the backend reporting that MusicBrainz is down; any
// other status is our own failure. Neither means "no matches".
Log.network.error("Error searching MusicBrainz: HTTP \(httpResponse.statusCode, privacy: .public)")
return []
return .unavailable
}
} catch {
Log.network.error("Error searching MusicBrainz: \(error)")
return []
return .unavailable
}
}

Expand Down
47 changes: 46 additions & 1 deletion apps/iOS/Views/MusicBrainzSearchSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ struct MusicBrainzSearchSheet: View {

@State private var searchResults: [MusicBrainzWork] = []
@State private var isSearching = false
/// The search itself failed, as opposed to returning no matches.
@State private var searchFailed = false
@State private var selectedWork: MusicBrainzWork?
@State private var isSubmitting = false
@State private var resultTitle: String?
Expand All @@ -28,6 +30,8 @@ struct MusicBrainzSearchSheet: View {
VStack(spacing: 0) {
if isSearching {
loadingView
} else if searchFailed {
unavailableView
} else if searchResults.isEmpty {
emptyView
} else {
Expand Down Expand Up @@ -110,6 +114,40 @@ struct MusicBrainzSearchSheet: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

private var unavailableView: some View {
VStack(spacing: ApproachNoteTheme.spacingMD) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 60))
.foregroundColor(ApproachNoteTheme.textSecondary.opacity(0.5))

Text("Search Unavailable")
.font(ApproachNoteTheme.headline())
.foregroundColor(ApproachNoteTheme.textPrimary)

Text("We couldn't reach MusicBrainz just now. This is usually temporary.")
.font(ApproachNoteTheme.subheadline())
.foregroundColor(ApproachNoteTheme.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal)

Button {
Task {
await performSearch()
}
} label: {
HStack {
Image(systemName: "arrow.clockwise")
Text("Try Again")
}
}
.buttonStyle(.bordered)
.tint(ApproachNoteTheme.brand)
.padding(.top, ApproachNoteTheme.spacingXS)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

private var emptyView: some View {
VStack(spacing: ApproachNoteTheme.spacingMD) {
Image(systemName: "magnifyingglass")
Expand Down Expand Up @@ -222,7 +260,14 @@ struct MusicBrainzSearchSheet: View {

private func performSearch() async {
isSearching = true
searchResults = await musicBrainzService.searchMusicBrainzWorks(query: searchQuery)
switch await musicBrainzService.searchMusicBrainzWorks(query: searchQuery) {
case .results(let works):
searchResults = works
searchFailed = false
case .unavailable:
searchResults = []
searchFailed = true
}
isSearching = false
}

Expand Down
138 changes: 101 additions & 37 deletions backend/integrations/musicbrainz/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@
logger = logging.getLogger(__name__)


class MusicBrainzUnavailable(Exception):
"""MusicBrainz could not be reached, or kept returning a transient error.

Raised only after the retry budget is exhausted. Callers must not treat
this as "no matches" — the distinction is the whole point: an empty
result set means MusicBrainz answered and had nothing, this means it
never answered.
"""


class MusicBrainzSearcher:
"""Shared MusicBrainz search functionality with caching"""

Expand Down Expand Up @@ -494,51 +504,39 @@ def search_works_multi(self, title, limit=5):
limit: Maximum number of results to return (default 5)

Returns:
List of dicts with keys: id, title, composers, score, type, musicbrainz_url
List of dicts with keys: id, title, composers, score, type, musicbrainz_url.
An empty list means MusicBrainz answered and had no matches.

Raises:
MusicBrainzUnavailable: MusicBrainz never answered successfully.
"""
self.last_made_api_call = True
self.rate_limit()

# Normalize apostrophes - MusicBrainz typically uses curly apostrophe (')
# Convert straight apostrophe to curly for better matching
normalized_title = title.replace("'", "'")
# Normalize apostrophes to ASCII. MusicBrainz stores most titles with
# a curly apostrophe, but its search index matches either form, so
# this only keeps our query text predictable.
#
# \u escapes are used deliberately: this line previously read
# `title.replace("'", "'")` with an ASCII apostrophe on both sides,
# an editor autocorrect having flattened the curly one, which made it
# a silent no-op. Same hazard as documented in normalize_title().
normalized_title = title
for variant in ['‘', '’', 'ʼ', '`', '´']:
normalized_title = normalized_title.replace(variant, "'")

# Search with the title as a phrase
query = f'work:"{normalized_title}"'

logger.debug(f"Searching MusicBrainz works (multi): {query}")

try:
response = self.session.get(
'https://musicbrainz.org/ws/2/work/',
params={
'query': query,
'fmt': 'json',
'limit': limit
},
timeout=10
)
response.raise_for_status()

data = response.json()
data = self._search_works_request(query, limit)
works = data.get('works', [])

# If no results with quoted search, try unquoted
if not works:
logger.debug("No results with quoted search, trying unquoted...")
self.rate_limit()

response = self.session.get(
'https://musicbrainz.org/ws/2/work/',
params={
'query': normalized_title,
'fmt': 'json',
'limit': limit
},
timeout=10
)
response.raise_for_status()
data = response.json()
data = self._search_works_request(normalized_title, limit)
works = data.get('works', [])

if not works:
Expand Down Expand Up @@ -575,16 +573,82 @@ def search_works_multi(self, title, limit=5):
logger.debug(f"Found {len(results)} MusicBrainz works")
return results

except requests.exceptions.Timeout:
logger.warning("MusicBrainz search timed out")
return []
except requests.exceptions.RequestException as e:
logger.error(f"MusicBrainz search failed: {e}")
return []
except MusicBrainzUnavailable:
# Never downgrade an outage to "no matches" — let the caller
# tell the user that search is unavailable.
raise
except Exception as e:
logger.error(f"Error searching MusicBrainz: {e}")
# A malformed/unexpected payload from an otherwise healthy
# MusicBrainz. Nothing to show, but nothing to retry either.
logger.error(f"Error parsing MusicBrainz search results: {e}")
return []

def _search_works_request(self, query, limit):
"""Run one work-search query, retrying transient MusicBrainz errors.

MusicBrainz sheds load with a 503 ("The MusicBrainz web server is
currently busy") often enough that a single attempt fails a few
percent of the time. Matches the backoff used by get_work_recordings
and the other detail fetches.

Raises:
MusicBrainzUnavailable: after the retry budget is exhausted.
"""
max_retries = 3
last_reason = 'unknown'
attempts_used = 0

for attempt in range(max_retries):
attempts_used = attempt + 1
if attempt > 0:
# 1s, 2s. Shorter than the importers' 2s/4s because this
# path is a user waiting on a search sheet, not a batch job.
backoff_time = 2 ** (attempt - 1)
logger.warning(f"BACKOFF: MusicBrainz work search retry "
f"{attempt + 1}/{max_retries}, waiting {backoff_time}s "
f"(query={query})")
time.sleep(backoff_time)

self.rate_limit()

try:
response = self.session.get(
'https://musicbrainz.org/ws/2/work/',
params={
'query': query,
'fmt': 'json',
'limit': limit
},
timeout=10
)

if response.status_code == 200:
return response.json()

if response.status_code in (503, 429):
last_reason = f'HTTP {response.status_code}'
logger.warning(f"MusicBrainz work search {last_reason} (transient)")
continue

# 4xx other than 429 is our fault, not theirs — a retry
# would just repeat the same bad request.
last_reason = f'HTTP {response.status_code}'
logger.error(f"MusicBrainz work search failed: {last_reason}")
break

except requests.exceptions.Timeout:
last_reason = 'timeout'
logger.warning("MusicBrainz work search timed out")
continue
except requests.exceptions.RequestException as e:
last_reason = str(e)
logger.warning(f"MusicBrainz work search connection error: {e}")
continue

raise MusicBrainzUnavailable(
f"MusicBrainz work search failed after {attempts_used} attempt(s) ({last_reason})"
)

def _escape_lucene_query(self, text):
"""
Escape special characters for Lucene query syntax
Expand Down
Loading
Loading