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