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