### -*- mode: python; coding: utf-8 -*-
from contextlib import contextmanager
+import optparse as OP
import os as OS
import re as RX
import sys as SYS
def check(cond, msg):
if not cond: raise ExpectedError(msg)
+def lookup(dict, key, msg):
+ try: return dict[key]
+ except KeyError: raise ExpectedError(msg)
+
+def forget(dict, key):
+ try: del dict[key]
+ except KeyError: pass
+
def getint(s):
if not s.isdigit(): raise ExpectedError("bad integer `%s'" % s)
return int(s)
me.used_chapters = set()
me.nuses = 0
def url(me, title = None, chapter = None):
- if title is None:
+ if title == "-":
if me.TITLEP: raise ExpectedError("missing title number")
if chapter is not None:
raise ExpectedError("can't specify chapter without title")
if chapter is not None: key, set = (title, chapter), me.used_chapters
else: key, set = title, me.used_titles
if key in set:
- if title is None:
+ if title == "-":
raise ExpectedError("`%s' already used" % me.fn)
elif chapter is None:
raise ExpectedError("`%s' title %d already used" % (me.fn, title))
else:
raise ExpectedError("`%s' title %d chapter %d already used" %
- (me.fn, title, chapter))
+ (me.fn, title, chapter))
if chapter is not None: me.used_chapters.add((title, chapter))
return me.PREFIX + ROOT + urlencode(me.fn) + suffix
def label(me):
return me.season._eplabel(me.i, me.neps, me.title)
-class Season (object):
- def __init__(me, playlist, title, i, implicitp = False):
- me.playlist = playlist
- me.title, me.i = title, i
- me.implicitp = implicitp
+class BaseSeason (object):
+ def __init__(me, series, implicitp = False):
+ me.series = series
me.episodes = []
+ me.implicitp = implicitp
+ me.ep_i, episodes = 1, []
def add_episode(me, j, neps, title, src, tno):
ep = Episode(me, j, neps, title, src, tno)
me.episodes.append(ep)
+ src.nuses += neps; me.ep_i += neps
return ep
+
+class Season (BaseSeason):
+ def __init__(me, series, title, i, *args, **kw):
+ super().__init__(series, *args, **kw)
+ me.title, me.i = title, i
def _eplabel(me, i, neps, title):
- if neps == 1: epname = me.playlist.epname; epn = "%d" % i
- elif neps == 2: epname = me.playlist.epnames; epn = "%d, %d" % (i, i + 1)
- else: epname = me.playlist.epnames; epn = "%d–%d" % (i, i + neps - 1)
+ playlist = me.series.playlist
+ if neps == 1: epname = playlist.epname; epn = "%d" % i
+ elif neps == 2: epname = playlist.epnames; epn = "%d, %d" % (i, i + 1)
+ else: epname = playlist.epnames; epn = "%d–%d" % (i, i + neps - 1)
if title is None:
- if me.implicitp: return "%s %s" % (epname, epn)
- elif me.title is None: return "%s %d.%s" % (epname, me.i, epn)
- else: return "%s—%s %s" % (me.title, epname, epn)
+ if me.implicitp: label = "%s %s" % (epname, epn)
+ elif me.title is None: label = "%s %d.%s" % (epname, me.i, epn)
+ else: label = "%s—%s %s" % (me.title, epname, epn)
else:
- if me.implicitp: return "%s. %s" % (epn, title)
- elif me.title is None: return "%d.%s. %s" % (me.i, epn, title)
- else: return "%s—%s. %s" % (me.title, epn, title)
+ if me.implicitp: label = "%s. %s" % (epn, title)
+ elif me.title is None: label = "%d.%s. %s" % (me.i, epn, title)
+ else: label = "%s—%s. %s" % (me.title, epn, title)
+ return label
-class MovieSeason (object):
- def __init__(me, playlist):
- me.playlist = playlist
- me.i = -1
- me.implicitp = False
- me.episodes = []
+class MovieSeason (BaseSeason):
def add_episode(me, j, neps, title, src, tno):
if title is None: raise ExpectedError("movie must have a title")
- ep = Episode(me, j, neps, title, src, tno)
- me.episodes.append(ep)
- return ep
+ return super().add_episode(j, neps, title, src, tno)
def _eplabel(me, i, epn, title):
return title
-class Playlist (object):
+class Series (object):
+ def __init__(me, playlist, title = None):
+ me.playlist = playlist
+ me.title = title
+ me.cur_season = None
+ def _add_season(me, season):
+ me.cur_season = season
+ def add_season(me, title, i, implicitp = False):
+ me._add_season(Season(me, title, i, implicitp))
+ def add_movies(me):
+ me._add_season(MovieSeason(me))
+ def ensure_season(me):
+ if me.cur_season is None: me.add_season(None, 1, implicitp = True)
+ return me.cur_season
+ def end_season(me):
+ me.cur_season = None
+class Playlist (object):
def __init__(me):
me.seasons = []
+ me.episodes = []
me.epname, me.epnames = "Episode", "Episodes"
-
- def add_season(me, title, i, implicitp = False):
- season = Season(me, title, i, implicitp)
- me.seasons.append(season)
- return season
-
- def add_movies(me):
- season = MovieSeason(me)
- me.seasons.append(season)
- return season
-
+ me.nseries = 0
+ def add_episode(me, episode):
+ me.episodes.append(episode)
+ def done_season(me):
+ if me.episodes:
+ me.seasons.append(me.episodes)
+ me.episodes = []
def write(me, f):
f.write("#EXTM3U\n")
for season in me.seasons:
f.write("\n")
- for i, ep in enumerate(season.episodes, 1):
+ for ep in season:
+ label = ep.label()
+ if me.nseries > 1: label = ep.season.series.title + " " + label
if not ep.chapters:
- f.write("#EXTINF:0,,%s\n%s\n" % (ep.label(), ep.url))
+ f.write("#EXTINF:0,,%s\n%s\n" % (label, ep.url))
else:
for ch in ep.chapters:
f.write("#EXTINF:0,,%s: %s\n%s\n" %
- (ep.label(), ch.title, ch.url))
-
-UNSET = ["UNSET"]
-
-def parse_list(fn):
- playlist = Playlist()
- season, episode, chapter, ep_i = None, None, None, 1
- vds = {}
- ads = iso = None
- with location(FileLocation(fn, 0)) as floc:
- with open(fn, "r") as f:
- for line in f:
- floc.stepline()
- sline = line.lstrip()
- if sline == "" or sline.startswith(";"): continue
-
- if line.startswith("!"):
- ww = Words(line[1:])
- cmd = ww.nextword()
- check(cmd is not None, "missing command")
-
- if cmd == "season":
- v = ww.nextword();
- check(v is not None, "missing season number")
- if v == "-":
- check(v.rest() is None, "trailing junk")
- season = playlist.add_movies()
- else:
- i = getint(v)
- title = ww.rest()
- season = playlist.add_season(title, i, implicitp = False)
- episode = chapter = None
- ep_i = 1
-
- elif cmd == "movie":
- check(ww.rest() is None, "trailing junk")
- season = playlist.add_movies()
- episode = chapter = None
- ep_i = 1
-
- elif cmd == "epname":
- name = ww.rest()
- check(name is not None, "missing episode name")
- try: sep = name.index(":")
- except ValueError: names = name + "s"
- else: name, names = name[:sep], name[sep + 1:]
- playlist.epname, playlist.epnames = name, names
-
- elif cmd == "epno":
- i = ww.rest()
- check(i is not None, "missing episode number")
- ep_i = getint(i)
-
- elif cmd == "iso":
- fn = ww.rest(); check(fn is not None, "missing filename")
- if fn == "-": iso = None
- else:
- check(OS.path.exists(OS.path.join(ROOT, fn)),
- "iso file `%s' not found" % fn)
- iso = VideoDisc(fn)
-
- elif cmd == "vdir":
- name = ww.nextword(); check(name is not None, "missing name")
- fn = ww.rest(); check(fn is not None, "missing directory")
- if fn == "-":
- try: del vds[name]
- except KeyError: pass
- else:
- vds[name] = VideoDir(fn)
-
- elif cmd == "adir":
- fn = ww.rest(); check(fn is not None, "missing directory")
- if fn == "-": ads = None
- else: ads = AudioDir(fn)
-
- elif cmd == "end":
- break
-
- else:
- raise ExpectedError("unknown command `%s'" % cmd)
+ (label, ch.title, ch.url))
+
+MODE_UNSET = 0
+MODE_SINGLE = 1
+MODE_MULTI = 2
+
+class EpisodeListParser (object):
+
+ def __init__(me, series_wanted = None, chapters_wanted_p = False):
+ me._pl = Playlist()
+ me._cur_episode = me._cur_chapter = None
+ me._series = {}; me._vdirs = {}; me._audirs = {}; me._isos = {}
+ me._series_wanted = series_wanted
+ me._chaptersp = chapters_wanted_p
+ if series_wanted is None: me._mode = MODE_UNSET
+ else: me._mode = MODE_MULTI
+
+ def _bad_keyval(me, cmd, k, v):
+ raise ExpectedError("invalid `!%s' option `%s'" %
+ (cmd, v if k is None else k))
+
+ def _keyvals(me, opts):
+ if opts is not None:
+ for kv in opts.split(","):
+ try: sep = kv.index("=")
+ except ValueError: yield None, kv
+ else: yield kv[:sep], kv[sep + 1:]
+
+ def _set_mode(me, mode):
+ if me._mode == MODE_UNSET:
+ me._mode = mode
+ elif me._mode != mode:
+ raise ExpectedError("inconsistent single-/multi-series usage")
+
+ def _get_series(me, name):
+ if name is None:
+ me._set_mode(MODE_SINGLE)
+ try: series = me._series[None]
+ except KeyError:
+ series = me._series[None] = Series(me._pl)
+ me._pl.nseries += 1
+ else:
+ me._set_mode(MODE_MULTI)
+ series = lookup(me._series, name, "unknown series `%s'" % name)
+ return series
+
+ def _opts_series(me, cmd, opts):
+ name = None
+ for k, v in me._keyvals(opts):
+ if k is None: name = v
+ else: me._bad_keyval(cmd, k, v)
+ return me._get_series(name), name
+
+ def _wantedp(me, name):
+ return me._series_wanted is None or name in me._series_wanted
+
+ def _process_cmd(me, ww):
+
+ cmd = ww.nextword(); check(cmd is not None, "missing command")
+ try: sep = cmd.index(":")
+ except ValueError: opts = None
+ else: cmd, opts = cmd[:sep], cmd[sep + 1:]
+
+ if cmd == "series":
+ name = None
+ for k, v in me._keyvals(opts):
+ if k is None: name = v
+ else: me._bad_keyval(cmd, k, v)
+ check(name is not None, "missing series name")
+ check(name not in me._series, "series `%s' already defined" % name)
+ title = ww.rest(); check(title is not None, "missing title")
+ me._set_mode(MODE_MULTI)
+ me._series[name] = Series(me._pl, title)
+ if me._wantedp(name): me._pl.nseries += 1
+
+ elif cmd == "season":
+ series, sname = me._opts_series(cmd, opts)
+ w = ww.nextword();
+ check(w is not None, "missing season number")
+ if w == "-":
+ check(ww.rest() is None, "trailing junk")
+ if not me._wantedp(sname): return
+ series.add_movies()
+ else:
+ title = ww.rest(); i = getint(w)
+ if not me._wantedp(sname): return
+ series.add_season(ww.rest(), getint(w), implicitp = False)
+ me._cur_episode = me._cur_chapter = None
+ me._pl.done_season()
+
+ elif cmd == "epname":
+ for k, v in me._keyvals(opts): me._bad_keyval("epname", k, v)
+ name = ww.rest(); check(name is not None, "missing episode name")
+ try: sep = name.index(",")
+ except ValueError: names = name + "s"
+ else: name, names = name[:sep], name[sep + 1:]
+ me._pl.epname, me._pl.epnames = name, names
+
+ elif cmd == "epno":
+ series, sname = me._opts_series(cmd, opts)
+ w = ww.rest(); check(w is not None, "missing episode number")
+ epi = getint(w)
+ if not me._wantedp(sname): return
+ series.ensure_season().ep_i = epi
+
+ elif cmd == "iso":
+ _, name = me._opts_series(cmd, opts)
+ fn = ww.rest(); check(fn is not None, "missing filename")
+ if not me._wantedp(name): return
+ if fn == "-": forget(me._isos, name)
+ else:
+ check(OS.path.exists(OS.path.join(ROOT, fn)),
+ "iso file `%s' not found" % fn)
+ me._isos[name] = VideoDisc(fn)
+
+ elif cmd == "vdir":
+ _, name = me._opts_series(cmd, opts)
+ dir = ww.rest(); check(dir is not None, "missing directory")
+ if not me._wantedp(name): return
+ if dir == "-": forget(me._vdirs, name)
+ else: me._vdirs[name] = VideoDir(dir)
+
+ elif cmd == "adir":
+ _, name = me._opts_series(cmd, opts)
+ dir = ww.rest(); check(dir is not None, "missing directory")
+ if not me._wantedp(name): return
+ if dir == "-": forget(me._audirs, name)
+ else: me._audirs[name] = AudioDir(dir)
- else:
+ else:
+ raise ExpectedError("unknown command `%s'" % cmd)
+
+ def _process_episode(me, ww):
+
+ opts = ww.nextword(); check(opts is not None, "missing title/options")
+ ti = None; sname = None; neps = 1; epi = None
+ for k, v in me._keyvals(opts):
+ if k is None:
+ if v.isdigit(): ti = int(v)
+ elif v == "-": ti = "-"
+ else: sname = v
+ elif k == "s": sname = v
+ elif k == "n": neps = getint(v)
+ elif k == "ep": epi = getint(v)
+ else: raise ExpectedError("unknown episode option `%s'" % k)
+ check(ti is not None, "missing title number")
+ series = me._get_series(sname)
+ me._cur_chapter = None
+
+ title = ww.rest()
+ if not me._wantedp(sname): return
+ season = series.ensure_season()
+ if epi is None: epi = season.ep_i
+
+ if ti == "-":
+ check(season.implicitp, "audio source, but explicit season")
+ if not me._wantedp(sname): return
+ dir = lookup(me._audirs, sname, "no title, and no audio directory")
+ src = lookup(dir.episodes, season.ep_i,
+ "episode %d not found in audio dir `%s'" % (epi, dir.dir))
- if not line[0].isspace():
- ww = Words(line)
- conf = ww.nextword()
-
- check(conf is not None, "missing config")
- i, vdname, neps, fake_epi = UNSET, "-", 1, ep_i
- for c in conf.split(","):
- if c.isdigit(): i = int(c)
- elif c == "-": i = None
- else:
- eq = c.find("="); check(eq >= 0, "bad assignment `%s'" % c)
- k, v = c[:eq], c[eq + 1:]
- if k == "vd": vdname = v
- elif k == "n": neps = getint(v)
- elif k == "ep": fake_epi = getint(v)
- else: raise ExpectedError("unknown setting `%s'" % k)
-
- title = ww.rest()
- check(i is not UNSET, "no title number")
- if season is None:
- season = playlist.add_season(None, 1, implicitp = True)
-
- if i is None:
- check(ads, "no title, but no audio directory")
- check(season.implicitp, "audio source, but explicit season")
- try: src = ads.episodes[ep_i]
- except KeyError:
- raise ExpectedError("episode %d not found in audio dir `%s'" %
- ep_i, ads.dir)
-
- elif iso:
- src = iso
-
- else:
- check(vdname in vds, "title, but no iso or video directory")
- try: vdir = vds[vdname]
- except KeyError:
- raise ExpectedError("video dir label `%s' not set" % vdname)
- try: s = vdir.seasons[season.i]
- except KeyError:
- raise ExpectedError("season %d not found in video dir `%s'" %
- (season.i, vdir.dir))
- try: src = s.episodes[ep_i]
- except KeyError:
- raise ExpectedError("episode %d.%d not found in video dir `%s'" %
- (season.i, ep_i, vdir.dir))
-
- episode = season.add_episode(fake_epi, neps, title, src, i)
- chapter = None
- ep_i += neps; src.nuses += neps
-
- else:
- ww = Words(line)
- title = ww.rest()
- check(episode is not None, "no current episode")
- check(episode.source.CHAPTERP,
- "episode source doesn't allow chapters")
- if chapter is None: j = 1
- else: j += 1
- chapter = episode.add_chapter(title, j)
-
- discs = set()
- for vdir in vds.values():
- for s in vdir.seasons.values():
- for d in s.episodes.values():
+ else:
+ try: src = me._isos[sname]
+ except KeyError:
+ dir = lookup(me._vdirs, sname,
+ "title, but no iso or video directory")
+ vseason = lookup(dir.seasons, season.i,
+ "season %d not found in video dir `%s'" %
+ (season.i, dir.dir))
+ src = lookup(vseason.episodes, season.ep_i,
+ "episode %d.%d not found in video dir `%s'" %
+ (season.i, season.ep_i, dir.dir))
+
+ episode = season.add_episode(epi, neps, title, src, ti)
+ me._pl.add_episode(episode)
+ me._cur_episode = episode
+
+ def _process_chapter(me, ww):
+ check(me._cur_episode is not None, "no current episode")
+ check(me._cur_episode.source.CHAPTERP,
+ "episode source doesn't allow chapters")
+ if me._chaptersp:
+ if me._cur_chapter is None: i = 1
+ else: i = me._cur_chapter.i + 1
+ me._cur_chapter = me._cur_episode.add_chapter(ww.rest(), i)
+
+ def parse_file(me, fn):
+ with location(FileLocation(fn, 0)) as floc:
+ with open(fn, "r") as f:
+ for line in f:
+ floc.stepline()
+ sline = line.lstrip()
+ if sline == "" or sline.startswith(";"): continue
+
+ if line.startswith("!"): me._process_cmd(Words(line[1:]))
+ elif not line[0].isspace(): me._process_episode(Words(line))
+ else: me._process_chapter(Words(line))
+ me._pl.done_season()
+
+ def done(me):
+ discs = set()
+ for name, vdir in me._vdirs.items():
+ if not me._wantedp(name): continue
+ for s in vdir.seasons.values():
+ for d in s.episodes.values():
+ discs.add(d)
+ for adir in me._audirs.values():
+ for d in adir.episodes.values():
discs.add(d)
- for d in sorted(discs, key = lambda d: d.fn):
- if d.neps != d.nuses:
- raise ExpectedError("disc `%s' has %d episodes, used %d times" %
- (d.fn, d.neps, d.nuses))
-
- return playlist
+ for d in sorted(discs, key = lambda d: d.fn):
+ if d.neps is not None and d.neps != d.nuses:
+ raise ExpectedError("disc `%s' has %d episodes, used %d times" %
+ (d.fn, d.neps, d.nuses))
+ return me._pl
ROOT = "/mnt/dvd/archive/"
+op = OP.OptionParser \
+ (usage = "%prog [-c] [-s SERIES] EPLS",
+ description = "Generate M3U playlists from an episode list.")
+op.add_option("-c", "--chapters",
+ dest = "chaptersp", action = "store_true", default = False,
+ help = "Output individual chapter names")
+op.add_option("-s", "--series",
+ dest = "series", type = "str", default = None,
+ help = "Output only the listed SERIES (comma-separated)")
+opts, argv = op.parse_args()
+if len(argv) != 1: op.print_usage(file = SYS.stderr); SYS.exit(2)
+if opts.series is None:
+ series_wanted = None
+else:
+ series_wanted = set()
+ for name in opts.series.split(","): series_wanted.add(name)
try:
- for f in SYS.argv[1:]:
- parse_list(f).write(SYS.stdout)
+ ep = EpisodeListParser(series_wanted, opts.chaptersp)
+ ep.parse_file(argv[0])
+ pl = ep.done()
+ pl.write(SYS.stdout)
except (ExpectedError, IOError, OSError) as e:
LOC.report(e)
SYS.exit(2)