├── .coveragerc ├── .dockerignore ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .gitmodules ├── .husky └── pre-commit ├── Dockerfile ├── docker_build.sh ├── eslint.config.mjs ├── install_instructions.md ├── lint-staged.config.cjs ├── logo └── namer.png ├── namer ├── __init__.py ├── __main__.py ├── command.py ├── comparison_results.py ├── configuration.py ├── configuration_utils.py ├── database.py ├── ffmpeg.py ├── fileinfo.py ├── http.py ├── metadataapi.py ├── models │ ├── __init__.py │ ├── base.py │ └── file.py ├── moviexml.py ├── mutagen.py ├── name_formatter.py ├── namer.cfg.default ├── namer.py ├── videohashes.py ├── videophash │ ├── __init__.py │ ├── imagehash.py │ ├── videophash.py │ └── videophashstash.py ├── watchdog.py └── web │ ├── __init__.py │ ├── actions.py │ ├── public │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── mstile-150x150.png │ ├── safari-pinned-tab.svg │ └── site.webmanifest │ ├── routes │ ├── __init__.py │ ├── api.py │ └── web.py │ └── server.py ├── package.json ├── pnpm-lock.yaml ├── poetry.lock ├── pyproject.toml ├── readme.rst ├── release.sh ├── src ├── css │ └── main.scss ├── js │ ├── helpers.js │ ├── main.js │ └── themes.js └── templates │ ├── components │ ├── card.html │ ├── navigations.html │ └── performersBadges.html │ ├── pages │ ├── failed.html │ ├── index.html │ ├── queue.html │ └── settings.html │ ├── partials │ └── base.html │ └── render │ ├── failedFiles.html │ ├── fileActions.html │ ├── logDetails.html │ ├── logFile.html │ ├── performerBadge.html │ └── searchResults.html ├── test ├── Big_Buck_Bunny_360_10s_2MB_h264.mp4 ├── Big_Buck_Bunny_720_10s_2MB_h264.mp4 ├── Big_Buck_Bunny_720_10s_2MB_h265.mp4 ├── Site.22.01.01.painful.pun.XXX.720p.xpost.mp4 ├── Site.22.01.01.painful.pun.XXX.720p.xpost_wrong.mp4 ├── __init__.py ├── dc.json ├── ea.full.json ├── ea.json ├── ea.nfo ├── namer_configparser_test.py ├── namer_ffmpeg_test.py ├── namer_file_parser_test.py ├── namer_fileutils_test.py ├── namer_metadataapi_test.py ├── namer_moviexml_test.py ├── namer_mutagen_test.py ├── namer_test.py ├── namer_types_test.py ├── namer_videophash_test.py ├── namer_watchdog_test.py ├── namer_webhook_test.py ├── p18.json ├── poster.png ├── ssb2.json ├── updatejson.ps1 ├── updatejson.sh ├── utils.py └── web │ ├── __init__.py │ ├── fake_tpdb.py │ ├── namer_web_pageobjects.py │ ├── namer_web_test.py │ └── parrot_webserver.py └── webpack.prod.js /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | include = namer/*, test/* 4 | #[report] 5 | #exclude_lines = pragma: no cover 6 | #[html] 7 | #directory = ./coverage_html_report 8 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | .coveragerc 3 | .github 4 | .gitignore 5 | .pytest_cache 6 | .vscode 7 | UNKNOWN.egg-info 8 | __pycache__ 9 | creds.sh 10 | dist 11 | htmlcov 12 | include 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | os: [ubuntu-latest, macos-latest, windows-latest] 16 | python-version: [ '3.10', '3.11', '3.12', '3.13' ] 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | with: 21 | submodules: true 22 | 23 | - name: Set up Python 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | 28 | - name: Display Python version 29 | run: python --version 30 | 31 | - name: Install poetry 32 | uses: abatilo/actions-poetry@v2 33 | with: 34 | poetry-version: '2.1.1' 35 | 36 | - name: Install Go 37 | uses: actions/setup-go@v5 38 | with: 39 | go-version-file: 'videohashes/go.mod' 40 | cache-dependency-path: 'videohashes/go.sum' 41 | 42 | - name: Install ffmpeg 43 | run: | 44 | if [ "$RUNNER_OS" == "Linux" ]; then 45 | sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg 46 | elif [ "$RUNNER_OS" == "Windows" ]; then 47 | choco install -q -y ffmpeg 48 | elif [ "$RUNNER_OS" == "macOS" ]; then 49 | env HOMEBREW_NO_AUTO_UPDATE=1 brew install ffmpeg 50 | fi 51 | shell: bash 52 | 53 | - uses: pnpm/action-setup@v3 54 | with: 55 | version: 10 56 | 57 | - name: Install Node 58 | uses: actions/setup-node@v4 59 | with: 60 | cache: 'pnpm' 61 | node-version: '22' 62 | 63 | - name: Install Poetry Dependencies 64 | run: poetry install 65 | 66 | - name: Build All 67 | run: poetry run poe build_all 68 | 69 | coverage: 70 | runs-on: ubuntu-latest 71 | steps: 72 | - name: Checkout 73 | uses: actions/checkout@v4 74 | with: 75 | submodules: true 76 | 77 | - name: Set up Python 78 | uses: actions/setup-python@v5 79 | with: 80 | python-version: '3.13' 81 | 82 | - name: Display Python version 83 | run: python --version 84 | 85 | - name: Install Go 86 | uses: actions/setup-go@v5 87 | with: 88 | go-version-file: 'videohashes/go.mod' 89 | cache-dependency-path: 'videohashes/go.sum' 90 | 91 | - name: Install ffmpeg 92 | run: | 93 | if [ "$RUNNER_OS" == "Linux" ]; then 94 | sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg 95 | elif [ "$RUNNER_OS" == "Windows" ]; then 96 | choco install -q -y ffmpeg 97 | elif [ "$RUNNER_OS" == "macOS" ]; then 98 | env HOMEBREW_NO_AUTO_UPDATE=1 brew install ffmpeg 99 | fi 100 | shell: bash 101 | 102 | - name: Install poetry 103 | uses: abatilo/actions-poetry@v2 104 | with: 105 | poetry-version: '2.1.1' 106 | 107 | - uses: pnpm/action-setup@v3 108 | with: 109 | version: 10 110 | 111 | - name: Install Node 112 | uses: actions/setup-node@v4 113 | with: 114 | cache: 'pnpm' 115 | node-version: '22' 116 | 117 | - name: Install Poetry Dependencies 118 | run: poetry install 119 | 120 | - name: Build All 121 | run: poetry run poe build_all 122 | 123 | - name: Run tests 124 | run: poetry run pytest --cov 125 | 126 | - name: Run coverage 127 | run: poetry run coverage xml 128 | 129 | - uses: codecov/codecov-action@v4 130 | with: 131 | files: ./coverage.xml 132 | name: unittest 133 | fail_ci_if_error: true 134 | verbose: true 135 | token: ${{ secrets.CODECOV_TOKEN }} 136 | 137 | dockerbuild: 138 | runs-on: ubuntu-latest 139 | steps: 140 | - name: Checkout 141 | uses: actions/checkout@v4 142 | with: 143 | submodules: true 144 | 145 | - run: ./docker_build.sh 146 | 147 | auto-tag: 148 | if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main'}} 149 | needs: 150 | - dockerbuild 151 | - build 152 | outputs: 153 | created-tag: ${{ steps.auto-tag.outputs.tag }} 154 | runs-on: ubuntu-latest 155 | steps: 156 | - name: Checkout 157 | uses: actions/checkout@v4 158 | with: 159 | fetch-depth: 2 160 | 161 | - name: Set up Python 162 | uses: actions/setup-python@v5 163 | with: 164 | python-version: '3.13' 165 | 166 | - name: Display Python version 167 | run: python --version 168 | 169 | - name: install poetry 170 | uses: abatilo/actions-poetry@v2 171 | with: 172 | poetry-version: '2.1.1' 173 | 174 | - name: create tag from pyproject version on change. 175 | id: auto-tag 176 | uses: salsify/action-detect-and-tag-new-version@v2 177 | with: 178 | version-command: | 179 | poetry version -s 180 | 181 | project-release: 182 | needs: 183 | - auto-tag 184 | if: ${{ needs.auto-tag.outputs.created-tag != '' }} 185 | runs-on: ubuntu-latest 186 | steps: 187 | - name: Create GitHub release 188 | uses: Roang-zero1/github-create-release-action@master 189 | with: 190 | created_tag: ${{ needs.auto-tag.outputs.created-tag }} 191 | env: 192 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 193 | 194 | 195 | pip-publish: 196 | needs: 197 | - auto-tag 198 | if: ${{ needs.auto-tag.outputs.created-tag != '' }} 199 | runs-on: ubuntu-latest 200 | steps: 201 | - name: Checkout 202 | uses: actions/checkout@v4 203 | with: 204 | submodules: true 205 | 206 | - name: Set up Python 207 | uses: actions/setup-python@v5 208 | with: 209 | python-version: '3.13' 210 | 211 | - name: Display Python version 212 | run: python --version 213 | 214 | - name: Install poetry 215 | uses: abatilo/actions-poetry@v2 216 | with: 217 | poetry-version: '2.1.1' 218 | 219 | - name: Install Go 220 | uses: actions/setup-go@v5 221 | with: 222 | go-version-file: 'videohashes/go.mod' 223 | cache-dependency-path: 'videohashes/go.sum' 224 | 225 | - name: Install ffmpeg 226 | run: | 227 | if [ "$RUNNER_OS" == "Linux" ]; then 228 | sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg 229 | elif [ "$RUNNER_OS" == "Windows" ]; then 230 | choco install -q -y ffmpeg 231 | elif [ "$RUNNER_OS" == "macOS" ]; then 232 | env HOMEBREW_NO_AUTO_UPDATE=1 brew install ffmpeg 233 | fi 234 | shell: bash 235 | 236 | - uses: pnpm/action-setup@v3 237 | with: 238 | version: 10 239 | 240 | - uses: actions/setup-node@v4 241 | with: 242 | cache: 'pnpm' 243 | node-version: '22' 244 | 245 | - name: Intall Poetry Dependencies 246 | run: poetry install 247 | 248 | - name: Build All 249 | run: poetry run poe build_all 250 | 251 | - name: Prepare to publish python pips. 252 | if: "${{ env.PYPI_TOKEN != '' }}" 253 | run: poetry config pypi-token.pypi "$PYPI_TOKEN" 254 | env: 255 | PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} 256 | 257 | - name: publish pip 258 | run: poetry publish 259 | if: "${{ env.PYPI_TOKEN != '' }}" 260 | env: 261 | PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} 262 | 263 | docker-release: 264 | needs: 265 | - auto-tag 266 | if: ${{ needs.auto-tag.outputs.created-tag != '' }} 267 | permissions: 268 | packages: write 269 | contents: read 270 | runs-on: ubuntu-latest 271 | steps: 272 | - name: Login to GitHub Container Registry 273 | uses: docker/login-action@v3 274 | with: 275 | registry: ghcr.io 276 | username: ${{ github.actor }} 277 | password: ${{ secrets.GITHUB_TOKEN }} 278 | 279 | - name: Checkout 280 | uses: actions/checkout@v4 281 | with: 282 | submodules: true 283 | 284 | - name: build and tag 285 | run: | 286 | export BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') 287 | export GIT_HASH=$(git rev-parse --verify HEAD) 288 | export repo=theporndatabase 289 | export version=$( echo "${{ needs.auto-tag.outputs.created-tag }}" | grep -e '[0-9.]*') 290 | docker build . --build-arg "BUILD_DATE=${BUILD_DATE}" --build-arg "GIT_HASH=${GIT_HASH}" --build-arg "PROJECT_VERSION=${version}" -t "ghcr.io/${repo}/namer:${version}" -t "ghcr.io/${repo}/namer:latest" 291 | docker push "ghcr.io/${repo}/namer:${version}" 292 | docker push "ghcr.io/${repo}/namer:latest" 293 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/.DS_Store 2 | .vscode 3 | .idea 4 | __pycache__/ 5 | .coverage 6 | /htmlcov/ 7 | cov.xml 8 | coverage.xml 9 | UNKNOWN.egg-info/ 10 | dist/ 11 | creds.sh 12 | init 13 | .python-version 14 | node_modules 15 | .env 16 | namer/web/public/assets 17 | namer/web/templates 18 | namer/tools 19 | .flakeheaven_cache 20 | .ruff_cache 21 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "videohashes"] 2 | path = videohashes 3 | url = https://github.com/ThePornDatabase/videohashes.git 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm lint-staged -q 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:latest AS base 2 | 3 | ENV PATH="/root/.local/bin:$PATH" 4 | ENV TZ=Europe/London 5 | ARG DEBIAN_FRONTEND=noninteractive 6 | 7 | # Install dependencies. 8 | RUN apt-get update \ 9 | && apt-get install -y --no-install-recommends \ 10 | python3-pip \ 11 | python3 \ 12 | pipx \ 13 | ffmpeg \ 14 | tzdata \ 15 | curl \ 16 | && rm -rf /var/lib/apt/lists/* \ 17 | && rm -Rf /usr/share/doc && rm -Rf /usr/share/man \ 18 | && apt-get clean 19 | 20 | FROM base AS build 21 | RUN apt-get update \ 22 | && apt-get install -y --no-install-recommends \ 23 | build-essential \ 24 | libffi-dev \ 25 | libssl-dev \ 26 | systemd \ 27 | systemd-sysv \ 28 | python3-dev \ 29 | python3-venv \ 30 | wget \ 31 | gnupg2 \ 32 | xvfb \ 33 | golang \ 34 | git \ 35 | && rm -rf /var/lib/apt/lists/* \ 36 | && rm -Rf /usr/share/doc && rm -Rf /usr/share/man \ 37 | && apt-get clean 38 | 39 | ENV DISPLAY=:99 40 | ARG CHROME_VERSION="google-chrome-stable" 41 | RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ 42 | && echo "deb http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list \ 43 | && apt-get update -qqy \ 44 | && apt-get -qqy install \ 45 | ${CHROME_VERSION:-google-chrome-stable} \ 46 | && rm /etc/apt/sources.list.d/google-chrome.list \ 47 | && rm -rf /var/lib/apt/lists/* /var/cache/apt/* 48 | 49 | RUN pipx install poetry 50 | RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash 51 | RUN . /root/.bashrc && nvm install 22 52 | RUN . /root/.bashrc && npm i -g pnpm@latest-10 53 | 54 | RUN mkdir /work/ 55 | COPY . /work 56 | WORKDIR /work 57 | RUN rm -rf /work/namer/__pycache__/ || true \ 58 | && rm -rf /work/test/__pycache__/ || true \ 59 | && poetry install 60 | RUN . /root/.bashrc && ( Xvfb :99 & cd /work/ && poetry run poe build_all ) 61 | 62 | FROM base 63 | COPY --from=build /work/dist/namer-*.tar.gz / 64 | RUN pipx install /namer-*.tar.gz \ 65 | && rm /namer-*.tar.gz 66 | 67 | ARG BUILD_DATE 68 | ARG GIT_HASH 69 | ARG PROJECT_VERSION 70 | 71 | ENV PYTHONUNBUFFERED=1 72 | ENV NAMER_CONFIG=/config/namer.cfg 73 | ENV BUILD_DATE=$BUILD_DATE 74 | ENV GIT_HASH=$GIT_HASH 75 | ENV PROJECT_VERSION=$PROJECT_VERSION 76 | 77 | EXPOSE 6980 78 | HEALTHCHECK --interval=1m --timeout=30s CMD curl -s $(namer url)/api/healthcheck >/dev/null || exit 1 79 | ENTRYPOINT ["namer", "watchdog"] 80 | -------------------------------------------------------------------------------- /docker_build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | repo=theporndatabase 4 | version=v$(cat pyproject.toml | grep -m1 "version = " | sed 's/.* = //' | sed 's/"//g' | tr -d '[:space:]') 5 | 6 | BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') 7 | export BUILD_DATE 8 | 9 | GIT_HASH=$(git rev-parse --verify HEAD) 10 | export GIT_HASH 11 | 12 | docker build . --build-arg "BUILD_DATE=${BUILD_DATE}" --build-arg "GIT_HASH=${GIT_HASH}" --build-arg "PROJECT_VERSION=${version}" -t "ghcr.io/${repo}/namer:${version}" -t "ghcr.io/${repo}/namer:latest" 13 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from 'globals' 2 | import pluginJs from '@eslint/js' 3 | 4 | export default [ 5 | { languageOptions: { globals: globals.browser } }, 6 | pluginJs.configs.recommended, 7 | ] 8 | -------------------------------------------------------------------------------- /install_instructions.md: -------------------------------------------------------------------------------- 1 | Install Instructions 2 | ================= 3 | 4 | To start off with namer there are 3 main ways to install it: 5 | 6 | * Windows 7 | * Docker 8 | * Unraid 9 | 10 | Windows can be an easy setup for the uninitiated and has a pretty good webui that can take care of you in most ways. Docker/Unraid are very strong if you have a bit of know how as they are just more reliable and cause less problems. I'll start with windows and work down the list with installation instructions for each. 11 | 12 | Windows 13 | ----------------- 14 | 15 | To start off with windows you only really need to manually install one thing and that's python. Everything else will be downloaded for you once you do you install in cmd prompt. 16 | 17 | **Step 1:** Install the latest version of python from [here.](https://www.python.org/downloads) When you do this install make sure you tick the `Add Python to path`. It makes your life a lot easier, and you won't have to manually do it in the future. 18 | 19 | **Step 2:** With python now installed you can now download Namer through CMD. This is pretty easily done with a quick command. Open your CMD by typing CMD in your search bar and then type: `pip install namer`. This is going to download and install all the packages that namer needs to run correctly. 20 | 21 | **Step 3:** Now that namer is installed you can setup your config. Go ahead and get a copy of a default config page from GitHub [here](https://github.com/ThePornDatabase/namer/blob/main/namer/namer.cfg.default). You can copy it and paste it into your own document or download it straight from GitHub, the option is yours. Once you have it copied or downloaded, go ahead and place it in your install location. The default location that namers uses is `C:/Users/YOURUSER`. With it placed in the correct folder go ahead and rename the file to `.namer.cfg`. Make sure that you add the period to the beginning of file, or it will not be detected. 22 | 23 | **Step 4:** Now that you have the default config, you have to set it up correctly. I'm not going to go into detail about every setting as they are mostly labeled, I'll just do the ones to get you running. You are going to need to get an API key for namer to talk to TPDB. 24 | 25 | To do that go [here](https://theporndb.net) and make an account if you haven't already. Once you do go into your profile and create an API key. Call it whatever you want and then copy that key into the config in the `porndb_token` section. 26 | 27 | Scroll down until you see the `Watchdog` section. This is where we are going to setup your directories for your folders that namer will look for. It's super important that these are named correctly and **NOT INSIDE ONE ANOTHER**. Putting folders inside each other could result in unexpected errors and outputs. 28 | 29 | Namer uses 4 main directories: 30 | 31 | * **Watch:** This is where namer looks for new files. When your new scenes are dropped in here namer will detect it and go to work. 32 | 33 | * **Work:** This is where namer moves your new scene to, so it can do the work scanning and renaming. 34 | 35 | * **Failed:** This is where namer moves your files to if it failed to find a match and will automatically try again in 24 hours, unless settings are change to do it earlier. 36 | 37 | * **Dest:** This is where namer will place the final file after it has been scanned and renamed. 38 | 39 | These folders are important and all 4 need to be accessible for namer to work correctly. 40 | 41 | My recommendation for folder structure is as following. Make a new folder and call it whatever you want, for this example I will use **Namer** and place it on my C: drive. Inside that folder make 3 new folders, naming one **work**, one **failed** and one **dest**. Now that you have all the folders, head back to the config and place the correct directory next to each setting. 42 | 43 | * watch_dir = `C:/Namer/watch` 44 | * work_dir = `C:/Namer/work` 45 | * failed_dir = `C:/Namer/failed` 46 | 47 | Now your dest_dir directory can be whatever you want. Just **DON'T PLACE IT INSIDE ANOTHER NAMER FOLDER**. It can cause errors and problems that you won't like. 48 | 49 | Now that that is done the very basic functionally of namer is complete, and you can run a command in CMD and let it go to work. You can run `python -m namer watchdog` and that will activate watchdog, and it will start automatically scanning and renaming files in your watch folder then move them to your destination folder. 50 | 51 | **Step 5:** Most people, like I, don't like command line stuff, so we have a webUI that can be used to make life a little easier for you. Firstly you have to active it in your config, search for the `web = False` in your config and set it to `True`. The default port is essentially your local host and port is 6980. You can change this if you need to and know what to do. Otherwise, just go to your browser and type `localhost:6980`. This should bring up the webui where you can manually search and match your files. Namer will only show files that appear in your failed_dir. If you want to do everything manually in the UI, make sure your files are in the failed_dir. If you want namer to do most of the work, place them in watch_dir, let namer match, and then you can manually match anything that Namer can't find. 52 | 53 | After these steps you are pretty much setup. There are a lot more settings you can play with but, as I said earlier, they are marked, and you can play with them if you need to. 54 | 55 | Before you start going crazy and slapping files everywhere: 56 | 57 | Namer uses a `STUDIO-DATE-SCENE` format. Essentially this means your folders need to be named that way or namer will not match your files, and you will need to do it manually all the time. 58 | 59 | Unraid 60 | ---------------- 61 | 62 | Linux's systems are more complicated than Windows systems but can be more stable and less problematic as namer mostly runs passively in the background. This isn't going to be an Unraid tutorial, and I'm going to take certain liberties hoping that you already know how it works. 63 | 64 | **Step 1:** Go to your docker tab then click add container down below. I'll put an example template below, the names can be somewhat interchangeable, but you have to remember them and use your names accordingly. 65 | 66 | * Name: `namer` 67 | * Repository: `ghcr.io/theporndatabase/namer:latest` 68 | 69 | After these are filled in you want to go ahead and add some paths for your media, config, and ports if you plan on using the web. Scroll down and click on `Add another Path, Port, Variable, Label or Device`. While in here make it as such. 70 | 71 | * Config Type: `Path` 72 | * Name: `config` 73 | * Container path: `/config` 74 | * Host Path: `/mnt/user/appdata/namer` (This is important as it's where your namer.cfg is going to get placed.) 75 | * Default value: `/config` 76 | 77 | Now we're going to do that again but create for where your media and namer working folders are going to live. You can make one for your work folders and then another to where your media lives but that's up to your personal preference. 78 | 79 | * Config Type: `Path` 80 | * Name: `media` 81 | * Container path: `/media` 82 | * Host Path: `/mnt/user/media` (This is the top folder that all my work folders are in and then another subfolder where my media lives. Essentially when namer looks in /media it can see all 4 folders that namer uses) 83 | * Default Value: `/media` 84 | 85 | After that we can go ahead and link in our port for the webUI. You can use whatever port you see fit just make sure to update it on your namer.cfg 86 | 87 | * Config Type: `Port` 88 | * Name: `port` 89 | * Container port: `6980` (or whatever you want it to be) 90 | * Host port: `6980` 91 | * Connection Type: `TCP` 92 | 93 | We're going to need to pass a UMASK of 000 which can be done like so: 94 | 95 | * Config Type: `Variable` 96 | * Name: `UMASK` 97 | * Key: `000` 98 | * Value: `000` 99 | 100 | Now we're going to go ahead and get our UID and GID passed for namer permissions as well. You are going to need 99 for ID and 100 for UD if using standard nobody:user permissions. 101 | 102 | * Config Type: `Variable` 103 | * Name: `PUID` 104 | * Key: `99` 105 | * Value: `99` 106 | 107 | and now PGID 108 | 109 | * Config Type: `Variable` 110 | * Name: `PGID` 111 | * Key: `100` 112 | * Value: `100` 113 | 114 | We also need to make some PUID and PUIG ones for your perms. It's exactly the same as above but you use variable instead of path. 115 | 116 | So, all of these are essentially translations between namer and Unraid. When you type `/config` into namer it will tell Unraid and Unraid will look in `/mnt/user/appdata/namer`. This also applies to all the other ones we made too. 117 | 118 | So go ahead and hit `Apply` and let Unraid do its thing. Once you do that namer will be installed, but it won't run as since your config isn't where it needs to be. 119 | 120 | **Step 2:** Go ahead and move your namer.cfg into your `/config` folder. You can do that via windows SMB or something of that sort. You can also learn how to create your config above in the Windows section. The only main difference is going to be your paths and the fact that, in Unraid, your `.namer.cfg` doesn't need the period at the beginning. If you put your folders in side of /media then your config should look like this: 121 | 122 | * watch_dir = `/media/watch` 123 | * work_dir = `/media/work` 124 | * failed_dir = `/media/failed` 125 | * dest_dir = `/media/DESTINATION` 126 | 127 | You can go ahead and set your webui and webui port if you wanted access to that as well. 128 | 129 | **Step 3:** You're essentially done, so you can go ahead and boot up namer. To access the webui you can go ahead and use your `serverip:6980`. Now you can start putting files in your watch folder and let namer go to work. 130 | -------------------------------------------------------------------------------- /lint-staged.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.py': (filenames) => filenames.map((filename) => `poetry run ruff check --output-format grouped "${filename}"`), 3 | '*.js': (filenames) => filenames.map((filename) => `pnpm eslint --no-color "${filename}"`) 4 | } 5 | -------------------------------------------------------------------------------- /logo/namer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePornDatabase/namer/19a75c32a5ecc2418548c304b2da4973490edb88/logo/namer.png -------------------------------------------------------------------------------- /namer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePornDatabase/namer/19a75c32a5ecc2418548c304b2da4973490edb88/namer/__init__.py -------------------------------------------------------------------------------- /namer/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Namer, the porn db file renamer. It can be a command line tool to rename mp4/mkv/avi/mov/flv files and to embed tags in mp4's, 3 | or a watchdog service to do the above watching a directory for new files. File names are assumed to be of 4 | the form SITE.[YY]YY.MM.DD.String.of.performers.and.or.scene.name..[mp4|mkv|...]. In the name, read the 5 | periods, ".", as any number of spaces " ", dashes "-", or periods ".". 6 | 7 | Provided you have an access token to the porndb (free sign up) https://www.theporndb.net/, this program will 8 | attempt to match your file's name to search results from the porndb. Please note that the site must at least be 9 | a substring of the actual site name on the porndb, and the date must be within one day or the release date on the 10 | porndb for a match to be considered. If the log file flag is enabled then a _namer.json.gz 11 | file will be written with all the potential matches sorted, descending by how closely the scene name/performer names 12 | match the file. 13 | """ 14 | 15 | import sys 16 | from datetime import timedelta 17 | from pathlib import Path 18 | 19 | from loguru import logger 20 | from requests_cache import CachedSession 21 | 22 | import namer.metadataapi 23 | import namer.namer 24 | import namer.videohashes 25 | import namer.watchdog 26 | import namer.web 27 | from namer.configuration_utils import default_config 28 | from namer.models import db 29 | 30 | DESCRIPTION = ( 31 | namer.namer.DESCRIPTION 32 | + """ 33 | 34 | The first argument should be 'watchdog', 'rename', 'suggest', or 'help' to see this message, for more help on rename, call 35 | namer 'namer rename -h' 36 | 37 | watchdog and help take no arguments (please see the config file example https://github.com/ThePornDatabase/namer/blob/main/namer/namer.cfg.default) 38 | 39 | 'suggest' takes a file name as input and will output a suggested file name. 40 | 'url' print url to namer web ui. 41 | 'hash' takes a file name as input and will output a hashes in json format. 42 | """ 43 | ) 44 | 45 | 46 | def create_default_config_if_missing(): 47 | """ 48 | Find or create config. 49 | """ 50 | config_file = Path('.namer.conf') 51 | print('Creating default config file here: {}', config_file) 52 | print('please edit the token or any other settings whose defaults you want changed.') 53 | 54 | 55 | def main(): 56 | """ 57 | Call main method in namer.namer or namer.watchdog. 58 | """ 59 | logger.remove() 60 | config = default_config() 61 | 62 | arg_list = sys.argv[1:] 63 | 64 | # create a CachedSession objects for request caching. 65 | if config.use_requests_cache: 66 | cache_file = config.database_path / 'namer_cache' 67 | expire_time = timedelta(minutes=config.requests_cache_expire_minutes) 68 | config.cache_session = CachedSession(str(cache_file), backend='sqlite', expire_after=expire_time, ignored_parameters=['Authorization']) 69 | 70 | if config.use_database: 71 | db_file = config.database_path / 'namer_database.sqlite' 72 | db.bind(provider='sqlite', filename=str(db_file), create_db=True) 73 | db.generate_mapping(create_tables=True) 74 | 75 | arg1 = None if len(arg_list) == 0 else arg_list[0] 76 | if arg1 == 'watchdog': 77 | namer.watchdog.main(config) 78 | elif arg1 == 'rename': 79 | namer.namer.main(arg_list[1:]) 80 | elif arg1 == 'suggest': 81 | namer.metadataapi.main(arg_list[1:]) 82 | elif arg1 == 'url': 83 | print(f'http://{config.host}:{config.port}{config.web_root}') 84 | elif arg1 == 'hash': 85 | namer.videohashes.main(arg_list[1:]) 86 | elif arg1 in ['-h', 'help', None]: 87 | print(DESCRIPTION) 88 | 89 | if config.use_requests_cache and config.cache_session: 90 | config.cache_session.cache.delete(expired=True) 91 | 92 | 93 | if __name__ == '__main__': 94 | main() 95 | -------------------------------------------------------------------------------- /namer/database.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Optional 3 | 4 | from pony.orm import commit, db_session 5 | 6 | from namer.models import File 7 | from namer.videophash import PerceptualHash 8 | 9 | abbreviations = { 10 | '18og': '18OnlyGirls', 11 | '18yo': '18YearsOld', 12 | '1kf': '1000Facials', 13 | '21ea': '21EroticAnal', 14 | '21fa': '21FootArt', 15 | '21n': '21Naturals', 16 | '2cst': '2ChicksSameTime', 17 | 'a1o1': 'Asian1on1', 18 | 'aa': 'AmateurAllure', 19 | 'abbw': 'AbbyWinters', 20 | 'abme': 'AbuseMe', 21 | 'ad': 'AmericanDaydreams', 22 | 'add': 'ManualAddActors', 23 | 'agm': 'AllGirlMassage', 24 | 'aktp': 'ATKPetites', 25 | 'am': 'AssMasterpiece', 26 | 'ana': 'AnalAngels', 27 | 'analb': 'AnalBeauty', 28 | 'atke': 'ATKExotics', 29 | 'atkg': 'ATKGalleria', 30 | 'atkgfs': 'ATKGirlfriends', 31 | 'atkh': 'ATKHairy', 32 | 'ba': 'BeautyAngels', 33 | 'bam': 'BruceAndMorgan', 34 | 'bblib': 'BigButtsLikeItBig', 35 | 'bcast': 'BrutalCastings', 36 | 'bcasting': 'BangCasting', 37 | 'bcb': 'BigCockBully', 38 | 'bch': 'BigCockHero', 39 | 'bconfessions': 'BangConfessions', 40 | 'bd': 'BrutalDildos', 41 | 'bdpov': 'BadDaddyPOV', 42 | 'bex': 'BrazzersExxtra', 43 | 'bgb': 'BabyGotBoobs', 44 | 'bgbs': 'BoundGangbangs', 45 | 'bgfs': 'BlackGFS', 46 | 'bglamkore': 'BangGlamkore', 47 | 'bgonzo': 'BangGonzo', 48 | 'bin': 'BigNaturals', 49 | 'bjf': 'BlowjobFridays', 50 | 'bna': 'BrandNew', 51 | 'bp': 'ButtPlays', 52 | 'bpu': 'BrutalPickups', 53 | 'brealmilfs': 'BangRealMilfs', 54 | 'brealteens': 'BangRealTeens', 55 | 'btas': 'BigTitsAtSchool', 56 | 'btaw': 'BigTitsAtWork', 57 | 'btc': 'BigTitCreampie', 58 | 'btis': 'BigTitsInSports', 59 | 'btiu': 'BigTitsInUniform', 60 | 'btlbd': 'BigTitsLikeBigDicks', 61 | 'btp': 'BadTeensPunished', 62 | 'btra': 'BigTitsRoundAsses', 63 | 'burna': 'BurningAngel', 64 | 'bwb': 'BigWetButts', 65 | 'byngr': 'BangYNGR', 66 | 'cc': 'CzechCasting', 67 | 'cfnm': 'ClothedFemaleNudeMale', 68 | 'cfnms': 'FNMSecret', 69 | 'cfnmt': 'CFNMTeens', 70 | 'cgfs': 'CzechGFS', 71 | 'clip': 'LegalPorno', 72 | 'cps': 'CherryPimps', 73 | 'css': 'CzechStreets', 74 | 'cuf': 'CumFiesta', 75 | 'cws': 'CzechWifeSwap', 76 | 'cza': 'CzechAmateurs', 77 | 'czb': 'CzechBitch', 78 | 'czbb': 'CzechBangBus', 79 | 'czc': 'CzechCouples', 80 | 'czestro': 'CzechEstrogenolit', 81 | 'czf': 'CzechFantasy', 82 | 'czgb': 'CzechGangBang', 83 | 'czharem': 'CzechHarem', 84 | 'czm': 'CzechMassage', 85 | 'czo': 'CzechOrgasm', 86 | 'czps': 'CzechPawnShop', 87 | 'czt': 'CzechTwins', 88 | 'cztaxi': 'CzechTaxi', 89 | 'da': 'DoctorAdventures', 90 | 'dbm': 'DontBreakMe', 91 | 'dc': 'DorcelVision', 92 | 'ddfb': 'DDFBusty', 93 | 'ddfvr': 'DDFNetworkVR', 94 | 'deb': 'DeviceBondage', 95 | 'dlla': 'DaddysLilAngel', 96 | 'dm': 'DirtyMasseur', 97 | 'dnj': 'DaneJones', 98 | 'doan': 'DiaryOfANanny', 99 | 'dpf': 'DPFanatics', 100 | 'dpg': 'DigitalPlayground', 101 | 'ds': 'DungeonSex', 102 | 'dsw': 'DaughterSwap', 103 | 'dts': 'DeepThroatSirens', 104 | 'dwc': 'DirtyWivesClub', 105 | 'dwp': 'DayWithAPornstar', 106 | 'esp': 'EuroSexParties', 107 | 'ete': 'EuroTeenErotica', 108 | 'ext': 'ExxxtraSmall', 109 | 'fab': 'FuckedAndBound', 110 | 'fams': 'FamilyStrokes', 111 | 'faq': 'FirstAnalQuest', 112 | 'fbbg': 'FirstBGG', 113 | 'fds': 'FakeDrivingSchool', 114 | 'ff': 'FilthyFamily', 115 | 'ffr': 'FacialsForever', 116 | 'fft': 'FemaleFakeTaxi', 117 | 'fhd': 'FantasyHD', 118 | 'fhl': 'FakeHostel', 119 | 'fho': 'FakeHubOriginals', 120 | 'fka': 'FakeAgent', 121 | 'fm': 'FuckingMachines', 122 | 'fms': 'FantasyMassage', 123 | 'frs': 'FitnessRooms', 124 | 'fs': 'FuckStudies', 125 | 'ft': 'FastTimes', 126 | 'ftx': 'FakeTaxi', 127 | 'fum': 'FuckingMachines', 128 | 'gbcp': 'GangbangCreampie', 129 | 'gdp': 'GirlsDoPorn', 130 | 'gfr': 'GFRevenge', 131 | 'gft': 'GrandpasFuckTeens', 132 | 'gta': 'GirlsTryAnal', 133 | 'gw': 'GirlsWay', 134 | 'h1o1': 'Housewife1on1', 135 | 'ham': 'HotAndMean', 136 | 'hart': 'Hegre', 137 | 'hcm': 'HotCrazyMess', 138 | 'hletee': 'HelplessTeens', 139 | 'hoh': 'HandsOnHardcore', 140 | 'hotab': 'HouseOfTaboo', 141 | 'hotb': 'HouseOfTaboo', 142 | 'ht': 'Hogtied', 143 | 'ihaw': 'IHaveAWife', 144 | 'iktg': 'IKnowThatGirl', 145 | 'il': 'ImmoralLive', 146 | 'infr': 'InfernalRestraints', 147 | 'inh': 'InnocentHigh', 148 | 'itc': 'InTheCrack', 149 | 'jlmf': 'JessieLoadsMonsterFacials', 150 | 'kha': 'KarupsHA', 151 | 'kow': 'KarupsOW', 152 | 'kpc': 'KarupsPC', 153 | 'la': 'LatinAdultery', 154 | 'lang': 'LANewGirl', 155 | 'lcd': 'LittleCaprice', 156 | 'lhf': 'LoveHerFeet', 157 | 'lsb': 'Lesbea', 158 | 'lst': 'LatinaSexTapes', 159 | 'lta': 'LetsTryAnal', 160 | 'maj': 'ManoJob', 161 | 'mbb': 'MommyBlowsBest', 162 | 'mbc': 'MyBabysittersClub', 163 | 'mbt': 'MomsBangTeens', 164 | 'mc': 'MassageCreep', 165 | 'mcu': 'MonsterCurves', 166 | 'mdhf': 'MyDaughtersHotFriend', 167 | 'mdhg': 'MyDadsHotGirlfriend', 168 | 'mdm': 'MyDirtyMaid', 169 | 'mfa': 'ManuelFerrara', 170 | 'mfhg': 'MyFriendsHotGirl', 171 | 'mfhm': 'MyFriendsHotMom', 172 | 'mfl': 'Mofos', 173 | 'mfp': 'MyFamilyPies', 174 | 'mfst': 'MyFirstSexTeacher', 175 | 'mgb': 'MommyGotBoobs', 176 | 'mgbf': 'MyGirlfriendsBustyFriend', 177 | 'mic': 'MomsInControl', 178 | 'mj': 'ManoJob', 179 | 'mlib': 'MilfsLikeItBig', 180 | 'mlt': 'MomsLickTeens', 181 | 'mmgs': 'MommysGirl', 182 | 'mmp': 'MMPNetwork', 183 | 'mnm': 'MyNaughtyMassage', 184 | 'mot': 'MoneyTalks', 185 | 'mpov': 'MrPOV', 186 | 'mrs': 'MassageRooms', 187 | 'mshf': 'MySistersHotFriend', 188 | 'mts': 'MomsTeachSex', 189 | 'mvft': 'MyVeryFirstTime', 190 | 'mwhf': 'MyWifesHotFriend', 191 | 'na': 'NaughtyAthletics', 192 | 'naf': 'NeighborAffair', 193 | 'nam': 'NaughtyAmerica', 194 | 'nb': 'NaughtyBookworms', 195 | 'news': 'NewSensations', 196 | 'nf': 'NubileFilms', 197 | 'no': 'NaughtyOffice', 198 | 'nrg': 'NaughtyRichGirls', 199 | 'nubilef': 'NubileFilms', 200 | 'nubp': 'NubilesPorn', 201 | 'num': 'NuruMassage', 202 | 'nvg': 'NetVideoGirls', 203 | 'nw': 'NaughtyWeddings', 204 | 'obj': 'OnlyBlowjob', 205 | 'oo': 'OnlyOpaques', 206 | 'os': 'OnlySecretaries', 207 | 'oss': 'OnlySilAndSatin', 208 | 'otb': 'OnlyTeenBlowjobs', 209 | 'pav': 'PixAndVideo', 210 | 'pba': 'PublicAgent', 211 | 'pbf': 'PetiteBallerinasFucked', 212 | 'pc': 'PrincessCum', 213 | 'pdmqfo': 'QuestForOrgasm', 214 | 'pf': 'PornFidelity', 215 | 'phd': 'PassionHD', 216 | 'phdp': 'PetiteHDPorn', 217 | 'plib': 'PornstarsLikeItBig', 218 | 'pop': 'PervsOnPatrol', 219 | 'ppu': 'PublicPickups', 220 | 'prdi': 'PrettyDirty', 221 | 'ps': 'PropertySex', 222 | 'psp': 'PornstarsPunishment', 223 | 'psus': 'PascalsSubSluts', 224 | 'pud': 'PublicDisgrace', 225 | 'rab': 'RoundAndBrown', 226 | 'reg': 'RealExGirlfriends', 227 | 'rkp': 'RKPrime', 228 | 'rtb': 'RealTimeBondage', 229 | 'rws': 'RealWifeStories', 230 | 'saf': 'ShesAFreak', 231 | 'sart': 'SexArt', 232 | 'sas': 'SexAndSubmission', 233 | 'sbj': 'StreetBlowjobs', 234 | 'seb': 'SexuallyBroken', 235 | 'sed': 'SexualDisgrace', 236 | 'sislov': 'SisLovesMe', 237 | 'sislove': 'SisLovesMe', 238 | 'smb': 'ShareMyBF', 239 | 'sr': 'SadisticRope', 240 | 'ssc': 'StepSiblingsCaught', 241 | 'ssn': 'ShesNew', 242 | 'steps': 'StepSiblings', 243 | 'stre': 'StrictRestraint', 244 | 'sts': 'StrandedTeens', 245 | 'swsn': 'SwallowSalon', 246 | 't18': 'Taboo18', 247 | 'taob': 'TheArtOfBlowJob', 248 | 'tdp': 'TeensDoPorn', 249 | 'tds': 'TheDickSuckers', 250 | 'ted': 'Throated', 251 | 'tf': 'TeenFidelity', 252 | 'tfcp': 'FullyClothedPissing', 253 | 'tft': 'TeacherFucksTeens', 254 | 'tg': 'TopGrl', 255 | 'tgs': 'ThisGirlSucks', 256 | 'tgw': 'ThaiGirlsWild', 257 | 'th': 'TwistysHard', 258 | 'these': 'TheStripperExperience', 259 | 'tla': 'TeensLoveAnal', 260 | 'tlc': 'TeensLoveCream', 261 | 'tle': 'TheLifeErotic', 262 | 'tlhc': 'TeensLoveHugeCocks', 263 | 'tlib': 'TeensLikeItBig', 264 | 'tlm': 'TeensLoveMoney', 265 | 'tmf': 'TeachMeFisting', 266 | 'tog': 'TonightsGirlfriend', 267 | 'togc': 'TonightsGirlfriendClassic', 268 | 'trwo': 'TheRealWorkout', 269 | 'tslw': 'SlimeWave', 270 | 'tsm': 'TeenSexMovs', 271 | 'tsma': 'TeenSexMania', 272 | 'tspa': 'TrickySpa', 273 | 'tss': 'ThatSitcomShow', 274 | 'tt': 'TryTeens', 275 | 'tto': 'TheTrainingOfO', 276 | 'ttw': 'TeensInTheWoods', 277 | 'tuf': 'TheUpperFloor', 278 | 'vp': 'VIPissy', 279 | 'wa': 'WhippedAss', 280 | 'wfbg': 'WeFuckBlackGirls', 281 | 'wgp': 'WhenGirlsPlay', 282 | 'wkp': 'Wicked', 283 | 'wlt': 'WeLiveTogether', 284 | 'woc': 'WildOnCam', 285 | 'wov': 'WivesOnVacation', 286 | 'wowg': 'WowGirls', 287 | 'wpa': 'WhippedAss', 288 | 'wrh': 'WeAreHairy', 289 | 'wy': 'WebYoung', 290 | 'yt': 'YoungThroats', 291 | 'zb': 'ZoliBoy', 292 | 'ztod': 'ZeroTolerance', 293 | 'zzs': 'ZZseries', 294 | } 295 | 296 | re_cleanup = [r'[0-9]{3,4}x[0-9]{3,4}', r'[0-9]{2}fps', r'[0-9]{3,4}p', r'[0-9]k', r'XXX.*', r'\[WEBDL-.*'] 297 | 298 | 299 | def safe_write_file_to_database(working_item: Path, phash: PerceptualHash): 300 | if not search_file_in_database(working_item): 301 | write_file_to_database(working_item, phash) 302 | 303 | 304 | @db_session 305 | def write_file_to_database(working_item: Path, phash: PerceptualHash): 306 | item_stats = working_item.stat() 307 | item_phash = str(phash.phash) if phash else None 308 | item_oshash = phash.oshash if phash else None 309 | 310 | File(file_name=working_item.name, file_size=item_stats.st_size, file_time=item_stats.st_mtime, duration=phash.duration, phash=item_phash, oshash=item_oshash) 311 | commit() 312 | 313 | 314 | @db_session 315 | def search_file_in_database(working_item: Path) -> Optional[File]: 316 | item_stats = working_item.stat() 317 | 318 | search_result = File.get(file_name=working_item.name, file_size=item_stats.st_size, file_time=item_stats.st_mtime) 319 | return search_result 320 | -------------------------------------------------------------------------------- /namer/fileinfo.py: -------------------------------------------------------------------------------- 1 | """ 2 | Parse string in to FileNamePart define in namer_types. 3 | """ 4 | 5 | from dataclasses import dataclass 6 | import re 7 | from pathlib import PurePath 8 | from typing import List, Optional, Pattern 9 | 10 | from loguru import logger 11 | 12 | from namer.configuration import NamerConfig 13 | from namer.videophash import PerceptualHash 14 | 15 | DEFAULT_REGEX_TOKENS = '{_site}{_sep}{_optional_date}{_ts}{_name}{_dot}{_ext}' 16 | 17 | 18 | @dataclass(init=False, repr=False, eq=True, order=False, unsafe_hash=True, frozen=False) 19 | class FileInfo: 20 | """ 21 | Represents info parsed from a file name, usually of a nzb, named something like: 22 | 'EvilAngel.22.01.03.Carmela.Clutch.Fabulous.Anal.3-Way.XXX.2160p.MP4-GAYME-xpost' 23 | or 24 | 'DorcelClub.20.12..Aya.Benetti.Megane.Lopez.And.Bella.Tina.2160p.MP4-GAYME-xpost' 25 | """ 26 | 27 | # pylint: disable=too-many-instance-attributes 28 | 29 | site: Optional[str] = None 30 | """ 31 | Site the file originated from, "DorcelClub", "EvilAngel", etc. 32 | """ 33 | date: Optional[str] = None 34 | """ 35 | formatted: YYYY-mm-dd 36 | """ 37 | trans: bool = False 38 | """ 39 | If the name originally started with an "TS" or "ts" 40 | it will be stripped out and placed in a separate location, aids in matching, usable to genre mark content. 41 | """ 42 | name: Optional[str] = None 43 | """ 44 | The remained of a file, usually between the date and video markers such as XXX, 4k, etc. Heavy lifting 45 | occurs to match this to a scene name, perform names, or a combo of both. 46 | """ 47 | extension: Optional[str] = None 48 | """ 49 | The file's extension .mp4 or .mkv 50 | """ 51 | source_file_name: Optional[str] = None 52 | """ 53 | What was originally parsed. 54 | """ 55 | hashes: Optional[PerceptualHash] = None 56 | """ 57 | File hashes. 58 | """ 59 | 60 | def __str__(self) -> str: 61 | return f"""site: {self.site} 62 | date: {self.date} 63 | trans: {self.trans} 64 | name: {self.name} 65 | extension: {self.extension} 66 | original full name: {self.source_file_name} 67 | hashes: {self.hashes.to_dict() if self.hashes else None} 68 | """ 69 | 70 | 71 | def name_cleaner(name: str, re_cleanup: List[Pattern]) -> str: 72 | """ 73 | Given the name parts, following a date, but preceding the file extension, attempt to glean 74 | extra information and discard useless information for matching with the porndb. 75 | """ 76 | for regex in re_cleanup: 77 | name = regex.sub('', name) 78 | 79 | name = name.replace('.', ' ') 80 | name = ' '.join(name.split()).strip('-') 81 | 82 | return name 83 | 84 | 85 | def parser_config_to_regex(tokens: str) -> Pattern[str]: 86 | """ 87 | ``{_site}{_sep}{_optional_date}{_ts}{_name}{_dot}{_ext}`` 88 | 89 | ``Site - YYYY.MM.DD - TS - name.mkv`` 90 | 91 | ``` 92 | _sep r'[\\.\\- ]+' 93 | _site r'(?P[a-zA-Z0-9\\'\\.\\-\\ ]*?[a-zA-Z0-9]*?)' 94 | _date r'(?P[0-9]{2}(?:[0-9]{2})?)[\\.\\- ]+(?P[0-9]{2})[\\.\\- ]+(?P[0-9]{2})' 95 | _optional_date r'(?:(?P[0-9]{2}(?:[0-9]{2})?)[\\.\\- ]+(?P[0-9]{2})[\\.\\- ]+(?P[0-9]{2})[\\.\\- ]+)?' 96 | _ts r'((?P[T|t][S|s])'+_sep+'){0,1}' 97 | _name r'(?P(?:.(?![0-9]{2,4}[\\.\\- ][0-9]{2}[\\.\\- ][0-9]{2}))*)' 98 | _dot r'\\.' 99 | _ext r'(?P[a-zA-Z0-9]{3,4})$' 100 | ``` 101 | """ 102 | 103 | _sep = r'[\.\- ]+' 104 | _site = r'(?P.*?)' 105 | _date = r'(?P[0-9]{2}(?:[0-9]{2})?)[\.\- ]+(?P[0-9]{2})[\.\- ]+(?P[0-9]{2})' 106 | _optional_date = r'(?:(?P[0-9]{2}(?:[0-9]{2})?)[\.\- ]+(?P[0-9]{2})[\.\- ]+(?P[0-9]{2})[\.\- ]+)?' 107 | _ts = r'((?P[T|t][S|s])' + _sep + '){0,1}' 108 | _name = r'(?P(?:.(?![0-9]{2,4}[\.\- ][0-9]{2}[\.\- ][0-9]{2}))*)' 109 | _dot = r'\.' 110 | _ext = r'(?P[a-zA-Z0-9]{3,4})$' 111 | regex = tokens.format_map( 112 | { 113 | '_site': _site, 114 | '_date': _date, 115 | '_optional_date': _optional_date, 116 | '_ts': _ts, 117 | '_name': _name, 118 | '_ext': _ext, 119 | '_sep': _sep, 120 | '_dot': _dot, 121 | } 122 | ) 123 | return re.compile(regex) 124 | 125 | 126 | def parse_file_name(filename: str, namer_config: NamerConfig) -> FileInfo: 127 | """ 128 | Given an input name of the form site-yy.mm.dd-some.name.part.1.XXX.2160p.mp4, 129 | parses out the relevant information in to a structure form. 130 | """ 131 | filename = replace_abbreviations(filename, namer_config) 132 | regex = parser_config_to_regex(namer_config.name_parser) 133 | file_name_parts = FileInfo() 134 | file_name_parts.extension = PurePath(filename).suffix[1:] 135 | match = regex.search(filename) 136 | if match: 137 | if match.groupdict().get('year'): 138 | prefix = '20' if len(match.group('year')) == 2 else '' 139 | file_name_parts.date = prefix + match.group('year') + '-' + match.group('month') + '-' + match.group('day') 140 | 141 | if match.groupdict().get('name'): 142 | file_name_parts.name = name_cleaner(match.group('name'), namer_config.re_cleanup) 143 | 144 | if match.groupdict().get('site'): 145 | file_name_parts.site = match.group('site') 146 | 147 | if match.groupdict().get('trans'): 148 | trans = match.group('trans') 149 | file_name_parts.trans = bool(trans and trans.strip().upper() == 'TS') 150 | 151 | file_name_parts.extension = match.group('ext') 152 | file_name_parts.source_file_name = filename 153 | else: 154 | logger.debug('Could not parse target name which may be a file (or directory) name depending on settings and input: {}', filename) 155 | 156 | return file_name_parts 157 | 158 | 159 | def replace_abbreviations(text: str, namer_config: NamerConfig): 160 | for abbreviation, full in namer_config.site_abbreviations.items(): 161 | if abbreviation.match(text): 162 | text = abbreviation.sub(full, text, 1) 163 | break 164 | 165 | return text 166 | -------------------------------------------------------------------------------- /namer/http.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from io import BytesIO 3 | from typing import Optional 4 | 5 | import requests 6 | from loguru import logger 7 | from requests_cache import CachedSession 8 | 9 | 10 | class RequestType(Enum): 11 | GET = 'GET' 12 | POST = 'POST' 13 | HEAD = 'HEAD' 14 | 15 | 16 | class Http: 17 | @staticmethod 18 | def request(method: RequestType, url, **kwargs): 19 | logger.debug(f'Requesting {method.value} "{url}"') 20 | cache_session: Optional[CachedSession] = kwargs.get('cache_session') 21 | if 'cache_session' in kwargs: 22 | del kwargs['cache_session'] 23 | 24 | if kwargs.get('stream', False) or not isinstance(cache_session, CachedSession): 25 | return requests.request(method.value, url, **kwargs) 26 | else: 27 | return cache_session.request(method.value, url, **kwargs) 28 | 29 | @staticmethod 30 | def get(url: str, **kwargs): 31 | return Http.request(RequestType.GET, url, **kwargs) 32 | 33 | @staticmethod 34 | def post(url: str, **kwargs): 35 | return Http.request(RequestType.POST, url, **kwargs) 36 | 37 | @staticmethod 38 | def head(url: str, **kwargs): 39 | return Http.request(RequestType.HEAD, url, **kwargs) 40 | 41 | @staticmethod 42 | def download_file(url: str, **kwargs) -> Optional[BytesIO]: 43 | kwargs.setdefault('stream', True) 44 | http = Http.get(url, **kwargs) 45 | if http.ok: 46 | f = BytesIO() 47 | for data in http.iter_content(1024): 48 | f.write(data) 49 | 50 | return f 51 | 52 | return None 53 | -------------------------------------------------------------------------------- /namer/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import db 2 | from .file import File 3 | 4 | __all__ = ['db', 'File'] 5 | -------------------------------------------------------------------------------- /namer/models/base.py: -------------------------------------------------------------------------------- 1 | from pony import orm 2 | 3 | db = orm.Database() 4 | -------------------------------------------------------------------------------- /namer/models/file.py: -------------------------------------------------------------------------------- 1 | from pony.orm import Optional, PrimaryKey, Required 2 | 3 | from namer.models import db 4 | 5 | 6 | class File(db.Entity): 7 | id = PrimaryKey(int, auto=True) 8 | 9 | file_name = Required(str) 10 | file_size = Required(int, size=64) 11 | file_time = Required(float) 12 | 13 | duration = Optional(int) 14 | phash = Optional(str) 15 | oshash = Optional(str) 16 | -------------------------------------------------------------------------------- /namer/moviexml.py: -------------------------------------------------------------------------------- 1 | """ 2 | Reads movie.xml (your.movie.name.nfo) of Emby/Jellyfin format in to a LookedUpFileInfo, 3 | allowing the metadata to be written in to video files (currently only mp4's), 4 | or used in renaming the video file. 5 | """ 6 | 7 | from pathlib import Path 8 | 9 | from typing import Any, Optional, List 10 | from xml.dom.minidom import parseString, Document, Element 11 | 12 | from namer.configuration import NamerConfig 13 | from namer.command import set_permissions 14 | from namer.comparison_results import LookedUpFileInfo, Performer 15 | from namer.videophash import PerceptualHash 16 | 17 | 18 | def get_childnode(node: Element, name: str) -> Element: 19 | return node.getElementsByTagName(name)[0] 20 | 21 | 22 | def get_all_childnode(node: Element, name: str) -> List[Element]: 23 | return node.getElementsByTagName(name) 24 | 25 | 26 | def get_childnode_text(node: Element, name: str) -> Optional[str]: 27 | node = node.getElementsByTagName(name) 28 | return node[0].childNodes[0].data if node else None 29 | 30 | 31 | def get_all_childnode_text(node: Element, name: str) -> List[str]: 32 | return [x.childNodes[0].data for x in node.getElementsByTagName(name)] 33 | 34 | 35 | def parse_movie_xml_file(xml_file: Path) -> LookedUpFileInfo: 36 | """ 37 | Parse an Emby/Jellyfin xml file and creates a LookedUpFileInfo from the data. 38 | """ 39 | content = xml_file.read_text(encoding='UTF-8') 40 | 41 | movie: Any = parseString(bytes(content, encoding='UTF-8')) 42 | info = LookedUpFileInfo() 43 | info.name = get_childnode_text(movie, 'title') 44 | info.site = get_all_childnode_text(movie, 'studio')[0] 45 | info.date = get_childnode_text(movie, 'releasedate') 46 | info.description = get_childnode_text(movie, 'plot') 47 | art = get_childnode(movie, 'art') 48 | info.poster_url = get_childnode_text(art, 'poster') 49 | 50 | info.performers = [] 51 | for actor in get_all_childnode(movie, 'actor'): 52 | name = get_childnode_text(actor, 'name') 53 | if actor and name: 54 | performer = Performer(name) 55 | performer.alias = get_childnode_text(actor, 'alias') 56 | performer.role = get_childnode_text(actor, 'role') 57 | info.performers.append(performer) 58 | 59 | phoenixadulturlid = get_childnode_text(movie, 'phoenixadulturlid') 60 | if phoenixadulturlid: 61 | info.look_up_site_id = phoenixadulturlid 62 | 63 | theporndbid = get_childnode_text(movie, 'theporndbid') 64 | if theporndbid: 65 | info.uuid = theporndbid 66 | 67 | info.tags = [] 68 | for genre in get_all_childnode_text(movie, 'genre'): 69 | info.tags.append(str(genre)) 70 | 71 | info.original_parsed_filename = None 72 | info.original_query = None 73 | info.original_response = None 74 | 75 | return info 76 | 77 | 78 | def add_sub_element(doc: Document, parent: Element, name: str, text: Optional[str] = None) -> Element: 79 | sub_element = doc.createElement(name) 80 | parent.appendChild(sub_element) 81 | 82 | if text: 83 | txt_node = doc.createTextNode(text) 84 | sub_element.appendChild(txt_node) 85 | 86 | return sub_element 87 | 88 | 89 | def add_all_sub_element(doc: Document, parent: Element, name: str, text_list: List[str]) -> None: 90 | if text_list: 91 | for text in text_list: 92 | sub_element = doc.createElement(name) 93 | parent.appendChild(sub_element) 94 | txt_node = doc.createTextNode(text) 95 | sub_element.appendChild(txt_node) 96 | 97 | 98 | def write_movie_xml_file(info: LookedUpFileInfo, config: NamerConfig, trailer: Optional[Path] = None, poster: Optional[Path] = None, background: Optional[Path] = None, phash: Optional[PerceptualHash] = None) -> str: 99 | """ 100 | Parse porndb info and create an Emby/Jellyfin xml file from the data. 101 | """ 102 | doc = Document() 103 | root: Element = doc.createElement('movie') 104 | doc.appendChild(root) 105 | add_sub_element(doc, root, 'plot', info.description) 106 | add_sub_element(doc, root, 'outline') 107 | add_sub_element(doc, root, 'title', info.name) 108 | add_sub_element(doc, root, 'dateadded') 109 | add_sub_element(doc, root, 'trailer', str(trailer) if trailer else info.trailer_url) 110 | add_sub_element(doc, root, 'year', info.date[:4] if info.date else None) 111 | add_sub_element(doc, root, 'premiered', info.date) 112 | add_sub_element(doc, root, 'releasedate', info.date) 113 | add_sub_element(doc, root, 'mpaa', 'XXX') 114 | 115 | art = add_sub_element(doc, root, 'art') 116 | add_sub_element(doc, art, 'poster', poster.name if poster else info.poster_url) 117 | add_sub_element(doc, art, 'background', background.name if background else info.background_url) 118 | 119 | if config.enable_metadataapi_genres: 120 | add_all_sub_element(doc, root, 'genre', info.tags) 121 | else: 122 | add_all_sub_element(doc, root, 'tag', info.tags) 123 | add_sub_element(doc, root, 'genre', config.default_genre) 124 | 125 | add_sub_element(doc, root, 'studio', info.site) 126 | add_sub_element(doc, root, 'theporndbid', str(info.uuid)) 127 | add_sub_element(doc, root, 'theporndbguid', str(info.guid)) 128 | add_sub_element(doc, root, 'phoenixadultid') 129 | add_sub_element(doc, root, 'phoenixadulturlid') 130 | 131 | add_sub_element(doc, root, 'phash', str(phash.phash) if phash else None) 132 | add_sub_element(doc, root, 'sourceid', info.source_url) 133 | 134 | for performer in info.performers: 135 | actor = add_sub_element(doc, root, 'actor') 136 | add_sub_element(doc, actor, 'type', 'Actor') 137 | add_sub_element(doc, actor, 'name', performer.name) 138 | add_sub_element(doc, actor, 'alias', performer.alias) 139 | add_sub_element(doc, actor, 'role', performer.role) 140 | 141 | if performer.image: 142 | image = performer.image.name if isinstance(performer.image, Path) else performer.image 143 | add_sub_element(doc, actor, 'image', image) 144 | 145 | add_sub_element(doc, actor, 'thumb') 146 | 147 | add_sub_element(doc, root, 'fileinfo') 148 | 149 | return str(doc.toprettyxml(indent=' ', newl='\n', encoding='UTF-8'), encoding='UTF-8') 150 | 151 | 152 | def write_nfo(video_file: Path, new_metadata: LookedUpFileInfo, namer_config: NamerConfig, trailer: Optional[Path], poster: Optional[Path], background: Optional[Path], phash: Optional[PerceptualHash]): 153 | """ 154 | Writes an .nfo to the correct place for a video file. 155 | """ 156 | if video_file and new_metadata and namer_config.write_nfo: 157 | target = video_file.parent / (video_file.stem + '.nfo') 158 | with open(target, 'wt', encoding='UTF-8') as nfo_file: 159 | data = write_movie_xml_file(new_metadata, namer_config, trailer, poster, background, phash) 160 | nfo_file.write(data) 161 | 162 | set_permissions(target, namer_config) 163 | -------------------------------------------------------------------------------- /namer/mutagen.py: -------------------------------------------------------------------------------- 1 | """ 2 | Updates mp4 files with metadata tags readable by Plex and Apple TV App. 3 | """ 4 | 5 | from pathlib import Path 6 | from typing import Any, List, Optional 7 | 8 | from loguru import logger 9 | from mutagen.mp4 import MP4, MP4Cover, MP4StreamInfoError 10 | 11 | from namer.configuration import NamerConfig 12 | from namer.ffmpeg import FFMpeg, FFProbeResults 13 | from namer.comparison_results import LookedUpFileInfo 14 | 15 | 16 | def resolution_to_hdv_setting(resolution: Optional[int]) -> int: 17 | """ 18 | Using the resolution (height) of a video stream return the atom value for hd video 19 | """ 20 | if not resolution: 21 | return 0 22 | elif resolution >= 2160: 23 | return 3 24 | elif resolution >= 1080: 25 | return 2 26 | elif resolution >= 720: 27 | return 1 28 | 29 | return 0 30 | 31 | 32 | def set_single_if_not_none(video: MP4, atom: str, value: Any): 33 | """ 34 | Set a single atom on the video if it is not None. 35 | """ 36 | video[atom] = [value] if value is not None else [] 37 | 38 | 39 | def set_array_if_not_none(video: MP4, atom: str, value: List[str]): 40 | """ 41 | Set an array atom on the video if it is not None. 42 | """ 43 | video[atom] = value if value is not None else [] 44 | 45 | 46 | def get_mp4_if_possible(mp4: Path, ffmpeg: FFMpeg) -> MP4: 47 | """ 48 | Attempt to read a mp4 file to prepare for edit. 49 | """ 50 | try: 51 | video = MP4(mp4) 52 | except MP4StreamInfoError: 53 | ffmpeg.attempt_fix_corrupt(mp4) 54 | video = MP4(mp4) 55 | 56 | return video 57 | 58 | 59 | @logger.catch 60 | def update_mp4_file(mp4: Path, looked_up: LookedUpFileInfo, poster: Optional[Path], ffprobe_results: Optional[FFProbeResults], config: NamerConfig): 61 | # pylint: disable=too-many-statements 62 | """ 63 | us-tv|TV-MA|600| 64 | us-tv|TV-14|500| 65 | us-tv|TV-PG|400| 66 | us-tv|TV-G|300| 67 | us-tv|TV-Y|200| 68 | us-tv|TV-Y7|100| 69 | us-tv||0| 70 | 71 | mpaa|UNRATED|600| 72 | mpaa|NC-17|500| 73 | mpaa|R|400| 74 | mpaa|PG-13|300| 75 | mpaa|PG|200| 76 | mpaa|G|100| 77 | mpaa|XXX|0| 78 | """ 79 | 80 | logger.info('Updating audio and tags for: {}', mp4) 81 | success = config.ffmpeg.update_audio_stream_if_needed(mp4, config.language) 82 | if not success: 83 | logger.info('Could not process audio or copy {}', mp4) 84 | 85 | logger.info('Updating atom tags on: {}', mp4) 86 | if mp4 and mp4.exists(): 87 | video: MP4 = get_mp4_if_possible(mp4, config.ffmpeg) 88 | video.clear() 89 | set_single_if_not_none(video, '\xa9nam', looked_up.name) 90 | video['\xa9day'] = [looked_up.date + 'T09:00:00Z'] if looked_up.date else [] 91 | 92 | if config.enable_metadataapi_genres: 93 | set_array_if_not_none(video, '\xa9gen', looked_up.tags) 94 | else: 95 | set_array_if_not_none(video, 'keyw', looked_up.tags) 96 | set_single_if_not_none(video, '\xa9gen', config.default_genre) 97 | 98 | set_single_if_not_none(video, 'tvnn', looked_up.site) 99 | set_single_if_not_none(video, '\xa9alb', looked_up.site) 100 | video['stik'] = [9] # Movie 101 | 102 | if ffprobe_results: 103 | stream = ffprobe_results.get_default_video_stream() 104 | if stream: 105 | resolution = resolution_to_hdv_setting(stream.height) 106 | set_single_if_not_none(video, 'hdvd', resolution) 107 | 108 | set_single_if_not_none(video, 'ldes', looked_up.description) 109 | set_single_if_not_none(video, '\xa9cmt', looked_up.source_url) 110 | video['----:com.apple.iTunes:iTunEXTC'] = 'mpaa|XXX|0|'.encode('UTF-8', errors='ignore') 111 | itunes_movie = '' 112 | itunes_movie += f'copy-warning{looked_up.source_url}' 113 | itunes_movie += f'studio {looked_up.site}' 114 | itunes_movie += f'tpdbid {looked_up.look_up_site_id}' 115 | itunes_movie += 'cast ' 116 | 117 | for performer in looked_up.performers: 118 | if performer.name: 119 | itunes_movie += f' name {performer.name}' 120 | if performer.role: 121 | itunes_movie += f'role {performer.role}' 122 | itunes_movie += '' 123 | 124 | itunes_movie += '' 125 | itunes_movie += 'codirectors ' 126 | itunes_movie += 'directors ' 127 | itunes_movie += 'screenwriters' 128 | itunes_movie += '' 129 | video['----:com.apple.iTunes:iTunMOVI'] = itunes_movie.encode('UTF-8', errors='ignore') 130 | add_poster(poster, video) 131 | video.save() 132 | logger.info('Updated atom tags: {}', mp4) 133 | else: 134 | logger.warning('Can not update tags of a non-existent file: {}', mp4) 135 | 136 | 137 | def add_poster(poster, video): 138 | """ 139 | Adds a poster to the mp4 metadata if available and correct format. 140 | """ 141 | if poster: 142 | with open(poster, 'rb') as file: 143 | ext = poster.suffix.upper() 144 | image_format = None 145 | if ext in ['.JPEG', '.JPG']: 146 | image_format = MP4Cover.FORMAT_JPEG 147 | elif ext == '.PNG': 148 | image_format = MP4Cover.FORMAT_PNG 149 | 150 | if image_format: 151 | video['covr'] = [MP4Cover(file.read(), image_format)] 152 | -------------------------------------------------------------------------------- /namer/name_formatter.py: -------------------------------------------------------------------------------- 1 | import re 2 | import string 3 | 4 | from jinja2 import Template 5 | from jinja2.filters import FILTERS 6 | 7 | 8 | class PartialFormatter(string.Formatter): 9 | """ 10 | Used for formatting NamerConfig.inplace_name and NamerConfig. 11 | """ 12 | 13 | supported_keys = [ 14 | 'date', 15 | 'description', 16 | 'name', 17 | 'site', 18 | 'full_site', 19 | 'parent', 20 | 'full_parent', 21 | 'network', 22 | 'full_network', 23 | 'performers', 24 | 'all_performers', 25 | 'performer-sites', 26 | 'all_performer-sites', 27 | 'act', 28 | 'ext', 29 | 'trans', 30 | 'source_file_name', 31 | 'uuid', 32 | 'vr', 33 | 'type', 34 | 'year', 35 | 'resolution', 36 | 'video_codec', 37 | 'audio_codec', 38 | 'external_id', 39 | ] 40 | 41 | __regex = { 42 | 's': re.compile(r'.\d+s'), 43 | 'p': re.compile(r'.\d+p'), 44 | 'i': re.compile(r'.\d+i'), 45 | } 46 | 47 | def __init__(self, missing='~~', bad_fmt='!!'): 48 | self.missing, self.bad_fmt = missing, bad_fmt 49 | FILTERS['split'] = str.split 50 | 51 | def get_field(self, field_name, args, kwargs): 52 | # Handle a key not found 53 | try: 54 | val = super().get_field(field_name, args, kwargs) 55 | except (KeyError, AttributeError) as err: 56 | val = None, field_name 57 | if field_name not in self.supported_keys: 58 | raise KeyError(f'Key {field_name} not in support keys: {self.supported_keys}') from err 59 | 60 | return val 61 | 62 | def format_field(self, value, format_spec: str): 63 | if not value: 64 | return self.missing 65 | 66 | try: 67 | if self.__regex['s'].match(format_spec): 68 | value = value + format_spec[0] * int(format_spec[1:-1]) 69 | format_spec = '' 70 | elif self.__regex['p'].match(format_spec): 71 | value = format_spec[0] * int(format_spec[1:-1]) + value 72 | format_spec = '' 73 | elif self.__regex['i'].match(format_spec): 74 | value = format_spec[0] * int(format_spec[1:-1]) + value + format_spec[0] * int(format_spec[1:-1]) 75 | format_spec = '' 76 | elif format_spec.startswith('|'): 77 | template = Template(f'{{{{ val{format_spec} }}}}') 78 | value = template.render(val=value) 79 | format_spec = '' 80 | 81 | return super().format_field(value, format_spec) 82 | except ValueError: 83 | if self.bad_fmt: 84 | return self.bad_fmt 85 | raise 86 | -------------------------------------------------------------------------------- /namer/videohashes.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | from pathlib import Path 4 | from typing import List 5 | 6 | from loguru import logger 7 | 8 | from namer.configuration_utils import default_config 9 | from namer.namer import calculate_phash 10 | 11 | 12 | def main(args_list: List[str]): 13 | """ 14 | Command line interface to calculate hashes for a file. 15 | """ 16 | description = """ 17 | Command line interface to calculate hashes for a file 18 | """ 19 | parser = argparse.ArgumentParser(description=description) 20 | parser.add_argument('-c', '--configfile', help='override location for a configuration file.', type=Path) 21 | parser.add_argument('-f', '--file', help='File we want to provide a match name for.', required=True, type=Path) 22 | parser.add_argument('-v', '--verbose', help='verbose, print logs', action='store_true') 23 | args = parser.parse_args(args=args_list) 24 | 25 | config = default_config(args.configfile.resolve() if args.configfile else None) 26 | if args.verbose: 27 | level = 'DEBUG' if config.debug else 'INFO' 28 | logger.add(sys.stdout, format=config.console_format, level=level, diagnose=config.diagnose_errors) 29 | 30 | file_hash = calculate_phash(args.file.resolve(), config) 31 | print(file_hash.to_dict()) 32 | -------------------------------------------------------------------------------- /namer/videophash/__init__.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Union 3 | 4 | from namer.videophash.imagehash import hex_to_hash, ImageHash 5 | 6 | __all__ = ['PerceptualHash', 'return_perceptual_hash', 'ImageHash'] 7 | 8 | 9 | @dataclass(init=False, repr=False, eq=True, order=False, unsafe_hash=True, frozen=False) 10 | class PerceptualHash: 11 | duration: int 12 | phash: ImageHash 13 | oshash: str 14 | 15 | def to_dict(self): 16 | return { 17 | 'duration': self.duration, 18 | 'phash': str(self.phash), 19 | 'oshash': self.oshash, 20 | } 21 | 22 | 23 | def return_perceptual_hash(duration: Union[float, int], phash: Union[str, ImageHash], file_oshash: str) -> PerceptualHash: 24 | output = PerceptualHash() 25 | output.duration = int(duration) if isinstance(duration, float) else duration 26 | output.phash = hex_to_hash(phash) if isinstance(phash, str) else phash 27 | output.oshash = file_oshash 28 | 29 | return output 30 | -------------------------------------------------------------------------------- /namer/videophash/imagehash.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, Optional 2 | 3 | import numpy 4 | import scipy.fft 5 | import scipy.fftpack 6 | from PIL import Image 7 | 8 | try: 9 | # enable numpy array typing (py3.7+) 10 | import numpy.typing 11 | 12 | NDArray = numpy.typing.NDArray[numpy.bool_] 13 | except (AttributeError, ImportError): 14 | NDArray = list # type: ignore 15 | 16 | 17 | class ImageHash: 18 | """ 19 | Hash encapsulation. Can be used for dictionary keys and comparisons. 20 | """ 21 | 22 | def __init__(self, binary_array: NDArray) -> None: 23 | self.hash = binary_array 24 | 25 | def __str__(self) -> str: 26 | return _binary_array_to_hex(self.hash.flatten()) 27 | 28 | def __repr__(self) -> str: 29 | return repr(self.hash) 30 | 31 | def __sub__(self, other: 'ImageHash') -> int: 32 | if other is None: 33 | raise TypeError('Other hash must not be None.') 34 | 35 | if self.hash.size != other.hash.size: 36 | raise TypeError('ImageHashes must be of the same shape.', self.hash.shape, other.hash.shape) 37 | 38 | return numpy.count_nonzero(self.hash.flatten() != other.hash.flatten()) 39 | 40 | def __eq__(self, other: object) -> bool: 41 | if other is None: 42 | return False 43 | 44 | return numpy.array_equal(self.hash.flatten(), other.hash.flatten()) # type: ignore 45 | 46 | def __ne__(self, other: object) -> bool: 47 | if other is None: 48 | return False 49 | 50 | return not numpy.array_equal(self.hash.flatten(), other.hash.flatten()) # type: ignore 51 | 52 | def __hash__(self) -> int: 53 | # this returns an 8-bit integer, intentionally shortening the information 54 | return sum([2 ** (i % 8) for i, v in enumerate(self.hash.flatten()) if v]) 55 | 56 | def __len__(self) -> int: 57 | # Returns the bit length of the hash 58 | return self.hash.size 59 | 60 | 61 | def _binary_array_to_hex(arr): 62 | """ 63 | internal function to make a hex string out of a binary array. 64 | """ 65 | bit_string = ''.join(str(b) for b in 1 * arr.flatten()) 66 | width = int(numpy.ceil(len(bit_string) / 4)) 67 | return '{:0>{width}x}'.format(int(bit_string, 2), width=width) 68 | 69 | 70 | def hex_to_hash(hex_str: str) -> ImageHash: 71 | """ 72 | Convert a stored hash (hex, as retrieved from str(Imagehash)) 73 | back to an Imagehash object. 74 | 75 | Notes: 76 | 1. This algorithm assumes all hashes are either 77 | bidimensional arrays with dimensions hash_size * hash_size, 78 | or one-dimensional arrays with dimensions binbits * 14. 79 | 2. This algorithm does not work for hash_size < 2. 80 | """ 81 | hash_size = int(numpy.sqrt(len(hex_str) * 4)) 82 | # assert hash_size == numpy.sqrt(len(hex_str)*4) 83 | binary_array = '{:0>{width}b}'.format(int(hex_str, 16), width=hash_size * hash_size) 84 | bit_rows = [binary_array[i : i + hash_size] for i in range(0, len(binary_array), hash_size)] 85 | hash_array = numpy.array([[bool(int(d)) for d in row] for row in bit_rows]) 86 | return ImageHash(hash_array) 87 | 88 | 89 | def phash(image: Image.Image, hash_size=8, high_freq_factor=4, resample: Literal[0, 1, 2, 3, 4, 5] = Image.Resampling.LANCZOS) -> Optional[ImageHash]: # type: ignore 90 | if hash_size < 2: 91 | raise ValueError('Hash size must be greater than or equal to 2') 92 | 93 | img_size = hash_size * high_freq_factor 94 | image = image.resize((img_size, img_size), resample).convert('L') 95 | pixels = numpy.asarray(image) 96 | 97 | dct = scipy.fft.dct(scipy.fft.dct(pixels, axis=0), axis=1) 98 | dct_low_freq = dct[:hash_size, :hash_size] # type: ignore 99 | med = numpy.median(dct_low_freq) 100 | diff = dct_low_freq > med 101 | 102 | return ImageHash(diff) 103 | -------------------------------------------------------------------------------- /namer/videophash/videophash.py: -------------------------------------------------------------------------------- 1 | import concurrent.futures 2 | from decimal import Decimal, ROUND_HALF_UP 3 | from functools import lru_cache 4 | from pathlib import Path 5 | from typing import List, Optional 6 | 7 | import oshash 8 | from loguru import logger 9 | from PIL import Image 10 | 11 | from namer.ffmpeg import FFMpeg 12 | from namer.videophash import PerceptualHash, return_perceptual_hash 13 | from namer.videophash import imagehash 14 | 15 | 16 | class VideoPerceptualHash: 17 | __screenshot_width: int = 160 18 | __columns: int = 5 19 | __rows: int = 5 20 | 21 | __ffmpeg: FFMpeg 22 | 23 | def __init__(self, ffmpeg: FFMpeg): 24 | self.__ffmpeg = ffmpeg 25 | 26 | def get_hashes(self, file: Path, max_workers: Optional[int] = None, use_gpu: bool = False) -> Optional[PerceptualHash]: 27 | data = None 28 | 29 | probe = self.__ffmpeg.ffprobe(file) 30 | if not probe: 31 | return data 32 | 33 | duration = probe.get_format().duration 34 | phash = self.get_phash(file, duration, max_workers, use_gpu) 35 | if phash: 36 | file_oshash = self.get_oshash(file) 37 | 38 | data = return_perceptual_hash(duration, phash, file_oshash) 39 | 40 | return data 41 | 42 | def get_phash(self, file: Path, duration: float, max_workers: Optional[int], use_gpu: bool) -> Optional[imagehash.ImageHash]: 43 | stat = file.stat() 44 | return self._get_phash(file, duration, max_workers, use_gpu, stat.st_size, stat.st_mtime) 45 | 46 | @lru_cache(maxsize=1024) # noqa: B019 47 | def _get_phash(self, file: Path, duration: float, max_workers: Optional[int], use_gpu: bool, file_size: int, file_update: float) -> Optional[imagehash.ImageHash]: 48 | logger.info(f'Calculating phash for file "{file}"') 49 | phash = self.__calculate_phash(file, duration, max_workers, use_gpu) 50 | return phash 51 | 52 | def __calculate_phash(self, file: Path, duration: float, max_workers: Optional[int], use_gpu: bool) -> Optional[imagehash.ImageHash]: 53 | phash = None 54 | 55 | thumbnail_image = self.__generate_image_thumbnail(file, duration, max_workers, use_gpu) 56 | if thumbnail_image: 57 | phash = imagehash.phash(thumbnail_image, hash_size=8, high_freq_factor=8, resample=Image.Resampling.BILINEAR) # type: ignore 58 | 59 | return phash 60 | 61 | def __generate_image_thumbnail(self, file: Path, duration: float, max_workers: Optional[int], use_gpu: bool) -> Optional[Image.Image]: 62 | thumbnail_image = None 63 | 64 | thumbnail_list = self.__generate_thumbnails(file, duration, max_workers, use_gpu) 65 | if thumbnail_list: 66 | thumbnail_image = self.__concat_images(thumbnail_list) 67 | 68 | return thumbnail_image 69 | 70 | def get_oshash(self, file: Path) -> str: 71 | stat = file.stat() 72 | return self._get_oshash(file, stat.st_size, stat.st_mtime) 73 | 74 | @lru_cache(maxsize=1024) # noqa: B019 75 | def _get_oshash(self, file: Path, file_size: int, file_update: float) -> str: 76 | logger.info(f'Calculating oshash for file "{file}"') 77 | file_hash = oshash.oshash(str(file)) 78 | return file_hash 79 | 80 | def __generate_thumbnails(self, file: Path, duration: float, max_workers: Optional[int], use_gpu: bool) -> List[Image.Image]: 81 | duration = int(Decimal(duration * 100).quantize(0, ROUND_HALF_UP)) / 100 82 | 83 | chunk_count = self.__columns * self.__rows 84 | offset = 0.05 * duration 85 | step_size = (0.9 * duration) / chunk_count 86 | 87 | if duration / chunk_count < 0.03: 88 | return [] 89 | 90 | queue = [] 91 | with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: 92 | for idx in range(chunk_count): 93 | time = offset + (idx * step_size) 94 | future = executor.submit(self.__ffmpeg.extract_screenshot, file, time, self.__screenshot_width, use_gpu) 95 | queue.append(future) 96 | 97 | concurrent.futures.wait(queue) 98 | images = [item.result() for item in queue] 99 | 100 | return images 101 | 102 | def __concat_images(self, images: List[Image.Image]) -> Image.Image: 103 | width, height = images[0].size 104 | 105 | image_size = (width * self.__columns, height * self.__rows) 106 | image = Image.new('RGB', image_size) 107 | 108 | for row in range(self.__rows): 109 | for col in range(self.__columns): 110 | offset = width * col, height * row 111 | idx = row * self.__columns + col 112 | image.paste(images[idx], offset) 113 | 114 | return image 115 | -------------------------------------------------------------------------------- /namer/videophash/videophashstash.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import subprocess 3 | from functools import lru_cache 4 | from pathlib import Path 5 | from typing import Optional 6 | 7 | import orjson 8 | from loguru import logger 9 | from orjson import JSONDecodeError 10 | 11 | from namer.videophash import PerceptualHash, return_perceptual_hash 12 | 13 | 14 | class StashVideoPerceptualHash: 15 | __home_path: Path = Path(__file__).parent.parent 16 | __phash_path: Path = __home_path / 'tools' 17 | __phash_name: str = 'videohashes' 18 | __supported_arch: dict = { 19 | 'amd64': 'amd64', 20 | 'x86_64': 'amd64', 21 | 'arm64': 'arm64', 22 | 'aarch64': 'arm64', 23 | 'arm': 'arm', 24 | } 25 | __phash_suffixes: dict = { 26 | 'windows': '.exe', 27 | 'linux': '-linux', 28 | 'darwin': '-macos', 29 | } 30 | 31 | def __init__(self): 32 | if not self.__phash_path.is_dir(): 33 | self.__phash_path.mkdir(exist_ok=True, parents=True) 34 | 35 | system = platform.system().lower() 36 | arch = platform.machine().lower() 37 | if arch not in self.__supported_arch.keys(): 38 | raise SystemError(f'Unsupported architecture error {arch}') 39 | 40 | self.__phash_name += '-' + self.__supported_arch[arch] + self.__phash_suffixes[system] 41 | 42 | def install_ffmpeg(self) -> None: 43 | # videohasher installs ffmpeg next to itself by default, even if 44 | # there's nothing to process. 45 | self.__execute_stash_phash() 46 | 47 | def get_hashes(self, file: Path, **kwargs) -> Optional[PerceptualHash]: 48 | stat = file.stat() 49 | return self._get_stash_phash(file, stat.st_size, stat.st_mtime) 50 | 51 | @lru_cache(maxsize=1024) # noqa: B019 52 | def _get_stash_phash(self, file: Path, file_size: int, file_update: float) -> Optional[PerceptualHash]: 53 | logger.info(f'Calculating phash for file "{file}"') 54 | return self.__execute_stash_phash(file) 55 | 56 | def __execute_stash_phash(self, file: Optional[Path] = None) -> Optional[PerceptualHash]: 57 | output = None 58 | if not self.__phash_path: 59 | return output 60 | 61 | args = [ 62 | str(self.__phash_path / self.__phash_name), 63 | '-json', 64 | ] 65 | 66 | if file: 67 | # fmt: off 68 | args.extend([ 69 | '--video', str(file) 70 | ]) 71 | 72 | with subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True) as process: 73 | stdout, stderr = process.communicate() 74 | stdout, stderr = stdout.strip(), stderr.strip() 75 | 76 | success = process.returncode == 0 77 | if success: 78 | data = None 79 | try: 80 | data = orjson.loads(stdout) 81 | except JSONDecodeError: 82 | logger.error(stdout) 83 | pass 84 | 85 | if data: 86 | output = return_perceptual_hash(data['duration'], data['phash'], data['oshash']) 87 | else: 88 | logger.error(stderr) 89 | 90 | return output 91 | -------------------------------------------------------------------------------- /namer/web/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePornDatabase/namer/19a75c32a5ecc2418548c304b2da4973490edb88/namer/web/__init__.py -------------------------------------------------------------------------------- /namer/web/actions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helper functions to tie in to namer's functionality. 3 | """ 4 | 5 | import gzip 6 | import math 7 | import shutil 8 | from enum import Enum 9 | from functools import lru_cache 10 | from pathlib import Path 11 | from queue import Queue 12 | from typing import Dict, List, Optional 13 | 14 | import orjson 15 | import jsonpickle 16 | from werkzeug.routing import Rule 17 | 18 | from namer.comparison_results import ComparisonResults, SceneType 19 | from namer.configuration import NamerConfig 20 | from namer.command import gather_target_files_from_dir, is_interesting_movie, is_relative_to, Command 21 | from namer.fileinfo import FileInfo, parse_file_name 22 | from namer.metadataapi import __build_url, __evaluate_match, __request_response_json_object, __metadataapi_response_to_data 23 | from namer.namer import calculate_phash 24 | from namer.videophash import PerceptualHash 25 | 26 | 27 | class SearchType(str, Enum): 28 | ANY = 'Any' 29 | SCENES = 'Scenes' 30 | MOVIES = 'Movies' 31 | JAV = 'JAV' 32 | 33 | 34 | def has_no_empty_params(rule: Rule) -> bool: 35 | """ 36 | Currently unused, useful to inspect Flask rules. 37 | """ 38 | defaults = rule.defaults if rule.defaults is not None else () 39 | arguments = rule.arguments if rule.arguments is not None else () 40 | return len(defaults) >= len(arguments) 41 | 42 | 43 | def get_failed_files(config: NamerConfig) -> List[Dict]: 44 | """ 45 | Get failed files to rename. 46 | """ 47 | return list(map(lambda o: command_to_file_info(o, config), gather_target_files_from_dir(config.failed_dir, config))) 48 | 49 | 50 | def get_queued_files(queue: Queue, config: NamerConfig, queue_limit: int = 100) -> List[Dict]: 51 | """ 52 | Get queued files. 53 | """ 54 | queue_items = list(queue.queue)[:queue_limit] 55 | return list(map(lambda x: command_to_file_info(x, config), filter(lambda i: i is not None, queue_items))) 56 | 57 | 58 | def get_queue_size(queue: Queue) -> int: 59 | return queue.qsize() 60 | 61 | 62 | def command_to_file_info(command: Command, config: NamerConfig) -> Dict: 63 | stat = command.target_movie_file.stat() 64 | 65 | sub_path = str(command.target_movie_file.resolve().relative_to(command.config.failed_dir.resolve())) if is_relative_to(command.target_movie_file, command.config.failed_dir) else None 66 | res = { 67 | 'file': sub_path, 68 | 'name': command.target_directory.stem if command.parsed_dir_name and command.target_directory else command.target_movie_file.stem, 69 | 'ext': command.target_movie_file.suffix[1:].upper(), 70 | 'update_time': int(stat.st_mtime), 71 | 'size': stat.st_size, 72 | } 73 | 74 | percentage, phash, oshash = 0.0, '', '' 75 | if config and config.write_namer_failed_log and config.add_columns_from_log and sub_path: 76 | log_data = read_failed_log_file(sub_path, config) 77 | if log_data: 78 | if log_data.results: 79 | percentage = max([100 - item.phash_distance * 2.5 if item.phash_distance is not None and item.phash_distance <= 8 else item.name_match for item in log_data.results]) 80 | 81 | if log_data.fileinfo and log_data.fileinfo.hashes: 82 | phash = str(log_data.fileinfo.hashes.phash) 83 | oshash = log_data.fileinfo.hashes.oshash 84 | 85 | res['percentage'] = percentage 86 | res['phash'] = phash 87 | res['oshash'] = oshash 88 | 89 | log_time = 0 90 | if config and config.add_complete_column and config.write_namer_failed_log and sub_path: 91 | log_file = command.target_movie_file.parent / (command.target_movie_file.stem + '_namer.json.gz') 92 | if log_file.is_file(): 93 | log_stat = log_file.stat() 94 | log_time = int(log_stat.st_ctime) 95 | 96 | res['log_time'] = log_time 97 | 98 | return res 99 | 100 | 101 | def metadataapi_responses_to_webui_response(responses: Dict, config: NamerConfig, file: str, phash: Optional[PerceptualHash] = None) -> List: 102 | file = Path(file) 103 | file_name = file.stem 104 | if not file.suffix and config.target_extensions: 105 | file_name += '.' + config.target_extensions[0] 106 | 107 | file_infos = [] 108 | for url, response in responses.items(): 109 | if response and response.strip() != '': 110 | json_obj = orjson.loads(response) 111 | formatted = orjson.dumps(orjson.loads(response), option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS).decode('UTF-8') 112 | name_parts = parse_file_name(file_name, config) 113 | file_infos.extend(__metadataapi_response_to_data(json_obj, url, formatted, name_parts, config)) 114 | 115 | files = [] 116 | for scene_data in file_infos: 117 | scene = __evaluate_match(scene_data.original_parsed_filename, scene_data, config, phash).as_dict() 118 | scene.update( 119 | { 120 | 'name_parts': scene_data.original_parsed_filename, 121 | 'looked_up': { 122 | 'uuid': scene_data.uuid, 123 | 'type': scene_data.type.value, 124 | 'name': scene_data.name, 125 | 'date': scene_data.date, 126 | 'poster_url': scene_data.poster_url, 127 | 'site': scene_data.site, 128 | 'network': scene_data.network, 129 | 'performers': scene_data.performers, 130 | }, 131 | } 132 | ) 133 | files.append(scene) 134 | 135 | return files 136 | 137 | 138 | def get_search_results(query: str, search_type: SearchType, file: str, config: NamerConfig, page: int = 1) -> Dict: 139 | """ 140 | Search results for user selection. 141 | """ 142 | 143 | responses = {} 144 | if search_type == SearchType.ANY or search_type == SearchType.SCENES: 145 | url = __build_url(config, name=query, page=page, scene_type=SceneType.SCENE) 146 | responses[url] = __request_response_json_object(url, config) 147 | 148 | if search_type == SearchType.ANY or search_type == SearchType.MOVIES: 149 | url = __build_url(config, name=query, page=page, scene_type=SceneType.MOVIE) 150 | responses[url] = __request_response_json_object(url, config) 151 | 152 | if search_type == SearchType.ANY or search_type == SearchType.JAV: 153 | url = __build_url(config, name=query, page=page, scene_type=SceneType.JAV) 154 | responses[url] = __request_response_json_object(url, config) 155 | 156 | files = metadataapi_responses_to_webui_response(responses, config, query) 157 | 158 | res = { 159 | 'file': file, 160 | 'files': files, 161 | } 162 | 163 | return res 164 | 165 | 166 | def get_phash_results(file: str, search_type: SearchType, config: NamerConfig) -> Dict: 167 | """ 168 | Search results by phash for user selection. 169 | """ 170 | 171 | phash_file = config.failed_dir / file 172 | if not phash_file.is_file(): 173 | return {} 174 | 175 | phash = calculate_phash(phash_file, config) 176 | 177 | responses = {} 178 | if search_type == SearchType.ANY or search_type == SearchType.SCENES: 179 | url = __build_url(config, phash=phash, scene_type=SceneType.SCENE) 180 | responses[url] = __request_response_json_object(url, config) 181 | 182 | if search_type == SearchType.ANY or search_type == SearchType.MOVIES: 183 | url = __build_url(config, phash=phash, scene_type=SceneType.MOVIE) 184 | responses[url] = __request_response_json_object(url, config) 185 | 186 | if search_type == SearchType.ANY or search_type == SearchType.JAV: 187 | url = __build_url(config, phash=phash, scene_type=SceneType.JAV) 188 | responses[url] = __request_response_json_object(url, config) 189 | 190 | files = metadataapi_responses_to_webui_response(responses, config, file, phash) 191 | 192 | res = { 193 | 'file': file, 194 | 'files': files, 195 | } 196 | 197 | return res 198 | 199 | 200 | def delete_file(file_name_str: str, config: NamerConfig) -> bool: 201 | """ 202 | Delete selected file. 203 | """ 204 | file_name = config.failed_dir / file_name_str 205 | if not is_acceptable_file(file_name, config) or not config.allow_delete_files: 206 | return False 207 | 208 | if config.del_other_files and file_name.is_dir(): 209 | target_name = config.failed_dir / Path(file_name_str).parts[0] 210 | shutil.rmtree(target_name) 211 | else: 212 | log_file = config.failed_dir / (file_name.stem + '_namer.json.gz') 213 | if log_file.is_file(): 214 | log_file.unlink() 215 | 216 | file_name.unlink() 217 | 218 | return not file_name.is_file() 219 | 220 | 221 | def read_failed_log_file(name: str, config: NamerConfig) -> Optional[ComparisonResults]: 222 | file = config.failed_dir / name 223 | file = file.parent / (file.stem + '_namer.json.gz') 224 | 225 | res: Optional[ComparisonResults] = None 226 | if file.is_file(): 227 | res = _read_failed_log_file(file, file.stat().st_size, file.stat().st_mtime) 228 | 229 | return res 230 | 231 | 232 | @lru_cache(maxsize=1024) 233 | def _read_failed_log_file(file: Path, file_size: int, file_update: float) -> Optional[ComparisonResults]: 234 | res: Optional[ComparisonResults] = None 235 | if file.is_file(): 236 | data = gzip.decompress(file.read_bytes()) 237 | decoded = jsonpickle.decode(data) 238 | if decoded and isinstance(decoded, ComparisonResults): 239 | for item in decoded.results: 240 | if not hasattr(item, 'phash_distance'): 241 | item.phash_distance = 0 if hasattr(item, 'phash_match') and getattr(item, 'phash_match') else None # noqa: B009 242 | 243 | if not hasattr(item, 'phash_duration'): 244 | item.phash_duration = None 245 | 246 | if not hasattr(item.looked_up, 'hashes'): 247 | item.looked_up.hashes = [] 248 | 249 | if item.looked_up.performers: 250 | for performer in item.looked_up.performers: 251 | if not hasattr(performer, 'alias'): 252 | performer.alias = None 253 | 254 | if not hasattr(decoded, 'fileinfo'): 255 | decoded.fileinfo = FileInfo() 256 | 257 | res = decoded 258 | 259 | return res 260 | 261 | 262 | def is_acceptable_file(file: Path, config: NamerConfig) -> bool: 263 | """ 264 | Checks if a file belong to namer. 265 | """ 266 | return str(config.failed_dir) in str(file.resolve()) and file.is_file() and is_interesting_movie(file, config) 267 | 268 | 269 | def human_format(num): 270 | if num == 0: 271 | return '0' 272 | 273 | size = 1000 274 | size_name = ('', 'K', 'M', 'B', 'T') 275 | i = int(math.floor(math.log(num, size))) 276 | p = math.pow(size, i) 277 | s = str(round(num / p, 2)) 278 | s = s.rstrip('0').rstrip('.') 279 | 280 | return f'{s}{size_name[i]}' 281 | -------------------------------------------------------------------------------- /namer/web/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePornDatabase/namer/19a75c32a5ecc2418548c304b2da4973490edb88/namer/web/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /namer/web/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePornDatabase/namer/19a75c32a5ecc2418548c304b2da4973490edb88/namer/web/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /namer/web/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePornDatabase/namer/19a75c32a5ecc2418548c304b2da4973490edb88/namer/web/public/apple-touch-icon.png -------------------------------------------------------------------------------- /namer/web/public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #0a182f 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /namer/web/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePornDatabase/namer/19a75c32a5ecc2418548c304b2da4973490edb88/namer/web/public/favicon-16x16.png -------------------------------------------------------------------------------- /namer/web/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePornDatabase/namer/19a75c32a5ecc2418548c304b2da4973490edb88/namer/web/public/favicon-32x32.png -------------------------------------------------------------------------------- /namer/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePornDatabase/namer/19a75c32a5ecc2418548c304b2da4973490edb88/namer/web/public/favicon.ico -------------------------------------------------------------------------------- /namer/web/public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePornDatabase/namer/19a75c32a5ecc2418548c304b2da4973490edb88/namer/web/public/mstile-150x150.png -------------------------------------------------------------------------------- /namer/web/public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /namer/web/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Namer", 3 | "short_name": "Namer", 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": "#0a182f", 17 | "background_color": "#0a182f" 18 | } 19 | -------------------------------------------------------------------------------- /namer/web/routes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePornDatabase/namer/19a75c32a5ecc2418548c304b2da4973490edb88/namer/web/routes/__init__.py -------------------------------------------------------------------------------- /namer/web/routes/api.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines the api routes of a Flask webserver for namer. 3 | """ 4 | 5 | from pathlib import Path 6 | from queue import Queue 7 | 8 | from flask import Blueprint, jsonify, render_template, request 9 | from flask.wrappers import Response 10 | 11 | from namer.command import make_command_relative_to, move_command_files 12 | from namer.configuration import NamerConfig 13 | from namer.web.actions import delete_file, get_failed_files, get_phash_results, get_queue_size, get_queued_files, get_search_results, human_format, read_failed_log_file 14 | 15 | 16 | def get_routes(config: NamerConfig, command_queue: Queue) -> Blueprint: 17 | """ 18 | Builds a blueprint for flask with passed in context, the NamerConfig. 19 | """ 20 | blueprint = Blueprint('api', __name__, url_prefix='/api') 21 | 22 | @blueprint.route('/v1/render', methods=['POST']) 23 | def render() -> Response: 24 | data = request.json 25 | 26 | res = False 27 | if data: 28 | template: str = data.get('template') 29 | client_data = data.get('data') 30 | 31 | active_page: str = data.get('url') 32 | if config.web_root and config.web_root != '': 33 | active_page = active_page.replace(config.web_root, '') if active_page.startswith(config.web_root) else active_page 34 | active_page = active_page.lstrip('/') 35 | 36 | template_file = f'render/{template}.html' 37 | response = render_template(template_file, data=client_data, config=config, active_page=active_page) 38 | 39 | res = { 40 | 'response': response, 41 | } 42 | 43 | return jsonify(res) 44 | 45 | @blueprint.route('/v1/get_files', methods=['POST']) 46 | def get_files() -> Response: 47 | data = get_failed_files(config) 48 | return jsonify(data) 49 | 50 | @blueprint.route('/v1/get_queued', methods=['POST']) 51 | def get_queued() -> Response: 52 | data = get_queued_files(command_queue, config) 53 | return jsonify(data) 54 | 55 | @blueprint.route('/v1/get_search', methods=['POST']) 56 | def get_search() -> Response: 57 | data = request.json 58 | 59 | res = False 60 | if data: 61 | page = data['page'] if 'page' in data else 1 62 | res = get_search_results(data['query'], data['type'], data['file'], config, page=page) 63 | 64 | return jsonify(res) 65 | 66 | @blueprint.route('/v1/get_phash', methods=['POST']) 67 | def get_phash() -> Response: 68 | data = request.json 69 | 70 | res = False 71 | if data: 72 | res = get_phash_results(data['file'], data['type'], config) 73 | 74 | return jsonify(res) 75 | 76 | @blueprint.route('/v1/get_queue', methods=['POST']) 77 | def get_queue() -> Response: 78 | res = get_queue_size(command_queue) 79 | res = human_format(res) 80 | 81 | return jsonify(res) 82 | 83 | @blueprint.route('/v1/rename', methods=['POST']) 84 | def rename() -> Response: 85 | data = request.json 86 | 87 | res = False 88 | if data: 89 | res = False 90 | movie = config.failed_dir / Path(data['file']) 91 | command = make_command_relative_to(movie, config.failed_dir, config=config, is_auto=False) 92 | moved_command = move_command_files(command, config.work_dir, is_auto=False) 93 | if moved_command: 94 | moved_command.tpdb_id = data['scene_id'] 95 | command_queue.put(moved_command) # Todo pass selection 96 | 97 | return jsonify(res) 98 | 99 | @blueprint.route('/v1/delete', methods=['POST']) 100 | def delete() -> Response: 101 | data = request.json 102 | 103 | res = False 104 | if data: 105 | res = delete_file(data['file'], config) 106 | 107 | return jsonify(res) 108 | 109 | @blueprint.route('/v1/read_failed_log', methods=['POST']) 110 | def read_failed_log() -> Response: 111 | data = request.json 112 | 113 | res = False 114 | if data: 115 | # fmt: off 116 | res = { 117 | 'file': data['file'], 118 | 'data': read_failed_log_file(data['file'], config) 119 | } 120 | 121 | return jsonify(res) 122 | 123 | @blueprint.route('/healthcheck', methods=['GET']) 124 | def healthcheck() -> Response: 125 | # fmt: off 126 | res = { 127 | 'status': 'OK', 128 | } 129 | 130 | return jsonify(res) 131 | 132 | return blueprint 133 | -------------------------------------------------------------------------------- /namer/web/routes/web.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines the web routes of a Flask webserver for namer. 3 | """ 4 | 5 | from queue import Queue 6 | 7 | from flask import Blueprint, redirect, render_template, request 8 | from flask.wrappers import Response 9 | 10 | from namer.configuration import NamerConfig 11 | from namer.metadataapi import get_user_info 12 | from namer.web.actions import get_failed_files, get_queued_files 13 | 14 | 15 | def get_routes(config: NamerConfig, command_queue: Queue) -> Blueprint: 16 | """ 17 | Builds a blueprint for flask with passed in context, the NamerConfig. 18 | """ 19 | blueprint = Blueprint('web', __name__) 20 | 21 | """ 22 | @blueprint.route('/') 23 | def index() -> str: 24 | data = [] 25 | for rule in app.url_map.iter_rules(): 26 | if rule.methods is not None and 'GET' in rule.methods and has_no_empty_params(rule): 27 | url = url_for(rule.endpoint, **(rule.defaults or {})) 28 | data.append((url, rule.endpoint)) 29 | 30 | return render_template('pages/index.html', links=data) 31 | """ 32 | 33 | @blueprint.route('/') 34 | def index() -> Response: 35 | return redirect('failed', code=302) # type: ignore 36 | 37 | @blueprint.route('/failed') 38 | def failed() -> str: 39 | """ 40 | Displays all failed to name files. 41 | """ 42 | data = get_failed_files(config) 43 | theme = request.cookies.get('theme', 'auto') 44 | user = get_user_info(config) 45 | 46 | return render_template('pages/failed.html', data=data, config=config, theme=theme, user=user) 47 | 48 | @blueprint.route('/queue') 49 | def queue() -> str: 50 | """ 51 | Displays all queued files. 52 | """ 53 | data = get_queued_files(command_queue, config) 54 | theme = request.cookies.get('theme', 'auto') 55 | user = get_user_info(config) 56 | 57 | return render_template('pages/queue.html', data=data, config=config, theme=theme, user=user) 58 | 59 | @blueprint.route('/settings') 60 | def settings() -> str: 61 | """ 62 | Displays namer settings. 63 | """ 64 | theme = request.cookies.get('theme', 'auto') 65 | user = get_user_info(config) 66 | 67 | return render_template('pages/settings.html', config=config, theme=theme, user=user) 68 | 69 | return blueprint 70 | -------------------------------------------------------------------------------- /namer/web/server.py: -------------------------------------------------------------------------------- 1 | """ 2 | A wrapper allowing shutdown of a Flask server. 3 | """ 4 | 5 | import logging 6 | import mimetypes 7 | import datetime 8 | from queue import Queue 9 | from threading import Thread 10 | from typing import Any, List, Optional, Union 11 | 12 | import orjson 13 | from flask import Blueprint, Flask 14 | from flask.json.provider import _default, JSONProvider 15 | from flask_compress import Compress 16 | from loguru import logger 17 | from waitress import create_server 18 | from waitress.server import BaseWSGIServer, MultiSocketServer 19 | from werkzeug.middleware.proxy_fix import ProxyFix 20 | 21 | from namer.configuration import NamerConfig 22 | from namer.configuration_utils import from_str_list_lower 23 | from namer.videophash import ImageHash 24 | from namer.web.routes import api, web 25 | 26 | 27 | class GenericWebServer: 28 | """ 29 | A wrapper allowing shutdown of a Flask server. 30 | """ 31 | 32 | __app: Flask 33 | __compress = Compress() 34 | 35 | __port: int 36 | __host: str 37 | __path: str 38 | __blueprints: List[Blueprint] 39 | 40 | __server: Union[MultiSocketServer, BaseWSGIServer] 41 | __thread: Thread 42 | 43 | __mime_types: dict = { 44 | 'font/woff': '.woff', 45 | 'font/woff2': '.woff2', 46 | } 47 | 48 | def __init__(self, host: str, port: int, webroot: Optional[str], blueprints: List[Blueprint], static_path: Optional[str] = 'public', quiet=True): 49 | self.__host = host 50 | self.__port = port 51 | self.__path = webroot if webroot else '/' 52 | self.__app = Flask(__name__, static_url_path=self.__path, static_folder=static_path, template_folder='templates') 53 | self.__blueprints = blueprints 54 | 55 | if quiet: 56 | logging.getLogger('waitress').disabled = True 57 | logging.getLogger('waitress.queue').disabled = True 58 | 59 | self.__add_mime_types() 60 | self.__register_blueprints() 61 | self.__make_server() 62 | self.__register_custom_processors() 63 | 64 | def __make_server(self): 65 | self.__app.wsgi_app = ProxyFix(self.__app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) 66 | self.__compress.init_app(self.__app) 67 | self.__server = create_server(self.__app, host=self.__host, port=self.__port, clear_untrusted_proxy_headers=True) 68 | self.__thread = Thread(target=self.__run, daemon=True) 69 | 70 | def __register_blueprints(self): 71 | for blueprint in self.__blueprints: 72 | blueprint_path = self.__path + blueprint.url_prefix if blueprint.url_prefix else self.__path 73 | self.__app.register_blueprint(blueprint, url_prefix=blueprint_path) 74 | 75 | def __add_mime_types(self): 76 | self.__app.json.mimetype = 'application/json; charset=utf-8' # type: ignore 77 | 78 | for mime, ext in self.__mime_types.items(): 79 | test_mime, test_ext = mimetypes.guess_type(f'0{ext}') 80 | if test_mime is None: 81 | mimetypes.add_type(mime, ext) 82 | 83 | def __register_custom_processors(self): 84 | functions = { 85 | 'bool_to_icon': self.bool_to_icon, 86 | } 87 | self.__app.jinja_env.globals.update(**functions) 88 | 89 | filters = { 90 | 'timestamp_to_datetime': self.timestamp_to_datetime, 91 | 'seconds_to_format': self.seconds_to_format, 92 | 'strftime': self.strftime, 93 | 'is_list': self.is_list, 94 | 'is_dict': self.is_dict, 95 | 'from_list': from_str_list_lower, 96 | } 97 | self.__app.jinja_env.filters.update(**filters) 98 | 99 | self.__app.jinja_env.add_extension('jinja2.ext.do') 100 | 101 | self.__app.jinja_env.trim_blocks = True 102 | self.__app.jinja_env.lstrip_blocks = True 103 | 104 | self.__app.json = CustomJSONProvider(self.__app) 105 | 106 | def start(self): 107 | logger.info(f'Starting server: {self.get_url()}') 108 | self.__thread.start() 109 | 110 | def __run(self): 111 | """ 112 | Start server on existing thread. 113 | """ 114 | if self.__server: 115 | try: 116 | self.__server.run() 117 | except OSError: 118 | logger.error('Stopping server') 119 | finally: 120 | self.stop() 121 | 122 | def stop(self): 123 | """ 124 | Stop severing requests and empty threads. 125 | """ 126 | if self.__server: 127 | self.__server.close() 128 | 129 | def get_effective_port(self) -> Optional[int]: 130 | return getattr(self.__server, 'effective_port', None) 131 | 132 | def get_url(self) -> str: 133 | """ 134 | Returns the full url to access this server, usually http://127.0.0.1:/ 135 | """ 136 | return f'http://{self.__host}:{self.get_effective_port()}{self.__path}' 137 | 138 | @staticmethod 139 | def bool_to_icon(item: bool) -> str: 140 | icon = 'x' 141 | if item: 142 | icon = 'check' 143 | 144 | return f'' 145 | 146 | @staticmethod 147 | def is_list(item: Any) -> bool: 148 | return isinstance(item, list) 149 | 150 | @staticmethod 151 | def is_dict(item: Any) -> bool: 152 | return isinstance(item, dict) 153 | 154 | @staticmethod 155 | def timestamp_to_datetime(item: int) -> datetime: 156 | return datetime.datetime.fromtimestamp(item) 157 | 158 | @staticmethod 159 | def seconds_to_format(item: int) -> str: 160 | return str(datetime.timedelta(seconds=item)) 161 | 162 | @staticmethod 163 | def strftime(item: datetime, datetime_format: str) -> str: 164 | return item.strftime(datetime_format) 165 | 166 | 167 | class NamerWebServer(GenericWebServer): 168 | __namer_config: NamerConfig 169 | __command_queue: Queue 170 | 171 | def __init__(self, namer_config: NamerConfig, command_queue: Queue): 172 | self.__namer_config = namer_config 173 | self.__command_queue = command_queue 174 | webroot = '/' if not self.__namer_config.web_root else self.__namer_config.web_root 175 | blueprints = [ 176 | web.get_routes(self.__namer_config, self.__command_queue), 177 | api.get_routes(self.__namer_config, self.__command_queue), 178 | ] 179 | 180 | super().__init__(self.__namer_config.host, self.__namer_config.port, webroot, blueprints) 181 | 182 | 183 | class CustomJSONProvider(JSONProvider): 184 | def dumps(self, obj: Any, **kwargs: Any): 185 | return orjson.dumps(obj, option=orjson.OPT_PASSTHROUGH_SUBCLASS, default=default).decode('UTF-8') 186 | 187 | def loads(self, s: str | bytes, **kwargs: Any): 188 | return orjson.loads(s) 189 | 190 | 191 | def default(obj): 192 | if isinstance(obj, ImageHash): 193 | return str(obj) 194 | 195 | return _default(obj) 196 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "", 6 | "license": "MIT", 7 | "author": "DirtyRacer", 8 | "main": "", 9 | "scripts": { 10 | "prepare": "husky", 11 | "build": "webpack --config webpack.prod.js", 12 | "test": "echo \"Error: no test specified\" && exit 1" 13 | }, 14 | "dependencies": { 15 | "@popperjs/core": "^2.11.8", 16 | "bootstrap": "^5.3.6", 17 | "bootstrap-icons": "^1.13.1", 18 | "datatables.net": "^2.3.1", 19 | "datatables.net-bs5": "^2.3.1", 20 | "datatables.net-buttons": "^3.2.3", 21 | "datatables.net-buttons-bs5": "^3.2.3", 22 | "datatables.net-colreorder": "^2.1.0", 23 | "datatables.net-colreorder-bs5": "^2.1.0", 24 | "datatables.net-fixedheader": "^4.0.2", 25 | "datatables.net-fixedheader-bs5": "^4.0.2", 26 | "datatables.net-responsive": "^3.0.4", 27 | "datatables.net-responsive-bs5": "^3.0.4", 28 | "jquery": "^3.7.1", 29 | "lodash": "^4.17.21" 30 | }, 31 | "devDependencies": { 32 | "@babel/core": "^7.27.1", 33 | "@babel/preset-env": "^7.27.2", 34 | "@eslint/eslintrc": "^3.3.1", 35 | "@eslint/js": "^9.27.0", 36 | "babel-loader": "^10.0.0", 37 | "copy-webpack-plugin": "^13.0.0", 38 | "css-loader": "^7.1.2", 39 | "css-minimizer-webpack-plugin": "^7.0.2", 40 | "eslint": "^9.27.0", 41 | "eslint-config-standard": "^17.1.0", 42 | "file-loader": "^6.2.0", 43 | "globals": "^16.1.0", 44 | "html-minimizer-webpack-plugin": "^5.0.2", 45 | "husky": "^9.1.7", 46 | "lint-staged": "^16.0.0", 47 | "mini-css-extract-plugin": "^2.9.2", 48 | "postcss": "^8.5.3", 49 | "postcss-loader": "^8.1.1", 50 | "postcss-preset-env": "^10.1.6", 51 | "sass": "^1.89.0", 52 | "sass-loader": "^16.0.5", 53 | "terser-webpack-plugin": "^5.3.14", 54 | "webpack": "^5.99.9", 55 | "webpack-cli": "^6.0.1" 56 | }, 57 | "engines": { 58 | "node": "^22.0", 59 | "npm": "^9.0", 60 | "pnpm": "^10.0" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "namer" 3 | version = "1.19.13" 4 | description = "A namer of video files based on metadata from the porndb." 5 | readme = "readme.rst" 6 | authors = [ 7 | { name = "4c0d3r", email = "4c0d3r@protonmail.com" }, 8 | { name = "DirtyRacer", email = "DirtyRacer@ya.ru" } 9 | ] 10 | dynamic = ["dependencies"] 11 | requires-python = ">=3.10,<3.14" 12 | 13 | [tool.poetry] 14 | include = [ 15 | { path = "namer/web/public/assets/**/*", format = ["sdist", "wheel"] }, 16 | { path = "namer/web/templates/**/*", format = ["sdist", "wheel"] }, 17 | { path = "namer/tools/videohashes*", format = ["sdist", "wheel"] }, 18 | { path = "namer/namer.cfg.default", format = ["sdist", "wheel"] } 19 | ] 20 | 21 | [tool.poetry.dependencies] 22 | rapidfuzz = "^3.9" 23 | watchdog = "^6.0" 24 | pathvalidate = "^3.2" 25 | requests = "^2.32" 26 | mutagen = "^1.47" 27 | schedule = "^1.2" 28 | loguru = "^0.7" 29 | Unidecode = "^1.3" 30 | flask = "^3.1" 31 | waitress = "^3.0" 32 | Flask-Compress = "^1.15" 33 | Pillow = "^11.1" 34 | requests-cache = "^1.2" 35 | ffmpeg-python = "^0.2" 36 | jsonpickle = "^4.0" 37 | ConfigUpdater = "^3.2" 38 | oshash = "^0.1" 39 | pony = "^0.7" 40 | numpy = "^2.1" 41 | scipy = "^1.13" 42 | orjson = "^3.10" 43 | 44 | [tool.poetry.group.dev.dependencies] 45 | pytest = "^8.0" 46 | pytest-cov = "^6.0" 47 | coverage = "^7.0" 48 | ruff = "^0.11" 49 | selenium = "^4.21" 50 | assertpy = "^1.1" 51 | poethepoet = "^0.34" 52 | pony-stubs = "^0.5" 53 | 54 | [tool.ruff] 55 | exclude = [".git", "__pycache__", "docs/source/conf.py", "old", "build", "dist", "*migrations*", "init", "node_modules"] 56 | line-length = 320 57 | indent-width = 4 58 | target-version = "py310" 59 | 60 | [tool.ruff.format] 61 | quote-style = "single" 62 | indent-style = "space" 63 | line-ending = "auto" 64 | 65 | [tool.ruff.lint] 66 | select = ["E", "F"] 67 | ignore = ["E501", "E722"] 68 | 69 | [tool.poe.tasks] 70 | install_npm = { shell = "pnpm install" } 71 | build_node = { shell = "pnpm run build" } 72 | install_videohashes_src = { shell = "command -v git >/dev/null && git submodule update || echo 'Skipping git sub module update'" } 73 | build_videohashes = { shell = "make build -C ./videohashes" } 74 | move_videohashes = { "script" = "shutil:copytree('./videohashes/dist', './namer/tools', dirs_exist_ok=True)" } 75 | build_namer = { shell = "poetry build" } 76 | build_deps = ["install_npm", "build_node", "install_videohashes_src", "build_videohashes", "move_videohashes"] 77 | test_format = { shell = "poetry run ruff check ." } 78 | test_namer = { shell = "poetry run pytest --log-cli-level=info --capture=no" } 79 | test = ["test_format", "test_namer"] 80 | build_all = ["build_deps", "test", "build_namer"] 81 | 82 | [tool.poetry.build] 83 | generate-setup-file = true 84 | 85 | [project.scripts] 86 | namer = "namer.__main__:main" 87 | 88 | [build-system] 89 | requires = ["poetry-core>=2.1"] 90 | build-backend = "poetry.core.masonry.api" 91 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eo pipefail 4 | 5 | version_bump=$1 6 | 7 | repo="ThePornDatabase" 8 | 9 | found=false 10 | for bump in 'minor' 'major' 'patch'; do 11 | if [[ "$version_bump" == "$bump" ]]; then 12 | found=true 13 | fi 14 | done 15 | 16 | if [ $found == false ]; then 17 | echo invalid arguement please use on of 'minor' 'major' 'patch' 18 | exit 1 19 | fi 20 | 21 | source ./creds.sh 22 | 23 | CLEAN=$(git diff-index --quiet HEAD; echo $?) 24 | if [[ "${CLEAN}" != "0" ]]; then 25 | echo Your git repo is not clean, can\'t releases. 26 | exit 1 27 | fi 28 | 29 | if [[ -z ${PYPI_TOKEN} ]]; then 30 | echo PYPI_TOKEN not set, make sure you have a token for this project set in a local creds.sh file \(it\'s git ignored\) 31 | exit 1 32 | fi 33 | 34 | if [[ -z ${GITHUB_TOKEN} ]]; then 35 | echo GITHUB_TOKEN not set, make sure you have a token for this project set in a local creds.sh file \(it\'s git ignored\) 36 | exit 1 37 | fi 38 | 39 | if [[ -z ${GITHUB_USERNAME} ]]; then 40 | echo GITHUB_TOKEN not set, make sure you have a token for this project set in a local creds.sh file \(it\'s git ignored\) 41 | exit 1 42 | fi 43 | 44 | branch=$(git rev-parse --abbrev-ref HEAD) 45 | 46 | if [[ "$branch" != "main" ]]; then 47 | echo May only release off of the main branch, not other branches. 48 | fi 49 | 50 | poetry version $version_bump 51 | new_version=$(poetry version -s) 52 | git add pyproject.toml 53 | 54 | poetry run pytest 55 | poetry run flakeheaven lint 56 | 57 | pnpm install 58 | pnpm run build 59 | 60 | poetry build 61 | 62 | echo build docker image before publishing pip 63 | BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') 64 | GIT_HASH=$(git rev-parse --verify HEAD) 65 | docker build . --build-arg "BUILD_DATE=${BUILD_DATE}" --build-arg "GITHASH=${GIT_HASH}" -t "${repo}"/namer:"${new_version}" 66 | 67 | echo publishing pip to poetry 68 | poetry config pypi-token.pypi "${PYPI_TOKEN}" 69 | poetry publish 70 | 71 | echo pushing new git tag v"${new_version}" 72 | git commit -m "prepare release v${new_version}" 73 | git push 74 | git tag v"${new_version}" main 75 | git push origin v"${new_version}" 76 | 77 | docker login ghcr.io -u ${GITHUB_USERNAME} -p ${GITHUB_TOKEN} 78 | docker tag "${repo}"/namer:"${new_version}" ghcr.io/"${repo}"/namer:"${new_version}" 79 | docker tag "${repo}"/namer:"${new_version}" ghcr.io/"${repo}"/namer:latest 80 | docker push ghcr.io/"${repo}"/namer:"${new_version}" 81 | docker push ghcr.io/"${repo}"/namer:latest 82 | -------------------------------------------------------------------------------- /src/css/main.scss: -------------------------------------------------------------------------------- 1 | @import '~bootstrap/scss/bootstrap'; 2 | @import '~bootstrap-icons/font/bootstrap-icons.css'; 3 | @import '~datatables.net-bs5/css/dataTables.bootstrap5.css'; 4 | @import '~datatables.net-colreorder-bs5/css/colReorder.bootstrap5.css'; 5 | @import '~datatables.net-buttons-bs5/css/buttons.bootstrap5.css'; 6 | 7 | $gender-colours: ( 8 | 'default': $gray-600, 9 | 'male': $blue-400, 10 | 'female': $pink-400, 11 | 'trans': $purple-400, 12 | ); 13 | 14 | @each $name, $value in $gender-colours { 15 | .bg-#{$name} { 16 | background-color: $value; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/js/helpers.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery' 2 | import { Tooltip } from 'bootstrap' 3 | 4 | export class Helpers { 5 | static #table 6 | static #queueSize = '0' 7 | 8 | static getProgressBar () { 9 | return '
' 10 | } 11 | 12 | static refreshFiles (selector, tableButtons, target = 'files') { 13 | selector.html(Helpers.getProgressBar()) 14 | 15 | let url 16 | switch (target) { 17 | case 'queue': 18 | url = 'get_queued' 19 | break 20 | default: 21 | url = 'get_files' 22 | } 23 | 24 | Helpers.request(`./api/v1/${url}`, null, function (data) { 25 | Helpers.render('failedFiles', data, selector, function (selector) { 26 | Helpers.setTableSort(selector, tableButtons) 27 | }) 28 | }) 29 | } 30 | 31 | static removeRow (selector, paging = false) { 32 | Helpers.#table 33 | .row(selector.parents('tr')) 34 | .remove() 35 | .draw(paging) 36 | } 37 | 38 | static setTableSort (selector, tableButtons) { 39 | Helpers.#table = $(selector).children('table').DataTable({ 40 | stateSave: true, 41 | stateSaveCallback: function (settings, data) { 42 | localStorage.setItem('DataTables_' + settings.sInstance, JSON.stringify(data)) 43 | }, 44 | stateLoadCallback: function (settings) { 45 | return JSON.parse(localStorage.getItem('DataTables_' + settings.sInstance)) 46 | }, 47 | responsive: true, 48 | fixedHeader: true, 49 | colReorder: { 50 | fixedColumnsRight: 1 51 | }, 52 | buttons: [ 53 | { 54 | extend: 'colvis', 55 | columns: ':not(.noVis)', 56 | text: '', 57 | titleAttr: 'Column Visibility' 58 | } 59 | ] 60 | }) 61 | 62 | tableButtons.empty() 63 | Helpers.#table.buttons().container().appendTo(tableButtons) 64 | } 65 | 66 | static render (template, res, selector, afterRender = null) { 67 | const data = { 68 | template, 69 | data: res, 70 | url: new URL(window.document.URL).pathname 71 | } 72 | 73 | Helpers.request('./api/v1/render', data, function (data) { 74 | selector.html(data.response) 75 | afterRender?.(selector) 76 | }) 77 | } 78 | 79 | static request (url, data, success = null) { 80 | const progressBar = $('#progressBar') 81 | 82 | $.ajax({ 83 | xhr: function () { 84 | const xhr = new window.XMLHttpRequest() 85 | xhr.addEventListener('progress', function (evt) { 86 | if (evt.lengthComputable) { 87 | const percentComplete = Math.ceil(evt.loaded / evt.total * 100) 88 | if (progressBar) { 89 | progressBar.width(percentComplete + '%') 90 | } 91 | } 92 | }, false) 93 | 94 | return xhr 95 | }, 96 | url, 97 | type: 'POST', 98 | data: JSON.stringify(data, null, 0), 99 | contentType: 'application/json', 100 | dataType: 'json', 101 | success 102 | }) 103 | } 104 | 105 | static initTooltips (selector) { 106 | const tooltips = selector.find('[data-bs-toggle="tooltip"]') 107 | tooltips.each(function (index, element) { 108 | new Tooltip(element, { 109 | boundary: selector[0] 110 | }) 111 | }) 112 | } 113 | 114 | static updateQueueSize (selector) { 115 | Helpers.request('./api/v1/get_queue', null, function (data) { 116 | if (Helpers.#queueSize !== data) { 117 | Helpers.#queueSize = data 118 | selector.html(data) 119 | } 120 | }) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/js/main.js: -------------------------------------------------------------------------------- 1 | // noinspection ES6UnusedImports 2 | // eslint-disable-next-line no-unused-vars 3 | import { Modal, Popover } from 'bootstrap' 4 | 5 | import $ from 'jquery' 6 | import 'datatables.net-bs5' 7 | import 'datatables.net-colreorder-bs5' 8 | import 'datatables.net-buttons/js/buttons.colVis' 9 | import 'datatables.net-buttons-bs5' 10 | import 'datatables.net-responsive' 11 | import 'datatables.net-responsive-bs5' 12 | import 'datatables.net-fixedheader' 13 | import 'datatables.net-fixedheader-bs5' 14 | import { escape } from 'lodash' 15 | 16 | import { Helpers } from './helpers' 17 | import './themes' 18 | 19 | window.jQuery = $ 20 | 21 | const filesResult = $('#filesResult') 22 | const tableButtons = $('#tableButtons') 23 | const resultForm = $('#searchResults .modal-body') 24 | const resultFormTitle = $('#modalSearchResultsLabel span') 25 | const logForm = $('#logFile .modal-body') 26 | const logFormTitle = $('#modalLogsLabel span') 27 | const searchForm = $('#searchForm') 28 | const searchButton = $('#searchForm .modal-footer .search') 29 | const phashButton = $('#searchForm .modal-footer .phash') 30 | const queryInput = $('#queryInput') 31 | const queryType = $('#queryType') 32 | const deleteFile = $('#deleteFile') 33 | const queueSize = $('#queueSize') 34 | const refreshFiles = $('#refreshFiles') 35 | const deleteButton = $('#deleteButton') 36 | 37 | let modalButton 38 | 39 | searchButton.on('click', function () { 40 | resultForm.html(Helpers.getProgressBar()) 41 | 42 | const data = { 43 | query: queryInput.val(), 44 | file: queryInput.data('file'), 45 | type: queryType.val() 46 | } 47 | 48 | const title = escape(`(${data.file}) [${data.query}]`) 49 | resultFormTitle.html(title) 50 | resultFormTitle.attr('title', title) 51 | 52 | Helpers.request('./api/v1/get_search', data, function (data) { 53 | Helpers.render('searchResults', data, resultForm, function (selector) { 54 | Helpers.initTooltips(selector) 55 | }) 56 | }) 57 | }) 58 | 59 | phashButton.on('click', function () { 60 | resultForm.html(Helpers.getProgressBar()) 61 | 62 | const data = { 63 | file: queryInput.data('file'), 64 | type: queryType.val() 65 | } 66 | 67 | const title = escape(`(${data.file})`) 68 | resultFormTitle.html(title) 69 | resultFormTitle.attr('title', title) 70 | 71 | Helpers.request('./api/v1/get_phash', data, function (data) { 72 | Helpers.render('searchResults', data, resultForm, function (selector) { 73 | Helpers.initTooltips(selector) 74 | }) 75 | }) 76 | }) 77 | 78 | queryInput.on('keyup', function (e) { 79 | if (e.which === 13) { 80 | searchButton.click() 81 | } 82 | }) 83 | 84 | filesResult.on('click', '.match', function () { 85 | modalButton = $(this) 86 | const query = modalButton.data('query') 87 | const file = modalButton.data('file') 88 | queryInput.val(query) 89 | queryInput.data('file', file) 90 | }) 91 | 92 | filesResult.on('click', '.log', function () { 93 | logForm.html(Helpers.getProgressBar()) 94 | modalButton = $(this) 95 | const data = { 96 | file: modalButton.data('file') 97 | } 98 | 99 | const title = escape(`[${data.file}]`) 100 | 101 | logFormTitle.html(title) 102 | logFormTitle.attr('title', title) 103 | 104 | Helpers.request('./api/v1/read_failed_log', data, function (data) { 105 | Helpers.render('logFile', data, logForm, function (selector) { 106 | Helpers.initTooltips(selector) 107 | }) 108 | }) 109 | }) 110 | 111 | filesResult.on('click', '.delete', function () { 112 | modalButton = $(this) 113 | const file = modalButton.data('file') 114 | deleteFile.val(file) 115 | deleteFile.data('file', file) 116 | }) 117 | 118 | refreshFiles.on('click', function () { 119 | Helpers.refreshFiles(filesResult, tableButtons, $(this).data('target')) 120 | if (queueSize) { 121 | Helpers.updateQueueSize(queueSize) 122 | } 123 | }) 124 | 125 | searchForm.on('shown.bs.modal', function () { 126 | queryInput.focus() 127 | }) 128 | 129 | resultForm.on('click', '.rename', rename) 130 | logForm.on('click', '.rename', rename) 131 | 132 | deleteButton.on('click', function () { 133 | const data = { 134 | file: deleteFile.data('file') 135 | } 136 | 137 | Helpers.request('./api/v1/delete', data, function () { 138 | Helpers.removeRow(modalButton) 139 | }) 140 | }) 141 | 142 | function rename () { 143 | const data = { 144 | file: $(this).data('file'), 145 | scene_id: $(this).data('scene-id') 146 | } 147 | 148 | Helpers.request('./api/v1/rename', data, function () { 149 | Helpers.removeRow(modalButton) 150 | }) 151 | } 152 | 153 | Helpers.setTableSort(filesResult, tableButtons) 154 | 155 | if (queueSize) { 156 | Helpers.updateQueueSize(queueSize) 157 | setInterval(function () { 158 | Helpers.updateQueueSize(queueSize) 159 | }, 5000) 160 | } 161 | -------------------------------------------------------------------------------- /src/js/themes.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | 'use strict' 3 | 4 | const storedTheme = localStorage.getItem('theme') 5 | 6 | const getPreferredTheme = () => { 7 | if (storedTheme) { 8 | return storedTheme 9 | } 10 | 11 | return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' 12 | } 13 | 14 | const setTheme = function (theme) { 15 | if (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) { 16 | document.documentElement.setAttribute('data-bs-theme', 'dark') 17 | document.cookie = 'theme=dark' 18 | } else { 19 | document.documentElement.setAttribute('data-bs-theme', theme) 20 | document.cookie = 'theme=' + theme 21 | } 22 | } 23 | 24 | setTheme(getPreferredTheme()) 25 | 26 | const showActiveTheme = theme => { 27 | const activeThemeIcon = document.getElementById('theme-icon-active').querySelector('i') 28 | const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`) 29 | const svgOfActiveBtn = btnToActive.querySelector('i').getAttribute('class') 30 | 31 | document.querySelectorAll('[data-bs-theme-value]').forEach(element => { 32 | element.classList.remove('active') 33 | }) 34 | 35 | btnToActive.classList.add('active') 36 | activeThemeIcon.setAttribute('class', svgOfActiveBtn) 37 | } 38 | 39 | window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { 40 | if (storedTheme !== 'light' || storedTheme !== 'dark') { 41 | setTheme(getPreferredTheme()) 42 | } 43 | }) 44 | 45 | window.addEventListener('DOMContentLoaded', () => { 46 | showActiveTheme(getPreferredTheme()) 47 | 48 | document.querySelectorAll('[data-bs-theme-value]') 49 | .forEach(toggle => { 50 | toggle.addEventListener('click', () => { 51 | const theme = toggle.getAttribute('data-bs-theme-value') 52 | localStorage.setItem('theme', theme) 53 | setTheme(theme) 54 | showActiveTheme(theme) 55 | }) 56 | }) 57 | }) 58 | })() 59 | -------------------------------------------------------------------------------- /src/templates/components/card.html: -------------------------------------------------------------------------------- 1 | {% if file['phash_distance'] is not none and file['phash_distance'] <= 8 %} 2 | {% set match = (100 - (file['phash_distance'] * 2.5)|float|round(2)) ~ '%' %} 3 | {% else %} 4 | {% set match = file['name_match']|float|round(2) ~ '%' %} 5 | {% endif %} 6 | {% set card_width = '11.5rem' %} 7 | {% set card_img_height = card_width + ' / 2 * 3' %} 8 |
9 |
10 | {{ match }} 11 | {% if file['phash_distance'] is not none %} 12 | {% if file['phash_distance'] <= 2 %} 13 | {% set phash_color = 'text-bg-success' %} 14 | {% elif file['phash_distance'] <= 8 %} 15 | {% set phash_color = 'text-bg-warning' %} 16 | {% else %} 17 | {% set phash_color = 'text-bg-danger' %} 18 | {% endif %} 19 | Phash: {{ file['phash_distance'] }} 20 | {% endif %} 21 |
22 | {{ file['looked_up']['name'] }} 23 |
{{ file['looked_up']['type'] }}
24 |
25 |
{{ file['looked_up']['name'] }}
26 |

