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