diff --git a/CHANGELOG.md b/CHANGELOG.md index b04afe6dab..a9735ea4bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ #### New Features - Added support for Python 3 (>= 3.5.0) ([#4982](https://github.com/pymedusa/Medusa/pull/4982)) - Added feature to search episodes early or late compared to their scheduled airdate ([#5874](https://github.com/pymedusa/Medusa/pull/5874)) +- Added per show required/preferred words exclude option ([#4982](https://github.com/pymedusa/Medusa/pull/6033)) #### Improvements diff --git a/dredd/api-description.yml b/dredd/api-description.yml index 470c78ef1c..77448eb9a1 100644 --- a/dredd/api-description.yml +++ b/dredd/api-description.yml @@ -1091,6 +1091,12 @@ definitions: description: Required release words items: type: string + requiredWordsExclude: + type: boolean + description: Exclude required words from global required words list + ignoredWordsExclude: + type: boolean + description: Exclude ignored words from global ignored words list airdateOffset: type: integer description: Amount of hours we want to start searching early (-1) or late (1) for new episodes diff --git a/medusa/databases/main_db.py b/medusa/databases/main_db.py index c9fa8fc3d7..b4ac92261c 100644 --- a/medusa/databases/main_db.py +++ b/medusa/databases/main_db.py @@ -882,3 +882,22 @@ def execute(self): self.addColumn('tv_shows', 'airdate_offset', 'NUMERIC', 0) self.inc_minor_version() + + +class AddReleaseIgnoreRequireExludeOptions(AddTvshowStartSearchOffset): + """Add release ignore and require exclude option flags.""" + + def test(self): + """Test if the version is at least 44.14""" + return self.connection.version >= (44, 14) + + def execute(self): + utils.backup_database(self.connection.path, self.connection.version) + + log.info(u'Adding release ignore and require exclude option flags to the tv_shows table') + if not self.hasColumn('tv_shows', 'rls_require_exclude'): + self.addColumn('tv_shows', 'rls_require_exclude', 'NUMERIC', 0) + if not self.hasColumn('tv_shows', 'rls_ignore_exclude'): + self.addColumn('tv_shows', 'rls_ignore_exclude', 'NUMERIC', 0) + + self.inc_minor_version() diff --git a/medusa/server/api/v2/series.py b/medusa/server/api/v2/series.py index ed5a6b44d7..822d117468 100644 --- a/medusa/server/api/v2/series.py +++ b/medusa/server/api/v2/series.py @@ -137,6 +137,8 @@ def http_patch(self, series_slug, path_param=None): 'config.release.ignoredWords': ListField(series, 'release_ignore_words'), 'config.release.blacklist': ListField(series, 'blacklist'), 'config.release.whitelist': ListField(series, 'whitelist'), + 'config.release.requiredWordsExclude': BooleanField(series, 'rls_require_exclude'), + 'config.release.ignoredWordsExclude': BooleanField(series, 'rls_ignore_exclude'), 'language': StringField(series, 'lang'), 'config.qualities.allowed': ListField(series, 'qualities_allowed'), 'config.qualities.preferred': ListField(series, 'qualities_preferred'), diff --git a/medusa/tv/series.py b/medusa/tv/series.py index 1beddb1d69..3d2ef150ee 100644 --- a/medusa/tv/series.py +++ b/medusa/tv/series.py @@ -16,7 +16,7 @@ from builtins import map from builtins import str from collections import ( - namedtuple, + OrderedDict, namedtuple ) from itertools import groupby @@ -226,6 +226,8 @@ def __init__(self, indexer, indexerid, lang='', quality=None, self.scene = 0 self.rls_ignore_words = '' self.rls_require_words = '' + self.rls_ignore_exclude = 0 + self.rls_require_exclude = 0 self.default_ep_status = SKIPPED self._location = '' self.episodes = {} @@ -931,13 +933,21 @@ def show_words(self): # If word is in global ignore and also in show require, then remove it from global ignore # Join new global ignore with show ignore - final_ignore = show_ignore + [i for i in global_ignore if i.lower() not in [r.lower() for r in show_require]] - # If word is in global require and also in show ignore, then remove it from global require + if not self.rls_ignore_exclude: + final_ignore = show_ignore + [i for i in global_ignore if i.lower() not in [r.lower() for r in show_require]] + else: + final_ignore = [i for i in global_ignore if i.lower() not in [r.lower() for r in show_require] and + i.lower() not in [sh_i.lower() for sh_i in show_ignore]] + # If word is in global require and also in show ignore, then remove it from global requires # Join new global required with show require - final_require = show_require + [i for i in global_require if i.lower() not in [r.lower() for r in show_ignore]] + if not self.rls_require_exclude: + final_require = show_require + [i for i in global_require if i.lower() not in [r.lower() for r in show_ignore]] + else: + final_require = [gl_r for gl_r in global_require if gl_r.lower() not in [r.lower() for r in show_ignore] and + gl_r.lower() not in [sh_r.lower() for sh_r in show_require]] - ignored_words = final_ignore - required_words = final_require + ignored_words = list(OrderedDict.fromkeys(final_ignore)) + required_words = list(OrderedDict.fromkeys(final_require)) return words(preferred_words, undesired_words, ignored_words, required_words) @@ -1471,6 +1481,8 @@ def _load_from_db(self): self.rls_ignore_words = sql_results[0]['rls_ignore_words'] self.rls_require_words = sql_results[0]['rls_require_words'] + self.rls_ignore_exclude = sql_results[0]['rls_ignore_exclude'] + self.rls_require_exclude = sql_results[0]['rls_require_exclude'] self.default_ep_status = int(sql_results[0]['default_ep_status'] or SKIPPED) @@ -2082,6 +2094,8 @@ def to_json(self, detailed=True, fetch=False): data['config']['release'] = {} data['config']['release']['ignoredWords'] = self.release_ignore_words data['config']['release']['requiredWords'] = self.release_required_words + data['config']['release']['ignoredWordsExclude'] = bool(self.rls_ignore_exclude) + data['config']['release']['requiredWordsExclude'] = bool(self.rls_require_exclude) data['config']['airdateOffset'] = self.airdate_offset # These are for now considered anime-only options diff --git a/tests/test_words.py b/tests/test_words.py index 1a7807c932..2062964eb7 100644 --- a/tests/test_words.py +++ b/tests/test_words.py @@ -47,3 +47,97 @@ def has_words_lazy(item, words): """Test if item contains words lazily.""" found_words = any(contains_words(item, words)) assert found_words, (item, words) + + +@pytest.mark.parametrize('p', [ + # The regular Show uses xem data. To map scene S06E29 to indexer S06E28 + { + 'series_info': { + 'name': u'Regular Show', + 'is_scene': False + }, + 'global': { + 'ignored': ['pref1', 'pref2', 'pref3'], + }, + 'series': { + 'ignored': 'pref1,pref5,pref6', + 'exclude_ignored': False, + }, + 'expected_ignored': [u'pref1', u'pref5', u'pref6', u'pref2', u'pref3'], + }, + { + 'series_info': { + 'name': u'Regular Show', + 'is_scene': False + }, + 'global': { + 'ignored': ['pref1', 'pref2', 'pref3'], + }, + 'series': { + 'ignored': 'pref1,pref2', + 'exclude_ignored': True, + }, + 'expected_ignored': [u'pref3'], + }, + +]) +def test_combine_ignored_words(p, create_tvshow, app_config): + app_config('IGNORE_WORDS', p['global']['ignored']) + + # confirm passed in show object indexer id matches result show object indexer id + series = create_tvshow(name=p['series_info']['name']) + series.rls_ignore_words = p['series']['ignored'] + series.rls_ignore_exclude = p['series']['exclude_ignored'] + + actual = series.show_words() + + expected = p['expected_ignored'] + + assert expected == actual.ignored_words + + +@pytest.mark.parametrize('p', [ + # The regular Show uses xem data. To map scene S06E29 to indexer S06E28 + { + 'series_info': { + 'name': u'Regular Show', + 'is_scene': False + }, + 'global': { + 'required': ['req1', 'req2', 'req3'] + }, + 'series': { + 'required': 'req1,req2,req4', + 'exclude_required': False, + }, + 'expected_required': [u'req1', u'req2', u'req4', u'req3'], + }, + { + 'series_info': { + 'name': u'Regular Show', + 'is_scene': False + }, + 'global': { + 'required': ['req1', 'req2', 'req3'] + }, + 'series': { + 'required': 'req2', + 'exclude_required': True, + }, + 'expected_required': [u'req1', u'req3'], + }, + +]) +def test_combine_required_words(p, create_tvshow, app_config): + app_config('REQUIRE_WORDS', p['global']['required']) + + # confirm passed in show object indexer id matches result show object indexer id + series = create_tvshow(name=p['series_info']['name']) + series.rls_require_words = p['series']['required'] + series.rls_require_exclude = p['series']['exclude_required'] + + actual = series.show_words() + + expected = p['expected_required'] + + assert expected == actual.required_words diff --git a/themes-default/slim/src/store/modules/defaults.js b/themes-default/slim/src/store/modules/defaults.js index 6e164ad278..2b702bff96 100644 --- a/themes-default/slim/src/store/modules/defaults.js +++ b/themes-default/slim/src/store/modules/defaults.js @@ -13,7 +13,15 @@ const state = { location: null, paused: null, qualities: null, - release: null, + release: { + requiredWords: [], + ignoredWords: [], + blacklist: [], + whitelist: [], + allgroups: [], + requiredWordsExclude: null, + ignoredWordsExclude: null + }, scene: null, seasonFolders: null, sports: null, diff --git a/themes-default/slim/views/editShow.mako b/themes-default/slim/views/editShow.mako index 91e6427be7..c1ea6317bc 100644 --- a/themes-default/slim/views/editShow.mako +++ b/themes-default/slim/views/editShow.mako @@ -44,7 +44,9 @@ window.app = new Vue({ ignoredWords: [], blacklist: [], whitelist: [], - allgroups: [] + allgroups: [], + requiredWordsExclude: false, + ignoredWordsExclude: false }, qualities: { preferred: [], @@ -106,7 +108,9 @@ window.app = new Vue({ subtitlesEnabled: this.series.config.subtitlesEnabled, release: { requiredWords: this.series.config.release.requiredWords, - ignoredWords: this.series.config.release.ignoredWords + ignoredWords: this.series.config.release.ignoredWords, + requiredWordsExclude: this.series.config.release.requiredWordsExclude, + ignoredWordsExclude: this.series.config.release.ignoredWordsExclude }, qualities: { preferred: this.series.config.qualities.preferred, @@ -154,6 +158,26 @@ window.app = new Vue({ }, updateLanguage(value) { this.series.language = value; + }, + arrayUnique(array) { + var a = array.concat(); + for (let i=0; i x.toLowerCase()); + }, + globalRequired() { + return this.$store.state.search.filters.ignored.map(x => x.toLowerCase()) + }, + effectiveIgnored() { + const { arrayExclude, arrayUnique, globalIgnored } = this; + const seriesIgnored = this.series.config.release.ignoredWords.map(x => x.toLowerCase()); + if (!this.series.config.release.ignoredWordsExclude) { + return arrayUnique(globalIgnored.concat(seriesIgnored)); + } else { + return arrayExclude(globalIgnored, seriesRequired); + } + }, + effectiveRequired() { + const { arrayExclude, arrayUnique, globalRequired } = this; + const seriesRequired = this.series.config.release.requiredWords.map(x => x.toLowerCase()); + if (!this.series.config.release.requiredWordsExclude) { + return arrayUnique(globalRequired.concat(seriesRequired)); + } else { + return arrayExclude(globalRequired, seriesRequired); + } } } }); @@ -203,54 +251,34 @@ window.app = new Vue({

