From 0543b89d8fec0f4aaf9231d4e349ee55f3368342 Mon Sep 17 00:00:00 2001 From: cere Date: Tue, 16 Jan 2024 05:08:13 -0500 Subject: [PATCH] Initial Commit --- .gitignore | 2 + README.md | 12 + addon.xml | 23 ++ lib/piped_api/__init__.py | 25 ++ lib/piped_api/client.py | 170 ++++++++++ lib/piped_api/models/__init__.py | 15 + lib/piped_api/models/channels.py | 104 ++++++ lib/piped_api/models/comments.py | 163 +++++++++ lib/piped_api/models/videos.py | 559 +++++++++++++++++++++++++++++++ main.py | 179 ++++++++++ resources/images/icon.png | Bin 0 -> 11769 bytes resources/settings.xml | 4 + 12 files changed, 1256 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 addon.xml create mode 100644 lib/piped_api/__init__.py create mode 100644 lib/piped_api/client.py create mode 100644 lib/piped_api/models/__init__.py create mode 100644 lib/piped_api/models/channels.py create mode 100644 lib/piped_api/models/comments.py create mode 100644 lib/piped_api/models/videos.py create mode 100644 main.py create mode 100644 resources/images/icon.png create mode 100644 resources/settings.xml 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 0000000000000000000000000000000000000000..034a7759f7f6df26a31a3bbacd2dadc3fc1f91ca GIT binary patch literal 11769 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 W*P4vGZVDLR2ECG1ktvrl3Hm=Yxim`v literal 0 HcmV?d00001 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