| 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 |
| 84 | me.neps = None |
| 85 | me.used_titles = dict() |
| 86 | me.used_chapters = set() |
| 87 | me.nuses = 0 |
| 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) |
| 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)) |
| 113 | return me.PREFIX + ROOT + urlencode(me.fn) + suffix |
| 114 | |
| 115 | class VideoDisc (Source): |
| 116 | PREFIX = "dvd://" |
| 117 | TITLEP = CHAPTERP = True |
| 118 | |
| 119 | def __init__(me, fn, *args, **kw): |
| 120 | super().__init__(fn, *args, **kw) |
| 121 | me.neps = 0 |
| 122 | |
| 123 | class VideoSeason (object): |
| 124 | def __init__(me, i, title): |
| 125 | me.i = i |
| 126 | me.title = title |
| 127 | me.episodes = {} |
| 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)) |
| 131 | me.episodes[i] = disc; disc.neps += 1 |
| 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 | |
| 141 | _R_ISO_PRE = RX.compile(r""" ^ |
| 142 | (?: S (?P<si> \d+) (?: \. \ (?P<st> .*)—)? (?: D (?P<sdi> \d+))? | |
| 143 | (?P<di> \d+)) |
| 144 | \. \ # |
| 145 | (?P<eps> .*) |
| 146 | \. iso $ |
| 147 | """, RX.X) |
| 148 | |
| 149 | _R_ISO_EP = RX.compile(r""" ^ |
| 150 | E (?P<ei> \d+) (?: – (?P<ej> \d+))? $ |
| 151 | """, RX.X) |
| 152 | |
| 153 | def __init__(me, dir): |
| 154 | me.dir = dir |
| 155 | fns = OS.listdir(OS.path.join(ROOT, dir)) |
| 156 | fns.sort() |
| 157 | season, last_j = None, 0 |
| 158 | seasons = {} |
| 159 | for fn in fns: |
| 160 | path = OS.path.join(dir, fn) |
| 161 | if not fn.endswith(".iso"): continue |
| 162 | m = me._R_ISO_PRE.match(fn) |
| 163 | if not m: continue |
| 164 | |
| 165 | i = filter(m.group("si"), int, 1) |
| 166 | stitle = m.group("st") |
| 167 | if season is None or i != season.i: |
| 168 | check(season is None or i == season.i + 1, |
| 169 | "season %d /= %d" % (i, season is None and -1 or season.i + 1)) |
| 170 | check(i not in seasons, "season %d already seen" % i) |
| 171 | seasons[i] = season = VideoSeason(i, stitle) |
| 172 | last_j = 0 |
| 173 | else: |
| 174 | check(stitle == season.title, |
| 175 | "season title `%s' /= `%s'" % (stitle, season.title)) |
| 176 | j = filter(some_group(m, "sdi", "di"), int) |
| 177 | if j is not None: |
| 178 | check(j == last_j + 1, |
| 179 | "season %d disc %d /= %d" % (season.i, j, last_j + 1)) |
| 180 | |
| 181 | disc = VideoDisc(path) |
| 182 | any, bad = False, False |
| 183 | for eprange in m.group("eps").split(", "): |
| 184 | mm = me._R_ISO_EP.match(eprange) |
| 185 | if mm is None: bad = True; continue |
| 186 | start = filter(mm.group("ei"), int) |
| 187 | end = filter(mm.group("ej"), int, start) |
| 188 | for k in range(start, end + 1): |
| 189 | season.set_episode_disc(k, disc) |
| 190 | any = True |
| 191 | if bad and any: |
| 192 | raise ExpectedError("bad ep list in `%s'", fn) |
| 193 | last_j = j |
| 194 | me.seasons = seasons |
| 195 | |
| 196 | class AudioDisc (Source): |
| 197 | #PREFIX = "file://" |
| 198 | TITLEP = CHAPTERP = False |
| 199 | |
| 200 | class AudioEpisode (Source): |
| 201 | #PREFIX = "file://" |
| 202 | TITLEP = CHAPTERP = False |
| 203 | def __init__(me, fn, i, *args, **kw): |
| 204 | super().__init__(fn, *args, **kw) |
| 205 | me.i = i |
| 206 | |
| 207 | class AudioDir (object): |
| 208 | |
| 209 | _R_FLAC = RX.compile(r""" ^ |
| 210 | E (\d+) |
| 211 | (?: \. \ (.*))? |
| 212 | \. flac $ |
| 213 | """, RX.X) |
| 214 | |
| 215 | def __init__(me, dir): |
| 216 | me.dir = dir |
| 217 | fns = OS.listdir(OS.path.join(ROOT, dir)) |
| 218 | fns.sort() |
| 219 | episodes = {} |
| 220 | last_i = 0 |
| 221 | for fn in fns: |
| 222 | path = OS.path.join(dir, fn) |
| 223 | if not fn.endswith(".flac"): continue |
| 224 | m = me._R_FLAC.match(fn) |
| 225 | if not m: continue |
| 226 | i = filter(m.group(1), int) |
| 227 | etitle = m.group(2) |
| 228 | check(i == last_i + 1, "episode %d /= %d" % (i, last_i + 1)) |
| 229 | episodes[i] = AudioEpisode(path, i) |
| 230 | last_i = i |
| 231 | me.episodes = episodes |
| 232 | |
| 233 | class Chapter (object): |
| 234 | def __init__(me, episode, title, i): |
| 235 | me.title, me.i = title, i |
| 236 | me.url = episode.source.url(episode.tno, i) |
| 237 | |
| 238 | class Episode (object): |
| 239 | def __init__(me, season, i, neps, title, src, tno = None): |
| 240 | me.season = season |
| 241 | me.i, me.neps, me.title = i, neps, title |
| 242 | me.chapters = [] |
| 243 | me.source, me.tno = src, tno |
| 244 | me.url = src.url(tno) |
| 245 | def add_chapter(me, title, j): |
| 246 | ch = Chapter(me, title, j) |
| 247 | me.chapters.append(ch) |
| 248 | return ch |
| 249 | def label(me): |
| 250 | return me.season._eplabel(me.i, me.neps, me.title) |
| 251 | |
| 252 | class Season (object): |
| 253 | def __init__(me, playlist, title, i, implicitp = False): |
| 254 | me.playlist = playlist |
| 255 | me.title, me.i = title, i |
| 256 | me.implicitp = implicitp |
| 257 | me.episodes = [] |
| 258 | def add_episode(me, j, neps, title, src, tno): |
| 259 | ep = Episode(me, j, neps, title, src, tno) |
| 260 | me.episodes.append(ep) |
| 261 | return ep |
| 262 | def _eplabel(me, i, neps, title): |
| 263 | if neps == 1: epname = me.playlist.epname; epn = "%d" % i |
| 264 | elif neps == 2: epname = me.playlist.epnames; epn = "%d, %d" % (i, i + 1) |
| 265 | else: epname = me.playlist.epnames; epn = "%d–%d" % (i, i + neps - 1) |
| 266 | if title is None: |
| 267 | if me.implicitp: return "%s %s" % (epname, epn) |
| 268 | elif me.title is None: return "%s %d.%s" % (epname, me.i, epn) |
| 269 | else: return "%s—%s %s" % (me.title, epname, epn) |
| 270 | else: |
| 271 | if me.implicitp: return "%s. %s" % (epn, title) |
| 272 | elif me.title is None: return "%d.%s. %s" % (me.i, epn, title) |
| 273 | else: return "%s—%s. %s" % (me.title, epn, title) |
| 274 | |
| 275 | class MovieSeason (object): |
| 276 | def __init__(me, playlist): |
| 277 | me.playlist = playlist |
| 278 | me.i = -1 |
| 279 | me.implicitp = False |
| 280 | me.episodes = [] |
| 281 | def add_episode(me, j, neps, title, src, tno): |
| 282 | if title is None: raise ExpectedError("movie must have a title") |
| 283 | ep = Episode(me, j, neps, title, src, tno) |
| 284 | me.episodes.append(ep) |
| 285 | return ep |
| 286 | def _eplabel(me, i, epn, title): |
| 287 | return title |
| 288 | |
| 289 | class Playlist (object): |
| 290 | |
| 291 | def __init__(me): |
| 292 | me.seasons = [] |
| 293 | me.epname, me.epnames = "Episode", "Episodes" |
| 294 | |
| 295 | def add_season(me, title, i, implicitp = False): |
| 296 | season = Season(me, title, i, implicitp) |
| 297 | me.seasons.append(season) |
| 298 | return season |
| 299 | |
| 300 | def add_movies(me): |
| 301 | season = MovieSeason(me) |
| 302 | me.seasons.append(season) |
| 303 | return season |
| 304 | |
| 305 | def write(me, f): |
| 306 | f.write("#EXTM3U\n") |
| 307 | for season in me.seasons: |
| 308 | f.write("\n") |
| 309 | for i, ep in enumerate(season.episodes, 1): |
| 310 | if not ep.chapters: |
| 311 | f.write("#EXTINF:0,,%s\n%s\n" % (ep.label(), ep.url)) |
| 312 | else: |
| 313 | for ch in ep.chapters: |
| 314 | f.write("#EXTINF:0,,%s: %s\n%s\n" % |
| 315 | (ep.label(), ch.title, ch.url)) |
| 316 | |
| 317 | UNSET = ["UNSET"] |
| 318 | |
| 319 | def parse_list(fn): |
| 320 | playlist = Playlist() |
| 321 | season, episode, chapter, ep_i = None, None, None, 1 |
| 322 | vds = {} |
| 323 | ads = iso = None |
| 324 | with location(FileLocation(fn, 0)) as floc: |
| 325 | with open(fn, "r") as f: |
| 326 | for line in f: |
| 327 | floc.stepline() |
| 328 | sline = line.lstrip() |
| 329 | if sline == "" or sline.startswith(";"): continue |
| 330 | |
| 331 | if line.startswith("!"): |
| 332 | ww = Words(line[1:]) |
| 333 | cmd = ww.nextword() |
| 334 | check(cmd is not None, "missing command") |
| 335 | |
| 336 | if cmd == "season": |
| 337 | v = ww.nextword(); |
| 338 | check(v is not None, "missing season number") |
| 339 | if v == "-": |
| 340 | check(v.rest() is None, "trailing junk") |
| 341 | season = playlist.add_movies() |
| 342 | else: |
| 343 | i = getint(v) |
| 344 | title = ww.rest() |
| 345 | season = playlist.add_season(title, i, implicitp = False) |
| 346 | episode = chapter = None |
| 347 | ep_i = 1 |
| 348 | |
| 349 | elif cmd == "movie": |
| 350 | check(ww.rest() is None, "trailing junk") |
| 351 | season = playlist.add_movies() |
| 352 | episode = chapter = None |
| 353 | ep_i = 1 |
| 354 | |
| 355 | elif cmd == "epname": |
| 356 | name = ww.rest() |
| 357 | check(name is not None, "missing episode name") |
| 358 | try: sep = name.index(":") |
| 359 | except ValueError: names = name + "s" |
| 360 | else: name, names = name[:sep], name[sep + 1:] |
| 361 | playlist.epname, playlist.epnames = name, names |
| 362 | |
| 363 | elif cmd == "epno": |
| 364 | i = ww.rest() |
| 365 | check(i is not None, "missing episode number") |
| 366 | ep_i = getint(i) |
| 367 | |
| 368 | elif cmd == "iso": |
| 369 | fn = ww.rest(); check(fn is not None, "missing filename") |
| 370 | if fn == "-": iso = None |
| 371 | else: |
| 372 | check(OS.path.exists(OS.path.join(ROOT, fn)), |
| 373 | "iso file `%s' not found" % fn) |
| 374 | iso = VideoDisc(fn) |
| 375 | |
| 376 | elif cmd == "vdir": |
| 377 | name = ww.nextword(); check(name is not None, "missing name") |
| 378 | fn = ww.rest(); check(fn is not None, "missing directory") |
| 379 | if fn == "-": |
| 380 | try: del vds[name] |
| 381 | except KeyError: pass |
| 382 | else: |
| 383 | vds[name] = VideoDir(fn) |
| 384 | |
| 385 | elif cmd == "adir": |
| 386 | fn = ww.rest(); check(fn is not None, "missing directory") |
| 387 | if fn == "-": ads = None |
| 388 | else: ads = AudioDir(fn) |
| 389 | |
| 390 | elif cmd == "end": |
| 391 | break |
| 392 | |
| 393 | else: |
| 394 | raise ExpectedError("unknown command `%s'" % cmd) |
| 395 | |
| 396 | else: |
| 397 | |
| 398 | if not line[0].isspace(): |
| 399 | ww = Words(line) |
| 400 | conf = ww.nextword() |
| 401 | |
| 402 | check(conf is not None, "missing config") |
| 403 | i, vdname, neps, fake_epi = UNSET, "-", 1, ep_i |
| 404 | for c in conf.split(","): |
| 405 | if c.isdigit(): i = int(c) |
| 406 | elif c == "-": i = None |
| 407 | else: |
| 408 | eq = c.find("="); check(eq >= 0, "bad assignment `%s'" % c) |
| 409 | k, v = c[:eq], c[eq + 1:] |
| 410 | if k == "vd": vdname = v |
| 411 | elif k == "n": neps = getint(v) |
| 412 | elif k == "ep": fake_epi = getint(v) |
| 413 | else: raise ExpectedError("unknown setting `%s'" % k) |
| 414 | |
| 415 | title = ww.rest() |
| 416 | check(i is not UNSET, "no title number") |
| 417 | if season is None: |
| 418 | season = playlist.add_season(None, 1, implicitp = True) |
| 419 | |
| 420 | if i is None: |
| 421 | check(ads, "no title, but no audio directory") |
| 422 | check(season.implicitp, "audio source, but explicit season") |
| 423 | try: src = ads.episodes[ep_i] |
| 424 | except KeyError: |
| 425 | raise ExpectedError("episode %d not found in audio dir `%s'" % |
| 426 | ep_i, ads.dir) |
| 427 | |
| 428 | elif iso: |
| 429 | src = iso |
| 430 | |
| 431 | else: |
| 432 | check(vdname in vds, "title, but no iso or video directory") |
| 433 | try: vdir = vds[vdname] |
| 434 | except KeyError: |
| 435 | raise ExpectedError("video dir label `%s' not set" % vdname) |
| 436 | try: s = vdir.seasons[season.i] |
| 437 | except KeyError: |
| 438 | raise ExpectedError("season %d not found in video dir `%s'" % |
| 439 | (season.i, vdir.dir)) |
| 440 | try: src = s.episodes[ep_i] |
| 441 | except KeyError: |
| 442 | raise ExpectedError("episode %d.%d not found in video dir `%s'" % |
| 443 | (season.i, ep_i, vdir.dir)) |
| 444 | |
| 445 | episode = season.add_episode(fake_epi, neps, title, src, i) |
| 446 | chapter = None |
| 447 | ep_i += neps; src.nuses += neps |
| 448 | |
| 449 | else: |
| 450 | ww = Words(line) |
| 451 | title = ww.rest() |
| 452 | check(episode is not None, "no current episode") |
| 453 | check(episode.source.CHAPTERP, |
| 454 | "episode source doesn't allow chapters") |
| 455 | if chapter is None: j = 1 |
| 456 | else: j += 1 |
| 457 | chapter = episode.add_chapter(title, j) |
| 458 | |
| 459 | discs = set() |
| 460 | for vdir in vds.values(): |
| 461 | for s in vdir.seasons.values(): |
| 462 | for d in s.episodes.values(): |
| 463 | discs.add(d) |
| 464 | for d in sorted(discs, key = lambda d: d.fn): |
| 465 | if d.neps != d.nuses: |
| 466 | raise ExpectedError("disc `%s' has %d episodes, used %d times" % |
| 467 | (d.fn, d.neps, d.nuses)) |
| 468 | |
| 469 | return playlist |
| 470 | |
| 471 | ROOT = "/mnt/dvd/archive/" |
| 472 | |
| 473 | try: |
| 474 | for f in SYS.argv[1:]: |
| 475 | parse_list(f).write(SYS.stdout) |
| 476 | except (ExpectedError, IOError, OSError) as e: |
| 477 | LOC.report(e) |
| 478 | SYS.exit(2) |