| 1 | #! /usr/bin/python3 |
| 2 | ### -*- mode: python; coding: utf-8 -*- |
| 3 | |
| 4 | from contextlib import contextmanager |
| 5 | import errno as E |
| 6 | import optparse as OP |
| 7 | import os as OS |
| 8 | import re as RX |
| 9 | import sqlite3 as SQL |
| 10 | import subprocess as SP |
| 11 | import sys as SYS |
| 12 | |
| 13 | class ExpectedError (Exception): pass |
| 14 | |
| 15 | @contextmanager |
| 16 | def location(loc): |
| 17 | global LOC |
| 18 | old, LOC = LOC, loc |
| 19 | yield loc |
| 20 | LOC = old |
| 21 | |
| 22 | def filter(value, func = None, dflt = None): |
| 23 | if value is None: return dflt |
| 24 | elif func is None: return value |
| 25 | else: return func(value) |
| 26 | |
| 27 | def check(cond, msg): |
| 28 | if not cond: raise ExpectedError(msg) |
| 29 | |
| 30 | def lookup(dict, key, msg): |
| 31 | try: return dict[key] |
| 32 | except KeyError: raise ExpectedError(msg) |
| 33 | |
| 34 | def forget(dict, key): |
| 35 | try: del dict[key] |
| 36 | except KeyError: pass |
| 37 | |
| 38 | def getint(s): |
| 39 | if not s.isdigit(): raise ExpectedError("bad integer `%s'" % s) |
| 40 | return int(s) |
| 41 | |
| 42 | def getbool(s): |
| 43 | if s == "t": return True |
| 44 | elif s == "nil": return False |
| 45 | else: raise ExpectedError("bad boolean `%s'" % s) |
| 46 | |
| 47 | def quote(s): |
| 48 | if s is None: return "-" |
| 49 | else: return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"' |
| 50 | |
| 51 | class Words (object): |
| 52 | def __init__(me, s): |
| 53 | me._s = s |
| 54 | me._i, me._n = 0, len(s) |
| 55 | def _wordstart(me): |
| 56 | s, i, n = me._s, me._i, me._n |
| 57 | while i < n: |
| 58 | if not s[i].isspace(): return i |
| 59 | i += 1 |
| 60 | return -1 |
| 61 | def nextword(me): |
| 62 | s, n = me._s, me._n |
| 63 | begin = i = me._wordstart() |
| 64 | if begin < 0: return None |
| 65 | while i < n and not s[i].isspace(): i += 1 |
| 66 | me._i = i |
| 67 | return s[begin:i] |
| 68 | def rest(me): |
| 69 | s, n = me._s, me._n |
| 70 | begin = me._wordstart() |
| 71 | if begin < 0: return None |
| 72 | else: return s[begin:].rstrip() |
| 73 | |
| 74 | def program_output(*args, **kw): |
| 75 | try: return SP.check_output(*args, **kw) |
| 76 | except SP.CalledProcessError as e: |
| 77 | raise ExpectedError("program `%s' failed with code %d" % |
| 78 | (e.cmd, e.returncode)) |
| 79 | |
| 80 | URL_SAFE_P = 256*[False] |
| 81 | for ch in \ |
| 82 | b"ABCDEFGHIJKLMNOPQRSTUVWXYZ" \ |
| 83 | b"abcdefghijklmnopqrstuvwxyz" \ |
| 84 | b"0123456789" b"!$%_-.,/": |
| 85 | URL_SAFE_P[ch] = True |
| 86 | def urlencode(s): |
| 87 | return "".join((URL_SAFE_P[ch] and chr(ch) or "%%%02x" % ch |
| 88 | for ch in s.encode("UTF-8"))) |
| 89 | |
| 90 | PROG = OS.path.basename(SYS.argv[0]) |
| 91 | |
| 92 | class BaseLocation (object): |
| 93 | def report(me, exc): |
| 94 | SYS.stderr.write("%s: %s%s\n" % (PROG, me._loc(), exc)) |
| 95 | |
| 96 | class DummyLocation (BaseLocation): |
| 97 | def _loc(me): return "" |
| 98 | |
| 99 | class FileLocation (BaseLocation): |
| 100 | def __init__(me, fn, lno = 1): me._fn, me._lno = fn, lno |
| 101 | def _loc(me): return "%s:%d: " % (me._fn, me._lno) |
| 102 | def stepline(me): me._lno += 1 |
| 103 | |
| 104 | LOC = DummyLocation() |
| 105 | |
| 106 | ROOT = "/mnt/dvd/archive/" |
| 107 | DB = None |
| 108 | |
| 109 | def init_db(fn): |
| 110 | global DB |
| 111 | DB = SQL.connect(fn) |
| 112 | DB.cursor().execute("PRAGMA journal_mode = WAL") |
| 113 | |
| 114 | def setup_db(fn): |
| 115 | try: OS.unlink(fn) |
| 116 | except OSError as e: |
| 117 | if e.errno == E.ENOENT: pass |
| 118 | else: raise |
| 119 | init_db(fn) |
| 120 | DB.cursor().execute(""" |
| 121 | CREATE TABLE duration |
| 122 | (path TEXT NOT NULL, |
| 123 | title INTEGER NOT NULL, |
| 124 | start_chapter INTEGER NOT NULL, |
| 125 | end_chapter INTEGER NOT NULL, |
| 126 | inode INTEGER NOT NULL, |
| 127 | device INTEGER NOT NULL, |
| 128 | size INTEGER NOT NULL, |
| 129 | mtime REAL NOT NULL, |
| 130 | duration REAL NOT NULL, |
| 131 | PRIMARY KEY (path, title, start_chapter, end_chapter)); |
| 132 | """) |
| 133 | |
| 134 | class Source (object): |
| 135 | |
| 136 | PREFIX = "" |
| 137 | TITLEP = CHAPTERP = False |
| 138 | |
| 139 | def __init__(me, fn): |
| 140 | me.fn = fn |
| 141 | me.neps = None |
| 142 | me.used_titles = set() |
| 143 | me.used_chapters = set() |
| 144 | me.nuses = 0 |
| 145 | |
| 146 | def _duration(me, title, start_chapter, end_chapter): |
| 147 | return -1 |
| 148 | |
| 149 | def url_and_duration(me, title = -1, start_chapter = -1, end_chapter = -1): |
| 150 | if title == -1: |
| 151 | if me.TITLEP: raise ExpectedError("missing title number") |
| 152 | if start_chapter != -1 or end_chapter != -1: |
| 153 | raise ExpectedError("can't specify chapter without title") |
| 154 | suffix = "" |
| 155 | elif not me.TITLEP: |
| 156 | raise ExpectedError("can't specify title with `%s'" % me.fn) |
| 157 | elif start_chapter == -1: |
| 158 | if end_chapter != -1: |
| 159 | raise ExpectedError("can't specify end chapter without start chapter") |
| 160 | suffix = "#%d" % title |
| 161 | elif not me.CHAPTERP: |
| 162 | raise ExpectedError("can't specify chapter with `%s'" % me.fn) |
| 163 | elif end_chapter == -1: |
| 164 | suffix = "#%d:%d" % (title, start_chapter) |
| 165 | else: |
| 166 | suffix = "#%d:%d-%d:%d" % (title, start_chapter, title, end_chapter - 1) |
| 167 | |
| 168 | duration = None |
| 169 | if DB is None: |
| 170 | duration = me._duration(title, start_chapter, end_chapter) |
| 171 | else: |
| 172 | st = OS.stat(OS.path.join(ROOT, me.fn)) |
| 173 | duration = None |
| 174 | c = DB.cursor() |
| 175 | c.execute(""" |
| 176 | SELECT device, inode, size, mtime, duration FROM duration |
| 177 | WHERE path = ? AND title = ? AND |
| 178 | start_chapter = ? AND end_chapter = ? |
| 179 | """, [me.fn, title, start_chapter, end_chapter]) |
| 180 | row = c.fetchone() |
| 181 | foundp = False |
| 182 | if row is None: |
| 183 | duration = me._duration(title, start_chapter, end_chapter) |
| 184 | c.execute(""" |
| 185 | INSERT OR REPLACE INTO duration |
| 186 | (path, title, start_chapter, end_chapter, |
| 187 | device, inode, size, mtime, duration) |
| 188 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) |
| 189 | """, [me.fn, title, start_chapter, end_chapter, |
| 190 | st.st_dev, st.st_ino, st.st_size, st.st_mtime, |
| 191 | duration]) |
| 192 | else: |
| 193 | dev, ino, sz, mt, d = row |
| 194 | if (dev, ino, sz, mt) == \ |
| 195 | (st.st_dev, st.st_ino, st.st_size, st.st_mtime): |
| 196 | duration = d |
| 197 | else: |
| 198 | duration = me._duration(title, start_chapter, end_chapter) |
| 199 | c.execute(""" |
| 200 | UPDATE duration |
| 201 | SET device = ?, inode = ?, size = ?, mtime = ?, duration = ? |
| 202 | WHERE path = ? AND title = ? AND |
| 203 | start_chapter = ? AND end_chapter = ? |
| 204 | """, [st.st_dev, st.st_dev, st.st_size, st.st_mtime, duration, |
| 205 | me.fn, title, start_chapter, end_chapter]) |
| 206 | DB.commit() |
| 207 | |
| 208 | if end_chapter != -1: |
| 209 | keys = [(title, ch) for ch in range(start_chapter, end_chapter)] |
| 210 | set = me.used_chapters |
| 211 | else: |
| 212 | keys, set = [title], me.used_titles |
| 213 | for k in keys: |
| 214 | if k in set: |
| 215 | if title == -1: |
| 216 | raise ExpectedError("`%s' already used" % me.fn) |
| 217 | elif end_chapter == -1: |
| 218 | raise ExpectedError("`%s' title %d already used" % (me.fn, title)) |
| 219 | else: |
| 220 | raise ExpectedError("`%s' title %d chapter %d already used" % |
| 221 | (me.fn, title, k[1])) |
| 222 | if end_chapter == -1: |
| 223 | me.used_titles.add(title) |
| 224 | else: |
| 225 | for ch in range(start_chapter, end_chapter): |
| 226 | me.used_chapters.add((title, ch)) |
| 227 | return me.PREFIX + ROOT + urlencode(me.fn) + suffix, duration |
| 228 | |
| 229 | class DVDFile (Source): |
| 230 | PREFIX = "dvd://" |
| 231 | TITLEP = CHAPTERP = True |
| 232 | |
| 233 | def __init__(me, fn, *args, **kw): |
| 234 | super().__init__(fn, *args, **kw) |
| 235 | me.neps = 0 |
| 236 | |
| 237 | def _duration(me, title, start_chapter, end_chapter): |
| 238 | path = OS.path.join(ROOT, me.fn) |
| 239 | ntitle = int(program_output(["dvd-info", path, "titles"])) |
| 240 | if not 1 <= title <= ntitle: |
| 241 | raise ExpectedError("bad title %d for `%s': must be in 1 .. %d" % |
| 242 | (title, me.fn, ntitle)) |
| 243 | if start_chapter == -1: |
| 244 | durq = "duration:%d" % title |
| 245 | else: |
| 246 | nch = int(program_output(["dvd-info", path, "chapters:%d" % title])) |
| 247 | if end_chapter == -1: end_chapter = nch |
| 248 | else: end_chapter -= 1 |
| 249 | if not 1 <= start_chapter <= end_chapter <= nch: |
| 250 | raise ExpectedError("bad chapter range %d .. %d for `%s' title %d: " |
| 251 | "must be in 1 .. %d" % |
| 252 | (start_chapter, end_chapter, me.fn, title, nch)) |
| 253 | durq = "duration:%d.%d-%d" % (title, start_chapter, end_chapter) |
| 254 | duration = int(program_output(["dvd-info", path, durq])) |
| 255 | return duration |
| 256 | |
| 257 | class DVDSeason (object): |
| 258 | def __init__(me, i, title): |
| 259 | me.i = i |
| 260 | me.title = title |
| 261 | me.episodes = {} |
| 262 | def set_episode_disc(me, i, disc): |
| 263 | if i in me.episodes: |
| 264 | raise ExpectedError("season %d episode %d already taken" % (me.i, i)) |
| 265 | me.episodes[i] = disc; disc.neps += 1 |
| 266 | |
| 267 | def match_group(m, *groups, dflt = None, mustp = False): |
| 268 | for g in groups: |
| 269 | try: s = m.group(g) |
| 270 | except IndexError: continue |
| 271 | if s is not None: return s |
| 272 | if mustp: raise ValueError("no match found") |
| 273 | else: return dflt |
| 274 | |
| 275 | class DVDDir (object): |
| 276 | |
| 277 | _R_ISO_PRE = list(map(lambda pats: |
| 278 | list(map(lambda pat: |
| 279 | RX.compile("^" + pat + r"\.iso$", RX.X), |
| 280 | pats)), |
| 281 | [[r""" S (?P<si> \d+) \. \ (?P<stitle> .*) — (?: D \d+ \. \ )? |
| 282 | (?P<epex> .*) """, |
| 283 | r""" S (?P<si> \d+) (?: D \d+)? \. \ (?P<epex> .*) """, |
| 284 | r""" S (?P<si> \d+) \. \ (?P<epex> E \d+ .*) """, |
| 285 | r""" S (?P<si> \d+) (?P<epex> E \d+) \. \ .* """], |
| 286 | [r""" (?P<si> \d+) [A-Z]? \. \ (?P<stitle> .*) — (?P<epex> .*) """], |
| 287 | [r""" \d+ \. \ (?P<epex> [ES] \d+ .*) """], |
| 288 | [r""" (?P<epnum> \d+ ) \. \ .* """]])) |
| 289 | |
| 290 | _R_ISO_EP = RX.compile(r""" ^ |
| 291 | (?: S (?P<si> \d+) \ )? |
| 292 | E (?P<ei> \d+) (?: – (?P<ej> \d+))? $ |
| 293 | """, RX.X) |
| 294 | |
| 295 | def __init__(me, dir): |
| 296 | me.dir = dir |
| 297 | fns = OS.listdir(OS.path.join(ROOT, dir)) |
| 298 | fns.sort() |
| 299 | season = None |
| 300 | seasons = {} |
| 301 | styles = me._R_ISO_PRE |
| 302 | for fn in fns: |
| 303 | path = OS.path.join(dir, fn) |
| 304 | if not fn.endswith(".iso"): continue |
| 305 | #print(";; `%s'" % path, file = SYS.stderr) |
| 306 | for sty in styles: |
| 307 | for r in sty: |
| 308 | m = r.match(fn) |
| 309 | if m: styles = [sty]; break |
| 310 | else: |
| 311 | continue |
| 312 | break |
| 313 | else: |
| 314 | #print(";;\tignored (regex mismatch)", file = SYS.stderr) |
| 315 | continue |
| 316 | |
| 317 | si = filter(match_group(m, "si"), int) |
| 318 | stitle = match_group(m, "stitle") |
| 319 | |
| 320 | check(si is not None or stitle is None, |
| 321 | "explicit season title without number in `%s'" % fn) |
| 322 | if si is not None: |
| 323 | if season is None or si != season.i: |
| 324 | check(season is None or si == season.i + 1, |
| 325 | "season %d /= %d" % |
| 326 | (si, season is None and -1 or season.i + 1)) |
| 327 | check(si not in seasons, "season %d already seen" % si) |
| 328 | seasons[si] = season = DVDSeason(si, stitle) |
| 329 | else: |
| 330 | check(stitle == season.title, |
| 331 | "season title `%s' /= `%s'" % (stitle, season.title)) |
| 332 | |
| 333 | disc = DVDFile(path) |
| 334 | ts = season |
| 335 | any, bad = False, False |
| 336 | epnum = match_group(m, "epnum") |
| 337 | if epnum is not None: eplist = ["E" + epnum] |
| 338 | else: eplist = match_group(m, "epex", mustp = True).split(", ") |
| 339 | for eprange in eplist: |
| 340 | mm = me._R_ISO_EP.match(eprange) |
| 341 | if mm is None: |
| 342 | #print(";;\t`%s'?" % eprange, file = SYS.stderr) |
| 343 | bad = True; continue |
| 344 | if not any: any = True |
| 345 | i = filter(mm.group("si"), int) |
| 346 | if i is not None: |
| 347 | try: ts = seasons[i] |
| 348 | except KeyError: ts = seasons[i] = DVDSeason(i, None) |
| 349 | if ts is None: |
| 350 | ts = season = seasons[1] = DVDSeason(1, None) |
| 351 | start = filter(mm.group("ei"), int) |
| 352 | end = filter(mm.group("ej"), int, start) |
| 353 | for k in range(start, end + 1): |
| 354 | ts.set_episode_disc(k, disc) |
| 355 | #print(";;\tepisode %d.%d" % (ts.i, k), file = SYS.stderr) |
| 356 | if not any: |
| 357 | #print(";;\tignored", file = SYS.stderr) |
| 358 | pass |
| 359 | elif bad: |
| 360 | raise ExpectedError("bad ep list in `%s'", fn) |
| 361 | me.seasons = seasons |
| 362 | |
| 363 | class SingleFileDir (object): |
| 364 | |
| 365 | _CHECK_COMPLETE = True |
| 366 | |
| 367 | def __init__(me, dir): |
| 368 | me.dir = dir |
| 369 | fns = OS.listdir(OS.path.join(ROOT, dir)) |
| 370 | fns.sort() |
| 371 | episodes = {} |
| 372 | last_i = 0 |
| 373 | rx = RX.compile(r""" |
| 374 | E (\d+) |
| 375 | (?: \. \ (.*))? |
| 376 | %s $ |
| 377 | """ % RX.escape(me._EXT), RX.X) |
| 378 | |
| 379 | for fn in fns: |
| 380 | path = OS.path.join(dir, fn) |
| 381 | if not fn.endswith(me._EXT): continue |
| 382 | m = rx.match(fn) |
| 383 | if not m: continue |
| 384 | i = filter(m.group(1), int) |
| 385 | etitle = m.group(2) |
| 386 | if me._CHECK_COMPLETE: |
| 387 | check(i == last_i + 1, "episode %d /= %d" % (i, last_i + 1)) |
| 388 | episodes[i] = me._mkepisode(path, i) |
| 389 | last_i = i |
| 390 | me.episodes = episodes |
| 391 | |
| 392 | class AudioFile (Source): |
| 393 | PREFIX = "file://" |
| 394 | TITLEP = CHAPTERP = False |
| 395 | |
| 396 | def _duration(me, title, start_chapter, end_chaptwr): |
| 397 | out = program_output(["metaflac", |
| 398 | "--show-total-samples", "--show-sample-rate", |
| 399 | OS.path.join(ROOT, me.fn)]) |
| 400 | nsamples, hz = map(float, out.split()) |
| 401 | return int(nsamples/hz) |
| 402 | |
| 403 | class AudioEpisode (AudioFile): |
| 404 | def __init__(me, fn, i, *args, **kw): |
| 405 | super().__init__(fn, *args, **kw) |
| 406 | me.i = i |
| 407 | |
| 408 | class AudioDir (SingleFileDir): |
| 409 | _EXT = ".flac" |
| 410 | |
| 411 | def _mkepisode(me, path, i): |
| 412 | return AudioEpisode(path, i) |
| 413 | |
| 414 | class VideoFile (Source): |
| 415 | PREFIX = "file://" |
| 416 | TITLEP = CHAPTERP = False |
| 417 | |
| 418 | def _duration(me, title, start_chapter, end_chaptwr): |
| 419 | out = program_output(["mediainfo", "--output=General;%Duration%", |
| 420 | OS.path.join(ROOT, me.fn)]) |
| 421 | return int(out)//1000 |
| 422 | |
| 423 | class VideoEpisode (VideoFile): |
| 424 | def __init__(me, fn, i, *args, **kw): |
| 425 | super().__init__(fn, *args, **kw) |
| 426 | me.i = i |
| 427 | |
| 428 | class VideoDir (SingleFileDir): |
| 429 | _EXT = ".mp4" |
| 430 | _CHECK_COMPLETE = False |
| 431 | |
| 432 | def _mkepisode(me, path, i): |
| 433 | return VideoEpisode(path, i) |
| 434 | |
| 435 | class Chapter (object): |
| 436 | def __init__(me, episode, title, i): |
| 437 | me.title, me.i = title, i |
| 438 | me.url, me.duration = \ |
| 439 | episode.source.url_and_duration(episode.tno, i, i + 1) |
| 440 | |
| 441 | class Episode (object): |
| 442 | def __init__(me, season, i, neps, title, src, series_title_p = True, |
| 443 | tno = -1, startch = -1, endch = -1): |
| 444 | me.season = season |
| 445 | me.i, me.neps, me.title = i, neps, title |
| 446 | me.chapters = [] |
| 447 | me.source, me.tno = src, tno |
| 448 | me.series_title_p = series_title_p |
| 449 | me.tno, me.start_chapter, me.end_chapter = tno, startch, endch |
| 450 | me.url, me.duration = src.url_and_duration(tno, startch, endch) |
| 451 | def add_chapter(me, title, j): |
| 452 | ch = Chapter(me, title, j) |
| 453 | me.chapters.append(ch) |
| 454 | return ch |
| 455 | def label(me): |
| 456 | return me.season._eplabel(me.i, me.neps, me.title) |
| 457 | |
| 458 | class BaseSeason (object): |
| 459 | def __init__(me, series, implicitp = False): |
| 460 | me.series = series |
| 461 | me.episodes = [] |
| 462 | me.implicitp = implicitp |
| 463 | me.ep_i, episodes = 1, [] |
| 464 | def add_episode(me, j, neps, title, src, series_title_p, |
| 465 | tno, startch, endch): |
| 466 | ep = Episode(me, j, neps, title, src, series_title_p, |
| 467 | tno, startch, endch) |
| 468 | me.episodes.append(ep) |
| 469 | src.nuses += neps; me.ep_i += neps |
| 470 | return ep |
| 471 | def _epnames(me, i, neps): |
| 472 | playlist = me.series.playlist |
| 473 | if neps == 1: return playlist.epname, ["%d" % i] |
| 474 | elif neps == 2: return playlist.epnames, ["%d" % i, "%d" % (i + 1)] |
| 475 | else: return playlist.epnames, ["%d–%d" % (i, i + neps - 1)] |
| 476 | |
| 477 | class Season (BaseSeason): |
| 478 | def __init__(me, series, title, i, *args, **kw): |
| 479 | super().__init__(series, *args, **kw) |
| 480 | me.title, me.i = title, i |
| 481 | def _eplabel(me, i, neps, title): |
| 482 | epname, epn = me._epnames(i, neps) |
| 483 | if title is None: |
| 484 | if me.implicitp: |
| 485 | label = "%s %s" % (epname, ", ".join(epn)) |
| 486 | elif me.title is None: |
| 487 | label = "%s %s" % \ |
| 488 | (epname, ", ".join("%d.%s" % (me.i, e) for e in epn)) |
| 489 | else: |
| 490 | label = "%s: %s %s" % (me.title, epname, ", ".join(epn)) |
| 491 | else: |
| 492 | if me.implicitp: |
| 493 | label = "%s. %s" % (", ".join(epn), title) |
| 494 | elif me.title is None: |
| 495 | label = "%s. %s" % \ |
| 496 | (", ".join("%d.%s" % (me.i, e) for e in epn), title) |
| 497 | else: |
| 498 | label = "%s: %s. %s" % (me.title, ", ".join(epn), title) |
| 499 | return label |
| 500 | |
| 501 | class MovieSeason (BaseSeason): |
| 502 | def __init__(me, series, title, *args, **kw): |
| 503 | super().__init__(series, *args, **kw) |
| 504 | me.title = title |
| 505 | me.i = None |
| 506 | def add_episode(me, j, neps, title, src, series_title_p, |
| 507 | tno, startch, endch): |
| 508 | if me.title is None and title is None: |
| 509 | raise ExpectedError("movie or movie season must have a title") |
| 510 | return super().add_episode(j, neps, title, src, series_title_p, |
| 511 | tno, startch, endch) |
| 512 | def _eplabel(me, i, neps, title): |
| 513 | if me.title is None: |
| 514 | label = title |
| 515 | elif title is None: |
| 516 | epname, epn = me._epnames(i, neps) |
| 517 | label = "%s: %s %s" % (me.title, epname, ", ".join(epn)) |
| 518 | else: |
| 519 | label = "%s: %s" % (me.title, title) |
| 520 | return label |
| 521 | |
| 522 | class Series (object): |
| 523 | def __init__(me, playlist, name, title = None, |
| 524 | full_title = None, wantedp = True): |
| 525 | me.playlist = playlist |
| 526 | me.name, me.title, me.full_title = name, title, full_title |
| 527 | me.cur_season = None |
| 528 | me.wantedp = wantedp |
| 529 | def _add_season(me, season): |
| 530 | me.cur_season = season |
| 531 | def add_season(me, title, i, implicitp = False): |
| 532 | me._add_season(Season(me, title, i, implicitp)) |
| 533 | def add_movies(me, title = None): |
| 534 | me._add_season(MovieSeason(me, title)) |
| 535 | def ensure_season(me): |
| 536 | if me.cur_season is None: me.add_season(None, 1, implicitp = True) |
| 537 | return me.cur_season |
| 538 | def end_season(me): |
| 539 | me.cur_season = None |
| 540 | |
| 541 | class Playlist (object): |
| 542 | |
| 543 | def __init__(me): |
| 544 | me.seasons = [] |
| 545 | me.episodes = [] |
| 546 | me.epname, me.epnames = "Episode", "Episodes" |
| 547 | me.nseries = 0 |
| 548 | me.single_series_p = False |
| 549 | me.series_title = None |
| 550 | |
| 551 | def add_episode(me, episode): |
| 552 | me.episodes.append(episode) |
| 553 | |
| 554 | def done_season(me): |
| 555 | if me.episodes: |
| 556 | me.seasons.append(me.episodes) |
| 557 | me.episodes = [] |
| 558 | |
| 559 | def write(me, f): |
| 560 | f.write("#EXTM3U\n") |
| 561 | for season in me.seasons: |
| 562 | f.write("\n") |
| 563 | for ep in season: |
| 564 | label = ep.label() |
| 565 | if me.nseries > 1 and ep.series_title_p and \ |
| 566 | ep.season.series.title is not None: |
| 567 | if ep.season.i is None: sep = ": " |
| 568 | else: sep = " " |
| 569 | label = ep.season.series.title + sep + label |
| 570 | if not ep.chapters: |
| 571 | f.write("#EXTINF:%d,,%s\n%s\n" % (ep.duration, label, ep.url)) |
| 572 | else: |
| 573 | for ch in ep.chapters: |
| 574 | f.write("#EXTINF:%d,,%s: %s\n%s\n" % |
| 575 | (ch.duration, label, ch.title, ch.url)) |
| 576 | |
| 577 | def dump(me, f): |
| 578 | if opts.list_name is not None: f.write("LIST %s\n" % opts.list_name) |
| 579 | if me.series_title is not None and \ |
| 580 | me.nseries > 1 and not me.single_series_p: |
| 581 | raise ExpectedError("can't force series name for multi-series list") |
| 582 | series = set() |
| 583 | if me.single_series_p: |
| 584 | f.write("SERIES - %s\n" % quote(me.series_title)) |
| 585 | for season in me.seasons: |
| 586 | for ep in season: |
| 587 | label = ep.label() |
| 588 | title = ep.season.series.full_title |
| 589 | if me.single_series_p: |
| 590 | stag = "-" |
| 591 | if title is not None: label = title + " " + label |
| 592 | else: |
| 593 | if title is None: title = me.series_title |
| 594 | stag = ep.season.series.name |
| 595 | if stag is None: stag = "-" |
| 596 | if stag not in series: |
| 597 | f.write("SERIES %s %s\n" % (stag, quote(title))) |
| 598 | series.add(stag) |
| 599 | f.write("ENTRY %s %s %s %d %d %d %g\n" % |
| 600 | (stag, quote(label), quote(ep.source.fn), |
| 601 | ep.tno, ep.start_chapter, ep.end_chapter, ep.duration)) |
| 602 | |
| 603 | def write_deps(me, f, out): |
| 604 | deps = set() |
| 605 | for season in me.seasons: |
| 606 | for ep in season: deps.add(ep.source.fn) |
| 607 | f.write("### -*-makefile-*-\n") |
| 608 | f.write("%s: $(call check-deps, %s," % (out, out)) |
| 609 | for dep in sorted(deps): |
| 610 | f.write(" \\\n\t'%s'" % |
| 611 | OS.path.join(ROOT, dep) |
| 612 | .replace(",", "$(comma)") |
| 613 | .replace("'", "'\\''")) |
| 614 | f.write(")\n") |
| 615 | |
| 616 | DEFAULT_EXPVAR = 0.05 |
| 617 | R_DURMULT = RX.compile(r""" ^ |
| 618 | (\d+ (?: \. \d+)?) x |
| 619 | $ """, RX.X) |
| 620 | R_DUR = RX.compile(r""" ^ |
| 621 | (?: (?: (\d+) :)? (\d+) :)? (\d+) |
| 622 | (?: / (\d+ (?: \. \d+)?) \%)? |
| 623 | $ """, RX.X) |
| 624 | def parse_duration(s, base = None, basevar = DEFAULT_EXPVAR): |
| 625 | if base is not None: |
| 626 | m = R_DURMULT.match(s) |
| 627 | if m is not None: return base*float(m.group(1)), basevar |
| 628 | m = R_DUR.match(s) |
| 629 | if not m: raise ExpectedError("invalid duration spec `%s'" % s) |
| 630 | hr, min, sec = map(lambda g: filter(m.group(g), int, 0), [1, 2, 3]) |
| 631 | var = filter(m.group(4), lambda x: float(x)/100.0) |
| 632 | if var is None: var = DEFAULT_EXPVAR |
| 633 | return 3600*hr + 60*min + sec, var |
| 634 | def format_duration(d): |
| 635 | if d >= 3600: return "%d:%02d:%02d" % (d//3600, (d//60)%60, d%60) |
| 636 | elif d >= 60: return "%d:%02d" % (d//60, d%60) |
| 637 | else: return "%d s" % d |
| 638 | |
| 639 | MODE_UNSET = 0 |
| 640 | MODE_SINGLE = 1 |
| 641 | MODE_MULTI = 2 |
| 642 | |
| 643 | class EpisodeListParser (object): |
| 644 | |
| 645 | def __init__(me, series_wanted = None, chapters_wanted_p = False): |
| 646 | me._pl = Playlist() |
| 647 | me._cur_episode = me._cur_chapter = None |
| 648 | me._series = {}; me._vdirs = {}; me._sfdirs = {}; me._isos = {} |
| 649 | me._series_wanted = series_wanted |
| 650 | me._chaptersp = chapters_wanted_p |
| 651 | me._explen, me._expvar = None, DEFAULT_EXPVAR |
| 652 | if series_wanted is None: me._mode = MODE_UNSET |
| 653 | else: me._mode = MODE_MULTI |
| 654 | |
| 655 | def _bad_keyval(me, cmd, k, v): |
| 656 | raise ExpectedError("invalid `!%s' option `%s'" % |
| 657 | (cmd, v if k is None else k)) |
| 658 | |
| 659 | def _keyvals(me, opts): |
| 660 | if opts is not None: |
| 661 | for kv in opts.split(","): |
| 662 | try: sep = kv.index("=") |
| 663 | except ValueError: yield None, kv |
| 664 | else: yield kv[:sep], kv[sep + 1:] |
| 665 | |
| 666 | def _set_mode(me, mode): |
| 667 | if me._mode == MODE_UNSET: |
| 668 | me._mode = mode |
| 669 | elif me._mode != mode: |
| 670 | raise ExpectedError("inconsistent single-/multi-series usage") |
| 671 | |
| 672 | def _get_series(me, name): |
| 673 | if name is None: |
| 674 | me._set_mode(MODE_SINGLE) |
| 675 | try: series = me._series[None] |
| 676 | except KeyError: |
| 677 | series = me._series[None] = Series(me._pl, None) |
| 678 | me._pl.nseries += 1 |
| 679 | else: |
| 680 | me._set_mode(MODE_MULTI) |
| 681 | series = lookup(me._series, name, "unknown series `%s'" % name) |
| 682 | return series |
| 683 | |
| 684 | def _opts_series(me, cmd, opts): |
| 685 | name = None |
| 686 | for k, v in me._keyvals(opts): |
| 687 | if k is None: name = v |
| 688 | else: me._bad_keyval(cmd, k, v) |
| 689 | return me._get_series(name) |
| 690 | |
| 691 | def _auto_epsrc(me, series): |
| 692 | dir = lookup(me._vdirs, series.name, "no active video directory") |
| 693 | season = series.ensure_season() |
| 694 | check(season.i is not None, "must use explicit iso for movie seasons") |
| 695 | vseason = lookup(dir.seasons, season.i, |
| 696 | "season %d not found in video dir `%s'" % |
| 697 | (season.i, dir.dir)) |
| 698 | src = lookup(vseason.episodes, season.ep_i, |
| 699 | "episode %d.%d not found in video dir `%s'" % |
| 700 | (season.i, season.ep_i, dir.dir)) |
| 701 | return src |
| 702 | |
| 703 | def _process_cmd(me, ww): |
| 704 | |
| 705 | cmd = ww.nextword(); check(cmd is not None, "missing command") |
| 706 | try: sep = cmd.index(":") |
| 707 | except ValueError: opts = None |
| 708 | else: cmd, opts = cmd[:sep], cmd[sep + 1:] |
| 709 | |
| 710 | if cmd == "title": |
| 711 | for k, v in me._keyvals(opts): me._bad_keyval("title", k, v) |
| 712 | title = ww.rest(); check(title is not None, "missing title") |
| 713 | check(me._pl.series_title is None, "already set a title") |
| 714 | me._pl.series_title = title |
| 715 | |
| 716 | elif cmd == "single": |
| 717 | for k, v in me._keyvals(opts): me._bad_keyval("single", k, v) |
| 718 | check(ww.rest() is None, "trailing junk") |
| 719 | check(not me._pl.single_series_p, "single-series already set") |
| 720 | me._pl.single_series_p = True |
| 721 | |
| 722 | elif cmd == "series": |
| 723 | name = None |
| 724 | for k, v in me._keyvals(opts): |
| 725 | if k is None: name = v |
| 726 | else: me._bad_keyval(cmd, k, v) |
| 727 | check(name is not None, "missing series name") |
| 728 | check(name not in me._series, "series `%s' already defined" % name) |
| 729 | title = ww.rest() |
| 730 | if title is None: |
| 731 | full = None |
| 732 | else: |
| 733 | try: sep = title.index("::") |
| 734 | except ValueError: full = title |
| 735 | else: |
| 736 | full = title[sep + 2:].strip() |
| 737 | if sep == 0: title = None |
| 738 | else: title = title[:sep].strip() |
| 739 | me._set_mode(MODE_MULTI) |
| 740 | me._series[name] = series = Series(me._pl, name, title, full, |
| 741 | me._series_wanted is None or |
| 742 | name in me._series_wanted) |
| 743 | if series.wantedp: me._pl.nseries += 1 |
| 744 | |
| 745 | elif cmd == "season": |
| 746 | series = me._opts_series(cmd, opts) |
| 747 | w = ww.nextword(); |
| 748 | check(w is not None, "missing season number") |
| 749 | if w == "-": |
| 750 | if not series.wantedp: return |
| 751 | series.add_movies(ww.rest()) |
| 752 | else: |
| 753 | title = ww.rest(); i = getint(w) |
| 754 | if not series.wantedp: return |
| 755 | series.add_season(ww.rest(), getint(w), implicitp = False) |
| 756 | me._cur_episode = me._cur_chapter = None |
| 757 | me._pl.done_season() |
| 758 | |
| 759 | elif cmd == "explen": |
| 760 | w = ww.rest(); check(w is not None, "missing duration spec") |
| 761 | if w == "-": |
| 762 | me._explen, me._expvar = None, DEFAULT_EXPVAR |
| 763 | else: |
| 764 | d, v = parse_duration(w) |
| 765 | me._explen = d |
| 766 | if v is not None: me._expvar = v |
| 767 | |
| 768 | elif cmd == "epname": |
| 769 | for k, v in me._keyvals(opts): me._bad_keyval("epname", k, v) |
| 770 | name = ww.rest(); check(name is not None, "missing episode name") |
| 771 | try: sep = name.index("::") |
| 772 | except ValueError: names = name + "s" |
| 773 | else: name, names = name[:sep], name[sep + 2:] |
| 774 | me._pl.epname, me._pl.epnames = name, names |
| 775 | |
| 776 | elif cmd == "epno": |
| 777 | series = me._opts_series(cmd, opts) |
| 778 | w = ww.rest(); check(w is not None, "missing episode number") |
| 779 | epi = getint(w) |
| 780 | if not series.wantedp: return |
| 781 | series.ensure_season().ep_i = epi |
| 782 | |
| 783 | elif cmd == "dvd": |
| 784 | series = me._opts_series(cmd, opts) |
| 785 | fn = ww.rest(); check(fn is not None, "missing filename") |
| 786 | if not series.wantedp: return |
| 787 | if fn == "-": forget(me._isos, series.name) |
| 788 | else: |
| 789 | check(OS.path.exists(OS.path.join(ROOT, fn)), |
| 790 | "dvd iso file `%s' not found" % fn) |
| 791 | me._isos[series.name] = DVDFile(fn) |
| 792 | |
| 793 | elif cmd == "dvddir": |
| 794 | series = me._opts_series(cmd, opts) |
| 795 | dir = ww.rest(); check(dir is not None, "missing directory") |
| 796 | if not series.wantedp: return |
| 797 | if dir == "-": forget(me._vdirs, series.name) |
| 798 | else: me._vdirs[series.name] = DVDDir(dir) |
| 799 | |
| 800 | elif cmd == "vdir": |
| 801 | series = me._opts_series(cmd, opts) |
| 802 | dir = ww.rest(); check(dir is not None, "missing directory") |
| 803 | if not series.wantedp: return |
| 804 | if dir == "-": forget(me._sfdirs, series.name) |
| 805 | else: me._sfdirs[series.name] = VideoDir(dir) |
| 806 | |
| 807 | elif cmd == "adir": |
| 808 | series = me._opts_series(cmd, opts) |
| 809 | dir = ww.rest(); check(dir is not None, "missing directory") |
| 810 | if not series.wantedp: return |
| 811 | if dir == "-": forget(me._sfdirs, series.name) |
| 812 | else: me._sfdirs[series.name] = AudioDir(dir) |
| 813 | |
| 814 | elif cmd == "displaced": |
| 815 | series = me._opts_series(cmd, opts) |
| 816 | w = ww.rest(); check(w is not None, "missing count"); n = getint(w) |
| 817 | src = me._auto_epsrc(series) |
| 818 | src.nuses += n |
| 819 | |
| 820 | else: |
| 821 | raise ExpectedError("unknown command `%s'" % cmd) |
| 822 | |
| 823 | def _process_episode(me, ww): |
| 824 | |
| 825 | opts = ww.nextword(); check(opts is not None, "missing title/options") |
| 826 | ti = -1; sname = None; neps = 1; epi = None; loch = hich = -1 |
| 827 | explen, expvar, explicitlen = me._explen, me._expvar, False |
| 828 | series_title_p = True |
| 829 | for k, v in me._keyvals(opts): |
| 830 | if k is None: |
| 831 | if v.isdigit(): ti = int(v) |
| 832 | elif v == "-": ti = -1 |
| 833 | else: sname = v |
| 834 | elif k == "s": sname = v |
| 835 | elif k == "n": neps = getint(v) |
| 836 | elif k == "ep": epi = getint(v) |
| 837 | elif k == "st": series_title_p = getbool(v) |
| 838 | elif k == "l": |
| 839 | if v == "-": me._explen, me._expvar = None, DEFAULT_EXPVAR |
| 840 | else: |
| 841 | explen, expvar = parse_duration(v, explen, expvar) |
| 842 | explicitlen = True |
| 843 | elif k == "ch": |
| 844 | try: sep = v.index("-") |
| 845 | except ValueError: loch, hich = getint(v), -1 |
| 846 | else: loch, hich = getint(v[:sep]), getint(v[sep + 1:]) + 1 |
| 847 | else: raise ExpectedError("unknown episode option `%s'" % k) |
| 848 | check(ti is not None, "missing title number") |
| 849 | series = me._get_series(sname) |
| 850 | me._cur_chapter = None |
| 851 | |
| 852 | title = ww.rest() |
| 853 | if not series.wantedp: return |
| 854 | season = series.ensure_season() |
| 855 | if epi is None: epi = season.ep_i |
| 856 | |
| 857 | if ti == -1: |
| 858 | check(season.implicitp or season.i is None, |
| 859 | "audio source, but explicit non-movie season") |
| 860 | dir = lookup(me._sfdirs, series.name, |
| 861 | "no title, and no single-file directory") |
| 862 | src = lookup(dir.episodes, season.ep_i, |
| 863 | "episode %d not found in single-file dir `%s'" % |
| 864 | (epi, dir.dir)) |
| 865 | |
| 866 | else: |
| 867 | try: src = me._isos[series.name] |
| 868 | except KeyError: src = me._auto_epsrc(series) |
| 869 | |
| 870 | episode = season.add_episode(epi, neps, title, src, |
| 871 | series_title_p, ti, loch, hich) |
| 872 | |
| 873 | if episode.duration != -1 and explen is not None: |
| 874 | if not explicitlen: explen *= neps |
| 875 | if not explen*(1 - expvar) <= episode.duration <= explen*(1 + expvar): |
| 876 | if season.i is None: epid = "episode %d" % epi |
| 877 | else: epid = "episode %d.%d" % (season.i, epi) |
| 878 | raise ExpectedError \ |
| 879 | ("%s duration %s %g%% > %g%% from expected %s" % |
| 880 | (epid, format_duration(episode.duration), |
| 881 | abs(100*(episode.duration - explen)/explen), 100*expvar, |
| 882 | format_duration(explen))) |
| 883 | me._pl.add_episode(episode) |
| 884 | me._cur_episode = episode |
| 885 | |
| 886 | def _process_chapter(me, ww): |
| 887 | check(me._cur_episode is not None, "no current episode") |
| 888 | check(me._cur_episode.source.CHAPTERP, |
| 889 | "episode source doesn't allow chapters") |
| 890 | if me._chaptersp: |
| 891 | if me._cur_chapter is None: i = 1 |
| 892 | else: i = me._cur_chapter.i + 1 |
| 893 | me._cur_chapter = me._cur_episode.add_chapter(ww.rest(), i) |
| 894 | |
| 895 | def parse_file(me, fn): |
| 896 | with location(FileLocation(fn, 0)) as floc: |
| 897 | with open(fn, "r") as f: |
| 898 | for line in f: |
| 899 | floc.stepline() |
| 900 | sline = line.lstrip() |
| 901 | if sline == "" or sline.startswith(";"): continue |
| 902 | |
| 903 | if line.startswith("!"): me._process_cmd(Words(line[1:])) |
| 904 | elif not line[0].isspace(): me._process_episode(Words(line)) |
| 905 | else: me._process_chapter(Words(line)) |
| 906 | me._pl.done_season() |
| 907 | |
| 908 | def done(me): |
| 909 | discs = set() |
| 910 | for name, vdir in me._vdirs.items(): |
| 911 | if not me._series[name].wantedp: continue |
| 912 | for s in vdir.seasons.values(): |
| 913 | for d in s.episodes.values(): |
| 914 | discs.add(d) |
| 915 | for sfdir in me._sfdirs.values(): |
| 916 | for d in sfdir.episodes.values(): |
| 917 | discs.add(d) |
| 918 | for d in sorted(discs, key = lambda d: d.fn): |
| 919 | if d.neps is not None and d.neps != d.nuses: |
| 920 | raise ExpectedError("disc `%s' has %d episodes, used %d times" % |
| 921 | (d.fn, d.neps, d.nuses)) |
| 922 | return me._pl |
| 923 | |
| 924 | op = OP.OptionParser \ |
| 925 | (usage = "%prog [-Dc] [-L NAME] [-M DEPS] [-d CACHE] [-o OUT] [-s SERIES] EPLS\n" |
| 926 | "%prog -i -d CACHE", |
| 927 | description = "Generate M3U playlists from an episode list.") |
| 928 | op.add_option("-D", "--dump", |
| 929 | dest = "dump", action = "store_true", default = False, |
| 930 | help = "Dump playlist in machine-readable form") |
| 931 | op.add_option("-L", "--list-name", metavar = "NAME", |
| 932 | dest = "list_name", type = "str", default = None, |
| 933 | help = "Set the playlist name") |
| 934 | op.add_option("-M", "--make-deps", metavar = "DEPS", |
| 935 | dest = "deps", type = "str", default = None, |
| 936 | help = "Write a `make' fragment for dependencies") |
| 937 | op.add_option("-c", "--chapters", |
| 938 | dest = "chaptersp", action = "store_true", default = False, |
| 939 | help = "Output individual chapter names") |
| 940 | op.add_option("-i", "--init-db", |
| 941 | dest = "initdbp", action = "store_true", default = False, |
| 942 | help = "Initialize the database") |
| 943 | op.add_option("-d", "--database", metavar = "CACHE", |
| 944 | dest = "database", type = "str", default = None, |
| 945 | help = "Set filename for cache database") |
| 946 | op.add_option("-o", "--output", metavar = "OUT", |
| 947 | dest = "output", type = "str", default = None, |
| 948 | help = "Write output playlist to OUT") |
| 949 | op.add_option("-O", "--fake-output", metavar = "OUT", |
| 950 | dest = "fakeout", type = "str", default = None, |
| 951 | help = "Pretend output goes to OUT for purposes of `-M'") |
| 952 | op.add_option("-s", "--series", metavar = "SERIES", |
| 953 | dest = "series", type = "str", default = None, |
| 954 | help = "Output only the listed SERIES (comma-separated)") |
| 955 | try: |
| 956 | opts, argv = op.parse_args() |
| 957 | |
| 958 | if opts.initdbp: |
| 959 | if opts.chaptersp or opts.series is not None or \ |
| 960 | opts.output is not None or opts.deps is not None or \ |
| 961 | opts.fakeout is not None or \ |
| 962 | opts.database is None or len(argv): |
| 963 | op.print_usage(file = SYS.stderr); SYS.exit(2) |
| 964 | setup_db(opts.database) |
| 965 | |
| 966 | else: |
| 967 | if len(argv) != 1: op.print_usage(file = SYS.stderr); SYS.exit(2) |
| 968 | if opts.database is not None: init_db(opts.database) |
| 969 | if opts.series is None: |
| 970 | series_wanted = None |
| 971 | else: |
| 972 | series_wanted = set() |
| 973 | for name in opts.series.split(","): series_wanted.add(name) |
| 974 | if opts.deps is not None: |
| 975 | if (opts.output is None or opts.output == "-") and opts.fakeout is None: |
| 976 | raise ExpectedError("can't write dep fragment without output file") |
| 977 | if opts.fakeout is None: opts.fakeout = opts.output |
| 978 | else: |
| 979 | if opts.fakeout is not None: |
| 980 | raise ExpectedError("fake output set but no dep fragment") |
| 981 | |
| 982 | ep = EpisodeListParser(series_wanted, opts.chaptersp) |
| 983 | ep.parse_file(argv[0]) |
| 984 | pl = ep.done() |
| 985 | |
| 986 | if opts.list_name is None: |
| 987 | opts.list_name, _ = OS.path.splitext(OS.path.basename(argv[0])) |
| 988 | |
| 989 | if opts.dump: outfn = pl.dump |
| 990 | else: outfn = pl.write |
| 991 | if opts.output is None or opts.output == "-": |
| 992 | outfn(SYS.stdout) |
| 993 | else: |
| 994 | with open(opts.output, "w") as f: outfn(f) |
| 995 | |
| 996 | if opts.deps: |
| 997 | if opts.deps == "-": |
| 998 | pl.write_deps(SYS.stdout, opts.fakeout) |
| 999 | else: |
| 1000 | with open(opts.deps, "w") as f: pl.write_deps(f, opts.fakeout) |
| 1001 | |
| 1002 | except (ExpectedError, IOError, OSError) as e: |
| 1003 | LOC.report(e) |
| 1004 | SYS.exit(2) |