├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── feature_request.yml ├── pull_request_template.md └── workflows │ ├── ci.yaml │ └── trigger-agent-toolkit-update.yaml ├── .gitignore ├── LICENSE ├── README.md ├── requirements-dev.txt ├── requirements.txt ├── setup.py ├── tests ├── __init__.py └── test_exceptions.py └── videodb ├── __about__.py ├── __init__.py ├── _constants.py ├── _upload.py ├── _utils ├── __init__.py ├── _http_client.py └── _video.py ├── asset.py ├── audio.py ├── client.py ├── collection.py ├── exceptions.py ├── image.py ├── rtstream.py ├── scene.py ├── search.py ├── shot.py ├── timeline.py └── video.py /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Create a report to help us improve 3 | labels: ['bug'] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks for taking the time to fill out this bug report! 9 | - type: checkboxes 10 | attributes: 11 | label: Confirm this is a new bug report 12 | description: > 13 | Select the checkboxes that apply to this bug report. If you're not sure about any of these, don't worry! We'll help you figure it out. 14 | options: 15 | - label: Possible new bug in VideoDB Python Client 16 | required: false 17 | - label: Potential new bug in VideoDB API 18 | required: false 19 | - label: I've checked the current issues, and there's no record of this bug 20 | required: false 21 | - type: textarea 22 | attributes: 23 | label: Current Behavior 24 | description: > 25 | A clear and concise description of what the bug is. 26 | placeholder: > 27 | I intended to perform action X, but unexpectedly encountered outcome Y. 28 | validations: 29 | required: true 30 | - type: textarea 31 | attributes: 32 | label: Expected Behavior 33 | description: > 34 | A clear and concise description of what you expected to happen. 35 | placeholder: > 36 | I expected outcome Y to occur. 37 | validations: 38 | required: true 39 | - type: textarea 40 | attributes: 41 | label: Steps to Reproduce 42 | description: > 43 | Steps to reproduce the behavior: 44 | placeholder: | 45 | 1. Fetch a '...' 46 | 2. Update the '....' 47 | 3. See error 48 | validations: 49 | required: true 50 | - type: textarea 51 | attributes: 52 | label: Relevant Logs and/or Screenshots 53 | description: > 54 | If applicable, add logs and/or screenshots to help explain your problem. 55 | validations: 56 | required: false 57 | - type: textarea 58 | attributes: 59 | label: Environment 60 | description: | 61 | Please complete the following information: 62 | eg: 63 | - OS: Ubuntu 20.04 64 | - Python: 3.9.1 65 | - Videodb: 0.0.1 66 | value: | 67 | - OS: 68 | - Python: 69 | - Videodb: 70 | validations: 71 | required: false 72 | - type: textarea 73 | attributes: 74 | label: Additional Context 75 | description: > 76 | Add any other context about the problem here. 77 | validations: 78 | required: false 79 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature 2 | description: Submit a proposal/request for a new feature 3 | labels: ['enhancement'] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks for taking the time to fill out this feature request! 9 | - type: checkboxes 10 | attributes: 11 | label: Confirm this is a new feature request 12 | description: > 13 | Select the checkboxes that apply to this feature request. If you're not sure about any of these, don't worry! We'll help you figure it out. 14 | options: 15 | - label: Possible new feature in VideoDB Python Client 16 | required: false 17 | - label: Potential new feature in VideoDB API 18 | required: false 19 | - label: I've checked the current issues, and there's no record of this feature request 20 | required: false 21 | - type: textarea 22 | attributes: 23 | label: Describe the feature 24 | description: > 25 | A clear and concise description of what the feature is and why it's needed. 26 | validations: 27 | required: true 28 | - type: textarea 29 | attributes: 30 | label: Describe the solution you'd like 31 | description: | 32 | A clear and concise description of what you want to happen. 33 | validations: 34 | required: true 35 | - type: textarea 36 | attributes: 37 | label: Describe alternatives you've considered 38 | description: > 39 | A clear and concise description of any alternative solutions or features you've considered. 40 | validations: 41 | required: false 42 | - type: textarea 43 | attributes: 44 | label: Additional Context 45 | description: > 46 | Add any other context about the feature request here. 47 | validations: 48 | required: false 49 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Pull Request 2 | 3 | **Description:** 4 | Describe the purpose of this pull request. 5 | 6 | **Changes:** 7 | - [ ] Feature A 8 | - [ ] Bugfix B 9 | 10 | **Related Issues:** 11 | - Closes #123 12 | - Addresses #456 13 | 14 | **Testing:** 15 | Describe any testing steps that have been taken or are necessary. 16 | Make sure to take in account any existing code change that require some feature to be re-tested. 17 | 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration Workflow 2 | # Trigger the workflow on push or pull request, on main branch 3 | 4 | on: 5 | push: 6 | branches: [ main ] 7 | pull_request: 8 | branches: [ main ] 9 | 10 | # Define environment variables 11 | env: 12 | PYTHON_VERSION: 3.9 13 | APP_DIR: ./ 14 | 15 | jobs: 16 | lint: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Set up Python 3 21 | uses: actions/setup-python@v2 22 | with: 23 | python-version: ${{ env.PYTHON_VERSION }} 24 | 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install -r requirements-dev.txt 29 | 30 | - name: Run linter 31 | run: | 32 | ruff check ${{ env.APP_DIR }} 33 | 34 | test: 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v2 38 | - name: Set up Python 3 39 | uses: actions/setup-python@v2 40 | with: 41 | python-version: ${{ env.PYTHON_VERSION }} 42 | 43 | - name: Install dependencies 44 | run: | 45 | python -m pip install --upgrade pip 46 | pip install -r requirements.txt 47 | pip install -r requirements-dev.txt 48 | 49 | - name: Run tests 50 | run: | 51 | python -m pytest tests/ 52 | 53 | build: 54 | runs-on: ubuntu-latest 55 | steps: 56 | - uses: actions/checkout@v2 57 | - name: Set up Python 3 58 | uses: actions/setup-python@v2 59 | with: 60 | python-version: ${{ env.PYTHON_VERSION }} 61 | 62 | - name: Install dependencies 63 | run: | 64 | python -m pip install --upgrade pip 65 | pip install -r requirements.txt 66 | pip install -r requirements-dev.txt 67 | 68 | - name: Build 69 | run: | 70 | python setup.py sdist bdist_wheel 71 | twine check dist/* 72 | -------------------------------------------------------------------------------- /.github/workflows/trigger-agent-toolkit-update.yaml: -------------------------------------------------------------------------------- 1 | name: Trigger Agent Toolkit Update 2 | 3 | on: 4 | pull_request: 5 | types: [closed] 6 | 7 | jobs: 8 | trigger-videodb-helper-update: 9 | if: ${{ github.event.pull_request.merged && github.event.pull_request.base.ref == 'main' }} 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Trigger Agent Toolkit Update workflow via repository_dispatch 13 | run: | 14 | curl -X POST -H "Accept: application/vnd.github+json" \ 15 | -H "Authorization: Bearer ${{ secrets.AGENT_TOOLKIT_TOKEN }}" \ 16 | -H "X-GitHub-Api-Version: 2022-11-28" \ 17 | https://api.github.com/repos/video-db/agent-toolkit/dispatches \ 18 | -d '{"event_type": "sdk-context-update", "client_payload": {"pr_number": ${{ github.event.pull_request.number }}}}' 19 | 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | __pycache__/* 3 | */__pycache__ 4 | *.pyc 5 | *.log 6 | .DS_Store 7 | log/ 8 | *.out 9 | *.zip 10 | .idea/* 11 | .env 12 | build/* 13 | dist/* 14 | *.egg-info/* 15 | venv/ 16 | .vscode/* 17 | example.ipynb 18 | example.py 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | [![PyPI version][pypi-shield]][pypi-url] 8 | [![Stargazers][stars-shield]][stars-url] 9 | [![Issues][issues-shield]][issues-url] 10 | [![Website][website-shield]][website-url] 11 | 12 | 13 |
14 |

