├── .dockerignore ├── .env.example ├── .github └── workflows │ ├── docker-build.yml │ ├── release-please.yml │ └── ruff.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets └── header.png ├── docker-compose.yaml ├── docker.md ├── dockerfile ├── functions ├── appFunctions.py ├── databaseFunctions.py ├── fuseFilesystemFunctions.py ├── mediaFunctions.py ├── stremFilesystemFunctions.py └── torboxFunctions.py ├── library ├── app.py ├── filesystem.py ├── http.py └── torbox.py ├── main.py └── requirements.txt /.dockerignore: -------------------------------------------------------------------------------- 1 | .github 2 | .env.example 3 | .gitignore 4 | CHANGELOG.md 5 | *.md 6 | *.yaml -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | TORBOX_API_KEY= 2 | MOUNT_METHOD=strm 3 | MOUNT_PATH=/torbox -------------------------------------------------------------------------------- /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- 1 | name: docker-build 2 | permissions: 3 | packages: write 4 | on: 5 | push: 6 | branches: 7 | - "**" 8 | tags: 9 | - "v*.*.*" 10 | pull_request: 11 | 12 | jobs: 13 | docker: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Docker meta 17 | id: meta 18 | uses: docker/metadata-action@v5 19 | with: 20 | # list of Docker images to use as base name for tags 21 | images: | 22 | anonymoussystems/torbox-media-center 23 | ghcr.io/torbox-app/torbox-media-center 24 | # generate Docker tags based on the following events/attributes 25 | tags: | 26 | type=ref,event=branch 27 | type=ref,event=pr 28 | type=semver,pattern={{version}} 29 | type=semver,pattern={{major}}.{{minor}} 30 | type=semver,pattern={{major}} 31 | type=sha 32 | - 33 | name: Set up QEMU 34 | uses: docker/setup-qemu-action@v3 35 | - 36 | name: Set up Docker Buildx 37 | uses: docker/setup-buildx-action@v3 38 | - 39 | name: Login to Docker Hub 40 | if: github.event_name != 'pull_request' 41 | uses: docker/login-action@v3 42 | with: 43 | username: ${{ secrets.DOCKERHUB_USERNAME }} 44 | password: ${{ secrets.DOCKERHUB_TOKEN }} 45 | - name: Login to GHCR 46 | if: github.event_name != 'pull_request' 47 | uses: docker/login-action@v3 48 | with: 49 | registry: ghcr.io 50 | username: ${{ github.repository_owner }} 51 | password: ${{ secrets.GITHUB_TOKEN }} 52 | - name: Build and push 53 | uses: docker/build-push-action@v6 54 | with: 55 | push: ${{ github.event_name != 'pull_request' }} 56 | tags: ${{ steps.meta.outputs.tags }} 57 | labels: ${{ steps.meta.outputs.labels }} 58 | platforms: linux/amd64,linux/arm64,linux/arm/v8,linux/arm/v7 -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | permissions: 6 | contents: write 7 | pull-requests: write 8 | name: release-please 9 | jobs: 10 | release-please: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: googleapis/release-please-action@v4 14 | with: 15 | token: ${{ secrets.RELEASE_PLEASE_TOKEN }} 16 | release-type: simple -------------------------------------------------------------------------------- /.github/workflows/ruff.yml: -------------------------------------------------------------------------------- 1 | name: Ruff 2 | on: [push, pull_request] 3 | jobs: 4 | ruff: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - uses: chartboost/ruff-action@v1 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # Ruff stuff: 171 | .ruff_cache/ 172 | 173 | # PyPI configuration file 174 | .pypirc 175 | 176 | 177 | torbox 178 | movies 179 | series 180 | test-* 181 | *.json -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | - repo: https://github.com/astral-sh/ruff-pre-commit 2 | # Ruff version. 3 | rev: v0.5.1 4 | hooks: 5 | # Run the linter. 6 | - id: ruff 7 | args: [ --fix ] 8 | # Run the formatter. 9 | - id: ruff-format -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.1.0](https://github.com/TorBox-App/torbox-media-center/compare/v1.0.0...v1.1.0) (2025-05-09) 4 | 5 | 6 | ### Features 7 | 8 | * ability to change mount refresh time ([6c6692e](https://github.com/TorBox-App/torbox-media-center/commit/6c6692ed86e81becfccefb7f695835ba66a1a1be)) 9 | * adds banner ([407081f](https://github.com/TorBox-App/torbox-media-center/commit/407081fdf085c91d46251a49faf2efe20c0a6c02)) 10 | * adds docker support back for linux/arm/v8 and linux/arm/v7 ([8c6bf74](https://github.com/TorBox-App/torbox-media-center/commit/8c6bf74e1406cf8c1a6c70eebaec1c1b84836e2f)) 11 | * builds for linux/arm64 and linux/arm/v8 ([8dd4719](https://github.com/TorBox-App/torbox-media-center/commit/8dd4719242d602d41bba63128e60126daeb3849c)) 12 | * gets all user files by iteration ([57c2b32](https://github.com/TorBox-App/torbox-media-center/commit/57c2b32da462f7adf924254379b7763084148dfd)) 13 | * support for windows and macos by splitting mounting methods and importing safely ([badc443](https://github.com/TorBox-App/torbox-media-center/commit/badc4438b5dd19dad7f2d275f7e6b6c8fa7dcdaf)) 14 | * uses search by file with full file name for better accuracy ([5f046d1](https://github.com/TorBox-App/torbox-media-center/commit/5f046d166959c50f0d230a6ce544cab0a18f2e9d)) 15 | 16 | 17 | ### Bug Fixes 18 | 19 | * adds timeout handling ([29aaf7b](https://github.com/TorBox-App/torbox-media-center/commit/29aaf7b7a04cf65ce17b747e0ce9fb0122f5eca4)) 20 | * cannot build on osx apple silicon ([d83aba0](https://github.com/TorBox-App/torbox-media-center/commit/d83aba0e1084a1107ad8560e057a4a54b154ba3a)) 21 | * cleans titles with invalid characters and code optimizaitons, handling error ([2b2e49a](https://github.com/TorBox-App/torbox-media-center/commit/2b2e49a65e9e5881333bf80d21557e72bd19d48a)) 22 | * cleans year to be single year only ([639d567](https://github.com/TorBox-App/torbox-media-center/commit/639d56775e14882c5a4f118de47d6e004682f365)) 23 | * darwin doesn't use unsupported parameter Cannot build on Apple Silicon Mac [#4](https://github.com/TorBox-App/torbox-media-center/issues/4) ([c1fc966](https://github.com/TorBox-App/torbox-media-center/commit/c1fc9663da8b477e404e22fc0c104a5f99f6f43c)) 24 | * falls back to short_name of item if no title, fixes Crashing with TypeError [#5](https://github.com/TorBox-App/torbox-media-center/issues/5) ([b13b372](https://github.com/TorBox-App/torbox-media-center/commit/b13b372daf5d504b9cb67c686676cf373486c933)) 25 | * handles errors when generating strm files ([6c9698e](https://github.com/TorBox-App/torbox-media-center/commit/6c9698e84b499133561b1d779a355f3cbc60da5f)) 26 | * handles when item name is the hash ([39af017](https://github.com/TorBox-App/torbox-media-center/commit/39af0177329a72d3377d55ab940bc348ee68c1a2)) 27 | * proper egg when installing on mac resolves Cannot build on Apple Silicon Mac [#4](https://github.com/TorBox-App/torbox-media-center/issues/4) ([c8127d4](https://github.com/TorBox-App/torbox-media-center/commit/c8127d443e1e416a52da4c0ecc3e10bc007fdf95)) 28 | * proper error when using fuse on Windows ([7669691](https://github.com/TorBox-App/torbox-media-center/commit/7669691776d0a54587b69a373d86291f113bfbf6)) 29 | * removes meta_title which had no bearing ([8f74ff3](https://github.com/TorBox-App/torbox-media-center/commit/8f74ff3955fe14bdd41913fbab60c801d2c3bc6f)) 30 | * uses a slim bookworm docker image ([1c83020](https://github.com/TorBox-App/torbox-media-center/commit/1c83020679eca0bcb92e6f906b4060e9691035e9)) 31 | 32 | ## 1.0.0 (2025-05-06) 33 | 34 | 35 | ### Features 36 | 37 | * adds docker comands, docker compose and updated installtion in readme ([c2215ee](https://github.com/TorBox-App/torbox-media-center/commit/c2215ee1702c8448e0c6217c5cf9e877873737d5)) 38 | * adds fuse mounting ([78a8842](https://github.com/TorBox-App/torbox-media-center/commit/78a8842d7a33f2f6818879cf307c55828ee8884d)) 39 | * adds mount path ([3a1e7ce](https://github.com/TorBox-App/torbox-media-center/commit/3a1e7ce84d47d7c619f2af1af9d109041f6c7b93)) 40 | * adds proper logging ([a570254](https://github.com/TorBox-App/torbox-media-center/commit/a57025407cc00514df434f16a42665a36bcc031b)) 41 | * better readme with links ([49cee8a](https://github.com/TorBox-App/torbox-media-center/commit/49cee8a92a63ffa9ad865d7d8c6993db9736e51f)) 42 | * cleans up strm files when exiting ([9cbc648](https://github.com/TorBox-App/torbox-media-center/commit/9cbc648a6387063829806107b77fa290dd2d98af)) 43 | * functions for retrieving user files with metadata ([7f6d2c4](https://github.com/TorBox-App/torbox-media-center/commit/7f6d2c4970cc3b52be46db54a6501839c032f8a6)) 44 | * path for folders, generates strem links ([fc1d476](https://github.com/TorBox-App/torbox-media-center/commit/fc1d476ba584f0bed07caa530723aed65c6464e4)) 45 | * properly returns file metadata for storage use ([44a56cf](https://github.com/TorBox-App/torbox-media-center/commit/44a56cfd21b23f60cc9a2168abbc8628de999d61)) 46 | * readme with basic information ([0b59229](https://github.com/TorBox-App/torbox-media-center/commit/0b59229dfd8aff53b25c0e84295b9b9100a0adeb)) 47 | * refreshes vfs in the background to reflect new files ([e3838aa](https://github.com/TorBox-App/torbox-media-center/commit/e3838aaa3e7de318d2c2f08d5ff63cd790b72c84)) 48 | * runs strm on boot ([8773e16](https://github.com/TorBox-App/torbox-media-center/commit/8773e160f509bfc3de8e3756e2c7c558ad9cf513)) 49 | * start of using fuse as alternative mounting method ([59e4f8d](https://github.com/TorBox-App/torbox-media-center/commit/59e4f8df3fa6fb2a5ba4ce3018025a11b00fe90a)) 50 | * uses internal database and gets fresh data on boot ([138400f](https://github.com/TorBox-App/torbox-media-center/commit/138400f007876f673037f23f9d9d47a2aa83d900)) 51 | 52 | 53 | ### Bug Fixes 54 | 55 | * bigger time between vfs refreshes ([e1bcf59](https://github.com/TorBox-App/torbox-media-center/commit/e1bcf59a086bb780790c3487709900093e5885e4)) 56 | * doesn't delete folder, only items inside ([be25b6f](https://github.com/TorBox-App/torbox-media-center/commit/be25b6f03133187fcbc01573f1672abbcc8577c1)) 57 | * properly gets episode and season ([aa0bb46](https://github.com/TorBox-App/torbox-media-center/commit/aa0bb46dc9eb4f14fd9c7cad4bd4aac8beb8d995)) 58 | * removes all files in directory on bootup ([effc502](https://github.com/TorBox-App/torbox-media-center/commit/effc5028e84d6a7c2032d9e2de8bd372ea820c7d)) 59 | * unpacks tuple ([aef17ff](https://github.com/TorBox-App/torbox-media-center/commit/aef17ff2669400bdebf8c7d3b69e3682d2b0bfc6)) 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 TorBox 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Logo](https://raw.githubusercontent.com/TorBox-App/torbox-media-center/main/assets/header.png) 2 | 3 | ## 📚 About 4 | 5 | The TorBox Media Center allows you to easily mount your TorBox media in a no-frills way. This mounts your playable media files to your filesystem for use with [Jellyfin](https://jellyfin.org/), [Emby](https://emby.media/), [Plex](https://www.plex.tv), [Infuse](https://firecore.com/infuse), [VLC](https://www.videolan.org/vlc/), or any other media player. With TorBox's custom built solution you can mount files as virtual files (which take up zero storage space), or as '.strm' files (which take up less than 1GB for libraries of any size). 6 | 7 | > [!IMPORTANT] 8 | > *TorBox does not allow piracy or condone it in any way. This is meant to be used with media you own and have the rights to.* 9 | 10 | ### ✨ Features 11 | 12 | - Organizing your media automatically, using the [TorBox Metadata Search API](https://www.postman.com/wamy-dev/torbox/request/ubj7d6v/get-metadata-by-query) 13 | - Mounting your media simply and safely 14 | - Making sure your media is easily discoverable by media players 15 | - Fast and effecient. 16 | - Proxy for files *(if your connection is slow)* 17 | - Compatible with all systems and OS *(when using the `strm` mount method)* 18 | - No limit on library size 19 | - Automatically updating library and mounts 20 | 21 | ### 🤖 Comparison to Zurg 22 | 23 | - Usability with TorBox 24 | - Latest features for free 25 | - Faster setup *(no config necessary)* 26 | - No reliance on RClone 27 | - Optimized for TorBox 28 | - More video server/player support 29 | - Works with torrents, usenet and web downloads. 30 | 31 | ### ✖️ What this application does not do 32 | 33 | - Folder customization *(limited to 'movies' and 'series')* 34 | - Provides WebDAV server *(use TorBox's WebDAV)* 35 | - Works with all types of files *(limited to video files)* 36 | - Gets you banned from TorBox *(developed by TorBox team)* 37 | - 'Repairing' or 'renewing' your library *(this is against TorBox ToS)* 38 | - Adding new downloads 39 | - Customizing downloads *(update/rename)* 40 | - Manage downloads *(delete)* 41 | 42 | ## 🔄 Compatibility 43 | 44 | ### 💻 Compatbility with OS 45 | 46 | Compatibility is limited to Linux/Unix/BSD based systems when using the `fuse` option due to requiring [FUSE](https://www.kernel.org/doc./html/next/filesystems/fuse.html). [MacOS is also supported](https://macfuse.github.io/). 47 | 48 | The `strm` option is compatible with all systems. 49 | 50 | If the `fuse` option is selected and your system is incompatible, the application will give an error and will not run. 51 | 52 | > [!NOTE] 53 | > If you are unsure, choose the `strm` option. 54 | 55 | ### 📺 Compatbility with players / media servers 56 | 57 | The `strm` option is geared towards media servers which support '.strm' files such as Jellyfin and Emby. If using either of these options, we recommend using the `strm` mounting method. 58 | 59 | The `fuse` option is meant to be a fallback for everything else, Plex, VLC, Infuse, etc. This is due to the fuse method mounting the virtual files right to your filesystem as if they were local. This means that any video player will be able to stream from them and the TorBox Media Center will handle the rest. 60 | 61 | > [!TIP] 62 | > Emby / Jellyfin => `strm` 63 | > 64 | > Plex / VLC / Anything else => `fuse` 65 | 66 | ## 🔌 Choosing a mounting method 67 | 68 | [Above](https://github.com/TorBox-App/torbox-media-center/tree/main?tab=readme-ov-file#compatibility) we explained compatibility, which should be the main driving factor for making a decision, but there are few other things we should mention. 69 | 70 | 1. The virtual filesystem created by the `fuse` mounting method can be slower (playing files, reading files, listing files and directories) and take up more resources as it emulates an entire filesystem. It also may not play well with your [Docker installation](https://github.com/TorBox-App/torbox-media-center/tree/main?tab=readme-ov-file#running-on-docker-recommended) (if going that route). 71 | 2. The `strm` mounting method takes up more storage space, and disk reads and writes as they are physical text files. Over longer periods of time it can wear down your disk (not by much, but it is something we should mention). If you have a slow filesystem (hard drive vs SSD), this can be slower if you have a lot of files. 72 | 73 | ## ❓ Why not use RClone? 74 | 75 | We wanted to reduce the number of moving parts required to use this application. [RClone](https://rclone.org/) would only be used for FUSE mounting, but ~~every single~~ most Linux systems ship with some type of FUSE already, so RClone would be redundant. RClone also introduces more challenges, such as configuration, making sure versions are up to date, and you would still need FUSE anyways. This application doesn't provide a WebDAV API, so realistically, RClone isn't necessary here. 76 | 77 | ## ✅ Requirements 78 | 79 | 1. A TorBox account. Must be on a paid plan. Sign up [here](https://torbox.app/subscription). 80 | 2. A server or computer running Linux/Unix/BSD/[MacOS](https://macfuse.github.io/). Must be able to run Python or has administrator access *(only necessary for Docker installation)* 81 | 3. A player in mind you want to use *(for choosing a mounting method)* 82 | 83 | ## 🔧 Environment Variables 84 | 85 | To run this project you will need to add the following environment variables to your `.env` file or to your Docker run command. 86 | 87 | `TORBOX_API_KEY` Your TorBox API key used to authenticate with TorBox. You can find this [here](https://torbox.app/settings). This is required. 88 | 89 | `MOUNT_METHOD` The mounting method you want to use. Must be either `strm` or `fuse`. Read here for choosing a method. The default is `strm` and is optional. 90 | 91 | `MOUNT_PATH` The mounting path where all of your files will be accessible. If inside of Docker, this path needs to be accessible to other applications. If running locally without Docker, this path must be owned. 92 | 93 | `MOUNT_REFRESH_TIME` How fast you would like your mount to look for new files. Must be either `slow` for every 3 hours, `normal` for every 2 hours, `fast` for every 1 hour, or `instant` for every 6 minutes. The default is `fast` and is optional. 94 | 95 | ## 🐳 Running on Docker (recommended) 96 | 97 | 1. Make sure you have Docker installed on your server/computer. You can find instructions on how to install Docker [here](https://www.digitalocean.com/community/tutorials/how-to-install-and-use-docker-on-ubuntu-22-04) *(you can change your distribution in the guide)*. 98 | 2. Edit the below Docker command with your proper environment variables and options. More Docker run commands can be found [here](https://github.com/TorBox-App/torbox-media-center/blob/main/docker.md). 99 | 100 | ```bash 101 | docker run -it -d \ 102 | --name=torbox-media-center \ 103 | --restart=always \ 104 | --init \ 105 | -v /home/$(whoami)/torbox:/torbox \ 106 | -e TORBOX_API_KEY= \ 107 | -e MOUNT_METHOD=strm \ 108 | -e MOUNT_PATH=/torbox \ 109 | anonymoussystems/torbox-media-center:latest 110 | ``` 111 | 112 | or if you prefer Docker compose, this is the yaml, also found [here](https://github.com/TorBox-App/torbox-media-center/blob/main/docker-compose.yaml). 113 | 114 | ```yaml 115 | name: torbox-media-center 116 | services: 117 | torbox-media-center: 118 | container_name: torbox-media-center 119 | stdin_open: true 120 | tty: true 121 | restart: always 122 | volumes: 123 | - /home/$(whoami)/torbox:/torbox 124 | environment: 125 | - TORBOX_API_KEY= 126 | - MOUNT_METHOD=strm 127 | - MOUNT_PATH=/torbox 128 | image: anonymoussystems/torbox-media-center:latest 129 | ``` 130 | 131 | *You may also use the Github repository container found here: ghcr.io/torbox-app/torbox-media-center:main* 132 | 133 | 3. Wait for the files to be mounted to your local system. 134 | 135 | ## 🏠 Running Locally (no Docker) 136 | 137 | 1. Make sure you have Python installed. Anything from v3.6 should be okay. 138 | 2. Download or git clone this repository. 139 | 140 | ```bash 141 | git clone https://github.com/TorBox-App/torbox-media-center.git 142 | ``` 143 | 144 | or download the repository zip file [here](https://github.com/TorBox-App/torbox-media-center/archive/refs/heads/main.zip) and extract the files. 145 | 146 | 3. Create a `.env` file or rename `.env.example` to `.env`. 147 | 4. Edit or add in your environment variables to the `.env` file. 148 | 5. Install the requirements. 149 | 150 | ```bash 151 | pip3 install -r requirements.txt 152 | ``` 153 | 154 | 6. Run the `main.py` script. 155 | 156 | ```bash 157 | python3 main.py 158 | ``` 159 | 160 | 7. Wait for the files to be mounted to your local machine. 161 | 162 | ## 🆘 Support 163 | 164 | For support, email [contact@torbox.app](mailto:contact@torbox.app) or join our Discord server [here](https://join-discord.torbox.app). *We will not give sources or help with piracy in any way. This is for technical support only.* 165 | 166 | ## 🤝 Contributing 167 | 168 | Contributions are always welcome! 169 | 170 | Please make sure to follow [Conventional Commits](https://conventionalcommits.org/) when creating commit messages. We will authorize most pull requests, so don't hesitate to help out! 171 | -------------------------------------------------------------------------------- /assets/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TorBox-App/torbox-media-center/7dc3109e551bab5ed7c96fde6b775edd717312ff/assets/header.png -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | name: torbox-media-center 2 | services: 3 | torbox-media-center: 4 | container_name: torbox-media-center 5 | stdin_open: true 6 | tty: true 7 | restart: always 8 | volumes: 9 | - /home/$(whoami)/torbox:/torbox 10 | environment: 11 | - TORBOX_API_KEY= 12 | - MOUNT_METHOD=strm 13 | - MOUNT_PATH=/torbox 14 | image: anonymoussystems/torbox-media-center:main -------------------------------------------------------------------------------- /docker.md: -------------------------------------------------------------------------------- 1 | ## Converting Commands to Docker Compose 2 | Since Docker compose isn't as popular as Docker run commands, we only supplied the most common one [here](https://github.com/TorBox-App/torbox-media-center/blob/main/docker-compose.yaml), but since there are so many different iterations of the Docker run command, you can convert any Docker run command to a Docker compose file using [Composerize](https://www.composerize.com/), which is a pretty neat tool. 3 | 4 | Simply paste in the Docker run command you need and it will accurately and instantly convert it to a Docker compose which you can then use in tools like [Portainer](https://www.portainer.io/), or create your own full compose file which includes Plex, Jellyfin, or Emby! 5 | 6 | ## Standard Docker Run Command (STRM) 7 | 8 | Below is a standard Docker run command if spawning from the command line. Keep in mind the volume mount paths. If you change these in the environment variable, make sure you change it in the volume mount path. 9 | 10 | ```bash 11 | docker run -it -d \ 12 | --name=torbox-media-center \ 13 | --restart=always \ 14 | --init \ 15 | -v /home/$(whoami)/torbox:/torbox \ 16 | -e TORBOX_API_KEY= \ 17 | -e MOUNT_METHOD=strm \ 18 | -e MOUNT_PATH=/torbox \ 19 | anonymoussystems/torbox-media-center:latest 20 | ``` 21 | 22 | ## Standard Docker Run Command (FUSE) 23 | 24 | Below is a standard Docker run command for using FUSE. Notice the difference when attaching the `/dev/fuse` device. We also needed to add the `--cap-add SYS_ADMIN` parameter. The Docker container needs access to the FUSE device of the system to activate the virtual file system. 25 | 26 | > [!CAUTION] 27 | > The `--cap-add SYS_ADMIN` parameter gives the container certain permissions which it would otherwise have. This is required to access the host systems FUSE device, but keep this in mind. You can read more about this [here](https://docs.docker.com/reference/cli/docker/container/run/#privileged). 28 | 29 | ```bash 30 | docker run -it -d \ 31 | --name=torbox-media-center \ 32 | --restart=always \ 33 | --init \ 34 | --cap-add SYS_ADMIN \ 35 | --device /dev/fuse \ 36 | -v /mnt/torbox:/torbox:rshared \ 37 | -e TORBOX_API_KEY= \ 38 | -e MOUNT_METHOD=fuse \ 39 | -e MOUNT_PATH=/torbox \ 40 | anonymoussystems/torbox-media-center:latest 41 | ``` 42 | 43 | 44 | ## Standard Docker Run Command Using GitHub Repository (STRM) 45 | 46 | This config below is exactly the same as the above, except it is pulling from the GitHub repository rather than Docker Hub. Some people, find that GitHub is much faster than Docker, and sometimes Docker is down. 47 | ```bash 48 | docker run -it -d \ 49 | --name=torbox-media-center \ 50 | --restart=always \ 51 | --init \ 52 | -v /home/$(whoami)/torbox:/torbox \ 53 | -e TORBOX_API_KEY= \ 54 | -e MOUNT_METHOD=strm \ 55 | -e MOUNT_PATH=/torbox \ 56 | ghcr.io/torbox-app/torbox-media-center:latest 57 | ``` 58 | ## Docker Run Command Changing Location Of Files On Local System (STRM) 59 | 60 | This config below changes where on the local system the files are mounted. Instead of in the home directory, it is now mounted in the `/mnt/torbox` directory, which is where you would be able to find your mounted files. 61 | 62 | ```bash 63 | docker run -it -d \ 64 | --name=torbox-media-center \ 65 | --restart=always \ 66 | --init \ 67 | -v /mnt/torbox:/torbox \ 68 | -e TORBOX_API_KEY= \ 69 | -e MOUNT_METHOD=strm \ 70 | -e MOUNT_PATH=/torbox \ 71 | anonymoussystems/torbox-media-center:latest 72 | ``` 73 | 74 | ## Docker Run Command Changing Location Of Files In Container (STRM) 75 | 76 | This config below changes where on the Docker side where the files are stored. This isn't exactly necessary, but some people may want to change it, so here is a sample where the local system sees the mounted files at `/mnt/torbox` but inside the container, the files are now located at `/data`. Keep in mind that the `MOUNT_PATH` changed to reflect this. 77 | 78 | ```bash 79 | docker run -it -d \ 80 | --name=torbox-media-center \ 81 | --restart=always \ 82 | --init \ 83 | -v /mnt/torbox:/data \ 84 | -e TORBOX_API_KEY= \ 85 | -e MOUNT_METHOD=strm \ 86 | -e MOUNT_PATH=/data \ 87 | anonymoussystems/torbox-media-center:latest 88 | ``` 89 | 90 | ## Standard Docker Run Command Using The Very Latest Update (STRM) 91 | 92 | Below is a standard Docker run command for running the bleeding edge of this repository. This usually isn't a good idea, but can be used for testing the latest updates, or debugging issues. For the latest release updates, use the `latest` Docker image tag. To get the latest nightly/bleeding edge/source updates straight from the repository, use the `main` Docker image tag. You also may use a commit SHA hash to get the image of a specific commit. For example: `sha-bdd4339`. This works with both GitHub and Docker repository URLs. 93 | 94 | ```bash 95 | docker run -it -d \ 96 | --name=torbox-media-center \ 97 | --restart=always \ 98 | --init \ 99 | -v /home/$(whoami)/torbox:/torbox \ 100 | -e TORBOX_API_KEY= \ 101 | -e MOUNT_METHOD=strm \ 102 | -e MOUNT_PATH=/torbox \ 103 | anonymoussystems/torbox-media-center:main 104 | ``` 105 | -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10.12-slim-bookworm 2 | 3 | WORKDIR /app 4 | COPY requirements.txt . 5 | 6 | # Install common dependencies 7 | RUN pip install $(grep -v "fuse\|git+" requirements.txt) 8 | 9 | # Install build dependencies 10 | RUN apt-get update && \ 11 | apt-get install -y git pkg-config libfuse-dev gcc make 12 | 13 | # Architecture-specific installations 14 | ARG TARGETPLATFORM 15 | RUN echo "Building for $TARGETPLATFORM" && \ 16 | case "$TARGETPLATFORM" in \ 17 | "linux/amd64"|"linux/arm64") \ 18 | pip install fuse-python \ 19 | ;; \ 20 | "linux/arm/v7"|"linux/arm/v8") \ 21 | pip install git+https://github.com/libfuse/python-fuse \ 22 | ;; \ 23 | *) \ 24 | echo "Unsupported platform: $TARGETPLATFORM" && exit 1 \ 25 | ;; \ 26 | esac 27 | 28 | COPY . . 29 | 30 | ENV TORBOX_API_KEY= 31 | ENV MOUNT_METHOD=strm 32 | ENV MOUNT_PATH=/torbox 33 | 34 | CMD ["python", "main.py"] -------------------------------------------------------------------------------- /functions/appFunctions.py: -------------------------------------------------------------------------------- 1 | from functions.torboxFunctions import getUserDownloads, DownloadType 2 | from library.filesystem import MOUNT_METHOD, MOUNT_PATH 3 | from library.app import MOUNT_REFRESH_TIME 4 | from library.torbox import TORBOX_API_KEY 5 | from functions.databaseFunctions import getAllData, clearDatabase 6 | import logging 7 | import os 8 | import shutil 9 | 10 | def initializeFolders(): 11 | """ 12 | Initialize the necessary folders for the application. 13 | """ 14 | folders = [ 15 | MOUNT_PATH, 16 | os.path.join(MOUNT_PATH, "movies"), 17 | os.path.join(MOUNT_PATH, "series"), 18 | ] 19 | 20 | for folder in folders: 21 | if os.path.exists(folder): 22 | logging.debug(f"Folder {folder} already exists. Deleting...") 23 | for item in os.listdir(folder): 24 | item_path = os.path.join(folder, item) 25 | if os.path.isdir(item_path): 26 | shutil.rmtree(item_path) 27 | else: 28 | os.remove(item_path) 29 | else: 30 | logging.debug(f"Creating folder {folder}...") 31 | os.makedirs(folder, exist_ok=True) 32 | 33 | 34 | def getAllUserDownloadsFresh(): 35 | all_downloads = [] 36 | logging.info("Fetching all user downloads...") 37 | for download_type in DownloadType: 38 | logging.debug(f"Clearing database for {download_type.value}...") 39 | success, detail = clearDatabase(download_type.value) 40 | if not success: 41 | logging.error(f"Error clearing {download_type.value} database: {detail}") 42 | continue 43 | logging.debug(f"Fetching {download_type.value} downloads...") 44 | downloads, success, detail = getUserDownloads(download_type) 45 | if not success: 46 | logging.error(f"Error fetching {download_type.value}: {detail}") 47 | continue 48 | if not downloads: 49 | logging.info(f"No {download_type.value} downloads found.") 50 | continue 51 | all_downloads.extend(downloads) 52 | logging.debug(f"Fetched {len(downloads)} {download_type.value} downloads.") 53 | return all_downloads 54 | 55 | def getAllUserDownloads(): 56 | all_downloads = [] 57 | for download_type in DownloadType: 58 | logging.debug(f"Fetching {download_type.value} downloads...") 59 | downloads, success, detail = getAllData(download_type.value) 60 | if not success: 61 | logging.error(f"Error fetching {download_type.value}: {detail}") 62 | continue 63 | all_downloads.extend(downloads) 64 | logging.debug(f"Fetched {len(downloads)} {download_type.value} downloads.") 65 | return all_downloads 66 | 67 | def bootUp(): 68 | logging.debug("Booting up...") 69 | logging.info("Mount method: %s", MOUNT_METHOD) 70 | logging.info("Mount path: %s", MOUNT_PATH) 71 | logging.info("TorBox API Key: %s", TORBOX_API_KEY) 72 | logging.info("Mount refresh time: %s %s", MOUNT_REFRESH_TIME, "hours") 73 | initializeFolders() 74 | 75 | return True 76 | 77 | def getMountMethod(): 78 | return MOUNT_METHOD 79 | 80 | def getMountPath(): 81 | return MOUNT_PATH 82 | 83 | def getMountRefreshTime(): 84 | return MOUNT_REFRESH_TIME -------------------------------------------------------------------------------- /functions/databaseFunctions.py: -------------------------------------------------------------------------------- 1 | from tinydb import TinyDB 2 | 3 | def getDatabase(name: str = "db"): 4 | """ 5 | Returns the TinyDB database instance. 6 | """ 7 | try: 8 | return TinyDB(f"{name}.json") 9 | except Exception as e: 10 | print(f"Error connecting to the database: {e}") 11 | return None 12 | 13 | def clearDatabase(type: str): 14 | """ 15 | Clears the entire database. 16 | """ 17 | db = getDatabase(type) 18 | if db is None: 19 | return False, "Database connection failed." 20 | try: 21 | db.truncate() 22 | return True, "Database cleared successfully." 23 | except Exception as e: 24 | return False, f"Error clearing the database: {e}" 25 | 26 | def insertData(data: dict, type: str): 27 | """ 28 | Inserts data into the database. 29 | """ 30 | db = getDatabase(type) 31 | if db is None: 32 | return False, "Database connection failed." 33 | try: 34 | db.insert(data) 35 | return True, "Data inserted successfully." 36 | except Exception as e: 37 | return False, f"Error inserting data. {e}" 38 | 39 | def getAllData(type: str): 40 | """ 41 | Retrieves all data from the database. 42 | """ 43 | db = getDatabase(type) 44 | if db is None: 45 | return None, False, "Database connection failed." 46 | try: 47 | data = db.all() 48 | return data, True, "Data retrieved successfully." 49 | except Exception as e: 50 | return None, False, f"Error retrieving data. {e}" -------------------------------------------------------------------------------- /functions/fuseFilesystemFunctions.py: -------------------------------------------------------------------------------- 1 | import os 2 | from library.filesystem import MOUNT_PATH 3 | import stat 4 | import errno 5 | from functions.torboxFunctions import getDownloadLink, downloadFile 6 | import time 7 | import sys 8 | import logging 9 | from functions.appFunctions import getAllUserDownloads 10 | import threading 11 | from sys import platform 12 | 13 | # Pull in some spaghetti to make this stuff work without fuse-py being installed 14 | try: 15 | import _find_fuse_parts # type: ignore # noqa: F401 16 | except ImportError: 17 | pass 18 | import fuse 19 | from fuse import Fuse 20 | if not hasattr(fuse, '__version__'): 21 | raise RuntimeError("your fuse-python doesn't know of fuse.__version__, probably it's too old.") 22 | 23 | fuse.fuse_python_api = (0, 2) 24 | 25 | class VirtualFileSystem: 26 | def __init__(self, files_list): 27 | self.files = files_list 28 | self.structure = self._build_structure() 29 | self.file_map = self._build_file_map() 30 | 31 | def _build_structure(self): 32 | structure = { 33 | '/': ['movies', 'series'], 34 | '/movies': set(), 35 | '/series': set() 36 | } 37 | 38 | 39 | for f in self.files: 40 | media_type = f.get('metadata_mediatype') 41 | root_folder = f.get('metadata_rootfoldername') 42 | 43 | if media_type == 'movie': 44 | path = f'/movies/{root_folder}' 45 | structure['/movies'].add(root_folder) 46 | 47 | if path not in structure: 48 | structure[path] = set() 49 | structure[path].add(f.get('metadata_filename')) 50 | 51 | elif media_type == 'series': 52 | path = f'/series/{root_folder}' 53 | structure['/series'].add(root_folder) 54 | 55 | if path not in structure: 56 | structure[path] = set() 57 | structure[path].add(f.get('metadata_foldername')) 58 | 59 | season_path = f'{path}/{f.get("metadata_foldername")}' 60 | if season_path not in structure: 61 | structure[season_path] = set() 62 | structure[season_path].add(f.get('metadata_filename')) 63 | 64 | # consistent ordering 65 | for key in structure: 66 | structure[key] = sorted([item for item in structure[key] if item is not None]) 67 | 68 | return structure 69 | 70 | def _build_file_map(self): 71 | file_map = {} 72 | 73 | for f in self.files: 74 | if f.get('metadata_mediatype') == 'movie': 75 | path = f'/movies/{f.get("metadata_rootfoldername")}/{f.get("metadata_filename")}' 76 | file_map[path] = f 77 | else: # series 78 | path = f'/series/{f.get("metadata_rootfoldername")}/{f.get("metadata_foldername")}/{f.get("metadata_filename")}' 79 | file_map[path] = f 80 | 81 | return file_map 82 | 83 | def is_dir(self, path): 84 | return path in self.structure 85 | 86 | def is_file(self, path): 87 | return path in self.file_map 88 | 89 | def get_file(self, path): 90 | return self.file_map.get(path) 91 | 92 | def list_dir(self, path): 93 | return self.structure.get(path, []) 94 | 95 | class FuseStat(fuse.Stat): 96 | def __init__(self): 97 | self.st_mode = 0 98 | self.st_ino = 0 99 | self.st_dev = 0 100 | self.st_nlink = 0 101 | self.st_uid = 0 102 | self.st_gid = 0 103 | self.st_size = 0 104 | self.st_atime = 0 105 | self.st_mtime = 0 106 | self.st_ctime = 0 107 | 108 | class TorBoxMediaCenterFuse(Fuse): 109 | def __init__(self, *args, **kwargs): 110 | super(TorBoxMediaCenterFuse, self).__init__(*args, **kwargs) 111 | 112 | threading.Thread(target=self.getFiles, daemon=True).start() 113 | 114 | self.files = [] 115 | self.vfs = VirtualFileSystem(self.files) 116 | self.file_handles = {} 117 | self.next_handle = 1 118 | self.cached_links = {} 119 | 120 | self.cache = {} 121 | self.block_size = 1024 * 1024 * 16 122 | self.max_blocks = 16 123 | 124 | def getFiles(self): 125 | while True: 126 | files = getAllUserDownloads() 127 | if files: 128 | self.files = files 129 | self.vfs = VirtualFileSystem(self.files) 130 | logging.debug(f"Updated {len(self.files)} files in VFS") 131 | time.sleep(300) 132 | 133 | def getattr(self, path): 134 | st = FuseStat() 135 | now = int(time.time()) 136 | st.st_atime = now 137 | st.st_mtime = now 138 | st.st_ctime = now 139 | 140 | st.st_uid = os.getuid() 141 | st.st_gid = os.getgid() 142 | 143 | if self.vfs.is_dir(path): 144 | st.st_mode = stat.S_IFDIR | 0o755 145 | st.st_nlink = 2 146 | return st 147 | elif self.vfs.is_file(path): 148 | file_info = self.vfs.get_file(path) 149 | st.st_mode = stat.S_IFREG | 0o444 150 | st.st_nlink = 1 151 | st.st_size = file_info.get('file_size', 0) 152 | return st 153 | 154 | # Not found 155 | return -errno.ENOENT 156 | 157 | def readdir(self, path, _): 158 | if not self.vfs.is_dir(path): 159 | return -errno.ENOENT 160 | 161 | yield fuse.Direntry('.') 162 | yield fuse.Direntry('..') 163 | 164 | for item in self.vfs.list_dir(path): 165 | yield fuse.Direntry(item) 166 | 167 | def open(self, _, flags): 168 | accmode = os.O_RDONLY | os.O_WRONLY | os.O_RDWR 169 | if (flags & accmode) != os.O_RDONLY: 170 | return -errno.EACCES 171 | 172 | def read(self, path, size, offset): 173 | logging.debug(f"READ Path: {path}") 174 | logging.debug(f"READ Size: {size}") 175 | logging.debug(f"READ Offset: {offset}") 176 | file = self.vfs.get_file(path) 177 | 178 | if path not in self.cached_links: 179 | self.cached_links[path] = getDownloadLink(file.get('download_link')) 180 | download_link = self.cached_links[path] 181 | 182 | start_block = offset // self.block_size 183 | end_block = (offset + size - 1) // self.block_size 184 | 185 | buffer = bytearray() 186 | 187 | for block_index in range(start_block, end_block + 1): 188 | block_offset = block_index * self.block_size 189 | block_end = min((block_index + 1) * self.block_size - 1, file.get('file_size') - 1) 190 | current_block_size = block_end - block_offset + 1 191 | 192 | # check for block 193 | if (path, block_index) not in self.cache: 194 | logging.debug(f"Cache miss for block {block_index}, fetching...") 195 | # get block 196 | block_data = downloadFile(download_link, current_block_size, block_offset) 197 | if not block_data: 198 | return -errno.EIO 199 | # save block to cache 200 | self.cache[(path, block_index)] = block_data 201 | # lru cache 202 | if len(self.cache) > self.max_blocks * len(self.cached_links): 203 | keys_to_remove = list(self.cache.keys())[:len(self.cache) - self.max_blocks] 204 | for key in keys_to_remove: 205 | del self.cache[key] 206 | # get block from cache 207 | block_data = self.cache[(path, block_index)] 208 | 209 | start_offset_in_block = max(0, offset - block_offset) 210 | end_offset_in_block = min(len(block_data), offset + size - block_offset) 211 | 212 | buffer.extend(block_data[start_offset_in_block:end_offset_in_block]) 213 | 214 | return bytes(buffer) 215 | 216 | def release(self, _, fh): 217 | if fh in self.file_handles: 218 | del self.file_handles[fh] 219 | return 0 220 | 221 | def runFuse(): 222 | server = TorBoxMediaCenterFuse( 223 | version="%prog " + fuse.__version__, 224 | usage="%prog [options] mountpoint", 225 | dash_s_do="setsingle", 226 | ) 227 | 228 | server.parser.add_option( 229 | mountopt="root", 230 | metavar="PATH", 231 | default=MOUNT_PATH, 232 | help="Mount point for the filesystem", 233 | ) 234 | if platform != "darwin": 235 | server.fuse_args.add( 236 | "nonempty" 237 | ) 238 | server.fuse_args.add( 239 | "allow_other" 240 | ) 241 | server.fuse_args.add( 242 | "-f" 243 | ) 244 | server.parse(values=server, errex=1) 245 | try: 246 | server.fuse_args.mountpoint = MOUNT_PATH 247 | except OSError as e: 248 | logging.error(f"Error changing directory: {e}") 249 | sys.exit(1) 250 | server.main() 251 | 252 | def unmountFuse(): 253 | try: 254 | os.system("fusermount -u " + MOUNT_PATH) 255 | except OSError as e: 256 | logging.error(f"Error unmounting: {e}") 257 | sys.exit(1) 258 | logging.info("Unmounted successfully.") -------------------------------------------------------------------------------- /functions/mediaFunctions.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | def constructSeriesTitle(season = None, episode = None, folder: bool = False): 4 | """ 5 | Constructs a proper title for a series based on the season and episode. 6 | 7 | :param season: The season number or a list of season numbers. 8 | :param episode: The episode number or a list of episode numbers. 9 | :param folder: If True, the title will be formatted for a folder name. 10 | """ 11 | 12 | 13 | title_season = None 14 | title_episode = None 15 | 16 | if isinstance(season, list): 17 | # get first and last season 18 | title_season = f"S{season[0]:02}-S{season[-1]:02}" 19 | elif isinstance(season, int) or season is not None: 20 | if folder: 21 | title_season = f"Season {season}" 22 | else: 23 | title_season = f"S{season:02}" 24 | 25 | if isinstance(episode, list): 26 | # get first and last episode 27 | title_episode = f"E{episode[0]:02}-E{episode[-1]:02}" 28 | elif isinstance(episode, int) or episode is not None: 29 | title_episode = f"E{episode:02}" 30 | 31 | if title_season and title_episode: 32 | return f"{title_season}{title_episode}" 33 | elif title_season: 34 | return title_season 35 | elif title_episode: 36 | return title_episode 37 | else: 38 | return None 39 | 40 | def cleanTitle(title: str): 41 | """ 42 | Removes invalid characters from the title. 43 | """ 44 | title = re.sub(r"[\/\\\:\*\?\"\<\>\|]", "", title) 45 | return title 46 | 47 | def cleanYear(year: str | int): 48 | """ 49 | Cleans the year listing which can be a string (2023-2024) or an int (2023). 50 | """ 51 | if isinstance(year, str): 52 | year = year.split("-")[0] 53 | return int(year) 54 | -------------------------------------------------------------------------------- /functions/stremFilesystemFunctions.py: -------------------------------------------------------------------------------- 1 | import os 2 | from library.filesystem import MOUNT_PATH 3 | import logging 4 | from functions.appFunctions import getAllUserDownloads 5 | import shutil 6 | 7 | def generateFolderPath(data: dict): 8 | """ 9 | Takes in a user download and returns the folder path for the download. 10 | 11 | Series (Year)/Season XX/Title SXXEXX.ext 12 | Movie (Year)/Title (Year).ext 13 | 14 | """ 15 | root_folder = data.get("metadata_rootfoldername", None) 16 | metadata_foldername = data.get("metadata_foldername", None) 17 | 18 | if data.get("metadata_mediatype") == "series": 19 | if not metadata_foldername: 20 | return None 21 | folder_path = os.path.join( 22 | root_folder, 23 | metadata_foldername, 24 | ) 25 | elif data.get("metadata_mediatype") == "movie": 26 | folder_path = os.path.join( 27 | root_folder 28 | ) 29 | 30 | elif data.get("metadata_mediatype") == "anime": 31 | if not metadata_foldername: 32 | return None 33 | folder_path = os.path.join( 34 | root_folder, 35 | metadata_foldername, 36 | ) 37 | else: 38 | folder_path = os.path.join( 39 | root_folder 40 | ) 41 | return folder_path 42 | 43 | def generateStremFile(file_path: str, url: str, type: str, file_name: str): 44 | if file_path is None: 45 | return 46 | if type == "movie": 47 | type = "movies" 48 | elif type == "series": 49 | type = "series" 50 | elif type == "anime": 51 | type = "series" 52 | 53 | full_path = os.path.join(MOUNT_PATH, type, file_path) 54 | 55 | try: 56 | os.makedirs(full_path, exist_ok=True) 57 | with open(f"{full_path}/{file_name}.strm", "w") as file: 58 | file.write(url) 59 | logging.debug(f"Created strm file: {full_path}/{file_name}.strm") 60 | return True 61 | except OSError as e: 62 | logging.error(f"Error creating strm file (likely bad or missing permissions): {e}") 63 | return False 64 | except FileNotFoundError as e: 65 | logging.error(f"Error creating strm file (likely bad naming scheme of file): {e}") 66 | return False 67 | except Exception as e: 68 | logging.error(f"Error creating strm file: {e}") 69 | return False 70 | 71 | def runStrm(): 72 | all_downloads = getAllUserDownloads() 73 | for download in all_downloads: 74 | file_path = generateFolderPath(download) 75 | if file_path is None: 76 | continue 77 | generateStremFile(file_path, download.get("download_link"), download.get("metadata_mediatype"), download.get("metadata_filename")) 78 | 79 | logging.debug(f"Updated {len(all_downloads)} strm files.") 80 | 81 | def unmountStrm(): 82 | """ 83 | Deletes all strm files and any subfolders in the mount path for cleaning up. 84 | """ 85 | folders = [ 86 | MOUNT_PATH, 87 | os.path.join(MOUNT_PATH, "movies"), 88 | os.path.join(MOUNT_PATH, "series"), 89 | ] 90 | for folder in folders: 91 | if os.path.exists(folder): 92 | logging.debug(f"Folder {folder} already exists. Deleting...") 93 | for item in os.listdir(folder): 94 | item_path = os.path.join(folder, item) 95 | if os.path.isdir(item_path): 96 | shutil.rmtree(item_path) 97 | else: 98 | os.remove(item_path) -------------------------------------------------------------------------------- /functions/torboxFunctions.py: -------------------------------------------------------------------------------- 1 | from library.http import api_http_client, search_api_http_client, general_http_client 2 | import httpx 3 | from enum import Enum 4 | import PTN 5 | from library.torbox import TORBOX_API_KEY 6 | from functions.mediaFunctions import constructSeriesTitle, cleanTitle, cleanYear 7 | from functions.databaseFunctions import insertData 8 | import os 9 | import logging 10 | 11 | class DownloadType(Enum): 12 | torrent = "torrents" 13 | usenet = "usenet" 14 | webdl = "webdl" 15 | 16 | class IDType(Enum): 17 | torrents = "torrent_id" 18 | usenet = "usenet_id" 19 | webdl = "web_id" 20 | 21 | ACCEPTABLE_MIME_TYPES = [ 22 | "video/x-matroska", 23 | "video/mp4", 24 | ] 25 | 26 | def getUserDownloads(type: DownloadType): 27 | 28 | offset = 0 29 | limit = 1000 30 | 31 | file_data = [] 32 | 33 | while True: 34 | params = { 35 | "limit": limit, 36 | "offset": offset, 37 | "bypass_cache": True, 38 | } 39 | try: 40 | response = api_http_client.get(f"/{type.value}/mylist", params=params) 41 | except Exception as e: 42 | logging.error(f"Error fetching {type.value}: {e}") 43 | return None, False, f"Error fetching {type.value}: {e}" 44 | if response.status_code != 200: 45 | return None, False, f"Error fetching {type.value}. {response.status_code}" 46 | data = response.json().get("data", []) 47 | if not data: 48 | break 49 | file_data.extend(data) 50 | offset += limit 51 | if len(data) < limit: 52 | break 53 | 54 | if not file_data: 55 | return None, True, f"No {type.value} found." 56 | 57 | logging.debug(f"Fetched {len(file_data)} {type.value} items from API.") 58 | 59 | files = [] 60 | 61 | for item in file_data: 62 | if not item.get("cached", False): 63 | continue 64 | for file in item.get("files", []): 65 | if not file.get("mimetype").startswith("video/") or file.get("mimetype") not in ACCEPTABLE_MIME_TYPES: 66 | logging.debug(f"Skipping file {file.get('short_name')} with mimetype {file.get('mimetype')}") 67 | continue 68 | data = { 69 | "item_id": item.get("id"), 70 | "type": type.value, 71 | "folder_name": item.get("name"), 72 | "folder_hash": item.get("hash"), 73 | "file_id": file.get("id"), 74 | "file_name": file.get("short_name"), 75 | "file_size": file.get("size"), 76 | "file_mimetype": file.get("mimetype"), 77 | "path": file.get("name"), 78 | "download_link": f"https://api.torbox.app/v1/api/{type.value}/requestdl?token={TORBOX_API_KEY}&{IDType[type.value].value}={item.get('id')}&file_id={file.get('id')}&redirect=true", 79 | "extension": os.path.splitext(file.get("short_name"))[-1], 80 | } 81 | title_data = PTN.parse(file.get("short_name")) 82 | 83 | if item.get("name") == item.get("hash"): 84 | item["name"] = title_data.get("title", file.get("short_name")) 85 | 86 | metadata, _, _ = searchMetadata(title_data.get("title", file.get("short_name")), title_data, file.get("short_name"), f"{item.get('name')} {file.get('short_name')}") 87 | data.update(metadata) 88 | files.append(data) 89 | logging.debug(data) 90 | insertData(data, type.value) 91 | 92 | return files, True, f"{type.value.capitalize()} fetched successfully." 93 | 94 | def searchMetadata(query: str, title_data: dict, file_name: str, full_title: str): 95 | base_metadata = { 96 | "metadata_title": cleanTitle(query), 97 | "metadata_link": None, 98 | "metadata_mediatype": "movie", 99 | "metadata_image": None, 100 | "metadata_backdrop": None, 101 | "metadata_years": None, 102 | "metadata_season": None, 103 | "metadata_episode": None, 104 | "metadata_filename": file_name, 105 | "metadata_rootfoldername": title_data.get("title", None), 106 | } 107 | extension = os.path.splitext(file_name)[-1] 108 | try: 109 | response = search_api_http_client.get(f"/meta/search/{full_title}", params={"type": "file"}) 110 | except Exception as e: 111 | logging.error(f"Error searching metadata: {e}") 112 | return base_metadata, False, f"Error searching metadata: {e}" 113 | if response.status_code != 200: 114 | logging.error(f"Error searching metadata: {response.status_code}") 115 | return base_metadata, False, f"Error searching metadata. {response.status_code}" 116 | try: 117 | data = response.json().get("data", [])[0] 118 | 119 | title = cleanTitle(data.get("title")) 120 | base_metadata["metadata_title"] = title 121 | base_metadata["metadata_years"] = cleanYear(title_data.get("year", None) or data.get("releaseYears")) 122 | 123 | if data.get("type") == "anime" or data.get("type") == "series": 124 | series_season_episode = constructSeriesTitle(season=title_data.get("season", None), episode=title_data.get("episode", None)) 125 | file_name = f"{title} {series_season_episode}{extension}" 126 | base_metadata["metadata_foldername"] = constructSeriesTitle(season=title_data.get("season", 1), folder=True) 127 | base_metadata["metadata_season"] = title_data.get("season", 1) 128 | base_metadata["metadata_episode"] = title_data.get("episode") 129 | elif data.get("type") == "movie": 130 | file_name = f"{title} ({base_metadata['metadata_years']}){extension}" 131 | else: 132 | return base_metadata, False, "No metadata found." 133 | 134 | base_metadata["metadata_filename"] = file_name 135 | base_metadata["metadata_mediatype"] = data.get("type") 136 | base_metadata["metadata_link"] = data.get("link") 137 | base_metadata["metadata_image"] = data.get("image") 138 | base_metadata["metadata_backdrop"] = data.get("backdrop") 139 | base_metadata["metadata_rootfoldername"] = f"{title} ({base_metadata['metadata_years']})" 140 | 141 | return base_metadata, True, "Metadata found." 142 | except IndexError: 143 | return base_metadata, False, "No metadata found." 144 | except Exception as e: 145 | logging.error(f"Error searching metadata: {e}") 146 | return base_metadata, False, f"Error searching metadata: {e}" 147 | 148 | def getDownloadLink(url: str): 149 | response = general_http_client.get(url) 150 | if response.status_code == httpx.codes.TEMPORARY_REDIRECT or response.status_code == httpx.codes.PERMANENT_REDIRECT or response.status_code == httpx.codes.FOUND: 151 | return response.headers.get('Location') 152 | return url 153 | 154 | def downloadFile(url: str, size: int, offset: int = 0): 155 | headers = { 156 | "Range": f"bytes={offset}-{offset + size - 1}", 157 | **general_http_client.headers, 158 | } 159 | response = general_http_client.get(url, headers=headers) 160 | if response.status_code == httpx.codes.OK: 161 | return response.content 162 | elif response.status_code == httpx.codes.PARTIAL_CONTENT: 163 | return response.content 164 | else: 165 | logging.error(f"Error downloading file: {response.status_code}") 166 | raise Exception(f"Error downloading file: {response.status_code}") 167 | 168 | -------------------------------------------------------------------------------- /library/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | from enum import Enum 4 | 5 | load_dotenv() 6 | 7 | class MountRefreshTimes(Enum): 8 | # times are shown in hours 9 | slow = 3 # 3 hours 10 | normal = 2 # 2 hours 11 | fast = 1 # 1 hour 12 | instant = 0.10 # 6 minutes 13 | 14 | MOUNT_REFRESH_TIME = os.getenv("MOUNT_REFRESH_TIME", MountRefreshTimes.fast.name) 15 | MOUNT_REFRESH_TIME = MOUNT_REFRESH_TIME.lower() 16 | assert MOUNT_REFRESH_TIME in [e.name for e in MountRefreshTimes], f"Invalid mount refresh time: {MOUNT_REFRESH_TIME}. Valid options are: {[e.name for e in MountRefreshTimes]}" 17 | 18 | MOUNT_REFRESH_TIME = MountRefreshTimes[MOUNT_REFRESH_TIME].value -------------------------------------------------------------------------------- /library/filesystem.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | from enum import Enum 4 | 5 | load_dotenv() 6 | 7 | class MountMethods(Enum): 8 | strm = "strm" 9 | fuse = "fuse" 10 | 11 | MOUNT_METHOD = os.getenv("MOUNT_METHOD", MountMethods.strm.value) 12 | assert MOUNT_METHOD in [method.value for method in MountMethods], "MOUNT_METHOD is not set correctly in .env file" 13 | 14 | MOUNT_PATH = os.getenv("MOUNT_PATH", "./torbox") 15 | assert MOUNT_PATH, "MOUNT_PATH is not set in .env file" -------------------------------------------------------------------------------- /library/http.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | from library.torbox import TORBOX_API_KEY 3 | 4 | TORBOX_API_URL = "https://api.torbox.app/v1/api" 5 | TORBOX_SEARCH_API_URL = "https://search-api.torbox.app" 6 | 7 | api_http_client = httpx.Client( 8 | base_url=TORBOX_API_URL, 9 | headers={ 10 | "Authorization": f"Bearer {TORBOX_API_KEY}", 11 | "User-Agent": "TorBox-Media-Center/1.0 TorBox/1.0", 12 | }, 13 | timeout=httpx.Timeout(60), 14 | follow_redirects=True, 15 | ) 16 | 17 | search_api_http_client = httpx.Client( 18 | base_url=TORBOX_SEARCH_API_URL, 19 | headers={ 20 | "Authorization": f"Bearer {TORBOX_API_KEY}", 21 | "User-Agent": "TorBox-Media-Center/1.0 TorBox/1.0", 22 | }, 23 | timeout=httpx.Timeout(60), 24 | follow_redirects=True, 25 | ) 26 | 27 | general_http_client = httpx.Client( 28 | headers={ 29 | "Authorization": f"Bearer {TORBOX_API_KEY}", 30 | "User-Agent": "TorBox-Media-Center/1.0 TorBox/1.0", 31 | }, 32 | timeout=httpx.Timeout(60), 33 | follow_redirects=False, 34 | ) 35 | -------------------------------------------------------------------------------- /library/torbox.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | 4 | load_dotenv() 5 | 6 | TORBOX_API_KEY = os.getenv("TORBOX_API_KEY") 7 | assert TORBOX_API_KEY, "TORBOX_API_KEY is not set in .env file" -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from apscheduler.schedulers.blocking import BlockingScheduler 2 | from apscheduler.schedulers.background import BackgroundScheduler 3 | from functions.appFunctions import bootUp, getMountMethod, getAllUserDownloadsFresh, getMountRefreshTime 4 | import logging 5 | from sys import platform 6 | 7 | logging.basicConfig( 8 | level=logging.INFO, 9 | format='%(asctime)s,%(msecs)03d %(name)s %(levelname)s %(message)s', 10 | datefmt='%Y-%m-%d %H:%M:%S', 11 | ) 12 | logging.getLogger("httpx").setLevel(logging.WARNING) 13 | 14 | if __name__ == "__main__": 15 | bootUp() 16 | mount_method = getMountMethod() 17 | 18 | if mount_method == "strm": 19 | scheduler = BlockingScheduler() 20 | elif mount_method == "fuse": 21 | if platform == "win32": 22 | logging.error("The FUSE mount method is not supported on Windows. Please use the STRM mount method or run this application on a Linux system.") 23 | exit(1) 24 | scheduler = BackgroundScheduler() 25 | else: 26 | logging.error("Invalid mount method specified.") 27 | exit(1) 28 | 29 | user_downloads = getAllUserDownloadsFresh() 30 | 31 | scheduler.add_job( 32 | getAllUserDownloadsFresh, 33 | "interval", 34 | hours=getMountRefreshTime(), 35 | id="get_all_user_downloads_fresh", 36 | ) 37 | 38 | try: 39 | logging.info("Starting scheduler and mounting...") 40 | if mount_method == "strm": 41 | from functions.stremFilesystemFunctions import runStrm 42 | runStrm() 43 | scheduler.add_job( 44 | runStrm, 45 | "interval", 46 | minutes=5, 47 | id="run_strm", 48 | ) 49 | scheduler.start() 50 | elif mount_method == "fuse": 51 | from functions.fuseFilesystemFunctions import runFuse 52 | scheduler.start() 53 | runFuse() 54 | except (KeyboardInterrupt, SystemExit): 55 | if mount_method == "fuse": 56 | from functions.fuseFilesystemFunctions import unmountFuse 57 | unmountFuse() 58 | elif mount_method == "strm": 59 | from functions.stremFilesystemFunctions import unmountStrm 60 | unmountStrm() 61 | pass -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | apscheduler 2 | tinydb 3 | httpx 4 | python-dotenv 5 | parse-torrent-title 6 | fuse-python; sys_platform != "win32" and sys_platform != "darwin" 7 | git+https://github.com/libfuse/python-fuse; sys_platform == "darwin" --------------------------------------------------------------------------------