mcu.epls: Add playlist for MCU films.
[epls] / mkm3u
... / ...
CommitLineData
1#! /usr/bin/python3
2### -*- mode: python; coding: utf-8 -*-
3
4from contextlib import contextmanager
5import errno as E
6import optparse as OP
7import os as OS
8import re as RX
9import sqlite3 as SQL
10import subprocess as SP
11import sys as SYS
12
13class ExpectedError (Exception): pass
14
15@contextmanager
16def location(loc):
17 global LOC
18 old, LOC = LOC, loc
19 yield loc
20 LOC = old
21
22def filter(value, func = None, dflt = None):
23 if value is None: return dflt
24 elif func is None: return value
25 else: return func(value)
26
27def check(cond, msg):
28 if not cond: raise ExpectedError(msg)
29
30def lookup(dict, key, msg):
31 try: return dict[key]
32 except KeyError: raise ExpectedError(msg)
33
34def forget(dict, key):
35 try: del dict[key]
36 except KeyError: pass
37
38def getint(s):
39 if not s.isdigit(): raise ExpectedError("bad integer `%s'" % s)
40 return int(s)
41
42def getbool(s):
43 if s == "t": return True
44 elif s == "nil": return False
45 else: raise ExpectedError("bad boolean `%s'" % s)
46
47def quote(s):
48 if s is None: return "-"
49 else: return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
50
51class Words (object):
52 def __init__(me, s):
53 me._s = s
54 me._i, me._n = 0, len(s)
55 def _wordstart(me):
56 s, i, n = me._s, me._i, me._n
57 while i < n:
58 if not s[i].isspace(): return i
59 i += 1
60 return -1
61 def nextword(me):
62 s, n = me._s, me._n
63 begin = i = me._wordstart()
64 if begin < 0: return None
65 while i < n and not s[i].isspace(): i += 1
66 me._i = i
67 return s[begin:i]
68 def rest(me):
69 s, n = me._s, me._n
70 begin = me._wordstart()
71 if begin < 0: return None
72 else: return s[begin:].rstrip()
73
74def program_output(*args, **kw):
75 try: return SP.check_output(*args, **kw)
76 except SP.CalledProcessError as e:
77 raise ExpectedError("program `%s' failed with code %d" %
78 (e.cmd, e.returncode))
79
80URL_SAFE_P = 256*[False]
81for ch in \
82 b"ABCDEFGHIJKLMNOPQRSTUVWXYZ" \
83 b"abcdefghijklmnopqrstuvwxyz" \
84 b"0123456789" b"!$%_-.,/":
85 URL_SAFE_P[ch] = True
86def urlencode(s):
87 return "".join((URL_SAFE_P[ch] and chr(ch) or "%%%02x" % ch
88 for ch in s.encode("UTF-8")))
89
90PROG = OS.path.basename(SYS.argv[0])
91
92class BaseLocation (object):
93 def report(me, exc):
94 SYS.stderr.write("%s: %s%s\n" % (PROG, me._loc(), exc))
95
96class DummyLocation (BaseLocation):
97 def _loc(me): return ""
98
99class FileLocation (BaseLocation):
100 def __init__(me, fn, lno = 1): me._fn, me._lno = fn, lno
101 def _loc(me): return "%s:%d: " % (me._fn, me._lno)
102 def stepline(me): me._lno += 1
103
104LOC = DummyLocation()
105
106ROOT = "/mnt/dvd/archive/"
107DB = None
108
109def init_db(fn):
110 global DB
111 DB = SQL.connect(fn)
112 DB.cursor().execute("PRAGMA journal_mode = WAL")
113
114def setup_db(fn):
115 try: OS.unlink(fn)
116 except OSError as e:
117 if e.errno == E.ENOENT: pass
118 else: raise
119 init_db(fn)
120 DB.cursor().execute("""
121 CREATE TABLE duration
122 (path TEXT NOT NULL,
123 title INTEGER NOT NULL,
124 start_chapter INTEGER NOT NULL,
125 end_chapter INTEGER NOT NULL,
126 inode INTEGER NOT NULL,
127 device INTEGER NOT NULL,
128 size INTEGER NOT NULL,
129 mtime REAL NOT NULL,
130 duration REAL NOT NULL,
131 PRIMARY KEY (path, title, start_chapter, end_chapter));
132 """)
133
134class Source (object):
135
136 PREFIX = ""
137 TITLEP = CHAPTERP = False
138
139 def __init__(me, fn, prefix = None):
140 me.fn = fn
141 me.neps = None
142 me.used_titles = set()
143 me.used_chapters = set()
144 me.nuses = 0
145 me._pfx = prefix
146
147 def _prefix(me):
148 if me._pfx is not None: return me._pfx
149 else: return me.PREFIX
150
151 def _duration(me, title, start_chapter, end_chapter):
152 return -1
153
154 def url_and_duration(me, title = -1, start_chapter = -1, end_chapter = -1):
155 if title == -1:
156 if me.TITLEP: raise ExpectedError("missing title number")
157 if start_chapter != -1 or end_chapter != -1:
158 raise ExpectedError("can't specify chapter without title")
159 suffix = ""
160 elif not me.TITLEP:
161 raise ExpectedError("can't specify title with `%s'" % me.fn)
162 elif start_chapter == -1:
163 if end_chapter != -1:
164 raise ExpectedError("can't specify end chapter without start chapter")
165 suffix = "#%d" % title
166 elif not me.CHAPTERP:
167 raise ExpectedError("can't specify chapter with `%s'" % me.fn)
168 elif end_chapter == -1:
169 suffix = "#%d:%d" % (title, start_chapter)
170 else:
171 suffix = "#%d:%d-%d:%d" % (title, start_chapter, title, end_chapter - 1)
172
173 duration = None
174 if DB is None:
175 duration = me._duration(title, start_chapter, end_chapter)
176 else:
177 st = OS.stat(OS.path.join(ROOT, me.fn))
178 duration = None
179 c = DB.cursor()
180 c.execute("""
181 SELECT device, inode, size, mtime, duration FROM duration
182 WHERE path = ? AND title = ? AND
183 start_chapter = ? AND end_chapter = ?
184 """, [me.fn, title, start_chapter, end_chapter])
185 row = c.fetchone()
186 foundp = False
187 if row is None:
188 duration = me._duration(title, start_chapter, end_chapter)
189 c.execute("""
190 INSERT OR REPLACE INTO duration
191 (path, title, start_chapter, end_chapter,
192 device, inode, size, mtime, duration)
193 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
194 """, [me.fn, title, start_chapter, end_chapter,
195 st.st_dev, st.st_ino, st.st_size, st.st_mtime,
196 duration])
197 else:
198 dev, ino, sz, mt, d = row
199 if (dev, ino, sz, mt) == \
200 (st.st_dev, st.st_ino, st.st_size, st.st_mtime):
201 duration = d
202 else:
203 duration = me._duration(title, start_chapter, end_chapter)
204 c.execute("""
205 UPDATE duration
206 SET device = ?, inode = ?, size = ?, mtime = ?, duration = ?
207 WHERE path = ? AND title = ? AND
208 start_chapter = ? AND end_chapter = ?
209 """, [st.st_dev, st.st_dev, st.st_size, st.st_mtime, duration,
210 me.fn, title, start_chapter, end_chapter])
211 DB.commit()
212
213 if end_chapter != -1:
214 keys = [(title, ch) for ch in range(start_chapter, end_chapter)]
215 set = me.used_chapters
216 else:
217 keys, set = [title], me.used_titles
218 for k in keys:
219 if k in set:
220 if title == -1:
221 raise ExpectedError("`%s' already used" % me.fn)
222 elif end_chapter == -1:
223 raise ExpectedError("`%s' title %d already used" % (me.fn, title))
224 else:
225 raise ExpectedError("`%s' title %d chapter %d already used" %
226 (me.fn, title, k[1]))
227 if end_chapter == -1:
228 me.used_titles.add(title)
229 else:
230 for ch in range(start_chapter, end_chapter):
231 me.used_chapters.add((title, ch))
232 return me._prefix() + ROOT + urlencode(me.fn) + suffix, duration
233
234class DVDFile (Source):
235 PREFIX = "dvd://"
236 TITLEP = CHAPTERP = True
237
238 def __init__(me, fn, menuhack = False, *args, **kw):
239 pfx = None
240 if menuhack: pfx = "dvdsimple://"
241 super().__init__(fn, prefix = pfx, *args, **kw)
242 me.neps = 0
243
244 def _duration(me, title, start_chapter, end_chapter):
245 path = OS.path.join(ROOT, me.fn)
246 ntitle = int(program_output(["dvd-info", path, "titles"]))
247 if not 1 <= title <= ntitle:
248 raise ExpectedError("bad title %d for `%s': must be in 1 .. %d" %
249 (title, me.fn, ntitle))
250 if start_chapter == -1:
251 durq = "duration:%d" % title
252 else:
253 nch = int(program_output(["dvd-info", path, "chapters:%d" % title]))
254 if end_chapter == -1: end_chapter = nch
255 else: end_chapter -= 1
256 if not 1 <= start_chapter <= end_chapter <= nch:
257 raise ExpectedError("bad chapter range %d .. %d for `%s' title %d: "
258 "must be in 1 .. %d" %
259 (start_chapter, end_chapter, me.fn, title, nch))
260 durq = "duration:%d.%d-%d" % (title, start_chapter, end_chapter)
261 duration = int(program_output(["dvd-info", path, durq]))
262 return duration
263
264class DVDSeason (object):
265 def __init__(me, i, title):
266 me.i = i
267 me.title = title
268 me.episodes = {}
269 def set_episode_disc(me, i, disc):
270 if i in me.episodes:
271 raise ExpectedError("season %d episode %d already taken" % (me.i, i))
272 me.episodes[i] = disc; disc.neps += 1
273
274def match_group(m, *groups, dflt = None, mustp = False):
275 for g in groups:
276 try: s = m.group(g)
277 except IndexError: continue
278 if s is not None: return s
279 if mustp: raise ValueError("no match found")
280 else: return dflt
281
282class DVDDir (object):
283
284 _R_ISO_PRE = list(map(lambda pats:
285 list(map(lambda pat:
286 RX.compile("^" + pat + r"\.iso$", RX.X),
287 pats)),
288 [[r""" S (?P<si> \d+) \. \ (?P<stitle> .*) — (?: D (?P<di> \d+) \. \ )?
289 (?P<epex> .*) """,
290 r""" S (?P<si> \d+) (?: D (?P<di> \d+))? \. \ (?P<epex> .*) """,
291 r""" S (?P<si> \d+) \. \ (?P<epex> E \d+ .*) """,
292 r""" S (?P<si> \d+) E (?P<ei> \d+) \. \ .* """],
293 [r""" (?P<si> \d+) [A-Z]? \. \ (?P<stitle> .*) — (?P<epex> .*) """],
294 [r""" (?P<di> \d+) \. \ (?P<epex> [ES] \d+ .*) """],
295 [r""" (?P<ei> \d+ ) \. \ .* """]]))
296
297 _R_ISO_EP = RX.compile(r""" ^
298 (?: S (?P<si> \d+) \ )?
299 E (?P<ei> \d+) (?: – (?P<ej> \d+))? $
300 """, RX.X)
301
302 def __init__(me, dir, hacks):
303 me.dir = dir
304 fns = OS.listdir(OS.path.join(ROOT, dir))
305 fns.sort()
306 season = None
307 seasons = {}
308 styles = me._R_ISO_PRE
309 for fn in fns:
310 path = OS.path.join(dir, fn)
311 if not fn.endswith(".iso"): continue
312 #print(";; `%s'" % path, file = SYS.stderr)
313 for sty in styles:
314 for r in sty:
315 m = r.match(fn)
316 if m: styles = [sty]; break
317 else:
318 continue
319 break
320 else:
321 #print(";;\tignored (regex mismatch)", file = SYS.stderr)
322 continue
323
324 si = filter(match_group(m, "si"), int)
325 stitle = match_group(m, "stitle")
326
327 check(si is not None or stitle is None,
328 "explicit season title without number in `%s'" % fn)
329 if si is not None:
330 if season is None or si != season.i:
331 check(season is None or si == season.i + 1,
332 "season %d /= %d" %
333 (si, season is None and -1 or season.i + 1))
334 check(si not in seasons, "season %d already seen" % si)
335 seasons[si] = season = DVDSeason(si, stitle)
336 else:
337 check(stitle == season.title,
338 "season title `%s' /= `%s'" % (stitle, season.title))
339
340 ts = season
341 any, bad = False, False
342 ei = match_group(m, "ei")
343 if ei is not None: eplist = ["E" + ei]
344 else: eplist = match_group(m, "epex", mustp = True).split(", ")
345
346 di = filter(match_group(m, "di"), int)
347 discid = ""
348 if si is not None: discid += "S%02d" % si
349 if di is not None: discid += "D%02d" % di
350 elif ei is not None: discid += "E%02d" % int(ei)
351 menuhack = False
352 for h in hacks.get(discid, []):
353 if h == "menu": menuhack = True
354 else: raise ExpectedError("unknown hack `%s'" % h)
355 disc = DVDFile(path, menuhack)
356
357 for eprange in eplist:
358 mm = me._R_ISO_EP.match(eprange)
359 if mm is None:
360 #print(";;\t`%s'?" % eprange, file = SYS.stderr)
361 bad = True; continue
362 if not any: any = True
363 i = filter(mm.group("si"), int)
364 if i is not None:
365 try: ts = seasons[i]
366 except KeyError: ts = seasons[i] = DVDSeason(i, None)
367 if ts is None:
368 ts = season = seasons[1] = DVDSeason(1, None)
369 start = filter(mm.group("ei"), int)
370 end = filter(mm.group("ej"), int, start)
371 for k in range(start, end + 1):
372 ts.set_episode_disc(k, disc)
373 #print(";;\tepisode %d.%d" % (ts.i, k), file = SYS.stderr)
374 if not any:
375 #print(";;\tignored", file = SYS.stderr)
376 pass
377 elif bad:
378 raise ExpectedError("bad ep list in `%s'", fn)
379 me.seasons = seasons
380
381class SingleFileDir (object):
382
383 _CHECK_COMPLETE = True
384
385 def __init__(me, dir):
386 me.dir = dir
387 fns = OS.listdir(OS.path.join(ROOT, dir))
388 fns.sort()
389 episodes = {}
390 last_i = 0
391 rx = RX.compile(r"""
392 E (\d+)
393 (?: \. \ (.*))?
394 %s $
395 """ % RX.escape(me._EXT), RX.X)
396
397 for fn in fns:
398 path = OS.path.join(dir, fn)
399 if not fn.endswith(me._EXT): continue
400 m = rx.match(fn)
401 if not m: continue
402 i = filter(m.group(1), int)
403 etitle = m.group(2)
404 if me._CHECK_COMPLETE:
405 check(i == last_i + 1, "episode %d /= %d" % (i, last_i + 1))
406 episodes[i] = me._mkepisode(path, i)
407 last_i = i
408 me.episodes = episodes
409
410class AudioFile (Source):
411 PREFIX = "file://"
412 TITLEP = CHAPTERP = False
413
414 def _duration(me, title, start_chapter, end_chaptwr):
415 out = program_output(["metaflac",
416 "--show-total-samples", "--show-sample-rate",
417 OS.path.join(ROOT, me.fn)])
418 nsamples, hz = map(float, out.split())
419 return int(nsamples/hz)
420
421class AudioEpisode (AudioFile):
422 def __init__(me, fn, i, *args, **kw):
423 super().__init__(fn, *args, **kw)
424 me.i = i
425
426class AudioDir (SingleFileDir):
427 _EXT = ".flac"
428
429 def _mkepisode(me, path, i):
430 return AudioEpisode(path, i)
431
432class VideoFile (Source):
433 PREFIX = "file://"
434 TITLEP = CHAPTERP = False
435
436 def _duration(me, title, start_chapter, end_chaptwr):
437 out = program_output(["mediainfo", "--output=General;%Duration%",
438 OS.path.join(ROOT, me.fn)])
439 return int(out)//1000
440
441class VideoEpisode (VideoFile):
442 def __init__(me, fn, i, *args, **kw):
443 super().__init__(fn, *args, **kw)
444 me.i = i
445
446class VideoDir (SingleFileDir):
447 _EXT = ".mp4"
448 _CHECK_COMPLETE = False
449
450 def _mkepisode(me, path, i):
451 return VideoEpisode(path, i)
452
453class Chapter (object):
454 def __init__(me, episode, title, i):
455 me.title, me.i = title, i
456 me.url, me.duration = \
457 episode.source.url_and_duration(episode.tno, i, i + 1)
458
459class Episode (object):
460 def __init__(me, season, i, neps, title, src, series_title_p = True,
461 tno = -1, startch = -1, endch = -1):
462 me.season = season
463 me.i, me.neps, me.title = i, neps, title
464 me.chapters = []
465 me.source, me.tno = src, tno
466 me.series_title_p = series_title_p
467 me.tno, me.start_chapter, me.end_chapter = tno, startch, endch
468 me.url, me.duration = src.url_and_duration(tno, startch, endch)
469 def add_chapter(me, title, j):
470 ch = Chapter(me, title, j)
471 me.chapters.append(ch)
472 return ch
473 def label(me):
474 return me.season._eplabel(me.i, me.neps, me.title)
475
476class BaseSeason (object):
477 def __init__(me, series, implicitp = False):
478 me.series = series
479 me.episodes = []
480 me.implicitp = implicitp
481 me.ep_i, episodes = 1, []
482 def add_episode(me, j, neps, title, src, series_title_p,
483 tno, startch, endch):
484 ep = Episode(me, j, neps, title, src, series_title_p,
485 tno, startch, endch)
486 me.episodes.append(ep)
487 src.nuses += neps; me.ep_i += neps
488 return ep
489 def _epnames(me, i, neps):
490 playlist = me.series.playlist
491 if neps == 1: return playlist.epname, ["%d" % i]
492 elif neps == 2: return playlist.epnames, ["%d" % i, "%d" % (i + 1)]
493 else: return playlist.epnames, ["%d–%d" % (i, i + neps - 1)]
494
495class Season (BaseSeason):
496 def __init__(me, series, title, i, *args, **kw):
497 super().__init__(series, *args, **kw)
498 me.title, me.i = title, i
499 def _eplabel(me, i, neps, title):
500 epname, epn = me._epnames(i, neps)
501 if title is None:
502 if me.implicitp:
503 label = "%s %s" % (epname, ", ".join(epn))
504 elif me.title is None:
505 label = "%s %s" % \
506 (epname, ", ".join("%d.%s" % (me.i, e) for e in epn))
507 else:
508 label = "%s: %s %s" % (me.title, epname, ", ".join(epn))
509 else:
510 if me.implicitp:
511 label = "%s. %s" % (", ".join(epn), title)
512 elif me.title is None:
513 label = "%s. %s" % \
514 (", ".join("%d.%s" % (me.i, e) for e in epn), title)
515 else:
516 label = "%s: %s. %s" % (me.title, ", ".join(epn), title)
517 return label
518
519class MovieSeason (BaseSeason):
520 def __init__(me, series, title, *args, **kw):
521 super().__init__(series, *args, **kw)
522 me.title = title
523 me.i = None
524 def add_episode(me, j, neps, title, src, series_title_p,
525 tno, startch, endch):
526 if me.title is None and title is None:
527 raise ExpectedError("movie or movie season must have a title")
528 return super().add_episode(j, neps, title, src, series_title_p,
529 tno, startch, endch)
530 def _eplabel(me, i, neps, title):
531 if me.title is None:
532 label = title
533 elif title is None:
534 epname, epn = me._epnames(i, neps)
535 label = "%s: %s %s" % (me.title, epname, ", ".join(epn))
536 else:
537 label = "%s: %s" % (me.title, title)
538 return label
539
540class Series (object):
541 def __init__(me, playlist, name, title = None,
542 full_title = None, wantedp = True):
543 me.playlist = playlist
544 me.name, me.title, me.full_title = name, title, full_title
545 me.cur_season = None
546 me.wantedp = wantedp
547 def _add_season(me, season):
548 me.cur_season = season
549 def add_season(me, title, i, implicitp = False):
550 me._add_season(Season(me, title, i, implicitp))
551 def add_movies(me, title = None):
552 me._add_season(MovieSeason(me, title))
553 def ensure_season(me):
554 if me.cur_season is None: me.add_season(None, 1, implicitp = True)
555 return me.cur_season
556 def end_season(me):
557 me.cur_season = None
558
559class Playlist (object):
560
561 def __init__(me):
562 me.seasons = []
563 me.episodes = []
564 me.epname, me.epnames = "Episode", "Episodes"
565 me.nseries = 0
566 me.single_series_p = False
567 me.series_title = None
568 me.series_sep = ""
569
570 def add_episode(me, episode):
571 me.episodes.append(episode)
572
573 def done_season(me):
574 if me.episodes:
575 me.seasons.append(me.episodes)
576 me.episodes = []
577
578 def write(me, f):
579 f.write("#EXTM3U\n")
580 for season in me.seasons:
581 f.write("\n")
582 for ep in season:
583 label = ep.label()
584 if me.nseries > 1 and ep.series_title_p and \
585 ep.season.series.title is not None:
586 if ep.season.i is None: sep = ":"
587 else: sep = me.series_sep
588 label = ep.season.series.title + sep + " " + label
589 if not ep.chapters:
590 f.write("#EXTINF:%d,,%s\n%s\n" % (ep.duration, label, ep.url))
591 else:
592 for ch in ep.chapters:
593 f.write("#EXTINF:%d,,%s: %s\n%s\n" %
594 (ch.duration, label, ch.title, ch.url))
595
596 def dump(me, f):
597 if opts.list_name is not None: f.write("LIST %s\n" % opts.list_name)
598 if me.series_title is not None and \
599 me.nseries > 1 and not me.single_series_p:
600 raise ExpectedError("can't force series name for multi-series list")
601 series = set()
602 if me.single_series_p:
603 f.write("SERIES - %s\n" % quote(me.series_title))
604 for season in me.seasons:
605 for ep in season:
606 label = ep.label()
607 title = ep.season.series.full_title
608 if me.single_series_p:
609 stag = "-"
610 if title is not None: label = title + me.series_sep + " " + label
611 else:
612 if title is None: title = me.series_title
613 stag = ep.season.series.name
614 if stag is None: stag = "-"
615 if stag not in series:
616 f.write("SERIES %s %s\n" % (stag, quote(title)))
617 series.add(stag)
618 f.write("ENTRY %s %s %s %d %d %d %g\n" %
619 (stag, quote(label), quote(ep.source.fn),
620 ep.tno, ep.start_chapter, ep.end_chapter, ep.duration))
621
622 def write_deps(me, f, out):
623 deps = set()
624 for season in me.seasons:
625 for ep in season: deps.add(ep.source.fn)
626 f.write("### -*-makefile-*-\n")
627 f.write("%s: $(call check-deps, %s," % (out, out))
628 for dep in sorted(deps):
629 f.write(" \\\n\t'%s'" %
630 OS.path.join(ROOT, dep)
631 .replace(",", "$(comma)")
632 .replace("'", "'\\''"))
633 f.write(")\n")
634
635DEFAULT_EXPVAR = 0.05
636R_DURMULT = RX.compile(r""" ^
637 (\d+ (?: \. \d+)?) x
638$ """, RX.X)
639R_DUR = RX.compile(r""" ^
640 (?: (?: (\d+) :)? (\d+) :)? (\d+)
641 (?: / (\d+ (?: \. \d+)?) \%)?
642$ """, RX.X)
643def parse_duration(s, base = None, basevar = DEFAULT_EXPVAR):
644 if base is not None:
645 m = R_DURMULT.match(s)
646 if m is not None: return base*float(m.group(1)), basevar
647 m = R_DUR.match(s)
648 if not m: raise ExpectedError("invalid duration spec `%s'" % s)
649 hr, min, sec = map(lambda g: filter(m.group(g), int, 0), [1, 2, 3])
650 var = filter(m.group(4), lambda x: float(x)/100.0)
651 if var is None: var = DEFAULT_EXPVAR
652 return 3600*hr + 60*min + sec, var
653def format_duration(d):
654 if d >= 3600: return "%d:%02d:%02d" % (d//3600, (d//60)%60, d%60)
655 elif d >= 60: return "%d:%02d" % (d//60, d%60)
656 else: return "%d s" % d
657
658MODE_UNSET = 0
659MODE_SINGLE = 1
660MODE_MULTI = 2
661
662class EpisodeListParser (object):
663
664 def __init__(me, series_wanted = None, chapters_wanted_p = False):
665 me._pl = Playlist()
666 me._cur_episode = me._cur_chapter = None
667 me._series = {}; me._vdirs = {}; me._sfdirs = {}; me._isos = {}
668 me._series_wanted = series_wanted
669 me._chaptersp = chapters_wanted_p
670 me._explen, me._expvar = None, DEFAULT_EXPVAR
671 me._hacks = {}
672 if series_wanted is None: me._mode = MODE_UNSET
673 else: me._mode = MODE_MULTI
674
675 def _bad_keyval(me, cmd, k, v):
676 raise ExpectedError("invalid `!%s' option `%s'" %
677 (cmd, v if k is None else k))
678
679 def _keyvals(me, opts):
680 if opts is not None:
681 for kv in opts.split(","):
682 try: sep = kv.index("=")
683 except ValueError: yield None, kv
684 else: yield kv[:sep], kv[sep + 1:]
685
686 def _set_mode(me, mode):
687 if me._mode == MODE_UNSET:
688 me._mode = mode
689 elif me._mode != mode:
690 raise ExpectedError("inconsistent single-/multi-series usage")
691
692 def _get_series(me, name):
693 if name is None:
694 me._set_mode(MODE_SINGLE)
695 try: series = me._series[None]
696 except KeyError:
697 series = me._series[None] = Series(me._pl, None)
698 me._pl.nseries += 1
699 else:
700 me._set_mode(MODE_MULTI)
701 series = lookup(me._series, name, "unknown series `%s'" % name)
702 return series
703
704 def _opts_series(me, cmd, opts):
705 name = None
706 for k, v in me._keyvals(opts):
707 if k is None: name = v
708 else: me._bad_keyval(cmd, k, v)
709 return me._get_series(name)
710
711 def _auto_epsrc(me, series):
712 dir = lookup(me._vdirs, series.name, "no active video directory")
713 season = series.ensure_season()
714 check(season.i is not None, "must use explicit iso for movie seasons")
715 vseason = lookup(dir.seasons, season.i,
716 "season %d not found in video dir `%s'" %
717 (season.i, dir.dir))
718 src = lookup(vseason.episodes, season.ep_i,
719 "episode %d.%d not found in video dir `%s'" %
720 (season.i, season.ep_i, dir.dir))
721 return src
722
723 def _process_cmd(me, ww):
724
725 cmd = ww.nextword(); check(cmd is not None, "missing command")
726 try: sep = cmd.index(":")
727 except ValueError: opts = None
728 else: cmd, opts = cmd[:sep], cmd[sep + 1:]
729
730 if cmd == "title":
731 for k, v in me._keyvals(opts): me._bad_keyval("title", k, v)
732 title = ww.rest(); check(title is not None, "missing title")
733 check(me._pl.series_title is None, "already set a title")
734 me._pl.series_title = title
735
736 elif cmd == "single":
737 for k, v in me._keyvals(opts): me._bad_keyval("single", k, v)
738 check(ww.rest() is None, "trailing junk")
739 check(not me._pl.single_series_p, "single-series already set")
740 me._pl.single_series_p = True
741
742 elif cmd == "series":
743 name = None
744 for k, v in me._keyvals(opts):
745 if k is None: name = v
746 else: me._bad_keyval(cmd, k, v)
747 check(name is not None, "missing series name")
748 check(name not in me._series, "series `%s' already defined" % name)
749 title = ww.rest()
750 if title is None:
751 full = None
752 else:
753 try: sep = title.index("::")
754 except ValueError: full = title
755 else:
756 full = title[sep + 2:].strip()
757 if sep == 0: title = None
758 else: title = title[:sep].strip()
759 me._set_mode(MODE_MULTI)
760 me._series[name] = series = Series(me._pl, name, title, full,
761 me._series_wanted is None or
762 name in me._series_wanted)
763 if series.wantedp: me._pl.nseries += 1
764
765 elif cmd == "season":
766 series = me._opts_series(cmd, opts)
767 w = ww.nextword(); check(w is not None, "missing season number")
768 if w == "-":
769 if not series.wantedp: return
770 series.add_movies(ww.rest())
771 else:
772 title = ww.rest(); i = getint(w)
773 if not series.wantedp: return
774 series.add_season(ww.rest(), getint(w), implicitp = False)
775 me._cur_episode = me._cur_chapter = None
776 me._pl.done_season()
777
778 elif cmd == "explen":
779 w = ww.rest(); check(w is not None, "missing duration spec")
780 if w == "-":
781 me._explen, me._expvar = None, DEFAULT_EXPVAR
782 else:
783 d, v = parse_duration(w)
784 me._explen = d
785 if v is not None: me._expvar = v
786
787 elif cmd == "epname":
788 for k, v in me._keyvals(opts): me._bad_keyval("epname", k, v)
789 name = ww.rest(); check(name is not None, "missing episode name")
790 try: sep = name.index("::")
791 except ValueError: names = name + "s"
792 else: name, names = name[:sep], name[sep + 2:]
793 me._pl.epname, me._pl.epnames = name, names
794
795 elif cmd == "epno":
796 series = me._opts_series(cmd, opts)
797 w = ww.rest(); check(w is not None, "missing episode number")
798 epi = getint(w)
799 if not series.wantedp: return
800 series.ensure_season().ep_i = epi
801
802 elif cmd == "hacks":
803 series = me._opts_series(cmd, opts)
804 discid = ww.nextword(); check(discid is not None, "missing disc id")
805 hh = me._hacks.setdefault(series.name, {}).setdefault(discid, [])
806 while True:
807 h = ww.nextword()
808 if h is None: break
809 hh.append(h)
810
811 elif cmd == "dvd" or cmd == "dvdsimple":
812 series = me._opts_series(cmd, opts)
813 fn = ww.rest(); check(fn is not None, "missing filename")
814 menuhack = cmd == "dvdsimple"
815 if not series.wantedp: return
816 if fn == "-": forget(me._isos, series.name)
817 else:
818 check(OS.path.exists(OS.path.join(ROOT, fn)),
819 "dvd iso file `%s' not found" % fn)
820 me._isos[series.name] = DVDFile(fn, menuhack)
821
822 elif cmd == "dvddir":
823 series = me._opts_series(cmd, opts)
824 dir = ww.rest(); check(dir is not None, "missing directory")
825 if not series.wantedp: return
826 if dir == "-": forget(me._vdirs, series.name)
827 else:
828 me._vdirs[series.name] = \
829 DVDDir(dir, me._hacks.get(series.name, {}))
830
831 elif cmd == "vdir":
832 series = me._opts_series(cmd, opts)
833 dir = ww.rest(); check(dir is not None, "missing directory")
834 if not series.wantedp: return
835 if dir == "-": forget(me._sfdirs, series.name)
836 else: me._sfdirs[series.name] = VideoDir(dir)
837
838 elif cmd == "adir":
839 series = me._opts_series(cmd, opts)
840 dir = ww.rest(); check(dir is not None, "missing directory")
841 if not series.wantedp: return
842 if dir == "-": forget(me._sfdirs, series.name)
843 else: me._sfdirs[series.name] = AudioDir(dir)
844
845 elif cmd == "displaced":
846 series = me._opts_series(cmd, opts)
847 w = ww.rest(); check(w is not None, "missing count"); n = getint(w)
848 src = me._auto_epsrc(series)
849 src.nuses += n
850
851 elif cmd == "sep":
852 sep = ww.rest(); check(sep is not None, "missing separator")
853 me._pl.series_sep = sep
854
855 else:
856 raise ExpectedError("unknown command `%s'" % cmd)
857
858 def _process_episode(me, ww):
859
860 opts = ww.nextword(); check(opts is not None, "missing title/options")
861 ti = -1; sname = None; neps = 1; epi = None; loch = hich = -1
862 explen, expvar, explicitlen = me._explen, me._expvar, False
863 series_title_p = True
864 for k, v in me._keyvals(opts):
865 if k is None:
866 if v.isdigit(): ti = int(v)
867 elif v == "-": ti = -1
868 else: sname = v
869 elif k == "s": sname = v
870 elif k == "n": neps = getint(v)
871 elif k == "ep": epi = getint(v)
872 elif k == "st": series_title_p = getbool(v)
873 elif k == "l":
874 if v == "-": me._explen, me._expvar = None, DEFAULT_EXPVAR
875 else:
876 explen, expvar = parse_duration(v, explen, expvar)
877 explicitlen = True
878 elif k == "ch":
879 try: sep = v.index("-")
880 except ValueError: loch, hich = getint(v), -1
881 else: loch, hich = getint(v[:sep]), getint(v[sep + 1:]) + 1
882 else: raise ExpectedError("unknown episode option `%s'" % k)
883 check(ti is not None, "missing title number")
884 series = me._get_series(sname)
885 me._cur_chapter = None
886
887 title = ww.rest()
888 if not series.wantedp: return
889 season = series.ensure_season()
890 if epi is None: epi = season.ep_i
891
892 if ti == -1:
893 check(season.implicitp or season.i is None,
894 "audio source, but explicit non-movie season")
895 dir = lookup(me._sfdirs, series.name,
896 "no title, and no single-file directory")
897 src = lookup(dir.episodes, season.ep_i,
898 "episode %d not found in single-file dir `%s'" %
899 (epi, dir.dir))
900
901 else:
902 try: src = me._isos[series.name]
903 except KeyError: src = me._auto_epsrc(series)
904
905 episode = season.add_episode(epi, neps, title, src,
906 series_title_p, ti, loch, hich)
907
908 if episode.duration != -1 and explen is not None:
909 if not explicitlen: explen *= neps
910 if not explen*(1 - expvar) <= episode.duration <= explen*(1 + expvar):
911 if season.i is None: epid = "episode %d" % epi
912 else: epid = "episode %d.%d" % (season.i, epi)
913 raise ExpectedError \
914 ("%s duration %s %g%% > %g%% from expected %s" %
915 (epid, format_duration(episode.duration),
916 abs(100*(episode.duration - explen)/explen), 100*expvar,
917 format_duration(explen)))
918 me._pl.add_episode(episode)
919 me._cur_episode = episode
920
921 def _process_chapter(me, ww):
922 check(me._cur_episode is not None, "no current episode")
923 check(me._cur_episode.source.CHAPTERP,
924 "episode source doesn't allow chapters")
925 if me._chaptersp:
926 if me._cur_chapter is None: i = 1
927 else: i = me._cur_chapter.i + 1
928 me._cur_chapter = me._cur_episode.add_chapter(ww.rest(), i)
929
930 def parse_file(me, fn):
931 with location(FileLocation(fn, 0)) as floc:
932 with open(fn, "r") as f:
933 for line in f:
934 floc.stepline()
935 sline = line.lstrip()
936 if sline == "" or sline.startswith(";"): continue
937
938 if line.startswith("!"): me._process_cmd(Words(line[1:]))
939 elif not line[0].isspace(): me._process_episode(Words(line))
940 else: me._process_chapter(Words(line))
941 me._pl.done_season()
942
943 def done(me):
944 discs = set()
945 for name, vdir in me._vdirs.items():
946 if not me._series[name].wantedp: continue
947 for s in vdir.seasons.values():
948 for d in s.episodes.values():
949 discs.add(d)
950 for sfdir in me._sfdirs.values():
951 for d in sfdir.episodes.values():
952 discs.add(d)
953 for d in sorted(discs, key = lambda d: d.fn):
954 if d.neps is not None and d.neps != d.nuses:
955 raise ExpectedError("disc `%s' has %d episodes, used %d times" %
956 (d.fn, d.neps, d.nuses))
957 return me._pl
958
959op = OP.OptionParser \
960 (usage = "%prog [-Dc] [-L NAME] [-M DEPS] [-d CACHE] [-o OUT] [-s SERIES] EPLS\n"
961 "%prog -i -d CACHE",
962 description = "Generate M3U playlists from an episode list.")
963op.add_option("-D", "--dump",
964 dest = "dump", action = "store_true", default = False,
965 help = "Dump playlist in machine-readable form")
966op.add_option("-L", "--list-name", metavar = "NAME",
967 dest = "list_name", type = "str", default = None,
968 help = "Set the playlist name")
969op.add_option("-M", "--make-deps", metavar = "DEPS",
970 dest = "deps", type = "str", default = None,
971 help = "Write a `make' fragment for dependencies")
972op.add_option("-c", "--chapters",
973 dest = "chaptersp", action = "store_true", default = False,
974 help = "Output individual chapter names")
975op.add_option("-i", "--init-db",
976 dest = "initdbp", action = "store_true", default = False,
977 help = "Initialize the database")
978op.add_option("-d", "--database", metavar = "CACHE",
979 dest = "database", type = "str", default = None,
980 help = "Set filename for cache database")
981op.add_option("-o", "--output", metavar = "OUT",
982 dest = "output", type = "str", default = None,
983 help = "Write output playlist to OUT")
984op.add_option("-O", "--fake-output", metavar = "OUT",
985 dest = "fakeout", type = "str", default = None,
986 help = "Pretend output goes to OUT for purposes of `-M'")
987op.add_option("-s", "--series", metavar = "SERIES",
988 dest = "series", type = "str", default = None,
989 help = "Output only the listed SERIES (comma-separated)")
990try:
991 opts, argv = op.parse_args()
992
993 if opts.initdbp:
994 if opts.chaptersp or opts.series is not None or \
995 opts.output is not None or opts.deps is not None or \
996 opts.fakeout is not None or \
997 opts.database is None or len(argv):
998 op.print_usage(file = SYS.stderr); SYS.exit(2)
999 setup_db(opts.database)
1000
1001 else:
1002 if len(argv) != 1: op.print_usage(file = SYS.stderr); SYS.exit(2)
1003 if opts.database is not None: init_db(opts.database)
1004 if opts.series is None:
1005 series_wanted = None
1006 else:
1007 series_wanted = set()
1008 for name in opts.series.split(","): series_wanted.add(name)
1009 if opts.deps is not None:
1010 if (opts.output is None or opts.output == "-") and opts.fakeout is None:
1011 raise ExpectedError("can't write dep fragment without output file")
1012 if opts.fakeout is None: opts.fakeout = opts.output
1013 else:
1014 if opts.fakeout is not None:
1015 raise ExpectedError("fake output set but no dep fragment")
1016
1017 ep = EpisodeListParser(series_wanted, opts.chaptersp)
1018 ep.parse_file(argv[0])
1019 pl = ep.done()
1020
1021 if opts.list_name is None:
1022 opts.list_name, _ = OS.path.splitext(OS.path.basename(argv[0]))
1023
1024 if opts.dump: outfn = pl.dump
1025 else: outfn = pl.write
1026 if opts.output is None or opts.output == "-":
1027 outfn(SYS.stdout)
1028 else:
1029 with open(opts.output, "w") as f: outfn(f)
1030
1031 if opts.deps:
1032 if opts.deps == "-":
1033 pl.write_deps(SYS.stdout, opts.fakeout)
1034 else:
1035 with open(opts.deps, "w") as f: pl.write_deps(f, opts.fakeout)
1036
1037except (ExpectedError, IOError, OSError) as e:
1038 LOC.report(e)
1039 SYS.exit(2)