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