Browse Source

Change season specific alt names now available not just for anime.

Add config/General/Updates/Alias Process button, minimum interval for a fetch of custom names/numbering is 30 mins.
Add Export alternatives button to edit show.
Change improve tooltip over show title in display show for multiple alternatives.
Add display season alternatives on hover over season titles in display show.
Change single digit season display to zero-padded double digits in edit show.
Change add note on edit show for season specific search rule.
Add mark next to season titles that have exceptions.
Add support for centralised sg alternative names and numbers.
Change sg alts can overwrite scene number field only if field value is blank.
Change add note on edit show for season specific search rule.
Change add has_season_exceptions to control newznab id search.
Change add season exceptions to torrent providers.
Add env var NO_ALT_GET (1 = only use cache json).
Change give remove_file functions time to process.
Change selection of allPossibleShowNames to be all seasons not season 1.
Change add allPossibleShowNames unit test.
Change improve order of exceptions on ui.
tags/release_0.25.1
JackDandy 5 years ago
parent
commit
e98503d01c
  1. 14
      CHANGES.md
  2. 10
      gui/slick/css/style.css
  3. 21
      gui/slick/interfaces/default/config_general.tmpl
  4. 7
      gui/slick/interfaces/default/displayShow.tmpl
  5. 21
      gui/slick/interfaces/default/editShow.tmpl
  6. 28
      gui/slick/js/config.js
  7. 31
      gui/slick/js/editShow.js
  8. 10
      gui/slick/js/sceneExceptionsTooltip.js
  9. 8
      lib/_23.py
  10. 68
      lib/sg_helpers.py
  11. 16
      sickbeard/providers/generic.py
  12. 23
      sickbeard/providers/newznab.py
  13. 185
      sickbeard/scene_exceptions.py
  14. 6
      sickbeard/scene_numbering.py
  15. 2
      sickbeard/show_name_helpers.py
  16. 94
      sickbeard/webserve.py
  17. 14
      tests/scene_helpers_tests.py

14
CHANGES.md

@ -1,5 +1,19 @@
### 0.23.0 (2019-xx-xx xx:xx:xx UTC) ### 0.23.0 (2019-xx-xx xx:xx:xx UTC)
* Add config/General/Updates/Alias Process button, minimum interval for a fetch of custom names/numbering is 30 mins
* Add Export alternatives button to edit show
* Change season specific alt names now available not just for anime
* Change improve tooltip over show title in display show for multiple alternatives
* Add display season alternatives on hover over season titles in display show
* Change single digit season display to zero-padded double digits in edit show
* Change add note on edit show for season specific search rule
* Add mark next to season titles that have exceptions
* Add support for centralised sg alternative names and numbers
* Change sg alts can overwrite scene number field only if field value is blank
* Change add note on edit show for season specific search rule
* Change add has_season_exceptions to control newznab id search
* Change add season exceptions to torrent providers
* Change give remove_file functions time to process
* Add ignore folders that contain ".sickgearignore" flag file * Add ignore folders that contain ".sickgearignore" flag file
* Change add 3 days cache for tmdb base info only * Change add 3 days cache for tmdb base info only
* Change `Discordapp` to `Discord` in line with company change * Change `Discordapp` to `Discord` in line with company change

10
gui/slick/css/style.css

