1 /*
2  * Cantata
3  *
4  * Copyright (c) 2011-2020 Craig Drummond <craig.p.drummond@gmail.com>
5  *
6  * ----
7  *
8  * This program is free software; you can redistribute it and/or modify
9  * it under the terms of the GNU General Public License as published by
10  * the Free Software Foundation; either version 2 of the License, or
11  * (at your option) any later version.
12  *
13  * This program is distributed in the hope that it will be useful,
14  * but WITHOUT ANY WARRANTY; without even the implied warranty of
15  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
16  * General Public License for more details.
17  *
18  * You should have received a copy of the GNU General Public License
19  * along with this program; see the file COPYING.  If not, write to
20  * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
21  * Boston, MA 02110-1301, USA.
22  */
23 
24 #include "smartplaylistspage.h"
25 #include "smartplaylists.h"
26 #include "playlistrulesdialog.h"
27 #include "widgets/icons.h"
28 #include "support/action.h"
29 #include "support/configuration.h"
30 #include "mpd-interface/mpdconnection.h"
31 #include "support/messagebox.h"
32 #include "gui/stdactions.h"
33 #include "models/mpdlibrarymodel.h"
34 #include <algorithm>
35 
SmartPlaylistsPage(QWidget * p)36 SmartPlaylistsPage::SmartPlaylistsPage(QWidget *p)
37     : SinglePageWidget(p)
38 {
39     addAction = new Action(Icons::self()->addNewItemIcon, tr("Add"), this);
40     editAction = new Action(Icons::self()->editIcon, tr("Edit"), this);
41     removeAction = new Action(Icons::self()->removeIcon, tr("Remove"), this);
42 
43     ToolButton *addBtn=new ToolButton(this);
44     ToolButton *editBtn=new ToolButton(this);
45     ToolButton *removeBtn=new ToolButton(this);
46 
47     addBtn->setDefaultAction(addAction);
48     editBtn->setDefaultAction(editAction);
49     removeBtn->setDefaultAction(removeAction);
50 
51     connect(this, SIGNAL(search(QByteArray,QString)), MPDConnection::self(), SLOT(search(QByteArray,QString)));
52     connect(MPDConnection::self(), SIGNAL(searchResponse(QString,QList<Song>)), this, SLOT(searchResponse(QString,QList<Song>)));
53     connect(this, SIGNAL(getRating(QString)), MPDConnection::self(), SLOT(getRating(QString)));
54     connect(MPDConnection::self(), SIGNAL(rating(QString,quint8)), this, SLOT(rating(QString,quint8)));
55     connect(view, SIGNAL(itemsSelected(bool)), this, SLOT(controlActions()));
56     connect(view, SIGNAL(headerClicked(int)), SLOT(headerClicked(int)));
57     connect(addAction, SIGNAL(triggered()), SLOT(addNew()));
58     connect(editAction, SIGNAL(triggered()), SLOT(edit()));
59     connect(removeAction, SIGNAL(triggered()), SLOT(remove()));
60 
61     proxy.setSourceModel(SmartPlaylists::self());
62     view->setModel(&proxy);
63     view->setDeleteAction(removeAction);
64     view->setMode(ItemView::Mode_List);
65     controlActions();
66     Configuration config(metaObject()->className());
67     view->load(config);
68     controls=QList<QWidget *>() << addBtn << editBtn << removeBtn;
69     init(ReplacePlayQueue|AppendToPlayQueue, QList<QWidget *>(), controls);
70 
71     view->addAction(editAction);
72     view->addAction(removeAction);
73     view->alwaysShowHeader();
74     view->setInfoText(tr("A 'smart' playlist contains a set of rules to select tracks from your music library to play. "
75                          "The playlist also controls the order in which tracks are added. "
76                          "Unlike 'dynamic' playlists, the play queue is not dynamically updated.")
77                       +QLatin1String("\n\n\n")+
78                       tr("Use the + icon (below) to create a new 'smart' playlist."));
79 }
80 
~SmartPlaylistsPage()81 SmartPlaylistsPage::~SmartPlaylistsPage()
82 {
83     Configuration config(metaObject()->className());
84     view->save(config);
85 }
86 
doSearch()87 void SmartPlaylistsPage::doSearch()
88 {
89     QString text=view->searchText().trimmed();
90     proxy.update(text);
91     if (proxy.enabled() && !proxy.filterText().isEmpty()) {
92         view->expandAll();
93     }
94 }
95 
controlActions()96 void SmartPlaylistsPage::controlActions()
97 {
98     QModelIndexList selected=qobject_cast<RulesPlaylists *>(sender()) ? QModelIndexList() : view->selectedIndexes(false); // Dont need sorted selection here...
99     StdActions::self()->enableAddToPlayQueue(1==selected.count());
100     editAction->setEnabled(1==selected.count());
101     removeAction->setEnabled(selected.count());
102 }
103 
addNew()104 void SmartPlaylistsPage::addNew()
105 {
106     PlaylistRulesDialog *dlg=new PlaylistRulesDialog(this, SmartPlaylists::self());
107     dlg->edit(QString());
108 }
109 
edit()110 void SmartPlaylistsPage::edit()
111 {
112     QModelIndexList selected=view->selectedIndexes(false); // Dont need sorted selection here...
113 
114     if (1!=selected.count()) {
115         return;
116     }
117 
118     PlaylistRulesDialog *dlg=new PlaylistRulesDialog(this, SmartPlaylists::self());
119     dlg->edit(selected.at(0).data(Qt::DisplayRole).toString());
120 }
121 
remove()122 void SmartPlaylistsPage::remove()
123 {
124     QModelIndexList selected=view->selectedIndexes();
125 
126     if (selected.isEmpty() ||
127         MessageBox::No==MessageBox::warningYesNo(this, tr("Are you sure you wish to remove the selected rules?\n\nThis cannot be undone."),
128                                                  tr("Remove Smart Rules"), StdGuiItem::remove(), StdGuiItem::cancel())) {
129         return;
130     }
131 
132     QStringList names;
133     for (const QModelIndex &idx: selected) {
134         names.append(idx.data(Qt::DisplayRole).toString());
135     }
136 
137     for (const QString &name: names) {
138         SmartPlaylists::self()->del(name);
139     }
140 }
141 
headerClicked(int level)142 void SmartPlaylistsPage::headerClicked(int level)
143 {
144     if (0==level) {
145         emit close();
146     }
147 }
148 
enableWidgets(bool enable)149 void SmartPlaylistsPage::enableWidgets(bool enable)
150 {
151     for (QWidget *c: controls) {
152         c->setEnabled(enable);
153     }
154 
155     view->setEnabled(enable);
156 }
157 
searchResponse(const QString & id,const QList<Song> & songs)158 void SmartPlaylistsPage::searchResponse(const QString &id, const QList<Song> &songs)
159 {
160     if (id.length()<3 || id.mid(2).toInt()!=command.id || command.isEmpty()) {
161         return;
162     }
163 
164     if (id.startsWith("I:")) {
165         command.songs.unite(songs.toSet());
166     } else if (id.startsWith("E:")) {
167         command.songs.subtract(songs.toSet());
168     }
169 
170     if (command.includeRules.isEmpty()) {
171         if (command.songs.isEmpty()) {
172             command.clear();
173             emit error(tr("Failed to locate any matching songs"));
174             return;
175         }
176         if (command.excludeRules.isEmpty()) {
177             filterCommand();
178         } else {
179             emit search(command.excludeRules.takeFirst(), "E:"+QString::number(command.id));
180         }
181     } else {
182         emit search(command.includeRules.takeFirst(), "I:"+QString::number(command.id));
183     }
184 }
185 
filterCommand()186 void SmartPlaylistsPage::filterCommand()
187 {
188     bool filterDuration = command.minDuration>0 || command.maxDuration>0;
189     bool filterAge = command.maxAge>0;
190 
191     if (filterDuration || filterAge) {
192         uint maxAge=time(nullptr)-(command.maxAge*24*60*60);
193         QSet<Song> toRemove;
194         for (const auto &s: command.songs) {
195             if ((filterAge && s.lastModified<maxAge) ||
196                 (filterDuration && ((command.minDuration>s.time || (command.maxDuration>0 && s.time>command.maxDuration))) ) ) {
197                 toRemove.insert(s);
198             } else {
199                 command.toCheck.append(s.file);
200             }
201         }
202         command.songs.subtract(toRemove);
203         if (command.songs.isEmpty()) {
204             command.clear();
205             emit error(tr("Failed to locate any matching songs"));
206             return;
207         }
208     }
209 
210     if (command.filterRating || command.fetchRatings || command.includeUnrated) {
211         if (command.toCheck.isEmpty()) {
212             for (const auto &s: command.songs) {
213                 command.toCheck.append(s.file);
214             }
215         }
216         command.checking=command.toCheck.takeFirst();
217         emit getRating(command.checking);
218     } else {
219         addSongsToPlayQueue();
220     }
221 }
222 
rating(const QString & file,quint8 val)223 void SmartPlaylistsPage::rating(const QString &file, quint8 val)
224 {
225     if (command.isEmpty() || file!=command.checking) {
226         return;
227     }
228 
229     for (auto &s: command.songs) {
230         if (s.file==file) {
231             s.rating=val;
232             if (command.filterRating && (val<command.ratingFrom || val>command.ratingTo) &&
233                 !(command.includeUnrated && val==0)) {
234                 command.songs.remove(s);
235             }
236             break;
237         }
238     }
239 
240     if (command.toCheck.isEmpty()) {
241         command.checking.clear();
242         addSongsToPlayQueue();
243     } else {
244         command.checking=command.toCheck.takeFirst();
245         emit getRating(command.checking);
246     }
247 }
248 
249 static bool sortAscending = true;
composerSort(const Song & s1,const Song & s2)250 static bool composerSort(const Song &s1, const Song &s2)
251 {
252     const QString v1=s1.hasComposer() ? s1.composer() : QString();
253     const QString v2=s2.hasComposer() ? s2.composer() : QString();
254     int c=v1.localeAwareCompare(v2);
255     return sortAscending ? (c<0 || (c==0 && s1<s2)) : (c>0 || (c==0 && s1<s2));
256 }
257 
artistSort(const Song & s1,const Song & s2)258 static bool artistSort(const Song &s1, const Song &s2)
259 {
260     const QString v1=s1.hasArtistSort() ? s1.artistSort() : s1.artist;
261     const QString v2=s2.hasArtistSort() ? s2.artistSort() : s2.artist;
262     int c=v1.localeAwareCompare(v2);
263     return sortAscending ? (c<0 || (c==0 && s1<s2)) : (c>0 || (c==0 && s1<s2));
264 }
265 
albumArtistSort(const Song & s1,const Song & s2)266 static bool albumArtistSort(const Song &s1, const Song &s2)
267 {
268     const QString v1=s1.hasAlbumArtistSort() ? s1.albumArtistSort() : s1.albumArtistOrComposer();
269     const QString v2=s2.hasAlbumArtistSort() ? s2.albumArtistSort() : s2.albumArtistOrComposer();
270     int c=v1.localeAwareCompare(v2);
271     return sortAscending ? (c<0 || (c==0 && s1<s2)) : (c>0 || (c==0 && s1<s2));
272 }
273 
albumSort(const Song & s1,const Song & s2)274 static bool albumSort(const Song &s1, const Song &s2)
275 {
276     const QString v1=s1.hasAlbumSort() ? s1.albumSort() : s1.album;
277     const QString v2=s2.hasAlbumSort() ? s2.albumSort() : s2.album;
278     int c=v1.localeAwareCompare(v2);
279     return sortAscending ? (c<0 || (c==0 && s1<s2)) : (c>0 || (c==0 && s1<s2));
280 }
281 
titleSort(const Song & s1,const Song & s2)282 static bool titleSort(const Song &s1, const Song &s2)
283 {
284     const QString v1=s1.title;
285     const QString v2=s2.title;
286     int c=v1.localeAwareCompare(v2);
287     return sortAscending ? (c<0 || (c==0 && s1<s2)) : (c>0 || (c==0 && s1<s2));
288 }
289 
genreSort(const Song & s1,const Song & s2)290 static bool genreSort(const Song &s1, const Song &s2)
291 {
292     int c=s1.compareGenres(s2);
293     return sortAscending ? (c<0 || (c==0 && s1<s2)) : (c>0 || (c==0 && s1<s2));
294 }
295 
dateSort(const Song & s1,const Song & s2)296 static bool dateSort(const Song &s1, const Song &s2)
297 {
298     return sortAscending ? (s1.year<s2.year || (s1.year==s2.year && s1<s2)) : (s1.year>s2.year || (s1.year==s2.year && s1<s2));
299 }
300 
ratingSort(const Song & s1,const Song & s2)301 static bool ratingSort(const Song &s1, const Song &s2)
302 {
303     return sortAscending ? (s1.rating<s2.rating || (s1.rating==s2.rating && s1<s2))
304                          : (s1.rating>s2.rating || (s1.rating==s2.rating && s1<s2));
305 }
306 
ageSort(const Song & s1,const Song & s2)307 static bool ageSort(const Song &s1, const Song &s2)
308 {
309     return sortAscending ? (s1.lastModified<s2.lastModified || (s1.lastModified==s2.lastModified && s1<s2))
310                          : (s1.lastModified>s2.lastModified || (s1.lastModified==s2.lastModified && s1<s2));
311 }
312 
addSongsToPlayQueue()313 void SmartPlaylistsPage::addSongsToPlayQueue()
314 {
315     if (command.songs.isEmpty()) {
316         command.clear();
317         emit error(tr("Failed to locate any matching songs"));
318         return;
319     }
320 
321     QList<Song> songs = command.songs.toList();
322     command.songs.clear();
323 
324     sortAscending = command.orderAscending;
325     switch(command.order) {
326     case RulesPlaylists::Order_AlbumArtist:
327         std::sort(songs.begin(), songs.end(), albumArtistSort);
328         break;
329     case RulesPlaylists::Order_Artist:
330         std::sort(songs.begin(), songs.end(), artistSort);
331         break;
332     case RulesPlaylists::Order_Album:
333         std::sort(songs.begin(), songs.end(), albumSort);
334         break;
335     case RulesPlaylists::Order_Composer:
336         std::sort(songs.begin(), songs.end(), composerSort);
337         break;
338     case RulesPlaylists::Order_Date:
339         std::sort(songs.begin(), songs.end(), dateSort);
340         break;
341     case RulesPlaylists::Order_Genre:
342         std::sort(songs.begin(), songs.end(), genreSort);
343         break;
344     case RulesPlaylists::Order_Rating:
345         std::sort(songs.begin(), songs.end(), ratingSort);
346         break;
347     case RulesPlaylists::Order_Title:
348         std::sort(songs.begin(), songs.end(), titleSort);
349         break;
350     case RulesPlaylists::Order_Age:
351         std::sort(songs.begin(), songs.end(), ageSort);
352         break;
353     default:
354     case RulesPlaylists::Order_Random:
355         std::random_shuffle(songs.begin(), songs.end());
356     }
357 
358     QStringList files;
359     for (int i=0; i<command.numTracks && !songs.isEmpty(); ++i) {
360         files.append(songs.takeFirst().file);
361     }
362     if (!files.isEmpty()) {
363         emit add(files, command.action, command.priority, command.decreasePriority);
364         view->clearSelection();
365     }
366     command.clear();
367 }
368 
addSelectionToPlaylist(const QString & name,int action,quint8 priority,bool decreasePriority)369 void SmartPlaylistsPage::addSelectionToPlaylist(const QString &name, int action, quint8 priority, bool decreasePriority)
370 {
371     if (!name.isEmpty()) {
372         return;
373     }
374 
375     QModelIndexList selected=view->selectedIndexes(false);
376     if (1!=selected.count()) {
377         return;
378     }
379 
380     QModelIndex idx = proxy.mapToSource(selected.at(0));
381     if (!idx.isValid()) {
382         return;
383     }
384     RulesPlaylists::Entry pl = SmartPlaylists::self()->entry(idx.row());
385     if (pl.name.isEmpty() || pl.numTracks<=0) {
386         return;
387     }
388 
389     command = Command(pl, action, priority, decreasePriority, command.id+1);
390 
391     QList<RulesPlaylists::Rule>::ConstIterator it = pl.rules.constBegin();
392     QList<RulesPlaylists::Rule>::ConstIterator end = pl.rules.constEnd();
393     QSet<QString> mpdGenres;
394 
395     for (; it!=end; ++it) {
396         QList<int> dates;
397         QByteArray match = "find";
398         bool isInclude = true;
399         RulesPlaylists::Rule::ConstIterator rIt = (*it).constBegin();
400         RulesPlaylists::Rule::ConstIterator rEnd = (*it).constEnd();
401         QByteArray baseRule;
402         QStringList genres;
403 
404         for (; rIt!=rEnd; ++rIt) {
405             if (RulesPlaylists::constDateKey==rIt.key()) {
406                 QStringList parts=rIt.value().trimmed().split(RulesPlaylists::constRangeSep);
407                 if (2==parts.length()) {
408                     int from = parts.at(0).toInt();
409                     int to = parts.at(1).toInt();
410                     if (from > to) {
411                         for (int i=to; i<=from; ++i) {
412                             dates.append(i);
413                         }
414                     } else {
415                         for (int i=from; i<=to; ++i) {
416                             dates.append(i);
417                         }
418                     }
419                 } else if (1==parts.length()) {
420                     dates.append(parts.at(0).toInt());
421                 }
422             } else if (RulesPlaylists::constGenreKey==rIt.key() && rIt.value().trimmed().endsWith("*")) {
423                 QString find=rIt.value().left(rIt.value().length()-1);
424                 if (!find.isEmpty()) {
425                     if (mpdGenres.isEmpty()) {
426                         mpdGenres = MpdLibraryModel::self()->getGenres();
427                     }
428                     for (const QString &g: mpdGenres) {
429                         if (g.startsWith(find, Qt::CaseInsensitive)) {
430                             genres.append(g);
431                         }
432                     }
433                 }
434                 if (genres.isEmpty()) {
435                     // No genres matching pattern - add dummy genre, so that no tracks will be found
436                     genres.append("XXXXXXXXX");
437                 }
438             } else if (RulesPlaylists::constArtistKey==rIt.key() || RulesPlaylists::constAlbumKey==rIt.key() ||
439                        RulesPlaylists::constAlbumArtistKey==rIt.key() || RulesPlaylists::constComposerKey==rIt.key() ||
440                        RulesPlaylists::constCommentKey==rIt.key() || RulesPlaylists::constTitleKey==rIt.key() ||
441                        RulesPlaylists::constSimilarArtistsKey==rIt.key() || RulesPlaylists::constGenreKey==rIt.key() ||
442                        RulesPlaylists::constFileKey==rIt.key()) {
443                 baseRule += " " + rIt.key() + " " + MPDConnection::encodeName(rIt.value());
444             } else if (RulesPlaylists::constExactKey==rIt.key()) {
445                 if ("false" == rIt.value()) {
446                     match = "search";
447                 }
448             } else if (RulesPlaylists::constExcludeKey==rIt.key()) {
449                 if ("true" == rIt.value()) {
450                     isInclude = false;
451                 }
452             }
453         }
454 
455         if (!baseRule.isEmpty() || !genres.isEmpty() || !dates.isEmpty()) {
456             QList<QByteArray> rules;
457             if (genres.isEmpty()) {
458                 if (dates.isEmpty()) {
459                     rules.append(match + baseRule);
460                 } else {
461                     for(int d: dates) {
462                         rules.append(match + baseRule + " Date \"" + QByteArray::number(d) + "\"");
463                     }
464                 }
465             } else {
466                 for (const QString &genre: genres) {
467                     QByteArray rule = match + baseRule + " Genre  " + MPDConnection::encodeName(genre);
468                     if (dates.isEmpty()) {
469                         rules.append(rule);
470                     } else {
471                         for (int d: dates) {
472                             rules.append(rule + " Date \"" + QByteArray::number(d) + "\"");
473                         }
474                     }
475                 }
476             }
477             if (!rules.isEmpty()) {
478                 if (isInclude) {
479                     command.includeRules += rules;
480                 } else {
481                     command.excludeRules += rules;
482                 }
483             }
484         }
485     }
486 
487     command.filterRating = command.haveRating();
488     command.fetchRatings = RulesPlaylists::Order_Rating == command.order;
489     if (command.includeRules.isEmpty()) {
490         if (command.haveRating()) {
491             command.includeRules.append("RATING:"+QByteArray::number(command.ratingFrom)+":"+QByteArray::number(command.ratingTo));
492             command.filterRating = false;
493             command.fetchRatings = false;
494         } else {
495             command.includeRules.append(QByteArray());
496         }
497     }
498     emit search(command.includeRules.takeFirst(), "I:"+QString::number(command.id));
499 }
500 
501 #include "moc_smartplaylistspage.cpp"
502