chiark / gitweb /
arrowverse.epls: Add `Superman & Lois' season 1.
[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
516   def write(me, f):
517     f.write("#EXTM3U\n")
518     for season in me.seasons:
519       f.write("\n")
520       for ep in season:
521         label = ep.label()
522         if me.nseries > 1 and ep.series_title_p and \
523            ep.season.series.title is not None:
524           if ep.season.i is None: sep = ": "
525           else: sep = " "
526           label = ep.season.series.title + sep + label
527         if not ep.chapters:
528           f.write("#EXTINF:%d,,%s\n%s\n" % (ep.duration, label, ep.url))
529         else:
530           for ch in ep.chapters:
531             f.write("#EXTINF:%d,,%s: %s\n%s\n" %
532                     (ch.duration, label, ch.title, ch.url))
533
534   def write_deps(me, f, out):
535     deps = set()
536     for season in me.seasons:
537       for ep in season: deps.add(ep.source.fn)
538     f.write("### -*-makefile-*-\n")
539     f.write("%s: $(call check-deps, %s," % (out, out))
540     for dep in sorted(deps):
541       f.write(" \\\n\t'%s'" %
542                 OS.path.join(ROOT, dep)
543                   .replace(",", "$(comma)")
544                   .replace("'", "'\\''"))
545     f.write(")\n")
546
547 DEFAULT_EXPVAR = 0.05
548 R_DURMULT = RX.compile(r""" ^
549         (\d+ (?: \. \d+)?) x
550 $ """, RX.X)
551 R_DUR = RX.compile(r""" ^
552         (?: (?: (\d+) :)? (\d+) :)? (\d+)
553         (?: / (\d+ (?: \. \d+)?) \%)?
554 $ """, RX.X)
555 def parse_duration(s, base = None, basevar = DEFAULT_EXPVAR):
556   if base is not None:
557     m = R_DURMULT.match(s)
558     if m is not None: return base*float(m.group(1)), basevar
559   m = R_DUR.match(s)
560   if not m: raise ExpectedError("invalid duration spec `%s'" % s)
561   hr, min, sec = map(lambda g: filter(m.group(g), int, 0), [1, 2, 3])
562   var = filter(m.group(4), lambda x: float(x)/100.0)
563   if var is None: var = DEFAULT_EXPVAR
564   return 3600*hr + 60*min + sec, var
565 def format_duration(d):
566   if d >= 3600: return "%d:%02d:%02d" % (d//3600, (d//60)%60, d%60)
567   elif d >= 60: return "%d:%02d" % (d//60, d%60)
568   else: return "%d s" % d
569
570 MODE_UNSET = 0
571 MODE_SINGLE = 1
572 MODE_MULTI = 2
573
574 class EpisodeListParser (object):
575
576   def __init__(me, series_wanted = None, chapters_wanted_p = False):
577     me._pl = Playlist()
578     me._cur_episode = me._cur_chapter = None
579     me._series = {}; me._vdirs = {}; me._audirs = {}; me._isos = {}
580     me._series_wanted = series_wanted
581     me._chaptersp = chapters_wanted_p
582     me._explen, me._expvar = None, DEFAULT_EXPVAR
583     if series_wanted is None: me._mode = MODE_UNSET
584     else: me._mode = MODE_MULTI
585
586   def _bad_keyval(me, cmd, k, v):
587     raise ExpectedError("invalid `!%s' option `%s'" %
588                           (cmd, v if k is None else k))
589
590   def _keyvals(me, opts):
591     if opts is not None:
592       for kv in opts.split(","):
593         try: sep = kv.index("=")
594         except ValueError: yield None, kv
595         else: yield kv[:sep], kv[sep + 1:]
596
597   def _set_mode(me, mode):
598     if me._mode == MODE_UNSET:
599       me._mode = mode
600     elif me._mode != mode:
601       raise ExpectedError("inconsistent single-/multi-series usage")
602
603   def _get_series(me, name):
604     if name is None:
605       me._set_mode(MODE_SINGLE)
606       try: series = me._series[None]
607       except KeyError:
608         series = me._series[None] = Series(me._pl, None)
609         me._pl.nseries += 1
610     else:
611       me._set_mode(MODE_MULTI)
612       series = lookup(me._series, name, "unknown series `%s'" % name)
613     return series
614
615   def _opts_series(me, cmd, opts):
616     name = None
617     for k, v in me._keyvals(opts):
618       if k is None: name = v
619       else: me._bad_keyval(cmd, k, v)
620     return me._get_series(name)
621
622   def _auto_epsrc(me, series):
623     dir = lookup(me._vdirs, series.name, "no active video directory")
624     season = series.ensure_season()
625     check(season.i is not None, "must use explicit iso for movie seasons")
626     vseason = lookup(dir.seasons, season.i,
627                      "season %d not found in video dir `%s'" %
628                        (season.i, dir.dir))
629     src = lookup(vseason.episodes, season.ep_i,
630                  "episode %d.%d not found in video dir `%s'" %
631                    (season.i, season.ep_i, dir.dir))
632     return src
633
634   def _process_cmd(me, ww):
635
636     cmd = ww.nextword(); check(cmd is not None, "missing command")
637     try: sep = cmd.index(":")
638     except ValueError: opts = None
639     else: cmd, opts = cmd[:sep], cmd[sep + 1:]
640
641     if cmd == "series":
642       name = None
643       for k, v in me._keyvals(opts):
644         if k is None: name = v
645         else: me._bad_keyval(cmd, k, v)
646       check(name is not None, "missing series name")
647       check(name not in me._series, "series `%s' already defined" % name)
648       title = ww.rest()
649       me._set_mode(MODE_MULTI)
650       me._series[name] = series = Series(me._pl, name, title,
651                                          me._series_wanted is None or
652                                            name in me._series_wanted)
653       if series.wantedp: me._pl.nseries += 1
654
655     elif cmd == "season":
656       series = me._opts_series(cmd, opts)
657       w = ww.nextword();
658       check(w is not None, "missing season number")
659       if w == "-":
660         if not series.wantedp: return
661         series.add_movies(ww.rest())
662       else:
663         title = ww.rest(); i = getint(w)
664         if not series.wantedp: return
665         series.add_season(ww.rest(), getint(w), implicitp = False)
666       me._cur_episode = me._cur_chapter = None
667       me._pl.done_season()
668
669     elif cmd == "explen":
670       w = ww.rest(); check(w is not None, "missing duration spec")
671       if w == "-":
672         me._explen, me._expvar = None, DEFAULT_EXPVAR
673       else:
674         d, v = parse_duration(w)
675         me._explen = d
676         if v is not None: me._expvar = v
677
678     elif cmd == "epname":
679       for k, v in me._keyvals(opts): me._bad_keyval("epname", k, v)
680       name = ww.rest(); check(name is not None, "missing episode name")
681       try: sep = name.index("::")
682       except ValueError: names = name + "s"
683       else: name, names = name[:sep], name[sep + 1:]
684       me._pl.epname, me._pl.epnames = name, names
685
686     elif cmd == "epno":
687       series = me._opts_series(cmd, opts)
688       w = ww.rest(); check(w is not None, "missing episode number")
689       epi = getint(w)
690       if not series.wantedp: return
691       series.ensure_season().ep_i = epi
692
693     elif cmd == "iso":
694       series = me._opts_series(cmd, opts)
695       fn = ww.rest(); check(fn is not None, "missing filename")
696       if not series.wantedp: return
697       if fn == "-": forget(me._isos, series.name)
698       else:
699         check(OS.path.exists(OS.path.join(ROOT, fn)),
700               "iso file `%s' not found" % fn)
701         me._isos[series.name] = VideoDisc(fn)
702
703     elif cmd == "vdir":
704       series = me._opts_series(cmd, opts)
705       dir = ww.rest(); check(dir is not None, "missing directory")
706       if not series.wantedp: return
707       if dir == "-": forget(me._vdirs, series.name)
708       else: me._vdirs[series.name] = VideoDir(dir)
709
710     elif cmd == "adir":
711       series = me._opts_series(cmd, opts)
712       dir = ww.rest(); check(dir is not None, "missing directory")
713       if not series.wantedp: return
714       if dir == "-": forget(me._audirs, series.name)
715       else: me._audirs[series.name] = AudioDir(dir)
716
717     elif cmd == "displaced":
718       series = me._opts_series(cmd, opts)
719       w = ww.rest(); check(w is not None, "missing count"); n = getint(w)
720       src = me._auto_epsrc(series)
721       src.nuses += n
722
723     else:
724       raise ExpectedError("unknown command `%s'" % cmd)
725
726   def _process_episode(me, ww):
727
728     opts = ww.nextword(); check(opts is not None, "missing title/options")
729     ti = None; sname = None; neps = 1; epi = None; loch = hich = None
730     explen, expvar, explicitlen = me._explen, me._expvar, False
731     series_title_p = True
732     for k, v in me._keyvals(opts):
733       if k is None:
734         if v.isdigit(): ti = int(v)
735         elif v == "-": ti = "-"
736         else: sname = v
737       elif k == "s": sname = v
738       elif k == "n": neps = getint(v)
739       elif k == "ep": epi = getint(v)
740       elif k == "st": series_title_p = getbool(v)
741       elif k == "l":
742         if v == "-": me._explen, me._expvar = None, DEFAULT_EXPVAR
743         else:
744           explen, expvar = parse_duration(v, explen, expvar)
745           explicitlen = True
746       elif k == "ch":
747         try: sep = v.index("-")
748         except ValueError: loch, hich = getint(v), None
749         else: loch, hich = getint(v[:sep]), getint(v[sep + 1:]) + 1
750       else: raise ExpectedError("unknown episode option `%s'" % k)
751     check(ti is not None, "missing title number")
752     series = me._get_series(sname)
753     me._cur_chapter = None
754
755     title = ww.rest()
756     if not series.wantedp: return
757     season = series.ensure_season()
758     if epi is None: epi = season.ep_i
759
760     if ti == "-":
761       check(season.implicitp, "audio source, but explicit season")
762       dir = lookup(me._audirs, series.name,
763                    "no title, and no audio directory")
764       src = lookup(dir.episodes, season.ep_i,
765                    "episode %d not found in audio dir `%s'" % (epi, dir.dir))
766
767     else:
768       try: src = me._isos[series.name]
769       except KeyError: src = me._auto_epsrc(series)
770
771     episode = season.add_episode(epi, neps, title, src,
772                                  series_title_p, ti, loch, hich)
773
774     if episode.duration != -1 and explen is not None:
775       if not explicitlen: explen *= neps
776       if not explen*(1 - expvar) <= episode.duration <= explen*(1 + expvar):
777         if season.i is None: epid = "episode %d" % epi
778         else: epid = "episode %d.%d" % (season.i, epi)
779         raise ExpectedError \
780           ("%s duration %s %g%% > %g%% from expected %s" %
781              (epid, format_duration(episode.duration),
782               abs(100*(episode.duration - explen)/explen), 100*expvar,
783               format_duration(explen)))
784     me._pl.add_episode(episode)
785     me._cur_episode = episode
786
787   def _process_chapter(me, ww):
788     check(me._cur_episode is not None, "no current episode")
789     check(me._cur_episode.source.CHAPTERP,
790           "episode source doesn't allow chapters")
791     if me._chaptersp:
792       if me._cur_chapter is None: i = 1
793       else: i = me._cur_chapter.i + 1
794       me._cur_chapter = me._cur_episode.add_chapter(ww.rest(), i)
795
796   def parse_file(me, fn):
797     with location(FileLocation(fn, 0)) as floc:
798       with open(fn, "r") as f:
799         for line in f:
800           floc.stepline()
801           sline = line.lstrip()
802           if sline == "" or sline.startswith(";"): continue
803
804           if line.startswith("!"): me._process_cmd(Words(line[1:]))
805           elif not line[0].isspace(): me._process_episode(Words(line))
806           else: me._process_chapter(Words(line))
807     me._pl.done_season()
808
809   def done(me):
810     discs = set()
811     for name, vdir in me._vdirs.items():
812       if not me._series[name].wantedp: continue
813       for s in vdir.seasons.values():
814         for d in s.episodes.values():
815           discs.add(d)
816     for adir in me._audirs.values():
817       for d in adir.episodes.values():
818         discs.add(d)
819     for d in sorted(discs, key = lambda d: d.fn):
820       if d.neps is not None and d.neps != d.nuses:
821         raise ExpectedError("disc `%s' has %d episodes, used %d times" %
822                             (d.fn, d.neps, d.nuses))
823     return me._pl
824
825 op = OP.OptionParser \
826   (usage = "%prog [-c] [-M DEPS] [-d CACHE] [-o OUT] [-s SERIES] EPLS\n"
827            "%prog -i -d CACHE",
828    description = "Generate M3U playlists from an episode list.")
829 op.add_option("-M", "--make-deps", metavar = "DEPS",
830               dest = "deps", type = "str", default = None,
831               help = "Write a `make' fragment for dependencies")
832 op.add_option("-c", "--chapters",
833               dest = "chaptersp", action = "store_true", default = False,
834               help = "Output individual chapter names")
835 op.add_option("-i", "--init-db",
836               dest = "initdbp", action = "store_true", default = False,
837               help = "Initialize the database")
838 op.add_option("-d", "--database", metavar = "CACHE",
839               dest = "database", type = "str", default = None,
840               help = "Set filename for cache database")
841 op.add_option("-o", "--output", metavar = "OUT",
842               dest = "output", type = "str", default = None,
843               help = "Write output playlist to OUT")
844 op.add_option("-O", "--fake-output", metavar = "OUT",
845               dest = "fakeout", type = "str", default = None,
846               help = "Pretend output goes to OUT for purposes of `-M'")
847 op.add_option("-s", "--series", metavar = "SERIES",
848               dest = "series", type = "str", default = None,
849               help = "Output only the listed SERIES (comma-separated)")
850 try:
851   opts, argv = op.parse_args()
852
853   if opts.initdbp:
854     if opts.chaptersp or opts.series is not None or \
855        opts.output is not None or opts.deps is not None or \
856        opts.fakeout is not None or \
857        opts.database is None or len(argv):
858       op.print_usage(file = SYS.stderr); SYS.exit(2)
859     setup_db(opts.database)
860
861   else:
862     if len(argv) != 1: op.print_usage(file = SYS.stderr); SYS.exit(2)
863     if opts.database is not None: init_db(opts.database)
864     if opts.series is None:
865       series_wanted = None
866     else:
867       series_wanted = set()
868       for name in opts.series.split(","): series_wanted.add(name)
869     if opts.deps is not None:
870       if (opts.output is None or opts.output == "-") and opts.fakeout is None:
871         raise ExpectedError("can't write dep fragment without output file")
872       if opts.fakeout is None: opts.fakeout = opts.output
873     else:
874       if opts.fakeout is not None:
875         raise ExpectedError("fake output set but no dep fragment")
876
877     ep = EpisodeListParser(series_wanted, opts.chaptersp)
878     ep.parse_file(argv[0])
879     pl = ep.done()
880
881     if opts.output is None or opts.output == "-":
882       pl.write(SYS.stdout)
883     else:
884       with open(opts.output, "w") as f: pl.write(f)
885
886     if opts.deps:
887       if opts.deps == "-":
888         pl.write_deps(SYS.stdout, opts.fakeout)
889       else:
890         with open(opts.deps, "w") as f: pl.write_deps(f, opts.fakeout)
891
892 except (ExpectedError, IOError, OSError) as e:
893   LOC.report(e)
894   SYS.exit(2)