parent
63cfaefe2b
commit
8fc06ca351
2 changed files with 845 additions and 633 deletions
@ -1,633 +0,0 @@ |
||||
#!/usr/bin/env python3 |
||||
# (c) 2015 Taeyeon Mori <orochimarufan.x3@gmail.com> |
||||
# Anime Import V2-4 |
||||
|
||||
import os |
||||
import sys |
||||
import re |
||||
import itertools |
||||
import logging |
||||
import argparse |
||||
|
||||
|
||||
logger = logging.getLogger("AnimeImport") |
||||
|
||||
|
||||
############################################################################### |
||||
## Utilities ## |
||||
############################################################################### |
||||
def cat_to(filename, text): |
||||
with open(filename, "w") as f: |
||||
f.write(text) |
||||
|
||||
|
||||
def cat_from(filename): |
||||
with open(filename) as f: |
||||
return f.read() |
||||
|
||||
|
||||
def abspath_from(path, anchor): |
||||
return os.path.normpath(os.path.join(anchor, path)) if not os.path.isabs(path) else path |
||||
|
||||
|
||||
def transport_path(path, old_anchor, new_anchor): |
||||
""" |
||||
:brief: Transport a relative path from one anchor to another |
||||
""" |
||||
return os.path.relpath(abspath_from(path, old_anchor), new_anchor) |
||||
|
||||
|
||||
def make_symlink(target, at, anchor=None): |
||||
""" |
||||
:brief: Make a symbolic link |
||||
:param target: The link target |
||||
:param at: The location of the new symlink |
||||
:param anchor: The anchor if <target> is relative. defaults to os.curdir |
||||
This function preserves the absoluteness of <target>, meaning that if you pass in |
||||
an absolute path, the link will be created using that absolute path. |
||||
However, any relative path will be transported to the new link's containing directory. |
||||
That is important if the link isn't created in the cwd because posix symlink() can take |
||||
any value which will be interpreted relative to the directory containing the link. |
||||
""" |
||||
if os.path.isabs(target): |
||||
os.symlink(target, at) |
||||
elif os.path.isdir(at): |
||||
os.symlink(transport_path(target, anchor if anchor else os.curdir, at), at) |
||||
else: |
||||
os.symlink(transport_path(target, anchor if anchor else os.curdir, os.path.dirname(at)), at) |
||||
|
||||
|
||||
def clean_links(path): |
||||
for f in os.listdir(path): |
||||
if not f.startswith(".") and os.path.islink(os.path.join(path, f)): |
||||
os.unlink(os.path.join(path, f)) |
||||
|
||||
|
||||
def maybe_number(s): |
||||
return int(s) if s.isdigit() else s |
||||
|
||||
|
||||
def opt_value_str(o): |
||||
# since maybe_number converts them back to numbers, we abuse numbers as bools |
||||
if o == True: |
||||
return "1" |
||||
elif o == False: |
||||
return "0" |
||||
else: |
||||
return str(o) |
||||
|
||||
|
||||
def natural_sort_key(s, _nsre=re.compile(r'(\d+)')): |
||||
return [int(text) if text.isdigit() else text.lower() for text in _nsre.split(s)] |
||||
|
||||
|
||||
############################################################################### |
||||
## Specials patterns ## |
||||
############################################################################### |
||||
class Special: |
||||
""" |
||||
Specials format: |
||||
Specials <= Special '\n' Specials | Special |
||||
Special <= Properties '\n' Regexp |
||||
Regexp <= regular expression with optional 'ep' group |
||||
Properties <= Property '|' Properties | Property |
||||
Property <= Name '=' Value |
||||
Name <= python identifier |
||||
Value <= string value without '\n' and '|' |
||||
Valid keys: |
||||
type <= "extern" "special" "opening" "ending" "trailer" "parody" "other" |
||||
name <= Folder name for "extern" type |
||||
offset <= Number to add to the episode number |
||||
epnum <= Force all matched episodes to have this episode number (discouraged. use offset instead) |
||||
first <= start counting episodes here. This only applies if <ep> is NOT matched in the regexp |
||||
subdir <= Specials are located in a subdir of the source |
||||
Regexp: |
||||
Should contain an <ep> group to match the (relative to [offset]) episode/special/ending/etc number |
||||
""" |
||||
def __init__(self, properties, pattern): |
||||
self._properties = properties |
||||
self.pattern = pattern |
||||
|
||||
self.season = 0 |
||||
|
||||
if "type" in properties: |
||||
t = properties["type"].lower() |
||||
|
||||
if t in ("extern", "episode", "episodes"): |
||||
self.season = 1 |
||||
off = 0 |
||||
elif t in ("special", "s", "specials"): |
||||
off = 0 |
||||
elif t in ("opening", "op", "openings"): |
||||
off = 99 |
||||
elif t in ("ending", "ed", "endings"): |
||||
off = 149 |
||||
elif t in ("trailer", "t", "trailers"): |
||||
off = 199 |
||||
elif t in ("parody", "p", "parodies"): |
||||
off = 299 |
||||
elif t in ("other", "o", "others"): |
||||
off = 399 |
||||
else: |
||||
off = 499 |
||||
|
||||
self.type_offset = off |
||||
else: |
||||
self.type_offset = 0 |
||||
|
||||
self.is_extern = "name" in properties |
||||
self.is_subdir = "subdir" in properties |
||||
|
||||
if "$custom" in properties: |
||||
if not callable(properties["$custom"]): |
||||
raise ValueError("$custom Specials must be created from python code and be callable objects") |
||||
self.custom = properties["$custom"] |
||||
|
||||
def __getattr__(self, name): |
||||
return self._properties[name] |
||||
|
||||
def __contains__(self, name): |
||||
return name in self._properties |
||||
|
||||
def source(self): |
||||
return "\n".join(("|".join(map("=".join, self._properties.items())), self.pattern.pattern)) |
||||
|
||||
def match(self, f): |
||||
return self.pattern.search(f) |
||||
|
||||
def custom(self, m, i): |
||||
return None |
||||
|
||||
def adjust_episode(self, ep): |
||||
ep += self.type_offset |
||||
|
||||
if "offset" in self: |
||||
return ep + int(self.offset) |
||||
elif "epnum" in self: |
||||
return int(self.epnum) |
||||
else: |
||||
return ep |
||||
|
||||
def get_episode(self, match, last): |
||||
if "ep" in self.pattern.groupindex and match.group("ep"): |
||||
# TODO: fix this. When doing it properly it breaks the assumption that ...OP is OP1 while OPn is OPn. It makes OP OPn+1 |
||||
return self.adjust_episode(int(match.group("ep"))) |
||||
#return self.adjust_episode(int(match.group("ep"))) |
||||
elif last == 0: |
||||
return self.adjust_episode(1) |
||||
else: |
||||
return last + 1 |
||||
|
||||
@staticmethod |
||||
def parse_properties(src): |
||||
return dict(map(lambda p: p.split("=", 1), src.split("|"))) |
||||
|
||||
@classmethod |
||||
def parse(cls, props_src, regexp_src): |
||||
return cls(cls.parse_properties(props_src), re.compile(regexp_src)) |
||||
|
||||
@classmethod |
||||
def iterparse(cls, src): |
||||
for a, b in zip(*[iter(src.split("\n"))]*2): |
||||
yield cls.parse(a, b) |
||||
|
||||
@classmethod |
||||
def simple(cls, type, regexp): |
||||
return cls({"type": type}, re.compile(regexp)) |
||||
|
||||
|
||||
############################################################################### |
||||
## The core ## |
||||
############################################################################### |
||||
class Importer: |
||||
# Filenames |
||||
source_fn = ".source" |
||||
master_fn = ".main" |
||||
pattern_fn = ".pattern" |
||||
exclude_fn = ".exclude" |
||||
special_fn = ".specials" |
||||
options_fn = ".options" |
||||
|
||||
# Defaults |
||||
default_pattern = re.compile(r"[_ ](E|e|Ep|Episode|ep)?[ _]?(?P<ep>\d+)([Vv]\d+)?[_ ]?[\(\[\.]?") |
||||
default_options = { |
||||
"auto_specials": 1, |
||||
"exclude_parts": 1, |
||||
"exclude_playlists": 1, |
||||
} |
||||
|
||||
auto_specials = [ |
||||
Special.simple("Opening", r"[_ ](NC)?OP[ _]?[^\d]"), # FIXME: HACK |
||||
Special.simple("Opening", r"[_ ]((NC)?OP|Opening)[ _]?(?P<ep>\d+)"), |
||||
Special.simple("Ending", r"[_ ](NC)?ED[ _]?[^\d]"), # FIXME: HACK |
||||
Special.simple("Ending", r"[_ ]((NC)?ED|Ending|Closing)[ _]?(?P<ep>\d+)"), |
||||
Special.simple("Special", r"[_ ](Special|OVA|SP)[ _]?(?P<ep>\d+)?[ _]?"), |
||||
# .nfo files |
||||
Special({"$custom": lambda m, i: ("link", os.path.join(i.destination, i.main_name + ".nfo"))}, re.compile(r".nfo$")) |
||||
] |
||||
|
||||
link_template = "{series} S{season:d}E{episode:03d}" |
||||
|
||||
def __init__(self, destination): |
||||
# Find master |
||||
master_f = os.path.join(destination, self.master_fn) |
||||
|
||||
while os.path.islink(master_f): |
||||
new_dest = os.readlink(master_f) |
||||
if not os.path.isabs(new_dest): |
||||
new_dest = transport_path(new_dest, destination, os.curdir) |
||||
|
||||
logger.info("Destination '%s' belongs to '%s'; Selecting that instead" % (destination, new_dest)) |
||||
|
||||
destination = new_dest |
||||
master_f = os.path.join(destination, self.master_fn) |
||||
|
||||
self.destination = os.path.abspath(destination) |
||||
self.main_name = os.path.basename(self.destination) |
||||
|
||||
source_f = os.path.join(destination, self.source_fn) |
||||
if os.path.islink(source_f): |
||||
self.source = os.readlink(source_f) |
||||
if not os.path.isabs(self.source): |
||||
self.source = transport_path(self.source, destination, os.curdir) |
||||
else: |
||||
self.source = None |
||||
|
||||
options_f = os.path.join(destination, self.options_fn) |
||||
if os.path.isfile(options_f): |
||||
self.options = {k: maybe_number(v) for k, v in (x.split(": ") for x in cat_from(options_f).split("\n"))} |
||||
else: |
||||
self.options = {} |
||||
|
||||
pattern_f = os.path.join(destination, self.pattern_fn) |
||||
if os.path.isfile(pattern_f): |
||||
self.pattern = re.compile(cat_from(pattern_f).rstrip("\n")) |
||||
else: |
||||
self.pattern = self.default_pattern |
||||
|
||||
exclude_f = os.path.join(destination, self.exclude_fn) |
||||
if os.path.isfile(exclude_f): |
||||
self.exclude = re.compile(cat_from(exclude_f).rstrip("\n")) |
||||
else: |
||||
self.exclude = None |
||||
|
||||
special_f = os.path.join(destination, self.special_fn) |
||||
if os.path.isfile(special_f): |
||||
self.specials = list(Special.iterparse(cat_from(special_f))) |
||||
else: |
||||
self.specials = [] |
||||
|
||||
def _save(self, filename, content): |
||||
# save data to <destination>/<filename> |
||||
path = os.path.join(self.destination, filename) |
||||
if content is not None: |
||||
with open(path, "w") as f: |
||||
f.write(content) |
||||
elif os.path.exists(path): |
||||
os.unlink(path) |
||||
|
||||
def save(self): |
||||
# Write settings to disk |
||||
if not os.path.isdir(self.destination): |
||||
os.mkdir(self.destination) |
||||
|
||||
self._save(self.pattern_fn, self.pattern.pattern if self.pattern is not self.default_pattern else None) |
||||
self._save(self.exclude_fn, self.exclude.pattern if self.exclude is not None else None) |
||||
self._save(self.special_fn, "\n".join(map(Special.source, self.specials)) if self.specials else None) |
||||
self._save(self.options_fn, "\n".join((": ".join((k, opt_value_str(v))) for k, v in self.options.items())) if self.options else None) |
||||
|
||||
source_f = os.path.join(self.destination, self.source_fn) |
||||
if os.path.islink(source_f): |
||||
oldpath = transport_path(os.readlink(source_f), self.destination, os.curdir) |
||||
if oldpath != self.source.rstrip("/"): |
||||
logger.warn("Updating source link '%s' with '%s'" % (oldpath, self.source)) |
||||
os.unlink(source_f) |
||||
make_symlink(self.source, source_f) |
||||
|
||||
for sp in self.specials: |
||||
if not sp.is_extern: |
||||
continue |
||||
|
||||
path = os.path.join(self.destination, "..", sp.name) |
||||
|
||||
if not os.path.isdir(path): |
||||
os.mkdir(path) |
||||
|
||||
master_f = os.path.join(path, self.master_fn) |
||||
if os.path.islink(master_f): |
||||
oldpath = transport_path(os.readlink(master_f), path, os.curdir) |
||||
if oldpath != self.destination.rstrip("/"): |
||||
logger.warn("Updating master link '%s' with '%s'" % (oldpath, self.destination)) |
||||
os.unlink(master_f) |
||||
make_symlink(self.destination, master_f) |
||||
|
||||
@property |
||||
def effective_specials(self): |
||||
return itertools.chain(self.specials, self.auto_specials) if self.option("auto_specials") else self.specials |
||||
|
||||
def option(self, name): |
||||
return self.options[name] if name in self.options else self.default_options.get(name, None) |
||||
|
||||
def process_file(self, filename, subdir=None): |
||||
# Exclude |
||||
if self.option("exclude_parts") and filename.endswith(".part"): |
||||
return "skip", "partial download" |
||||
|
||||
elif self.option("exclude_playlists") and (filename[-4:] in (".vml", ".m3u", ".pls")): |
||||
return "skip", "playlist file" |
||||
|
||||
elif self.exclude and self.exclude.search(filename): |
||||
return "skip", "excluded" |
||||
|
||||
linkpath = self.destination |
||||
|
||||
# Specials |
||||
for special in self.effective_specials: |
||||
if bool(subdir) != special.is_subdir or (subdir and subdir != special.subdir): |
||||
continue |
||||
|
||||
sm = special.match(filename) |
||||
if sm: |
||||
result = special.custom(sm, self) |
||||
if result: |
||||
return result |
||||
|
||||
ep = special.get_episode(sm, self.last_special.get(special, 0)) |
||||
self.last_special[special] = ep |
||||
|
||||
if special.is_extern: |
||||
name = special.name |
||||
linkpath = os.path.join(self.destination, "..", name) |
||||
else: |
||||
name = self.main_name |
||||
|
||||
linkname = self.link_template.format(series=name, season=special.season, episode=ep) |
||||
break |
||||
|
||||
else: |
||||
if subdir: |
||||
#logger.warn("Unhandled file in subdir %s: %s" % (subdir, filename)) |
||||
return "skip", "in subdirectory" |
||||
|
||||
m = self.pattern.search(filename) |
||||
|
||||
if m: |
||||
self.last_episode = int(m.group("ep")) |
||||
else: |
||||
self.last_episode += 1 |
||||
|
||||
linkname = self.link_template.format(series=self.main_name, season=1, episode=self.last_episode) # TODO: allow more seasons? |
||||
|
||||
return "link", os.path.join(linkpath, linkname + os.path.splitext(filename)[1]) |
||||
|
||||
def clean_all(self): |
||||
clean_links(self.destination) |
||||
|
||||
for special in self.specials: # auto_specials doesn't have "extern" specials |
||||
if special.is_extern: |
||||
clean_links(os.path.join(self.destination, "..", special.name)) |
||||
|
||||
def run(self, *, dry=False): |
||||
if not dry: |
||||
self.clean_all() |
||||
|
||||
self.last_episode = 0 # FIXME: global state |
||||
self.last_special = {} |
||||
|
||||
for f in sorted(os.listdir(self.source), key=natural_sort_key): |
||||
path = os.path.join(self.source, f) |
||||
|
||||
if os.path.isdir(path): |
||||
if self.specials: |
||||
for ff in sorted(os.listdir(path), key=natural_sort_key): |
||||
if self.handle_file(os.path.join(f, ff), *self.process_file(ff, subdir=f), dry=dry) != 0: |
||||
return 1 |
||||
else: |
||||
if self.handle_file(f, *self.process_file(f), dry=dry) != 0: |
||||
return 1 |
||||
|
||||
def handle_file(self, f, what, where, *, dry=False): |
||||
if what == "link": |
||||
if os.path.exists(where) and not dry: |
||||
logger.error("LINK %s => %s exists!" % (f, os.path.basename(where))) |
||||
return 1 |
||||
else: |
||||
logger.info("LINK %s => %s" % (f, os.path.basename(where))) |
||||
|
||||
if not dry: |
||||
make_symlink(os.path.join(self.source, f), where) |
||||
|
||||
elif what == "skip": |
||||
logger.info("SKIP %s (%s)" % (f, where)) |
||||
|
||||
else: |
||||
assert(False and "Should not be reached") |
||||
|
||||
return 0 |
||||
|
||||
def reset(self, *things): |
||||
if "pattern" in things: |
||||
self.pattern = self.default_pattern |
||||
if "exclude" in things: |
||||
self.exclude = None |
||||
if "specials" in things: |
||||
self.specials.clear() |
||||
if "options" in things: |
||||
self.options.clear() |
||||
|
||||
def print_info(self): |
||||
print("Import Info for %s:" % self.main_name) |
||||
print(" Pattern: r'%s'%s" % (self.pattern.pattern, " (default)" if self.pattern is self.default_pattern else "")) |
||||
print(" Exclude: %s" % (("r'%s'" % self.exclude.pattern) if self.exclude else "None")) |
||||
print(" Options: %s" % self.options) |
||||
print(" Specials:%s" % (" (None)" if not self.specials else "")) |
||||
for special in self.specials: |
||||
print(" %-25s :: %s" % ("r'%s'" % special.pattern.pattern, special._properties)) |
||||
|
||||
def flags(self): |
||||
return "".join((f for c, f in [ |
||||
(self.pattern is not self.default_pattern, "p"), |
||||
(self.exclude, "e"), |
||||
#(self.option("exclude_parts"), "d"), |
||||
(not self.option("exclude_parts"), "D"), |
||||
(self.option("auto_specials"), "i"), |
||||
(not self.option("auto_specials"), "I"), |
||||
(self.specials, str(len(self.specials))), |
||||
] if c)) |
||||
|
||||
|
||||
############################################################################### |
||||
## Argument handling ## |
||||
############################################################################### |
||||
class HelpFormatter(argparse.RawTextHelpFormatter): |
||||
def __init__(self, prog): |
||||
super().__init__(prog, max_help_position=16) |
||||
|
||||
|
||||
def parse_args(argv): |
||||
parser = argparse.ArgumentParser(prog=argv[0], formatter_class=HelpFormatter) |
||||
|
||||
paths = parser.add_argument_group("Paths") |
||||
paths.add_argument("source", |
||||
help="The source directory") |
||||
paths.add_argument("destination", default=".", nargs="?", |
||||
help="The target directory. Note that all visible symlinks inside will be deleted! (default: working dir)") |
||||
paths.add_argument("-r", "--recurse", action="store_true", |
||||
help="Walk all subdirectories of <destination>") |
||||
paths.add_argument("-S", "--check-unknown", action="append", default=[], metavar="PATH", |
||||
help="Check The source directory for untracked folders. Use with --recurse") |
||||
paths.add_argument("-X", "--check-ignore", action="append", default=[], metavar="FOLDER", |
||||
help="Ignore a folder <FOLDER> when checking for untracked folders") |
||||
|
||||
patterns = parser.add_argument_group("Patterns", |
||||
description="Patterns are Python re patterns: https://docs.python.org/3/library/re.html") |
||||
patterns.add_argument("-p", "--pattern", default=None, metavar="PATTERN", |
||||
help="Set the episode pattern. Include a named group <ep>") |
||||
patterns.add_argument("-x", "--exclude", default=None, metavar="PATTERN", |
||||
help="Set the exclusion pattern") |
||||
patterns.add_argument("-s", "--special", "--specials", default=[], nargs=2, action="append", metavar=("SPECIAL", "PATTERN"), dest="specials", |
||||
help=("Set the special mapping. This takes 2 arguments and can be specified multiple times.\n" |
||||
"1st argument: key=value properties separated by '|'.\n" |
||||
"2nd argument: the matching pattern. It should contain a <ep> group\n" |
||||
"Valid keys so far are 'type', 'offset', 'name'.\n" |
||||
"type can be 'special', 'opening', 'ending', 'trailer', 'parody', 'other' and 'extern'.\n" |
||||
"offset adds a fixed number to all episodes numbers matched by the pattern.\n" |
||||
"name must only be used with 'extern'. It creates a slave Series to hold the matched episodes in the parent directory.\n" |
||||
"It's useful for singling out specials that are recorded as independent series in the metadata provider.")) |
||||
patterns.add_argument("-a", "--append", action="store_true", |
||||
help="Extend the special mapping instead of replacing it") |
||||
|
||||
options = parser.add_argument_group("Options") |
||||
options.add_argument("-i", "--auto-specials", action="store_true", dest="auto_specials", default=None, |
||||
help="Implicitly add some Specials patterns (default)") |
||||
options.add_argument("-I", "--no-auto-specials", action="store_false", dest="auto_specials", default=None, |
||||
help="Don't add the implicit Specials patterns") |
||||
|
||||
parser.add_argument("--clear", default=[], action="append", choices={"pattern", "exclude", "specials", "options"}, |
||||
help="Reset a property to the defaults") |
||||
parser.add_argument("-D", "--dry-run", action="store_true", |
||||
help="Don't save anything to disk Useful combination") |
||||
parser.add_argument("-q", "--quiet", action="store_true") |
||||
|
||||
return parser.parse_args(argv[1:]) |
||||
|
||||
|
||||
############################################################################### |
||||
## Put the pieces together ## |
||||
############################################################################### |
||||
def main(argv): |
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)-5s %(message)s") |
||||
|
||||
args = parse_args(argv) |
||||
|
||||
if args.quiet: |
||||
logger.setLevel(logging.WARNING) |
||||
|
||||
if args.source == "info": |
||||
if args.recurse: |
||||
logger.setLevel(logging.WARNING) |
||||
have_dirs = set() |
||||
|
||||
for dest in filter(os.path.isdir, (os.path.join(args.destination, x) for x in os.listdir(args.destination))): |
||||
i = Importer(dest) |
||||
if i.destination not in have_dirs: |
||||
i.print_info() |
||||
print() |
||||
have_dirs.add(i.destination) |
||||
# We just ignore dupes from slaves |
||||
|
||||
else: |
||||
Importer(args.destination).print_info() |
||||
print() |
||||
return 0 |
||||
|
||||
if args.recurse: |
||||
if args.pattern or args.exclude or args.specials or args.auto_specials is not None or args.clear: |
||||
logger.error("--recurse can only be combined with --check-unknown") |
||||
return -1 |
||||
|
||||
got_dirs = set() |
||||
OK = 0 |
||||
dirs = filter(os.path.isdir, (os.path.join(args.destination, x) for x in os.listdir(args.destination))) |
||||
|
||||
if args.source == "update": |
||||
fin_dirs = set() |
||||
|
||||
for dest in dirs: |
||||
i = Importer(dest) |
||||
logger.info("Processing '%s' (%s)" % (dest, i.flags())) |
||||
if not i.source: |
||||
logger.info("'%s' doesn't seem to be an import. Skipping" % os.path.basename(dest)) |
||||
elif i.destination in fin_dirs: |
||||
logger.info("Already processed '%s'. Skipping" % os.path.basename(dest)) |
||||
elif not os.path.exists(i.source): |
||||
logger.error("Source directory doesn't exist: '%s'" % i.source) |
||||
OK += 1 |
||||
elif not i.run(dry=args.dry_run): # returns 0 (False) on success |
||||
got_dirs.add(os.path.abspath(i.source)) |
||||
fin_dirs.add(i.destination) |
||||
else: |
||||
OK += 1 |
||||
|
||||
elif args.source == "check": |
||||
for dest in dirs: |
||||
i = Importer(dest) |
||||
if i.source: |
||||
got_dirs.add(os.path.abspath(i.source)) |
||||
|
||||
else: |
||||
logger.error("--recurse can only be used with 'update', 'check' and 'info'") |
||||
return -1 |
||||
|
||||
if args.check_unknown: |
||||
dirs = set(map(os.path.abspath, filter(os.path.isdir, itertools.chain.from_iterable(((os.path.join(f, x) for x in os.listdir(f)) for f in args.check_unknown))))) |
||||
ignore = set(map(os.path.abspath, filter(os.path.isdir, itertools.chain.from_iterable(((os.path.join(f, x) for x in args.check_ignore) for f in args.check_unknown))))) |
||||
missing = dirs - got_dirs - ignore |
||||
if missing: |
||||
print("Found missing directories: %s" % "\n ".join(missing)) |
||||
|
||||
return OK |
||||
|
||||
elif args.source == "check": |
||||
logger.error("'check' only makes sense in combination with --recurse.") |
||||
return -1 |
||||
|
||||
else: |
||||
if args.check_unknown: |
||||
logger.error("--check-unknown must be used with --recurse") |
||||
return -1 |
||||
|
||||
i = Importer(args.destination) |
||||
|
||||
if args.source != "update": |
||||
i.source = args.source |
||||
elif not args.source: |
||||
logger.error("'%s' doesn't look like a previously imported directory" % args.destination) |
||||
return -2 |
||||
|
||||
i.reset(*args.clear) |
||||
|
||||
if args.pattern: |
||||
i.pattern = re.compile(args.pattern) |
||||
|
||||
if args.exclude: |
||||
i.exclude = re.compile(args.exclude) |
||||
|
||||
if args.specials: |
||||
if args.append: |
||||
i.specials.extend(itertools.starmap(Special.parse, args.specials)) |
||||
else: |
||||
i.specials = list(itertools.starmap(Special.parse, args.specials)) |
||||
|
||||
for opt in ("auto_specials",): |
||||
if getattr(args, opt) is not None: |
||||
i.options[opt] = getattr(args, opt) |
||||
|
||||
if not args.dry_run: |
||||
i.save() |
||||
|
||||
return i.run(dry=args.dry_run) |
||||
|
||||
|
||||
if __name__ == "__main__": |
||||
sys.exit(main(sys.argv)) |
@ -0,0 +1 @@ |
||||
animelib.py |
@ -0,0 +1,844 @@ |
||||
#!/usr/bin/env python3 |
||||
# (c) 2015-2017 Taeyeon Mori <orochimarufan.x3@gmail.com> |
||||
# Anime Import V5 |
||||
|
||||
import os |
||||
import sys |
||||
import re |
||||
import itertools |
||||
import functools |
||||
import logging |
||||
import argparse |
||||
|
||||
|
||||
logger = logging.getLogger("AnimeImport") |
||||
|
||||
|
||||
############################################################################### |
||||
## Utilities ## |
||||
############################################################################### |
||||
def cat_to(filename, text): |
||||
with open(filename, "w") as f: |
||||
f.write(text) |
||||
|
||||
|
||||
def cat_from(filename): |
||||
with open(filename) as f: |
||||
return f.read() |
||||
|
||||
|
||||
def abspath_from(path, anchor): |
||||
return os.path.normpath(os.path.join(anchor, path)) if not os.path.isabs(path) else path |
||||
|
||||
|
||||
def transport_path(path, old_anchor, new_anchor): |
||||
""" |
||||
:brief: Transport a relative path from one anchor to another |
||||
""" |
||||
return os.path.relpath(abspath_from(path, old_anchor), new_anchor) |
||||
|
||||
|
||||
def make_symlink(target, at, anchor=None): |
||||
""" |
||||
:brief: Make a symbolic link |
||||
:param target: The link target |
||||
:param at: The location of the new symlink |
||||
:param anchor: The anchor if <target> is relative. defaults to os.curdir |
||||
This function preserves the absoluteness of <target>, meaning that if you pass in |
||||
an absolute path, the link will be created using that absolute path. |
||||
However, any relative path will be transported to the new link's containing directory. |
||||
That is important if the link isn't created in the cwd because posix symlink() can take |
||||
any value which will be interpreted relative to the directory containing the link. |
||||
""" |
||||
if os.path.isabs(target): |
||||
os.symlink(target, at) |
||||
elif os.path.isdir(at): |
||||
os.symlink(transport_path(target, anchor if anchor else os.curdir, at), at) |
||||
else: |
||||
os.symlink(transport_path(target, anchor if anchor else os.curdir, os.path.dirname(at)), at) |
||||
|
||||
|
||||
def make_symlink_in(target, in_directory, linkname, anchor=None): |
||||
if os.path.isabs(target): |
||||
os.symlink(target, os.path.join(in_directory, linkname)) |
||||
else: |
||||
os.symlink(transport_path(target, anchor if anchor else os.curdir, in_directory), |
||||
os.path.join(in_directory, linkname)) |
||||
|
||||
def clean_links(path): |
||||
for f in os.listdir(path): |
||||
if not f.startswith(".") and os.path.islink(os.path.join(path, f)): |
||||
os.unlink(os.path.join(path, f)) |
||||
|
||||
|
||||
def maybe_number(s): |
||||
return int(s) if s.isdigit() else s |
||||
|
||||
|
||||
def opt_value_str(o): |
||||
# since maybe_number converts them back to numbers, we abuse numbers as bools |
||||
if o == True: |
||||
return "1" |
||||
elif o == False: |
||||
return "0" |
||||
else: |
||||
return str(o) |
||||
|
||||
|
||||
def natural_sort_key(s, _nsre=re.compile(r'(\d+)')): |
||||
return [int(text) if text.isdigit() else text.lower() for text in _nsre.split(s)] |
||||
|
||||
|
||||
############################################################################### |
||||
## Specials patterns ## |
||||
############################################################################### |
||||
class Special: |
||||
""" |
||||
Specials format: |
||||
Specials <= Special '\n' Specials | Special |
||||
Special <= Properties '\n' Regexp |
||||
Regexp <= regular expression with optional 'ep' group |
||||
Properties <= Property '|' Properties | Property |
||||
Property <= Name '=' Value |
||||
Name <= python identifier |
||||
Value <= string value without '\n' and '|' |
||||
Valid keys: |
||||
type <= "extern" "special" "opening" "ending" "trailer" "parody" "other" |
||||
name <= Folder name for "extern" type |
||||
offset <= Number to add to the episode number |
||||
epnum <= Force all matched episodes to have this episode number (discouraged. use offset instead) |
||||
first <= start counting episodes here. This only applies if <ep> is NOT matched in the regexp |
||||
subdir <= Specials are located in a subdir of the source |
||||
Regexp: |
||||
Should contain an <ep> group to match the (relative to [offset]) episode/special/ending/etc number |
||||
""" |
||||
def __init__(self, properties, pattern): |
||||
self._properties = properties |
||||
self.pattern = pattern |
||||
|
||||
self.season = 0 |
||||
|
||||
if "type" in properties: |
||||
t = properties["type"].lower() |
||||
|
||||
if t in ("extern", "episode", "episodes"): |
||||
self.season = 1 |
||||
off = 0 |
||||
elif t in ("special", "s", "specials"): |
||||
off = 0 |
||||
elif t in ("opening", "op", "openings"): |
||||
off = 99 |
||||
elif t in ("ending", "ed", "endings"): |
||||
off = 149 |
||||
elif t in ("trailer", "t", "trailers"): |
||||
off = 199 |
||||
elif t in ("parody", "p", "parodies"): |
||||
off = 299 |
||||
elif t in ("other", "o", "others"): |
||||
off = 399 |
||||
else: |
||||
off = 499 |
||||
|
||||
self.type_offset = off |
||||
else: |
||||
self.type_offset = 0 |
||||
|
||||
self.is_extern = "name" in properties |
||||
self.is_subdir = "subdir" in properties |
||||
|
||||
if "$custom" in properties: |
||||
if not callable(properties["$custom"]): |
||||
raise ValueError("$custom Specials must be created from python code and be callable objects") |
||||
self.custom = properties["$custom"] |
||||
|
||||
def __getattr__(self, name): |
||||
return self._properties[name] |
||||
|
||||
def __contains__(self, name): |
||||
return name in self._properties |
||||
|
||||
def source(self): |
||||
return "\n".join(("|".join(map("=".join, self._properties.items())), self.pattern.pattern)) |
||||
|
||||
def match(self, f): |
||||
return self.pattern.search(f) |
||||
|
||||
def custom(self, m, i): |
||||
return None |
||||
|
||||
def adjust_episode(self, ep): |
||||
ep += self.type_offset |
||||
|
||||
if "offset" in self: |
||||
return ep + int(self.offset) |
||||
elif "epnum" in self: |
||||
return int(self.epnum) |
||||
else: |
||||
return ep |
||||
|
||||
def get_episode(self, match, last): |
||||
if "ep" in self.pattern.groupindex and match.group("ep"): |
||||
# TODO: fix this. When doing it properly it breaks the assumption that ...OP is OP1 while OPn is OPn. It makes OP OPn+1 |
||||
return self.adjust_episode(int(match.group("ep"))) |
||||
#return self.adjust_episode(int(match.group("ep"))) |
||||
elif last == 0: |
||||
return self.adjust_episode(1) |
||||
else: |
||||
return last + 1 |
||||
|
||||
@staticmethod |
||||
def parse_properties(src): |
||||
return dict(map(lambda p: p.split("=", 1), src.split("|"))) |
||||
|
||||
@classmethod |
||||
def parse(cls, props_src, regexp_src): |
||||
return cls(cls.parse_properties(props_src), re.compile(regexp_src)) |
||||
|
||||
@classmethod |
||||
def iterparse(cls, src): |
||||
for a, b in zip(*[iter(src.split("\n"))]*2): |
||||
yield cls.parse(a, b) |
||||
|
||||
@classmethod |
||||
def simple(cls, type, regexp): |
||||
return cls({"type": type}, re.compile(regexp)) |
||||
|
||||
|
||||
############################################################################### |
||||
## The core ## |
||||
############################################################################### |
||||
class Importer: |
||||
# Filenames |
||||
source_fn = ".source" |
||||
master_fn = ".main" |
||||
pattern_fn = ".pattern" |
||||
exclude_fn = ".exclude" |
||||
special_fn = ".specials" |
||||
options_fn = ".options" |
||||
|
||||
# Defaults |
||||
default_pattern = re.compile(r"[\s_.](?:[Ss](?:eason)?(?P<season>\d+))?[\s_.]*(?:[Ee](?:[Pp]|pisode)?)?[\s_.]?(?P<ep>\d+)(?:\-(?P<untilep>\d+))?([Vv]\d+)?[\s_.]*[\(\[\.]?") |
||||
default_options = { |
||||
"auto_specials": 1, |
||||
"exclude_parts": 1, |
||||
"exclude_playlists": 1, |
||||
} |
||||
|
||||
auto_specials = [ |
||||
Special.simple("Opening", r"[_ ](NC|TV)?OP[ _]?[^\d]"), # FIXME: HACK |
||||
Special.simple("Opening", r"[_ ]((NC|TV)?OP|Opening)[ _]?(?P<ep>\d+)"), |
||||
Special.simple("Ending", r"[_ ](NC|TV)?ED[ _]?[^\d]"), # FIXME: HACK |
||||
Special.simple("Ending", r"[_ ]((NC|TV)?ED|Ending|Closing)[ _]?(?P<ep>\d+)"), |
||||
Special.simple("Special", r"[_ ](Special|OVA|SP)[ _]?(?P<ep>\d+)?[ _]?"), |
||||
# .nfo files |
||||
Special({"$custom": lambda m, i: ("link", os.path.join(i.destination, i.main_name + ".nfo"))}, re.compile(r".nfo$")) |
||||
] |
||||
|
||||
link_template = "{series} S{season:d}E{episode:03d}" |
||||
until_template = "-E{episode:03d}" |
||||
|
||||
def format_linkname(self, series, season, episode, *, until_ep=None): |
||||
linkname = self.link_template.format(series=series, season=season, episode=episode) |
||||
if until_ep: |
||||
linkname += self.until_template.format(episode=until_ep) |
||||
return linkname |
||||
|
||||
def __init__(self, destination): |
||||
# Find master |
||||
master_f = os.path.join(destination, self.master_fn) |
||||
|
||||
while os.path.islink(master_f): |
||||
new_dest = os.readlink(master_f) |
||||
if not os.path.isabs(new_dest): |
||||
new_dest = transport_path(new_dest, destination, os.curdir) |
||||
|
||||
logger.info("Destination '%s' belongs to '%s'; Selecting that instead" % (destination, new_dest)) |
||||
|
||||
destination = new_dest |
||||
master_f = os.path.join(destination, self.master_fn) |
||||
|
||||
self.destination = os.path.abspath(destination) |
||||
self.main_name = os.path.basename(self.destination) |
||||
|
||||
def get_source_loc(fn): |
||||
source = os.readlink(fn) |
||||
if not os.path.isabs(source): |
||||
source = transport_path(source, os.path.dirname(fn), os.curdir) |
||||
return source |
||||
|
||||
source_f = os.path.join(destination, self.source_fn) |
||||
if os.path.islink(source_f): |
||||
self.sources = [get_source_loc(source_f)] |
||||
|
||||
elif os.path.isdir(source_f): |
||||
self.sources = list( |
||||
map(get_source_loc, |
||||
sorted( |
||||
filter(os.path.islink, |
||||
(os.path.join(source_f, f) |
||||
for f in os.listdir(source_f)))))) |
||||
|
||||
else: |
||||
self.sources = [] |
||||
|
||||
options_f = os.path.join(destination, self.options_fn) |
||||
if os.path.isfile(options_f): |
||||
self.options = {k: maybe_number(v) for k, v in (x.split(": ") for x in cat_from(options_f).split("\n"))} |
||||
else: |
||||
self.options = {} |
||||
|
||||
pattern_f = os.path.join(destination, self.pattern_fn) |
||||
if os.path.isfile(pattern_f): |
||||
self.pattern = re.compile(cat_from(pattern_f).rstrip("\n")) |
||||
else: |
||||
self.pattern = self.default_pattern |
||||
|
||||
exclude_f = os.path.join(destination, self.exclude_fn) |
||||
if os.path.isfile(exclude_f): |
||||
self.exclude = re.compile(cat_from(exclude_f).rstrip("\n")) |
||||
else: |
||||
self.exclude = None |
||||
|
||||
special_f = os.path.join(destination, self.special_fn) |
||||
if os.path.isfile(special_f): |
||||
self.specials = list(Special.iterparse(cat_from(special_f))) |
||||
else: |
||||
self.specials = [] |
||||
|
||||
def _save(self, filename, content): |
||||
# save data to <destination>/<filename> |
||||
path = os.path.join(self.destination, filename) |
||||
if content is not None: |
||||
with open(path, "w") as f: |
||||
f.write(content) |
||||
elif os.path.exists(path): |
||||
os.unlink(path) |
||||
|
||||
def save(self): |
||||
# Write settings to disk |
||||
if not os.path.isdir(self.destination): |
||||
os.mkdir(self.destination) |
||||
|
||||
self._save(self.pattern_fn, self.pattern.pattern if self.pattern is not self.default_pattern else None) |
||||
self._save(self.exclude_fn, self.exclude.pattern if self.exclude is not None else None) |
||||
self._save(self.special_fn, "\n".join(map(Special.source, self.specials)) if self.specials else None) |
||||
self._save(self.options_fn, "\n".join((": ".join((k, opt_value_str(v))) for k, v in self.options.items())) if self.options else None) |
||||
|
||||
source_f = os.path.join(self.destination, self.source_fn) |
||||
if os.path.islink(source_f): |
||||
oldpath = transport_path(os.readlink(source_f), self.destination, os.curdir) |
||||
if oldpath != self.sources[0].rstrip("/"): |
||||
logger.warn("Updating source link '%s' with '%s'" % (oldpath, self.sources[0])) |
||||
os.unlink(source_f) |
||||
if len(self.sources) > 1 or os.path.isdir(source_f): |
||||
if os.path.isdir(source_f): |
||||
for i, link in enumerate( |
||||
filter(os.path.islink, |
||||
sorted((os.path.join(source_f, f) for f in os.listdir(source_f)), key=natural_sort_key))): |
||||
oldpath = transport_path(os.readlink(link), source_f, os.curdir) |
||||
if i >= len(self.sources): |
||||
logger.warn("Removing source link '%s'" % oldpath) |
||||
elif oldpath != self.sources[i].rstrip("/"): |
||||
logger.warn("Updating source link '%s' with '%s'" % (oldpath, self.sources[i])) |
||||
os.unlink(link) |
||||
else: |
||||
os.mkdir(source_f) |
||||
|
||||
for i, source in enumerate(self.sources): |
||||
make_symlink_in(source, source_f, str(i)) |
||||
|
||||
else: |
||||
make_symlink(self.sources[0], source_f) |
||||
|
||||
for sp in self.specials: |
||||
if not sp.is_extern: |
||||
continue |
||||
|
||||
path = os.path.join(self.destination, "..", sp.name) |
||||
|
||||
if not os.path.isdir(path): |
||||
os.mkdir(path) |
||||
|
||||
master_f = os.path.join(path, self.master_fn) |
||||
if os.path.islink(master_f): |
||||
oldpath = transport_path(os.readlink(master_f), path, os.curdir) |
||||
if oldpath != self.destination.rstrip("/"): |
||||
logger.warn("Updating master link '%s' with '%s'" % (oldpath, self.destination)) |
||||
os.unlink(master_f) |
||||
make_symlink(self.destination, master_f) |
||||
|
||||
@property |
||||
def effective_specials(self): |
||||
return itertools.chain(self.specials, self.auto_specials) if self.option("auto_specials") else self.specials |
||||
|
||||
def option(self, name): |
||||
return self.options[name] if name in self.options else self.default_options.get(name, None) |
||||
|
||||
def process_file(self, filename, subdir=None): |
||||
# Exclude |
||||
if self.option("exclude_parts") and filename.endswith(".part"): |
||||
return "skip", "partial download" |
||||
|
||||
elif self.option("exclude_playlists") and (filename[-4:] in (".vml", ".m3u", ".pls")): |
||||
return "skip", "playlist file" |
||||
|
||||
elif self.exclude and self.exclude.search(filename): |
||||
return "skip", "excluded" |
||||
|
||||
linkpath = self.destination |
||||
|
||||
# Specials |
||||
for special in self.effective_specials: |
||||
if bool(subdir) != special.is_subdir or (subdir and subdir != special.subdir): |
||||
continue |
||||
|
||||
sm = special.match(filename) |
||||
if sm: |
||||
result = special.custom(sm, self) |
||||
if result: |
||||
return result |
||||
|
||||
ep = special.get_episode(sm, self.last_special.get(special, 0)) |
||||
self.last_special[special] = ep |
||||
|
||||
if special.is_extern: |
||||
name = special.name |
||||
linkpath = os.path.join(self.destination, "..", name) |
||||
else: |
||||
name = self.main_name |
||||
|
||||
linkname = self.link_template.format(series=name, season=special.season, episode=ep) |
||||
break |
||||
|
||||
else: |
||||
if subdir: |
||||
#logger.warn("Unhandled file in subdir %s: %s" % (subdir, filename)) |
||||
return "skip", "in subdirectory" |
||||
|
||||
# Default values |
||||
data = { |
||||
"series": self.main_name, |
||||
"season": 1, |
||||
"episode": self.last_episode + 1 |
||||
} |
||||
|
||||
# Try to extract info from filename |
||||
m = self.pattern.search(filename) |
||||
|
||||
if m: |
||||
# Season number (optional) |
||||
if "season" in self.pattern.groupindex and m.group("season"): |
||||
season = int(m.group("season")) |
||||
|
||||
# Episode number (mandatory) |
||||
data["episode"] = int(m.group("ep")) |
||||
|
||||
# Until episode number for files containing multiple episodes (optional) |
||||
if "untilep" in self.pattern.groupindex and m.group("untilep"): |
||||
data["until_ep"] = int(m.group("untilep")) |
||||
|
||||
linkname = self.format_linkname(**data) |
||||
self.last_episode = (data["until_ep"] if "until_ep" in data else data["episode"]) |
||||
|
||||
return "link", os.path.join(linkpath, linkname + os.path.splitext(filename)[1]) |
||||
|
||||
def clean_all(self): |
||||
clean_links(self.destination) |
||||
|
||||
for special in self.specials: # auto_specials doesn't have "extern" specials |
||||
if special.is_extern: |
||||
clean_links(os.path.join(self.destination, "..", special.name)) |
||||
|
||||
def run(self, *, dry=False): |
||||
if not dry: |
||||
self.clean_all() |
||||
|
||||
self.last_episode = 0 # FIXME: global state is bad |
||||
self.last_special = {} |
||||
|
||||
for source in self.sources: |
||||
for f in sorted(os.listdir(source), key=natural_sort_key): |
||||
path = os.path.join(source, f) |
||||
|
||||
if os.path.isdir(path): |
||||
if self.specials: |
||||
for ff in sorted(os.listdir(path), key=natural_sort_key): |
||||
if self.handle_file(source, os.path.join(f, ff), *self.process_file(ff, subdir=f), dry=dry) != 0: |
||||
return 1 |
||||
else: |
||||
if self.handle_file(source, f, *self.process_file(f), dry=dry) != 0: |
||||
return 1 |
||||
|
||||
def handle_file(self, source, f, what, where, *, dry=False): |
||||
if what == "link": |
||||
if os.path.exists(where) and not dry: |
||||
logger.error("LINK %s => %s exists!" % (f, os.path.basename(where))) |
||||
return 1 |
||||
else: |
||||
logger.info("LINK %s => %s" % (f, os.path.basename(where))) |
||||
|
||||
if not dry: |
||||
make_symlink(os.path.join(source, f), where) |
||||
|
||||
elif what == "skip": |
||||
logger.info("SKIP %s (%s)" % (f, where)) |
||||
|
||||
else: |
||||
assert(False and "Should not be reached") |
||||
|
||||
return 0 |
||||
|
||||
def reset(self, *things): |
||||
if "pattern" in things: |
||||
self.pattern = self.default_pattern |
||||
if "exclude" in things: |
||||
self.exclude = None |
||||
if "specials" in things: |
||||
self.specials.clear() |
||||
if "options" in things: |
||||
self.options.clear() |
||||
|
||||
def print_info(self): |
||||
print("Series Info for %s:" % self.main_name) |
||||
print("-" * (17 + len(self.main_name))) |
||||
print("Sources | %s" % ("\n | ".join(self.sources))) |
||||
print("Pattern | r'%s'%s" % (self.pattern.pattern, " (default)" if self.pattern is self.default_pattern else "")) |
||||
print("Exclude | %s" % (("r'%s'" % self.exclude.pattern) if self.exclude else "None")) |
||||
print("Options | %s" % self.options) |
||||
print("Specials | %s" % ("None" |
||||
if not self.specials else |
||||
"\n | ".join("%-35s :: %s" % ("r'%s'" % special.pattern.pattern, special._properties) |
||||
for special in self.specials))) |
||||
|
||||
def flags(self): |
||||
return "".join((f for c, f in [ |
||||
(self.pattern is not self.default_pattern, "p"), |
||||
(self.exclude, "e"), |
||||
#(self.option("exclude_parts"), "d"), |
||||
(not self.option("exclude_parts"), "D"), |
||||
(self.option("auto_specials"), "i"), |
||||
(not self.option("auto_specials"), "I"), |
||||
(self.specials, str(len(self.specials))), |
||||
] if c)) |
||||
|
||||
|
||||
############################################################################### |
||||
## Argument handling ## |
||||
############################################################################### |
||||
class HelpFormatter(argparse.RawTextHelpFormatter): |
||||
def __init__(self, prog): |
||||
super().__init__(prog, max_help_position=16) |
||||
|
||||
def parse_args(argv): |
||||
parser = argparse.ArgumentParser(prog=argv[0], formatter_class=HelpFormatter) |
||||
|
||||
parser.add_argument("-l", "--library", default=".", |
||||
help="The library folder to work on [%(default)s]") |
||||
|
||||
commands = parser.add_subparsers(title="Commands", dest="command") |
||||
|
||||
# import |
||||
importc = commands.add_parser("import", description="Add a new source location") |
||||
|
||||
importc.add_argument("source", |
||||
help="The new source directory") |
||||
importc.add_argument("series", |
||||
help="The series (directory) name. All non-hidden symlinks inside will be deleted!") |
||||
|
||||
# unlink |
||||
unlink = commands.add_parser("unlink", description="Remove a source location") |
||||
|
||||
unlink.add_argument("series", |
||||
help="The series name") |
||||
unlink.add_argument("source", |
||||
help="The source directory to remove") |
||||
|
||||
# config |
||||
config = commands.add_parser("config", description="Modify series configuration") |
||||
|
||||
config.add_argument("series", |
||||
help="The series name") |
||||
|
||||
patterns = config.add_argument_group("Patterns", |
||||
description="Patterns are Python re patterns: https://docs.python.org/3/library/re.html") |
||||
patterns.add_argument("-p", "--pattern", default=None, metavar="PATTERN", |
||||
help="Set the episode pattern. Include a named group <ep>; Optionally <season> and <untilep>") |
||||
patterns.add_argument("-x", "--exclude", default=None, metavar="PATTERN", |
||||
help="Set the exclusion pattern") |
||||
patterns.add_argument("-s", "--special", "--specials", default=[], nargs=2, action="append", metavar=("SPECIAL", "PATTERN"), dest="specials", |
||||
help=("Set the special mapping. This takes 2 arguments and can be specified multiple times.\n" |
||||
"1st argument: key=value properties separated by '|'.\n" |
||||
"2nd argument: the matching pattern. It should contain a <ep> group\n" |
||||
"Valid keys so far are 'type', 'offset', 'name'.\n" |
||||
"type can be 'special', 'opening', 'ending', 'trailer', 'parody', 'other' and 'extern'.\n" |
||||
"offset adds a fixed number to all episodes numbers matched by the pattern.\n" |
||||
"name must only be used with 'extern'. It creates a slave Series to hold the matched episodes in the parent directory.\n" |
||||
"It's useful for singling out specials that are recorded as independent series in the metadata provider.")) |
||||
patterns.add_argument("-a", "--append", action="store_true", |
||||
help="Extend the special mapping instead of replacing it") |
||||
|
||||
options = config.add_argument_group("Options") |
||||
options.add_argument("-i", "--auto-specials", action="store_true", dest="auto_specials", default=None, |
||||
help="Implicitly add some Specials patterns (default)") |
||||
options.add_argument("-I", "--no-auto-specials", action="store_false", dest="auto_specials", default=None, |
||||
help="Don't add the implicit Specials patterns") |
||||
|
||||
config.add_argument("--clear", default=[], action="append", choices={"pattern", "exclude", "specials", "options"}, |
||||
help="Reset a property to the defaults") |
||||
|
||||
# Check |
||||
check = commands.add_parser("check", description="Check library and report untracked sources", fromfile_prefix_chars="@") |
||||
|
||||
check.add_argument("source_roots", nargs="*", metavar="SOURCE_ROOT", |
||||
help="Directory path containing source directories") |
||||
|
||||
check.add_argument("-X", "--ignore", action="append", default=[], metavar="DIR", |
||||
help="Ignore a directory DIR when checking for untracked sources") |
||||
|
||||
check.add_argument("-I", "--interactive-import", action="store_true", |
||||
help="Interactively import any untracked sources") |
||||
|
||||
# Update |
||||
update = commands.add_parser("update", description="Update the symlinks") |
||||
|
||||
update.add_argument("series", nargs="*", default=[], |
||||
help="Update only these series (Default: all)") |
||||
|
||||
# Info |
||||
info = commands.add_parser("info", description="Show information about a series") |
||||
|
||||
info.add_argument("series", nargs="+", |
||||
help="Update only these series") |
||||
|
||||
# Global misc |
||||
parser.add_argument("-U", "--no-update", action="store_true", |
||||
help="Don't automatically update symlinks. Use '%(prog)s update' later (applicable to import, unlink, config)") |
||||
parser.add_argument("-D", "--dry-run", action="store_true", |
||||
help="Don't save anything to disk Useful combination") |
||||
parser.add_argument("-q", "--quiet", action="store_true") |
||||
|
||||
return parser.parse_args(argv[1:]) |
||||
|
||||
|
||||
############################################################################### |
||||
## Put the pieces together ## |
||||
############################################################################### |
||||
# Helpers |
||||
def run_update(i, args): |
||||
""" Update the symlinks for a Series Importer """ |
||||
return i.run(dry=args.dry_run) == 0 |
||||
|
||||
def get_series_importer(args, series=None): |
||||
if series is None: |
||||
series = args.series |
||||
|
||||
if series is ".": |
||||
if args.library == "..": |
||||
series = os.path.dirname(os.getcwd()) |
||||
else: |
||||
raise ValueError("Using '.' as series is only valid when using '..' for library") |
||||
|
||||
if args.library != ".": |
||||
return Importer(os.path.join(args.library, series)) |
||||
else: |
||||
return Importer(series) |
||||
|
||||
def list_series_paths(library): |
||||
return [de.path |
||||
for de in os.scandir(library) |
||||
if de.is_dir(follow_symlinks=False)] |
||||
|
||||
def get_series_importers(args, series=None): |
||||
if series: |
||||
return map(functools.partial(get_series_importer, args), series) |
||||
else: |
||||
return map(Importer, list_series_paths(args.library)) |
||||
|
||||
def check(i): |
||||
if not i.sources: |
||||
logger.warn("'%s' doesn't have any sources" % i.main_name) |
||||
return False |
||||
|
||||
have_sources = list(map(os.path.isdir, i.sources)) |
||||
if not all(have_sources): |
||||
for source, isdir in zip(i.sources, have_sources): |
||||
if not isdir: |
||||
logger.error("Source link for '%s' doesn't exist: '%s'" % (i.main_name, source)) |
||||
return False |
||||
|
||||
return True |
||||
|
||||
def do_interactive_import(args, sources): |
||||
for source in sources: |
||||
source = os.path.relpath(source, args.library) |
||||
print("Importing from %s:" % source) |
||||
series = input(" Enter Series Name or hit return to skip --> ") |
||||
|
||||
if not series: |
||||
continue |
||||
|
||||
i = get_series_importer(args, series) |
||||
|
||||
if i.sources: |
||||
print("Adding source '%s' to existing '%s'" % (source, series)) |
||||
i.sources.append(source) |
||||
|
||||
if not args.dry_run: |
||||
i.save() |
||||
|
||||
if not args.no_update: |
||||
run_update(i, args) |
||||
|
||||
# Command Mains |
||||
def import_main(args): |
||||
i = get_series_importer(args) |
||||
|
||||
if args.source not in i.sources: |
||||
if i.sources: |
||||
logger.info("Adding source '%s' to '%s'" % (args.source, args.series)) |
||||
i.sources.append(args.source) |
||||
else: |
||||
logger.warn("Source '%s' already linked to '%s'" % (args.source, args.series)) |
||||
|
||||
if not args.dry_run: |
||||
i.save() |
||||
|
||||
if not args.no_update: |
||||
run_update(i, args) |
||||
|
||||
return 0 |
||||
|
||||
def unlink_main(args): |
||||
i = get_series_importer(args) |
||||
|
||||
source = transport_path(args.source, os.getcwd(), args.library) |
||||
print (i.sources) |
||||
|
||||
if source not in i.sources: |
||||
logger.error("Source '%s' not linked to '%s'" % (args.source, args.series)) |
||||
return 1 |
||||
else: |
||||
i.sources.remove(source) |
||||
logger.warn("Unlinking Source '%s' from '%s'" % (args.source, args.series)) |
||||
|
||||
if not args.dry_run: |
||||
i.save() |
||||
|
||||
if not args.no_update: |
||||
run_update(i, args) |
||||
|
||||
return 0 |
||||
|
||||
def config_main(args): |
||||
i = get_series_importer(args) |
||||
|
||||
i.reset(*args.clear) |
||||
|
||||
if args.pattern: |
||||
i.pattern = re.compile(args.pattern) |
||||
|
||||
if args.exclude: |
||||
i.exclude = re.compile(args.exclude) |
||||
|
||||
if args.specials: |
||||
if args.append: |
||||
i.specials.extend(itertools.starmap(Special.parse, args.specials)) |
||||
else: |
||||
i.specials = list(itertools.starmap(Special.parse, args.specials)) |
||||
|
||||
for opt in ("auto_specials",): |
||||
if getattr(args, opt) is not None: |
||||
i.options[opt] = getattr(args, opt) |
||||
|
||||
if not args.dry_run: |
||||
i.save() |
||||
|
||||
if not args.no_update: |
||||
run_update(i, args) |
||||
|
||||
return 0 |
||||
|
||||
def check_main(args): |
||||
got_dirs = set() |
||||
|
||||
for i in get_series_importers(args): |
||||
if check(i): |
||||
got_dirs.update(map(os.path.abspath, i.sources)) |
||||
|
||||
if args.source_roots: |
||||
dirs = set(map(os.path.abspath, filter(os.path.isdir, itertools.chain.from_iterable(((os.path.join(f, x) for x in os.listdir(f)) for f in args.source_roots))))) |
||||
ignore = set(map(os.path.abspath, filter(os.path.isdir, itertools.chain.from_iterable(((os.path.join(f, x) for x in args.ignore) for f in args.source_roots))))) |
||||
print(ignore) |
||||
missing = dirs - got_dirs - ignore |
||||
if missing: |
||||
if args.interactive_import: |
||||
return do_interactive_import(args, missing) |
||||
else: |
||||
print("Found missing directories:\n %s" % "\n ".join(os.path.relpath(m) for m in missing)) |
||||
|
||||
return 0 |
||||
|
||||
def update_main(args): |
||||
fin_dirs = set() |
||||
|
||||
for i in get_series_importers(args, args.series): |
||||
logger.info("Processing '%s' (%s)" % (i.main_name, i.flags())) |
||||
|
||||
if i.destination in fin_dirs: |
||||
logger.info("Already processed '%s'. Skipping" % i.main_name) |
||||
continue |
||||
|
||||
if not check(i): |
||||
continue |
||||
|
||||
if run_update(i, args): |
||||
got_dirs.update(map(os.path.abspath, i.sources)) |
||||
fin_dirs.add(i.destination) |
||||
|
||||
return 0 |
||||
|
||||
def info_main(args): |
||||
logger.setLevel(logging.WARNING) |
||||
|
||||
for i in get_series_importers(args, args.series): |
||||
i.print_info() |
||||
print() |
||||
|
||||
return 0 |
||||
|
||||
|
||||
# Program Main |
||||
def main(argv): |
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)-5s %(message)s") |
||||
|
||||
args = parse_args(argv) |
||||
|
||||
if args.quiet: |
||||
logger.setLevel(logging.WARNING) |
||||
|
||||
try: |
||||
if args.command == "info": |
||||
return info_main(args) |
||||
|
||||
if args.command == "import": |
||||
return import_main(args) |
||||
|
||||
if args.command == "unlink": |
||||
return unlink_main(args) |
||||
|
||||
if args.command == "config": |
||||
return config_main(args) |
||||
|
||||
if args.command == "update": |
||||
return update_main(args) |
||||
|
||||
if args.command == "check": |
||||
return check_main(args) |
||||
|
||||
except: |
||||
logger.exception("An Exception occured") |
||||
return 255 |
||||
|
||||
|
||||
if __name__ == "__main__": |
||||
sys.exit(main(sys.argv)) |
Loading…
Reference in new issue