mkm3u: Determine and write out accurate durations for episodes and chapters.
[epls] / mkm3u
diff --git a/mkm3u b/mkm3u
index 08ef4ff..32b9bf2 100755 (executable)
--- a/mkm3u
+++ b/mkm3u
@@ -5,6 +5,7 @@ from contextlib import contextmanager
 import optparse as OP
 import os as OS
 import re as RX
+import subprocess as SP
 import sys as SYS
 
 class ExpectedError (Exception): pass
@@ -59,6 +60,12 @@ class Words (object):
     if begin < 0: return None
     else: return s[begin:].rstrip()
 
+def program_output(*args, **kw):
+  try: return SP.check_output(*args, **kw)
+  except SP.CalledProcessError as e:
+    raise ExpectedError("program `%s' failed with code %d" %
+                          (e.cmd, e.returncode))
+
 URL_SAFE_P = 256*[False]
 for ch in \
     b"ABCDEFGHIJKLMNOPQRSTUVWXYZ" \
@@ -94,7 +101,10 @@ class Source (object):
     me.used_titles = dict()
     me.used_chapters = set()
     me.nuses = 0
-  def url(me, title = None, start_chapter = None, end_chapter = None):
+  def _duration(me, title, start_chapter, end_chapter):
+    return -1
+  def url_and_duration(me, title = None,
+                       start_chapter = None, end_chapter = None):
     if title == "-":
       if me.TITLEP: raise ExpectedError("missing title number")
       if start_chapter is not None or end_chapter is not None:
@@ -112,6 +122,9 @@ class Source (object):
       suffix = "#%d:%d" % (title, start_chapter)
     else:
       suffix = "#%d:%d-%d:%d" % (title, start_chapter, title, end_chapter - 1)
+
+    duration = me._duration(title, start_chapter, end_chapter)
+
     if end_chapter is not None:
       keys = [(title, ch) for ch in range(start_chapter, end_chapter)]
       set = me.used_chapters
@@ -129,7 +142,7 @@ class Source (object):
     if end_chapter is not None:
       for ch in range(start_chapter, end_chapter):
         me.used_chapters.add((title, ch))
-    return me.PREFIX + ROOT + urlencode(me.fn) + suffix
+    return me.PREFIX + ROOT + urlencode(me.fn) + suffix, duration
 
 class VideoDisc (Source):
   PREFIX = "dvd://"
@@ -139,6 +152,26 @@ class VideoDisc (Source):
     super().__init__(fn, *args, **kw)
     me.neps = 0
 
+  def _duration(me, title, start_chapter, end_chapter):
+    path = OS.path.join(ROOT, me.fn)
+    ntitle = int(program_output(["dvd-info", path, "titles"]))
+    if not 1 <= title <= ntitle:
+      raise ExpectedError("bad title %d for `%s': must be in 1 .. %d" %
+                            (title, me.fn, ntitle))
+    if start_chapter is None:
+      durq = "duration:%d" % title
+    else:
+      nch = int(program_output(["dvd-info", path, "chapters:%d" % title]))
+      if end_chapter is None: end_chapter = nch
+      else: end_chapter -= 1
+      if not 1 <= start_chapter <= end_chapter <= nch:
+        raise ExpectedError("bad chapter range %d .. %d for `%s' title %d: "
+                            "must be in 1 .. %d" %
+                            (start_chapter, end_chapter, me.fn, title, nch))
+      durq = "duration:%d.%d-%d" % (title, start_chapter, end_chapter)
+    duration = int(program_output(["dvd-info", path, durq]))
+    return duration
+
 class VideoSeason (object):
   def __init__(me, i, title):
     me.i = i
@@ -159,16 +192,18 @@ def match_group(m, *groups, dflt = None, mustp = False):
 
 class VideoDir (object):
 
