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