diff --git a/CMakeLists.txt b/CMakeLists.txt index 488bedac0..cf0c879d0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -84,6 +84,8 @@ find_library(LASTFM_LIBRARIES lastfm) find_path(LASTFM_INCLUDE_DIRS lastfm/ws.h) find_path(LASTFM1_INCLUDE_DIRS lastfm/Track.h) +find_path(SPARSEHASH_INCLUDE_DIRS google/sparsetable) + if(LASTFM_INCLUDE_DIRS AND LASTFM1_INCLUDE_DIRS) set(HAVE_LIBLASTFM1 ON) endif() @@ -200,6 +202,7 @@ option(ENABLE_BREAKPAD "Enable crash reporting" OFF) option(ENABLE_SPOTIFY_BLOB "Build the spotify non-GPL binary" ON) option(ENABLE_SPOTIFY "Enable spotify support" ON) option(ENABLE_MOODBAR "Enable moodbar" ON) +option(ENABLE_GOOGLE_DRIVE "Enable Google Drive support" ON) if(WIN32) option(ENABLE_WIN32_CONSOLE "Show the windows console even outside Debug mode" OFF) @@ -266,6 +269,10 @@ if(ENABLE_MOODBAR AND FFTW3_FOUND) set(HAVE_MOODBAR ON) endif() +if(ENABLE_GOOGLE_DRIVE AND SPARSEHASH_INCLUDE_DIRS AND TAGLIB_VERSION VERSION_GREATER 1.7.999) + set(HAVE_GOOGLE_DRIVE ON) +endif() + if(ENABLE_VISUALISATIONS) # When/if upstream accepts our patches then these options can be used to link @@ -439,6 +446,7 @@ summary_add("Devices: iPod Touch, iPhone, iPad support" HAVE_IMOBILEDEVICE) summary_add("Devices: MTP support" HAVE_LIBMTP) summary_add("Devices: GIO backend" HAVE_GIO) summary_add("Gnome sound menu integration" HAVE_LIBINDICATE) +summary_add("Google Drive support" HAVE_GOOGLE_DRIVE) summary_add("Last.fm support" HAVE_LIBLASTFM) summary_add("Moodbar support" HAVE_MOODBAR) summary_add("Spotify support: core code" HAVE_SPOTIFY) diff --git a/data/data.qrc b/data/data.qrc index a51757c54..71022f56b 100644 --- a/data/data.qrc +++ b/data/data.qrc @@ -275,6 +275,7 @@ providers/digitallyimported-32.png providers/digitallyimported.png providers/echonest.png + providers/googledrive.png providers/grooveshark.png providers/itunes.png providers/jamendo.png @@ -326,6 +327,7 @@ schema/schema-35.sql schema/schema-36.sql schema/schema-37.sql + schema/schema-38.sql schema/schema-3.sql schema/schema-4.sql schema/schema-5.sql @@ -348,5 +350,6 @@ volumeslider-handle_glow.png volumeslider-handle.png volumeslider-inset.png + oauthsuccess.html diff --git a/data/oauthsuccess.html b/data/oauthsuccess.html new file mode 100644 index 000000000..4aa0bda46 --- /dev/null +++ b/data/oauthsuccess.html @@ -0,0 +1,37 @@ + + + tr("Return to Clementine") + + + + + +
+

tr("Success!")

+ +

tr("Please close your browser and return to Clementine.")

