essandess/etv-comskip

Mods to Add Comskip Information to IPad/iPhone/Apple TV H.264 Files

GoogleCodeExporter opened this issue · 0 comments

ETVComskip works great for EyeTV's original recordings, but the Comskip 
information isn't included with exports to iPad/iPhone/Apple TV files created 
by EyeTV or Turbo.264. Result: iTunes exports and wifi streams have ads. This 
modification addresses this issue by adding Comskip .edl information as 
chapters within the H.264 .m4v files exported by EyeTV and Turbo.264, providing 
a mobile Comskip solution: when the ads appear, skip to the next chapter. This 
is a lot easier and faster than using the (barely functional) time swiping 
capability to guess where the show starts. It also avoids the problem of false 
detections by not deleting any program material.

I've tested this new setup with the HD mods I described here 
<http://code.google.com/p/etv-comskip/issues/detail?id=53> and it all works. 
The one new necessary tool is the executable mp4chaps, available in the mp4v2 
package, e.g. From Macports:

$ sudo port selfupdate
$ sudo port install mp4v2

It all works great. The only issue I would highlight is that while iTunes 
Sharing over wifi supports navigation by by mp4 chapters, the iOS EyeTV app 
does not. Please contact Elgato <http://tickets.kb.elgato.com/?language=en-us> 
and submit a ticket requesting this capability if you want to use their app for 
commercial-free tv on your mobile device. But iTunes just works.

Everything except mp4chaps is done by the mods I added to the Python 
script /Library/Application\ 
Support/ETVComskip/MarkCommercials.app/Contents/Resources/MarkCommercials.py, 
which also includes mods to use the Macports wine package with some nice-ness. 
I'll include the whole script here:

MarkCommercials.py
#!/usr/bin/env python
#
# MarkCommercials.py, EyeTV3 version
#
# Copyright (c) 2008, Jon A. Christopher
# Copyright (c) 2008, TeamSTARS Dick Gordon and Rick Kier
# Licensed under the GNU General Public License, version 2
#
#
# Run comskip on the specified file and replace the markers in the .eyetvr
# file with the new markers.
#
# if no arguments show id, recording name...
#
# If argument is 'all', process all recordings which don't
# have markers and do not match any exclude information from the cfg file.  
#
# If argument is'forceall', process all recordings except those that match
# any exclude information in the cfg file.
#
# Otherwise, argument is treated as an EyeTV recording id, and that
# recording is processed if it is not excluded and it does NOT have any markers.
#
# To be done:
#  exporting - not needed??
#  exclude channels
#   Issue: some channels are 0
#  exclude titles
#  exclude station names
#   Issue: some station names are blank
#  Catch ^c when in c program - comskip throws SIGINT's away?? (see mpeg2dec.c)
#  Catch ^c when in python program - coded
#  EXCLUDED_TITLES and EXCLUDED_CHANNELS are NOT empty lists
#  Handle multiple video pids.
# Added argument for PID - Ben Blake September 2009

# STS
# Added macports wine, nice-ness, mp4chaps chapters headings at commercials

import sys, os, string, os.path
import time
import math
import traceback
from optparse import OptionParser
from ConfigParser import SafeConfigParser 

from os import listdir
from os.path import isfile, join

# Exit Codes
#  Everything worked ok
successExitCode = 0
#  Unable to import appscript
importExitCode = 1
#  Unable to find the recoring specified
noRecordingExitCode = 2
#  Error getting recordings from EyeTV
getRecordingsErrorExitCode = 3
#  Unknown exit code from comskip
unknownComskipErrorExitCode = 4
#  Error when accessing plist file
accessPlistFileErrorExitCode = 5
#  Error when accessing config file
accessConfigFileErrorExitCode = 6
#  Keyboard interrupt (^c)
keyboardInterruptExitCode = 7
#  Unable to communicate with the application
communicationsErrorExitCode = 8

# provided with appscript package
try:
    import aem
    from appscript import *
except ImportError, e:
    sys.stderr.write('Error: importing appscript\n%s\n' % e)
    sys.exit(importExitCode)
    
