Major overhaul to cope with multi-season episode lists.
[epls] / mkm3u
diff --git a/mkm3u b/mkm3u
index 9fdc90f..046373f 100755 (executable)
--- a/mkm3u
+++ b/mkm3u
@@ -2,6 +2,7 @@
 ### -*- 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
@@ -23,6 +24,14 @@ def filter(value, func = None, dflt = None):
 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)
@@ -86,7 +95,7 @@ class Source (object):
     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")
@@ -102,13 +111,13 @@ class Source (object):
     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
 
@@ -269,230 +278,323 @@ class Episode (object):
   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)