├── .dir-locals.el ├── .github └── workflows │ └── docker.yml ├── .gitignore ├── CHANGELOG.md ├── CREDITS ├── Dockerfile ├── INSTALL.md ├── PtpUploaderTorrentSender.user.js ├── README.md ├── TODO ├── poetry.lock ├── pyproject.toml └── src ├── FlexGet └── config.example.yml ├── PtpUploader ├── Helper.py ├── ImageHost │ ├── Base.py │ ├── Catbox.py │ ├── ImageUploader.py │ ├── ImgBB.py │ ├── PtpImg.py │ └── __init__.py ├── InformationSource │ ├── Imdb.py │ ├── MoviePoster.py │ └── __init__.py ├── Job │ ├── CheckAnnouncement.py │ ├── Delete.py │ ├── FinishedJobPhase.py │ ├── JobRunningState.py │ ├── JobStartMode.py │ ├── LoadFile.py │ ├── Supervisor.py │ ├── Upload.py │ ├── WorkerBase.py │ └── __init__.py ├── MyGlobals.py ├── Notifier.example.ini ├── Notifier.py ├── Ptp.py ├── PtpImdbInfo.py ├── PtpMovieSearchResult.py ├── PtpUploaderException.py ├── PtpUploaderMessage.py ├── ReleaseDescriptionFormatter.py ├── ReleaseInfo.py ├── ReleaseInfoMaker.py ├── ReleaseNameParser.py ├── SceneGroups.txt ├── Settings.py ├── Source │ ├── Cinemageddon.py │ ├── File.py │ ├── Karagarga.py │ ├── Prowlarr.py │ ├── SourceBase.py │ ├── SourceFactory.py │ ├── Torrent.py │ └── __init__.py ├── Tool │ ├── BdInfo.py │ ├── Ffmpeg.py │ ├── ImageMagick.py │ ├── LibMpv.py │ ├── MediaInfo.py │ ├── Mktor.py │ ├── Mplayer.py │ ├── Mpv.py │ ├── Oxipng.py │ ├── Rtorrent.py │ ├── ScreenshotMaker.py │ ├── Transmission.py │ ├── Unrar.py │ └── __init__.py ├── __init__.py ├── config.default.yml ├── manage.py ├── nfo_parser.py ├── ptp_subtitle.py ├── release_extractor.py └── web │ ├── __init__.py │ ├── forms.py │ ├── management │ └── commands │ │ ├── __init__.py │ │ └── runuploader.py │ ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20211025_0320.py │ ├── 0003_alter_releaseinfo_jobrunningstate.py │ ├── 0004_auto_20211028_2254.py │ ├── 0005_auto_20211029_0135.py │ ├── 0006_auto_20211029_2024.py │ ├── 0007_alter_releaseinfo_trumpable.py │ ├── 0008_alter_releaseinfo_subtitles.py │ ├── 0009_alter_releaseinfo_trumpable.py │ ├── 0010_alter_releaseinfo_screenshots.py │ ├── 0011_alter_releaseinfo_screenshots.py │ ├── 0012_alter_releaseinfo_screenshots.py │ ├── 0013_auto_20211104_0344.py │ ├── 0014_rename_scheduletimeutc_releaseinfo_scheduletime.py │ ├── 0015_alter_releaseinfo_scheduletime.py │ ├── 0016_releaseinfo_bdinfo.py │ ├── 0017_releaseinfo_includedfilelist.py │ └── __init__.py │ ├── models.py │ ├── settings.py │ ├── static │ ├── delete.png │ ├── edit.png │ ├── error.png │ ├── favicon.png │ ├── film.png │ ├── hourglass.png │ ├── normalize.css │ ├── pause.png │ ├── ptp.ico │ ├── sad.png │ ├── scheduled.png │ ├── script │ │ ├── bulma.min.css │ │ ├── datatables.min.css │ │ ├── datatables.min.js │ │ ├── fontawesome.all.min.js │ │ ├── jquery-3.6.0.min.js │ │ ├── jquery-confirm.min.css │ │ ├── jquery-confirm.min.js │ │ ├── jquery-ui.min.js │ │ ├── jquery.contextMenu.min.css │ │ ├── jquery.contextMenu.min.js │ │ ├── jquery.fancytree-all-deps.min.js │ │ ├── jquery.ui.position.min.js │ │ ├── select2.min.css │ │ ├── select2.min.js │ │ └── ui.fancytree.min.css │ ├── skin-win8 │ │ ├── icons.gif │ │ └── loading.gif │ ├── source.sh │ ├── source_icon │ │ ├── ar.ico │ │ ├── cg.ico │ │ ├── dh.ico │ │ ├── file.ico │ │ ├── gft.ico │ │ ├── hdbits.ico │ │ ├── kg.ico │ │ ├── prowlarr.ico │ │ ├── rtt.ico │ │ ├── tby.ico │ │ ├── tds.ico │ │ ├── tik.ico │ │ ├── tl.ico │ │ └── torrent.ico │ ├── start.png │ ├── stop.png │ ├── style.css │ ├── success.png │ ├── throbber.gif │ └── warning.png │ ├── templates │ ├── bulk.html │ ├── edit_job.html │ ├── index.html │ ├── jobs.html │ ├── layout.html │ ├── movieAvailabilityCheck.html │ └── settings.html │ ├── urls.py │ ├── views.py │ └── wsgi.py └── autodl-irssi └── autodl.example.cfg /.dir-locals.el: -------------------------------------------------------------------------------- 1 | ((web-mode (eval . (web-mode-set-engine "django")))) 2 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Docker push 3 | "on": 4 | push: 5 | paths-ignore: 6 | - 'docs/**' 7 | branches: 8 | - 'main' 9 | tags: 10 | - 'v[0-9]+.[0-9]+.[0-9]+' 11 | jobs: 12 | docker: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | - name: Docker meta 18 | id: meta 19 | uses: docker/metadata-action@v4 20 | with: 21 | images: | 22 | kannibalox/pyrosimple 23 | tags: | 24 | type=raw,value=latest,enable={{is_default_branch}} 25 | type=semver,pattern={{version}} 26 | type=semver,pattern={{major}}.{{minor}} 27 | type=semver,pattern={{major}} 28 | type=sha 29 | - name: Set up Docker Buildx 30 | uses: docker/setup-buildx-action@v2 31 | - name: Login to DockerHub 32 | uses: docker/login-action@v2 33 | with: 34 | username: ${{ secrets.DOCKERHUB_USERNAME }} 35 | password: ${{ secrets.DOCKERHUB_TOKEN }} 36 | - name: Build and push 37 | uses: docker/build-push-action@v4 38 | with: 39 | context: . 40 | push: true 41 | tags: ${{ steps.meta.outputs.tags }} 42 | labels: ${{ steps.meta.outputs.labels }} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | src/FlexGet/config.yml 2 | src/PtpUploader/Settings.ini 3 | src/db.sqlite3 4 | *~ 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | 34 | # Unit test / coverage reports 35 | htmlcov/ 36 | .tox/ 37 | .coverage 38 | .cache 39 | nosetests.xml 40 | coverage.xml 41 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [0.12.2] 2 | ### Fixed 3 | - Fix mediainfo error by ensuring IncludedFileList is set 4 | 5 | ## [0.12.1] 6 | ### Fixed 7 | - Allow loading .torrent files from the announce directory 8 | 9 | ## [0.12.0] 10 | ### Changed 11 | - Allow selecting files manually 12 | ### Fixed 13 | - Respect the ignore_files config setting properly 14 | 15 | ## [0.11.3] 16 | ### Changed 17 | - Due to new restrictions for pypi, the cinemagoer package now uses 18 | the newest version uploaded on pypi, as opposed to installing 19 | directly from git (the recommended way to install by the 20 | project). To use the recommended installation method, run `pip 21 | install git+https://github.com/cinemagoer/cinemagoer` manually 22 | 23 | ### Fixed 24 | - Check if PNGs are 16-bit and downgrade the depth when imagemagick is 25 | present. 26 | - Bypass "multiple" attribute exception in Django. 27 | - Disable scene check by default 28 | 29 | ### Added 30 | - Malay subtitle option 31 | 32 | ## [0.11.2] 33 | ### Fixed 34 | - Typo in scene result check 35 | 36 | ## [0.11.1] 37 | ### Fixed 38 | - Ignore missing result during srrdb.com check 39 | - Update objects being passed to pyrosimple 40 | 41 | ## [0.11.0] 42 | ### Added 43 | - Experimental libmpv screenshot tool. This is primarily to help with 44 | keyframe-reliant codecs like VC1 that would otherwise produce grey 45 | screenshots, but has not been vetted as well as the mpv 46 | CLI. Requires optional dependencies: `pip install pillow mpv`. 47 | ### Fixed 48 | - Better blu-ray support 49 | ### Changed 50 | - ReleaseInfoMaker improvements 51 | 52 | ## [0.10.2] 53 | ### Fixed 54 | - Config option to disable recursive deletes 55 | - Remove accented characters from CG folders 56 | - Use IFO to detect DVD subtitles when possible 57 | 58 | ## [0.10.1] 59 | ### Fixed 60 | - Properly ignore PTP directory when extracting releases from directories 61 | - Two bugs from refactoring/linting efforts (#27, #28) 62 | 63 | ## [0.10.0] - 2022-06-04 64 | ### Changed 65 | - Update CSS/JS dependencies 66 | - Move blu-ray support out of experimental support 67 | ### Fixed 68 | - Improve CG failed login handling 69 | - Improve x264/x265 detection from filenames 70 | - Auto-create VIDEO_TS folder if not present 71 | 72 | ## [0.9.1] - 2022-05-20 73 | ### Added 74 | - 2FA support 75 | -------------------------------------------------------------------------------- /CREDITS: -------------------------------------------------------------------------------- 1 | Code: 2 | - TnS 3 | 4 | Contributors: 5 | - cerbere (HDBits support) 6 | - dhosha (FunFile, RevolutionTT support, PtpUploaderTorrentSender sites) 7 | - kannibalox (configurable initial directory for the file picker) 8 | - Mako_1 (HD-Torrents and TorrentShack support, CloudFlare verification) 9 | - verysofttoiletpaper (Transmission support) 10 | 11 | Additional code: 12 | - cloudflare-scrape by Anorov -- https://github.com/Anorov/cloudflare-scrape 13 | - jQuery -- http://jquery.org/ 14 | - jQuery BlockUI by M. Alsup -- http://malsup.com/jquery/block/ 15 | - jQuery contextMenu by Rodney Rehm, Christian Baartse & Addy Osmani -- http://medialize.github.com/jQuery-contextMenu/ 16 | - jQuery File Tree by Cory LaViska -- http://abeautifulsite.net/blog/2008/04/jquery-multiselect/ 17 | - jQuery-File-Upload by Sebastian Tschan -- https://github.com/blueimp/jQuery-File-Upload 18 | - jQuery MultiSelect by Cory LaViska -- http://abeautifulsite.net/blog/2008/04/jquery-multiselect/ 19 | - dynatree by Martin Wendt -- http://code.google.com/p/dynatree/ 20 | 21 | GFX: 22 | - Site favicons made by the creators of the site 23 | - delete.png -- http://momentumdesignlab.com/ 24 | - via http://findicons.com/icon/263490/delete 25 | - edit.png -- http://momentumdesignlab.com/ 26 | - via http://findicons.com/icon/263403/document_edit?id=348522 27 | - error.png -- http://splashyfish.com/ 28 | - via http://findicons.com/icon/181827/error 29 | - file.ico -- http://momentumdesignlab.com/ 30 | - via http://findicons.com/icon/261295/folder 31 | - hourglass.png -- http://www.famfamfam.com/ 32 | - via http://findicons.com/icon/159536/hourglass?id=396850# 33 | - pause.png -- http://www.visualpharm.com/ 34 | - via http://findicons.com/icon/51049/pause?id=407904 35 | - play.png -- http://momentumdesignlab.com/ 36 | - via http://findicons.com/icon/263312/play?id=263312 37 | - sad.png -- http://www.fasticon.com/ 38 | - via http://findicons.com/icon/38340/sad 39 | - schedule.png -- Yusuke Kamiyamane, http://p.yusukekamiyamane.com/ 40 | - stop.png -- http://www.zeusboxstudio.com/ 41 | - via http://findicons.com/icon/124656/stop?id=394525 42 | - success.png -- http://www.visualpharm.com/ 43 | - via http://findicons.com/icon/51032/check 44 | - throbber.gif -- http://www.ajaxload.info/ 45 | - torrent.ico -- http://tidav.deviantart.com/ 46 | - via http://findicons.com/icon/89009/utorrent 47 | - warning.png -- http://www.starfishwebconsulting.co.uk/ 48 | - via http://findicons.com/icon/184035/alert 49 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10 2 | 3 | RUN apt-get update \ 4 | && apt-get install -y --no-install-recommends \ 5 | postgresql-client sqlite3 mariadb-client mpv imagemagick mediainfo \ 6 | && rm -rf /var/lib/apt/lists/* 7 | 8 | # Optional dependencies 9 | RUN pip install --no-cache-dir psycopg2 transmissionrpc 10 | 11 | WORKDIR /usr/src/app 12 | 13 | ENV PTPUP_WORKDIR /data 14 | 15 | EXPOSE 8000 16 | 17 | COPY . . 18 | 19 | RUN pip install --no-cache-dir . 20 | CMD [ "bash", "-c", "PtpUploader runuploader 0.0.0.0:8000"] 21 | -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | # Installation details 2 | 3 | PtpUploader needs Python 3.7+, as well as a couple other programs. 4 | The docker image contains all these images by default, and most distrubutions 5 | provide easy-to-install packages. 6 | 7 | Required external programs: 8 | - rTorrent or transmission 9 | - if transmission is being used, the `transmissionrpc` package must be installed 10 | - One of: mpv (preferred), ffmpeg, mplayer 11 | - Mediainfo 12 | 13 | Optional external programs: 14 | - ImageMagick: Highly recommended for losslessly compressing PNG screenshots 15 | - FlexGet: Can be used to write announce files 16 | - autodl-irssi: Can be used to write announce files 17 | 18 | ## Upgrading 19 | 20 | ### Pip 21 | 22 | ```bash 23 | pip install -U PtpUploader 24 | ``` 25 | 26 | ### Docker 27 | 28 | When stopping the daemon in docker, be sure to fully remove the existing container: 29 | ```bash 30 | sudo docker rm ptpuploader 31 | ``` 32 | 33 | ```bash 34 | cd PtpUploader 35 | git pull 36 | # Re-do installation steps 37 | ``` 38 | 39 | # Usage 40 | 41 | ## Uploading 42 | 43 | To start your first upload, go to http://localhost:8000/upload, and use the form to fill out as much 44 | information as possible about your upload. One of the first three fields (link, local path, or file) 45 | must be filled out, but anything else can be left blank, and PtpUploader will alert you if anything 46 | is missing. 47 | 48 | Once the job is submitted, it can viewed on the jobs page at http://localhost:8000/jobs. 49 | 50 | ## Automatic announcing 51 | 52 | PtpUploader has the ability to receive jobs from files placed in the announce folder 53 | (`$WORK_DIR/announce/`). These files are JSON formatted, and allow for customizing the submitted 54 | job with some additional information. An example of using flexget and autodl-irssi to create these 55 | files is available in the repo. 56 | 57 | # Configuration 58 | 59 | ## Configuring Django 60 | 61 | Django's [settings](https://docs.djangoproject.com/en/4.0/topics/settings/) also 62 | allows for complex configurations. Some will be discussed here, however trying to list all of 63 | the neat things you can do is beyond the scope of this document. 64 | 65 | ### Using an external database 66 | 67 | By default PtpUploader will set up and use a SQLite database automatically. 68 | For most setups, this will work perfectly fine, however if you want to 69 | use a separate database such as PostgreSQL or MySQL/MariaDB for 70 | performance (e.g. adding more workers) or conveience, that's made possible 71 | by overriding the Django settings. 72 | 73 | As an example for PostgreSQL, in your config.yml add the following section: 74 | ```yaml 75 | DATABASES: 76 | default: 77 | ENGINE: 'django.db.backends.postgresql_psycopg2' 78 | NAME: 'ptpuploader' 79 | USER: 'ptpuploader' 80 | PASSWORD: '&PtPuPlOaDeR!' 81 | HOST: 'sql.example.com' 82 | PORT: '' 83 | ``` 84 | 85 | # Command line only usage 86 | 87 | PtpUploader can create release description (with media info and screenshots) for manual uploading from command line. 88 | ``` 89 | # source ~/.venv/ptpuploader/bin/activate # Optional, if a virtualenv was used during installation. 90 | ReleaseInfoMaker --help 91 | ``` 92 | 93 | Syntax: 94 | * `ReleaseInfoMaker ` creates the release description and starts seeding the torrent. 95 | * `ReleaseInfoMaker --notorrent ` creates the release description. 96 | * `ReleaseInfoMaker --noscreens ` creates the release description, without screenshots. 97 | 98 | Use the resulting torrent that starts with PTP for uploading to the tracker. 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PtpUploader 2 | 3 | A small uploader for a mildly popular movie site. 4 | 5 | ## About 6 | 7 | With PtpUploader's WebUI you can upload to PTP by specifying a torrent and an IMDb or PTP link. The torrent 8 | can be a local path, a link to another site, or a literal `.torrent` file. 9 | 10 | There is also an automatic mode built-in that can check announcements from IRC or RSS and upload everything 11 | (semi-)automatically. 12 | 13 | **It is still solely your responsibility to make sure anything you upload has correct information 14 | and is allowed under the rules.** 15 | 16 | ## Getting started 17 | 18 | ### Manual 19 | 20 | 1. Install required dependencies. 21 | This is example is for Ubuntu, the exact command/package names may change depending on your distro: 22 | ```bash 23 | sudo apt install python3 mpv imagemagick mediainfo 24 | ``` 25 | 2. Install the python package: 26 | ``` 27 | ## Using a dedicated virtualenv is optional but highly recommended 28 | # sudo apt install python3-venv 29 | # virtualenv ~/.venv/ptpuploader/ 30 | # source ~/.venv/ptpuploader/bin/activate 31 | pip3 install PtpUploader 32 | ``` 33 | 3. Create the config file: 34 | ```bash 35 | mkdir -pv ~/.config/ptpuploader/ 36 | wget https://raw.githubusercontent.com/kannibalox/PtpUploader/main/src/PtpUploader/config.default.yml -O ~/.config/ptpuploader/config.yml 37 | nano ~/.config/ptpuploader/config.yml # Edit config file as needed 38 | ``` 39 | 4. Start the process: 40 | ```bash 41 | PtpUploader runuploader 42 | ``` 43 | 5. Add an admin user: 44 | ```bash 45 | PtpUploader createsuperuser 46 | ``` 47 | 6. Navigate to http://localhost:8000/jobs and enter the admin credentials. 48 | 49 | ### Docker 50 | 51 | 1. Clone the repo 52 | ```bash 53 | git clone https://github.com/kannibalox/PtpUploader.git 54 | cd PtpUploader/ 55 | ``` 56 | 2. Create the config file 57 | ``` 58 | mkdir -pv ~/.config/ptpuploader/ 59 | wget https://raw.githubusercontent.com/kannibalox/PtpUploader/main/src/PtpUploader/config.default.yml -O ~/.config/ptpuploader/config.yml 60 | nano ~/.config/ptpuploader/config.yml # Edit config file as needed 61 | ``` 62 | When running in docker, be sure to enter the address to rTorrent's SCGI port (**not** a ruTorrent port). 63 | 2. Build the image and start the daemon in the background 64 | ```bash 65 | sudo docker build -t ptpuploader . 66 | sudo docker run --name ptpuploader -d \ 67 | -v YOUR_WORK_DIR:YOUR_WORK_DIR \ # modify to match your work_dir in config.yml 68 | -v $HOME/.config/ptpuploader/:/root/.config/ptpuploader/ 69 | -p 8000:8000 ptpuploader 70 | ``` 71 | 3. Add an admin user. 72 | ```bash 73 | sudo docker exec -it ptpuploader PtpUploader createsuperuser 74 | ``` 75 | 4. Navigate to http://localhost:8000/jobs and enter the admin credentials. 76 | 77 | ## Next Steps and Help 78 | 79 | See [INSTALL.md](INSTALL.md) and the comments in the config file for advanced usage instructions. 80 | 81 | Support is provided on [PTP](https://passthepopcorn.me/forums.php?action=viewthread&threadid=9245) or in Github issues. 82 | 83 | ## Changelog 84 | 85 | Many things have changed in preparation for version 1.0. Most importantly, only python 3.7+ is supported. 86 | 87 | Non-exhaustive list of other changes: 88 | - Reduce login sessions by write to a cookie file 89 | - Update UI with new theme 90 | - Allow viewing screenshots in edit page 91 | - Add uploads in bulk 92 | - Prowlarr integration 93 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | - ignore non-English AVI scene releases that don't contain subtitle 2 | - add timeout when waiting till the download to finish 3 | - announcement rescheduling while torrent is not visible on GFT (needed only when we will support more than one sources?) 4 | - PtpUploaderInvalidLoginException should just disable the source (if it is ptp it can quit) 5 | - check pending downloads too when checking for dupes 6 | - support of relogging, timeouts and retrying in all HTTP requests 7 | - per source filtering settings 8 | - limit ratio (and seeding time) on source 9 | - replace pyWHATauto or at least make it capable of rejoining 10 | - check available space before downloading 11 | - automatically switching to an alternative host if ptpimg is down 12 | - replace file based communication between irc bot and the uploader (or keep it and add an alternative) 13 | - store Settings.ini elsewhere 14 | 15 | ---- 16 | - GFT: use pretime from IRC announcements to differentiate scene releases 17 | - CG: how do we let the user pick a release name? 18 | - instead of international title make a force release name boolean checkbox? 19 | - add size checking: allow an ~1400 MB xvid if only a ~700 MB version exists 20 | - source: cinematik support 21 | - source: finish directory/file support 22 | - wizard interface for upload page: http://flowplayer.org/tools/demos/tabs/wizard.html 23 | - better layout for jobs page. Eg.: http://community.nuxeo.com/ 24 | - add separate options for each settings that the force mode currently overrides 25 | - so it would be possible to resume a torrent with multiple NFOs in it with a new setting 26 | - force instant start regardless of maximum simultaneous downloads. What about (manual) torrent source? 27 | - torrent file uploads: delete torrents from the temporary directory 28 | - torrent file uploads: Firefox doesn't restrict the file selector to torrents files. Works in Chrome. 29 | - jquery-html5-upload was used only because it doesn't create form. Any alternative that works inside a form? 30 | - http://blog.insicdesigns.com/2010/02/10-best-ajax-file-uploader-for-your-web-application/ 31 | - https://github.com/blueimp/jQuery-File-Upload/ 32 | - torrent site uploads: delete torrent file from release directory 33 | - optimization: torrent site uploads should contact the source site only when there is a missing info 34 | - for example when resuming a torrent, there is a useless page load 35 | - optimization: is expire_on_commit needed? 36 | - during a work on a job, there are a lot of commits, that cause full reloads from the server 37 | - upload UI: support for user specified screens -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "PtpUploader" 3 | version = "0.12.2" 4 | description = "A small uploader for a mildly popular movie site" 5 | authors = ["kannibalox "] 6 | packages = [ 7 | { include = "PtpUploader", from = "src" } 8 | ] 9 | repository = "https://github.com/kannibalox/PtpUploader" 10 | classifiers = [ 11 | "Development Status :: 4 - Beta", 12 | "Environment :: Web Environment", 13 | "Natural Language :: English", 14 | "Operating System :: POSIX", 15 | "Programming Language :: Python :: 3 :: Only", 16 | "Programming Language :: Python :: 3.7", 17 | "Programming Language :: Python :: 3.8", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | "Programming Language :: Python :: 3.12", 22 | "Topic :: Utilities", 23 | ] 24 | 25 | 26 | [tool.poetry.dependencies] 27 | python = ">3.7.2,<4.0" 28 | requests = "^2.26.0" 29 | Django = "^3.2.19" 30 | guessit = "^3.3.1" 31 | pyrosimple = "^2.7.0" 32 | dynaconf = "^3.1.7" 33 | Unidecode = "^1.3.2" 34 | Werkzeug = "^2.0.2" 35 | cinemagoer = "^2023.5.1" 36 | rarfile = "^4.0" 37 | 38 | [tool.poetry.scripts] 39 | PtpUploader = "PtpUploader.manage:run" 40 | ReleaseInfoMaker = "PtpUploader.ReleaseInfoMaker:run" 41 | 42 | [tool.poetry.group.dev.dependencies] 43 | black = "^22.12.0" 44 | pylint = "^2.15.9" 45 | mypy = "^0.991" 46 | 47 | [tool.pylint] 48 | [tool.pylint.'MESSAGES CONTROL'] 49 | 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-module-docstring, missing-class-docstring, missing-function-docstring" 50 | 51 | [tool.isort] 52 | profile = "black" 53 | force_single_line = false 54 | atomic = true 55 | include_trailing_comma = true 56 | lines_after_imports = 2 57 | lines_between_types = 1 58 | use_parentheses = true 59 | 60 | [tool.black] 61 | #include = '\.py$' 62 | extend-exclude = "/migrations/" 63 | 64 | [tool.mypy] 65 | [[tool.mypy.overrides]] 66 | module = ['django.*'] 67 | ignore_missing_imports = true 68 | 69 | [build-system] 70 | requires = ["poetry-core>=1.0.0"] 71 | build-backend = "poetry.core.masonry.api" 72 | -------------------------------------------------------------------------------- /src/FlexGet/config.example.yml: -------------------------------------------------------------------------------- 1 | # Enable the feeds that you require by removing the underscore from their names. 2 | # You have to edit the exec line(s) to point to your working directory. 3 | # For Cinematik you have to copy your RSS link to the url field. 4 | # For TorrentLeech you have to copy your RSS link from your edit profile page to the url field. 5 | 6 | tasks: 7 | # Cinemageddon 8 | _cinemageddon: 9 | disable_urlrewriters: [urlrewrite_redirect] 10 | rss: 11 | url: http://cinemageddon.net/rss.xml 12 | # Force ASCII because the feed is not encoded properly. 13 | ascii: yes 14 | regexp: 15 | from: title 16 | reject: 17 | - .+\(XXX\) 18 | - .+\(OST\) 19 | accept_all: yes 20 | manipulate: 21 | # Create TorrentId field by extracting the id from the URL. 22 | - TorrentId: 23 | from: url 24 | extract: 'http://cinemageddon\.net/details\.php\?id=(\d+).*' 25 | exec: echo '{"AnnouncementSourceName":"cg","AnnouncementId":"{{TorrentId}}"}' > "/data/announcement/cg-{{TorrentId}}.json" 26 | 27 | # Karagarga 28 | _karagarga: 29 | disable_urlrewriters: [urlrewrite_redirect] 30 | rss: 31 | url: https://karagarga.in/rss.php?passkey=AAAAAAAAAAAAAAAA&user=AAAAAAAAAAAAAAAA 32 | # Force ASCII just to be sure. 33 | ascii: yes 34 | regexp: 35 | from: title 36 | reject: 37 | # Ignore literature. 38 | - .+\[Audiobooks\]$ 39 | - .+\[Books\]$ 40 | - .+\[Comics\]$ 41 | # Ignore music. (Experimental can't be ignored because it is also a movie genre.) 42 | - .+\[Blues\]$ 43 | - .+\[Classical\]$ 44 | - .+\[Country\]$ 45 | - .+\[Electronica\]$ 46 | - .+\[Exotica\]$ 47 | - .+\[Folk\]$ 48 | - .+\[Funk\]$ 49 | - .+\[Indie\]$ 50 | - .+\[Jazz\]$ 51 | - .+\[Latin\]$ 52 | - .+\[Live\]$ 53 | - .+\[Metal\]$ 54 | - .+\[Punk & Hardcore\]$ 55 | - .+\[Rap & Hiphop\]$ 56 | - .+\[Reggae\]$ 57 | - .+\[Rock\]$ 58 | - .+\[Soul\]$ 59 | - .+\[Soundtrack\]$ 60 | - .+\[World\]$ 61 | accept_all: yes 62 | manipulate: 63 | # Create TorrentId field by extracting the ID from the URL. 64 | - TorrentId: 65 | from: url 66 | extract: 'https?://(?:www\.)?karagarga\.in/details\.php\?id=(\d+)' 67 | exec: echo '{"AnnouncementSourceName":"kg","AnnouncementId":"{{TorrentId}}"}' > "/data/announcement/kg-{{TorrentId}}.json" 68 | -------------------------------------------------------------------------------- /src/PtpUploader/Helper.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import time 4 | 5 | from datetime import timedelta 6 | from pathlib import Path 7 | from typing import List, Tuple 8 | 9 | import bencode 10 | import requests 11 | 12 | from PtpUploader.MyGlobals import MyGlobals 13 | from PtpUploader.PtpUploaderException import PtpUploaderException 14 | 15 | 16 | def GetIdxSubtitleLanguages(path: str): 17 | languages = [] 18 | 19 | # id: en, index: 0 20 | languageRe = re.compile(r"id: ([a-z][a-z]), index: \d+$", re.IGNORECASE) 21 | 22 | # U is needed for "universal" newline support: to handle \r\n as \n. 23 | with open(path) as pathHandle: 24 | for line in pathHandle.readlines(): 25 | match = languageRe.match(line) 26 | if match is not None: 27 | languages.append(match.group(1)) 28 | 29 | return languages 30 | 31 | 32 | # Supported formats: "100 GB", "100 MB", "100 bytes". (Space is optional.) 33 | # Returns with an integer. 34 | # Returns with 0 if size can't be found. 35 | def GetSizeFromText(text: str): 36 | text = text.replace(" ", "").replace( 37 | ",", "" 38 | ) # For sizes like this: 1,471,981,530bytes 39 | text = text.replace("GiB", "GB").replace("MiB", "MB") 40 | 41 | matches = re.match("(.+)GB", text) 42 | if matches is not None: 43 | return int(float(matches.group(1)) * 1024 * 1024 * 1024) 44 | 45 | matches = re.match("(.+)MB", text) 46 | if matches is not None: 47 | return int(float(matches.group(1)) * 1024 * 1024) 48 | 49 | matches = re.match("(.+)bytes", text) 50 | if matches is not None: 51 | return int(matches.group(1)) 52 | 53 | return 0 54 | 55 | 56 | def SizeToText(size: int): 57 | if size < 1024 * 1024 * 1024: 58 | return "%.2f MiB" % (float(size) / (1024 * 1024)) 59 | return "%.2f GiB" % (float(size) / (1024 * 1024 * 1024)) 60 | 61 | 62 | # timeDifference must be datetime.timedelta. 63 | def TimeDifferenceToText( 64 | td: timedelta, levels: int = 2, agoText=" ago", noDifferenceText="Just now" 65 | ) -> str: 66 | data = {} 67 | data["y"], seconds = divmod(int(td.total_seconds()), 31556926) 68 | # 2629744 seconds = ~1 month (The mean month length of the Gregorian calendar is 30.436875 days.) 69 | data["mo"], seconds = divmod(seconds, 2629744) 70 | data["d"], seconds = divmod(seconds, 86400) 71 | data["h"], seconds = divmod(seconds, 3600) 72 | data["m"], data["s"] = divmod(seconds, 60) 73 | 74 | time_parts = [f"{round(value)}{name}" for name, value in data.items() if value > 0] 75 | if time_parts: 76 | return "".join(time_parts[:levels]) + agoText 77 | return noDifferenceText 78 | 79 | 80 | def MakeRetryingHttpGetRequestWithRequests( 81 | url: str, maximumTries=3, delayBetweenRetriesInSec=10 82 | ): 83 | headers = { 84 | "User-Agent": "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:34.0) Gecko/20100101 Firefox/34.0" 85 | } 86 | 87 | while True: 88 | try: 89 | result = MyGlobals.session.get(url, headers=headers) 90 | result.raise_for_status() 91 | return result 92 | except requests.exceptions.ConnectionError: 93 | if maximumTries > 1: 94 | maximumTries -= 1 95 | time.sleep(delayBetweenRetriesInSec) 96 | else: 97 | raise 98 | 99 | 100 | def MakeRetryingHttpPostRequestWithRequests( 101 | url: str, postData, maximumTries=3, delayBetweenRetriesInSec=10 102 | ): 103 | headers = { 104 | "User-Agent": "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:34.0) Gecko/20100101 Firefox/34.0" 105 | } 106 | 107 | while True: 108 | try: 109 | result = MyGlobals.session.post(url, data=postData, headers=headers) 110 | result.raise_for_status() 111 | return result 112 | except requests.exceptions.ConnectionError: 113 | if maximumTries > 1: 114 | maximumTries -= 1 115 | time.sleep(delayBetweenRetriesInSec) 116 | else: 117 | raise 118 | 119 | 120 | # Path can be a file or a directory. (Obviously.) 121 | def GetPathSize(path) -> int: 122 | path = Path(path).resolve() 123 | if path.is_file(): 124 | return path.stat().st_size 125 | 126 | return sum(p.stat().st_size for p in path.rglob("*")) 127 | 128 | 129 | # Always uses / as path separator. 130 | def GetFileListFromTorrent(torrentPath) -> List[str]: 131 | with open(torrentPath, "rb") as fh: 132 | data = bencode.decode(fh.read()) 133 | name = data["info"].get("name", None) 134 | files = data["info"].get("files", None) 135 | 136 | if files is None: 137 | return [name] 138 | 139 | fileList = [] 140 | for fileInfo in files: 141 | path = "/".join(fileInfo["path"]) 142 | fileList.append(path) 143 | 144 | return fileList 145 | 146 | 147 | def RemoveDisallowedCharactersFromPath(text: str) -> str: 148 | newText = text 149 | 150 | # These characters can't be in filenames on Windows. 151 | forbiddenCharacters = r"""\/:*?"<>|""" 152 | for c in forbiddenCharacters: 153 | newText = newText.replace(c, "") 154 | 155 | newText = newText.strip() 156 | 157 | if len(newText) > 0: 158 | return newText 159 | raise PtpUploaderException("New name for '%s' resulted in empty string." % text) 160 | 161 | 162 | def ValidateTorrentFile(torrentPath): 163 | try: 164 | with open(torrentPath, "rb") as fh: 165 | bencode.decode(fh.read()) 166 | return True 167 | except Exception as e: 168 | raise PtpUploaderException( 169 | "File '%s' is not a valid torrent." % torrentPath 170 | ) from e 171 | 172 | 173 | def GetSuggestedReleaseNameAndSizeFromTorrentFile(torrentPath) -> Tuple[str, int]: 174 | with open(torrentPath, "rb") as fh: 175 | data = bencode.decode(fh.read()) 176 | name = data["info"].get("name", None) 177 | files = data["info"].get("files", None) 178 | if files is None: 179 | # It is a single file torrent, remove the extension. 180 | name, _ = os.path.splitext(name) 181 | size = data["info"]["length"] 182 | return name, size 183 | size = 0 184 | for file in files: 185 | size += file["length"] 186 | 187 | return name, size 188 | 189 | 190 | def DecodeHtmlEntities(html): 191 | return html.unescape(html) 192 | -------------------------------------------------------------------------------- /src/PtpUploader/ImageHost/Base.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from PtpUploader.Settings import config 4 | 5 | 6 | class ImageSite: 7 | def __init__(self, logger=None): 8 | if logger is None: 9 | self.logger = logging.getLogger(__name__) 10 | else: 11 | self.logger = logger 12 | self.config = config.image_host[self.name] 13 | 14 | def upload_url(self, url: str): 15 | raise NotImplementedError 16 | 17 | def upload_path(self, path: str): 18 | raise NotImplementedError 19 | -------------------------------------------------------------------------------- /src/PtpUploader/ImageHost/Catbox.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from PtpUploader.ImageHost.Base import ImageSite 4 | 5 | 6 | class CatboxMoe(ImageSite): 7 | def __init__(self, logger=None): 8 | self.name = "catbox" 9 | self.endpoint = "https://catbox.moe/user/api.php" 10 | super().__init__(logger) 11 | 12 | def upload_url(self, url: str): 13 | return self.upload({"userhash": "", "reqtype": "urlupload", "url": url}, {}) 14 | 15 | def upload_path(self, path: str): 16 | with open(path, "rb") as imageHandle: 17 | return self.upload( 18 | {"userhash": "", "reqtype": "fileupload"}, {"fileToUpload": imageHandle} 19 | ) 20 | 21 | def upload(self, data, files): 22 | response = requests.post(self.endpoint, data=data, files=files) 23 | response.raise_for_status() 24 | return response.text 25 | -------------------------------------------------------------------------------- /src/PtpUploader/ImageHost/ImageUploader.py: -------------------------------------------------------------------------------- 1 | from PtpUploader.ImageHost.Catbox import CatboxMoe 2 | from PtpUploader.ImageHost.ImgBB import ImgBB 3 | from PtpUploader.ImageHost.PtpImg import PtpImg 4 | from PtpUploader.PtpUploaderException import PtpUploaderException 5 | from PtpUploader.Settings import config 6 | 7 | 8 | class ImageUploader: 9 | @staticmethod 10 | def Upload(logger, imagePath=None, imageUrl=None): 11 | if (imagePath is None) and (imageUrl is None): 12 | raise PtpUploaderException( 13 | "ImageUploader error: both image path and image url are None." 14 | ) 15 | 16 | if (imagePath is not None) and (imageUrl is not None): 17 | raise PtpUploaderException( 18 | "ImageUploader error: both image path and image url are given." 19 | ) 20 | 21 | # TODO: fall back to secondary host if the upload to ptpimg wasn't successful. Also start a 1 hour countdown and doesn't use ptpimg till it gets to 0. 22 | 23 | host = None 24 | if config.image_host.use == "ptpimg": 25 | host = PtpImg(logger) 26 | elif config.image_host.use == "imgbb": 27 | host = ImgBB(logger) 28 | elif config.image_host.use == "catbox": 29 | host = CatboxMoe(logger) 30 | else: 31 | raise PtpUploaderException( 32 | "Unknown image host: '%s'." % config.image_host.use 33 | ) 34 | 35 | if imagePath: 36 | return host.upload_path(imagePath) 37 | elif imageUrl: 38 | return host.upload_url(imageUrl) 39 | else: 40 | raise PtpUploaderException("No image source specified") 41 | -------------------------------------------------------------------------------- /src/PtpUploader/ImageHost/ImgBB.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from PtpUploader.ImageHost.Base import ImageSite 4 | from PtpUploader.PtpUploaderException import PtpUploaderException 5 | 6 | 7 | class ImgBB(ImageSite): 8 | def __init__(self, logger=None): 9 | self.name = "imgbb" 10 | super().__init__(logger) 11 | if not self.config.api_key: 12 | raise PtpUploaderException("imgbb API key is not set") 13 | 14 | def upload_url(self, url: str): 15 | return self.upload({"image": url}, {}) 16 | 17 | def upload_path(self, path: str): 18 | with open(path, "rb") as imageHandle: 19 | return self.upload({}, {"image": imageHandle}) 20 | 21 | def upload(self, data, files): 22 | endpoint = f"https://api.imgbb.com/1/upload?key={self.config.api_key}" 23 | response = requests.post(endpoint, data=data, files=files) 24 | response.raise_for_status() 25 | try: 26 | rjson = response.json() 27 | return rjson["data"]["url"] 28 | except (ValueError, KeyError): 29 | self.logger.exception( 30 | "Got an exception while loading JSON response from imgbb. Response: '{}'.".format( 31 | response 32 | ) 33 | ) 34 | raise 35 | -------------------------------------------------------------------------------- /src/PtpUploader/ImageHost/PtpImg.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from PtpUploader.ImageHost.Base import ImageSite 4 | from PtpUploader.PtpUploaderException import PtpUploaderException 5 | 6 | 7 | class PtpImg(ImageSite): 8 | def __init__(self, logger=None): 9 | self.name = "ptpimg" 10 | self.endpoint = "https://ptpimg.me/upload.php" 11 | super().__init__(logger) 12 | if not self.config.api_key: 13 | raise PtpUploaderException("ptpimg.me API key is not set") 14 | 15 | def upload_url(self, url: str): 16 | return self.upload({"link-upload": url}, {}) 17 | 18 | def upload_path(self, path: str): 19 | with open(path, "rb") as imageHandle: 20 | return self.upload({}, {"file-upload": imageHandle}) 21 | 22 | def upload(self, data, files): 23 | data["api_key"] = self.config.api_key 24 | response = requests.post("https://ptpimg.me/upload.php", data=data, files=files) 25 | response.raise_for_status() 26 | try: 27 | rjson = response.json()[0] 28 | return "https://ptpimg.me/{}.{}".format(rjson["code"], rjson["ext"]) 29 | except (ValueError, KeyError): 30 | self.logger.exception( 31 | "Got an exception while loading JSON response from ptpimg.me. Response: '{}'.".format( 32 | str(response.text()) 33 | ) 34 | ) 35 | raise 36 | -------------------------------------------------------------------------------- /src/PtpUploader/ImageHost/__init__.py: -------------------------------------------------------------------------------- 1 | # The __init__.py files are required to make Python treat the directories as containing packages 2 | # http://docs.python.org/tutorial/modules.html 3 | from PtpUploader.ImageHost.ImageUploader import ImageUploader 4 | 5 | 6 | def list_hosts(): 7 | return ["ptpimg", "catbox", "imgbb"] 8 | 9 | 10 | def upload(logger, imagePath=None, imageUrl=None): 11 | return ImageUploader.Upload(logger, imagePath, imageUrl) 12 | -------------------------------------------------------------------------------- /src/PtpUploader/InformationSource/Imdb.py: -------------------------------------------------------------------------------- 1 | from imdb import IMDb 2 | 3 | 4 | class ImdbInfo: 5 | def __init__(self): 6 | self.Title: str = "" 7 | self.Year: str = "" 8 | self.PosterUrl: str = "" 9 | self.Plot: str = "" 10 | self.IsSeries: bool = False 11 | self.ImdbRating: str = "" 12 | self.ImdbVoteCount: str = "" 13 | self.Raw = None 14 | 15 | 16 | class Imdb: 17 | @staticmethod 18 | def GetInfo(logger, imdbId: str) -> ImdbInfo: 19 | logger.info("Getting IMDb info for IMDb id '%s'." % imdbId) 20 | 21 | # We don't care if this fails. It gives only extra information. 22 | imdbInfo = ImdbInfo() 23 | try: 24 | ia = IMDb() 25 | movie = ia.get_movie(imdbId.strip("t")) 26 | imdbInfo.Raw = movie 27 | if "title" in movie: 28 | imdbInfo.Title = movie["title"] 29 | if "year" in movie: 30 | imdbInfo.Year = str(movie["year"]) 31 | if "rating" in movie: 32 | imdbInfo.ImdbRating = movie["rating"] 33 | if "votes" in movie: 34 | imdbInfo.ImdbVoteCount = movie["votes"] 35 | if "full-size cover url" in movie: 36 | imdbInfo.PosterUrl = movie["full-size cover url"] 37 | if "plot" in movie: 38 | imdbInfo.Plot = movie["plot"][0] 39 | except Exception: 40 | logger.exception( 41 | "Got exception while trying to get IMDb info by IMDb id '%s'." % imdbId 42 | ) 43 | 44 | return imdbInfo 45 | -------------------------------------------------------------------------------- /src/PtpUploader/InformationSource/MoviePoster.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from PtpUploader.MyGlobals import MyGlobals 4 | from PtpUploader.PtpUploaderException import PtpUploaderException 5 | 6 | 7 | class MoviePoster: 8 | # We use The Internet Movie Poster DataBase's embedding code to get the movie poster. 9 | @staticmethod 10 | def __GetFromMoviePosterDb(imdbId): 11 | url = "http://www.movieposterdb.com/embed.inc.php?movie_id=%s" % imdbId 12 | result = MyGlobals.session.get(url) 13 | result.raise_for_status() 14 | response = result.text 15 | 16 | # Response looks like this: 17 | # document.write('\"Garden'); 18 | match = re.search(r'src=\\"(.+)\\"', response) 19 | if match: 20 | url = match.group(1) 21 | url = url.replace("/t_", "/l_") # Change thumbnail to full image. 22 | return url 23 | 24 | return "" 25 | 26 | @staticmethod 27 | def Get(logger, imdbId): 28 | logger.info( 29 | "Getting movie poster from The Internet Movie Poster DataBase for IMDb id '%s'." 30 | % imdbId 31 | ) 32 | 33 | # We don't care if this fails. It gives only extra information. 34 | try: 35 | return MoviePoster.__GetFromMoviePosterDb(imdbId) 36 | except Exception: 37 | logger.exception( 38 | "Got exception while trying to get poster from The Internet Movie Poster DataBase by IMDb id '%s'." 39 | % imdbId 40 | ) 41 | 42 | return "" 43 | -------------------------------------------------------------------------------- /src/PtpUploader/InformationSource/__init__.py: -------------------------------------------------------------------------------- 1 | # The __init__.py files are required to make Python treat the directories as containing packages 2 | # http://docs.python.org/tutorial/modules.html 3 | -------------------------------------------------------------------------------- /src/PtpUploader/Job/Delete.py: -------------------------------------------------------------------------------- 1 | """Handle deletion of jobs when requested by user""" 2 | import logging 3 | import threading 4 | 5 | from PtpUploader.Job.WorkerBase import WorkerBase 6 | from PtpUploader.MyGlobals import MyGlobals 7 | from PtpUploader.Settings import Settings 8 | 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class Delete(WorkerBase): 14 | """Create a simple worker with a single phase""" 15 | 16 | def __init__(self, release_id: int, mode: str, stop_requested: threading.Event): 17 | super().__init__(release_id, stop_requested) 18 | self.Phases = [self.__delete] 19 | self.mode = mode 20 | 21 | def __delete(self): 22 | if not self.ReleaseInfo.CanDeleted(): 23 | logger.error("The job is currently running and can't be deleted!") 24 | return "Error" 25 | 26 | delete_mode = self.mode.lower() 27 | delete_source_data = delete_mode in ["job_source", "job_all"] 28 | delete_upload_data = delete_mode in ["job_upload", "job_all"] 29 | 30 | announcement_source = self.ReleaseInfo.AnnouncementSource 31 | if announcement_source is None: 32 | announcement_source = MyGlobals.SourceFactory.GetSource( 33 | self.ReleaseInfo.AnnouncementSourceName 34 | ) 35 | 36 | if announcement_source is not None: # Still possibly not there 37 | announcement_source.Delete( 38 | self.ReleaseInfo, 39 | Settings.GetTorrentClient(), 40 | delete_source_data, 41 | delete_upload_data, 42 | ) 43 | 44 | self.ReleaseInfo.delete() 45 | logger.info("Release deleted") 46 | 47 | return "OK" 48 | -------------------------------------------------------------------------------- /src/PtpUploader/Job/FinishedJobPhase.py: -------------------------------------------------------------------------------- 1 | class FinishedJobPhase: 2 | Download_CreateReleaseDirectory = 1 << 0 3 | Upload_CreateUploadPath = 1 << 1 4 | Upload_ExtractRelease = 1 << 2 5 | Upload_UploadMovie = 1 << 3 6 | -------------------------------------------------------------------------------- /src/PtpUploader/Job/JobRunningState.py: -------------------------------------------------------------------------------- 1 | class JobRunningState: 2 | WaitingForStart = 0 3 | InProgress = 1 4 | Paused = 2 5 | Finished = 3 6 | Failed = 4 7 | Ignored = 5 8 | Ignored_AlreadyExists = 6 9 | Ignored_Forbidden = 7 10 | Ignored_MissingInfo = 8 11 | Ignored_NotSupported = 9 12 | DownloadedAlreadyExists = 10 13 | Scheduled = 11 14 | 15 | @staticmethod 16 | def ToText(state): 17 | if state == JobRunningState.WaitingForStart: 18 | return "Waiting for start" 19 | elif state == JobRunningState.InProgress: 20 | return "In progress" 21 | elif state == JobRunningState.Paused: 22 | return "Paused" 23 | elif state == JobRunningState.Finished: 24 | return "Finished" 25 | elif state == JobRunningState.Failed: 26 | return "Failed" 27 | elif state == JobRunningState.Ignored: 28 | return "Ignored" 29 | elif state == JobRunningState.Ignored_AlreadyExists: 30 | return "Ignored, already exists" 31 | elif state == JobRunningState.Ignored_Forbidden: 32 | return "Ignored, forbidden" 33 | elif state == JobRunningState.Ignored_MissingInfo: 34 | return "Ignored, missing info" 35 | elif state == JobRunningState.Ignored_NotSupported: 36 | return "Ignored, not supported" 37 | elif state == JobRunningState.DownloadedAlreadyExists: 38 | return "Downloaded, already exists" 39 | elif state == JobRunningState.Scheduled: 40 | return "Scheduled" 41 | else: 42 | return "Unknown" 43 | -------------------------------------------------------------------------------- /src/PtpUploader/Job/JobStartMode.py: -------------------------------------------------------------------------------- 1 | class JobStartMode: 2 | Automatic = 0 3 | Manual = 1 4 | 5 | """ 6 | If this is set then: 7 | - there is no duplicate checking 8 | - there is no adult tag checking 9 | - series is not rejected 10 | - user specified container overrides the one returned by MediaInfo (even if they are different) 11 | - user specified codec overrides the one returned by MediaInfo (even if they are different) 12 | - user specified resolution overrides the one returned by MediaInfo (even if they are different) 13 | """ 14 | ManualForced = 2 15 | -------------------------------------------------------------------------------- /src/PtpUploader/Job/LoadFile.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | from pathlib import Path 5 | from typing import Dict, List 6 | 7 | from PtpUploader.ReleaseInfo import ReleaseInfo 8 | from PtpUploader.Settings import Settings 9 | 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def load_json_release(path: Path): 15 | with path.open() as fh: 16 | data: Dict = json.load(fh) 17 | release = ReleaseInfo() 18 | allowed_fields: List[str] = [ 19 | "ImdbId", 20 | "Title", 21 | "Year", 22 | "AnnouncementId", 23 | "AnnouncementSourceName", 24 | "CoverArtUrl", 25 | "Codec", 26 | "Container", 27 | "Source", 28 | "RemasterTitle", 29 | "Resolution", 30 | ] 31 | for k, v in data.items(): 32 | if k in allowed_fields: 33 | if k == "Source": 34 | pass # TODO: properly put things into "other" as needed 35 | setattr(release, k, v) 36 | release.JobRunningState = ReleaseInfo.JobState.WaitingForStart 37 | release.save() 38 | path.unlink() 39 | 40 | def load_torrent_release(path: Path): 41 | release = ReleaseInfo() 42 | release.AnnouncementSourceName = "torrent" 43 | release.SourceTorrentFilePath = path 44 | release.JobRunningState = ReleaseInfo.JobState.WaitingForStart 45 | release.save() 46 | path.unlink() 47 | 48 | def scan_dir(): 49 | path = Path(Settings.GetAnnouncementWatchPath()) 50 | for child in path.iterdir(): 51 | if child.is_file(): 52 | try: 53 | load_json_release(child) 54 | continue 55 | except (json.decoder.JSONDecodeError, UnicodeDecodeError) as exc: 56 | logger.debug("Cannot load %r as JSON (%s), attempting .torrent check", child, exc) 57 | try: 58 | load_torrent_release(child) 59 | continue 60 | except json.decoder.JSONDecodeError: 61 | pass 62 | -------------------------------------------------------------------------------- /src/PtpUploader/Job/Supervisor.py: -------------------------------------------------------------------------------- 1 | """Replaces the artisanal (but impressive) python 2 threading system 2 | This directly handles: 3 | - Loading announcement files 4 | - Scanning the DB for work 5 | - Watching the client for pending downloads 6 | All of these actions will happen on a schedule, but the thread can be woken up immediately by an Event. 7 | 8 | For WorkerBase actions, they get launched into a ThreadPoolExecutor, with an event flag 9 | to allow for interrupting the work phases. This allows this class to check the status of 10 | any active futures when deciding how to handle start/stop requests. 11 | 12 | However, there is no direct locking of resources, as we can use the DB 13 | as more flexible thread-safe state holder. 14 | 15 | It's called the JobSupervisor because supervisors are better than managers. 16 | 17 | """ 18 | 19 | import logging 20 | import queue 21 | import threading 22 | import traceback 23 | 24 | from concurrent import futures 25 | from typing import Dict, List 26 | 27 | from django.db.models import Q # type: ignore 28 | from django.utils import timezone # type: ignore 29 | from pyrosimple.util.rpc import HashNotFound 30 | 31 | from PtpUploader.Job import LoadFile 32 | from PtpUploader.Job.CheckAnnouncement import CheckAnnouncement 33 | from PtpUploader.Job.Delete import Delete 34 | from PtpUploader.Job.Upload import Upload 35 | from PtpUploader.PtpUploaderMessage import * 36 | from PtpUploader.ReleaseInfo import ReleaseInfo 37 | from PtpUploader.Settings import config 38 | 39 | 40 | logger = logging.getLogger(__name__) 41 | 42 | 43 | class JobSupervisor(threading.Thread): 44 | def __init__(self): 45 | super().__init__() 46 | self.futures: Dict[str, List[threading.Event, futures.Future]] = {} 47 | self.message_queue: queue.SimpleQueue = queue.SimpleQueue() 48 | self.stop_requested: threading.Event = threading.Event() 49 | self.pool: futures.ThreadPoolExecutor = futures.ThreadPoolExecutor( 50 | max_workers=config.workers.threads 51 | ) 52 | 53 | def __repr__(self): 54 | running = [] 55 | done = [] 56 | waiting = [] 57 | for key, val in self.futures.items(): 58 | _, future = val 59 | if future.running(): 60 | running.append(key) 61 | elif future.done(): 62 | done.append(key) 63 | else: 64 | waiting.append(key) 65 | return f"running: {running}, waiting: {waiting}, done: {done}" 66 | 67 | def check_pending_downloads(self): 68 | for release in ReleaseInfo.objects.filter( 69 | JobRunningState=ReleaseInfo.JobState.InDownload 70 | ): 71 | try: 72 | release.AnnouncementSource.IsDownloadFinished(logger, release) 73 | except HashNotFound as e: 74 | release.ErrorMessage = str(e) 75 | release.JobRunningState = ReleaseInfo.JobState.Failed 76 | release.save() 77 | continue 78 | if ( 79 | release.Id not in self.futures 80 | and release.AnnouncementSource.IsDownloadFinished(logger, release) 81 | ): 82 | logger.info("Launching upload job for %s", release.Id) 83 | worker_stop_flag = threading.Event() 84 | worker = Upload(release_id=release.Id, stop_requested=worker_stop_flag) 85 | self.futures[release.Id] = [ 86 | worker_stop_flag, 87 | self.pool.submit(worker.Work), 88 | ] 89 | 90 | def load_announcements(self): 91 | LoadFile.scan_dir() 92 | 93 | def add_message(self, message): 94 | if isinstance(message, PtpUploaderMessageBase): 95 | self.message_queue.put(message) 96 | else: 97 | logger.warning("Unknown message '%s'", message) 98 | 99 | def delete_job(self, r_id, mode): 100 | if r_id in self.futures: 101 | return # Don't muck with an active job 102 | worker_stop_flag = threading.Event() 103 | worker = Delete(release_id=r_id, mode=mode, stop_requested=worker_stop_flag) 104 | self.futures[r_id] = [ 105 | worker_stop_flag, 106 | self.pool.submit(worker.Work), 107 | ] 108 | 109 | def scan_db(self): 110 | """Find releases pending work by their DB status""" 111 | for release in ReleaseInfo.objects.filter( 112 | Q(JobRunningState=ReleaseInfo.JobState.WaitingForStart) 113 | | ( 114 | Q(ScheduleTime__lte=timezone.now()) 115 | & Q(JobRunningState=ReleaseInfo.JobState.Scheduled) 116 | ) 117 | ): 118 | if release.Id not in self.futures: 119 | logger.info("Launching check job for %s", release.Id) 120 | worker_stop_flag = threading.Event() 121 | worker = CheckAnnouncement( 122 | release_id=release.Id, stop_requested=worker_stop_flag 123 | ) 124 | self.futures[release.Id] = [ 125 | worker_stop_flag, 126 | self.pool.submit(worker.Work), 127 | ] 128 | 129 | def process_pending(self): 130 | self.check_pending_downloads() 131 | self.load_announcements() 132 | self.scan_db() 133 | 134 | def stop_future(self, release_id): 135 | release = ReleaseInfo.objects.get(Id=release_id) 136 | if release.Id in self.futures: 137 | pass 138 | elif release.JobRunningState in [ 139 | ReleaseInfo.JobState.InDownload, 140 | ReleaseInfo.JobState.Scheduled, 141 | ]: 142 | release.JobRunningState = ReleaseInfo.JobState.Paused 143 | release.save() 144 | 145 | def reap_finished(self): 146 | for key, val in list(self.futures.items()): 147 | _, result = val 148 | if result.done(): 149 | if result.exception(): 150 | logger.info( # Exceptions are used as messengers, hence info 151 | "Job %s finished with exception '%s'", key, result.exception() 152 | ) 153 | del self.futures[key] 154 | 155 | def work(self): 156 | if self.futures.keys(): 157 | logger.info(repr(self)) 158 | try: 159 | message = self.message_queue.get(timeout=3) 160 | if isinstance(message, PtpUploaderMessageStopJob): 161 | self.stop_future(message.ReleaseInfoId) 162 | elif isinstance(message, PtpUploaderMessageStartJob): 163 | pass # Just wake up the thread to scan the db 164 | elif isinstance(message, PtpUploaderMessageDeleteJob): 165 | self.delete_job(message.ReleaseInfoId, message.mode) 166 | elif isinstance(message, PtpUploaderMessageQuit): 167 | self.stop_requested.set() 168 | except queue.Empty: 169 | pass 170 | 171 | if self.stop_requested.is_set(): 172 | self.reap_finished() 173 | self.cleanup_futures() 174 | return True 175 | self.reap_finished() 176 | self.process_pending() 177 | return None 178 | 179 | def run(self): 180 | logger.info("Starting supervisors") 181 | while True: 182 | try: 183 | if self.work() is not None: 184 | break 185 | except (KeyboardInterrupt, SystemExit): 186 | logger.info("Received system interrupt") 187 | self.add_message(PtpUploaderMessageQuit()) 188 | except Exception as e: 189 | print(traceback.print_exc()) 190 | logger.info("Received exception %s, attempting to continue", e) 191 | 192 | def cleanup_futures(self): 193 | pass 194 | -------------------------------------------------------------------------------- /src/PtpUploader/Job/WorkerBase.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import threading 4 | import traceback 5 | 6 | from PtpUploader.Job.JobRunningState import JobRunningState 7 | from PtpUploader.PtpUploaderException import * 8 | from PtpUploader.ReleaseInfo import ReleaseInfo 9 | from PtpUploader.Settings import Settings 10 | 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class WorkLogFilter(logging.Filter): 16 | def __init__(self, release_id, *args, **kwargs): 17 | super().__init__(*args, **kwargs) 18 | self.release_id = release_id 19 | 20 | def filter(self, record): 21 | if "release_id" in record.__dict__: 22 | return self.release_id == record.release_id 23 | return False 24 | 25 | 26 | class WorkerBase: 27 | def __init__(self, release_id: int, stop_requested: threading.Event): 28 | self.Phases = [] 29 | self.stop_requested: threading.Event = stop_requested 30 | self.ReleaseInfo = ReleaseInfo.objects.get(Id=release_id) 31 | self.logHandler = ( 32 | self.start_worker_logging() 33 | ) # needed to clean up after job is finished 34 | self.logger = self.ReleaseInfo.logger(logger) # Just a nice shortcut 35 | 36 | def __WorkInternal(self): 37 | if not self.Phases: 38 | raise NotImplementedError("Add phases to this worker") 39 | for phase in self.Phases: 40 | if self.stop_requested.is_set(): 41 | self.ReleaseInfo.JobRunningState = JobRunningState.Paused 42 | self.ReleaseInfo.save() 43 | break 44 | 45 | phase() 46 | self.stop_worker_logging() 47 | 48 | def stop_worker_logging(self): 49 | logging.getLogger().removeHandler(self.logHandler) 50 | 51 | def start_worker_logging(self): 52 | """ 53 | Add a log handler to separate file for current thread 54 | """ 55 | path = os.path.join(Settings.GetJobLogPath(), str(self.ReleaseInfo.Id)) 56 | log_handler = logging.FileHandler(path) 57 | 58 | log_handler.setLevel(logging.DEBUG) 59 | 60 | formatter = logging.Formatter( 61 | "%(asctime)-15s" " %(name)-11s" " %(levelname)-5s" " %(message)s" 62 | ) 63 | log_handler.setFormatter(formatter) 64 | 65 | log_filter = WorkLogFilter(self.ReleaseInfo.Id) 66 | log_handler.addFilter(log_filter) 67 | 68 | log = logging.getLogger() 69 | log.addHandler(log_handler) 70 | return log_handler 71 | 72 | def Work(self): 73 | try: 74 | self.__WorkInternal() 75 | self.ReleaseInfo.clean() 76 | except Exception as e: 77 | if hasattr(e, "JobRunningState"): 78 | self.ReleaseInfo.JobRunningState = e.JobRunningState 79 | else: 80 | self.ReleaseInfo.JobRunningState = JobRunningState.Failed 81 | 82 | self.ReleaseInfo.ErrorMessage = str(e) 83 | self.ReleaseInfo.save() 84 | 85 | if isinstance(e, PtpUploaderException) and str(e).startswith("Stopping "): 86 | self.ReleaseInfo.logger().info(f"Received stop: {e}") 87 | else: 88 | self.ReleaseInfo.logger().exception(traceback.format_exc()) 89 | raise 90 | -------------------------------------------------------------------------------- /src/PtpUploader/Job/__init__.py: -------------------------------------------------------------------------------- 1 | # The __init__.py files are required to make Python treat the directories as containing packages 2 | # http://docs.python.org/tutorial/modules.html 3 | -------------------------------------------------------------------------------- /src/PtpUploader/MyGlobals.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import os 4 | import pickle 5 | import sys 6 | 7 | from pathlib import Path 8 | 9 | import requests 10 | 11 | 12 | class MyGlobalsClass: 13 | def __init__(self): 14 | self.Logger = None 15 | self.PtpUploader = None 16 | self.SourceFactory = None 17 | self.TorrentClient = None 18 | 19 | self.session = requests.session() 20 | self.session.headers.update( 21 | { 22 | "User-Agent": "Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.45 Safari/537.36" 23 | } 24 | ) 25 | 26 | # Use cloudflare-scrape if installed. 27 | try: 28 | from cfscrape import CloudflareAdapter # type: ignore 29 | 30 | self.session.mount("https://", CloudflareAdapter()) 31 | except ImportError: 32 | pass 33 | 34 | def InitializeGlobals(self, workingPath): 35 | from PtpUploader.Settings import config 36 | 37 | self.InitializeLogger(workingPath) 38 | self.cookie_file: Path = Path(workingPath).joinpath("cookies.pickle") 39 | self.cookie_file: Path = Path(config.cookie_file).expanduser() 40 | if self.cookie_file.exists() and self.cookie_file.is_file(): 41 | with self.cookie_file.open("rb") as fh: 42 | self.session.cookies = pickle.load(fh) 43 | 44 | def SaveCookies(self): 45 | with self.cookie_file.open("wb") as fh: 46 | pickle.dump(self.session.cookies, fh) 47 | 48 | # workingPath from Settings.WorkingPath. 49 | def InitializeLogger(self, workingPath): 50 | # This will create the log directory too. 51 | self.Logger = logging.getLogger(__name__) 52 | 53 | # Inline imports are used here to avoid unnecessary dependencies. 54 | def GetTorrentClient(self): 55 | if self.TorrentClient is None: 56 | from PtpUploader.Settings import Settings 57 | 58 | if Settings.TorrentClientName.lower() == "transmission": 59 | from PtpUploader.Tool.Transmission import Transmission 60 | 61 | self.TorrentClient = Transmission( 62 | Settings.TorrentClientAddress, Settings.TorrentClientPort 63 | ) 64 | else: 65 | from PtpUploader.Tool.Rtorrent import Rtorrent 66 | 67 | self.TorrentClient = Rtorrent(Settings.TorrentClientAddress) 68 | return self.TorrentClient 69 | 70 | 71 | MyGlobals = MyGlobalsClass() 72 | -------------------------------------------------------------------------------- /src/PtpUploader/Notifier.example.ini: -------------------------------------------------------------------------------- 1 | [Settings] 2 | # Id of the user that receives the notification. Eg.: 49236 3 | UserId = -------------------------------------------------------------------------------- /src/PtpUploader/Notifier.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import os 3 | import sys 4 | 5 | from PtpUploader import Ptp 6 | from PtpUploader.MyGlobals import MyGlobals 7 | from PtpUploader.Settings import Settings 8 | 9 | 10 | def LoadNotifierSettings(): 11 | configParser = configparser.ConfigParser() 12 | configParser.optionxform = str # Make option names case sensitive. 13 | 14 | # Load Notifier.ini from the same directory where PtpUploader is. 15 | settingsDirectory, _ = os.path.split( 16 | __file__ 17 | ) # __file__ contains the full path of the current running module 18 | settingsPath = os.path.join(settingsDirectory, "Notifier.ini") 19 | configParser.read(settingsPath) 20 | 21 | return configParser.get("Settings", "UserId") 22 | 23 | 24 | def Notify(releaseName, uploadedTorrentUrl): 25 | userId = LoadNotifierSettings() 26 | userId = userId.strip() 27 | if not userId.isdigit(): 28 | return 29 | 30 | Ptp.Login() 31 | subject = "[PtpUploader] %s" % releaseName 32 | message = ( 33 | "This is an automatic notification about a new [url=%s]upload[/url]." 34 | % uploadedTorrentUrl 35 | ) 36 | Ptp.SendPrivateMessage(userId, subject, message) 37 | 38 | 39 | if __name__ == "__main__": 40 | Settings.LoadSettings() 41 | MyGlobals.InitializeGlobals(Settings.WorkingPath) 42 | 43 | if len(sys.argv) == 3: 44 | Notify(sys.argv[1], sys.argv[2]) 45 | -------------------------------------------------------------------------------- /src/PtpUploader/PtpImdbInfo.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | 4 | from PtpUploader import Ptp 5 | from PtpUploader.MyGlobals import MyGlobals 6 | from PtpUploader.PtpUploaderException import * 7 | 8 | 9 | class PtpImdbInfo: 10 | def __init__(self, imdbId): 11 | self.ImdbId = imdbId 12 | self.JsonResponse = "" 13 | self.JsonMovie = None 14 | 15 | def __LoadmdbInfo(self): 16 | # Already loaded 17 | if self.JsonMovie is not None: 18 | return 19 | 20 | # Get IMDb info through PTP's ajax API used by the site when the user presses the auto fill button. 21 | result = MyGlobals.session.get( 22 | "https://passthepopcorn.me/ajax.php?action=torrent_info&imdb=%s" 23 | % Ptp.NormalizeImdbIdForPtp(self.ImdbId) 24 | ) 25 | self.JsonResponse = result.text 26 | Ptp.CheckIfLoggedInFromResponse(result, self.JsonResponse) 27 | 28 | # The response is JSON. 29 | # [{"title":"Devil's Playground","plot":"As the world succumbs to a zombie apocalypse, Cole a hardened mercenary, is chasing the one person who can provide a cure. Not only to the plague but to Cole's own incumbent destiny. DEVIL'S PLAYGROUND is a cutting edge British horror film that features zombies portrayed by free runners for a terrifyingly authentic representation of the undead","art":false,"year":"2010","director":[{"imdb":"1654324","name":"Mark McQueen","role":null}],"tags":"action, horror","writers":[{"imdb":"1057010","name":"Bart Ruspoli","role":" screenplay"}]}] 30 | 31 | jsonResult = json.loads(self.JsonResponse) 32 | if len(jsonResult) != 1: 33 | raise PtpUploaderException( 34 | "Bad PTP movie info JSON response: array length is not one.\nFull response:\n%s" 35 | % self.JsonResponse 36 | ) 37 | if not jsonResult[0]: 38 | raise PtpUploaderException( 39 | "Bad PTP movie info JSON response: no movie info. Perhaps the IMDb ID has been moved?" 40 | ) 41 | self.JsonMovie = jsonResult[0] 42 | 43 | def GetTitle(self): 44 | self.__LoadmdbInfo() 45 | title = self.JsonMovie["title"] 46 | if (title is None) or len(title) == 0: 47 | raise PtpUploaderException( 48 | "Bad PTP movie info JSON response: title is empty.\nFull response:\n%s" 49 | % self.JsonResponse 50 | ) 51 | return title 52 | 53 | def GetYear(self): 54 | self.__LoadmdbInfo() 55 | year = self.JsonMovie["year"] 56 | if (year is None) or len(year) == 0: 57 | raise PtpUploaderException( 58 | "Bad PTP movie info JSON response: year is empty.\nFull response:\n%s" 59 | % self.JsonResponse 60 | ) 61 | return year 62 | 63 | def GetMovieDescription(self): 64 | self.__LoadmdbInfo() 65 | movieDescription = self.JsonMovie["plot"] 66 | if movieDescription is None: 67 | return "" 68 | return movieDescription 69 | 70 | def GetTags(self): 71 | self.__LoadmdbInfo() 72 | tags = self.JsonMovie["tags"] 73 | if tags is None: 74 | raise PtpUploaderException( 75 | "Bad PTP movie info JSON response: tags key doesn't exists.\nFull response:\n%s" 76 | % self.JsonResponse 77 | ) 78 | return tags 79 | 80 | def GetCoverArtUrl(self): 81 | self.__LoadmdbInfo() 82 | coverArtUrl = self.JsonMovie["art"] 83 | if coverArtUrl is None: 84 | raise PtpUploaderException( 85 | "Bad PTP movie info JSON response: art key doesn't exists.\nFull response:\n%s" 86 | % self.JsonResponse 87 | ) 88 | 89 | # It may be false... Eg.: "art": false 90 | if isinstance(coverArtUrl, str): 91 | # Maximize height in 768 pixels. 92 | # Example links: 93 | # http://ia.media-imdb.com/images/M/MV5BMTM2MjE0NTcwNl5BMl5BanBnXkFtZTcwOTM0MDQ1NA@@._V1._SY317_CR1,0,214,317_.jpg 94 | # http://ia.media-imdb.com/images/M/MV5BMjEwNjQ5NDU4OF5BMl5BanBnXkFtZTYwOTI2NzA5._V1._SY317_CR1,0,214,317_.jpg 95 | # http://ia.media-imdb.com/images/M/MV5BMzE3NTMwOTk5OF5BMl5BanBnXkFtZTgwODcxNTE1NDE@._V1_UX182_CR0,0,182,268_AL_.jpg 96 | match = re.match(r"""(.+?\._V1).*\.jpg""", coverArtUrl) 97 | if match is None: 98 | return coverArtUrl 99 | else: 100 | return match.group(1) + "_SY768_.jpg" 101 | else: 102 | return "" 103 | 104 | 105 | class PtpZeroImdbInfo: 106 | def __init__(self): 107 | pass 108 | 109 | def GetTitle(self): 110 | return "" 111 | 112 | def GetYear(self): 113 | return "" 114 | 115 | def GetMovieDescription(self): 116 | return "" 117 | 118 | def GetTags(self): 119 | return "" 120 | 121 | def GetCoverArtUrl(self): 122 | return "" 123 | -------------------------------------------------------------------------------- /src/PtpUploader/PtpMovieSearchResult.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import logging 4 | 5 | from PtpUploader.PtpUploaderException import PtpUploaderException 6 | from PtpUploader.ReleaseInfo import ReleaseInfo 7 | 8 | 9 | # Shortcuts for reference 10 | Codecs = ReleaseInfo.CodecChoices 11 | Containers = ReleaseInfo.ContainerChoices 12 | Sources = ReleaseInfo.SourceChoices 13 | Resolutions = ReleaseInfo.ResolutionChoices 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | def GetSourceScore(source): 19 | scores = { 20 | "VHS": 3, 21 | "TV": 3, 22 | "HDTV": 5, 23 | "WEB": 5, 24 | # DVD has the same score as HD-DVD and Blu-ray because it must be 25 | # manually checked if it can co-exists or not. 26 | "DVD": 7, 27 | "HD-DVD": 7, 28 | "Blu-ray": 7, 29 | } 30 | 31 | return scores.get(source, -1) # -1 is the default value 32 | 33 | 34 | # Notes: 35 | # - We treat HD-DVD and Blu-ray as same quality. 36 | # - We treat DVD and Blu-ray rips equally in the standard definition category. 37 | # - We treat H.264 and x264 equally because of the uploading rules: "MP4 can only be trumped by MKV if the use of that container causes problems with video or audio". 38 | # - We treat XviD and DivX as equally irrelevant. 39 | class PtpMovieSearchResult: 40 | def __init__(self, ptpId, moviePageJsonText): 41 | self.PtpId = ptpId 42 | self.ImdbId = "" 43 | self.ImdbRating = "" 44 | self.ImdbVoteCount = "" 45 | self.Torrents = [] 46 | 47 | if moviePageJsonText is not None: 48 | self.__ParseMoviePage(moviePageJsonText) 49 | 50 | def __ParseMoviePageMakeItems(self, itemList, torrent): 51 | torrent["Id"] = int(torrent["Id"]) 52 | torrent["Size"] = int(torrent["Size"]) 53 | torrent["SourceScore"] = GetSourceScore(torrent["Source"]) 54 | torrent["UploadTime"] = datetime.datetime.strptime( 55 | torrent["UploadTime"], "%Y-%m-%d %H:%M:%S" 56 | ) 57 | if "RemasterTitle" not in torrent: 58 | torrent["RemasterTitle"] = "" 59 | if "RemasterYear" not in torrent: 60 | torrent["RemasterYear"] = "" 61 | 62 | fullTitle = f'{torrent["ReleaseName"]} / {torrent["Container"]} / {torrent["Codec"]} / {torrent["Resolution"]}' 63 | if len(torrent["RemasterTitle"]) > 0: 64 | fullTitle += " / " + torrent["RemasterTitle"] 65 | if len(torrent["RemasterYear"]) > 0: 66 | fullTitle += " (%s)" % torrent["RemasterYear"] 67 | torrent["FullTitle"] = fullTitle 68 | 69 | itemList.append(torrent) 70 | 71 | def __ParseMoviePage(self, moviePageJsonText): 72 | moviePageJson = json.loads(moviePageJsonText) 73 | 74 | if moviePageJson["Result"] != "OK": 75 | raise PtpUploaderException( 76 | "Unexpected movie page JSON response: '%s'." % moviePageJsonText 77 | ) 78 | 79 | self.ImdbId = moviePageJson.get("ImdbId", "") 80 | self.ImdbRating = str(moviePageJson.get("ImdbRating", "")) 81 | self.ImdbVoteCount = str(moviePageJson.get("ImdbVoteCount", "")) 82 | 83 | torrents = moviePageJson["Torrents"] 84 | if len(torrents) <= 0: 85 | raise PtpUploaderException( 86 | "No torrents on movie page 'https://passthepopcorn.me/torrents.php?id=%s'." 87 | % self.PtpId 88 | ) 89 | 90 | # Get the list of torrents for each section. 91 | for torrent in torrents: 92 | self.__ParseMoviePageMakeItems(self.Torrents, torrent) 93 | 94 | def GetLatestTorrent(self): 95 | return sorted(self.Torrents, key=lambda t: int(t["Id"]), reverse=True)[0] 96 | 97 | def IsReleaseExists(self, release): 98 | candidates = [t.copy() for t in self.Torrents] # Semi-shallow copy 99 | if self.PtpId == "": 100 | return None 101 | # Flag un-checkable fields 102 | if release.Codec == Codecs.Other: 103 | raise PtpUploaderException( 104 | "Unsupported codec '%s' for duplicate checking" % release.CodecOther 105 | ) 106 | if release.Container == Containers.Other: 107 | raise PtpUploaderException( 108 | "Unsupported container '%s' for duplicate checking" 109 | % release.ContainerOther 110 | ) 111 | if release.Source == Sources.Other: 112 | raise PtpUploaderException( 113 | "Unsupported source '%s' for duplicate checking" % release.SourceOther 114 | ) 115 | 116 | # 3.1.3 If literally anything else exists, xvid/divx need manual checking 117 | if release.Codec in [ 118 | Codecs.XVID, 119 | Codecs.DIVX, 120 | ]: 121 | return candidates[0] 122 | candidates = [ 123 | t for t in candidates if t["Codec"] not in [Codecs.XVID, Codecs.DIVX] 124 | ] 125 | 126 | # 4.4.1 One slot per untouched DVD format, and screen them out early 127 | if release.ResolutionType in ["PAL", "NTSC"]: 128 | if release.ResolutionType in [t["Resolution"] for t in candidates]: 129 | return [ 130 | t for t in candidates if t["Resolution"] == release.ResolutionType 131 | ][0] 132 | return None 133 | candidates = [t for t in candidates if t["Resolution"] not in ["PAL", "NTSC"]] 134 | 135 | for t in candidates: 136 | # PTP wouldn't let us upload something with the same name anyway 137 | if t["ReleaseName"] == release.ReleaseName: 138 | return t 139 | # Most likely not coincedence 140 | if t["Size"] == release.Size: 141 | return t 142 | 143 | # Find any really close duplicates (within 3%) 144 | if t["Source"] == release.Source and t["Codec"] == release.Codec: 145 | if abs((release.Size / int(t["Size"])) - 1) * 100 < 3: 146 | return t 147 | 148 | # Two slots are available, first check if we can coexist with any of them 149 | if ( 150 | release.ResolutionType in [Resolutions.Other, "480p"] 151 | and t["Quality"] == "Standard Definition" 152 | ): 153 | if ( 154 | abs((release.Size / int(t["Size"])) - 1) * 100 < 40 155 | ): # 4.1.1.1 40% size difference to be able to coexist 156 | return t 157 | if release.ResolutionType == "576p" and t["Resolution"] == "576p": 158 | return t 159 | 160 | return None 161 | -------------------------------------------------------------------------------- /src/PtpUploader/PtpUploaderException.py: -------------------------------------------------------------------------------- 1 | # Inner exceptions will be only supported in Python 3000... 2 | # All this magic here looks gross 3 | 4 | 5 | class PtpUploaderException(Exception): 6 | # Overloads: 7 | # - PtpUploaderException( message ) 8 | # - PtpUploaderException( jobRunningState, message ) 9 | def __init__(self, *args): 10 | if len(args) == 1: 11 | Exception.__init__(self, args[0]) 12 | self.JobRunningState = 4 # Failed, see ReleaseInfo 13 | else: 14 | Exception.__init__(self, args[1]) 15 | self.JobRunningState = args[0] 16 | 17 | 18 | # We handle this exception specially to make it unrecoverable. 19 | # This is needed because to many login attempts with bad user name or password could result in temporary ban. 20 | class PtpUploaderInvalidLoginException(PtpUploaderException): 21 | def __init__(self, message): 22 | PtpUploaderException.__init__(self, message) 23 | -------------------------------------------------------------------------------- /src/PtpUploader/PtpUploaderMessage.py: -------------------------------------------------------------------------------- 1 | class PtpUploaderMessageBase: 2 | pass 3 | 4 | 5 | class PtpUploaderMessageStartJob(PtpUploaderMessageBase): 6 | def __init__(self, releaseInfoId): 7 | self.ReleaseInfoId = releaseInfoId 8 | 9 | 10 | class PtpUploaderMessageStopJob(PtpUploaderMessageBase): 11 | def __init__(self, releaseInfoId): 12 | self.ReleaseInfoId = releaseInfoId 13 | 14 | 15 | class PtpUploaderMessageDeleteJob(PtpUploaderMessageBase): 16 | def __init__(self, releaseInfoId, mode): 17 | self.ReleaseInfoId = releaseInfoId 18 | self.mode = mode 19 | 20 | 21 | class PtpUploaderMessageNewAnnouncementFile(PtpUploaderMessageBase): 22 | def __init__(self, announcementFilePath): 23 | self.AnnouncementFilePath = announcementFilePath 24 | 25 | 26 | class PtpUploaderMessageQuit(PtpUploaderMessageBase): 27 | pass 28 | -------------------------------------------------------------------------------- /src/PtpUploader/ReleaseInfoMaker.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import re 4 | 5 | from pathlib import Path 6 | from typing import Optional 7 | 8 | import django 9 | import requests 10 | 11 | from pyrosimple.util import metafile 12 | 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "PtpUploader.web.settings") 15 | django.setup() 16 | 17 | from PtpUploader import MyGlobals, Ptp, release_extractor 18 | from PtpUploader.MyGlobals import MyGlobals 19 | from PtpUploader.PtpUploaderException import PtpUploaderException 20 | from PtpUploader.ReleaseDescriptionFormatter import ReleaseDescriptionFormatter 21 | from PtpUploader.ReleaseInfo import ReleaseInfo 22 | from PtpUploader.Settings import Settings 23 | from PtpUploader.Tool import Mktor 24 | 25 | 26 | class ReleaseInfoMaker: 27 | def __init__(self, path: os.PathLike): 28 | self.path = Path(path) 29 | self.release_info = ReleaseInfo() 30 | self.release_info.ReleaseDownloadPath = str(self.path) 31 | self.release_info.ReleaseUploadPath = str(self.path) 32 | self.release_info.ReleaseName = self.path.stem 33 | self.release_info.Logger = MyGlobals.Logger 34 | 35 | def collect_files(self): 36 | self.release_info.SetIncludedFileList() 37 | self.detect_images() 38 | 39 | def detect_images(self): 40 | for file in self.release_info.AdditionalFiles(): 41 | if str(file).lower().endswith(".ifo"): 42 | self.release_info.Codec = "DVD5" 43 | return 44 | if self.path.is_dir() and "BDMV" in list([f.name for f in self.path.iterdir()]): 45 | self.release_info.Codec = "BD25" 46 | 47 | def save_description(self, output_path: os.PathLike, create_screens: bool): 48 | output_path = Path(output_path) 49 | formatter = ReleaseDescriptionFormatter( 50 | self.release_info, [], [], self.path.parent, create_screens 51 | ) 52 | description = formatter.Format(includeReleaseName=True) 53 | self.release_info.Logger.info("Saving to description file %r", str(output_path)) 54 | with output_path.open("w") as handle: 55 | handle.write(description) 56 | 57 | def make_release_info( 58 | self, 59 | create_torrent=True, 60 | create_screens=True, 61 | overwrite=False, 62 | setDescription: Optional[str] = None, 63 | ): 64 | self.collect_files() 65 | description_path = Path( 66 | self.path.parent, 67 | "PTP " + self.release_info.ReleaseName + ".release description.txt", 68 | ) 69 | if description_path.exists() and not overwrite: 70 | raise Exception( 71 | "Can't create release description because %r already exists!" 72 | % str(description_path) 73 | ) 74 | torrent_path = Path( 75 | self.path.parent, "PTP " + self.release_info.ReleaseName + ".torrent" 76 | ) 77 | self.save_description(description_path, create_screens) 78 | if create_torrent: 79 | if torrent_path.exists() and not overwrite: 80 | raise Exception( 81 | "Can't create torrent because %r already exists!" % str(torrentPath) 82 | ) 83 | return 84 | Mktor.Make(MyGlobals.Logger, self.path, torrent_path) 85 | base_dir = ( 86 | self.path.parent if self.release_info.SourceIsAFile() else self.path 87 | ) 88 | MyGlobals.GetTorrentClient().AddTorrentSkipHashCheck( 89 | MyGlobals.Logger, torrent_path, base_dir 90 | ) 91 | 92 | def set_description(self, target: str): 93 | tID = None 94 | if os.path.exists(target): 95 | meta = metafile.Metafile.from_file(Path(target)) 96 | target = meta["comment"] 97 | if target and "torrentid=" in target: 98 | tID = re.search("torrentid=(\d+)", target).group(1) 99 | Ptp.Login() 100 | self.release_info.Logger.info("Uploading description as a report to %s", tID) 101 | if not Settings.AntiCsrfToken: 102 | raise PtpUploaderException("No AntiCsrfToken found") 103 | description_path = Path( 104 | self.path.parent, 105 | "PTP " + self.release_info.ReleaseName + ".release description.txt", 106 | ) 107 | with open(description_path, "r") as fh: 108 | r = MyGlobals.session.post( 109 | "https://passthepopcorn.me/reportsv2.php?action=takereport", 110 | data={ 111 | "extra": fh.read(), 112 | "torrentid": tID, 113 | "categoryid": "1", 114 | "type": "replacement description", 115 | "submit": "true", 116 | "AntiCsrfToken": Settings.AntiCsrfToken, 117 | }, 118 | ) 119 | r.raise_for_status() 120 | 121 | 122 | def run(): 123 | parser = argparse.ArgumentParser( 124 | description="PtpUploader Release Description Maker by TnS" 125 | ) 126 | 127 | parser.add_argument( 128 | "--notorrent", action="store_true", help="skip creating and seeding the torrent" 129 | ) 130 | parser.add_argument( 131 | "--force", 132 | action="store_true", 133 | help="overwrite the description file if it already exists", 134 | ) 135 | parser.add_argument( 136 | "--noscreens", 137 | action="store_true", 138 | help="skip creating and uploading screenshots", 139 | ) 140 | # Hidden stub option to upload description directly to PTP 141 | parser.add_argument("--set-description", help=argparse.SUPPRESS, default=None) 142 | parser.add_argument("path", nargs=1, help="The file or directory to use") 143 | 144 | args = parser.parse_args() 145 | 146 | Settings.LoadSettings() 147 | MyGlobals.InitializeGlobals(Settings.WorkingPath) 148 | 149 | releaseInfoMaker = ReleaseInfoMaker(args.path[0]) 150 | releaseInfoMaker.make_release_info( 151 | overwrite=args.force, 152 | create_torrent=(not args.notorrent), 153 | create_screens=(not args.noscreens), 154 | setDescription=args.set_description, 155 | ) 156 | if args.set_description: 157 | releaseInfoMaker.set_description(args.set_description) 158 | 159 | 160 | if __name__ == "__main__": 161 | run() 162 | -------------------------------------------------------------------------------- /src/PtpUploader/SceneGroups.txt: -------------------------------------------------------------------------------- 1 | 7SinS 2 | 8BaLLRiPS 3 | aAF 4 | aBD 5 | AEN 6 | AFO 7 | AiHD 8 | ALLiANCE 9 | AMBASSADOR 10 | AMIABLE 11 | AN0NYM0US 12 | ARCHiViST 13 | ARROW 14 | AVCHD 15 | AVS720 16 | BAND1D0S 17 | BeFRee 18 | BeStDivX 19 | BestHD 20 | BiFOS 21 | BiPOLAR 22 | BiQ 23 | BiRDHOUSE 24 | BLOW 25 | BRMP 26 | CADAVER 27 | CBGB 28 | CiNEFiLE 29 | COCAIN 30 | CONDITION 31 | Counterfeit 32 | CoWRY 33 | CREEPSHOW 34 | CROSSBOW 35 | DAA 36 | DASH 37 | D3Si 38 | DeBTViD 39 | DeBTXViD 40 | DEFACED 41 | DEFLATE 42 | DEPRAViTY 43 | DEPRiVED 44 | DERANGED 45 | DEUTERiUM 46 | DEV0 47 | DiAMOND 48 | DIMENSION 49 | DiSPOSABLE 50 | DiVERGE 51 | DiVERSiFY 52 | DOCUMENT 53 | DOGE 54 | DoNE 55 | DRONES 56 | DvF 57 | EPHEMERiD 58 | EPiSODE 59 | ESPiSE 60 | EwDp 61 | EXViD 62 | EXViDiNT 63 | FaiLED 64 | FAPCAVE 65 | FASTHD 66 | Felony 67 | FiCO 68 | FiEND 69 | FilmHD 70 | FKKHD 71 | FLAiR 72 | FLHD 73 | FLS 74 | FRAGMENT 75 | G3LHD 76 | GECKOS 77 | GFW 78 | GHOULS 79 | GiMCHi 80 | GiNJi 81 | GNiSTOR 82 | GOREHOUNDS 83 | GUACAMOLE 84 | GUFFAW 85 | GxP 86 | HAGGiS 87 | HAiDEAF 88 | HALCYON 89 | HCA 90 | HD4U 91 | HDEX 92 | HLS 93 | iBEX 94 | iFPD 95 | iGNiTiON 96 | IGUANA 97 | iLG 98 | iLLUSiON 99 | iMBT 100 | iNFAMOUS 101 | iNVANDRAREN 102 | iTCH 103 | J4F 104 | Japhson 105 | JETSET 106 | KAFFEREP 107 | KaKa 108 | Kata 109 | LAP 110 | Larceny 111 | LCHD 112 | LiBRARiANS 113 | LiViDiTY 114 | LOUNGE 115 | LPD 116 | Ltu 117 | MARGiN 118 | MARS 119 | MaxHD 120 | MCHD 121 | MELiTE 122 | MESS 123 | METH 124 | METiS 125 | MHD 126 | MoH 127 | MOOVEE 128 | MULTiPLY 129 | N3WS 130 | nDn 131 | NeDiVx 132 | NODLABS 133 | NOHD 134 | NORDiCHD 135 | NOSCREENS 136 | OEM 137 | OEM1080 138 | Ouzo 139 | PFa 140 | PHOBOS 141 | PROFOUND 142 | PSV 143 | PSYCHD 144 | QCF 145 | QSP 146 | RAWNiTRO 147 | RCDiVX 148 | REACTOR 149 | RedBlade 150 | REFiNED 151 | REKT 152 | Replica 153 | RiTALiN 154 | RiTALiX 155 | ROVERS 156 | RRH 157 | RUSTED 158 | SADPANDA 159 | SAiMORNY 160 | SAPHiRE 161 | SCREAM 162 | SECTOR7 163 | SEMTEX 164 | SEPTiC 165 | SEVENTWENTY 166 | SHORTBREHD 167 | SiNNERS 168 | SML 169 | SMOKEY 170 | SONiDO 171 | SOUNDWAVE 172 | SPARKS 173 | SPLiTSViLLE 174 | SPRiNTER 175 | SSF 176 | STRATOS 177 | SUBMERGE 178 | SUNSPOT 179 | SUPERiER 180 | SWAGGERHD 181 | TARGET 182 | TASTE 183 | TENEIGHTY 184 | TheWretched 185 | THUGLiNE 186 | TiMELORDS 187 | TiTANS 188 | TOPCAT 189 | TRiPS 190 | TWiST 191 | TWiZTED 192 | ULSHD 193 | UNTOUCHABLES 194 | UNVEiL 195 | USURY 196 | utL 197 | VAMPS 198 | VeDeTT 199 | VETO 200 | VH-PROD 201 | VoMiT 202 | WaLMaRT 203 | WASTE 204 | WAVEY 205 | WiDE 206 | WOMBAT 207 | WPi 208 | WRD 209 | WZW -------------------------------------------------------------------------------- /src/PtpUploader/Source/File.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import shutil 4 | from pathlib import Path 5 | 6 | from unidecode import unidecode 7 | 8 | from PtpUploader import nfo_parser 9 | from PtpUploader.Helper import GetPathSize 10 | from PtpUploader.PtpUploaderException import PtpUploaderException 11 | from PtpUploader.ReleaseNameParser import ReleaseNameParser 12 | from PtpUploader.Source.SourceBase import SourceBase 13 | 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class File(SourceBase): 19 | UploadDirectoryName = "PTP" 20 | 21 | def __init__(self): 22 | SourceBase.__init__(self) 23 | 24 | self.Name = "file" 25 | self.NameInSettings = "FileSource" 26 | 27 | def IsEnabled(self) -> bool: 28 | return True 29 | 30 | def PrepareDownload(self, _logger, releaseInfo): 31 | path = releaseInfo.GetReleaseDownloadPath() 32 | 33 | if not os.path.exists(path): 34 | raise PtpUploaderException(f"Source '{path}' doesn't exist.") 35 | 36 | releaseInfo.Size = GetPathSize(path) 37 | 38 | releaseNameParser = ReleaseNameParser(releaseInfo.ReleaseName) 39 | releaseNameParser.GetSourceAndFormat(releaseInfo) 40 | if releaseNameParser.Scene: 41 | releaseInfo.SetSceneRelease() 42 | 43 | def CheckFileList(self, *_): 44 | pass 45 | 46 | def IsDownloadFinished(self, _logger, releaseInfo): 47 | return True 48 | 49 | def GetCustomUploadPath(self, _logger, releaseInfo): 50 | path = releaseInfo.GetReleaseDownloadPath() 51 | if releaseInfo.SourceIsAFile(): 52 | # In case of single files the parent directory of the file will be the upload directory. 53 | return os.path.split(path)[0] 54 | return os.path.join( 55 | path, File.UploadDirectoryName, unidecode(releaseInfo.ReleaseName) 56 | ) 57 | 58 | def CreateUploadDirectory(self, releaseInfo): 59 | if not releaseInfo.SourceIsAFile(): 60 | SourceBase.CreateUploadDirectory(self, releaseInfo) 61 | 62 | def ReadNfo(self, releaseInfo): 63 | if releaseInfo.SourceIsAFile(): 64 | # Try to read the NFO with the same name as the video file but with nfo extension. 65 | basePath, fileName = os.path.split(releaseInfo.GetReleaseDownloadPath()) 66 | fileName, _ = os.path.splitext(fileName) 67 | nfoPath = os.path.join(basePath, fileName) + ".nfo" 68 | if os.path.isfile(nfoPath): 69 | releaseInfo.Nfo = nfo_parser.read_nfo(nfoPath) 70 | else: 71 | SourceBase.ReadNfo(self, releaseInfo) 72 | 73 | def GetIncludedFileList(self, releaseInfo): 74 | includedFileList = IncludedFileList() 75 | 76 | path = releaseInfo.GetReleaseDownloadPath() 77 | if os.path.isdir(path): 78 | includedFileList.FromDirectory(path) 79 | 80 | return includedFileList 81 | 82 | def IterReleaseFiles(self, releaseInfo): 83 | return Path(releaseInfo.GetReleaseDownloadPath()).rglob("*") 84 | 85 | @staticmethod 86 | def __DeleteDirectoryWithoutTheUploadDirectory(path): 87 | if not os.path.isdir(path): 88 | return 89 | 90 | if not config.uploader.allow_recursive_delete: 91 | raise PtpUploaderException( 92 | "Recursive delete requested but functionality is disabled by uploader.allow_recursive_delete setting" 93 | ) 94 | 95 | entries = os.listdir(path) 96 | for entry in entries: 97 | if entry == File.UploadDirectoryName: 98 | continue 99 | 100 | absolutePath = os.path.join(path, entry) 101 | 102 | if os.path.isdir(absolutePath): 103 | shutil.rmtree(absolutePath) 104 | elif os.path.isfile(absolutePath): 105 | os.remove(absolutePath) 106 | 107 | def Delete(self, releaseInfo, torrentClient, deleteSourceData, deleteUploadData): 108 | # We have to make sure to not to delete source if it is single file because no hard link is being made in this case. 109 | # Also see how GetCustomUploadPath works. 110 | sourceIsAFile = None 111 | path = releaseInfo.GetReleaseDownloadPath() 112 | if os.path.isdir(path): 113 | sourceIsAFile = False 114 | elif os.path.isfile(path): 115 | sourceIsAFile = True 116 | 117 | if ( 118 | deleteSourceData or deleteUploadData 119 | ) and not config.uploader.allow_recursive_delete: 120 | raise PtpUploaderException( 121 | "Recursive delete requested but functionality is disabled by uploader.allow_recursive_delete setting" 122 | ) 123 | 124 | # Delete source folder without the PTP directory. 125 | if deleteSourceData: 126 | if not sourceIsAFile: 127 | File.__DeleteDirectoryWithoutTheUploadDirectory( 128 | releaseInfo.GetReleaseDownloadPath() 129 | ) 130 | 131 | if deleteUploadData: 132 | # Delete the uploaded torrent file. 133 | if releaseInfo.UploadTorrentFilePath and os.path.isfile( 134 | releaseInfo.UploadTorrentFilePath 135 | ): 136 | os.remove(releaseInfo.UploadTorrentFilePath) 137 | 138 | # Delete the uploaded torrent from the torrent client. 139 | if len(releaseInfo.UploadTorrentInfoHash) > 0: 140 | torrentClient.DeleteTorrent(logger, releaseInfo.UploadTorrentInfoHash) 141 | 142 | # Delete the data of the uploaded torrent. 143 | # If it is a single file then upload path is its parent directory, so it would be unfortunate to delete. (See GetCustomUploadPath.) 144 | if not sourceIsAFile and os.path.isdir(releaseInfo.GetReleaseUploadPath()): 145 | shutil.rmtree(releaseInfo.GetReleaseUploadPath()) 146 | 147 | if deleteSourceData and deleteUploadData: 148 | if sourceIsAFile: 149 | os.remove(releaseInfo.GetReleaseDownloadPath()) 150 | 151 | def GetTemporaryFolderForImagesAndTorrent(self, releaseInfo): 152 | if releaseInfo.SourceIsAFile(): 153 | return releaseInfo.GetReleaseUploadPath() 154 | else: 155 | return os.path.join( 156 | releaseInfo.GetReleaseDownloadPath(), File.UploadDirectoryName 157 | ) 158 | 159 | def IsSingleFileTorrentNeedsDirectory(self, releaseInfo): 160 | return not releaseInfo.SourceIsAFile() 161 | -------------------------------------------------------------------------------- /src/PtpUploader/Source/Prowlarr.py: -------------------------------------------------------------------------------- 1 | import html 2 | import logging 3 | import os 4 | import re 5 | import xml.etree.ElementTree as ET 6 | 7 | from urllib.parse import urlparse 8 | 9 | import requests 10 | 11 | from PtpUploader import release_extractor 12 | from PtpUploader.PtpUploaderException import PtpUploaderException 13 | from PtpUploader.ReleaseNameParser import ReleaseNameParser 14 | from PtpUploader.Settings import Settings 15 | from PtpUploader.Source.SourceBase import SourceBase 16 | 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | class Prowlarr(SourceBase): 22 | def __init__(self): 23 | super().__init__() 24 | self.MaximumParallelDownloads = 1 25 | self.Name = "prowlarr" 26 | self.NameInSettings = "Prowlarr" 27 | 28 | def LoadSettings(self, _): 29 | super().LoadSettings(_) 30 | self.ApiKey = self.settings.api_key 31 | self.Url = self.settings.url 32 | self.loaded_indexers = {} 33 | 34 | def Login(self): 35 | logger.info("Logging into %s", self.Name) 36 | self.session = requests.Session() 37 | self.session.headers.update({"X-Api-Key": self.ApiKey}) 38 | r = self.session.get(self.Url + "/api/v1/indexer").json() 39 | for t in r: 40 | self.loaded_indexers[t["name"]] = t 41 | logger.info( 42 | "Loaded indexers from prowlarr: %s", list(self.loaded_indexers.keys()) 43 | ) 44 | 45 | def PrepareDownload(self, _, releaseInfo): 46 | logger.info("Processing '%s' with prowlarr", releaseInfo.AnnouncementId) 47 | if not releaseInfo.ImdbId: 48 | return 49 | match = self.match_imdb(releaseInfo) 50 | if match is None: 51 | logger.warning("Could not find release info in prowlarr") 52 | return 53 | for field in match: 54 | if field.tag == "title" and not releaseInfo.ReleaseName: 55 | releaseInfo.ReleaseName = field.text 56 | if field.tag == "size" and not releaseInfo.Size: 57 | releaseInfo.Size = field.text 58 | releaseNameParser = ReleaseNameParser(releaseInfo.ReleaseName) 59 | releaseNameParser.GetSourceAndFormat(releaseInfo) 60 | if releaseNameParser.Scene: 61 | releaseInfo.SetSceneRelease() 62 | 63 | def match_imdb(self, releaseInfo): 64 | indexer = self.get_indexer(releaseInfo) 65 | response = self.session.get( 66 | f"{self.Url}/api/v1/indexer/{indexer['id']}/newznab", 67 | params={"t": "movie", "imdbid": "tt" + str(releaseInfo.ImdbId)}, 68 | ) 69 | for i in ET.fromstring(response.text)[0].findall("item"): 70 | for field in i: 71 | if ( 72 | field.tag in ["guid", "comments"] 73 | and field.text == releaseInfo.AnnouncementId 74 | ): 75 | return i 76 | return None 77 | 78 | def get_indexer(self, release): 79 | o = urlparse(release.AnnouncementId) 80 | for t in self.loaded_indexers.values(): 81 | for u in t["indexerUrls"]: 82 | iu = urlparse(u) 83 | if iu.netloc == o.netloc: 84 | return t 85 | return None 86 | 87 | def DownloadTorrent(self, _, releaseInfo, path): 88 | match = self.match_imdb(releaseInfo) 89 | link = None 90 | for field in match: 91 | if field.tag == "link": 92 | link = field.text 93 | break 94 | if link is None: 95 | raise PtpUploaderException("No download link found in prowlarr") 96 | with open(path, "wb") as fh: 97 | fh.write(self.session.get(html.unescape(link)).content) 98 | 99 | def GetTemporaryFolderForImagesAndTorrent(self, releaseInfo): 100 | return releaseInfo.GetReleaseRootPath() 101 | 102 | def IsSingleFileTorrentNeedsDirectory(self, releaseInfo) -> bool: 103 | return True 104 | 105 | def IncludeReleaseNameInReleaseDescription(self): 106 | return True 107 | 108 | def GetIdFromUrl(self, url: str) -> str: 109 | o = urlparse(url) 110 | for t in self.loaded_indexers.values(): 111 | for u in t["indexerUrls"]: 112 | iu = urlparse(u) 113 | if iu.netloc == o.netloc: 114 | return url 115 | return "" 116 | 117 | # Unique situation, the ID is a full URL, since that's what prowlarr's 118 | # newznab API uses as a guid 119 | def GetUrlFromId(self, id: str) -> str: 120 | return id 121 | -------------------------------------------------------------------------------- /src/PtpUploader/Source/SourceFactory.py: -------------------------------------------------------------------------------- 1 | from PtpUploader.MyGlobals import MyGlobals 2 | from PtpUploader.Settings import Settings 3 | from PtpUploader.Source.Cinemageddon import Cinemageddon 4 | from PtpUploader.Source.File import File 5 | from PtpUploader.Source.Karagarga import Karagarga 6 | from PtpUploader.Source.Prowlarr import Prowlarr 7 | from PtpUploader.Source.Torrent import Torrent 8 | 9 | 10 | class SourceFactory: 11 | def __init__(self): 12 | self.Sources = {} 13 | 14 | self.__AddSource(File()) 15 | self.__AddSource(Torrent()) 16 | 17 | self.__AddSource(Cinemageddon()) 18 | self.__AddSource(Karagarga()) 19 | self.__AddSource(Prowlarr()) 20 | 21 | MyGlobals.Logger.info("Sources initialized.") 22 | 23 | def __AddSource(self, source): 24 | if source.IsEnabled(): 25 | source.LoadSettings(Settings) 26 | source.Login() 27 | self.Sources[source.Name] = source 28 | 29 | def GetSource(self, sourceName): 30 | # We don't want to throw KeyError exception, so we use get. 31 | return self.Sources.get(sourceName) 32 | 33 | def GetSourceAndIdByUrl(self, url): 34 | for _, source in list(self.Sources.items()): 35 | source_id = source.GetIdFromUrl(url) 36 | if source_id: 37 | return source, source_id 38 | 39 | return None, "" 40 | -------------------------------------------------------------------------------- /src/PtpUploader/Source/Torrent.py: -------------------------------------------------------------------------------- 1 | import bencode 2 | 3 | from PtpUploader.PtpUploaderException import PtpUploaderException 4 | from PtpUploader.ReleaseNameParser import ReleaseNameParser 5 | from PtpUploader.Source.SourceBase import SourceBase 6 | 7 | 8 | class Torrent(SourceBase): 9 | def __init__(self): 10 | SourceBase.__init__(self) 11 | 12 | self.Name = "torrent" 13 | self.NameInSettings = "TorrentFileSource" 14 | 15 | def IsEnabled(self): 16 | return True 17 | 18 | def PrepareDownload(self, logger, releaseInfo): 19 | # TODO: support for uploads from torrent without specifying IMDb id and reading it from NFO. (We only get IMDb id when the download is finished.) 20 | 21 | # TODO: support for new movies without IMDB id 22 | if (not releaseInfo.ImdbId) and (not releaseInfo.PtpId): 23 | raise PtpUploaderException("Doesn't contain IMDb ID.") 24 | if not releaseInfo.ReleaseName: 25 | with open(releaseInfo.SourceTorrentFilePath, "rb") as fh: 26 | meta = bencode.decode(fh.read()) 27 | releaseInfo.ReleaseName = meta["info"]["name"] 28 | releaseNameParser = ReleaseNameParser(releaseInfo.ReleaseName) 29 | releaseNameParser.GetSourceAndFormat(releaseInfo) 30 | if releaseNameParser.Scene: 31 | releaseInfo.SetSceneRelease() 32 | -------------------------------------------------------------------------------- /src/PtpUploader/Source/__init__.py: -------------------------------------------------------------------------------- 1 | # The __init__.py files are required to make Python treat the directories as containing packages 2 | # http://docs.python.org/tutorial/modules.html 3 | -------------------------------------------------------------------------------- /src/PtpUploader/Tool/BdInfo.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import shlex 4 | import subprocess 5 | 6 | from pathlib import Path 7 | from tempfile import TemporaryDirectory 8 | from typing import List, Optional, Tuple 9 | 10 | from PtpUploader.PtpUploaderException import PtpUploaderException 11 | from PtpUploader.Settings import config 12 | 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | def bdinfo_cmd() -> List[str]: 18 | if config.tools.bdinfo.path: 19 | return [ 20 | str(Path(p).expanduser()) for p in shlex.split(config.tools.bdinfo.path) 21 | ] 22 | else: 23 | raise PtpUploaderException("BDInfo path not set") 24 | 25 | 26 | def run(path: Path, mpls=None, extra_args=None) -> str: 27 | if mpls is None: 28 | mpls, _ = get_longest_playlist(path) 29 | logger.info(f"Building bdinfo for path '{path}' and playlist '{mpls}'") 30 | with TemporaryDirectory() as tempdir: 31 | args: List[str] = bdinfo_cmd() + [str(path), tempdir, "-m", mpls] 32 | if extra_args: 33 | args.append(extra_args) 34 | subprocess.run(args, check=True) 35 | for child in Path(tempdir).glob("*"): 36 | with child.open("r") as fh: 37 | text = fh.read() 38 | match = re.search(r"(DISC INFO:.*)FILES:", text, flags=re.M | re.S) 39 | if match: 40 | return match.group(1) 41 | raise PtpUploaderException(f"Could not find BDInfo output for path '{path}'") 42 | 43 | 44 | def get_longest_playlist(path: Path) -> Tuple[str, int]: 45 | logger.info(f"Scanning '{path}' for playlists") 46 | proc = subprocess.run( 47 | bdinfo_cmd() + [str(path), "-l"], check=True, capture_output=True 48 | ) 49 | longest_mpls: Optional[str] = None 50 | longest_len: int = 0 51 | for line in proc.stdout.decode().split("\n"): 52 | if ".MPLS" in line: 53 | length_str = line[26:34].split(":") 54 | length_sec = sum( 55 | int(x[1]) * (60 ** (2 - x[0])) for x in enumerate(length_str) 56 | ) 57 | if length_sec > longest_len: 58 | longest_len = length_sec 59 | longest_mpls = line.split(" ")[9] 60 | if longest_mpls: 61 | return longest_mpls, longest_len 62 | else: 63 | logger.error("Could not parse output: %s", proc.stdout.decode()) 64 | raise PtpUploaderException(f"Could not find playlist from path '{path}'") 65 | -------------------------------------------------------------------------------- /src/PtpUploader/Tool/Ffmpeg.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import subprocess 4 | 5 | from pathlib import Path 6 | 7 | from PtpUploader.PtpUploaderException import PtpUploaderException 8 | from PtpUploader.Settings import Settings 9 | from PtpUploader.Tool.MediaInfo import MediaInfo 10 | 11 | 12 | class Ffmpeg: 13 | def __init__(self, logger, inputVideoPath): 14 | self.Logger = logger 15 | self.InputVideoPath = inputVideoPath 16 | self.ScaleSize = None 17 | 18 | self.__CalculateSizeAccordingToAspectRatio() 19 | 20 | def __CalculateSizeAccordingToAspectRatio(self): 21 | # Get resolution and pixel aspect ratio from FFmpeg. 22 | args = [Settings.FfmpegPath, "-i", self.InputVideoPath] 23 | proc: subprocess.CompletedProcess = subprocess.run(args, capture_output=True) 24 | result: str = proc.stderr.decode("utf-8", "ignore") 25 | 26 | # Formatting can be one of the following. PAR can be SAR too. 27 | # Stream #0.0(eng): Video: h264, yuv420p, 1280x544, PAR 1:1 DAR 40:17, 24 tbr, 1k tbn, 48 tbc 28 | # Stream #0.0[0x1e0]: Video: mpeg2video, yuv420p, 720x480 [PAR 8:9 DAR 4:3], 7500 kb/s, 29.97 tbr, 90k tbn, 59.94 tbc 29 | match = re.search(r"(\d+)x(\d+), [SP]AR \d+:\d+ DAR (\d+):(\d+)", result) 30 | if match is None: 31 | match = re.search(r"(\d+)x(\d+) \[[SP]AR \d+:\d+ DAR (\d+):(\d+)", result) 32 | if match is None: 33 | return 34 | 35 | width: int = int(match.group(1)) 36 | height: int = int(match.group(2)) 37 | darX: int = int(match.group(3)) 38 | darY: int = int(match.group(4)) 39 | # We ignore invalid resolutions, invalid aspect ratios and aspect ratio 1:1. 40 | if ( 41 | width <= 0 42 | or height <= 0 43 | or darX <= 0 44 | or darY <= 0 45 | or (darX == 1 and darY == 1) 46 | ): 47 | return 48 | 49 | # If we are a DVD, sometimes the DAR can get messed up between the VOB and IFO 50 | # We'll use the mediainfo to draw our conclusions from instead 51 | ifoPath = Path(self.InputVideoPath[:-5] + "0.IFO") 52 | if ifoPath.exists(): 53 | self.Logger.debug("Fetching DAR information from '%s'", ifoPath) 54 | m = MediaInfo(self.Logger, ifoPath, "") 55 | try: 56 | darX = float(m.DAR) 57 | darY = 1 58 | except ValueError: 59 | try: 60 | darX = int(m.DAR.split(":")[0].strip()) 61 | darY = int(m.DAR.split(":")[1].strip()) 62 | except (ValueError, IndexError): 63 | pass 64 | 65 | # Choose whether we resize height or width. 66 | if (float(darX) / darY) >= (float(width) / height): 67 | # Resize width 68 | newWidth = (height * darX) / darY 69 | newWidth = int(newWidth) 70 | if abs(newWidth - width) <= 1: 71 | return 72 | 73 | # For FFmpeg frame size must be a multiple of 2. 74 | if (newWidth % 2) != 0: 75 | newWidth += 1 76 | 77 | self.ScaleSize = f"{newWidth}x{height}" 78 | else: 79 | # Resize height 80 | newHeight = (width * darY) / darX 81 | newHeight = int(newHeight) 82 | if abs(newHeight - height) <= 1: 83 | return 84 | 85 | # For FFmpeg frame size must be a multiple of 2. 86 | if (newHeight % 2) != 0: 87 | newHeight += 1 88 | 89 | self.ScaleSize = f"{width}x{newHeight}" 90 | 91 | def MakeScreenshotInPng(self, timeInSeconds, outputPngPath): 92 | self.Logger.info( 93 | "Making screenshot with ffmpeg from '%s' to '%s'." 94 | % (self.InputVideoPath, outputPngPath) 95 | ) 96 | 97 | # -an: disable audio 98 | # -sn: disable subtitle 99 | # There is no way to set PNG compression level. :( 100 | args = [] 101 | time = str(int(timeInSeconds)) 102 | args = [ 103 | Settings.FfmpegPath, 104 | "-an", 105 | "-sn", 106 | "-ss", 107 | time, 108 | "-i", 109 | self.InputVideoPath, 110 | "-vcodec", 111 | "png", 112 | "-vframes", 113 | "1", 114 | "-pix_fmt", 115 | "rgb24", 116 | ] 117 | if self.ScaleSize is not None: 118 | self.Logger.info( 119 | "Pixel aspect ratio wasn't 1:1, scaling video to resolution: '%s'." 120 | % self.ScaleSize 121 | ) 122 | args += [ 123 | "-s", 124 | self.ScaleSize, 125 | ] 126 | args += ["-y", outputPngPath] 127 | errorCode = subprocess.call(args) 128 | if errorCode != 0: 129 | raise PtpUploaderException( 130 | "Process execution '%s' returned with error code '%s'." 131 | % (args, errorCode) 132 | ) 133 | -------------------------------------------------------------------------------- /src/PtpUploader/Tool/ImageMagick.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import subprocess 4 | 5 | from PtpUploader.PtpUploaderException import PtpUploaderException 6 | from PtpUploader.Settings import Settings, config 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | def convert_8bit(src_image): 12 | if not os.path.isfile(src_image): 13 | raise PtpUploaderException( 14 | "Can't read source image '%s' for PNG bit depth check." % src_image 15 | ) 16 | proc = subprocess.run(["identify", src_image], capture_output=True, check=True) 17 | if b"16-bit" not in proc.stdout: 18 | return src_image 19 | logger.info("Converting PNG %r to 8-bit depth with ImageMagick.", src_image) 20 | dest_image = src_image + ".optimized.png" 21 | subprocess.run( 22 | [config.tools.imagemagick.path, src_image, "-depth", "8", dest_image] 23 | ) 24 | os.remove(src_image) 25 | os.rename(dest_image, src_image) 26 | return src_image 27 | 28 | 29 | def optimize_png(src_image): 30 | logger.info("Optimizing PNG '%s' with ImageMagick." % (src_image)) 31 | 32 | if not os.path.isfile(src_image): 33 | raise PtpUploaderException( 34 | "Can't read source image '%s' for PNG optimization." % src_image 35 | ) 36 | 37 | dest_image = src_image + ".optimized.png" 38 | if os.path.exists(dest_image): 39 | raise PtpUploaderException( 40 | "Can't optimize PNG because output file '%s' already exists." % dest_image 41 | ) 42 | 43 | args = [ 44 | config.tools.imagemagick.path, 45 | src_image, 46 | "-define", 47 | "png:exclude-chunk=gAMA", 48 | "-define", 49 | "png:exclude-chunk=cHRM", 50 | "-quality", 51 | "99", 52 | dest_image, 53 | ] 54 | errorCode = subprocess.call(args) 55 | if errorCode != 0: 56 | raise PtpUploaderException( 57 | "Process execution '%s' returned with error code '%s'." % (args, errorCode) 58 | ) 59 | 60 | sourceSize = os.path.getsize(src_image) 61 | outputSize = os.path.getsize(dest_image) 62 | gainedBytes = sourceSize - outputSize 63 | if outputSize > 0 and gainedBytes > 0: 64 | os.remove(src_image) 65 | os.rename(dest_image, src_image) 66 | logger.info("Optimized PNG is %s bytes smaller." % (gainedBytes)) 67 | else: 68 | os.remove(dest_image) 69 | -------------------------------------------------------------------------------- /src/PtpUploader/Tool/LibMpv.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | from PtpUploader.PtpUploaderException import PtpUploaderException 4 | 5 | 6 | class LibMpv: 7 | def __init__(self, logger, inputVideoPath): 8 | self.Logger = logger 9 | self.InputVideoPath = inputVideoPath 10 | self.ScaleSize = None 11 | 12 | def MakeScreenshotInPng(self, timeInSeconds, outputPngPath): 13 | import mpv 14 | 15 | self.Logger.info( 16 | "Making screenshot with libmpv from '%s' to '%s'." 17 | % (self.InputVideoPath, outputPngPath) 18 | ) 19 | 20 | args = { 21 | "audio": "no", 22 | "screenshot-format": "png", 23 | "screenshot-png-compression": "9", 24 | "start": str(int(timeInSeconds)), 25 | "sub": "no", 26 | "vf": "lavfi=[scale='max(iw,iw*sar)':'max(ih/sar,ih)']", 27 | "vo": "null", 28 | } 29 | player = mpv.MPV(input_default_bindings=False, input_vo_keyboard=False, **args) 30 | 31 | @player.property_observer("video-frame-info") 32 | def time_observer(_name, value): 33 | if value is not None: 34 | if value["picture-type"] == "I": 35 | player.frame_step() 36 | img = player.screenshot_raw() 37 | img.save(outputPngPath) 38 | self.Logger.info("Saved to %s", outputPngPath) 39 | player.quit(0) 40 | 41 | player.play(self.InputVideoPath) 42 | player.wait_for_playback() 43 | del player 44 | -------------------------------------------------------------------------------- /src/PtpUploader/Tool/Mktor.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | import re 4 | from pathlib import Path 5 | from typing import List, Optional 6 | 7 | from pyrosimple.util.metafile import Metafile 8 | 9 | from PtpUploader.Settings import config 10 | 11 | 12 | def Make(logger, path, torrentPath, includedFileList: Optional[List[str]] = None): 13 | logger.info("Making torrent from '%s' to '%s'." % (path, torrentPath)) 14 | if includedFileList: 15 | logger.info("Only including %s in torrent", includedFileList) 16 | 17 | ignore = [] 18 | path = Path(path) 19 | 20 | def ptpup_walk(datapath: Path): 21 | if datapath.is_dir(): 22 | for subpath in datapath.rglob("*"): 23 | if subpath.is_file() and str(subpath.relative_to(path)) in includedFileList: 24 | yield subpath 25 | else: 26 | yield datapath 27 | 28 | if os.path.exists(torrentPath): 29 | # We should be safe to allow the existing torrent to be used, 30 | # even when/if file selection is re-implemented, all the filesystem 31 | # manipulation has to be performed before we reach this API. 32 | # If it changes, at that point we can reset the torrentPath 33 | # and let it get rebuilt here. 34 | 35 | # Ignore the result of this method, we just want to check that files haven't changed/moved 36 | metafile = Metafile.from_file(torrentPath) 37 | metafile.add_fast_resume(path) 38 | logger.info("Using existing torrent file at '%s'.", torrentPath) 39 | else: 40 | logger.info("Making torrent from '%s' to '%s'.", path, torrentPath) 41 | metafile = Metafile.from_path( 42 | Path(path), 43 | config.ptp.announce_url, 44 | created_by="PtpUploader", 45 | private=True, 46 | progress=None, 47 | file_generator=ptpup_walk, 48 | ) 49 | metafile["info"]["source"] = "PTP" 50 | metafile.save(Path(torrentPath)) 51 | -------------------------------------------------------------------------------- /src/PtpUploader/Tool/Mplayer.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import subprocess 4 | 5 | from PtpUploader.PtpUploaderException import PtpUploaderException 6 | from PtpUploader.Settings import Settings 7 | 8 | 9 | class Mplayer: 10 | NoAutoSubParameterSupported = None 11 | 12 | def __init__(self, logger, inputVideoPath): 13 | Mplayer.DetectNoAutoSubParameterSupport() 14 | 15 | self.Logger = logger 16 | self.InputVideoPath = inputVideoPath 17 | self.ScaleSize = None 18 | 19 | self.__CalculateSizeAccordingToAspectRatio() 20 | 21 | # The noautosub parameter is not present in all version of MPlayer, and it returns with error code if it is specified. 22 | @staticmethod 23 | def DetectNoAutoSubParameterSupport(): 24 | if Mplayer.NoAutoSubParameterSupported is not None: 25 | return 26 | 27 | args = [Settings.MplayerPath, "-noautosub"] 28 | Mplayer.NoAutoSubParameterSupported = ( 29 | subprocess.run(args, check=False).returncode == 0 30 | ) 31 | 32 | # We could get this info when making the first screenshot, but ScaleSize is not stored in the database and screenshots are not made again when resuming a job. 33 | def __CalculateSizeAccordingToAspectRatio(self): 34 | args = [ 35 | Settings.MplayerPath, 36 | "-identify", 37 | "-vo", 38 | "null", 39 | "-frames", 40 | "1", 41 | "-nosound", 42 | "-nosub", 43 | "-nolirc", 44 | ] 45 | if Mplayer.NoAutoSubParameterSupported: 46 | args.append("-noautosub") 47 | args.append(self.InputVideoPath) 48 | 49 | proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 50 | stdout, stderr = proc.communicate() 51 | errorCode = proc.wait() 52 | if errorCode != 0: 53 | raise PtpUploaderException( 54 | "Process execution '%s' returned with error code '%s'." 55 | % (args, errorCode) 56 | ) 57 | 58 | result = stdout.decode("utf-8", "ignore") 59 | 60 | # VO: [null] 1280x720 => 1280x720 RGB 24-bit 61 | match = re.search(r"VO: \[null\] (\d+)x(\d+) => (\d+)x(\d+).+", result) 62 | if match is None: 63 | raise PtpUploaderException("Can't read video size from MPlayer's output.") 64 | 65 | width = int(match.group(1)) 66 | height = int(match.group(2)) 67 | outputWidth = int(match.group(3)) 68 | outputHeight = int(match.group(4)) 69 | if width <= 0 or height <= 0 or outputWidth <= 0 or outputHeight <= 0: 70 | raise PtpUploaderException("Can't read video size from MPlayer's output.") 71 | 72 | if outputWidth != width or outputHeight != height: 73 | self.ScaleSize = f"{outputWidth}x{outputHeight}" 74 | 75 | def MakeScreenshotInPng(self, timeInSeconds, outputPngPath): 76 | self.Logger.info( 77 | "Making screenshot with MPlayer from '%s' to '%s'." 78 | % (self.InputVideoPath, outputPngPath) 79 | ) 80 | 81 | # mplayer -ss 101 -vo png:z=9:outdir="/home/tnsuser/temp/a b/" -frames 1 -vf scale=0:0 -nosound -nosub -noautosub -nolirc a.vob 82 | # outdir is not working with the Windows version of MPlayer, so for the sake of easier testing we set the output directory with by setting the current working directory. 83 | # -vf scale=0:0 -- use display aspect ratio 84 | time = str(int(timeInSeconds)) 85 | args = [ 86 | Settings.MplayerPath, 87 | "-ss", 88 | time, 89 | "-vo", 90 | "png:z=9", 91 | "-frames", 92 | "1", 93 | "-vf", 94 | "scale=0:0", 95 | "-nosound", 96 | "-nosub", 97 | "-nolirc", 98 | ] 99 | if Mplayer.NoAutoSubParameterSupported: 100 | args.append("-noautosub") 101 | args.append(self.InputVideoPath) 102 | 103 | errorCode = subprocess.call(args, cwd=Path(outputPngPath).parent) 104 | if errorCode != 0: 105 | raise PtpUploaderException( 106 | "Process execution '%s' returned with error code '%s'." 107 | % (args, errorCode) 108 | ) 109 | 110 | return outputPngPath 111 | -------------------------------------------------------------------------------- /src/PtpUploader/Tool/Mpv.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | from PtpUploader.PtpUploaderException import PtpUploaderException 4 | from PtpUploader.Settings import Settings 5 | 6 | 7 | class Mpv: 8 | def __init__(self, logger, inputVideoPath): 9 | self.Logger = logger 10 | self.InputVideoPath = inputVideoPath 11 | self.ScaleSize = None 12 | 13 | def MakeScreenshotInPng(self, timeInSeconds, outputPngPath): 14 | self.Logger.info( 15 | "Making screenshot with mpv from '%s' to '%s'." 16 | % (self.InputVideoPath, outputPngPath) 17 | ) 18 | 19 | args = [ 20 | Settings.MpvPath, 21 | "--no-config", 22 | "--no-audio", 23 | "--no-sub", 24 | "--start=" + str(int(timeInSeconds)), 25 | "--frames=1", 26 | "--screenshot-format=png", 27 | "--screenshot-png-compression=9", # doesn't seem to be working 28 | "--vf=lavfi=[scale='max(iw,iw*sar)':'max(ih/sar,ih)']", 29 | "--o=" + outputPngPath, 30 | self.InputVideoPath, 31 | ] 32 | 33 | errorCode = subprocess.call(args) 34 | if errorCode != 0: 35 | raise PtpUploaderException( 36 | "Process execution '%s' returned with error code '%s'." 37 | % (args, errorCode) 38 | ) 39 | -------------------------------------------------------------------------------- /src/PtpUploader/Tool/Oxipng.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import shlex 4 | import subprocess 5 | 6 | from PtpUploader.PtpUploaderException import PtpUploaderException 7 | from PtpUploader.Settings import config 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | def optimize_png(sourceImagePath: os.PathLike): 13 | logger.info("Optimizing PNG '%s' with oxipng." % (sourceImagePath)) 14 | if not os.path.isfile(sourceImagePath): 15 | raise PtpUploaderException( 16 | "Can't read source image '%s' for PNG optimization." % sourceImagePath 17 | ) 18 | args = ( 19 | [config.tools.oxipng.path] 20 | + shlex.split(config.tools.oxipng.args) 21 | + [sourceImagePath] 22 | ) 23 | proc = subprocess.run(args, capture_output=True, encoding="utf-8") 24 | if proc.returncode != 0: 25 | raise PtpUploaderException( 26 | "Process execution '%s' returned with error code '%s'." 27 | % (args, proc.returncode) 28 | ) 29 | logger.info(proc.stderr) 30 | -------------------------------------------------------------------------------- /src/PtpUploader/Tool/Rtorrent.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import time 4 | import xmlrpc.client as xmlrpc_client 5 | from pathlib import Path 6 | 7 | import pyrosimple 8 | 9 | from pyrosimple.util.metafile import Metafile 10 | from pyrosimple.util.rpc import HashNotFound 11 | 12 | from PtpUploader.MyGlobals import MyGlobals 13 | from PtpUploader.PtpUploaderException import PtpUploaderException 14 | 15 | 16 | class Rtorrent: 17 | def __init__(self, address): 18 | MyGlobals.Logger.info("Initializing PyroScope.") 19 | if address: 20 | engine = pyrosimple.connect(address) 21 | else: 22 | engine = pyrosimple.connect() 23 | self.proxy = engine.open() 24 | 25 | # downloadPath is the final path. Suggested directory name from torrent won't be added to it. 26 | # Returns with the info hash of the torrent. 27 | def AddTorrent(self, logger, torrentPath, downloadPath): 28 | logger.info( 29 | "Initiating the download of torrent '%s' with rTorrent to '%s'." 30 | % (torrentPath, downloadPath) 31 | ) 32 | 33 | metafile = Metafile.from_file(torrentPath) 34 | metafile.check_meta() 35 | 36 | contents = xmlrpc_client.Binary(metafile.bencode()) 37 | infoHash = metafile.info_hash() 38 | 39 | self.proxy.load.raw_start( 40 | "", 41 | contents, 42 | f'd.directory_base.set="{downloadPath}"', 43 | "d.custom.set=ptpuploader,true", 44 | ) 45 | 46 | # If load_raw is slow then set_directory_base throws an exception (Fault: ), 47 | # so we retry adding the torrent some delay. 48 | maximumTries = 15 49 | while True: 50 | try: 51 | self.proxy.d.hash(infoHash) 52 | break 53 | except HashNotFound: 54 | if maximumTries > 1: 55 | maximumTries -= 1 56 | time.sleep(2) # Two seconds. 57 | else: 58 | raise 59 | 60 | return infoHash 61 | 62 | # Fast resume file is created beside the source torrent with "fast resume " prefix. 63 | # downloadPath must already contain the data. 64 | # downloadPath is the final path. Suggested directory name from torrent won't be added to it. 65 | # Returns with the info hash of the torrent. 66 | def AddTorrentSkipHashCheck(self, logger, torrentPath, downloadPath): 67 | logger.info( 68 | "Adding torrent '%s' without hash checking to rTorrent to '%s'." 69 | % (torrentPath, downloadPath) 70 | ) 71 | 72 | sourceDirectory, sourceFilename = os.path.split(torrentPath) 73 | sourceFilename = "fast resume " + sourceFilename 74 | destinationTorrentPath = os.path.join(sourceDirectory, sourceFilename) 75 | 76 | if os.path.exists(destinationTorrentPath): 77 | raise PtpUploaderException( 78 | "Can't create fast resume torrent because path '%s' already exists." 79 | % destinationTorrentPath 80 | ) 81 | 82 | shutil.copyfile(torrentPath, destinationTorrentPath) 83 | 84 | metafile = Metafile.from_file(destinationTorrentPath) 85 | metafile.add_fast_resume(downloadPath) 86 | metafile.save(Path(destinationTorrentPath)) 87 | 88 | infoHash = "" 89 | try: 90 | infoHash = self.AddTorrent(logger, destinationTorrentPath, downloadPath) 91 | finally: 92 | # We always remove the fast resume torrent regardless of result of adding the torrent to rTorrent. 93 | # This ensures that even if adding to rTorent fails, then resuming the job will work. 94 | os.remove(destinationTorrentPath) 95 | 96 | return infoHash 97 | 98 | def IsTorrentFinished(self, logger, infoHash): 99 | # TODO: this try catch block is here because xmlrpclib throws an exception when it timeouts or when the torrent with the given info hash doesn't exists. 100 | # The latter error most likely will cause stuck downloads so we should add some logic here to cancel an upload. For example: if it haven't download a single byte in ten minutes we can cancel it. 101 | 102 | try: 103 | # TODO: not the most sophisticated way. 104 | # Even a watch dir with Pyinotify would be better probably. rTorrent could write the info hash to a directory watched by us. 105 | return self.proxy.d.complete(infoHash) == 1 106 | except HashNotFound as e: 107 | raise e # Raise this up to the web UI 108 | except Exception as e: 109 | logger.exception( 110 | "Got exception while trying to check torrent's completion status. hash: '%s', error: '%s'", 111 | infoHash, 112 | e, 113 | ) 114 | 115 | return False 116 | 117 | # It doesn't delete the data. 118 | def DeleteTorrent(self, logger, infoHash): 119 | try: 120 | self.proxy.d.stop(infoHash) 121 | self.proxy.d.erase(infoHash) 122 | except Exception: 123 | logger.exception( 124 | "Got exception while trying to delete torrent. Info hash: '%s'." 125 | % infoHash 126 | ) 127 | 128 | # rTorrent can't download torrents with fast resume information in them, so we clean the torrents before starting the download. 129 | # This can happen if the uploader uploaded the wrong torrent to the tracker. 130 | def CleanTorrentFile(self, logger, torrentPath): 131 | logger.info("Cleaning torrent file '%s'." % torrentPath) 132 | 133 | metafile = Metafile.from_file(torrentPath) 134 | metafile.clean_meta() 135 | metafile.save(Path(torrentPath)) 136 | -------------------------------------------------------------------------------- /src/PtpUploader/Tool/ScreenshotMaker.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import functools 3 | import os 4 | import shutil 5 | import tempfile 6 | 7 | from PtpUploader import ImageHost 8 | from PtpUploader.PtpUploaderException import PtpUploaderException 9 | from PtpUploader.Settings import Settings, config 10 | from PtpUploader.Tool import Oxipng, ImageMagick 11 | from PtpUploader.Tool.Ffmpeg import Ffmpeg 12 | from PtpUploader.Tool.Mplayer import Mplayer 13 | from PtpUploader.Tool.Mpv import Mpv 14 | from PtpUploader.Tool.LibMpv import LibMpv 15 | 16 | 17 | # Blatantly stolen from https://stackoverflow.com/a/57701186 18 | @contextlib.contextmanager 19 | def temporary_filename(suffix=None): 20 | """Context that introduces a temporary file. 21 | 22 | Creates a temporary file, yields its name, and upon context exit, deletes it. 23 | (In contrast, tempfile.NamedTemporaryFile() provides a 'file' object and 24 | deletes the file as soon as that file object is closed, so the temporary file 25 | cannot be safely re-opened by another library or process.) 26 | 27 | Args: 28 | suffix: desired filename extension (e.g. '.mp4'). 29 | 30 | Yields: 31 | The name of the temporary file. 32 | """ 33 | try: 34 | f = tempfile.NamedTemporaryFile(suffix=suffix, delete=False) 35 | tmp_name = f.name 36 | f.close() 37 | yield tmp_name 38 | finally: 39 | os.unlink(tmp_name) 40 | 41 | 42 | class ScreenshotMaker: 43 | def __init__(self, logger, inputVideoPath): 44 | self.Logger = logger 45 | 46 | self.InternalScreenshotMaker = None 47 | 48 | if config.tools.screenshot_tool == "mpv": 49 | self.InternalScreenshotMaker = Mpv(logger, inputVideoPath) 50 | elif config.tools.screenshot_tool == "libmpv": 51 | self.InternalScreenshotMaker = LibMpv(logger, inputVideoPath) 52 | elif config.tools.screenshot_tool == "ffmpeg": 53 | self.InternalScreenshotMaker = Ffmpeg(logger, inputVideoPath) 54 | elif config.tools.screenshot_tool == "mplayer": 55 | self.InternalScreenshotMaker = Mplayer(logger, inputVideoPath) 56 | else: 57 | if Settings.MpvPath and shutil.which(Settings.MpvPath): 58 | self.InternalScreenshotMaker = Mpv(logger, inputVideoPath) 59 | elif Settings.FfmpegPath and shutil.which(Settings.FfmpegPath): 60 | self.InternalScreenshotMaker = Ffmpeg(logger, inputVideoPath) 61 | elif Settings.MplayerPath and shutil.which(Settings.MplayerPath): 62 | self.InternalScreenshotMaker = Mplayer(logger, inputVideoPath) 63 | if self.InternalScreenshotMaker is None: 64 | raise PtpUploaderException("No screenshot tool found") 65 | 66 | def GetScaleSize(self): 67 | return self.InternalScreenshotMaker.ScaleSize 68 | 69 | # Returns with the URL of the uploaded image. 70 | def __TakeAndUploadScreenshot(self, timeInSeconds): 71 | with temporary_filename(".png") as outputPngPath: 72 | self.InternalScreenshotMaker.MakeScreenshotInPng( 73 | timeInSeconds, outputPngPath 74 | ) 75 | 76 | # Always convert with imagemagick, even if it's not used for compression 77 | imagemagick_exists = config.tools.imagemagick.path and shutil.which( 78 | config.tools.imagemagick.path 79 | ) 80 | if imagemagick_exists: 81 | ImageMagick.convert_8bit(outputPngPath) 82 | 83 | if config.tools.oxipng.path and shutil.which(config.tools.oxipng.path): 84 | Oxipng.optimize_png(outputPngPath) 85 | elif imagemagick_exists: 86 | ImageMagick.optimize_png(outputPngPath) 87 | 88 | imageUrl = ImageHost.upload(self.Logger, imagePath=outputPngPath) 89 | 90 | return imageUrl 91 | 92 | # Takes maximum five screenshots from the first 30% of the video. 93 | # Returns with the URLs of the uploaded images. 94 | def TakeAndUploadScreenshots( 95 | self, outputImageDirectory, durationInSec, numberOfScreenshotsToTake 96 | ): 97 | urls = [] 98 | 99 | if numberOfScreenshotsToTake > config.uploader.max_screenshots: 100 | numberOfScreenshotsToTake = config.uploader.max_screenshots 101 | 102 | for i in range(numberOfScreenshotsToTake): 103 | position = 0.10 + (i * 0.05) 104 | urls.append(self.__TakeAndUploadScreenshot(int(durationInSec * position))) 105 | 106 | return urls 107 | 108 | # We sort video files by their size (less than 50 MB difference is ignored) and by their name. 109 | # Sorting by name is needed to ensure that the screenshot is taken from the first video to avoid spoilers when a release contains multiple videos. 110 | # Sorting by size is needed to ensure that we don't take the screenshots from the sample or extras included. 111 | # Ignoring less than 50 MB difference is needed to make sure that CD1 will be sorted before CD2 even if CD2 is larger than CD1 by 49 MB. 112 | @staticmethod 113 | def SortVideoFiles(files): 114 | class SortItem: 115 | def __init__(self, path): 116 | self.Path = path 117 | self.LowerPath = str(path).lower() 118 | self.Size = os.path.getsize(path) 119 | 120 | @staticmethod 121 | def Compare(item1, item2): 122 | ignoreSizeDifference = 50 * 1024 * 1024 123 | sizeDifference = item1.Size - item2.Size 124 | if abs(sizeDifference) > ignoreSizeDifference: 125 | if item1.Size > item2.Size: 126 | return -1 127 | else: 128 | return 1 129 | 130 | if item1.LowerPath < item2.LowerPath: 131 | return -1 132 | elif item1.LowerPath > item2.LowerPath: 133 | return 1 134 | else: 135 | return 0 136 | 137 | filesToSort = [] 138 | for file in files: 139 | item = SortItem(file) 140 | filesToSort.append(item) 141 | 142 | filesToSort.sort(key=functools.cmp_to_key(SortItem.Compare)) 143 | 144 | files = [] 145 | for item in filesToSort: 146 | files.append(item.Path) 147 | 148 | return files 149 | -------------------------------------------------------------------------------- /src/PtpUploader/Tool/Transmission.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import transmissionrpc 4 | 5 | 6 | class Transmission: 7 | def __init__(self, address, port): 8 | logging.getLogger(__name__).info("Initializing transmissionrpc.") 9 | self.transmission = transmissionrpc.Client(address, port) 10 | 11 | # downloadPath is the final path. Suggested directory name from torrent won't be added to it. 12 | # Returns with the info hash of the torrent. 13 | def AddTorrent(self, logger=None, torrentPath=None, downloadPath=None): 14 | logger.info( 15 | "Initiating the download of torrent '%s' with Transmission to '%s'." 16 | % (torrentPath, downloadPath) 17 | ) 18 | torrent = self.transmission.add_torrent(torrentPath, download_dir=downloadPath) 19 | return torrent.hashString 20 | 21 | # Transmission doesn't allow hash check skipping... 22 | def AddTorrentSkipHashCheck(self, logger, torrentPath, downloadPath): 23 | logger.info( 24 | "Adding torrent '%s' without hash checking (not really) to Transmission to '%s'." 25 | % (torrentPath, downloadPath) 26 | ) 27 | hashString = self.AddTorrent(logger, torrentPath, downloadPath) 28 | return hashString 29 | 30 | def IsTorrentFinished(self, logger, infoHash): 31 | try: 32 | # TODO: not the most sophisticated way. 33 | # Even a watch dir with Pyinotify would be better probably. rTorrent could write the info hash to a directory watched by us. 34 | # completed = self.proxy.d.get_complete( infoHash ); 35 | if self.transmission.get_torrent(infoHash).doneDate > 0: 36 | return True 37 | except Exception: 38 | logger.exception( 39 | "Got exception while trying to check torrent's completion status. Info hash: '%s'." 40 | % infoHash 41 | ) 42 | 43 | return False 44 | 45 | # It doesn't delete the data. 46 | def DeleteTorrent(self, logger, infoHash): 47 | try: 48 | self.transmission.stop_torrent(infoHash) 49 | self.transmission.remove_torrent(infoHash, delete_data=False) 50 | except Exception: 51 | logger.exception( 52 | "Got exception while trying to delete torrent. Info hash: '%s'." 53 | % infoHash 54 | ) 55 | 56 | # Transmission doesn't have any problems with this.. so just skip 57 | def CleanTorrentFile(self, logger, torrentPath): 58 | # logger.info( "Cleaning torrent file '%s'... nah" % torrentPath ) 59 | pass 60 | -------------------------------------------------------------------------------- /src/PtpUploader/Tool/Unrar.py: -------------------------------------------------------------------------------- 1 | import fnmatch 2 | import os 3 | import subprocess 4 | import uuid 5 | 6 | from PtpUploader.PtpUploaderException import PtpUploaderException 7 | from PtpUploader.Settings import Settings 8 | 9 | 10 | # Supported: 11 | # - .rar 12 | # - .001 13 | # - .part01.rar 14 | class Unrar: 15 | # Because there is no easy way to tell if there will be an overwrite upon extraction, we make a temporary directory into the destination path then move the extracted files out of there. 16 | @staticmethod 17 | def Extract(rarPath, destinationPath): 18 | # Create the temporary folder. 19 | tempPath = os.path.join(destinationPath, str(uuid.uuid1())) 20 | if os.path.exists(tempPath): 21 | raise PtpUploaderException("Temporary path '%s' already exists." % tempPath) 22 | os.mkdir(tempPath) 23 | 24 | # Extract RAR to the temporary folder. 25 | args = [Settings.UnrarPath, "x", rarPath, tempPath] 26 | errorCode = subprocess.call(args) 27 | if errorCode != 0: 28 | raise PtpUploaderException( 29 | "CProcess execution '%s' returned with error code '%s'." 30 | % (args, errorCode) 31 | ) 32 | 33 | # Move everything out from the temporary folder to its destination. 34 | files = os.listdir(tempPath) 35 | for file in files: 36 | tempFilePath = os.path.join(tempPath, file) 37 | destinationFilePath = os.path.join(destinationPath, file) 38 | if os.path.exists(destinationFilePath): 39 | raise PtpUploaderException( 40 | "Can't move file '%s' to '%s' because destination already exists." 41 | % (tempFilePath, destinationFilePath) 42 | ) 43 | 44 | os.rename(tempFilePath, destinationFilePath) 45 | 46 | os.rmdir(tempPath) 47 | 48 | @staticmethod 49 | def IsFirstRar(path): 50 | path = path.lower() 51 | if fnmatch.fnmatch(path, "*.001"): 52 | return True 53 | 54 | if fnmatch.fnmatch(path, "*.rar"): 55 | if fnmatch.fnmatch(path, "*.part01.rar") or fnmatch.fnmatch( 56 | path, "*.part001.rar" 57 | ): 58 | return True 59 | if not fnmatch.fnmatch(path, "*.part*.rar"): 60 | return True 61 | 62 | return False 63 | 64 | @staticmethod 65 | def GetRars(path): 66 | entries = os.listdir(path) 67 | files = [] 68 | 69 | for entry in entries: 70 | if Unrar.IsFirstRar(entry): 71 | filePath = os.path.join(path, entry) 72 | if os.path.isfile(filePath): 73 | files.append(filePath) 74 | 75 | return files 76 | -------------------------------------------------------------------------------- /src/PtpUploader/Tool/__init__.py: -------------------------------------------------------------------------------- 1 | # The __init__.py files are required to make Python treat the directories as containing packages 2 | # http://docs.python.org/tutorial/modules.html 3 | -------------------------------------------------------------------------------- /src/PtpUploader/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/__init__.py -------------------------------------------------------------------------------- /src/PtpUploader/config.default.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Required, used for storing data 3 | work_dir: 4 | # Required, needed to correctly upload 5 | ptp: 6 | username: 7 | password: 8 | announce_url: # Can be found on the upload form 9 | web: 10 | # This is used for various scripts to send jobs to PtpUploader remotely 11 | api_key: 12 | # Allows controlling the pop-up on the upload page 13 | file_selector_root: 14 | address: 15 | ssl: 16 | enabled: false 17 | key: 18 | cert: 19 | # Settings for the upload forms and workers 20 | uploader: 21 | # Files that are explicitly video 22 | video_files: [avi, divx, mkv, mp4, vob, m2ts] 23 | # Additional files wanted but not technically videos 24 | additional_files: [bup, idx, ifo, srt, sub, bdmv, mpls, clpi] 25 | ignore_files: ['.*sample.*', '.*d-z0n3\.assistant\.rar'] # Uses a regex 26 | ignore_dirs: [] # Uses a regex 27 | # Controls the default settings for checkboxes on the upload form. Can always be overridden on a per-job basis. 28 | override_screenshots: false 29 | force_directoryless_single_file: false 30 | is_personal: false 31 | is_scene: false 32 | skip_duplicate_checking: false 33 | release_notes: '' 34 | # Maximum number of screenshots to take per file 35 | max_screenshots: 5 36 | # Since PtpUploader can't know exactly which trees are under it's control, 37 | # it has to blindly do a recursive delete for the "remove job + data" functionality. 38 | # Since that's a little dangerous even behind two mouse clicks, there's a flag 39 | # to let people turn it off. 40 | allow_recursive_delete: true 41 | # Experimental feature to check srrdb.com to see if a release is 42 | # from the scene. Since this introduces an external dependency, it 43 | # is disabled by default. 44 | srrdb_scene_check: false 45 | workers: 46 | threads: 1 # With more than 1 thread, you may encounter database locks in sqlite 47 | # Switching to mysql or postgres will fix those issues. 48 | # Stores cookies to avoid multiple logins, safe to delete if you're having login issues 49 | # It can be set to `/dev/null` to avoid saving cookies 50 | cookie_file: ~/.config/ptpuploader/cookies.pickle 51 | # rTorrent is preferred, although transmission is also supported 52 | client: 53 | use: rtorrent 54 | rtorrent: 55 | address: # If running next to rtorrent, PtpUploader will attempt to automatically determine the SCGI port. 56 | transmission: 57 | address: 58 | # Optional, can be used to set settings for specific programs 59 | tools: 60 | # Only one of mpv, ffmpeg or mplayer is required. mpv is preferred, but PtpUploader will attempt to auto-detect 61 | # whichever one is available. 62 | screenshot_tool: auto 63 | mpv: 64 | path: mpv 65 | ffmpeg: 66 | path: ffmpeg 67 | mplayer: 68 | path: mplayer 69 | mediainfo: 70 | path: mediainfo 71 | timeout: 60 72 | bdinfo: 73 | path: # Needs to be set for Bluray support 74 | # Entirely optional, but useful for losslessly compressing screenshots 75 | imagemagick: 76 | path: convert 77 | oxipng: 78 | path: oxipng 79 | args: "-o 3 --strip all" 80 | unrar: 81 | path: 82 | image_host: 83 | use: ptpimg 84 | ptpimg: 85 | # If you want to use PtpImg, the API key needs to be set 86 | # It can be found by first logging into https://ptpimg.me/. Then in the browser bar type 'javascript:', and then paste the following line: 87 | # (function()%7Balert(document.getElementById('api_key').value)%7D)() 88 | api_key: 89 | imgbb: 90 | api_key: 91 | catbox: # Needs no configuration 92 | source: 93 | # Anything set in _default is applied to all sources (including file) unless overridden in the source-specific section. 94 | _default: 95 | # Set to zero (the default) to disable 96 | job_start_delay: 0 97 | max_size: 0 98 | min_size: 0 99 | min_imdb_rating: 0 100 | min_imdb_votes: 0 101 | max_active_downloads: 0 102 | 103 | # Possible values: default (no effect), only, ignore 104 | scene: default 105 | 106 | # The 'stop' group of settings only effect automatically created jobs 107 | # Possible values: false (don't stop), before_downloading, before_extracting, before_uploading 108 | always_stop_before: before_downloading 109 | 110 | # Possible values: false (don't stop), before_downloading, before_extracting 111 | stop_if_multiple_videos: false 112 | 113 | # Possible values: false (don't stop, but it will stil throw an error before uploading due to PTP's rules), before_downloading 114 | stop_if_art_missing: false 115 | 116 | # Possible values: false (don't stop), before_downloading, before_uploading 117 | stop_if_synopsis_missing: false 118 | 119 | # FiCO is here because they release a quite a few adult(ish) movies that are miscategorized on IMDb. 120 | ignore_release_group: [aXXo, BRrip, CM8, CrEwSaDe, CTFOH, DNL, FaNGDiNG0, HD2DVD, HDTime, ION10, iPlanet, KiNGDOM, mHD, mSD, nHD, nikt0, nSD, NhaNc3, OFT, PRODJi, SANTi, STUTTERSHIT, ViSION, VXT, WAF, x0r, YIFY] 121 | allow_tags: '' 122 | ignore_tags: '' 123 | ## Example of site-specific configurations 124 | # karagarga: 125 | # job_start_delay: 120 126 | # username: user 127 | # password: password 128 | # cinemageddon: 129 | # username: user 130 | # password: password 131 | # prowlarr: 132 | # url: https://prowlarr.example.com 133 | # api_key: apikey-apikey-apikey 134 | # file: 135 | # Allows running arbitrary commands after a successful run 136 | # See ReleaseInfo.py for a full list of available fields 137 | hook: 138 | # As an example, write to a log file after an upload completes successfully 139 | on_upload: # "echo '{{ReleaseName}}: {{UploadedTorrentUrl}}' >> ~/.config/ptpuploader/finished.log" 140 | -------------------------------------------------------------------------------- /src/PtpUploader/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | from PtpUploader.MyGlobals import MyGlobals 6 | from PtpUploader.Settings import Settings 7 | 8 | 9 | def run(): 10 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "PtpUploader.web.settings") 11 | import django 12 | 13 | from django.core.management import execute_from_command_line 14 | 15 | django.setup() 16 | execute_from_command_line(sys.argv) 17 | 18 | 19 | if __name__ == "__main__": 20 | run() 21 | -------------------------------------------------------------------------------- /src/PtpUploader/nfo_parser.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | from pathlib import Path 5 | 6 | from PtpUploader.Helper import GetFileListFromTorrent 7 | 8 | 9 | IMDB_TITLE_RE = re.compile(r"imdb\.com/[Tt]itle/tt(\d+)") 10 | 11 | 12 | def get_imdb_id(nfo_text: str) -> str: 13 | match = IMDB_TITLE_RE.search(nfo_text) 14 | if match: 15 | return match.group(1) 16 | return "" 17 | 18 | 19 | def read_nfo(path: os.PathLike) -> str: 20 | with Path(path).open("rb") as nfoFile: 21 | return nfoFile.read().decode("cp437", "ignore") 22 | 23 | 24 | def find_and_read_nfo(directory: os.PathLike) -> str: 25 | # If there are multiple NFOs, it returns an empty string 26 | nfos = list(Path(directory).glob("*.nfo")) 27 | if len(nfos) != 1: 28 | return "" 29 | return read_nfo(nfos[0]) 30 | 31 | 32 | def torrent_has_multiple_nfos(torrent_path: os.PathLike) -> bool: 33 | found_nfo = False 34 | for f in GetFileListFromTorrent(torrent_path): 35 | fpath = Path(f) 36 | if fpath.parent == "." and fpath.suffix == ".nfo": 37 | if found_nfo: 38 | return True 39 | else: 40 | found_nfo = True 41 | return False 42 | -------------------------------------------------------------------------------- /src/PtpUploader/ptp_subtitle.py: -------------------------------------------------------------------------------- 1 | # Three letter codes: ISO 639-2/B 2 | # Two letter codes: ISO 639-1 3 | # http://en.wikipedia.org/wiki/List_of_ISO_639-1_codes 4 | from typing import Optional 5 | 6 | 7 | subtitle_ids = { 8 | 3: ["english", "eng", "en", "english (cc)", "english - sdh"], 9 | 4: ["spanish", "spa", "es"], 10 | 5: ["french", "fre", "fr"], 11 | 6: ["german", "ger", "de"], 12 | 7: ["russian", "rus", "ru"], 13 | 8: ["japanese", "jpn", "ja"], 14 | 9: ["dutch", "dut", "nl"], 15 | 10: ["danish", "dan", "da"], 16 | 11: ["swedish", "swe", "sv"], 17 | 12: ["norwegian", "nor", "no"], 18 | 13: ["romanian", "rum", "ro"], 19 | 14: ["chinese", "chi", "zh", "chinese (simplified)", "chinese (traditional)"], 20 | 15: ["finnish", "fin", "fi"], 21 | 16: ["italian", "ita", "it"], 22 | 17: ["polish", "pol", "pl"], 23 | 18: ["turkish", "tur", "tr"], 24 | 19: ["korean", "kor", "ko"], 25 | 20: ["thai", "tha", "th"], 26 | 21: ["portuguese", "por", "pt"], 27 | 22: ["arabic", "ara", "ar"], 28 | 23: ["croatian", "hrv", "hr", "scr"], 29 | 24: ["hungarian", "hun", "hu"], 30 | 25: ["vietnamese", "vie", "vi"], 31 | 26: ["greek", "gre", "el"], 32 | 28: ["icelandic", "ice", "is"], 33 | 29: ["bulgarian", "bul", "bg"], 34 | 30: ["czech", "cze", "cz", "cs"], 35 | 31: ["serbian", "srp", "sr", "scc"], 36 | 34: ["ukrainian", "ukr", "uk"], 37 | 37: ["latvian", "lav", "lv"], 38 | 38: ["estonian", "est", "et"], 39 | 39: ["lithuanian", "lit", "lt"], 40 | 40: ["hebrew", "heb", "he"], 41 | 41: ["hindihin", "hi"], 42 | 42: ["slovak", "slo", "sk"], 43 | 43: ["slovenian", "slv", "sl"], 44 | 44: ["no subtitles"], 45 | 47: ["indonesian", "ind", "id"], 46 | 49: ["brazilian portuguese", "brazilian", "portuguese-br"], 47 | 50: ["english - forced", "english (forced)"], 48 | 51: ["english intertitles", "english (intertitles)", "english - intertitles"], 49 | 52: ["persian", "fa", "far"], 50 | 54: ["malay", "my", "mys"], 51 | } 52 | 53 | 54 | def get_id(lang: str) -> Optional[int]: 55 | lang = lang.lower() 56 | for sub_id, names in subtitle_ids.items(): 57 | if lang in names: 58 | return sub_id 59 | return None 60 | -------------------------------------------------------------------------------- /src/PtpUploader/release_extractor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import os 4 | 5 | from pathlib import Path 6 | from typing import List 7 | 8 | import rarfile # type: ignore 9 | 10 | from unidecode import unidecode 11 | 12 | from PtpUploader.PtpUploaderException import PtpUploaderException 13 | from PtpUploader.Settings import config 14 | 15 | 16 | # from PtpUploader.ReleaseInfo import ReleaseInfo 17 | 18 | log = logging.getLogger(__name__) 19 | 20 | 21 | def parse_directory(release_info): 22 | """Split the release upload path into video and non-video files""" 23 | if release_info.SourceIsAFile(): 24 | return [release_info.GetReleaseDownloadPath()], [] 25 | path = Path(release_info.GetReleaseUploadPath()) 26 | return find_allowed_files(path) 27 | 28 | 29 | def find_allowed_files(path: Path): 30 | video_files = [] 31 | addtl_files = [] 32 | for child in Path(path).rglob("*"): 33 | if any([re.match(d, str(child.parent)) for d in config.uploader.ignore_dirs]): 34 | continue 35 | if child.is_file(): 36 | if any([re.match(r, child.name) for r in config.uploader.ignore_files]): 37 | continue 38 | if child.suffix.lower().strip(".") in config.uploader.video_files: 39 | video_files.append(child) 40 | elif child.suffix.lower().strip(".") in config.uploader.additional_files: 41 | addtl_files.append(child) 42 | return video_files, addtl_files 43 | 44 | 45 | def extract_release(release_info): 46 | """This function essentially just configures all the variables to be passed to extract_files, 47 | purely to provide separation of concerns""" 48 | ignored_top_dirs: List[str] = [] 49 | allow_exts: List[str] = ( 50 | config.uploader.video_files + config.uploader.additional_files 51 | ) 52 | source = Path(release_info.GetReleaseDownloadPath()) 53 | if release_info.SourceIsAFile(): 54 | if source.suffix.lower().strip(".") in config.uploader.video_files: 55 | log.info("Source is a single file, skipping extraction") 56 | return 57 | else: 58 | raise PtpUploaderException( 59 | f"Single file {source} is not a known video extension ({config.uploader.video_files})" 60 | ) 61 | 62 | dest = Path(release_info.GetReleaseUploadPath()) 63 | handle_scene_folders = False 64 | # Allow an existing directory only if it's empty 65 | if dest.exists() and list(dest.iterdir()): 66 | raise PtpUploaderException( 67 | f"Can't make destination directory '{dest}' because path exists and is not empty." 68 | ) 69 | if not source.exists(): 70 | raise PtpUploaderException(f"Source '{source}' does not exist.") 71 | if release_info.IsSceneRelease(): 72 | handle_scene_folders = True 73 | # Blu-rays can contain funky files, just allow all 74 | if release_info.IsBlurayImage(): 75 | allow_exts = ["*"] 76 | handle_scene_folders = False 77 | # Fix DVDs without top level VIDEO_TS 78 | if release_info.IsDvdImage() and "VIDEO_TS" not in [ 79 | c.name for c in source.glob("*") 80 | ]: 81 | dest = Path(dest, "VIDEO_TS") 82 | if ( 83 | release_info.AnnouncementSource.Name == "file" 84 | and not release_info.SourceIsAFile() 85 | ): 86 | ignored_top_dirs = ["PTP"] 87 | log.info("Extracting release from '%s' to '%s'", source, dest) 88 | try: 89 | extract_files(source, dest, allow_exts, ignored_top_dirs, handle_scene_folders) 90 | except Exception: 91 | # Clean up the directory if it's empty 92 | try: 93 | dest.rmdir() 94 | except OSError: 95 | pass 96 | raise 97 | 98 | 99 | def extract_files( 100 | source: Path, 101 | dest: Path, 102 | allow_exts: List[str], 103 | ignored_top_dirs: List[str], 104 | handle_scene_folders: bool = False, 105 | dry_run: bool = False, # Exists for testing purposes 106 | ): 107 | """This is the method to actually extract files. That usually 108 | means just hardlinking any allowed files into the same tree 109 | structure, but there is some logic to handle things like 110 | RARs. Importantly, though, this function has no concept of what 111 | the release object looks like. This helps to separate out the 112 | 'business logic'. 113 | 114 | """ 115 | if source.is_file() and ( 116 | source.suffix.lower().strip(".") in allow_exts or allow_exts == ["*"] 117 | ): 118 | dest = Path(dest, unidecode(str(Path(source.name)))) 119 | if dry_run: 120 | print(f"{source} -> {dest}") 121 | else: 122 | os.link(source, dest) 123 | for child in source.rglob("*"): 124 | if not child.is_file(): 125 | continue 126 | if len(child.parent.parts) == 0: 127 | top_dir = "." 128 | else: 129 | top_dir = child.relative_to(source).parts[0] 130 | if top_dir in ignored_top_dirs: 131 | continue 132 | child_dest = Path(dest, unidecode(str(child.relative_to(source)))) 133 | # Move scene subtitles to the top level 134 | if handle_scene_folders: 135 | if top_dir.lower().startswith("cd") or top_dir in [ 136 | "sub", 137 | "subs", 138 | "subtitle", 139 | "subtitles", 140 | ]: 141 | child_dest = Path(dest, child.name) 142 | # Extract any RARs 143 | if child.suffix == ".rar": 144 | try: 145 | rar = rarfile.RarFile(child) 146 | rar.infolist() 147 | except Exception as e: 148 | log.error("Cannot unrar file %s: %s", child, e) 149 | continue 150 | for f in rar.infolist(): 151 | if f.is_file and ( 152 | Path(f.filename).suffix.lower().strip(".") in allow_exts 153 | or "*" in allow_exts 154 | ): 155 | child_dest = Path(dest, unidecode(f.filename)) 156 | log.info(f"unrar {f.filename} from {child} -> {child_dest}") 157 | if not dry_run: 158 | dest.mkdir(parents=True, exist_ok=True) 159 | with child_dest.open("wb") as fh: 160 | fh.write(rar.open(f).readall()) 161 | # Or just hard link 162 | elif child.suffix.lower().strip(".") in allow_exts or "*" in allow_exts: 163 | if dry_run: 164 | print(f"{child} -> {child_dest}") 165 | else: 166 | child_dest.parent.mkdir(parents=True, exist_ok=True) 167 | os.link(child, child_dest) 168 | -------------------------------------------------------------------------------- /src/PtpUploader/web/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/__init__.py -------------------------------------------------------------------------------- /src/PtpUploader/web/forms.py: -------------------------------------------------------------------------------- 1 | from django.forms import ( 2 | BooleanField, 3 | CharField, 4 | ChoiceField, 5 | ClearableFileInput, 6 | CheckboxInput, 7 | FileField, 8 | Form, 9 | HiddenInput, 10 | IntegerField, 11 | ModelForm, 12 | MultipleChoiceField, 13 | PasswordInput, 14 | Textarea, 15 | TextInput, 16 | ) 17 | 18 | from PtpUploader import ImageHost, ptp_subtitle 19 | from PtpUploader.Job.JobStartMode import JobStartMode 20 | from PtpUploader.ReleaseInfo import ReleaseInfo 21 | from PtpUploader.Settings import config 22 | from django import forms 23 | 24 | 25 | class SettingsForm(Form): 26 | image_host_use = ChoiceField( 27 | choices=[(x, x) for x in ImageHost.list_hosts()], 28 | initial=config.image_host.use, 29 | label="Host", 30 | ) 31 | ptp_username = CharField(initial=config.ptp.username, label="Username") 32 | ptp_password = CharField( 33 | initial=config.ptp.password, 34 | label="Password", 35 | widget=TextInput(attrs={"type": "password"}), 36 | ) 37 | ptp_announce_url = CharField(initial=config.ptp.announce_url, label="Announce URL") 38 | client_use = ChoiceField( 39 | choices=[(x, x) for x in ["rtorrent", "transmission"]], 40 | initial=config.client.use, 41 | label="Use", 42 | ) 43 | client_address = CharField( 44 | initial=config.client[config.client.use].address, 45 | label="Address", 46 | help_text="Automatically determined if not set", 47 | required=False, 48 | ) 49 | 50 | 51 | class MultipleFileInput(ClearableFileInput): 52 | allow_multiple_selected = True 53 | 54 | 55 | class BulkReleaseForm(Form): 56 | Links = CharField(widget=Textarea(attrs={"placeholder": "Links"}), required=False) 57 | Paths = CharField(widget=Textarea(attrs={"placeholder": "Paths"}), required=False) 58 | LocalFile = forms.CharField(required=False, widget=forms.HiddenInput()) 59 | 60 | 61 | class ReleaseForm(ModelForm): 62 | Type = ChoiceField(choices=ReleaseInfo.TypeChoices.choices, required=False) 63 | Codec = ChoiceField(choices=ReleaseInfo.CodecChoices.choices, required=False) 64 | Container = ChoiceField( 65 | choices=ReleaseInfo.ContainerChoices.choices, required=False 66 | ) 67 | Source = ChoiceField(choices=ReleaseInfo.SourceChoices.choices, required=False) 68 | SceneRelease = BooleanField( 69 | initial=config.uploader.is_scene, 70 | widget=CheckboxInput(attrs={"checked": config.uploader.is_scene}), 71 | required=False, 72 | ) 73 | ResolutionType = ChoiceField( 74 | choices=ReleaseInfo.ResolutionChoices.choices, required=False 75 | ) 76 | Subtitles = MultipleChoiceField( 77 | choices=[(str(k), v[0].title()) for k, v in ptp_subtitle.subtitle_ids.items()], 78 | required=False, 79 | ) 80 | if config.uploader.skip_duplicate_checking: 81 | init = 0 82 | else: 83 | init = None 84 | DuplicateCheckCanIgnore = IntegerField( 85 | initial=init, 86 | widget=HiddenInput(), 87 | ) 88 | Tags = MultipleChoiceField( 89 | required=False, 90 | choices=[ 91 | (v, v) 92 | for v in ( 93 | "action", 94 | "adventure", 95 | "animation", 96 | "arthouse", 97 | "asian", 98 | "biography", 99 | "camp", 100 | "comedy", 101 | "crime", 102 | "cult", 103 | "documentary", 104 | "drama", 105 | "experimental", 106 | "exploitation", 107 | "family", 108 | "fantasy", 109 | "film.noir", 110 | "history", 111 | "horror", 112 | "martial.arts", 113 | "musical", 114 | "mystery", 115 | "performance", 116 | "philosophy", 117 | "politics", 118 | "romance", 119 | "sci.fi", 120 | "short", 121 | "silent", 122 | "sport", 123 | "thriller", 124 | "video.art", 125 | "war", 126 | "western", 127 | ) 128 | ], 129 | ) 130 | # Fields that don't map to the release object 131 | ForceUpload = BooleanField(required=False, initial=False) 132 | TrumpableNoEnglish = BooleanField(required=False, initial=False) 133 | TrumpableHardSubs = BooleanField(required=False, initial=False) 134 | TorrentLink = CharField(required=False, widget=TextInput(attrs={"size": "60"})) 135 | LocalFile = CharField(required=False, widget=TextInput(attrs={"size": "60"})) 136 | JobStartMode = CharField(required=False) 137 | RawFile = FileField( 138 | required=False, 139 | widget=ClearableFileInput(attrs={"accept": "application/x-bittorrent"}), 140 | ) 141 | 142 | class Meta: 143 | model = ReleaseInfo 144 | exclude = [ 145 | "JobRunningState", 146 | "ScheduleTime", 147 | "FinishedJobPhase", 148 | "Size", 149 | ] 150 | widgets = { 151 | "ImdbId": TextInput(attrs={"size": "60"}), 152 | "Directors": TextInput(attrs={"size": "60"}), 153 | "YouTubeId": TextInput(attrs={"size": "60"}), 154 | "CoverArtUrl": TextInput(attrs={"size": "60"}), 155 | "Title": TextInput(attrs={"size": "53"}), 156 | "Year": TextInput(attrs={"size": "4", "placeholder": "Year"}), 157 | "RemasterTitle": TextInput(attrs={"size": "53"}), 158 | "RemasterYear": TextInput(attrs={"size": "4", "placeholder": "Year"}), 159 | "MovieDescription": Textarea(attrs={"cols": "60", "rows": "8"}), 160 | "ReleaseNotes": Textarea(attrs={"cols": "60", "rows": "8"}), 161 | "ReleaseName": TextInput(attrs={"size": "60"}), 162 | "DuplicateCheckCanIgnore": HiddenInput(), 163 | "IncludedFileList": HiddenInput(), 164 | "SourceOther": TextInput(attrs={"size": "5"}), 165 | "Resolution": TextInput(attrs={"size": "8"}), 166 | "ContainerOther": TextInput(attrs={"size": "5"}), 167 | "CodecOther": TextInput(attrs={"size": "5"}), 168 | } 169 | 170 | def clean(self): 171 | data = super().clean() 172 | data["Tags"] = ",".join(data["Tags"]) 173 | data["JobStartMode"] = JobStartMode.Manual 174 | if data["ForceUpload"]: 175 | data["JobStartMode"] = JobStartMode.ManualForced 176 | del data["ForceUpload"] 177 | data["Trumpable"] = [] 178 | if data["TrumpableNoEnglish"]: 179 | data["Trumpable"] += [14] 180 | if data["TrumpableHardSubs"]: 181 | data["Trumpable"] += [4] 182 | return data 183 | -------------------------------------------------------------------------------- /src/PtpUploader/web/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/management/commands/__init__.py -------------------------------------------------------------------------------- /src/PtpUploader/web/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-10-23 23:05 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='ReleaseInfo', 16 | fields=[ 17 | ('Id', models.AutoField(primary_key=True, serialize=False)), 18 | ('AnnouncementSourceName', models.TextField(blank=True, default='')), 19 | ('AnnouncementId', models.TextField(blank=True, default='')), 20 | ('ReleaseName', models.TextField(blank=True, default='')), 21 | ('Type', models.TextField(blank=True, default='Feature Film')), 22 | ('ImdbId', models.TextField(blank=True, default='')), 23 | ('Directors', models.TextField(blank=True, default='')), 24 | ('Title', models.TextField(blank=True, default='')), 25 | ('Year', models.TextField(blank=True, default='')), 26 | ('Tags', models.TextField(blank=True, default='')), 27 | ('MovieDescription', models.TextField(blank=True, default='')), 28 | ('CoverArtUrl', models.TextField(blank=True, default='')), 29 | ('YouTubeId', models.TextField(blank=True, default='')), 30 | ('MetacriticUrl', models.TextField(blank=True, default='')), 31 | ('RottenTomatoesUrl', models.TextField(blank=True, default='')), 32 | ('Codec', models.TextField(blank=True, default='')), 33 | ('CodecOther', models.TextField(blank=True, default='')), 34 | ('Container', models.TextField(blank=True, default='')), 35 | ('ContainerOther', models.TextField(blank=True, default='')), 36 | ('ResolutionType', models.TextField(blank=True, default='')), 37 | ('Resolution', models.TextField(blank=True, default='')), 38 | ('Source', models.TextField(blank=True, default='')), 39 | ('SourceOther', models.TextField(blank=True, default='')), 40 | ('RemasterTitle', models.TextField(blank=True, default='')), 41 | ('RemasterYear', models.TextField(blank=True, default='')), 42 | ('JobStartMode', models.IntegerField(default=0)), 43 | ('JobRunningState', models.IntegerField(default=0)), 44 | ('FinishedJobPhase', models.IntegerField(default=0)), 45 | ('Flags', models.IntegerField(default=0)), 46 | ('ErrorMessage', models.TextField(blank=True, default='')), 47 | ('PtpId', models.TextField(blank=True, default='')), 48 | ('PtpTorrentId', models.TextField(blank=True, default='')), 49 | ('InternationalTitle', models.TextField(blank=True, default='')), 50 | ('Nfo', models.TextField(blank=True, default='')), 51 | ('SourceTorrentFilePath', models.TextField(blank=True, default='')), 52 | ('SourceTorrentInfoHash', models.TextField(blank=True, default='')), 53 | ('UploadTorrentCreatePath', models.TextField(blank=True, default='')), 54 | ('UploadTorrentFilePath', models.TextField(blank=True, default='')), 55 | ('UploadTorrentInfoHash', models.TextField(blank=True, default='')), 56 | ('ReleaseDownloadPath', models.TextField(blank=True, default='')), 57 | ('ReleaseUploadPath', models.TextField(blank=True, default='')), 58 | ('ReleaseNotes', models.TextField(blank=True, default='')), 59 | ('Screenshots', models.TextField(blank=True, default='')), 60 | ('LastModificationTime', models.DateTimeField(auto_now=True)), 61 | ('Size', models.IntegerField(default=0)), 62 | ('Subtitles', models.TextField(blank=True, default='')), 63 | ('IncludedFiles', models.TextField(blank=True, default='')), 64 | ('DuplicateCheckCanIgnore', models.IntegerField(default=0)), 65 | ('ScheduleTimeUtc', models.DateTimeField()), 66 | ], 67 | ), 68 | ] 69 | -------------------------------------------------------------------------------- /src/PtpUploader/web/migrations/0002_auto_20211025_0320.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-10-25 03:20 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('web', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='releaseinfo', 16 | name='Trumpable', 17 | field=models.TextField(blank=True, default=''), 18 | ), 19 | migrations.AlterField( 20 | model_name='releaseinfo', 21 | name='ScheduleTimeUtc', 22 | field=models.DateTimeField(default=django.utils.timezone.now), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /src/PtpUploader/web/migrations/0003_alter_releaseinfo_jobrunningstate.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-10-27 19:57 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('web', '0002_auto_20211025_0320'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='releaseinfo', 15 | name='JobRunningState', 16 | field=models.IntegerField(choices=[(0, 'Waiting for start'), (1, 'In progress'), (2, 'Paused'), (3, 'Finished'), (4, 'Failed'), (5, 'Ignored'), (6, 'Ignored, already exists'), (7, 'Ignored, forbidden'), (8, 'Ignored, missing info'), (9, 'Ignored, not supported'), (10, 'Downloaded, already exists'), (11, 'Scheduled'), (12, 'Downloading')], default=0), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/PtpUploader/web/migrations/0004_auto_20211028_2254.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-10-28 22:54 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('web', '0003_alter_releaseinfo_jobrunningstate'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='releaseinfo', 15 | name='Tags', 16 | ), 17 | migrations.AddField( 18 | model_name='releaseinfo', 19 | name='ForceDirectorylessSingleFileTorrent', 20 | field=models.BooleanField(default=False), 21 | ), 22 | migrations.AddField( 23 | model_name='releaseinfo', 24 | name='OverrideScreenshots', 25 | field=models.BooleanField(default=False), 26 | ), 27 | migrations.AddField( 28 | model_name='releaseinfo', 29 | name='PersonalRip', 30 | field=models.BooleanField(default=False), 31 | ), 32 | migrations.AddField( 33 | model_name='releaseinfo', 34 | name='SceneRelease', 35 | field=models.BooleanField(default=False), 36 | ), 37 | migrations.AddField( 38 | model_name='releaseinfo', 39 | name='SpecialRelease', 40 | field=models.BooleanField(default=False), 41 | ), 42 | migrations.AddField( 43 | model_name='releaseinfo', 44 | name='StartImmediately', 45 | field=models.BooleanField(default=False), 46 | ), 47 | migrations.AddField( 48 | model_name='releaseinfo', 49 | name='StopBeforeUploading', 50 | field=models.BooleanField(default=False), 51 | ), 52 | ] 53 | -------------------------------------------------------------------------------- /src/PtpUploader/web/migrations/0005_auto_20211029_0135.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-10-29 01:35 2 | 3 | import re 4 | 5 | import django.core.validators 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ("web", "0004_auto_20211028_2254"), 13 | ] 14 | 15 | operations = [ 16 | migrations.RemoveField( 17 | model_name="releaseinfo", 18 | name="Flags", 19 | ), 20 | migrations.AddField( 21 | model_name="releaseinfo", 22 | name="Tags", 23 | field=models.TextField(blank=True, default=""), 24 | ), 25 | migrations.AlterField( 26 | model_name="releaseinfo", 27 | name="Subtitles", 28 | field=models.TextField( 29 | blank=True, 30 | default="", 31 | validators=[ 32 | django.core.validators.RegexValidator( 33 | re.compile("^\\d+(?:,\\d+)*\\Z"), 34 | code="invalid", 35 | message="Enter only digits separated by commas.", 36 | ) 37 | ], 38 | ), 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /src/PtpUploader/web/migrations/0006_auto_20211029_2024.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-10-29 20:24 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('web', '0005_auto_20211029_0135'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='releaseinfo', 16 | name='ScheduleTimeUtc', 17 | field=models.DateTimeField(default=django.utils.timezone.now, null=True), 18 | ), 19 | migrations.AlterField( 20 | model_name='releaseinfo', 21 | name='Subtitles', 22 | field=models.TextField(blank=True, default=''), 23 | ), 24 | migrations.AlterField( 25 | model_name='releaseinfo', 26 | name='Trumpable', 27 | field=models.JSONField(default=[]), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /src/PtpUploader/web/migrations/0007_alter_releaseinfo_trumpable.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-10-29 20:24 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('web', '0006_auto_20211029_2024'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='releaseinfo', 15 | name='Trumpable', 16 | field=models.JSONField(default=list), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/PtpUploader/web/migrations/0008_alter_releaseinfo_subtitles.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-10-29 20:26 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('web', '0007_alter_releaseinfo_trumpable'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='releaseinfo', 15 | name='Subtitles', 16 | field=models.JSONField(blank=True, default=list), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/PtpUploader/web/migrations/0009_alter_releaseinfo_trumpable.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-10-29 21:00 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('web', '0008_alter_releaseinfo_subtitles'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='releaseinfo', 15 | name='Trumpable', 16 | field=models.JSONField(blank=True, default=list), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/PtpUploader/web/migrations/0010_alter_releaseinfo_screenshots.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-10-31 19:39 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('web', '0009_alter_releaseinfo_trumpable'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='releaseinfo', 15 | name='Screenshots', 16 | field=models.JSONField(blank=True, default=''), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/PtpUploader/web/migrations/0011_alter_releaseinfo_screenshots.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-10-31 19:40 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('web', '0010_alter_releaseinfo_screenshots'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='releaseinfo', 15 | name='Screenshots', 16 | field=models.JSONField(blank=True, default={}), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/PtpUploader/web/migrations/0012_alter_releaseinfo_screenshots.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-10-31 19:40 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('web', '0011_alter_releaseinfo_screenshots'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='releaseinfo', 15 | name='Screenshots', 16 | field=models.JSONField(blank=True, default=dict), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/PtpUploader/web/migrations/0013_auto_20211104_0344.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2021-11-04 03:44 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('web', '0012_alter_releaseinfo_screenshots'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='releaseinfo', 15 | name='Size', 16 | field=models.BigIntegerField(default=0), 17 | ), 18 | migrations.AlterField( 19 | model_name='releaseinfo', 20 | name='Type', 21 | field=models.TextField(blank=True, choices=[('Feature Film', 'Feature Film'), ('Short Film', 'Short Film'), ('Miniseries', 'Miniseries'), ('Stand-up Comedy', 'Stand-up Comedy'), ('Concert', 'Concert'), ('Live Performance', 'Live Performance'), ('Movie Collection', 'Movie Collection')], default='Feature Film'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /src/PtpUploader/web/migrations/0014_rename_scheduletimeutc_releaseinfo_scheduletime.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2022-01-08 21:47 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('web', '0013_auto_20211104_0344'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameField( 14 | model_name='releaseinfo', 15 | old_name='ScheduleTimeUtc', 16 | new_name='ScheduleTime', 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/PtpUploader/web/migrations/0015_alter_releaseinfo_scheduletime.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-01-15 03:38 2 | 3 | import datetime 4 | from django.db import migrations, models 5 | from django.utils.timezone import utc 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('web', '0014_rename_scheduletimeutc_releaseinfo_scheduletime'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='releaseinfo', 17 | name='ScheduleTime', 18 | field=models.DateTimeField(default=datetime.datetime(1970, 1, 1, 0, 0, tzinfo=utc), null=True), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /src/PtpUploader/web/migrations/0016_releaseinfo_bdinfo.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.13 on 2022-06-03 16:08 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('web', '0015_alter_releaseinfo_scheduletime'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='releaseinfo', 15 | name='BdInfo', 16 | field=models.JSONField(blank=True, default=dict), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/PtpUploader/web/migrations/0017_releaseinfo_includedfilelist.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.16 on 2023-01-07 16:17 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('web', '0016_releaseinfo_bdinfo'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='releaseinfo', 15 | name='IncludedFileList', 16 | field=models.JSONField(blank=True, default=list), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/PtpUploader/web/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/migrations/__init__.py -------------------------------------------------------------------------------- /src/PtpUploader/web/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from PtpUploader.ReleaseInfo import ReleaseInfo 4 | 5 | 6 | @admin.register(ReleaseInfo) 7 | class ReleaseInfoAdmin(admin.ModelAdmin): 8 | pass 9 | -------------------------------------------------------------------------------- /src/PtpUploader/web/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for mysite project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.2.7. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.2/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | from pathlib import Path 16 | 17 | import dynaconf 18 | 19 | from PtpUploader.Settings import Settings 20 | 21 | 22 | Settings.LoadSettings() 23 | 24 | X_FRAME_OPTIONS = "SAMEORIGIN" 25 | 26 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 27 | BASE_DIR = Path(__file__).resolve().parent.parent 28 | 29 | 30 | # Quick-start development settings - unsuitable for production 31 | # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ 32 | 33 | # SECURITY WARNING: keep the secret key used in production secret! 34 | SECRET_KEY = "django-insecure-3)felyc-ikqtmr57j)1r^86zd^p)#oc2hkmbduuo!ssj4!w^w5" 35 | 36 | # SECURITY WARNING: don't run with debug turned on in production! 37 | DEBUG = False 38 | 39 | ALLOWED_HOSTS = "*" 40 | 41 | LOGIN_URL = "/admin/login/" 42 | 43 | # Application definition 44 | 45 | INSTALLED_APPS = [ 46 | "django.contrib.admin", 47 | "django.contrib.auth", 48 | "django.contrib.contenttypes", 49 | "django.contrib.sessions", 50 | "django.contrib.messages", 51 | "django.contrib.staticfiles", 52 | "PtpUploader.web", 53 | ] 54 | 55 | MIDDLEWARE = [ 56 | "django.middleware.security.SecurityMiddleware", 57 | "django.contrib.sessions.middleware.SessionMiddleware", 58 | "django.middleware.common.CommonMiddleware", 59 | "django.middleware.csrf.CsrfViewMiddleware", 60 | "django.contrib.auth.middleware.AuthenticationMiddleware", 61 | "django.contrib.messages.middleware.MessageMiddleware", 62 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 63 | ] 64 | 65 | ROOT_URLCONF = "PtpUploader.web.urls" 66 | 67 | TEMPLATES = [ 68 | { 69 | "BACKEND": "django.template.backends.django.DjangoTemplates", 70 | "DIRS": [], 71 | "APP_DIRS": True, 72 | "OPTIONS": { 73 | "context_processors": [ 74 | "django.template.context_processors.debug", 75 | "django.template.context_processors.request", 76 | "django.contrib.auth.context_processors.auth", 77 | "django.contrib.messages.context_processors.messages", 78 | ], 79 | }, 80 | }, 81 | ] 82 | 83 | WSGI_APPLICATION = "PtpUploader.web.wsgi.application" 84 | 85 | 86 | # Database 87 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases 88 | 89 | DATABASES = { 90 | "default": { 91 | "ENGINE": "django.db.backends.sqlite3", 92 | "NAME": Path(Settings.WorkingPath, "db.sqlite3"), 93 | }, 94 | } 95 | 96 | 97 | # Password validation 98 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators 99 | 100 | AUTH_PASSWORD_VALIDATORS = [ 101 | { 102 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 103 | }, 104 | { 105 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 106 | }, 107 | { 108 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 109 | }, 110 | { 111 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 112 | }, 113 | ] 114 | 115 | 116 | # Internationalization 117 | # https://docs.djangoproject.com/en/3.2/topics/i18n/ 118 | 119 | LANGUAGE_CODE = "en-us" 120 | 121 | TIME_ZONE = "UTC" 122 | 123 | USE_I18N = True 124 | 125 | USE_L10N = True 126 | 127 | USE_TZ = True 128 | 129 | 130 | # Static files (CSS, JavaScript, Images) 131 | # https://docs.djangoproject.com/en/3.2/howto/static-files/ 132 | 133 | STATIC_URL = "/static/" 134 | 135 | # Default primary key field type 136 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 137 | 138 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 139 | 140 | LOGGING = { 141 | "version": 1, 142 | "disable_existing_loggers": False, 143 | "formatters": { 144 | "root_formatter": { 145 | "format": "%(asctime)-15s %(levelname)-5s %(name)-11s %(message)s" 146 | } 147 | }, 148 | "handlers": { 149 | "console": { 150 | "level": "INFO", 151 | "class": "logging.StreamHandler", 152 | "formatter": "root_formatter", 153 | }, 154 | }, 155 | "loggers": { 156 | "": { 157 | "handlers": [ 158 | "console", 159 | ], 160 | "level": "DEBUG", 161 | "propagate": True, 162 | }, 163 | "django": { 164 | "handlers": ["console"], 165 | "level": os.getenv("DJANGO_LOG_LEVEL", "ERROR"), 166 | "propagate": False, 167 | }, 168 | }, 169 | } 170 | 171 | 172 | settings = dynaconf.DjangoDynaconf( 173 | __name__, 174 | SETTINGS_FILE_FOR_DYNACONF=[ 175 | Path(Path(__file__).parent, "config.default.yml"), 176 | Path("~/.config/ptpuploader/config.yml").expanduser(), 177 | ".secrets.yaml", 178 | ], 179 | ENVIRONMENTS_FOR_DYNACONF=False, 180 | ) 181 | -------------------------------------------------------------------------------- /src/PtpUploader/web/static/delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/delete.png -------------------------------------------------------------------------------- /src/PtpUploader/web/static/edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/edit.png -------------------------------------------------------------------------------- /src/PtpUploader/web/static/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/error.png -------------------------------------------------------------------------------- /src/PtpUploader/web/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/favicon.png -------------------------------------------------------------------------------- /src/PtpUploader/web/static/film.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/film.png -------------------------------------------------------------------------------- /src/PtpUploader/web/static/hourglass.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/hourglass.png -------------------------------------------------------------------------------- /src/PtpUploader/web/static/pause.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/pause.png -------------------------------------------------------------------------------- /src/PtpUploader/web/static/ptp.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/ptp.ico -------------------------------------------------------------------------------- /src/PtpUploader/web/static/sad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/sad.png -------------------------------------------------------------------------------- /src/PtpUploader/web/static/scheduled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/scheduled.png -------------------------------------------------------------------------------- /src/PtpUploader/web/static/script/jquery.contextMenu.min.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8";/*! 2 | * jQuery contextMenu - Plugin for simple contextMenu handling 3 | * 4 | * Version: v2.9.2 5 | * 6 | * Authors: Björn Brala (SWIS.nl), Rodney Rehm, Addy Osmani (patches for FF) 7 | * Web: http://swisnl.github.io/jQuery-contextMenu/ 8 | * 9 | * Copyright (c) 2011-2020 SWIS BV and contributors 10 | * 11 | * Licensed under 12 | * MIT License http://www.opensource.org/licenses/mit-license 13 | * 14 | * Date: 2020-05-13T13:55:37.023Z 15 | */@-webkit-keyframes cm-spin{0%{-webkit-transform:translateY(-50%) rotate(0);transform:translateY(-50%) rotate(0)}100%{-webkit-transform:translateY(-50%) rotate(359deg);transform:translateY(-50%) rotate(359deg)}}@-o-keyframes cm-spin{0%{-webkit-transform:translateY(-50%) rotate(0);-o-transform:translateY(-50%) rotate(0);transform:translateY(-50%) rotate(0)}100%{-webkit-transform:translateY(-50%) rotate(359deg);-o-transform:translateY(-50%) rotate(359deg);transform:translateY(-50%) rotate(359deg)}}@keyframes cm-spin{0%{-webkit-transform:translateY(-50%) rotate(0);-o-transform:translateY(-50%) rotate(0);transform:translateY(-50%) rotate(0)}100%{-webkit-transform:translateY(-50%) rotate(359deg);-o-transform:translateY(-50%) rotate(359deg);transform:translateY(-50%) rotate(359deg)}}@font-face{font-family:context-menu-icons;font-style:normal;font-weight:400;src:url(font/context-menu-icons.eot?33lxn);src:url(font/context-menu-icons.eot?33lxn#iefix) format("embedded-opentype"),url(font/context-menu-icons.woff2?33lxn) format("woff2"),url(font/context-menu-icons.woff?33lxn) format("woff"),url(font/context-menu-icons.ttf?33lxn) format("truetype")}.context-menu-icon-add:before{content:"\EA01"}.context-menu-icon-copy:before{content:"\EA02"}.context-menu-icon-cut:before{content:"\EA03"}.context-menu-icon-delete:before{content:"\EA04"}.context-menu-icon-edit:before{content:"\EA05"}.context-menu-icon-loading:before{content:"\EA06"}.context-menu-icon-paste:before{content:"\EA07"}.context-menu-icon-quit:before{content:"\EA08"}.context-menu-icon::before{position:absolute;top:50%;left:0;width:2em;font-family:context-menu-icons;font-size:1em;font-style:normal;font-weight:400;line-height:1;color:#2980b9;text-align:center;-webkit-transform:translateY(-50%);-ms-transform:translateY(-50%);-o-transform:translateY(-50%);transform:translateY(-50%);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.context-menu-icon.context-menu-hover:before{color:#fff}.context-menu-icon.context-menu-disabled::before{color:#bbb}.context-menu-icon.context-menu-icon-loading:before{-webkit-animation:cm-spin 2s infinite;-o-animation:cm-spin 2s infinite;animation:cm-spin 2s infinite}.context-menu-icon.context-menu-icon--fa{display:list-item;font-family:inherit;line-height:inherit}.context-menu-icon.context-menu-icon--fa::before{position:absolute;top:50%;left:0;width:2em;font-family:FontAwesome;font-size:1em;font-style:normal;font-weight:400;line-height:1;color:#2980b9;text-align:center;-webkit-transform:translateY(-50%);-ms-transform:translateY(-50%);-o-transform:translateY(-50%);transform:translateY(-50%);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.context-menu-icon.context-menu-icon--fa.context-menu-hover:before{color:#fff}.context-menu-icon.context-menu-icon--fa.context-menu-disabled::before{color:#bbb}.context-menu-icon.context-menu-icon--fa5{display:list-item;font-family:inherit;line-height:inherit}.context-menu-icon.context-menu-icon--fa5 i,.context-menu-icon.context-menu-icon--fa5 svg{position:absolute;top:.3em;left:.5em;color:#2980b9}.context-menu-icon.context-menu-icon--fa5.context-menu-hover>i,.context-menu-icon.context-menu-icon--fa5.context-menu-hover>svg{color:#fff}.context-menu-icon.context-menu-icon--fa5.context-menu-disabled i,.context-menu-icon.context-menu-icon--fa5.context-menu-disabled svg{color:#bbb}.context-menu-list{position:absolute;display:inline-block;min-width:13em;max-width:26em;padding:.25em 0;margin:.3em;font-family:inherit;font-size:inherit;list-style-type:none;background:#fff;border:1px solid #bebebe;border-radius:.2em;-webkit-box-shadow:0 2px 5px rgba(0,0,0,.5);box-shadow:0 2px 5px rgba(0,0,0,.5)}.context-menu-item{position:relative;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;padding:.2em 2em;color:#2f2f2f;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-color:#fff}.context-menu-separator{padding:0;margin:.35em 0;border-bottom:1px solid #e6e6e6}.context-menu-item>label>input,.context-menu-item>label>textarea{-webkit-user-select:text;-moz-user-select:text;-ms-user-select:text;user-select:text}.context-menu-item.context-menu-hover{color:#fff;cursor:pointer;background-color:#2980b9}.context-menu-item.context-menu-disabled{color:#bbb;cursor:default;background-color:#fff}.context-menu-input.context-menu-hover{color:#2f2f2f;cursor:default}.context-menu-submenu:after{position:absolute;top:50%;right:.5em;z-index:1;width:0;height:0;content:'';border-color:transparent transparent transparent #2f2f2f;border-style:solid;border-width:.25em 0 .25em .25em;-webkit-transform:translateY(-50%);-ms-transform:translateY(-50%);-o-transform:translateY(-50%);transform:translateY(-50%)}.context-menu-item.context-menu-input{padding:.3em .6em}.context-menu-input>label>*{vertical-align:top}.context-menu-input>label>input[type=checkbox],.context-menu-input>label>input[type=radio]{position:relative;top:.12em;margin-right:.4em}.context-menu-input>label{margin:0}.context-menu-input>label,.context-menu-input>label>input[type=text],.context-menu-input>label>select,.context-menu-input>label>textarea{display:block;width:100%;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.context-menu-input>label>textarea{height:7em}.context-menu-item>.context-menu-list{top:.3em;right:-.3em;display:none}.context-menu-item.context-menu-visible>.context-menu-list{display:block}.context-menu-accesskey{text-decoration:underline} 16 | -------------------------------------------------------------------------------- /src/PtpUploader/web/static/script/jquery.ui.position.min.js: -------------------------------------------------------------------------------- 1 | /*! jQuery UI - v1.12.1 - 2016-09-16 2 | * http://jqueryui.com 3 | * Includes: position.js 4 | * Copyright jQuery Foundation and other contributors; Licensed MIT */ 5 | 6 | (function(t){"function"==typeof define&&define.amd?define(["jquery"],t):t(jQuery)})(function(t){t.ui=t.ui||{},t.ui.version="1.12.1",function(){function e(t,e,i){return[parseFloat(t[0])*(u.test(t[0])?e/100:1),parseFloat(t[1])*(u.test(t[1])?i/100:1)]}function i(e,i){return parseInt(t.css(e,i),10)||0}function s(e){var i=e[0];return 9===i.nodeType?{width:e.width(),height:e.height(),offset:{top:0,left:0}}:t.isWindow(i)?{width:e.width(),height:e.height(),offset:{top:e.scrollTop(),left:e.scrollLeft()}}:i.preventDefault?{width:0,height:0,offset:{top:i.pageY,left:i.pageX}}:{width:e.outerWidth(),height:e.outerHeight(),offset:e.offset()}}var n,o=Math.max,a=Math.abs,r=/left|center|right/,l=/top|center|bottom/,h=/[\+\-]\d+(\.[\d]+)?%?/,c=/^\w+/,u=/%$/,d=t.fn.position;t.position={scrollbarWidth:function(){if(void 0!==n)return n;var e,i,s=t("
"),o=s.children()[0];return t("body").append(s),e=o.offsetWidth,s.css("overflow","scroll"),i=o.offsetWidth,e===i&&(i=s[0].clientWidth),s.remove(),n=e-i},getScrollInfo:function(e){var i=e.isWindow||e.isDocument?"":e.element.css("overflow-x"),s=e.isWindow||e.isDocument?"":e.element.css("overflow-y"),n="scroll"===i||"auto"===i&&e.widthi?"left":e>0?"right":"center",vertical:0>r?"top":s>0?"bottom":"middle"};h>p&&p>a(e+i)&&(u.horizontal="center"),c>f&&f>a(s+r)&&(u.vertical="middle"),u.important=o(a(e),a(i))>o(a(s),a(r))?"horizontal":"vertical",n.using.call(this,t,u)}),l.offset(t.extend(D,{using:r}))})},t.ui.position={fit:{left:function(t,e){var i,s=e.within,n=s.isWindow?s.scrollLeft:s.offset.left,a=s.width,r=t.left-e.collisionPosition.marginLeft,l=n-r,h=r+e.collisionWidth-a-n;e.collisionWidth>a?l>0&&0>=h?(i=t.left+l+e.collisionWidth-a-n,t.left+=l-i):t.left=h>0&&0>=l?n:l>h?n+a-e.collisionWidth:n:l>0?t.left+=l:h>0?t.left-=h:t.left=o(t.left-r,t.left)},top:function(t,e){var i,s=e.within,n=s.isWindow?s.scrollTop:s.offset.top,a=e.within.height,r=t.top-e.collisionPosition.marginTop,l=n-r,h=r+e.collisionHeight-a-n;e.collisionHeight>a?l>0&&0>=h?(i=t.top+l+e.collisionHeight-a-n,t.top+=l-i):t.top=h>0&&0>=l?n:l>h?n+a-e.collisionHeight:n:l>0?t.top+=l:h>0?t.top-=h:t.top=o(t.top-r,t.top)}},flip:{left:function(t,e){var i,s,n=e.within,o=n.offset.left+n.scrollLeft,r=n.width,l=n.isWindow?n.scrollLeft:n.offset.left,h=t.left-e.collisionPosition.marginLeft,c=h-l,u=h+e.collisionWidth-r-l,d="left"===e.my[0]?-e.elemWidth:"right"===e.my[0]?e.elemWidth:0,p="left"===e.at[0]?e.targetWidth:"right"===e.at[0]?-e.targetWidth:0,f=-2*e.offset[0];0>c?(i=t.left+d+p+f+e.collisionWidth-r-o,(0>i||a(c)>i)&&(t.left+=d+p+f)):u>0&&(s=t.left-e.collisionPosition.marginLeft+d+p+f-l,(s>0||u>a(s))&&(t.left+=d+p+f))},top:function(t,e){var i,s,n=e.within,o=n.offset.top+n.scrollTop,r=n.height,l=n.isWindow?n.scrollTop:n.offset.top,h=t.top-e.collisionPosition.marginTop,c=h-l,u=h+e.collisionHeight-r-l,d="top"===e.my[1],p=d?-e.elemHeight:"bottom"===e.my[1]?e.elemHeight:0,f="top"===e.at[1]?e.targetHeight:"bottom"===e.at[1]?-e.targetHeight:0,g=-2*e.offset[1];0>c?(s=t.top+p+f+g+e.collisionHeight-r-o,(0>s||a(c)>s)&&(t.top+=p+f+g)):u>0&&(i=t.top-e.collisionPosition.marginTop+p+f+g-l,(i>0||u>a(i))&&(t.top+=p+f+g))}},flipfit:{left:function(){t.ui.position.flip.left.apply(this,arguments),t.ui.position.fit.left.apply(this,arguments)},top:function(){t.ui.position.flip.top.apply(this,arguments),t.ui.position.fit.top.apply(this,arguments)}}}}(),t.ui.position}); -------------------------------------------------------------------------------- /src/PtpUploader/web/static/skin-win8/icons.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/skin-win8/icons.gif -------------------------------------------------------------------------------- /src/PtpUploader/web/static/skin-win8/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/skin-win8/loading.gif -------------------------------------------------------------------------------- /src/PtpUploader/web/static/source.sh: -------------------------------------------------------------------------------- 1 | # Serves a source of truth for third-party JS/CSS, primarily to avoid 2 | # having to rely on javascript tooling. 3 | set -euo pipefail 4 | wget https://raw.githubusercontent.com/necolas/normalize.css/8.0.1/normalize.css -O normalize.css 5 | pushd script 6 | wget https://cdnjs.cloudflare.com/ajax/libs/jquery-contextmenu/2.7.1/jquery.contextMenu.min.js -O jquery.contextMenu.min.js 7 | wget https://cdnjs.cloudflare.com/ajax/libs/jquery-contextmenu/2.7.1/jquery.ui.position.min.js -O jquery.ui.position.min.js 8 | wget https://cdnjs.cloudflare.com/ajax/libs/bulma/0.9.2/css/bulma.min.css -O bulma.min.css 9 | wget https://cdn.datatables.net/v/bm/dt-1.12.1/datatables.min.css -O datatables.min.css 10 | wget https://cdn.datatables.net/v/bm/dt-1.12.1/datatables.min.js -O datatables.min.js 11 | wget https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/js/all.min.js -O fontawesome.all.min.js 12 | 13 | wget https://cdn.jsdelivr.net/npm/jquery.fancytree@2.38.1/dist/skin-win8/ui.fancytree.min.css -O ui.fancytree.min.css 14 | wget https://cdn.jsdelivr.net/npm/jquery.fancytree@2.38.1/dist/jquery.fancytree-all-deps.min.js -O jquery.fancytree-all-deps.min.js 15 | wget https://code.jquery.com/ui/1.13.0/jquery-ui.min.js -O jquery-ui.min.js 16 | wget https://cdnjs.cloudflare.com/ajax/libs/jquery-confirm/3.3.4/jquery-confirm.min.css -O jquery-confirm.min.css 17 | wget https://cdnjs.cloudflare.com/ajax/libs/jquery-confirm/3.3.4/jquery-confirm.min.js -O jquery-confirm.min.js 18 | wget https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css -O select2.min.css 19 | wget https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js -O select2.min.js 20 | popd 21 | -------------------------------------------------------------------------------- /src/PtpUploader/web/static/source_icon/ar.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/source_icon/ar.ico -------------------------------------------------------------------------------- /src/PtpUploader/web/static/source_icon/cg.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/source_icon/cg.ico -------------------------------------------------------------------------------- /src/PtpUploader/web/static/source_icon/dh.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/source_icon/dh.ico -------------------------------------------------------------------------------- /src/PtpUploader/web/static/source_icon/file.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/source_icon/file.ico -------------------------------------------------------------------------------- /src/PtpUploader/web/static/source_icon/gft.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/source_icon/gft.ico -------------------------------------------------------------------------------- /src/PtpUploader/web/static/source_icon/hdbits.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/source_icon/hdbits.ico -------------------------------------------------------------------------------- /src/PtpUploader/web/static/source_icon/kg.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/source_icon/kg.ico -------------------------------------------------------------------------------- /src/PtpUploader/web/static/source_icon/prowlarr.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/source_icon/prowlarr.ico -------------------------------------------------------------------------------- /src/PtpUploader/web/static/source_icon/rtt.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/source_icon/rtt.ico -------------------------------------------------------------------------------- /src/PtpUploader/web/static/source_icon/tby.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/source_icon/tby.ico -------------------------------------------------------------------------------- /src/PtpUploader/web/static/source_icon/tds.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/source_icon/tds.ico -------------------------------------------------------------------------------- /src/PtpUploader/web/static/source_icon/tik.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/source_icon/tik.ico -------------------------------------------------------------------------------- /src/PtpUploader/web/static/source_icon/tl.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/source_icon/tl.ico -------------------------------------------------------------------------------- /src/PtpUploader/web/static/source_icon/torrent.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/source_icon/torrent.ico -------------------------------------------------------------------------------- /src/PtpUploader/web/static/start.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/start.png -------------------------------------------------------------------------------- /src/PtpUploader/web/static/stop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/stop.png -------------------------------------------------------------------------------- /src/PtpUploader/web/static/style.css: -------------------------------------------------------------------------------- 1 | @keyframes spinner { 2 | to { transform: rotate(360deg); } 3 | } 4 | 5 | div#tree { 6 | height: 500px; 7 | } 8 | 9 | .jconfirm-content-pane ul.fancytree-container { 10 | border: 0; 11 | outline: 0; 12 | } 13 | 14 | 15 | .download-spinner::before { 16 | content: ''; 17 | box-sizing: border-box; 18 | position: relative; 19 | /* top: 50%; */ 20 | /* left: 50%; */ 21 | width: 16px; 22 | height: 16px; 23 | margin-top: 2px; 24 | margin-left: 9px; 25 | border-radius: 50%; 26 | border: 2px solid #48c774; 27 | border-top-color: #0000; 28 | animation: spinner 1.8s linear infinite; 29 | display: inline-block; 30 | margin-right: 4px; 31 | } 32 | 33 | .inprogress-spinner::before { 34 | content: ''; 35 | box-sizing: border-box; 36 | position: relative; 37 | /* top: 50%; */ 38 | /* left: 50%; */ 39 | width: 16px; 40 | height: 16px; 41 | margin-top: 2px; 42 | margin-left: 9px; 43 | border-radius: 50%; 44 | border: 2px solid rgb(0, 209, 178); 45 | border-top-color: #0000; 46 | border-bottom-color: #0000; 47 | animation: spinner 1.0s linear infinite; 48 | display: inline-block; 49 | margin-right: 4px; 50 | } 51 | 52 | table#jobs td.nowrap 53 | { 54 | white-space: nowrap; 55 | } 56 | 57 | table#jobs td 58 | { 59 | padding: 3px; 60 | font-size: 13px; 61 | } 62 | 63 | table#jobs span.job_title 64 | { 65 | font-size: 14px; 66 | } 67 | 68 | html#uploader, body#uploader, table#jobs th, #jobs.table.is-striped tbody, td.label { 69 | font-family: Arial,sans-serif; 70 | background-color: #181818; 71 | color: #D1D4C9; 72 | } 73 | 74 | #editjob_table td.label 75 | { 76 | text-align: right; 77 | } 78 | 79 | input[type="text"], input[type="password"], input[type="search"], textarea, button, select, textarea, span.select2, span.select2-selection--multiple.select2-selection { 80 | background-color : #363636; 81 | border: thin solid #30373d; 82 | border-radius: 3px; 83 | color: white; 84 | } 85 | 86 | #search_filter.input::placeholder { 87 | color: rgb(255, 255, 255); 88 | } 89 | 90 | span.select2-selection__choice__display, span.select2-dropdown, #uploader li.select2-selection__choice { 91 | background-color: #242424; 92 | } 93 | 94 | /* .select2-container--default li.select2-results__option--highlighted.select2-results__option--selectable { */ 95 | /* background-color: #202020; */ 96 | /* } */ 97 | 98 | .select2-container--default li.select2-results__option--selected.select2-results__option--selectable { 99 | background-color: #303030; 100 | } 101 | 102 | table#editjob_table, table#jobs 103 | { 104 | line-height: 1; 105 | border-collapse: collapse; 106 | } 107 | 108 | table#jobs 109 | { 110 | line-height: 1.2; 111 | border-collapse: collapse; 112 | } 113 | 114 | #editjob_table td, th 115 | { 116 | border: 1px; 117 | padding: 1px 3px 1px 3px; 118 | text-align: left; 119 | } 120 | 121 | 122 | body#uploader nav, body#uploader .navbar-item, { 123 | background-color: #29435C; 124 | text: #161616; 125 | } 126 | 127 | table#jobs.table td, table#jobs.table th { 128 | border-color: #2f2e2e; 129 | } 130 | 131 | table#jobs.table.is-hoverable.is-striped tbody tr:not(.is-selected):hover { 132 | background-color: #252224; 133 | } 134 | 135 | table#jobs.table.is-striped tbody tr:not(.is-selected):nth-child(2n) { 136 | background-color: #101010; 137 | } 138 | 139 | table#jobs thead { 140 | border-bottom: 7px solid white; 141 | } 142 | 143 | table#jobs th::after, table#jobs th::before { 144 | color: #D1D4C9; 145 | } 146 | 147 | span.job_title, span.job_title a 148 | { 149 | color: white; 150 | font-weight: bold; 151 | } 152 | 153 | 154 | table.jobs 155 | { 156 | width: 100%; 157 | } 158 | -------------------------------------------------------------------------------- /src/PtpUploader/web/static/success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/success.png -------------------------------------------------------------------------------- /src/PtpUploader/web/static/throbber.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/throbber.gif -------------------------------------------------------------------------------- /src/PtpUploader/web/static/warning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kannibalox/PtpUploader/247f231322dab6d514f74431e5d319577fe25481/src/PtpUploader/web/static/warning.png -------------------------------------------------------------------------------- /src/PtpUploader/web/templates/bulk.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% load static %} 4 | 5 | {% block head %} 6 | Bulk Upload - PtpUploader 7 | 8 | 9 | 10 | 11 | 12 | 13 | 21 | 78 | {% endblock %} 79 | 80 | 81 | {% block body %} 82 |
83 |
84 |
85 | 90 |
91 |
92 | {% csrf_token %} 93 | {{ form.errors }} 94 |
95 |
96 | 109 |
110 |
111 | 114 | 121 |
122 | 123 | 124 |
125 |
126 |
127 | {% endblock %} 128 | -------------------------------------------------------------------------------- /src/PtpUploader/web/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block head %} 4 | {% endblock %} 5 | 6 | {% block body %} 7 | 8 | {% endblock %} -------------------------------------------------------------------------------- /src/PtpUploader/web/templates/layout.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | {% block head %} 13 | {% endblock %} 14 | 15 | 16 |
17 | 44 | 48 |
49 | 50 |
51 | {% block body %} 52 | {% endblock %} 53 |
54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /src/PtpUploader/web/templates/movieAvailabilityCheck.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block head %} 4 | Availability Check - PtpUploader 5 | {% endblock %} 6 | 7 | {% block body %} 8 | 9 |
10 | 11 | 12 | 13 | 14 | 24 | 25 | 26 | 27 | 28 | 31 | 32 | 33 | 34 | 39 | 40 | 41 | 42 |
Format 15 | 23 |
IMDb links 29 | 30 |
35 |
36 |
37 | 38 |
43 |
44 | 45 | {% endblock %} 46 | -------------------------------------------------------------------------------- /src/PtpUploader/web/templates/settings.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% load static %} 4 | 5 | {% block head %} 6 | Settings - PtpUploader 7 | {% endblock %} 8 | 9 | {% block body %} 10 |
11 |
12 |
13 |
14 | {% csrf_token %} 15 | {# Include the hidden fields #} 16 | 17 | 18 | {% for hidden in form.hidden_fields %} 19 | {{ hidden }} 20 | {% endfor %} 21 | {# Include the visible fields #} 22 | 23 | 24 | {{ form.image_host_use.errors }} 25 | 26 | 27 | 28 | 29 | 30 | {{ form.ptp_username.errors }} 31 | 32 | 33 | 34 | 35 | {{ form.ptp_password.errors }} 36 | 37 | 38 | 39 | 40 | {{ form.ptp_announce_url.errors }} 41 | 42 | 43 | 44 | 45 | 46 | {{ form.client_use.errors }} 47 | 48 | 49 | 50 | 51 | {{ form.client_address.errors }} 52 | 53 | 54 | 55 | 56 |
Image Hosts
{{ form.image_host_use.label_tag }}{{ form.image_host_use }}
PTP
{{ form.ptp_username.label_tag }}{{ form.ptp_username }}
{{ form.ptp_password.label_tag }}{{ form.ptp_password }}
{{ form.ptp_announce_url.label_tag }}{{ form.ptp_announce_url }}
Client
{{ form.client_use.label_tag }}{{ form.client_use }}
{{ form.client_address.label_tag }}{{ form.client_address }}
57 | 58 |
59 |
60 |
61 | {% endblock %} 62 | -------------------------------------------------------------------------------- /src/PtpUploader/web/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.shortcuts import redirect 3 | from django.urls import path 4 | 5 | from . import views 6 | 7 | 8 | urlpatterns = [ 9 | path("", lambda r: redirect("/jobs"), name="index"), 10 | path("jobs", views.jobs, name="jobs"), 11 | path("ajax/jobs", views.jobs_json, name="jobs_json"), 12 | path("ajax/localdir", views.local_dir, name="local_dir"), 13 | path("ajax/filelist", views.file_list, name="file_list"), 14 | path("ajax/bulkfilelist", views.file_listBulkUpload, name="bulkfilelist"), 15 | path("ajax/getlatest", views.jobs_get_latest, name="jobs_get_latest"), 16 | path("ajax/create", views.create, name="ajax_create"), 17 | path("upload", views.edit_job, name="upload"), 18 | path("settings", views.settings, name="settings"), 19 | path("upload/bulk", views.bulk_upload, name="bulk_upload"), 20 | path("movieAvailabilityCheck", views.jobs, name="movieAvailabilityCheck"), 21 | path("quit", views.jobs, name="quit"), 22 | path("job//log", views.log, name="log"), 23 | path("job//stop", views.stop_job, name="stop_job"), 24 | path("job//start", views.start_job, name="start_job"), 25 | path("job//edit", views.edit_job, name="edit_job"), 26 | path("job//delete/", views.delete_job, name="edit_job"), 27 | path("admin/", admin.site.urls), 28 | ] 29 | -------------------------------------------------------------------------------- /src/PtpUploader/web/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for mysite project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | 15 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "PtpUpload.Settings") 16 | 17 | application = get_wsgi_application() 18 | -------------------------------------------------------------------------------- /src/autodl-irssi/autodl.example.cfg: -------------------------------------------------------------------------------- 1 | [options] 2 | download-duplicates = false 3 | upload-type = exec 4 | upload-command = /bin/sh 5 | upload-args = -c "echo '{\"AnnouncementSourceName\":\"$(TrackerShort)\",\"AnnouncementId\":\"$(TorrentId)\"}' > '/data/announcement/$(TrackerShort)-$(TorrentName).json'" 6 | 7 | # GFT ####################################################### 8 | 9 | [tracker gft] 10 | enabled = false # Change it to true if you want to use GFT. 11 | passkey = YOUR_GFT_PASSKEY 12 | 13 | [server irc.station51.net] 14 | enabled = false # Change it to true if you want to use GFT. 15 | nick = YOUR_GFT_USERNAME 16 | ident-password = YOUR_GFT_NICKSERVER_PASSWORD 17 | 18 | [channel irc.station51.net] 19 | name = #gftracker-spam 20 | invite-command = msg Spannage invite YOUR_GFT_IRC_PASSWORD 21 | 22 | [filter AllFilterGft] 23 | match-sites = gft 24 | match-categories = Movies/XVID, Movies/X264 25 | --------------------------------------------------------------------------------