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