+
+ + diff --git a/data/providers/googledrive.png b/data/providers/googledrive.png new file mode 100644 index 000000000..590dde867 Binary files /dev/null and b/data/providers/googledrive.png differ diff --git a/data/schema/schema-38.sql b/data/schema/schema-38.sql new file mode 100644 index 000000000..bfa88048e --- /dev/null +++ b/data/schema/schema-38.sql @@ -0,0 +1,50 @@ +ALTER TABLE %allsongstables ADD COLUMN etag TEXT; + +CREATE TABLE google_drive_songs( + title TEXT, + album TEXT, + artist TEXT, + albumartist TEXT, + composer TEXT, + track INTEGER, + disc INTEGER, + bpm REAL, + year INTEGER, + genre TEXT, + comment TEXT, + compilation INTEGER, + + length INTEGER, + bitrate INTEGER, + samplerate INTEGER, + + directory INTEGER NOT NULL, + filename TEXT NOT NULL, + mtime INTEGER NOT NULL, + ctime INTEGER NOT NULL, + filesize INTEGER NOT NULL, + sampler INTEGER NOT NULL DEFAULT 0, + art_automatic TEXT, + art_manual TEXT, + filetype INTEGER NOT NULL DEFAULT 0, + playcount INTEGER NOT NULL DEFAULT 0, + lastplayed INTEGER, + rating INTEGER, + forced_compilation_on INTEGER NOT NULL DEFAULT 0, + forced_compilation_off INTEGER NOT NULL DEFAULT 0, + effective_compilation NOT NULL DEFAULT 0, + skipcount INTEGER NOT NULL DEFAULT 0, + score INTEGER NOT NULL DEFAULT 0, + beginning INTEGER NOT NULL DEFAULT 0, + cue_path TEXT, + unavailable INTEGER DEFAULT 0, + effective_albumartist TEXT, + etag TEXT +); + +CREATE VIRTUAL TABLE google_drive_songs_fts USING fts3 ( + ftstitle, ftsalbum, ftsartist, ftsalbumartist, ftscomposer, ftsgenre, ftscomment, + tokenize=unicode +); + +UPDATE schema_version SET version=38; diff --git a/ext/clementine-tagreader/CMakeLists.txt b/ext/clementine-tagreader/CMakeLists.txt index 77b07ae4a..796334137 100644 --- a/ext/clementine-tagreader/CMakeLists.txt +++ b/ext/clementine-tagreader/CMakeLists.txt @@ -4,6 +4,7 @@ include_directories(${CMAKE_CURRENT_BINARY_DIR}) include_directories(${CMAKE_SOURCE_DIR}/ext/libclementine-common) include_directories(${CMAKE_BINARY_DIR}/ext/libclementine-tagreader) include_directories(${CMAKE_SOURCE_DIR}/src) +include_directories(${CMAKE_BINARY_DIR}/src) set(EXECUTABLE_OUTPUT_PATH ${CMAKE_BINARY_DIR}) @@ -16,6 +17,12 @@ set(SOURCES set(HEADERS ) +optional_source(HAVE_GOOGLE_DRIVE + INCLUDE_DIRECTORIES ${SPARSEHASH_INCLUDE_DIRS} + SOURCES + googledrivestream.cpp +) + qt4_wrap_cpp(MOC ${HEADERS}) add_executable(clementine-tagreader diff --git a/ext/clementine-tagreader/googledrivestream.cpp b/ext/clementine-tagreader/googledrivestream.cpp new file mode 100644 index 000000000..7489fc116 --- /dev/null +++ b/ext/clementine-tagreader/googledrivestream.cpp @@ -0,0 +1,185 @@ +/* This file is part of Clementine. + Copyright 2012, David Sansome + + 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 . +*/ + +#include "googledrivestream.h" +#include "core/logging.h" + +#include +#include +#include +#include + +#include +#include + +namespace { + static const int kTaglibPrefixCacheBytes = 64 * 1024; // Should be enough. + static const int kTaglibSuffixCacheBytes = 8 * 1024; +} + +GoogleDriveStream::GoogleDriveStream( + const QUrl& url, const QString& filename, const long length, + const QString& auth, QNetworkAccessManager* network) + : url_(url), + filename_(filename), + encoded_filename_(filename_.toUtf8()), + length_(length), + auth_(auth), + cursor_(0), + network_(network), + cache_(length), + num_requests_(0) { +} + +TagLib::FileName GoogleDriveStream::name() const { + return encoded_filename_.data(); +} + +bool GoogleDriveStream::CheckCache(int start, int end) { + for (int i = start; i <= end; ++i) { + if (!cache_.test(i)) { + return false; + } + } + return true; +} + +void GoogleDriveStream::FillCache(int start, TagLib::ByteVector data) { + for (int i = 0; i < data.size(); ++i) { + cache_.set(start + i, data[i]); + } +} + +TagLib::ByteVector GoogleDriveStream::GetCached(int start, int end) { + const uint size = end - start + 1; + TagLib::ByteVector ret(size); + for (int i = 0; i < size; ++i) { + ret[i] = cache_.get(start + i); + } + return ret; +} + +void GoogleDriveStream::Precache() { + // For reading the tags of an MP3, TagLib tends to request: + // 1. The first 1024 bytes + // 2. Somewhere between the first 2KB and first 60KB + // 3. The last KB or two. + // 4. Somewhere in the first 64KB again + // + // OGG Vorbis may read the last 4KB. + // + // So, if we precache the first 64KB and the last 8KB we should be sorted :-) + // Ideally, we would use bytes=0-655364,-8096 but Google Drive does not seem + // to support multipart byte ranges yet so we have to make do with two + // requests. + + seek(0, TagLib::IOStream::Beginning); + readBlock(kTaglibPrefixCacheBytes); + seek(kTaglibSuffixCacheBytes, TagLib::IOStream::End); + readBlock(kTaglibSuffixCacheBytes); + clear(); +} + +TagLib::ByteVector GoogleDriveStream::readBlock(ulong length) { + const uint start = cursor_; + const uint end = qMin(cursor_ + length - 1, length_ - 1); + + if (end < start) { + return TagLib::ByteVector(); + } + + if (CheckCache(start, end)) { + TagLib::ByteVector cached = GetCached(start, end); + cursor_ += cached.size(); + return cached; + } + + QNetworkRequest request = QNetworkRequest(url_); + request.setRawHeader( + "Authorization", QString("Bearer %1").arg(auth_).toUtf8()); + request.setRawHeader( + "Range", QString("bytes=%1-%2").arg(start).arg(end).toUtf8()); + + QNetworkReply* reply = network_->get(request); + ++num_requests_; + + QEventLoop loop; + QObject::connect(reply, SIGNAL(finished()), &loop, SLOT(quit())); + loop.exec(); + reply->deleteLater(); + + QByteArray data = reply->readAll(); + TagLib::ByteVector bytes(data.data(), data.size()); + cursor_ += data.size(); + + FillCache(start, bytes); + return bytes; +} + +void GoogleDriveStream::writeBlock(const TagLib::ByteVector&) { + qLog(Debug) << Q_FUNC_INFO << "not implemented"; +} + +void GoogleDriveStream::insert(const TagLib::ByteVector&, ulong, ulong) { + qLog(Debug) << Q_FUNC_INFO << "not implemented"; +} + +void GoogleDriveStream::removeBlock(ulong, ulong) { + qLog(Debug) << Q_FUNC_INFO << "not implemented"; +} + +bool GoogleDriveStream::readOnly() const { + qLog(Debug) << Q_FUNC_INFO; + return true; +} + +bool GoogleDriveStream::isOpen() const { + return true; +} + +void GoogleDriveStream::seek(long offset, TagLib::IOStream::Position p) { + switch (p) { + case TagLib::IOStream::Beginning: + cursor_ = offset; + break; + + case TagLib::IOStream::Current: + cursor_ = qMin(ulong(cursor_ + offset), length_); + break; + + case TagLib::IOStream::End: + // This should really not have qAbs(), but OGG reading needs it. + cursor_ = qMax(0UL, length_ - qAbs(offset)); + break; + } +} + +void GoogleDriveStream::clear() { + cursor_ = 0; +} + +long GoogleDriveStream::tell() const { + return cursor_; +} + +long GoogleDriveStream::length() { + return length_; +} + +void GoogleDriveStream::truncate(long) { + qLog(Debug) << Q_FUNC_INFO << "not implemented"; +} diff --git a/ext/clementine-tagreader/googledrivestream.h b/ext/clementine-tagreader/googledrivestream.h new file mode 100644 index 000000000..f11a34dc6 --- /dev/null +++ b/ext/clementine-tagreader/googledrivestream.h @@ -0,0 +1,80 @@ +/* This file is part of Clementine. + Copyright 2012, David Sansome + + 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 . +*/ + +#ifndef GOOGLEDRIVESTREAM_H +#define GOOGLEDRIVESTREAM_H + +#include + +#include +#include + +class QNetworkAccessManager; + +class GoogleDriveStream : public TagLib::IOStream { + public: + GoogleDriveStream(const QUrl& url, + const QString& filename, + const long length, + const QString& auth, + QNetworkAccessManager* network); + + // Taglib::IOStream + virtual TagLib::FileName name() const; + virtual TagLib::ByteVector readBlock(ulong length); + virtual void writeBlock(const TagLib::ByteVector&); + virtual void insert(const TagLib::ByteVector&, ulong, ulong); + virtual void removeBlock(ulong, ulong); + virtual bool readOnly() const; + virtual bool isOpen() const; + virtual void seek(long offset, TagLib::IOStream::Position p); + virtual void clear(); + virtual long tell() const; + virtual long length(); + virtual void truncate(long); + + google::sparsetable::size_type cached_bytes() const { + return cache_.num_nonempty(); + } + + int num_requests() const { + return num_requests_; + } + + // Use educated guess to request the bytes that TagLib will probably want. + void Precache(); + + private: + bool CheckCache(int start, int end); + void FillCache(int start, TagLib::ByteVector data); + TagLib::ByteVector GetCached(int start, int end); + + private: + const QUrl url_; + const QString filename_; + const QByteArray encoded_filename_; + const ulong length_; + const QString auth_; + + int cursor_; + QNetworkAccessManager* network_; + + google::sparsetable cache_; + int num_requests_; +}; + +#endif // GOOGLEDRIVESTREAM_H diff --git a/ext/clementine-tagreader/tagreaderworker.cpp b/ext/clementine-tagreader/tagreaderworker.cpp index b87723a4d..ab6b8a614 100644 --- a/ext/clementine-tagreader/tagreaderworker.cpp +++ b/ext/clementine-tagreader/tagreaderworker.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include @@ -56,6 +57,10 @@ # define TAGLIB_HAS_FLAC_PICTURELIST #endif +#ifdef HAVE_GOOGLE_DRIVE +# include "googledrivestream.h" +#endif + using boost::scoped_ptr; @@ -93,6 +98,7 @@ TagLib::String QStringToTaglibString(const QString& s) { TagReaderWorker::TagReaderWorker(QIODevice* socket, QObject* parent) : AbstractMessageHandler(socket, parent), factory_(new TagLibFileRefFactory), + network_(new QNetworkAccessManager), kEmbeddedCover("(embedded)") { } @@ -123,6 +129,21 @@ void TagReaderWorker::MessageArrived(const pb::tagreader::Message& message) { QStringFromStdString(message.load_embedded_art_request().filename())); reply.mutable_load_embedded_art_response()->set_data( data.constData(), data.size()); + } else if (message.has_read_google_drive_request()) { +#ifdef HAVE_GOOGLE_DRIVE + const pb::tagreader::ReadGoogleDriveRequest& req = + message.read_google_drive_request(); + if (!ReadGoogleDrive( + QUrl::fromEncoded(QByteArray(req.download_url().data(), + req.download_url().size())), + QStringFromStdString(req.title()), + req.size(), + QStringFromStdString(req.mime_type()), + QStringFromStdString(req.access_token()), + reply.mutable_read_google_drive_response()->mutable_metadata())) { + reply.mutable_read_google_drive_response()->clear_metadata(); + } +#endif } SendReply(message, &reply); @@ -588,3 +609,74 @@ void TagReaderWorker::DeviceClosed() { qApp->exit(); } + +#ifdef HAVE_GOOGLE_DRIVE +bool TagReaderWorker::ReadGoogleDrive(const QUrl& download_url, + const QString& title, + int size, + const QString& mime_type, + const QString& access_token, + pb::tagreader::SongMetadata* song) const { + qLog(Debug) << "Loading tags from" << title; + + GoogleDriveStream* stream = new GoogleDriveStream( + download_url, title, size, access_token, network_); + stream->Precache(); + scoped_ptr tag; + if (mime_type == "audio/mpeg" && title.endsWith(".mp3")) { + tag.reset(new TagLib::MPEG::File( + stream, // Takes ownership. + TagLib::ID3v2::FrameFactory::instance(), + TagLib::AudioProperties::Accurate)); + } else if (mime_type == "audio/mpeg" && title.endsWith(".m4a")) { + tag.reset(new TagLib::MP4::File( + stream, + true, + TagLib::AudioProperties::Accurate)); + } else if (mime_type == "application/ogg") { + tag.reset(new TagLib::Ogg::Vorbis::File( + stream, + true, + TagLib::AudioProperties::Accurate)); + } else if (mime_type == "application/x-flac") { + tag.reset(new TagLib::FLAC::File( + stream, + TagLib::ID3v2::FrameFactory::instance(), + true, + TagLib::AudioProperties::Accurate)); + } else { + qLog(Debug) << "Unknown mime type for tagging:" << mime_type; + return false; + } + + if (stream->num_requests() > 2) { + // Warn if pre-caching failed. + qLog(Warning) << "Total requests for file:" << title + << stream->num_requests() + << stream->cached_bytes(); + } + + if (tag->tag()) { + song->set_title(tag->tag()->title().toCString(true)); + song->set_artist(tag->tag()->artist().toCString(true)); + song->set_album(tag->tag()->album().toCString(true)); + song->set_filesize(size); + + if (tag->tag()->track() != 0) { + song->set_track(tag->tag()->track()); + } + if (tag->tag()->year() != 0) { + song->set_year(tag->tag()->year()); + } + + song->set_type(pb::tagreader::SongMetadata_Type_STREAM); + + if (tag->audioProperties()) { + song->set_length_nanosec(tag->audioProperties()->length() * kNsecPerSec); + } + return true; + } + + return false; +} +#endif // HAVE_GOOGLE_DRIVE diff --git a/ext/clementine-tagreader/tagreaderworker.h b/ext/clementine-tagreader/tagreaderworker.h index 021600bb0..997a8aaef 100644 --- a/ext/clementine-tagreader/tagreaderworker.h +++ b/ext/clementine-tagreader/tagreaderworker.h @@ -18,11 +18,16 @@ #ifndef TAGREADERWORKER_H #define TAGREADERWORKER_H +#include "config.h" #include "tagreadermessages.pb.h" #include "core/messagehandler.h" #include +#include + +class QNetworkAccessManager; + namespace TagLib { class FileRef; @@ -49,6 +54,15 @@ private: bool IsMediaFile(const QString& filename) const; QByteArray LoadEmbeddedArt(const QString& filename) const; + #ifdef HAVE_GOOGLE_DRIVE + bool ReadGoogleDrive(const QUrl& download_url, + const QString& title, + int size, + const QString& mime_type, + const QString& access_token, + pb::tagreader::SongMetadata* song) const; + #endif // HAVE_GOOGLE_DRIVE + static void Decode(const TagLib::String& tag, const QTextCodec* codec, std::string* output); static void Decode(const QString& tag, const QTextCodec* codec, @@ -69,6 +83,7 @@ private: private: FileRefFactory* factory_; + QNetworkAccessManager* network_; const std::string kEmbeddedCover; }; diff --git a/ext/libclementine-tagreader/tagreadermessages.proto b/ext/libclementine-tagreader/tagreadermessages.proto index 505e0eba2..4cc04c14a 100644 --- a/ext/libclementine-tagreader/tagreadermessages.proto +++ b/ext/libclementine-tagreader/tagreadermessages.proto @@ -85,6 +85,18 @@ message LoadEmbeddedArtResponse { optional bytes data = 1; } +message ReadGoogleDriveRequest { + optional string download_url = 1; + optional string title = 2; + optional int32 size = 3; + optional string access_token = 4; + optional string mime_type = 5; +} + +message ReadGoogleDriveResponse { + optional SongMetadata metadata = 1; +} + message Message { optional int32 id = 1; @@ -99,4 +111,7 @@ message Message { optional LoadEmbeddedArtRequest load_embedded_art_request = 8; optional LoadEmbeddedArtResponse load_embedded_art_response = 9; + + optional ReadGoogleDriveRequest read_google_drive_request = 10; + optional ReadGoogleDriveResponse read_google_drive_response = 11; } diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6be27725a..fc834f034 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -182,6 +182,7 @@ set(SOURCES internet/magnatunesettingspage.cpp internet/magnatuneservice.cpp internet/magnatuneurlhandler.cpp + internet/oauthenticator.cpp internet/savedradio.cpp internet/searchboxwidget.cpp internet/somafmservice.cpp @@ -451,6 +452,7 @@ set(HEADERS internet/magnatunedownloaddialog.h internet/magnatunesettingspage.h internet/magnatuneservice.h + internet/oauthenticator.h internet/savedradio.h internet/searchboxwidget.h internet/somafmservice.h @@ -997,6 +999,24 @@ optional_source(HAVE_MOODBAR moodbar/moodbarproxystyle.h ) +# Google Drive support +optional_source(HAVE_GOOGLE_DRIVE + SOURCES + internet/googledriveclient.cpp + internet/googledrivefoldermodel.cpp + internet/googledriveservice.cpp + internet/googledrivesettingspage.cpp + internet/googledriveurlhandler.cpp + HEADERS + internet/googledriveclient.h + internet/googledrivefoldermodel.h + internet/googledriveservice.h + internet/googledrivesettingspage.h + internet/googledriveurlhandler.h + UI + internet/googledrivesettingspage.ui +) + # Hack to add Clementine to the Unity system tray whitelist optional_source(LINUX SOURCES core/ubuntuunityhack.cpp @@ -1017,6 +1037,7 @@ add_pot(POT ${CMAKE_CURRENT_SOURCE_DIR}/translations/header ${CMAKE_CURRENT_SOURCE_DIR}/translations/translations.pot ${SOURCES} ${MOC} ${UIC} ${OTHER_SOURCES} + ../data/oauthsuccess.html ) add_po(PO clementine_ LANGUAGES ${LANGUAGES} diff --git a/src/config.h.in b/src/config.h.in index 9a6750420..cd0dc9fd4 100644 --- a/src/config.h.in +++ b/src/config.h.in @@ -26,6 +26,7 @@ #cmakedefine HAVE_DBUS #cmakedefine HAVE_DEVICEKIT #cmakedefine HAVE_GIO +#cmakedefine HAVE_GOOGLE_DRIVE #cmakedefine HAVE_IMOBILEDEVICE #cmakedefine HAVE_LIBARCHIVE #cmakedefine HAVE_LIBGPOD diff --git a/src/core/database.cpp b/src/core/database.cpp index 57191f94c..906e779a6 100644 --- a/src/core/database.cpp +++ b/src/core/database.cpp @@ -37,7 +37,7 @@ #include const char* Database::kDatabaseFilename = "clementine.db"; -const int Database::kSchemaVersion = 37; +const int Database::kSchemaVersion = 38; const char* Database::kMagicAllSongsTables = "%allsongstables"; int Database::sNextConnectionId = 1; diff --git a/src/core/song.cpp b/src/core/song.cpp index 1387c2232..23c757042 100644 --- a/src/core/song.cpp +++ b/src/core/song.cpp @@ -79,7 +79,7 @@ const QStringList Song::kColumns = QStringList() << "art_manual" << "filetype" << "playcount" << "lastplayed" << "rating" << "forced_compilation_on" << "forced_compilation_off" << "effective_compilation" << "skipcount" << "score" << "beginning" << "length" - << "cue_path" << "unavailable" << "effective_albumartist"; + << "cue_path" << "unavailable" << "effective_albumartist" << "etag"; const QString Song::kColumnSpec = Song::kColumns.join(", "); const QString Song::kBindSpec = Utilities::Prepend(":", Song::kColumns).join(", "); @@ -167,6 +167,8 @@ struct Song::Private : public QSharedData { // Whether the song does not exist on the file system anymore, but is still // stored in the database so as to remember the user's metadata. bool unavailable_; + + QString etag_; }; @@ -263,6 +265,7 @@ bool Song::is_stream() const { return d->filetype_ == Type_Stream; } bool Song::is_cdda() const { return d->filetype_ == Type_Cdda; } const QString& Song::art_automatic() const { return d->art_automatic_; } const QString& Song::art_manual() const { return d->art_manual_; } +const QString& Song::etag() const { return d->etag_; } bool Song::has_manually_unset_cover() const { return d->art_manual_ == kManuallyUnsetCover; } void Song::manually_unset_cover() { d->art_manual_ = kManuallyUnsetCover; } bool Song::has_embedded_cover() const { return d->art_automatic_ == kEmbeddedCover; } @@ -304,6 +307,7 @@ void Song::set_lastplayed(int v) { d->lastplayed_ = v; } void Song::set_score(int v) { d->score_ = qBound(0, v, 100); } void Song::set_cue_path(const QString& v) { d->cue_path_ = v; } void Song::set_unavailable(bool v) { d->unavailable_ = v; } +void Song::set_etag(const QString& etag) { d->etag_ = etag; } void Song::set_url(const QUrl& v) { d->url_ = v; } void Song::set_basefilename(const QString& v) { d->basefilename_ = v; } void Song::set_directory_id(int v) { d->directory_id_ = v; } @@ -1002,8 +1006,11 @@ void Song::BindToQuery(QSqlQuery *query) const { query->bindValue(":unavailable", d->unavailable_ ? 1 : 0); query->bindValue(":effective_albumartist", this->effective_albumartist()); + query->bindValue(":etag", strval(d->etag_)); + #undef intval #undef notnullintval + #undef strval } void Song::BindToFtsQuery(QSqlQuery *query) const { diff --git a/src/core/song.h b/src/core/song.h index 94f5a34ce..2a254ee54 100644 --- a/src/core/song.h +++ b/src/core/song.h @@ -187,6 +187,8 @@ class Song { const QString& art_automatic() const; const QString& art_manual() const; + const QString& etag() const; + // Returns true if this Song had it's cover manually unset by user. bool has_manually_unset_cover() const; // This method represents an explicit request to unset this song's @@ -250,6 +252,7 @@ class Song { void set_score(int v); void set_cue_path(const QString& v); void set_unavailable(bool v); + void set_etag(const QString& etag); // Setters that should only be used by tests void set_url(const QUrl& v); diff --git a/src/core/tagreaderclient.cpp b/src/core/tagreaderclient.cpp index 7aec6aeb5..145d6dded 100644 --- a/src/core/tagreaderclient.cpp +++ b/src/core/tagreaderclient.cpp @@ -21,6 +21,7 @@ #include #include #include +#include const char* TagReaderClient::kWorkerExecutableName = "clementine-tagreader"; @@ -83,6 +84,25 @@ TagReaderReply* TagReaderClient::LoadEmbeddedArt(const QString& filename) { return worker_pool_->SendMessageWithReply(&message); } +TagReaderReply* TagReaderClient::ReadGoogleDrive(const QUrl& download_url, + const QString& title, + int size, + const QString& mime_type, + const QString& access_token) { + pb::tagreader::Message message; + pb::tagreader::ReadGoogleDriveRequest* req = + message.mutable_read_google_drive_request(); + + const QString url_string = download_url.toEncoded(); + req->set_download_url(DataCommaSizeFromQString(url_string)); + req->set_title(DataCommaSizeFromQString(title)); + req->set_size(size); + req->set_mime_type(DataCommaSizeFromQString(mime_type)); + req->set_access_token(DataCommaSizeFromQString(access_token)); + + return worker_pool_->SendMessageWithReply(&message); +} + void TagReaderClient::ReadFileBlocking(const QString& filename, Song* song) { Q_ASSERT(QThread::currentThread() != thread()); diff --git a/src/core/tagreaderclient.h b/src/core/tagreaderclient.h index 97d0b56cd..864d8ba8f 100644 --- a/src/core/tagreaderclient.h +++ b/src/core/tagreaderclient.h @@ -45,6 +45,11 @@ public: ReplyType* SaveFile(const QString& filename, const Song& metadata); ReplyType* IsMediaFile(const QString& filename); ReplyType* LoadEmbeddedArt(const QString& filename); + ReplyType* ReadGoogleDrive(const QUrl& download_url, + const QString& title, + int size, + const QString& mime_type, + const QString& access_token); // Convenience functions that call the above functions and wait for a // response. These block the calling thread with a semaphore, and must NOT diff --git a/src/core/taskmanager.h b/src/core/taskmanager.h index 3e2f753c1..4a240c1b5 100644 --- a/src/core/taskmanager.h +++ b/src/core/taskmanager.h @@ -36,6 +36,24 @@ public: bool blocks_library_scans; }; + class ScopedTask { + public: + ScopedTask(const int task_id, TaskManager* task_manager) + : task_id_(task_id), + task_manager_(task_manager) { + } + + ~ScopedTask() { + task_manager_->SetTaskFinished(task_id_); + } + + private: + const int task_id_; + TaskManager* task_manager_; + + Q_DISABLE_COPY(ScopedTask); + }; + // Everything here is thread safe QList GetTasks(); @@ -56,6 +74,8 @@ private: QMutex mutex_; QMap tasks_; int next_task_id_; + + Q_DISABLE_COPY(TaskManager); }; #endif // TASKMANAGER_H diff --git a/src/engines/gstenginepipeline.cpp b/src/engines/gstenginepipeline.cpp index 6382eada0..d4679bdac 100644 --- a/src/engines/gstenginepipeline.cpp +++ b/src/engines/gstenginepipeline.cpp @@ -734,6 +734,21 @@ void GstEnginePipeline::SourceSetupCallback(GstURIDecodeBin* bin, GParamSpec *ps g_object_set(element, "extra-headers", headers, NULL); gst_structure_free(headers); } + + if (element && + g_object_class_find_property(G_OBJECT_GET_CLASS(element), "extra-headers") && + instance->url().host().contains("googleusercontent.com") && + instance->url().hasFragment()) { + QByteArray authorization = QString("Bearer %1").arg( + instance->url().fragment()).toAscii(); + GstStructure* headers = gst_structure_new( + "extra-headers", + "Authorization", G_TYPE_STRING, + authorization.constData(), + NULL); + g_object_set(element, "extra-headers", headers, NULL); + gst_structure_free(headers); + } } void GstEnginePipeline::TransitionToNext() { diff --git a/src/internet/googledriveclient.cpp b/src/internet/googledriveclient.cpp new file mode 100644 index 000000000..b907a2b97 --- /dev/null +++ b/src/internet/googledriveclient.cpp @@ -0,0 +1,188 @@ +/* This file is part of Clementine. + Copyright 2012, David Sansome + + 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 . +*/ + +#include "googledriveclient.h" +#include "oauthenticator.h" +#include "core/closure.h" +#include "core/network.h" + +#include + +using namespace google_drive; + +const char* File::kFolderMimeType = "application/vnd.google-apps.folder"; + +namespace { + static const char* kGoogleDriveFiles = "https://www.googleapis.com/drive/v2/files"; + static const char* kGoogleDriveFile = "https://www.googleapis.com/drive/v2/files/%1"; +} + +QStringList File::parent_ids() const { + QStringList ret; + + foreach (const QVariant& var, data_["parents"].toList()) { + QVariantMap map(var.toMap()); + + if (map["isRoot"].toBool()) { + ret << QString(); + } else { + ret << map["id"].toString(); + } + } + + return ret; +} + +ConnectResponse::ConnectResponse(QObject* parent) + : QObject(parent) +{ +} + +ListFilesResponse::ListFilesResponse(const QString& query, QObject* parent) + : QObject(parent), + query_(query) +{ +} + +GetFileResponse::GetFileResponse(const QString& file_id, QObject* parent) + : QObject(parent), + file_id_(file_id) +{ +} + +Client::Client(QObject* parent) + : QObject(parent), + network_(new NetworkAccessManager(this)) +{ +} + +ConnectResponse* Client::Connect(const QString& refresh_token) { + ConnectResponse* ret = new ConnectResponse(this); + OAuthenticator* oauth = new OAuthenticator(this); + + if (refresh_token.isEmpty()) { + oauth->StartAuthorisation(); + } else { + oauth->RefreshAuthorisation(refresh_token); + } + + NewClosure(oauth, SIGNAL(Finished()), + this, SLOT(ConnectFinished(ConnectResponse*,OAuthenticator*)), + ret, oauth); + return ret; +} + +void Client::ConnectFinished(ConnectResponse* response, OAuthenticator* oauth) { + oauth->deleteLater(); + access_token_ = oauth->access_token(); + response->refresh_token_ = oauth->refresh_token(); + emit response->Finished(); + + emit Authenticated(); +} + +void Client::AddAuthorizationHeader(QNetworkRequest* request) const { + request->setRawHeader( + "Authorization", QString("Bearer %1").arg(access_token_).toUtf8()); +} + +ListFilesResponse* Client::ListFiles(const QString& query) { + ListFilesResponse* ret = new ListFilesResponse(query, this); + MakeListFilesRequest(ret); + return ret; +} + +void Client::MakeListFilesRequest(ListFilesResponse* response, const QString& page_token) { + QUrl url = QUrl(kGoogleDriveFiles); + + if (!response->query_.isEmpty()) { + url.addQueryItem("q", response->query_); + } + + if (!page_token.isEmpty()) { + url.addQueryItem("pageToken", page_token); + } + + QNetworkRequest request = QNetworkRequest(url); + AddAuthorizationHeader(&request); + + QNetworkReply* reply = network_->get(request); + NewClosure(reply, SIGNAL(finished()), + this, SLOT(ListFilesFinished(ListFilesResponse*, QNetworkReply*)), + response, reply); +} + +void Client::ListFilesFinished(ListFilesResponse* response, QNetworkReply* reply) { + reply->deleteLater(); + + // Parse the response + QJson::Parser parser; + bool ok = false; + QVariantMap result = parser.parse(reply, &ok).toMap(); + if (!ok) { + qLog(Error) << "Failed to request files from Google Drive"; + emit response->Finished(); + return; + } + + // Emit the FilesFound signal for the files in the response. + FileList files; + foreach (const QVariant& v, result["items"].toList()) { + files << File(v.toMap()); + } + + emit response->FilesFound(files); + + // Get the next page of results if there is one. + if (result.contains("nextPageToken")) { + MakeListFilesRequest(response, result["nextPageToken"].toString()); + } else { + emit response->Finished(); + } +} + +GetFileResponse* Client::GetFile(const QString& file_id) { + GetFileResponse* ret = new GetFileResponse(file_id, this); + + QString url = QString(kGoogleDriveFile).arg(file_id); + + QNetworkRequest request = QNetworkRequest(url); + AddAuthorizationHeader(&request); + + QNetworkReply* reply = network_->get(request); + NewClosure(reply, SIGNAL(finished()), + this, SLOT(GetFileFinished(GetFileResponse*,QNetworkReply*)), + ret, reply); + + return ret; +} + +void Client::GetFileFinished(GetFileResponse* response, QNetworkReply* reply) { + reply->deleteLater(); + + QJson::Parser parser; + bool ok = false; + QVariantMap result = parser.parse(reply, &ok).toMap(); + if (!ok) { + qLog(Error) << "Failed to fetch file with ID" << response->file_id_; + emit response->Finished(); + return; + } + + response->file_ = File(result); + emit response->Finished(); +} diff --git a/src/internet/googledriveclient.h b/src/internet/googledriveclient.h new file mode 100644 index 000000000..6944a373a --- /dev/null +++ b/src/internet/googledriveclient.h @@ -0,0 +1,166 @@ +/* This file is part of Clementine. + Copyright 2012, David Sansome + + 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 . +*/ + +#ifndef GOOGLEDRIVECLIENT_H +#define GOOGLEDRIVECLIENT_H + +#include +#include +#include +#include +#include +#include + +class OAuthenticator; +class QNetworkAccessManager; +class QNetworkReply; +class QNetworkRequest; + + +namespace google_drive { + +class Client; + +// Holds the metadata for a file on Google Drive. +class File { +public: + File(const QVariantMap& data = QVariantMap()) : data_(data) {} + + static const char* kFolderMimeType; + + QString id() const { return data_["id"].toString(); } + QString etag() const { return data_["etag"].toString(); } + QString title() const { return data_["title"].toString(); } + QString mime_type() const { return data_["mimeType"].toString(); } + QString description() const { return data_["description"].toString(); } + long size() const { return data_["fileSize"].toUInt(); } + QUrl download_url() const { return data_["downloadUrl"].toUrl(); } + + QDateTime modified_date() const { + return QDateTime::fromString(data_["modifiedDate"].toString(), Qt::ISODate); + } + + QDateTime created_date() const { + return QDateTime::fromString(data_["createdDate"].toString(), Qt::ISODate); + } + + bool is_folder() const { return mime_type() == kFolderMimeType; } + QStringList parent_ids() const; + + bool has_label(const QString& name) const { + return data_["labels"].toMap()[name].toBool(); + } + + bool is_starred() const { return has_label("starred"); } + bool is_hidden() const { return has_label("hidden"); } + bool is_trashed() const { return has_label("trashed"); } + bool is_restricted() const { return has_label("restricted"); } + bool is_viewed() const { return has_label("viewed"); } + +private: + QVariantMap data_; +}; + +typedef QList FileList; + + +class ConnectResponse : public QObject { + Q_OBJECT + friend class Client; + +public: + const QString& refresh_token() const { return refresh_token_; } + +signals: + void Finished(); + +private: + ConnectResponse(QObject* parent); + QString refresh_token_; +}; + + +class ListFilesResponse : public QObject { + Q_OBJECT + friend class Client; + +public: + const QString& query() const { return query_; } + +signals: + void FilesFound(const QList& files); + void Finished(); + +private: + ListFilesResponse(const QString& query, QObject* parent); + QString query_; +}; + + +class GetFileResponse : public QObject { + Q_OBJECT + friend class Client; + +public: + const QString& file_id() const { return file_id_; } + const File& file() const { return file_; } + +signals: + void Finished(); + +private: + GetFileResponse(const QString& file_id, QObject* parent); + QString file_id_; + File file_; +}; + + +class Client : public QObject { + Q_OBJECT + +public: + Client(QObject* parent = 0); + + bool is_authenticated() const { return !access_token_.isEmpty(); } + const QString& access_token() const { return access_token_; } + + ConnectResponse* Connect(const QString& refresh_token = QString()); + ListFilesResponse* ListFiles(const QString& query); + GetFileResponse* GetFile(const QString& file_id); + +signals: + void Authenticated(); + +private slots: + void ConnectFinished(ConnectResponse* response, OAuthenticator* oauth); + void ListFilesFinished(ListFilesResponse* response, QNetworkReply* reply); + void GetFileFinished(GetFileResponse* response, QNetworkReply* reply); + +private: + void AddAuthorizationHeader(QNetworkRequest* request) const; + void MakeListFilesRequest(ListFilesResponse* response, + const QString& page_token = QString()); + +private: + QNetworkAccessManager* network_; + + QString access_token_; +}; + +} // namespace + +#endif // GOOGLEDRIVECLIENT_H diff --git a/src/internet/googledrivefoldermodel.cpp b/src/internet/googledrivefoldermodel.cpp new file mode 100644 index 000000000..cbf0b51aa --- /dev/null +++ b/src/internet/googledrivefoldermodel.cpp @@ -0,0 +1,112 @@ +/* This file is part of Clementine. + Copyright 2012, David Sansome + + 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 . +*/ + +#include "googledriveclient.h" +#include "googledrivefoldermodel.h" +#include "core/closure.h" +#include "ui/iconloader.h" + +using namespace google_drive; + +FolderModel::FolderModel(Client* client, QObject* parent) + : QStandardItemModel(parent), + client_(client) +{ + folder_icon_ = IconLoader::Load("folder"); + + root_ = new QStandardItem(tr("My Drive")); + item_by_id_[QString()] = root_; + invisibleRootItem()->appendRow(root_); + + connect(client, SIGNAL(Authenticated()), SLOT(Refresh())); + if (client->is_authenticated()) { + Refresh(); + } +} + +void FolderModel::Refresh() { + ListFilesResponse* reply = client_->ListFiles( + QString("mimeType = '%1' and trashed = false and hidden = false") + .arg(File::kFolderMimeType)); + connect(reply, SIGNAL(FilesFound(QList)), + this, SLOT(FilesFound(QList))); + NewClosure(reply, SIGNAL(Finished()), + this, SLOT(FindFilesFinished(ListFilesResponse*)), + reply); +} + +void FolderModel::FindFilesFinished(ListFilesResponse* reply) { + reply->deleteLater(); +} + +void FolderModel::FilesFound(const QList& files) { + foreach (const File& file, files) { + const QString id(file.id()); + + // Does this file exist in the model already? + if (item_by_id_.contains(id)) { + // If it has the same etag ignore it, otherwise remove and recreate it. + QStandardItem* old_item = item_by_id_[id]; + if (old_item->data(Role_Etag).toString() == file.etag()) { + continue; + } else { + item_by_id_.remove(id); + old_item->parent()->removeRow(old_item->row()); + } + } + + // Get the first parent's ID + const QStringList parent_ids = file.parent_ids(); + if (parent_ids.isEmpty()) { + continue; + } + const QString parent_id = parent_ids.first(); + + // If the parent doesn't exist yet, remember this file for later. + if (!item_by_id_.contains(parent_id)) { + orphans_[parent_id] << file; + continue; + } + + // Find the item for the parent + QStandardItem* parent = item_by_id_[parent_id]; + + // Create the item + QStandardItem* item = new QStandardItem(file.title()); + item->setData(file.etag(), Role_Etag); + item->setData(id, Role_Id); + item_by_id_[id] = item; + parent->appendRow(item); + + // Add any children for this item that we saw before. + if (orphans_.contains(id)) { + FilesFound(orphans_.take(id)); + } + } +} + +QVariant FolderModel::data(const QModelIndex& index, int role) const { + if (role == Qt::DecorationRole) { + return folder_icon_; + } + + return QStandardItemModel::data(index, role); +} + +QStandardItem* FolderModel::ItemById(const QString& id) const { + return item_by_id_[id]; +} diff --git a/src/internet/googledrivefoldermodel.h b/src/internet/googledrivefoldermodel.h new file mode 100644 index 000000000..899274d14 --- /dev/null +++ b/src/internet/googledrivefoldermodel.h @@ -0,0 +1,66 @@ +/* This file is part of Clementine. + Copyright 2012, David Sansome + + 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 . +*/ + +#ifndef GOOGLEDRIVEFOLDERMODEL_H +#define GOOGLEDRIVEFOLDERMODEL_H + +#include "googledriveclient.h" + +#include + +namespace google_drive { + +class Client; + +class FolderModel : public QStandardItemModel { + Q_OBJECT + +public: + FolderModel(Client* client, QObject* parent = 0); + + enum Role { + Role_Etag = Qt::UserRole, + Role_Id + }; + + QIcon folder_icon() const { return folder_icon_; } + void set_folder_icon(const QIcon& icon) { folder_icon_ = icon; } + + QVariant data(const QModelIndex& index, int role) const; + + QStandardItem* ItemById(const QString& id) const; + +public slots: + void Refresh(); + +private slots: + void FilesFound(const QList& files); + void FindFilesFinished(ListFilesResponse* reply); + +private: + Client* client_; + QIcon folder_icon_; + + QStandardItem* root_; + + QMap item_by_id_; + QMap > orphans_; +}; + +} // namespace + +#endif // GOOGLEDRIVEFOLDERMODEL_H diff --git a/src/internet/googledriveservice.cpp b/src/internet/googledriveservice.cpp new file mode 100644 index 000000000..4c4086b60 --- /dev/null +++ b/src/internet/googledriveservice.cpp @@ -0,0 +1,209 @@ +#include "googledriveservice.h" + +#include +#include +#include + +#include "core/application.h" +#include "core/closure.h" +#include "core/database.h" +#include "core/mergedproxymodel.h" +#include "core/player.h" +#include "core/taskmanager.h" +#include "core/timeconstants.h" +#include "globalsearch/globalsearch.h" +#include "globalsearch/librarysearchprovider.h" +#include "library/librarybackend.h" +#include "library/librarymodel.h" +#include "googledriveclient.h" +#include "googledriveurlhandler.h" +#include "internetmodel.h" + +const char* GoogleDriveService::kServiceName = "Google Drive"; +const char* GoogleDriveService::kSettingsGroup = "GoogleDrive"; + +namespace { + +static const char* kSongsTable = "google_drive_songs"; +static const char* kFtsTable = "google_drive_songs_fts"; + +} + + +GoogleDriveService::GoogleDriveService(Application* app, InternetModel* parent) + : InternetService(kServiceName, app, parent, parent), + root_(NULL), + client_(new google_drive::Client(this)), + task_manager_(app->task_manager()), + library_sort_model_(new QSortFilterProxyModel(this)) { + library_backend_ = new LibraryBackend; + library_backend_->moveToThread(app_->database()->thread()); + library_backend_->Init(app_->database(), kSongsTable, + QString::null, QString::null, kFtsTable); + library_model_ = new LibraryModel(library_backend_, app_, this); + + library_sort_model_->setSourceModel(library_model_); + library_sort_model_->setSortRole(LibraryModel::Role_SortText); + library_sort_model_->setDynamicSortFilter(true); + library_sort_model_->sort(0); + + app->player()->RegisterUrlHandler(new GoogleDriveUrlHandler(this, this)); + app->global_search()->AddProvider(new LibrarySearchProvider( + library_backend_, + tr("Google Drive"), + "google_drive", + QIcon(":/providers/googledrive.png"), + true, app_, this)); +} + +QStandardItem* GoogleDriveService::CreateRootItem() { + root_ = new QStandardItem(QIcon(":providers/googledrive.png"), "Google Drive"); + root_->setData(true, InternetModel::Role_CanLazyLoad); + return root_; +} + +void GoogleDriveService::LazyPopulate(QStandardItem* item) { + switch (item->data(InternetModel::Role_Type).toInt()) { + case InternetModel::Type_Service: + if (!client_->is_authenticated()) { + Connect(); + } + library_model_->Init(); + model()->merged_model()->AddSubModel(item->index(), library_sort_model_); + break; + + default: + break; + } +} + +void GoogleDriveService::Connect() { + QSettings s; + s.beginGroup(kSettingsGroup); + + google_drive::ConnectResponse* response = + client_->Connect(s.value("refresh_token").toString()); + NewClosure(response, SIGNAL(Finished()), + this, SLOT(ConnectFinished(google_drive::ConnectResponse*)), + response); +} + +void GoogleDriveService::ListFilesForMimeType(const QString& mime_type) { + google_drive::ListFilesResponse* list_response = client_->ListFiles( + QString("mimeType = '%1' and trashed = false").arg(mime_type)); + connect(list_response, SIGNAL(FilesFound(QList)), + this, SLOT(FilesFound(QList))); + NewClosure(list_response, SIGNAL(Finished()), + this, SLOT(ListFilesFinished(google_drive::ListFilesResponse*)), + list_response); +} + +void GoogleDriveService::ConnectFinished(google_drive::ConnectResponse* response) { + response->deleteLater(); + + // Save the refresh token + QSettings s; + s.beginGroup(kSettingsGroup); + s.setValue("refresh_token", response->refresh_token()); + + // Find any music files + ListFilesForMimeType("audio/mpeg"); // MP3/AAC + ListFilesForMimeType("application/ogg"); // OGG + ListFilesForMimeType("application/x-flac"); // FLAC +} + +void GoogleDriveService::EnsureConnected() { + if (client_->is_authenticated()) { + return; + } + + QEventLoop loop; + connect(client_, SIGNAL(Authenticated()), &loop, SLOT(quit())); + Connect(); + loop.exec(); +} + +void GoogleDriveService::FilesFound(const QList& files) { + foreach (const google_drive::File& file, files) { + MaybeAddFileToDatabase(file); + } +} + +void GoogleDriveService::ListFilesFinished(google_drive::ListFilesResponse* response) { + response->deleteLater(); +} + +void GoogleDriveService::MaybeAddFileToDatabase(const google_drive::File& file) { + QString url = QString("googledrive:%1").arg(file.id()); + Song song = library_backend_->GetSongByUrl(QUrl(url)); + // Song already in index. + // TODO: Check etag and maybe update. + if (song.is_valid()) { + return; + } + + const int task_id = task_manager_->StartTask( + tr("Indexing %1").arg(file.title())); + + // Song not in index; tag and add. + TagReaderClient::ReplyType* reply = app_->tag_reader_client()->ReadGoogleDrive( + file.download_url(), + file.title(), + file.size(), + file.mime_type(), + client_->access_token()); + + NewClosure(reply, SIGNAL(Finished(bool)), + this, SLOT(ReadTagsFinished(TagReaderClient::ReplyType*,google_drive::File,QString,int)), + reply, file, url, task_id); +} + +void GoogleDriveService::ReadTagsFinished(TagReaderClient::ReplyType* reply, + const google_drive::File& metadata, + const QString& url, + const int task_id) { + reply->deleteLater(); + + TaskManager::ScopedTask(task_id, task_manager_); + + const pb::tagreader::ReadGoogleDriveResponse& msg = + reply->message().read_google_drive_response(); + if (!msg.has_metadata() || !msg.metadata().filesize()) { + qLog(Debug) << "Failed to tag:" << metadata.title(); + return; + } + + // Read the Song metadata from the message. + Song song; + song.InitFromProtobuf(msg.metadata()); + + // Add some extra tags from the Google Drive metadata. + song.set_etag(metadata.etag().remove('"')); + song.set_mtime(metadata.modified_date().toTime_t()); + song.set_ctime(metadata.created_date().toTime_t()); + song.set_comment(metadata.description()); + song.set_directory_id(0); + song.set_url(url); + + // Use the Google Drive title if we couldn't read tags from the file. + if (song.title().isEmpty()) { + song.set_title(metadata.title()); + } + + // Add the song to the database + qLog(Debug) << "Adding song to db:" << song.title(); + library_backend_->AddOrUpdateSongs(SongList() << song); +} + +QUrl GoogleDriveService::GetStreamingUrlFromSongId(const QString& id) { + EnsureConnected(); + QScopedPointer response(client_->GetFile(id)); + + QEventLoop loop; + connect(response.data(), SIGNAL(Finished()), &loop, SLOT(quit())); + loop.exec(); + + QUrl url(response->file().download_url()); + url.setFragment(client_->access_token()); + return url; +} diff --git a/src/internet/googledriveservice.h b/src/internet/googledriveservice.h new file mode 100644 index 000000000..22fa562b0 --- /dev/null +++ b/src/internet/googledriveservice.h @@ -0,0 +1,68 @@ +#ifndef GOOGLEDRIVESERVICE_H +#define GOOGLEDRIVESERVICE_H + +#include "internetservice.h" + +#include "core/network.h" +#include "core/tagreaderclient.h" + +class QStandardItem; + +class LibraryBackend; +class LibraryModel; +class TaskManager; +class QSortFilterProxyModel; + +namespace google_drive { + class Client; + class ConnectResponse; + class File; + class ListFilesResponse; +} + +class GoogleDriveService : public InternetService { + Q_OBJECT + public: + GoogleDriveService(Application* app, InternetModel* parent); + + static const char* kServiceName; + static const char* kSettingsGroup; + + google_drive::Client* client() const { return client_; } + + QStandardItem* CreateRootItem(); + void LazyPopulate(QStandardItem* item); + + QUrl GetStreamingUrlFromSongId(const QString& file_id); + + private slots: + void ConnectFinished(google_drive::ConnectResponse* response); + void FilesFound(const QList& files); + void ListFilesFinished(google_drive::ListFilesResponse* response); + void ReadTagsFinished(TagReaderClient::ReplyType* reply, + const google_drive::File& metadata, + const QString& url, + const int task_id); + + private: + void Connect(); + void EnsureConnected(); + void RefreshAuthorisation(const QString& refresh_token); + void MaybeAddFileToDatabase(const google_drive::File& file); + void ListFilesForMimeType(const QString& mime_type); + + QStandardItem* root_; + + google_drive::Client* client_; + + NetworkAccessManager network_; + TaskManager* task_manager_; + + LibraryBackend* library_backend_; + LibraryModel* library_model_; + QSortFilterProxyModel* library_sort_model_; + + int indexing_task_id_; +}; + +#endif diff --git a/src/internet/googledrivesettingspage.cpp b/src/internet/googledrivesettingspage.cpp new file mode 100644 index 000000000..cdffd0222 --- /dev/null +++ b/src/internet/googledrivesettingspage.cpp @@ -0,0 +1,90 @@ +/* This file is part of Clementine. + Copyright 2012, David Sansome + + 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 . +*/ + +#include "googledriveclient.h" +#include "googledrivefoldermodel.h" +#include "googledriveservice.h" +#include "googledrivesettingspage.h" +#include "ui_googledrivesettingspage.h" +#include "core/application.h" +#include "internet/internetmodel.h" +#include "ui/settingsdialog.h" + +#include + +GoogleDriveSettingsPage::GoogleDriveSettingsPage(SettingsDialog* parent) + : SettingsPage(parent), + ui_(new Ui::GoogleDriveSettingsPage), + model_(NULL), + proxy_model_(NULL), + item_needs_selecting_(false) +{ + ui_->setupUi(this); +} + +GoogleDriveSettingsPage::~GoogleDriveSettingsPage() { + delete ui_; +} + +void GoogleDriveSettingsPage::Load() { + QSettings s; + s.beginGroup(GoogleDriveService::kSettingsGroup); + + destination_folder_id_ = s.value("destination_folder_id").toString(); + item_needs_selecting_ = !destination_folder_id_.isEmpty(); + + if (!model_) { + GoogleDriveService* service = + dialog()->app()->internet_model()->Service(); + google_drive::Client* client = service->client(); + + model_ = new google_drive::FolderModel(client, this); + proxy_model_ = new QSortFilterProxyModel(this); + proxy_model_->setSourceModel(model_); + proxy_model_->setDynamicSortFilter(true); + proxy_model_->setSortCaseSensitivity(Qt::CaseInsensitive); + proxy_model_->sort(0); + + ui_->upload_destination->setModel(proxy_model_); + + connect(model_, SIGNAL(rowsInserted(QModelIndex,int,int)), + this, SLOT(DirectoryRowsInserted(QModelIndex))); + } +} + +void GoogleDriveSettingsPage::Save() { + QSettings s; + s.beginGroup(GoogleDriveService::kSettingsGroup); + + s.setValue("destination_folder_id", + ui_->upload_destination->currentIndex().data( + google_drive::FolderModel::Role_Id).toString()); +} + +void GoogleDriveSettingsPage::DirectoryRowsInserted(const QModelIndex& parent) { + ui_->upload_destination->expand(proxy_model_->mapFromSource(parent)); + + if (item_needs_selecting_) { + QStandardItem* item = model_->ItemById(destination_folder_id_); + if (item) { + ui_->upload_destination->selectionModel()->select( + proxy_model_->mapFromSource(item->index()), + QItemSelectionModel::ClearAndSelect); + item_needs_selecting_ = false; + } + } +} diff --git a/src/internet/googledrivesettingspage.h b/src/internet/googledrivesettingspage.h new file mode 100644 index 000000000..fb8ddf7b4 --- /dev/null +++ b/src/internet/googledrivesettingspage.h @@ -0,0 +1,57 @@ +/* This file is part of Clementine. + Copyright 2012, David Sansome + + 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 . +*/ + +#ifndef GOOGLEDRIVESETTINGSPAGE_H +#define GOOGLEDRIVESETTINGSPAGE_H + +#include "ui/settingspage.h" + +#include +#include + +class Ui_GoogleDriveSettingsPage; + +class QSortFilterProxyModel; + +namespace google_drive { + class FolderModel; +} + +class GoogleDriveSettingsPage : public SettingsPage { + Q_OBJECT + +public: + GoogleDriveSettingsPage(SettingsDialog* parent = 0); + ~GoogleDriveSettingsPage(); + + void Load(); + void Save(); + +private slots: + void DirectoryRowsInserted(const QModelIndex& parent); + +private: + Ui_GoogleDriveSettingsPage* ui_; + + google_drive::FolderModel* model_; + QSortFilterProxyModel* proxy_model_; + + QString destination_folder_id_; + bool item_needs_selecting_; +}; + +#endif // GOOGLEDRIVESETTINGSPAGE_H diff --git a/src/internet/googledrivesettingspage.ui b/src/internet/googledrivesettingspage.ui new file mode 100644 index 000000000..57c5b5b00 --- /dev/null +++ b/src/internet/googledrivesettingspage.ui @@ -0,0 +1,72 @@ + + + GoogleDriveSettingsPage + + + + 0 + 0 + 569 + 491 + + + + Google Drive + + + + :/providers/googledrive.png:/providers/googledrive.png + + + + + + Uploads + + + + + + You can upload songs to Google Drive by right clicking and using "Copy to device". + + + true + + + + + + + Upload new songs to + + + + + + + + 16777215 + 150 + + + + QAbstractItemView::NoEditTriggers + + + false + + + false + + + + + + + + + + + + + diff --git a/src/internet/googledriveurlhandler.cpp b/src/internet/googledriveurlhandler.cpp new file mode 100644 index 000000000..3c74e72dc --- /dev/null +++ b/src/internet/googledriveurlhandler.cpp @@ -0,0 +1,16 @@ +#include "googledriveurlhandler.h" + +#include "googledriveservice.h" + +GoogleDriveUrlHandler::GoogleDriveUrlHandler( + GoogleDriveService* service, + QObject* parent) + : UrlHandler(parent), + service_(service) { +} + +UrlHandler::LoadResult GoogleDriveUrlHandler::StartLoading(const QUrl& url) { + QString file_id = url.path(); + QUrl real_url = service_->GetStreamingUrlFromSongId(file_id); + return LoadResult(url, LoadResult::TrackAvailable, real_url); +} diff --git a/src/internet/googledriveurlhandler.h b/src/internet/googledriveurlhandler.h new file mode 100644 index 000000000..46b1bebea --- /dev/null +++ b/src/internet/googledriveurlhandler.h @@ -0,0 +1,21 @@ +#ifndef GOOGLEDRIVEURLHANDLER_H +#define GOOGLEDRIVEURLHANDLER_H + +#include "core/urlhandler.h" + +class GoogleDriveService; + +class GoogleDriveUrlHandler : public UrlHandler { + Q_OBJECT + public: + GoogleDriveUrlHandler(GoogleDriveService* service, QObject* parent = 0); + + QString scheme() const { return "googledrive"; } + QIcon icon() const { return QIcon(":providers/googledrive.png"); } + LoadResult StartLoading(const QUrl& url); + + private: + GoogleDriveService* service_; +}; + +#endif diff --git a/src/internet/internetmodel.cpp b/src/internet/internetmodel.cpp index 0453bfe94..428be65ef 100644 --- a/src/internet/internetmodel.cpp +++ b/src/internet/internetmodel.cpp @@ -37,6 +37,9 @@ #ifdef HAVE_SPOTIFY #include "spotifyservice.h" #endif +#ifdef HAVE_GOOGLE_DRIVE + #include "googledriveservice.h" +#endif #include #include @@ -64,6 +67,9 @@ InternetModel::InternetModel(Application* app, QObject* parent) AddService(new JamendoService(app, this)); #ifdef HAVE_LIBLASTFM AddService(new LastFMService(app, this)); +#endif +#ifdef HAVE_GOOGLE_DRIVE + AddService(new GoogleDriveService(app, this)); #endif AddService(new GroovesharkService(app, this)); AddService(new JazzRadioService(app, this)); diff --git a/src/internet/oauthenticator.cpp b/src/internet/oauthenticator.cpp new file mode 100644 index 000000000..b2cf31e75 --- /dev/null +++ b/src/internet/oauthenticator.cpp @@ -0,0 +1,198 @@ +#include "oauthenticator.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/closure.h" + +namespace { + +const char* kGoogleOAuthEndpoint = "https://accounts.google.com/o/oauth2/auth"; +const char* kGoogleOAuthTokenEndpoint = + "https://accounts.google.com/o/oauth2/token"; + +const char* kClientId = "679260893280.apps.googleusercontent.com"; +const char* kClientSecret = "l3cWb8efUZsrBI4wmY3uKl6i"; + +} // namespace + +OAuthenticator::OAuthenticator(QObject* parent) + : QObject(parent) { +} + +void OAuthenticator::StartAuthorisation() { + server_.listen(QHostAddress::LocalHost); + const quint16 port = server_.serverPort(); + + NewClosure(&server_, SIGNAL(newConnection()), this, SLOT(NewConnection())); + + QUrl url = QUrl(kGoogleOAuthEndpoint); + url.addQueryItem("response_type", "code"); + url.addQueryItem("client_id", kClientId); + url.addQueryItem("redirect_uri", QString("http://localhost:%1").arg(port)); + url.addQueryItem("scope", "https://www.googleapis.com/auth/drive.readonly"); + + QDesktopServices::openUrl(url); +} + +void OAuthenticator::NewConnection() { + QTcpSocket* socket = server_.nextPendingConnection(); + server_.close(); + + QByteArray buffer; + + NewClosure(socket, SIGNAL(readyRead()), + this, SLOT(RedirectArrived(QTcpSocket*, QByteArray)), socket, buffer); + + // Everything is bon. Prepare and display the success page. + QFile page_file(":oauthsuccess.html"); + page_file.open(QIODevice::ReadOnly); + QString page_data = QString::fromLatin1(page_file.readAll()); + + // Translate the strings inside + QRegExp tr_regexp("tr\\(\"([^\"]+)\"\\)"); + int offset = 0; + forever { + offset = tr_regexp.indexIn(page_data, offset); + if (offset == -1) { + break; + } + + page_data.replace(offset, tr_regexp.matchedLength(), + tr(tr_regexp.cap(1).toAscii())); + offset += tr_regexp.matchedLength(); + } + + // Add the tick image. + QBuffer image_buffer; + image_buffer.open(QIODevice::ReadWrite); + QApplication::style()->standardIcon(QStyle::SP_DialogOkButton) + .pixmap(16).toImage().save(&image_buffer, "PNG"); + + page_data.replace("@IMAGE_DATA@", image_buffer.data().toBase64()); + + socket->write("HTTP/1.0 200 OK\r\n"); + socket->write("Content-type: text/html;charset=UTF-8\r\n"); + socket->write("\r\n\r\n"); + socket->write(page_data.toUtf8()); + socket->flush(); +} + +void OAuthenticator::RedirectArrived(QTcpSocket* socket, QByteArray buffer) { + buffer.append(socket->readAll()); + + if (socket->atEnd() || buffer.endsWith("\r\n\r\n")) { + socket->deleteLater(); + const QByteArray& code = ParseHttpRequest(buffer); + qLog(Debug) << "Code:" << code; + RequestAccessToken(code, socket->localPort()); + } else { + NewClosure(socket, SIGNAL(readyReady()), + this, SLOT(RedirectArrived(QTcpSocket*, QByteArray)), socket, buffer); + } +} + +QByteArray OAuthenticator::ParseHttpRequest(const QByteArray& request) const { + QList split = request.split('\r'); + const QByteArray& request_line = split[0]; + QByteArray path = request_line.split(' ')[1]; + QByteArray code = path.split('=')[1]; + + return code; +} + +void OAuthenticator::RequestAccessToken(const QByteArray& code, quint16 port) { + typedef QPair Param; + QList parameters; + parameters << Param("code", code) + << Param("client_id", kClientId) + << Param("client_secret", kClientSecret) + << Param("grant_type", "authorization_code") + // Even though we don't use this URI anymore, it must match the + // original one. + << Param("redirect_uri", QString("http://localhost:%1").arg(port)); + + QStringList params; + foreach (const Param& p, parameters) { + params.append(QString("%1=%2").arg(p.first, QString(QUrl::toPercentEncoding(p.second)))); + } + QString post_data = params.join("&"); + qLog(Debug) << post_data; + + QNetworkRequest request = QNetworkRequest(QUrl(kGoogleOAuthTokenEndpoint)); + request.setHeader(QNetworkRequest::ContentTypeHeader, + "application/x-www-form-urlencoded"); + + QNetworkReply* reply = network_.post(request, post_data.toUtf8()); + NewClosure(reply, SIGNAL(finished()), this, + SLOT(FetchAccessTokenFinished(QNetworkReply*)), reply); +} + +void OAuthenticator::FetchAccessTokenFinished(QNetworkReply* reply) { + reply->deleteLater(); + + if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute) != 200) { + qLog(Error) << "Failed to get access token" + << reply->readAll(); + return; + } + + QJson::Parser parser; + bool ok = false; + QVariantMap result = parser.parse(reply, &ok).toMap(); + if (!ok) { + qLog(Error) << "Failed to parse oauth reply"; + return; + } + + qLog(Debug) << result; + + access_token_ = result["access_token"].toString(); + refresh_token_ = result["refresh_token"].toString(); + + emit Finished(); +} + +void OAuthenticator::RefreshAuthorisation(const QString& refresh_token) { + refresh_token_ = refresh_token; + + QUrl url = QUrl(kGoogleOAuthTokenEndpoint); + + typedef QPair Param; + QList parameters; + parameters << Param("client_id", kClientId) + << Param("client_secret", kClientSecret) + << Param("grant_type", "refresh_token") + << Param("refresh_token", refresh_token); + QStringList params; + foreach (const Param& p, parameters) { + params.append(QString("%1=%2").arg(p.first, QString(QUrl::toPercentEncoding(p.second)))); + } + QString post_data = params.join("&"); + qLog(Debug) << "Refresh post data:" << post_data; + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, + "application/x-www-form-urlencoded"); + QNetworkReply* reply = network_.post(request, post_data.toUtf8()); + NewClosure(reply, SIGNAL(finished()), this, + SLOT(RefreshAccessTokenFinished(QNetworkReply*)), reply); +} + +void OAuthenticator::RefreshAccessTokenFinished(QNetworkReply* reply) { + reply->deleteLater(); + QJson::Parser parser; + bool ok = false; + + QVariantMap result = parser.parse(reply, &ok).toMap(); + access_token_ = result["access_token"].toString(); + emit Finished(); +} diff --git a/src/internet/oauthenticator.h b/src/internet/oauthenticator.h new file mode 100644 index 000000000..a073fb186 --- /dev/null +++ b/src/internet/oauthenticator.h @@ -0,0 +1,44 @@ +#ifndef OAUTHENTICATOR_H +#define OAUTHENTICATOR_H + +#include +#include + +#include "core/network.h" + +class QTcpSocket; + +class OAuthenticator : public QObject { + Q_OBJECT + public: + explicit OAuthenticator(QObject* parent = 0); + void StartAuthorisation(); + void RefreshAuthorisation(const QString& refresh_token); + + // Token to use now. + const QString& access_token() const { return access_token_; } + + // Token to use to get a new access token when it expires. + const QString& refresh_token() const { return refresh_token_; } + + signals: + void Finished(); + + private slots: + void NewConnection(); + void RedirectArrived(QTcpSocket* socket, QByteArray buffer); + void FetchAccessTokenFinished(QNetworkReply* reply); + void RefreshAccessTokenFinished(QNetworkReply* reply); + + private: + QByteArray ParseHttpRequest(const QByteArray& request) const; + void RequestAccessToken(const QByteArray& code, quint16 port); + + QTcpServer server_; + NetworkAccessManager network_; + + QString access_token_; + QString refresh_token_; +}; + +#endif diff --git a/src/ui/settingsdialog.cpp b/src/ui/settingsdialog.cpp index fd7671376..868d06452 100644 --- a/src/ui/settingsdialog.cpp +++ b/src/ui/settingsdialog.cpp @@ -59,6 +59,10 @@ # include "internet/spotifysettingspage.h" #endif +#ifdef HAVE_GOOGLE_DRIVE +# include "internet/googledrivesettingspage.h" +#endif + #include #include #include @@ -139,6 +143,10 @@ SettingsDialog::SettingsDialog(Application* app, BackgroundStreams* streams, QWi AddPage(Page_Grooveshark, new GroovesharkSettingsPage(this), providers); +#ifdef HAVE_GOOGLE_DRIVE + AddPage(Page_GoogleDrive, new GoogleDriveSettingsPage(this), providers); +#endif + #ifdef HAVE_SPOTIFY AddPage(Page_Spotify, new SpotifySettingsPage(this), providers); #endif diff --git a/src/ui/settingsdialog.h b/src/ui/settingsdialog.h index b7db62d60..3d73b5691 100644 --- a/src/ui/settingsdialog.h +++ b/src/ui/settingsdialog.h @@ -76,6 +76,7 @@ public: Page_Remote, Page_Wiimotedev, Page_Podcasts, + Page_GoogleDrive, }; enum Role {