1 /*
2  * Cantata
3  *
4  * Copyright (c) 2011-2020 Craig Drummond <craig.p.drummond@gmail.com>
5  *
6  * This file contains code from QMPDClient:
7  * Copyright (C) 2005-2008 Håvard Tautra Knutsen <havtknut@tihlde.org>
8  * Copyright (C) 2009 Voker57 <voker57@gmail.com>
9  *
10  * ----
11  *
12  * This program is free software; you can redistribute it and/or modify
13  * it under the terms of the GNU General Public License as published by
14  * the Free Software Foundation; either version 2 of the License, or
15  * (at your option) any later version.
16  *
17  * This program is distributed in the hope that it will be useful,
18  * but WITHOUT ANY WARRANTY; without even the implied warranty of
19  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
20  * General Public License for more details.
21  *
22  * You should have received a copy of the GNU General Public License
23  * along with this program; see the file COPYING.  If not, write to
24  * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
25  * Boston, MA 02110-1301, USA.
26  */
27 
28 #include "scrobbler.h"
29 #include "pausabletimer.h"
30 #include "config.h"
31 #include "gui/apikeys.h"
32 #include "network/networkaccessmanager.h"
33 #include "mpd-interface/mpdconnection.h"
34 #include "support/globalstatic.h"
35 #include "support/utils.h"
36 #include "support/configuration.h"
37 #include "qtiocompressor/qtiocompressor.h"
38 #include <QUrl>
39 #include <QStringList>
40 #include <QCryptographicHash>
41 #include <QTimer>
42 #include <QFile>
43 #include <QDir>
44 #include <QXmlStreamReader>
45 #include <QSslSocket>
46 
47 #include <QDebug>
48 static bool debugIsEnabled=false;
49 static bool fakeScrobbling=false;
50 #define DBUG if (debugIsEnabled) qWarning() << metaObject()->className() << __FUNCTION__
51 #define DBUG_CLASS(C) if (debugIsEnabled) qWarning() << C << __FUNCTION__
enableDebug()52 void Scrobbler::enableDebug()
53 {
54     debugIsEnabled=true;
55 }
56 
57 const QLatin1String Scrobbler::constCacheDir("scrobbling");
58 const QLatin1String Scrobbler::constCacheFile("tracks.xml.gz");
59 static const QLatin1String constSettingsGroup("Scrobbling");
60 static const QString constSecretKey=QLatin1String("0753a75ccded9b17b872744d4bb60b35");
61 static const int constMaxBatchSize=50;
62 static const int constNowPlayingInterval=5000;
63 
64 GLOBAL_STATIC(Scrobbler, instance)
65 
66 enum LastFmErrors
67 {
68     NoError = 0, // ??
69 
70     InvalidService = 2,
71     InvalidMethod,
72     AuthenticationFailed,
73     InvalidFormat,
74     InvalidParameters,
75     InvalidResourceSpecified,
76     OperationFailed,
77     InvalidSessionKey,
78     InvalidApiKey,
79     ServiceOffline,
80 
81     TokenNotAuthorised = 14,
82 
83     TryAgainLater = 16,
84 
85     RateLimitExceeded = 29
86 };
87 
errorString(int code,const QString & msg)88 static QString errorString(int code, const QString &msg)
89 {
90     switch (code) {
91     case InvalidService: return QObject::tr("Invalid service");
92     case InvalidMethod: return QObject::tr("Invalid method");
93     case AuthenticationFailed: return QObject::tr("Authentication failed");
94     case InvalidFormat: return QObject::tr("Invalid format");
95     case InvalidParameters: return QObject::tr("Invalid parameters");
96     case InvalidResourceSpecified: return QObject::tr("Invalid resource specified");
97     case OperationFailed: return QObject::tr("Operation failed");
98     case InvalidSessionKey: return QObject::tr("Invalid session key");
99     case InvalidApiKey: return QObject::tr("Invalid API key");
100     case ServiceOffline: return QObject::tr("Service offline");
101     case TryAgainLater: return QObject::tr("Last.fm is currently busy, please try again in a few minutes");
102     case RateLimitExceeded: return QObject::tr("Rate-limit exceeded");
103     default:
104         return msg.isEmpty() ? QObject::tr("Unknown error") : msg.trimmed();
105     }
106 }
107 
md5(const QString & s)108 static QString md5(const QString &s)
109 {
110     return QString::fromLatin1(QCryptographicHash::hash(s.toUtf8(), QCryptographicHash::Md5).toHex());
111 }
112 
sign(QMap<QString,QString> & params)113 static void sign(QMap<QString, QString> &params)
114 {
115     QString s;
116     params[QLatin1String("api_key")] = ApiKeys::self()->get(ApiKeys::LastFm);
117     QStringList keys=params.keys();
118     keys.sort();
119     for (const QString &k: keys) {
120         s += k+params[k];
121     }
122     s += constSecretKey;
123     params[QLatin1String("api_sig")] = md5(s);
124 }
125 
format(const QMap<QString,QString> & params)126 static QByteArray format(const QMap<QString, QString> &params)
127 {
128     QByteArray data;
129     QMapIterator<QString, QString> i(params);
130     while (i.hasNext()) {
131         i.next();
132         data+=i.key().toLatin1()+'='+QUrl::toPercentEncoding(i.value());
133         if (i.hasNext()) {
134             data+='&';
135         }
136     }
137     DBUG_CLASS("Scrobbler") << data;
138     return data;
139 }
140 
cacheName(bool createDir)141 static QString cacheName(bool createDir)
142 {
143     QString dir=Utils::cacheDir(Scrobbler::constCacheDir, createDir);
144     return dir.isEmpty() ? QString() : (dir+Scrobbler::constCacheFile);
145 }
146 
Track(const Song & s)147 Scrobbler::Track::Track(const Song &s)
148 {
149     if (!(s.blank&Song::BlankTitle)) {
150         title=s.title;
151     }
152     if (!(s.blank&Song::BlankArtist)) {
153         artist=s.artist;
154     }
155     if (!(s.blank&Song::BlankAlbum)) {
156         album=s.album;
157     }
158     albumartist=s.albumartist;
159     track=s.track;
160     length=s.time;
161     timestamp=0;
162 }
163 
Scrobbler()164 Scrobbler::Scrobbler()
165     : QObject(nullptr)
166     , scrobblingEnabled(false)
167     , loveIsEnabled(false)
168     , scrobbler("Last.fm")
169     , lastNowPlaying(0)
170     , nowPlayingIsPending(false)
171     , lovePending(false)
172     , lastScrobbleFailed(false)
173     , nowPlayingSent(false)
174     , loveSent(false)
175     , scrobbledCurrent(false)
176     , scrobbleViaMpd(false)
177     , failedCount(0)
178     , lastState(MPDState_Inactive)
179     , authJob(nullptr)
180     , scrobbleJob(nullptr)
181 {
182     hardFailTimer = new QTimer(this);
183     hardFailTimer->setInterval(60*1000);
184     hardFailTimer->setSingleShot(true);
185     scrobbleTimer = new PausableTimer();
186     scrobbleTimer->setInterval(9000000); // fakeScrobblinghuge number to avoid scrobbling just started song start (rare case)
187     scrobbleTimer->setSingleShot(true);
188     nowPlayingTimer = new PausableTimer();
189     nowPlayingTimer->setSingleShot(true);
190     nowPlayingTimer->setInterval(constNowPlayingInterval);
191     retryTimer = new QTimer(this);
192     retryTimer->setSingleShot(true);
193     retryTimer->setInterval(10000);
194     connect(scrobbleTimer, SIGNAL(timeout()), this, SLOT(scrobbleCurrent()));
195     connect(retryTimer, SIGNAL(timeout()), this, SLOT(scrobbleQueued()));
196     connect(nowPlayingTimer, SIGNAL(timeout()), this, SLOT(scrobbleNowPlaying()));
197     connect(hardFailTimer, SIGNAL(timeout()), this, SLOT(authenticate()));
198     loadSettings();
199     connect(this, SIGNAL(clientMessage(QString,QString,QString)), MPDConnection::self(), SLOT(sendClientMessage(QString,QString,QString)));
200     connect(MPDConnection::self(), SIGNAL(clientMessageFailed(QString,QString)), SLOT(clientMessageFailed(QString,QString)));
201     connect(MPDConnection::self(), SIGNAL(statusUpdated(MPDStatusValues)), this, SLOT(mpdStatusUpdated(MPDStatusValues)));
202     connect(MPDConnection::self(), SIGNAL(currentSongUpdated(Song)), this, SLOT(setSong(Song)));
203 }
204 
~Scrobbler()205 Scrobbler::~Scrobbler()
206 {
207     DBUG;
208     stop();
209 }
210 
stop()211 void Scrobbler::stop()
212 {
213     cancelJobs();
214     saveCache();
215 }
216 
setActive()217 void Scrobbler::setActive()
218 {
219     if (isEnabled()) {
220         loadCache();
221     } else {
222         reset();
223     }
224 
225     if (isEnabled() || scrobbleViaMpd) {
226         connect(MPDStatus::self(), SIGNAL(updated()), this, SLOT(mpdStateUpdated()));
227     } else {
228         disconnect(MPDStatus::self(), SIGNAL(updated()), this, SLOT(mpdStateUpdated()));
229     }
230 
231     if (isEnabled() && !inactiveSong.isEmpty()) {
232         Song s;
233         s.title=inactiveSong.title;
234         s.artist=inactiveSong.artist;
235         s.albumartist=inactiveSong.albumartist;
236         s.album=inactiveSong.album;
237         s.track=inactiveSong.track;
238         s.time=inactiveSong.length;
239         setSong(s);
240         inactiveSong.clear();
241     }
242     if (!isAuthenticated()) {
243         authenticate();
244     } else if (!songQueue.isEmpty()) {
245         scrobbleQueued();
246     }
247 }
248 
249 static const QLatin1String constFakeScrobbling("fake");
250 
loadSettings()251 void Scrobbler::loadSettings()
252 {
253     Configuration cfg(constSettingsGroup);
254 
255     userName=cfg.get("userName", userName);
256 //    password=cfg.get("password", password);
257     sessionKey=cfg.get("sessionKey", sessionKey);
258     scrobblingEnabled=cfg.get("enabled", scrobblingEnabled);
259     loveIsEnabled=cfg.get("loveEnabled", loveIsEnabled);
260     scrobbler=cfg.get("scrobbler", scrobbler);
261     scrobbleViaMpd=viaMpd(scrobblerUrl());
262     fakeScrobbling=constFakeScrobbling==scrobbler;
263     if (fakeScrobbling) {
264         sessionKey=constFakeScrobbling;
265     }
266     DBUG << scrobbler << userName << sessionKey.isEmpty() << scrobblingEnabled;
267     emit authenticated(isAuthenticated());
268     emit enabled(isEnabled());
269     emit loveEnabled(loveIsEnabled);
270     setActive();
271 }
272 
setDetails(const QString & s,const QString & u,const QString & p)273 void Scrobbler::setDetails(const QString &s, const QString &u, const QString &p)
274 {
275     if (fakeScrobbling) {
276         return;
277     }
278 
279     if (u!=scrobbler || u!=userName || p!=password) {
280         DBUG << "details changed";
281         Configuration cfg(constSettingsGroup);
282 
283         scrobbler=s;
284         userName=u;
285         password=p;
286         sessionKey=QString();
287         reset();
288         cfg.set("scrobbler", scrobbler);
289         cfg.set("userName", userName);
290         cfg.set("sessionKey", sessionKey);
291         scrobbleViaMpd=viaMpd(scrobblerUrl());
292 //        cfg.set("password", password);
293         setActive();
294         if (!isEnabled()) {
295             emit authenticated(false);
296         }
297         emit scrobblerChanged();
298     } else if (!isAuthenticated() && haveLoginDetails()) {
299         authenticate();
300     } else if (!scrobbleViaMpd) {
301         DBUG << "details NOT changed";
302         emit authenticated(isAuthenticated());
303     }
304 }
305 
love()306 void Scrobbler::love()
307 {
308     if (lovedTrack()) {
309         return;
310     }
311 
312     lovePending=false;
313     if (!loveIsEnabled) {
314         return;
315     }
316 
317     const Track &song=isEnabled() ? currentSong : inactiveSong;
318     if (song.title.isEmpty() || song.artist.isEmpty() || loveSent) {
319         return;
320     }
321 
322     if (scrobbleViaMpd) {
323         emit clientMessage(scrobblerUrl(), QLatin1String("love"), scrobbler);
324         loveSent=true;
325         return;
326     }
327 
328     if (!ensureAuthenticated()) {
329         lovePending=true;
330         return;
331     }
332 
333     QMap<QString, QString> params;
334     params["method"] = "track.love";
335     params["track"] = song.title;
336     params["artist"] = song.artist;
337     params["sk"] = sessionKey;
338     sign(params);
339     DBUG << song.title << song.artist;
340     loveSent=true;
341     if (fakeScrobbling) {
342         DBUG << "MSG" << params;
343     } else {
344         QNetworkReply *job=NetworkAccessManager::self()->postFormData(QUrl(scrobblerUrl()), format(params));
345         connect(job, SIGNAL(finished()), this, SLOT(handleResp()));
346     }
347 }
348 
setEnabled(bool e)349 void Scrobbler::setEnabled(bool e)
350 {
351     if (e!=scrobblingEnabled) {
352         scrobblingEnabled=e;
353         Configuration(constSettingsGroup).set("enabled", scrobblingEnabled);
354         setActive();
355         emit enabled(e);
356     }
357 }
358 
setLoveEnabled(bool e)359 void Scrobbler::setLoveEnabled(bool e)
360 {
361     if (e!=loveIsEnabled) {
362         loveIsEnabled=e;
363         Configuration(constSettingsGroup).set("loveEnabled", loveIsEnabled);
364         setActive();
365         emit loveEnabled(e);
366     }
367 }
368 
calcScrobbleIntervals()369 void Scrobbler::calcScrobbleIntervals()
370 {
371     int elapsed=MPDStatus::self()->timeElapsed()*1000;
372     if (elapsed<0) {
373         elapsed=0;
374     }
375     int nowPlayingTimemout=constNowPlayingInterval;
376     if (elapsed>4000) {
377         nowPlayingTimemout=10;
378     } else {
379         nowPlayingTimemout-=elapsed;
380     }
381     nowPlayingTimer->setInterval(nowPlayingTimemout);
382     int timeout=qMin(currentSong.length/2, (quint32)240)*1000; // Scrobble at 1/2 way point or 4 mins - whichever comes first!
383     DBUG << "timeout" << timeout << elapsed << nowPlayingTimemout;
384     if (timeout>elapsed) {
385         timeout-=elapsed;
386     } else {
387         timeout=100;
388     }
389     scrobbleTimer->setInterval(timeout);
390 }
391 
setSong(const Song & s)392 void Scrobbler::setSong(const Song &s)
393 {
394     DBUG << isEnabled() << s.isStandardStream() << s.time << s.file << s.title << s.artist << s.album << s.albumartist << s.blank;
395     if (!scrobbleViaMpd && !isEnabled()) {
396         if (inactiveSong.artist != s.artist || inactiveSong.title!=s.title || inactiveSong.album!=s.album) {
397             emit songChanged(!s.isStandardStream() && !s.isEmpty());
398         }
399         inactiveSong=Track(s);
400         return;
401     }
402 
403     inactiveSong.clear();
404     if (currentSong.artist != s.artist || currentSong.title!=s.title || currentSong.album!=s.album) {
405         nowPlayingSent=scrobbledCurrent=loveSent=lovePending=nowPlayingIsPending=false;
406         currentSong=Track(s);
407         lastNowPlaying=0;
408         emit songChanged(!s.isStandardStream() && !s.isEmpty());
409         if (scrobbleViaMpd || !isEnabled() || s.isStandardStream() || s.time<30) {
410             return;
411         }
412 
413         calcScrobbleIntervals();
414         if (MPDState_Playing==MPDStatus::self()->state()) {
415             mpdStateUpdated(true);
416         }
417     }
418 }
419 
scrobbleNowPlaying()420 void Scrobbler::scrobbleNowPlaying()
421 {
422     nowPlayingIsPending=false;
423     if (!ensureAuthenticated()) {
424         nowPlayingIsPending=true;
425         return;
426     }
427     if (currentSong.title.isEmpty() || currentSong.artist.isEmpty() || nowPlayingSent || scrobbleViaMpd) {
428         return;
429     }
430     QMap<QString, QString> params;
431     params["method"] = "track.updateNowPlaying";
432     params["track"] = currentSong.title;
433     if (!currentSong.album.isEmpty()) {
434         params["album"] = currentSong.album;
435     }
436     params["artist"] = currentSong.artist;
437     if (!currentSong.albumartist.isEmpty() && currentSong.albumartist!=currentSong.artist) {
438         params["albumArtist"] = currentSong.albumartist;
439     }
440     if (currentSong.track) {
441         params["trackNumber"] = QString::number(currentSong.track);
442     }
443     if (currentSong.length) {
444         params["duration"] = QString::number(currentSong.length);
445     }
446     params["sk"] = sessionKey;
447     sign(params);
448     DBUG << currentSong.title << currentSong.artist << currentSong.albumartist << currentSong.album << currentSong.track << currentSong.length;
449     nowPlayingSent=true;
450     lastNowPlaying=time(nullptr);
451     if (fakeScrobbling) {
452         DBUG << "MSG" << params;
453     } else {
454         QNetworkReply *job=NetworkAccessManager::self()->postFormData(QUrl(scrobblerUrl()), format(params));
455         connect(job, SIGNAL(finished()), this, SLOT(handleResp()));
456     }
457 }
458 
handleResp()459 void Scrobbler::handleResp()
460 {
461     QNetworkReply *job=qobject_cast<QNetworkReply *>(sender());
462     if (!job) {
463         return;
464     }
465     job->deleteLater();
466     DBUG << job->readAll();
467 }
468 
scrobbleCurrent()469 void Scrobbler::scrobbleCurrent()
470 {
471     if (!scrobbledCurrent) {
472         if (songQueue.isEmpty() || songQueue.last()!=currentSong) {
473             songQueue.enqueue(currentSong);
474         }
475         scrobbledCurrent=true;
476     }
477     scrobbleQueued();
478 }
479 
scrobbleQueued()480 void Scrobbler::scrobbleQueued()
481 {
482     if (!scrobblingEnabled || scrobbleViaMpd) {
483         return;
484     }
485     if (!ensureAuthenticated() || scrobbleJob) {
486         if (!retryTimer->isActive()) {
487             retryTimer->start();
488         }
489         return;
490     }
491 
492     if (!songQueue.isEmpty()) {
493         QMap<QString, QString> params;
494         params["method"] = "track.scrobble";
495         int batchSize=qMin(constMaxBatchSize, songQueue.size());
496         DBUG << "queued:" << songQueue.size() << "batchSize:" << batchSize;
497         for (int i=0; i<batchSize; ++i) {
498             Track s=songQueue.takeAt(0);
499             DBUG << s.artist << s.albumartist << s.album << s.title << s.track << s.length << s.timestamp;
500             params[QString("track[%1]").arg(i)] = s.title;
501             if (!s.album.isEmpty()) {
502                 params[QString("album[%1]").arg(i)] = s.album;
503             }
504             params[QString("artist[%1]").arg(i)] = s.artist;
505             if (!s.albumartist.isEmpty() && s.albumartist!=s.artist) {
506                 params[QString("albumArtist[%1]").arg(i)] = s.albumartist;
507             }
508             if (s.track) {
509                 params[QString("trackNumber[%1]").arg(i)] = QString::number(s.track);
510             }
511             if (s.length) {
512                 params[QString("duration[%1]").arg(i)] = QString::number(s.length);
513             }
514             params[QString("timestamp[%1]").arg(i)] = QString::number(s.timestamp);
515             lastScrobbledSongs.append(s);
516         }
517         params["sk"] = sessionKey;
518         sign(params);
519         if (fakeScrobbling) {
520             DBUG << "MSG" << params;
521             lastScrobbledSongs.clear();
522         } else {
523             scrobbleJob=NetworkAccessManager::self()->postFormData(scrobblerUrl(), format(params));
524             connect(scrobbleJob, SIGNAL(finished()), this, SLOT(scrobbleFinished()));
525         }
526     }
527 }
528 
scrobbleFinished()529 void Scrobbler::scrobbleFinished()
530 {
531     QNetworkReply *job=qobject_cast<QNetworkReply *>(sender());
532 
533     if (!job) {
534         return;
535     }
536     job->deleteLater();
537     if (job==scrobbleJob) {
538         QByteArray data=job->readAll();
539         DBUG << job->errorString() << data << songQueue.size() << lastScrobbledSongs.size();
540         scrobbleJob=nullptr;
541 
542         int errorCode=NoError;
543         QXmlStreamReader reader(data);
544         while (!reader.atEnd() && !reader.hasError()) {
545             reader.readNext();
546             if (reader.isStartElement()) {
547                 if (QLatin1String("lfm")==reader.name().toString()) {
548                     QString status=reader.attributes().value("status").toString().toLower();
549                     DBUG << status;
550                     if (QLatin1String("failed")==status) {
551                         while (!reader.atEnd() && !reader.hasError()) {
552                             reader.readNext();
553                             if (reader.isStartElement()) {
554                                 if (QLatin1String("error")==reader.name().toString()) {
555                                     errorCode=reader.attributes().value(QLatin1String("code")).toString().toInt();
556                                     QString errorStr=errorString(errorCode, reader.readElementText());
557                                     emit error(tr("%1 error: %2").arg(scrobbler).arg(errorStr));
558                                     DBUG << errorStr;
559                                     break;
560                                 }
561                             }
562                         }
563                     }
564                     break;
565                 }
566             }
567         }
568 
569         switch (errorCode) {
570         case NoError:
571             failedCount=0;
572             DBUG << "Scrobble succeeded";
573             lastScrobbledSongs.clear();
574             return;
575         case AuthenticationFailed:
576         case InvalidSessionKey:
577         case TokenNotAuthorised:
578             sessionKey.clear();
579             authenticate();
580             failedCount=0;
581             break;
582         default:
583             if (++failedCount > 2 && !hardFailTimer->isActive()) {
584                 sessionKey.clear();
585                 hardFailTimer->setInterval((failedCount > 120 ? 120 : failedCount)*60*1000);
586                 hardFailTimer->start();
587             }
588             break;
589         }
590 
591         DBUG << "Move last scrobbled into queued";
592         songQueue << lastScrobbledSongs;
593         lastScrobbledSongs.clear();
594     }
595 }
596 
ensureAuthenticated()597 bool Scrobbler::ensureAuthenticated()
598 {
599     if (fakeScrobbling || !sessionKey.isEmpty()) {
600         return true;
601     }
602     authenticate();
603     return false;
604 }
605 
authenticate()606 void Scrobbler::authenticate()
607 {
608     if (fakeScrobbling) {
609         return;
610     }
611     if (hardFailTimer->isActive() || authJob || scrobbleViaMpd) {
612         DBUG << "authentication delayed";
613         return;
614     }
615 
616     if (!haveLoginDetails()) {
617         DBUG << "no login details";
618         return;
619     }
620     QUrl url(scrobblerUrl());
621     QMap<QString, QString> params;
622     params["method"] = "auth.getMobileSession";
623     params["username"] = userName;
624 
625     bool supportsSsl = false;
626     #ifndef QT_NO_SSL
627     supportsSsl = QSslSocket::supportsSsl();
628     #endif
629     if (supportsSsl) {
630         params["password"] = password;
631         url.setScheme("https"); // Use HTTPS to authenticate
632     } else {
633         params["authToken"]=md5(userName+md5(password));
634     }
635     sign(params);
636 
637     authJob=NetworkAccessManager::self()->postFormData(url, format(params));
638     connect(authJob, SIGNAL(finished()), this, SLOT(authResp()));
639     DBUG << url.toString();
640 }
641 
authResp()642 void Scrobbler::authResp()
643 {
644     QNetworkReply *job=qobject_cast<QNetworkReply *>(sender());
645 
646     if (!job) {
647         return;
648     }
649     job->deleteLater();
650     if (job!=authJob) {
651         return;
652     }
653     authJob=nullptr;
654     sessionKey.clear();
655 
656     QByteArray data=job->readAll();
657     DBUG << data;
658     QXmlStreamReader reader(data);
659     while (!reader.atEnd() && !reader.hasError()) {
660         reader.readNext();
661         if (reader.isStartElement()) {
662             QString element = reader.name().toString();
663             if (QLatin1String("session")==element) {
664                 while (!reader.atEnd() && !reader.hasError()) {
665                     reader.readNext();
666                     if (reader.isStartElement()) {
667                         element = reader.name().toString();
668                         if (QLatin1String("key")==element) {
669                             sessionKey = reader.readElementText();
670                             break;
671                         }
672                     }
673                 }
674                 break;
675             } else if (QLatin1String("error")==element) {
676                 int code=reader.attributes().value(QLatin1String("code")).toString().toInt();
677                 emit error(tr("%1 error: %2").arg(scrobbler).arg(errorString(code, reader.readElementText())));
678                 break;
679             }
680         }
681     }
682 
683     DBUG << "authenticated:" << !sessionKey.isEmpty();
684     emit authenticated(isAuthenticated());
685     Configuration cfg(constSettingsGroup);
686     cfg.set("sessionKey", sessionKey);
687 
688     if (isAuthenticated()) {
689         if (nowPlayingIsPending) {
690             scrobbleNowPlaying();
691         }
692         if (lovePending) {
693             love();
694         }
695     }
696 }
697 
loadCache()698 void Scrobbler::loadCache()
699 {
700     QString fileName=cacheName(false);
701     if (fileName.isEmpty()) {
702         return;
703     }
704     QFile file(fileName);
705     QtIOCompressor compressor(&file);
706     compressor.setStreamFormat(QtIOCompressor::GzipFormat);
707     if (compressor.open(QIODevice::ReadOnly)) {
708         QXmlStreamReader reader(&compressor);
709         while (!reader.atEnd()) {
710             reader.readNext();
711             if (reader.isStartElement() && QLatin1String("track")==reader.name()) {
712                 Track t;
713                 t.artist = reader.attributes().value(QLatin1String("artist")).toString();
714                 t.album = reader.attributes().value(QLatin1String("album")).toString();
715                 t.albumartist = reader.attributes().value(QLatin1String("albumartist")).toString();
716                 t.title = reader.attributes().value(QLatin1String("title")).toString();
717                 t.track = reader.attributes().value(QLatin1String("track")).toString().toUInt();
718                 t.length = reader.attributes().value(QLatin1String("length")).toString().toUInt();
719                 t.timestamp = reader.attributes().value(QLatin1String("timestamp")).toString().toUInt();
720                 songQueue.append(t);
721             }
722         }
723     }
724     DBUG << fileName << songQueue.size();
725 }
726 
saveCache()727 void Scrobbler::saveCache()
728 {
729     QString fileName=cacheName(true);
730     DBUG << fileName << lastScrobbledSongs.count() << songQueue.count();
731     if (fileName.isEmpty()) {
732         return;
733     }
734 
735     if (lastScrobbledSongs.isEmpty() && songQueue.isEmpty()) {
736         if (QFile::exists(fileName)) {
737             QFile::remove(fileName);
738         }
739         return;
740     }
741 
742     QFile file(fileName);
743     QtIOCompressor compressor(&file);
744     compressor.setStreamFormat(QtIOCompressor::GzipFormat);
745     if (compressor.open(QIODevice::WriteOnly)) {
746         QXmlStreamWriter writer(&compressor);
747         writer.setAutoFormatting(false);
748         writer.writeStartDocument();
749         writer.writeStartElement("tracks");
750         writer.writeAttribute("version", "1");
751 
752         const QQueue<Track> &queue=lastScrobbledSongs.isEmpty() ? songQueue : lastScrobbledSongs;
753         for (const Track &t: queue) {
754             writer.writeEmptyElement("track");
755             writer.writeAttribute(QLatin1String("artist"), t.artist);
756             writer.writeAttribute(QLatin1String("album"), t.album);
757             writer.writeAttribute(QLatin1String("albumartist"), t.albumartist);
758             writer.writeAttribute(QLatin1String("title"), t.title);
759             writer.writeAttribute(QLatin1String("track"), QString::number(t.track));
760             writer.writeAttribute(QLatin1String("length"), QString::number(t.length));
761             writer.writeAttribute(QLatin1String("timestamp"), QString::number(t.timestamp));
762         }
763         writer.writeEndElement();
764         writer.writeEndDocument();
765     }
766 }
767 
mpdStateUpdated(bool songChanged)768 void Scrobbler::mpdStateUpdated(bool songChanged)
769 {
770     if (isEnabled() && !scrobbleViaMpd) {
771         DBUG << songChanged << lastState << MPDStatus::self()->state();
772         bool stateChange=songChanged || lastState!=MPDStatus::self()->state();
773         bool isRepeat=!stateChange && MPDState_Playing==MPDStatus::self()->state() &&
774                       MPDStatus::self()->timeElapsed()>=0 && MPDStatus::self()->timeElapsed()<2;
775         if (!stateChange && !isRepeat) {
776             return;
777         }
778         lastState=MPDStatus::self()->state();
779         switch (lastState) {
780         case MPDState_Paused:
781             scrobbleTimer->pause();
782             nowPlayingTimer->pause();
783             break;
784         case MPDState_Playing: {
785             time_t now=time(nullptr);
786             currentSong.timestamp = now-MPDStatus::self()->timeElapsed();
787             DBUG << "Timestamp:" << currentSong.timestamp << "scrobbledCurrent:" << scrobbledCurrent << "nowPlayingSent:" << nowPlayingSent
788                  << "now:" << now << "lastNowPlaying:" << lastNowPlaying << "isRepeat:" << isRepeat;
789 
790             if (isRepeat) {
791                 calcScrobbleIntervals();
792                 nowPlayingSent=scrobbledCurrent=nowPlayingIsPending=false;
793                 lastNowPlaying=0;
794                 scrobbleTimer->start();
795                 nowPlayingTimer->start();
796                 return;
797             }
798             if (!scrobbledCurrent) {
799                 scrobbleTimer->start();
800             }
801             // Send now playing if it has not already been sent, or if not scrobbled current track and its been
802             // over X seconds since now paying was sent.
803             if (!nowPlayingSent) {
804                 nowPlayingTimer->start();
805             } else if (!scrobbledCurrent && ((now-lastNowPlaying)*1000)>constNowPlayingInterval) {
806                 int remaining=(MPDStatus::self()->timeTotal()-MPDStatus::self()->timeElapsed())*1000;
807                 DBUG << "remaining:" << remaining;
808                 if (remaining>constNowPlayingInterval) {
809                     nowPlayingTimer->setInterval(constNowPlayingInterval);
810                     nowPlayingSent=false;
811                     nowPlayingTimer->start();
812                 }
813             }
814             break;
815         }
816         default:
817             scrobbleTimer->stop();
818             nowPlayingTimer->stop();
819             nowPlayingTimer->setInterval(constNowPlayingInterval);
820         }
821     }
822 }
823 
mpdStatusUpdated(const MPDStatusValues & vals)824 void Scrobbler::mpdStatusUpdated(const MPDStatusValues &vals)
825 {
826     if (!vals.playlistLength) {
827         currentSong.clear();
828         emit songChanged(false);
829     }
830 }
831 
clientMessageFailed(const QString & client,const QString & msg)832 void Scrobbler::clientMessageFailed(const QString &client, const QString &msg)
833 {
834     if (loveSent && client==scrobblerUrl() && msg==QLatin1String("love")) {
835         // 'love' failed, so re-enable...
836         loveSent=lovePending=false;
837         emit songChanged(true);
838     }
839 }
840 
cancelJobs()841 void Scrobbler::cancelJobs()
842 {
843     if (authJob) {
844         disconnect(authJob, SIGNAL(finished()), this, SLOT(authResp()));
845         authJob->close();
846         authJob->abort();
847         authJob->deleteLater();
848         authJob=nullptr;
849     }
850     if (scrobbleJob) {
851         disconnect(scrobbleJob, SIGNAL(finished()), this, SLOT(scrobbleFinished()));
852         authJob->close();
853         authJob->abort();
854         authJob->deleteLater();
855         scrobbleJob=nullptr;
856     }
857 }
858 
reset()859 void Scrobbler::reset()
860 {
861     songQueue.clear();
862     lastScrobbledSongs.clear();
863     saveCache();
864     cancelJobs();
865 }
866 
loadScrobblers()867 void Scrobbler::loadScrobblers()
868 {
869     if (scrobblers.isEmpty()) {
870         QStringList files;
871         QString userDir=Utils::dataDir();
872 
873         if (!userDir.isEmpty()) {
874             files.append(Utils::fixPath(userDir)+QLatin1String("scrobblers.xml"));
875         }
876 
877         files.append(":scrobblers.xml");
878 
879         for (const auto &f: files) {
880             QFile file(f);
881             if (file.open(QIODevice::ReadOnly)) {
882                 QXmlStreamReader doc(&file);
883                 while (!doc.atEnd()) {
884                     doc.readNext();
885                     if (doc.isStartElement() && QLatin1String("scrobbler")==doc.name()) {
886                         QString name=doc.attributes().value("name").toString();
887                         QString url=doc.attributes().value("url").toString();
888                         if (!name.isEmpty() && !url.isEmpty() && !scrobblers.contains(name)) {
889                             scrobblers.insert(name, url);
890                         }
891                     }
892                 }
893             }
894         }
895     }
896 }
897 
scrobblerUrl()898 QString Scrobbler::scrobblerUrl()
899 {
900     loadScrobblers();
901     if (scrobblers.isEmpty()) {
902         return QString();
903     }
904 
905     if (!scrobblers.contains(scrobbler)) {
906         return scrobblers.constBegin().value();
907     }
908     return scrobblers[scrobbler];
909 }
910 
911 #include "moc_scrobbler.cpp"
912