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