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