Initial Commit

This commit is contained in:
cere 2024-01-16 05:08:13 -05:00
commit 0543b89d8f
12 changed files with 1256 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
lib/piped_api/__pycache__/
lib/piped_api/models/__pycache__/

12
README.md Normal file
View file

@ -0,0 +1,12 @@
# plugin.video.piped
[Piped](https://github.com/TeamPiped/Piped) Addon for Kodi.
This plugin is a **very** barebones Piped API client for Kodi using my [fork](https://gitdab.com/cere/python-piped-api-client) of the [Piped API Python Client](https://github.com/SKevo18/python-piped-api-client)
Download the latest version from [here](https://gitdab.com/cere/plugin.video.piped/archive/master.zip)
Piped API URL can be set in the addon's configuration
Don't expect support for this addon it's mainly for my personal use.
[Piped API instances](https://github.com/TeamPiped/Piped/wiki/Instances)

23
addon.xml Normal file
View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<addon id="plugin.video.piped"
version="3.0.1"
name="Kodi Piped Plugin"
provider-name="cere">
<requires>
<import addon="xbmc.python" version="3.0.1"/>
<import addon="script.module.inputstreamhelper" version="0.6.0" />
</requires>
<extension point="xbmc.python.pluginsource" library="main.py">
<provides>video</provides>
</extension>
<extension point="xbmc.addon.metadata">
<summary lang="en_GB">Example Kodi Video Plugin</summary>
<description lang="en_GB">An example video plugin for Kodi mediacenter.</description>
<disclaimer lang="en_GB">Public domain movies are provided by publicdomainmovie.net.</disclaimer>
<license>GPL-3.0-only </license>
<assets>
<icon>resources/images/icon.png</icon>
</assets>
<news>Updated with latest artwork metadata</news>
</extension>
</addon>

25
lib/piped_api/__init__.py Normal file
View file

@ -0,0 +1,25 @@
import typing as t
from pathlib import Path
from .client import PipedClient
from .models.comments import Comments
# For pdoc:
README_PATH = Path(__file__).parent.parent.absolute() / Path('README.md')
try:
with open(README_PATH, 'r', encoding="UTF-8") as readme:
__readme__ = readme.read()
except:
__readme__ = "Failed to read README.md!"
__doc__ = __readme__
# Supress unused-import warnings:
if t.TYPE_CHECKING:
_ = [PipedClient, Comments]

170
lib/piped_api/client.py Normal file
View file

@ -0,0 +1,170 @@
import typing as t
from requests import Session
from .models import BasePipedModel
from .models.comments import Comments
from .models.videos import Video
from .models.channels import NextPageChannel, Channel
_MDL = t.TypeVar('_MDL', bound=t.Type[BasePipedModel])
class APIError(Exception): """Raised when an API call fails"""
class PipedClient:
"""
An API client for [Piped](https://piped.kavin.rocks).
See also [Piped API docs](https://piped-docs.kavin.rocks/docs)
"""
def __init__(self, base_api_url: str='https://pipedapi.kavin.rocks', session: t.Type[Session]=Session()) -> None:
"""
### Parameters:
- `base_api_url` - The base URL to the instance's API. Trailing slashes will be stripped.
- `session` - A class/subclass of `requests.Session` to use for all requests.
For example, you could use [requests-cache](https://pypi.org/project/requests-cache/) to make all requests cacheable.
"""
self.base_api_url = base_api_url.strip("/")
self.session = session
def _get_json(self, uri: str, as_model: t.Optional[_MDL]=None, **kwargs) -> t.Union[_MDL, t.Dict[str, t.Any], t.List[t.Any]]:
"""
Obtains JSON data from specific URI of the Piped API.
### Parameters:
- `uri` - The URI to get JSON data from
- `as_model` - The `BasePipedModel` to load the JSON data into. If this is `None`, the JSON data is returned as a `dict`.
- `**kwargs` - Additional keyword arguments to pass to `requests.Session.get`
"""
json: t.Union[dict, list] = self.session.get(f"{self.base_api_url}{uri}", **kwargs).json()
if isinstance(json, dict) and json.get('error', None) is not None:
raise APIError(f"Error: {json['error']}")
if as_model is not None:
return as_model(json)
return json
def get_video(self, video_id: str, **kwargs) -> Video:
"""
Gets information about a specific video.
### Parameters:
- `video_id` - The ID of the video to get information for
- `**kwargs` - Additional keyword arguments to pass to `requests.Session.get`
[Piped Documentation](https://piped-docs.kavin.rocks/docs/api-documentation/#streamsvideoid)
"""
return self._get_json(f"/streams/{video_id}", Video, **kwargs)
def get_comments(self, video_id: str, nextpage: t.Optional[t.Dict[str, t.Optional[str]]]=None, **kwargs) -> Comments:
"""
Gets a list of comments for a specific video.
### Parameters:
- `video_id` - The ID of the video to get comments for
- `nextpage` - Nextpage data, obtained from `.models.comments.Comments.nextpage` property. If this is `None`, the first page of comments is returned.
There are often 20 comments per page.
- `**kwargs` - Additional keyword arguments to pass to `requests.Session.get`
[Piped Documentation](https://piped-docs.kavin.rocks/docs/api-documentation/#commentsvideoid)
"""
if nextpage is not None:
kwargs.update({'params': {'nextpage': nextpage}})
return self._get_json(f"/nextpage/comments/{video_id}", Comments, **kwargs)
return self._get_json(f"/comments/{video_id}", Comments, **kwargs)
def get_trending(self, country_code: str='US', **kwargs) -> t.List[Video.RelatedStream]:
"""
Obtains trending videos for a specific country. If there are no trending videos (or `country_code` is invalid),
an empty list is returned.
### Parameters:
- `country_code` - The country code ([ISO 3166-1 alpha-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2#Officially_assigned_code_elements)) to get trending videos for. This is automatically capitalized by this package,
since Piped for some reason doesn't accept lowercase country codes. Note: countries such as China or North Korea don't have trending videos, so they will always return an empty list.
- `**kwargs` - Additional keyword arguments to pass to `requests.Session.get`
[Piped Documentation](https://piped-docs.kavin.rocks/docs/api-documentation/#trending)
"""
kwargs.update({'params': {'region': country_code.upper()}})
return [Video.RelatedStream(trending_video) for trending_video in self._get_json(f"/trending", **kwargs)]
def get_channel_by_id(self, channel_id: str, nextpage: t.Optional[t.Dict[str, t.Optional[str]]]=None, **kwargs) -> t.Union[NextPageChannel, Channel]:
"""
Gets information about a specific channel by its ID.
### Parameters:
- `channel_id` - The ID of the channel to get information for
- `**kwargs` - Additional keyword arguments to pass to `requests.Session.get`
[Piped Documentation](https://piped-docs.kavin.rocks/docs/api-documentation/#channelchannelid)
"""
if nextpage is not None:
kwargs.update({'params': {'nextpage': nextpage}})
return self._get_json(f"/nextpage/channel/{channel_id}", NextPageChannel, **kwargs)
return self._get_json(f"/channel/{channel_id}", Channel, **kwargs)
def get_channel_by_name(self, channel_name: str, **kwargs) -> Channel:
"""
Gets information about a specific channel by its name.
### Parameters:
- `channel_name` - The name of the channel to get information for
- `**kwargs` - Additional keyword arguments to pass to `requests.Session.get`
[Piped Documentation](https://piped-docs.kavin.rocks/docs/api-documentation/#cname)
"""
return self._get_json(f"/c/{channel_name}", Channel, **kwargs)
def get_search_suggestions(self, search_query: str, **kwargs) -> t.List[str]:
"""
Obtains search suggestions for a query.
### Parameters:
- `search_query` - The query to get search suggestions for
- `**kwargs` - Additional keyword arguments to pass to `requests.Session.get`
[Piped Documentation](https://piped-docs.kavin.rocks/docs/api-documentation/#suggestions)
"""
kwargs.update({'params': {'query': search_query}})
return self._get_json(f"/suggestions", **kwargs)
def get_search_results(self, search_query: str, filter: str="all", **kwargs) -> t.List[str]:
"""
Obtains search results for a query.
### Parameters:
- `search_query` - The query to get search results for
- `**kwargs` - Additional keyword arguments to pass to `requests.Session.get`
"""
kwargs.update({'params': {'q': search_query, 'filter': filter}})
return self._get_json(f"/search", **kwargs)

View file

@ -0,0 +1,15 @@
import typing as t
class BasePipedModel:
"""
Base class for all Piped models.
"""
def __init__(self, data: t.Dict[str, t.Any]) -> None:
"""
### Parameters:
- `data` - The JSON (`dict`) data to initialize the model with.
"""
self.data = data

View file

@ -0,0 +1,104 @@
import typing as t
from . import BasePipedModel
from .videos import Video
class NextPageChannel(BasePipedModel):
"""
Represents a channel obtained via the `nextpage` endpoint.
This model contains only `nextpage` and `relatedStreams`. It's a parent for `Channel`.
"""
@property
def nextpage(self) -> str:
"""
A JSON encoded string to be passed to the `'nextpage'` endpoint(s) when
obtaining paginated data.
"""
return self.data['nextpage']
@property
def uploaded_videos(self) -> t.List[Video.RelatedStream]:
"""
List of uploaded videos from the current fetched data
There are max. 30 videos per page
"""
return [Video.RelatedStream(video_data) for video_data in self.data['relatedStreams']]
class Channel(NextPageChannel):
"""
Represents a YouTube channel.
Contains properties of `NextPageChannel`.
"""
@property
def id(self) -> str:
"""
The channel's ID
"""
return self.data['id']
@property
def name(self) -> str:
"""
The channel's name
"""
return self.data['name']
@property
def avatar_url(self) -> str:
"""
The channel's avatar URL
"""
return self.data['avatarUrl']
@property
def banner_url(self) -> str:
"""
The channel's banner URL
"""
return self.data['bannerUrl']
@property
def description(self) -> str:
"""
The channel's description
"""
return self.data['description']
@property
def subscriber_count(self) -> int:
"""
The number of subscribers the channel has
"""
return self.data['subscriberCount']
@property
def verified(self) -> bool:
"""
Whether or not the channel is verified by YouTube (has a badge)
"""
return self.data['verified']

View file

@ -0,0 +1,163 @@
import typing as t
from . import BasePipedModel
class Comments(BasePipedModel):
class Comment(BasePipedModel):
@property
def author(self) -> str:
"""
The name of the author of the comment
"""
return self.data['author']
@property
def comment_id(self) -> str:
"""
The comment ID
"""
return self.data['commentId']
@property
def comment_text(self) -> str:
"""
The text of the comment
"""
return self.data['commentText']
@property
def commented_time(self) -> str:
"""
The time the comment was made (format: `'x y ago'`).
### Note:
The raw time from API also includes the `'(edited)'` suffix to mark comment as edited (if it was).
By accessing this property, the suffix is automatically removed.
If you for whatever reason want to keep the suffix, access this property directly via `Comment.data['commentedTime']`
"""
time: str = self.data['commentedTime']
return time.removesuffix(' (edited)')
@property
def is_edited(self) -> bool:
"""
Whether or not the comment is edited.
### Note:
This property checks whether there is `'(edited)'` in the `commentedTime` property, because that's where you get that from.
See `Comments.Comment.commented_time`
"""
time: str = self.data['commentedTime']
return time.endswith('(edited)')
@property
def commentor_url(self) -> str:
"""
The URL of the channel that made the comment
"""
return self.data['commentorUrl']
@property
def replies_page(self) -> t.Optional[str]:
"""
Same as `Comments.nextpage`, but to load replies.
`None` means that there are no replies.
"""
return self.data['repliesPage']
@property
def hearted(self) -> bool:
"""
Whether or not the comment has been hearted
"""
return self.data['hearted']
@property
def like_count(self) -> int:
"""
The number of likes the comment has
"""
return self.data['likeCount']
@property
def pinned(self) -> bool:
"""
Whether or not the comment is pinned
"""
return self.data['pinned']
@property
def thumbnail(self) -> str:
"""
The thumbnail of the commentor's channel
"""
return self.data['thumbnail']
@property
def verified(self) -> bool:
"""
Whether or not the author of the comment is verified
"""
return self.data['verified']
def get_comments(self) -> t.List[Comment]:
"""
Obtain a list of comments
"""
return [self.Comment(comment_json) for comment_json in self.data['comments']]
def __iter__(self) -> t.Iterator[Comment]:
iter(self.get_comments())
@property
def disabled(self) -> bool:
"""
Whether or not the comments are disabled
"""
return self.data['disabled']
@property
def nextpage(self) -> t.Optional[str]:
"""
A JSON encoded page, which is used for the nextpage endpoint.
If there is no nextpage data, this returns `None`.
"""
return self.data['nextpage']

View file

@ -0,0 +1,559 @@
import typing as t
from datetime import datetime, date, timedelta
from . import BasePipedModel
class Video(BasePipedModel):
@property
def title(self) -> str:
"""
The title/name of the video
"""
return self.data['title']
@property
def description(self) -> str:
"""
The description of the video
"""
return self.data['description']
@property
def upload_date(self) -> date:
"""
The date the video was uploaded at.
### Note:
Use `Video.data['uploadDate']` to get the raw string that was obtained from the API - this package
automatically converts the raw string to a `datetime.date` object.
"""
raw = self.data['uploadDate']
return datetime.strptime(raw, r"%Y-%m-%d").date()
@property
def uploader(self) -> str:
"""
The channel name of the author of the video
"""
return self.data['uploader']
@property
def uploader_url(self) -> str:
"""
The URI to the author's channel
"""
return self.data['uploaderUrl']
@property
def uploader_avatar(self) -> str:
"""
The URL to the video author's avatar image
"""
return self.data['uploaderAvatar']
@property
def thumbnail_url(self) -> str:
"""
The URL to the video's thumbnail image
"""
return self.data['thumbnail']
@property
def hls(self) -> t.Optional[str]:
"""
The hls manifest URL, to be used for Livestreams
"""
return self.data['hls']
@property
def dash(self) -> t.Optional[str]:
"""
The dash manifest URL for OTF streams
"""
return self.data['dash']
@property
def lbry_id(self) -> str:
"""
The lbry id of the video, if available. I assume this has something to do with https://lbry.com/
"""
return self.data['lbryId']
@property
def uploader_verified(self) -> str:
"""
Whether or not the channel that uploaded the video is verified by YouTube (badge)
"""
return self.data['uploaderVerified']
@property
def duration(self) -> timedelta:
"""
The duration of the video.
### Note:
The original value from the API was in seconds (`Video.data['duration']`), but this package
converts it to a `datetime.timedelta` object.
"""
return timedelta(seconds=self.data['duration'])
@property
def views(self) -> int:
"""
The number of views the video has received
"""
return self.data['views']
@property
def likes(self) -> int:
"""
The amount of likes the video has received. `-1` if hidden
"""
return self.data['likes']
@property
def dislikes(self) -> int:
"""
The amount of dislikes the video has received. `-1` if hidden
### Note:
This is obsolete since YouTube did a tiny gigantical little big whoopsie with their like system and screwed it all up
You can use awesome user-made projects such as https://returnyoutubedislike.com to obtain the dislike count
"""
return self.data['dislikes']
class Stream(BasePipedModel):
"""
Either an audio or video stream of a video
"""
@property
def url(self) -> str:
"""
The URL of the stream
"""
return self.data['url']
@property
def format(self) -> str:
"""
The format of the stream (`'M4A' or 'WEBMA_OPUS' or 'MPEG_4' or 'WEBM' or 'v3GPP'`
No, I don't know how many are there or what does each mean
"""
return self.data['format']
@property
def quality(self) -> str:
"""
The standard quality we all know and love (e. g.: `'240p'` for video or `'128k'` for audio)
"""
return self.data['quality']
@property
def mime_type(self) -> str:
"""
If you come from web development (or other invidious area that works with these French mimes),
then you already know what this is. If not, consider [checking the Mozilla documentation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types)
"""
return self.data['mimeType']
@property
def codec(self) -> str:
"""
What is this? I don't know. A codec?
"""
return self.data['codec']
@property
def video_only(self) -> bool:
"""
Whether or not the stream is video only (AKA. muted video)
"""
return self.data['videoOnly']
@property
def bitrate(self) -> int:
"""
The bitrate of the stream
"""
return self.data['bitrate']
@property
def init_start(self) -> int:
"""
Not sure what this does, but it seems to be useful for creating dash streams
"""
return self.data['initStart']
@property
def init_end(self) -> int:
"""
Not sure what this does, but it seems to be useful for creating dash streams
"""
return self.data['initEnd']
@property
def index_start(self) -> int:
"""
Not sure what this does, but it seems to be useful for creating dash streams
"""
return self.data['indexStart']
@property
def index_end(self) -> int:
"""
Not sure what this does, but it seems to be useful for creating dash streams
"""
return self.data['indexEnd']
@property
def width(self) -> int:
"""
The width of the stream. `'0'` for audio streams (makes sense)
"""
return self.data['width']
@property
def height(self) -> int:
"""
The height of the stream. `'0'` for audio streams (makes sense)
"""
return self.data['width']
@property
def fps(self) -> int:
"""
Frames Per Second. This is usually `'0'` for audio and `'30'` or `'60'` for video
"""
return self.data['fps']
def get_streams(self, type: t.Literal['video', 'audio']='video') -> t.List[Stream]:
"""
Get the streams of a video.
### Parameters:
- `type` - The type of stream to get. Either `'video'` or `'audio'`
"""
if type == 'video' or type == 'audio':
return [self.Stream(stream_data) for stream_data in self.data[f"{type}Streams"]]
raise ValueError('Invalid stream type. Must be either `video` or `audio`')
class RelatedStream(BasePipedModel):
"""
A related stream (e. g.: related video to the current one from the right sidebar, video related to/uploaded by a channel and trending video).
"""
@property
def url(self) -> str:
"""
The URL to the related video
"""
return self.data['url']
@property
def title(self) -> str:
"""
The title of the related video
"""
return self.data['title']
@property
def thumbnail(self) -> str:
"""
The thumbnail URL of the related video
"""
return self.data['thumbnail']
@property
def uploader_name(self) -> str:
"""
The name of the channel that uploaded the related video
"""
return self.data['uploaderName']
@property
def uploader_url(self) -> str:
"""
The URL of the channel that uploaded the related video
"""
return self.data['uploaderUrl']
@property
def uploader_avatar(self) -> str:
"""
The URL of the channel's avatar
"""
return self.data['uploaderAvatar']
@property
def uploaded_date(self) -> str:
"""
The date the related video was uploaded (format: `'x y ago'`)
"""
return self.data['uploadedDate']
@property
def short_description(self) -> t.Optional[str]:
"""
The short description of the related video. As far as I know, this is always `None` - perhaps some unused YouTube feature?
"""
return self.data['shortDescription']
@property
def duration(self) -> timedelta:
"""
The duration of the related video.
### Note:
The original value from the API was in seconds (`Video.data['duration']`), but this package
converts it to a `datetime.timedelta` object.
"""
return timedelta(seconds=self.data['duration'])
@property
def views(self) -> str:
"""
The amount of views the related video has received
"""
return self.data['views']
@property
def uploaded(self) -> datetime:
"""
The date the related video was uploaded (as a `datetime.datetime` object).
### Note:
The original value was in milliseconds since epoch (`Video.data['uploaded']`), but this package converts it to a `datetime.datetime` object.
"""
return datetime.fromtimestamp(self.data['uploaded'] / 1000)
@property
def uploader_verified(self) -> bool:
"""
Whether or not the channel that uploaded the related video is verified by YouTube (e. g.: has badge)
"""
return self.data['uploaderVerified']
@property
def related_videos(self) -> t.List[RelatedStream]:
"""
List of related streams
"""
return [self.RelatedStream(video_data) for video_data in self.data['relatedStreams']]
class Subtitle(BasePipedModel):
@property
def url(self) -> str:
"""
The URL to the subtitle
"""
return self.data['url']
@property
def mime_type(self) -> str:
"""
If you come from web development (or other invidious area that works with these French mimes),
then you already know what this is. If not, consider [checking the Mozilla documentation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types)
"""
return self.data['mimeType']
@property
def name(self) -> str:
"""
The name of the language the captions are in
"""
return self.data['name']
@property
def code(self) -> str:
"""
The country code for the captions
"""
return self.data['code']
@property
def auto_generated(self) -> bool:
"""
Whether or not the captions are auto-generated by YouTube
"""
return self.data['autoGenerated']
@property
def subtitles(self) -> t.List[Subtitle]:
"""
A list of captions for the video
"""
return [self.Subtitle(subtitle_data) for subtitle_data in self.data['subtitles']]
@property
def livestream(self) -> bool:
"""
Whether or not the video is a livestream
"""
return self.data['livestream']
@property
def proxy_url(self) -> str:
"""
The base URL for Piped proxy
"""
return self.data['proxyUrl']
class Chapter(BasePipedModel):
"""
A video chapter (or "section").
YouTube displays a list of chapters, if there are timestamps in the description.
"""
@property
def title(self) -> str:
"""
The title of the chapter
"""
return self.data['title']
@property
def image(self) -> str:
"""
The image URL for the chapter
"""
return self.data['image']
@property
def start(self) -> timedelta:
"""
The start time of the chapter
### Note:
The original value from the API was in seconds, this package automatically converts it to `datetime.timedelta`
"""
return timedelta(seconds=self.data['start'])
@property
def chapters(self) -> t.List[Chapter]:
"""
A list of chapters for the video
"""
return [self.Chapter(chapter_data) for chapter_data in self.data['chapters']]

179
main.py Normal file
View file

@ -0,0 +1,179 @@
# Copyright (C) 2023, Roman V. M.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
Example video plugin that is compatible with Kodi 20.x "Nexus" and above
"""
import os
import sys
from urllib.parse import urlencode, parse_qsl
from lib.piped_api import PipedClient
from datetime import datetime
import xbmcgui
import xbmcplugin
from xbmcaddon import Addon
from xbmcvfs import translatePath
# Get the plugin url in plugin:// notation.
URL = sys.argv[0]
# Get a plugin handle as an integer number.
HANDLE = int(sys.argv[1])
# Get addon base path
ADDON_PATH = translatePath(Addon().getAddonInfo('path'))
ICONS_DIR = os.path.join(ADDON_PATH, 'resources', 'images', 'icons')
FANART_DIR = os.path.join(ADDON_PATH, 'resources', 'images', 'fanart')
# Public domain movies are from https://publicdomainmovie.net
# Here we use a hardcoded list of movies simply for demonstrating purposes
# In a "real life" plugin you will need to get info and links to video files/streams
# from some website or online service.
PIPED = PipedClient(base_api_url=xbmcplugin.getSetting(int(sys.argv[1]), "base_api_url"))
def get_url(**kwargs):
"""
Create a URL for calling the plugin recursively from the given set of keyword arguments.
:param kwargs: "argument=value" pairs
:return: plugin call URL
:rtype: str
"""
return '{}?{}'.format(URL, urlencode(kwargs))
def get_videos():
"""
Get the list of videofiles/streams.
Here you can insert some code that retrieves
the list of video streams in the given section from some site or API.
:return: the list of videos from the search results
:rtype: list
"""
query = xbmcgui.Dialog().input("Search Videos")
return PIPED.get_search_results(query, "videos")['items']
def list_videos():
"""
Create the list of playable videos in the Kodi interface.
"""
# Set plugin category. It is displayed in some skins as the name
# of the current section.
xbmcplugin.setPluginCategory(HANDLE, "Search Results")
# Set plugin content. It allows Kodi to select appropriate views
# for this type of content.
xbmcplugin.setContent(HANDLE, 'movies')
# Get the list of videos in the category.
videos = get_videos()
# Iterate through videos.
for video in videos:
# Create a list item with a text label
list_item = xbmcgui.ListItem(label=video['title'])
# Set graphics (thumbnail, fanart, banner, poster, landscape etc.) for the list item.
# Here we use only poster for simplicity's sake.
# In a real-life plugin you may need to set multiple image types.
list_item.setArt({'poster': video['thumbnail']})
# Set additional info for the list item via InfoTag.
# 'mediatype' is needed for skin to display info for this ListItem correctly.
info_tag = list_item.getVideoInfoTag()
info_tag.setMediaType('movie')
info_tag.setTitle(video['title'])
info_tag.setPlot(video['shortDescription'])
# info_tag.setYear(video['uploadedDate'])
# Set 'IsPlayable' property to 'true'.
# This is mandatory for playable items!
list_item.setProperty('IsPlayable', 'true')
# Create a URL for a plugin recursive call.
# Example: plugin://plugin.video.example/?action=play&video=https%3A%2F%2Fia600702.us.archive.org%2F3%2Fitems%2Firon_mask%2Firon_mask_512kb.mp4
# /watch?v= is 9 characters long, strip it
vidid = video['url'][9:]
url = get_url(action='play', video=vidid)
# Add the list item to a virtual Kodi folder.
# is_folder = False means that this item won't open any sub-list.
is_folder = False
# Add our item to the Kodi virtual folder listing.
xbmcplugin.addDirectoryItem(HANDLE, url, list_item, is_folder)
# Add sort methods for the virtual folder items
xbmcplugin.addSortMethod(HANDLE, xbmcplugin.SORT_METHOD_LABEL_IGNORE_THE)
xbmcplugin.addSortMethod(HANDLE, xbmcplugin.SORT_METHOD_VIDEO_YEAR)
# Finish creating a virtual folder.
xbmcplugin.endOfDirectory(HANDLE)
def play_video(path):
"""
Play a video by the provided path.
:param path: Fully-qualified video URL
:type path: str
"""
# Create a playable item with a path to play.
# offscreen=True means that the list item is not meant for displaying,
# only to pass info to the Kodi player
play_item = xbmcgui.ListItem(offscreen=True)
play_item.setPath(path)
# Pass the item to the Kodi player.
xbmcplugin.setResolvedUrl(HANDLE, True, listitem=play_item)
def play_yt_video(vidid):
"""
Play a HLS video stream by the provided video ID.
:param path: Fully-qualified video URL
:type path: str
"""
# Create a playable item with a path to play.
# offscreen=True means that the list item is not meant for displaying,
# only to pass info to the Kodi player
play_item = xbmcgui.ListItem(offscreen=True)
play_item.setPath(PIPED.get_video(vidid).hls)
play_item.setProperty('inputstream', 'inputstream.adaptive')
play_item.setProperty('inputstream.adaptive.manifest_type', 'hls')
# Pass the item to the Kodi player.
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
"""
# Parse a URL-encoded paramstring to the dictionary of
# {<parameter>: <value>} elements
params = dict(parse_qsl(paramstring))
# Check the parameters passed to the plugin
if not params:
# If the plugin is called from Kodi UI without any parameters,
# display the list of video categories
list_videos()
elif params['action'] == 'play':
# Play a video from a provided URL.
play_yt_video(params['video'])
else:
# If the provided paramstring does not contain a supported action
# we raise an exception. This helps to catch coding errors,
# e.g. typos in action names.
raise ValueError(f'Invalid paramstring: {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:])

BIN
resources/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

4
resources/settings.xml Normal file
View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<settings>
<setting type="text" id="base_api_url" default="https://pipedapi.kavin.rocks"/>
</settings>