5aa9fc69af66df0f950185452684f91a6c3a03f9
[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 PREFIX = ""
132 TITLEP = CHAPTERP = False
133 def __init__(me, fn):
134 me.fn = fn
135 me.neps = None
136 me.used_titles = dict()
137 me.used_chapters = set()
138 me.nuses = 0
139 def _duration(me, title, start_chapter, end_chapter):
140 return -1
141 def url_and_duration(me, title = None,
142 start_chapter = None, end_chapter = None):
143 if title == "-":
144 if me.TITLEP: raise ExpectedError("missing title number")
145 if start_chapter is not None or end_chapter is not None:
146 raise ExpectedError("can't specify chapter without title")
147 suffix = ""
148 elif not me.TITLEP:
149 raise ExpectedError("can't specify title with `%s'" % me.fn)
150 elif start_chapter is None:
151 if end_chapter is not None:
152 raise ExpectedError("can't specify end chapter without start chapter")
153 suffix = "#%d" % title
154 elif not me.CHAPTERP:
155 raise ExpectedError("can't specify chapter with `%s'" % me.fn)
156 elif end_chapter is None:
157 suffix = "#%d:%d" % (title, start_chapter)
158 else:
159 suffix = "#%d:%d-%d:%d" % (title, start_chapter, title, end_chapter - 1)
160
161 duration = None
162 if DB is None:
163 duration = me._duration(title, start_chapter, end_chapter)
164 else:
165 st = OS.stat(OS.path.join(ROOT, me.fn))
166 duration = None
167 c = DB.cursor()
168 c.execute("""
169 SELECT device, inode, size, mtime, duration FROM duration
170 WHERE path = ? AND title = ? AND
171 start_chapter = ? AND end_chapter = ?
172 """, [me.fn, title, start_chapter is None and -1 or start_chapter,
173 end_chapter is None and -1 or end_chapter])
174 row = c.fetchone()
175 foundp = False
176 if row is None:
177 duration = me._duration(title, start_chapter, end_chapter)
178 c.execute("""
179 INSERT OR REPLACE INTO duration
180 (path, title, start_chapter, end_chapter,
181 device, inode, size, mtime, duration)
182 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
183 """, [me.fn, title, start_chapter is None and -1 or start_chapter,
184 end_chapter is None and -1 or end_chapter,
185 st.st_dev, st.st_ino, st.st_size, st.st_mtime,
186 duration])
187 else:
188 dev, ino, sz, mt, d = row
189 if (dev, ino, sz, mt) == \
190 (st.st_dev, st.st_ino, st.st_size, st.st_mtime):
191 duration = d
192 else:
193 duration = me._duration(title, start_chapter, end_chapter)
194 c.execute("""
195 UPDATE duration
196 SET device = ?, inode = ?, size = ?, mtime = ?, duration = ?
197 WHERE path = ? AND title = ? AND
198 start_chapter = ? AND end_chapter = ?
199 """, [st.st_dev, st.st_dev, st.st_size, st.st_mtime, duration,
200 me.fn, title, start_chapter is None and -1 or start_chapter,
201 end_chapter is None and -1 or end_chapter])
202 DB.commit()
203
204 if end_chapter is not None:
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 == "-":
212 raise ExpectedError("`%s' already used" % me.fn)
213 elif end_chapter is None:
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 is not None:
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 is None:
238 durq = "duration:%d" % title
239 else:
240 nch = int(program_output(["dvd-info", path, "chapters:%d" % title]))
241 if end_chapter is None: 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 = None, startch = None, endch = None):
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 def __init__(me):
505 me.seasons = []
506 me.episodes = []
507 me.epname, me.epnames = "Episode", "Episodes"
508 me.nseries = 0
509 def add_episode(me, episode):
510 me.episodes.append(episode)
511 def done_season(me):
512 if me.episodes:
513 me.seasons.append(me.episodes)
514 me.episodes = []
515
516 def write(me, f):
517 f.write("#EXTM3U\n")
518 for season in me.seasons:
519 f.write("\n")
520 for ep in season:
521 label = ep.label()
522 if me.nseries > 1 and ep.series_title_p and \
523 ep.season.series.title is not None:
524 if ep.season.i is None: sep = ": "
525 else: sep = " "
526 label = ep.season.series.title + sep + label
527 if not ep.chapters:
528 f.write("#EXTINF:%d,,%s\n%s\n" % (ep.duration, label, ep.url))
529 else:
530 for ch in ep.chapters:
531 f.write("#EXTINF:%d,,%s: %s\n%s\n" %
532 (ch.duration, label, ch.title, ch.url))
533
534 def write_deps(me, f, out):
535 deps = set()
536 for season in me.seasons:
537 for ep in season: deps.add(ep.source.fn)
538 f.write("### -*-makefile-*-\n")
539 f.write("%s: $(call check-deps, %s," % (out, out))
540 for dep in sorted(deps):
541 f.write(" \\\n\t'%s'" %
542 OS.path.join(ROOT, dep)
543 .replace(",", "$(comma)")
544 .replace("'", "'\\''"))
545 f.write(")\n")
546
547 DEFAULT_EXPVAR = 0.05
548 R_DURMULT = RX.compile(r""" ^
549 (\d+ (?: \. \d+)?) x
550 $ """, RX.X)
551 R_DUR = RX.compile(r""" ^
552 (?: (?: (\d+) :)? (\d+) :)? (\d+)
553 (?: / (\d+ (?: \. \d+)?) \%)?
554 $ """, RX.X)
555 def parse_duration(s, base = None, basevar = DEFAULT_EXPVAR):
556 if base is not None:
557 m = R_DURMULT.match(s)
558 if m is not None: return base*float(m.group(1)), basevar
559 m = R_DUR.match(s)
560 if not m: raise ExpectedError("invalid duration spec `%s'" % s)
561 hr, min, sec = map(lambda g: filter(m.group(g), int, 0), [1, 2, 3])
562 var = filter(m.group(4), lambda x: float(x)/100.0)
563 if var is None: var = DEFAULT_EXPVAR
564 return 3600*hr + 60*min + sec, var
565 def format_duration(d):
566 if d >= 3600: return "%d:%02d:%02d" % (d//3600, (d//60)%60, d%60)
567 elif d >= 60: return "%d:%02d" % (d//60, d%60)
568 else: return "%d s" % d
569
570 MODE_UNSET = 0
571 MODE_SINGLE = 1
572 MODE_MULTI = 2
573
574 class EpisodeListParser (object):
575
576 def __init__(me, series_wanted = None, chapters_wanted_p = False):
577 me._pl = Playlist()
578 me._cur_episode = me._cur_chapter = None
579 me._series = {}; me._vdirs = {}; me._audirs = {}; me._isos = {}
580 me._series_wanted = series_wanted
581 me._chaptersp = chapters_wanted_p
582 me._explen, me._expvar = None, DEFAULT_EXPVAR
583 if series_wanted is None: me._mode = MODE_UNSET
584 else: me._mode = MODE_MULTI
585
586 def _bad_keyval(me, cmd, k, v):
587 raise ExpectedError("invalid `!%s' option `%s'" %
588 (cmd, v if k is None else k))
589
590 def _keyvals(me, opts):
591 if opts is not None:
592 for kv in opts.split(","):
593 try: sep = kv.index("=")
594 except ValueError: yield None, kv
595 else: yield kv[:sep], kv[sep + 1:]
596
597 def _set_mode(me, mode):
598 if me._mode == MODE_UNSET:
599 me._mode = mode
600 elif me._mode != mode:
601 raise ExpectedError("inconsistent single-/multi-series usage")
602
603 def _get_series(me, name):
604 if name is None:
605 me._set_mode(MODE_SINGLE)
606 try: series = me._series[None]
607 except KeyError:
608 series = me._series[None] = Series(me._pl, None)
609 me._pl.nseries += 1
610 else:
611 me._set_mode(MODE_MULTI)
612 series = lookup(me._series, name, "unknown series `%s'" % name)
613 return series
614
615 def _opts_series(me, cmd, opts):
616 name = None
617 for k, v in me._keyvals(opts):
618 if k is None: name = v
619 else: me._bad_keyval(cmd, k, v)
620 return me._get_series(name)
621
622 def _auto_epsrc(me, series):
623 dir = lookup(me._vdirs, series.name, "no active video directory")
624 season = series.ensure_season()
625 check(season.i is not None, "must use explicit iso for movie seasons")
626 vseason = lookup(dir.seasons, season.i,
627 "season %d not found in video dir `%s'" %
628 (season.i, dir.dir))
629 src = lookup(vseason.episodes, season.ep_i,
630 "episode %d.%d not found in video dir `%s'" %
631 (season.i, season.ep_i, dir.dir))
632 return src
633
634 def _process_cmd(me, ww):
635
636 cmd = ww.nextword(); check(cmd is not None, "missing command")
637 try: sep = cmd.index(":")
638 except ValueError: opts = None
639 else: cmd, opts = cmd[:sep], cmd[sep + 1:]
640
641 if cmd == "series":
642 name = None
643 for k, v in me._keyvals(opts):
644 if k is None: name = v
645 else: me._bad_keyval(cmd, k, v)
646 check(name is not None, "missing series name")
647 check(name not in me._series, "series `%s' already defined" % name)
648 title = ww.rest()
649 me._set_mode(MODE_MULTI)
650 me._series[name] = series = Series(me._pl, name, title,
651 me._series_wanted is None or
652 name in me._series_wanted)
653 if series.wantedp: me._pl.nseries += 1
654
655 elif cmd == "season":
656 series = me._opts_series(cmd, opts)
657 w = ww.nextword();
658 check(w is not None, "missing season number")
659 if w == "-":
660 if not series.wantedp: return
661 series.add_movies(ww.rest())
662 else:
663 title = ww.rest(); i = getint(w)
664 if not series.wantedp: return
665 series.add_season(ww.rest(), getint(w), implicitp = False)
666 me._cur_episode = me._cur_chapter = None
667 me._pl.done_season()
668
669 elif cmd == "explen":
670 w = ww.rest(); check(w is not None, "missing duration spec")
671 if w == "-":
672 me._explen, me._expvar = None, DEFAULT_EXPVAR
673 else:
674 d, v = parse_duration(w)
675 me._explen = d
676 if v is not None: me._expvar = v
677
678 elif cmd == "epname":
679 for k, v in me._keyvals(opts): me._bad_keyval("epname", k, v)
680 name = ww.rest(); check(name is not None, "missing episode name")
681 try: sep = name.index("::")
682 except ValueError: names = name + "s"
683 else: name, names = name[:sep], name[sep + 1:]
684 me._pl.epname, me._pl.epnames = name, names
685
686 elif cmd == "epno":
687 series = me._opts_series(cmd, opts)
688 w = ww.rest(); check(w is not None, "missing episode number")
689 epi = getint(w)
690 if not series.wantedp: return
691 series.ensure_season().ep_i = epi
692
693 elif cmd == "iso":
694 series = me._opts_series(cmd, opts)
695 fn = ww.rest(); check(fn is not None, "missing filename")
696 if not series.wantedp: return
697 if fn == "-": forget(me._isos, series.name)
698 else:
699 check(OS.path.exists(OS.path.join(ROOT, fn)),
700 "iso file `%s' not found" % fn)
701 me._isos[series.name] = VideoDisc(fn)
702
703 elif cmd == "vdir":
704 series = me._opts_series(cmd, opts)
705 dir = ww.rest(); check(dir is not None, "missing directory")
706 if not series.wantedp: return
707 if dir == "-": forget(me._vdirs, series.name)
708 else: me._vdirs[series.name] = VideoDir(dir)
709
710 elif cmd == "adir":
711 series = me._opts_series(cmd, opts)
712 dir = ww.rest(); check(dir is not None, "missing directory")
713 if not series.wantedp: return
714 if dir == "-": forget(me._audirs, series.name)
715 else: me._audirs[series.name] = AudioDir(dir)
716
717 elif cmd == "displaced":
718 series = me._opts_series(cmd, opts)
719 w = ww.rest(); check(w is not None, "missing count"); n = getint(w)
720 src = me._auto_epsrc(series)
721 src.nuses += n
722
723 else:
724 raise ExpectedError("unknown command `%s'" % cmd)
725
726 def _process_episode(me, ww):
727
728 opts = ww.nextword(); check(opts is not None, "missing title/options")
729 ti = None; sname = None; neps = 1; epi = None; loch = hich = None
730 explen, expvar, explicitlen = me._explen, me._expvar, False
731 series_title_p = True
732 for k, v in me._keyvals(opts):
733 if k is None:
734 if v.isdigit(): ti = int(v)
735 elif v == "-": ti = "-"
736 else: sname = v
737 elif k == "s": sname = v
738 elif k == "n": neps = getint(v)
739 elif k == "ep": epi = getint(v)
740 elif k == "st": series_title_p = getbool(v)
741 elif k == "l":
742 if v == "-": me._explen, me._expvar = None, DEFAULT_EXPVAR
743 else:
744 explen, expvar = parse_duration(v, explen, expvar)
745 explicitlen = True
746 elif k == "ch":
747 try: sep = v.index("-")
748 except ValueError: loch, hich = getint(v), None
749 else: loch, hich = getint(v[:sep]), getint(v[sep + 1:]) + 1
750 else: raise ExpectedError("unknown episode option `%s'" % k)
751 check(ti is not None, "missing title number")
752 series = me._get_series(sname)
753 me._cur_chapter = None
754
755 title = ww.rest()
756 if not series.wantedp: return
757 season = series.ensure_season()
758 if epi is None: epi = season.ep_i
759
760 if ti == "-":
761 check(season.implicitp or season.i is None,
762 "audio source, but explicit non-movie season")
763 dir = lookup(me._audirs, series.name,
764 "no title, and no audio directory")
765 src = lookup(dir.episodes, season.ep_i,
766 "episode %d not found in audio dir `%s'" % (epi, dir.dir))
767
768 else:
769 try: src = me._isos[series.name]
770 except KeyError: src = me._auto_epsrc(series)
771
772 episode = season.add_episode(epi, neps, title, src,
773 series_title_p, ti, loch, hich)
774
775 if episode.duration != -1 and explen is not None:
776 if not explicitlen: explen *= neps
777 if not explen*(1 - expvar) <= episode.duration <= explen*(1 + expvar):
778 if season.i is None: epid = "episode %d" % epi
779 else: epid = "episode %d.%d" % (season.i, epi)
780 raise ExpectedError \
781 ("%s duration %s %g%% > %g%% from expected %s" %
782 (epid, format_duration(episode.duration),
783 abs(100*(episode.duration - explen)/explen), 100*expvar,
784 format_duration(explen)))
785 me._pl.add_episode(episode)
786 me._cur_episode = episode
787
788 def _process_chapter(me, ww):
789 check(me._cur_episode is not None, "no current episode")
790 check(me._cur_episode.source.CHAPTERP,
791 "episode source doesn't allow chapters")
792 if me._chaptersp:
793 if me._cur_chapter is None: i = 1
794 else: i = me._cur_chapter.i + 1
795 me._cur_chapter = me._cur_episode.add_chapter(ww.rest(), i)
796
797 def parse_file(me, fn):
798 with location(FileLocation(fn, 0)) as floc:
799 with open(fn, "r") as f:
800 for line in f:
801 floc.stepline()
802 sline = line.lstrip()
803 if sline == "" or sline.startswith(";"): continue
804
805 if line.startswith("!"): me._process_cmd(Words(line[1:]))
806 elif not line[0].isspace(): me._process_episode(Words(line))
807 else: me._process_chapter(Words(line))
808 me._pl.done_season()
809
810 def done(me):
811 discs = set()
812 for name, vdir in me._vdirs.items():
813 if not me._series[name].wantedp: continue
814 for s in vdir.seasons.values():
815 for d in s.episodes.values():
816 discs.add(d)
817 for adir in me._audirs.values():
818 for d in adir.episodes.values():
819 discs.add(d)
820 for d in sorted(discs, key = lambda d: d.fn):
821 if d.neps is not None and d.neps != d.nuses:
822 raise ExpectedError("disc `%s' has %d episodes, used %d times" %
823 (d.fn, d.neps, d.nuses))
824 return me._pl
825
826 op = OP.OptionParser \
827 (usage = "%prog [-c] [-M DEPS] [-d CACHE] [-o OUT] [-s SERIES] EPLS\n"
828 "%prog -i -d CACHE",
829 description = "Generate M3U playlists from an episode list.")
830 op.add_option("-M", "--make-deps", metavar = "DEPS",
831 dest = "deps", type = "str", default = None,
832 help = "Write a `make' fragment for dependencies")
833 op.add_option("-c", "--chapters",
834 dest = "chaptersp", action = "store_true", default = False,
835 help = "Output individual chapter names")
836 op.add_option("-i", "--init-db",
837 dest = "initdbp", action = "store_true", default = False,
838 help = "Initialize the database")
839 op.add_option("-d", "--database", metavar = "CACHE",
840 dest = "database", type = "str", default = None,
841 help = "Set filename for cache database")
842 op.add_option("-o", "--output", metavar = "OUT",
843 dest = "output", type = "str", default = None,
844 help = "Write output playlist to OUT")
845 op.add_option("-O", "--fake-output", metavar = "OUT",
846 dest = "fakeout", type = "str", default = None,
847 help = "Pretend output goes to OUT for purposes of `-M'")
848 op.add_option("-s", "--series", metavar = "SERIES",
849 dest = "series", type = "str", default = None,
850 help = "Output only the listed SERIES (comma-separated)")
851 try:
852 opts, argv = op.parse_args()
853
854 if opts.initdbp:
855 if opts.chaptersp or opts.series is not None or \
856 opts.output is not None or opts.deps is not None or \
857 opts.fakeout is not None or \
858 opts.database is None or len(argv):
859 op.print_usage(file = SYS.stderr); SYS.exit(2)
860 setup_db(opts.database)
861
862 else:
863 if len(argv) != 1: op.print_usage(file = SYS.stderr); SYS.exit(2)
864 if opts.database is not None: init_db(opts.database)
865 if opts.series is None:
866 series_wanted = None
867 else:
868 series_wanted = set()
869 for name in opts.series.split(","): series_wanted.add(name)
870 if opts.deps is not None:
871 if (opts.output is None or opts.output == "-") and opts.fakeout is None:
872 raise ExpectedError("can't write dep fragment without output file")
873 if opts.fakeout is None: opts.fakeout = opts.output
874 else:
875 if opts.fakeout is not None:
876 raise ExpectedError("fake output set but no dep fragment")
877
878 ep = EpisodeListParser(series_wanted, opts.chaptersp)
879 ep.parse_file(argv[0])
880 pl = ep.done()
881
882 if opts.output is None or opts.output == "-":
883 pl.write(SYS.stdout)
884 else:
885 with open(opts.output, "w") as f: pl.write(f)
886
887 if opts.deps:
888 if opts.deps == "-":
889 pl.write_deps(SYS.stdout, opts.fakeout)
890 else:
891 with open(opts.deps, "w") as f: pl.write_deps(f, opts.fakeout)
892
893 except (ExpectedError, IOError, OSError) as e:
894 LOC.report(e)
895 SYS.exit(2)