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