mkm3u: Add some spaces to improve the layout.
[epls] / mkm3u
... / ...
CommitLineData
1#! /usr/bin/python3
2### -*- mode: python; coding: utf-8 -*-
3
4from contextlib import contextmanager
5import errno as E
6import optparse as OP
7import os as OS
8import re as RX
9import sqlite3 as SQL
10import subprocess as SP
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
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
38def getint(s):
39 if not s.isdigit(): raise ExpectedError("bad integer `%s'" % s)
40 return int(s)
41
42def getbool(s):
43 if s == "t": return True
44 elif s == "nil": return False
45 else: raise ExpectedError("bad boolean `%s'" % s)
46
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
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
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
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
130class Source (object):
131
132 PREFIX = ""
133 TITLEP = CHAPTERP = False
134
135 def __init__(me, fn):
136 me.fn = fn
137 me.neps = None
138 me.used_titles = dict()
139 me.used_chapters = set()
140 me.nuses = 0
141
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):
146 if title == "-":
147 if me.TITLEP: raise ExpectedError("missing title number")
148 if start_chapter is not None or end_chapter is not None:
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)
153 elif start_chapter is None:
154 if end_chapter is not None:
155 raise ExpectedError("can't specify end chapter without start chapter")
156 suffix = "#%d" % title
157 elif not me.CHAPTERP:
158 raise ExpectedError("can't specify chapter with `%s'" % me.fn)
159 elif end_chapter is None:
160 suffix = "#%d:%d" % (title, start_chapter)
161 else:
162 suffix = "#%d:%d-%d:%d" % (title, start_chapter, title, end_chapter - 1)
163
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()
206
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))
224 return me.PREFIX + ROOT + urlencode(me.fn) + suffix, duration
225
226class VideoDisc (Source):
227 PREFIX = "dvd://"
228 TITLEP = CHAPTERP = True
229
230 def __init__(me, fn, *args, **kw):
231 super().__init__(fn, *args, **kw)
232 me.neps = 0
233
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
254class VideoSeason (object):
255 def __init__(me, i, title):
256 me.i = i
257 me.title = title
258 me.episodes = {}
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))
262 me.episodes[i] = disc; disc.neps += 1
263
264def match_group(m, *groups, dflt = None, mustp = False):
265 for g in groups:
266 try: s = m.group(g)
267 except IndexError: continue
268 if s is not None: return s
269 if mustp: raise ValueError("no match found")
270 else: return dflt
271
272class VideoDir (object):
273
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+ ) \. \ .* """]]))
286
287 _R_ISO_EP = RX.compile(r""" ^
288 (?: S (?P<si> \d+) \ )?
289 E (?P<ei> \d+) (?: – (?P<ej> \d+))? $
290 """, RX.X)
291
292 def __init__(me, dir):
293 me.dir = dir
294 fns = OS.listdir(OS.path.join(ROOT, dir))
295 fns.sort()
296 season = None
297 seasons = {}
298 styles = me._R_ISO_PRE
299 for fn in fns:
300 path = OS.path.join(dir, fn)
301 if not fn.endswith(".iso"): continue
302 #print(";; `%s'" % path, file = SYS.stderr)
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
310 else:
311 #print(";;\tignored (regex mismatch)", file = SYS.stderr)
312 continue
313
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,
318 "explicit season title without number in `%s'" % fn)
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,
322 "season %d /= %d" %
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)
326 else:
327 check(stitle == season.title,
328 "season title `%s' /= `%s'" % (stitle, season.title))
329
330 disc = VideoDisc(path)
331 ts = season
332 any, bad = False, False
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(", ")
336 for eprange in eplist:
337 mm = me._R_ISO_EP.match(eprange)
338 if mm is None:
339 #print(";;\t`%s'?" % eprange, file = SYS.stderr)
340 bad = True; continue
341 if not any: any = True
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)
348 start = filter(mm.group("ei"), int)
349 end = filter(mm.group("ej"), int, start)
350 for k in range(start, end + 1):
351 ts.set_episode_disc(k, disc)
352 #print(";;\tepisode %d.%d" % (ts.i, k), file = SYS.stderr)
353 if not any:
354 #print(";;\tignored", file = SYS.stderr)
355 pass
356 elif bad:
357 raise ExpectedError("bad ep list in `%s'", fn)
358 me.seasons = seasons
359
360class AudioDisc (Source):
361 PREFIX = "file://"
362 TITLEP = CHAPTERP = False
363
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
371class AudioEpisode (AudioDisc):
372 def __init__(me, fn, i, *args, **kw):
373 super().__init__(fn, *args, **kw)
374 me.i = i
375
376class AudioDir (object):
377
378 _R_FLAC = RX.compile(r""" ^
379 E (\d+)
380 (?: \. \ (.*))?
381 \. flac $
382 """, RX.X)
383
384 def __init__(me, dir):
385 me.dir = dir
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
405 me.url, me.duration = \
406 episode.source.url_and_duration(episode.tno, i, i + 1)
407
408class Episode (object):
409 def __init__(me, season, i, neps, title, src, series_title_p = True,
410 tno = None, startch = None, endch = None):
411 me.season = season
412 me.i, me.neps, me.title = i, neps, title
413 me.chapters = []
414 me.source, me.tno = src, tno
415 me.series_title_p = series_title_p
416 me.url, me.duration = src.url_and_duration(tno, startch, endch)
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
424class BaseSeason (object):
425 def __init__(me, series, implicitp = False):
426 me.series = series
427 me.episodes = []
428 me.implicitp = implicitp
429 me.ep_i, episodes = 1, []
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)
434 me.episodes.append(ep)
435 src.nuses += neps; me.ep_i += neps
436 return ep
437 def _epnames(me, i, neps):
438 playlist = me.series.playlist
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)]
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
447 def _eplabel(me, i, neps, title):
448 epname, epn = me._epnames(i, neps)
449 if title is None:
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:
456 label = "%s: %s %s" % (me.title, epname, ", ".join(epn))
457 else:
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:
464 label = "%s: %s. %s" % (me.title, ", ".join(epn), title)
465 return label
466
467class MovieSeason (BaseSeason):
468 def __init__(me, series, title, *args, **kw):
469 super().__init__(series, *args, **kw)
470 me.title = title
471 me.i = None
472 def add_episode(me, j, neps, title, src, series_title_p,
473 tno, startch, endch):
474 if me.title is None and title is None:
475 raise ExpectedError("movie or movie season must have a title")
476 return super().add_episode(j, neps, title, src, series_title_p,
477 tno, startch, endch)
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)
483 label = "%s: %s %s" % (me.title, epname, ", ".join(epn))
484 else:
485 label = "%s: %s" % (me.title, title)
486 return label
487
488class Series (object):
489 def __init__(me, playlist, name, title = None, wantedp = True):
490 me.playlist = playlist
491 me.name, me.title = name, title
492 me.cur_season = None
493 me.wantedp = wantedp
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))
498 def add_movies(me, title = None):
499 me._add_season(MovieSeason(me, title))
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
505
506class Playlist (object):
507
508 def __init__(me):
509 me.seasons = []
510 me.episodes = []
511 me.epname, me.epnames = "Episode", "Episodes"
512 me.nseries = 0
513
514 def add_episode(me, episode):
515 me.episodes.append(episode)
516
517 def done_season(me):
518 if me.episodes:
519 me.seasons.append(me.episodes)
520 me.episodes = []
521
522 def write(me, f):
523 f.write("#EXTM3U\n")
524 for season in me.seasons:
525 f.write("\n")
526 for ep in season:
527 label = ep.label()
528 if me.nseries > 1 and ep.series_title_p and \
529 ep.season.series.title is not None:
530 if ep.season.i is None: sep = ": "
531 else: sep = " "
532 label = ep.season.series.title + sep + label
533 if not ep.chapters:
534 f.write("#EXTINF:%d,,%s\n%s\n" % (ep.duration, label, ep.url))
535 else:
536 for ch in ep.chapters:
537 f.write("#EXTINF:%d,,%s: %s\n%s\n" %
538 (ch.duration, label, ch.title, ch.url))
539
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
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
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
588 me._explen, me._expvar = None, DEFAULT_EXPVAR
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:
614 series = me._series[None] = Series(me._pl, None)
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)
626 return me._get_series(name)
627
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
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)
654 title = ww.rest()
655 me._set_mode(MODE_MULTI)
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
660
661 elif cmd == "season":
662 series = me._opts_series(cmd, opts)
663 w = ww.nextword();
664 check(w is not None, "missing season number")
665 if w == "-":
666 if not series.wantedp: return
667 series.add_movies(ww.rest())
668 else:
669 title = ww.rest(); i = getint(w)
670 if not series.wantedp: return
671 series.add_season(ww.rest(), getint(w), implicitp = False)
672 me._cur_episode = me._cur_chapter = None
673 me._pl.done_season()
674
675 elif cmd == "explen":
676 w = ww.rest(); check(w is not None, "missing duration spec")
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
683
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")
687 try: sep = name.index("::")
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":
693 series = me._opts_series(cmd, opts)
694 w = ww.rest(); check(w is not None, "missing episode number")
695 epi = getint(w)
696 if not series.wantedp: return
697 series.ensure_season().ep_i = epi
698
699 elif cmd == "iso":
700 series = me._opts_series(cmd, opts)
701 fn = ww.rest(); check(fn is not None, "missing filename")
702 if not series.wantedp: return
703 if fn == "-": forget(me._isos, series.name)
704 else:
705 check(OS.path.exists(OS.path.join(ROOT, fn)),
706 "iso file `%s' not found" % fn)
707 me._isos[series.name] = VideoDisc(fn)
708
709 elif cmd == "vdir":
710 series = me._opts_series(cmd, opts)
711 dir = ww.rest(); check(dir is not None, "missing directory")
712 if not series.wantedp: return
713 if dir == "-": forget(me._vdirs, series.name)
714 else: me._vdirs[series.name] = VideoDir(dir)
715
716 elif cmd == "adir":
717 series = me._opts_series(cmd, opts)
718 dir = ww.rest(); check(dir is not None, "missing directory")
719 if not series.wantedp: return
720 if dir == "-": forget(me._audirs, series.name)
721 else: me._audirs[series.name] = AudioDir(dir)
722
723 elif cmd == "displaced":
724 series = me._opts_series(cmd, opts)
725 w = ww.rest(); check(w is not None, "missing count"); n = getint(w)
726 src = me._auto_epsrc(series)
727 src.nuses += n
728
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")
735 ti = None; sname = None; neps = 1; epi = None; loch = hich = None
736 explen, expvar, explicitlen = me._explen, me._expvar, False
737 series_title_p = True
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)
746 elif k == "st": series_title_p = getbool(v)
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
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
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()
762 if not series.wantedp: return
763 season = series.ensure_season()
764 if epi is None: epi = season.ep_i
765
766 if ti == "-":
767 check(season.implicitp or season.i is None,
768 "audio source, but explicit non-movie season")
769 dir = lookup(me._audirs, series.name,
770 "no title, and no audio directory")
771 src = lookup(dir.episodes, season.ep_i,
772 "episode %d not found in audio dir `%s'" % (epi, dir.dir))
773
774 else:
775 try: src = me._isos[series.name]
776 except KeyError: src = me._auto_epsrc(series)
777
778 episode = season.add_episode(epi, neps, title, src,
779 series_title_p, ti, loch, hich)
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)))
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():
819 if not me._series[name].wantedp: continue
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():
825 discs.add(d)
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
831
832op = OP.OptionParser \
833 (usage = "%prog [-c] [-M DEPS] [-d CACHE] [-o OUT] [-s SERIES] EPLS\n"
834 "%prog -i -d CACHE",
835 description = "Generate M3U playlists from an episode list.")
836op.add_option("-M", "--make-deps", metavar = "DEPS",
837 dest = "deps", type = "str", default = None,
838 help = "Write a `make' fragment for dependencies")
839op.add_option("-c", "--chapters",
840 dest = "chaptersp", action = "store_true", default = False,
841 help = "Output individual chapter names")
842op.add_option("-i", "--init-db",
843 dest = "initdbp", action = "store_true", default = False,
844 help = "Initialize the database")
845op.add_option("-d", "--database", metavar = "CACHE",
846 dest = "database", type = "str", default = None,
847 help = "Set filename for cache database")
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",
855 dest = "series", type = "str", default = None,
856 help = "Output only the listed SERIES (comma-separated)")
857try:
858 opts, argv = op.parse_args()
859
860 if opts.initdbp:
861 if opts.chaptersp or opts.series is not None or \
862 opts.output is not None or opts.deps is not None or \
863 opts.fakeout is not None or \
864 opts.database is None or len(argv):
865 op.print_usage(file = SYS.stderr); SYS.exit(2)
866 setup_db(opts.database)
867
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)
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
884 ep = EpisodeListParser(series_wanted, opts.chaptersp)
885 ep.parse_file(argv[0])
886 pl = ep.done()
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
899except (ExpectedError, IOError, OSError) as e:
900 LOC.report(e)
901 SYS.exit(2)