mkm3u, stargate.epls, star-wars.epls: Introduce `full series titles'.
[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 = set()
139 me.used_chapters = set()
140 me.nuses = 0
141
142 def _duration(me, title, start_chapter, end_chapter):
143 return -1
144
145 def url_and_duration(me, title = -1, start_chapter = -1, end_chapter = -1):
146 if title == -1:
147 if me.TITLEP: raise ExpectedError("missing title number")
148 if start_chapter != -1 or end_chapter != -1:
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 == -1:
154 if end_chapter != -1:
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 == -1:
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, end_chapter])
176 row = c.fetchone()
177 foundp = False
178 if row is None:
179 duration = me._duration(title, start_chapter, end_chapter)
180 c.execute("""
181 INSERT OR REPLACE INTO duration
182 (path, title, start_chapter, end_chapter,
183 device, inode, size, mtime, duration)
184 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
185 """, [me.fn, title, start_chapter, end_chapter,
186 st.st_dev, st.st_ino, st.st_size, st.st_mtime,
187 duration])
188 else:
189 dev, ino, sz, mt, d = row
190 if (dev, ino, sz, mt) == \
191 (st.st_dev, st.st_ino, st.st_size, st.st_mtime):
192 duration = d
193 else:
194 duration = me._duration(title, start_chapter, end_chapter)
195 c.execute("""
196 UPDATE duration
197 SET device = ?, inode = ?, size = ?, mtime = ?, duration = ?
198 WHERE path = ? AND title = ? AND
199 start_chapter = ? AND end_chapter = ?
200 """, [st.st_dev, st.st_dev, st.st_size, st.st_mtime, duration,
201 me.fn, title, start_chapter, end_chapter])
202 DB.commit()
203
204 if end_chapter != -1:
205 keys = [(title, ch) for ch in range(start_chapter, end_chapter)]
206 set = me.used_chapters
207 else:
208 keys, set = [title], me.used_titles
209 for k in keys:
210 if k in set:
211 if title == -1:
212 raise ExpectedError("`%s' already used" % me.fn)
213 elif end_chapter == -1:
214 raise ExpectedError("`%s' title %d already used" % (me.fn, title))
215 else:
216 raise ExpectedError("`%s' title %d chapter %d already used" %
217 (me.fn, title, k[1]))
218 if end_chapter == -1:
219 me.used_titles.add(title)
220 else:
221 for ch in range(start_chapter, end_chapter):
222 me.used_chapters.add((title, ch))
223 return me.PREFIX + ROOT + urlencode(me.fn) + suffix, duration
224
225class VideoDisc (Source):
226 PREFIX = "dvd://"
227 TITLEP = CHAPTERP = True
228
229 def __init__(me, fn, *args, **kw):
230 super().__init__(fn, *args, **kw)
231 me.neps = 0
232
233 def _duration(me, title, start_chapter, end_chapter):
234 path = OS.path.join(ROOT, me.fn)
235 ntitle = int(program_output(["dvd-info", path, "titles"]))
236 if not 1 <= title <= ntitle:
237 raise ExpectedError("bad title %d for `%s': must be in 1 .. %d" %
238 (title, me.fn, ntitle))
239 if start_chapter == -1:
240 durq = "duration:%d" % title
241 else:
242 nch = int(program_output(["dvd-info", path, "chapters:%d" % title]))
243 if end_chapter == -1: end_chapter = nch
244 else: end_chapter -= 1
245 if not 1 <= start_chapter <= end_chapter <= nch:
246 raise ExpectedError("bad chapter range %d .. %d for `%s' title %d: "
247 "must be in 1 .. %d" %
248 (start_chapter, end_chapter, me.fn, title, nch))
249 durq = "duration:%d.%d-%d" % (title, start_chapter, end_chapter)
250 duration = int(program_output(["dvd-info", path, durq]))
251 return duration
252
253class VideoSeason (object):
254 def __init__(me, i, title):
255 me.i = i
256 me.title = title
257 me.episodes = {}
258 def set_episode_disc(me, i, disc):
259 if i in me.episodes:
260 raise ExpectedError("season %d episode %d already taken" % (me.i, i))
261 me.episodes[i] = disc; disc.neps += 1
262
263def match_group(m, *groups, dflt = None, mustp = False):
264 for g in groups:
265 try: s = m.group(g)
266 except IndexError: continue
267 if s is not None: return s
268 if mustp: raise ValueError("no match found")
269 else: return dflt
270
271class VideoDir (object):
272
273 _R_ISO_PRE = list(map(lambda pats:
274 list(map(lambda pat:
275 RX.compile("^" + pat + r"\.iso$", RX.X),
276 pats)),
277 [[r""" S (?P<si> \d+) \. \ (?P<stitle> .*) — (?: D \d+ \. \ )?
278 (?P<epex> .*) """,
279 r""" S (?P<si> \d+) (?: D \d+)? \. \ (?P<epex> .*) """,
280 r""" S (?P<si> \d+) \. \ (?P<epex> E \d+ .*) """,
281 r""" S (?P<si> \d+) (?P<epex> E \d+) \. \ .* """],
282 [r""" (?P<si> \d+) [A-Z]? \. \ (?P<stitle> .*) — (?P<epex> .*) """],
283 [r""" \d+ \. \ (?P<epex> [ES] \d+ .*) """],
284 [r""" (?P<epnum> \d+ ) \. \ .* """]]))
285
286 _R_ISO_EP = RX.compile(r""" ^
287 (?: S (?P<si> \d+) \ )?
288 E (?P<ei> \d+) (?: – (?P<ej> \d+))? $
289 """, RX.X)
290
291 def __init__(me, dir):
292 me.dir = dir
293 fns = OS.listdir(OS.path.join(ROOT, dir))
294 fns.sort()
295 season = None
296 seasons = {}
297 styles = me._R_ISO_PRE
298 for fn in fns:
299 path = OS.path.join(dir, fn)
300 if not fn.endswith(".iso"): continue
301 #print(";; `%s'" % path, file = SYS.stderr)
302 for sty in styles:
303 for r in sty:
304 m = r.match(fn)
305 if m: styles = [sty]; break
306 else:
307 continue
308 break
309 else:
310 #print(";;\tignored (regex mismatch)", file = SYS.stderr)
311 continue
312
313 si = filter(match_group(m, "si"), int)
314 stitle = match_group(m, "stitle")
315
316 check(si is not None or stitle is None,
317 "explicit season title without number in `%s'" % fn)
318 if si is not None:
319 if season is None or si != season.i:
320 check(season is None or si == season.i + 1,
321 "season %d /= %d" %
322 (si, season is None and -1 or season.i + 1))
323 check(si not in seasons, "season %d already seen" % si)
324 seasons[si] = season = VideoSeason(si, stitle)
325 else:
326 check(stitle == season.title,
327 "season title `%s' /= `%s'" % (stitle, season.title))
328
329 disc = VideoDisc(path)
330 ts = season
331 any, bad = False, False
332 epnum = match_group(m, "epnum")
333 if epnum is not None: eplist = ["E" + epnum]
334 else: eplist = match_group(m, "epex", mustp = True).split(", ")
335 for eprange in eplist:
336 mm = me._R_ISO_EP.match(eprange)
337 if mm is None:
338 #print(";;\t`%s'?" % eprange, file = SYS.stderr)
339 bad = True; continue
340 if not any: any = True
341 i = filter(mm.group("si"), int)
342 if i is not None:
343 try: ts = seasons[i]
344 except KeyError: ts = seasons[i] = VideoSeason(i, None)
345 if ts is None:
346 ts = season = seasons[1] = VideoSeason(1, None)
347 start = filter(mm.group("ei"), int)
348 end = filter(mm.group("ej"), int, start)
349 for k in range(start, end + 1):
350 ts.set_episode_disc(k, disc)
351 #print(";;\tepisode %d.%d" % (ts.i, k), file = SYS.stderr)
352 if not any:
353 #print(";;\tignored", file = SYS.stderr)
354 pass
355 elif bad:
356 raise ExpectedError("bad ep list in `%s'", fn)
357 me.seasons = seasons
358
359class AudioDisc (Source):
360 PREFIX = "file://"
361 TITLEP = CHAPTERP = False
362
363 def _duration(me, title, start_chapter, end_chaptwr):
364 out = program_output(["metaflac",
365 "--show-total-samples", "--show-sample-rate",
366 OS.path.join(ROOT, me.fn)])
367 nsamples, hz = map(float, out.split())
368 return int(nsamples/hz)
369
370class AudioEpisode (AudioDisc):
371 def __init__(me, fn, i, *args, **kw):
372 super().__init__(fn, *args, **kw)
373 me.i = i
374
375class AudioDir (object):
376
377 _R_FLAC = RX.compile(r""" ^
378 E (\d+)
379 (?: \. \ (.*))?
380 \. flac $
381 """, RX.X)
382
383 def __init__(me, dir):
384 me.dir = dir
385 fns = OS.listdir(OS.path.join(ROOT, dir))
386 fns.sort()
387 episodes = {}
388 last_i = 0
389 for fn in fns:
390 path = OS.path.join(dir, fn)
391 if not fn.endswith(".flac"): continue
392 m = me._R_FLAC.match(fn)
393 if not m: continue
394 i = filter(m.group(1), int)
395 etitle = m.group(2)
396 check(i == last_i + 1, "episode %d /= %d" % (i, last_i + 1))
397 episodes[i] = AudioEpisode(path, i)
398 last_i = i
399 me.episodes = episodes
400
401class Chapter (object):
402 def __init__(me, episode, title, i):
403 me.title, me.i = title, i
404 me.url, me.duration = \
405 episode.source.url_and_duration(episode.tno, i, i + 1)
406
407class Episode (object):
408 def __init__(me, season, i, neps, title, src, series_title_p = True,
409 tno = -1, startch = -1, endch = -1):
410 me.season = season
411 me.i, me.neps, me.title = i, neps, title
412 me.chapters = []
413 me.source, me.tno = src, tno
414 me.series_title_p = series_title_p
415 me.url, me.duration = src.url_and_duration(tno, startch, endch)
416 def add_chapter(me, title, j):
417 ch = Chapter(me, title, j)
418 me.chapters.append(ch)
419 return ch
420 def label(me):
421 return me.season._eplabel(me.i, me.neps, me.title)
422
423class BaseSeason (object):
424 def __init__(me, series, implicitp = False):
425 me.series = series
426 me.episodes = []
427 me.implicitp = implicitp
428 me.ep_i, episodes = 1, []
429 def add_episode(me, j, neps, title, src, series_title_p,
430 tno, startch, endch):
431 ep = Episode(me, j, neps, title, src, series_title_p,
432 tno, startch, endch)
433 me.episodes.append(ep)
434 src.nuses += neps; me.ep_i += neps
435 return ep
436 def _epnames(me, i, neps):
437 playlist = me.series.playlist
438 if neps == 1: return playlist.epname, ["%d" % i]
439 elif neps == 2: return playlist.epnames, ["%d" % i, "%d" % (i + 1)]
440 else: return playlist.epnames, ["%d–%d" % (i, i + neps - 1)]
441
442class Season (BaseSeason):
443 def __init__(me, series, title, i, *args, **kw):
444 super().__init__(series, *args, **kw)
445 me.title, me.i = title, i
446 def _eplabel(me, i, neps, title):
447 epname, epn = me._epnames(i, neps)
448 if title is None:
449 if me.implicitp:
450 label = "%s %s" % (epname, ", ".join(epn))
451 elif me.title is None:
452 label = "%s %s" % \
453 (epname, ", ".join("%d.%s" % (me.i, e) for e in epn))
454 else:
455 label = "%s: %s %s" % (me.title, epname, ", ".join(epn))
456 else:
457 if me.implicitp:
458 label = "%s. %s" % (", ".join(epn), title)
459 elif me.title is None:
460 label = "%s. %s" % \
461 (", ".join("%d.%s" % (me.i, e) for e in epn), title)
462 else:
463 label = "%s: %s. %s" % (me.title, ", ".join(epn), title)
464 return label
465
466class MovieSeason (BaseSeason):
467 def __init__(me, series, title, *args, **kw):
468 super().__init__(series, *args, **kw)
469 me.title = title
470 me.i = None
471 def add_episode(me, j, neps, title, src, series_title_p,
472 tno, startch, endch):
473 if me.title is None and title is None:
474 raise ExpectedError("movie or movie season must have a title")
475 return super().add_episode(j, neps, title, src, series_title_p,
476 tno, startch, endch)
477 def _eplabel(me, i, neps, title):
478 if me.title is None:
479 label = title
480 elif title is None:
481 epname, epn = me._epnames(i, neps)
482 label = "%s: %s %s" % (me.title, epname, ", ".join(epn))
483 else:
484 label = "%s: %s" % (me.title, title)
485 return label
486
487class Series (object):
488 def __init__(me, playlist, name, title = None,
489 full_title = None, wantedp = True):
490 me.playlist = playlist
491 me.name, me.title, me.full_title = name, title, full_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 if title is None:
656 full = None
657 else:
658 try: sep = title.index("::")
659 except ValueError: full = title
660 else:
661 full = title[sep + 2:].strip()
662 if sep == 0: title = None
663 else: title = title[:sep].strip()
664 me._set_mode(MODE_MULTI)
665 me._series[name] = series = Series(me._pl, name, title, full,
666 me._series_wanted is None or
667 name in me._series_wanted)
668 if series.wantedp: me._pl.nseries += 1
669
670 elif cmd == "season":
671 series = me._opts_series(cmd, opts)
672 w = ww.nextword();
673 check(w is not None, "missing season number")
674 if w == "-":
675 if not series.wantedp: return
676 series.add_movies(ww.rest())
677 else:
678 title = ww.rest(); i = getint(w)
679 if not series.wantedp: return
680 series.add_season(ww.rest(), getint(w), implicitp = False)
681 me._cur_episode = me._cur_chapter = None
682 me._pl.done_season()
683
684 elif cmd == "explen":
685 w = ww.rest(); check(w is not None, "missing duration spec")
686 if w == "-":
687 me._explen, me._expvar = None, DEFAULT_EXPVAR
688 else:
689 d, v = parse_duration(w)
690 me._explen = d
691 if v is not None: me._expvar = v
692
693 elif cmd == "epname":
694 for k, v in me._keyvals(opts): me._bad_keyval("epname", k, v)
695 name = ww.rest(); check(name is not None, "missing episode name")
696 try: sep = name.index("::")
697 except ValueError: names = name + "s"
698 else: name, names = name[:sep], name[sep + 2:]
699 me._pl.epname, me._pl.epnames = name, names
700
701 elif cmd == "epno":
702 series = me._opts_series(cmd, opts)
703 w = ww.rest(); check(w is not None, "missing episode number")
704 epi = getint(w)
705 if not series.wantedp: return
706 series.ensure_season().ep_i = epi
707
708 elif cmd == "iso":
709 series = me._opts_series(cmd, opts)
710 fn = ww.rest(); check(fn is not None, "missing filename")
711 if not series.wantedp: return
712 if fn == "-": forget(me._isos, series.name)
713 else:
714 check(OS.path.exists(OS.path.join(ROOT, fn)),
715 "iso file `%s' not found" % fn)
716 me._isos[series.name] = VideoDisc(fn)
717
718 elif cmd == "vdir":
719 series = me._opts_series(cmd, opts)
720 dir = ww.rest(); check(dir is not None, "missing directory")
721 if not series.wantedp: return
722 if dir == "-": forget(me._vdirs, series.name)
723 else: me._vdirs[series.name] = VideoDir(dir)
724
725 elif cmd == "adir":
726 series = me._opts_series(cmd, opts)
727 dir = ww.rest(); check(dir is not None, "missing directory")
728 if not series.wantedp: return
729 if dir == "-": forget(me._audirs, series.name)
730 else: me._audirs[series.name] = AudioDir(dir)
731
732 elif cmd == "displaced":
733 series = me._opts_series(cmd, opts)
734 w = ww.rest(); check(w is not None, "missing count"); n = getint(w)
735 src = me._auto_epsrc(series)
736 src.nuses += n
737
738 else:
739 raise ExpectedError("unknown command `%s'" % cmd)
740
741 def _process_episode(me, ww):
742
743 opts = ww.nextword(); check(opts is not None, "missing title/options")
744 ti = -1; sname = None; neps = 1; epi = None; loch = hich = -1
745 explen, expvar, explicitlen = me._explen, me._expvar, False
746 series_title_p = True
747 for k, v in me._keyvals(opts):
748 if k is None:
749 if v.isdigit(): ti = int(v)
750 elif v == "-": ti = -1
751 else: sname = v
752 elif k == "s": sname = v
753 elif k == "n": neps = getint(v)
754 elif k == "ep": epi = getint(v)
755 elif k == "st": series_title_p = getbool(v)
756 elif k == "l":
757 if v == "-": me._explen, me._expvar = None, DEFAULT_EXPVAR
758 else:
759 explen, expvar = parse_duration(v, explen, expvar)
760 explicitlen = True
761 elif k == "ch":
762 try: sep = v.index("-")
763 except ValueError: loch, hich = getint(v), -1
764 else: loch, hich = getint(v[:sep]), getint(v[sep + 1:]) + 1
765 else: raise ExpectedError("unknown episode option `%s'" % k)
766 check(ti is not None, "missing title number")
767 series = me._get_series(sname)
768 me._cur_chapter = None
769
770 title = ww.rest()
771 if not series.wantedp: return
772 season = series.ensure_season()
773 if epi is None: epi = season.ep_i
774
775 if ti == -1:
776 check(season.implicitp or season.i is None,
777 "audio source, but explicit non-movie season")
778 dir = lookup(me._audirs, series.name,
779 "no title, and no audio directory")
780 src = lookup(dir.episodes, season.ep_i,
781 "episode %d not found in audio dir `%s'" % (epi, dir.dir))
782
783 else:
784 try: src = me._isos[series.name]
785 except KeyError: src = me._auto_epsrc(series)
786
787 episode = season.add_episode(epi, neps, title, src,
788 series_title_p, ti, loch, hich)
789
790 if episode.duration != -1 and explen is not None:
791 if not explicitlen: explen *= neps
792 if not explen*(1 - expvar) <= episode.duration <= explen*(1 + expvar):
793 if season.i is None: epid = "episode %d" % epi
794 else: epid = "episode %d.%d" % (season.i, epi)
795 raise ExpectedError \
796 ("%s duration %s %g%% > %g%% from expected %s" %
797 (epid, format_duration(episode.duration),
798 abs(100*(episode.duration - explen)/explen), 100*expvar,
799 format_duration(explen)))
800 me._pl.add_episode(episode)
801 me._cur_episode = episode
802
803 def _process_chapter(me, ww):
804 check(me._cur_episode is not None, "no current episode")
805 check(me._cur_episode.source.CHAPTERP,
806 "episode source doesn't allow chapters")
807 if me._chaptersp:
808 if me._cur_chapter is None: i = 1
809 else: i = me._cur_chapter.i + 1
810 me._cur_chapter = me._cur_episode.add_chapter(ww.rest(), i)
811
812 def parse_file(me, fn):
813 with location(FileLocation(fn, 0)) as floc:
814 with open(fn, "r") as f:
815 for line in f:
816 floc.stepline()
817 sline = line.lstrip()
818 if sline == "" or sline.startswith(";"): continue
819
820 if line.startswith("!"): me._process_cmd(Words(line[1:]))
821 elif not line[0].isspace(): me._process_episode(Words(line))
822 else: me._process_chapter(Words(line))
823 me._pl.done_season()
824
825 def done(me):
826 discs = set()
827 for name, vdir in me._vdirs.items():
828 if not me._series[name].wantedp: continue
829 for s in vdir.seasons.values():
830 for d in s.episodes.values():
831 discs.add(d)
832 for adir in me._audirs.values():
833 for d in adir.episodes.values():
834 discs.add(d)
835 for d in sorted(discs, key = lambda d: d.fn):
836 if d.neps is not None and d.neps != d.nuses:
837 raise ExpectedError("disc `%s' has %d episodes, used %d times" %
838 (d.fn, d.neps, d.nuses))
839 return me._pl
840
841op = OP.OptionParser \
842 (usage = "%prog [-c] [-M DEPS] [-d CACHE] [-o OUT] [-s SERIES] EPLS\n"
843 "%prog -i -d CACHE",
844 description = "Generate M3U playlists from an episode list.")
845op.add_option("-M", "--make-deps", metavar = "DEPS",
846 dest = "deps", type = "str", default = None,
847 help = "Write a `make' fragment for dependencies")
848op.add_option("-c", "--chapters",
849 dest = "chaptersp", action = "store_true", default = False,
850 help = "Output individual chapter names")
851op.add_option("-i", "--init-db",
852 dest = "initdbp", action = "store_true", default = False,
853 help = "Initialize the database")
854op.add_option("-d", "--database", metavar = "CACHE",
855 dest = "database", type = "str", default = None,
856 help = "Set filename for cache database")
857op.add_option("-o", "--output", metavar = "OUT",
858 dest = "output", type = "str", default = None,
859 help = "Write output playlist to OUT")
860op.add_option("-O", "--fake-output", metavar = "OUT",
861 dest = "fakeout", type = "str", default = None,
862 help = "Pretend output goes to OUT for purposes of `-M'")
863op.add_option("-s", "--series", metavar = "SERIES",
864 dest = "series", type = "str", default = None,
865 help = "Output only the listed SERIES (comma-separated)")
866try:
867 opts, argv = op.parse_args()
868
869 if opts.initdbp:
870 if opts.chaptersp or opts.series is not None or \
871 opts.output is not None or opts.deps is not None or \
872 opts.fakeout is not None or \
873 opts.database is None or len(argv):
874 op.print_usage(file = SYS.stderr); SYS.exit(2)
875 setup_db(opts.database)
876
877 else:
878 if len(argv) != 1: op.print_usage(file = SYS.stderr); SYS.exit(2)
879 if opts.database is not None: init_db(opts.database)
880 if opts.series is None:
881 series_wanted = None
882 else:
883 series_wanted = set()
884 for name in opts.series.split(","): series_wanted.add(name)
885 if opts.deps is not None:
886 if (opts.output is None or opts.output == "-") and opts.fakeout is None:
887 raise ExpectedError("can't write dep fragment without output file")
888 if opts.fakeout is None: opts.fakeout = opts.output
889 else:
890 if opts.fakeout is not None:
891 raise ExpectedError("fake output set but no dep fragment")
892
893 ep = EpisodeListParser(series_wanted, opts.chaptersp)
894 ep.parse_file(argv[0])
895 pl = ep.done()
896
897 if opts.output is None or opts.output == "-":
898 pl.write(SYS.stdout)
899 else:
900 with open(opts.output, "w") as f: pl.write(f)
901
902 if opts.deps:
903 if opts.deps == "-":
904 pl.write_deps(SYS.stdout, opts.fakeout)
905 else:
906 with open(opts.deps, "w") as f: pl.write_deps(f, opts.fakeout)
907
908except (ExpectedError, IOError, OSError) as e:
909 LOC.report(e)
910 SYS.exit(2)