@ -1029,6 +1029,16 @@ home.tmpl
background-image:linear-gradient(to left, rgba(223, 218, 207, 1), rgba(223, 218, 207, 0)) background-image:linear-gradient(to left, rgba(223, 218, 207, 1), rgba(223, 218, 207, 0))
} }
.exception-divider{
margin:3px 0
}
.season-mark-exception{
font-size:12px;
vertical-align:super;
margin-right:-6px
}
.show-toggle-hide{ .show-toggle-hide{
position:absolute; position:absolute;
top:272px; top:272px;

21
gui/slick/interfaces/default/config_general.tmpl

@ -212,12 +212,27 @@
</span> </span>
</label> </label>
</div> </div>
#else
<div class="field-pair">
<span class="component-title">Built-in updates disabled</span>
<span class="component-desc"><p>using #echo $sg_var('EXT_UPDATES') or 'other'# update method instead</p>
</span>
</div>
#end if
<div class="field-pair">
<span class="component-title">Alias show names/numbers</span>
<span class="component-desc" style="line-height:20px">
<input id="alias" class="btn btn-inline" type="button" value="Process">
<p>updates for alternative show names and numbers</p>
<span id="alias-result"></span>
</span>
</div>
#if not $sg_var('EXT_UPDATES')
<input type="submit" class="btn config_submitter" value="Save Changes"> <input type="submit" class="btn config_submitter" value="Save Changes">
</fieldset>
#else
<div><p>Repo updates disabled. Using $sg_var('EXT_UPDATES') update method</p></div>
#end if #end if
</fieldset>
</div> </div>
</div><!-- /component-group1 //--> </div><!-- /component-group1 //-->

7
gui/slick/interfaces/default/displayShow.tmpl

@ -449,7 +449,7 @@
#set $scene_anime = True #set $scene_anime = True
#end if #end if
#for $season, $episodes in $seasons #for $season, $episodes, $has_season_exceptions in $seasons
#for $ep in $episodes #for $ep in $episodes
#if None is not $ep #if None is not $ep
#set $ep_str = '%sx%s' % ($season, $ep['episode']) #set $ep_str = '%sx%s' % ($season, $ep['episode'])
@ -466,6 +466,9 @@
#set $working_season = $season #set $working_season = $season
#set $human_season = ('Season %s' % $season, 'Specials')[0 == $season] #set $human_season = ('Season %s' % $season, 'Specials')[0 == $season]
#if $has_season_exceptions
#set $human_season += '<b class="season-mark-exception">*</b>'
#end if
## one off variable migration, on next version apply... (s/$getVar('display_seasons', [])/[]/), ## one off variable migration, on next version apply... (s/$getVar('display_seasons', [])/[]/),
<table class="sickbeardTable#echo '%s%s%s' % (('', ' season-min')[$season in $getVar('season_min', $getVar('display_seasons', []))], ('', ' latest-season')[$latest_season == $season], ('', ' open')[$season in $getVar('other_seasons', [])])#"> <table class="sickbeardTable#echo '%s%s%s' % (('', ' season-min')[$season in $getVar('season_min', $getVar('display_seasons', []))], ('', ' latest-season')[$latest_season == $season], ('', ' open')[$season in $getVar('other_seasons', [])])#">
<thead> <thead>
@ -484,7 +487,7 @@
#set $qual = $season_stats.get($Overview.QUAL, None) #set $qual = $season_stats.get($Overview.QUAL, None)
#set $good = $season_stats.get($Overview.GOOD, '0') #set $good = $season_stats.get($Overview.GOOD, '0')
#set $archived = False if $season not in $ep_counts['archived'] else $ep_counts['archived'][$season] #set $archived = False if $season not in $ep_counts['archived'] else $ep_counts['archived'][$season]
<h3>$human_season<a id="season-$season" name="season-$season"></a> <h3 id="season-$show_obj.tvid_prodid-$season"><span class="title">$human_season</span><a id="season-$season" name="season-$season"></a>
#if None is not $has_art #if None is not $has_art
<span class="season-status"><span class="good status-badge">&nbsp;D: <strong>$good</strong>&nbsp;</span>#if snatched#<span class="snatched status-badge">&nbsp;S: <strong>$snatched</strong>&nbsp;</span>#end if##if $wanted#<span class="wanted status-badge">&nbsp;W: <strong>$wanted</strong>&nbsp;</span>#end if##if $qual#<span class="qual status-badge">&nbsp;LQ: <strong>$qual</strong>&nbsp;</span>#end if#&nbsp;of&nbsp;<span class="footerhighlight">$ep_counts['totals'][$season]</span>#if 0 < $archived#&nbsp;with <span class="footerhighlight">$archived</span> archived#end if##if int($videos)#&nbsp;#echo ('with', 'and')[0 < $archived]#&nbsp;<span class="footerhighlight">$videos</span> file$maybe_plural($videos)#end if#</span> <span class="season-status"><span class="good status-badge">&nbsp;D: <strong>$good</strong>&nbsp;</span>#if snatched#<span class="snatched status-badge">&nbsp;S: <strong>$snatched</strong>&nbsp;</span>#end if##if $wanted#<span class="wanted status-badge">&nbsp;W: <strong>$wanted</strong>&nbsp;</span>#end if##if $qual#<span class="qual status-badge">&nbsp;LQ: <strong>$qual</strong>&nbsp;</span>#end if#&nbsp;of&nbsp;<span class="footerhighlight">$ep_counts['totals'][$season]</span>#if 0 < $archived#&nbsp;with <span class="footerhighlight">$archived</span> archived#end if##if int($videos)#&nbsp;#echo ('with', 'and')[0 < $archived]#&nbsp;<span class="footerhighlight">$videos</span> file$maybe_plural($videos)#end if#</span>
#end if #end if

21
gui/slick/interfaces/default/editShow.tmpl

@ -98,32 +98,33 @@
<span class="component-title">Alternative release name(s)</span> <span class="component-title">Alternative release name(s)</span>
<span class="component-desc"> <span class="component-desc">
<input type="text" id="SceneName" class="form-control form-control-inline input-sm input200" placeholder="Enter one title here, then 'Add'"> <input type="text" id="SceneName" class="form-control form-control-inline input-sm input200" placeholder="Enter one title here, then 'Add'">
<select id="SceneNameSeason" class="form-control form-control-inline input-sm input100" style="#echo ('visibility:hidden','float:left')[$show_obj.anime]#"> <select id="SceneNameSeason" class="form-control form-control-inline input-sm input100" style="float:left">
<option value="-1">Series</option> <option value="-1">Series</option>
#if $show_obj.anime: #for $season in $seasonResults:
#for $season in $seasonResults:
<option value="$season[0]">Season $season[0]</option> <option value="$season[0]">Season $season[0]</option>
#end for #end for
#end if
</select> </select>
<input class="btn btn-inline" type="button" value="Add" id="addSceneName"> <input class="btn btn-inline" type="button" value="Add" id="addSceneName">
<p style="float:left"><span class="add-tip">Enter one.. </span>e.g. Show, Show (2016), or The Show (US)</p> <p style="float:left"><span class="add-tip">Enter one.. </span>e.g. Show, Show (2016), or The Show (US)</p>
<p class="clear-left note">searching and post-processing require the alternatives if "Show not found" errors are in the logs</p> <p class="clear-left note">searching and post-processing require the alternatives if "Show not found" errors are in the logs</p>
</span> </span>
<span id="SceneException" class="component-desc" style="display:none"> <span id="SceneException" class="component-desc" style="display:none">
<h4 class="grey-text">Alternative a.k.a scene exceptions list (multi-selectable)</h4> <h5 class="grey-text" style="margin-bottom:5px">Alternative a.k.a scene exceptions list (multi-selectable)</h5>
<select id="exceptions_list" name="exceptions_list" multiple="multiple" class="input350" style="min-height:90px; float:left" > <select id="exceptions_list" name="exceptions_list" multiple="multiple" class="input350" style="min-height:90px; float:left" >
#for $cur_exception_season in $show_obj.exceptions: #for $cur_exception_season in $show_obj.exceptions:
#for $cur_exception in $show_obj.exceptions[$cur_exception_season]: #for $cur_exception in $show_obj.exceptions[$cur_exception_season]:
<option value="$cur_exception_season|$cur_exception">#if $show_obj.is_anime#S#echo ($cur_exception_season, '*')[$cur_exception_season == -1]#: #end if#$cur_exception</option> <option value="$cur_exception_season|$cur_exception">S#echo ('%02d' % $cur_exception_season, '*')[$cur_exception_season == -1]#: $cur_exception</option>
#end for #end for
#end for #end for
</select> </select>
<span><p class="note">#if $show_obj.is_anime#S* = Any series. #end if#The original name is used along<br />with this case insensitive list</p></span> <span><p class="note">S* = Any series. The original name is used along with this case insensitive list, except where season is specified</p></span>
<div> <div style="margin-top:6px">
<input id="removeSceneName" value="Remove" class="btn pull-left" type="button" style="margin-top: 10px;"/> <input id="removeSceneName" value="Remove" class="btn pull-left" type="button">
</div> </div>
</span> </span>
<span class="component-desc" style="clear:both;padding-top:10px">
<input id="export-alts" value="Export" class="btn btn-inline" type="button"><p style="float:left">alternative names and/or numbers</p>
</span>
</span> </span>
</div> </div>

28
gui/slick/js/config.js

@ -43,6 +43,34 @@ $(document).ready(function () {
toggle$(this, 0 < $(this).find('option:selected').val()); toggle$(this, 0 < $(this).find('option:selected').val());
}); });
/** @namespace data.names */
/** @namespace data.numbers */
/** @namespace data.min_remain_iv */
$('input#alias').on('click', function() {
var result$ = $('#alias-result'), that$ = $(this);
that$.attr('disabled', 'disabled');
result$.html('checking for updates...');
$.getJSON(sbRoot + '/config/general/update-alt',
function (data) {
var output = 'checked, ', remain;
result$.removeClass('grey-text');
if (data.names) {
output += 'new alias names found';
result$.addClass('grey-text');
} else if (!data.numbers) {
output += 'no updates found';
}
if (data.numbers) {
output += (data.names ? ' and ' : '') + data.numbers + ' alternative numbers updated';
result$.addClass('grey-text');
}
remain = data.min_remain_iv/60;
output += ', wait ' + parseInt(remain) + 'm before next fetch process'
result$.html(output);
that$.removeAttr('disabled');
});
});
var idSelect = '#imdb-accounts', idDel = '#imdb-list-del', idInput = '#imdb-url', idOnOff = '#imdb-list-onoff', var idSelect = '#imdb-accounts', idDel = '#imdb-list-del', idInput = '#imdb-url', idOnOff = '#imdb-list-onoff',
sel = 'selected', opt = 'option', selOpt = [opt, sel].join(':'), sel = 'selected', opt = 'option', selOpt = [opt, sel].join(':'),
elDropDown = $(idSelect), elDel = $(idDel), elInput = $(idInput), elOnOff = $(idOnOff); elDropDown = $(idSelect), elDel = $(idDel), elInput = $(idInput), elOnOff = $(idOnOff);

31
gui/slick/js/editShow.js

