├── .github └── workflows │ └── docker.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── README.md ├── docs ├── Makefile ├── conf.py └── index.rst ├── poetry.lock ├── ptpapi.conf.example ├── pyproject.toml ├── resources └── archiver.sh └── src └── ptpapi ├── __init__.py ├── api.py ├── config.py ├── error.py ├── movie.py ├── scripts ├── __init__.py ├── ptp-hnrs ├── ptp-trump ├── ptp.py ├── ptp_origin.py ├── ptp_reseed.py └── ptp_reseed_machine.py ├── session.py ├── torrent.py ├── user.py └── util.py /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker push 2 | on: 3 | push: 4 | paths-ignore: 5 | - 'docs/**' 6 | - '*.md' 7 | branches: 8 | - 'main' 9 | jobs: 10 | docker: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | - name: Set up Docker Buildx 16 | uses: docker/setup-buildx-action@v2 17 | - name: Login to DockerHub 18 | uses: docker/login-action@v2 19 | with: 20 | username: ${{ secrets.DOCKERHUB_USERNAME }} 21 | password: ${{ secrets.DOCKERHUB_TOKEN }} 22 | - name: Build and push 23 | uses: docker/build-push-action@v3 24 | with: 25 | context: . 26 | push: true 27 | tags: kannibalox/ptpapi:latest 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *~ 3 | creds.ini 4 | test.py 5 | cookies.txt 6 | _build 7 | *.egg-info/ 8 | *.egg 9 | dist/ 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | ## [Unreleased] 3 | 4 | ### Added 5 | - `Type` field for movie objects ([#32](https://github.com/kannibalox/PTPAPI/pull/32), [@yachtrock87](https://github.com/yachtrock87)) 6 | 7 | ### Removed 8 | - The CG/KG submodules have been removed. They have been supplanted by 9 | Prowlarr support in the few places they were used. 10 | 11 | ### Fixed 12 | - Retain group ID when searching with direct URLs ([#32](https://github.com/kannibalox/PTPAPI/pull/34], [@yatchrock87](https://github.com/yachtrock87)) 13 | - Avoid possible errors by pre-filling torrents from HTML ([#32](https://github.com/kannibalox/PTPAPI/pull/34], [@yatchrock87](https://github.com/yachtrock87)) 14 | 15 | ## [0.10.3] - 2024-02-15 16 | 17 | ### Added 18 | - `ptp search`: Allow search users' snatched/leeching/uploaded/seeding 19 | torrents, e.g. `ptp search type=snatched` 20 | 21 | ## [0.10.2] - 2023-10-30 22 | ### Added 23 | - `ptp inbox`: Add `--raw` flag to get HTML for conversations ([#24](https://github.com/kannibalox/PTPAPI/pull/24), [@bonswouar](https://github.com/bonswouar)) 24 | 25 | ## [0.10.1] - 2023-09-28 26 | ### Fixed 27 | - `ptp-origin`: Fix writing output on newer python versions 28 | 29 | ## [0.10.0] - 2023-06-04 30 | ### Added 31 | - UHD/HDR/proprietary codecs filters ([#18](https://github.com/kannibalox/PTPAPI/pull/18), [@einsern](https://github.com/einsern)) 32 | - `ptp-reseed-machine`: 33 | - Support searching for movies without an IMDb 34 | - Allow adding more query types (still defaults to just IMDb and title queries) 35 | - Reduce number of requests when scraping needforseed.php 36 | - Add `--history-file` flag for capturing history and skipping duplicate checks 37 | - New torrent fields: `LastActive`, `LastReseedRequest`, `ReseedWaitingUsers`, `Trumpable` 38 | - `ptp-reseed`: Add client configuration section in ptpapi.conf 39 | - Allow specifying a config file path with the `PTPAPI_CONFIG` 40 | environment variable (set to `/dev/null` to disable the config file 41 | altogether) 42 | ### Fixed 43 | - `ptp-reseed`: Fix rtorrent existence check 44 | - `ptp-reseed`: Allow `--overwrite-incomplete` to work correctly with `--hash-check` 45 | - `ptp`: Parse pages correctly 46 | ### Changed 47 | - `ptp download`: Download up to `-l/--limit` movies, instead of just searching that many movies 48 | 49 | ## [0.9.2] - 2023-03-28 50 | ### Fixed 51 | - `ptp-reseed`: 52 | - Mark torrents saved to file as loaded 53 | - Handle hashing multi-file torrents correctly 54 | 55 | ## [0.9.1] - 2023-03-28 56 | ### Fixed 57 | - `ptp-reseed` now only tries to connect to rtorrent when not using 58 | `--client` 59 | 60 | ## [0.9.0] - 2023-03-28 61 | ### Added 62 | - Utility script for downloading and running archiver.py 63 | - [`tinycron`](https://github.com/bcicen/tinycron) has been added to 64 | docker to allow for simple cron-like containers 65 | - `ptp-reseed` 66 | - simple file downloading: use `file://` with `--client` 67 | - Add flag for pre-hash-checking: `--hash-check` 68 | - Add flag for changing the path of an existing incomplete torrent: `--overwrite-incomplete` (rtorrent only) 69 | 70 | ## [0.8.0] - 2023-03-18 71 | ### Fixed 72 | - Size comparisons in filters were be compared case-sensitively 73 | 74 | ### Changed 75 | - Cleaned up and documented `ptp-reseed-machine` for general usage 76 | 77 | ### Added 78 | - Add `ReseedMachine -> ignoreTitleResults` config setting to allow 79 | filtering out trackers from the title search 80 | - Allow reading config from multiple locations: `~/.ptpapi.conf`, 81 | `~/.config/ptpapi.conf`, `~/.config/ptpapi/ptpapi.conf` 82 | - Config values can now be loaded from environment variables 83 | - Added a changelog 84 | - Created dockerfile 85 | 86 | [Unreleased]: https://github.com/kannibalox/PTPAPI/compare/v0.10.3...HEAD 87 | [0.10.3]: https://github.com/kannibalox/PTPAPI/compare/v0.10.2...v0.10.3 88 | [0.10.2]: https://github.com/kannibalox/PTPAPI/compare/v0.10.1...v0.10.2 89 | [0.10.1]: https://github.com/kannibalox/pyrosimple/compare/v0.10.0...v0.10.1 90 | [0.10.0]: https://github.com/kannibalox/pyrosimple/compare/v0.9.3...v0.10.0 91 | [0.9.3]: https://github.com/kannibalox/pyrosimple/compare/v0.9.2...v0.9.3 92 | [0.9.2]: https://github.com/kannibalox/pyrosimple/compare/v0.9.1...v0.9.2 93 | [0.9.1]: https://github.com/kannibalox/pyrosimple/compare/v0.9.0...v0.9.1 94 | [0.9.0]: https://github.com/kannibalox/pyrosimple/compare/v0.8.0...v0.9.0 95 | [0.8.0]: https://github.com/kannibalox/pyrosimple/compare/v0.7.2...v0.8.0 96 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 as base 2 | 3 | ENV PYTHONFAULTHANDLER=1 \ 4 | PYTHONHASHSEED=random \ 5 | PYTHONUNBUFFERED=1 6 | 7 | # Build virtual env 8 | FROM base as builder 9 | 10 | ENV PIP_NO_CACHE_DIR=off \ 11 | PIP_DISABLE_PIP_VERSION_CHECK=on \ 12 | PIP_DEFAULT_TIMEOUT=100 \ 13 | POETRY_VERSION=1.3.0 14 | 15 | RUN pip install "poetry==$POETRY_VERSION" 16 | RUN python -m venv /venv 17 | ENV VIRTUAL_ENV="/venv" 18 | 19 | WORKDIR /app 20 | 21 | COPY poetry.lock pyproject.toml ./ 22 | 23 | RUN poetry install -n --no-ansi --no-root -E origin 24 | 25 | COPY . ./ 26 | 27 | RUN poetry build && /venv/bin/pip install dist/*.whl 28 | 29 | # Install into final image 30 | FROM base as final 31 | 32 | ENV PATH="/venv/bin:${PATH}" 33 | ENV VIRTUAL_ENV="/venv" 34 | 35 | RUN bash -c 'echo -e "[Main]\nbaseURL=https://passthepopcorn.me/\ndownloadDirectory=/data/" > ~/.ptpapi.conf' 36 | 37 | RUN curl -sSL https://github.com/bcicen/tinycron/releases/download/v0.4/tinycron-0.4-linux-amd64 > /usr/local/bin/tinycron && chmod +x /usr/local/bin/tinycron 38 | COPY ./resources/archiver.sh /usr/local/bin/ 39 | COPY --from=builder /venv /venv 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PTPAPI 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/ptpapi)](https://pypi.org/project/ptpapi/) 4 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/ptpapi) 5 | 6 | A small API for a mildly popular movie site. The goal was to be able 7 | to collect as much information in as few network requests as possible. 8 | 9 | ## Dependencies 10 | 11 | * Python 3.7+ 12 | * pip 13 | 14 | ## Installation 15 | 16 | Use of a 17 | [virtualenv](https://virtualenv.readthedocs.org/en/latest/userguide.html#usage) 18 | or [pipx](https://pypa.github.io/pipx/) is optional, but highly 19 | recommended. 20 | 21 | `pip install ptpapi` 22 | 23 | ## Configuration 24 | 25 | Open the file `~/.ptpapi.conf` for editing, and make sure it looks like the following: 26 | 27 | ```ini 28 | [Main] 29 | 30 | [PTP] 31 | ApiUser= 32 | ApiKey= 33 | ``` 34 | 35 | Both values can be found in the "Security" section of your 36 | profile. This is only the minimum required configuration. See 37 | `ptpapi.conf.example` for a full-featured config file with comments. 38 | 39 | ## Usage 40 | 41 | The three CLI commands are `ptp`, `ptp-reseed`, and `ptp-bookmarks` 42 | 43 | ### `ptp` 44 | 45 | This is a generally utility to do various things inside PTP. As of 46 | right now it can download files, search the site for movies, and list 47 | message in your inbox. 48 | 49 | See `ptp help` for more information. 50 | 51 | #### `ptp inbox` 52 | 53 | A small utility to read messages in your inbox. No reply capability currently. 54 | 55 | #### `ptp download` 56 | 57 | An alias for `ptp-search -d` 58 | 59 | #### `ptp search` 60 | 61 | This subcommand lets you search the site for movies. It can take movie 62 | and permalinks, as well as search by arbitrary parameters, and the 63 | `-d` flag allows for downloading matching torrents. For instance: 64 | - `ptp search year=1980-2000 taglist=sci.fi` 65 | - `ptp search "Star Wars"`. 66 | 67 | It can also accept URLs for torrents and collages: 68 | - `ptp search "https://passthepopcorn.me/torrents.php?id=68148"` 69 | - `ptp search "https://passthepopcorn.me/collages.php?id=2438"` 70 | 71 | and regular search URLs: 72 | - `ptp search "https://passthepopcorn.me/torrents.php?action=advanced&year=1980-2000&taglist=action"`. 73 | 74 | As a general rule of thumb anything supported by the advanced site 75 | search will work with `ptp search`, e.g. searching 76 | `https://passthepopcorn.me/torrents.php?action=advanced&taglist=comedy&format=x264&media=Blu-ray&resolution=1080p&scene=1` 77 | is the same as `ptp search taglist=comedy format=x264 media=Blu-ray 78 | resolution=1080p scene=1`. 79 | 80 | To work with multiple pages of results, use the `--pages ` flag. 81 | 82 | There are a couple aliases to make life easier: 83 | 84 | * `genre`, `genres`, `tags` -> `taglist` 85 | * `name` -> `searchstr` 86 | * `bookmarks` -> Search only your bookmarks 87 | 88 | In addition, [Tempita](http://pythonpaste.org/tempita/) can be used 89 | for custom formatting. For instance, `ptp search --movie-format="" 90 | --torrent-format="{{UploadTime}} - {{ReleaseName}}" year=1980-2000 91 | taglist=sci.fi grouping=no`. 92 | 93 | Using the `-d` flag will download one torrent from each of the matched 94 | torrents (deciding which one to download is done via 95 | [filters](#filters)) to the 96 | [downloadDirectory](ptpapi.conf.example#L9). 97 | 98 | The `-p/--pages [int]` option can be used to scrape multiple pages at 99 | once. N.B.: If any `page` parameter is in the original search query, 100 | paging will start from that page. 101 | 102 | #### `ptp fields` 103 | 104 | Simply lists fields that can be used for the `ptp search` formatting. 105 | 106 | #### `ptp origin ` 107 | 108 | This downloads PTP metadata for any given torrent files in YAML 109 | format. The data includes movie information, covers, torrent 110 | description information, and screenshots (use `--no-images` to skip 111 | downloading any images). By default it will output to a directory 112 | named after the torrent file, or you can change the target with `-d 113 | dir`. It will not overwrite already fetched data, unless the `--force` 114 | flag is passed. 115 | 116 | See `ptp origin --help` for more options. 117 | 118 | ### `ptp-reseed` 119 | 120 | This script automatically matches up files to movies on PTP. It's most 121 | basic usage is `ptp-reseed `. This will search PTP for any 122 | movies matching that filename, and if it finds a match, will 123 | automatically download the torrent and add it to rtorrent. It can do 124 | some basic file manipulation if it finds a close enough match. 125 | 126 | For instance, if you have the file `Movie.2000.mkv`, and the torrent 127 | contains `Movie (2000)/Movie.2000.mkv`, the script will try to 128 | automatically create the folder `Movie (2000)` and hard link the file 129 | inside of it before attempting to seed it. 130 | 131 | See `ptp-reseed -h` and `ptpapi.conf.example` for more information and 132 | configuration options. 133 | 134 | #### guessit 135 | 136 | By default the script looks for exact matches against file names and 137 | sizes. If you'd like the name matching to be less strict, you can 138 | install the guessit library (`pip install 'guessit>=3'`), and if the 139 | filename search fails, the script will attempt to parse the movie name 140 | out of the file with guessit. 141 | 142 | ### `ptp-reseed-machine` 143 | 144 | This tool is meant to complement `ptp-reseed`, by using 145 | [Prowlarr](https://github.com/Prowlarr/Prowlarr) to find and download 146 | potential reseeds from any supported site. 147 | 148 | To get it set up, first [install 149 | Prowlarr](https://wiki.servarr.com/prowlarr/installation). Be sure 150 | your instance (or any of the *arrs) isn't exposed to the internet! 151 | From there, simply use the UI to add any trackers/indexers you'd like to 152 | search, as well as any downloaders. Then, add the following config to 153 | `~/.ptpapi.conf`: 154 | 155 | ```ini 156 | [Prowlarr] 157 | url=http://YOUR_PROWLER_HOSTNAME_OR_IP/ 158 | api_key=YOUR_API_KEY 159 | ``` 160 | 161 | If everything thing is all setup, running `ptp-reseed-machine` will 162 | scrape the first page of needforseed.php and attempt to download any 163 | potential matches. See `--help` for passing additional parameters or 164 | different search targets. 165 | 166 | After a download has been triggered, you can then use `ptp-reseed` 167 | with your download client of choice to automatically reseed the path 168 | into a client. Here's a simple example of a post script for 169 | [sabnzbd](https://sabnzbd.org/): 170 | 171 | ```bash 172 | #!/bin/bash 173 | if [[ "$SAB_PP_STATUS" -eq 0 ]]; then 174 | ls *.iso *.img 2>/dev/null | xargs -r 7z x 175 | ptp-reseed "$SAB_COMPLETE_DIR" 176 | fi 177 | ``` 178 | 179 | or for a `rtorrent.rc`: 180 | ```ini 181 | method.set_key = event.download.finished, ptp_reseed, "execute.nothrow.bg={ptp-reseed,$d.base_path=}" 182 | ``` 183 | 184 | ## Concepts 185 | 186 | ### Filters 187 | 188 | Filters were designed as a way to take a full movie group, and narrow 189 | it down to a single torrent. A filter consists of multiple 190 | sub-filters, where the first sub-filter to match will download the 191 | torrent, and if not, the next sub-filter will be checked. If none of 192 | the sub-filters match, no download will occur. Filters are separate 193 | from the actual search parameters sent to the site 194 | 195 | The full list of possible values for picking encodes is: 196 | * `GP` or `Scene` 197 | * `576p` or `720p` or `1080p` 198 | * `XviD` or `x264` 199 | * `HD` or `SD` 200 | * `remux` or `not-remux` 201 | * `seeded` - the number of seeds is greater than 0 (deprecated, use `seeders>0`) 202 | * `not-trumpable` - ignore any trumpable torrents 203 | * `unseen` - ignores all torrents if you've marked the movie as seen or rated it 204 | * `unsnatched` - ignore all torrents unless you've never snatched one before (note that seeding counts as "snatched", but leeching doesn't) 205 | There are also values that allow for simple comparisons, e.g. `size>1400M`. 206 | * `seeders` 207 | * `size` 208 | 209 | Note that it's possible to have two incompatible values, e.g. `GP` and 210 | `Scene`, but this simply means the sub-filter won't ever match a 211 | torrent, and will always be skipped over. 212 | 213 | The possible values for sorting are: 214 | * `most recent` (the default if none are specified) 215 | * `smallest` 216 | * `most seeders` 217 | * `largest` 218 | 219 | #### Examples 220 | 221 | For instance, the filter `smallest GP,720p scene,largest` would 222 | attempt to download the smallest GP. If there are no GPs, it will try 223 | to find a 720p scene encode. If it can't find either of those, it will 224 | just pick the largest torrent available. 225 | 226 | As another example, if you wanted to filter for encodes that are less 227 | than 200MiB with only one seeder, you could use `seeders=1 size<200M`. 228 | 229 | ## Notes 230 | 231 | I did this mostly for fun and to serve my limited needs, which is why 232 | it's not as polished as it could be, and will probably change 233 | frequently. Pull requests are welcomed. 234 | 235 | ### Deprecated Configuration 236 | 237 | The new ApiUser/ApiKey system is preferred, however if you find bugs 238 | or limitations, the old cookie-based method can be used: 239 | 240 | Open the file `~/.ptpapi.conf` for editing, and make sure it looks 241 | like the following: 242 | 243 | ```ini 244 | [Main] 245 | 246 | [PTP] 247 | username= 248 | password= 249 | passkey= 250 | ``` 251 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = PTPAPI 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # PTPAPI documentation build configuration file, created by 4 | # sphinx-quickstart on Thu Jun 28 18:03:45 2018. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | import os 20 | import sys 21 | sys.path.insert(0, os.path.abspath('../src/ptpapi/')) 22 | 23 | 24 | # -- General configuration ------------------------------------------------ 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | # 28 | # needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = ['sphinx.ext.autodoc', 34 | 'sphinx.ext.doctest', 35 | 'sphinx.ext.intersphinx', 36 | 'sphinx.ext.todo'] 37 | 38 | # Add any paths that contain templates here, relative to this directory. 39 | templates_path = ['_templates'] 40 | 41 | # The suffix(es) of source filenames. 42 | # You can specify multiple suffix as a list of string: 43 | # 44 | # source_suffix = ['.rst', '.md'] 45 | source_suffix = '.rst' 46 | 47 | # The master toctree document. 48 | master_doc = 'index' 49 | 50 | # General information about the project. 51 | project = u'PTPAPI' 52 | copyright = u'2018, kannibalox' 53 | author = u'kannibalox' 54 | 55 | # The version info for the project you're documenting, acts as replacement for 56 | # |version| and |release|, also used in various other places throughout the 57 | # built documents. 58 | # 59 | # The short X.Y version. 60 | version = u'0.4' 61 | # The full version, including alpha/beta/rc tags. 62 | release = u'0.4' 63 | 64 | # The language for content autogenerated by Sphinx. Refer to documentation 65 | # for a list of supported languages. 66 | # 67 | # This is also used if you do content translation via gettext catalogs. 68 | # Usually you set "language" from the command line for these cases. 69 | language = None 70 | 71 | # List of patterns, relative to source directory, that match files and 72 | # directories to ignore when looking for source files. 73 | # This patterns also effect to html_static_path and html_extra_path 74 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 75 | 76 | # The name of the Pygments (syntax highlighting) style to use. 77 | pygments_style = 'sphinx' 78 | 79 | # If true, `todo` and `todoList` produce output, else they produce nothing. 80 | todo_include_todos = True 81 | 82 | 83 | # -- Options for HTML output ---------------------------------------------- 84 | 85 | # The theme to use for HTML and HTML Help pages. See the documentation for 86 | # a list of builtin themes. 87 | # 88 | html_theme = 'alabaster' 89 | 90 | # Theme options are theme-specific and customize the look and feel of a theme 91 | # further. For a list of options available for each theme, see the 92 | # documentation. 93 | # 94 | # html_theme_options = {} 95 | 96 | # Add any paths that contain custom static files (such as style sheets) here, 97 | # relative to this directory. They are copied after the builtin static files, 98 | # so a file named "default.css" will overwrite the builtin "default.css". 99 | html_static_path = ['_static'] 100 | 101 | 102 | # -- Options for HTMLHelp output ------------------------------------------ 103 | 104 | # Output file base name for HTML help builder. 105 | htmlhelp_basename = 'PTPAPIdoc' 106 | 107 | 108 | # -- Options for LaTeX output --------------------------------------------- 109 | 110 | latex_elements = { 111 | # The paper size ('letterpaper' or 'a4paper'). 112 | # 113 | # 'papersize': 'letterpaper', 114 | 115 | # The font size ('10pt', '11pt' or '12pt'). 116 | # 117 | # 'pointsize': '10pt', 118 | 119 | # Additional stuff for the LaTeX preamble. 120 | # 121 | # 'preamble': '', 122 | 123 | # Latex figure (float) alignment 124 | # 125 | # 'figure_align': 'htbp', 126 | } 127 | 128 | # Grouping the document tree into LaTeX files. List of tuples 129 | # (source start file, target name, title, 130 | # author, documentclass [howto, manual, or own class]). 131 | latex_documents = [ 132 | (master_doc, 'PTPAPI.tex', u'PTPAPI Documentation', 133 | u'kannibalox', 'manual'), 134 | ] 135 | 136 | 137 | # -- Options for manual page output --------------------------------------- 138 | 139 | # One entry per manual page. List of tuples 140 | # (source start file, name, description, authors, manual section). 141 | man_pages = [ 142 | (master_doc, 'ptpapi', u'PTPAPI Documentation', 143 | [author], 1) 144 | ] 145 | 146 | 147 | # -- Options for Texinfo output ------------------------------------------- 148 | 149 | # Grouping the document tree into Texinfo files. List of tuples 150 | # (source start file, target name, title, author, 151 | # dir menu entry, description, category) 152 | texinfo_documents = [ 153 | (master_doc, 'PTPAPI', u'PTPAPI Documentation', 154 | author, 'PTPAPI', 'One line description of project.', 155 | 'Miscellaneous'), 156 | ] 157 | 158 | 159 | 160 | 161 | # Example configuration for intersphinx: refer to the Python standard library. 162 | intersphinx_mapping = {'https://docs.python.org/': None} 163 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. PTPAPI documentation master file, created by 2 | sphinx-quickstart on Thu Jun 28 18:03:45 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to PTPAPI's documentation! 7 | ================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | .. automodule:: ptpapi.api 14 | :members: 15 | 16 | .. automodule:: ptpapi.user 17 | :members: 18 | 19 | .. automodule:: ptpapi.movie 20 | :members: 21 | 22 | .. automodule:: ptpapi.torrent 23 | :members: 24 | 25 | .. automodule:: ptpapi.session 26 | :members: 27 | 28 | 29 | Indices and tables 30 | ================== 31 | 32 | * :ref:`genindex` 33 | * :ref:`modindex` 34 | * :ref:`search` 35 | -------------------------------------------------------------------------------- /ptpapi.conf.example: -------------------------------------------------------------------------------- 1 | [Main] 2 | # The base URL to use when making HTTP calls 3 | #baseURL=https://passthepopcorn.me/ 4 | 5 | # Where cookies are stored from the site, to reduce the necessary number of logins 6 | #cookiesFile=~/.ptp.cookies.txt 7 | 8 | # The directory to download any .torrent files to (the . means download to the current directory) 9 | #downloadDirectory=. 10 | 11 | # A filter to select a torrent automatically from a movie 12 | # See the README for more information 13 | #filter= 14 | 15 | [PTP] 16 | # Your ApiUser value 17 | ApiUser= 18 | # Your ApiKey value 19 | ApiKey= 20 | 21 | ## Deprecated 22 | # Your site username 23 | #username= 24 | # Your site password 25 | #password= 26 | # Your passkey (can be found on upload.php, it's that random string of number and letters inside the announce URL) 27 | #passkey= 28 | 29 | [Reseed] 30 | # The action to use when creating new files to seed 31 | # hard = hard links, soft = symlinks 32 | #action=hard 33 | 34 | # Where to create any new files 35 | # Defaults to the same directory as where the reseed match was found 36 | #createInDirectory= 37 | 38 | # The methods to use when searching for a file, in order 39 | # Available methods: 40 | # * filename 41 | # * title 42 | #findBy=filename,title 43 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "PTPAPI" 3 | version = "0.10.3" 4 | description = "A small API for a mildly popular movie site" 5 | authors = ["kannibalox "] 6 | repository = "https://github.com/kannibalox/PTPAPI" 7 | packages = [ 8 | { include = "ptpapi", from = "src" } 9 | ] 10 | classifiers = [ 11 | "Development Status :: 4 - Beta", 12 | "Natural Language :: English", 13 | "Operating System :: POSIX", 14 | "Programming Language :: Python :: 3 :: Only", 15 | "Programming Language :: Python :: 3.7", 16 | "Programming Language :: Python :: 3.8", 17 | "Programming Language :: Python :: 3.9", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "Topic :: Utilities", 21 | ] 22 | readme = "README.md" 23 | 24 | [tool.poetry.dependencies] 25 | python = ">=3.7.2,<4.0" 26 | guessit = "^3.4.3" 27 | pyrosimple = "^2.7.0" 28 | requests = "^2.27.1" 29 | Tempita = "^0.5.2" 30 | beautifulsoup4 = "^4.10.0" 31 | "bencode.py" = "^4.0.0" 32 | humanize = "^4.0.0" 33 | libtc = "^1.3.1" 34 | ruamel-yaml = {version = "^0.17.33", optional = true} 35 | 36 | [tool.poetry.scripts] 37 | ptp = "ptpapi.scripts.ptp:main" 38 | "ptp-reseed" = "ptpapi.scripts.ptp_reseed:main" 39 | "ptp-reseed-machine" = "ptpapi.scripts.ptp_reseed_machine:main" 40 | 41 | [tool.poetry.extras] 42 | origin = ["ruamel-yaml"] 43 | 44 | [tool.poetry.group.dev.dependencies] 45 | mypy = "^1.1.1" 46 | black = "23.3.0" 47 | pylint = "2.17.5" 48 | 49 | [build-system] 50 | requires = ["poetry-core>=1.0.0"] 51 | build-backend = "poetry.core.masonry.api" 52 | 53 | [tool.pylint] 54 | [tool.pylint.'MESSAGES CONTROL'] 55 | disable="locally-disabled, superfluous-parens, no-else-return, too-many-arguments,logging-not-lazy, logging-format-interpolation, too-few-public-methods, protected-access, duplicate-code, consider-using-f-string, fixme, invalid-name, line-too-long, design, missing-function-docstring, missing-class-docstring, missing-module-docstring" 56 | 57 | [tool.isort] 58 | profile = "black" 59 | force_single_line = false 60 | atomic = true 61 | include_trailing_comma = true 62 | lines_after_imports = 2 63 | lines_between_types = 1 64 | use_parentheses = true 65 | 66 | [tool.mypy] 67 | warn_return_any = true 68 | warn_unused_configs = true 69 | ignore_missing_imports = true 70 | -------------------------------------------------------------------------------- /resources/archiver.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -u 3 | archive_script="/data/archiver.py" 4 | if [[ ! -d "/data/" ]]; then 5 | mkdir /data/ 6 | fi 7 | cd /data/ || exit 1 # Exit if /data/ isn't available 8 | if [[ ! -e "$archive_script" ]]; then 9 | echo "archiver.py does not exist, installing" 10 | wget --header "ApiUser: ${PTPAPI_APIUSER}" --header "ApiKey: ${PTPAPI_APIKEY}" \ 11 | "https://passthepopcorn.me/archive.php?action=script" \ 12 | -O "$archive_script" 13 | if [ $? -ne 0 ] ; then 14 | echo "Could not download script" 15 | if [[ -e "$archive_script" ]]; then 16 | cat "$archive_script" 17 | rm -f "$archive_script" 18 | fi 19 | exit 20 | fi 21 | fi 22 | /venv/bin/python "${archive_script}" "$@" 23 | -------------------------------------------------------------------------------- /src/ptpapi/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | """Exists solely to make 'import ptpapi' possible""" 3 | from ptpapi.api import API 4 | from ptpapi.movie import Movie 5 | from ptpapi.torrent import Torrent 6 | from ptpapi.user import User 7 | 8 | 9 | def login(**kwargs): 10 | """A helper function to make it easy to log in""" 11 | return API(**kwargs) 12 | -------------------------------------------------------------------------------- /src/ptpapi/api.py: -------------------------------------------------------------------------------- 1 | #!/bin/env python 2 | """The entrypoint module for access the API""" 3 | import html 4 | import logging 5 | import os 6 | import pickle 7 | import re 8 | 9 | from pathlib import Path 10 | 11 | import requests 12 | 13 | from bs4 import BeautifulSoup as bs4 14 | 15 | from ptpapi import util 16 | from ptpapi.config import config 17 | from ptpapi.error import PTPAPIException 18 | from ptpapi.movie import Movie 19 | from ptpapi.session import session 20 | from ptpapi.torrent import Torrent 21 | from ptpapi.user import CurrentUser 22 | 23 | 24 | LOGGER = logging.getLogger(__name__) 25 | 26 | 27 | def login(kwargs): 28 | """Simple helper function""" 29 | return API(**kwargs) 30 | 31 | 32 | class API: 33 | """Used for instantiating an object that can access the API""" 34 | 35 | def __init__( 36 | self, username=None, password=None, passkey=None, api_user=None, api_key=None 37 | ): 38 | self.current_user_id = None 39 | j = None 40 | self.cookies_file = Path(config.get("Main", "cookiesFile")) 41 | logger = logging.getLogger(__name__) 42 | 43 | if config.has_option("PTP", "ApiUser") and config.has_option("PTP", "ApiKey"): 44 | pass 45 | elif ( 46 | config.has_option("PTP", "password") 47 | and config.has_option("PTP", "username") 48 | and config.has_option("PTP", "passkey") 49 | ): 50 | logger.warning( 51 | "Using your password/passkey to access the site is deprecated, " 52 | + "see README.md for instructions on using the new ApiUser/ApiKey." 53 | ) 54 | else: 55 | raise PTPAPIException("No credentials found!") 56 | 57 | req = None 58 | 59 | if config.has_option("PTP", "ApiUser") and api_user is None and api_key is None: 60 | session.headers.update( 61 | { 62 | "ApiUser": config.get("PTP", "ApiUser"), 63 | "ApiKey": config.get("PTP", "ApiKey"), 64 | } 65 | ) 66 | elif api_user is not None and api_key is not None: 67 | session.headers.update( 68 | { 69 | "ApiUser": api_user, 70 | "ApiKey": api_key, 71 | } 72 | ) 73 | elif self.cookies_file.is_file(): 74 | LOGGER.debug("Initiating login sequence.") 75 | self.__load_cookies() 76 | # A really crude test to see if we're logged in 77 | session.max_redirects = 1 78 | try: 79 | req = session.base_get("torrents.php") 80 | util.raise_for_cloudflare(req.text) 81 | except requests.exceptions.TooManyRedirects: 82 | if self.cookies_file.is_file(): 83 | self.cookies_file.unlink() 84 | session.cookies = requests.cookies.RequestsCookieJar() 85 | session.max_redirects = 3 86 | # If we're not using the new method and we don't have a cookie, get one 87 | if not config.has_option("PTP", "ApiUser") and not self.cookies_file.is_file(): 88 | password = password or config.get("PTP", "password") 89 | username = username or config.get("PTP", "username") 90 | passkey = passkey or config.get("PTP", "passkey") 91 | if not password or not passkey or not username: 92 | raise PTPAPIException("Not enough info provided to log in.") 93 | try: 94 | req = session.base_post( 95 | "ajax.php?action=login", 96 | data={ 97 | "username": username, 98 | "password": password, 99 | "passkey": passkey, 100 | }, 101 | ) 102 | j = req.json() 103 | except ValueError: 104 | if req.status_code == 200: 105 | raise PTPAPIException( 106 | "Could not parse returned json data." 107 | ) from ValueError 108 | if req.status_code == 429: 109 | LOGGER.critical(req.text.strip()) 110 | req.raise_for_status() 111 | if j["Result"] == "TfaRequired": 112 | req = session.base_post( 113 | "ajax.php?action=login", 114 | data={ 115 | "username": username, 116 | "password": password, 117 | "passkey": passkey, 118 | "TfaType": "normal", 119 | "TfaCode": input("Enter 2FA auth code:"), 120 | }, 121 | ) 122 | j = req.json() 123 | if j["Result"] != "Ok": 124 | raise PTPAPIException( 125 | "Failed to log in. Please check the username, password and passkey. Response: %s" 126 | % j 127 | ) 128 | self.__save_cookie() 129 | # Get some information that will be useful for later 130 | req = session.base_get("index.php") 131 | util.raise_for_cloudflare(req.text) 132 | LOGGER.info("Login successful.") 133 | 134 | def is_api(self): 135 | """Helper function to check for the use of ApiUser""" 136 | return config.has_option("PTP", "ApiKey") 137 | 138 | def logout(self): 139 | """Forces a logout. In ApiUser mode, essentially a waste of two request tokens.""" 140 | req = session.base_get("index.php") 141 | auth_key = re.search(r"auth=([0-9a-f]{32})", req.text).group(1) 142 | self.cookies_file.unlink() 143 | return session.base_get("logout.php", params={"auth": auth_key}) 144 | 145 | def __save_cookie(self): 146 | """Save requests' cookies to a file""" 147 | with self.cookies_file.open("wb") as fileh: 148 | LOGGER.debug("Pickling HTTP cookies to %s", self.cookies_file) 149 | pickle.dump(requests.utils.dict_from_cookiejar(session.cookies), fileh) 150 | 151 | def __load_cookies(self): 152 | """Reload requests' cookies""" 153 | with self.cookies_file.open("rb") as fileh: 154 | LOGGER.debug("Unpickling HTTP cookies from file %s", self.cookies_file) 155 | session.cookies = requests.utils.cookiejar_from_dict(pickle.load(fileh)) 156 | 157 | def current_user(self): 158 | """Function to get the current user""" 159 | # TODO: See if it can be scraped earlier without an extra request 160 | if self.current_user_id is None: 161 | req = session.base_get("index.php") 162 | self.current_user_id = re.search(r"user.php\?id=(\d+)", req.text).group(1) 163 | return CurrentUser(self.current_user_id) 164 | 165 | def search(self, filters): 166 | """Perform a movie search""" 167 | if "name" in filters: 168 | filters["searchstr"] = filters["name"] 169 | filters["json"] = "noredirect" 170 | ret_array = [] 171 | for movie in session.base_get("torrents.php", params=filters).json()["Movies"]: 172 | if "Directors" not in movie: 173 | movie["Directors"] = [] 174 | if "ImdbId" not in movie: 175 | movie["ImdbId"] = "0" 176 | movie["Title"] = html.unescape(movie["Title"]) 177 | ret_array.append(Movie(data=movie)) 178 | return ret_array 179 | 180 | # There's probably a better place to put this, but it's not really useful inside the Movie class 181 | search_coverview_fields = [ 182 | "RtRating", 183 | "RtUrl", 184 | "UserRating", 185 | "TotalSeeders", 186 | "TotalSnatched", 187 | "TotalLeechers", 188 | ] 189 | 190 | def search_coverview(self, filters): 191 | filters["json"] = 0 192 | ret_array = [] 193 | if "name" in filters: 194 | filters["searchstr"] = filters["name"] 195 | for movie in util.snarf_cover_view_data( 196 | session.base_get("torrents.php", params=filters).content, key=b"PageData" 197 | ): 198 | if "UserRating" not in movie: 199 | movie["UserRating"] = None 200 | ret_array.append(Movie(data=movie)) 201 | return ret_array 202 | 203 | def search_single(self, filters): 204 | """If you know ahead of time that a filter will redirect to a single movie, 205 | you can use this method to avoid an exception until that behavior is 206 | fixed upstream.""" 207 | if "name" in filters: 208 | filters["searchstr"] = filters["name"] 209 | filters["json"] = "noredirect" 210 | resp = session.base_get("torrents.php", params=filters) 211 | movie_id = re.search(r"id=([0-9]+)", resp.url) 212 | if movie_id is not None: 213 | return Movie(ID=movie_id.group(1)) 214 | else: 215 | return None 216 | 217 | def upload_info(self): 218 | """Scrape as much info as possible from upload.php""" 219 | data = {} 220 | soup = bs4(session.base_get("upload.php").content, "html.parser") 221 | data["announce"] = soup.find_all( 222 | "input", 223 | type="text", 224 | value=re.compile("http://please.passthepopcorn.me:2710/.*/announce"), 225 | )[0]["value"] 226 | data["subtitles"] = {} 227 | label_list = [ 228 | u.find_all("label") for u in soup.find_all(class_="languageselector") 229 | ] 230 | labels = [item for sublist in label_list for item in sublist] 231 | for l in labels: 232 | data["subtitles"][l["for"].lstrip("subtitle_")] = l.get_text().strip() 233 | data["remaster_title"] = [ 234 | a.get_text() for a in soup.find(id="remaster_tags").find_all("a") 235 | ] 236 | data["resolutions"] = [ 237 | o.get_text() for o in soup.find(id="resolution").find_all("option") 238 | ] 239 | data["containers"] = [ 240 | o.get_text() for o in soup.find(id="container").find_all("option") 241 | ] 242 | data["sources"] = [ 243 | o.get_text() for o in soup.find(id="source").find_all("option") 244 | ] 245 | data["codecs"] = [ 246 | o.get_text() for o in soup.find(id="codec").find_all("option") 247 | ] 248 | data["tags"] = [ 249 | o.get_text() for o in soup.find(id="genre_tags").find_all("option") 250 | ] 251 | data["categories"] = [ 252 | o.get_text() for o in soup.find(id="categories").find_all("option") 253 | ] 254 | data["AntiCsrfToken"] = soup.find("body")["data-anticsrftoken"] 255 | return data 256 | 257 | def requests(self, filters=None): 258 | if filters is None: 259 | filters = {} 260 | request_list = [] 261 | soup = bs4( 262 | session.base_get("requests.php", params=filters).content, 263 | features="html.parser", 264 | ) 265 | for row in soup.find(id="request_table").find_all("tr"): 266 | if row.td is None: 267 | continue 268 | if len(row.find_all("td")[0].find_all("a")) < 3: 269 | imdb_link = "https://imdb.com/title/tt0" 270 | else: 271 | imdb_link = ( 272 | row.find_all("td")[0] 273 | .find_all("a")[2]["href"] 274 | .replace("http://", "https://") 275 | ) 276 | data = { 277 | "Title": row.td.a.text, 278 | "RequestLink": os.path.join( 279 | config.get("Main", "baseURL"), row.td.a["href"] 280 | ), 281 | "RequestBounty": int(row.find_all("td")[1].span.text), 282 | "RequestCriteria": row.find_all("td")[0].div.text, 283 | "RequestBountyHuman": row.find_all("td")[2].text, 284 | "Year": re.search(r"\[(\d{4})\]", row.td.text.strip()).group(1), 285 | "ImdbLink": imdb_link, 286 | } 287 | request_list.append(data) 288 | return request_list 289 | 290 | def need_for_seed(self, filters=None): 291 | """List torrents that need seeding""" 292 | if filters is None: 293 | filters = {} 294 | data = util.snarf_cover_view_data( 295 | session.base_get("needforseed.php", params=filters).content 296 | ) 297 | torrents = [] 298 | for m in data: 299 | torrent = m["GroupingQualities"][0]["Torrents"][0] 300 | movie = Movie(data=m) 301 | torrent["Link"] = ( 302 | config.get("Main", "baseURL") 303 | + bs4(torrent["Title"], "html.parser").find("a")["href"] 304 | ) 305 | torrent["Movie"] = movie 306 | # The size provided here isn't exact, it's better to load it if needed 307 | del torrent["Size"] 308 | torrents.append(Torrent(data=torrent)) 309 | return torrents 310 | 311 | def contest_leaders(self): 312 | """Get data on who's winning""" 313 | LOGGER.debug("Fetching contest leaderboard") 314 | soup = bs4(session.base_get("contestleaders.php").content, "html.parser") 315 | ret_array = [] 316 | for cell in ( 317 | soup.find("table", class_="table--panel-like").find("tbody").find_all("tr") 318 | ): 319 | ret_array.append( 320 | (cell.find_all("td")[1].get_text(), cell.find_all("td")[2].get_text()) 321 | ) 322 | return ret_array 323 | 324 | def collage_all(self, coll_id, search_terms=None): 325 | """Gets all the movies in a collage in one request, only fills torrentid""" 326 | if search_terms is None: 327 | search_terms = {} 328 | search_terms["id"] = coll_id 329 | req = session.base_get("collages.php", params=search_terms) 330 | soup = bs4(req.content, "html.parser") 331 | 332 | def not_missing_li(tag): 333 | return tag.has_attr("name") and (tag.name == "li") 334 | 335 | movielist = soup.find(id="collection_movielist").find_all(not_missing_li) 336 | movies = [] 337 | for page_movie in movielist: 338 | movieid = page_movie.a["href"].split("id=")[1] 339 | movies.append(Movie(ID=movieid)) 340 | return movies 341 | 342 | def collage_add(self, coll_id, movieobj): 343 | """Adds a given movie to a collage, requires password login.""" 344 | search_terms = {"id": coll_id} 345 | req = session.base_get("collages.php", params=search_terms) 346 | soup = bs4(req.content, "html.parser") 347 | csrf_token = soup.find(id="add_film").find("input")["value"] 348 | movieobj.load_inferred_data() 349 | resp = session.base_post( 350 | "collages.php", 351 | params={"action": "add_torrent"}, 352 | data={ 353 | "AntiCsrfToken": csrf_token, 354 | "action": "add_torrent", 355 | "collageid": coll_id, 356 | "url": movieobj.data["Link"], 357 | }, 358 | ) 359 | return resp 360 | 361 | def collage(self, coll_id, search_terms=None): 362 | """Simplistic representation of a collage, might be split out later""" 363 | if search_terms is None: 364 | search_terms = {} 365 | search_terms["id"] = coll_id 366 | req = session.base_get("collages.php", params=search_terms) 367 | movies = [] 368 | for movie in util.snarf_cover_view_data(req.content): 369 | movie["Torrents"] = [] 370 | for group in movie["GroupingQualities"]: 371 | movie["Torrents"].extend(group["Torrents"]) 372 | movies.append(Movie(data=movie)) 373 | return movies 374 | 375 | def subscriptions(self): 376 | data = {"forum subscriptions": []} 377 | req = session.base_get("userhistory.php", params={"action": "subscriptions"}) 378 | soup = bs4(req.content, "html.parser") 379 | tabs = soup.find("div", class_="tabs__panels") 380 | for sub in tabs.find(id="forum-subscriptions").find_all( 381 | "div", class_="forum-post" 382 | ): 383 | title = sub.find_all("a")[0].text + " > " + sub.find_all("a")[1].text 384 | data["forum subscriptions"].append(title) 385 | return data 386 | 387 | def artist(self, art_id, search_terms=None): 388 | """Simplistic representation of an artist page, might be split out later""" 389 | if search_terms is None: 390 | search_terms = {} 391 | search_terms["id"] = art_id 392 | req = session.base_get("artist.php", params=search_terms) 393 | movies = [] 394 | for movie in util.snarf_cover_view_data( 395 | req.content, key=b"ungroupedCoverViewJsonData" 396 | ): 397 | movie["Torrents"] = [] 398 | for group in movie["GroupingQualities"]: 399 | movie["Torrents"].extend(group["Torrents"]) 400 | movies.append(Movie(data=movie)) 401 | return movies 402 | 403 | def log(self): 404 | """Gets the PTP log""" 405 | soup = bs4(session.base_get("log.php").content, "html.parser") 406 | ret_array = [] 407 | for message in soup.find("table").find("tbody").find_all("tr"): 408 | ret_array.append( 409 | ( 410 | message.find("span", class_="time")["title"], 411 | message.find("span", class_="log__message") 412 | .get_text() 413 | .lstrip() 414 | .encode("UTF-8"), 415 | ) 416 | ) 417 | return ret_array 418 | -------------------------------------------------------------------------------- /src/ptpapi/config.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=invalid-name 2 | """Hold config values""" 3 | import configparser 4 | import os 5 | 6 | from io import StringIO 7 | from pathlib import Path 8 | 9 | 10 | conf_files = [ 11 | Path(p) 12 | for p in ["~/.ptpapi.conf", "~/.config/ptpapi.conf", "~/.config/ptpapi/ptpapi.conf"] 13 | ] 14 | 15 | default = """ 16 | [Main] 17 | baseURL=https://passthepopcorn.me/ 18 | cookiesFile=~/.ptp.cookies.txt 19 | downloadDirectory=. 20 | filter= 21 | retry=False 22 | 23 | [Reseed] 24 | action=hard 25 | findBy=filename,title 26 | """ 27 | 28 | env_prefix = "PTPAPI_" 29 | env_keys = { 30 | "BASEURL": ("Main", "baseURL"), 31 | "COOKIESFILE": ("Main", "cookiesFile"), 32 | "DOWNLOADDIRECTORY": ("Main", "downloadDirectory"), 33 | "FILTER": ("Main", "filter"), 34 | "RETRY": ("Main", "retry"), 35 | "APIKEY": ("PTP", "ApiKey"), 36 | "APIUSER": ("PTP", "ApiUser"), 37 | "ARCHIVE_CONTAINER_NAME": ("PTP", "archiveContainerName"), 38 | "ARCHIVE_CONTAINER_SIZE": ("PTP", "archiveContainerSize"), 39 | "ARCHIVE_MAX_STALLED": ("PTP", "archiveContainerMaxStalled"), 40 | "RESEED_ACTION": ("Reseed", "action"), 41 | "RESEED_FINDBY": ("Reseed", "findBy"), 42 | "RESEED_CLIENT": ("Reseed", "client"), 43 | "PROWLARR_API_KEY": ("Prowlarr", "api_key"), 44 | "PROWLARR_URL": ("Prowlarr", "url"), 45 | } 46 | 47 | 48 | config = configparser.ConfigParser() 49 | config.read_file(StringIO(default)) 50 | 51 | if os.getenv("PTPAPI_CONFIG"): 52 | config.read(Path(os.getenv("PTPAPI_CONFIG")).expanduser()) 53 | else: 54 | for c in conf_files: 55 | if c.expanduser().exists(): 56 | config.read(c.expanduser()) 57 | break 58 | else: 59 | raise ValueError( 60 | f"Config file not found in any of the following paths: {conf_files!r}" 61 | ) 62 | 63 | for key, section in env_keys.items(): 64 | if os.getenv(env_prefix + key) is not None: 65 | if section[0] not in config.sections(): 66 | config.add_section(section[0]) 67 | config.set(section[0], section[1], os.getenv(env_prefix + key)) 68 | -------------------------------------------------------------------------------- /src/ptpapi/error.py: -------------------------------------------------------------------------------- 1 | """PTPAPI-specfic errors""" 2 | 3 | 4 | class PTPAPIException(Exception): 5 | """A generic exception to designate module-specific errors""" 6 | -------------------------------------------------------------------------------- /src/ptpapi/movie.py: -------------------------------------------------------------------------------- 1 | """Represents a movie""" 2 | import logging 3 | import operator 4 | import os.path 5 | import re 6 | 7 | from datetime import datetime 8 | 9 | from bs4 import BeautifulSoup as bs4 # pylint: disable=import-error 10 | 11 | from ptpapi import torrent 12 | from ptpapi.error import PTPAPIException 13 | from ptpapi.session import session 14 | from ptpapi.util import human_to_bytes 15 | 16 | 17 | LOGGER = logging.getLogger(__name__) 18 | 19 | 20 | class Movie: 21 | """A class representing a movie""" 22 | 23 | def __init__(self, ID=None, data=None): 24 | self.torrents = [] 25 | self.key_finder = { 26 | "json": [ 27 | "ImdbId", 28 | "ImdbRating", 29 | "ImdbVoteCount", 30 | "Torrents", 31 | "CoverImage", 32 | "Name", 33 | "Year", 34 | ], 35 | "html": [ 36 | "Title", 37 | "Cover", 38 | "Type", 39 | "Tags", 40 | "Directors", 41 | "PtpRating", 42 | "PtpVoteCount", 43 | "UserRating", 44 | "Seen", 45 | "Snatched", 46 | ], 47 | "inferred": ["Link", "Id", "GroupId"], 48 | } 49 | 50 | if data: 51 | self.data = data 52 | self.conv_json_torrents() 53 | self.ID = data["GroupId"] # pylint: disable=invalid-name 54 | elif ID: 55 | self.ID = ID 56 | self.data = {} 57 | else: 58 | raise PTPAPIException("Could not load necessary data for Movie class") 59 | 60 | def __repr__(self): 61 | return "" % self.ID 62 | 63 | def __str__(self): 64 | return "" % self.ID 65 | 66 | def __getitem__(self, name): 67 | if name not in self.data or self.data[name] is None: 68 | for key, val in self.key_finder.items(): 69 | if name in val: 70 | getattr(self, "load_%s_data" % key)() 71 | return self.data[name] 72 | 73 | def items(self): 74 | """Passthru function for underlying dict""" 75 | return self.data.items() 76 | 77 | def update(self, obj): 78 | for k, v in obj.items(): 79 | self.data[k] = v 80 | 81 | def __setitem__(self, key, value): 82 | self.data[key] = value 83 | 84 | def load_inferred_data(self): 85 | self.data["Id"] = self.ID 86 | self.data["GroupId"] = self.ID 87 | self.data["Link"] = "https://passthepopcorn.me/torrents.php?id=" + self.ID 88 | 89 | def load_json_data(self): 90 | """Load movie JSON data""" 91 | self.data.update( 92 | session.base_get("torrents.php", params={"id": self.ID, "json": "1"}).json() 93 | ) 94 | if "ImdbId" not in self.data: 95 | self.data["ImdbId"] = "" 96 | self.conv_json_torrents() 97 | 98 | def conv_json_torrents(self): 99 | """Util function to normalize data""" 100 | if self.data["Torrents"]: 101 | torrents = self.data["Torrents"] 102 | for t in torrents: 103 | if "RemasterTitle" not in t: 104 | t["RemasterTitle"] = "" 105 | self.data["Torrents"] = [torrent.Torrent(data=t) for t in torrents] 106 | 107 | def load_html_data(self): 108 | """Scrape all data from a movie's HTML page""" 109 | soup = bs4( 110 | session.base_get("torrents.php", params={"id": self.ID, "json": 0}).text, 111 | "html.parser", 112 | ) 113 | self.data["Cover"] = soup.find("img", class_="sidebar-cover-image")["src"] 114 | # Title 115 | match = re.match( 116 | r"(.*:?) \[(\d{4})\]", 117 | soup.find("h2", class_="page__title").get_text(), 118 | ) 119 | self.data["Title"] = match.group(1) 120 | # Type 121 | self.data["Type"] = str(soup.find("span", class_="basic-movie-list__torrent-edition__main").string) 122 | # Genre tags 123 | self.data["Tags"] = [] 124 | for tagbox in soup.find_all("div", class_="box_tags"): 125 | for tag in tagbox.find_all("li"): 126 | self.data["Tags"].append(str(tag.find("a").string)) 127 | # Directors 128 | self.data["Directors"] = [] 129 | for director in soup.find("h2", class_="page__title").find_all( 130 | "a", class_="artist-info-link" 131 | ): 132 | self.data["Directors"].append({"Name": director.string.strip()}) 133 | # Ratings 134 | rating = soup.find(id="ptp_rating_td") 135 | self.data["PtpRating"] = rating.find(id="user_rating").text.strip("%") 136 | self.data["PtpRatingCount"] = re.sub( 137 | r"\D", "", rating.find(id="user_total").text 138 | ) 139 | your_rating = rating.find(id="ptp_your_rating").text 140 | if "?" in your_rating: 141 | self.data["UserRating"] = None 142 | self.data["Seen"] = False 143 | elif re.sub(r"\D", "", your_rating) == "": 144 | self.data["UserRating"] = None 145 | self.data["Seen"] = True 146 | else: 147 | self.data["UserRating"] = re.sub(r"\D", "", your_rating) 148 | self.data["Seen"] = True 149 | # Have we snatched this 150 | self.data["Snatched"] = False 151 | if soup.find(class_="torrent-info-link--user-snatched") or soup.find( 152 | class_="torrent-info-link--user-seeding" 153 | ): 154 | self.data["Snatched"] = True 155 | 156 | # File list & trumpability for torrents 157 | # Populate torrent IDs if not already populated 158 | if "Torrents" not in self.data.keys(): 159 | self.data["Torrents"] = [torrent.Torrent( 160 | re.match(r"torrent_(\d*)", torr['id']).group(1)) for torr in soup.find_all("tr", class_="torrent_info_row")] 161 | for tor in self.data["Torrents"]: 162 | # Get file list 163 | filediv = soup.find("div", id="files_%s" % tor.ID) 164 | tor.data["Filelist"] = {} 165 | basepath = re.match( 166 | r"\/(.*)\/", filediv.find("thead").find_all("div")[1].get_text() 167 | ).group(1) 168 | for elem in filediv.find("tbody").find_all("tr"): 169 | try: 170 | bytesize = ( 171 | elem("td")[1]("span")[0]["title"] 172 | .replace(",", "") 173 | .replace(" bytes", "") 174 | ) 175 | except IndexError: 176 | LOGGER.error( 177 | "Could not parse site for filesize, possibly check for bad filenames: https://passthepopcorn.me/torrents.php?torrentid=%s", 178 | tor.ID, 179 | ) 180 | continue 181 | filepath = os.path.join(basepath, elem("td")[0].string) 182 | tor.data["Filelist"][filepath] = bytesize 183 | # Check if trumpable 184 | if soup.find(id="trumpable_%s" % tor.ID): 185 | tor.data["Trumpable"] = [ 186 | s.get_text() 187 | for s in soup.find(id="trumpable_%s" % tor.ID).find_all("span") 188 | ] 189 | else: 190 | tor.data["Trumpable"] = [] 191 | 192 | def best_match(self, profile): 193 | """A function to pull the best match of a movie, based on a human-readable filter 194 | 195 | :param profile: a filter string 196 | :rtype: The best matching movie, or None""" 197 | # We're going to emulate what.cd's collector option 198 | profiles = profile.lower().split(",") 199 | current_sort = None 200 | if "Torrents" not in self.data: 201 | self.load_json_data() 202 | for subprofile in profiles: 203 | LOGGER.debug("Attempting to match movie to profile '%s'", subprofile) 204 | matches = self.data["Torrents"] 205 | simple_filter_dict = { 206 | "gp": (lambda t, _: t["GoldenPopcorn"]), 207 | "scene": (lambda t, _: t["Scene"]), 208 | "576p": (lambda t, _: t["Resolution"] == "576p"), 209 | "480p": (lambda t, _: t["Resolution"] == "480p"), 210 | "720p": (lambda t, _: t["Resolution"] == "720p"), 211 | "1080p": (lambda t, _: t["Resolution"] == "1080p"), 212 | "2160p": (lambda t, _: t["Resolution"] == "2160p"), 213 | "HD": (lambda t, _: t["Quality"] == "High Definition"), 214 | "SD": (lambda t, _: t["Quality"] == "Standard Definition"), 215 | "UHD": (lambda t, _: t["Quality"] == "Ultra High Definition"), 216 | "not-remux": (lambda t, _: "remux" not in t["RemasterTitle"].lower()), 217 | "remux": (lambda t, _: "remux" in t["RemasterTitle"].lower()), 218 | "DV": (lambda t, _: "dolby vision" in t["RemasterTitle"].lower()), 219 | "HDR10": (lambda t, _: "hdr10" in t["RemasterTitle"].lower()), 220 | "HDR10+": (lambda t, _: "hdr10+" in t["RemasterTitle"].lower()), 221 | "x264": (lambda t, _: t["Codec"] == "x264"), 222 | "H264": (lambda t, _: t["Codec"] == "H.264"), 223 | "x265": (lambda t, _: t["Codec"] == "x265"), 224 | "H265": (lambda t, _: t["Codec"] == "H.265"), 225 | "xvid": (lambda t, _: t["Codec"] == "XviD"), 226 | "seeded": (lambda t, _: int(t["Seeders"]) > 0), 227 | "not-trumpable": (lambda t, _: not t["Trumpable"]), 228 | "unseen": (lambda t, m: not m["Seen"]), 229 | "unsnatched": (lambda t, m: not m["Snatched"]), 230 | } 231 | for name, func in simple_filter_dict.items(): 232 | if name.lower() in subprofile.split(" "): 233 | matches = [t for t in matches if func(t, self)] 234 | LOGGER.debug( 235 | "%i matches after filtering by parameter '%s'", 236 | len(matches), 237 | name, 238 | ) 239 | # lambdas that take a torrent, a function for comparison, and a value-as-a-string 240 | comparative_filter_dict = { 241 | "seeders": (lambda t, f, v: f(int(t["Seeders"]), int(v))), 242 | "size": ( 243 | lambda t, f, v: f( 244 | int(t["Size"]), human_to_bytes(v, case_sensitive=False) 245 | ) 246 | ), 247 | } 248 | comparisons = { 249 | ">": operator.gt, 250 | ">=": operator.ge, 251 | "=": operator.eq, 252 | "==": operator.eq, 253 | "!=": operator.ne, 254 | "<>": operator.ne, 255 | "<": operator.lt, 256 | "<=": operator.le, 257 | } 258 | for name, func in comparative_filter_dict.items(): 259 | match = re.search(r"\b%s([<>=!]+)(.+?)\b" % name, subprofile) 260 | if match is not None: 261 | comp_func = comparisons[match.group(1)] 262 | value = match.group(2) 263 | matches = [t for t in matches if func(t, comp_func, value)] 264 | LOGGER.debug( 265 | "%i matches after filtering by parameter '%s'", 266 | len(matches), 267 | name, 268 | ) 269 | sort_dict = { 270 | "most recent": ( 271 | True, 272 | (lambda t: datetime.strptime(t["UploadTime"], "%Y-%m-%d %H:%M:%S")), 273 | ), 274 | "smallest": ( 275 | False, 276 | (lambda t: human_to_bytes(t["Size"], case_sensitive=False)), 277 | ), 278 | "most seeders": (True, (lambda t: int(t["Seeders"]))), 279 | "largest": ( 280 | True, 281 | (lambda t: human_to_bytes(t["Size"], case_sensitive=False)), 282 | ), 283 | } 284 | if len(matches) == 1: 285 | return matches[0] 286 | elif len(matches) > 1: 287 | for name, (rev, sort) in sort_dict.items(): 288 | if name in subprofile: 289 | current_sort = name 290 | if current_sort is None: 291 | current_sort = "most recent" 292 | LOGGER.debug("Sorting by parameter %s", current_sort) 293 | (rev, sort) = sort_dict[current_sort] 294 | return sorted(matches, key=sort, reverse=rev)[0] 295 | LOGGER.info("Could not find best match for movie %s", self.ID) 296 | return None 297 | -------------------------------------------------------------------------------- /src/ptpapi/scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PTPAPI/a47a4319c61bb6645e9fe35c91309038080f4921/src/ptpapi/scripts/__init__.py -------------------------------------------------------------------------------- /src/ptpapi/scripts/ptp-hnrs: -------------------------------------------------------------------------------- 1 | #!/bin/env python 2 | # Download hnr files to specified directory 3 | import argparse 4 | import logging 5 | import os 6 | import os.path 7 | import subprocess 8 | 9 | import ptpapi 10 | 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def main(): 16 | parser = argparse.ArgumentParser(description="Download HnR torrent files") 17 | parser.add_argument('-d', '--destination', help="The destination folder", default=os.getcwd()) 18 | parser.add_argument('-n', '--no-unzip', help="Do not try to automatically unzip file", action="store_true") 19 | parser.add_argument('-q', '--quiet', help="Suppress outpuit", action="store_const", dest="loglevel", const=logging.CRITICAL) 20 | parser.add_argument('-v', '--verbose', help="Print extra information", action="store_const", dest="loglevel", const=logging.INFO) 21 | parser.add_argument('--debug', help='Print lots of debugging statements', action="store_const", dest="loglevel", 22 | const=logging.DEBUG, default=logging.WARNING) 23 | 24 | args = parser.parse_args() 25 | 26 | api = ptpapi.login() 27 | logging.basicConfig(level=args.loglevel) 28 | 29 | os.chdir(args.destination) 30 | zip_data = api.current_user().hnr_zip() 31 | if zip_data is None: 32 | logger.error("No HNRs found to create zip file from.") 33 | return 34 | with open('hnr.zip', 'wb') as fh: 35 | fh.write(api.current_user().hnr_zip().content) 36 | if not args.no_unzip and os.path.isfile('hnr.zip'): 37 | subprocess.call(['unzip', 'hnr.zip']) 38 | os.remove('hnr.zip') 39 | 40 | 41 | main() 42 | -------------------------------------------------------------------------------- /src/ptpapi/scripts/ptp-trump: -------------------------------------------------------------------------------- 1 | #!/bin/env python 2 | import argparse 3 | import logging 4 | import os.path 5 | import re 6 | 7 | from bs4 import BeautifulSoup as bs4 8 | from urlparse import parse_qs, urlparse 9 | 10 | import ptpapi 11 | 12 | 13 | def main(): 14 | parser = argparse.ArgumentParser(description='Automatically download trumped torrents') 15 | parser.add_argument('--debug', help='Print lots of debugging statements', action="store_const", dest="loglevel", const=logging.DEBUG, default=logging.WARNING) 16 | parser.add_argument('-v', '--verbose', help='Be verbose', action="store_const", dest="loglevel", const=logging.INFO) 17 | parser.add_argument('-d', '--directory', help="The directory to save the files to") 18 | 19 | args = parser.parse_args() 20 | logging.basicConfig(level=args.loglevel) 21 | 22 | if args.directory: 23 | os.chdir(args.directory) 24 | 25 | api = ptpapi.login() 26 | for m in api.current_user().inbox(): 27 | if m['Unread'] and m['Sender'] == "System" and m['Subject'].startswith('Torrent deleted:'): 28 | conv_html = bs4(ptpapi.session.session.base_get('inbox.php', params={'action':'viewconv', 'id': m['ID']}).text, "html.parser") 29 | new_link = conv_html.find('a', text='here')['href'] 30 | t = ptpapi.Torrent(parse_qs(urlparse(new_link).query)['torrentid'][0]) 31 | t.download_to_dir() 32 | 33 | 34 | 35 | if __name__ == '__main__': 36 | main() 37 | 38 | -------------------------------------------------------------------------------- /src/ptpapi/scripts/ptp.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import argparse 3 | import json 4 | import logging 5 | import os.path 6 | 7 | from pathlib import Path 8 | from time import sleep 9 | from urllib.parse import parse_qs, urlparse 10 | 11 | import tempita 12 | 13 | from bs4 import BeautifulSoup as bs4 14 | 15 | import ptpapi 16 | 17 | 18 | def ellipsize(string, length): 19 | if len(string) > length: 20 | return string[: length - 3] + "..." 21 | return string 22 | 23 | 24 | def do_inbox(api, args): 25 | page = args.page 26 | user = api.current_user() 27 | if args.mark_all_read: 28 | print("Clearing out {0} messages".format(user.get_num_messages())) 29 | while user.new_messages > 0: 30 | for msg in api.current_user().inbox(page=page): 31 | if msg["Unread"] is False: 32 | continue 33 | user.inbox_conv(msg["ID"]) 34 | page += 1 35 | elif args.conversation: 36 | conv = user.inbox_conv(args.conversation, args.raw) 37 | print(conv["Subject"]) 38 | for msg in conv["Message"]: 39 | print("{0} - {1}\n".format(msg["User"], msg["Time"])) 40 | print(msg["Text"]) 41 | print("----------------------------") 42 | elif args.mark_read: 43 | for conv in args.mark_read: 44 | user.inbox_conv(conv) 45 | else: 46 | msgs = list(user.inbox(page=page)) 47 | print("ID" + " " * 8 + "Subject" + " " * 25 + "Sender" + " " * 9) 48 | print("-" * 55) 49 | for msg in msgs: 50 | if args.unread and msg["Unread"] is False: 51 | continue 52 | if args.user is not None and msg["Sender"] != args.user: 53 | continue 54 | print( 55 | "{0: <10}{1: <32}{2: <15}".format( 56 | msg["ID"], 57 | ellipsize(msg["Subject"].decode("utf-8"), 31), 58 | ellipsize(msg["Sender"], 15), 59 | ) 60 | ) 61 | 62 | 63 | def parse_terms(termlist): 64 | """Takes an array of terms, and sorts them out into 4 categories: 65 | * torrent URLs 66 | * movie URLs 67 | * targets (where to perform the search e.g. collages or bookmarks) 68 | * all other search parameters 69 | """ 70 | torrents = [] 71 | movies = [] 72 | terms = {} 73 | target = "torrents" 74 | 75 | for arg in termlist: 76 | url = urlparse(arg) 77 | url_args = parse_qs(url.query) 78 | if url.path == "/collages.php": 79 | target = "collage" 80 | terms = url_args 81 | elif url.path == "/artist.php": 82 | target = "artist" 83 | terms = url_args 84 | elif url.path == "/torrents.php": 85 | if "torrentid" in url_args: 86 | if "id" in url_args: 87 | torrents.append(ptpapi.Torrent(data={"Id":url_args["torrentid"][0], "GroupId":url_args["id"][0]})) 88 | else: 89 | torrents.append(ptpapi.Torrent(url_args["torrentid"][0])) 90 | elif "id" in url_args: 91 | if "action" in url_args and url_args["action"][0] == "download": 92 | torrents.append(ptpapi.Torrent(url_args["id"][0])) 93 | else: 94 | movies.append(ptpapi.Movie(url_args["id"][0])) 95 | else: 96 | terms = url_args 97 | else: 98 | term = arg.partition("=") 99 | if not term[2]: 100 | if term[0] == "bookmarks": 101 | target = "bookmarks" 102 | else: 103 | terms["searchstr"] = term[0] 104 | else: 105 | # Provide aliases for commonly used terms 106 | term_map = { 107 | "taglist": ["genre", "genres", "tags"], 108 | "searchstr": ["name", "title"], 109 | } 110 | for key, value in term_map.items(): 111 | if term[0] in value: 112 | term = (key, term[1], term[2]) 113 | terms[term[0]] = term[2] 114 | return (target, movies, torrents, terms) 115 | 116 | 117 | def get_pages(target, terms): 118 | if target == "torrents": 119 | return ptpapi.util.find_page_range( 120 | ptpapi.session.session.base_get("torrents.php", params=terms).content 121 | ) 122 | return None 123 | 124 | 125 | def do_subscriptions(api, _args): 126 | sub_data = api.subscriptions() 127 | for section, data in sub_data.items(): 128 | if data: 129 | print(section.title()) 130 | for d in data: 131 | print(f"- {d}") 132 | 133 | 134 | def do_search(api, args): 135 | (target, movies, torrents, terms) = parse_terms(args.search_terms) 136 | if args.all: 137 | args.pages = get_pages(target, terms) 138 | logger = logging.getLogger(__name__) 139 | logger.debug("Auto-detected maximum page as %s") 140 | if "page" not in terms: 141 | terms["page"] = "1" 142 | else: 143 | if isinstance(terms["page"], list): 144 | page = int(terms["page"][0]) 145 | else: 146 | page = int(terms["page"]) 147 | terms["page"] = page 148 | for _ in range(args.pages): 149 | search_page(api, args, target, movies, torrents, terms.copy()) 150 | terms["page"] = str(int(terms["page"]) + 1) 151 | 152 | 153 | def search_page(api, args, target, movies, torrents, terms): 154 | logger = logging.getLogger(__name__) 155 | if args.movie_format == "": 156 | movie_template = None # Just to make linting happy 157 | elif args.movie_format is not None: 158 | movie_template = tempita.Template(args.movie_format) 159 | else: 160 | movie_template = tempita.Template( 161 | "{{Title}} ({{Year}}) - {{if Directors}}{{','.join([d['Name'].strip() for d in Directors])}} -{{endif}} " 162 | "[{{'/'.join(Tags)}}] - [PTP {{GroupId}}{{if ImdbId}}, IMDB tt{{ImdbId}}{{endif}}]" 163 | ) 164 | if args.torrent_format == "": 165 | torrent_template = None 166 | elif args.torrent_format is not None: 167 | torrent_template = tempita.Template(args.torrent_format) 168 | else: 169 | torrent_template = tempita.Template( 170 | "{{if GoldenPopcorn}}\u2606{{else}}-{{endif}} {{Codec}}/{{Container}}/{{Source}}/{{Resolution}}" 171 | " - {{ReleaseName}} - {{Snatched}}/{{Seeders}}/{{Leechers}}" 172 | ) 173 | 174 | # If we haven't found any URL-looking things 175 | if not movies and not torrents: 176 | logger.debug('Attempting to search target "%s" with terms %s', target, terms) 177 | if target == "torrents": 178 | if terms.get("type") in ["snatched", "uploaded", "seeding", "leeching"]: 179 | if "userid" in terms: 180 | user = ptpapi.User(terms["userid"]) 181 | else: 182 | user = api.current_user() 183 | movies = user.search(terms.get("type"), terms) 184 | else: 185 | movies = api.search(terms) 186 | # Check to see if we should scrape the cover view data to save calls 187 | wanted_fields = set() 188 | if movie_template is not None: 189 | wanted_fields = { 190 | l[2].split("|")[0] for l in movie_template._parsed if l[0] == "expr" 191 | } 192 | if len(wanted_fields & set(api.search_coverview_fields)): 193 | for movie in api.search_coverview(terms): 194 | for ret_movie in movies: 195 | if movie["GroupId"] == ret_movie["GroupId"]: 196 | ret_movie.update(movie) 197 | elif target == "bookmarks": 198 | movies = api.current_user().bookmarks(search_terms=terms) 199 | elif target == "collage": 200 | movies = api.collage(terms["id"], terms) 201 | elif target == "artist": 202 | movies = api.artist(terms["id"], terms) 203 | if not args.download: 204 | movies = movies[: args.limit] 205 | 206 | if args.download: 207 | downloaded = 0 208 | for movie in movies: 209 | if movie_template: 210 | print(movie_template.substitute(movie)) 211 | match = movie.best_match(args.filter) 212 | if match: 213 | if torrent_template: 214 | print(torrent_template.substitute(match)) 215 | if not args.dry_run: 216 | match.download_to_dir(args.output_directory) 217 | else: 218 | logger.info("Dry-run, not downloading %s", match) 219 | downloaded = +1 220 | if downloaded >= args.limit: 221 | break 222 | else: 223 | logger.info( 224 | "No match found for for movie %s (%s)", 225 | movie["Title"], 226 | movie["Year"], 227 | ) 228 | for torrent in torrents: 229 | if args.download and not args.dry_run: 230 | if torrent_template: 231 | print(torrent_template.substitute(torrent)) 232 | torrent.download_to_dir(args.output_directory) 233 | elif args.dry_run: 234 | logger.info("Dry-run, not downloading %s", torrent) 235 | else: 236 | for movie in movies[: args.limit]: 237 | if movie_template: 238 | print(movie_template.substitute(movie)) 239 | for torrent in movie["Torrents"]: 240 | if torrent_template: 241 | print(torrent_template.substitute(torrent)) 242 | for torrent in torrents: 243 | if torrent_template: 244 | print(torrent_template.substitute(torrent)) 245 | 246 | 247 | def do_raw(_api, args): 248 | """Given a URL, download the raw HTML to the current directory""" 249 | for url_str in args.url: 250 | url = urlparse(url_str) 251 | data = ptpapi.session.session.base_get("?".join([url.path, url.query])).content 252 | if args.output: 253 | if args.output == "-": 254 | print(data.decode(), end="") 255 | return 256 | else: 257 | file_out = args.output 258 | else: 259 | file_out = os.path.basename(url.path) 260 | with open(file_out, "wb") as fileh: 261 | fileh.write(data) 262 | 263 | 264 | def do_log(api, args): 265 | interval = 30.0 266 | lastmsg = None 267 | while True: 268 | printmsg = False 269 | msgs = api.log() 270 | # We actually want it 'reversed' by default, with the newest at the bottom 271 | if not args.reverse: 272 | msgs.reverse() 273 | for time, msg in msgs: 274 | if lastmsg is None or printmsg: 275 | print(time, "-", msg) 276 | lastmsg = msg 277 | if lastmsg == msg: 278 | printmsg = True 279 | if args.follow: 280 | sleep(interval) 281 | else: 282 | break 283 | 284 | 285 | def do_fields(_api, _args): 286 | print("Movie:") 287 | m = ptpapi.Movie(ID=1) 288 | for values in m.key_finder.values(): 289 | for val in values: 290 | print(f"- {val}") 291 | print("Torrent:") 292 | t = ptpapi.Torrent(ID=1) 293 | for values in t.key_finder.values(): 294 | for val in values: 295 | print(f"- {val}") 296 | 297 | 298 | def do_search_fields(_api, _args): 299 | soup = bs4( 300 | ptpapi.session.session.base_get( 301 | "torrents.php", params={"action": "advanced", "json": "0"} 302 | ).content, 303 | "html.parser", 304 | ) 305 | for e in soup.find(id="filter_torrents_form")("input"): 306 | if ( 307 | e["type"] in ["submit", "button"] 308 | or e["name"].startswith("filter_cat") 309 | or e["name"].startswith("tags_type") 310 | or e["name"].startswith("country_type") 311 | or e["name"] == "action" 312 | or e["name"] == "noredirect" 313 | ): 314 | continue 315 | name = e["name"] 316 | if "placeholder" in e.attrs.keys(): 317 | name += " - " + e["placeholder"] 318 | if "title" in e.attrs.keys(): 319 | name += " - " + e["title"] 320 | print(name) 321 | print( 322 | "seeders - The number of seeders. You can use ranges too. E.g.: -5 or 1- or 1-5" 323 | ) 324 | 325 | 326 | def do_userstats(api, args): 327 | if args.user_id: 328 | user = ptpapi.User(args.user_id) 329 | else: 330 | user = api.current_user() 331 | if args.hummingbird: 332 | # e.g. '[ Example ] :: [ Power User ] :: [ Uploaded: 10.241 TiB | Downloaded: 1.448 TiB | Points: 79,76g2,506 | Ratio: 2.58 ] :: [ https://passthepopcorn.me/user.php?id=XXXXX ]' 333 | stats = user.stats() 334 | stats["Id"] = user.ID 335 | print( 336 | "[ {{Username}} ] :: [ {Class} ] :: [ Uploaded: {Uploaded} | Downloaded: {Downloaded} | Points: {Points} | Ratio: {Ratio} ] :: [ https://passthepopcorn.me/user.php?id={Id} ]".format( 337 | **stats 338 | ) 339 | ) 340 | else: 341 | for stat, value in user.stats().items(): 342 | print(stat + ": " + value) 343 | 344 | 345 | def do_archive(_api, args): 346 | logger = logging.getLogger(__name__) 347 | r = ptpapi.session.session.base_get( 348 | "archive.php", 349 | params={ 350 | "action": "fetch", 351 | "MaxStalled": 0, 352 | "ContainerName": ptpapi.config.config.get("PTP", "archiveContainerName"), 353 | "ContainerSize": ptpapi.config.config.get("PTP", "archiveContainerSize"), 354 | }, 355 | ) 356 | r.raise_for_status() 357 | try: 358 | data = r.json() 359 | except json.JSONDecodeError: 360 | logger.fatal("Response could not be converted to JSON: %s", r.text) 361 | if "TorrentID" not in data: 362 | logger.fatal( 363 | "Could not parse 'TorrentID' from JSON string %s", json.dumps(data) 364 | ) 365 | return 366 | if data.get("Status", "") == "Error": 367 | logger.fatal("Received error in JSON response: %s", json.dumps(data)) 368 | return 369 | ptpapi.Torrent(ID=data["TorrentID"]).download_to_dir( 370 | params={"ArchiveID": data["ArchiveID"]} 371 | ) 372 | if args.download_incomplete: 373 | for _id, i_data in data["IncompleteTransactions"].items(): 374 | if i_data["InfoHash"] is not None: 375 | ptpapi.Torrent(ID=i_data["TorrentID"]).download_to_dir( 376 | params={"ArchiveID": data["ArchiveID"]} 377 | ) 378 | 379 | 380 | def do_requests(api, args): 381 | filters = {} 382 | for f in args.search_terms: 383 | if "=" not in f: 384 | filters["search"] = f 385 | else: 386 | filters[f.split("=")[0]] = f.partition("=")[2] 387 | for r in api.requests(filters): 388 | print( 389 | "{Title} [{Year}] / {RequestCriteria} / {ImdbLink} / {RequestLink} / {RequestBountyHuman}".format( 390 | **r 391 | ) 392 | ) 393 | 394 | 395 | def do_origin(api, args): 396 | import ptpapi.scripts.ptp_origin 397 | logger = logging.getLogger(__name__) 398 | for p in args.torrent: 399 | p_path = Path(p) 400 | if p_path.is_dir(): 401 | if args.recursive: 402 | for t in p_path.rglob("*.torrent"): 403 | try: 404 | ptpapi.scripts.ptp_origin.write_origin(t, args) 405 | except Exception: 406 | logger.error("Error handling file %s", t) 407 | raise 408 | else: 409 | logger.warning( 410 | "Skipping directory %s, use --recursive to descend into directories", 411 | p, 412 | ) 413 | if p_path.is_file(): 414 | try: 415 | ptpapi.scripts.ptp_origin.write_origin(p, args) 416 | except Exception: 417 | logger.error("Error handling file %s", p) 418 | raise 419 | 420 | 421 | def add_verbosity_args(parser): 422 | """Helper function to improve DRY""" 423 | parser.add_argument( 424 | "--debug", 425 | help="Print lots of debugging statements", 426 | action="store_const", 427 | dest="loglevel", 428 | const=logging.DEBUG, 429 | default=logging.WARNING, 430 | ) 431 | parser.add_argument( 432 | "-v", 433 | "--verbose", 434 | help="Be verbose", 435 | action="store_const", 436 | dest="loglevel", 437 | const=logging.INFO, 438 | ) 439 | parser.add_argument( 440 | "-q", 441 | "--quiet", 442 | help="Hide most messages", 443 | action="store_const", 444 | dest="loglevel", 445 | const=logging.CRITICAL, 446 | ) 447 | 448 | 449 | def main(): 450 | logger = logging.getLogger(__name__) 451 | parser = argparse.ArgumentParser( 452 | description="Extensible command line utility for PTP" 453 | ) 454 | parser.set_defaults(func=None) 455 | add_verbosity_args(parser) 456 | subparsers = parser.add_subparsers() 457 | 458 | # Search & download 459 | search_parent = argparse.ArgumentParser() 460 | add_verbosity_args(search_parent) 461 | search_parent.add_argument( 462 | "search_terms", 463 | help="""A list of terms in [field]=[text] format. 464 | If the '=' is omitted, the field is assumed to be 'name'.""", 465 | nargs="+", 466 | metavar="term", 467 | ) 468 | search_parent.add_argument( 469 | "-n", 470 | "--dry-run", 471 | help="Don't actually download any torrents", 472 | action="store_true", 473 | ) 474 | search_parent.add_argument( 475 | "-l", "--limit", help="Limit search results to N movies", default=100, type=int 476 | ) 477 | search_parent.add_argument( 478 | "-f", 479 | "--filter", 480 | help="Define a filter to download movies with", 481 | default=ptpapi.config.config.get("Main", "filter"), 482 | ) 483 | search_parent.add_argument( 484 | "-m", "--movie-format", help="Set the output for movies", default=None 485 | ) 486 | search_parent.add_argument( 487 | "-t", "--torrent-format", help="Set the output for torrents", default=None 488 | ) 489 | search_parent.add_argument( 490 | "-o", 491 | "--output-directory", 492 | help="Location for any downloaded files", 493 | default=None, 494 | ) 495 | search_parent.add_argument( 496 | "-p", "--pages", help="The number of pages to download", default=1, type=int 497 | ) 498 | search_parent.add_argument( 499 | "-a", "--all", help="Return all search results", action="store_true" 500 | ) 501 | 502 | # Search 503 | search_parser = subparsers.add_parser( 504 | "search", 505 | help="Search for or download movies", 506 | add_help=False, 507 | parents=[search_parent], 508 | ) 509 | search_parser.add_argument( 510 | "-d", "--download", help="Download any movies found", action="store_true" 511 | ) 512 | search_parser.set_defaults(func=do_search) 513 | 514 | # Download 515 | download_parser = subparsers.add_parser( 516 | "download", 517 | help="An alias for `search -d`", 518 | add_help=False, 519 | parents=[search_parent], 520 | ) 521 | download_parser.add_argument( 522 | "-d", 523 | "--download", 524 | help="Download any movies found", 525 | action="store_true", 526 | default=True, 527 | ) 528 | download_parser.set_defaults(func=do_search) 529 | 530 | # Archive 531 | archive_parser = subparsers.add_parser( 532 | "archive", help="Commands related to the archive project." 533 | ) 534 | archive_parser.add_argument( 535 | "--download-incomplete", 536 | help="Also download any incomplete transactions", 537 | action="store_true", 538 | ) 539 | archive_parser.set_defaults(func=do_archive) 540 | 541 | # Inbox 542 | inbox_parser = subparsers.add_parser("inbox", help="Reads messages in your inbox") 543 | add_verbosity_args(inbox_parser) 544 | inbox_parser.add_argument( 545 | "-u", "--unread", help="Only show unread messages", action="store_true" 546 | ) 547 | inbox_parser.add_argument( 548 | "-m", 549 | "--mark-read", 550 | help="Mark messages as read", 551 | type=lambda s: [int(n) for n in s.split(",")], 552 | ) 553 | inbox_parser.add_argument( 554 | "--mark-all-read", 555 | help="Scan and mark all messages as read. " 556 | "WARNING: If new messages arrive while this is running, the script can get caught in a loop until it reaches the end of the inbox's pages", 557 | action="store_true", 558 | ) 559 | inbox_parser.add_argument("--user", help="Filter messages by the sender") 560 | inbox_parser.add_argument( 561 | "-c", 562 | "--conversation", 563 | help="Get the messages of a specific conversation", 564 | type=int, 565 | ) 566 | inbox_parser.add_argument( 567 | "-p", "--page", help="Start at a certain page", type=int, default=1 568 | ) 569 | inbox_parser.add_argument( 570 | "--raw", 571 | help="Combined with -c, fetch the raw HTML message", 572 | action="store_true", 573 | ) 574 | inbox_parser.set_defaults(func=do_inbox) 575 | 576 | # Raw 577 | raw_parser = subparsers.add_parser("raw", help="Fetch the raw HTML of pages") 578 | add_verbosity_args(raw_parser) 579 | raw_parser.add_argument("url", help="A list of urls to download", nargs="+") 580 | raw_parser.add_argument( 581 | "-o", 582 | "--output", 583 | help="Set output file (or - for stdout)", 584 | ) 585 | raw_parser.set_defaults(func=do_raw) 586 | 587 | # User stats 588 | userstats_parser = subparsers.add_parser( 589 | "userstats", help="Gather users' stats from profile pages" 590 | ) 591 | add_verbosity_args(userstats_parser) 592 | userstats_parser.add_argument( 593 | "-i", "--user-id", help="The user to look at", nargs="?", default=None 594 | ) 595 | userstats_parser.add_argument( 596 | "--hummingbird", help="Imitate Hummingbird's format", action="store_true" 597 | ) 598 | userstats_parser.set_defaults(func=do_userstats) 599 | 600 | # Fields 601 | field_parser = subparsers.add_parser( 602 | "fields", help="List the fields available for each PTPAPI resource" 603 | ) 604 | add_verbosity_args(field_parser) 605 | field_parser.set_defaults(func=do_fields) 606 | 607 | search_field_parser = subparsers.add_parser( 608 | "search-fields", help="List the fields available when searching" 609 | ) 610 | add_verbosity_args(search_field_parser) 611 | search_field_parser.set_defaults(func=do_search_fields) 612 | 613 | # Log 614 | log_parser = subparsers.add_parser("log", help="Show the log of recent events") 615 | add_verbosity_args(log_parser) 616 | log_parser.add_argument( 617 | "-r", "--reverse", help="Sort in reverse", action="store_true" 618 | ) 619 | log_parser.add_argument( 620 | "-f", "--follow", help="Print new entries as they appear", action="store_true" 621 | ) 622 | log_parser.set_defaults(func=do_log) 623 | 624 | # Requests 625 | requests_parser = subparsers.add_parser("requests", help="Search requests") 626 | add_verbosity_args(requests_parser) 627 | requests_parser.add_argument( 628 | "search_terms", 629 | help="""A list of terms in [field]=[text] format. 630 | If the '=' is omitted, the field is assumed to be 'name'.""", 631 | nargs="*", 632 | metavar="term", 633 | ) 634 | requests_parser.set_defaults(func=do_requests) 635 | 636 | # Subscriptions 637 | subscriptions_parser = subparsers.add_parser( 638 | "subscriptions", help="List subscribed posts/comments/mentions" 639 | ) 640 | add_verbosity_args(subscriptions_parser) 641 | subscriptions_parser.set_defaults(func=do_subscriptions) 642 | 643 | # Origin 644 | origin_parser = subparsers.add_parser( 645 | "origin", help="Download metadata from PTP for archival purposes" 646 | ) 647 | origin_parser.add_argument( 648 | "torrent", nargs="+", help="Torrent file to use for scraping information" 649 | ) 650 | origin_parser.add_argument( 651 | "-r", "--recursive", help="Recursively walk directory", action="store_true" 652 | ) 653 | origin_parser.add_argument( 654 | "--overwrite", 655 | help="Re-download files even if they already exist", 656 | action="store_true", 657 | ) 658 | origin_parser.add_argument( 659 | "-d", 660 | "--output-directory", 661 | help="Directory to write files to (defaults to torrent name without extension)", 662 | metavar="DIR", 663 | ) 664 | origin_parser.add_argument( 665 | "--no-images", help="Skip downloading images", action="store_true" 666 | ) 667 | add_verbosity_args(origin_parser) 668 | origin_parser.set_defaults(func=do_origin) 669 | 670 | # Main function 671 | args = parser.parse_args() 672 | 673 | logging.basicConfig(level=args.loglevel) 674 | 675 | api = ptpapi.login() 676 | 677 | if args.func is None: 678 | parser.print_help() 679 | return 680 | args.func(api, args) 681 | logger.debug( 682 | "Total session tokens consumed: %s", ptpapi.session.session.consumed_tokens 683 | ) 684 | logger.debug("Exiting...") 685 | 686 | 687 | if __name__ == "__main__": 688 | main() 689 | -------------------------------------------------------------------------------- /src/ptpapi/scripts/ptp_origin.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import argparse 3 | import io 4 | import logging 5 | import re 6 | import textwrap 7 | 8 | from pathlib import Path 9 | from urllib.parse import urlparse 10 | 11 | import requests 12 | import ruamel.yaml 13 | import urllib3 14 | 15 | from bs4 import BeautifulSoup as bs4 16 | from pyrosimple.util import metafile 17 | 18 | import ptpapi 19 | 20 | 21 | YAML = ruamel.yaml.YAML() 22 | YAML.top_level_colon_align = True 23 | YAML.width = float("inf") 24 | YAML.allow_unicode = True 25 | YAML.encoding = "utf-8" 26 | 27 | RE_COMMENT = re.compile( 28 | r"https://passthepopcorn.me/torrents.php\?id=(\d+)&torrentid=(\d+)" 29 | ) 30 | RE_URL = re.compile( 31 | r"((http|https)\:\/\/)[a-zA-Z0-9\.\/\?\:@\-_=#]+\.([a-zA-Z]){2,6}([a-zA-Z0-9\.\&\/\?\:@\-_=#])*" 32 | ) 33 | RE_DELETED_BY = re.compile(r"was deleted by .* for") 34 | 35 | 36 | def write_origin(t, args): 37 | logger = logging.getLogger(__name__) 38 | mfile_path = Path(t) 39 | mfile = metafile.Metafile.from_file(mfile_path) 40 | if "comment" not in mfile or not RE_COMMENT.match(mfile["comment"]): 41 | logger.info("Skipping file %s, does not contain PTP URL in comment", t) 42 | return 43 | logger.info("Working file %s", t) 44 | match = RE_COMMENT.match(mfile["comment"]) 45 | movie = ptpapi.Movie(match.group(1)) 46 | torrent = ptpapi.Torrent(data={"Id": match.group(2), "GroupId": match.group(1)}) 47 | if args.output_directory is not None: 48 | output_dir = args.output_directory 49 | else: 50 | output_dir = Path(mfile_path.stem) 51 | output_dir.mkdir(parents=True, exist_ok=True) 52 | yaml_path = Path(output_dir, mfile_path.with_suffix(".yaml").name) 53 | if yaml_path.exists() and not args.overwrite: 54 | logger.info("Skipping file %s, origin file '%s' exists", t, yaml_path) 55 | return 56 | nfo_path = Path(output_dir, mfile_path.with_suffix(".nfo").name) 57 | logger.info("Writing origin YAML file %s", yaml_path) 58 | output = "---\n" 59 | # Basic data 60 | data = { 61 | "Title": movie["Name"], 62 | "Year": int(movie["Year"]), 63 | "Directors": movie["Directors"], 64 | "Type": movie["Type"], 65 | "ReleaseName": torrent["ReleaseName"], 66 | "RemasterTitle": torrent["RemasterTitle"], 67 | "IMDb": f'https://imdb.com/title/tt{movie["ImdbId"]}', 68 | "Cover": movie["Cover"], 69 | "Permalink": mfile["comment"], 70 | "InfoHash": torrent["InfoHash"], 71 | "Codec": torrent["Codec"], 72 | "Container": torrent["Container"], 73 | "UploadTime": torrent["UploadTime"], 74 | "Checked": torrent["Checked"], 75 | "GoldenPopcorn": torrent["GoldenPopcorn"], 76 | "Scene": torrent["Scene"], 77 | "ReleaseGroup": torrent["ReleaseGroup"], 78 | "Resolution": torrent["Resolution"], 79 | "Size": int(torrent["Size"]), 80 | "Source": torrent["Source"], 81 | "Tags": movie["Tags"], 82 | } 83 | buf = io.StringIO() 84 | YAML.dump(data, buf) 85 | output += buf.getvalue() 86 | # Nicely format multi-line descriptions 87 | desc = torrent["BBCodeDescription"] 88 | output += "Description: |\n" 89 | output += textwrap.indent(desc, "") 90 | output += "\n" 91 | # Scrubbed deletion log 92 | soup = bs4( 93 | ptpapi.session.session.base_get( 94 | "torrents.php", 95 | params={ 96 | "action": "history_log", 97 | "groupid": movie.ID, 98 | "search": "", 99 | "only_deletions": 1, 100 | }, 101 | ).content, 102 | features="html.parser", 103 | ) 104 | log_body = soup.find("tbody") 105 | log_data = [] 106 | for row in log_body.find_all("tr"): 107 | time = row.find_all("span")[0]["title"] 108 | message = RE_DELETED_BY.sub("was deleted for", row.find_all("span")[1].text) 109 | if message != row.find_all("span")[1].text: 110 | log_data.append({"Time": time, "Message": message.strip()}) 111 | buf = io.StringIO() 112 | YAML.dump({"Log": log_data}, buf) 113 | output += buf.read() 114 | with yaml_path.open("w", encoding="utf-8") as stream: 115 | stream.write(output) 116 | # NFO 117 | if "Nfo" in torrent.data and torrent["Nfo"]: 118 | logger.info("Writing NFO file %s", nfo_path) 119 | nfo_path.write_text(torrent["Nfo"], encoding="utf-8") 120 | # Download anything that looks like a URL 121 | if not args.no_images: 122 | for m in re.finditer(RE_URL, desc): 123 | url_parts = urlparse(m.group(0)) 124 | path = Path(output_dir, Path(url_parts.path).name) 125 | # Skip IMDb title URLS 126 | url = m.group(0) 127 | if "imdb.com/title/" not in url and "passthepopcorn.me" not in url: 128 | if not path.exists() or args.overwrite: 129 | logger.info("Downloading description image %s to %s", url, path) 130 | try: 131 | resp = requests.get(url, timeout=30) 132 | except ( 133 | requests.exceptions.RequestException, 134 | urllib3.exceptions.HTTPError, 135 | ) as exc: 136 | logger.error("Could not fetch URL %s: %s", url, exc) 137 | continue 138 | if "Content-Type" in resp.headers and resp.headers[ 139 | "Content-Type" 140 | ].startswith("image"): 141 | with path.open("wb") as fh: 142 | fh.write(resp.content) 143 | # Cover 144 | url_parts = urlparse(movie["Cover"]) 145 | path = Path(output_dir, Path(url_parts.path).name) 146 | if not path.exists(): 147 | logger.info("Downloading cover %s to %s", movie["Cover"], path) 148 | try: 149 | resp = requests.get(movie["Cover"], timeout=30) 150 | except ( 151 | requests.exceptions.RequestException, 152 | urllib3.exceptions.HTTPError, 153 | ) as exc: 154 | logger.error("Could not fetch cover URL %s: %s", movie["Cover"], exc) 155 | return 156 | if "Content-Type" not in resp.headers: 157 | logger.warning("Cover did not return an content-type, cannot save") 158 | if resp.headers["Content-Type"].startswith("image"): 159 | with path.open("wb") as fh: 160 | fh.write(resp.content) 161 | else: 162 | logger.warning( 163 | "Cover did not appear to be an image (content-type %s), not saving", 164 | resp.headers["Content-Type"], 165 | ) 166 | 167 | 168 | def main(): 169 | parser = argparse.ArgumentParser( 170 | usage="Download metadata from PTP for archival purposes" 171 | ) 172 | parser.add_argument( 173 | "torrent", nargs="+", help="Torrent file to use for scraping information" 174 | ) 175 | parser.add_argument( 176 | "--debug", 177 | help="Print lots of debugging statements", 178 | action="store_const", 179 | dest="loglevel", 180 | const=logging.DEBUG, 181 | default=logging.WARNING, 182 | ) 183 | parser.add_argument( 184 | "-v", 185 | "--verbose", 186 | help="Be verbose", 187 | action="store_const", 188 | dest="loglevel", 189 | const=logging.INFO, 190 | ) 191 | parser.add_argument( 192 | "-q", 193 | "--quiet", 194 | help="Hide most messages", 195 | action="store_const", 196 | dest="loglevel", 197 | const=logging.CRITICAL, 198 | ) 199 | parser.add_argument( 200 | "-r", "--recursive", help="Recursively walk directory", action="store_true" 201 | ) 202 | parser.add_argument( 203 | "--overwrite", 204 | help="Re-download files even if they already exist", 205 | action="store_true", 206 | ) 207 | parser.add_argument( 208 | "-d", 209 | "--output-directory", 210 | help="Directory to write files to (defaults to torrent name without extension)", 211 | metavar="DIR", 212 | ) 213 | parser.add_argument( 214 | "--no-images", help="Skip downloading images", action="store_true" 215 | ) 216 | args = parser.parse_args() 217 | logging.basicConfig(level=args.loglevel) 218 | logger = logging.getLogger(__name__) 219 | ptpapi.login() 220 | for p in args.torrent: 221 | p_path = Path(p) 222 | if p_path.is_dir(): 223 | if args.recursive: 224 | for t in p_path.rglob("*.torrent"): 225 | try: 226 | write_origin(t, args) 227 | except Exception: 228 | logger.error("Error handling file %s", t) 229 | raise 230 | else: 231 | logger.warning( 232 | "Skipping directory %s, use --recursive to descend into directories", 233 | p, 234 | ) 235 | if p_path.is_file(): 236 | try: 237 | write_origin(p, args) 238 | except Exception: 239 | logger.error("Error handling file %s", p) 240 | raise 241 | 242 | 243 | if __name__ == "__main__": 244 | main() 245 | -------------------------------------------------------------------------------- /src/ptpapi/scripts/ptp_reseed.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Reseed a torrent from PTP, given a path""" 3 | import argparse 4 | import logging 5 | import os 6 | import os.path 7 | import sys 8 | 9 | from pathlib import Path 10 | from time import sleep, time 11 | from typing import Optional, Union 12 | from urllib.parse import parse_qs, urlparse 13 | from xmlrpc import client as xmlrpc_client 14 | 15 | import bencode 16 | import bencodepy 17 | import libtc 18 | import pyrosimple 19 | 20 | from pyrosimple.util import metafile, rpc 21 | 22 | import ptpapi 23 | 24 | 25 | class Match: 26 | """A tiny class to make matching easier 27 | 28 | Could be expanded to introduce a confidence indicator 29 | 30 | ID is an integer-as-a-string, and path is filepath""" 31 | 32 | # pylint: disable=too-few-public-methods 33 | def __init__( 34 | self, 35 | ID: Optional[str] = None, 36 | path: Optional[os.PathLike] = None, 37 | matched_files: Optional[dict[str, str]] = None, 38 | failure_reason: str = "No match", 39 | ): 40 | """A defined match""" 41 | self.ID = ID 42 | self.path = path 43 | if matched_files is None: 44 | matched_files = {} 45 | self.matched_files = matched_files 46 | self.failure_reason = failure_reason 47 | 48 | def __nonzero__(self): 49 | if self.ID is not None and self.path is not None: 50 | return True 51 | return False 52 | 53 | def __bool__(self): 54 | return self.__nonzero__() 55 | 56 | def __str__(self): 57 | return "".format(self.ID, self.path) 58 | 59 | 60 | def match_by_torrent(torrent, filepath: str) -> Match: 61 | """Attempt matching against a torrent ID""" 62 | logger = logging.getLogger(__name__) 63 | logger.info( 64 | "Attempting to match against torrent {0} ({1})".format( 65 | torrent.ID, torrent["ReleaseName"] 66 | ) 67 | ) 68 | 69 | if isinstance(filepath, bytes): 70 | filepath = filepath.decode("utf-8") 71 | path1 = os.path.abspath(filepath) 72 | path1_files = {} 73 | if os.path.isdir(path1): 74 | for root, _, filenames in os.walk(path1, followlinks=True): 75 | for filename in filenames: 76 | realpath = os.path.join(root, filename).replace( 77 | os.path.dirname(path1) + os.sep, "" 78 | ) 79 | path1_files[realpath] = os.path.getsize(os.path.join(root, filename)) 80 | elif os.path.isfile(path1): 81 | path1_files[os.path.basename(path1)] = os.path.getsize(path1) 82 | 83 | path2_files = dict((f, int(s)) for f, s in torrent["Filelist"].items()) 84 | 85 | if len(path1_files) < len(path2_files): 86 | logger.debug( 87 | "Too little files to match torrent ({0} locally, {1} in torrent)".format( 88 | len(path1_files), len(path2_files) 89 | ) 90 | ) 91 | return Match( 92 | None, 93 | failure_reason="Too little files to match torrent ({0} locally, {1} in torrent)".format( 94 | len(path1_files), len(path2_files) 95 | ), 96 | ) 97 | 98 | matched_files = {} 99 | logger.debug("Looking for exact matches") 100 | for filename, size in list(path1_files.items()): 101 | if filename in path2_files.keys() and path2_files[filename] == size: 102 | matched_files[filename] = filename 103 | del path1_files[filename] 104 | del path2_files[filename] 105 | logger.debug( 106 | "{0} of {1} files matched".format( 107 | len(matched_files), len(path2_files) + len(matched_files) 108 | ) 109 | ) 110 | 111 | logger.debug( 112 | "Looking for matches with same size and name but different root folder" 113 | ) 114 | for filename1, size1 in list(path1_files.items()): 115 | no_root1 = os.sep.join(os.path.normpath(filename1).split(os.sep)[1:]) 116 | for filename2, size2 in list(list(path2_files.items())): 117 | no_root2 = os.sep.join(os.path.normpath(filename2).split(os.sep)[1:]) 118 | if no_root1 == no_root2 and size1 == size2: 119 | matched_files[filename1] = filename2 120 | del path1_files[filename1] 121 | del path2_files[filename2] 122 | break 123 | logger.debug( 124 | "{0} of {1} files matched".format( 125 | len(matched_files), len(path2_files) + len(matched_files) 126 | ) 127 | ) 128 | 129 | logger.debug("Looking for matches with same base name and size") 130 | for filename1, size1 in list(path1_files.items()): 131 | for filename2, size2 in list(path2_files.items()): 132 | if ( 133 | os.path.basename(filename1) == os.path.basename(filename2) 134 | and size1 == size2 135 | ): 136 | if os.path.basename(filename1) not in [ 137 | os.path.basename(p) for p in path2_files.keys() 138 | ]: 139 | matched_files[filename1] = filename2 140 | del path1_files[filename1] 141 | del path2_files[filename2] 142 | break 143 | logger.debug( 144 | "{0} of {1} files matched".format( 145 | len(matched_files), len(path2_files) + len(matched_files) 146 | ) 147 | ) 148 | 149 | logger.debug("Looking for matches by size only") 150 | for filename1, size1 in list(path1_files.items()): 151 | for filename2, size2 in list(path2_files.items()): 152 | logger.debug( 153 | "Comparing size of {0} ({1}) to size of {2} ({3})".format( 154 | filename1, size1, filename2, size2 155 | ) 156 | ) 157 | if size1 == size2: 158 | logger.debug("Matched {0} to {1}".format(filename1, filename2)) 159 | matched_files[filename1] = filename2 160 | del path1_files[filename1] 161 | del path2_files[filename2] 162 | break 163 | logger.debug( 164 | "{0} of {1} files matched".format( 165 | len(matched_files), len(path2_files) + len(matched_files) 166 | ) 167 | ) 168 | 169 | if len(path2_files) > 0: 170 | logger.info("Not all files could be matched, returning...") 171 | return Match( 172 | None, f"Not all files could be matched ({len(path2_files)} remaining)" 173 | ) 174 | return Match(torrent.ID, Path(os.path.dirname(path1)), matched_files) 175 | 176 | 177 | def match_by_movie(movie, filepath) -> Match: 178 | """Tries to match a torrent against a single movie""" 179 | logger = logging.getLogger(__name__) 180 | logger.info("Attempting to match against movie %s (%r)", movie.ID, movie["Title"]) 181 | 182 | movie.load_html_data() 183 | for torrent in movie["Torrents"]: 184 | match = match_by_torrent(torrent, os.path.abspath(filepath)) 185 | if match: 186 | return match 187 | return Match( 188 | None, 189 | failure_reason="No match found against movie {0} ({1})".format( 190 | movie.ID, movie["Title"] 191 | ), 192 | ) 193 | 194 | 195 | def match_by_guessed_name(ptp, filepath, limit, name=None) -> Match: 196 | """Use guessit to find the movie by metadata scraped from the filename""" 197 | logger = logging.getLogger(__name__) 198 | filepath = os.path.abspath(filepath) 199 | try: 200 | import guessit # pylint: disable=import-error 201 | except ImportError: 202 | logger.warning("Error importing 'guessit' module, skipping name guess") 203 | return Match(None, failure_reason="Error importing 'guessit' module") 204 | logger.info("Guessing name from filepath with guessit") 205 | if not name: 206 | name = os.path.basename(filepath) 207 | guess = guessit.guessit(name) 208 | if "title" not in guess: 209 | return Match( 210 | None, failure_reason=f"Could not find title from filename {name!r}" 211 | ) 212 | if "title" in guess and guess["title"]: 213 | search_params = {"searchstr": guess["title"]} 214 | if "year" in guess: 215 | search_params["year"] = guess["year"] 216 | movies = ptp.search(search_params) 217 | if len(movies) == 0: 218 | movies = ptp.search({"searchstr": guess["title"], "inallakas": "1"}) 219 | if len(movies) == 0: 220 | logger.debug("Could not find any movies by search with a guessed name") 221 | return Match( 222 | None, 223 | failure_reason="Could not find any movies by search with a guessed name", 224 | ) 225 | for movie in movies[:limit]: 226 | match = match_by_movie(movie, filepath) 227 | if match: 228 | return match 229 | return Match( 230 | None, failure_reason=f"Could not find match via guessed name {guess['title']}" 231 | ) 232 | 233 | 234 | def match_against_file(ptp, filepath, movie_limit) -> Match: 235 | """Use's PTP's file search feature to match a filename to a movie""" 236 | logger = logging.getLogger(__name__) 237 | filepath = os.path.abspath(filepath) 238 | logger.info("Searching movies by file list") 239 | for movie in ptp.search({"filelist": os.path.basename(filepath)})[:movie_limit]: 240 | match = match_by_movie(movie, filepath) 241 | if match: 242 | return match 243 | return Match(None, failure_reason="Could not find any match by filename") 244 | 245 | 246 | def create_matched_files(match, directory=None, action="hard", dry_run=False): 247 | """Intelligently create any necessary files or directories by different methods""" 248 | logger = logging.getLogger(__name__) 249 | if dry_run: 250 | logger.info("Dry run, no files or directories will be created") 251 | for origin_file, matched_file in match.matched_files.items(): 252 | origin_file = os.path.join(match.path, origin_file) 253 | if directory is None: 254 | directory = match.path 255 | file_to_create = os.path.join(directory, matched_file) 256 | path_to_create = os.path.dirname(file_to_create) 257 | try: 258 | logger.debug("Creating directory '{0}'".format(path_to_create)) 259 | if not dry_run: 260 | os.makedirs(path_to_create) 261 | except OSError as exc: 262 | # Ignore OSError only if the directory already exists 263 | if exc.errno != 17: 264 | raise 265 | if os.path.lexists(file_to_create): 266 | logger.debug( 267 | "File '{0}' already exists, skipping creation".format(file_to_create) 268 | ) 269 | continue 270 | logger.info( 271 | "Creating file '{0}' from '{1}' via action '{2}'".format( 272 | file_to_create, origin_file, action 273 | ) 274 | ) 275 | if not dry_run: 276 | if action == "soft": 277 | os.symlink(origin_file, file_to_create) 278 | elif action == "hard": 279 | os.link(origin_file, file_to_create) 280 | elif action == "skip": 281 | continue 282 | match.path = directory 283 | return match 284 | 285 | 286 | def is_torrent_complete(infohash: str, client=None) -> Union[bool, None]: 287 | """This returns a sort of horrible ternary: None if the torrent 288 | does not exist, True if it's complete, and false otherwise.""" 289 | if client is None: 290 | proxy = pyrosimple.connect().open() 291 | try: 292 | if proxy.d.complete(infohash): 293 | return True 294 | return False 295 | except rpc.HashNotFound: 296 | return None 297 | elif isinstance(client, str) and client.startswith("file://"): 298 | return None 299 | else: 300 | for torrent in client.list(): 301 | if torrent.infohash == infohash: 302 | if torrent.progress == 100: 303 | return True 304 | return False 305 | return None 306 | 307 | 308 | def load_torrent( 309 | ID, path, client=None, hash_check=False, overwrite_incomplete=False 310 | ) -> bool: 311 | """Send a torrent to rtorrent and kick off the hash recheck""" 312 | logger = logging.getLogger(__name__) 313 | torrent = ptpapi.Torrent(ID=ID) 314 | torrent_data = torrent.download() 315 | data = metafile.Metafile(bencode.bdecode(torrent_data)) 316 | thash = data.info_hash() 317 | path = Path(path) 318 | if hash_check: 319 | from pyrosimple.util.metafile import PieceFailer 320 | 321 | logger.debug("Starting hash check against %r", str(path)) 322 | pf = PieceFailer(data) 323 | try: 324 | check_path = path 325 | if data.is_multi_file: 326 | check_path = Path(path, data["info"]["name"]) 327 | data.hash_check(check_path, piece_callback=pf.check_piece) 328 | data.add_fast_resume(check_path) 329 | except OSError as exc: 330 | logger.error("Could not complete hash check: %s", exc) 331 | return False 332 | if client is None: 333 | hash_exists = False 334 | proxy = pyrosimple.connect().open() 335 | try: 336 | logger.debug("Testing for hash {0}".format(proxy.d.hash(thash))) 337 | if proxy.d.complete(thash): 338 | logger.error( 339 | "Hash {0} is already completed in rtorrent as {1}, cannot load.".format( 340 | thash, proxy.d.name(thash) 341 | ) 342 | ) 343 | return False 344 | hash_exists = True 345 | except rpc.HashNotFound: 346 | pass 347 | if hash_exists and not overwrite_incomplete: 348 | logger.error( 349 | "Hash {0} already exists in rtorrent as {1}, cannot load.".format( 350 | thash, proxy.d.name(thash) 351 | ) 352 | ) 353 | return False 354 | proxy.load.raw("", xmlrpc_client.Binary(torrent_data)) 355 | # Wait until the torrent is loaded and available 356 | while True: 357 | sleep(1) 358 | try: 359 | proxy.d.hash(thash) 360 | break 361 | except (xmlrpc_client.Fault, rpc.HashNotFound): 362 | pass 363 | logger.info("Torrent loaded at %r", str(path)) 364 | proxy.d.custom.set(thash, "tm_completed", str(int(time()))) 365 | proxy.d.directory.set(thash, str(path)) 366 | if hash_check and not overwrite_incomplete: 367 | proxy.d.start(thash) 368 | else: 369 | proxy.d.check_hash(thash) 370 | return True 371 | elif isinstance(client, str) and client.startswith("file://"): 372 | dest = Path(client[7:], data["info"]["name"] + ".torrent").expanduser() 373 | logger.info("Saving file to %r", str(dest)) 374 | data.save(dest) 375 | return True 376 | else: 377 | bd = bencodepy.BencodeDecoder() 378 | return bool(client.add(bd.decode(torrent_data), path)) 379 | 380 | 381 | def define_parser(): 382 | """Define the arguments for the CLI""" 383 | parser = argparse.ArgumentParser( 384 | description="Attempt to find and reseed torrents on PTP" 385 | ) 386 | parser.add_argument("-u", "--url", help="Permalink to the torrent page") 387 | parser.add_argument( 388 | "files", 389 | help="Paths to files/directories to reseed (or leave blank to read stdin)", 390 | nargs="*", 391 | type=str, 392 | ) 393 | parser.add_argument( 394 | "-n", 395 | "--dry-run", 396 | help="Don't actually create any files or load torrents", 397 | action="store_true", 398 | ) 399 | parser.add_argument( 400 | "-a", 401 | "--action", 402 | help="Method to use when creating files", 403 | choices=["hard", "soft", "skip"], 404 | default=ptpapi.config.config.get("Reseed", "action"), 405 | ) 406 | parser.add_argument( 407 | "-d", 408 | "--create-in-directory", 409 | help="Directory to create any new files in, if necessary", 410 | default=None, 411 | ) 412 | parser.add_argument( 413 | "--debug", 414 | help="Print lots of debugging statements", 415 | action="store_const", 416 | dest="loglevel", 417 | const=logging.DEBUG, 418 | default=logging.WARNING, 419 | ) 420 | parser.add_argument( 421 | "--client", 422 | help="Experimental: use a custom libtc URL. See https://github.com/JohnDoee/libtc#url-syntax for examples", 423 | default=ptpapi.config.config.get("Reseed", "client", fallback=None), 424 | ) 425 | parser.add_argument( 426 | "-v", 427 | "--verbose", 428 | help="Be verbose", 429 | action="store_const", 430 | dest="loglevel", 431 | const=logging.INFO, 432 | ) 433 | parser.add_argument( 434 | "-q", 435 | "--quiet", 436 | help="Don't show any messages", 437 | action="store_const", 438 | dest="loglevel", 439 | const=logging.CRITICAL, 440 | ) 441 | parser.add_argument( 442 | "-s", "--summary", help="Show a summary of all actions", action="store_true" 443 | ) 444 | parser.add_argument( 445 | "-l", 446 | "--limit", 447 | help="Limit the maximum number of movies checked for each file", 448 | type=int, 449 | default=5, 450 | ) 451 | parser.add_argument( 452 | "--hash-check", 453 | help="Hash check against any found matches before loading", 454 | action="store_true", 455 | ) 456 | parser.add_argument( 457 | "--overwrite-incomplete", 458 | help="If the torrent exists as incomplete, change the path of the existing torrent (rtorrent only)", 459 | action="store_true", 460 | ) 461 | return parser 462 | 463 | 464 | def process(cli_args): 465 | """The entrypoint""" 466 | parser = define_parser() 467 | args = parser.parse_args(cli_args) 468 | logger = logging.getLogger("ptp-reseed") 469 | 470 | logging.basicConfig(level=args.loglevel) 471 | 472 | # Futile attempt to impose our loglevel upon pyroscope 473 | logging.basicConfig(level=args.loglevel) 474 | 475 | # Load PTP API 476 | ptp = ptpapi.login() 477 | 478 | loaded = [] 479 | would_load = [] 480 | could_not_load = [] 481 | not_found = [] 482 | 483 | if args.files in (["-"], []): 484 | filelist = sys.stdin 485 | else: 486 | filelist = args.files 487 | 488 | loaded_paths = [] 489 | 490 | if args.client: 491 | if args.client[0].startswith("file://"): 492 | client = args.client[0] 493 | else: 494 | client = libtc.parse_libtc_url(args.client) 495 | else: 496 | client = None 497 | 498 | for filename in filelist: 499 | match = Match(None) 500 | filename = filename.strip("\n") 501 | 502 | logger.info('Starting reseed attempt on file "{0}"'.format(filename)) 503 | 504 | if not os.path.exists(filename): 505 | logger.error("File/directory {0} does not exist".format(filename)) 506 | continue 507 | 508 | if args.url: 509 | parsed_url = parse_qs(urlparse(args.url).query) 510 | if "torrentid" in parsed_url: 511 | match = match_by_torrent( 512 | ptpapi.Torrent(ID=parsed_url["torrentid"][0]), filename.encode() 513 | ) 514 | if match: 515 | match.path = filename 516 | elif "id" in parsed_url: 517 | match = match_by_movie( 518 | ptpapi.Movie(ID=parsed_url["id"][0]), filename.encode() 519 | ) 520 | elif filename: 521 | for match_type in ptpapi.config.config.get("Reseed", "findBy").split(","): 522 | try: 523 | if match_type == "filename": 524 | if os.path.abspath(filename) in loaded_paths: 525 | logger.info( 526 | "Path {0} already in rtorrent, skipping".format( 527 | os.path.abspath(filename) 528 | ) 529 | ) 530 | else: 531 | logger.debug( 532 | "Path {0} not in rtorrent".format( 533 | os.path.abspath(filename) 534 | ) 535 | ) 536 | match = match_against_file(ptp, filename, args.limit) 537 | elif match_type == "title": 538 | match = match_by_guessed_name(ptp, filename, args.limit) 539 | else: 540 | logger.error( 541 | "Match type {0} not recognized for {1}, skipping".format( 542 | match_type, filename 543 | ) 544 | ) 545 | if match: 546 | break 547 | except Exception: 548 | print("Error while attempting to match file '{0}'".format(filename)) 549 | raise 550 | 551 | # Make sure we have the minimum information required 552 | if not match: 553 | not_found.append(filename) 554 | logger.error( 555 | "Could not find an associated torrent for '%s', cannot reseed: %s", 556 | filename, 557 | match.failure_reason, 558 | ) 559 | continue 560 | 561 | if args.create_in_directory: 562 | create_in = args.create_in_directory 563 | elif ptpapi.config.config.has_option("Reseed", "createInDirectory"): 564 | create_in = ptpapi.config.config.get("Reseed", "createInDirectory") 565 | else: 566 | create_in = None 567 | create_matched_files( 568 | match, directory=create_in, action=args.action, dry_run=args.dry_run 569 | ) 570 | logger.info( 571 | "Found match, now loading torrent {0} to path {1}".format( 572 | match.ID, match.path 573 | ) 574 | ) 575 | match_log_line = ( 576 | f"https://passthepopcorn.me/torrents.php?torrentid={match.ID} -> {filename}" 577 | ) 578 | if args.dry_run: 579 | would_load.append(match_log_line) 580 | logger.debug("Dry-run: Stopping before actual load") 581 | continue 582 | if load_torrent( 583 | match.ID, 584 | Path(match.path), 585 | client, 586 | hash_check=args.hash_check, 587 | overwrite_incomplete=args.overwrite_incomplete, 588 | ): 589 | loaded.append(match_log_line) 590 | else: 591 | could_not_load.append(match_log_line) 592 | 593 | if args.summary: 594 | if loaded: 595 | print("==> Loaded:") 596 | print("\n".join(loaded)) 597 | if would_load: 598 | print("==> Would have loaded:") 599 | print("\n".join(would_load)) 600 | if could_not_load: 601 | print("==> Could not load:") 602 | print("\n".join(could_not_load)) 603 | if not_found: 604 | print("==> Not found:") 605 | print("\n".join(not_found)) 606 | 607 | exit_code = 0 608 | if len(not_found) == 1: 609 | exit_code = 1 610 | elif len(not_found) > 1: 611 | exit_code = 2 612 | elif len(could_not_load) > 0: 613 | exit_code = 3 614 | 615 | logger.debug( 616 | "Total session tokens consumed: %s", ptpapi.session.session.consumed_tokens 617 | ) 618 | logger.debug("Exiting...") 619 | return exit_code 620 | 621 | 622 | def main(): 623 | # Load pyroscope 624 | exit_code = process(sys.argv[1:]) 625 | sys.exit(exit_code) 626 | 627 | 628 | if __name__ == "__main__": 629 | main() 630 | -------------------------------------------------------------------------------- /src/ptpapi/scripts/ptp_reseed_machine.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """The reseed machine runs a scan against prowlarr to find potential 3 | reseeds.""" 4 | import argparse 5 | import json 6 | import logging 7 | 8 | from datetime import datetime 9 | from pathlib import Path 10 | from urllib.parse import parse_qs, urljoin, urlparse 11 | 12 | import requests 13 | 14 | import ptpapi 15 | 16 | from ptpapi.config import config 17 | 18 | 19 | def main(): 20 | parser = argparse.ArgumentParser( 21 | description="Attempt to find torrents to reseed on PTP from other sites" 22 | ) 23 | parser.add_argument("-i", "--id", help="Only full PTP links for now", nargs="*") 24 | parser.add_argument( 25 | "--debug", 26 | help="Print lots of debugging statements", 27 | action="store_const", 28 | dest="loglevel", 29 | const=logging.DEBUG, 30 | default=logging.INFO, 31 | ) 32 | parser.add_argument( 33 | "-q", 34 | "--quiet", 35 | help="Only show error messages", 36 | action="store_const", 37 | dest="loglevel", 38 | const=logging.ERROR, 39 | ) 40 | parser.add_argument( 41 | "-l", 42 | "--limit", 43 | help="Limit need-for-seed results to N movies", 44 | default=100, 45 | type=int, 46 | ) 47 | parser.add_argument( 48 | "-s", "--search", help="Allow filtering the need-for-seed results", default=None 49 | ) 50 | parser.add_argument( 51 | "--target-tracker", 52 | help="Specify the tracker to try and reseed to", 53 | default="PassThePopcorn", 54 | ) 55 | parser.add_argument( 56 | "--history-file", 57 | help="Keep track of previously searched results, and skip duplicate requests", 58 | default=None, 59 | type=Path, 60 | ) 61 | parser.add_argument( 62 | "-r", 63 | "--required-remote-seeds", 64 | help="The number of seeds required on remote torrent", 65 | default=1, 66 | type=int, 67 | ) 68 | parser.add_argument( 69 | "-m", 70 | "--min-ptp-seeds", 71 | help="Set the minimum number of PTP seeds before a reseed attempt will happen", 72 | default=0, 73 | type=int, 74 | ) 75 | parser.add_argument( 76 | "-t", 77 | "--query-type", 78 | help="Set the query type to use (can be specified multiple times)", 79 | default=[], 80 | choices=[ 81 | "imdb", 82 | "title", 83 | "sortTitle", 84 | "sortTitleNoQuotes", 85 | "dotToSpace", 86 | "spaceToDot", 87 | "underscoreToDot", 88 | "underscoreToSpace", 89 | ], 90 | action="append", 91 | ) 92 | args = parser.parse_args() 93 | 94 | logging.basicConfig(level=args.loglevel) 95 | 96 | ptp = ptpapi.login() 97 | 98 | if not args.query_type: 99 | args.query_type = ["imdb", "title"] 100 | history = [] 101 | if args.history_file and args.history_file.exists(): 102 | for line in args.history_file.read_text().split("\n"): 103 | if line: 104 | data = json.loads(line) 105 | if data["search"]: 106 | history.append(data["search"]) 107 | if args.target_tracker == "PassThePopcorn": 108 | if args.id: 109 | torrents = [] 110 | for t in args.id: 111 | if "://passthepopcorn.me" in t: 112 | parsed_url = parse_qs(urlparse(t).query) 113 | torrent_id = int(parsed_url.get("torrentid", ["0"])[0]) 114 | torrents.append(ptpapi.Torrent(ID=torrent_id)) 115 | else: 116 | filters = {} 117 | if args.search: 118 | for arg in args.search.split(","): 119 | filters[arg.split("=")[0]] = arg.split("=")[1] 120 | torrents = ptp.need_for_seed(filters)[: args.limit] 121 | 122 | for t in torrents: 123 | if not any(f"torrentid={t.ID}" in h["infoUrl"] for h in history): 124 | find_match(args, t) 125 | 126 | 127 | # Stolen from https://en.wikibooks.org/wiki/Algorithm_Implementation/Strings/Levenshtein_distance#Python 128 | def levenshtein(s1: str, s2: str): 129 | """Measure the edit distance between two strings""" 130 | if len(s1) < len(s2): 131 | return levenshtein(s2, s1) # pylint: disable=arguments-out-of-order 132 | 133 | if len(s2) == 0: 134 | return len(s1) 135 | 136 | previous_row = list(range(len(s2) + 1)) 137 | for i, c1 in enumerate(s1): 138 | current_row = [i + 1] 139 | for j, c2 in enumerate(s2): 140 | insertions = ( 141 | previous_row[j + 1] + 1 142 | ) # j+1 instead of j since previous_row and current_row are one character longer 143 | deletions = current_row[j] + 1 # than s2 144 | substitutions = previous_row[j] + (c1 != c2) 145 | current_row.append(min(insertions, deletions, substitutions)) 146 | previous_row = current_row 147 | 148 | return previous_row[-1] 149 | 150 | 151 | def sort_title(title: str) -> str: 152 | sortTitle = "" 153 | for c in title: 154 | if c.isalnum(): 155 | sortTitle += c.lower() 156 | else: 157 | sortTitle += " " 158 | splitTitle = sortTitle.split() 159 | # Clean out articles 160 | for s in ["a", "the", "of"]: 161 | try: 162 | splitTitle.remove(s) 163 | except ValueError: 164 | pass 165 | return " ".join(splitTitle) 166 | 167 | 168 | def match_results( 169 | ptp_result: dict, other_result: dict, ignore_tracker: str, title_distance=1 170 | ) -> dict: 171 | logger = logging.getLogger("reseed-machine.match") 172 | percent_diff = 1 173 | # How useful is this check? IMDb IDs can change, or may not be present at all 174 | if ( 175 | "imdbId" in other_result 176 | and other_result["imdbId"] 177 | and ptp_result.get("imdbId", "xxx") != other_result["imdbId"] 178 | ): 179 | logger.debug( 180 | "%s IMDb ID mismatch: %s %s %s", 181 | other_result["protocol"], 182 | other_result["imdbId"], 183 | other_result["indexer"], 184 | other_result["title"], 185 | ) 186 | if other_result["protocol"] == "torrent": 187 | size_diff = round( 188 | abs(((other_result["size"] / ptp_result["size"]) - 1) * 100), 2 189 | ) 190 | if other_result["indexer"] == ignore_tracker: 191 | return {} 192 | if other_result["seeders"] == 0: 193 | logger.debug( 194 | "torrent has no seeders: %s %s", 195 | other_result["indexer"], 196 | other_result["title"], 197 | ) 198 | return {} 199 | if 0 <= size_diff < percent_diff: 200 | logger.info( 201 | "torrent size match: %s %s (%s (%s)), with %.2f%% size diff", 202 | other_result["indexer"], 203 | other_result["title"], 204 | other_result["size"], 205 | bytes_to_human(other_result["size"]), 206 | size_diff, 207 | ) 208 | return other_result 209 | else: 210 | logger.debug( 211 | "torrent size mismatch: %s %s (%s (%s)), with %.2f%% size diff", 212 | other_result["indexer"], 213 | other_result["title"], 214 | other_result["size"], 215 | bytes_to_human(other_result["size"]), 216 | size_diff, 217 | ) 218 | elif other_result["protocol"] == "usenet": 219 | # Usenet sizes vary wildly based on PAR2 levels, 220 | # etc, so size comparisons aren't very useful 221 | if levenshtein(other_result["title"], ptp_result["title"]) <= title_distance: 222 | logger.info( 223 | "usenet title match: %s (%s)", 224 | other_result["indexer"], 225 | other_result["title"], 226 | ) 227 | return other_result 228 | else: 229 | logger.debug( 230 | "usenet title mismatch: %s", 231 | other_result["title"], 232 | ) 233 | # Also check sortTitle if present 234 | if "sortTitle" in ptp_result and "sortTitle" in other_result: 235 | if ( 236 | levenshtein(other_result["sortTitle"], ptp_result["sortTitle"]) 237 | <= title_distance 238 | ): 239 | logger.info( 240 | "usenet sort title match: %s (%s)", 241 | other_result["title"], 242 | other_result["sortTitle"], 243 | ) 244 | return other_result 245 | else: 246 | logger.debug( 247 | "usenet sort title mismatch: %s (%s)", 248 | other_result["title"], 249 | other_result["sortTitle"], 250 | ) 251 | return {} 252 | 253 | 254 | def bytes_to_human(b: float): 255 | for count in ["B", "KiB", "MiB", "GiB"]: 256 | if b < 1024.0: 257 | return "%3.1f %s" % (b, count) 258 | b /= 1024.0 259 | return "%3.1f TiB" % b 260 | 261 | 262 | def find_match(args, torrent): 263 | logger = logging.getLogger("reseed-machine.find") 264 | session = requests.Session() 265 | session.headers.update({"X-Api-Key": config.get("Prowlarr", "api_key")}) 266 | result = {} 267 | imdb_resp = [] # Might be cached for later usage 268 | download = {} 269 | if "imdb" in args.query_type and torrent["Movie"]["ImdbId"]: 270 | imdb_resp = session.get( 271 | urljoin(config.get("Prowlarr", "url"), "api/v1/search"), 272 | params={ 273 | "query": "{ImdbId:" + torrent["Movie"]["ImdbId"] + "}", 274 | "categories": "2000", 275 | "type": "movie", 276 | }, 277 | ).json() 278 | for r in imdb_resp: 279 | if r[ 280 | "indexer" 281 | ] == args.target_tracker and f"torrentid={torrent['Id']}" in r.get( 282 | "infoUrl", "" 283 | ): 284 | result = r 285 | break 286 | if not result: 287 | # Build a result object that resembles what prowlarr would return 288 | result = { 289 | "title": torrent["ReleaseName"], 290 | "size": int(torrent["Size"]), 291 | "indexer": args.target_tracker, 292 | "infoUrl": torrent["Link"], 293 | "sortTitle": sort_title(torrent["ReleaseName"]), 294 | } 295 | if torrent["Movie"]["ImdbId"]: 296 | result.update({"imdbId": torrent["Movie"]["ImdbId"]}) 297 | logger.info( 298 | "Working torrent %s (size %s (%s), sortTitle '%s')", 299 | result["title"], 300 | result["size"], 301 | bytes_to_human(int(result["size"])), 302 | result["sortTitle"], 303 | ) 304 | 305 | queries = { 306 | "title": lambda r: {"query": r["title"]}, 307 | "sortTitle": lambda r: {"query": r["sortTitle"]}, 308 | "sortTitleNoQuotes": lambda r: { 309 | "query": sort_title(r["title"].replace("'", "")) 310 | }, 311 | "dotToSpace": lambda r: {"query": r["title"].replace(".", " ")}, 312 | "underscoreToDot": lambda r: {"query": r["title"].replace("_", ".")}, 313 | "underscoreToSpace": lambda r: {"query": r["title"].replace("_", " ")}, 314 | "spaceToDot": lambda r: {"query": r["title"].replace(" ", ".")}, 315 | } 316 | 317 | # Some indexers return completely irrelevant results when the 318 | # title isn't present. 319 | ignore_title_indexers = [ 320 | i 321 | for i in config.get("ReseedMachine", "ignoreTitleResults", fallback="").split( 322 | "," 323 | ) 324 | if i 325 | ] 326 | 327 | # We already have this result from before, and it'll be empty if 328 | # the imdb query is disabled 329 | for other_result in imdb_resp: 330 | download = match_results(result, other_result, args.target_tracker) 331 | if download: 332 | break 333 | if not download: 334 | for q_type in args.query_type: 335 | if q_type == "imdb": 336 | continue 337 | if download: 338 | break 339 | params = queries[q_type](result) 340 | params.setdefault("type", "search") 341 | params.setdefault("limit", "100") 342 | release_title_resp = session.get( 343 | urljoin(config.get("Prowlarr", "url"), "api/v1/search"), 344 | params=params, 345 | ).json() 346 | for release_result in release_title_resp: 347 | if release_result["indexer"] not in ignore_title_indexers: 348 | download = match_results( 349 | result, release_result, args.target_tracker 350 | ) 351 | if download: 352 | break 353 | if download: 354 | logger.info( 355 | "Downloading %s (%s) from %s", 356 | download["title"], 357 | download["infoUrl"], 358 | download["indexer"], 359 | ) 360 | r = session.post( 361 | urljoin(config.get("Prowlarr", "url"), "api/v1/search"), 362 | json={ 363 | "guid": download["guid"], 364 | "indexerId": download["indexerId"], 365 | }, 366 | ) 367 | r.raise_for_status() 368 | if args.history_file: 369 | with args.history_file.open("ta") as fh: 370 | info_keys = ["title", "infoUrl", "indexer", "imdbId"] 371 | match_info = {} 372 | if download: 373 | match_info = {k: v for k, v in download.items() if k in info_keys} 374 | search_info = {k: v for k, v in result.items() if k in info_keys} 375 | fh.write( 376 | json.dumps( 377 | { 378 | "checked": datetime.now().isoformat(), 379 | "found_match": bool(download), 380 | "match": match_info, 381 | "search": search_info, 382 | } 383 | ) 384 | + "\n" 385 | ) 386 | 387 | 388 | if __name__ == "__main__": 389 | main() 390 | -------------------------------------------------------------------------------- /src/ptpapi/session.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from time import sleep, time 4 | 5 | import requests 6 | 7 | from urllib3.util.retry import Retry 8 | 9 | from .config import config 10 | 11 | 12 | LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | class TokenSession(requests.Session): 16 | """Allows rate-limiting requests to the site""" 17 | 18 | def __init__(self, tokens, fill_rate): 19 | """tokens is the total tokens in the bucket. fill_rate is the 20 | rate in tokens/second that the bucket will be refilled.""" 21 | requests.Session.__init__(self) 22 | self.capacity = float(tokens) 23 | self._tokens = float(tokens) 24 | self.consumed_tokens = 0 25 | self.fill_rate = float(fill_rate) 26 | self.timestamp = time() 27 | 28 | def consume(self, tokens): 29 | """Consume tokens from the bucket. Returns True if there were 30 | sufficient tokens otherwise False.""" 31 | self.get_tokens() 32 | if tokens <= self.tokens: 33 | self._tokens -= tokens 34 | self.consumed_tokens += tokens 35 | LOGGER.debug("Consuming %i token(s)." % tokens) 36 | else: 37 | return False 38 | return True 39 | 40 | def request(self, *args, **kwargs): 41 | while not self.consume(1): 42 | LOGGER.debug("Waiting for token bucket to refill...") 43 | sleep(1) 44 | req = requests.Session.request(self, *args, **kwargs) 45 | return req 46 | 47 | def get_tokens(self): 48 | if self._tokens < self.capacity: 49 | now = time() 50 | delta = self.fill_rate * (now - self.timestamp) 51 | self._tokens = min(self.capacity, self._tokens + delta) 52 | self.timestamp = now 53 | return self._tokens 54 | 55 | tokens = property(get_tokens) 56 | 57 | def base_get(self, url_path, *args, **kwargs): 58 | return self.get(config.get("Main", "baseURL") + url_path, *args, **kwargs) 59 | 60 | def base_post(self, url_path, *args, **kwargs): 61 | return self.post(config.get("Main", "baseURL") + url_path, *args, **kwargs) 62 | 63 | 64 | LOGGER.debug("Initializing token session") 65 | # If you change this and get in trouble, don't blame me 66 | session = TokenSession(3, 0.5) 67 | if config.get("Main", "retry").lower() == "true": 68 | LOGGER.debug("Setting up automatic retry") 69 | retry_config = Retry( 70 | 10, connect=4, status=4, backoff_factor=0.5, status_forcelist=[502] 71 | ) 72 | session.mount("https://", requests.adapters.HTTPAdapter(max_retries=retry_config)) 73 | session.headers.update({"User-Agent": "Wget/1.13.4"}) 74 | -------------------------------------------------------------------------------- /src/ptpapi/torrent.py: -------------------------------------------------------------------------------- 1 | """Represent a single torrent object""" 2 | import html 3 | import logging 4 | import re 5 | 6 | from pathlib import Path 7 | from urllib.parse import parse_qs, urlparse 8 | 9 | import humanize 10 | 11 | from bs4 import BeautifulSoup as bs4 12 | 13 | from ptpapi import movie 14 | from ptpapi.config import config 15 | from ptpapi.error import PTPAPIException 16 | from ptpapi.session import session 17 | from ptpapi.util import title_time_to_json_format 18 | 19 | 20 | LOGGER = logging.getLogger(__name__) 21 | 22 | 23 | class Torrent: 24 | """Represent a single torrent""" 25 | 26 | def __init__(self, ID=None, data=None): 27 | self.key_finder = { 28 | "movie_json": [ 29 | "Checked", 30 | "Codec", 31 | "Container", 32 | "GoldenPopcorn", 33 | "GroupId", 34 | "InfoHash", 35 | "Leechers", 36 | "Quality", 37 | "ReleaseGroup", 38 | "ReleaseName", 39 | "RemasterTitle", 40 | "Resolution", 41 | "Scene", 42 | "Seeders", 43 | "Size", 44 | "Snatched", 45 | "Source", 46 | "UploadTime", 47 | ], 48 | "torrent_json": ["Description", "Nfo"], 49 | "movie_html": [ 50 | "Filelist", 51 | "LastActive", 52 | "LastReseedRequest", 53 | "ReseedWaitingUsers", 54 | "Trumpable", 55 | ], 56 | "inferred": ["Link", "Id", "HumanSize"], 57 | "inferred_size": ["HumanSize"], 58 | "torrent_description": ["BBCodeDescription"], 59 | "parent": [ 60 | "Movie" # Would be 'inferred' if it didn't have a chance to trigger a request 61 | ], 62 | } 63 | 64 | if data: 65 | self.data = data 66 | if "Id" in data: 67 | self.ID = data["Id"] # pylint: disable=invalid-name 68 | elif "TorrentId" in data: 69 | self.ID = data["TorrentId"] 70 | else: 71 | raise PTPAPIException("Could not find torrent ID in data") 72 | elif ID: 73 | self.ID = ID 74 | self.data = {"Id": ID} 75 | else: 76 | raise PTPAPIException("Not enough information to intialize torrent") 77 | 78 | def __repr__(self): 79 | return "" % self.ID 80 | 81 | def __str__(self): 82 | return "" % self.ID 83 | 84 | def __nonzero__(self): 85 | return self.ID is not None 86 | 87 | def __getitem__(self, name): 88 | if name not in self.data or self.data[name] is None: 89 | for k, value in self.key_finder.items(): 90 | if name in value: 91 | getattr(self, "load_%s_data" % k)() 92 | return self.data[name] 93 | 94 | def __setitem__(self, key, value): 95 | self.data[key] = value 96 | 97 | def items(self): 98 | """Passthru for underlying dict""" 99 | return self.data.items() 100 | 101 | def keys(self): 102 | """Passthru for underlying dict""" 103 | return self.data.keys() 104 | 105 | def load_torrent_description_data(self): 106 | self.data["BBCodeDescription"] = html.unescape( 107 | session.base_get( 108 | "torrents.php", params={"id": self.ID, "action": "get_description"} 109 | ).text 110 | ) 111 | 112 | def load_movie_html_data(self): 113 | """Get data from the parent movie's JSON data""" 114 | if "GroupId" not in self.data or not self.data["GroupId"]: 115 | movie_url = session.base_get( 116 | "torrents.php", params={"torrentid": self.ID} 117 | ).url 118 | self.data["GroupId"] = parse_qs(urlparse(movie_url).query)["id"][0] 119 | soup = bs4( 120 | session.base_get( 121 | "torrents.php", params={"id": self.data["GroupId"], "json": 0} 122 | ).content, 123 | "html.parser", 124 | ) 125 | # Scrape file list 126 | filediv = soup.find("div", id="files_%s" % self.ID) 127 | self.data["Filelist"] = {} 128 | for elem in filediv.find("tbody").find_all("tr"): 129 | bytesize = ( 130 | elem("td")[1]("span")[0]["title"].replace(",", "").replace(" bytes", "") 131 | ) 132 | self.data["Filelist"][elem("td")[0].string] = bytesize 133 | # Check if trumpable 134 | if soup.find("trumpable_%s" % self.ID): 135 | self.data["Trumpable"] = [ 136 | s.get_text() for s in soup.find("trumpable_%s" % self.ID).find("span") 137 | ] 138 | else: 139 | self.data["Trumpable"] = [] 140 | # Get reseed information 141 | tor_row = soup.find("tr", id="torrent_%s" % self.ID) 142 | self.data.update( 143 | {"LastActive": "", "LastReseedRequest": "", "ReseedWaitingUsers": ""} 144 | ) 145 | for elem in tor_row.find_all(class_="torrent_quick_edit_ignore"): 146 | if "Last active:" in str(elem): 147 | self.data["LastActive"] = title_time_to_json_format(elem.span["title"]) 148 | if "Last re-seed request sent" in str(elem): 149 | self.data["LastReseedRequest"] = title_time_to_json_format( 150 | elem.span["title"] 151 | ) 152 | self.data["ReseedWaitingUsers"] = re.search( 153 | r"(\d+) user", str(elem) 154 | ).group(1) 155 | 156 | def load_movie_json_data(self): 157 | """Load data from the movie page""" 158 | LOGGER.debug("Loading Torrent data from movie JSON page.") 159 | if "GroupId" not in self.data or not self.data["GroupId"]: 160 | movie_url = session.base_get( 161 | "torrents.php", params={"torrentid": self.ID} 162 | ).url 163 | self.data["GroupId"] = re.search(r"\?id=(\d+)", movie_url).group(1) 164 | movie_data = session.base_get( 165 | "torrents.php", 166 | params={"torrentid": self.ID, "id": self.data["GroupId"], "json": "1"}, 167 | ).json() 168 | for tor in movie_data["Torrents"]: 169 | if int(tor["Id"]) == int(self.ID): 170 | # Fill in any optional fields 171 | for key in ["RemasterTitle"]: 172 | if key not in self.data: 173 | self.data[key] = "" 174 | self.data.update(tor) 175 | break 176 | 177 | def load_inferred_data(self): 178 | self.data["Id"] = self.ID 179 | self.data["Link"] = "https://passthepopcorn.me/torrents.php?torrentid=" + str( 180 | self.ID 181 | ) 182 | 183 | def load_inferred_size_data(self): 184 | self.data["HumanSize"] = humanize.naturalsize( 185 | int(self.data["Size"]), binary=True 186 | ) 187 | 188 | def load_parent_data(self): 189 | self.data["Movie"] = movie.Movie(ID=self["GroupId"]) 190 | 191 | def load_torrent_json_data(self): 192 | """Load torrent data from a JSON call""" 193 | LOGGER.debug("Loading Torrent data from torrent JSON page.") 194 | if "GroupId" not in self.data or not self.data["GroupId"]: 195 | movie_url = session.base_get( 196 | "torrents.php", params={"torrentid": self.ID} 197 | ).url 198 | self.data["GroupId"] = re.search(r"\?id=(\d+)", movie_url).group(1) 199 | self.data.update( 200 | session.base_get( 201 | "torrents.php", 202 | params={ 203 | "action": "description", 204 | "id": self.data["GroupId"], 205 | "torrentid": self.ID, 206 | }, 207 | ).json() 208 | ) 209 | if "Nfo" in self.data and self.data["Nfo"]: 210 | self.data["Nfo"] = html.unescape(self.data["Nfo"]) 211 | 212 | def download(self, params=None): 213 | """Download the torrent contents""" 214 | if params is None: 215 | params = {} 216 | req_params = params.copy() 217 | req_params.update({"action": "download", "id": self.ID}) 218 | req = session.base_get("torrents.php", params=req_params) 219 | return req.content 220 | 221 | def download_to_dir(self, dest=None, params=None): 222 | """Convenience method to download directly to a directory""" 223 | if params is None: 224 | params = {} 225 | req_params = params.copy() 226 | req_params.update({"action": "download", "id": self.ID}) 227 | req = session.base_get("torrents.php", params=req_params) 228 | if not dest: 229 | dest = Path(config.get("Main", "downloadDirectory")) 230 | name = re.search(r'filename="(.*)"', req.headers["Content-Disposition"]).group( 231 | 1 232 | ) 233 | dest = Path(dest, name) 234 | with dest.open("wb") as fileh: 235 | fileh.write(req.content) 236 | return dest 237 | -------------------------------------------------------------------------------- /src/ptpapi/user.py: -------------------------------------------------------------------------------- 1 | """Represent a user""" 2 | import re 3 | 4 | from bs4 import BeautifulSoup as bs4 # pylint: disable=import-error 5 | 6 | from .movie import Movie 7 | from .session import session 8 | from .util import human_to_bytes, snarf_cover_view_data 9 | 10 | 11 | class User: 12 | """A primitive class to represent a user""" 13 | 14 | def __init__(self, ID): 15 | # Requires an ID, as searching by name isn't exact on PTP 16 | self.ID = ID # pylint: disable=invalid-name 17 | 18 | def __repr__(self): 19 | return self.__str__() 20 | 21 | def __str__(self): 22 | return "" % self.ID 23 | 24 | def search(self, search_type, filters=None): 25 | if filters is None: 26 | filters = {} 27 | filters["type"] = search_type 28 | filters["userid"] = str(self.ID) 29 | req = session.base_get("torrents.php", params=filters) 30 | return [Movie(data=m) for m in snarf_cover_view_data(req.content)] 31 | 32 | def bookmarks(self, search_terms=None): 33 | """Fetch a list of movies the user has bookmarked 34 | 35 | :rtype: array of Movies""" 36 | search_terms = search_terms or {} 37 | search_terms.update({"userid": self.ID}) 38 | req = session.base_get("bookmarks.php", params=search_terms) 39 | movies = [] 40 | for movie in snarf_cover_view_data(req.content): 41 | movies.append(Movie(data=movie)) 42 | return movies 43 | 44 | def ratings(self): 45 | """Fetch a list of rated movies 46 | 47 | :rtype: array of tuples with a Movie and a rating out of 100""" 48 | soup = bs4( 49 | session.base_get( 50 | "user.php", params={"id": self.ID, "action": "ratings"} 51 | ).text, 52 | "html.parser", 53 | ) 54 | ratings = [] 55 | for row in soup.find(id="ratings_table").tbody.find_all("tr"): 56 | movie_id = re.search(r"id=(\d+)", row.find(class_="l_movie")["href"]).group( 57 | 1 58 | ) 59 | rating = row.find(id="user_rating_%s" % movie_id).text.rstrip("%") 60 | ratings.append((movie_id, rating)) 61 | return ratings 62 | 63 | def __parse_stat(self, stat_line): 64 | stat, _, value = stat_line.partition(":") 65 | stat = stat.title().replace(" ", "").strip() 66 | value = re.sub(r"\t.*", "", value).replace("[View]", "").strip() 67 | return stat, value 68 | 69 | def stats(self): 70 | """ 71 | Return all stats associated with a user 72 | 73 | :rtype: A dictionary of stat names and their values, both in string format. 74 | """ 75 | soup = bs4( 76 | session.base_get("user.php", params={"id": self.ID}).text, "html.parser" 77 | ) 78 | stats = {} 79 | for li in soup.find("span", text="Stats").parent.parent.find_all("li"): 80 | stat, value = self.__parse_stat(li.text) 81 | stats[stat] = value 82 | for li in soup.find("span", text="Personal").parent.parent.find_all("li"): 83 | stat, value = self.__parse_stat(li.text) 84 | if value: 85 | stats[stat] = value 86 | for li in soup.find("span", text="Community").parent.parent.find_all("li"): 87 | stat, value = self.__parse_stat(li.text) 88 | if stat == "Uploaded": 89 | match = re.search(r"(.*) \((.*)\)", value) 90 | stats["UploadedTorrentsWithDeleted"] = match.group(1) 91 | value = match.group(2) 92 | stat = "UploadedTorrents" 93 | elif stat == "Downloaded": 94 | stat = "DownloadedTorrents" 95 | elif stat == "SnatchesFromUploads": 96 | match = re.search(r"(.*) \((.*)\)", value) 97 | stats["SnatchesFromUploadsWithDeleted"] = match.group(1) 98 | value = match.group(2) 99 | elif stat == "AverageSeedTime(Active)": 100 | stat = "AverageSeedTimeActive" 101 | stats[stat] = value 102 | return stats 103 | 104 | 105 | class CurrentUser(User): 106 | """Defines some additional methods that only apply to the logged in user.""" 107 | 108 | def __init__(self, ID): 109 | self.new_messages = 0 110 | super().__init__(ID) 111 | 112 | def archive_container(self, ID): 113 | """Fetch info about a containers from the archive project 114 | 115 | :returns: A list of dictionaries""" 116 | torrents = [] 117 | params = {"action": "container", "UserID": self.ID, "containerid": ID} 118 | soup = bs4(session.base_get("archive.php", params=params).text, "html.parser") 119 | headers = [ 120 | h.text 121 | for h in soup.find(class_="table").find("thead").find("tr").find_all("th") 122 | ] 123 | rows = soup.find(class_="table").find("tbody").find_all("tr") 124 | for r in rows: 125 | # Get as much info as possible from the pages 126 | row_dict = dict(zip(headers, [f.text for f in r.find_all("td")])) 127 | # Also add in a Torrent object for creating torrent objects 128 | if "Torrent Deleted" not in row_dict["Torrent"]: 129 | row_dict["TorrentId"] = re.search( 130 | r"torrentid=([0-9]*)", r.find_all("td")[0].find("a")["href"] 131 | ).group(1) 132 | return torrents 133 | 134 | def archive_containers(self): 135 | """Fetch a list of containers from the archive project 136 | 137 | :returns: A list of dictionaries""" 138 | containers = [] 139 | soup = bs4(session.base_get("archive.php").text, "html.parser") 140 | for row in soup.find(class_="table").find("tbody").find_all("tr"): 141 | cont = { 142 | "name": row[0].text, 143 | "link": row[0].find("a")["href"], 144 | "size": human_to_bytes(row[1].text), 145 | "max_size": human_to_bytes(row[2].text), 146 | "last_fetch": row[3].text, # TODO: convert this to actual time object 147 | } 148 | for field in row: 149 | if field.find("label"): 150 | l = field.find("label") 151 | cont[l["title"].lower()] = int(l.text.replace(",", "")) 152 | containers.append(cont) 153 | return containers 154 | 155 | def __parse_new_messages(self, soup): 156 | """Parse the number of messages from a soup of html""" 157 | msgs = 0 158 | if soup.find(class_="alert-bar"): 159 | for alert in soup.find(class_="alert-bar"): 160 | match = re.search(r"You have (\d+) new message", alert.text) 161 | if match: 162 | msgs = match.group(1) 163 | return msgs 164 | 165 | def get_new_messages(self): 166 | """Update the number of messages""" 167 | soup = bs4(session.base_get("inbox.php").text, "html.parser") 168 | self.new_messages = self.__parse_new_messages(soup) 169 | return self.new_messages 170 | 171 | def inbox(self, page=1): 172 | """Fetch a list of messages from the user's inbox 173 | Incidentally update the number of messages""" 174 | soup = bs4( 175 | session.base_get("inbox.php", params={"page": page}).text, "html.parser" 176 | ) 177 | 178 | self.new_messages = self.__parse_new_messages(soup) 179 | 180 | for row in soup.find(id="messageformtable").tbody.find_all("tr"): 181 | yield { 182 | "Subject": row.find_all("td")[1].text.encode("UTF-8").strip(), 183 | "Sender": row.find_all("td")[2].text, 184 | "Date": row.find_all("td")[3].span["title"], 185 | "ID": re.search(r"id=(\d+)", row.find_all("td")[1].a["href"]).group(1), 186 | "Unread": bool("inbox-message--unread" in row["class"]), 187 | } 188 | 189 | def inbox_conv(self, conv_id, raw=False): 190 | """Get a specific conversation from the inbox""" 191 | soup = bs4( 192 | session.base_get( 193 | "inbox.php", params={"action": "viewconv", "id": conv_id} 194 | ).text, 195 | "html.parser", 196 | ) 197 | messages = [] 198 | for msg in soup.find_all("div", id=re.compile("^message"), class_="forum-post"): 199 | message = {} 200 | if raw: 201 | message["Text"] = msg.find("div", class_="forum-post__body") 202 | else: 203 | message["Text"] = msg.find( 204 | "div", class_="forum-post__body" 205 | ).text.strip() 206 | username = msg.find("strong").find("a", class_="username") 207 | if username is None: 208 | message["User"] = "System" 209 | else: 210 | message["User"] = username.text.strip() 211 | message["Time"] = msg.find("span", class_="time").text.strip() 212 | messages.append(message) 213 | return { 214 | "Subject": soup.find("h2", class_="page__title").text, 215 | "Message": messages, 216 | } 217 | 218 | def remove_snatched_bookmarks(self): 219 | """Remove snatched bookmarks""" 220 | session.base_post("bookmarks.php", data={"action": "remove_snatched"}) 221 | 222 | def remove_seen_bookmarks(self): 223 | """Remove seen bookmarks""" 224 | session.base_post("bookmarks.php", data={"action": "remove_seen"}) 225 | 226 | def remove_uploaded_bookmarks(self): 227 | """Remove uploads bookmarks""" 228 | session.base_post("bookmarks.php", data={"action": "remove_uploaded"}) 229 | 230 | def hnr_zip(self): 231 | """Download the zip file of all HnRs""" 232 | zip_file = session.base_get("snatchlist.php", params={"action": "hnrzip"}) 233 | if zip_file.headers["Content-Type"] == "application/zip": 234 | return zip_file 235 | else: 236 | return None 237 | -------------------------------------------------------------------------------- /src/ptpapi/util.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import html 3 | import json 4 | import math 5 | import re 6 | import urllib 7 | 8 | from bs4 import BeautifulSoup as bs4 9 | 10 | from ptpapi.error import PTPAPIException 11 | 12 | 13 | def raise_for_cloudflare(text): 14 | """Raises an exception if a CloudFlare error page is detected 15 | 16 | :param text: a raw html string""" 17 | soup = bs4(text, "html.parser") 18 | if soup.find(class_="cf-error-overview") is not None: 19 | msg = "-".join(soup.find(class_="cf-error-overview").get_text().splitlines()) 20 | raise PTPAPIException("Encountered Cloudflare error page: ", msg) 21 | 22 | 23 | def sizeof_fmt(num, suffix="B"): 24 | for unit in ["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"]: 25 | if abs(num) < 1024.0: 26 | return "%3.1f%s%s" % (num, unit, suffix) 27 | num /= 1024.0 28 | return "%.1f%s%s" % (num, "Yi", suffix) 29 | 30 | 31 | # Adapted from https://gist.github.com/leepro/9694638 32 | 33 | SYMBOLS = { 34 | "customary": ("B", "K", "M", "G", "T", "P", "E", "Z", "Y"), 35 | "customary_ext": ( 36 | "byte", 37 | "kilo", 38 | "mega", 39 | "giga", 40 | "tera", 41 | "peta", 42 | "exa", 43 | "zetta", 44 | "iotta", 45 | ), 46 | "iec": ("Bi", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi", "Yi"), 47 | "iec_b": ("BiB", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"), 48 | "iec_ext": ("byte", "kibi", "mebi", "gibi", "tebi", "pebi", "exbi", "zebi", "yobi"), 49 | } 50 | 51 | 52 | def human_to_bytes(s, case_sensitive=True): 53 | """ 54 | Attempts to guess the string format based on default symbols 55 | set and return the corresponding bytes as an integer. 56 | When unable to recognize the format ValueError is raised. 57 | 58 | >>> human2bytes('0 B') 59 | 0 60 | >>> human2bytes('1 K') 61 | 1024 62 | >>> human2bytes('1 M') 63 | 1048576 64 | >>> human2bytes('1 Gi') 65 | 1073741824 66 | >>> human2bytes('1 tera') 67 | 1099511627776 68 | 69 | >>> human2bytes('0.5kilo') 70 | 512 71 | >>> human2bytes('0.1 byte') 72 | 0 73 | >>> human2bytes('1 k') # k is an alias for K 74 | 1024 75 | >>> human2bytes('12 foo') 76 | Traceback (most recent call last): 77 | ... 78 | ValueError: can't interpret '12 foo' 79 | """ 80 | try: 81 | return int(s) 82 | except ValueError: 83 | pass 84 | s = s.replace(",", "") 85 | init = s 86 | num = "" 87 | while s and s[0:1].isdigit() or s[0:1] == ".": 88 | num += s[0] 89 | s = s[1:] 90 | num = float(num) 91 | letter = s.strip() 92 | for _, sset in SYMBOLS.items(): 93 | if letter in sset: 94 | break 95 | if not case_sensitive and letter.lower() in [s.lower() for s in sset]: 96 | break 97 | else: 98 | if letter == "k": 99 | # treat 'k' as an alias for 'K' as per: http://goo.gl/kTQMs 100 | sset = SYMBOLS["customary"] 101 | letter = letter.upper() 102 | else: 103 | raise ValueError("can't interpret %r" % init) 104 | prefix = {sset[0]: 1} 105 | for i, sval in enumerate(sset[1:]): 106 | if case_sensitive: 107 | prefix[sval] = 1 << (i + 1) * 10 108 | else: 109 | prefix[sval.lower()] = 1 << (i + 1) * 10 110 | letter = letter.lower() 111 | return int(num * prefix[letter]) 112 | 113 | 114 | def snarf_cover_view_data(text, key=rb"coverViewJsonData\[\s*\d+\s*\]"): 115 | """Grab cover view data directly from an html source 116 | and parse out any relevant infomation we can 117 | 118 | :param text: a raw html string 119 | :rtype: a dictionary of movie data""" 120 | data = [] 121 | for json_data in re.finditer(key + rb"\s*=\s*({.*});", text, flags=re.DOTALL): 122 | data.extend(json.loads(json_data.group(1).decode())["Movies"]) 123 | for movie in data: 124 | movie["Title"] = html.unescape(movie["Title"]) 125 | movie["Torrents"] = [] 126 | for group in movie["GroupingQualities"]: 127 | for torrent in group["Torrents"]: 128 | soup = bs4(torrent["Title"], "html.parser") 129 | if len(soup.a.text.split("/")) < 4: 130 | continue 131 | ( 132 | torrent["Codec"], 133 | torrent["Container"], 134 | torrent["Source"], 135 | torrent["Resolution"], 136 | ) = [item.strip() for item in soup.a.text.split("/")[0:4]] 137 | if soup.contents[0].string is not None: 138 | torrent["GoldenPopcorn"] = ( 139 | soup.contents[0].string.strip(" ") == "\u10047" 140 | ) # 10047 = Unicode GP symbol 141 | if not soup.a.has_attr("title"): 142 | continue 143 | torrent["ReleaseName"] = soup.a["title"].split("\n")[-1] 144 | match = re.search( 145 | r"torrents.php\?id=(\d+)&torrentid=(\d+)", soup.a["href"] 146 | ) 147 | torrent["Id"] = match.group(2) 148 | movie["Torrents"].append(torrent) 149 | return data 150 | 151 | 152 | def find_page_range(text) -> int: 153 | """From a full HTML page, try to find the number of available 154 | pages.""" 155 | # Try loading as a big JSON 156 | try: 157 | data = json.loads(text) 158 | return math.ceil(int(data["TotalResults"]) / len(data["Movies"])) 159 | except json.decoder.JSONDecodeError: 160 | pass 161 | # Try parsing pagination infromation from HTML 162 | soup = bs4(text, "html.parser") 163 | url = soup.select("a.pagination__link--last")[0]["href"] 164 | qs = urllib.parse.parse_qs(urllib.parse.urlparse(url).query) 165 | return int(qs["page"][0]) 166 | 167 | 168 | def title_time_to_json_format(timestr: str) -> str: 169 | """Massage to match JSON output""" 170 | return datetime.datetime.strptime(timestr, "%b %d %Y, %H:%M").strftime( 171 | "%Y-%m-%d %H:%M:%S" 172 | ) 173 | --------------------------------------------------------------------------------