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