You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1202 lines
44 KiB
1202 lines
44 KiB
#!/usr/bin/python3 -OO
|
|
# Copyright 2007-2018 The SABnzbd-Team <team@sabnzbd.org>
|
|
#
|
|
# This program is free software; you can redistribute it and/or
|
|
# modify it under the terms of the GNU General Public License
|
|
# as published by the Free Software Foundation; either version 2
|
|
# of the License, or (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program; if not, write to the Free Software
|
|
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
|
|
|
"""
|
|
sabnzbd.sorting - Sorting Functions
|
|
Series Sorting - Sorting downloads into seasons & episodes
|
|
Date Sorting - Sorting downloads by a custom date matching
|
|
Generic Sorting - Sorting large files by a custom matching
|
|
"""
|
|
|
|
import os
|
|
import logging
|
|
import re
|
|
|
|
import sabnzbd
|
|
from sabnzbd.filesystem import move_to_path, cleanup_empty_directories, get_unique_path, \
|
|
get_unique_filename, get_ext, renamer, sanitize_foldername, clip_path
|
|
from sabnzbd.constants import series_match, date_match, year_match, sample_match
|
|
from sabnzbd.encoding import unicoder
|
|
import sabnzbd.cfg as cfg
|
|
|
|
RE_SAMPLE = re.compile(sample_match, re.I)
|
|
# Do not rename .vob files as they are usually DVD's
|
|
EXCLUDED_FILE_EXTS = ('.vob', '.bin')
|
|
|
|
LOWERCASE = ('the', 'of', 'and', 'at', 'vs', 'a', 'an', 'but', 'nor', 'for', 'on',
|
|
'so', 'yet', 'with')
|
|
UPPERCASE = ('III', 'II', 'IV')
|
|
|
|
REPLACE_AFTER = {
|
|
'()': '',
|
|
'..': '.',
|
|
'__': '_',
|
|
' ': ' ',
|
|
' .%ext': '.%ext'
|
|
}
|
|
|
|
# Title() function messes up country names, so need to replace them instead
|
|
COUNTRY_REP = ('(US)', '(UK)', '(EU)', '(CA)', '(YU)', '(VE)', '(TR)', '(CH)',
|
|
'(SE)', '(ES)', '(KR)', '(ZA)', '(SK)', '(SG)', '(RU)', '(RO)',
|
|
'(PR)', '(PT)', '(PL)', '(PH)', '(PK)', '(NO)', '(NG)', '(NZ)',
|
|
'(NL)', '(MX)', '(MY)', '(MK)', '(KZ)', '(JP)', '(JM)', '(IT)',
|
|
'(IL)', '(IE)', '(IN)', '(IS)', '(HU)', '(HK)', '(HN)', '(GR)',
|
|
'(GH)', '(DE)', '(FR)', '(FI)', '(DK)', '(CZ)', '(HR)', '(CR)',
|
|
'(CO)', '(CN)', '(CL)', '(BG)', '(BR)', '(BE)', '(AT)', '(AU)',
|
|
'(AW)', '(AR)', '(AL)', '(AF)')
|
|
|
|
|
|
def ends_in_file(path):
|
|
""" Return True when path ends with '.%ext' or '%fn' """
|
|
_RE_ENDEXT = re.compile(r'\.%ext[{}]*$', re.I)
|
|
_RE_ENDFN = re.compile(r'%fn[{}]*$', re.I)
|
|
return bool(_RE_ENDEXT.search(path) or _RE_ENDFN.search(path))
|
|
|
|
|
|
def move_to_parent_folder(workdir):
|
|
""" Move all in 'workdir' into 'workdir/..' """
|
|
# Determine 'folder'/..
|
|
workdir = os.path.abspath(os.path.normpath(workdir))
|
|
dest = os.path.abspath(os.path.normpath(os.path.join(workdir, '..')))
|
|
|
|
# Check for DVD folders and stop if found
|
|
for item in os.listdir(workdir):
|
|
if item.lower() in ('video_ts', 'audio_ts', 'bdmv'):
|
|
return workdir, True
|
|
|
|
for root, dirs, files in os.walk(workdir):
|
|
for _file in files:
|
|
path = os.path.join(root, _file)
|
|
new_path = path.replace(workdir, dest)
|
|
ok, new_path = move_to_path(path, new_path)
|
|
if not ok:
|
|
return dest, False
|
|
|
|
cleanup_empty_directories(workdir)
|
|
return dest, True
|
|
|
|
|
|
class Sorter(object):
|
|
""" Generic Sorter class """
|
|
|
|
def __init__(self, nzo, cat):
|
|
self.sorter = None
|
|
self.type = None
|
|
self.sort_file = False
|
|
self.nzo = nzo
|
|
self.cat = cat
|
|
self.ext = ''
|
|
|
|
def detect(self, job_name, complete_dir):
|
|
""" Detect which kind of sort applies """
|
|
self.sorter = SeriesSorter(self.nzo, job_name, complete_dir, self.cat)
|
|
if self.sorter.matched:
|
|
complete_dir = self.sorter.get_final_path()
|
|
self.type = 'tv'
|
|
self.sort_file = True
|
|
return complete_dir
|
|
|
|
self.sorter = DateSorter(self.nzo, job_name, complete_dir, self.cat)
|
|
if self.sorter.matched:
|
|
complete_dir = self.sorter.get_final_path()
|
|
self.type = 'date'
|
|
self.sort_file = True
|
|
return complete_dir
|
|
|
|
self.sorter = MovieSorter(self.nzo, job_name, complete_dir, self.cat)
|
|
if self.sorter.matched:
|
|
complete_dir = self.sorter.get_final_path()
|
|
self.type = 'movie'
|
|
self.sort_file = True
|
|
return complete_dir
|
|
|
|
self.sort_file = False
|
|
return complete_dir
|
|
|
|
def rename(self, newfiles, workdir_complete):
|
|
""" Rename files of the job """
|
|
if self.sorter.rename_or_not:
|
|
self.sorter.rename(newfiles, workdir_complete)
|
|
|
|
def rename_with_ext(self, workdir_complete):
|
|
""" Special renamer for %ext """
|
|
if self.sorter.rename_or_not and '%ext' in workdir_complete and self.ext:
|
|
# Replace %ext with extension
|
|
newpath = workdir_complete.replace('%ext', self.ext)
|
|
try:
|
|
renamer(workdir_complete, newpath)
|
|
except:
|
|
return newpath, False
|
|
return newpath, True
|
|
else:
|
|
return workdir_complete, True
|
|
|
|
def move(self, workdir_complete):
|
|
ok = True
|
|
if self.type == 'movie':
|
|
move_to_parent = True
|
|
# check if we should leave the files inside an extra folder
|
|
if cfg.movie_extra_folders():
|
|
# if there is a folder in the download, leave it in an extra folder
|
|
move_to_parent = not check_for_folder(workdir_complete)
|
|
if move_to_parent:
|
|
workdir_complete, ok = move_to_parent_folder(workdir_complete)
|
|
else:
|
|
workdir_complete, ok = move_to_parent_folder(workdir_complete)
|
|
if not ok:
|
|
return workdir_complete, False
|
|
|
|
path, part = os.path.split(workdir_complete)
|
|
if '%fn' in part and self.sorter.fname:
|
|
old = workdir_complete
|
|
workdir_complete = os.path.join(path, part.replace('%fn', self.sorter.fname))
|
|
workdir_complete = get_unique_path(workdir_complete, create_dir=False)
|
|
try:
|
|
renamer(old, workdir_complete)
|
|
except:
|
|
logging.error(T('Cannot create directory %s'), clip_path(workdir_complete))
|
|
workdir_complete = old
|
|
ok = False
|
|
return workdir_complete, ok
|
|
|
|
|
|
class SeriesSorter(object):
|
|
""" Methods for Series Sorting """
|
|
|
|
def __init__(self, nzo, job_name, path, cat):
|
|
self.matched = False
|
|
|
|
self.original_job_name = job_name
|
|
self.original_path = path
|
|
self.nzo = nzo
|
|
self.cat = cat
|
|
self.sort_string = cfg.tv_sort_string()
|
|
self.cats = cfg.tv_categories()
|
|
self.filename_set = ''
|
|
self.fname = '' # Value for %fn substitution in folders
|
|
self.final_path = ''
|
|
|
|
self.match_obj = None
|
|
self.extras = None
|
|
|
|
self.rename_or_not = False
|
|
|
|
self.show_info = {}
|
|
|
|
# Check if it is a TV show on init()
|
|
self.match()
|
|
|
|
def match(self, force=False):
|
|
""" Checks the regex for a match, if so set self.match to true """
|
|
if force or (cfg.enable_tv_sorting() and cfg.tv_sort_string()):
|
|
if force or (not self.cats) or (self.cat and self.cat.lower() in self.cats) or (not self.cat and 'None' in self.cats):
|
|
# First check if the show matches TV episode regular expressions. Returns regex match object
|
|
self.match_obj, self.extras = check_regexs(self.original_job_name, series_match)
|
|
if self.match_obj:
|
|
logging.debug("Found TV Show (%s)", self.original_job_name)
|
|
self.matched = True
|
|
|
|
def is_match(self):
|
|
""" Returns whether there was a match or not """
|
|
return self.matched
|
|
|
|
def get_final_path(self):
|
|
""" Collect and construct all the variables such as episode name, show names """
|
|
if self.get_values():
|
|
# Get the final path
|
|
path = self.construct_path()
|
|
self.final_path = os.path.join(self.original_path, path)
|
|
return self.final_path
|
|
else:
|
|
# Error Sorting
|
|
return os.path.join(self.original_path, self.original_job_name)
|
|
|
|
def get_multi_ep_naming(self, one, two, extras):
|
|
""" Returns a list of unique values joined into a string and separated by - (ex:01-02-03-04) """
|
|
extra_list = [one]
|
|
extra2_list = [two]
|
|
for extra in extras:
|
|
if extra not in (extra_list, extra2_list):
|
|
ep_no2 = extra.rjust(2, '0')
|
|
extra_list.append(extra)
|
|
extra2_list.append(ep_no2)
|
|
|
|
one = '-'.join(extra_list)
|
|
two = '-'.join(extra2_list)
|
|
return one, two
|
|
|
|
def get_shownames(self):
|
|
""" Get the show name from the match object and format it """
|
|
# Get the formatted title and alternate title formats
|
|
self.show_info['show_tname'], self.show_info['show_tname_two'], self.show_info['show_tname_three'] = get_titles(self.nzo, self.match_obj, self.original_job_name, True)
|
|
self.show_info['show_name'], self.show_info['show_name_two'], self.show_info['show_name_three'] = get_titles(self.nzo, self.match_obj, self.original_job_name)
|
|
|
|
def get_seasons(self):
|
|
""" Get the season number from the match object and format it """
|
|
try:
|
|
season = self.match_obj.group(1).strip('_') # season number
|
|
except AttributeError:
|
|
season = '1'
|
|
|
|
# Provide alternative formatting (0 padding)
|
|
if season.lower() == 's':
|
|
season2 = season
|
|
else:
|
|
try:
|
|
season = str(int(season))
|
|
except:
|
|
pass
|
|
season2 = season.rjust(2, '0')
|
|
|
|
self.show_info['season_num'] = season
|
|
self.show_info['season_num_alt'] = season2
|
|
|
|
def get_episodes(self):
|
|
""" Get the episode numbers from the match object, format and join them """
|
|
try:
|
|
ep_no = self.match_obj.group(2) # episode number
|
|
except AttributeError:
|
|
ep_no = '1'
|
|
# Store the original episode number
|
|
|
|
# Provide alternative formatting (0 padding)
|
|
ep_no2 = ep_no.rjust(2, '0')
|
|
try:
|
|
ep_no = str(int(ep_no))
|
|
except:
|
|
pass
|
|
|
|
# Dual episode support
|
|
if self.extras:
|
|
ep_no, ep_no2 = self.get_multi_ep_naming(ep_no, ep_no2, self.extras)
|
|
|
|
self.show_info['episode_num'] = ep_no
|
|
self.show_info['episode_num_alt'] = ep_no2
|
|
|
|
def get_showdescriptions(self):
|
|
""" Get the show descriptions from the match object and format them """
|
|
self.show_info['ep_name'], self.show_info['ep_name_two'], self.show_info['ep_name_three'] = get_descriptions(self.nzo, self.match_obj, self.original_job_name)
|
|
|
|
def get_values(self):
|
|
""" Collect and construct all the values needed for path replacement """
|
|
try:
|
|
# - Show Name
|
|
self.get_shownames()
|
|
|
|
# - Season
|
|
self.get_seasons()
|
|
|
|
# - Episode Number
|
|
self.get_episodes()
|
|
|
|
# - Episode Name
|
|
self.get_showdescriptions()
|
|
|
|
return True
|
|
|
|
except:
|
|
logging.error(T('Error getting TV info (%s)'), clip_path(self.original_job_name))
|
|
logging.info("Traceback: ", exc_info=True)
|
|
return False
|
|
|
|
def construct_path(self):
|
|
""" Replaces the sort string with real values such as Show Name and Episode Number """
|
|
|
|
sorter = self.sort_string.replace('\\', '/')
|
|
mapping = []
|
|
|
|
if ends_in_file(sorter):
|
|
extension = True
|
|
sorter = sorter.replace('.%ext', '')
|
|
else:
|
|
extension = False
|
|
|
|
# Replace Show name
|
|
mapping.append(('%sn', self.show_info['show_tname']))
|
|
mapping.append(('%s.n', self.show_info['show_tname_two']))
|
|
mapping.append(('%s_n', self.show_info['show_tname_three']))
|
|
mapping.append(('%sN', self.show_info['show_name']))
|
|
mapping.append(('%s.N', self.show_info['show_name_two']))
|
|
mapping.append(('%s_N', self.show_info['show_name_three']))
|
|
|
|
# Replace season number
|
|
mapping.append(('%s', self.show_info['season_num']))
|
|
mapping.append(('%0s', self.show_info['season_num_alt']))
|
|
|
|
# Original dir name
|
|
mapping.append(('%dn', self.original_job_name))
|
|
|
|
# Replace episode names
|
|
if self.show_info['ep_name']:
|
|
mapping.append(('%en', self.show_info['ep_name']))
|
|
mapping.append(('%e.n', self.show_info['ep_name_two']))
|
|
mapping.append(('%e_n', self.show_info['ep_name_three']))
|
|
else:
|
|
mapping.append(('%en', ''))
|
|
mapping.append(('%e.n', ''))
|
|
mapping.append(('%e_n', ''))
|
|
|
|
# Replace episode number
|
|
mapping.append(('%e', self.show_info['episode_num']))
|
|
mapping.append(('%0e', self.show_info['episode_num_alt']))
|
|
|
|
# Make sure unsupported %desc is removed
|
|
mapping.append(('%desc', ''))
|
|
|
|
# Replace elements
|
|
path = path_subst(sorter, mapping)
|
|
|
|
for key, name in REPLACE_AFTER.items():
|
|
path = path.replace(key, name)
|
|
|
|
# Lowercase all characters wrapped in {}
|
|
path = to_lowercase(path)
|
|
|
|
# Strip any extra ' ' '.' or '_' around foldernames
|
|
path = strip_folders(path)
|
|
|
|
# Split the last part of the path up for the renamer
|
|
if extension:
|
|
head, tail = os.path.split(path)
|
|
self.filename_set = tail
|
|
self.rename_or_not = True
|
|
else:
|
|
head = path
|
|
|
|
return os.path.normpath(head)
|
|
|
|
def rename(self, files, current_path):
|
|
""" Rename for Series """
|
|
logging.debug("Renaming Series")
|
|
largest = (None, None, 0)
|
|
|
|
def to_filepath(f, current_path):
|
|
if is_full_path(f):
|
|
filepath = os.path.normpath(f)
|
|
else:
|
|
filepath = os.path.normpath(os.path.join(current_path, f))
|
|
return filepath
|
|
|
|
# Create a generator of filepaths, ignore sample files and excluded files (vobs ect)
|
|
filepaths = ((file, to_filepath(file, current_path)) for file in files if not RE_SAMPLE.search(file)
|
|
and get_ext(file) not in EXCLUDED_FILE_EXTS)
|
|
|
|
# Find the largest existing file
|
|
for file, fp in filepaths:
|
|
# If for some reason the file no longer exists, skip
|
|
if not os.path.exists(fp):
|
|
continue
|
|
|
|
size = os.stat(fp).st_size
|
|
f_file, f_fp, f_size = largest
|
|
if size > f_size:
|
|
largest = (file, fp, size)
|
|
|
|
file, filepath, size = largest
|
|
# >20MB
|
|
if filepath and size > 20971520:
|
|
self.fname, self.ext = os.path.splitext(os.path.split(file)[1])
|
|
newname = "%s%s" % (self.filename_set, self.ext)
|
|
# Replace %fn with the original filename
|
|
newname = newname.replace('%fn', self.fname)
|
|
newpath = os.path.join(current_path, newname)
|
|
# Replace %ext with extension
|
|
newpath = newpath.replace('%ext', self.ext)
|
|
try:
|
|
logging.debug("Rename: %s to %s", filepath, newpath)
|
|
renamer(filepath, newpath)
|
|
except:
|
|
logging.error(T('Failed to rename: %s to %s'), clip_path(current_path), clip_path(newpath))
|
|
logging.info("Traceback: ", exc_info=True)
|
|
rename_similar(current_path, self.ext, self.filename_set, ())
|
|
else:
|
|
logging.debug('Nothing to rename, %s', files)
|
|
|
|
|
|
_RE_MULTIPLE = (
|
|
re.compile(r'cd\W?(\d+)\W?', re.I), # .cd1.mkv
|
|
re.compile(r'\w\W?([\w\d])[{}]*$', re.I), # blah1.mkv blaha.mkv
|
|
re.compile(r'\w\W([\w\d])\W', re.I) # blah-1-ok.mkv blah-a-ok.mkv
|
|
)
|
|
|
|
|
|
def check_for_multiple(files):
|
|
""" Return list of files that looks like a multi-part post """
|
|
for regex in _RE_MULTIPLE:
|
|
matched_files = check_for_sequence(regex, files)
|
|
if matched_files:
|
|
return matched_files
|
|
return ''
|
|
|
|
|
|
def check_for_sequence(regex, files):
|
|
""" Return list of files that looks like a sequence, using 'regex' """
|
|
matches = {}
|
|
prefix = None
|
|
# Build up a dictionary of matches
|
|
# The key is based off the match, ie {1:'blah-part1.mkv'}
|
|
for _file in files:
|
|
name, ext = os.path.splitext(_file)
|
|
match1 = regex.search(name)
|
|
if match1:
|
|
if not prefix or prefix == name[:match1.start()]:
|
|
matches[match1.group(1)] = name + ext
|
|
prefix = name[:match1.start()]
|
|
|
|
# Don't do anything if only one or no files matched
|
|
if len(list(matches.keys())) < 2:
|
|
return {}
|
|
|
|
key_prev = 0
|
|
passed = True
|
|
alphabet = 'abcdefghijklmnopqrstuvwxyz'
|
|
|
|
# Check the dictionary to see if the keys are in a numeric or alphabetic sequence
|
|
for akey in sorted(matches.keys()):
|
|
if akey.isdigit():
|
|
key = int(akey)
|
|
elif akey in alphabet:
|
|
key = alphabet.find(akey) + 1
|
|
else:
|
|
passed = False
|
|
|
|
if passed:
|
|
if not key_prev:
|
|
key_prev = key
|
|
else:
|
|
if key_prev + 1 == key:
|
|
key_prev = key
|
|
else:
|
|
passed = False
|
|
if passed:
|
|
# convert {'b':'filename-b.mkv'} to {'2', 'filename-b.mkv'}
|
|
item = matches.pop(akey)
|
|
matches[str(key)] = item
|
|
|
|
if passed:
|
|
return matches
|
|
else:
|
|
return {}
|
|
|
|
|
|
class MovieSorter(object):
|
|
""" Methods for Generic Sorting """
|
|
|
|
def __init__(self, nzo, job_name, path, cat):
|
|
self.matched = False
|
|
|
|
self.original_job_name = job_name
|
|
self.original_path = path
|
|
self.sort_string = cfg.movie_sort_string()
|
|
self.extra = cfg.movie_sort_extra()
|
|
self.cats = cfg.movie_categories()
|
|
self.cat = cat
|
|
self.nzo = nzo
|
|
self.filename_set = ''
|
|
self.fname = '' # Value for %fn substitution in folders
|
|
self.final_path = ''
|
|
|
|
self.match_obj = None
|
|
|
|
self.rename_or_not = False
|
|
|
|
self.movie_info = {}
|
|
|
|
# Check if we match the category in init()
|
|
self.match()
|
|
|
|
def match(self, force=False):
|
|
""" Checks the category for a match, if so set self.match to true """
|
|
if force or (cfg.enable_movie_sorting() and self.sort_string):
|
|
# First check if the show matches TV episode regular expressions. Returns regex match object
|
|
if force or (self.cat and self.cat.lower() in self.cats) or (not self.cat and 'None' in self.cats):
|
|
logging.debug("Found Movie (%s)", self.original_job_name)
|
|
self.matched = True
|
|
|
|
def get_final_path(self):
|
|
""" Collect and construct all the variables such as episode name, show names """
|
|
if self.get_values():
|
|
# Get the final path
|
|
path = self.construct_path()
|
|
self.final_path = os.path.join(self.original_path, path)
|
|
return self.final_path
|
|
else:
|
|
# Error Sorting
|
|
return os.path.join(self.original_path, self.original_job_name)
|
|
|
|
def get_values(self):
|
|
""" Collect and construct all the values needed for path replacement """
|
|
# - Get Year
|
|
if self.nzo:
|
|
year = self.nzo.nzo_info.get('year')
|
|
else:
|
|
year = ''
|
|
if year:
|
|
year_m = None
|
|
else:
|
|
job_name = self.original_job_name.replace('_', ' ')
|
|
RE_YEAR = re.compile(year_match, re.I)
|
|
year_m = RE_YEAR.search(job_name)
|
|
if year_m:
|
|
# Find the last matched date
|
|
# Keep year_m to use in get_titles
|
|
year = RE_YEAR.findall(job_name)[-1][0]
|
|
else:
|
|
year = ''
|
|
self.movie_info['year'] = year
|
|
|
|
# - Get Decades
|
|
self.movie_info['decade'], self.movie_info['decade_two'] = get_decades(year)
|
|
|
|
# - Get Title
|
|
self.movie_info['ttitle'], self.movie_info['ttitle_two'], self.movie_info['ttitle_three'] = get_titles(self.nzo, year_m, self.original_job_name, True)
|
|
self.movie_info['title'], self.movie_info['title_two'], self.movie_info['title_three'] = get_titles(self.nzo, year_m, self.original_job_name)
|
|
|
|
return True
|
|
|
|
def construct_path(self):
|
|
""" Return path reconstructed from original and sort expression """
|
|
sorter = self.sort_string.replace('\\', '/')
|
|
mapping = []
|
|
|
|
if ends_in_file(sorter):
|
|
extension = True
|
|
sorter = sorter.replace(".%ext", '')
|
|
else:
|
|
extension = False
|
|
|
|
# Replace title
|
|
mapping.append(('%title', self.movie_info['title']))
|
|
mapping.append(('%.title', self.movie_info['title_two']))
|
|
mapping.append(('%_title', self.movie_info['title_three']))
|
|
|
|
# Replace title (short forms)
|
|
mapping.append(('%t', self.movie_info['title']))
|
|
mapping.append(('%.t', self.movie_info['title_two']))
|
|
mapping.append(('%_t', self.movie_info['title_three']))
|
|
|
|
mapping.append(('%sn', self.movie_info['title']))
|
|
mapping.append(('%s.n', self.movie_info['title_two']))
|
|
mapping.append(('%s_n', self.movie_info['title_three']))
|
|
|
|
mapping.append(('%sN', self.movie_info['ttitle']))
|
|
mapping.append(('%s.N', self.movie_info['ttitle_two']))
|
|
mapping.append(('%s_N', self.movie_info['ttitle_three']))
|
|
|
|
# Replace year
|
|
mapping.append(('%y', self.movie_info['year']))
|
|
|
|
# Replace decades
|
|
mapping.append(('%decade', self.movie_info['decade']))
|
|
mapping.append(('%0decade', self.movie_info['decade_two']))
|
|
|
|
# Original dir name
|
|
mapping.append(('%dn', self.original_job_name))
|
|
|
|
path = path_subst(sorter, mapping)
|
|
|
|
for key, name in REPLACE_AFTER.items():
|
|
path = path.replace(key, name)
|
|
|
|
# Lowercase all characters wrapped in {}
|
|
path = to_lowercase(path)
|
|
|
|
# Strip any extra ' ' '.' or '_' around foldernames
|
|
path = strip_folders(path)
|
|
|
|
# Split the last part of the path up for the renamer
|
|
if extension:
|
|
head, tail = os.path.split(path)
|
|
self.filename_set = tail
|
|
self.rename_or_not = True
|
|
else:
|
|
head = path
|
|
|
|
return os.path.normpath(head)
|
|
|
|
def rename(self, _files, current_path):
|
|
""" Rename for Generic files """
|
|
logging.debug("Renaming Generic file")
|
|
|
|
def filter_files(_file, current_path):
|
|
if is_full_path(_file):
|
|
filepath = os.path.normpath(_file)
|
|
else:
|
|
filepath = os.path.normpath(os.path.join(current_path, _file))
|
|
if os.path.exists(filepath):
|
|
size = os.stat(filepath).st_size
|
|
if size >= cfg.movie_rename_limit.get_int() and not RE_SAMPLE.search(_file) \
|
|
and get_ext(_file) not in EXCLUDED_FILE_EXTS:
|
|
return True
|
|
return False
|
|
|
|
# remove any files below the limit from this list
|
|
files = [_file for _file in _files if filter_files(_file, current_path)]
|
|
|
|
length = len(files)
|
|
# Single File Handling
|
|
if length == 1:
|
|
file = files[0]
|
|
if is_full_path(file):
|
|
filepath = os.path.normpath(file)
|
|
else:
|
|
filepath = os.path.normpath(os.path.join(current_path, file))
|
|
if os.path.exists(filepath):
|
|
self.fname, ext = os.path.splitext(os.path.split(file)[1])
|
|
newname = "%s%s" % (self.filename_set, ext)
|
|
newname = newname.replace('%fn', self.fname)
|
|
newpath = os.path.join(current_path, newname)
|
|
try:
|
|
logging.debug("Rename: %s to %s", filepath, newpath)
|
|
renamer(filepath, newpath)
|
|
except:
|
|
logging.error(T('Failed to rename: %s to %s'), clip_path(filepath), clip_path(newpath))
|
|
logging.info("Traceback: ", exc_info=True)
|
|
rename_similar(current_path, ext, self.filename_set, ())
|
|
|
|
# Sequence File Handling
|
|
# if there is more than one extracted file check for CD1/1/A in the title
|
|
elif self.extra:
|
|
matched_files = check_for_multiple(files)
|
|
# rename files marked as in a set
|
|
if matched_files:
|
|
logging.debug("Renaming a series of generic files (%s)", matched_files)
|
|
renamed = list(matched_files.values())
|
|
for index, file in matched_files.items():
|
|
filepath = os.path.join(current_path, file)
|
|
renamed.append(filepath)
|
|
self.fname, ext = os.path.splitext(os.path.split(file)[1])
|
|
name = '%s%s' % (self.filename_set, self.extra)
|
|
name = name.replace('%1', str(index)).replace('%fn', self.fname)
|
|
name = name + ext
|
|
newpath = os.path.join(current_path, name)
|
|
try:
|
|
logging.debug("Rename: %s to %s", filepath, newpath)
|
|
renamer(filepath, newpath)
|
|
except:
|
|
logging.error(T('Failed to rename: %s to %s'), clip_path(filepath), clip_path(newpath))
|
|
logging.info("Traceback: ", exc_info=True)
|
|
rename_similar(current_path, ext, self.filename_set, renamed)
|
|
else:
|
|
logging.debug("Movie files not in sequence %s", _files)
|
|
|
|
|
|
class DateSorter(object):
|
|
""" Methods for Date Sorting """
|
|
|
|
def __init__(self, nzo, job_name, path, cat):
|
|
self.matched = False
|
|
|
|
self.original_job_name = job_name
|
|
self.original_path = path
|
|
self.sort_string = cfg.date_sort_string()
|
|
self.cats = cfg.date_categories()
|
|
self.cat = cat
|
|
self.nzo = nzo
|
|
self.filename_set = ''
|
|
self.fname = '' # Value for %fn substitution in folders
|
|
|
|
self.match_obj = None
|
|
|
|
self.rename_or_not = False
|
|
self.date_type = None
|
|
|
|
self.date_info = {}
|
|
self.final_path = ''
|
|
|
|
# Check if we match the category in init()
|
|
self.match()
|
|
|
|
def match(self, force=False):
|
|
""" Checks the category for a match, if so set self.matched to true """
|
|
if force or (cfg.enable_date_sorting() and self.sort_string):
|
|
# First check if the show matches TV episode regular expressions. Returns regex match object
|
|
if force or (self.cat and self.cat.lower() in self.cats) or (not self.cat and 'None' in self.cats):
|
|
self.match_obj, self.date_type = check_for_date(self.original_job_name, date_match)
|
|
if self.match_obj:
|
|
logging.debug("Found date for sorting (%s)", self.original_job_name)
|
|
self.matched = True
|
|
|
|
def is_match(self):
|
|
""" Returns whether there was a match or not """
|
|
return self.matched
|
|
|
|
def get_final_path(self):
|
|
""" Collect and construct all the variables such as episode name, show names """
|
|
if self.get_values():
|
|
# Get the final path
|
|
path = self.construct_path()
|
|
self.final_path = os.path.join(self.original_path, path)
|
|
return self.final_path
|
|
else:
|
|
# Error Sorting
|
|
return os.path.join(self.original_path, self.original_job_name)
|
|
|
|
def get_values(self):
|
|
""" Collect and construct all the values needed for path replacement """
|
|
|
|
# 2008-10-16
|
|
if self.date_type == 1:
|
|
self.date_info['year'] = self.match_obj.group(1)
|
|
self.date_info['month'] = self.match_obj.group(2)
|
|
self.date_info['date'] = self.match_obj.group(3)
|
|
# 10.16.2008
|
|
else:
|
|
self.date_info['year'] = self.match_obj.group(3)
|
|
self.date_info['month'] = self.match_obj.group(1)
|
|
self.date_info['date'] = self.match_obj.group(2)
|
|
|
|
self.date_info['month_two'] = self.date_info['month'].rjust(2, '0')
|
|
self.date_info['date_two'] = self.date_info['date'].rjust(2, '0')
|
|
|
|
# - Get Decades
|
|
self.date_info['decade'], self.date_info['decade_two'] = get_decades(self.date_info['year'])
|
|
|
|
# - Get Title
|
|
self.date_info['ttitle'], self.date_info['ttitle_two'], self.date_info['ttitle_three'] = get_titles(self.nzo, self.match_obj, self.original_job_name, True)
|
|
self.date_info['title'], self.date_info['title_two'], self.date_info['title_three'] = get_titles(self.nzo, self.match_obj, self.original_job_name)
|
|
|
|
self.date_info['ep_name'], self.date_info['ep_name_two'], self.date_info['ep_name_three'] = get_descriptions(self.nzo, self.match_obj, self.original_job_name)
|
|
|
|
return True
|
|
|
|
def construct_path(self):
|
|
""" Return path reconstructed from original and sort expression """
|
|
sorter = self.sort_string.replace('\\', '/')
|
|
mapping = []
|
|
|
|
if ends_in_file(sorter):
|
|
extension = True
|
|
sorter = sorter.replace(".%ext", '')
|
|
else:
|
|
extension = False
|
|
|
|
# Replace title
|
|
mapping.append(('%title', self.date_info['title']))
|
|
mapping.append(('%.title', self.date_info['title_two']))
|
|
mapping.append(('%_title', self.date_info['title_three']))
|
|
|
|
mapping.append(('%t', self.date_info['title']))
|
|
mapping.append(('%.t', self.date_info['title_two']))
|
|
mapping.append(('%_t', self.date_info['title_three']))
|
|
|
|
mapping.append(('%sn', self.date_info['ttitle']))
|
|
mapping.append(('%s.n', self.date_info['ttitle_two']))
|
|
mapping.append(('%s_n', self.date_info['ttitle_three']))
|
|
mapping.append(('%sN', self.date_info['title']))
|
|
mapping.append(('%s.N', self.date_info['title_two']))
|
|
mapping.append(('%s_N', self.date_info['title_three']))
|
|
|
|
# Replace year
|
|
mapping.append(('%year', self.date_info['year']))
|
|
mapping.append(('%y', self.date_info['year']))
|
|
|
|
if self.date_info['ep_name']:
|
|
mapping.append(('%desc', self.date_info['ep_name']))
|
|
mapping.append(('%.desc', self.date_info['ep_name_two']))
|
|
mapping.append(('%_desc', self.date_info['ep_name_three']))
|
|
else:
|
|
mapping.append(('%desc', ''))
|
|
mapping.append(('%.desc', ''))
|
|
mapping.append(('%_desc', ''))
|
|
|
|
# Replace dir-name before replacing %d for month
|
|
mapping.append(('%dn', self.original_job_name))
|
|
|
|
# Replace decades
|
|
mapping.append(('%decade', self.date_info['decade']))
|
|
mapping.append(('%0decade', self.date_info['decade_two']))
|
|
|
|
# Replace month
|
|
mapping.append(('%m', self.date_info['month']))
|
|
mapping.append(('%0m', self.date_info['month_two']))
|
|
|
|
# Replace date
|
|
mapping.append(('%d', self.date_info['date']))
|
|
mapping.append(('%0d', self.date_info['date_two']))
|
|
|
|
path = path_subst(sorter, mapping)
|
|
|
|
for key, name in REPLACE_AFTER.items():
|
|
path = path.replace(key, name)
|
|
|
|
# Lowercase all characters wrapped in {}
|
|
path = to_lowercase(path)
|
|
|
|
# Strip any extra ' ' '.' or '_' around foldernames
|
|
path = strip_folders(path)
|
|
|
|
# Split the last part of the path up for the renamer
|
|
if extension:
|
|
head, tail = os.path.split(path)
|
|
self.filename_set = tail
|
|
self.rename_or_not = True
|
|
else:
|
|
head = path
|
|
|
|
return os.path.normpath(head)
|
|
|
|
def rename(self, files, current_path):
|
|
""" Renaming Date file """
|
|
logging.debug("Renaming Date file")
|
|
# find the master file to rename
|
|
for file in files:
|
|
if is_full_path(file):
|
|
filepath = os.path.normpath(file)
|
|
else:
|
|
filepath = os.path.normpath(os.path.join(current_path, file))
|
|
|
|
if os.path.exists(filepath):
|
|
size = os.stat(filepath).st_size
|
|
if size > cfg.movie_rename_limit.get_int():
|
|
if 'sample' not in file:
|
|
self.fname, ext = os.path.splitext(os.path.split(file)[1])
|
|
newname = "%s%s" % (self.filename_set, ext)
|
|
newname = newname.replace('%fn', self.fname)
|
|
newpath = os.path.join(current_path, newname)
|
|
if not os.path.exists(newpath):
|
|
try:
|
|
logging.debug("Rename: %s to %s", filepath, newpath)
|
|
renamer(filepath, newpath)
|
|
except:
|
|
logging.error(T('Failed to rename: %s to %s'), clip_path(current_path), clip_path(newpath))
|
|
logging.info("Traceback: ", exc_info=True)
|
|
rename_similar(current_path, ext, self.filename_set, ())
|
|
break
|
|
|
|
|
|
def path_subst(path, mapping):
|
|
""" Replace the sort sting elements by real values.
|
|
Non-elements are copied literally.
|
|
path = the sort string
|
|
mapping = array of tuples that maps all elements to their values
|
|
"""
|
|
# Added ugly hack to prevent %ext from being masked by %e
|
|
newpath = []
|
|
plen = len(path)
|
|
n = 0
|
|
while n < plen:
|
|
result = path[n]
|
|
if result == '%':
|
|
for key, value in mapping:
|
|
if path.startswith(key, n) and not path.startswith('%ext', n):
|
|
n += len(key) - 1
|
|
result = value
|
|
break
|
|
newpath.append(result)
|
|
n += 1
|
|
return ''.join([unicoder(x) for x in newpath])
|
|
|
|
|
|
def get_titles(nzo, match, name, titleing=False):
|
|
""" The title will be the part before the match
|
|
Clean it up and title() it
|
|
|
|
''.title() isn't very good under python so this contains
|
|
a lot of little hacks to make it better and for more control
|
|
"""
|
|
if nzo:
|
|
title = nzo.nzo_info.get('propername')
|
|
else:
|
|
title = ''
|
|
if not title:
|
|
if match:
|
|
name = name[:match.start()]
|
|
|
|
# Replace .US. with (US)
|
|
if cfg.tv_sort_countries() == 1:
|
|
for rep in COUNTRY_REP:
|
|
# (us) > (US)
|
|
name = replace_word(name, rep.lower(), rep)
|
|
# (Us) > (US)
|
|
name = replace_word(name, rep.title(), rep)
|
|
# .US. > (US)
|
|
dotted_country = '.%s.' % (rep.strip('()'))
|
|
name = replace_word(name, dotted_country, rep)
|
|
# Remove .US. and (US)
|
|
elif cfg.tv_sort_countries() == 2:
|
|
for rep in COUNTRY_REP:
|
|
# Remove (US)
|
|
name = replace_word(name, rep, '')
|
|
dotted_country = '.%s.' % (rep.strip('()'))
|
|
# Remove .US.
|
|
name = replace_word(name, dotted_country, '.')
|
|
|
|
title = name.replace('.', ' ').replace('_', ' ')
|
|
title = title.strip().strip('(').strip('_').strip('-').strip().strip('_')
|
|
|
|
if titleing:
|
|
title = title.title() # title the show name so it is in a consistent letter case
|
|
|
|
# title applied uppercase to 's Python bug?
|
|
title = title.replace("'S", "'s")
|
|
|
|
# Replace titled country names, (Us) with (US) and so on
|
|
if cfg.tv_sort_countries() == 1:
|
|
for rep in COUNTRY_REP:
|
|
title = title.replace(rep.title(), rep)
|
|
# Remove country names, ie (Us)
|
|
elif cfg.tv_sort_countries() == 2:
|
|
for rep in COUNTRY_REP:
|
|
title = title.replace(rep.title(), '').strip()
|
|
|
|
# Make sure some words such as 'and' or 'of' stay lowercased.
|
|
for x in LOWERCASE:
|
|
xtitled = x.title()
|
|
title = replace_word(title, xtitled, x)
|
|
|
|
# Make sure some words such as 'III' or 'IV' stay uppercased.
|
|
for x in UPPERCASE:
|
|
xtitled = x.title()
|
|
title = replace_word(title, xtitled, x)
|
|
|
|
# Make sure the first letter of the title is always uppercase
|
|
if title:
|
|
title = title[0].title() + title[1:]
|
|
|
|
# The title with spaces replaced by dots
|
|
dots = title.replace(" - ", "-").replace(' ', '.').replace('_', '.')
|
|
dots = dots.replace('(', '.').replace(')', '.').replace('..', '.').rstrip('.')
|
|
|
|
# The title with spaces replaced by underscores
|
|
underscores = title.replace(' ', '_').replace('.', '_').replace('__', '_').rstrip('_')
|
|
|
|
return title, dots, underscores
|
|
|
|
|
|
def replace_word(word_input, one, two):
|
|
""" Regex replace on just words """
|
|
regex = re.compile(r'\W(%s)(\W|$)' % one, re.I)
|
|
matches = regex.findall(word_input)
|
|
if matches:
|
|
for unused in matches:
|
|
word_input = word_input.replace(one, two)
|
|
return word_input
|
|
|
|
|
|
def get_descriptions(nzo, match, name):
|
|
""" If present, get a description from the nzb name.
|
|
A description has to be after the matched item, separated either
|
|
like ' - Description' or '_-_Description'
|
|
"""
|
|
if nzo:
|
|
ep_name = nzo.nzo_info.get('episodename')
|
|
else:
|
|
ep_name = ''
|
|
if not ep_name:
|
|
if match:
|
|
ep_name = name[match.end():] # Need to improve for multi-ep support
|
|
else:
|
|
ep_name = name
|
|
ep_name = ep_name.strip(' _.')
|
|
if ep_name.startswith('-'):
|
|
ep_name = ep_name.strip('- _.')
|
|
if '.' in ep_name and ' ' not in ep_name:
|
|
ep_name = ep_name.replace('.', ' ')
|
|
ep_name = ep_name.replace('_', ' ')
|
|
ep_name2 = ep_name.replace(" - ", "-").replace(" ", ".")
|
|
ep_name3 = ep_name.replace(" ", "_")
|
|
return ep_name, ep_name2, ep_name3
|
|
|
|
|
|
def get_decades(year):
|
|
""" Return 4 digit and 2 digit decades given 'year' """
|
|
if year:
|
|
try:
|
|
decade = year[2:3] + '0'
|
|
decade2 = year[:3] + '0'
|
|
except:
|
|
decade = ''
|
|
decade2 = ''
|
|
else:
|
|
decade = ''
|
|
decade2 = ''
|
|
return decade, decade2
|
|
|
|
|
|
def check_for_folder(path):
|
|
""" Return True if any folder is found in the tree at 'path' """
|
|
for _root, dirs, _files in os.walk(path):
|
|
if dirs:
|
|
return True
|
|
return False
|
|
|
|
|
|
def to_lowercase(path):
|
|
""" Lowercases any characters enclosed in {} """
|
|
_RE_LOWERCASE = re.compile(r'{([^{]*)}')
|
|
while True:
|
|
m = _RE_LOWERCASE.search(path)
|
|
if not m:
|
|
break
|
|
path = path[:m.start()] + m.group(1).lower() + path[m.end():]
|
|
|
|
# just in case
|
|
path = path.replace('{', '')
|
|
path = path.replace('}', '')
|
|
return path
|
|
|
|
|
|
def strip_folders(path):
|
|
""" Return 'path' without leading and trailing spaces and underscores in each element
|
|
For Windows, also remove leading and trailing dots
|
|
"""
|
|
unc = sabnzbd.WIN32 and (path.startswith('//') or path.startswith('\\\\'))
|
|
f = path.strip('/').split('/')
|
|
|
|
# For path beginning with a slash, insert empty element to prevent loss
|
|
if path.strip()[0] in '/\\':
|
|
f.insert(0, '')
|
|
|
|
def strip_all(x):
|
|
""" Strip all leading/trailing underscores also dots for Windows """
|
|
x = x.strip().strip('_')
|
|
if sabnzbd.WIN32:
|
|
# OSX and Linux should keep dots, because leading dots are significant
|
|
# while Windows cannot handle trailing dots
|
|
x = x.strip('.')
|
|
x = x.strip()
|
|
return x
|
|
|
|
path = os.path.normpath('/'.join([strip_all(x) for x in f]))
|
|
if unc:
|
|
return '\\' + path
|
|
else:
|
|
return path
|
|
|
|
|
|
def rename_similar(folder, skip_ext, name, skipped_files):
|
|
""" Rename all other files in the 'folder' hierarchy after 'name'
|
|
and move them to the root of 'folder'.
|
|
Files having extension 'skip_ext' will be moved, but not renamed.
|
|
Don't touch files in list `skipped_files`
|
|
"""
|
|
logging.debug('Give files in set "%s" matching names.', name)
|
|
folder = os.path.normpath(folder)
|
|
skip_ext = skip_ext.lower()
|
|
|
|
for root, dirs, files in os.walk(folder):
|
|
for f in files:
|
|
path = os.path.join(root, f)
|
|
if path in skipped_files:
|
|
continue
|
|
org, ext = os.path.splitext(f)
|
|
if ext.lower() == skip_ext:
|
|
# Move file, but do not rename
|
|
newpath = os.path.join(folder, f)
|
|
else:
|
|
# Move file and rename
|
|
newname = "%s%s" % (name, ext)
|
|
newname = newname.replace('%fn', org)
|
|
newpath = os.path.join(folder, newname)
|
|
if path != newpath:
|
|
newpath = get_unique_filename(newpath)
|
|
try:
|
|
logging.debug("Rename: %s to %s", path, newpath)
|
|
renamer(path, newpath)
|
|
except:
|
|
logging.error(T('Failed to rename similar file: %s to %s'), clip_path(path), clip_path(newpath))
|
|
logging.info("Traceback: ", exc_info=True)
|
|
cleanup_empty_directories(folder)
|
|
|
|
|
|
def check_regexs(filename, matchers):
|
|
""" Regular Expression match for a list of regexes
|
|
Returns the MatchObject if a match is made
|
|
This version checks for an additional match
|
|
"""
|
|
extras = []
|
|
for expressions in matchers:
|
|
expression, extramatchers = expressions
|
|
match1 = expression.search(filename)
|
|
if match1:
|
|
for m in extramatchers:
|
|
match2 = m.findall(filename, match1.end())
|
|
if match2:
|
|
for match in match2:
|
|
if type(match) == type(()) and len(match) > 1:
|
|
extras.append(match[1])
|
|
else:
|
|
extras.append(match)
|
|
break
|
|
return match1, extras
|
|
return None, None
|
|
|
|
|
|
def check_for_date(filename, matcher):
|
|
""" Regular Expression match for date based files
|
|
Returns the MatchObject if a match is made
|
|
"""
|
|
x = 0
|
|
if matcher:
|
|
for expression in matcher:
|
|
regex = re.compile(expression)
|
|
match1 = regex.search(filename)
|
|
x += 1
|
|
if match1:
|
|
return match1, x
|
|
return None, 0
|
|
|
|
|
|
def is_full_path(file):
|
|
""" Return True if path is absolute """
|
|
if file.startswith('\\') or file.startswith('/'):
|
|
return True
|
|
try:
|
|
if file[1:3] == ':\\':
|
|
return True
|
|
except:
|
|
pass
|
|
return False
|
|
|
|
|
|
def eval_sort(sorttype, expression, name=None, multipart=''):
|
|
""" Preview a sort expression, to be used by API """
|
|
from sabnzbd.api import Ttemplate
|
|
path = ''
|
|
name = sanitize_foldername(name)
|
|
if sorttype == 'series':
|
|
name = name or ('%s S01E05 - %s [DTS]' % (Ttemplate('show-name'), Ttemplate('ep-name')))
|
|
sorter = SeriesSorter(None, name, path, 'tv')
|
|
elif sorttype == 'movie':
|
|
name = name or (Ttemplate('movie-sp-name') + ' (2009)')
|
|
sorter = MovieSorter(None, name, path, 'tv')
|
|
elif sorttype == 'date':
|
|
name = name or (Ttemplate('show-name') + ' 2009-01-02')
|
|
sorter = DateSorter(None, name, path, 'tv')
|
|
else:
|
|
return None
|
|
sorter.sort_string = expression
|
|
sorter.match(force=True)
|
|
path = sorter.get_final_path()
|
|
path = os.path.normpath(os.path.join(path, sorter.filename_set))
|
|
fname = Ttemplate('orgFilename')
|
|
fpath = path
|
|
if sorttype == 'movie' and '%1' in multipart:
|
|
fname = fname + multipart.replace('%1', '1')
|
|
fpath = fpath + multipart.replace('%1', '1')
|
|
if '%fn' in path:
|
|
path = path.replace('%fn', fname + '.mkv')
|
|
else:
|
|
if sorter.rename_or_not:
|
|
path = fpath + '.mkv'
|
|
else:
|
|
if sabnzbd.WIN32:
|
|
path += '\\'
|
|
else:
|
|
path += '/'
|
|
return path
|
|
|