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