Initial Commit
This commit is contained in:
commit
0543b89d8f
12 changed files with 1256 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
lib/piped_api/__pycache__/
|
||||||
|
lib/piped_api/models/__pycache__/
|
12
README.md
Normal file
12
README.md
Normal 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
23
addon.xml
Normal 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
25
lib/piped_api/__init__.py
Normal 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
170
lib/piped_api/client.py
Normal 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)
|
15
lib/piped_api/models/__init__.py
Normal file
15
lib/piped_api/models/__init__.py
Normal 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
|
104
lib/piped_api/models/channels.py
Normal file
104
lib/piped_api/models/channels.py
Normal 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']
|
163
lib/piped_api/models/comments.py
Normal file
163
lib/piped_api/models/comments.py
Normal 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']
|
559
lib/piped_api/models/videos.py
Normal file
559
lib/piped_api/models/videos.py
Normal 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
179
main.py
Normal 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
BIN
resources/images/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
4
resources/settings.xml
Normal file
4
resources/settings.xml
Normal 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>
|
Loading…
Reference in a new issue