version = '0.4.0'
# Cfg file definitions and variables
userSectionName = 'User Section'
listDelimiterName = 'LIST_DELIMITER'
excludedTitlesName = 'EXCLUDED_TITLES'
excludedChannelsName = 'EXCLUDED_CHANNELS'
excludedStationNamesName = 'EXCLUDED_STATION_NAMES'
excludedTitles = []
excludedChannels = []
excludedStationNames = []
# General variables
options = None
args = None
recordingCount = 0
comskipLogPathName = None
log = None
growl = None
eyeTV = None
pathToComskip = None
nameOfComskip = 'comskip'
comskipLocations = ['.', r'/Library/Application Support/ETVComskip']

# for debugging. when False, will not actually run comskip, but will do 
everything else
RUN_COMSKIP = True

# Get the executable directory
ETVComskipDir = os.path.abspath(os.path.dirname(__file__))

# Growl support
commercialStart = 'Start'
commercialStartDescription = 'Start detecting commercials'
commercialStop = 'Stop'
commercialStopDescription = 'Stop detecting commercials'
programName = 'Mark Commercials'
allNotificationsList = [commercialStart, commercialStop]

def InitGrowl():
    """docstring for InitGrowl"""
    global growl
    global allNotificationsList
    
    # Should we use growl?
    if not options.growl:
        # No
        return
    growl = app('GrowlHelperApp')  
    enabledNotificationsList = allNotificationsList
    WriteToLog('Registering with growl\n')
    try:
        growl.register(as_application=programName,
                        all_notifications=allNotificationsList,
                        
default_notifications=enabledNotificationsList)
    except Exception, e:
        WriteToLog('Error: registering with growl\n  %s\n' % e)
        growl = None
        
    
def sendGrowlNotification(title, description):
    """docstring for sendGrowlNotification"""
    # Send a Notification...
    if growl is not None:
        WriteToLog('Sending notification via growl\n')
        try:
            growl.notify(with_name=programName,
                        title=title,
                        description=description,
                        application_name=programName)
        except Exception, e:
            WriteToLog('Error: growl notify\n  %s\n' % e)

    
    
# Create the log file
def GetLog(name=None):
    """docstring for GetLog"""
    global log
    global comskipLogPathName
    
    # Should we log?
    if not options.log:
        return

    # Is the log directory created?
    fullPath = os.path.expanduser('~/Library/Logs/ETVComskip')
    if not os.path.isdir(fullPath):
        # No, create it.
        os.mkdir(fullPath)
    # Create the log
    if name is None:
        name = time.strftime('%m-%d-%Y %H-%M-%S')
    comskipLogPathName = os.path.join(fullPath, name + '_comskip.log')
    log = open(os.path.join(fullPath, name + '.log'), 'w')
    
def WriteToLog(message):
    """docstring for WriteToLog"""
    if options.log:
        if type(message) == type(u""):
            message=message.encode("utf-8")
        log.write('%s - %s' % (time.asctime(), message))
        log.flush()

def CheckForApplicationCommunications(retries=3):
    """docstring for CheckForApplicationCommunications"""
    global EyeTV
    
    # launch the application
    EyeTV.launch()
    WriteToLog('Checking communications to %s with %d retries\n' % 
(options.app, retries))
    for attempt in range(retries):
        try:
            # Get the recordings
            EyeTV.recordings.get()
            # It worked - break out of here
            WriteToLog('  Attempt %d worked\n' % (attempt + 1))
            break
        except Exception, e:
            WriteToLog('  Attempt %d failed\n    %s\n' % ((attempt + 
1), e))
            EyeTV = app(options.app)
            time.sleep(0.5)
            continue
    else:
        msg = 'Error: unable to communicate with %s\n' % options.app
        WriteToLog(msg)
        sys.stderr.write(msg)
        sys.exit(communicationsErrorExitCode)
    
