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