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