def GetRecordings(retries=0):
    """docstring for GetRecordings"""
    global EyeTV
    
    WriteToLog('Getting recordings\n')
    try:
        recordings = EyeTV.recordings.get()
    except Exception, e:
        msg = 'Error: unable to get recordings\n%s\n' % e
        WriteToLog(msg)
        sys.stderr.write(msg)
        sys.exit(getRecordingsErrorExitCode)
    WriteToLog('Recordings: %s\n' % recordings)
    return recordings

# Possibly run comskip and return the name of a plist file with commercial 
markers in it
def GetPlistFile(etvr_file, run_comskip=True):
    
    FileDir = os.path.dirname(etvr_file)
    dir, fil = os.path.split(etvr_file)
    FileRoot, ext = os.path.splitext(fil)

    MpgFile = FileRoot + ".mpg"
    PlistFile = FileRoot + ".edl"

    #cmd = '"/Library/Application 
Support/ETVComskip/Wine.app/Contents/Resources/bin/wine" "/Library/Application 
Support/ETVComskip/comskip/comskip.exe" --ini="/Library/Application 
Support/ETVComskip/comskip/comskip.ini" "%s"' % MpgFile
    # MacPorts 64-bit wine
    #cmd = '"/Applications/Wine.app/Contents/Resources/bin/wine" 
"/Library/Application Support/ETVComskip/comskip/comskip.exe" 
--ini="/Library/Application Support/ETVComskip/comskip/comskip.ini" "%s"' % 
MpgFile
    cmd = '"/opt/local/bin/wine" "/Library/Application 
Support/ETVComskip/comskip/comskip.exe" --ini="/Library/Application 
Support/ETVComskip/comskip/comskip.ini" "%s"' % MpgFile

    if options.pid <> "":
      cmd += " --pid=" + options.pid
    
    outputName = '/dev/null'
    if options.log:
        cmd += ' > %s 2>&1' % comskipLogPathName
    else:
        cmd += ' > %s 2>&1' % '/dev/null'
    if options.verbose:
        cmd += ' --verbose=%d' % options.verbose
    # nice the wine command
    cmd = "/usr/bin/nice -n 14 " + cmd
    WriteToLog('Changing directory to %s\n' % FileDir)
    os.chdir(FileDir)
    if run_comskip:
        # Notify the user
        sendGrowlNotification(commercialStart, commercialStartDescription)
        # TBD stop comskip when ^c happens
        WriteToLog('Running: %s\n' % cmd)
        rc = os.system(cmd)
        # Add the Comskip information as chapters to all m4v files
        mp4chaps_all_m4v(FileDir)
        # Notify the user
        sendGrowlNotification(commercialStop, commercialStopDescription)
        WriteToLog('Return code is: %d, 0x%x\n' % (rc, rc))
        errorCode = (rc >> 8) & 0xff
        WriteToLog('Error code is: %d, 0x%x\n' % (errorCode, errorCode))
        # Error code:
        #   3 = no Video pid found
        #   2 = Can't open the mpeg2 file TBD
        #   1 = Commercials found
        #   0 = Commercials not found
        if errorCode  == 2:
            msg = 'Unable to open mpeg2 file return from "comskip": %d\n' 
% errorCode
            WriteToLog(msg)
            sys.stderr.write(msg)
            return None
        elif errorCode == 3:
            msg = 'No video pid found return from "comskip": %d\n' % 
errorCode
            WriteToLog(msg)
            sys.stderr.write(msg)
            return None
        elif errorCode == 1:
            WriteToLog('Commercials found by comskip\n')
            return PlistFile
        elif errorCode == 0:
            WriteToLog('No commercials found by comskip\n')
            return None
        else:
            msg = 'Error: unknown error code from comskip: %d, assuming 
it worked.\n' % errorCode
            WriteToLog(msg)
            return PlistFile

# return start and ending times for the given line
def TimeChop(line):
    fields = line.split("\t")
    start = (float(fields[0]))
    end = (float(fields[1]))
    return (start,end)