Main Settings

-
- -
- -
-
+ + + -
- -
+ -
-
+ -
- -
- -

This will set the status for future episodes.

-
-
- -
- -
- -

This only applies to episode filenames and the contents of metadata files.

-
-
+ + + + + + +

This only applies to episode filenames and the contents of metadata files.

+
-
- -
- - search for subtitles -
-
+ + search for subtitles + -
- -
- + pause this show (Medusa will not download episodes) -
-
+
@@ -259,63 +287,36 @@ window.app = new Vue({

Format Settings

-
- -
- - check if the show is released as Show.03.02.2010 rather than Show.S02E03 -

In case of an air date conflict between regular and special episodes, the later will be ignored.

-
-
- -
- -
- - enable if the show is Anime and episodes are released as Show.265 rather than Show.S02E03 -
-
- -
- -
- -
-
- -
- -
- - enable if the show is a sporting or MMA event released as Show.03.02.2010 rather than Show.S02E03 -

In case of an air date conflict between regular and special episodes, the later will be ignored.

-
-
- -
- -
- - group episodes by season folder (disable to store in a single folder) -
-
+ + check if the show is released as Show.03.02.2010 rather than Show.S02E03 +

In case of an air date conflict between regular and special episodes, the later will be ignored.

+
+ + + enable if the show is Anime and episodes are released as Show.265 rather than Show.S02E03 + + + + + + + + enable if the show is a sporting or MMA event released as Show.03.02.2010 rather than Show.S02E03 +

In case of an air date conflict between regular and special episodes, the later will be ignored.

+
-
- -
- - search by scene numbering (disable to search by indexer numbering) -
-
+ + group episodes by season folder (disable to store in a single folder) + -
- -
- - use the DVD order instead of the air order -

A "Force Full Update" is necessary, and if you have existing episodes you need to sort them manually.

-
-
+ + search by scene numbering (disable to search by indexer numbering) + + + + use the DVD order instead of the air order +

A "Force Full Update" is necessary, and if you have existing episodes you need to sort them manually.

+
@@ -324,35 +325,32 @@ window.app = new Vue({

Advanced Settings

-
- -
- -
-

Search results with one or more words from this list will be ignored.

-
+ + +
+

Search results with one or more words from this list will be ignored.

-
+ -
- -
- -
-

Search results with no words from this list will be ignored.

-
-
-
+ +
Use the Ignored Words list to exclude these from the global ignored list
+

Currently the effective list is: {{ effectiveIgnored }}

+
-
- -
- -
-

This will affect episode search on NZB and torrent providers. This list appends to the original show name.

-
-
-
+ + +

Search results with no words from this list will be ignored.

+
+ + +

Use the Required Words list to exclude these from the global required words list

+

Currently the effective list is: {{ effectiveRequired }}

+
+ + + +

This will affect episode search on NZB and torrent providers. This list appends to the original show name.

+
x.toLowerCase()); + }, + globalRequired() { + return this.$store.state.search.filters.ignored.map(x => x.toLowerCase()) + }, + effectiveIgnored() { + const { arrayExclude, arrayUnique, globalIgnored } = this; + const seriesIgnored = this.series.config.release.ignoredWords.map(x => x.toLowerCase()); + if (!this.series.config.release.ignoredWordsExclude) { + return arrayUnique(globalIgnored.concat(seriesIgnored)); + } else { + return arrayExclude(globalIgnored, seriesRequired); + } + }, + effectiveRequired() { + const { arrayExclude, arrayUnique, globalRequired } = this; + const seriesRequired = this.series.config.release.requiredWords.map(x => x.toLowerCase()); + if (!this.series.config.release.requiredWordsExclude) { + return arrayUnique(globalRequired.concat(seriesRequired)); + } else { + return arrayExclude(globalRequired, seriesRequired); + } } } }); @@ -203,54 +251,34 @@ window.app = new Vue({

Main Settings

-
- -
- -
-
+ + + -
- -
+ -
-
+ -
- -
- -

This will set the status for future episodes.

-
-
- -
- -
- -

This only applies to episode filenames and the contents of metadata files.

-
-
+ + + + + + +

This only applies to episode filenames and the contents of metadata files.

+
-
- -
- - search for subtitles -
-
+ + search for subtitles + -
- -
- + pause this show (Medusa will not download episodes) -
-
+
@@ -259,63 +287,36 @@ window.app = new Vue({

Format Settings

-
- -
- - check if the show is released as Show.03.02.2010 rather than Show.S02E03 -

In case of an air date conflict between regular and special episodes, the later will be ignored.

-
-
- -
- -
- - enable if the show is Anime and episodes are released as Show.265 rather than Show.S02E03 -
-
- -
- -
- -
-
- -
- -
- - enable if the show is a sporting or MMA event released as Show.03.02.2010 rather than Show.S02E03 -

In case of an air date conflict between regular and special episodes, the later will be ignored.

-
-
- -
- -
- - group episodes by season folder (disable to store in a single folder) -
-
+ + check if the show is released as Show.03.02.2010 rather than Show.S02E03 +

In case of an air date conflict between regular and special episodes, the later will be ignored.

+
+ + + enable if the show is Anime and episodes are released as Show.265 rather than Show.S02E03 + + + + + + + + enable if the show is a sporting or MMA event released as Show.03.02.2010 rather than Show.S02E03 +

In case of an air date conflict between regular and special episodes, the later will be ignored.

+
-
- -
- - search by scene numbering (disable to search by indexer numbering) -
-
+ + group episodes by season folder (disable to store in a single folder) + -
- -
- - use the DVD order instead of the air order -

A "Force Full Update" is necessary, and if you have existing episodes you need to sort them manually.

-
-
+ + search by scene numbering (disable to search by indexer numbering) + + + + use the DVD order instead of the air order +

A "Force Full Update" is necessary, and if you have existing episodes you need to sort them manually.

+
@@ -324,35 +325,32 @@ window.app = new Vue({

Advanced Settings

-
- -
- -
-

Search results with one or more words from this list will be ignored.

-
+ + +
+

Search results with one or more words from this list will be ignored.

-
+ -
- -
- -
-

Search results with no words from this list will be ignored.

-
-
-
+ +
Use the Ignored Words list to exclude these from the global ignored list
+

Currently the effective list is: {{ effectiveIgnored }}

+
-
- -
- -
-

This will affect episode search on NZB and torrent providers. This list appends to the original show name.

-
-
-
+ + +

Search results with no words from this list will be ignored.

+
+ + +

Use the Required Words list to exclude these from the global required words list

+

Currently the effective list is: {{ effectiveRequired }}

+
+ + + +

This will affect episode search on NZB and torrent providers. This list appends to the original show name.

+
x.toLowerCase()); + }, + globalRequired() { + return this.$store.state.search.filters.ignored.map(x => x.toLowerCase()) + }, + effectiveIgnored() { + const { arrayExclude, arrayUnique, globalIgnored } = this; + const seriesIgnored = this.series.config.release.ignoredWords.map(x => x.toLowerCase()); + if (!this.series.config.release.ignoredWordsExclude) { + return arrayUnique(globalIgnored.concat(seriesIgnored)); + } else { + return arrayExclude(globalIgnored, seriesRequired); + } + }, + effectiveRequired() { + const { arrayExclude, arrayUnique, globalRequired } = this; + const seriesRequired = this.series.config.release.requiredWords.map(x => x.toLowerCase()); + if (!this.series.config.release.requiredWordsExclude) { + return arrayUnique(globalRequired.concat(seriesRequired)); + } else { + return arrayExclude(globalRequired, seriesRequired); + } } } }); @@ -203,54 +251,34 @@ window.app = new Vue({

Main Settings

-
- -
- -
-
+ + + -
- -
+ -
-
+ -
- -
- -

This will set the status for future episodes.

-
-
- -
- -
- -

This only applies to episode filenames and the contents of metadata files.

-
-
+ + + + + + +

This only applies to episode filenames and the contents of metadata files.

+
-
- -
- - search for subtitles -
-
+ + search for subtitles + -
- -
- + pause this show (Medusa will not download episodes) -
-
+
@@ -259,63 +287,36 @@ window.app = new Vue({

Format Settings

-
- -
- - check if the show is released as Show.03.02.2010 rather than Show.S02E03 -

In case of an air date conflict between regular and special episodes, the later will be ignored.

-
-
- -
- -
- - enable if the show is Anime and episodes are released as Show.265 rather than Show.S02E03 -
-
- -
- -
- -
-
- -
- -
- - enable if the show is a sporting or MMA event released as Show.03.02.2010 rather than Show.S02E03 -

In case of an air date conflict between regular and special episodes, the later will be ignored.

-
-
- -
- -
- - group episodes by season folder (disable to store in a single folder) -
-
+ + check if the show is released as Show.03.02.2010 rather than Show.S02E03 +

In case of an air date conflict between regular and special episodes, the later will be ignored.

+
+ + + enable if the show is Anime and episodes are released as Show.265 rather than Show.S02E03 + + + + + + + + enable if the show is a sporting or MMA event released as Show.03.02.2010 rather than Show.S02E03 +

In case of an air date conflict between regular and special episodes, the later will be ignored.

+
-
- -
- - search by scene numbering (disable to search by indexer numbering) -
-
+ + group episodes by season folder (disable to store in a single folder) + -
- -
- - use the DVD order instead of the air order -

A "Force Full Update" is necessary, and if you have existing episodes you need to sort them manually.

-
-
+ + search by scene numbering (disable to search by indexer numbering) + + + + use the DVD order instead of the air order +

A "Force Full Update" is necessary, and if you have existing episodes you need to sort them manually.

+
@@ -324,35 +325,32 @@ window.app = new Vue({

Advanced Settings

-
- -
- -
-

Search results with one or more words from this list will be ignored.

-
+ + +
+

Search results with one or more words from this list will be ignored.

-
+ -
- -
- -
-

Search results with no words from this list will be ignored.

-
-
-
+ +
Use the Ignored Words list to exclude these from the global ignored list
+

Currently the effective list is: {{ effectiveIgnored }}

+
-
- -
- -
-

This will affect episode search on NZB and torrent providers. This list appends to the original show name.

-
-
-
+ + +

Search results with no words from this list will be ignored.

+
+ + +

Use the Required Words list to exclude these from the global required words list

+

Currently the effective list is: {{ effectiveRequired }}

+
+ + + +

This will affect episode search on NZB and torrent providers. This list appends to the original show name.

+