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)
* 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
* Change add 3 days cache for tmdb base info only
* 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))
}
.exception-divider{
margin:3px 0
}
.season-mark-exception{
font-size:12px;
vertical-align:super;
margin-right:-6px
}
.show-toggle-hide{
position:absolute;
top:272px;

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

@ -212,12 +212,27 @@
</span>
</label>
</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">
</fieldset>
#else
<div><p>Repo updates disabled. Using $sg_var('EXT_UPDATES') update method</p></div>
#end if
</fieldset>
</div>
</div><!-- /component-group1 //-->

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

@ -449,7 +449,7 @@
#set $scene_anime = True
#end if
#for $season, $episodes in $seasons
#for $season, $episodes, $has_season_exceptions in $seasons
#for $ep in $episodes
#if None is not $ep
#set $ep_str = '%sx%s' % ($season, $ep['episode'])
@ -466,6 +466,9 @@
#set $working_season = $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', [])/[]/),
<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>
@ -484,7 +487,7 @@
#set $qual = $season_stats.get($Overview.QUAL, None)
#set $good = $season_stats.get($Overview.GOOD, '0')
#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
<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

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

@ -98,32 +98,33 @@
<span class="component-title">Alternative release name(s)</span>
<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'">
<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>
#if $show_obj.anime:
#for $season in $seasonResults:
#for $season in $seasonResults:
<option value="$season[0]">Season $season[0]</option>
#end for
#end if
#end for
</select>
<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 class="clear-left note">searching and post-processing require the alternatives if "Show not found" errors are in the logs</p>
</span>
<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" >
#for $cur_exception_season in $show_obj.exceptions:
#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
</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>
<div>
<input id="removeSceneName" value="Remove" class="btn pull-left" type="button" style="margin-top: 10px;"/>
<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 style="margin-top:6px">
<input id="removeSceneName" value="Remove" class="btn pull-left" type="button">
</div>
</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>
</div>

28
gui/slick/js/config.js

@ -43,6 +43,34 @@ $(document).ready(function () {
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',
sel = 'selected', opt = 'option', selOpt = [opt, sel].join(':'),
elDropDown = $(idSelect), elDel = $(idDel), elInput = $(idInput), elOnOff = $(idOnOff);

31
gui/slick/js/editShow.js

@ -7,6 +7,10 @@
$(document).ready(function () {
$('#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) {
return ' class="flag" style="background-image:url(' + $.SickGear.Root + '/images/flags/' + lang + '.png)"'
@ -71,7 +75,7 @@ $(document).ready(function () {
if (null === sceneExSeason)
sceneExSeason = '-1';
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'));
});
@ -82,6 +86,31 @@ $(document).ready(function () {
$(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 () {
var elSceneException = $('#SceneException');

10
gui/slick/js/sceneExceptionsTooltip.js

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

8
lib/_23.py

@ -155,9 +155,11 @@ if 2 != version_info[0]:
# noinspection PyUnresolvedReferences
from subprocess import Popen
# noinspection PyUnresolvedReferences
# noinspection PyUnresolvedReferences, PyPep8Naming
import xml.etree.ElementTree as etree
ordered_dict = dict
native_timestamp = datetime.datetime.timestamp # type: Callable[[datetime.datetime], float]
def unquote(string, encoding='utf-8', errors='replace'):
@ -237,6 +239,9 @@ else:
# noinspection PyPep8Naming
import xml.etree.ElementTree as etree
from collections import OrderedDict
ordered_dict = OrderedDict
def _totimestamp(dt=None):
# 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
@ -247,6 +252,7 @@ else:
native_timestamp = _totimestamp # type: Callable[[datetime.datetime], float]
from subprocess import Popen as _Popen
class Popen(_Popen):
def __enter__(self):

68
lib/sg_helpers.py

@ -16,6 +16,7 @@ import stat
import subprocess
import tempfile
import threading
import time
import traceback
from exceptions_helper import ex, ConnectionSkipException
@ -28,7 +29,7 @@ from send2trash import send2trash
import encodingKludge as ek
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
# noinspection PyUnreachableCode
@ -971,19 +972,6 @@ def file_bit_filter(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):
"""
@ -1138,6 +1126,26 @@ def move_file(src_file, dest_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):
"""
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
if filepath:
try:
result = 'Deleted'
if TRASH_REMOVE_SHOW:
result = 'Trashed'
ek.ek(send2trash, filepath)
elif tree:
ek.ek(shutil.rmtree, filepath)
else:
ek.ek(os.remove, filepath)
except OSError as 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)))
for t in list_range(10): # total seconds to wait 0 - 9 = 45s over 10 iterations
try:
result = 'Deleted'
if TRASH_REMOVE_SHOW:
result = 'Trashed'
ek.ek(send2trash, filepath)
elif tree:
ek.ek(shutil.rmtree, filepath)
else:
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
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)]

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 ..helpers import maybe_plural, remove_file_failed
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 ..sgdatetime import SGDatetime, timestamp_near
from ..tv import TVEpisode, TVShow
@ -1652,6 +1653,7 @@ class TorrentProvider(GenericProvider):
return []
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)
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
@ -1659,7 +1661,8 @@ class TorrentProvider(GenericProvider):
sp_detail = ([sp_detail], sp_detail)[isinstance(sp_detail, list)]
detail = ({}, {'Season_only': sp_detail})[detail_only
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)))]
def _episode_strings(self,
@ -1686,6 +1689,7 @@ class TorrentProvider(GenericProvider):
return []
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:
ep_detail = [str(ep_obj.airdate).replace('-', sep_date)]\
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)]
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)))]
@staticmethod
@ -1718,8 +1723,8 @@ class TorrentProvider(GenericProvider):
(ep_obj.scene_season, ep_obj.scene_episode))[bool(ep_obj.show_obj.is_scene)]
return {'seasonnumber': season, 'episodenumber': episode}
def _build_search_strings(self, ep_detail, process_name=True, prefix=''):
# type: (Union[List[AnyStr], AnyStr], bool, AnyStr) -> List[AnyStr]
def _build_search_strings(self, ep_detail, process_name=True, prefix='', season=-1):
# type: (Union[List[AnyStr], AnyStr], bool, AnyStr, int) -> List[AnyStr]
"""
Build a list of search strings for querying a provider
:param ep_detail: String of episode detail or List of episode details
@ -1733,7 +1738,8 @@ class TorrentProvider(GenericProvider):
search_params = []
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:
search_params += [crop.sub(r'\1', '%s %s%s' % (name, x, detail)) for x in prefix]
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 ..search import get_aired_in_season, get_wanted_qualities
from ..show_name_helpers import get_show_names
from ..scene_exceptions import has_season_exceptions
from ..tv import TVEpisode, TVShow
from lib.dateutil import parser
@ -457,11 +458,12 @@ class NewznabProvider(generic.NZBProvider):
# id search
params = base_params.copy()
use_id = False
for i in sickbeard.TVInfoAPI().all_sources:
if i in ep_obj.show_obj.ids and 0 < ep_obj.show_obj.ids[i]['id'] and i in self.caps:
params[self.caps[i]] = ep_obj.show_obj.ids[i]['id']
use_id = True
use_id and search_params.append(params)
if not has_season_exceptions(ep_obj.show_obj.tvid, ep_obj.show_obj.prodid, ep_obj.season):
for i in sickbeard.TVInfoAPI().all_sources:
if i in ep_obj.show_obj.ids and 0 < ep_obj.show_obj.ids[i]['id'] and i in self.caps:
params[self.caps[i]] = ep_obj.show_obj.ids[i]['id']
use_id = True
use_id and search_params.append(params)
spacer = 'nzbgeek.info' in self.url.lower() and ' ' or '.'
# query search and exceptions
@ -516,11 +518,12 @@ class NewznabProvider(generic.NZBProvider):
# id search
params = base_params.copy()
use_id = False
for i in sickbeard.TVInfoAPI().all_sources:
if i in ep_obj.show_obj.ids and 0 < ep_obj.show_obj.ids[i]['id'] and i in self.caps:
params[self.caps[i]] = ep_obj.show_obj.ids[i]['id']
use_id = True
use_id and search_params.append(params)
if not has_season_exceptions(ep_obj.show_obj.tvid, ep_obj.show_obj.prodid, ep_obj.season):
for i in sickbeard.TVInfoAPI().all_sources:
if i in ep_obj.show_obj.ids and 0 < ep_obj.show_obj.ids[i]['id'] and i in self.caps:
params[self.caps[i]] = ep_obj.show_obj.ids[i]['id']
use_id = True
use_id and search_params.append(params)
spacer = 'nzbgeek.info' in self.url.lower() and ' ' or '.'
# query search and exceptions

185
sickbeard/scene_exceptions.py

@ -19,25 +19,33 @@
from collections import defaultdict
import datetime
import io
import os
import re
import sys
import threading
import time
import traceback
import sickbeard
# noinspection PyPep8Naming
import encodingKludge as ek
from exceptions_helper import ex
from . import db, helpers, logger, name_cache
from .anime import create_anidb_obj
from .classes import OrderedDefaultdict
from .helpers import json
from .indexers.indexer_config import TVINFO_TVDB
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
# noinspection PyUnreachableCode
if False:
# noinspection PyUnresolvedReferences
from typing import AnyStr, List, Tuple
from typing import AnyStr, List, Tuple, Union
exception_dict = {}
anidb_exception_dict = {}
@ -50,20 +58,22 @@ exceptionsSeasonCache = {}
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
:type name: AnyStr
:param max_refresh_age_secs:
:param remaining: True to return remaining seconds
:return:
:rtype: bool
"""
max_refresh_age_secs = 86400 # 1 day
my_db = db.DBConnection()
rows = my_db.select('SELECT last_refreshed FROM scene_exceptions_refresh WHERE list = ?', [name])
if rows:
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 True
@ -80,6 +90,13 @@ def set_last_refresh(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):
"""
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'
' FROM scene_exceptions'
' WHERE indexer = ? AND indexer_id = ?'
' ORDER BY season',
' ORDER BY season DESC, show_name DESC',
TVidProdid(tvid_prodid).list)
exceptions_seasons = []
if 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'])
return exceptions_dict
@ -257,6 +282,14 @@ def retrieve_exceptions():
else:
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
# 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:
# get a list of the existing exceptions for this ID
existing_exceptions = [x['show_name'] for x in
my_db.select('SELECT show_name'
existing_exceptions = [{x['show_name']: x['season']} for x in
my_db.select('SELECT show_name, season'
' FROM scene_exceptions'
' WHERE indexer = ? AND indexer_id = ?',
list(cur_tvid_prodid))]
if cur_tvid_prodid not in exception_dict:
continue
for cur_exception_dict in exception_dict[cur_tvid_prodid]:
# if this exception isn't already in the DB then add it
for cur_exception_dict in filter_iter(lambda e: e not in existing_exceptions, exception_dict[cur_tvid_prodid]):
try:
cur_exception, cur_season = next(iteritems(cur_exception_dict))
except (BaseException, Exception):
@ -282,16 +313,13 @@ def retrieve_exceptions():
logger.log(traceback.format_exc(), logger.ERROR)
continue
# if this exception isn't already in the DB then add it
if cur_exception not in existing_exceptions:
if PY2 and not isinstance(cur_exception, text_type):
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'
' (indexer, indexer_id, show_name, season) VALUES (?,?,?,?)',
list(cur_tvid_prodid) + [cur_exception, cur_season]])
changed_exceptions = True
cl.append(['INSERT INTO scene_exceptions'
' (indexer, indexer_id, show_name, season) VALUES (?,?,?,?)',
list(cur_tvid_prodid) + [cur_exception, cur_season]])
changed_exceptions = True
if cl:
my_db.mass_action(cl)
@ -308,6 +336,8 @@ def retrieve_exceptions():
anidb_exception_dict.clear()
xem_exception_dict.clear()
return changed_exceptions, cnt_updated_numbers, min_remain_iv
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)
for exception in scene_exceptions:
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)
@ -344,6 +380,107 @@ def update_scene_exceptions(tvid, prodid, scene_exceptions):
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():
global anidb_exception_dict

6
sickbeard/scene_numbering.py

@ -21,7 +21,6 @@
# @copyright: Dermot Buckley
#
import time
import datetime
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)
set_scene_numbering(**scene_args)
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:
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
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)
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
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
# noinspection PyUnreachableCode
@ -2110,7 +2110,8 @@ class Home(MainHandler):
' WHERE indexer = ? AND showid = ?'
' AND season = ?'
' 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'
' 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:]
@staticmethod
def scene_exceptions(tvid_prodid):
def scene_exceptions(tvid_prodid, wanted_season=None):
exceptionsList = sickbeard.scene_exceptions.get_all_scene_exceptions(tvid_prodid)
if not exceptionsList:
return 'No scene exceptions'
wanted_season = helpers.try_int(wanted_season, None)
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 = []
for season, names in iter(sorted(iteritems(exceptionsList))):
out.append('S%s: %s' % ((season, '*')[-1 == season], ',<br />\n'.join(names)))
return '---------<br />\n'.join(out)
if None is wanted_season or wanted_season == season:
out.append('S%s: %s' % (('%02d' % season, '*')[-1 == season], ',<br>\n'.join(names)))
return '\n<hr class="exception-divider">\n'.join(out)
@staticmethod
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'
' FROM tv_episodes'
' WHERE indexer = ? AND showid = ?'
' ORDER BY season ASC',
' ORDER BY season DESC',
[show_obj.tvid, show_obj.prodid])
if show_obj.is_anime:
@ -6748,6 +6752,80 @@ class ConfigGeneral(Config):
return t.respond()
@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():
""" 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):
def _test_allPossibleShowNames(self, name, prodid=0, expected=[]):
def _test_allPossibleShowNames(self, name, prodid=0, expected=[], season=-1):
s = TVShow(TVINFO_TVDB, prodid)
s.tvid = TVINFO_TVDB
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))
def _test_pass_wordlist_checks(self, name, expected):
@ -35,9 +35,12 @@ class SceneTests(test.SickbeardTestDBCase):
def test_allPossibleShowNames(self):
# common.sceneExceptions[-1] = ['Exception Test']
my_db = db.DBConnection()
my_db.action('INSERT INTO scene_exceptions'
' (indexer, indexer_id, show_name, season) VALUES (?,?,?,?)',
[TVINFO_TVDB, -1, 'Exception Test', -1])
my_db.mass_action([
['INSERT INTO scene_exceptions (indexer, indexer_id, show_name, season) VALUES (?,?,?,?)',
[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'
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 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):
self._test_pass_wordlist_checks('Show.S02.German.Stuff-Grp', False)

Loading…
Cancel
Save