# given a plist file, return a markers array suitable for adding to a recording
def GetMarkersArray(PlistFile):
    try:
        file = open(PlistFile)
        lines = file.readlines()
        file.close()
    except Exception, e:
        msg = 'Error: accessing %s\n%s\n' % (PlistFile, e)
        WriteToLog(msg)
        sys.stderr.write(msg)
        sys.exit(accessPlistFileErrorExitCode)

    WriteToLog('Plist file contents: %s\n' % lines)
    markers=[]
    for line in lines:
        WriteToLog("Processing plist file line: '%s'\n" % line)
        start,end = TimeChop(line)
        WriteToLog('Adding marker, start: %d, end: %d\n' % (start, end))
        marker = {}
        marker['position'] = start
        marker[aem.AEType('leng')] = end - start
        markers.append(marker)

    return markers

def ProcessRecording(recording, run_comskip):
    
    global recordingCount 
    
    channel = recording.channel_number()
    title = recording.title()
    stationName = recording.station_name()

    recordingCount += 1
    msg = '%2d. Processing "%s" on [%s] channel [%s]...' % (recordingCount, 
title, stationName, channel)
    WriteToLog('%s\n' % msg)
    print msg.encode("utf-8")

    # Should excludes be allowed?
    if not options.noexclude:
        # Yes, Is this one allowed?
        #  User can exclude titles, channels and station names
        # Channel
        msg='  Channel: %s' % channel
        print msg.encode("utf-8"),
        if str(channel) in excludedChannels:
            WriteToLog('Skipped due to channel match\n')
            print ' skipped'
            return
        print ', not skipped'
        # Title
        msg='  Title: %s' % title
        print msg.encode("utf-8"),
        if title in excludedTitles:
            WriteToLog('Skipped due to title match\n')
            print ' skipped'
            return
        print ', not skipped'
        # Station name
        msg='  Station name: %s' % stationName
        print msg.encode("utf-8"),
        if stationName in excludedStationNames:
            WriteToLog('Skipped due to station name match\n')
            print ' skipped'
            return
        print ', not skipped'

    # Get its path
    etvr_path = recording.location.get().path
    WriteToLog('Path to recording is %s\n' % etvr_path)

    # get the plist file for this recording, and make a markers array for it
    Plist = GetPlistFile(etvr_path, run_comskip)
    # Did we get a plist file?
    if Plist is not None:
        # Yes, convert it.
        markers = GetMarkersArray(Plist)    
        # and finally, set them
        WriteToLog('Setting markers on recording\n')
        markers_string = str(markers)
        WriteToLog('Adding marker: %s\n' % (markers_string))
        recording.markers.set(markers)

mp4chaps = '/opt/local/bin/mp4chaps'

def sec2hhmmss(secs):
    """Convert seconds to string HH:MM:SS.SSS format."""
    rem = secs/3600
    hh = int(rem)
    rem = (rem-hh)*60
    mm = int(rem)
    rem = (rem-mm)*60
    ss = int(rem)
    rem = (rem-ss)
    rem = '%.3f' % rem  # millisecond precision
    rem = rem.replace('0.','.')
    return '%02d:%02d:%02d%s' % (hh,mm,ss,rem)

def split_whitespace_nolibs(str):
    """Split a string by whitespace without using re or shlex libraries.."""
    strs = filter(None,str.split('\t'))
    strs = map(lambda s: s.split(' '),strs)
    strs = filter(None,[item for sublist in strs for item in sublist])
    return strs

def edl2mp4chaps(edl_file,file):
    """Convert an edl file into an mp4chaps file."""
    txt_file = file.replace('.m4v','.chapters.txt')
    ftxt = open(txt_file,'w')

    comskipno = 0
    comskipchapno = 0
    lines = [line.strip() for line in open(edl_file)]
    for line in lines:
        times = map(float,split_whitespace_nolibs(line))
        if (comskipno == 0 and times[0] != 0.0):
            comskipno += 1
        if (len(times) < 2 or times[2] == 0.0):
            if (times[0] != 0.0):
                ftxt.write('%s Chapter %d End\n' % 
(sec2hhmmss(times[0]),comskipno))
            else:
                ftxt.write('00:00:00.000 Beginning\n')
            comskipno += 1
            ftxt.write('%s Chapter %d Start\n' % 
(sec2hhmmss(times[1]),comskipno))
        else:
            # never seen this case, but here for logical consistency
            comskipchapno += 1
            if (times[0] != 0.0):
                ftxt.write('%s Chapter %d Start\n' % 
(sec2hhmmss(times[0]),comskipchapno))
            else:
                ftxt.write('00:00:00.000 Beginning\n')
            ftxt.write('%s Chapter %d End\n' % 
(sec2hhmmss(times[1]),comskipchapno))
    ftxt.close()
    return

