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