3eb16b13ca446613eed25793f21bdace4e53574b
[epls] / mkm3u
1 #! /usr/bin/python3
2 ### -*- mode: python; coding: utf-8 -*-
3
4 from contextlib import contextmanager
5 import errno as E
6 import optparse as OP
7 import os as OS
8 import re as RX
9 import sqlite3 as SQL
10 import subprocess as SP
11 import sys as SYS
12
13 class ExpectedError (Exception): pass
14
15 @contextmanager
16 def location(loc):
17 global LOC
18 old, LOC = LOC, loc
19 yield loc
20 LOC = old
21
22 def 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
27 def check(cond, msg):
28 if not cond: raise ExpectedError(msg)
29
30 def lookup(dict, key, msg):
31 try: return dict[key]
32 except KeyError: raise ExpectedError(msg)
33
34 def forget(dict, key):
35 try: del dict[key]
36 except KeyError: pass
37
38 def getint(s):
39 if not s.isdigit(): raise ExpectedError("bad integer `%s'" % s)
40 return int(s)
41
42 def getbool(s):
43 if s == "t": return True
44 elif s == "nil": return False
45 else: raise ExpectedError("bad boolean `%s'" % s)
46
47 class 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
70 def 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
76 URL_SAFE_P = 256*[False]
77 for ch in \
78 b"ABCDEFGHIJKLMNOPQRSTUVWXYZ" \
79 b"abcdefghijklmnopqrstuvwxyz" \
80 b"0123456789" b"!$%-.,/":
81 URL_SAFE_P[ch] = True
82 def urlencode(s):
83 return "".join((URL_SAFE_P[ch] and chr(ch) or "%%%02x" % ch
84 for ch in s.encode("UTF-8")))
85
86 PROG = OS.path.basename(SYS.argv[0])
87
88 class BaseLocation (object):
89 def report(me, exc):
90 SYS.stderr.write("%s: %s%s\n" % (PROG, me._loc(), exc))
91
92 class DummyLocation (BaseLocation):
93 def _loc(me): return ""
94
95 class 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
100 LOC = DummyLocation()
101
102 ROOT = "/mnt/dvd/archive/"
103 DB = None
104
105 def init_db(fn):
106 global DB
107 DB = SQL.connect(fn)
108 DB.cursor().execute("PRAGMA journal_mode = WAL")
109
110 def 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
130 class 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
225 class 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
253 class 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
263 def 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
271 class 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
359 class 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
370 class AudioEpisode (AudioDisc):
371 def __init__(me, fn, i, *args, **kw):
372 super().__init__(fn, *args, **kw)
373 me.i = i
374
375 class 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
401 class 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
407 class 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
423 class 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
442 class 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
466 class 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
487 class 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
506 class 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 me.single_series_p = False
514 me.series_title = None
515
516 def add_episode(me, episode):
517 me.episodes.append(episode)
518
519 def done_season(me):
520 if me.episodes:
521 me.seasons.append(me.episodes)
522 me.episodes = []
523
524 def write(me, f):
525 f.write("#EXTM3U\n")
526 for season in me.seasons:
527 f.write("\n")
528 for ep in season:
529 label = ep.label()
530 if me.nseries > 1 and ep.series_title_p and \
531 ep.season.series.title is not None:
532 if ep.season.i is None: sep = ": "
533 else: sep = " "
534 label = ep.season.series.title + sep + label
535 if not ep.chapters:
536 f.write("#EXTINF:%d,,%s\n%s\n" % (ep.duration, label, ep.url))
537 else:
538 for ch in ep.chapters:
539 f.write("#EXTINF:%d,,%s: %s\n%s\n" %
540 (ch.duration, label, ch.title, ch.url))
541
542 def write_deps(me, f, out):
543 deps = set()
544 for season in me.seasons:
545 for ep in season: deps.add(ep.source.fn)
546 f.write("### -*-makefile-*-\n")
547 f.write("%s: $(call check-deps, %s," % (out, out))
548 for dep in sorted(deps):
549 f.write(" \\\n\t'%s'" %
550 OS.path.join(ROOT, dep)
551 .replace(",", "$(comma)")
552 .replace("'", "'\\''"))
553 f.write(")\n")
554
555 DEFAULT_EXPVAR = 0.05
556 R_DURMULT = RX.compile(r""" ^
557 (\d+ (?: \. \d+)?) x
558 $ """, RX.X)
559 R_DUR = RX.compile(r""" ^
560 (?: (?: (\d+) :)? (\d+) :)? (\d+)
561 (?: / (\d+ (?: \. \d+)?) \%)?
562 $ """, RX.X)
563 def parse_duration(s, base = None, basevar = DEFAULT_EXPVAR):
564 if base is not None:
565 m = R_DURMULT.match(s)
566 if m is not None: return base*float(m.group(1)), basevar
567 m = R_DUR.match(s)
568 if not m: raise ExpectedError("invalid duration spec `%s'" % s)
569 hr, min, sec = map(lambda g: filter(m.group(g), int, 0), [1, 2, 3])
570 var = filter(m.group(4), lambda x: float(x)/100.0)
571 if var is None: var = DEFAULT_EXPVAR
572 return 3600*hr + 60*min + sec, var
573 def format_duration(d):
574 if d >= 3600: return "%d:%02d:%02d" % (d//3600, (d//60)%60, d%60)
575 elif d >= 60: return "%d:%02d" % (d//60, d%60)
576 else: return "%d s" % d
577
578 MODE_UNSET = 0
579 MODE_SINGLE = 1
580 MODE_MULTI = 2
581
582 class EpisodeListParser (object):
583
584 def __init__(me, series_wanted = None, chapters_wanted_p = False):
585 me._pl = Playlist()
586 me._cur_episode = me._cur_chapter = None
587 me._series = {}; me._vdirs = {}; me._audirs = {}; me._isos = {}
588 me._series_wanted = series_wanted
589 me._chaptersp = chapters_wanted_p
590 me._explen, me._expvar = None, DEFAULT_EXPVAR
591 if series_wanted is None: me._mode = MODE_UNSET
592 else: me._mode = MODE_MULTI
593
594 def _bad_keyval(me, cmd, k, v):
595 raise ExpectedError("invalid `!%s' option `%s'" %
596 (cmd, v if k is None else k))
597
598 def _keyvals(me, opts):
599 if opts is not None:
600 for kv in opts.split(","):
601 try: sep = kv.index("=")
602 except ValueError: yield None, kv
603 else: yield kv[:sep], kv[sep + 1:]
604
605 def _set_mode(me, mode):
606 if me._mode == MODE_UNSET:
607 me._mode = mode
608 elif me._mode != mode:
609 raise ExpectedError("inconsistent single-/multi-series usage")
610
611 def _get_series(me, name):
612 if name is None:
613 me._set_mode(MODE_SINGLE)
614 try: series = me._series[None]
615 except KeyError:
616 series = me._series[None] = Series(me._pl, None)
617 me._pl.nseries += 1
618 else:
619 me._set_mode(MODE_MULTI)
620 series = lookup(me._series, name, "unknown series `%s'" % name)
621 return series
622
623 def _opts_series(me, cmd, opts):
624 name = None
625 for k, v in me._keyvals(opts):
626 if k is None: name = v
627 else: me._bad_keyval(cmd, k, v)
628 return me._get_series(name)
629
630 def _auto_epsrc(me, series):
631 dir = lookup(me._vdirs, series.name, "no active video directory")
632 season = series.ensure_season()
633 check(season.i is not None, "must use explicit iso for movie seasons")
634 vseason = lookup(dir.seasons, season.i,
635 "season %d not found in video dir `%s'" %
636 (season.i, dir.dir))
637 src = lookup(vseason.episodes, season.ep_i,
638 "episode %d.%d not found in video dir `%s'" %
639 (season.i, season.ep_i, dir.dir))
640 return src
641
642 def _process_cmd(me, ww):
643
644 cmd = ww.nextword(); check(cmd is not None, "missing command")
645 try: sep = cmd.index(":")
646 except ValueError: opts = None
647 else: cmd, opts = cmd[:sep], cmd[sep + 1:]
648
649 if cmd == "title":
650 for k, v in me._keyvals(opts): me._bad_keyval("title", k, v)
651 title = ww.rest(); check(title is not None, "missing title")
652 check(me._pl.series_title is None, "already set a title")
653 me._pl.series_title = title
654
655 elif cmd == "single":
656 for k, v in me._keyvals(opts): me._bad_keyval("single", k, v)
657 check(ww.rest() is None, "trailing junk")
658 check(not me._pl.single_series_p, "single-series already set")
659 me._pl.single_series_p = True
660
661 elif cmd == "series":
662 name = None
663 for k, v in me._keyvals(opts):
664 if k is None: name = v
665 else: me._bad_keyval(cmd, k, v)
666 check(name is not None, "missing series name")
667 check(name not in me._series, "series `%s' already defined" % name)
668 title = ww.rest()
669 if title is None:
670 full = None
671 else:
672 try: sep = title.index("::")
673 except ValueError: full = title
674 else:
675 full = title[sep + 2:].strip()
676 if sep == 0: title = None
677 else: title = title[:sep].strip()
678 me._set_mode(MODE_MULTI)
679 me._series[name] = series = Series(me._pl, name, title, full,
680 me._series_wanted is None or
681 name in me._series_wanted)
682 if series.wantedp: me._pl.nseries += 1
683
684 elif cmd == "season":
685 series = me._opts_series(cmd, opts)
686 w = ww.nextword();
687 check(w is not None, "missing season number")
688 if w == "-":
689 if not series.wantedp: return
690 series.add_movies(ww.rest())
691 else:
692 title = ww.rest(); i = getint(w)
693 if not series.wantedp: return
694 series.add_season(ww.rest(), getint(w), implicitp = False)
695 me._cur_episode = me._cur_chapter = None
696 me._pl.done_season()
697
698 elif cmd == "explen":
699 w = ww.rest(); check(w is not None, "missing duration spec")
700 if w == "-":
701 me._explen, me._expvar = None, DEFAULT_EXPVAR
702 else:
703 d, v = parse_duration(w)
704 me._explen = d
705 if v is not None: me._expvar = v
706
707 elif cmd == "epname":
708 for k, v in me._keyvals(opts): me._bad_keyval("epname", k, v)
709 name = ww.rest(); check(name is not None, "missing episode name")
710 try: sep = name.index("::")
711 except ValueError: names = name + "s"
712 else: name, names = name[:sep], name[sep + 2:]
713 me._pl.epname, me._pl.epnames = name, names
714
715 elif cmd == "epno":
716 series = me._opts_series(cmd, opts)
717 w = ww.rest(); check(w is not None, "missing episode number")
718 epi = getint(w)
719 if not series.wantedp: return
720 series.ensure_season().ep_i = epi
721
722 elif cmd == "iso":
723 series = me._opts_series(cmd, opts)
724 fn = ww.rest(); check(fn is not None, "missing filename")
725 if not series.wantedp: return
726 if fn == "-": forget(me._isos, series.name)
727 else:
728 check(OS.path.exists(OS.path.join(ROOT, fn)),
729 "iso file `%s' not found" % fn)
730 me._isos[series.name] = VideoDisc(fn)
731
732 elif cmd == "vdir":
733 series = me._opts_series(cmd, opts)
734 dir = ww.rest(); check(dir is not None, "missing directory")
735 if not series.wantedp: return
736 if dir == "-": forget(me._vdirs, series.name)
737 else: me._vdirs[series.name] = VideoDir(dir)
738
739 elif cmd == "adir":
740 series = me._opts_series(cmd, opts)
741 dir = ww.rest(); check(dir is not None, "missing directory")
742 if not series.wantedp: return
743 if dir == "-": forget(me._audirs, series.name)
744 else: me._audirs[series.name] = AudioDir(dir)
745
746 elif cmd == "displaced":
747 series = me._opts_series(cmd, opts)
748 w = ww.rest(); check(w is not None, "missing count"); n = getint(w)
749 src = me._auto_epsrc(series)
750 src.nuses += n
751
752 else:
753 raise ExpectedError("unknown command `%s'" % cmd)
754
755 def _process_episode(me, ww):
756
757 opts = ww.nextword(); check(opts is not None, "missing title/options")
758 ti = -1; sname = None; neps = 1; epi = None; loch = hich = -1
759 explen, expvar, explicitlen = me._explen, me._expvar, False
760 series_title_p = True
761 for k, v in me._keyvals(opts):
762 if k is None:
763 if v.isdigit(): ti = int(v)
764 elif v == "-": ti = -1
765 else: sname = v
766 elif k == "s": sname = v
767 elif k == "n": neps = getint(v)
768 elif k == "ep": epi = getint(v)
769 elif k == "st": series_title_p = getbool(v)
770 elif k == "l":
771 if v == "-": me._explen, me._expvar = None, DEFAULT_EXPVAR
772 else:
773 explen, expvar = parse_duration(v, explen, expvar)
774 explicitlen = True
775 elif k == "ch":
776 try: sep = v.index("-")
777 except ValueError: loch, hich = getint(v), -1
778 else: loch, hich = getint(v[:sep]), getint(v[sep + 1:]) + 1
779 else: raise ExpectedError("unknown episode option `%s'" % k)
780 check(ti is not None, "missing title number")
781 series = me._get_series(sname)
782 me._cur_chapter = None
783
784 title = ww.rest()
785 if not series.wantedp: return
786 season = series.ensure_season()
787 if epi is None: epi = season.ep_i
788
789 if ti == -1:
790 check(season.implicitp or season.i is None,
791 "audio source, but explicit non-movie season")
792 dir = lookup(me._audirs, series.name,
793 "no title, and no audio directory")
794 src = lookup(dir.episodes, season.ep_i,
795 "episode %d not found in audio dir `%s'" % (epi, dir.dir))
796
797 else:
798 try: src = me._isos[series.name]
799 except KeyError: src = me._auto_epsrc(series)
800
801 episode = season.add_episode(epi, neps, title, src,
802 series_title_p, ti, loch, hich)
803
804 if episode.duration != -1 and explen is not None:
805 if not explicitlen: explen *= neps
806 if not explen*(1 - expvar) <= episode.duration <= explen*(1 + expvar):
807 if season.i is None: epid = "episode %d" % epi
808 else: epid = "episode %d.%d" % (season.i, epi)
809 raise ExpectedError \
810 ("%s duration %s %g%% > %g%% from expected %s" %
811 (epid, format_duration(episode.duration),
812 abs(100*(episode.duration - explen)/explen), 100*expvar,
813 format_duration(explen)))
814 me._pl.add_episode(episode)
815 me._cur_episode = episode
816
817 def _process_chapter(me, ww):
818 check(me._cur_episode is not None, "no current episode")
819 check(me._cur_episode.source.CHAPTERP,
820 "episode source doesn't allow chapters")
821 if me._chaptersp:
822 if me._cur_chapter is None: i = 1
823 else: i = me._cur_chapter.i + 1
824 me._cur_chapter = me._cur_episode.add_chapter(ww.rest(), i)
825
826 def parse_file(me, fn):
827 with location(FileLocation(fn, 0)) as floc:
828 with open(fn, "r") as f:
829 for line in f:
830 floc.stepline()
831 sline = line.lstrip()
832 if sline == "" or sline.startswith(";"): continue
833
834 if line.startswith("!"): me._process_cmd(Words(line[1:]))
835 elif not line[0].isspace(): me._process_episode(Words(line))
836 else: me._process_chapter(Words(line))
837 me._pl.done_season()
838
839 def done(me):
840 discs = set()
841 for name, vdir in me._vdirs.items():
842 if not me._series[name].wantedp: continue
843 for s in vdir.seasons.values():
844 for d in s.episodes.values():
845 discs.add(d)
846 for adir in me._audirs.values():
847 for d in adir.episodes.values():
848 discs.add(d)
849 for d in sorted(discs, key = lambda d: d.fn):
850 if d.neps is not None and d.neps != d.nuses:
851 raise ExpectedError("disc `%s' has %d episodes, used %d times" %
852 (d.fn, d.neps, d.nuses))
853 return me._pl
854
855 op = OP.OptionParser \
856 (usage = "%prog [-c] [-M DEPS] [-d CACHE] [-o OUT] [-s SERIES] EPLS\n"
857 "%prog -i -d CACHE",
858 description = "Generate M3U playlists from an episode list.")
859 op.add_option("-M", "--make-deps", metavar = "DEPS",
860 dest = "deps", type = "str", default = None,
861 help = "Write a `make' fragment for dependencies")
862 op.add_option("-c", "--chapters",
863 dest = "chaptersp", action = "store_true", default = False,
864 help = "Output individual chapter names")
865 op.add_option("-i", "--init-db",
866 dest = "initdbp", action = "store_true", default = False,
867 help = "Initialize the database")
868 op.add_option("-d", "--database", metavar = "CACHE",
869 dest = "database", type = "str", default = None,
870 help = "Set filename for cache database")
871 op.add_option("-o", "--output", metavar = "OUT",
872 dest = "output", type = "str", default = None,
873 help = "Write output playlist to OUT")
874 op.add_option("-O", "--fake-output", metavar = "OUT",
875 dest = "fakeout", type = "str", default = None,
876 help = "Pretend output goes to OUT for purposes of `-M'")
877 op.add_option("-s", "--series", metavar = "SERIES",
878 dest = "series", type = "str", default = None,
879 help = "Output only the listed SERIES (comma-separated)")
880 try:
881 opts, argv = op.parse_args()
882
883 if opts.initdbp:
884 if opts.chaptersp or opts.series is not None or \
885 opts.output is not None or opts.deps is not None or \
886 opts.fakeout is not None or \
887 opts.database is None or len(argv):
888 op.print_usage(file = SYS.stderr); SYS.exit(2)
889 setup_db(opts.database)
890
891 else:
892 if len(argv) != 1: op.print_usage(file = SYS.stderr); SYS.exit(2)
893 if opts.database is not None: init_db(opts.database)
894 if opts.series is None:
895 series_wanted = None
896 else:
897 series_wanted = set()
898 for name in opts.series.split(","): series_wanted.add(name)
899 if opts.deps is not None:
900 if (opts.output is None or opts.output == "-") and opts.fakeout is None:
901 raise ExpectedError("can't write dep fragment without output file")
902 if opts.fakeout is None: opts.fakeout = opts.output
903 else:
904 if opts.fakeout is not None:
905 raise ExpectedError("fake output set but no dep fragment")
906
907 ep = EpisodeListParser(series_wanted, opts.chaptersp)
908 ep.parse_file(argv[0])
909 pl = ep.done()
910
911 if opts.output is None or opts.output == "-":
912 pl.write(SYS.stdout)
913 else:
914 with open(opts.output, "w") as f: pl.write(f)
915
916 if opts.deps:
917 if opts.deps == "-":
918 pl.write_deps(SYS.stdout, opts.fakeout)
919 else:
920 with open(opts.deps, "w") as f: pl.write_deps(f, opts.fakeout)
921
922 except (ExpectedError, IOError, OSError) as e:
923 LOC.report(e)
924 SYS.exit(2)