/* * Cantata * * Copyright (c) 2011-2021 Craig Drummond * * ---- * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; see the file COPYING. If not, write to * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, * Boston, MA 02110-1301, USA. */ #include "dynamicplaylists.h" #include "config.h" #include "support/utils.h" #include "mpd-interface/mpdconnection.h" #include "widgets/icons.h" #include "models/roles.h" #include "network/networkaccessmanager.h" #include "gui/settings.h" #include "support/actioncollection.h" #include "support/globalstatic.h" #include "support/monoicon.h" #include #include #include #include #include #include #include #include #include #include #include static bool debugEnabled=false; #define DBUG if (debugEnabled) qWarning() << metaObject()->className() << __FUNCTION__ void DynamicPlaylists::enableDebug() { debugEnabled=true; } static const QString constActiveRules=QLatin1String("rules"); static const QString constLockFile=QLatin1String("lock"); static const QString constPingCmd=QLatin1String("ping"); static const QString constListCmd=QLatin1String("list"); static const QString constStatusCmd=QLatin1String("status"); static const QString constSaveCmd=QLatin1String("save"); static const QString constDeleteCmd=QLatin1String("delete"); static const QString constSetActiveCmd=QLatin1String("setActive"); static const QString constControlCmd=QLatin1String("control"); static QString remoteError(const QStringList &status) { if (!status.isEmpty()) { switch (status.at(0).toInt()) { case 1: return QObject::tr("Empty filename."); case 2: return QObject::tr("Invalid filename. (%1)").arg(status.length()<2 ? QString() : status.at(2)); case 3: return QObject::tr("Failed to save %1.").arg(status.length()<2 ? QString() : status.at(2)); case 4: return QObject::tr("Failed to delete rules file. (%1)").arg(status.length()<2 ? QString() : status.at(2)); case 5: return QObject::tr("Invalid command. (%1)").arg(status.length()<2 ? QString() : status.at(2)); case 6: return QObject::tr("Could not remove active rules link."); case 7: return QObject::tr("Active rules is not a link."); case 8: return QObject::tr("Could not create active rules link."); case 9: return QObject::tr("Rules file, %1, does not exist.").arg(status.length()<2 ? QString() : status.at(2)); case 10: return QObject::tr("Incorrect arguments supplied."); case 11: return QObject::tr("Unknown method called."); } } return QObject::tr("Unknown error"); } DynamicPlaylists::Command DynamicPlaylists::toCommand(const QString &cmd) { if (constListCmd==cmd) { return List; } if (constStatusCmd==cmd) { return Status; } if (constSaveCmd==cmd) { return Save; } if (constDeleteCmd==cmd) { return Del; } if (constSetActiveCmd==cmd) { return SetActive; } if (constControlCmd==cmd) { return Control; } return Unknown; } QString DynamicPlaylists::toString(Command cmd) { switch (cmd) { case Unknown: return QString(); case Ping: return constPingCmd; case List: return constListCmd; case Status: return constStatusCmd; case Save: return constSaveCmd; case Del: return constDeleteCmd; case SetActive: return constSetActiveCmd; case Control: return constControlCmd; } return QString(); } GLOBAL_STATIC(DynamicPlaylists, instance) const QString constOk=QLatin1String("0"); const QString constFilename=QLatin1String("FILENAME:"); DynamicPlaylists::DynamicPlaylists() : RulesPlaylists(FontAwesome::random, "dynamic") , localTimer(nullptr) , usingRemote(false) , remoteTimer(nullptr) , remotePollingEnabled(false) , statusTime(0) , currentCommand(Unknown) { connect(this, SIGNAL(clear()), MPDConnection::self(), SLOT(clear())); connect(MPDConnection::self(), SIGNAL(dynamicSupport(bool)), this, SLOT(remoteDynamicSupported(bool))); connect(this, SIGNAL(remoteMessage(QStringList)), MPDConnection::self(), SLOT(sendDynamicMessage(QStringList))); connect(MPDConnection::self(), SIGNAL(dynamicResponse(QStringList)), this, SLOT(remoteResponse(QStringList))); QTimer::singleShot(500, this, SLOT(checkHelper())); startAction = ActionCollection::get()->createAction("startdynamic", tr("Start Dynamic Playlist"), Icons::self()->replacePlayQueueIcon); stopAction = ActionCollection::get()->createAction("stopdynamic", tr("Stop Dynamic Mode"), Icons::self()->stopDynamicIcon); } QString DynamicPlaylists::name() const { return QLatin1String("dynamic"); } QString DynamicPlaylists::title() const { return tr("Dynamic Playlists"); } QString DynamicPlaylists::descr() const { return tr("Dynamically generated playlists"); } #define IS_ACTIVE(E) !currentEntry.isEmpty() && (E)==currentEntry && (!isRemote() || QLatin1String("IDLE")!=lastState) QVariant DynamicPlaylists::data(const QModelIndex &index, int role) const { if (!index.isValid()) { return RulesPlaylists::data(index, role); } if (index.parent().isValid() || index.row()>=entryList.count()) { return QVariant(); } switch (role) { case Qt::DecorationRole: return IS_ACTIVE(entryList.at(index.row()).name) ? Icons::self()->replacePlayQueueIcon : icn; case Cantata::Role_Actions: { QVariant v; v.setValue >(QList() << (IS_ACTIVE(entryList.at(index.row()).name) ? stopAction : startAction)); return v; } default: return RulesPlaylists::data(index, role); } } bool DynamicPlaylists::saveRemote(const QString &string, const Entry &e) { if (sendCommand(Save, QStringList() << e.name << string)) { currentSave=e; return true; } return false; } void DynamicPlaylists::del(const QString &name) { if (isRemote()) { if (sendCommand(Del, QStringList() << name)) { currentDelete=name; } } else { RulesPlaylists::del(name); } } void DynamicPlaylists::start(const QString &name) { if (isRemote()) { sendCommand(SetActive, QStringList() << name << "1"); return; } if (Utils::findExe("perl").isEmpty()) { emit error(tr("You need to install \"perl\" on your system in order for Cantata's dynamic mode to function.")); return; } QString fName(Utils::dataDir(rulesDir, false)+name+constExtension); if (!QFile::exists(fName)) { emit error(tr("Failed to locate rules file - %1").arg(fName)); return; } QString rules(Utils::cacheDir(rulesDir, true)+constActiveRules); QFile::remove(rules); if (QFile::exists(rules)) { emit error(tr("Failed to remove previous rules file - %1").arg(rules)); return; } if (!QFile::link(fName, rules)) { emit error(tr("Failed to install rules file - %1 -> %2").arg(fName).arg(rules)); return; } int i=currentEntry.isEmpty() ? -1 : entryList.indexOf(currentEntry); QModelIndex idx=index(i, 0, QModelIndex()); currentEntry=name; if (idx.isValid()) { emit dataChanged(idx, idx); } i=entryList.indexOf(currentEntry); idx=index(i, 0, QModelIndex()); if (idx.isValid()) { emit dataChanged(idx, idx); } if (isRunning()) { emit clear(); return; } if (controlApp(true)) { emit running(isRunning()); emit clear(); return; } } void DynamicPlaylists::stop(bool sendClear) { if (isRemote()) { if (sendClear) { sendCommand(Control, QStringList() << "stop" << "1"); } else { sendCommand(Control, QStringList() << "stop"); } return; } #if !defined Q_OS_WIN int i=currentEntry.isEmpty() ? -1 : entryList.indexOf(currentEntry); QModelIndex idx=index(i, 0, QModelIndex()); int pid=getPid(); if (!pid) { if (sendClear) { emit clear(); } currentEntry=QString(); emit running(false); if (idx.isValid()) { emit dataChanged(idx, idx); } return; } if (0!=::kill(pid, 0)) { if (sendClear) { emit clear(); } currentEntry=QString(); emit running(false); if (idx.isValid()) { emit dataChanged(idx, idx); } return; } if (controlApp(false)) { if (sendClear) { emit clear(); } currentEntry=QString(); emit running(isRunning()); if (idx.isValid()) { emit dataChanged(idx, idx); } return; } #endif } void DynamicPlaylists::toggle(const QString &name) { if(name==currentEntry) { stop(); } else { start(name); } } bool DynamicPlaylists::isRunning() { #if defined Q_OS_WIN return false; #else int pid=getPid(); return pid ? 0==::kill(pid, 0) : false; #endif } void DynamicPlaylists::enableRemotePolling(bool e) { remotePollingEnabled=e; if (remoteTimer && remoteTimer->isActive()) { if (remotePollingEnabled) { checkIfRemoteIsRunning(); } remoteTimer->start(remotePollingEnabled ? 5000 : 30000); } } int DynamicPlaylists::getPid() const { QFile pidFile(Utils::cacheDir(rulesDir, false)+constLockFile); if (pidFile.open(QIODevice::ReadOnly|QIODevice::Text)) { QTextStream str(&pidFile); int pid=0; str >> pid; return pid; } return 0; } bool DynamicPlaylists::controlApp(bool isStart) { QString cmd=CANTATA_SYS_SCRIPTS_DIR+QLatin1String("cantata-dynamic"); QProcess process; if (isStart) { QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); MPDConnectionDetails details=MPDConnection::self()->getDetails(); env.insert("MPD_HOST", details.password.isEmpty() ? details.hostname : (details.password+'@'+details.hostname)); env.insert("MPD_PORT", QString::number(details.port)); process.setProcessEnvironment(env); } process.start(cmd, QStringList() << QLatin1String(isStart ? "start" : "stop"), QIODevice::WriteOnly); if (!localTimer) { localTimer=new QTimer(this); connect(localTimer, SIGNAL(timeout()), SLOT(checkHelper())); } bool rv=process.waitForFinished(1000); if (isStart && rv) { int exitCode = process.exitCode(); DBUG << exitCode; if (0!=exitCode) { emit error(tr("Could not start dynamic helper. Please check all dependencies are installed.")); } } localTimer->start(1000); return rv; } void DynamicPlaylists::parseRemote(const QStringList &response) { DBUG << response; beginResetModel(); entryList.clear(); currentEntry=QString(); QStringList keys=QStringList() << constArtistKey << constSimilarArtistsKey << constAlbumArtistKey << constDateKey << constExactKey << constAlbumKey << constTitleKey << constGenreKey << constFileKey << constExcludeKey; Entry e; Rule r; for (const QString &part: response) { QStringList lines=part.split('\n', CANTATA_SKIP_EMPTY); for (const QString &s: lines) { QString str=s.trimmed(); if (str.isEmpty() || str.startsWith('#')) { continue; } if (str.startsWith(constFilename)) { if (!e.name.isEmpty()) { if (!r.isEmpty()) { e.rules.append(r); } entryList.append(e); } e.name=str.mid(9, str.length()-15); // Remove extension... e.ratingFrom=e.ratingTo=0; e.rules.clear(); r.clear(); } else if (str==constRuleKey) { if (!r.isEmpty()) { e.rules.append(r); r.clear(); } } else if (str.startsWith(constRatingKey+constKeyValSep)) { QStringList vals=str.mid(constRatingKey.length()+1).split(constRangeSep); if (2==vals.count()) { e.ratingFrom=vals.at(0).toUInt(); e.ratingTo=vals.at(1).toUInt(); } } else if (str.startsWith(constDurationKey+constKeyValSep)) { QStringList vals=str.mid(constDurationKey.length()+1).split(constRangeSep); if (2==vals.count()) { e.minDuration=vals.at(0).toUInt(); e.maxDuration=vals.at(1).toUInt(); } } else { for (const QString &k: keys) { if (str.startsWith(k+constKeyValSep)) { r.insert(k, str.mid(k.length()+1)); } } } } } if (!e.name.isEmpty()) { if (!r.isEmpty()) { e.rules.append(r); } entryList.append(e); } endResetModel(); } void DynamicPlaylists::parseStatus(QStringList response) { DBUG << response; if (response.isEmpty()) { return; } QString state=response.takeAt(0); QString prevEntry=currentEntry; int st=statusTime; if (!response.isEmpty()) { st=response.takeAt(0).toInt(); } if (!response.isEmpty()) { currentEntry=response.takeAt(0); } bool stateChanged=lastState!=state; bool noSongs=QLatin1String("NO_SONGS")==state; bool terminated=false; DBUG << "lastState" << lastState << "state" << state << "statusTime(before)" << statusTime << "statusTime(now)" << st; if (stateChanged) { lastState=state; if (noSongs) { bool sendError=!currentEntry.isEmpty(); emit running(true); if (sendError) { emit error(QLatin1String("NO_SONGS")); } //sendCommand(Control, QStringList() << "stop"); } else if (QLatin1String("HAVE_SONGS")==state || QLatin1String("STARTING")==state) { emit running(true); } else if (QLatin1String("IDLE")==state) { currentEntry.clear(); emit running(false); } else if (QLatin1String("TERMINATED")==state) { terminated=true; currentEntry.clear(); emit running(false); emit error(tr("Dynamizer has been terminated.")); pollRemoteHelper(); currentEntry=QString(); } } if (prevEntry!=currentEntry) { int prev=prevEntry.isEmpty() ? -1 : entryList.indexOf(prevEntry); int cur=currentEntry.isEmpty() ? -1 : entryList.indexOf(currentEntry); if (-1!=prev) { QModelIndex idx=index(prev, 0, QModelIndex()); emit dataChanged(idx, idx); } if (-1!=cur) { QModelIndex idx=index(cur, 0, QModelIndex()); emit dataChanged(idx, idx); } } else if (stateChanged) { int row=currentEntry.isEmpty() ? -1 : entryList.indexOf(currentEntry); if (-1!=row) { QModelIndex idx=index(row, 0, QModelIndex()); emit dataChanged(idx, idx); } } if (st>statusTime && !terminated) { statusTime=st; sendCommand(List, QStringList() << "1"); } } bool DynamicPlaylists::sendCommand(Command cmd, const QStringList &args) { DBUG << toString(cmd) << args; if (Ping==cmd) { emit remoteMessage(QStringList() << toString(cmd)); return true; } if (Status==currentCommand) { if (cmd==Status) { return true; } currentCommand=Unknown; } if (Status!=cmd && (Save==currentCommand || Del==currentCommand)) { emit error(tr("Awaiting response for previous command. (%1)").arg(Save==cmd ? tr("Saving rule") : tr("Deleting rule"))); return false; } currentCommand=cmd; emit remoteMessage(QStringList() << toString(cmd) << args); if (List==cmd) { emit loadingList(); } return true; } void DynamicPlaylists::checkHelper() { if (isRemote()) { return; } if (!isRunning()) { emit running(false); int i=currentEntry.isEmpty() ? -1 : entryList.indexOf(currentEntry); currentEntry=QString(); if (i>-1) { QModelIndex idx=index(i, 0, QModelIndex()); emit dataChanged(idx, idx); } if (localTimer) { localTimer->stop(); } } else { if (localTimer && localTimer->isActive()) { static const int constAppCheck=15*1000; if (localTimer->interval()start(constAppCheck); } } else { // No timer => app startup! // Attempt to read current name... QFileInfo inf(Utils::cacheDir(rulesDir, false)+constActiveRules); if (inf.exists() && inf.isSymLink()) { QString link=inf.symLinkTarget(); if (!link.isEmpty()) { QString fname=QFileInfo(link).fileName(); if (fname.endsWith(constExtension)) { currentEntry=fname.left(fname.length()-constExtension.length()); } } } emit running(true); } } } void DynamicPlaylists::pollRemoteHelper() { if (!remoteTimer) { remoteTimer=new QTimer(this); connect(remoteTimer, SIGNAL(timeout()), SLOT(checkIfRemoteIsRunning())); } beginResetModel(); entryList.clear(); currentEntry=QString(); endResetModel(); remoteTimer->start(remotePollingEnabled ? 5000 : 30000); } void DynamicPlaylists::checkIfRemoteIsRunning() { if (isRemote()) { if (remotePollingEnabled) { sendCommand(Ping); } } else if (remoteTimer) { remoteTimer->stop(); } } void DynamicPlaylists::updateRemoteStatus() { if (isRemote()) { sendCommand(Status); } } void DynamicPlaylists::remoteResponse(QStringList msg) { DBUG << msg; if (msg.isEmpty()) { return; } QString cmd=msg.takeAt(0); Command msgCmd=toCommand(cmd); switch (msgCmd) { case List: parseRemote(msg); emit loadedList(); break; case Status: parseStatus(msg); break; case Save: if (!msg.isEmpty() && msg.at(0)==constOk) { updateEntry(currentSave); emit saved(true); } else { emit error(tr("Failed to save %1. (%2)").arg(currentSave.name).arg(remoteError(msg))); emit saved(false); } currentSave.name=QString(); currentSave.rules.clear(); break; case Del: if (!msg.isEmpty() && msg.at(0)==constOk) { QList::Iterator it=find(currentDelete); if (it!=entryList.end()) { beginRemoveRows(QModelIndex(), it-entryList.begin(), it-entryList.begin()); entryList.erase(it); endRemoveRows(); } else { emit error(tr("Failed to delete rules file. (%1)").arg(remoteError(msg))); } } else { emit error(tr("Failed to delete rules file. (%1)").arg(remoteError(msg))); emit saved(false); } currentDelete.clear(); break; case Control: if (msg.isEmpty() || msg.at(0)!=constOk) { emit error(tr("Failed to control dynamizer state. (%1)").arg(remoteError(msg))); } break; case SetActive: if (!msg.isEmpty() && msg.at(0)==constOk) { QTimer::singleShot(1000, this, SLOT(updateRemoteStatus())); } else { emit error(tr("Failed to set the current dynamic rules. (%1)").arg(remoteError(msg))); } break; default: break; } if (msgCmd==currentCommand) { currentCommand=Unknown; } } void DynamicPlaylists::remoteDynamicSupported(bool s) { if (usingRemote==s) { return; } // Stop any local dynamizer before switching to remote... if (!isRemote()) { stop(); } usingRemote=s; if (s) { if (localTimer) { localTimer->stop(); } pollRemoteHelper(); sendCommand(List); sendCommand(Status); } else { if (remoteTimer) { remoteTimer->stop(); } loadLocal(); // #ifndef Q_OS_WIN // emit error(tr("Communication with the remote dynamizer has been lost, reverting to local mode.")); // #endif } } #include "moc_dynamicplaylists.cpp"