mirror of
https://git.deluge-torrent.org/deluge
synced 2025-04-20 11:35:49 +00:00
Compare commits
No commits in common. "develop" and "deluge-2.0.1" have entirely different histories.
develop
...
deluge-2.0
915 changed files with 268620 additions and 450480 deletions
1
.gitattributes
vendored
1
.gitattributes
vendored
|
@ -3,4 +3,3 @@
|
|||
.gitignore export-ignore
|
||||
*.py diff=python
|
||||
ext-all.js diff=minjs
|
||||
*.state -merge -text
|
||||
|
|
104
.github/workflows/cd.yml
vendored
104
.github/workflows/cd.yml
vendored
|
@ -1,104 +0,0 @@
|
|||
name: Package
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "deluge-*"
|
||||
- "!deluge*-dev*"
|
||||
branches:
|
||||
- develop
|
||||
pull_request:
|
||||
types: [labeled, opened, synchronize, reopened]
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Enter a tag or commit to package"
|
||||
default: ""
|
||||
|
||||
jobs:
|
||||
windows_package:
|
||||
runs-on: windows-2022
|
||||
if: (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'package'))
|
||||
strategy:
|
||||
matrix:
|
||||
arch: [x64, x86]
|
||||
python: ["3.9"]
|
||||
libtorrent: [2.0.7, 1.2.19]
|
||||
|
||||
steps:
|
||||
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Checkout Deluge source to subdir to enable packaging any tag/commit
|
||||
- name: Checkout Deluge source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
fetch-depth: 0
|
||||
path: deluge_src
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python}}
|
||||
architecture: ${{ matrix.arch }}
|
||||
cache: pip
|
||||
|
||||
- name: Prepare pip
|
||||
run: python -m pip install wheel setuptools==68.*
|
||||
|
||||
- name: Install GTK
|
||||
run: |
|
||||
$WebClient = New-Object System.Net.WebClient
|
||||
$WebClient.DownloadFile("https://github.com/deluge-torrent/gvsbuild-release/releases/download/latest/gvsbuild-py${{ matrix.python }}-vs16-${{ matrix.arch }}.zip","C:\GTK.zip")
|
||||
7z x C:\GTK.zip -oc:\GTK
|
||||
echo "C:\GTK\release\lib" | Out-File -FilePath $env:GITHUB_PATH -Append
|
||||
echo "C:\GTK\release\bin" | Out-File -FilePath $env:GITHUB_PATH -Append
|
||||
echo "C:\GTK\release" | Out-File -FilePath $env:GITHUB_PATH -Append
|
||||
python -m pip install --no-index --find-links="C:\GTK\release\python" pycairo PyGObject
|
||||
|
||||
- name: Install Python dependencies
|
||||
# Pillow no longer provides 32-bit wheels for Windows
|
||||
# so specify only-binary to install old version.
|
||||
run: >
|
||||
python -m pip install
|
||||
--only-binary=pillow
|
||||
twisted[tls]==22.8.0
|
||||
libtorrent==${{ matrix.libtorrent }}
|
||||
pyinstaller
|
||||
pygame
|
||||
-r requirements.txt
|
||||
|
||||
- name: Install Deluge
|
||||
working-directory: deluge_src
|
||||
run: |
|
||||
python -m pip install .
|
||||
python setup.py install_scripts
|
||||
|
||||
- name: Freeze Deluge
|
||||
working-directory: packaging/win
|
||||
run: |
|
||||
pyinstaller --clean delugewin.spec --distpath freeze
|
||||
|
||||
- name: Verify Deluge exes
|
||||
working-directory: packaging/win/freeze/Deluge/
|
||||
run: |
|
||||
deluge-debug.exe -v
|
||||
deluged-debug.exe -v
|
||||
deluge-web-debug.exe -v
|
||||
deluge-console -v
|
||||
|
||||
- name: Make Deluge Installer
|
||||
working-directory: ./packaging/win
|
||||
run: |
|
||||
python setup_nsis.py
|
||||
makensis /Darch=${{ matrix.arch }} deluge-win-installer.nsi
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: deluge-py${{ matrix.python }}-lt${{ matrix.libtorrent }}-${{ matrix.arch }}
|
||||
path: packaging/win/*.exe
|
101
.github/workflows/ci.yml
vendored
101
.github/workflows/ci.yml
vendored
|
@ -1,101 +0,0 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
core-dump:
|
||||
description: "Set to 1 to enable retrieving core dump from crashes"
|
||||
default: "0"
|
||||
jobs:
|
||||
test-linux:
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.7", "3.10"]
|
||||
|
||||
steps:
|
||||
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "pip"
|
||||
cache-dependency-path: "requirements*.txt"
|
||||
|
||||
- name: Sets env var for security
|
||||
if: (github.event_name == 'pull_request' && contains(github.event.pull_request.body, 'security_test')) || (github.event_name == 'push' && contains(github.event.head_commit.message, 'security_test'))
|
||||
run: echo "SECURITY_TESTS=True" >> $GITHUB_ENV
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install --upgrade pip wheel setuptools
|
||||
pip install -r requirements-ci.txt
|
||||
pip install -e .
|
||||
|
||||
- name: Install security dependencies
|
||||
if: contains(env.SECURITY_TESTS, 'True')
|
||||
run: |
|
||||
wget -O- $TESTSSL_URL$TESTSSL_VER | tar xz
|
||||
mv -t deluge/tests/data testssl.sh-$TESTSSL_VER/testssl.sh testssl.sh-$TESTSSL_VER/etc/;
|
||||
env:
|
||||
TESTSSL_VER: 3.0.6
|
||||
TESTSSL_URL: https://codeload.github.com/drwetter/testssl.sh/tar.gz/refs/tags/v
|
||||
|
||||
- name: Setup core dump catch and store
|
||||
if: github.event.inputs.core-dump == '1'
|
||||
run: |
|
||||
sudo mkdir /cores/ && sudo chmod 777 /cores/
|
||||
echo "/cores/%E.%p" | sudo tee /proc/sys/kernel/core_pattern
|
||||
ulimit -c unlimited
|
||||
sudo apt install glibc-tools
|
||||
echo "DEBUG_PREFIX=catchsegv python -X dev -m" >> $GITHUB_ENV
|
||||
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
python -c 'from deluge._libtorrent import lt; print(lt.__version__)';
|
||||
$DEBUG_PREFIX pytest -v -m "not (todo or gtkui)" deluge
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
# capture all crashes as build artifacts
|
||||
if: failure()
|
||||
with:
|
||||
name: crashes
|
||||
path: /cores
|
||||
|
||||
test-windows:
|
||||
runs-on: windows-2022
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.7", "3.10"]
|
||||
|
||||
steps:
|
||||
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "pip"
|
||||
cache-dependency-path: "requirements*.txt"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install --upgrade pip wheel setuptools
|
||||
pip install -r requirements-ci.txt
|
||||
pip install -e .
|
||||
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
python -c 'import libtorrent as lt; print(lt.__version__)';
|
||||
pytest -v -m "not (todo or gtkui or security)" deluge
|
38
.github/workflows/docs.yml
vendored
38
.github/workflows/docs.yml
vendored
|
@ -1,38 +0,0 @@
|
|||
name: Docs
|
||||
|
||||
# Controls when the action will run.
|
||||
on:
|
||||
# Triggers the workflow on push or pull request events but only for the main branch
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
cache: "pip"
|
||||
cache-dependency-path: "requirements*.txt"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install --upgrade pip wheel
|
||||
pip install tox
|
||||
sudo apt-get install enchant-2
|
||||
|
||||
- name: Build docs with tox
|
||||
env:
|
||||
TOX_ENV: docs
|
||||
run: |
|
||||
tox -e $TOX_ENV
|
17
.github/workflows/lint.yml
vendored
17
.github/workflows/lint.yml
vendored
|
@ -1,17 +0,0 @@
|
|||
name: Linting
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- name: Run pre-commit linting
|
||||
uses: pre-commit/action@v3.0.1
|
7
.gitignore
vendored
7
.gitignore
vendored
|
@ -10,16 +10,15 @@ docs/source/modules/deluge*.rst
|
|||
__pycache__/
|
||||
*.py[cod]
|
||||
*.tar.*
|
||||
_trial_temp
|
||||
.tox/
|
||||
deluge/i18n/*/
|
||||
deluge.pot
|
||||
deluge/ui/web/js/*.js
|
||||
deluge/ui/web/js/extjs/ext-extensions*.js
|
||||
*.desktop
|
||||
*.metainfo.xml
|
||||
*.appdata.xml
|
||||
.build_data*
|
||||
osx/app
|
||||
RELEASE-VERSION
|
||||
.venv*
|
||||
# used by setuptools to cache downloaded eggs
|
||||
/.eggs
|
||||
_pytest_temp/
|
||||
|
|
|
@ -1,30 +1,36 @@
|
|||
default_language_version:
|
||||
default_language:
|
||||
python: python3
|
||||
exclude: >
|
||||
(?x)^(
|
||||
deluge/ui/web/docs/template/.*|
|
||||
deluge/tests/data/.*svg|
|
||||
)$
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.6.4
|
||||
- repo: https://github.com/ambv/black
|
||||
rev: 19.3b0
|
||||
hooks:
|
||||
- id: ruff
|
||||
name: Chk Ruff
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
name: Fmt Ruff
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v2.7.1
|
||||
- id: black
|
||||
name: Fmt Black
|
||||
language_version: python3.6
|
||||
- repo: https://github.com/prettier/prettier
|
||||
rev: 1.17.0
|
||||
hooks:
|
||||
- id: prettier
|
||||
name: Fmt Prettier
|
||||
# Workaround to list modified files only.
|
||||
args: [--list-different]
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.4.0
|
||||
- repo: https://gitlab.com/pycqa/flake8
|
||||
rev: 3.7.7
|
||||
hooks:
|
||||
- id: flake8
|
||||
name: Chk Flake8
|
||||
additional_dependencies:
|
||||
- flake8-isort==2.7
|
||||
- pep8-naming==0.8.2
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v2.2.1
|
||||
hooks:
|
||||
- id: double-quote-string-fixer
|
||||
name: Fix Double-quotes
|
||||
- id: end-of-file-fixer
|
||||
name: Fix End-of-files
|
||||
exclude_types: [javascript, css]
|
||||
|
@ -33,9 +39,3 @@ repos:
|
|||
args: [--fix=auto]
|
||||
- id: trailing-whitespace
|
||||
name: Fix Trailing whitespace
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.3.1
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py37-plus]
|
||||
stages: [manual]
|
||||
|
|
|
@ -289,7 +289,7 @@ callbacks=cb_,_cb
|
|||
|
||||
# List of qualified module names which can have objects that can redefine
|
||||
# builtins.
|
||||
redefining-builtins-modules=
|
||||
redefining-builtins-modules=six.moves,future.builtins,future_builtins
|
||||
|
||||
|
||||
[TYPECHECK]
|
||||
|
@ -359,6 +359,11 @@ known-standard-library=
|
|||
# Force import order to recognize a module as part of a third party library.
|
||||
known-third-party=enchant
|
||||
|
||||
# Analyse import fallback blocks. This can be used to support both Python 2 and
|
||||
# 3 compatible code, which means that the block might have code that exists
|
||||
# only in one or another interpreter, leading to false positives when analysed.
|
||||
analyse-fallback-blocks=no
|
||||
|
||||
|
||||
[DESIGN]
|
||||
|
||||
|
|
|
@ -5,14 +5,6 @@
|
|||
# Required
|
||||
version: 2
|
||||
|
||||
build:
|
||||
os: ubuntu-22.04
|
||||
tools:
|
||||
python: "3.10"
|
||||
jobs:
|
||||
post_checkout:
|
||||
- git fetch --unshallow || true
|
||||
|
||||
# Build documentation in the docs/ directory with Sphinx
|
||||
sphinx:
|
||||
configuration: docs/source/conf.py
|
||||
|
@ -22,8 +14,9 @@ formats: all
|
|||
|
||||
# Optionally set the version of Python and requirements required to build your docs
|
||||
python:
|
||||
version: 3.7
|
||||
install:
|
||||
- requirements: requirements.txt
|
||||
- requirements: docs/requirements.txt
|
||||
- method: pip
|
||||
- method: setuptools
|
||||
path: .
|
||||
|
|
79
.travis.yml
Normal file
79
.travis.yml
Normal file
|
@ -0,0 +1,79 @@
|
|||
dist: xenial
|
||||
sudo: required
|
||||
|
||||
language: python
|
||||
python:
|
||||
# Travis Xenial Python to support system_site_packages
|
||||
- 3.5
|
||||
cache: pip
|
||||
virtualenv:
|
||||
system_site_packages: true
|
||||
|
||||
env:
|
||||
global:
|
||||
- DISPLAY=:99.0
|
||||
|
||||
git:
|
||||
# Set greater depth to get version from tags.
|
||||
depth: 1000
|
||||
|
||||
matrix:
|
||||
include:
|
||||
- name: Unit tests
|
||||
env: TOX_ENV=py3
|
||||
- name: Unit tests (libtorrent 1.2)
|
||||
env: TOX_ENV=py3
|
||||
addons:
|
||||
apt:
|
||||
sources: [sourceline: "ppa:libtorrent.org/1.2-daily"]
|
||||
packages: [python3-libtorrent, python3-venv]
|
||||
- if: commit_message =~ SECURITY_TEST
|
||||
env: TOX_ENV=security
|
||||
- name: Code linting
|
||||
env: TOX_ENV=lint
|
||||
- name: Docs build
|
||||
env: TOX_ENV=docs
|
||||
- name: GTK unit tests
|
||||
env: TOX_ENV=gtkui
|
||||
- name: Plugins unit tests
|
||||
env: TOX_ENV=plugins
|
||||
|
||||
addons:
|
||||
apt:
|
||||
sources:
|
||||
- sourceline: "ppa:libtorrent.org/rc-1.1-daily"
|
||||
- deadsnakes
|
||||
packages:
|
||||
- python3-libtorrent
|
||||
# Install py36 specifically for pre-commit to run black formatter.
|
||||
- python3.6
|
||||
# Intall python3-venv to provide ensurepip module for tox.
|
||||
- python3-venv
|
||||
|
||||
# Install dependencies
|
||||
install:
|
||||
- pip install tox tox-venv
|
||||
# GTKUI tests
|
||||
- "if [ $TOX_ENV == 'gtkui' ]; then
|
||||
sudo apt install python3-gi python3-gi-cairo gir1.2-gtk-3.0;
|
||||
fi"
|
||||
# Security tests
|
||||
- "if [ $TOX_ENV == 'security' ]; then
|
||||
testssl_url=https://github.com/drwetter/testssl.sh/archive/v2.9.5-5.tar.gz;
|
||||
wget -O- $testssl_url | tar xz
|
||||
&& mv -t deluge/tests/data testssl.sh-2.9.5-5/testssl.sh testssl.sh-2.9.5-5/etc/;
|
||||
fi"
|
||||
|
||||
before_script:
|
||||
- export PYTHONPATH=$PYTHONPATH:$PWD
|
||||
# Verify libtorrent installed and version
|
||||
- python -c "import libtorrent as lt; print(lt.__version__)"
|
||||
# Start xvfb for the GTKUI tests
|
||||
- "if [ $TOX_ENV == 'gtkui' ]; then
|
||||
/sbin/start-stop-daemon --start --quiet --background \
|
||||
--make-pidfile --pidfile /tmp/custom_xvfb_99.pid \
|
||||
--exec /usr/bin/Xvfb -- :99 -ac -screen 0 1280x1024x16;
|
||||
fi"
|
||||
|
||||
script:
|
||||
- tox -e $TOX_ENV
|
17
AUTHORS
17
AUTHORS
|
@ -39,9 +39,14 @@ Images Authors:
|
|||
* files: deluge/ui/data/pixmaps/*.svg, *.png
|
||||
deluge/ui/web/icons/active.png, alert.png, all.png, checking.png, dht.png,
|
||||
downloading.png, inactive.png, queued.png, seeding.png, traffic.png
|
||||
deluge/ui/web/images/deluge*.png
|
||||
exceptions: deluge/ui/data/pixmaps/deluge.svg and derivatives
|
||||
copyright: Andrew Resch
|
||||
license: GPLv3
|
||||
|
||||
* files: deluge/ui/data/pixmaps/deluge.svg and derivatives
|
||||
deluge/ui/web/icons/apple-pre-*.png, deluge*.png
|
||||
copyright: Calum Lind
|
||||
deluge/ui/web/images/deluge*.png
|
||||
copyright: Andrew Wedderburn
|
||||
license: GPLv3
|
||||
|
||||
* files: deluge/plugins/blocklist/blocklist/data/*.png
|
||||
|
@ -50,9 +55,11 @@ Images Authors:
|
|||
license: GPLv2
|
||||
url: http://ftp.acc.umu.se/pub/GNOME/sources/gnome-icon-theme
|
||||
|
||||
* files: deluge/ui/data/pixmaps/magnet*.svg, *.png
|
||||
copyright: Matias Wilkman
|
||||
license:
|
||||
* files: deluge/ui/data/pixmaps/magnet.png
|
||||
copyright: Woothemes
|
||||
license: Freeware
|
||||
icon pack: WP Woothemes Ultimate
|
||||
url: http://www.woothemes.com/
|
||||
|
||||
* files: deluge/ui/data/pixmaps/flags/*.png
|
||||
copyright: Mark James <mjames@gmail.com>
|
||||
|
|
228
CHANGELOG.md
228
CHANGELOG.md
|
@ -1,212 +1,8 @@
|
|||
# Changelog
|
||||
|
||||
## 2.2.x (TBA)
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- Python 3.6 support removed (Python >= 3.7)
|
||||
|
||||
### Web UI
|
||||
|
||||
- Accept network interface name in addition to IP adress in "Incoming Address"
|
||||
|
||||
## 2.1.1 (2022-07-10)
|
||||
|
||||
### Core
|
||||
|
||||
- Fix missing trackers added via magnet
|
||||
- Fix handling magnets with tracker tiers
|
||||
|
||||
## 2.1.0 (2022-06-28)
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- Python 2 support removed (Python >= 3.6)
|
||||
- libtorrent minimum requirement increased (>= 1.2).
|
||||
|
||||
### Core
|
||||
|
||||
- Add support for SVG tracker icons.
|
||||
- Fix tracker icon error handling.
|
||||
- Fix cleaning-up tracker icon temp files.
|
||||
- Fix Plugin manager to handle new metadata 2.1.
|
||||
- Hide passwords in config logs.
|
||||
- Fix cleaning-up temp files in add_torrent_url.
|
||||
- Fix KeyError in sessionproxy after torrent delete.
|
||||
- Remove libtorrent deprecated functions.
|
||||
- Fix file_completed_alert handling.
|
||||
- Add plugin keys to get_torrents_status.
|
||||
- Add support for pygeoip dependency.
|
||||
- Fix crash logging to Windows protected folder.
|
||||
- Add is_interface and is_interface_name to validate network interfaces.
|
||||
- Fix is_url and is_infohash error with None value.
|
||||
- Fix load_libintl error.
|
||||
- Add support for IPv6 in host lists.
|
||||
- Add systemd user services.
|
||||
- Fix refresh and expire the torrent status cache.
|
||||
- Fix crash when logging errors initializing gettext.
|
||||
|
||||
### Web UI
|
||||
|
||||
- Fix ETA column sorting in correct order (#3413).
|
||||
- Fix defining foreground and background colors.
|
||||
- Accept charset in content-type for json messages.
|
||||
- Fix 'Complete Seen' and 'Completed' sorting.
|
||||
- Fix encoding HTML entities for torrent attributes to prevent XSS.
|
||||
|
||||
### Gtk UI
|
||||
|
||||
- Fix download location textbox width.
|
||||
- Fix obscured port number in Connection Manager.
|
||||
- Increase connection manager default height.
|
||||
- Fix bug with setting move completed in Options tab.
|
||||
- Fix adding daemon accounts.
|
||||
- Add workaround for crash on Windows with ico or gif icons.
|
||||
- Hide account password length in log.
|
||||
- Added a torrent menu option for magnet copy.
|
||||
- Fix unable to prefetch magnet in thinclient mode.
|
||||
- Use GtkSpinner when testing open port.
|
||||
- Update About Dialog year.
|
||||
- Fix Edit Torrents dialogs close issues.
|
||||
- Fix ETA being copied to neighboring empty cells.
|
||||
- Disable GTK CSD by default on Windows.
|
||||
|
||||
### Console UI
|
||||
|
||||
- Fix curses.init_pair raise ValueError on Py3.10.
|
||||
- Swap j and k key's behavior to fit vim mode.
|
||||
- Fix torrent details status error.
|
||||
- Fix incorrect test for when a host is online.
|
||||
- Add the torrent label to info command.
|
||||
|
||||
### AutoAdd
|
||||
|
||||
- Fix handling torrent decode errors.
|
||||
- Fix error dialog not being shown on error.
|
||||
|
||||
### Blocklist
|
||||
|
||||
- Add frequency unit to interval label.
|
||||
|
||||
### Notifications
|
||||
|
||||
- Fix UnicodeEncodeError upon non-ascii torrent name.
|
||||
|
||||
## 2.0.5 (2021-12-15)
|
||||
|
||||
### WebUI
|
||||
|
||||
- Fix js minifying error resulting in WebUI blank screen.
|
||||
- Silence erronous missing translations warning.
|
||||
|
||||
## 2.0.4 (2021-12-12)
|
||||
|
||||
### Packaging
|
||||
|
||||
- Fix python optional setup.py requirements
|
||||
|
||||
### Gtk UI
|
||||
|
||||
- Add detection of torrent URL on GTK UI focus
|
||||
- Fix piecesbar crashing when enabled
|
||||
- Remove num_blocks_cache_hits in stats
|
||||
- Fix unhandled error with empty clipboard
|
||||
- Add torrentdetails tabs position menu (#3441)
|
||||
- Hide pygame community banner in console
|
||||
- Fix cmp function for None types (#3309)
|
||||
- Fix loading config with double-quotes in string
|
||||
- Fix Status tab download speed and uploaded
|
||||
|
||||
### Web UI
|
||||
|
||||
- Handle torrent add failures
|
||||
- Add menu option to copy magnet URI
|
||||
- Fix md5sums in torrent files breaking file listing (#3388)
|
||||
- Add country flag alt/title for accessibility
|
||||
|
||||
### Console UI
|
||||
|
||||
- Fix allowing use of windows-curses on Windows
|
||||
- Fix hostlist status lookup errors
|
||||
- Fix AttributeError setting config values
|
||||
- Fix setting 'Skip' priority
|
||||
|
||||
### Core
|
||||
|
||||
- Add workaround libtorrent 2.0 file_progress error
|
||||
- Fix allow enabling any plugin Python version
|
||||
- Export torrent get_magnet_uri method
|
||||
- Fix loading magnet with resume_data and no metadata (#3478)
|
||||
- Fix httpdownloader reencoding torrent file downloads (#3440)
|
||||
- Fix lt listen_interfaces not comma-separated (#3337)
|
||||
- Fix unable to remove magnet with delete_copies enabled (#3325)
|
||||
- Fix Python 3.8 compatibility
|
||||
- Fix loading config with double-quotes in string
|
||||
- Fix pickle loading non-ascii state error (#3298)
|
||||
- Fix creation of pidfile via command option
|
||||
- Fix for peer.client UnicodeDecodeError
|
||||
- Fix show_file unhandled dbus error
|
||||
|
||||
### Documentation
|
||||
|
||||
- Add How-to guides about services.
|
||||
|
||||
### Stats plugin
|
||||
|
||||
- Fix constant session status key warnings
|
||||
- Fix cairo error
|
||||
|
||||
### Notifications plugin
|
||||
|
||||
- Fix email KeyError with status name
|
||||
- Fix unhandled TypeErrors on Python 3
|
||||
|
||||
### Autoadd plugin
|
||||
|
||||
- Fix magnet missing applied labels
|
||||
|
||||
### Execute plugin
|
||||
|
||||
- Fix failing to run on Windows (#3439)
|
||||
|
||||
## 2.0.3 (2019-06-12)
|
||||
|
||||
### Gtk UI
|
||||
|
||||
- Fix errors running on Wayland (#3265).
|
||||
- Fix Peers Tab tooltip and context menu errors (#3266).
|
||||
|
||||
### Web UI
|
||||
|
||||
- Fix TypeError in Peers Tab setting country flag.
|
||||
- Fix reverse proxy header TypeError (#3260).
|
||||
- Fix request.base 'idna' codec error (#3261).
|
||||
- Fix unable to change password (#3262).
|
||||
|
||||
### Extractor plugin
|
||||
|
||||
- Fix potential error starting plugin.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Fix macOS install typo.
|
||||
- Fix Windows install instructions.
|
||||
|
||||
## 2.0.2 (2019-06-08)
|
||||
|
||||
### Packaging
|
||||
|
||||
- Add systemd deluged and deluge-web service files to package tarball (#2034)
|
||||
|
||||
### Core
|
||||
|
||||
- Fix Python 2 compatibility issue with SimpleNamespace.
|
||||
|
||||
## 2.0.1 (2019-06-07)
|
||||
|
||||
### Packaging
|
||||
|
||||
- Fix `setup.py` build error without git installed.
|
||||
- Fix setup.py build error without git installed.
|
||||
|
||||
## 2.0.0 (2019-06-06)
|
||||
|
||||
|
@ -224,37 +20,37 @@
|
|||
there to allow acting upon them.
|
||||
- Updated SSL/TLS Protocol parameters for better security.
|
||||
- Make the distinction between adding to the session new unmanaged torrents
|
||||
and torrents loaded from state. This will break backwards compatibility.
|
||||
and torrents loaded from state. This will break backwards compatability.
|
||||
- Pass a copy of an event instead of passing the event arguments to the
|
||||
event handlers. This will break backwards compatibility.
|
||||
event handlers. This will break backwards compatability.
|
||||
- Allow changing ownership of torrents.
|
||||
- File modifications on the auth file are now detected and when they happen,
|
||||
the file is reloaded. Upon finding an old auth file with an old format, an
|
||||
upgrade to the new format is made, file saved, and reloaded.
|
||||
- Authentication no longer requires a username/password. If one or both of
|
||||
these is missing, an authentication error will be sent to the client
|
||||
which should then ask the username/password to the user.
|
||||
which sould then ask the username/password to the user.
|
||||
- Implemented sequential downloads.
|
||||
- Provide information about a torrent's pieces states
|
||||
- Add Option To Specify Outgoing Connection Interface.
|
||||
- Fix potential for host_id collision when creating hostlist entries.
|
||||
|
||||
### Gtk UI
|
||||
### GtkUI
|
||||
|
||||
- Ported to GTK3 (3rd-party plugins will need updated).
|
||||
- Allow changing ownership of torrents.
|
||||
- Host entries in the Connection Manager UI are now editable.
|
||||
- Implemented sequential downloads UI handling.
|
||||
- Add optional pieces bar instead of a regular progress bar in torrent status tab.
|
||||
- Make torrent opening compatible with all Unicode paths.
|
||||
- Make torrent opening compatible with all unicode paths.
|
||||
- Fix magnet association button on Windows.
|
||||
- Add keyboard shortcuts for changing queue position:
|
||||
- Up: `Ctrl+Alt+Up`
|
||||
- Down: `Ctrl+Alt+Down`
|
||||
- Top: `Ctrl+Alt+Shift+Up`
|
||||
- Bottom: `Ctrl+Alt+Shift+Down`
|
||||
- Up: Ctrl+Alt+Up
|
||||
- Down: Ctrl+Alt+Down
|
||||
- Top: Ctrl+Alt+Shift+Up
|
||||
- Bottom: Ctrl+Alt+Shift+Down
|
||||
|
||||
### Web UI
|
||||
### WebUI
|
||||
|
||||
- Server (deluge-web) now daemonizes by default, use '-d' or '--do-not-daemonize' to disable.
|
||||
- Fixed the '--base' option to work for regular use, not just with reverse proxies.
|
||||
|
@ -262,7 +58,7 @@
|
|||
### Blocklist Plugin
|
||||
|
||||
- Implemented whitelist support to both core and GTK UI.
|
||||
- Implemented IP filter cleaning before each update. Restarting the deluge
|
||||
- Implemented ip filter cleaning before each update. Restarting the deluge
|
||||
daemon is no longer needed.
|
||||
- If "check_after_days" is 0(zero), the timer is not started anymore. It
|
||||
would keep updating one call after the other. If the value changed, the
|
||||
|
|
25
DEPENDS.md
25
DEPENDS.md
|
@ -7,13 +7,13 @@ All modules will require the [common](#common) section dependencies.
|
|||
|
||||
## Prerequisite
|
||||
|
||||
- [Python] _>= 3.6_
|
||||
- [Python] _>= 3.5_
|
||||
|
||||
## Build
|
||||
|
||||
- [setuptools]
|
||||
- [intltool] - Optional: Desktop file translation for \*nix.
|
||||
- [closure-compiler] - Minify javascript (alternative is [rjsmin])
|
||||
- [closure-compiler] - Minify javascript (alternative is [slimit])
|
||||
|
||||
## Common
|
||||
|
||||
|
@ -23,26 +23,26 @@ All modules will require the [common](#common) section dependencies.
|
|||
- [rencode] _>= 1.0.2_ - Encoding library.
|
||||
- [PyXDG] - Access freedesktop.org standards for \*nix.
|
||||
- [xdg-utils] - Provides xdg-open for \*nix.
|
||||
- [six]
|
||||
- [zope.interface]
|
||||
- [chardet] - Optional: Encoding detection.
|
||||
- [setproctitle] - Optional: Renaming processes.
|
||||
- [Pillow] - Optional: Support for resizing tracker icons.
|
||||
- [dbus-python] - Optional: Show item location in filemanager.
|
||||
- [ifaddr] - Optional: Verify network interfaces.
|
||||
|
||||
### Linux and BSD
|
||||
#### Linux and BSD
|
||||
|
||||
- [distro] - Optional: OS platform information.
|
||||
|
||||
### Windows OS
|
||||
#### Windows OS
|
||||
|
||||
- [pywin32]
|
||||
- [certifi]
|
||||
|
||||
## Core (deluged daemon)
|
||||
|
||||
- [libtorrent] _>= 1.2.0_
|
||||
- [GeoIP] or [pygeoip] - Optional: IP address country lookup. (_Debian: `python-geoip`_)
|
||||
- [libtorrent] _>= 1.1.1_
|
||||
- [GeoIP] - Optional: IP address location lookup. (_Debian: `python-geoip`_)
|
||||
|
||||
## GTK UI
|
||||
|
||||
|
@ -50,9 +50,9 @@ All modules will require the [common](#common) section dependencies.
|
|||
- [PyGObject]
|
||||
- [Pycairo]
|
||||
- [librsvg] _>= 2_
|
||||
- [ayatanaappindicator3] w/GIR - Optional: Ubuntu system tray icon.
|
||||
- [libappindicator3] w/GIR - Optional: Ubuntu system tray icon.
|
||||
|
||||
### MacOS
|
||||
#### MacOS
|
||||
|
||||
- [GtkOSXApplication]
|
||||
|
||||
|
@ -71,7 +71,7 @@ All modules will require the [common](#common) section dependencies.
|
|||
[setuptools]: https://setuptools.readthedocs.io/en/latest/
|
||||
[intltool]: https://freedesktop.org/wiki/Software/intltool/
|
||||
[closure-compiler]: https://developers.google.com/closure/compiler/
|
||||
[rjsmin]: https://pypi.org/project/rjsmin/
|
||||
[slimit]: https://slimit.readthedocs.io/en/latest/
|
||||
[openssl]: https://www.openssl.org/
|
||||
[pyopenssl]: https://pyopenssl.org
|
||||
[twisted]: https://twistedmatrix.com
|
||||
|
@ -81,12 +81,14 @@ All modules will require the [common](#common) section dependencies.
|
|||
[distro]: https://github.com/nir0s/distro
|
||||
[pywin32]: https://github.com/mhammond/pywin32
|
||||
[certifi]: https://pypi.org/project/certifi/
|
||||
[py2-ipaddress]: https://pypi.org/project/py2-ipaddress/
|
||||
[dbus-python]: https://pypi.org/project/dbus-python/
|
||||
[setproctitle]: https://pypi.org/project/setproctitle/
|
||||
[gtkosxapplication]: https://github.com/jralls/gtk-mac-integration
|
||||
[chardet]: https://chardet.github.io/
|
||||
[rencode]: https://github.com/aresch/rencode
|
||||
[pyxdg]: https://www.freedesktop.org/wiki/Software/pyxdg/
|
||||
[six]: https://pythonhosted.org/six/
|
||||
[xdg-utils]: https://www.freedesktop.org/wiki/Software/xdg-utils/
|
||||
[gtk+]: https://www.gtk.org/
|
||||
[pycairo]: https://cairographics.org/pycairo/
|
||||
|
@ -95,6 +97,5 @@ All modules will require the [common](#common) section dependencies.
|
|||
[mako]: https://www.makotemplates.org/
|
||||
[pygame]: https://www.pygame.org/
|
||||
[libnotify]: https://developer.gnome.org/libnotify/
|
||||
[ayatanaappindicator3]: https://lazka.github.io/pgi-docs/AyatanaAppIndicator3-0.1/index.html
|
||||
[python-appindicator]: https://packages.ubuntu.com/xenial/python-appindicator
|
||||
[librsvg]: https://wiki.gnome.org/action/show/Projects/LibRsvg
|
||||
[ifaddr]: https://pypi.org/project/ifaddr/
|
||||
|
|
|
@ -8,7 +8,6 @@ include version.py
|
|||
include gen_web_gettext.py
|
||||
|
||||
graft docs/man
|
||||
graft packaging/systemd
|
||||
|
||||
include deluge/i18n/*.po
|
||||
recursive-exclude deluge/i18n *.mo
|
||||
|
@ -23,7 +22,7 @@ recursive-exclude deluge/tests *.pyc
|
|||
|
||||
graft deluge/ui/data
|
||||
recursive-exclude deluge/ui/data *.desktop *.xml
|
||||
graft deluge/ui/gtk3/glade
|
||||
graft deluge/ui/gtkui/glade
|
||||
|
||||
include deluge/ui/web/index.html
|
||||
include deluge/ui/web/css/*.css
|
||||
|
|
26
README.md
26
README.md
|
@ -1,10 +1,10 @@
|
|||
# Deluge BitTorrent Client
|
||||
|
||||
[![build-status]][github-ci] [![docs-status]][rtd-deluge]
|
||||
[![build-status]][travis-deluge] [![docs-status]][rtd-deluge]
|
||||
|
||||
Deluge is a BitTorrent client that utilizes a daemon/client model.
|
||||
It has various user interfaces available such as the GTK-UI, Web-UI and
|
||||
Console-UI. It uses [libtorrent][lt] at its core to handle the BitTorrent
|
||||
a Console-UI. It uses [libtorrent][lt] at it's core to handle the BitTorrent
|
||||
protocol.
|
||||
|
||||
## Install
|
||||
|
@ -13,17 +13,10 @@ From [PyPi](https://pypi.org/project/deluge):
|
|||
|
||||
pip install deluge
|
||||
|
||||
with all optional dependencies:
|
||||
|
||||
pip install deluge[all]
|
||||
|
||||
From source code:
|
||||
|
||||
pip install .
|
||||
|
||||
with all optional dependencies:
|
||||
|
||||
pip install .[all]
|
||||
python setup.py build
|
||||
python setup.py install
|
||||
|
||||
See [DEPENDS](DEPENDS.md) and [Installing/Source] for dependency details.
|
||||
|
||||
|
@ -58,14 +51,13 @@ See the [Thinclient guide] to connect to the daemon from another computer.
|
|||
- [Homepage](https://deluge-torrent.org)
|
||||
- [User guide][user guide]
|
||||
- [Forum](https://forum.deluge-torrent.org)
|
||||
- [IRC Libera.Chat #deluge](irc://irc.libera.chat/deluge)
|
||||
- [Discord](https://discord.gg/nwaHSE6tqn)
|
||||
- [IRC Freenode #deluge](irc://irc.freenode.net/deluge)
|
||||
|
||||
[user guide]: https://dev.deluge-torrent.org/wiki/UserGuide
|
||||
[thinclient guide]: https://dev.deluge-torrent.org/wiki/UserGuide/ThinClient
|
||||
[installing/source]: https://dev.deluge-torrent.org/wiki/Installing/Source
|
||||
[build-status]: https://github.com/deluge-torrent/deluge/actions/workflows/ci.yml/badge.svg?branch=develop "CI"
|
||||
[github-ci]: https://github.com/deluge-torrent/deluge/actions/workflows/ci.yml
|
||||
[docs-status]: https://readthedocs.org/projects/deluge/badge/?version=latest
|
||||
[rtd-deluge]: https://deluge.readthedocs.io/en/latest/?badge=latest "Documentation Status"
|
||||
[build-status]: https://travis-ci.org/deluge-torrent/deluge.svg "Travis Status"
|
||||
[travis-deluge]: https://travis-ci.org/deluge-torrent/deluge
|
||||
[docs-status]: https://readthedocs.org/projects/deluge/badge/?version=develop
|
||||
[rtd-deluge]: https://deluge.readthedocs.io/en/develop/?badge=develop "Documentation Status"
|
||||
[lt]: https://libtorrent.org
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
from twisted.web.http import Request
|
||||
|
||||
__request__: Request
|
||||
|
||||
def _(string: str) -> str: ...
|
||||
def _n(string: str) -> str: ...
|
53
appveyor.yml
Normal file
53
appveyor.yml
Normal file
|
@ -0,0 +1,53 @@
|
|||
environment:
|
||||
PYTHON_VERSION: 3.6
|
||||
PYTHON_ARCH: 64
|
||||
PYTHON: "C:\\Python36-x64"
|
||||
APPVEYOR_SAVE_CACHE_ON_ERROR: true
|
||||
|
||||
matrix:
|
||||
- TOXENV: py36
|
||||
|
||||
pull_requests:
|
||||
do_not_increment_build_number: true
|
||||
|
||||
install:
|
||||
# If there is a newer build queued for same PR, cancel this one. Credit: JuliaLang devs
|
||||
- ps:
|
||||
if ($env:APPVEYOR_PULL_REQUEST_NUMBER -and $env:APPVEYOR_BUILD_NUMBER -ne ((Invoke-RestMethod `
|
||||
https://ci.appveyor.com/api/projects/$env:APPVEYOR_ACCOUNT_NAME/$env:APPVEYOR_PROJECT_SLUG/history?recordsNumber=50).builds | `
|
||||
Where-Object pullRequestId -eq $env:APPVEYOR_PULL_REQUEST_NUMBER)[0].buildNumber) { `
|
||||
throw "There are newer queued builds for this pull request, failing early." }
|
||||
- "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%"
|
||||
- "python -VV"
|
||||
- if defined TOXENV (
|
||||
python -m pip install tox tox_venv
|
||||
) else (
|
||||
python -m pip install -rrequirements.txt pygame bbfreeze pefile
|
||||
)
|
||||
- "SET PATH=C:\\OpenSSL-v11-Win64\\bin;%PATH%"
|
||||
- openssl version -v
|
||||
- python -m pip install deluge-libtorrent
|
||||
- 'python -c "import libtorrent; print(libtorrent.__version__)"'
|
||||
|
||||
cache:
|
||||
- '%LOCALAPPDATA%\pip\cache'
|
||||
|
||||
build: false
|
||||
|
||||
test_script:
|
||||
- if defined TOXENV tox
|
||||
# Commented out as require GTK3 to create package.
|
||||
# after_test:
|
||||
# - if not defined TOXENV python setup.py build && python setup.py install
|
||||
# - cd %APPVEYOR_BUILD_FOLDER%\\packaging\\win32
|
||||
# - if not defined TOXENV deluge-bbfreeze.py debug
|
||||
# - "SET PATH=C:\\Program Files (x86)\\NSIS;%PATH%"
|
||||
# - if not defined TOXENV makensis deluge-win32-installer.nsi
|
||||
# - if not defined TOXENV 7z a deluge-win32.zip build-win32 "-x!*.exe"
|
||||
|
||||
# artifacts:
|
||||
# - path: packaging\win32\deluge-win32.zip
|
||||
# - path: packaging\win32\build-win32\*.exe
|
||||
|
||||
#on_success:
|
||||
#
|
|
@ -1,3 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2009 Andrew Resch <andrewresch@gmail.com>
|
||||
#
|
||||
|
@ -14,23 +15,19 @@ Example:
|
|||
>>> from deluge._libtorrent import lt
|
||||
|
||||
"""
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from deluge.common import VersionSplit, get_version
|
||||
from deluge.error import LibtorrentImportError
|
||||
|
||||
try:
|
||||
import deluge.libtorrent as lt
|
||||
except ImportError:
|
||||
try:
|
||||
import libtorrent as lt
|
||||
except ImportError as ex:
|
||||
raise LibtorrentImportError('No libtorrent library found: %s' % (ex))
|
||||
import libtorrent as lt
|
||||
|
||||
|
||||
REQUIRED_VERSION = '1.2.0.0'
|
||||
REQUIRED_VERSION = '1.1.2.0'
|
||||
LT_VERSION = lt.__version__
|
||||
|
||||
if VersionSplit(LT_VERSION) < VersionSplit(REQUIRED_VERSION):
|
||||
raise LibtorrentImportError(
|
||||
f'Deluge {get_version()} requires libtorrent >= {REQUIRED_VERSION}'
|
||||
raise ImportError(
|
||||
'Deluge %s requires libtorrent >= %s' % (get_version(), REQUIRED_VERSION)
|
||||
)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2007 Andrew Resch <andrewresch@gmail.com>
|
||||
#
|
||||
|
@ -6,6 +7,8 @@
|
|||
# See LICENSE for more details.
|
||||
#
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
|
@ -92,7 +95,7 @@ def _get_version_detail():
|
|||
except ImportError:
|
||||
pass
|
||||
version_str += 'Python: %s\n' % platform.python_version()
|
||||
version_str += f'OS: {platform.system()} {common.get_os_version()}\n'
|
||||
version_str += 'OS: %s %s\n' % (platform.system(), common.get_os_version())
|
||||
return version_str
|
||||
|
||||
|
||||
|
@ -106,8 +109,8 @@ class DelugeTextHelpFormatter(argparse.RawDescriptionHelpFormatter):
|
|||
line instead. This way list formatting is not mangled by textwrap.wrap.
|
||||
"""
|
||||
wrapped_lines = []
|
||||
for line in text.splitlines():
|
||||
wrapped_lines.extend(textwrap.wrap(line, width, subsequent_indent=' '))
|
||||
for l in text.splitlines():
|
||||
wrapped_lines.extend(textwrap.wrap(l, width, subsequent_indent=' '))
|
||||
return wrapped_lines
|
||||
|
||||
def _format_action_invocation(self, action):
|
||||
|
@ -119,7 +122,7 @@ class DelugeTextHelpFormatter(argparse.RawDescriptionHelpFormatter):
|
|||
|
||||
"""
|
||||
if not action.option_strings:
|
||||
(metavar,) = self._metavar_formatter(action, action.dest)(1)
|
||||
metavar, = self._metavar_formatter(action, action.dest)(1)
|
||||
return metavar
|
||||
else:
|
||||
parts = []
|
||||
|
@ -134,7 +137,7 @@ class DelugeTextHelpFormatter(argparse.RawDescriptionHelpFormatter):
|
|||
default = action.dest.upper()
|
||||
args_string = self._format_args(action, default)
|
||||
opt = ', '.join(action.option_strings)
|
||||
parts.append(f'{opt} {args_string}')
|
||||
parts.append('%s %s' % (opt, args_string))
|
||||
return ', '.join(parts)
|
||||
|
||||
|
||||
|
@ -162,7 +165,7 @@ class ArgParserBase(argparse.ArgumentParser):
|
|||
self.log_stream = kwargs['log_stream']
|
||||
del kwargs['log_stream']
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
super(ArgParserBase, self).__init__(*args, **kwargs)
|
||||
|
||||
self.common_setup = False
|
||||
self.process_arg_group = False
|
||||
|
@ -199,7 +202,7 @@ class ArgParserBase(argparse.ArgumentParser):
|
|||
self.group.add_argument(
|
||||
'-L',
|
||||
'--loglevel',
|
||||
choices=[level for k in deluge.log.levels for level in (k, k.upper())],
|
||||
choices=[l for k in deluge.log.levels for l in (k, k.upper())],
|
||||
help=_('Set the log level (none, error, warning, info, debug)'),
|
||||
metavar='<level>',
|
||||
)
|
||||
|
@ -243,7 +246,7 @@ class ArgParserBase(argparse.ArgumentParser):
|
|||
argparse.Namespace: The parsed arguments.
|
||||
|
||||
"""
|
||||
options = super().parse_args(args=args)
|
||||
options = super(ArgParserBase, self).parse_args(args=args)
|
||||
return self._handle_ui_options(options)
|
||||
|
||||
def parse_known_ui_args(self, args, withhold=None):
|
||||
|
@ -259,9 +262,9 @@ class ArgParserBase(argparse.ArgumentParser):
|
|||
"""
|
||||
if withhold:
|
||||
args = [a for a in args if a not in withhold]
|
||||
options, remaining = super().parse_known_args(args=args)
|
||||
options, remaining = super(ArgParserBase, self).parse_known_args(args=args)
|
||||
options.remaining = remaining
|
||||
# Handle common and process group options
|
||||
# Hanlde common and process group options
|
||||
return self._handle_ui_options(options)
|
||||
|
||||
def _handle_ui_options(self, options):
|
||||
|
@ -322,22 +325,22 @@ class ArgParserBase(argparse.ArgumentParser):
|
|||
|
||||
# Write pid file before chuid
|
||||
if options.pidfile:
|
||||
with open(options.pidfile, 'w') as _file:
|
||||
with open(options.pidfile, 'wb') as _file:
|
||||
_file.write('%d\n' % os.getpid())
|
||||
|
||||
if not common.windows_check():
|
||||
if options.group:
|
||||
if not options.group.isdigit():
|
||||
import grp
|
||||
|
||||
options.group = grp.getgrnam(options.group)[2]
|
||||
os.setgid(options.group)
|
||||
if options.user:
|
||||
if not options.user.isdigit():
|
||||
import pwd
|
||||
|
||||
options.user = pwd.getpwnam(options.user)[2]
|
||||
os.setuid(options.user)
|
||||
if options.group:
|
||||
if not options.group.isdigit():
|
||||
import grp
|
||||
|
||||
options.group = grp.getgrnam(options.group)[2]
|
||||
os.setuid(options.group)
|
||||
|
||||
return options
|
||||
|
||||
|
|
|
@ -9,7 +9,13 @@
|
|||
# License.
|
||||
|
||||
# Written by Petru Paler
|
||||
# Updated by Calum Lind to support Python 3.
|
||||
# Updated by Calum Lind to support both Python 2 and Python 3.
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from sys import version_info
|
||||
|
||||
PY2 = version_info.major == 2
|
||||
|
||||
|
||||
class BTFailure(Exception):
|
||||
|
@ -84,7 +90,8 @@ def bdecode(x):
|
|||
return r
|
||||
|
||||
|
||||
class Bencached:
|
||||
class Bencached(object):
|
||||
|
||||
__slots__ = ['bencoded']
|
||||
|
||||
def __init__(self, s):
|
||||
|
@ -139,6 +146,10 @@ encode_func[dict] = encode_dict
|
|||
encode_func[bool] = encode_bool
|
||||
encode_func[str] = encode_string
|
||||
encode_func[bytes] = encode_bytes
|
||||
if PY2:
|
||||
encode_func[long] = encode_int # noqa: F821
|
||||
encode_func[str] = encode_bytes
|
||||
encode_func[unicode] = encode_string # noqa: F821
|
||||
|
||||
|
||||
def bencode(x):
|
||||
|
|
422
deluge/common.py
422
deluge/common.py
|
@ -1,3 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2007,2008 Andrew Resch <andrewresch@gmail.com>
|
||||
#
|
||||
|
@ -7,43 +8,44 @@
|
|||
#
|
||||
|
||||
"""Common functions for various parts of Deluge to use."""
|
||||
from __future__ import division, print_function, unicode_literals
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import functools
|
||||
import glob
|
||||
import locale
|
||||
import logging
|
||||
import numbers
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
from contextlib import closing
|
||||
from datetime import datetime
|
||||
from importlib import resources
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote_plus, urljoin
|
||||
from urllib.request import pathname2url
|
||||
from io import BytesIO, open
|
||||
|
||||
import pkg_resources
|
||||
|
||||
from deluge.decorators import deprecated
|
||||
from deluge.error import InvalidPathError
|
||||
|
||||
try:
|
||||
from importlib.metadata import distribution
|
||||
except ImportError:
|
||||
from pkg_resources import get_distribution as distribution
|
||||
|
||||
|
||||
try:
|
||||
import chardet
|
||||
except ImportError:
|
||||
chardet = None
|
||||
|
||||
try:
|
||||
from urllib.parse import unquote_plus, urljoin
|
||||
from urllib.request import pathname2url
|
||||
except ImportError:
|
||||
# PY2 fallback
|
||||
from urlparse import urljoin # pylint: disable=ungrouped-imports
|
||||
from urllib import pathname2url, unquote_plus # pylint: disable=ungrouped-imports
|
||||
|
||||
# Windows workaround for HTTPS requests requiring certificate authority bundle.
|
||||
# see: https://twistedmatrix.com/trac/ticket/9209
|
||||
if platform.system() in ('Windows', 'Microsoft'):
|
||||
|
@ -51,11 +53,6 @@ if platform.system() in ('Windows', 'Microsoft'):
|
|||
|
||||
os.environ['SSL_CERT_FILE'] = where()
|
||||
|
||||
try:
|
||||
import ifaddr
|
||||
except ImportError:
|
||||
ifaddr = None
|
||||
|
||||
|
||||
if platform.system() not in ('Windows', 'Microsoft', 'Darwin'):
|
||||
# gi makes dbus available on Window but don't import it as unused.
|
||||
|
@ -84,11 +81,7 @@ TORRENT_STATE = [
|
|||
# The output formatting for json.dump
|
||||
JSON_FORMAT = {'indent': 4, 'sort_keys': True, 'ensure_ascii': False}
|
||||
|
||||
DBUS_FM_ID = 'org.freedesktop.FileManager1'
|
||||
DBUS_FM_PATH = '/org/freedesktop/FileManager1'
|
||||
|
||||
# Retained for plugin backward compatibility
|
||||
PY2 = False
|
||||
PY2 = sys.version_info.major == 2
|
||||
|
||||
|
||||
def get_version():
|
||||
|
@ -97,7 +90,7 @@ def get_version():
|
|||
Returns:
|
||||
str: The version of Deluge.
|
||||
"""
|
||||
return distribution('Deluge').version
|
||||
return pkg_resources.get_distribution('Deluge').version
|
||||
|
||||
|
||||
def get_default_config_dir(filename=None):
|
||||
|
@ -115,8 +108,10 @@ def get_default_config_dir(filename=None):
|
|||
def save_config_path(resource):
|
||||
app_data_path = os.environ.get('APPDATA')
|
||||
if not app_data_path:
|
||||
import winreg
|
||||
|
||||
try:
|
||||
import winreg
|
||||
except ImportError:
|
||||
import _winreg as winreg # For Python 2.
|
||||
hkey = winreg.OpenKey(
|
||||
winreg.HKEY_CURRENT_USER,
|
||||
'Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Shell Folders',
|
||||
|
@ -149,14 +144,14 @@ def get_default_download_dir():
|
|||
|
||||
try:
|
||||
user_dirs_path = os.path.join(xdg_config_home, 'user-dirs.dirs')
|
||||
with open(user_dirs_path, encoding='utf8') as _file:
|
||||
with open(user_dirs_path, 'r', encoding='utf8') as _file:
|
||||
for line in _file:
|
||||
if not line.startswith('#') and line.startswith('XDG_DOWNLOAD_DIR'):
|
||||
download_dir = os.path.expandvars(
|
||||
line.partition('=')[2].rstrip().strip('"')
|
||||
)
|
||||
break
|
||||
except OSError:
|
||||
except IOError:
|
||||
pass
|
||||
|
||||
if not download_dir:
|
||||
|
@ -180,8 +175,8 @@ def archive_files(arc_name, filepaths, message=None, rotate=10):
|
|||
|
||||
from deluge.configmanager import get_config_dir
|
||||
|
||||
# Set archive compression to lzma
|
||||
arc_comp = 'xz'
|
||||
# Set archive compression to lzma with bz2 fallback.
|
||||
arc_comp = 'xz' if not PY2 else 'bz2'
|
||||
|
||||
archive_dir = os.path.join(get_config_dir(), 'archive')
|
||||
timestamp = datetime.now().replace(microsecond=0).isoformat().replace(':', '-')
|
||||
|
@ -277,7 +272,7 @@ def get_os_version():
|
|||
os_version = list(platform.mac_ver())
|
||||
os_version[1] = '' # versioninfo always empty.
|
||||
elif distro:
|
||||
os_version = (distro.name(), distro.version(), distro.codename())
|
||||
os_version = distro.linux_distribution()
|
||||
else:
|
||||
os_version = (platform.release(),)
|
||||
|
||||
|
@ -297,22 +292,20 @@ def get_pixmap(fname):
|
|||
return resource_filename('deluge', os.path.join('ui', 'data', 'pixmaps', fname))
|
||||
|
||||
|
||||
def resource_filename(module: str, path: str) -> str:
|
||||
"""Get filesystem path for a non-python resource.
|
||||
def resource_filename(module, path):
|
||||
"""Get filesystem path for a resource.
|
||||
|
||||
Abstracts getting module resource files. Originally created to
|
||||
workaround pkg_resources.resource_filename limitations with
|
||||
multiple Deluge packages installed.
|
||||
This function contains a work-around for pkg_resources.resource_filename
|
||||
not returning the correct path with multiple packages installed.
|
||||
|
||||
So if there's a second deluge package, installed globally and another in
|
||||
develop mode somewhere else, while pkg_resources.get_distribution('Deluge')
|
||||
returns the proper deluge instance, pkg_resources.resource_filename
|
||||
does not, it returns the first found on the python path, which is wrong.
|
||||
"""
|
||||
path = Path(path)
|
||||
|
||||
try:
|
||||
with resources.as_file(resources.files(module) / path) as resource_file:
|
||||
return str(resource_file)
|
||||
except AttributeError:
|
||||
# Python <= 3.8
|
||||
with resources.path(module, path.parts[0]) as resource_file:
|
||||
return str(resource_file.joinpath(*path.parts[1:]))
|
||||
return pkg_resources.get_distribution('Deluge').get_resource_filename(
|
||||
pkg_resources._manager, os.path.join(*(module.split('.') + [path]))
|
||||
)
|
||||
|
||||
|
||||
def open_file(path, timestamp=None):
|
||||
|
@ -362,30 +355,27 @@ def show_file(path, timestamp=None):
|
|||
timestamp,
|
||||
timestamp,
|
||||
)
|
||||
|
||||
if dbus:
|
||||
bus = dbus.SessionBus()
|
||||
try:
|
||||
filemanager1 = bus.get_object(DBUS_FM_ID, DBUS_FM_PATH)
|
||||
except dbus.exceptions.DBusException as ex:
|
||||
log.debug('Unable to get dbus file manager: %s', ex)
|
||||
# Fallback to xdg-open
|
||||
else:
|
||||
paths = [urljoin('file:', pathname2url(path))]
|
||||
filemanager1.ShowItems(paths, startup_id, dbus_interface=DBUS_FM_ID)
|
||||
return
|
||||
|
||||
env = os.environ.copy()
|
||||
env['DESKTOP_STARTUP_ID'] = startup_id.replace('dbus', 'xdg-open')
|
||||
# No option in xdg to highlight a file so just open parent folder.
|
||||
subprocess.Popen(['xdg-open', os.path.dirname(path.rstrip('/'))], env=env)
|
||||
filemanager1 = bus.get_object(
|
||||
'org.freedesktop.FileManager1', '/org/freedesktop/FileManager1'
|
||||
)
|
||||
paths = [urljoin('file:', pathname2url(path))]
|
||||
filemanager1.ShowItems(
|
||||
paths, startup_id, dbus_interface='org.freedesktop.FileManager1'
|
||||
)
|
||||
else:
|
||||
env = os.environ.copy()
|
||||
env['DESKTOP_STARTUP_ID'] = startup_id.replace('dbus', 'xdg-open')
|
||||
# No option in xdg to highlight a file so just open parent folder.
|
||||
subprocess.Popen(['xdg-open', os.path.dirname(path.rstrip('/'))], env=env)
|
||||
|
||||
|
||||
def open_url_in_browser(url):
|
||||
"""
|
||||
Opens a URL in the desktop's default browser
|
||||
Opens a url in the desktop's default browser
|
||||
|
||||
:param url: the URL to open
|
||||
:param url: the url to open
|
||||
:type url: string
|
||||
|
||||
"""
|
||||
|
@ -424,49 +414,43 @@ def translate_size_units():
|
|||
|
||||
|
||||
def fsize(fsize_b, precision=1, shortform=False):
|
||||
"""Formats the bytes value into a string with KiB, MiB, GiB or TiB units.
|
||||
"""Formats the bytes value into a string with KiB, MiB or GiB units.
|
||||
|
||||
Args:
|
||||
fsize_b (int): The filesize in bytes.
|
||||
precision (int): The output float precision, 1 by default.
|
||||
shortform (bool): The output short|long form, False (long form) by default.
|
||||
precision (int): The filesize float precision.
|
||||
|
||||
Returns:
|
||||
str: A formatted string in KiB, MiB, GiB or TiB units.
|
||||
str: A formatted string in KiB, MiB or GiB units.
|
||||
|
||||
Examples:
|
||||
>>> fsize(112245)
|
||||
'109.6 KiB'
|
||||
>>> fsize(112245, precision=0)
|
||||
'110 KiB'
|
||||
>>> fsize(112245, shortform=True)
|
||||
'109.6 K'
|
||||
|
||||
Note:
|
||||
This function has been refactored for performance with the
|
||||
This function has been refactored for perfomance with the
|
||||
fsize units being translated outside the function.
|
||||
|
||||
Notice that short forms K|M|G|T are synonymous here with
|
||||
KiB|MiB|GiB|TiB. They are powers of 1024, not 1000.
|
||||
|
||||
"""
|
||||
|
||||
if fsize_b >= 1024**4:
|
||||
if fsize_b >= 1024 ** 4:
|
||||
return '%.*f %s' % (
|
||||
precision,
|
||||
fsize_b / 1024**4,
|
||||
fsize_b / 1024 ** 4,
|
||||
tib_txt_short if shortform else tib_txt,
|
||||
)
|
||||
elif fsize_b >= 1024**3:
|
||||
elif fsize_b >= 1024 ** 3:
|
||||
return '%.*f %s' % (
|
||||
precision,
|
||||
fsize_b / 1024**3,
|
||||
fsize_b / 1024 ** 3,
|
||||
gib_txt_short if shortform else gib_txt,
|
||||
)
|
||||
elif fsize_b >= 1024**2:
|
||||
elif fsize_b >= 1024 ** 2:
|
||||
return '%.*f %s' % (
|
||||
precision,
|
||||
fsize_b / 1024**2,
|
||||
fsize_b / 1024 ** 2,
|
||||
mib_txt_short if shortform else mib_txt,
|
||||
)
|
||||
elif fsize_b >= 1024:
|
||||
|
@ -484,7 +468,7 @@ def fpcnt(dec, precision=2):
|
|||
|
||||
Args:
|
||||
dec (float): The ratio in the range [0.0, 1.0].
|
||||
precision (int): The output float precision, 2 by default.
|
||||
precision (int): The percentage float precision.
|
||||
|
||||
Returns:
|
||||
str: A formatted string representing a percentage.
|
||||
|
@ -508,8 +492,6 @@ def fspeed(bps, precision=1, shortform=False):
|
|||
|
||||
Args:
|
||||
bps (int): The speed in bytes per second.
|
||||
precision (int): The output float precision, 1 by default.
|
||||
shortform (bool): The output short|long form, False (long form) by default.
|
||||
|
||||
Returns:
|
||||
str: A formatted string representing transfer speed.
|
||||
|
@ -518,34 +500,30 @@ def fspeed(bps, precision=1, shortform=False):
|
|||
>>> fspeed(43134)
|
||||
'42.1 KiB/s'
|
||||
|
||||
Note:
|
||||
Notice that short forms K|M|G|T are synonymous here with
|
||||
KiB|MiB|GiB|TiB. They are powers of 1024, not 1000.
|
||||
|
||||
"""
|
||||
|
||||
if bps < 1024**2:
|
||||
if bps < 1024 ** 2:
|
||||
return '%.*f %s' % (
|
||||
precision,
|
||||
bps / 1024,
|
||||
_('K/s') if shortform else _('KiB/s'),
|
||||
)
|
||||
elif bps < 1024**3:
|
||||
elif bps < 1024 ** 3:
|
||||
return '%.*f %s' % (
|
||||
precision,
|
||||
bps / 1024**2,
|
||||
bps / 1024 ** 2,
|
||||
_('M/s') if shortform else _('MiB/s'),
|
||||
)
|
||||
elif bps < 1024**4:
|
||||
elif bps < 1024 ** 4:
|
||||
return '%.*f %s' % (
|
||||
precision,
|
||||
bps / 1024**3,
|
||||
bps / 1024 ** 3,
|
||||
_('G/s') if shortform else _('GiB/s'),
|
||||
)
|
||||
else:
|
||||
return '%.*f %s' % (
|
||||
precision,
|
||||
bps / 1024**4,
|
||||
bps / 1024 ** 4,
|
||||
_('T/s') if shortform else _('TiB/s'),
|
||||
)
|
||||
|
||||
|
@ -558,7 +536,7 @@ def fpeer(num_peers, total_peers):
|
|||
total_peers (int): The total number of peers.
|
||||
|
||||
Returns:
|
||||
str: A formatted string 'num_peers (total_peers)' or if total_peers < 0, just 'num_peers'.
|
||||
str: A formatted string 'num_peers (total_peers)' or total_peers < 0, just 'num_peers'.
|
||||
|
||||
Examples:
|
||||
>>> fpeer(10, 20)
|
||||
|
@ -568,9 +546,9 @@ def fpeer(num_peers, total_peers):
|
|||
|
||||
"""
|
||||
if total_peers > -1:
|
||||
return f'{num_peers:d} ({total_peers:d})'
|
||||
return '{:d} ({:d})'.format(num_peers, total_peers)
|
||||
else:
|
||||
return f'{num_peers:d}'
|
||||
return '{:d}'.format(num_peers)
|
||||
|
||||
|
||||
def ftime(secs):
|
||||
|
@ -587,7 +565,7 @@ def ftime(secs):
|
|||
'6h 23m'
|
||||
|
||||
Note:
|
||||
This function has been refactored for performance.
|
||||
This function has been refactored for perfomance.
|
||||
|
||||
"""
|
||||
|
||||
|
@ -596,27 +574,27 @@ def ftime(secs):
|
|||
if secs <= 0:
|
||||
time_str = ''
|
||||
elif secs < 60:
|
||||
time_str = f'{secs}s'
|
||||
time_str = '{}s'.format(secs)
|
||||
elif secs < 3600:
|
||||
time_str = f'{secs // 60}m {secs % 60}s'
|
||||
time_str = '{}m {}s'.format(secs // 60, secs % 60)
|
||||
elif secs < 86400:
|
||||
time_str = f'{secs // 3600}h {secs // 60 % 60}m'
|
||||
time_str = '{}h {}m'.format(secs // 3600, secs // 60 % 60)
|
||||
elif secs < 604800:
|
||||
time_str = f'{secs // 86400}d {secs // 3600 % 24}h'
|
||||
time_str = '{}d {}h'.format(secs // 86400, secs // 3600 % 24)
|
||||
elif secs < 31449600:
|
||||
time_str = f'{secs // 604800}w {secs // 86400 % 7}d'
|
||||
time_str = '{}w {}d'.format(secs // 604800, secs // 86400 % 7)
|
||||
else:
|
||||
time_str = f'{secs // 31449600}y {secs // 604800 % 52}w'
|
||||
time_str = '{}y {}w'.format(secs // 31449600, secs // 604800 % 52)
|
||||
|
||||
return time_str
|
||||
|
||||
|
||||
def fdate(seconds, date_only=False, precision_secs=False):
|
||||
"""Formats a date time string in the locale's date representation based on the system's timezone.
|
||||
"""Formats a date time string in the locale's date representation based on the systems timezone.
|
||||
|
||||
Args:
|
||||
seconds (float): Time in seconds since the Epoch.
|
||||
date_only (bool): Whether to include only the date, False by default.
|
||||
precision_secs (bool): Include seconds in time format, False by default.
|
||||
precision_secs (bool): Include seconds in time format.
|
||||
|
||||
Returns:
|
||||
str: A string in the locale's datetime representation or "" if seconds < 0
|
||||
|
@ -641,14 +619,10 @@ def tokenize(text):
|
|||
Returns:
|
||||
list: A list of strings and/or numbers.
|
||||
|
||||
Note:
|
||||
This function is used to implement robust tokenization of user input
|
||||
It automatically coerces integer and floating point numbers, ignores
|
||||
whitespace and knows how to separate numbers from strings even without
|
||||
whitespace.
|
||||
|
||||
Possible optimization: move the 2 regexes outside of function.
|
||||
|
||||
This function is used to implement robust tokenization of user input
|
||||
It automatically coerces integer and floating point numbers, ignores
|
||||
whitespace and knows how to separate numbers from strings even without
|
||||
whitespace.
|
||||
"""
|
||||
tokenized_input = []
|
||||
for token in re.split(r'(\d+(?:\.\d+)?)', text):
|
||||
|
@ -664,21 +638,17 @@ def tokenize(text):
|
|||
|
||||
size_units = [
|
||||
{'prefix': 'b', 'divider': 1, 'singular': 'byte', 'plural': 'bytes'},
|
||||
{'prefix': 'KiB', 'divider': 1024**1},
|
||||
{'prefix': 'MiB', 'divider': 1024**2},
|
||||
{'prefix': 'GiB', 'divider': 1024**3},
|
||||
{'prefix': 'TiB', 'divider': 1024**4},
|
||||
{'prefix': 'PiB', 'divider': 1024**5},
|
||||
{'prefix': 'k', 'divider': 1000**1},
|
||||
{'prefix': 'm', 'divider': 1000**2},
|
||||
{'prefix': 'g', 'divider': 1000**3},
|
||||
{'prefix': 't', 'divider': 1000**4},
|
||||
{'prefix': 'p', 'divider': 1000**5},
|
||||
{'prefix': 'KB', 'divider': 1000**1},
|
||||
{'prefix': 'MB', 'divider': 1000**2},
|
||||
{'prefix': 'GB', 'divider': 1000**3},
|
||||
{'prefix': 'TB', 'divider': 1000**4},
|
||||
{'prefix': 'PB', 'divider': 1000**5},
|
||||
{'prefix': 'KiB', 'divider': 1024 ** 1},
|
||||
{'prefix': 'MiB', 'divider': 1024 ** 2},
|
||||
{'prefix': 'GiB', 'divider': 1024 ** 3},
|
||||
{'prefix': 'TiB', 'divider': 1024 ** 4},
|
||||
{'prefix': 'PiB', 'divider': 1024 ** 5},
|
||||
{'prefix': 'KB', 'divider': 1000 ** 1},
|
||||
{'prefix': 'MB', 'divider': 1000 ** 2},
|
||||
{'prefix': 'GB', 'divider': 1000 ** 3},
|
||||
{'prefix': 'TB', 'divider': 1000 ** 4},
|
||||
{'prefix': 'PB', 'divider': 1000 ** 5},
|
||||
{'prefix': 'm', 'divider': 1000 ** 2},
|
||||
]
|
||||
|
||||
|
||||
|
@ -721,21 +691,11 @@ def parse_human_size(size):
|
|||
raise InvalidSize(msg % (size, tokens))
|
||||
|
||||
|
||||
def anchorify_urls(text: str) -> str:
|
||||
"""
|
||||
Wrap all occurrences of text URLs with HTML
|
||||
"""
|
||||
url_pattern = r'((htt)|(ft)|(ud))ps?://\S+'
|
||||
html_href_pattern = r'<a href="\g<0>">\g<0></a>'
|
||||
|
||||
return re.sub(url_pattern, html_href_pattern, text)
|
||||
|
||||
|
||||
def is_url(url):
|
||||
"""
|
||||
A simple test to check if the URL is valid
|
||||
|
||||
:param url: the URL to test
|
||||
:param url: the url to test
|
||||
:type url: string
|
||||
:returns: True or False
|
||||
:rtype: bool
|
||||
|
@ -746,9 +706,6 @@ def is_url(url):
|
|||
True
|
||||
|
||||
"""
|
||||
if not url:
|
||||
return False
|
||||
|
||||
return url.partition('://')[0] in ('http', 'https', 'ftp', 'udp')
|
||||
|
||||
|
||||
|
@ -763,9 +720,6 @@ def is_infohash(infohash):
|
|||
bool: True if valid infohash, False otherwise.
|
||||
|
||||
"""
|
||||
if not infohash:
|
||||
return False
|
||||
|
||||
return len(infohash) == 40 and infohash.isalnum()
|
||||
|
||||
|
||||
|
@ -773,15 +727,13 @@ MAGNET_SCHEME = 'magnet:?'
|
|||
XT_BTIH_PARAM = 'xt=urn:btih:'
|
||||
DN_PARAM = 'dn='
|
||||
TR_PARAM = 'tr='
|
||||
TR_TIER_PARAM = 'tr.'
|
||||
TR_TIER_REGEX = re.compile(r'^tr.(\d+)=(\S+)')
|
||||
|
||||
|
||||
def is_magnet(uri):
|
||||
"""
|
||||
A check to determine if a URI is a valid bittorrent magnet URI
|
||||
A check to determine if a uri is a valid bittorrent magnet uri
|
||||
|
||||
:param uri: the URI to check
|
||||
:param uri: the uri to check
|
||||
:type uri: string
|
||||
:returns: True or False
|
||||
:rtype: bool
|
||||
|
@ -817,6 +769,8 @@ def get_magnet_info(uri):
|
|||
|
||||
"""
|
||||
|
||||
tr0_param = 'tr.'
|
||||
tr0_param_regex = re.compile(r'^tr.(\d+)=(\S+)')
|
||||
if not uri.startswith(MAGNET_SCHEME):
|
||||
return {}
|
||||
|
||||
|
@ -844,14 +798,12 @@ def get_magnet_info(uri):
|
|||
tracker = unquote_plus(param[len(TR_PARAM) :])
|
||||
trackers[tracker] = tier
|
||||
tier += 1
|
||||
elif param.startswith(TR_TIER_PARAM):
|
||||
tracker_match = re.match(TR_TIER_REGEX, param)
|
||||
if not tracker_match:
|
||||
continue
|
||||
|
||||
tier, tracker = tracker_match.groups()
|
||||
tracker = unquote_plus(tracker)
|
||||
trackers[tracker] = int(tier)
|
||||
elif param.startswith(tr0_param):
|
||||
try:
|
||||
tier, tracker = re.match(tr0_param_regex, param).groups()
|
||||
trackers[tracker] = tier
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
if info_hash:
|
||||
if not name:
|
||||
|
@ -867,15 +819,15 @@ def get_magnet_info(uri):
|
|||
|
||||
|
||||
def create_magnet_uri(infohash, name=None, trackers=None):
|
||||
"""Creates a magnet URI
|
||||
"""Creates a magnet uri
|
||||
|
||||
Args:
|
||||
infohash (str): The info-hash of the torrent.
|
||||
name (str, optional): The name of the torrent.
|
||||
trackers (list or dict, optional): A list of trackers or a dict or some {tracker: tier} pairs.
|
||||
trackers (list or dict, optional): A list of trackers or dict or {tracker: tier} pairs.
|
||||
|
||||
Returns:
|
||||
str: A magnet URI string.
|
||||
str: A magnet uri string.
|
||||
|
||||
"""
|
||||
try:
|
||||
|
@ -914,7 +866,7 @@ def get_path_size(path):
|
|||
return os.path.getsize(path)
|
||||
|
||||
dir_size = 0
|
||||
for p, dummy_dirs, files in os.walk(path):
|
||||
for (p, dummy_dirs, files) in os.walk(path):
|
||||
for _file in files:
|
||||
filename = os.path.join(p, _file)
|
||||
dir_size += os.path.getsize(filename)
|
||||
|
@ -946,29 +898,6 @@ def free_space(path):
|
|||
return disk_data.f_bavail * block_size
|
||||
|
||||
|
||||
def is_interface(interface):
|
||||
"""Check if interface is a valid IP or network adapter.
|
||||
|
||||
Args:
|
||||
interface (str): The IP or interface name to test.
|
||||
|
||||
Returns:
|
||||
bool: Whether interface is valid is not.
|
||||
|
||||
Examples:
|
||||
Windows:
|
||||
>>> is_interface('{7A30AE62-23ZA-3744-Z844-A5B042524871}')
|
||||
>>> is_interface('127.0.0.1')
|
||||
True
|
||||
Linux:
|
||||
>>> is_interface('lo')
|
||||
>>> is_interface('127.0.0.1')
|
||||
True
|
||||
|
||||
"""
|
||||
return is_ip(interface) or is_interface_name(interface)
|
||||
|
||||
|
||||
def is_ip(ip):
|
||||
"""A test to see if 'ip' is a valid IPv4 or IPv6 address.
|
||||
|
||||
|
@ -1004,12 +933,15 @@ def is_ipv4(ip):
|
|||
|
||||
"""
|
||||
|
||||
import socket
|
||||
|
||||
try:
|
||||
socket.inet_pton(socket.AF_INET, ip)
|
||||
except OSError:
|
||||
if windows_check():
|
||||
return socket.inet_aton(ip)
|
||||
else:
|
||||
return socket.inet_pton(socket.AF_INET, ip)
|
||||
except socket.error:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def is_ipv6(ip):
|
||||
|
@ -1028,51 +960,23 @@ def is_ipv6(ip):
|
|||
"""
|
||||
|
||||
try:
|
||||
socket.inet_pton(socket.AF_INET6, ip)
|
||||
except OSError:
|
||||
return False
|
||||
import ipaddress
|
||||
except ImportError:
|
||||
import socket
|
||||
|
||||
try:
|
||||
return socket.inet_pton(socket.AF_INET6, ip)
|
||||
except (socket.error, AttributeError):
|
||||
if windows_check():
|
||||
log.warning('Unable to verify IPv6 Address on Windows.')
|
||||
return True
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def is_interface_name(name):
|
||||
"""Returns True if an interface name exists.
|
||||
|
||||
Args:
|
||||
name (str): The Interface to test. eg. eth0 linux. GUID on Windows.
|
||||
|
||||
Returns:
|
||||
bool: Whether name is valid or not.
|
||||
|
||||
Examples:
|
||||
>>> is_interface_name("eth0")
|
||||
True
|
||||
>>> is_interface_name("{7A30AE62-23ZA-3744-Z844-A5B042524871}")
|
||||
True
|
||||
|
||||
"""
|
||||
|
||||
if not windows_check():
|
||||
try:
|
||||
socket.if_nametoindex(name)
|
||||
except OSError:
|
||||
return ipaddress.IPv6Address(decode_bytes(ip))
|
||||
except ipaddress.AddressValueError:
|
||||
pass
|
||||
else:
|
||||
return True
|
||||
|
||||
if ifaddr:
|
||||
try:
|
||||
adapters = ifaddr.get_adapters()
|
||||
except OSError:
|
||||
return True
|
||||
else:
|
||||
return any([name == a.name for a in adapters])
|
||||
|
||||
if windows_check():
|
||||
regex = '^{[0-9A-Z]{8}-([0-9A-Z]{4}-){3}[0-9A-Z]{12}}$'
|
||||
return bool(re.search(regex, str(name)))
|
||||
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def decode_bytes(byte_str, encoding='utf8'):
|
||||
|
@ -1103,9 +1007,9 @@ def decode_bytes(byte_str, encoding='utf8'):
|
|||
if encoding.lower() not in ['utf8', 'utf-8']:
|
||||
encodings.insert(0, lambda: (encoding, 'strict'))
|
||||
|
||||
for enc in encodings:
|
||||
for l in encodings:
|
||||
try:
|
||||
return byte_str.decode(*enc())
|
||||
return byte_str.decode(*l())
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
return ''
|
||||
|
@ -1150,7 +1054,7 @@ def utf8_encode_structure(data):
|
|||
|
||||
|
||||
@functools.total_ordering
|
||||
class VersionSplit:
|
||||
class VersionSplit(object):
|
||||
"""
|
||||
Used for comparing version numbers.
|
||||
|
||||
|
@ -1234,7 +1138,6 @@ AUTH_LEVEL_DEFAULT = AUTH_LEVEL_NORMAL
|
|||
|
||||
def create_auth_file():
|
||||
import stat
|
||||
|
||||
import deluge.configmanager
|
||||
|
||||
auth_file = deluge.configmanager.get_config_dir('auth')
|
||||
|
@ -1250,7 +1153,6 @@ def create_auth_file():
|
|||
def create_localclient_account(append=False):
|
||||
import random
|
||||
from hashlib import sha1 as sha
|
||||
|
||||
import deluge.configmanager
|
||||
|
||||
auth_file = deluge.configmanager.get_config_dir('auth')
|
||||
|
@ -1273,7 +1175,7 @@ def create_localclient_account(append=False):
|
|||
|
||||
|
||||
def get_localhost_auth():
|
||||
"""Grabs the localclient auth line from the 'auth' file and creates a localhost URI.
|
||||
"""Grabs the localclient auth line from the 'auth' file and creates a localhost uri.
|
||||
|
||||
Returns:
|
||||
tuple: With the username and password to login as.
|
||||
|
@ -1329,10 +1231,15 @@ def set_env_variable(name, value):
|
|||
http://sourceforge.net/p/gramps/code/HEAD/tree/branches/maintenance/gramps32/src/TransUtils.py
|
||||
"""
|
||||
# Update Python's copy of the environment variables
|
||||
os.environ[name] = value
|
||||
try:
|
||||
os.environ[name] = value
|
||||
except UnicodeEncodeError:
|
||||
# Python 2
|
||||
os.environ[name] = value.encode('utf8')
|
||||
|
||||
if windows_check():
|
||||
from ctypes import cdll, windll
|
||||
from ctypes import windll
|
||||
from ctypes import cdll
|
||||
|
||||
# Update the copy maintained by Windows (so SysInternals Process Explorer sees it)
|
||||
result = windll.kernel32.SetEnvironmentVariableW(name, value)
|
||||
|
@ -1348,13 +1255,56 @@ def set_env_variable(name, value):
|
|||
)
|
||||
|
||||
# Update the copy maintained by msvcrt (used by gtk+ runtime)
|
||||
result = cdll.msvcrt._wputenv(f'{name}={value}')
|
||||
result = cdll.msvcrt._wputenv('%s=%s' % (name, value))
|
||||
if result != 0:
|
||||
log.info("Failed to set Env Var '%s' (msvcrt._putenv)", name)
|
||||
else:
|
||||
log.debug("Set Env Var '%s' to '%s' (msvcrt._putenv)", name, value)
|
||||
|
||||
|
||||
def unicode_argv():
|
||||
""" Gets sys.argv as list of unicode objects on any platform."""
|
||||
if windows_check():
|
||||
# Versions 2.x of Python don't support Unicode in sys.argv on
|
||||
# Windows, with the underlying Windows API instead replacing multi-byte
|
||||
# characters with '?'.
|
||||
from ctypes import POINTER, byref, cdll, c_int, windll
|
||||
from ctypes.wintypes import LPCWSTR, LPWSTR
|
||||
|
||||
get_cmd_linew = cdll.kernel32.GetCommandLineW
|
||||
get_cmd_linew.argtypes = []
|
||||
get_cmd_linew.restype = LPCWSTR
|
||||
|
||||
cmdline_to_argvw = windll.shell32.CommandLineToArgvW
|
||||
cmdline_to_argvw.argtypes = [LPCWSTR, POINTER(c_int)]
|
||||
cmdline_to_argvw.restype = POINTER(LPWSTR)
|
||||
|
||||
cmd = get_cmd_linew()
|
||||
argc = c_int(0)
|
||||
argv = cmdline_to_argvw(cmd, byref(argc))
|
||||
if argc.value > 0:
|
||||
# Remove Python executable and commands if present
|
||||
start = argc.value - len(sys.argv)
|
||||
return [argv[i] for i in range(start, argc.value)]
|
||||
else:
|
||||
# On other platforms, we have to find the likely encoding of the args and decode
|
||||
# First check if sys.stdout or stdin have encoding set
|
||||
encoding = getattr(sys.stdout, 'encoding') or getattr(sys.stdin, 'encoding')
|
||||
# If that fails, check what the locale is set to
|
||||
encoding = encoding or locale.getpreferredencoding()
|
||||
# As a last resort, just default to utf-8
|
||||
encoding = encoding or 'utf-8'
|
||||
|
||||
arg_list = []
|
||||
for arg in sys.argv:
|
||||
try:
|
||||
arg_list.append(arg.decode(encoding))
|
||||
except AttributeError:
|
||||
arg_list.append(arg)
|
||||
|
||||
return arg_list
|
||||
|
||||
|
||||
def run_profiled(func, *args, **kwargs):
|
||||
"""
|
||||
Profile a function with cProfile
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2007-2010 Andrew Resch <andrewresch@gmail.com>
|
||||
#
|
||||
|
@ -6,10 +7,13 @@
|
|||
# See LICENSE for more details.
|
||||
#
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
from collections import defaultdict
|
||||
|
||||
from six import string_types
|
||||
from twisted.internet import reactor
|
||||
from twisted.internet.defer import DeferredList, fail, maybeDeferred, succeed
|
||||
from twisted.internet.task import LoopingCall, deferLater
|
||||
|
@ -23,13 +27,13 @@ class ComponentAlreadyRegistered(Exception):
|
|||
|
||||
class ComponentException(Exception):
|
||||
def __init__(self, message, tb):
|
||||
super().__init__(message)
|
||||
super(ComponentException, self).__init__(message)
|
||||
self.message = message
|
||||
self.tb = tb
|
||||
|
||||
def __str__(self):
|
||||
s = super().__str__()
|
||||
return '{}\n{}'.format(s, ''.join(self.tb))
|
||||
s = super(ComponentException, self).__str__()
|
||||
return '%s\n%s' % (s, ''.join(self.tb))
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, self.__class__):
|
||||
|
@ -41,7 +45,7 @@ class ComponentException(Exception):
|
|||
return not self.__eq__(other)
|
||||
|
||||
|
||||
class Component:
|
||||
class Component(object):
|
||||
"""Component objects are singletons managed by the :class:`ComponentRegistry`.
|
||||
|
||||
When a new Component object is instantiated, it will be automatically
|
||||
|
@ -59,16 +63,11 @@ class Component:
|
|||
Deluge core.
|
||||
|
||||
**update()** - This method is called every 1 second by default while the
|
||||
Component is in a *Started* state. The interval can be
|
||||
Componented is in a *Started* state. The interval can be
|
||||
specified during instantiation. The update() timer can be
|
||||
paused by instructing the :class:`ComponentRegistry` to pause
|
||||
this Component.
|
||||
|
||||
**pause()** - This method is called when the component is being paused.
|
||||
|
||||
**resume()** - This method is called when the component resumes from a Paused
|
||||
state.
|
||||
|
||||
**shutdown()** - This method is called when the client is exiting. If the
|
||||
Component is in a "Started" state when this is called, a
|
||||
call to stop() will be issued prior to shutdown().
|
||||
|
@ -85,10 +84,10 @@ class Component:
|
|||
|
||||
**Stopped** - The Component has either been stopped or has yet to be started.
|
||||
|
||||
**Stopping** - The Component has had its stop method called, but it hasn't
|
||||
**Stopping** - The Component has had it's stop method called, but it hasn't
|
||||
fully stopped yet.
|
||||
|
||||
**Paused** - The Component has had its update timer stopped, but will
|
||||
**Paused** - The Component has had it's update timer stopped, but will
|
||||
still be considered in a Started state.
|
||||
|
||||
"""
|
||||
|
@ -116,8 +115,9 @@ class Component:
|
|||
_ComponentRegistry.deregister(self)
|
||||
|
||||
def _component_start_timer(self):
|
||||
self._component_timer = LoopingCall(self.update)
|
||||
self._component_timer.start(self._component_interval)
|
||||
if hasattr(self, 'update'):
|
||||
self._component_timer = LoopingCall(self.update)
|
||||
self._component_timer.start(self._component_interval)
|
||||
|
||||
def _component_start(self):
|
||||
def on_start(result):
|
||||
|
@ -133,10 +133,13 @@ class Component:
|
|||
return fail(result)
|
||||
|
||||
if self._component_state == 'Stopped':
|
||||
self._component_state = 'Starting'
|
||||
d = deferLater(reactor, 0, self.start)
|
||||
d.addCallbacks(on_start, on_start_fail)
|
||||
self._component_starting_deferred = d
|
||||
if hasattr(self, 'start'):
|
||||
self._component_state = 'Starting'
|
||||
d = deferLater(reactor, 0, self.start)
|
||||
d.addCallbacks(on_start, on_start_fail)
|
||||
self._component_starting_deferred = d
|
||||
else:
|
||||
d = maybeDeferred(on_start, None)
|
||||
elif self._component_state == 'Starting':
|
||||
return self._component_starting_deferred
|
||||
elif self._component_state == 'Started':
|
||||
|
@ -166,11 +169,14 @@ class Component:
|
|||
return result
|
||||
|
||||
if self._component_state != 'Stopped' and self._component_state != 'Stopping':
|
||||
self._component_state = 'Stopping'
|
||||
d = maybeDeferred(self.stop)
|
||||
d.addCallback(on_stop)
|
||||
d.addErrback(on_stop_fail)
|
||||
self._component_stopping_deferred = d
|
||||
if hasattr(self, 'stop'):
|
||||
self._component_state = 'Stopping'
|
||||
d = maybeDeferred(self.stop)
|
||||
d.addCallback(on_stop)
|
||||
d.addErrback(on_stop_fail)
|
||||
self._component_stopping_deferred = d
|
||||
else:
|
||||
d = maybeDeferred(on_stop, None)
|
||||
|
||||
if self._component_state == 'Stopping':
|
||||
return self._component_stopping_deferred
|
||||
|
@ -180,12 +186,13 @@ class Component:
|
|||
def _component_pause(self):
|
||||
def on_pause(result):
|
||||
self._component_state = 'Paused'
|
||||
if self._component_timer and self._component_timer.running:
|
||||
self._component_timer.stop()
|
||||
|
||||
if self._component_state == 'Started':
|
||||
d = maybeDeferred(self.pause)
|
||||
d.addCallback(on_pause)
|
||||
if self._component_timer and self._component_timer.running:
|
||||
d = maybeDeferred(self._component_timer.stop)
|
||||
d.addCallback(on_pause)
|
||||
else:
|
||||
d = succeed(None)
|
||||
elif self._component_state == 'Paused':
|
||||
d = succeed(None)
|
||||
else:
|
||||
|
@ -202,10 +209,9 @@ class Component:
|
|||
def _component_resume(self):
|
||||
def on_resume(result):
|
||||
self._component_state = 'Started'
|
||||
self._component_start_timer()
|
||||
|
||||
if self._component_state == 'Paused':
|
||||
d = maybeDeferred(self.resume)
|
||||
d = maybeDeferred(self._component_start_timer)
|
||||
d.addCallback(on_resume)
|
||||
else:
|
||||
d = fail(
|
||||
|
@ -220,7 +226,9 @@ class Component:
|
|||
|
||||
def _component_shutdown(self):
|
||||
def on_stop(result):
|
||||
return maybeDeferred(self.shutdown)
|
||||
if hasattr(self, 'shutdown'):
|
||||
return maybeDeferred(self.shutdown)
|
||||
return succeed(None)
|
||||
|
||||
d = self._component_stop()
|
||||
d.addCallback(on_stop)
|
||||
|
@ -241,14 +249,8 @@ class Component:
|
|||
def shutdown(self):
|
||||
pass
|
||||
|
||||
def pause(self):
|
||||
pass
|
||||
|
||||
def resume(self):
|
||||
pass
|
||||
|
||||
|
||||
class ComponentRegistry:
|
||||
class ComponentRegistry(object):
|
||||
"""The ComponentRegistry holds a list of currently registered :class:`Component` objects.
|
||||
|
||||
It is used to manage the Components by starting, stopping, pausing and shutting them down.
|
||||
|
@ -291,8 +293,7 @@ class ComponentRegistry:
|
|||
obj (Component): a component object to deregister
|
||||
|
||||
Returns:
|
||||
Deferred: a deferred object that will fire once the Component has been
|
||||
successfully deregistered
|
||||
Deferred: a deferred object that will fire once the Component has been sucessfully deregistered
|
||||
|
||||
"""
|
||||
if obj in self.components.values():
|
||||
|
@ -323,7 +324,7 @@ class ComponentRegistry:
|
|||
# Start all the components if names is empty
|
||||
if not names:
|
||||
names = list(self.components)
|
||||
elif isinstance(names, str):
|
||||
elif isinstance(names, string_types):
|
||||
names = [names]
|
||||
|
||||
def on_depends_started(result, name):
|
||||
|
@ -357,7 +358,7 @@ class ComponentRegistry:
|
|||
"""
|
||||
if not names:
|
||||
names = list(self.components)
|
||||
elif isinstance(names, str):
|
||||
elif isinstance(names, string_types):
|
||||
names = [names]
|
||||
|
||||
def on_dependents_stopped(result, name):
|
||||
|
@ -397,7 +398,7 @@ class ComponentRegistry:
|
|||
"""
|
||||
if not names:
|
||||
names = list(self.components)
|
||||
elif isinstance(names, str):
|
||||
elif isinstance(names, string_types):
|
||||
names = [names]
|
||||
|
||||
deferreds = []
|
||||
|
@ -423,7 +424,7 @@ class ComponentRegistry:
|
|||
"""
|
||||
if not names:
|
||||
names = list(self.components)
|
||||
elif isinstance(names, str):
|
||||
elif isinstance(names, string_types):
|
||||
names = [names]
|
||||
|
||||
deferreds = []
|
||||
|
@ -447,7 +448,7 @@ class ComponentRegistry:
|
|||
|
||||
def on_stopped(result):
|
||||
return DeferredList(
|
||||
[comp._component_shutdown() for comp in list(self.components.values())]
|
||||
[comp._component_shutdown() for comp in self.components.values()]
|
||||
)
|
||||
|
||||
return self.stop(list(self.components)).addCallback(on_stopped)
|
||||
|
|
218
deluge/config.py
218
deluge/config.py
|
@ -1,3 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2008 Andrew Resch <andrewresch@gmail.com>
|
||||
#
|
||||
|
@ -38,67 +39,78 @@ this can only be done for the 'config file version' and not for the 'format'
|
|||
version as this will be done internally.
|
||||
|
||||
"""
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
import shutil
|
||||
from codecs import getwriter
|
||||
from io import open
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
import six.moves.cPickle as pickle # noqa: N813
|
||||
|
||||
from deluge.common import JSON_FORMAT, get_default_config_dir
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
callLater = None # noqa: N816 Necessary for the config tests
|
||||
|
||||
|
||||
def find_json_objects(text, decoder=json.JSONDecoder()):
|
||||
"""Find json objects in text.
|
||||
def prop(func):
|
||||
"""Function decorator for defining property attributes
|
||||
|
||||
The decorated function is expected to return a dictionary
|
||||
containing one or more of the following pairs:
|
||||
|
||||
fget - function for getting attribute value
|
||||
fset - function for setting attribute value
|
||||
fdel - function for deleting attribute
|
||||
|
||||
This can be conveniently constructed by the locals() builtin
|
||||
function; see:
|
||||
http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/205183
|
||||
"""
|
||||
return property(doc=func.__doc__, **func())
|
||||
|
||||
|
||||
def find_json_objects(s):
|
||||
"""Find json objects in a string.
|
||||
|
||||
Args:
|
||||
text (str): The text to find json objects within.
|
||||
s (str): the string to find json objects in
|
||||
|
||||
Returns:
|
||||
list: A list of tuples containing start and end locations of json
|
||||
objects in the text. e.g. [(start, end), ...]
|
||||
|
||||
objects in string `s`. e.g. [(start, end), ...]
|
||||
|
||||
"""
|
||||
objects = []
|
||||
offset = 0
|
||||
while True:
|
||||
try:
|
||||
start = text.index('{', offset)
|
||||
except ValueError:
|
||||
break
|
||||
opens = 0
|
||||
start = s.find('{')
|
||||
offset = start
|
||||
|
||||
try:
|
||||
__, index = decoder.raw_decode(text[start:])
|
||||
except json.decoder.JSONDecodeError:
|
||||
offset = start + 1
|
||||
else:
|
||||
offset = start + index
|
||||
objects.append((start, offset))
|
||||
if start < 0:
|
||||
return []
|
||||
|
||||
quoted = False
|
||||
for index, c in enumerate(s[offset:]):
|
||||
if c == '"':
|
||||
quoted = not quoted
|
||||
elif quoted:
|
||||
continue
|
||||
elif c == '{':
|
||||
opens += 1
|
||||
elif c == '}':
|
||||
opens -= 1
|
||||
if opens == 0:
|
||||
objects.append((start, index + offset + 1))
|
||||
start = index + offset + 1
|
||||
|
||||
return objects
|
||||
|
||||
|
||||
def cast_to_existing_type(value, old_value):
|
||||
"""Attempt to convert new value type to match old value type"""
|
||||
types_match = isinstance(old_value, (type(None), type(value)))
|
||||
if value is not None and not types_match:
|
||||
old_type = type(old_value)
|
||||
# Skip convert to bytes since requires knowledge of encoding and value should
|
||||
# be unicode anyway.
|
||||
if old_type is bytes:
|
||||
return value
|
||||
|
||||
return old_type(value)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class Config:
|
||||
class Config(object):
|
||||
"""This class is used to access/create/modify config files.
|
||||
|
||||
Args:
|
||||
|
@ -108,23 +120,13 @@ class Config:
|
|||
file_version (int): The file format for the default config values when creating
|
||||
a fresh config. This value should be increased whenever a new migration function is
|
||||
setup to convert old config files. (default: 1)
|
||||
log_mask_funcs (dict): A dict of key:function, used to mask sensitive
|
||||
key values (e.g. passwords) when logging is enabled.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
filename,
|
||||
defaults=None,
|
||||
config_dir=None,
|
||||
file_version=1,
|
||||
log_mask_funcs=None,
|
||||
):
|
||||
def __init__(self, filename, defaults=None, config_dir=None, file_version=1):
|
||||
self.__config = {}
|
||||
self.__set_functions = {}
|
||||
self.__change_callbacks = []
|
||||
self.__log_mask_funcs = log_mask_funcs if log_mask_funcs else {}
|
||||
|
||||
# These hold the version numbers and they will be set when loaded
|
||||
self.__version = {'format': 1, 'file': file_version}
|
||||
|
@ -135,7 +137,7 @@ class Config:
|
|||
|
||||
if defaults:
|
||||
for key, value in defaults.items():
|
||||
self.set_item(key, value, default=True)
|
||||
self.set_item(key, value)
|
||||
|
||||
# Load the config from file in the config_dir
|
||||
if config_dir:
|
||||
|
@ -145,12 +147,6 @@ class Config:
|
|||
|
||||
self.load()
|
||||
|
||||
def callLater(self, period, func, *args, **kwargs): # noqa: N802 ignore camelCase
|
||||
"""Wrapper around reactor.callLater for test purpose."""
|
||||
from twisted.internet import reactor
|
||||
|
||||
return reactor.callLater(period, func, *args, **kwargs)
|
||||
|
||||
def __contains__(self, item):
|
||||
return item in self.__config
|
||||
|
||||
|
@ -159,7 +155,7 @@ class Config:
|
|||
|
||||
return self.set_item(key, value)
|
||||
|
||||
def set_item(self, key, value, default=False):
|
||||
def set_item(self, key, value):
|
||||
"""Sets item 'key' to 'value' in the config dictionary.
|
||||
|
||||
Does not allow changing the item's type unless it is None.
|
||||
|
@ -171,8 +167,6 @@ class Config:
|
|||
key (str): Item to change to change.
|
||||
value (any): The value to change item to, must be same type as what is
|
||||
currently in the config.
|
||||
default (optional, bool): When setting a default value skip func or save
|
||||
callbacks.
|
||||
|
||||
Raises:
|
||||
ValueError: Raised when the type of value is not the same as what is
|
||||
|
@ -185,54 +179,61 @@ class Config:
|
|||
5
|
||||
|
||||
"""
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode()
|
||||
if key not in self.__config:
|
||||
self.__config[key] = value
|
||||
log.debug('Setting key "%s" to: %s (of type: %s)', key, value, type(value))
|
||||
return
|
||||
|
||||
if key in self.__config:
|
||||
if self.__config[key] == value:
|
||||
return
|
||||
|
||||
# Change the value type if it is not None and does not match.
|
||||
type_match = isinstance(self.__config[key], (type(None), type(value)))
|
||||
if value is not None and not type_match:
|
||||
try:
|
||||
value = cast_to_existing_type(value, self.__config[key])
|
||||
oldtype = type(self.__config[key])
|
||||
# Don't convert to bytes as requires encoding and value will
|
||||
# be decoded anyway.
|
||||
if oldtype is not bytes:
|
||||
value = oldtype(value)
|
||||
except ValueError:
|
||||
log.warning('Value Type "%s" invalid for key: %s', type(value), key)
|
||||
raise
|
||||
else:
|
||||
if self.__config[key] == value:
|
||||
return
|
||||
|
||||
if log.isEnabledFor(logging.DEBUG):
|
||||
if key in self.__log_mask_funcs:
|
||||
value = self.__log_mask_funcs[key](value)
|
||||
log.debug(
|
||||
'Setting key "%s" to: %s (of type: %s)',
|
||||
key,
|
||||
value,
|
||||
type(value),
|
||||
)
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode('utf8')
|
||||
|
||||
log.debug('Setting key "%s" to: %s (of type: %s)', key, value, type(value))
|
||||
self.__config[key] = value
|
||||
|
||||
# Skip save or func callbacks if setting default value for keys
|
||||
if default:
|
||||
return
|
||||
|
||||
global callLater
|
||||
if callLater is None:
|
||||
# Must import here and not at the top or it will throw ReactorAlreadyInstalledError
|
||||
from twisted.internet.reactor import (
|
||||
callLater,
|
||||
) # pylint: disable=redefined-outer-name
|
||||
# Run the set_function for this key if any
|
||||
for func in self.__set_functions.get(key, []):
|
||||
self.callLater(0, func, key, value)
|
||||
|
||||
try:
|
||||
for func in self.__set_functions[key]:
|
||||
callLater(0, func, key, value)
|
||||
except KeyError:
|
||||
pass
|
||||
try:
|
||||
|
||||
def do_change_callbacks(key, value):
|
||||
for func in self.__change_callbacks:
|
||||
func(key, value)
|
||||
|
||||
self.callLater(0, do_change_callbacks, key, value)
|
||||
callLater(0, do_change_callbacks, key, value)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# We set the save_timer for 5 seconds if not already set
|
||||
if not self._save_timer or not self._save_timer.active():
|
||||
self._save_timer = self.callLater(5, self.save)
|
||||
self._save_timer = callLater(5, self.save)
|
||||
|
||||
def __getitem__(self, key):
|
||||
"""See get_item"""
|
||||
"""See get_item """
|
||||
return self.get_item(key)
|
||||
|
||||
def get_item(self, key):
|
||||
|
@ -305,9 +306,16 @@ class Config:
|
|||
|
||||
del self.__config[key]
|
||||
|
||||
global callLater
|
||||
if callLater is None:
|
||||
# Must import here and not at the top or it will throw ReactorAlreadyInstalledError
|
||||
from twisted.internet.reactor import (
|
||||
callLater,
|
||||
) # pylint: disable=redefined-outer-name
|
||||
|
||||
# We set the save_timer for 5 seconds if not already set
|
||||
if not self._save_timer or not self._save_timer.active():
|
||||
self._save_timer = self.callLater(5, self.save)
|
||||
self._save_timer = callLater(5, self.save)
|
||||
|
||||
def register_change_callback(self, callback):
|
||||
"""Registers a callback function for any changed value.
|
||||
|
@ -353,6 +361,7 @@ class Config:
|
|||
# Run the function now if apply_now is set
|
||||
if apply_now:
|
||||
function(key, self.__config[key])
|
||||
return
|
||||
|
||||
def apply_all(self):
|
||||
"""Calls all set functions.
|
||||
|
@ -395,9 +404,9 @@ class Config:
|
|||
filename = self.__config_file
|
||||
|
||||
try:
|
||||
with open(filename, encoding='utf8') as _file:
|
||||
with open(filename, 'r', encoding='utf8') as _file:
|
||||
data = _file.read()
|
||||
except OSError as ex:
|
||||
except IOError as ex:
|
||||
log.warning('Unable to open config file %s: %s', filename, ex)
|
||||
return
|
||||
|
||||
|
@ -427,24 +436,12 @@ class Config:
|
|||
log.exception(ex)
|
||||
log.warning('Unable to load config file: %s', filename)
|
||||
|
||||
if not log.isEnabledFor(logging.DEBUG):
|
||||
return
|
||||
|
||||
config = self.__config
|
||||
if self.__log_mask_funcs:
|
||||
config = {
|
||||
key: self.__log_mask_funcs[key](config[key])
|
||||
if key in self.__log_mask_funcs
|
||||
else config[key]
|
||||
for key in config
|
||||
}
|
||||
|
||||
log.debug(
|
||||
'Config %s version: %s.%s loaded: %s',
|
||||
filename,
|
||||
self.__version['format'],
|
||||
self.__version['file'],
|
||||
config,
|
||||
self.__config,
|
||||
)
|
||||
|
||||
def save(self, filename=None):
|
||||
|
@ -462,7 +459,7 @@ class Config:
|
|||
# Check to see if the current config differs from the one on disk
|
||||
# We will only write a new config file if there is a difference
|
||||
try:
|
||||
with open(filename, encoding='utf8') as _file:
|
||||
with open(filename, 'r', encoding='utf8') as _file:
|
||||
data = _file.read()
|
||||
objects = find_json_objects(data)
|
||||
start, end = objects[0]
|
||||
|
@ -474,7 +471,7 @@ class Config:
|
|||
if self._save_timer and self._save_timer.active():
|
||||
self._save_timer.cancel()
|
||||
return True
|
||||
except (OSError, IndexError) as ex:
|
||||
except (IOError, IndexError) as ex:
|
||||
log.warning('Unable to open config file: %s because: %s', filename, ex)
|
||||
|
||||
# Save the new config and make sure it's written to disk
|
||||
|
@ -488,7 +485,7 @@ class Config:
|
|||
json.dump(self.__config, getwriter('utf8')(_file), **JSON_FORMAT)
|
||||
_file.flush()
|
||||
os.fsync(_file.fileno())
|
||||
except OSError as ex:
|
||||
except IOError as ex:
|
||||
log.error('Error writing new config file: %s', ex)
|
||||
return False
|
||||
|
||||
|
@ -499,7 +496,7 @@ class Config:
|
|||
try:
|
||||
log.debug('Backing up old config file to %s.bak', filename)
|
||||
shutil.move(filename, filename + '.bak')
|
||||
except OSError as ex:
|
||||
except IOError as ex:
|
||||
log.warning('Unable to backup old config: %s', ex)
|
||||
|
||||
# The new config file has been written successfully, so let's move it over
|
||||
|
@ -507,7 +504,7 @@ class Config:
|
|||
try:
|
||||
log.debug('Moving new config file %s to %s', filename_tmp, filename)
|
||||
shutil.move(filename_tmp, filename)
|
||||
except OSError as ex:
|
||||
except IOError as ex:
|
||||
log.error('Error moving new config file: %s', ex)
|
||||
return False
|
||||
else:
|
||||
|
@ -559,11 +556,14 @@ class Config:
|
|||
def config_file(self):
|
||||
return self.__config_file
|
||||
|
||||
@property
|
||||
def config(self):
|
||||
@prop
|
||||
def config(): # pylint: disable=no-method-argument
|
||||
"""The config dictionary"""
|
||||
return self.__config
|
||||
|
||||
@config.deleter
|
||||
def config(self):
|
||||
return self.save()
|
||||
def fget(self):
|
||||
return self.__config
|
||||
|
||||
def fdel(self):
|
||||
return self.save()
|
||||
|
||||
return locals()
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2007 Andrew Resch <andrewresch@gmail.com>
|
||||
#
|
||||
|
@ -6,6 +7,8 @@
|
|||
# See LICENSE for more details.
|
||||
#
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
|
@ -16,7 +19,7 @@ from deluge.config import Config
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _ConfigManager:
|
||||
class _ConfigManager(object):
|
||||
def __init__(self):
|
||||
log.debug('ConfigManager started..')
|
||||
self.config_files = {}
|
||||
|
|
|
@ -1,215 +0,0 @@
|
|||
#
|
||||
# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with
|
||||
# the additional special exception to link portions of this program with the OpenSSL library.
|
||||
# See LICENSE for more details.
|
||||
#
|
||||
import asyncio
|
||||
import tempfile
|
||||
import warnings
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_twisted
|
||||
from twisted.internet import reactor
|
||||
from twisted.internet.defer import Deferred, maybeDeferred
|
||||
from twisted.internet.error import CannotListenError, ProcessTerminated
|
||||
from twisted.python.failure import Failure
|
||||
|
||||
import deluge.component as _component
|
||||
import deluge.configmanager
|
||||
from deluge.common import get_localhost_auth
|
||||
from deluge.tests import common
|
||||
from deluge.ui.client import client as _client
|
||||
|
||||
DEFAULT_LISTEN_PORT = 58900
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def listen_port(request):
|
||||
if request and 'daemon' in request.fixturenames:
|
||||
try:
|
||||
return request.getfixturevalue('daemon').listen_port
|
||||
except Exception:
|
||||
pass
|
||||
return DEFAULT_LISTEN_PORT
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_callback():
|
||||
"""Returns a `Mock` object which can be registered as a callback to test against.
|
||||
|
||||
If callback was not called within `timeout` seconds, it will raise a TimeoutError.
|
||||
The returned Mock instance will have a `deferred` attribute which will complete when the callback has been called.
|
||||
"""
|
||||
|
||||
def reset(timeout=0.5, *args, **kwargs):
|
||||
if mock.called:
|
||||
original_reset_mock(*args, **kwargs)
|
||||
if mock.deferred:
|
||||
mock.deferred.cancel()
|
||||
deferred = Deferred(canceller=lambda x: deferred.callback(None))
|
||||
deferred.addTimeout(timeout, reactor)
|
||||
mock.side_effect = lambda *args, **kw: deferred.callback((args, kw))
|
||||
mock.deferred = deferred
|
||||
|
||||
mock = Mock()
|
||||
mock.__qualname__ = 'mock'
|
||||
original_reset_mock = mock.reset_mock
|
||||
mock.reset_mock = reset
|
||||
mock.reset_mock()
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_dir(tmp_path):
|
||||
config_dir = tmp_path / 'config'
|
||||
deluge.configmanager.set_config_dir(config_dir)
|
||||
yield config_dir
|
||||
|
||||
|
||||
@pytest_twisted.async_yield_fixture()
|
||||
async def client(request, config_dir, monkeypatch, listen_port):
|
||||
# monkeypatch.setattr(
|
||||
# _client, 'connect', functools.partial(_client.connect, port=listen_port)
|
||||
# )
|
||||
try:
|
||||
username, password = get_localhost_auth()
|
||||
except Exception:
|
||||
username, password = '', ''
|
||||
await _client.connect(
|
||||
'localhost',
|
||||
port=listen_port,
|
||||
username=username,
|
||||
password=password,
|
||||
)
|
||||
yield _client
|
||||
if _client.connected():
|
||||
await _client.disconnect()
|
||||
|
||||
|
||||
@pytest_twisted.async_yield_fixture
|
||||
async def daemon(request, config_dir, tmp_path):
|
||||
listen_port = DEFAULT_LISTEN_PORT
|
||||
logfile = tmp_path / 'daemon.log'
|
||||
|
||||
if hasattr(request.cls, 'daemon_custom_script'):
|
||||
custom_script = request.cls.daemon_custom_script
|
||||
else:
|
||||
custom_script = ''
|
||||
|
||||
for dummy in range(10):
|
||||
try:
|
||||
d, daemon = common.start_core(
|
||||
listen_port=listen_port,
|
||||
logfile=logfile,
|
||||
timeout=5,
|
||||
timeout_msg='Timeout!',
|
||||
custom_script=custom_script,
|
||||
print_stdout=True,
|
||||
print_stderr=True,
|
||||
config_directory=config_dir,
|
||||
)
|
||||
await d
|
||||
except CannotListenError as ex:
|
||||
exception_error = ex
|
||||
listen_port += 1
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
raise
|
||||
else:
|
||||
break
|
||||
else:
|
||||
raise exception_error
|
||||
daemon.listen_port = listen_port
|
||||
yield daemon
|
||||
try:
|
||||
await daemon.kill()
|
||||
except ProcessTerminated:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def common_fixture(config_dir, request, monkeypatch, listen_port):
|
||||
"""Adds some instance attributes to test classes for backwards compatibility with old testing."""
|
||||
|
||||
def fail(self, reason):
|
||||
if isinstance(reason, Failure):
|
||||
reason = reason.value
|
||||
return pytest.fail(str(reason))
|
||||
|
||||
if request.instance:
|
||||
request.instance.patch = monkeypatch.setattr
|
||||
request.instance.config_dir = config_dir
|
||||
request.instance.listen_port = listen_port
|
||||
request.instance.id = lambda: request.node.name
|
||||
request.cls.fail = fail
|
||||
|
||||
|
||||
@pytest_twisted.async_yield_fixture(scope='function')
|
||||
async def component():
|
||||
"""Verify component registry is clean, and clean up after test."""
|
||||
if len(_component._ComponentRegistry.components) != 0:
|
||||
warnings.warn(
|
||||
'The component._ComponentRegistry.components is not empty on test setup.\n'
|
||||
'This is probably caused by another test that did not clean up after finishing!: %s'
|
||||
% _component._ComponentRegistry.components
|
||||
)
|
||||
|
||||
yield _component
|
||||
|
||||
await _component.shutdown()
|
||||
_component._ComponentRegistry.components.clear()
|
||||
_component._ComponentRegistry.dependents.clear()
|
||||
|
||||
|
||||
@pytest_twisted.async_yield_fixture(scope='function')
|
||||
async def base_fixture(common_fixture, component, request):
|
||||
"""This fixture is autoused on all tests that subclass BaseTestCase"""
|
||||
self = request.instance
|
||||
|
||||
if hasattr(self, 'set_up'):
|
||||
try:
|
||||
await maybeDeferred(self.set_up)
|
||||
except Exception as exc:
|
||||
warnings.warn('Error caught in test setup!\n%s' % exc)
|
||||
pytest.fail('Error caught in test setup!\n%s' % exc)
|
||||
|
||||
yield
|
||||
|
||||
if hasattr(self, 'tear_down'):
|
||||
try:
|
||||
await maybeDeferred(self.tear_down)
|
||||
except Exception as exc:
|
||||
pytest.fail('Error caught in test teardown!\n%s' % exc)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('base_fixture')
|
||||
class BaseTestCase:
|
||||
"""This is the base class that should be used for all test classes
|
||||
that create classes that inherit from deluge.component.Component. It
|
||||
ensures that the component registry has been cleaned up when tests
|
||||
have finished.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mkstemp(tmp_path):
|
||||
"""Return known tempfile location to verify file deleted"""
|
||||
tmp_file = tempfile.mkstemp(dir=tmp_path)
|
||||
with patch('tempfile.mkstemp', return_value=tmp_file):
|
||||
yield tmp_file
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(session, config, items) -> None:
|
||||
"""
|
||||
Automatically runs async tests with pytest_twisted.ensureDeferred
|
||||
"""
|
||||
function_items = (item for item in items if isinstance(item, pytest.Function))
|
||||
for function_item in function_items:
|
||||
function = function_item.obj
|
||||
if hasattr(function, '__func__'):
|
||||
# methods need to be unwrapped.
|
||||
function = function.__func__
|
||||
if asyncio.iscoroutinefunction(function):
|
||||
# This is how pytest_twisted marks ensureDeferred tests
|
||||
setattr(function, '_pytest_twisted_mark', 'async_test')
|
|
@ -1,3 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2007-2009 Andrew Resch <andrewresch@gmail.com>
|
||||
#
|
||||
|
@ -14,16 +15,12 @@ This should typically only be used by the Core. Plugins should utilize the
|
|||
`:mod:EventManager` for similar functionality.
|
||||
|
||||
"""
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from functools import partial
|
||||
from typing import Any, Callable
|
||||
import types
|
||||
|
||||
from twisted.internet import reactor, task, threads
|
||||
from twisted.internet import reactor
|
||||
|
||||
import deluge.component as component
|
||||
from deluge._libtorrent import lt
|
||||
|
@ -37,7 +34,7 @@ class AlertManager(component.Component):
|
|||
|
||||
def __init__(self):
|
||||
log.debug('AlertManager init...')
|
||||
component.Component.__init__(self, 'AlertManager')
|
||||
component.Component.__init__(self, 'AlertManager', interval=0.3)
|
||||
self.session = component.get('Core').session
|
||||
|
||||
# Increase the alert queue size so that alerts don't get lost.
|
||||
|
@ -52,94 +49,53 @@ class AlertManager(component.Component):
|
|||
| lt.alert.category_t.status_notification
|
||||
| lt.alert.category_t.ip_block_notification
|
||||
| lt.alert.category_t.performance_warning
|
||||
| lt.alert.category_t.file_progress_notification
|
||||
)
|
||||
|
||||
self.session.apply_settings({'alert_mask': alert_mask})
|
||||
|
||||
# handlers is a dictionary of lists {"alert_type": [handler1,h2,..]}
|
||||
self.handlers = defaultdict(list)
|
||||
self.handlers_timeout_secs = 2
|
||||
self.handlers = {}
|
||||
self.delayed_calls = []
|
||||
self._event = threading.Event()
|
||||
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
def start(self):
|
||||
thread = threading.Thread(
|
||||
target=self.wait_for_alert_in_thread, name='alert-poller', daemon=True
|
||||
)
|
||||
thread.start()
|
||||
self._event.set()
|
||||
|
||||
def stop(self):
|
||||
self.cancel_delayed_calls()
|
||||
|
||||
def pause(self):
|
||||
self._event.clear()
|
||||
|
||||
def resume(self):
|
||||
self._event.set()
|
||||
|
||||
def wait_for_alert_in_thread(self):
|
||||
while self._component_state not in ('Stopping', 'Stopped'):
|
||||
if self.check_delayed_calls():
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
|
||||
if self.session.wait_for_alert(1000) is None:
|
||||
continue
|
||||
if self._event.wait():
|
||||
threads.blockingCallFromThread(reactor, self.maybe_handle_alerts)
|
||||
|
||||
def on_delayed_call_timeout(self, result, timeout, **kwargs):
|
||||
log.warning('Alert handler was timed-out before being called %s', kwargs)
|
||||
|
||||
def cancel_delayed_calls(self):
|
||||
"""Cancel all delayed handlers."""
|
||||
for delayed_call in self.delayed_calls:
|
||||
delayed_call.cancel()
|
||||
self.delayed_calls = []
|
||||
|
||||
def check_delayed_calls(self) -> bool:
|
||||
"""Returns True if any handler calls are delayed."""
|
||||
self.delayed_calls = [dc for dc in self.delayed_calls if not dc.called]
|
||||
return len(self.delayed_calls) > 0
|
||||
|
||||
def maybe_handle_alerts(self) -> None:
|
||||
if self._component_state != 'Started':
|
||||
return
|
||||
|
||||
self.delayed_calls = [dc for dc in self.delayed_calls if dc.active()]
|
||||
self.handle_alerts()
|
||||
|
||||
def register_handler(self, alert_type: str, handler: Callable[[Any], None]) -> None:
|
||||
def stop(self):
|
||||
for delayed_call in self.delayed_calls:
|
||||
if delayed_call.active():
|
||||
delayed_call.cancel()
|
||||
self.delayed_calls = []
|
||||
|
||||
def register_handler(self, alert_type, handler):
|
||||
"""
|
||||
Registers a function that will be called when 'alert_type' is pop'd
|
||||
in handle_alerts. The handler function should look like: handler(alert)
|
||||
Where 'alert' is the actual alert object from libtorrent.
|
||||
|
||||
Args:
|
||||
alert_type: String representation of the libtorrent alert name.
|
||||
Can be supplied with or without `_alert` suffix.
|
||||
handler: Callback function when the alert is raised.
|
||||
:param alert_type: str, this is string representation of the alert name
|
||||
:param handler: func(alert), the function to be called when the alert is raised
|
||||
"""
|
||||
if alert_type and alert_type.endswith('_alert'):
|
||||
alert_type = alert_type[: -len('_alert')]
|
||||
if alert_type not in self.handlers:
|
||||
# There is no entry for this alert type yet, so lets make it with an
|
||||
# empty list.
|
||||
self.handlers[alert_type] = []
|
||||
|
||||
# Append the handler to the list in the handlers dictionary
|
||||
self.handlers[alert_type].append(handler)
|
||||
log.debug('Registered handler for alert %s', alert_type)
|
||||
|
||||
def deregister_handler(self, handler: Callable[[Any], None]):
|
||||
def deregister_handler(self, handler):
|
||||
"""
|
||||
De-registers the `handler` function from all alert types.
|
||||
De-registers the `:param:handler` function from all alert types.
|
||||
|
||||
Args:
|
||||
handler: The handler function to deregister.
|
||||
:param handler: func, the handler function to deregister
|
||||
"""
|
||||
for alert_type_handlers in self.handlers.values():
|
||||
with contextlib.suppress(ValueError):
|
||||
alert_type_handlers.remove(handler)
|
||||
# Iterate through all handlers and remove 'handler' where found
|
||||
for (dummy_key, value) in self.handlers.items():
|
||||
if handler in value:
|
||||
# Handler is in this alert type list
|
||||
value.remove(handler)
|
||||
|
||||
def handle_alerts(self):
|
||||
"""
|
||||
|
@ -158,32 +114,26 @@ class AlertManager(component.Component):
|
|||
num_alerts,
|
||||
)
|
||||
|
||||
# Loop through all alerts in the queue
|
||||
for alert in alerts:
|
||||
alert_type = alert.what()
|
||||
|
||||
alert_type = type(alert).__name__
|
||||
# Display the alert message
|
||||
if log.isEnabledFor(logging.DEBUG):
|
||||
log.debug('%s: %s', alert_type, decode_bytes(alert.message()))
|
||||
|
||||
if alert_type not in self.handlers:
|
||||
continue
|
||||
|
||||
# Call any handlers for this alert type
|
||||
for handler in self.handlers[alert_type]:
|
||||
if log.isEnabledFor(logging.DEBUG):
|
||||
log.debug('Handling alert: %s', alert_type)
|
||||
d = task.deferLater(reactor, 0, handler, alert)
|
||||
on_handler_timeout = partial(
|
||||
self.on_delayed_call_timeout,
|
||||
handler=handler.__qualname__,
|
||||
alert_type=alert_type,
|
||||
)
|
||||
d.addTimeout(
|
||||
self.handlers_timeout_secs,
|
||||
reactor,
|
||||
onTimeoutCancel=on_handler_timeout,
|
||||
)
|
||||
self.delayed_calls.append(d)
|
||||
if alert_type in self.handlers:
|
||||
for handler in self.handlers[alert_type]:
|
||||
if log.isEnabledFor(logging.DEBUG):
|
||||
log.debug('Handling alert: %s', alert_type)
|
||||
# Copy alert attributes
|
||||
alert_copy = types.SimpleNamespace(
|
||||
**{
|
||||
attr: getattr(alert, attr)
|
||||
for attr in dir(alert)
|
||||
if not attr.startswith('__')
|
||||
}
|
||||
)
|
||||
self.delayed_calls.append(reactor.callLater(0, handler, alert_copy))
|
||||
|
||||
def set_alert_queue_size(self, queue_size):
|
||||
"""Sets the maximum size of the libtorrent alert queue"""
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2009 Andrew Resch <andrewresch@gmail.com>
|
||||
# Copyright (C) 2011 Pedro Algarvio <pedro@algarvio.me>
|
||||
|
@ -7,9 +8,12 @@
|
|||
# See LICENSE for more details.
|
||||
#
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from io import open
|
||||
|
||||
import deluge.component as component
|
||||
import deluge.configmanager as configmanager
|
||||
|
@ -28,14 +32,14 @@ log = logging.getLogger(__name__)
|
|||
AUTH_LEVELS_MAPPING = {
|
||||
'NONE': AUTH_LEVEL_NONE,
|
||||
'READONLY': AUTH_LEVEL_READONLY,
|
||||
'DEFAULT': AUTH_LEVEL_DEFAULT,
|
||||
'NORMAL': AUTH_LEVEL_NORMAL,
|
||||
'DEFAULT': AUTH_LEVEL_NORMAL,
|
||||
'NORMAL': AUTH_LEVEL_DEFAULT,
|
||||
'ADMIN': AUTH_LEVEL_ADMIN,
|
||||
}
|
||||
AUTH_LEVELS_MAPPING_REVERSE = {v: k for k, v in AUTH_LEVELS_MAPPING.items()}
|
||||
|
||||
|
||||
class Account:
|
||||
class Account(object):
|
||||
__slots__ = ('username', 'password', 'authlevel')
|
||||
|
||||
def __init__(self, username, password, authlevel):
|
||||
|
@ -52,10 +56,10 @@ class Account:
|
|||
}
|
||||
|
||||
def __repr__(self):
|
||||
return '<Account username="{username}" authlevel={authlevel}>'.format(
|
||||
username=self.username,
|
||||
authlevel=self.authlevel,
|
||||
)
|
||||
return '<Account username="%(username)s" authlevel=%(authlevel)s>' % {
|
||||
'username': self.username,
|
||||
'authlevel': self.authlevel,
|
||||
}
|
||||
|
||||
|
||||
class AuthManager(component.Component):
|
||||
|
@ -97,7 +101,7 @@ class AuthManager(component.Component):
|
|||
int: The auth level for this user.
|
||||
|
||||
Raises:
|
||||
AuthenticationRequired: If additional details are required to authenticate.
|
||||
AuthenticationRequired: If aditional details are required to authenticate.
|
||||
BadLoginError: If the username does not exist or password does not match.
|
||||
|
||||
"""
|
||||
|
@ -180,7 +184,7 @@ class AuthManager(component.Component):
|
|||
if os.path.isfile(filepath):
|
||||
log.debug('Creating backup of %s at: %s', filename, filepath_bak)
|
||||
shutil.copy2(filepath, filepath_bak)
|
||||
except OSError as ex:
|
||||
except IOError as ex:
|
||||
log.error('Unable to backup %s to %s: %s', filepath, filepath_bak, ex)
|
||||
else:
|
||||
log.info('Saving the %s at: %s', filename, filepath)
|
||||
|
@ -194,7 +198,7 @@ class AuthManager(component.Component):
|
|||
_file.flush()
|
||||
os.fsync(_file.fileno())
|
||||
shutil.move(filepath_tmp, filepath)
|
||||
except OSError as ex:
|
||||
except IOError as ex:
|
||||
log.error('Unable to save %s: %s', filename, ex)
|
||||
if os.path.isfile(filepath_bak):
|
||||
log.info('Restoring backup of %s from: %s', filename, filepath_bak)
|
||||
|
@ -223,9 +227,9 @@ class AuthManager(component.Component):
|
|||
for _filepath in (auth_file, auth_file_bak):
|
||||
log.info('Opening %s for load: %s', filename, _filepath)
|
||||
try:
|
||||
with open(_filepath, encoding='utf8') as _file:
|
||||
with open(_filepath, 'r', encoding='utf8') as _file:
|
||||
file_data = _file.readlines()
|
||||
except OSError as ex:
|
||||
except IOError as ex:
|
||||
log.warning('Unable to load %s: %s', _filepath, ex)
|
||||
file_data = []
|
||||
else:
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,3 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2007-2009 Andrew Resch <andrewresch@gmail.com>
|
||||
#
|
||||
|
@ -7,6 +8,7 @@
|
|||
#
|
||||
|
||||
"""The Deluge daemon"""
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
@ -42,8 +44,8 @@ def is_daemon_running(pid_file):
|
|||
|
||||
try:
|
||||
with open(pid_file) as _file:
|
||||
pid, port = (int(x) for x in _file.readline().strip().split(';'))
|
||||
except (OSError, ValueError):
|
||||
pid, port = [int(x) for x in _file.readline().strip().split(';')]
|
||||
except (EnvironmentError, ValueError):
|
||||
return False
|
||||
|
||||
if is_process_running(pid):
|
||||
|
@ -51,7 +53,7 @@ def is_daemon_running(pid_file):
|
|||
_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
_socket.connect(('127.0.0.1', port))
|
||||
except OSError:
|
||||
except socket.error:
|
||||
# Can't connect, so pid is not a deluged process.
|
||||
return False
|
||||
else:
|
||||
|
@ -60,7 +62,7 @@ def is_daemon_running(pid_file):
|
|||
return True
|
||||
|
||||
|
||||
class Daemon:
|
||||
class Daemon(object):
|
||||
"""The Deluge Daemon class"""
|
||||
|
||||
def __init__(
|
||||
|
@ -154,7 +156,7 @@ class Daemon:
|
|||
pid = os.getpid()
|
||||
log.debug('Storing pid %s & port %s in: %s', pid, self.port, self.pid_file)
|
||||
with open(self.pid_file, 'w') as _file:
|
||||
_file.write(f'{pid};{self.port}\n')
|
||||
_file.write('%s;%s\n' % (pid, self.port))
|
||||
|
||||
component.start()
|
||||
|
||||
|
@ -198,7 +200,6 @@ class Daemon:
|
|||
if rpc not in self.get_method_list():
|
||||
return False
|
||||
|
||||
return (
|
||||
self.rpcserver.get_session_auth_level()
|
||||
>= self.rpcserver.get_rpc_auth_level(rpc)
|
||||
return self.rpcserver.get_session_auth_level() >= self.rpcserver.get_rpc_auth_level(
|
||||
rpc
|
||||
)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2007 Andrew Resch <andrewresch@gmail.com>
|
||||
# Copyright (C) 2010 Pedro Algarvio <pedro@algarvio.me>
|
||||
|
@ -6,6 +7,8 @@
|
|||
# the additional special exception to link portions of this program with the OpenSSL library.
|
||||
# See LICENSE for more details.
|
||||
#
|
||||
from __future__ import print_function, unicode_literals
|
||||
|
||||
import os
|
||||
import sys
|
||||
from logging import DEBUG, FileHandler, getLogger
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2009 Andrew Resch <andrewresch@gmail.com>
|
||||
#
|
||||
|
@ -6,6 +7,8 @@
|
|||
# See LICENSE for more details.
|
||||
#
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
import deluge.component as component
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2008 Martijn Voncken <mvoncken@gmail.com>
|
||||
#
|
||||
|
@ -6,8 +7,12 @@
|
|||
# See LICENSE for more details.
|
||||
#
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
from six import string_types
|
||||
|
||||
import deluge.component as component
|
||||
from deluge.common import TORRENT_STATE
|
||||
|
||||
|
@ -95,7 +100,9 @@ def tracker_error_filter(torrent_ids, values):
|
|||
|
||||
|
||||
class FilterManager(component.Component):
|
||||
"""FilterManager"""
|
||||
"""FilterManager
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, core):
|
||||
component.Component.__init__(self, 'FilterManager')
|
||||
|
@ -131,7 +138,7 @@ class FilterManager(component.Component):
|
|||
|
||||
# Sanitize input: filter-value must be a list of strings
|
||||
for key, value in filter_dict.items():
|
||||
if isinstance(value, str):
|
||||
if isinstance(value, string_types):
|
||||
filter_dict[key] = [value]
|
||||
|
||||
# Optimized filter for id
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2007 Andrew Resch <andrewresch@gmail.com>
|
||||
#
|
||||
|
@ -8,6 +9,7 @@
|
|||
|
||||
|
||||
"""PluginManager for Core"""
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2008-2010 Andrew Resch <andrewresch@gmail.com>
|
||||
#
|
||||
|
@ -7,13 +8,13 @@
|
|||
#
|
||||
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import random
|
||||
import threading
|
||||
from urllib.parse import quote_plus
|
||||
from urllib.request import urlopen
|
||||
|
||||
from twisted.internet.task import LoopingCall
|
||||
|
||||
|
@ -23,14 +24,17 @@ import deluge.configmanager
|
|||
from deluge._libtorrent import lt
|
||||
from deluge.event import ConfigValueChangedEvent
|
||||
|
||||
GeoIP = None
|
||||
try:
|
||||
from GeoIP import GeoIP
|
||||
import GeoIP
|
||||
except ImportError:
|
||||
try:
|
||||
from pygeoip import GeoIP
|
||||
except ImportError:
|
||||
pass
|
||||
GeoIP = None
|
||||
|
||||
try:
|
||||
from urllib.parse import quote_plus
|
||||
from urllib.request import urlopen
|
||||
except ImportError:
|
||||
from urllib import quote_plus
|
||||
from urllib2 import urlopen
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
@ -198,7 +202,7 @@ class PreferencesManager(component.Component):
|
|||
self.__set_listen_on()
|
||||
|
||||
def __set_listen_on(self):
|
||||
"""Set the ports and interface address to listen for incoming connections on."""
|
||||
""" Set the ports and interface address to listen for incoming connections on."""
|
||||
if self.config['random_port']:
|
||||
if not self.config['listen_random_port']:
|
||||
self.config['listen_random_port'] = random.randrange(49152, 65525)
|
||||
|
@ -221,13 +225,13 @@ class PreferencesManager(component.Component):
|
|||
self.config['listen_use_sys_port'],
|
||||
)
|
||||
interfaces = [
|
||||
f'{interface}:{port}'
|
||||
'%s:%s' % (interface, port)
|
||||
for port in range(listen_ports[0], listen_ports[1] + 1)
|
||||
]
|
||||
self.core.apply_session_settings(
|
||||
{
|
||||
'listen_system_port_fallback': self.config['listen_use_sys_port'],
|
||||
'listen_interfaces': ','.join(interfaces),
|
||||
'listen_interfaces': ''.join(interfaces),
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -396,7 +400,7 @@ class PreferencesManager(component.Component):
|
|||
+ quote_plus(':'.join(self.config['enabled_plugins']))
|
||||
)
|
||||
urlopen(url)
|
||||
except OSError as ex:
|
||||
except IOError as ex:
|
||||
log.debug('Network error while trying to send info: %s', ex)
|
||||
else:
|
||||
self.config['info_sent'] = now
|
||||
|
@ -460,9 +464,11 @@ class PreferencesManager(component.Component):
|
|||
# Load the GeoIP DB for country look-ups if available
|
||||
if os.path.exists(geoipdb_path):
|
||||
try:
|
||||
self.core.geoip_instance = GeoIP(geoipdb_path, 0)
|
||||
except Exception as ex:
|
||||
log.warning('GeoIP Unavailable: %s', ex)
|
||||
self.core.geoip_instance = GeoIP.open(
|
||||
geoipdb_path, GeoIP.GEOIP_STANDARD
|
||||
)
|
||||
except AttributeError:
|
||||
log.warning('GeoIP Unavailable')
|
||||
else:
|
||||
log.warning('Unable to find GeoIP database file: %s', geoipdb_path)
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2008,2009 Andrew Resch <andrewresch@gmail.com>
|
||||
#
|
||||
|
@ -7,15 +8,17 @@
|
|||
#
|
||||
|
||||
"""RPCServer Module"""
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import traceback
|
||||
from collections import namedtuple
|
||||
from types import FunctionType
|
||||
from typing import Callable, TypeVar, overload
|
||||
|
||||
from OpenSSL import crypto
|
||||
from twisted.internet import defer, reactor
|
||||
from twisted.internet.protocol import Factory, connectionDone
|
||||
|
||||
|
@ -26,7 +29,7 @@ from deluge.core.authmanager import (
|
|||
AUTH_LEVEL_DEFAULT,
|
||||
AUTH_LEVEL_NONE,
|
||||
)
|
||||
from deluge.crypto_utils import check_ssl_keys, get_context_factory
|
||||
from deluge.crypto_utils import get_context_factory
|
||||
from deluge.error import (
|
||||
DelugeError,
|
||||
IncompatibleClient,
|
||||
|
@ -43,16 +46,6 @@ RPC_EVENT = 3
|
|||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
TCallable = TypeVar('TCallable', bound=Callable)
|
||||
|
||||
|
||||
@overload
|
||||
def export(func: TCallable) -> TCallable: ...
|
||||
|
||||
|
||||
@overload
|
||||
def export(auth_level: int) -> Callable[[TCallable], TCallable]: ...
|
||||
|
||||
|
||||
def export(auth_level=AUTH_LEVEL_DEFAULT):
|
||||
"""
|
||||
|
@ -76,7 +69,7 @@ def export(auth_level=AUTH_LEVEL_DEFAULT):
|
|||
if func.__doc__:
|
||||
if func.__doc__.endswith(' '):
|
||||
indent = func.__doc__.split('\n')[-1]
|
||||
func.__doc__ += f'\n{indent}'
|
||||
func.__doc__ += '\n{}'.format(indent)
|
||||
else:
|
||||
func.__doc__ += '\n\n'
|
||||
func.__doc__ += rpc_text
|
||||
|
@ -121,7 +114,7 @@ def format_request(call):
|
|||
|
||||
class DelugeRPCProtocol(DelugeTransferProtocol):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
super(DelugeRPCProtocol, self).__init__()
|
||||
# namedtuple subclass with auth_level, username for the connected session.
|
||||
self.AuthLevel = namedtuple('SessionAuthlevel', 'auth_level, username')
|
||||
|
||||
|
@ -273,9 +266,9 @@ class DelugeRPCProtocol(DelugeTransferProtocol):
|
|||
raise IncompatibleClient(deluge.common.get_version())
|
||||
ret = component.get('AuthManager').authorize(*args, **kwargs)
|
||||
if ret:
|
||||
self.factory.authorized_sessions[self.transport.sessionno] = (
|
||||
self.AuthLevel(ret, args[0])
|
||||
)
|
||||
self.factory.authorized_sessions[
|
||||
self.transport.sessionno
|
||||
] = self.AuthLevel(ret, args[0])
|
||||
self.factory.session_protocols[self.transport.sessionno] = self
|
||||
except Exception as ex:
|
||||
send_error()
|
||||
|
@ -544,8 +537,8 @@ class RPCServer(component.Component):
|
|||
:type event: :class:`deluge.event.DelugeEvent`
|
||||
"""
|
||||
log.debug('intevents: %s', self.factory.interested_events)
|
||||
# Use copy of `interested_events` since it can mutate while iterating.
|
||||
for session_id, interest in self.factory.interested_events.copy().items():
|
||||
# Find sessions interested in this event
|
||||
for session_id, interest in self.factory.interested_events.items():
|
||||
if event.name in interest:
|
||||
log.debug('Emit Event: %s %s', event.name, event.args)
|
||||
# This session is interested so send a RPC_EVENT
|
||||
|
@ -595,3 +588,59 @@ class RPCServer(component.Component):
|
|||
|
||||
def stop(self):
|
||||
self.factory.state = 'stopping'
|
||||
|
||||
|
||||
def check_ssl_keys():
|
||||
"""
|
||||
Check for SSL cert/key and create them if necessary
|
||||
"""
|
||||
ssl_dir = deluge.configmanager.get_config_dir('ssl')
|
||||
if not os.path.exists(ssl_dir):
|
||||
# The ssl folder doesn't exist so we need to create it
|
||||
os.makedirs(ssl_dir)
|
||||
generate_ssl_keys()
|
||||
else:
|
||||
for f in ('daemon.pkey', 'daemon.cert'):
|
||||
if not os.path.exists(os.path.join(ssl_dir, f)):
|
||||
generate_ssl_keys()
|
||||
break
|
||||
|
||||
|
||||
def generate_ssl_keys():
|
||||
"""
|
||||
This method generates a new SSL key/cert.
|
||||
"""
|
||||
from deluge.common import PY2
|
||||
|
||||
digest = 'sha256' if not PY2 else b'sha256'
|
||||
|
||||
# Generate key pair
|
||||
pkey = crypto.PKey()
|
||||
pkey.generate_key(crypto.TYPE_RSA, 2048)
|
||||
|
||||
# Generate cert request
|
||||
req = crypto.X509Req()
|
||||
subj = req.get_subject()
|
||||
setattr(subj, 'CN', 'Deluge Daemon')
|
||||
req.set_pubkey(pkey)
|
||||
req.sign(pkey, digest)
|
||||
|
||||
# Generate certificate
|
||||
cert = crypto.X509()
|
||||
cert.set_serial_number(0)
|
||||
cert.gmtime_adj_notBefore(0)
|
||||
cert.gmtime_adj_notAfter(60 * 60 * 24 * 365 * 3) # Three Years
|
||||
cert.set_issuer(req.get_subject())
|
||||
cert.set_subject(req.get_subject())
|
||||
cert.set_pubkey(req.get_pubkey())
|
||||
cert.sign(pkey, digest)
|
||||
|
||||
# Write out files
|
||||
ssl_dir = deluge.configmanager.get_config_dir('ssl')
|
||||
with open(os.path.join(ssl_dir, 'daemon.pkey'), 'wb') as _file:
|
||||
_file.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey))
|
||||
with open(os.path.join(ssl_dir, 'daemon.cert'), 'wb') as _file:
|
||||
_file.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert))
|
||||
# Make the files only readable by this user
|
||||
for f in ('daemon.pkey', 'daemon.cert'):
|
||||
os.chmod(os.path.join(ssl_dir, f), stat.S_IREAD | stat.S_IWRITE)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2007-2009 Andrew Resch <andrewresch@gmail.com>
|
||||
#
|
||||
|
@ -13,12 +14,11 @@ Attributes:
|
|||
|
||||
"""
|
||||
|
||||
from __future__ import division, unicode_literals
|
||||
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from twisted.internet.defer import Deferred, DeferredList
|
||||
|
||||
|
@ -34,6 +34,18 @@ from deluge.event import (
|
|||
TorrentTrackerStatusEvent,
|
||||
)
|
||||
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
except ImportError:
|
||||
# PY2 fallback
|
||||
from urlparse import urlparse # pylint: disable=ungrouped-imports
|
||||
|
||||
try:
|
||||
from future_builtins import zip
|
||||
except ImportError:
|
||||
# Ignore on Py3.
|
||||
pass
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
LT_TORRENT_STATE_MAP = {
|
||||
|
@ -82,7 +94,7 @@ def convert_lt_files(files):
|
|||
"""Indexes and decodes files from libtorrent get_files().
|
||||
|
||||
Args:
|
||||
files (file_storage): The libtorrent torrent files.
|
||||
files (list): The libtorrent torrent files.
|
||||
|
||||
Returns:
|
||||
list of dict: The files.
|
||||
|
@ -97,18 +109,18 @@ def convert_lt_files(files):
|
|||
}
|
||||
"""
|
||||
filelist = []
|
||||
for index in range(files.num_files()):
|
||||
for index, _file in enumerate(files):
|
||||
try:
|
||||
file_path = files.file_path(index).decode('utf8')
|
||||
file_path = _file.path.decode('utf8')
|
||||
except AttributeError:
|
||||
file_path = files.file_path(index)
|
||||
file_path = _file.path
|
||||
|
||||
filelist.append(
|
||||
{
|
||||
'index': index,
|
||||
'path': file_path.replace('\\', '/'),
|
||||
'size': files.file_size(index),
|
||||
'offset': files.file_offset(index),
|
||||
'size': _file.size,
|
||||
'offset': _file.offset,
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -149,7 +161,7 @@ class TorrentOptions(dict):
|
|||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
super(TorrentOptions, self).__init__()
|
||||
config = ConfigManager('core.conf').config
|
||||
options_conf_map = {
|
||||
'add_paused': 'add_paused',
|
||||
|
@ -179,14 +191,14 @@ class TorrentOptions(dict):
|
|||
self['seed_mode'] = False
|
||||
|
||||
|
||||
class TorrentError:
|
||||
class TorrentError(object):
|
||||
def __init__(self, error_message, was_paused=False, restart_to_resume=False):
|
||||
self.error_message = error_message
|
||||
self.was_paused = was_paused
|
||||
self.restart_to_resume = restart_to_resume
|
||||
|
||||
|
||||
class Torrent:
|
||||
class Torrent(object):
|
||||
"""Torrent holds information about torrents added to the libtorrent session.
|
||||
|
||||
Args:
|
||||
|
@ -194,12 +206,12 @@ class Torrent:
|
|||
options (dict): The torrent options.
|
||||
state (TorrentState): The torrent state.
|
||||
filename (str): The filename of the torrent file.
|
||||
magnet (str): The magnet URI.
|
||||
magnet (str): The magnet uri.
|
||||
|
||||
Attributes:
|
||||
torrent_id (str): The torrent_id for this torrent
|
||||
handle: Holds the libtorrent torrent handle
|
||||
magnet (str): The magnet URI used to add this torrent (if available).
|
||||
magnet (str): The magnet uri used to add this torrent (if available).
|
||||
status: Holds status info so that we don"t need to keep getting it from libtorrent.
|
||||
torrent_info: store the torrent info.
|
||||
has_metadata (bool): True if the metadata for the torrent is available, False otherwise.
|
||||
|
@ -236,10 +248,9 @@ class Torrent:
|
|||
self.handle = handle
|
||||
|
||||
self.magnet = magnet
|
||||
self._status: Optional['lt.torrent_status'] = None
|
||||
self._status_last_update: float = 0.0
|
||||
self.status = self.handle.status()
|
||||
|
||||
self.torrent_info = self.handle.torrent_file()
|
||||
self.torrent_info = self.handle.get_torrent_info()
|
||||
self.has_metadata = self.status.has_metadata
|
||||
|
||||
self.options = TorrentOptions()
|
||||
|
@ -255,9 +266,6 @@ class Torrent:
|
|||
self.is_finished = False
|
||||
self.filename = filename
|
||||
|
||||
if not self.filename:
|
||||
self.filename = ''
|
||||
|
||||
self.forced_error = None
|
||||
self.statusmsg = None
|
||||
self.state = None
|
||||
|
@ -270,6 +278,7 @@ class Torrent:
|
|||
self.prev_status = {}
|
||||
self.waiting_on_folder_rename = []
|
||||
|
||||
self.update_status(self.handle.status())
|
||||
self._create_status_funcs()
|
||||
self.set_options(self.options)
|
||||
self.update_state()
|
||||
|
@ -277,18 +286,6 @@ class Torrent:
|
|||
if log.isEnabledFor(logging.DEBUG):
|
||||
log.debug('Torrent object created.')
|
||||
|
||||
def _set_handle_flags(self, flag: lt.torrent_flags, set_flag: bool):
|
||||
"""set or unset a flag to the lt handle
|
||||
|
||||
Args:
|
||||
flag (lt.torrent_flags): the flag to set/unset
|
||||
set_flag (bool): True for setting the flag, False for unsetting it
|
||||
"""
|
||||
if set_flag:
|
||||
self.handle.set_flags(flag)
|
||||
else:
|
||||
self.handle.unset_flags(flag)
|
||||
|
||||
def on_metadata_received(self):
|
||||
"""Process the metadata received alert for this torrent"""
|
||||
self.has_metadata = True
|
||||
|
@ -373,7 +370,7 @@ class Torrent:
|
|||
"""Sets maximum download speed for this torrent.
|
||||
|
||||
Args:
|
||||
m_down_speed (float): Maximum download speed in KiB/s.
|
||||
m_up_speed (float): Maximum download speed in KiB/s.
|
||||
"""
|
||||
self.options['max_download_speed'] = m_down_speed
|
||||
if m_down_speed < 0:
|
||||
|
@ -405,7 +402,7 @@ class Torrent:
|
|||
return
|
||||
|
||||
# A list of priorities for each piece in the torrent
|
||||
priorities = self.handle.get_piece_priorities()
|
||||
priorities = self.handle.piece_priorities()
|
||||
|
||||
def get_file_piece(idx, byte_offset):
|
||||
return self.torrent_info.map_file(idx, byte_offset, 0).piece
|
||||
|
@ -431,17 +428,14 @@ class Torrent:
|
|||
# Setting the priorites for all the pieces of this torrent
|
||||
self.handle.prioritize_pieces(priorities)
|
||||
|
||||
def set_sequential_download(self, sequential):
|
||||
def set_sequential_download(self, set_sequencial):
|
||||
"""Sets whether to download the pieces of the torrent in order.
|
||||
|
||||
Args:
|
||||
sequential (bool): Enable sequential downloading.
|
||||
set_sequencial (bool): Enable sequencial downloading.
|
||||
"""
|
||||
self.options['sequential_download'] = sequential
|
||||
self._set_handle_flags(
|
||||
flag=lt.torrent_flags.sequential_download,
|
||||
set_flag=sequential,
|
||||
)
|
||||
self.options['sequential_download'] = set_sequencial
|
||||
self.handle.set_sequential_download(set_sequencial)
|
||||
|
||||
def set_auto_managed(self, auto_managed):
|
||||
"""Set auto managed mode, i.e. will be started or queued automatically.
|
||||
|
@ -451,10 +445,7 @@ class Torrent:
|
|||
"""
|
||||
self.options['auto_managed'] = auto_managed
|
||||
if not (self.status.paused and not self.status.auto_managed):
|
||||
self._set_handle_flags(
|
||||
flag=lt.torrent_flags.auto_managed,
|
||||
set_flag=auto_managed,
|
||||
)
|
||||
self.handle.auto_managed(auto_managed)
|
||||
self.update_state()
|
||||
|
||||
def set_super_seeding(self, super_seeding):
|
||||
|
@ -464,10 +455,7 @@ class Torrent:
|
|||
super_seeding (bool): Enable super seeding.
|
||||
"""
|
||||
self.options['super_seeding'] = super_seeding
|
||||
self._set_handle_flags(
|
||||
flag=lt.torrent_flags.super_seeding,
|
||||
set_flag=super_seeding,
|
||||
)
|
||||
self.handle.super_seeding(super_seeding)
|
||||
|
||||
def set_stop_ratio(self, stop_ratio):
|
||||
"""The seeding ratio to stop (or remove) the torrent at.
|
||||
|
@ -528,7 +516,7 @@ class Torrent:
|
|||
self.handle.prioritize_files(file_priorities)
|
||||
else:
|
||||
log.debug('Unable to set new file priorities.')
|
||||
file_priorities = self.handle.get_file_priorities()
|
||||
file_priorities = self.handle.file_priorities()
|
||||
|
||||
if 0 in self.options['file_priorities']:
|
||||
# Previously marked a file 'skip' so check for any 0's now >0.
|
||||
|
@ -578,7 +566,7 @@ class Torrent:
|
|||
trackers (list of dicts): A list of trackers.
|
||||
"""
|
||||
if trackers is None:
|
||||
self.trackers = list(self.handle.trackers())
|
||||
self.trackers = [tracker for tracker in self.handle.trackers()]
|
||||
self.tracker_host = None
|
||||
return
|
||||
|
||||
|
@ -643,7 +631,7 @@ class Torrent:
|
|||
|
||||
def update_state(self):
|
||||
"""Updates the state, based on libtorrent's torrent state"""
|
||||
status = self.get_lt_status()
|
||||
status = self.handle.status()
|
||||
session_paused = component.get('Core').session.is_paused()
|
||||
old_state = self.state
|
||||
self.set_status_message()
|
||||
|
@ -655,10 +643,7 @@ class Torrent:
|
|||
elif status_error:
|
||||
self.state = 'Error'
|
||||
# auto-manage status will be reverted upon resuming.
|
||||
self._set_handle_flags(
|
||||
flag=lt.torrent_flags.auto_managed,
|
||||
set_flag=False,
|
||||
)
|
||||
self.handle.auto_managed(False)
|
||||
self.set_status_message(decode_bytes(status_error))
|
||||
elif status.moving_storage:
|
||||
self.state = 'Moving'
|
||||
|
@ -711,11 +696,8 @@ class Torrent:
|
|||
restart_to_resume (bool, optional): Prevent resuming clearing the error, only restarting
|
||||
session can resume.
|
||||
"""
|
||||
status = self.get_lt_status()
|
||||
self._set_handle_flags(
|
||||
flag=lt.torrent_flags.auto_managed,
|
||||
set_flag=False,
|
||||
)
|
||||
status = self.handle.status()
|
||||
self.handle.auto_managed(False)
|
||||
self.forced_error = TorrentError(message, status.paused, restart_to_resume)
|
||||
if not status.paused:
|
||||
self.handle.pause()
|
||||
|
@ -729,10 +711,7 @@ class Torrent:
|
|||
log.error('Restart deluge to clear this torrent error')
|
||||
|
||||
if not self.forced_error.was_paused and self.options['auto_managed']:
|
||||
self._set_handle_flags(
|
||||
flag=lt.torrent_flags.auto_managed,
|
||||
set_flag=True,
|
||||
)
|
||||
self.handle.auto_managed(True)
|
||||
self.forced_error = None
|
||||
self.set_status_message('OK')
|
||||
if update_state:
|
||||
|
@ -831,11 +810,7 @@ class Torrent:
|
|||
if peer.flags & peer.connecting or peer.flags & peer.handshake:
|
||||
continue
|
||||
|
||||
try:
|
||||
client = decode_bytes(peer.client)
|
||||
except UnicodeDecodeError:
|
||||
# libtorrent on Py3 can raise UnicodeDecodeError for peer_info.client
|
||||
client = 'unknown'
|
||||
client = decode_bytes(peer.client)
|
||||
|
||||
try:
|
||||
country = component.get('Core').geoip_instance.country_code_by_addr(
|
||||
|
@ -856,7 +831,7 @@ class Torrent:
|
|||
'client': client,
|
||||
'country': country,
|
||||
'down_speed': peer.payload_down_speed,
|
||||
'ip': f'{peer.ip[0]}:{peer.ip[1]}',
|
||||
'ip': '%s:%s' % (peer.ip[0], peer.ip[1]),
|
||||
'progress': peer.progress,
|
||||
'seed': peer.flags & peer.seed,
|
||||
'up_speed': peer.payload_up_speed,
|
||||
|
@ -875,7 +850,7 @@ class Torrent:
|
|||
|
||||
def get_file_priorities(self):
|
||||
"""Return the file priorities"""
|
||||
if not self.handle.status().has_metadata:
|
||||
if not self.handle.has_metadata():
|
||||
return []
|
||||
|
||||
if not self.options['file_priorities']:
|
||||
|
@ -892,18 +867,11 @@ class Torrent:
|
|||
"""
|
||||
if not self.has_metadata:
|
||||
return []
|
||||
|
||||
try:
|
||||
files_progresses = zip(
|
||||
self.handle.file_progress(), self.torrent_info.files()
|
||||
)
|
||||
except Exception:
|
||||
# Handle libtorrent >=2.0.0,<=2.0.4 file_progress error
|
||||
files_progresses = zip(iter(lambda: 0, 1), self.torrent_info.files())
|
||||
|
||||
return [
|
||||
progress / _file.size if _file.size else 0.0
|
||||
for progress, _file in files_progresses
|
||||
for progress, _file in zip(
|
||||
self.handle.file_progress(), self.torrent_info.files()
|
||||
)
|
||||
]
|
||||
|
||||
def get_tracker_host(self):
|
||||
|
@ -928,7 +896,7 @@ class Torrent:
|
|||
# Check if hostname is an IP address and just return it if that's the case
|
||||
try:
|
||||
socket.inet_aton(host)
|
||||
except OSError:
|
||||
except socket.error:
|
||||
pass
|
||||
else:
|
||||
# This is an IP address because an exception wasn't raised
|
||||
|
@ -945,7 +913,7 @@ class Torrent:
|
|||
return ''
|
||||
|
||||
def get_magnet_uri(self):
|
||||
"""Returns a magnet URI for this torrent"""
|
||||
"""Returns a magnet uri for this torrent"""
|
||||
return lt.make_magnet_uri(self.handle)
|
||||
|
||||
def get_name(self):
|
||||
|
@ -964,10 +932,10 @@ class Torrent:
|
|||
|
||||
if self.has_metadata:
|
||||
# Use the top-level folder as torrent name.
|
||||
filename = decode_bytes(self.torrent_info.files().file_path(0))
|
||||
filename = decode_bytes(self.torrent_info.file_at(0).path)
|
||||
name = filename.replace('\\', '/', 1).split('/', 1)[0]
|
||||
else:
|
||||
name = decode_bytes(self.handle.status().name)
|
||||
name = decode_bytes(self.handle.name())
|
||||
|
||||
if not name:
|
||||
name = self.torrent_id
|
||||
|
@ -1019,14 +987,12 @@ class Torrent:
|
|||
call to get_status based on the session_id
|
||||
update (bool): If True the status will be updated from libtorrent
|
||||
if False, the cached values will be returned
|
||||
all_keys (bool): If True return all keys while ignoring the keys param
|
||||
if False, return only the requested keys
|
||||
|
||||
Returns:
|
||||
dict: a dictionary of the status keys and their values
|
||||
"""
|
||||
if update:
|
||||
self.get_lt_status()
|
||||
self.update_status(self.handle.status())
|
||||
|
||||
if all_keys:
|
||||
keys = list(self.status_funcs)
|
||||
|
@ -1056,35 +1022,13 @@ class Torrent:
|
|||
|
||||
return status_dict
|
||||
|
||||
def get_lt_status(self) -> 'lt.torrent_status':
|
||||
"""Get the torrent status fresh, not from cache.
|
||||
|
||||
This should be used when a guaranteed fresh status is needed rather than
|
||||
`torrent.handle.status()` because it will update the cache as well.
|
||||
"""
|
||||
self.status = self.handle.status()
|
||||
return self.status
|
||||
|
||||
@property
|
||||
def status(self) -> 'lt.torrent_status':
|
||||
"""Cached copy of the libtorrent status for this torrent.
|
||||
|
||||
If it has not been updated within the last five seconds, it will be
|
||||
automatically refreshed.
|
||||
"""
|
||||
if self._status_last_update < (time.time() - 5):
|
||||
self.status = self.handle.status()
|
||||
return self._status
|
||||
|
||||
@status.setter
|
||||
def status(self, status: 'lt.torrent_status') -> None:
|
||||
def update_status(self, status):
|
||||
"""Updates the cached status.
|
||||
|
||||
Args:
|
||||
status: a libtorrent torrent status
|
||||
status (libtorrent.torrent_status): a libtorrent torrent status
|
||||
"""
|
||||
self._status = status
|
||||
self._status_last_update = time.time()
|
||||
self.status = status
|
||||
|
||||
def _create_status_funcs(self):
|
||||
"""Creates the functions for getting torrent status"""
|
||||
|
@ -1138,8 +1082,9 @@ class Torrent:
|
|||
'download_location': lambda: self.options['download_location'],
|
||||
'seeds_peers_ratio': lambda: -1.0
|
||||
if self.status.num_incomplete == 0
|
||||
# Use -1.0 to signify infinity
|
||||
else (self.status.num_complete / self.status.num_incomplete),
|
||||
else ( # Use -1.0 to signify infinity
|
||||
self.status.num_complete / self.status.num_incomplete
|
||||
),
|
||||
'seed_rank': lambda: self.status.seed_rank,
|
||||
'state': lambda: self.state,
|
||||
'stop_at_ratio': lambda: self.options['stop_at_ratio'],
|
||||
|
@ -1205,10 +1150,7 @@ class Torrent:
|
|||
|
||||
"""
|
||||
# Turn off auto-management so the torrent will not be unpaused by lt queueing
|
||||
self._set_handle_flags(
|
||||
flag=lt.torrent_flags.auto_managed,
|
||||
set_flag=False,
|
||||
)
|
||||
self.handle.auto_managed(False)
|
||||
if self.state == 'Error':
|
||||
log.debug('Unable to pause torrent while in Error state')
|
||||
elif self.status.paused:
|
||||
|
@ -1243,10 +1185,7 @@ class Torrent:
|
|||
else:
|
||||
# Check if torrent was originally being auto-managed.
|
||||
if self.options['auto_managed']:
|
||||
self._set_handle_flags(
|
||||
flag=lt.torrent_flags.auto_managed,
|
||||
set_flag=True,
|
||||
)
|
||||
self.handle.auto_managed(True)
|
||||
try:
|
||||
self.handle.resume()
|
||||
except RuntimeError as ex:
|
||||
|
@ -1269,8 +1208,8 @@ class Torrent:
|
|||
bool: True is successful, otherwise False
|
||||
"""
|
||||
try:
|
||||
self.handle.connect_peer((peer_ip, int(peer_port)), 0)
|
||||
except (RuntimeError, ValueError) as ex:
|
||||
self.handle.connect_peer((peer_ip, peer_port), 0)
|
||||
except RuntimeError as ex:
|
||||
log.debug('Unable to connect to peer: %s', ex)
|
||||
return False
|
||||
return True
|
||||
|
@ -1350,7 +1289,7 @@ class Torrent:
|
|||
try:
|
||||
with open(filepath, 'wb') as save_file:
|
||||
save_file.write(filedump)
|
||||
except OSError as ex:
|
||||
except IOError as ex:
|
||||
log.error('Unable to save torrent file to: %s', ex)
|
||||
|
||||
filepath = os.path.join(get_config_dir(), 'state', self.torrent_id + '.torrent')
|
||||
|
@ -1373,7 +1312,7 @@ class Torrent:
|
|||
torrent_files = [
|
||||
os.path.join(get_config_dir(), 'state', self.torrent_id + '.torrent')
|
||||
]
|
||||
if delete_copies and self.filename:
|
||||
if delete_copies:
|
||||
torrent_files.append(
|
||||
os.path.join(self.config['torrentfiles_location'], self.filename)
|
||||
)
|
||||
|
@ -1397,8 +1336,8 @@ class Torrent:
|
|||
def scrape_tracker(self):
|
||||
"""Scrape the tracker
|
||||
|
||||
A scrape request queries the tracker for statistics such as total
|
||||
number of incomplete peers, complete peers, number of downloads etc.
|
||||
A scrape request queries the tracker for statistics such as total
|
||||
number of incomplete peers, complete peers, number of downloads etc.
|
||||
"""
|
||||
try:
|
||||
self.handle.scrape_tracker()
|
||||
|
@ -1445,7 +1384,7 @@ class Torrent:
|
|||
This basically does a file rename on all of the folders children.
|
||||
|
||||
Args:
|
||||
folder (str): The original folder name
|
||||
folder (str): The orignal folder name
|
||||
new_folder (str): The new folder name
|
||||
|
||||
Returns:
|
||||
|
@ -1543,18 +1482,20 @@ class Torrent:
|
|||
self.status.pieces, self.handle.piece_availability()
|
||||
):
|
||||
if piece:
|
||||
# Completed.
|
||||
pieces.append(3)
|
||||
pieces.append(3) # Completed.
|
||||
elif avail_piece:
|
||||
# Available, just not downloaded nor being downloaded.
|
||||
pieces.append(1)
|
||||
pieces.append(
|
||||
1
|
||||
) # Available, just not downloaded nor being downloaded.
|
||||
else:
|
||||
# Missing, no known peer with piece, or not asked for yet.
|
||||
pieces.append(0)
|
||||
pieces.append(
|
||||
0
|
||||
) # Missing, no known peer with piece, or not asked for yet.
|
||||
|
||||
for peer_info in self.handle.get_peer_info():
|
||||
if peer_info.downloading_piece_index >= 0:
|
||||
# Being downloaded from peer.
|
||||
pieces[peer_info.downloading_piece_index] = 2
|
||||
pieces[
|
||||
peer_info.downloading_piece_index
|
||||
] = 2 # Being downloaded from peer.
|
||||
|
||||
return pieces
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2007-2009 Andrew Resch <andrewresch@gmail.com>
|
||||
#
|
||||
|
@ -7,34 +8,27 @@
|
|||
#
|
||||
|
||||
"""TorrentManager handles Torrent objects"""
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import operator
|
||||
import os
|
||||
import pickle
|
||||
import time
|
||||
from base64 import b64encode
|
||||
from collections import namedtuple
|
||||
from tempfile import gettempdir
|
||||
from typing import Dict, List, NamedTuple, Tuple
|
||||
|
||||
from twisted.internet import defer, reactor, threads
|
||||
import six.moves.cPickle as pickle # noqa: N813
|
||||
from twisted.internet import defer, error, reactor, threads
|
||||
from twisted.internet.defer import Deferred, DeferredList
|
||||
from twisted.internet.task import LoopingCall
|
||||
|
||||
import deluge.component as component
|
||||
from deluge._libtorrent import LT_VERSION, lt
|
||||
from deluge.common import (
|
||||
VersionSplit,
|
||||
archive_files,
|
||||
decode_bytes,
|
||||
get_magnet_info,
|
||||
is_magnet,
|
||||
)
|
||||
from deluge._libtorrent import lt
|
||||
from deluge.common import archive_files, decode_bytes, get_magnet_info, is_magnet
|
||||
from deluge.configmanager import ConfigManager, get_config_dir
|
||||
from deluge.core.authmanager import AUTH_LEVEL_ADMIN
|
||||
from deluge.core.torrent import Torrent, TorrentOptions, sanitize_filepath
|
||||
from deluge.decorators import maybe_coroutine
|
||||
from deluge.error import AddTorrentError, InvalidTorrentError
|
||||
from deluge.event import (
|
||||
ExternalIPEvent,
|
||||
|
@ -51,18 +45,13 @@ from deluge.event import (
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
LT_DEFAULT_ADD_TORRENT_FLAGS = (
|
||||
lt.torrent_flags.paused
|
||||
| lt.torrent_flags.auto_managed
|
||||
| lt.torrent_flags.update_subscribe
|
||||
| lt.torrent_flags.apply_ip_filter
|
||||
lt.add_torrent_params_flags_t.flag_paused
|
||||
| lt.add_torrent_params_flags_t.flag_auto_managed
|
||||
| lt.add_torrent_params_flags_t.flag_update_subscribe
|
||||
| lt.add_torrent_params_flags_t.flag_apply_ip_filter
|
||||
)
|
||||
|
||||
|
||||
class PrefetchQueueItem(NamedTuple):
|
||||
alert_deferred: Deferred
|
||||
result_queue: List[Deferred]
|
||||
|
||||
|
||||
class TorrentState: # pylint: disable=old-style-class
|
||||
"""Create a torrent state.
|
||||
|
||||
|
@ -100,7 +89,7 @@ class TorrentState: # pylint: disable=old-style-class
|
|||
super_seeding=False,
|
||||
name=None,
|
||||
):
|
||||
# Build the class attribute list from args
|
||||
# Build the class atrribute list from args
|
||||
for key, value in locals().items():
|
||||
if key == 'self':
|
||||
continue
|
||||
|
@ -140,8 +129,7 @@ class TorrentManager(component.Component):
|
|||
|
||||
"""
|
||||
|
||||
# This is used in the test to mock out timeouts
|
||||
clock = reactor
|
||||
callLater = reactor.callLater # noqa: N815
|
||||
|
||||
def __init__(self):
|
||||
component.Component.__init__(
|
||||
|
@ -170,7 +158,7 @@ class TorrentManager(component.Component):
|
|||
self.is_saving_state = False
|
||||
self.save_resume_data_file_lock = defer.DeferredLock()
|
||||
self.torrents_loading = {}
|
||||
self.prefetching_metadata: Dict[str, PrefetchQueueItem] = {}
|
||||
self.prefetching_metadata = {}
|
||||
|
||||
# This is a map of torrent_ids to Deferreds used to track needed resume data.
|
||||
# The Deferreds will be completed when resume data has been saved.
|
||||
|
@ -203,32 +191,34 @@ class TorrentManager(component.Component):
|
|||
|
||||
# Register alert functions
|
||||
alert_handles = [
|
||||
'external_ip',
|
||||
'performance',
|
||||
'add_torrent',
|
||||
'metadata_received',
|
||||
'torrent_finished',
|
||||
'torrent_paused',
|
||||
'torrent_checked',
|
||||
'torrent_resumed',
|
||||
'tracker_reply',
|
||||
'tracker_announce',
|
||||
'tracker_warning',
|
||||
'tracker_error',
|
||||
'file_renamed',
|
||||
'file_error',
|
||||
'file_completed',
|
||||
'storage_moved',
|
||||
'storage_moved_failed',
|
||||
'state_update',
|
||||
'state_changed',
|
||||
'save_resume_data',
|
||||
'save_resume_data_failed',
|
||||
'fastresume_rejected',
|
||||
'external_ip_alert',
|
||||
'performance_alert',
|
||||
'add_torrent_alert',
|
||||
'metadata_received_alert',
|
||||
'torrent_finished_alert',
|
||||
'torrent_paused_alert',
|
||||
'torrent_checked_alert',
|
||||
'torrent_resumed_alert',
|
||||
'tracker_reply_alert',
|
||||
'tracker_announce_alert',
|
||||
'tracker_warning_alert',
|
||||
'tracker_error_alert',
|
||||
'file_renamed_alert',
|
||||
'file_error_alert',
|
||||
'file_completed_alert',
|
||||
'storage_moved_alert',
|
||||
'storage_moved_failed_alert',
|
||||
'state_update_alert',
|
||||
'state_changed_alert',
|
||||
'save_resume_data_alert',
|
||||
'save_resume_data_failed_alert',
|
||||
'fastresume_rejected_alert',
|
||||
]
|
||||
|
||||
for alert_handle in alert_handles:
|
||||
on_alert_func = getattr(self, ''.join(['on_alert_', alert_handle]))
|
||||
on_alert_func = getattr(
|
||||
self, ''.join(['on_alert_', alert_handle.replace('_alert', '')])
|
||||
)
|
||||
self.alerts.register_handler(alert_handle, on_alert_func)
|
||||
|
||||
# Define timers
|
||||
|
@ -253,8 +243,8 @@ class TorrentManager(component.Component):
|
|||
self.save_resume_data_timer.start(190, False)
|
||||
self.prev_status_cleanup_loop.start(10)
|
||||
|
||||
@maybe_coroutine
|
||||
async def stop(self):
|
||||
@defer.inlineCallbacks
|
||||
def stop(self):
|
||||
# Stop timers
|
||||
if self.save_state_timer.running:
|
||||
self.save_state_timer.stop()
|
||||
|
@ -266,11 +256,11 @@ class TorrentManager(component.Component):
|
|||
self.prev_status_cleanup_loop.stop()
|
||||
|
||||
# Save state on shutdown
|
||||
await self.save_state()
|
||||
yield self.save_state()
|
||||
|
||||
self.session.pause()
|
||||
|
||||
result = await self.save_resume_data(flush_disk_cache=True)
|
||||
result = yield self.save_resume_data(flush_disk_cache=True)
|
||||
# Remove the temp_file to signify successfully saved state
|
||||
if result and os.path.isfile(self.temp_file):
|
||||
os.remove(self.temp_file)
|
||||
|
@ -284,6 +274,11 @@ class TorrentManager(component.Component):
|
|||
'Paused',
|
||||
'Queued',
|
||||
):
|
||||
# If the global setting is set, but the per-torrent isn't...
|
||||
# Just skip to the next torrent.
|
||||
# This is so that a user can turn-off the stop at ratio option on a per-torrent basis
|
||||
if not torrent.options['stop_at_ratio']:
|
||||
continue
|
||||
if (
|
||||
torrent.get_ratio() >= torrent.options['stop_ratio']
|
||||
and torrent.is_finished
|
||||
|
@ -291,8 +286,8 @@ class TorrentManager(component.Component):
|
|||
if torrent.options['remove_at_ratio']:
|
||||
self.remove(torrent_id)
|
||||
break
|
||||
|
||||
torrent.pause()
|
||||
if not torrent.handle.status().paused:
|
||||
torrent.pause()
|
||||
|
||||
def __getitem__(self, torrent_id):
|
||||
"""Return the Torrent with torrent_id.
|
||||
|
@ -344,64 +339,66 @@ class TorrentManager(component.Component):
|
|||
else:
|
||||
return torrent_info
|
||||
|
||||
@maybe_coroutine
|
||||
async def prefetch_metadata(self, magnet: str, timeout: int) -> Tuple[str, bytes]:
|
||||
"""Download the metadata for a magnet URI.
|
||||
def prefetch_metadata(self, magnet, timeout):
|
||||
"""Download the metadata for a magnet uri.
|
||||
|
||||
Args:
|
||||
magnet: A magnet URI to download the metadata for.
|
||||
timeout: Number of seconds to wait before canceling.
|
||||
magnet (str): A magnet uri to download the metadata for.
|
||||
timeout (int): Number of seconds to wait before cancelling.
|
||||
|
||||
Returns:
|
||||
A tuple of (torrent_id, metadata)
|
||||
Deferred: A tuple of (torrent_id (str), metadata (dict))
|
||||
|
||||
"""
|
||||
|
||||
torrent_id = get_magnet_info(magnet)['info_hash']
|
||||
if torrent_id in self.prefetching_metadata:
|
||||
d = Deferred()
|
||||
self.prefetching_metadata[torrent_id].result_queue.append(d)
|
||||
return await d
|
||||
return self.prefetching_metadata[torrent_id].defer
|
||||
|
||||
add_torrent_params = lt.parse_magnet_uri(magnet)
|
||||
add_torrent_params.save_path = gettempdir()
|
||||
add_torrent_params.flags = (
|
||||
add_torrent_params = {}
|
||||
add_torrent_params['save_path'] = gettempdir()
|
||||
add_torrent_params['url'] = magnet.strip().encode('utf8')
|
||||
add_torrent_params['flags'] = (
|
||||
(
|
||||
LT_DEFAULT_ADD_TORRENT_FLAGS
|
||||
| lt.torrent_flags.duplicate_is_error
|
||||
| lt.torrent_flags.upload_mode
|
||||
| lt.add_torrent_params_flags_t.flag_duplicate_is_error
|
||||
| lt.add_torrent_params_flags_t.flag_upload_mode
|
||||
)
|
||||
^ lt.torrent_flags.auto_managed
|
||||
^ lt.torrent_flags.paused
|
||||
^ lt.add_torrent_params_flags_t.flag_auto_managed
|
||||
^ lt.add_torrent_params_flags_t.flag_paused
|
||||
)
|
||||
|
||||
torrent_handle = self.session.add_torrent(add_torrent_params)
|
||||
|
||||
d = Deferred()
|
||||
# Cancel the defer if timeout reached.
|
||||
d.addTimeout(timeout, self.clock)
|
||||
self.prefetching_metadata[torrent_id] = PrefetchQueueItem(d, [])
|
||||
defer_timeout = self.callLater(timeout, d.cancel)
|
||||
d.addBoth(self.on_prefetch_metadata, torrent_id, defer_timeout)
|
||||
Prefetch = namedtuple('Prefetch', 'defer handle')
|
||||
self.prefetching_metadata[torrent_id] = Prefetch(defer=d, handle=torrent_handle)
|
||||
return d
|
||||
|
||||
def on_prefetch_metadata(self, torrent_info, torrent_id, defer_timeout):
|
||||
# Cancel reactor.callLater.
|
||||
try:
|
||||
torrent_info = await d
|
||||
except (defer.TimeoutError, defer.CancelledError):
|
||||
log.debug(f'Prefetching metadata for {torrent_id} timed out or cancelled.')
|
||||
metadata = b''
|
||||
else:
|
||||
log.debug('prefetch metadata received')
|
||||
if VersionSplit(LT_VERSION) < VersionSplit('2.0.0.0'):
|
||||
metadata = torrent_info.metadata()
|
||||
else:
|
||||
metadata = torrent_info.info_section()
|
||||
defer_timeout.cancel()
|
||||
except error.AlreadyCalled:
|
||||
pass
|
||||
|
||||
log.debug('remove prefetch magnet from session')
|
||||
result_queue = self.prefetching_metadata.pop(torrent_id).result_queue
|
||||
self.session.remove_torrent(torrent_handle, 1)
|
||||
result = torrent_id, b64encode(metadata)
|
||||
try:
|
||||
torrent_handle = self.prefetching_metadata.pop(torrent_id).handle
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
self.session.remove_torrent(torrent_handle, 1)
|
||||
|
||||
for d in result_queue:
|
||||
d.callback(result)
|
||||
return result
|
||||
metadata = None
|
||||
if isinstance(torrent_info, lt.torrent_info):
|
||||
log.debug('prefetch metadata received')
|
||||
metadata = lt.bdecode(torrent_info.metadata())
|
||||
|
||||
return torrent_id, metadata
|
||||
|
||||
def _build_torrent_options(self, options):
|
||||
"""Load default options and update if needed."""
|
||||
|
@ -434,10 +431,9 @@ class TorrentManager(component.Component):
|
|||
elif magnet:
|
||||
magnet_info = get_magnet_info(magnet)
|
||||
if magnet_info:
|
||||
add_torrent_params['url'] = magnet.strip().encode('utf8')
|
||||
add_torrent_params['name'] = magnet_info['name']
|
||||
add_torrent_params['trackers'] = list(magnet_info['trackers'])
|
||||
torrent_id = magnet_info['info_hash']
|
||||
add_torrent_params['info_hash'] = bytes(bytearray.fromhex(torrent_id))
|
||||
else:
|
||||
raise AddTorrentError(
|
||||
'Unable to add magnet, invalid magnet info: %s' % magnet
|
||||
|
@ -452,7 +448,7 @@ class TorrentManager(component.Component):
|
|||
raise AddTorrentError('Torrent already being added (%s).' % torrent_id)
|
||||
elif torrent_id in self.prefetching_metadata:
|
||||
# Cancel and remove metadata fetching torrent.
|
||||
self.prefetching_metadata[torrent_id].alert_deferred.cancel()
|
||||
self.prefetching_metadata[torrent_id].defer.cancel()
|
||||
|
||||
# Check for renamed files and if so, rename them in the torrent_info before adding.
|
||||
if options['mapped_files'] and torrent_info:
|
||||
|
@ -480,12 +476,16 @@ class TorrentManager(component.Component):
|
|||
|
||||
# Set flags: enable duplicate_is_error & override_resume_data, disable auto_managed.
|
||||
add_torrent_params['flags'] = (
|
||||
LT_DEFAULT_ADD_TORRENT_FLAGS | lt.torrent_flags.duplicate_is_error
|
||||
) ^ lt.torrent_flags.auto_managed
|
||||
LT_DEFAULT_ADD_TORRENT_FLAGS
|
||||
| lt.add_torrent_params_flags_t.flag_duplicate_is_error
|
||||
| lt.add_torrent_params_flags_t.flag_override_resume_data
|
||||
) ^ lt.add_torrent_params_flags_t.flag_auto_managed
|
||||
if options['seed_mode']:
|
||||
add_torrent_params['flags'] |= lt.torrent_flags.seed_mode
|
||||
add_torrent_params['flags'] |= lt.add_torrent_params_flags_t.flag_seed_mode
|
||||
if options['super_seeding']:
|
||||
add_torrent_params['flags'] |= lt.torrent_flags.super_seeding
|
||||
add_torrent_params[
|
||||
'flags'
|
||||
] |= lt.add_torrent_params_flags_t.flag_super_seeding
|
||||
|
||||
return torrent_id, add_torrent_params
|
||||
|
||||
|
@ -509,7 +509,7 @@ class TorrentManager(component.Component):
|
|||
save_state (bool, optional): If True save the session state after adding torrent, defaults to True.
|
||||
filedump (str, optional): bencoded filedump of a torrent file.
|
||||
filename (str, optional): The filename of the torrent file.
|
||||
magnet (str, optional): The magnet URI.
|
||||
magnet (str, optional): The magnet uri.
|
||||
resume_data (lt.entry, optional): libtorrent fast resume data.
|
||||
|
||||
Returns:
|
||||
|
@ -574,7 +574,7 @@ class TorrentManager(component.Component):
|
|||
save_state (bool, optional): If True save the session state after adding torrent, defaults to True.
|
||||
filedump (str, optional): bencoded filedump of a torrent file.
|
||||
filename (str, optional): The filename of the torrent file.
|
||||
magnet (str, optional): The magnet URI.
|
||||
magnet (str, optional): The magnet uri.
|
||||
resume_data (lt.entry, optional): libtorrent fast resume data.
|
||||
|
||||
Returns:
|
||||
|
@ -642,7 +642,7 @@ class TorrentManager(component.Component):
|
|||
# Resume AlertManager if paused for adding torrent to libtorrent.
|
||||
component.resume('AlertManager')
|
||||
|
||||
# Store the original resume_data, in case of errors.
|
||||
# Store the orignal resume_data, in case of errors.
|
||||
if resume_data:
|
||||
self.resume_data[torrent.torrent_id] = resume_data
|
||||
|
||||
|
@ -809,9 +809,9 @@ class TorrentManager(component.Component):
|
|||
|
||||
try:
|
||||
with open(filepath, 'rb') as _file:
|
||||
state = pickle.load(_file, encoding='utf8')
|
||||
except (OSError, EOFError, pickle.UnpicklingError) as ex:
|
||||
message = f'Unable to load {filepath}: {ex}'
|
||||
state = pickle.load(_file)
|
||||
except (IOError, EOFError, pickle.UnpicklingError) as ex:
|
||||
message = 'Unable to load {}: {}'.format(filepath, ex)
|
||||
log.error(message)
|
||||
if not filepath.endswith('.bak'):
|
||||
self.archive_state(message)
|
||||
|
@ -1022,7 +1022,7 @@ class TorrentManager(component.Component):
|
|||
)
|
||||
|
||||
def on_torrent_resume_save(dummy_result, torrent_id):
|
||||
"""Received torrent resume_data alert so remove from waiting list"""
|
||||
"""Recieved torrent resume_data alert so remove from waiting list"""
|
||||
self.waiting_on_resume_data.pop(torrent_id, None)
|
||||
|
||||
deferreds = []
|
||||
|
@ -1067,7 +1067,7 @@ class TorrentManager(component.Component):
|
|||
try:
|
||||
with open(_filepath, 'rb') as _file:
|
||||
resume_data = lt.bdecode(_file.read())
|
||||
except (OSError, EOFError, RuntimeError) as ex:
|
||||
except (IOError, EOFError, RuntimeError) as ex:
|
||||
if self.torrents:
|
||||
log.warning('Unable to load %s: %s', _filepath, ex)
|
||||
resume_data = None
|
||||
|
@ -1240,7 +1240,7 @@ class TorrentManager(component.Component):
|
|||
def on_alert_add_torrent(self, alert):
|
||||
"""Alert handler for libtorrent add_torrent_alert"""
|
||||
if not alert.handle.is_valid():
|
||||
log.warning('Torrent handle is invalid: %s', alert.error.message())
|
||||
log.warning('Torrent handle is invalid!')
|
||||
return
|
||||
|
||||
try:
|
||||
|
@ -1351,8 +1351,10 @@ class TorrentManager(component.Component):
|
|||
torrent.set_tracker_status('Announce OK')
|
||||
|
||||
# Check for peer information from the tracker, if none then send a scrape request.
|
||||
torrent.get_lt_status()
|
||||
if torrent.status.num_complete == -1 or torrent.status.num_incomplete == -1:
|
||||
if (
|
||||
alert.handle.status().num_complete == -1
|
||||
or alert.handle.status().num_incomplete == -1
|
||||
):
|
||||
torrent.scrape_tracker()
|
||||
|
||||
def on_alert_tracker_announce(self, alert):
|
||||
|
@ -1387,18 +1389,7 @@ class TorrentManager(component.Component):
|
|||
log.debug(
|
||||
'Tracker Error Alert: %s [%s]', decode_bytes(alert.message()), error_message
|
||||
)
|
||||
# libtorrent 1.2 added endpoint struct to each tracker. to prevent false updates
|
||||
# we will need to verify that at least one endpoint to the errored tracker is working
|
||||
for tracker in torrent.handle.trackers():
|
||||
if tracker['url'] == alert.url:
|
||||
if any(
|
||||
endpoint['last_error']['value'] == 0
|
||||
for endpoint in tracker['endpoints']
|
||||
):
|
||||
torrent.set_tracker_status('Announce OK')
|
||||
else:
|
||||
torrent.set_tracker_status('Error: ' + error_message)
|
||||
break
|
||||
torrent.set_tracker_status('Error: ' + error_message)
|
||||
|
||||
def on_alert_storage_moved(self, alert):
|
||||
"""Alert handler for libtorrent storage_moved_alert"""
|
||||
|
@ -1472,9 +1463,7 @@ class TorrentManager(component.Component):
|
|||
return
|
||||
if torrent_id in self.torrents:
|
||||
# libtorrent add_torrent expects bencoded resume_data.
|
||||
self.resume_data[torrent_id] = lt.bencode(
|
||||
lt.write_resume_data(alert.params)
|
||||
)
|
||||
self.resume_data[torrent_id] = lt.bencode(alert.resume_data)
|
||||
|
||||
if torrent_id in self.waiting_on_resume_data:
|
||||
self.waiting_on_resume_data[torrent_id].callback(None)
|
||||
|
@ -1556,7 +1545,7 @@ class TorrentManager(component.Component):
|
|||
|
||||
# Try callback to prefetch_metadata method.
|
||||
try:
|
||||
d = self.prefetching_metadata[torrent_id].alert_deferred
|
||||
d = self.prefetching_metadata[torrent_id].defer
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
|
@ -1602,14 +1591,23 @@ class TorrentManager(component.Component):
|
|||
except RuntimeError:
|
||||
continue
|
||||
if torrent_id in self.torrents:
|
||||
self.torrents[torrent_id].status = t_status
|
||||
self.torrents[torrent_id].update_status(t_status)
|
||||
|
||||
self.handle_torrents_status_callback(self.torrents_status_requests.pop())
|
||||
|
||||
def on_alert_external_ip(self, alert):
|
||||
"""Alert handler for libtorrent external_ip_alert"""
|
||||
log.info('on_alert_external_ip: %s', alert.external_address)
|
||||
component.get('EventManager').emit(ExternalIPEvent(alert.external_address))
|
||||
"""Alert handler for libtorrent external_ip_alert
|
||||
|
||||
Note:
|
||||
The alert.message IPv4 address format is:
|
||||
'external IP received: 0.0.0.0'
|
||||
and IPv6 address format is:
|
||||
'external IP received: 0:0:0:0:0:0:0:0'
|
||||
"""
|
||||
|
||||
external_ip = decode_bytes(alert.message()).split(' ')[-1]
|
||||
log.info('on_alert_external_ip: %s', external_ip)
|
||||
component.get('EventManager').emit(ExternalIPEvent(external_ip))
|
||||
|
||||
def on_alert_performance(self, alert):
|
||||
"""Alert handler for libtorrent performance_alert"""
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2007,2008 Andrew Resch <andrewresch@gmail.com>
|
||||
#
|
||||
|
@ -6,10 +7,8 @@
|
|||
# See LICENSE for more details.
|
||||
#
|
||||
|
||||
import os
|
||||
import stat
|
||||
from __future__ import division, print_function, unicode_literals
|
||||
|
||||
from OpenSSL import crypto
|
||||
from OpenSSL.crypto import FILETYPE_PEM
|
||||
from twisted.internet.ssl import (
|
||||
AcceptableCiphers,
|
||||
|
@ -19,8 +18,6 @@ from twisted.internet.ssl import (
|
|||
TLSVersion,
|
||||
)
|
||||
|
||||
import deluge.configmanager
|
||||
|
||||
# A TLS ciphers list.
|
||||
# Sources for more information on TLS ciphers:
|
||||
# - https://wiki.mozilla.org/Security/Server_Side_TLS
|
||||
|
@ -80,57 +77,3 @@ def get_context_factory(cert_path, pkey_path):
|
|||
ctx.set_options(SSL_OP_NO_RENEGOTIATION)
|
||||
|
||||
return cert_options
|
||||
|
||||
|
||||
def check_ssl_keys():
|
||||
"""
|
||||
Check for SSL cert/key and create them if necessary
|
||||
"""
|
||||
ssl_dir = deluge.configmanager.get_config_dir('ssl')
|
||||
if not os.path.exists(ssl_dir):
|
||||
# The ssl folder doesn't exist so we need to create it
|
||||
os.makedirs(ssl_dir)
|
||||
generate_ssl_keys()
|
||||
else:
|
||||
for f in ('daemon.pkey', 'daemon.cert'):
|
||||
if not os.path.exists(os.path.join(ssl_dir, f)):
|
||||
generate_ssl_keys()
|
||||
break
|
||||
|
||||
|
||||
def generate_ssl_keys():
|
||||
"""
|
||||
This method generates a new SSL key/cert.
|
||||
"""
|
||||
digest = 'sha256'
|
||||
|
||||
# Generate key pair
|
||||
pkey = crypto.PKey()
|
||||
pkey.generate_key(crypto.TYPE_RSA, 2048)
|
||||
|
||||
# Generate cert request
|
||||
req = crypto.X509Req()
|
||||
subj = req.get_subject()
|
||||
setattr(subj, 'CN', 'Deluge Daemon')
|
||||
req.set_pubkey(pkey)
|
||||
req.sign(pkey, digest)
|
||||
|
||||
# Generate certificate
|
||||
cert = crypto.X509()
|
||||
cert.set_serial_number(0)
|
||||
cert.gmtime_adj_notBefore(0)
|
||||
cert.gmtime_adj_notAfter(60 * 60 * 24 * 365 * 3) # Three Years
|
||||
cert.set_issuer(req.get_subject())
|
||||
cert.set_subject(req.get_subject())
|
||||
cert.set_pubkey(req.get_pubkey())
|
||||
cert.sign(pkey, digest)
|
||||
|
||||
# Write out files
|
||||
ssl_dir = deluge.configmanager.get_config_dir('ssl')
|
||||
with open(os.path.join(ssl_dir, 'daemon.pkey'), 'wb') as _file:
|
||||
_file.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey))
|
||||
with open(os.path.join(ssl_dir, 'daemon.cert'), 'wb') as _file:
|
||||
_file.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert))
|
||||
# Make the files only readable by this user
|
||||
for f in ('daemon.pkey', 'daemon.cert'):
|
||||
os.chmod(os.path.join(ssl_dir, f), stat.S_IREAD | stat.S_IWRITE)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2010 John Garland <johnnybg+deluge@gmail.com>
|
||||
#
|
||||
|
@ -6,13 +7,12 @@
|
|||
# See LICENSE for more details.
|
||||
#
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import inspect
|
||||
import re
|
||||
import warnings
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Coroutine, TypeVar
|
||||
|
||||
from twisted.internet import defer
|
||||
|
||||
|
||||
def proxy(proxy_func):
|
||||
|
@ -56,7 +56,7 @@ def overrides(*args):
|
|||
if inspect.isfunction(args[0]):
|
||||
return _overrides(stack, args[0])
|
||||
else:
|
||||
# One or more classes are specified, so return a function that will be
|
||||
# One or more classes are specifed, so return a function that will be
|
||||
# called with the real function as argument
|
||||
def ret_func(func, **kwargs):
|
||||
return _overrides(stack, func, explicit_base_classes=args)
|
||||
|
@ -107,7 +107,7 @@ def _overrides(stack, method, explicit_base_classes=None):
|
|||
for c in base_classes + check_classes:
|
||||
classes[c] = get_class(c)
|
||||
|
||||
# Verify that the explicit override class is one of base classes
|
||||
# Verify that the excplicit override class is one of base classes
|
||||
if explicit_base_classes:
|
||||
from itertools import product
|
||||
|
||||
|
@ -127,7 +127,7 @@ def _overrides(stack, method, explicit_base_classes=None):
|
|||
% (
|
||||
method.__name__,
|
||||
cls,
|
||||
f'File: {stack[1][1]}:{stack[1][2]}',
|
||||
'File: %s:%s' % (stack[1][1], stack[1][2]),
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -137,7 +137,7 @@ def _overrides(stack, method, explicit_base_classes=None):
|
|||
% (
|
||||
method.__name__,
|
||||
check_classes,
|
||||
f'File: {stack[1][1]}:{stack[1][2]}',
|
||||
'File: %s:%s' % (stack[1][1], stack[1][2]),
|
||||
)
|
||||
)
|
||||
return method
|
||||
|
@ -146,7 +146,7 @@ def _overrides(stack, method, explicit_base_classes=None):
|
|||
def deprecated(func):
|
||||
"""This is a decorator which can be used to mark function as deprecated.
|
||||
|
||||
It will result in a warning being emitted when the function is used.
|
||||
It will result in a warning being emmitted when the function is used.
|
||||
|
||||
"""
|
||||
|
||||
|
@ -154,7 +154,7 @@ def deprecated(func):
|
|||
def depr_func(*args, **kwargs):
|
||||
warnings.simplefilter('always', DeprecationWarning) # Turn off filter
|
||||
warnings.warn(
|
||||
f'Call to deprecated function {func.__name__}.',
|
||||
'Call to deprecated function {}.'.format(func.__name__),
|
||||
category=DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
@ -162,74 +162,3 @@ def deprecated(func):
|
|||
return func(*args, **kwargs)
|
||||
|
||||
return depr_func
|
||||
|
||||
|
||||
class CoroutineDeferred(defer.Deferred):
|
||||
"""Wraps a coroutine in a Deferred.
|
||||
It will dynamically pass through the underlying coroutine without wrapping where apporpriate.
|
||||
"""
|
||||
|
||||
def __init__(self, coro: Coroutine):
|
||||
# Delay this import to make sure a reactor was installed first
|
||||
from twisted.internet import reactor
|
||||
|
||||
super().__init__()
|
||||
self.coro = coro
|
||||
self.awaited = None
|
||||
self.activate_deferred = reactor.callLater(0, self.activate)
|
||||
|
||||
def __await__(self):
|
||||
if self.awaited in [None, True]:
|
||||
self.awaited = True
|
||||
return self.coro.__await__()
|
||||
# Already in deferred mode
|
||||
return super().__await__()
|
||||
|
||||
def activate(self):
|
||||
"""If the result wasn't awaited before the next context switch, we turn it into a deferred."""
|
||||
if self.awaited is None:
|
||||
self.awaited = False
|
||||
try:
|
||||
d = defer.Deferred.fromCoroutine(self.coro)
|
||||
except AttributeError:
|
||||
# Fallback for Twisted <= 21.2 without fromCoroutine
|
||||
d = defer.ensureDeferred(self.coro)
|
||||
d.chainDeferred(self)
|
||||
|
||||
def _callback_activate(self):
|
||||
"""Verify awaited status before calling activate."""
|
||||
assert not self.awaited, 'Cannot add callbacks to an already awaited coroutine.'
|
||||
self.activate()
|
||||
|
||||
def addCallback(self, *args, **kwargs): # noqa: N802
|
||||
self._callback_activate()
|
||||
return super().addCallback(*args, **kwargs)
|
||||
|
||||
def addCallbacks(self, *args, **kwargs): # noqa: N802
|
||||
self._callback_activate()
|
||||
return super().addCallbacks(*args, **kwargs)
|
||||
|
||||
def addErrback(self, *args, **kwargs): # noqa: N802
|
||||
self._callback_activate()
|
||||
return super().addErrback(*args, **kwargs)
|
||||
|
||||
def addBoth(self, *args, **kwargs): # noqa: N802
|
||||
self._callback_activate()
|
||||
return super().addBoth(*args, **kwargs)
|
||||
|
||||
|
||||
_RetT = TypeVar('_RetT')
|
||||
|
||||
|
||||
def maybe_coroutine(
|
||||
f: Callable[..., Coroutine[Any, Any, _RetT]],
|
||||
) -> 'Callable[..., defer.Deferred[_RetT]]':
|
||||
"""Wraps a coroutine function to make it usable as a normal function that returns a Deferred."""
|
||||
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
# Uncomment for quick testing to make sure CoroutineDeferred magic isn't at fault
|
||||
# return defer.ensureDeferred(f(*args, **kwargs))
|
||||
return CoroutineDeferred(f(*args, **kwargs))
|
||||
|
||||
return wrapper
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2008 Andrew Resch <andrewresch@gmail.com>
|
||||
# Copyright (C) 2011 Pedro Algarvio <pedro@algarvio.me>
|
||||
|
@ -8,15 +9,18 @@
|
|||
#
|
||||
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
|
||||
class DelugeError(Exception):
|
||||
def __new__(cls, *args, **kwargs):
|
||||
inst = super().__new__(cls, *args, **kwargs)
|
||||
inst = super(DelugeError, cls).__new__(cls, *args, **kwargs)
|
||||
inst._args = args
|
||||
inst._kwargs = kwargs
|
||||
return inst
|
||||
|
||||
def __init__(self, message=None):
|
||||
super().__init__(message)
|
||||
super(DelugeError, self).__init__(message)
|
||||
self.message = message
|
||||
|
||||
def __str__(self):
|
||||
|
@ -41,12 +45,12 @@ class InvalidPathError(DelugeError):
|
|||
|
||||
class WrappedException(DelugeError):
|
||||
def __init__(self, message, exception_type, traceback):
|
||||
super().__init__(message)
|
||||
super(WrappedException, self).__init__(message)
|
||||
self.type = exception_type
|
||||
self.traceback = traceback
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.message}\n{self.traceback}'
|
||||
return '%s\n%s' % (self.message, self.traceback)
|
||||
|
||||
|
||||
class _ClientSideRecreateError(DelugeError):
|
||||
|
@ -60,7 +64,7 @@ class IncompatibleClient(_ClientSideRecreateError):
|
|||
'Your deluge client is not compatible with the daemon. '
|
||||
'Please upgrade your client to %(daemon_version)s'
|
||||
) % {'daemon_version': self.daemon_version}
|
||||
super().__init__(message=msg)
|
||||
super(IncompatibleClient, self).__init__(message=msg)
|
||||
|
||||
|
||||
class NotAuthorizedError(_ClientSideRecreateError):
|
||||
|
@ -69,14 +73,14 @@ class NotAuthorizedError(_ClientSideRecreateError):
|
|||
'current_level': current_level,
|
||||
'required_level': required_level,
|
||||
}
|
||||
super().__init__(message=msg)
|
||||
super(NotAuthorizedError, self).__init__(message=msg)
|
||||
self.current_level = current_level
|
||||
self.required_level = required_level
|
||||
|
||||
|
||||
class _UsernameBasedPasstroughError(_ClientSideRecreateError):
|
||||
def __init__(self, message, username):
|
||||
super().__init__(message)
|
||||
super(_UsernameBasedPasstroughError, self).__init__(message)
|
||||
self.username = username
|
||||
|
||||
|
||||
|
@ -90,7 +94,3 @@ class AuthenticationRequired(_UsernameBasedPasstroughError):
|
|||
|
||||
class AuthManagerError(_UsernameBasedPasstroughError):
|
||||
pass
|
||||
|
||||
|
||||
class LibtorrentImportError(ImportError):
|
||||
pass
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2009 Andrew Resch <andrewresch@gmail.com>
|
||||
#
|
||||
|
@ -13,6 +14,9 @@ This module describes the types of events that can be generated by the daemon
|
|||
and subsequently emitted to the clients.
|
||||
|
||||
"""
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import six
|
||||
|
||||
known_events = {}
|
||||
|
||||
|
@ -23,12 +27,12 @@ class DelugeEventMetaClass(type):
|
|||
"""
|
||||
|
||||
def __init__(cls, name, bases, dct): # pylint: disable=bad-mcs-method-argument
|
||||
super().__init__(name, bases, dct)
|
||||
super(DelugeEventMetaClass, cls).__init__(name, bases, dct)
|
||||
if name != 'DelugeEvent':
|
||||
known_events[name] = cls
|
||||
|
||||
|
||||
class DelugeEvent(metaclass=DelugeEventMetaClass):
|
||||
class DelugeEvent(six.with_metaclass(DelugeEventMetaClass, object)):
|
||||
"""
|
||||
The base class for all events.
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2009 Andrew Resch <andrewresch@gmail.com>
|
||||
#
|
||||
|
@ -6,7 +7,9 @@
|
|||
# See LICENSE for more details.
|
||||
#
|
||||
|
||||
import email.message
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import cgi
|
||||
import logging
|
||||
import os.path
|
||||
import zlib
|
||||
|
@ -16,7 +19,7 @@ from twisted.internet.defer import Deferred
|
|||
from twisted.python.failure import Failure
|
||||
from twisted.web import client, http
|
||||
from twisted.web._newclient import HTTPClientParser
|
||||
from twisted.web.error import Error, PageRedirect
|
||||
from twisted.web.error import PageRedirect
|
||||
from twisted.web.http_headers import Headers
|
||||
from twisted.web.iweb import IAgent
|
||||
from zope.interface import implementer
|
||||
|
@ -37,11 +40,11 @@ class CompressionDecoderProtocol(client._GzipProtocol):
|
|||
"""A compression decoder protocol for CompressionDecoder."""
|
||||
|
||||
def __init__(self, protocol, response):
|
||||
super().__init__(protocol, response)
|
||||
super(CompressionDecoderProtocol, self).__init__(protocol, response)
|
||||
self._zlibDecompress = zlib.decompressobj(32 + zlib.MAX_WBITS)
|
||||
|
||||
|
||||
class BodyHandler(HTTPClientParser):
|
||||
class BodyHandler(HTTPClientParser, object):
|
||||
"""An HTTP parser that saves the response to a file."""
|
||||
|
||||
def __init__(self, request, finished, length, agent, encoding=None):
|
||||
|
@ -53,7 +56,7 @@ class BodyHandler(HTTPClientParser):
|
|||
length (int): The length of the response.
|
||||
agent (t.w.i.IAgent): The agent from which the request was sent.
|
||||
"""
|
||||
super().__init__(request, finished)
|
||||
super(BodyHandler, self).__init__(request, finished)
|
||||
self.agent = agent
|
||||
self.finished = finished
|
||||
self.total_length = length
|
||||
|
@ -73,12 +76,12 @@ class BodyHandler(HTTPClientParser):
|
|||
with open(self.agent.filename, 'wb') as _file:
|
||||
_file.write(self.data)
|
||||
self.finished.callback(self.agent.filename)
|
||||
self.state = 'DONE'
|
||||
self.state = u'DONE'
|
||||
HTTPClientParser.connectionLost(self, reason)
|
||||
|
||||
|
||||
@implementer(IAgent)
|
||||
class HTTPDownloaderAgent:
|
||||
class HTTPDownloaderAgent(object):
|
||||
"""A File Downloader Agent."""
|
||||
|
||||
def __init__(
|
||||
|
@ -122,9 +125,6 @@ class HTTPDownloaderAgent:
|
|||
location = response.headers.getRawHeaders(b'location')[0]
|
||||
error = PageRedirect(response.code, location=location)
|
||||
finished.errback(Failure(error))
|
||||
elif response.code >= 400:
|
||||
error = Error(response.code)
|
||||
finished.errback(Failure(error))
|
||||
else:
|
||||
headers = response.headers
|
||||
body_length = int(headers.getRawHeaders(b'content-length', default=[0])[0])
|
||||
|
@ -133,10 +133,9 @@ class HTTPDownloaderAgent:
|
|||
content_disp = headers.getRawHeaders(b'content-disposition')[0].decode(
|
||||
'utf-8'
|
||||
)
|
||||
message = email.message.EmailMessage()
|
||||
message['content-disposition'] = content_disp
|
||||
new_file_name = message.get_filename()
|
||||
if new_file_name:
|
||||
content_disp_params = cgi.parse_header(content_disp)[1]
|
||||
if 'filename' in content_disp_params:
|
||||
new_file_name = content_disp_params['filename']
|
||||
new_file_name = sanitise_filename(new_file_name)
|
||||
new_file_name = os.path.join(
|
||||
os.path.split(self.filename)[0], new_file_name
|
||||
|
@ -147,20 +146,14 @@ class HTTPDownloaderAgent:
|
|||
fileext = os.path.splitext(new_file_name)[1]
|
||||
while os.path.isfile(new_file_name):
|
||||
# Increment filename if already exists
|
||||
new_file_name = f'{fileroot}-{count}{fileext}'
|
||||
new_file_name = '%s-%s%s' % (fileroot, count, fileext)
|
||||
count += 1
|
||||
|
||||
self.filename = new_file_name
|
||||
|
||||
cont_type_header = headers.getRawHeaders(b'content-type')[0].decode()
|
||||
message = email.message.EmailMessage()
|
||||
message['content-type'] = cont_type_header
|
||||
cont_type = message.get_content_type()
|
||||
params = message['content-type'].params
|
||||
# Only re-ecode text content types.
|
||||
encoding = None
|
||||
if cont_type.startswith('text/'):
|
||||
encoding = params.get('charset', None)
|
||||
cont_type = headers.getRawHeaders(b'content-type')[0].decode()
|
||||
params = cgi.parse_header(cont_type)[1]
|
||||
encoding = params.get('charset', None)
|
||||
response.deliverBody(
|
||||
BodyHandler(response.request, finished, body_length, self, encoding)
|
||||
)
|
||||
|
|
6234
deluge/i18n/af.po
6234
deluge/i18n/af.po
File diff suppressed because it is too large
Load diff
8849
deluge/i18n/ar.po
8849
deluge/i18n/ar.po
File diff suppressed because it is too large
Load diff
9633
deluge/i18n/ast.po
9633
deluge/i18n/ast.po
File diff suppressed because it is too large
Load diff
9278
deluge/i18n/be.po
9278
deluge/i18n/be.po
File diff suppressed because it is too large
Load diff
9714
deluge/i18n/bg.po
9714
deluge/i18n/bg.po
File diff suppressed because it is too large
Load diff
8267
deluge/i18n/bn.po
8267
deluge/i18n/bn.po
File diff suppressed because it is too large
Load diff
8308
deluge/i18n/bs.po
8308
deluge/i18n/bs.po
File diff suppressed because it is too large
Load diff
10364
deluge/i18n/ca.po
10364
deluge/i18n/ca.po
File diff suppressed because it is too large
Load diff
9093
deluge/i18n/cs.po
9093
deluge/i18n/cs.po
File diff suppressed because it is too large
Load diff
8336
deluge/i18n/cy.po
8336
deluge/i18n/cy.po
File diff suppressed because it is too large
Load diff
9396
deluge/i18n/da.po
9396
deluge/i18n/da.po
File diff suppressed because it is too large
Load diff
10065
deluge/i18n/de.po
10065
deluge/i18n/de.po
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
9864
deluge/i18n/el.po
9864
deluge/i18n/el.po
File diff suppressed because it is too large
Load diff
10260
deluge/i18n/en_AU.po
10260
deluge/i18n/en_AU.po
File diff suppressed because it is too large
Load diff
10406
deluge/i18n/en_CA.po
10406
deluge/i18n/en_CA.po
File diff suppressed because it is too large
Load diff
9700
deluge/i18n/en_GB.po
9700
deluge/i18n/en_GB.po
File diff suppressed because it is too large
Load diff
8311
deluge/i18n/eo.po
8311
deluge/i18n/eo.po
File diff suppressed because it is too large
Load diff
9935
deluge/i18n/es.po
9935
deluge/i18n/es.po
File diff suppressed because it is too large
Load diff
9770
deluge/i18n/et.po
9770
deluge/i18n/et.po
File diff suppressed because it is too large
Load diff
7864
deluge/i18n/eu.po
7864
deluge/i18n/eu.po
File diff suppressed because it is too large
Load diff
8440
deluge/i18n/fa.po
8440
deluge/i18n/fa.po
File diff suppressed because it is too large
Load diff
9311
deluge/i18n/fi.po
9311
deluge/i18n/fi.po
File diff suppressed because it is too large
Load diff
6217
deluge/i18n/fo.po
6217
deluge/i18n/fo.po
File diff suppressed because it is too large
Load diff
9844
deluge/i18n/fr.po
9844
deluge/i18n/fr.po
File diff suppressed because it is too large
Load diff
9198
deluge/i18n/fy.po
9198
deluge/i18n/fy.po
File diff suppressed because it is too large
Load diff
6214
deluge/i18n/ga.po
6214
deluge/i18n/ga.po
File diff suppressed because it is too large
Load diff
9627
deluge/i18n/gl.po
9627
deluge/i18n/gl.po
File diff suppressed because it is too large
Load diff
8839
deluge/i18n/he.po
8839
deluge/i18n/he.po
File diff suppressed because it is too large
Load diff
9312
deluge/i18n/hi.po
9312
deluge/i18n/hi.po
File diff suppressed because it is too large
Load diff
9431
deluge/i18n/hr.po
9431
deluge/i18n/hr.po
File diff suppressed because it is too large
Load diff
9046
deluge/i18n/hu.po
9046
deluge/i18n/hu.po
File diff suppressed because it is too large
Load diff
8549
deluge/i18n/id.po
8549
deluge/i18n/id.po
File diff suppressed because it is too large
Load diff
9613
deluge/i18n/is.po
9613
deluge/i18n/is.po
File diff suppressed because it is too large
Load diff
9123
deluge/i18n/it.po
9123
deluge/i18n/it.po
File diff suppressed because it is too large
Load diff
8252
deluge/i18n/iu.po
8252
deluge/i18n/iu.po
File diff suppressed because it is too large
Load diff
9461
deluge/i18n/ja.po
9461
deluge/i18n/ja.po
File diff suppressed because it is too large
Load diff
8867
deluge/i18n/ka.po
8867
deluge/i18n/ka.po
File diff suppressed because it is too large
Load diff
9831
deluge/i18n/kk.po
9831
deluge/i18n/kk.po
File diff suppressed because it is too large
Load diff
6222
deluge/i18n/km.po
6222
deluge/i18n/km.po
File diff suppressed because it is too large
Load diff
8365
deluge/i18n/kn.po
8365
deluge/i18n/kn.po
File diff suppressed because it is too large
Load diff
9411
deluge/i18n/ko.po
9411
deluge/i18n/ko.po
File diff suppressed because it is too large
Load diff
8300
deluge/i18n/ku.po
8300
deluge/i18n/ku.po
File diff suppressed because it is too large
Load diff
6214
deluge/i18n/ky.po
6214
deluge/i18n/ky.po
File diff suppressed because it is too large
Load diff
8277
deluge/i18n/la.po
8277
deluge/i18n/la.po
File diff suppressed because it is too large
Load diff
|
@ -1,7 +1,10 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# This file is public domain.
|
||||
#
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
# Language code for this installation. All choices can be found here:
|
||||
# http://www.i18nguy.com/unicode/language-identifiers.html
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
|
6214
deluge/i18n/lb.po
6214
deluge/i18n/lb.po
File diff suppressed because it is too large
Load diff
10252
deluge/i18n/lt.po
10252
deluge/i18n/lt.po
File diff suppressed because it is too large
Load diff
9669
deluge/i18n/lv.po
9669
deluge/i18n/lv.po
File diff suppressed because it is too large
Load diff
8917
deluge/i18n/mk.po
8917
deluge/i18n/mk.po
File diff suppressed because it is too large
Load diff
6214
deluge/i18n/ml.po
6214
deluge/i18n/ml.po
File diff suppressed because it is too large
Load diff
6214
deluge/i18n/mo.po
6214
deluge/i18n/mo.po
File diff suppressed because it is too large
Load diff
9815
deluge/i18n/ms.po
9815
deluge/i18n/ms.po
File diff suppressed because it is too large
Load diff
6222
deluge/i18n/nap.po
6222
deluge/i18n/nap.po
File diff suppressed because it is too large
Load diff
9710
deluge/i18n/nb.po
9710
deluge/i18n/nb.po
File diff suppressed because it is too large
Load diff
5335
deluge/i18n/nds.po
5335
deluge/i18n/nds.po
File diff suppressed because it is too large
Load diff
9510
deluge/i18n/nl.po
9510
deluge/i18n/nl.po
File diff suppressed because it is too large
Load diff
6236
deluge/i18n/nn.po
6236
deluge/i18n/nn.po
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue