commit ed587e94d3b048968bce01e25bed4d1d66ef7838 Author: John-Mark Gurney Date: Wed Feb 8 23:57:11 2006 -0800 import a python media server based upon twisted among other things.. from: http://frungy.org/~tpot/weblog/2006/01/19#python-upnp-abandoned [git-p4: depot-paths = "//depot/": change = 717] diff --git a/ConnectionManager.py b/ConnectionManager.py new file mode 100644 index 0000000..1c191d9 --- /dev/null +++ b/ConnectionManager.py @@ -0,0 +1,19 @@ +# Licensed under the MIT license +# http://opensource.org/licenses/mit-license.php + +# (c) 2005, Tim Potter + +# Connection Manager service + +from twisted.python import log +from twisted.web import resource, static, soap +from upnp import UPnPPublisher + +class ConnectionManagerControl(UPnPPublisher): + pass + +class ConnectionManagerServer(resource.Resource): + def __init__(self): + resource.Resource.__init__(self) + self.putChild('scpd.xml', static.File('connection-manager-scpd.xml')) + self.putChild('control', ConnectionManagerControl()) diff --git a/ContentDirectory.py b/ContentDirectory.py new file mode 100644 index 0000000..3e13a43 --- /dev/null +++ b/ContentDirectory.py @@ -0,0 +1,202 @@ +# Licensed under the MIT license +# http://opensource.org/licenses/mit-license.php + +# (c) 2005, Tim Potter + +# +# This module implements the Content Directory Service (CDS) service +# type as documented in the ContentDirectory:1 Service Template +# Version 1.01 +# + +# +# TODO: Figure out a nicer pattern for debugging soap server calls as +# twisted swallows the tracebacks. At the moment I'm going: +# +# try: +# .... +# except: +# traceback.print_exc(file = log.logfile) +# + +from twisted.python import log +from twisted.web import resource, static + +from elementtree.ElementTree import Element, SubElement, tostring +from upnp import UPnPPublisher +from DIDLLite import DIDLElement, Container, Movie, Resource, MusicTrack + +import traceback +from urllib import quote + +class ContentDirectoryControl(UPnPPublisher): + """This class implements the CDS actions over SOAP.""" + + # Required actions + + def soap_GetSearchCapabilities(self, *args, **kwargs): + """Return the searching capabilities supported by the device.""" + + log.msg('GetSearchCapabilities()') + + def soap_GetSortCapabilities(self, *args, **kwargs): + """Return the CSV list of meta-data tags that can be used in + sortCriteria.""" + + log.msg('GetSortCapabilities()') + + def soap_GetSystemUpdateID(self, *args, **kwargs): + """Return the current value of state variable SystemUpdateID.""" + + log.msg('GetSystemUpdateID()') + + BrowseFlags = ('BrowseMetaData', 'BrowseDirectChildren') + + def soap_Browse(self, *args): + """Incrementally browse the native heirachy of the Content + Directory objects exposed by the Content Directory Service.""" + + (ObjectID, BrowseFlag, Filter, StartingIndex, RequestedCount, + SortCriteria) = args + + log.msg('Browse(ObjectID=%s, BrowseFlags=%s, Filter=%s, ' + 'StartingIndex=%s RequestedCount=%s SortCriteria=%s)' % + (`ObjectID`, `BrowseFlag`, `Filter`, `StartingIndex`, + `RequestedCount`, `SortCriteria`)) + + didl = DIDLElement() + + try: + + if ObjectID == '0\\Music\\': + + c = Container('0\\Music\\Spotty\\', '0\\Music\\', 'Spotty') + c.childCount = 6 + c.searchable = 0 + didl.addItem(c) + + c = Container('0\\Music\\Foot\\', '0\\Music\\', 'Foot') + c.childCount = 1 + c.searchable = 0 + didl.addItem(c) + + + if ObjectID == '0\\Music\\Spotty\\': + + m = MusicTrack('0\\Music\\media\\foo.mp3', '0\\Music\\', 'foo.mp3') + + m.res = Resource('http://192.168.1.105:8080/media/foo.mp3', 'http-get:*:audio/mpeg:*') + m.res.size = 1234 + m.res.bitrate = 256 + m.genre = 'Others' + m.artist = 'Others' + m.album = 'Others' + + didl.addItem(m) + + result = {'BrowseResponse': {'Result': didl.toString() , + 'NumberReturned': didl.numItems(), + 'TotalMatches': didl.numItems(), + 'UpdateID': 0}} + + except: + traceback.print_exc(file=log.logfile) + + log.msg('Returning: %s' % result) + + return result + + # Optional actions + + def soap_Search(self, *args, **kwargs): + """Search for objects that match some search criteria.""" + + (ContainerID, SearchCriteria, Filter, StartingIndex, + RequestedCount, SortCriteria) = args + + log.msg('Search(ContainerID=%s, SearchCriteria=%s, Filter=%s, ' \ + 'StartingIndex=%s, RequestedCount=%s, SortCriteria=%s)' % + (`ContainerID`, `SearchCriteria`, `Filter`, + `StartingIndex`, `RequestedCount`, `SortCriteria`)) + + def soap_CreateObject(self, *args, **kwargs): + """Create a new object.""" + + (ContainerID, Elements) = args + + log.msg('CreateObject(ContainerID=%s, Elements=%s)' % + (`ContainerID`, `Elements`)) + + def soap_DestroyObject(self, *args, **kwargs): + """Destroy the specified object.""" + + (ObjectID) = args + + log.msg('DestroyObject(ObjectID=%s)' % `ObjectID`) + + def soap_UpdateObject(self, *args, **kwargs): + """Modify, delete or insert object metadata.""" + + (ObjectID, CurrentTagValue, NewTagValue) = args + + log.msg('UpdateObject(ObjectID=%s, CurrentTagValue=%s, ' \ + 'NewTagValue=%s)' % (`ObjectID`, `CurrentTagValue`, + `NewTagValue`)) + + def soap_ImportResource(self, *args, **kwargs): + """Transfer a file from a remote source to a local + destination in the Content Directory Service.""" + + (SourceURI, DestinationURI) = args + + log.msg('ImportResource(SourceURI=%s, DestinationURI=%s)' % + (`SourceURI`, `DestinationURI`)) + + def soap_ExportResource(self, *args, **kwargs): + """Transfer a file from a local source to a remote + destination.""" + + (SourceURI, DestinationURI) = args + + log.msg('ExportResource(SourceURI=%s, DestinationURI=%s)' % + (`SourceURI`, `DestinationURI`)) + + def soap_StopTransferResource(self, *args, **kwargs): + """Stop a file transfer initiated by ImportResource or + ExportResource.""" + + (TransferID) = args + + log.msg('StopTransferResource(TransferID=%s)' % TransferID) + + def soap_GetTransferProgress(self, *args, **kwargs): + """Query the progress of a file transfer initiated by + an ImportResource or ExportResource action.""" + + (TransferID, TransferStatus, TransferLength, TransferTotal) = args + + log.msg('GetTransferProgress(TransferID=%s, TransferStatus=%s, ' \ + 'TransferLength=%s, TransferTotal=%s)' % + (`TransferId`, `TransferStatus`, `TransferLength`, + `TransferTotal`)) + + def soap_DeleteResource(self, *args, **kwargs): + """Delete a specified resource.""" + + (ResourceURI) = args + + log.msg('DeleteResource(ResourceURI=%s)' % `ResourceURI`) + + def soap_CreateReference(self, *args, **kwargs): + """Create a reference to an existing object.""" + + (ContainerID, ObjectID) = args + + log.msg('CreateReference(ContainerID=%s, ObjectID=%s)' % + (`ContainerID`, `ObjectID`)) + +class ContentDirectoryServer(resource.Resource): + def __init__(self): + resource.Resource.__init__(self) + self.putChild('scpd.xml', static.File('content-directory-scpd.xml')) + self.putChild('control', ContentDirectoryControl()) diff --git a/DIDLLite.py b/DIDLLite.py new file mode 100644 index 0000000..2884b23 --- /dev/null +++ b/DIDLLite.py @@ -0,0 +1,304 @@ +# Licensed under the MIT license +# http://opensource.org/licenses/mit-license.php + +# (c) 2005, Tim Potter + +from elementtree.ElementTree import Element, SubElement, tostring, _ElementInterface + +class Resource: + """An object representing a resource.""" + + def __init__(self, data, protocolInfo): + self.data = data + self.protocolInfo = protocolInfo + self.bitrate = None + self.size = None + + def toElement(self): + + root = Element('res') + root.attrib['protocolInfo'] = self.protocolInfo + root.text = self.data + + if self.bitrate is not None: + root.attrib['bitrate'] = str(self.bitrate) + + if self.size is not None: + root.attrib['size'] = str(self.size) + + return root + +class Object: + """The root class of the entire content directory class heirachy.""" + + klass = 'object' + creator = None + res = None + writeStatus = None + + def __init__(self, id, parentID, title, restricted = False, + creator = None): + + self.id = id + self.parentID = parentID + self.title = title + self.creator = creator + + if restricted: + self.restricted = '1' + else: + self.restricted = '0' + + def toElement(self): + + root = Element(self.elementName) + + root.attrib['id'] = self.id + root.attrib['parentID'] = self.parentID + SubElement(root, 'dc:title').text = self.title + SubElement(root, 'upnp:class').text = self.klass + + root.attrib['restricted'] = self.restricted + + if self.creator is not None: + SubElement(root, 'dc:creator').text = self.creator + + if self.res is not None: + root.append(self.res.toElement()) + + if self.writeStatus is not None: + SubElement(root, 'upnp:writeStatus').text = self.writeStatus + + return root + + def toString(self): + return tostring(self.toElement()) + +class Item(Object): + """A class used to represent atomic (non-container) content + objects.""" + + klass = Object.klass + '.item' + elementName = 'item' + refID = None + + def toElement(self): + + root = Object.toElement(self) + + if self.refID is not None: + SubElement(root, 'refID').text = self.refID + + return root + +class ImageItem(Item): + klass = Item.klass + '.imageItem' + +class Photo(ImageItem): + klass = ImageItem.klass + '.photo' + +class AudioItem(Item): + """A piece of content that when rendered generates some audio.""" + + klass = Item.klass + '.audioItem' + + genre = None + description = None + longDescription = None + publisher = None + language = None + relation = None + rights = None + + def toElement(self): + + root = Item.toElement(self) + + if self.genre is not None: + SubElement(root, 'upnp:genre').text = self.genre + + if self.description is not None: + SubElement(root, 'dc:description').text = self.description + + if self.longDescription is not None: + SubElement(root, 'upnp:longDescription').text = \ + self.longDescription + + if self.publisher is not None: + SubElement(root, 'dc:publisher').text = self.publisher + + if self.language is not None: + SubElement(root, 'dc:language').text = self.language + + if self.relation is not None: + SubElement(root, 'dc:relation').text = self.relation + + if self.rights is not None: + SubElement(root, 'dc:rights').text = self.rights + + return root + +class MusicTrack(AudioItem): + """A discrete piece of audio that should be interpreted as music.""" + + klass = AudioItem.klass + '.musicTrack' + + artist = None + album = None + originalTrackNumber = None + playlist = None + storageMedium = None + contributor = None + date = None + + def toElement(self): + + root = AudioItem.toElement(self) + + if self.artist is not None: + SubElement(root, 'upnp:artist').text = self.artist + + if self.album is not None: + SubElement(root, 'upnp:album').text = self.album + + if self.originalTrackNumber is not None: + SubElement(root, 'upnp:originalTrackNumber').text = \ + self.originalTrackNumber + + if self.playlist is not None: + SubElement(root, 'upnp:playlist').text = self.playlist + + if self.storageMedium is not None: + SubElement(root, 'upnp:storageMedium').text = self.storageMedium + + if self.contributor is not None: + SubElement(root, 'dc:contributor').text = self.contributor + + if self.date is not None: + SubElement(root, 'dc:date').text = self.date + + return root + +class AudioBroadcast(AudioItem): + klass = AudioItem.klass + '.audioBroadcast' + +class AudioBook(AudioItem): + klass = AudioItem.klass + '.audioBook' + +class VideoItem(Item): + klass = Item.klass + '.videoItem' + +class Movie(VideoItem): + klass = VideoItem.klass + '.movie' + +class VideoBroadcast(VideoItem): + klass = VideoItem.klass + '.videoBroadcast' + +class MusicVideoClip(VideoItem): + klass = VideoItem.klass + '.musicVideoClip' + +class PlaylistItem(Item): + klass = Item.klass + '.playlistItem' + +class TextItem(Item): + klass = Item.klass + '.textItem' + +class Container(Object): + """An object that can contain other objects.""" + + klass = Object.klass + '.container' + + elementName = 'container' + childCount = 0 + createClass = None + searchClass = None + searchable = None + + def __init__(self, id, parentID, title, restricted = 0, creator = None): + Object.__init__(self, id, parentID, title, restricted, creator) + + def toElement(self): + + root = Object.toElement(self) + + root.attrib['childCount'] = str(self.childCount) + + if self.createClass is not None: + SubElement(root, 'upnp:createclass').text = self.createClass + + if self.searchClass is not None: + if not isinstance(self.searchClass, (list, tuple)): + self.searchClass = ['searchClass'] + for i in searchClass: + SubElement(root, 'upnp:searchclass').text = i + + if self.searchable is not None: + root.attrib['searchable'] = str(self.searchable) + + return root + +class Person(Container): + klass = Container.klass + '.person' + +class MusicArtist(Person): + klass = Person.klass + '.musicArtist' + +class PlaylistContainer(Container): + klass = Container.klass + '.playlistContainer' + +class Album(Container): + klass = Container.klass + '.album' + +class MusicAlbum(Album): + klass = Album.klass + '.musicAlbum' + +class PhotoAlbum(Album): + klass = Album.klass + '.photoAlbum' + +class Genre(Container): + klass = Container.klass + '.genre' + +class MusicGenre(Genre): + klass = Genre.klass + '.musicGenre' + +class MovieGenre(Genre): + klass = Genre.klass + '.movieGenre' + +class StorageSystem(Container): + klass = Container.klass + '.storageSystem' + +class StorageVolume(Container): + klass = Container.klass + '.storageVolume' + +class StorageFolder(Container): + klass = Container.klass + '.storageFolder' + +class DIDLElement(_ElementInterface): + def __init__(self): + _ElementInterface.__init__(self, 'DIDL-Lite', {}) + self.attrib['xmlns'] = 'urn:schemas-upnp-org:metadata-1-0/DIDL-Lite' + self.attrib['xmlns:dc'] = 'http://purl.org/dc/elements/1.1/' + self.attrib['xmlns:upnp'] = 'urn:schemas-upnp-org:metadata-1-0/upnp' + + def addContainer(self, id, parentID, title, restricted = False): + e = Container(id, parentID, title, restricted, creator = '') + self.append(e.toElement()) + + def addItem(self, item): + self.append(item.toElement()) + + def numItems(self): + return len(self) + + def toString(self): + return tostring(self) + +if __name__ == '__main__': + + root = DIDLElement() + root.addContainer('0\Movie\\', '0\\', 'Movie') + root.addContainer('0\Music\\', '0\\', 'Music') + root.addContainer('0\Photo\\', '0\\', 'Photo') + root.addContainer('0\OnlineMedia\\', '0\\', 'OnlineMedia') + + print tostring(root) diff --git a/MediaServer.py b/MediaServer.py new file mode 100644 index 0000000..63724d0 --- /dev/null +++ b/MediaServer.py @@ -0,0 +1,9 @@ +# Licensed under the MIT license +# http://opensource.org/licenses/mit-license.php + +# (c) 2005, Tim Potter + +from twisted.web import static + +class MediaServer(static.File): + pass diff --git a/README b/README new file mode 100644 index 0000000..9a3e8d4 --- /dev/null +++ b/README @@ -0,0 +1,8 @@ +This code has been abandoned, but released in the hope that it might +be useful for someone. It is licensed under the MIT license at: + +http://opensource.org/licenses/mit-license.php + + +Tim Potter +2006-01-19 diff --git a/SSDP.py b/SSDP.py new file mode 100644 index 0000000..f1ad76b --- /dev/null +++ b/SSDP.py @@ -0,0 +1,143 @@ +# Licensed under the MIT license +# http://opensource.org/licenses/mit-license.php + +# (c) 2005, Tim Potter + +# +# Implementation of SSDP server under Twisted Python. +# + +import string + +from twisted.python import log +from twisted.internet.protocol import DatagramProtocol + +# TODO: Is there a better way of hooking the SSDPServer into a reactor +# without having to know the default SSDP port and multicast address? +# There must be a twisted idiom for doing this. + +SSDP_PORT = 1900 +SSDP_ADDR = '239.255.255.250' + +# TODO: Break out into a HTTPOverUDP class and implement +# process_SEARCH(), process_NOTIFY() methods. Create UPNP specific +# class to handle services etc. + +class SSDPServer(DatagramProtocol): + """A class implementing a SSDP server. The notifyReceived and + searchReceived methods are called when the appropriate type of + datagram is received by the server.""" + + elements = {} + known = {} + + def datagramReceived(self, data, (host, port)): + """Handle a received multicast datagram.""" + + # Break up message in to command and headers + + data = string.replace(data, '\r', '') + data = string.replace(data, ': ', ':') + lines = string.split(data, '\n') + lines = filter(lambda x: len(x) > 0, lines) + cmd = string.split(lines[0], ' ') + + headers = dict([string.split(x, ':', 1) for x in lines[1:]]) + + # TODO: datagram may contain a payload, i.e Content-Length + # header is > 0. Maybe use some twisted HTTP object to do the + # parsing for us. + + # SSDP discovery + + if cmd[0] == 'M-SEARCH' and cmd[1] == '*': + self.discoveryRequest(headers, (host, port)) + + # SSDP presence + + if cmd[0] == 'NOTIFY' and cmd[1] == '*': + self.notifyReceived(headers, (host, port)) + + def discoveryRequest(self, headers, (host, port)): + """Process a discovery request. The response must be sent to + the address specified by (host, port).""" + + log.msg('Discovery request for %s' % headers['ST']) + + # Do we know about this service? + + if not self.known.has_key(headers['ST']): + return + + # Generate a response + + response = [] + response.append('HTTP/1.1 200 OK') + + for k, v in self.known[headers['ST']].items(): + response.append('%s: %s' % (k, v)) + + log.msg('responding with: %s' % response) + + self.transport.write( + string.join(response, '\r\n') + '\r\n\r\n', (host, port)) + + def register(self, usn, st, location): + """Register a service or device that this SSDP server will + respond to.""" + + log.msg('Registering %s' % st) + + self.known[st] = {} + self.known[st]['USN'] = usn + self.known[st]['LOCATION'] = location + self.known[st]['ST'] = st + self.known[st]['EXT'] = '' + self.known[st]['SERVER'] = 'Twisted, UPnP/1.0, python-upnp' + self.known[st]['CACHE-CONTROL'] = 'max-age=1800' + + def notifyReceived(self, headers, (host, port)): + """Process a presence announcement. We just remember the + details of the SSDP service announced.""" + + if headers['NTS'] == 'ssdp:alive': + + if not self.elements.has_key(headers['NT']): + + # Register device/service + + self.elements[headers['NT']] = {} + self.elements[headers['NT']]['USN'] = headers['USN'] + self.elements[headers['NT']]['host'] = (host, port) + + log.msg('Detected presence for %s' % headers['NT']) + + elif headers['NTS'] == 'ssdp:byebye': + + if self.elements.has_key(headers['NT']): + + # Unregister device/service + + del(self.elements[headers['NT']]) + + log.msg('Detected absence for %s' % headers['NT']) + + else: + log.msg('Unknown subtype %s for notification type %s' % + (headers['NTS'], headers['NT'])) + + def findService(self, name): + """Return information about a service registered over SSDP.""" + + # TODO: Implement me. + + # TODO: Send out a discovery request if we haven't registered + # a presence announcement. + + def findDevice(self, name): + """Return information about a device registered over SSDP.""" + + # TODO: Implement me. + + # TODO: Send out a discovery request if we haven't registered + # a presence announcement. diff --git a/browse-zms.py b/browse-zms.py new file mode 100755 index 0000000..743e090 --- /dev/null +++ b/browse-zms.py @@ -0,0 +1,53 @@ +#!/usr/bin/python +# +# Small client for sending text to a socket and displaying the result. +# + +# Licensed under the MIT license +# http://opensource.org/licenses/mit-license.php + +# (c) 2005, Tim Potter + +from twisted.internet import reactor, error +from twisted.internet.protocol import Protocol, ClientFactory + +class Send(Protocol): + def connectionMade(self): + self.transport.write('''POST /ContentDirectory/control HTTP/1.1\r +Host: 192.168.126.1:80\r +User-Agent: POSIX, UPnP/1.0, Intel MicroStack/1.0.1423\r +SOAPACTION: "urn:schemas-upnp-org:service:ContentDirectory:1#Browse"\r +Content-Type: text/xml\r +Content-Length: 511\r +\r +\r +\r + \r + \r + \r + 0\OnlineMedia\Internet radio\\r + BrowseDirectChildren\r + *\r + 0\r + 7\r + \r + \r + \r + \r\n''') + + def dataReceived(self, data): + print(data) + + def connectionLost(self, reason): + if reason.type != error.ConnectionDone: + print str(reason) + reactor.stop() + +class SendFactory(ClientFactory): + protocol = Send + +host = '192.168.126.128' +port = 5643 + +reactor.connectTCP(host, port, SendFactory()) +reactor.run() diff --git a/cdsclient b/cdsclient new file mode 100755 index 0000000..06e3119 --- /dev/null +++ b/cdsclient @@ -0,0 +1,67 @@ +#!/usr/bin/python +# +# Test harness for UPnP ContentDirectory:1 service. +# + +# Licensed under the MIT license +# http://opensource.org/licenses/mit-license.php + +# (c) 2005, Tim Potter + +from twisted.web import client +from twisted.internet import reactor +from twisted.python import usage + +import sys, string, SOAPpy + +class UPnPSOAPProxy: + """A proxy for making UPnP SOAP calls.""" + + def __init__(self, url): + self.url = url + + def _cbGotResult(self, result): + return SOAPpy.parseSOAPRPC(result) + + def callRemote(self, method, *args, **kwargs): + + payload = SOAPpy.buildSOAP(args = args, kw = kwargs, method = method) + payload = string.replace(payload, '\n', '\r\n') + + action = \ + '"urn:schemas-upnp-org:service:ContentDirectory:1#%s"' % method + + page = client.getPage(self.url, postdata = payload, method = 'POST', + headers = {'Content-Type': 'text/xml', + 'SOAPACTION': action}) + + return page.addCallback(self._cbGotResult) + +class Options(usage.Options): + pass + +def printResult(value): + print value + reactor.stop() + +def printError(error): + print 'error', error + reactor.stop() + +#proxy = UPnPSOAPProxy('http://192.168.126.128:5643/ContentDirectory/control') +proxy = UPnPSOAPProxy('http://127.0.0.1:8080/ContentDirectory/control') +#proxy = UPnPSOAPProxy('http://16.176.65.48:5643/ContentDirectory/control') + +#proxy.callRemote('GetSearchCapabilities').addCallbacks(printResult, printError) +#proxy.callRemote('GetSortCapabilities').addCallbacks(printResult, printError) + +proxy.callRemote('Browse', + ObjectID = '0\\Music\\Genres\\Others\\chimes.wav', +# BrowseFlag = 'BrowseDirectChildren', + BrowseFlag = 'BrowseMetadata', + Filter = '*', + StartingIndex = 0, + RequestedCount = 700, + SortCriteria = None).addCallbacks(printResult, printError) + +reactor.run() diff --git a/connection-manager-scpd.xml b/connection-manager-scpd.xml new file mode 100644 index 0000000..3c7d500 --- /dev/null +++ b/connection-manager-scpd.xml @@ -0,0 +1,6 @@ +10GetCurrentConnectionInfoConnectionIDinA_ARG_TYPE_ConnectionIDRcsIDoutA_ARG_TYPE_RcsIDAVTransportIDoutA_ARG_TYPE_AVTransportIDProtocolInfooutA_ARG_TYPE_ProtocolInfoPeerConnectionManageroutA_ARG_TYPE_ConnectionManagerPeerConnectionIDoutA_ARG_TYPE_ConnectionIDDirectionoutA_ARG_TYPE_DirectionStatusoutA_ARG_TYPE_ConnectionStatusGetProtocolInfoSourceoutSourceProtocolInfoSinkoutSinkProtocolInfoGetCurrentConnectionIDsConnectionIDsoutCurrentConnectionIDsA_ARG_TYPE_ProtocolInfostringA_ARG_TYPE_ConnectionStatusstringOKContentFormatMismatchInsufficientBandwidthUnreliableChannelUnknownA_ARG_TYPE_AVTransportIDi4A_ARG_TYPE_RcsIDi4A_ARG_TYPE_ConnectionIDi4A_ARG_TYPE_ConnectionManagerstring +SourceProtocolInfostringSinkProtocolInfostringA_ARG_TYPE_DirectionstringInputOutputCurrentConnectionIDsstring diff --git a/content-directory-scpd.xml b/content-directory-scpd.xml new file mode 100644 index 0000000..b43ba47 --- /dev/null +++ b/content-directory-scpd.xml @@ -0,0 +1,7 @@ +10BrowseObjectIDinA_ARG_TYPE_ObjectIDBrowseFlaginA_ARG_TYPE_BrowseFlagFilterinA_ARG_TYPE_FilterStartingIndexinA_ARG_TYPE_IndexRequestedCountinA_ARG_TYPE_CountSortCriteriainA_ARG_TYPE_SortCriteriaResultoutA_ARG_TYPE_ResultNumberReturnedoutA_ARG_TYPE_CountTotalMatchesoutA_ARG_T +YPE_CountUpdateIDoutA_ARG_TYPE_UpdateIDGetSortCapabilitiesSortCapsoutSortCapabilitiesGetSystemUpdateIDIdoutSystemUpdateIDSearchContainerIDinA_ARG_TYPE_ObjectIDSearchCriteriainA_ARG_TYPE_SearchCriteriaFilterinA_ARG_TYPE_FilterStartingIndexinA_ARG_TYPE_IndexRequestedCountinA_ARG_TYPE_CountSortCriteriainA_ARG_TYPE_SortCriteriaResultoutA_ARG_TYPE_ResultNumberReturnedoutA_ARG_TYPE_CountTotalMatchesoutA_ARG_TYPE_CountUpdateIDoutA_ARG_TYPE_UpdateIDGetSearchCapabilitiesSearchCapsoutSearchCapabilitiesA_ARG_TYPE_BrowseFlagstringBrowseMetadataBrowseDirectChildren +A_ARG_TYPE_SearchCriteriastringSystemUpdateIDui4A_ARG_TYPE_Countui4A_ARG_TYPE_SortCriteriastringSortCapabilitiesstringA_ARG_TYPE_Indexui4A_ARG_TYPE_ObjectIDstringA_ARG_TYPE_UpdateIDui4A_ARG_TYPE_ResultstringSearchCapabilitiesstringA_ARG_TYPE_Filterstring diff --git a/pymediaserv b/pymediaserv new file mode 100755 index 0000000..b590f45 --- /dev/null +++ b/pymediaserv @@ -0,0 +1,77 @@ +#!/usr/bin/python + +# Licensed under the MIT license +# http://opensource.org/licenses/mit-license.php + +# (c) 2005, Tim Potter + +import sys +from twisted.python import log +from twisted.internet import reactor + +listenAddr = sys.argv[1] + +log.startLogging(sys.stdout) + +# Create SSDP server + +from SSDP import SSDPServer, SSDP_PORT, SSDP_ADDR + +s = SSDPServer() + +port = reactor.listenMulticast(SSDP_PORT, s, SSDP_ADDR) +port.joinGroup(SSDP_ADDR, listenAddr) + +uuid = 'uuid:XVKKBUKYRDLGJQDTPOT' + +s.register('%s::upnp:rootdevice' % uuid, + 'upnp:rootdevice', + 'http://%s:8080/root-device.xml' % listenAddr) + +s.register(uuid, + uuid, + 'http://%s:8080/root-device.xml' % listenAddr) + +s.register('%s::urn:schemas-upnp-org:device:MediaServer:1' % uuid, + 'urn:schemas-upnp-org:device:MediaServer:1', + 'http://%s:8080/root-device.xml' % listenAddr) + +s.register('%s::urn:schemas-upnp-org:service:ConnectionManager:1' % uuid, + 'urn:schemas-upnp-org:device:ConnectionManager:1', + 'http://%s:8080/root-device.xml' % listenAddr) + +s.register('%s::urn:schemas-upnp-org:service:ContentDirectory:1' % uuid, + 'urn:schemas-upnp-org:device:ContentDirectory:1', + 'http://%s:8080/root-device.xml' % listenAddr) + +# Create SOAP server + +from twisted.web import server, resource, static +from ContentDirectory import ContentDirectoryServer +from ConnectionManager import ConnectionManagerServer + +class WebServer(resource.Resource): + def __init__(self): + resource.Resource.__init__(self) + +class RootDevice(static.File): + def __init__(self): + static.File.__init__(self, 'root-device.xml', defaultType = 'text/xml') + +root = WebServer() +root.putChild('ContentDirectory', ContentDirectoryServer()) +root.putChild('ConnectionManager', ConnectionManagerServer()) +root.putChild('root-device.xml', RootDevice()) + +# Area of server to serve media files from + +from MediaServer import MediaServer + +root.putChild('media', static.File('media')) + +site = server.Site(root) +reactor.listenTCP(8080, site) + +# Main loop + +reactor.run() diff --git a/root-device.xml b/root-device.xml new file mode 100644 index 0000000..f74caba --- /dev/null +++ b/root-device.xml @@ -0,0 +1,35 @@ + + + +1 +0 + + +urn:schemas-upnp-org:device:MediaServer:1 +1.0 +Python Media Server +Zensonic +http://www.redsonic.com +UPnP/AV 1.0 Compliant Media Server +PC-MediaServer-DSM +103 +0000001 +uuid:XVKKBUKYRDLGJQDTPOT + + +urn:schemas-upnp-org:service:ConnectionManager:1 +urn:upnp-org:serviceId:CMGR_0-99 +ConnectionManager/scpd.xml +ConnectionManager/control +ConnectionManager/event + + +urn:schemas-upnp-org:service:ContentDirectory:1 +urn:upnp-org:serviceId:CDS_0-99 +ContentDirectory/scpd.xml +ContentDirectory/control +ContentDirectory/event + + + + diff --git a/upnp.py b/upnp.py new file mode 100644 index 0000000..12254f4 --- /dev/null +++ b/upnp.py @@ -0,0 +1,18 @@ +# Licensed under the MIT license +# http://opensource.org/licenses/mit-license.php + +# (c) 2005, Tim Potter + +from twisted.web import soap + +import SOAPpy + +class UPnPPublisher(soap.SOAPPublisher): + """UPnP requires OUT parameters to be returned in a slightly + different way than the SOAPPublisher class does.""" + + def _gotResult(self, result, request, methodName): + response = SOAPpy.buildSOAP(kw=result, + encoding=self.encoding) + self._sendResponse(request, response) + diff --git a/xml/browsemusic.xml b/xml/browsemusic.xml new file mode 100644 index 0000000..671ee27 --- /dev/null +++ b/xml/browsemusic.xml @@ -0,0 +1,32 @@ + + + All Tracks + object.container.storageFolder + + + + Playlists + object.container + + + + Genres + object.container + + + + Artists + object.container + + + + Albums + object.container + + + + Folders + object.container + + + diff --git a/xml/browsemusicgenres.xml b/xml/browsemusicgenres.xml new file mode 100644 index 0000000..065ec66 --- /dev/null +++ b/xml/browsemusicgenres.xml @@ -0,0 +1,11 @@ + + + chimes.wav + object.item.audioItem.musicTrack + + Others + Others + Others + http://16.176.65.48:5643/web/C:\temp\Resource%20Kit\kixtart\chimes.wav + + diff --git a/xml/browsephotos.xml b/xml/browsephotos.xml new file mode 100644 index 0000000..bde5967 --- /dev/null +++ b/xml/browsephotos.xml @@ -0,0 +1,14 @@ + + + All Photos + object.container.album.photoAlbum + + http://16.176.65.48:5643/web/C:\temp\foo.jpg.thumb + + + Folders + object.container.album.photoAlbum + + http://16.176.65.48:5643/web/C:\temp\foo.jpg.thumb + + diff --git a/xml/browsetoplevel.xml b/xml/browsetoplevel.xml new file mode 100644 index 0000000..27a7e2e --- /dev/null +++ b/xml/browsetoplevel.xml @@ -0,0 +1,22 @@ + + + Movie + object.container + + + + Music + object.container + + + + Photo + object.container + + + + OnlineMedia + object.container + + + diff --git a/xml/browsetrackmetadata.xml b/xml/browsetrackmetadata.xml new file mode 100644 index 0000000..94e5f37 --- /dev/null +++ b/xml/browsetrackmetadata.xml @@ -0,0 +1,8 @@ + + + chimes.wav + object.item.audioItem.musicTrack + + + + diff --git a/xmlpretty b/xmlpretty new file mode 100755 index 0000000..800b52a --- /dev/null +++ b/xmlpretty @@ -0,0 +1,18 @@ +#!/usr/bin/python + +# (c) 2005, Tim Potter + +# Licensed under the MIT license +# http://opensource.org/licenses/mit-license.php + +"""Take XML on stdin and produce pretty-printed XML on stdout.""" + +import sys +from xml.dom import minidom + +str = "" +for s in sys.stdin.readlines(): + str = str + s[:-1] # Eat trailing \n + +doc = minidom.parseString(str) +print doc.toprettyxml(indent = " ")