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