Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[plugin.video.piped] 1.0.0 #4445

Merged
merged 7 commits into from
Feb 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading