1 /****************************************************************************************
2 * Copyright (c) 2009 Leo Franchi <lfranchi@kde.org> *
3 * Copyright (c) 2010, 2011, 2013 Ralf Engels <ralf-engels@gmx.de> *
4 * *
5 * This program is free software; you can redistribute it and/or modify it under *
6 * the terms of the GNU General Public License as published by the Free Software *
7 * Foundation; either version 2 of the License, or (at your option) any later *
8 * version. *
9 * *
10 * This program is distributed in the hope that it will be useful, but WITHOUT ANY *
11 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A *
12 * PARTICULAR PURPOSE. See the GNU General Public License for more details. *
13 * *
14 * You should have received a copy of the GNU General Public License along with *
15 * this program. If not, see <http://www.gnu.org/licenses/>. *
16 ****************************************************************************************/
17
18 #define DEBUG_PREFIX "EchoNestBias"
19
20 #include "EchoNestBias.h"
21
22 #include "core/meta/Meta.h"
23 #include "core/support/Amarok.h"
24 #include "core/support/Debug.h"
25 #include "core-impl/collections/support/CollectionManager.h"
26
27 #include <KIO/Job>
28 #include <KLocalizedString>
29
30 #include <QDomDocument>
31 #include <QDomNode>
32 #include <QFile>
33 #include <QLabel>
34 #include <QPixmap>
35 #include <QRadioButton>
36 #include <QStandardPaths>
37 #include <QTimer>
38 #include <QUrlQuery>
39 #include <QVBoxLayout>
40 #include <QXmlStreamReader>
41 #include <QXmlStreamWriter>
42
43 QString
i18nName() const44 Dynamic::EchoNestBiasFactory::i18nName() const
45 { return i18nc("Name of the \"EchoNest\" bias", "EchoNest similar artist"); }
46
47 QString
name() const48 Dynamic::EchoNestBiasFactory::name() const
49 { return Dynamic::EchoNestBias::sName(); }
50
51 QString
i18nDescription() const52 Dynamic::EchoNestBiasFactory::i18nDescription() const
53 { return i18nc("Description of the \"EchoNest\" bias",
54 "The \"EchoNest\" bias looks up tracks on echo nest and only adds similar tracks."); }
55
56 Dynamic::BiasPtr
createBias()57 Dynamic::EchoNestBiasFactory::createBias()
58 { return Dynamic::BiasPtr( new Dynamic::EchoNestBias() ); }
59
60
61 // ----- EchoNestBias --------
62
EchoNestBias()63 Dynamic::EchoNestBias::EchoNestBias()
64 : SimpleMatchBias()
65 , m_artistSuggestedQuery( 0 )
66 , m_match( PreviousTrack )
67 , m_mutex( QMutex::Recursive )
68 {
69 loadDataFromFile();
70 }
71
~EchoNestBias()72 Dynamic::EchoNestBias::~EchoNestBias()
73 {
74 // TODO: kill all running queries
75 }
76
77 void
fromXml(QXmlStreamReader * reader)78 Dynamic::EchoNestBias::fromXml( QXmlStreamReader *reader )
79 {
80 while (!reader->atEnd()) {
81 reader->readNext();
82
83 if( reader->isStartElement() )
84 {
85 QStringRef name = reader->name();
86 if( name == "match" )
87 m_match = matchForName( reader->readElementText(QXmlStreamReader::SkipChildElements) );
88 else
89 {
90 debug()<<"Unexpected xml start element"<<reader->name()<<"in input";
91 reader->skipCurrentElement();
92 }
93 }
94 else if( reader->isEndElement() )
95 {
96 break;
97 }
98 }
99 }
100
101 void
toXml(QXmlStreamWriter * writer) const102 Dynamic::EchoNestBias::toXml( QXmlStreamWriter *writer ) const
103 {
104 writer->writeTextElement( QStringLiteral("match"), nameForMatch( m_match ) );
105 }
106
107 QString
sName()108 Dynamic::EchoNestBias::sName()
109 {
110 return QStringLiteral( "echoNestBias" );
111 }
112
113 QString
name() const114 Dynamic::EchoNestBias::name() const
115 {
116 return Dynamic::EchoNestBias::sName();
117 }
118
119 QString
toString() const120 Dynamic::EchoNestBias::toString() const
121 {
122 switch( m_match )
123 {
124 case PreviousTrack:
125 return i18nc("EchoNest bias representation",
126 "Similar to the previous artist (as reported by EchoNest)");
127 case Playlist:
128 return i18nc("EchoNest bias representation",
129 "Similar to any artist in the current playlist (as reported by EchoNest)");
130 }
131 return QString();
132 }
133
134 QWidget*
widget(QWidget * parent)135 Dynamic::EchoNestBias::widget( QWidget* parent )
136 {
137 QWidget *widget = new QWidget( parent );
138 QVBoxLayout *layout = new QVBoxLayout( widget );
139
140 QLabel *imageLabel = new QLabel();
141 imageLabel->setPixmap( QPixmap( QStandardPaths::locate( QStandardPaths::GenericDataLocation, QStringLiteral("amarok/images/echonest.png") ) ) );
142 QLabel *label = new QLabel( i18n( "<a href=\"http://the.echonest.com/\">the echonest</a> thinks the artist is similar to" ) );
143
144 QRadioButton *rb1 = new QRadioButton( i18n( "the previous track's artist" ) );
145 QRadioButton *rb2 = new QRadioButton( i18n( "one of the artist in the current playlist" ) );
146
147 rb1->setChecked( m_match == PreviousTrack );
148 rb2->setChecked( m_match == Playlist );
149
150 connect( rb2, &QRadioButton::toggled,
151 this, &Dynamic::EchoNestBias::setMatchTypePlaylist );
152
153 layout->addWidget( imageLabel );
154 layout->addWidget( label );
155 layout->addWidget( rb1 );
156 layout->addWidget( rb2 );
157
158 return widget;
159 }
160
161 Dynamic::TrackSet
matchingTracks(const Meta::TrackList & playlist,int contextCount,int finalCount,const Dynamic::TrackCollectionPtr & universe) const162 Dynamic::EchoNestBias::matchingTracks( const Meta::TrackList& playlist,
163 int contextCount, int finalCount,
164 const Dynamic::TrackCollectionPtr &universe ) const
165 {
166 Q_UNUSED( contextCount );
167 Q_UNUSED( finalCount );
168
169 // collect the artist
170 QStringList artists = currentArtists( playlist.count() - 1, playlist );
171 if( artists.isEmpty() )
172 return Dynamic::TrackSet( universe, true );
173
174 {
175 QMutexLocker locker( &m_mutex );
176 QString key = tracksMapKey( artists );
177 // debug() << "searching in cache for"<<key
178 // <<"have tracks"<<m_tracksMap.contains( key )
179 // <<"have artists"<<m_similarArtistMap.contains( key );
180 if( m_tracksMap.contains( key ) )
181 return m_tracksMap.value( key );
182 }
183
184 m_tracks = Dynamic::TrackSet( universe, false );
185 m_currentArtists = artists;
186 QTimer::singleShot(0,
187 const_cast<EchoNestBias*>(this),
188 &EchoNestBias::newQuery); // create the new query from my parent thread
189
190 return Dynamic::TrackSet();
191 }
192
193
194 bool
trackMatches(int position,const Meta::TrackList & playlist,int contextCount) const195 Dynamic::EchoNestBias::trackMatches( int position,
196 const Meta::TrackList& playlist,
197 int contextCount ) const
198 {
199 Q_UNUSED( contextCount );
200
201 // collect the artist
202 QStringList artists = currentArtists( position, playlist );
203 if( artists.isEmpty() )
204 return true;
205
206 // the artist of this track
207 if( position < 0 || position >= playlist.count() )
208 return false;
209
210 Meta::TrackPtr track = playlist[position];
211 Meta::ArtistPtr artist = track->artist();
212 if( !artist || artist->name().isEmpty() )
213 return false;
214
215 {
216 QMutexLocker locker( &m_mutex );
217 QString key = tracksMapKey( artists );
218 if( m_similarArtistMap.contains( key ) )
219 return m_similarArtistMap.value( key ).contains( artist->name() );
220 }
221 debug() << "didn't have artist suggestions saved for this artist:" << artist->name();
222 return false;
223 }
224
225
226 void
invalidate()227 Dynamic::EchoNestBias::invalidate()
228 {
229 SimpleMatchBias::invalidate();
230 m_tracksMap.clear();
231 }
232
233 void
newQuery()234 Dynamic::EchoNestBias::newQuery()
235 {
236 // - get the similar artists
237 QStringList similar;
238 {
239 QMutexLocker locker( &m_mutex );
240 QString key = tracksMapKey( m_currentArtists );
241 if( m_similarArtistMap.contains( key ) )
242 {
243 similar = m_similarArtistMap.value( key );
244 debug() << "got similar artists:" << similar.join(QStringLiteral(", "));
245 }
246 else
247 {
248 newSimilarArtistQuery();
249 return; // not yet ready to do construct a query maker
250 }
251 }
252
253 // ok, I need a new query maker
254 m_qm.reset( CollectionManager::instance()->queryMaker() );
255
256 // - construct the query
257 m_qm->beginOr();
258 foreach( const QString &artistName, similar )
259 {
260 m_qm->addFilter( Meta::valArtist, artistName, true, true );
261
262 }
263 m_qm->endAndOr();
264
265 m_qm->setQueryType( Collections::QueryMaker::Custom );
266 m_qm->addReturnValue( Meta::valUniqueId );
267
268 connect( m_qm.data(), &Collections::QueryMaker::newResultReady,
269 this, &EchoNestBias::updateReady );
270 connect( m_qm.data(), &Collections::QueryMaker::queryDone,
271 this, &EchoNestBias::updateFinished );
272
273 // - run the query
274 m_qm->run();
275 }
276
277 void
newSimilarArtistQuery()278 Dynamic::EchoNestBias::newSimilarArtistQuery()
279 {
280 QMultiMap< QString, QString > params;
281
282 // -- start the query
283 params.insert( QStringLiteral("results"), QStringLiteral("30") );
284 params.insert( QStringLiteral("name"), m_currentArtists.join(QStringLiteral(", ")) );
285 m_artistSuggestedQuery = KIO::storedGet( createUrl( QStringLiteral("artist/similar"), params ), KIO::NoReload, KIO::HideProgressInfo );
286 connect( m_artistSuggestedQuery, &KJob::result,
287 this, &EchoNestBias::similarArtistQueryDone );
288 }
289
290 void
similarArtistQueryDone(KJob * job)291 Dynamic::EchoNestBias::similarArtistQueryDone( KJob* job ) // slot
292 {
293 job->deleteLater();
294 if( job != m_artistSuggestedQuery )
295 {
296 debug() << "job was deleted from under us...wtf! blame the gerbils.";
297 m_tracks.reset( false );
298 Q_EMIT resultReady( m_tracks );
299 return;
300 }
301
302 QDomDocument doc;
303 if( !doc.setContent( m_artistSuggestedQuery->data() ) )
304 {
305 debug() << "got invalid XML from EchoNest::get_similar!";
306 m_tracks.reset( false );
307 Q_EMIT resultReady( m_tracks );
308 return;
309 }
310
311 // -- decode the result
312 QDomNodeList artists = doc.elementsByTagName( QStringLiteral("artist") );
313 if( artists.isEmpty() )
314 {
315 debug() << "Got no similar artists! Bailing!";
316 m_tracks.reset( false );
317 Q_EMIT resultReady( m_tracks );
318 return;
319 }
320
321 QStringList similarArtists;
322 for( int i = 0; i < artists.count(); i++ )
323 {
324 similarArtists.append( artists.at(i).firstChildElement( QStringLiteral("name") ).text() );
325 }
326
327 // -- commit the result
328 {
329 QMutexLocker locker( &m_mutex );
330 QString key = tracksMapKey( m_currentArtists );
331 m_similarArtistMap.insert( key, similarArtists );
332 saveDataToFile();
333 }
334
335 newQuery();
336 }
337
338 void
updateFinished()339 Dynamic::EchoNestBias::updateFinished()
340 {
341 // -- store away the result for future reference
342 QString key = tracksMapKey( m_currentArtists );
343 m_tracksMap.insert( key, m_tracks );
344 debug() << "saving found similar tracks to key:" << key;
345
346 SimpleMatchBias::updateFinished();
347 }
348
349 QStringList
currentArtists(int position,const Meta::TrackList & playlist) const350 Dynamic::EchoNestBias::currentArtists( int position, const Meta::TrackList& playlist ) const
351 {
352 QStringList result;
353
354 if( m_match == PreviousTrack )
355 {
356 if( position >= 0 && position < playlist.count() )
357 {
358 Meta::ArtistPtr artist = playlist[ position ]->artist();
359 if( artist && !artist->name().isEmpty() )
360 result.append( artist->name() );
361 }
362 }
363 else if( m_match == Playlist )
364 {
365 for( int i=0; i < position && i < playlist.count(); i++ )
366 {
367 Meta::ArtistPtr artist = playlist[i]->artist();
368 if( artist && !artist->name().isEmpty() )
369 result.append( artist->name() );
370 }
371 }
372
373 return result;
374 }
375
376
377 // this method shamelessly inspired by liblastfm/src/ws/ws.cpp
createUrl(const QString & method,QMultiMap<QString,QString> params)378 QUrl Dynamic::EchoNestBias::createUrl( const QString &method, QMultiMap< QString, QString > params )
379 {
380 params.insert( QStringLiteral("api_key"), QStringLiteral("DD9P0OV9OYFH1LCAE") );
381 params.insert( QStringLiteral("format"), QStringLiteral("xml") );
382
383 QUrl url;
384 QUrlQuery query;
385 url.setScheme( QStringLiteral("http") );
386 url.setHost( QStringLiteral("developer.echonest.com") );
387 url.setPath( "/api/v4/" + method );
388
389 // take care of the ID possibility manually
390 // Qt setQueryItems doesn't encode a bunch of stuff, so we do it manually
391 QMapIterator<QString, QString> i( params );
392 while ( i.hasNext() ) {
393 i.next();
394 QByteArray const key = QUrl::toPercentEncoding( i.key() );
395 QByteArray const value = QUrl::toPercentEncoding( i.value() );
396 query.addQueryItem( key, value );
397 }
398 url.setQuery( query );
399
400 return url;
401 }
402
403 void
saveDataToFile() const404 Dynamic::EchoNestBias::saveDataToFile() const
405 {
406 QFile file( Amarok::saveLocation() + "dynamic_echonest_similar.xml" );
407 if( !file.open( QIODevice::WriteOnly | QIODevice::Truncate ) )
408 return;
409
410 QXmlStreamWriter writer( &file );
411 writer.setAutoFormatting( true );
412
413 writer.writeStartDocument();
414 writer.writeStartElement( QStringLiteral("echonestSimilar") );
415
416 // -- write the similar artists
417 foreach( const QString& key, m_similarArtistMap.keys() )
418 {
419 writer.writeStartElement( QStringLiteral("similarArtist") );
420 writer.writeTextElement( QStringLiteral("artist"), key );
421 foreach( const QString& name, m_similarArtistMap.value( key ) )
422 {
423 writer.writeTextElement( QStringLiteral("similar"), name );
424 }
425 writer.writeEndElement();
426 }
427
428 writer.writeEndElement();
429 writer.writeEndDocument();
430 }
431
432 void
readSimilarArtists(QXmlStreamReader * reader)433 Dynamic::EchoNestBias::readSimilarArtists( QXmlStreamReader *reader )
434 {
435 QString key;
436 QList<QString> artists;
437
438 while (!reader->atEnd()) {
439 reader->readNext();
440 QStringRef name = reader->name();
441
442 if( reader->isStartElement() )
443 {
444 if( name == QLatin1String("artist") )
445 key = reader->readElementText(QXmlStreamReader::SkipChildElements);
446 else if( name == QLatin1String("similar") )
447 artists.append( reader->readElementText(QXmlStreamReader::SkipChildElements) );
448 else
449 reader->skipCurrentElement();
450 }
451 else if( reader->isEndElement() )
452 {
453 break;
454 }
455 }
456
457 m_similarArtistMap.insert( key, artists );
458 }
459
460 void
loadDataFromFile()461 Dynamic::EchoNestBias::loadDataFromFile()
462 {
463 m_similarArtistMap.clear();
464
465 QFile file( Amarok::saveLocation() + "dynamic_echonest_similar.xml" );
466
467 if( !file.exists() ||
468 !file.open( QIODevice::ReadOnly ) )
469 return;
470
471 QXmlStreamReader reader( &file );
472
473 while (!reader.atEnd()) {
474 reader.readNext();
475
476 QStringRef name = reader.name();
477 if( reader.isStartElement() )
478 {
479 if( name == QLatin1String("lastfmSimilar") )
480 {
481 ; // just recurse into the element
482 }
483 else if( name == QLatin1String("similarArtist") )
484 {
485 readSimilarArtists( &reader );
486 }
487 else
488 {
489 reader.skipCurrentElement();
490 }
491 }
492 else if( reader.isEndElement() )
493 {
494 break;
495 }
496 }
497 }
498
499 Dynamic::EchoNestBias::MatchType
match() const500 Dynamic::EchoNestBias::match() const
501 { return m_match; }
502
503 void
setMatch(Dynamic::EchoNestBias::MatchType value)504 Dynamic::EchoNestBias::setMatch( Dynamic::EchoNestBias::MatchType value )
505 {
506 m_match = value;
507 invalidate();
508 Q_EMIT changed( BiasPtr(this) );
509 }
510
511
512 void
setMatchTypePlaylist(bool playlist)513 Dynamic::EchoNestBias::setMatchTypePlaylist( bool playlist )
514 {
515 setMatch( playlist ? Playlist : PreviousTrack );
516 }
517
518
519 QString
nameForMatch(Dynamic::EchoNestBias::MatchType match)520 Dynamic::EchoNestBias::nameForMatch( Dynamic::EchoNestBias::MatchType match )
521 {
522 switch( match )
523 {
524 case Dynamic::EchoNestBias::PreviousTrack: return QStringLiteral("previous");
525 case Dynamic::EchoNestBias::Playlist: return QStringLiteral("playlist");
526 }
527 return QString();
528 }
529
530 Dynamic::EchoNestBias::MatchType
matchForName(const QString & name)531 Dynamic::EchoNestBias::matchForName( const QString &name )
532 {
533 if( name == QLatin1String("previous") ) return PreviousTrack;
534 else if( name == QLatin1String("playlist") ) return Playlist;
535 else return PreviousTrack;
536 }
537
538 QString
tracksMapKey(const QStringList & artists)539 Dynamic::EchoNestBias::tracksMapKey( const QStringList &artists )
540 {
541 return artists.join(QStringLiteral("|"));
542 }
543
544