{{ file['looked_up']['site'] }}

27 | {% if file['looked_up']['parent'] %} 28 |

{{ file['looked_up']['parent'] }}

29 | {% endif %} 30 | {% if file['looked_up']['network'] %} 31 |

{{ file['looked_up']['network'] }}

32 | {% endif %} 33 | {% if file['looked_up']['duration'] %} 34 |

35 | {{ file['looked_up']['duration']|seconds_to_format }} 36 |

37 | {% endif %} 38 |

39 | {{ file['looked_up']['date'] }} 40 |

41 | {% include 'components/performersBadges.html' %} 42 |
43 | 49 |
50 | -------------------------------------------------------------------------------- /src/templates/components/navigations.html: -------------------------------------------------------------------------------- 1 | 40 | -------------------------------------------------------------------------------- /src/templates/components/performersBadges.html: -------------------------------------------------------------------------------- 1 | {% set background_colours = { 2 | 'Male': ('bg-male', 'gender-male'), 3 | 'Female': ('bg-female', 'gender-female'), 4 | 'Trans': ('bg-trans', 'gender-trans'), 5 | 'Intersex': ('bg-trans', 'gender-ambiguous'), 6 | 'Non Binary': ('bg-trans', 'gender-neuter'), 7 | 'Transgender Female': ('bg-trans', 'gender-trans'), 8 | 'Transgender Male ': ('bg-trans', 'gender-trans'), 9 | 'default': ('bg-default', None), 10 | } %} 11 | {% set performers_limit = 5 %} 12 | 13 | {% set file=file['looked_up'] %} 14 | {% if file['performers'] %} 15 | {% set scene_performers = file['performers'][:performers_limit] %} 16 | {% set other_performer = file['performers'][performers_limit:] %} 17 | 18 | {% for performer in scene_performers %} 19 | {% set gender = performer['role'] if performer['role'] in background_colours else 'default' %} 20 | {% set background_colour, gender_icon = background_colours[gender] %} 21 | 22 | {% with name = performer['name'] %} 23 | {% set background_colour, gender_icon = background_colours[gender] %} 24 | {% include 'render/performerBadge.html' %} 25 | {% endwith %} 26 | {% endfor %} 27 | 28 | {% if other_performer %} 29 | {% with name = 'and ' ~ other_performer|count ~ ' more' %} 30 | {% set tooltip = other_performer|map(attribute='name')|join(', ') %} 31 | {% set background_colour, gender_icon = background_colours['default'] %} 32 | {% include 'render/performerBadge.html' %} 33 | {% endwith %} 34 | {% endif %} 35 | {% endif %} 36 | -------------------------------------------------------------------------------- /src/templates/pages/failed.html: -------------------------------------------------------------------------------- 1 | {% extends "partials/base.html" %} 2 | {% block title %}Failed files{% endblock %} 3 | {% set active_page = "failed" %} 4 | {% block content %} 5 |
6 |
Unmatched Files
7 |
8 | 11 |
12 |
13 |
14 |
15 |
16 | {% include 'render/failedFiles.html' %} 17 |
18 |
19 | {% endblock %} 20 | {% block external %} 21 | {% if config.write_namer_failed_log %} 22 | 38 | {% endif %} 39 | 73 | 90 | {% if config.allow_delete_files %} 91 | 113 | {% endif %} 114 | {% endblock %} 115 | -------------------------------------------------------------------------------- /src/templates/pages/index.html: -------------------------------------------------------------------------------- 1 | {% extends "partials/base.html" %} 2 | {% block title %}Main{% endblock %} 3 | {% block content %} 4 | 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /src/templates/pages/queue.html: -------------------------------------------------------------------------------- 1 | {% extends "partials/base.html" %} 2 | {% block title %}Queued files{% endblock %} 3 | {% set active_page = "queue" %} 4 | {% block content %} 5 |
6 |
Queued Files
7 |
8 | 11 |
12 |
13 |
14 |
15 |
16 | {% include 'render/failedFiles.html' %} 17 |
18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /src/templates/pages/settings.html: -------------------------------------------------------------------------------- 1 | {% extends "partials/base.html" %} 2 | {% block title %}Settings{% endblock %} 3 | {% set active_page = "settings" %} 4 | {% block content %} 5 |
6 | {% for section, settings in config.to_dict().items() %} 7 |

