├── .dockerignore ├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── countries.json ├── default-tags.toml ├── default-templates ├── fakestash-v3 ├── r18 └── tushy ├── default.toml ├── emp_stash_fill.py ├── emp_stash_fill.user.js ├── migrations ├── alembic.ini ├── env.py ├── script.py.mako └── versions │ ├── 017767fd9fdb_.py │ ├── 0b7028b71064_.py │ ├── 7990cc760362_.py │ ├── e8e3ef7a0fd7_.py │ └── feb7b60bf53f_.py ├── requirements.txt ├── resources └── stash_library.user.js ├── static ├── browserconfig.xml ├── images │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── logo.svg │ ├── mstile-144x144.png │ ├── mstile-150x150.png │ ├── mstile-310x150.png │ ├── mstile-310x310.png │ ├── mstile-70x70.png │ └── safari-pinned-tab.svg ├── node_modules │ └── strftime │ │ └── strftime-min.js ├── scripts │ └── forms.js └── site.webmanifest ├── utils ├── bencoder.py ├── confighandler.py ├── customtypes.py ├── db.py ├── generator.py ├── imagehandler.py ├── packs.py ├── paths.py ├── taghandler.py └── torrentclients.py └── webui ├── forms.py ├── templates ├── base-settings.html ├── base.html ├── categories.html ├── dbexport.html ├── error-page.html ├── navbar.html ├── search.html ├── settings.html ├── tag-advanced.html └── tag-settings.html ├── validators.py └── webui.py /.dockerignore: -------------------------------------------------------------------------------- 1 | config/ 2 | Dockerfile 3 | .dockerignore 4 | emp_stash_fill.user.js 5 | .git/ 6 | .gitignore 7 | *.md 8 | __pycache__/ 9 | .vscode/ 10 | .pytests_cache/ 11 | dist/ 12 | tests/ 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | groups: 8 | flask: 9 | patterns: 10 | - "*flask*" 11 | - "*Flask*" 12 | python-packages: 13 | patterns: 14 | - "*" 15 | 16 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: docker-ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | tags: 8 | - 'v*' 9 | pull_request: 10 | branches: 11 | - 'master' 12 | 13 | jobs: 14 | docker: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - 18 | name: Checkout 19 | uses: actions/checkout@v4 20 | - 21 | name: Docker meta 22 | id: meta 23 | uses: docker/metadata-action@v5 24 | with: 25 | images: | 26 | bdbenim/stash-empornium 27 | tags: | 28 | type=ref,event=branch 29 | type=ref,event=pr 30 | type=semver,pattern={{version}} 31 | type=semver,pattern={{major}} 32 | type=semver,pattern={{major}}.{{minor}} 33 | - 34 | name: Login to DockerHub 35 | if: github.event_name != 'pull_request' 36 | uses: docker/login-action@v3 37 | with: 38 | username: ${{ secrets.DOCKERHUB_USERNAME }} 39 | password: ${{ secrets.DOCKERHUB_TOKEN }} 40 | - 41 | name: Build and push 42 | uses: docker/build-push-action@v5 43 | with: 44 | context: . 45 | push: ${{ github.event_name != 'pull_request' }} 46 | tags: ${{ steps.meta.outputs.tags }} 47 | labels: ${{ steps.meta.outputs.labels }} 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config/ 2 | .vscode/ 3 | __pycache__/ 4 | package*.json 5 | node_modules/ 6 | !strftime-min.js 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## How to contribute to stash-empornium 2 | 3 | #### **Did you find a bug?** 4 | 5 | * **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/bdbenim/stash-empornium/issues). 6 | 7 | * If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/rails/rails/issues/new). Be sure to include a **title and clear description**, as much relevant information as possible, and your **log output** demonstrating where the failure occurred. 8 | 9 | #### **Did you write a patch that fixes a bug?** 10 | 11 | * Open a new GitHub pull request with the patch. 12 | 13 | * Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable. 14 | 15 | #### **Did you fix whitespace, format code, or make a purely cosmetic patch?** 16 | 17 | Changes that are cosmetic in nature and do not add anything substantial to the stability, functionality, or testability of the code will generally not be accepted. 18 | 19 | #### **Do you intend to add a new feature or change an existing one?** 20 | 21 | * Suggest your change by opening a [new issue](https://github.com/rails/rails/issues/new) 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # this dockerfile is used to build the image for the emp_stash_fill service, install all the dependencies and run the service to listen to the tampermonkey script on the host browser 2 | 3 | FROM python:3.12-slim 4 | 5 | WORKDIR /emp_stash_fill 6 | 7 | # Install system dependencies 8 | RUN apt-get update && \ 9 | apt-get install -y ffmpeg mktorrent mediainfo build-essential && \ 10 | rm -rf /var/lib/apt/lists/* 11 | 12 | # Copy files and install Python dependencies 13 | COPY . . 14 | RUN pip install --no-cache-dir -r requirements.txt 15 | 16 | # Remove build-essential as it is only needed during pip install 17 | RUN apt-get remove -y build-essential 18 | 19 | # Run the Flask app when the container is executed 20 | ENTRYPOINT ["python", "emp_stash_fill.py", "--configdir", "/config"] 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Docker Image Version](https://img.shields.io/docker/v/bdbenim/stash-empornium?logo=docker) 2 | ](https://hub.docker.com/repository/docker/bdbenim/stash-empornium) [![GitHub release](https://img.shields.io/github/v/release/bdbenim/stash-empornium?logo=github) 3 | ](https://github.com/bdbenim/stash-empornium/releases) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/stash-empornium) ![GitHub](https://img.shields.io/github/license/bdbenim/stash-empornium) 4 | 5 | ![logo](static/images/logo.svg) 6 | 7 | # stash-empornium 8 | 9 | This fork of a script by user humbaba allows torrent files and associated presentations to be created for empornium 10 | based on scenes from a local [stash][1] instance. 11 | 12 | [1]: https://github.com/stashapp/stash 13 | 14 | ## Installation 15 | 16 | The backend can be installed by cloning this repository or by running the Docker image [`bdbenim/stash-empornium`](https://hub.docker.com/r/bdbenim/stash-empornium). 17 | 18 | For detailed instructions on installing the backend server, refer to the [Installation](https://github.com/bdbenim/stash-empornium/wiki/Installation) page on the wiki. 19 | 20 | ### Userscript 21 | 22 | #### Dependencies 23 | 24 | - [Tampermonkey](https://www.tampermonkey.net) 25 | 26 | Currently, the script does not work with other userscript managers, though this may change in the future. 27 | 28 | The userscript can be installed [here][2]. 29 | 30 | [2]: https://github.com/bdbenim/stash-empornium/raw/main/emp_stash_fill.user.js 31 | 32 | ## Configuration 33 | 34 | 1. Visit `upload.php` and open the Tampermonkey menu. Set the backend URL, stash URL, and API key (if you use 35 | authentication). 36 | 37 | 2. Update config file located at `config/config.ini`: 38 | 39 | ```toml 40 | [backend] 41 | ## name of a file in templates/ dir 42 | default_template = "fakestash-v2" 43 | ## List of directories where torrents are placed 44 | ## Multiple directories can be specified in this format: 45 | ## torrent_directories = ["/torrents", "/downloads"] 46 | torrent_directories = ["/torrents"] 47 | ## port that the backend listens on 48 | port = 9932 49 | 50 | [stash] 51 | ## url of your stash instance 52 | url = "http://localhost:9999" 53 | ## only needed if you set up authentication for stash 54 | #api_key = 123abc.xyz 55 | ``` 56 | 57 | The port above corresponds to the backend URL in step 1, so if you change one you must change the other. 58 | 59 | ### Redis 60 | 61 | The backend server can be configured to connect to an optional [redis][3] server. This is not required for any of the 62 | functionality of the script, but it allows image URLs to be cached even when restarting the backend, speeding up the 63 | upload process whenever an image is reused (e.g. performer images, studio logos). If redis is not used, these URLs will 64 | still be cached in memory for as long as the server is running. 65 | 66 | Connection settings can be specified in the `[redis]` configuration section: 67 | 68 | ```toml 69 | [redis] 70 | host = "localhost" 71 | port = 6379 72 | username = "stash-empornium" 73 | password = "stash-empornium" 74 | ssl = false 75 | ``` 76 | 77 | Any unused options can simply be omitted. 78 | 79 | [3]: https://redis.io/ 80 | 81 | ### Torrent Clients 82 | 83 | The backend server can be configured to communicate with any of several different torrent clients, allowing 84 | generated `.torrent` files to be automatically added to the client. Path mappings can also be used to ensure the torrent 85 | points at the correct location of files on disk, allowing them to be started with minimal settings. Additionally, some 86 | clients support applying labels to torrents for more granular control. 87 | 88 | Torrent client integrations are optional and are not required for the backend to work. 89 | 90 | #### rTorrent 91 | 92 | This software has been tested with rTorrent `v0.9.6` with ruTorrent `v3.10`. 93 | 94 | Example configuration: 95 | 96 | ```toml 97 | [rtorrent] 98 | # Hostname or IP address 99 | host = "localhost" 100 | # Port number 101 | port = 8080 102 | # Set to true for https 103 | ssl = false 104 | # API path, typically "XMLRPC" or "RPC2" 105 | path = "RPC2" 106 | # Username for XMLRPC if applicable (may be different from webui) 107 | username = "user" 108 | # Password for XMLRPC if applicable (may be different from webui) 109 | password = "password" 110 | label = "stash-empornium" 111 | 112 | [rtorrent.pathmaps] 113 | "/stash-empornium/path" = "/rtorrent/path" 114 | ``` 115 | 116 | > [!NOTE] 117 | > The path mappings for the torrent client are with respect to the paths on the **backend server**, not stash. If your 118 | > client is reporting errors that files are missing, make sure you check this setting carefully. For example, if your 119 | > files are stored in `/media` on your stash server, and that directory is mapped to `/data` on your backend 120 | > and `/downloads` in your torrent client, then you will need something like this in your config: 121 | > 122 | > ```toml 123 | > ["file.maps"] 124 | > "/media" = "/data" 125 | > ... 126 | > [rtorrent.pathmaps] 127 | > "/data" = "/downloads" 128 | > ``` 129 | 130 | ### Deluge 131 | 132 | This software has been tested with Deluge `v2.1.1`. The same configuration options are supported as with rTorrent, with 133 | two exceptions: 134 | 135 | - Labels are not supported 136 | - No username is required for authentication 137 | 138 | ### qBittorrent 139 | 140 | This software has been tested with qBittorrent `v4.6.0`. The same configuration options are supported as with rTorrent. 141 | 142 | Currently there is one limitation with the qBittorrent API integration which prevents the backend from triggering a 143 | recheck of downloaded files when adding a `.torrent`. This is planned for a future release. 144 | 145 | ## Usage 146 | 147 | 1. Run `emp_stash_fill.py` 148 | 2. Get scene ID (it's in the url, e.g. for `http://localhost:9999/scenes/4123` the scene id is `4123`) 149 | 3. Go to `upload.php` and enter the scene ID in the "Fill from stash" box 150 | 4. Select the file you want if you have multiple files attached to that scene, tick/untick the generate screens box, 151 | pick template if you have defined others 152 | 5. Click "fill from" and wait as the tedious parts of the upload process are done for you. Status messages should appear 153 | and instructions for final steps. Performer tags like `pamela.anderson` will be generated for you, along with 154 | resolution tags and url tags of the studio, e.g. `1080p` and `brazzers.com` 155 | 6. You still need to load the torrent file (the location on your filesystem will be given to you) into the form, set a 156 | category, optionally check for dupes if you didn't do so manually. Also load the torrent file into your client (you 157 | can configure the torrent output directory to be a watch dir for your torrent client) and make sure the media file is 158 | visible to your torrent client 159 | 7. When you're satisfied everything is ready, upload 160 | 161 | ### Within Stash 162 | 163 | As of `v0.17.0`, a new button has been added to the scene page within Stash: 164 | 165 | ![Screenshot of Stash upload button](https://github.com/bdbenim/stash-empornium/assets/97994155/12ee111a-e358-4d99-abf3-95910b5fe289) 166 | 167 | Clicking this button will launch `upload.php` and automatically fill in the form with the current scene. This feature is 168 | still somewhat experimental, including the following issues: 169 | 170 | - The script needs to save your tracker announce URL before this feature can work, which is done simply by navigating 171 | to `upload.php` with the script enabled. 172 | - Clicking this button occasionally fails to fill in the form. If this happens, simply go back and try a second time. 173 | - This seems to happen more frequently when starting multiple uploads in quick succession from different tabs 174 | - Currently this only works with the default settings of generating screenshots and excluding associated galleries. In 175 | the future, these options will be configurable. 176 | - There may be other issues not mentioned above 177 | 178 | ### Including Galleries 179 | 180 | Uploads can optionally include a gallery associated with a scene by checking the box labeled "Include Gallery?" on the 181 | upload page. In order to generate a torrent with multiple files, they must be saved in a directory together, which 182 | requires some additional configuration options: 183 | 184 | ```toml 185 | [backend] 186 | ## Where to save media for torrents with more than one file: 187 | media_directory = "/torrents" 188 | ## How to move files to media_directory. Must be 'copy', 'hardlink', or 'symlink' 189 | move_method = 'copy' 190 | ``` 191 | 192 | The `media_directory` option specifies the parent directory where media files will be saved. Each torrent will get an 193 | associated subdirectory here, based on the title of the scene. 194 | 195 | `move_method` specifies how media files will be added to this new directory. The default is `copy` because it is the 196 | most likely to work across different setups, but the downside is that this will create a duplicate of your media. To 197 | avoid this, the `hardlink` or `symlink` options can be selected, but these have limitations. Symlinks point to the path 198 | of the original file, which means that if your torrent client sees a different path structure than your backend server 199 | then it won't be able to follow symlinks created by the backend. Hardlinks do not have this issue, but they can only be 200 | created on the same file system as the original file. If you're using Docker, locations from the same file system added 201 | via separate mount points will be treated as separate file systems and will not allow hardlinks between them. There are 202 | additional pros and cons that are beyond the scope of this readme. 203 | 204 | ### Command Line Arguments 205 | 206 | The script can be run with optional command line arguments, most of which override a corresponding configuration file 207 | option. These can be used to quickly change a setting without needing to modify the config file, such as for temporarily 208 | listening on a different port or saving torrent files in a different directory. Not all configuration options can 209 | currently be set via the command line. The available options are described in the script's help text below: 210 | 211 | ```text 212 | usage: emp_stash_fill.py [-h] [--configdir CONFIGDIR] [--version] [-q | -v | -l LEVEL] [--flush] [--no-cache | --overwrite] 213 | 214 | backend server for EMP Stash upload helper userscript 215 | 216 | options: 217 | -h, --help show this help message and exit 218 | --configdir CONFIGDIR 219 | specify the directory containing configuration files 220 | --version show program's version number and exit 221 | 222 | Output: 223 | options for setting the log level 224 | 225 | -q, --quiet output less 226 | -v, --verbose, --debug 227 | output more 228 | -l LEVEL, --log LEVEL 229 | log level: [DEBUG | INFO | WARNING | ERROR | CRITICAL] 230 | 231 | redis: 232 | options for connecting to a redis server 233 | 234 | --flush flush redis cache 235 | --no-cache do not retrieve cached values 236 | --overwrite overwrite cached values 237 | ``` 238 | 239 | ## Templates 240 | 241 | This repository includes default templates which can be used to fill in the presentation based on data from stash. 242 | Currently there are two, however more may be added in the future. 243 | 244 | ### Adding Templates 245 | 246 | To add a new template, save it in the `templates` directory alongside your `config.ini` file. Then add it to your 247 | configuration with the following format: 248 | 249 | ```toml 250 | [templates] 251 | filename = "description" 252 | ``` 253 | 254 | Templates are written using Jinja syntax. The available variables are: 255 | 256 | - audio_bitrate 257 | - audio_codec 258 | - bitrate 259 | - contact_sheet 260 | - container 261 | - cover 262 | - date 263 | - details 264 | - duration 265 | - framerate 266 | - gallery_contact 267 | - image_count 268 | - media_info (if `mediainfo` is installed) 269 | - performers 270 | - name 271 | - details 272 | - image_remote_url 273 | - tag 274 | - resolution 275 | - screens 276 | - sex_acts 277 | - studio 278 | - studio_logo 279 | - title 280 | - video_codec 281 | 282 | Refer to the default templates for examples of how they are used. 283 | 284 | ### Custom Lists 285 | 286 | In addition to the template variables described above, additional tag lists may be added to the `empornium` config 287 | section by following the format of the `sex_acts` variable. These will automatically be parsed and made available to any 288 | custom templates as comma-separated lists. For instance, you may wish to add a section called `performer_attributes` to 289 | describe characteristics of performers in the scene. 290 | 291 | ## Titles 292 | 293 | Similarly to templates, the title has a few options for formatting. This uses python's builtin string formatter, so 294 | variable names are enclosed in braces (`{}`) within the string. The default title format is: 295 | 296 | ```python 297 | [{studio}] 298 | {performers} - {title}({date})[{resolution}] 299 | ``` 300 | 301 | This would result in something like this: 302 | 303 | > [Blender Institute] Big Buck Bunny, Frank, Rinky, Gimera - Big Buck Bunny \(2008-05-10)[1080p] 304 | 305 | The available variables that can be used are: 306 | 307 | - codec 308 | - date 309 | - duration 310 | - framerate 311 | - performers 312 | - resolution 313 | - studio 314 | - title 315 | 316 | ### Title Templates 317 | 318 | Beginning with `v0.7.0`, the `title_template` config option has been added, which extends the title formatting 319 | capability using jinja templates. With this system, the equivalent to the earlier example is: 320 | 321 | ```python 322 | { % if studio %}[{{studio}}] 323 | { % endif %}{{performers | join(', ')}} 324 | { % if performers %} - { % endif %}{{title}} 325 | { % if date %}({{date}}) 326 | { % endif %}[{{resolution}}] 327 | ``` 328 | 329 | This system has the added advantage of builtin `if` statements, `for` loops, and many other features. The above example 330 | uses these to ensure that there are no empty square brackets if the scene's studio is not set, nor empty parentheses 331 | around a missing date. Since the resolution is determined by the script, this will always be available. The same 332 | variables are available to this setting as the `title_default` option, with some minor differences: 333 | 334 | - `performers` will be provided as a list rather than a single comma-separated string. This allows more control over how 335 | the list will be formatted, but the above example shows how to keep the same comma-separated list formatting. 336 | - `framerate` does not include "fps" in the string, again for more flexibility in the template 337 | 338 | For more information on using jinja templates, refer to the [documentation](https://jinja.palletsprojects.com/en/3.1.x/) 339 | -------------------------------------------------------------------------------- /default-templates/fakestash-v3: -------------------------------------------------------------------------------- 1 | {# 20231112 -#} 2 | [bg=#202b33][color=#F5F8FA][font=Helvetica][table=nopad,nball,vat][tr][td=#202b33][/td] 3 | 4 | 5 | [td=400px,#202b33][bg=90%][size=2] 6 | 7 | [center][img=100]{{ studio_logo }}[/img][/center] 8 | {% if date %} 9 | [size=4][font=Arial Black]{{title}}[/font][/size] 10 | [imgnm]https://jerking.empornium.ph/images/2023/03/10/pad.png[/imgnm]{{date}} 11 | {% endif %} 12 | {% if details %} 13 | [b]Details[/b] 14 | [imgnm]https://jerking.empornium.ph/images/2023/03/10/pad.png[/imgnm]{{details}} 15 | {% endif %} 16 | 17 | [b]Includes[/b] 18 | [imgnm]https://jerking.empornium.ph/images/2023/03/10/pad.png[/imgnm]{{sex_acts}} 19 | 20 | 21 | [b]Performers[/b][br] 22 | [table=nball,left][tr] 23 | 24 | 25 | {% for name, details in performers.items() -%} 26 | [td=#30404d,124px][img=123]{% if details["image_remote_url"] %}{{details["image_remote_url"]}}{% else %}https://fapping.empornium.sx/images/2023/04/06/imageR8b7P.png{% endif %}[/img][url=/torrents.php?taglist={{details["tag"]}}][size=3][/size][color=white][bg=90%]{{name}} 27 | [br][/bg][/color][/url][/td] 28 | {% if not loop.last %} 29 | [td=8px][/td] 30 | 31 | {% endif %}{% endfor %} 32 | [td][/td][/tr][/table] 33 | 34 | 35 | 36 | [/size][/bg][/td] 37 | 38 | 39 | [td=vat,800px][bg=98%] 40 | [imgnm]{{cover}}[/imgnm][bg=#30404d][color=#F0EEEB][size=2] 41 | [table=100%,nball,vam][tr] 42 | [td=16px][/td] 43 | [td]{{duration}}[/td] 44 | [td][align=right]{{container}} {{video_codec}}/{{audio_codec}} {{resolution}} {{bitrate}} {{framerate}}[/align][/td] 45 | [td=16px][/td] 46 | [/tr][/table] 47 | [/size][/color][/bg] 48 | 49 | [size=2] 50 | {% if screens %}[b]Screens[/b] 51 | 52 | {% for screen in screens %}[img=200]{{screen}}[/img]{% endfor %} 53 | 54 | {% endif %}[b]Contact Sheet[/b] 55 | 56 | [spoiler=Click to view] 57 | [img]{{contact_sheet}}[/img] 58 | [/spoiler] 59 | 60 | [/size] 61 | [img]https://fapping.empornium.sx/images/2022/02/24/space.png[/img] 62 | [/bg][/td][td=#202b33][/td] 63 | [/tr][/table][/font][/color][/bg] 64 | -------------------------------------------------------------------------------- /default-templates/r18: -------------------------------------------------------------------------------- 1 | {# 20231112 -#} 2 | [font=Helvetica][bg=#211e1e][table] 3 | 4 | Replace links and everything in {% raw %}{{curly brackets}}{% endraw %} 5 | 6 | [/table][bg=#f0f0f0][table=nball,nopad][tr][td=24px][/td] 7 | [td] 8 | [size=2][b][url=#screens][color=#262626]SCREENS[/color][/url] [url=#contact][color=#262626]CONTACT SHEET[/color][/url][/b][/size][/td][td=24px] 9 | [/td] 10 | [/tr] 11 | 12 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 13 | Cover, title, performers, date, duration - Starting at the `center` tag 14 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 15 | 16 | [/table] 17 | [/bg][bg=800px][size=2][color=White] 18 | [center][imgnm]{{cover}}[/imgnm][/center][imgnm]https://jerking.empornium.ph/images/2023/03/10/pad.png[/imgnm]{{details}} 19 | [size=3][/size]{% for name, details in performers.items() -%}[url=/torrents.php?taglist={{details["tag"]}}][color=#ff2962]{{name}}[/color][/url] {% endfor %}{% if date %} [color=#b2b2b2]{{date}} {% endif %}{{duration}}[/color] 20 | 21 | [/color][/size][/bg][/bg] 22 | [bg=95%] 23 | [color=#262626] 24 | [table=nball,nopad,vat] 25 | [tr] 26 | [td=4px,#ff2962,vab][/td][td=8px][/td] 27 | [td][size=1]MORE DETAILS[/size][br][size=6][/size][size=3]About this movie[/size][/td] 28 | [/tr] 29 | [/table] 30 | [bg=#f4f4f4][size=2][color=#262626][table=nball,nopad][tr] 31 | 32 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 33 | Scene info - first column - description, categories, studio 34 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 35 | 36 | [td=24px][/td][td=vat] 37 | [color=#626262]Uploader Description[/color] 38 | [imgnm]https://jerking.empornium.ph/images/2023/03/10/pad.png[/imgnm]REPLACEME 39 | 40 | [color=#626262]Categories[/color] 41 | [imgnm]https://jerking.empornium.ph/images/2023/03/10/pad.png[/imgnm][color=#ff2962]{{sex_acts}}[/color] 42 | 43 | [color=#626262]Studio[/color] 44 | [imgnm]https://jerking.empornium.ph/images/2023/03/10/pad.png[/imgnm][img=100]{{ studio_logo }}[/img] 45 | [/td] 46 | 47 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 48 | Scene info - second column - resolution, bitrate, framerate 49 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 50 | 51 | [td=24px][/td] 52 | [td=vat,10%] 53 | [color=#626262]Resolution[/color] 54 | [imgnm]https://jerking.empornium.ph/images/2023/03/10/pad.png[/imgnm]{{resolution}} 55 | 56 | [color=#626262]Bitrate[/color] 57 | [imgnm]https://jerking.empornium.ph/images/2023/03/10/pad.png[/imgnm]{{bitrate}} 58 | 59 | [color=#626262]Framerate[/color] 60 | [imgnm]https://jerking.empornium.ph/images/2023/03/10/pad.png[/imgnm]{{framerate}} 61 | [/td] 62 | 63 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 64 | Scene info - third column - codec, dvd id 65 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 66 | 67 | [td=24px][/td] 68 | [td=vat,10%] 69 | [color=#626262]Codec[/color] 70 | [imgnm]https://jerking.empornium.ph/images/2023/03/10/pad.png[/imgnm]{{codec}} 71 | 72 | [color=#626262]DVD ID[/color] 73 | [imgnm]https://jerking.empornium.ph/images/2023/03/10/pad.png[/imgnm]{{title}} 74 | [/td] 75 | 76 | 77 | [td=24px][/td][/tr][/table] 78 | [/color][/size][/bg] 79 | 80 | [hr] 81 | [anchor=performers][/anchor][table=nball,nopad,vat][tr] 82 | [td=4px,#ff2962,vab][/td][td=8px][/td] 83 | [td]ACTRESSES[br][size=6][/size][size=3]Appearing in this movie[/size][/td] 84 | [/tr][/table][size=2][table=nball,nopad][tr=vat] 85 | 86 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 87 | Performers - Delete starting at above hr to remove section 88 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 89 | {% for name, details in performers.items() -%} 90 | [td=140px,vac][center] 91 | [img=130]{% if details["image_remote_url"] %}{{details["image_remote_url"]}}{% else %}https://fapping.empornium.sx/images/2023/04/06/imageR8b7P.png{% endif %}[/img] 92 | [size=3][/size][url=/torrents.php?taglist={{details["tag"]}}][color=#ff2962]{{name}}[/color][/url] 93 | [/center][/td] 94 | {% if loop.last %}[td][/td]{% endif %}{% endfor %} 95 | [/tr][/table][/size] 96 | 97 | {% if screens %}[anchor=screens][/anchor] 98 | [table=nball,nopad,vat] 99 | [tr] 100 | [td=4px,#ff2962,vab][/td][td=8px][/td] 101 | [td]PREVIEW THIS MOVIE[br][size=6][/size][size=3]Screenshot gallery[/size][/td] 102 | [/tr] 103 | 104 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 105 | Screens - links after the `/table` tag, `imgnm` align with side 106 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 107 | 108 | [/table] 109 | {% for screen in screens %}[img=196]{{screen}}[/img]{% endfor %} 110 | {% if marker_gifs %}{% for gif in marker_gifs -%}[imgnm]{{gif}}[/imgnm] {% endfor %}{% endif %} 111 | 112 | 113 | {% endif %}[anchor=contact][/anchor] 114 | [table=nball,nopad,vat] 115 | [tr] 116 | [td=4px,#ff2962,vab][/td][td=8px][/td] 117 | [td]PREVIEW THIS MOVIE[br][size=6][/size][size=3]Contact sheet[/size][/td] 118 | [/tr] 119 | [/table] 120 | [table=nball,nopad] 121 | [tr] 122 | 123 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 124 | Contact sheet - link after the bgblack tag 125 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 126 | 127 | [/table][center][bg=#f4f4f4] 128 | 129 | [imgnm]{{contact_sheet}}[/imgnm] 130 | 131 | [/bg][/center] 132 | 133 | 134 | 135 | [/color][/bg][/font][bg=#292b33] 136 | [center][size=2][url=#top][color=white][b]top⮭[/b][/color][/url][/size][/center] 137 | [/bg] 138 | -------------------------------------------------------------------------------- /default-templates/tushy: -------------------------------------------------------------------------------- 1 | {# 20231107 -#} 2 | [bg=#000000] 3 | [table=100%,nball] 4 | [tr][td] [table=95%,nball] 5 | [tr][td=15%] [color=#FFFFFF][size=10][font=Helvetica][b]{{studio|upper}}[/b][/font][/size][/color] [/td] 6 | [td][url=/torrents.php?taglist={{studiotag}}][color=#FFFFFF][size=2][font=Helvetica][b]VIDEOS[/b][/font][/size][/color][/url][/td] 7 | [/tr] 8 | [/table] 9 | [table=95%,nball] 10 | [tr][td=nopad][align=center] [img]{{cover}}[/img] [/align][/td][/tr] 11 | [/table] 12 | [table=95%,nball] 13 | [tr][td=nopad,10%] [img]https://jerking.empornium.ph/images/2022/08/30/bitmap.png[/img] [/td][td=vat] [color=#FFFFFF][size=5][b][font=Impact] {{title|upper}}[/font][/b][/size][/color] 14 | [font=Impact][size=1] 15 | {%- for name, details in performers.items() -%} 16 | [color=#0099FF][url=/torrents.php?taglist={{details["tag"]}}] {{name.upper()}}[/url][/color] 17 | {%- if loop.length > 1 %}[color=#FFFFFF] 18 | {%- if loop.revindex0 == 0 %} 19 | {%- elif loop.revindex0 == 1 %} & 20 | {%- else %}, 21 | {%- endif %}[/color] 22 | {%- endif %}[/size][/font] 23 | {%- endfor %} 24 | 25 | [font=Impact][size=1][color=#FFFFFF] [b]+INFO[/b][/color][/size][/font][/td] 26 | [/tr][/table][table=98%,nball] 27 | [tr][td][hr][/td][/tr] 28 | {% if director %}[tr][td][size=1][font=Helvetica][color=#FFFFFF]DIRECTED BY: [b]{{director}}[/b][/color][/font][/size][/td][/tr]{% endif %} 29 | [tr][td][size=1][font=Helvetica][color=#FFFFFF]{{details}}[/color][/font][/size][/td][/tr] 30 | [tr][td][hr][/td][/tr] 31 | {% if sex_acts %}[tr][td][size=1][font=Helvetica][color=#FFFFFF][b]INCLUDES:[/b] {{sex_acts}}[/color][/font][/size][/td][/tr]{% endif %} 32 | [tr][td][hr][/td][/tr] 33 | [/table][table=97%,nball] 34 | [tr][td=nopad,1%][img]https://jerking.empornium.ph/images/2022/08/30/clock_grey.png[/img][/td] 35 | [td=5%][size=1][color=#999999][b][font=Helvetica]{{duration}}[/font][/b][/color][/size][/td] 36 | [td=nopad,1%][img]https://jerking.empornium.ph/images/2022/08/30/calendar_grey.png[/img][/td] 37 | [td=11%][size=1][color=#999999][b][font=Helvetica]{{date.upper()}}[/font][/b][/color][/size] [/td] 38 | [td=nopad,1%][img]https://jerking.empornium.ph/images/2022/08/30/pictures_grey.png[/img][/td] 39 | [td=5%][size=1][color=#999999][b][font=Helvetica]{{image_count}}[/font][/b][/color][/size][/td] 40 | [td=55%][/td] 41 | [/tr] 42 | [/table] 43 | [bg=#232323] 44 | [align=center][color=#FFFFFF][size=3][b]Media Info[/b][/size][/color] 45 | 46 | [table=98%,nball] 47 | [tr] 48 | [td][img]https://jerking.empornium.ph/images/2022/08/31/down_final.png[/img][/td] 49 | 50 | [td][color=#FFFFFF][size=2][b]DIMENSIONS[/b][/size][/color] 51 | 52 | [size=1][color=#999999]{{resolution}}[/color][/size][/td] 53 | [td][color=#FFFFFF][size=2][b]V.CODEC[/b][/size][/color] 54 | 55 | [size=1][color=#999999]{{video_codec}}[/color][/size][/td] 56 | [td][color=#FFFFFF][size=2][b]FRAMERATE[/b][/size][/color] 57 | 58 | [size=1][color=#999999]{{framerate}}[/color][/size][/td] 59 | [td][color=#FFFFFF][size=2][b]V.BITRATE[/b][/size][/color] 60 | 61 | [size=1][color=#999999]{{bitrate}}[/color][/size][/td] 62 | [td][color=#FFFFFF][size=2][b]A.CODEC[/b][/size][/color] 63 | 64 | [size=1][color=#999999]{{audio_codec}}[/color][/size][/td] 65 | [td][color=#FFFFFF][size=2][b]A.BITRATE[/b][/size][/color] 66 | 67 | [size=1][color=#999999]{{audio_bitrate}}[/color][/size][/td] 68 | 69 | [/tr] 70 | [/table][/align] 71 | [/bg] 72 | {% if images %}[table=nball] 73 | [tr][td] [hr][/td][td=10%][align=center][size=3][color=#FFFFFF][b]GALLERY[/b][/color][/size][/align][/td][td][hr][/td][/tr] 74 | [/table] 75 | [align=center] 76 | [table=95%,nball] 77 | {%- for row in images|batch(3) %} 78 | [tr] 79 | {%- for col in row %}[td] [img]{{col}}[/img] [/td]{% endfor %}[/tr] 80 | {%- endfor %} 81 | [/table] 82 | [/align] 83 | {%- endif %} 84 | 85 | {% if gifs %}[table=nball] 86 | [tr][td] [hr][/td][td=10%][align=center][size=3][color=#FFFFFF][b]GIFS[/b][/color][/size][/align][/td][td][hr][/td][/tr] 87 | [/table] 88 | [align=center] 89 | [table=nball,95%] 90 | {%- for row in gifs|batch(3) %} 91 | [tr] 92 | {%- for col in row %}[td] [img]{{col}}[/img] [/td]{% endfor %}[/tr] 93 | {%- endfor %} 94 | [/table] 95 | [/align] 96 | {%- endif %} 97 | 98 | 99 | [table=nball] 100 | [tr][td] [hr][/td][td=15%][align=center][size=3][color=#FFFFFF][b]SCREENSHOTS[/b][/color][/size][/align][/td][td][hr][/td][/tr] 101 | [/table] 102 | {% if screens %}[align=center] 103 | [table=95%,nball] 104 | {%- for row in screens|batch(3) %} 105 | [tr] 106 | {%- for col in row %}[td] [img]{{col}}[/img] [/td]{% endfor %}[/tr] 107 | {%- endfor %} 108 | [/table] 109 | [/align] 110 | {%- endif %} 111 | [table=nball,95%] 112 | [tr][td=nopad][color=#FFFFFF][spoiler=Contact sheet][align=center][img]{{contact_sheet}}[/img][/align][/spoiler][/color][/td][/tr] 113 | [/table] 114 | {% if gallery_contact %} 115 | [table=nball,95%] 116 | [tr][td=nopad][color=#FFFFFF][spoiler=Image Contact sheet][align=center][img]{{gallery_contact}}[/img][/align][/spoiler][/color][/td][/tr] 117 | [/table] 118 | {% endif %} 119 | [/td][/tr] 120 | [/table] 121 | 122 | 123 | [/bg] 124 | -------------------------------------------------------------------------------- /default.toml: -------------------------------------------------------------------------------- 1 | [backend] 2 | ## name of a file in templates/ dir 3 | default_template = "fakestash-v3" 4 | ## List of directories where torrents are placed 5 | ## Multiple directories can be specified in this format: 6 | ## torrent_directories = ["/torrents", "/downloads"] 7 | torrent_directories = ["/torrents"] 8 | ## port that the backend listens on 9 | port = 9932 10 | ## jinja template for title 11 | ## see README.md for available variables 12 | ## Uncomment this title for Whisparr compatibility 13 | #title_template = {{ studio|replace(' ', '')|replace('[^a-zA-Z0-9]', '') }} - {{ date.split('-')[0][-2:] }}.{{ date.split('-')[1] }}.{{ date.split('-')[2] }} - {{ title }} - {{performers|join(', ')}} - {{ codec }} - WEBDL - {{ resolution }} 14 | ## Uncomment this title for Classic EMP template 15 | title_template = '{% if studio %}[{{studio}}] {% endif %}{{performers|join(", ")}}{% if performers %} - {% endif %}{{title}} {% if date %}({{date}}){% endif %}[{{resolution}}]' 16 | # Date format for title release - https://strftime.org/ for reference 17 | date_format = "%Y-%m-%d" 18 | ## Set to 'true' to enable anonymous uploading 19 | anon = false 20 | ## Where to save media for torrents with more than one file: 21 | # media_directory = "/torrents" 22 | ## How to move files to media_directory. Must be 'copy', 'hardlink', or 'symlink' 23 | move_method = 'copy' 24 | ## Upload a GIF preview of the scene 25 | use_preview = false 26 | ## Use the preview GIF as the upload cover. Ignored if 'use_preview' is false 27 | animated_cover = true 28 | 29 | [hamster] 30 | api_key = "" 31 | 32 | # [rtorrent] 33 | ## Hostname or IP address 34 | # host = "localhost" 35 | ## Port number 36 | # port = 8080 37 | ## Set to true for https 38 | # ssl = false 39 | ## API path, typically "XMLRPC" or "RPC2" 40 | # path = "RPC2" 41 | ## Username for XMLRPC if applicable (may be different from webui) 42 | # username = "user" 43 | ## Password for XMLRPC if applicable (may be different from webui) 44 | # password = "password" 45 | # label = "stash-empornium" 46 | 47 | # [rtorrent.pathmaps] 48 | # "/stash-empornium/path" = "/rtorrent/path" 49 | 50 | # [deluge] 51 | ## Hostname or IP address 52 | # host = "127.0.0.1" 53 | ## Port number 54 | # port = 8112 55 | ## Set to true for https 56 | # ssl = false 57 | ## Password for API 58 | # password = "deluge" 59 | 60 | # [deluge.pathmaps] 61 | # "/stash-empornium/path" = "/deluge/path" 62 | 63 | # [qbittorrent] 64 | ## Hostname or IP address 65 | # host = "127.0.0.1" 66 | ## Port number 67 | # port = 8080 68 | ## Set to true for https 69 | # ssl = false 70 | ## Username for API 71 | # username = "admin" 72 | ## Password for API 73 | # password = "adminadmin" 74 | ## Equivalent to QBittorrent "category" 75 | # label = "stash-empornium" 76 | 77 | # [qbittorrent.pathmaps] 78 | # "/stash-empornium/path" = "/qbittorrent/path" 79 | 80 | #[redis] 81 | #host = "localhost" 82 | #port = 6379 83 | #username = "stash-empornium" 84 | #password = "stash-empornium" 85 | #ssl = false 86 | 87 | #[file.maps] 88 | ## For Docker, this should be configured using mount points 89 | 90 | [metadata] 91 | ## various optional metadata attributes to include as tags 92 | tag_codec = false 93 | tag_date = true 94 | tag_framerate = true 95 | tag_resolution = true 96 | 97 | [performers] 98 | tag_ethnicity = false 99 | tag_hair_color = false 100 | tag_eye_color = false 101 | 102 | # [performers.cup_sizes] 103 | ## Map EMP tags to cup sizes. Add a '-' to match sizes less than or equal to the specified 104 | ## size, or a '+' to match sizes greater or equal. Add neither for exact matches only. 105 | # "tiny.tits" = "A-" 106 | # "small.tits" = "B-" 107 | # "big.tits" = "D+" 108 | # "huge.tits" = "DD+" 109 | # "a.cup" = "A" 110 | 111 | [templates] 112 | fakestash-v3 = "Stash / Fake video player v3 (by warblcoffin)" 113 | r18 = "R18 Ripoff (by warblcoffin)" 114 | tushy = "Tushy video page lookalike (by dickon)" 115 | 116 | [stash] 117 | ## url of your stash instance 118 | url = "http://localhost:9999" 119 | ## only needed if you set up authentication for stash 120 | #api_key = "123abc.xyz" 121 | -------------------------------------------------------------------------------- /emp_stash_fill.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3.12 2 | 3 | __author__ = "An EMP user" 4 | __license__ = "unlicense" 5 | __version__ = "0.19.0" 6 | 7 | # built-in 8 | import json 9 | import logging 10 | import os 11 | import time 12 | from concurrent.futures import Future 13 | 14 | # external 15 | from flask import (Flask, Response, redirect, request, stream_with_context, 16 | url_for) 17 | from flask_bootstrap import Bootstrap5 18 | from flask_migrate import Migrate 19 | from flask_wtf import CSRFProtect 20 | 21 | # included 22 | from utils import db, generator, taghandler 23 | from utils.confighandler import ConfigHandler 24 | from webui.webui import settings_page 25 | 26 | ############# 27 | # CONSTANTS # 28 | ############# 29 | 30 | ODBL_NOTICE = ("Contains information from https://github.com/mledoze/countries which is made available here under the " 31 | "Open Database License (ODbL), available at https://github.com/mledoze/countries/blob/master/LICENSE") 32 | 33 | config = ConfigHandler() 34 | logger = logging.getLogger(__name__) 35 | logger.info(f"stash-empornium version {__version__}.") 36 | logger.info(f"Release notes: https://github.com/bdbenim/stash-empornium/releases/tag/v{__version__}") 37 | logger.info(ODBL_NOTICE) 38 | 39 | app = Flask(__name__, template_folder=config.template_dir) 40 | app.secret_key = "secret" 41 | app.config["BOOTSTRAP_BOOTSWATCH_THEME"] = "cyborg" 42 | app.register_blueprint(settings_page) 43 | db_path = os.path.abspath(os.path.join(config.config_dir, "db.sqlite3")) 44 | app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_path}" 45 | db.db.init_app(app) 46 | # Ensure FOREIGN KEY for sqlite3 47 | if 'sqlite:' in app.config['SQLALCHEMY_DATABASE_URI']: 48 | def _fk_pragma_on_connect(dbapi_con, con_record): # noqa 49 | dbapi_con.execute('pragma foreign_keys=ON') 50 | 51 | 52 | with app.app_context(): 53 | from sqlalchemy import event 54 | 55 | event.listen(db.db.engine, 'connect', _fk_pragma_on_connect) 56 | migrate = Migrate(app, db.db) 57 | with app.app_context(): 58 | db.upgrade() 59 | taghandler.setup(app) 60 | bootstrap = Bootstrap5(app) 61 | csrf = CSRFProtect(app) 62 | 63 | 64 | @stream_with_context 65 | def generate(): 66 | j = request.get_json() 67 | try: 68 | future: Future = generator.jobs[j["id"]] 69 | except KeyError: 70 | yield generator.error("Error getting job. Is your userscript updated?") 71 | return 72 | except ValueError: 73 | yield generator.error("Invalid job ID") 74 | return 75 | except IndexError: 76 | yield generator.error("Job does not exist") 77 | return 78 | for msg in future.result(): 79 | yield msg 80 | time.sleep(0.1) 81 | 82 | 83 | @app.route("/submit", methods=["POST"]) 84 | @csrf.exempt 85 | def submit(): 86 | j = request.get_json() 87 | logger.debug(f"Torrent submitted: {j}") 88 | for client in config.torrent_clients: 89 | try: 90 | client.start(j["torrent_path"]) 91 | except Exception as e: 92 | logger.error(f"Error attempting to start torrent in {client.name}") 93 | logger.debug(e) 94 | return json.dumps({"status": "success"}) 95 | 96 | 97 | @app.route("/suggestions", methods=["POST"]) 98 | @csrf.exempt 99 | def process_suggestions(): 100 | j = request.get_json() 101 | logger.debug(f"Got json {j}") 102 | accepted_tags = {} 103 | if "accept" in j: 104 | logger.info(f"Accepting {len(j['accept'])} tag suggestions") 105 | for tag in j["accept"]: 106 | if "name" in tag: 107 | accepted_tags[tag["name"]] = tag["emp"] 108 | ignored_tags = [] 109 | if "ignore" in j: 110 | logger.info(f"Ignoring {len(j['ignore'])} tags") 111 | for tag in j["ignore"]: 112 | ignored_tags.append(tag) 113 | taghandler.accept_suggestions(accepted_tags, j["tracker"]) 114 | taghandler.reject_suggestions(ignored_tags) 115 | return json.dumps({"status": "success", "data": {"message": "Tags saved"}}) 116 | 117 | 118 | @app.route("/fill", methods=["POST"]) 119 | @csrf.exempt 120 | def fill(): 121 | return Response(generate(), mimetype="application/json") # type: ignore 122 | 123 | 124 | @app.route("/generate", methods=["POST"]) 125 | @csrf.exempt 126 | def submit_job(): 127 | j = request.get_json() 128 | job_id = generator.add_job(j) 129 | return json.dumps({"id": job_id}) 130 | 131 | 132 | @app.route("/templates") 133 | @csrf.exempt 134 | def templates(): 135 | return json.dumps(config.template_names) 136 | 137 | 138 | @app.route("/favicon.ico") 139 | def favicon(): 140 | return redirect(url_for("static", filename="images/favicon.ico")) 141 | 142 | 143 | @app.route("/favicon-16x16.png") 144 | def favicon16(): 145 | return redirect(url_for("static", filename="images/favicon-16x16.png")) 146 | 147 | 148 | @app.route("/favicon-32x32.png") 149 | def favicon32(): 150 | return redirect(url_for("static", filename="images/favicon-32x32.png")) 151 | 152 | 153 | @app.route("/apple-touch-icon.png") 154 | def apple_touch_icon(): 155 | return redirect(url_for("static", filename="images/apple-touch-icon.png")) 156 | 157 | 158 | @app.route("/android-chrome-192x192.png") 159 | def android_chrome_192(): 160 | return redirect(url_for("static", filename="images/android-chrome-192x192.png")) 161 | 162 | 163 | @app.route("/android-chrome-512x512.png") 164 | def android_chrome_512(): 165 | return redirect(url_for("static", filename="images/android-chrome-512x512.png")) 166 | 167 | 168 | @app.route("/mstile-70x70.png") 169 | def mstile70(): 170 | return redirect(url_for("static", filename="images/mstile-70x70.png")) 171 | 172 | 173 | @app.route("/mstile-144x144.png") 174 | def mstile144(): 175 | return redirect(url_for("static", filename="images/mstile-144x144.png")) 176 | 177 | 178 | @app.route("/mstile-150x150.png") 179 | def mstile150(): 180 | return redirect(url_for("static", filename="images/mstile-150x150.png")) 181 | 182 | 183 | @app.route("/mstile-310x150.png") 184 | def mstile310_150(): 185 | return redirect(url_for("static", filename="images/mstile-310x150.png")) 186 | 187 | 188 | @app.route("/mstile-310x310.png") 189 | def mstile310(): 190 | return redirect(url_for("static", filename="images/mstile-310x310.png")) 191 | 192 | 193 | @app.route("/safari-pinned-tab.svg") 194 | def safari(): 195 | return redirect(url_for("static", filename="images/safari-pinned-tab.svg")) 196 | 197 | 198 | @app.route("/browserconfig.xml") 199 | def browserconfig(): 200 | return redirect(url_for("static", filename="browserconfig.xml")) 201 | 202 | 203 | @app.route("/site.webmanifest") 204 | def webmanifest(): 205 | return redirect(url_for("static", filename="site.webmanifest")) 206 | 207 | 208 | if __name__ == "__main__": 209 | try: 210 | from waitress import serve 211 | 212 | serve(app, host="0.0.0.0", port=config.get("backend", "port", 9932)) 213 | # app.run(host="0.0.0.0", port=config.port, debug=True) 214 | except ModuleNotFoundError: 215 | logger.info("Waitress not installed, using builtin server") 216 | app.run(host="0.0.0.0", port=config.get("backend", "port", 9932), debug=False) # type: ignore 217 | -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # set to 'true' to run the environment during 11 | # the 'revision' command, regardless of autogenerate 12 | # revision_environment = false 13 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from alembic import context 4 | from flask import current_app 5 | 6 | # this is the Alembic Config object, which provides 7 | # access to the values within the .ini file in use. 8 | config = context.config 9 | 10 | logger = logging.getLogger('alembic.env') 11 | 12 | 13 | def get_engine(): 14 | try: 15 | # this works with Flask-SQLAlchemy<3 and Alchemical 16 | return current_app.extensions['migrate'].db.get_engine() 17 | except (TypeError, AttributeError): 18 | # this works with Flask-SQLAlchemy>=3 19 | return current_app.extensions['migrate'].db.engine 20 | 21 | 22 | def get_engine_url(): 23 | try: 24 | return get_engine().url.render_as_string(hide_password=False).replace( 25 | '%', '%%') 26 | except AttributeError: 27 | return str(get_engine().url).replace('%', '%%') 28 | 29 | 30 | # add your model's MetaData object here 31 | # for 'autogenerate' support 32 | # from myapp import mymodel 33 | # target_metadata = mymodel.Base.metadata 34 | config.set_main_option('sqlalchemy.url', get_engine_url()) 35 | target_db = current_app.extensions['migrate'].db 36 | 37 | 38 | # other values from the config, defined by the needs of env.py, 39 | # can be acquired: 40 | # my_important_option = config.get_main_option("my_important_option") 41 | # ... etc. 42 | 43 | 44 | def get_metadata(): 45 | if hasattr(target_db, 'metadatas'): 46 | return target_db.metadatas[None] 47 | return target_db.metadata 48 | 49 | 50 | def run_migrations_offline(): 51 | """Run migrations in 'offline' mode. 52 | 53 | This configures the context with just a URL 54 | and not an Engine, though an Engine is acceptable 55 | here as well. By skipping the Engine creation 56 | we don't even need a DBAPI to be available. 57 | 58 | Calls to context.execute() here emit the given string to the 59 | script output. 60 | 61 | """ 62 | url = config.get_main_option("sqlalchemy.url") 63 | context.configure( 64 | url=url, target_metadata=get_metadata(), literal_binds=True 65 | ) 66 | 67 | with context.begin_transaction(): 68 | context.run_migrations() 69 | 70 | 71 | def run_migrations_online(): 72 | """Run migrations in 'online' mode. 73 | 74 | In this scenario we need to create an Engine 75 | and associate a connection with the context. 76 | 77 | """ 78 | 79 | # this callback is used to prevent an auto-migration from being generated 80 | # when there are no changes to the schema 81 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 82 | def process_revision_directives(context, revision, directives): 83 | if getattr(config.cmd_opts, 'autogenerate', False): 84 | script = directives[0] 85 | if script.upgrade_ops.is_empty(): 86 | directives[:] = [] 87 | logger.info('No changes in schema detected.') 88 | 89 | conf_args = current_app.extensions['migrate'].configure_args 90 | if conf_args.get("process_revision_directives") is None: 91 | conf_args["process_revision_directives"] = process_revision_directives 92 | 93 | connectable = get_engine() 94 | 95 | with connectable.connect() as connection: 96 | context.configure( 97 | connection=connection, 98 | target_metadata=get_metadata(), 99 | **conf_args 100 | ) 101 | 102 | with context.begin_transaction(): 103 | context.run_migrations() 104 | 105 | 106 | if context.is_offline_mode(): 107 | run_migrations_offline() 108 | else: 109 | run_migrations_online() 110 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /migrations/versions/017767fd9fdb_.py: -------------------------------------------------------------------------------- 1 | """Add PB, FC, ENT tag maps 2 | 3 | Revision ID: 017767fd9fdb 4 | Revises: e8e3ef7a0fd7 5 | Create Date: 2023-12-05 18:24:39.749539 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '017767fd9fdb' 14 | down_revision = 'e8e3ef7a0fd7' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('ent_tags', 22 | sa.Column('stashtag_id', sa.Integer(), nullable=False), 23 | sa.Column('enttag_id', sa.Integer(), nullable=False), 24 | sa.ForeignKeyConstraint(['enttag_id'], ['gazelle_tags.id'], name=op.f('fk_ent_tags_enttag_id_gazelle_tags'), ondelete='CASCADE'), 25 | sa.ForeignKeyConstraint(['stashtag_id'], ['stash_tag.id'], name=op.f('fk_ent_tags_stashtag_id_stash_tag'), ondelete='CASCADE'), 26 | sa.PrimaryKeyConstraint('stashtag_id', 'enttag_id', name=op.f('pk_ent_tags')) 27 | ) 28 | op.create_table('fc_tags', 29 | sa.Column('stashtag_id', sa.Integer(), nullable=False), 30 | sa.Column('fctag_id', sa.Integer(), nullable=False), 31 | sa.ForeignKeyConstraint(['fctag_id'], ['gazelle_tags.id'], name=op.f('fk_fc_tags_fctag_id_gazelle_tags'), ondelete='CASCADE'), 32 | sa.ForeignKeyConstraint(['stashtag_id'], ['stash_tag.id'], name=op.f('fk_fc_tags_stashtag_id_stash_tag'), ondelete='CASCADE'), 33 | sa.PrimaryKeyConstraint('stashtag_id', 'fctag_id', name=op.f('pk_fc_tags')) 34 | ) 35 | op.create_table('pb_tags', 36 | sa.Column('stashtag_id', sa.Integer(), nullable=False), 37 | sa.Column('pbtag_id', sa.Integer(), nullable=False), 38 | sa.ForeignKeyConstraint(['pbtag_id'], ['gazelle_tags.id'], name=op.f('fk_pb_tags_pbtag_id_gazelle_tags'), ondelete='CASCADE'), 39 | sa.ForeignKeyConstraint(['stashtag_id'], ['stash_tag.id'], name=op.f('fk_pb_tags_stashtag_id_stash_tag'), ondelete='CASCADE'), 40 | sa.PrimaryKeyConstraint('stashtag_id', 'pbtag_id', name=op.f('pk_pb_tags')) 41 | ) 42 | # ### end Alembic commands ### 43 | 44 | 45 | def downgrade(): 46 | # ### commands auto generated by Alembic - please adjust! ### 47 | op.drop_table('pb_tags') 48 | op.drop_table('fc_tags') 49 | op.drop_table('ent_tags') 50 | # ### end Alembic commands ### 51 | -------------------------------------------------------------------------------- /migrations/versions/0b7028b71064_.py: -------------------------------------------------------------------------------- 1 | """Add HF tags 2 | 3 | Revision ID: 0b7028b71064 4 | Revises: 7990cc760362 5 | Create Date: 2023-12-02 21:13:11.813492 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | from alembic.operations.ops import CreatePrimaryKeyOp 11 | from sqlalchemy import text 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = '0b7028b71064' 15 | down_revision = '7990cc760362' 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | conn = op.get_bind() 22 | ctx = op.get_context() 23 | existing_metadata = sa.schema.MetaData() 24 | target_metadata = ctx.opts['target_metadata'] 25 | 26 | op.execute(text("pragma foreign_keys=OFF")) 27 | 28 | # Rename tables. 29 | op.rename_table("emp_tag", "gazelle_tags") 30 | op.rename_table("tag_map", "emp_tags") 31 | 32 | # Drop PK and FKs reflected from existing table. 33 | existing_table = sa.Table("gazelle_tags", existing_metadata, autoload_with=conn) 34 | with op.batch_alter_table("gazelle_tags") as batch_op: 35 | batch_op.drop_constraint(existing_table.primary_key.name) 36 | 37 | # Recreate PK and FKs according to naming convention and current class name. 38 | target_table = sa.Table("gazelle_tags", target_metadata) 39 | batch_op.invoke(CreatePrimaryKeyOp.from_constraint(target_table.primary_key)) 40 | batch_op.drop_constraint("uq_emp_tag_tagname") 41 | batch_op.create_unique_constraint(op.f("uq_gazelle_tags_tagname"), ["tagname"]) 42 | 43 | # Drop PK and FKs reflected from existing table. 44 | existing_table = sa.Table("emp_tags", existing_metadata, autoload_with=conn) 45 | with op.batch_alter_table("emp_tags") as batch_op: 46 | for c in existing_table.foreign_key_constraints: 47 | batch_op.drop_constraint(c.name) 48 | 49 | # Recreate PK and FKs according to naming convention and current class name. 50 | batch_op.create_foreign_key(op.f('fk_emp_tags_emptag_id_gazelle_tags'), "gazelle_tags", ["emptag_id"], ["id"]) 51 | batch_op.create_foreign_key(op.f('fk_emp_tags_stashtag_id_stash_tag'), "stash_tag", ["stashtag_id"], ["id"]) 52 | 53 | op.create_table('hf_tags', sa.Column('stashtag_id', sa.Integer(), nullable=True), 54 | sa.Column('hftag_id', sa.Integer(), nullable=True), 55 | sa.ForeignKeyConstraint(['hftag_id'], ['gazelle_tags.id'], ), 56 | sa.ForeignKeyConstraint(['stashtag_id'], ['stash_tag.id'])) 57 | 58 | op.execute(text("pragma foreign_keys=ON")) 59 | 60 | 61 | def downgrade(): 62 | # ### commands auto generated by Alembic - please adjust! ### 63 | op.drop_table('hf_tags') # ### end Alembic commands ### 64 | op.rename_table("gazelle_tags", "emp_tag") 65 | op.rename_table("emp_tags", "tag_map") 66 | -------------------------------------------------------------------------------- /migrations/versions/7990cc760362_.py: -------------------------------------------------------------------------------- 1 | """Initial database creation 2 | 3 | Revision ID: 7990cc760362 4 | Revises: 5 | Create Date: 2023-12-02 19:10:05.966030 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | import sqlalchemy as sa 11 | from alembic import op 12 | 13 | # revision identifiers, used by Alembic. 14 | revision: str = '7990cc760362' 15 | down_revision: Union[str, None] = None 16 | branch_labels: Union[str, Sequence[str], None] = None 17 | depends_on: Union[str, Sequence[str], None] = None 18 | 19 | 20 | def upgrade() -> None: 21 | op.create_table('category', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('name', sa.String(), nullable=False), 24 | sa.PrimaryKeyConstraint('id', name=op.f('pk_category')), 25 | sa.UniqueConstraint('name', name=op.f('uq_category_name')) 26 | ) 27 | op.create_table('emp_tag', 28 | sa.Column('id', sa.Integer(), nullable=False), 29 | sa.Column('tagname', sa.String(length=32), nullable=False), 30 | sa.PrimaryKeyConstraint('id', name=op.f('pk_emp_tag')), 31 | sa.UniqueConstraint('tagname', name=op.f('uq_emp_tag_tagname')) 32 | ) 33 | op.create_table('stash_tag', 34 | sa.Column('id', sa.Integer(), nullable=False), 35 | sa.Column('tagname', sa.String(collation='NOCASE'), nullable=False), 36 | sa.Column('display', sa.String(), nullable=True), 37 | sa.Column('ignored', sa.Boolean(), nullable=False), 38 | sa.PrimaryKeyConstraint('id', name=op.f('pk_stash_tag')), 39 | sa.UniqueConstraint('tagname', name=op.f('uq_stash_tag_tagname')) 40 | ) 41 | op.create_table('tag_categories', 42 | sa.Column('stashtag', sa.Integer(), nullable=True), 43 | sa.Column('category', sa.Integer(), nullable=True), 44 | sa.ForeignKeyConstraint(['category'], ['category.id'], 45 | name=op.f('fk_tag_categories_category_category')), 46 | sa.ForeignKeyConstraint(['stashtag'], ['stash_tag.id'], 47 | name=op.f('fk_tag_categories_stashtag_stash_tag')) 48 | ) 49 | op.create_table('tag_map', 50 | sa.Column('stashtag_id', sa.Integer(), nullable=True), 51 | sa.Column('emptag_id', sa.Integer(), nullable=True), 52 | sa.ForeignKeyConstraint(['emptag_id'], ['emp_tag.id'], name=op.f('fk_tag_map_emptag_id_emp_tag')), 53 | sa.ForeignKeyConstraint(['stashtag_id'], ['stash_tag.id'], 54 | name=op.f('fk_tag_map_stashtag_id_stash_tag')) 55 | ) 56 | 57 | 58 | def downgrade() -> None: 59 | op.drop_table('tag_map') 60 | op.drop_table('tag_categories') 61 | op.drop_table('stash_tag') 62 | op.drop_table('emp_tag') 63 | op.drop_table('category') 64 | -------------------------------------------------------------------------------- /migrations/versions/e8e3ef7a0fd7_.py: -------------------------------------------------------------------------------- 1 | """Create primary key constraints 2 | 3 | Revision ID: e8e3ef7a0fd7 4 | Revises: 0b7028b71064 5 | Create Date: 2023-12-03 22:02:39.660770 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | from alembic.operations import BatchOperations 11 | from sqlalchemy import text 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = 'e8e3ef7a0fd7' 15 | down_revision = '0b7028b71064' 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | conn = op.get_bind() 22 | rows = conn.execute(text("SELECT DISTINCT stashtag_id, emptag_id FROM emp_tags")).all() 23 | op.execute(text("DELETE FROM emp_tags")) 24 | # op.execute(emp_tag_map.delete()) 25 | # print(len(conn.execute(text("SELECT DISTINCT stashtag_id, emptag_id FROM emp_tags")).all())) 26 | for row in rows: 27 | op.execute(text(f"INSERT INTO emp_tags (stashtag_id, emptag_id) VALUES {row.tuple()}")) 28 | # conn.commit() 29 | 30 | rows = conn.execute(text("SELECT DISTINCT stashtag, category FROM tag_categories")).all() 31 | op.execute(text("DELETE FROM tag_categories")) 32 | # op.execute(list_tags.delete()) 33 | for row in rows: 34 | op.execute(text(f"INSERT INTO tag_categories (stashtag, category) VALUES {row.tuple()}")) 35 | # conn.commit() 36 | 37 | with op.batch_alter_table('emp_tags', schema=None) as batch_op: 38 | batch_op: BatchOperations 39 | batch_op.alter_column('stashtag_id', 40 | existing_type=sa.INTEGER(), 41 | nullable=False) 42 | batch_op.alter_column('emptag_id', 43 | existing_type=sa.INTEGER(), 44 | nullable=False) 45 | batch_op.create_primary_key("pk_emp_tags", ["stashtag_id", "emptag_id"]) 46 | batch_op.drop_constraint('fk_emp_tags_stashtag_id_stash_tag', type_='foreignkey') 47 | batch_op.drop_constraint('fk_emp_tags_emptag_id_gazelle_tags', type_='foreignkey') 48 | batch_op.create_foreign_key(batch_op.f('fk_emp_tags_emptag_id_gazelle_tags'), 'gazelle_tags', ['emptag_id'], 49 | ['id'], ondelete='CASCADE') 50 | batch_op.create_foreign_key(batch_op.f('fk_emp_tags_stashtag_id_stash_tag'), 'stash_tag', ['stashtag_id'], 51 | ['id'], ondelete='CASCADE') 52 | 53 | with op.batch_alter_table('hf_tags', schema=None) as batch_op: 54 | batch_op.alter_column('stashtag_id', 55 | existing_type=sa.INTEGER(), 56 | nullable=False) 57 | batch_op.alter_column('hftag_id', 58 | existing_type=sa.INTEGER(), 59 | nullable=False) 60 | batch_op.create_primary_key("pk_hf_tags", ["stashtag_id", "hftag_id"]) 61 | batch_op.drop_constraint('fk_hf_tags_hftag_id_gazelle_tags', type_='foreignkey') 62 | batch_op.drop_constraint('fk_hf_tags_stashtag_id_stash_tag', type_='foreignkey') 63 | batch_op.create_foreign_key(batch_op.f('fk_hf_tags_stashtag_id_stash_tag'), 'stash_tag', ['stashtag_id'], 64 | ['id'], ondelete='CASCADE') 65 | batch_op.create_foreign_key(batch_op.f('fk_hf_tags_hftag_id_gazelle_tags'), 'gazelle_tags', ['hftag_id'], 66 | ['id'], ondelete='CASCADE') 67 | 68 | with op.batch_alter_table('tag_categories', schema=None) as batch_op: 69 | batch_op.alter_column('stashtag', 70 | existing_type=sa.INTEGER(), 71 | nullable=False) 72 | batch_op.alter_column('category', 73 | existing_type=sa.INTEGER(), 74 | nullable=False) 75 | batch_op.create_primary_key("pk_tag_categories", ["stashtag", "category"]) 76 | batch_op.drop_constraint('fk_tag_categories_stashtag_stash_tag', type_='foreignkey') 77 | batch_op.drop_constraint('fk_tag_categories_category_category', type_='foreignkey') 78 | batch_op.create_foreign_key(batch_op.f('fk_tag_categories_category_category'), 'category', ['category'], ['id'], 79 | ondelete='CASCADE') 80 | batch_op.create_foreign_key(batch_op.f('fk_tag_categories_stashtag_stash_tag'), 'stash_tag', ['stashtag'], 81 | ['id'], ondelete='CASCADE') 82 | 83 | 84 | def downgrade(): 85 | with op.batch_alter_table('tag_categories', schema=None) as batch_op: 86 | batch_op: BatchOperations 87 | batch_op.alter_column('category', 88 | existing_type=sa.INTEGER(), 89 | nullable=True) 90 | batch_op.alter_column('stashtag', 91 | existing_type=sa.INTEGER(), 92 | nullable=True) 93 | batch_op.drop_constraint("pk_tag_categories") 94 | batch_op.drop_constraint(batch_op.f('fk_tag_categories_stashtag_stash_tag'), type_='foreignkey') 95 | batch_op.drop_constraint(batch_op.f('fk_tag_categories_category_category'), type_='foreignkey') 96 | batch_op.create_foreign_key('fk_tag_categories_category_category', 'category', ['category'], ['id']) 97 | batch_op.create_foreign_key('fk_tag_categories_stashtag_stash_tag', 'stash_tag', ['stashtag'], ['id']) 98 | 99 | with op.batch_alter_table('hf_tags', schema=None) as batch_op: 100 | batch_op.alter_column('hftag_id', 101 | existing_type=sa.INTEGER(), 102 | nullable=True) 103 | batch_op.alter_column('stashtag_id', 104 | existing_type=sa.INTEGER(), 105 | nullable=True) 106 | batch_op.drop_constraint("pk_hf_tags") 107 | batch_op.drop_constraint(batch_op.f('fk_hf_tags_hftag_id_gazelle_tags'), type_='foreignkey') 108 | batch_op.drop_constraint(batch_op.f('fk_hf_tags_stashtag_id_stash_tag'), type_='foreignkey') 109 | batch_op.create_foreign_key('fk_hf_tags_stashtag_id_stash_tag', 'stash_tag', ['stashtag_id'], ['id']) 110 | batch_op.create_foreign_key('fk_hf_tags_hftag_id_gazelle_tags', 'gazelle_tags', ['hftag_id'], ['id']) 111 | 112 | with op.batch_alter_table('emp_tags', schema=None) as batch_op: 113 | batch_op.alter_column('emptag_id', 114 | existing_type=sa.INTEGER(), 115 | nullable=True) 116 | batch_op.alter_column('stashtag_id', 117 | existing_type=sa.INTEGER(), 118 | nullable=True) 119 | batch_op.drop_constraint("pk_emp_tags") 120 | batch_op.drop_constraint(batch_op.f('fk_emp_tags_stashtag_id_stash_tag'), type_='foreignkey') 121 | batch_op.drop_constraint(batch_op.f('fk_emp_tags_emptag_id_gazelle_tags'), type_='foreignkey') 122 | batch_op.create_foreign_key('fk_emp_tags_emptag_id_gazelle_tags', 'gazelle_tags', ['emptag_id'], ['id']) 123 | batch_op.create_foreign_key('fk_emp_tags_stashtag_id_stash_tag', 'stash_tag', ['stashtag_id'], ['id']) 124 | -------------------------------------------------------------------------------- /migrations/versions/feb7b60bf53f_.py: -------------------------------------------------------------------------------- 1 | """Add global default tag maps 2 | 3 | Revision ID: feb7b60bf53f 4 | Revises: 017767fd9fdb 5 | Create Date: 2023-12-27 10:17:24.231424 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = 'feb7b60bf53f' 13 | down_revision = '017767fd9fdb' 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | op.create_table('def_tags', 20 | sa.Column('stashtag_id', sa.Integer(), nullable=False), 21 | sa.Column('gazelletag_id', sa.Integer(), nullable=False), 22 | sa.ForeignKeyConstraint(['gazelletag_id'], ['gazelle_tags.id'], 23 | name=op.f('fk_def_tags_gazelletag_id_gazelle_tags'), ondelete='CASCADE'), 24 | sa.ForeignKeyConstraint(['stashtag_id'], ['stash_tag.id'], 25 | name=op.f('fk_def_tags_stashtag_id_stash_tag'), ondelete='CASCADE'), 26 | sa.PrimaryKeyConstraint('stashtag_id', 'gazelletag_id', name=op.f('pk_def_tags')) 27 | ) 28 | op.execute(sa.text("INSERT INTO def_tags (stashtag_id, gazelletag_id) SELECT stashtag_id, emptag_id FROM emp_tags")) 29 | 30 | 31 | def downgrade(): 32 | op.drop_table('def_tags') 33 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask == 3.0.* 2 | requests == 2.32.* 3 | vcsi == 7.0.* 4 | waitress == 3.0.* 5 | cairosvg == 2.7.* 6 | redis[hiredis] == 5.0.* 7 | tomlkit == 0.12.* 8 | Flask-WTF == 1.2.* 9 | bootstrap-Flask == 2.4.* 10 | Flask-SQLAlchemy == 3.1.* 11 | Flask-Migrate == 4.0.7 12 | pyimgbox == 1.0.7 13 | Pillow ~= 10.3.0 -------------------------------------------------------------------------------- /static/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /static/images/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bdbenim/stash-empornium/b3a5bbe316055bbf8af948605033b5bb9a73f845/static/images/android-chrome-192x192.png -------------------------------------------------------------------------------- /static/images/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bdbenim/stash-empornium/b3a5bbe316055bbf8af948605033b5bb9a73f845/static/images/android-chrome-512x512.png -------------------------------------------------------------------------------- /static/images/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bdbenim/stash-empornium/b3a5bbe316055bbf8af948605033b5bb9a73f845/static/images/apple-touch-icon.png -------------------------------------------------------------------------------- /static/images/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bdbenim/stash-empornium/b3a5bbe316055bbf8af948605033b5bb9a73f845/static/images/favicon-16x16.png -------------------------------------------------------------------------------- /static/images/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bdbenim/stash-empornium/b3a5bbe316055bbf8af948605033b5bb9a73f845/static/images/favicon-32x32.png -------------------------------------------------------------------------------- /static/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bdbenim/stash-empornium/b3a5bbe316055bbf8af948605033b5bb9a73f845/static/images/favicon.ico -------------------------------------------------------------------------------- /static/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 84 | -------------------------------------------------------------------------------- /static/images/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bdbenim/stash-empornium/b3a5bbe316055bbf8af948605033b5bb9a73f845/static/images/mstile-144x144.png -------------------------------------------------------------------------------- /static/images/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bdbenim/stash-empornium/b3a5bbe316055bbf8af948605033b5bb9a73f845/static/images/mstile-150x150.png -------------------------------------------------------------------------------- /static/images/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bdbenim/stash-empornium/b3a5bbe316055bbf8af948605033b5bb9a73f845/static/images/mstile-310x150.png -------------------------------------------------------------------------------- /static/images/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bdbenim/stash-empornium/b3a5bbe316055bbf8af948605033b5bb9a73f845/static/images/mstile-310x310.png -------------------------------------------------------------------------------- /static/images/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bdbenim/stash-empornium/b3a5bbe316055bbf8af948605033b5bb9a73f845/static/images/mstile-70x70.png -------------------------------------------------------------------------------- /static/images/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 38 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /static/node_modules/strftime/strftime-min.js: -------------------------------------------------------------------------------- 1 | (function(){function w(e,h,r){function l(g,a,c,f){for(var b="",d=null,n=!1,J=g.length,x=!1,v=0;va.getHours()?c.am:c.pm;break;case 82:b+=l(c.formats.R,a,c,f);break;case 83:b+=k(a.getSeconds(),d);break;case 84:b+=l(c.formats.T,a,c,f);break;case 85:b+=k(D(a,"sunday"),d);break;case 87:b+=k(D(a,"monday"),d);break;case 88:b+=l(c.formats.X,a,c,f);break;case 89:b+=a.getFullYear();break;case 90:t&&0===m?b+="GMT":(d= 3 | a.toString().match(/\(([\w\s]+)\)/),b+=d&&d[1]||"");break;case 97:b+=c.shortDays[a.getDay()];break;case 98:b+=c.shortMonths[a.getMonth()];break;case 99:b+=l(c.formats.c,a,c,f);break;case 100:b+=k(a.getDate(),d);break;case 101:b+=k(a.getDate(),null==d?" ":d);break;case 104:b+=c.shortMonths[a.getMonth()];break;case 106:d=new Date(a.getFullYear(),0,1);d=Math.ceil((a.getTime()-d.getTime())/864E5);b+=C(d);break;case 107:b+=k(a.getHours(),null==d?" ":d);break;case 108:b+=k(B(a.getHours()),null==d?" ":d); 4 | break;case 109:b+=k(a.getMonth()+1,d);break;case 110:b+="\n";break;case 111:d=a.getDate();b=c.ordinalSuffixes?b+(String(d)+(c.ordinalSuffixes[d-1]||E(d))):b+(String(d)+E(d));break;case 112:b+=12>a.getHours()?c.AM:c.PM;break;case 114:b+=l(c.formats.r,a,c,f);break;case 115:b+=Math.floor(f/1E3);break;case 116:b+="\t";break;case 117:d=a.getDay();b+=0===d?7:d;break;case 118:b+=l(c.formats.v,a,c,f);break;case 119:b+=a.getDay();break;case 120:b+=l(c.formats.x,a,c,f);break;case 121:n=a.getFullYear()%100; 5 | b+=k(n,d);break;case 122:t&&0===m?b+=x?"+00:00":"+0000":(d=0!==m?m/6E4:-a.getTimezoneOffset(),n=x?":":"",p=Math.abs(d%60),b+=(0>d?"-":"+")+k(Math.floor(Math.abs(d/60)))+n+k(p));break;default:n&&(b+="%"),b+=g[v]}d=null;n=!1}else 37===p?n=!0:b+=g[v]}return b}var y=e||F,m=h||0,t=r||!1,u=0,z,q=function(g,a){if(a){var c=a.getTime();if(t){var f=6E4*(a.getTimezoneOffset()||0);a=new Date(c+f+m);6E4*(a.getTimezoneOffset()||0)!==f&&(a=6E4*(a.getTimezoneOffset()||0),a=new Date(c+a+m))}}else c=Date.now(),c>u? 6 | (u=c,z=new Date(u),c=u,t&&(z=new Date(u+6E4*(z.getTimezoneOffset()||0)+m))):c=u,a=z;return l(g,a,y,c)};q.localize=function(g){return new w(g||y,m,t)};q.localizeByIdentifier=function(g){var a=G[g];return a?q.localize(a):(A('[WARNING] No locale found with identifier "'+g+'".'),q)};q.timezone=function(g){var a=m,c=t,f=typeof g;if("number"===f||"string"===f)c=!0,"string"===f?(a="-"===g[0]?-1:1,f=parseInt(g.slice(1,3),10),g=parseInt(g.slice(3,5),10),a=a*(60*f+g)*6E4):"number"===f&&(a=6E4*g);return new w(y, 7 | a,c)};q.utc=function(){return new w(y,m,!0)};return q}function k(e,h){if(""===h||9=e||0===h||4<=h)return"th";switch(h){case 1:return"st"; 8 | case 2:return"nd";case 3:return"rd"}}function A(e){"undefined"!==typeof console&&"function"==typeof console.warn&&console.warn(e)}var G={de_DE:{identifier:"de-DE",days:"Sonntag Montag Dienstag Mittwoch Donnerstag Freitag Samstag".split(" "),shortDays:"So Mo Di Mi Do Fr Sa".split(" "),months:"Januar Februar M\u00e4rz April Mai Juni Juli August September Oktober November Dezember".split(" "),shortMonths:"Jan Feb M\u00e4r Apr Mai Jun Jul Aug Sep Okt Nov Dez".split(" "),AM:"AM",PM:"PM",am:"am",pm:"pm", 9 | formats:{c:"%a %d %b %Y %X %Z",D:"%d.%m.%Y",F:"%Y-%m-%d",R:"%H:%M",r:"%I:%M:%S %p",T:"%H:%M:%S",v:"%e-%b-%Y",X:"%T",x:"%D"}},en_CA:{identifier:"en-CA",days:"Sunday Monday Tuesday Wednesday Thursday Friday Saturday".split(" "),shortDays:"Sun Mon Tue Wed Thu Fri Sat".split(" "),months:"January February March April May June July August September October November December".split(" "),shortMonths:"Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" "),ordinalSuffixes:"st nd rd th th th th th th th th th th th th th th th th th st nd rd th th th th th th th st".split(" "), 10 | AM:"AM",PM:"PM",am:"am",pm:"pm",formats:{c:"%a %d %b %Y %X %Z",D:"%d/%m/%y",F:"%Y-%m-%d",R:"%H:%M",r:"%I:%M:%S %p",T:"%H:%M:%S",v:"%e-%b-%Y",X:"%r",x:"%D"}},en_US:{identifier:"en-US",days:"Sunday Monday Tuesday Wednesday Thursday Friday Saturday".split(" "),shortDays:"Sun Mon Tue Wed Thu Fri Sat".split(" "),months:"January February March April May June July August September October November December".split(" "),shortMonths:"Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" "),ordinalSuffixes:"st nd rd th th th th th th th th th th th th th th th th th st nd rd th th th th th th th st".split(" "), 11 | AM:"AM",PM:"PM",am:"am",pm:"pm",formats:{c:"%a %d %b %Y %X %Z",D:"%m/%d/%y",F:"%Y-%m-%d",R:"%H:%M",r:"%I:%M:%S %p",T:"%H:%M:%S",v:"%e-%b-%Y",X:"%r",x:"%D"}},es_MX:{identifier:"es-MX",days:"domingo lunes martes mi\u00e9rcoles jueves viernes s\u00e1bado".split(" "),shortDays:"dom lun mar mi\u00e9 jue vie s\u00e1b".split(" "),months:"enero febrero marzo abril mayo junio julio agosto septiembre octubre noviembre diciembre".split(" "),shortMonths:"ene feb mar abr may jun jul ago sep oct nov dic".split(" "), 12 | AM:"AM",PM:"PM",am:"am",pm:"pm",formats:{c:"%a %d %b %Y %X %Z",D:"%d/%m/%Y",F:"%Y-%m-%d",R:"%H:%M",r:"%I:%M:%S %p",T:"%H:%M:%S",v:"%e-%b-%Y",X:"%T",x:"%D"}},fr_FR:{identifier:"fr-FR",days:"dimanche lundi mardi mercredi jeudi vendredi samedi".split(" "),shortDays:"dim. lun. mar. mer. jeu. ven. sam.".split(" "),months:"janvier f\u00e9vrier mars avril mai juin juillet ao\u00fbt septembre octobre novembre d\u00e9cembre".split(" "),shortMonths:"janv. f\u00e9vr. mars avril mai juin juil. ao\u00fbt sept. oct. nov. d\u00e9c.".split(" "), 13 | AM:"AM",PM:"PM",am:"am",pm:"pm",formats:{c:"%a %d %b %Y %X %Z",D:"%d/%m/%Y",F:"%Y-%m-%d",R:"%H:%M",r:"%I:%M:%S %p",T:"%H:%M:%S",v:"%e-%b-%Y",X:"%T",x:"%D"}},it_IT:{identifier:"it-IT",days:"domenica luned\u00ec marted\u00ec mercoled\u00ec gioved\u00ec venerd\u00ec sabato".split(" "),shortDays:"dom lun mar mer gio ven sab".split(" "),months:"gennaio febbraio marzo aprile maggio giugno luglio agosto settembre ottobre novembre dicembre".split(" "),shortMonths:"gen feb mar apr mag giu lug ago set ott nov dic".split(" "), 14 | AM:"AM",PM:"PM",am:"am",pm:"pm",formats:{c:"%a %d %b %Y %X %Z",D:"%d/%m/%Y",F:"%Y-%m-%d",R:"%H:%M",r:"%I:%M:%S %p",T:"%H:%M:%S",v:"%e-%b-%Y",X:"%T",x:"%D"}},nl_NL:{identifier:"nl-NL",days:"zondag maandag dinsdag woensdag donderdag vrijdag zaterdag".split(" "),shortDays:"zo ma di wo do vr za".split(" "),months:"januari februari maart april mei juni juli augustus september oktober november december".split(" "),shortMonths:"jan feb mrt apr mei jun jul aug sep okt nov dec".split(" "),AM:"AM",PM:"PM", 15 | am:"am",pm:"pm",formats:{c:"%a %d %b %Y %X %Z",D:"%d-%m-%y",F:"%Y-%m-%d",R:"%H:%M",r:"%I:%M:%S %p",T:"%H:%M:%S",v:"%e-%b-%Y",X:"%T",x:"%D"}},pt_BR:{identifier:"pt-BR",days:"domingo segunda ter\u00e7a quarta quinta sexta s\u00e1bado".split(" "),shortDays:"Dom Seg Ter Qua Qui Sex S\u00e1b".split(" "),months:"janeiro fevereiro mar\u00e7o abril maio junho julho agosto setembro outubro novembro dezembro".split(" "),shortMonths:"Jan Fev Mar Abr Mai Jun Jul Ago Set Out Nov Dez".split(" "),AM:"AM",PM:"PM", 16 | am:"am",pm:"pm",formats:{c:"%a %d %b %Y %X %Z",D:"%d-%m-%Y",F:"%Y-%m-%d",R:"%H:%M",r:"%I:%M:%S %p",T:"%H:%M:%S",v:"%e-%b-%Y",X:"%T",x:"%D"}},ru_RU:{identifier:"ru-RU",days:"\u0412\u043e\u0441\u043a\u0440\u0435\u0441\u0435\u043d\u044c\u0435 \u041f\u043e\u043d\u0435\u0434\u0435\u043b\u044c\u043d\u0438\u043a \u0412\u0442\u043e\u0440\u043d\u0438\u043a \u0421\u0440\u0435\u0434\u0430 \u0427\u0435\u0442\u0432\u0435\u0440\u0433 \u041f\u044f\u0442\u043d\u0438\u0446\u0430 \u0421\u0443\u0431\u0431\u043e\u0442\u0430".split(" "), 17 | shortDays:"\u0412\u0441 \u041f\u043d \u0412\u0442 \u0421\u0440 \u0427\u0442 \u041f\u0442 \u0421\u0431".split(" "),months:"\u042f\u043d\u0432\u0430\u0440\u044c \u0424\u0435\u0432\u0440\u0430\u043b\u044c \u041c\u0430\u0440\u0442 \u0410\u043f\u0440\u0435\u043b\u044c \u041c\u0430\u0439 \u0418\u044e\u043d\u044c \u0418\u044e\u043b\u044c \u0410\u0432\u0433\u0443\u0441\u0442 \u0421\u0435\u043d\u0442\u044f\u0431\u0440\u044c \u041e\u043a\u0442\u044f\u0431\u0440\u044c \u041d\u043e\u044f\u0431\u0440\u044c \u0414\u0435\u043a\u0430\u0431\u0440\u044c".split(" "), 18 | shortMonths:"\u044f\u043d\u0432 \u0444\u0435\u0432 \u043c\u0430\u0440 \u0430\u043f\u0440 \u043c\u0430\u0439 \u0438\u044e\u043d \u0438\u044e\u043b \u0430\u0432\u0433 \u0441\u0435\u043d \u043e\u043a\u0442 \u043d\u043e\u044f \u0434\u0435\u043a".split(" "),AM:"AM",PM:"PM",am:"am",pm:"pm",formats:{c:"%a %d %b %Y %X",D:"%d.%m.%y",F:"%Y-%m-%d",R:"%H:%M",r:"%I:%M:%S %p",T:"%H:%M:%S",v:"%e-%b-%Y",X:"%T",x:"%D"}},tr_TR:{identifier:"tr-TR",days:"Pazar Pazartesi Sal\u0131 \u00c7ar\u015famba Per\u015fembe Cuma Cumartesi".split(" "), 19 | shortDays:"Paz Pzt Sal \u00c7r\u015f Pr\u015f Cum Cts".split(" "),months:"Ocak \u015eubat Mart Nisan May\u0131s Haziran Temmuz A\u011fustos Eyl\u00fcl Ekim Kas\u0131m Aral\u0131k".split(" "),shortMonths:"Oca \u015eub Mar Nis May Haz Tem A\u011fu Eyl Eki Kas Ara".split(" "),AM:"\u00d6\u00d6",PM:"\u00d6S",am:"\u00d6\u00d6",pm:"\u00d6S",formats:{c:"%a %d %b %Y %X %Z",D:"%d-%m-%Y",F:"%Y-%m-%d",R:"%H:%M",r:"%I:%M:%S %p",T:"%H:%M:%S",v:"%e-%b-%Y",X:"%T",x:"%D"}},zh_CN:{identifier:"zh-CN",days:"\u661f\u671f\u65e5 \u661f\u671f\u4e00 \u661f\u671f\u4e8c \u661f\u671f\u4e09 \u661f\u671f\u56db \u661f\u671f\u4e94 \u661f\u671f\u516d".split(" "), 20 | shortDays:"\u65e5\u4e00\u4e8c\u4e09\u56db\u4e94\u516d".split(""),months:"\u4e00\u6708\u4efd \u4e8c\u6708\u4efd \u4e09\u6708\u4efd \u56db\u6708\u4efd \u4e94\u6708\u4efd \u516d\u6708\u4efd \u4e03\u6708\u4efd \u516b\u6708\u4efd \u4e5d\u6708\u4efd \u5341\u6708\u4efd \u5341\u4e00\u6708\u4efd \u5341\u4e8c\u6708\u4efd".split(" "),shortMonths:"\u4e00\u6708 \u4e8c\u6708 \u4e09\u6708 \u56db\u6708 \u4e94\u6708 \u516d\u6708 \u4e03\u6708 \u516b\u6708 \u4e5d\u6708 \u5341\u6708 \u5341\u4e00\u6708 \u5341\u4e8c\u6708".split(" "), 21 | AM:"\u4e0a\u5348",PM:"\u4e0b\u5348",am:"\u4e0a\u5348",pm:"\u4e0b\u5348",formats:{c:"%a %d %b %Y %X %Z",D:"%d/%m/%y",F:"%Y-%m-%d",R:"%H:%M",r:"%I:%M:%S %p",T:"%H:%M:%S",v:"%e-%b-%Y",X:"%r",x:"%D"}}},F=G.en_US,H=new w(F,0,!1);if("undefined"!==typeof module)var I=module.exports=H;else I=function(){return this||(0,eval)("this")}(),I.strftime=H;"function"!==typeof Date.now&&(Date.now=function(){return+new Date})})(); 22 | -------------------------------------------------------------------------------- /static/scripts/forms.js: -------------------------------------------------------------------------------- 1 | try { 2 | let enabled = document.getElementById("enable_form"); 3 | function toggle() { 4 | const form = document.querySelector("#enable_form").parentElement.closest("form"); 5 | for (el of form.querySelectorAll("input")) { 6 | if (el.id != "csrf_token" && el.id != "save" && el.id != enabled.id) { 7 | el.disabled = !enabled.checked; 8 | } 9 | } 10 | } 11 | enabled.onchange = toggle; 12 | toggle(); 13 | } catch {} 14 | 15 | function showPass(id) { 16 | let el = document.getElementById(id); 17 | console.log("btn-" + el.id); 18 | let btn = document.getElementById("btn-" + el.id).querySelector("i"); 19 | if (el.type === "password") { 20 | el.type = "text"; 21 | btn.classList.remove("bi-eye-fill"); 22 | btn.classList.add("bi-eye-slash-fill"); 23 | } else { 24 | el.type = "password"; 25 | btn.classList.remove("bi-eye-slash-fill"); 26 | btn.classList.add("bi-eye-fill"); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /static/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stash-empornium", 3 | "short_name": "stash-empornium", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /utils/bencoder.py: -------------------------------------------------------------------------------- 1 | import re 2 | import string 3 | import itertools as it 4 | import hashlib 5 | 6 | 7 | def encode(obj): 8 | """ 9 | bencodes given object. Given object should be a int, 10 | bytes, list or dict. If a str is given, it'll be 11 | encoded as ASCII. 12 | 13 | >>> [encode(i) for i in (-2, 42, b"answer", b"")] \ 14 | == [b'i-2e', b'i42e', b'6:answer', b'0:'] 15 | True 16 | >>> encode([b'a', 42, [13, 14]]) == b'l1:ai42eli13ei14eee' 17 | True 18 | >>> encode({b'bar': b'spam', b'foo': 42, b'mess': [1, b'c']}) \ 19 | == b'd3:bar4:spam3:fooi42e4:messli1e1:cee' 20 | True 21 | """ 22 | 23 | if isinstance(obj, int): 24 | return b"i" + str(obj).encode() + b"e" 25 | elif isinstance(obj, bytes): 26 | return str(len(obj)).encode() + b":" + obj 27 | elif isinstance(obj, str): 28 | return encode(obj.encode("ascii")) 29 | elif isinstance(obj, list): 30 | return b"l" + b"".join(map(encode, obj)) + b"e" 31 | elif isinstance(obj, dict): 32 | if all(isinstance(i, bytes) for i in obj.keys()): 33 | items = list(obj.items()) 34 | items.sort() 35 | return b"d" + b"".join(map(encode, it.chain(*items))) + b"e" 36 | else: 37 | raise ValueError("dict keys should be bytes") 38 | raise ValueError("Allowed types: int, bytes, list, dict; not %s", type(obj)) 39 | 40 | 41 | def decode(s): 42 | """ 43 | Decodes given bencoded bytes object. 44 | 45 | >>> decode(b'i-42e') 46 | -42 47 | >>> decode(b'4:utku') == b'utku' 48 | True 49 | >>> decode(b'li1eli2eli3eeee') 50 | [1, [2, [3]]] 51 | >>> decode(b'd3:bar4:spam3:fooi42ee') == {b'bar': b'spam', b'foo': 42} 52 | True 53 | """ 54 | 55 | def decode_first(s): 56 | if s.startswith(b"i"): 57 | match = re.match(b"i(-?\\d+)e", s) 58 | assert match is not None 59 | return int(match.group(1)), s[match.span()[1] :] 60 | elif s.startswith(b"l") or s.startswith(b"d"): 61 | l = [] 62 | rest = s[1:] 63 | while not rest.startswith(b"e"): 64 | elem, rest = decode_first(rest) 65 | l.append(elem) 66 | rest = rest[1:] 67 | if s.startswith(b"l"): 68 | return l, rest 69 | else: 70 | return {i: j for i, j in zip(l[::2], l[1::2])}, rest 71 | elif any(s.startswith(i.encode()) for i in string.digits): 72 | m = re.match(b"(\\d+):", s) 73 | assert m is not None 74 | length = int(m.group(1)) 75 | rest_i = m.span()[1] 76 | start = rest_i 77 | end = rest_i + length 78 | return s[start:end], s[end:] 79 | else: 80 | raise ValueError("Malformed input.") 81 | 82 | if isinstance(s, str): 83 | s = s.encode("ascii") 84 | 85 | ret, rest = decode_first(s) 86 | if rest: 87 | raise ValueError("Malformed input.") 88 | return ret 89 | 90 | 91 | def infohash(f: bytes) -> str: 92 | data = decode(f) 93 | try: 94 | assert isinstance(data, dict) and b"info" in data 95 | return hashlib.sha1(encode(data[b"info"])).hexdigest() 96 | except: 97 | return "" 98 | -------------------------------------------------------------------------------- /utils/confighandler.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import os 4 | import shutil 5 | 6 | import tomlkit 7 | 8 | from utils.customtypes import CaseInsensitiveDict, Singleton 9 | from utils.torrentclients import TorrentClient, Deluge, Qbittorrent, RTorrent 10 | 11 | stash_headers = { 12 | "Content-type": "application/json", 13 | } 14 | 15 | stash_query = """ 16 | findScene(id: "{}") {{ 17 | title 18 | details 19 | director 20 | date 21 | galleries {{ 22 | folder {{ 23 | path 24 | }} 25 | files {{ 26 | path 27 | }} 28 | }} 29 | studio {{ 30 | name 31 | url 32 | image_path 33 | parent_studio {{ 34 | url 35 | }} 36 | }} 37 | tags {{ 38 | name 39 | parents {{ 40 | name 41 | }} 42 | }} 43 | performers {{ 44 | name 45 | circumcised 46 | country 47 | eye_color 48 | fake_tits 49 | gender 50 | hair_color 51 | height_cm 52 | measurements 53 | piercings 54 | image_path 55 | tags {{ 56 | name 57 | }} 58 | tattoos 59 | }} 60 | paths {{ 61 | screenshot 62 | preview 63 | }} 64 | files {{ 65 | id 66 | path 67 | basename 68 | width 69 | height 70 | format 71 | duration 72 | video_codec 73 | audio_codec 74 | frame_rate 75 | bit_rate 76 | size 77 | }} 78 | }} 79 | """ 80 | 81 | 82 | class ConfigHandler(Singleton): 83 | initialized = False 84 | logger: logging.Logger 85 | log_level: int 86 | args: argparse.Namespace 87 | conf: tomlkit.TOMLDocument 88 | tag_conf: tomlkit.TOMLDocument 89 | port: int 90 | username: str 91 | password: str 92 | torrent_dirs: list[str] 93 | template_dir: str 94 | template_names: dict[str, str] 95 | config_file: str 96 | tag_config_file: str 97 | torrent_clients: list[TorrentClient] = [] 98 | 99 | def __init__(self): 100 | if not self.initialized: 101 | self.parse_args() 102 | self.logging_init() 103 | self.configure() 104 | self.initialized = True 105 | 106 | def logging_init(self) -> None: 107 | self.log_level = getattr(logging, self.args.log) if self.args.log else min(10 * self.args.level, 50) 108 | logging.basicConfig(format="%(asctime)s - %(levelname)s - %(message)s", level=self.log_level) 109 | # logging.basicConfig(format="%(levelname)-5.5s [%(name)s] %(message)s", level=self.log_level) 110 | self.logger = logging.getLogger(__name__) 111 | 112 | def parse_args(self) -> None: 113 | parser = argparse.ArgumentParser(description="backend server for EMP Stash upload helper userscript") 114 | parser.add_argument( 115 | "--configdir", 116 | default=[os.path.join(os.getcwd(), "config")], 117 | help="specify the directory containing configuration files", 118 | nargs=1, 119 | ) 120 | mutex = parser.add_argument_group("Output", "options for setting the log level").add_mutually_exclusive_group() 121 | mutex.add_argument("-q", "--quiet", dest="level", action="count", default=2, help="output less") 122 | mutex.add_argument( 123 | "-v", "--verbose", "--debug", dest="level", action="store_const", const=1, help="output more" 124 | ) 125 | mutex.add_argument( 126 | "-l", 127 | "--log", 128 | choices=["DEBUG", "INFO", "WARN", "WARNING", "ERROR", "CRITICAL", "FATAL"], 129 | metavar="LEVEL", 130 | help="log level: [DEBUG | INFO | WARNING | ERROR | CRITICAL]", 131 | type=str.upper, 132 | ) 133 | 134 | redis_group = parser.add_argument_group("redis", "options for connecting to a redis server") 135 | redis_group.add_argument("--flush", help="flush redis cache", action="store_true") 136 | cache = redis_group.add_mutually_exclusive_group() 137 | cache.add_argument("--no-cache", help="do not retrieve cached values", action="store_true") 138 | cache.add_argument("--overwrite", help="overwrite cached values", action="store_true") 139 | 140 | self.args = parser.parse_args() 141 | 142 | def rename_key(self, section: str, old_key: str, new_key: str, conf: tomlkit.TOMLDocument) -> None: 143 | if old_key in conf[section]: # type: ignore 144 | conf[section][new_key] = self.conf[section][old_key] # type: ignore 145 | del conf[section][old_key] # type: ignore 146 | self.update_file() 147 | self.logger.info(f"Key '{old_key}' renamed to '{new_key}'") 148 | 149 | def update_file(self) -> None: 150 | with open(self.config_file, "w") as f: 151 | tomlkit.dump(self.conf, f) 152 | with open(self.tag_config_file, "w") as f: 153 | tomlkit.dump(self.tag_conf, f) 154 | 155 | def backup_config(self) -> None: 156 | conf_bak = self.config_file + ".bak" 157 | tags_bak = self.tag_config_file + ".bak" 158 | if os.path.isfile(self.config_file): 159 | shutil.copy(self.config_file, conf_bak) 160 | if os.path.isfile(self.tag_config_file): 161 | shutil.copy(self.tag_config_file, tags_bak) 162 | 163 | def configure(self) -> None: 164 | self.config_dir = self.args.configdir[0] 165 | 166 | self.template_dir = os.path.join(self.config_dir, "templates") 167 | self.config_file = os.path.join(self.config_dir, "config.toml") 168 | self.tag_config_file = os.path.join(self.config_dir, "tags.toml") 169 | 170 | # Ensure config file is present 171 | if not os.path.isfile(self.config_file): 172 | self.logger.info(f"Config file not found at {self.config_file}, creating") 173 | if not os.path.exists(self.config_dir): 174 | os.makedirs(self.config_dir) 175 | with open("default.toml") as f: 176 | self.conf = tomlkit.load(f) 177 | else: 178 | self.logger.info(f"Reading config from {self.config_file}") 179 | try: 180 | with open(self.config_file) as f: 181 | self.conf = tomlkit.load(f) 182 | except Exception as e: 183 | self.logger.critical(f"Failed to read config file: {e}") 184 | exit(1) 185 | with open("default.toml") as f: 186 | default_conf = tomlkit.load(f) 187 | for section in default_conf: 188 | if section not in self.conf: 189 | s = tomlkit.table(True) 190 | s.add(tomlkit.comment("Section added from default.toml")) 191 | self.conf.append(section, s) 192 | for option in default_conf[section]: # type: ignore 193 | if option not in self.conf[section]: 194 | self.conf[section].add(tomlkit.comment("Option imported automatically:")) # type: ignore 195 | value = default_conf[section][option] # type: ignore 196 | self.conf[section][option] = value # type: ignore 197 | self.logger.info( 198 | f"Automatically added option '{option}' to section [{section}] with value '{value}'" 199 | ) 200 | try: 201 | if os.path.isfile(self.tag_config_file): 202 | self.logger.debug(f"Found tag config at {self.tag_config_file}") 203 | with open(self.tag_config_file) as f: 204 | self.tag_conf = tomlkit.load(f) 205 | else: 206 | self.logger.info(f"Config file not found at {self.tag_config_file}, creating") 207 | self.tag_conf = tomlkit.document() 208 | if "empornium" in self.conf: 209 | emp = self.conf["empornium"] 210 | self.tag_conf.append("empornium", emp) # type: ignore 211 | del self.conf["empornium"] 212 | if "empornium.tags" in self.conf: 213 | emptags = self.conf["empornium.tags"] 214 | if "empornium" not in self.tag_conf: 215 | self.tag_conf.append("empornium", tomlkit.table(True)) 216 | self.tag_conf["empornium"].append("tags", emptags) # type: ignore 217 | del self.conf["empornium.tags"] 218 | else: 219 | with open("default-tags.toml") as f: 220 | self.tag_conf = tomlkit.load(f) 221 | with open("default-tags.toml") as f: 222 | default_tags = tomlkit.load(f) 223 | if "empornium" not in self.tag_conf: 224 | emp = tomlkit.table(True) 225 | emp.append("tags", tomlkit.table(False)) 226 | self.tag_conf.append("empornium", emp) 227 | for option in default_tags["empornium"]: # type: ignore 228 | if option not in self.tag_conf["empornium"]: 229 | self.tag_conf["empornium"].add(tomlkit.comment("Option imported automatically")) # type: ignore 230 | value = default_tags["empornium"][option] # type: ignore 231 | self.tag_conf["empornium"][option] = value # type: ignore 232 | for tag in default_tags["empornium"]["tags"]: # type: ignore 233 | if tag not in self.tag_conf["empornium"]["ignored_tags"] and tag not in self.tag_conf["empornium"][ 234 | "tags"]: # type: ignore 235 | value = default_tags["empornium"]["tags"][tag] # type: ignore 236 | self.tag_conf["empornium"]["tags"][tag] = value # type: ignore 237 | except Exception as e: 238 | self.logger.error(f"Failed to read tag config file: {e}") 239 | try: 240 | self.backup_config() 241 | self.update_file() 242 | except: 243 | self.logger.error("Unable to save updated config") 244 | 245 | if not os.path.exists(self.template_dir): 246 | shutil.copytree("default-templates", self.template_dir, copy_function=shutil.copyfile) 247 | self.installed_templates = os.listdir(self.template_dir) 248 | for filename in os.listdir("default-templates"): 249 | src = os.path.join("default-templates", filename) 250 | if os.path.isfile(src): 251 | dst = os.path.join(self.template_dir, filename) 252 | if os.path.isfile(dst): 253 | try: 254 | with open(src) as srcFile, open(dst) as dstFile: 255 | srcVer = int("".join(filter(str.isdigit, "0" + srcFile.readline()))) 256 | dstVer = int("".join(filter(str.isdigit, "0" + dstFile.readline()))) 257 | if srcVer > dstVer: 258 | self.logger.info( 259 | f'Template "{filename}" has a new version available in the default-templates directory' 260 | ) 261 | except: 262 | self.logger.error(f"Couldn't compare version of {src} and {dst}") 263 | else: 264 | shutil.copyfile(src, dst) 265 | self.logger.info( 266 | f"Template {filename} has a been added. To use it, add it to config.ini under [templates]" 267 | ) 268 | if filename not in self.conf["templates"]: # type: ignore 269 | with open("default.toml") as f: 270 | tmpConf = tomlkit.load(f) 271 | conf["templates"][filename] = tmpConf["templates"][filename] # type: ignore 272 | 273 | self.torrent_dirs = list(self.conf["backend"]["torrent_directories"]) # type: ignore 274 | assert self.torrent_dirs is not None and len(self.torrent_dirs) > 0 275 | for dir in self.torrent_dirs: 276 | if not os.path.isdir(dir): 277 | if os.path.isfile(dir): 278 | self.logger.error(f"Cannot use {dir} for torrents, path is a file") 279 | self.torrent_dirs.remove(dir) 280 | exit(1) 281 | self.logger.info(f"Creating directory {dir}") 282 | os.makedirs(dir) 283 | self.logger.debug(f"Torrent directories: {self.torrent_dirs}") 284 | if len(self.torrent_dirs) == 0: 285 | self.logger.critical("No valid output directories found") 286 | exit(1) 287 | 288 | self.template_names = {} 289 | template_files = os.listdir(self.template_dir) 290 | for k in self.items("templates"): 291 | if k in template_files: 292 | self.template_names[k] = str(self.get("templates", k)) 293 | else: 294 | self.logger.warning(f"Template {k} from config.toml is not present in {self.template_dir}") 295 | 296 | if "api_key" in self.conf["stash"]: # type: ignore 297 | api_key = self.get("stash", "api_key") 298 | assert api_key is not None 299 | stash_headers["apiKey"] = str(api_key) 300 | 301 | self.configureTorrents() 302 | 303 | def configureTorrents(self) -> None: 304 | self.torrent_clients.clear() 305 | clients = {"rtorrent": RTorrent, "deluge": Deluge, "qbittorrent": Qbittorrent} 306 | for client, clientType in clients.items(): 307 | assert issubclass(clientType, TorrentClient) 308 | try: 309 | if client in self.conf and not self.get(client, "disable", False): 310 | settings = dict(self.conf[client]) # type: ignore 311 | tc = clientType(settings) 312 | if tc.connected(): 313 | self.torrent_clients.append(tc) 314 | else: 315 | self.logger.error(f"Could not connect to {client}") 316 | except: 317 | pass 318 | self.logger.debug(f"Configured {len(self.torrent_clients)} torrent client(s)") 319 | 320 | def get(self, section: str, key: str, default=None): 321 | if section in self.conf: 322 | if key in self.conf[section]: # type: ignore 323 | return self.conf[section][key] # type: ignore 324 | if section in self.tag_conf and key in self.tag_conf[section]: # type: ignore 325 | return self.tag_conf[section][key] # type: ignore 326 | return default 327 | 328 | def set(self, section: str, key: str, value) -> None: 329 | if section not in self.conf: 330 | if section in self.tag_conf: 331 | self.tag_conf[section][key] = value # type: ignore 332 | return 333 | self.conf[section] = {} 334 | self.conf[section][key] = value # type: ignore 335 | 336 | def delete(self, section: str, key: str | None = None) -> None: 337 | if section in self.conf: 338 | if key: 339 | if key in self.conf[section]: # type: ignore 340 | del self.conf[section][key] # type: ignore 341 | else: 342 | del self.conf[section] 343 | elif section in self.tag_conf: 344 | if key: 345 | if key in self.tag_conf[section]: # type: ignore 346 | del self.tag_conf[section][key] # type: ignore 347 | else: 348 | del self.tag_conf[section] 349 | 350 | def items(self, section: str) -> dict: 351 | if section in self.conf: 352 | return CaseInsensitiveDict(self.conf[section]) # type: ignore 353 | if section in self.tag_conf: 354 | return CaseInsensitiveDict(self.tag_conf[section]) # type: ignore 355 | return {} 356 | 357 | def __iter__(self): 358 | for section in self.conf: 359 | yield section 360 | 361 | def __contains__(self, __key: object) -> bool: 362 | return self.conf.__contains__(__key) or self.tag_conf.__contains__(__key) 363 | 364 | def __getitem__(self, key: str): 365 | return self.conf.__getitem__(key) if self.conf.__contains__(key) else self.tag_conf.__getitem__(key) 366 | -------------------------------------------------------------------------------- /utils/customtypes.py: -------------------------------------------------------------------------------- 1 | """ 2 | NOTICE 3 | This file contains source code licensed under the Apache License from the 4 | following source(s): 5 | 6 | Requests 7 | Copyright 2019 Kenneth Reitz 8 | 9 | 10 | Apache License 11 | Version 2.0, January 2004 12 | http://www.apache.org/licenses/ 13 | 14 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 15 | 16 | 1. Definitions. 17 | 18 | "License" shall mean the terms and conditions for use, reproduction, 19 | and distribution as defined by Sections 1 through 9 of this document. 20 | 21 | "Licensor" shall mean the copyright owner or entity authorized by 22 | the copyright owner that is granting the License. 23 | 24 | "Legal Entity" shall mean the union of the acting entity and all 25 | other entities that control, are controlled by, or are under common 26 | control with that entity. For the purposes of this definition, 27 | "control" means (i) the power, direct or indirect, to cause the 28 | direction or management of such entity, whether by contract or 29 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 30 | outstanding shares, or (iii) beneficial ownership of such entity. 31 | 32 | "You" (or "Your") shall mean an individual or Legal Entity 33 | exercising permissions granted by this License. 34 | 35 | "Source" form shall mean the preferred form for making modifications, 36 | including but not limited to software source code, documentation 37 | source, and configuration files. 38 | 39 | "Object" form shall mean any form resulting from mechanical 40 | transformation or translation of a Source form, including but 41 | not limited to compiled object code, generated documentation, 42 | and conversions to other media types. 43 | 44 | "Work" shall mean the work of authorship, whether in Source or 45 | Object form, made available under the License, as indicated by a 46 | copyright notice that is included in or attached to the work 47 | (an example is provided in the Appendix below). 48 | 49 | "Derivative Works" shall mean any work, whether in Source or Object 50 | form, that is based on (or derived from) the Work and for which the 51 | editorial revisions, annotations, elaborations, or other modifications 52 | represent, as a whole, an original work of authorship. For the purposes 53 | of this License, Derivative Works shall not include works that remain 54 | separable from, or merely link (or bind by name) to the interfaces of, 55 | the Work and Derivative Works thereof. 56 | 57 | "Contribution" shall mean any work of authorship, including 58 | the original version of the Work and any modifications or additions 59 | to that Work or Derivative Works thereof, that is intentionally 60 | submitted to Licensor for inclusion in the Work by the copyright owner 61 | or by an individual or Legal Entity authorized to submit on behalf of 62 | the copyright owner. For the purposes of this definition, "submitted" 63 | means any form of electronic, verbal, or written communication sent 64 | to the Licensor or its representatives, including but not limited to 65 | communication on electronic mailing lists, source code control systems, 66 | and issue tracking systems that are managed by, or on behalf of, the 67 | Licensor for the purpose of discussing and improving the Work, but 68 | excluding communication that is conspicuously marked or otherwise 69 | designated in writing by the copyright owner as "Not a Contribution." 70 | 71 | "Contributor" shall mean Licensor and any individual or Legal Entity 72 | on behalf of whom a Contribution has been received by Licensor and 73 | subsequently incorporated within the Work. 74 | 75 | 2. Grant of Copyright License. Subject to the terms and conditions of 76 | this License, each Contributor hereby grants to You a perpetual, 77 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 78 | copyright license to reproduce, prepare Derivative Works of, 79 | publicly display, publicly perform, sublicense, and distribute the 80 | Work and such Derivative Works in Source or Object form. 81 | 82 | 3. Grant of Patent License. Subject to the terms and conditions of 83 | this License, each Contributor hereby grants to You a perpetual, 84 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 85 | (except as stated in this section) patent license to make, have made, 86 | use, offer to sell, sell, import, and otherwise transfer the Work, 87 | where such license applies only to those patent claims licensable 88 | by such Contributor that are necessarily infringed by their 89 | Contribution(s) alone or by combination of their Contribution(s) 90 | with the Work to which such Contribution(s) was submitted. If You 91 | institute patent litigation against any entity (including a 92 | cross-claim or counterclaim in a lawsuit) alleging that the Work 93 | or a Contribution incorporated within the Work constitutes direct 94 | or contributory patent infringement, then any patent licenses 95 | granted to You under this License for that Work shall terminate 96 | as of the date such litigation is filed. 97 | 98 | 4. Redistribution. You may reproduce and distribute copies of the 99 | Work or Derivative Works thereof in any medium, with or without 100 | modifications, and in Source or Object form, provided that You 101 | meet the following conditions: 102 | 103 | (a) You must give any other recipients of the Work or 104 | Derivative Works a copy of this License; and 105 | 106 | (b) You must cause any modified files to carry prominent notices 107 | stating that You changed the files; and 108 | 109 | (c) You must retain, in the Source form of any Derivative Works 110 | that You distribute, all copyright, patent, trademark, and 111 | attribution notices from the Source form of the Work, 112 | excluding those notices that do not pertain to any part of 113 | the Derivative Works; and 114 | 115 | (d) If the Work includes a "NOTICE" text file as part of its 116 | distribution, then any Derivative Works that You distribute must 117 | include a readable copy of the attribution notices contained 118 | within such NOTICE file, excluding those notices that do not 119 | pertain to any part of the Derivative Works, in at least one 120 | of the following places: within a NOTICE text file distributed 121 | as part of the Derivative Works; within the Source form or 122 | documentation, if provided along with the Derivative Works; or, 123 | within a display generated by the Derivative Works, if and 124 | wherever such third-party notices normally appear. The contents 125 | of the NOTICE file are for informational purposes only and 126 | do not modify the License. You may add Your own attribution 127 | notices within Derivative Works that You distribute, alongside 128 | or as an addendum to the NOTICE text from the Work, provided 129 | that such additional attribution notices cannot be construed 130 | as modifying the License. 131 | 132 | You may add Your own copyright statement to Your modifications and 133 | may provide additional or different license terms and conditions 134 | for use, reproduction, or distribution of Your modifications, or 135 | for any such Derivative Works as a whole, provided Your use, 136 | reproduction, and distribution of the Work otherwise complies with 137 | the conditions stated in this License. 138 | 139 | 5. Submission of Contributions. Unless You explicitly state otherwise, 140 | any Contribution intentionally submitted for inclusion in the Work 141 | by You to the Licensor shall be under the terms and conditions of 142 | this License, without any additional terms or conditions. 143 | Notwithstanding the above, nothing herein shall supersede or modify 144 | the terms of any separate license agreement you may have executed 145 | with Licensor regarding such Contributions. 146 | 147 | 6. Trademarks. This License does not grant permission to use the trade 148 | names, trademarks, service marks, or product names of the Licensor, 149 | except as required for reasonable and customary use in describing the 150 | origin of the Work and reproducing the content of the NOTICE file. 151 | 152 | 7. Disclaimer of Warranty. Unless required by applicable law or 153 | agreed to in writing, Licensor provides the Work (and each 154 | Contributor provides its Contributions) on an "AS IS" BASIS, 155 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 156 | implied, including, without limitation, any warranties or conditions 157 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 158 | PARTICULAR PURPOSE. You are solely responsible for determining the 159 | appropriateness of using or redistributing the Work and assume any 160 | risks associated with Your exercise of permissions under this License. 161 | 162 | 8. Limitation of Liability. In no event and under no legal theory, 163 | whether in tort (including negligence), contract, or otherwise, 164 | unless required by applicable law (such as deliberate and grossly 165 | negligent acts) or agreed to in writing, shall any Contributor be 166 | liable to You for damages, including any direct, indirect, special, 167 | incidental, or consequential damages of any character arising as a 168 | result of this License or out of the use or inability to use the 169 | Work (including but not limited to damages for loss of goodwill, 170 | work stoppage, computer failure or malfunction, or any and all 171 | other commercial damages or losses), even if such Contributor 172 | has been advised of the possibility of such damages. 173 | 174 | 9. Accepting Warranty or Additional Liability. While redistributing 175 | the Work or Derivative Works thereof, You may choose to offer, 176 | and charge a fee for, acceptance of support, warranty, indemnity, 177 | or other liability obligations and/or rights consistent with this 178 | License. However, in accepting such obligations, You may act only 179 | on Your own behalf and on Your sole responsibility, not on behalf 180 | of any other Contributor, and only if You agree to indemnify, 181 | defend, and hold each Contributor harmless for any liability 182 | incurred by, or claims asserted against, such Contributor by reason 183 | of your accepting any such warranty or additional liability. 184 | """ 185 | 186 | from collections.abc import Mapping, MutableMapping 187 | from typing import TypeVar, Iterable 188 | 189 | 190 | class CaseInsensitiveDict[T](MutableMapping[str, T]): 191 | """ 192 | A case-insensitive ``dict``-like object. 193 | 194 | Implements all methods and operations of 195 | ``collections.MutableMapping`` as well as dict's ``copy``. Also 196 | provides ``lower_items``. 197 | 198 | All keys are expected to be strings. The structure remembers the 199 | case of the last key to be set, and ``iter(instance)``, 200 | ``keys()``, ``items()``, ``iterkeys()``, and ``iteritems()`` 201 | will contain case-sensitive keys. However, querying and contains 202 | testing is case insensitive: 203 | 204 | cid = CaseInsensitiveDict() 205 | cid['Accept'] = 'application/json' 206 | cid['aCCEPT'] == 'application/json' # True 207 | list(cid) == ['Accept'] # True 208 | 209 | For example, ``headers['content-encoding']`` will return the 210 | value of a ``'Content-Encoding'`` response header, regardless 211 | of how the header name was originally stored. 212 | 213 | If the constructor, ``.update``, or equality comparison 214 | operations are given keys that have equal ``.lower()``s, the 215 | behavior is undefined. 216 | 217 | This class was copied from 218 | https://github.com/kennethreitz/requests/blob/v1.2.3/requests/structures.py 219 | and is licensed under the Apache License. 220 | """ 221 | 222 | def __init__(self, data=None, **kwargs): 223 | self._store: dict[str, tuple[str, T]] = dict() 224 | if data is None: 225 | data = {} 226 | self.update(data, **kwargs) 227 | 228 | def __contains__(self, __key: object) -> bool: 229 | if hasattr(__key, "lower"): 230 | return super().__contains__(__key) 231 | return False 232 | 233 | def __setitem__(self, key: str, value): 234 | # Use the lowercased key for lookups, but store the actual 235 | # key alongside the value. 236 | self._store[key.lower()] = (key, value) 237 | 238 | def __getitem__(self, key: str) -> T: 239 | return self._store[key.lower()][1] 240 | 241 | def __delitem__(self, key: str): 242 | del self._store[key.lower()] 243 | 244 | def __iter__(self): 245 | return (casedkey for casedkey, mappedvalue in self._store.values()) 246 | 247 | def __len__(self) -> int: 248 | return len(self._store) 249 | 250 | def lower_items(self): 251 | """Like iteritems(), but with all lowercase keys.""" 252 | return ((lowerkey, keyval[1]) for (lowerkey, keyval) in self._store.items()) 253 | 254 | def __eq__(self, other): 255 | if isinstance(other, Mapping): 256 | other = CaseInsensitiveDict(other) 257 | else: 258 | return NotImplemented 259 | # Compare insensitively 260 | return dict(self.lower_items()) == dict(other.lower_items()) 261 | 262 | # Copy is required 263 | def copy(self) -> "CaseInsensitiveDict[T]": 264 | return CaseInsensitiveDict(self._store.values()) 265 | 266 | def __repr__(self) -> str: 267 | return "%s(%r)" % (self.__class__.__name__, dict(self.items())) 268 | 269 | 270 | class Singleton(object): 271 | """Use to create a singleton""" 272 | 273 | def __new__(cls, *args, **kwds): 274 | """ 275 | >>> s = Singleton() 276 | >>> p = Singleton() 277 | >>> id(s) == id(p) 278 | True 279 | """ 280 | it_id = "__it__" 281 | # getattr will dip into base classes, so __dict__ must be used 282 | it = cls.__dict__.get(it_id, None) 283 | if it is not None: 284 | return it 285 | it = object.__new__(cls) 286 | setattr(cls, it_id, it) 287 | it.__init__(*args, **kwds) 288 | return it 289 | 290 | def __init__(self, *args, **kwds): 291 | pass 292 | 293 | 294 | class Pagination: 295 | def __init__(self, items: list, per_page: int = 100, page: int = 1) -> None: 296 | self.total = None 297 | self.total_items = items 298 | self.total = len(items) 299 | total_pages = ceildiv(max(self.total, 1), per_page) 300 | 301 | self.has_next = page < total_pages 302 | if self.has_next: 303 | self.next_num = page + 1 304 | else: 305 | self.next_num = None 306 | 307 | if self.total == 0: 308 | self.first = 0 309 | else: 310 | self.first = (page - 1) * per_page + 1 311 | 312 | self.items = {x["st"]: x["et"] for x in self.total_items[self.first : self.first + per_page]} 313 | 314 | self.per_page = per_page 315 | self.page = page 316 | if page == 1: 317 | self.has_prev = False 318 | self.prev_num = None 319 | else: 320 | self.has_prev = True 321 | self.prev_num = page - 1 322 | 323 | def prev(self, error_out=False) -> "Pagination": 324 | return Pagination(self.total_items, self.per_page, self.page - 1) 325 | 326 | def next(self, error_out=False) -> "Pagination": 327 | return Pagination(self.total_items, self.per_page, self.page + 1) 328 | 329 | def iter_pages(self, *, left_edge=2, left_current=2, right_current=4, right_edge=2) -> Iterable[int | None]: 330 | total = self.total if self.total else 1 331 | total_pages = ceildiv(total, self.per_page) 332 | 333 | pages: list[int | None] = [i for i in range(1, 1 + left_edge) if i <= total_pages] 334 | 335 | for i in range(self.page - left_current, self.per_page + right_current + 1): 336 | if i in pages or i < 0: 337 | continue 338 | if i > total_pages: 339 | break 340 | if pages[-1] and i > pages[-1] + 1: 341 | pages.append(None) 342 | pages.append(i) 343 | 344 | for i in range(total_pages - right_edge, total_pages + 1): 345 | if i in pages: 346 | continue 347 | if pages[-1] and i > pages[-1] + 1: 348 | pages.append(None) 349 | pages.append(i) 350 | 351 | return pages 352 | 353 | 354 | def ceildiv(a: int, b: int) -> int: 355 | "Same as `a // b` except result is rounded up not down" 356 | return -(a // -b) 357 | -------------------------------------------------------------------------------- /utils/db.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any 3 | 4 | import sqlalchemy.exc 5 | from flask_migrate import upgrade as fm_upgrade 6 | from flask_sqlalchemy import SQLAlchemy 7 | from sqlalchemy import MetaData, String, Integer, ForeignKey, Column, Boolean, text 8 | from sqlalchemy.orm import DeclarativeBase, mapped_column, Mapped 9 | 10 | __schema__ = 2 11 | 12 | 13 | class Base(DeclarativeBase): 14 | metadata = MetaData( 15 | naming_convention={ 16 | "ix": "ix_%(column_0_label)s", 17 | "uq": "uq_%(table_name)s_%(column_0_name)s", 18 | "ck": "ck_%(table_name)s_%(constraint_name)s", 19 | "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", 20 | "pk": "pk_%(table_name)s", 21 | } 22 | ) 23 | 24 | 25 | db = SQLAlchemy(model_class=Base) 26 | 27 | 28 | def upgrade(): 29 | logger = logging.getLogger(__name__) 30 | base_rev = '7990cc760362' 31 | t = text(f"INSERT INTO alembic_version VALUES('{base_rev}')") 32 | with db.engine.connect() as con: 33 | with con.begin(): 34 | # con.execute(text("PRAGMA foreign_keys = ON")) # sqlite ignores foreign keys otherwise 35 | try: 36 | stmt = text("SELECT version_num FROM alembic_version") 37 | result = con.execute(stmt).first()[0] 38 | logger.debug(f"DB revision: {result}") 39 | except TypeError: 40 | con.execute(t) 41 | except sqlalchemy.exc.OperationalError: 42 | try: 43 | con.execute(text("SELECT COUNT(*) FROM stash_tag")).first() # Confirm that DB is not empty 44 | logger.debug("Initializing alembic_version table") 45 | con.execute(text("""CREATE TABLE alembic_version ( 46 | version_num VARCHAR(32) NOT NULL, 47 | CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num) 48 | )""")) 49 | con.execute(t) 50 | except sqlalchemy.exc.OperationalError: 51 | pass # DB was empty, so allow Alembic to create 52 | fm_upgrade() 53 | con.execute(text("VACUUM")) 54 | con.commit() 55 | 56 | 57 | emp_tag_map = db.Table( 58 | "emp_tags", 59 | Column("stashtag_id", 60 | ForeignKey("stash_tag.id", ondelete="CASCADE"), primary_key=True), 61 | Column("emptag_id", 62 | ForeignKey("gazelle_tags.id", ondelete="CASCADE"), primary_key=True) 63 | ) 64 | 65 | hf_tag_map = db.Table( 66 | "hf_tags", 67 | Column("stashtag_id", 68 | ForeignKey("stash_tag.id", ondelete="CASCADE"), primary_key=True), 69 | Column("hftag_id", 70 | ForeignKey("gazelle_tags.id", ondelete="CASCADE"), primary_key=True) 71 | ) 72 | 73 | fc_tag_map = db.Table( 74 | "fc_tags", 75 | Column("stashtag_id", 76 | ForeignKey("stash_tag.id", ondelete="CASCADE"), primary_key=True), 77 | Column("fctag_id", 78 | ForeignKey("gazelle_tags.id", ondelete="CASCADE"), primary_key=True) 79 | ) 80 | 81 | pb_tag_map = db.Table( 82 | "pb_tags", 83 | Column("stashtag_id", 84 | ForeignKey("stash_tag.id", ondelete="CASCADE"), primary_key=True), 85 | Column("pbtag_id", 86 | ForeignKey("gazelle_tags.id", ondelete="CASCADE"), primary_key=True) 87 | ) 88 | 89 | ent_tag_map = db.Table( 90 | "ent_tags", 91 | Column("stashtag_id", 92 | ForeignKey("stash_tag.id", ondelete="CASCADE"), primary_key=True), 93 | Column("enttag_id", 94 | ForeignKey("gazelle_tags.id", ondelete="CASCADE"), primary_key=True) 95 | ) 96 | 97 | # Default tag maps to be used if tracker-specific maps are unavailable 98 | def_tag_map = db.Table( 99 | "def_tags", 100 | Column("stashtag_id", 101 | ForeignKey("stash_tag.id", ondelete="CASCADE"), primary_key=True), 102 | Column("gazelletag_id", 103 | ForeignKey("gazelle_tags.id", ondelete="CASCADE"), primary_key=True) 104 | ) 105 | 106 | list_tags = db.Table( 107 | "tag_categories", 108 | Column("stashtag", ForeignKey("stash_tag.id", ondelete="CASCADE"), primary_key=True), 109 | Column("category", ForeignKey("category.id", ondelete="CASCADE"), primary_key=True), 110 | ) 111 | 112 | 113 | class GazelleTag(db.Model): 114 | __tablename__ = "gazelle_tags" 115 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 116 | tagname: Mapped[str] = mapped_column(String(32), unique=True, nullable=False) 117 | emp_stash_tags: Mapped[list["StashTag"]] = db.relationship(secondary=emp_tag_map, 118 | back_populates="emp_tags", 119 | passive_deletes=True) # type: ignore 120 | hf_stash_tags: Mapped[list["StashTag"]] = db.relationship(secondary=hf_tag_map, 121 | back_populates="hf_tags", 122 | passive_deletes=True) # type: ignore 123 | fc_stash_tags: Mapped[list["StashTag"]] = db.relationship(secondary=fc_tag_map, 124 | back_populates="fc_tags", 125 | passive_deletes=True) # type: ignore 126 | pb_stash_tags: Mapped[list["StashTag"]] = db.relationship(secondary=pb_tag_map, 127 | back_populates="pb_tags", 128 | passive_deletes=True) # type: ignore 129 | ent_stash_tags: Mapped[list["StashTag"]] = db.relationship(secondary=ent_tag_map, 130 | back_populates="ent_tags", 131 | passive_deletes=True) # type: ignore 132 | def_stash_tags: Mapped[list["StashTag"]] = db.relationship(secondary=def_tag_map, 133 | back_populates="def_tags", 134 | passive_deletes=True) # type: ignore 135 | 136 | 137 | class StashTag(db.Model): 138 | __tablename__ = "stash_tag" 139 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 140 | tagname: Mapped[str] = mapped_column(String(collation="NOCASE"), unique=True, nullable=False) 141 | display: Mapped[str] = mapped_column(String, nullable=True) 142 | ignored: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) 143 | emp_tags: Mapped[list[GazelleTag]] = db.relationship("GazelleTag", secondary=emp_tag_map, 144 | back_populates="emp_stash_tags", 145 | passive_deletes=True) # type: ignore 146 | hf_tags: Mapped[list[GazelleTag]] = db.relationship("GazelleTag", secondary=hf_tag_map, 147 | back_populates="hf_stash_tags", 148 | passive_deletes=True) # type: ignore 149 | fc_tags: Mapped[list[GazelleTag]] = db.relationship("GazelleTag", secondary=fc_tag_map, 150 | back_populates="fc_stash_tags", 151 | passive_deletes=True) # type: ignore 152 | pb_tags: Mapped[list[GazelleTag]] = db.relationship("GazelleTag", secondary=pb_tag_map, 153 | back_populates="pb_stash_tags", 154 | passive_deletes=True) # type: ignore 155 | ent_tags: Mapped[list[GazelleTag]] = db.relationship("GazelleTag", secondary=ent_tag_map, 156 | back_populates="ent_stash_tags", 157 | passive_deletes=True) # type: ignore 158 | def_tags: Mapped[list[GazelleTag]] = db.relationship("GazelleTag", secondary=def_tag_map, 159 | back_populates="def_stash_tags", 160 | passive_deletes=True) # type: ignore 161 | categories: Mapped[list["Category"]] = db.relationship("Category", secondary=list_tags, 162 | back_populates="tags", 163 | passive_deletes=True) # type: ignore 164 | 165 | 166 | class Category(db.Model): 167 | __tablename__ = "category" 168 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 169 | name: Mapped[str] = mapped_column(String, unique=True, nullable=False) 170 | tags: Mapped[list[StashTag]] = db.relationship(secondary=list_tags, back_populates="categories", 171 | passive_deletes=True) # type: ignore 172 | 173 | 174 | def get_or_create[T](model: type[T], **kwargs) -> T: 175 | session = db.session 176 | with session.no_autoflush: 177 | instance = session.query(model).filter_by(**kwargs).first() 178 | if instance: 179 | return instance 180 | else: 181 | instance = model(**kwargs) 182 | session.add(instance) 183 | session.commit() 184 | return instance 185 | 186 | 187 | def get_or_create_no_commit[T](model: type[T], **kwargs) -> T: 188 | session = db.session 189 | with session.no_autoflush: 190 | instance = session.query(model).filter_by(**kwargs).first() 191 | if instance: 192 | return instance 193 | else: 194 | instance = model(**kwargs) 195 | session.add(instance) 196 | # session.flush() 197 | return instance 198 | 199 | 200 | def to_dict() -> dict[str, Any]: 201 | categories: list[Category] = Category.query.all() 202 | s_tags: list[StashTag] = StashTag.query.all() 203 | g_tags: list[GazelleTag] = GazelleTag.query.all() 204 | data = {"revision": "", "stash_tags": [], "gazelle_tags": [], "categories": []} 205 | for tag in s_tags: 206 | stag = { 207 | "id": tag.id, 208 | "name": tag.tagname, 209 | "display": tag.display, 210 | "ignored": tag.ignored, 211 | "defaults": [e.id for e in tag.def_tags], 212 | "emp_tags": [e.id for e in tag.emp_tags], 213 | "hf_tags": [h.id for h in tag.hf_tags], 214 | "fc_tags": [h.id for h in tag.fc_tags], 215 | "pb_tags": [h.id for h in tag.pb_tags], 216 | "ent_tags": [h.id for h in tag.ent_tags], 217 | "categories": [c.id for c in tag.categories], 218 | } 219 | data["stash_tags"].append(stag) 220 | 221 | for cat in categories: 222 | category = {"id": cat.id, "name": cat.name} 223 | data["categories"].append(category) 224 | 225 | for tag in g_tags: 226 | etag = {"id": tag.id, "name": tag.tagname} 227 | data["gazelle_tags"].append(etag) 228 | 229 | rev = db.session.execute(text("SELECT version_num FROM alembic_version")).first()[0] 230 | data['revision'] = rev 231 | 232 | return data 233 | 234 | 235 | def from_dict(data: dict[str, Any]) -> None: 236 | rev = db.session.execute(text("SELECT version_num FROM alembic_version")).first()[0] 237 | if data["revision"] != rev: 238 | raise ValueError("Schema version mismatch") 239 | 240 | with db.session.begin(): 241 | # 1. Delete existing records 242 | Category.query.delete() 243 | StashTag.query.delete() 244 | GazelleTag.query.delete() 245 | 246 | # 2. Categories 247 | for cat in data["categories"]: 248 | db.session.add(Category(id=cat["id"], name=cat["name"])) # type: ignore 249 | db.session.flush() 250 | 251 | # 3. EMP Tags 252 | for tag in data["gazelle_tags"]: 253 | db.session.add(GazelleTag(id=tag["id"], tagname=tag["name"])) # type: ignore 254 | db.session.flush() 255 | 256 | # 4. Stash Tags 257 | for tag in data["stash_tags"]: 258 | stag = StashTag( 259 | id=tag["id"], tagname=tag["name"], ignored=tag["ignored"], display=tag["display"] 260 | ) # type: ignore 261 | db.session.add(stag) 262 | for tag_id in tag["categories"]: 263 | cat = Category.query.filter_by(id=tag_id).first() 264 | assert cat is not None 265 | stag.categories.append(cat) 266 | for tag_id in tag["defaults"]: 267 | etag = GazelleTag.query.filter_by(id=tag_id).first() 268 | assert etag is not None 269 | stag.def_tags.append(etag) 270 | for tag_id in tag["emp_tags"]: 271 | etag = GazelleTag.query.filter_by(id=tag_id).first() 272 | assert etag is not None 273 | stag.emp_tags.append(etag) 274 | for tag_id in tag["hf_tags"]: 275 | etag = GazelleTag.query.filter_by(id=tag_id).first() 276 | assert etag is not None 277 | stag.hf_tags.append(etag) 278 | for tag_id in tag["fc_tags"]: 279 | etag = GazelleTag.query.filter_by(id=tag_id).first() 280 | assert etag is not None 281 | stag.fc_tags.append(etag) 282 | for tag_id in tag["pb_tags"]: 283 | etag = GazelleTag.query.filter_by(id=tag_id).first() 284 | assert etag is not None 285 | stag.pb_tags.append(etag) 286 | for tag_id in tag["ent_tags"]: 287 | etag = GazelleTag.query.filter_by(id=tag_id).first() 288 | assert etag is not None 289 | stag.ent_tags.append(etag) 290 | -------------------------------------------------------------------------------- /utils/packs.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import tempfile 4 | from typing import Any 5 | from zipfile import ZipFile 6 | 7 | from utils.confighandler import ConfigHandler 8 | from utils.paths import mapPath 9 | 10 | conf = ConfigHandler() 11 | filetypes = tuple(s if s.startswith(".") else "." + s for s in 12 | conf.get("backend", "image_formats", ["jpg", "jpeg", "png"])) # type: ignore 13 | 14 | 15 | def prep_dir(directory: str): 16 | if not os.path.exists(directory): 17 | os.makedirs(directory) 18 | elif not os.path.isdir(directory): 19 | raise ValueError(f"Cannot save files to {directory}: destination is not a directory!") 20 | 21 | 22 | def link(source: str, dest: str): 23 | """ 24 | Create a link in the dest directory pointing to source. 25 | """ 26 | prep_dir(dest) 27 | basename = os.path.basename(source) 28 | method = conf.get("backend", "move_method") 29 | try: 30 | match method: 31 | case "hardlink": 32 | os.link(source, os.path.join(dest, basename)) 33 | case "symlink": 34 | os.symlink(source, os.path.join(dest, basename)) 35 | case "copy": 36 | shutil.copy(source, dest) 37 | case _: 38 | raise ValueError("move_method must be one of 'hardlink', 'symlink', or 'copy'") 39 | except FileExistsError: 40 | pass 41 | 42 | 43 | def unzip(source: str, dest: str) -> list[str]: 44 | """ 45 | Extracts all image files from `source` and places them in `dest` 46 | :param source: The file to unzip 47 | :param dest: The destination directory for unzipped files 48 | :return: A list containing the paths of all extracted files 49 | """ 50 | prep_dir(dest) 51 | with ZipFile(source) as z: 52 | files = z.namelist() 53 | if ".*" not in filetypes: 54 | files = [file for file in files if file.endswith(filetypes)] 55 | z.extractall(dest, files) 56 | return [os.path.join(dest, file) for file in files] 57 | 58 | 59 | def zip_files(files: list[str], dest: str, name: str): 60 | if not name.endswith(".zip"): 61 | name = name + ".zip" 62 | prep_dir(dest) 63 | with ZipFile(os.path.join(dest, name), "w") as z: 64 | for file in files: 65 | basename = os.path.basename(file) 66 | z.write(file, basename) 67 | 68 | 69 | def get_torrent_directory(scene: dict[str, Any]) -> str | None: 70 | """ 71 | Returns a directory name for a given Stash scene. 72 | :param scene: 73 | :raises: ValueError if media_directory not specified in config 74 | :return: The path for the torrent contents 75 | """ 76 | dirname: str = conf.get("backend", "media_directory") # type: ignore 77 | if not dirname: 78 | raise ValueError("media_directory not specified in config") 79 | if scene["title"]: 80 | dirname = os.path.join(dirname, scene["title"]) 81 | else: 82 | title = ".".join(scene["files"][0]["basename"].split(".")[:-1]) 83 | dirname = os.path.join(dirname, title) 84 | return dirname 85 | 86 | 87 | def read_gallery(scene: dict[str, Any]) -> tuple[str, str, bool] | None: 88 | """ 89 | Find gallery associated with a scene. If one is present, copy 90 | (by hard or soft link) its files to the media_directory specified 91 | in config.toml. 92 | 93 | Additionally, if the gallery is a zip file, extract the images to a 94 | temporary directory to allow a contact sheet to be generated later. 95 | 96 | Returns a tuple containing the directory for torrent creation, the 97 | directory where image files are located, and a boolean indicating 98 | whether the image files are in a temporary directory (requiring cleanup) 99 | """ 100 | if len(scene["galleries"]) < 1: 101 | return 102 | dirname = get_torrent_directory(scene) 103 | image_dir = os.path.join(dirname, "Gallery") 104 | temp = False 105 | gallery = scene["galleries"][0] 106 | if gallery["folder"]: 107 | source_dir = mapPath(gallery["folder"]["path"], conf.items("file.maps")) 108 | os.makedirs(image_dir, exist_ok=True) 109 | for file in os.listdir(source_dir): 110 | link(os.path.join(source_dir, file), image_dir) 111 | elif gallery["files"]: 112 | temp = True 113 | zip_file = mapPath(gallery["files"][0]["path"], conf.items("file.maps")) 114 | source_dir = tempfile.mkdtemp() 115 | files = unzip(zip_file, source_dir) 116 | for file in files: 117 | os.chmod(file, 0o666) # Ensures torrent client can read the file 118 | shutil.copy(file, image_dir) 119 | else: 120 | return 121 | return dirname, source_dir, temp 122 | -------------------------------------------------------------------------------- /utils/paths.py: -------------------------------------------------------------------------------- 1 | def mapPath(path: str, pathmaps: dict[str,str]) -> str: 2 | # Apply remote path mappings 3 | for remote, local in pathmaps.items(): 4 | if not path.startswith(remote): 5 | continue 6 | if remote[-1] != "/": 7 | remote += "/" 8 | if local[-1] != "/": 9 | local += "/" 10 | path = local + path.removeprefix(remote) 11 | break 12 | return path -------------------------------------------------------------------------------- /utils/taghandler.py: -------------------------------------------------------------------------------- 1 | """This module provides an object for storing and processing stash scene tags 2 | for uploading to empornium.""" 3 | 4 | import json 5 | import logging 6 | import os 7 | import re 8 | from collections.abc import MutableMapping 9 | from typing import Literal 10 | 11 | import tomlkit 12 | from flask import Flask 13 | from tomlkit.items import AbstractTable 14 | 15 | from utils.confighandler import ConfigHandler 16 | from utils.customtypes import CaseInsensitiveDict 17 | from utils.db import db, StashTag, GazelleTag, get_or_create, get_or_create_no_commit, Category 18 | 19 | HAIR_COLOR_MAP = CaseInsensitiveDict( 20 | { 21 | "blonde": "blonde", 22 | "blond": "blonde", 23 | "black": "black.hair", 24 | "brown": "brunette", 25 | "brunette": "brunette", 26 | "red": "redhead", 27 | "auburn": "auburn.hair", 28 | "grey": "grey.hair", 29 | } 30 | ) 31 | 32 | ETHNICITY_MAP = CaseInsensitiveDict( 33 | { 34 | "caucasian": "caucasian", 35 | "black": "black", 36 | "asian": "asian", 37 | "mixed": "mixed.race", 38 | "latin": "latina", 39 | "middle eastern": "middle.eastern", 40 | "indian": "indian", 41 | } 42 | ) 43 | 44 | DEMONYMS: dict[str, list[str]] = {} 45 | 46 | 47 | def empify(tag: str) -> str: 48 | """Return an EMP-compatible tag for a given input 49 | tag. This function replaces all whitespace and 50 | some special characters with a '.' and strips out 51 | all other characters that are not alphanumeric 52 | before finally converting the full string to 53 | lowercase.""" 54 | logger = logging.getLogger(__name__) 55 | new_tag = re.sub(r"[^\w\s._-]", "", tag).lower() # remove most special characters 56 | new_tag = re.sub(r"[\s._-]+", ".", new_tag) # replace remaining special chars and whitespace with '.' 57 | new_tag = new_tag[:32] # truncate to max length 58 | logger.debug(f"Reformatted tag '{tag}' to '{new_tag}'") 59 | return new_tag 60 | 61 | 62 | def query_maps(page=1, per_page=50): 63 | return StashTag.query.paginate(page=page, per_page=per_page) 64 | 65 | 66 | class TagHandler: 67 | conf: ConfigHandler 68 | tag_sets: dict[str, set] = {} 69 | countries = [] 70 | cup_sizes: dict[str, tuple[int, Literal[-1, 0, 1]]] = {} 71 | 72 | def __init__(self) -> None: 73 | """Initialize a TagHandler object from a config object.""" 74 | # Set of tags to apply to the current scene 75 | self.tags: set[str] = set() 76 | 77 | # Dict of autogenerated tag suggestions 78 | self.tag_suggestions: CaseInsensitiveDict[str] = CaseInsensitiveDict() 79 | 80 | self.conf: ConfigHandler = ConfigHandler() # type: ignore 81 | for key in Category.query.all(): 82 | self.tag_sets[key.name] = set() 83 | 84 | if "performers" in self.conf: 85 | t = self.conf["performers"] 86 | if isinstance(t, AbstractTable): 87 | if "cup_sizes" in t: 88 | sizes = t["cup_sizes"] 89 | if isinstance(sizes, AbstractTable): 90 | op: Literal[-1, 0, 1] 91 | for tag in sizes: 92 | size = sizes[tag].as_string() 93 | op = 0 94 | if '-' in size or '<' in size: 95 | op = -1 96 | elif '+' in size or '>' in size: 97 | op = 1 98 | size = self.process_tits(size) 99 | self.cup_sizes[tag] = (size, op) 100 | 101 | with open("countries.json") as c: 102 | self.countries = json.load(c) 103 | 104 | def sort_tag_list(self, tagset: str) -> list[str]: 105 | """Return a sorted list for a given 106 | tag set name, or an empty list if 107 | the requested set does not exist.""" 108 | if tagset in self.tag_sets: 109 | tmp = list(self.tag_sets[tagset]) 110 | tmp.sort() 111 | return tmp 112 | return [] 113 | 114 | def sort_tag_lists(self) -> dict[str, list[str]]: 115 | """Returns a dictionary where the keys are 116 | the names of custom lists and the associated 117 | values are the sorted lists of tags from the 118 | current scene.""" 119 | return {key: self.sort_tag_list(key) for key in self.tag_sets} 120 | 121 | def process_tag(self, tag: str, tracker: str) -> None: 122 | """Check for the appropriate EMP tag 123 | mapping for a provided tag and add it to 124 | the working lists, or generate a suggested 125 | mapping.""" 126 | s_tag: StashTag 127 | s_tag = get_or_create(StashTag, tagname=tag) 128 | if s_tag.ignored: 129 | return 130 | tag_list: list[GazelleTag] 131 | match tracker: 132 | case "EMP": 133 | tag_list = s_tag.emp_tags 134 | case "PB": 135 | tag_list = s_tag.pb_tags 136 | case "FC": 137 | tag_list = s_tag.fc_tags 138 | case "HF": 139 | tag_list = s_tag.hf_tags 140 | case "ENT": 141 | tag_list = s_tag.ent_tags 142 | case _: 143 | raise ValueError('Tracker must be one of ["EMP", "PB", "FC", "HF", "ENT"]') 144 | if len(tag_list) == 0: 145 | tag_list = s_tag.def_tags 146 | if len(tag_list) == 0: 147 | self.tag_suggestions[tag] = empify(tag) 148 | else: 149 | for e_tag in tag_list: 150 | self.tags.add(e_tag.tagname) 151 | for cat in s_tag.categories: 152 | self.tag_sets[cat.name].add(s_tag.display if s_tag.display else tag) 153 | 154 | def process_performer(self, performer: dict, tracker: str) -> str: 155 | # also include alias tags? 156 | logger = logging.getLogger(__name__) 157 | logger.debug(performer) 158 | performer_tag = empify(performer["name"]) 159 | self.tags.add(performer_tag) 160 | gender = performer["gender"] if performer["gender"] else "FEMALE" # Should this default be configurable? 161 | for tag in performer["tags"]: 162 | self.process_tag(tag["name"], tracker) 163 | if len(self.countries) > 0: 164 | cca2 = performer["country"] 165 | g = "m" if (gender == "MALE" or gender == "TRANSGENDER_MALE") else "f" 166 | if len(cca2) > 0: 167 | for country in self.countries: 168 | if country["cca2"] == cca2: 169 | self.tags.add(empify(country["demonyms"]["eng"][g])) 170 | if cca2 in DEMONYMS: 171 | logger.debug(f"Found demonyms {DEMONYMS[cca2]} for performer {performer['name']}") 172 | self.tags.update(DEMONYMS[cca2]) 173 | if "circumcised" in performer: 174 | if performer["circumcised"] == "CUT": 175 | self.tags.add("circumcised.cock") 176 | elif performer["circumcised"] == "UNCUT": 177 | self.tags.add("uncircumcised.cock") 178 | if gender not in ("MALE", "TRANSGENDER_MALE"): 179 | if self.conf["performers"]["tag_ethnicity"] and "ethnicity" in performer and performer[ 180 | "ethnicity"] in ETHNICITY_MAP: # type: ignore 181 | self.add(ETHNICITY_MAP[performer["ethnicity"]]) 182 | if self.conf["performers"]["tag_eye_color"] and "eye_color" in performer and len( 183 | performer["eye_color"]) > 0: # type: ignore 184 | self.add(performer["eye_color"] + ".eyes") 185 | if self.conf["performers"]["tag_hair_color"] and "hair_color" in performer and performer[ 186 | "hair_color"] in HAIR_COLOR_MAP: # type: ignore 187 | self.add(HAIR_COLOR_MAP[performer["hair_color"]]) 188 | tattoos = "tattoos" in performer and len(performer["tattoos"]) > 0 189 | piercings = "piercings" in performer and len(performer["piercings"]) > 0 190 | if tattoos: 191 | self.add("tattoo") 192 | if gender.lower() == "female": 193 | self.add("tattooed.female") 194 | if piercings: 195 | self.add("piercings") 196 | if tattoos and piercings: 197 | self.add("tattoo.and.piercing") 198 | fake_tits = "" 199 | if "fake_tits" in performer: 200 | fake_tits = performer["fake_tits"].lower() 201 | if fake_tits == "natural": 202 | self.add("natural.tits") 203 | elif fake_tits == "augmented" or fake_tits == "fake": 204 | self.add("fake.tits") 205 | if "measurements" in performer and len(performer["measurements"]) > 0: 206 | tits = self.process_tits(performer["measurements"], fake_tits) 207 | if tits >= 0: 208 | for key, (value, op) in self.cup_sizes.items(): 209 | match op: 210 | case -1: 211 | if tits <= value: 212 | self.add(key) 213 | case 0: 214 | if tits == value: 215 | self.add(key) 216 | case 1: 217 | if tits >= value: 218 | self.add(key) 219 | return performer_tag 220 | 221 | def process_tits(self, measurements: str, fake_tits: str = "") -> int: 222 | # TODO process size/type combo tags, e.g. big.natural.tits 223 | logger = logging.getLogger(__name__) 224 | cup_size = re.sub(r"[^A-Z]", "", measurements.upper()) 225 | if cup_size == "": 226 | logger.error(f"No cup size found in {measurements}") 227 | return -1 228 | self.add(f"{cup_size}.cup") 229 | if len(cup_size) > 1: 230 | letter = cup_size[0] 231 | if cup_size != len(cup_size) * letter or (letter != "A" and letter != "D"): 232 | logger.error(f"Invalid cup size {cup_size}") 233 | return -1 234 | if letter == "A": 235 | return 0 236 | return len(cup_size) + 3 # DD->5, DDD->6, etc 237 | return ord(cup_size) - 64 # A->1, B->2, C->3, etc 238 | 239 | def add(self, tag: str) -> str: 240 | """Convert a tag to en EMP-compatible 241 | version and add it to the main list, 242 | skipping the check for custom lists. 243 | Returns the EMP-compatible tag.""" 244 | tag = empify(tag) 245 | self.tags.add(tag) 246 | return tag 247 | 248 | def clear(self) -> None: 249 | """Reset the working tag sets without 250 | clearing the mapping or custom list 251 | definitions.""" 252 | for tagset in self.tag_sets: 253 | self.tag_sets[tagset].clear() 254 | self.tag_suggestions.clear() 255 | self.tags.clear() 256 | 257 | 258 | def db_init(app: Flask, tag_map: MutableMapping, tag_lists): 259 | logger = logging.getLogger(__name__) 260 | logger.info("Updating db") 261 | with app.app_context(): 262 | cats: dict[str, Category] = {} 263 | for cat in tag_lists: 264 | if cat == "ignored_tags": 265 | continue 266 | cats[cat] = get_or_create(Category, name=cat) 267 | for st, et in tag_map.items(): 268 | s_tag = get_or_create(StashTag, tagname=st) 269 | for tag in str(et).split(): 270 | e_tag = get_or_create(GazelleTag, tagname=tag) 271 | if e_tag not in s_tag.def_tags: 272 | s_tag.def_tags.append(e_tag) 273 | for cat, cat_obj in cats.items(): 274 | if st in tag_lists[cat] or st.lower() in tag_lists[cat]: 275 | if cat_obj not in s_tag.categories: 276 | s_tag.categories.append(cat_obj) 277 | for st in tag_lists["ignored_tags"]: 278 | s_tag = get_or_create(StashTag, tagname=st) 279 | s_tag.ignored = True 280 | db.session.commit() 281 | logger.info("Updated db") 282 | 283 | 284 | def setup(app: Flask): 285 | tags_toml = os.path.join(ConfigHandler().config_dir, "tags.toml") 286 | 287 | with open("default-tags.toml") as f: 288 | conf = tomlkit.load(f) 289 | 290 | if os.path.exists(tags_toml): 291 | with open(tags_toml) as f: 292 | conf2 = tomlkit.load(f) 293 | if 'empornium.tags' in conf2: 294 | conf2['empornium']['tags'] = conf2["empornium.tags"] # type: ignore 295 | conf.update(conf2) 296 | 297 | tag_map = CaseInsensitiveDict(conf["empornium"]["tags"]) # type: ignore 298 | tag_lists = {} 299 | for lst, tags in conf["empornium"].items(): # type: ignore 300 | if lst == "tags": 301 | continue 302 | tag_lists[lst] = tags 303 | 304 | db_init(app, tag_map, tag_lists) 305 | 306 | 307 | def accept_suggestions(tags: MutableMapping[str, str], tracker: str) -> None: 308 | """Adds the provided tag mappings to the db, 309 | creating the tags as required""" 310 | logger = logging.getLogger(__name__) 311 | logger.info("Saving tag mappings") 312 | logger.debug(f"Tags: {tags}") 313 | with db.session.begin(): 314 | for st, gt in tags.items(): 315 | s_tag = get_or_create_no_commit(StashTag, tagname=st) 316 | g_tags = [] 317 | for tag in gt.split(): 318 | g_tags.append(get_or_create_no_commit(GazelleTag, tagname=tag)) 319 | match tracker: 320 | case "EMP": 321 | s_tag.emp_tags = g_tags 322 | case "PB": 323 | s_tag.pb_tags = g_tags 324 | case "FC": 325 | s_tag.fc_tags = g_tags 326 | case "HF": 327 | s_tag.hf_tags = g_tags 328 | case "ENT": 329 | s_tag.ent_tags = g_tags 330 | case _: 331 | raise ValueError('Tracker must be one of ["EMP", "PB", "FC", "HF", "ENT"]') 332 | 333 | 334 | def reject_suggestions(tags: list[str]) -> None: 335 | """Marks all supplied tags as ignored""" 336 | logger = logging.getLogger(__name__) 337 | logger.debug(f"Ignoring tags: {tags}") 338 | for tag in tags: 339 | s_tag = get_or_create_no_commit(StashTag, tagname=tag) 340 | s_tag.ignored = True 341 | db.session.commit() 342 | -------------------------------------------------------------------------------- /utils/torrentclients.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from utils.paths import mapPath 3 | from utils import bencoder 4 | import os 5 | from xmlrpc import client 6 | import requests 7 | 8 | 9 | class TorrentClient: 10 | "Base torrent client class" 11 | pathmaps: dict[str, str] = {} 12 | hashes: dict[str, str] = {} 13 | logger: logging.Logger 14 | label: str = "" 15 | name: str = "Torrent Client" 16 | 17 | def __init__(self, settings: dict) -> None: 18 | self.logger = logging.getLogger(__name__) 19 | if "pathmaps" in settings: 20 | self.pathmaps = settings["pathmaps"] 21 | if "label" in settings: 22 | self.label = settings["label"] 23 | 24 | def add(self, torrent_path: str, file_path: str) -> None: 25 | if torrent_path not in TorrentClient.hashes: 26 | with open(torrent_path, "rb") as f: 27 | TorrentClient.hashes[torrent_path] = bencoder.infohash(f.read()) 28 | 29 | def start(self, torrent_path: str) -> None: 30 | raise NotImplementedError() 31 | 32 | def resume(self, infohash: str): 33 | raise NotImplementedError() 34 | 35 | def connected(self) -> bool: 36 | return True 37 | 38 | 39 | class RTorrent(TorrentClient): 40 | """Implements rtorrent's XMLRPC protocol to 41 | allow adding torrents""" 42 | 43 | server: client.Server 44 | name: str = "rTorrent" 45 | 46 | def __init__(self, settings: dict) -> None: 47 | super().__init__(settings) 48 | userstring = "" 49 | safe_userstring = "" 50 | if "username" in settings and len(settings["username"]) > 0: 51 | userstring = settings["username"] 52 | safe_userstring = userstring 53 | if "password" in settings and len(settings["password"]) > 0: 54 | userstring += ":" + settings["password"] 55 | safe_userstring += ":[REDACTED]" 56 | userstring += "@" 57 | safe_userstring += "@" 58 | host = settings["host"] 59 | ssl = settings["ssl"] 60 | if "port" not in settings: 61 | port = 443 if ssl else 8080 62 | else: 63 | port = settings["port"] 64 | uri = f"http{'s' if ssl else ''}://{userstring}{host}:{port}/{settings['path']}" 65 | self.server = client.Server(uri) 66 | if "password" in settings: 67 | uri = uri.replace(settings["password"], "[REDACTED]") 68 | self.logger.debug(f"Connecting to rtorrent at '{uri}'") 69 | 70 | def add(self, torrent_path: str, file_path: str) -> None: 71 | super().add(torrent_path, file_path) 72 | file_path = mapPath(file_path, self.pathmaps) 73 | dir = os.path.split(file_path)[0] 74 | self.logger.debug(f"Adding torrent {torrent_path} to directory {dir}") 75 | with open(torrent_path, "rb") as torrent: 76 | self.server.load.raw_verbose( 77 | "", 78 | client.Binary(torrent.read()), 79 | f"d.directory.set={dir}", 80 | f"d.custom1.set={self.label}", 81 | "d.check_hash=", 82 | ) 83 | self.logger.info("Torrent added to rTorrent") 84 | 85 | def start(self, torrent_path: str) -> None: 86 | if torrent_path in RTorrent.hashes: 87 | self.resume(RTorrent.hashes[torrent_path]) 88 | 89 | def resume(self, infohash: str): 90 | self.server.d.start(infohash.upper()) 91 | 92 | def connected(self) -> bool: 93 | try: 94 | self.server.system.listMethods() 95 | return True 96 | except: 97 | return False 98 | 99 | 100 | class Qbittorrent(TorrentClient): 101 | "Implements qBittorrent's WebUI API for adding torrents" 102 | url: str 103 | username: str 104 | password: str 105 | cookies = None 106 | host: str = "" 107 | torrent_dir: str 108 | logged_in: bool = False 109 | name: str = "qBittorrent" 110 | 111 | def __init__(self, settings: dict) -> None: 112 | super().__init__(settings) 113 | ssl = settings["ssl"] if "ssl" in settings else False 114 | self.username = settings["username"] 115 | if "port" in settings: 116 | port = settings["port"] 117 | else: 118 | port = 443 if ssl else 8080 119 | self.url = f"http{'s' if ssl else ''}://{settings['host']}:{port}/api/v2" 120 | self.password = settings["password"] if "password" in settings else "" 121 | self.__login() 122 | 123 | def __login(self): 124 | r = self._post("/auth/login", {"username": self.username, "password": self.password}) 125 | self.cookies = r.cookies 126 | self.logged_in = r.content.decode() == "Ok." 127 | if not self.logged_in: 128 | self.logger.error("Failed to login to qBittorrent") 129 | 130 | def add(self, torrent_path: str, file_path: str) -> None: 131 | super().add(torrent_path, file_path) 132 | if not self.logged_in: 133 | return 134 | with open(torrent_path, "rb") as f: 135 | hash = bencoder.infohash(f.read()) 136 | file_path = mapPath(file_path, self.pathmaps) 137 | dir = os.path.split(file_path)[0] 138 | torrent_name = os.path.basename(torrent_path) 139 | options = {"paused": "true", "savepath": dir} 140 | if len(self.label) > 0: 141 | options["category"] = self.label 142 | with open(torrent_path, "rb") as f: 143 | files={"torrents": (torrent_name, f, "application/x-bittorrent")} 144 | r = self._post("/torrents/add", options, files=files, timeout=15) 145 | if r.ok and r.content.decode() != "Fails.": 146 | self.recheck(hash) 147 | self.logger.info("Torrent added to qBittorrent") 148 | else: 149 | self.logger.error("Failed to add torrent to qBittorrent") 150 | 151 | def recheck(self, infohash: str): 152 | if not self.logged_in: 153 | return 154 | self._post("/torrents/recheck", {"hashes": infohash}) 155 | 156 | def start(self, torrent_path: str) -> None: 157 | if not self.logged_in or torrent_path not in Qbittorrent.hashes: 158 | return 159 | self.resume(Qbittorrent.hashes[torrent_path]) 160 | 161 | def resume(self, infohash: str): 162 | self._post("/torrents/start", {"hashes": infohash}) 163 | 164 | def _post(self, path: str, data: dict, files:dict|None = None, timeout: int = 5) -> requests.Response: 165 | r = requests.post(self.url+path, data=data, cookies=self.cookies, timeout=timeout, files=files) 166 | return r 167 | 168 | def connected(self) -> bool: 169 | return self.logged_in 170 | 171 | 172 | class Deluge(TorrentClient): 173 | "Implements Deluge's JSON RPC API for adding torrents" 174 | url: str 175 | password: str 176 | cookies = None 177 | host: str = "" 178 | name: str = "Deluge" 179 | 180 | def __init__(self, settings: dict) -> None: 181 | super().__init__(settings) 182 | ssl = settings["ssl"] if "ssl" in settings else False 183 | if "port" in settings: 184 | port = settings["port"] 185 | else: 186 | port = 443 if ssl else 8112 187 | self.url = f"http{'s' if ssl else ''}://{settings['host']}:{port}/json" 188 | self.password = settings["password"] if "password" in settings else "" 189 | self.__connect() 190 | 191 | def __connect(self): 192 | self.__login() 193 | result = requests.post( 194 | self.url, 195 | json={"method": "web.get_host_status", "params": [self.host], "id": 1}, 196 | cookies=self.cookies, 197 | timeout=5, 198 | ) 199 | j = result.json() 200 | if "result" in j: 201 | connected = j["result"] is not None and j["result"][1] == "Connected" 202 | if not connected: 203 | requests.post( 204 | self.url, 205 | json={"method": "web.connect", "params": [self.host], "id": 1}, 206 | cookies=self.cookies, 207 | timeout=5, 208 | ) 209 | 210 | def __login(self): 211 | if not self.connected(): 212 | body = {"method": "auth.login", "params": [self.password], "id": 1} 213 | r = requests.post(self.url, json=body, cookies=self.cookies) 214 | self.cookies = r.cookies 215 | 216 | def connected(self) -> bool: 217 | result = requests.post( 218 | self.url, json={"method": "web.connected", "params": [], "id": 1}, cookies=self.cookies, timeout=5 219 | ) 220 | j = result.json() 221 | if "result" in j and j["result"]: 222 | if len(self.host) == 0: 223 | result = requests.post( 224 | self.url, json={"method": "web.get_hosts", "params": [], "id": 1}, cookies=self.cookies, timeout=5 225 | ) 226 | j = result.json() 227 | if "result" in j: 228 | self.host = j["result"][0][0] 229 | return True 230 | return False 231 | 232 | def add(self, torrent_path: str, file_path: str) -> None: 233 | super().add(torrent_path, file_path) 234 | file_path = mapPath(file_path, self.pathmaps) 235 | dir = os.path.split(file_path)[0] 236 | torrent_name = os.path.basename(torrent_path) 237 | 238 | with open(torrent_path, "rb") as f: 239 | r = requests.post( 240 | self.url.replace("/json", "/upload"), 241 | files={"file": (torrent_name, f, "application/x-bittorrent")}, 242 | cookies=self.cookies, 243 | timeout=30, 244 | ) 245 | j = r.json() 246 | self.logger.debug(f"Deluge response: {j}") 247 | if "success" in j and j["success"]: 248 | torrent_path = j["files"][0] 249 | body = { 250 | "method": "web.add_torrents", 251 | "params": [[{"path": torrent_path, "options": {"download_location": dir, "add_paused": True}}]], 252 | "id": 1, 253 | } 254 | try: 255 | result = requests.post(self.url, json=body, cookies=self.cookies, timeout=5) 256 | j = result.json() 257 | self.logger.debug(f"Deluge response: {j}") 258 | if "result" in j and j["result"][0][0]: 259 | infohash = j["result"][0][1] 260 | self.recheck(infohash) 261 | self.logger.info("Torrent added to deluge") 262 | else: 263 | self.logger.error( 264 | f"Torrent uploaded to Deluge but failed to add: {j['error'] if 'error' in j and j['error'] else 'Unknown error'}" 265 | ) 266 | except requests.ReadTimeout: 267 | self.logger.error("Failed to add torrent to Deluge (does it already exist?)") 268 | else: 269 | self.logger.error("Failed to upload torrent to Deluge") 270 | 271 | def recheck(self, infohash: str): 272 | body = {"method": "core.force_recheck", "params": [[infohash]], "id": 1} 273 | requests.post(self.url, json=body, cookies=self.cookies, timeout=5) 274 | 275 | def resume(self, infohash: str) -> None: 276 | body = { 277 | "method": "core.resume_torrent", 278 | "params": [[infohash]], 279 | "id": 1 280 | } 281 | requests.post(self.url, json=body, cookies=self.cookies, timeout=5) 282 | 283 | def start(self, torrent_path: str) -> None: 284 | if torrent_path in Deluge.hashes: 285 | self.resume(Deluge.hashes[torrent_path]) -------------------------------------------------------------------------------- /webui/forms.py: -------------------------------------------------------------------------------- 1 | from flask_bootstrap import SwitchField 2 | from flask_wtf import FlaskForm 3 | from flask_wtf.file import FileField, FileAllowed 4 | from wtforms import ( 5 | Form, 6 | FieldList, 7 | FormField, 8 | SelectField, 9 | StringField, 10 | SubmitField, 11 | URLField, 12 | SelectMultipleField, 13 | ) 14 | from wtforms.validators import URL, DataRequired, Optional 15 | from wtforms.widgets import Input, PasswordInput 16 | 17 | from utils.db import StashTag, Category 18 | from webui.validators import PortRange, ConditionallyRequired, Directory, Tag 19 | 20 | 21 | class PasswordField(StringField): 22 | """ 23 | Original source: https://github.com/wtforms/wtforms/blob/2.0.2/wtforms/fields/simple.py#L35-L42 24 | 25 | A StringField, except renders an ````. 26 | Also, whatever value is accepted by this field is not rendered back 27 | to the browser like normal fields. 28 | """ 29 | 30 | widget = PasswordInput(hide_value=False) 31 | 32 | 33 | class ButtonInput(Input): 34 | """ 35 | Renders a button input. 36 | 37 | The field's label is used as the text of the button instead of the 38 | data on the field. 39 | """ 40 | 41 | input_type = "reset" 42 | 43 | def __call__(self, field, **kwargs): 44 | kwargs.setdefault("value", field.label.text) 45 | return super().__call__(field, **kwargs) 46 | 47 | 48 | class FileMap(Form): 49 | local_path = StringField( 50 | render_kw={"data-toggle": "tooltip", "title": "This is the path as stash-empornium sees it"} 51 | ) 52 | remote_path = StringField() 53 | delete = SubmitField() 54 | 55 | 56 | class BackendSettings(FlaskForm): 57 | def __init__(self, **kwargs): 58 | super().__init__(**kwargs) 59 | if "choices" in kwargs: 60 | self.default_template.choices = kwargs["choices"] 61 | 62 | default_template = SelectField("Default Template") 63 | torrent_directories = StringField("Torrent Directories", render_kw={"placeholder": ""}) 64 | port = StringField("Port", validators=[PortRange(1024), DataRequired()]) 65 | date_format = StringField() 66 | example = StringField("Date Example:", render_kw={"readonly": True}) 67 | title_template = StringField() 68 | anon = SwitchField("Upload Anonymously") 69 | media_directory = StringField( 70 | validators=[Directory()], 71 | render_kw={"data-toggle": "tooltip", "title": "Where to save data for multi-file torrents"}, 72 | ) 73 | move_method = SelectField(choices=["copy", "hardlink", "symlink"]) # type: ignore 74 | upload_gif = SwitchField("Upload Preview GIF") 75 | use_gif = SwitchField("Use GIF as Cover") 76 | tag_codec = SwitchField() 77 | tag_date = SwitchField() 78 | tag_framerate = SwitchField() 79 | tag_resolution = SwitchField() 80 | save = SubmitField() 81 | 82 | 83 | class RedisSettings(FlaskForm): 84 | enable_form = SwitchField("Use Redis") 85 | host = StringField() 86 | port = StringField(validators=[PortRange(), ConditionallyRequired()]) 87 | username = StringField(validators=[Optional()]) 88 | password = PasswordField(validators=[Optional()]) 89 | ssl = SwitchField("SSL") 90 | save = SubmitField() 91 | 92 | 93 | class TorrentSettings(FlaskForm): 94 | enable_form = SwitchField("Enable") 95 | host = StringField(validators=[ConditionallyRequired()]) 96 | port = StringField(validators=[PortRange(), ConditionallyRequired()]) 97 | path = StringField( 98 | validators=[ConditionallyRequired(message="Please specify the API path (typically XMLRPC or RPC2)")] 99 | ) 100 | username = StringField(validators=[Optional()]) 101 | password = PasswordField(validators=[Optional()]) 102 | label = StringField() 103 | ssl = SwitchField("SSL") 104 | file_maps = FieldList(FormField(FileMap, "File Map")) 105 | new_map = SubmitField() 106 | save = SubmitField() 107 | 108 | def __init__(self, *args, **kwargs): 109 | maps = [] 110 | if "maps" in kwargs: 111 | for local, remote in kwargs["maps"].items(): 112 | maps.append({"local_path": local, "remote_path": remote}) 113 | kwargs["file_maps"] = maps 114 | super().__init__(*args, **kwargs) 115 | for map in self.file_maps.entries: 116 | map["remote_path"].render_kw = { 117 | "data-toggle": "tooltip", 118 | "title": "This is the path as rTorrent sees it", 119 | } 120 | 121 | def update_self(self): 122 | map = None 123 | 124 | # read the data in the form 125 | read_form_data = self.data 126 | 127 | # modify the data: 128 | updated_list = read_form_data["file_maps"] 129 | if read_form_data["new_map"]: 130 | updated_list.append({}) 131 | else: 132 | for i, row in enumerate(read_form_data["file_maps"]): 133 | if row["delete"]: 134 | del updated_list[i] 135 | map = row["local_path"] 136 | read_form_data["file_maps"] = updated_list 137 | 138 | # reload the form from the modified data 139 | self.__init__(formdata=None, **read_form_data) 140 | self.validate() # the errors on validation are cancelled in the line above 141 | return map 142 | 143 | 144 | class RTorrentSettings(TorrentSettings): 145 | pass 146 | 147 | 148 | class QBittorrentSettings(TorrentSettings): 149 | path = None 150 | 151 | 152 | class DelugeSettings(TorrentSettings): 153 | username = None 154 | label = None 155 | path = None 156 | 157 | 158 | class StashSettings(FlaskForm): 159 | url = URLField("URL", validators=[URL(require_tld=False), DataRequired()]) 160 | api_key = PasswordField("API Key", validators=[Optional()]) 161 | save = SubmitField() 162 | 163 | 164 | class TagMap(Form): 165 | stash_tag = StringField() 166 | emp_tag = StringField("EMP Tag", validators=[Tag()]) 167 | advanced = SubmitField() 168 | delete = SubmitField() 169 | 170 | 171 | class TagMapForm(FlaskForm): 172 | tags = FieldList(FormField(TagMap), min_entries=1) 173 | submit = SubmitField() 174 | newline = SubmitField("Add Tag") 175 | 176 | def __init__(self, *args, **kwargs): 177 | tags = [] 178 | if "s_tags" in kwargs: 179 | for stag in kwargs["s_tags"]: 180 | etag = " ".join([et.tagname for et in stag.emp_tags]) 181 | tags.append({"stash_tag": stag.tagname, "emp_tag": etag}) 182 | kwargs["tags"] = tags 183 | super().__init__(*args, **kwargs) 184 | 185 | def update_self(self): 186 | tag = None 187 | 188 | # read the data in the form 189 | read_form_data = self.data 190 | 191 | # modify the data: 192 | updated_list = read_form_data["tags"] 193 | if read_form_data["newline"]: 194 | updated_list.append({}) 195 | else: 196 | for i, row in enumerate(read_form_data["tags"]): 197 | if row["delete"]: 198 | del updated_list[i] 199 | tag = row["stash_tag"] 200 | read_form_data["tags"] = updated_list 201 | 202 | # reload the form from the modified data 203 | self.__init__(formdata=None, **read_form_data) 204 | self.validate() # the errors on validation are cancelled in the line above 205 | return tag 206 | 207 | 208 | def getCategories(): 209 | return [cat.name for cat in Category.query.all()] 210 | 211 | 212 | class TagAdvancedForm(FlaskForm): 213 | ignored = SwitchField( 214 | render_kw={ 215 | "data-toggle": "tooltip", 216 | "title": "Don't include this tag in uploads", 217 | } 218 | ) 219 | stash_tag = StringField( 220 | validators=[DataRequired()], 221 | render_kw={ 222 | "data-toggle": "tooltip", 223 | "title": "The name of the tag in your stash server", 224 | }, 225 | ) 226 | display = StringField( 227 | render_kw={ 228 | "data-toggle": "tooltip", 229 | "title": "(Optional) How you want this tag to be displayed in your presentation", 230 | } 231 | ) 232 | def_tags = StringField( 233 | "Default Tags", 234 | render_kw={ 235 | "data-toggle": "tooltip", 236 | "title": "(Optional) The tag(s) that this will correspond to on any tracker that does not have its own tags set", 237 | }, 238 | ) 239 | emp_tags = StringField( 240 | "EMP Tags", 241 | render_kw={ 242 | "data-toggle": "tooltip", 243 | "title": "(Optional) The tag(s) that this corresponds to on Empornium", 244 | }, 245 | ) 246 | pb_tags = StringField( 247 | "PB Tags", 248 | render_kw={ 249 | "data-toggle": "tooltip", 250 | "title": "(Optional) The tag(s) that this corresponds to on Pornbay", 251 | }, 252 | ) 253 | fc_tags = StringField( 254 | "FC Tags", 255 | render_kw={ 256 | "data-toggle": "tooltip", 257 | "title": "(Optional) The tag(s) that this corresponds to on Femdom Cult", 258 | }, 259 | ) 260 | ent_tags = StringField( 261 | "ENT Tags", 262 | render_kw={ 263 | "data-toggle": "tooltip", 264 | "title": "(Optional) The tag(s) that this corresponds to on Enthralled", 265 | }, 266 | ) 267 | hf_tags = StringField( 268 | "HF Tags", 269 | render_kw={ 270 | "data-toggle": "tooltip", 271 | "title": "(Optional) The tag(s) that this corresponds to on Happy Fappy", 272 | }, 273 | ) 274 | categories = SelectMultipleField(choices=getCategories) # type: ignore 275 | delete = SubmitField() 276 | save = SubmitField() 277 | 278 | def __init__(self, *args, **kwargs): 279 | if "tag" in kwargs: 280 | tag: StashTag = kwargs["tag"] 281 | kwargs["stash_tag"] = tag.tagname 282 | kwargs["def_tags"] = " ".join([et.tagname for et in tag.def_tags]) 283 | kwargs["emp_tags"] = " ".join([et.tagname for et in tag.emp_tags]) 284 | kwargs["pb_tags"] = " ".join([et.tagname for et in tag.pb_tags]) 285 | kwargs["fc_tags"] = " ".join([et.tagname for et in tag.fc_tags]) 286 | kwargs["ent_tags"] = " ".join([et.tagname for et in tag.ent_tags]) 287 | kwargs["hf_tags"] = " ".join([et.tagname for et in tag.hf_tags]) 288 | kwargs["display"] = tag.display 289 | kwargs["categories"] = [cat.name for cat in tag.categories] 290 | kwargs["ignored"] = tag.ignored 291 | super().__init__(*args, **kwargs) 292 | 293 | 294 | class CategoryForm(Form): 295 | name = StringField() 296 | delete = SubmitField() 297 | 298 | 299 | class CategoryList(FlaskForm): 300 | categories = FieldList(FormField(CategoryForm)) 301 | new_category = SubmitField() 302 | submit = SubmitField() 303 | 304 | def __init__(self, *args, **kwargs): 305 | if "category_objs" in kwargs: 306 | kwargs["categories"] = [{"name": cat.name} for cat in kwargs["category_objs"]] 307 | super().__init__(*args, **kwargs) 308 | 309 | def update_self(self): 310 | category = None 311 | 312 | # read the data in the form 313 | read_form_data = self.data 314 | 315 | # modify the data: 316 | updated_list = read_form_data["categories"] 317 | if self.new_category.data: 318 | updated_list.append({}) 319 | else: 320 | for i, row in enumerate(read_form_data["categories"]): 321 | if row["delete"]: 322 | del updated_list[i] 323 | category = row["name"] 324 | read_form_data["categories"] = updated_list 325 | 326 | # reload the form from the modified data 327 | self.__init__(formdata=None, **read_form_data) 328 | self.validate() # the errors on validation are cancelled in the line above 329 | return category 330 | 331 | 332 | class SearchResult(Form): 333 | stash_tag = StringField(render_kw={"readonly": True}) 334 | emp_tag = StringField("EMP Tag", render_kw={"readonly": True}) 335 | settings = SubmitField() 336 | 337 | 338 | class SearchForm(FlaskForm): 339 | tags = FieldList(FormField(SearchResult, render_kw={"readonly": True})) 340 | 341 | def __init__(self, *args, **kwargs): 342 | tags = [] 343 | if "s_tags" in kwargs: 344 | for stag in kwargs["s_tags"]: 345 | etag = " ".join([et.tagname for et in stag.emp_tags]) 346 | tags.append({"stash_tag": stag.tagname, "emp_tag": etag}) 347 | kwargs["tags"] = tags 348 | super().__init__(*args, **kwargs) 349 | 350 | 351 | class FileMapForm(FlaskForm): 352 | file_maps = FieldList(FormField(FileMap, "File Maps")) 353 | new_map = SubmitField() 354 | submit = SubmitField() 355 | 356 | def __init__(self, *args, **kwargs): 357 | maps = [] 358 | if "maps" in kwargs: 359 | for remote, local in kwargs["maps"].items(): 360 | maps.append({"local_path": local, "remote_path": remote}) 361 | kwargs["file_maps"] = maps 362 | super().__init__(*args, **kwargs) 363 | 364 | def update_self(self): 365 | map = None 366 | 367 | # read the data in the form 368 | read_form_data = self.data 369 | 370 | # modify the data: 371 | updated_list = read_form_data["file_maps"] 372 | if read_form_data["new_map"]: 373 | updated_list.append({}) 374 | else: 375 | for i, row in enumerate(read_form_data["file_maps"]): 376 | if row["delete"]: 377 | del updated_list[i] 378 | map = row["local_path"] 379 | read_form_data["file_maps"] = updated_list 380 | 381 | # reload the form from the modified data 382 | self.__init__(formdata=None, **read_form_data) 383 | self.validate() # the errors on validation are cancelled in the line above 384 | return map 385 | 386 | 387 | class DBImportExport(FlaskForm): 388 | export_database = SubmitField() 389 | upload_database = FileField(validators=[FileAllowed(["txt", "json", "md"])]) 390 | imp = SubmitField("Import") 391 | -------------------------------------------------------------------------------- /webui/templates/base-settings.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "bootstrap5/utils.html" import render_static %} 3 | {% from "bootstrap5/pagination.html" import render_pager, render_pagination %} 4 | {% block title %} 5 | Settings 6 | {% endblock title %} 7 | {% block head %} 8 | {{ render_static('css', 'https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css', False) }} 9 | {% endblock head %} 10 | {% block content %} 11 |
12 | {% if pagination %}
{{ render_pagination(pagination, align='right') }}
{% endif %} 13 |
14 |
15 |

