Commit | Line | Data |
---|---|---|
04a05f7f MW |
1 | #! /usr/bin/python3 |
2 | ### -*- mode: python; coding: utf-8 -*- | |
3 | ||
4 | from contextlib import contextmanager | |
151b3c4f | 5 | import optparse as OP |
04a05f7f MW |
6 | import os as OS |
7 | import re as RX | |
1bec83d0 | 8 | import subprocess as SP |
04a05f7f MW |
9 | import sys as SYS |
10 | ||
11 | class ExpectedError (Exception): pass | |
12 | ||
13 | @contextmanager | |
14 | def location(loc): | |
15 | global LOC | |
16 | old, LOC = LOC, loc | |
17 | yield loc | |
18 | LOC = old | |
19 | ||
20 | def 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 | ||
25 | def check(cond, msg): | |
26 | if not cond: raise ExpectedError(msg) | |
27 | ||
151b3c4f MW |
28 | def lookup(dict, key, msg): |
29 | try: return dict[key] | |
30 | except KeyError: raise ExpectedError(msg) | |
31 | ||
32 | def forget(dict, key): | |
33 | try: del dict[key] | |
34 | except KeyError: pass | |
35 | ||
04a05f7f MW |
36 | def getint(s): |
37 | if not s.isdigit(): raise ExpectedError("bad integer `%s'" % s) | |
38 | return int(s) | |
39 | ||
40 | class 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 |
63 | def 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 |
69 | URL_SAFE_P = 256*[False] |
70 | for ch in \ | |
71 | b"ABCDEFGHIJKLMNOPQRSTUVWXYZ" \ | |
72 | b"abcdefghijklmnopqrstuvwxyz" \ | |
73 | b"0123456789" b"!$%-.,/": | |
74 | URL_SAFE_P[ch] = True | |
75 | def urlencode(s): | |
76 | return "".join((URL_SAFE_P[ch] and chr(ch) or "%%%02x" % ch | |
77 | for ch in s.encode("UTF-8"))) | |
78 | ||
79 | PROG = OS.path.basename(SYS.argv[0]) | |
80 | ||
81 | class BaseLocation (object): | |
82 | def report(me, exc): | |
83 | SYS.stderr.write("%s: %s%s\n" % (PROG, me._loc(), exc)) | |
84 | ||
85 | class DummyLocation (BaseLocation): | |
86 | def _loc(me): return "" | |
87 | ||
88 | class 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 | ||
93 | LOC = DummyLocation() | |
94 | ||
95 | class 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 | |
147 | class 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 |
175 | class 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 |
185 | def 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 | |
193 | class 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 | ||
281 | class 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 | 292 | class AudioEpisode (AudioDisc): |
04a05f7f MW |
293 | def __init__(me, fn, i, *args, **kw): |
294 | super().__init__(fn, *args, **kw) | |
295 | me.i = i | |
296 | ||
297 | class 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 | ||
323 | class 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 | |
329 | class 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 |
344 | class 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 | |
361 | class 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 | 385 | class 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 | 404 | class 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 | 422 | class 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 |
451 | DEFAULT_EXPVAR = 0.05 |
452 | R_DURMULT = RX.compile(r""" ^ | |
453 | (\d+ (?: \. \d+)?) x | |
454 | $ """, RX.X) | |
455 | R_DUR = RX.compile(r""" ^ | |
456 | (?: (?: (\d+) :)? (\d+) :)? (\d+) | |
457 | (?: / (\d+ (?: \. \d+)?) \%)? | |
458 | $ """, RX.X) | |
459 | def 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 | |
469 | def 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 |
474 | MODE_UNSET = 0 |
475 | MODE_SINGLE = 1 | |
476 | MODE_MULTI = 2 | |
477 | ||
478 | class 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) | |
028b4b51 | 552 | title = ww.rest() |
151b3c4f | 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 | |
723 | ROOT = "/mnt/dvd/archive/" | |
724 | ||
151b3c4f MW |
725 | op = OP.OptionParser \ |
726 | (usage = "%prog [-c] [-s SERIES] EPLS", | |
727 | description = "Generate M3U playlists from an episode list.") | |
728 | op.add_option("-c", "--chapters", | |
729 | dest = "chaptersp", action = "store_true", default = False, | |
730 | help = "Output individual chapter names") | |
731 | op.add_option("-s", "--series", | |
732 | dest = "series", type = "str", default = None, | |
733 | help = "Output only the listed SERIES (comma-separated)") | |
734 | opts, argv = op.parse_args() | |
735 | if len(argv) != 1: op.print_usage(file = SYS.stderr); SYS.exit(2) | |
736 | if opts.series is None: | |
737 | series_wanted = None | |
738 | else: | |
739 | series_wanted = set() | |
740 | for name in opts.series.split(","): series_wanted.add(name) | |
04a05f7f | 741 | try: |
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 |
746 | except (ExpectedError, IOError, OSError) as e: |
747 | LOC.report(e) | |
748 | SYS.exit(2) |