1 /*
2  * Bittorrent Client using Qt and libtorrent.
3  * Copyright (C) 2017  Vladimir Golovnev <glassez@yandex.ru>
4  * Copyright (C) 2010  Christophe Dumez <chris@qbittorrent.org>
5  *
6  * This program is free software; you can redistribute it and/or
7  * modify it under the terms of the GNU General Public License
8  * as published by the Free Software Foundation; either version 2
9  * of the License, or (at your option) any later version.
10  *
11  * This program is distributed in the hope that it will be useful,
12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14  * GNU General Public License for more details.
15  *
16  * You should have received a copy of the GNU General Public License
17  * along with this program; if not, write to the Free Software
18  * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19  *
20  * In addition, as a special exception, the copyright holders give permission to
21  * link this program with the OpenSSL project's "OpenSSL" library (or with
22  * modified versions of it that use the same license as the "OpenSSL" library),
23  * and distribute the linked executables. You must obey the GNU General Public
24  * License in all respects for all of the code used other than "OpenSSL".  If you
25  * modify file(s), you may extend this exception to your version of the file(s),
26  * but you are not obligated to do so. If you do not wish to do so, delete this
27  * exception statement from your version.
28  */
29 
30 #include "rss_autodownloadrule.h"
31 
32 #include <algorithm>
33 
34 #include <QDebug>
35 #include <QHash>
36 #include <QJsonArray>
37 #include <QJsonObject>
38 #include <QRegularExpression>
39 #include <QSharedData>
40 #include <QString>
41 #include <QStringList>
42 
43 #include "base/global.h"
44 #include "base/preferences.h"
45 #include "base/utils/fs.h"
46 #include "base/utils/string.h"
47 #include "rss_article.h"
48 #include "rss_autodownloader.h"
49 #include "rss_feed.h"
50 
51 namespace
52 {
toOptionalBool(const QJsonValue & jsonVal)53     std::optional<bool> toOptionalBool(const QJsonValue &jsonVal)
54     {
55         if (jsonVal.isBool())
56             return jsonVal.toBool();
57 
58         return std::nullopt;
59     }
60 
toJsonValue(const std::optional<bool> boolValue)61     QJsonValue toJsonValue(const std::optional<bool> boolValue)
62     {
63         return boolValue.has_value() ? *boolValue : QJsonValue {};
64     }
65 
addPausedLegacyToOptionalBool(const int val)66     std::optional<bool> addPausedLegacyToOptionalBool(const int val)
67     {
68         switch (val)
69         {
70         case 1:
71             return true; // always
72         case 2:
73             return false; // never
74         default:
75             return std::nullopt; // default
76         }
77     }
78 
toAddPausedLegacy(const std::optional<bool> boolValue)79     int toAddPausedLegacy(const std::optional<bool> boolValue)
80     {
81         if (!boolValue.has_value())
82             return 0; // default
83 
84         return (*boolValue ? 1 /* always */ : 2 /* never */);
85     }
86 
jsonValueToContentLayout(const QJsonValue & jsonVal)87     std::optional<BitTorrent::TorrentContentLayout> jsonValueToContentLayout(const QJsonValue &jsonVal)
88     {
89         const QString str = jsonVal.toString();
90         if (str.isEmpty())
91             return std::nullopt;
92         return Utils::String::toEnum(str, BitTorrent::TorrentContentLayout::Original);
93     }
94 
contentLayoutToJsonValue(const std::optional<BitTorrent::TorrentContentLayout> contentLayout)95     QJsonValue contentLayoutToJsonValue(const std::optional<BitTorrent::TorrentContentLayout> contentLayout)
96     {
97         if (!contentLayout)
98             return {};
99         return Utils::String::fromEnum(*contentLayout);
100     }
101 }
102 
103 const QString Str_Name(QStringLiteral("name"));
104 const QString Str_Enabled(QStringLiteral("enabled"));
105 const QString Str_UseRegex(QStringLiteral("useRegex"));
106 const QString Str_MustContain(QStringLiteral("mustContain"));
107 const QString Str_MustNotContain(QStringLiteral("mustNotContain"));
108 const QString Str_EpisodeFilter(QStringLiteral("episodeFilter"));
109 const QString Str_AffectedFeeds(QStringLiteral("affectedFeeds"));
110 const QString Str_SavePath(QStringLiteral("savePath"));
111 const QString Str_AssignedCategory(QStringLiteral("assignedCategory"));
112 const QString Str_LastMatch(QStringLiteral("lastMatch"));
113 const QString Str_IgnoreDays(QStringLiteral("ignoreDays"));
114 const QString Str_AddPaused(QStringLiteral("addPaused"));
115 const QString Str_CreateSubfolder(QStringLiteral("createSubfolder"));
116 const QString Str_ContentLayout(QStringLiteral("torrentContentLayout"));
117 const QString Str_SmartFilter(QStringLiteral("smartFilter"));
118 const QString Str_PreviouslyMatched(QStringLiteral("previouslyMatchedEpisodes"));
119 
120 namespace RSS
121 {
122     struct AutoDownloadRuleData : public QSharedData
123     {
124         QString name;
125         bool enabled = true;
126 
127         QStringList mustContain;
128         QStringList mustNotContain;
129         QString episodeFilter;
130         QStringList feedURLs;
131         bool useRegex = false;
132         int ignoreDays = 0;
133         QDateTime lastMatch;
134 
135         QString savePath;
136         QString category;
137         std::optional<bool> addPaused;
138         std::optional<BitTorrent::TorrentContentLayout> contentLayout;
139 
140         bool smartFilter = false;
141         QStringList previouslyMatchedEpisodes;
142 
143         mutable QStringList lastComputedEpisodes;
144         mutable QHash<QString, QRegularExpression> cachedRegexes;
145 
operator ==RSS::AutoDownloadRuleData146         bool operator==(const AutoDownloadRuleData &other) const
147         {
148             return (name == other.name)
149                     && (enabled == other.enabled)
150                     && (mustContain == other.mustContain)
151                     && (mustNotContain == other.mustNotContain)
152                     && (episodeFilter == other.episodeFilter)
153                     && (feedURLs == other.feedURLs)
154                     && (useRegex == other.useRegex)
155                     && (ignoreDays == other.ignoreDays)
156                     && (lastMatch == other.lastMatch)
157                     && (savePath == other.savePath)
158                     && (category == other.category)
159                     && (addPaused == other.addPaused)
160                     && (contentLayout == other.contentLayout)
161                     && (smartFilter == other.smartFilter);
162         }
163     };
164 }
165 
166 using namespace RSS;
167 
computeEpisodeName(const QString & article)168 QString computeEpisodeName(const QString &article)
169 {
170     const QRegularExpression episodeRegex = AutoDownloader::instance()->smartEpisodeRegex();
171     const QRegularExpressionMatch match = episodeRegex.match(article);
172 
173     // See if we can extract an season/episode number or date from the title
174     if (!match.hasMatch())
175         return {};
176 
177     QStringList ret;
178     for (int i = 1; i <= match.lastCapturedIndex(); ++i)
179     {
180         const QString cap = match.captured(i);
181 
182         if (cap.isEmpty())
183             continue;
184 
185         bool isInt = false;
186         const int x = cap.toInt(&isInt);
187 
188         ret.append(isInt ? QString::number(x) : cap);
189     }
190     return ret.join('x');
191 }
192 
AutoDownloadRule(const QString & name)193 AutoDownloadRule::AutoDownloadRule(const QString &name)
194     : m_dataPtr(new AutoDownloadRuleData)
195 {
196     setName(name);
197 }
198 
AutoDownloadRule(const AutoDownloadRule & other)199 AutoDownloadRule::AutoDownloadRule(const AutoDownloadRule &other)
200     : m_dataPtr(other.m_dataPtr)
201 {
202 }
203 
~AutoDownloadRule()204 AutoDownloadRule::~AutoDownloadRule() {}
205 
cachedRegex(const QString & expression,const bool isRegex) const206 QRegularExpression AutoDownloadRule::cachedRegex(const QString &expression, const bool isRegex) const
207 {
208     // Use a cache of regexes so we don't have to continually recompile - big performance increase.
209     // The cache is cleared whenever the regex/wildcard, must or must not contain fields or
210     // episode filter are modified.
211     Q_ASSERT(!expression.isEmpty());
212 
213     QRegularExpression &regex = m_dataPtr->cachedRegexes[expression];
214     if (regex.pattern().isEmpty())
215     {
216         regex = QRegularExpression
217         {
218                 (isRegex ? expression : Utils::String::wildcardToRegex(expression))
219                 , QRegularExpression::CaseInsensitiveOption};
220     }
221 
222     return regex;
223 }
224 
matchesExpression(const QString & articleTitle,const QString & expression) const225 bool AutoDownloadRule::matchesExpression(const QString &articleTitle, const QString &expression) const
226 {
227     const QRegularExpression whitespace {"\\s+"};
228 
229     if (expression.isEmpty())
230     {
231         // A regex of the form "expr|" will always match, so do the same for wildcards
232         return true;
233     }
234 
235     if (m_dataPtr->useRegex)
236     {
237         const QRegularExpression reg(cachedRegex(expression));
238         return reg.match(articleTitle).hasMatch();
239     }
240 
241     // Only match if every wildcard token (separated by spaces) is present in the article name.
242     // Order of wildcard tokens is unimportant (if order is important, they should have used *).
243     const QStringList wildcards {expression.split(whitespace, QString::SplitBehavior::SkipEmptyParts)};
244     for (const QString &wildcard : wildcards)
245     {
246         const QRegularExpression reg {cachedRegex(wildcard, false)};
247         if (!reg.match(articleTitle).hasMatch())
248             return false;
249     }
250 
251     return true;
252 }
253 
matchesMustContainExpression(const QString & articleTitle) const254 bool AutoDownloadRule::matchesMustContainExpression(const QString &articleTitle) const
255 {
256     if (m_dataPtr->mustContain.empty())
257         return true;
258 
259     // Each expression is either a regex, or a set of wildcards separated by whitespace.
260     // Accept if any complete expression matches.
261     return std::any_of(m_dataPtr->mustContain.cbegin(), m_dataPtr->mustContain.cend(), [this, &articleTitle](const QString &expression)
262     {
263         // A regex of the form "expr|" will always match, so do the same for wildcards
264         return matchesExpression(articleTitle, expression);
265     });
266 }
267 
matchesMustNotContainExpression(const QString & articleTitle) const268 bool AutoDownloadRule::matchesMustNotContainExpression(const QString &articleTitle) const
269 {
270     if (m_dataPtr->mustNotContain.empty())
271         return true;
272 
273     // Each expression is either a regex, or a set of wildcards separated by whitespace.
274     // Reject if any complete expression matches.
275     return std::none_of(m_dataPtr->mustNotContain.cbegin(), m_dataPtr->mustNotContain.cend(), [this, &articleTitle](const QString &expression)
276     {
277         // A regex of the form "expr|" will always match, so do the same for wildcards
278         return matchesExpression(articleTitle, expression);
279     });
280 }
281 
matchesEpisodeFilterExpression(const QString & articleTitle) const282 bool AutoDownloadRule::matchesEpisodeFilterExpression(const QString &articleTitle) const
283 {
284     // Reset the lastComputedEpisode, we don't want to leak it between matches
285     m_dataPtr->lastComputedEpisodes.clear();
286 
287     if (m_dataPtr->episodeFilter.isEmpty())
288         return true;
289 
290     const QRegularExpression filterRegex {cachedRegex("(^\\d{1,4})x(.*;$)")};
291     const QRegularExpressionMatch matcher {filterRegex.match(m_dataPtr->episodeFilter)};
292     if (!matcher.hasMatch())
293         return false;
294 
295     const QString season {matcher.captured(1)};
296     const QStringList episodes {matcher.captured(2).split(';')};
297     const int seasonOurs {season.toInt()};
298 
299     for (QString episode : episodes)
300     {
301         if (episode.isEmpty())
302             continue;
303 
304         // We need to trim leading zeroes, but if it's all zeros then we want episode zero.
305         while ((episode.size() > 1) && episode.startsWith('0'))
306             episode = episode.right(episode.size() - 1);
307 
308         if (episode.indexOf('-') != -1)
309         { // Range detected
310             const QString partialPattern1 {"\\bs0?(\\d{1,4})[ -_\\.]?e(0?\\d{1,4})(?:\\D|\\b)"};
311             const QString partialPattern2 {"\\b(\\d{1,4})x(0?\\d{1,4})(?:\\D|\\b)"};
312 
313             // Extract partial match from article and compare as digits
314             QRegularExpressionMatch matcher = cachedRegex(partialPattern1).match(articleTitle);
315             bool matched = matcher.hasMatch();
316 
317             if (!matched)
318             {
319                 matcher = cachedRegex(partialPattern2).match(articleTitle);
320                 matched = matcher.hasMatch();
321             }
322 
323             if (matched)
324             {
325                 const int seasonTheirs {matcher.captured(1).toInt()};
326                 const int episodeTheirs {matcher.captured(2).toInt()};
327 
328                 if (episode.endsWith('-'))
329                 { // Infinite range
330                     const int episodeOurs {episode.leftRef(episode.size() - 1).toInt()};
331                     if (((seasonTheirs == seasonOurs) && (episodeTheirs >= episodeOurs)) || (seasonTheirs > seasonOurs))
332                         return true;
333                 }
334                 else
335                 { // Normal range
336                     const QStringList range {episode.split('-')};
337                     Q_ASSERT(range.size() == 2);
338                     if (range.first().toInt() > range.last().toInt())
339                         continue; // Ignore this subrule completely
340 
341                     const int episodeOursFirst {range.first().toInt()};
342                     const int episodeOursLast {range.last().toInt()};
343                     if ((seasonTheirs == seasonOurs) && ((episodeOursFirst <= episodeTheirs) && (episodeOursLast >= episodeTheirs)))
344                         return true;
345                 }
346             }
347         }
348         else
349         { // Single number
350             const QString expStr {QString::fromLatin1("\\b(?:s0?%1[ -_\\.]?e0?%2|%1x0?%2)(?:\\D|\\b)").arg(season, episode)};
351             if (cachedRegex(expStr).match(articleTitle).hasMatch())
352                 return true;
353         }
354     }
355 
356     return false;
357 }
358 
matchesSmartEpisodeFilter(const QString & articleTitle) const359 bool AutoDownloadRule::matchesSmartEpisodeFilter(const QString &articleTitle) const
360 {
361     if (!useSmartFilter())
362         return true;
363 
364     const QString episodeStr = computeEpisodeName(articleTitle);
365     if (episodeStr.isEmpty())
366         return true;
367 
368     // See if this episode has been downloaded before
369     const bool previouslyMatched = m_dataPtr->previouslyMatchedEpisodes.contains(episodeStr);
370     if (previouslyMatched)
371     {
372         if (!AutoDownloader::instance()->downloadRepacks())
373             return false;
374 
375         // Now see if we've downloaded this particular repack/proper combination
376         const bool isRepack = articleTitle.contains("REPACK", Qt::CaseInsensitive);
377         const bool isProper = articleTitle.contains("PROPER", Qt::CaseInsensitive);
378 
379         if (!isRepack && !isProper)
380             return false;
381 
382         const QString fullEpisodeStr = QString::fromLatin1("%1%2%3").arg(episodeStr,
383                                                              isRepack ? "-REPACK" : "",
384                                                              isProper ? "-PROPER" : "");
385         const bool previouslyMatchedFull = m_dataPtr->previouslyMatchedEpisodes.contains(fullEpisodeStr);
386         if (previouslyMatchedFull)
387             return false;
388 
389         m_dataPtr->lastComputedEpisodes.append(fullEpisodeStr);
390 
391         // If this is a REPACK and PROPER download, add the individual entries to the list
392         // so we don't download those
393         if (isRepack && isProper)
394         {
395             m_dataPtr->lastComputedEpisodes.append(episodeStr + QLatin1String("-REPACK"));
396             m_dataPtr->lastComputedEpisodes.append(episodeStr + QLatin1String("-PROPER"));
397         }
398     }
399 
400     m_dataPtr->lastComputedEpisodes.append(episodeStr);
401     return true;
402 }
403 
matches(const QVariantHash & articleData) const404 bool AutoDownloadRule::matches(const QVariantHash &articleData) const
405 {
406     const QDateTime articleDate {articleData[Article::KeyDate].toDateTime()};
407     if (ignoreDays() > 0)
408     {
409         if (lastMatch().isValid() && (articleDate < lastMatch().addDays(ignoreDays())))
410             return false;
411     }
412 
413     const QString articleTitle {articleData[Article::KeyTitle].toString()};
414     if (!matchesMustContainExpression(articleTitle))
415         return false;
416     if (!matchesMustNotContainExpression(articleTitle))
417         return false;
418     if (!matchesEpisodeFilterExpression(articleTitle))
419         return false;
420     if (!matchesSmartEpisodeFilter(articleTitle))
421         return false;
422 
423     return true;
424 }
425 
accepts(const QVariantHash & articleData)426 bool AutoDownloadRule::accepts(const QVariantHash &articleData)
427 {
428     if (!matches(articleData))
429         return false;
430 
431     setLastMatch(articleData[Article::KeyDate].toDateTime());
432 
433     // If there's a matched episode string, add that to the previously matched list
434     if (!m_dataPtr->lastComputedEpisodes.isEmpty())
435     {
436         m_dataPtr->previouslyMatchedEpisodes.append(m_dataPtr->lastComputedEpisodes);
437         m_dataPtr->lastComputedEpisodes.clear();
438     }
439 
440     return true;
441 }
442 
operator =(const AutoDownloadRule & other)443 AutoDownloadRule &AutoDownloadRule::operator=(const AutoDownloadRule &other)
444 {
445     if (this != &other)
446     {
447         m_dataPtr = other.m_dataPtr;
448     }
449     return *this;
450 }
451 
operator ==(const AutoDownloadRule & other) const452 bool AutoDownloadRule::operator==(const AutoDownloadRule &other) const
453 {
454     return (m_dataPtr == other.m_dataPtr) // optimization
455             || (*m_dataPtr == *other.m_dataPtr);
456 }
457 
operator !=(const AutoDownloadRule & other) const458 bool AutoDownloadRule::operator!=(const AutoDownloadRule &other) const
459 {
460     return !operator==(other);
461 }
462 
toJsonObject() const463 QJsonObject AutoDownloadRule::toJsonObject() const
464 {
465     return {{Str_Enabled, isEnabled()}
466         , {Str_UseRegex, useRegex()}
467         , {Str_MustContain, mustContain()}
468         , {Str_MustNotContain, mustNotContain()}
469         , {Str_EpisodeFilter, episodeFilter()}
470         , {Str_AffectedFeeds, QJsonArray::fromStringList(feedURLs())}
471         , {Str_SavePath, savePath()}
472         , {Str_AssignedCategory, assignedCategory()}
473         , {Str_LastMatch, lastMatch().toString(Qt::RFC2822Date)}
474         , {Str_IgnoreDays, ignoreDays()}
475         , {Str_AddPaused, toJsonValue(addPaused())}
476         , {Str_ContentLayout, contentLayoutToJsonValue(torrentContentLayout())}
477         , {Str_SmartFilter, useSmartFilter()}
478         , {Str_PreviouslyMatched, QJsonArray::fromStringList(previouslyMatchedEpisodes())}};
479 }
480 
fromJsonObject(const QJsonObject & jsonObj,const QString & name)481 AutoDownloadRule AutoDownloadRule::fromJsonObject(const QJsonObject &jsonObj, const QString &name)
482 {
483     AutoDownloadRule rule(name.isEmpty() ? jsonObj.value(Str_Name).toString() : name);
484 
485     rule.setUseRegex(jsonObj.value(Str_UseRegex).toBool(false));
486     rule.setMustContain(jsonObj.value(Str_MustContain).toString());
487     rule.setMustNotContain(jsonObj.value(Str_MustNotContain).toString());
488     rule.setEpisodeFilter(jsonObj.value(Str_EpisodeFilter).toString());
489     rule.setEnabled(jsonObj.value(Str_Enabled).toBool(true));
490     rule.setSavePath(jsonObj.value(Str_SavePath).toString());
491     rule.setCategory(jsonObj.value(Str_AssignedCategory).toString());
492     rule.setAddPaused(toOptionalBool(jsonObj.value(Str_AddPaused)));
493 
494     // TODO: The following code is deprecated. Replace with the commented one after several releases in 4.4.x.
495     // === BEGIN DEPRECATED CODE === //
496     if (jsonObj.contains(Str_ContentLayout))
497     {
498         rule.setTorrentContentLayout(jsonValueToContentLayout(jsonObj.value(Str_ContentLayout)));
499     }
500     else
501     {
502         const std::optional<bool> createSubfolder = toOptionalBool(jsonObj.value(Str_CreateSubfolder));
503         std::optional<BitTorrent::TorrentContentLayout> contentLayout;
504         if (createSubfolder.has_value())
505         {
506             contentLayout = (*createSubfolder
507                              ? BitTorrent::TorrentContentLayout::Original
508                              : BitTorrent::TorrentContentLayout::NoSubfolder);
509         }
510 
511         rule.setTorrentContentLayout(contentLayout);
512     }
513     // === END DEPRECATED CODE === //
514     // === BEGIN REPLACEMENT CODE === //
515 //    rule.setTorrentContentLayout(jsonValueToContentLayout(jsonObj.value(Str_ContentLayout)));
516     // === END REPLACEMENT CODE === //
517 
518     rule.setLastMatch(QDateTime::fromString(jsonObj.value(Str_LastMatch).toString(), Qt::RFC2822Date));
519     rule.setIgnoreDays(jsonObj.value(Str_IgnoreDays).toInt());
520     rule.setUseSmartFilter(jsonObj.value(Str_SmartFilter).toBool(false));
521 
522     const QJsonValue feedsVal = jsonObj.value(Str_AffectedFeeds);
523     QStringList feedURLs;
524     if (feedsVal.isString())
525         feedURLs << feedsVal.toString();
526     else for (const QJsonValue &urlVal : asConst(feedsVal.toArray()))
527         feedURLs << urlVal.toString();
528     rule.setFeedURLs(feedURLs);
529 
530     const QJsonValue previouslyMatchedVal = jsonObj.value(Str_PreviouslyMatched);
531     QStringList previouslyMatched;
532     if (previouslyMatchedVal.isString())
533     {
534         previouslyMatched << previouslyMatchedVal.toString();
535     }
536     else
537     {
538         for (const QJsonValue &val : asConst(previouslyMatchedVal.toArray()))
539             previouslyMatched << val.toString();
540     }
541     rule.setPreviouslyMatchedEpisodes(previouslyMatched);
542 
543     return rule;
544 }
545 
toLegacyDict() const546 QVariantHash AutoDownloadRule::toLegacyDict() const
547 {
548     return {{"name", name()},
549         {"must_contain", mustContain()},
550         {"must_not_contain", mustNotContain()},
551         {"save_path", savePath()},
552         {"affected_feeds", feedURLs()},
553         {"enabled", isEnabled()},
554         {"category_assigned", assignedCategory()},
555         {"use_regex", useRegex()},
556         {"add_paused", toAddPausedLegacy(addPaused())},
557         {"episode_filter", episodeFilter()},
558         {"last_match", lastMatch()},
559         {"ignore_days", ignoreDays()}};
560 }
561 
fromLegacyDict(const QVariantHash & dict)562 AutoDownloadRule AutoDownloadRule::fromLegacyDict(const QVariantHash &dict)
563 {
564     AutoDownloadRule rule(dict.value("name").toString());
565 
566     rule.setUseRegex(dict.value("use_regex", false).toBool());
567     rule.setMustContain(dict.value("must_contain").toString());
568     rule.setMustNotContain(dict.value("must_not_contain").toString());
569     rule.setEpisodeFilter(dict.value("episode_filter").toString());
570     rule.setFeedURLs(dict.value("affected_feeds").toStringList());
571     rule.setEnabled(dict.value("enabled", false).toBool());
572     rule.setSavePath(dict.value("save_path").toString());
573     rule.setCategory(dict.value("category_assigned").toString());
574     rule.setAddPaused(addPausedLegacyToOptionalBool(dict.value("add_paused").toInt()));
575     rule.setLastMatch(dict.value("last_match").toDateTime());
576     rule.setIgnoreDays(dict.value("ignore_days").toInt());
577 
578     return rule;
579 }
580 
setMustContain(const QString & tokens)581 void AutoDownloadRule::setMustContain(const QString &tokens)
582 {
583     m_dataPtr->cachedRegexes.clear();
584 
585     if (m_dataPtr->useRegex)
586         m_dataPtr->mustContain = QStringList() << tokens;
587     else
588         m_dataPtr->mustContain = tokens.split('|');
589 
590     // Check for single empty string - if so, no condition
591     if ((m_dataPtr->mustContain.size() == 1) && m_dataPtr->mustContain[0].isEmpty())
592         m_dataPtr->mustContain.clear();
593 }
594 
setMustNotContain(const QString & tokens)595 void AutoDownloadRule::setMustNotContain(const QString &tokens)
596 {
597     m_dataPtr->cachedRegexes.clear();
598 
599     if (m_dataPtr->useRegex)
600         m_dataPtr->mustNotContain = QStringList() << tokens;
601     else
602         m_dataPtr->mustNotContain = tokens.split('|');
603 
604     // Check for single empty string - if so, no condition
605     if ((m_dataPtr->mustNotContain.size() == 1) && m_dataPtr->mustNotContain[0].isEmpty())
606         m_dataPtr->mustNotContain.clear();
607 }
608 
feedURLs() const609 QStringList AutoDownloadRule::feedURLs() const
610 {
611     return m_dataPtr->feedURLs;
612 }
613 
setFeedURLs(const QStringList & urls)614 void AutoDownloadRule::setFeedURLs(const QStringList &urls)
615 {
616     m_dataPtr->feedURLs = urls;
617 }
618 
name() const619 QString AutoDownloadRule::name() const
620 {
621     return m_dataPtr->name;
622 }
623 
setName(const QString & name)624 void AutoDownloadRule::setName(const QString &name)
625 {
626     m_dataPtr->name = name;
627 }
628 
savePath() const629 QString AutoDownloadRule::savePath() const
630 {
631     return m_dataPtr->savePath;
632 }
633 
setSavePath(const QString & savePath)634 void AutoDownloadRule::setSavePath(const QString &savePath)
635 {
636     m_dataPtr->savePath = Utils::Fs::toUniformPath(savePath);
637 }
638 
addPaused() const639 std::optional<bool> AutoDownloadRule::addPaused() const
640 {
641     return m_dataPtr->addPaused;
642 }
643 
setAddPaused(const std::optional<bool> addPaused)644 void AutoDownloadRule::setAddPaused(const std::optional<bool> addPaused)
645 {
646     m_dataPtr->addPaused = addPaused;
647 }
648 
torrentContentLayout() const649 std::optional<BitTorrent::TorrentContentLayout> AutoDownloadRule::torrentContentLayout() const
650 {
651     return m_dataPtr->contentLayout;
652 }
653 
setTorrentContentLayout(const std::optional<BitTorrent::TorrentContentLayout> contentLayout)654 void AutoDownloadRule::setTorrentContentLayout(const std::optional<BitTorrent::TorrentContentLayout> contentLayout)
655 {
656     m_dataPtr->contentLayout = contentLayout;
657 }
658 
assignedCategory() const659 QString AutoDownloadRule::assignedCategory() const
660 {
661     return m_dataPtr->category;
662 }
663 
setCategory(const QString & category)664 void AutoDownloadRule::setCategory(const QString &category)
665 {
666     m_dataPtr->category = category;
667 }
668 
isEnabled() const669 bool AutoDownloadRule::isEnabled() const
670 {
671     return m_dataPtr->enabled;
672 }
673 
setEnabled(const bool enable)674 void AutoDownloadRule::setEnabled(const bool enable)
675 {
676     m_dataPtr->enabled = enable;
677 }
678 
lastMatch() const679 QDateTime AutoDownloadRule::lastMatch() const
680 {
681     return m_dataPtr->lastMatch;
682 }
683 
setLastMatch(const QDateTime & lastMatch)684 void AutoDownloadRule::setLastMatch(const QDateTime &lastMatch)
685 {
686     m_dataPtr->lastMatch = lastMatch;
687 }
688 
setIgnoreDays(const int d)689 void AutoDownloadRule::setIgnoreDays(const int d)
690 {
691     m_dataPtr->ignoreDays = d;
692 }
693 
ignoreDays() const694 int AutoDownloadRule::ignoreDays() const
695 {
696     return m_dataPtr->ignoreDays;
697 }
698 
mustContain() const699 QString AutoDownloadRule::mustContain() const
700 {
701     return m_dataPtr->mustContain.join('|');
702 }
703 
mustNotContain() const704 QString AutoDownloadRule::mustNotContain() const
705 {
706     return m_dataPtr->mustNotContain.join('|');
707 }
708 
useSmartFilter() const709 bool AutoDownloadRule::useSmartFilter() const
710 {
711     return m_dataPtr->smartFilter;
712 }
713 
setUseSmartFilter(const bool enabled)714 void AutoDownloadRule::setUseSmartFilter(const bool enabled)
715 {
716     m_dataPtr->smartFilter = enabled;
717 }
718 
useRegex() const719 bool AutoDownloadRule::useRegex() const
720 {
721     return m_dataPtr->useRegex;
722 }
723 
setUseRegex(const bool enabled)724 void AutoDownloadRule::setUseRegex(const bool enabled)
725 {
726     m_dataPtr->useRegex = enabled;
727     m_dataPtr->cachedRegexes.clear();
728 }
729 
previouslyMatchedEpisodes() const730 QStringList AutoDownloadRule::previouslyMatchedEpisodes() const
731 {
732     return m_dataPtr->previouslyMatchedEpisodes;
733 }
734 
setPreviouslyMatchedEpisodes(const QStringList & previouslyMatchedEpisodes)735 void AutoDownloadRule::setPreviouslyMatchedEpisodes(const QStringList &previouslyMatchedEpisodes)
736 {
737     m_dataPtr->previouslyMatchedEpisodes = previouslyMatchedEpisodes;
738 }
739 
episodeFilter() const740 QString AutoDownloadRule::episodeFilter() const
741 {
742     return m_dataPtr->episodeFilter;
743 }
744 
setEpisodeFilter(const QString & e)745 void AutoDownloadRule::setEpisodeFilter(const QString &e)
746 {
747     m_dataPtr->episodeFilter = e;
748     m_dataPtr->cachedRegexes.clear();
749 }
750