mkm3u, *.epls: Introduce single-series titles and forced single-series.
[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
d1f1c578
MW
513 me.single_series_p = False
514 me.series_title = None
3b9c615b 515
151b3c4f
MW
516 def add_episode(me, episode):
517 me.episodes.append(episode)
3b9c615b 518
151b3c4f
MW
519 def done_season(me):
520 if me.episodes:
521 me.seasons.append(me.episodes)
522 me.episodes = []
2f4f35b0 523
04a05f7f
MW
524 def write(me, f):
525 f.write("#EXTM3U\n")
526 for season in me.seasons:
527 f.write("\n")
151b3c4f
MW
528 for ep in season:
529 label = ep.label()
fb430389
MW
530 if me.nseries > 1 and ep.series_title_p and \
531 ep.season.series.title is not None:
b0c5ef2d 532 if ep.season.i is None: sep = ": "
48d26ec8
MW
533 else: sep = " "
534 label = ep.season.series.title + sep + label
04a05f7f 535 if not ep.chapters:
1bec83d0 536 f.write("#EXTINF:%d,,%s\n%s\n" % (ep.duration, label, ep.url))
04a05f7f
MW
537 else:
538 for ch in ep.chapters:
1bec83d0
MW
539 f.write("#EXTINF:%d,,%s: %s\n%s\n" %
540 (ch.duration, label, ch.title, ch.url))
151b3c4f 541
2f4f35b0
MW
542 def write_deps(me, f, out):
543 deps = set()
544 for season in me.seasons:
545 for ep in season: deps.add(ep.source.fn)
546 f.write("### -*-makefile-*-\n")
547 f.write("%s: $(call check-deps, %s," % (out, out))
548 for dep in sorted(deps):
549 f.write(" \\\n\t'%s'" %
550 OS.path.join(ROOT, dep)
551 .replace(",", "$(comma)")
552 .replace("'", "'\\''"))
553 f.write(")\n")
554
066e5d43
MW
555DEFAULT_EXPVAR = 0.05
556R_DURMULT = RX.compile(r""" ^
557 (\d+ (?: \. \d+)?) x
558$ """, RX.X)
559R_DUR = RX.compile(r""" ^
560 (?: (?: (\d+) :)? (\d+) :)? (\d+)
561 (?: / (\d+ (?: \. \d+)?) \%)?
562$ """, RX.X)
563def parse_duration(s, base = None, basevar = DEFAULT_EXPVAR):
564 if base is not None:
565 m = R_DURMULT.match(s)
566 if m is not None: return base*float(m.group(1)), basevar
567 m = R_DUR.match(s)
568 if not m: raise ExpectedError("invalid duration spec `%s'" % s)
569 hr, min, sec = map(lambda g: filter(m.group(g), int, 0), [1, 2, 3])
570 var = filter(m.group(4), lambda x: float(x)/100.0)
571 if var is None: var = DEFAULT_EXPVAR
572 return 3600*hr + 60*min + sec, var
573def format_duration(d):
574 if d >= 3600: return "%d:%02d:%02d" % (d//3600, (d//60)%60, d%60)
575 elif d >= 60: return "%d:%02d" % (d//60, d%60)
576 else: return "%d s" % d
577
151b3c4f
MW
578MODE_UNSET = 0
579MODE_SINGLE = 1
580MODE_MULTI = 2
581
582class EpisodeListParser (object):
583
584 def __init__(me, series_wanted = None, chapters_wanted_p = False):
585 me._pl = Playlist()
586 me._cur_episode = me._cur_chapter = None
587 me._series = {}; me._vdirs = {}; me._audirs = {}; me._isos = {}
588 me._series_wanted = series_wanted
589 me._chaptersp = chapters_wanted_p
066e5d43 590 me._explen, me._expvar = None, DEFAULT_EXPVAR
151b3c4f
MW
591 if series_wanted is None: me._mode = MODE_UNSET
592 else: me._mode = MODE_MULTI
593
594 def _bad_keyval(me, cmd, k, v):
595 raise ExpectedError("invalid `!%s' option `%s'" %
596 (cmd, v if k is None else k))
597
598 def _keyvals(me, opts):
599 if opts is not None:
600 for kv in opts.split(","):
601 try: sep = kv.index("=")
602 except ValueError: yield None, kv
603 else: yield kv[:sep], kv[sep + 1:]
604
605 def _set_mode(me, mode):
606 if me._mode == MODE_UNSET:
607 me._mode = mode
608 elif me._mode != mode:
609 raise ExpectedError("inconsistent single-/multi-series usage")
610
611 def _get_series(me, name):
612 if name is None:
613 me._set_mode(MODE_SINGLE)
614 try: series = me._series[None]
615 except KeyError:
08f08e7c 616 series = me._series[None] = Series(me._pl, None)
151b3c4f
MW
617 me._pl.nseries += 1
618 else:
619 me._set_mode(MODE_MULTI)
620 series = lookup(me._series, name, "unknown series `%s'" % name)
621 return series
622
623 def _opts_series(me, cmd, opts):
624 name = None
625 for k, v in me._keyvals(opts):
626 if k is None: name = v
627 else: me._bad_keyval(cmd, k, v)
08f08e7c 628 return me._get_series(name)
151b3c4f 629
08f08e7c
MW
630 def _auto_epsrc(me, series):
631 dir = lookup(me._vdirs, series.name, "no active video directory")
632 season = series.ensure_season()
633 check(season.i is not None, "must use explicit iso for movie seasons")
634 vseason = lookup(dir.seasons, season.i,
635 "season %d not found in video dir `%s'" %
636 (season.i, dir.dir))
637 src = lookup(vseason.episodes, season.ep_i,
638 "episode %d.%d not found in video dir `%s'" %
639 (season.i, season.ep_i, dir.dir))
640 return src
151b3c4f
MW
641
642 def _process_cmd(me, ww):
643
644 cmd = ww.nextword(); check(cmd is not None, "missing command")
645 try: sep = cmd.index(":")
646 except ValueError: opts = None
647 else: cmd, opts = cmd[:sep], cmd[sep + 1:]
648
d1f1c578
MW
649 if cmd == "title":
650 for k, v in me._keyvals(opts): me._bad_keyval("title", k, v)
651 title = ww.rest(); check(title is not None, "missing title")
652 check(me._pl.series_title is None, "already set a title")
653 me._pl.series_title = title
654
655 elif cmd == "single":
656 for k, v in me._keyvals(opts): me._bad_keyval("single", k, v)
657 check(ww.rest() is None, "trailing junk")
658 check(not me._pl.single_series_p, "single-series already set")
659 me._pl.single_series_p = True
660
661 elif cmd == "series":
151b3c4f
MW
662 name = None
663 for k, v in me._keyvals(opts):
664 if k is None: name = v
665 else: me._bad_keyval(cmd, k, v)
666 check(name is not None, "missing series name")
667 check(name not in me._series, "series `%s' already defined" % name)
028b4b51 668 title = ww.rest()
2cf61248
MW
669 if title is None:
670 full = None
671 else:
672 try: sep = title.index("::")
673 except ValueError: full = title
674 else:
675 full = title[sep + 2:].strip()
676 if sep == 0: title = None
677 else: title = title[:sep].strip()
151b3c4f 678 me._set_mode(MODE_MULTI)
2cf61248 679 me._series[name] = series = Series(me._pl, name, title, full,
08f08e7c
MW
680 me._series_wanted is None or
681 name in me._series_wanted)
682 if series.wantedp: me._pl.nseries += 1
151b3c4f
MW
683
684 elif cmd == "season":
08f08e7c 685 series = me._opts_series(cmd, opts)
151b3c4f
MW
686 w = ww.nextword();
687 check(w is not None, "missing season number")
688 if w == "-":
08f08e7c 689 if not series.wantedp: return
c3538df6 690 series.add_movies(ww.rest())
151b3c4f
MW
691 else:
692 title = ww.rest(); i = getint(w)
08f08e7c 693 if not series.wantedp: return
151b3c4f
MW
694 series.add_season(ww.rest(), getint(w), implicitp = False)
695 me._cur_episode = me._cur_chapter = None
696 me._pl.done_season()
697
066e5d43
MW
698 elif cmd == "explen":
699 w = ww.rest(); check(w is not None, "missing duration spec")
fe3e636c
MW
700 if w == "-":
701 me._explen, me._expvar = None, DEFAULT_EXPVAR
702 else:
703 d, v = parse_duration(w)
704 me._explen = d
705 if v is not None: me._expvar = v
066e5d43 706
151b3c4f
MW
707 elif cmd == "epname":
708 for k, v in me._keyvals(opts): me._bad_keyval("epname", k, v)
709 name = ww.rest(); check(name is not None, "missing episode name")
b06cf0d0 710 try: sep = name.index("::")
151b3c4f 711 except ValueError: names = name + "s"
250b0ab7 712 else: name, names = name[:sep], name[sep + 2:]
151b3c4f
MW
713 me._pl.epname, me._pl.epnames = name, names
714
715 elif cmd == "epno":
08f08e7c 716 series = me._opts_series(cmd, opts)
151b3c4f
MW
717 w = ww.rest(); check(w is not None, "missing episode number")
718 epi = getint(w)
08f08e7c 719 if not series.wantedp: return
151b3c4f
MW
720 series.ensure_season().ep_i = epi
721
722 elif cmd == "iso":
08f08e7c 723 series = me._opts_series(cmd, opts)
151b3c4f 724 fn = ww.rest(); check(fn is not None, "missing filename")
08f08e7c
MW
725 if not series.wantedp: return
726 if fn == "-": forget(me._isos, series.name)
151b3c4f
MW
727 else:
728 check(OS.path.exists(OS.path.join(ROOT, fn)),
729 "iso file `%s' not found" % fn)
08f08e7c 730 me._isos[series.name] = VideoDisc(fn)
151b3c4f
MW
731
732 elif cmd == "vdir":
08f08e7c 733 series = me._opts_series(cmd, opts)
151b3c4f 734 dir = ww.rest(); check(dir is not None, "missing directory")
08f08e7c
MW
735 if not series.wantedp: return
736 if dir == "-": forget(me._vdirs, series.name)
737 else: me._vdirs[series.name] = VideoDir(dir)
151b3c4f
MW
738
739 elif cmd == "adir":
08f08e7c 740 series = me._opts_series(cmd, opts)
151b3c4f 741 dir = ww.rest(); check(dir is not None, "missing directory")
08f08e7c
MW
742 if not series.wantedp: return
743 if dir == "-": forget(me._audirs, series.name)
744 else: me._audirs[series.name] = AudioDir(dir)
04a05f7f 745
0c4ca4f3 746 elif cmd == "displaced":
08f08e7c 747 series = me._opts_series(cmd, opts)
0c4ca4f3 748 w = ww.rest(); check(w is not None, "missing count"); n = getint(w)
08f08e7c 749 src = me._auto_epsrc(series)
0c4ca4f3 750 src.nuses += n
066e5d43 751
151b3c4f
MW
752 else:
753 raise ExpectedError("unknown command `%s'" % cmd)
754
755 def _process_episode(me, ww):
756
757 opts = ww.nextword(); check(opts is not None, "missing title/options")
d4f60571 758 ti = -1; sname = None; neps = 1; epi = None; loch = hich = -1
066e5d43 759 explen, expvar, explicitlen = me._explen, me._expvar, False
fb430389 760 series_title_p = True
151b3c4f
MW
761 for k, v in me._keyvals(opts):
762 if k is None:
763 if v.isdigit(): ti = int(v)
d4f60571 764 elif v == "-": ti = -1
151b3c4f
MW
765 else: sname = v
766 elif k == "s": sname = v
767 elif k == "n": neps = getint(v)
768 elif k == "ep": epi = getint(v)
fb430389 769 elif k == "st": series_title_p = getbool(v)
066e5d43
MW
770 elif k == "l":
771 if v == "-": me._explen, me._expvar = None, DEFAULT_EXPVAR
772 else:
773 explen, expvar = parse_duration(v, explen, expvar)
774 explicitlen = True
0411af2c
MW
775 elif k == "ch":
776 try: sep = v.index("-")
d4f60571 777 except ValueError: loch, hich = getint(v), -1
0411af2c 778 else: loch, hich = getint(v[:sep]), getint(v[sep + 1:]) + 1
151b3c4f
MW
779 else: raise ExpectedError("unknown episode option `%s'" % k)
780 check(ti is not None, "missing title number")
781 series = me._get_series(sname)
782 me._cur_chapter = None
783
784 title = ww.rest()
08f08e7c 785 if not series.wantedp: return
151b3c4f
MW
786 season = series.ensure_season()
787 if epi is None: epi = season.ep_i
788
d4f60571 789 if ti == -1:
71305e44
MW
790 check(season.implicitp or season.i is None,
791 "audio source, but explicit non-movie season")
08f08e7c
MW
792 dir = lookup(me._audirs, series.name,
793 "no title, and no audio directory")
151b3c4f
MW
794 src = lookup(dir.episodes, season.ep_i,
795 "episode %d not found in audio dir `%s'" % (epi, dir.dir))
04a05f7f 796
151b3c4f 797 else:
08f08e7c
MW
798 try: src = me._isos[series.name]
799 except KeyError: src = me._auto_epsrc(series)
151b3c4f 800
fb430389
MW
801 episode = season.add_episode(epi, neps, title, src,
802 series_title_p, ti, loch, hich)
066e5d43
MW
803
804 if episode.duration != -1 and explen is not None:
805 if not explicitlen: explen *= neps
806 if not explen*(1 - expvar) <= episode.duration <= explen*(1 + expvar):
807 if season.i is None: epid = "episode %d" % epi
808 else: epid = "episode %d.%d" % (season.i, epi)
809 raise ExpectedError \
810 ("%s duration %s %g%% > %g%% from expected %s" %
811 (epid, format_duration(episode.duration),
812 abs(100*(episode.duration - explen)/explen), 100*expvar,
813 format_duration(explen)))
151b3c4f
MW
814 me._pl.add_episode(episode)
815 me._cur_episode = episode
816
817 def _process_chapter(me, ww):
818 check(me._cur_episode is not None, "no current episode")
819 check(me._cur_episode.source.CHAPTERP,
820 "episode source doesn't allow chapters")
821 if me._chaptersp:
822 if me._cur_chapter is None: i = 1
823 else: i = me._cur_chapter.i + 1
824 me._cur_chapter = me._cur_episode.add_chapter(ww.rest(), i)
825
826 def parse_file(me, fn):
827 with location(FileLocation(fn, 0)) as floc:
828 with open(fn, "r") as f:
829 for line in f:
830 floc.stepline()
831 sline = line.lstrip()
832 if sline == "" or sline.startswith(";"): continue
833
834 if line.startswith("!"): me._process_cmd(Words(line[1:]))
835 elif not line[0].isspace(): me._process_episode(Words(line))
836 else: me._process_chapter(Words(line))
837 me._pl.done_season()
838
839 def done(me):
840 discs = set()
841 for name, vdir in me._vdirs.items():
08f08e7c 842 if not me._series[name].wantedp: continue
151b3c4f
MW
843 for s in vdir.seasons.values():
844 for d in s.episodes.values():
845 discs.add(d)
846 for adir in me._audirs.values():
847 for d in adir.episodes.values():
b092d511 848 discs.add(d)
151b3c4f
MW
849 for d in sorted(discs, key = lambda d: d.fn):
850 if d.neps is not None and d.neps != d.nuses:
851 raise ExpectedError("disc `%s' has %d episodes, used %d times" %
852 (d.fn, d.neps, d.nuses))
853 return me._pl
04a05f7f 854
151b3c4f 855op = OP.OptionParser \
2f4f35b0 856 (usage = "%prog [-c] [-M DEPS] [-d CACHE] [-o OUT] [-s SERIES] EPLS\n"
0becf74e 857 "%prog -i -d CACHE",
151b3c4f 858 description = "Generate M3U playlists from an episode list.")
2f4f35b0
MW
859op.add_option("-M", "--make-deps", metavar = "DEPS",
860 dest = "deps", type = "str", default = None,
861 help = "Write a `make' fragment for dependencies")
151b3c4f
MW
862op.add_option("-c", "--chapters",
863 dest = "chaptersp", action = "store_true", default = False,
864 help = "Output individual chapter names")
0becf74e
MW
865op.add_option("-i", "--init-db",
866 dest = "initdbp", action = "store_true", default = False,
867 help = "Initialize the database")
2f4f35b0 868op.add_option("-d", "--database", metavar = "CACHE",
0becf74e
MW
869 dest = "database", type = "str", default = None,
870 help = "Set filename for cache database")
2f4f35b0
MW
871op.add_option("-o", "--output", metavar = "OUT",
872 dest = "output", type = "str", default = None,
873 help = "Write output playlist to OUT")
874op.add_option("-O", "--fake-output", metavar = "OUT",
875 dest = "fakeout", type = "str", default = None,
876 help = "Pretend output goes to OUT for purposes of `-M'")
877op.add_option("-s", "--series", metavar = "SERIES",
151b3c4f
MW
878 dest = "series", type = "str", default = None,
879 help = "Output only the listed SERIES (comma-separated)")
04a05f7f 880try:
0becf74e 881 opts, argv = op.parse_args()
2f4f35b0 882
0becf74e
MW
883 if opts.initdbp:
884 if opts.chaptersp or opts.series is not None or \
2f4f35b0
MW
885 opts.output is not None or opts.deps is not None or \
886 opts.fakeout is not None or \
0becf74e
MW
887 opts.database is None or len(argv):
888 op.print_usage(file = SYS.stderr); SYS.exit(2)
889 setup_db(opts.database)
2f4f35b0 890
0becf74e
MW
891 else:
892 if len(argv) != 1: op.print_usage(file = SYS.stderr); SYS.exit(2)
893 if opts.database is not None: init_db(opts.database)
894 if opts.series is None:
895 series_wanted = None
896 else:
897 series_wanted = set()
898 for name in opts.series.split(","): series_wanted.add(name)
2f4f35b0
MW
899 if opts.deps is not None:
900 if (opts.output is None or opts.output == "-") and opts.fakeout is None:
901 raise ExpectedError("can't write dep fragment without output file")
902 if opts.fakeout is None: opts.fakeout = opts.output
903 else:
904 if opts.fakeout is not None:
905 raise ExpectedError("fake output set but no dep fragment")
906
0becf74e
MW
907 ep = EpisodeListParser(series_wanted, opts.chaptersp)
908 ep.parse_file(argv[0])
909 pl = ep.done()
2f4f35b0
MW
910
911 if opts.output is None or opts.output == "-":
912 pl.write(SYS.stdout)
913 else:
914 with open(opts.output, "w") as f: pl.write(f)
915
916 if opts.deps:
917 if opts.deps == "-":
918 pl.write_deps(SYS.stdout, opts.fakeout)
919 else:
920 with open(opts.deps, "w") as f: pl.write_deps(f, opts.fakeout)
921
04a05f7f
MW
922except (ExpectedError, IOError, OSError) as e:
923 LOC.report(e)
924 SYS.exit(2)