#! /usr/bin/env python

# MPlayer synchronizer
# Copyright 2014 Stefan Schuermans <stefan@schuermans.info>
# Copyleft: GNU public license - http://www.gnu.org/copyleft/gpl.html

import datetime
import fcntl
import os
import re
import select
import socket
import struct
import subprocess
import sys
import time

scriptdir = os.path.dirname(os.path.abspath(__file__))

verbose = False

class Synchronizer:

  def __init__(self):
    """construct an MPlayer Synchronizer object"""
    # set constants
    self.info_timeout     = 1.0   # timeout (s) for info from MPlayer or PoSy
    self.max_equal_offset = 0.1   # maximum offset tolerated as equal
    self.min_cmd_delay    = 0.1   # minimum time (s) between MPlayer commands
    self.min_seek_offset  = 0.5   # minimum offset (s) required for a seek
    self.select_timeout   = 0.1   # timeout (s) for select syscall
    self.speed_change     = 0.05  # change of MPlayer speed for catching up
    # create static objects
    self.re_mplayer_audio_pos = re.compile(r"A: *([0-9]+.[0-9]+) *\([0-9:.]*\) of .*")
    self.re_mplayer_video_pos = re.compile(r"A: *([0-9]+.[0-9]+) *V: *[0-9]+.[0-9]+ A-V: .*")
    self.re_ignore_prefix = re.compile(r"[0-9a-zA-Z_]+__(.*)")
    # create member variables
    self.mplayer = None
    self.mplayer_buf_stdout = ""
    self.mplayer_buf_stderr = ""
    self.mplayer_last_cmd_timestamp = None
    self.mplayer_name = None
    self.mplayer_pause = None
    self.mplayer_pos = None
    self.mplayer_speed = None
    self.mplayer_timestamp = None
    self.offset_samples = []
    self.playlist = []
    self.playlist_idx = None
    self.posy_name = None
    self.posy_pause = None
    self.posy_pos = None
    self.posy_timestamp = None
    self.restart = False
    self.sock = None
    self.verbose = False
    # startup
    self.sockSetup()

  def __del__(self):
    """deconstruct object"""
    self.mplayerStop()
    self.sockClose()

  def dbg_print(self, txt):
    """output debug information in verbose mode"""
    if self.verbose:
      print >>sys.stderr, txt

  def mplayerClear(self):
    """clear MPlayer buffers and information"""
    self.mplayer_buf_stdin = ""
    self.mplayer_buf_stderr = ""
    self.mplayer_last_cmd_timestamp = None
    self.mplayer_name = None
    self.mplayer_pause = None
    self.mplayer_pos = None
    self.mplayer_speed = None
    self.mplayer_timestamp = None
    self.offset_samples = []

  def mplayerExit(self):
    """react to MPlayer exit"""
    # close pipes
    self.mplayer.stdin.close()
    self.mplayer.stdout.close()
    self.mplayer.stderr.close()
    # close process
    self.mplayer.wait()
    self.mplayer = None
    # clear buffers and information
    self.mplayerClear()
    # play next file
    self.playNext()

  def mplayerLine(self, line, err):
    """process line from MPlayer stdout (err = False) or stderr (err = True)"""
    if len(line) > 0:
      if err:
        self.dbg_print("MPlayer stderr: " + line)
      else:
        self.dbg_print("MPlayer stdout: " + line)
    if not err:
      # MPlayer position information (audio)
      m_mplayer_audio_pos = self.re_mplayer_audio_pos.match(line)
      if m_mplayer_audio_pos:
        self.mplayer_timestamp = datetime.datetime.now()
        self.mplayer_pos = float(m_mplayer_audio_pos.group(1))
        # synchronize
        self.sync()
      else:
        # MPlayer position information (video)
        m_mplayer_video_pos = self.re_mplayer_video_pos.match(line)
        if m_mplayer_video_pos:
          self.mplayer_timestamp = datetime.datetime.now()
          self.mplayer_pos = float(m_mplayer_video_pos.group(1))
          # synchronize
          self.sync()

  def mplayerPause(self, pause):
    """pause/unpause MPlayer"""
    # leave if no MPlayer running
    if self.mplayer is None:
      return
    # switch to pause mode
    if pause:
      self.dbg_print("MPlayer stdin: pausing seek 0 0")
      try:
        self.mplayer.stdin.write("pausing seek 0 0\n") # rel seek 0s, then pause
      except:
        pass
      self.mplayer_pause = True
    # continue playing
    else:
      self.dbg_print("MPlayer stdin: seek 0 0")
      try:
        self.mplayer.stdin.write("seek 0 0\n") # realtive seek of 0s
      except:
        pass
      self.mplayer_pause = False
    self.mplayer_last_cmd_timestamp = datetime.datetime.now()

  def mplayerSetFile(self, filename):
    """set file played by MPlayer"""
    # start MPlayer if not running or if restart mode is active
    if self.mplayer is None or self.restart:
      self.mplayerStart(filename)
      return
    # set new filename
    self.dbg_print("MPlayer stdin: loadfile \"%s\"" % filename)
    try:
      self.mplayer.stdin.write("loadfile \"%s\"\n" % filename)
    except:
      pass
    self.mplayer_name = filename
    self.mplayer_last_cmd_timestamp = datetime.datetime.now()

  def mplayerSetPos(self, pos):
    """set MPlayer position"""
    # leave if no MPlayer running
    if self.mplayer is None:
      return
    # sanitize position to avoid mplayer crashes in any case
    if pos < 0.0:
      pos = 0.0
    # set new position
    self.dbg_print("MPlayer stdin: seek %5.3f 2" % pos)
    try:
      self.mplayer.stdin.write("seek %5.3f 2\n" % pos) # 2 means absolute pos
    except:
      pass
    self.mplayer_pos = pos
    self.mplayer_last_cmd_timestamp = datetime.datetime.now()

  def mplayerSetSpeed(self, speed):
    """set MPlayer speed"""
    # leave if no MPlayer running
    if self.mplayer is None:
      return
    # sanitize speed to avoid mplayer crashes in any case
    if speed < 0.5:
      speed = 0.5
    if speed > 2.0:
      speed = 2.0
    # set new speed
    self.dbg_print("MPlayer stdin: speed_set %5.3f" % speed)
    try:
      self.mplayer.stdin.write("speed_set %5.3f\n" % speed)
    except:
      pass
    self.mplayer_speed = speed
    self.mplayer_last_cmd_timestamp = datetime.datetime.now()

  def mplayerStart(self, filename):
    """start MPlayer process in background"""
    # stop old MPlayer
    self.mplayerStop()
    # start MPlayer
    cmd = [ "mplayer", "-volume", "80", "-slave", "-af", "scaletempo"]
    if not self.restart:
      cmd.append("-idle") # keep mplayer in idle mode if restart not desired
    cmd.append(filename)
    print >>sys.stderr, "starting background process: " + " ".join(cmd)
    self.mplayer = subprocess.Popen(cmd, stdin = subprocess.PIPE,
                                         stdout = subprocess.PIPE,
                                         stderr = subprocess.PIPE)
    # make output pipes nonblocking
    fcntl.fcntl(self.mplayer.stdout, fcntl.F_SETFL, os.O_NONBLOCK)
    fcntl.fcntl(self.mplayer.stderr, fcntl.F_SETFL, os.O_NONBLOCK)
    # set initial information
    self.mplayer_name = filename
    self.mplayer_pause = False
    self.mplayer_speed = 1.0
    self.mplayer_last_cmd_timestamp = datetime.datetime.now()

  def mplayerStdouterr(self, err):
    """process data from MPlayer stdout (err = False) or stderr (err = True)"""
    if self.mplayer is None:
      return
    # receive data
    if err:
      txt = self.mplayer.stderr.read()
    else:
      txt = self.mplayer.stdout.read()
    # check if MPlayer exited
    if len(txt) == 0:
      self.mplayerExit()
      return
    # MPlayer did not exit
    # replace CRs with LFs add data to buffer
    txt = txt.replace("\r", "\n")
    if err:
      buf = self.mplayer_buf_stderr + txt
    else:
      buf = self.mplayer_buf_stdout + txt
    # process complete lines and store remaining data in buffer
    lines = buf.split("\n")
    for line in lines[:-1]:
      self.mplayerLine(line, err)
    if err:
      self.mplayer_buf_stderr = buf[-1]
    else:
      self.mplayer_buf_stdout = buf[-1]

  def mplayerStop(self):
    """stop MPlayer process in background"""
    if self.mplayer is not None:
      # send quit command
      try:
        self.mplayer.stdin.write("quit\n")
      except:
        pass
      # close pipes
      self.mplayer.stdin.close()
      self.mplayer.stdout.close()
      self.mplayer.stderr.close()
      # terminate process
      self.mplayer.terminate()
      self.mplayer.kill()
      # close process
      self.mplayer.wait()
      self.mplayer = None
    # clear buffers and information
    self.mplayerClear()

  def playlistFind(self, posy_name):
    """find file in playlist by PoSy name"""
    idx = 0
    for file_name in self.playlist:
      if self.posyCheckName(posy_name, file_name):
        return idx;
      idx += 1
    return None

  def playlistRead(self, playlist):
    """read playlist file"""
    # read filenames from playlist
    filenames = []
    try:
      with open(playlist, "rt") as f:
        filenames = f.readlines()
    except:
      return False
    filenames = [filename.strip() for filename in filenames]
    # convert filenames to absolute paths
    playlistdir = os.path.dirname(playlist)
    filenames = [os.path.join(playlistdir, filename) for filename in filenames]
    # replace playlist
    self.playlist = filenames
    self.playlist_idx = None
    return True

  def playNext(self):
    """play next file in playlist"""
    # playlist empty -> stop MPlayer
    if len(self.playlist) == 0:
      self.playlist_idx = None
      self.mplayerStop()
    # playlist not empty -> play next file
    else:
      if self.playlist_idx is None:
        self.playlist_idx = 0
      else:
        self.playlist_idx += 1
        if self.playlist_idx >= len(self.playlist):
          self.playlist_idx = 0
      self.mplayerSetFile(self.playlist[self.playlist_idx])

  def playPosyName(self, posy_name):
    """play file by PoSy name (if found)"""
    # find file in playlist
    idx = self.playlistFind(posy_name)
    # file not found -> stop MPlayer
    if idx is None:
      self.playlist_idx = None
      self.mplayerStop()
    # file found -> (re-)start MPlayer
    else:
      self.playlist_idx = idx
      self.mplayerSetFile(self.playlist[idx])

  def posyCheckName(self, posy_name, file_name):
    """check if filename matches PoSyName"""
    # remove directory part of file name and check
    file_name = os.path.basename(file_name)
    if file_name == posy_name:
      return True
    # remove extension and check
    file_name = os.path.splitext(file_name)[0]
    if file_name == posy_name:
      return True
    # remove ignore prefix and check
    m_ignore_prefix = self.re_ignore_prefix.match(file_name)
    if m_ignore_prefix and m_ignore_prefix.group(1) == posy_name:
      return True
    # not matching
    return False

  def posyParse(self, data):
    """parse received PoSy packet"""
    if len(data) < 76 or data[0:4] != "PoSy":
      return False
    flags, name, pos_ms = struct.unpack("!I64sI", data[4:76])
    name_end = name.find("\0")
    if name_end >= 0:
      name = name[:name_end]
    if flags & 1:
      pause = True
    else:
      pause = False
    # store info from PoSy packet
    self.posy_timestamp = datetime.datetime.now()
    self.posy_name = name
    self.posy_pos = pos_ms * 1e-3
    self.posy_pause = pause
    return True

  def restartSet(self, restart):
    """set restart mode"""
    self.restart = restart

  def run(self):
    """run application"""
    try:
      while True:
        self.waitForInput()
    except KeyboardInterrupt:
      pass

  def sockRecv(self):
    """receive data from socket"""
    if self.sock is None:
      return
    # receive message
    data = self.sock.recv(4096)
    self.dbg_print("data from socket: %d bytes" % len(data))
    # parse (ignore message on error)
    if not self.posyParse(data):
      return
    # synchronize
    self.sync()

  def sockClose(self):
    """close UDP socket"""
    if self.sock is not None:
      self.sock.close()
      self.sock = None

  def sockSetup(self):
    """create a new UDP socket and bind it"""
    self.sockClose()
    try:
      self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
      self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
      self.sock.bind(("0.0.0.0", 5740))
    except:
      self.sockClose()

  def sync(self):
    """synchronize MPlayer to PoSy input"""
    now = datetime.datetime.now()
    # do nothing if PoSy information is missing
    if self.posy_name is None or \
       self.posy_pause is None or \
       self.posy_pos is None or \
       self.posy_timestamp is None:
      return
    # do nothing if PoSy information is too old
    posy_age = (now - self.posy_timestamp).total_seconds()
    if posy_age > self.info_timeout:
      return
    # MPlayer not running -> play requested file (if found)
    if self.mplayer is None:
      self.playPosyName(self.posy_name)
      return
    # do nothing if MPlayer information is missing
    if self.mplayer_name is None or \
       self.mplayer_pause is None or \
       self.mplayer_pos is None or \
       self.mplayer_timestamp is None:
      return
    # do nothing if last MPlayer command has been sent shortly
    if self.mplayer_last_cmd_timestamp is not None:
      last_cmd_age = (now - self.mplayer_last_cmd_timestamp).total_seconds()
      if last_cmd_age < self.min_cmd_delay:
        return
    # output information for debugging
    self.dbg_print("MPlayer: %s name \"%s\" pos %f pause %s" % \
              (self.mplayer_timestamp, self.mplayer_name, self.mplayer_pos,
               self.mplayer_pause))
    self.dbg_print("PoSy:    %s name \"%s\" pos %f pause %s" % \
              (self.posy_timestamp, self.posy_name, self.posy_pos,
               self.posy_pause))
    # name mismatch -> play requested file (if found)
    if not self.posyCheckName(self.posy_name, self.mplayer_name):
      self.playPosyName(self.posy_name)
      return
    # pause mode mismatch -> pause/unpause MPlayer
    if self.mplayer_pause != self.posy_pause:
      self.mplayerPause(self.posy_pause)
      return
    # never seek in MPlayer pause mode (this continues playback)
    if self.mplayer_pause:
      return
    # calculate offset (account for time elapsed since last info)
    mplayer_pos = self.mplayer_pos
    if not self.mplayer_pause:
      mplayer_age = (now - self.mplayer_timestamp).total_seconds()
      mplayer_pos += mplayer_age
    posy_pos = self.posy_pos
    if not self.posy_pause:
      posy_pos += posy_age
    offset = posy_pos - mplayer_pos
    self.dbg_print("offset: %5.3f" % offset)
    # seek if offset is too big
    if abs(offset) > self.min_seek_offset:
      self.mplayerSetSpeed(1.0) # position will be okay -> normal speed
      self.mplayerSetPos(posy_pos)
      return
    # compute sliding average of offset (to get rid of jitter)
    self.offset_samples.append(offset)
    self.offset_samples = self.offset_samples[-10:]
    off_avg = 0
    for o in self.offset_samples:
      off_avg += o
    off_avg *= 0.1
    self.dbg_print("off_avg: %5.3f" % off_avg)
    # normal speed, position almost matches -> everything fine (do nothing)
    if abs(off_avg) < self.max_equal_offset and self.mplayer_speed == 1.0:
      return
    # position is really good -> go to normal speed
    if abs(off_avg) < self.max_equal_offset / 3:
      self.mplayerSetSpeed(1.0)
      return
    # synchronize by varying speed
    if off_avg < 0.0:
      speed = 1.0 - self.speed_change
    else:
      speed = 1.0 + self.speed_change
    if self.mplayer_speed is None or self.mplayer_speed != speed:
      self.mplayerSetSpeed(speed)

  def verboseSet(self, verbose):
    """set verbose mode"""
    self.verbose = verbose

  def waitForInput(self):
    """wait for input from UDP socket or MPlayer pipes"""
    # poll for data
    inputs = []
    outputs = []
    errors = []
    wait_txt = ""
    if self.sock is not None:
      inputs.append(self.sock)
      wait_txt += " socket"
    if self.mplayer is not None:
      inputs.append(self.mplayer.stdout)
      inputs.append(self.mplayer.stderr)
      wait_txt += " MPlayer"
    self.dbg_print("waiting for input:" + wait_txt)
    rds, wrs, exs = select.select(inputs, outputs, errors, self.select_timeout)
    # obtain available data
    for rd in rds:
      if self.sock is not None and rd is self.sock:
        self.dbg_print("input from socket")
        self.sockRecv()
      if self.mplayer is not None and rd is self.mplayer.stdout:
        self.dbg_print("input from MPlayer stdout")
        self.mplayerStdouterr(False)
      if self.mplayer is not None and rd is self.mplayer.stderr:
        self.dbg_print("input from MPlayer stderr")
        self.mplayerStdouterr(True)

# main function
def main(argv):
  # check parameters
  if len(argv) < 2:
    print >>sys.stderr, "usage: %s <playlist.txt> [<options>]" % argv[0]
    print >>sys.stderr, "options: -v   verbose"
    print >>sys.stderr, "         -r   restart MPlayer for each entry"
    return 2
  playlist = argv[1]
  verbose = False
  restart = False
  for arg in argv[2:]:
    if arg == "-v":
      verbose = True
    elif arg == "-r":
      restart = True
    else:
      print >>sys.stderr, "unknown option \"%s\"" % arg
      return 3
  # run application
  app = Synchronizer()
  app.verboseSet(verbose)
  app.restartSet(restart)
  if not app.playlistRead(playlist):
    print >>sys.stderr, "could not read playlist \"%s\"" % playlist
    return 4
  app.run()
  # done
  return 0

# main application entry point
if __name__ == "__main__":
  sys.exit(main(sys.argv))