-  _R_ISO_PRE = list(map(lambda pat: RX.compile("^" + pat + r"\.iso$", RX.X),
-    [r""" S? (?P<si> \d+) [A-Z]? \. \ (?P<stitle> .*) —
-            (?: D \d+ \. \ )?
-          (?P<epex> .*) """,
-     r""" S (?P<si> \d+) (?: D \d+)? \. \ (?P<epex> .*) """,
-     r""" S (?P<si> \d+) \. \ (?P<epex> E \d+ .*) """,
-     r""" S (?P<si> \d+) \. \ (?P<epex> E \d+ .*) """,
-     r""" S (?P<si> \d+) (?P<epex> E \d+) \. \ .* """,
-     r""" \d+ \. \ (?P<epex> [ES] \d+ .*) """,
-     r""" (?P<epnum> \d+ ) \. \ .* """]))
+  _R_ISO_PRE = list(map(lambda pats:
+                          list(map(lambda pat:
+                                     RX.compile("^" + pat + r"\.iso$", RX.X),
+                                   pats)),
+    [[r""" S (?P<si> \d+) \. \ (?P<stitle> .*) — (?: D \d+ \. \ )?
+           (?P<epex> .*) """,
+      r""" S (?P<si> \d+) (?: D \d+)? \. \ (?P<epex> .*) """,
+      r""" S (?P<si> \d+) \. \ (?P<epex> E \d+ .*) """,
+      r""" S (?P<si> \d+) (?P<epex> E \d+) \. \ .* """],
+     [r""" (?P<si> \d+) [A-Z]? \. \ (?P<stitle> .*) — (?P<epex> .*) """],
+     [r""" \d+ \. \ (?P<epex> [ES] \d+ .*) """],
+     [r""" (?P<epnum> \d+ ) \. \ .* """]]))
 
   _R_ISO_EP = RX.compile(r""" ^
         (?: S (?P<si> \d+) \ )?
@@ -181,13 +216,18 @@ class VideoDir (object):
     fns.sort()
     season = None
     seasons = {}
+    styles = me._R_ISO_PRE
     for fn in fns:
       path = OS.path.join(dir, fn)
       if not fn.endswith(".iso"): continue
       #print(";; `%s'" % path, file = SYS.stderr)
-      for r in me._R_ISO_PRE:
-        m = r.match(fn)
-        if m: break
+      for sty in styles:
+        for r in sty:
+          m = r.match(fn)
+          if m: styles = [sty]; break
+        else:
+          continue
+        break
       else:
         #print(";;\tignored (regex mismatch)", file = SYS.stderr)
         continue
@@ -242,9 +282,14 @@ class AudioDisc (Source):
   PREFIX = "file://"
   TITLEP = CHAPTERP = False
 
-class AudioEpisode (Source):
-  PREFIX = "file://"
-  TITLEP = CHAPTERP = False
+  def _duration(me, title, start_chapter, end_chaptwr):
+    out = program_output(["metaflac",
+                          "--show-total-samples", "--show-sample-rate",
+                          OS.path.join(ROOT, me.fn)])
+    nsamples, hz = map(float, out.split())
+    return int(nsamples/hz)
+
+class AudioEpisode (AudioDisc):
   def __init__(me, fn, i, *args, **kw):
     super().__init__(fn, *args, **kw)
     me.i = i
@@ -278,7 +323,8 @@ class AudioDir (object):
 class Chapter (object):
   def __init__(me, episode, title, i):
     me.title, me.i = title, i
-    me.url = episode.source.url(episode.tno, i, i + 1)
+    me.url, me.duration = \
+      episode.source.url_and_duration(episode.tno, i, i + 1)
 
 class Episode (object):
   def __init__(me, season, i, neps, title, src, tno = None,
@@ -287,7 +333,7 @@ class Episode (object):
     me.i, me.neps, me.title = i, neps, title
     me.chapters = []
     me.source, me.tno = src, tno
-    me.url = src.url(tno, startch, endch)
+    me.url, me.duration = src.url_and_duration(tno, startch, endch)
   def add_chapter(me, title, j):
     ch = Chapter(me, title, j)
     me.chapters.append(ch)
@@ -385,11 +431,11 @@ class Playlist (object):
         label = ep.label()
         if me.nseries > 1: label = ep.season.series.title + " " + label
         if not ep.chapters:
-          f.write("#EXTINF:0,,%s\n%s\n" % (label, ep.url))
+          f.write("#EXTINF:%d,,%s\n%s\n" % (ep.duration, label, ep.url))
         else:
           for ch in ep.chapters:
-            f.write("#EXTINF:0,,%s: %s\n%s\n" %
-                    (label, ch.title, ch.url))
+            f.write("#EXTINF:%d,,%s: %s\n%s\n" %
+                    (ch.duration, label, ch.title, ch.url))
 
 MODE_UNSET = 0
 MODE_SINGLE = 1