16 | {% block heading %} 17 | stash-empornium settings 18 | {% endblock heading %} 19 |

20 |

21 | {% block desc %} 22 | Here you can configure the settings for {{ settings_option }}. 23 | {% endblock desc %} 24 |

25 | {% block formfields %} 26 | {% endblock formfields %} 27 |
28 |
29 | {% if message %} 30 |
31 |
32 | 33 |
34 |
35 | {% endif %} 36 | {% if pagination %}
{{ render_pagination(pagination, align='right') }}
{% endif %} 37 |
38 | {% endblock content %} 39 | {% block scripts %} 40 | {{ render_static('js', 'scripts/forms.js') }} 41 | {% endblock scripts %} 42 | -------------------------------------------------------------------------------- /webui/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {# 16 | 17 | 18 | 19 | #} 20 | {% block styles %} 21 | {# Bootstrap CSS #} 22 | {{ bootstrap.load_css() }} 23 | {% endblock styles %} 24 | 25 | {% block title %} 26 | Default 27 | {% endblock title %} 28 | - stash-empornium 29 | {% block head %} 30 | {% endblock head %} 31 | 32 | 33 | {% include "navbar.html" %} 34 | {# Content #} 35 | {% block content %} 36 | {% endblock content %} 37 | {# JavaScript #} 38 | {{ bootstrap.load_js() }} 39 | {% block scripts %} 40 | {% endblock scripts %} 41 | 42 | 43 | -------------------------------------------------------------------------------- /webui/templates/categories.html: -------------------------------------------------------------------------------- 1 | {% extends "base-settings.html" %}{% from "bootstrap5/form.html" import render_field, render_form_row %} 2 | {% block heading %} 3 | category settings 4 | {% endblock heading %} 5 | {% block desc %} 6 | Here you can configure your tag categories. 7 | {% endblock desc %} 8 | {% block formfields %} 9 |
10 | {{ form.csrf_token() }} 11 |
12 |
Categories
13 |
14 |
15 | {% for field in form.categories %} 16 | {% if field.errors %} 17 |
18 | {% for subfield in field %} 19 |
20 | {% for error in subfield.errors %}
{{ error }}
{% endfor %} 21 |
22 | {% endfor %} 23 |
24 | {% endif %} 25 | {{ render_form_row(field, form_type='inline') }} 26 | {% endfor %} 27 | {{ render_field(form.new_category) }} 28 | {{ render_field(form.submit) }} 29 |
30 | {% endblock formfields %} 31 | -------------------------------------------------------------------------------- /webui/templates/dbexport.html: -------------------------------------------------------------------------------- 1 | {% extends "settings.html" %}{% from "bootstrap5/form.html" import render_form %} 2 | {% block formfields %} 3 | {{ render_form(form) }} 4 | {% endblock formfields %} 5 | -------------------------------------------------------------------------------- /webui/templates/error-page.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %} 3 | Page Not Found 4 | {% endblock title %} 5 | {% block 6 | content %} 7 |
8 |
9 |
10 |
11 |

