mkm3u: Rewrite `Playlist.dump' to use series tags directly.
[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
47def quote(s):
48 if s is None: return "-"
49 else: return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
50
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
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
80URL_SAFE_P = 256*[False]
81for ch in \
82 b"ABCDEFGHIJKLMNOPQRSTUVWXYZ" \
83 b"abcdefghijklmnopqrstuvwxyz" \
84 b"0123456789" b"!$%-.,/":
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
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
134class Source (object):
135
136 PREFIX = ""
137 TITLEP = CHAPTERP = False
138
139 def __init__(me, fn):
140 me.fn = fn
141 me.neps = None
142 me.used_titles = set()
143 me.used_chapters = set()
144 me.nuses = 0
145
146 def _duration(me, title, start_chapter, end_chapter):
147 return -1
148
149 def url_and_duration(me, title = -1, start_chapter = -1, end_chapter = -1):
150 if title == -1:
151 if me.TITLEP: raise ExpectedError("missing title number")
152 if start_chapter != -1 or end_chapter != -1:
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)
157 elif start_chapter == -1:
158 if end_chapter != -1:
159 raise ExpectedError("can't specify end chapter without start chapter")
160 suffix = "#%d" % title
161 elif not me.CHAPTERP:
162 raise ExpectedError("can't specify chapter with `%s'" % me.fn)
163 elif end_chapter == -1:
164 suffix = "#%d:%d" % (title, start_chapter)
165 else:
166 suffix = "#%d:%d-%d:%d" % (title, start_chapter, title, end_chapter - 1)
167
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 = ?
179 """, [me.fn, title, start_chapter, end_chapter])
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 (?, ?, ?, ?, ?, ?, ?, ?, ?)
189 """, [me.fn, title, start_chapter, end_chapter,
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 = ?
204 """, [st.st_dev, st.st_dev, st.st_size, st.st_mtime, duration,
205 me.fn, title, start_chapter, end_chapter])
206 DB.commit()
207
208 if end_chapter != -1:
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:
215 if title == -1:
216 raise ExpectedError("`%s' already used" % me.fn)
217 elif end_chapter == -1:
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]))
222 if end_chapter == -1:
223 me.used_titles.add(title)
224 else:
225 for ch in range(start_chapter, end_chapter):
226 me.used_chapters.add((title, ch))
227 return me.PREFIX + ROOT + urlencode(me.fn) + suffix, duration
228
229class VideoDisc (Source):
230 PREFIX = "dvd://"
231 TITLEP = CHAPTERP = True
232
233 def __init__(me, fn, *args, **kw):
234 super().__init__(fn, *args, **kw)
235 me.neps = 0
236
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))
243 if start_chapter == -1:
244 durq = "duration:%d" % title
245 else:
246 nch = int(program_output(["dvd-info", path, "chapters:%d" % title]))
247 if end_chapter == -1: end_chapter = nch
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
257class VideoSeason (object):
258 def __init__(me, i, title):
259 me.i = i
260 me.title = title
261 me.episodes = {}
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))
265 me.episodes[i] = disc; disc.neps += 1
266
267def match_group(m, *groups, dflt = None, mustp = False):
268 for g in groups:
269 try: s = m.group(g)
270 except IndexError: continue
271 if s is not None: return s
272 if mustp: raise ValueError("no match found")
273 else: return dflt
274
275class VideoDir (object):
276
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+ ) \. \ .* """]]))
289
290 _R_ISO_EP = RX.compile(r""" ^
291 (?: S (?P<si> \d+) \ )?
292 E (?P<ei> \d+) (?: – (?P<ej> \d+))? $
293 """, RX.X)
294
295 def __init__(me, dir):
296 me.dir = dir
297 fns = OS.listdir(OS.path.join(ROOT, dir))
298 fns.sort()
299 season = None
300 seasons = {}
301 styles = me._R_ISO_PRE
302 for fn in fns:
303 path = OS.path.join(dir, fn)
304 if not fn.endswith(".iso"): continue
305 #print(";; `%s'" % path, file = SYS.stderr)
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
313 else:
314 #print(";;\tignored (regex mismatch)", file = SYS.stderr)
315 continue
316
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,
321 "explicit season title without number in `%s'" % fn)
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,
325 "season %d /= %d" %
326 (si, season is None and -1 or season.i + 1))
327 check(si not in seasons, "season %d already seen" % si)
328 seasons[si] = season = VideoSeason(si, stitle)
329 else:
330 check(stitle == season.title,
331 "season title `%s' /= `%s'" % (stitle, season.title))
332
333 disc = VideoDisc(path)
334 ts = season
335 any, bad = False, False
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(", ")
339 for eprange in eplist:
340 mm = me._R_ISO_EP.match(eprange)
341 if mm is None:
342 #print(";;\t`%s'?" % eprange, file = SYS.stderr)
343 bad = True; continue
344 if not any: any = True
345 i = filter(mm.group("si"), int)
346 if i is not None:
347 try: ts = seasons[i]
348 except KeyError: ts = seasons[i] = VideoSeason(i, None)
349 if ts is None:
350 ts = season = seasons[1] = VideoSeason(1, None)
351 start = filter(mm.group("ei"), int)
352 end = filter(mm.group("ej"), int, start)
353 for k in range(start, end + 1):
354 ts.set_episode_disc(k, disc)
355 #print(";;\tepisode %d.%d" % (ts.i, k), file = SYS.stderr)
356 if not any:
357 #print(";;\tignored", file = SYS.stderr)
358 pass
359 elif bad:
360 raise ExpectedError("bad ep list in `%s'", fn)
361 me.seasons = seasons
362
363class AudioDisc (Source):
364 PREFIX = "file://"
365 TITLEP = CHAPTERP = False
366
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
374class AudioEpisode (AudioDisc):
375 def __init__(me, fn, i, *args, **kw):
376 super().__init__(fn, *args, **kw)
377 me.i = i
378
379class AudioDir (object):
380
381 _R_FLAC = RX.compile(r""" ^
382 E (\d+)
383 (?: \. \ (.*))?
384 \. flac $
385 """, RX.X)
386
387 def __init__(me, dir):
388 me.dir = dir
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
408 me.url, me.duration = \
409 episode.source.url_and_duration(episode.tno, i, i + 1)
410
411class Episode (object):
412 def __init__(me, season, i, neps, title, src, series_title_p = True,
413 tno = -1, startch = -1, endch = -1):
414 me.season = season
415 me.i, me.neps, me.title = i, neps, title
416 me.chapters = []
417 me.source, me.tno = src, tno
418 me.series_title_p = series_title_p
419 me.tno, me.start_chapter, me.end_chapter = tno, startch, endch
420 me.url, me.duration = src.url_and_duration(tno, startch, endch)
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
428class BaseSeason (object):
429 def __init__(me, series, implicitp = False):
430 me.series = series
431 me.episodes = []
432 me.implicitp = implicitp
433 me.ep_i, episodes = 1, []
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)
438 me.episodes.append(ep)
439 src.nuses += neps; me.ep_i += neps
440 return ep
441 def _epnames(me, i, neps):
442 playlist = me.series.playlist
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)]
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
451 def _eplabel(me, i, neps, title):
452 epname, epn = me._epnames(i, neps)
453 if title is None:
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:
460 label = "%s: %s %s" % (me.title, epname, ", ".join(epn))
461 else:
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:
468 label = "%s: %s. %s" % (me.title, ", ".join(epn), title)
469 return label
470
471class MovieSeason (BaseSeason):
472 def __init__(me, series, title, *args, **kw):
473 super().__init__(series, *args, **kw)
474 me.title = title
475 me.i = None
476 def add_episode(me, j, neps, title, src, series_title_p,
477 tno, startch, endch):
478 if me.title is None and title is None:
479 raise ExpectedError("movie or movie season must have a title")
480 return super().add_episode(j, neps, title, src, series_title_p,
481 tno, startch, endch)
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)
487 label = "%s: %s %s" % (me.title, epname, ", ".join(epn))
488 else:
489 label = "%s: %s" % (me.title, title)
490 return label
491
492class Series (object):
493 def __init__(me, playlist, name, title = None,
494 full_title = None, wantedp = True):
495 me.playlist = playlist
496 me.name, me.title, me.full_title = name, title, full_title
497 me.cur_season = None
498 me.wantedp = wantedp
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))
503 def add_movies(me, title = None):
504 me._add_season(MovieSeason(me, title))
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
510
511class Playlist (object):
512
513 def __init__(me):
514 me.seasons = []
515 me.episodes = []
516 me.epname, me.epnames = "Episode", "Episodes"
517 me.nseries = 0
518 me.single_series_p = False
519 me.series_title = None
520
521 def add_episode(me, episode):
522 me.episodes.append(episode)
523
524 def done_season(me):
525 if me.episodes:
526 me.seasons.append(me.episodes)
527 me.episodes = []
528
529 def write(me, f):
530 f.write("#EXTM3U\n")
531 for season in me.seasons:
532 f.write("\n")
533 for ep in season:
534 label = ep.label()
535 if me.nseries > 1 and ep.series_title_p and \
536 ep.season.series.title is not None:
537 if ep.season.i is None: sep = ": "
538 else: sep = " "
539 label = ep.season.series.title + sep + label
540 if not ep.chapters:
541 f.write("#EXTINF:%d,,%s\n%s\n" % (ep.duration, label, ep.url))
542 else:
543 for ch in ep.chapters:
544 f.write("#EXTINF:%d,,%s: %s\n%s\n" %
545 (ch.duration, label, ch.title, ch.url))
546
547 def dump(me, f):
548 if me.series_title is not None and \
549 me.nseries > 1 and not me.single_series_p:
550 raise ExpectedError("can't force series name for multi-series list")
551 series = set()
552 if me.single_series_p:
553 f.write("SERIES - %s\n" % quote(me.series_title))
554 for season in me.seasons:
555 for ep in season:
556 label = ep.label()
557 title = ep.season.series.full_title
558 if me.single_series_p:
559 stag = "-"
560 if title is not None: label = title + " " + label
561 else:
562 if title is None: title = me.series_title
563 stag = ep.season.series.name
564 if stag is None: stag = "-"
565 if stag not in series:
566 f.write("SERIES %s %s\n" % (stag, quote(title)))
567 series.add(stag)
568 f.write("ENTRY %s %s %s %d %d %d %g\n" %
569 (stag, quote(label), quote(ep.source.fn),
570 ep.tno, ep.start_chapter, ep.end_chapter, ep.duration))
571
572 def write_deps(me, f, out):
573 deps = set()
574 for season in me.seasons:
575 for ep in season: deps.add(ep.source.fn)
576 f.write("### -*-makefile-*-\n")
577 f.write("%s: $(call check-deps, %s," % (out, out))
578 for dep in sorted(deps):
579 f.write(" \\\n\t'%s'" %
580 OS.path.join(ROOT, dep)
581 .replace(",", "$(comma)")
582 .replace("'", "'\\''"))
583 f.write(")\n")
584
585DEFAULT_EXPVAR = 0.05
586R_DURMULT = RX.compile(r""" ^
587 (\d+ (?: \. \d+)?) x
588$ """, RX.X)
589R_DUR = RX.compile(r""" ^
590 (?: (?: (\d+) :)? (\d+) :)? (\d+)
591 (?: / (\d+ (?: \. \d+)?) \%)?
592$ """, RX.X)
593def parse_duration(s, base = None, basevar = DEFAULT_EXPVAR):
594 if base is not None:
595 m = R_DURMULT.match(s)
596 if m is not None: return base*float(m.group(1)), basevar
597 m = R_DUR.match(s)
598 if not m: raise ExpectedError("invalid duration spec `%s'" % s)
599 hr, min, sec = map(lambda g: filter(m.group(g), int, 0), [1, 2, 3])
600 var = filter(m.group(4), lambda x: float(x)/100.0)
601 if var is None: var = DEFAULT_EXPVAR
602 return 3600*hr + 60*min + sec, var
603def format_duration(d):
604 if d >= 3600: return "%d:%02d:%02d" % (d//3600, (d//60)%60, d%60)
605 elif d >= 60: return "%d:%02d" % (d//60, d%60)
606 else: return "%d s" % d
607
608MODE_UNSET = 0
609MODE_SINGLE = 1
610MODE_MULTI = 2
611
612class EpisodeListParser (object):
613
614 def __init__(me, series_wanted = None, chapters_wanted_p = False):
615 me._pl = Playlist()
616 me._cur_episode = me._cur_chapter = None
617 me._series = {}; me._vdirs = {}; me._audirs = {}; me._isos = {}
618 me._series_wanted = series_wanted
619 me._chaptersp = chapters_wanted_p
620 me._explen, me._expvar = None, DEFAULT_EXPVAR
621 if series_wanted is None: me._mode = MODE_UNSET
622 else: me._mode = MODE_MULTI
623
624 def _bad_keyval(me, cmd, k, v):
625 raise ExpectedError("invalid `!%s' option `%s'" %
626 (cmd, v if k is None else k))
627
628 def _keyvals(me, opts):
629 if opts is not None:
630 for kv in opts.split(","):
631 try: sep = kv.index("=")
632 except ValueError: yield None, kv
633 else: yield kv[:sep], kv[sep + 1:]
634
635 def _set_mode(me, mode):
636 if me._mode == MODE_UNSET:
637 me._mode = mode
638 elif me._mode != mode:
639 raise ExpectedError("inconsistent single-/multi-series usage")
640
641 def _get_series(me, name):
642 if name is None:
643 me._set_mode(MODE_SINGLE)
644 try: series = me._series[None]
645 except KeyError:
646 series = me._series[None] = Series(me._pl, None)
647 me._pl.nseries += 1
648 else:
649 me._set_mode(MODE_MULTI)
650 series = lookup(me._series, name, "unknown series `%s'" % name)
651 return series
652
653 def _opts_series(me, cmd, opts):
654 name = None
655 for k, v in me._keyvals(opts):
656 if k is None: name = v
657 else: me._bad_keyval(cmd, k, v)
658 return me._get_series(name)
659
660 def _auto_epsrc(me, series):
661 dir = lookup(me._vdirs, series.name, "no active video directory")
662 season = series.ensure_season()
663 check(season.i is not None, "must use explicit iso for movie seasons")
664 vseason = lookup(dir.seasons, season.i,
665 "season %d not found in video dir `%s'" %
666 (season.i, dir.dir))
667 src = lookup(vseason.episodes, season.ep_i,
668 "episode %d.%d not found in video dir `%s'" %
669 (season.i, season.ep_i, dir.dir))
670 return src
671
672 def _process_cmd(me, ww):
673
674 cmd = ww.nextword(); check(cmd is not None, "missing command")
675 try: sep = cmd.index(":")
676 except ValueError: opts = None
677 else: cmd, opts = cmd[:sep], cmd[sep + 1:]
678
679 if cmd == "title":
680 for k, v in me._keyvals(opts): me._bad_keyval("title", k, v)
681 title = ww.rest(); check(title is not None, "missing title")
682 check(me._pl.series_title is None, "already set a title")
683 me._pl.series_title = title
684
685 elif cmd == "single":
686 for k, v in me._keyvals(opts): me._bad_keyval("single", k, v)
687 check(ww.rest() is None, "trailing junk")
688 check(not me._pl.single_series_p, "single-series already set")
689 me._pl.single_series_p = True
690
691 elif cmd == "series":
692 name = None
693 for k, v in me._keyvals(opts):
694 if k is None: name = v
695 else: me._bad_keyval(cmd, k, v)
696 check(name is not None, "missing series name")
697 check(name not in me._series, "series `%s' already defined" % name)
698 title = ww.rest()
699 if title is None:
700 full = None
701 else:
702 try: sep = title.index("::")
703 except ValueError: full = title
704 else:
705 full = title[sep + 2:].strip()
706 if sep == 0: title = None
707 else: title = title[:sep].strip()
708 me._set_mode(MODE_MULTI)
709 me._series[name] = series = Series(me._pl, name, title, full,
710 me._series_wanted is None or
711 name in me._series_wanted)
712 if series.wantedp: me._pl.nseries += 1
713
714 elif cmd == "season":
715 series = me._opts_series(cmd, opts)
716 w = ww.nextword();
717 check(w is not None, "missing season number")
718 if w == "-":
719 if not series.wantedp: return
720 series.add_movies(ww.rest())
721 else:
722 title = ww.rest(); i = getint(w)
723 if not series.wantedp: return
724 series.add_season(ww.rest(), getint(w), implicitp = False)
725 me._cur_episode = me._cur_chapter = None
726 me._pl.done_season()
727
728 elif cmd == "explen":
729 w = ww.rest(); check(w is not None, "missing duration spec")
730 if w == "-":
731 me._explen, me._expvar = None, DEFAULT_EXPVAR
732 else:
733 d, v = parse_duration(w)
734 me._explen = d
735 if v is not None: me._expvar = v
736
737 elif cmd == "epname":
738 for k, v in me._keyvals(opts): me._bad_keyval("epname", k, v)
739 name = ww.rest(); check(name is not None, "missing episode name")
740 try: sep = name.index("::")
741 except ValueError: names = name + "s"
742 else: name, names = name[:sep], name[sep + 2:]
743 me._pl.epname, me._pl.epnames = name, names
744
745 elif cmd == "epno":
746 series = me._opts_series(cmd, opts)
747 w = ww.rest(); check(w is not None, "missing episode number")
748 epi = getint(w)
749 if not series.wantedp: return
750 series.ensure_season().ep_i = epi
751
752 elif cmd == "iso":
753 series = me._opts_series(cmd, opts)
754 fn = ww.rest(); check(fn is not None, "missing filename")
755 if not series.wantedp: return
756 if fn == "-": forget(me._isos, series.name)
757 else:
758 check(OS.path.exists(OS.path.join(ROOT, fn)),
759 "iso file `%s' not found" % fn)
760 me._isos[series.name] = VideoDisc(fn)
761
762 elif cmd == "vdir":
763 series = me._opts_series(cmd, opts)
764 dir = ww.rest(); check(dir is not None, "missing directory")
765 if not series.wantedp: return
766 if dir == "-": forget(me._vdirs, series.name)
767 else: me._vdirs[series.name] = VideoDir(dir)
768
769 elif cmd == "adir":
770 series = me._opts_series(cmd, opts)
771 dir = ww.rest(); check(dir is not None, "missing directory")
772 if not series.wantedp: return
773 if dir == "-": forget(me._audirs, series.name)
774 else: me._audirs[series.name] = AudioDir(dir)
775
776 elif cmd == "displaced":
777 series = me._opts_series(cmd, opts)
778 w = ww.rest(); check(w is not None, "missing count"); n = getint(w)
779 src = me._auto_epsrc(series)
780 src.nuses += n
781
782 else:
783 raise ExpectedError("unknown command `%s'" % cmd)
784
785 def _process_episode(me, ww):
786
787 opts = ww.nextword(); check(opts is not None, "missing title/options")
788 ti = -1; sname = None; neps = 1; epi = None; loch = hich = -1
789 explen, expvar, explicitlen = me._explen, me._expvar, False
790 series_title_p = True
791 for k, v in me._keyvals(opts):
792 if k is None:
793 if v.isdigit(): ti = int(v)
794 elif v == "-": ti = -1
795 else: sname = v
796 elif k == "s": sname = v
797 elif k == "n": neps = getint(v)
798 elif k == "ep": epi = getint(v)
799 elif k == "st": series_title_p = getbool(v)
800 elif k == "l":
801 if v == "-": me._explen, me._expvar = None, DEFAULT_EXPVAR
802 else:
803 explen, expvar = parse_duration(v, explen, expvar)
804 explicitlen = True
805 elif k == "ch":
806 try: sep = v.index("-")
807 except ValueError: loch, hich = getint(v), -1
808 else: loch, hich = getint(v[:sep]), getint(v[sep + 1:]) + 1
809 else: raise ExpectedError("unknown episode option `%s'" % k)
810 check(ti is not None, "missing title number")
811 series = me._get_series(sname)
812 me._cur_chapter = None
813
814 title = ww.rest()
815 if not series.wantedp: return
816 season = series.ensure_season()
817 if epi is None: epi = season.ep_i
818
819 if ti == -1:
820 check(season.implicitp or season.i is None,
821 "audio source, but explicit non-movie season")
822 dir = lookup(me._audirs, series.name,
823 "no title, and no audio directory")
824 src = lookup(dir.episodes, season.ep_i,
825 "episode %d not found in audio dir `%s'" % (epi, dir.dir))
826
827 else:
828 try: src = me._isos[series.name]
829 except KeyError: src = me._auto_epsrc(series)
830
831 episode = season.add_episode(epi, neps, title, src,
832 series_title_p, ti, loch, hich)
833
834 if episode.duration != -1 and explen is not None:
835 if not explicitlen: explen *= neps
836 if not explen*(1 - expvar) <= episode.duration <= explen*(1 + expvar):
837 if season.i is None: epid = "episode %d" % epi
838 else: epid = "episode %d.%d" % (season.i, epi)
839 raise ExpectedError \
840 ("%s duration %s %g%% > %g%% from expected %s" %
841 (epid, format_duration(episode.duration),
842 abs(100*(episode.duration - explen)/explen), 100*expvar,
843 format_duration(explen)))
844 me._pl.add_episode(episode)
845 me._cur_episode = episode
846
847 def _process_chapter(me, ww):
848 check(me._cur_episode is not None, "no current episode")
849 check(me._cur_episode.source.CHAPTERP,
850 "episode source doesn't allow chapters")
851 if me._chaptersp:
852 if me._cur_chapter is None: i = 1
853 else: i = me._cur_chapter.i + 1
854 me._cur_chapter = me._cur_episode.add_chapter(ww.rest(), i)
855
856 def parse_file(me, fn):
857 with location(FileLocation(fn, 0)) as floc:
858 with open(fn, "r") as f:
859 for line in f:
860 floc.stepline()
861 sline = line.lstrip()
862 if sline == "" or sline.startswith(";"): continue
863
864 if line.startswith("!"): me._process_cmd(Words(line[1:]))
865 elif not line[0].isspace(): me._process_episode(Words(line))
866 else: me._process_chapter(Words(line))
867 me._pl.done_season()
868
869 def done(me):
870 discs = set()
871 for name, vdir in me._vdirs.items():
872 if not me._series[name].wantedp: continue
873 for s in vdir.seasons.values():
874 for d in s.episodes.values():
875 discs.add(d)
876 for adir in me._audirs.values():
877 for d in adir.episodes.values():
878 discs.add(d)
879 for d in sorted(discs, key = lambda d: d.fn):
880 if d.neps is not None and d.neps != d.nuses:
881 raise ExpectedError("disc `%s' has %d episodes, used %d times" %
882 (d.fn, d.neps, d.nuses))
883 return me._pl
884
885op = OP.OptionParser \
886 (usage = "%prog [-Dc] [-M DEPS] [-d CACHE] [-o OUT] [-s SERIES] EPLS\n"
887 "%prog -i -d CACHE",
888 description = "Generate M3U playlists from an episode list.")
889op.add_option("-D", "--dump",
890 dest = "dump", action = "store_true", default = False,
891 help = "Dump playlist in machine-readable form")
892op.add_option("-M", "--make-deps", metavar = "DEPS",
893 dest = "deps", type = "str", default = None,
894 help = "Write a `make' fragment for dependencies")
895op.add_option("-c", "--chapters",
896 dest = "chaptersp", action = "store_true", default = False,
897 help = "Output individual chapter names")
898op.add_option("-i", "--init-db",
899 dest = "initdbp", action = "store_true", default = False,
900 help = "Initialize the database")
901op.add_option("-d", "--database", metavar = "CACHE",
902 dest = "database", type = "str", default = None,
903 help = "Set filename for cache database")
904op.add_option("-o", "--output", metavar = "OUT",
905 dest = "output", type = "str", default = None,
906 help = "Write output playlist to OUT")
907op.add_option("-O", "--fake-output", metavar = "OUT",
908 dest = "fakeout", type = "str", default = None,
909 help = "Pretend output goes to OUT for purposes of `-M'")
910op.add_option("-s", "--series", metavar = "SERIES",
911 dest = "series", type = "str", default = None,
912 help = "Output only the listed SERIES (comma-separated)")
913try:
914 opts, argv = op.parse_args()
915
916 if opts.initdbp:
917 if opts.chaptersp or opts.series is not None or \
918 opts.output is not None or opts.deps is not None or \
919 opts.fakeout is not None or \
920 opts.database is None or len(argv):
921 op.print_usage(file = SYS.stderr); SYS.exit(2)
922 setup_db(opts.database)
923
924 else:
925 if len(argv) != 1: op.print_usage(file = SYS.stderr); SYS.exit(2)
926 if opts.database is not None: init_db(opts.database)
927 if opts.series is None:
928 series_wanted = None
929 else:
930 series_wanted = set()
931 for name in opts.series.split(","): series_wanted.add(name)
932 if opts.deps is not None:
933 if (opts.output is None or opts.output == "-") and opts.fakeout is None:
934 raise ExpectedError("can't write dep fragment without output file")
935 if opts.fakeout is None: opts.fakeout = opts.output
936 else:
937 if opts.fakeout is not None:
938 raise ExpectedError("fake output set but no dep fragment")
939
940 ep = EpisodeListParser(series_wanted, opts.chaptersp)
941 ep.parse_file(argv[0])
942 pl = ep.done()
943
944 if opts.dump: outfn = pl.dump
945 else: outfn = pl.write
946 if opts.output is None or opts.output == "-":
947 outfn(SYS.stdout)
948 else:
949 with open(opts.output, "w") as f: outfn(f)
950
951 if opts.deps:
952 if opts.deps == "-":
953 pl.write_deps(SYS.stdout, opts.fakeout)
954 else:
955 with open(opts.deps, "w") as f: pl.write_deps(f, opts.fakeout)
956
957except (ExpectedError, IOError, OSError) as e:
958 LOC.report(e)
959 SYS.exit(2)