diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f913b20..24fb4a9 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -15,10 +15,3 @@ Describe the purpose of this pull request. Describe any testing steps that have been taken or are necessary. Make sure to take in account any existing code change that require some feature to be re-tested. - -**Checklist:** -- [ ] Code follows project coding standards -- [ ] Tests have been added or updated -- [ ] Code Review -- [ ] Manual test after merge -- [ ] All checks passed \ No newline at end of file diff --git a/.github/workflows/trigger-agent-toolkit-update.yaml b/.github/workflows/trigger-agent-toolkit-update.yaml new file mode 100644 index 0000000..2e4b3d1 --- /dev/null +++ b/.github/workflows/trigger-agent-toolkit-update.yaml @@ -0,0 +1,19 @@ +name: Trigger Agent Toolkit Update + +on: + pull_request: + types: [closed] + +jobs: + trigger-videodb-helper-update: + if: ${{ github.event.pull_request.merged && github.event.pull_request.base.ref == 'main' }} + runs-on: ubuntu-latest + steps: + - name: Trigger Agent Toolkit Update workflow via repository_dispatch + run: | + curl -X POST -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ secrets.AGENT_TOOLKIT_TOKEN }}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + https://api.github.com/repos/video-db/agent-toolkit/dispatches \ + -d '{"event_type": "sdk-context-update", "client_payload": {"pr_number": ${{ github.event.pull_request.number }}}}' + diff --git a/requirements-dev.txt b/requirements-dev.txt index bf8db06..07fee5c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,4 @@ ruff==0.1.7 pytest==7.4.3 -twine==4.0.2 +twine==5.1.1 wheel==0.42.0 diff --git a/setup.py b/setup.py index dff1f90..db2f955 100644 --- a/setup.py +++ b/setup.py @@ -2,16 +2,16 @@ import os from setuptools import setup, find_packages -ROOT = os.path.dirname(__file__) +ROOT = os.path.dirname(os.path.abspath(__file__)) # Read in the package version per recommendations from: # https://packaging.python.org/guides/single-sourcing-package-version/ -def get_version(): - with open(os.path.join(ROOT, "videodb", "__init__.py")) as f: - for line in f.readlines(): - if line.startswith("__version__"): - return line.split("=")[1].strip().strip('''"''') + +about_path = os.path.join(ROOT, "videodb", "__about__.py") +about = {} +with open(about_path) as fp: + exec(fp.read(), about) # read the contents of README file @@ -19,16 +19,17 @@ def get_version(): setup( - name="videodb", - version=get_version(), - author="videodb", - author_email="contact@videodb.io", + name=about["__title__"], + version=about["__version__"], + author=about["__author__"], + author_email=about["__email__"], + license=about["__license__"], description="VideoDB Python SDK", long_description=long_description, long_description_content_type="text/markdown", - url="https://github.com/video-db/videodb-python", + url=about["__url__"], packages=find_packages(exclude=["tests", "tests.*"]), - python_requires=">=3.9", + python_requires=">=3.8", install_requires=[ "requests>=2.25.1", "backoff>=2.2.1", @@ -37,6 +38,11 @@ def get_version(): classifiers=[ "Intended Audience :: Developers", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: Apache Software License", ], ) diff --git a/videodb/__about__.py b/videodb/__about__.py new file mode 100644 index 0000000..3cc2806 --- /dev/null +++ b/videodb/__about__.py @@ -0,0 +1,10 @@ +""" About information for videodb sdk""" + + + +__version__ = "0.2.15" +__title__ = "videodb" +__author__ = "videodb" +__email__ = "contact@videodb.io" +__url__ = "https://github.com/video-db/videodb-python" +__license__ = "Apache License 2.0" diff --git a/videodb/__init__.py b/videodb/__init__.py index 62accc2..d1d3215 100644 --- a/videodb/__init__.py +++ b/videodb/__init__.py @@ -5,7 +5,22 @@ from typing import Optional from videodb._utils._video import play_stream -from videodb._constants import VIDEO_DB_API, MediaType +from videodb._constants import ( + VIDEO_DB_API, + IndexType, + SceneExtractionType, + MediaType, + SearchType, + Segmenter, + SubtitleAlignment, + SubtitleBorderStyle, + SubtitleStyle, + TextStyle, + TranscodeMode, + ResizeMode, + VideoConfig, + AudioConfig, +) from videodb.client import Connection from videodb.exceptions import ( VideodbError, @@ -16,16 +31,26 @@ logger: logging.Logger = logging.getLogger("videodb") -__version__ = "0.0.3" -__author__ = "videodb" __all__ = [ "VideodbError", "AuthenticationError", "InvalidRequestError", + "IndexType", "SearchError", "play_stream", "MediaType", + "SearchType", + "SubtitleAlignment", + "SubtitleBorderStyle", + "SubtitleStyle", + "TextStyle", + "SceneExtractionType", + "Segmenter", + "TranscodeMode", + "ResizeMode", + "VideoConfig", + "AudioConfig", ] diff --git a/videodb/_constants.py b/videodb/_constants.py index 7d4b864..b98ddab 100644 --- a/videodb/_constants.py +++ b/videodb/_constants.py @@ -1,5 +1,7 @@ """Constants used in the videodb package.""" +from typing import Union +from dataclasses import dataclass VIDEO_DB_API: str = "https://api.videodb.io" @@ -7,14 +9,24 @@ class MediaType: video = "video" audio = "audio" + image = "image" class SearchType: semantic = "semantic" + keyword = "keyword" + scene = "scene" + llm = "llm" class IndexType: - semantic = "semantic" + spoken_word = "spoken_word" + scene = "scene" + + +class SceneExtractionType: + shot_based = "shot" + time_based = "time" class Workflows: @@ -26,13 +38,21 @@ class SemanticSearchDefaultValues: score_threshold = 0.2 +class Segmenter: + time = "time" + word = "word" + sentence = "sentence" + + class ApiPath: collection = "collection" upload = "upload" video = "video" audio = "audio" + image = "image" stream = "stream" thumbnail = "thumbnail" + thumbnails = "thumbnails" upload_url = "upload_url" transcription = "transcription" index = "index" @@ -40,6 +60,27 @@ class ApiPath: compile = "compile" workflow = "workflow" timeline = "timeline" + delete = "delete" + billing = "billing" + usage = "usage" + invoices = "invoices" + scenes = "scenes" + scene = "scene" + frame = "frame" + describe = "describe" + storage = "storage" + download = "download" + title = "title" + rtstream = "rtstream" + status = "status" + event = "event" + alert = "alert" + generate_url = "generate_url" + generate = "generate" + web = "web" + translate = "translate" + dub = "dub" + transcode = "transcode" class Status: @@ -56,3 +97,100 @@ class HttpClientDefaultValues: class MaxSupported: fade_duration = 5 + + +class SubtitleBorderStyle: + no_border = 1 + opaque_box = 3 + outline = 4 + + +class SubtitleAlignment: + bottom_left = 1 + bottom_center = 2 + bottom_right = 3 + middle_left = 9 + middle_center = 10 + middle_right = 11 + top_left = 5 + top_center = 6 + top_right = 7 + + +@dataclass +class SubtitleStyle: + font_name: str = "Arial" + font_size: float = 18 + primary_colour: str = "&H00FFFFFF" # white + secondary_colour: str = "&H000000FF" # blue + outline_colour: str = "&H00000000" # black + back_colour: str = "&H00000000" # black + bold: bool = False + italic: bool = False + underline: bool = False + strike_out: bool = False + scale_x: float = 1.0 + scale_y: float = 1.0 + spacing: float = 0 + angle: float = 0 + border_style: int = SubtitleBorderStyle.outline + outline: float = 1.0 + shadow: float = 0.0 + alignment: int = SubtitleAlignment.bottom_center + margin_l: int = 10 + margin_r: int = 10 + margin_v: int = 10 + + +@dataclass +class TextStyle: + fontsize: int = 24 + fontcolor: str = "black" + fontcolor_expr: str = "" + alpha: float = 1.0 + font: str = "Sans" + box: bool = True + boxcolor: str = "white" + boxborderw: str = "10" + boxw: int = 0 + boxh: int = 0 + line_spacing: int = 0 + text_align: str = "T" + y_align: str = "text" + borderw: int = 0 + bordercolor: str = "black" + expansion: str = "normal" + basetime: int = 0 + fix_bounds: bool = False + text_shaping: bool = True + shadowcolor: str = "black" + shadowx: int = 0 + shadowy: int = 0 + tabsize: int = 4 + x: Union[str, int] = "(main_w-text_w)/2" + y: Union[str, int] = "(main_h-text_h)/2" + + +class TranscodeMode: + lightning = "lightning" + economy = "economy" + + +class ResizeMode: + crop = "crop" + fit = "fit" + pad = "pad" + + +@dataclass +class VideoConfig: + resolution: int = None + quality: int = 23 + framerate: int = None + aspect_ratio: str = None + resize_mode: str = ResizeMode.crop + + +@dataclass +class AudioConfig: + mute: bool = False diff --git a/videodb/_utils/_http_client.py b/videodb/_utils/_http_client.py index ae8f0d0..8633ebb 100644 --- a/videodb/_utils/_http_client.py +++ b/videodb/_utils/_http_client.py @@ -19,6 +19,7 @@ from videodb.exceptions import ( AuthenticationError, InvalidRequestError, + RequestTimeoutError, ) logger = logging.getLogger(__name__) @@ -31,6 +32,7 @@ def __init__( self, api_key: str, base_url: str, + version: str, max_retries: Optional[int] = HttpClientDefaultValues.max_retries, ) -> None: """Create a new http client instance @@ -49,8 +51,13 @@ def __init__( adapter = HTTPAdapter(max_retries=retries) self.session.mount("http://", adapter) self.session.mount("https://", adapter) + self.version = version self.session.headers.update( - {"x-access-token": api_key, "Content-Type": "application/json"} + { + "x-access-token": api_key, + "x-videodb-client": f"videodb-python/{self.version}", + "Content-Type": "application/json", + } ) self.base_url = base_url self.show_progress = False @@ -87,7 +94,7 @@ def _make_request( def _handle_request_error(self, e: requests.exceptions.RequestException) -> None: """Handle request errors""" - + self.show_progress = False if isinstance(e, requests.exceptions.HTTPError): try: error_message = e.response.json().get("message", "Unknown error") @@ -109,8 +116,8 @@ def _handle_request_error(self, e: requests.exceptions.RequestException) -> None ) from None elif isinstance(e, requests.exceptions.Timeout): - raise InvalidRequestError( - "Invalid request: Request timed out", e.response + raise RequestTimeoutError( + "Timeout error: Request timed out", e.response ) from None elif isinstance(e, requests.exceptions.ConnectionError): @@ -133,7 +140,7 @@ def _get_output(self, url: str): response_json.get("status") == Status.in_progress or response_json.get("status") == Status.processing ): - percentage = response_json.get("data").get("percentage") + percentage = response_json.get("data", {}).get("percentage") if percentage and self.show_progress and self.progress_bar: self.progress_bar.n = int(percentage) self.progress_bar.update(0) @@ -169,7 +176,7 @@ def _parse_response(self, response: requests.Response): bar_format="{l_bar}{bar:100}{r_bar}{bar:-100b}", ) response_json = self._get_output( - response_json.get("data").get("output_url") + response_json.get("data", {}).get("output_url") ) if response_json.get("success"): return response_json.get("data") @@ -198,8 +205,11 @@ def get( self.show_progress = show_progress return self._make_request(method=self.session.get, path=path, **kwargs) - def post(self, path: str, data=None, **kwargs) -> requests.Response: + def post( + self, path: str, data=None, show_progress: Optional[bool] = False, **kwargs + ) -> requests.Response: """Make a post request""" + self.show_progress = show_progress return self._make_request(self.session.post, path, json=data, **kwargs) def put(self, path: str, data=None, **kwargs) -> requests.Response: diff --git a/videodb/asset.py b/videodb/asset.py index 97c3d31..6061b4b 100644 --- a/videodb/asset.py +++ b/videodb/asset.py @@ -1,9 +1,10 @@ import copy import logging +import uuid from typing import Optional, Union -from videodb._constants import MaxSupported +from videodb._constants import MaxSupported, TextStyle logger = logging.getLogger(__name__) @@ -32,8 +33,8 @@ class VideoAsset(MediaAsset): def __init__( self, asset_id: str, - start: Optional[int] = 0, - end: Optional[Union[int, None]] = None, + start: Optional[float] = 0, + end: Optional[float] = None, ) -> None: super().__init__(asset_id) self.start: int = start @@ -55,8 +56,8 @@ class AudioAsset(MediaAsset): def __init__( self, asset_id: str, - start: Optional[int] = 0, - end: Optional[Union[int, None]] = None, + start: Optional[float] = 0, + end: Optional[float] = None, disable_other_tracks: Optional[bool] = True, fade_in_duration: Optional[Union[int, float]] = 0, fade_out_duration: Optional[Union[int, float]] = 0, @@ -85,3 +86,65 @@ def __repr__(self) -> str: f"fade_in_duration={self.fade_in_duration}, " f"fade_out_duration={self.fade_out_duration})" ) + + +class ImageAsset(MediaAsset): + def __init__( + self, + asset_id: str, + width: Union[int, str] = 100, + height: Union[int, str] = 100, + x: Union[int, str] = 80, + y: Union[int, str] = 20, + duration: Optional[int] = None, + ) -> None: + super().__init__(asset_id) + self.width = width + self.height = height + self.x = x + self.y = y + self.duration = duration + + def to_json(self) -> dict: + return copy.deepcopy(self.__dict__) + + def __repr__(self) -> str: + return ( + f"ImageAsset(" + f"asset_id={self.asset_id}, " + f"width={self.width}, " + f"height={self.height}, " + f"x={self.x}, " + f"y={self.y}, " + f"duration={self.duration})" + ) + + +class TextAsset(MediaAsset): + def __init__( + self, + text: str, + duration: Optional[int] = None, + style: TextStyle = TextStyle(), + ) -> None: + super().__init__(f"txt-{str(uuid.uuid4())}") + self.text = text + self.duration = duration + self.style: TextStyle = style + + def to_json(self) -> dict: + return { + "text": copy.deepcopy(self.text), + "asset_id": copy.deepcopy(self.asset_id), + "duration": copy.deepcopy(self.duration), + "style": copy.deepcopy(self.style.__dict__), + } + + def __repr__(self) -> str: + return ( + f"TextAsset(" + f"text={self.text}, " + f"asset_id={self.asset_id}, " + f"duration={self.duration}, " + f"style={self.style})" + ) diff --git a/videodb/audio.py b/videodb/audio.py index 7cab2b2..21c250c 100644 --- a/videodb/audio.py +++ b/videodb/audio.py @@ -4,7 +4,17 @@ class Audio: - def __init__(self, _connection, id: str, collection_id: str, **kwargs) -> None: + """Audio class to interact with the Audio + + :ivar str id: Unique identifier for the audio + :ivar str collection_id: ID of the collection this audio belongs to + :ivar str name: Name of the audio file + :ivar float length: Duration of the audio in seconds + """ + + def __init__( + self, _connection, id: str, collection_id: str, **kwargs + ) -> None: self._connection = _connection self.id = id self.collection_id = collection_id @@ -20,5 +30,24 @@ def __repr__(self) -> str: f"length={self.length})" ) + def generate_url(self) -> str: + """Generate the signed url of the audio. + + :raises InvalidRequestError: If the get_url fails + :return: The signed url of the audio + :rtype: str + """ + url_data = self._connection.post( + path=f"{ApiPath.audio}/{self.id}/{ApiPath.generate_url}", + params={"collection_id": self.collection_id}, + ) + return url_data.get("signed_url", None) + def delete(self) -> None: + """Delete the audio. + + :raises InvalidRequestError: If the delete fails + :return: None if the delete is successful + :rtype: None + """ self._connection.delete(f"{ApiPath.audio}/{self.id}") diff --git a/videodb/client.py b/videodb/client.py index 001e586..25ae399 100644 --- a/videodb/client.py +++ b/videodb/client.py @@ -3,16 +3,21 @@ from typing import ( Optional, Union, + List, ) - +from videodb.__about__ import __version__ from videodb._constants import ( ApiPath, + TranscodeMode, + VideoConfig, + AudioConfig, ) from videodb.collection import Collection from videodb._utils._http_client import HttpClient from videodb.video import Video from videodb.audio import Audio +from videodb.image import Image from videodb._upload import ( upload, @@ -22,13 +27,32 @@ class Connection(HttpClient): - def __init__(self, api_key: str, base_url: str) -> None: + """Connection class to interact with the VideoDB""" + + def __init__(self, api_key: str, base_url: str) -> "Connection": + """Initializes a new instance of the Connection class with specified API credentials. + + Note: Users should not initialize this class directly. + Instead use :meth:`videodb.connect() ` + + :param str api_key: API key for authentication + :param str base_url: Base URL of the VideoDB API + :raise ValueError: If the API key is not provided + :return: :class:`Connection ` object, to interact with the VideoDB + :rtype: :class:`videodb.client.Connection` + """ self.api_key = api_key self.base_url = base_url self.collection_id = "default" - super().__init__(api_key, base_url) + super().__init__(api_key=api_key, base_url=base_url, version=__version__) def get_collection(self, collection_id: Optional[str] = "default") -> Collection: + """Get a collection object by its ID. + + :param str collection_id: ID of the collection (optional, default: "default") + :return: :class:`Collection ` object + :rtype: :class:`videodb.collection.Collection` + """ collection_data = self.get(path=f"{ApiPath.collection}/{collection_id}") self.collection_id = collection_data.get("id", "default") return Collection( @@ -36,8 +60,200 @@ def get_collection(self, collection_id: Optional[str] = "default") -> Collection self.collection_id, collection_data.get("name"), collection_data.get("description"), + collection_data.get("is_public", False), + ) + + def get_collections(self) -> List[Collection]: + """Get a list of all collections. + + :return: List of :class:`Collection ` objects + :rtype: list[:class:`videodb.collection.Collection`] + """ + collections_data = self.get(path=ApiPath.collection) + return [ + Collection( + self, + collection.get("id"), + collection.get("name"), + collection.get("description"), + collection.get("is_public", False), + ) + for collection in collections_data.get("collections") + ] + + def create_collection( + self, name: str, description: str, is_public: bool = False + ) -> Collection: + """Create a new collection. + + :param str name: Name of the collection + :param str description: Description of the collection + :param bool is_public: Make collection public (optional, default: False) + :return: :class:`Collection ` object + :rtype: :class:`videodb.collection.Collection` + """ + collection_data = self.post( + path=ApiPath.collection, + data={ + "name": name, + "description": description, + "is_public": is_public, + }, + ) + self.collection_id = collection_data.get("id", "default") + return Collection( + self, + collection_data.get("id"), + collection_data.get("name"), + collection_data.get("description"), + collection_data.get("is_public", False), + ) + + def update_collection(self, id: str, name: str, description: str) -> Collection: + """Update an existing collection. + + :param str id: ID of the collection + :param str name: Name of the collection + :param str description: Description of the collection + :return: :class:`Collection ` object + :rtype: :class:`videodb.collection.Collection` + """ + collection_data = self.patch( + path=f"{ApiPath.collection}/{id}", + data={ + "name": name, + "description": description, + }, + ) + self.collection_id = collection_data.get("id", "default") + return Collection( + self, + collection_data.get("id"), + collection_data.get("name"), + collection_data.get("description"), + collection_data.get("is_public", False), ) + def check_usage(self) -> dict: + """Check the usage. + + :return: Usage data + :rtype: dict + """ + return self.get(path=f"{ApiPath.billing}/{ApiPath.usage}") + + def get_invoices(self) -> List[dict]: + """Get a list of all invoices. + + :return: List of invoices + :rtype: list[dict] + """ + return self.get(path=f"{ApiPath.billing}/{ApiPath.invoices}") + + def create_event(self, event_prompt: str, label: str): + """Create an rtstream event. + + :param str event_prompt: Prompt for the event + :param str label: Label for the event + :return: Event ID + :rtype: str + """ + event_data = self.post( + f"{ApiPath.rtstream}/{ApiPath.event}", + data={"event_prompt": event_prompt, "label": label}, + ) + + return event_data.get("event_id") + + def list_events(self): + """List all rtstream events. + + :return: List of events + :rtype: list[dict] + """ + event_data = self.get(f"{ApiPath.rtstream}/{ApiPath.event}") + return event_data.get("events", []) + + def download(self, stream_link: str, name: str) -> dict: + """Download a file from a stream link. + + :param stream_link: URL of the stream to download + :param name: Name to save the downloaded file as + :return: Download response data + :rtype: dict + """ + return self.post( + path=f"{ApiPath.download}", + data={ + "stream_link": stream_link, + "name": name, + }, + ) + + def youtube_search( + self, + query: str, + result_threshold: Optional[int] = 10, + duration: str = "medium", + ) -> List[dict]: + """Search for a query on YouTube. + + :param str query: Query to search for + :param int result_threshold: Number of results to return (optional) + :param str duration: Duration of the video (optional) + :return: List of YouTube search results + :rtype: List[dict] + """ + search_data = self.post( + path=f"{ApiPath.collection}/{self.collection_id}/{ApiPath.search}/{ApiPath.web}", + data={ + "query": query, + "result_threshold": result_threshold, + "platform": "youtube", + "duration": duration, + }, + ) + return search_data.get("results") + + def transcode( + self, + source: str, + callback_url: str, + mode: TranscodeMode = TranscodeMode.economy, + video_config: VideoConfig = VideoConfig(), + audio_config: AudioConfig = AudioConfig(), + ) -> None: + """Transcode the video + + :param str source: URL of the video to transcode, preferably a downloadable URL + :param str callback_url: URL to receive the callback + :param TranscodeMode mode: Mode of the transcoding + :param VideoConfig video_config: Video configuration (optional) + :param AudioConfig audio_config: Audio configuration (optional) + :return: Transcode job ID + :rtype: str + """ + job_data = self.post( + path=f"{ApiPath.transcode}", + data={ + "source": source, + "callback_url": callback_url, + "mode": mode, + "video_config": video_config.__dict__, + "audio_config": audio_config.__dict__, + }, + ) + return job_data.get("job_id") + + def get_transcode_details(self, job_id: str) -> dict: + """Get the details of a transcode job. + + :param str job_id: ID of the transcode job + :return: Details of the transcode job + :rtype: dict + """ + return self.get(path=f"{ApiPath.transcode}/{job_id}") + def upload( self, file_path: str = None, @@ -46,7 +262,18 @@ def upload( name: Optional[str] = None, description: Optional[str] = None, callback_url: Optional[str] = None, - ) -> Union[Video, Audio, None]: + ) -> Union[Video, Audio, Image, None]: + """Upload a file. + + :param str file_path: Path to the file to upload (optional) + :param str url: URL of the file to upload (optional) + :param MediaType media_type: MediaType object (optional) + :param str name: Name of the file (optional) + :param str description: Description of the file (optional) + :param str callback_url: URL to receive the callback (optional) + :return: :class:`Video