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