chiark / gitweb /
mkm3u: Allow explicit setting of series-name separator.
[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     me.series_sep = ""
551
552   def add_episode(me, episode):
553     me.episodes.append(episode)
554
555   def done_season(me):
556     if me.episodes:
557       me.seasons.append(me.episodes)
558       me.episodes = []
559
560   def write(me, f):
561     f.write("#EXTM3U\n")
562     for season in me.seasons:
563       f.write("\n")
564       for ep in season:
565         label = ep.label()
566         if me.nseries > 1 and ep.series_title_p and \
567            ep.season.series.title is not None:
568           if ep.season.i is None: sep = ":"
569           else: sep = me.series_sep
570           label = ep.season.series.title + sep + " " + label
571         if not ep.chapters:
572           f.write("#EXTINF:%d,,%s\n%s\n" % (ep.duration, label, ep.url))
573         else:
574           for ch in ep.chapters:
575             f.write("#EXTINF:%d,,%s: %s\n%s\n" %
576                       (ch.duration, label, ch.title, ch.url))
577
578   def dump(me, f):
579     if opts.list_name is not None: f.write("LIST %s\n" % opts.list_name)
580     if me.series_title is not None and \
581        me.nseries > 1 and not me.single_series_p:
582       raise ExpectedError("can't force series name for multi-series list")
583     series = set()
584     if me.single_series_p:
585       f.write("SERIES - %s\n" % quote(me.series_title))
586     for season in me.seasons:
587       for ep in season:
588         label = ep.label()
589         title = ep.season.series.full_title
590         if me.single_series_p:
591           stag = "-"
592           if title is not None: label = title + me.series_sep + " " + label
593         else:
594           if title is None: title = me.series_title
595           stag = ep.season.series.name
596           if stag is None: stag = "-"
597           if stag not in series:
598             f.write("SERIES %s %s\n" % (stag, quote(title)))
599             series.add(stag)
600         f.write("ENTRY %s %s %s %d %d %d %g\n" %
601                   (stag, quote(label), quote(ep.source.fn),
602                    ep.tno, ep.start_chapter, ep.end_chapter, ep.duration))
603
604   def write_deps(me, f, out):
605     deps = set()
606     for season in me.seasons:
607       for ep in season: deps.add(ep.source.fn)
608     f.write("### -*-makefile-*-\n")
609     f.write("%s: $(call check-deps, %s," % (out, out))
610     for dep in sorted(deps):
611       f.write(" \\\n\t'%s'" %
612                 OS.path.join(ROOT, dep)
613                   .replace(",", "$(comma)")
614                   .replace("'", "'\\''"))
615     f.write(")\n")
616
617 DEFAULT_EXPVAR = 0.05
618 R_DURMULT = RX.compile(r""" ^
619         (\d+ (?: \. \d+)?) x
620 $ """, RX.X)
621 R_DUR = RX.compile(r""" ^
622         (?: (?: (\d+) :)? (\d+) :)? (\d+)
623         (?: / (\d+ (?: \. \d+)?) \%)?
624 $ """, RX.X)
625 def parse_duration(s, base = None, basevar = DEFAULT_EXPVAR):
626   if base is not None:
627     m = R_DURMULT.match(s)
628     if m is not None: return base*float(m.group(1)), basevar
629   m = R_DUR.match(s)
630   if not m: raise ExpectedError("invalid duration spec `%s'" % s)
631   hr, min, sec = map(lambda g: filter(m.group(g), int, 0), [1, 2, 3])
632   var = filter(m.group(4), lambda x: float(x)/100.0)
633   if var is None: var = DEFAULT_EXPVAR
634   return 3600*hr + 60*min + sec, var
635 def format_duration(d):
636   if d >= 3600: return "%d:%02d:%02d" % (d//3600, (d//60)%60, d%60)
637   elif d >= 60: return "%d:%02d" % (d//60, d%60)
638   else: return "%d s" % d
639
640 MODE_UNSET = 0
641 MODE_SINGLE = 1
642 MODE_MULTI = 2
643
644 class EpisodeListParser (object):
645
646   def __init__(me, series_wanted = None, chapters_wanted_p = False):
647     me._pl = Playlist()
648     me._cur_episode = me._cur_chapter = None
649     me._series = {}; me._vdirs = {}; me._sfdirs = {}; me._isos = {}
650     me._series_wanted = series_wanted
651     me._chaptersp = chapters_wanted_p
652     me._explen, me._expvar = None, DEFAULT_EXPVAR
653     if series_wanted is None: me._mode = MODE_UNSET
654     else: me._mode = MODE_MULTI
655
656   def _bad_keyval(me, cmd, k, v):
657     raise ExpectedError("invalid `!%s' option `%s'" %
658                           (cmd, v if k is None else k))
659
660   def _keyvals(me, opts):
661     if opts is not None:
662       for kv in opts.split(","):
663         try: sep = kv.index("=")
664         except ValueError: yield None, kv
665         else: yield kv[:sep], kv[sep + 1:]
666
667   def _set_mode(me, mode):
668     if me._mode == MODE_UNSET:
669       me._mode = mode
670     elif me._mode != mode:
671       raise ExpectedError("inconsistent single-/multi-series usage")
672
673   def _get_series(me, name):
674     if name is None:
675       me._set_mode(MODE_SINGLE)
676       try: series = me._series[None]
677       except KeyError:
678         series = me._series[None] = Series(me._pl, None)
679         me._pl.nseries += 1
680     else:
681       me._set_mode(MODE_MULTI)
682       series = lookup(me._series, name, "unknown series `%s'" % name)
683     return series
684
685   def _opts_series(me, cmd, opts):
686     name = None
687     for k, v in me._keyvals(opts):
688       if k is None: name = v
689       else: me._bad_keyval(cmd, k, v)
690     return me._get_series(name)
691
692   def _auto_epsrc(me, series):
693     dir = lookup(me._vdirs, series.name, "no active video directory")
694     season = series.ensure_season()
695     check(season.i is not None, "must use explicit iso for movie seasons")
696     vseason = lookup(dir.seasons, season.i,
697                      "season %d not found in video dir `%s'" %
698                        (season.i, dir.dir))
699     src = lookup(vseason.episodes, season.ep_i,
700                  "episode %d.%d not found in video dir `%s'" %
701                    (season.i, season.ep_i, dir.dir))
702     return src
703
704   def _process_cmd(me, ww):
705
706     cmd = ww.nextword(); check(cmd is not None, "missing command")
707     try: sep = cmd.index(":")
708     except ValueError: opts = None
709     else: cmd, opts = cmd[:sep], cmd[sep + 1:]
710
711     if cmd == "title":
712       for k, v in me._keyvals(opts): me._bad_keyval("title", k, v)
713       title = ww.rest(); check(title is not None, "missing title")
714       check(me._pl.series_title is None, "already set a title")
715       me._pl.series_title = title
716
717     elif cmd == "single":
718       for k, v in me._keyvals(opts): me._bad_keyval("single", k, v)
719       check(ww.rest() is None, "trailing junk")
720       check(not me._pl.single_series_p, "single-series already set")
721       me._pl.single_series_p = True
722
723     elif cmd == "series":
724       name = None
725       for k, v in me._keyvals(opts):
726         if k is None: name = v
727         else: me._bad_keyval(cmd, k, v)
728       check(name is not None, "missing series name")
729       check(name not in me._series, "series `%s' already defined" % name)
730       title = ww.rest()
731       if title is None:
732         full = None
733       else:
734         try: sep = title.index("::")
735         except ValueError: full = title
736         else:
737           full = title[sep + 2:].strip()
738           if sep == 0: title = None
739           else: title = title[:sep].strip()
740       me._set_mode(MODE_MULTI)
741       me._series[name] = series = Series(me._pl, name, title, full,
742                                          me._series_wanted is None or
743                                            name in me._series_wanted)
744       if series.wantedp: me._pl.nseries += 1
745
746     elif cmd == "season":
747       series = me._opts_series(cmd, opts)
748       w = ww.nextword();
749       check(w is not None, "missing season number")
750       if w == "-":
751         if not series.wantedp: return
752         series.add_movies(ww.rest())
753       else:
754         title = ww.rest(); i = getint(w)
755         if not series.wantedp: return
756         series.add_season(ww.rest(), getint(w), implicitp = False)
757       me._cur_episode = me._cur_chapter = None
758       me._pl.done_season()
759
760     elif cmd == "explen":
761       w = ww.rest(); check(w is not None, "missing duration spec")
762       if w == "-":
763         me._explen, me._expvar = None, DEFAULT_EXPVAR
764       else:
765         d, v = parse_duration(w)
766         me._explen = d
767         if v is not None: me._expvar = v
768
769     elif cmd == "epname":
770       for k, v in me._keyvals(opts): me._bad_keyval("epname", k, v)
771       name = ww.rest(); check(name is not None, "missing episode name")
772       try: sep = name.index("::")
773       except ValueError: names = name + "s"
774       else: name, names = name[:sep], name[sep + 2:]
775       me._pl.epname, me._pl.epnames = name, names
776
777     elif cmd == "epno":
778       series = me._opts_series(cmd, opts)
779       w = ww.rest(); check(w is not None, "missing episode number")
780       epi = getint(w)
781       if not series.wantedp: return
782       series.ensure_season().ep_i = epi
783
784     elif cmd == "dvd":
785       series = me._opts_series(cmd, opts)
786       fn = ww.rest(); check(fn is not None, "missing filename")
787       if not series.wantedp: return
788       if fn == "-": forget(me._isos, series.name)
789       else:
790         check(OS.path.exists(OS.path.join(ROOT, fn)),
791               "dvd iso file `%s' not found" % fn)
792         me._isos[series.name] = DVDFile(fn)
793
794     elif cmd == "dvddir":
795       series = me._opts_series(cmd, opts)
796       dir = ww.rest(); check(dir is not None, "missing directory")
797       if not series.wantedp: return
798       if dir == "-": forget(me._vdirs, series.name)
799       else: me._vdirs[series.name] = DVDDir(dir)
800
801     elif cmd == "vdir":
802       series = me._opts_series(cmd, opts)
803       dir = ww.rest(); check(dir is not None, "missing directory")
804       if not series.wantedp: return
805       if dir == "-": forget(me._sfdirs, series.name)
806       else: me._sfdirs[series.name] = VideoDir(dir)
807
808     elif cmd == "adir":
809       series = me._opts_series(cmd, opts)
810       dir = ww.rest(); check(dir is not None, "missing directory")
811       if not series.wantedp: return
812       if dir == "-": forget(me._sfdirs, series.name)
813       else: me._sfdirs[series.name] = AudioDir(dir)
814
815     elif cmd == "displaced":
816       series = me._opts_series(cmd, opts)
817       w = ww.rest(); check(w is not None, "missing count"); n = getint(w)
818       src = me._auto_epsrc(series)
819       src.nuses += n
820
821     elif cmd == "sep":
822       sep = ww.rest(); check(sep is not None, "missing separator")
823       me._pl.series_sep = sep
824
825     else:
826       raise ExpectedError("unknown command `%s'" % cmd)
827
828   def _process_episode(me, ww):
829
830     opts = ww.nextword(); check(opts is not None, "missing title/options")
831     ti = -1; sname = None; neps = 1; epi = None; loch = hich = -1
832     explen, expvar, explicitlen = me._explen, me._expvar, False
833     series_title_p = True
834     for k, v in me._keyvals(opts):
835       if k is None:
836         if v.isdigit(): ti = int(v)
837         elif v == "-": ti = -1
838         else: sname = v
839       elif k == "s": sname = v
840       elif k == "n": neps = getint(v)
841       elif k == "ep": epi = getint(v)
842       elif k == "st": series_title_p = getbool(v)
843       elif k == "l":
844         if v == "-": me._explen, me._expvar = None, DEFAULT_EXPVAR
845         else:
846           explen, expvar = parse_duration(v, explen, expvar)
847           explicitlen = True
848       elif k == "ch":
849         try: sep = v.index("-")
850         except ValueError: loch, hich = getint(v), -1
851         else: loch, hich = getint(v[:sep]), getint(v[sep + 1:]) + 1
852       else: raise ExpectedError("unknown episode option `%s'" % k)
853     check(ti is not None, "missing title number")
854     series = me._get_series(sname)
855     me._cur_chapter = None
856
857     title = ww.rest()
858     if not series.wantedp: return
859     season = series.ensure_season()
860     if epi is None: epi = season.ep_i
861
862     if ti == -1:
863       check(season.implicitp or season.i is None,
864             "audio source, but explicit non-movie season")
865       dir = lookup(me._sfdirs, series.name,
866                    "no title, and no single-file directory")
867       src = lookup(dir.episodes, season.ep_i,
868                    "episode %d not found in single-file dir `%s'" %
869                      (epi, dir.dir))
870
871     else:
872       try: src = me._isos[series.name]
873       except KeyError: src = me._auto_epsrc(series)
874
875     episode = season.add_episode(epi, neps, title, src,
876                                  series_title_p, ti, loch, hich)
877
878     if episode.duration != -1 and explen is not None:
879       if not explicitlen: explen *= neps
880       if not explen*(1 - expvar) <= episode.duration <= explen*(1 + expvar):
881         if season.i is None: epid = "episode %d" % epi
882         else: epid = "episode %d.%d" % (season.i, epi)
883         raise ExpectedError \
884           ("%s duration %s %g%% > %g%% from expected %s" %
885              (epid, format_duration(episode.duration),
886               abs(100*(episode.duration - explen)/explen), 100*expvar,
887               format_duration(explen)))
888     me._pl.add_episode(episode)
889     me._cur_episode = episode
890
891   def _process_chapter(me, ww):
892     check(me._cur_episode is not None, "no current episode")
893     check(me._cur_episode.source.CHAPTERP,
894           "episode source doesn't allow chapters")
895     if me._chaptersp:
896       if me._cur_chapter is None: i = 1
897       else: i = me._cur_chapter.i + 1
898       me._cur_chapter = me._cur_episode.add_chapter(ww.rest(), i)
899
900   def parse_file(me, fn):
901     with location(FileLocation(fn, 0)) as floc:
902       with open(fn, "r") as f:
903         for line in f:
904           floc.stepline()
905           sline = line.lstrip()
906           if sline == "" or sline.startswith(";"): continue
907
908           if line.startswith("!"): me._process_cmd(Words(line[1:]))
909           elif not line[0].isspace(): me._process_episode(Words(line))
910           else: me._process_chapter(Words(line))
911     me._pl.done_season()
912
913   def done(me):
914     discs = set()
915     for name, vdir in me._vdirs.items():
916       if not me._series[name].wantedp: continue
917       for s in vdir.seasons.values():
918         for d in s.episodes.values():
919           discs.add(d)
920     for sfdir in me._sfdirs.values():
921       for d in sfdir.episodes.values():
922         discs.add(d)
923     for d in sorted(discs, key = lambda d: d.fn):
924       if d.neps is not None and d.neps != d.nuses:
925         raise ExpectedError("disc `%s' has %d episodes, used %d times" %
926                             (d.fn, d.neps, d.nuses))
927     return me._pl
928
929 op = OP.OptionParser \
930   (usage = "%prog [-Dc] [-L NAME] [-M DEPS] [-d CACHE] [-o OUT] [-s SERIES] EPLS\n"
931             "%prog -i -d CACHE",
932    description = "Generate M3U playlists from an episode list.")
933 op.add_option("-D", "--dump",
934               dest = "dump", action = "store_true", default = False,
935               help = "Dump playlist in machine-readable form")
936 op.add_option("-L", "--list-name", metavar = "NAME",
937               dest = "list_name", type = "str", default = None,
938               help = "Set the playlist name")
939 op.add_option("-M", "--make-deps", metavar = "DEPS",
940               dest = "deps", type = "str", default = None,
941               help = "Write a `make' fragment for dependencies")
942 op.add_option("-c", "--chapters",
943               dest = "chaptersp", action = "store_true", default = False,
944               help = "Output individual chapter names")
945 op.add_option("-i", "--init-db",
946               dest = "initdbp", action = "store_true", default = False,
947               help = "Initialize the database")
948 op.add_option("-d", "--database", metavar = "CACHE",
949               dest = "database", type = "str", default = None,
950               help = "Set filename for cache database")
951 op.add_option("-o", "--output", metavar = "OUT",
952               dest = "output", type = "str", default = None,
953               help = "Write output playlist to OUT")
954 op.add_option("-O", "--fake-output", metavar = "OUT",
955               dest = "fakeout", type = "str", default = None,
956               help = "Pretend output goes to OUT for purposes of `-M'")
957 op.add_option("-s", "--series", metavar = "SERIES",
958               dest = "series", type = "str", default = None,
959               help = "Output only the listed SERIES (comma-separated)")
960 try:
961   opts, argv = op.parse_args()
962
963   if opts.initdbp:
964     if opts.chaptersp or opts.series is not None or \
965        opts.output is not None or opts.deps is not None or \
966        opts.fakeout is not None or \
967        opts.database is None or len(argv):
968       op.print_usage(file = SYS.stderr); SYS.exit(2)
969     setup_db(opts.database)
970
971   else:
972     if len(argv) != 1: op.print_usage(file = SYS.stderr); SYS.exit(2)
973     if opts.database is not None: init_db(opts.database)
974     if opts.series is None:
975       series_wanted = None
976     else:
977       series_wanted = set()
978       for name in opts.series.split(","): series_wanted.add(name)
979     if opts.deps is not None:
980       if (opts.output is None or opts.output == "-") and opts.fakeout is None:
981         raise ExpectedError("can't write dep fragment without output file")
982       if opts.fakeout is None: opts.fakeout = opts.output
983     else:
984       if opts.fakeout is not None:
985         raise ExpectedError("fake output set but no dep fragment")
986
987     ep = EpisodeListParser(series_wanted, opts.chaptersp)
988     ep.parse_file(argv[0])
989     pl = ep.done()
990
991     if opts.list_name is None:
992       opts.list_name, _ = OS.path.splitext(OS.path.basename(argv[0]))
993
994     if opts.dump: outfn = pl.dump
995     else: outfn = pl.write
996     if opts.output is None or opts.output == "-":
997       outfn(SYS.stdout)
998     else:
999       with open(opts.output, "w") as f: outfn(f)
1000
1001     if opts.deps:
1002       if opts.deps == "-":
1003         pl.write_deps(SYS.stdout, opts.fakeout)
1004       else:
1005         with open(opts.deps, "w") as f: pl.write_deps(f, opts.fakeout)
1006
1007 except (ExpectedError, IOError, OSError) as e:
1008   LOC.report(e)
1009   SYS.exit(2)