mkm3u: Factor out the guts of `AudioDir'.
[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
04a05f7f
MW
414
415class Chapter (object):
416 def __init__(me, episode, title, i):
417 me.title, me.i = title, i
1bec83d0
MW
418 me.url, me.duration = \
419 episode.source.url_and_duration(episode.tno, i, i + 1)
04a05f7f
MW
420
421class Episode (object):
fb430389 422 def __init__(me, season, i, neps, title, src, series_title_p = True,
d4f60571 423 tno = -1, startch = -1, endch = -1):
04a05f7f
MW
424 me.season = season
425 me.i, me.neps, me.title = i, neps, title
426 me.chapters = []
427 me.source, me.tno = src, tno
fb430389 428 me.series_title_p = series_title_p
1766dcfb 429 me.tno, me.start_chapter, me.end_chapter = tno, startch, endch
1bec83d0 430 me.url, me.duration = src.url_and_duration(tno, startch, endch)
04a05f7f
MW
431 def add_chapter(me, title, j):
432 ch = Chapter(me, title, j)
433 me.chapters.append(ch)
434 return ch
435 def label(me):
436 return me.season._eplabel(me.i, me.neps, me.title)
437
151b3c4f
MW
438class BaseSeason (object):
439 def __init__(me, series, implicitp = False):
440 me.series = series
04a05f7f 441 me.episodes = []
151b3c4f
MW
442 me.implicitp = implicitp
443 me.ep_i, episodes = 1, []
fb430389
MW
444 def add_episode(me, j, neps, title, src, series_title_p,
445 tno, startch, endch):
446 ep = Episode(me, j, neps, title, src, series_title_p,
447 tno, startch, endch)
04a05f7f 448 me.episodes.append(ep)
151b3c4f 449 src.nuses += neps; me.ep_i += neps
04a05f7f 450 return ep
4a25b86c
MW
451 def _epnames(me, i, neps):
452 playlist = me.series.playlist
c6b2a381
MW
453 if neps == 1: return playlist.epname, ["%d" % i]
454 elif neps == 2: return playlist.epnames, ["%d" % i, "%d" % (i + 1)]
455 else: return playlist.epnames, ["%d–%d" % (i, i + neps - 1)]
151b3c4f
MW
456
457class Season (BaseSeason):
458 def __init__(me, series, title, i, *args, **kw):
459 super().__init__(series, *args, **kw)
460 me.title, me.i = title, i
04a05f7f 461 def _eplabel(me, i, neps, title):
4a25b86c 462 epname, epn = me._epnames(i, neps)
04a05f7f 463 if title is None:
c6b2a381
MW
464 if me.implicitp:
465 label = "%s %s" % (epname, ", ".join(epn))
466 elif me.title is None:
467 label = "%s %s" % \
468 (epname, ", ".join("%d.%s" % (me.i, e) for e in epn))
469 else:
b0c5ef2d 470 label = "%s: %s %s" % (me.title, epname, ", ".join(epn))
04a05f7f 471 else:
c6b2a381
MW
472 if me.implicitp:
473 label = "%s. %s" % (", ".join(epn), title)
474 elif me.title is None:
475 label = "%s. %s" % \
476 (", ".join("%d.%s" % (me.i, e) for e in epn), title)
477 else:
b0c5ef2d 478 label = "%s: %s. %s" % (me.title, ", ".join(epn), title)
151b3c4f 479 return label
04a05f7f 480
151b3c4f 481class MovieSeason (BaseSeason):
c3538df6
MW
482 def __init__(me, series, title, *args, **kw):
483 super().__init__(series, *args, **kw)
484 me.title = title
5ca4c92e 485 me.i = None
fb430389
MW
486 def add_episode(me, j, neps, title, src, series_title_p,
487 tno, startch, endch):
c3538df6
MW
488 if me.title is None and title is None:
489 raise ExpectedError("movie or movie season must have a title")
fb430389
MW
490 return super().add_episode(j, neps, title, src, series_title_p,
491 tno, startch, endch)
c3538df6
MW
492 def _eplabel(me, i, neps, title):
493 if me.title is None:
494 label = title
495 elif title is None:
496 epname, epn = me._epnames(i, neps)
b0c5ef2d 497 label = "%s: %s %s" % (me.title, epname, ", ".join(epn))
c3538df6 498 else:
b0c5ef2d 499 label = "%s: %s" % (me.title, title)
c3538df6 500 return label
04a05f7f 501
151b3c4f 502class Series (object):
2cf61248
MW
503 def __init__(me, playlist, name, title = None,
504 full_title = None, wantedp = True):
151b3c4f 505 me.playlist = playlist
2cf61248 506 me.name, me.title, me.full_title = name, title, full_title
151b3c4f 507 me.cur_season = None
08f08e7c 508 me.wantedp = wantedp
151b3c4f
MW
509 def _add_season(me, season):
510 me.cur_season = season
511 def add_season(me, title, i, implicitp = False):
512 me._add_season(Season(me, title, i, implicitp))
c3538df6
MW
513 def add_movies(me, title = None):
514 me._add_season(MovieSeason(me, title))
151b3c4f
MW
515 def ensure_season(me):
516 if me.cur_season is None: me.add_season(None, 1, implicitp = True)
517 return me.cur_season
518 def end_season(me):
519 me.cur_season = None
04a05f7f 520
151b3c4f 521class Playlist (object):
3b9c615b 522
04a05f7f
MW
523 def __init__(me):
524 me.seasons = []
151b3c4f 525 me.episodes = []
04a05f7f 526 me.epname, me.epnames = "Episode", "Episodes"
151b3c4f 527 me.nseries = 0
d1f1c578
MW
528 me.single_series_p = False
529 me.series_title = None
3b9c615b 530
151b3c4f
MW
531 def add_episode(me, episode):
532 me.episodes.append(episode)
3b9c615b 533
151b3c4f
MW
534 def done_season(me):
535 if me.episodes:
536 me.seasons.append(me.episodes)
537 me.episodes = []
2f4f35b0 538
04a05f7f
MW
539 def write(me, f):
540 f.write("#EXTM3U\n")
541 for season in me.seasons:
542 f.write("\n")
151b3c4f
MW
543 for ep in season:
544 label = ep.label()
fb430389
MW
545 if me.nseries > 1 and ep.series_title_p and \
546 ep.season.series.title is not None:
b0c5ef2d 547 if ep.season.i is None: sep = ": "
48d26ec8
MW
548 else: sep = " "
549 label = ep.season.series.title + sep + label
04a05f7f 550 if not ep.chapters:
1bec83d0 551 f.write("#EXTINF:%d,,%s\n%s\n" % (ep.duration, label, ep.url))
04a05f7f
MW
552 else:
553 for ch in ep.chapters:
1bec83d0 554 f.write("#EXTINF:%d,,%s: %s\n%s\n" %
1766dcfb
MW
555 (ch.duration, label, ch.title, ch.url))
556
557 def dump(me, f):
e6650127 558 if opts.list_name is not None: f.write("LIST %s\n" % opts.list_name)
1766dcfb
MW
559 if me.series_title is not None and \
560 me.nseries > 1 and not me.single_series_p:
561 raise ExpectedError("can't force series name for multi-series list")
be6d4012 562 series = set()
1766dcfb 563 if me.single_series_p:
be6d4012 564 f.write("SERIES - %s\n" % quote(me.series_title))
1766dcfb
MW
565 for season in me.seasons:
566 for ep in season:
be6d4012
MW
567 label = ep.label()
568 title = ep.season.series.full_title
1766dcfb 569 if me.single_series_p:
be6d4012
MW
570 stag = "-"
571 if title is not None: label = title + " " + label
1766dcfb 572 else:
be6d4012
MW
573 if title is None: title = me.series_title
574 stag = ep.season.series.name
575 if stag is None: stag = "-"
576 if stag not in series:
1766dcfb 577 f.write("SERIES %s %s\n" % (stag, quote(title)))
be6d4012
MW
578 series.add(stag)
579 f.write("ENTRY %s %s %s %d %d %d %g\n" %
1766dcfb
MW
580 (stag, quote(label), quote(ep.source.fn),
581 ep.tno, ep.start_chapter, ep.end_chapter, ep.duration))
151b3c4f 582
2f4f35b0
MW
583 def write_deps(me, f, out):
584 deps = set()
585 for season in me.seasons:
586 for ep in season: deps.add(ep.source.fn)
587 f.write("### -*-makefile-*-\n")
588 f.write("%s: $(call check-deps, %s," % (out, out))
589 for dep in sorted(deps):
590 f.write(" \\\n\t'%s'" %
591 OS.path.join(ROOT, dep)
592 .replace(",", "$(comma)")
593 .replace("'", "'\\''"))
594 f.write(")\n")
595
066e5d43
MW
596DEFAULT_EXPVAR = 0.05
597R_DURMULT = RX.compile(r""" ^
598 (\d+ (?: \. \d+)?) x
599$ """, RX.X)
600R_DUR = RX.compile(r""" ^
601 (?: (?: (\d+) :)? (\d+) :)? (\d+)
602 (?: / (\d+ (?: \. \d+)?) \%)?
603$ """, RX.X)
604def parse_duration(s, base = None, basevar = DEFAULT_EXPVAR):
605 if base is not None:
606 m = R_DURMULT.match(s)
607 if m is not None: return base*float(m.group(1)), basevar
608 m = R_DUR.match(s)
609 if not m: raise ExpectedError("invalid duration spec `%s'" % s)
610 hr, min, sec = map(lambda g: filter(m.group(g), int, 0), [1, 2, 3])
611 var = filter(m.group(4), lambda x: float(x)/100.0)
612 if var is None: var = DEFAULT_EXPVAR
613 return 3600*hr + 60*min + sec, var
614def format_duration(d):
615 if d >= 3600: return "%d:%02d:%02d" % (d//3600, (d//60)%60, d%60)
616 elif d >= 60: return "%d:%02d" % (d//60, d%60)
617 else: return "%d s" % d
618
151b3c4f
MW
619MODE_UNSET = 0
620MODE_SINGLE = 1
621MODE_MULTI = 2
622
623class EpisodeListParser (object):
624
625 def __init__(me, series_wanted = None, chapters_wanted_p = False):
626 me._pl = Playlist()
627 me._cur_episode = me._cur_chapter = None
7427f306 628 me._series = {}; me._vdirs = {}; me._sfdirs = {}; me._isos = {}
151b3c4f
MW
629 me._series_wanted = series_wanted
630 me._chaptersp = chapters_wanted_p
066e5d43 631 me._explen, me._expvar = None, DEFAULT_EXPVAR
151b3c4f
MW
632 if series_wanted is None: me._mode = MODE_UNSET
633 else: me._mode = MODE_MULTI
634
635 def _bad_keyval(me, cmd, k, v):
636 raise ExpectedError("invalid `!%s' option `%s'" %
637 (cmd, v if k is None else k))
638
639 def _keyvals(me, opts):
640 if opts is not None:
641 for kv in opts.split(","):
642 try: sep = kv.index("=")
643 except ValueError: yield None, kv
644 else: yield kv[:sep], kv[sep + 1:]
645
646 def _set_mode(me, mode):
647 if me._mode == MODE_UNSET:
648 me._mode = mode
649 elif me._mode != mode:
650 raise ExpectedError("inconsistent single-/multi-series usage")
651
652 def _get_series(me, name):
653 if name is None:
654 me._set_mode(MODE_SINGLE)
655 try: series = me._series[None]
656 except KeyError:
08f08e7c 657 series = me._series[None] = Series(me._pl, None)
151b3c4f
MW
658 me._pl.nseries += 1
659 else:
660 me._set_mode(MODE_MULTI)
661 series = lookup(me._series, name, "unknown series `%s'" % name)
662 return series
663
664 def _opts_series(me, cmd, opts):
665 name = None
666 for k, v in me._keyvals(opts):
667 if k is None: name = v
668 else: me._bad_keyval(cmd, k, v)
08f08e7c 669 return me._get_series(name)
151b3c4f 670
08f08e7c
MW
671 def _auto_epsrc(me, series):
672 dir = lookup(me._vdirs, series.name, "no active video directory")
673 season = series.ensure_season()
674 check(season.i is not None, "must use explicit iso for movie seasons")
675 vseason = lookup(dir.seasons, season.i,
676 "season %d not found in video dir `%s'" %
677 (season.i, dir.dir))
678 src = lookup(vseason.episodes, season.ep_i,
679 "episode %d.%d not found in video dir `%s'" %
680 (season.i, season.ep_i, dir.dir))
681 return src
151b3c4f
MW
682
683 def _process_cmd(me, ww):
684
685 cmd = ww.nextword(); check(cmd is not None, "missing command")
686 try: sep = cmd.index(":")
687 except ValueError: opts = None
688 else: cmd, opts = cmd[:sep], cmd[sep + 1:]
689
d1f1c578
MW
690 if cmd == "title":
691 for k, v in me._keyvals(opts): me._bad_keyval("title", k, v)
692 title = ww.rest(); check(title is not None, "missing title")
693 check(me._pl.series_title is None, "already set a title")
694 me._pl.series_title = title
695
696 elif cmd == "single":
697 for k, v in me._keyvals(opts): me._bad_keyval("single", k, v)
698 check(ww.rest() is None, "trailing junk")
699 check(not me._pl.single_series_p, "single-series already set")
700 me._pl.single_series_p = True
701
702 elif cmd == "series":
151b3c4f
MW
703 name = None
704 for k, v in me._keyvals(opts):
705 if k is None: name = v
706 else: me._bad_keyval(cmd, k, v)
707 check(name is not None, "missing series name")
708 check(name not in me._series, "series `%s' already defined" % name)
028b4b51 709 title = ww.rest()
2cf61248
MW
710 if title is None:
711 full = None
712 else:
713 try: sep = title.index("::")
714 except ValueError: full = title
715 else:
716 full = title[sep + 2:].strip()
717 if sep == 0: title = None
718 else: title = title[:sep].strip()
151b3c4f 719 me._set_mode(MODE_MULTI)
2cf61248 720 me._series[name] = series = Series(me._pl, name, title, full,
08f08e7c
MW
721 me._series_wanted is None or
722 name in me._series_wanted)
723 if series.wantedp: me._pl.nseries += 1
151b3c4f
MW
724
725 elif cmd == "season":
08f08e7c 726 series = me._opts_series(cmd, opts)
151b3c4f
MW
727 w = ww.nextword();
728 check(w is not None, "missing season number")
729 if w == "-":
08f08e7c 730 if not series.wantedp: return
c3538df6 731 series.add_movies(ww.rest())
151b3c4f
MW
732 else:
733 title = ww.rest(); i = getint(w)
08f08e7c 734 if not series.wantedp: return
151b3c4f
MW
735 series.add_season(ww.rest(), getint(w), implicitp = False)
736 me._cur_episode = me._cur_chapter = None
737 me._pl.done_season()
738
066e5d43
MW
739 elif cmd == "explen":
740 w = ww.rest(); check(w is not None, "missing duration spec")
fe3e636c
MW
741 if w == "-":
742 me._explen, me._expvar = None, DEFAULT_EXPVAR
743 else:
744 d, v = parse_duration(w)
745 me._explen = d
746 if v is not None: me._expvar = v
066e5d43 747
151b3c4f
MW
748 elif cmd == "epname":
749 for k, v in me._keyvals(opts): me._bad_keyval("epname", k, v)
750 name = ww.rest(); check(name is not None, "missing episode name")
b06cf0d0 751 try: sep = name.index("::")
151b3c4f 752 except ValueError: names = name + "s"
250b0ab7 753 else: name, names = name[:sep], name[sep + 2:]
151b3c4f
MW
754 me._pl.epname, me._pl.epnames = name, names
755
756 elif cmd == "epno":
08f08e7c 757 series = me._opts_series(cmd, opts)
151b3c4f
MW
758 w = ww.rest(); check(w is not None, "missing episode number")
759 epi = getint(w)
08f08e7c 760 if not series.wantedp: return
151b3c4f
MW
761 series.ensure_season().ep_i = epi
762
b16d5186 763 elif cmd == "dvd":
08f08e7c 764 series = me._opts_series(cmd, opts)
151b3c4f 765 fn = ww.rest(); check(fn is not None, "missing filename")
08f08e7c
MW
766 if not series.wantedp: return
767 if fn == "-": forget(me._isos, series.name)
151b3c4f
MW
768 else:
769 check(OS.path.exists(OS.path.join(ROOT, fn)),
7427f306
MW
770 "dvd iso file `%s' not found" % fn)
771 me._isos[series.name] = DVDFile(fn)
151b3c4f 772
b16d5186 773 elif cmd == "dvddir":
08f08e7c 774 series = me._opts_series(cmd, opts)
151b3c4f 775 dir = ww.rest(); check(dir is not None, "missing directory")
08f08e7c
MW
776 if not series.wantedp: return
777 if dir == "-": forget(me._vdirs, series.name)
7427f306 778 else: me._vdirs[series.name] = DVDDir(dir)
151b3c4f
MW
779
780 elif cmd == "adir":
08f08e7c 781 series = me._opts_series(cmd, opts)
151b3c4f 782 dir = ww.rest(); check(dir is not None, "missing directory")
08f08e7c 783 if not series.wantedp: return
7427f306
MW
784 if dir == "-": forget(me._sfdirs, series.name)
785 else: me._sfdirs[series.name] = AudioDir(dir)
04a05f7f 786
0c4ca4f3 787 elif cmd == "displaced":
08f08e7c 788 series = me._opts_series(cmd, opts)
0c4ca4f3 789 w = ww.rest(); check(w is not None, "missing count"); n = getint(w)
08f08e7c 790 src = me._auto_epsrc(series)
0c4ca4f3 791 src.nuses += n
066e5d43 792
151b3c4f
MW
793 else:
794 raise ExpectedError("unknown command `%s'" % cmd)
795
796 def _process_episode(me, ww):
797
798 opts = ww.nextword(); check(opts is not None, "missing title/options")
d4f60571 799 ti = -1; sname = None; neps = 1; epi = None; loch = hich = -1
066e5d43 800 explen, expvar, explicitlen = me._explen, me._expvar, False
fb430389 801 series_title_p = True
151b3c4f
MW
802 for k, v in me._keyvals(opts):
803 if k is None:
804 if v.isdigit(): ti = int(v)
d4f60571 805 elif v == "-": ti = -1
151b3c4f
MW
806 else: sname = v
807 elif k == "s": sname = v
808 elif k == "n": neps = getint(v)
809 elif k == "ep": epi = getint(v)
fb430389 810 elif k == "st": series_title_p = getbool(v)
066e5d43
MW
811 elif k == "l":
812 if v == "-": me._explen, me._expvar = None, DEFAULT_EXPVAR
813 else:
814 explen, expvar = parse_duration(v, explen, expvar)
815 explicitlen = True
0411af2c
MW
816 elif k == "ch":
817 try: sep = v.index("-")
d4f60571 818 except ValueError: loch, hich = getint(v), -1
0411af2c 819 else: loch, hich = getint(v[:sep]), getint(v[sep + 1:]) + 1
151b3c4f
MW
820 else: raise ExpectedError("unknown episode option `%s'" % k)
821 check(ti is not None, "missing title number")
822 series = me._get_series(sname)
823 me._cur_chapter = None
824
825 title = ww.rest()
08f08e7c 826 if not series.wantedp: return
151b3c4f
MW
827 season = series.ensure_season()
828 if epi is None: epi = season.ep_i
829
d4f60571 830 if ti == -1:
71305e44
MW
831 check(season.implicitp or season.i is None,
832 "audio source, but explicit non-movie season")
7427f306
MW
833 dir = lookup(me._sfdirs, series.name,
834 "no title, and no single-file directory")
151b3c4f 835 src = lookup(dir.episodes, season.ep_i,
7427f306
MW
836 "episode %d not found in single-file dir `%s'" %
837 (epi, dir.dir))
04a05f7f 838
151b3c4f 839 else:
08f08e7c
MW
840 try: src = me._isos[series.name]
841 except KeyError: src = me._auto_epsrc(series)
151b3c4f 842
fb430389
MW
843 episode = season.add_episode(epi, neps, title, src,
844 series_title_p, ti, loch, hich)
066e5d43
MW
845
846 if episode.duration != -1 and explen is not None:
847 if not explicitlen: explen *= neps
848 if not explen*(1 - expvar) <= episode.duration <= explen*(1 + expvar):
849 if season.i is None: epid = "episode %d" % epi
850 else: epid = "episode %d.%d" % (season.i, epi)
851 raise ExpectedError \
852 ("%s duration %s %g%% > %g%% from expected %s" %
853 (epid, format_duration(episode.duration),
854 abs(100*(episode.duration - explen)/explen), 100*expvar,
855 format_duration(explen)))
151b3c4f
MW
856 me._pl.add_episode(episode)
857 me._cur_episode = episode
858
859 def _process_chapter(me, ww):
860 check(me._cur_episode is not None, "no current episode")
861 check(me._cur_episode.source.CHAPTERP,
862 "episode source doesn't allow chapters")
863 if me._chaptersp:
864 if me._cur_chapter is None: i = 1
865 else: i = me._cur_chapter.i + 1
866 me._cur_chapter = me._cur_episode.add_chapter(ww.rest(), i)
867
868 def parse_file(me, fn):
869 with location(FileLocation(fn, 0)) as floc:
870 with open(fn, "r") as f:
871 for line in f:
872 floc.stepline()
873 sline = line.lstrip()
874 if sline == "" or sline.startswith(";"): continue
875
876 if line.startswith("!"): me._process_cmd(Words(line[1:]))
877 elif not line[0].isspace(): me._process_episode(Words(line))
878 else: me._process_chapter(Words(line))
879 me._pl.done_season()
880
881 def done(me):
882 discs = set()
883 for name, vdir in me._vdirs.items():
08f08e7c 884 if not me._series[name].wantedp: continue
151b3c4f
MW
885 for s in vdir.seasons.values():
886 for d in s.episodes.values():
887 discs.add(d)
7427f306
MW
888 for sfdir in me._sfdirs.values():
889 for d in sfdir.episodes.values():
b092d511 890 discs.add(d)
151b3c4f
MW
891 for d in sorted(discs, key = lambda d: d.fn):
892 if d.neps is not None and d.neps != d.nuses:
893 raise ExpectedError("disc `%s' has %d episodes, used %d times" %
894 (d.fn, d.neps, d.nuses))
895 return me._pl
04a05f7f 896
151b3c4f 897op = OP.OptionParser \
e6650127 898 (usage = "%prog [-Dc] [-L NAME] [-M DEPS] [-d CACHE] [-o OUT] [-s SERIES] EPLS\n"
1766dcfb 899 "%prog -i -d CACHE",
151b3c4f 900 description = "Generate M3U playlists from an episode list.")
1766dcfb
MW
901op.add_option("-D", "--dump",
902 dest = "dump", action = "store_true", default = False,
903 help = "Dump playlist in machine-readable form")
e6650127
MW
904op.add_option("-L", "--list-name", metavar = "NAME",
905 dest = "list_name", type = "str", default = None,
906 help = "Set the playlist name")
2f4f35b0
MW
907op.add_option("-M", "--make-deps", metavar = "DEPS",
908 dest = "deps", type = "str", default = None,
909 help = "Write a `make' fragment for dependencies")
151b3c4f
MW
910op.add_option("-c", "--chapters",
911 dest = "chaptersp", action = "store_true", default = False,
912 help = "Output individual chapter names")
0becf74e
MW
913op.add_option("-i", "--init-db",
914 dest = "initdbp", action = "store_true", default = False,
915 help = "Initialize the database")
2f4f35b0 916op.add_option("-d", "--database", metavar = "CACHE",
0becf74e
MW
917 dest = "database", type = "str", default = None,
918 help = "Set filename for cache database")
2f4f35b0
MW
919op.add_option("-o", "--output", metavar = "OUT",
920 dest = "output", type = "str", default = None,
921 help = "Write output playlist to OUT")
922op.add_option("-O", "--fake-output", metavar = "OUT",
923 dest = "fakeout", type = "str", default = None,
924 help = "Pretend output goes to OUT for purposes of `-M'")
925op.add_option("-s", "--series", metavar = "SERIES",
151b3c4f
MW
926 dest = "series", type = "str", default = None,
927 help = "Output only the listed SERIES (comma-separated)")
04a05f7f 928try:
0becf74e 929 opts, argv = op.parse_args()
2f4f35b0 930
0becf74e
MW
931 if opts.initdbp:
932 if opts.chaptersp or opts.series is not None or \
2f4f35b0
MW
933 opts.output is not None or opts.deps is not None or \
934 opts.fakeout is not None or \
0becf74e
MW
935 opts.database is None or len(argv):
936 op.print_usage(file = SYS.stderr); SYS.exit(2)
937 setup_db(opts.database)
2f4f35b0 938
0becf74e
MW
939 else:
940 if len(argv) != 1: op.print_usage(file = SYS.stderr); SYS.exit(2)
941 if opts.database is not None: init_db(opts.database)
942 if opts.series is None:
943 series_wanted = None
944 else:
945 series_wanted = set()
946 for name in opts.series.split(","): series_wanted.add(name)
2f4f35b0
MW
947 if opts.deps is not None:
948 if (opts.output is None or opts.output == "-") and opts.fakeout is None:
949 raise ExpectedError("can't write dep fragment without output file")
950 if opts.fakeout is None: opts.fakeout = opts.output
951 else:
952 if opts.fakeout is not None:
953 raise ExpectedError("fake output set but no dep fragment")
954
0becf74e
MW
955 ep = EpisodeListParser(series_wanted, opts.chaptersp)
956 ep.parse_file(argv[0])
957 pl = ep.done()
2f4f35b0 958
e6650127
MW
959 if opts.list_name is None:
960 opts.list_name, _ = OS.path.splitext(OS.path.basename(argv[0]))
961
1766dcfb
MW
962 if opts.dump: outfn = pl.dump
963 else: outfn = pl.write
2f4f35b0 964 if opts.output is None or opts.output == "-":
1766dcfb 965 outfn(SYS.stdout)
2f4f35b0 966 else:
1766dcfb 967 with open(opts.output, "w") as f: outfn(f)
2f4f35b0
MW
968
969 if opts.deps:
970 if opts.deps == "-":
971 pl.write_deps(SYS.stdout, opts.fakeout)
972 else:
973 with open(opts.deps, "w") as f: pl.write_deps(f, opts.fakeout)
974
04a05f7f
MW
975except (ExpectedError, IOError, OSError) as e:
976 LOC.report(e)
977 SYS.exit(2)