def mp4chaps_all_m4v(dir):
    """Apply an edl file's entries to all m4v files in a directory."""
    os.chdir(dir)
    edl_file = ""
    files = [ file for file in listdir(dir) if isfile(join(dir,file)) ]
    for file in files:
        if file.find('.edl') != -1:
            edl_file = file
            break
    if edl_file != "" and os.path.isfile(mp4chaps):
        for file in files:
            if file.find('.m4v') != -1:
                # remove all chapters
                cmd = mp4chaps + ' -r ' + file + ' > /dev/null 2>&1'
                rc = os.system(cmd)
                # create chapter file
                edl2mp4chaps(edl_file,file)
                # import chapters
                cmd = mp4chaps + ' -i ' + file + ' > /dev/null 2>&1'
                rc = os.system(cmd)
    return

def main():
    global options
    global args
    global excludedTitles
    global excludedChannels
    global excludedStationNames
    global log
    global EyeTV
    
    # Do the options
    usage = "usage: %prog [options] [RECORDING-ID | 'all' | 'forceall']"
    parser = OptionParser(usage=usage, version=version)
    parser.add_option("--noexclude",
                    action="store_true", dest="noexclude", 
default=False,
                    help="Do NOT exclude recordings specified in cfg 
file, default=%default")
    parser.add_option("--force",
                    action="store_true", dest="force", default=False,
                    help="Force commercial marking on specified 
RECORDING-ID. Allows marking when markers already exist, default=%default")
    parser.add_option("--growl",
                    action="store_true", dest="growl", default=False,
                    help="Enable growl notification, 
default=%default")
    parser.add_option("--log",
                    action="store_true", dest="log", default=False,
                    help="Enable logging, default=%default")
    parser.add_option("--app",
                    dest="app", default='EyeTV',
                    help="Specify EyeTV application name, 
default=%default")
    parser.add_option("--verbose",
                    type='int',
                    dest="verbose", default=0,
                    help="Verbosity level, 0-10, default=%default")
    parser.add_option("--pid",
                    dest="pid", default='',
                    help="Specify the Video PID, default=%default")
    (options, args) = parser.parse_args()

    if len(args):
        name = args[0]
    else:
        name = None
    GetLog(name=name)
    WriteToLog('%s, %s starting\n' % (sys.argv[0], version))
    WriteToLog('Command line: %s\n' % sys.argv)
    WriteToLog('Application name: %s\n' % options.app)
    print '\t\t%s\t%s\n' % 
(os.path.splitext(os.path.basename(sys.argv[0]))[0], version)
    
    # Get the app
    EyeTV = app(options.app)

    # Use growl notifications
    InitGrowl()
    # Get our configuration file & data
    configInput = SafeConfigParser()
    try:
        cfgFilesRead = 
configInput.read([os.path.join(os.path.dirname(sys.argv[0]), 
'MarkCommercials.cfg'),
                                        
 os.path.expanduser('~/Library/Application 
Support/ETVComskip/MarkCommercials.cfg')])
    except Exception, e:
        msg = 'Error: reading configuration file\n%s\n' % e
        WriteToLog(msg)
        sys.stderr.write(msg)
        sys.exit(accessConfigFileErrorExitCode)
    WriteToLog('Cfg files read: %s\n' % cfgFilesRead)
    if cfgFilesRead:
        # Process the user's section
        if configInput.has_section(userSectionName):
            # Process the user's section
            #   Get the delimiter
            listDelimiter = configInput.get(userSectionName, 
listDelimiterName)
            if configInput.has_option(userSectionName, 
excludedChannelsName):
                for channel in configInput.get(userSectionName, 
excludedChannelsName).split(listDelimiter):
                    excludedChannels.append(channel)
            if configInput.has_option(userSectionName, 
excludedTitlesName):
                for title in configInput.get(userSectionName, 
excludedTitlesName).split(listDelimiter):
                    excludedTitles.append(title)
            if configInput.has_option(userSectionName, 
excludedStationNamesName):
                for title in configInput.get(userSectionName, 
excludedStationNamesName).split(listDelimiter):
                    excludedStationNames.append(title)
        WriteToLog('List Delimiter: %s\n' % listDelimiter)
        WriteToLog('Excluded Channels: %s\n' % excludedChannels)
        WriteToLog('Excluded Titles: %s\n' % excludedTitles)
        WriteToLog('Excluded Station names: %s\n' % excludedStationNames)
         
    # Test communications with application
    CheckForApplicationCommunications()

    # Get the location of the commercial skipper
    #findComskip()

    # Show the IDs and program names when there are no arguments
    #    replace any non ascii characters with ?
    if len(args) == 0:
        for rec in GetRecordings():
            programName = 
os.path.split(os.path.splitext(os.path.dirname(rec.location.get().path))[0])[1]
            outputName = ''
            for char in programName:
                # Insure the character is ascii
                if ord(char) <= 127:
                    outputName += char
                else:
                    outputName += '?'
            msg = '  %d = [%s], [%s], [%s]' % (rec.unique_ID.get(), 
outputName, rec.channel_number(), rec.station_name())
            WriteToLog('%s\n' % msg)
            print msg.encode("utf-8")
        return successExitCode

    if args[0] == "all" or args[0] == "forceall":
        # batch mode, process all recordings without markers
        recs = GetRecordings()
        for rec in recs:
            markerCount = len(rec.markers.get())
            WriteToLog('Marker count: %d\n' % markerCount)
            if markerCount == 0 or args[0] == "forceall":
                ProcessRecording(rec, RUN_COMSKIP)
    else:
        # triggered mode, process just the listed recording
        recs = GetRecordings()
        recordingRequested = int(args[0])
        for rec in recs:
            if rec.unique_ID() == recordingRequested:
                WriteToLog('Found recording %d\n' % recordingRequested)
                break
        else:
            msg = 'Error: unable to find recording %d\n' % 
recordingRequested
            WriteToLog(msg)
            sys.stderr.write(msg)
            sys.exit(noRecordingExitCode)
        markerCount = len(rec.markers.get())
        WriteToLog('Marker count: %d\n' % markerCount)
        # Recording already have markers?
        if markerCount == 0:
            # No
            try:
                ProcessRecording(rec, RUN_COMSKIP)
            except Exception,e:
                exc_type, exc_value, exc_traceback = sys.exc_info()
                WriteToLog(repr(traceback.format_exception(exc_type, 
exc_value,exc_traceback)))

        # Is the recording already marked but the user wants it done again?
        elif markerCount != 0 and options.force:
            # Yes
            WriteToLog('Recording already marked - use forcing with 
--force option\n')
            ProcessRecording(rec, RUN_COMSKIP)
        else:
            # Recording already maked and user doesn't want it done again
            msg = 'Recording previously marked'
            WriteToLog('%s\n' % msg)
            print '  ',
            print msg.encode("utf-8")
    return successExitCode

if __name__ == '__main__':
    '''
    Call main
    '''
    try:
        exitStatus = main()
    except KeyboardInterrupt, e:
        msg = 'Error: %s\n' % e
        WriteToLog(msg)
        sys.stderr.write(msg)
        sys.exit(keyboardInterruptExitCode)
        pass
    WriteToLog('Exiting\n')
    sys.exit(exitStatus)

Original issue reported on code.google.com by steve.t....@gmail.com on 9 Oct 2012 at 12:18