d70485662a6cbdaee7bc88a756c3b499fddc15fa
[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 = set()
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 me.used_titles.add(title)
220 else:
221 for ch in range(start_chapter, end_chapter):
222 me.used_chapters.add((title, ch))
223 return me.PREFIX + ROOT + urlencode(me.fn) + suffix, duration
224
225 class VideoDisc (Source):
226 PREFIX = "dvd://"
227 TITLEP = CHAPTERP = True
228
229 def __init__(me, fn, *args, **kw):
230 super().__init__(fn, *args, **kw)
231 me.neps = 0
232
233 def _duration(me, title, start_chapter, end_chapter):
234 path = OS.path.join(ROOT, me.fn)
235 ntitle = int(program_output(["dvd-info", path, "titles"]))
236 if not 1 <= title <= ntitle:
237 raise ExpectedError("bad title %d for `%s': must be in 1 .. %d" %
238 (title, me.fn, ntitle))
239 if start_chapter == -1:
240 durq = "duration:%d" % title
241 else:
242 nch = int(program_output(["dvd-info", path, "chapters:%d" % title]))
243 if end_chapter == -1: end_chapter = nch
244 else: end_chapter -= 1
245 if not 1 <= start_chapter <= end_chapter <= nch:
246 raise ExpectedError("bad chapter range %d .. %d for `%s' title %d: "
247 "must be in 1 .. %d" %
248 (start_chapter, end_chapter, me.fn, title, nch))
249 durq = "duration:%d.%d-%d" % (title, start_chapter, end_chapter)
250 duration = int(program_output(["dvd-info", path, durq]))
251 return duration
252
253 class VideoSeason (object):
254 def __init__(me, i, title):
255 me.i = i
256 me.title = title
257 me.episodes = {}
258 def set_episode_disc(me, i, disc):
259 if i in me.episodes:
260 raise ExpectedError("season %d episode %d already taken" % (me.i, i))
261 me.episodes[i] = disc; disc.neps += 1
262
263 def match_group(m, *groups, dflt = None, mustp = False):
264 for g in groups:
265 try: s = m.group(g)
266 except IndexError: continue
267 if s is not None: return s
268 if mustp: raise ValueError("no match found")
269 else: return dflt
270
271 class VideoDir (object):
272
273 _R_ISO_PRE = list(map(lambda pats:
274 list(map(lambda pat:
275 RX.compile("^" + pat + r"\.iso$", RX.X),
276 pats)),
277 [[r""" S (?P<si> \d+) \. \ (?P<stitle> .*) — (?: D \d+ \. \ )?
278 (?P<epex> .*) """,
279 r""" S (?P<si> \d+) (?: D \d+)? \. \ (?P<epex> .*) """,
280 r""" S (?P<si> \d+) \. \ (?P<epex> E \d+ .*) """,
281 r""" S (?P<si> \d+) (?P<epex> E \d+) \. \ .* """],
282 [r""" (?P<si> \d+) [A-Z]? \. \ (?P<stitle> .*) — (?P<epex> .*) """],
283 [r""" \d+ \. \ (?P<epex> [ES] \d+ .*) """],
284 [r""" (?P<epnum> \d+ ) \. \ .* """]]))
285
286 _R_ISO_EP = RX.compile(r""" ^
287 (?: S (?P<si> \d+) \ )?
288 E (?P<ei> \d+) (?: – (?P<ej> \d+))? $
289 """, RX.X)
290
291 def __init__(me, dir):
292 me.dir = dir
293 fns = OS.listdir(OS.path.join(ROOT, dir))
294 fns.sort()
295 season = None
296 seasons = {}
297 styles = me._R_ISO_PRE
298 for fn in fns:
299 path = OS.path.join(dir, fn)
300 if not fn.endswith(".iso"): continue
301 #print(";; `%s'" % path, file = SYS.stderr)
302 for sty in styles:
303 for r in sty:
304 m = r.match(fn)
305 if m: styles = [sty]; break
306 else:
307 continue
308 break
309 else:
310 #print(";;\tignored (regex mismatch)", file = SYS.stderr)
311 continue
312
313 si = filter(match_group(m, "si"), int)
314 stitle = match_group(m, "stitle")
315
316 check(si is not None or stitle is None,
317 "explicit season title without number in `%s'" % fn)
318 if si is not None:
319 if season is None or si != season.i:
320 check(season is None or si == season.i + 1,
321 "season %d /= %d" %
322 (si, season is None and -1 or season.i + 1))
323 check(si not in seasons, "season %d already seen" % si)
324 seasons[si] = season = VideoSeason(si, stitle)
325 else:
326 check(stitle == season.title,
327 "season title `%s' /= `%s'" % (stitle, season.title))
328
329 disc = VideoDisc(path)
330 ts = season
331 any, bad = False, False
332 epnum = match_group(m, "epnum")
333 if epnum is not None: eplist = ["E" + epnum]
334 else: eplist = match_group(m, "epex", mustp = True).split(", ")
335 for eprange in eplist:
336 mm = me._R_ISO_EP.match(eprange)
337 if mm is None:
338 #print(";;\t`%s'?" % eprange, file = SYS.stderr)
339 bad = True; continue
340 if not any: any = True
341 i = filter(mm.group("si"), int)
342 if i is not None:
343 try: ts = seasons[i]
344 except KeyError: ts = seasons[i] = VideoSeason(i, None)
345 if ts is None:
346 ts = season = seasons[1] = VideoSeason(1, None)
347 start = filter(mm.group("ei"), int)
348 end = filter(mm.group("ej"), int, start)
349 for k in range(start, end + 1):
350 ts.set_episode_disc(k, disc)
351 #print(";;\tepisode %d.%d" % (ts.i, k), file = SYS.stderr)
352 if not any:
353 #print(";;\tignored", file = SYS.stderr)
354 pass
355 elif bad:
356 raise ExpectedError("bad ep list in `%s'", fn)
357 me.seasons = seasons
358
359 class AudioDisc (Source):
360 PREFIX = "file://"
361 TITLEP = CHAPTERP = False
362
363 def _duration(me, title, start_chapter, end_chaptwr):
364 out = program_output(["metaflac",
365 "--show-total-samples", "--show-sample-rate",
366 OS.path.join(ROOT, me.fn)])
367 nsamples, hz = map(float, out.split())
368 return int(nsamples/hz)
369
370 class AudioEpisode (AudioDisc):
371 def __init__(me, fn, i, *args, **kw):
372 super().__init__(fn, *args, **kw)
373 me.i = i
374
375 class AudioDir (object):
376
377 _R_FLAC = RX.compile(r""" ^
378 E (\d+)
379 (?: \. \ (.*))?
380 \. flac $
381 """, RX.X)
382
383 def __init__(me, dir):
384 me.dir = dir
385 fns = OS.listdir(OS.path.join(ROOT, dir))
386 fns.sort()
387 episodes = {}
388 last_i = 0
389 for fn in fns:
390 path = OS.path.join(dir, fn)
391 if not fn.endswith(".flac"): continue
392 m = me._R_FLAC.match(fn)
393 if not m: continue
394 i = filter(m.group(1), int)
395 etitle = m.group(2)
396 check(i == last_i + 1, "episode %d /= %d" % (i, last_i + 1))
397 episodes[i] = AudioEpisode(path, i)
398 last_i = i
399 me.episodes = episodes
400
401 class Chapter (object):
402 def __init__(me, episode, title, i):
403 me.title, me.i = title, i
404 me.url, me.duration = \
405 episode.source.url_and_duration(episode.tno, i, i + 1)
406
407 class Episode (object):
408 def __init__(me, season, i, neps, title, src, series_title_p = True,
409 tno = -1, startch = -1, endch = -1):
410 me.season = season
411 me.i, me.neps, me.title = i, neps, title
412 me.chapters = []
413 me.source, me.tno = src, tno
414 me.series_title_p = series_title_p
415 me.url, me.duration = src.url_and_duration(tno, startch, endch)
416 def add_chapter(me, title, j):
417 ch = Chapter(me, title, j)
418 me.chapters.append(ch)
419 return ch
420 def label(me):
421 return me.season._eplabel(me.i, me.neps, me.title)
422
423 class BaseSeason (object):
424 def __init__(me, series, implicitp = False):
425 me.series = series
426 me.episodes = []
427 me.implicitp = implicitp
428 me.ep_i, episodes = 1, []
429 def add_episode(me, j, neps, title, src, series_title_p,
430 tno, startch, endch):
431 ep = Episode(me, j, neps, title, src, series_title_p,
432 tno, startch, endch)
433 me.episodes.append(ep)
434 src.nuses += neps; me.ep_i += neps
435 return ep
436 def _epnames(me, i, neps):
437 playlist = me.series.playlist
438 if neps == 1: return playlist.epname, ["%d" % i]
439 elif neps == 2: return playlist.epnames, ["%d" % i, "%d" % (i + 1)]
440 else: return playlist.epnames, ["%d–%d" % (i, i + neps - 1)]
441
442 class Season (BaseSeason):
443 def __init__(me, series, title, i, *args, **kw):
444 super().__init__(series, *args, **kw)
445 me.title, me.i = title, i
446 def _eplabel(me, i, neps, title):
447 epname, epn = me._epnames(i, neps)
448 if title is None:
449 if me.implicitp:
450 label = "%s %s" % (epname, ", ".join(epn))
451 elif me.title is None:
452 label = "%s %s" % \
453 (epname, ", ".join("%d.%s" % (me.i, e) for e in epn))
454 else:
455 label = "%s: %s %s" % (me.title, epname, ", ".join(epn))
456 else:
457 if me.implicitp:
458 label = "%s. %s" % (", ".join(epn), title)
459 elif me.title is None:
460 label = "%s. %s" % \
461 (", ".join("%d.%s" % (me.i, e) for e in epn), title)
462 else:
463 label = "%s: %s. %s" % (me.title, ", ".join(epn), title)
464 return label
465
466 class MovieSeason (BaseSeason):
467 def __init__(me, series, title, *args, **kw):
468 super().__init__(series, *args, **kw)
469 me.title = title
470 me.i = None
471 def add_episode(me, j, neps, title, src, series_title_p,
472 tno, startch, endch):
473 if me.title is None and title is None:
474 raise ExpectedError("movie or movie season must have a title")
475 return super().add_episode(j, neps, title, src, series_title_p,
476 tno, startch, endch)
477 def _eplabel(me, i, neps, title):
478 if me.title is None:
479 label = title
480 elif title is None:
481 epname, epn = me._epnames(i, neps)
482 label = "%s: %s %s" % (me.title, epname, ", ".join(epn))
483 else:
484 label = "%s: %s" % (me.title, title)
485 return label
486
487 class Series (object):
488 def __init__(me, playlist, name, title = None, wantedp = True):
489 me.playlist = playlist
490 me.name, me.title = name, title
491 me.cur_season = None
492 me.wantedp = wantedp
493 def _add_season(me, season):
494 me.cur_season = season
495 def add_season(me, title, i, implicitp = False):
496 me._add_season(Season(me, title, i, implicitp))
497 def add_movies(me, title = None):
498 me._add_season(MovieSeason(me, title))
499 def ensure_season(me):
500 if me.cur_season is None: me.add_season(None, 1, implicitp = True)
501 return me.cur_season
502 def end_season(me):
503 me.cur_season = None
504
505 class Playlist (object):
506
507 def __init__(me):
508 me.seasons = []
509 me.episodes = []
510 me.epname, me.epnames = "Episode", "Episodes"
511 me.nseries = 0
512
513 def add_episode(me, episode):
514 me.episodes.append(episode)
515
516 def done_season(me):
517 if me.episodes:
518 me.seasons.append(me.episodes)
519 me.episodes = []
520
521 def write(me, f):
522 f.write("#EXTM3U\n")
523 for season in me.seasons:
524 f.write("\n")
525 for ep in season:
526 label = ep.label()
527 if me.nseries > 1 and ep.series_title_p and \
528 ep.season.series.title is not None:
529 if ep.season.i is None: sep = ": "
530 else: sep = " "
531 label = ep.season.series.title + sep + label
532 if not ep.chapters:
533 f.write("#EXTINF:%d,,%s\n%s\n" % (ep.duration, label, ep.url))
534 else:
535 for ch in ep.chapters:
536 f.write("#EXTINF:%d,,%s: %s\n%s\n" %
537 (ch.duration, label, ch.title, ch.url))
538
539 def write_deps(me, f, out):
540 deps = set()
541 for season in me.seasons:
542 for ep in season: deps.add(ep.source.fn)
543 f.write("### -*-makefile-*-\n")
544 f.write("%s: $(call check-deps, %s," % (out, out))
545 for dep in sorted(deps):
546 f.write(" \\\n\t'%s'" %
547 OS.path.join(ROOT, dep)
548 .replace(",", "$(comma)")
549 .replace("'", "'\\''"))
550 f.write(")\n")
551
552 DEFAULT_EXPVAR = 0.05
553 R_DURMULT = RX.compile(r""" ^
554 (\d+ (?: \. \d+)?) x
555 $ """, RX.X)
556 R_DUR = RX.compile(r""" ^
557 (?: (?: (\d+) :)? (\d+) :)? (\d+)
558 (?: / (\d+ (?: \. \d+)?) \%)?
559 $ """, RX.X)
560 def parse_duration(s, base = None, basevar = DEFAULT_EXPVAR):
561 if base is not None:
562 m = R_DURMULT.match(s)
563 if m is not None: return base*float(m.group(1)), basevar
564 m = R_DUR.match(s)
565 if not m: raise ExpectedError("invalid duration spec `%s'" % s)
566 hr, min, sec = map(lambda g: filter(m.group(g), int, 0), [1, 2, 3])
567 var = filter(m.group(4), lambda x: float(x)/100.0)
568 if var is None: var = DEFAULT_EXPVAR
569 return 3600*hr + 60*min + sec, var
570 def format_duration(d):
571 if d >= 3600: return "%d:%02d:%02d" % (d//3600, (d//60)%60, d%60)
572 elif d >= 60: return "%d:%02d" % (d//60, d%60)
573 else: return "%d s" % d
574
575 MODE_UNSET = 0
576 MODE_SINGLE = 1
577 MODE_MULTI = 2
578
579 class EpisodeListParser (object):
580
581 def __init__(me, series_wanted = None, chapters_wanted_p = False):
582 me._pl = Playlist()
583 me._cur_episode = me._cur_chapter = None
584 me._series = {}; me._vdirs = {}; me._audirs = {}; me._isos = {}
585 me._series_wanted = series_wanted
586 me._chaptersp = chapters_wanted_p
587 me._explen, me._expvar = None, DEFAULT_EXPVAR
588 if series_wanted is None: me._mode = MODE_UNSET
589 else: me._mode = MODE_MULTI
590
591 def _bad_keyval(me, cmd, k, v):
592 raise ExpectedError("invalid `!%s' option `%s'" %
593 (cmd, v if k is None else k))
594
595 def _keyvals(me, opts):
596 if opts is not None:
597 for kv in opts.split(","):
598 try: sep = kv.index("=")
599 except ValueError: yield None, kv
600 else: yield kv[:sep], kv[sep + 1:]
601
602 def _set_mode(me, mode):
603 if me._mode == MODE_UNSET:
604 me._mode = mode
605 elif me._mode != mode:
606 raise ExpectedError("inconsistent single-/multi-series usage")
607
608 def _get_series(me, name):
609 if name is None:
610 me._set_mode(MODE_SINGLE)
611 try: series = me._series[None]
612 except KeyError:
613 series = me._series[None] = Series(me._pl, None)
614 me._pl.nseries += 1
615 else:
616 me._set_mode(MODE_MULTI)
617 series = lookup(me._series, name, "unknown series `%s'" % name)
618 return series
619
620 def _opts_series(me, cmd, opts):
621 name = None
622 for k, v in me._keyvals(opts):
623 if k is None: name = v
624 else: me._bad_keyval(cmd, k, v)
625 return me._get_series(name)
626
627 def _auto_epsrc(me, series):
628 dir = lookup(me._vdirs, series.name, "no active video directory")
629 season = series.ensure_season()
630 check(season.i is not None, "must use explicit iso for movie seasons")
631 vseason = lookup(dir.seasons, season.i,
632 "season %d not found in video dir `%s'" %
633 (season.i, dir.dir))
634 src = lookup(vseason.episodes, season.ep_i,
635 "episode %d.%d not found in video dir `%s'" %
636 (season.i, season.ep_i, dir.dir))
637 return src
638
639 def _process_cmd(me, ww):
640
641 cmd = ww.nextword(); check(cmd is not None, "missing command")
642 try: sep = cmd.index(":")
643 except ValueError: opts = None
644 else: cmd, opts = cmd[:sep], cmd[sep + 1:]
645
646 if cmd == "series":
647 name = None
648 for k, v in me._keyvals(opts):
649 if k is None: name = v
650 else: me._bad_keyval(cmd, k, v)
651 check(name is not None, "missing series name")
652 check(name not in me._series, "series `%s' already defined" % name)
653 title = ww.rest()
654 me._set_mode(MODE_MULTI)
655 me._series[name] = series = Series(me._pl, name, title,
656 me._series_wanted is None or
657 name in me._series_wanted)
658 if series.wantedp: me._pl.nseries += 1
659
660 elif cmd == "season":
661 series = me._opts_series(cmd, opts)
662 w = ww.nextword();
663 check(w is not None, "missing season number")
664 if w == "-":
665 if not series.wantedp: return
666 series.add_movies(ww.rest())
667 else:
668 title = ww.rest(); i = getint(w)
669 if not series.wantedp: return
670 series.add_season(ww.rest(), getint(w), implicitp = False)
671 me._cur_episode = me._cur_chapter = None
672 me._pl.done_season()
673
674 elif cmd == "explen":
675 w = ww.rest(); check(w is not None, "missing duration spec")
676 if w == "-":
677 me._explen, me._expvar = None, DEFAULT_EXPVAR
678 else:
679 d, v = parse_duration(w)
680 me._explen = d
681 if v is not None: me._expvar = v
682
683 elif cmd == "epname":
684 for k, v in me._keyvals(opts): me._bad_keyval("epname", k, v)
685 name = ww.rest(); check(name is not None, "missing episode name")
686 try: sep = name.index("::")
687 except ValueError: names = name + "s"
688 else: name, names = name[:sep], name[sep + 2:]
689 me._pl.epname, me._pl.epnames = name, names
690
691 elif cmd == "epno":
692 series = me._opts_series(cmd, opts)
693 w = ww.rest(); check(w is not None, "missing episode number")
694 epi = getint(w)
695 if not series.wantedp: return
696 series.ensure_season().ep_i = epi
697
698 elif cmd == "iso":
699 series = me._opts_series(cmd, opts)
700 fn = ww.rest(); check(fn is not None, "missing filename")
701 if not series.wantedp: return
702 if fn == "-": forget(me._isos, series.name)
703 else:
704 check(OS.path.exists(OS.path.join(ROOT, fn)),
705 "iso file `%s' not found" % fn)
706 me._isos[series.name] = VideoDisc(fn)
707
708 elif cmd == "vdir":
709 series = me._opts_series(cmd, opts)
710 dir = ww.rest(); check(dir is not None, "missing directory")
711 if not series.wantedp: return
712 if dir == "-": forget(me._vdirs, series.name)
713 else: me._vdirs[series.name] = VideoDir(dir)
714
715 elif cmd == "adir":
716 series = me._opts_series(cmd, opts)
717 dir = ww.rest(); check(dir is not None, "missing directory")
718 if not series.wantedp: return
719 if dir == "-": forget(me._audirs, series.name)
720 else: me._audirs[series.name] = AudioDir(dir)
721
722 elif cmd == "displaced":
723 series = me._opts_series(cmd, opts)
724 w = ww.rest(); check(w is not None, "missing count"); n = getint(w)
725 src = me._auto_epsrc(series)
726 src.nuses += n
727
728 else:
729 raise ExpectedError("unknown command `%s'" % cmd)
730
731 def _process_episode(me, ww):
732
733 opts = ww.nextword(); check(opts is not None, "missing title/options")
734 ti = -1; sname = None; neps = 1; epi = None; loch = hich = -1
735 explen, expvar, explicitlen = me._explen, me._expvar, False
736 series_title_p = True
737 for k, v in me._keyvals(opts):
738 if k is None:
739 if v.isdigit(): ti = int(v)
740 elif v == "-": ti = -1
741 else: sname = v
742 elif k == "s": sname = v
743 elif k == "n": neps = getint(v)
744 elif k == "ep": epi = getint(v)
745 elif k == "st": series_title_p = getbool(v)
746 elif k == "l":
747 if v == "-": me._explen, me._expvar = None, DEFAULT_EXPVAR
748 else:
749 explen, expvar = parse_duration(v, explen, expvar)
750 explicitlen = True
751 elif k == "ch":
752 try: sep = v.index("-")
753 except ValueError: loch, hich = getint(v), -1
754 else: loch, hich = getint(v[:sep]), getint(v[sep + 1:]) + 1
755 else: raise ExpectedError("unknown episode option `%s'" % k)
756 check(ti is not None, "missing title number")
757 series = me._get_series(sname)
758 me._cur_chapter = None
759
760 title = ww.rest()
761 if not series.wantedp: return
762 season = series.ensure_season()
763 if epi is None: epi = season.ep_i
764
765 if ti == -1:
766 check(season.implicitp or season.i is None,
767 "audio source, but explicit non-movie season")
768 dir = lookup(me._audirs, series.name,
769 "no title, and no audio directory")
770 src = lookup(dir.episodes, season.ep_i,
771 "episode %d not found in audio dir `%s'" % (epi, dir.dir))
772
773 else:
774 try: src = me._isos[series.name]
775 except KeyError: src = me._auto_epsrc(series)
776
777 episode = season.add_episode(epi, neps, title, src,
778 series_title_p, ti, loch, hich)
779
780 if episode.duration != -1 and explen is not None:
781 if not explicitlen: explen *= neps
782 if not explen*(1 - expvar) <= episode.duration <= explen*(1 + expvar):
783 if season.i is None: epid = "episode %d" % epi
784 else: epid = "episode %d.%d" % (season.i, epi)
785 raise ExpectedError \
786 ("%s duration %s %g%% > %g%% from expected %s" %
787 (epid, format_duration(episode.duration),
788 abs(100*(episode.duration - explen)/explen), 100*expvar,
789 format_duration(explen)))
790 me._pl.add_episode(episode)
791 me._cur_episode = episode
792
793 def _process_chapter(me, ww):
794 check(me._cur_episode is not None, "no current episode")
795 check(me._cur_episode.source.CHAPTERP,
796 "episode source doesn't allow chapters")
797 if me._chaptersp:
798 if me._cur_chapter is None: i = 1
799 else: i = me._cur_chapter.i + 1
800 me._cur_chapter = me._cur_episode.add_chapter(ww.rest(), i)
801
802 def parse_file(me, fn):
803 with location(FileLocation(fn, 0)) as floc:
804 with open(fn, "r") as f:
805 for line in f:
806 floc.stepline()
807 sline = line.lstrip()
808 if sline == "" or sline.startswith(";"): continue
809
810 if line.startswith("!"): me._process_cmd(Words(line[1:]))
811 elif not line[0].isspace(): me._process_episode(Words(line))
812 else: me._process_chapter(Words(line))
813 me._pl.done_season()
814
815 def done(me):
816 discs = set()
817 for name, vdir in me._vdirs.items():
818 if not me._series[name].wantedp: continue
819 for s in vdir.seasons.values():
820 for d in s.episodes.values():
821 discs.add(d)
822 for adir in me._audirs.values():
823 for d in adir.episodes.values():
824 discs.add(d)
825 for d in sorted(discs, key = lambda d: d.fn):
826 if d.neps is not None and d.neps != d.nuses:
827 raise ExpectedError("disc `%s' has %d episodes, used %d times" %
828 (d.fn, d.neps, d.nuses))
829 return me._pl
830
831 op = OP.OptionParser \
832 (usage = "%prog [-c] [-M DEPS] [-d CACHE] [-o OUT] [-s SERIES] EPLS\n"
833 "%prog -i -d CACHE",
834 description = "Generate M3U playlists from an episode list.")
835 op.add_option("-M", "--make-deps", metavar = "DEPS",
836 dest = "deps", type = "str", default = None,
837 help = "Write a `make' fragment for dependencies")
838 op.add_option("-c", "--chapters",
839 dest = "chaptersp", action = "store_true", default = False,
840 help = "Output individual chapter names")
841 op.add_option("-i", "--init-db",
842 dest = "initdbp", action = "store_true", default = False,
843 help = "Initialize the database")
844 op.add_option("-d", "--database", metavar = "CACHE",
845 dest = "database", type = "str", default = None,
846 help = "Set filename for cache database")
847 op.add_option("-o", "--output", metavar = "OUT",
848 dest = "output", type = "str", default = None,
849 help = "Write output playlist to OUT")
850 op.add_option("-O", "--fake-output", metavar = "OUT",
851 dest = "fakeout", type = "str", default = None,
852 help = "Pretend output goes to OUT for purposes of `-M'")
853 op.add_option("-s", "--series", metavar = "SERIES",
854 dest = "series", type = "str", default = None,
855 help = "Output only the listed SERIES (comma-separated)")
856 try:
857 opts, argv = op.parse_args()
858
859 if opts.initdbp:
860 if opts.chaptersp or opts.series is not None or \
861 opts.output is not None or opts.deps is not None or \
862 opts.fakeout is not None or \
863 opts.database is None or len(argv):
864 op.print_usage(file = SYS.stderr); SYS.exit(2)
865 setup_db(opts.database)
866
867 else:
868 if len(argv) != 1: op.print_usage(file = SYS.stderr); SYS.exit(2)
869 if opts.database is not None: init_db(opts.database)
870 if opts.series is None:
871 series_wanted = None
872 else:
873 series_wanted = set()
874 for name in opts.series.split(","): series_wanted.add(name)
875 if opts.deps is not None:
876 if (opts.output is None or opts.output == "-") and opts.fakeout is None:
877 raise ExpectedError("can't write dep fragment without output file")
878 if opts.fakeout is None: opts.fakeout = opts.output
879 else:
880 if opts.fakeout is not None:
881 raise ExpectedError("fake output set but no dep fragment")
882
883 ep = EpisodeListParser(series_wanted, opts.chaptersp)
884 ep.parse_file(argv[0])
885 pl = ep.done()
886
887 if opts.output is None or opts.output == "-":
888 pl.write(SYS.stdout)
889 else:
890 with open(opts.output, "w") as f: pl.write(f)
891
892 if opts.deps:
893 if opts.deps == "-":
894 pl.write_deps(SYS.stdout, opts.fakeout)
895 else:
896 with open(opts.deps, "w") as f: pl.write_deps(f, opts.fakeout)
897
898 except (ExpectedError, IOError, OSError) as e:
899 LOC.report(e)
900 SYS.exit(2)