chiark / gitweb /
aeon-flux.epls: Add playlist for new series.
[epls] / mkm3u
1 #! /usr/bin/python3
2 ### -*- mode: python; coding: utf-8 -*-
3
4 from contextlib import contextmanager
5 import errno as E
6 import optparse as OP
7 import os as OS
8 import re as RX
9 import sqlite3 as SQL
10 import subprocess as SP
11 import sys as SYS
12
13 class ExpectedError (Exception): pass
14
15 @contextmanager
16 def location(loc):
17   global LOC
18   old, LOC = LOC, loc
19   yield loc
20   LOC = old
21
22 def filter(value, func = None, dflt = None):
23   if value is None: return dflt
24   elif func is None: return value
25   else: return func(value)
26
27 def check(cond, msg):
28   if not cond: raise ExpectedError(msg)
29
30 def lookup(dict, key, msg):
31   try: return dict[key]
32   except KeyError: raise ExpectedError(msg)
33
34 def forget(dict, key):
35   try: del dict[key]
36   except KeyError: pass
37
38 def getint(s):
39   if not s.isdigit(): raise ExpectedError("bad integer `%s'" % s)
40   return int(s)
41
42 def getbool(s):
43   if s == "t": return True
44   elif s == "nil": return False
45   else: raise ExpectedError("bad boolean `%s'" % s)
46
47 def quote(s):
48   if s is None: return "-"
49   else: return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
50
51 class Words (object):
52   def __init__(me, s):
53     me._s = s
54     me._i, me._n = 0, len(s)
55   def _wordstart(me):
56     s, i, n = me._s, me._i, me._n
57     while i < n:
58       if not s[i].isspace(): return i
59       i += 1
60     return -1
61   def nextword(me):
62     s, n = me._s, me._n
63     begin = i = me._wordstart()
64     if begin < 0: return None
65     while i < n and not s[i].isspace(): i += 1
66     me._i = i
67     return s[begin:i]
68   def rest(me):
69     s, n = me._s, me._n
70     begin = me._wordstart()
71     if begin < 0: return None
72     else: return s[begin:].rstrip()
73
74 def program_output(*args, **kw):
75   try: return SP.check_output(*args, **kw)
76   except SP.CalledProcessError as e:
77     raise ExpectedError("program `%s' failed with code %d" %
78                           (e.cmd, e.returncode))
79
80 URL_SAFE_P = 256*[False]
81 for ch in \
82     b"ABCDEFGHIJKLMNOPQRSTUVWXYZ" \
83     b"abcdefghijklmnopqrstuvwxyz" \
84     b"0123456789" b"!$%_-.,/":
85   URL_SAFE_P[ch] = True
86 def urlencode(s):
87   return "".join((URL_SAFE_P[ch] and chr(ch) or "%%%02x" % ch
88                   for ch in s.encode("UTF-8")))
89
90 PROG = OS.path.basename(SYS.argv[0])
91
92 class BaseLocation (object):
93   def report(me, exc):
94     SYS.stderr.write("%s: %s%s\n" % (PROG, me._loc(), exc))
95
96 class DummyLocation (BaseLocation):
97   def _loc(me): return ""
98
99 class FileLocation (BaseLocation):
100   def __init__(me, fn, lno = 1): me._fn, me._lno = fn, lno
101   def _loc(me): return "%s:%d: " % (me._fn, me._lno)
102   def stepline(me): me._lno += 1
103
104 LOC = DummyLocation()
105
106 ROOT = "/mnt/dvd/archive/"
107 DB = None
108
109 def init_db(fn):
110   global DB
111   DB = SQL.connect(fn)
112   DB.cursor().execute("PRAGMA journal_mode = WAL")
113
114 def setup_db(fn):
115   try: OS.unlink(fn)
116   except OSError as e:
117     if e.errno == E.ENOENT: pass
118     else: raise
119   init_db(fn)
120   DB.cursor().execute("""
121           CREATE TABLE duration
122                   (path TEXT NOT NULL,
123                    title INTEGER NOT NULL,
124                    start_chapter INTEGER NOT NULL,
125                    end_chapter INTEGER NOT NULL,
126                    inode INTEGER NOT NULL,
127                    device INTEGER NOT NULL,
128                    size INTEGER NOT NULL,
129                    mtime REAL NOT NULL,
130                    duration REAL NOT NULL,
131                    PRIMARY KEY (path, title, start_chapter, end_chapter));
132   """)
133
134 class Source (object):
135
136   PREFIX = ""
137   TITLEP = CHAPTERP = False
138
139   def __init__(me, fn):
140     me.fn = fn
141     me.neps = None
142     me.used_titles = set()
143     me.used_chapters = set()
144     me.nuses = 0
145
146   def _duration(me, title, start_chapter, end_chapter):
147     return -1
148
149   def url_and_duration(me, title = -1, start_chapter = -1, end_chapter = -1):
150     if title == -1:
151       if me.TITLEP: raise ExpectedError("missing title number")
152       if start_chapter != -1 or end_chapter != -1:
153         raise ExpectedError("can't specify chapter without title")
154       suffix = ""
155     elif not me.TITLEP:
156       raise ExpectedError("can't specify title with `%s'" % me.fn)
157     elif start_chapter == -1:
158       if end_chapter != -1:
159         raise ExpectedError("can't specify end chapter without start chapter")
160       suffix = "#%d" % title
161     elif not me.CHAPTERP:
162       raise ExpectedError("can't specify chapter with `%s'" % me.fn)
163     elif end_chapter == -1:
164       suffix = "#%d:%d" % (title, start_chapter)
165     else:
166       suffix = "#%d:%d-%d:%d" % (title, start_chapter, title, end_chapter - 1)
167
168     duration = None
169     if DB is None:
170       duration = me._duration(title, start_chapter, end_chapter)
171     else:
172       st = OS.stat(OS.path.join(ROOT, me.fn))
173       duration = None
174       c = DB.cursor()
175       c.execute("""
176               SELECT device, inode, size, mtime,  duration FROM duration
177               WHERE path = ? AND title = ? AND
178                     start_chapter = ? AND end_chapter = ?
179       """, [me.fn, title, start_chapter, end_chapter])
180       row = c.fetchone()
181       foundp = False
182       if row is None:
183         duration = me._duration(title, start_chapter, end_chapter)
184         c.execute("""
185                 INSERT OR REPLACE INTO duration
186                         (path, title, start_chapter, end_chapter,
187                          device, inode, size, mtime,  duration)
188                 VALUES (?, ?, ?, ?,  ?, ?, ?, ?,  ?)
189         """, [me.fn, title, start_chapter, end_chapter,
190               st.st_dev, st.st_ino, st.st_size, st.st_mtime,
191               duration])
192       else:
193         dev, ino, sz, mt,  d = row
194         if (dev, ino, sz, mt) == \
195            (st.st_dev, st.st_ino, st.st_size, st.st_mtime):
196           duration = d
197         else:
198           duration = me._duration(title, start_chapter, end_chapter)
199           c.execute("""
200                   UPDATE duration
201                   SET device = ?, inode = ?, size = ?, mtime = ?, duration = ?
202                   WHERE path = ? AND title = ? AND
203                         start_chapter = ? AND end_chapter = ?
204           """, [st.st_dev, st.st_dev, st.st_size, st.st_mtime,  duration,
205                 me.fn, title, start_chapter, end_chapter])
206       DB.commit()
207
208     if end_chapter != -1:
209       keys = [(title, ch) for ch in range(start_chapter, end_chapter)]
210       set = me.used_chapters
211     else:
212       keys, set = [title], me.used_titles
213     for k in keys:
214       if k in set:
215         if title == -1:
216           raise ExpectedError("`%s' already used" % me.fn)
217         elif end_chapter == -1:
218           raise ExpectedError("`%s' title %d already used" % (me.fn, title))
219         else:
220           raise ExpectedError("`%s' title %d chapter %d already used" %
221                               (me.fn, title, k[1]))
222     if end_chapter == -1:
223       me.used_titles.add(title)
224     else:
225       for ch in range(start_chapter, end_chapter):
226         me.used_chapters.add((title, ch))
227     return me.PREFIX + ROOT + urlencode(me.fn) + suffix, duration
228
229 class DVDFile (Source):
230   PREFIX = "dvd://"
231   TITLEP = CHAPTERP = True
232
233   def __init__(me, fn, *args, **kw):
234     super().__init__(fn, *args, **kw)
235     me.neps = 0
236
237   def _duration(me, title, start_chapter, end_chapter):
238     path = OS.path.join(ROOT, me.fn)
239     ntitle = int(program_output(["dvd-info", path, "titles"]))
240     if not 1 <= title <= ntitle:
241       raise ExpectedError("bad title %d for `%s': must be in 1 .. %d" %
242                             (title, me.fn, ntitle))
243     if start_chapter == -1:
244       durq = "duration:%d" % title
245     else:
246       nch = int(program_output(["dvd-info", path, "chapters:%d" % title]))
247       if end_chapter == -1: end_chapter = nch
248       else: end_chapter -= 1
249       if not 1 <= start_chapter <= end_chapter <= nch:
250         raise ExpectedError("bad chapter range %d .. %d for `%s' title %d: "
251                             "must be in 1 .. %d" %
252                             (start_chapter, end_chapter, me.fn, title, nch))
253       durq = "duration:%d.%d-%d" % (title, start_chapter, end_chapter)
254     duration = int(program_output(["dvd-info", path, durq]))
255     return duration
256
257 class DVDSeason (object):
258   def __init__(me, i, title):
259     me.i = i
260     me.title = title
261     me.episodes = {}
262   def set_episode_disc(me, i, disc):
263     if i in me.episodes:
264       raise ExpectedError("season %d episode %d already taken" % (me.i, i))
265     me.episodes[i] = disc; disc.neps += 1
266
267 def match_group(m, *groups, dflt = None, mustp = False):
268   for g in groups:
269     try: s = m.group(g)
270     except IndexError: continue
271     if s is not None: return s
272   if mustp: raise ValueError("no match found")
273   else: return dflt
274
275 class DVDDir (object):
276
277   _R_ISO_PRE = list(map(lambda pats:
278                           list(map(lambda pat:
279                                      RX.compile("^" + pat + r"\.iso$", RX.X),
280                                    pats)),
281     [[r""" S (?P<si> \d+) \. \ (?P<stitle> .*) — (?: D \d+ \. \ )?
282            (?P<epex> .*) """,
283       r""" S (?P<si> \d+) (?: D \d+)? \. \ (?P<epex> .*) """,
284       r""" S (?P<si> \d+) \. \ (?P<epex> E \d+ .*) """,
285       r""" S (?P<si> \d+) (?P<epex> E \d+) \. \ .* """],
286      [r""" (?P<si> \d+) [A-Z]? \. \ (?P<stitle> .*) — (?P<epex> .*) """],
287      [r""" \d+ \. \ (?P<epex> [ES] \d+ .*) """],
288      [r""" (?P<epnum> \d+ ) \. \ .* """]]))
289
290   _R_ISO_EP = RX.compile(r""" ^
291         (?: S (?P<si> \d+) \ )?
292         E (?P<ei> \d+) (?: – (?P<ej> \d+))? $
293   """, RX.X)
294
295   def __init__(me, dir):
296     me.dir = dir
297     fns = OS.listdir(OS.path.join(ROOT, dir))
298     fns.sort()
299     season = None
300     seasons = {}
301     styles = me._R_ISO_PRE
302     for fn in fns:
303       path = OS.path.join(dir, fn)
304       if not fn.endswith(".iso"): continue
305       #print(";; `%s'" % path, file = SYS.stderr)
306       for sty in styles:
307         for r in sty:
308           m = r.match(fn)
309           if m: styles = [sty]; break
310         else:
311           continue
312         break
313       else:
314         #print(";;\tignored (regex mismatch)", file = SYS.stderr)
315         continue
316
317       si = filter(match_group(m, "si"), int)
318       stitle = match_group(m, "stitle")
319
320       check(si is not None or stitle is None,
321             "explicit season title without number in `%s'" % fn)
322       if si is not None:
323         if season is None or si != season.i:
324           check(season is None or si == season.i + 1,
325                 "season %d /= %d" %
326                   (si, season is None and -1 or season.i + 1))
327           check(si not in seasons, "season %d already seen" % si)
328           seasons[si] = season = DVDSeason(si, stitle)
329         else:
330           check(stitle == season.title,
331                 "season title `%s' /= `%s'" % (stitle, season.title))
332
333       disc = DVDFile(path)
334       ts = season
335       any, bad = False, False
336       epnum = match_group(m, "epnum")
337       if epnum is not None: eplist = ["E" + epnum]
338       else: eplist = match_group(m, "epex", mustp = True).split(", ")
339       for eprange in eplist:
340         mm = me._R_ISO_EP.match(eprange)
341         if mm is None:
342           #print(";;\t`%s'?" % eprange, file = SYS.stderr)
343           bad = True; continue
344         if not any: any = True
345         i = filter(mm.group("si"), int)
346         if i is not None:
347           try: ts = seasons[i]
348           except KeyError: ts = seasons[i] = DVDSeason(i, None)
349         if ts is None:
350           ts = season = seasons[1] = DVDSeason(1, None)
351         start = filter(mm.group("ei"), int)
352         end = filter(mm.group("ej"), int, start)
353         for k in range(start, end + 1):
354           ts.set_episode_disc(k, disc)
355           #print(";;\tepisode %d.%d" % (ts.i, k), file = SYS.stderr)
356       if not any:
357         #print(";;\tignored", file = SYS.stderr)
358         pass
359       elif bad:
360         raise ExpectedError("bad ep list in `%s'", fn)
361     me.seasons = seasons
362
363 class SingleFileDir (object):
364
365   _CHECK_COMPLETE = True
366
367   def __init__(me, dir):
368     me.dir = dir
369     fns = OS.listdir(OS.path.join(ROOT, dir))
370     fns.sort()
371     episodes = {}
372     last_i = 0
373     rx = RX.compile(r"""
374           E (\d+)
375           (?: \. \ (.*))?
376           %s $
377     """ % RX.escape(me._EXT), RX.X)
378
379     for fn in fns:
380       path = OS.path.join(dir, fn)
381       if not fn.endswith(me._EXT): continue
382       m = rx.match(fn)
383       if not m: continue
384       i = filter(m.group(1), int)
385       etitle = m.group(2)
386       if me._CHECK_COMPLETE:
387         check(i == last_i + 1, "episode %d /= %d" % (i, last_i + 1))
388       episodes[i] = me._mkepisode(path, i)
389       last_i = i
390     me.episodes = episodes
391
392 class AudioFile (Source):
393   PREFIX = "file://"
394   TITLEP = CHAPTERP = False
395
396   def _duration(me, title, start_chapter, end_chaptwr):
397     out = program_output(["metaflac",
398                           "--show-total-samples", "--show-sample-rate",
399                           OS.path.join(ROOT, me.fn)])
400     nsamples, hz = map(float, out.split())
401     return int(nsamples/hz)
402
403 class AudioEpisode (AudioFile):
404   def __init__(me, fn, i, *args, **kw):
405     super().__init__(fn, *args, **kw)
406     me.i = i
407
408 class AudioDir (SingleFileDir):
409   _EXT = ".flac"
410
411   def _mkepisode(me, path, i):
412     return AudioEpisode(path, i)
413
414 class VideoFile (Source):
415   PREFIX = "file://"
416   TITLEP = CHAPTERP = False
417
418   def _duration(me, title, start_chapter, end_chaptwr):
419     out = program_output(["mediainfo", "--output=General;%Duration%",
420                           OS.path.join(ROOT, me.fn)])
421     return int(out)//1000
422
423 class VideoEpisode (VideoFile):
424   def __init__(me, fn, i, *args, **kw):
425     super().__init__(fn, *args, **kw)
426     me.i = i
427
428 class VideoDir (SingleFileDir):
429   _EXT = ".mp4"
430   _CHECK_COMPLETE = False
431
432   def _mkepisode(me, path, i):
433     return VideoEpisode(path, i)
434
435 class Chapter (object):
436   def __init__(me, episode, title, i):
437     me.title, me.i = title, i
438     me.url, me.duration = \
439       episode.source.url_and_duration(episode.tno, i, i + 1)
440
441 class Episode (object):
442   def __init__(me, season, i, neps, title, src, series_title_p = True,
443                tno = -1, startch = -1, endch = -1):
444     me.season = season
445     me.i, me.neps, me.title = i, neps, title
446     me.chapters = []
447     me.source, me.tno = src, tno
448     me.series_title_p = series_title_p
449     me.tno, me.start_chapter, me.end_chapter = tno, startch, endch
450     me.url, me.duration = src.url_and_duration(tno, startch, endch)
451   def add_chapter(me, title, j):
452     ch = Chapter(me, title, j)
453     me.chapters.append(ch)
454     return ch
455   def label(me):
456     return me.season._eplabel(me.i, me.neps, me.title)
457
458 class BaseSeason (object):
459   def __init__(me, series, implicitp = False):
460     me.series = series
461     me.episodes = []
462     me.implicitp = implicitp
463     me.ep_i, episodes = 1, []
464   def add_episode(me, j, neps, title, src, series_title_p,
465                   tno, startch, endch):
466     ep = Episode(me, j, neps, title, src, series_title_p,
467                  tno, startch, endch)
468     me.episodes.append(ep)
469     src.nuses += neps; me.ep_i += neps
470     return ep
471   def _epnames(me, i, neps):
472     playlist = me.series.playlist
473     if neps == 1: return playlist.epname, ["%d" % i]
474     elif neps == 2: return playlist.epnames, ["%d" % i, "%d" % (i + 1)]
475     else: return playlist.epnames, ["%d–%d" % (i, i + neps - 1)]
476
477 class Season (BaseSeason):
478   def __init__(me, series, title, i, *args, **kw):
479     super().__init__(series, *args, **kw)
480     me.title, me.i = title, i
481   def _eplabel(me, i, neps, title):
482     epname, epn = me._epnames(i, neps)
483     if title is None:
484       if me.implicitp:
485         label = "%s %s" % (epname, ", ".join(epn))
486       elif me.title is None:
487         label = "%s %s" % \
488           (epname, ", ".join("%d.%s" % (me.i, e) for e in epn))
489       else:
490         label = "%s: %s %s" % (me.title, epname, ", ".join(epn))
491     else:
492       if me.implicitp:
493         label = "%s. %s" % (", ".join(epn), title)
494       elif me.title is None:
495         label = "%s. %s" % \
496           (", ".join("%d.%s" % (me.i, e) for e in epn), title)
497       else:
498         label = "%s: %s. %s" % (me.title, ", ".join(epn), title)
499     return label
500
501 class MovieSeason (BaseSeason):
502   def __init__(me, series, title, *args, **kw):
503     super().__init__(series, *args, **kw)
504     me.title = title
505     me.i = None
506   def add_episode(me, j, neps, title, src, series_title_p,
507                   tno, startch, endch):
508     if me.title is None and title is None:
509       raise ExpectedError("movie or movie season must have a title")
510     return super().add_episode(j, neps, title, src, series_title_p,
511                                tno, startch, endch)
512   def _eplabel(me, i, neps, title):
513     if me.title is None:
514       label = title
515     elif title is None:
516       epname, epn = me._epnames(i, neps)
517       label = "%s: %s %s" % (me.title, epname, ", ".join(epn))
518     else:
519       label = "%s: %s" % (me.title, title)
520     return label
521
522 class Series (object):
523   def __init__(me, playlist, name, title = None,
524                full_title = None, wantedp = True):
525     me.playlist = playlist
526     me.name, me.title, me.full_title = name, title, full_title
527     me.cur_season = None
528     me.wantedp = wantedp
529   def _add_season(me, season):
530     me.cur_season = season
531   def add_season(me, title, i, implicitp = False):
532     me._add_season(Season(me, title, i, implicitp))
533   def add_movies(me, title = None):
534     me._add_season(MovieSeason(me, title))
535   def ensure_season(me):
536     if me.cur_season is None: me.add_season(None, 1, implicitp = True)
537     return me.cur_season
538   def end_season(me):
539     me.cur_season = None
540
541 class Playlist (object):
542
543   def __init__(me):
544     me.seasons = []
545     me.episodes = []
546     me.epname, me.epnames = "Episode", "Episodes"
547     me.nseries = 0
548     me.single_series_p = False
549     me.series_title = None
550
551   def add_episode(me, episode):
552     me.episodes.append(episode)
553
554   def done_season(me):
555     if me.episodes:
556       me.seasons.append(me.episodes)
557       me.episodes = []
558
559   def write(me, f):
560     f.write("#EXTM3U\n")
561     for season in me.seasons:
562       f.write("\n")
563       for ep in season:
564         label = ep.label()
565         if me.nseries > 1 and ep.series_title_p and \
566            ep.season.series.title is not None:
567           if ep.season.i is None: sep = ": "
568           else: sep = " "
569           label = ep.season.series.title + sep + label
570         if not ep.chapters:
571           f.write("#EXTINF:%d,,%s\n%s\n" % (ep.duration, label, ep.url))
572         else:
573           for ch in ep.chapters:
574             f.write("#EXTINF:%d,,%s: %s\n%s\n" %
575                       (ch.duration, label, ch.title, ch.url))
576
577   def dump(me, f):
578     if opts.list_name is not None: f.write("LIST %s\n" % opts.list_name)
579     if me.series_title is not None and \
580        me.nseries > 1 and not me.single_series_p:
581       raise ExpectedError("can't force series name for multi-series list")
582     series = set()
583     if me.single_series_p:
584       f.write("SERIES - %s\n" % quote(me.series_title))
585     for season in me.seasons:
586       for ep in season:
587         label = ep.label()
588         title = ep.season.series.full_title
589         if me.single_series_p:
590           stag = "-"
591           if title is not None: label = title + " " + label
592         else:
593           if title is None: title = me.series_title
594           stag = ep.season.series.name
595           if stag is None: stag = "-"
596           if stag not in series:
597             f.write("SERIES %s %s\n" % (stag, quote(title)))
598             series.add(stag)
599         f.write("ENTRY %s %s %s %d %d %d %g\n" %
600                   (stag, quote(label), quote(ep.source.fn),
601                    ep.tno, ep.start_chapter, ep.end_chapter, ep.duration))
602
603   def write_deps(me, f, out):
604     deps = set()
605     for season in me.seasons:
606       for ep in season: deps.add(ep.source.fn)
607     f.write("### -*-makefile-*-\n")
608     f.write("%s: $(call check-deps, %s," % (out, out))
609     for dep in sorted(deps):
610       f.write(" \\\n\t'%s'" %
611                 OS.path.join(ROOT, dep)
612                   .replace(",", "$(comma)")
613                   .replace("'", "'\\''"))
614     f.write(")\n")
615
616 DEFAULT_EXPVAR = 0.05
617 R_DURMULT = RX.compile(r""" ^
618         (\d+ (?: \. \d+)?) x
619 $ """, RX.X)
620 R_DUR = RX.compile(r""" ^
621         (?: (?: (\d+) :)? (\d+) :)? (\d+)
622         (?: / (\d+ (?: \. \d+)?) \%)?
623 $ """, RX.X)
624 def parse_duration(s, base = None, basevar = DEFAULT_EXPVAR):
625   if base is not None:
626     m = R_DURMULT.match(s)
627     if m is not None: return base*float(m.group(1)), basevar
628   m = R_DUR.match(s)
629   if not m: raise ExpectedError("invalid duration spec `%s'" % s)
630   hr, min, sec = map(lambda g: filter(m.group(g), int, 0), [1, 2, 3])
631   var = filter(m.group(4), lambda x: float(x)/100.0)
632   if var is None: var = DEFAULT_EXPVAR
633   return 3600*hr + 60*min + sec, var
634 def format_duration(d):
635   if d >= 3600: return "%d:%02d:%02d" % (d//3600, (d//60)%60, d%60)
636   elif d >= 60: return "%d:%02d" % (d//60, d%60)
637   else: return "%d s" % d
638
639 MODE_UNSET = 0
640 MODE_SINGLE = 1
641 MODE_MULTI = 2
642
643 class EpisodeListParser (object):
644
645   def __init__(me, series_wanted = None, chapters_wanted_p = False):
646     me._pl = Playlist()
647     me._cur_episode = me._cur_chapter = None
648     me._series = {}; me._vdirs = {}; me._sfdirs = {}; me._isos = {}
649     me._series_wanted = series_wanted
650     me._chaptersp = chapters_wanted_p
651     me._explen, me._expvar = None, DEFAULT_EXPVAR
652     if series_wanted is None: me._mode = MODE_UNSET
653     else: me._mode = MODE_MULTI
654
655   def _bad_keyval(me, cmd, k, v):
656     raise ExpectedError("invalid `!%s' option `%s'" %
657                           (cmd, v if k is None else k))
658
659   def _keyvals(me, opts):
660     if opts is not None:
661       for kv in opts.split(","):
662         try: sep = kv.index("=")
663         except ValueError: yield None, kv
664         else: yield kv[:sep], kv[sep + 1:]
665
666   def _set_mode(me, mode):
667     if me._mode == MODE_UNSET:
668       me._mode = mode
669     elif me._mode != mode:
670       raise ExpectedError("inconsistent single-/multi-series usage")
671
672   def _get_series(me, name):
673     if name is None:
674       me._set_mode(MODE_SINGLE)
675       try: series = me._series[None]
676       except KeyError:
677         series = me._series[None] = Series(me._pl, None)
678         me._pl.nseries += 1
679     else:
680       me._set_mode(MODE_MULTI)
681       series = lookup(me._series, name, "unknown series `%s'" % name)
682     return series
683
684   def _opts_series(me, cmd, opts):
685     name = None
686     for k, v in me._keyvals(opts):
687       if k is None: name = v
688       else: me._bad_keyval(cmd, k, v)
689     return me._get_series(name)
690
691   def _auto_epsrc(me, series):
692     dir = lookup(me._vdirs, series.name, "no active video directory")
693     season = series.ensure_season()
694     check(season.i is not None, "must use explicit iso for movie seasons")
695     vseason = lookup(dir.seasons, season.i,
696                      "season %d not found in video dir `%s'" %
697                        (season.i, dir.dir))
698     src = lookup(vseason.episodes, season.ep_i,
699                  "episode %d.%d not found in video dir `%s'" %
700                    (season.i, season.ep_i, dir.dir))
701     return src
702
703   def _process_cmd(me, ww):
704
705     cmd = ww.nextword(); check(cmd is not None, "missing command")
706     try: sep = cmd.index(":")
707     except ValueError: opts = None
708     else: cmd, opts = cmd[:sep], cmd[sep + 1:]
709
710     if cmd == "title":
711       for k, v in me._keyvals(opts): me._bad_keyval("title", k, v)
712       title = ww.rest(); check(title is not None, "missing title")
713       check(me._pl.series_title is None, "already set a title")
714       me._pl.series_title = title
715
716     elif cmd == "single":
717       for k, v in me._keyvals(opts): me._bad_keyval("single", k, v)
718       check(ww.rest() is None, "trailing junk")
719       check(not me._pl.single_series_p, "single-series already set")
720       me._pl.single_series_p = True
721
722     elif cmd == "series":
723       name = None
724       for k, v in me._keyvals(opts):
725         if k is None: name = v
726         else: me._bad_keyval(cmd, k, v)
727       check(name is not None, "missing series name")
728       check(name not in me._series, "series `%s' already defined" % name)
729       title = ww.rest()
730       if title is None:
731         full = None
732       else:
733         try: sep = title.index("::")
734         except ValueError: full = title
735         else:
736           full = title[sep + 2:].strip()
737           if sep == 0: title = None
738           else: title = title[:sep].strip()
739       me._set_mode(MODE_MULTI)
740       me._series[name] = series = Series(me._pl, name, title, full,
741                                          me._series_wanted is None or
742                                            name in me._series_wanted)
743       if series.wantedp: me._pl.nseries += 1
744
745     elif cmd == "season":
746       series = me._opts_series(cmd, opts)
747       w = ww.nextword();
748       check(w is not None, "missing season number")
749       if w == "-":
750         if not series.wantedp: return
751         series.add_movies(ww.rest())
752       else:
753         title = ww.rest(); i = getint(w)
754         if not series.wantedp: return
755         series.add_season(ww.rest(), getint(w), implicitp = False)
756       me._cur_episode = me._cur_chapter = None
757       me._pl.done_season()
758
759     elif cmd == "explen":
760       w = ww.rest(); check(w is not None, "missing duration spec")
761       if w == "-":
762         me._explen, me._expvar = None, DEFAULT_EXPVAR
763       else:
764         d, v = parse_duration(w)
765         me._explen = d
766         if v is not None: me._expvar = v
767
768     elif cmd == "epname":
769       for k, v in me._keyvals(opts): me._bad_keyval("epname", k, v)
770       name = ww.rest(); check(name is not None, "missing episode name")
771       try: sep = name.index("::")
772       except ValueError: names = name + "s"
773       else: name, names = name[:sep], name[sep + 2:]
774       me._pl.epname, me._pl.epnames = name, names
775
776     elif cmd == "epno":
777       series = me._opts_series(cmd, opts)
778       w = ww.rest(); check(w is not None, "missing episode number")
779       epi = getint(w)
780       if not series.wantedp: return
781       series.ensure_season().ep_i = epi
782
783     elif cmd == "dvd":
784       series = me._opts_series(cmd, opts)
785       fn = ww.rest(); check(fn is not None, "missing filename")
786       if not series.wantedp: return
787       if fn == "-": forget(me._isos, series.name)
788       else:
789         check(OS.path.exists(OS.path.join(ROOT, fn)),
790               "dvd iso file `%s' not found" % fn)
791         me._isos[series.name] = DVDFile(fn)
792
793     elif cmd == "dvddir":
794       series = me._opts_series(cmd, opts)
795       dir = ww.rest(); check(dir is not None, "missing directory")
796       if not series.wantedp: return
797       if dir == "-": forget(me._vdirs, series.name)
798       else: me._vdirs[series.name] = DVDDir(dir)
799
800     elif cmd == "vdir":
801       series = me._opts_series(cmd, opts)
802       dir = ww.rest(); check(dir is not None, "missing directory")
803       if not series.wantedp: return
804       if dir == "-": forget(me._sfdirs, series.name)
805       else: me._sfdirs[series.name] = VideoDir(dir)
806
807     elif cmd == "adir":
808       series = me._opts_series(cmd, opts)
809       dir = ww.rest(); check(dir is not None, "missing directory")
810       if not series.wantedp: return
811       if dir == "-": forget(me._sfdirs, series.name)
812       else: me._sfdirs[series.name] = AudioDir(dir)
813
814     elif cmd == "displaced":
815       series = me._opts_series(cmd, opts)
816       w = ww.rest(); check(w is not None, "missing count"); n = getint(w)
817       src = me._auto_epsrc(series)
818       src.nuses += n
819
820     else:
821       raise ExpectedError("unknown command `%s'" % cmd)
822
823   def _process_episode(me, ww):
824
825     opts = ww.nextword(); check(opts is not None, "missing title/options")
826     ti = -1; sname = None; neps = 1; epi = None; loch = hich = -1
827     explen, expvar, explicitlen = me._explen, me._expvar, False
828     series_title_p = True
829     for k, v in me._keyvals(opts):
830       if k is None:
831         if v.isdigit(): ti = int(v)
832         elif v == "-": ti = -1
833         else: sname = v
834       elif k == "s": sname = v
835       elif k == "n": neps = getint(v)
836       elif k == "ep": epi = getint(v)
837       elif k == "st": series_title_p = getbool(v)
838       elif k == "l":
839         if v == "-": me._explen, me._expvar = None, DEFAULT_EXPVAR
840         else:
841           explen, expvar = parse_duration(v, explen, expvar)
842           explicitlen = True
843       elif k == "ch":
844         try: sep = v.index("-")
845         except ValueError: loch, hich = getint(v), -1
846         else: loch, hich = getint(v[:sep]), getint(v[sep + 1:]) + 1
847       else: raise ExpectedError("unknown episode option `%s'" % k)
848     check(ti is not None, "missing title number")
849     series = me._get_series(sname)
850     me._cur_chapter = None
851
852     title = ww.rest()
853     if not series.wantedp: return
854     season = series.ensure_season()
855     if epi is None: epi = season.ep_i
856
857     if ti == -1:
858       check(season.implicitp or season.i is None,
859             "audio source, but explicit non-movie season")
860       dir = lookup(me._sfdirs, series.name,
861                    "no title, and no single-file directory")
862       src = lookup(dir.episodes, season.ep_i,
863                    "episode %d not found in single-file dir `%s'" %
864                      (epi, dir.dir))
865
866     else:
867       try: src = me._isos[series.name]
868       except KeyError: src = me._auto_epsrc(series)
869
870     episode = season.add_episode(epi, neps, title, src,
871                                  series_title_p, ti, loch, hich)
872
873     if episode.duration != -1 and explen is not None:
874       if not explicitlen: explen *= neps
875       if not explen*(1 - expvar) <= episode.duration <= explen*(1 + expvar):
876         if season.i is None: epid = "episode %d" % epi
877         else: epid = "episode %d.%d" % (season.i, epi)
878         raise ExpectedError \
879           ("%s duration %s %g%% > %g%% from expected %s" %
880              (epid, format_duration(episode.duration),
881               abs(100*(episode.duration - explen)/explen), 100*expvar,
882               format_duration(explen)))
883     me._pl.add_episode(episode)
884     me._cur_episode = episode
885
886   def _process_chapter(me, ww):
887     check(me._cur_episode is not None, "no current episode")
888     check(me._cur_episode.source.CHAPTERP,
889           "episode source doesn't allow chapters")
890     if me._chaptersp:
891       if me._cur_chapter is None: i = 1
892       else: i = me._cur_chapter.i + 1
893       me._cur_chapter = me._cur_episode.add_chapter(ww.rest(), i)
894
895   def parse_file(me, fn):
896     with location(FileLocation(fn, 0)) as floc:
897       with open(fn, "r") as f:
898         for line in f:
899           floc.stepline()
900           sline = line.lstrip()
901           if sline == "" or sline.startswith(";"): continue
902
903           if line.startswith("!"): me._process_cmd(Words(line[1:]))
904           elif not line[0].isspace(): me._process_episode(Words(line))
905           else: me._process_chapter(Words(line))
906     me._pl.done_season()
907
908   def done(me):
909     discs = set()
910     for name, vdir in me._vdirs.items():
911       if not me._series[name].wantedp: continue
912       for s in vdir.seasons.values():
913         for d in s.episodes.values():
914           discs.add(d)
915     for sfdir in me._sfdirs.values():
916       for d in sfdir.episodes.values():
917         discs.add(d)
918     for d in sorted(discs, key = lambda d: d.fn):
919       if d.neps is not None and d.neps != d.nuses:
920         raise ExpectedError("disc `%s' has %d episodes, used %d times" %
921                             (d.fn, d.neps, d.nuses))
922     return me._pl
923
924 op = OP.OptionParser \
925   (usage = "%prog [-Dc] [-L NAME] [-M DEPS] [-d CACHE] [-o OUT] [-s SERIES] EPLS\n"
926             "%prog -i -d CACHE",
927    description = "Generate M3U playlists from an episode list.")
928 op.add_option("-D", "--dump",
929               dest = "dump", action = "store_true", default = False,
930               help = "Dump playlist in machine-readable form")
931 op.add_option("-L", "--list-name", metavar = "NAME",
932               dest = "list_name", type = "str", default = None,
933               help = "Set the playlist name")
934 op.add_option("-M", "--make-deps", metavar = "DEPS",
935               dest = "deps", type = "str", default = None,
936               help = "Write a `make' fragment for dependencies")
937 op.add_option("-c", "--chapters",
938               dest = "chaptersp", action = "store_true", default = False,
939               help = "Output individual chapter names")
940 op.add_option("-i", "--init-db",
941               dest = "initdbp", action = "store_true", default = False,
942               help = "Initialize the database")
943 op.add_option("-d", "--database", metavar = "CACHE",
944               dest = "database", type = "str", default = None,
945               help = "Set filename for cache database")
946 op.add_option("-o", "--output", metavar = "OUT",
947               dest = "output", type = "str", default = None,
948               help = "Write output playlist to OUT")
949 op.add_option("-O", "--fake-output", metavar = "OUT",
950               dest = "fakeout", type = "str", default = None,
951               help = "Pretend output goes to OUT for purposes of `-M'")
952 op.add_option("-s", "--series", metavar = "SERIES",
953               dest = "series", type = "str", default = None,
954               help = "Output only the listed SERIES (comma-separated)")
955 try:
956   opts, argv = op.parse_args()
957
958   if opts.initdbp:
959     if opts.chaptersp or opts.series is not None or \
960        opts.output is not None or opts.deps is not None or \
961        opts.fakeout is not None or \
962        opts.database is None or len(argv):
963       op.print_usage(file = SYS.stderr); SYS.exit(2)
964     setup_db(opts.database)
965
966   else:
967     if len(argv) != 1: op.print_usage(file = SYS.stderr); SYS.exit(2)
968     if opts.database is not None: init_db(opts.database)
969     if opts.series is None:
970       series_wanted = None
971     else:
972       series_wanted = set()
973       for name in opts.series.split(","): series_wanted.add(name)
974     if opts.deps is not None:
975       if (opts.output is None or opts.output == "-") and opts.fakeout is None:
976         raise ExpectedError("can't write dep fragment without output file")
977       if opts.fakeout is None: opts.fakeout = opts.output
978     else:
979       if opts.fakeout is not None:
980         raise ExpectedError("fake output set but no dep fragment")
981
982     ep = EpisodeListParser(series_wanted, opts.chaptersp)
983     ep.parse_file(argv[0])
984     pl = ep.done()
985
986     if opts.list_name is None:
987       opts.list_name, _ = OS.path.splitext(OS.path.basename(argv[0]))
988
989     if opts.dump: outfn = pl.dump
990     else: outfn = pl.write
991     if opts.output is None or opts.output == "-":
992       outfn(SYS.stdout)
993     else:
994       with open(opts.output, "w") as f: outfn(f)
995
996     if opts.deps:
997       if opts.deps == "-":
998         pl.write_deps(SYS.stdout, opts.fakeout)
999       else:
1000         with open(opts.deps, "w") as f: pl.write_deps(f, opts.fakeout)
1001
1002 except (ExpectedError, IOError, OSError) as e:
1003   LOC.report(e)
1004   SYS.exit(2)