commit 0543b89d8fec0f4aaf9231d4e349ee55f3368342 Author: cere Date: Tue Jan 16 05:08:13 2024 -0500 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd47011 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +lib/piped_api/__pycache__/ +lib/piped_api/models/__pycache__/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6fff739 --- /dev/null +++ b/README.md @@ -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) \ No newline at end of file diff --git a/addon.xml b/addon.xml new file mode 100644 index 0000000..58c074d --- /dev/null +++ b/addon.xml @@ -0,0 +1,23 @@ + + + + + + + + video + + + Example Kodi Video Plugin + An example video plugin for Kodi mediacenter. + Public domain movies are provided by publicdomainmovie.net. + GPL-3.0-only + + resources/images/icon.png + + Updated with latest artwork metadata + + diff --git a/lib/piped_api/__init__.py b/lib/piped_api/__init__.py new file mode 100644 index 0000000..c045c46 --- /dev/null +++ b/lib/piped_api/__init__.py @@ -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] diff --git a/lib/piped_api/client.py b/lib/piped_api/client.py new file mode 100644 index 0000000..4f4a242 --- /dev/null +++ b/lib/piped_api/client.py @@ -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) \ No newline at end of file diff --git a/lib/piped_api/models/__init__.py b/lib/piped_api/models/__init__.py new file mode 100644 index 0000000..8f4dfe3 --- /dev/null +++ b/lib/piped_api/models/__init__.py @@ -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 diff --git a/lib/piped_api/models/channels.py b/lib/piped_api/models/channels.py new file mode 100644 index 0000000..9f38148 --- /dev/null +++ b/lib/piped_api/models/channels.py @@ -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'] diff --git a/lib/piped_api/models/comments.py b/lib/piped_api/models/comments.py new file mode 100644 index 0000000..72f4812 --- /dev/null +++ b/lib/piped_api/models/comments.py @@ -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'] diff --git a/lib/piped_api/models/videos.py b/lib/piped_api/models/videos.py new file mode 100644 index 0000000..ce8e8ad --- /dev/null +++ b/lib/piped_api/models/videos.py @@ -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']] diff --git a/main.py b/main.py new file mode 100644 index 0000000..54649ba --- /dev/null +++ b/main.py @@ -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 . +""" +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 + # {: } 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:]) diff --git a/resources/images/icon.png b/resources/images/icon.png new file mode 100644 index 0000000..034a775 Binary files /dev/null and b/resources/images/icon.png differ diff --git a/resources/settings.xml b/resources/settings.xml new file mode 100644 index 0000000..f3b1a11 --- /dev/null +++ b/resources/settings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file