15 | 16 | Logo 17 | 18 | 19 |

VideoDB Python SDK

20 | 21 |

22 | Video Database for your AI Applications 23 |
24 | Explore the docs » 25 |
26 |
27 | View Demo 28 | · 29 | Report Bug 30 | · 31 | Request Feature 32 |

33 |

34 | 35 | 36 | 37 | # VideoDB Python SDK 38 | 39 | VideoDB Python SDK allows you to interact with the VideoDB serverless database. Manage videos as intelligent data, not files. It's scalable, cost-efficient & optimized for AI applications and LLM integration. 40 | 41 | 42 | 44 | 45 | 46 | 47 | ## Installation 48 | 49 | To install the package, run the following command in your terminal: 50 | 51 | ``` 52 | pip install videodb 53 | ``` 54 | 55 | 56 | 57 | ## Quick Start 58 | 59 | ### Creating a Connection 60 | 61 | Get an API key from the [VideoDB console](https://console.videodb.io). Free for first 50 uploads _(No credit card required)_. 62 | 63 | ```python 64 | import videodb 65 | conn = videodb.connect(api_key="YOUR_API_KEY") 66 | ``` 67 | 68 | ## Working with a Single Video 69 | 70 | --- 71 | 72 | ### ⬆️ Uploading a Video 73 | 74 | Now that you have established a connection to VideoDB, you can upload your videos using `conn.upload()`. 75 | You can directly upload from `youtube`, `any public url`, `S3 bucket` or a `local file path`. A default collection is created when you create your first connection. 76 | 77 | `upload` method returns a `Video` object. 78 | 79 | ```python 80 | # Upload a video by url 81 | video = conn.upload(url="https://www.youtube.com/watch?v=WDv4AWk0J3U") 82 | 83 | # Upload a video from file system 84 | video_f = conn.upload(file_path="./my_video.mp4") 85 | 86 | ``` 87 | 88 | ### 📺 View your Video 89 | 90 | Once uploaded, your video is immediately available for viewing in 720p resolution. ⚡️ 91 | 92 | - Generate a streamable url for the video using video.generate_stream() 93 | - Preview the video using video.play(). This will open the video in your default browser/notebook 94 | 95 | ```python 96 | video.generate_stream() 97 | video.play() 98 | ``` 99 | 100 | ### ⛓️ Stream Specific Sections of Videos 101 | 102 | You can easily clip specific sections of a video by passing a timeline of the start and end timestamps (in seconds) as a parameter. 103 | For example, this will generate and play a compilation of the first `10 seconds` and the clip between the `120th` and the `140th` second. 104 | 105 | ```python 106 | stream_link = video.generate_stream(timeline=[[0,10], [120,140]]) 107 | play_stream(stream_link) 108 | ``` 109 | 110 | ### 🔍 Search Inside a Video 111 | 112 | To search bits inside a video, you have to `index` the video first. This can be done by a simple command. 113 | _P.S. Indexing may take some time for longer videos._ 114 | 115 | ```python 116 | video.index_spoken_words() 117 | result = video.search("Morning Sunlight") 118 | result.play() 119 | video.get_transcript() 120 | ``` 121 | 122 | `Videodb` is launching more indexing options in upcoming versions. As of now you can try the `semantic` index - Index by spoken words. 123 | 124 | In the future you'll be able to index videos using: 125 | 126 | 1. **Scene** - Visual concepts and events. 127 | 2. **Faces**. 128 | 3. **Specific domain Index** like Football, Baseball, Drone footage, Cricket etc. 129 | 130 | ### Viewing Search Results 131 | 132 | `video.search()` returns a `SearchResults` object, which contains the sections or as we call them, `shots` of videos which semantically match your search query. 133 | 134 | - `result.get_shots()` Returns a list of Shot(s) that matched the search query. 135 | - `result.play()` Returns a playable url for the video (similar to video.play(); you can open this link in the browser, or embed it into your website using an iframe). 136 | 137 | ## RAG: Search inside Multiple Videos 138 | 139 | --- 140 | 141 | `VideoDB` can store and search inside multiple videos with ease. By default, videos are uploaded to your default collection. 142 | 143 | ### 🔄 Using Collection to Upload Multiple Videos 144 | 145 | ```python 146 | # Get the default collection 147 | coll = conn.get_collection() 148 | 149 | # Upload Videos to a collection 150 | coll.upload(url="https://www.youtube.com/watch?v=lsODSDmY4CY") 151 | coll.upload(url="https://www.youtube.com/watch?v=vZ4kOr38JhY") 152 | coll.upload(url="https://www.youtube.com/watch?v=uak_dXHh6s4") 153 | ``` 154 | 155 | - `conn.get_collection()` : Returns a Collection object; the default collection. 156 | - `coll.get_videos()` : Returns a list of Video objects; all videos in the collections. 157 | - `coll.get_video(video_id)`: Returns a Video object, corresponding video from the provided `video_id`. 158 | - `coll.delete_video(video_id)`: Deletes the video from the Collection. 159 | 160 | ### 📂 Search Inside Collection 161 | 162 | You can simply Index all the videos in a collection and use the search method to find relevant results. 163 | Here we are indexing the spoken content of a collection and performing semantic search. 164 | 165 | ```python 166 | # Index all videos in collection 167 | for video in coll.get_videos(): 168 | video.index_spoken_words() 169 | 170 | # search in the collection of videos 171 | results = coll.search(query = "What is Dopamine?") 172 | results.play() 173 | ``` 174 | 175 | The result here has all the matching bits in a single stream from your collection. You can use these results in your application right away. 176 | 177 | ### 🌟 Explore the Video object 178 | 179 | There are multiple methods available on a Video Object, that can be helpful for your use-case. 180 | 181 | **Get the Transcript** 182 | 183 | ```python 184 | # words with timestamps 185 | text_json = video.get_transcript() 186 | text = video.get_transcript_text() 187 | print(text) 188 | ``` 189 | 190 | **Add Subtitles to a video** 191 | 192 | It returns a new stream instantly with subtitles added to the video. 193 | 194 | ```python 195 | new_stream = video.add_subtitle() 196 | play_stream(new_stream) 197 | ``` 198 | 199 | **Get Thumbnail of a Video:** 200 | 201 | `video.generate_thumbnail()`: Returns a thumbnail image of video. 202 | 203 | **Delete a video:** 204 | 205 | `video.delete()`: Deletes the video. 206 | 207 | Checkout more examples and tutorials 👉 [Build with VideoDB](https://docs.videodb.io/build-with-videodb-35) to explore what you can build with `VideoDB`. 208 | 209 | --- 210 | 211 | 212 | 213 | ## Roadmap 214 | 215 | - Adding More Indexes : `Face`, `Scene`, `Security`, `Events`, and `Sports` 216 | - Give prompt support to generate thumbnails using GenAI. 217 | - Give prompt support to access content. 218 | - Give prompt support to edit videos. 219 | - See the [open issues](https://github.com/video-db/videodb-python/issues) for a list of proposed features (and known issues). 220 | 221 | --- 222 | 223 | 224 | 225 | ## Contributing 226 | 227 | Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **greatly appreciated**. 228 | 229 | 1. Fork the Project 230 | 2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) 231 | 3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) 232 | 4. Push to the Branch (`git push origin feature/AmazingFeature`) 233 | 5. Open a Pull Request 234 | 235 | --- 236 | 237 | 238 | 239 | 240 | [pypi-shield]: https://img.shields.io/pypi/v/videodb?style=for-the-badge 241 | [pypi-url]: https://pypi.org/project/videodb/ 242 | [python-shield]: https://img.shields.io/pypi/pyversions/videodb?style=for-the-badge 243 | [stars-shield]: https://img.shields.io/github/stars/video-db/videodb-python.svg?style=for-the-badge 244 | [stars-url]: https://github.com/video-db/videodb-python/stargazers 245 | [issues-shield]: https://img.shields.io/github/issues/video-db/videodb-python.svg?style=for-the-badge 246 | [issues-url]: https://github.com/video-db/videodb-python/issues 247 | [website-shield]: https://img.shields.io/website?url=https%3A%2F%2Fvideodb.io%2F&style=for-the-badge&label=videodb.io 248 | [website-url]: https://videodb.io/ 249 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | ruff==0.1.7 2 | pytest==7.4.3 3 | twine==5.1.1 4 | wheel==0.42.0 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.31.0 2 | backoff==2.2.1 3 | tqdm==4.66.1 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # package setup 2 | import os 3 | from setuptools import setup, find_packages 4 | 5 | ROOT = os.path.dirname(os.path.abspath(__file__)) 6 | 7 | 8 | # Read in the package version per recommendations from: 9 | # https://packaging.python.org/guides/single-sourcing-package-version/ 10 | 11 | about_path = os.path.join(ROOT, "videodb", "__about__.py") 12 | about = {} 13 | with open(about_path) as fp: 14 | exec(fp.read(), about) 15 | 16 | 17 | # read the contents of README file 18 | long_description = open(os.path.join(ROOT, "README.md"), "r", encoding="utf-8").read() 19 | 20 | 21 | setup( 22 | name=about["__title__"], 23 | version=about["__version__"], 24 | author=about["__author__"], 25 | author_email=about["__email__"], 26 | license=about["__license__"], 27 | description="VideoDB Python SDK", 28 | long_description=long_description, 29 | long_description_content_type="text/markdown", 30 | url=about["__url__"], 31 | packages=find_packages(exclude=["tests", "tests.*"]), 32 | python_requires=">=3.8", 33 | install_requires=[ 34 | "requests>=2.25.1", 35 | "backoff>=2.2.1", 36 | "tqdm>=4.66.1", 37 | ], 38 | classifiers=[ 39 | "Intended Audience :: Developers", 40 | "Programming Language :: Python :: 3", 41 | "Programming Language :: Python :: 3.8", 42 | "Programming Language :: Python :: 3.9", 43 | "Programming Language :: Python :: 3.10", 44 | "Programming Language :: Python :: 3.11", 45 | "Programming Language :: Python :: 3.12", 46 | "License :: OSI Approved :: Apache Software License", 47 | ], 48 | ) 49 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/video-db/videodb-python/48ee29d8aad107c6f8e81347b9178b8e32e8ffd1/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_exceptions.py: -------------------------------------------------------------------------------- 1 | from videodb.exceptions import ( 2 | VideodbError, 3 | AuthenticationError, 4 | InvalidRequestError, 5 | SearchError, 6 | ) 7 | 8 | 9 | def test_videodb_error(): 10 | try: 11 | raise VideodbError("An error occurred", cause="Something") 12 | 13 | except VideodbError as e: 14 | assert str(e) == "An error occurred caused by Something" 15 | assert e.cause == "Something" 16 | 17 | 18 | def test_authentication_error(): 19 | try: 20 | raise AuthenticationError( 21 | "An error occurred with authentication", response="Something" 22 | ) 23 | 24 | except AuthenticationError as e: 25 | print(e) 26 | assert str(e) == "An error occurred with authentication " 27 | assert e.response == "Something" 28 | 29 | 30 | def test_invalid_request_error(): 31 | try: 32 | raise InvalidRequestError( 33 | "An error occurred with request", response="Something" 34 | ) 35 | 36 | except InvalidRequestError as e: 37 | assert str(e) == "An error occurred with request " 38 | assert e.response == "Something" 39 | 40 | 41 | def test_search_error(): 42 | try: 43 | raise SearchError("An error occurred with search") 44 | 45 | except SearchError as e: 46 | assert str(e) == "An error occurred with search " 47 | -------------------------------------------------------------------------------- /videodb/__about__.py: -------------------------------------------------------------------------------- 1 | """ About information for videodb sdk""" 2 | 3 | 4 | __version__ = "0.2.14" 5 | __title__ = "videodb" 6 | __author__ = "videodb" 7 | __email__ = "contact@videodb.io" 8 | __url__ = "https://github.com/video-db/videodb-python" 9 | __license__ = "Apache License 2.0" 10 | -------------------------------------------------------------------------------- /videodb/__init__.py: -------------------------------------------------------------------------------- 1 | """Videodb API client library""" 2 | 3 | import os 4 | import logging 5 | 6 | from typing import Optional 7 | from videodb._utils._video import play_stream 8 | from videodb._constants import ( 9 | VIDEO_DB_API, 10 | IndexType, 11 | SceneExtractionType, 12 | MediaType, 13 | SearchType, 14 | Segmenter, 15 | SubtitleAlignment, 16 | SubtitleBorderStyle, 17 | SubtitleStyle, 18 | TextStyle, 19 | ) 20 | from videodb.client import Connection 21 | from videodb.exceptions import ( 22 | VideodbError, 23 | AuthenticationError, 24 | InvalidRequestError, 25 | SearchError, 26 | ) 27 | 28 | logger: logging.Logger = logging.getLogger("videodb") 29 | 30 | 31 | __all__ = [ 32 | "VideodbError", 33 | "AuthenticationError", 34 | "InvalidRequestError", 35 | "IndexType", 36 | "SearchError", 37 | "play_stream", 38 | "MediaType", 39 | "SearchType", 40 | "SubtitleAlignment", 41 | "SubtitleBorderStyle", 42 | "SubtitleStyle", 43 | "TextStyle", 44 | "SceneExtractionType", 45 | "Segmenter", 46 | ] 47 | 48 | 49 | def connect( 50 | api_key: str = None, 51 | base_url: Optional[str] = VIDEO_DB_API, 52 | log_level: Optional[int] = logging.INFO, 53 | ) -> Connection: 54 | """A client for interacting with a videodb via REST API 55 | 56 | :param str api_key: The api key to use for authentication 57 | :param str base_url: (optional) The base url to use for the api 58 | :param int log_level: (optional) The log level to use for the logger 59 | :return: A connection object 60 | :rtype: videodb.client.Connection 61 | """ 62 | 63 | logger.setLevel(log_level) 64 | if api_key is None: 65 | api_key = os.environ.get("VIDEO_DB_API_KEY") 66 | if api_key is None: 67 | raise AuthenticationError( 68 | "No API key provided. Set an API key either as an environment variable (VIDEO_DB_API_KEY) or pass it as an argument." 69 | ) 70 | 71 | return Connection(api_key, base_url) 72 | -------------------------------------------------------------------------------- /videodb/_constants.py: -------------------------------------------------------------------------------- 1 | """Constants used in the videodb package.""" 2 | 3 | from typing import Union 4 | from dataclasses import dataclass 5 | 6 | VIDEO_DB_API: str = "https://api.videodb.io" 7 | 8 | 9 | class MediaType: 10 | video = "video" 11 | audio = "audio" 12 | image = "image" 13 | 14 | 15 | class SearchType: 16 | semantic = "semantic" 17 | keyword = "keyword" 18 | scene = "scene" 19 | llm = "llm" 20 | 21 | 22 | class IndexType: 23 | spoken_word = "spoken_word" 24 | scene = "scene" 25 | 26 | 27 | class SceneExtractionType: 28 | shot_based = "shot" 29 | time_based = "time" 30 | 31 | 32 | class Workflows: 33 | add_subtitles = "add_subtitles" 34 | 35 | 36 | class SemanticSearchDefaultValues: 37 | result_threshold = 5 38 | score_threshold = 0.2 39 | 40 | 41 | class Segmenter: 42 | time = "time" 43 | word = "word" 44 | sentence = "sentence" 45 | 46 | 47 | class ApiPath: 48 | collection = "collection" 49 | upload = "upload" 50 | video = "video" 51 | audio = "audio" 52 | image = "image" 53 | stream = "stream" 54 | thumbnail = "thumbnail" 55 | thumbnails = "thumbnails" 56 | upload_url = "upload_url" 57 | transcription = "transcription" 58 | index = "index" 59 | search = "search" 60 | compile = "compile" 61 | workflow = "workflow" 62 | timeline = "timeline" 63 | delete = "delete" 64 | billing = "billing" 65 | usage = "usage" 66 | invoices = "invoices" 67 | scenes = "scenes" 68 | scene = "scene" 69 | frame = "frame" 70 | describe = "describe" 71 | storage = "storage" 72 | download = "download" 73 | title = "title" 74 | rtstream = "rtstream" 75 | status = "status" 76 | event = "event" 77 | alert = "alert" 78 | generate_url = "generate_url" 79 | generate = "generate" 80 | web = "web" 81 | translate = "translate" 82 | dub = "dub" 83 | 84 | 85 | class Status: 86 | processing = "processing" 87 | in_progress = "in progress" 88 | 89 | 90 | class HttpClientDefaultValues: 91 | max_retries = 1 92 | timeout = 30 93 | backoff_factor = 0.1 94 | status_forcelist = [502, 503, 504] 95 | 96 | 97 | class MaxSupported: 98 | fade_duration = 5 99 | 100 | 101 | class SubtitleBorderStyle: 102 | no_border = 1 103 | opaque_box = 3 104 | outline = 4 105 | 106 | 107 | class SubtitleAlignment: 108 | bottom_left = 1 109 | bottom_center = 2 110 | bottom_right = 3 111 | middle_left = 9 112 | middle_center = 10 113 | middle_right = 11 114 | top_left = 5 115 | top_center = 6 116 | top_right = 7 117 | 118 | 119 | @dataclass 120 | class SubtitleStyle: 121 | font_name: str = "Arial" 122 | font_size: float = 18 123 | primary_colour: str = "&H00FFFFFF" # white 124 | secondary_colour: str = "&H000000FF" # blue 125 | outline_colour: str = "&H00000000" # black 126 | back_colour: str = "&H00000000" # black 127 | bold: bool = False 128 | italic: bool = False 129 | underline: bool = False 130 | strike_out: bool = False 131 | scale_x: float = 1.0 132 | scale_y: float = 1.0 133 | spacing: float = 0 134 | angle: float = 0 135 | border_style: int = SubtitleBorderStyle.outline 136 | outline: float = 1.0 137 | shadow: float = 0.0 138 | alignment: int = SubtitleAlignment.bottom_center 139 | margin_l: int = 10 140 | margin_r: int = 10 141 | margin_v: int = 10 142 | 143 | 144 | @dataclass 145 | class TextStyle: 146 | fontsize: int = 24 147 | fontcolor: str = "black" 148 | fontcolor_expr: str = "" 149 | alpha: float = 1.0 150 | font: str = "Sans" 151 | box: bool = True 152 | boxcolor: str = "white" 153 | boxborderw: str = "10" 154 | boxw: int = 0 155 | boxh: int = 0 156 | line_spacing: int = 0 157 | text_align: str = "T" 158 | y_align: str = "text" 159 | borderw: int = 0 160 | bordercolor: str = "black" 161 | expansion: str = "normal" 162 | basetime: int = 0 163 | fix_bounds: bool = False 164 | text_shaping: bool = True 165 | shadowcolor: str = "black" 166 | shadowx: int = 0 167 | shadowy: int = 0 168 | tabsize: int = 4 169 | x: Union[str, int] = "(main_w-text_w)/2" 170 | y: Union[str, int] = "(main_h-text_h)/2" 171 | -------------------------------------------------------------------------------- /videodb/_upload.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from typing import Optional 4 | from requests import HTTPError 5 | 6 | 7 | from videodb._constants import ( 8 | ApiPath, 9 | ) 10 | 11 | from videodb.exceptions import ( 12 | VideodbError, 13 | ) 14 | 15 | 16 | def upload( 17 | _connection, 18 | file_path: str = None, 19 | url: str = None, 20 | media_type: Optional[str] = None, 21 | name: Optional[str] = None, 22 | description: Optional[str] = None, 23 | callback_url: Optional[str] = None, 24 | ) -> dict: 25 | if not file_path and not url: 26 | raise VideodbError("Either file_path or url is required") 27 | if file_path and url: 28 | raise VideodbError("Only one of file_path or url is allowed") 29 | 30 | if file_path: 31 | try: 32 | name = file_path.split("/")[-1].split(".")[0] if not name else name 33 | upload_url_data = _connection.get( 34 | path=f"{ApiPath.collection}/{_connection.collection_id}/{ApiPath.upload_url}", 35 | params={"name": name}, 36 | ) 37 | upload_url = upload_url_data.get("upload_url") 38 | with open(file_path, "rb") as file: 39 | files = {"file": (name, file)} 40 | response = requests.post(upload_url, files=files) 41 | response.raise_for_status() 42 | url = upload_url 43 | 44 | except FileNotFoundError as e: 45 | raise VideodbError("File not found", cause=e) 46 | 47 | except HTTPError as e: 48 | raise VideodbError("Error while uploading file", cause=e) 49 | 50 | upload_data = _connection.post( 51 | path=f"{ApiPath.collection}/{_connection.collection_id}/{ApiPath.upload}", 52 | data={ 53 | "url": url, 54 | "name": name, 55 | "description": description, 56 | "callback_url": callback_url, 57 | "media_type": media_type, 58 | }, 59 | ) 60 | return upload_data 61 | -------------------------------------------------------------------------------- /videodb/_utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/video-db/videodb-python/48ee29d8aad107c6f8e81347b9178b8e32e8ffd1/videodb/_utils/__init__.py -------------------------------------------------------------------------------- /videodb/_utils/_http_client.py: -------------------------------------------------------------------------------- 1 | """Http client Module.""" 2 | 3 | import logging 4 | import requests 5 | import backoff 6 | 7 | from tqdm import tqdm 8 | from typing import ( 9 | Callable, 10 | Optional, 11 | ) 12 | from requests.adapters import HTTPAdapter 13 | from requests.packages.urllib3.util.retry import Retry 14 | 15 | from videodb._constants import ( 16 | HttpClientDefaultValues, 17 | Status, 18 | ) 19 | from videodb.exceptions import ( 20 | AuthenticationError, 21 | InvalidRequestError, 22 | RequestTimeoutError, 23 | ) 24 | 25 | logger = logging.getLogger(__name__) 26 | 27 | 28 | class HttpClient: 29 | """Http client for making requests""" 30 | 31 | def __init__( 32 | self, 33 | api_key: str, 34 | base_url: str, 35 | version: str, 36 | max_retries: Optional[int] = HttpClientDefaultValues.max_retries, 37 | ) -> None: 38 | """Create a new http client instance 39 | 40 | :param str api_key: The api key to use for authentication 41 | :param str base_url: The base url to use for the api 42 | :param int max_retries: (optional) The maximum number of retries to make for a request 43 | """ 44 | self.session = requests.Session() 45 | 46 | retries = Retry( 47 | total=max_retries, 48 | backoff_factor=HttpClientDefaultValues.backoff_factor, 49 | status_forcelist=HttpClientDefaultValues.status_forcelist, 50 | ) 51 | adapter = HTTPAdapter(max_retries=retries) 52 | self.session.mount("http://", adapter) 53 | self.session.mount("https://", adapter) 54 | self.version = version 55 | self.session.headers.update( 56 | { 57 | "x-access-token": api_key, 58 | "x-videodb-client": f"videodb-python/{self.version}", 59 | "Content-Type": "application/json", 60 | } 61 | ) 62 | self.base_url = base_url 63 | self.show_progress = False 64 | self.progress_bar = None 65 | logger.debug(f"Initialized http client with base url: {self.base_url}") 66 | 67 | def _make_request( 68 | self, 69 | method: Callable[..., requests.Response], 70 | path: str, 71 | base_url: Optional[str] = None, 72 | headers: Optional[dict] = None, 73 | **kwargs, 74 | ): 75 | """Make a request to the api 76 | 77 | :param Callable method: The method to use for the request 78 | :param str path: The path to make the request to 79 | :param str base_url: (optional) The base url to use for the request 80 | :param dict headers: (optional) The headers to use for the request 81 | :param kwargs: The keyword arguments to pass to the request method 82 | :return: json response from the request 83 | """ 84 | try: 85 | url = f"{base_url or self.base_url}/{path}" 86 | timeout = kwargs.pop("timeout", HttpClientDefaultValues.timeout) 87 | request_headers = {**self.session.headers, **(headers or {})} 88 | response = method(url, headers=request_headers, timeout=timeout, **kwargs) 89 | response.raise_for_status() 90 | return self._parse_response(response) 91 | 92 | except requests.exceptions.RequestException as e: 93 | self._handle_request_error(e) 94 | 95 | def _handle_request_error(self, e: requests.exceptions.RequestException) -> None: 96 | """Handle request errors""" 97 | self.show_progress = False 98 | if isinstance(e, requests.exceptions.HTTPError): 99 | try: 100 | error_message = e.response.json().get("message", "Unknown error") 101 | except ValueError: 102 | error_message = e.response.text 103 | 104 | if e.response.status_code == 401: 105 | raise AuthenticationError( 106 | f"Error: {error_message}", e.response 107 | ) from None 108 | else: 109 | raise InvalidRequestError( 110 | f"Invalid request: {error_message}", e.response 111 | ) from None 112 | 113 | elif isinstance(e, requests.exceptions.RetryError): 114 | raise InvalidRequestError( 115 | "Invalid request: Max retries exceeded", e.response 116 | ) from None 117 | 118 | elif isinstance(e, requests.exceptions.Timeout): 119 | raise RequestTimeoutError( 120 | "Timeout error: Request timed out", e.response 121 | ) from None 122 | 123 | elif isinstance(e, requests.exceptions.ConnectionError): 124 | raise InvalidRequestError( 125 | "Invalid request: Connection error", e.response 126 | ) from None 127 | 128 | else: 129 | raise InvalidRequestError( 130 | f"Invalid request: {str(e)}", e.response 131 | ) from None 132 | 133 | @backoff.on_exception( 134 | backoff.constant, Exception, max_time=500, interval=5, logger=None, jitter=None 135 | ) 136 | def _get_output(self, url: str): 137 | """Get the output from an async request""" 138 | response_json = self.session.get(url).json() 139 | if ( 140 | response_json.get("status") == Status.in_progress 141 | or response_json.get("status") == Status.processing 142 | ): 143 | percentage = response_json.get("data", {}).get("percentage") 144 | if percentage and self.show_progress and self.progress_bar: 145 | self.progress_bar.n = int(percentage) 146 | self.progress_bar.update(0) 147 | 148 | logger.debug("Waiting for processing to complete") 149 | raise Exception("Stuck on processing status") from None 150 | if self.show_progress and self.progress_bar: 151 | self.progress_bar.n = 100 152 | self.progress_bar.update(0) 153 | self.progress_bar.close() 154 | self.progress_bar = None 155 | self.show_progress = False 156 | return response_json.get("response") or response_json 157 | 158 | def _parse_response(self, response: requests.Response): 159 | """Parse the response from the api""" 160 | try: 161 | response_json = response.json() 162 | if ( 163 | response_json.get("status") == Status.processing 164 | and response_json.get("request_type", "sync") == "async" 165 | ): 166 | return None 167 | elif ( 168 | response_json.get("status") == Status.processing 169 | and response_json.get("request_type", "sync") == "sync" 170 | ): 171 | if self.show_progress: 172 | self.progress_bar = tqdm( 173 | total=100, 174 | position=0, 175 | leave=True, 176 | bar_format="{l_bar}{bar:100}{r_bar}{bar:-100b}", 177 | ) 178 | response_json = self._get_output( 179 | response_json.get("data", {}).get("output_url") 180 | ) 181 | if response_json.get("success"): 182 | return response_json.get("data") 183 | else: 184 | raise InvalidRequestError( 185 | f"Invalid request: {response_json.get('message')}", response 186 | ) from None 187 | 188 | elif response_json.get("success"): 189 | return response_json.get("data") 190 | 191 | else: 192 | raise InvalidRequestError( 193 | f"Invalid request: {response_json.get('message')}", response 194 | ) from None 195 | 196 | except ValueError: 197 | raise InvalidRequestError( 198 | f"Invalid request: {response.text}", response 199 | ) from None 200 | 201 | def get( 202 | self, path: str, show_progress: Optional[bool] = False, **kwargs 203 | ) -> requests.Response: 204 | """Make a get request""" 205 | self.show_progress = show_progress 206 | return self._make_request(method=self.session.get, path=path, **kwargs) 207 | 208 | def post( 209 | self, path: str, data=None, show_progress: Optional[bool] = False, **kwargs 210 | ) -> requests.Response: 211 | """Make a post request""" 212 | self.show_progress = show_progress 213 | return self._make_request(self.session.post, path, json=data, **kwargs) 214 | 215 | def put(self, path: str, data=None, **kwargs) -> requests.Response: 216 | """Make a put request""" 217 | return self._make_request(self.session.put, path, json=data, **kwargs) 218 | 219 | def delete(self, path: str, **kwargs) -> requests.Response: 220 | """Make a delete request""" 221 | return self._make_request(self.session.delete, path, **kwargs) 222 | 223 | def patch(self, path: str, data=None, **kwargs) -> requests.Response: 224 | """Make a patch request""" 225 | return self._make_request(self.session.patch, path, json=data, **kwargs) 226 | -------------------------------------------------------------------------------- /videodb/_utils/_video.py: -------------------------------------------------------------------------------- 1 | import webbrowser as web 2 | PLAYER_URL: str = "https://console.videodb.io/player" 3 | 4 | 5 | def play_stream(url: str): 6 | """Play a stream url in the browser/ notebook 7 | 8 | :param str url: The url of the stream 9 | :return: The player url if the stream is opened in the browser or the iframe if the stream is opened in the notebook 10 | """ 11 | player = f"{PLAYER_URL}?url={url}" 12 | opend = web.open(player) 13 | if not opend: 14 | try: 15 | from IPython.display import IFrame 16 | 17 | player_width = 800 18 | player_height = 400 19 | return IFrame(player, player_width, player_height) 20 | except ImportError: 21 | return player 22 | return player 23 | -------------------------------------------------------------------------------- /videodb/asset.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import logging 3 | import uuid 4 | 5 | from typing import Optional, Union 6 | 7 | from videodb._constants import MaxSupported, TextStyle 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | def validate_max_supported( 13 | duration: Union[int, float], max_duration: Union[int, float], attribute: str = "" 14 | ) -> Union[int, float, None]: 15 | if duration is None: 16 | return 0 17 | if duration is not None and max_duration is not None and duration > max_duration: 18 | logger.warning( 19 | f"{attribute}: {duration} is greater than max supported: {max_duration}" 20 | ) 21 | return duration 22 | 23 | 24 | class MediaAsset: 25 | def __init__(self, asset_id: str) -> None: 26 | self.asset_id: str = asset_id 27 | 28 | def to_json(self) -> dict: 29 | return self.__dict__ 30 | 31 | 32 | class VideoAsset(MediaAsset): 33 | def __init__( 34 | self, 35 | asset_id: str, 36 | start: Optional[float] = 0, 37 | end: Optional[float] = None, 38 | ) -> None: 39 | super().__init__(asset_id) 40 | self.start: int = start 41 | self.end: Union[int, None] = end 42 | 43 | def to_json(self) -> dict: 44 | return copy.deepcopy(self.__dict__) 45 | 46 | def __repr__(self) -> str: 47 | return ( 48 | f"VideoAsset(" 49 | f"asset_id={self.asset_id}, " 50 | f"start={self.start}, " 51 | f"end={self.end})" 52 | ) 53 | 54 | 55 | class AudioAsset(MediaAsset): 56 | def __init__( 57 | self, 58 | asset_id: str, 59 | start: Optional[float] = 0, 60 | end: Optional[float] = None, 61 | disable_other_tracks: Optional[bool] = True, 62 | fade_in_duration: Optional[Union[int, float]] = 0, 63 | fade_out_duration: Optional[Union[int, float]] = 0, 64 | ): 65 | super().__init__(asset_id) 66 | self.start: int = start 67 | self.end: Union[int, None] = end 68 | self.disable_other_tracks: bool = disable_other_tracks 69 | self.fade_in_duration: Union[int, float] = validate_max_supported( 70 | fade_in_duration, MaxSupported.fade_duration, "fade_in_duration" 71 | ) 72 | self.fade_out_duration: Union[int, float] = validate_max_supported( 73 | fade_out_duration, MaxSupported.fade_duration, "fade_out_duration" 74 | ) 75 | 76 | def to_json(self) -> dict: 77 | return copy.deepcopy(self.__dict__) 78 | 79 | def __repr__(self) -> str: 80 | return ( 81 | f"AudioAsset(" 82 | f"asset_id={self.asset_id}, " 83 | f"start={self.start}, " 84 | f"end={self.end}, " 85 | f"disable_other_tracks={self.disable_other_tracks}, " 86 | f"fade_in_duration={self.fade_in_duration}, " 87 | f"fade_out_duration={self.fade_out_duration})" 88 | ) 89 | 90 | 91 | class ImageAsset(MediaAsset): 92 | def __init__( 93 | self, 94 | asset_id: str, 95 | width: Union[int, str] = 100, 96 | height: Union[int, str] = 100, 97 | x: Union[int, str] = 80, 98 | y: Union[int, str] = 20, 99 | duration: Optional[int] = None, 100 | ) -> None: 101 | super().__init__(asset_id) 102 | self.width = width 103 | self.height = height 104 | self.x = x 105 | self.y = y 106 | self.duration = duration 107 | 108 | def to_json(self) -> dict: 109 | return copy.deepcopy(self.__dict__) 110 | 111 | def __repr__(self) -> str: 112 | return ( 113 | f"ImageAsset(" 114 | f"asset_id={self.asset_id}, " 115 | f"width={self.width}, " 116 | f"height={self.height}, " 117 | f"x={self.x}, " 118 | f"y={self.y}, " 119 | f"duration={self.duration})" 120 | ) 121 | 122 | 123 | class TextAsset(MediaAsset): 124 | def __init__( 125 | self, 126 | text: str, 127 | duration: Optional[int] = None, 128 | style: TextStyle = TextStyle(), 129 | ) -> None: 130 | super().__init__(f"txt-{str(uuid.uuid4())}") 131 | self.text = text 132 | self.duration = duration 133 | self.style: TextStyle = style 134 | 135 | def to_json(self) -> dict: 136 | return { 137 | "text": copy.deepcopy(self.text), 138 | "asset_id": copy.deepcopy(self.asset_id), 139 | "duration": copy.deepcopy(self.duration), 140 | "style": copy.deepcopy(self.style.__dict__), 141 | } 142 | 143 | def __repr__(self) -> str: 144 | return ( 145 | f"TextAsset(" 146 | f"text={self.text}, " 147 | f"asset_id={self.asset_id}, " 148 | f"duration={self.duration}, " 149 | f"style={self.style})" 150 | ) 151 | -------------------------------------------------------------------------------- /videodb/audio.py: -------------------------------------------------------------------------------- 1 | from videodb._constants import ( 2 | ApiPath, 3 | ) 4 | 5 | 6 | class Audio: 7 | """Audio class to interact with the Audio 8 | 9 | :ivar str id: Unique identifier for the audio 10 | :ivar str collection_id: ID of the collection this audio belongs to 11 | :ivar str name: Name of the audio file 12 | :ivar float length: Duration of the audio in seconds 13 | """ 14 | 15 | def __init__( 16 | self, _connection, id: str, collection_id: str, **kwargs 17 | ) -> None: 18 | self._connection = _connection 19 | self.id = id 20 | self.collection_id = collection_id 21 | self.name = kwargs.get("name", None) 22 | self.length = kwargs.get("length", None) 23 | 24 | def __repr__(self) -> str: 25 | return ( 26 | f"Audio(" 27 | f"id={self.id}, " 28 | f"collection_id={self.collection_id}, " 29 | f"name={self.name}, " 30 | f"length={self.length})" 31 | ) 32 | 33 | def generate_url(self) -> str: 34 | """Generate the signed url of the audio. 35 | 36 | :raises InvalidRequestError: If the get_url fails 37 | :return: The signed url of the audio 38 | :rtype: str 39 | """ 40 | url_data = self._connection.post( 41 | path=f"{ApiPath.audio}/{self.id}/{ApiPath.generate_url}", 42 | params={"collection_id": self.collection_id}, 43 | ) 44 | return url_data.get("signed_url", None) 45 | 46 | def delete(self) -> None: 47 | """Delete the audio. 48 | 49 | :raises InvalidRequestError: If the delete fails 50 | :return: None if the delete is successful 51 | :rtype: None 52 | """ 53 | self._connection.delete(f"{ApiPath.audio}/{self.id}") 54 | -------------------------------------------------------------------------------- /videodb/client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from typing import ( 4 | Optional, 5 | Union, 6 | List, 7 | ) 8 | from videodb.__about__ import __version__ 9 | from videodb._constants import ( 10 | ApiPath, 11 | ) 12 | 13 | from videodb.collection import Collection 14 | from videodb._utils._http_client import HttpClient 15 | from videodb.video import Video 16 | from videodb.audio import Audio 17 | from videodb.image import Image 18 | 19 | from videodb._upload import ( 20 | upload, 21 | ) 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | 26 | class Connection(HttpClient): 27 | """Connection class to interact with the VideoDB""" 28 | 29 | def __init__(self, api_key: str, base_url: str) -> "Connection": 30 | """Initializes a new instance of the Connection class with specified API credentials. 31 | 32 | Note: Users should not initialize this class directly. 33 | Instead use :meth:`videodb.connect() ` 34 | 35 | :param str api_key: API key for authentication 36 | :param str base_url: Base URL of the VideoDB API 37 | :raise ValueError: If the API key is not provided 38 | :return: :class:`Connection ` object, to interact with the VideoDB 39 | :rtype: :class:`videodb.client.Connection` 40 | """ 41 | self.api_key = api_key 42 | self.base_url = base_url 43 | self.collection_id = "default" 44 | super().__init__(api_key=api_key, base_url=base_url, version=__version__) 45 | 46 | def get_collection(self, collection_id: Optional[str] = "default") -> Collection: 47 | """Get a collection object by its ID. 48 | 49 | :param str collection_id: ID of the collection (optional, default: "default") 50 | :return: :class:`Collection ` object 51 | :rtype: :class:`videodb.collection.Collection` 52 | """ 53 | collection_data = self.get(path=f"{ApiPath.collection}/{collection_id}") 54 | self.collection_id = collection_data.get("id", "default") 55 | return Collection( 56 | self, 57 | self.collection_id, 58 | collection_data.get("name"), 59 | collection_data.get("description"), 60 | collection_data.get("is_public", False), 61 | ) 62 | 63 | def get_collections(self) -> List[Collection]: 64 | """Get a list of all collections. 65 | 66 | :return: List of :class:`Collection ` objects 67 | :rtype: list[:class:`videodb.collection.Collection`] 68 | """ 69 | collections_data = self.get(path=ApiPath.collection) 70 | return [ 71 | Collection( 72 | self, 73 | collection.get("id"), 74 | collection.get("name"), 75 | collection.get("description"), 76 | collection.get("is_public", False), 77 | ) 78 | for collection in collections_data.get("collections") 79 | ] 80 | 81 | def create_collection( 82 | self, name: str, description: str, is_public: bool = False 83 | ) -> Collection: 84 | """Create a new collection. 85 | 86 | :param str name: Name of the collection 87 | :param str description: Description of the collection 88 | :param bool is_public: Make collection public (optional, default: False) 89 | :return: :class:`Collection ` object 90 | :rtype: :class:`videodb.collection.Collection` 91 | """ 92 | collection_data = self.post( 93 | path=ApiPath.collection, 94 | data={ 95 | "name": name, 96 | "description": description, 97 | "is_public": is_public, 98 | }, 99 | ) 100 | self.collection_id = collection_data.get("id", "default") 101 | return Collection( 102 | self, 103 | collection_data.get("id"), 104 | collection_data.get("name"), 105 | collection_data.get("description"), 106 | collection_data.get("is_public", False), 107 | ) 108 | 109 | def update_collection(self, id: str, name: str, description: str) -> Collection: 110 | """Update an existing collection. 111 | 112 | :param str id: ID of the collection 113 | :param str name: Name of the collection 114 | :param str description: Description of the collection 115 | :return: :class:`Collection ` object 116 | :rtype: :class:`videodb.collection.Collection` 117 | """ 118 | collection_data = self.patch( 119 | path=f"{ApiPath.collection}/{id}", 120 | data={ 121 | "name": name, 122 | "description": description, 123 | }, 124 | ) 125 | self.collection_id = collection_data.get("id", "default") 126 | return Collection( 127 | self, 128 | collection_data.get("id"), 129 | collection_data.get("name"), 130 | collection_data.get("description"), 131 | collection_data.get("is_public", False), 132 | ) 133 | 134 | def check_usage(self) -> dict: 135 | """Check the usage. 136 | 137 | :return: Usage data 138 | :rtype: dict 139 | """ 140 | return self.get(path=f"{ApiPath.billing}/{ApiPath.usage}") 141 | 142 | def get_invoices(self) -> List[dict]: 143 | """Get a list of all invoices. 144 | 145 | :return: List of invoices 146 | :rtype: list[dict] 147 | """ 148 | return self.get(path=f"{ApiPath.billing}/{ApiPath.invoices}") 149 | 150 | def create_event(self, event_prompt: str, label: str): 151 | """Create an rtstream event. 152 | 153 | :param str event_prompt: Prompt for the event 154 | :param str label: Label for the event 155 | :return: Event ID 156 | :rtype: str 157 | """ 158 | event_data = self.post( 159 | f"{ApiPath.rtstream}/{ApiPath.event}", 160 | data={"event_prompt": event_prompt, "label": label}, 161 | ) 162 | 163 | return event_data.get("event_id") 164 | 165 | def list_events(self): 166 | """List all rtstream events. 167 | 168 | :return: List of events 169 | :rtype: list[dict] 170 | """ 171 | event_data = self.get(f"{ApiPath.rtstream}/{ApiPath.event}") 172 | return event_data.get("events", []) 173 | 174 | def download(self, stream_link: str, name: str) -> dict: 175 | """Download a file from a stream link. 176 | 177 | :param stream_link: URL of the stream to download 178 | :param name: Name to save the downloaded file as 179 | :return: Download response data 180 | :rtype: dict 181 | """ 182 | return self.post( 183 | path=f"{ApiPath.download}", 184 | data={ 185 | "stream_link": stream_link, 186 | "name": name, 187 | }, 188 | ) 189 | 190 | def youtube_search( 191 | self, 192 | query: str, 193 | result_threshold: Optional[int] = 10, 194 | duration: str = "medium", 195 | ) -> List[dict]: 196 | """Search for a query on YouTube. 197 | 198 | :param str query: Query to search for 199 | :param int result_threshold: Number of results to return (optional) 200 | :param str duration: Duration of the video (optional) 201 | :return: List of YouTube search results 202 | :rtype: List[dict] 203 | """ 204 | search_data = self.post( 205 | path=f"{ApiPath.collection}/{self.collection_id}/{ApiPath.search}/{ApiPath.web}", 206 | data={ 207 | "query": query, 208 | "result_threshold": result_threshold, 209 | "platform": "youtube", 210 | "duration": duration, 211 | }, 212 | ) 213 | return search_data.get("results") 214 | 215 | def upload( 216 | self, 217 | file_path: str = None, 218 | url: str = None, 219 | media_type: Optional[str] = None, 220 | name: Optional[str] = None, 221 | description: Optional[str] = None, 222 | callback_url: Optional[str] = None, 223 | ) -> Union[Video, Audio, Image, None]: 224 | """Upload a file. 225 | 226 | :param str file_path: Path to the file to upload (optional) 227 | :param str url: URL of the file to upload (optional) 228 | :param MediaType media_type: MediaType object (optional) 229 | :param str name: Name of the file (optional) 230 | :param str description: Description of the file (optional) 231 | :param str callback_url: URL to receive the callback (optional) 232 | :return: :class:`Video