├── kodi_strm ├── __main__.py ├── cli.py ├── file_handler.py └── drive_handler.py ├── strm-generator.py ├── .gitignore ├── setup.bat ├── .github ├── workflows │ ├── black.yml │ └── build.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── .stale.yml └── dependabot.yml ├── setup.sh ├── requirements.txt ├── LICENSE └── readme.md /kodi_strm/__main__.py: -------------------------------------------------------------------------------- 1 | from . import cli 2 | 3 | if __name__ == "__main__": 4 | cli.main() 5 | -------------------------------------------------------------------------------- /strm-generator.py: -------------------------------------------------------------------------------- 1 | import kodi_strm.cli as cli 2 | 3 | if __name__ == "__main__": 4 | cli.main() 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The virtual environment. 2 | .venv/ 3 | venv/ 4 | 5 | # Cache files 6 | .vscode/ 7 | __pycache__ 8 | 9 | # A bash script that I'm using to port the script on different systems 10 | # setup.** -- Adding this file too, might ease the setup for some people. 11 | 12 | # Configuration files to interact with Google Drive API. 13 | **/**.pickle 14 | **/**.json 15 | 16 | # Cache directory generated by PyCharm. 17 | .idea/ 18 | 19 | # Ignoring anything present under the test directory 20 | test_** 21 | -------------------------------------------------------------------------------- /setup.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | echo Upgrading pip 4 | python -m pip install --upgrade pip 5 | 6 | echo Installing virtual-environment 7 | pip install --upgrade virtualenv 8 | 9 | echo Creating a virutal environment 10 | virtualenv venv 11 | 12 | REM Activating the virtual environment. 13 | venv\Scripts\activate 14 | 15 | echo Installing the required packages 16 | pip install -r requirements.txt 17 | 18 | echo Setup executed successfully. 19 | 20 | echo Activate the virtual-environment using 21 | echo `venv\Scripts\activate` 22 | echo 23 | echo Once done, follow the rest of instructions in the setup. 24 | -------------------------------------------------------------------------------- /.github/workflows/black.yml: -------------------------------------------------------------------------------- 1 | name: Black 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches-ignore: 7 | - "draft/**" 8 | - "docs/**" 9 | pull_request: 10 | types: [ opened, reopened ] 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: [ "3.9" ] 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | 27 | - uses: psf/black@stable -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | echo -e "\nUpgrading pip" 2 | python -m pip install --upgrade pip 3 | 4 | echo -e "\nInstall virtual-environment" 5 | pip install --upgrade virtualenv 6 | 7 | echo -e "\nCreating a virutal environment" 8 | virtualenv ./venv 9 | 10 | # Activating the virtual environment. 11 | source venv/bin/activate 12 | 13 | echo -e "\nInstalling the required packages" 14 | pip install -r ./requirements.txt 15 | 16 | echo -e " 17 | Setup executed successfully. 18 | 19 | Activate the virtual-environment using 20 | \`source venv/bin/activate\` 21 | 22 | Once done, follow the rest of instructions in the setup. 23 | " 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | backports.shutil-get-terminal-size==1.0.0 2 | cachetools==5.3.0 3 | certifi==2022.12.7 4 | charset-normalizer==2.1.1 5 | click==8.1.3 6 | colorama==0.4.6 7 | google-api-core==2.11.0 8 | google-api-python-client==2.80.0 9 | google-auth==2.16.2 10 | google-auth-httplib2==0.1.0 11 | google-auth-oauthlib==1.0.0 12 | googleapis-common-protos==1.58.0 13 | httplib2==0.21.0 14 | idna==3.4 15 | oauthlib==3.2.2 16 | packaging==23.0 17 | protobuf==4.22.0 18 | pyasn1==0.4.8 19 | pyasn1-modules==0.2.8 20 | pyparsing==3.0.9 21 | reprint==0.6.0 22 | requests==2.28.2 23 | requests-oauthlib==1.3.1 24 | rsa==4.9 25 | six==1.16.0 26 | typer==0.7.0 27 | upgrade-requirements==1.7.0 28 | uritemplate==4.1.1 29 | urllib3==1.26.14 30 | -------------------------------------------------------------------------------- /.github/.stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | 4 | # Number of days of inactivity before a stale issue is closed 5 | daysUntilClose: 7 6 | 7 | # Issues with these labels will never be considered stale 8 | exemptLabels: 9 | - pinned 10 | - security 11 | - open 12 | 13 | # Label to use when marking an issue as stale 14 | staleLabel: stale 15 | 16 | # Comment to post when marking an issue as stale. Set to `false` to disable 17 | markComment: > 18 | This issue has been automatically marked as stale because it does not have any 19 | recent activity. It will be closed if no further activity occurs. Thank you 20 | for your contributions. 21 | 22 | # Comment to post when closing a stale issue. Set to `false` to disable 23 | closeComment: true 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Demon Rem 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. Assume that you're explaining the problem to a kid. Explain in detail. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behaviour: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behaviour** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Please fill the following information:** 27 | - OS: [e.g. Windows/Linux] 28 | - Browser [e.g. chrome, safari] 29 | 30 | **Packages Installed:** 31 | Run `pip freeze > packages.txt` this will create a file called `packages.txt` in the working directory. Copy and paste the contents of the file in this section. 32 | Note: If you are using a virtual environment, remember to activate it *before* firing the pip freeze command. 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Builder 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches-ignore: 7 | - "draft/**" 8 | - "docs/**" 9 | pull_request: 10 | types: [ opened, reopened ] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: [ "3.9" ] 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | 27 | - name: Set up cache 28 | id: setup-cache 29 | uses: actions/cache@v3 30 | with: 31 | path: .venv 32 | key: .venv-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }} 33 | 34 | - name: Install Dependencies 35 | if: steps.setup-cache.outputs.cache-hit != 'true' 36 | run: | 37 | pip install --upgrade virtualenv 38 | virtualenv .venv 39 | source .venv/bin/activate 40 | python -m pip install --upgrade pip 41 | pip install -r requirements.txt 42 | 43 | - name: Test 44 | run: | 45 | source .venv/bin/activate 46 | python -m kodi_strm --help 47 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 10 8 | reviewers: 9 | - notsatan 10 | ignore: 11 | - dependency-name: astroid 12 | versions: 13 | - 2.5.2 14 | - 2.5.3 15 | - dependency-name: pylint 16 | versions: 17 | - 2.7.4 18 | - dependency-name: google-api-python-client 19 | versions: 20 | - 2.1.0 21 | - dependency-name: virtualenv 22 | versions: 23 | - 20.4.0 24 | - 20.4.1 25 | - 20.4.3 26 | - dependency-name: google-api-core 27 | versions: 28 | - 1.25.0 29 | - 1.25.1 30 | - 1.26.0 31 | - 1.26.2 32 | - dependency-name: google-auth-oauthlib 33 | versions: 34 | - 0.4.3 35 | - dependency-name: google-auth-httplib2 36 | versions: 37 | - 0.1.0 38 | - dependency-name: google-auth 39 | versions: 40 | - 1.25.0 41 | - 1.26.1 42 | - 1.27.0 43 | - 1.28.0 44 | - dependency-name: autopep8 45 | versions: 46 | - 1.5.6 47 | - dependency-name: googleapis-common-protos 48 | versions: 49 | - 1.53.0 50 | - dependency-name: isort 51 | versions: 52 | - 5.7.0 53 | - dependency-name: urllib3 54 | versions: 55 | - 1.26.3 56 | - dependency-name: rsa 57 | versions: 58 | - "4.7" 59 | - 4.7.1 60 | - dependency-name: pytz 61 | versions: 62 | - "2020.5" 63 | -------------------------------------------------------------------------------- /kodi_strm/cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | import shutil 4 | import sysconfig 5 | from os.path import exists as path_exists 6 | from os.path import join as join_path 7 | from pathlib import Path 8 | from typing import Optional 9 | 10 | import typer 11 | from reprint import output 12 | 13 | from kodi_strm.drive_handler import DriveHandler 14 | from kodi_strm.file_handler import FileHandler 15 | 16 | __VERSION: Optional[str] = "2.0.0" 17 | __APP_NAME: Optional[str] = "kodi-strm" 18 | 19 | # Switch to flip all flags to be case in/sensitive 20 | __CASE_SENSITIVE: bool = False 21 | 22 | 23 | def __callback_version(fired: bool): 24 | """ 25 | Callback function - fired when the `--version` flag is invoked. Check the value 26 | of `fired` to know if the user has actually used the flag 27 | """ 28 | 29 | if not fired: 30 | return # flag was not invoked 31 | 32 | typer.echo( 33 | f"{__APP_NAME} v{__VERSION}\n" 34 | + f"- os/kernel: {platform.release()}\n" 35 | + f"- os/type: {sysconfig.get_platform()}\n" 36 | + f"- os/machine: {platform.machine()}\n" 37 | + f"- os/arch: {platform.architecture()[0]}\n" 38 | + f"- python/version: {platform.python_version()}\n", 39 | ) 40 | 41 | raise typer.Exit() # direct exit 42 | 43 | 44 | def __check_collisions(dst: str, force: bool): 45 | """ 46 | Checks against collisions for the destination path 47 | """ 48 | 49 | if not path_exists(dst): 50 | return # direct return 51 | 52 | if force: 53 | # Force flag enabled, direct remove and exit 54 | shutil.rmtree(dst) 55 | return 56 | 57 | typer.secho( 58 | f"Destination directory `{dst}` already exists\n" 59 | + "Proceed by wiping the existing directory?", 60 | err=True, 61 | fg=typer.colors.RED, 62 | ) 63 | 64 | while True: 65 | typer.echo("y/n> ", nl=False) 66 | choice = str(input()) 67 | 68 | if choice.lower() == "n": 69 | raise typer.Abort() 70 | elif choice.lower() == "y": 71 | shutil.rmtree(dst) 72 | typer.echo(f"Successfully wiped path '{dst}'\n", err=True) 73 | return 74 | else: 75 | typer.secho(f"\tUnexpected input: `{choice}`\n", fg=typer.colors.RED) 76 | 77 | 78 | def cmd_interface( 79 | source: Optional[str] = typer.Option( 80 | None, 81 | "--source", 82 | show_default=False, 83 | case_sensitive=__CASE_SENSITIVE, 84 | help="Folder ID for source directory on Google Drive", 85 | ), 86 | destination: Optional[Path] = typer.Option( 87 | None, 88 | "--dest", 89 | "--destination", 90 | exists=True, # path needs to exist 91 | writable=True, # ensures a writeable path 92 | dir_okay=True, # allows path to directory 93 | file_okay=False, # rejects path to a file 94 | resolve_path=True, # resolves complete path 95 | case_sensitive=__CASE_SENSITIVE, 96 | help="Destination directory where `strm` files will be placed", 97 | ), 98 | root_name: Optional[str] = typer.Option( 99 | None, 100 | "--root", 101 | "--rootname", 102 | case_sensitive=__CASE_SENSITIVE, 103 | help="Custom name for the source directory", 104 | ), 105 | rem_extensions: bool = typer.Option( 106 | False, 107 | "--no-ext", 108 | "--no-extensions", 109 | show_default=False, 110 | case_sensitive=__CASE_SENSITIVE, 111 | help="Remove original extensions from generated strm files", 112 | ), 113 | hide_updates: bool = typer.Option( 114 | False, 115 | "--no-updates", 116 | show_default=False, 117 | case_sensitive=__CASE_SENSITIVE, 118 | help="Disable live progress/updates", 119 | ), 120 | force: bool = typer.Option( 121 | False, 122 | "--force", 123 | "-f", 124 | show_default=True, 125 | case_sensitive=__CASE_SENSITIVE, 126 | help="Wipe out root directory (if exists) in case of a collision", 127 | ), 128 | version: bool = typer.Option( 129 | None, 130 | "--version", 131 | "-v", 132 | is_eager=True, 133 | callback=__callback_version, 134 | case_sensitive=__CASE_SENSITIVE, 135 | help="Display current app version", 136 | ), 137 | ) -> None: 138 | drive_handler = DriveHandler() # authenticate drive api 139 | 140 | with output(output_type="list", initial_len=9, interval=500) as outstream: 141 | # Replace destination directory with the current directory path if not supplied 142 | destination = destination if destination else os.getcwd() 143 | file_handler = FileHandler( 144 | destination=destination, 145 | include_extensions=not rem_extensions, 146 | live_updates=not hide_updates, 147 | outstream=outstream, 148 | ) 149 | 150 | if not source or len(source) == 0: 151 | # No source directory is provided, get the user to choose a teamdrive 152 | source = drive_handler.select_teamdrive() 153 | 154 | if not hide_updates: 155 | typer.secho( 156 | f"Walking through `{drive_handler.drive_name(source)}`\n", 157 | fg=typer.colors.GREEN, 158 | err=True, 159 | ) 160 | 161 | __check_collisions( 162 | force=force, 163 | dst=join_path( 164 | destination, 165 | root_name if root_name else drive_handler.drive_name(source), 166 | ), 167 | ) 168 | 169 | drive_handler.walk( 170 | source=source, 171 | change_dir=file_handler.switch_dir, 172 | generator=file_handler.strm_generator, 173 | orig_path=destination, 174 | custom_root=root_name, 175 | ) 176 | 177 | typer.secho( 178 | f"Completed generating strm files\nFiles generated in: {destination}", 179 | fg=typer.colors.GREEN, 180 | ) 181 | 182 | 183 | def main(): 184 | typer.run(cmd_interface) 185 | -------------------------------------------------------------------------------- /kodi_strm/file_handler.py: -------------------------------------------------------------------------------- 1 | from os import mkdir 2 | from os.path import exists as path_exists 3 | from os.path import join as join_path 4 | from os.path import splitext 5 | from typing import Optional 6 | 7 | import typer 8 | from reprint import output 9 | 10 | 11 | class FileHandler: 12 | def __init__( 13 | self, 14 | destination: str, 15 | include_extensions: bool, 16 | live_updates: bool, 17 | outstream: output = None, 18 | ) -> None: 19 | self.__cur_path: str = destination 20 | self.__cur_dir: str = None 21 | self.__cur_file: str = None 22 | 23 | self.__directories: int = 0 24 | self.__files: int = 0 25 | self.__skipped: int = 0 26 | self.__size: int = 0 27 | 28 | self.__live_updates = live_updates 29 | self.__include_ext = include_extensions 30 | 31 | self.__outstream = outstream 32 | 33 | @staticmethod 34 | def __readable_size(size: int) -> str: 35 | """ 36 | Converts number of bytes into readable format, and returns the same as string 37 | """ 38 | 39 | # An array size units. Will be used to convert raw size into a readable format. 40 | sizes = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"] 41 | 42 | counter = 0 43 | while size >= 1024: 44 | size /= 1024 45 | counter += 1 46 | 47 | return "{:.3f} {}".format(size, sizes[counter]) 48 | 49 | @staticmethod 50 | def __is_media_file(file_name: str, mime_type: str) -> bool: 51 | """ 52 | Decides if a file is a media file -- used to decide which files to create 53 | `.strm` file for 54 | 55 | Params 56 | ------- 57 | file_name: Name of the file. Used to identify media files from their extension 58 | mime_type: Mime type of the file on google drive 59 | 60 | Returns 61 | -------- 62 | Boolean indicating if the file is a media file, or not 63 | """ 64 | 65 | if "video" in mime_type: 66 | return True 67 | elif file_name.endswith( 68 | ( 69 | ".mp4", 70 | ".mkv", 71 | ) 72 | ): 73 | # Skip if item doesn't have media-file extension, or mime type 74 | return True 75 | 76 | return False 77 | 78 | @staticmethod 79 | def __shrink(input: str, *, max_len: int = 60) -> str: 80 | """ 81 | Shrinks the string to fit into a fixed number of characters 82 | 83 | Remarks 84 | -------- 85 | Shortens string to fit `max_len` characters by replacing with period(s) [...] 86 | 87 | For example, the string 88 | `This is a long string` 89 | 90 | When shrunk to 10 max characters using this method, will be 91 | `Thi....ing` 92 | 93 | Returns 94 | -------- 95 | String containing `input` string shrunk to fit within `max_len` charcters 96 | """ 97 | 98 | if len(input) <= max_len: 99 | return input 100 | 101 | # Leave space for 4 period(s) - two on each side, divide rest characters in two 102 | half_len = int((max_len / 2) - 2) 103 | return f"{input[:half_len]}....{input[-half_len:]}" 104 | 105 | def __update(self): 106 | """ 107 | Prints updates to the screen 108 | """ 109 | 110 | if not self.__live_updates: 111 | return # direct return 112 | 113 | max_len = 75 114 | 115 | if self.__cur_dir: 116 | self.__outstream[0] = typer.style( 117 | self.__shrink(f"Scanning directory: {self.__cur_dir}", max_len=max_len), 118 | fg=typer.colors.GREEN, 119 | ) 120 | 121 | self.__outstream[1] = "\n" 122 | if self.__cur_file: 123 | self.__outstream[2] = self.__shrink(self.__cur_file, max_len=max_len) 124 | self.__outstream[3] = "\n" 125 | 126 | self.__outstream[4] = f"Directories Scanned: {self.__directories}" 127 | self.__outstream[5] = f"Files Scanned: {self.__files}" 128 | self.__outstream[6] = f"Bytes Scanned: {self.__readable_size(self.__size)}" 129 | self.__outstream[7] = f"Files Skipped: {self.__skipped}" 130 | self.__outstream[8] = "\n" 131 | 132 | def __create_strm( 133 | self, 134 | item_id: str, 135 | item_name: str, 136 | drive_id: Optional[str], 137 | td_id: Optional[str], 138 | ) -> bool: 139 | """ 140 | Creates `.strm` file for files using their ID 141 | 142 | Params 143 | ------- 144 | item_id: ID of the item on Google Drive 145 | item_name: Name of the item - as on Drive 146 | drive_id: Optional, ID of the drive containing the item 147 | td_id: Optional, ID of teamdrive containing the item. For items in a teamdrive 148 | """ 149 | 150 | # The hard-coded strings are simply how the `Drive Add-on` extension expects 151 | # them to be 152 | file_contents: str = ( 153 | f"plugin://plugin.googledrive/?action=play&item_id={item_id}" 154 | ) 155 | 156 | if td_id: 157 | file_contents += f"&item_driveid={td_id}" 158 | if drive_id: 159 | file_contents += f"&driveid={drive_id}" 160 | 161 | file_name = ( 162 | f"{item_name}.strm" 163 | if self.__include_ext 164 | else f"{splitext(item_name)[0]}.strm" # remove extension if not needed 165 | ) 166 | 167 | # Create strm file, and write to it 168 | with open(join_path(self.__cur_path, file_name), "w+") as f: 169 | f.write(file_contents) 170 | 171 | return True 172 | 173 | def switch_dir(self, path: str, dir_name: str): 174 | if not path_exists(path): 175 | mkdir(path=path) 176 | self.__directories += 1 177 | 178 | self.__cur_path = path 179 | self.__cur_dir = dir_name 180 | 181 | def strm_generator( 182 | self, 183 | item_id: str, 184 | item_name: str, 185 | mime_type: str, 186 | item_size: int, 187 | drive_id: Optional[str], 188 | td_id: Optional[str], 189 | ): 190 | """ 191 | Internally creates `.strm` files -- ignores non-media files 192 | """ 193 | 194 | self.__cur_file = item_name 195 | 196 | # Check if the file is a media file -- if not, direct return 197 | if not self.__is_media_file(file_name=item_name, mime_type=mime_type): 198 | self.__skipped += 1 # calculate this as a `skipped` file 199 | self.__update() 200 | return 201 | 202 | result = self.__create_strm( 203 | item_id=item_id, 204 | item_name=item_name, 205 | drive_id=drive_id, 206 | td_id=td_id, 207 | ) 208 | 209 | if result: 210 | self.__size += item_size 211 | self.__files += 1 212 | self.__update() 213 | -------------------------------------------------------------------------------- /kodi_strm/drive_handler.py: -------------------------------------------------------------------------------- 1 | from collections import deque 2 | from os.path import exists as path_exists 3 | from os.path import join as join_path 4 | from pickle import dump as dump_pickle 5 | from pickle import load as load_pickle 6 | from typing import Any, Callable, Dict, List, Optional, Tuple 7 | 8 | import googleapiclient 9 | import googleapiclient.discovery as discovery 10 | import typer 11 | from google.auth.transport.requests import Request 12 | from google.oauth2.credentials import Credentials 13 | from google_auth_oauthlib.flow import InstalledAppFlow 14 | 15 | 16 | class DriveHandler: 17 | """ 18 | Deals with Drive API and related stuff 19 | """ 20 | 21 | def __init__(self): 22 | self.resource: googleapiclient.discovery.Resource = self.__authenticate() 23 | 24 | # Dictionary mapping ID's to their (human-readable) name. Acts as a simple cache 25 | # to reduce API calls. Can be used for teamdrives, and normal directories 26 | self.dirs: Dict[str, str] = {} 27 | 28 | def __authenticate(self) -> discovery.Resource: 29 | """ 30 | Authenticates user session using Drive API. 31 | 32 | Remarks 33 | -------- 34 | Attempts to open a browser window asking user to login and grant permissions 35 | during the first run. Saves a `.pickle` file to skip this step in future runs 36 | 37 | Returns 38 | -------- 39 | Object of `googleapiclient.discovery.Resource` 40 | """ 41 | 42 | creds: Optional[Credentials] = None 43 | 44 | # Selectively asks for read-only permission 45 | SCOPES = ["https://www.googleapis.com/auth/drive.readonly"] 46 | 47 | if path_exists("token.pickle"): 48 | with open("token.pickle", "rb") as token: 49 | creds: Credentials = load_pickle(token) 50 | 51 | if not creds or not creds.valid: 52 | if creds and creds.expired and creds.refresh_token: 53 | creds.refresh(Request()) 54 | else: 55 | flow = InstalledAppFlow.from_client_secrets_file( 56 | "credentials.json", SCOPES 57 | ) 58 | creds = flow.run_local_server(port=0) 59 | with open("token.pickle", "wb") as token: 60 | dump_pickle(creds, token) # save credentials for next run 61 | 62 | return googleapiclient.discovery.build("drive", "v3", credentials=creds) 63 | 64 | def __get_teamdrives(self) -> Dict[str, str]: 65 | """ 66 | Fetches and returns a list of all teamdrives associated with the Google account 67 | 68 | Remarks 69 | -------- 70 | Returns a dictionary of all teamdrives associated with the account. Aborts if 71 | the account has no teamdrive associated with it. 72 | 73 | Teamdrives (if found) will be cached in `self.dirs` 74 | """ 75 | 76 | next_page_token: Optional[str] = None 77 | first_run: bool = True 78 | 79 | tds: Dict[str, str] = {} 80 | while next_page_token or first_run: 81 | first_run = False 82 | page_content: Dict[str, Any] = ( 83 | self.resource.drives() 84 | .list(pageSize=100, pageToken=next_page_token) 85 | .execute() 86 | ) 87 | 88 | for item in page_content["drives"]: 89 | self.dirs[item["id"]] = item["name"] 90 | tds[item["id"]] = item["name"] 91 | 92 | # Loops as long as there is a `nextPageToken` 93 | next_page_token = page_content.get("nextPageToken", None) 94 | 95 | return tds 96 | 97 | def drive_name(self, drive_id: str) -> str: 98 | """ 99 | Returns name for a teamdrive using its ID 100 | 101 | Remarks 102 | -------- 103 | Looks for info in local cache - if not found, will internally make a network 104 | call and return the directory name 105 | """ 106 | 107 | if drive_id not in self.dirs: 108 | self.fetch_dir_name(dir_id=drive_id) # fetch info if not cached already 109 | 110 | return self.dirs[drive_id] 111 | 112 | def fetch_dir_name(self, *, dir_id: str) -> str: 113 | """ 114 | Returns info obtained regarding a directory/file from Google Drive API 115 | 116 | Remarks 117 | -------- 118 | Designed to get directory name from drive API, but can work with files as well. 119 | Internally caches directory name against folder id in `self.dirs` 120 | 121 | Always fetches info through a network call. Use `drive_name` to look through 122 | cache before making a network call 123 | """ 124 | 125 | try: 126 | result = ( 127 | self.resource.files() 128 | .get(fileId=dir_id, supportsAllDrives=True) 129 | .execute() 130 | ) 131 | 132 | if result.get("id", True) == result.get("teamDriveId", None): 133 | # Enters this block only if the `dir_id` belongs to a teamdrive 134 | result = self.resource.drives().get(driveId=dir_id).execute() 135 | 136 | # Cache directory name -- works with teamdrives and folders, id's are unique 137 | self.dirs[result["id"]] = result["name"] 138 | return result["name"] 139 | except Exception as e: 140 | typer.secho( 141 | f"Unable to find drive directory `{dir_id}`", fg=typer.colors.RED 142 | ) 143 | raise typer.Abort() 144 | 145 | def select_teamdrive(self) -> str: 146 | """ 147 | Gets user to select source drive from team-drives 148 | 149 | Returns 150 | -------- 151 | String containing ID of the selected teamdrive 152 | """ 153 | 154 | tds = self.__get_teamdrives() 155 | 156 | if len(tds) == 0: 157 | typer.secho("Unable to locate any teamdrives!", fg=typer.colors.RED) 158 | raise typer.Abort() 159 | 160 | counter: int = 1 161 | keys: List[str] = list(tds.keys()) 162 | for id in keys: 163 | typer.secho( 164 | f" {counter}. " + ("/" if counter % 2 else "\\") + f"\t{tds[id]}", 165 | fg=typer.colors.GREEN if counter % 2 else typer.colors.CYAN, 166 | ) 167 | 168 | counter += 1 169 | 170 | while True: 171 | typer.echo("\nSelect a teamdrive \nteamdrive> ", err=True, nl=False) 172 | choice: str = input().strip() 173 | 174 | try: 175 | selected: int = int(choice) 176 | if selected > len(tds): 177 | raise AssertionError(f"Expected input in range [1-{len(tds)}]") 178 | return keys[selected - 1] 179 | except AssertionError as e: 180 | typer.secho(f"\t{type(e).__name__}: {e}", fg=typer.colors.RED, err=True) 181 | except ValueError as e: 182 | typer.secho( 183 | f"\tValueError: Invalid input `{choice}`", 184 | fg=typer.colors.RED, 185 | err=True, 186 | ) 187 | 188 | def walk( 189 | self, 190 | source: str, 191 | *, 192 | orig_path: str, 193 | change_dir: Callable[[str], None], 194 | generator: Callable[[str, str, str, int, Optional[str], Optional[str]], None], 195 | custom_root: Optional[str] = None, 196 | ): 197 | """ 198 | Walks through the source folder in Google Drive - creating `.strm` files for 199 | each media file encountered 200 | 201 | Params 202 | ------- 203 | source: String containing ID of the source directory 204 | orig_path: String containing path to the destination directory 205 | change_dir: Method call to create, and change directories. Should accept 206 | complete path to the directory as parameter 207 | generator: Method call to create `strm` files 208 | strm_creator: Function to create `.strm` files - supplied arguments; item id, 209 | item name, teamdrive id [optional] 210 | custom_root: Optional. String containing custom name for root directory 211 | """ 212 | 213 | if not custom_root and not self.dirs.get(source, False): 214 | # The source directory has not been cached, fetch the same 215 | self.fetch_dir_name(dir_id=source) 216 | 217 | # Stack to track directories encountered. Each entry in the stack will be a 218 | # tuple consisting of the directory id, and a path to the local directory, 219 | # and a string containing the name of the directory 220 | queue: deque[Tuple[str, str]] = deque() 221 | queue.append( 222 | [ 223 | source, 224 | join_path(orig_path, custom_root if custom_root else self.dirs[source]), 225 | self.dirs[source], 226 | ] 227 | ) 228 | 229 | page_token: str = None 230 | while len(queue): 231 | 232 | dir_id, path, dir_name = queue.pop() 233 | change_dir(path, dir_name) 234 | 235 | page = ( 236 | self.resource.files() 237 | .list( 238 | pageSize=1000, # get max items possible with each call 239 | pageToken=page_token, # decides page for pagination 240 | fields="files(name, id, mimeType, teamDriveId, driveId, size)", 241 | supportsAllDrives=True, # enable support for teamdrives 242 | includeItemsFromAllDrives=True, 243 | # Ensure items are in parent directory, exclude deleted items 244 | q=f"'{dir_id}' in parents and trashed=false", 245 | ) 246 | .execute() 247 | ) 248 | 249 | for item in page["files"]: 250 | if item["mimeType"] == "application/vnd.google-apps.folder": 251 | # Add this directory to the queue 252 | queue.append( 253 | [item["id"], join_path(path, item["name"]), item["name"]] 254 | ) 255 | continue 256 | 257 | # Generate STRM file if the flow-of-control reaches this point 258 | # The function will internally ignore non-media files 259 | generator( 260 | item_id=item["id"], 261 | item_name=item["name"], 262 | mime_type=item["mimeType"], 263 | item_size=int(item["size"]), 264 | drive_id=item.get("driveId", None), 265 | td_id=item.get("teamDriveId", None), 266 | ) 267 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ## kodi-strm 2 | 3 |
16 | Jump to Docs »
17 |
18 | ·
19 | Bug Report
20 | ·
21 | Request Feature
22 | ·
23 | Fork Repo
24 | ·
25 |