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