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 | |
8 | import sys as SYS | |
9 | ||
10 | class ExpectedError (Exception): pass | |
11 | ||
12 | @contextmanager | |
13 | def location(loc): | |
14 | global LOC | |
15 | old, LOC = LOC, loc | |
16 | yield loc | |
17 | LOC = old | |
18 | ||
19 | def filter(value, func = None, dflt = None): | |
20 | if value is None: return dflt | |
21 | elif func is None: return value | |
22 | else: return func(value) | |
23 | ||
24 | def check(cond, msg): | |
25 | if not cond: raise ExpectedError(msg) | |
26 | ||
151b3c4f MW |
27 | def lookup(dict, key, msg): |
28 | try: return dict[key] | |
29 | except KeyError: raise ExpectedError(msg) | |
30 | ||
31 | def forget(dict, key): | |
32 | try: del dict[key] | |
33 | except KeyError: pass | |
34 | ||
04a05f7f MW |
35 | def getint(s): |
36 | if not s.isdigit(): raise ExpectedError("bad integer `%s'" % s) | |
37 | return int(s) | |
38 | ||
39 | class Words (object): | |
40 | def __init__(me, s): | |
41 | me._s = s | |
42 | me._i, me._n = 0, len(s) | |
43 | def _wordstart(me): | |
44 | s, i, n = me._s, me._i, me._n | |
45 | while i < n: | |
46 | if not s[i].isspace(): return i | |
47 | i += 1 | |
48 | return -1 | |
49 | def nextword(me): | |
50 | s, n = me._s, me._n | |
51 | begin = i = me._wordstart() | |
52 | if begin < 0: return None | |
53 | while i < n and not s[i].isspace(): i += 1 | |
54 | me._i = i | |
55 | return s[begin:i] | |
56 | def rest(me): | |
57 | s, n = me._s, me._n | |
58 | begin = me._wordstart() | |
59 | if begin < 0: return None | |
60 | else: return s[begin:].rstrip() | |
61 | ||
62 | URL_SAFE_P = 256*[False] | |
63 | for ch in \ | |
64 | b"ABCDEFGHIJKLMNOPQRSTUVWXYZ" \ | |
65 | b"abcdefghijklmnopqrstuvwxyz" \ | |
66 | b"0123456789" b"!$%-.,/": | |
67 | URL_SAFE_P[ch] = True | |
68 | def urlencode(s): | |
69 | return "".join((URL_SAFE_P[ch] and chr(ch) or "%%%02x" % ch | |
70 | for ch in s.encode("UTF-8"))) | |
71 | ||
72 | PROG = OS.path.basename(SYS.argv[0]) | |
73 | ||
74 | class BaseLocation (object): | |
75 | def report(me, exc): | |
76 | SYS.stderr.write("%s: %s%s\n" % (PROG, me._loc(), exc)) | |
77 | ||
78 | class DummyLocation (BaseLocation): | |
79 | def _loc(me): return "" | |
80 | ||
81 | class FileLocation (BaseLocation): | |
82 | def __init__(me, fn, lno = 1): me._fn, me._lno = fn, lno | |
83 | def _loc(me): return "%s:%d: " % (me._fn, me._lno) | |
84 | def stepline(me): me._lno += 1 | |
85 | ||
86 | LOC = DummyLocation() | |
87 | ||
88 | class Source (object): | |
89 | PREFIX = "" | |
90 | TITLEP = CHAPTERP = False | |
91 | def __init__(me, fn): | |
92 | me.fn = fn | |
b092d511 MW |
93 | me.neps = None |
94 | me.used_titles = dict() | |
95 | me.used_chapters = set() | |
96 | me.nuses = 0 | |
04a05f7f | 97 | def url(me, title = None, chapter = None): |
151b3c4f | 98 | if title == "-": |
04a05f7f MW |
99 | if me.TITLEP: raise ExpectedError("missing title number") |
100 | if chapter is not None: | |
101 | raise ExpectedError("can't specify chapter without title") | |
102 | suffix = "" | |
103 | elif not me.TITLEP: | |
104 | raise ExpectedError("can't specify title with `%s'" % me.fn) | |
105 | elif chapter is None: | |
106 | suffix = "#%d" % title | |
107 | elif not me.CHAPTERP: | |
108 | raise ExpectedError("can't specify chapter with `%s'" % me.fn) | |
109 | else: | |
110 | suffix = "#%d:%d-%d:%d" % (title, chapter, title, chapter) | |
b092d511 MW |
111 | if chapter is not None: key, set = (title, chapter), me.used_chapters |
112 | else: key, set = title, me.used_titles | |
113 | if key in set: | |
151b3c4f | 114 | if title == "-": |
b092d511 MW |
115 | raise ExpectedError("`%s' already used" % me.fn) |
116 | elif chapter is None: | |
117 | raise ExpectedError("`%s' title %d already used" % (me.fn, title)) | |
118 | else: | |
119 | raise ExpectedError("`%s' title %d chapter %d already used" % | |
151b3c4f | 120 | (me.fn, title, chapter)) |
b092d511 | 121 | if chapter is not None: me.used_chapters.add((title, chapter)) |
04a05f7f MW |
122 | return me.PREFIX + ROOT + urlencode(me.fn) + suffix |
123 | ||
124 | class VideoDisc (Source): | |
125 | PREFIX = "dvd://" | |
126 | TITLEP = CHAPTERP = True | |
127 | ||
b092d511 MW |
128 | def __init__(me, fn, *args, **kw): |
129 | super().__init__(fn, *args, **kw) | |
130 | me.neps = 0 | |
131 | ||
04a05f7f MW |
132 | class VideoSeason (object): |
133 | def __init__(me, i, title): | |
134 | me.i = i | |
135 | me.title = title | |
136 | me.episodes = {} | |
32cd109c MW |
137 | def set_episode_disc(me, i, disc): |
138 | if i in me.episodes: | |
139 | raise ExpectedError("season %d episode %d already taken" % (me.i, i)) | |
b092d511 | 140 | me.episodes[i] = disc; disc.neps += 1 |
04a05f7f MW |
141 | |
142 | def some_group(m, *gg): | |
143 | for g in gg: | |
144 | s = m.group(g) | |
145 | if s is not None: return s | |
146 | return None | |
147 | ||
148 | class VideoDir (object): | |
149 | ||
9fc467bb | 150 | _R_ISO_PRE = RX.compile(r""" ^ |
35ecb6eb MW |
151 | (?: S (?P<si> \d+) |
152 | (?: \. \ (?P<st> .*) — (?: D \d+ \. \ )? | | |
153 | D \d+ \. \ | | |
154 | (?= E \d+ \. \ ) | | |
155 | \. \ ) | | |
156 | \d+ \. \ ) | |
157 | (?: (?P<eplist> | |
158 | (?: S \d+ \ )? E \d+ (?: – \d+)? | |
159 | (?: , \ (?: S \d+ \ )? E \d+ (?: – \d+)?)*) | | |
160 | (?P<epname> E \d+) \. \ .*) | |
04a05f7f MW |
161 | \. iso $ |
162 | """, RX.X) | |
163 | ||
9fc467bb | 164 | _R_ISO_EP = RX.compile(r""" ^ |
6b5cec73 | 165 | (?: S (?P<si> \d+) \ )? |
9fc467bb | 166 | E (?P<ei> \d+) (?: – (?P<ej> \d+))? $ |
04a05f7f MW |
167 | """, RX.X) |
168 | ||
169 | def __init__(me, dir): | |
b092d511 | 170 | me.dir = dir |
04a05f7f MW |
171 | fns = OS.listdir(OS.path.join(ROOT, dir)) |
172 | fns.sort() | |
6b5cec73 | 173 | season = None |
04a05f7f MW |
174 | seasons = {} |
175 | for fn in fns: | |
176 | path = OS.path.join(dir, fn) | |
177 | if not fn.endswith(".iso"): continue | |
178 | m = me._R_ISO_PRE.match(fn) | |
065a5db6 MW |
179 | if not m: |
180 | #print(";; `%s' ignored" % path, file = SYS.stderr) | |
181 | continue | |
04a05f7f | 182 | |
6b5cec73 | 183 | i = filter(m.group("si"), int) |
04a05f7f | 184 | stitle = m.group("st") |
6b5cec73 MW |
185 | check(i is not None or stitle is None, |
186 | "explicit season title without number in `%s'" % fn) | |
187 | if i is not None: | |
188 | if season is None or i != season.i: | |
189 | check(season is None or i == season.i + 1, | |
190 | "season %d /= %d" % | |
191 | (i, season is None and -1 or season.i + 1)) | |
192 | check(i not in seasons, "season %d already seen" % i) | |
193 | seasons[i] = season = VideoSeason(i, stitle) | |
194 | else: | |
195 | check(stitle == season.title, | |
196 | "season title `%s' /= `%s'" % (stitle, season.title)) | |
04a05f7f | 197 | |
32cd109c | 198 | disc = VideoDisc(path) |
6b5cec73 | 199 | ts = season |
32cd109c | 200 | any, bad = False, False |
35ecb6eb MW |
201 | epname = m.group("epname") |
202 | if epname is not None: eplist = [epname] | |
203 | else: eplist = m.group("eplist").split(", ") | |
204 | for eprange in eplist: | |
04a05f7f MW |
205 | mm = me._R_ISO_EP.match(eprange) |
206 | if mm is None: bad = True; continue | |
065a5db6 MW |
207 | if not any: |
208 | #print(";; `%s'" % path, file = SYS.stderr) | |
209 | any = True | |
6b5cec73 MW |
210 | i = filter(mm.group("si"), int) |
211 | if i is not None: | |
212 | try: ts = seasons[i] | |
213 | except KeyError: ts = seasons[i] = VideoSeason(i, None) | |
214 | if ts is None: | |
215 | ts = season = seasons[1] = VideoSeason(1, None) | |
04a05f7f MW |
216 | start = filter(mm.group("ei"), int) |
217 | end = filter(mm.group("ej"), int, start) | |
32cd109c | 218 | for k in range(start, end + 1): |
6b5cec73 | 219 | ts.set_episode_disc(k, disc) |
065a5db6 MW |
220 | #print(";;\tepisode %d.%d" % (ts.i, k), file = SYS.stderr) |
221 | if not any: pass #print(";; `%s' ignored" % path, file = SYS.stderr) | |
222 | elif bad: raise ExpectedError("bad ep list in `%s'", fn) | |
04a05f7f MW |
223 | me.seasons = seasons |
224 | ||
225 | class AudioDisc (Source): | |
fbac3340 | 226 | PREFIX = "file://" |
04a05f7f MW |
227 | TITLEP = CHAPTERP = False |
228 | ||
229 | class AudioEpisode (Source): | |
fbac3340 | 230 | PREFIX = "file://" |
04a05f7f MW |
231 | TITLEP = CHAPTERP = False |
232 | def __init__(me, fn, i, *args, **kw): | |
233 | super().__init__(fn, *args, **kw) | |
234 | me.i = i | |
235 | ||
236 | class AudioDir (object): | |
237 | ||
9fc467bb | 238 | _R_FLAC = RX.compile(r""" ^ |
04a05f7f MW |
239 | E (\d+) |
240 | (?: \. \ (.*))? | |
241 | \. flac $ | |
242 | """, RX.X) | |
243 | ||
244 | def __init__(me, dir): | |
b092d511 | 245 | me.dir = dir |
04a05f7f MW |
246 | fns = OS.listdir(OS.path.join(ROOT, dir)) |
247 | fns.sort() | |
248 | episodes = {} | |
249 | last_i = 0 | |
250 | for fn in fns: | |
251 | path = OS.path.join(dir, fn) | |
252 | if not fn.endswith(".flac"): continue | |
253 | m = me._R_FLAC.match(fn) | |
254 | if not m: continue | |
255 | i = filter(m.group(1), int) | |
256 | etitle = m.group(2) | |
257 | check(i == last_i + 1, "episode %d /= %d" % (i, last_i + 1)) | |
258 | episodes[i] = AudioEpisode(path, i) | |
259 | last_i = i | |
260 | me.episodes = episodes | |
261 | ||
262 | class Chapter (object): | |
263 | def __init__(me, episode, title, i): | |
264 | me.title, me.i = title, i | |
265 | me.url = episode.source.url(episode.tno, i) | |
266 | ||
267 | class Episode (object): | |
268 | def __init__(me, season, i, neps, title, src, tno = None): | |
269 | me.season = season | |
270 | me.i, me.neps, me.title = i, neps, title | |
271 | me.chapters = [] | |
272 | me.source, me.tno = src, tno | |
273 | me.url = src.url(tno) | |
274 | def add_chapter(me, title, j): | |
275 | ch = Chapter(me, title, j) | |
276 | me.chapters.append(ch) | |
277 | return ch | |
278 | def label(me): | |
279 | return me.season._eplabel(me.i, me.neps, me.title) | |
280 | ||
151b3c4f MW |
281 | class BaseSeason (object): |
282 | def __init__(me, series, implicitp = False): | |
283 | me.series = series | |
04a05f7f | 284 | me.episodes = [] |
151b3c4f MW |
285 | me.implicitp = implicitp |
286 | me.ep_i, episodes = 1, [] | |
04a05f7f MW |
287 | def add_episode(me, j, neps, title, src, tno): |
288 | ep = Episode(me, j, neps, title, src, tno) | |
289 | me.episodes.append(ep) | |
151b3c4f | 290 | src.nuses += neps; me.ep_i += neps |
04a05f7f | 291 | return ep |
151b3c4f MW |
292 | |
293 | class Season (BaseSeason): | |
294 | def __init__(me, series, title, i, *args, **kw): | |
295 | super().__init__(series, *args, **kw) | |
296 | me.title, me.i = title, i | |
04a05f7f | 297 | def _eplabel(me, i, neps, title): |
151b3c4f MW |
298 | playlist = me.series.playlist |
299 | if neps == 1: epname = playlist.epname; epn = "%d" % i | |
300 | elif neps == 2: epname = playlist.epnames; epn = "%d, %d" % (i, i + 1) | |
301 | else: epname = playlist.epnames; epn = "%d–%d" % (i, i + neps - 1) | |
04a05f7f | 302 | if title is None: |
151b3c4f MW |
303 | if me.implicitp: label = "%s %s" % (epname, epn) |
304 | elif me.title is None: label = "%s %d.%s" % (epname, me.i, epn) | |
305 | else: label = "%s—%s %s" % (me.title, epname, epn) | |
04a05f7f | 306 | else: |
151b3c4f MW |
307 | if me.implicitp: label = "%s. %s" % (epn, title) |
308 | elif me.title is None: label = "%d.%s. %s" % (me.i, epn, title) | |
309 | else: label = "%s—%s. %s" % (me.title, epn, title) | |
310 | return label | |
04a05f7f | 311 | |
151b3c4f | 312 | class MovieSeason (BaseSeason): |
04a05f7f MW |
313 | def add_episode(me, j, neps, title, src, tno): |
314 | if title is None: raise ExpectedError("movie must have a title") | |
151b3c4f | 315 | return super().add_episode(j, neps, title, src, tno) |
04a05f7f MW |
316 | def _eplabel(me, i, epn, title): |
317 | return title | |
318 | ||
151b3c4f MW |
319 | class Series (object): |
320 | def __init__(me, playlist, title = None): | |
321 | me.playlist = playlist | |
322 | me.title = title | |
323 | me.cur_season = None | |
324 | def _add_season(me, season): | |
325 | me.cur_season = season | |
326 | def add_season(me, title, i, implicitp = False): | |
327 | me._add_season(Season(me, title, i, implicitp)) | |
328 | def add_movies(me): | |
329 | me._add_season(MovieSeason(me)) | |
330 | def ensure_season(me): | |
331 | if me.cur_season is None: me.add_season(None, 1, implicitp = True) | |
332 | return me.cur_season | |
333 | def end_season(me): | |
334 | me.cur_season = None | |
04a05f7f | 335 | |
151b3c4f | 336 | class Playlist (object): |
04a05f7f MW |
337 | def __init__(me): |
338 | me.seasons = [] | |
151b3c4f | 339 | me.episodes = [] |
04a05f7f | 340 | me.epname, me.epnames = "Episode", "Episodes" |
151b3c4f MW |
341 | me.nseries = 0 |
342 | def add_episode(me, episode): | |
343 | me.episodes.append(episode) | |
344 | def done_season(me): | |
345 | if me.episodes: | |
346 | me.seasons.append(me.episodes) | |
347 | me.episodes = [] | |
04a05f7f MW |
348 | def write(me, f): |
349 | f.write("#EXTM3U\n") | |
350 | for season in me.seasons: | |
351 | f.write("\n") | |
151b3c4f MW |
352 | for ep in season: |
353 | label = ep.label() | |
354 | if me.nseries > 1: label = ep.season.series.title + " " + label | |
04a05f7f | 355 | if not ep.chapters: |
151b3c4f | 356 | f.write("#EXTINF:0,,%s\n%s\n" % (label, ep.url)) |
04a05f7f MW |
357 | else: |
358 | for ch in ep.chapters: | |
359 | f.write("#EXTINF:0,,%s: %s\n%s\n" % | |
151b3c4f MW |
360 | (label, ch.title, ch.url)) |
361 | ||
362 | MODE_UNSET = 0 | |
363 | MODE_SINGLE = 1 | |
364 | MODE_MULTI = 2 | |
365 | ||
366 | class EpisodeListParser (object): | |
367 | ||
368 | def __init__(me, series_wanted = None, chapters_wanted_p = False): | |
369 | me._pl = Playlist() | |
370 | me._cur_episode = me._cur_chapter = None | |
371 | me._series = {}; me._vdirs = {}; me._audirs = {}; me._isos = {} | |
372 | me._series_wanted = series_wanted | |
373 | me._chaptersp = chapters_wanted_p | |
374 | if series_wanted is None: me._mode = MODE_UNSET | |
375 | else: me._mode = MODE_MULTI | |
376 | ||
377 | def _bad_keyval(me, cmd, k, v): | |
378 | raise ExpectedError("invalid `!%s' option `%s'" % | |
379 | (cmd, v if k is None else k)) | |
380 | ||
381 | def _keyvals(me, opts): | |
382 | if opts is not None: | |
383 | for kv in opts.split(","): | |
384 | try: sep = kv.index("=") | |
385 | except ValueError: yield None, kv | |
386 | else: yield kv[:sep], kv[sep + 1:] | |
387 | ||
388 | def _set_mode(me, mode): | |
389 | if me._mode == MODE_UNSET: | |
390 | me._mode = mode | |
391 | elif me._mode != mode: | |
392 | raise ExpectedError("inconsistent single-/multi-series usage") | |
393 | ||
394 | def _get_series(me, name): | |
395 | if name is None: | |
396 | me._set_mode(MODE_SINGLE) | |
397 | try: series = me._series[None] | |
398 | except KeyError: | |
399 | series = me._series[None] = Series(me._pl) | |
400 | me._pl.nseries += 1 | |
401 | else: | |
402 | me._set_mode(MODE_MULTI) | |
403 | series = lookup(me._series, name, "unknown series `%s'" % name) | |
404 | return series | |
405 | ||
406 | def _opts_series(me, cmd, opts): | |
407 | name = None | |
408 | for k, v in me._keyvals(opts): | |
409 | if k is None: name = v | |
410 | else: me._bad_keyval(cmd, k, v) | |
411 | return me._get_series(name), name | |
412 | ||
413 | def _wantedp(me, name): | |
414 | return me._series_wanted is None or name in me._series_wanted | |
415 | ||
416 | def _process_cmd(me, ww): | |
417 | ||
418 | cmd = ww.nextword(); check(cmd is not None, "missing command") | |
419 | try: sep = cmd.index(":") | |
420 | except ValueError: opts = None | |
421 | else: cmd, opts = cmd[:sep], cmd[sep + 1:] | |
422 | ||
423 | if cmd == "series": | |
424 | name = None | |
425 | for k, v in me._keyvals(opts): | |
426 | if k is None: name = v | |
427 | else: me._bad_keyval(cmd, k, v) | |
428 | check(name is not None, "missing series name") | |
429 | check(name not in me._series, "series `%s' already defined" % name) | |
430 | title = ww.rest(); check(title is not None, "missing title") | |
431 | me._set_mode(MODE_MULTI) | |
432 | me._series[name] = Series(me._pl, title) | |
433 | if me._wantedp(name): me._pl.nseries += 1 | |
434 | ||
435 | elif cmd == "season": | |
436 | series, sname = me._opts_series(cmd, opts) | |
437 | w = ww.nextword(); | |
438 | check(w is not None, "missing season number") | |
439 | if w == "-": | |
440 | check(ww.rest() is None, "trailing junk") | |
441 | if not me._wantedp(sname): return | |
442 | series.add_movies() | |
443 | else: | |
444 | title = ww.rest(); i = getint(w) | |
445 | if not me._wantedp(sname): return | |
446 | series.add_season(ww.rest(), getint(w), implicitp = False) | |
447 | me._cur_episode = me._cur_chapter = None | |
448 | me._pl.done_season() | |
449 | ||
450 | elif cmd == "epname": | |
451 | for k, v in me._keyvals(opts): me._bad_keyval("epname", k, v) | |
452 | name = ww.rest(); check(name is not None, "missing episode name") | |
453 | try: sep = name.index(",") | |
454 | except ValueError: names = name + "s" | |
455 | else: name, names = name[:sep], name[sep + 1:] | |
456 | me._pl.epname, me._pl.epnames = name, names | |
457 | ||
458 | elif cmd == "epno": | |
459 | series, sname = me._opts_series(cmd, opts) | |
460 | w = ww.rest(); check(w is not None, "missing episode number") | |
461 | epi = getint(w) | |
462 | if not me._wantedp(sname): return | |
463 | series.ensure_season().ep_i = epi | |
464 | ||
465 | elif cmd == "iso": | |
466 | _, name = me._opts_series(cmd, opts) | |
467 | fn = ww.rest(); check(fn is not None, "missing filename") | |
468 | if not me._wantedp(name): return | |
469 | if fn == "-": forget(me._isos, name) | |
470 | else: | |
471 | check(OS.path.exists(OS.path.join(ROOT, fn)), | |
472 | "iso file `%s' not found" % fn) | |
473 | me._isos[name] = VideoDisc(fn) | |
474 | ||
475 | elif cmd == "vdir": | |
476 | _, name = me._opts_series(cmd, opts) | |
477 | dir = ww.rest(); check(dir is not None, "missing directory") | |
478 | if not me._wantedp(name): return | |
479 | if dir == "-": forget(me._vdirs, name) | |
480 | else: me._vdirs[name] = VideoDir(dir) | |
481 | ||
482 | elif cmd == "adir": | |
483 | _, name = me._opts_series(cmd, opts) | |
484 | dir = ww.rest(); check(dir is not None, "missing directory") | |
485 | if not me._wantedp(name): return | |
486 | if dir == "-": forget(me._audirs, name) | |
487 | else: me._audirs[name] = AudioDir(dir) | |
04a05f7f | 488 | |
151b3c4f MW |
489 | else: |
490 | raise ExpectedError("unknown command `%s'" % cmd) | |
491 | ||
492 | def _process_episode(me, ww): | |
493 | ||
494 | opts = ww.nextword(); check(opts is not None, "missing title/options") | |
495 | ti = None; sname = None; neps = 1; epi = None | |
496 | for k, v in me._keyvals(opts): | |
497 | if k is None: | |
498 | if v.isdigit(): ti = int(v) | |
499 | elif v == "-": ti = "-" | |
500 | else: sname = v | |
501 | elif k == "s": sname = v | |
502 | elif k == "n": neps = getint(v) | |
503 | elif k == "ep": epi = getint(v) | |
504 | else: raise ExpectedError("unknown episode option `%s'" % k) | |
505 | check(ti is not None, "missing title number") | |
506 | series = me._get_series(sname) | |
507 | me._cur_chapter = None | |
508 | ||
509 | title = ww.rest() | |
510 | if not me._wantedp(sname): return | |
511 | season = series.ensure_season() | |
512 | if epi is None: epi = season.ep_i | |
513 | ||
514 | if ti == "-": | |
515 | check(season.implicitp, "audio source, but explicit season") | |
516 | if not me._wantedp(sname): return | |
517 | dir = lookup(me._audirs, sname, "no title, and no audio directory") | |
518 | src = lookup(dir.episodes, season.ep_i, | |
519 | "episode %d not found in audio dir `%s'" % (epi, dir.dir)) | |
04a05f7f | 520 | |
151b3c4f MW |
521 | else: |
522 | try: src = me._isos[sname] | |
523 | except KeyError: | |
524 | dir = lookup(me._vdirs, sname, | |
525 | "title, but no iso or video directory") | |
526 | vseason = lookup(dir.seasons, season.i, | |
527 | "season %d not found in video dir `%s'" % | |
528 | (season.i, dir.dir)) | |
529 | src = lookup(vseason.episodes, season.ep_i, | |
530 | "episode %d.%d not found in video dir `%s'" % | |
531 | (season.i, season.ep_i, dir.dir)) | |
532 | ||
533 | episode = season.add_episode(epi, neps, title, src, ti) | |
534 | me._pl.add_episode(episode) | |
535 | me._cur_episode = episode | |
536 | ||
537 | def _process_chapter(me, ww): | |
538 | check(me._cur_episode is not None, "no current episode") | |
539 | check(me._cur_episode.source.CHAPTERP, | |
540 | "episode source doesn't allow chapters") | |
541 | if me._chaptersp: | |
542 | if me._cur_chapter is None: i = 1 | |
543 | else: i = me._cur_chapter.i + 1 | |
544 | me._cur_chapter = me._cur_episode.add_chapter(ww.rest(), i) | |
545 | ||
546 | def parse_file(me, fn): | |
547 | with location(FileLocation(fn, 0)) as floc: | |
548 | with open(fn, "r") as f: | |
549 | for line in f: | |
550 | floc.stepline() | |
551 | sline = line.lstrip() | |
552 | if sline == "" or sline.startswith(";"): continue | |
553 | ||
554 | if line.startswith("!"): me._process_cmd(Words(line[1:])) | |
555 | elif not line[0].isspace(): me._process_episode(Words(line)) | |
556 | else: me._process_chapter(Words(line)) | |
557 | me._pl.done_season() | |
558 | ||
559 | def done(me): | |
560 | discs = set() | |
561 | for name, vdir in me._vdirs.items(): | |
562 | if not me._wantedp(name): continue | |
563 | for s in vdir.seasons.values(): | |
564 | for d in s.episodes.values(): | |
565 | discs.add(d) | |
566 | for adir in me._audirs.values(): | |
567 | for d in adir.episodes.values(): | |
b092d511 | 568 | discs.add(d) |
151b3c4f MW |
569 | for d in sorted(discs, key = lambda d: d.fn): |
570 | if d.neps is not None and d.neps != d.nuses: | |
571 | raise ExpectedError("disc `%s' has %d episodes, used %d times" % | |
572 | (d.fn, d.neps, d.nuses)) | |
573 | return me._pl | |
04a05f7f MW |
574 | |
575 | ROOT = "/mnt/dvd/archive/" | |
576 | ||
151b3c4f MW |
577 | op = OP.OptionParser \ |
578 | (usage = "%prog [-c] [-s SERIES] EPLS", | |
579 | description = "Generate M3U playlists from an episode list.") | |
580 | op.add_option("-c", "--chapters", | |
581 | dest = "chaptersp", action = "store_true", default = False, | |
582 | help = "Output individual chapter names") | |
583 | op.add_option("-s", "--series", | |
584 | dest = "series", type = "str", default = None, | |
585 | help = "Output only the listed SERIES (comma-separated)") | |
586 | opts, argv = op.parse_args() | |
587 | if len(argv) != 1: op.print_usage(file = SYS.stderr); SYS.exit(2) | |
588 | if opts.series is None: | |
589 | series_wanted = None | |
590 | else: | |
591 | series_wanted = set() | |
592 | for name in opts.series.split(","): series_wanted.add(name) | |
04a05f7f | 593 | try: |
151b3c4f MW |
594 | ep = EpisodeListParser(series_wanted, opts.chaptersp) |
595 | ep.parse_file(argv[0]) | |
596 | pl = ep.done() | |
597 | pl.write(SYS.stdout) | |
04a05f7f MW |
598 | except (ExpectedError, IOError, OSError) as e: |
599 | LOC.report(e) | |
600 | SYS.exit(2) |