| 1 | #! /usr/bin/python3 |
| 2 | ### -*- mode: python; coding: utf-8 -*- |
| 3 | |
| 4 | from contextlib import contextmanager |
| 5 | import optparse as OP |
| 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 | |
| 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 | |
| 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 |
| 93 | me.neps = None |
| 94 | me.used_titles = dict() |
| 95 | me.used_chapters = set() |
| 96 | me.nuses = 0 |
| 97 | def url(me, title = None, chapter = None): |
| 98 | if title == "-": |
| 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) |
| 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: |
| 114 | if title == "-": |
| 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" % |
| 120 | (me.fn, title, chapter)) |
| 121 | if chapter is not None: me.used_chapters.add((title, chapter)) |
| 122 | return me.PREFIX + ROOT + urlencode(me.fn) + suffix |
| 123 | |
| 124 | class VideoDisc (Source): |
| 125 | PREFIX = "dvd://" |
| 126 | TITLEP = CHAPTERP = True |
| 127 | |
| 128 | def __init__(me, fn, *args, **kw): |
| 129 | super().__init__(fn, *args, **kw) |
| 130 | me.neps = 0 |
| 131 | |
| 132 | class VideoSeason (object): |
| 133 | def __init__(me, i, title): |
| 134 | me.i = i |
| 135 | me.title = title |
| 136 | me.episodes = {} |
| 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)) |
| 140 | me.episodes[i] = disc; disc.neps += 1 |
| 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 | |
| 150 | _R_ISO_PRE = RX.compile(r""" ^ |
| 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+) \. \ .*) |
| 161 | \. iso $ |
| 162 | """, RX.X) |
| 163 | |
| 164 | _R_ISO_EP = RX.compile(r""" ^ |
| 165 | (?: S (?P<si> \d+) \ )? |
| 166 | E (?P<ei> \d+) (?: – (?P<ej> \d+))? $ |
| 167 | """, RX.X) |
| 168 | |
| 169 | def __init__(me, dir): |
| 170 | me.dir = dir |
| 171 | fns = OS.listdir(OS.path.join(ROOT, dir)) |
| 172 | fns.sort() |
| 173 | season = None |
| 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) |
| 179 | if not m: |
| 180 | #print(";; `%s' ignored" % path, file = SYS.stderr) |
| 181 | continue |
| 182 | |
| 183 | i = filter(m.group("si"), int) |
| 184 | stitle = m.group("st") |
| 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)) |
| 197 | |
| 198 | disc = VideoDisc(path) |
| 199 | ts = season |
| 200 | any, bad = False, False |
| 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: |
| 205 | mm = me._R_ISO_EP.match(eprange) |
| 206 | if mm is None: bad = True; continue |
| 207 | if not any: |
| 208 | #print(";; `%s'" % path, file = SYS.stderr) |
| 209 | any = True |
| 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) |
| 216 | start = filter(mm.group("ei"), int) |
| 217 | end = filter(mm.group("ej"), int, start) |
| 218 | for k in range(start, end + 1): |
| 219 | ts.set_episode_disc(k, disc) |
| 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) |
| 223 | me.seasons = seasons |
| 224 | |
| 225 | class AudioDisc (Source): |
| 226 | PREFIX = "file://" |
| 227 | TITLEP = CHAPTERP = False |
| 228 | |
| 229 | class AudioEpisode (Source): |
| 230 | PREFIX = "file://" |
| 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 | |
| 238 | _R_FLAC = RX.compile(r""" ^ |
| 239 | E (\d+) |
| 240 | (?: \. \ (.*))? |
| 241 | \. flac $ |
| 242 | """, RX.X) |
| 243 | |
| 244 | def __init__(me, dir): |
| 245 | me.dir = dir |
| 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 | |
| 281 | class BaseSeason (object): |
| 282 | def __init__(me, series, implicitp = False): |
| 283 | me.series = series |
| 284 | me.episodes = [] |
| 285 | me.implicitp = implicitp |
| 286 | me.ep_i, episodes = 1, [] |
| 287 | def add_episode(me, j, neps, title, src, tno): |
| 288 | ep = Episode(me, j, neps, title, src, tno) |
| 289 | me.episodes.append(ep) |
| 290 | src.nuses += neps; me.ep_i += neps |
| 291 | return ep |
| 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 |
| 297 | def _eplabel(me, i, neps, title): |
| 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) |
| 302 | if title is None: |
| 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) |
| 306 | else: |
| 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 |
| 311 | |
| 312 | class MovieSeason (BaseSeason): |
| 313 | def add_episode(me, j, neps, title, src, tno): |
| 314 | if title is None: raise ExpectedError("movie must have a title") |
| 315 | return super().add_episode(j, neps, title, src, tno) |
| 316 | def _eplabel(me, i, epn, title): |
| 317 | return title |
| 318 | |
| 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 |
| 335 | |
| 336 | class Playlist (object): |
| 337 | def __init__(me): |
| 338 | me.seasons = [] |
| 339 | me.episodes = [] |
| 340 | me.epname, me.epnames = "Episode", "Episodes" |
| 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 = [] |
| 348 | def write(me, f): |
| 349 | f.write("#EXTM3U\n") |
| 350 | for season in me.seasons: |
| 351 | f.write("\n") |
| 352 | for ep in season: |
| 353 | label = ep.label() |
| 354 | if me.nseries > 1: label = ep.season.series.title + " " + label |
| 355 | if not ep.chapters: |
| 356 | f.write("#EXTINF:0,,%s\n%s\n" % (label, ep.url)) |
| 357 | else: |
| 358 | for ch in ep.chapters: |
| 359 | f.write("#EXTINF:0,,%s: %s\n%s\n" % |
| 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) |
| 488 | |
| 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)) |
| 520 | |
| 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(): |
| 568 | discs.add(d) |
| 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 |
| 574 | |
| 575 | ROOT = "/mnt/dvd/archive/" |
| 576 | |
| 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) |
| 593 | try: |
| 594 | ep = EpisodeListParser(series_wanted, opts.chaptersp) |
| 595 | ep.parse_file(argv[0]) |
| 596 | pl = ep.done() |
| 597 | pl.write(SYS.stdout) |
| 598 | except (ExpectedError, IOError, OSError) as e: |
| 599 | LOC.report(e) |
| 600 | SYS.exit(2) |