{{ code }}

12 |

{{ message }}

13 | Back to Home 14 |
15 |
16 |
17 |
18 | {% endblock content %} 19 | -------------------------------------------------------------------------------- /webui/templates/navbar.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webui/templates/search.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {# {% from "bootstrap5/utils.html" import render_static %} #} 3 | {% from "bootstrap5/pagination.html" import render_pagination %} 4 | {% from "bootstrap5/form.html" import render_form_row %} 5 | {% block title %} 6 | Search Results 7 | {% endblock title %} 8 | {# {% block head %} 9 | {{ render_static('css', 'https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css', False) }} 10 | {% endblock head %} #} 11 | {% block content %} 12 |
13 | {% if pagination %}
{{ render_pagination(pagination, align='right') }}
{% endif %} 14 |
15 |
16 |

search results

17 |
18 |
19 | {% if form and form.tags %} 20 |
21 | Showing results for: {{ searched }} 22 |
23 |
24 | {{ form.hidden_tag() }} 25 | {% for tag in form.tags %} 26 | {{ render_form_row(tag, form_type='inline', col_map={tag.settings.id: 'col col-2'}) }} 27 | {% endfor %} 28 |
29 | {% else %} 30 |
31 | No results found for: {{ searched }} 32 |
33 | {% endif %} 34 | {% if pagination %}
{{ render_pagination(pagination, align='right') }}
{% endif %} 35 |
36 | {% endblock content %} 37 | {# {% block scripts %} 38 | {{ render_static('js', 'scripts/forms.js') }} 39 | {% endblock scripts %} #} 40 | -------------------------------------------------------------------------------- /webui/templates/settings.html: -------------------------------------------------------------------------------- 1 | {% extends "base-settings.html" %}{% from "bootstrap5/form.html" import render_field %} 2 | {% block formfields %} 3 |
4 | {{ form.csrf_token() }} 5 | {%- for item in form %} 6 | {%- if item == form.csrf_token %} 7 | {% else %} 8 |
9 | {%- if item.type == "PasswordField" %} 10 | {{ item.label() }} 11 |
12 | {{ item(class='form-control', placeholder='') }} 13 | 19 |
20 | {% else %} 21 | {{ render_field(item) }} 22 | {% endif %} 23 |
24 | {%- endif %} 25 | {%- endfor %} 26 |
27 | {% endblock formfields %} 28 | {% block scripts %} 29 | {{ super() }} 30 | {% if form.date_format %} 31 | 32 | 40 | {% endif %} 41 | {% endblock scripts %} 42 | -------------------------------------------------------------------------------- /webui/templates/tag-advanced.html: -------------------------------------------------------------------------------- 1 | {% extends "base-settings.html" %}{% from "bootstrap5/form.html" import render_form %} 2 | {% block heading %} 3 | tag settings 4 | {% endblock heading %} 5 | {% block desc %} 6 | Here you can configure your tag mappings. 7 | {% endblock desc %} 8 | {% block formfields %} 9 | {{ render_form(form) }} 10 | {% endblock formfields %} 11 | -------------------------------------------------------------------------------- /webui/templates/tag-settings.html: -------------------------------------------------------------------------------- 1 | {% extends "base-settings.html" %}{% from "bootstrap5/form.html" import render_field, render_form_row %} 2 | {% block heading %} 3 | tag settings 4 | {% endblock heading %} 5 | {% block desc %} 6 | Here you can configure your tag mappings. 7 | {% endblock desc %} 8 | {% block formfields %} 9 |
10 | {{ form.csrf_token() }} 11 |
12 |
Stash Tag
13 |
EMP Tag
14 |
15 |
16 |
17 | {% for field in form.tags %} 18 | {% if field.errors %} 19 |
20 | {% for subfield in field %} 21 |
22 | {% for error in subfield.errors %}
{{ error }}
{% endfor %} 23 |
24 | {% endfor %} 25 |
26 | {% endif %} 27 | {{ render_form_row(field, form_type='inline', col_map={field.delete.id: 'col col-2', field.advanced.id: 'col col-2'}) }} 28 | {% endfor %} 29 | {{ render_field(form.newline) }} 30 | {{ render_field(form.submit) }} 31 |
32 | {% endblock formfields %} 33 | -------------------------------------------------------------------------------- /webui/validators.py: -------------------------------------------------------------------------------- 1 | import re 2 | import os 3 | from wtforms.validators import StopValidation, ValidationError 4 | 5 | class PortRange: 6 | def __init__(self, min=0, max=65535, message=None) -> None: 7 | self.min = min 8 | self.max = max 9 | if not message: 10 | message = f"Value must be an integer between {min} and {max}" 11 | self.message = message 12 | 13 | def __call__(self, form, field) -> None: 14 | try: 15 | value = int(field.data) 16 | assert value >= self.min and value <= self.max 17 | except: 18 | raise ValidationError(self.message) 19 | 20 | 21 | class ConditionallyRequired: 22 | def __init__(self, *, message="This field is required", fieldname="enable_form") -> None: 23 | self.fieldname = fieldname 24 | self.message = message 25 | 26 | def __call__(self, form, field) -> None: 27 | if form.data[self.fieldname]: 28 | if not field.data: 29 | raise ValidationError(self.message) 30 | else: 31 | field.errors[:] = [] 32 | raise StopValidation() 33 | 34 | 35 | class Directory: 36 | def __call__(self, form, field) -> None: 37 | dir: str = field.data 38 | try: 39 | if dir and os.path.isfile(dir): 40 | raise ValidationError("Must be a directory") 41 | except: 42 | raise ValidationError("Must be a directory") 43 | 44 | class Tag: 45 | def __call__(self, form, field) -> None: 46 | if not field.data: 47 | return 48 | tags:list[str] = field.data.split() 49 | for tag in tags: 50 | if len(tag) > 32: 51 | raise ValidationError(f"Tags must not exceed 32 characters") 52 | if tag.startswith(".") or tag.endswith("."): 53 | raise ValidationError("Tags cannot start or end with a period") 54 | result = re.fullmatch(r"[\.\w]+", tag) 55 | if not result: 56 | raise ValidationError("Tags must only contain letters, numbers, and periods") 57 | result = re.match(r".*\.\.", field.data) 58 | if result: 59 | raise ValidationError("Tags cannot contain consecutive periods") 60 | -------------------------------------------------------------------------------- /webui/webui.py: -------------------------------------------------------------------------------- 1 | import json 2 | import tempfile 3 | from flask import Blueprint, abort, redirect, render_template, url_for, request, send_file 4 | from webui.forms import ( 5 | TagMapForm, 6 | BackendSettings, 7 | RedisSettings, 8 | RTorrentSettings, 9 | DelugeSettings, 10 | QBittorrentSettings, 11 | StashSettings, 12 | TagAdvancedForm, 13 | CategoryList, 14 | SearchForm, 15 | FileMapForm, 16 | TorrentSettings, 17 | DBImportExport 18 | ) 19 | 20 | from utils.confighandler import ConfigHandler 21 | from utils.taghandler import query_maps 22 | from utils.db import get_or_create, StashTag, GazelleTag, db, get_or_create_no_commit, Category, from_dict, to_dict 23 | from werkzeug.exceptions import HTTPException 24 | 25 | conf = ConfigHandler() 26 | 27 | settings_page = Blueprint("settings_page", __name__, template_folder="templates") 28 | 29 | 30 | @settings_page.route("/", methods=["GET"]) 31 | def index(): 32 | return redirect(url_for(".settings", page="backend")) 33 | 34 | 35 | @settings_page.route("/tags") 36 | def tags(): 37 | return redirect(url_for(".tag_settings", page="maps")) 38 | 39 | 40 | @settings_page.route("/tag/", methods=["GET", "POST"]) 41 | def tag(id): 42 | stag: StashTag = StashTag.query.filter_by(id=id).first_or_404() 43 | form = TagAdvancedForm(tag=stag) 44 | if form.validate_on_submit(): 45 | if form.data["save"]: 46 | stag.ignored = form.data["ignored"] 47 | stag.display = form.data["display"] 48 | etags = [] 49 | for et in form.data["def_tags"].split(): 50 | etags.append(get_or_create_no_commit(GazelleTag, tagname=et)) 51 | stag.def_tags = etags 52 | etags = [] 53 | for et in form.data["emp_tags"].split(): 54 | etags.append(get_or_create_no_commit(GazelleTag, tagname=et)) 55 | stag.emp_tags = etags 56 | etags = [] 57 | for et in form.data["pb_tags"].split(): 58 | etags.append(get_or_create_no_commit(GazelleTag, tagname=et)) 59 | stag.pb_tags = etags 60 | etags = [] 61 | for et in form.data["fc_tags"].split(): 62 | etags.append(get_or_create_no_commit(GazelleTag, tagname=et)) 63 | stag.fc_tags = etags 64 | etags = [] 65 | for et in form.data["ent_tags"].split(): 66 | etags.append(get_or_create_no_commit(GazelleTag, tagname=et)) 67 | stag.ent_tags = etags 68 | etags = [] 69 | for et in form.data["hf_tags"].split(): 70 | etags.append(get_or_create_no_commit(GazelleTag, tagname=et)) 71 | stag.hf_tags = etags 72 | cats = [] 73 | for cat in form.data["categories"]: 74 | cats.append(get_or_create_no_commit(Category, name=cat)) 75 | stag.categories = cats 76 | db.session.commit() 77 | elif form.data["delete"]: 78 | db.session.delete(stag) 79 | db.session.commit() 80 | return render_template("tag-advanced.html", form=form) 81 | 82 | 83 | @settings_page.route("/tags/", methods=["GET", "POST"]) 84 | def tag_settings(page): 85 | pagination = query_maps(page=int(page)) 86 | form = TagMapForm(s_tags=pagination.items) 87 | if form.validate_on_submit(): 88 | tag = form.update_self() 89 | if tag: 90 | s_tag = get_or_create(StashTag, tagname=tag["stash_tag"]) 91 | db.session.delete(s_tag) 92 | db.session.commit() 93 | if form.data["submit"]: 94 | for tag in form.data["tags"]: 95 | if not tag["stash_tag"]: 96 | continue # Ignore empty tag inputs 97 | s_tag = get_or_create(StashTag, tagname=tag["stash_tag"]) 98 | e_tags = [] 99 | for et in tag["emp_tag"].split(): 100 | e_tags.append(get_or_create(GazelleTag, tagname=et)) 101 | s_tag.emp_tags = e_tags 102 | db.session.commit() 103 | else: 104 | for tag in form.data["tags"]: 105 | if tag["advanced"]: 106 | stag = StashTag.query.filter_by(tagname=tag["stash_tag"]).first_or_404() 107 | return redirect(url_for(".tag", id=stag.id)) 108 | return render_template("tag-settings.html", form=form, pagination=pagination) 109 | 110 | 111 | @settings_page.route("/search", methods=["GET", "POST"]) 112 | def search(): 113 | tags = StashTag.query 114 | page = request.args.get("page", default=1, type=int) 115 | searched = request.args.get("search") 116 | form = SearchForm() 117 | if form.validate_on_submit(): 118 | for tag in form.tags: 119 | s_tag = StashTag.query.filter_by(tagname=tag.stash_tag.data).first_or_404() 120 | if tag.settings.data: 121 | return redirect(url_for(".tag", id=s_tag.id)) 122 | elif searched: 123 | tags = tags.filter(StashTag.tagname.like(f"%{searched}%")) 124 | e_tags = StashTag.query.join(StashTag.emp_tags).filter(GazelleTag.tagname.like(f"%{searched}%")) 125 | tags = tags.union(e_tags) 126 | pagination = tags.order_by(StashTag.tagname).paginate(page=page) 127 | form = SearchForm(s_tags=pagination.items) 128 | return render_template("search.html", searched=searched, form=form, pagination=pagination) 129 | return render_template("search.html") 130 | 131 | 132 | @settings_page.route("/categories") 133 | def category(): 134 | return redirect(url_for(".category_settings", page=1)) 135 | 136 | 137 | @settings_page.route("/categories/", methods=["GET", "POST"]) 138 | def category_settings(page): 139 | pagination = Category.query.paginate(page=int(page)) 140 | form = CategoryList(category_objs=pagination.items) 141 | if form.validate_on_submit(): 142 | cat = form.update_self() 143 | if cat: 144 | cat = Category.query.filter_by(name=cat).first() 145 | db.session.delete(cat) 146 | db.session.commit() 147 | elif form.submit.data: 148 | for cat in form.categories.data: 149 | cat = get_or_create(Category, name=cat["name"]) 150 | return render_template("categories.html", form=form, pagination=pagination) 151 | 152 | 153 | @settings_page.route("/settings/", methods=["GET", "POST"]) 154 | def settings(page): 155 | template_context = {} 156 | template = "settings.html" 157 | enable = page in conf and not conf.get(page, "disable", False) 158 | match page: 159 | case "backend": 160 | template_context["settings_option"] = "your stash-empornium backend" 161 | form = BackendSettings( 162 | default_template=conf.get(page, "default_template", ""), 163 | torrent_directories=", ".join(conf.get(page, "torrent_directories", "")), # type: ignore 164 | port=conf.get(page, "port", ""), 165 | date_format=conf.get(page, "date_format", ""), 166 | title_template=conf.get(page, "title_template", ""), 167 | media_directory=conf.get(page, "media_directory", ""), 168 | move_method=conf.get(page, "move_method", "copy"), 169 | anon=conf.get(page, "anon", False), 170 | choices=[opt for opt in conf["templates"]], # type: ignore 171 | upload_gif=conf.get(page, "use_preview", False), 172 | use_gif=conf.get(page, "animated_cover", False), 173 | tag_codec = conf.get("metadata", "tag_codec", False), 174 | tag_date = conf.get("metadata", "tag_date", False), 175 | tag_framerate = conf.get("metadata", "tag_framerate", False), 176 | tag_resolution = conf.get("metadata", "tag_resolution", False), 177 | ) 178 | case "stash": 179 | template_context["settings_option"] = "your stash server" 180 | form = StashSettings(url=conf.get(page, "url", ""), api_key=conf.get(page, "api_key", "")) 181 | case "redis": 182 | template_context["settings_option"] = "your redis server" 183 | form = RedisSettings( 184 | enable_form=enable, 185 | host=conf.get(page, "host", ""), 186 | port=conf.get(page, "port", ""), 187 | username=conf.get(page, "username", ""), 188 | password=conf.get(page, "password", ""), 189 | ssl=conf.get(page, "ssl", False), 190 | ) 191 | case "rtorrent": 192 | template_context["settings_option"] = "your rTorrent client" 193 | form = RTorrentSettings( 194 | enable_form=enable, 195 | host=conf.get(page, "host", ""), 196 | port=conf.get(page, "port", ""), 197 | username=conf.get(page, "username", ""), 198 | password=conf.get(page, "password", ""), 199 | path=conf.get(page, "path", "RPC2"), 200 | label=conf.get(page, "label", ""), 201 | ssl=conf.get(page, "ssl", False), 202 | maps=conf.get(page, "pathmaps", {}) 203 | ) 204 | case "deluge": 205 | template_context["settings_option"] = "your Deluge client" 206 | form = DelugeSettings( 207 | enable_form=enable, 208 | host=conf.get(page, "host", ""), 209 | port=conf.get(page, "port", ""), 210 | password=conf.get(page, "password", ""), 211 | ssl=conf.get(page, "ssl", False), 212 | maps=conf.get(page, "pathmaps", {}) 213 | ) 214 | case "qbittorrent": 215 | template_context["settings_option"] = "your qBittorrent client" 216 | form = QBittorrentSettings( 217 | enable_form=enable, 218 | host=conf.get(page, "host", ""), 219 | port=conf.get(page, "port", ""), 220 | username=conf.get(page, "username", ""), 221 | password=conf.get(page, "password", ""), 222 | label=conf.get(page, "label", ""), 223 | ssl=conf.get(page, "ssl", False), 224 | maps=conf.get(page, "pathmaps", {}) 225 | ) 226 | case "files": 227 | template_context["settings_option"] = "stash path mappings" 228 | form = FileMapForm(maps=conf.items("file.maps")) 229 | for field in form.file_maps.entries: 230 | field["remote_path"].render_kw = { 231 | "data-toggle": "tooltip", 232 | "title": "This is the path as stash sees it", 233 | } 234 | case "tags": 235 | return redirect(url_for(".tag_settings", page="maps")) 236 | case "database": 237 | template_context["settings_option"] = "the tag database" 238 | form = DBImportExport() 239 | template = "dbexport.html" 240 | case _: 241 | abort(404) 242 | if form.validate_on_submit(): 243 | template_context["message"] = "Settings saved" 244 | match page: 245 | case "backend": 246 | assert isinstance(form, BackendSettings) 247 | conf.set(page, "default_template", form.data["default_template"]) 248 | conf.set(page, "torrent_directories", [x.strip() for x in form.data["torrent_directories"].split(",")]) 249 | conf.set(page, "port", int(form.data["port"])) 250 | conf.set(page, "title_template", form.data["title_template"]) 251 | conf.set(page, "date_format", form.data["date_format"]) 252 | conf.set(page, "use_preview", form.upload_gif.data) 253 | conf.set(page, "animated_cover", form.use_gif.data) 254 | conf.set("metadata", "tag_codec", form.tag_codec.data) 255 | conf.set("metadata", "tag_date", form.tag_date.data) 256 | conf.set("metadata", "tag_framerate", form.tag_framerate.data) 257 | conf.set("metadata", "tag_resolution", form.tag_resolution.data) 258 | if form.data["media_directory"]: 259 | conf.set(page, "media_directory", form.data["media_directory"]) 260 | conf.set(page, "move_method", form.data["move_method"]) 261 | conf.set(page, "anon", form.data["anon"]) 262 | case "stash": 263 | conf.set(page, "url", form.data["url"]) 264 | if form.data["api_key"]: 265 | conf.set(page, "api_key", form.data["api_key"]) 266 | else: 267 | conf.delete(page, "api_key") 268 | case "redis": 269 | if form.data["enable_form"]: 270 | conf.set(page, "disable", False) 271 | conf.set(page, "host", form.data["host"]) 272 | conf.set(page, "port", int(form.data["port"])) 273 | conf.set(page, "ssl", form.data["ssl"]) 274 | if form.data["username"]: 275 | conf.set(page, "username", form.data["username"]) 276 | else: 277 | conf.delete(page, "username") 278 | if form.data["password"]: 279 | conf.set(page, "password", form.data["password"]) 280 | else: 281 | conf.delete(page, "password") 282 | else: 283 | if page in conf: 284 | conf.set(page, "disable", True) 285 | case "rtorrent" | "deluge" | "qbittorrent": 286 | assert isinstance(form, TorrentSettings) 287 | path = form.update_self() 288 | if path: 289 | maps:dict = conf.get(page, "pathmaps") # type: ignore 290 | del maps[path] 291 | if len(maps) > 0: 292 | conf.set(page, "pathmaps", maps) 293 | else: 294 | conf.delete(page, "pathmaps") 295 | else: 296 | conf.set(page, "disable", not form.data["enable_form"]) 297 | conf.set(page, "host", form.data["host"]) 298 | conf.set(page, "port", int(form.data["port"])) 299 | conf.set(page, "ssl", form.data["ssl"]) 300 | if "path" in form.data: 301 | conf.set(page, "path", form.data["path"]) 302 | if "username" in form.data and form.data["username"]: 303 | conf.set(page, "username", form.data["username"]) 304 | else: 305 | conf.delete(page, "username") 306 | if form.data["password"]: 307 | conf.set(page, "password", form.data["password"]) 308 | else: 309 | conf.delete(page, "password") 310 | if "label" in form.data and form.data["label"]: 311 | conf.set(page, "label", form.data["label"]) 312 | else: 313 | conf.delete(page, "label") 314 | maps = {} 315 | for field in form.file_maps: 316 | if field["local_path"].data and field["remote_path"].data: 317 | maps[field["local_path"].data] = field["remote_path"].data 318 | if len(maps) > 0: 319 | conf.set(page, "pathmaps", maps) 320 | conf.configureTorrents() 321 | case "files": 322 | del template_context["message"] 323 | assert isinstance(form, FileMapForm) 324 | map = form.update_self() 325 | if map: 326 | conf.delete("file.maps", map) 327 | elif form.submit.data: 328 | template_context["message"] = "Settings saved" 329 | conf.conf["file.maps"].clear() # type: ignore 330 | for map in form.file_maps: 331 | conf.set("file.maps", map.data["local_path"], map.data["remote_path"]) 332 | case "database": 333 | del template_context["message"] 334 | assert isinstance(form, DBImportExport) 335 | if form.export_database.data: 336 | data = json.dumps(to_dict()) 337 | temp = tempfile.mktemp() 338 | with open(temp, "w") as f: 339 | f.write(data) 340 | return send_file(temp, as_attachment=True, download_name="export.json") 341 | elif form.imp.data: 342 | data = json.loads(form.upload_database.data.read()) 343 | from_dict(data) 344 | template_context["message"] = "Settings imported" 345 | case _: 346 | abort(404) 347 | conf.update_file() 348 | template_context["form"] = form 349 | return render_template(template, **template_context) 350 | 351 | 352 | @settings_page.app_errorhandler(HTTPException) 353 | def handle_exception(e): 354 | message = e.name 355 | if e.code == 404: 356 | message = "The page you were looking for was not found" 357 | return render_template("error-page.html", code=e.code, message=message), e.code 358 | --------------------------------------------------------------------------------