Skip to content

Commit

Permalink
Merge pull request #4445 from syhlx/matrix
Browse files Browse the repository at this point in the history
[plugin.video.piped] 1.0.0
  • Loading branch information
basrieter authored Feb 11, 2024
2 parents 2eeba9a + 9c543a4 commit b59d044
Show file tree
Hide file tree
Showing 21 changed files with 1,572 additions and 0 deletions.
661 changes: 661 additions & 0 deletions plugin.video.piped/LICENSE.txt

Large diffs are not rendered by default.

45 changes: 45 additions & 0 deletions plugin.video.piped/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Piped Addon for Kodi

[![AGPL v3](https://shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0.en.html)

An addon which allows you to access any Piped instance, login and manage your playlists and watch history.

## Features:

**Basic Features**

- [x] Watch videos (obviously!)
- [x] Search for videos, channels and playlists
- [x] Multi-language audio
- [x] Subtitles
- [x] Pick favourite your Piped instance
- [x] Compatible with Sponsor Block
- [ ] Watch live streams (can be watched once finished, for now)

**Account Features (logged in to a Piped instance)**

- [x] Personal Feed
- [x] Subscriptions
- [x] Playlists
- [x] Optional: Watch History (use a playlist as watch history)
- [x] Optional: Hide already watched videos in each section

## Watch history

To enable the watch history: After logging in from the settings, go to your playlists and choose "**Set as watch history**" from the context menu on the playlist you wish to use as your watch history.

## Piped instances

The official Piped instance is set by default.

But, you can also choose your favourite Piped instance or host your own and change it in the settings.

- List of public Piped Instances: [Public Piped instances](https://github.com/TeamPiped/Piped/wiki/Instances)
- Host your own instance: [Self-Hosting](https://docs.piped.video/docs/self-hosting/)

## Disclaimer

This plugin is neither affiliated with nor endorsed by TeamPiped.

# License
Piped Addon for Kodi is licensed under the AGPL v3 License. See [LICENSE](LICENSE.txt) for details.
25 changes: 25 additions & 0 deletions plugin.video.piped/addon.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<addon id="plugin.video.piped" name="Piped" version="1.0.0" provider-name="Syhlx">
<requires>
<import addon="xbmc.python" version="3.0.0" />
<import addon="inputstream.adaptive" version="19.0.0" />
<import addon="script.module.requests" version="2.27.0" />
</requires>
<extension point="xbmc.service" library="service.py" />
<extension point="xbmc.python.pluginsource" library="default.py">
<provides>video</provides>
</extension>
<extension point="xbmc.addon.metadata">
<summary lang="en_GB">Piped Addon for Kodi</summary>
<description lang="en_GB">An addon which allows you to access any Piped instance, login and manage your playlists and watch history</description>
<disclaimer lang="en_GB">This plugin is neither affiliated with nor endorsed by TeamPiped</disclaimer>
<platform>all</platform>
<license>AGPL-3.0-only</license>
<source>https://github.com/syhlx/plugin.video.piped</source>
<news>None</news>
<assets>
<icon>resources/icon.png</icon>
<fanart>resources/fanart.jpg</fanart>
</assets>
</extension>
</addon>
8 changes: 8 additions & 0 deletions plugin.video.piped/default.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import sys
from xbmcplugin import setContent

from lib.sections import router

setContent(int(sys.argv[1]), 'videos')

router(sys.argv)
Empty file.
26 changes: 26 additions & 0 deletions plugin.video.piped/lib/authentication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from xbmcaddon import Addon
from xbmcgui import Dialog

def get_auth_token() -> str:
addon = Addon()
if not addon.getSettingBool('use_login'): return ''

if len(addon.getSettingString('auth_token')) > 0:
return addon.getSettingString('auth_token')
else:
error_msg: str = ''
try:
result = requests.post(f'{addon.getSettingString("instance")}/login', json = dict(
username = addon.getSettingString('username'),
password = addon.getSettingString('password')
))

error_msg = result.text

auth_token = result.json()['token']
addon.setSettingString('auth_token', auth_token)

return auth_token
except:
Dialog().ok(addon.getLocalizedString(30016), error_msg)
return ''
70 changes: 70 additions & 0 deletions plugin.video.piped/lib/dash.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from requests import get
import xbmc
from xbmcaddon import Addon

def generate_dash(video_id: str) -> str:
addon = Addon()
resp = get(f'{addon.getSettingString("instance")}/streams/{video_id}').json()

streams: dict = dict(
audio = dict(),
video = dict(),
)

default_audio: dict = dict()
prefer_original_lang: bool = addon.getSettingBool("audio_prefer_original_lang")
preferred_lang: str = addon.getSettingString("audio_custom_lang") if addon.getSettingString("audio_custom_lang") and not addon.getSettingBool("audio_prefer_kodi_lang") else xbmc.getLanguage(xbmc.ISO_639_1)

for stream in resp["audioStreams"] + resp["videoStreams"]:
media_lang = stream["audioTrackLocale"] if stream["audioTrackLocale"] is not None else 'null'
media_type, media_format = stream["mimeType"].split("/")
if media_type in ['audio', 'video'] and "googlevideo" in stream["url"]:
if media_type == 'audio' and ((prefer_original_lang and stream["audioTrackType"] == "ORIGINAL") or (not prefer_original_lang and media_lang[:2] == preferred_lang)):
if not default_audio.__contains__(media_lang): default_audio[media_lang]: dict = dict()
if not default_audio[media_lang].__contains__(media_format): default_audio[media_lang][media_format]: list = list()
default_audio[media_lang][media_format].append(stream)
else:
if not streams[media_type].__contains__(media_lang): streams[media_type][media_lang]: dict = dict()
if not streams[media_type][media_lang].__contains__(media_format): streams[media_type][media_lang][media_format]: list = list()
streams[media_type][media_lang][media_format].append(stream)

streams['audio'] = default_audio | streams['audio']

mpd: str = '<?xml version="1.0" encoding="utf-8"?>'
mpd += f'<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" profiles="urn:mpeg:dash:profile:full:2011" minBufferTime="PT1.5S" type="static" mediaPresentationDuration="PT{resp["duration"]}S">'

mpd += '<Period>'

if addon.getSettingBool("subtitles_load"):
for subtitle in resp["subtitles"]:
mpd += f'<AdaptationSet contentType="text" mimeType="{subtitle["mimeType"]}" segmentAlignment="true" lang="{subtitle["code"]}">'
mpd += '<Role schemeIdUri="urn:mpeg:dash:role:2011" value="subtitle"/>'
mpd += f'<Representation id="caption_{subtitle["code"]}{"_auto" if subtitle["autoGenerated"] else ""}" bandwidth="256">'
mpd += f'<BaseURL>{subtitle["url"].replace("&", "&amp;")}</BaseURL>'
mpd += '</Representation></AdaptationSet>'

for stream_type in ['audio', 'video']:
for stream_lang in streams[stream_type]:
for stream_format in streams[stream_type][stream_lang]:
stream_xml: str = ''
for stream in streams[stream_type][stream_lang][stream_format]:
if stream["initEnd"] > 0:
stream_xml += f'<Representation id="{stream["itag"]}" codecs="{stream["codec"]}" bandwidth="{stream["bitrate"]}"'
if stream_type == 'audio':
stream_xml += '><AudioChannelConfiguration schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011" value="2"/>'
elif stream_type == 'video':
stream_xml += f' width="{stream["width"]}" height="{stream["height"]}" maxPlayoutRate="1" frameRate="{stream["fps"]}">'
stream_xml += f'<BaseURL>{stream["url"].replace("&", "&amp;")}</BaseURL>'
stream_xml += f'<SegmentBase indexRange="{stream["indexStart"]}-{stream["indexEnd"]}">'
stream_xml += f'<Initialization range="{stream["initStart"]}-{stream["initEnd"]}"/>'
stream_xml += '</SegmentBase></Representation>'

if len(stream_xml) > 0:
mpd += f'<AdaptationSet mimeType="{stream_type}/{stream_format}" startWithSAP="1" subsegmentAlignment="true"'
mpd += ' scanType="progressive">' if stream_type == 'video' else f' lang="{stream_lang}">'
mpd += stream_xml
mpd += f'</AdaptationSet>'

mpd += '</Period></MPD>'

return mpd
69 changes: 69 additions & 0 deletions plugin.video.piped/lib/history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import json
from requests import get, post
from xbmcaddon import Addon
from xbmcvfs import translatePath

from lib.authentication import get_auth_token

def set_watch_history(playlist_id: str) -> None:
addon = Addon()
addon.setSettingBool('watch_history_enable', True)
addon.setSettingString('watch_history_playlist', playlist_id)

def mark_as_watched(video_id: str) -> None:
addon = Addon()
profile_path: str = translatePath(addon.getAddonInfo("profile"))
instance: str = addon.getSettingString('instance')
auth_token: str = get_auth_token()
playlist_id: str = addon.getSettingString('watch_history_playlist')

videos = get(f'{instance}/playlists/{playlist_id}').json()['relatedStreams']
index: int = -1

for i in range(len(videos)):
if videos[i]['url'] == f"/watch?v={video_id}":
index = i
break

if index == -1:
post(f'{instance}/user/playlists/add', json = dict(
playlistId = playlist_id,
videoId = video_id
), headers={'Authorization': auth_token})

try:
with open(f'{profile_path}/watch_history.json', 'r') as f:
history = json.load(f)
history.insert(0, video_id)

with open(f'{profile_path}/watch_history.json', 'w+') as f:
json.dump(history, f)
except:
pass

def mark_as_unwatched(video_id: str) -> None:
addon = Addon()
profile_path: str = translatePath(addon.getAddonInfo("profile"))
instance: str = addon.getSettingString('instance')
auth_token: str = get_auth_token()
playlist_id: str = addon.getSettingString('watch_history_playlist')

videos: list = get(f'{instance}/playlists/{playlist_id}').json()['relatedStreams']

for i in range(len(videos)):
if videos[i]['url'] == f"/watch?v={video_id}":
post(f'{instance}/user/playlists/remove', json = dict(
playlistId = playlist_id,
index = i
), headers={'Authorization': auth_token})
break

try:
with open(f'{profile_path}/watch_history.json', 'r') as f:
history = json.load(f)
history.remove(video_id)

with open(f'{profile_path}/watch_history.json', 'w+') as f:
json.dump(history, f)
except:
pass
Loading

0 comments on commit b59d044

Please sign in to comment.