1 /* This file is part of Clementine.
2 Copyright 2010, David Sansome <me@davidsansome.com>
3
4 Clementine is free software: you can redistribute it and/or modify
5 it under the terms of the GNU General Public License as published by
6 the Free Software Foundation, either version 3 of the License, or
7 (at your option) any later version.
8
9 Clementine is distributed in the hope that it will be useful,
10 but WITHOUT ANY WARRANTY; without even the implied warranty of
11 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 GNU General Public License for more details.
13
14 You should have received a copy of the GNU General Public License
15 along with Clementine. If not, see <http://www.gnu.org/licenses/>.
16 */
17
18 #include "cueparser.h"
19 #include "core/logging.h"
20 #include "core/timeconstants.h"
21
22 #include <QBuffer>
23 #include <QDateTime>
24 #include <QFileInfo>
25 #include <QStringBuilder>
26 #include <QRegExp>
27 #include <QTextCodec>
28 #include <QTextStream>
29 #include <QtDebug>
30
31 const char* CueParser::kFileLineRegExp =
32 "(\\S+)\\s+(?:\"([^\"]+)\"|(\\S+))\\s*(?:\"([^\"]+)\"|(\\S+))?";
33 const char* CueParser::kIndexRegExp = "(\\d{2,3}):(\\d{2}):(\\d{2})";
34
35 const char* CueParser::kPerformer = "performer";
36 const char* CueParser::kTitle = "title";
37 const char* CueParser::kSongWriter = "songwriter";
38 const char* CueParser::kFile = "file";
39 const char* CueParser::kTrack = "track";
40 const char* CueParser::kIndex = "index";
41 const char* CueParser::kAudioTrackType = "audio";
42 const char* CueParser::kRem = "rem";
43 const char* CueParser::kGenre = "genre";
44 const char* CueParser::kDate = "date";
45 const char* CueParser::kDisc = "discnumber";
46
CueParser(LibraryBackendInterface * library,QObject * parent)47 CueParser::CueParser(LibraryBackendInterface* library, QObject* parent)
48 : ParserBase(library, parent) {}
49
Load(QIODevice * device,const QString & playlist_path,const QDir & dir) const50 SongList CueParser::Load(QIODevice* device, const QString& playlist_path,
51 const QDir& dir) const {
52 SongList ret;
53
54 QTextStream text_stream(device);
55 text_stream.setCodec(QTextCodec::codecForUtfText(
56 device->peek(1024), QTextCodec::codecForName("UTF-8")));
57
58 QString dir_path = dir.absolutePath();
59 // read the first line already
60 QString line = text_stream.readLine();
61
62 QList<CueEntry> entries;
63 int files = 0;
64
65 // -- whole file
66 while (!text_stream.atEnd()) {
67 QString album_artist;
68 QString album;
69 QString album_composer;
70 QString file;
71 QString file_type;
72 QString genre;
73 QString date;
74 QString disc;
75
76 // -- FILE section
77 do {
78 QStringList splitted = SplitCueLine(line);
79
80 // uninteresting or incorrect line
81 if (splitted.size() < 2) {
82 continue;
83 }
84
85 QString line_name = splitted[0].toLower();
86 QString line_value = splitted[1];
87
88 // PERFORMER
89 if (line_name == kPerformer) {
90 album_artist = line_value;
91
92 // TITLE
93 } else if (line_name == kTitle) {
94 album = line_value;
95
96 // SONGWRITER
97 } else if (line_name == kSongWriter) {
98 album_composer = line_value;
99
100 // FILE
101 } else if (line_name == kFile) {
102 file = QDir::isAbsolutePath(line_value)
103 ? line_value
104 : dir.absoluteFilePath(line_value);
105
106 if (splitted.size() > 2) {
107 file_type = splitted[2];
108 }
109
110 // REM
111 } else if (line_name == kRem) {
112 if (splitted.size() < 3) {
113 break;
114 }
115
116 // REM GENRE
117 if (line_value.toLower() == kGenre) {
118 genre = splitted[2];
119
120 // REM DATE
121 } else if (line_value.toLower() == kDate) {
122 date = splitted[2];
123
124 // REM DISC
125 } else if (line_value.toLower() == kDisc) {
126 disc = splitted[2];
127 }
128
129 // end of the header -> go into the track mode
130 } else if (line_name == kTrack) {
131 files++;
132 break;
133 }
134
135 // just ignore the rest of possible field types for now...
136 } while (!(line = text_stream.readLine()).isNull());
137
138 if (line.isNull()) {
139 qLog(Warning) << "the .cue file from " << dir_path
140 << " defines no tracks!";
141 return ret;
142 }
143
144 // if this is a data file, all of it's tracks will be ignored
145 bool valid_file = file_type.compare("BINARY", Qt::CaseInsensitive) &&
146 file_type.compare("MOTOROLA", Qt::CaseInsensitive);
147
148 QString track_type;
149 QString index;
150 QString artist;
151 QString composer;
152 QString title;
153
154 // TRACK section
155 do {
156 QStringList splitted = SplitCueLine(line);
157
158 // uninteresting or incorrect line
159 if (splitted.size() < 2) {
160 continue;
161 }
162
163 QString line_name = splitted[0].toLower();
164 QString line_value = splitted[1];
165 QString line_additional =
166 splitted.size() > 2 ? splitted[2].toLower() : "";
167
168 if (line_name == kTrack) {
169 // the beginning of another track's definition - we're saving the
170 // current one
171 // for later (if it's valid of course)
172 // please note that the same code is repeated just after this 'do-while'
173 // loop
174 if (valid_file && !index.isEmpty() &&
175 (track_type.isEmpty() || track_type == kAudioTrackType)) {
176 entries.append(CueEntry(file, index, title, artist, album_artist,
177 album, composer, album_composer, genre,
178 date, disc));
179 }
180
181 // clear the state
182 track_type = index = artist = title = "";
183
184 if (!line_additional.isEmpty()) {
185 track_type = line_additional;
186 }
187
188 } else if (line_name == kIndex) {
189 // we need the index's position field
190 if (!line_additional.isEmpty()) {
191 // if there's none "01" index, we'll just take the first one
192 // also, we'll take the "01" index even if it's the last one
193 if (line_value == "01" || index.isEmpty()) {
194 index = line_additional;
195 }
196 }
197
198 } else if (line_name == kPerformer) {
199 artist = line_value;
200
201 } else if (line_name == kTitle) {
202 title = line_value;
203
204 } else if (line_name == kSongWriter) {
205 composer = line_value;
206
207 // end of track's for the current file -> parse next one
208 } else if (line_name == kFile) {
209 break;
210 }
211
212 // just ignore the rest of possible field types for now...
213 } while (!(line = text_stream.readLine()).isNull());
214
215 // we didn't add the last song yet...
216 if (valid_file && !index.isEmpty() &&
217 (track_type.isEmpty() || track_type == kAudioTrackType)) {
218 entries.append(CueEntry(file, index, title, artist, album_artist, album,
219 composer, album_composer, genre, date, disc));
220 }
221 }
222
223 QDateTime cue_mtime = QFileInfo(playlist_path).lastModified();
224
225 // finalize parsing songs
226 for (int i = 0; i < entries.length(); i++) {
227 CueEntry entry = entries.at(i);
228
229 Song song = LoadSong(entry.file, IndexToMarker(entry.index), dir);
230
231 // cue song has mtime equal to qMax(media_file_mtime, cue_sheet_mtime)
232 if (cue_mtime.isValid()) {
233 song.set_mtime(qMax(cue_mtime.toTime_t(), song.mtime()));
234 }
235 song.set_cue_path(playlist_path);
236
237 // overwrite the stuff, we may have read from the file or library, using
238 // the current .cue metadata
239
240 // set track number only in single-file mode
241 if (files == 1) {
242 song.set_track(i + 1);
243 }
244
245 // the last TRACK for every FILE gets it's 'end' marker from the media
246 // file's
247 // length
248 if (i + 1 < entries.size() &&
249 entries.at(i).file == entries.at(i + 1).file) {
250 // incorrect indices?
251 if (!UpdateSong(entry, entries.at(i + 1).index, &song)) {
252 continue;
253 }
254 } else {
255 // incorrect index?
256 if (!UpdateLastSong(entry, &song)) {
257 continue;
258 }
259 }
260
261 ret << song;
262 }
263
264 return ret;
265 }
266
267 // This and the kFileLineRegExp do most of the "dirty" work, namely: splitting
268 // the raw .cue
269 // line into logical parts and getting rid of all the unnecessary whitespaces
270 // and quoting.
SplitCueLine(const QString & line) const271 QStringList CueParser::SplitCueLine(const QString& line) const {
272 QRegExp line_regexp(kFileLineRegExp);
273 if (!line_regexp.exactMatch(line.trimmed())) {
274 return QStringList();
275 }
276
277 // let's remove the empty entries while we're at it
278 return line_regexp.capturedTexts().filter(QRegExp(".+")).mid(1, -1);
279 }
280
281 // Updates the song with data from the .cue entry. This one mustn't be used for
282 // the
283 // last song in the .cue file.
UpdateSong(const CueEntry & entry,const QString & next_index,Song * song) const284 bool CueParser::UpdateSong(const CueEntry& entry, const QString& next_index,
285 Song* song) const {
286 qint64 beginning = IndexToMarker(entry.index);
287 qint64 end = IndexToMarker(next_index);
288
289 // incorrect indices (we won't be able to calculate beginning or end)
290 if (beginning == -1 || end == -1) {
291 return false;
292 }
293
294 // believe the CUE: Init() forces validity
295 song->Init(entry.title, entry.PrettyArtist(), entry.album, beginning, end);
296 song->set_albumartist(entry.album_artist);
297 song->set_composer(entry.PrettyComposer());
298 song->set_genre(entry.genre);
299 song->set_year(entry.date.toInt());
300 song->set_disc(entry.disc.toInt());
301
302 return true;
303 }
304
305 // Updates the song with data from the .cue entry. This one must be used only
306 // for the
307 // last song in the .cue file.
UpdateLastSong(const CueEntry & entry,Song * song) const308 bool CueParser::UpdateLastSong(const CueEntry& entry, Song* song) const {
309 qint64 beginning = IndexToMarker(entry.index);
310
311 // incorrect index (we won't be able to calculate beginning)
312 if (beginning == -1) {
313 return false;
314 }
315
316 // believe the CUE and force validity (like UpdateSong() does)
317 song->set_valid(true);
318
319 song->set_title(entry.title);
320 song->set_artist(entry.PrettyArtist());
321 song->set_album(entry.album);
322 song->set_albumartist(entry.album_artist);
323 song->set_genre(entry.genre);
324 song->set_year(entry.date.toInt());
325 song->set_composer(entry.PrettyComposer());
326 song->set_disc(entry.disc.toInt());
327
328 // we don't do anything with the end here because it's already set to
329 // the end of the media file (if it exists)
330 song->set_beginning_nanosec(beginning);
331
332 return true;
333 }
334
IndexToMarker(const QString & index) const335 qint64 CueParser::IndexToMarker(const QString& index) const {
336 QRegExp index_regexp(kIndexRegExp);
337 if (!index_regexp.exactMatch(index)) {
338 return -1;
339 }
340
341 QStringList splitted = index_regexp.capturedTexts().mid(1, -1);
342 qlonglong frames = splitted.at(0).toLongLong() * 60 * 75 +
343 splitted.at(1).toLongLong() * 75 +
344 splitted.at(2).toLongLong();
345 return (frames * kNsecPerSec) / 75;
346 }
347
Save(const SongList & songs,QIODevice * device,const QDir & dir,Playlist::Path path_type) const348 void CueParser::Save(const SongList& songs, QIODevice* device, const QDir& dir,
349 Playlist::Path path_type) const {
350 // TODO
351 }
352
353 // Looks for a track starting with one of the .cue's keywords.
TryMagic(const QByteArray & data) const354 bool CueParser::TryMagic(const QByteArray& data) const {
355 QStringList splitted = QString::fromUtf8(data.constData()).split('\n');
356
357 for (int i = 0; i < splitted.length(); i++) {
358 QString line = splitted.at(i).trimmed();
359 if (line.startsWith(kPerformer, Qt::CaseInsensitive) ||
360 line.startsWith(kTitle, Qt::CaseInsensitive) ||
361 line.startsWith(kFile, Qt::CaseInsensitive) ||
362 line.startsWith(kTrack, Qt::CaseInsensitive)) {
363 return true;
364 }
365 }
366
367 return false;
368 }
369