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> ¶ms)
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> ¶ms)
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