chiark / gitweb /
mkm3u: Don't escape underscores in URLs.
[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 opts.list_name is not None: f.write("LIST %s\n" % opts.list_name)
549     if me.series_title is not None and \
550        me.nseries > 1 and not me.single_series_p:
551       raise ExpectedError("can't force series name for multi-series list")
552     series = set()
553     if me.single_series_p:
554       f.write("SERIES - %s\n" % quote(me.series_title))
555     for season in me.seasons:
556       for ep in season:
557         label = ep.label()
558         title = ep.season.series.full_title
559         if me.single_series_p:
560           stag = "-"
561           if title is not None: label = title + " " + label
562         else:
563           if title is None: title = me.series_title
564           stag = ep.season.series.name
565           if stag is None: stag = "-"
566           if stag not in series:
567             f.write("SERIES %s %s\n" % (stag, quote(title)))
568             series.add(stag)
569         f.write("ENTRY %s %s %s %d %d %d %g\n" %
570                   (stag, quote(label), quote(ep.source.fn),
571                    ep.tno, ep.start_chapter, ep.end_chapter, ep.duration))
572
573   def write_deps(me, f, out):
574     deps = set()
575     for season in me.seasons:
576       for ep in season: deps.add(ep.source.fn)
577     f.write("### -*-makefile-*-\n")
578     f.write("%s: $(call check-deps, %s," % (out, out))
579     for dep in sorted(deps):
580       f.write(" \\\n\t'%s'" %
581                 OS.path.join(ROOT, dep)
582                   .replace(",", "$(comma)")
583                   .replace("'", "'\\''"))
584     f.write(")\n")
585
586 DEFAULT_EXPVAR = 0.05
587 R_DURMULT = RX.compile(r""" ^
588         (\d+ (?: \. \d+)?) x
589 $ """, RX.X)
590 R_DUR = RX.compile(r""" ^
591         (?: (?: (\d+) :)? (\d+) :)? (\d+)
592         (?: / (\d+ (?: \. \d+)?) \%)?
593 $ """, RX.X)
594 def parse_duration(s, base = None, basevar = DEFAULT_EXPVAR):
595   if base is not None:
596     m = R_DURMULT.match(s)
597     if m is not None: return base*float(m.group(1)), basevar
598   m = R_DUR.match(s)
599   if not m: raise ExpectedError("invalid duration spec `%s'" % s)
600   hr, min, sec = map(lambda g: filter(m.group(g), int, 0), [1, 2, 3])
601   var = filter(m.group(4), lambda x: float(x)/100.0)
602   if var is None: var = DEFAULT_EXPVAR
603   return 3600*hr + 60*min + sec, var
604 def format_duration(d):
605   if d >= 3600: return "%d:%02d:%02d" % (d//3600, (d//60)%60, d%60)
606   elif d >= 60: return "%d:%02d" % (d//60, d%60)
607   else: return "%d s" % d
608
609 MODE_UNSET = 0
610 MODE_SINGLE = 1
611 MODE_MULTI = 2
612
613 class EpisodeListParser (object):
614
615   def __init__(me, series_wanted = None, chapters_wanted_p = False):
616     me._pl = Playlist()
617     me._cur_episode = me._cur_chapter = None
618     me._series = {}; me._vdirs = {}; me._audirs = {}; me._isos = {}
619     me._series_wanted = series_wanted
620     me._chaptersp = chapters_wanted_p
621     me._explen, me._expvar = None, DEFAULT_EXPVAR
622     if series_wanted is None: me._mode = MODE_UNSET
623     else: me._mode = MODE_MULTI
624
625   def _bad_keyval(me, cmd, k, v):
626     raise ExpectedError("invalid `!%s' option `%s'" %
627                           (cmd, v if k is None else k))
628
629   def _keyvals(me, opts):
630     if opts is not None:
631       for kv in opts.split(","):
632         try: sep = kv.index("=")
633         except ValueError: yield None, kv
634         else: yield kv[:sep], kv[sep + 1:]
635
636   def _set_mode(me, mode):
637     if me._mode == MODE_UNSET:
638       me._mode = mode
639     elif me._mode != mode:
640       raise ExpectedError("inconsistent single-/multi-series usage")
641
642   def _get_series(me, name):
643     if name is None:
644       me._set_mode(MODE_SINGLE)
645       try: series = me._series[None]
646       except KeyError:
647         series = me._series[None] = Series(me._pl, None)
648         me._pl.nseries += 1
649     else:
650       me._set_mode(MODE_MULTI)
651       series = lookup(me._series, name, "unknown series `%s'" % name)
652     return series
653
654   def _opts_series(me, cmd, opts):
655     name = None
656     for k, v in me._keyvals(opts):
657       if k is None: name = v
658       else: me._bad_keyval(cmd, k, v)
659     return me._get_series(name)
660
661   def _auto_epsrc(me, series):
662     dir = lookup(me._vdirs, series.name, "no active video directory")
663     season = series.ensure_season()
664     check(season.i is not None, "must use explicit iso for movie seasons")
665     vseason = lookup(dir.seasons, season.i,
666                      "season %d not found in video dir `%s'" %
667                        (season.i, dir.dir))
668     src = lookup(vseason.episodes, season.ep_i,
669                  "episode %d.%d not found in video dir `%s'" %
670                    (season.i, season.ep_i, dir.dir))
671     return src
672
673   def _process_cmd(me, ww):
674
675     cmd = ww.nextword(); check(cmd is not None, "missing command")
676     try: sep = cmd.index(":")
677     except ValueError: opts = None
678     else: cmd, opts = cmd[:sep], cmd[sep + 1:]
679
680     if cmd == "title":
681       for k, v in me._keyvals(opts): me._bad_keyval("title", k, v)
682       title = ww.rest(); check(title is not None, "missing title")
683       check(me._pl.series_title is None, "already set a title")
684       me._pl.series_title = title
685
686     elif cmd == "single":
687       for k, v in me._keyvals(opts): me._bad_keyval("single", k, v)
688       check(ww.rest() is None, "trailing junk")
689       check(not me._pl.single_series_p, "single-series already set")
690       me._pl.single_series_p = True
691
692     elif cmd == "series":
693       name = None
694       for k, v in me._keyvals(opts):
695         if k is None: name = v
696         else: me._bad_keyval(cmd, k, v)
697       check(name is not None, "missing series name")
698       check(name not in me._series, "series `%s' already defined" % name)
699       title = ww.rest()
700       if title is None:
701         full = None
702       else:
703         try: sep = title.index("::")
704         except ValueError: full = title
705         else:
706           full = title[sep + 2:].strip()
707           if sep == 0: title = None
708           else: title = title[:sep].strip()
709       me._set_mode(MODE_MULTI)
710       me._series[name] = series = Series(me._pl, name, title, full,
711                                          me._series_wanted is None or
712                                            name in me._series_wanted)
713       if series.wantedp: me._pl.nseries += 1
714
715     elif cmd == "season":
716       series = me._opts_series(cmd, opts)
717       w = ww.nextword();
718       check(w is not None, "missing season number")
719       if w == "-":
720         if not series.wantedp: return
721         series.add_movies(ww.rest())
722       else:
723         title = ww.rest(); i = getint(w)
724         if not series.wantedp: return
725         series.add_season(ww.rest(), getint(w), implicitp = False)
726       me._cur_episode = me._cur_chapter = None
727       me._pl.done_season()
728
729     elif cmd == "explen":
730       w = ww.rest(); check(w is not None, "missing duration spec")
731       if w == "-":
732         me._explen, me._expvar = None, DEFAULT_EXPVAR
733       else:
734         d, v = parse_duration(w)
735         me._explen = d
736         if v is not None: me._expvar = v
737
738     elif cmd == "epname":
739       for k, v in me._keyvals(opts): me._bad_keyval("epname", k, v)
740       name = ww.rest(); check(name is not None, "missing episode name")
741       try: sep = name.index("::")
742       except ValueError: names = name + "s"
743       else: name, names = name[:sep], name[sep + 2:]
744       me._pl.epname, me._pl.epnames = name, names
745
746     elif cmd == "epno":
747       series = me._opts_series(cmd, opts)
748       w = ww.rest(); check(w is not None, "missing episode number")
749       epi = getint(w)
750       if not series.wantedp: return
751       series.ensure_season().ep_i = epi
752
753     elif cmd == "iso":
754       series = me._opts_series(cmd, opts)
755       fn = ww.rest(); check(fn is not None, "missing filename")
756       if not series.wantedp: return
757       if fn == "-": forget(me._isos, series.name)
758       else:
759         check(OS.path.exists(OS.path.join(ROOT, fn)),
760               "iso file `%s' not found" % fn)
761         me._isos[series.name] = VideoDisc(fn)
762
763     elif cmd == "vdir":
764       series = me._opts_series(cmd, opts)
765       dir = ww.rest(); check(dir is not None, "missing directory")
766       if not series.wantedp: return
767       if dir == "-": forget(me._vdirs, series.name)
768       else: me._vdirs[series.name] = VideoDir(dir)
769
770     elif cmd == "adir":
771       series = me._opts_series(cmd, opts)
772       dir = ww.rest(); check(dir is not None, "missing directory")
773       if not series.wantedp: return
774       if dir == "-": forget(me._audirs, series.name)
775       else: me._audirs[series.name] = AudioDir(dir)
776
777     elif cmd == "displaced":
778       series = me._opts_series(cmd, opts)
779       w = ww.rest(); check(w is not None, "missing count"); n = getint(w)
780       src = me._auto_epsrc(series)
781       src.nuses += n
782
783     else:
784       raise ExpectedError("unknown command `%s'" % cmd)
785
786   def _process_episode(me, ww):
787
788     opts = ww.nextword(); check(opts is not None, "missing title/options")
789     ti = -1; sname = None; neps = 1; epi = None; loch = hich = -1
790     explen, expvar, explicitlen = me._explen, me._expvar, False
791     series_title_p = True
792     for k, v in me._keyvals(opts):
793       if k is None:
794         if v.isdigit(): ti = int(v)
795         elif v == "-": ti = -1
796         else: sname = v
797       elif k == "s": sname = v
798       elif k == "n": neps = getint(v)
799       elif k == "ep": epi = getint(v)
800       elif k == "st": series_title_p = getbool(v)
801       elif k == "l":
802         if v == "-": me._explen, me._expvar = None, DEFAULT_EXPVAR
803         else:
804           explen, expvar = parse_duration(v, explen, expvar)
805           explicitlen = True
806       elif k == "ch":
807         try: sep = v.index("-")
808         except ValueError: loch, hich = getint(v), -1
809         else: loch, hich = getint(v[:sep]), getint(v[sep + 1:]) + 1
810       else: raise ExpectedError("unknown episode option `%s'" % k)
811     check(ti is not None, "missing title number")
812     series = me._get_series(sname)
813     me._cur_chapter = None
814
815     title = ww.rest()
816     if not series.wantedp: return
817     season = series.ensure_season()
818     if epi is None: epi = season.ep_i
819
820     if ti == -1:
821       check(season.implicitp or season.i is None,
822             "audio source, but explicit non-movie season")
823       dir = lookup(me._audirs, series.name,
824                    "no title, and no audio directory")
825       src = lookup(dir.episodes, season.ep_i,
826                    "episode %d not found in audio dir `%s'" % (epi, dir.dir))
827
828     else:
829       try: src = me._isos[series.name]
830       except KeyError: src = me._auto_epsrc(series)
831
832     episode = season.add_episode(epi, neps, title, src,
833                                  series_title_p, ti, loch, hich)
834
835     if episode.duration != -1 and explen is not None:
836       if not explicitlen: explen *= neps
837       if not explen*(1 - expvar) <= episode.duration <= explen*(1 + expvar):
838         if season.i is None: epid = "episode %d" % epi
839         else: epid = "episode %d.%d" % (season.i, epi)
840         raise ExpectedError \
841           ("%s duration %s %g%% > %g%% from expected %s" %
842              (epid, format_duration(episode.duration),
843               abs(100*(episode.duration - explen)/explen), 100*expvar,
844               format_duration(explen)))
845     me._pl.add_episode(episode)
846     me._cur_episode = episode
847
848   def _process_chapter(me, ww):
849     check(me._cur_episode is not None, "no current episode")
850     check(me._cur_episode.source.CHAPTERP,
851           "episode source doesn't allow chapters")
852     if me._chaptersp:
853       if me._cur_chapter is None: i = 1
854       else: i = me._cur_chapter.i + 1
855       me._cur_chapter = me._cur_episode.add_chapter(ww.rest(), i)
856
857   def parse_file(me, fn):
858     with location(FileLocation(fn, 0)) as floc:
859       with open(fn, "r") as f:
860         for line in f:
861           floc.stepline()
862           sline = line.lstrip()
863           if sline == "" or sline.startswith(";"): continue
864
865           if line.startswith("!"): me._process_cmd(Words(line[1:]))
866           elif not line[0].isspace(): me._process_episode(Words(line))
867           else: me._process_chapter(Words(line))
868     me._pl.done_season()
869
870   def done(me):
871     discs = set()
872     for name, vdir in me._vdirs.items():
873       if not me._series[name].wantedp: continue
874       for s in vdir.seasons.values():
875         for d in s.episodes.values():
876           discs.add(d)
877     for adir in me._audirs.values():
878       for d in adir.episodes.values():
879         discs.add(d)
880     for d in sorted(discs, key = lambda d: d.fn):
881       if d.neps is not None and d.neps != d.nuses:
882         raise ExpectedError("disc `%s' has %d episodes, used %d times" %
883                             (d.fn, d.neps, d.nuses))
884     return me._pl
885
886 op = OP.OptionParser \
887   (usage = "%prog [-Dc] [-L NAME] [-M DEPS] [-d CACHE] [-o OUT] [-s SERIES] EPLS\n"
888             "%prog -i -d CACHE",
889    description = "Generate M3U playlists from an episode list.")
890 op.add_option("-D", "--dump",
891               dest = "dump", action = "store_true", default = False,
892               help = "Dump playlist in machine-readable form")
893 op.add_option("-L", "--list-name", metavar = "NAME",
894               dest = "list_name", type = "str", default = None,
895               help = "Set the playlist name")
896 op.add_option("-M", "--make-deps", metavar = "DEPS",
897               dest = "deps", type = "str", default = None,
898               help = "Write a `make' fragment for dependencies")
899 op.add_option("-c", "--chapters",
900               dest = "chaptersp", action = "store_true", default = False,
901               help = "Output individual chapter names")
902 op.add_option("-i", "--init-db",
903               dest = "initdbp", action = "store_true", default = False,
904               help = "Initialize the database")
905 op.add_option("-d", "--database", metavar = "CACHE",
906               dest = "database", type = "str", default = None,
907               help = "Set filename for cache database")
908 op.add_option("-o", "--output", metavar = "OUT",
909               dest = "output", type = "str", default = None,
910               help = "Write output playlist to OUT")
911 op.add_option("-O", "--fake-output", metavar = "OUT",
912               dest = "fakeout", type = "str", default = None,
913               help = "Pretend output goes to OUT for purposes of `-M'")
914 op.add_option("-s", "--series", metavar = "SERIES",
915               dest = "series", type = "str", default = None,
916               help = "Output only the listed SERIES (comma-separated)")
917 try:
918   opts, argv = op.parse_args()
919
920   if opts.initdbp:
921     if opts.chaptersp or opts.series is not None or \
922        opts.output is not None or opts.deps is not None or \
923        opts.fakeout is not None or \
924        opts.database is None or len(argv):
925       op.print_usage(file = SYS.stderr); SYS.exit(2)
926     setup_db(opts.database)
927
928   else:
929     if len(argv) != 1: op.print_usage(file = SYS.stderr); SYS.exit(2)
930     if opts.database is not None: init_db(opts.database)
931     if opts.series is None:
932       series_wanted = None
933     else:
934       series_wanted = set()
935       for name in opts.series.split(","): series_wanted.add(name)
936     if opts.deps is not None:
937       if (opts.output is None or opts.output == "-") and opts.fakeout is None:
938         raise ExpectedError("can't write dep fragment without output file")
939       if opts.fakeout is None: opts.fakeout = opts.output
940     else:
941       if opts.fakeout is not None:
942         raise ExpectedError("fake output set but no dep fragment")
943
944     ep = EpisodeListParser(series_wanted, opts.chaptersp)
945     ep.parse_file(argv[0])
946     pl = ep.done()
947
948     if opts.list_name is None:
949       opts.list_name, _ = OS.path.splitext(OS.path.basename(argv[0]))
950
951     if opts.dump: outfn = pl.dump
952     else: outfn = pl.write
953     if opts.output is None or opts.output == "-":
954       outfn(SYS.stdout)
955     else:
956       with open(opts.output, "w") as f: outfn(f)
957
958     if opts.deps:
959       if opts.deps == "-":
960         pl.write_deps(SYS.stdout, opts.fakeout)
961       else:
962         with open(opts.deps, "w") as f: pl.write_deps(f, opts.fakeout)
963
964 except (ExpectedError, IOError, OSError) as e:
965   LOC.report(e)
966   SYS.exit(2)