Commit | Line | Data |
---|---|---|
04a05f7f MW |
1 | #! /usr/bin/python3 |
2 | ### -*- mode: python; coding: utf-8 -*- | |
3 | ||
4 | from contextlib import contextmanager | |
5 | import os as OS | |
6 | import re as RX | |
7 | import sys as SYS | |
8 | ||
9 | class ExpectedError (Exception): pass | |
10 | ||
11 | @contextmanager | |
12 | def location(loc): | |
13 | global LOC | |
14 | old, LOC = LOC, loc | |
15 | yield loc | |
16 | LOC = old | |
17 | ||
18 | def filter(value, func = None, dflt = None): | |
19 | if value is None: return dflt | |
20 | elif func is None: return value | |
21 | else: return func(value) | |
22 | ||
23 | def check(cond, msg): | |
24 | if not cond: raise ExpectedError(msg) | |
25 | ||
26 | def getint(s): | |
27 | if not s.isdigit(): raise ExpectedError("bad integer `%s'" % s) | |
28 | return int(s) | |
29 | ||
30 | class Words (object): | |
31 | def __init__(me, s): | |
32 | me._s = s | |
33 | me._i, me._n = 0, len(s) | |
34 | def _wordstart(me): | |
35 | s, i, n = me._s, me._i, me._n | |
36 | while i < n: | |
37 | if not s[i].isspace(): return i | |
38 | i += 1 | |
39 | return -1 | |
40 | def nextword(me): | |
41 | s, n = me._s, me._n | |
42 | begin = i = me._wordstart() | |
43 | if begin < 0: return None | |
44 | while i < n and not s[i].isspace(): i += 1 | |
45 | me._i = i | |
46 | return s[begin:i] | |
47 | def rest(me): | |
48 | s, n = me._s, me._n | |
49 | begin = me._wordstart() | |
50 | if begin < 0: return None | |
51 | else: return s[begin:].rstrip() | |
52 | ||
53 | URL_SAFE_P = 256*[False] | |
54 | for ch in \ | |
55 | b"ABCDEFGHIJKLMNOPQRSTUVWXYZ" \ | |
56 | b"abcdefghijklmnopqrstuvwxyz" \ | |
57 | b"0123456789" b"!$%-.,/": | |
58 | URL_SAFE_P[ch] = True | |
59 | def urlencode(s): | |
60 | return "".join((URL_SAFE_P[ch] and chr(ch) or "%%%02x" % ch | |
61 | for ch in s.encode("UTF-8"))) | |
62 | ||
63 | PROG = OS.path.basename(SYS.argv[0]) | |
64 | ||
65 | class BaseLocation (object): | |
66 | def report(me, exc): | |
67 | SYS.stderr.write("%s: %s%s\n" % (PROG, me._loc(), exc)) | |
68 | ||
69 | class DummyLocation (BaseLocation): | |
70 | def _loc(me): return "" | |
71 | ||
72 | class FileLocation (BaseLocation): | |
73 | def __init__(me, fn, lno = 1): me._fn, me._lno = fn, lno | |
74 | def _loc(me): return "%s:%d: " % (me._fn, me._lno) | |
75 | def stepline(me): me._lno += 1 | |
76 | ||
77 | LOC = DummyLocation() | |
78 | ||
79 | class Source (object): | |
80 | PREFIX = "" | |
81 | TITLEP = CHAPTERP = False | |
82 | def __init__(me, fn): | |
83 | me.fn = fn | |
b092d511 MW |
84 | me.neps = None |
85 | me.used_titles = dict() | |
86 | me.used_chapters = set() | |
87 | me.nuses = 0 | |
04a05f7f MW |
88 | def url(me, title = None, chapter = None): |
89 | if title is None: | |
90 | if me.TITLEP: raise ExpectedError("missing title number") | |
91 | if chapter is not None: | |
92 | raise ExpectedError("can't specify chapter without title") | |
93 | suffix = "" | |
94 | elif not me.TITLEP: | |
95 | raise ExpectedError("can't specify title with `%s'" % me.fn) | |
96 | elif chapter is None: | |
97 | suffix = "#%d" % title | |
98 | elif not me.CHAPTERP: | |
99 | raise ExpectedError("can't specify chapter with `%s'" % me.fn) | |
100 | else: | |
101 | suffix = "#%d:%d-%d:%d" % (title, chapter, title, chapter) | |
b092d511 MW |
102 | if chapter is not None: key, set = (title, chapter), me.used_chapters |
103 | else: key, set = title, me.used_titles | |
104 | if key in set: | |
105 | if title is None: | |
106 | raise ExpectedError("`%s' already used" % me.fn) | |
107 | elif chapter is None: | |
108 | raise ExpectedError("`%s' title %d already used" % (me.fn, title)) | |
109 | else: | |
110 | raise ExpectedError("`%s' title %d chapter %d already used" % | |
111 | (me.fn, title, chapter)) | |
112 | if chapter is not None: me.used_chapters.add((title, chapter)) | |
04a05f7f MW |
113 | return me.PREFIX + ROOT + urlencode(me.fn) + suffix |
114 | ||
115 | class VideoDisc (Source): | |
116 | PREFIX = "dvd://" | |
117 | TITLEP = CHAPTERP = True | |
118 | ||
b092d511 MW |
119 | def __init__(me, fn, *args, **kw): |
120 | super().__init__(fn, *args, **kw) | |
121 | me.neps = 0 | |
122 | ||
04a05f7f MW |
123 | class VideoSeason (object): |
124 | def __init__(me, i, title): | |
125 | me.i = i | |
126 | me.title = title | |
127 | me.episodes = {} | |
32cd109c MW |
128 | def set_episode_disc(me, i, disc): |
129 | if i in me.episodes: | |
130 | raise ExpectedError("season %d episode %d already taken" % (me.i, i)) | |
b092d511 | 131 | me.episodes[i] = disc; disc.neps += 1 |
04a05f7f MW |
132 | |
133 | def some_group(m, *gg): | |
134 | for g in gg: | |
135 | s = m.group(g) | |
136 | if s is not None: return s | |
137 | return None | |
138 | ||
139 | class VideoDir (object): | |
140 | ||
9fc467bb | 141 | _R_ISO_PRE = RX.compile(r""" ^ |
35ecb6eb MW |
142 | (?: S (?P<si> \d+) |
143 | (?: \. \ (?P<st> .*) — (?: D \d+ \. \ )? | | |
144 | D \d+ \. \ | | |
145 | (?= E \d+ \. \ ) | | |
146 | \. \ ) | | |
147 | \d+ \. \ ) | |
148 | (?: (?P<eplist> | |
149 | (?: S \d+ \ )? E \d+ (?: – \d+)? | |
150 | (?: , \ (?: S \d+ \ )? E \d+ (?: – \d+)?)*) | | |
151 | (?P<epname> E \d+) \. \ .*) | |
04a05f7f MW |
152 | \. iso $ |
153 | """, RX.X) | |
154 | ||
9fc467bb | 155 | _R_ISO_EP = RX.compile(r""" ^ |
6b5cec73 | 156 | (?: S (?P<si> \d+) \ )? |
9fc467bb | 157 | E (?P<ei> \d+) (?: – (?P<ej> \d+))? $ |
04a05f7f MW |
158 | """, RX.X) |
159 | ||
160 | def __init__(me, dir): | |
b092d511 | 161 | me.dir = dir |
04a05f7f MW |
162 | fns = OS.listdir(OS.path.join(ROOT, dir)) |
163 | fns.sort() | |
6b5cec73 | 164 | season = None |
04a05f7f MW |
165 | seasons = {} |
166 | for fn in fns: | |
167 | path = OS.path.join(dir, fn) | |
168 | if not fn.endswith(".iso"): continue | |
169 | m = me._R_ISO_PRE.match(fn) | |
065a5db6 MW |
170 | if not m: |
171 | #print(";; `%s' ignored" % path, file = SYS.stderr) | |
172 | continue | |
04a05f7f | 173 | |
6b5cec73 | 174 | i = filter(m.group("si"), int) |
04a05f7f | 175 | stitle = m.group("st") |
6b5cec73 MW |
176 | check(i is not None or stitle is None, |
177 | "explicit season title without number in `%s'" % fn) | |
178 | if i is not None: | |
179 | if season is None or i != season.i: | |
180 | check(season is None or i == season.i + 1, | |
181 | "season %d /= %d" % | |
182 | (i, season is None and -1 or season.i + 1)) | |
183 | check(i not in seasons, "season %d already seen" % i) | |
184 | seasons[i] = season = VideoSeason(i, stitle) | |
185 | else: | |
186 | check(stitle == season.title, | |
187 | "season title `%s' /= `%s'" % (stitle, season.title)) | |
04a05f7f | 188 | |
32cd109c | 189 | disc = VideoDisc(path) |
6b5cec73 | 190 | ts = season |
32cd109c | 191 | any, bad = False, False |
35ecb6eb MW |
192 | epname = m.group("epname") |
193 | if epname is not None: eplist = [epname] | |
194 | else: eplist = m.group("eplist").split(", ") | |
195 | for eprange in eplist: | |
04a05f7f MW |
196 | mm = me._R_ISO_EP.match(eprange) |
197 | if mm is None: bad = True; continue | |
065a5db6 MW |
198 | if not any: |
199 | #print(";; `%s'" % path, file = SYS.stderr) | |
200 | any = True | |
6b5cec73 MW |
201 | i = filter(mm.group("si"), int) |
202 | if i is not None: | |
203 | try: ts = seasons[i] | |
204 | except KeyError: ts = seasons[i] = VideoSeason(i, None) | |
205 | if ts is None: | |
206 | ts = season = seasons[1] = VideoSeason(1, None) | |
04a05f7f MW |
207 | start = filter(mm.group("ei"), int) |
208 | end = filter(mm.group("ej"), int, start) | |
32cd109c | 209 | for k in range(start, end + 1): |
6b5cec73 | 210 | ts.set_episode_disc(k, disc) |
065a5db6 MW |
211 | #print(";;\tepisode %d.%d" % (ts.i, k), file = SYS.stderr) |
212 | if not any: pass #print(";; `%s' ignored" % path, file = SYS.stderr) | |
213 | elif bad: raise ExpectedError("bad ep list in `%s'", fn) | |
04a05f7f MW |
214 | me.seasons = seasons |
215 | ||
216 | class AudioDisc (Source): | |
fbac3340 | 217 | PREFIX = "file://" |
04a05f7f MW |
218 | TITLEP = CHAPTERP = False |
219 | ||
220 | class AudioEpisode (Source): | |
fbac3340 | 221 | PREFIX = "file://" |
04a05f7f MW |
222 | TITLEP = CHAPTERP = False |
223 | def __init__(me, fn, i, *args, **kw): | |
224 | super().__init__(fn, *args, **kw) | |
225 | me.i = i | |
226 | ||
227 | class AudioDir (object): | |
228 | ||
9fc467bb | 229 | _R_FLAC = RX.compile(r""" ^ |
04a05f7f MW |
230 | E (\d+) |
231 | (?: \. \ (.*))? | |
232 | \. flac $ | |
233 | """, RX.X) | |
234 | ||
235 | def __init__(me, dir): | |
b092d511 | 236 | me.dir = dir |
04a05f7f MW |
237 | fns = OS.listdir(OS.path.join(ROOT, dir)) |
238 | fns.sort() | |
239 | episodes = {} | |
240 | last_i = 0 | |
241 | for fn in fns: | |
242 | path = OS.path.join(dir, fn) | |
243 | if not fn.endswith(".flac"): continue | |
244 | m = me._R_FLAC.match(fn) | |
245 | if not m: continue | |
246 | i = filter(m.group(1), int) | |
247 | etitle = m.group(2) | |
248 | check(i == last_i + 1, "episode %d /= %d" % (i, last_i + 1)) | |
249 | episodes[i] = AudioEpisode(path, i) | |
250 | last_i = i | |
251 | me.episodes = episodes | |
252 | ||
253 | class Chapter (object): | |
254 | def __init__(me, episode, title, i): | |
255 | me.title, me.i = title, i | |
256 | me.url = episode.source.url(episode.tno, i) | |
257 | ||
258 | class Episode (object): | |
259 | def __init__(me, season, i, neps, title, src, tno = None): | |
260 | me.season = season | |
261 | me.i, me.neps, me.title = i, neps, title | |
262 | me.chapters = [] | |
263 | me.source, me.tno = src, tno | |
264 | me.url = src.url(tno) | |
265 | def add_chapter(me, title, j): | |
266 | ch = Chapter(me, title, j) | |
267 | me.chapters.append(ch) | |
268 | return ch | |
269 | def label(me): | |
270 | return me.season._eplabel(me.i, me.neps, me.title) | |
271 | ||
272 | class Season (object): | |
273 | def __init__(me, playlist, title, i, implicitp = False): | |
274 | me.playlist = playlist | |
275 | me.title, me.i = title, i | |
276 | me.implicitp = implicitp | |
277 | me.episodes = [] | |
278 | def add_episode(me, j, neps, title, src, tno): | |
279 | ep = Episode(me, j, neps, title, src, tno) | |
280 | me.episodes.append(ep) | |
281 | return ep | |
282 | def _eplabel(me, i, neps, title): | |
283 | if neps == 1: epname = me.playlist.epname; epn = "%d" % i | |
284 | elif neps == 2: epname = me.playlist.epnames; epn = "%d, %d" % (i, i + 1) | |
285 | else: epname = me.playlist.epnames; epn = "%d–%d" % (i, i + neps - 1) | |
286 | if title is None: | |
287 | if me.implicitp: return "%s %s" % (epname, epn) | |
288 | elif me.title is None: return "%s %d.%s" % (epname, me.i, epn) | |
289 | else: return "%s—%s %s" % (me.title, epname, epn) | |
290 | else: | |
291 | if me.implicitp: return "%s. %s" % (epn, title) | |
292 | elif me.title is None: return "%d.%s. %s" % (me.i, epn, title) | |
293 | else: return "%s—%s. %s" % (me.title, epn, title) | |
294 | ||
295 | class MovieSeason (object): | |
296 | def __init__(me, playlist): | |
297 | me.playlist = playlist | |
298 | me.i = -1 | |
299 | me.implicitp = False | |
300 | me.episodes = [] | |
301 | def add_episode(me, j, neps, title, src, tno): | |
302 | if title is None: raise ExpectedError("movie must have a title") | |
303 | ep = Episode(me, j, neps, title, src, tno) | |
304 | me.episodes.append(ep) | |
305 | return ep | |
306 | def _eplabel(me, i, epn, title): | |
307 | return title | |
308 | ||
309 | class Playlist (object): | |
310 | ||
311 | def __init__(me): | |
312 | me.seasons = [] | |
313 | me.epname, me.epnames = "Episode", "Episodes" | |
314 | ||
315 | def add_season(me, title, i, implicitp = False): | |
316 | season = Season(me, title, i, implicitp) | |
317 | me.seasons.append(season) | |
318 | return season | |
319 | ||
320 | def add_movies(me): | |
321 | season = MovieSeason(me) | |
322 | me.seasons.append(season) | |
323 | return season | |
324 | ||
325 | def write(me, f): | |
326 | f.write("#EXTM3U\n") | |
327 | for season in me.seasons: | |
328 | f.write("\n") | |
329 | for i, ep in enumerate(season.episodes, 1): | |
330 | if not ep.chapters: | |
331 | f.write("#EXTINF:0,,%s\n%s\n" % (ep.label(), ep.url)) | |
332 | else: | |
333 | for ch in ep.chapters: | |
334 | f.write("#EXTINF:0,,%s: %s\n%s\n" % | |
335 | (ep.label(), ch.title, ch.url)) | |
336 | ||
337 | UNSET = ["UNSET"] | |
338 | ||
339 | def parse_list(fn): | |
340 | playlist = Playlist() | |
341 | season, episode, chapter, ep_i = None, None, None, 1 | |
342 | vds = {} | |
343 | ads = iso = None | |
344 | with location(FileLocation(fn, 0)) as floc: | |
345 | with open(fn, "r") as f: | |
346 | for line in f: | |
347 | floc.stepline() | |
348 | sline = line.lstrip() | |
349 | if sline == "" or sline.startswith(";"): continue | |
350 | ||
351 | if line.startswith("!"): | |
352 | ww = Words(line[1:]) | |
353 | cmd = ww.nextword() | |
354 | check(cmd is not None, "missing command") | |
355 | ||
356 | if cmd == "season": | |
357 | v = ww.nextword(); | |
358 | check(v is not None, "missing season number") | |
359 | if v == "-": | |
360 | check(v.rest() is None, "trailing junk") | |
361 | season = playlist.add_movies() | |
362 | else: | |
363 | i = getint(v) | |
364 | title = ww.rest() | |
365 | season = playlist.add_season(title, i, implicitp = False) | |
366 | episode = chapter = None | |
367 | ep_i = 1 | |
368 | ||
369 | elif cmd == "movie": | |
370 | check(ww.rest() is None, "trailing junk") | |
371 | season = playlist.add_movies() | |
372 | episode = chapter = None | |
373 | ep_i = 1 | |
374 | ||
375 | elif cmd == "epname": | |
376 | name = ww.rest() | |
377 | check(name is not None, "missing episode name") | |
378 | try: sep = name.index(":") | |
379 | except ValueError: names = name + "s" | |
380 | else: name, names = name[:sep], name[sep + 1:] | |
381 | playlist.epname, playlist.epnames = name, names | |
382 | ||
383 | elif cmd == "epno": | |
384 | i = ww.rest() | |
385 | check(i is not None, "missing episode number") | |
386 | ep_i = getint(i) | |
387 | ||
388 | elif cmd == "iso": | |
389 | fn = ww.rest(); check(fn is not None, "missing filename") | |
390 | if fn == "-": iso = None | |
391 | else: | |
392 | check(OS.path.exists(OS.path.join(ROOT, fn)), | |
393 | "iso file `%s' not found" % fn) | |
394 | iso = VideoDisc(fn) | |
395 | ||
396 | elif cmd == "vdir": | |
397 | name = ww.nextword(); check(name is not None, "missing name") | |
398 | fn = ww.rest(); check(fn is not None, "missing directory") | |
399 | if fn == "-": | |
400 | try: del vds[name] | |
401 | except KeyError: pass | |
402 | else: | |
403 | vds[name] = VideoDir(fn) | |
404 | ||
405 | elif cmd == "adir": | |
406 | fn = ww.rest(); check(fn is not None, "missing directory") | |
407 | if fn == "-": ads = None | |
408 | else: ads = AudioDir(fn) | |
409 | ||
410 | elif cmd == "end": | |
411 | break | |
412 | ||
413 | else: | |
414 | raise ExpectedError("unknown command `%s'" % cmd) | |
415 | ||
416 | else: | |
417 | ||
418 | if not line[0].isspace(): | |
419 | ww = Words(line) | |
420 | conf = ww.nextword() | |
421 | ||
422 | check(conf is not None, "missing config") | |
423 | i, vdname, neps, fake_epi = UNSET, "-", 1, ep_i | |
424 | for c in conf.split(","): | |
425 | if c.isdigit(): i = int(c) | |
426 | elif c == "-": i = None | |
427 | else: | |
428 | eq = c.find("="); check(eq >= 0, "bad assignment `%s'" % c) | |
429 | k, v = c[:eq], c[eq + 1:] | |
430 | if k == "vd": vdname = v | |
431 | elif k == "n": neps = getint(v) | |
432 | elif k == "ep": fake_epi = getint(v) | |
433 | else: raise ExpectedError("unknown setting `%s'" % k) | |
434 | ||
435 | title = ww.rest() | |
436 | check(i is not UNSET, "no title number") | |
437 | if season is None: | |
438 | season = playlist.add_season(None, 1, implicitp = True) | |
439 | ||
440 | if i is None: | |
441 | check(ads, "no title, but no audio directory") | |
442 | check(season.implicitp, "audio source, but explicit season") | |
0b8c4773 MW |
443 | try: src = ads.episodes[ep_i] |
444 | except KeyError: | |
445 | raise ExpectedError("episode %d not found in audio dir `%s'" % | |
446 | ep_i, ads.dir) | |
04a05f7f MW |
447 | |
448 | elif iso: | |
449 | src = iso | |
450 | ||
451 | else: | |
452 | check(vdname in vds, "title, but no iso or video directory") | |
0b8c4773 MW |
453 | try: vdir = vds[vdname] |
454 | except KeyError: | |
455 | raise ExpectedError("video dir label `%s' not set" % vdname) | |
456 | try: s = vdir.seasons[season.i] | |
457 | except KeyError: | |
458 | raise ExpectedError("season %d not found in video dir `%s'" % | |
459 | (season.i, vdir.dir)) | |
460 | try: src = s.episodes[ep_i] | |
461 | except KeyError: | |
462 | raise ExpectedError("episode %d.%d not found in video dir `%s'" % | |
463 | (season.i, ep_i, vdir.dir)) | |
04a05f7f MW |
464 | |
465 | episode = season.add_episode(fake_epi, neps, title, src, i) | |
466 | chapter = None | |
b092d511 | 467 | ep_i += neps; src.nuses += neps |
04a05f7f MW |
468 | |
469 | else: | |
470 | ww = Words(line) | |
471 | title = ww.rest() | |
472 | check(episode is not None, "no current episode") | |
473 | check(episode.source.CHAPTERP, | |
474 | "episode source doesn't allow chapters") | |
475 | if chapter is None: j = 1 | |
476 | else: j += 1 | |
477 | chapter = episode.add_chapter(title, j) | |
478 | ||
b092d511 MW |
479 | discs = set() |
480 | for vdir in vds.values(): | |
481 | for s in vdir.seasons.values(): | |
482 | for d in s.episodes.values(): | |
483 | discs.add(d) | |
484 | for d in sorted(discs, key = lambda d: d.fn): | |
485 | if d.neps != d.nuses: | |
486 | raise ExpectedError("disc `%s' has %d episodes, used %d times" % | |
487 | (d.fn, d.neps, d.nuses)) | |
488 | ||
04a05f7f MW |
489 | return playlist |
490 | ||
491 | ROOT = "/mnt/dvd/archive/" | |
492 | ||
493 | try: | |
494 | for f in SYS.argv[1:]: | |
495 | parse_list(f).write(SYS.stdout) | |
496 | except (ExpectedError, IOError, OSError) as e: | |
497 | LOC.report(e) | |
498 | SYS.exit(2) |