mkm3u: Add support for individual MPEG4 video files.
[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
551 def add_episode(me, episode):
552 me.episodes.append(episode)
553
554 def done_season(me):
555 if me.episodes:
556 me.seasons.append(me.episodes)
557 me.episodes = []
558
559 def write(me, f):
560 f.write("#EXTM3U\n")
561 for season in me.seasons:
562 f.write("\n")
563 for ep in season:
564 label = ep.label()
565 if me.nseries > 1 and ep.series_title_p and \
566 ep.season.series.title is not None:
567 if ep.season.i is None: sep = ": "
568 else: sep = " "
569 label = ep.season.series.title + sep + label
570 if not ep.chapters:
571 f.write("#EXTINF:%d,,%s\n%s\n" % (ep.duration, label, ep.url))
572 else:
573 for ch in ep.chapters:
574 f.write("#EXTINF:%d,,%s: %s\n%s\n" %
575 (ch.duration, label, ch.title, ch.url))
576
577 def dump(me, f):
578 if opts.list_name is not None: f.write("LIST %s\n" % opts.list_name)
579 if me.series_title is not None and \
580 me.nseries > 1 and not me.single_series_p:
581 raise ExpectedError("can't force series name for multi-series list")
582 series = set()
583 if me.single_series_p:
584 f.write("SERIES - %s\n" % quote(me.series_title))
585 for season in me.seasons:
586 for ep in season:
587 label = ep.label()
588 title = ep.season.series.full_title
589 if me.single_series_p:
590 stag = "-"
591 if title is not None: label = title + " " + label
592 else:
593 if title is None: title = me.series_title
594 stag = ep.season.series.name
595 if stag is None: stag = "-"
596 if stag not in series:
597 f.write("SERIES %s %s\n" % (stag, quote(title)))
598 series.add(stag)
599 f.write("ENTRY %s %s %s %d %d %d %g\n" %
600 (stag, quote(label), quote(ep.source.fn),
601 ep.tno, ep.start_chapter, ep.end_chapter, ep.duration))
602
603 def write_deps(me, f, out):
604 deps = set()
605 for season in me.seasons:
606 for ep in season: deps.add(ep.source.fn)
607 f.write("### -*-makefile-*-\n")
608 f.write("%s: $(call check-deps, %s," % (out, out))
609 for dep in sorted(deps):
610 f.write(" \\\n\t'%s'" %
611 OS.path.join(ROOT, dep)
612 .replace(",", "$(comma)")
613 .replace("'", "'\\''"))
614 f.write(")\n")
615
616 DEFAULT_EXPVAR = 0.05
617 R_DURMULT = RX.compile(r""" ^
618 (\d+ (?: \. \d+)?) x
619 $ """, RX.X)
620 R_DUR = RX.compile(r""" ^
621 (?: (?: (\d+) :)? (\d+) :)? (\d+)
622 (?: / (\d+ (?: \. \d+)?) \%)?
623 $ """, RX.X)
624 def parse_duration(s, base = None, basevar = DEFAULT_EXPVAR):
625 if base is not None:
626 m = R_DURMULT.match(s)
627 if m is not None: return base*float(m.group(1)), basevar
628 m = R_DUR.match(s)
629 if not m: raise ExpectedError("invalid duration spec `%s'" % s)
630 hr, min, sec = map(lambda g: filter(m.group(g), int, 0), [1, 2, 3])
631 var = filter(m.group(4), lambda x: float(x)/100.0)
632 if var is None: var = DEFAULT_EXPVAR
633 return 3600*hr + 60*min + sec, var
634 def format_duration(d):
635 if d >= 3600: return "%d:%02d:%02d" % (d//3600, (d//60)%60, d%60)
636 elif d >= 60: return "%d:%02d" % (d//60, d%60)
637 else: return "%d s" % d
638
639 MODE_UNSET = 0
640 MODE_SINGLE = 1
641 MODE_MULTI = 2
642
643 class EpisodeListParser (object):
644
645 def __init__(me, series_wanted = None, chapters_wanted_p = False):
646 me._pl = Playlist()
647 me._cur_episode = me._cur_chapter = None
648 me._series = {}; me._vdirs = {}; me._sfdirs = {}; me._isos = {}
649 me._series_wanted = series_wanted
650 me._chaptersp = chapters_wanted_p
651 me._explen, me._expvar = None, DEFAULT_EXPVAR
652 if series_wanted is None: me._mode = MODE_UNSET
653 else: me._mode = MODE_MULTI
654
655 def _bad_keyval(me, cmd, k, v):
656 raise ExpectedError("invalid `!%s' option `%s'" %
657 (cmd, v if k is None else k))
658
659 def _keyvals(me, opts):
660 if opts is not None:
661 for kv in opts.split(","):
662 try: sep = kv.index("=")
663 except ValueError: yield None, kv
664 else: yield kv[:sep], kv[sep + 1:]
665
666 def _set_mode(me, mode):
667 if me._mode == MODE_UNSET:
668 me._mode = mode
669 elif me._mode != mode:
670 raise ExpectedError("inconsistent single-/multi-series usage")
671
672 def _get_series(me, name):
673 if name is None:
674 me._set_mode(MODE_SINGLE)
675 try: series = me._series[None]
676 except KeyError:
677 series = me._series[None] = Series(me._pl, None)
678 me._pl.nseries += 1
679 else:
680 me._set_mode(MODE_MULTI)
681 series = lookup(me._series, name, "unknown series `%s'" % name)
682 return series
683
684 def _opts_series(me, cmd, opts):
685 name = None
686 for k, v in me._keyvals(opts):
687 if k is None: name = v
688 else: me._bad_keyval(cmd, k, v)
689 return me._get_series(name)
690
691 def _auto_epsrc(me, series):
692 dir = lookup(me._vdirs, series.name, "no active video directory")
693 season = series.ensure_season()
694 check(season.i is not None, "must use explicit iso for movie seasons")
695 vseason = lookup(dir.seasons, season.i,
696 "season %d not found in video dir `%s'" %
697 (season.i, dir.dir))
698 src = lookup(vseason.episodes, season.ep_i,
699 "episode %d.%d not found in video dir `%s'" %
700 (season.i, season.ep_i, dir.dir))
701 return src
702
703 def _process_cmd(me, ww):
704
705 cmd = ww.nextword(); check(cmd is not None, "missing command")
706 try: sep = cmd.index(":")
707 except ValueError: opts = None
708 else: cmd, opts = cmd[:sep], cmd[sep + 1:]
709
710 if cmd == "title":
711 for k, v in me._keyvals(opts): me._bad_keyval("title", k, v)
712 title = ww.rest(); check(title is not None, "missing title")
713 check(me._pl.series_title is None, "already set a title")
714 me._pl.series_title = title
715
716 elif cmd == "single":
717 for k, v in me._keyvals(opts): me._bad_keyval("single", k, v)
718 check(ww.rest() is None, "trailing junk")
719 check(not me._pl.single_series_p, "single-series already set")
720 me._pl.single_series_p = True
721
722 elif cmd == "series":
723 name = None
724 for k, v in me._keyvals(opts):
725 if k is None: name = v
726 else: me._bad_keyval(cmd, k, v)
727 check(name is not None, "missing series name")
728 check(name not in me._series, "series `%s' already defined" % name)
729 title = ww.rest()
730 if title is None:
731 full = None
732 else:
733 try: sep = title.index("::")
734 except ValueError: full = title
735 else:
736 full = title[sep + 2:].strip()
737 if sep == 0: title = None
738 else: title = title[:sep].strip()
739 me._set_mode(MODE_MULTI)
740 me._series[name] = series = Series(me._pl, name, title, full,
741 me._series_wanted is None or
742 name in me._series_wanted)
743 if series.wantedp: me._pl.nseries += 1
744
745 elif cmd == "season":
746 series = me._opts_series(cmd, opts)
747 w = ww.nextword();
748 check(w is not None, "missing season number")
749 if w == "-":
750 if not series.wantedp: return
751 series.add_movies(ww.rest())
752 else:
753 title = ww.rest(); i = getint(w)
754 if not series.wantedp: return
755 series.add_season(ww.rest(), getint(w), implicitp = False)
756 me._cur_episode = me._cur_chapter = None
757 me._pl.done_season()
758
759 elif cmd == "explen":
760 w = ww.rest(); check(w is not None, "missing duration spec")
761 if w == "-":
762 me._explen, me._expvar = None, DEFAULT_EXPVAR
763 else:
764 d, v = parse_duration(w)
765 me._explen = d
766 if v is not None: me._expvar = v
767
768 elif cmd == "epname":
769 for k, v in me._keyvals(opts): me._bad_keyval("epname", k, v)
770 name = ww.rest(); check(name is not None, "missing episode name")
771 try: sep = name.index("::")
772 except ValueError: names = name + "s"
773 else: name, names = name[:sep], name[sep + 2:]
774 me._pl.epname, me._pl.epnames = name, names
775
776 elif cmd == "epno":
777 series = me._opts_series(cmd, opts)
778 w = ww.rest(); check(w is not None, "missing episode number")
779 epi = getint(w)
780 if not series.wantedp: return
781 series.ensure_season().ep_i = epi
782
783 elif cmd == "dvd":
784 series = me._opts_series(cmd, opts)
785 fn = ww.rest(); check(fn is not None, "missing filename")
786 if not series.wantedp: return
787 if fn == "-": forget(me._isos, series.name)
788 else:
789 check(OS.path.exists(OS.path.join(ROOT, fn)),
790 "dvd iso file `%s' not found" % fn)
791 me._isos[series.name] = DVDFile(fn)
792
793 elif cmd == "dvddir":
794 series = me._opts_series(cmd, opts)
795 dir = ww.rest(); check(dir is not None, "missing directory")
796 if not series.wantedp: return
797 if dir == "-": forget(me._vdirs, series.name)
798 else: me._vdirs[series.name] = DVDDir(dir)
799
800 elif cmd == "vdir":
801 series = me._opts_series(cmd, opts)
802 dir = ww.rest(); check(dir is not None, "missing directory")
803 if not series.wantedp: return
804 if dir == "-": forget(me._sfdirs, series.name)
805 else: me._sfdirs[series.name] = VideoDir(dir)
806
807 elif cmd == "adir":
808 series = me._opts_series(cmd, opts)
809 dir = ww.rest(); check(dir is not None, "missing directory")
810 if not series.wantedp: return
811 if dir == "-": forget(me._sfdirs, series.name)
812 else: me._sfdirs[series.name] = AudioDir(dir)
813
814 elif cmd == "displaced":
815 series = me._opts_series(cmd, opts)
816 w = ww.rest(); check(w is not None, "missing count"); n = getint(w)
817 src = me._auto_epsrc(series)
818 src.nuses += n
819
820 else:
821 raise ExpectedError("unknown command `%s'" % cmd)
822
823 def _process_episode(me, ww):
824
825 opts = ww.nextword(); check(opts is not None, "missing title/options")
826 ti = -1; sname = None; neps = 1; epi = None; loch = hich = -1
827 explen, expvar, explicitlen = me._explen, me._expvar, False
828 series_title_p = True
829 for k, v in me._keyvals(opts):
830 if k is None:
831 if v.isdigit(): ti = int(v)
832 elif v == "-": ti = -1
833 else: sname = v
834 elif k == "s": sname = v
835 elif k == "n": neps = getint(v)
836 elif k == "ep": epi = getint(v)
837 elif k == "st": series_title_p = getbool(v)
838 elif k == "l":
839 if v == "-": me._explen, me._expvar = None, DEFAULT_EXPVAR
840 else:
841 explen, expvar = parse_duration(v, explen, expvar)
842 explicitlen = True
843 elif k == "ch":
844 try: sep = v.index("-")
845 except ValueError: loch, hich = getint(v), -1
846 else: loch, hich = getint(v[:sep]), getint(v[sep + 1:]) + 1
847 else: raise ExpectedError("unknown episode option `%s'" % k)
848 check(ti is not None, "missing title number")
849 series = me._get_series(sname)
850 me._cur_chapter = None
851
852 title = ww.rest()
853 if not series.wantedp: return
854 season = series.ensure_season()
855 if epi is None: epi = season.ep_i
856
857 if ti == -1:
858 check(season.implicitp or season.i is None,
859 "audio source, but explicit non-movie season")
860 dir = lookup(me._sfdirs, series.name,
861 "no title, and no single-file directory")
862 src = lookup(dir.episodes, season.ep_i,
863 "episode %d not found in single-file dir `%s'" %
864 (epi, dir.dir))
865
866 else:
867 try: src = me._isos[series.name]
868 except KeyError: src = me._auto_epsrc(series)
869
870 episode = season.add_episode(epi, neps, title, src,
871 series_title_p, ti, loch, hich)
872
873 if episode.duration != -1 and explen is not None:
874 if not explicitlen: explen *= neps
875 if not explen*(1 - expvar) <= episode.duration <= explen*(1 + expvar):
876 if season.i is None: epid = "episode %d" % epi
877 else: epid = "episode %d.%d" % (season.i, epi)
878 raise ExpectedError \
879 ("%s duration %s %g%% > %g%% from expected %s" %
880 (epid, format_duration(episode.duration),
881 abs(100*(episode.duration - explen)/explen), 100*expvar,
882 format_duration(explen)))
883 me._pl.add_episode(episode)
884 me._cur_episode = episode
885
886 def _process_chapter(me, ww):
887 check(me._cur_episode is not None, "no current episode")
888 check(me._cur_episode.source.CHAPTERP,
889 "episode source doesn't allow chapters")
890 if me._chaptersp:
891 if me._cur_chapter is None: i = 1
892 else: i = me._cur_chapter.i + 1
893 me._cur_chapter = me._cur_episode.add_chapter(ww.rest(), i)
894
895 def parse_file(me, fn):
896 with location(FileLocation(fn, 0)) as floc:
897 with open(fn, "r") as f:
898 for line in f:
899 floc.stepline()
900 sline = line.lstrip()
901 if sline == "" or sline.startswith(";"): continue
902
903 if line.startswith("!"): me._process_cmd(Words(line[1:]))
904 elif not line[0].isspace(): me._process_episode(Words(line))
905 else: me._process_chapter(Words(line))
906 me._pl.done_season()
907
908 def done(me):
909 discs = set()
910 for name, vdir in me._vdirs.items():
911 if not me._series[name].wantedp: continue
912 for s in vdir.seasons.values():
913 for d in s.episodes.values():
914 discs.add(d)
915 for sfdir in me._sfdirs.values():
916 for d in sfdir.episodes.values():
917 discs.add(d)
918 for d in sorted(discs, key = lambda d: d.fn):
919 if d.neps is not None and d.neps != d.nuses:
920 raise ExpectedError("disc `%s' has %d episodes, used %d times" %
921 (d.fn, d.neps, d.nuses))
922 return me._pl
923
924 op = OP.OptionParser \
925 (usage = "%prog [-Dc] [-L NAME] [-M DEPS] [-d CACHE] [-o OUT] [-s SERIES] EPLS\n"
926 "%prog -i -d CACHE",
927 description = "Generate M3U playlists from an episode list.")
928 op.add_option("-D", "--dump",
929 dest = "dump", action = "store_true", default = False,
930 help = "Dump playlist in machine-readable form")
931 op.add_option("-L", "--list-name", metavar = "NAME",
932 dest = "list_name", type = "str", default = None,
933 help = "Set the playlist name")
934 op.add_option("-M", "--make-deps", metavar = "DEPS",
935 dest = "deps", type = "str", default = None,
936 help = "Write a `make' fragment for dependencies")
937 op.add_option("-c", "--chapters",
938 dest = "chaptersp", action = "store_true", default = False,
939 help = "Output individual chapter names")
940 op.add_option("-i", "--init-db",
941 dest = "initdbp", action = "store_true", default = False,
942 help = "Initialize the database")
943 op.add_option("-d", "--database", metavar = "CACHE",
944 dest = "database", type = "str", default = None,
945 help = "Set filename for cache database")
946 op.add_option("-o", "--output", metavar = "OUT",
947 dest = "output", type = "str", default = None,
948 help = "Write output playlist to OUT")
949 op.add_option("-O", "--fake-output", metavar = "OUT",
950 dest = "fakeout", type = "str", default = None,
951 help = "Pretend output goes to OUT for purposes of `-M'")
952 op.add_option("-s", "--series", metavar = "SERIES",
953 dest = "series", type = "str", default = None,
954 help = "Output only the listed SERIES (comma-separated)")
955 try:
956 opts, argv = op.parse_args()
957
958 if opts.initdbp:
959 if opts.chaptersp or opts.series is not None or \
960 opts.output is not None or opts.deps is not None or \
961 opts.fakeout is not None or \
962 opts.database is None or len(argv):
963 op.print_usage(file = SYS.stderr); SYS.exit(2)
964 setup_db(opts.database)
965
966 else:
967 if len(argv) != 1: op.print_usage(file = SYS.stderr); SYS.exit(2)
968 if opts.database is not None: init_db(opts.database)
969 if opts.series is None:
970 series_wanted = None
971 else:
972 series_wanted = set()
973 for name in opts.series.split(","): series_wanted.add(name)
974 if opts.deps is not None:
975 if (opts.output is None or opts.output == "-") and opts.fakeout is None:
976 raise ExpectedError("can't write dep fragment without output file")
977 if opts.fakeout is None: opts.fakeout = opts.output
978 else:
979 if opts.fakeout is not None:
980 raise ExpectedError("fake output set but no dep fragment")
981
982 ep = EpisodeListParser(series_wanted, opts.chaptersp)
983 ep.parse_file(argv[0])
984 pl = ep.done()
985
986 if opts.list_name is None:
987 opts.list_name, _ = OS.path.splitext(OS.path.basename(argv[0]))
988
989 if opts.dump: outfn = pl.dump
990 else: outfn = pl.write
991 if opts.output is None or opts.output == "-":
992 outfn(SYS.stdout)
993 else:
994 with open(opts.output, "w") as f: outfn(f)
995
996 if opts.deps:
997 if opts.deps == "-":
998 pl.write_deps(SYS.stdout, opts.fakeout)
999 else:
1000 with open(opts.deps, "w") as f: pl.write_deps(f, opts.fakeout)
1001
1002 except (ExpectedError, IOError, OSError) as e:
1003 LOC.report(e)
1004 SYS.exit(2)