chiark / gitweb /
mkm3u: Maintain a cache of durations because they take ages to look up.
[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   PREFIX = ""
132   TITLEP = CHAPTERP = False
133   def __init__(me, fn):
134     me.fn = fn
135     me.neps = None
136     me.used_titles = dict()
137     me.used_chapters = set()
138     me.nuses = 0
139   def _duration(me, title, start_chapter, end_chapter):
140     return -1
141   def url_and_duration(me, title = None,
142                        start_chapter = None, end_chapter = None):
143     if title == "-":
144       if me.TITLEP: raise ExpectedError("missing title number")
145       if start_chapter is not None or end_chapter is not None:
146         raise ExpectedError("can't specify chapter without title")
147       suffix = ""
148     elif not me.TITLEP:
149       raise ExpectedError("can't specify title with `%s'" % me.fn)
150     elif start_chapter is None:
151       if end_chapter is not None:
152         raise ExpectedError("can't specify end chapter without start chapter")
153       suffix = "#%d" % title
154     elif not me.CHAPTERP:
155       raise ExpectedError("can't specify chapter with `%s'" % me.fn)
156     elif end_chapter is None:
157       suffix = "#%d:%d" % (title, start_chapter)
158     else:
159       suffix = "#%d:%d-%d:%d" % (title, start_chapter, title, end_chapter - 1)
160
161     duration = None
162     if DB is None:
163       duration = me._duration(title, start_chapter, end_chapter)
164     else:
165       st = OS.stat(OS.path.join(ROOT, me.fn))
166       duration = None
167       c = DB.cursor()
168       c.execute("""
169               SELECT device, inode, size, mtime,  duration FROM duration
170               WHERE path = ? AND title = ? AND
171                     start_chapter = ? AND end_chapter = ?
172       """, [me.fn, title, start_chapter is None and -1 or start_chapter,
173             end_chapter is None and -1 or end_chapter])
174       row = c.fetchone()
175       foundp = False
176       if row is None:
177         duration = me._duration(title, start_chapter, end_chapter)
178         c.execute("""
179                 INSERT OR REPLACE INTO duration
180                         (path, title, start_chapter, end_chapter,
181                          device, inode, size, mtime,  duration)
182                 VALUES (?, ?, ?, ?,  ?, ?, ?, ?,  ?)
183         """, [me.fn, title, start_chapter is None and -1 or start_chapter,
184               end_chapter is None and -1 or end_chapter,
185               st.st_dev, st.st_ino, st.st_size, st.st_mtime,
186               duration])
187       else:
188         dev, ino, sz, mt,  d = row
189         if (dev, ino, sz, mt) == \
190            (st.st_dev, st.st_ino, st.st_size, st.st_mtime):
191           duration = d
192         else:
193           duration = me._duration(title, start_chapter, end_chapter)
194           c.execute("""
195                   UPDATE duration
196                   SET device = ?, inode = ?, size = ?, mtime = ?, duration = ?
197                   WHERE path = ? AND title = ? AND
198                         start_chapter = ? AND end_chapter = ?
199         """, [st.st_dev, st.st_dev, st.st_size, st.st_mtime,  duration,
200               me.fn, title, start_chapter is None and -1 or start_chapter,
201               end_chapter is None and -1 or end_chapter])
202       DB.commit()
203
204     if end_chapter is not None:
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 == "-":
212           raise ExpectedError("`%s' already used" % me.fn)
213         elif end_chapter is None:
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 is not None:
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 is None:
238       durq = "duration:%d" % title
239     else:
240       nch = int(program_output(["dvd-info", path, "chapters:%d" % title]))
241       if end_chapter is None: 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 = None, startch = None, endch = None):
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   def __init__(me):
505     me.seasons = []
506     me.episodes = []
507     me.epname, me.epnames = "Episode", "Episodes"
508     me.nseries = 0
509   def add_episode(me, episode):
510     me.episodes.append(episode)
511   def done_season(me):
512     if me.episodes:
513       me.seasons.append(me.episodes)
514       me.episodes = []
515   def write(me, f):
516     f.write("#EXTM3U\n")
517     for season in me.seasons:
518       f.write("\n")
519       for ep in season:
520         label = ep.label()
521         if me.nseries > 1 and ep.series_title_p and \
522            ep.season.series.title is not None:
523           if ep.season.i is None: sep = ": "
524           else: sep = " "
525           label = ep.season.series.title + sep + label
526         if not ep.chapters:
527           f.write("#EXTINF:%d,,%s\n%s\n" % (ep.duration, label, ep.url))
528         else:
529           for ch in ep.chapters:
530             f.write("#EXTINF:%d,,%s: %s\n%s\n" %
531                     (ch.duration, label, ch.title, ch.url))
532
533 DEFAULT_EXPVAR = 0.05
534 R_DURMULT = RX.compile(r""" ^
535         (\d+ (?: \. \d+)?) x
536 $ """, RX.X)
537 R_DUR = RX.compile(r""" ^
538         (?: (?: (\d+) :)? (\d+) :)? (\d+)
539         (?: / (\d+ (?: \. \d+)?) \%)?
540 $ """, RX.X)
541 def parse_duration(s, base = None, basevar = DEFAULT_EXPVAR):
542   if base is not None:
543     m = R_DURMULT.match(s)
544     if m is not None: return base*float(m.group(1)), basevar
545   m = R_DUR.match(s)
546   if not m: raise ExpectedError("invalid duration spec `%s'" % s)
547   hr, min, sec = map(lambda g: filter(m.group(g), int, 0), [1, 2, 3])
548   var = filter(m.group(4), lambda x: float(x)/100.0)
549   if var is None: var = DEFAULT_EXPVAR
550   return 3600*hr + 60*min + sec, var
551 def format_duration(d):
552   if d >= 3600: return "%d:%02d:%02d" % (d//3600, (d//60)%60, d%60)
553   elif d >= 60: return "%d:%02d" % (d//60, d%60)
554   else: return "%d s" % d
555
556 MODE_UNSET = 0
557 MODE_SINGLE = 1
558 MODE_MULTI = 2
559
560 class EpisodeListParser (object):
561
562   def __init__(me, series_wanted = None, chapters_wanted_p = False):
563     me._pl = Playlist()
564     me._cur_episode = me._cur_chapter = None
565     me._series = {}; me._vdirs = {}; me._audirs = {}; me._isos = {}
566     me._series_wanted = series_wanted
567     me._chaptersp = chapters_wanted_p
568     me._explen, me._expvar = None, DEFAULT_EXPVAR
569     if series_wanted is None: me._mode = MODE_UNSET
570     else: me._mode = MODE_MULTI
571
572   def _bad_keyval(me, cmd, k, v):
573     raise ExpectedError("invalid `!%s' option `%s'" %
574                           (cmd, v if k is None else k))
575
576   def _keyvals(me, opts):
577     if opts is not None:
578       for kv in opts.split(","):
579         try: sep = kv.index("=")
580         except ValueError: yield None, kv
581         else: yield kv[:sep], kv[sep + 1:]
582
583   def _set_mode(me, mode):
584     if me._mode == MODE_UNSET:
585       me._mode = mode
586     elif me._mode != mode:
587       raise ExpectedError("inconsistent single-/multi-series usage")
588
589   def _get_series(me, name):
590     if name is None:
591       me._set_mode(MODE_SINGLE)
592       try: series = me._series[None]
593       except KeyError:
594         series = me._series[None] = Series(me._pl, None)
595         me._pl.nseries += 1
596     else:
597       me._set_mode(MODE_MULTI)
598       series = lookup(me._series, name, "unknown series `%s'" % name)
599     return series
600
601   def _opts_series(me, cmd, opts):
602     name = None
603     for k, v in me._keyvals(opts):
604       if k is None: name = v
605       else: me._bad_keyval(cmd, k, v)
606     return me._get_series(name)
607
608   def _auto_epsrc(me, series):
609     dir = lookup(me._vdirs, series.name, "no active video directory")
610     season = series.ensure_season()
611     check(season.i is not None, "must use explicit iso for movie seasons")
612     vseason = lookup(dir.seasons, season.i,
613                      "season %d not found in video dir `%s'" %
614                        (season.i, dir.dir))
615     src = lookup(vseason.episodes, season.ep_i,
616                  "episode %d.%d not found in video dir `%s'" %
617                    (season.i, season.ep_i, dir.dir))
618     return src
619
620   def _process_cmd(me, ww):
621
622     cmd = ww.nextword(); check(cmd is not None, "missing command")
623     try: sep = cmd.index(":")
624     except ValueError: opts = None
625     else: cmd, opts = cmd[:sep], cmd[sep + 1:]
626
627     if cmd == "series":
628       name = None
629       for k, v in me._keyvals(opts):
630         if k is None: name = v
631         else: me._bad_keyval(cmd, k, v)
632       check(name is not None, "missing series name")
633       check(name not in me._series, "series `%s' already defined" % name)
634       title = ww.rest()
635       me._set_mode(MODE_MULTI)
636       me._series[name] = series = Series(me._pl, name, title,
637                                          me._series_wanted is None or
638                                            name in me._series_wanted)
639       if series.wantedp: me._pl.nseries += 1
640
641     elif cmd == "season":
642       series = me._opts_series(cmd, opts)
643       w = ww.nextword();
644       check(w is not None, "missing season number")
645       if w == "-":
646         if not series.wantedp: return
647         series.add_movies(ww.rest())
648       else:
649         title = ww.rest(); i = getint(w)
650         if not series.wantedp: return
651         series.add_season(ww.rest(), getint(w), implicitp = False)
652       me._cur_episode = me._cur_chapter = None
653       me._pl.done_season()
654
655     elif cmd == "explen":
656       w = ww.rest(); check(w is not None, "missing duration spec")
657       if w == "-":
658         me._explen, me._expvar = None, DEFAULT_EXPVAR
659       else:
660         d, v = parse_duration(w)
661         me._explen = d
662         if v is not None: me._expvar = v
663
664     elif cmd == "epname":
665       for k, v in me._keyvals(opts): me._bad_keyval("epname", k, v)
666       name = ww.rest(); check(name is not None, "missing episode name")
667       try: sep = name.index("::")
668       except ValueError: names = name + "s"
669       else: name, names = name[:sep], name[sep + 1:]
670       me._pl.epname, me._pl.epnames = name, names
671
672     elif cmd == "epno":
673       series = me._opts_series(cmd, opts)
674       w = ww.rest(); check(w is not None, "missing episode number")
675       epi = getint(w)
676       if not series.wantedp: return
677       series.ensure_season().ep_i = epi
678
679     elif cmd == "iso":
680       series = me._opts_series(cmd, opts)
681       fn = ww.rest(); check(fn is not None, "missing filename")
682       if not series.wantedp: return
683       if fn == "-": forget(me._isos, series.name)
684       else:
685         check(OS.path.exists(OS.path.join(ROOT, fn)),
686               "iso file `%s' not found" % fn)
687         me._isos[series.name] = VideoDisc(fn)
688
689     elif cmd == "vdir":
690       series = me._opts_series(cmd, opts)
691       dir = ww.rest(); check(dir is not None, "missing directory")
692       if not series.wantedp: return
693       if dir == "-": forget(me._vdirs, series.name)
694       else: me._vdirs[series.name] = VideoDir(dir)
695
696     elif cmd == "adir":
697       series = me._opts_series(cmd, opts)
698       dir = ww.rest(); check(dir is not None, "missing directory")
699       if not series.wantedp: return
700       if dir == "-": forget(me._audirs, series.name)
701       else: me._audirs[series.name] = AudioDir(dir)
702
703     elif cmd == "displaced":
704       series = me._opts_series(cmd, opts)
705       w = ww.rest(); check(w is not None, "missing count"); n = getint(w)
706       src = me._auto_epsrc(series)
707       src.nuses += n
708
709     else:
710       raise ExpectedError("unknown command `%s'" % cmd)
711
712   def _process_episode(me, ww):
713
714     opts = ww.nextword(); check(opts is not None, "missing title/options")
715     ti = None; sname = None; neps = 1; epi = None; loch = hich = None
716     explen, expvar, explicitlen = me._explen, me._expvar, False
717     series_title_p = True
718     for k, v in me._keyvals(opts):
719       if k is None:
720         if v.isdigit(): ti = int(v)
721         elif v == "-": ti = "-"
722         else: sname = v
723       elif k == "s": sname = v
724       elif k == "n": neps = getint(v)
725       elif k == "ep": epi = getint(v)
726       elif k == "st": series_title_p = getbool(v)
727       elif k == "l":
728         if v == "-": me._explen, me._expvar = None, DEFAULT_EXPVAR
729         else:
730           explen, expvar = parse_duration(v, explen, expvar)
731           explicitlen = True
732       elif k == "ch":
733         try: sep = v.index("-")
734         except ValueError: loch, hich = getint(v), None
735         else: loch, hich = getint(v[:sep]), getint(v[sep + 1:]) + 1
736       else: raise ExpectedError("unknown episode option `%s'" % k)
737     check(ti is not None, "missing title number")
738     series = me._get_series(sname)
739     me._cur_chapter = None
740
741     title = ww.rest()
742     if not series.wantedp: return
743     season = series.ensure_season()
744     if epi is None: epi = season.ep_i
745
746     if ti == "-":
747       check(season.implicitp, "audio source, but explicit season")
748       dir = lookup(me._audirs, series.name,
749                    "no title, and no audio directory")
750       src = lookup(dir.episodes, season.ep_i,
751                    "episode %d not found in audio dir `%s'" % (epi, dir.dir))
752
753     else:
754       try: src = me._isos[series.name]
755       except KeyError: src = me._auto_epsrc(series)
756
757     episode = season.add_episode(epi, neps, title, src,
758                                  series_title_p, ti, loch, hich)
759
760     if episode.duration != -1 and explen is not None:
761       if not explicitlen: explen *= neps
762       if not explen*(1 - expvar) <= episode.duration <= explen*(1 + expvar):
763         if season.i is None: epid = "episode %d" % epi
764         else: epid = "episode %d.%d" % (season.i, epi)
765         raise ExpectedError \
766           ("%s duration %s %g%% > %g%% from expected %s" %
767              (epid, format_duration(episode.duration),
768               abs(100*(episode.duration - explen)/explen), 100*expvar,
769               format_duration(explen)))
770     me._pl.add_episode(episode)
771     me._cur_episode = episode
772
773   def _process_chapter(me, ww):
774     check(me._cur_episode is not None, "no current episode")
775     check(me._cur_episode.source.CHAPTERP,
776           "episode source doesn't allow chapters")
777     if me._chaptersp:
778       if me._cur_chapter is None: i = 1
779       else: i = me._cur_chapter.i + 1
780       me._cur_chapter = me._cur_episode.add_chapter(ww.rest(), i)
781
782   def parse_file(me, fn):
783     with location(FileLocation(fn, 0)) as floc:
784       with open(fn, "r") as f:
785         for line in f:
786           floc.stepline()
787           sline = line.lstrip()
788           if sline == "" or sline.startswith(";"): continue
789
790           if line.startswith("!"): me._process_cmd(Words(line[1:]))
791           elif not line[0].isspace(): me._process_episode(Words(line))
792           else: me._process_chapter(Words(line))
793     me._pl.done_season()
794
795   def done(me):
796     discs = set()
797     for name, vdir in me._vdirs.items():
798       if not me._series[name].wantedp: continue
799       for s in vdir.seasons.values():
800         for d in s.episodes.values():
801           discs.add(d)
802     for adir in me._audirs.values():
803       for d in adir.episodes.values():
804         discs.add(d)
805     for d in sorted(discs, key = lambda d: d.fn):
806       if d.neps is not None and d.neps != d.nuses:
807         raise ExpectedError("disc `%s' has %d episodes, used %d times" %
808                             (d.fn, d.neps, d.nuses))
809     return me._pl
810
811 op = OP.OptionParser \
812   (usage = "%prog [-c] [-d CACHE] [-s SERIES] EPLS\n"
813            "%prog -i -d CACHE",
814    description = "Generate M3U playlists from an episode list.")
815 op.add_option("-c", "--chapters",
816               dest = "chaptersp", action = "store_true", default = False,
817               help = "Output individual chapter names")
818 op.add_option("-i", "--init-db",
819               dest = "initdbp", action = "store_true", default = False,
820               help = "Initialize the database")
821 op.add_option("-d", "--database",
822               dest = "database", type = "str", default = None,
823               help = "Set filename for cache database")
824 op.add_option("-s", "--series",
825               dest = "series", type = "str", default = None,
826               help = "Output only the listed SERIES (comma-separated)")
827 try:
828   opts, argv = op.parse_args()
829   if opts.initdbp:
830     if opts.chaptersp or opts.series is not None or \
831        opts.database is None or len(argv):
832       op.print_usage(file = SYS.stderr); SYS.exit(2)
833     setup_db(opts.database)
834   else:
835     if len(argv) != 1: op.print_usage(file = SYS.stderr); SYS.exit(2)
836     if opts.database is not None: init_db(opts.database)
837     if opts.series is None:
838       series_wanted = None
839     else:
840       series_wanted = set()
841       for name in opts.series.split(","): series_wanted.add(name)
842     ep = EpisodeListParser(series_wanted, opts.chaptersp)
843     ep.parse_file(argv[0])
844     pl = ep.done()
845     pl.write(SYS.stdout)
846 except (ExpectedError, IOError, OSError) as e:
847   LOC.report(e)
848   SYS.exit(2)