mkm3u, stargate.epls, star-wars.epls: Introduce `full series titles'.
[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 130class Source (object):
3b9c615b 131
04a05f7f
MW
132 PREFIX = ""
133 TITLEP = CHAPTERP = False
3b9c615b 134
04a05f7f
MW
135 def __init__(me, fn):
136 me.fn = fn
b092d511 137 me.neps = None
4ae03d6c 138 me.used_titles = set()
b092d511
MW
139 me.used_chapters = set()
140 me.nuses = 0
3b9c615b 141
1bec83d0
MW
142 def _duration(me, title, start_chapter, end_chapter):
143 return -1
d4f60571
MW
144
145 def url_and_duration(me, title = -1, start_chapter = -1, end_chapter = -1):
146 if title == -1:
04a05f7f 147 if me.TITLEP: raise ExpectedError("missing title number")
d4f60571 148 if start_chapter != -1 or end_chapter != -1:
04a05f7f
MW
149 raise ExpectedError("can't specify chapter without title")
150 suffix = ""
151 elif not me.TITLEP:
152 raise ExpectedError("can't specify title with `%s'" % me.fn)
d4f60571
MW
153 elif start_chapter == -1:
154 if end_chapter != -1:
fd3b422f 155 raise ExpectedError("can't specify end chapter without start chapter")
04a05f7f
MW
156 suffix = "#%d" % title
157 elif not me.CHAPTERP:
158 raise ExpectedError("can't specify chapter with `%s'" % me.fn)
d4f60571 159 elif end_chapter == -1:
fd3b422f 160 suffix = "#%d:%d" % (title, start_chapter)
04a05f7f 161 else:
fd3b422f 162 suffix = "#%d:%d-%d:%d" % (title, start_chapter, title, end_chapter - 1)
1bec83d0 163
0becf74e
MW
164 duration = None
165 if DB is None:
166 duration = me._duration(title, start_chapter, end_chapter)
167 else:
168 st = OS.stat(OS.path.join(ROOT, me.fn))
169 duration = None
170 c = DB.cursor()
171 c.execute("""
172 SELECT device, inode, size, mtime, duration FROM duration
173 WHERE path = ? AND title = ? AND
174 start_chapter = ? AND end_chapter = ?
d4f60571 175 """, [me.fn, title, start_chapter, end_chapter])
0becf74e
MW
176 row = c.fetchone()
177 foundp = False
178 if row is None:
179 duration = me._duration(title, start_chapter, end_chapter)
180 c.execute("""
181 INSERT OR REPLACE INTO duration
182 (path, title, start_chapter, end_chapter,
183 device, inode, size, mtime, duration)
184 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
d4f60571 185 """, [me.fn, title, start_chapter, end_chapter,
0becf74e
MW
186 st.st_dev, st.st_ino, st.st_size, st.st_mtime,
187 duration])
188 else:
189 dev, ino, sz, mt, d = row
190 if (dev, ino, sz, mt) == \
191 (st.st_dev, st.st_ino, st.st_size, st.st_mtime):
192 duration = d
193 else:
194 duration = me._duration(title, start_chapter, end_chapter)
195 c.execute("""
196 UPDATE duration
197 SET device = ?, inode = ?, size = ?, mtime = ?, duration = ?
198 WHERE path = ? AND title = ? AND
199 start_chapter = ? AND end_chapter = ?
d4f60571
MW
200 """, [st.st_dev, st.st_dev, st.st_size, st.st_mtime, duration,
201 me.fn, title, start_chapter, end_chapter])
0becf74e 202 DB.commit()
1bec83d0 203
d4f60571 204 if end_chapter != -1:
fd3b422f
MW
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:
d4f60571 211 if title == -1:
fd3b422f 212 raise ExpectedError("`%s' already used" % me.fn)
d4f60571 213 elif end_chapter == -1:
fd3b422f
MW
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]))
4ae03d6c
MW
218 if end_chapter == -1:
219 me.used_titles.add(title)
220 else:
fd3b422f
MW
221 for ch in range(start_chapter, end_chapter):
222 me.used_chapters.add((title, ch))
1bec83d0 223 return me.PREFIX + ROOT + urlencode(me.fn) + suffix, duration
04a05f7f
MW
224
225class VideoDisc (Source):
226 PREFIX = "dvd://"
227 TITLEP = CHAPTERP = True
228
b092d511
MW
229 def __init__(me, fn, *args, **kw):
230 super().__init__(fn, *args, **kw)
231 me.neps = 0
232
1bec83d0
MW
233 def _duration(me, title, start_chapter, end_chapter):
234 path = OS.path.join(ROOT, me.fn)
235 ntitle = int(program_output(["dvd-info", path, "titles"]))
236 if not 1 <= title <= ntitle:
237 raise ExpectedError("bad title %d for `%s': must be in 1 .. %d" %
238 (title, me.fn, ntitle))
d4f60571 239 if start_chapter == -1:
1bec83d0
MW
240 durq = "duration:%d" % title
241 else:
242 nch = int(program_output(["dvd-info", path, "chapters:%d" % title]))
d4f60571 243 if end_chapter == -1: end_chapter = nch
1bec83d0
MW
244 else: end_chapter -= 1
245 if not 1 <= start_chapter <= end_chapter <= nch:
246 raise ExpectedError("bad chapter range %d .. %d for `%s' title %d: "
247 "must be in 1 .. %d" %
248 (start_chapter, end_chapter, me.fn, title, nch))
249 durq = "duration:%d.%d-%d" % (title, start_chapter, end_chapter)
250 duration = int(program_output(["dvd-info", path, durq]))
251 return duration
252
04a05f7f
MW
253class VideoSeason (object):
254 def __init__(me, i, title):
255 me.i = i
256 me.title = title
257 me.episodes = {}
32cd109c
MW
258 def set_episode_disc(me, i, disc):
259 if i in me.episodes:
260 raise ExpectedError("season %d episode %d already taken" % (me.i, i))
b092d511 261 me.episodes[i] = disc; disc.neps += 1
04a05f7f 262
dcb1cc6c
MW
263def match_group(m, *groups, dflt = None, mustp = False):
264 for g in groups:
265 try: s = m.group(g)
266 except IndexError: continue
04a05f7f 267 if s is not None: return s
dcb1cc6c
MW
268 if mustp: raise ValueError("no match found")
269 else: return dflt
04a05f7f
MW
270
271class VideoDir (object):
272
4f8020f7
MW
273 _R_ISO_PRE = list(map(lambda pats:
274 list(map(lambda pat:
275 RX.compile("^" + pat + r"\.iso$", RX.X),
276 pats)),
277 [[r""" S (?P<si> \d+) \. \ (?P<stitle> .*) — (?: D \d+ \. \ )?
278 (?P<epex> .*) """,
279 r""" S (?P<si> \d+) (?: D \d+)? \. \ (?P<epex> .*) """,
280 r""" S (?P<si> \d+) \. \ (?P<epex> E \d+ .*) """,
281 r""" S (?P<si> \d+) (?P<epex> E \d+) \. \ .* """],
282 [r""" (?P<si> \d+) [A-Z]? \. \ (?P<stitle> .*) — (?P<epex> .*) """],
283 [r""" \d+ \. \ (?P<epex> [ES] \d+ .*) """],
284 [r""" (?P<epnum> \d+ ) \. \ .* """]]))
04a05f7f 285
9fc467bb 286 _R_ISO_EP = RX.compile(r""" ^
6b5cec73 287 (?: S (?P<si> \d+) \ )?
9fc467bb 288 E (?P<ei> \d+) (?: – (?P<ej> \d+))? $
04a05f7f
MW
289 """, RX.X)
290
291 def __init__(me, dir):
b092d511 292 me.dir = dir
04a05f7f
MW
293 fns = OS.listdir(OS.path.join(ROOT, dir))
294 fns.sort()
6b5cec73 295 season = None
04a05f7f 296 seasons = {}
4f8020f7 297 styles = me._R_ISO_PRE
04a05f7f
MW
298 for fn in fns:
299 path = OS.path.join(dir, fn)
300 if not fn.endswith(".iso"): continue
dcb1cc6c 301 #print(";; `%s'" % path, file = SYS.stderr)
4f8020f7
MW
302 for sty in styles:
303 for r in sty:
304 m = r.match(fn)
305 if m: styles = [sty]; break
306 else:
307 continue
308 break
dcb1cc6c
MW
309 else:
310 #print(";;\tignored (regex mismatch)", file = SYS.stderr)
065a5db6 311 continue
04a05f7f 312
dcb1cc6c
MW
313 si = filter(match_group(m, "si"), int)
314 stitle = match_group(m, "stitle")
315
316 check(si is not None or stitle is None,
6b5cec73 317 "explicit season title without number in `%s'" % fn)
dcb1cc6c
MW
318 if si is not None:
319 if season is None or si != season.i:
320 check(season is None or si == season.i + 1,
6b5cec73 321 "season %d /= %d" %
dcb1cc6c
MW
322 (si, season is None and -1 or season.i + 1))
323 check(si not in seasons, "season %d already seen" % si)
324 seasons[si] = season = VideoSeason(si, stitle)
6b5cec73
MW
325 else:
326 check(stitle == season.title,
327 "season title `%s' /= `%s'" % (stitle, season.title))
04a05f7f 328
32cd109c 329 disc = VideoDisc(path)
6b5cec73 330 ts = season
32cd109c 331 any, bad = False, False
dcb1cc6c
MW
332 epnum = match_group(m, "epnum")
333 if epnum is not None: eplist = ["E" + epnum]
334 else: eplist = match_group(m, "epex", mustp = True).split(", ")
35ecb6eb 335 for eprange in eplist:
04a05f7f 336 mm = me._R_ISO_EP.match(eprange)
3ee2c072
MW
337 if mm is None:
338 #print(";;\t`%s'?" % eprange, file = SYS.stderr)
339 bad = True; continue
340 if not any: any = True
6b5cec73
MW
341 i = filter(mm.group("si"), int)
342 if i is not None:
343 try: ts = seasons[i]
344 except KeyError: ts = seasons[i] = VideoSeason(i, None)
345 if ts is None:
346 ts = season = seasons[1] = VideoSeason(1, None)
04a05f7f
MW
347 start = filter(mm.group("ei"), int)
348 end = filter(mm.group("ej"), int, start)
32cd109c 349 for k in range(start, end + 1):
6b5cec73 350 ts.set_episode_disc(k, disc)
065a5db6 351 #print(";;\tepisode %d.%d" % (ts.i, k), file = SYS.stderr)
3ee2c072 352 if not any:
dcb1cc6c 353 #print(";;\tignored", file = SYS.stderr)
3ee2c072
MW
354 pass
355 elif bad:
356 raise ExpectedError("bad ep list in `%s'", fn)
04a05f7f
MW
357 me.seasons = seasons
358
359class AudioDisc (Source):
fbac3340 360 PREFIX = "file://"
04a05f7f
MW
361 TITLEP = CHAPTERP = False
362
1bec83d0
MW
363 def _duration(me, title, start_chapter, end_chaptwr):
364 out = program_output(["metaflac",
365 "--show-total-samples", "--show-sample-rate",
366 OS.path.join(ROOT, me.fn)])
367 nsamples, hz = map(float, out.split())
368 return int(nsamples/hz)
369
d5c4caf1 370class AudioEpisode (AudioDisc):
04a05f7f
MW
371 def __init__(me, fn, i, *args, **kw):
372 super().__init__(fn, *args, **kw)
373 me.i = i
374
375class AudioDir (object):
376
9fc467bb 377 _R_FLAC = RX.compile(r""" ^
04a05f7f
MW
378 E (\d+)
379 (?: \. \ (.*))?
380 \. flac $
381 """, RX.X)
382
383 def __init__(me, dir):
b092d511 384 me.dir = dir
04a05f7f
MW
385 fns = OS.listdir(OS.path.join(ROOT, dir))
386 fns.sort()
387 episodes = {}
388 last_i = 0
389 for fn in fns:
390 path = OS.path.join(dir, fn)
391 if not fn.endswith(".flac"): continue
392 m = me._R_FLAC.match(fn)
393 if not m: continue
394 i = filter(m.group(1), int)
395 etitle = m.group(2)
396 check(i == last_i + 1, "episode %d /= %d" % (i, last_i + 1))
397 episodes[i] = AudioEpisode(path, i)
398 last_i = i
399 me.episodes = episodes
400
401class Chapter (object):
402 def __init__(me, episode, title, i):
403 me.title, me.i = title, i
1bec83d0
MW
404 me.url, me.duration = \
405 episode.source.url_and_duration(episode.tno, i, i + 1)
04a05f7f
MW
406
407class Episode (object):
fb430389 408 def __init__(me, season, i, neps, title, src, series_title_p = True,
d4f60571 409 tno = -1, startch = -1, endch = -1):
04a05f7f
MW
410 me.season = season
411 me.i, me.neps, me.title = i, neps, title
412 me.chapters = []
413 me.source, me.tno = src, tno
fb430389 414 me.series_title_p = series_title_p
1bec83d0 415 me.url, me.duration = src.url_and_duration(tno, startch, endch)
04a05f7f
MW
416 def add_chapter(me, title, j):
417 ch = Chapter(me, title, j)
418 me.chapters.append(ch)
419 return ch
420 def label(me):
421 return me.season._eplabel(me.i, me.neps, me.title)
422
151b3c4f
MW
423class BaseSeason (object):
424 def __init__(me, series, implicitp = False):
425 me.series = series
04a05f7f 426 me.episodes = []
151b3c4f
MW
427 me.implicitp = implicitp
428 me.ep_i, episodes = 1, []
fb430389
MW
429 def add_episode(me, j, neps, title, src, series_title_p,
430 tno, startch, endch):
431 ep = Episode(me, j, neps, title, src, series_title_p,
432 tno, startch, endch)
04a05f7f 433 me.episodes.append(ep)
151b3c4f 434 src.nuses += neps; me.ep_i += neps
04a05f7f 435 return ep
4a25b86c
MW
436 def _epnames(me, i, neps):
437 playlist = me.series.playlist
c6b2a381
MW
438 if neps == 1: return playlist.epname, ["%d" % i]
439 elif neps == 2: return playlist.epnames, ["%d" % i, "%d" % (i + 1)]
440 else: return playlist.epnames, ["%d–%d" % (i, i + neps - 1)]
151b3c4f
MW
441
442class Season (BaseSeason):
443 def __init__(me, series, title, i, *args, **kw):
444 super().__init__(series, *args, **kw)
445 me.title, me.i = title, i
04a05f7f 446 def _eplabel(me, i, neps, title):
4a25b86c 447 epname, epn = me._epnames(i, neps)
04a05f7f 448 if title is None:
c6b2a381
MW
449 if me.implicitp:
450 label = "%s %s" % (epname, ", ".join(epn))
451 elif me.title is None:
452 label = "%s %s" % \
453 (epname, ", ".join("%d.%s" % (me.i, e) for e in epn))
454 else:
b0c5ef2d 455 label = "%s: %s %s" % (me.title, epname, ", ".join(epn))
04a05f7f 456 else:
c6b2a381
MW
457 if me.implicitp:
458 label = "%s. %s" % (", ".join(epn), title)
459 elif me.title is None:
460 label = "%s. %s" % \
461 (", ".join("%d.%s" % (me.i, e) for e in epn), title)
462 else:
b0c5ef2d 463 label = "%s: %s. %s" % (me.title, ", ".join(epn), title)
151b3c4f 464 return label
04a05f7f 465
151b3c4f 466class MovieSeason (BaseSeason):
c3538df6
MW
467 def __init__(me, series, title, *args, **kw):
468 super().__init__(series, *args, **kw)
469 me.title = title
5ca4c92e 470 me.i = None
fb430389
MW
471 def add_episode(me, j, neps, title, src, series_title_p,
472 tno, startch, endch):
c3538df6
MW
473 if me.title is None and title is None:
474 raise ExpectedError("movie or movie season must have a title")
fb430389
MW
475 return super().add_episode(j, neps, title, src, series_title_p,
476 tno, startch, endch)
c3538df6
MW
477 def _eplabel(me, i, neps, title):
478 if me.title is None:
479 label = title
480 elif title is None:
481 epname, epn = me._epnames(i, neps)
b0c5ef2d 482 label = "%s: %s %s" % (me.title, epname, ", ".join(epn))
c3538df6 483 else:
b0c5ef2d 484 label = "%s: %s" % (me.title, title)
c3538df6 485 return label
04a05f7f 486
151b3c4f 487class Series (object):
2cf61248
MW
488 def __init__(me, playlist, name, title = None,
489 full_title = None, wantedp = True):
151b3c4f 490 me.playlist = playlist
2cf61248 491 me.name, me.title, me.full_title = name, title, full_title
151b3c4f 492 me.cur_season = None
08f08e7c 493 me.wantedp = wantedp
151b3c4f
MW
494 def _add_season(me, season):
495 me.cur_season = season
496 def add_season(me, title, i, implicitp = False):
497 me._add_season(Season(me, title, i, implicitp))
c3538df6
MW
498 def add_movies(me, title = None):
499 me._add_season(MovieSeason(me, title))
151b3c4f
MW
500 def ensure_season(me):
501 if me.cur_season is None: me.add_season(None, 1, implicitp = True)
502 return me.cur_season
503 def end_season(me):
504 me.cur_season = None
04a05f7f 505
151b3c4f 506class Playlist (object):
3b9c615b 507
04a05f7f
MW
508 def __init__(me):
509 me.seasons = []
151b3c4f 510 me.episodes = []
04a05f7f 511 me.epname, me.epnames = "Episode", "Episodes"
151b3c4f 512 me.nseries = 0
3b9c615b 513
151b3c4f
MW
514 def add_episode(me, episode):
515 me.episodes.append(episode)
3b9c615b 516
151b3c4f
MW
517 def done_season(me):
518 if me.episodes:
519 me.seasons.append(me.episodes)
520 me.episodes = []
2f4f35b0 521
04a05f7f
MW
522 def write(me, f):
523 f.write("#EXTM3U\n")
524 for season in me.seasons:
525 f.write("\n")
151b3c4f
MW
526 for ep in season:
527 label = ep.label()
fb430389
MW
528 if me.nseries > 1 and ep.series_title_p and \
529 ep.season.series.title is not None:
b0c5ef2d 530 if ep.season.i is None: sep = ": "
48d26ec8
MW
531 else: sep = " "
532 label = ep.season.series.title + sep + label
04a05f7f 533 if not ep.chapters:
1bec83d0 534 f.write("#EXTINF:%d,,%s\n%s\n" % (ep.duration, label, ep.url))
04a05f7f
MW
535 else:
536 for ch in ep.chapters:
1bec83d0
MW
537 f.write("#EXTINF:%d,,%s: %s\n%s\n" %
538 (ch.duration, label, ch.title, ch.url))
151b3c4f 539
2f4f35b0
MW
540 def write_deps(me, f, out):
541 deps = set()
542 for season in me.seasons:
543 for ep in season: deps.add(ep.source.fn)
544 f.write("### -*-makefile-*-\n")
545 f.write("%s: $(call check-deps, %s," % (out, out))
546 for dep in sorted(deps):
547 f.write(" \\\n\t'%s'" %
548 OS.path.join(ROOT, dep)
549 .replace(",", "$(comma)")
550 .replace("'", "'\\''"))
551 f.write(")\n")
552
066e5d43
MW
553DEFAULT_EXPVAR = 0.05
554R_DURMULT = RX.compile(r""" ^
555 (\d+ (?: \. \d+)?) x
556$ """, RX.X)
557R_DUR = RX.compile(r""" ^
558 (?: (?: (\d+) :)? (\d+) :)? (\d+)
559 (?: / (\d+ (?: \. \d+)?) \%)?
560$ """, RX.X)
561def parse_duration(s, base = None, basevar = DEFAULT_EXPVAR):
562 if base is not None:
563 m = R_DURMULT.match(s)
564 if m is not None: return base*float(m.group(1)), basevar
565 m = R_DUR.match(s)
566 if not m: raise ExpectedError("invalid duration spec `%s'" % s)
567 hr, min, sec = map(lambda g: filter(m.group(g), int, 0), [1, 2, 3])
568 var = filter(m.group(4), lambda x: float(x)/100.0)
569 if var is None: var = DEFAULT_EXPVAR
570 return 3600*hr + 60*min + sec, var
571def format_duration(d):
572 if d >= 3600: return "%d:%02d:%02d" % (d//3600, (d//60)%60, d%60)
573 elif d >= 60: return "%d:%02d" % (d//60, d%60)
574 else: return "%d s" % d
575
151b3c4f
MW
576MODE_UNSET = 0
577MODE_SINGLE = 1
578MODE_MULTI = 2
579
580class EpisodeListParser (object):
581
582 def __init__(me, series_wanted = None, chapters_wanted_p = False):
583 me._pl = Playlist()
584 me._cur_episode = me._cur_chapter = None
585 me._series = {}; me._vdirs = {}; me._audirs = {}; me._isos = {}
586 me._series_wanted = series_wanted
587 me._chaptersp = chapters_wanted_p
066e5d43 588 me._explen, me._expvar = None, DEFAULT_EXPVAR
151b3c4f
MW
589 if series_wanted is None: me._mode = MODE_UNSET
590 else: me._mode = MODE_MULTI
591
592 def _bad_keyval(me, cmd, k, v):
593 raise ExpectedError("invalid `!%s' option `%s'" %
594 (cmd, v if k is None else k))
595
596 def _keyvals(me, opts):
597 if opts is not None:
598 for kv in opts.split(","):
599 try: sep = kv.index("=")
600 except ValueError: yield None, kv
601 else: yield kv[:sep], kv[sep + 1:]
602
603 def _set_mode(me, mode):
604 if me._mode == MODE_UNSET:
605 me._mode = mode
606 elif me._mode != mode:
607 raise ExpectedError("inconsistent single-/multi-series usage")
608
609 def _get_series(me, name):
610 if name is None:
611 me._set_mode(MODE_SINGLE)
612 try: series = me._series[None]
613 except KeyError:
08f08e7c 614 series = me._series[None] = Series(me._pl, None)
151b3c4f
MW
615 me._pl.nseries += 1
616 else:
617 me._set_mode(MODE_MULTI)
618 series = lookup(me._series, name, "unknown series `%s'" % name)
619 return series
620
621 def _opts_series(me, cmd, opts):
622 name = None
623 for k, v in me._keyvals(opts):
624 if k is None: name = v
625 else: me._bad_keyval(cmd, k, v)
08f08e7c 626 return me._get_series(name)
151b3c4f 627
08f08e7c
MW
628 def _auto_epsrc(me, series):
629 dir = lookup(me._vdirs, series.name, "no active video directory")
630 season = series.ensure_season()
631 check(season.i is not None, "must use explicit iso for movie seasons")
632 vseason = lookup(dir.seasons, season.i,
633 "season %d not found in video dir `%s'" %
634 (season.i, dir.dir))
635 src = lookup(vseason.episodes, season.ep_i,
636 "episode %d.%d not found in video dir `%s'" %
637 (season.i, season.ep_i, dir.dir))
638 return src
151b3c4f
MW
639
640 def _process_cmd(me, ww):
641
642 cmd = ww.nextword(); check(cmd is not None, "missing command")
643 try: sep = cmd.index(":")
644 except ValueError: opts = None
645 else: cmd, opts = cmd[:sep], cmd[sep + 1:]
646
647 if cmd == "series":
648 name = None
649 for k, v in me._keyvals(opts):
650 if k is None: name = v
651 else: me._bad_keyval(cmd, k, v)
652 check(name is not None, "missing series name")
653 check(name not in me._series, "series `%s' already defined" % name)
028b4b51 654 title = ww.rest()
2cf61248
MW
655 if title is None:
656 full = None
657 else:
658 try: sep = title.index("::")
659 except ValueError: full = title
660 else:
661 full = title[sep + 2:].strip()
662 if sep == 0: title = None
663 else: title = title[:sep].strip()
151b3c4f 664 me._set_mode(MODE_MULTI)
2cf61248 665 me._series[name] = series = Series(me._pl, name, title, full,
08f08e7c
MW
666 me._series_wanted is None or
667 name in me._series_wanted)
668 if series.wantedp: me._pl.nseries += 1
151b3c4f
MW
669
670 elif cmd == "season":
08f08e7c 671 series = me._opts_series(cmd, opts)
151b3c4f
MW
672 w = ww.nextword();
673 check(w is not None, "missing season number")
674 if w == "-":
08f08e7c 675 if not series.wantedp: return
c3538df6 676 series.add_movies(ww.rest())
151b3c4f
MW
677 else:
678 title = ww.rest(); i = getint(w)
08f08e7c 679 if not series.wantedp: return
151b3c4f
MW
680 series.add_season(ww.rest(), getint(w), implicitp = False)
681 me._cur_episode = me._cur_chapter = None
682 me._pl.done_season()
683
066e5d43
MW
684 elif cmd == "explen":
685 w = ww.rest(); check(w is not None, "missing duration spec")
fe3e636c
MW
686 if w == "-":
687 me._explen, me._expvar = None, DEFAULT_EXPVAR
688 else:
689 d, v = parse_duration(w)
690 me._explen = d
691 if v is not None: me._expvar = v
066e5d43 692
151b3c4f
MW
693 elif cmd == "epname":
694 for k, v in me._keyvals(opts): me._bad_keyval("epname", k, v)
695 name = ww.rest(); check(name is not None, "missing episode name")
b06cf0d0 696 try: sep = name.index("::")
151b3c4f 697 except ValueError: names = name + "s"
250b0ab7 698 else: name, names = name[:sep], name[sep + 2:]
151b3c4f
MW
699 me._pl.epname, me._pl.epnames = name, names
700
701 elif cmd == "epno":
08f08e7c 702 series = me._opts_series(cmd, opts)
151b3c4f
MW
703 w = ww.rest(); check(w is not None, "missing episode number")
704 epi = getint(w)
08f08e7c 705 if not series.wantedp: return
151b3c4f
MW
706 series.ensure_season().ep_i = epi
707
708 elif cmd == "iso":
08f08e7c 709 series = me._opts_series(cmd, opts)
151b3c4f 710 fn = ww.rest(); check(fn is not None, "missing filename")
08f08e7c
MW
711 if not series.wantedp: return
712 if fn == "-": forget(me._isos, series.name)
151b3c4f
MW
713 else:
714 check(OS.path.exists(OS.path.join(ROOT, fn)),
715 "iso file `%s' not found" % fn)
08f08e7c 716 me._isos[series.name] = VideoDisc(fn)
151b3c4f
MW
717
718 elif cmd == "vdir":
08f08e7c 719 series = me._opts_series(cmd, opts)
151b3c4f 720 dir = ww.rest(); check(dir is not None, "missing directory")
08f08e7c
MW
721 if not series.wantedp: return
722 if dir == "-": forget(me._vdirs, series.name)
723 else: me._vdirs[series.name] = VideoDir(dir)
151b3c4f
MW
724
725 elif cmd == "adir":
08f08e7c 726 series = me._opts_series(cmd, opts)
151b3c4f 727 dir = ww.rest(); check(dir is not None, "missing directory")
08f08e7c
MW
728 if not series.wantedp: return
729 if dir == "-": forget(me._audirs, series.name)
730 else: me._audirs[series.name] = AudioDir(dir)
04a05f7f 731
0c4ca4f3 732 elif cmd == "displaced":
08f08e7c 733 series = me._opts_series(cmd, opts)
0c4ca4f3 734 w = ww.rest(); check(w is not None, "missing count"); n = getint(w)
08f08e7c 735 src = me._auto_epsrc(series)
0c4ca4f3 736 src.nuses += n
066e5d43 737
151b3c4f
MW
738 else:
739 raise ExpectedError("unknown command `%s'" % cmd)
740
741 def _process_episode(me, ww):
742
743 opts = ww.nextword(); check(opts is not None, "missing title/options")
d4f60571 744 ti = -1; sname = None; neps = 1; epi = None; loch = hich = -1
066e5d43 745 explen, expvar, explicitlen = me._explen, me._expvar, False
fb430389 746 series_title_p = True
151b3c4f
MW
747 for k, v in me._keyvals(opts):
748 if k is None:
749 if v.isdigit(): ti = int(v)
d4f60571 750 elif v == "-": ti = -1
151b3c4f
MW
751 else: sname = v
752 elif k == "s": sname = v
753 elif k == "n": neps = getint(v)
754 elif k == "ep": epi = getint(v)
fb430389 755 elif k == "st": series_title_p = getbool(v)
066e5d43
MW
756 elif k == "l":
757 if v == "-": me._explen, me._expvar = None, DEFAULT_EXPVAR
758 else:
759 explen, expvar = parse_duration(v, explen, expvar)
760 explicitlen = True
0411af2c
MW
761 elif k == "ch":
762 try: sep = v.index("-")
d4f60571 763 except ValueError: loch, hich = getint(v), -1
0411af2c 764 else: loch, hich = getint(v[:sep]), getint(v[sep + 1:]) + 1
151b3c4f
MW
765 else: raise ExpectedError("unknown episode option `%s'" % k)
766 check(ti is not None, "missing title number")
767 series = me._get_series(sname)
768 me._cur_chapter = None
769
770 title = ww.rest()
08f08e7c 771 if not series.wantedp: return
151b3c4f
MW
772 season = series.ensure_season()
773 if epi is None: epi = season.ep_i
774
d4f60571 775 if ti == -1:
71305e44
MW
776 check(season.implicitp or season.i is None,
777 "audio source, but explicit non-movie season")
08f08e7c
MW
778 dir = lookup(me._audirs, series.name,
779 "no title, and no audio directory")
151b3c4f
MW
780 src = lookup(dir.episodes, season.ep_i,
781 "episode %d not found in audio dir `%s'" % (epi, dir.dir))
04a05f7f 782
151b3c4f 783 else:
08f08e7c
MW
784 try: src = me._isos[series.name]
785 except KeyError: src = me._auto_epsrc(series)
151b3c4f 786
fb430389
MW
787 episode = season.add_episode(epi, neps, title, src,
788 series_title_p, ti, loch, hich)
066e5d43
MW
789
790 if episode.duration != -1 and explen is not None:
791 if not explicitlen: explen *= neps
792 if not explen*(1 - expvar) <= episode.duration <= explen*(1 + expvar):
793 if season.i is None: epid = "episode %d" % epi
794 else: epid = "episode %d.%d" % (season.i, epi)
795 raise ExpectedError \
796 ("%s duration %s %g%% > %g%% from expected %s" %
797 (epid, format_duration(episode.duration),
798 abs(100*(episode.duration - explen)/explen), 100*expvar,
799 format_duration(explen)))
151b3c4f
MW
800 me._pl.add_episode(episode)
801 me._cur_episode = episode
802
803 def _process_chapter(me, ww):
804 check(me._cur_episode is not None, "no current episode")
805 check(me._cur_episode.source.CHAPTERP,
806 "episode source doesn't allow chapters")
807 if me._chaptersp:
808 if me._cur_chapter is None: i = 1
809 else: i = me._cur_chapter.i + 1
810 me._cur_chapter = me._cur_episode.add_chapter(ww.rest(), i)
811
812 def parse_file(me, fn):
813 with location(FileLocation(fn, 0)) as floc:
814 with open(fn, "r") as f:
815 for line in f:
816 floc.stepline()
817 sline = line.lstrip()
818 if sline == "" or sline.startswith(";"): continue
819
820 if line.startswith("!"): me._process_cmd(Words(line[1:]))
821 elif not line[0].isspace(): me._process_episode(Words(line))
822 else: me._process_chapter(Words(line))
823 me._pl.done_season()
824
825 def done(me):
826 discs = set()
827 for name, vdir in me._vdirs.items():
08f08e7c 828 if not me._series[name].wantedp: continue
151b3c4f
MW
829 for s in vdir.seasons.values():
830 for d in s.episodes.values():
831 discs.add(d)
832 for adir in me._audirs.values():
833 for d in adir.episodes.values():
b092d511 834 discs.add(d)
151b3c4f
MW
835 for d in sorted(discs, key = lambda d: d.fn):
836 if d.neps is not None and d.neps != d.nuses:
837 raise ExpectedError("disc `%s' has %d episodes, used %d times" %
838 (d.fn, d.neps, d.nuses))
839 return me._pl
04a05f7f 840
151b3c4f 841op = OP.OptionParser \
2f4f35b0 842 (usage = "%prog [-c] [-M DEPS] [-d CACHE] [-o OUT] [-s SERIES] EPLS\n"
0becf74e 843 "%prog -i -d CACHE",
151b3c4f 844 description = "Generate M3U playlists from an episode list.")
2f4f35b0
MW
845op.add_option("-M", "--make-deps", metavar = "DEPS",
846 dest = "deps", type = "str", default = None,
847 help = "Write a `make' fragment for dependencies")
151b3c4f
MW
848op.add_option("-c", "--chapters",
849 dest = "chaptersp", action = "store_true", default = False,
850 help = "Output individual chapter names")
0becf74e
MW
851op.add_option("-i", "--init-db",
852 dest = "initdbp", action = "store_true", default = False,
853 help = "Initialize the database")
2f4f35b0 854op.add_option("-d", "--database", metavar = "CACHE",
0becf74e
MW
855 dest = "database", type = "str", default = None,
856 help = "Set filename for cache database")
2f4f35b0
MW
857op.add_option("-o", "--output", metavar = "OUT",
858 dest = "output", type = "str", default = None,
859 help = "Write output playlist to OUT")
860op.add_option("-O", "--fake-output", metavar = "OUT",
861 dest = "fakeout", type = "str", default = None,
862 help = "Pretend output goes to OUT for purposes of `-M'")
863op.add_option("-s", "--series", metavar = "SERIES",
151b3c4f
MW
864 dest = "series", type = "str", default = None,
865 help = "Output only the listed SERIES (comma-separated)")
04a05f7f 866try:
0becf74e 867 opts, argv = op.parse_args()
2f4f35b0 868
0becf74e
MW
869 if opts.initdbp:
870 if opts.chaptersp or opts.series is not None or \
2f4f35b0
MW
871 opts.output is not None or opts.deps is not None or \
872 opts.fakeout is not None or \
0becf74e
MW
873 opts.database is None or len(argv):
874 op.print_usage(file = SYS.stderr); SYS.exit(2)
875 setup_db(opts.database)
2f4f35b0 876
0becf74e
MW
877 else:
878 if len(argv) != 1: op.print_usage(file = SYS.stderr); SYS.exit(2)
879 if opts.database is not None: init_db(opts.database)
880 if opts.series is None:
881 series_wanted = None
882 else:
883 series_wanted = set()
884 for name in opts.series.split(","): series_wanted.add(name)
2f4f35b0
MW
885 if opts.deps is not None:
886 if (opts.output is None or opts.output == "-") and opts.fakeout is None:
887 raise ExpectedError("can't write dep fragment without output file")
888 if opts.fakeout is None: opts.fakeout = opts.output
889 else:
890 if opts.fakeout is not None:
891 raise ExpectedError("fake output set but no dep fragment")
892
0becf74e
MW
893 ep = EpisodeListParser(series_wanted, opts.chaptersp)
894 ep.parse_file(argv[0])
895 pl = ep.done()
2f4f35b0
MW
896
897 if opts.output is None or opts.output == "-":
898 pl.write(SYS.stdout)
899 else:
900 with open(opts.output, "w") as f: pl.write(f)
901
902 if opts.deps:
903 if opts.deps == "-":
904 pl.write_deps(SYS.stdout, opts.fakeout)
905 else:
906 with open(opts.deps, "w") as f: pl.write_deps(f, opts.fakeout)
907
04a05f7f
MW
908except (ExpectedError, IOError, OSError) as e:
909 LOC.report(e)
910 SYS.exit(2)