chiark / gitweb /
lois-and-clark.epls, terminator-scc.epls: Fix filenames.
[epls] / mkm3u
1 #! /usr/bin/python3
2 ### -*- mode: python; coding: utf-8 -*-
3
4 from contextlib import contextmanager
5 import errno as E
6 import optparse as OP
7 import os as OS
8 import re as RX
9 import sqlite3 as SQL
10 import subprocess as SP
11 import sys as SYS
12
13 class ExpectedError (Exception): pass
14
15 @contextmanager
16 def location(loc):
17   global LOC
18   old, LOC = LOC, loc
19   yield loc
20   LOC = old
21
22 def filter(value, func = None, dflt = None):
23   if value is None: return dflt
24   elif func is None: return value
25   else: return func(value)
26
27 def check(cond, msg):
28   if not cond: raise ExpectedError(msg)
29
30 def lookup(dict, key, msg):
31   try: return dict[key]
32   except KeyError: raise ExpectedError(msg)
33
34 def forget(dict, key):
35   try: del dict[key]
36   except KeyError: pass
37
38 def getint(s):
39   if not s.isdigit(): raise ExpectedError("bad integer `%s'" % s)
40   return int(s)
41
42 def getbool(s):
43   if s == "t": return True
44   elif s == "nil": return False
45   else: raise ExpectedError("bad boolean `%s'" % s)
46
47 class Words (object):
48   def __init__(me, s):
49     me._s = s
50     me._i, me._n = 0, len(s)
51   def _wordstart(me):
52     s, i, n = me._s, me._i, me._n
53     while i < n:
54       if not s[i].isspace(): return i
55       i += 1
56     return -1
57   def nextword(me):
58     s, n = me._s, me._n
59     begin = i = me._wordstart()
60     if begin < 0: return None
61     while i < n and not s[i].isspace(): i += 1
62     me._i = i
63     return s[begin:i]
64   def rest(me):
65     s, n = me._s, me._n
66     begin = me._wordstart()
67     if begin < 0: return None
68     else: return s[begin:].rstrip()
69
70 def program_output(*args, **kw):
71   try: return SP.check_output(*args, **kw)
72   except SP.CalledProcessError as e:
73     raise ExpectedError("program `%s' failed with code %d" %
74                           (e.cmd, e.returncode))
75
76 URL_SAFE_P = 256*[False]
77 for ch in \
78     b"ABCDEFGHIJKLMNOPQRSTUVWXYZ" \
79     b"abcdefghijklmnopqrstuvwxyz" \
80     b"0123456789" b"!$%-.,/":
81   URL_SAFE_P[ch] = True
82 def urlencode(s):
83   return "".join((URL_SAFE_P[ch] and chr(ch) or "%%%02x" % ch
84                   for ch in s.encode("UTF-8")))
85
86 PROG = OS.path.basename(SYS.argv[0])
87
88 class BaseLocation (object):
89   def report(me, exc):
90     SYS.stderr.write("%s: %s%s\n" % (PROG, me._loc(), exc))
91
92 class DummyLocation (BaseLocation):
93   def _loc(me): return ""
94
95 class FileLocation (BaseLocation):
96   def __init__(me, fn, lno = 1): me._fn, me._lno = fn, lno
97   def _loc(me): return "%s:%d: " % (me._fn, me._lno)
98   def stepline(me): me._lno += 1
99
100 LOC = DummyLocation()
101
102 ROOT = "/mnt/dvd/archive/"
103 DB = None
104
105 def init_db(fn):
106   global DB
107   DB = SQL.connect(fn)
108   DB.cursor().execute("PRAGMA journal_mode = WAL")
109
110 def setup_db(fn):
111   try: OS.unlink(fn)
112   except OSError as e:
113     if e.errno == E.ENOENT: pass
114     else: raise
115   init_db(fn)
116   DB.cursor().execute("""
117           CREATE TABLE duration
118                   (path TEXT NOT NULL,
119                    title INTEGER NOT NULL,
120                    start_chapter INTEGER NOT NULL,
121                    end_chapter INTEGER NOT NULL,
122                    inode INTEGER NOT NULL,
123                    device INTEGER NOT NULL,
124                    size INTEGER NOT NULL,
125                    mtime REAL NOT NULL,
126                    duration REAL NOT NULL,
127                    PRIMARY KEY (path, title, start_chapter, end_chapter));
128   """)
129
130 class Source (object):
131
132   PREFIX = ""
133   TITLEP = CHAPTERP = False
134
135   def __init__(me, fn):
136     me.fn = fn
137     me.neps = None
138     me.used_titles = set()
139     me.used_chapters = set()
140     me.nuses = 0
141
142   def _duration(me, title, start_chapter, end_chapter):
143     return -1
144
145   def url_and_duration(me, title = -1, start_chapter = -1, end_chapter = -1):
146     if title == -1:
147       if me.TITLEP: raise ExpectedError("missing title number")
148       if start_chapter != -1 or end_chapter != -1:
149         raise ExpectedError("can't specify chapter without title")
150       suffix = ""
151     elif not me.TITLEP:
152       raise ExpectedError("can't specify title with `%s'" % me.fn)
153     elif start_chapter == -1:
154       if end_chapter != -1:
155         raise ExpectedError("can't specify end chapter without start chapter")
156       suffix = "#%d" % title
157     elif not me.CHAPTERP:
158       raise ExpectedError("can't specify chapter with `%s'" % me.fn)
159     elif end_chapter == -1:
160       suffix = "#%d:%d" % (title, start_chapter)
161     else:
162       suffix = "#%d:%d-%d:%d" % (title, start_chapter, title, end_chapter - 1)
163
164     duration = None
165     if DB is None:
166       duration = me._duration(title, start_chapter, end_chapter)
167     else:
168       st = OS.stat(OS.path.join(ROOT, me.fn))
169       duration = None
170       c = DB.cursor()
171       c.execute("""
172               SELECT device, inode, size, mtime,  duration FROM duration
173               WHERE path = ? AND title = ? AND
174                     start_chapter = ? AND end_chapter = ?
175       """, [me.fn, title, start_chapter, end_chapter])
176       row = c.fetchone()
177       foundp = False
178       if row is None:
179         duration = me._duration(title, start_chapter, end_chapter)
180         c.execute("""
181                 INSERT OR REPLACE INTO duration
182                         (path, title, start_chapter, end_chapter,
183                          device, inode, size, mtime,  duration)
184                 VALUES (?, ?, ?, ?,  ?, ?, ?, ?,  ?)
185         """, [me.fn, title, start_chapter, end_chapter,
186               st.st_dev, st.st_ino, st.st_size, st.st_mtime,
187               duration])
188       else:
189         dev, ino, sz, mt,  d = row
190         if (dev, ino, sz, mt) == \
191            (st.st_dev, st.st_ino, st.st_size, st.st_mtime):
192           duration = d
193         else:
194           duration = me._duration(title, start_chapter, end_chapter)
195           c.execute("""
196                   UPDATE duration
197                   SET device = ?, inode = ?, size = ?, mtime = ?, duration = ?
198                   WHERE path = ? AND title = ? AND
199                         start_chapter = ? AND end_chapter = ?
200           """, [st.st_dev, st.st_dev, st.st_size, st.st_mtime,  duration,
201                 me.fn, title, start_chapter, end_chapter])
202       DB.commit()
203
204     if end_chapter != -1:
205       keys = [(title, ch) for ch in range(start_chapter, end_chapter)]
206       set = me.used_chapters
207     else:
208       keys, set = [title], me.used_titles
209     for k in keys:
210       if k in set:
211         if title == -1:
212           raise ExpectedError("`%s' already used" % me.fn)
213         elif end_chapter == -1:
214           raise ExpectedError("`%s' title %d already used" % (me.fn, title))
215         else:
216           raise ExpectedError("`%s' title %d chapter %d already used" %
217                               (me.fn, title, k[1]))
218     if end_chapter == -1:
219       me.used_titles.add(title)
220     else:
221       for ch in range(start_chapter, end_chapter):
222         me.used_chapters.add((title, ch))
223     return me.PREFIX + ROOT + urlencode(me.fn) + suffix, duration
224
225 class VideoDisc (Source):
226   PREFIX = "dvd://"
227   TITLEP = CHAPTERP = True
228
229   def __init__(me, fn, *args, **kw):
230     super().__init__(fn, *args, **kw)
231     me.neps = 0
232
233   def _duration(me, title, start_chapter, end_chapter):
234     path = OS.path.join(ROOT, me.fn)
235     ntitle = int(program_output(["dvd-info", path, "titles"]))
236     if not 1 <= title <= ntitle:
237       raise ExpectedError("bad title %d for `%s': must be in 1 .. %d" %
238                             (title, me.fn, ntitle))
239     if start_chapter == -1:
240       durq = "duration:%d" % title
241     else:
242       nch = int(program_output(["dvd-info", path, "chapters:%d" % title]))
243       if end_chapter == -1: end_chapter = nch
244       else: end_chapter -= 1
245       if not 1 <= start_chapter <= end_chapter <= nch:
246         raise ExpectedError("bad chapter range %d .. %d for `%s' title %d: "
247                             "must be in 1 .. %d" %
248                             (start_chapter, end_chapter, me.fn, title, nch))
249       durq = "duration:%d.%d-%d" % (title, start_chapter, end_chapter)
250     duration = int(program_output(["dvd-info", path, durq]))
251     return duration
252
253 class VideoSeason (object):
254   def __init__(me, i, title):
255     me.i = i
256     me.title = title
257     me.episodes = {}
258   def set_episode_disc(me, i, disc):
259     if i in me.episodes:
260       raise ExpectedError("season %d episode %d already taken" % (me.i, i))
261     me.episodes[i] = disc; disc.neps += 1
262
263 def match_group(m, *groups, dflt = None, mustp = False):
264   for g in groups:
265     try: s = m.group(g)
266     except IndexError: continue
267     if s is not None: return s
268   if mustp: raise ValueError("no match found")
269   else: return dflt
270
271 class VideoDir (object):
272
273   _R_ISO_PRE = list(map(lambda pats:
274                           list(map(lambda pat:
275                                      RX.compile("^" + pat + r"\.iso$", RX.X),
276                                    pats)),
277     [[r""" S (?P<si> \d+) \. \ (?P<stitle> .*) — (?: D \d+ \. \ )?
278            (?P<epex> .*) """,
279       r""" S (?P<si> \d+) (?: D \d+)? \. \ (?P<epex> .*) """,
280       r""" S (?P<si> \d+) \. \ (?P<epex> E \d+ .*) """,
281       r""" S (?P<si> \d+) (?P<epex> E \d+) \. \ .* """],
282      [r""" (?P<si> \d+) [A-Z]? \. \ (?P<stitle> .*) — (?P<epex> .*) """],
283      [r""" \d+ \. \ (?P<epex> [ES] \d+ .*) """],
284      [r""" (?P<epnum> \d+ ) \. \ .* """]]))
285
286   _R_ISO_EP = RX.compile(r""" ^
287         (?: S (?P<si> \d+) \ )?
288         E (?P<ei> \d+) (?: – (?P<ej> \d+))? $
289   """, RX.X)
290
291   def __init__(me, dir):
292     me.dir = dir
293     fns = OS.listdir(OS.path.join(ROOT, dir))
294     fns.sort()
295     season = None
296     seasons = {}
297     styles = me._R_ISO_PRE
298     for fn in fns:
299       path = OS.path.join(dir, fn)
300       if not fn.endswith(".iso"): continue
301       #print(";; `%s'" % path, file = SYS.stderr)
302       for sty in styles:
303         for r in sty:
304           m = r.match(fn)
305           if m: styles = [sty]; break
306         else:
307           continue
308         break
309       else:
310         #print(";;\tignored (regex mismatch)", file = SYS.stderr)
311         continue
312
313       si = filter(match_group(m, "si"), int)
314       stitle = match_group(m, "stitle")
315
316       check(si is not None or stitle is None,
317             "explicit season title without number in `%s'" % fn)
318       if si is not None:
319         if season is None or si != season.i:
320           check(season is None or si == season.i + 1,
321                 "season %d /= %d" %
322                   (si, season is None and -1 or season.i + 1))
323           check(si not in seasons, "season %d already seen" % si)
324           seasons[si] = season = VideoSeason(si, stitle)
325         else:
326           check(stitle == season.title,
327                 "season title `%s' /= `%s'" % (stitle, season.title))
328
329       disc = VideoDisc(path)
330       ts = season
331       any, bad = False, False
332       epnum = match_group(m, "epnum")
333       if epnum is not None: eplist = ["E" + epnum]
334       else: eplist = match_group(m, "epex", mustp = True).split(", ")
335       for eprange in eplist:
336         mm = me._R_ISO_EP.match(eprange)
337         if mm is None:
338           #print(";;\t`%s'?" % eprange, file = SYS.stderr)
339           bad = True; continue
340         if not any: any = True
341         i = filter(mm.group("si"), int)
342         if i is not None:
343           try: ts = seasons[i]
344           except KeyError: ts = seasons[i] = VideoSeason(i, None)
345         if ts is None:
346           ts = season = seasons[1] = VideoSeason(1, None)
347         start = filter(mm.group("ei"), int)
348         end = filter(mm.group("ej"), int, start)
349         for k in range(start, end + 1):
350           ts.set_episode_disc(k, disc)
351           #print(";;\tepisode %d.%d" % (ts.i, k), file = SYS.stderr)
352       if not any:
353         #print(";;\tignored", file = SYS.stderr)
354         pass
355       elif bad:
356         raise ExpectedError("bad ep list in `%s'", fn)
357     me.seasons = seasons
358
359 class AudioDisc (Source):
360   PREFIX = "file://"
361   TITLEP = CHAPTERP = False
362
363   def _duration(me, title, start_chapter, end_chaptwr):
364     out = program_output(["metaflac",
365                           "--show-total-samples", "--show-sample-rate",
366                           OS.path.join(ROOT, me.fn)])
367     nsamples, hz = map(float, out.split())
368     return int(nsamples/hz)
369
370 class AudioEpisode (AudioDisc):
371   def __init__(me, fn, i, *args, **kw):
372     super().__init__(fn, *args, **kw)
373     me.i = i
374
375 class AudioDir (object):
376
377   _R_FLAC = RX.compile(r""" ^
378           E (\d+)
379           (?: \. \ (.*))?
380           \. flac $
381   """, RX.X)
382
383   def __init__(me, dir):
384     me.dir = dir
385     fns = OS.listdir(OS.path.join(ROOT, dir))
386     fns.sort()
387     episodes = {}
388     last_i = 0
389     for fn in fns:
390       path = OS.path.join(dir, fn)
391       if not fn.endswith(".flac"): continue
392       m = me._R_FLAC.match(fn)
393       if not m: continue
394       i = filter(m.group(1), int)
395       etitle = m.group(2)
396       check(i == last_i + 1, "episode %d /= %d" % (i, last_i + 1))
397       episodes[i] = AudioEpisode(path, i)
398       last_i = i
399     me.episodes = episodes
400
401 class Chapter (object):
402   def __init__(me, episode, title, i):
403     me.title, me.i = title, i
404     me.url, me.duration = \
405       episode.source.url_and_duration(episode.tno, i, i + 1)
406
407 class Episode (object):
408   def __init__(me, season, i, neps, title, src, series_title_p = True,
409                tno = -1, startch = -1, endch = -1):
410     me.season = season
411     me.i, me.neps, me.title = i, neps, title
412     me.chapters = []
413     me.source, me.tno = src, tno
414     me.series_title_p = series_title_p
415     me.url, me.duration = src.url_and_duration(tno, startch, endch)
416   def add_chapter(me, title, j):
417     ch = Chapter(me, title, j)
418     me.chapters.append(ch)
419     return ch
420   def label(me):
421     return me.season._eplabel(me.i, me.neps, me.title)
422
423 class BaseSeason (object):
424   def __init__(me, series, implicitp = False):
425     me.series = series
426     me.episodes = []
427     me.implicitp = implicitp
428     me.ep_i, episodes = 1, []
429   def add_episode(me, j, neps, title, src, series_title_p,
430                   tno, startch, endch):
431     ep = Episode(me, j, neps, title, src, series_title_p,
432                  tno, startch, endch)
433     me.episodes.append(ep)
434     src.nuses += neps; me.ep_i += neps
435     return ep
436   def _epnames(me, i, neps):
437     playlist = me.series.playlist
438     if neps == 1: return playlist.epname, ["%d" % i]
439     elif neps == 2: return playlist.epnames, ["%d" % i, "%d" % (i + 1)]
440     else: return playlist.epnames, ["%d–%d" % (i, i + neps - 1)]
441
442 class Season (BaseSeason):
443   def __init__(me, series, title, i, *args, **kw):
444     super().__init__(series, *args, **kw)
445     me.title, me.i = title, i
446   def _eplabel(me, i, neps, title):
447     epname, epn = me._epnames(i, neps)
448     if title is None:
449       if me.implicitp:
450         label = "%s %s" % (epname, ", ".join(epn))
451       elif me.title is None:
452         label = "%s %s" % \
453           (epname, ", ".join("%d.%s" % (me.i, e) for e in epn))
454       else:
455         label = "%s: %s %s" % (me.title, epname, ", ".join(epn))
456     else:
457       if me.implicitp:
458         label = "%s. %s" % (", ".join(epn), title)
459       elif me.title is None:
460         label = "%s. %s" % \
461           (", ".join("%d.%s" % (me.i, e) for e in epn), title)
462       else:
463         label = "%s: %s. %s" % (me.title, ", ".join(epn), title)
464     return label
465
466 class MovieSeason (BaseSeason):
467   def __init__(me, series, title, *args, **kw):
468     super().__init__(series, *args, **kw)
469     me.title = title
470     me.i = None
471   def add_episode(me, j, neps, title, src, series_title_p,
472                   tno, startch, endch):
473     if me.title is None and title is None:
474       raise ExpectedError("movie or movie season must have a title")
475     return super().add_episode(j, neps, title, src, series_title_p,
476                                tno, startch, endch)
477   def _eplabel(me, i, neps, title):
478     if me.title is None:
479       label = title
480     elif title is None:
481       epname, epn = me._epnames(i, neps)
482       label = "%s: %s %s" % (me.title, epname, ", ".join(epn))
483     else:
484       label = "%s: %s" % (me.title, title)
485     return label
486
487 class Series (object):
488   def __init__(me, playlist, name, title = None, wantedp = True):
489     me.playlist = playlist
490     me.name, me.title = name, title
491     me.cur_season = None
492     me.wantedp = wantedp
493   def _add_season(me, season):
494     me.cur_season = season
495   def add_season(me, title, i, implicitp = False):
496     me._add_season(Season(me, title, i, implicitp))
497   def add_movies(me, title = None):
498     me._add_season(MovieSeason(me, title))
499   def ensure_season(me):
500     if me.cur_season is None: me.add_season(None, 1, implicitp = True)
501     return me.cur_season
502   def end_season(me):
503     me.cur_season = None
504
505 class Playlist (object):
506
507   def __init__(me):
508     me.seasons = []
509     me.episodes = []
510     me.epname, me.epnames = "Episode", "Episodes"
511     me.nseries = 0
512
513   def add_episode(me, episode):
514     me.episodes.append(episode)
515
516   def done_season(me):
517     if me.episodes:
518       me.seasons.append(me.episodes)
519       me.episodes = []
520
521   def write(me, f):
522     f.write("#EXTM3U\n")
523     for season in me.seasons:
524       f.write("\n")
525       for ep in season:
526         label = ep.label()
527         if me.nseries > 1 and ep.series_title_p and \
528            ep.season.series.title is not None:
529           if ep.season.i is None: sep = ": "
530           else: sep = " "
531           label = ep.season.series.title + sep + label
532         if not ep.chapters:
533           f.write("#EXTINF:%d,,%s\n%s\n" % (ep.duration, label, ep.url))
534         else:
535           for ch in ep.chapters:
536             f.write("#EXTINF:%d,,%s: %s\n%s\n" %
537                     (ch.duration, label, ch.title, ch.url))
538
539   def write_deps(me, f, out):
540     deps = set()
541     for season in me.seasons:
542       for ep in season: deps.add(ep.source.fn)
543     f.write("### -*-makefile-*-\n")
544     f.write("%s: $(call check-deps, %s," % (out, out))
545     for dep in sorted(deps):
546       f.write(" \\\n\t'%s'" %
547                 OS.path.join(ROOT, dep)
548                   .replace(",", "$(comma)")
549                   .replace("'", "'\\''"))
550     f.write(")\n")
551
552 DEFAULT_EXPVAR = 0.05
553 R_DURMULT = RX.compile(r""" ^
554         (\d+ (?: \. \d+)?) x
555 $ """, RX.X)
556 R_DUR = RX.compile(r""" ^
557         (?: (?: (\d+) :)? (\d+) :)? (\d+)
558         (?: / (\d+ (?: \. \d+)?) \%)?
559 $ """, RX.X)
560 def parse_duration(s, base = None, basevar = DEFAULT_EXPVAR):
561   if base is not None:
562     m = R_DURMULT.match(s)
563     if m is not None: return base*float(m.group(1)), basevar
564   m = R_DUR.match(s)
565   if not m: raise ExpectedError("invalid duration spec `%s'" % s)
566   hr, min, sec = map(lambda g: filter(m.group(g), int, 0), [1, 2, 3])
567   var = filter(m.group(4), lambda x: float(x)/100.0)
568   if var is None: var = DEFAULT_EXPVAR
569   return 3600*hr + 60*min + sec, var
570 def format_duration(d):
571   if d >= 3600: return "%d:%02d:%02d" % (d//3600, (d//60)%60, d%60)
572   elif d >= 60: return "%d:%02d" % (d//60, d%60)
573   else: return "%d s" % d
574
575 MODE_UNSET = 0
576 MODE_SINGLE = 1
577 MODE_MULTI = 2
578
579 class EpisodeListParser (object):
580
581   def __init__(me, series_wanted = None, chapters_wanted_p = False):
582     me._pl = Playlist()
583     me._cur_episode = me._cur_chapter = None
584     me._series = {}; me._vdirs = {}; me._audirs = {}; me._isos = {}
585     me._series_wanted = series_wanted
586     me._chaptersp = chapters_wanted_p
587     me._explen, me._expvar = None, DEFAULT_EXPVAR
588     if series_wanted is None: me._mode = MODE_UNSET
589     else: me._mode = MODE_MULTI
590
591   def _bad_keyval(me, cmd, k, v):
592     raise ExpectedError("invalid `!%s' option `%s'" %
593                           (cmd, v if k is None else k))
594
595   def _keyvals(me, opts):
596     if opts is not None:
597       for kv in opts.split(","):
598         try: sep = kv.index("=")
599         except ValueError: yield None, kv
600         else: yield kv[:sep], kv[sep + 1:]
601
602   def _set_mode(me, mode):
603     if me._mode == MODE_UNSET:
604       me._mode = mode
605     elif me._mode != mode:
606       raise ExpectedError("inconsistent single-/multi-series usage")
607
608   def _get_series(me, name):
609     if name is None:
610       me._set_mode(MODE_SINGLE)
611       try: series = me._series[None]
612       except KeyError:
613         series = me._series[None] = Series(me._pl, None)
614         me._pl.nseries += 1
615     else:
616       me._set_mode(MODE_MULTI)
617       series = lookup(me._series, name, "unknown series `%s'" % name)
618     return series
619
620   def _opts_series(me, cmd, opts):
621     name = None
622     for k, v in me._keyvals(opts):
623       if k is None: name = v
624       else: me._bad_keyval(cmd, k, v)
625     return me._get_series(name)
626
627   def _auto_epsrc(me, series):
628     dir = lookup(me._vdirs, series.name, "no active video directory")
629     season = series.ensure_season()
630     check(season.i is not None, "must use explicit iso for movie seasons")
631     vseason = lookup(dir.seasons, season.i,
632                      "season %d not found in video dir `%s'" %
633                        (season.i, dir.dir))
634     src = lookup(vseason.episodes, season.ep_i,
635                  "episode %d.%d not found in video dir `%s'" %
636                    (season.i, season.ep_i, dir.dir))
637     return src
638
639   def _process_cmd(me, ww):
640
641     cmd = ww.nextword(); check(cmd is not None, "missing command")
642     try: sep = cmd.index(":")
643     except ValueError: opts = None
644     else: cmd, opts = cmd[:sep], cmd[sep + 1:]
645
646     if cmd == "series":
647       name = None
648       for k, v in me._keyvals(opts):
649         if k is None: name = v
650         else: me._bad_keyval(cmd, k, v)
651       check(name is not None, "missing series name")
652       check(name not in me._series, "series `%s' already defined" % name)
653       title = ww.rest()
654       me._set_mode(MODE_MULTI)
655       me._series[name] = series = Series(me._pl, name, title,
656                                          me._series_wanted is None or
657                                            name in me._series_wanted)
658       if series.wantedp: me._pl.nseries += 1
659
660     elif cmd == "season":
661       series = me._opts_series(cmd, opts)
662       w = ww.nextword();
663       check(w is not None, "missing season number")
664       if w == "-":
665         if not series.wantedp: return
666         series.add_movies(ww.rest())
667       else:
668         title = ww.rest(); i = getint(w)
669         if not series.wantedp: return
670         series.add_season(ww.rest(), getint(w), implicitp = False)
671       me._cur_episode = me._cur_chapter = None
672       me._pl.done_season()
673
674     elif cmd == "explen":
675       w = ww.rest(); check(w is not None, "missing duration spec")
676       if w == "-":
677         me._explen, me._expvar = None, DEFAULT_EXPVAR
678       else:
679         d, v = parse_duration(w)
680         me._explen = d
681         if v is not None: me._expvar = v
682
683     elif cmd == "epname":
684       for k, v in me._keyvals(opts): me._bad_keyval("epname", k, v)
685       name = ww.rest(); check(name is not None, "missing episode name")
686       try: sep = name.index("::")
687       except ValueError: names = name + "s"
688       else: name, names = name[:sep], name[sep + 2:]
689       me._pl.epname, me._pl.epnames = name, names
690
691     elif cmd == "epno":
692       series = me._opts_series(cmd, opts)
693       w = ww.rest(); check(w is not None, "missing episode number")
694       epi = getint(w)
695       if not series.wantedp: return
696       series.ensure_season().ep_i = epi
697
698     elif cmd == "iso":
699       series = me._opts_series(cmd, opts)
700       fn = ww.rest(); check(fn is not None, "missing filename")
701       if not series.wantedp: return
702       if fn == "-": forget(me._isos, series.name)
703       else:
704         check(OS.path.exists(OS.path.join(ROOT, fn)),
705               "iso file `%s' not found" % fn)
706         me._isos[series.name] = VideoDisc(fn)
707
708     elif cmd == "vdir":
709       series = me._opts_series(cmd, opts)
710       dir = ww.rest(); check(dir is not None, "missing directory")
711       if not series.wantedp: return
712       if dir == "-": forget(me._vdirs, series.name)
713       else: me._vdirs[series.name] = VideoDir(dir)
714
715     elif cmd == "adir":
716       series = me._opts_series(cmd, opts)
717       dir = ww.rest(); check(dir is not None, "missing directory")
718       if not series.wantedp: return
719       if dir == "-": forget(me._audirs, series.name)
720       else: me._audirs[series.name] = AudioDir(dir)
721
722     elif cmd == "displaced":
723       series = me._opts_series(cmd, opts)
724       w = ww.rest(); check(w is not None, "missing count"); n = getint(w)
725       src = me._auto_epsrc(series)
726       src.nuses += n
727
728     else:
729       raise ExpectedError("unknown command `%s'" % cmd)
730
731   def _process_episode(me, ww):
732
733     opts = ww.nextword(); check(opts is not None, "missing title/options")
734     ti = -1; sname = None; neps = 1; epi = None; loch = hich = -1
735     explen, expvar, explicitlen = me._explen, me._expvar, False
736     series_title_p = True
737     for k, v in me._keyvals(opts):
738       if k is None:
739         if v.isdigit(): ti = int(v)
740         elif v == "-": ti = -1
741         else: sname = v
742       elif k == "s": sname = v
743       elif k == "n": neps = getint(v)
744       elif k == "ep": epi = getint(v)
745       elif k == "st": series_title_p = getbool(v)
746       elif k == "l":
747         if v == "-": me._explen, me._expvar = None, DEFAULT_EXPVAR
748         else:
749           explen, expvar = parse_duration(v, explen, expvar)
750           explicitlen = True
751       elif k == "ch":
752         try: sep = v.index("-")
753         except ValueError: loch, hich = getint(v), -1
754         else: loch, hich = getint(v[:sep]), getint(v[sep + 1:]) + 1
755       else: raise ExpectedError("unknown episode option `%s'" % k)
756     check(ti is not None, "missing title number")
757     series = me._get_series(sname)
758     me._cur_chapter = None
759
760     title = ww.rest()
761     if not series.wantedp: return
762     season = series.ensure_season()
763     if epi is None: epi = season.ep_i
764
765     if ti == -1:
766       check(season.implicitp or season.i is None,
767             "audio source, but explicit non-movie season")
768       dir = lookup(me._audirs, series.name,
769                    "no title, and no audio directory")
770       src = lookup(dir.episodes, season.ep_i,
771                    "episode %d not found in audio dir `%s'" % (epi, dir.dir))
772
773     else:
774       try: src = me._isos[series.name]
775       except KeyError: src = me._auto_epsrc(series)
776
777     episode = season.add_episode(epi, neps, title, src,
778                                  series_title_p, ti, loch, hich)
779
780     if episode.duration != -1 and explen is not None:
781       if not explicitlen: explen *= neps
782       if not explen*(1 - expvar) <= episode.duration <= explen*(1 + expvar):
783         if season.i is None: epid = "episode %d" % epi
784         else: epid = "episode %d.%d" % (season.i, epi)
785         raise ExpectedError \
786           ("%s duration %s %g%% > %g%% from expected %s" %
787              (epid, format_duration(episode.duration),
788               abs(100*(episode.duration - explen)/explen), 100*expvar,
789               format_duration(explen)))
790     me._pl.add_episode(episode)
791     me._cur_episode = episode
792
793   def _process_chapter(me, ww):
794     check(me._cur_episode is not None, "no current episode")
795     check(me._cur_episode.source.CHAPTERP,
796           "episode source doesn't allow chapters")
797     if me._chaptersp:
798       if me._cur_chapter is None: i = 1
799       else: i = me._cur_chapter.i + 1
800       me._cur_chapter = me._cur_episode.add_chapter(ww.rest(), i)
801
802   def parse_file(me, fn):
803     with location(FileLocation(fn, 0)) as floc:
804       with open(fn, "r") as f:
805         for line in f:
806           floc.stepline()
807           sline = line.lstrip()
808           if sline == "" or sline.startswith(";"): continue
809
810           if line.startswith("!"): me._process_cmd(Words(line[1:]))
811           elif not line[0].isspace(): me._process_episode(Words(line))
812           else: me._process_chapter(Words(line))
813     me._pl.done_season()
814
815   def done(me):
816     discs = set()
817     for name, vdir in me._vdirs.items():
818       if not me._series[name].wantedp: continue
819       for s in vdir.seasons.values():
820         for d in s.episodes.values():
821           discs.add(d)
822     for adir in me._audirs.values():
823       for d in adir.episodes.values():
824         discs.add(d)
825     for d in sorted(discs, key = lambda d: d.fn):
826       if d.neps is not None and d.neps != d.nuses:
827         raise ExpectedError("disc `%s' has %d episodes, used %d times" %
828                             (d.fn, d.neps, d.nuses))
829     return me._pl
830
831 op = OP.OptionParser \
832   (usage = "%prog [-c] [-M DEPS] [-d CACHE] [-o OUT] [-s SERIES] EPLS\n"
833            "%prog -i -d CACHE",
834    description = "Generate M3U playlists from an episode list.")
835 op.add_option("-M", "--make-deps", metavar = "DEPS",
836               dest = "deps", type = "str", default = None,
837               help = "Write a `make' fragment for dependencies")
838 op.add_option("-c", "--chapters",
839               dest = "chaptersp", action = "store_true", default = False,
840               help = "Output individual chapter names")
841 op.add_option("-i", "--init-db",
842               dest = "initdbp", action = "store_true", default = False,
843               help = "Initialize the database")
844 op.add_option("-d", "--database", metavar = "CACHE",
845               dest = "database", type = "str", default = None,
846               help = "Set filename for cache database")
847 op.add_option("-o", "--output", metavar = "OUT",
848               dest = "output", type = "str", default = None,
849               help = "Write output playlist to OUT")
850 op.add_option("-O", "--fake-output", metavar = "OUT",
851               dest = "fakeout", type = "str", default = None,
852               help = "Pretend output goes to OUT for purposes of `-M'")
853 op.add_option("-s", "--series", metavar = "SERIES",
854               dest = "series", type = "str", default = None,
855               help = "Output only the listed SERIES (comma-separated)")
856 try:
857   opts, argv = op.parse_args()
858
859   if opts.initdbp:
860     if opts.chaptersp or opts.series is not None or \
861        opts.output is not None or opts.deps is not None or \
862        opts.fakeout is not None or \
863        opts.database is None or len(argv):
864       op.print_usage(file = SYS.stderr); SYS.exit(2)
865     setup_db(opts.database)
866
867   else:
868     if len(argv) != 1: op.print_usage(file = SYS.stderr); SYS.exit(2)
869     if opts.database is not None: init_db(opts.database)
870     if opts.series is None:
871       series_wanted = None
872     else:
873       series_wanted = set()
874       for name in opts.series.split(","): series_wanted.add(name)
875     if opts.deps is not None:
876       if (opts.output is None or opts.output == "-") and opts.fakeout is None:
877         raise ExpectedError("can't write dep fragment without output file")
878       if opts.fakeout is None: opts.fakeout = opts.output
879     else:
880       if opts.fakeout is not None:
881         raise ExpectedError("fake output set but no dep fragment")
882
883     ep = EpisodeListParser(series_wanted, opts.chaptersp)
884     ep.parse_file(argv[0])
885     pl = ep.done()
886
887     if opts.output is None or opts.output == "-":
888       pl.write(SYS.stdout)
889     else:
890       with open(opts.output, "w") as f: pl.write(f)
891
892     if opts.deps:
893       if opts.deps == "-":
894         pl.write_deps(SYS.stdout, opts.fakeout)
895       else:
896         with open(opts.deps, "w") as f: pl.write_deps(f, opts.fakeout)
897
898 except (ExpectedError, IOError, OSError) as e:
899   LOC.report(e)
900   SYS.exit(2)