[{{ section }}]

8 | {% for key, value in settings.items() %} 9 |
10 | {% if value is string or value is none %} 11 | 12 | {% elif value|is_list %} 13 | 14 | {% elif value|is_dict %} 15 | 16 | {% elif value is integer %} 17 | 18 | {% elif value is boolean %} 19 | 20 | {% endif %} 21 | 22 |
23 | {% endfor %} 24 | {% endfor %} 25 |
26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /src/templates/partials/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {% block title %}{% endblock %} - Namer 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | {% if user %} 19 | 20 | 21 | {% endif %} 22 | 23 | 24 |
25 |
26 |
27 | {% include 'components/navigations.html' %} 28 |
29 |
30 | {% block content %}{% endblock %} 31 |
32 |
33 |
34 | {% block external %}{% endblock %} 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/templates/render/failedFiles.html: -------------------------------------------------------------------------------- 1 | {% if data %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | {% if config.add_complete_column and active_page == 'failed' %} 9 | 10 | {% endif %} 11 | 12 | {% if config.add_columns_from_log and config.write_namer_failed_log and active_page == 'failed' %} 13 | 14 | 15 | 16 | {% endif %} 17 | {% if active_page == 'failed' %} 18 | 19 | {% endif %} 20 | 21 | 22 | 23 | {% for file in data %} 24 | 25 | 26 | 27 | 28 | {% if config.add_complete_column and active_page == 'failed' %} 29 | 30 | {% endif %} 31 | 32 | {% if config.add_columns_from_log and config.write_namer_failed_log and active_page == 'failed' %} 33 | 34 | 35 | 36 | {% endif %} 37 | {% if active_page == 'failed' %} 38 | 41 | {% endif %} 42 | 43 | {% endfor %} 44 | 45 |
File NameExtensionDateCompletedSizePercentagePHashOSHash
{{ file['name'] }}{{ file['ext'] }}{{ file['update_time']|timestamp_to_datetime|strftime('%d-%m-%Y %H:%M:%S') }}{{ file['log_time']|timestamp_to_datetime|strftime('%d-%m-%Y %H:%M:%S') }}{{ file['size']|filesizeformat(binary=True) }}{{ '%.1f'|format(file['percentage']) }}%{{ file['phash'] }}{{ file['oshash'] }} 39 | {% include 'render/fileActions.html' %} 40 |
46 | {% else %} 47 |
48 | No files found 49 |
50 | {% endif %} 51 | -------------------------------------------------------------------------------- /src/templates/render/fileActions.html: -------------------------------------------------------------------------------- 1 | {% if file['file'] %} 2 | {% if config.write_namer_failed_log %} 3 | 6 | {% endif %} 7 | 10 | {% if config.allow_delete_files %} 11 | 14 | {% endif %} 15 | {% endif %} 16 | -------------------------------------------------------------------------------- /src/templates/render/logDetails.html: -------------------------------------------------------------------------------- 1 | {% set name_match = file['name_match'] > 94.9 %} 2 | {% set phash_match = file['phash_distance'] is not none and file['phash_distance'] <= 8 %} 3 | 4 |

5 | Phash Match: {{ bool_to_icon(phash_match) }} 6 |
7 | {% if file['phash_distance'] is not none %} 8 | Phash distance: {{ file['phash_distance'] }} 9 |
10 | {% endif %} 11 | {% if file['phash_duration'] is not none %} 12 | Duration Match: {{ bool_to_icon(file['phash_duration']) }} 13 |
14 | {% endif %} 15 |

16 |

17 | Site Match: {{ bool_to_icon(file['site_match']) }} 18 |
19 | File: {{ file['name_parts']['site']|default('', True) }} 20 |
21 | Found: {{ file['looked_up']['site']|default('', True) }} 22 |
23 |

24 |

25 | Date Match: {{ bool_to_icon(file['date_match']) }} 26 |
27 | File: {{ file['name_parts']['date']|default('', True) }} 28 |
29 | Found: {{ file['looked_up']['date']|default('', True) }} 30 |

31 |

32 | Name Match: {{ bool_to_icon(name_match) }} 33 |
34 | File: {{ file['name_parts']['name']|default('', True) }} 35 |
36 | Found: {{ file['looked_up']['name']|default('', True) }} 37 |
38 | Matched With: {{ file['name'] }} 39 |

40 | -------------------------------------------------------------------------------- /src/templates/render/logFile.html: -------------------------------------------------------------------------------- 1 |
2 | {% set results = [] %} 3 | {% for item in data['data']['results'] %} 4 | {% if item['name_match'] >= 80 or (item['phash_distance'] is not none and item['phash_distance'] <= 8) %} 5 | {% do results.append(item) %} 6 | {% endif %} 7 | {% endfor %} 8 | 9 | {% if results %} 10 | {% for file in results %} 11 |
12 | {% include 'components/card.html' %} 13 |
14 | {% endfor %} 15 | {% else %} 16 |
17 | No results found 18 |
19 | {% endif %} 20 |
21 | -------------------------------------------------------------------------------- /src/templates/render/performerBadge.html: -------------------------------------------------------------------------------- 1 | 2 | {{ name }} 3 | {% if gender_icon %} 4 | 5 | {% endif %} 6 | 7 | -------------------------------------------------------------------------------- /src/templates/render/searchResults.html: -------------------------------------------------------------------------------- 1 |
2 | {% if data['files'] %} 3 | {% for file in data['files'] %} 4 |
5 | {% include 'components/card.html' %} 6 |
7 | {% endfor %} 8 | {% else %} 9 |
10 | No results found 11 |
12 | {% endif %} 13 |
14 | -------------------------------------------------------------------------------- /test/Big_Buck_Bunny_360_10s_2MB_h264.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePornDatabase/namer/19a75c32a5ecc2418548c304b2da4973490edb88/test/Big_Buck_Bunny_360_10s_2MB_h264.mp4 -------------------------------------------------------------------------------- /test/Big_Buck_Bunny_720_10s_2MB_h264.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePornDatabase/namer/19a75c32a5ecc2418548c304b2da4973490edb88/test/Big_Buck_Bunny_720_10s_2MB_h264.mp4 -------------------------------------------------------------------------------- /test/Big_Buck_Bunny_720_10s_2MB_h265.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePornDatabase/namer/19a75c32a5ecc2418548c304b2da4973490edb88/test/Big_Buck_Bunny_720_10s_2MB_h265.mp4 -------------------------------------------------------------------------------- /test/Site.22.01.01.painful.pun.XXX.720p.xpost.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePornDatabase/namer/19a75c32a5ecc2418548c304b2da4973490edb88/test/Site.22.01.01.painful.pun.XXX.720p.xpost.mp4 -------------------------------------------------------------------------------- /test/Site.22.01.01.painful.pun.XXX.720p.xpost_wrong.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePornDatabase/namer/19a75c32a5ecc2418548c304b2da4973490edb88/test/Site.22.01.01.painful.pun.XXX.720p.xpost_wrong.mp4 -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePornDatabase/namer/19a75c32a5ecc2418548c304b2da4973490edb88/test/__init__.py -------------------------------------------------------------------------------- /test/ea.nfo: -------------------------------------------------------------------------------- 1 |  2 | 3 | Cute brunette Carmela Clutch positions her big, juicy ass for famed director/cocksman Mark Wood's camera to ogle. The well-endowed babe teases, flaunting her voluptuous jugs and derriere. Mark's sexy MILF partner, Francesca Le, finds a 'nice warm place' for her tongue and serves Carmela a lesbian rim job. Francesca takes a labia-licking face ride from the busty babe. Francesca takes over the camera as Mark takes over Carmela's hairy snatch, his big cock ram-fucking her twat. Carmela sucks Mark's meat in a lewd blowjob. Carmela jerks her clit as Mark delivers a vigorous anal pounding! With Mark's prick shoved up her ass, off-screen Francesca orders, 'Keep that pussy busy!' Carmela's huge boobs jiggle as she takes a rectal reaming and buzzes a vibrator on her clit at the same time. Francesca jumps in to make it a threesome, trading ass-to-mouth flavor with the young tramp. This ribald romp reaches its climax as Mark drops a messy, open-mouth cum facial onto Carmela. She lets the jizz drip from her lips, licking the mess from her fingers and rubbing it onto her robust melons. 4 | 5 | false 6 | 2022-01-23 20:09:36 7 | Carmela Clutch: Fabulous Anal 3-Way! 8 | https://trailers-fame.gammacdn.com/6/7/6/5/c85676/trailers/85676_01/tr_85676_01_1080p.mp4 9 | 2022 10 | XXX 11 | 2022-01-03 12 | 2022-01-03 13 | 38 14 | Anal 15 | Ass 16 | Ass to Mouth 17 | Big Butt 18 | Big Dick 19 | Blow Job 20 | Brunette 21 | Cum Shot 22 | Cum Swallowing 23 | Deep Throat 24 | Dildos 25 | Face Sitting 26 | Facial 27 | Hairy Pussy 28 | Hand Job 29 | Hardcore 30 | Latina 31 | MILF 32 | No Story 33 | Rim Job 34 | Sex 35 | Tattoo 36 | Threesome 37 | Toys 38 | Evil Angel 39 | Gamma Enterprises 40 | 1678283 41 | 11#1#198543#scenes#2022-01-03 42 | https://www.evilangel.com/en/video/0/198543/ 43 | 44 | ./poster.png 45 | ./fanart.png 46 | 47 | 48 | Carmela Clutch 49 | Female 50 | Actor 51 | /install/path/data/metadata/People/C/Carmela Clutch/poster.png 52 | 53 | 54 | Francesca Le 55 | Female 56 | Actor 57 | /install/path/data/metadata/People/F/Francesca Le/poster.jpg 58 | 59 | 60 | Mark Wood 61 | Male 62 | Actor 63 | /config/data/metadata/People/M/Mark Wood/poster.jpg 64 | 65 | 66 | -------------------------------------------------------------------------------- /test/namer_configparser_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests namer_configparser 3 | """ 4 | 5 | from configupdater import ConfigUpdater 6 | import unittest 7 | from importlib import resources 8 | 9 | from loguru import logger 10 | 11 | from namer.configuration import NamerConfig 12 | from namer.configuration_utils import from_config, to_ini 13 | from test import utils 14 | 15 | 16 | class UnitTestAsTheDefaultExecution(unittest.TestCase): 17 | """ 18 | Always test first. 19 | """ 20 | 21 | def __init__(self, method_name='runTest'): 22 | super().__init__(method_name) 23 | 24 | if not utils.is_debugging(): 25 | logger.remove() 26 | 27 | def test_configuration(self) -> None: 28 | updater = ConfigUpdater(allow_no_value=True) 29 | config_str = '' 30 | if hasattr(resources, 'files'): 31 | config_str = resources.files('namer').joinpath('namer.cfg.default').read_text() 32 | elif hasattr(resources, 'read_text'): 33 | config_str = resources.read_text('namer', 'namer.cfg.default') 34 | updater.read_string(config_str) 35 | namer_config = from_config(updater, NamerConfig()) 36 | namer_config.config_updater = updater 37 | namer_config.sites_with_no_date_info = ['badsite'] 38 | ini_content = to_ini(namer_config) 39 | self.assertIn('sites_with_no_date_info = badsite', ini_content.splitlines()) 40 | 41 | updated = ConfigUpdater(allow_no_value=True) 42 | lines = ini_content.splitlines() 43 | lines.remove('sites_with_no_date_info = badsite') 44 | files_no_sites_with_no_date_info = '\n'.join(lines) 45 | 46 | updated.read_string(files_no_sites_with_no_date_info) 47 | double_read = NamerConfig() 48 | double_read = from_config(updated, double_read) 49 | self.assertEqual(double_read.sites_with_no_date_info, []) 50 | updated.read_string(ini_content) 51 | double_read = from_config(updated, double_read) 52 | self.assertIn('badsite', double_read.sites_with_no_date_info) 53 | 54 | updated.read_string(files_no_sites_with_no_date_info) 55 | double_read = from_config(updated, double_read) 56 | self.assertIn('badsite', double_read.sites_with_no_date_info) 57 | -------------------------------------------------------------------------------- /test/namer_ffmpeg_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests namer_ffmpeg 3 | """ 4 | 5 | import shutil 6 | import tempfile 7 | import unittest 8 | from pathlib import Path 9 | 10 | from loguru import logger 11 | 12 | from namer.ffmpeg import FFMpeg 13 | from test import utils 14 | 15 | 16 | class UnitTestAsTheDefaultExecution(unittest.TestCase): 17 | """ 18 | Always test first. 19 | """ 20 | def __init__(self, method_name='runTest'): 21 | super().__init__(method_name) 22 | 23 | if not utils.is_debugging(): 24 | logger.remove() 25 | 26 | def test_get_resolution(self): 27 | """ 28 | Verifies we can resolutions from mp4 files. 29 | """ 30 | with tempfile.TemporaryDirectory(prefix='test') as tmpdir: 31 | temp_dir = Path(tmpdir) 32 | shutil.copytree(Path(__file__).resolve().parent, temp_dir / 'test') 33 | file = temp_dir / 'test' / 'Site.22.01.01.painful.pun.XXX.720p.xpost.mp4' 34 | results = FFMpeg().ffprobe(file) 35 | self.assertIsNotNone(results) 36 | if results: 37 | res = results.get_resolution() 38 | self.assertEqual(res, 240) 39 | 40 | def test_get_audio_stream(self): 41 | """ 42 | Verifies we can get audio stream language names from files. 43 | """ 44 | with tempfile.TemporaryDirectory(prefix='test') as tmpdir: 45 | temp_dir = Path(tmpdir) 46 | shutil.copytree(Path(__file__).resolve().parent, temp_dir / 'test') 47 | file = temp_dir / 'test' / 'Site.22.01.01.painful.pun.XXX.720p.xpost.mp4' 48 | stream_number = FFMpeg().get_audio_stream_for_lang(file, 'und') 49 | self.assertEqual(stream_number, -1) 50 | stream_number = FFMpeg().get_audio_stream_for_lang(file, 'eng') 51 | self.assertEqual(stream_number, -1) 52 | 53 | def test_ffprobe(self) -> None: 54 | """ 55 | read stream info. 56 | """ 57 | with tempfile.TemporaryDirectory(prefix='test') as tmpdir: 58 | temp_dir = Path(tmpdir) 59 | shutil.copytree(Path(__file__).resolve().parent, temp_dir / 'test') 60 | file = temp_dir / 'test' / 'Site.22.01.01.painful.pun.XXX.720p.xpost_wrong.mp4' 61 | results = FFMpeg().ffprobe(file) 62 | self.assertIsNotNone(results) 63 | if results: 64 | self.assertTrue(results.get_all_streams()[0].is_video()) 65 | self.assertEqual(results.get_all_streams()[0].bit_rate, 8487) 66 | self.assertEqual(results.get_all_streams()[0].height, 240) 67 | self.assertEqual(results.get_all_streams()[0].width, 320) 68 | self.assertEqual(results.get_all_streams()[0].avg_frame_rate, 15.0) 69 | self.assertEqual(results.get_all_streams()[0].codec_name, 'h264') 70 | self.assertGreaterEqual(results.get_all_streams()[0].duration, 30) 71 | self.assertTrue(results.get_all_streams()[1].is_audio()) 72 | self.assertEqual(results.get_all_streams()[1].disposition_default, True) 73 | self.assertEqual(results.get_all_streams()[1].tags_language, 'und') 74 | self.assertTrue(results.get_all_streams()[2].is_audio()) 75 | self.assertEqual(results.get_all_streams()[2].disposition_default, False) 76 | self.assertEqual(results.get_all_streams()[2].tags_language, 'eng') 77 | self.assertEqual(results.get_default_video_stream(), results.get_all_streams()[0]) 78 | self.assertEqual(results.get_default_audio_stream(), results.get_all_streams()[1]) 79 | self.assertEqual(results.get_audio_stream('eng'), results.get_all_streams()[2]) 80 | self.assertEqual(results.get_audio_stream('und'), results.get_all_streams()[1]) 81 | 82 | def test_update_audio_stream(self): 83 | """ 84 | Verifies we can change default audio stream languages for mp4's. 85 | """ 86 | with tempfile.TemporaryDirectory(prefix='test') as tmpdir: 87 | temp_dir = Path(tmpdir) 88 | shutil.copytree(Path(__file__).resolve().parent, temp_dir / 'test') 89 | file = temp_dir / 'test' / 'Site.22.01.01.painful.pun.XXX.720p.xpost_wrong.mp4' 90 | stream_number = FFMpeg().get_audio_stream_for_lang(file, 'und') 91 | self.assertEqual(stream_number, -1) 92 | stream_number = FFMpeg().get_audio_stream_for_lang(file, 'eng') 93 | self.assertEqual(stream_number, 1) 94 | FFMpeg().update_audio_stream_if_needed(file, 'eng') 95 | stream_number = FFMpeg().get_audio_stream_for_lang(file, 'eng') 96 | self.assertEqual(stream_number, -1) 97 | 98 | def test_file_ffmpeg(self): 99 | versions = FFMpeg().ffmpeg_version() 100 | for _, version in versions.items(): 101 | self.assertIsNotNone(version) 102 | 103 | 104 | if __name__ == '__main__': 105 | unittest.main() 106 | -------------------------------------------------------------------------------- /test/namer_file_parser_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for namer_file_parser.py 3 | """ 4 | 5 | import io 6 | import shutil 7 | import unittest 8 | from pathlib import Path 9 | from unittest.mock import patch 10 | 11 | from loguru import logger 12 | 13 | from namer.fileinfo import parse_file_name 14 | from namer.command import make_command 15 | from test import utils 16 | from test.utils import environment, sample_config 17 | 18 | REGEX_TOKEN = '{_site}{_sep}{_optional_date}{_ts}{_name}{_dot}{_ext}' 19 | 20 | 21 | class UnitTestAsTheDefaultExecution(unittest.TestCase): 22 | """ 23 | Always test first. 24 | """ 25 | 26 | def __init__(self, method_name='runTest'): 27 | super().__init__(method_name) 28 | 29 | if not utils.is_debugging(): 30 | logger.remove() 31 | 32 | def test_parse_file_name(self): 33 | """ 34 | Test standard name parsing. 35 | """ 36 | name = parse_file_name('EvilAngel.22.01.03.Carmela.Clutch.Fabulous.Anal.3-Way.XXX.mp4', sample_config()) 37 | self.assertEqual(name.site, 'EvilAngel') 38 | self.assertEqual(name.date, '2022-01-03') 39 | self.assertEqual(name.name, 'Carmela Clutch Fabulous Anal 3-Way') 40 | self.assertEqual(name.trans, False) 41 | self.assertEqual(name.extension, 'mp4') 42 | 43 | def test_parse_file_name_interesting_site(self): 44 | """ 45 | Test standard name parsing. 46 | """ 47 | name = parse_file_name("Mommy's Girl - 15.04.20 - BTS-Mommy Takes a Squirt.mp4", sample_config()) 48 | self.assertEqual(name.site, "Mommy's Girl") 49 | self.assertEqual(name.date, '2015-04-20') 50 | self.assertEqual(name.name, 'BTS-Mommy Takes a Squirt') 51 | self.assertEqual(name.trans, False) 52 | self.assertEqual(name.extension, 'mp4') 53 | 54 | def test_parse_file_name_no_date(self): 55 | """ 56 | Test standard name parsing. 57 | """ 58 | name = parse_file_name('EvilAngel.Carmela.Clutch.Fabulous.Anal.3-Way.XXX.mp4', sample_config()) 59 | self.assertEqual(name.site, 'EvilAngel') 60 | self.assertEqual(name.date, None) 61 | self.assertEqual(name.name, 'Carmela Clutch Fabulous Anal 3-Way') 62 | self.assertEqual(name.trans, False) 63 | self.assertEqual(name.extension, 'mp4') 64 | 65 | def test_parse_file_name_no_date_ts_stamp(self): 66 | """ 67 | Test standard name parsing. 68 | """ 69 | name = parse_file_name('EvilAngel.TS.Carmela.Clutch.Fabulous.Anal.3-Way.XXX.mp4', sample_config()) 70 | self.assertEqual(name.site, 'EvilAngel') 71 | self.assertEqual(name.date, None) 72 | self.assertEqual(name.name, 'Carmela Clutch Fabulous Anal 3-Way') 73 | self.assertEqual(name.trans, True) 74 | self.assertEqual(name.extension, 'mp4') 75 | 76 | def test_parse_clean_file_name(self): 77 | """ 78 | Test standard name parsing. 79 | """ 80 | name = parse_file_name('Evil Angel - 2022-01-03 - Carmela Clutch Fabulous Anal 3-Way.XXX.mp4', sample_config()) 81 | self.assertEqual(name.site, 'Evil Angel') 82 | self.assertEqual(name.date, '2022-01-03') 83 | self.assertEqual(name.name, 'Carmela Clutch Fabulous Anal 3-Way') 84 | self.assertEqual(name.trans, False) 85 | self.assertEqual(name.extension, 'mp4') 86 | 87 | def test_parse_file_name_with_trans(self): 88 | """ 89 | Test parsing a name with a TS tag after the date, uncommon, but not unheard of. 90 | """ 91 | name = parse_file_name('EvilAngel.22.01.03.TS.Carmela.Clutch.Fabulous.Anal.3-Way.part-1-XXX.mp4', sample_config()) 92 | self.assertEqual(name.site, 'EvilAngel') 93 | self.assertEqual(name.date, '2022-01-03') 94 | self.assertEqual(name.name, 'Carmela Clutch Fabulous Anal 3-Way part-1') 95 | self.assertEqual(name.trans, True) 96 | self.assertEqual(name.extension, 'mp4') 97 | 98 | def test_parse_file_name_complex_site(self): 99 | """ 100 | Test parsing a name with a TS tag after the date, uncommon, but not unheard of. 101 | """ 102 | name = parse_file_name('Twistys Feature Film.16.04.07.aidra.fox.the.getaway.part.1.mp4', sample_config()) 103 | self.assertEqual(name.site, 'Twistys Feature Film') 104 | self.assertEqual(name.date, '2016-04-07') 105 | self.assertEqual(name.name, 'aidra fox the getaway part 1') 106 | self.assertEqual(name.trans, False) 107 | self.assertEqual(name.extension, 'mp4') 108 | 109 | def test_parse_file_name_c_site(self): 110 | """ 111 | Test parsing a name with a TS tag after the date, uncommon, but not unheard of. 112 | """ 113 | name = parse_file_name('BrazzersExxtra - 2021-12-07 - Dr. Polla & The Chronic Discharge Conundrum.mp4', sample_config()) 114 | self.assertEqual(name.site, 'BrazzersExxtra') 115 | self.assertEqual(name.date, '2021-12-07') 116 | self.assertEqual(name.name, 'Dr Polla & The Chronic Discharge Conundrum') 117 | self.assertEqual(name.trans, False) 118 | self.assertEqual(name.extension, 'mp4') 119 | 120 | def test_parse_file_name_site_abbreviations(self): 121 | """ 122 | Test parsing a name with a TS tag after the date, uncommon, but not unheard of. 123 | """ 124 | name = parse_file_name('bex - 2021-12-07 - Dr. Polla & The Chronic Discharge Conundrum.mp4', sample_config()) 125 | self.assertEqual(name.site, 'BrazzersExxtra') 126 | self.assertEqual(name.date, '2021-12-07') 127 | self.assertEqual(name.name, 'Dr Polla & The Chronic Discharge Conundrum') 128 | self.assertEqual(name.trans, False) 129 | self.assertEqual(name.extension, 'mp4') 130 | 131 | @patch('sys.stdout', new_callable=io.StringIO) 132 | def test_parse_file(self, mock_stdout): 133 | """ 134 | Test the main method. 135 | """ 136 | with environment() as (tmp_dir, _parrot, config): 137 | temp_dir = Path(tmp_dir) 138 | test_dir = Path(__file__).resolve().parent 139 | target_file = temp_dir / 'EvilAngel.22.01.03.Carmela.Clutch.Fabulous.Anal.3-Way.XXX.mp4' 140 | shutil.copy(test_dir / 'Site.22.01.01.painful.pun.XXX.720p.xpost.mp4', target_file) 141 | config.min_file_size = 0 142 | command = make_command(target_file, config) 143 | self.assertIsNotNone(command) 144 | if command is not None: 145 | self.assertIsNotNone(command.parsed_file) 146 | if command.parsed_file is not None: 147 | self.assertEqual(command.parsed_file.site, 'EvilAngel') 148 | self.assertEqual(command.parsed_file.date, '2022-01-03') 149 | self.assertEqual(command.parsed_file.name, 'Carmela Clutch Fabulous Anal 3-Way') 150 | self.assertEqual(command.parsed_file.trans, False) 151 | self.assertEqual(command.parsed_file.extension, 'mp4') 152 | 153 | @patch('sys.stdout', new_callable=io.StringIO) 154 | def test_parse_dir_name(self, mock_stdout): 155 | """ 156 | Test the main method. 157 | """ 158 | with environment() as (tmp_dir, _parrot, config): 159 | temp_dir = Path(tmp_dir) 160 | test_dir = Path(__file__).resolve().parent 161 | target_file = temp_dir / 'EvilAngel.22.01.03.Carmela.Clutch.Fabulous.Anal.3-Way.XXX.1080p.HEVC.x265.PRT[XvX]-xpost' / 'EvilAngel.22.01.03.Carmela.Clutch.Fabulous.Anal.3-Way.XXX.mp4' 162 | target_file.parent.mkdir() 163 | shutil.copy(test_dir / 'Site.22.01.01.painful.pun.XXX.720p.xpost.mp4', target_file) 164 | config.min_file_size = 0 165 | config.prefer_dir_name_if_available = True 166 | command = make_command(target_file.parent, config) 167 | self.assertIsNotNone(command) 168 | if command is not None: 169 | self.assertIsNotNone(command.parsed_file) 170 | if command.parsed_file is not None: 171 | self.assertEqual(command.parsed_file.site, 'EvilAngel') 172 | self.assertEqual(command.parsed_file.date, '2022-01-03') 173 | self.assertEqual(command.parsed_file.name, 'Carmela Clutch Fabulous Anal 3-Way') 174 | self.assertEqual(command.parsed_file.trans, False) 175 | self.assertEqual(command.parsed_file.extension, 'mp4') 176 | 177 | 178 | if __name__ == '__main__': 179 | unittest.main() 180 | -------------------------------------------------------------------------------- /test/namer_fileutils_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for namer_file_parser.py 3 | """ 4 | 5 | import io 6 | import shutil 7 | import tempfile 8 | import unittest 9 | from pathlib import Path 10 | from platform import system 11 | from unittest.mock import patch 12 | 13 | from loguru import logger 14 | 15 | from namer.command import main, set_permissions 16 | from test import utils 17 | from test.utils import environment, sample_config 18 | 19 | REGEX_TOKEN = '{_site}{_sep}{_optional_date}{_ts}{_name}{_dot}{_ext}' 20 | 21 | 22 | class UnitTestAsTheDefaultExecution(unittest.TestCase): 23 | """ 24 | Always test first. 25 | """ 26 | 27 | def __init__(self, method_name='runTest'): 28 | super().__init__(method_name) 29 | 30 | if not utils.is_debugging(): 31 | logger.remove() 32 | 33 | @patch('sys.stdout', new_callable=io.StringIO) 34 | def test_main_method(self, mock_stdout): 35 | """ 36 | Test the main method. 37 | """ 38 | config = sample_config() 39 | config.min_file_size = 0 40 | with environment(config) as (temp_dir, _parrot, config): 41 | test_dir = Path(__file__).resolve().parent 42 | target_file = temp_dir / 'EvilAngel.22.01.03.Carmela.Clutch.Fabulous.Anal.3-Way.XXX.mp4' 43 | shutil.copy(test_dir / 'Site.22.01.01.painful.pun.XXX.720p.xpost.mp4', target_file) 44 | main(arg_list=['-f', str(target_file), '-c', str(config.config_file)]) 45 | self.assertIn('site: EvilAngel', mock_stdout.getvalue()) 46 | 47 | def test_set_permission(self): 48 | """ 49 | Verify set permission. 50 | """ 51 | if system() != 'Windows': 52 | with tempfile.TemporaryDirectory(prefix='test') as tmpdir: 53 | temp_dir = Path(tmpdir) 54 | target_dir = temp_dir / 'target_dir' 55 | target_dir.mkdir() 56 | testfile = target_dir / 'test_file.txt' 57 | with open(testfile, 'w', encoding='utf-8') as file: 58 | file.write('Create a new text file!') 59 | self.assertEqual(oct(testfile.stat().st_mode)[-3:], '644') 60 | self.assertEqual(oct(target_dir.stat().st_mode)[-3:], '755') 61 | self.assertNotEqual(target_dir.stat().st_gid, '1234567890') 62 | config = sample_config() 63 | config.set_dir_permissions = 777 64 | config.set_file_permissions = 666 65 | set_permissions(testfile, config) 66 | self.assertEqual(oct(testfile.stat().st_mode)[-3:], '666') 67 | set_permissions(target_dir, config) 68 | self.assertEqual(oct(target_dir.stat().st_mode)[-3:], '777') 69 | 70 | 71 | if __name__ == '__main__': 72 | unittest.main() 73 | -------------------------------------------------------------------------------- /test/namer_mutagen_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test for namer_mutagen.py 3 | """ 4 | 5 | import shutil 6 | import tempfile 7 | import unittest 8 | from pathlib import Path 9 | import hashlib 10 | 11 | from loguru import logger 12 | from mutagen.mp4 import MP4 13 | 14 | from namer.configuration import NamerConfig 15 | from namer.fileinfo import parse_file_name 16 | from namer.ffmpeg import FFMpeg 17 | from namer.metadataapi import match 18 | from namer.mutagen import resolution_to_hdv_setting, update_mp4_file 19 | from namer.comparison_results import LookedUpFileInfo 20 | from test import utils 21 | from test.utils import validate_mp4_tags 22 | from test.namer_metadataapi_test import environment 23 | 24 | 25 | class UnitTestAsTheDefaultExecution(unittest.TestCase): 26 | """ 27 | Always test first. 28 | """ 29 | 30 | def __init__(self, method_name='runTest'): 31 | super().__init__(method_name) 32 | 33 | if not utils.is_debugging(): 34 | logger.remove() 35 | 36 | def test_video_size(self): 37 | """ 38 | Test resolution. 39 | """ 40 | self.assertEqual(resolution_to_hdv_setting(2160), 3) 41 | self.assertEqual(resolution_to_hdv_setting(1080), 2) 42 | self.assertEqual(resolution_to_hdv_setting(720), 1) 43 | self.assertEqual(resolution_to_hdv_setting(480), 0) 44 | self.assertEqual(resolution_to_hdv_setting(320), 0) 45 | self.assertEqual(resolution_to_hdv_setting(None), 0) 46 | 47 | def test_writing_metadata(self): 48 | """ 49 | verify tag in place functions. 50 | """ 51 | with environment() as (temp_dir, _parrot, config): 52 | test_dir = Path(__file__).resolve().parent 53 | poster = temp_dir / 'poster.png' 54 | shutil.copy(test_dir / 'poster.png', poster) 55 | target_file = temp_dir / 'DorcelClub - 2021-12-23 - Aya.Benetti.Megane.Lopez.And.Bella.Tina.XXX.1080p.mp4' 56 | shutil.copy(test_dir / 'Site.22.01.01.painful.pun.XXX.720p.xpost.mp4', target_file) 57 | name_parts = parse_file_name(target_file.name, config) 58 | info = match(name_parts, config) 59 | ffprobe_results = FFMpeg().ffprobe(target_file) 60 | update_mp4_file(target_file, info.results[0].looked_up, poster, ffprobe_results, NamerConfig()) 61 | output = MP4(target_file) 62 | self.assertEqual(output.get('\xa9nam'), ['Peeping Tom']) 63 | 64 | def test_writing_full_metadata(self): 65 | """ 66 | Test writing metadata to a mp4, including tag information, which is only 67 | available on scene requests to the porndb using uuid to request scene information. 68 | """ 69 | with environment() as (temp_dir, _parrot, config): 70 | test_dir = Path(__file__).resolve().parent 71 | target_file = temp_dir / 'EvilAngel.22.01.03.Carmela.Clutch.Fabulous.Anal.3-Way.XXX.mp4' 72 | shutil.copy(test_dir / 'Site.22.01.01.painful.pun.XXX.720p.xpost.mp4', target_file) 73 | poster = temp_dir / 'poster.png' 74 | shutil.copy(test_dir / 'poster.png', poster) 75 | name_parts = parse_file_name(target_file.name, config) 76 | info = match(name_parts, config) 77 | ffprobe_results = FFMpeg().ffprobe(target_file) 78 | update_mp4_file(target_file, info.results[0].looked_up, poster, ffprobe_results, NamerConfig()) 79 | validate_mp4_tags(self, target_file) 80 | 81 | def test_sha_sum_two_identical_transformations(self): 82 | """ 83 | Test that adding metadata to two identical files on two different systems, at two different times 84 | produces the shame bytes (via sha256) 85 | """ 86 | # when the id = 87 | expected_on_all_oses = '1772fcba7610818eaef63d3e268c5ea9134b4531680cdb66ae6e16a3a1c20acc' 88 | 89 | sha_1 = None 90 | sha_2 = None 91 | with environment() as (temp_dir, _parrot, config): 92 | test_dir = Path(__file__).resolve().parent 93 | target_file = temp_dir / 'EvilAngel.22.01.03.Carmela.Clutch.Fabulous.Anal.3-Way.XXX.mp4' 94 | shutil.copy(test_dir / 'Site.22.01.01.painful.pun.XXX.720p.xpost.mp4', target_file) 95 | poster = temp_dir / 'poster.png' 96 | shutil.copy(test_dir / 'poster.png', poster) 97 | name_parts = parse_file_name(target_file.name, config) 98 | info = match(name_parts, config) 99 | ffprobe_results = FFMpeg().ffprobe(target_file) 100 | update_mp4_file(target_file, info.results[0].looked_up, poster, ffprobe_results, NamerConfig()) 101 | validate_mp4_tags(self, target_file) 102 | sha_1 = hashlib.sha256(target_file.read_bytes()).digest().hex() 103 | with environment() as (temp_dir, _parrot, config): 104 | test_dir = Path(__file__).resolve().parent 105 | target_file = temp_dir / 'EvilAngel.22.01.03.Carmela.Clutch.Fabulous.Anal.3-Way.XXX.mp4' 106 | shutil.copy(test_dir / 'Site.22.01.01.painful.pun.XXX.720p.xpost.mp4', target_file) 107 | poster = temp_dir / 'poster.png' 108 | shutil.copy(test_dir / 'poster.png', poster) 109 | name_parts = parse_file_name(target_file.name, config) 110 | info = match(name_parts, config) 111 | ffprobe_results = FFMpeg().ffprobe(target_file) 112 | update_mp4_file(target_file, info.results[0].looked_up, poster, ffprobe_results, NamerConfig()) 113 | validate_mp4_tags(self, target_file) 114 | sha_2 = hashlib.sha256(target_file.read_bytes()).digest().hex() 115 | self.assertEqual(str(sha_1), str(sha_2)) 116 | self.assertEqual(sha_1, expected_on_all_oses) 117 | 118 | def test_non_existent_poster(self): 119 | """ 120 | Test writing metadata to an mp4, including tag information, which is only 121 | available on scene requests to the porndb using uuid to request scene information. 122 | """ 123 | with environment() as (temp_dir, _parrot, config): 124 | test_dir = Path(__file__).resolve().parent 125 | target_file = temp_dir / 'EvilAngel.22.01.03.Carmela.Clutch.Fabulous.Anal.3-Way.XXX.mp4' 126 | shutil.copy(test_dir / 'Site.22.01.01.painful.pun.XXX.720p.xpost.mp4', target_file) 127 | poster = None 128 | name_parts = parse_file_name(target_file.name, config) 129 | info = match(name_parts, config) 130 | ffprobe_results = FFMpeg().ffprobe(target_file) 131 | update_mp4_file(target_file, info.results[0].looked_up, poster, ffprobe_results, NamerConfig()) 132 | validate_mp4_tags(self, target_file) 133 | 134 | def test_non_existent_file(self): 135 | """ 136 | Test writing metadata to an mp4, including tag information, which is only 137 | available on scene requests to the porndb using uuid to request scene information. 138 | """ 139 | with environment() as (temp_dir, _parrot, config): 140 | targetfile = temp_dir / 'test' / 'EvilAngel.22.01.03.Carmela.Clutch.Fabulous.Anal.3-Way.XXX.mp4' 141 | poster = None 142 | name_parts = parse_file_name(targetfile.name, config) 143 | info = match(name_parts, config) 144 | ffprobe_results = FFMpeg().ffprobe(targetfile) 145 | update_mp4_file(targetfile, info.results[0].looked_up, poster, ffprobe_results, config) 146 | self.assertFalse(targetfile.exists()) 147 | 148 | def test_empty_infos(self): 149 | """ 150 | Test writing metadata to an mp4, including tag information, which is only 151 | available on scene requests to the porndb using uuid to request scene information. 152 | """ 153 | with tempfile.TemporaryDirectory(prefix='test') as tmpdir: 154 | temp_dir = Path(tmpdir) 155 | target_file = temp_dir / 'test' / 'EvilAngel.22.01.03.Carmela.Clutch.Fabulous.Anal.3-Way.XXX.mp4' 156 | target_file.parent.mkdir(parents=True, exist_ok=True) 157 | test_dir = Path(__file__).resolve().parent 158 | shutil.copy(test_dir / 'Site.22.01.01.painful.pun.XXX.720p.xpost.mp4', target_file) 159 | info = LookedUpFileInfo() 160 | ffprobe_results = FFMpeg().ffprobe(target_file) 161 | update_mp4_file(target_file, info, None, ffprobe_results, NamerConfig()) 162 | self.assertTrue(target_file.exists()) 163 | mp4 = MP4(target_file) 164 | self.assertEqual(mp4.get('\xa9nam'), []) 165 | self.assertEqual(mp4.get('\xa9day'), []) 166 | self.assertEqual(mp4.get('\xa9alb'), []) 167 | self.assertEqual(mp4.get('tvnn'), []) 168 | 169 | 170 | if __name__ == '__main__': 171 | unittest.main() 172 | -------------------------------------------------------------------------------- /test/namer_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | Fully test namer.py 3 | """ 4 | 5 | import os 6 | import shutil 7 | import tempfile 8 | import unittest 9 | from pathlib import Path 10 | 11 | from loguru import logger 12 | from mutagen.mp4 import MP4 13 | 14 | from namer.configuration import NamerConfig 15 | from namer.configuration_utils import to_ini 16 | from namer.namer import check_arguments, main, set_permissions 17 | from test import utils 18 | from test.utils import new_ea, sample_config, validate_mp4_tags, environment, FakeTPDB 19 | 20 | 21 | class UnitTestAsTheDefaultExecution(unittest.TestCase): 22 | """ 23 | Always test first. 24 | """ 25 | 26 | def __init__(self, method_name='runTest'): 27 | super().__init__(method_name) 28 | 29 | if not utils.is_debugging(): 30 | logger.remove() 31 | 32 | def test_check_arguments(self): 33 | """ 34 | verify file system checks 35 | """ 36 | with tempfile.TemporaryDirectory(prefix='test') as tmpdir: 37 | temp_dir = Path(tmpdir) 38 | target_dir = temp_dir / 'path/' 39 | file = temp_dir / 'file' 40 | config = temp_dir / 'config' 41 | error = check_arguments(dir_to_process=target_dir, file_to_process=file, config_override=config) 42 | self.assertTrue(error) 43 | target_dir.mkdir() 44 | file.write_text('test') 45 | config.write_text('test') 46 | error = check_arguments(dir_to_process=target_dir, file_to_process=file, config_override=config) 47 | self.assertFalse(error) 48 | 49 | def test_writing_metadata_file(self: unittest.TestCase): 50 | """ 51 | test namer main method renames and tags in place when -f (video file) is passed 52 | """ 53 | with environment() as (temp_dir, fake_tpdb, config): 54 | targets = [new_ea(temp_dir, use_dir=False)] 55 | main(['-f', str(targets[0].file), '-c', str(config.config_file)]) 56 | output = MP4(targets[0].get_file().parent / 'Evil Angel - 2022-01-03 - Carmela Clutch Fabulous Anal 3-Way! [WEBDL-240].mp4') 57 | self.assertEqual(output.get('\xa9nam'), ['Carmela Clutch: Fabulous Anal 3-Way!']) 58 | 59 | def test_writing_metadata_dir(self: unittest.TestCase): 60 | """ 61 | test namer main method renames and tags in place when -d (directory) is passed 62 | """ 63 | with environment() as (temp_dir, fake_tpdb, config): 64 | targets = [new_ea(temp_dir, use_dir=True)] 65 | main(['-d', str(targets[0].get_file().parent), '-c', str(config.config_file)]) 66 | output = MP4(targets[0].get_file().parent.parent / 'Evil Angel' / 'Evil Angel - 2022-01-03 - Carmela Clutch Fabulous Anal 3-Way! [WEBDL-240].mp4') 67 | self.assertEqual(output.get('\xa9nam'), ['Carmela Clutch: Fabulous Anal 3-Way!']) 68 | 69 | def test_writing_metadata_all_dirs(self: unittest.TestCase): 70 | """ 71 | Test multiple directories are processed when -d (directory) and -m are passed. 72 | Process all subdirs of -d. 73 | """ 74 | with environment() as (temp_dir, fake_tpdb, config): 75 | temp_dir: Path 76 | fake_tpdb: FakeTPDB 77 | config: NamerConfig 78 | targets = [ 79 | new_ea(temp_dir, use_dir=True, post_stem='1'), 80 | new_ea(temp_dir, use_dir=True, post_stem='2'), 81 | ] 82 | main(['-d', str(targets[0].get_file().parent.parent), '-m', '-c', str(config.config_file)]) 83 | output = MP4(targets[0].get_file().parent.parent / 'Evil Angel' / 'Evil Angel - 2022-01-03 - Carmela Clutch Fabulous Anal 3-Way! [WEBDL-240].mp4') 84 | self.assertEqual(output.get('\xa9nam'), ['Carmela Clutch: Fabulous Anal 3-Way!']) 85 | output = MP4(targets[1].get_file().parent.parent / 'Evil Angel' / 'Evil Angel - 2022-01-03 - Carmela Clutch Fabulous Anal 3-Way! [WEBDL-240](1).mp4') 86 | self.assertEqual(output.get('\xa9nam'), ['Carmela Clutch: Fabulous Anal 3-Way!']) 87 | 88 | def test_writing_metadata_from_nfo(self): 89 | """ 90 | Test renaming and writing a movie's metadata from a nfo file. 91 | """ 92 | config = sample_config() 93 | config.enabled_tagging = True 94 | config.enabled_poster = True 95 | config.write_nfo = False 96 | config.min_file_size = 0 97 | with tempfile.TemporaryDirectory(prefix='test') as tmpdir: 98 | current = Path(__file__).resolve().parent 99 | temp_dir = Path(tmpdir) 100 | nfo_file = current / 'ea.nfo' 101 | mp4_file = current / 'Site.22.01.01.painful.pun.XXX.720p.xpost.mp4' 102 | poster_file = current / 'poster.png' 103 | target_nfo_file = temp_dir / 'ea.nfo' 104 | target_mp4_file = temp_dir / 'ea.mp4' 105 | target_poster_file = temp_dir / 'poster.png' 106 | shutil.copy(mp4_file, target_mp4_file) 107 | shutil.copy(nfo_file, target_nfo_file) 108 | shutil.copy(poster_file, target_poster_file) 109 | 110 | cfg_file = temp_dir / 'test_namer.cfg' 111 | with open(cfg_file, 'w') as file: 112 | content = to_ini(config) 113 | file.write(content) 114 | 115 | main(['-f', str(target_mp4_file), '-i', '-c', str(cfg_file)]) 116 | output = MP4(target_mp4_file.parent / 'Evil Angel - 2022-01-03 - Carmela Clutch Fabulous Anal 3-Way! [WEBDL-240].mp4') 117 | self.assertEqual(output.get('\xa9nam'), ['Carmela Clutch: Fabulous Anal 3-Way!']) 118 | 119 | def test_writing_metadata_all_dirs_files(self): 120 | """ 121 | Test multiple directories are processed when -d (directory) and -m are passed. 122 | Process all sub-dirs of -d. 123 | """ 124 | config = sample_config() 125 | with environment(config) as (temp_dir, fake_tpdb, config): 126 | targets = [new_ea(temp_dir, use_dir=False, post_stem='1'), new_ea(temp_dir, use_dir=False, post_stem='2')] 127 | main(['-d', str(targets[0].get_file().parent), '-m', '-c', str(config.config_file)]) 128 | output1 = targets[0].get_file().parent / 'Evil Angel - 2022-01-03 - Carmela Clutch Fabulous Anal 3-Way! [WEBDL-240].mp4' 129 | validate_mp4_tags(self, output1) 130 | output2 = targets[1].get_file().parent / 'Evil Angel - 2022-01-03 - Carmela Clutch Fabulous Anal 3-Way! [WEBDL-240](1).mp4' 131 | validate_mp4_tags(self, output2) 132 | 133 | def test_set_permissions(self): 134 | """ 135 | Test set permissions 136 | """ 137 | with tempfile.TemporaryDirectory(prefix='test') as tmpdir: 138 | path = Path(tmpdir) 139 | test_file = path / 'test_file.txt' 140 | test_file.write_text('test') 141 | test_dir = path / 'test_dir' 142 | test_dir.mkdir() 143 | config = sample_config() 144 | if hasattr(os, 'getgroups'): 145 | config.set_gid = None if len(os.getgroups()) == 0 else os.getgroups()[0] 146 | if hasattr(os, 'getuid'): 147 | config.set_uid = os.getuid() 148 | config.set_dir_permissions = 777 149 | config.set_file_permissions = 666 150 | set_permissions(test_file, config) 151 | self.assertTrue(os.access(test_file, os.R_OK)) 152 | config.set_file_permissions = None 153 | set_permissions(test_file, config) 154 | self.assertTrue(os.access(test_file, os.R_OK)) 155 | set_permissions(test_dir, config) 156 | self.assertTrue(os.access(test_dir, os.R_OK)) 157 | set_permissions(None, config) 158 | 159 | 160 | if __name__ == '__main__': 161 | unittest.main() 162 | -------------------------------------------------------------------------------- /test/namer_types_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test namer_types.py 3 | """ 4 | 5 | import os 6 | import sys 7 | import unittest 8 | from pathlib import Path 9 | 10 | from loguru import logger 11 | 12 | from namer.configuration import NamerConfig 13 | from namer.configuration_utils import verify_configuration 14 | from namer.name_formatter import PartialFormatter 15 | from namer.comparison_results import Performer 16 | from test import utils 17 | 18 | 19 | class UnitTestAsTheDefaultExecution(unittest.TestCase): 20 | """ 21 | Always test first. 22 | """ 23 | 24 | def __init__(self, method_name='runTest'): 25 | super().__init__(method_name) 26 | 27 | if not utils.is_debugging(): 28 | logger.remove() 29 | 30 | def test_performer(self): 31 | """ 32 | Test performer __str__ 33 | """ 34 | self.assertEqual(str(Performer(None, None)), 'Unknown') 35 | self.assertEqual(str(Performer('Name', None)), 'Name') 36 | self.assertEqual(str(Performer(None, 'Role')), 'Unknown (Role)') 37 | self.assertEqual(str(Performer('Name', 'Role')), 'Name (Role)') 38 | 39 | def test_default_no_config(self): 40 | """ 41 | verify the default values of NamerConfig 42 | """ 43 | config = NamerConfig() 44 | self.assertEqual(config.del_other_files, False) 45 | self.assertEqual(config.inplace_name, '{full_site} - {date} - {name} [WEBDL-{resolution}].{ext}') 46 | self.assertEqual(config.enabled_tagging, False) 47 | self.assertEqual(config.write_namer_log, False) 48 | self.assertEqual(config.enable_metadataapi_genres, False) 49 | self.assertEqual(config.default_genre, 'Adult') 50 | self.assertFalse(hasattr(config, 'dest_dir')) 51 | self.assertFalse(hasattr(config, 'failed_dir')) 52 | self.assertEqual(config.min_file_size, 300) 53 | self.assertEqual(config.language, None) 54 | if sys.platform != 'win32': 55 | self.assertEqual(config.set_uid, os.getuid()) 56 | self.assertEqual(config.set_gid, os.getgid()) 57 | self.assertEqual(config.set_dir_permissions, 775) 58 | self.assertEqual(config.set_file_permissions, 664) 59 | 60 | def test_formatter(self): 61 | """ 62 | Verify that partial formatter can handle missing fields gracefully, 63 | and it's prefix, postfix, and infix capabilities work. 64 | """ 65 | bad_fmt = '---' 66 | fmt = PartialFormatter(missing='', bad_fmt=bad_fmt) 67 | name = fmt.format('{name}{act: 1p}', name='scene1', act='act1') 68 | self.assertEqual(name, 'scene1 act1') 69 | name = fmt.format('{name}{act: 1p}', name='scene1', act=None) 70 | self.assertEqual(name, 'scene1') 71 | 72 | name = fmt.format('{name}{act: 1s}', name='scene1', act='act1') 73 | self.assertEqual(name, 'scene1act1 ') 74 | name = fmt.format('{name}{act: 1s}', name='scene1', act=None) 75 | self.assertEqual(name, 'scene1') 76 | 77 | name = fmt.format('{name}{act: 1i}', name='scene1', act='act1') 78 | self.assertEqual(name, 'scene1 act1 ') 79 | name = fmt.format('{name}{act: 1i}', name='scene1', act=None) 80 | self.assertEqual(name, 'scene1') 81 | 82 | name = fmt.format('{name}{act:_1i}', name='scene1', act='act1') 83 | self.assertEqual(name, 'scene1_act1_') 84 | 85 | name = fmt.format('{name}{act: >10}', name='scene1', act='act1') 86 | self.assertEqual(name, 'scene1 act1') 87 | 88 | name = fmt.format('{name:|title}{act:|upper}', name='scene1', act='act1') 89 | self.assertEqual(name, 'Scene1ACT1') 90 | 91 | with self.assertRaises(Exception) as error1: 92 | name = fmt.format('{name1}{act: >10}', name='scene1', act='act1') 93 | self.assertEqual(name, 'scene1 act1') 94 | self.assertTrue('name1' in str(error1.exception)) 95 | self.assertTrue('all_performers' in str(error1.exception)) 96 | 97 | self.assertEqual(fmt.format_field(format_spec='adsfadsf', value='fmt'), bad_fmt) 98 | 99 | with self.assertRaises(Exception) as error2: 100 | fmt1 = PartialFormatter(missing='', bad_fmt=None) # type: ignore 101 | fmt1.format_field(format_spec='adsfadsf', value='fmt') 102 | self.assertTrue('Invalid format specifier' in str(error2.exception)) 103 | 104 | def test_config_verification(self): 105 | """ 106 | Verify config verification. 107 | """ 108 | config = NamerConfig() 109 | success = verify_configuration(config, PartialFormatter()) 110 | self.assertEqual(success, True) 111 | 112 | config = NamerConfig() 113 | config.watch_dir = Path('/not/a/real/path') 114 | success = verify_configuration(config, PartialFormatter()) 115 | self.assertEqual(success, False) 116 | 117 | config = NamerConfig() 118 | config.work_dir = Path('/not/a/real/path') 119 | success = verify_configuration(config, PartialFormatter()) 120 | self.assertEqual(success, False) 121 | 122 | config = NamerConfig() 123 | config.failed_dir = Path('/not/a/real/path') 124 | success = verify_configuration(config, PartialFormatter()) 125 | self.assertEqual(success, False) 126 | 127 | config = NamerConfig() 128 | config.inplace_name = '{sitesadf} - {date}' 129 | success = verify_configuration(config, PartialFormatter()) 130 | self.assertEqual(success, False) 131 | 132 | config1 = NamerConfig() 133 | config1.new_relative_path_name = '{whahha}/{site} - {date}' 134 | success = verify_configuration(config, PartialFormatter()) 135 | self.assertEqual(success, False) 136 | 137 | 138 | if __name__ == '__main__': 139 | unittest.main() 140 | -------------------------------------------------------------------------------- /test/namer_videophash_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test namer_videophash.py 3 | """ 4 | 5 | import shutil 6 | import tempfile 7 | import unittest 8 | from pathlib import Path 9 | 10 | from loguru import logger 11 | 12 | from namer.videophash import imagehash 13 | from namer.videophash.videophashstash import StashVideoPerceptualHash 14 | from namer.videophash.videophash import VideoPerceptualHash 15 | from test import utils 16 | from test.utils import sample_config 17 | 18 | 19 | class UnitTestAsTheDefaultExecution(unittest.TestCase): 20 | """ 21 | Always test first. 22 | """ 23 | 24 | def __init__(self, method_name='runTest'): 25 | super().__init__(method_name) 26 | 27 | if not utils.is_debugging(): 28 | logger.remove() 29 | 30 | config = sample_config() 31 | __generator = VideoPerceptualHash(config.ffmpeg) 32 | __stash_generator = StashVideoPerceptualHash() 33 | 34 | def test_get_phash(self): 35 | """ 36 | Test phash calculation. 37 | """ 38 | expected_phash = imagehash.hex_to_hash('88982eebd3552d9c') 39 | expected_oshash = 'ae547a6b1d8488bc' 40 | expected_duration = 30 41 | 42 | with tempfile.TemporaryDirectory(prefix='test') as tmpdir: 43 | temp_dir = Path(tmpdir) 44 | shutil.copytree(Path(__file__).resolve().parent, temp_dir / 'test') 45 | file = temp_dir / 'test' / 'Site.22.01.01.painful.pun.XXX.720p.xpost.mp4' 46 | res = self.__generator.get_hashes(file) 47 | 48 | self.assertIsNotNone(res) 49 | if res: 50 | self.assertEqual(res.phash, expected_phash) 51 | self.assertEqual(res.oshash, expected_oshash) 52 | self.assertEqual(res.duration, expected_duration) 53 | 54 | def test_get_stash_phash(self): 55 | """ 56 | Test phash calculation. 57 | """ 58 | expected_phash = imagehash.hex_to_hash('88982eebd3552d9c') 59 | expected_oshash = 'ae547a6b1d8488bc' 60 | expected_duration = 30 61 | 62 | with tempfile.TemporaryDirectory(prefix='test') as tmpdir: 63 | temp_dir = Path(tmpdir) 64 | shutil.copytree(Path(__file__).resolve().parent, temp_dir / 'test') 65 | file = temp_dir / 'test' / 'Site.22.01.01.painful.pun.XXX.720p.xpost.mp4' 66 | res = self.__stash_generator.get_hashes(file) 67 | 68 | self.assertIsNotNone(res) 69 | if res: 70 | self.assertEqual(res.phash, expected_phash) 71 | self.assertEqual(res.oshash, expected_oshash) 72 | self.assertEqual(res.duration, expected_duration) 73 | 74 | 75 | if __name__ == '__main__': 76 | unittest.main() 77 | -------------------------------------------------------------------------------- /test/namer_webhook_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test webhook notification functionality in namer.py 3 | """ 4 | 5 | import json 6 | import unittest 7 | from pathlib import Path 8 | from unittest.mock import patch, MagicMock 9 | 10 | from loguru import logger 11 | 12 | from namer.namer import send_webhook_notification 13 | from test import utils 14 | from test.utils import sample_config, environment, new_ea 15 | 16 | 17 | class WebhookTest(unittest.TestCase): 18 | """ 19 | Test the webhook notification functionality. 20 | """ 21 | 22 | def __init__(self, method_name='runTest'): 23 | super().__init__(method_name) 24 | 25 | if not utils.is_debugging(): 26 | logger.remove() 27 | 28 | def test_webhook_disabled(self): 29 | """ 30 | Test that no webhook is sent when the feature is disabled. 31 | """ 32 | config = sample_config() 33 | config.webhook_enabled = False 34 | config.webhook_url = 'http://example.com/webhook' 35 | 36 | with patch('requests.request') as mock_post: 37 | send_webhook_notification(Path('/some/path/movie.mp4'), config) 38 | mock_post.assert_not_called() 39 | 40 | def test_webhook_no_url(self): 41 | """ 42 | Test that no webhook is sent when the URL is not configured. 43 | """ 44 | config = sample_config() 45 | config.webhook_enabled = True 46 | config.webhook_url = '' 47 | 48 | with patch('requests.request') as mock_post: 49 | send_webhook_notification(Path('/some/path/movie.mp4'), config) 50 | mock_post.assert_not_called() 51 | 52 | def test_webhook_success(self): 53 | """ 54 | Test that a webhook is successfully sent when enabled and URL is configured. 55 | """ 56 | config = sample_config() 57 | config.webhook_enabled = True 58 | config.webhook_url = 'http://example.com/webhook' 59 | 60 | with patch('requests.request') as mock_post: 61 | mock_response = MagicMock() 62 | mock_response.raise_for_status.return_value = None 63 | mock_post.return_value = mock_response 64 | 65 | send_webhook_notification(Path('/some/path/movie.mp4'), config) 66 | 67 | mock_post.assert_called_once() 68 | # Verify payload structure 69 | args, kwargs = mock_post.call_args 70 | self.assertEqual(args[0], 'POST') 71 | self.assertEqual(args[1], 'http://example.com/webhook') 72 | self.assertEqual(kwargs['data'].decode('UTF-8'), json.dumps({'target_movie_file': str(Path('/some/path/movie.mp4'))}, separators=(',', ':'))) 73 | 74 | def test_webhook_failure(self): 75 | """ 76 | Test that webhook errors are properly handled. 77 | """ 78 | config = sample_config() 79 | config.webhook_enabled = True 80 | config.webhook_url = 'http://example.com/webhook' 81 | 82 | with patch('requests.request') as mock_post: 83 | mock_post.side_effect = Exception('Connection error') 84 | 85 | # Should not raise an exception 86 | send_webhook_notification(Path('/some/path/movie.mp4'), config) 87 | 88 | mock_post.assert_called_once() 89 | 90 | def test_integration_with_file_processing(self): 91 | """ 92 | Test that webhook is triggered when a file is successfully processed. 93 | """ 94 | with environment() as (temp_dir, fake_tpdb, config): 95 | config.webhook_enabled = True 96 | config.webhook_url = 'http://example.com/webhook' 97 | 98 | with patch('namer.namer.send_webhook_notification') as mock_webhook: 99 | targets = [new_ea(temp_dir, use_dir=False)] 100 | 101 | # Process the file 102 | from namer.namer import main 103 | 104 | main(['-f', str(targets[0].file), '-c', str(config.config_file)]) 105 | 106 | # Verify webhook was called 107 | mock_webhook.assert_called() 108 | 109 | 110 | if __name__ == '__main__': 111 | unittest.main() 112 | -------------------------------------------------------------------------------- /test/poster.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePornDatabase/namer/19a75c32a5ecc2418548c304b2da4973490edb88/test/poster.png -------------------------------------------------------------------------------- /test/updatejson.ps1: -------------------------------------------------------------------------------- 1 | $TOKEN = Read-Host "Please enter your token" -AsSecureString 2 | 3 | $headers = @{} 4 | $headers["Authorization"] = "Bearer $TOKEN" 5 | 6 | Invoke-WebRequest -Uri "https://api.theporndb.net/scenes?q=dorcelclub-2021-12-23-peeping-tom" -ContentType "application/json" -Headers $headers | Set-Content ./dc.json 7 | Invoke-WebRequest -Uri "https://api.theporndb.net/scenes?q=evil-angel-2022-01-03-carmela-clutch-fabulous-anal-3-way" -ContentType "application/json" -Headers $headers | Set-Content ./ea.json 8 | Invoke-WebRequest -Uri "https://api.theporndb.net/scenes/1678283" -ContentType "application/json" -Headers $headers | Set-Content ./ea.full.json 9 | Invoke-WebRequest -Uri "https://api.theporndb.net/scenes?q=brazzers-exxtra-suck-suck-blow" -ContentType "application/json" -Headers $headers | Set-Content ./ssb2.json 10 | Invoke-WebRequest -Uri "https://api.theporndb.net/movies?q=petite18.Harper%20Red&limit=25" -ContentType "application/json" -Headers $headers | Set-Content ./p18.json 11 | -------------------------------------------------------------------------------- /test/updatejson.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | TOKEN=${1} 4 | 5 | curl --request GET --get "https://api.metadataapi.net/scenes?q=dorcelclub-2021-12-23-peeping-tom" --header "Authorization: Bearer ${TOKEN}" --header "Content-Type: application/json" --header "Accept: application/json" > ./dc.json 6 | curl --request GET --get "https://api.metadataapi.net/scenes?q=evil-angel-2022-01-03-carmela-clutch-fabulous-anal-3-way" --header "Authorization: Bearer ${TOKEN}" --header "Content-Type: application/json" --header "Accept: application/json" > ./ea.json 7 | curl --request GET --get "https://api.metadataapi.net/scenes/1678283" --header "Authorization: Bearer ${TOKEN}" --header "Content-Type: application/json" --header "Accept: application/json" > ./ea.full.json 8 | curl --request GET --get "https://api.metadataapi.net/scenes?q=brazzers-exxtra-suck-suck-blow" --header "Authorization: Bearer ${TOKEN}" --header "Content-Type: application/json" --header "Accept: application/json" > ./ssb2.json 9 | curl --request GET --get "https://api.metadataapi.net/movies?q=petite18.Harper%20Red&limit=25" --header "Authorization: Bearer ${TOKEN}" --header "Content-Type: application/json" --header "Accept: application/json" > ./p18.json 10 | -------------------------------------------------------------------------------- /test/web/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePornDatabase/namer/19a75c32a5ecc2418548c304b2da4973490edb88/test/web/__init__.py -------------------------------------------------------------------------------- /test/web/fake_tpdb.py: -------------------------------------------------------------------------------- 1 | from test.web.parrot_webserver import ParrotWebServer 2 | 3 | 4 | def make_fake_tpbd() -> ParrotWebServer: 5 | server = ParrotWebServer() 6 | # start setting up the server 7 | # server.set_response( url, bytearray()) 8 | return server 9 | -------------------------------------------------------------------------------- /test/web/namer_web_test.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import os 3 | import unittest 4 | import warnings 5 | from platform import system 6 | 7 | import requests 8 | from loguru import logger 9 | from selenium.webdriver import Chrome, ChromeOptions, Edge, EdgeOptions, Safari 10 | from selenium.webdriver.remote.webdriver import WebDriver 11 | from selenium.webdriver.safari.service import Service as SafariService 12 | 13 | from namer.configuration import NamerConfig 14 | from namer.watchdog import create_watcher 15 | from test import utils 16 | from test.namer_metadataapi_test import environment 17 | from test.namer_watchdog_test import new_ea 18 | from test.utils import is_debugging, sample_config 19 | from test.web.namer_web_pageobjects import FailedPage 20 | from test.web.parrot_webserver import ParrotWebServer 21 | 22 | 23 | def chrome_factory(debug: bool) -> WebDriver: 24 | options = ChromeOptions() 25 | if (system() == 'Linux' and os.environ.get('DISPLAY') is None) or not debug: 26 | options.add_argument('--headless') 27 | if system() != 'Windows' and os.geteuid() == 0: 28 | options.add_argument('--no-sandbox') 29 | 30 | return Chrome(options=options) 31 | 32 | 33 | def edge_factory(debug: bool) -> WebDriver: 34 | options = EdgeOptions() 35 | if (system() == 'Linux' and os.environ.get('DISPLAY') is None) or not debug: 36 | options.add_argument('--headless') 37 | if system() != 'Windows' and os.geteuid() == 0: 38 | options.add_argument('--no-sandbox') 39 | 40 | webdriver = Edge(options=options) 41 | 42 | return webdriver 43 | 44 | 45 | def safari_factory(debug: bool) -> WebDriver: 46 | service = SafariService() 47 | with warnings.catch_warnings(): 48 | warnings.filterwarnings('ignore', category=DeprecationWarning) 49 | 50 | return Safari(service=service) 51 | 52 | 53 | def default_os_browser(debug: bool) -> WebDriver: 54 | name = system() 55 | # ci_str = os.getenv('CI') 56 | # ci = ci_str.lower() == "true" if ci_str else False 57 | if name == 'Windows': # and not ci: 58 | return edge_factory(debug) 59 | # until GitHub actions 60 | # if name in ['Darwin', 'macOS']: 61 | # return safari_factory(debug) 62 | return chrome_factory(debug) 63 | 64 | 65 | @contextlib.contextmanager # type: ignore 66 | def make_test_context(config: NamerConfig): 67 | with environment(config) as (temp_dir, mock_tpdb, config), create_watcher(config) as watcher, default_os_browser(is_debugging()) as browser: 68 | url = f'http://{config.host}:{watcher.get_web_port()}{config.web_root}/failed' 69 | browser.get(url) 70 | yield temp_dir, watcher, browser, mock_tpdb 71 | 72 | 73 | class UnitTestAsTheDefaultExecution(unittest.TestCase): 74 | """ 75 | Always test first. 76 | """ 77 | 78 | def __init__(self, method_name='runTest'): 79 | super().__init__(method_name) 80 | 81 | if not utils.is_debugging(): 82 | logger.remove() 83 | 84 | def test_webdriver_flow(self: unittest.TestCase): 85 | """ 86 | Test we can start the app, install, run and control a browser and shut it all down safely. 87 | """ 88 | config = sample_config() 89 | config.web = True 90 | config.web_root = '/namer' 91 | config.host = '127.0.0.1' 92 | config.port = 0 93 | config.allow_delete_files = True 94 | config.write_nfo = False 95 | config.min_file_size = 0 96 | config.write_namer_failed_log = True 97 | config.del_other_files = True 98 | config.extra_sleep_time = 1 99 | with make_test_context(config) as (temp_dir, watcher, browser, mock_tpdb): 100 | new_ea(config.failed_dir, use_dir=False) 101 | ( 102 | FailedPage(browser) 103 | .refresh_items() 104 | .navigate_to() 105 | .queue_page() 106 | .navigate_to() 107 | .failed_page() 108 | .items()[0] 109 | .file_name() 110 | .is_equal_to('EvilAngel - 2022-01-03 - Carmela Clutch Fabulous Anal 3-Way!') 111 | .on_success() 112 | .file_extension() 113 | .is_equal_to('MP4') 114 | .on_success() 115 | .show_log_modal() 116 | .log_text() 117 | .is_equal_to('No results found') 118 | .on_success() 119 | .close() 120 | .items()[0] 121 | .show_search_modal() 122 | .search() 123 | .results()[0] 124 | .title_text() 125 | .is_equal_to('Carmela Clutch: Fabulous Anal 3-Way!') 126 | .on_success() 127 | .site_text() 128 | .is_equal_to('Evil Angel') 129 | .on_success() 130 | .date_text() 131 | .is_equal_to('2022-01-03') 132 | .on_success() 133 | .performers()[0] 134 | .is_equal_to('Carmela Clutch') 135 | .on_success() 136 | .select() # returns to failed page 137 | .assert_has_no_files() 138 | ) 139 | 140 | def test_parrot(self): 141 | with ParrotWebServer() as parrot: 142 | parrot.set_response('/test?', bytearray('response', 'utf-8')) 143 | url = parrot.get_url() 144 | 145 | headers = { 146 | 'Authorization': 'Bearer token', 147 | 'Content-Type': 'application/json', 148 | 'Accept': 'application/json', 149 | 'User-Agent': 'namer-1', 150 | } 151 | with requests.request('GET', f'{url}test', headers=headers) as response: 152 | response.raise_for_status() 153 | self.assertEqual(response.text, 'response') 154 | -------------------------------------------------------------------------------- /test/web/parrot_webserver.py: -------------------------------------------------------------------------------- 1 | import time 2 | from pathlib import Path 3 | from threading import Thread 4 | from typing import Any, Dict, Optional, Callable 5 | 6 | from flask import Blueprint, make_response, request 7 | from flask.wrappers import Response 8 | 9 | from namer.web.server import GenericWebServer 10 | 11 | 12 | def get_routes(responses: Dict[str, Any]) -> Blueprint: 13 | """ 14 | Builds a blueprint for flask with passed in context, the NamerConfig. 15 | """ 16 | blueprint = Blueprint('/', __name__) 17 | 18 | @blueprint.route('/', defaults={'path': ''}) 19 | @blueprint.route('/') 20 | def get_files(path) -> Response: 21 | # args = request.args 22 | output = responses.get(request.full_path) 23 | value = None 24 | if isinstance(output, Callable): 25 | output = output() 26 | if isinstance(output, bytearray): 27 | value = output 28 | if isinstance(output, Path): 29 | file: Path = output 30 | value = bytearray(file.read_bytes()) 31 | if isinstance(output, str): 32 | value = bytearray(output, 'utf-8') 33 | 34 | if value: 35 | response = make_response(value, 200) 36 | else: 37 | response = make_response('', 404) 38 | # response.mimetype = "text/plain" 39 | return response 40 | 41 | return blueprint 42 | 43 | 44 | class ParrotWebServer(GenericWebServer): 45 | __responses: Dict[str, Any] 46 | __background_thread: Optional[Thread] 47 | 48 | def __init__(self): 49 | self.__responses = {} 50 | super().__init__('127.0.0.1', port=0, webroot='/', blueprints=[get_routes(self.__responses)], static_path=None) 51 | 52 | def __enter__(self): 53 | self.__background_thread = Thread(target=self.start) 54 | self.__background_thread.start() 55 | 56 | tries = 0 57 | while super().get_effective_port() is None and tries < 20: 58 | time.sleep(0.2) 59 | tries += 1 60 | 61 | if super().get_effective_port is None: 62 | raise RuntimeError('application did not get assigned a port within 4 seconds.') 63 | 64 | return self 65 | 66 | def __simple_exit__(self): 67 | self.stop() 68 | if self.__background_thread is not None: 69 | self.__background_thread.join() 70 | del self.__background_thread 71 | 72 | def __exit__(self, exc_type, exc_value, traceback): 73 | self.__simple_exit__() 74 | 75 | def set_response(self, url: str, response): 76 | self.__responses[url] = response 77 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | 5 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 6 | const CssMinimizerPlugin = require('css-minimizer-webpack-plugin') 7 | const TerserPlugin = require('terser-webpack-plugin') 8 | const HtmlMinimizerPlugin = require('html-minimizer-webpack-plugin') 9 | const CopyPlugin = require('copy-webpack-plugin') 10 | 11 | const targetPath = path.resolve(__dirname, 'namer', 'web') 12 | 13 | module.exports = { 14 | entry: [ 15 | './src/js/main.js', 16 | './src/css/main.scss' 17 | ], 18 | mode: 'production', 19 | performance: { 20 | hints: false 21 | }, 22 | output: { 23 | path: path.resolve(targetPath, 'public', 'assets'), 24 | filename: 'bundle.min.js' 25 | }, 26 | plugins: [ 27 | new MiniCssExtractPlugin({ 28 | filename: 'bundle.min.css' 29 | }), 30 | new CopyPlugin({ 31 | patterns: [ 32 | { 33 | context: path.resolve(__dirname, 'src', 'templates'), 34 | from: './**/*.html', 35 | to: path.resolve(targetPath, 'templates') 36 | } 37 | ] 38 | }) 39 | ], 40 | module: { 41 | rules: [ 42 | { 43 | test: /\.s[ac]ss$/i, 44 | use: [ 45 | MiniCssExtractPlugin.loader, 46 | 'css-loader', 47 | { 48 | loader: 'postcss-loader', 49 | options: { 50 | postcssOptions: { 51 | plugins: [ 52 | 'postcss-preset-env' 53 | ] 54 | } 55 | } 56 | }, 57 | 'sass-loader' 58 | ] 59 | }, 60 | { 61 | test: /\.js$/i, 62 | exclude: /node_modules/, 63 | use: { 64 | loader: 'babel-loader', 65 | options: { 66 | presets: [ 67 | ['@babel/preset-env'] 68 | ] 69 | } 70 | } 71 | }, 72 | { 73 | test: /\.woff($|\?)|\.woff2($|\?)|\.ttf($|\?)|\.eot($|\?)|\.svg($|\?)/i, 74 | type: 'asset/resource', 75 | generator: { 76 | filename: 'fonts/[name][ext]' 77 | } 78 | }, 79 | { 80 | test: /\.html$/i, 81 | type: 'asset/resource' 82 | } 83 | ] 84 | }, 85 | optimization: { 86 | minimizer: [ 87 | new CssMinimizerPlugin({ 88 | minimizerOptions: { 89 | preset: [ 90 | 'default', 91 | { 92 | discardComments: { 93 | removeAll: true 94 | } 95 | } 96 | ] 97 | } 98 | }), 99 | new TerserPlugin({ 100 | extractComments: false, 101 | terserOptions: { 102 | format: { 103 | comments: false 104 | } 105 | } 106 | }), 107 | new HtmlMinimizerPlugin({ 108 | test: /\.html/i, 109 | minimizerOptions: { 110 | collapseWhitespace: true, 111 | conservativeCollapse: false, 112 | includeAutoGeneratedTags: false, 113 | ignoreCustomFragments: [ 114 | /{%.*?%}/, 115 | /{{.*?}}/ 116 | ], 117 | minifyJS: true, 118 | removeComments: true, 119 | trimCustomFragments: false 120 | } 121 | }) 122 | ] 123 | } 124 | } 125 | --------------------------------------------------------------------------------