Streaming a video from Python memory?
#1
Can anyone help me? 
I'm trying to make Kodi play an MP4 file that was loaded into a Python string, that is, making Kodi stream from memory. My script uses threading.Thread to create a server socket at localhost and tries to feed the video file content to Kodi's player. 

I managed to make Kodi connect to the socket and I'm able to read the requests, but it doesn't start streaming. It fails to find the MIME type of the file, even though I'm clearly putting it in the headers.  
It did work on a rare occasion, so I know that such a thing can be done. 
I'm new to server programming and thought this would be simple to do. 

The MP4 in question is a small clip from Big Buck Bunny that I downloaded to a file so it could be loaded from the script: https://www.w3schools.com/html/mov_bbb.mp4
Here's my code as "default.py" from the add-on. I can clean it up later, I just wanted to get it working first: 
python:
# -*- coding: UTF-8 -*-
import sys
import socket
from select import select
from threading import Thread

import xbmc
from xbmcaddon import Addon


ADDON = Addon()


# Log a LOGNOTICE-level message.
def xbmcLog(*args):
    xbmc.log('MemoryPlayer > ' + ' '.join((var if isinstance(var, str) else repr(var)) for var in args), xbmc.LOGNOTICE)


class MemoryPlayer(xbmc.Player):
    def __init__ (self):
        xbmc.Player.__init__(self)


    def serverPlay(self, data, host, port, listitem=None):
        self.keepServing = True

        xbmcLog('\n\n\n=======\nserverPlay()\n=======')
        
        self.t = Thread(target=MemoryPlayer.handleServer, args=(self, host, port, data, 'video/mp4'))
        self.play('http://%s:%i' % (host, port), listitem=listitem)
        self.t.start()


    def onPlayBackStarted(self):
        xbmcLog('onPlaybackStarted()')


    def onPlayBackStopped(self):
        self.stopServer()
        xbmcLog('onPlaybackStopped()')


    def onPlayBackEnded(self):
        self.stopServer()
        xbmcLog('onPlaybackEnded()')


    @staticmethod
    def makeHeaders(statusMessage, rangeStart, fileSize, mimeType):
        return (
            'HTTP/1.0 %s\r\n'
            'Accept-Ranges: bytes\r\n'
            'Content-Range: bytes %i-%i/%i\r\n'
            'Content-Type: %s\r\n'
        ) % (statusMessage, rangeStart, fileSize-1, fileSize, mimeType)


    @staticmethod
    def handleServer(self, host, port, data, mimeType):
        serverSocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        serverSocket.bind((host, port))
        serverSocket.listen(1)
        
        SERVER_TUPLE = (serverSocket,)
        EMPTY_TUPLE = ( )
        
        fileSize = len(data)

        while self.keepServing:
            # Blocking call.
            readSockets = select(SERVER_TUPLE, EMPTY_TUPLE, EMPTY_TUPLE)[0]
            if not readSockets:
                xbmcLog('SELECT: empty readSockets')
                continue

            if not self.keepServing:
                break

            # Blocking call.
            connection, sockname = serverSocket.accept()
            xbmcLog('CONNECTION:', sockname)
        
            if not self.keepServing:
                break
                
            try:
                # recv() can possibly raise an Exception when the other peer disconnects.
                input = connection.recv(4096) # 4096 bytes to give enough room for Kodi's standard requests.
                xbmcLog('INPUT:', input, '(%i)' % len(input))
                
                # We can take shortcuts since we know we're only dealing with specific Kodi requests.
                if input.startswith('GET'):
                    # Range request. It seems Kodi always sends only the range start (bytes=START-) instead of
                    # the full range (bytes=START-END).
                    dataIndex = int(input.split('bytes=', 1)[1].split('-', 1)[0])
                    xbmcLog('RANGE-START:', dataIndex)
                    # Assume that the requested range is within bounds.
                    #dataIndex = min(fileSize, max(0, dataIndex))
                    output = (
                        MemoryPlayer.makeHeaders('206 Partial Content', dataIndex, fileSize, mimeType)
                        + '\r\n'
                        + data[dataIndex:]
                    )
                else:
                    # HEAD request to probe the MIME type (and maybe length?).
                    output = MemoryPlayer.makeHeaders('200 OK', 0, fileSize, mimeType)

                try:
                    connection.sendall(output)
                except Exception as e:
                    xbmcLog('SENDALL:', e)
            except Exception as e:
                xbmcLog('RECV:', e)
                self.keepServing = False

        # Shutdown the server socket.
        try:
            serverSocket.shutdown(socket.SHUT_RDWR)
            serverSocket.close()
            xbmcLog('CLOSE -----')
        except Exception as e:
            xbmcLog('SHUTDOWN:', e)
        self.keepServing = False


videoPath = ADDON.getAddonInfo('path').replace('\\', '/') + '/video.mp4'
with open(videoPath, 'rb') as f:
    data = f.read()

# Change 'n' each time you run the add-on to use a different port.
n = 12

PORT = 11100 + n
MemoryPlayer().serverPlay(data, 'localhost', PORT)
Reply
#2
Try adding an extra '\r\n' after your headers response,
Quote:The end of the header section is indicated by an empty field line, resulting in the transmission of two consecutive CR-LF pairs. 

Eg:

Code:
'Content-Type: %s\r\n'
'\r\n'
Reply
#3
No need to do it on low level. Kodi includes Bottle library as script.module.bottle that makes things much simpler. This is a very basic example:
python:

# coding: utf-8

import io
from wsgiref.simple_server import make_server

from bottle import app, route, request, HTTPResponse
import xbmc


@route('/stream')
def stream():
payload = io.BytesIO(b'video file byte content')
range_header = request.get_header('Range')
# Analyze range_header then do payload.seek() to the necessary position
response = HTTPResponse(body=payload)
response.add_header('Content-Type', 'video/mp4')
response.add_header('Accept-Ranges', 'bytes')
# Add the necessary Content-Range and Content-Length headers
return response


httpd = make_server('127.0.0.1', 8000, app)
monitor = xbmc.Monitor()
while not monitor.abortRequested():
httpd.handle_request()


Note that the server is single threaded and cannot process more than 1 request at a time. With multiple clients things are a bit more complex, but not that much.
Reply
#4
Thanks a lot for the help guys. I got it to at least play. Seeking is not working yet because I'm doing some experiments on how to use select.select() to both read and respond.

The reason I'm trying to do it low-level like this with sockets is to both learn how this works (lol), and also because I'm thinking it'd be the fastest way: with Kodi it seems we only have to handle its HEADs to get the Content-Type, and then its GETs for the partial content, so we can optimize away the other types of requests (there won't be POSTs or the like). And you can even do a listItem.setMimeType(...) and listItem.setContentLookup(False) since we already know the MIME type of the file, and then when playing that ListItem with xbmc.Player().play() it avoids the HEADs altogether and goes straight to the byte range GET requests.

@Roman_V_M I saw an add-on you made years ago, it's what inspired me to try this memory streaming. Thanks the example.

If I make any breakthroughs I'll report back in here.
Reply

Logout Mark Read Team Forum Stats Members Help
Streaming a video from Python memory?0