drwho.epls: Remove dummy episodes from incomplete extras data.
[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 def quote(s):
48 if s is None: return "-"
49 else: return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
50
51 class 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
74 def 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
80 URL_SAFE_P = 256*[False]
81 for ch in \
82 b"ABCDEFGHIJKLMNOPQRSTUVWXYZ" \
83 b"abcdefghijklmnopqrstuvwxyz" \
84 b"0123456789" b"!$%_-.,/":
85 URL_SAFE_P[ch] = True
86 def urlencode(s):
87 return "".join((URL_SAFE_P[ch] and chr(ch) or "%%%02x" % ch
88 for ch in s.encode("UTF-8")))
89
90 PROG = OS.path.basename(SYS.argv[0])
91
92 class BaseLocation (object):
93 def report(me, exc):
94 SYS.stderr.write("%s: %s%s\n" % (PROG, me._loc(), exc))
95
96 class DummyLocation (BaseLocation):
97 def _loc(me): return ""
98
99 class 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
104 LOC = DummyLocation()
105
106 ROOT = "/mnt/dvd/archive/"
107 DB = None
108
109 def init_db(fn):
110 global DB
111 DB = SQL.connect(fn)
112 DB.cursor().execute("PRAGMA journal_mode = WAL")
113
114 def 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
134 class 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
229 class DVDFile (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
257 class DVDSeason (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
267 def 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
275 class DVDDir (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 = DVDSeason(si, stitle)
329 else:
330 check(stitle == season.title,
331 "season title `%s' /= `%s'" % (stitle, season.title))
332
333 disc = DVDFile(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] = DVDSeason(i, None)
349 if ts is None:
350 ts = season = seasons[1] = DVDSeason(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
363 class SingleFileDir (object):
364
365 _CHECK_COMPLETE = True
366
367 def __init__(me, dir):
368 me.dir = dir
369 fns = OS.listdir(OS.path.join(ROOT, dir))
370 fns.sort()
371 episodes = {}
372 last_i = 0
373 rx = RX.compile(r"""
374 E (\d+)
375 (?: \. \ (.*))?
376 %s $
377 """ % RX.escape(me._EXT), RX.X)
378
379 for fn in fns:
380 path = OS.path.join(dir, fn)
381 if not fn.endswith(me._EXT): continue
382 m = rx.match(fn)
383 if not m: continue
384 i = filter(m.group(1), int)
385 etitle = m.group(2)
386 if me._CHECK_COMPLETE:
387 check(i == last_i + 1, "episode %d /= %d" % (i, last_i + 1))
388 episodes[i] = me._mkepisode(path, i)
389 last_i = i
390 me.episodes = episodes
391
392 class AudioFile (Source):
393 PREFIX = "file://"
394 TITLEP = CHAPTERP = False
395
396 def _duration(me, title, start_chapter, end_chaptwr):
397 out = program_output(["metaflac",
398 "--show-total-samples", "--show-sample-rate",
399 OS.path.join(ROOT, me.fn)])
400 nsamples, hz = map(float, out.split())
401 return int(nsamples/hz)
402
403 class AudioEpisode (AudioFile):
404 def __init__(me, fn, i, *args, **kw):
405 super().__init__(fn, *args, **kw)
406 me.i = i
407
408 class AudioDir (SingleFileDir):
409 _EXT = ".flac"
410
411 def _mkepisode(me, path, i):
412 return AudioEpisode(path, i)
413
414 class VideoFile (Source):
415 PREFIX = "file://"
416 TITLEP = CHAPTERP = False
417
418 def _duration(me, title, start_chapter, end_chaptwr):
419 out = program_output(["mediainfo", "--output=General;%Duration%",
420 OS.path.join(ROOT, me.fn)])
421 return int(out)//1000
422
423 class VideoEpisode (VideoFile):
424 def __init__(me, fn, i, *args, **kw):
425 super().__init__(fn, *args, **kw)
426 me.i = i
427
428 class VideoDir (SingleFileDir):
429 _EXT = ".mp4"
430 _CHECK_COMPLETE = False
431
432 def _mkepisode(me, path, i):
433 return VideoEpisode(path, i)
434
435 class Chapter (object):
436 def __init__(me, episode, title, i):
437 me.title, me.i = title, i
438 me.url, me.duration = \
439 episode.source.url_and_duration(episode.tno, i, i + 1)
440
441 class Episode (object):
442 def __init__(me, season, i, neps, title, src, series_title_p = True,
443 tno = -1, startch = -1, endch = -1):
444 me.season = season
445 me.i, me.neps, me.title = i, neps, title
446 me.chapters = []
447 me.source, me.tno = src, tno
448 me.series_title_p = series_title_p
449 me.tno, me.start_chapter, me.end_chapter = tno, startch, endch
450 me.url, me.duration = src.url_and_duration(tno, startch, endch)
451 def add_chapter(me, title, j):
452 ch = Chapter(me, title, j)
453 me.chapters.append(ch)
454 return ch
455 def label(me):
456 return me.season._eplabel(me.i, me.neps, me.title)
457
458 class BaseSeason (object):
459 def __init__(me, series, implicitp = False):
460 me.series = series
461 me.episodes = []
462 me.implicitp = implicitp
463 me.ep_i, episodes = 1, []
464 def add_episode(me, j, neps, title, src, series_title_p,
465 tno, startch, endch):
466 ep = Episode(me, j, neps, title, src, series_title_p,
467 tno, startch, endch)
468 me.episodes.append(ep)
469 src.nuses += neps; me.ep_i += neps
470 return ep
471 def _epnames(me, i, neps):
472 playlist = me.series.playlist
473 if neps == 1: return playlist.epname, ["%d" % i]
474 elif neps == 2: return playlist.epnames, ["%d" % i, "%d" % (i + 1)]
475 else: return playlist.epnames, ["%d–%d" % (i, i + neps - 1)]
476
477 class Season (BaseSeason):
478 def __init__(me, series, title, i, *args, **kw):
479 super().__init__(series, *args, **kw)
480 me.title, me.i = title, i
481 def _eplabel(me, i, neps, title):
482 epname, epn = me._epnames(i, neps)
483 if title is None:
484 if me.implicitp:
485 label = "%s %s" % (epname, ", ".join(epn))
486 elif me.title is None:
487 label = "%s %s" % \
488 (epname, ", ".join("%d.%s" % (me.i, e) for e in epn))
489 else:
490 label = "%s: %s %s" % (me.title, epname, ", ".join(epn))
491 else:
492 if me.implicitp:
493 label = "%s. %s" % (", ".join(epn), title)
494 elif me.title is None:
495 label = "%s. %s" % \
496 (", ".join("%d.%s" % (me.i, e) for e in epn), title)
497 else:
498 label = "%s: %s. %s" % (me.title, ", ".join(epn), title)
499 return label
500
501 class MovieSeason (BaseSeason):
502 def __init__(me, series, title, *args, **kw):
503 super().__init__(series, *args, **kw)
504 me.title = title
505 me.i = None
506 def add_episode(me, j, neps, title, src, series_title_p,
507 tno, startch, endch):
508 if me.title is None and title is None:
509 raise ExpectedError("movie or movie season must have a title")
510 return super().add_episode(j, neps, title, src, series_title_p,
511 tno, startch, endch)
512 def _eplabel(me, i, neps, title):
513 if me.title is None:
514 label = title
515 elif title is None:
516 epname, epn = me._epnames(i, neps)
517 label = "%s: %s %s" % (me.title, epname, ", ".join(epn))
518 else:
519 label = "%s: %s" % (me.title, title)
520 return label
521
522 class Series (object):
523 def __init__(me, playlist, name, title = None,
524 full_title = None, wantedp = True):
525 me.playlist = playlist
526 me.name, me.title, me.full_title = name, title, full_title
527 me.cur_season = None
528 me.wantedp = wantedp
529 def _add_season(me, season):
530 me.cur_season = season
531 def add_season(me, title, i, implicitp = False):
532 me._add_season(Season(me, title, i, implicitp))
533 def add_movies(me, title = None):
534 me._add_season(MovieSeason(me, title))
535 def ensure_season(me):
536 if me.cur_season is None: me.add_season(None, 1, implicitp = True)
537 return me.cur_season
538 def end_season(me):
539 me.cur_season = None
540
541 class Playlist (object):
542
543 def __init__(me):
544 me.seasons = []
545 me.episodes = []
546 me.epname, me.epnames = "Episode", "Episodes"
547 me.nseries = 0
548 me.single_series_p = False
549 me.series_title = None
550 me.series_sep = ""
551
552 def add_episode(me, episode):
553 me.episodes.append(episode)
554
555 def done_season(me):
556 if me.episodes:
557 me.seasons.append(me.episodes)
558 me.episodes = []
559
560 def write(me, f):
561 f.write("#EXTM3U\n")
562 for season in me.seasons:
563 f.write("\n")
564 for ep in season:
565 label = ep.label()
566 if me.nseries > 1 and ep.series_title_p and \
567 ep.season.series.title is not None:
568 if ep.season.i is None: sep = ":"
569 else: sep = me.series_sep
570 label = ep.season.series.title + sep + " " + label
571 if not ep.chapters:
572 f.write("#EXTINF:%d,,%s\n%s\n" % (ep.duration, label, ep.url))
573 else:
574 for ch in ep.chapters:
575 f.write("#EXTINF:%d,,%s: %s\n%s\n" %
576 (ch.duration, label, ch.title, ch.url))
577
578 def dump(me, f):
579 if opts.list_name is not None: f.write("LIST %s\n" % opts.list_name)
580 if me.series_title is not None and \
581 me.nseries > 1 and not me.single_series_p:
582 raise ExpectedError("can't force series name for multi-series list")
583 series = set()
584 if me.single_series_p:
585 f.write("SERIES - %s\n" % quote(me.series_title))
586 for season in me.seasons:
587 for ep in season:
588 label = ep.label()
589 title = ep.season.series.full_title
590 if me.single_series_p:
591 stag = "-"
592 if title is not None: label = title + me.series_sep + " " + label
593 else:
594 if title is None: title = me.series_title
595 stag = ep.season.series.name
596 if stag is None: stag = "-"
597 if stag not in series:
598 f.write("SERIES %s %s\n" % (stag, quote(title)))
599 series.add(stag)
600 f.write("ENTRY %s %s %s %d %d %d %g\n" %
601 (stag, quote(label), quote(ep.source.fn),
602 ep.tno, ep.start_chapter, ep.end_chapter, ep.duration))
603
604 def write_deps(me, f, out):
605 deps = set()
606 for season in me.seasons:
607 for ep in season: deps.add(ep.source.fn)
608 f.write("### -*-makefile-*-\n")
609 f.write("%s: $(call check-deps, %s," % (out, out))
610 for dep in sorted(deps):
611 f.write(" \\\n\t'%s'" %
612 OS.path.join(ROOT, dep)
613 .replace(",", "$(comma)")
614 .replace("'", "'\\''"))
615 f.write(")\n")
616
617 DEFAULT_EXPVAR = 0.05
618 R_DURMULT = RX.compile(r""" ^
619 (\d+ (?: \. \d+)?) x
620 $ """, RX.X)
621 R_DUR = RX.compile(r""" ^
622 (?: (?: (\d+) :)? (\d+) :)? (\d+)
623 (?: / (\d+ (?: \. \d+)?) \%)?
624 $ """, RX.X)
625 def parse_duration(s, base = None, basevar = DEFAULT_EXPVAR):
626 if base is not None:
627 m = R_DURMULT.match(s)
628 if m is not None: return base*float(m.group(1)), basevar
629 m = R_DUR.match(s)
630 if not m: raise ExpectedError("invalid duration spec `%s'" % s)
631 hr, min, sec = map(lambda g: filter(m.group(g), int, 0), [1, 2, 3])
632 var = filter(m.group(4), lambda x: float(x)/100.0)
633 if var is None: var = DEFAULT_EXPVAR
634 return 3600*hr + 60*min + sec, var
635 def format_duration(d):
636 if d >= 3600: return "%d:%02d:%02d" % (d//3600, (d//60)%60, d%60)
637 elif d >= 60: return "%d:%02d" % (d//60, d%60)
638 else: return "%d s" % d
639
640 MODE_UNSET = 0
641 MODE_SINGLE = 1
642 MODE_MULTI = 2
643
644 class EpisodeListParser (object):
645
646 def __init__(me, series_wanted = None, chapters_wanted_p = False):
647 me._pl = Playlist()
648 me._cur_episode = me._cur_chapter = None
649 me._series = {}; me._vdirs = {}; me._sfdirs = {}; me._isos = {}
650 me._series_wanted = series_wanted
651 me._chaptersp = chapters_wanted_p
652 me._explen, me._expvar = None, DEFAULT_EXPVAR
653 if series_wanted is None: me._mode = MODE_UNSET
654 else: me._mode = MODE_MULTI
655
656 def _bad_keyval(me, cmd, k, v):
657 raise ExpectedError("invalid `!%s' option `%s'" %
658 (cmd, v if k is None else k))
659
660 def _keyvals(me, opts):
661 if opts is not None:
662 for kv in opts.split(","):
663 try: sep = kv.index("=")
664 except ValueError: yield None, kv
665 else: yield kv[:sep], kv[sep + 1:]
666
667 def _set_mode(me, mode):
668 if me._mode == MODE_UNSET:
669 me._mode = mode
670 elif me._mode != mode:
671 raise ExpectedError("inconsistent single-/multi-series usage")
672
673 def _get_series(me, name):
674 if name is None:
675 me._set_mode(MODE_SINGLE)
676 try: series = me._series[None]
677 except KeyError:
678 series = me._series[None] = Series(me._pl, None)
679 me._pl.nseries += 1
680 else:
681 me._set_mode(MODE_MULTI)
682 series = lookup(me._series, name, "unknown series `%s'" % name)
683 return series
684
685 def _opts_series(me, cmd, opts):
686 name = None
687 for k, v in me._keyvals(opts):
688 if k is None: name = v
689 else: me._bad_keyval(cmd, k, v)
690 return me._get_series(name)
691
692 def _auto_epsrc(me, series):
693 dir = lookup(me._vdirs, series.name, "no active video directory")
694 season = series.ensure_season()
695 check(season.i is not None, "must use explicit iso for movie seasons")
696 vseason = lookup(dir.seasons, season.i,
697 "season %d not found in video dir `%s'" %
698 (season.i, dir.dir))
699 src = lookup(vseason.episodes, season.ep_i,
700 "episode %d.%d not found in video dir `%s'" %
701 (season.i, season.ep_i, dir.dir))
702 return src
703
704 def _process_cmd(me, ww):
705
706 cmd = ww.nextword(); check(cmd is not None, "missing command")
707 try: sep = cmd.index(":")
708 except ValueError: opts = None
709 else: cmd, opts = cmd[:sep], cmd[sep + 1:]
710
711 if cmd == "title":
712 for k, v in me._keyvals(opts): me._bad_keyval("title", k, v)
713 title = ww.rest(); check(title is not None, "missing title")
714 check(me._pl.series_title is None, "already set a title")
715 me._pl.series_title = title
716
717 elif cmd == "single":
718 for k, v in me._keyvals(opts): me._bad_keyval("single", k, v)
719 check(ww.rest() is None, "trailing junk")
720 check(not me._pl.single_series_p, "single-series already set")
721 me._pl.single_series_p = True
722
723 elif cmd == "series":
724 name = None
725 for k, v in me._keyvals(opts):
726 if k is None: name = v
727 else: me._bad_keyval(cmd, k, v)
728 check(name is not None, "missing series name")
729 check(name not in me._series, "series `%s' already defined" % name)
730 title = ww.rest()
731 if title is None:
732 full = None
733 else:
734 try: sep = title.index("::")
735 except ValueError: full = title
736 else:
737 full = title[sep + 2:].strip()
738 if sep == 0: title = None
739 else: title = title[:sep].strip()
740 me._set_mode(MODE_MULTI)
741 me._series[name] = series = Series(me._pl, name, title, full,
742 me._series_wanted is None or
743 name in me._series_wanted)
744 if series.wantedp: me._pl.nseries += 1
745
746 elif cmd == "season":
747 series = me._opts_series(cmd, opts)
748 w = ww.nextword();
749 check(w is not None, "missing season number")
750 if w == "-":
751 if not series.wantedp: return
752 series.add_movies(ww.rest())
753 else:
754 title = ww.rest(); i = getint(w)
755 if not series.wantedp: return
756 series.add_season(ww.rest(), getint(w), implicitp = False)
757 me._cur_episode = me._cur_chapter = None
758 me._pl.done_season()
759
760 elif cmd == "explen":
761 w = ww.rest(); check(w is not None, "missing duration spec")
762 if w == "-":
763 me._explen, me._expvar = None, DEFAULT_EXPVAR
764 else:
765 d, v = parse_duration(w)
766 me._explen = d
767 if v is not None: me._expvar = v
768
769 elif cmd == "epname":
770 for k, v in me._keyvals(opts): me._bad_keyval("epname", k, v)
771 name = ww.rest(); check(name is not None, "missing episode name")
772 try: sep = name.index("::")
773 except ValueError: names = name + "s"
774 else: name, names = name[:sep], name[sep + 2:]
775 me._pl.epname, me._pl.epnames = name, names
776
777 elif cmd == "epno":
778 series = me._opts_series(cmd, opts)
779 w = ww.rest(); check(w is not None, "missing episode number")
780 epi = getint(w)
781 if not series.wantedp: return
782 series.ensure_season().ep_i = epi
783
784 elif cmd == "dvd":
785 series = me._opts_series(cmd, opts)
786 fn = ww.rest(); check(fn is not None, "missing filename")
787 if not series.wantedp: return
788 if fn == "-": forget(me._isos, series.name)
789 else:
790 check(OS.path.exists(OS.path.join(ROOT, fn)),
791 "dvd iso file `%s' not found" % fn)
792 me._isos[series.name] = DVDFile(fn)
793
794 elif cmd == "dvddir":
795 series = me._opts_series(cmd, opts)
796 dir = ww.rest(); check(dir is not None, "missing directory")
797 if not series.wantedp: return
798 if dir == "-": forget(me._vdirs, series.name)
799 else: me._vdirs[series.name] = DVDDir(dir)
800
801 elif cmd == "vdir":
802 series = me._opts_series(cmd, opts)
803 dir = ww.rest(); check(dir is not None, "missing directory")
804 if not series.wantedp: return
805 if dir == "-": forget(me._sfdirs, series.name)
806 else: me._sfdirs[series.name] = VideoDir(dir)
807
808 elif cmd == "adir":
809 series = me._opts_series(cmd, opts)
810 dir = ww.rest(); check(dir is not None, "missing directory")
811 if not series.wantedp: return
812 if dir == "-": forget(me._sfdirs, series.name)
813 else: me._sfdirs[series.name] = AudioDir(dir)
814
815 elif cmd == "displaced":
816 series = me._opts_series(cmd, opts)
817 w = ww.rest(); check(w is not None, "missing count"); n = getint(w)
818 src = me._auto_epsrc(series)
819 src.nuses += n
820
821 elif cmd == "sep":
822 sep = ww.rest(); check(sep is not None, "missing separator")
823 me._pl.series_sep = sep
824
825 else:
826 raise ExpectedError("unknown command `%s'" % cmd)
827
828 def _process_episode(me, ww):
829
830 opts = ww.nextword(); check(opts is not None, "missing title/options")
831 ti = -1; sname = None; neps = 1; epi = None; loch = hich = -1
832 explen, expvar, explicitlen = me._explen, me._expvar, False
833 series_title_p = True
834 for k, v in me._keyvals(opts):
835 if k is None:
836 if v.isdigit(): ti = int(v)
837 elif v == "-": ti = -1
838 else: sname = v
839 elif k == "s": sname = v
840 elif k == "n": neps = getint(v)
841 elif k == "ep": epi = getint(v)
842 elif k == "st": series_title_p = getbool(v)
843 elif k == "l":
844 if v == "-": me._explen, me._expvar = None, DEFAULT_EXPVAR
845 else:
846 explen, expvar = parse_duration(v, explen, expvar)
847 explicitlen = True
848 elif k == "ch":
849 try: sep = v.index("-")
850 except ValueError: loch, hich = getint(v), -1
851 else: loch, hich = getint(v[:sep]), getint(v[sep + 1:]) + 1
852 else: raise ExpectedError("unknown episode option `%s'" % k)
853 check(ti is not None, "missing title number")
854 series = me._get_series(sname)
855 me._cur_chapter = None
856
857 title = ww.rest()
858 if not series.wantedp: return
859 season = series.ensure_season()
860 if epi is None: epi = season.ep_i
861
862 if ti == -1:
863 check(season.implicitp or season.i is None,
864 "audio source, but explicit non-movie season")
865 dir = lookup(me._sfdirs, series.name,
866 "no title, and no single-file directory")
867 src = lookup(dir.episodes, season.ep_i,
868 "episode %d not found in single-file dir `%s'" %
869 (epi, dir.dir))
870
871 else:
872 try: src = me._isos[series.name]
873 except KeyError: src = me._auto_epsrc(series)
874
875 episode = season.add_episode(epi, neps, title, src,
876 series_title_p, ti, loch, hich)
877
878 if episode.duration != -1 and explen is not None:
879 if not explicitlen: explen *= neps
880 if not explen*(1 - expvar) <= episode.duration <= explen*(1 + expvar):
881 if season.i is None: epid = "episode %d" % epi
882 else: epid = "episode %d.%d" % (season.i, epi)
883 raise ExpectedError \
884 ("%s duration %s %g%% > %g%% from expected %s" %
885 (epid, format_duration(episode.duration),
886 abs(100*(episode.duration - explen)/explen), 100*expvar,
887 format_duration(explen)))
888 me._pl.add_episode(episode)
889 me._cur_episode = episode
890
891 def _process_chapter(me, ww):
892 check(me._cur_episode is not None, "no current episode")
893 check(me._cur_episode.source.CHAPTERP,
894 "episode source doesn't allow chapters")
895 if me._chaptersp:
896 if me._cur_chapter is None: i = 1
897 else: i = me._cur_chapter.i + 1
898 me._cur_chapter = me._cur_episode.add_chapter(ww.rest(), i)
899
900 def parse_file(me, fn):
901 with location(FileLocation(fn, 0)) as floc:
902 with open(fn, "r") as f:
903 for line in f:
904 floc.stepline()
905 sline = line.lstrip()
906 if sline == "" or sline.startswith(";"): continue
907
908 if line.startswith("!"): me._process_cmd(Words(line[1:]))
909 elif not line[0].isspace(): me._process_episode(Words(line))
910 else: me._process_chapter(Words(line))
911 me._pl.done_season()
912
913 def done(me):
914 discs = set()
915 for name, vdir in me._vdirs.items():
916 if not me._series[name].wantedp: continue
917 for s in vdir.seasons.values():
918 for d in s.episodes.values():
919 discs.add(d)
920 for sfdir in me._sfdirs.values():
921 for d in sfdir.episodes.values():
922 discs.add(d)
923 for d in sorted(discs, key = lambda d: d.fn):
924 if d.neps is not None and d.neps != d.nuses:
925 raise ExpectedError("disc `%s' has %d episodes, used %d times" %
926 (d.fn, d.neps, d.nuses))
927 return me._pl
928
929 op = OP.OptionParser \
930 (usage = "%prog [-Dc] [-L NAME] [-M DEPS] [-d CACHE] [-o OUT] [-s SERIES] EPLS\n"
931 "%prog -i -d CACHE",
932 description = "Generate M3U playlists from an episode list.")
933 op.add_option("-D", "--dump",
934 dest = "dump", action = "store_true", default = False,
935 help = "Dump playlist in machine-readable form")
936 op.add_option("-L", "--list-name", metavar = "NAME",
937 dest = "list_name", type = "str", default = None,
938 help = "Set the playlist name")
939 op.add_option("-M", "--make-deps", metavar = "DEPS",
940 dest = "deps", type = "str", default = None,
941 help = "Write a `make' fragment for dependencies")
942 op.add_option("-c", "--chapters",
943 dest = "chaptersp", action = "store_true", default = False,
944 help = "Output individual chapter names")
945 op.add_option("-i", "--init-db",
946 dest = "initdbp", action = "store_true", default = False,
947 help = "Initialize the database")
948 op.add_option("-d", "--database", metavar = "CACHE",
949 dest = "database", type = "str", default = None,
950 help = "Set filename for cache database")
951 op.add_option("-o", "--output", metavar = "OUT",
952 dest = "output", type = "str", default = None,
953 help = "Write output playlist to OUT")
954 op.add_option("-O", "--fake-output", metavar = "OUT",
955 dest = "fakeout", type = "str", default = None,
956 help = "Pretend output goes to OUT for purposes of `-M'")
957 op.add_option("-s", "--series", metavar = "SERIES",
958 dest = "series", type = "str", default = None,
959 help = "Output only the listed SERIES (comma-separated)")
960 try:
961 opts, argv = op.parse_args()
962
963 if opts.initdbp:
964 if opts.chaptersp or opts.series is not None or \
965 opts.output is not None or opts.deps is not None or \
966 opts.fakeout is not None or \
967 opts.database is None or len(argv):
968 op.print_usage(file = SYS.stderr); SYS.exit(2)
969 setup_db(opts.database)
970
971 else:
972 if len(argv) != 1: op.print_usage(file = SYS.stderr); SYS.exit(2)
973 if opts.database is not None: init_db(opts.database)
974 if opts.series is None:
975 series_wanted = None
976 else:
977 series_wanted = set()
978 for name in opts.series.split(","): series_wanted.add(name)
979 if opts.deps is not None:
980 if (opts.output is None or opts.output == "-") and opts.fakeout is None:
981 raise ExpectedError("can't write dep fragment without output file")
982 if opts.fakeout is None: opts.fakeout = opts.output
983 else:
984 if opts.fakeout is not None:
985 raise ExpectedError("fake output set but no dep fragment")
986
987 ep = EpisodeListParser(series_wanted, opts.chaptersp)
988 ep.parse_file(argv[0])
989 pl = ep.done()
990
991 if opts.list_name is None:
992 opts.list_name, _ = OS.path.splitext(OS.path.basename(argv[0]))
993
994 if opts.dump: outfn = pl.dump
995 else: outfn = pl.write
996 if opts.output is None or opts.output == "-":
997 outfn(SYS.stdout)
998 else:
999 with open(opts.output, "w") as f: outfn(f)
1000
1001 if opts.deps:
1002 if opts.deps == "-":
1003 pl.write_deps(SYS.stdout, opts.fakeout)
1004 else:
1005 with open(opts.deps, "w") as f: pl.write_deps(f, opts.fakeout)
1006
1007 except (ExpectedError, IOError, OSError) as e:
1008 LOC.report(e)
1009 SYS.exit(2)