an initial version of .cue sheets parser

initial support for multipart media files in Song ('beginning' and 'end' fields)
This commit is contained in:
Paweł Bara 2010-12-23 21:13:43 +00:00
parent cd4ecc1f8e
commit d7fe4600b8
11 changed files with 528 additions and 21 deletions

View File

@ -116,6 +116,7 @@ set(SOURCES
playlistparsers/asxparser.cpp
playlistparsers/asxiniparser.cpp
playlistparsers/cueparser.cpp
playlistparsers/m3uparser.cpp
playlistparsers/parserbase.cpp
playlistparsers/playlistparser.cpp
@ -279,6 +280,7 @@ set(HEADERS
playlistparsers/asxparser.h
playlistparsers/asxiniparser.h
playlistparsers/cueparser.h
playlistparsers/m3uparser.h
playlistparsers/parserbase.h
playlistparsers/playlistparser.h

View File

@ -158,7 +158,8 @@ Song::Private::Private()
skipcount_(0),
lastplayed_(-1),
score_(0),
length_(-1),
beginning_(0),
end_(-1),
bitrate_(-1),
samplerate_(-1),
directory_id_(-1),
@ -193,10 +194,23 @@ Song::Song(FileRefFactory* factory)
void Song::Init(const QString& title, const QString& artist, const QString& album, int length) {
d->valid_ = true;
d->title_ = title;
d->artist_ = artist;
d->album_ = album;
d->length_ = length;
set_length(length);
}
void Song::Init(const QString& title, const QString& artist, const QString& album, int beginning, int end) {
d->valid_ = true;
d->title_ = title;
d->artist_ = artist;
d->album_ = album;
d->beginning_ = beginning;
d->end_ = end;
}
void Song::set_genre(int id) {
@ -339,8 +353,8 @@ void Song::InitFromFile(const QString& filename, int directory_id) {
if (fileref->audioProperties()) {
d->bitrate_ = fileref->audioProperties()->bitrate();
d->length_ = fileref->audioProperties()->length();
d->samplerate_ = fileref->audioProperties()->sampleRate();
set_length(fileref->audioProperties()->length());
}
// Get the filetype if we can
@ -452,7 +466,8 @@ void Song::InitFromQuery(const SqlRow& q, int col) {
d->comment_ = tostr(col + 11);
d->compilation_ = q.value(col + 12).toBool();
d->length_ = toint(col + 13);
// TODO: this should be replaced by beginning and end
set_length(toint(col + 13));
d->bitrate_ = toint(col + 14);
d->samplerate_ = toint(col + 15);
@ -496,7 +511,8 @@ void Song::InitFromLastFM(const lastfm::Track& track) {
d->album_ = track.album();
d->artist_ = track.artist();
d->track_ = track.trackNumber();
d->length_ = track.duration();
set_length(track.duration());
}
#endif // HAVE_LIBLASTFM
@ -516,7 +532,7 @@ void Song::InitFromLastFM(const lastfm::Track& track) {
d->genre_ = QString::fromUtf8(track->genre);
d->comment_ = QString::fromUtf8(track->comment);
d->compilation_ = track->compilation;
d->length_ = track->tracklen / 1000;
set_length(track->tracklen / 1000);
d->bitrate_ = track->bitrate;
d->samplerate_ = track->samplerate;
d->mtime_ = track->time_modified;
@ -546,7 +562,7 @@ void Song::InitFromLastFM(const lastfm::Track& track) {
track->genre = strdup(d->genre_.toUtf8().constData());
track->comment = strdup(d->comment_.toUtf8().constData());
track->compilation = d->compilation_;
track->tracklen = d->length_ * 1000;
track->tracklen = length() * 1000;
track->bitrate = d->bitrate_;
track->samplerate = d->samplerate_;
track->time_modified = d->mtime_;
@ -575,7 +591,7 @@ void Song::InitFromLastFM(const lastfm::Track& track) {
d->basefilename_ = d->filename_;
d->track_ = track->tracknumber;
d->length_ = track->duration / 1000;
set_length(track->duration / 1000);
d->samplerate_ = track->samplerate;
d->bitrate_ = track->bitrate;
d->filesize_ = track->filesize;
@ -615,7 +631,7 @@ void Song::InitFromLastFM(const lastfm::Track& track) {
track->filename = strdup(d->basefilename_.toUtf8().constData());
track->tracknumber = d->track_;
track->duration = d->length_ * 1000;
track->duration = length() * 1000;
track->samplerate = d->samplerate_;
track->nochannels = 0;
track->wavecodec = 0;
@ -749,7 +765,7 @@ void Song::InitFromLastFM(const lastfm::Track& track) {
d->filename_ = item_value.toString();
else if (wcscmp(name, g_wszWMDMDuration) == 0)
d->length_ = item_value.toULongLong() / 10000000ll;
set_length(item_value.toULongLong() / 10000000ll);
else if (wcscmp(name, L"WMDM/FileSize") == 0)
d->filesize_ = item_value.toULongLong();
@ -813,7 +829,7 @@ void Song::InitFromLastFM(const lastfm::Track& track) {
if (!d->title_.isEmpty() || !d->artist_.isEmpty() ||
!d->album_.isEmpty() || !d->comment_.isEmpty() ||
!d->genre_.isEmpty() || d->track_ != -1 || d->year_ != -1 ||
d->length_ != -1) {
length() != -1) {
d->filetype_ = Song::Type_Unknown;
break;
}
@ -847,7 +863,7 @@ void Song::InitFromLastFM(const lastfm::Track& track) {
AddWmdmItem(metadata, g_wszWMDMComposer, d->composer_);
AddWmdmItem(metadata, g_wszWMDMBitrate, d->bitrate_);
AddWmdmItem(metadata, g_wszWMDMFileName, d->basefilename_);
AddWmdmItem(metadata, g_wszWMDMDuration, qint64(d->length_) * 10000000ll);
AddWmdmItem(metadata, g_wszWMDMDuration, qint64(length()) * 10000000ll);
AddWmdmItem(metadata, L"WMDM/FileSize", d->filesize_);
WMDM_FORMATCODE format;
@ -885,7 +901,7 @@ void Song::MergeFromSimpleMetaBundle(const Engine::SimpleMetaBundle &bundle) {
if (!bundle.genre.isEmpty()) d->genre_ = Decode(bundle.genre, codec);
if (!bundle.bitrate.isEmpty()) d->bitrate_ = bundle.bitrate.toInt();
if (!bundle.samplerate.isEmpty()) d->samplerate_ = bundle.samplerate.toInt();
if (!bundle.length.isEmpty()) d->length_ = bundle.length.toInt();
if (!bundle.length.isEmpty()) set_length(bundle.length.toInt());
if (!bundle.year.isEmpty()) d->year_ = bundle.year.toInt();
if (!bundle.tracknr.isEmpty()) d->track_ = bundle.tracknr.toInt();
}
@ -910,7 +926,8 @@ void Song::BindToQuery(QSqlQuery *query) const {
query->bindValue(":comment", strval(d->comment_));
query->bindValue(":compilation", d->compilation_ ? 1 : 0);
query->bindValue(":length", intval(d->length_));
// TODO: replace this with beginning and end
query->bindValue(":length", intval(length()));
query->bindValue(":bitrate", intval(d->bitrate_));
query->bindValue(":samplerate", intval(d->samplerate_));
@ -959,7 +976,7 @@ void Song::ToLastFM(lastfm::Track* track) const {
mtrack.setArtist(d->artist_);
mtrack.setAlbum(d->album_);
mtrack.setTitle(d->title_);
mtrack.setDuration(d->length_);
mtrack.setDuration(length());
mtrack.setTrackNumber(d->track_);
mtrack.setSource(lastfm::Track::Player);
}
@ -987,10 +1004,10 @@ QString Song::PrettyTitleWithArtist() const {
}
QString Song::PrettyLength() const {
if (d->length_ == -1)
if (length() == -1)
return QString::null;
return Utilities::PrettyTime(d->length_);
return Utilities::PrettyTime(length());
}
QString Song::PrettyYear() const {
@ -1025,7 +1042,8 @@ bool Song::IsMetadataEqual(const Song& other) const {
d->genre_ == other.d->genre_ &&
d->comment_ == other.d->comment_ &&
d->compilation_ == other.d->compilation_ &&
d->length_ == other.d->length_ &&
// this should be replaced by beginning and end
length() == other.length() &&
d->bitrate_ == other.d->bitrate_ &&
d->samplerate_ == other.d->samplerate_ &&
d->sampler_ == other.d->sampler_ &&

View File

@ -112,6 +112,7 @@ class Song {
// Constructors
void Init(const QString& title, const QString& artist, const QString& album, int length);
void Init(const QString& title, const QString& artist, const QString& album, int beginning, int end);
void InitFromFile(const QString& filename, int directory_id);
void InitFromQuery(const SqlRow& query, int col = 0);
#ifdef HAVE_LIBLASTFM
@ -170,7 +171,11 @@ class Song {
int lastplayed() const { return d->lastplayed_; }
int score() const { return d->score_; }
int length() const { return d->length_; }
int beginning() const { return d->beginning_; }
int end() const { return d->end_; }
int length() const { return d->end_ - d->beginning_; }
int bitrate() const { return d->bitrate_; }
int samplerate() const { return d->samplerate_; }
@ -217,7 +222,9 @@ class Song {
void set_comment(const QString& v) { d->comment_ = v; }
void set_compilation(bool v) { d->compilation_ = v; }
void set_sampler(bool v) { d->sampler_ = v; }
void set_length(int v) { d->length_ = v; }
void set_beginning(int v) { d->beginning_ = v; }
void set_end(int v) { d->end_ = v; }
void set_length(int v) { d->end_ = d->beginning_ + v; }
void set_bitrate(int v) { d->bitrate_ = v; }
void set_samplerate(int v) { d->samplerate_ = v; }
void set_mtime(int v) { d->mtime_ = v; }
@ -281,7 +288,17 @@ class Song {
int lastplayed_;
int score_;
int length_; // Seconds.
// The beginning of the song in seconds. In case of single-part media
// streams, this will equal to 0. In case of multi-part streams on the
// other hand, this will mark the beginning of a section represented by
// this Song object.
int beginning_;
// The end of the song in seconds. In case of single-part media
// streams, this will equal to the song's length. In case of multi-part
// streams on the other hand, this will mark the end of a section
// represented by this Song object.
int end_;
int bitrate_;
int samplerate_;

View File

@ -0,0 +1,289 @@
/* This file is part of Clementine.
Copyright 2010, David Sansome <me@davidsansome.com>
Clementine is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Clementine is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Clementine. If not, see <http://www.gnu.org/licenses/>.
*/
#include "cueparser.h"
#include <QBuffer>
#include <QStringBuilder>
#include <QRegExp>
#include <QTextStream>
#include <QtDebug>
const char* CueParser::kFileLineRegExp = "([^ \t\r\n]+)\\s+(?:\"([^\"]+)\"|([^ \t\r\n]+))\\s*(?:\"([^\"]+)\"|([^ \t\r\n]+))?";
const char* CueParser::kIndexRegExp = "(\\d{2}):(\\d{2}):(\\d{2})";
const char* CueParser::kPerformer = "performer";
const char* CueParser::kTitle = "title";
const char* CueParser::kFile = "file";
const char* CueParser::kTrack = "track";
const char* CueParser::kIndex = "index";
const char* CueParser::kAudioTrackType = "audio";
// TODO: if some song misses it's next one (because the next one was somehow
// broken), we need to discard the song too (can't really determine where it
// ends
// TODO: utf and regexps (check on Zucchero - there's something wrong)
CueParser::CueParser(LibraryBackendInterface* library, QObject* parent)
: ParserBase(library, parent)
{
}
SongList CueParser::Load(QIODevice* device, const QDir& dir) const {
SongList ret;
QTextStream text_stream(device);
QString dir_path = dir.absolutePath();
QString line;
QString album_artist;
QString album;
QString file;
// header
while (!(line = text_stream.readLine()).isNull()) {
QStringList splitted = SplitCueLine(line);
// uninteresting or incorrect line
if(splitted.size() < 2) {
continue;
}
QString line_name = splitted[0].toLower();
QString line_value = splitted[1];
// PERFORMER
if(line_name == kPerformer) {
album_artist = line_value;
// TITLE
} else if(line_name == kTitle) {
album = line_value;
// FILE
} else if(line_name == kFile) {
file = QDir::isAbsolutePath(line_value)
? line_value
: dir.absoluteFilePath(line_value);
// end of the header -> go into the track mode
} else if(line_name == kTrack) {
break;
}
// just ignore the rest of possible field types for now...
}
if(line.isNull()) {
qWarning() << "the .cue file from " << dir_path << " defines no tracks!";
return ret;
}
QString track_type;
QString index;
QString artist;
QString title;
QList<CueEntry> entries;
// tracks
do {
QStringList splitted = SplitCueLine(line);
// uninteresting or incorrect line
if(splitted.size() < 2) {
continue;
}
QString line_name = splitted[0].toLower();
QString line_value = splitted[1];
QString line_additional = splitted.size() > 2 ? splitted[2].toLower() : "";
if(line_name == kTrack) {
// the beginning of another track's definition - we're saving the current one
// for later (if it's valid of course)
if(!index.isEmpty() && (track_type.isEmpty() || track_type == kAudioTrackType)) {
entries.append(CueEntry(file, index, title, artist, album_artist, album));
}
// clear the state
track_type = index = artist = title = "";
if(!line_additional.isEmpty()) {
track_type = line_additional;
}
} else if(line_name == kIndex) {
// we need the index's position field
if(!line_additional.isEmpty()) {
// if there's none "01" index, we'll just take the first one
// also, we'll take the "01" index even if it's the last one
if(line_value == "01" || index.isEmpty()) {
index = line_additional;
}
}
} else if(line_name == kPerformer) {
artist = line_value;
} else if(line_name == kTitle) {
title = line_value;
}
// just ignore the rest of possible field types for now...
} while(!(line = text_stream.readLine()).isNull());
// we didn't add the last song yet...
if(!index.isEmpty() && (track_type.isEmpty() || track_type == kAudioTrackType)) {
entries.append(CueEntry(file, index, title, artist, album_artist, album));
}
// finalize parsing songs
for(int i = 0; i < entries.length(); i++) {
CueEntry entry = entries.at(i);
Song current;
if (!ParseTrackLocation(entry.file, dir, &current)) {
qWarning() << "failed to parse location in .cue file from " << dir_path;
} else {
// overwrite the stuff, we may have read from the file, using
// the .cue's metadata
if(i + 1 < entries.size()) {
// incorrect indices?
if(!UpdateSong(entry, entries.at(i + 1).index, &current)) {
continue;
}
} else {
// incorrect index?
if(!UpdateLastSong(entry, &current)) {
continue;
}
}
Song final;
// TODO: make this work
// load this song from the library if it's there
// Song final = LoadLibrarySong(current.filename());
Song to_add = final.is_valid() ? final : current;
ret << to_add;
}
}
return ret;
}
// This and the kFileLineRegExp do most of the "dirty" work, namely: splitting the raw .cue
// line into logical parts and getting rid of all the unnecessary whitespaces and quoting.
// This also validates the input: if returned list has less than two positions, the given
// line should be considered irrelevant.
QStringList CueParser::SplitCueLine(const QString& line) const {
QRegExp line_regexp(kFileLineRegExp);
if(!line_regexp.exactMatch(line.trimmed())) {
return QStringList();
}
// let's remove the empty entries while we're at it
return line_regexp.capturedTexts().filter(QRegExp(".+")).mid(1, -1);
}
// Updates the song with data from the .cue entry. This one mustn't be used for the
// last song in the .cue file.
bool CueParser::UpdateSong(const CueEntry& entry, const QString& next_index, Song* song) const {
int beginning = IndexToMarker(entry.index);
int end = IndexToMarker(next_index);
// incorrect indices (we won't be able to calculate beginning or end)
if(beginning == -1 || end == -1) {
return false;
}
song->Init(entry.title, entry.PrettyArtist(),
entry.album, beginning, end);
return true;
}
// Updates the song with data from the .cue entry. This one must be used only for the
// last song in the .cue file.
bool CueParser::UpdateLastSong(const CueEntry& entry, Song* song) const {
int beginning = IndexToMarker(entry.index);
// incorrect index (we won't be able to calculate beginning)
if(beginning == -1) {
return false;
}
song->set_title(entry.title);
song->set_artist(entry.PrettyArtist());
song->set_album(entry.album);
// we don't do anything with the end here because it's already set to
// the end of the media file (if it exists)
song->set_beginning(beginning);
return true;
}
int CueParser::IndexToMarker(const QString& index) const {
QRegExp index_regexp(kIndexRegExp);
if(!index_regexp.exactMatch(index)) {
return -1;
}
QStringList splitted = index_regexp.capturedTexts().mid(1, -1);
// TODO: use frames when #1166 is fixed
return splitted.at(0).toInt() * 60 + splitted.at(1).toInt();
}
void CueParser::Save(const SongList &songs, QIODevice *device, const QDir &dir) const {
// TODO
}
// Looks for a track starting with one of the .cue's keywords.
bool CueParser::TryMagic(const QByteArray &data) const {
QStringList splitted = QString::fromUtf8(data.constData()).split('\n');
for(int i = 0; i < splitted.length(); i++) {
QString line = splitted.at(i).trimmed();
if(line.startsWith(kPerformer, Qt::CaseInsensitive) ||
line.startsWith(kTitle, Qt::CaseInsensitive) ||
line.startsWith(kFile, Qt::CaseInsensitive) ||
line.startsWith(kTrack, Qt::CaseInsensitive)) {
return true;
}
}
return false;
}

View File

@ -0,0 +1,82 @@
/* This file is part of Clementine.
Copyright 2010, David Sansome <me@davidsansome.com>
Clementine is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Clementine is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Clementine. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef CUEPARSER_H
#define CUEPARSER_H
#include "parserbase.h"
#include <QRegExp>
class CueParser : public ParserBase {
Q_OBJECT
public:
static const char* kFileLineRegExp;
static const char* kIndexRegExp;
static const char* kPerformer;
static const char* kTitle;
static const char* kFile;
static const char* kTrack;
static const char* kIndex;
static const char* kAudioTrackType;
CueParser(LibraryBackendInterface* library, QObject* parent = 0);
QString name() const { return "CUE"; }
QStringList file_extensions() const { return QStringList() << "cue"; }
QString mime_type() const { return "application/x-cue"; }
bool TryMagic(const QByteArray& data) const;
SongList Load(QIODevice* device, const QDir& dir = QDir()) const;
void Save(const SongList& songs, QIODevice* device, const QDir& dir = QDir()) const;
private:
// A single TRACK entry in .cue file.
struct CueEntry {
QString file;
QString index;
QString title;
QString artist;
QString album_artist;
QString album;
QString PrettyArtist() const { return artist.isEmpty() ? album_artist : artist; }
CueEntry(QString& file, QString& index, QString& title, QString& artist,
QString& album_artist, QString& album) {
this->file = file;
this->index = index;
this->title = title;
this->artist = artist;
this->album_artist = album_artist;
this->album = album;
}
};
bool UpdateSong(const CueEntry& entry, const QString& next_index, Song* song) const;
bool UpdateLastSong(const CueEntry& entry, Song* song) const;
QStringList SplitCueLine(const QString& line) const;
int IndexToMarker(const QString& index) const;
};
#endif // CUEPARSER_H

View File

@ -19,6 +19,7 @@
#include "xspfparser.h"
#include "m3uparser.h"
#include "plsparser.h"
#include "cueparser.h"
#include "asxparser.h"
#include "asxiniparser.h"
@ -35,6 +36,7 @@ PlaylistParser::PlaylistParser(LibraryBackendInterface* library, QObject *parent
parsers_ << new PLSParser(library, this);
parsers_ << new ASXParser(library, this);
parsers_ << new AsxIniParser(library, this);
parsers_ << new CueParser(library, this);
}
QStringList PlaylistParser::file_extensions() const {

View File

@ -98,6 +98,7 @@ endmacro (add_test_file)
add_test_file(albumcovermanager_test.cpp true)
add_test_file(asxparser_test.cpp false)
add_test_file(asxiniparser_test.cpp false)
add_test_file(cueparser_test.cpp false)
add_test_file(database_test.cpp false)
add_test_file(fileformats_test.cpp false)
add_test_file(fmpsparser_test.cpp false)

79
tests/cueparser_test.cpp Normal file
View File

@ -0,0 +1,79 @@
/* This file is part of Clementine.
Copyright 2010, David Sansome <me@davidsansome.com>
Clementine is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Clementine is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Clementine. If not, see <http://www.gnu.org/licenses/>.
*/
#include "gmock/gmock-matchers.h"
#include "gtest/gtest.h"
#include "test_utils.h"
#include "mock_taglib.h"
#include "playlistparsers/cueparser.h"
class CueParserTest : public ::testing::Test {
protected:
static void SetUpTestCase() {
testing::DefaultValue<TagLib::String>::Set("foobarbaz");
}
CueParserTest()
: parser_(NULL) {
}
CueParser parser_;
MockFileRefFactory taglib_;
};
TEST_F(CueParserTest, ParsesASong) {
QFile file(":testdata/onesong.cue");
file.open(QIODevice::ReadOnly);
SongList song_list = parser_.Load(&file, QDir(""));
// one song
ASSERT_EQ(1, song_list.size());
// with the specified metadata
Song first_song = song_list.at(0);
ASSERT_EQ("Un soffio caldo", first_song.title());
ASSERT_EQ("Zucchero", first_song.artist());
ASSERT_EQ("", first_song.album());
ASSERT_EQ(1, first_song.beginning());
}
TEST_F(CueParserTest, ParsesTwoSongs) {
QFile file(":testdata/twosongs.cue");
file.open(QIODevice::ReadOnly);
SongList song_list = parser_.Load(&file, QDir(""));
// two songs
ASSERT_EQ(2, song_list.size());
// with the specified metadata
Song first_song = song_list.at(0);
ASSERT_EQ("Un soffio caldo", first_song.title());
ASSERT_EQ("Chocabeck", first_song.album());
ASSERT_EQ("Zucchero himself", first_song.artist());
ASSERT_EQ(1, first_song.beginning());
ASSERT_EQ((5 * 60 + 3) - 1, first_song.length());
Song second_song = song_list.at(1);
ASSERT_EQ("Somewon Else's Tears", second_song.title());
ASSERT_EQ("Chocabeck", second_song.album());
ASSERT_EQ("Zucchero himself", second_song.artist());
ASSERT_EQ(5 * 60 + 3, second_song.beginning());
}

6
tests/data/onesong.cue Normal file
View File

@ -0,0 +1,6 @@
PERFORMER "Zucchero"
FILE "file.mp3" WAVE
TRACK 01 AUDIO
TITLE "Un soffio caldo"
PERFORMER Zucchero
INDEX 01 00:01:00

View File

@ -7,6 +7,8 @@
<file>beep.wav</file>
<file>beep.wma</file>
<file>beep.m4a</file>
<file>onesong.cue</file>
<file>twosongs.cue</file>
<file>pls_one.pls</file>
<file>pls_somafm.pls</file>
<file>test.m3u</file>

9
tests/data/twosongs.cue Normal file
View File

@ -0,0 +1,9 @@
PERFORMER "Zucchero himself"
TITLE "Chocabeck"
FILE files/longer.mp3 WAVE
TRACK 01 AUDIO
TITLE "Un soffio caldo"
INDEX 01 00:01:00
TRACK 02 AUDIO
TITLE "Somewon Else's Tears"
INDEX 01 05:03:68