diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c6c035 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/.idea +/build +/*.egg-info +/.pytest_cache +__pycache__ +/venv diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..185d421 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ + +SOURCES=$(shell find . -name *.py) +OUTPUT_PATH=build/plugin.video.ipfs + +venv: + ( \ + virtualenv --python=python3.7 venv + source venv/bin/activate; \ + pip install -r requirements.txt; \ + ) + +test: install venv $(SOURCES) + venv/bin/py.test + +install: + venv/bin/python setup.py develop + +clean: + rm -rf build + +build/plugin_video_ipfs.zip: build + cd build && zip -r plugin_video_ipfs.zip plugin.video.ipfs + +build: $(SOURCES) fanart.jpg icon.png addon.xml + mkdir -p $(OUTPUT_PATH)/ipfs + cp -r src/*.py $(OUTPUT_PATH) + cp -r src/ipfs/*.py $(OUTPUT_PATH)/ipfs + cp -r resources $(OUTPUT_PATH) + cp icon.png $(OUTPUT_PATH) + cp addon.xml $(OUTPUT_PATH) + cp fanart.jpg $(OUTPUT_PATH) + +package: build/plugin_video_ipfs.zip + unzip -t build/plugin_video_ipfs.zip diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8fc640 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# IPFS video plugin for Kodi + +Plugin to browse and play video media from IPFS. + +It has a configurable gateway and a configurable root CID that you can browse. + +# Build from source + +- `make package` +- Copy the `build/plugin_video_ipfs.zip` + +# Viewing your own content + +- Install IPFS on your media player +- Use `ipfs add -r -w yourdirectory` to insert your directory +- Configure plugin to have gateway point to `http://localhost:8080` +- Use the hash of the directory as the root CID in the plugin configuration + +# Develop + +- `make venv` +- `make test` +- `make build` + +# License information + +Source code license: [GPL v.3](http://www.gnu.org/copyleft/gpl.html) + +Fanart is from https://pixabay.com/nl/beaded-spinneweb-raagbol-web-dauw-1630493/ + +Icon is from https://pixabay.com/nl/puzzel-spel-kubus-rubiks-kubus-1243091/ diff --git a/addon.xml b/addon.xml new file mode 100644 index 0000000..a182440 --- /dev/null +++ b/addon.xml @@ -0,0 +1,23 @@ + + + + + + + + video + + + IPFS video viewing plugin + A plugin allowing access to IPFS video media. + https://ipfs.video/ + + icon.png + fanart.jpg + resources\screenshot-01.jpg + + + diff --git a/fanart.jpg b/fanart.jpg new file mode 100644 index 0000000..e609ead Binary files /dev/null and b/fanart.jpg differ diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..5318c69 Binary files /dev/null and b/icon.png differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9a55a62 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ + +requests + +pytest diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po new file mode 100644 index 0000000..dbc5e9f --- /dev/null +++ b/resources/language/resource.language.en_gb/strings.po @@ -0,0 +1,25 @@ +# Kodi Media Center language file +# Addon Name: IPFS +# Addon id: plugin.video.ipfs +# Addon Provider: ipfs.video +msgid "" +msgstr "" +"Project-Id-Version: Kodi Addons\n" +"Report-Msgid-Bugs-To: alanwww1@xbmc.org\n" +"POT-Creation-Date: YEAR-MO-DA HO:MI+ZONE\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: Kodi Translation Team\n" +"Language-Team: English (http://www.transifex.com/projects/p/xbmc-addons/language/en/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: en\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgctxt "#32000" +msgid "Root CID" +msgstr "" + +msgctxt "#32001" +msgid "IPFS Gateway" +msgstr "" diff --git a/resources/screenshot-01.jpg b/resources/screenshot-01.jpg new file mode 100644 index 0000000..1bec755 Binary files /dev/null and b/resources/screenshot-01.jpg differ diff --git a/resources/settings.xml b/resources/settings.xml new file mode 100644 index 0000000..f4e0c76 --- /dev/null +++ b/resources/settings.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..5818e32 --- /dev/null +++ b/setup.py @@ -0,0 +1,11 @@ +from setuptools import setup + +setup(name='ipfs-video-kodi', + version='0.1', + description='The funniest joke in the world', + url='', + author='Flying Circus', + author_email='flyingcircus@example.com', + license='MIT', + packages=["src"], + zip_safe=False) diff --git a/src/ipfs/__init__.py b/src/ipfs/__init__.py new file mode 100644 index 0000000..88593b7 --- /dev/null +++ b/src/ipfs/__init__.py @@ -0,0 +1,37 @@ + +import requests +import random + +def via(gateway): + return IPFS(gateway) + +class IPFS: + def __init__(self, gateway): + assert len(gateway) > 0 + self._gateway = gateway + self._cache = {} + + def get(self, path, params): + r = requests.get(self._gateway + '/api/v0/dag/get', params=params) + r.raise_for_status() + return r + + def list(self, hash): + """Get the directory content of the given hash""" + assert type(hash) == str + if hash in self._cache: + if len(self._cache) > 50: + #Drop 10 keys + for k in random.sample(self._cache.keys(), 10): + del self._cache[k] + return self._cache[hash] + + r = self.get('/api/v0/dag/get', params={"arg": hash}) + r.raise_for_status() + + entries = list(filter(lambda link: len(link['Name']) > 0 and '/' in link['Cid'], r.json()["links"])) + self._cache[hash] = entries + return entries + + def link(self, hash): + return self._gateway + "/ipfs/" + hash diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..afcfdd9 --- /dev/null +++ b/src/main.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +import sys + +try: + #Python 3 + from urllib.parse import urlencode, parse_qsl +except ImportError: + from urllib import urlencode + from urlparse import parse_qsl + +import xbmcgui +import xbmcplugin +import ipfs + +# Get the plugin url in plugin:// notation. +_url = sys.argv[0] +# Get the plugin handle as an integer number. +_handle = int(sys.argv[1]) +_rootCid = xbmcplugin.getSetting(_handle, 'rootCid') +_ipfs = ipfs.via(xbmcplugin.getSetting(_handle, 'ipfsGateway')) + +def self_url(**kwargs): + """ + Create a URL for calling the plugin recursively from the given set of keyword arguments. + + :param kwargs: "argument=value" pairs + :type kwargs: dict + :return: plugin call URL + :rtype: str + """ + return '{0}?{1}'.format(_url, urlencode(kwargs)) + +def list_node(cid): + """ + Create a listing of the given ipfs cid + + :param cid: content identifier + :type category: str + """ + # Set plugin category. It is displayed in some skins as the name + # of the current section. + xbmcplugin.setPluginCategory(_handle, cid) + # Set plugin content. It allows Kodi to select appropriate views + # for this type of content. + xbmcplugin.setContent(_handle, 'videos') + # Get the list of videos in the category. + links = _ipfs.list(cid) + + for link in links: + is_folder = len(_ipfs.list(link['Cid']['/'])) > 0 + + list_item = xbmcgui.ListItem(label=link['Name']) + # Set additional info for the list item. + # 'mediatype' is needed for skin to display info for this ListItem correctly. + list_item.setInfo('video', {'title': link['Name'], + 'mediatype': 'video'}) + # TODO set thumbnails + # list_item.setArt({'thumb': video['thumb'], 'icon': video['thumb'], 'fanart': video['thumb']}) + + list_item.setProperty('IsPlayable', ('false' if is_folder else 'true')) + + url = self_url(action=('list' if is_folder else 'play'), cid=link['Cid']['/']) + # Add our item to the Kodi virtual folder listing. + xbmcplugin.addDirectoryItem(_handle, url, list_item, is_folder) + # Add a sort method for the virtual folder items (alphabetically, ignore articles) + xbmcplugin.addSortMethod(_handle, xbmcplugin.SORT_METHOD_TITLE) + xbmcplugin.endOfDirectory(_handle) + + +def play_node(cid): + """ + Play a video by the provided cid. + + :param cid: Content id + :type path: str + """ + play_item = xbmcgui.ListItem(path=_ipfs.link(cid)) + xbmcplugin.setResolvedUrl(_handle, True, listitem=play_item) + + +def router(paramstring): + """ + Router function that calls other functions + depending on the provided paramstring + + :param paramstring: URL encoded plugin paramstring + :type paramstring: str + """ + params = dict(parse_qsl(paramstring)) + + #Default action + if not params: + params['action'] = 'list' + params['cid'] = _rootCid + + # Check the parameters passed to the plugin + if params['action'] == 'list': + list_node(params['cid']) + elif params['action'] == 'play': + play_node(params['cid']) + else: + raise ValueError('Invalid paramstring: {0}!'.format(paramstring)) + + +if __name__ == '__main__': + # Call the router function and pass the plugin call parameters to it. + # We use string slicing to trim the leading '?' from the plugin call paramstring + router(sys.argv[2][1:]) diff --git a/test/test_ipfs.py b/test/test_ipfs.py new file mode 100644 index 0000000..b8ac98d --- /dev/null +++ b/test/test_ipfs.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +import unittest +import src.ipfs as ipfs + +test_gateway = ipfs.via("http://51.15.122.1") + + +class TestIpfsMethods(unittest.TestCase): + + def test_list_file_should_be_empty(self): + a = test_gateway.list("QmTNdv6MBhCjcGY5tpabi7aCeLZL65tmDzW37J9ZrFbZfL") + self.assertEqual(a, []) + + def test_list_directory_should_work(self): + a = test_gateway.list("QmVZV84e6nSwfA8LppiS4KXKiAhpbqqGYzofHtecQjd9js") + self.assertEqual(len(a), 1) + self.assertEqual(a[0]['Name'], "pexel") + + + +if __name__ == '__main__': + unittest.main()