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