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