/
/
/
1"""
2CUE sheet parser for Music Assistant.
3
4Parses standard CUE sheet format into structured data.
5Supports the CATALOG, FILE, TRACK, INDEX, TITLE, PERFORMER, ISRC and REM
6directives, plus the non-standard top-level GENRE extension. Other standard
7directives (FLAGS, PREGAP, POSTGAP, SONGWRITER, CDTEXTFILE) are accepted
8but ignored.
9"""
10
11from __future__ import annotations
12
13import logging
14import re
15from dataclasses import dataclass, field
16
17_LOGGER = logging.getLogger(__name__)
18
19
20@dataclass
21class CueTrack:
22 """A single track entry from a CUE sheet."""
23
24 number: int
25 title: str | None = None
26 performers: list[str] = field(default_factory=list) # one PERFORMER line per artist
27 start_position: float = 0.0 # seconds from INDEX 01
28 isrcs: list[str] = field(default_factory=list) # ISRC directive, repeated for multi-value
29 sort_name: str | None = None # REM TITLESORT
30 artist_sort_names: list[str] = field(
31 default_factory=list
32 ) # REM ARTISTSORT, aligned by index with performers
33 musicbrainz_artistids: list[str] = field(
34 default_factory=list
35 ) # REM MUSICBRAINZ_ARTISTID, aligned by index
36 musicbrainz_recordingid: str | None = None # REM MUSICBRAINZ_RECORDINGID
37 musicbrainz_releasetrackid: str | None = (
38 None # REM MUSICBRAINZ_TRACKID (matches Picard's %musicbrainz_trackid%)
39 )
40 copyright: str | None = None # REM COPYRIGHT
41 grouping: str | None = None # REM GROUPING
42 comment: str | None = None # REM COMMENT â metadata.description
43 explicit: bool | None = None # REM ITUNESADVISORY ("1"/"0")
44 genres: list[str] = field(default_factory=list) # REM GENRE inside a TRACK block, multi-line
45
46
47@dataclass
48class CueSheet:
49 """Parsed CUE sheet data."""
50
51 file_path: str | None = None # referenced audio file (None for embedded CUE)
52 title: str | None = None # album title
53 performers: list[str] = field(default_factory=list) # album artists, one PERFORMER line each
54 sort_title: str | None = None # REM ALBUMSORT
55 album_artist_sort_names: list[str] = field(
56 default_factory=list
57 ) # REM ALBUMARTISTSORT, aligned by index
58 musicbrainz_albumartistids: list[str] = field(
59 default_factory=list
60 ) # REM MUSICBRAINZ_ALBUMARTISTID, aligned by index
61 date: str | None = None # REM DATE
62 genres: list[str] = field(default_factory=list) # REM GENRE at sheet level
63 album_types: list[str] = field(
64 default_factory=list
65 ) # REM RELEASETYPE (e.g. "album", "compilation")
66 barcode: str | None = None # CATALOG directive (UPC/EAN)
67 musicbrainz_albumid: str | None = None # REM MUSICBRAINZ_ALBUMID
68 musicbrainz_releasegroupid: str | None = None # REM MUSICBRAINZ_RELEASEGROUPID
69 tracks: list[CueTrack] = field(default_factory=list)
70
71
72def _parse_timestamp(timestamp: str) -> float:
73 """
74 Convert CUE timestamp (MM:SS:FF) to seconds.
75
76 :param timestamp: CUE format timestamp where FF = frames at 75fps.
77 """
78 match = re.match(r"(\d+):(\d+):(\d+)", timestamp)
79 if not match:
80 _LOGGER.warning("Invalid CUE timestamp %r, treating as 0", timestamp)
81 return 0.0
82 minutes, seconds, frames = int(match.group(1)), int(match.group(2)), int(match.group(3))
83 return minutes * 60.0 + seconds + frames / 75.0
84
85
86def _unquote(value: str) -> str:
87 """Remove surrounding quotes from a CUE value."""
88 value = value.strip()
89 if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
90 return value[1:-1]
91 return value
92
93
94def parse_cue_sheet(cue_content: str) -> CueSheet:
95 """
96 Parse CUE sheet content into structured data.
97
98 :param cue_content: The raw text content of a CUE sheet.
99 """
100 sheet = CueSheet()
101 current_track: CueTrack | None = None
102
103 for raw_line in cue_content.splitlines():
104 line = raw_line.strip()
105 if not line:
106 continue
107
108 upper_line = line.upper()
109
110 if upper_line.startswith("REM "):
111 _parse_rem_line(line, sheet, current_track)
112
113 elif upper_line.startswith("PERFORMER "):
114 value = _unquote(line[10:])
115 if current_track is not None:
116 current_track.performers.append(value)
117 else:
118 sheet.performers.append(value)
119
120 elif upper_line.startswith("TITLE "):
121 value = _unquote(line[6:])
122 if current_track is not None:
123 current_track.title = value
124 else:
125 sheet.title = value
126
127 elif upper_line.startswith("FILE "):
128 # FILE "filename.flac" WAVE
129 # extract filename between quotes, ignore type
130 match = re.match(r'FILE\s+"([^"]+)"', line, re.IGNORECASE)
131 if match:
132 sheet.file_path = match.group(1)
133 else:
134 # handle unquoted filename: FILE filename.flac WAVE
135 parts = line.split(None, 2)
136 if len(parts) >= 2:
137 sheet.file_path = parts[1]
138
139 elif upper_line.startswith("TRACK "):
140 # TRACK 01 AUDIO
141 match = re.match(r"TRACK\s+(\d+)", line, re.IGNORECASE)
142 if match:
143 current_track = CueTrack(number=int(match.group(1)))
144 sheet.tracks.append(current_track)
145
146 elif upper_line.startswith("INDEX "):
147 if current_track is None:
148 continue
149 # INDEX 01 MM:SS:FF, use INDEX 01 as track start
150 match = re.match(r"INDEX\s+(\d+)\s+(\d+:\d+:\d+)", line, re.IGNORECASE)
151 if match and match.group(1) == "01":
152 current_track.start_position = _parse_timestamp(match.group(2))
153
154 elif upper_line.startswith("ISRC "):
155 if current_track is not None:
156 current_track.isrcs.append(_unquote(line[5:]))
157
158 elif upper_line.startswith("CATALOG "):
159 # disc-level UPC/EAN, only valid outside a TRACK block
160 if current_track is None:
161 sheet.barcode = _unquote(line[8:])
162
163 elif upper_line.startswith("GENRE "):
164 # non-standard CD-Text extension (cuetools, foobar2000); same landing
165 # as REM GENRE so tools using either form produce the same result
166 target = current_track.genres if current_track is not None else sheet.genres
167 target.append(_unquote(line[6:]))
168
169 return sheet
170
171
172def _parse_rem_line(line: str, sheet: CueSheet, current_track: CueTrack | None) -> None:
173 """
174 Parse a REM line for metadata.
175
176 :param line: The full REM line.
177 :param sheet: The CueSheet being built.
178 :param current_track: The current track context, if any.
179 """
180 # REM KEY VALUE, split into at most 3 parts
181 parts = line.split(None, 2)
182 if len(parts) < 3:
183 return
184
185 key = parts[1].upper()
186 value = _unquote(parts[2])
187
188 # sheet-level directives (written outside any TRACK block)
189 if current_track is None:
190 if key == "DATE":
191 sheet.date = value
192 elif key == "GENRE":
193 sheet.genres.append(value)
194 elif key == "MUSICBRAINZ_ALBUMID":
195 sheet.musicbrainz_albumid = value
196 elif key == "MUSICBRAINZ_RELEASEGROUPID":
197 sheet.musicbrainz_releasegroupid = value
198 elif key == "MUSICBRAINZ_ALBUMARTISTID":
199 sheet.musicbrainz_albumartistids.append(value)
200 elif key == "ALBUMSORT":
201 sheet.sort_title = value
202 elif key == "ALBUMARTISTSORT":
203 sheet.album_artist_sort_names.append(value)
204 elif key == "RELEASETYPE":
205 sheet.album_types.append(value)
206 return
207
208 # track-level (inside a TRACK block)
209 if key == "GENRE":
210 current_track.genres.append(value)
211 elif key == "MUSICBRAINZ_RECORDINGID":
212 current_track.musicbrainz_recordingid = value
213 elif key == "MUSICBRAINZ_TRACKID":
214 # matches Picard's %musicbrainz_trackid% variable (release-track MBID)
215 current_track.musicbrainz_releasetrackid = value
216 elif key == "MUSICBRAINZ_ARTISTID":
217 current_track.musicbrainz_artistids.append(value)
218 elif key == "ARTISTSORT":
219 current_track.artist_sort_names.append(value)
220 elif key == "TITLESORT":
221 current_track.sort_name = value
222 elif key == "COPYRIGHT":
223 current_track.copyright = value
224 elif key == "GROUPING":
225 current_track.grouping = value
226 elif key == "COMMENT":
227 current_track.comment = value
228 elif key == "ITUNESADVISORY":
229 current_track.explicit = value == "1"
230