diff --git a/.gitignore b/.gitignore index 84e109c..8ae2cb6 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ dist/* venv/ .vscode/* example.ipynb +example.py diff --git a/README.md b/README.md index f7100fb..7b84481 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ video = conn.upload(url="https://www.youtube.com/") video = conn.upload(file_path="path/to/video.mp4") # get the stream url for the video -stream_url = video.get_stream() +stream_url = video.generate_stream() ``` @@ -113,7 +113,7 @@ collection = conn.get_collection() # get the video from the collection video = collection.get_video("video_id") -# index the video for symantic search +# index the video for semantic search video.index_spoken_words() # search relevant moment in video and stream resultant video clip instantly. @@ -139,7 +139,7 @@ stream_url = result.compile() # get shots of the result returns a list of Shot objects shots = result.get_shots() # get stream url of the shot -short_stream_url = shots[0].get_stream() +short_stream_url = shots[0].generate_stream() ``` @@ -155,10 +155,10 @@ video = collection.get_video("video_id") # get the stream url of the dynamically curated video based on the given timeline sequence # optional parameters: # - timeline: Optional[list[tuple[int, int]] to specify the start and end time of the video -stream_url = video.get_stream(timeline=[(0, 10), (30, 40)]) +stream_url = video.generate_stream(timeline=[(0, 10), (30, 40)]) -# get thumbnail of the video -thumbnail = video.get_thumbnail() +# get thumbnail url of the video +thumbnail_url = video.generate_thumbnail() # get transcript of the video # optional parameters: diff --git a/videodb/__init__.py b/videodb/__init__.py index 02c7fa5..2318106 100644 --- a/videodb/__init__.py +++ b/videodb/__init__.py @@ -4,6 +4,7 @@ import logging from typing import Optional +from videodb._utils._video import play_stream from videodb._constants import VIDEO_DB_API from videodb.client import Connection from videodb.exceptions import ( @@ -23,6 +24,7 @@ "AuthenticationError", "InvalidRequestError", "SearchError", + "play_stream", ] diff --git a/videodb/_constants.py b/videodb/_constants.py index fca23af..2ad2d8a 100644 --- a/videodb/_constants.py +++ b/videodb/_constants.py @@ -2,6 +2,7 @@ VIDEO_DB_API: str = "https://api.videodb.io" +PLAYER_URL: str = "https://console.videodb.io/player" class SearchType: diff --git a/videodb/_utils/_http_client.py b/videodb/_utils/_http_client.py index 0c30c66..07a19c1 100644 --- a/videodb/_utils/_http_client.py +++ b/videodb/_utils/_http_client.py @@ -120,7 +120,7 @@ def _handle_request_error(self, e: requests.exceptions.RequestException) -> None f"Invalid request: {str(e)}", e.response ) from None - @backoff.on_exception(backoff.expo, Exception, max_time=500) + @backoff.on_exception(backoff.expo, Exception, max_time=500, logger=None) def _get_output(self, url: str): """Get the output from an async request""" response_json = self.session.get(url).json() diff --git a/videodb/_utils/_video.py b/videodb/_utils/_video.py new file mode 100644 index 0000000..b5b0bf9 --- /dev/null +++ b/videodb/_utils/_video.py @@ -0,0 +1,23 @@ +import webbrowser as web + +from videodb._constants import PLAYER_URL + + +def play_stream(url: str): + """Play a stream url in the browser/ notebook + + :param str url: The url of the stream + :return: The player url if the stream is opened in the browser or the iframe if the stream is opened in the notebook + """ + player = f"{PLAYER_URL}?url={url}" + opend = web.open(player) + if not opend: + try: + from IPython.display import IFrame + + player_width = 800 + player_height = 400 + return IFrame(player, player_width, player_height) + except ImportError: + return player + return player diff --git a/videodb/search.py b/videodb/search.py index 6543793..f42f645 100644 --- a/videodb/search.py +++ b/videodb/search.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from videodb._utils._video import play_stream from videodb._constants import ( SearchType, ApiPath, @@ -15,8 +16,8 @@ class SearchResult: def __init__(self, _connection, **kwargs): self._connection = _connection self.shots = [] - self.text_summary = None - self.stream = None + self.stream_url = None + self.player_url = None self.collection_id = "default" self._results = kwargs.get("results", []) self._format_results() @@ -38,18 +39,27 @@ def _format_results(self): ) ) + def __repr__(self) -> str: + return ( + f"SearchResult(" + f"collection_id={self.collection_id}, " + f"stream_url={self.stream_url}, " + f"player_url={self.player_url}, " + f"shots={self.shots})" + ) + def get_shots(self) -> List[Shot]: return self.shots def compile(self) -> str: - """Compile the search result shots into a stream link + """Compile the search result shots into a stream url :raises SearchError: If no shots are found in the search results - :return: The stream link + :return: The stream url :rtype: str """ - if self.stream: - return self.stream + if self.stream_url: + return self.stream_url elif self.shots: compile_data = self._connection.post( path=f"{ApiPath.compile}", @@ -62,12 +72,22 @@ def compile(self) -> str: for shot in self.shots ], ) - self.stream = compile_data.get("stream_link") - return self.stream + self.stream_url = compile_data.get("stream_url") + self.player_url = compile_data.get("player_url") + return self.stream_url else: raise SearchError("No shots found in search results to compile") + def play(self) -> str: + """Generate a stream url for the shot and open it in the default browser + + :return: The stream url + :rtype: str + """ + self.compile() + return play_stream(self.stream_url) + class Search(ABC): """Search interface inside video or collection""" diff --git a/videodb/shot.py b/videodb/shot.py index f592616..d715de0 100644 --- a/videodb/shot.py +++ b/videodb/shot.py @@ -2,6 +2,7 @@ from typing import Optional +from videodb._utils._video import play_stream from videodb._constants import ( ApiPath, ) @@ -29,7 +30,8 @@ def __init__( self.end = end self.text = text self.search_score = search_score - self.stream = None + self.stream_url = None + self.player_url = None def __repr__(self) -> str: return ( @@ -40,22 +42,23 @@ def __repr__(self) -> str: f"end={self.end}, " f"text={self.text}, " f"search_score={self.search_score}, " - f"stream={self.stream})" + f"stream_url={self.stream_url}, " + f"player_url={self.player_url})" ) def __getitem__(self, key): """Get an item from the shot object""" return self.__dict__[key] - def get_stream(self) -> str: - """Get the shot into a stream link + def generate_stream(self) -> str: + """Generate a stream url for the shot - :return: The stream link + :return: The stream url :rtype: str """ - if self.stream: - return self.stream + if self.stream_url: + return self.stream_url else: stream_data = self._connection.post( path=f"{ApiPath.video}/{self.video_id}/{ApiPath.stream}", @@ -64,5 +67,15 @@ def get_stream(self) -> str: "length": self.video_length, }, ) - self.stream = stream_data.get("stream_link") - return self.stream + self.stream_url = stream_data.get("stream_url") + self.player_url = stream_data.get("player_url") + return self.stream_url + + def play(self) -> str: + """Generate a stream url for the shot and open it in the default browser/ notebook + + :return: The stream url + :rtype: str + """ + self.generate_stream() + return play_stream(self.stream_url) diff --git a/videodb/video.py b/videodb/video.py index d639795..0a17113 100644 --- a/videodb/video.py +++ b/videodb/video.py @@ -1,3 +1,5 @@ +from typing import Optional +from videodb._utils._video import play_stream from videodb._constants import ( ApiPath, SearchType, @@ -6,7 +8,6 @@ ) from videodb.search import SearchFactory, SearchResult from videodb.shot import Shot -from typing import Optional class Video: @@ -14,10 +15,11 @@ def __init__(self, _connection, id: str, collection_id: str, **kwargs) -> None: self._connection = _connection self.id = id self.collection_id = collection_id - self.stream_link = kwargs.get("stream_link", None) + self.stream_url = kwargs.get("stream_url", None) + self.player_url = kwargs.get("player_url", None) self.name = kwargs.get("name", None) self.description = kwargs.get("description", None) - self.thumbnail = kwargs.get("thumbnail", None) + self.thumbnail_url = kwargs.get("thumbnail_url", None) self.length = float(kwargs.get("length", 0.0)) self.transcript = kwargs.get("transcript", None) self.transcript_text = kwargs.get("transcript_text", None) @@ -27,10 +29,11 @@ def __repr__(self) -> str: f"Video(" f"id={self.id}, " f"collection_id={self.collection_id}, " - f"stream_link={self.stream_link}, " + f"stream_url={self.stream_url}, " + f"player_url={self.player_url}, " f"name={self.name}, " f"description={self.description}, " - f"thumbnail={self.thumbnail}, " + f"thumbnail_url={self.thumbnail_url}, " f"length={self.length})" ) @@ -63,16 +66,16 @@ def delete(self) -> None: """ self._connection.delete(path=f"{ApiPath.video}/{self.id}") - def get_stream(self, timeline: Optional[list[tuple[int, int]]] = None) -> str: - """Get the stream link of the video + def generate_stream(self, timeline: Optional[list[tuple[int, int]]] = None) -> str: + """Generate the stream url of the video :param list timeline: The timeline of the video to be streamed. Defaults to None. :raises InvalidRequestError: If the get_stream fails - :return: The stream link of the video + :return: The stream url of the video :rtype: str """ - if not timeline and self.stream_link: - return self.stream_link + if not timeline and self.stream_url: + return self.stream_url stream_data = self._connection.post( path=f"{ApiPath.video}/{self.id}/{ApiPath.stream}", @@ -81,16 +84,16 @@ def get_stream(self, timeline: Optional[list[tuple[int, int]]] = None) -> str: "length": self.length, }, ) - return stream_data.get("stream_link") + return stream_data.get("stream_url", None) - def get_thumbnail(self): - if self.thumbnail: - return self.thumbnail + def generate_thumbnail(self): + if self.thumbnail_url: + return self.thumbnail_url thumbnail_data = self._connection.get( path=f"{ApiPath.video}/{self.id}/{ApiPath.thumbnail}" ) - self.thumbnail = thumbnail_data.get("thumbnail") - return self.thumbnail + self.thumbnail_url = thumbnail_data.get("thumbnail_url") + return self.thumbnail_url def _fetch_transcript(self, force: bool = False) -> None: if self.transcript and not force: @@ -111,7 +114,7 @@ def get_transcript_text(self, force: bool = False) -> str: return self.transcript_text def index_spoken_words(self) -> None: - """Symantic indexing of spoken words in the video + """Semantic indexing of spoken words in the video :raises InvalidRequestError: If the video is already indexed :return: None if the indexing is successful @@ -132,7 +135,7 @@ def add_subtitle(self) -> str: "type": Workflows.add_subtitles, }, ) - return subtitle_data.get("stream_link") + return subtitle_data.get("stream_url", None) def insert_video(self, video, timestamp: float) -> str: """Insert a video into another video @@ -140,7 +143,7 @@ def insert_video(self, video, timestamp: float) -> str: :param Video video: The video to be inserted :param float timestamp: The timestamp where the video should be inserted :raises InvalidRequestError: If the insert fails - :return: The stream link of the inserted video + :return: The stream url of the inserted video :rtype: str """ if timestamp > float(self.length): @@ -171,5 +174,12 @@ def insert_video(self, video, timestamp: float) -> str: for shot in all_shots ], ) - stream_link = compile_data.get("stream_link") - return stream_link + return compile_data.get("stream_url", None) + + def play(self) -> str: + """Open the player url in the browser/iframe and return the stream url + + :return: The stream url + :rtype: str + """ + return play_stream(self.stream_url)