@ -7,6 +7,10 @@
$(document).ready(function () { $(document).ready(function () {
$('#location').fileBrowser({title: 'Select Show Location'}); $('#location').fileBrowser({title: 'Select Show Location'});
String.prototype.padLeft = function padLeft(length, leadingChar) {
if (undefined === leadingChar) leadingChar = '0';
return this.length < length ? (leadingChar + this).padLeft(length, leadingChar) : this;
};
function htmlFlag(lang) { function htmlFlag(lang) {
return ' class="flag" style="background-image:url(' + $.SickGear.Root + '/images/flags/' + lang + '.png)"' return ' class="flag" style="background-image:url(' + $.SickGear.Root + '/images/flags/' + lang + '.png)"'
@ -71,7 +75,7 @@ $(document).ready(function () {
if (null === sceneExSeason) if (null === sceneExSeason)
sceneExSeason = '-1'; sceneExSeason = '-1';
option.val(sceneExSeason + '|' + sceneEx); option.val(sceneExSeason + '|' + sceneEx);
option.html((config.showIsAnime ? 'S' + ('-1' === sceneExSeason ? '*' : sceneExSeason) + ': ' : '') + sceneEx); option.html(('S' + ('-1' === sceneExSeason ? '*' : sceneExSeason.padLeft(2)) + ': ') + sceneEx);
return option.appendTo($('#exceptions_list')); return option.appendTo($('#exceptions_list'));
}); });
@ -82,6 +86,31 @@ $(document).ready(function () {
$(this).toggle_SceneException(); $(this).toggle_SceneException();
}); });
/** @namespace data.text */
$('#export-alts').on('click', function (e) {
e.preventDefault();
var that$ = $(this);
that$.attr('disabled', 'disabled');
$.getJSON(sbRoot + '/config/general/export-alt', {'tvid_prodid': $('#tvid_prodid').val()},
function (data) {
if (data.text) {
$.confirm({
'title' : 'Export names/numbers',
'message' : 'Copy/paste the following for export...' +
'<div><pre style="width:95%;margin:0 auto;max-height:250px">' + data.text + '</pre></div>',
'buttons' : {
'close' : {
'class' : 'green',
'action': function(){} // Nothing to do in this case. You can as well omit the action property.
}
}
});
}
that$.removeAttr('disabled');
});
});
$.fn.toggle_SceneException = function () { $.fn.toggle_SceneException = function () {
var elSceneException = $('#SceneException'); var elSceneException = $('#SceneException');

10
gui/slick/js/sceneExceptionsTooltip.js

@ -1,6 +1,9 @@
$(function () { $(function () {
$('.title span').each(function () { $('.title span, [id^="season"] .title').each(function () {
var match = $(this).parent().attr('id').match(/^scene_exception_(.*)$/); var match = $(this).parent().attr('id').match(/^scene_exception_(.*)$/)
if (undefined == typeof (match) || !match) {
match = $(this).parent().attr('id').match(/^season-([^-]+)-(\d+)$/);
}
$(this).qtip({ $(this).qtip({
content: { content: {
text: function(event, api) { text: function(event, api) {
@ -9,7 +12,8 @@ $(function () {
url: $.SickGear.Root + '/home/scene-exceptions', url: $.SickGear.Root + '/home/scene-exceptions',
type: 'GET', type: 'GET',
data: { data: {
tvid_prodid: match[1] tvid_prodid: match[1],
wanted_season: 3 === match.length ? match[2] : ''
} }
}) })
.then(function(content) { .then(function(content) {

8
lib/_23.py

@ -155,9 +155,11 @@ if 2 != version_info[0]:
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
from subprocess import Popen from subprocess import Popen
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences, PyPep8Naming
import xml.etree.ElementTree as etree import xml.etree.ElementTree as etree
ordered_dict = dict
native_timestamp = datetime.datetime.timestamp # type: Callable[[datetime.datetime], float] native_timestamp = datetime.datetime.timestamp # type: Callable[[datetime.datetime], float]
def unquote(string, encoding='utf-8', errors='replace'): def unquote(string, encoding='utf-8', errors='replace'):
@ -237,6 +239,9 @@ else:
# noinspection PyPep8Naming # noinspection PyPep8Naming
import xml.etree.ElementTree as etree import xml.etree.ElementTree as etree
from collections import OrderedDict
ordered_dict = OrderedDict
def _totimestamp(dt=None): def _totimestamp(dt=None):
# type: (datetime.datetime) -> float # type: (datetime.datetime) -> float
""" This function should only be used in this module due to its 1970s+ limitation as that's all we need here and """ This function should only be used in this module due to its 1970s+ limitation as that's all we need here and
@ -247,6 +252,7 @@ else:
native_timestamp = _totimestamp # type: Callable[[datetime.datetime], float] native_timestamp = _totimestamp # type: Callable[[datetime.datetime], float]
from subprocess import Popen as _Popen from subprocess import Popen as _Popen
class Popen(_Popen): class Popen(_Popen):
def __enter__(self): def __enter__(self):

68
lib/sg_helpers.py

@ -16,6 +16,7 @@ import stat
import subprocess import subprocess
import tempfile import tempfile
import threading import threading
import time
import traceback import traceback
from exceptions_helper import ex, ConnectionSkipException from exceptions_helper import ex, ConnectionSkipException
@ -28,7 +29,7 @@ from send2trash import send2trash
import encodingKludge as ek import encodingKludge as ek
import requests import requests
from _23 import decode_bytes, filter_list, html_unescape, urlparse, urlsplit, urlunparse from _23 import decode_bytes, filter_list, html_unescape, list_range, urlparse, urlsplit, urlunparse
from six import integer_types, iteritems, iterkeys, itervalues, PY2, string_types, text_type from six import integer_types, iteritems, iterkeys, itervalues, PY2, string_types, text_type
# noinspection PyUnreachableCode # noinspection PyUnreachableCode
@ -971,19 +972,6 @@ def file_bit_filter(mode):
return mode return mode
def remove_file_failed(filename):
"""
delete given file
:param filename: filename
:type filename: AnyStr
"""
try:
ek.ek(os.remove, filename)
except (BaseException, Exception):
pass
def chmod_as_parent(child_path): def chmod_as_parent(child_path):
""" """
@ -1138,6 +1126,26 @@ def move_file(src_file, dest_file):
ek.ek(os.unlink, src_file) ek.ek(os.unlink, src_file)
def remove_file_failed(filepath):
"""
Remove file
:param filepath: Path and file name
:type filepath: AnyStr
"""
for t in list_range(10): # total seconds to wait 0 - 9 = 45s over 10 iterations
try:
ek.ek(os.remove, filepath)
except OSError as e:
if getattr(e, 'winerror', 0) not in (5, 32): # 5=access denied (e.g. av), 32=another process has lock
break
except (BaseException, Exception):
pass
time.sleep(t)
if not ek.ek(os.path.exists, filepath):
break
def remove_file(filepath, tree=False, prefix_failure='', log_level=logging.INFO): def remove_file(filepath, tree=False, prefix_failure='', log_level=logging.INFO):
""" """
Remove file based on setting for trash v permanent delete Remove file based on setting for trash v permanent delete
@ -1155,19 +1163,25 @@ def remove_file(filepath, tree=False, prefix_failure='', log_level=logging.INFO)
""" """
result = None result = None
if filepath: if filepath:
try: for t in list_range(10): # total seconds to wait 0 - 9 = 45s over 10 iterations
result = 'Deleted' try:
if TRASH_REMOVE_SHOW: result = 'Deleted'
result = 'Trashed' if TRASH_REMOVE_SHOW:
ek.ek(send2trash, filepath) result = 'Trashed'
elif tree: ek.ek(send2trash, filepath)
ek.ek(shutil.rmtree, filepath) elif tree:
else: ek.ek(shutil.rmtree, filepath)
ek.ek(os.remove, filepath) else:
except OSError as e: ek.ek(os.remove, filepath)
logger.log(level=log_level, msg=u'%sUnable to %s %s %s: %s' % except OSError as e:
(prefix_failure, ('delete', 'trash')[TRASH_REMOVE_SHOW], if getattr(e, 'winerror', 0) not in (5, 32): # 5=access denied (e.g. av), 32=another process has lock
('file', 'dir')[tree], filepath, ex(e))) logger.log(level=log_level, msg=u'%sUnable to %s %s %s: %s' %
(prefix_failure, ('delete', 'trash')[TRASH_REMOVE_SHOW],
('file', 'dir')[tree], filepath, ex(e)))
break
time.sleep(t)
if not ek.ek(os.path.exists, filepath):
break
return (None, result)[filepath and not ek.ek(os.path.exists, filepath)] return (None, result)[filepath and not ek.ek(os.path.exists, filepath)]

16
sickbeard/providers/generic.py

@ -41,6 +41,7 @@ from ..classes import NZBSearchResult, TorrentSearchResult, SearchResult
from ..common import Quality, MULTI_EP_RESULT, SEASON_RESULT, USER_AGENT from ..common import Quality, MULTI_EP_RESULT, SEASON_RESULT, USER_AGENT
from ..helpers import maybe_plural, remove_file_failed from ..helpers import maybe_plural, remove_file_failed
from ..name_parser.parser import InvalidNameException, InvalidShowException, NameParser from ..name_parser.parser import InvalidNameException, InvalidShowException, NameParser
from ..scene_exceptions import has_season_exceptions
from ..show_name_helpers import get_show_names_all_possible from ..show_name_helpers import get_show_names_all_possible
from ..sgdatetime import SGDatetime, timestamp_near from ..sgdatetime import SGDatetime, timestamp_near
from ..tv import TVEpisode, TVShow from ..tv import TVEpisode, TVShow
@ -1652,6 +1653,7 @@ class TorrentProvider(GenericProvider):
return [] return []
show_obj = ep_obj.show_obj show_obj = ep_obj.show_obj
season = (-1, ep_obj.season)[has_season_exceptions(ep_obj.show_obj.tvid, ep_obj.show_obj.prodid, ep_obj.season)]
ep_dict = self._ep_dict(ep_obj) ep_dict = self._ep_dict(ep_obj)
sp_detail = (show_obj.air_by_date or show_obj.is_sports) and str(ep_obj.airdate).split('-')[0] or \ sp_detail = (show_obj.air_by_date or show_obj.is_sports) and str(ep_obj.airdate).split('-')[0] or \
(show_obj.is_anime and ep_obj.scene_absolute_number or (show_obj.is_anime and ep_obj.scene_absolute_number or
@ -1659,7 +1661,8 @@ class TorrentProvider(GenericProvider):
sp_detail = ([sp_detail], sp_detail)[isinstance(sp_detail, list)] sp_detail = ([sp_detail], sp_detail)[isinstance(sp_detail, list)]
detail = ({}, {'Season_only': sp_detail})[detail_only detail = ({}, {'Season_only': sp_detail})[detail_only
and not self.show_obj.is_sports and not self.show_obj.is_anime] and not self.show_obj.is_sports and not self.show_obj.is_anime]
return [dict(itertools.chain(iteritems({'Season': self._build_search_strings(sp_detail, scene, prefix)}), return [dict(itertools.chain(iteritems({'Season': self._build_search_strings(sp_detail, scene, prefix,
season=season)}),
iteritems(detail)))] iteritems(detail)))]
def _episode_strings(self, def _episode_strings(self,
@ -1686,6 +1689,7 @@ class TorrentProvider(GenericProvider):
return [] return []
show_obj = ep_obj.show_obj show_obj = ep_obj.show_obj
season = (-1, ep_obj.season)[has_season_exceptions(ep_obj.show_obj.tvid, ep_obj.show_obj.prodid, ep_obj.season)]
if show_obj.air_by_date or show_obj.is_sports: if show_obj.air_by_date or show_obj.is_sports:
ep_detail = [str(ep_obj.airdate).replace('-', sep_date)]\ ep_detail = [str(ep_obj.airdate).replace('-', sep_date)]\
if 'date_detail' not in kwargs else kwargs['date_detail'](ep_obj.airdate) if 'date_detail' not in kwargs else kwargs['date_detail'](ep_obj.airdate)
@ -1703,7 +1707,8 @@ class TorrentProvider(GenericProvider):
ep_detail = ([ep_detail], ep_detail)[isinstance(ep_detail, list)] + ['%d' % ep_dict['episodenumber']] ep_detail = ([ep_detail], ep_detail)[isinstance(ep_detail, list)] + ['%d' % ep_dict['episodenumber']]
ep_detail = ([ep_detail], ep_detail)[isinstance(ep_detail, list)] ep_detail = ([ep_detail], ep_detail)[isinstance(ep_detail, list)]
detail = ({}, {'Episode_only': ep_detail})[detail_only and not show_obj.is_sports and not show_obj.is_anime] detail = ({}, {'Episode_only': ep_detail})[detail_only and not show_obj.is_sports and not show_obj.is_anime]
return [dict(itertools.chain(iteritems({'Episode': self._build_search_strings(ep_detail, scene, prefix)}), return [dict(itertools.chain(iteritems({'Episode': self._build_search_strings(ep_detail, scene, prefix,
season=season)}),
iteritems(detail)))] iteritems(detail)))]
@staticmethod @staticmethod
@ -1718,8 +1723,8 @@ class TorrentProvider(GenericProvider):
(ep_obj.scene_season, ep_obj.scene_episode))[bool(ep_obj.show_obj.is_scene)] (ep_obj.scene_season, ep_obj.scene_episode))[bool(ep_obj.show_obj.is_scene)]
return {'seasonnumber': season, 'episodenumber': episode} return {'seasonnumber': season, 'episodenumber': episode}
def _build_search_strings(self, ep_detail, process_name=True, prefix=''): def _build_search_strings(self, ep_detail, process_name=True, prefix='', season=-1):
# type: (Union[List[AnyStr], AnyStr], bool, AnyStr) -> List[AnyStr] # type: (Union[List[AnyStr], AnyStr], bool, AnyStr, int) -> List[AnyStr]
""" """
Build a list of search strings for querying a provider Build a list of search strings for querying a provider
:param ep_detail: String of episode detail or List of episode details :param ep_detail: String of episode detail or List of episode details
@ -1733,7 +1738,8 @@ class TorrentProvider(GenericProvider):
search_params = [] search_params = []
crop = re.compile(r'([.\s])(?:\1)+') crop = re.compile(r'([.\s])(?:\1)+')
for name in get_show_names_all_possible(self.show_obj, scenify=process_name and getattr(self, 'scene', True)): for name in get_show_names_all_possible(self.show_obj, scenify=process_name and getattr(self, 'scene', True),
season=season):
for detail in ep_detail: for detail in ep_detail:
search_params += [crop.sub(r'\1', '%s %s%s' % (name, x, detail)) for x in prefix] search_params += [crop.sub(r'\1', '%s %s%s' % (name, x, detail)) for x in prefix]
return search_params return search_params

23
sickbeard/providers/newznab.py

@ -36,6 +36,7 @@ from ..network_timezones import SG_TIMEZONE
from ..sgdatetime import SGDatetime, timestamp_near from ..sgdatetime import SGDatetime, timestamp_near
from ..search import get_aired_in_season, get_wanted_qualities from ..search import get_aired_in_season, get_wanted_qualities
from ..show_name_helpers import get_show_names from ..show_name_helpers import get_show_names
from ..scene_exceptions import has_season_exceptions
from ..tv import TVEpisode, TVShow from ..tv import TVEpisode, TVShow
from lib.dateutil import parser from lib.dateutil import parser
@ -457,11 +458,12 @@ class NewznabProvider(generic.NZBProvider):
# id search # id search
params = base_params.copy() params = base_params.copy()
use_id = False use_id = False
for i in sickbeard.TVInfoAPI().all_sources: if not has_season_exceptions(ep_obj.show_obj.tvid, ep_obj.show_obj.prodid, ep_obj.season):
if i in ep_obj.show_obj.ids and 0 < ep_obj.show_obj.ids[i]['id'] and i in self.caps: for i in sickbeard.TVInfoAPI().all_sources:
params[self.caps[i]] = ep_obj.show_obj.ids[i]['id'] if i in ep_obj.show_obj.ids and 0 < ep_obj.show_obj.ids[i]['id'] and i in self.caps:
use_id = True params[self.caps[i]] = ep_obj.show_obj.ids[i]['id']
use_id and search_params.append(params) use_id = True
use_id and search_params.append(params)
spacer = 'nzbgeek.info' in self.url.lower() and ' ' or '.' spacer = 'nzbgeek.info' in self.url.lower() and ' ' or '.'
# query search and exceptions # query search and exceptions
@ -516,11 +518,12 @@ class NewznabProvider(generic.NZBProvider):
# id search # id search
params = base_params.copy() params = base_params.copy()
use_id = False use_id = False
for i in sickbeard.TVInfoAPI().all_sources: if not has_season_exceptions(ep_obj.show_obj.tvid, ep_obj.show_obj.prodid, ep_obj.season):
if i in ep_obj.show_obj.ids and 0 < ep_obj.show_obj.ids[i]['id'] and i in self.caps: for i in sickbeard.TVInfoAPI().all_sources:
params[self.caps[i]] = ep_obj.show_obj.ids[i]['id'] if i in ep_obj.show_obj.ids and 0 < ep_obj.show_obj.ids[i]['id'] and i in self.caps:
use_id = True params[self.caps[i]] = ep_obj.show_obj.ids[i]['id']
use_id and search_params.append(params) use_id = True
use_id and search_params.append(params)
spacer = 'nzbgeek.info' in self.url.lower() and ' ' or '.' spacer = 'nzbgeek.info' in self.url.lower() and ' ' or '.'
# query search and exceptions # query search and exceptions

185
sickbeard/scene_exceptions.py

@ -19,25 +19,33 @@
from collections import defaultdict from collections import defaultdict
import datetime import datetime
import io
import os
import re import re
import sys
import threading import threading
import time
import traceback import traceback
import sickbeard import sickbeard
# noinspection PyPep8Naming
import encodingKludge as ek
from exceptions_helper import ex
from . import db, helpers, logger, name_cache from . import db, helpers, logger, name_cache
from .anime import create_anidb_obj from .anime import create_anidb_obj
from .classes import OrderedDefaultdict from .classes import OrderedDefaultdict
from .helpers import json
from .indexers.indexer_config import TVINFO_TVDB from .indexers.indexer_config import TVINFO_TVDB
from .sgdatetime import timestamp_near from .sgdatetime import timestamp_near
from _23 import filter_iter, map_iter import lib.rarfile.rarfile as rarfile
from _23 import filter_iter, list_range, map_iter
from six import iteritems, PY2, text_type from six import iteritems, PY2, text_type
# noinspection PyUnreachableCode # noinspection PyUnreachableCode
if False: if False:
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
from typing import AnyStr, List, Tuple from typing import AnyStr, List, Tuple, Union
exception_dict = {} exception_dict = {}
anidb_exception_dict = {} anidb_exception_dict = {}
@ -50,20 +58,22 @@ exceptionsSeasonCache = {}
exceptionLock = threading.Lock() exceptionLock = threading.Lock()
def should_refresh(name): def should_refresh(name, max_refresh_age_secs=86400, remaining=False):
# type: (AnyStr, int, bool) -> Union[bool, int]
""" """
:param name: name :param name: name
:type name: AnyStr :param max_refresh_age_secs:
:param remaining: True to return remaining seconds
:return: :return:
:rtype: bool
""" """
max_refresh_age_secs = 86400 # 1 day
my_db = db.DBConnection() my_db = db.DBConnection()
rows = my_db.select('SELECT last_refreshed FROM scene_exceptions_refresh WHERE list = ?', [name]) rows = my_db.select('SELECT last_refreshed FROM scene_exceptions_refresh WHERE list = ?', [name])
if rows: if rows:
last_refresh = int(rows[0]['last_refreshed']) last_refresh = int(rows[0]['last_refreshed'])
if remaining:
time_left = (last_refresh + max_refresh_age_secs - int(timestamp_near(datetime.datetime.now())))
return (0, time_left)[time_left > 0]
return int(timestamp_near(datetime.datetime.now())) > last_refresh + max_refresh_age_secs return int(timestamp_near(datetime.datetime.now())) > last_refresh + max_refresh_age_secs
return True return True
@ -80,6 +90,13 @@ def set_last_refresh(name):
{'list': name}) {'list': name})
def has_season_exceptions(tvid, prodid, season):
get_scene_exceptions(tvid, prodid, season=season)
if (tvid, prodid) in exceptionsCache and -1 < season and season in exceptionsCache[(tvid, prodid)]:
return True
return False
def get_scene_exceptions(tvid, prodid, season=-1): def get_scene_exceptions(tvid, prodid, season=-1):
""" """
Given a indexer_id, return a list of all the scene exceptions. Given a indexer_id, return a list of all the scene exceptions.
@ -133,11 +150,19 @@ def get_all_scene_exceptions(tvid_prodid):
exceptions = my_db.select('SELECT show_name,season' exceptions = my_db.select('SELECT show_name,season'
' FROM scene_exceptions' ' FROM scene_exceptions'
' WHERE indexer = ? AND indexer_id = ?' ' WHERE indexer = ? AND indexer_id = ?'
' ORDER BY season', ' ORDER BY season DESC, show_name DESC',
TVidProdid(tvid_prodid).list) TVidProdid(tvid_prodid).list)
exceptions_seasons = []
if exceptions: if exceptions:
for cur_exception in exceptions: for cur_exception in exceptions:
# order as, s*, and then season desc, show_name also desc (so years in names may fall newest on top)
if -1 == cur_exception['season']:
exceptions_dict[cur_exception['season']].append(cur_exception['show_name'])
else:
exceptions_seasons += [cur_exception]
for cur_exception in exceptions_seasons:
exceptions_dict[cur_exception['season']].append(cur_exception['show_name']) exceptions_dict[cur_exception['season']].append(cur_exception['show_name'])
return exceptions_dict return exceptions_dict
@ -257,6 +282,14 @@ def retrieve_exceptions():
else: else:
exception_dict[anidb_ex] = anidb_exception_dict[anidb_ex] exception_dict[anidb_ex] = anidb_exception_dict[anidb_ex]
# Custom exceptions
custom_exception_dict, cnt_updated_numbers, min_remain_iv = _custom_exceptions_fetcher()
for custom_ex in custom_exception_dict:
if custom_ex in exception_dict:
exception_dict[custom_ex] = exception_dict[custom_ex] + custom_exception_dict[custom_ex]
else:
exception_dict[custom_ex] = custom_exception_dict[custom_ex]
changed_exceptions = False changed_exceptions = False
# write all the exceptions we got off the net into the database # write all the exceptions we got off the net into the database
@ -265,16 +298,14 @@ def retrieve_exceptions():
for cur_tvid_prodid in exception_dict: for cur_tvid_prodid in exception_dict:
# get a list of the existing exceptions for this ID # get a list of the existing exceptions for this ID
existing_exceptions = [x['show_name'] for x in existing_exceptions = [{x['show_name']: x['season']} for x in
my_db.select('SELECT show_name' my_db.select('SELECT show_name, season'
' FROM scene_exceptions' ' FROM scene_exceptions'
' WHERE indexer = ? AND indexer_id = ?', ' WHERE indexer = ? AND indexer_id = ?',
list(cur_tvid_prodid))] list(cur_tvid_prodid))]
if cur_tvid_prodid not in exception_dict: # if this exception isn't already in the DB then add it
continue for cur_exception_dict in filter_iter(lambda e: e not in existing_exceptions, exception_dict[cur_tvid_prodid]):
for cur_exception_dict in exception_dict[cur_tvid_prodid]:
try: try:
cur_exception, cur_season = next(iteritems(cur_exception_dict)) cur_exception, cur_season = next(iteritems(cur_exception_dict))
except (BaseException, Exception): except (BaseException, Exception):
@ -282,16 +313,13 @@ def retrieve_exceptions():
logger.log(traceback.format_exc(), logger.ERROR) logger.log(traceback.format_exc(), logger.ERROR)
continue continue
# if this exception isn't already in the DB then add it if PY2 and not isinstance(cur_exception, text_type):
if cur_exception not in existing_exceptions: cur_exception = text_type(cur_exception, 'utf-8', 'replace')
if PY2 and not isinstance(cur_exception, text_type):
cur_exception = text_type(cur_exception, 'utf-8', 'replace')
cl.append(['INSERT INTO scene_exceptions' cl.append(['INSERT INTO scene_exceptions'
' (indexer, indexer_id, show_name, season) VALUES (?,?,?,?)', ' (indexer, indexer_id, show_name, season) VALUES (?,?,?,?)',
list(cur_tvid_prodid) + [cur_exception, cur_season]]) list(cur_tvid_prodid) + [cur_exception, cur_season]])
changed_exceptions = True changed_exceptions = True
if cl: if cl:
my_db.mass_action(cl) my_db.mass_action(cl)
@ -308,6 +336,8 @@ def retrieve_exceptions():
anidb_exception_dict.clear() anidb_exception_dict.clear()
xem_exception_dict.clear() xem_exception_dict.clear()
return changed_exceptions, cnt_updated_numbers, min_remain_iv
def update_scene_exceptions(tvid, prodid, scene_exceptions): def update_scene_exceptions(tvid, prodid, scene_exceptions):
""" """
@ -331,6 +361,12 @@ def update_scene_exceptions(tvid, prodid, scene_exceptions):
logger.log(u'Updating scene exceptions', logger.MESSAGE) logger.log(u'Updating scene exceptions', logger.MESSAGE)
for exception in scene_exceptions: for exception in scene_exceptions:
cur_season, cur_exception = exception.split('|', 1) cur_season, cur_exception = exception.split('|', 1)
try:
cur_season = int(cur_season)
except (BaseException, Exception):
logger.log('invalid scene exception: %s - %s:%s' % ('%s:%s' % (tvid, prodid), cur_season, cur_exception),
logger.ERROR)
continue
exceptionsCache[(tvid, prodid)][cur_season].append(cur_exception) exceptionsCache[(tvid, prodid)][cur_season].append(cur_exception)
@ -344,6 +380,107 @@ def update_scene_exceptions(tvid, prodid, scene_exceptions):
sickbeard.name_cache.buildNameCache(update_only_scene=True) sickbeard.name_cache.buildNameCache(update_only_scene=True)
def _custom_exceptions_fetcher():
custom_exception_dict = {}
cnt_updated_numbers = 0
src_id = 'GHSG'
logger.log(u'Checking to update custom alternatives from %s' % src_id)
dirpath = ek.ek(os.path.join, sickbeard.CACHE_DIR, 'alts')
tmppath = ek.ek(os.path.join, dirpath, 'tmp')
file_rar = ek.ek(os.path.join, tmppath, 'alt.rar')
file_cache = ek.ek(os.path.join, dirpath, 'alt.json')
iv = 30 * 60 # min interval to fetch updates
refresh = should_refresh(src_id, iv)
fetch_data = not ek.ek(os.path.isfile, file_cache) or (not int(os.environ.get('NO_ALT_GET', 0)) and refresh)
if fetch_data:
if ek.ek(os.path.exists, tmppath):
helpers.remove_file(tmppath, tree=True)
helpers.make_dirs(tmppath)
helpers.download_file(r'https://github.com/SickGear/sickgear.altdata/raw/master/alt.rar', file_rar)
rar_handle = None
if 'win32' == sys.platform:
rarfile.UNRAR_TOOL = ek.ek(os.path.join, sickbeard.PROG_DIR, 'lib', 'rarfile', 'UnRAR.exe')
try:
rar_handle = rarfile.RarFile(file_rar)
rar_handle.extractall(path=dirpath, pwd='sickgear_alt')
except(BaseException, Exception) as e:
logger.log(u'Failed to unpack archive: %s with error: %s' % (file_rar, ex(e)), logger.ERROR)
if rar_handle:
rar_handle.close()
del rar_handle
helpers.remove_file(tmppath, tree=True)
if refresh:
set_last_refresh(src_id)
data = {}
try:
with io.open(file_cache) as fh:
data = json.load(fh)
except(BaseException, Exception) as e:
logger.log(u'Failed to unpack json data: %s with error: %s' % (file_rar, ex(e)), logger.ERROR)
# handle data
from .scene_numbering import find_scene_numbering, set_scene_numbering_helper
from .tv import TVidProdid
for tvid_prodid, season_data in iteritems(data):
show_obj = sickbeard.helpers.find_show_by_id(tvid_prodid, no_mapped_ids=True)
if not show_obj:
continue
used = set()
for for_season, data in iteritems(season_data):
for_season = helpers.try_int(for_season, None)
tvid, prodid = TVidProdid(tvid_prodid).tuple
if data.get('n'): # alt names
custom_exception_dict.setdefault((tvid, prodid), [])
custom_exception_dict[(tvid, prodid)] += [{name: for_season} for name in data.get('n')]
for update in data.get('se') or []:
for for_episode, se_range in iteritems(update): # scene episode alt numbers
for_episode = helpers.try_int(for_episode, None)
target_season, episode_range = se_range.split('x')
scene_episodes = [int(x) for x in episode_range.split('-') if None is not helpers.try_int(x, None)]
if 2 == len(scene_episodes):
desc = scene_episodes[0] > scene_episodes[1]
if desc: # handle a descending range case
scene_episodes.reverse()
scene_episodes = list_range(*[scene_episodes[0], scene_episodes[1] + 1])
if desc:
scene_episodes.reverse()
target_season = helpers.try_int(target_season, None)
for target_episode in scene_episodes:
sn = find_scene_numbering(tvid, prodid, for_season, for_episode)
used.add((for_season, for_episode, target_season, target_episode))
if sn and ((for_season, for_episode) + sn) not in used \
and (for_season, for_episode) not in used:
logger.log(
u'Skipped setting "%s" episode %sx%s to target a release %sx%s because set to %sx%s'
% (show_obj.name, for_season, for_episode, target_season, target_episode, sn[0], sn[1]),
logger.DEBUG)
else:
used.add((for_season, for_episode))
if not sn or sn != (target_season, target_episode): # not already set
result = set_scene_numbering_helper(
tvid, prodid, for_season=for_season, for_episode=for_episode,
scene_season=target_season, scene_episode=target_episode)
if result.get('success'):
cnt_updated_numbers += 1
for_episode = for_episode + 1
return custom_exception_dict, cnt_updated_numbers, should_refresh(src_id, iv, remaining=True)
def _anidb_exceptions_fetcher(): def _anidb_exceptions_fetcher():
global anidb_exception_dict global anidb_exception_dict

6
sickbeard/scene_numbering.py

@ -21,7 +21,6 @@
# @copyright: Dermot Buckley # @copyright: Dermot Buckley
# #
import time
import datetime import datetime
import traceback import traceback
@ -1015,6 +1014,11 @@ def set_scene_numbering_helper(tvid, prodid, for_season=None, for_episode=None,
logger.log(action_log, logger.DEBUG) logger.log(action_log, logger.DEBUG)
set_scene_numbering(**scene_args) set_scene_numbering(**scene_args)
show_obj.flush_episodes() show_obj.flush_episodes()
if None is scene_season and None is scene_episode:
# when clearing the field, do not return existing values of sxe, otherwise this may be confusing
# with the case where manually setting sxe to the actual sxe is done to prevent a data overwrite.
# So now the only instance an actual sxe is in the field is if user enters it, else 0x0 is presented.
return result
else: else:
result['errorMessage'] = "Episode couldn't be retrieved, invalid parameters" result['errorMessage'] = "Episode couldn't be retrieved, invalid parameters"

2
sickbeard/show_name_helpers.py

@ -396,7 +396,7 @@ def allPossibleShowNames(show_obj, season=-1):
season = -1 season = -1
showNames = get_scene_exceptions(show_obj.tvid, show_obj.prodid, season=season)[:] showNames = get_scene_exceptions(show_obj.tvid, show_obj.prodid, season=season)[:]
if season in [-1, 1]: if -1 == season:
showNames.append(show_obj.name) showNames.append(show_obj.name)
if not show_obj.is_anime: if not show_obj.is_anime:

94
sickbeard/webserve.py

@ -93,7 +93,7 @@ from tvinfo_base import BaseTVinfoException
import lib.rarfile.rarfile as rarfile import lib.rarfile.rarfile as rarfile
from _23 import decode_bytes, decode_str, filter_list, filter_iter, getargspec, list_values, \ from _23 import decode_bytes, decode_str, filter_list, filter_iter, getargspec, list_values, \
map_consume, map_iter, map_list, map_none, quote_plus, unquote_plus, urlparse map_consume, map_iter, map_list, map_none, ordered_dict, quote_plus, unquote_plus, urlparse
from six import binary_type, integer_types, iteritems, iterkeys, itervalues, PY2, string_types from six import binary_type, integer_types, iteritems, iterkeys, itervalues, PY2, string_types
# noinspection PyUnreachableCode # noinspection PyUnreachableCode
@ -2110,7 +2110,8 @@ class Home(MainHandler):
' WHERE indexer = ? AND showid = ?' ' WHERE indexer = ? AND showid = ?'
' AND season = ?' ' AND season = ?'
' ORDER BY episode DESC', ' ORDER BY episode DESC',
[show_obj.tvid, show_obj.prodid, x]))] [show_obj.tvid, show_obj.prodid, x]
), scene_exceptions.has_season_exceptions(show_obj.tvid, show_obj.prodid, x))]
for row in my_db.select('SELECT season, episode, status' for row in my_db.select('SELECT season, episode, status'
' FROM tv_episodes' ' FROM tv_episodes'
@ -2275,16 +2276,19 @@ class Home(MainHandler):
return 'Episode not found.' if not sql_result else (sql_result[0]['description'] or '')[:250:] return 'Episode not found.' if not sql_result else (sql_result[0]['description'] or '')[:250:]
@staticmethod @staticmethod
def scene_exceptions(tvid_prodid): def scene_exceptions(tvid_prodid, wanted_season=None):
exceptionsList = sickbeard.scene_exceptions.get_all_scene_exceptions(tvid_prodid) exceptionsList = sickbeard.scene_exceptions.get_all_scene_exceptions(tvid_prodid)
if not exceptionsList: wanted_season = helpers.try_int(wanted_season, None)
return 'No scene exceptions' wanted_not_found = None is not wanted_season and wanted_season not in exceptionsList
if not exceptionsList or wanted_not_found:
return ('No scene exceptions', 'No season exceptions')[wanted_not_found]
out = [] out = []
for season, names in iter(sorted(iteritems(exceptionsList))): for season, names in iter(sorted(iteritems(exceptionsList))):
out.append('S%s: %s' % ((season, '*')[-1 == season], ',<br />\n'.join(names))) if None is wanted_season or wanted_season == season:
return '---------<br />\n'.join(out) out.append('S%s: %s' % (('%02d' % season, '*')[-1 == season], ',<br>\n'.join(names)))
return '\n<hr class="exception-divider">\n'.join(out)
@staticmethod @staticmethod
def switch_infosrc(prodid, tvid, m_prodid, m_tvid, set_pause=False, mark_wanted=False): def switch_infosrc(prodid, tvid, m_prodid, m_tvid, set_pause=False, mark_wanted=False):
@ -2498,7 +2502,7 @@ class Home(MainHandler):
'SELECT DISTINCT season' 'SELECT DISTINCT season'
' FROM tv_episodes' ' FROM tv_episodes'
' WHERE indexer = ? AND showid = ?' ' WHERE indexer = ? AND showid = ?'
' ORDER BY season ASC', ' ORDER BY season DESC',
[show_obj.tvid, show_obj.prodid]) [show_obj.tvid, show_obj.prodid])
if show_obj.is_anime: if show_obj.is_anime:
@ -6748,6 +6752,80 @@ class ConfigGeneral(Config):
return t.respond() return t.respond()
@staticmethod @staticmethod
def update_alt():
""" Load scene exceptions """
changed_exceptions, cnt_updated_numbers, min_remain_iv = scene_exceptions.retrieve_exceptions()
return json.dumps(dict(names=int(changed_exceptions), numbers=cnt_updated_numbers, min_remain_iv=min_remain_iv))
@staticmethod
def export_alt(tvid_prodid=None):
""" Return alternative release names and numbering as json text"""
alts = {}
# alternative release names and numbers
alt_names = scene_exceptions.get_all_scene_exceptions(tvid_prodid)
alt_numbers = get_scene_numbering_for_show(*TVidProdid(tvid_prodid).tuple) # arbitrary order
ui_output = 'No alternative names or numbers to export'
# combine all possible season numbers into a sorted desc list
seasons = sorted(set(list(set([s for (s, e) in alt_numbers])) + [s for s in alt_names]), reverse=True)
if seasons:
if -1 == seasons[-1]:
seasons = [-1] + seasons[0:-1] # bubble -1
# prepare a seasonal ordered dict for output
alts = ordered_dict([(season, {}) for season in seasons])
# add original show name
show_obj = sickbeard.helpers.find_show_by_id(tvid_prodid, no_mapped_ids=True)
first_key = next(iteritems(alts))[0]
alts[first_key].update(dict({'#': show_obj.name}))
# process alternative release names
for (season, names) in iteritems(alt_names):
alts[season].update(dict(n=names))
# process alternative release numbers
for_target_group = {}
# uses a sorted list of (for seasons, for episodes) as a method
# to group (for, target) seasons with lists of target episodes
for f_se in sorted(alt_numbers): # sort season list (and therefore, implicitly asc/desc of targets)
t_se = alt_numbers[f_se]
for_target_group.setdefault((f_se[0], t_se[0]), []) # f_se[0] = for_season, t_se[0] = target_season
for_target_group[(f_se[0], t_se[0])] += [(f_se[1], t_se[1])] # f_se[1] = for_ep, t_se[1] = target_ep
# minimise episode lists into ranges e.g. 1x1, 2x2, ... 5x5 => 1x1-5
minimal = {}
for ft_s, ft_e_range in iteritems(for_target_group):
minimal.setdefault(ft_s, [])
last_f_e = None
for (f_e, t_e) in ft_e_range:
add_new = True
if minimal[ft_s]:
last = minimal[ft_s][-1]
last_t_e = last[-1]
if (f_e, t_e) in ((last_f_e + 1, last_t_e + 1), (last_f_e - 1, last_t_e - 1)):
add_new = False
if 2 == len(last):
minimal[ft_s][-1] += [t_e] # create range
else:
minimal[ft_s][-1][-1] += (-1, 1)[t_e == last_t_e + 1] # adjust range
last_f_e = f_e
if add_new:
minimal[ft_s] += [[f_e, t_e]] # singular
for (f_s, t_s), ft_list in iteritems(minimal):
alts[f_s].setdefault('se', [])
for fe_te in ft_list:
alts[f_s]['se'] += [dict({fe_te[0]: '%sx%s' % (t_s, '-'.join(['%s' % x for x in fe_te[1:]]))})]
ui_output = json.dumps(dict({tvid_prodid: alts}), indent=2, separators=(',', ': '))
return json.dumps(dict(text='%s\n\n' % ui_output))
@staticmethod
def generate_key(): def generate_key():
""" Return a new randomized API_KEY """ Return a new randomized API_KEY
""" """

14
tests/scene_helpers_tests.py

@ -20,12 +20,12 @@ from sickbeard.tv import TVShow
class SceneTests(test.SickbeardTestDBCase): class SceneTests(test.SickbeardTestDBCase):
def _test_allPossibleShowNames(self, name, prodid=0, expected=[]): def _test_allPossibleShowNames(self, name, prodid=0, expected=[], season=-1):
s = TVShow(TVINFO_TVDB, prodid) s = TVShow(TVINFO_TVDB, prodid)
s.tvid = TVINFO_TVDB s.tvid = TVINFO_TVDB
s.name = name s.name = name
result = show_name_helpers.allPossibleShowNames(s) result = show_name_helpers.allPossibleShowNames(s, season=season)
self.assertTrue(len(set(expected).intersection(set(result))) == len(expected)) self.assertTrue(len(set(expected).intersection(set(result))) == len(expected))
def _test_pass_wordlist_checks(self, name, expected): def _test_pass_wordlist_checks(self, name, expected):
@ -35,9 +35,12 @@ class SceneTests(test.SickbeardTestDBCase):
def test_allPossibleShowNames(self): def test_allPossibleShowNames(self):
# common.sceneExceptions[-1] = ['Exception Test'] # common.sceneExceptions[-1] = ['Exception Test']
my_db = db.DBConnection() my_db = db.DBConnection()
my_db.action('INSERT INTO scene_exceptions' my_db.mass_action([
' (indexer, indexer_id, show_name, season) VALUES (?,?,?,?)', ['INSERT INTO scene_exceptions (indexer, indexer_id, show_name, season) VALUES (?,?,?,?)',
[TVINFO_TVDB, -1, 'Exception Test', -1]) [TVINFO_TVDB, -1, 'Exception Test', -1]],
['INSERT INTO scene_exceptions (indexer, indexer_id, show_name, season) VALUES (?,?,?,?)',
[TVINFO_TVDB, -1, 'Season Test', 19]]
])
common.countryList['Full Country Name'] = 'FCN' common.countryList['Full Country Name'] = 'FCN'
self._test_allPossibleShowNames('Show Name', expected=['Show Name']) self._test_allPossibleShowNames('Show Name', expected=['Show Name'])
@ -46,6 +49,7 @@ class SceneTests(test.SickbeardTestDBCase):
self._test_allPossibleShowNames('Show Name (FCN)', expected=['Show Name (FCN)', 'Show Name (Full Country Name)']) self._test_allPossibleShowNames('Show Name (FCN)', expected=['Show Name (FCN)', 'Show Name (Full Country Name)'])
self._test_allPossibleShowNames('Show Name Full Country Name', expected=['Show Name Full Country Name', 'Show Name (FCN)']) self._test_allPossibleShowNames('Show Name Full Country Name', expected=['Show Name Full Country Name', 'Show Name (FCN)'])
self._test_allPossibleShowNames('Show Name (Full Country Name)', expected=['Show Name (Full Country Name)', 'Show Name (FCN)']) self._test_allPossibleShowNames('Show Name (Full Country Name)', expected=['Show Name (Full Country Name)', 'Show Name (FCN)'])
self._test_allPossibleShowNames('Show Name', -1, expected=['Season Test'], season=19)
def test_pass_wordlist_checks(self): def test_pass_wordlist_checks(self):
self._test_pass_wordlist_checks('Show.S02.German.Stuff-Grp', False) self._test_pass_wordlist_checks('Show.S02.German.Stuff-Grp', False)

Loading…
Cancel
Save