Compare commits

..

75 Commits

Author SHA1 Message Date
Mateusz Gruszczyński
4d3322b87c multilang_2 2026-05-29 13:27:58 +02:00
Mateusz Gruszczyński
22e2983dc2 multilang_1 2026-05-29 13:18:53 +02:00
gru
4c8debb103 Merge pull request 'ux changes' (#10) from ux_small_fixes1 into master
Reviewed-on: #10
2026-05-29 11:35:01 +02:00
Mateusz Gruszczyński
15078c30da ux changes 2026-05-29 11:26:28 +02:00
gru
5eeb0da092 Merge pull request 'easteregg' (#9) from easteregg_marcin into master
Reviewed-on: #9
2026-05-28 22:48:05 +02:00
Mateusz Gruszczyński
8c1cc23a8d easteregg 2026-05-28 22:44:06 +02:00
Mateusz Gruszczyński
0408f7859e prank 2026-05-28 22:24:39 +02:00
gru
05a26a6cfe Merge pull request 'multilang_and_other' (#8) from multilang_and_other into master
Reviewed-on: #8
2026-05-28 22:08:32 +02:00
Mateusz Gruszczyński
76ffe32319 states fix 2026-05-28 15:41:41 +02:00
Mateusz Gruszczyński
e4310797c8 small changes 2026-05-28 15:35:34 +02:00
Mateusz Gruszczyński
1651075f40 chages in profiles 2026-05-28 09:01:14 +02:00
Mateusz Gruszczyński
a611113d2a chages in smart queue 2026-05-28 07:21:44 +02:00
Mateusz Gruszczyński
46fec57ab8 revert planner js 2026-05-27 23:01:31 +02:00
Mateusz Gruszczyński
1768b30df6 revert planner js 2026-05-27 22:56:12 +02:00
Mateusz Gruszczyński
31895f9783 rebuild rtorrent config 2026-05-27 22:53:22 +02:00
Mateusz Gruszczyński
01c5c54c10 rebuild rtorrent config 2026-05-27 22:42:37 +02:00
Mateusz Gruszczyński
80c71c8d79 rebuild rtorrent config 2026-05-27 22:31:22 +02:00
Mateusz Gruszczyński
a8adee0f2f light poller commit3 2026-05-27 15:13:22 +02:00
Mateusz Gruszczyński
054c9122f8 light poller commit1 2026-05-27 14:58:26 +02:00
gru
4075e934eb Merge pull request 'profles_and_ux' (#7) from profles_and_ux into master
Reviewed-on: #7
2026-05-27 14:38:06 +02:00
Mateusz Gruszczyński
1eb3aeff6c ux, and themes 2026-05-26 22:42:14 +02:00
Mateusz Gruszczyński
869af8756f ux, and themes 2026-05-26 22:31:48 +02:00
Mateusz Gruszczyński
f0da24f484 db cleanup 2026-05-26 09:43:38 +02:00
Mateusz Gruszczyński
514482f0b5 changes in db 2026-05-26 09:38:12 +02:00
Mateusz Gruszczyński
70a9344cdd changes in db 2026-05-26 09:25:47 +02:00
Mateusz Gruszczyński
8268ad87cf changes in ux 2026-05-26 09:15:37 +02:00
Mateusz Gruszczyński
32c780793b fix in js's 2026-05-26 09:07:34 +02:00
Mateusz Gruszczyński
92d870878f big changes in profiles and users 2026-05-26 09:00:29 +02:00
Mateusz Gruszczyński
629b06a9df fix for commit1 2026-05-26 08:28:41 +02:00
Mateusz Gruszczyński
5ab750226a commit_1 2026-05-26 08:25:17 +02:00
gru
77a161a7f6 Merge pull request 'tiny_auth_support' (#6) from tiny_auth_support into master
Reviewed-on: #6
2026-05-26 08:04:52 +02:00
Mateusz Gruszczyński
81d9556443 urgent fixes2 2026-05-25 22:32:36 +02:00
Mateusz Gruszczyński
0ee0f3424c urgent fixes 2026-05-25 22:21:29 +02:00
Mateusz Gruszczyński
680a673a9a allow health without auth 2026-05-25 21:50:29 +02:00
Mateusz Gruszczyński
1df01e8cc6 changes in auth logic 2026-05-25 15:44:35 +02:00
Mateusz Gruszczyński
109811c024 bypass profile select 2026-05-25 10:22:14 +02:00
Mateusz Gruszczyński
2e2d747fa2 bypass profile select 2026-05-25 10:15:04 +02:00
Mateusz Gruszczyński
ff7d836b77 logout inactive on exteranal auth 2026-05-25 10:07:51 +02:00
Mateusz Gruszczyński
9021b09bc5 auth providers 2026-05-25 09:58:02 +02:00
Mateusz Gruszczyński
58d1c7a761 auth providers 2026-05-25 09:21:06 +02:00
Mateusz Gruszczyński
93aaca553b auth providers 2026-05-25 09:09:41 +02:00
Mateusz Gruszczyński
352c53617c auth providers 2026-05-25 08:38:08 +02:00
gru
f79e072610 Merge pull request 'compact_1' (#5) from compact_v into master
Reviewed-on: #5
2026-05-25 08:09:47 +02:00
Mateusz Gruszczyński
e298edd1e3 compact_1 2026-05-25 07:21:38 +02:00
gru
17b497a32b Merge pull request 'mobile_torrent_details' (#4) from mobile_torrent_details into master
Reviewed-on: #4
2026-05-25 07:11:29 +02:00
Mateusz Gruszczyński
80bb921148 mobile torrent details 2026-05-24 13:43:45 +02:00
Mateusz Gruszczyński
f8eddd6fd5 mobile torrent details 2026-05-24 13:40:54 +02:00
Mateusz Gruszczyński
778717d8b3 mobile torrent details 2026-05-24 13:32:46 +02:00
Mateusz Gruszczyński
d44cbe2429 mobile torrent details 2026-05-24 13:23:28 +02:00
gru
0398dd9d39 Merge pull request 'fixes' (#3) from fixes into master
Reviewed-on: #3
2026-05-24 12:54:07 +02:00
Mateusz Gruszczyński
a9ebf901ab select all fix 2026-05-24 12:48:22 +02:00
Mateusz Gruszczyński
173ac3951a fix select/unselect 2026-05-24 12:25:45 +02:00
Mateusz Gruszczyński
5a11730ee0 harder post-ceheck 2026-05-24 11:24:26 +02:00
Mateusz Gruszczyński
9caa155324 post-check 2026-05-24 11:04:42 +02:00
gru
8553615fbf Merge pull request 'themes' (#2) from themes into master
Reviewed-on: #2
2026-05-23 00:32:07 +02:00
Mateusz Gruszczyński
953616e126 autorefresh files logic 2026-05-22 23:24:40 +02:00
Mateusz Gruszczyński
57e45ea858 block file info for incomplete files 2026-05-22 23:17:28 +02:00
Mateusz Gruszczyński
c69142e328 block file info for incomplete files 2026-05-22 23:03:30 +02:00
Mateusz Gruszczyński
7c0a4ff703 fix in download torrents 2026-05-22 14:14:07 +02:00
Mateusz Gruszczyński
00a3831386 fix in download torrents 2026-05-22 14:04:26 +02:00
Mateusz Gruszczyński
6aea0c1ad9 fix in download torrents 2026-05-22 13:53:10 +02:00
Mateusz Gruszczyński
0a0ee9e8e5 fix in download torrents 2026-05-22 13:43:06 +02:00
Mateusz Gruszczyński
d383d89994 fix in download torrents 2026-05-22 13:32:36 +02:00
Mateusz Gruszczyński
cae6d4163b new themes 2026-05-22 09:02:07 +02:00
Mateusz Gruszczyński
4956322677 new themes 2026-05-22 08:56:27 +02:00
Mateusz Gruszczyński
c62640ba99 fix ion import 2026-05-21 22:12:33 +02:00
Mateusz Gruszczyński
e27ffbb6e2 temporary_link feature 2026-05-21 22:06:52 +02:00
Mateusz Gruszczyński
b772c97d50 temporary_link feature 2026-05-21 22:05:08 +02:00
Mateusz Gruszczyński
cb48735178 better pdf ux 2026-05-21 12:34:18 +02:00
Mateusz Gruszczyński
9142590c79 media info - read pdf 2026-05-21 10:39:19 +02:00
Mateusz Gruszczyński
c2948ea277 media info - read txt and images 2026-05-21 10:18:24 +02:00
Mateusz Gruszczyński
d0026ab7f9 media info - fix file read 2026-05-21 10:05:55 +02:00
Mateusz Gruszczyński
b0b3497eec media info - better imports 2026-05-21 10:01:29 +02:00
Mateusz Gruszczyński
4e009ccf05 media info 2026-05-21 09:52:25 +02:00
Mateusz Gruszczyński
bd9be0d11c media info 2026-05-21 09:49:50 +02:00
77 changed files with 11457 additions and 850 deletions

View File

@@ -3,8 +3,8 @@ PYTORRENT_DB_PATH=data/pytorrent.sqlite3
PYTORRENT_HOST=0.0.0.0 PYTORRENT_HOST=0.0.0.0
PYTORRENT_PORT=8090 PYTORRENT_PORT=8090
PYTORRENT_DEBUG=0 PYTORRENT_DEBUG=0
PYTORRENT_POLL_INTERVAL=0.5 PYTORRENT_POLL_INTERVAL=1
MIN_POLL_INTERVAL_SECONDS=0.5 MIN_POLL_INTERVAL_SECONDS=1
PYTORRENT_WORKERS=16 PYTORRENT_WORKERS=16
PYTORRENT_GEOIP_DB=data/GeoLite2-City.mmdb PYTORRENT_GEOIP_DB=data/GeoLite2-City.mmdb
PYTORRENT_ALLOW_UNSAFE_WERKZEUG=0 PYTORRENT_ALLOW_UNSAFE_WERKZEUG=0
@@ -13,14 +13,6 @@ PYTORRENT_SCGI_RETRIES=8
# css/js libs # css/js libs
PYTORRENT_USE_OFFLINE_LIBS=true PYTORRENT_USE_OFFLINE_LIBS=true
# python -m pytorrent.cli reset-password admin new_Pass
PYTORRENT_AUTH_ENABLE=false
# Reverse proxy / HTTPS
PYTORRENT_PROXY_FIX_ENABLE=false
PYTORRENT_SESSION_COOKIE_SECURE=false
# PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS=https://your-domain.com
# Retention / Smart Queue # Retention / Smart Queue
PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS=90 PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS=90
PYTORRENT_JOBS_RETENTION_DAYS=30 PYTORRENT_JOBS_RETENTION_DAYS=30
@@ -41,4 +33,39 @@ PYTORRENT_LOG_DIR=data/logs
PYTORRENT_LOG_RETENTION_HOURS=24 PYTORRENT_LOG_RETENTION_HOURS=24
PYTORRENT_GUNICORN_ACCESS_LOG=data/logs/gunicorn-access.log PYTORRENT_GUNICORN_ACCESS_LOG=data/logs/gunicorn-access.log
PYTORRENT_GUNICORN_ERROR_LOG=data/logs/gunicorn-error.log PYTORRENT_GUNICORN_ERROR_LOG=data/logs/gunicorn-error.log
PYTORRENT_GUNICORN_LOG_LEVEL=info PYTORRENT_GUNICORN_LOG_LEVEL=info
#### AUTH
# python -m pytorrent.cli reset-password admin new_Pass
PYTORRENT_AUTH_ENABLE=false
# Authentication provider
# Available variants:
# - local = built-in login screen with username/password
# - tinyauth = external auth via Tinyauth / reverse proxy headers
# - proxy = generic external reverse proxy auth
PYTORRENT_AUTH_PROVIDER=tinyauth
# Headers passed by Tinyauth
PYTORRENT_AUTH_PROXY_USER_HEADER=Remote-User
# Headers passed by external reverse proxy
#PYTORRENT_AUTH_PROXY_USER_HEADER=X-Forwarded-User
# Auto-create user when authenticated externally but missing in DB
PYTORRENT_AUTH_PROXY_AUTO_CREATE=true
# Defaults for auto-created users
PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=admin
PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION=rw
# Reverse proxy / HTTPS
PYTORRENT_PROXY_FIX_ENABLE=true
PYTORRENT_SESSION_COOKIE_SECURE=false
#PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS=https://pytorrent.domain.com
#PYTORRENT_API_ALLOWED_ORIGINS=https://pytorrent.domain.com
# bypass auth on specific hosts (ex. local ip)
PYTORRENT_AUTH_BYPASS_HOSTS=10.11.1.11:8090,10.11.1.11
PYTORRENT_AUTH_BYPASS_USER=admin

4
.gitignore vendored
View File

@@ -42,4 +42,6 @@ data/logs/*
todo.txt todo.txt
pytorrent/static/libs/* pytorrent/static/libs/*
!pytorrent/static/libs/pytorrent-themes/
!pytorrent/static/libs/pytorrent-themes/**

233
auth.md Normal file
View File

@@ -0,0 +1,233 @@
# Authentication configuration
## Overview
pyTorrent supports three authentication modes:
- `local` - built-in pyTorrent login screen with username and password.
- `tinyauth` - external authentication through Tinyauth and a trusted reverse proxy username header.
- `proxy` - generic external authentication through a trusted reverse proxy username header.
When `tinyauth` or `proxy` is used, pyTorrent does not show the local login form. The reverse proxy must authenticate the request first and pass the authenticated username to pyTorrent in the configured header.
## Environment variables
```env
PYTORRENT_AUTH_ENABLE=true
# local | tinyauth | proxy
PYTORRENT_AUTH_PROVIDER=tinyauth
# Header that contains the authenticated username.
PYTORRENT_AUTH_PROXY_USER_HEADER=Remote-User
# Create a local pyTorrent user when the external user is missing.
PYTORRENT_AUTH_PROXY_AUTO_CREATE=true
# Role for auto-created external users: user | admin
PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=admin
# Permission for auto-created role=user accounts: none | ro | rw | full
# rw is accepted as an alias of full.
# Admin users ignore this value and can access all profiles.
PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION=rw
# Optional: trusted direct-IP/local hosts that should skip pyTorrent auth.
# Use this only on private networks, never on public proxy hostnames.
PYTORRENT_AUTH_BYPASS_HOSTS=10.11.1.11:8090,10.11.1.11
# Existing active user used by bypassed requests. Defaults to admin.
PYTORRENT_AUTH_BYPASS_USER=admin
```
## Reverse proxy origin checks
pyTorrent blocks unsafe API requests when the browser `Origin`/`Referer` does not match the application origin. Behind HTTPS reverse proxy this requires either correct forwarded headers or an explicit API origin allowlist.
Recommended variables for reverse proxy mode:
```env
PYTORRENT_PROXY_FIX_ENABLE=true
PYTORRENT_SESSION_COOKIE_SECURE=true
PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS=https://pytorrent.example.com
PYTORRENT_API_ALLOWED_ORIGINS=https://pytorrent.example.com
```
`PYTORRENT_API_ALLOWED_ORIGINS` accepts a comma-separated list, for example:
```env
PYTORRENT_API_ALLOWED_ORIGINS=https://pytorrent.example.com
```
If `PYTORRENT_API_ALLOWED_ORIGINS` is not set, pyTorrent reuses `PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS` for API origin checks.
## Local authentication
Use this when pyTorrent should manage its own login screen and passwords.
```env
PYTORRENT_AUTH_ENABLE=true
PYTORRENT_AUTH_PROVIDER=local
```
Password reset example:
```bash
python -m pytorrent.cli reset-password admin new_Pass
```
## Tinyauth authentication
Use this when Tinyauth protects pyTorrent before the request reaches the application.
```env
PYTORRENT_AUTH_ENABLE=true
PYTORRENT_AUTH_PROVIDER=tinyauth
PYTORRENT_AUTH_PROXY_USER_HEADER=Remote-User
PYTORRENT_AUTH_PROXY_AUTO_CREATE=true
PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=admin
PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION=rw
```
Behavior:
- Tinyauth authenticates the browser request.
- The reverse proxy forwards the authenticated username in `Remote-User`.
- pyTorrent reads only that username header.
- If the username already exists in pyTorrent, that user is used.
- If the username does not exist and `PYTORRENT_AUTH_PROXY_AUTO_CREATE=true`, pyTorrent creates it.
- Passwordless external users are synchronized with `PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE` and `PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION` on login.
## Example Nginx / Nginx Proxy Manager advanced vhost
```nginx
location / {
proxy_pass $forward_scheme://$server:$port;
auth_request /tinyauth;
error_page 401 = @tinyauth_login;
auth_request_set $redirection_url $upstream_http_x_tinyauth_location;
auth_request_set $auth_user $upstream_http_remote_user;
proxy_set_header Remote-User $auth_user;
}
location /tinyauth {
proxy_pass http://10.11.1.11:3000/api/auth/nginx;
proxy_set_header x-forwarded-proto $scheme;
proxy_set_header x-forwarded-host $http_host;
proxy_set_header x-forwarded-uri $request_uri;
}
location @tinyauth_login {
return 302 http://auth.example.com/login?redirect_uri=$scheme://$http_host$request_uri;
}
```
Use `PYTORRENT_AUTH_PROXY_USER_HEADER=Remote-User` when this setup forwards `Remote-User` to pyTorrent.
## Direct-IP auth bypass
Use this only when pyTorrent is reachable on a trusted private IP and you want:
- reverse proxy hostname protected by Tinyauth;
- direct private IP access without pyTorrent login.
Example:
```env
PYTORRENT_AUTH_ENABLE=true
PYTORRENT_AUTH_PROVIDER=tinyauth
PYTORRENT_AUTH_BYPASS_HOSTS=10.11.1.11:8090,10.11.1.11
# Existing active user used by bypassed requests. Defaults to admin.
PYTORRENT_AUTH_BYPASS_USER=admin
```
Behavior:
- requests with `Host: 10.11.1.11:8090` or `Host: 10.11.1.11` use the built-in default admin user;
- requests through the reverse proxy still require the configured auth provider;
- `PYTORRENT_AUTH_BYPASS_USER` must point to an existing active user; when unset, pyTorrent uses `admin`;
- if the bypass user is `admin`, profile permissions are ignored because admins can access all profiles;
- when no active profile is saved for the bypass user, pyTorrent opens the profile picker instead of silently selecting the first profile;
- after selecting a profile, the choice is saved in the bypass user's preferences and reused on the next direct-IP visit.
Do not add public domains to this list.
## Generic reverse proxy authentication
Use this when another proxy authenticates users and sends a username header.
```env
PYTORRENT_AUTH_ENABLE=true
PYTORRENT_AUTH_PROVIDER=proxy
PYTORRENT_AUTH_PROXY_USER_HEADER=X-Forwarded-User
PYTORRENT_AUTH_PROXY_AUTO_CREATE=true
PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=user
PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION=rw
```
## Auto-created user permissions
`PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=admin`:
- user is created as admin;
- profile permissions are not needed;
- all profiles are visible and writable.
`PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=user`:
- `none` - creates the user without profile access;
- `ro` - grants read-only access to all profiles;
- `rw` - grants read-write access to all profiles;
- `full` - same as `rw`.
## Connection badge behind Tinyauth
The top-right badge shows Socket.IO connectivity, not REST API health.
If the application loads data through REST API but the badge stays `offline`, the most common cause is that the Socket.IO handshake or follow-up events are not authenticated with the same external identity header. pyTorrent resolves external auth during Socket.IO connect/events as well as normal REST requests.
For Tinyauth, make sure the same location that proxies pyTorrent also forwards `Remote-User` to all paths, including `/socket.io/`:
```nginx
auth_request_set $auth_user $upstream_http_remote_user;
proxy_set_header Remote-User $auth_user;
```
No separate badge-disable option is needed. The badge should become `online` when Socket.IO connects correctly.
## Troubleshooting
If the user is created but profiles are missing:
1. Check the created user's role in pyTorrent user management.
2. For admin access, use:
```env
PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=admin
```
3. For non-admin read-write access, use:
```env
PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE=user
PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION=rw
```
4. Delete the wrongly auto-created external user or log in again. Passwordless external users are synchronized on login by the current config.
If login fails completely, verify that the configured header reaches pyTorrent:
```env
PYTORRENT_AUTH_PROXY_USER_HEADER=Remote-User
```
The configured header must contain a non-empty username.
## External provider logout
When `PYTORRENT_AUTH_PROVIDER=tinyauth` or `PYTORRENT_AUTH_PROVIDER=proxy` is used, pyTorrent does not render an active logout action. The authenticated session is owned by the upstream provider, so logging out must be handled by that provider, for example through the Tinyauth logout endpoint or its own UI.
The `/logout` route becomes a safe no-op redirect to the main page for external auth providers. Local authentication keeps the original pyTorrent logout behavior.

View File

@@ -29,6 +29,22 @@ DEBUG = _env_bool("PYTORRENT_DEBUG", False)
USE_OFFLINE_LIBS = _env_bool("PYTORRENT_USE_OFFLINE_LIBS", False) USE_OFFLINE_LIBS = _env_bool("PYTORRENT_USE_OFFLINE_LIBS", False)
# Note: Optional authentication remains disabled unless explicitly enabled in .env. # Note: Optional authentication remains disabled unless explicitly enabled in .env.
AUTH_ENABLE = _env_bool("PYTORRENT_AUTH_ENABLE", False) AUTH_ENABLE = _env_bool("PYTORRENT_AUTH_ENABLE", False)
AUTH_PROVIDER = os.getenv("PYTORRENT_AUTH_PROVIDER", "local").strip().lower() or "local"
if AUTH_PROVIDER not in {"local", "proxy", "tinyauth"}:
AUTH_PROVIDER = "local"
# Note: External auth reads only one identity value from the trusted reverse proxy.
AUTH_PROXY_USER_HEADER = os.getenv("PYTORRENT_AUTH_PROXY_USER_HEADER", "Remote-User").strip() or "Remote-User"
AUTH_PROXY_AUTO_CREATE = _env_bool("PYTORRENT_AUTH_PROXY_AUTO_CREATE", False)
AUTH_PROXY_AUTO_CREATE_ROLE = os.getenv("PYTORRENT_AUTH_PROXY_AUTO_CREATE_ROLE", "user").strip().lower()
AUTH_PROXY_AUTO_CREATE_PERMISSION = os.getenv("PYTORRENT_AUTH_PROXY_AUTO_CREATE_PERMISSION", "ro").strip().lower()
if AUTH_PROXY_AUTO_CREATE_ROLE not in {"user", "admin"}:
AUTH_PROXY_AUTO_CREATE_ROLE = "user"
# Note: Keep rw as an operator-friendly alias while storing full internally.
if AUTH_PROXY_AUTO_CREATE_PERMISSION == "rw":
AUTH_PROXY_AUTO_CREATE_PERMISSION = "full"
if AUTH_PROXY_AUTO_CREATE_PERMISSION not in {"none", "ro", "full"}:
AUTH_PROXY_AUTO_CREATE_PERMISSION = "ro"
if AUTH_ENABLE and (not _SECRET_KEY_ENV or SECRET_KEY == "dev-change-me"): if AUTH_ENABLE and (not _SECRET_KEY_ENV or SECRET_KEY == "dev-change-me"):
# Note: Auth mode cannot use Flask's development secret; persist a local random session key instead. # Note: Auth mode cannot use Flask's development secret; persist a local random session key instead.
_secret_file = BASE_DIR / "data" / ".session_secret" _secret_file = BASE_DIR / "data" / ".session_secret"
@@ -70,8 +86,18 @@ PROXY_FIX_X_HOST = _env_int("PYTORRENT_PROXY_FIX_X_HOST", 1, 0)
PROXY_FIX_X_PORT = _env_int("PYTORRENT_PROXY_FIX_X_PORT", 1, 0) PROXY_FIX_X_PORT = _env_int("PYTORRENT_PROXY_FIX_X_PORT", 1, 0)
PROXY_FIX_X_PREFIX = _env_int("PYTORRENT_PROXY_FIX_X_PREFIX", 1, 0) PROXY_FIX_X_PREFIX = _env_int("PYTORRENT_PROXY_FIX_X_PREFIX", 1, 0)
def _env_csv(name: str) -> list[str]:
return [item.strip().rstrip("/") for item in os.getenv(name, "").split(",") if item.strip()]
_SOCKETIO_CORS = os.getenv("PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS", "").strip() _SOCKETIO_CORS = os.getenv("PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS", "").strip()
SOCKETIO_CORS_ALLOWED_ORIGINS = None if not _SOCKETIO_CORS else [item.strip() for item in _SOCKETIO_CORS.split(",") if item.strip()] SOCKETIO_CORS_ALLOWED_ORIGINS = None if not _SOCKETIO_CORS else [item.strip() for item in _SOCKETIO_CORS.split(",") if item.strip()]
# Note: API origin checks are separate from Socket.IO CORS. When unset, reuse the Socket.IO allowlist for operator-friendly reverse proxy setups.
_API_ALLOWED_ORIGINS = _env_csv("PYTORRENT_API_ALLOWED_ORIGINS")
API_ALLOWED_ORIGINS = _API_ALLOWED_ORIGINS or _env_csv("PYTORRENT_SOCKETIO_CORS_ALLOWED_ORIGINS")
# Note: Optional auth bypass for trusted direct-IP/local access. Values can be hosts or host:port pairs.
AUTH_BYPASS_HOSTS = {item.lower() for item in _env_csv("PYTORRENT_AUTH_BYPASS_HOSTS")}
# Note: Trusted auth-bypass requests act as this existing active user.
AUTH_BYPASS_USER = os.getenv("PYTORRENT_AUTH_BYPASS_USER", "admin").strip() or "admin"
TRAFFIC_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS", 90, 1) TRAFFIC_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS", 90, 1)
JOBS_RETENTION_DAYS = _env_int("PYTORRENT_JOBS_RETENTION_DAYS", 30, 1) JOBS_RETENTION_DAYS = _env_int("PYTORRENT_JOBS_RETENTION_DAYS", 30, 1)

View File

@@ -10,6 +10,10 @@ CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL,
password_hash TEXT, password_hash TEXT,
email TEXT,
display_name TEXT,
external_auth_provider TEXT,
external_subject TEXT,
role TEXT DEFAULT 'user', role TEXT DEFAULT 'user',
is_active INTEGER DEFAULT 1, is_active INTEGER DEFAULT 1,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
@@ -48,35 +52,46 @@ CREATE TABLE IF NOT EXISTS user_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
theme TEXT DEFAULT 'dark', theme TEXT DEFAULT 'dark',
language TEXT DEFAULT 'en_US',
bootstrap_theme TEXT DEFAULT 'default', bootstrap_theme TEXT DEFAULT 'default',
font_family TEXT DEFAULT 'default', font_family TEXT DEFAULT 'default',
active_rtorrent_id INTEGER, active_rtorrent_id INTEGER,
table_columns_json TEXT,
keyboard_json TEXT, keyboard_json TEXT,
mobile_mode INTEGER DEFAULT 0, mobile_mode INTEGER DEFAULT 0,
peers_refresh_seconds INTEGER DEFAULT 0, compact_torrent_list_enabled INTEGER DEFAULT 0,
port_check_enabled INTEGER DEFAULT 0,
footer_items_json TEXT, footer_items_json TEXT,
title_speed_enabled INTEGER DEFAULT 0, title_speed_enabled INTEGER DEFAULT 0,
tracker_favicons_enabled INTEGER DEFAULT 0,
reverse_dns_enabled INTEGER DEFAULT 0,
automation_toasts_enabled INTEGER DEFAULT 1, automation_toasts_enabled INTEGER DEFAULT 1,
smart_queue_toasts_enabled INTEGER DEFAULT 1, smart_queue_toasts_enabled INTEGER DEFAULT 1,
disk_monitor_paths_json TEXT, easter_egg_enabled INTEGER DEFAULT 0,
disk_monitor_mode TEXT DEFAULT 'default', easter_egg_loading_image_url TEXT DEFAULT '',
disk_monitor_selected_path TEXT, easter_egg_click_image_url TEXT DEFAULT '',
disk_monitor_stop_enabled INTEGER DEFAULT 0,
disk_monitor_stop_threshold INTEGER DEFAULT 98,
interface_scale INTEGER DEFAULT 100, interface_scale INTEGER DEFAULT 100,
detail_panel_height INTEGER DEFAULT 255, detail_panel_height INTEGER DEFAULT 255,
torrent_sort_json TEXT,
active_filter TEXT DEFAULT 'all',
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) FOREIGN KEY(user_id) REFERENCES users(id)
); );
CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id); CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id);
CREATE TABLE IF NOT EXISTS profile_preferences (
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL,
table_columns_json TEXT,
torrent_sort_json TEXT,
active_filter TEXT DEFAULT 'all',
peers_refresh_seconds INTEGER DEFAULT 0,
port_check_enabled INTEGER DEFAULT 0,
tracker_favicons_enabled INTEGER DEFAULT 0,
reverse_dns_enabled INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(user_id, profile_id),
FOREIGN KEY(user_id) REFERENCES users(id),
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id)
);
CREATE INDEX IF NOT EXISTS idx_profile_preferences_user_profile ON profile_preferences(user_id, profile_id);
CREATE TABLE IF NOT EXISTS rtorrent_profiles ( CREATE TABLE IF NOT EXISTS rtorrent_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
@@ -170,8 +185,7 @@ CREATE TABLE IF NOT EXISTS ratio_groups (
CREATE TABLE IF NOT EXISTS rss_feeds ( CREATE TABLE IF NOT EXISTS rss_feeds (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL,
profile_id INTEGER,
name TEXT NOT NULL, name TEXT NOT NULL,
url TEXT NOT NULL, url TEXT NOT NULL,
enabled INTEGER DEFAULT 1, enabled INTEGER DEFAULT 1,
@@ -185,8 +199,7 @@ CREATE TABLE IF NOT EXISTS rss_feeds (
CREATE TABLE IF NOT EXISTS rss_rules ( CREATE TABLE IF NOT EXISTS rss_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL,
profile_id INTEGER,
name TEXT NOT NULL, name TEXT NOT NULL,
pattern TEXT NOT NULL, pattern TEXT NOT NULL,
exclude_pattern TEXT, exclude_pattern TEXT,
@@ -203,13 +216,12 @@ CREATE TABLE IF NOT EXISTS rss_rules (
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_rss_feeds_user_profile_enabled_next ON rss_feeds(user_id, profile_id, enabled, next_check_at); CREATE INDEX IF NOT EXISTS idx_rss_feeds_profile_enabled_next ON rss_feeds(profile_id, enabled, next_check_at);
CREATE INDEX IF NOT EXISTS idx_rss_rules_user_profile_enabled ON rss_rules(user_id, profile_id, enabled); CREATE INDEX IF NOT EXISTS idx_rss_rules_profile_enabled ON rss_rules(profile_id, enabled);
CREATE TABLE IF NOT EXISTS rss_history ( CREATE TABLE IF NOT EXISTS rss_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL,
profile_id INTEGER,
feed_id INTEGER, feed_id INTEGER,
rule_id INTEGER, rule_id INTEGER,
title TEXT, title TEXT,
@@ -219,8 +231,7 @@ CREATE TABLE IF NOT EXISTS rss_history (
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at); CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at);
CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_created ON rss_history(user_id, profile_id, created_at); CREATE INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(profile_id, status);
CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_status ON rss_history(user_id, profile_id, status);
CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added'); CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added');
CREATE TABLE IF NOT EXISTS ratio_assignments ( CREATE TABLE IF NOT EXISTS ratio_assignments (
@@ -257,12 +268,13 @@ CREATE TABLE IF NOT EXISTS app_backups (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
backup_type TEXT DEFAULT 'app',
profile_id INTEGER,
payload_json TEXT NOT NULL, payload_json TEXT NOT NULL,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS smart_queue_settings ( CREATE TABLE IF NOT EXISTS smart_queue_settings (
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL, profile_id INTEGER NOT NULL,
enabled INTEGER DEFAULT 0, enabled INTEGER DEFAULT 0,
max_active_downloads INTEGER DEFAULT 5, max_active_downloads INTEGER DEFAULT 5,
@@ -283,7 +295,7 @@ CREATE TABLE IF NOT EXISTS smart_queue_settings (
protect_active_below_cap INTEGER DEFAULT 1, protect_active_below_cap INTEGER DEFAULT 1,
auto_stop_idle INTEGER DEFAULT 0, auto_stop_idle INTEGER DEFAULT 0,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
PRIMARY KEY(user_id, profile_id) PRIMARY KEY(profile_id)
); );
CREATE TABLE IF NOT EXISTS smart_queue_stalled ( CREATE TABLE IF NOT EXISTS smart_queue_stalled (
@@ -304,19 +316,17 @@ CREATE TABLE IF NOT EXISTS smart_queue_start_grace (
); );
CREATE TABLE IF NOT EXISTS smart_queue_exclusions ( CREATE TABLE IF NOT EXISTS smart_queue_exclusions (
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL, profile_id INTEGER NOT NULL,
torrent_hash TEXT NOT NULL, torrent_hash TEXT NOT NULL,
reason TEXT, reason TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
PRIMARY KEY(user_id, profile_id, torrent_hash) PRIMARY KEY(profile_id, torrent_hash)
); );
CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_user_profile_created ON smart_queue_exclusions(user_id, profile_id, created_at); CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_profile_created ON smart_queue_exclusions(profile_id, created_at);
CREATE TABLE IF NOT EXISTS smart_queue_history ( CREATE TABLE IF NOT EXISTS smart_queue_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL, profile_id INTEGER NOT NULL,
event TEXT NOT NULL, event TEXT NOT NULL,
paused_count INTEGER DEFAULT 0, paused_count INTEGER DEFAULT 0,
@@ -327,7 +337,7 @@ CREATE TABLE IF NOT EXISTS smart_queue_history (
); );
CREATE INDEX IF NOT EXISTS idx_smart_queue_history_profile_created ON smart_queue_history(profile_id, created_at); CREATE INDEX IF NOT EXISTS idx_smart_queue_history_profile_created ON smart_queue_history(profile_id, created_at);
CREATE INDEX IF NOT EXISTS idx_smart_queue_history_user_profile_created ON smart_queue_history(user_id, profile_id, created_at);
CREATE TABLE IF NOT EXISTS smart_queue_auto_labels ( CREATE TABLE IF NOT EXISTS smart_queue_auto_labels (
profile_id INTEGER NOT NULL, profile_id INTEGER NOT NULL,
@@ -405,14 +415,13 @@ CREATE INDEX IF NOT EXISTS idx_automation_history_profile_created ON automation_
CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at); CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at);
CREATE TABLE IF NOT EXISTS rtorrent_config_overrides ( CREATE TABLE IF NOT EXISTS rtorrent_config_overrides (
user_id INTEGER NOT NULL,
profile_id INTEGER NOT NULL, profile_id INTEGER NOT NULL,
key TEXT NOT NULL, key TEXT NOT NULL,
value TEXT, value TEXT,
baseline_value TEXT, baseline_value TEXT,
apply_on_start INTEGER DEFAULT 0, apply_on_start INTEGER DEFAULT 0,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
PRIMARY KEY(user_id, profile_id, key) PRIMARY KEY(profile_id, key)
); );
CREATE INDEX IF NOT EXISTS idx_rtorrent_config_overrides_profile ON rtorrent_config_overrides(profile_id, apply_on_start); CREATE INDEX IF NOT EXISTS idx_rtorrent_config_overrides_profile ON rtorrent_config_overrides(profile_id, apply_on_start);
@@ -421,6 +430,13 @@ CREATE TABLE IF NOT EXISTS app_settings (
value TEXT value TEXT
); );
CREATE TABLE IF NOT EXISTS poller_settings (
profile_id INTEGER PRIMARY KEY,
settings_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id)
);
CREATE TABLE IF NOT EXISTS download_plan_settings ( CREATE TABLE IF NOT EXISTS download_plan_settings (
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
@@ -500,22 +516,25 @@ CREATE TABLE IF NOT EXISTS tracker_favicon_cache (
MIGRATIONS = [ MIGRATIONS = [
"ALTER TABLE api_tokens ADD COLUMN last_used_at TEXT", "ALTER TABLE api_tokens ADD COLUMN last_used_at TEXT",
"ALTER TABLE users ADD COLUMN email TEXT",
"ALTER TABLE users ADD COLUMN display_name TEXT",
"ALTER TABLE users ADD COLUMN external_auth_provider TEXT",
"ALTER TABLE users ADD COLUMN external_subject TEXT",
"ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'user'", "ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'user'",
"ALTER TABLE users ADD COLUMN is_active INTEGER DEFAULT 1", "ALTER TABLE users ADD COLUMN is_active INTEGER DEFAULT 1",
"ALTER TABLE users ADD COLUMN updated_at TEXT", "ALTER TABLE users ADD COLUMN updated_at TEXT",
"ALTER TABLE user_preferences ADD COLUMN mobile_mode INTEGER DEFAULT 0", "ALTER TABLE user_preferences ADD COLUMN mobile_mode INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN peers_refresh_seconds INTEGER DEFAULT 0", "ALTER TABLE user_preferences ADD COLUMN language TEXT DEFAULT 'en_US'",
"ALTER TABLE user_preferences ADD COLUMN port_check_enabled INTEGER DEFAULT 0", "ALTER TABLE user_preferences ADD COLUMN compact_torrent_list_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN bootstrap_theme TEXT DEFAULT 'default'", "ALTER TABLE user_preferences ADD COLUMN bootstrap_theme TEXT DEFAULT 'default'",
"ALTER TABLE user_preferences ADD COLUMN font_family TEXT DEFAULT 'default'", "ALTER TABLE user_preferences ADD COLUMN font_family TEXT DEFAULT 'default'",
"ALTER TABLE user_preferences ADD COLUMN footer_items_json TEXT", "ALTER TABLE user_preferences ADD COLUMN footer_items_json TEXT",
"ALTER TABLE user_preferences ADD COLUMN title_speed_enabled INTEGER DEFAULT 0", "ALTER TABLE user_preferences ADD COLUMN title_speed_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN tracker_favicons_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN reverse_dns_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN interface_scale INTEGER DEFAULT 100", "ALTER TABLE user_preferences ADD COLUMN interface_scale INTEGER DEFAULT 100",
"ALTER TABLE user_preferences ADD COLUMN detail_panel_height INTEGER DEFAULT 255", "ALTER TABLE user_preferences ADD COLUMN detail_panel_height INTEGER DEFAULT 255",
"ALTER TABLE user_preferences ADD COLUMN torrent_sort_json TEXT", "ALTER TABLE user_preferences ADD COLUMN easter_egg_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN active_filter TEXT DEFAULT 'all'", "ALTER TABLE user_preferences ADD COLUMN easter_egg_loading_image_url TEXT DEFAULT ''",
"ALTER TABLE user_preferences ADD COLUMN easter_egg_click_image_url TEXT DEFAULT ''",
"ALTER TABLE rtorrent_profiles ADD COLUMN max_parallel_jobs INTEGER DEFAULT 5", "ALTER TABLE rtorrent_profiles ADD COLUMN max_parallel_jobs INTEGER DEFAULT 5",
"ALTER TABLE rtorrent_profiles ADD COLUMN light_parallel_jobs INTEGER DEFAULT 4", "ALTER TABLE rtorrent_profiles ADD COLUMN light_parallel_jobs INTEGER DEFAULT 4",
"ALTER TABLE rtorrent_profiles ADD COLUMN light_job_timeout_seconds INTEGER DEFAULT 300", "ALTER TABLE rtorrent_profiles ADD COLUMN light_job_timeout_seconds INTEGER DEFAULT 300",
@@ -550,11 +569,6 @@ MIGRATIONS = [
"CREATE TABLE IF NOT EXISTS tracker_favicon_cache (domain TEXT PRIMARY KEY, source_url TEXT, file_path TEXT, mime_type TEXT, updated_at TEXT NOT NULL, updated_epoch REAL DEFAULT 0, error TEXT)", "CREATE TABLE IF NOT EXISTS tracker_favicon_cache (domain TEXT PRIMARY KEY, source_url TEXT, file_path TEXT, mime_type TEXT, updated_at TEXT NOT NULL, updated_epoch REAL DEFAULT 0, error TEXT)",
"ALTER TABLE user_preferences ADD COLUMN automation_toasts_enabled INTEGER DEFAULT 1", "ALTER TABLE user_preferences ADD COLUMN automation_toasts_enabled INTEGER DEFAULT 1",
"ALTER TABLE user_preferences ADD COLUMN smart_queue_toasts_enabled INTEGER DEFAULT 1", "ALTER TABLE user_preferences ADD COLUMN smart_queue_toasts_enabled INTEGER DEFAULT 1",
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_paths_json TEXT",
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_mode TEXT DEFAULT 'default'",
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_selected_path TEXT",
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_stop_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_preferences ADD COLUMN disk_monitor_stop_threshold INTEGER DEFAULT 98",
"ALTER TABLE smart_queue_settings ADD COLUMN cooldown_minutes INTEGER DEFAULT 10", "ALTER TABLE smart_queue_settings ADD COLUMN cooldown_minutes INTEGER DEFAULT 10",
"ALTER TABLE smart_queue_settings ADD COLUMN last_run_at TEXT", "ALTER TABLE smart_queue_settings ADD COLUMN last_run_at TEXT",
"ALTER TABLE smart_queue_settings ADD COLUMN refill_enabled INTEGER DEFAULT 1", "ALTER TABLE smart_queue_settings ADD COLUMN refill_enabled INTEGER DEFAULT 1",
@@ -584,7 +598,7 @@ MIGRATIONS = [
"ALTER TABLE automation_history ADD COLUMN rule_name TEXT", "ALTER TABLE automation_history ADD COLUMN rule_name TEXT",
"ALTER TABLE automation_history ADD COLUMN actions_json TEXT", "ALTER TABLE automation_history ADD COLUMN actions_json TEXT",
"ALTER TABLE automation_history ADD COLUMN torrent_hash TEXT", "ALTER TABLE automation_history ADD COLUMN torrent_hash TEXT",
"CREATE TABLE IF NOT EXISTS rss_history (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER, feed_id INTEGER, rule_id INTEGER, title TEXT, link TEXT, status TEXT NOT NULL, message TEXT, created_at TEXT NOT NULL)", "CREATE TABLE IF NOT EXISTS rss_history (id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER NOT NULL, feed_id INTEGER, rule_id INTEGER, title TEXT, link TEXT, status TEXT NOT NULL, message TEXT, created_at TEXT NOT NULL)",
"CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at)",
"CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added')", "CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added')",
"CREATE TABLE IF NOT EXISTS ratio_assignments (profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, group_id INTEGER, group_name TEXT, applied_at TEXT, last_status TEXT, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash))", "CREATE TABLE IF NOT EXISTS ratio_assignments (profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, group_id INTEGER, group_name TEXT, applied_at TEXT, last_status TEXT, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash))",
@@ -597,15 +611,15 @@ MIGRATIONS = [
"CREATE INDEX IF NOT EXISTS idx_download_plan_paused_profile ON download_plan_paused(profile_id, updated_at)", "CREATE INDEX IF NOT EXISTS idx_download_plan_paused_profile ON download_plan_paused(profile_id, updated_at)",
"CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at)", "CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at)",
"CREATE INDEX IF NOT EXISTS idx_jobs_profile_created ON jobs(profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_jobs_profile_created ON jobs(profile_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_rss_feeds_user_profile_enabled_next ON rss_feeds(user_id, profile_id, enabled, next_check_at)", "CREATE INDEX IF NOT EXISTS idx_rss_feeds_profile_enabled_next ON rss_feeds(profile_id, enabled, next_check_at)",
"CREATE INDEX IF NOT EXISTS idx_rss_rules_user_profile_enabled ON rss_rules(user_id, profile_id, enabled)", "CREATE INDEX IF NOT EXISTS idx_rss_rules_profile_enabled ON rss_rules(profile_id, enabled)",
"CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_created ON rss_history(user_id, profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(profile_id, status)",
"CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_status ON rss_history(user_id, profile_id, status)", "CREATE INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(profile_id, status)",
"CREATE INDEX IF NOT EXISTS idx_ratio_groups_user_profile_enabled ON ratio_groups(user_id, profile_id, enabled)", "CREATE INDEX IF NOT EXISTS idx_ratio_groups_user_profile_enabled ON ratio_groups(user_id, profile_id, enabled)",
"CREATE INDEX IF NOT EXISTS idx_ratio_assignments_profile_status ON ratio_assignments(profile_id, last_status)", "CREATE INDEX IF NOT EXISTS idx_ratio_assignments_profile_status ON ratio_assignments(profile_id, last_status)",
"CREATE INDEX IF NOT EXISTS idx_ratio_history_user_profile_id ON ratio_history(user_id, profile_id, id)", "CREATE INDEX IF NOT EXISTS idx_ratio_history_user_profile_id ON ratio_history(user_id, profile_id, id)",
"CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_user_profile_created ON smart_queue_exclusions(user_id, profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_profile_created ON smart_queue_exclusions(profile_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_smart_queue_history_user_profile_created ON smart_queue_history(user_id, profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_smart_queue_history_profile_created ON smart_queue_history(profile_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_automation_rules_user_profile_enabled ON automation_rules(user_id, profile_id, enabled)", "CREATE INDEX IF NOT EXISTS idx_automation_rules_user_profile_enabled ON automation_rules(user_id, profile_id, enabled)",
"CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id)", "CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id)",
@@ -614,6 +628,10 @@ MIGRATIONS = [
"CREATE INDEX IF NOT EXISTS idx_operation_logs_profile_created ON operation_logs(profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_operation_logs_profile_created ON operation_logs(profile_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_operation_logs_event_type ON operation_logs(event_type, created_at)", "CREATE INDEX IF NOT EXISTS idx_operation_logs_event_type ON operation_logs(event_type, created_at)",
"CREATE TABLE IF NOT EXISTS profile_preferences (user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, table_columns_json TEXT, torrent_sort_json TEXT, active_filter TEXT DEFAULT 'all', peers_refresh_seconds INTEGER DEFAULT 0, port_check_enabled INTEGER DEFAULT 0, tracker_favicons_enabled INTEGER DEFAULT 0, reverse_dns_enabled INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id), FOREIGN KEY(user_id) REFERENCES users(id), FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id))",
"ALTER TABLE app_backups ADD COLUMN backup_type TEXT DEFAULT 'app'",
'ALTER TABLE app_backups ADD COLUMN profile_id INTEGER',
'CREATE TABLE IF NOT EXISTS poller_settings (profile_id INTEGER PRIMARY KEY, settings_json TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id))',
"CREATE TABLE IF NOT EXISTS operation_log_settings (user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL DEFAULT 0, retention_mode TEXT DEFAULT 'days', retention_days INTEGER DEFAULT 30, retention_lines INTEGER DEFAULT 5000, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id))", "CREATE TABLE IF NOT EXISTS operation_log_settings (user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL DEFAULT 0, retention_mode TEXT DEFAULT 'days', retention_days INTEGER DEFAULT 30, retention_lines INTEGER DEFAULT 5000, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id))",
] ]
@@ -629,6 +647,81 @@ POST_MIGRATION_INDEXES = [
"CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at)",
] ]
PROFILE_ONLY_TABLES = {
"rss_feeds": {
"columns": "id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER NOT NULL, name TEXT NOT NULL, url TEXT NOT NULL, enabled INTEGER DEFAULT 1, interval_minutes INTEGER DEFAULT 30, last_error TEXT, last_checked_at TEXT, next_check_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL",
"copy": ["id", "profile_id", "name", "url", "enabled", "interval_minutes", "last_error", "last_checked_at", "next_check_at", "created_at", "updated_at"],
"indexes": ["CREATE INDEX IF NOT EXISTS idx_rss_feeds_profile_enabled_next ON rss_feeds(profile_id, enabled, next_check_at)"],
},
"rss_rules": {
"columns": "id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER NOT NULL, name TEXT NOT NULL, pattern TEXT NOT NULL, exclude_pattern TEXT, min_size_mb INTEGER DEFAULT 0, max_size_mb INTEGER DEFAULT 0, category TEXT, quality TEXT, season INTEGER, episode INTEGER, save_path TEXT, label TEXT, start INTEGER DEFAULT 1, enabled INTEGER DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL",
"copy": ["id", "profile_id", "name", "pattern", "exclude_pattern", "min_size_mb", "max_size_mb", "category", "quality", "season", "episode", "save_path", "label", "start", "enabled", "created_at", "updated_at"],
"indexes": ["CREATE INDEX IF NOT EXISTS idx_rss_rules_profile_enabled ON rss_rules(profile_id, enabled)"],
},
"rss_history": {
"columns": "id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER NOT NULL, feed_id INTEGER, rule_id INTEGER, title TEXT, link TEXT, status TEXT NOT NULL, message TEXT, created_at TEXT NOT NULL",
"copy": ["id", "profile_id", "feed_id", "rule_id", "title", "link", "status", "message", "created_at"],
"indexes": ["CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(profile_id, status)", "CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added')"],
},
"smart_queue_settings": {
"columns": "profile_id INTEGER NOT NULL, enabled INTEGER DEFAULT 0, max_active_downloads INTEGER DEFAULT 5, stalled_seconds INTEGER DEFAULT 300, min_speed_bytes INTEGER DEFAULT 1024, min_seeds INTEGER DEFAULT 1, min_peers INTEGER DEFAULT 0, ignore_seed_peer INTEGER DEFAULT 0, ignore_speed INTEGER DEFAULT 0, manage_stopped INTEGER DEFAULT 0, cooldown_minutes INTEGER DEFAULT 10, last_run_at TEXT, refill_enabled INTEGER DEFAULT 1, refill_interval_minutes INTEGER DEFAULT 0, last_refill_at TEXT, stop_batch_size INTEGER DEFAULT 50, start_grace_seconds INTEGER DEFAULT 900, protect_active_below_cap INTEGER DEFAULT 1, auto_stop_idle INTEGER DEFAULT 0, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id)",
"copy": ["profile_id", "enabled", "max_active_downloads", "stalled_seconds", "min_speed_bytes", "min_seeds", "min_peers", "ignore_seed_peer", "ignore_speed", "manage_stopped", "cooldown_minutes", "last_run_at", "refill_enabled", "refill_interval_minutes", "last_refill_at", "stop_batch_size", "start_grace_seconds", "protect_active_below_cap", "auto_stop_idle", "updated_at"],
"indexes": [],
},
"smart_queue_exclusions": {
"columns": "profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, reason TEXT, created_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash)",
"copy": ["profile_id", "torrent_hash", "reason", "created_at"],
"indexes": ["CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_profile_created ON smart_queue_exclusions(profile_id, created_at)"],
},
"smart_queue_history": {
"columns": "id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER NOT NULL, event TEXT NOT NULL, paused_count INTEGER DEFAULT 0, resumed_count INTEGER DEFAULT 0, checked_count INTEGER DEFAULT 0, details_json TEXT, created_at TEXT NOT NULL",
"copy": ["id", "profile_id", "event", "paused_count", "resumed_count", "checked_count", "details_json", "created_at"],
"indexes": ["CREATE INDEX IF NOT EXISTS idx_smart_queue_history_profile_created ON smart_queue_history(profile_id, created_at)"],
},
"rtorrent_config_overrides": {
"columns": "profile_id INTEGER NOT NULL, key TEXT NOT NULL, value TEXT, baseline_value TEXT, apply_on_start INTEGER DEFAULT 0, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, key)",
"copy": ["profile_id", "key", "value", "baseline_value", "apply_on_start", "updated_at"],
"indexes": ["CREATE INDEX IF NOT EXISTS idx_rtorrent_config_overrides_profile ON rtorrent_config_overrides(profile_id, apply_on_start)"],
},
}
def _table_columns(conn, table: str) -> set[str]:
try:
return {str(row["name"]) for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
except sqlite3.OperationalError:
return set()
def _normalize_profile_only_tables(conn) -> None:
"""Move operational settings from user scope to profile scope on existing databases."""
for table, spec in PROFILE_ONLY_TABLES.items():
columns = _table_columns(conn, table)
if not columns or "user_id" not in columns:
for index_sql in spec["indexes"]:
try:
conn.execute(index_sql)
except sqlite3.OperationalError:
pass
continue
tmp = f"{table}_profile_scope_tmp"
conn.execute("PRAGMA foreign_keys = OFF")
conn.execute(f"DROP TABLE IF EXISTS {tmp}")
conn.execute(f"CREATE TABLE {tmp} ({spec['columns']})")
copy_cols = [col for col in spec["copy"] if col in columns]
if copy_cols:
col_sql = ",".join(copy_cols)
if table in {"smart_queue_settings", "smart_queue_exclusions", "rtorrent_config_overrides"}:
conn.execute(f"INSERT OR REPLACE INTO {tmp}({col_sql}) SELECT {col_sql} FROM {table} WHERE profile_id IS NOT NULL")
else:
conn.execute(f"INSERT INTO {tmp}({col_sql}) SELECT {col_sql} FROM {table} WHERE profile_id IS NOT NULL")
conn.execute(f"DROP TABLE {table}")
conn.execute(f"ALTER TABLE {tmp} RENAME TO {table}")
for index_sql in spec["indexes"]:
conn.execute(index_sql)
conn.execute("PRAGMA foreign_keys = ON")
def utcnow() -> str: def utcnow() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds") return datetime.now(timezone.utc).isoformat(timespec="seconds")
@@ -669,6 +762,7 @@ def init_db():
conn.execute(sql) conn.execute(sql)
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass pass
_normalize_profile_only_tables(conn)
now = utcnow() now = utcnow()
conn.execute( conn.execute(
"INSERT OR IGNORE INTO users(id, username, password_hash, role, is_active, created_at, updated_at) VALUES(1, 'default', NULL, 'admin', 1, ?, ?)", "INSERT OR IGNORE INTO users(id, username, password_hash, role, is_active, created_at, updated_at) VALUES(1, 'default', NULL, 'admin', 1, ?, ?)",
@@ -678,7 +772,7 @@ def init_db():
pref = conn.execute("SELECT id FROM user_preferences WHERE user_id=1").fetchone() pref = conn.execute("SELECT id FROM user_preferences WHERE user_id=1").fetchone()
if not pref: if not pref:
conn.execute( conn.execute(
"INSERT INTO user_preferences(user_id, theme, created_at, updated_at) VALUES(1, 'dark', ?, ?)", "INSERT INTO user_preferences(user_id, theme, language, created_at, updated_at) VALUES(1, 'dark', 'en_US', ?, ?)",
(now, now), (now, now),
) )
try: try:

View File

@@ -2111,6 +2111,28 @@
"type": "object" "type": "object"
} }
] ]
},
"TemporaryLinkResponse": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"example": true
},
"url": {
"type": "string",
"example": "/download/r4nd0mTemporaryToken"
},
"expires_in": {
"type": "integer",
"example": 600
}
},
"required": [
"ok",
"url",
"expires_in"
]
} }
}, },
"securitySchemes": { "securitySchemes": {
@@ -7101,6 +7123,362 @@
}, },
"summary": "Traffic history" "summary": "Traffic history"
} }
},
"/preview/pdf/{token}": {
"get": {
"summary": "Open temporary PDF preview",
"description": "Streams a PDF through an in-app temporary preview URL created by the API. The browser-visible URL does not expose the stable /api download route.",
"parameters": [
{
"in": "path",
"name": "token",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "PDF stream",
"content": {
"application/pdf": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"403": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/download/{token}": {
"get": {
"summary": "Open temporary download link",
"description": "Resolves a short-lived in-app download token created by an API endpoint and streams the requested file or ZIP.",
"parameters": [
{
"in": "path",
"name": "token",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "File stream",
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
},
"application/zip": {
"schema": {
"type": "string",
"format": "binary"
}
},
"application/x-bittorrent": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"403": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/torrents/{torrent_hash}/files/{file_index}/download-link": {
"post": {
"summary": "Create temporary torrent file download link",
"description": "Validates the selected torrent file through the API and returns a short-lived /download URL for the UI.",
"parameters": [
{
"in": "path",
"name": "torrent_hash",
"required": true,
"schema": {
"type": "string"
}
},
{
"in": "path",
"name": "file_index",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemporaryLinkResponse"
}
}
}
},
"400": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/torrents/{torrent_hash}/files/download-link": {
"post": {
"summary": "Create temporary torrent file download link from body",
"description": "Body-based alias that validates a selected torrent file and returns a short-lived /download URL.",
"parameters": [
{
"in": "path",
"name": "torrent_hash",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"file_index": {
"type": "integer"
}
},
"required": [
"file_index"
]
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemporaryLinkResponse"
}
}
}
},
"400": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/torrents/{torrent_hash}/files/download.zip/link": {
"post": {
"summary": "Create temporary torrent files ZIP download link",
"description": "Validates selected torrent files and returns a short-lived /download URL for a ZIP archive. If indexes is omitted or null, all files are included.",
"parameters": [
{
"in": "path",
"name": "torrent_hash",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"indexes": {
"type": "array",
"items": {
"type": "integer"
},
"nullable": true
}
}
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemporaryLinkResponse"
}
}
}
},
"400": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/torrents/{torrent_hash}/torrent-file/link": {
"get": {
"summary": "Create temporary .torrent export download link",
"description": "Validates .torrent export availability and returns a short-lived /download URL for the UI.",
"parameters": [
{
"in": "path",
"name": "torrent_hash",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemporaryLinkResponse"
}
}
}
},
"400": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/torrents/torrent-files.zip/link": {
"post": {
"summary": "Create temporary .torrent files ZIP download link",
"description": "Validates selected torrents and returns a short-lived /download URL for a ZIP of exported .torrent files.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"hashes": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"hashes"
]
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemporaryLinkResponse"
}
}
}
},
"400": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
} }
} }
} }

View File

@@ -18,11 +18,12 @@ import queue
import threading import threading
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
from flask import Blueprint, jsonify, request, abort, send_file, redirect, Response, stream_with_context from flask import Blueprint, jsonify, request, abort, send_file, redirect, Response, stream_with_context, url_for
# Note: url_for is exported through this shared module for API routes that build temporary in-app links.
from ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, LOG_RETENTION_DAYS, WORKERS, PYTORRENT_TMP_DIR from ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, LOG_RETENTION_DAYS, WORKERS, PYTORRENT_TMP_DIR
from ..db import connect, utcnow from ..db import connect, utcnow
from ..services.auth import current_user_id as default_user_id, current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, require_profile_write from ..services.auth import current_user_id as default_user_id, current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, require_profile_write
from ..services import preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache, rss as rss_service, ratio_rules, backup as backup_service, download_planner, operation_logs from ..services import preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache, rss as rss_service, ratio_rules, backup as backup_service, download_planner, operation_logs, poller_control
from ..services.torrent_cache import torrent_cache from ..services.torrent_cache import torrent_cache
from ..services.torrent_summary import cached_summary from ..services.torrent_summary import cached_summary
from ..services.workers import enqueue, list_jobs, cancel_job, retry_job, force_job, clear_jobs, emergency_clear_jobs from ..services.workers import enqueue, list_jobs, cancel_job, retry_job, force_job, clear_jobs, emergency_clear_jobs
@@ -289,6 +290,7 @@ def cleanup_summary() -> dict:
(profile_id,), (profile_id,),
) if profile_id else _table_count("operation_logs") ) if profile_id else _table_count("operation_logs")
operation_log_retention = operation_logs.get_settings(profile_id) if profile_id else operation_logs.get_settings(0) operation_log_retention = operation_logs.get_settings(profile_id) if profile_id else operation_logs.get_settings(0)
poller_runtime = poller_control.snapshot(profile_id) if profile_id else {}
return { return {
"jobs_total": _table_count("jobs"), "jobs_total": _table_count("jobs"),
"jobs_clearable": _table_count("jobs", "WHERE status NOT IN ('pending', 'running')"), "jobs_clearable": _table_count("jobs", "WHERE status NOT IN ('pending', 'running')"),
@@ -297,6 +299,7 @@ def cleanup_summary() -> dict:
"automation_history_total": _table_count("automation_history"), "automation_history_total": _table_count("automation_history"),
"planner_history_total": download_planner.history_count(profile_id) if profile_id else 0, "planner_history_total": download_planner.history_count(profile_id) if profile_id else 0,
"cache": _active_profile_cache_summary(profile_id if profile_id else None), "cache": _active_profile_cache_summary(profile_id if profile_id else None),
"poller_runtime": poller_runtime,
"retention_days": { "retention_days": {
"jobs": JOBS_RETENTION_DAYS, "jobs": JOBS_RETENTION_DAYS,
"smart_queue_history": SMART_QUEUE_HISTORY_RETENTION_DAYS, "smart_queue_history": SMART_QUEUE_HISTORY_RETENTION_DAYS,

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from flask import abort, jsonify, request from flask import abort, jsonify, request
from ..services.auth import current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, list_api_tokens, create_api_token, revoke_api_token from ..services.auth import current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, provider as auth_provider, uses_external_provider, external_auth_summary, list_api_tokens, create_api_token, revoke_api_token
def _ok(payload=None): def _ok(payload=None):
@@ -21,18 +21,20 @@ def register_auth_routes(bp):
user = login_user(str(data.get("username") or ""), str(data.get("password") or "")) user = login_user(str(data.get("username") or ""), str(data.get("password") or ""))
if not user: if not user:
return jsonify({"ok": False, "error": "Invalid username or password"}), 401 return jsonify({"ok": False, "error": "Invalid username or password"}), 401
return _ok({"user": user, "auth_enabled": auth_enabled()}) return _ok({"user": user, "auth_enabled": auth_enabled(), "auth_provider": auth_provider()})
@bp.get("/auth/me") @bp.get("/auth/me")
def auth_me(): def auth_me():
if not auth_enabled(): if not auth_enabled():
abort(404) abort(404)
return _ok({"user": current_user(), "auth_enabled": auth_enabled()}) return _ok({"user": current_user(), "auth_enabled": auth_enabled(), "auth_provider": auth_provider()})
@bp.post("/auth/logout") @bp.post("/auth/logout")
def auth_logout(): def auth_logout():
if not auth_enabled(): if not auth_enabled():
abort(404) abort(404)
if uses_external_provider():
return _ok({"logout_managed_by_provider": True, "auth_provider": auth_provider()})
logout_user() logout_user()
return _ok() return _ok()
@@ -40,7 +42,7 @@ def register_auth_routes(bp):
def auth_users_list(): def auth_users_list():
if not auth_enabled(): if not auth_enabled():
abort(404) abort(404)
return _ok({"users": list_users()}) return _ok({"users": list_users(), "auth": external_auth_summary()})
@bp.post("/auth/users") @bp.post("/auth/users")
def auth_users_create(): def auth_users_create():

View File

@@ -1,31 +1,96 @@
from __future__ import annotations from __future__ import annotations
from ._shared import * from ._shared import *
from ..services import auth
def _active_profile_id() -> int | None:
profile = preferences.active_profile()
return int(profile["id"]) if profile else None
@bp.get("/backup") @bp.get("/backup")
def backup_list(): def backup_list():
return ok({"backups": backup_service.list_backups(default_user_id()), "auto": backup_service.get_auto_backup_settings(default_user_id())}) uid = default_user_id()
pid = _active_profile_id()
can_app = auth.is_admin()
return ok({
"profile_backups": backup_service.list_backups(uid, "profile", pid) if pid else [],
"app_backups": backup_service.list_backups(uid, "app") if can_app else [],
"profile_auto": backup_service.get_auto_backup_settings(uid, "profile", pid) if pid else None,
"app_auto": backup_service.get_auto_backup_settings(uid, "app") if can_app else None,
"auto": backup_service.get_auto_backup_settings(uid, "app") if can_app else None,
"can_app_backup": can_app,
})
@bp.post("/backup/profile")
def backup_create_profile():
data = request.get_json(silent=True) or {}
pid = _active_profile_id()
if not pid:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
return ok({
"backup": backup_service.create_profile_backup(str(data.get("name") or "Profile backup"), pid, default_user_id()),
"profile_backups": backup_service.list_backups(default_user_id(), "profile", pid),
})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/backup/app")
def backup_create_app():
data = request.get_json(silent=True) or {}
try:
return ok({
"backup": backup_service.create_app_backup(str(data.get("name") or "Application backup"), default_user_id()),
"app_backups": backup_service.list_backups(default_user_id(), "app"),
})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400
@bp.post("/backup") @bp.post("/backup")
def backup_create(): def backup_create():
data = request.get_json(silent=True) or {} # Note: Legacy endpoint now creates a profile backup so non-admin users cannot capture other users' settings.
return ok({"backup": backup_service.create_backup(str(data.get("name") or "Manual backup"), default_user_id()), "backups": backup_service.list_backups(default_user_id())}) return backup_create_profile()
@bp.get("/backup/settings") @bp.get("/backup/settings")
def backup_settings_get(): def backup_settings_get():
return ok({"settings": backup_service.get_auto_backup_settings(default_user_id())}) if not auth.is_admin():
return jsonify({"ok": False, "error": "Application backup settings are admin-only"}), 403
return ok({"settings": backup_service.get_auto_backup_settings(default_user_id(), "app")})
@bp.post("/backup/settings") @bp.post("/backup/settings")
def backup_settings_save(): def backup_settings_save():
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
try: try:
return ok({"settings": backup_service.save_auto_backup_settings(data, default_user_id())}) return ok({"settings": backup_service.save_auto_backup_settings(data, default_user_id(), "app")})
except Exception as exc: except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400 return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400
@bp.get("/backup/profile/settings")
def profile_backup_settings_get():
pid = _active_profile_id()
if not pid:
return jsonify({"ok": False, "error": "No profile"}), 400
return ok({"settings": backup_service.get_auto_backup_settings(default_user_id(), "profile", pid)})
@bp.post("/backup/profile/settings")
def profile_backup_settings_save():
data = request.get_json(silent=True) or {}
pid = _active_profile_id()
if not pid:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
return ok({"settings": backup_service.save_auto_backup_settings(data, default_user_id(), "profile", pid)})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400
@bp.get("/backup/<int:backup_id>/preview") @bp.get("/backup/<int:backup_id>/preview")
@@ -36,14 +101,13 @@ def backup_preview(backup_id: int):
return jsonify({"ok": False, "error": str(exc)}), 400 return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/backup/<int:backup_id>/restore") @bp.post("/backup/<int:backup_id>/restore")
def backup_restore(backup_id: int): def backup_restore(backup_id: int):
try: try:
return ok({"result": backup_service.restore_backup(backup_id, default_user_id())}) pid = _active_profile_id()
return ok({"result": backup_service.restore_backup(backup_id, default_user_id(), profile_id=pid)})
except Exception as exc: except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400 return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400
@bp.delete("/backup/<int:backup_id>") @bp.delete("/backup/<int:backup_id>")
@@ -54,7 +118,6 @@ def backup_delete(backup_id: int):
return jsonify({"ok": False, "error": str(exc)}), 400 return jsonify({"ok": False, "error": str(exc)}), 400
@bp.get("/backup/<int:backup_id>/download") @bp.get("/backup/<int:backup_id>/download")
def backup_download(backup_id: int): def backup_download(backup_id: int):
try: try:
@@ -62,8 +125,6 @@ def backup_download(backup_id: int):
tmp = tempfile.NamedTemporaryFile(prefix="pytorrent-backup-", suffix=".json", delete=False, mode="w", encoding="utf-8") tmp = tempfile.NamedTemporaryFile(prefix="pytorrent-backup-", suffix=".json", delete=False, mode="w", encoding="utf-8")
json.dump(payload, tmp, ensure_ascii=False, indent=2) json.dump(payload, tmp, ensure_ascii=False, indent=2)
tmp.close() tmp.close()
return send_file(tmp.name, as_attachment=True, download_name=f"pytorrent-backup-{backup_id}.json") return send_file(tmp.name, as_attachment=True, download_name=f"pytorrent-{payload.get('backup_type') or 'backup'}-{backup_id}.json")
except Exception as exc: except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400 return jsonify({"ok": False, "error": str(exc)}), 400

View File

@@ -1,10 +1,16 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from urllib.parse import quote
import queue
import tempfile
import threading
import zipfile
from flask import Blueprint, render_template, Response, request, redirect, url_for, abort, send_file from flask import Blueprint, render_template, Response, request, redirect, url_for, abort, send_file, stream_with_context
from ..services.preferences import get_preferences, list_profiles, active_profile, BOOTSTRAP_THEMES, FONT_FAMILIES from ..services.preferences import get_preferences, list_profiles, active_profile, get_profile, BOOTSTRAP_THEMES, FONT_FAMILIES, SUPPORTED_LANGUAGES
from ..services import auth from ..services import auth, pdf_preview_links, rtorrent
from ..config import PYTORRENT_TMP_DIR
from ..services.frontend_assets import asset_path from ..services.frontend_assets import asset_path
# for favicon # for favicon
@@ -18,6 +24,141 @@ def _asset_url(key: str) -> str:
return path if path.startswith("http") else url_for("static", filename=path) return path if path.startswith("http") else url_for("static", filename=path)
def _attachment_headers(download_name: str, content_type: str = "application/octet-stream", disposition: str = "attachment") -> dict:
safe = Path(download_name or "download.bin").name or "download.bin"
safe_disposition = "inline" if disposition == "inline" else "attachment"
return {
"Content-Type": content_type,
"Content-Disposition": f"{safe_disposition}; filename*=UTF-8''{quote(safe)}",
"X-Content-Type-Options": "nosniff",
}
def _cleanup_staged_file(profile: dict, path: str, local: bool = False) -> None:
if local:
try:
Path(path).unlink()
except Exception:
pass
return
rtorrent._remote_remove_staged(profile, path)
try:
tmp_prefix = str(PYTORRENT_TMP_DIR).rstrip("/") + "/pytorrent-download-"
if str(path).startswith(tmp_prefix) and Path(path).exists():
Path(path).unlink()
except Exception:
pass
def _read_staged_file(profile: dict, path: str, local: bool = False) -> bytes:
if local:
return Path(path).read_bytes()
return b"".join(bytes(chunk) for chunk in rtorrent.iter_remote_file_chunks(profile, path) if chunk)
def _safe_zip_name(name: str, fallback: str) -> str:
value = str(name or fallback).replace("\\", "/").lstrip("/")
parts = [part for part in value.split("/") if part not in ("", ".", "..")]
return "/".join(parts) or fallback
class _ZipStream:
def __init__(self):
self.queue: queue.Queue[bytes | None] = queue.Queue(maxsize=16)
self.closed = False
def write(self, data):
if not data:
return 0
payload = bytes(data)
self.queue.put(payload)
return len(payload)
def flush(self):
return None
def close(self):
if not self.closed:
self.closed = True
self.queue.put(None)
def writable(self):
return True
def _stream_torrent_files_zip(profile: dict, items: list[dict]):
writer = _ZipStream()
errors: list[BaseException] = []
def produce():
try:
with zipfile.ZipFile(writer, "w", compression=zipfile.ZIP_STORED, allowZip64=True) as archive:
used = set()
for item in items:
arcname = _safe_zip_name(str(item.get("path") or ""), f"file-{item.get('index', 0)}")
base = arcname
counter = 2
while arcname in used:
stem = Path(base).stem or "file"
suffix = Path(base).suffix
parent = str(Path(base).parent).replace(".", "", 1).strip("/")
candidate = f"{stem}-{counter}{suffix}"
arcname = f"{parent}/{candidate}" if parent else candidate
counter += 1
used.add(arcname)
info = zipfile.ZipInfo(arcname)
info.compress_type = zipfile.ZIP_STORED
info.file_size = int(item.get("size") or 0)
with archive.open(info, "w", force_zip64=True) as dest:
for chunk in rtorrent.iter_remote_file_chunks(profile, item["remote_path"], size=int(item.get("size") or 0) or None):
dest.write(chunk)
except BaseException as exc:
errors.append(exc)
finally:
writer.close()
threading.Thread(target=produce, name="pytorrent-token-zip-stream", daemon=True).start()
while True:
chunk = writer.queue.get()
if chunk is None:
break
yield chunk
if errors:
raise errors[0]
def _send_staged_torrent_file(profile: dict, path: str, download_name: str, local: bool = False):
headers = _attachment_headers(download_name, "application/x-bittorrent")
if local:
data = Path(path).read_bytes()
_cleanup_staged_file(profile, path, local=True)
headers["Content-Length"] = str(len(data))
return Response(data, headers=headers)
def generate():
try:
yield from rtorrent.iter_remote_file_chunks(profile, path)
finally:
_cleanup_staged_file(profile, path, local=False)
return Response(stream_with_context(generate()), headers=headers, direct_passthrough=True)
def _profile_for_temporary_target(target: dict):
profile_id = int(target.get("profile_id") or 0)
owner_user_id = int(target.get("user_id") or 0)
if auth.enabled() and owner_user_id != auth.current_user_id():
abort(403)
if not auth.can_access_profile(profile_id):
abort(403)
profile = active_profile() if not profile_id else get_profile(profile_id)
if not profile:
abort(404)
return profile
@bp.get("/favicon.ico") @bp.get("/favicon.ico")
def favicon_ico(): def favicon_ico():
response = send_from_directory( response = send_from_directory(
@@ -33,17 +174,30 @@ def login():
# Note: When optional authentication is disabled, /login is intentionally unavailable. # Note: When optional authentication is disabled, /login is intentionally unavailable.
if not auth.enabled(): if not auth.enabled():
abort(404) abort(404)
next_url = request.args.get("next") or url_for("main.index")
if auth.uses_external_provider():
user = auth.authenticate_external_user()
if user:
return redirect(next_url)
return render_template(
"login.html",
error="External authentication headers were not accepted by pyTorrent.",
external_provider=auth.provider(),
), 401
error = "" error = ""
if request.method == "POST": if request.method == "POST":
user = auth.login_user(request.form.get("username", ""), request.form.get("password", "")) user = auth.login_user(request.form.get("username", ""), request.form.get("password", ""))
if user: if user:
return redirect(request.args.get("next") or url_for("main.index")) return redirect(next_url)
error = "Invalid username or password" error = "Invalid username or password"
return render_template("login.html", error=error) return render_template("login.html", error=error, external_provider=None)
@bp.get("/logout") @bp.get("/logout")
def logout(): def logout():
# Note: External providers such as Tinyauth own the login session, so pyTorrent must not pretend to log the user out locally.
if auth.uses_external_provider():
return redirect(url_for("main.index"))
auth.logout_user() auth.logout_user()
if not auth.enabled(): if not auth.enabled():
return redirect(url_for("main.index")) return redirect(url_for("main.index"))
@@ -60,11 +214,128 @@ def index():
active_profile=active_profile(), active_profile=active_profile(),
bootstrap_themes=BOOTSTRAP_THEMES, bootstrap_themes=BOOTSTRAP_THEMES,
font_families=FONT_FAMILIES, font_families=FONT_FAMILIES,
supported_languages=SUPPORTED_LANGUAGES,
auth_enabled=auth.enabled(), auth_enabled=auth.enabled(),
auth_provider=auth.provider(),
external_auth=auth.uses_external_provider(),
current_user=auth.current_user(), current_user=auth.current_user(),
) )
@bp.get("/preview/pdf/<token>")
def pdf_preview(token: str):
# Note: This route keeps browser-visible PDF links inside the app and delegates streaming to the existing rTorrent file reader.
target = pdf_preview_links.get_pdf_preview_link(token)
if not target:
abort(404)
profile_id = int(target.get("profile_id") or 0)
owner_user_id = int(target.get("user_id") or 0)
if auth.enabled() and owner_user_id != auth.current_user_id():
abort(403)
if not auth.can_access_profile(profile_id):
abort(403)
profile = active_profile() if not profile_id else get_profile(profile_id)
if not profile:
abort(404)
item = rtorrent.torrent_download_file_info(profile, target["torrent_hash"], int(target["file_index"]))
filename = Path(item.get("download_name") or "preview.pdf").name or "preview.pdf"
if Path(filename).suffix.lower() != ".pdf":
abort(404)
size = int(item.get("size") or 0)
headers = {
"Content-Disposition": f"inline; filename*=UTF-8''{quote(filename)}",
"Content-Type": "application/pdf",
"X-Content-Type-Options": "nosniff",
}
if size > 0:
headers["Content-Length"] = str(size)
def generate():
yield from rtorrent.iter_remote_file_chunks(profile, item["remote_path"], size=size or None)
return Response(stream_with_context(generate()), headers=headers, direct_passthrough=True)
@bp.get("/download/<token>")
def temporary_download(token: str):
# Note: UI download actions resolve API-created temporary tokens here, keeping browser-visible URLs outside /api/.
target = pdf_preview_links.get_temporary_link(token)
if not target:
abort(404)
profile = _profile_for_temporary_target(target)
kind = str(target.get("kind") or "")
if kind == "file_download":
item = rtorrent.torrent_download_file_info(profile, target["torrent_hash"], int(target["file_index"]))
size = int(item.get("size") or 0)
headers = _attachment_headers(item.get("download_name") or "file.bin")
if size > 0:
headers["Content-Length"] = str(size)
def generate_file():
yield from rtorrent.iter_remote_file_chunks(profile, item["remote_path"], size=size or None)
return Response(stream_with_context(generate_file()), headers=headers, direct_passthrough=True)
if kind == "file_zip_download":
items = rtorrent.torrent_download_zip_items(profile, target["torrent_hash"], target.get("indexes"))
headers = _attachment_headers(f"{str(target['torrent_hash'])[:12]}-files.zip", "application/zip")
headers["X-PyTorrent-Download-Mode"] = "temporary-token"
return Response(stream_with_context(_stream_torrent_files_zip(profile, items)), headers=headers, direct_passthrough=True)
if kind == "torrent_file_download":
item = rtorrent.export_torrent_file(profile, target["torrent_hash"])
return _send_staged_torrent_file(profile, item["path"], item["download_name"], bool(item.get("local")))
if kind == "torrent_files_zip_download":
hashes = [str(item) for item in (target.get("hashes") or []) if str(item).strip()]
if not hashes:
abort(404)
staged_paths = []
PYTORRENT_TMP_DIR.mkdir(parents=True, exist_ok=True)
tmp = tempfile.NamedTemporaryFile(prefix="pytorrent-torrents-", suffix=".zip", delete=False, dir=str(PYTORRENT_TMP_DIR))
tmp.close()
try:
with zipfile.ZipFile(tmp.name, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
used_names = set()
for torrent_hash in hashes:
item = rtorrent.export_torrent_file(profile, torrent_hash)
staged_paths.append((item["path"], bool(item.get("local"))))
name = Path(item["download_name"]).name or f"{torrent_hash}.torrent"
base_name = name
counter = 2
while name in used_names:
stem = Path(base_name).stem
name = f"{stem}-{counter}.torrent"
counter += 1
used_names.add(name)
archive.writestr(name, _read_staged_file(profile, item["path"], bool(item.get("local"))))
response = send_file(tmp.name, as_attachment=True, download_name="pytorrent-torrents.zip")
def cleanup():
for path, is_local in staged_paths:
_cleanup_staged_file(profile, path, is_local)
try:
Path(tmp.name).unlink()
except Exception:
pass
response.call_on_close(cleanup)
return response
except Exception:
for path, is_local in staged_paths:
_cleanup_staged_file(profile, path, is_local)
try:
Path(tmp.name).unlink()
except Exception:
pass
raise
abort(404)
@bp.get("/docs") @bp.get("/docs")
def docs(): def docs():
html = f"""<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>pyTorrent API Docs</title><link rel="stylesheet" href="{_asset_url('swagger_css')}"></head><body><div id="swagger-ui"></div><script src="{_asset_url('swagger_js')}"></script><script>window.onload=()=>SwaggerUIBundle({{url:'/api/openapi.json',dom_id:'#swagger-ui',deepLinking:true,persistAuthorization:true}});</script></body></html>""" html = f"""<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>pyTorrent API Docs</title><link rel="stylesheet" href="{_asset_url('swagger_css')}"></head><body><div id="swagger-ui"></div><script src="{_asset_url('swagger_js')}"></script><script>window.onload=()=>SwaggerUIBundle({{url:'/api/openapi.json',dom_id:'#swagger-ui',deepLinking:true,persistAuthorization:true}});</script></body></html>"""

View File

@@ -2,65 +2,109 @@ from __future__ import annotations
from ._shared import * from ._shared import *
def _active_profile_or_400():
profile = preferences.active_profile()
if not profile:
return None
return profile
@bp.get("/rss") @bp.get("/rss")
def rss_list(): def rss_list():
profile = preferences.active_profile() profile = _active_profile_or_400()
pid = profile["id"] if profile else None if not profile:
return ok({"feeds": [], "rules": [], "history": []})
pid = int(profile["id"])
with connect() as conn: with connect() as conn:
feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY name", (default_user_id(), pid)).fetchall() feeds = conn.execute("SELECT * FROM rss_feeds WHERE profile_id=? ORDER BY name", (pid,)).fetchall()
rules = conn.execute("SELECT * FROM rss_rules WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY name", (default_user_id(), pid)).fetchall() rules = conn.execute("SELECT * FROM rss_rules WHERE profile_id=? ORDER BY name", (pid,)).fetchall()
history = conn.execute("SELECT * FROM rss_history WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY id DESC LIMIT 80", (default_user_id(), pid)).fetchall() history = conn.execute("SELECT * FROM rss_history WHERE profile_id=? ORDER BY id DESC LIMIT 80", (pid,)).fetchall()
return ok({"feeds": feeds, "rules": rules, "history": history}) return ok({"feeds": feeds, "rules": rules, "history": history})
@bp.post("/rss/feeds") @bp.post("/rss/feeds")
def rss_feed_save(): def rss_feed_save():
profile = preferences.active_profile() profile = _active_profile_or_400()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
now = utcnow() now = utcnow()
feed_id = data.get("id") feed_id = data.get("id")
pid = int(profile["id"])
with connect() as conn: with connect() as conn:
if feed_id: if feed_id:
conn.execute("UPDATE rss_feeds SET name=?,url=?,enabled=?,interval_minutes=?,updated_at=? WHERE id=? AND user_id=?", (data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, feed_id, default_user_id())) conn.execute(
"UPDATE rss_feeds SET name=?,url=?,enabled=?,interval_minutes=?,updated_at=? WHERE id=? AND profile_id=?",
(data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, feed_id, pid),
)
else: else:
conn.execute("INSERT INTO rss_feeds(user_id,profile_id,name,url,enabled,interval_minutes,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)", (default_user_id(), profile["id"] if profile else None, data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, now)) conn.execute(
"INSERT INTO rss_feeds(profile_id,name,url,enabled,interval_minutes,created_at,updated_at) VALUES(?,?,?,?,?,?,?)",
(pid, data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, now),
)
return rss_list() return rss_list()
@bp.delete("/rss/feeds/<int:feed_id>") @bp.delete("/rss/feeds/<int:feed_id>")
def rss_feed_delete(feed_id: int): def rss_feed_delete(feed_id: int):
profile = _active_profile_or_400()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
with connect() as conn: with connect() as conn:
conn.execute("DELETE FROM rss_feeds WHERE id=? AND user_id=?", (feed_id, default_user_id())) conn.execute("DELETE FROM rss_feeds WHERE id=? AND profile_id=?", (feed_id, int(profile["id"])))
return rss_list() return rss_list()
@bp.post("/rss/rules") @bp.post("/rss/rules")
def rss_rule_save(): def rss_rule_save():
profile = preferences.active_profile() profile = _active_profile_or_400()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
now = utcnow() now = utcnow()
rule_id = data.get("id") rule_id = data.get("id")
values = (data.get("name") or "Rule", data.get("pattern") or ".*", data.get("exclude_pattern") or "", int(data.get("min_size_mb") or 0), int(data.get("max_size_mb") or 0), data.get("category") or "", data.get("quality") or "", data.get("season") or None, data.get("episode") or None, data.get("save_path") or active_default_download_path(profile), data.get("label") or "", 1 if data.get("start", True) else 0, 1 if data.get("enabled", True) else 0, now) pid = int(profile["id"])
values = (
data.get("name") or "Rule",
data.get("pattern") or ".*",
data.get("exclude_pattern") or "",
int(data.get("min_size_mb") or 0),
int(data.get("max_size_mb") or 0),
data.get("category") or "",
data.get("quality") or "",
data.get("season") or None,
data.get("episode") or None,
data.get("save_path") or active_default_download_path(profile),
data.get("label") or "",
1 if data.get("start", True) else 0,
1 if data.get("enabled", True) else 0,
now,
)
with connect() as conn: with connect() as conn:
if rule_id: if rule_id:
conn.execute("UPDATE rss_rules SET name=?,pattern=?,exclude_pattern=?,min_size_mb=?,max_size_mb=?,category=?,quality=?,season=?,episode=?,save_path=?,label=?,start=?,enabled=?,updated_at=? WHERE id=? AND user_id=?", (*values, rule_id, default_user_id())) conn.execute(
"UPDATE rss_rules SET name=?,pattern=?,exclude_pattern=?,min_size_mb=?,max_size_mb=?,category=?,quality=?,season=?,episode=?,save_path=?,label=?,start=?,enabled=?,updated_at=? WHERE id=? AND profile_id=?",
(*values, rule_id, pid),
)
else: else:
conn.execute("INSERT INTO rss_rules(user_id,profile_id,name,pattern,exclude_pattern,min_size_mb,max_size_mb,category,quality,season,episode,save_path,label,start,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (default_user_id(), profile["id"] if profile else None, *values, now)) conn.execute(
"INSERT INTO rss_rules(profile_id,name,pattern,exclude_pattern,min_size_mb,max_size_mb,category,quality,season,episode,save_path,label,start,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(pid, *values, now),
)
return rss_list() return rss_list()
@bp.delete("/rss/rules/<int:rule_id>") @bp.delete("/rss/rules/<int:rule_id>")
def rss_rule_delete(rule_id: int): def rss_rule_delete(rule_id: int):
profile = _active_profile_or_400()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
with connect() as conn: with connect() as conn:
conn.execute("DELETE FROM rss_rules WHERE id=? AND user_id=?", (rule_id, default_user_id())) conn.execute("DELETE FROM rss_rules WHERE id=? AND profile_id=?", (rule_id, int(profile["id"])))
return rss_list() return rss_list()
@bp.post("/rss/rules/test") @bp.post("/rss/rules/test")
def rss_rule_test(): def rss_rule_test():
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
@@ -71,12 +115,9 @@ def rss_rule_test():
return jsonify({"ok": False, "error": str(exc)}), 400 return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/rss/check") @bp.post("/rss/check")
def rss_check(): def rss_check():
profile = preferences.active_profile() profile = preferences.active_profile()
if not profile: if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400 return jsonify({"ok": False, "error": "No profile"}), 400
return ok(rss_service.check(profile, default_user_id(), only_due=False)) return ok(rss_service.check(profile, only_due=False))

View File

@@ -198,12 +198,17 @@ def cleanup_jobs():
@bp.post("/cleanup/smart-queue") @bp.post("/cleanup/smart-queue")
def cleanup_smart_queue(): def cleanup_smart_queue():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
profile_id = int(profile["id"])
with connect() as conn: with connect() as conn:
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='smart_queue_history'").fetchone() exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='smart_queue_history'").fetchone()
if not exists: if not exists:
deleted = 0 deleted = 0
else: else:
cur = conn.execute("DELETE FROM smart_queue_history") # Note: Cleanup is limited to the active profile so read/write permissions never affect other profiles.
cur = conn.execute("DELETE FROM smart_queue_history WHERE profile_id=?", (profile_id,))
deleted = int(cur.rowcount or 0) deleted = int(cur.rowcount or 0)
return ok({"deleted": deleted, "cleanup": cleanup_summary()}) return ok({"deleted": deleted, "cleanup": cleanup_summary()})
@@ -232,18 +237,34 @@ def cleanup_planner():
@bp.post("/cleanup/automations") @bp.post("/cleanup/automations")
def cleanup_automations(): def cleanup_automations():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
profile_id = int(profile["id"])
with connect() as conn: with connect() as conn:
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='automation_history'").fetchone() exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='automation_history'").fetchone()
if not exists: if not exists:
deleted = 0 deleted = 0
else: else:
# Note: Cleanup panel removes only automation logs, not saved automation rules. # Note: Cleanup panel removes only active profile automation logs, not saved automation rules.
cur = conn.execute("DELETE FROM automation_history") cur = conn.execute("DELETE FROM automation_history WHERE profile_id=?", (profile_id,))
deleted = int(cur.rowcount or 0) deleted = int(cur.rowcount or 0)
return ok({"deleted": deleted, "cleanup": cleanup_summary()}) return ok({"deleted": deleted, "cleanup": cleanup_summary()})
@bp.post("/cleanup/poller-diagnostics")
def cleanup_poller_diagnostics():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
profile_id = int(profile["id"])
# Note: This cleanup clears only in-memory poller diagnostics; polling, settings and torrent state are preserved.
runtime = poller_control.reset_runtime_stats(profile_id)
return ok({"deleted": {"poller_runtime_counters": 1}, "runtime": runtime, "cleanup": cleanup_summary()})
@bp.post("/cleanup/all") @bp.post("/cleanup/all")
def cleanup_all(): def cleanup_all():
deleted_jobs = clear_jobs() deleted_jobs = clear_jobs()
@@ -256,13 +277,13 @@ def cleanup_all():
if not exists: if not exists:
deleted_smart = 0 deleted_smart = 0
else: else:
cur = conn.execute("DELETE FROM smart_queue_history") cur = conn.execute("DELETE FROM smart_queue_history WHERE profile_id=?", (active_profile_id,))
deleted_smart = int(cur.rowcount or 0) deleted_smart = int(cur.rowcount or 0)
exists_auto = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='automation_history'").fetchone() exists_auto = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='automation_history'").fetchone()
if not exists_auto: if not exists_auto:
deleted_auto = 0 deleted_auto = 0
else: else:
cur = conn.execute("DELETE FROM automation_history") cur = conn.execute("DELETE FROM automation_history WHERE profile_id=?", (active_profile_id,))
deleted_auto = int(cur.rowcount or 0) deleted_auto = int(cur.rowcount or 0)
return ok({"deleted": {"jobs": deleted_jobs, "smart_queue_history": deleted_smart, "operation_logs": deleted_logs, "planner_history": deleted_planner, "automation_history": deleted_auto}, "cleanup": cleanup_summary()}) return ok({"deleted": {"jobs": deleted_jobs, "smart_queue_history": deleted_smart, "operation_logs": deleted_logs, "planner_history": deleted_planner, "automation_history": deleted_auto}, "cleanup": cleanup_summary()})

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from ._shared import * from ._shared import *
from ..services import torrent_creator from ..services import pdf_preview_links, torrent_creator
from ..services.reverse_dns import attach_reverse_dns from ..services.reverse_dns import attach_reverse_dns
@bp.get("/torrents") @bp.get("/torrents")
@@ -98,6 +98,29 @@ def torrent_files(torrent_hash: str):
@bp.get("/torrents/<torrent_hash>/files/<int:file_index>/mediainfo")
def torrent_file_media_info(torrent_hash: str, file_index: int):
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
# Note: The route is additive and keeps all existing file endpoints unchanged.
media_info = rtorrent.torrent_file_media_info(profile, torrent_hash, file_index)
if media_info.get("kind") == "pdf":
link = pdf_preview_links.create_pdf_preview_link(
torrent_hash,
file_index,
int(profile.get("id") or 0),
int(default_user_id() or 0),
)
# Note: The frontend receives an in-app temporary URL instead of exposing the API download endpoint in the new-tab action.
media_info["preview_url"] = url_for("main.pdf_preview", token=link["token"])
media_info["preview_expires_in"] = link["expires_in"]
return ok({"media_info": media_info})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/torrents/<torrent_hash>/files/priority") @bp.post("/torrents/<torrent_hash>/files/priority")
def torrent_file_priority(torrent_hash: str): def torrent_file_priority(torrent_hash: str):
profile = preferences.active_profile() profile = preferences.active_profile()
@@ -133,11 +156,12 @@ def torrent_folder_priority(torrent_hash: str):
return ok(result), status return ok(result), status
def _attachment_headers(download_name: str, content_type: str = "application/octet-stream") -> dict: def _attachment_headers(download_name: str, content_type: str = "application/octet-stream", disposition: str = "attachment") -> dict:
safe = Path(download_name or "download.bin").name or "download.bin" safe = Path(download_name or "download.bin").name or "download.bin"
safe_disposition = "inline" if disposition == "inline" else "attachment"
return { return {
"Content-Type": content_type, "Content-Type": content_type,
"Content-Disposition": f"attachment; filename*=UTF-8''{quote(safe)}", "Content-Disposition": f"{safe_disposition}; filename*=UTF-8''{quote(safe)}",
"X-Content-Type-Options": "nosniff", "X-Content-Type-Options": "nosniff",
} }
@@ -186,6 +210,77 @@ def _send_staged_file(profile: dict, path: str, download_name: str, local: bool
@bp.post("/torrents/<torrent_hash>/files/<int:file_index>/download-link")
def torrent_file_download_link(torrent_hash: str, file_index: int):
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
# Note: The API validates the file selection before returning a short-lived in-app /download URL to the UI.
rtorrent.torrent_download_file_info(profile, torrent_hash, file_index)
link = pdf_preview_links.create_file_download_link(torrent_hash, file_index, int(profile.get("id") or 0), int(default_user_id() or 0))
return ok({"url": url_for("main.temporary_download", token=link["token"]), "expires_in": link["expires_in"]})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/torrents/<torrent_hash>/files/download-link")
def torrent_file_download_link_from_body(torrent_hash: str):
data = request.get_json(silent=True) or {}
try:
file_index = int(data.get("file_index"))
except Exception:
return jsonify({"ok": False, "error": "file_index is required"}), 400
return torrent_file_download_link(torrent_hash, file_index)
@bp.post("/torrents/<torrent_hash>/files/download.zip/link")
def torrent_files_download_zip_link(torrent_hash: str):
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {}
try:
indexes = data.get("indexes") or None
# Note: ZIP link creation validates the requested files through the same service used by the direct download endpoint.
rtorrent.torrent_download_zip_items(profile, torrent_hash, indexes)
link = pdf_preview_links.create_file_zip_download_link(torrent_hash, indexes, int(profile.get("id") or 0), int(default_user_id() or 0))
return ok({"url": url_for("main.temporary_download", token=link["token"]), "expires_in": link["expires_in"]})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.get("/torrents/<torrent_hash>/torrent-file/link")
def torrent_file_export_link(torrent_hash: str):
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
# Note: Create only a short-lived link here; the actual .torrent export runs once when the browser opens /download/<token>.
link = pdf_preview_links.create_torrent_file_download_link(torrent_hash, int(profile.get("id") or 0), int(default_user_id() or 0))
return ok({"url": url_for("main.temporary_download", token=link["token"]), "expires_in": link["expires_in"]})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/torrents/torrent-files.zip/link")
def torrent_files_export_zip_link():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {}
hashes = [str(h) for h in (data.get("hashes") or []) if str(h).strip()]
if not hashes:
return jsonify({"ok": False, "error": "No torrents selected"}), 400
try:
# Note: Store only the selected hashes in the temporary token; exporting each .torrent now happens once during the real ZIP download.
link = pdf_preview_links.create_torrent_files_zip_download_link(hashes, int(profile.get("id") or 0), int(default_user_id() or 0))
return ok({"url": url_for("main.temporary_download", token=link["token"]), "expires_in": link["expires_in"]})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.get("/torrents/<torrent_hash>/files/<int:file_index>/download") @bp.get("/torrents/<torrent_hash>/files/<int:file_index>/download")
def torrent_file_download(torrent_hash: str, file_index: int): def torrent_file_download(torrent_hash: str, file_index: int):
profile = preferences.active_profile() profile = preferences.active_profile()
@@ -194,7 +289,10 @@ def torrent_file_download(torrent_hash: str, file_index: int):
try: try:
item = rtorrent.torrent_download_file_info(profile, torrent_hash, file_index) item = rtorrent.torrent_download_file_info(profile, torrent_hash, file_index)
size = int(item.get("size") or 0) size = int(item.get("size") or 0)
headers = _attachment_headers(item.get("download_name") or "file.bin") download_name = item.get("download_name") or "file.bin"
inline_pdf = str(request.args.get("disposition") or "").lower() == "inline" and Path(download_name).suffix.lower() == ".pdf"
# Note: Inline mode is limited to PDFs so the existing download behavior remains unchanged for every other file type.
headers = _attachment_headers(download_name, "application/pdf" if inline_pdf else "application/octet-stream", "inline" if inline_pdf else "attachment")
if size > 0: if size > 0:
headers["Content-Length"] = str(size) headers["Content-Length"] = str(size)
def generate(): def generate():

View File

@@ -6,10 +6,20 @@ import secrets
from urllib.parse import urlparse from urllib.parse import urlparse
from flask import abort, g, jsonify, redirect, request, session, url_for from flask import abort, g, has_request_context, jsonify, redirect, request, session, url_for
from werkzeug.security import check_password_hash, generate_password_hash from werkzeug.security import check_password_hash, generate_password_hash
from ..config import AUTH_ENABLE from ..config import (
AUTH_ENABLE,
AUTH_PROVIDER,
AUTH_PROXY_AUTO_CREATE,
AUTH_PROXY_AUTO_CREATE_PERMISSION,
AUTH_PROXY_AUTO_CREATE_ROLE,
AUTH_PROXY_USER_HEADER,
API_ALLOWED_ORIGINS,
AUTH_BYPASS_HOSTS,
AUTH_BYPASS_USER,
)
from ..db import connect, default_user_id, utcnow from ..db import connect, default_user_id, utcnow
PUBLIC_ENDPOINTS = {"main.login", "main.logout", "api.auth_login", "api.auth_me", "static"} PUBLIC_ENDPOINTS = {"main.login", "main.logout", "api.auth_login", "api.auth_me", "static"}
@@ -21,11 +31,16 @@ RTORRENT_WRITE_PREFIXES = (
"/api/rss", "/api/rss",
"/api/smart-queue", "/api/smart-queue",
"/api/automations", "/api/automations",
"/api/download-planner",
"/api/poller/settings",
"/api/operation-logs",
"/api/jobs", "/api/jobs",
"/api/cleanup",
) )
RTORRENT_CONFIG_PREFIXES = ("/api/rtorrent-config",) RTORRENT_CONFIG_PREFIXES = ("/api/rtorrent-config",)
ADMIN_PREFIXES = ("/api/auth/users", "/api/profiles") ADMIN_PREFIXES = ("/api/auth/users", "/api/profiles")
# Note: API reads that expose rTorrent/profile data must also respect profile permissions. # Note: API reads that expose rTorrent/profile data must also respect profile permissions.
# Note: Planner, poller and operation-log endpoints are profile-scoped and must follow the active profile context.
PROFILE_READ_PREFIXES = ( PROFILE_READ_PREFIXES = (
"/api/torrents", "/api/torrents",
"/api/torrent-stats", "/api/torrent-stats",
@@ -40,6 +55,9 @@ PROFILE_READ_PREFIXES = (
"/api/smart-queue", "/api/smart-queue",
"/api/traffic/history", "/api/traffic/history",
"/api/automations", "/api/automations",
"/api/download-planner",
"/api/poller/settings",
"/api/operation-logs",
) )
@@ -47,16 +65,77 @@ def enabled() -> bool:
return bool(AUTH_ENABLE) return bool(AUTH_ENABLE)
def provider() -> str:
return AUTH_PROVIDER if AUTH_PROVIDER in {"local", "proxy", "tinyauth"} else "local"
def uses_external_provider() -> bool:
return enabled() and provider() in {"proxy", "tinyauth"}
def external_auth_summary() -> dict[str, Any]:
# Note: Exposes safe auth-mode facts for the Users panel without leaking secrets.
return {
"enabled": enabled(),
"provider": provider(),
"external": uses_external_provider(),
"auto_create": bool(AUTH_PROXY_AUTO_CREATE) if uses_external_provider() else False,
"auto_create_role": AUTH_PROXY_AUTO_CREATE_ROLE,
"auto_create_permission": AUTH_PROXY_AUTO_CREATE_PERMISSION,
"bypass_enabled": bool(AUTH_BYPASS_HOSTS),
"bypass_hosts": sorted(AUTH_BYPASS_HOSTS),
"bypass_user": AUTH_BYPASS_USER,
"password_editable": not uses_external_provider(),
}
def password_hash(password: str) -> str: def password_hash(password: str) -> str:
return generate_password_hash(password or "") return generate_password_hash(password or "")
def _host_matches_bypass(host: str) -> bool:
clean = str(host or "").strip().lower()
if not clean:
return False
return clean in AUTH_BYPASS_HOSTS or clean.split(":", 1)[0] in AUTH_BYPASS_HOSTS
def auth_bypassed_request() -> bool:
# Note: Allows trusted direct-IP access to keep auth enabled for reverse-proxy traffic.
if not enabled() or not AUTH_BYPASS_HOSTS or not has_request_context():
return False
return _host_matches_bypass(request.host)
def bypass_user_id() -> int:
"""Return the configured active user id used for trusted auth-bypass requests."""
username = str(AUTH_BYPASS_USER or "admin").strip() or "admin"
with connect() as conn:
row = conn.execute("SELECT id FROM users WHERE username=? AND is_active=1", (username,)).fetchone()
if row:
return int(row["id"])
# Note: Keep direct-IP access usable after old installs, but never choose an inactive fallback.
row = conn.execute("SELECT id FROM users WHERE username='admin' AND is_active=1").fetchone()
if row:
return int(row["id"])
row = conn.execute("SELECT id FROM users WHERE id=? AND is_active=1", (default_user_id(),)).fetchone()
return int(row["id"]) if row else 0
def current_user_id() -> int: def current_user_id() -> int:
if not enabled(): if not enabled():
return default_user_id() return default_user_id()
if not has_request_context():
# Note: Background jobs and schedulers do not have Flask request/session state.
return 0
if auth_bypassed_request():
return bypass_user_id()
api_user_id = getattr(g, "api_user_id", None) api_user_id = getattr(g, "api_user_id", None)
if api_user_id: if api_user_id:
return int(api_user_id) return int(api_user_id)
external_user_id = getattr(g, "external_user_id", None)
if external_user_id:
return int(external_user_id)
try: try:
return int(session.get("user_id") or 0) return int(session.get("user_id") or 0)
except Exception: except Exception:
@@ -69,7 +148,7 @@ def current_user() -> dict[str, Any] | None:
return None return None
with connect() as conn: with connect() as conn:
return conn.execute( return conn.execute(
"SELECT id, username, role, is_active, created_at, updated_at FROM users WHERE id=?", "SELECT id, username, email, display_name, external_auth_provider, external_subject, role, is_active, created_at, updated_at FROM users WHERE id=?",
(uid,), (uid,),
).fetchone() ).fetchone()
@@ -153,14 +232,29 @@ def visible_profile_ids(user_id: int | None = None) -> set[int] | None:
def _origin_key(value: str) -> str:
parsed = urlparse(str(value or "").strip())
if not parsed.scheme or not parsed.netloc:
return ""
return f"{parsed.scheme.lower()}://{parsed.netloc.lower()}"
def _request_origin() -> str:
return _origin_key(f"{request.scheme}://{request.host}")
def same_origin_request() -> bool: def same_origin_request() -> bool:
"""Return False only when an unsafe request clearly comes from another origin.""" """Return False only when an unsafe API request clearly comes from an untrusted origin."""
origin = request.headers.get("Origin") or request.headers.get("Referer") origin = request.headers.get("Origin") or request.headers.get("Referer")
if not origin: if not origin:
return True return True
try: try:
parsed = urlparse(origin) source_origin = _origin_key(origin)
return parsed.scheme == request.scheme and parsed.netloc == request.host if not source_origin:
return False
if source_origin == _request_origin():
return True
return source_origin in set(API_ALLOWED_ORIGINS)
except Exception: except Exception:
return False return False
@@ -200,6 +294,8 @@ def require_profile_write(profile_id: int | None) -> None:
def login_user(username: str, password: str) -> dict[str, Any] | None: def login_user(username: str, password: str) -> dict[str, Any] | None:
if not enabled(): if not enabled():
return {"id": default_user_id(), "username": "default", "role": "admin", "is_active": 1} return {"id": default_user_id(), "username": "default", "role": "admin", "is_active": 1}
if uses_external_provider():
return None
with connect() as conn: with connect() as conn:
user = conn.execute("SELECT * FROM users WHERE username=?", (username.strip(),)).fetchone() user = conn.execute("SELECT * FROM users WHERE username=?", (username.strip(),)).fetchone()
if not user or not int(user.get("is_active") or 0): if not user or not int(user.get("is_active") or 0):
@@ -213,6 +309,139 @@ def login_user(username: str, password: str) -> dict[str, Any] | None:
return current_user() return current_user()
def _clean_header_value(name: str) -> str:
if not name:
return ""
value = request.headers.get(name) or request.headers.get(name.lower()) or request.headers.get(name.upper()) or ""
return str(value).strip()
def _safe_username(value: str, fallback: str = "external-user") -> str:
raw = str(value or "").strip()
if "@" in raw:
raw = raw.split("@", 1)[0]
clean = "".join(ch for ch in raw if ch.isalnum() or ch in {".", "_", "-"}).strip("._-")
return (clean or fallback)[:80]
def _external_identity_from_headers() -> dict[str, str] | None:
# Note: Tinyauth and generic proxy auth use a single trusted username header.
username = _clean_header_value(AUTH_PROXY_USER_HEADER)
if not username:
return None
safe_username = _safe_username(username)
return {
"provider": provider(),
"username": safe_username,
"subject": safe_username,
}
def _grant_default_external_permissions(conn, user_id: int, now: str) -> None:
# Note: Admins can see and write all profiles through role-based access.
if AUTH_PROXY_AUTO_CREATE_PERMISSION == "none" or AUTH_PROXY_AUTO_CREATE_ROLE == "admin":
return
conn.execute(
"INSERT OR IGNORE INTO user_profile_permissions(user_id,profile_id,access_level,created_at,updated_at) VALUES(?,?,?,?,?)",
(user_id, 0, AUTH_PROXY_AUTO_CREATE_PERMISSION, now, now),
)
def _sync_external_auto_created_user(conn, user: dict[str, Any], now: str) -> None:
# Note: Passwordless external users follow the external auto-create defaults on login.
if not AUTH_PROXY_AUTO_CREATE or user.get("password_hash"):
return
if user.get("external_auth_provider") and user.get("external_auth_provider") != provider():
return
user_id = int(user["id"])
conn.execute("UPDATE users SET role=?, updated_at=? WHERE id=?", (AUTH_PROXY_AUTO_CREATE_ROLE, now, user_id))
if AUTH_PROXY_AUTO_CREATE_ROLE == "admin" or AUTH_PROXY_AUTO_CREATE_PERMISSION == "none":
conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (user_id,))
return
conn.execute(
"INSERT OR REPLACE INTO user_profile_permissions(user_id,profile_id,access_level,created_at,updated_at) VALUES(?,?,?,?,?)",
(user_id, 0, AUTH_PROXY_AUTO_CREATE_PERMISSION, now, now),
)
def authenticate_external_user() -> dict[str, Any] | None:
if not uses_external_provider():
return None
identity = _external_identity_from_headers()
if not identity:
return None
now = utcnow()
with connect() as conn:
user = None
if identity["subject"]:
user = conn.execute(
"SELECT * FROM users WHERE external_auth_provider=? AND external_subject=?",
(identity["provider"], identity["subject"]),
).fetchone()
if not user:
user = conn.execute("SELECT * FROM users WHERE username=?", (identity["username"],)).fetchone()
if not user:
if not AUTH_PROXY_AUTO_CREATE:
return None
cur = conn.execute(
"""INSERT INTO users(username,password_hash,email,display_name,external_auth_provider,external_subject,role,is_active,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)""",
(
identity["username"],
None,
None,
None,
identity["provider"],
identity["subject"] or identity["username"],
AUTH_PROXY_AUTO_CREATE_ROLE,
1,
now,
now,
),
)
user_id = int(cur.lastrowid)
_grant_default_external_permissions(conn, user_id, now)
user = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
else:
user_id = int(user["id"])
conn.execute(
"""UPDATE users
SET external_auth_provider=?,
external_subject=COALESCE(NULLIF(?, ''), external_subject),
updated_at=?
WHERE id=?""",
(identity["provider"], identity["subject"], now, user_id),
)
user = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
if user:
_sync_external_auto_created_user(conn, user, now)
user = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
if not user or not int(user.get("is_active") or 0):
return None
g.external_user_id = int(user["id"])
session["user_id"] = int(user["id"])
session["username"] = user.get("username")
session["role"] = user.get("role") or "user"
return _public_user(user)
def ensure_request_user() -> int:
# Note: Socket.IO events do not go through Flask before_request like normal REST calls,
# so external proxy auth must be resolved explicitly during the Socket.IO handshake/events.
if not enabled():
return default_user_id()
if auth_bypassed_request():
return bypass_user_id()
uid = current_user_id()
if uid:
return uid
if uses_external_provider():
authenticate_external_user()
return current_user_id()
def logout_user() -> None: def logout_user() -> None:
session.clear() session.clear()
@@ -236,7 +465,7 @@ def list_users() -> list[dict[str, Any]]:
require_admin() require_admin()
with connect() as conn: with connect() as conn:
users = conn.execute( users = conn.execute(
"SELECT id, username, role, is_active, created_at, updated_at FROM users ORDER BY username COLLATE NOCASE" "SELECT id, username, email, display_name, external_auth_provider, external_subject, role, is_active, created_at, updated_at FROM users ORDER BY username COLLATE NOCASE"
).fetchall() ).fetchall()
perms = conn.execute( perms = conn.execute(
"SELECT user_id, profile_id, access_level FROM user_profile_permissions ORDER BY user_id, profile_id" "SELECT user_id, profile_id, access_level FROM user_profile_permissions ORDER BY user_id, profile_id"
@@ -263,6 +492,7 @@ def save_user(data: dict[str, Any], user_id: int | None = None) -> dict[str, Any
username = str(data.get("username") or "").strip() username = str(data.get("username") or "").strip()
role = "admin" if data.get("role") == "admin" else "user" role = "admin" if data.get("role") == "admin" else "user"
is_active = 1 if data.get("is_active", True) else 0 is_active = 1 if data.get("is_active", True) else 0
password_editable = not uses_external_provider()
if not username: if not username:
raise ValueError("Username is required") raise ValueError("Username is required")
with connect() as conn: with connect() as conn:
@@ -271,16 +501,19 @@ def save_user(data: dict[str, Any], user_id: int | None = None) -> dict[str, Any
if not row: if not row:
raise ValueError("User does not exist") raise ValueError("User does not exist")
conn.execute( conn.execute(
"UPDATE users SET username=?, role=?, is_active=?, updated_at=? WHERE id=?", "UPDATE users SET username=?, email=?, display_name=?, role=?, is_active=?, updated_at=? WHERE id=?",
(username, role, is_active, now, user_id), (username, str(data.get("email") or "").strip() or None, str(data.get("display_name") or "").strip() or None, role, is_active, now, user_id),
) )
else: else:
initial_password_hash = password_hash(str(data.get("password") or username)) if password_editable else None
# Note: TinyAuth/proxy users are passwordless in pyTorrent; credentials stay with the auth provider.
cur = conn.execute( cur = conn.execute(
"INSERT INTO users(username,password_hash,role,is_active,created_at,updated_at) VALUES(?,?,?,?,?,?)", "INSERT INTO users(username,password_hash,email,display_name,role,is_active,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)",
(username, password_hash(str(data.get("password") or username)), role, is_active, now, now), (username, initial_password_hash, str(data.get("email") or "").strip() or None, str(data.get("display_name") or "").strip() or None, role, is_active, now, now),
) )
user_id = int(cur.lastrowid) user_id = int(cur.lastrowid)
if data.get("password"): if data.get("password") and password_editable:
# Note: Password changes are intentionally disabled for external auth providers.
conn.execute("UPDATE users SET password_hash=?, updated_at=? WHERE id=?", (password_hash(str(data.get("password"))), now, user_id)) conn.execute("UPDATE users SET password_hash=?, updated_at=? WHERE id=?", (password_hash(str(data.get("password"))), now, user_id))
if role != "admin": if role != "admin":
conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (user_id,)) conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (user_id,))
@@ -293,7 +526,7 @@ def save_user(data: dict[str, Any], user_id: int | None = None) -> dict[str, Any
) )
else: else:
conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (user_id,)) conn.execute("DELETE FROM user_profile_permissions WHERE user_id=?", (user_id,))
return conn.execute("SELECT id, username, role, is_active, created_at, updated_at FROM users WHERE id=?", (user_id,)).fetchone() return conn.execute("SELECT id, username, email, display_name, external_auth_provider, external_subject, role, is_active, created_at, updated_at FROM users WHERE id=?", (user_id,)).fetchone()
def delete_user(user_id: int) -> None: def delete_user(user_id: int) -> None:
@@ -323,6 +556,10 @@ def _public_user(row: dict[str, Any] | None) -> dict[str, Any] | None:
return { return {
"id": int(row["id"]), "id": int(row["id"]),
"username": row.get("username"), "username": row.get("username"),
"email": row.get("email"),
"display_name": row.get("display_name"),
"external_auth_provider": row.get("external_auth_provider"),
"external_subject": row.get("external_subject"),
"role": row.get("role") or "user", "role": row.get("role") or "user",
"is_active": int(row.get("is_active") or 0), "is_active": int(row.get("is_active") or 0),
"created_at": row.get("created_at"), "created_at": row.get("created_at"),
@@ -352,7 +589,7 @@ def list_api_tokens(user_id: int) -> list[dict[str, Any]]:
abort(403) abort(403)
with connect() as conn: with connect() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT id,user_id,name,token_prefix,last_used_at,created_at,updated_at,revoked_at FROM api_tokens WHERE user_id=? ORDER BY created_at DESC", "SELECT id,user_id,name,token_prefix,last_used_at,created_at,updated_at,revoked_at FROM api_tokens WHERE user_id=? AND revoked_at IS NULL ORDER BY created_at DESC",
(uid,), (uid,),
).fetchall() ).fetchall()
return [_token_response(row) for row in rows] return [_token_response(row) for row in rows]
@@ -396,10 +633,13 @@ def revoke_api_token(user_id: int, token_id: int) -> None:
abort(403) abort(403)
now = utcnow() now = utcnow()
with connect() as conn: with connect() as conn:
conn.execute( # Note: Report missing/already revoked tokens instead of showing a false success in the UI.
"UPDATE api_tokens SET revoked_at=COALESCE(revoked_at, ?), updated_at=? WHERE id=? AND user_id=?", cur = conn.execute(
"UPDATE api_tokens SET revoked_at=COALESCE(revoked_at, ?), updated_at=? WHERE id=? AND user_id=? AND revoked_at IS NULL",
(now, now, tid, uid), (now, now, tid, uid),
) )
if cur.rowcount <= 0:
raise ValueError("Active API token not found")
def authenticate_api_token(token: str) -> dict[str, Any] | None: def authenticate_api_token(token: str) -> dict[str, Any] | None:
@@ -439,12 +679,22 @@ def install_guards(app) -> None:
def _auth_guard(): def _auth_guard():
if not enabled(): if not enabled():
return None return None
# Allow unauthenticated health checks for monitoring.
if request.path == "/api/health" or request.path.startswith("/api/health/"):
return None
g.api_token_authenticated = False g.api_token_authenticated = False
if auth_bypassed_request():
return None
if request.path.startswith("/api/"): if request.path.startswith("/api/"):
token_user = authenticate_api_token(_request_api_token()) token_user = authenticate_api_token(_request_api_token())
if token_user: if token_user:
g.api_user_id = int(token_user["id"]) g.api_user_id = int(token_user["id"])
g.api_token_authenticated = True g.api_token_authenticated = True
if not getattr(g, "api_user_id", None):
authenticate_external_user()
endpoint = request.endpoint or "" endpoint = request.endpoint or ""
if endpoint in PUBLIC_ENDPOINTS or endpoint.startswith("static"): if endpoint in PUBLIC_ENDPOINTS or endpoint.startswith("static"):
return None return None

View File

@@ -5,15 +5,39 @@ import threading
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from ..db import connect, utcnow, default_user_id from ..db import connect, utcnow, default_user_id
from . import auth
# Note: Settings backups include persistent configuration tables only; volatile queues, caches, histories and tokens are intentionally skipped. # Note: Application backups are admin-only because they include users, permissions and all profiles.
BACKUP_TABLES = [ APP_BACKUP_TABLES = [
"users", "user_profile_permissions", "user_preferences", "rtorrent_profiles", "users", "user_profile_permissions", "user_preferences", "profile_preferences", "rtorrent_profiles",
"disk_monitor_preferences", "labels", "ratio_groups", "rss_feeds", "rss_rules", "disk_monitor_preferences", "labels", "ratio_groups", "rss_feeds", "rss_rules",
"smart_queue_settings", "smart_queue_exclusions", "automation_rules", "smart_queue_settings", "smart_queue_exclusions", "automation_rules",
"rtorrent_config_overrides", "app_settings", "download_plan_settings", "rtorrent_config_overrides", "poller_settings", "app_settings", "download_plan_settings",
] ]
# Note: Profile backups contain active profile data. User-specific preferences remain scoped to the current user.
PROFILE_BACKUP_TABLES = [
"rtorrent_profiles", "profile_preferences", "disk_monitor_preferences", "labels", "ratio_groups",
"rss_feeds", "rss_rules", "smart_queue_settings", "smart_queue_exclusions",
"automation_rules", "rtorrent_config_overrides", "poller_settings", "download_plan_settings",
]
PROFILE_TABLE_FILTERS = {
"rtorrent_profiles": "id=?",
"profile_preferences": "user_id=? AND profile_id=?",
"disk_monitor_preferences": "user_id=? AND profile_id=?",
"labels": "user_id=? AND profile_id=?",
"ratio_groups": "user_id=? AND profile_id=?",
"rss_feeds": "profile_id=?",
"rss_rules": "profile_id=?",
"smart_queue_settings": "profile_id=?",
"smart_queue_exclusions": "profile_id=?",
"automation_rules": "user_id=? AND profile_id=?",
"rtorrent_config_overrides": "profile_id=?",
"poller_settings": "profile_id=?",
"download_plan_settings": "user_id=? AND profile_id=?",
}
DEFAULT_AUTO_BACKUP_SETTINGS = { DEFAULT_AUTO_BACKUP_SETTINGS = {
"enabled": False, "enabled": False,
"interval_hours": 24, "interval_hours": 24,
@@ -22,101 +46,26 @@ DEFAULT_AUTO_BACKUP_SETTINGS = {
} }
BACKUP_PREVIEW_VALUE_LIMIT = 80 BACKUP_PREVIEW_VALUE_LIMIT = 80
BACKUP_PREVIEW_ROW_LIMIT = 3 BACKUP_PREVIEW_ROW_LIMIT = 3
BACKUP_PREVIEW_SENSITIVE_KEYS = { BACKUP_PREVIEW_SENSITIVE_KEYS = {"password", "password_hash", "token", "token_hash", "api_key", "secret"}
"password",
"password_hash",
"token",
"token_hash",
"api_key",
"secret",
}
AUTO_BACKUP_SETTINGS_KEY = "backup:auto" AUTO_BACKUP_SETTINGS_KEY = "backup:auto"
_scheduler_started = False _scheduler_started = False
_scheduler_lock = threading.Lock() _scheduler_lock = threading.Lock()
def create_backup(name: str, user_id: int | None = None, automatic: bool = False) -> dict: def _is_admin_user(user_id: int | None = None) -> bool:
"""Create a settings backup and return a table-count summary. if not auth.enabled():
return True
Note: The automatic flag is metadata only; restore/download behavior remains unchanged. uid = user_id or auth.current_user_id()
""" if not uid:
user_id = user_id or default_user_id() return False
payload = {"version": 1, "created_at": utcnow(), "automatic": bool(automatic), "tables": {}}
with connect() as conn: with connect() as conn:
for table in BACKUP_TABLES: row = conn.execute("SELECT role,is_active FROM users WHERE id=?", (uid,)).fetchone()
try: return bool(row and row.get("role") == "admin" and int(row.get("is_active") or 0))
payload["tables"][table] = conn.execute(f"SELECT * FROM {table}").fetchall()
except Exception:
payload["tables"][table] = []
cur = conn.execute(
"INSERT INTO app_backups(user_id,name,payload_json,created_at) VALUES(?,?,?,?)",
(user_id, name or f"Backup {payload['created_at']}", json.dumps(payload), payload["created_at"]),
)
backup_id = cur.lastrowid
return {"id": backup_id, "name": name, "created_at": payload["created_at"], "automatic": bool(automatic), "tables": {k: len(v) for k, v in payload["tables"].items()}}
def list_backups(user_id: int | None = None) -> list[dict]:
user_id = user_id or default_user_id()
with connect() as conn:
rows = conn.execute("SELECT id,name,created_at,payload_json FROM app_backups WHERE user_id=? ORDER BY id DESC", (user_id,)).fetchall()
result = []
for row in rows:
payload = _loads(row.get("payload_json") or "{}")
tables = payload.get("tables") or {}
result.append({
"id": row.get("id"),
"name": row.get("name"),
"created_at": row.get("created_at"),
"automatic": bool(payload.get("automatic")),
"tables": {key: len(value or []) for key, value in tables.items()},
})
return result
def payload_for_backup(backup_id: int, user_id: int | None = None) -> dict:
user_id = user_id or default_user_id()
with connect() as conn:
row = conn.execute("SELECT payload_json FROM app_backups WHERE id=? AND user_id=?", (backup_id, user_id)).fetchone()
if not row:
raise ValueError("Backup not found")
return json.loads(row["payload_json"] or "{}")
def restore_backup(backup_id: int, user_id: int | None = None) -> dict:
user_id = user_id or default_user_id()
payload = payload_for_backup(backup_id, user_id)
tables = payload.get("tables") or {}
restored = {}
with connect() as conn:
conn.execute("PRAGMA foreign_keys = OFF")
try:
for table in BACKUP_TABLES:
rows = tables.get(table) or []
if not rows:
continue
columns = list(rows[0].keys())
placeholders = ",".join("?" for _ in columns)
conn.execute(f"DELETE FROM {table}")
for row in rows:
conn.execute(f"INSERT INTO {table}({','.join(columns)}) VALUES({placeholders})", [row.get(col) for col in columns])
restored[table] = len(rows)
finally:
conn.execute("PRAGMA foreign_keys = ON")
return {"restored": restored}
def delete_backup(backup_id: int, user_id: int | None = None) -> dict:
user_id = user_id or default_user_id()
with connect() as conn:
cur = conn.execute(
"DELETE FROM app_backups WHERE id=? AND user_id=?",
(backup_id, user_id),
)
if not cur.rowcount:
raise ValueError("Backup not found")
return {"deleted": backup_id}
def _require_admin(user_id: int | None = None) -> None:
if not _is_admin_user(user_id):
raise PermissionError("Application backups are available only to admins")
def _loads(value: str) -> dict: def _loads(value: str) -> dict:
@@ -127,26 +76,240 @@ def _loads(value: str) -> dict:
return {} return {}
def _settings_row_key(user_id: int | None = None) -> str: def _table_columns(conn, table: str) -> set[str]:
return f"{AUTO_BACKUP_SETTINGS_KEY}:{user_id or default_user_id()}" try:
return {str(row["name"]) for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
except Exception:
return set()
def _latest_backup_created_at(user_id: int) -> str | None: def _table_rows(conn, table: str, where: str | None = None, params: tuple = ()) -> list[dict]:
"""Return the newest persisted backup timestamp for scheduler recovery after restarts. try:
sql = f"SELECT * FROM {table}" + (f" WHERE {where}" if where else "")
return [dict(row) for row in conn.execute(sql, params).fetchall()]
except Exception:
return []
Note: Automatic scheduling is based on the latest database backup record, so process
restarts cannot create repeated backups before the configured interval elapses. def _store_backup(user_id: int, name: str, backup_type: str, profile_id: int | None, payload: dict) -> dict:
""" with connect() as conn:
cur = conn.execute(
"INSERT INTO app_backups(user_id,name,backup_type,profile_id,payload_json,created_at) VALUES(?,?,?,?,?,?)",
(user_id, name or f"Backup {payload['created_at']}", backup_type, profile_id, json.dumps(payload), payload["created_at"]),
)
backup_id = cur.lastrowid
return {
"id": backup_id,
"name": name,
"backup_type": backup_type,
"profile_id": profile_id,
"created_at": payload["created_at"],
"automatic": bool(payload.get("automatic")),
"tables": {k: len(v) for k, v in (payload.get("tables") or {}).items()},
}
def create_app_backup(name: str, user_id: int | None = None, automatic: bool = False) -> dict:
user_id = user_id or auth.current_user_id() or default_user_id()
_require_admin(user_id)
payload = {"version": 2, "backup_type": "app", "created_at": utcnow(), "automatic": bool(automatic), "tables": {}}
with connect() as conn:
for table in APP_BACKUP_TABLES:
payload["tables"][table] = _table_rows(conn, table)
return _store_backup(user_id, name, "app", None, payload)
def create_profile_backup(name: str, profile_id: int, user_id: int | None = None, automatic: bool = False) -> dict:
user_id = user_id or auth.current_user_id() or default_user_id()
if not auth.can_access_profile(profile_id, user_id):
raise PermissionError("No access to profile")
payload = {"version": 2, "backup_type": "profile", "source_profile_id": int(profile_id), "created_at": utcnow(), "automatic": bool(automatic), "tables": {}}
with connect() as conn:
for table in PROFILE_BACKUP_TABLES:
where = PROFILE_TABLE_FILTERS.get(table)
if where == "id=?" or where == "profile_id=?":
params = (int(profile_id),)
else:
params = (user_id, int(profile_id))
payload["tables"][table] = _table_rows(conn, table, where, params)
return _store_backup(user_id, name, "profile", int(profile_id), payload)
def create_backup(name: str, user_id: int | None = None, automatic: bool = False) -> dict:
return create_app_backup(name, user_id, automatic)
def list_backups(user_id: int | None = None, backup_type: str | None = None, profile_id: int | None = None) -> list[dict]:
user_id = user_id or auth.current_user_id() or default_user_id()
clauses = ["user_id=?"]
params: list[object] = [user_id]
if backup_type:
clauses.append("COALESCE(backup_type,'app')=?")
params.append(backup_type)
if profile_id is not None:
clauses.append("profile_id=?")
params.append(int(profile_id))
with connect() as conn:
rows = conn.execute(
f"SELECT id,name,created_at,payload_json,COALESCE(backup_type,'app') AS backup_type,profile_id FROM app_backups WHERE {' AND '.join(clauses)} ORDER BY id DESC",
tuple(params),
).fetchall()
result = []
for row in rows:
payload = _loads(row.get("payload_json") or "{}")
tables = payload.get("tables") or {}
result.append({
"id": row.get("id"),
"name": row.get("name"),
"created_at": row.get("created_at"),
"backup_type": row.get("backup_type") or payload.get("backup_type") or "app",
"profile_id": row.get("profile_id") or payload.get("source_profile_id"),
"automatic": bool(payload.get("automatic")),
"tables": {key: len(value or []) for key, value in tables.items()},
})
return result
def payload_for_backup(backup_id: int, user_id: int | None = None) -> dict:
user_id = user_id or auth.current_user_id() or default_user_id()
with connect() as conn:
row = conn.execute("SELECT payload_json FROM app_backups WHERE id=? AND user_id=?", (backup_id, user_id)).fetchone()
if not row:
raise ValueError("Backup not found")
return json.loads(row["payload_json"] or "{}")
def _backup_type(payload: dict) -> str:
return str(payload.get("backup_type") or ("profile" if payload.get("source_profile_id") else "app"))
def restore_app_backup(backup_id: int, user_id: int | None = None) -> dict:
user_id = user_id or auth.current_user_id() or default_user_id()
_require_admin(user_id)
payload = payload_for_backup(backup_id, user_id)
if _backup_type(payload) != "app":
raise ValueError("This is not an application backup")
tables = payload.get("tables") or {}
restored = {}
with connect() as conn:
conn.execute("PRAGMA foreign_keys = OFF")
try:
for table in APP_BACKUP_TABLES:
rows = tables.get(table) or []
if not rows:
continue
available = _table_columns(conn, table)
columns = [col for col in rows[0].keys() if col in available]
if not columns:
continue
placeholders = ",".join("?" for _ in columns)
conn.execute(f"DELETE FROM {table}")
for row in rows:
conn.execute(f"INSERT INTO {table}({','.join(columns)}) VALUES({placeholders})", [row.get(col) for col in columns])
restored[table] = len(rows)
finally:
conn.execute("PRAGMA foreign_keys = ON")
return {"restored": restored, "backup_type": "app"}
def _rewrite_profile_row(table: str, row: dict, user_id: int, target_profile_id: int) -> dict:
clean = dict(row)
if table == "rtorrent_profiles":
clean["id"] = target_profile_id
clean["user_id"] = user_id
clean["is_default"] = int(clean.get("is_default") or 0)
return clean
if "profile_id" in clean:
clean["profile_id"] = target_profile_id
if "user_id" in clean:
clean["user_id"] = user_id
if table == "poller_settings":
clean["profile_id"] = target_profile_id
if "id" in clean and table != "rtorrent_profiles":
clean.pop("id", None)
return clean
def restore_profile_backup(backup_id: int, target_profile_id: int, user_id: int | None = None) -> dict:
user_id = user_id or auth.current_user_id() or default_user_id()
if not auth.can_write_profile(target_profile_id, user_id):
raise PermissionError("No write access to profile")
payload = payload_for_backup(backup_id, user_id)
if _backup_type(payload) != "profile":
raise ValueError("This is not a profile backup")
tables = payload.get("tables") or {}
restored = {}
with connect() as conn:
conn.execute("PRAGMA foreign_keys = OFF")
try:
for table in PROFILE_BACKUP_TABLES:
rows = tables.get(table) or []
where = PROFILE_TABLE_FILTERS.get(table)
if where == "id=?" or where == "profile_id=?":
params = (int(target_profile_id),)
else:
params = (user_id, int(target_profile_id))
conn.execute(f"DELETE FROM {table} WHERE {where}", params)
if not rows:
continue
count = 0
for row in rows:
clean = _rewrite_profile_row(table, dict(row), user_id, int(target_profile_id))
available = _table_columns(conn, table)
columns = [col for col in clean.keys() if col in available]
if not columns:
continue
placeholders = ",".join("?" for _ in columns)
conn.execute(f"INSERT INTO {table}({','.join(columns)}) VALUES({placeholders})", [clean.get(col) for col in columns])
count += 1
restored[table] = count
finally:
conn.execute("PRAGMA foreign_keys = ON")
return {"restored": restored, "backup_type": "profile", "profile_id": int(target_profile_id)}
def restore_backup(backup_id: int, user_id: int | None = None, profile_id: int | None = None) -> dict:
payload = payload_for_backup(backup_id, user_id)
if _backup_type(payload) == "profile":
target = profile_id or payload.get("source_profile_id")
if not target:
raise ValueError("Missing target profile")
return restore_profile_backup(backup_id, int(target), user_id)
return restore_app_backup(backup_id, user_id)
def delete_backup(backup_id: int, user_id: int | None = None) -> dict:
user_id = user_id or auth.current_user_id() or default_user_id()
with connect() as conn:
cur = conn.execute("DELETE FROM app_backups WHERE id=? AND user_id=?", (backup_id, user_id))
if not cur.rowcount:
raise ValueError("Backup not found")
return {"deleted": backup_id}
def _settings_row_key(user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> str:
uid = user_id or auth.current_user_id() or default_user_id()
scope = "profile" if backup_type == "profile" else "app"
if scope == "profile":
return f"{AUTO_BACKUP_SETTINGS_KEY}:profile:{uid}:{int(profile_id or 0)}"
return f"{AUTO_BACKUP_SETTINGS_KEY}:app:{uid}"
def _latest_backup_created_at(user_id: int, backup_type: str = "app", profile_id: int | None = None) -> str | None:
clauses = ["user_id=?", "COALESCE(backup_type,'app')=?"]
params: list[object] = [user_id, backup_type]
if backup_type == "profile":
clauses.append("profile_id=?")
params.append(int(profile_id or 0))
with connect() as conn: with connect() as conn:
row = conn.execute( row = conn.execute(
"SELECT created_at FROM app_backups WHERE user_id=? ORDER BY created_at DESC, id DESC LIMIT 1", f"SELECT created_at FROM app_backups WHERE {' AND '.join(clauses)} ORDER BY created_at DESC, id DESC LIMIT 1",
(user_id,), tuple(params),
).fetchone() ).fetchone()
return str(row["created_at"] or "") if row and row.get("created_at") else None return str(row["created_at"] or "") if row and row.get("created_at") else None
def _preview_value(value: object) -> object: def _preview_value(value: object) -> object:
"""Return a safe, compact value for backup previews without exposing secrets."""
if value is None or isinstance(value, (int, float, bool)): if value is None or isinstance(value, (int, float, bool)):
return value return value
text = str(value) text = str(value)
@@ -157,34 +320,34 @@ def _preview_row(row: dict) -> dict:
output = {} output = {}
for key, value in row.items(): for key, value in row.items():
lowered = str(key).lower() lowered = str(key).lower()
if any(secret in lowered for secret in BACKUP_PREVIEW_SENSITIVE_KEYS): output[key] = "[hidden]" if any(secret in lowered for secret in BACKUP_PREVIEW_SENSITIVE_KEYS) else _preview_value(value)
output[key] = "[hidden]"
else:
output[key] = _preview_value(value)
return output return output
def get_auto_backup_settings(user_id: int | None = None) -> dict: def get_auto_backup_settings(user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> dict:
"""Return automatic backup schedule settings for the current user. key = _settings_row_key(user_id, backup_type, profile_id)
Note: The UI uses this as the single source for interval and retention controls.
"""
key = _settings_row_key(user_id)
with connect() as conn: with connect() as conn:
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone() row = conn.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone()
settings = {**DEFAULT_AUTO_BACKUP_SETTINGS, **_loads(row.get("value") if row else "{}")} settings = {**DEFAULT_AUTO_BACKUP_SETTINGS, **_loads(row.get("value") if row else "{}")}
settings["enabled"] = bool(settings.get("enabled")) settings["enabled"] = bool(settings.get("enabled"))
settings["interval_hours"] = max(1, int(settings.get("interval_hours") or 24)) settings["interval_hours"] = max(1, int(settings.get("interval_hours") or 24))
settings["retention_days"] = max(1, int(settings.get("retention_days") or 30)) settings["retention_days"] = max(1, int(settings.get("retention_days") or 30))
settings["backup_type"] = "profile" if backup_type == "profile" else "app"
if backup_type == "profile":
settings["profile_id"] = int(profile_id or 0)
return settings return settings
def save_auto_backup_settings(data: dict, user_id: int | None = None) -> dict: def save_auto_backup_settings(data: dict, user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> dict:
"""Persist automatic backup schedule settings after validating UI input. user_id = user_id or auth.current_user_id() or default_user_id()
backup_type = "profile" if backup_type == "profile" else "app"
Note: Minimum interval is one hour to avoid creating excessive database rows. if backup_type == "app":
""" _require_admin(user_id)
current = get_auto_backup_settings(user_id) else:
# Note: Profile backup schedules affect profile operations, so read-only users may view/export backups but cannot change automation.
if not profile_id or not auth.can_write_profile(int(profile_id), user_id):
raise PermissionError("No write access to profile")
current = get_auto_backup_settings(user_id, backup_type, profile_id)
settings = { settings = {
**current, **current,
"enabled": bool(data.get("enabled")), "enabled": bool(data.get("enabled")),
@@ -192,22 +355,20 @@ def save_auto_backup_settings(data: dict, user_id: int | None = None) -> dict:
"retention_days": max(1, int(data.get("retention_days") or current["retention_days"])), "retention_days": max(1, int(data.get("retention_days") or current["retention_days"])),
"last_run_at": data.get("last_run_at", current.get("last_run_at")), "last_run_at": data.get("last_run_at", current.get("last_run_at")),
} }
key = _settings_row_key(user_id) key = _settings_row_key(user_id, backup_type, profile_id)
with connect() as conn: with connect() as conn:
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (key, json.dumps(settings))) conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (key, json.dumps(settings)))
return settings return settings
def preview_backup(backup_id: int, user_id: int | None = None) -> dict: def preview_backup(backup_id: int, user_id: int | None = None) -> dict:
"""Return a compact backup preview without exposing the full JSON payload in the list view.
Note: The preview shows included tables and example keys so users can verify settings coverage.
"""
payload = payload_for_backup(backup_id, user_id) payload = payload_for_backup(backup_id, user_id)
tables = payload.get("tables") or {} tables = payload.get("tables") or {}
return { return {
"version": payload.get("version"), "version": payload.get("version"),
"created_at": payload.get("created_at"), "created_at": payload.get("created_at"),
"backup_type": _backup_type(payload),
"source_profile_id": payload.get("source_profile_id"),
"automatic": bool(payload.get("automatic")), "automatic": bool(payload.get("automatic")),
"tables": [ "tables": [
{ {
@@ -221,50 +382,70 @@ def preview_backup(backup_id: int, user_id: int | None = None) -> dict:
} }
def prune_old_backups(user_id: int | None = None, retention_days: int = 30) -> int: def prune_old_backups(user_id: int | None = None, retention_days: int = 30, backup_type: str = "app", profile_id: int | None = None) -> int:
"""Delete backups older than the configured retention window for the selected user. user_id = user_id or auth.current_user_id() or default_user_id()
Note: Retention is applied only to backup records, not to restored application settings.
"""
user_id = user_id or default_user_id()
cutoff = (datetime.now(timezone.utc) - timedelta(days=max(1, int(retention_days)))).isoformat(timespec="seconds") cutoff = (datetime.now(timezone.utc) - timedelta(days=max(1, int(retention_days)))).isoformat(timespec="seconds")
clauses = ["user_id=?", "COALESCE(backup_type,'app')=?", "created_at<?"]
params: list[object] = [user_id, backup_type, cutoff]
if backup_type == "profile":
clauses.append("profile_id=?")
params.append(int(profile_id or 0))
with connect() as conn: with connect() as conn:
cur = conn.execute("DELETE FROM app_backups WHERE user_id=? AND created_at<?", (user_id, cutoff)) cur = conn.execute(f"DELETE FROM app_backups WHERE {' AND '.join(clauses)}", tuple(params))
return int(cur.rowcount or 0) return int(cur.rowcount or 0)
def maybe_create_automatic_backup(user_id: int | None = None) -> dict | None: def _should_run(settings: dict, last_value: str | None) -> bool:
"""Create an automatic backup when the saved interval has elapsed.
Note: The scheduler calls this periodically, while the UI controls the interval and retention values.
"""
user_id = user_id or default_user_id()
settings = get_auto_backup_settings(user_id)
if not settings.get("enabled"):
return None
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
last_value = settings.get("last_run_at") or _latest_backup_created_at(user_id)
try: try:
last = datetime.fromisoformat(str(last_value).replace("Z", "+00:00")) if last_value else None last = datetime.fromisoformat(str(last_value).replace("Z", "+00:00")) if last_value else None
except Exception: except Exception:
last = None last = None
if last and now - last < timedelta(hours=settings["interval_hours"]): return not last or now - last >= timedelta(hours=settings["interval_hours"])
def maybe_create_automatic_backup(user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> dict | None:
user_id = user_id or default_user_id()
backup_type = "profile" if backup_type == "profile" else "app"
if backup_type == "app" and not _is_admin_user(user_id):
return None
if backup_type == "profile" and (not profile_id or not auth.can_access_profile(int(profile_id), user_id)):
return None
settings = get_auto_backup_settings(user_id, backup_type, profile_id)
if not settings.get("enabled"):
return None
last_value = settings.get("last_run_at") or _latest_backup_created_at(user_id, backup_type, profile_id)
if not _should_run(settings, last_value):
if settings.get("last_run_at") != last_value: if settings.get("last_run_at") != last_value:
settings["last_run_at"] = last_value settings["last_run_at"] = last_value
save_auto_backup_settings(settings, user_id) save_auto_backup_settings(settings, user_id, backup_type, profile_id)
return None return None
backup = create_backup(f"Automatic backup {now.isoformat(timespec='seconds')}", user_id, automatic=True) now = datetime.now(timezone.utc)
if backup_type == "profile":
backup = create_profile_backup(f"Automatic profile backup {now.isoformat(timespec='seconds')}", int(profile_id or 0), user_id, automatic=True)
else:
backup = create_app_backup(f"Automatic application backup {now.isoformat(timespec='seconds')}", user_id, automatic=True)
settings["last_run_at"] = backup.get("created_at") or now.isoformat(timespec="seconds") settings["last_run_at"] = backup.get("created_at") or now.isoformat(timespec="seconds")
save_auto_backup_settings(settings, user_id) save_auto_backup_settings(settings, user_id, backup_type, profile_id)
prune_old_backups(user_id, settings["retention_days"]) prune_old_backups(user_id, settings["retention_days"], backup_type, profile_id)
return backup return backup
def start_scheduler() -> None: def _profile_schedule_keys() -> list[tuple[int, int]]:
"""Start a lightweight automatic-backup scheduler. prefix = f"{AUTO_BACKUP_SETTINGS_KEY}:profile:"
keys: list[tuple[int, int]] = []
with connect() as conn:
rows = conn.execute("SELECT key FROM app_settings WHERE key LIKE ?", (prefix + "%",)).fetchall()
for row in rows:
parts = str(row.get("key") or "").split(":")
try:
keys.append((int(parts[-2]), int(parts[-1])))
except Exception:
continue
return keys
Note: It scans configured users and never blocks normal request handling.
""" def start_scheduler() -> None:
global _scheduler_started global _scheduler_started
with _scheduler_lock: with _scheduler_lock:
if _scheduler_started: if _scheduler_started:
@@ -275,10 +456,12 @@ def start_scheduler() -> None:
while True: while True:
try: try:
with connect() as conn: with connect() as conn:
rows = conn.execute("SELECT id FROM users WHERE is_active=1").fetchall() rows = conn.execute("SELECT id FROM users WHERE is_active=1 AND role='admin'").fetchall()
user_ids = [int(row["id"]) for row in rows] or [default_user_id()] user_ids = [int(row["id"]) for row in rows] or [default_user_id()]
for uid in user_ids: for uid in user_ids:
maybe_create_automatic_backup(uid) maybe_create_automatic_backup(uid, "app")
for uid, pid in _profile_schedule_keys():
maybe_create_automatic_backup(uid, "profile", pid)
except Exception: except Exception:
pass pass
time.sleep(300) time.sleep(300)

View File

@@ -443,11 +443,13 @@ def evaluate(profile: dict, settings: dict | None = None, now: datetime | None =
} }
def enforce(profile: dict, force: bool = False) -> dict: def enforce(profile: dict, force: bool = False, user_id: int | None = None) -> dict:
profile_id = int(profile.get("id") or 0) profile_id = int(profile.get("id") or 0)
settings = get_settings(profile_id) user_id = user_id or int(profile.get("user_id") or default_user_id())
# Note: Background planner runs without Flask session state, so settings are resolved with the profile owner.
settings = get_settings(profile_id, user_id)
if not settings.get("enabled"): if not settings.get("enabled"):
return {"ok": True, "enabled": False, "profile_id": profile_id, "history": history(profile_id, 20), "history_total": history_count(profile_id), "preview": preview(profile)} return {"ok": True, "enabled": False, "profile_id": profile_id, "history": history(profile_id, 20), "history_total": history_count(profile_id), "preview": preview(profile, user_id=user_id)}
now = time.monotonic() now = time.monotonic()
interval = int(settings.get("check_interval_seconds") or 30) interval = int(settings.get("check_interval_seconds") or 30)
if not force and now - _LAST_RUN.get(profile_id, 0) < interval: if not force and now - _LAST_RUN.get(profile_id, 0) < interval:
@@ -497,13 +499,14 @@ def enforce(profile: dict, force: bool = False) -> dict:
_append_history(profile_id, "resumed_torrents", {"count": len(hashes), "dry_run": dry_run}) _append_history(profile_id, "resumed_torrents", {"count": len(hashes), "dry_run": dry_run})
result["history"] = history(profile_id, 20) result["history"] = history(profile_id, 20)
result["history_total"] = history_count(profile_id) result["history_total"] = history_count(profile_id)
result["preview"] = preview(profile) result["preview"] = preview(profile, user_id=user_id)
return result return result
def preview(profile: dict) -> dict: def preview(profile: dict, user_id: int | None = None) -> dict:
profile_id = int(profile.get("id") or 0) profile_id = int(profile.get("id") or 0)
settings = get_settings(profile_id) user_id = user_id or int(profile.get("user_id") or default_user_id())
settings = get_settings(profile_id, user_id)
decision = evaluate(profile, settings) decision = evaluate(profile, settings)
return { return {
"profile_id": profile_id, "profile_id": profile_id,

View File

@@ -40,18 +40,76 @@ def google_fonts_css_url() -> str:
return f"https://fonts.googleapis.com/css2?{families}&display=swap" return f"https://fonts.googleapis.com/css2?{families}&display=swap"
BOOTSTRAP_THEMES = ( DEVEXPRESS_BOOTSTRAP_THEMES = {
"default", "blazing-berry": "Blazing Berry",
"flatly", "office-white": "Office White",
"litera", "purple": "Purple",
"lumen", }
"minty",
"sketchy", PYTORRENT_APP_THEMES = {
"solar", "adaptive": "pyTorrent Adaptive",
"spacelab", "ocean": "pyTorrent Ocean",
"united", "graphite": "pyTorrent Graphite",
"zephyr", "forest": "pyTorrent Forest",
) "amber": "pyTorrent Amber",
"nord": "pyTorrent Nord",
"crimson": "pyTorrent Crimson",
"sky": "pyTorrent Sky",
"bootstrap22": "Bootstrap 2 Classic",
"bootstrap22-inverse": "Bootstrap 2 Inverse",
"bootstrap3": "Bootstrap 3 Glyph",
"bootstrap3-inverse": "Bootstrap 3 Inverse",
}
BOOTSTRAP_THEME_DEFINITIONS = {
"default": {
"label": "Default Bootstrap",
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/css/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/css/bootstrap.min.css",
},
# Bootswatch themes.
"flatly": {"label": "Bootswatch: Flatly", "provider": "bootswatch"},
"litera": {"label": "Bootswatch: Litera", "provider": "bootswatch"},
"lumen": {"label": "Bootswatch: Lumen", "provider": "bootswatch"},
"minty": {"label": "Bootswatch: Minty", "provider": "bootswatch"},
"sketchy": {"label": "Bootswatch: Sketchy", "provider": "bootswatch"},
"spacelab": {"label": "Bootswatch: Spacelab", "provider": "bootswatch"},
"united": {"label": "Bootswatch: United", "provider": "bootswatch"},
"zephyr": {"label": "Bootswatch: Zephyr", "provider": "bootswatch"},
# Complete DevExpress Bootstrap v5 dist.v5 set.
**{
f"dx-{theme}": {
"label": f"DevExpress: {label}",
"provider": "devexpress",
"local": f"{LIBS_STATIC_DIR}/devexpress-bootstrap-themes/dist.v5/{theme}/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/gh/DevExpress/bootstrap-themes@master/dist.v5/{theme}/bootstrap.min.css",
}
for theme, label in DEVEXPRESS_BOOTSTRAP_THEMES.items()
},
# App-specific Bootstrap variable overrides. These sit on top of default Bootstrap.
**{
f"pytorrent-{theme}": {
"label": f"Custom: {label}",
"provider": "pytorrent",
"local": f"{LIBS_STATIC_DIR}/pytorrent-themes/{theme}/bootstrap.min.css",
"cdn": f"/static/{LIBS_STATIC_DIR}/pytorrent-themes/{theme}/bootstrap.min.css",
}
for theme, label in PYTORRENT_APP_THEMES.items()
},
}
def _theme_definition(theme: str | None) -> dict[str, str]:
theme = theme if theme in BOOTSTRAP_THEME_DEFINITIONS else "default"
item = dict(BOOTSTRAP_THEME_DEFINITIONS[theme])
if item.get("provider") == "bootswatch":
item["local"] = f"{LIBS_STATIC_DIR}/bootswatch/{BOOTSWATCH_VERSION}/{theme}/bootstrap.min.css"
item["cdn"] = f"https://cdn.jsdelivr.net/npm/bootswatch@{BOOTSWATCH_VERSION}/dist/{theme}/bootstrap.min.css"
return item
BOOTSTRAP_THEMES = tuple(BOOTSTRAP_THEME_DEFINITIONS.keys())
BOOTSTRAP_THEME_LABELS = {key: value["label"] for key, value in BOOTSTRAP_THEME_DEFINITIONS.items()}
STATIC_ASSETS = { STATIC_ASSETS = {
"bootstrap_js": { "bootstrap_js": {
@@ -86,16 +144,8 @@ STATIC_ASSETS = {
def bootstrap_css_asset(theme: str | None = None) -> dict[str, str]: def bootstrap_css_asset(theme: str | None = None) -> dict[str, str]:
theme = theme if theme in BOOTSTRAP_THEMES else "default" item = _theme_definition(theme)
if theme == "default": return {"local": item["local"], "cdn": item["cdn"]}
return {
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/css/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/css/bootstrap.min.css",
}
return {
"local": f"{LIBS_STATIC_DIR}/bootswatch/{BOOTSWATCH_VERSION}/{theme}/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/npm/bootswatch@{BOOTSWATCH_VERSION}/dist/{theme}/bootstrap.min.css",
}
def asset_path(key: str) -> str: def asset_path(key: str) -> str:

View File

@@ -98,7 +98,10 @@ def record_job_event(profile_id: int, action: str, status: str, payload: dict |
severity = "danger" if status == "failed" else "info" severity = "danger" if status == "failed" else "info"
if action in {"add_magnet", "add_torrent_raw"}: if action in {"add_magnet", "add_torrent_raw"}:
name = str(payload.get("name") or payload.get("filename") or payload.get("uri") or "torrent")[:300] name = str(payload.get("name") or payload.get("filename") or payload.get("uri") or "torrent")[:300]
msg = f"{action} {status}: {name}" # Note: Keep the internal action name stable, but show a user-facing label instead of raw worker identifiers.
source_label = "Torrent file" if action == "add_torrent_raw" else "Magnet link"
status_label = {"started": "queued", "done": "added", "failed": "failed"}.get(str(status), str(status))
msg = f"{source_label} {status_label}: {name}"
record(profile_id, "torrent_added" if status == "done" else event_type, msg, severity=severity, source="job", action=action, details={"job_id": job_id, "status": status, "directory": payload.get("directory"), "label": payload.get("label"), "error": error, "result": result}, user_id=user_id) record(profile_id, "torrent_added" if status == "done" else event_type, msg, severity=severity, source="job", action=action, details={"job_id": job_id, "status": status, "directory": payload.get("directory"), "label": payload.get("label"), "error": error, "result": result}, user_id=user_id)
return return
if not hashes: if not hashes:

View File

@@ -0,0 +1,109 @@
from __future__ import annotations
import secrets
import threading
import time
_LINK_TTL_SECONDS = 10 * 60
_TEMPORARY_LINKS: dict[str, dict] = {}
_TEMPORARY_LINK_LOCK = threading.Lock()
def _cleanup_expired(now: float | None = None) -> None:
now = time.time() if now is None else float(now)
expired = [token for token, item in _TEMPORARY_LINKS.items() if float(item.get("expires_at") or 0) <= now]
for token in expired:
_TEMPORARY_LINKS.pop(token, None)
def _create_temporary_link(kind: str, profile_id: int, user_id: int, payload: dict) -> dict:
"""Create a short-lived in-app link target used by preview and download routes."""
# Note: API routes validate the request first, then return an app URL token instead of exposing stable download URLs in the UI.
now = time.time()
token = secrets.token_urlsafe(24)
with _TEMPORARY_LINK_LOCK:
_cleanup_expired(now)
_TEMPORARY_LINKS[token] = {
"kind": str(kind),
"profile_id": int(profile_id),
"user_id": int(user_id),
"expires_at": now + _LINK_TTL_SECONDS,
**payload,
}
return {"token": token, "expires_in": _LINK_TTL_SECONDS}
def create_pdf_preview_link(torrent_hash: str, file_index: int, profile_id: int, user_id: int) -> dict:
"""Create a short-lived in-app PDF preview link without exposing the API download URL."""
# Note: The public link is temporary and points to an app route, while streaming still reuses the existing file reader.
return _create_temporary_link(
"pdf_preview",
profile_id,
user_id,
{"torrent_hash": str(torrent_hash), "file_index": int(file_index)},
)
def create_file_download_link(torrent_hash: str, file_index: int, profile_id: int, user_id: int) -> dict:
"""Create a temporary in-app download link for one torrent file."""
# Note: File downloads use /download/<token> in the UI, but the backend keeps the same rTorrent streaming logic.
return _create_temporary_link(
"file_download",
profile_id,
user_id,
{"torrent_hash": str(torrent_hash), "file_index": int(file_index)},
)
def create_file_zip_download_link(torrent_hash: str, indexes: list[int] | None, profile_id: int, user_id: int) -> dict:
"""Create a temporary in-app download link for a ZIP of torrent files."""
# Note: Selected indexes are stored with the token so the final /download route does not need an API body.
clean_indexes = None if indexes is None else [int(index) for index in indexes]
return _create_temporary_link(
"file_zip_download",
profile_id,
user_id,
{"torrent_hash": str(torrent_hash), "indexes": clean_indexes},
)
def create_torrent_file_download_link(torrent_hash: str, profile_id: int, user_id: int) -> dict:
"""Create a temporary in-app download link for an exported .torrent file."""
# Note: The token hides the stable export API URL from browser-visible download actions.
return _create_temporary_link(
"torrent_file_download",
profile_id,
user_id,
{"torrent_hash": str(torrent_hash)},
)
def create_torrent_files_zip_download_link(hashes: list[str], profile_id: int, user_id: int) -> dict:
"""Create a temporary in-app download link for a ZIP of exported .torrent files."""
# Note: Hashes are copied into the token target after the API validates that the request is non-empty.
return _create_temporary_link(
"torrent_files_zip_download",
profile_id,
user_id,
{"hashes": [str(item) for item in hashes]},
)
def get_temporary_link(token: str) -> dict | None:
"""Return a temporary target if the link is still valid."""
# Note: Expired links are removed on read so stale browser tabs stop resolving automatically.
clean = str(token or "").strip()
if not clean:
return None
with _TEMPORARY_LINK_LOCK:
_cleanup_expired()
item = _TEMPORARY_LINKS.get(clean)
return dict(item) if item else None
def get_pdf_preview_link(token: str) -> dict | None:
"""Return a temporary PDF preview target if the link is still valid."""
item = get_temporary_link(token)
if not item or item.get("kind") != "pdf_preview":
return None
return item

View File

@@ -11,10 +11,11 @@ from ..config import POLL_INTERVAL, MIN_POLL_INTERVAL_SECONDS
DEFAULTS = { DEFAULTS = {
"adaptive_enabled": True, "adaptive_enabled": True,
"safe_fallback_enabled": True, "safe_fallback_enabled": True,
"active_interval_seconds": 5.0, "active_interval_seconds": 3.0,
"idle_interval_seconds": 15.0, "idle_interval_seconds": 15.0,
"error_interval_seconds": 30.0, "error_interval_seconds": 30.0,
"torrent_list_interval_seconds": 5.0, "live_stats_interval_seconds": 3.0,
"torrent_list_interval_seconds": 30.0,
"system_stats_interval_seconds": 5.0, "system_stats_interval_seconds": 5.0,
"tracker_stats_interval_seconds": 300.0, "tracker_stats_interval_seconds": 300.0,
"disk_stats_interval_seconds": 60.0, "disk_stats_interval_seconds": 60.0,
@@ -27,6 +28,20 @@ DEFAULTS = {
"recovery_after_errors": 3, "recovery_after_errors": 3,
} }
SAFE_FALLBACK_MINIMUMS = {
"active_interval_seconds": 3.0,
"idle_interval_seconds": 15.0,
"error_interval_seconds": 30.0,
"live_stats_interval_seconds": 3.0,
"torrent_list_interval_seconds": 30.0,
"system_stats_interval_seconds": 5.0,
"tracker_stats_interval_seconds": 300.0,
"disk_stats_interval_seconds": 60.0,
"queue_stats_interval_seconds": 15.0,
"slow_stats_interval_seconds": 60.0,
"heartbeat_interval_seconds": 15.0,
}
def _key(profile_id: int) -> str: def _key(profile_id: int) -> str:
return f"poller.settings.{int(profile_id)}" return f"poller.settings.{int(profile_id)}"
@@ -52,6 +67,7 @@ def normalize_settings(data: dict | None) -> dict:
"active_interval_seconds": _coerce_float(raw.get("active_interval_seconds"), DEFAULTS["active_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 30.0), "active_interval_seconds": _coerce_float(raw.get("active_interval_seconds"), DEFAULTS["active_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 30.0),
"idle_interval_seconds": _coerce_float(raw.get("idle_interval_seconds"), DEFAULTS["idle_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0), "idle_interval_seconds": _coerce_float(raw.get("idle_interval_seconds"), DEFAULTS["idle_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0),
"error_interval_seconds": _coerce_float(raw.get("error_interval_seconds"), DEFAULTS["error_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 300.0), "error_interval_seconds": _coerce_float(raw.get("error_interval_seconds"), DEFAULTS["error_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 300.0),
"live_stats_interval_seconds": _coerce_float(raw.get("live_stats_interval_seconds"), DEFAULTS["live_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 60.0),
"torrent_list_interval_seconds": _coerce_float(raw.get("torrent_list_interval_seconds"), DEFAULTS["torrent_list_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0), "torrent_list_interval_seconds": _coerce_float(raw.get("torrent_list_interval_seconds"), DEFAULTS["torrent_list_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0),
"system_stats_interval_seconds": _coerce_float(raw.get("system_stats_interval_seconds"), DEFAULTS["system_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0), "system_stats_interval_seconds": _coerce_float(raw.get("system_stats_interval_seconds"), DEFAULTS["system_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 120.0),
"tracker_stats_interval_seconds": _coerce_float(raw.get("tracker_stats_interval_seconds"), DEFAULTS["tracker_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 1800.0), "tracker_stats_interval_seconds": _coerce_float(raw.get("tracker_stats_interval_seconds"), DEFAULTS["tracker_stats_interval_seconds"], MIN_POLL_INTERVAL_SECONDS, 1800.0),
@@ -65,17 +81,27 @@ def normalize_settings(data: dict | None) -> dict:
"recovery_after_errors": int(_coerce_float(raw.get("recovery_after_errors"), 3, 1, 20)), "recovery_after_errors": int(_coerce_float(raw.get("recovery_after_errors"), 3, 1, 20)),
} }
if settings["safe_fallback_enabled"]: if settings["safe_fallback_enabled"]:
for key in ("active_interval_seconds", "idle_interval_seconds", "error_interval_seconds", "torrent_list_interval_seconds", "system_stats_interval_seconds", "queue_stats_interval_seconds"): # Note: Safe fallback keeps existing functionality, but prevents very aggressive polling from overloading rTorrent or the browser.
if settings[key] <= 0: for key, minimum in SAFE_FALLBACK_MINIMUMS.items():
settings[key] = DEFAULTS[key] settings[key] = max(float(settings.get(key) or DEFAULTS[key]), float(minimum))
return settings return settings
def get_settings(profile_id: int) -> dict: def get_settings(profile_id: int) -> dict:
with connect() as conn: with connect() as conn:
row = conn.execute("SELECT value FROM app_settings WHERE key=?", (_key(profile_id),)).fetchone() row = conn.execute("SELECT settings_json FROM poller_settings WHERE profile_id=?", (int(profile_id),)).fetchone()
if not row:
# Note: Existing installs stored profile poller settings in app_settings; migrate lazily on first read.
legacy = conn.execute("SELECT value FROM app_settings WHERE key=?", (_key(profile_id),)).fetchone()
if legacy:
try:
settings = normalize_settings(json.loads(legacy.get("value") or "{}"))
except Exception:
settings = normalize_settings({})
conn.execute("INSERT OR REPLACE INTO poller_settings(profile_id,settings_json,updated_at) VALUES(?,?,?)", (int(profile_id), json.dumps(settings), utcnow()))
return settings
try: try:
data = json.loads(row.get("value") or "{}") if row else {} data = json.loads(row.get("settings_json") or "{}") if row else {}
except Exception: except Exception:
data = {} data = {}
return normalize_settings(data) return normalize_settings(data)
@@ -84,7 +110,7 @@ def get_settings(profile_id: int) -> dict:
def save_settings(profile_id: int, data: dict) -> dict: def save_settings(profile_id: int, data: dict) -> dict:
settings = normalize_settings(data) settings = normalize_settings(data)
with connect() as conn: with connect() as conn:
conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (_key(profile_id), json.dumps(settings))) conn.execute("INSERT OR REPLACE INTO poller_settings(profile_id,settings_json,updated_at) VALUES(?,?,?)", (int(profile_id), json.dumps(settings), utcnow()))
return settings return settings
@@ -92,6 +118,8 @@ def save_settings(profile_id: int, data: dict) -> dict:
class ProfilePollState: class ProfilePollState:
profile_id: int profile_id: int
last_fast_at: float = 0.0 last_fast_at: float = 0.0
last_live_at: float = 0.0
last_list_at: float = 0.0
last_system_at: float = 0.0 last_system_at: float = 0.0
last_slow_at: float = 0.0 last_slow_at: float = 0.0
last_tracker_at: float = 0.0 last_tracker_at: float = 0.0
@@ -112,6 +140,24 @@ class ProfilePollState:
skipped_emissions: int = 0 skipped_emissions: int = 0
emitted_payload_size: int = 0 emitted_payload_size: int = 0
rtorrent_call_count: int = 0 rtorrent_call_count: int = 0
live_poll_count: int = 0
list_poll_count: int = 0
live_updated_total: int = 0
live_full_refresh_requested_total: int = 0
list_added_total: int = 0
list_updated_total: int = 0
list_removed_total: int = 0
last_live_duration_ms: float = 0.0
last_list_duration_ms: float = 0.0
last_live_updated_count: int = 0
last_list_added_count: int = 0
last_list_updated_count: int = 0
last_list_removed_count: int = 0
last_live_ok: bool = True
last_list_ok: bool = True
last_live_error: str = ""
last_list_error: str = ""
last_live_requires_full_refresh: bool = False
adaptive_mode: str = "normal" adaptive_mode: str = "normal"
slow_task_running: bool = False slow_task_running: bool = False
system_task_running: bool = False system_task_running: bool = False
@@ -141,12 +187,29 @@ def interval_for(settings: dict, state: ProfilePollState) -> float:
return base return base
def effective_live_interval(settings: dict, state: ProfilePollState) -> float:
return max(MIN_POLL_INTERVAL_SECONDS, interval_for(settings, state), float(settings.get("live_stats_interval_seconds") or DEFAULTS["live_stats_interval_seconds"]))
def effective_list_interval(settings: dict, state: ProfilePollState) -> float:
return max(MIN_POLL_INTERVAL_SECONDS, float(settings.get("torrent_list_interval_seconds") or DEFAULTS["torrent_list_interval_seconds"]))
def effective_fast_interval(settings: dict, state: ProfilePollState) -> float: def effective_fast_interval(settings: dict, state: ProfilePollState) -> float:
return max(MIN_POLL_INTERVAL_SECONDS, interval_for(settings, state), float(settings.get("torrent_list_interval_seconds") or DEFAULTS["torrent_list_interval_seconds"])) # Note: Kept for compatibility with older diagnostics; the fast interval now means lightweight live stats.
return effective_live_interval(settings, state)
def should_live_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
return (now - state.last_live_at) >= effective_live_interval(settings, state)
def should_list_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
return (now - state.last_list_at) >= effective_list_interval(settings, state)
def should_fast_poll(now: float, settings: dict, state: ProfilePollState) -> bool: def should_fast_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
return (now - state.last_fast_at) >= effective_fast_interval(settings, state) return should_live_poll(now, settings, state)
def should_system_poll(now: float, settings: dict, state: ProfilePollState) -> bool: def should_system_poll(now: float, settings: dict, state: ProfilePollState) -> bool:
@@ -175,6 +238,69 @@ def should_heartbeat(now: float, settings: dict, state: ProfilePollState, change
return (now - state.last_heartbeat_at) >= float(settings["heartbeat_interval_seconds"]) return (now - state.last_heartbeat_at) >= float(settings["heartbeat_interval_seconds"])
def mark_live_poll(state: ProfilePollState, started_at: float, ok: bool, error: str = "", updated_count: int = 0, requires_full_refresh: bool = False) -> None:
now = time.monotonic()
# Note: Live poller diagnostics track only lightweight speed/status refreshes, not the full torrent snapshot loop.
state.live_poll_count += 1
state.last_live_duration_ms = round((now - started_at) * 1000.0, 2)
state.last_live_updated_count = int(updated_count or 0)
state.live_updated_total += int(updated_count or 0)
state.last_live_requires_full_refresh = bool(requires_full_refresh)
if requires_full_refresh:
state.live_full_refresh_requested_total += 1
state.last_live_ok = bool(ok)
state.last_live_error = str(error or "")
def mark_list_poll(state: ProfilePollState, started_at: float, ok: bool, error: str = "", added_count: int = 0, updated_count: int = 0, removed_count: int = 0) -> None:
now = time.monotonic()
# Note: List poller diagnostics are separate because this slower loop runs full torrent snapshot reconciliation.
state.list_poll_count += 1
state.last_list_duration_ms = round((now - started_at) * 1000.0, 2)
state.last_list_added_count = int(added_count or 0)
state.last_list_updated_count = int(updated_count or 0)
state.last_list_removed_count = int(removed_count or 0)
state.list_added_total += int(added_count or 0)
state.list_updated_total += int(updated_count or 0)
state.list_removed_total += int(removed_count or 0)
state.last_list_ok = bool(ok)
state.last_list_error = str(error or "")
def reset_runtime_stats(profile_id: int) -> dict:
state = state_for(profile_id)
# Note: Cleanup resets diagnostic counters only; poller timers and saved settings keep running unchanged.
state.tick_count = 0
state.last_tick_ms = 0.0
state.last_tick_gap_ms = 0.0
state.last_tick_started_at = 0.0
state.error_count = 0
state.slow_count = 0
state.skipped_emissions = 0
state.emitted_payload_size = 0
state.rtorrent_call_count = 0
state.live_poll_count = 0
state.list_poll_count = 0
state.live_updated_total = 0
state.live_full_refresh_requested_total = 0
state.list_added_total = 0
state.list_updated_total = 0
state.list_removed_total = 0
state.last_live_duration_ms = 0.0
state.last_list_duration_ms = 0.0
state.last_live_updated_count = 0
state.last_list_added_count = 0
state.last_list_updated_count = 0
state.last_list_removed_count = 0
state.last_live_ok = True
state.last_list_ok = True
state.last_live_error = ""
state.last_list_error = ""
state.last_live_requires_full_refresh = False
state.stats = {}
return snapshot(profile_id)
def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool, error: str = "", emitted_payload_size: int = 0, rtorrent_call_count: int = 0, skipped_emissions: int = 0, settings: dict | None = None) -> dict: def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool, error: str = "", emitted_payload_size: int = 0, rtorrent_call_count: int = 0, skipped_emissions: int = 0, settings: dict | None = None) -> dict:
now = time.monotonic() now = time.monotonic()
effective_settings = normalize_settings(settings) if settings is not None else DEFAULTS effective_settings = normalize_settings(settings) if settings is not None else DEFAULTS
@@ -184,7 +310,7 @@ def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool
state.last_tick_gap_ms = round((started_at - previous_started_at) * 1000.0, 2) if previous_started_at else 0.0 state.last_tick_gap_ms = round((started_at - previous_started_at) * 1000.0, 2) if previous_started_at else 0.0
state.last_tick_started_at = started_at state.last_tick_started_at = started_at
state.last_active = bool(active) state.last_active = bool(active)
state.effective_interval_seconds = effective_fast_interval(effective_settings, state) state.effective_interval_seconds = effective_live_interval(effective_settings, state)
state.last_ok = bool(ok) state.last_ok = bool(ok)
state.last_error = str(error or "") state.last_error = str(error or "")
state.emitted_payload_size = int(emitted_payload_size or 0) state.emitted_payload_size = int(emitted_payload_size or 0)
@@ -224,6 +350,8 @@ def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool
"last_ok": state.last_ok, "last_ok": state.last_ok,
"last_tick_gap_ms": state.last_tick_gap_ms, "last_tick_gap_ms": state.last_tick_gap_ms,
"effective_interval_seconds": state.effective_interval_seconds, "effective_interval_seconds": state.effective_interval_seconds,
"live_stats_interval_seconds": effective_live_interval(effective_settings, state),
"torrent_list_interval_seconds": effective_list_interval(effective_settings, state),
"configured_min_interval_seconds": MIN_POLL_INTERVAL_SECONDS, "configured_min_interval_seconds": MIN_POLL_INTERVAL_SECONDS,
"last_error": state.last_error, "last_error": state.last_error,
"duration_ms": state.last_tick_ms, "duration_ms": state.last_tick_ms,
@@ -234,6 +362,24 @@ def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool
"adaptive_mode": state.adaptive_mode, "adaptive_mode": state.adaptive_mode,
"error_count": state.error_count, "error_count": state.error_count,
"slow_count": state.slow_count, "slow_count": state.slow_count,
"live_poll_count": state.live_poll_count,
"list_poll_count": state.list_poll_count,
"last_live_duration_ms": state.last_live_duration_ms,
"last_list_duration_ms": state.last_list_duration_ms,
"last_live_updated_count": state.last_live_updated_count,
"last_list_added_count": state.last_list_added_count,
"last_list_updated_count": state.last_list_updated_count,
"last_list_removed_count": state.last_list_removed_count,
"live_updated_total": state.live_updated_total,
"list_added_total": state.list_added_total,
"list_updated_total": state.list_updated_total,
"list_removed_total": state.list_removed_total,
"live_full_refresh_requested_total": state.live_full_refresh_requested_total,
"last_live_requires_full_refresh": state.last_live_requires_full_refresh,
"last_live_ok": state.last_live_ok,
"last_list_ok": state.last_list_ok,
"last_live_error": state.last_live_error,
"last_list_error": state.last_list_error,
"updated_at": utcnow(), "updated_at": utcnow(),
} }
return dict(state.stats) return dict(state.stats)
@@ -241,4 +387,26 @@ def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool
def snapshot(profile_id: int) -> dict: def snapshot(profile_id: int) -> dict:
state = state_for(profile_id) state = state_for(profile_id)
return dict(state.stats or {"profile_id": int(profile_id), "tick_count": state.tick_count}) data = dict(state.stats or {"profile_id": int(profile_id), "tick_count": state.tick_count})
# Note: Snapshot always exposes split-poller counters, even before the first post-cleanup tick rebuilds full stats.
data.update({
"live_poll_count": state.live_poll_count,
"list_poll_count": state.list_poll_count,
"last_live_duration_ms": state.last_live_duration_ms,
"last_list_duration_ms": state.last_list_duration_ms,
"last_live_updated_count": state.last_live_updated_count,
"last_list_added_count": state.last_list_added_count,
"last_list_updated_count": state.last_list_updated_count,
"last_list_removed_count": state.last_list_removed_count,
"live_updated_total": state.live_updated_total,
"list_added_total": state.list_added_total,
"list_updated_total": state.list_updated_total,
"list_removed_total": state.list_removed_total,
"live_full_refresh_requested_total": state.live_full_refresh_requested_total,
"last_live_requires_full_refresh": state.last_live_requires_full_refresh,
"last_live_ok": state.last_live_ok,
"last_list_ok": state.last_list_ok,
"last_live_error": state.last_live_error,
"last_list_error": state.last_list_error,
})
return data

View File

@@ -4,19 +4,9 @@ import json
from ..db import connect, utcnow, default_user_id from ..db import connect, utcnow, default_user_id
from . import auth from . import auth
from .frontend_assets import BOOTSTRAP_THEME_LABELS
BOOTSTRAP_THEMES = { BOOTSTRAP_THEMES = BOOTSTRAP_THEME_LABELS
"default": "Default Bootstrap",
"flatly": "Flatly",
"litera": "Litera",
"lumen": "Lumen",
"minty": "Minty",
"sketchy": "Sketchy",
"solar": "Solar",
"spacelab": "Spacelab",
"united": "United",
"zephyr": "Zephyr",
}
FONT_FAMILIES = { FONT_FAMILIES = {
"default": "Theme default", "default": "Theme default",
@@ -38,6 +28,18 @@ FONT_FAMILIES = {
"adwaita-mono": "Adwaita Mono", "adwaita-mono": "Adwaita Mono",
} }
SUPPORTED_LANGUAGES = {
"en_US": {"label": "English", "flag": "us"},
"pl_PL": {"label": "Polski", "flag": "pl"},
"de_DE": {"label": "Deutsch", "flag": "de"},
"nb_NO": {"label": "Norsk bokmål", "flag": "no"},
"ru_RU": {"label": "Русский", "flag": "ru"},
"fr_FR": {"label": "Français", "flag": "fr"},
"cs_CZ": {"label": "Čeština", "flag": "cz"},
"es_ES": {"label": "Español", "flag": "es"},
}
# Note: Backend owns the recommended torrent table layout so frontend builds do not duplicate presets. # Note: Backend owns the recommended torrent table layout so frontend builds do not duplicate presets.
RECOMMENDED_TABLE_COLUMNS = { RECOMMENDED_TABLE_COLUMNS = {
"hidden": ["hash", "priority", "hashing", "active", "message", "complete", "state", "ratio_group"], "hidden": ["hash", "priority", "hashing", "active", "message", "complete", "state", "ratio_group"],
@@ -68,17 +70,21 @@ def recommended_table_columns_json() -> str:
return json.dumps(RECOMMENDED_TABLE_COLUMNS, separators=(",", ":")) return json.dumps(RECOMMENDED_TABLE_COLUMNS, separators=(",", ":"))
def apply_recommended_table_columns(user_id: int | None = None): def apply_recommended_table_columns(user_id: int | None = None, profile_id: int | None = None):
user_id = user_id or auth.current_user_id() or default_user_id() user_id = user_id or auth.current_user_id() or default_user_id()
get_preferences(user_id) profile_id = profile_id or _active_profile_id_for_user(user_id)
if not profile_id:
return get_preferences(user_id)
get_preferences(user_id, profile_id)
now = utcnow() now = utcnow()
value = recommended_table_columns_json() value = recommended_table_columns_json()
with connect() as conn: with connect() as conn:
conn.execute( conn.execute(
"UPDATE user_preferences SET table_columns_json=?, updated_at=? WHERE user_id=?", "INSERT INTO profile_preferences(user_id,profile_id,table_columns_json,created_at,updated_at) VALUES(?,?,?,?,?) "
(value, now, user_id), "ON CONFLICT(user_id,profile_id) DO UPDATE SET table_columns_json=excluded.table_columns_json, updated_at=excluded.updated_at",
(user_id, profile_id, value, now, now),
) )
return get_preferences(user_id) return get_preferences(user_id, profile_id)
def bootstrap_css_url(theme: str | None) -> str: def bootstrap_css_url(theme: str | None) -> str:
from .frontend_assets import bootstrap_css_path from .frontend_assets import bootstrap_css_path
@@ -94,6 +100,15 @@ def _int_setting(data: dict, key: str, default: int, minimum: int, maximum: int)
return max(minimum, min(maximum, value)) return max(minimum, min(maximum, value))
def _url_setting(data: dict, key: str, default: str = "") -> str:
value = str(data.get(key) if data.get(key) is not None else default).strip()
if len(value) > 2048:
value = value[:2048]
if value and not (value.startswith("https://") or value.startswith("http://")):
return ""
return value
def list_profiles(user_id: int | None = None): def list_profiles(user_id: int | None = None):
user_id = user_id or auth.current_user_id() or default_user_id() user_id = user_id or auth.current_user_id() or default_user_id()
visible = auth.visible_profile_ids(user_id) visible = auth.visible_profile_ids(user_id)
@@ -128,6 +143,10 @@ def active_profile(user_id: int | None = None):
if row: if row:
return row return row
profiles = list_profiles(user_id) profiles = list_profiles(user_id)
# Note: Trusted auth-bypass access must choose a profile explicitly on first entry,
# instead of silently reusing the first configured profile.
if auth.auth_bypassed_request() and profiles:
return None
return profiles[0] if profiles else None return profiles[0] if profiles else None
@@ -323,42 +342,151 @@ def save_disk_monitor_preferences(profile_id: int | None, data: dict, user_id: i
return clean return clean
PROFILE_PREFERENCE_COLUMNS = {
"table_columns_json",
"torrent_sort_json",
"active_filter",
"peers_refresh_seconds",
"port_check_enabled",
"tracker_favicons_enabled",
"reverse_dns_enabled",
}
def _seed_profile_preferences(conn, user_id: int, profile_id: int) -> dict:
now = utcnow()
legacy = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone() or {}
row = conn.execute("SELECT * FROM profile_preferences WHERE user_id=? AND profile_id=?", (user_id, profile_id)).fetchone()
if row:
return dict(row)
# Note: First profile preference row is seeded from legacy user-level values so upgrades keep the current layout/filter behavior.
conn.execute(
"INSERT INTO profile_preferences(user_id,profile_id,table_columns_json,torrent_sort_json,active_filter,peers_refresh_seconds,port_check_enabled,tracker_favicons_enabled,reverse_dns_enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
(
user_id,
profile_id,
legacy.get("table_columns_json"),
legacy.get("torrent_sort_json"),
legacy.get("active_filter") or "all",
int(legacy.get("peers_refresh_seconds") or 0),
int(legacy.get("port_check_enabled") or 0),
int(legacy.get("tracker_favicons_enabled") or 0),
int(legacy.get("reverse_dns_enabled") or 0),
now,
now,
),
)
return dict(conn.execute("SELECT * FROM profile_preferences WHERE user_id=? AND profile_id=?", (user_id, profile_id)).fetchone() or {})
def get_profile_preferences(user_id: int, profile_id: int | None) -> dict:
if not profile_id:
return {}
with connect() as conn:
return _seed_profile_preferences(conn, user_id, int(profile_id))
def save_profile_preferences(user_id: int, profile_id: int | None, data: dict) -> None:
if not profile_id:
return
profile_id = int(profile_id)
now = utcnow()
with connect() as conn:
current = _seed_profile_preferences(conn, user_id, profile_id)
updates: dict[str, object] = {}
if data.get("table_columns_json") is not None:
updates["table_columns_json"] = str(data.get("table_columns_json"))
if data.get("peers_refresh_seconds") is not None:
sec = int(data.get("peers_refresh_seconds") or 0)
updates["peers_refresh_seconds"] = sec if sec in {0, 10, 15, 30, 60} else 0
if data.get("port_check_enabled") is not None:
updates["port_check_enabled"] = 1 if data.get("port_check_enabled") else 0
if data.get("tracker_favicons_enabled") is not None:
updates["tracker_favicons_enabled"] = 1 if data.get("tracker_favicons_enabled") else 0
if data.get("reverse_dns_enabled") is not None:
# Note: Reverse DNS is stored per profile because PTR lookups depend on swarm size and profile network latency.
updates["reverse_dns_enabled"] = 1 if data.get("reverse_dns_enabled") else 0
if data.get("torrent_sort_json") is not None:
value = data.get("torrent_sort_json") if isinstance(data.get("torrent_sort_json"), str) else json.dumps(data.get("torrent_sort_json"))
parsed = json.loads(value or "{}")
if not isinstance(parsed, dict):
parsed = {}
try:
direction = int(parsed.get("dir") or 1)
except (TypeError, ValueError):
direction = 1
allowed_sort_keys = {"name", "status", "size", "progress", "down_rate", "up_rate", "eta", "seeds", "peers", "ratio", "path", "label", "ratio_group", "down_total", "to_download", "up_total", "created", "priority", "state", "active", "complete", "hashing", "message", "hash"}
sort_key = str(parsed.get("key") or "name")
if sort_key not in allowed_sort_keys:
sort_key = "name"
updates["torrent_sort_json"] = json.dumps({"key": sort_key, "dir": 1 if direction >= 0 else -1})
if data.get("active_filter") is not None:
value = str(data.get("active_filter") or "all").strip()
if not value or len(value) > 180:
value = "all"
allowed_static_filters = {"all", "downloading", "seeding", "paused", "checking", "error", "stopped", "moving"}
if value not in allowed_static_filters and not value.startswith("label:") and not value.startswith("tracker:"):
value = "all"
updates["active_filter"] = value
if not updates:
return
merged = {**current, **updates}
conn.execute(
"INSERT INTO profile_preferences(user_id,profile_id,table_columns_json,torrent_sort_json,active_filter,peers_refresh_seconds,port_check_enabled,tracker_favicons_enabled,reverse_dns_enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?) "
"ON CONFLICT(user_id,profile_id) DO UPDATE SET table_columns_json=excluded.table_columns_json, torrent_sort_json=excluded.torrent_sort_json, active_filter=excluded.active_filter, peers_refresh_seconds=excluded.peers_refresh_seconds, port_check_enabled=excluded.port_check_enabled, tracker_favicons_enabled=excluded.tracker_favicons_enabled, reverse_dns_enabled=excluded.reverse_dns_enabled, updated_at=excluded.updated_at",
(
user_id,
profile_id,
merged.get("table_columns_json"),
merged.get("torrent_sort_json"),
merged.get("active_filter") or "all",
int(merged.get("peers_refresh_seconds") or 0),
int(merged.get("port_check_enabled") or 0),
int(merged.get("tracker_favicons_enabled") or 0),
int(merged.get("reverse_dns_enabled") or 0),
merged.get("created_at") or now,
now,
),
)
def get_preferences(user_id: int | None = None, profile_id: int | None = None): def get_preferences(user_id: int | None = None, profile_id: int | None = None):
user_id = user_id or auth.current_user_id() or default_user_id() user_id = user_id or auth.current_user_id() or default_user_id()
profile_id = profile_id or _active_profile_id_for_user(user_id)
with connect() as conn: with connect() as conn:
pref = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone() pref = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone()
if not pref: if not pref:
now = utcnow() now = utcnow()
conn.execute("INSERT INTO user_preferences(user_id, theme, created_at, updated_at) VALUES(?, 'dark', ?, ?)", (user_id, now, now)) conn.execute("INSERT INTO user_preferences(user_id, theme, language, created_at, updated_at) VALUES(?, 'dark', 'en_US', ?, ?)", (user_id, now, now))
pref = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone() pref = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone()
merged = dict(pref or {}) merged = dict(pref or {})
if profile_id:
merged.update(_seed_profile_preferences(conn, user_id, int(profile_id)))
merged.update(get_disk_monitor_preferences(profile_id, user_id)) merged.update(get_disk_monitor_preferences(profile_id, user_id))
return merged return merged
def save_preferences(data: dict, user_id: int | None = None): def save_preferences(data: dict, user_id: int | None = None):
user_id = user_id or auth.current_user_id() or default_user_id() user_id = user_id or auth.current_user_id() or default_user_id()
profile_id = _active_profile_id_for_user(user_id)
allowed_theme = data.get("theme") if data.get("theme") in {"light", "dark"} else None allowed_theme = data.get("theme") if data.get("theme") in {"light", "dark"} else None
language = data.get("language") if data.get("language") in SUPPORTED_LANGUAGES else None
bootstrap_theme = data.get("bootstrap_theme") if data.get("bootstrap_theme") in BOOTSTRAP_THEMES else None bootstrap_theme = data.get("bootstrap_theme") if data.get("bootstrap_theme") in BOOTSTRAP_THEMES else None
font_family = data.get("font_family") if data.get("font_family") in FONT_FAMILIES else None font_family = data.get("font_family") if data.get("font_family") in FONT_FAMILIES else None
table_columns_json = data.get("table_columns_json")
peers_refresh_seconds = data.get("peers_refresh_seconds")
port_check_enabled = data.get("port_check_enabled")
footer_items_json = data.get("footer_items_json") footer_items_json = data.get("footer_items_json")
title_speed_enabled = data.get("title_speed_enabled") title_speed_enabled = data.get("title_speed_enabled")
tracker_favicons_enabled = data.get("tracker_favicons_enabled")
reverse_dns_enabled = data.get("reverse_dns_enabled")
automation_toasts_enabled = data.get("automation_toasts_enabled") automation_toasts_enabled = data.get("automation_toasts_enabled")
smart_queue_toasts_enabled = data.get("smart_queue_toasts_enabled") smart_queue_toasts_enabled = data.get("smart_queue_toasts_enabled")
easter_egg_enabled = data.get("easter_egg_enabled")
easter_egg_loading_image_url = data.get("easter_egg_loading_image_url")
easter_egg_click_image_url = data.get("easter_egg_click_image_url")
disk_monitor_paths_json = data.get("disk_monitor_paths_json") disk_monitor_paths_json = data.get("disk_monitor_paths_json")
disk_monitor_mode = data.get("disk_monitor_mode") disk_monitor_mode = data.get("disk_monitor_mode")
disk_monitor_selected_path = data.get("disk_monitor_selected_path") disk_monitor_selected_path = data.get("disk_monitor_selected_path")
disk_monitor_stop_enabled = data.get("disk_monitor_stop_enabled") disk_monitor_stop_enabled = data.get("disk_monitor_stop_enabled")
disk_monitor_stop_threshold = data.get("disk_monitor_stop_threshold") disk_monitor_stop_threshold = data.get("disk_monitor_stop_threshold")
interface_scale = data.get("interface_scale") interface_scale = data.get("interface_scale")
compact_torrent_list_enabled = data.get("compact_torrent_list_enabled")
detail_panel_height = data.get("detail_panel_height") detail_panel_height = data.get("detail_panel_height")
torrent_sort_json = data.get("torrent_sort_json")
active_filter = data.get("active_filter")
disk_payload = None disk_payload = None
if any(value is not None for value in (disk_monitor_paths_json, disk_monitor_mode, disk_monitor_selected_path, disk_monitor_stop_enabled, disk_monitor_stop_threshold)): if any(value is not None for value in (disk_monitor_paths_json, disk_monitor_mode, disk_monitor_selected_path, disk_monitor_stop_enabled, disk_monitor_stop_threshold)):
disk_payload = { disk_payload = {
@@ -372,36 +500,35 @@ def save_preferences(data: dict, user_id: int | None = None):
now = utcnow() now = utcnow()
if allowed_theme: if allowed_theme:
conn.execute("UPDATE user_preferences SET theme=?, updated_at=? WHERE user_id=?", (allowed_theme, now, user_id)) conn.execute("UPDATE user_preferences SET theme=?, updated_at=? WHERE user_id=?", (allowed_theme, now, user_id))
if language:
# Note: Language is stored per user so every account keeps its own UI locale across profiles.
conn.execute("UPDATE user_preferences SET language=?, updated_at=? WHERE user_id=?", (language, now, user_id))
if bootstrap_theme: if bootstrap_theme:
conn.execute("UPDATE user_preferences SET bootstrap_theme=?, updated_at=? WHERE user_id=?", (bootstrap_theme, now, user_id)) conn.execute("UPDATE user_preferences SET bootstrap_theme=?, updated_at=? WHERE user_id=?", (bootstrap_theme, now, user_id))
if font_family: if font_family:
conn.execute("UPDATE user_preferences SET font_family=?, updated_at=? WHERE user_id=?", (font_family, now, user_id)) conn.execute("UPDATE user_preferences SET font_family=?, updated_at=? WHERE user_id=?", (font_family, now, user_id))
if table_columns_json is not None:
conn.execute("UPDATE user_preferences SET table_columns_json=?, updated_at=? WHERE user_id=?", (str(table_columns_json), now, user_id))
if peers_refresh_seconds is not None:
sec = int(peers_refresh_seconds or 0)
if sec not in {0, 10, 15, 30, 60}: sec = 0
conn.execute("UPDATE user_preferences SET peers_refresh_seconds=?, updated_at=? WHERE user_id=?", (sec, now, user_id))
if port_check_enabled is not None:
conn.execute("UPDATE user_preferences SET port_check_enabled=?, updated_at=? WHERE user_id=?", (1 if port_check_enabled else 0, now, user_id))
if title_speed_enabled is not None: if title_speed_enabled is not None:
conn.execute("UPDATE user_preferences SET title_speed_enabled=?, updated_at=? WHERE user_id=?", (1 if title_speed_enabled else 0, now, user_id)) conn.execute("UPDATE user_preferences SET title_speed_enabled=?, updated_at=? WHERE user_id=?", (1 if title_speed_enabled else 0, now, user_id))
if tracker_favicons_enabled is not None:
conn.execute("UPDATE user_preferences SET tracker_favicons_enabled=?, updated_at=? WHERE user_id=?", (1 if tracker_favicons_enabled else 0, now, user_id))
if reverse_dns_enabled is not None:
# Note: Reverse DNS is optional because peer PTR lookups can add latency on busy swarms.
conn.execute("UPDATE user_preferences SET reverse_dns_enabled=?, updated_at=? WHERE user_id=?", (1 if reverse_dns_enabled else 0, now, user_id))
if automation_toasts_enabled is not None: if automation_toasts_enabled is not None:
# Note: Lets users silence automation-created toast noise without hiding job/history data. # Note: Lets users silence automation-created toast noise without hiding job/history data.
conn.execute("UPDATE user_preferences SET automation_toasts_enabled=?, updated_at=? WHERE user_id=?", (1 if automation_toasts_enabled else 0, now, user_id)) conn.execute("UPDATE user_preferences SET automation_toasts_enabled=?, updated_at=? WHERE user_id=?", (1 if automation_toasts_enabled else 0, now, user_id))
if smart_queue_toasts_enabled is not None: if smart_queue_toasts_enabled is not None:
# Note: Smart Queue toast noise can be disabled independently from automation notifications. # Note: Smart Queue toast noise can be disabled independently from automation notifications.
conn.execute("UPDATE user_preferences SET smart_queue_toasts_enabled=?, updated_at=? WHERE user_id=?", (1 if smart_queue_toasts_enabled else 0, now, user_id)) conn.execute("UPDATE user_preferences SET smart_queue_toasts_enabled=?, updated_at=? WHERE user_id=?", (1 if smart_queue_toasts_enabled else 0, now, user_id))
if easter_egg_enabled is not None:
conn.execute("UPDATE user_preferences SET easter_egg_enabled=?, updated_at=? WHERE user_id=?", (1 if easter_egg_enabled else 0, now, user_id))
if easter_egg_loading_image_url is not None:
conn.execute("UPDATE user_preferences SET easter_egg_loading_image_url=?, updated_at=? WHERE user_id=?", (_url_setting(data, "easter_egg_loading_image_url"), now, user_id))
if easter_egg_click_image_url is not None:
conn.execute("UPDATE user_preferences SET easter_egg_click_image_url=?, updated_at=? WHERE user_id=?", (_url_setting(data, "easter_egg_click_image_url"), now, user_id))
if interface_scale is not None: if interface_scale is not None:
scale = int(interface_scale or 100) scale = int(interface_scale or 100)
if scale < 80: scale = 80 if scale < 80: scale = 80
if scale > 140: scale = 140 if scale > 140: scale = 140
conn.execute("UPDATE user_preferences SET interface_scale=?, updated_at=? WHERE user_id=?", (scale, now, user_id)) conn.execute("UPDATE user_preferences SET interface_scale=?, updated_at=? WHERE user_id=?", (scale, now, user_id))
if compact_torrent_list_enabled is not None:
# Note: Compact torrent list is a visual-only preference for desktop and mobile list density.
conn.execute("UPDATE user_preferences SET compact_torrent_list_enabled=?, updated_at=? WHERE user_id=?", (1 if compact_torrent_list_enabled else 0, now, user_id))
if footer_items_json is not None: if footer_items_json is not None:
# Note: Store only JSON objects so footer visibility can be extended without schema churn. # Note: Store only JSON objects so footer visibility can be extended without schema churn.
value = footer_items_json if isinstance(footer_items_json, str) else json.dumps(footer_items_json) value = footer_items_json if isinstance(footer_items_json, str) else json.dumps(footer_items_json)
@@ -417,30 +544,7 @@ def save_preferences(data: dict, user_id: int | None = None):
if height < 160: height = 160 if height < 160: height = 160
if height > 720: height = 720 if height > 720: height = 720
conn.execute("UPDATE user_preferences SET detail_panel_height=?, updated_at=? WHERE user_id=?", (height, now, user_id)) conn.execute("UPDATE user_preferences SET detail_panel_height=?, updated_at=? WHERE user_id=?", (height, now, user_id))
if torrent_sort_json is not None: save_profile_preferences(user_id, profile_id, data)
# Note: Persist only a compact sort object; unknown keys are ignored on the client.
value = torrent_sort_json if isinstance(torrent_sort_json, str) else json.dumps(torrent_sort_json)
parsed = json.loads(value or "{}")
if not isinstance(parsed, dict):
parsed = {}
try:
direction = int(parsed.get("dir") or 1)
except (TypeError, ValueError):
direction = 1
allowed_sort_keys = {"name", "status", "size", "progress", "down_rate", "up_rate", "eta", "seeds", "peers", "ratio", "path", "label", "ratio_group", "down_total", "to_download", "up_total", "created", "priority", "state", "active", "complete", "hashing", "message", "hash"}
sort_key = str(parsed.get("key") or "name")
if sort_key not in allowed_sort_keys:
sort_key = "name"
clean = {"key": sort_key, "dir": 1 if direction >= 0 else -1}
conn.execute("UPDATE user_preferences SET torrent_sort_json=?, updated_at=? WHERE user_id=?", (json.dumps(clean), now, user_id))
if active_filter is not None:
value = str(active_filter or "all").strip()
if not value or len(value) > 180:
value = "all"
allowed_static_filters = {"all", "downloading", "seeding", "paused", "checking", "error", "stopped", "moving"}
if value not in allowed_static_filters and not value.startswith("label:") and not value.startswith("tracker:"):
value = "all"
conn.execute("UPDATE user_preferences SET active_filter=?, updated_at=? WHERE user_id=?", (value, now, user_id))
if disk_payload is not None: if disk_payload is not None:
save_disk_monitor_preferences(_active_profile_id_for_user(user_id), disk_payload, user_id) save_disk_monitor_preferences(profile_id, disk_payload, user_id)
return get_preferences(user_id) return get_preferences(user_id, profile_id)

View File

@@ -8,7 +8,7 @@ from datetime import datetime, timezone, timedelta
from email.utils import parsedate_to_datetime from email.utils import parsedate_to_datetime
from typing import Iterable from typing import Iterable
from ..db import connect, utcnow, default_user_id from ..db import connect, utcnow
from . import rtorrent from . import rtorrent
from .workers import enqueue from .workers import enqueue
@@ -122,12 +122,12 @@ def matches_rule(rule: dict, item: dict) -> tuple[bool, str]:
return True, "matched" return True, "matched"
def _log(user_id: int, profile_id: int, feed_id: int | None, rule_id: int | None, item: dict, status: str, message: str) -> None: def _log(profile_id: int, feed_id: int | None, rule_id: int | None, item: dict, status: str, message: str) -> None:
with connect() as conn: with connect() as conn:
try: try:
conn.execute( conn.execute(
"INSERT INTO rss_history(user_id,profile_id,feed_id,rule_id,title,link,status,message,created_at) VALUES(?,?,?,?,?,?,?,?,?)", "INSERT INTO rss_history(profile_id,feed_id,rule_id,title,link,status,message,created_at) VALUES(?,?,?,?,?,?,?,?)",
(user_id, profile_id, feed_id, rule_id, item.get("title"), item.get("link"), status, message, utcnow()), (profile_id, feed_id, rule_id, item.get("title"), item.get("link"), status, message, utcnow()),
) )
except Exception: except Exception:
# Note: Duplicate successful RSS matches are ignored to prevent recurring duplicate downloads. # Note: Duplicate successful RSS matches are ignored to prevent recurring duplicate downloads.
@@ -135,15 +135,14 @@ def _log(user_id: int, profile_id: int, feed_id: int | None, rule_id: int | None
def check(profile: dict, user_id: int | None = None, only_due: bool = False) -> dict: def check(profile: dict, user_id: int | None = None, only_due: bool = False) -> dict:
user_id = user_id or default_user_id()
profile_id = int(profile["id"]) profile_id = int(profile["id"])
now = utcnow() now = utcnow()
with connect() as conn: with connect() as conn:
if only_due: if only_due:
feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND profile_id=? AND enabled=1 AND (next_check_at IS NULL OR next_check_at<=?)", (user_id, profile_id, now)).fetchall() feeds = conn.execute("SELECT * FROM rss_feeds WHERE profile_id=? AND enabled=1 AND (next_check_at IS NULL OR next_check_at<=?)", (profile_id, now)).fetchall()
else: else:
feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND profile_id=? AND enabled=1", (user_id, profile_id)).fetchall() feeds = conn.execute("SELECT * FROM rss_feeds WHERE profile_id=? AND enabled=1", (profile_id,)).fetchall()
rules = conn.execute("SELECT * FROM rss_rules WHERE user_id=? AND profile_id=? AND enabled=1", (user_id, profile_id)).fetchall() rules = conn.execute("SELECT * FROM rss_rules WHERE profile_id=? AND enabled=1", (profile_id,)).fetchall()
queued = 0 queued = 0
tested = 0 tested = 0
errors: list[dict] = [] errors: list[dict] = []
@@ -160,11 +159,11 @@ def check(profile: dict, user_id: int | None = None, only_due: bool = False) ->
continue continue
link = item.get("link") or "" link = item.get("link") or ""
if not link: if not link:
_log(user_id, profile_id, feed["id"], rule["id"], item, "skipped", "missing link") _log(profile_id, feed["id"], rule["id"], item, "skipped", "missing link")
continue continue
enqueue("add_magnet", profile_id, {"uri": link, "start": bool(rule["start"]), "directory": rule.get("save_path") or rtorrent.default_download_path(profile), "label": rule.get("label") or "", "source": "rss"}, user_id=user_id) enqueue("add_magnet", profile_id, {"uri": link, "start": bool(rule["start"]), "directory": rule.get("save_path") or rtorrent.default_download_path(profile), "label": rule.get("label") or "", "source": "rss"}, user_id=user_id)
queued += 1 queued += 1
_log(user_id, profile_id, feed["id"], rule["id"], item, "queued", reason) _log(profile_id, feed["id"], rule["id"], item, "queued", reason)
with connect() as conn: with connect() as conn:
conn.execute("UPDATE rss_feeds SET last_error=NULL,last_checked_at=?,next_check_at=?,updated_at=? WHERE id=?", (now, next_check, now, feed["id"])) conn.execute("UPDATE rss_feeds SET last_error=NULL,last_checked_at=?,next_check_at=?,updated_at=? WHERE id=?", (now, next_check, now, feed["id"]))
except Exception as exc: except Exception as exc:
@@ -200,11 +199,11 @@ def start_scheduler(socketio=None) -> None:
try: try:
from .preferences import get_profile from .preferences import get_profile
with connect() as conn: with connect() as conn:
profiles = conn.execute("SELECT DISTINCT user_id, profile_id FROM rss_feeds WHERE enabled=1 AND profile_id IS NOT NULL").fetchall() profiles = conn.execute("SELECT DISTINCT profile_id FROM rss_feeds WHERE enabled=1 AND profile_id IS NOT NULL").fetchall()
for row in profiles: for row in profiles:
profile = get_profile(int(row["profile_id"]), int(row["user_id"])) profile = get_profile(int(row["profile_id"]))
if profile: if profile:
result = check(profile, int(row["user_id"]), only_due=True) result = check(profile, only_due=True)
if socketio and result.get("queued"): if socketio and result.get("queued"):
socketio.emit("rss_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}") socketio.emit("rss_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}")
except Exception: except Exception:

View File

@@ -3,45 +3,347 @@ from __future__ import annotations
from .client import * from .client import *
RTORRENT_CONFIG_FIELDS = [ RTORRENT_CONFIG_FIELDS = [
{"group": "Directories", "key": "directory.default", "label": "Default download directory", "type": "text"}, {
{"group": "Directories", "key": "session.path", "label": "Session path", "type": "text"}, "group": "Directories",
{"group": "Directories", "key": "system.cwd", "label": "Working directory", "type": "text", "readonly": True}, "key": "directory.default",
{"group": "Network", "key": "network.port_range", "label": "Incoming port range", "type": "text", "placeholder": "49164-49164"}, "label": "Default download directory",
{"group": "Network", "key": "network.port_random", "label": "Random incoming port", "type": "bool"}, "type": "text",
{"group": "Network", "key": "network.bind_address", "label": "Bind address", "type": "text", "placeholder": "0.0.0.0"}, "description": "Main destination for new downloads added without an explicit directory.",
{"group": "Network", "key": "network.local_address", "label": "Local address", "type": "text"}, "recommendation": "Use a stable absolute path on storage with enough free space; avoid changing it while active torrents use relative paths.",
{"group": "Network", "key": "network.max_open_files", "label": "Max open files", "type": "number"}, },
{"group": "Network", "key": "network.max_open_sockets", "label": "Max open sockets", "type": "number"}, {
{"group": "Network", "key": "network.http.max_open", "label": "Max HTTP connections", "type": "number"}, "group": "Directories",
{"group": "Network", "key": "network.http.ssl_verify_peer", "label": "Verify SSL peers", "type": "bool"}, "key": "session.path",
{"group": "Network", "key": "network.xmlrpc.size_limit", "label": "XML-RPC upload size limit", "type": "text", "placeholder": "16M"}, "label": "Session path",
{"group": "Peers", "key": "throttle.min_peers.normal", "label": "Min peers downloading", "type": "number"}, "type": "text",
{"group": "Peers", "key": "throttle.max_peers.normal", "label": "Max peers downloading", "type": "number"}, "description": "Directory where rTorrent stores session state, resume data and internal torrent metadata.",
{"group": "Peers", "key": "throttle.min_peers.seed", "label": "Min peers seeding", "type": "number"}, "recommendation": "Keep it on reliable local storage and include it in backups before maintenance.",
{"group": "Peers", "key": "throttle.max_peers.seed", "label": "Max peers seeding", "type": "number"}, },
{"group": "Peers", "key": "trackers.numwant", "label": "Tracker numwant", "type": "number"}, {
{"group": "Throttle", "key": "throttle.global_down.max_rate", "label": "Global download limit B/s", "type": "number"}, "group": "Directories",
{"group": "Throttle", "key": "throttle.global_up.max_rate", "label": "Global upload limit B/s", "type": "number"}, "key": "system.cwd",
{"group": "Throttle", "key": "throttle.max_downloads.global", "label": "Max active downloads", "type": "number"}, "label": "Working directory",
{"group": "Throttle", "key": "throttle.max_uploads.global", "label": "Max active uploads", "type": "number"}, "type": "text",
{"group": "Throttle", "key": "throttle.max_downloads.div", "label": "Max downloads per throttle", "type": "number"}, "readonly": True,
{"group": "Throttle", "key": "throttle.max_uploads.div", "label": "Max uploads per throttle", "type": "number"}, "description": "Current rTorrent process working directory reported by rTorrent.",
{"group": "DHT / PEX", "key": "dht.mode", "label": "DHT mode", "type": "text", "placeholder": "disable/off/auto/on"}, "recommendation": "Read-only diagnostic value; change it in the service or startup configuration if needed.",
{"group": "DHT / PEX", "key": "dht.port", "label": "DHT port", "type": "number"}, },
{"group": "DHT / PEX", "key": "protocol.pex", "label": "Peer exchange", "type": "bool"}, {
{"group": "Protocol", "key": "protocol.encryption.set", "label": "Encryption flags", "type": "text", "placeholder": "allow_incoming,try_outgoing,enable_retry"}, "group": "Network",
{"group": "Protocol", "key": "protocol.connection.leech", "label": "Leech connection type", "type": "text", "placeholder": "leech"}, "key": "network.port_range",
{"group": "Protocol", "key": "protocol.connection.seed", "label": "Seed connection type", "type": "text", "placeholder": "seed"}, "label": "Incoming port range",
{"group": "Files", "key": "pieces.hash.on_completion", "label": "Hash check on completion", "type": "bool"}, "type": "text",
{"group": "Files", "key": "pieces.preload.type", "label": "Pieces preload type", "type": "number"}, "placeholder": "49164-49164",
{"group": "Files", "key": "pieces.preload.min_size", "label": "Pieces preload min size", "type": "number"}, "description": "TCP port or range used for incoming peer connections.",
{"group": "Files", "key": "pieces.preload.min_rate", "label": "Pieces preload min rate", "type": "number"}, "recommendation": "Use a fixed forwarded port, for example 49164-49164, for stable connectivity.",
{"group": "Files", "key": "system.file.allocate", "label": "File allocation", "type": "number"}, },
{"group": "Files", "key": "system.file.max_size", "label": "Max file size", "type": "number"}, {
{"group": "System", "key": "system.umask", "label": "File umask", "type": "text", "placeholder": "0002"}, "group": "Network",
{"group": "System", "key": "system.hostname", "label": "Hostname", "type": "text", "readonly": True}, "key": "network.port_random",
{"group": "System", "key": "system.client_version", "label": "Client version", "type": "text", "readonly": True}, "label": "Random incoming port",
{"group": "System", "key": "system.library_version", "label": "Library version", "type": "text", "readonly": True}, "type": "bool",
"description": "Lets rTorrent select a random incoming port on startup.",
"recommendation": "Disable it when using router/NAT forwarding; fixed ports are easier to monitor.",
},
{
"group": "Network",
"key": "network.bind_address",
"label": "Bind address",
"type": "text",
"placeholder": "0.0.0.0",
"description": "Local interface address used for peer traffic binding.",
"recommendation": "Leave empty unless the host has multiple interfaces or policy routing.",
},
{
"group": "Network",
"key": "network.local_address",
"label": "Announced local address",
"type": "text",
"description": "Address rTorrent may announce as its local network address.",
"recommendation": "Usually leave empty; set only when a specific advertised address is required.",
},
{
"group": "Network",
"key": "network.max_open_files",
"label": "Max open files",
"type": "number",
"description": "Maximum number of files rTorrent can keep open at once.",
"recommendation": "Raise together with the OS file descriptor limit on large seeds.",
},
{
"group": "Network",
"key": "network.max_open_sockets",
"label": "Max open sockets",
"type": "number",
"description": "Upper bound for peer and tracker sockets opened by rTorrent.",
"recommendation": "Keep below OS limits; increase gradually when many torrents are active.",
},
{
"group": "Network",
"key": "network.http.max_open",
"label": "Max HTTP connections",
"type": "number",
"description": "Maximum simultaneous HTTP connections for tracker and metadata requests.",
"recommendation": "Moderate values reduce tracker pressure; increase only if tracker requests queue up.",
},
{
"group": "Network",
"key": "network.http.ssl_verify_peer",
"label": "Verify SSL peers",
"type": "bool",
"description": "Controls certificate verification for HTTPS tracker connections.",
"recommendation": "Keep enabled unless a private tracker has a known certificate problem.",
},
{
"group": "Network",
"key": "network.xmlrpc.size_limit",
"label": "XML-RPC upload size limit",
"type": "text",
"placeholder": "16M",
"description": "Maximum XML-RPC payload size accepted by rTorrent.",
"recommendation": "Keep enough headroom for large UI responses; avoid very high values on public endpoints.",
},
{
"group": "Peers",
"key": "throttle.min_peers.normal",
"label": "Min peers while downloading",
"type": "number",
"description": "Minimum peer target for incomplete torrents.",
"recommendation": "Use a conservative floor; too high values can waste sockets on weak swarms.",
},
{
"group": "Peers",
"key": "throttle.max_peers.normal",
"label": "Max peers while downloading",
"type": "number",
"description": "Maximum peer target for incomplete torrents.",
"recommendation": "Increase for fast lines, but keep total sockets and CPU usage under control.",
},
{
"group": "Peers",
"key": "throttle.min_peers.seed",
"label": "Min peers while seeding",
"type": "number",
"description": "Minimum peer target for complete torrents.",
"recommendation": "Lower than download min peers is usually enough for long-term seeding.",
},
{
"group": "Peers",
"key": "throttle.max_peers.seed",
"label": "Max peers while seeding",
"type": "number",
"description": "Maximum peer target for complete torrents.",
"recommendation": "Avoid excessive values on many seeding torrents because sockets multiply quickly.",
},
{
"group": "Peers",
"key": "trackers.numwant",
"label": "Tracker numwant",
"type": "number",
"description": "Number of peers requested from trackers per announce where supported.",
"recommendation": "Use moderate values; many trackers cap this server-side anyway.",
},
{
"group": "Throttle",
"key": "throttle.global_down.max_rate",
"label": "Global download limit B/s",
"type": "number",
"description": "Global download speed cap in bytes per second. Zero usually means unlimited.",
"recommendation": "Leave unlimited or cap below line speed if other services share the connection.",
},
{
"group": "Throttle",
"key": "throttle.global_up.max_rate",
"label": "Global upload limit B/s",
"type": "number",
"description": "Global upload speed cap in bytes per second. Zero usually means unlimited.",
"recommendation": "Keep below real upstream capacity to avoid bufferbloat and slow downloads.",
},
{
"group": "Throttle",
"key": "throttle.max_downloads.global",
"label": "Max active downloads",
"type": "number",
"description": "Maximum number of downloading torrents active at once.",
"recommendation": "Match disk and network capacity; fewer active downloads often finish faster.",
},
{
"group": "Throttle",
"key": "throttle.max_uploads.global",
"label": "Max active uploads",
"type": "number",
"description": "Maximum number of uploading torrents active at once.",
"recommendation": "Keep enough slots for ratio goals without overloading disks and sockets.",
},
{
"group": "Throttle",
"key": "throttle.max_downloads.div",
"label": "Max downloads per throttle",
"type": "number",
"description": "Per-throttle download slot divisor used by rTorrent throttling logic.",
"recommendation": "Change only when using named throttle groups or advanced queues.",
},
{
"group": "Throttle",
"key": "throttle.max_uploads.div",
"label": "Max uploads per throttle",
"type": "number",
"description": "Per-throttle upload slot divisor used by rTorrent throttling logic.",
"recommendation": "Change only when using named throttle groups or advanced queues.",
},
{
"group": "DHT / PEX",
"key": "dht.mode",
"label": "DHT mode",
"type": "text",
"placeholder": "disable/off/auto/on",
"description": "Controls Distributed Hash Table usage for peer discovery.",
"recommendation": "Private-tracker setups often disable DHT; public torrents usually benefit from auto/on.",
},
{
"group": "DHT / PEX",
"key": "dht.port",
"label": "DHT port",
"type": "number",
"description": "UDP port used by DHT traffic.",
"recommendation": "Use the same forwarded port strategy as incoming TCP when DHT is enabled.",
},
{
"group": "DHT / PEX",
"key": "protocol.pex",
"label": "Peer exchange",
"type": "bool",
"description": "Enables Peer Exchange peer discovery between connected peers.",
"recommendation": "Disable for strict private-tracker policies; enable for public swarms if allowed.",
},
{
"group": "DHT / PEX",
"key": "trackers.use_udp",
"label": "UDP trackers",
"type": "bool",
"description": "Allows rTorrent to use UDP trackers where supported.",
"recommendation": "Keep enabled for public torrents unless the network blocks UDP tracker traffic.",
},
{
"group": "Protocol",
"key": "protocol.encryption.set",
"label": "Encryption flags",
"type": "text",
"placeholder": "allow_incoming,try_outgoing,enable_retry",
"description": "Encryption policy flags for peer connections.",
"recommendation": "Prefer permissive settings unless a tracker or network requires strict encryption.",
},
{
"group": "Protocol",
"key": "protocol.connection.leech",
"label": "Leech connection type",
"type": "text",
"placeholder": "leech",
"description": "Connection behavior profile used by incomplete torrents.",
"recommendation": "Leave default unless tuning advanced libTorrent behavior.",
},
{
"group": "Protocol",
"key": "protocol.connection.seed",
"label": "Seed connection type",
"type": "text",
"placeholder": "seed",
"description": "Connection behavior profile used by complete torrents.",
"recommendation": "Leave default unless tuning advanced libTorrent behavior.",
},
{
"group": "Files",
"key": "pieces.hash.on_completion",
"label": "Hash check on completion",
"type": "bool",
"description": "Runs a hash verification after a torrent completes.",
"recommendation": "Enable for data integrity when storage is unreliable; disable if completion checks are too expensive.",
},
{
"group": "Files",
"key": "pieces.preload.type",
"label": "Pieces preload type",
"type": "number",
"description": "Controls how rTorrent preloads torrent pieces from disk.",
"recommendation": "Keep default unless you are tuning disk cache behavior for a known workload.",
},
{
"group": "Files",
"key": "pieces.preload.min_size",
"label": "Pieces preload min size",
"type": "number",
"description": "Minimum piece size threshold for preload behavior.",
"recommendation": "Keep default unless large-piece torrents show disk latency issues.",
},
{
"group": "Files",
"key": "pieces.preload.min_rate",
"label": "Pieces preload min rate",
"type": "number",
"description": "Minimum transfer rate threshold for preloading pieces.",
"recommendation": "Tune only after measuring disk read pressure.",
},
{
"group": "Files",
"key": "pieces.memory.max",
"label": "Pieces memory max",
"type": "text",
"placeholder": "512M",
"description": "Maximum memory rTorrent may use for piece handling where supported.",
"recommendation": "Avoid values that compete with OS page cache; increase only on hosts with spare RAM.",
},
{
"group": "Files",
"key": "system.file.allocate",
"label": "File allocation",
"type": "number",
"description": "Controls preallocation behavior for downloaded files.",
"recommendation": "Preallocation can reduce fragmentation but may slow adding very large torrents.",
},
{
"group": "Files",
"key": "system.file.max_size",
"label": "Max file size",
"type": "number",
"description": "Maximum single file size rTorrent accepts where supported.",
"recommendation": "Leave default unless you intentionally need to block oversized files.",
},
{
"group": "System",
"key": "system.umask",
"label": "File umask",
"type": "text",
"placeholder": "0002",
"description": "Permission mask applied to files created by rTorrent.",
"recommendation": "Use 0002 for shared media groups, 0022 for private single-user setups.",
},
{
"group": "System",
"key": "system.hostname",
"label": "Hostname",
"type": "text",
"readonly": True,
"description": "Hostname reported by the rTorrent runtime.",
"recommendation": "Read-only diagnostic value.",
},
{
"group": "System",
"key": "system.client_version",
"label": "Client version",
"type": "text",
"readonly": True,
"description": "rTorrent client version reported through XML-RPC.",
"recommendation": "Read-only diagnostic value useful when checking compatibility.",
},
{
"group": "System",
"key": "system.library_version",
"label": "Library version",
"type": "text",
"readonly": True,
"description": "libTorrent library version used by rTorrent.",
"recommendation": "Read-only diagnostic value useful when checking compatibility.",
},
] ]
@@ -54,11 +356,10 @@ def _normalize_config_value(meta: dict, value):
def saved_config_overrides(profile_id: int, user_id: int | None = None) -> dict[str, dict]: def saved_config_overrides(profile_id: int, user_id: int | None = None) -> dict[str, dict]:
user_id = user_id or default_user_id()
with connect() as conn: with connect() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT key,value,baseline_value,apply_on_start,updated_at FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?", "SELECT key,value,baseline_value,apply_on_start,updated_at FROM rtorrent_config_overrides WHERE profile_id=?",
(user_id, int(profile_id)), (int(profile_id),),
).fetchall() ).fetchall()
return {r["key"]: r for r in rows} return {r["key"]: r for r in rows}
@@ -129,7 +430,6 @@ def _read_rtorrent_config_value(client, key: str, meta: dict) -> str:
def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, baseline_values: dict | None = None, clear_keys: list[str] | None = None) -> list[str]: def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, baseline_values: dict | None = None, clear_keys: list[str] | None = None) -> list[str]:
known = {f["key"]: f for f in RTORRENT_CONFIG_FIELDS} known = {f["key"]: f for f in RTORRENT_CONFIG_FIELDS}
user_id = default_user_id()
now = utcnow() now = utcnow()
profile_id = int(profile["id"]) profile_id = int(profile["id"])
baseline_values = baseline_values or {} baseline_values = baseline_values or {}
@@ -139,8 +439,8 @@ def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, ba
for key in clear_set: for key in clear_set:
if key in known: if key in known:
conn.execute( conn.execute(
"DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?", "DELETE FROM rtorrent_config_overrides WHERE profile_id=? AND key=?",
(user_id, profile_id, key), (profile_id, key),
) )
for key, value in (values or {}).items(): for key, value in (values or {}).items():
if key in clear_set: if key in clear_set:
@@ -150,8 +450,8 @@ def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, ba
continue continue
normalized = _normalize_config_value(meta, value) normalized = _normalize_config_value(meta, value)
existing = conn.execute( existing = conn.execute(
"SELECT baseline_value FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?", "SELECT baseline_value FROM rtorrent_config_overrides WHERE profile_id=? AND key=?",
(user_id, profile_id, key), (profile_id, key),
).fetchone() ).fetchone()
existing_baseline = existing.get("baseline_value") if existing else None existing_baseline = existing.get("baseline_value") if existing else None
@@ -165,18 +465,18 @@ def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, ba
if baseline not in (None, "") and normalized == baseline: if baseline not in (None, "") and normalized == baseline:
conn.execute( conn.execute(
"DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?", "DELETE FROM rtorrent_config_overrides WHERE profile_id=? AND key=?",
(user_id, profile_id, key), (profile_id, key),
) )
continue continue
conn.execute( conn.execute(
"INSERT OR REPLACE INTO rtorrent_config_overrides(user_id,profile_id,key,value,baseline_value,apply_on_start,updated_at) VALUES(?,?,?,?,?,?,?)", "INSERT OR REPLACE INTO rtorrent_config_overrides(profile_id,key,value,baseline_value,apply_on_start,updated_at) VALUES(?,?,?,?,?,?)",
(user_id, profile_id, key, normalized, baseline, 1 if apply_on_start else 0, now), (profile_id, key, normalized, baseline, 1 if apply_on_start else 0, now),
) )
stored.append(key) stored.append(key)
conn.execute( conn.execute(
"UPDATE rtorrent_config_overrides SET apply_on_start=?, updated_at=? WHERE user_id=? AND profile_id=?", "UPDATE rtorrent_config_overrides SET apply_on_start=?, updated_at=? WHERE profile_id=?",
(1 if apply_on_start else 0, now, user_id, profile_id), (1 if apply_on_start else 0, now, profile_id),
) )
return stored return stored
@@ -220,17 +520,16 @@ def set_config(profile: dict, values: dict, apply_now: bool = True, apply_on_sta
def reset_config_overrides(profile: dict, user_id: int | None = None) -> dict: def reset_config_overrides(profile: dict, user_id: int | None = None) -> dict:
"""Remove saved UI overrides and return the freshly read rTorrent config.""" """Remove saved UI overrides and return the freshly read rTorrent config."""
# Note: Reset means "forget pyTorrent UI overrides"; it does not write defaults back to rTorrent. # Note: Reset means "forget pyTorrent UI overrides"; it does not write defaults back to rTorrent.
user_id = user_id or default_user_id()
profile_id = int(profile["id"]) profile_id = int(profile["id"])
with connect() as conn: with connect() as conn:
row = conn.execute( row = conn.execute(
"SELECT COUNT(*) AS count FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?", "SELECT COUNT(*) AS count FROM rtorrent_config_overrides WHERE profile_id=?",
(user_id, profile_id), (profile_id,),
).fetchone() ).fetchone()
removed = int((row or {}).get("count") or 0) removed = int((row or {}).get("count") or 0)
conn.execute( conn.execute(
"DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?", "DELETE FROM rtorrent_config_overrides WHERE profile_id=?",
(user_id, profile_id), (profile_id,),
) )
config = get_config(profile) config = get_config(profile)
config["reset_removed"] = removed config["reset_removed"] = removed

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from .client import * from .client import *
from .. import poller_control
import shlex import shlex
def scgi_diagnostics(profile: dict) -> dict: def scgi_diagnostics(profile: dict) -> dict:
@@ -64,7 +65,12 @@ def scgi_diagnostics(profile: dict) -> dict:
def profile_diagnostics(profile: dict) -> dict: def profile_diagnostics(profile: dict) -> dict:
"""Lightweight per-profile diagnostics for save/test UI.""" """Lightweight per-profile diagnostics for save/test UI."""
started = time.perf_counter() started = time.perf_counter()
result = {"profile_id": profile.get("id"), "ok": False, "checks": {}} profile_id = profile.get("id")
try:
slow_threshold_ms = float(poller_control.get_settings(int(profile_id)).get("slow_response_threshold_ms") or poller_control.DEFAULTS["slow_response_threshold_ms"])
except Exception:
slow_threshold_ms = float(poller_control.DEFAULTS["slow_response_threshold_ms"])
result = {"profile_id": profile_id, "ok": False, "checks": {}, "slow_threshold_ms": slow_threshold_ms}
try: try:
c = client_for(profile) c = client_for(profile)
version = str(c.call("system.client_version") or "") version = str(c.call("system.client_version") or "")
@@ -96,7 +102,7 @@ def profile_diagnostics(profile: dict) -> dict:
free_disk[base] = {"error": str(exc)} free_disk[base] = {"error": str(exc)}
result.update({ result.update({
"ok": True, "ok": True,
"status": "online", "status": "normal",
"version": version, "version": version,
"library_version": library, "library_version": library,
"base_paths": paths, "base_paths": paths,
@@ -106,7 +112,8 @@ def profile_diagnostics(profile: dict) -> dict:
}) })
except Exception as exc: except Exception as exc:
result.update({"ok": False, "status": "error", "error": str(exc), "response_time_ms": round((time.perf_counter() - started) * 1000, 2)}) result.update({"ok": False, "status": "error", "error": str(exc), "response_time_ms": round((time.perf_counter() - started) * 1000, 2)})
if result.get("ok") and result.get("response_time_ms", 0) > 1500: # Note: Profile diagnostics uses the same slow-response threshold as Tools -> Poller for this profile.
if result.get("ok") and result.get("response_time_ms", 0) > slow_threshold_ms:
result["status"] = "slow" result["status"] = "slow"
return result return result

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from .client import * from .client import *
from ...config import BASE_DIR
def torrent_files(profile: dict, torrent_hash: str) -> list[dict]: def torrent_files(profile: dict, torrent_hash: str) -> list[dict]:
rows = client_for(profile).f.multicall(torrent_hash, "", "f.path=", "f.size_bytes=", "f.completed_chunks=", "f.size_chunks=", "f.priority=") rows = client_for(profile).f.multicall(torrent_hash, "", "f.path=", "f.size_bytes=", "f.completed_chunks=", "f.size_chunks=", "f.priority=")
@@ -58,10 +59,17 @@ def _torrent_file_remote_path(profile: dict, torrent_hash: str, index: int) -> t
if selected is None: if selected is None:
available = ", ".join(str(f.get("index")) for f in files[:20]) or "none" available = ", ".join(str(f.get("index")) for f in files[:20]) or "none"
raise ValueError(f"File index {index} not found. Available indexes: {available}") raise ValueError(f"File index {index} not found. Available indexes: {available}")
base = _remote_clean_path(_torrent_data_path(c, torrent_hash)) base = _remote_clean_path(_torrent_data_path(c, torrent_hash))
rel = str(selected.get("path") or "").lstrip("/") rel = str(selected.get("path") or "").lstrip("/")
if len(files) == 1 and base and not base.endswith("/"):
path = base # Note: rTorrent can report d.base_path as either the payload file or the
# containing data directory for a one-file torrent. Keep both existing
# layouts working and avoid treating a directory as the media file.
if len(files) == 1 and base and rel:
base_name = posixpath.basename(base.rstrip("/"))
rel_name = posixpath.basename(rel.rstrip("/"))
path = base if base_name == rel_name else _remote_join(base, rel)
else: else:
path = _remote_join(base, rel) path = _remote_join(base, rel)
return selected, path return selected, path
@@ -123,6 +131,392 @@ def iter_remote_file_chunks(profile: dict, source_path: str, size: int | None =
break break
_MEDIA_INFO_EXTENSIONS = {
".3g2", ".3gp", ".aac", ".aiff", ".ape", ".asf", ".avi", ".flac",
".flv", ".m4a", ".m4v", ".mka", ".mkv", ".mov", ".mp3", ".mp4",
".mpeg", ".mpg", ".ogg", ".opus", ".ts", ".wav", ".webm", ".wma", ".wmv",
}
_TEXT_PREVIEW_EXTENSIONS = {
".ass", ".cue", ".csv", ".ini", ".json", ".log", ".m3u", ".m3u8",
".md", ".nfo", ".srt", ".ssa", ".sub", ".sfv", ".txt", ".url",
".xml", ".yaml", ".yml",
}
_IMAGE_PREVIEW_EXTENSIONS = {".avif", ".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp"}
_PDF_PREVIEW_EXTENSIONS = {".pdf"}
_MEDIA_INFO_SAMPLE_BYTES = 32 * 1024 * 1024
_MEDIA_INFO_CHUNK_BYTES = 1024 * 1024
_TEXT_PREVIEW_BYTES = 512 * 1024
_IMAGE_PREVIEW_BYTES = 8 * 1024 * 1024
_MEDIA_INFO_TMP_DIR = BASE_DIR / "data" / "media-info-samples"
def _file_extension(path: str) -> str:
return LocalPath(str(path or "")).suffix.lower()
def _media_info_supported(path: str) -> bool:
# Note: Extension filtering avoids trying binary metadata parsers on every torrent payload file.
return _file_extension(path) in _MEDIA_INFO_EXTENSIONS
def _text_preview_supported(path: str) -> bool:
# Note: Text previews intentionally include NFO and subtitle files so the existing info button becomes useful for release notes too.
return _file_extension(path) in _TEXT_PREVIEW_EXTENSIONS
def _image_preview_supported(path: str) -> bool:
# Note: Image previews are limited to browser-safe raster formats and avoid SVG to prevent inline script-like payloads.
return _file_extension(path) in _IMAGE_PREVIEW_EXTENSIONS
def _pdf_preview_supported(path: str) -> bool:
# Note: PDF previews are rendered inline by the browser so image-heavy books keep their page layout.
return _file_extension(path) in _PDF_PREVIEW_EXTENSIONS
def _media_info_sample_suffix(source_path: str) -> str:
suffix = LocalPath(str(source_path or "")).suffix.lower()
if suffix and len(suffix) <= 16 and all(ch.isalnum() or ch in ".-_" for ch in suffix):
return suffix
return ".bin"
def _read_file_prefix(profile: dict, source_path: str, max_bytes: int) -> bytes:
# Note: File info must read through rTorrent, not the pyTorrent process, because torrents may live on a remote host or under rTorrent-only permissions.
limit = max(0, int(max_bytes or 0))
chunks: list[bytes] = []
collected = 0
for chunk in iter_remote_file_chunks(profile, source_path, size=limit, chunk_size=_MEDIA_INFO_CHUNK_BYTES):
if collected >= limit:
break
data = bytes(chunk[: max(0, limit - collected)])
chunks.append(data)
collected += len(data)
return b"".join(chunks)
def _decode_text_preview(data: bytes) -> tuple[str, str]:
# Note: NFO files are often CP437, while normal text is usually UTF-8; the fallback keeps ASCII art readable.
if not data:
return "utf-8", ""
for encoding in ("utf-8-sig", "utf-8"):
try:
return encoding, data.decode(encoding)
except UnicodeDecodeError:
pass
for encoding in ("cp437", "cp1250", "latin-1"):
try:
return encoding, data.decode(encoding, errors="replace")
except Exception:
pass
return "utf-8", data.decode("utf-8", errors="replace")
def _image_preview_mime(path: str) -> str:
# Note: The MIME type is extension-based because preview input is already restricted to known image suffixes.
ext = _file_extension(path)
return {
".avif": "image/avif",
".bmp": "image/bmp",
".gif": "image/gif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
}.get(ext, "application/octet-stream")
def _text_file_preview(profile: dict, selected: dict, remote_path: str, max_bytes: int = _TEXT_PREVIEW_BYTES) -> dict:
# Note: Text preview returns escaped-by-frontend content and a clear truncation flag for large NFO/log/subtitle files.
size = int(selected.get("size") or 0)
data = _read_file_prefix(profile, remote_path, max_bytes)
encoding, text = _decode_text_preview(data)
return {
**selected,
"kind": "text",
"parser": "text-preview",
"supported": True,
"sample_bytes": len(data),
"sample_limit": int(max_bytes),
"partial": bool(size and len(data) < size),
"encoding": encoding,
"text": text,
"line_count": text.count("\n") + (1 if text else 0),
"summary": {},
"fields": [
{"key": "Type", "value": "Text preview"},
{"key": "Encoding", "value": encoding},
{"key": "Preview bytes", "value": human_size(len(data))},
],
"raw": [],
}
def _image_file_preview(profile: dict, selected: dict, remote_path: str, max_bytes: int = _IMAGE_PREVIEW_BYTES) -> dict:
# Note: Image preview is size capped and CSS-constrained in the modal instead of decoding/resizing images server-side.
size = int(selected.get("size") or 0)
result = {
**selected,
"kind": "image",
"parser": "image-preview",
"supported": True,
"sample_bytes": 0,
"sample_limit": int(max_bytes),
"partial": False,
"mime_type": _image_preview_mime(str(selected.get("path") or remote_path)),
"summary": {},
"fields": [
{"key": "Type", "value": "Image preview"},
{"key": "Preview limit", "value": human_size(max_bytes)},
],
"raw": [],
}
if size > max_bytes:
result.update({
"too_large": True,
"error": f"Image preview is limited to {human_size(max_bytes)}. Download the file to view the full image.",
})
return result
data = _read_file_prefix(profile, remote_path, max_bytes)
import base64
result.update({
"sample_bytes": len(data),
"data_url": f"data:{result['mime_type']};base64,{base64.b64encode(data).decode('ascii')}",
"fields": result["fields"] + [
{"key": "Image bytes", "value": human_size(len(data))},
{"key": "MIME type", "value": result["mime_type"]},
],
})
return result
def _pdf_file_preview(
profile: dict,
selected: dict,
remote_path: str,
) -> dict:
# Note: pypdf is no longer required because PDFs are not parsed; the browser renders the original file stream.
size = int(selected.get("size") or 0)
return {
**selected,
"kind": "pdf",
"parser": "browser-pdf-viewer",
"supported": True,
"sample_bytes": 0,
"sample_limit": 0,
"page_limit": 0,
"partial": False,
"summary": {
"duration": None,
"bit_rate": human_size(size) if size else None,
"compression": "PDF",
"producer": "Browser inline preview",
"creation_date": None,
},
"fields": [
{"key": "Type", "value": "PDF inline preview"},
{"key": "PDF size", "value": human_size(size)},
{"key": "Preview mode", "value": "Browser PDF renderer"},
],
"raw": [],
"text": "",
}
def _media_info_temp_sample(profile: dict, source_path: str, max_bytes: int) -> tuple[str, int]:
# Note: hachoir needs a seekable file, so this writes a bounded sample into the app data directory instead of loading whole media into RAM.
import tempfile
_MEDIA_INFO_TMP_DIR.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(
prefix="pytorrent-mediainfo-",
suffix=_media_info_sample_suffix(source_path),
dir=str(_MEDIA_INFO_TMP_DIR),
)
written = 0
try:
with os.fdopen(fd, "wb") as tmp:
for chunk in iter_remote_file_chunks(profile, source_path, size=max_bytes, chunk_size=_MEDIA_INFO_CHUNK_BYTES):
if written >= max_bytes:
break
data = bytes(chunk[: max(0, max_bytes - written)])
tmp.write(data)
written += len(data)
return tmp_path, written
except Exception:
try:
os.unlink(tmp_path)
except Exception:
pass
raise
def _media_info_plaintext(metadata) -> list[str]:
# Note: exportPlaintext is the most stable hachoir API across supported package versions.
try:
lines = metadata.exportPlaintext() or []
except Exception:
return []
return [str(line).strip(" -") for line in lines if str(line).strip(" -")]
def _media_info_parse_lines(lines: list[str]) -> list[dict]:
# Note: The frontend receives both grouped fields and raw text so unknown hachoir fields stay visible.
fields = []
for line in lines:
if not line or ":" not in line:
continue
key, value = line.split(":", 1)
key = key.strip()
value = value.strip()
if key and value:
fields.append({"key": key, "value": value})
return fields
def _media_info_field_lookup(fields: list[dict]) -> dict:
lookup = {}
for field in fields:
key = str(field.get("key") or "").lower()
if key and key not in lookup:
lookup[key] = field.get("value")
return lookup
def _media_info_summary(fields: list[dict]) -> dict:
# Note: Summary keeps the modal readable while raw fields remain available below it.
lookup = _media_info_field_lookup(fields)
def first(*names):
for name in names:
value = lookup.get(name.lower())
if value:
return value
return None
return {
"duration": first("Duration", "Play duration"),
"bit_rate": first("Bit rate", "Overall bit rate"),
"width": first("Image width", "Width"),
"height": first("Image height", "Height"),
"frame_rate": first("Frame rate"),
"sample_rate": first("Sample rate"),
"channels": first("Channel", "Channel(s)", "Channels"),
"compression": first("Compression", "Compressor", "Codec", "Video codec", "Audio codec"),
"producer": first("Producer", "Encoder", "Writing application"),
"creation_date": first("Creation date", "Creation time"),
}
def _media_info_hachoir_imports():
# Note: Import is checked before reading the media sample so dependency problems fail fast and clearly.
import sys
try:
from hachoir.metadata import extractMetadata
from hachoir.parser import createParser
return createParser, extractMetadata
except ModuleNotFoundError as exc:
missing = str(getattr(exc, "name", "") or "hachoir")
if missing.split(".", 1)[0] == "hachoir":
raise RuntimeError(
"Python package 'hachoir' is not importable in the application runtime. "
"Install it inside the pyTorrent virtualenv and restart the service: "
"/opt/pyTorrent/venv/bin/pip install -r /opt/pyTorrent/requirements.txt && systemctl restart pytorrent. "
f"Runtime: {sys.executable}."
) from exc
raise RuntimeError(
f"hachoir is installed, but one of its Python dependencies is missing: {missing}. "
f"Runtime: {sys.executable}."
) from exc
except Exception as exc:
raise RuntimeError(
"hachoir was found, but failed during import. "
f"Runtime: {sys.executable}. Details: {exc}"
) from exc
def _torrent_file_is_complete(selected: dict) -> bool:
# Note: File info reads real file bytes, so incomplete payload files are blocked before any parser touches them.
size = int(selected.get("size") or 0)
completed_chunks = int(selected.get("completed_chunks") or 0)
size_chunks = int(selected.get("size_chunks") or 0)
progress = float(selected.get("progress") or 0)
return size <= 0 or progress >= 100.0 or (size_chunks > 0 and completed_chunks >= size_chunks)
def torrent_file_media_info(profile: dict, torrent_hash: str, index: int, max_bytes: int = _MEDIA_INFO_SAMPLE_BYTES) -> dict:
# Note: This additive endpoint now acts as a smart file preview: media metadata, text/NFO reader, or image preview depending on file type.
selected, remote_path = _torrent_file_remote_path(profile, torrent_hash, index)
name = str(selected.get("path") or remote_path)
size = int(selected.get("size") or 0)
if not _torrent_file_is_complete(selected):
raise RuntimeError("File info is available only after this file is fully downloaded.")
err = remote_file_readability_error(profile, remote_path)
if err:
raise RuntimeError(err)
if _text_preview_supported(name):
return _text_file_preview(profile, selected, remote_path)
if _image_preview_supported(name):
return _image_file_preview(profile, selected, remote_path)
if _pdf_preview_supported(name):
return _pdf_file_preview(profile, selected, remote_path)
supported = _media_info_supported(name)
result = {
**selected,
"kind": "media",
"supported": supported,
"sample_bytes": 0,
"sample_limit": int(max_bytes),
"partial": True,
"summary": {},
"fields": [],
"raw": [],
"parser": "hachoir",
}
if not supported:
result.update({
"kind": "unsupported",
"error": "This file extension is not supported by the built-in preview or media info parser.",
})
return result
createParser, extractMetadata = _media_info_hachoir_imports()
tmp_path = None
try:
tmp_path, written = _media_info_temp_sample(profile, remote_path, max(1024 * 1024, int(max_bytes)))
# Note: Do not pass real_filename here; some hachoir versions treat it as an input path and fail for nested torrent file names.
parser = createParser(tmp_path)
if parser is None:
result.update({"sample_bytes": written, "error": "hachoir could not detect this media container."})
return result
with parser:
metadata = extractMetadata(parser)
if metadata is None:
result.update({"sample_bytes": written, "error": "No media metadata found in the sampled part of the file."})
return result
raw = _media_info_plaintext(metadata)
fields = _media_info_parse_lines(raw)
result.update({
"sample_bytes": written,
"partial": bool(size and written < size),
"summary": _media_info_summary(fields),
"fields": fields,
"raw": raw,
})
return result
finally:
if tmp_path:
try:
os.unlink(tmp_path)
except Exception:
pass
def torrent_download_file_info(profile: dict, torrent_hash: str, index: int) -> dict: def torrent_download_file_info(profile: dict, torrent_hash: str, index: int) -> dict:
selected, remote_path = _torrent_file_remote_path(profile, torrent_hash, index) selected, remote_path = _torrent_file_remote_path(profile, torrent_hash, index)
err = remote_file_readability_error(profile, remote_path) err = remote_file_readability_error(profile, remote_path)
@@ -148,17 +542,31 @@ def torrent_download_zip_items(profile: dict, torrent_hash: str, indexes: list[i
return items return items
def _remote_file_exists(c: ScgiRtorrentClient, source_path: str) -> bool:
# Note: Export fallback checks candidate .torrent files on the rTorrent host before staging, avoiding stale tied-file paths.
clean = _remote_clean_path(source_path)
if not clean:
return False
script = 'p=$1; [ -f "$p" ] && [ -r "$p" ] && printf OK || true'
try:
return str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-file-exists", clean) or "").strip() == "OK"
except Exception:
return False
def _remote_stage_path(c: ScgiRtorrentClient, source_path: str, suffix: str = "") -> str: def _remote_stage_path(c: ScgiRtorrentClient, source_path: str, suffix: str = "") -> str:
token = uuid.uuid4().hex token = uuid.uuid4().hex
safe_suffix = ''.join(ch if ch.isalnum() or ch in '.-_' else '_' for ch in str(suffix or ''))[:80] safe_suffix = ''.join(ch if ch.isalnum() or ch in '.-_' else '_' for ch in str(suffix or ''))[:80]
target = f"{download_tmp_dir().rstrip('/')}/pytorrent-download-{token}{safe_suffix}" target = f"{download_tmp_dir().rstrip('/')}/pytorrent-download-{token}{safe_suffix}"
script = ( script = (
'src=$1; dst=$2; ' 'src=$1; dst=$2; '
'if [ ! -f "$src" ]; then echo "ERR\tmissing source"; exit 0; fi; ' 'if [ ! -f "$src" ]; then printf "ERR\tmissing source: %s\n" "$src"; exit 0; fi; '
'if [ ! -r "$src" ]; then printf "ERR\tsource is not readable: %s\n" "$src"; exit 0; fi; '
'cp -- "$src" "$dst" 2>/tmp/pytorrent-cp-err-$$ || { rc=$?; err=$(cat /tmp/pytorrent-cp-err-$$ 2>/dev/null); rm -f /tmp/pytorrent-cp-err-$$; printf "ERR\t%s\t%s\n" "$rc" "$err"; exit 0; }; ' 'cp -- "$src" "$dst" 2>/tmp/pytorrent-cp-err-$$ || { rc=$?; err=$(cat /tmp/pytorrent-cp-err-$$ 2>/dev/null); rm -f /tmp/pytorrent-cp-err-$$; printf "ERR\t%s\t%s\n" "$rc" "$err"; exit 0; }; '
'rm -f /tmp/pytorrent-cp-err-$$; chmod 0644 "$dst" 2>/dev/null || true; printf "OK\t%s\n" "$dst"' 'rm -f /tmp/pytorrent-cp-err-$$; chmod 0644 "$dst" 2>/dev/null || true; printf "OK\t%s\n" "$dst"'
) )
output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-stage-file", source_path, target) or "").strip() clean_source = _remote_clean_path(source_path)
output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-stage-file", clean_source, target) or "").strip()
parts = (output.splitlines()[0] if output else "").split("\t", 2) parts = (output.splitlines()[0] if output else "").split("\t", 2)
if len(parts) >= 2 and parts[0] == "OK": if len(parts) >= 2 and parts[0] == "OK":
return parts[1] return parts[1]
@@ -250,14 +658,48 @@ def _torrent_raw_from_method(c: ScgiRtorrentClient, torrent_hash: str) -> bytes
return None return None
def _torrent_source_file(c: ScgiRtorrentClient, torrent_hash: str) -> str: def _rtorrent_session_path(c: ScgiRtorrentClient) -> str:
for method in ("session.path", "get_session"):
try:
value = str(c.call(method) or "").strip()
except Exception:
continue
if value:
return _remote_clean_path(value)
return ""
def _torrent_source_file_candidates(c: ScgiRtorrentClient, torrent_hash: str) -> list[str]:
# Note: rTorrent may keep stale watch/tied paths; session candidates preserve .torrent export when the original source was moved.
candidates: list[str] = []
for method in ("d.tied_to_file", "d.get_tied_to_file", "d.loaded_file", "d.get_loaded_file", "d.session_file", "d.get_session_file"): for method in ("d.tied_to_file", "d.get_tied_to_file", "d.loaded_file", "d.get_loaded_file", "d.session_file", "d.get_session_file"):
try: try:
value = str(c.call(method, torrent_hash) or "").strip() value = str(c.call(method, torrent_hash) or "").strip()
except Exception: except Exception:
continue continue
if value: if value:
return value candidates.append(value)
session_path = _rtorrent_session_path(c)
hash_values = []
clean_hash = str(torrent_hash or "").strip()
if clean_hash:
hash_values.extend([clean_hash, clean_hash.upper(), clean_hash.lower()])
for h in dict.fromkeys(hash_values):
if session_path:
candidates.append(_remote_join(session_path, f"{h}.torrent"))
candidates.append(f"/tmp/{h}.torrent")
result = []
for item in candidates:
clean = _remote_clean_path(item)
if clean and clean not in result:
result.append(clean)
return result
def _torrent_source_file(c: ScgiRtorrentClient, torrent_hash: str) -> str:
for source in _torrent_source_file_candidates(c, torrent_hash):
if _remote_file_exists(c, source):
return source
return "" return ""
@@ -265,16 +707,16 @@ def export_torrent_file(profile: dict, torrent_hash: str) -> dict:
c = client_for(profile) c = client_for(profile)
name = str(c.call("d.name", torrent_hash) or torrent_hash).strip() or torrent_hash name = str(c.call("d.name", torrent_hash) or torrent_hash).strip() or torrent_hash
filename = f"{name}.torrent" if not name.lower().endswith(".torrent") else name filename = f"{name}.torrent" if not name.lower().endswith(".torrent") else name
source = _torrent_source_file(c, torrent_hash)
if source:
# Note: Stream the existing .torrent source directly instead of copying it to a temporary staged file first.
return {"path": source, "download_name": filename, "local": False}
raw = _torrent_raw_from_method(c, torrent_hash) raw = _torrent_raw_from_method(c, torrent_hash)
if raw: if raw:
target = LocalPath(download_tmp_dir()) / f"pytorrent-download-{uuid.uuid4().hex}.torrent" target = LocalPath(download_tmp_dir()) / f"pytorrent-download-{uuid.uuid4().hex}.torrent"
target.write_bytes(raw) target.write_bytes(raw)
return {"path": str(target), "download_name": filename, "local": True} return {"path": str(target), "download_name": filename, "local": True}
source = _torrent_source_file(c, torrent_hash) raise RuntimeError("Cannot find torrent source file in rTorrent")
if not source:
raise RuntimeError("Cannot find torrent source file in rTorrent")
staged = _remote_stage_path(c, source, ".torrent")
return {"path": staged, "download_name": filename, "local": False}
def set_file_priorities(profile: dict, torrent_hash: str, files: list[dict]) -> dict: def set_file_priorities(profile: dict, torrent_hash: str, files: list[dict]) -> dict:

View File

@@ -149,7 +149,8 @@ def _cleanup_post_check_label_if_ready(c: ScgiRtorrentClient, row: dict) -> bool
if POST_CHECK_DOWNLOAD_LABEL not in labels: if POST_CHECK_DOWNLOAD_LABEL not in labels:
return False return False
status = str(row.get("status") or "").lower() status = str(row.get("status") or "").lower()
started_after_wait = bool(int(row.get("state") or 0)) and status != "checking" # Note: rTorrent may report state=1 after a recheck even when the download is not really active yet.
started_after_wait = bool(int(row.get("state") or 0)) and bool(int(row.get("active") or 0)) and status != "checking"
if not (_row_progress_complete(row) or status == "seeding" or started_after_wait): if not (_row_progress_complete(row) or status == "seeding" or started_after_wait):
return False return False
# Note: Keep the post-check label while the torrent is stopped; remove it once it is started for download/seeding. # Note: Keep the post-check label while the torrent is stopped; remove it once it is started for download/seeding.
@@ -197,8 +198,8 @@ def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict
except Exception: except Exception:
pass pass
c.call("d.custom1.set", h, label_value) c.call("d.custom1.set", h, label_value)
row.update({"state": 0, "active": 0, "paused": False, "status": "Stopped", "label": label_value}) row.update({"state": 0, "active": 0, "paused": False, "post_check": True, "status": "Post-check", "label": label_value})
changes.append({"hash": h, "action": "stop_and_label_after_check", "complete": False, "label": POST_CHECK_DOWNLOAD_LABEL}) changes.append({"hash": h, "action": "mark_post_check_waiting", "complete": False, "label": POST_CHECK_DOWNLOAD_LABEL})
_clear_post_check_watch(profile_id, h) _clear_post_check_watch(profile_id, h)
except Exception as exc: except Exception as exc:
changes.append({"hash": h, "action": "post_check_policy_failed", "error": str(exc)}) changes.append({"hash": h, "action": "post_check_policy_failed", "error": str(exc)})
@@ -216,6 +217,13 @@ TORRENT_OPTIONAL_FIELDS = [
"d.timestamp.finished=", "d.timestamp.finished=",
] ]
LIVE_TORRENT_FIELDS = [
"d.hash=", "d.state=", "d.complete=", "d.size_bytes=", "d.completed_bytes=",
"d.ratio=", "d.up.rate=", "d.down.rate=", "d.up.total=", "d.down.total=",
"d.peers_connected=", "d.peers_complete=", "d.message=", "d.hashing=", "d.is_active=",
"d.custom1=",
]
def human_duration(seconds: int) -> str: def human_duration(seconds: int) -> str:
# Note: Download ETA is derived locally from remaining bytes and current download speed. # Note: Download ETA is derived locally from remaining bytes and current download speed.
@@ -267,8 +275,10 @@ def normalize_row(row: list) -> dict:
complete = int(row[3] or 0) complete = int(row[3] or 0)
# Note: d.hashing is authoritative; stale "hash check complete" messages must not keep the UI in Checking forever. # Note: d.hashing is authoritative; stale "hash check complete" messages must not keep the UI in Checking forever.
is_checking = bool(hashing) or _message_indicates_active_check(msg_l) is_checking = bool(hashing) or _message_indicates_active_check(msg_l)
is_paused = bool(state) and not bool(is_active) and not is_checking post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(str(row[17] or "")) and not is_checking and not bool(is_active)
status = "Checking" if is_checking else "Paused" if is_paused else "Seeding" if complete and state else "Downloading" if state else "Stopped" is_paused = bool(state) and not bool(is_active) and not is_checking and not post_check
# Note: Post-check is an application-level state that separates torrents waiting after a recheck from manually stopped torrents.
status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Seeding" if complete and state else "Downloading" if state else "Stopped"
to_download_bytes = remaining_bytes if not complete else 0 to_download_bytes = remaining_bytes if not complete else 0
# Note: The To download column is only meaningful for incomplete torrents; complete rows expose an empty display value. # Note: The To download column is only meaningful for incomplete torrents; complete rows expose an empty display value.
return { return {
@@ -305,10 +315,70 @@ def normalize_row(row: list) -> dict:
"ratio_group": str(row[18] or ""), "ratio_group": str(row[18] or ""),
"message": msg, "message": msg,
"status": status, "status": status,
"post_check": post_check,
"hashing": hashing, "hashing": hashing,
} }
def normalize_live_row(row: list) -> dict:
"""Normalize the small row used by the fast live stats poller."""
# Note: The live poller intentionally reads only volatile fields so the main list poller can run less often.
size = int(row[3] or 0)
completed = int(row[4] or 0)
complete = int(row[2] or 0)
state = int(row[1] or 0)
down_rate = int(row[7] or 0)
up_rate = int(row[6] or 0)
ratio_raw = int(row[5] or 0)
remaining_bytes = max(0, size - completed)
eta_seconds = int(remaining_bytes / down_rate) if down_rate > 0 and not complete else 0
msg = str(row[12] or "")
hashing = int(row[13] or 0)
is_active = int(row[14] or 0)
labels = str(row[15] or "")
is_checking = bool(hashing) or _message_indicates_active_check(msg.lower())
post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(labels) and not is_checking and not bool(is_active)
is_paused = bool(state) and not bool(is_active) and not is_checking and not post_check
status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Seeding" if complete and state else "Downloading" if state else "Stopped"
progress = 100.0 if size <= 0 and complete else round((completed / size) * 100, 2) if size else 0.0
to_download_bytes = remaining_bytes if not complete else 0
return {
"hash": str(row[0] or ""),
"state": state,
"active": is_active,
"paused": is_paused,
"complete": complete,
"completed_bytes": completed,
"progress": progress,
"ratio": round(ratio_raw / 1000, 3),
"up_rate": up_rate,
"up_rate_h": human_rate(up_rate),
"down_rate": down_rate,
"down_rate_h": human_rate(down_rate),
"eta_seconds": eta_seconds,
"eta_h": human_duration(eta_seconds) if eta_seconds else "-",
"up_total": int(row[8] or 0),
"up_total_h": human_size(row[8] or 0),
"down_total": int(row[9] or 0),
"down_total_h": human_size(row[9] or 0),
"to_download": to_download_bytes,
"to_download_h": human_size(to_download_bytes) if to_download_bytes else "",
"peers": int(row[10] or 0),
"seeds": int(row[11] or 0),
"message": msg,
"status": status,
"post_check": post_check,
"hashing": hashing,
}
def list_torrent_live_stats(profile: dict) -> list[dict]:
"""Return lightweight live torrent stats for the fast poller."""
# Note: This avoids the full torrent row multicall on every speed/status tick.
rows = client_for(profile).d.multicall2("", "main", *LIVE_TORRENT_FIELDS)
return [normalize_live_row(list(row)) for row in rows]
def list_torrents(profile: dict) -> list[dict]: def list_torrents(profile: dict) -> list[dict]:
c = client_for(profile) c = client_for(profile)
try: try:
@@ -545,12 +615,16 @@ def _download_runtime_state(c: ScgiRtorrentClient, h: str) -> dict:
active = _int_rpc(c, 'd.is_active', h) active = _int_rpc(c, 'd.is_active', h)
opened = _int_rpc(c, 'd.is_open', h) opened = _int_rpc(c, 'd.is_open', h)
# Note: In rTorrent, pause does not change d.state. Paused means state=1, open=1, active=0. # Note: In rTorrent, pause does not change d.state. Paused means state=1, open=1, active=0.
label = _str_rpc(c, 'd.custom1', h)
post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(label) and not bool(active)
return { return {
'state': state, 'state': state,
'open': opened, 'open': opened,
'active': active, 'active': active,
'paused': bool(state and opened and not active), 'paused': bool(state and opened and not active and not post_check),
'stopped': not bool(state), 'stopped': not bool(state),
'post_check': post_check,
'label': label,
'message': _str_rpc(c, 'd.message', h), 'message': _str_rpc(c, 'd.message', h),
} }
@@ -590,10 +664,14 @@ def stop_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
return {'hash': h, 'ok': False, 'error': 'missing hash'} return {'hash': h, 'ok': False, 'error': 'missing hash'}
before = _download_runtime_state(c, h) before = _download_runtime_state(c, h)
result = {'hash': h, 'before': before, 'commands': []} result = {'hash': h, 'before': before, 'commands': []}
if before.get('stopped'): if before.get('stopped') and not before.get('post_check'):
result.update({'ok': True, 'skipped': 'already_stopped', 'after': before}) result.update({'ok': True, 'skipped': 'already_stopped', 'after': before})
return result return result
try: try:
# Note: User Stop converts the app-level Post-check state into a regular stopped torrent.
if before.get('post_check'):
clear_post_check_download_label(c, h, before.get('label'))
result['commands'].append('clear_post_check_label')
# Note: Smart Queue now enforces the queue with d.stop only; user-paused torrents stay untouched. # Note: Smart Queue now enforces the queue with d.stop only; user-paused torrents stay untouched.
c.call('d.stop', h) c.call('d.stop', h)
result['commands'].append('d.stop') result['commands'].append('d.stop')
@@ -643,10 +721,13 @@ def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start:
result: dict = {'hash': h, 'before': before, 'commands': []} result: dict = {'hash': h, 'before': before, 'commands': []}
if before.get('active'): if before.get('active'):
if before.get('post_check'):
clear_post_check_download_label(c, h, before.get('label'))
before = _download_runtime_state(c, h)
result.update({'ok': True, 'skipped': 'already_active', 'after': before}) result.update({'ok': True, 'skipped': 'already_active', 'after': before})
return result return result
if before.get('paused') and not prefer_start: if before.get('paused') and not prefer_start and not before.get('post_check'):
# Note: Manual Start keeps the clean pause-to-resume path. Do not classify every # Note: Manual Start keeps the clean pause-to-resume path. Do not classify every
# state=1/active=0 item as paused; after auto-check this can be only a transient # state=1/active=0 item as paused; after auto-check this can be only a transient
# open/inactive rTorrent state and needs d.open + d.start. # open/inactive rTorrent state and needs d.open + d.start.
@@ -654,6 +735,13 @@ def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start:
resumed['mode'] = 'resume_paused' resumed['mode'] = 'resume_paused'
return resumed return resumed
if before.get('post_check'):
try:
# Note: Post-check start first forces a clean stopped state, matching the manual Stop -> Start recovery path.
c.call('d.stop', h)
result['commands'].append('d.stop')
except Exception as exc:
result.setdefault('ignored_errors', []).append(f'd.stop: {exc}')
try: try:
c.call('d.open', h) c.call('d.open', h)
result['commands'].append('d.open') result['commands'].append('d.open')
@@ -670,7 +758,13 @@ def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start:
except Exception as exc2: except Exception as exc2:
result.setdefault('ignored_errors', []).append(f'd.try_start: {exc2}') result.setdefault('ignored_errors', []).append(f'd.try_start: {exc2}')
result['ok'] = False result['ok'] = False
result['after'] = _download_runtime_state(c, h) after = _download_runtime_state(c, h)
if before.get('post_check') and after.get('active'):
# Note: The marker stays in place when start fails so the row remains visible in the Post-check filter.
clear_post_check_download_label(c, h, before.get('label'))
result['commands'].append('clear_post_check_label')
after = _download_runtime_state(c, h)
result['after'] = after
result['ok'] = result.get('ok', True) result['ok'] = result.get('ok', True)
return result return result

View File

@@ -135,9 +135,8 @@ def _int_setting(data: dict[str, Any], current: dict[str, Any], key: str, defaul
return max(minimum, int(default)) return max(minimum, int(default))
def _default_settings(user_id: int, profile_id: int) -> dict[str, Any]: def _default_settings(profile_id: int) -> dict[str, Any]:
return { return {
'user_id': user_id,
'profile_id': profile_id, 'profile_id': profile_id,
'enabled': 0, 'enabled': 0,
'max_active_downloads': 5, 'max_active_downloads': 5,
@@ -162,18 +161,16 @@ def _default_settings(user_id: int, profile_id: int) -> dict[str, Any]:
def get_settings(profile_id: int, user_id: int | None = None) -> dict[str, Any]: def get_settings(profile_id: int, user_id: int | None = None) -> dict[str, Any]:
user_id = user_id or default_user_id()
with connect() as conn: with connect() as conn:
row = conn.execute( row = conn.execute(
'SELECT * FROM smart_queue_settings WHERE user_id=? AND profile_id=?', 'SELECT * FROM smart_queue_settings WHERE profile_id=?',
(user_id, profile_id), (profile_id,),
).fetchone() ).fetchone()
settings = dict(row or _default_settings(user_id, profile_id)) settings = dict(row or _default_settings(profile_id))
return settings return settings
def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = None) -> dict[str, Any]: def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = None) -> dict[str, Any]:
user_id = user_id or default_user_id()
current = get_settings(profile_id, user_id) current = get_settings(profile_id, user_id)
settings = { settings = {
'enabled': 1 if data.get('enabled', current.get('enabled')) else 0, 'enabled': 1 if data.get('enabled', current.get('enabled')) else 0,
@@ -214,9 +211,9 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N
now = utcnow() now = utcnow()
with connect() as conn: with connect() as conn:
conn.execute( conn.execute(
'''INSERT INTO smart_queue_settings(user_id,profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,min_peers,ignore_seed_peer,ignore_speed,manage_stopped,cooldown_minutes,stop_batch_size,start_grace_seconds,protect_active_below_cap,auto_stop_idle,refill_enabled,refill_interval_minutes,updated_at) '''INSERT INTO smart_queue_settings(profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,min_peers,ignore_seed_peer,ignore_speed,manage_stopped,cooldown_minutes,stop_batch_size,start_grace_seconds,protect_active_below_cap,auto_stop_idle,refill_enabled,refill_interval_minutes,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(user_id, profile_id) DO UPDATE SET ON CONFLICT(profile_id) DO UPDATE SET
enabled=excluded.enabled, enabled=excluded.enabled,
max_active_downloads=excluded.max_active_downloads, max_active_downloads=excluded.max_active_downloads,
stalled_seconds=excluded.stalled_seconds, stalled_seconds=excluded.stalled_seconds,
@@ -234,80 +231,74 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N
refill_enabled=excluded.refill_enabled, refill_enabled=excluded.refill_enabled,
refill_interval_minutes=excluded.refill_interval_minutes, refill_interval_minutes=excluded.refill_interval_minutes,
updated_at=excluded.updated_at''', updated_at=excluded.updated_at''',
(user_id, profile_id, settings['enabled'], settings['max_active_downloads'], settings['stalled_seconds'], settings['min_speed_bytes'], settings['min_seeds'], settings['min_peers'], settings['ignore_seed_peer'], settings['ignore_speed'], settings['manage_stopped'], settings['cooldown_minutes'], settings['stop_batch_size'], settings['start_grace_seconds'], settings['protect_active_below_cap'], settings['auto_stop_idle'], settings['refill_enabled'], settings['refill_interval_minutes'], now), (profile_id, settings['enabled'], settings['max_active_downloads'], settings['stalled_seconds'], settings['min_speed_bytes'], settings['min_seeds'], settings['min_peers'], settings['ignore_seed_peer'], settings['ignore_speed'], settings['manage_stopped'], settings['cooldown_minutes'], settings['stop_batch_size'], settings['start_grace_seconds'], settings['protect_active_below_cap'], settings['auto_stop_idle'], settings['refill_enabled'], settings['refill_interval_minutes'], now),
) )
return get_settings(profile_id, user_id) return get_settings(profile_id, user_id)
def list_exclusions(profile_id: int, user_id: int | None = None) -> list[dict[str, Any]]: def list_exclusions(profile_id: int, user_id: int | None = None) -> list[dict[str, Any]]:
user_id = user_id or default_user_id()
with connect() as conn: with connect() as conn:
return conn.execute( return conn.execute(
'SELECT * FROM smart_queue_exclusions WHERE user_id=? AND profile_id=? ORDER BY created_at DESC', 'SELECT * FROM smart_queue_exclusions WHERE profile_id=? ORDER BY created_at DESC',
(user_id, profile_id), (profile_id,),
).fetchall() ).fetchall()
def set_exclusion(profile_id: int, torrent_hash: str, excluded: bool, reason: str = '', user_id: int | None = None) -> None: def set_exclusion(profile_id: int, torrent_hash: str, excluded: bool, reason: str = '', user_id: int | None = None) -> None:
user_id = user_id or default_user_id()
now = utcnow() now = utcnow()
with connect() as conn: with connect() as conn:
if excluded: if excluded:
conn.execute( conn.execute(
'INSERT OR REPLACE INTO smart_queue_exclusions(user_id,profile_id,torrent_hash,reason,created_at) VALUES(?,?,?,?,?)', 'INSERT OR REPLACE INTO smart_queue_exclusions(profile_id,torrent_hash,reason,created_at) VALUES(?,?,?,?)',
(user_id, profile_id, torrent_hash, reason, now), (profile_id, torrent_hash, reason, now),
) )
else: else:
conn.execute( conn.execute(
'DELETE FROM smart_queue_exclusions WHERE user_id=? AND profile_id=? AND torrent_hash=?', 'DELETE FROM smart_queue_exclusions WHERE profile_id=? AND torrent_hash=?',
(user_id, profile_id, torrent_hash), (profile_id, torrent_hash),
) )
def add_history(profile_id: int, event: str, paused: list[str] | None = None, resumed: list[str] | None = None, checked: int = 0, details: dict[str, Any] | None = None, user_id: int | None = None) -> None: def add_history(profile_id: int, event: str, paused: list[str] | None = None, resumed: list[str] | None = None, checked: int = 0, details: dict[str, Any] | None = None, user_id: int | None = None) -> None:
user_id = user_id or default_user_id()
paused = paused or [] paused = paused or []
resumed = resumed or [] resumed = resumed or []
details = details or {} details = details or {}
with connect() as conn: with connect() as conn:
conn.execute( conn.execute(
'INSERT INTO smart_queue_history(user_id,profile_id,event,paused_count,resumed_count,checked_count,details_json,created_at) VALUES(?,?,?,?,?,?,?,?)', 'INSERT INTO smart_queue_history(profile_id,event,paused_count,resumed_count,checked_count,details_json,created_at) VALUES(?,?,?,?,?,?,?)',
(user_id, profile_id, event, len(paused), len(resumed), int(checked or 0), json.dumps({**details, 'paused': paused, 'resumed': resumed}), utcnow()), (profile_id, event, len(paused), len(resumed), int(checked or 0), json.dumps({**details, 'paused': paused, 'resumed': resumed}), utcnow()),
) )
def list_history(profile_id: int, user_id: int | None = None, limit: int = 30) -> list[dict[str, Any]]: def list_history(profile_id: int, user_id: int | None = None, limit: int = 30) -> list[dict[str, Any]]:
user_id = user_id or default_user_id()
with connect() as conn: with connect() as conn:
return conn.execute( return conn.execute(
'SELECT * FROM smart_queue_history WHERE user_id=? AND profile_id=? ORDER BY created_at DESC LIMIT ?', 'SELECT * FROM smart_queue_history WHERE profile_id=? ORDER BY created_at DESC LIMIT ?',
(user_id, profile_id, max(1, min(int(limit or 30), 100))), (profile_id, max(1, min(int(limit or 30), 100))),
).fetchall() ).fetchall()
def clear_history(profile_id: int, user_id: int | None = None) -> int: def clear_history(profile_id: int, user_id: int | None = None) -> int:
"""Delete Smart Queue history rows for the current profile and return the removed count.""" """Delete Smart Queue history rows for the current profile and return the removed count."""
# Note: Manual cleanup only removes audit history; settings, exclusions and pending queue state stay untouched. # Note: Manual cleanup only removes audit history; settings, exclusions and pending queue state stay untouched.
user_id = user_id or default_user_id()
with connect() as conn: with connect() as conn:
row = conn.execute( row = conn.execute(
'SELECT COUNT(*) AS count FROM smart_queue_history WHERE user_id=? AND profile_id=?', 'SELECT COUNT(*) AS count FROM smart_queue_history WHERE profile_id=?',
(user_id, profile_id), (profile_id,),
).fetchone() ).fetchone()
count = int((row or {}).get('count') or 0) count = int((row or {}).get('count') or 0)
conn.execute( conn.execute(
'DELETE FROM smart_queue_history WHERE user_id=? AND profile_id=?', 'DELETE FROM smart_queue_history WHERE profile_id=?',
(user_id, profile_id), (profile_id,),
) )
return count return count
def count_history(profile_id: int, user_id: int | None = None) -> int: def count_history(profile_id: int, user_id: int | None = None) -> int:
user_id = user_id or default_user_id()
with connect() as conn: with connect() as conn:
row = conn.execute( row = conn.execute(
'SELECT COUNT(*) AS count FROM smart_queue_history WHERE user_id=? AND profile_id=?', 'SELECT COUNT(*) AS count FROM smart_queue_history WHERE profile_id=?',
(user_id, profile_id), (profile_id,),
).fetchone() ).fetchone()
return int((row or {}).get('count') or 0) return int((row or {}).get('count') or 0)
@@ -315,11 +306,10 @@ def count_history(profile_id: int, user_id: int | None = None) -> int:
def _latest_history_event(profile_id: int, user_id: int | None = None) -> str: def _latest_history_event(profile_id: int, user_id: int | None = None) -> str:
"""Return the newest Smart Queue history event for duplicate suppression.""" """Return the newest Smart Queue history event for duplicate suppression."""
# Note: Disabled Smart Queue should leave one waiting marker, not a poller-generated log stream. # Note: Disabled Smart Queue should leave one waiting marker, not a poller-generated log stream.
user_id = user_id or default_user_id()
with connect() as conn: with connect() as conn:
row = conn.execute( row = conn.execute(
'SELECT event FROM smart_queue_history WHERE user_id=? AND profile_id=? ORDER BY created_at DESC LIMIT 1', 'SELECT event FROM smart_queue_history WHERE profile_id=? ORDER BY created_at DESC LIMIT 1',
(user_id, profile_id), (profile_id,),
).fetchone() ).fetchone()
return str((row or {}).get('event') or '') return str((row or {}).get('event') or '')
@@ -338,8 +328,8 @@ def _record_disabled_waiting_once(profile_id: int, user_id: int, details: dict[s
return True return True
def _excluded_hashes(profile_id: int, user_id: int) -> set[str]: def _excluded_hashes(profile_id: int, user_id: int | None = None) -> set[str]:
return {r['torrent_hash'] for r in list_exclusions(profile_id, user_id)} return {r['torrent_hash'] for r in list_exclusions(profile_id)}
@@ -891,7 +881,7 @@ def _refill_mode(settings: dict[str, Any]) -> str:
def _mark_refill_run(profile_id: int, user_id: int) -> None: def _mark_refill_run(profile_id: int, user_id: int) -> None:
# Note: Custom refill interval is measured from the last lightweight refill attempt. # Note: Custom refill interval is measured from the last lightweight refill attempt.
with connect() as conn: with connect() as conn:
conn.execute('UPDATE smart_queue_settings SET last_refill_at=?, updated_at=? WHERE user_id=? AND profile_id=?', (utcnow(), utcnow(), user_id, profile_id)) conn.execute('UPDATE smart_queue_settings SET last_refill_at=?, updated_at=? WHERE profile_id=?', (utcnow(), utcnow(), profile_id))
def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_id: int, user_id: int) -> dict[str, Any]: def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_id: int, user_id: int) -> dict[str, Any]:
@@ -987,13 +977,37 @@ def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_i
| {str(t.get('hash') or '') for t in stopped if _has_smart_queue_label(str(t.get('label') or '')) and str(t.get('hash') or '') not in set(started_by_queue)} | {str(t.get('hash') or '') for t in stopped if _has_smart_queue_label(str(t.get('label') or '')) and str(t.get('hash') or '') not in set(started_by_queue)}
) )
restored = _cleanup_auto_labels(c, profile_id, torrents, keep_labels, True) restored = _cleanup_auto_labels(c, profile_id, torrents, keep_labels, True)
# Note: Cooldown refill uses started incomplete torrents as queue slots. This diagnostic
# explains why a refill may legitimately start nothing even when only a few torrents transfer data.
active_transferring = sum(1 for t in downloading if int(t.get('down_rate') or 0) > 0 or int(t.get('up_rate') or 0) > 0)
active_rtorrent = sum(1 for t in downloading if int(t.get('active') or 0))
active_state = sum(1 for t in downloading if int(t.get('state') or 0))
active_after_expected = len(downloading) + len(start_requested)
if available_slots <= 0:
refill_decision = f'Cooldown refill skipped: active slots at limit ({len(downloading)}/{max_active})'
refill_blocked_reason = 'active_slots_at_limit'
elif not candidates:
refill_decision = 'Cooldown refill skipped: no stopped candidates available'
refill_blocked_reason = 'no_candidates'
elif start_requested:
refill_decision = f'Cooldown refill requested {len(start_requested)} start(s)'
refill_blocked_reason = ''
else:
refill_decision = 'Cooldown refill ran but rTorrent did not confirm new starts yet'
refill_blocked_reason = 'start_not_confirmed'
details = { details = {
'decision': refill_decision,
'blocked_reason': refill_blocked_reason,
'enabled': bool(settings.get('enabled')), 'enabled': bool(settings.get('enabled')),
'cooldown_refill': True, 'cooldown_refill': True,
'cooldown_respected': True, 'cooldown_respected': True,
'refill_mode': _refill_mode(settings), 'refill_mode': _refill_mode(settings),
'refill_interval_minutes': int(settings.get('refill_interval_minutes') or 0), 'refill_interval_minutes': int(settings.get('refill_interval_minutes') or 0),
'active_before': len(downloading), 'active_before': len(downloading),
'active_after_expected': active_after_expected,
'active_transferring_count': active_transferring,
'active_rtorrent_count': active_rtorrent,
'active_state_count': active_state,
'available_slots': available_slots, 'available_slots': available_slots,
'candidates': len(candidates), 'candidates': len(candidates),
'start_source_skipped': len(source_skipped), 'start_source_skipped': len(source_skipped),
@@ -1024,6 +1038,10 @@ def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_i
'max_active_downloads': max_active, 'max_active_downloads': max_active,
'available_slots': available_slots, 'available_slots': available_slots,
'candidates': len(candidates), 'candidates': len(candidates),
'active_transferring': active_transferring,
'active_rtorrent': active_rtorrent,
'active_state': active_state,
'blocked_reason': refill_blocked_reason,
'start_source_skipped': len(source_skipped), 'start_source_skipped': len(source_skipped),
'requested': len(start_requested), 'requested': len(start_requested),
'verified': len(active_verified), 'verified': len(active_verified),
@@ -1079,7 +1097,11 @@ def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_i
'start_pending_confirmation': start_pending_confirmation, 'start_pending_confirmation': start_pending_confirmation,
'active_verified': active_verified, 'active_verified': active_verified,
'active_before': len(downloading), 'active_before': len(downloading),
'active_after_expected': len(downloading) + len(started_by_queue), 'active_after_expected': active_after_expected,
'active_transferring_count': active_transferring,
'active_rtorrent_count': active_rtorrent,
'active_state_count': active_state,
'blocked_reason': refill_blocked_reason,
'available_slots': available_slots, 'available_slots': available_slots,
'start_source_skipped': len(source_skipped), 'start_source_skipped': len(source_skipped),
'checked': len(torrents), 'checked': len(torrents),
@@ -1090,13 +1112,13 @@ def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_i
def mark_run(profile_id: int, user_id: int | None = None) -> None: def mark_run(profile_id: int, user_id: int | None = None) -> None:
user_id = user_id or default_user_id() user_id = user_id or default_user_id()
with connect() as conn: with connect() as conn:
conn.execute('UPDATE smart_queue_settings SET last_run_at=?, updated_at=? WHERE user_id=? AND profile_id=?', (utcnow(), utcnow(), user_id, profile_id)) conn.execute('UPDATE smart_queue_settings SET last_run_at=?, updated_at=? WHERE profile_id=?', (utcnow(), utcnow(), profile_id))
def _disable_when_idle(profile_id: int, user_id: int, torrents: list[dict[str, Any]], details: dict[str, Any]) -> dict[str, Any]: def _disable_when_idle(profile_id: int, user_id: int, torrents: list[dict[str, Any]], details: dict[str, Any]) -> dict[str, Any]:
# Note: Auto-stop is intentionally profile-scoped and only flips the Smart Queue enabled flag; saved thresholds remain intact. # Note: Auto-stop is intentionally profile-scoped and only flips the Smart Queue enabled flag; saved thresholds remain intact.
now = utcnow() now = utcnow()
with connect() as conn: with connect() as conn:
conn.execute('UPDATE smart_queue_settings SET enabled=0, last_run_at=?, updated_at=? WHERE user_id=? AND profile_id=?', (now, now, user_id, profile_id)) conn.execute('UPDATE smart_queue_settings SET enabled=0, last_run_at=?, updated_at=? WHERE profile_id=?', (now, now, profile_id))
add_history(profile_id, 'auto_stopped_idle', [], [], len(torrents), details, user_id) add_history(profile_id, 'auto_stopped_idle', [], [], len(torrents), details, user_id)
settings = get_settings(profile_id, user_id) settings = get_settings(profile_id, user_id)
return {'ok': True, 'enabled': False, 'auto_stopped_idle': True, 'paused': [], 'resumed': [], 'stopped': [], 'started': [], 'checked': len(torrents), 'settings': settings, 'message': 'Smart Queue stopped because there is no active or waiting work.'} return {'ok': True, 'enabled': False, 'auto_stopped_idle': True, 'paused': [], 'resumed': [], 'stopped': [], 'started': [], 'checked': len(torrents), 'settings': settings, 'message': 'Smart Queue stopped because there is no active or waiting work.'}

View File

@@ -4,6 +4,8 @@ from threading import RLock
from time import time from time import time
from . import rtorrent, operation_logs from . import rtorrent, operation_logs
_LIVE_KEYS = {"state", "active", "paused", "complete", "completed_bytes", "progress", "ratio", "up_rate", "up_rate_h", "down_rate", "down_rate_h", "eta_seconds", "eta_h", "up_total", "up_total_h", "down_total", "down_total_h", "to_download", "to_download_h", "peers", "seeds", "message", "status", "post_check", "hashing"}
_VOLATILE = {"down_rate", "down_rate_h", "up_rate", "up_rate_h", "progress", "completed_bytes", "peers", "seeds", "ratio", "state", "status", "message", "down_total", "down_total_h", "to_download", "to_download_h", "up_total", "up_total_h"} _VOLATILE = {"down_rate", "down_rate_h", "up_rate", "up_rate_h", "progress", "completed_bytes", "peers", "seeds", "ratio", "state", "status", "message", "down_total", "down_total_h", "to_download", "to_download_h", "up_total", "up_total_h"}
@@ -33,6 +35,42 @@ class TorrentCache:
self._updated_at.pop(profile_id, None) self._updated_at.pop(profile_id, None)
return removed return removed
def refresh_live(self, profile: dict) -> dict:
"""Refresh only volatile live fields without replacing the full cached torrent rows."""
# Note: The fast poller uses this lightweight path so speeds/statuses can update often while the full list poller stays slower.
profile_id = int(profile["id"])
try:
rows = rtorrent.list_torrent_live_stats(profile)
live = {t["hash"]: t for t in rows if t.get("hash")}
with self._lock:
old = dict(self._data.get(profile_id, {}))
if not old:
self._errors[profile_id] = ""
return {"ok": True, "profile_id": profile_id, "updated": [], "missing": [], "unknown": list(live.keys()), "requires_full_refresh": bool(live)}
updated = []
for h, live_row in live.items():
current = old.get(h)
if not current:
continue
patch = {"hash": h}
for key in _LIVE_KEYS:
if key in live_row and current.get(key) != live_row.get(key):
patch[key] = live_row.get(key)
if len(patch) > 1:
current.update({k: v for k, v in patch.items() if k != "hash"})
updated.append(patch)
missing = [h for h in old.keys() if h not in live]
unknown = [h for h in live.keys() if h not in old]
self._data[profile_id] = old
self._errors[profile_id] = ""
self._updated_at[profile_id] = time()
return {"ok": True, "profile_id": profile_id, "updated": updated, "missing": missing, "unknown": unknown, "requires_full_refresh": bool(missing or unknown)}
except Exception as exc:
with self._lock:
self._errors[profile_id] = str(exc)
return {"ok": False, "profile_id": profile_id, "error": str(exc), "updated": [], "missing": [], "unknown": [], "requires_full_refresh": False}
def refresh(self, profile: dict) -> dict: def refresh(self, profile: dict) -> dict:
profile_id = int(profile["id"]) profile_id = int(profile["id"])
try: try:

View File

@@ -19,7 +19,7 @@ _ERROR_PATTERNS = (
"unreachable", "unreachable",
"denied", "denied",
) )
_SUMMARY_TYPES = ("all", "downloading", "seeding", "paused", "checking", "error", "stopped") _SUMMARY_TYPES = ("all", "downloading", "seeding", "paused", "checking", "error", "post_check", "stopped")
_summary_cache: dict[int, dict] = {} _summary_cache: dict[int, dict] = {}
_summary_lock = RLock() _summary_lock = RLock()
@@ -55,9 +55,12 @@ def _matches(row: dict, summary_type: str) -> bool:
return checking return checking
if summary_type == "error": if summary_type == "error":
return _has_error(row) return _has_error(row)
if summary_type == "post_check":
# Note: Post-check is counted separately from Stopped so automation can target it safely.
return str(row.get("status") or "") == "Post-check" or bool(row.get("post_check"))
if summary_type == "stopped": if summary_type == "stopped":
# Note: Stopped count follows the UI filter exactly, so torrents being hash-checked do not inflate an empty Stopped list. # Note: Stopped count follows the UI filter exactly and excludes app-level post-check waiting rows.
return not checking and not bool(row.get("state")) return not checking and not bool(row.get("state")) and str(row.get("status") or "") != "Post-check" and not bool(row.get("post_check"))
return False return False

View File

@@ -6,6 +6,7 @@ import json
import psutil import psutil
from flask_socketio import emit, join_room, leave_room, disconnect from flask_socketio import emit, join_room, leave_room, disconnect
from .preferences import active_profile, get_profile from .preferences import active_profile, get_profile
from ..db import default_user_id
from .torrent_cache import torrent_cache from .torrent_cache import torrent_cache
from .torrent_summary import cached_summary from .torrent_summary import cached_summary
from . import rtorrent, smart_queue, traffic_history, automation_rules, torrent_stats, auth, speed_peaks, poller_control, download_planner from . import rtorrent, smart_queue, traffic_history, automation_rules, torrent_stats, auth, speed_peaks, poller_control, download_planner
@@ -38,13 +39,15 @@ def _emit_profile(socketio, event: str, payload: dict, profile_id: int) -> None:
def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None: def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None:
state = poller_control.state_for(profile_id) state = poller_control.state_for(profile_id)
# Note: Background checks keep the profile owner so bypass/admin profiles do not enqueue jobs as the fallback user.
profile_user_id = int(profile.get("user_id") or default_user_id())
try: try:
try: try:
torrent_stats.queue_refresh(socketio, profile, force=False, room=_profile_room(profile_id) if auth.enabled() else None) torrent_stats.queue_refresh(socketio, profile, force=False, room=_profile_room(profile_id) if auth.enabled() else None)
except Exception as exc: except Exception as exc:
_emit_profile(socketio, "torrent_stats_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id) _emit_profile(socketio, "torrent_stats_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
try: try:
result = smart_queue.check(profile, force=False) result = smart_queue.check(profile, user_id=profile_user_id, force=False)
if result.get("enabled"): if result.get("enabled"):
_emit_profile(socketio, "smart_queue_update", result, profile_id) _emit_profile(socketio, "smart_queue_update", result, profile_id)
if result.get("stopped") or result.get("started") or result.get("start_requested") or result.get("paused") or result.get("resumed"): if result.get("stopped") or result.get("started") or result.get("start_requested") or result.get("paused") or result.get("resumed"):
@@ -55,13 +58,13 @@ def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None:
except Exception as exc: except Exception as exc:
_emit_profile(socketio, "smart_queue_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id) _emit_profile(socketio, "smart_queue_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
try: try:
auto_result = automation_rules.check(profile, force=False) auto_result = automation_rules.check(profile, user_id=profile_user_id, force=False)
if auto_result.get("applied"): if auto_result.get("applied"):
_emit_profile(socketio, "automation_update", auto_result, profile_id) _emit_profile(socketio, "automation_update", auto_result, profile_id)
except Exception as exc: except Exception as exc:
_emit_profile(socketio, "automation_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id) _emit_profile(socketio, "automation_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
try: try:
plan_result = download_planner.enforce(profile, force=False) plan_result = download_planner.enforce(profile, force=False, user_id=profile_user_id)
if plan_result.get("enabled") and not plan_result.get("skipped"): if plan_result.get("enabled") and not plan_result.get("skipped"):
_emit_profile(socketio, "download_plan_update", plan_result, profile_id) _emit_profile(socketio, "download_plan_update", plan_result, profile_id)
except Exception as exc: except Exception as exc:
@@ -103,7 +106,7 @@ def register_socketio_handlers(socketio):
def poller(): def poller():
while True: while True:
loop_started = time.monotonic() loop_started = time.monotonic()
next_sleep = poller_control.MIN_POLL_INTERVAL_SECONDS next_sleep = 10.0
for profile in _poller_profiles(): for profile in _poller_profiles():
if not profile: if not profile:
continue continue
@@ -111,47 +114,96 @@ def register_socketio_handlers(socketio):
settings = poller_control.get_settings(pid) settings = poller_control.get_settings(pid)
state = poller_control.state_for(pid) state = poller_control.state_for(pid)
now = time.monotonic() now = time.monotonic()
next_sleep = min(next_sleep, poller_control.effective_fast_interval(settings, state)) live_interval = poller_control.effective_live_interval(settings, state)
if not poller_control.should_fast_poll(now, settings, state): list_interval = poller_control.effective_list_interval(settings, state)
next_sleep = min(
next_sleep,
max(poller_control.MIN_POLL_INTERVAL_SECONDS, live_interval - (now - state.last_live_at)),
max(poller_control.MIN_POLL_INTERVAL_SECONDS, list_interval - (now - state.last_list_at)),
max(poller_control.MIN_POLL_INTERVAL_SECONDS, float(settings["system_stats_interval_seconds"]) - (now - state.last_system_at)),
max(poller_control.MIN_POLL_INTERVAL_SECONDS, float(settings["slow_stats_interval_seconds"]) - (now - state.last_slow_at)),
max(poller_control.MIN_POLL_INTERVAL_SECONDS, float(settings["queue_stats_interval_seconds"]) - (now - state.last_queue_at)),
)
run_live = poller_control.should_live_poll(now, settings, state)
run_list = poller_control.should_list_poll(now, settings, state)
run_system = poller_control.should_system_poll(now, settings, state)
run_slow = poller_control.should_slow_poll(now, settings, state)
run_queue = poller_control.should_queue_poll(now, settings, state)
if not (run_live or run_list or run_system or run_slow or run_queue):
continue continue
tick_started = time.monotonic() tick_started = time.monotonic()
changed = False changed = False
ok = True ok = True
error = "" error = ""
active = False active = state.last_active
emitted_payload_size = 0 emitted_payload_size = 0
rtorrent_call_count = 0 rtorrent_call_count = 0
skipped_emissions = 0 skipped_emissions = 0
heartbeat = {"ok": True, "profile_id": pid, "tick": state.tick_count + 1, "error": ""} heartbeat = {"ok": True, "profile_id": pid, "tick": state.tick_count + 1, "error": ""}
try: try:
diff = torrent_cache.refresh(profile)
rtorrent_call_count += 1
state.last_fast_at = now
ok = bool(diff.get("ok"))
error = str(diff.get("error") or "")
rows = torrent_cache.snapshot(pid) rows = torrent_cache.snapshot(pid)
active = _is_active_rows(rows) speed_status = _speed_status_from_rows(pid, rows)
speed_status = _speed_status_from_rows(pid, rows) if diff.get("ok") else None
if diff.get("ok") and (diff["added"] or diff["updated"] or diff["removed"]): if run_live:
changed = True live_started = time.monotonic()
payload = {**diff, "summary": cached_summary(pid, rows, force=True), "speed_status": speed_status} live = torrent_cache.refresh_live(profile)
emitted_payload_size += len(json.dumps(payload, default=str)) rtorrent_call_count += 1
_emit_profile(socketio, "torrent_patch", payload, pid) state.last_live_at = now
elif not diff.get("ok"): state.last_fast_at = now
_emit_profile(socketio, "rtorrent_error", diff, pid) ok = bool(live.get("ok"))
else: error = str(live.get("error") or "")
# Note: Speeds and peak records may change even when no torrent rows need repainting. poller_control.mark_live_poll(state, live_started, ok, error, len(live.get("updated") or []), bool(live.get("requires_full_refresh")))
if speed_status: rows = torrent_cache.snapshot(pid)
payload = {"ok": True, "profile_id": pid, "added": [], "updated": [], "removed": [], "speed_status": speed_status} active = _is_active_rows(rows)
speed_status = _speed_status_from_rows(pid, rows) if live.get("ok") else speed_status
if live.get("ok"):
if live.get("updated") or speed_status:
changed = changed or bool(live.get("updated"))
payload = {
"ok": True,
"profile_id": pid,
"updated": live.get("updated") or [],
"speed_status": speed_status,
"requires_full_refresh": bool(live.get("requires_full_refresh")),
}
emitted_payload_size += len(json.dumps(payload, default=str))
_emit_profile(socketio, "torrent_live_patch", payload, pid)
else:
skipped_emissions += 1
if live.get("requires_full_refresh"):
# Note: Missing or unknown hashes mean the next slow list tick must reconcile rows.
state.last_list_at = 0.0
run_list = True
else:
_emit_profile(socketio, "rtorrent_error", live, pid)
if run_list:
list_started = time.monotonic()
diff = torrent_cache.refresh(profile)
rtorrent_call_count += 1
state.last_list_at = now
ok = bool(diff.get("ok"))
error = str(diff.get("error") or "")
poller_control.mark_list_poll(state, list_started, ok, error, len(diff.get("added") or []), len(diff.get("updated") or []), len(diff.get("removed") or []))
rows = torrent_cache.snapshot(pid)
active = _is_active_rows(rows)
speed_status = _speed_status_from_rows(pid, rows) if diff.get("ok") else speed_status
if diff.get("ok") and (diff["added"] or diff["updated"] or diff["removed"]):
changed = True
payload = {**diff, "summary": cached_summary(pid, rows, force=True), "speed_status": speed_status}
emitted_payload_size += len(json.dumps(payload, default=str)) emitted_payload_size += len(json.dumps(payload, default=str))
_emit_profile(socketio, "torrent_patch", payload, pid) _emit_profile(socketio, "torrent_patch", payload, pid)
elif not diff.get("ok"):
_emit_profile(socketio, "rtorrent_error", diff, pid)
else: else:
skipped_emissions += 1 skipped_emissions += 1
if poller_control.should_system_poll(now, settings, state): if run_system:
state.last_system_at = now state.last_system_at = now
rows = torrent_cache.snapshot(pid)
status = rtorrent.system_status(profile, rows) status = rtorrent.system_status(profile, rows)
rtorrent_call_count += 1 rtorrent_call_count += 1
if bool(profile.get("is_remote")): if bool(profile.get("is_remote")):
@@ -182,9 +234,11 @@ def register_socketio_handlers(socketio):
if poller_control.should_tracker_poll(now, settings, state): if poller_control.should_tracker_poll(now, settings, state):
state.last_tracker_at = now state.last_tracker_at = now
if poller_control.should_slow_poll(now, settings, state) or poller_control.should_queue_poll(now, settings, state): if run_slow or run_queue:
state.last_slow_at = now if run_slow:
state.last_queue_at = now state.last_slow_at = now
if run_queue:
state.last_queue_at = now
if state.slow_task_running: if state.slow_task_running:
skipped_emissions += 1 skipped_emissions += 1
else: else:
@@ -217,7 +271,7 @@ def register_socketio_handlers(socketio):
@socketio.on("connect") @socketio.on("connect")
def handle_connect(): def handle_connect():
ensure_poller_started() ensure_poller_started()
if auth.enabled() and not auth.current_user_id(): if auth.enabled() and not auth.ensure_request_user():
disconnect() disconnect()
return False return False
profile = active_profile() profile = active_profile()
@@ -234,7 +288,7 @@ def register_socketio_handlers(socketio):
@socketio.on("select_profile") @socketio.on("select_profile")
def handle_select_profile(data): def handle_select_profile(data):
if auth.enabled() and not auth.current_user_id(): if auth.enabled() and not auth.ensure_request_user():
disconnect() disconnect()
return return
old_profile = active_profile() old_profile = active_profile()

View File

@@ -9,6 +9,8 @@ from . import rtorrent, auth, disk_guard, operation_logs
from .preferences import get_profile from .preferences import get_profile
from ..config import WORKERS from ..config import WORKERS
from ..db import connect, utcnow, default_user_id from ..db import connect, utcnow, default_user_id
from .torrent_cache import torrent_cache
from .torrent_summary import cached_summary
LIGHT_ACTIONS = {"start", "stop", "pause", "resume", "unpause", "set_label", "set_ratio_group", "reannounce", "set_limits"} LIGHT_ACTIONS = {"start", "stop", "pause", "resume", "unpause", "set_label", "set_ratio_group", "reannounce", "set_limits"}
WATCHDOG_INTERVAL_SECONDS = 30 WATCHDOG_INTERVAL_SECONDS = 30
@@ -216,10 +218,11 @@ def _job_event_meta(payload: dict) -> dict:
return meta return meta
def _execute(profile: dict, action_name: str, payload: dict): def _execute(profile: dict, action_name: str, payload: dict, user_id: int | None = None):
if action_name == "smart_queue_check": if action_name == "smart_queue_check":
from . import smart_queue from . import smart_queue
return smart_queue.check(profile, user_id=auth.current_user_id() or default_user_id(), force=True) # Note: Worker execution uses the job owner instead of Flask session state.
return smart_queue.check(profile, user_id=user_id or default_user_id(), force=True)
if action_name == "add_magnet": if action_name == "add_magnet":
if bool(payload.get("start", True)): if bool(payload.get("start", True)):
disk_guard.assert_can_start_download(profile) disk_guard.assert_can_start_download(profile)
@@ -268,11 +271,43 @@ def _mark_running(job_id: str, attempts: int) -> bool:
return int(cur.rowcount or 0) == 1 return int(cur.rowcount or 0) == 1
def _emit_torrent_refresh(profile: dict, action_name: str) -> None:
if action_name not in {"add_magnet", "add_torrent_raw", "remove", "move", "start", "stop", "pause", "resume", "unpause", "set_label", "set_ratio_group", "recheck"}:
return
try:
diff = torrent_cache.refresh(profile)
profile_id = int(profile["id"])
if diff.get("ok"):
rows = torrent_cache.snapshot(profile_id)
_emit("torrent_patch", {**diff, "summary": cached_summary(profile_id, rows, force=True)})
else:
_emit("rtorrent_error", diff)
except Exception as exc:
# Note: A failed live refresh must not change the already completed job result.
_emit("rtorrent_error", {"profile_id": int(profile.get("id") or 0), "error": str(exc)})
def _schedule_delayed_torrent_refresh(profile: dict, action_name: str) -> None:
if action_name not in {"start", "stop", "pause", "resume", "unpause"} or not _socketio:
return
def delayed_refresh():
# Note: rTorrent may expose state changes one poll later than the XML-RPC action result.
sleep_fn = getattr(_socketio, "sleep", time.sleep)
for delay in (0.75, 1.75):
sleep_fn(delay)
_emit_torrent_refresh(profile, action_name)
_socketio.start_background_task(delayed_refresh)
def _run(job_id: str): def _run(job_id: str):
if not _claim_runner(job_id): if not _claim_runner(job_id):
return return
sem = None sem = None
ordered_lock = None ordered_lock = None
job = {}
payload = {}
try: try:
job = _job_row(job_id) job = _job_row(job_id)
if not job or job["status"] == "cancelled": if not job or job["status"] == "cancelled":
@@ -303,7 +338,7 @@ def _run(job_id: str):
operation_logs.record_job_event(profile["id"], job["action"], "started", payload, job_id=job_id, user_id=int(job.get("user_id") or 0)) operation_logs.record_job_event(profile["id"], job["action"], "started", payload, job_id=job_id, user_id=int(job.get("user_id") or 0))
_emit("operation_started", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, **event_meta}) _emit("operation_started", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, **event_meta})
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "running", "attempts": attempts}) _emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "running", "attempts": attempts})
result = _execute(profile, job["action"], payload) result = _execute(profile, job["action"], payload, user_id=int(job.get("user_id") or 0))
fresh = _job_row(job_id) fresh = _job_row(job_id)
# Note: Emergency cancel and watchdog timeout keep late work from overwriting a terminal state. # Note: Emergency cancel and watchdog timeout keep late work from overwriting a terminal state.
if fresh and fresh["status"] != "running": if fresh and fresh["status"] != "running":
@@ -311,6 +346,10 @@ def _run(job_id: str):
_set_job(job_id, "done", result=result, finished=True) _set_job(job_id, "done", result=result, finished=True)
operation_logs.record_job_event(profile["id"], job["action"], "done", payload, result=result or {}, job_id=job_id, user_id=int(job.get("user_id") or 0)) operation_logs.record_job_event(profile["id"], job["action"], "done", payload, result=result or {}, job_id=job_id, user_id=int(job.get("user_id") or 0))
_emit("operation_finished", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, "result": result, **event_meta}) _emit("operation_finished", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, "result": result, **event_meta})
# Note: Completed jobs must publish a fresh torrent snapshot/patch so removed or moved torrents disappear without a page reload.
action_name = str(job["action"] or "")
_emit_torrent_refresh(profile, action_name)
_schedule_delayed_torrent_refresh(profile, action_name)
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "done", "result": result}) _emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "done", "result": result})
except Exception as exc: except Exception as exc:
fresh = _job_row(job_id) or {} fresh = _job_row(job_id) or {}

View File

@@ -0,0 +1,566 @@
{
"meta": {
"locale": "cs_CZ",
"label": "Čeština",
"flag": "cz"
},
"translations": {
"0_MEANS_ONLY_SEED_THRESHOLD_IS_REQUIRED": "0 means pouze seed threshold je required.",
"0_MEANS_UNLIMITED_SLIDERS_USE_MBIT_S_AND_SAVE_THROUGH_THE_EXISTING_SPEED_LIMITS_": "0 means unlimited. Sliders použít Mbit/s a Uložit through existing Rychlost Limity API.",
"0_SELECTED": "0 vybráno",
"ABOUT_PYTORRENT": "About pyTorrent",
"ACTION": "Akce",
"ACTIVE": "Aktivní",
"ACTIVE_RTORRENT_DOWNLOADS_MAX_GLOBAL_DOWNLOADS": "Aktivní rTorrent Stahování / max global Stahování",
"ACTIVE_RTORRENT_UPLOADS_MAX_GLOBAL_UPLOADS": "Aktivní rTorrent Odesílání / max global Odesílání",
"ADD": "Přidat",
"ADDED": "Added",
"ADDS_REMOVALS_COMPLETIONS_AND_QUEUED_OPERATION_RESULTS": "Adds, removals, completions a queued operation results.",
"ADD_ACTION": "Přidat Akce",
"ADD_CONDITION": "Přidat Podmínka",
"ADD_CREATE_TORRENT": "Přidat / Vytvořit Torrent",
"ADD_FIRST_LABEL_ABOVE": "Přidat První Štítek above.",
"ADD_LABEL": "Přidat Štítek",
"ADD_MAGNET_LINK": "Přidat magnet link",
"ADD_NEW_LABEL": "Přidat Nový Štítek",
"ADD_OR_EDIT_GROUP": "Přidat nebo Upravit Skupina",
"ADD_PATH": "Přidat Cesta",
"ADD_PROFILE": "Přidat Profil",
"ADD_RTORRENT_PROFILE": "Přidat rTorrent Profil",
"ADD_THE_FIRST_RTORRENT_PROFILE_TO_START_LOADING_TORRENTS": "Přidat První rTorrent Profil do Spustit Načítání Torrenty.",
"ADD_TORRENT": "Přidat Torrent",
"ADD_TORRENT_FILE": "Přidat Torrent Soubor",
"ADMIN": "Správce",
"ADMIN_ONLY_FULL_APPLICATION_BACKUP_RESTORE_CAN_REPLACE_USERS_PERMISSIONS_PROFILE": "Správce-pouze full Aplikace záloha. Obnovit může replace Uživatelé, permissions, Profily a global Aplikace Nastavení.",
"AGGREGATE_ALL_PATHS": "Aggregate Vše cesty",
"ALL": "Vše",
"ALL_NON_JOB_TYPES": "Vše non-Úloha types",
"ALL_PROFILES": "Vše Profily",
"ALL_TYPES": "Vše types",
"ANNOUNCE": "Announce",
"API_DOCS": "API docs",
"APPEARANCE": "Vzhled",
"APPEARANCE_PREFERENCES_SAVED": "Vzhled Předvolby Uloženo",
"APPEARS_NEAR_CLICKED_BUTTONS_ONLY_SOMETIMES": "Appears near clicked buttons pouze sometimes.",
"APPLICATION_BACKUP": "Aplikace záloha",
"APPLICATION_BACKUP_NAME": "Aplikace záloha Název",
"APPLY": "Apply",
"APPLY_RETENTION_NOW": "Apply Uchování now",
"APPLY_SAVED_CHANGES_60S_AFTER_PYTORRENT_START": "Apply Uloženo changes 60s po pyTorrent Spustit",
"APP_STATUS": "App Stav",
"AUTHENTICATION_IS_ENABLED_FOR_THIS_PYTORRENT_INSTANCE": "Authentication je Zapnuto pro toto pyTorrent instance.",
"AUTHOR": "Autor",
"AUTOMATIC": "Automaticky",
"AUTOMATIC_KEEPS_THE_CURRENT_POLLER_CADENCE_CUSTOM_RUNS_ONLY_AFTER_THE_SELECTED_N": "Automaticky keeps Aktuální poller cadence. Vlastní runs pouze po vybráno number minut. Off disables refill completely.",
"AUTOMATIC_QUEUE_BALANCING_FOR_SLOW_OR_STALLED_DOWNLOADS": "Automaticky Fronta vyvažování pro pomalé nebo zaseknuté Stahování.",
"AUTOMATIC_RUNS_USE_THE_COOLDOWN_BELOW_MANUAL_CHECK_NOW_STILL_RUNS_IMMEDIATELY": "Automaticky runs použít Prodleva below. Ručně Zkontrolovat now still runs immediately.",
"AUTOMATIONS": "Automations",
"AUTOMATIONS_RULES": "Automations / Pravidla",
"AUTOMATION_TOASTS": "Automation toasts",
"AUTO_STOP_WHEN_IDLE": "Auto-Zastavit when idle",
"BACKEND": "Backend",
"BACKUP": "Záloha",
"BACKUP_RESTORE": "Záloha / Obnovit",
"BACK_TO_DASHBOARD": "Back do dashboard",
"BOOTSTRAP_THEME": "Bootstrap Motiv",
"BROWSER": "Prohlížeč",
"BROWSER_TITLE": "Prohlížeč Název",
"BUILD_A_RULE_AS_CONDITIONS_FIRST_THEN_ORDERED_ACTIONS_MATCHING_TORRENTS_ARE_HAND": "Build Pravidlo jako: Podmínky První, poté ordered Akce. Matching Torrenty jsou handled jako jedna batch a Prodleva je použito do whole Pravidlo.",
"BUSY": "busy",
"BY_DAYS": "Podle dny",
"BY_LINE_COUNT": "Podle line count",
"CACHED_METADATA_SUMMARY_FILE_METADATA_IS_REFRESHED_EVERY_15_MINUTES_A_FEW_MINUTE": "Cached metadata summary. Soubor metadata je refreshed every 15 minut, několik minut po start, nebo manually.",
"CANCEL": "Zrušit",
"CANCEL_EDIT": "Zrušit Upravit",
"CATEGORY": "Kategorie",
"CHANGES_APPLY_IMMEDIATELY_WHERE_POSSIBLE_INITIAL_STARTUP_LOADER_USES_THEM_AFTER_": "Changes apply immediately where possible; initial start loader uses je po reload.",
"CHANGE_RTORRENT": "Změnit rTorrent",
"CHANGE_THEME": "Změnit Motiv",
"CHANGING_RTORRENT_RELOADS_THE_LIVE_TORRENT_SNAPSHOT": "Změna rTorrent reloads live Torrent snímek.",
"CHECKING": "Kontrola",
"CHECK_NOW": "Zkontrolovat nyní",
"CHECK_PORT_NOW": "Zkontrolovat port nyní",
"CHECK_THIS_IF_YOU_WANT_TO_CONNECT_TO_A_REMOTE_RTORRENT_INSTANCE_INSTEAD_OF_LOCAL": "Zkontrolovat toto pokud chcete chcete do připojit do Vzdálené rTorrent instance místo localhost.",
"CHOOSE_COLUMNS_VISIBLE_IN_THE_TORRENT_LIST": "Vybrat Sloupce viditelné v Torrent seznam.",
"CHOOSE_FILES": "Vybrat Soubory",
"CHOOSE_RTORRENT": "Vybrat rTorrent",
"CHOOSE_TORRENTS_IGNORED_BY_SMART_QUEUE_EXISTING_BEHAVIOR_STAYS_UNCHANGED_FOR_ALL": "Vybrat Torrenty ignored podle Smart Queue. Existing behavior stays unchanged pro Vše non-excluded Torrenty.",
"CHOOSE_WHAT_THE_FOOTER_DISK_BAR_SHOULD_REPRESENT_AND_ADD_EXTRA_STORAGE_PATHS": "Vybrat what Patička disk lišta má represent a Přidat extra storage cesty.",
"CHOOSE_WHICH_STATUS_ITEMS_ARE_VISIBLE_IN_THE_BOTTOM_BAR": "Vybrat která Stav items jsou viditelné v bottom lišta.",
"CHUNKS": "Chunks",
"CLEANUP": "Čištění",
"CLEANUP_RETENTION": "Čištění / Uchování",
"CLEAR": "Vymazat",
"CLEAR_CURRENT_FILTER": "Vymazat Aktuální filtr",
"CLEAR_FINISHED": "Vymazat finished",
"CLEAR_LABELS": "Vymazat Štítky",
"CLEAR_SELECTION": "Vymazat selection",
"CLEAR_VISIBLE": "Vymazat viditelné",
"CLOSE": "Zavřít",
"COLUMNS": "Sloupce",
"COLUMNS_SAVED": "Sloupce Uloženo",
"COMMENT": "Comment",
"COMPACT_TORRENT_LIST": "Compact Torrent seznam",
"COMPLETE": "Complete",
"COMPLETED": "Dokončeno",
"CONFIGURED_RTORRENTS": "Configured rTorrents",
"CONNECTING_TO_RTORRENT_AND_PREPARING_DATA": "Connecting do rTorrent a preparing Data.",
"CONNECTION_ADDRESS_IN_SCGI_HOST_PORT_RPC2_FORMAT": "Připojení address v scgi://host:Port/RPC2 format.",
"CONTROLS_THE_DEFAULT_CATEGORY_AND_JOB_LOG_VISIBILITY_USED_BY_THE_LOGS_MODAL": "Controls Výchozí Kategorie a Úloha Log visibility used podle Logy modal.",
"CONTROLS_WHAT_IS_SHOWN_IN_THE_BROWSER_TAB": "Controls what je shown v Prohlížeč karta.",
"COOLDOWN_MINUTES": "Prodleva minut",
"COPY": "Kopírovat",
"COPY_HASH": "Kopírovat Hash",
"COPY_NAME": "Kopírovat Název",
"COPY_PATH": "Kopírovat Cesta",
"CPU_RAM_USAGE": "CPU / RAM usage",
"CREATE": "Vytvořit",
"CREATES_AND_RESTORES_SETTINGS_FOR_THE_CURRENTLY_SELECTED_PROFILE_USER_SCOPED_PRE": "Creates a restores Nastavení pro aktuálně vybráno Profil. Uživatel-scoped Předvolby jsou remapped do Aktuální Uživatel where needed.",
"CREATE_APPLICATION_BACKUP": "Vytvořit Aplikace záloha",
"CREATE_ONE_RTORRENT_PROFILE_AT_A_TIME_MOVE_REMOVE_QUEUES_KEEP_THEIR_ORDER_FOR_EA": "Vytvořit jedna rTorrent Profil at čas. Přesunout/Odebrat queues keep jejich order pro každý Profil.",
"CREATE_PROFILE_BACKUP": "Vytvořit Profil záloha",
"CREATE_REUSABLE_LABELS_AND_REMOVE_LABELS_THAT_ARE_NO_LONGER_NEEDED": "Vytvořit opakovaně použitelné Štítky a Odebrat Štítky že jsou Žádné déle needed.",
"CREATE_TORRENT": "Vytvořit Torrent",
"CTRL_A_SELECT_VISIBLE": "Ctrl+ - Vybrat viditelné",
"CTRL_I_INVERT_VISIBLE": "Ctrl+I - invert viditelné",
"CTRL_O_ADD": "Ctrl+O - Přidat",
"CTRL_S_DOWNLOAD_TORRENT": "Ctrl+S - Stahování .Torrent",
"CURRENT_TRANSFER_SPEED": "Aktuální transfer Rychlost",
"CUSTOM_DOWNLOAD": "Vlastní Stahování",
"CUSTOM_UPLOAD": "Vlastní Odesílání",
"DAYS_AND_LINE_COUNT": "Dny a line count",
"DECREASE_OR_INCREASE_THE_WHOLE_INTERFACE_SIZE": "Decrease nebo increase whole interface Velikost.",
"DEFAULT_LOG_CATEGORY": "Výchozí Log Kategorie",
"DEFAULT_LOG_VIEW": "Výchozí Log view",
"DEFAULT_RTORRENT_PATH": "Výchozí rTorrent Cesta",
"DEGRADED": "degraded",
"DELETE": "Smazat",
"DELETE_REMOVE": "Smazat - Odebrat",
"DESKTOP": "Desktop",
"DETAILS_WILL_APPEAR_AFTER_THE_FIRST_SUCCESSFUL_RESPONSE": "Detaily bude appear po První úspěšná odpověď.",
"DIAGNOSTICS": "Diagnostika",
"DISABLED": "Vypnuto",
"DISK_MONITOR": "Disk monitor",
"DISK_USAGE_UNAVAILABLE": "Disk usage unavailable",
"DISPLAYS_CURRENT_SPEEDS_NEXT_TO_PYTORRENT_IN_THE_TAB_TITLE": "Displays Aktuální speeds Další do pyTorrent v karta Název.",
"DOCS_API": "Docs API",
"DOWNLOADED": "Staženo",
"DOWNLOADING": "Stahování",
"DOWNLOADS": "Stahování",
"DOWNLOAD_KIB_S": "Stahování KiB/s",
"DOWNLOAD_STARTED": "Stahování spuštěné",
"DOWNLOAD_TORRENT": "Stahování .Torrent",
"DOWNLOAD_TRACKER_FAVICONS": "Stahování Tracker favicons",
"DRAG_TO_RESIZE_DETAILS_PANEL": "Drag do resize Detaily Panel",
"EASTER_EGG": "Easter egg",
"EDIT": "Upravit",
"EMERGENCY_CANCEL": "Emergency Zrušit",
"EMERGENCY_CLEAN_ALL": "Emergency clean Vše",
"ENABLED": "Zapnuto",
"ENABLE_AUTOMATIC_APPLICATION_BACKUPS": "Zapnout Automaticky Aplikace zálohy",
"ENABLE_AUTOMATIC_PROFILE_BACKUPS": "Zapnout Automaticky Profil zálohy",
"ENABLE_EASTER_EGG": "Zapnout easter egg",
"ENABLE_INCOMING_PORT_CHECK": "Zapnout incoming Port Zkontrolovat",
"ENABLE_REVERSE_DNS_FOR_PEERS": "Zapnout reverse DNS pro Peers",
"ENGLISH": "Angličtina",
"EPISODE": "Epizoda",
"ESC_CLEAR_SELECTION": "Esc - Vymazat selection",
"ETA": "ETA",
"EVERY_N_MINUTES": "Every N minut",
"EVERY_X_HOURS": "Every X hours",
"EXCLUDE": "Vyloučit",
"EXCLUDE_FROM_SMART_QUEUE": "Vyloučit z Smart Queue",
"EXCLUDE_PATTERN": "Vyloučit Vzor",
"EXISTING_GROUPS": "Existing Skupiny",
"EXPORT": "Export",
"EXPORT_JSON": "Export JSON",
"EXTERNAL_AUTHENTICATION_IS_ENABLED_THROUGH_EXTERNAL_PROVIDER": "External authentication je Zapnuto through {{ external_provider }}.",
"E_G_PUNKTY_KATALOGOWANIE": "e.g. Punkty katalogowanie",
"FEATURES": "Funkce",
"FEED": "Feed",
"FEEDS_ARE_CHECKED_BY_SCHEDULE_AND_EVERY_MATCH_IS_LOGGED_PER_FEED_RULE": "Kanály jsou kontrolovány podle plán a every shoda je zapsána per feed/Pravidlo.",
"FEEDS_RULES_AND_MATCHES": "Kanály, Pravidla a shody",
"FEED_NAME": "Feed Název",
"FEED_URL": "Feed URL",
"FILES": "Soubory",
"FILE_OR_DIRECTORY_PATH": "Soubor nebo Adresář Cesta",
"FONT": "Písmo",
"FOOTER": "Patička",
"FORCE": "Vynutit",
"FORCE_RECHECK": "Vynutit Znovu zkontrolovat",
"FRONTEND": "Frontend",
"FULL": "Full",
"GENERAL": "Obecné",
"GENERATED_RTORRENT_CONFIG_CHANGES_WILL_APPEAR_HERE": "Vygenerováno rTorrent Konfigurace changes bude appear here.",
"GENERATE_CONFIG": "Generate Konfigurace",
"GERMAN": "Němčina",
"GO": "Go",
"GROUPED_RTORRENT_RUNTIME_SETTINGS_WITH_INLINE_RECOMMENDATIONS_AND_COMPATIBILITY_": "Seskupená rTorrent Runtime Nastavení s inline doporučení a kompatibilita Stav.",
"GROUP_NAME": "Skupina Název",
"HASH": "Hash",
"HASHING": "Hashing",
"HEAVY_PARALLEL_JOBS": "Heavy parallel Úlohy",
"HEAVY_TIMEOUT_SECONDS": "Heavy Timeout sekund",
"HIDE_JOB_LOGS": "Skrýt Úloha Logy",
"HIDE_JOB_LOGS_BY_DEFAULT": "Skrýt Úloha Logy podle Výchozí",
"HISTORY": "Historie",
"HOW_LONG_A_MATCHING_ACTIVE_TORRENT_MUST_STAY_STALLED_BEFORE_IT_CAN_BE_REPLACED": "Jak dlouho matching Aktivní Torrent musí stay zaseknuté před it může být nahrazen.",
"IGNORE_MISSING_SEEDS_PEERS_FOR_STALLED_TIMER": "Ignorovat missing Seeds/Peers pro zaseknuté timer",
"IGNORE_SPEED_FOR_STALLED_TIMER": "Ignorovat Rychlost pro zaseknuté timer",
"IMPORT": "Import",
"IMPORT_JSON": "Import JSON",
"INCLUDE_PATTERN": "Zahrnout Vzor",
"INCOMING_CONNECTION_TEST_SEPARATE_FROM_VISUAL_PREFERENCES": "Incoming Připojení Test, separate z visual Předvolby.",
"INTERFACE_SCALE": "Interface scale",
"INTERVAL_MINUTES": "Interval minut",
"INVERT_VISIBLE": "invert viditelné",
"JOBS": "Úlohy",
"JOB_DONE": "Úloha hotovo",
"JOB_FAILED": "Úloha selhalo",
"JOB_QUEUE": "Úloha Fronta",
"JOB_SCHEDULING": "Úloha scheduling",
"JOB_STARTED": "Úloha spuštěné",
"KEEP_LINES": "Keep lines",
"KEEP_SEEDING": "Keep Seedování",
"LABEL": "Štítek",
"LABELS": "Štítky",
"LABELS_SEPARATED_BY_COMMA": "Štítky separated podle comma",
"LABEL_AFTER_SHARE": "Štítek po share",
"LABEL_EXISTS": "Štítek exists",
"LABEL_IS_MISSING": "Štítek je missing",
"LABEL_NAME_OR_SEVERAL_SEPARATED_BY_COMMA": "Štítek Název, nebo několik separated podle comma",
"LANGUAGE": "Jazyk",
"LANGUAGE_SAVED": "Jazyk Uloženo",
"LAST_OPERATIONS": "Poslední operations",
"LICENSE": "Licence",
"LIGHTWEIGHT_WEB_PANEL_FOR_RTORRENT_MANAGEMENT_QUEUE_CONTROL_AND_LIVE_TORRENT_DIA": "Lightweight web Panel pro rTorrent management, Fronta control a live Torrent Diagnostika.",
"LIGHT_PARALLEL_JOBS": "Light parallel Úlohy",
"LIGHT_TIMEOUT_SECONDS": "Light Timeout sekund",
"LIMIT_DL": "Limit DL",
"LOADING": "Načítání",
"LOADING_CLEANUP_DATA": "Načítání Čištění Data...",
"LOADING_CONFIG": "Načítání Konfigurace...",
"LOADING_IMAGE_URL": "Načítání image URL",
"LOADING_JOBS": "Načítání Úlohy...",
"LOADING_LOGS": "Načítání Logy...",
"LOADING_PROFILES": "Načítání Profily...",
"LOADING_STATISTICS": "Načítání Statistiky...",
"LOADING_TORRENTS": "Načítání Torrenty...",
"LOADING_TORRENT_DETAILS": "Načítání Torrent Detaily...",
"LOCAL_BROWSER_TIME": "Lokální Prohlížeč čas",
"LOCATION": "Umístění",
"LOG": "Log",
"LOGS": "Logy",
"LOG_IN": "Přihlásit se",
"LOG_OUT": "Odhlásit se",
"LOG_STATISTICS": "Log Statistiky",
"MAGNET_AND_TORRENT_UPLOAD_FILE_PRIORITIES_LABELS_RATIO_GROUPS_SMART_QUEUE_AUTOMA": "Magnet a Torrent Odesílání, Soubor priority, Štítky, Ratio Skupiny, Smart Queue, automation Pravidla, RSS, provoz grafy, Port kontrola, system Stav.",
"MAGNET_LINKS": "Magnet links",
"MANAGE_EXCEPTIONS": "Manage exceptions",
"MANAGE_OPERATION_LOG_RETENTION_WITHOUT_CHANGING_TORRENT_DATA": "Manage operation Log Uchování bez Změna Torrent Data.",
"MANAGE_OPTIONAL_PYTORRENT_USERS_EMPTY_PROFILE_MEANS_ALL_PROFILES_R_O_BLOCKS_RTOR": "Manage volitelné pyTorrent Uživatelé. Empty Profil means Vše Profily. R/O blocks rTorrent-Změna Akce; Full allows je.",
"MANUAL_CLEANUP_ONLY": "Ručně Čištění pouze",
"MAXIMUM_HEAVY_JOBS_RUNNING_AT_ONCE_FOR_THIS_PROFILE_DEFAULT_5": "Maximum heavy Úlohy running at once pro toto Profil. Výchozí: 5.",
"MAXIMUM_QUEUED_ACTIONS_RUNNING_AT_ONCE": "Maximum queued Akce running at once.",
"MAXIMUM_STALLED_OVERFLOW_DOWNLOADS_SMART_QUEUE_MAY_STOP_IN_ONE_PASS": "Maximum zaseknuté/overflow Stahování Smart Queue may Zastavit v jedna pass.",
"MAX_MB": "Max MB",
"MAX_RATIO": "Max Ratio",
"MAX_STOPS_PER_CHECK": "Max stops per Zkontrolovat",
"MESSAGE": "Zpráva",
"MINUTES": "Minut",
"MIN_MB": "Min MB",
"MIN_PEERS": "Min Peers",
"MIN_RATIO": "Min Ratio",
"MIN_SEEDS": "Min Seeds",
"MIN_SEED_MINUTES": "Min seed minut",
"MIN_SPEED_KIB_S": "Min Rychlost KiB/s",
"MOBILE": "Mobilní",
"MOBILE_COLUMNS": "Mobilní Sloupce",
"MOBILE_FILTER_GROUPS": "Mobilní filtr Skupiny",
"MOBILE_SIMPLE_MODE": "Mobilní/simple mode",
"MOBILE_SORT_FILTERS": "Mobilní sort filters",
"MODE": "Mode",
"MONITORED_PATHS": "Monitored cesty",
"MOVE": "Přesunout",
"MOVE_DATA": "Přesunout Data",
"MOVE_DATA_FILES": "Přesunout Data Soubory",
"MOVE_PATH": "Přesunout Cesta",
"MOVE_TO_PATH": "Přesunout do Cesta",
"MOVIES": "filmy",
"MOVING": "Moving",
"M_MOVE": "M - Přesunout",
"NAME": "Název",
"NEGATE": "Negate",
"NEWLY_QUEUE_STARTED_TORRENTS_ARE_PROTECTED_FROM_STALLED_CLEANUP_DURING_THIS_WARM": "Nově Fronta-spuštěné Torrenty jsou chráněné z zaseknuté Čištění během toto zahřívání-nahoru.",
"NEW_LABEL": "Nový Štítek",
"NEXT_READY": "Další: ready",
"NEXT_SMART_QUEUE_RUN": "Další Smart Queue běh",
"NOTIFICATIONS": "Oznámení",
"NOT_LOADED": "Nenačteno.",
"NO_CHANGES": "Žádné změny",
"NO_FILES_RETURNED_BY_RTORRENT": "Žádné Soubory returned podle rTorrent.",
"NO_FILES_SELECTED": "Nejsou vybrány žádné soubory.",
"NO_LABELS": "Žádné Štítky.",
"NO_LABELS_SELECTED": "Nejsou vybrány žádné štítky.",
"NO_PATH_LOADED": "Žádné Cesta loaded.",
"NO_PEERS_RETURNED_BY_RTORRENT": "Žádné Peers returned podle rTorrent.",
"NO_RTORRENT_PROFILE_CONFIGURED": "Žádné rTorrent Profil configured.",
"NO_SAVED_LABELS": "Žádné Uloženo Štítky.",
"NO_TORRENTS": "Žádné Torrenty.",
"NO_TORRENTS_FOR_THIS_FILTER": "Žádné Torrenty pro toto filtr.",
"NO_TORRENTS_SELECTED": "Nejsou vybrány žádné torrenty",
"NO_TORRENT_SELECTED": "Není vybrán žádný torrent",
"NO_TRACKERS_RETURNED_BY_RTORRENT": "Žádné Trackery returned podle rTorrent.",
"OFF": "Off",
"OFFLINE": "offline",
"ONE_PLACE_TO_CLEAR_LOGS_AND_ACTIVE_PROFILE_CACHES_PENDING_RUNNING_JOBS_RULES_SET": "Jedna place do Vymazat Logy a Aktivní Profil caches. Čekající/running Úlohy, Pravidla, Nastavení a Torrenty jsou preserved.",
"ONE_TRACKER_URL_PER_LINE": "Jedna Tracker URL per line",
"ONLINE": "online",
"ONLY_SELECTED": "Pouze vybráno",
"OPEN_DOWNLOAD_PLANNER": "Otevřít Stahování planner",
"OPEN_RTORRENT_FILES_MAX_OPEN_FILES": "Otevřít rTorrent Soubory / max Otevřít Soubory",
"OPEN_RTORRENT_HTTP_CONNECTIONS_MAX_HTTP_CONNECTIONS": "Otevřít rTorrent HTTP connections / max HTTP connections",
"OPEN_RTORRENT_SOCKETS": "Otevřít rTorrent sockets",
"OPEN_SOURCE": "Open source",
"OPEN_THIS_TAB_TO_LOAD_DIAGNOSTICS": "Otevřít toto karta do načíst Diagnostika.",
"OPEN_THIS_TAB_TO_LOAD_STATISTICS": "Otevřít toto karta do načíst Statistiky.",
"OPERATION_LOG_RETENTION": "Operation Log Uchování",
"OPTIONAL": "volitelné",
"OPTIONAL_PEER_TABLE_HELPERS": "Volitelné peer table helpers.",
"OPTIONAL_PRIVATE_SOURCE_TAG": "volitelné Soukromý Zdroj tag",
"OPTIONAL_VISUAL_EASTER_EGG_FOR_LOADING_STATES_AND_OCCASIONAL_BUTTON_CLICKS_DISAB": "Volitelné visual easter egg pro Načítání stavy a občasné tlačítko kliknutí. Vypnuto podle Výchozí.",
"PARALLEL_JOBS": "Parallel Úlohy",
"PARENT_DIRECTORY": "Nadřazený Adresář",
"PARTIAL_DETAILS_LOADED": "Partial Detaily loaded",
"PASSWORD": "Heslo",
"PASSWORD_NEW_PASSWORD": "Heslo / Nový Heslo",
"PASTE_ONE_MAGNET_URI_PER_LINE": "Paste jedna magnet URI per line.",
"PATH": "Cesta",
"PATH_CONTAINS": "Cesta contains",
"PATH_TEXT": "Cesta Text",
"PATH_USED_FOR_SELECTED_MODE": "Cesta used pro vybráno mode",
"PAUSE": "Pozastavit",
"PAUSED": "Pozastaveno",
"PEAK_S": "Peak S",
"PEAK_SPEED_UNAVAILABLE": "Peak Rychlost unavailable",
"PEERS": "Peers",
"PEERS_AUTO_REFRESH": "Peers auto Obnovit",
"PENDING_JOBS_OLDER_THAN_THIS_ARE_RESUBMITTED_IF_NO_WORKER_IS_CURRENTLY_HANDLING_": "Čekající Úlohy starší než toto jsou znovu odeslány pokud Žádné worker je aktuálně zpracovává je. Výchozí: 900 sekund.",
"PENDING_RUNNING_DONE_FAILED_RETRY_AND_CANCEL_HISTORY": "Čekající, running, hotovo, selhalo, Opakovat a Zrušit Historie.",
"PENDING_TIMEOUT_SECONDS": "Čekající Timeout sekund",
"PIECE_SIZE": "Piece Velikost",
"PLANNER": "Planner",
"POLISH": "Polština",
"PORT": "Port",
"PORT_CHECKER": "Port kontrola",
"PORT_CHECK_DISABLED": "Port Zkontrolovat Vypnuto",
"PORT_UNKNOWN": "Port - unknown",
"POST_CHECK": "Post-Zkontrolovat",
"PREFERENCES": "Předvolby",
"PREFERENCES_SAVED": "Předvolby Uloženo",
"PRIORITY": "Priorita",
"PRIVATE_TORRENT": "Soukromý Torrent",
"PROFILE_BACKUP": "Profil záloha",
"PROFILE_BACKUP_NAME": "Profil záloha Název",
"PROFILE_BACKUP_RESTORES_ONLY_THE_ACTIVE_PROFILE_CONTEXT_APPLICATION_BACKUP_RESTO": "Profil záloha restores pouze Aktivní Profil kontext. Aplikace záloha restores global Aplikace Data a je dostupné pouze do správci.",
"PROFILE_NAME": "Profil Název",
"PROFILE_SCOPED_LOG_COUNTS_AND_CLEANUP_OVERVIEW": "Profil-scoped Log počty a Čištění přehled.",
"PROGRESS": "Průběh",
"PROGRESS_IS_AT_LEAST": "Průběh je at least %",
"PROGRESS_IS_AT_MOST": "Průběh je at most %",
"PROGRESS_SOURCE": "Průběh Zdroj",
"PROTECT_ACTIVE_COUNT_BELOW_CAP": "Protect Aktivní count below cap",
"PYTORRENT_EXPECTS_TRUSTED_REVERSE_PROXY_IDENTITY_HEADERS_IF_YOU_ARE_ALREADY_SIGN": "pyTorrent expects trusted reverse-proxy identity headers. Pokud chcete jsou already signed v, Zkontrolovat provider headers a Uživatel mapping.",
"PYTORRENT_LOGIN": "pyTorrent login",
"PYTORRENT_STATUS": "pyTorrent Stav",
"P_PAUSE": "P - Pozastavit",
"QUALITY": "Kvalita",
"QUEUE_REFILL_DURING_COOLDOWN": "Fronta refill během Prodleva",
"RANDOM_CLICK_IMAGE_URL": "Random click image URL",
"RATIO": "Ratio",
"RATIO_GROUP": "Ratio Skupina",
"RATIO_GROUPS": "Ratio Skupiny",
"RATIO_IS_AT_LEAST": "Ratio je at least",
"RATIO_RULES": "Ratio Pravidla",
"REANNOUNCE": "Reannounce",
"RECHECK": "Znovu zkontrolovat",
"RECHECK_AFTER_MOVE": "Znovu zkontrolovat po Přesunout",
"RECOMMENDED_COLUMNS": "Doporučené Sloupce",
"RECOMMENDED_COLUMNS_APPLIED": "Doporučené Sloupce použito",
"RECONNECTING": "reconnecting",
"REFERENCE_VALUE_IS_KEPT_FROM_THE_FIRST_OVERRIDE_SAVE_LATER_SAVES_ADD_OR_CLEAR_DI": "Reference hodnota je kept z První override Uložit. Later saves Přidat nebo Vymazat differences bez replacing original reference.",
"REFRESH": "Obnovit",
"REFRESH_NOW": "Obnovit now",
"REGEX_TEXT": "Regex / Text",
"RELOAD": "Reload",
"REMOTE_LOCATION": "Vzdálené Umístění",
"REMOVE": "Odebrat",
"REMOVE_DATA": "Odebrat Data",
"REMOVE_LABEL": "Odebrat Štítek",
"REMOVE_SELECTED_TORRENTS": "Odebrat vybráno Torrenty",
"REMOVE_WITH_DATA": "Odebrat s Data",
"REPOSITORY": "Repozitář",
"RESET": "Resetovat",
"RESET_UI_SETTINGS": "Resetovat UI Nastavení",
"RESET_VIEW_DEFAULTS": "Resetovat view defaults",
"RESIZE_TORRENT_DETAILS_PANEL": "Resize Torrent Detaily Panel",
"RESOLVE_PEER_IP_TO_REVERSE_DNS_HOST": "Resolve peer IP do reverse DNS host",
"RESUME": "Pokračovat",
"RETENTION_DAYS": "Uchování dny",
"RETENTION_MODE": "Uchování mode",
"RETRY": "Opakovat",
"REVERSE_DNS": "Reverse DNS",
"RSS_DOWNLOADER": "RSS downloader",
"RTORRENT": "rTorrent",
"RTORRENTS": "rTorrents",
"RTORRENT_CONFIG": "rTorrent Konfigurace",
"RTORRENT_INCOMING_PORT": "rTorrent incoming Port",
"RTORRENT_IS_STARTING_OR_NOT_RESPONDING_YET": "rTorrent je starting nebo není responding yet.",
"RTORRENT_PROFILES": "rTorrent Profily",
"RULE": "Pravidlo",
"RULES": "Pravidla",
"RULES_ARE_CHECKED_AUTOMATICALLY_EVERY_5_MINUTES_A_TORRENT_USES_THE_GROUP_STORED_": "Pravidla jsou kontrolovány automatically every 5 minut. Torrent uses Skupina stored v its rTorrent Vlastní Ratio field.",
"RULE_NAME": "Pravidlo Název",
"RUNTIME": "Runtime",
"RUN_A_PROFILE_TEST_TO_SHOW_DIAGNOSTICS": "Běh Profil Test do Zobrazit Diagnostika.",
"RUN_SMART_QUEUE_DURING_POLLING_STOPPED_TORRENTS_ARE_MANAGED_PAUSED_TORRENTS_STAY": "Běh Smart Queue během polling. Zastaveno Torrenty jsou spravované; Pozastaveno Torrenty stay Uživatel-controlled.",
"R_RESUME": "R - Pokračovat",
"SAVE": "Uložit",
"SAVED_LABELS": "Uloženo Štítky",
"SAVE_COLUMNS": "Uložit Sloupce",
"SAVE_CONFIG": "Uložit Konfigurace",
"SAVE_EASTER_EGG": "Uložit easter egg",
"SAVE_EXCEPTIONS": "Uložit exceptions",
"SAVE_FEED": "Uložit feed",
"SAVE_FOOTER": "Uložit Patička",
"SAVE_GROUP": "Uložit Skupina",
"SAVE_JOB_SETTINGS": "Uložit Úloha Nastavení",
"SAVE_LIMITS": "Uložit Limity",
"SAVE_LOCATION": "Uložit Umístění",
"SAVE_LOG_VIEW": "Uložit Log view",
"SAVE_PATH": "Uložit Cesta",
"SAVE_PREFERENCES": "Uložit Předvolby",
"SAVE_RETENTION": "Uložit Uchování",
"SAVE_RULE": "Uložit Pravidlo",
"SAVE_SCHEDULE": "Uložit plán",
"SAVE_USER": "Uložit Uživatel",
"SCGI_URL": "SCGI URL",
"SEARCH_LOGS": "Hledat Logy...",
"SEARCH_TORRENTS": "Hledat Torrenty...",
"SEARCH_TORRENTS_TO_EXCLUDE": "Hledat Torrenty do Vyloučit...",
"SEASON": "Řada",
"SECONDS_TO_WAIT_FOR_RTORRENT_RESPONSE": "Sekund do čekat pro rTorrent odpověď.",
"SEEDING": "Seedování",
"SEEDS": "Seeds",
"SEEDS_ARE_LOW_FOR_TIME": "Seeds jsou nízké pro čas",
"SEED_MINUTES": "Seed minut",
"SELECT": "Vybrat",
"SELECTED": "vybráno",
"SELECTED_LABELS": "Vybráno Štítky",
"SELECTED_MONITORED_PATH": "Vybráno monitored Cesta",
"SELECTED_TORRENTS_WILL_BE_REMOVED": "vybráno Torrenty bude být removed.",
"SELECT_A_MONITORED_PATH_FIRST": "Vybrat monitored Cesta První.",
"SELECT_A_TORRENT": "Vybrat Torrent.",
"SELECT_ONE_OR_MORE_TORRENT_FILES": "Vybrat jedna nebo more .Torrent Soubory.",
"SELECT_PATH": "Vybrat Cesta",
"SELECT_TORRENTS_THAT_SMART_QUEUE_SHOULD_IGNORE_USE_SEARCH_TO_FILTER_BY_NAME_LABE": "Vybrat Torrenty že Smart Queue má ignorovat. Použít Hledat do filtr podle Název, Štítek, Stav nebo Hash.",
"SELECT_VISIBLE": "Vybrat viditelné",
"SEPARATE_SLOT_POOL_FOR_LIGHTWEIGHT_CONTROL_JOBS_SO_THEY_DO_NOT_WAIT_BEHIND_HEAVY": "Separate slot pool pro lightweight control Úlohy so they do není čekat za heavy IO práce. Výchozí: 4.",
"SETTINGS": "Nastavení",
"SET_LABEL": "Set Štítek...",
"SET_LABELS": "Set Štítky",
"SET_RATIO_GROUP": "Set Ratio Skupina...",
"SHARE_AFTER_CREATING": "Share po creating",
"SHORTCUTS": "Shortcuts",
"SHOWN": "Shown",
"SHOWN_IN_LOADING_STATES_INSTEAD_OF_THE_STANDARD_SPINNER": "Shown v Načítání stavy místo standardní spinner.",
"SHOWS_TRACKER_ICONS_IN_THE_SIDEBAR_TRACKER_FILTER_WHEN_AVAILABLE": "Zobrazuje Tracker ikony v postranní panel Tracker filtr when dostupné.",
"SHOW_COMBINED_USAGE_SINGLE_PATH_SELECTION_IS_DISABLED_IN_THIS_MODE": "Zobrazit kombinované usage. Jedna Cesta selection je Vypnuto v toto mode.",
"SHOW_DL_UP_IN_BROWSER_TITLE": "Zobrazit DL/UP v Prohlížeč Název",
"SHOW_SMART_QUEUE_AUTOMATIC_RUN_MESSAGES": "Zobrazit Smart Queue Automaticky běh zprávy.",
"SHOW_TOASTS_CREATED_BY_AUTOMATION_RUNS": "Zobrazit toasts created podle automation runs.",
"SIGN_IN": "Přihlásit se",
"SIZE": "Velikost",
"SKIP_ACTIVE_UPLOAD": "Skip Aktivní Odesílání",
"SKIP_PRIVATE_TORRENTS": "Skip Soukromý Torrenty",
"SMART_FILTERS": "Smart filters",
"SMART_QUEUE": "Smart Queue",
"SMART_QUEUE_EXCEPTIONS": "Smart Queue exceptions",
"SMART_QUEUE_KEEPS_ONLY_THIS_MANY_ACTIVE_DOWNLOADS_OVERFLOW_IS_STOPPED": "Smart Queue keeps pouze toto many Aktivní Stahování; overflow je Zastaveno.",
"SMART_QUEUE_TOASTS": "Smart Queue toasts",
"SOCKETS": "Sockets",
"SOURCE": "Zdroj",
"SPACE_START": "Space - Spustit",
"SPEED": "Rychlost",
"SPEED_LIMITS": "Rychlost Limity",
"SPEED_TREND": "Rychlost trend",
"STALLED_AFTER_SECONDS": "Zaseknuté po sekund",
"START": "Spustit",
"START_AFTER_ADD": "Spustit po Přidat",
"START_GRACE_SECONDS": "Spustit grace sekund",
"STATE": "Stav",
"STATUS": "Stav",
"STATUS_EQUALS": "Stav equals",
"STOP": "Zastavit",
"STOPPED": "Zastaveno",
"S_STOP": "S - Zastavit",
"TARGET_ACTIVE_DOWNLOADS": "Cíl Aktivní Stahování",
"TARGET_PATH": "Cíl Cesta",
"TEST_RULE": "Test Pravidlo",
"TEST_SCGI": "Test SCGI",
"THEME_TYPOGRAPHY_AND_INTERFACE_SCALE": "Motiv, typography a interface scale.",
"THE_FOOTER_TOOLTIP_ALWAYS_SHOWS_DETAILS_FOR_AVAILABLE_PATHS_THIS_SETTING_ONLY_DE": "Patička tooltip vždy zobrazuje Detaily pro dostupné cesty; toto nastavení pouze rozhoduje která hodnota řídí viditelné Průběh lišta.",
"TIMEOUT": "Timeout",
"TOAST_NOTIFICATIONS_FROM_AUTOMATIC_SYSTEMS": "Toast Oznámení z Automaticky systems.",
"TOOLS": "Nástroje",
"TOOLS_RTORRENTS": "Nástroje & rTorrents",
"TOOLS_SECTIONS": "Nástroje sections",
"TORRENT_ADDED": "Torrent added",
"TORRENT_COMPLETED": "Torrent Dokončeno",
"TORRENT_DETAILS": "Torrent Detaily",
"TORRENT_FILES": "Torrent Soubory",
"TORRENT_FILTERS": "Torrent filters",
"TORRENT_PROPERTIES": "Torrent properties",
"TORRENT_REMOVED": "Torrent removed",
"TORRENT_STATISTICS": "Torrent Statistiky",
"TORRENT_STATS": "Torrent stats",
"TOTAL_DL_UP": "Total DL/UP",
"TO_DOWNLOAD": "Do Stahování",
"TRACKERS": "Trackery",
"TRACKER_ICONS": "Tracker ikony",
"TRANSFER": "Transfer",
"TRANSFERRED_DATA": "Transferred Data",
"TRANSFER_HISTORY": "Transfer Historie",
"UNDEFINED": "undefined",
"UNLIMITED": "Unlimited",
"UPLOADED": "Odesláno",
"UPLOADS": "Odesílání",
"UPLOAD_KIB_S": "Odesílání KiB/s",
"USER": "Uživatel",
"USERS": "Uživatelé",
"USES_A_LIGHTWEIGHT_BUILT_IN_RESOLVER_WITH_CACHE_HOSTNAMES_APPEAR_ONLY_IN_THE_PEE": "Uses lightweight built-v resolver s cache. Hostnames appear pouze v Peers karta.",
"USES_LOWER_ROWS_AND_SMALLER_LIST_ELEMENTS_ON_DESKTOP_AND_MOBILE_SO_MORE_TORRENTS": "Uses nižší řádky a smaller seznam elements on Desktop a Mobilní so more Torrenty vejde on obrazovka.",
"USES_YOUGETSIGNAL_FIRST_MANUAL_CHECK_BYPASSES_THE_6H_CACHE": "Uses YouGetSignal První. Ručně Zkontrolovat bypasses 6h cache.",
"USE_ONE_CUSTOM_PATH_BELOW_AS_THE_FOOTER_PROGRESS_VALUE": "Použít jedna Vlastní Cesta below jako Patička Průběh hodnota.",
"USE_SELECTED": "Použít vybráno",
"USE_THE_MAIN_DIRECTORY_FROM_THE_ACTIVE_RTORRENT_PROFILE": "Použít main Adresář z Aktivní rTorrent Profil.",
"VIEW_PREFERENCES_RESET": "View Předvolby Resetovat",
"VIEW_STATE_IS_SAVED_AUTOMATICALLY_IN_THE_DATABASE_CURRENT_TORRENT_FILTER_LAST_SO": "View Stav je Uloženo automatically v database: Aktuální Torrent filtr, Poslední sort column a direction, viditelné Sloupce, a Detaily Panel height.",
"VISIBLE_NAME_USED_IN_THE_PROFILE_SELECTOR": "Viditelné Název used v Profil selector.",
"VISUAL_HELPER_FOR_TRACKER_FILTERS_IN_THE_SIDEBAR": "Visual pomocník pro Tracker filters v postranní panel.",
"WAITING_FOR_DATA": "Čekání pro Data.",
"WAITING_FOR_TORRENT_DATA_FROM_THE_ACTIVE_PROFILE": "Čekání pro Torrent Data z Aktivní Profil.",
"WATCHDOG_MARKS_A_LIGHT_JOB_AS_FAILED_AFTER_THIS_TIME_DEFAULT_300_SECONDS": "Watchdog marks light Úloha jako selhalo po toto čas. Výchozí: 300 sekund.",
"WATCHDOG_TIMEOUT_FOR_MOVE_REMOVE_ADD_JOBS_DEFAULT_7200_SECONDS": "Watchdog Timeout pro Přesunout/Odebrat/Přidat Úlohy. Výchozí: 7200 sekund.",
"WHEN_DISABLED_THE_APPLICATION_USES_THE_NORMAL_PRODUCTION_UI": "When Vypnuto, Aplikace uses normální produkční UI.",
"WHEN_ENABLED_LOW_SPEED_IS_NOT_REQUIRED_WITH_SOURCE_AND_SPEED_IGNORES_ENABLED_ONL": "When Zapnuto, nízké Rychlost je není required. S Zdroj a Rychlost ignores Zapnuto, pouze Zaseknuté po sekund rozhoduje.",
"WHEN_ENABLED_SMART_QUEUE_DOES_NOT_USE_SEED_PEER_COUNT_AS_A_STALLED_CRITERION": "When Zapnuto, Smart Queue does není použít seed/peer count jako zaseknuté kritérium.",
"WITH_ERROR": "S Chyba",
"WORKING": "Pracuji...",
"FRENCH": "Francouzština",
"CZECH": "Čeština",
"SPANISH": "Španělština",
"NORWEGIAN": "Norština",
"RUSSIAN": "Ruština"
}
}

View File

@@ -0,0 +1,566 @@
{
"meta": {
"locale": "de_DE",
"label": "Deutsch",
"flag": "de"
},
"translations": {
"0_MEANS_ONLY_SEED_THRESHOLD_IS_REQUIRED": "0 means nur seed threshold is required.",
"0_MEANS_UNLIMITED_SLIDERS_USE_MBIT_S_AND_SAVE_THROUGH_THE_EXISTING_SPEED_LIMITS_": "0 means unlimited. Sliders verwenden Mbit/s und Speichern through existing Geschwindigkeit Limits API.",
"0_SELECTED": "0 ausgewählt",
"ABOUT_PYTORRENT": "About pyTorrent",
"ACTION": "Aktion",
"ACTIVE": "Aktiv",
"ACTIVE_RTORRENT_DOWNLOADS_MAX_GLOBAL_DOWNLOADS": "Aktiv rTorrent Downloads / max global Downloads",
"ACTIVE_RTORRENT_UPLOADS_MAX_GLOBAL_UPLOADS": "Aktiv rTorrent Uploads / max global Uploads",
"ADD": "Hinzufügen",
"ADDED": "Hinzugefügt",
"ADDS_REMOVALS_COMPLETIONS_AND_QUEUED_OPERATION_RESULTS": "Adds, removals, completions und queued operation results.",
"ADD_ACTION": "Hinzufügen Aktion",
"ADD_CONDITION": "Hinzufügen Bedingung",
"ADD_CREATE_TORRENT": "Hinzufügen / Erstellen Torrent",
"ADD_FIRST_LABEL_ABOVE": "Hinzufügen Erste Label above.",
"ADD_LABEL": "Hinzufügen Label",
"ADD_MAGNET_LINK": "Magnet-Link hinzufügen",
"ADD_NEW_LABEL": "Hinzufügen Neu Label",
"ADD_OR_EDIT_GROUP": "Hinzufügen or Bearbeiten Gruppe",
"ADD_PATH": "Pfad hinzufügen",
"ADD_PROFILE": "Hinzufügen Profil",
"ADD_RTORRENT_PROFILE": "rTorrent-Profil hinzufügen",
"ADD_THE_FIRST_RTORRENT_PROFILE_TO_START_LOADING_TORRENTS": "Füge das erste rTorrent-Profil hinzu, um Torrents zu laden.",
"ADD_TORRENT": "Torrent hinzufügen",
"ADD_TORRENT_FILE": "Torrent-Datei hinzufügen",
"ADMIN": "Administrator",
"ADMIN_ONLY_FULL_APPLICATION_BACKUP_RESTORE_CAN_REPLACE_USERS_PERMISSIONS_PROFILE": "Administrator-nur full Anwendung Backup. Wiederherstellen kann replace Benutzer, permissions, Profile und global Anwendung Einstellungen.",
"AGGREGATE_ALL_PATHS": "Alle Pfade aggregieren",
"ALL": "Alle",
"ALL_NON_JOB_TYPES": "Alle non-Job types",
"ALL_PROFILES": "Alle Profile",
"ALL_TYPES": "Alle types",
"ANNOUNCE": "Announce",
"API_DOCS": "API docs",
"APPEARANCE": "Darstellung",
"APPEARANCE_PREFERENCES_SAVED": "Darstellungseinstellungen gespeichert",
"APPEARS_NEAR_CLICKED_BUTTONS_ONLY_SOMETIMES": "Appears near clicked buttons nur sometimes.",
"APPLICATION_BACKUP": "Anwendung Backup",
"APPLICATION_BACKUP_NAME": "Anwendung Backup Name",
"APPLY": "Apply",
"APPLY_RETENTION_NOW": "Apply Aufbewahrung now",
"APPLY_SAVED_CHANGES_60S_AFTER_PYTORRENT_START": "Apply Gespeichert changes 60s nach pyTorrent Start",
"APP_STATUS": "App Status",
"AUTHENTICATION_IS_ENABLED_FOR_THIS_PYTORRENT_INSTANCE": "Authentication ist Aktiviert für diese pyTorrent Instanz.",
"AUTHOR": "Autor",
"AUTOMATIC": "Automatisch",
"AUTOMATIC_KEEPS_THE_CURRENT_POLLER_CADENCE_CUSTOM_RUNS_ONLY_AFTER_THE_SELECTED_N": "Automatisch keeps Aktuell poller cadence. Benutzerdefiniert runs nur nach ausgewählt number Minuten. Off disables refill completely.",
"AUTOMATIC_QUEUE_BALANCING_FOR_SLOW_OR_STALLED_DOWNLOADS": "Automatisch Warteschlange balancing for slow or stalled Downloads.",
"AUTOMATIC_RUNS_USE_THE_COOLDOWN_BELOW_MANUAL_CHECK_NOW_STILL_RUNS_IMMEDIATELY": "Automatisch runs verwenden Cooldown below. Manuell Prüfen now still runs immediately.",
"AUTOMATIONS": "Automations",
"AUTOMATIONS_RULES": "Automations / Regeln",
"AUTOMATION_TOASTS": "Automation-Toasts",
"AUTO_STOP_WHEN_IDLE": "Auto-Stop when idle",
"BACKEND": "Backend",
"BACKUP": "Backup",
"BACKUP_RESTORE": "Backup / Wiederherstellen",
"BACK_TO_DASHBOARD": "Back zu dashboard",
"BOOTSTRAP_THEME": "Bootstrap-Theme",
"BROWSER": "Browser",
"BROWSER_TITLE": "Browser-Titel",
"BUILD_A_RULE_AS_CONDITIONS_FIRST_THEN_ORDERED_ACTIONS_MATCHING_TORRENTS_ARE_HAND": "Build Regel als: Bedingungen Erste, dann ordered Aktionen. Matching Torrents sind handled als eine batch und Cooldown ist angewendet zu whole Regel.",
"BUSY": "beschäftigt",
"BY_DAYS": "By Tage",
"BY_LINE_COUNT": "Nach line count",
"CACHED_METADATA_SUMMARY_FILE_METADATA_IS_REFRESHED_EVERY_15_MINUTES_A_FEW_MINUTE": "Cached metadata summary. Datei metadata ist refreshed every 15 Minuten, a einige Minuten nach Start, oder manually.",
"CANCEL": "Abbrechen",
"CANCEL_EDIT": "Abbrechen Bearbeiten",
"CATEGORY": "Kategorie",
"CHANGES_APPLY_IMMEDIATELY_WHERE_POSSIBLE_INITIAL_STARTUP_LOADER_USES_THEM_AFTER_": "Changes apply immediately where possible; initial Start loader uses sie nach reload.",
"CHANGE_RTORRENT": "Ändern rTorrent",
"CHANGE_THEME": "Change Theme",
"CHANGING_RTORRENT_RELOADS_THE_LIVE_TORRENT_SNAPSHOT": "Änderung rTorrent reloads live Torrent Snapshot.",
"CHECKING": "Prüfung",
"CHECK_NOW": "Jetzt prüfen",
"CHECK_PORT_NOW": "Port jetzt prüfen",
"CHECK_THIS_IF_YOU_WANT_TO_CONNECT_TO_A_REMOTE_RTORRENT_INSTANCE_INSTEAD_OF_LOCAL": "Prüfen diese wenn Sie möchten zu verbinden zu a Remote rTorrent Instanz statt of localhost.",
"CHOOSE_COLUMNS_VISIBLE_IN_THE_TORRENT_LIST": "Wählen Spalten sichtbar in Torrent Liste.",
"CHOOSE_FILES": "Choose Dateien",
"CHOOSE_RTORRENT": "Wählen rTorrent",
"CHOOSE_TORRENTS_IGNORED_BY_SMART_QUEUE_EXISTING_BEHAVIOR_STAYS_UNCHANGED_FOR_ALL": "Wählen Torrents ignored nach Smart Queue. Existing behavior stays unchanged für Alle non-excluded Torrents.",
"CHOOSE_WHAT_THE_FOOTER_DISK_BAR_SHOULD_REPRESENT_AND_ADD_EXTRA_STORAGE_PATHS": "Wählen what Fußzeile disk Leiste soll represent und Hinzufügen extra storage Pfade.",
"CHOOSE_WHICH_STATUS_ITEMS_ARE_VISIBLE_IN_THE_BOTTOM_BAR": "Wählen welcher Status items sind sichtbar in bottom Leiste.",
"CHUNKS": "Chunks",
"CLEANUP": "Bereinigung",
"CLEANUP_RETENTION": "Bereinigung / Aufbewahrung",
"CLEAR": "Löschen",
"CLEAR_CURRENT_FILTER": "Löschen Aktuell filter",
"CLEAR_FINISHED": "Löschen finished",
"CLEAR_LABELS": "Löschen Labels",
"CLEAR_SELECTION": "Auswahl löschen",
"CLEAR_VISIBLE": "Löschen sichtbar",
"CLOSE": "Schließen",
"COLUMNS": "Spalten",
"COLUMNS_SAVED": "Spalten gespeichert",
"COMMENT": "Comment",
"COMPACT_TORRENT_LIST": "Kompakte Torrent-Liste",
"COMPLETE": "Abgeschlossen",
"COMPLETED": "Abgeschlossen",
"CONFIGURED_RTORRENTS": "Configured rTorrents",
"CONNECTING_TO_RTORRENT_AND_PREPARING_DATA": "Verbindung zu rTorrent wird hergestellt und Daten werden vorbereitet.",
"CONNECTION_ADDRESS_IN_SCGI_HOST_PORT_RPC2_FORMAT": "Verbindung address in scgi://host:Port/RPC2 format.",
"CONTROLS_THE_DEFAULT_CATEGORY_AND_JOB_LOG_VISIBILITY_USED_BY_THE_LOGS_MODAL": "Controls Standard Kategorie und Job Log visibility used nach Logs Modal.",
"CONTROLS_WHAT_IS_SHOWN_IN_THE_BROWSER_TAB": "Steuert, was im Browser-Tab angezeigt wird.",
"COOLDOWN_MINUTES": "Cooldown Minuten",
"COPY": "Kopieren",
"COPY_HASH": "Kopieren Hash",
"COPY_NAME": "Kopieren Name",
"COPY_PATH": "Kopieren Pfad",
"CPU_RAM_USAGE": "CPU / RAM usage",
"CREATE": "Erstellen",
"CREATES_AND_RESTORES_SETTINGS_FOR_THE_CURRENTLY_SELECTED_PROFILE_USER_SCOPED_PRE": "Creates und restores Einstellungen für derzeit ausgewählt Profil. Benutzer-scoped Einstellungen sind remapped zu Aktuell Benutzer where needed.",
"CREATE_APPLICATION_BACKUP": "Erstellen Anwendung Backup",
"CREATE_ONE_RTORRENT_PROFILE_AT_A_TIME_MOVE_REMOVE_QUEUES_KEEP_THEIR_ORDER_FOR_EA": "Erstellen one rTorrent Profil at a time. Verschieben/Entfernen queues keep their order for each Profil.",
"CREATE_PROFILE_BACKUP": "Erstellen Profil Backup",
"CREATE_REUSABLE_LABELS_AND_REMOVE_LABELS_THAT_ARE_NO_LONGER_NEEDED": "Erstellen wiederverwendbare Labels und Entfernen Labels dass sind Keine länger needed.",
"CREATE_TORRENT": "Erstellen Torrent",
"CTRL_A_SELECT_VISIBLE": "Ctrl+A - Auswählen sichtbar",
"CTRL_I_INVERT_VISIBLE": "Ctrl+I - invert sichtbar",
"CTRL_O_ADD": "Ctrl+O - Hinzufügen",
"CTRL_S_DOWNLOAD_TORRENT": "Ctrl+S - Download .Torrent",
"CURRENT_TRANSFER_SPEED": "Aktuelle Übertragungsgeschwindigkeit",
"CUSTOM_DOWNLOAD": "Benutzerdefiniert Download",
"CUSTOM_UPLOAD": "Benutzerdefiniert Upload",
"DAYS_AND_LINE_COUNT": "Tage und line count",
"DECREASE_OR_INCREASE_THE_WHOLE_INTERFACE_SIZE": "Gesamte Oberfläche verkleinern oder vergrößern.",
"DEFAULT_LOG_CATEGORY": "Standard Log Kategorie",
"DEFAULT_LOG_VIEW": "Standard Log view",
"DEFAULT_RTORRENT_PATH": "Standard-rTorrent-Pfad",
"DEGRADED": "eingeschränkt",
"DELETE": "Löschen",
"DELETE_REMOVE": "Löschen - Entfernen",
"DESKTOP": "Desktop",
"DETAILS_WILL_APPEAR_AFTER_THE_FIRST_SUCCESSFUL_RESPONSE": "Details erscheinen nach der ersten erfolgreichen Antwort.",
"DIAGNOSTICS": "Diagnose",
"DISABLED": "Deaktiviert",
"DISK_MONITOR": "Festplattenmonitor",
"DISK_USAGE_UNAVAILABLE": "Disk usage unavailable",
"DISPLAYS_CURRENT_SPEEDS_NEXT_TO_PYTORRENT_IN_THE_TAB_TITLE": "Displays Aktuell speeds Nächste zu pyTorrent in Tab Titel.",
"DOCS_API": "Docs API",
"DOWNLOADED": "Heruntergeladen",
"DOWNLOADING": "Wird heruntergeladen",
"DOWNLOADS": "Downloads",
"DOWNLOAD_KIB_S": "Download KiB/s",
"DOWNLOAD_STARTED": "Download gestartete",
"DOWNLOAD_TORRENT": "Download .Torrent",
"DOWNLOAD_TRACKER_FAVICONS": "Tracker-Favicons laden",
"DRAG_TO_RESIZE_DETAILS_PANEL": "Drag to resize Details Panel",
"EASTER_EGG": "Easter Egg",
"EDIT": "Bearbeiten",
"EMERGENCY_CANCEL": "Notfall-Abbruch",
"EMERGENCY_CLEAN_ALL": "Emergency clean Alle",
"ENABLED": "Aktiviert",
"ENABLE_AUTOMATIC_APPLICATION_BACKUPS": "Enable Automatisch Anwendung backups",
"ENABLE_AUTOMATIC_PROFILE_BACKUPS": "Enable Automatisch Profil backups",
"ENABLE_EASTER_EGG": "Aktivieren easter egg",
"ENABLE_INCOMING_PORT_CHECK": "Prüfung des eingehenden Ports aktivieren",
"ENABLE_REVERSE_DNS_FOR_PEERS": "Reverse DNS für Peers aktivieren",
"ENGLISH": "Englisch",
"EPISODE": "Episode",
"ESC_CLEAR_SELECTION": "Esc - Löschen selection",
"ETA": "ETA",
"EVERY_N_MINUTES": "Every N Minuten",
"EVERY_X_HOURS": "Every X hours",
"EXCLUDE": "Ausschließen",
"EXCLUDE_FROM_SMART_QUEUE": "Ausschließen von Smart Queue",
"EXCLUDE_PATTERN": "Ausschließen Muster",
"EXISTING_GROUPS": "Existing Gruppen",
"EXPORT": "Export",
"EXPORT_JSON": "Export JSON",
"EXTERNAL_AUTHENTICATION_IS_ENABLED_THROUGH_EXTERNAL_PROVIDER": "External authentication is Aktiviert through {{ external_provider }}.",
"E_G_PUNKTY_KATALOGOWANIE": "e.g. Punkty katalogowanie",
"FEATURES": "Funktionen",
"FEED": "Feed",
"FEEDS_ARE_CHECKED_BY_SCHEDULE_AND_EVERY_MATCH_IS_LOGGED_PER_FEED_RULE": "Feeds sind checked by schedule und every match ist logged per feed/Regel.",
"FEEDS_RULES_AND_MATCHES": "Feeds, Regeln und Treffer",
"FEED_NAME": "Feed Name",
"FEED_URL": "Feed URL",
"FILES": "Dateien",
"FILE_OR_DIRECTORY_PATH": "Datei or Verzeichnis Pfad",
"FONT": "Schriftart",
"FOOTER": "Fußzeile",
"FORCE": "Erzwingen",
"FORCE_RECHECK": "Erzwingen Erneut prüfen",
"FRONTEND": "Frontend",
"FULL": "Full",
"GENERAL": "Allgemein",
"GENERATED_RTORRENT_CONFIG_CHANGES_WILL_APPEAR_HERE": "Generierte rTorrent Konfiguration changes will appear here.",
"GENERATE_CONFIG": "Generate Konfiguration",
"GERMAN": "Deutsch",
"GO": "Go",
"GROUPED_RTORRENT_RUNTIME_SETTINGS_WITH_INLINE_RECOMMENDATIONS_AND_COMPATIBILITY_": "Gruppierte rTorrent Runtime Einstellungen mit Inline Empfehlungen und Kompatibilität Status.",
"GROUP_NAME": "Gruppe Name",
"HASH": "Hash",
"HASHING": "Hashing",
"HEAVY_PARALLEL_JOBS": "Heavy parallel Jobs",
"HEAVY_TIMEOUT_SECONDS": "Heavy Timeout Sekunden",
"HIDE_JOB_LOGS": "Ausblenden Job Logs",
"HIDE_JOB_LOGS_BY_DEFAULT": "Ausblenden Job Logs nach Standard",
"HISTORY": "Verlauf",
"HOW_LONG_A_MATCHING_ACTIVE_TORRENT_MUST_STAY_STALLED_BEFORE_IT_CAN_BE_REPLACED": "Wie lange a matching Aktiv Torrent muss stay stalled vor it kann sein ersetzt.",
"IGNORE_MISSING_SEEDS_PEERS_FOR_STALLED_TIMER": "Ignore missing Seeds/Peers for stalled timer",
"IGNORE_SPEED_FOR_STALLED_TIMER": "Ignore Geschwindigkeit for stalled timer",
"IMPORT": "Import",
"IMPORT_JSON": "Import JSON",
"INCLUDE_PATTERN": "Einschließen Muster",
"INCOMING_CONNECTION_TEST_SEPARATE_FROM_VISUAL_PREFERENCES": "Incoming Verbindung Test, separate von visual Einstellungen.",
"INTERFACE_SCALE": "Interface-Skalierung",
"INTERVAL_MINUTES": "Intervall Minuten",
"INVERT_VISIBLE": "sichtbare umkehren",
"JOBS": "Jobs",
"JOB_DONE": "Job erledigt",
"JOB_FAILED": "Job fehlgeschlagen",
"JOB_QUEUE": "Job Warteschlange",
"JOB_SCHEDULING": "Job scheduling",
"JOB_STARTED": "Job gestartete",
"KEEP_LINES": "Keep lines",
"KEEP_SEEDING": "Keep Seeding",
"LABEL": "Label",
"LABELS": "Labels",
"LABELS_SEPARATED_BY_COMMA": "Labels separated nach comma",
"LABEL_AFTER_SHARE": "Label nach share",
"LABEL_EXISTS": "Label exists",
"LABEL_IS_MISSING": "Label ist missing",
"LABEL_NAME_OR_SEVERAL_SEPARATED_BY_COMMA": "Label Name, oder mehrere separated nach comma",
"LANGUAGE": "Sprache",
"LANGUAGE_SAVED": "Sprache Gespeichert",
"LAST_OPERATIONS": "Letzte operations",
"LICENSE": "Lizenz",
"LIGHTWEIGHT_WEB_PANEL_FOR_RTORRENT_MANAGEMENT_QUEUE_CONTROL_AND_LIVE_TORRENT_DIA": "Lightweight web Panel für rTorrent management, Warteschlange control und live Torrent Diagnose.",
"LIGHT_PARALLEL_JOBS": "Light parallel Jobs",
"LIGHT_TIMEOUT_SECONDS": "Light Timeout Sekunden",
"LIMIT_DL": "Limit DL",
"LOADING": "Lädt",
"LOADING_CLEANUP_DATA": "Lädt Bereinigung Daten...",
"LOADING_CONFIG": "Lädt Konfiguration...",
"LOADING_IMAGE_URL": "Lädt image URL",
"LOADING_JOBS": "Lädt Jobs...",
"LOADING_LOGS": "Lädt Logs...",
"LOADING_PROFILES": "Lädt Profile...",
"LOADING_STATISTICS": "Lädt Statistik...",
"LOADING_TORRENTS": "Torrents werden geladen...",
"LOADING_TORRENT_DETAILS": "Torrent-Details werden geladen...",
"LOCAL_BROWSER_TIME": "Local Browser time",
"LOCATION": "Ort",
"LOG": "Log",
"LOGS": "Logs",
"LOG_IN": "Anmelden",
"LOG_OUT": "Abmelden",
"LOG_STATISTICS": "Log Statistik",
"MAGNET_AND_TORRENT_UPLOAD_FILE_PRIORITIES_LABELS_RATIO_GROUPS_SMART_QUEUE_AUTOMA": "Magnet und Torrent Upload, Datei Prioritäten, Labels, Ratio Gruppen, Smart Queue, automation Regeln, RSS, Traffic Diagramme, Port Prüfer, system Status.",
"MAGNET_LINKS": "Magnet links",
"MANAGE_EXCEPTIONS": "Manage exceptions",
"MANAGE_OPERATION_LOG_RETENTION_WITHOUT_CHANGING_TORRENT_DATA": "Manage operation Log Aufbewahrung without changing Torrent Daten.",
"MANAGE_OPTIONAL_PYTORRENT_USERS_EMPTY_PROFILE_MEANS_ALL_PROFILES_R_O_BLOCKS_RTOR": "Manage optional pyTorrent Benutzer. Empty Profil means Alle Profile. R/O blocks rTorrent-Änderung Aktionen; Full allows sie.",
"MANUAL_CLEANUP_ONLY": "Manuell Bereinigung nur",
"MAXIMUM_HEAVY_JOBS_RUNNING_AT_ONCE_FOR_THIS_PROFILE_DEFAULT_5": "Maximum heavy Jobs running at once für diese Profil. Standard: 5.",
"MAXIMUM_QUEUED_ACTIONS_RUNNING_AT_ONCE": "Maximum queued Aktionen running at once.",
"MAXIMUM_STALLED_OVERFLOW_DOWNLOADS_SMART_QUEUE_MAY_STOP_IN_ONE_PASS": "Maximum hängende/overflow Downloads Smart Queue may Stop in eine pass.",
"MAX_MB": "Max MB",
"MAX_RATIO": "Max Ratio",
"MAX_STOPS_PER_CHECK": "Max stops per Prüfen",
"MESSAGE": "Meldung",
"MINUTES": "Minuten",
"MIN_MB": "Min MB",
"MIN_PEERS": "Min Peers",
"MIN_RATIO": "Min Ratio",
"MIN_SEEDS": "Min Seeds",
"MIN_SEED_MINUTES": "Min seed Minuten",
"MIN_SPEED_KIB_S": "Min Geschwindigkeit KiB/s",
"MOBILE": "Mobil",
"MOBILE_COLUMNS": "Mobile Spalten",
"MOBILE_FILTER_GROUPS": "Mobile Filtergruppen",
"MOBILE_SIMPLE_MODE": "Mobil/simple mode",
"MOBILE_SORT_FILTERS": "Mobile Sortierfilter",
"MODE": "Mode",
"MONITORED_PATHS": "Überwachte Pfade",
"MOVE": "Verschieben",
"MOVE_DATA": "Verschieben Daten",
"MOVE_DATA_FILES": "Verschieben Daten Dateien",
"MOVE_PATH": "Verschieben Pfad",
"MOVE_TO_PATH": "Verschieben to Pfad",
"MOVIES": "Filme",
"MOVING": "Verschieben",
"M_MOVE": "M - Verschieben",
"NAME": "Name",
"NEGATE": "Negate",
"NEWLY_QUEUE_STARTED_TORRENTS_ARE_PROTECTED_FROM_STALLED_CLEANUP_DURING_THIS_WARM": "Newly Warteschlange-started Torrents sind protected von stalled Bereinigung während diese warm-up.",
"NEW_LABEL": "Neu Label",
"NEXT_READY": "Nächste: ready",
"NEXT_SMART_QUEUE_RUN": "Nächste Smart Queue Lauf",
"NOTIFICATIONS": "Benachrichtigungen",
"NOT_LOADED": "Nicht geladen.",
"NO_CHANGES": "Keine Änderungen",
"NO_FILES_RETURNED_BY_RTORRENT": "rTorrent hat keine Dateien zurückgegeben.",
"NO_FILES_SELECTED": "Keine Dateien ausgewählt",
"NO_LABELS": "Keine Labels.",
"NO_LABELS_SELECTED": "Keine Labels ausgewählt.",
"NO_PATH_LOADED": "Keine Pfad loaded.",
"NO_PEERS_RETURNED_BY_RTORRENT": "rTorrent hat keine Peers zurückgegeben.",
"NO_RTORRENT_PROFILE_CONFIGURED": "Kein rTorrent-Profil konfiguriert.",
"NO_SAVED_LABELS": "Keine gespeicherten Labels.",
"NO_TORRENTS": "Keine Torrents.",
"NO_TORRENTS_FOR_THIS_FILTER": "Keine Torrents für diesen Filter.",
"NO_TORRENTS_SELECTED": "Keine Torrents ausgewählt",
"NO_TORRENT_SELECTED": "Kein Torrent ausgewählt",
"NO_TRACKERS_RETURNED_BY_RTORRENT": "rTorrent hat keine Tracker zurückgegeben.",
"OFF": "Off",
"OFFLINE": "offline",
"ONE_PLACE_TO_CLEAR_LOGS_AND_ACTIVE_PROFILE_CACHES_PENDING_RUNNING_JOBS_RULES_SET": "Eine place zu Löschen Logs und Aktiv Profil caches. Ausstehende/running Jobs, Regeln, Einstellungen und Torrents sind preserved.",
"ONE_TRACKER_URL_PER_LINE": "One Tracker URL per line",
"ONLINE": "online",
"ONLY_SELECTED": "Nur ausgewählt",
"OPEN_DOWNLOAD_PLANNER": "Öffnen Download planner",
"OPEN_RTORRENT_FILES_MAX_OPEN_FILES": "Öffnen rTorrent Dateien / max Öffnen Dateien",
"OPEN_RTORRENT_HTTP_CONNECTIONS_MAX_HTTP_CONNECTIONS": "Öffnen rTorrent HTTP connections / max HTTP connections",
"OPEN_RTORRENT_SOCKETS": "Öffnen rTorrent sockets",
"OPEN_SOURCE": "Open Source",
"OPEN_THIS_TAB_TO_LOAD_DIAGNOSTICS": "Öffnen diese Tab zu laden Diagnose.",
"OPEN_THIS_TAB_TO_LOAD_STATISTICS": "Öffnen diese Tab zu laden Statistik.",
"OPERATION_LOG_RETENTION": "Operation Log Aufbewahrung",
"OPTIONAL": "optional",
"OPTIONAL_PEER_TABLE_HELPERS": "Optional peer table helpers.",
"OPTIONAL_PRIVATE_SOURCE_TAG": "optional Privater Quelle tag",
"OPTIONAL_VISUAL_EASTER_EGG_FOR_LOADING_STATES_AND_OCCASIONAL_BUTTON_CLICKS_DISAB": "Optional visual easter egg für Lädt Zustände und gelegentliche Schaltfläche Klicks. Deaktiviert by Standard.",
"PARALLEL_JOBS": "Parallel Jobs",
"PARENT_DIRECTORY": "Übergeordnet Verzeichnis",
"PARTIAL_DETAILS_LOADED": "Details teilweise geladen",
"PASSWORD": "Passwort",
"PASSWORD_NEW_PASSWORD": "Passwort / Neu Passwort",
"PASTE_ONE_MAGNET_URI_PER_LINE": "Paste eine magnet URI per line.",
"PATH": "Pfad",
"PATH_CONTAINS": "Pfad contains",
"PATH_TEXT": "Pfad Text",
"PATH_USED_FOR_SELECTED_MODE": "Pfad für ausgewählten Modus",
"PAUSE": "Pause",
"PAUSED": "Pausiert",
"PEAK_S": "Peak S",
"PEAK_SPEED_UNAVAILABLE": "Peak Geschwindigkeit unavailable",
"PEERS": "Peers",
"PEERS_AUTO_REFRESH": "Peers auto Aktualisieren",
"PENDING_JOBS_OLDER_THAN_THIS_ARE_RESUBMITTED_IF_NO_WORKER_IS_CURRENTLY_HANDLING_": "Ausstehende Jobs älter als diese sind erneut eingereicht wenn Keine Worker ist derzeit bearbeitet sie. Standard: 900 Sekunden.",
"PENDING_RUNNING_DONE_FAILED_RETRY_AND_CANCEL_HISTORY": "Ausstehende, running, erledigt, fehlgeschlagen, Wiederholen und Abbrechen Verlauf.",
"PENDING_TIMEOUT_SECONDS": "Pending Timeout Sekunden",
"PIECE_SIZE": "Piece Größe",
"PLANNER": "Planner",
"POLISH": "Polnisch",
"PORT": "Port",
"PORT_CHECKER": "Portprüfung",
"PORT_CHECK_DISABLED": "Port Prüfen Deaktiviert",
"PORT_UNKNOWN": "Port - unknown",
"POST_CHECK": "Nachprüfung",
"PREFERENCES": "Einstellungen",
"PREFERENCES_SAVED": "Einstellungen gespeichert",
"PRIORITY": "Priorität",
"PRIVATE_TORRENT": "Privater Torrent",
"PROFILE_BACKUP": "Profil Backup",
"PROFILE_BACKUP_NAME": "Profil Backup Name",
"PROFILE_BACKUP_RESTORES_ONLY_THE_ACTIVE_PROFILE_CONTEXT_APPLICATION_BACKUP_RESTO": "Profil Backup restores nur Aktiv Profil Kontext. Anwendung Backup restores global Anwendung Daten und ist verfügbar nur zu Administratoren.",
"PROFILE_NAME": "Profil Name",
"PROFILE_SCOPED_LOG_COUNTS_AND_CLEANUP_OVERVIEW": "Profil-scoped Log Anzahlen und Bereinigung Übersicht.",
"PROGRESS": "Fortschritt",
"PROGRESS_IS_AT_LEAST": "Fortschritt is at least %",
"PROGRESS_IS_AT_MOST": "Fortschritt is at most %",
"PROGRESS_SOURCE": "Fortschrittsquelle",
"PROTECT_ACTIVE_COUNT_BELOW_CAP": "Protect Aktiv count below cap",
"PYTORRENT_EXPECTS_TRUSTED_REVERSE_PROXY_IDENTITY_HEADERS_IF_YOU_ARE_ALREADY_SIGN": "pyTorrent expects trusted reverse-proxy identity headers. Wenn Sie sind already signed in, Prüfen provider headers und Benutzer mapping.",
"PYTORRENT_LOGIN": "pyTorrent login",
"PYTORRENT_STATUS": "pyTorrent Status",
"P_PAUSE": "P - Pause",
"QUALITY": "Qualität",
"QUEUE_REFILL_DURING_COOLDOWN": "Warteschlange refill during Cooldown",
"RANDOM_CLICK_IMAGE_URL": "Random click image URL",
"RATIO": "Ratio",
"RATIO_GROUP": "Ratio-Gruppe",
"RATIO_GROUPS": "Ratio Gruppen",
"RATIO_IS_AT_LEAST": "Ratio ist at least",
"RATIO_RULES": "Ratio Regeln",
"REANNOUNCE": "Reannounce",
"RECHECK": "Erneut prüfen",
"RECHECK_AFTER_MOVE": "Erneut prüfen nach Verschieben",
"RECOMMENDED_COLUMNS": "Empfohlene Spalten",
"RECOMMENDED_COLUMNS_APPLIED": "Empfohlene Spalten angewendet",
"RECONNECTING": "erneut verbinden",
"REFERENCE_VALUE_IS_KEPT_FROM_THE_FIRST_OVERRIDE_SAVE_LATER_SAVES_ADD_OR_CLEAR_DI": "Reference Wert ist kept von Erste override Speichern. Later saves Hinzufügen oder Löschen differences ohne replacing original reference.",
"REFRESH": "Aktualisieren",
"REFRESH_NOW": "Aktualisieren now",
"REGEX_TEXT": "Regex / Text",
"RELOAD": "Reload",
"REMOTE_LOCATION": "Remote Ort",
"REMOVE": "Entfernen",
"REMOVE_DATA": "Entfernen Daten",
"REMOVE_LABEL": "Entfernen Label",
"REMOVE_SELECTED_TORRENTS": "Entfernen ausgewählt Torrents",
"REMOVE_WITH_DATA": "Entfernen mit Daten",
"REPOSITORY": "Repository",
"RESET": "Zurücksetzen",
"RESET_UI_SETTINGS": "Zurücksetzen UI Einstellungen",
"RESET_VIEW_DEFAULTS": "Zurücksetzen view defaults",
"RESIZE_TORRENT_DETAILS_PANEL": "Resize Torrent Details Panel",
"RESOLVE_PEER_IP_TO_REVERSE_DNS_HOST": "Resolve peer IP zu reverse DNS host",
"RESUME": "Fortsetzen",
"RETENTION_DAYS": "Aufbewahrung Tage",
"RETENTION_MODE": "Aufbewahrung mode",
"RETRY": "Wiederholen",
"REVERSE_DNS": "Reverse DNS",
"RSS_DOWNLOADER": "RSS downloader",
"RTORRENT": "rTorrent",
"RTORRENTS": "rTorrents",
"RTORRENT_CONFIG": "rTorrent Konfiguration",
"RTORRENT_INCOMING_PORT": "rTorrent incoming Port",
"RTORRENT_IS_STARTING_OR_NOT_RESPONDING_YET": "rTorrent startet oder antwortet noch nicht.",
"RTORRENT_PROFILES": "rTorrent Profile",
"RULE": "Regel",
"RULES": "Regeln",
"RULES_ARE_CHECKED_AUTOMATICALLY_EVERY_5_MINUTES_A_TORRENT_USES_THE_GROUP_STORED_": "Regeln sind geprüft automatically every 5 Minuten. Torrent uses Gruppe stored in its rTorrent Benutzerdefiniert Ratio field.",
"RULE_NAME": "Regel Name",
"RUNTIME": "Runtime",
"RUN_A_PROFILE_TEST_TO_SHOW_DIAGNOSTICS": "Run a Profil Test to Anzeigen Diagnose.",
"RUN_SMART_QUEUE_DURING_POLLING_STOPPED_TORRENTS_ARE_MANAGED_PAUSED_TORRENTS_STAY": "Lauf Smart Queue während polling. Gestoppt Torrents sind verwaltet; Pausiert Torrents stay Benutzer-controlled.",
"R_RESUME": "R - Fortsetzen",
"SAVE": "Speichern",
"SAVED_LABELS": "Gespeichert Labels",
"SAVE_COLUMNS": "Speichern Spalten",
"SAVE_CONFIG": "Speichern Konfiguration",
"SAVE_EASTER_EGG": "Speichern easter egg",
"SAVE_EXCEPTIONS": "Speichern exceptions",
"SAVE_FEED": "Speichern feed",
"SAVE_FOOTER": "Speichern Fußzeile",
"SAVE_GROUP": "Speichern Gruppe",
"SAVE_JOB_SETTINGS": "Speichern Job Einstellungen",
"SAVE_LIMITS": "Speichern Limits",
"SAVE_LOCATION": "Speichern Ort",
"SAVE_LOG_VIEW": "Speichern Log view",
"SAVE_PATH": "Speichern Pfad",
"SAVE_PREFERENCES": "Einstellungen speichern",
"SAVE_RETENTION": "Speichern Aufbewahrung",
"SAVE_RULE": "Speichern Regel",
"SAVE_SCHEDULE": "Speichern schedule",
"SAVE_USER": "Speichern Benutzer",
"SCGI_URL": "SCGI URL",
"SEARCH_LOGS": "Suchen Logs...",
"SEARCH_TORRENTS": "Torrents suchen...",
"SEARCH_TORRENTS_TO_EXCLUDE": "Suchen Torrents to Ausschließen...",
"SEASON": "Staffel",
"SECONDS_TO_WAIT_FOR_RTORRENT_RESPONSE": "Sekunden to wait for rTorrent response.",
"SEEDING": "Seeding",
"SEEDS": "Seeds",
"SEEDS_ARE_LOW_FOR_TIME": "Seeds sind niedrig für Zeit",
"SEED_MINUTES": "Seed Minuten",
"SELECT": "Auswählen",
"SELECTED": "ausgewählt",
"SELECTED_LABELS": "Ausgewählt Labels",
"SELECTED_MONITORED_PATH": "Ausgewählter überwachter Pfad",
"SELECTED_TORRENTS_WILL_BE_REMOVED": "ausgewählt Torrents will be removed.",
"SELECT_A_MONITORED_PATH_FIRST": "Zuerst einen überwachten Pfad auswählen.",
"SELECT_A_TORRENT": "Auswählen a Torrent.",
"SELECT_ONE_OR_MORE_TORRENT_FILES": "Auswählen one or more .Torrent Dateien.",
"SELECT_PATH": "Auswählen Pfad",
"SELECT_TORRENTS_THAT_SMART_QUEUE_SHOULD_IGNORE_USE_SEARCH_TO_FILTER_BY_NAME_LABE": "Auswählen Torrents dass Smart Queue soll ignorieren. Verwenden Suchen zu filtern nach Name, Label, Status oder Hash.",
"SELECT_VISIBLE": "sichtbare auswählen",
"SEPARATE_SLOT_POOL_FOR_LIGHTWEIGHT_CONTROL_JOBS_SO_THEY_DO_NOT_WAIT_BEHIND_HEAVY": "Separate Slot Pool für lightweight control Jobs so they do nicht warten hinter heavy IO Arbeit. Standard: 4.",
"SETTINGS": "Einstellungen",
"SET_LABEL": "Set Label...",
"SET_LABELS": "Set Labels",
"SET_RATIO_GROUP": "Set Ratio Gruppe...",
"SHARE_AFTER_CREATING": "Share nach creating",
"SHORTCUTS": "Tastenkürzel",
"SHOWN": "Shown",
"SHOWN_IN_LOADING_STATES_INSTEAD_OF_THE_STANDARD_SPINNER": "Shown in Lädt Zustände statt Standard Spinner.",
"SHOWS_TRACKER_ICONS_IN_THE_SIDEBAR_TRACKER_FILTER_WHEN_AVAILABLE": "Zeigt Tracker Symbole in Seitenleiste Tracker filtern when verfügbar.",
"SHOW_COMBINED_USAGE_SINGLE_PATH_SELECTION_IS_DISABLED_IN_THIS_MODE": "Anzeigen kombinierte usage. Einzelne Pfad selection ist Deaktiviert in diese mode.",
"SHOW_DL_UP_IN_BROWSER_TITLE": "DL/UP im Browser-Titel anzeigen",
"SHOW_SMART_QUEUE_AUTOMATIC_RUN_MESSAGES": "Anzeigen Smart Queue Automatisch Lauf Meldungen.",
"SHOW_TOASTS_CREATED_BY_AUTOMATION_RUNS": "Anzeigen toasts created by automation runs.",
"SIGN_IN": "Anmelden",
"SIZE": "Größe",
"SKIP_ACTIVE_UPLOAD": "Skip Aktiv Upload",
"SKIP_PRIVATE_TORRENTS": "Skip Privater Torrents",
"SMART_FILTERS": "Smart-Filter",
"SMART_QUEUE": "Smart Queue",
"SMART_QUEUE_EXCEPTIONS": "Smart Queue exceptions",
"SMART_QUEUE_KEEPS_ONLY_THIS_MANY_ACTIVE_DOWNLOADS_OVERFLOW_IS_STOPPED": "Smart Queue keeps nur diese many Aktiv Downloads; overflow ist Gestoppt.",
"SMART_QUEUE_TOASTS": "Smart Queue toasts",
"SOCKETS": "Sockets",
"SOURCE": "Quelle",
"SPACE_START": "Space - Start",
"SPEED": "Geschwindigkeit",
"SPEED_LIMITS": "Geschwindigkeit Limits",
"SPEED_TREND": "Geschwindigkeit trend",
"STALLED_AFTER_SECONDS": "Stalled nach Sekunden",
"START": "Starten",
"START_AFTER_ADD": "Start nach Hinzufügen",
"START_GRACE_SECONDS": "Start grace Sekunden",
"STATE": "Zustand",
"STATUS": "Status",
"STATUS_EQUALS": "Status equals",
"STOP": "Stoppen",
"STOPPED": "Gestoppt",
"S_STOP": "S - Stop",
"TARGET_ACTIVE_DOWNLOADS": "Ziel Aktiv Downloads",
"TARGET_PATH": "Ziel Pfad",
"TEST_RULE": "Test Regel",
"TEST_SCGI": "Test SCGI",
"THEME_TYPOGRAPHY_AND_INTERFACE_SCALE": "Theme, Typografie und Interface-Skalierung.",
"THE_FOOTER_TOOLTIP_ALWAYS_SHOWS_DETAILS_FOR_AVAILABLE_PATHS_THIS_SETTING_ONLY_DE": "Fußzeile Tooltip immer zeigt Details für verfügbar Pfade; diese Einstellung nur entscheidet welcher Wert steuert sichtbar Fortschritt Leiste.",
"TIMEOUT": "Timeout",
"TOAST_NOTIFICATIONS_FROM_AUTOMATIC_SYSTEMS": "Toast Benachrichtigungen von Automatisch systems.",
"TOOLS": "Werkzeuge",
"TOOLS_RTORRENTS": "Werkzeuge & rTorrents",
"TOOLS_SECTIONS": "Werkzeuge sections",
"TORRENT_ADDED": "Torrent added",
"TORRENT_COMPLETED": "Torrent Abgeschlossen",
"TORRENT_DETAILS": "Torrent-Details",
"TORRENT_FILES": "Torrent Dateien",
"TORRENT_FILTERS": "Torrent filters",
"TORRENT_PROPERTIES": "Torrent properties",
"TORRENT_REMOVED": "Torrent removed",
"TORRENT_STATISTICS": "Torrent Statistik",
"TORRENT_STATS": "Torrent stats",
"TOTAL_DL_UP": "Total DL/UP",
"TO_DOWNLOAD": "Zu laden",
"TRACKERS": "Tracker",
"TRACKER_ICONS": "Tracker-Icons",
"TRANSFER": "Transfer",
"TRANSFERRED_DATA": "Transferred Daten",
"TRANSFER_HISTORY": "Transfer Verlauf",
"UNDEFINED": "undefined",
"UNLIMITED": "Unlimited",
"UPLOADED": "Hochgeladen",
"UPLOADS": "Uploads",
"UPLOAD_KIB_S": "Upload KiB/s",
"USER": "Benutzer",
"USERS": "Benutzer",
"USES_A_LIGHTWEIGHT_BUILT_IN_RESOLVER_WITH_CACHE_HOSTNAMES_APPEAR_ONLY_IN_THE_PEE": "Uses lightweight built-in Resolver mit Cache. Hostnamen appear nur in Peers Tab.",
"USES_LOWER_ROWS_AND_SMALLER_LIST_ELEMENTS_ON_DESKTOP_AND_MOBILE_SO_MORE_TORRENTS": "Uses niedrigere Zeilen und smaller Liste elements on Desktop und Mobil so more Torrents passen on Bildschirm.",
"USES_YOUGETSIGNAL_FIRST_MANUAL_CHECK_BYPASSES_THE_6H_CACHE": "Uses YouGetSignal Erste. Manuell Prüfen bypasses 6h Cache.",
"USE_ONE_CUSTOM_PATH_BELOW_AS_THE_FOOTER_PROGRESS_VALUE": "Verwenden eine Benutzerdefiniert Pfad below als Fußzeile Fortschritt Wert.",
"USE_SELECTED": "Verwenden ausgewählt",
"USE_THE_MAIN_DIRECTORY_FROM_THE_ACTIVE_RTORRENT_PROFILE": "Verwenden main Verzeichnis von Aktiv rTorrent Profil.",
"VIEW_PREFERENCES_RESET": "Ansichtseinstellungen zurückgesetzt",
"VIEW_STATE_IS_SAVED_AUTOMATICALLY_IN_THE_DATABASE_CURRENT_TORRENT_FILTER_LAST_SO": "View Status ist Gespeichert automatically in database: Aktuell Torrent filtern, Letzte sort column und direction, sichtbar Spalten, und Details Panel height.",
"VISIBLE_NAME_USED_IN_THE_PROFILE_SELECTOR": "Sichtbar Name used in Profil selector.",
"VISUAL_HELPER_FOR_TRACKER_FILTERS_IN_THE_SIDEBAR": "Visual Helfer für Tracker filters in Seitenleiste.",
"WAITING_FOR_DATA": "Warten for Daten.",
"WAITING_FOR_TORRENT_DATA_FROM_THE_ACTIVE_PROFILE": "Warte auf Torrent-Daten vom aktiven Profil.",
"WATCHDOG_MARKS_A_LIGHT_JOB_AS_FAILED_AFTER_THIS_TIME_DEFAULT_300_SECONDS": "Watchdog marks light Job als fehlgeschlagen nach diese Zeit. Standard: 300 Sekunden.",
"WATCHDOG_TIMEOUT_FOR_MOVE_REMOVE_ADD_JOBS_DEFAULT_7200_SECONDS": "Watchdog Timeout für Verschieben/Entfernen/Hinzufügen Jobs. Standard: 7200 Sekunden.",
"WHEN_DISABLED_THE_APPLICATION_USES_THE_NORMAL_PRODUCTION_UI": "When Deaktiviert, Anwendung uses normale Produktions UI.",
"WHEN_ENABLED_LOW_SPEED_IS_NOT_REQUIRED_WITH_SOURCE_AND_SPEED_IGNORES_ENABLED_ONL": "When Aktiviert, niedrig Geschwindigkeit ist nicht required. Mit Quelle und Geschwindigkeit ignores Aktiviert, nur Hängende nach Sekunden entscheidet.",
"WHEN_ENABLED_SMART_QUEUE_DOES_NOT_USE_SEED_PEER_COUNT_AS_A_STALLED_CRITERION": "When Aktiviert, Smart Queue does nicht verwenden seed/peer count als hängende Kriterium.",
"WITH_ERROR": "Mit Fehler",
"WORKING": "Arbeite...",
"FRENCH": "Französisch",
"CZECH": "Tschechisch",
"SPANISH": "Spanisch",
"NORWEGIAN": "Norwegisch",
"RUSSIAN": "Russisch"
}
}

View File

@@ -0,0 +1,566 @@
{
"meta": {
"locale": "en_US",
"label": "English",
"flag": "us"
},
"translations": {
"0_MEANS_ONLY_SEED_THRESHOLD_IS_REQUIRED": "0 means only seed threshold is required.",
"0_MEANS_UNLIMITED_SLIDERS_USE_MBIT_S_AND_SAVE_THROUGH_THE_EXISTING_SPEED_LIMITS_": "0 means unlimited. Sliders use Mbit/s and save through the existing speed limits API.",
"0_SELECTED": "0 selected",
"ABOUT_PYTORRENT": "About pyTorrent",
"ACTION": "Action",
"ACTIVE": "Active",
"ACTIVE_RTORRENT_DOWNLOADS_MAX_GLOBAL_DOWNLOADS": "Active rTorrent downloads / max global downloads",
"ACTIVE_RTORRENT_UPLOADS_MAX_GLOBAL_UPLOADS": "Active rTorrent uploads / max global uploads",
"ADD": "Add",
"ADDED": "Added",
"ADDS_REMOVALS_COMPLETIONS_AND_QUEUED_OPERATION_RESULTS": "Adds, removals, completions and queued operation results.",
"ADD_ACTION": "Add action",
"ADD_CONDITION": "Add condition",
"ADD_CREATE_TORRENT": "Add / create torrent",
"ADD_FIRST_LABEL_ABOVE": "Add first label above.",
"ADD_LABEL": "Add label",
"ADD_MAGNET_LINK": "Add magnet link",
"ADD_NEW_LABEL": "Add new label",
"ADD_OR_EDIT_GROUP": "Add or edit group",
"ADD_PATH": "Add path",
"ADD_PROFILE": "Add profile",
"ADD_RTORRENT_PROFILE": "Add rTorrent profile",
"ADD_THE_FIRST_RTORRENT_PROFILE_TO_START_LOADING_TORRENTS": "Add the first rTorrent profile to start loading torrents.",
"ADD_TORRENT": "Add torrent",
"ADD_TORRENT_FILE": "Add torrent file",
"ADMIN": "admin",
"ADMIN_ONLY_FULL_APPLICATION_BACKUP_RESTORE_CAN_REPLACE_USERS_PERMISSIONS_PROFILE": "Admin-only full application backup. Restore can replace users, permissions, profiles and global application settings.",
"AGGREGATE_ALL_PATHS": "Aggregate all paths",
"ALL": "All",
"ALL_NON_JOB_TYPES": "All non-job types",
"ALL_PROFILES": "All profiles",
"ALL_TYPES": "All types",
"ANNOUNCE": "Announce",
"API_DOCS": "API docs",
"APPEARANCE": "Appearance",
"APPEARANCE_PREFERENCES_SAVED": "Appearance preferences saved",
"APPEARS_NEAR_CLICKED_BUTTONS_ONLY_SOMETIMES": "Appears near clicked buttons only sometimes.",
"APPLICATION_BACKUP": "Application backup",
"APPLICATION_BACKUP_NAME": "Application backup name",
"APPLY": "Apply",
"APPLY_RETENTION_NOW": "Apply retention now",
"APPLY_SAVED_CHANGES_60S_AFTER_PYTORRENT_START": "Apply saved changes 60s after pyTorrent start",
"APP_STATUS": "App status",
"AUTHENTICATION_IS_ENABLED_FOR_THIS_PYTORRENT_INSTANCE": "Authentication is enabled for this pyTorrent instance.",
"AUTHOR": "Author",
"AUTOMATIC": "Automatic",
"AUTOMATIC_KEEPS_THE_CURRENT_POLLER_CADENCE_CUSTOM_RUNS_ONLY_AFTER_THE_SELECTED_N": "Automatic keeps the current poller cadence. Custom runs only after the selected number of minutes. Off disables refill completely.",
"AUTOMATIC_QUEUE_BALANCING_FOR_SLOW_OR_STALLED_DOWNLOADS": "Automatic queue balancing for slow or stalled downloads.",
"AUTOMATIC_RUNS_USE_THE_COOLDOWN_BELOW_MANUAL_CHECK_NOW_STILL_RUNS_IMMEDIATELY": "Automatic runs use the cooldown below. Manual Check now still runs immediately.",
"AUTOMATIONS": "Automations",
"AUTOMATIONS_RULES": "Automations / rules",
"AUTOMATION_TOASTS": "Automation toasts",
"AUTO_STOP_WHEN_IDLE": "Auto-stop when idle",
"BACKEND": "Backend",
"BACKUP": "Backup",
"BACKUP_RESTORE": "Backup / restore",
"BACK_TO_DASHBOARD": "Back to dashboard",
"BOOTSTRAP_THEME": "Bootstrap theme",
"BROWSER": "Browser",
"BROWSER_TITLE": "Browser title",
"BUILD_A_RULE_AS_CONDITIONS_FIRST_THEN_ORDERED_ACTIONS_MATCHING_TORRENTS_ARE_HAND": "Build a rule as: conditions first, then ordered actions. Matching torrents are handled as one batch and the cooldown is applied to the whole rule.",
"BUSY": "busy",
"BY_DAYS": "By days",
"BY_LINE_COUNT": "By line count",
"CACHED_METADATA_SUMMARY_FILE_METADATA_IS_REFRESHED_EVERY_15_MINUTES_A_FEW_MINUTE": "Cached metadata summary. File metadata is refreshed every 15 minutes, a few minutes after startup, or manually.",
"CANCEL": "Cancel",
"CANCEL_EDIT": "Cancel edit",
"CATEGORY": "Category",
"CHANGES_APPLY_IMMEDIATELY_WHERE_POSSIBLE_INITIAL_STARTUP_LOADER_USES_THEM_AFTER_": "Changes apply immediately where possible; initial startup loader uses them after reload.",
"CHANGE_RTORRENT": "Change rTorrent",
"CHANGE_THEME": "Change theme",
"CHANGING_RTORRENT_RELOADS_THE_LIVE_TORRENT_SNAPSHOT": "Changing rTorrent reloads the live torrent snapshot.",
"CHECKING": "Checking",
"CHECK_NOW": "Check now",
"CHECK_PORT_NOW": "Check port now",
"CHECK_THIS_IF_YOU_WANT_TO_CONNECT_TO_A_REMOTE_RTORRENT_INSTANCE_INSTEAD_OF_LOCAL": "Check this if you want to connect to a remote rTorrent instance instead of localhost.",
"CHOOSE_COLUMNS_VISIBLE_IN_THE_TORRENT_LIST": "Choose columns visible in the torrent list.",
"CHOOSE_FILES": "Choose files",
"CHOOSE_RTORRENT": "Choose rTorrent",
"CHOOSE_TORRENTS_IGNORED_BY_SMART_QUEUE_EXISTING_BEHAVIOR_STAYS_UNCHANGED_FOR_ALL": "Choose torrents ignored by Smart Queue. Existing behavior stays unchanged for all non-excluded torrents.",
"CHOOSE_WHAT_THE_FOOTER_DISK_BAR_SHOULD_REPRESENT_AND_ADD_EXTRA_STORAGE_PATHS": "Choose what the footer disk bar should represent and add extra storage paths.",
"CHOOSE_WHICH_STATUS_ITEMS_ARE_VISIBLE_IN_THE_BOTTOM_BAR": "Choose which status items are visible in the bottom bar.",
"CHUNKS": "Chunks",
"CLEANUP": "Cleanup",
"CLEANUP_RETENTION": "Cleanup / retention",
"CLEAR": "Clear",
"CLEAR_CURRENT_FILTER": "Clear current filter",
"CLEAR_FINISHED": "Clear finished",
"CLEAR_LABELS": "Clear labels",
"CLEAR_SELECTION": "clear selection",
"CLEAR_VISIBLE": "Clear visible",
"CLOSE": "Close",
"COLUMNS": "Columns",
"COLUMNS_SAVED": "Columns saved",
"COMMENT": "Comment",
"COMPACT_TORRENT_LIST": "Compact torrent list",
"COMPLETE": "Complete",
"COMPLETED": "Completed",
"CONFIGURED_RTORRENTS": "Configured rTorrents",
"CONNECTING_TO_RTORRENT_AND_PREPARING_DATA": "Connecting to rTorrent and preparing data.",
"CONNECTION_ADDRESS_IN_SCGI_HOST_PORT_RPC2_FORMAT": "Connection address in scgi://host:port/RPC2 format.",
"CONTROLS_THE_DEFAULT_CATEGORY_AND_JOB_LOG_VISIBILITY_USED_BY_THE_LOGS_MODAL": "Controls the default category and job log visibility used by the Logs modal.",
"CONTROLS_WHAT_IS_SHOWN_IN_THE_BROWSER_TAB": "Controls what is shown in the browser tab.",
"COOLDOWN_MINUTES": "Cooldown minutes",
"COPY": "Copy",
"COPY_HASH": "Copy hash",
"COPY_NAME": "Copy name",
"COPY_PATH": "Copy path",
"CPU_RAM_USAGE": "CPU / RAM usage",
"CREATE": "Create",
"CREATES_AND_RESTORES_SETTINGS_FOR_THE_CURRENTLY_SELECTED_PROFILE_USER_SCOPED_PRE": "Creates and restores settings for the currently selected profile. User-scoped preferences are remapped to the current user where needed.",
"CREATE_APPLICATION_BACKUP": "Create application backup",
"CREATE_ONE_RTORRENT_PROFILE_AT_A_TIME_MOVE_REMOVE_QUEUES_KEEP_THEIR_ORDER_FOR_EA": "Create one rTorrent profile at a time. Move/remove queues keep their order for each profile.",
"CREATE_PROFILE_BACKUP": "Create profile backup",
"CREATE_REUSABLE_LABELS_AND_REMOVE_LABELS_THAT_ARE_NO_LONGER_NEEDED": "Create reusable labels and remove labels that are no longer needed.",
"CREATE_TORRENT": "Create torrent",
"CTRL_A_SELECT_VISIBLE": "Ctrl+A - select visible",
"CTRL_I_INVERT_VISIBLE": "Ctrl+I - invert visible",
"CTRL_O_ADD": "Ctrl+O - add",
"CTRL_S_DOWNLOAD_TORRENT": "Ctrl+S - download .torrent",
"CURRENT_TRANSFER_SPEED": "Current transfer speed",
"CUSTOM_DOWNLOAD": "Custom download",
"CUSTOM_UPLOAD": "Custom upload",
"DAYS_AND_LINE_COUNT": "Days and line count",
"DECREASE_OR_INCREASE_THE_WHOLE_INTERFACE_SIZE": "Decrease or increase the whole interface size.",
"DEFAULT_LOG_CATEGORY": "Default log category",
"DEFAULT_LOG_VIEW": "Default log view",
"DEFAULT_RTORRENT_PATH": "Default rTorrent path",
"DEGRADED": "degraded",
"DELETE": "Delete",
"DELETE_REMOVE": "Delete - remove",
"DESKTOP": "Desktop",
"DETAILS_WILL_APPEAR_AFTER_THE_FIRST_SUCCESSFUL_RESPONSE": "Details will appear after the first successful response.",
"DIAGNOSTICS": "Diagnostics",
"DISABLED": "disabled",
"DISK_MONITOR": "Disk monitor",
"DISK_USAGE_UNAVAILABLE": "Disk usage unavailable",
"DISPLAYS_CURRENT_SPEEDS_NEXT_TO_PYTORRENT_IN_THE_TAB_TITLE": "Displays current speeds next to pyTorrent in the tab title.",
"DOCS_API": "Docs API",
"DOWNLOADED": "Downloaded",
"DOWNLOADING": "Downloading",
"DOWNLOADS": "Downloads",
"DOWNLOAD_KIB_S": "Download KiB/s",
"DOWNLOAD_STARTED": "Download started",
"DOWNLOAD_TORRENT": "Download .torrent",
"DOWNLOAD_TRACKER_FAVICONS": "Download tracker favicons",
"DRAG_TO_RESIZE_DETAILS_PANEL": "Drag to resize details panel",
"EASTER_EGG": "Easter egg",
"EDIT": "Edit",
"EMERGENCY_CANCEL": "Emergency cancel",
"EMERGENCY_CLEAN_ALL": "Emergency clean all",
"ENABLED": "Enabled",
"ENABLE_AUTOMATIC_APPLICATION_BACKUPS": "Enable automatic application backups",
"ENABLE_AUTOMATIC_PROFILE_BACKUPS": "Enable automatic profile backups",
"ENABLE_EASTER_EGG": "Enable easter egg",
"ENABLE_INCOMING_PORT_CHECK": "Enable incoming port check",
"ENABLE_REVERSE_DNS_FOR_PEERS": "Enable reverse DNS for peers",
"ENGLISH": "English",
"EPISODE": "Episode",
"ESC_CLEAR_SELECTION": "Esc - clear selection",
"ETA": "ETA",
"EVERY_N_MINUTES": "Every N minutes",
"EVERY_X_HOURS": "Every X hours",
"EXCLUDE": "Exclude",
"EXCLUDE_FROM_SMART_QUEUE": "Exclude from Smart Queue",
"EXCLUDE_PATTERN": "Exclude pattern",
"EXISTING_GROUPS": "Existing groups",
"EXPORT": "Export",
"EXPORT_JSON": "Export JSON",
"EXTERNAL_AUTHENTICATION_IS_ENABLED_THROUGH_EXTERNAL_PROVIDER": "External authentication is enabled through {{ external_provider }}.",
"E_G_PUNKTY_KATALOGOWANIE": "e.g. Punkty katalogowanie",
"FEATURES": "Features",
"FEED": "Feed",
"FEEDS_ARE_CHECKED_BY_SCHEDULE_AND_EVERY_MATCH_IS_LOGGED_PER_FEED_RULE": "Feeds are checked by schedule and every match is logged per feed/rule.",
"FEEDS_RULES_AND_MATCHES": "Feeds, rules and matches",
"FEED_NAME": "Feed name",
"FEED_URL": "Feed URL",
"FILES": "Files",
"FILE_OR_DIRECTORY_PATH": "File or directory path",
"FONT": "Font",
"FOOTER": "Footer",
"FORCE": "Force",
"FORCE_RECHECK": "Force recheck",
"FRONTEND": "Frontend",
"FULL": "Full",
"GENERAL": "General",
"GENERATED_RTORRENT_CONFIG_CHANGES_WILL_APPEAR_HERE": "Generated rTorrent config changes will appear here.",
"GENERATE_CONFIG": "Generate config",
"GERMAN": "German",
"GO": "Go",
"GROUPED_RTORRENT_RUNTIME_SETTINGS_WITH_INLINE_RECOMMENDATIONS_AND_COMPATIBILITY_": "Grouped rTorrent runtime settings with inline recommendations and compatibility status.",
"GROUP_NAME": "Group name",
"HASH": "Hash",
"HASHING": "Hashing",
"HEAVY_PARALLEL_JOBS": "Heavy parallel jobs",
"HEAVY_TIMEOUT_SECONDS": "Heavy timeout seconds",
"HIDE_JOB_LOGS": "Hide job logs",
"HIDE_JOB_LOGS_BY_DEFAULT": "Hide job logs by default",
"HISTORY": "History",
"HOW_LONG_A_MATCHING_ACTIVE_TORRENT_MUST_STAY_STALLED_BEFORE_IT_CAN_BE_REPLACED": "How long a matching active torrent must stay stalled before it can be replaced.",
"IGNORE_MISSING_SEEDS_PEERS_FOR_STALLED_TIMER": "Ignore missing seeds/peers for stalled timer",
"IGNORE_SPEED_FOR_STALLED_TIMER": "Ignore speed for stalled timer",
"IMPORT": "Import",
"IMPORT_JSON": "Import JSON",
"INCLUDE_PATTERN": "Include pattern",
"INCOMING_CONNECTION_TEST_SEPARATE_FROM_VISUAL_PREFERENCES": "Incoming connection test, separate from visual preferences.",
"INTERFACE_SCALE": "Interface scale",
"INTERVAL_MINUTES": "Interval minutes",
"INVERT_VISIBLE": "invert visible",
"JOBS": "Jobs",
"JOB_DONE": "Job done",
"JOB_FAILED": "Job failed",
"JOB_QUEUE": "Job queue",
"JOB_SCHEDULING": "Job scheduling",
"JOB_STARTED": "Job started",
"KEEP_LINES": "Keep lines",
"KEEP_SEEDING": "Keep seeding",
"LABEL": "Label",
"LABELS": "Labels",
"LABELS_SEPARATED_BY_COMMA": "Labels separated by comma",
"LABEL_AFTER_SHARE": "Label after share",
"LABEL_EXISTS": "Label exists",
"LABEL_IS_MISSING": "Label is missing",
"LABEL_NAME_OR_SEVERAL_SEPARATED_BY_COMMA": "Label name, or several separated by comma",
"LANGUAGE": "Language",
"LANGUAGE_SAVED": "Language saved",
"LAST_OPERATIONS": "Last operations",
"LICENSE": "License",
"LIGHTWEIGHT_WEB_PANEL_FOR_RTORRENT_MANAGEMENT_QUEUE_CONTROL_AND_LIVE_TORRENT_DIA": "Lightweight web panel for rTorrent management, queue control and live torrent diagnostics.",
"LIGHT_PARALLEL_JOBS": "Light parallel jobs",
"LIGHT_TIMEOUT_SECONDS": "Light timeout seconds",
"LIMIT_DL": "Limit DL",
"LOADING": "Loading",
"LOADING_CLEANUP_DATA": "Loading cleanup data...",
"LOADING_CONFIG": "Loading config...",
"LOADING_IMAGE_URL": "Loading image URL",
"LOADING_JOBS": "Loading jobs...",
"LOADING_LOGS": "Loading logs...",
"LOADING_PROFILES": "Loading profiles...",
"LOADING_STATISTICS": "Loading statistics...",
"LOADING_TORRENTS": "Loading torrents...",
"LOADING_TORRENT_DETAILS": "Loading torrent details...",
"LOCAL_BROWSER_TIME": "Local browser time",
"LOCATION": "Location",
"LOG": "Log",
"LOGS": "Logs",
"LOG_IN": "Log in",
"LOG_OUT": "Log out",
"LOG_STATISTICS": "Log statistics",
"MAGNET_AND_TORRENT_UPLOAD_FILE_PRIORITIES_LABELS_RATIO_GROUPS_SMART_QUEUE_AUTOMA": "Magnet and torrent upload, file priorities, labels, ratio groups, Smart Queue, automation rules, RSS, traffic charts, port checker, system status.",
"MAGNET_LINKS": "Magnet links",
"MANAGE_EXCEPTIONS": "Manage exceptions",
"MANAGE_OPERATION_LOG_RETENTION_WITHOUT_CHANGING_TORRENT_DATA": "Manage operation log retention without changing torrent data.",
"MANAGE_OPTIONAL_PYTORRENT_USERS_EMPTY_PROFILE_MEANS_ALL_PROFILES_R_O_BLOCKS_RTOR": "Manage optional pyTorrent users. Empty profile means all profiles. R/O blocks rTorrent-changing actions; Full allows them.",
"MANUAL_CLEANUP_ONLY": "Manual cleanup only",
"MAXIMUM_HEAVY_JOBS_RUNNING_AT_ONCE_FOR_THIS_PROFILE_DEFAULT_5": "Maximum heavy jobs running at once for this profile. Default: 5.",
"MAXIMUM_QUEUED_ACTIONS_RUNNING_AT_ONCE": "Maximum queued actions running at once.",
"MAXIMUM_STALLED_OVERFLOW_DOWNLOADS_SMART_QUEUE_MAY_STOP_IN_ONE_PASS": "Maximum stalled/overflow downloads Smart Queue may stop in one pass.",
"MAX_MB": "Max MB",
"MAX_RATIO": "Max ratio",
"MAX_STOPS_PER_CHECK": "Max stops per check",
"MESSAGE": "Message",
"MINUTES": "Minutes",
"MIN_MB": "Min MB",
"MIN_PEERS": "Min peers",
"MIN_RATIO": "Min ratio",
"MIN_SEEDS": "Min seeds",
"MIN_SEED_MINUTES": "Min seed minutes",
"MIN_SPEED_KIB_S": "Min speed KiB/s",
"MOBILE": "Mobile",
"MOBILE_COLUMNS": "Mobile columns",
"MOBILE_FILTER_GROUPS": "Mobile filter groups",
"MOBILE_SIMPLE_MODE": "Mobile/simple mode",
"MOBILE_SORT_FILTERS": "Mobile sort filters",
"MODE": "Mode",
"MONITORED_PATHS": "Monitored paths",
"MOVE": "Move",
"MOVE_DATA": "Move data",
"MOVE_DATA_FILES": "Move data files",
"MOVE_PATH": "Move path",
"MOVE_TO_PATH": "Move to path",
"MOVIES": "movies",
"MOVING": "Moving",
"M_MOVE": "M - move",
"NAME": "Name",
"NEGATE": "Negate",
"NEWLY_QUEUE_STARTED_TORRENTS_ARE_PROTECTED_FROM_STALLED_CLEANUP_DURING_THIS_WARM": "Newly queue-started torrents are protected from stalled cleanup during this warm-up.",
"NEW_LABEL": "New label",
"NEXT_READY": "next: ready",
"NEXT_SMART_QUEUE_RUN": "Next Smart Queue run",
"NOTIFICATIONS": "Notifications",
"NOT_LOADED": "Not loaded.",
"NO_CHANGES": "No changes",
"NO_FILES_RETURNED_BY_RTORRENT": "No files returned by rTorrent.",
"NO_FILES_SELECTED": "No files selected.",
"NO_LABELS": "No labels.",
"NO_LABELS_SELECTED": "No labels selected.",
"NO_PATH_LOADED": "No path loaded.",
"NO_PEERS_RETURNED_BY_RTORRENT": "No peers returned by rTorrent.",
"NO_RTORRENT_PROFILE_CONFIGURED": "No rTorrent profile configured.",
"NO_SAVED_LABELS": "No saved labels.",
"NO_TORRENTS": "No torrents.",
"NO_TORRENTS_FOR_THIS_FILTER": "No torrents for this filter.",
"NO_TORRENTS_SELECTED": "No torrents selected",
"NO_TORRENT_SELECTED": "No torrent selected",
"NO_TRACKERS_RETURNED_BY_RTORRENT": "No trackers returned by rTorrent.",
"OFF": "Off",
"OFFLINE": "offline",
"ONE_PLACE_TO_CLEAR_LOGS_AND_ACTIVE_PROFILE_CACHES_PENDING_RUNNING_JOBS_RULES_SET": "One place to clear logs and active profile caches. Pending/running jobs, rules, settings and torrents are preserved.",
"ONE_TRACKER_URL_PER_LINE": "One tracker URL per line",
"ONLINE": "online",
"ONLY_SELECTED": "Only selected",
"OPEN_DOWNLOAD_PLANNER": "Open Download planner",
"OPEN_RTORRENT_FILES_MAX_OPEN_FILES": "Open rTorrent files / max open files",
"OPEN_RTORRENT_HTTP_CONNECTIONS_MAX_HTTP_CONNECTIONS": "Open rTorrent HTTP connections / max HTTP connections",
"OPEN_RTORRENT_SOCKETS": "Open rTorrent sockets",
"OPEN_SOURCE": "Open source",
"OPEN_THIS_TAB_TO_LOAD_DIAGNOSTICS": "Open this tab to load diagnostics.",
"OPEN_THIS_TAB_TO_LOAD_STATISTICS": "Open this tab to load statistics.",
"OPERATION_LOG_RETENTION": "Operation log retention",
"OPTIONAL": "optional",
"OPTIONAL_PEER_TABLE_HELPERS": "Optional peer table helpers.",
"OPTIONAL_PRIVATE_SOURCE_TAG": "optional private source tag",
"OPTIONAL_VISUAL_EASTER_EGG_FOR_LOADING_STATES_AND_OCCASIONAL_BUTTON_CLICKS_DISAB": "Optional visual easter egg for loading states and occasional button clicks. Disabled by default.",
"PARALLEL_JOBS": "Parallel jobs",
"PARENT_DIRECTORY": "Parent directory",
"PARTIAL_DETAILS_LOADED": "Partial details loaded",
"PASSWORD": "Password",
"PASSWORD_NEW_PASSWORD": "Password / new password",
"PASTE_ONE_MAGNET_URI_PER_LINE": "Paste one magnet URI per line.",
"PATH": "Path",
"PATH_CONTAINS": "Path contains",
"PATH_TEXT": "Path text",
"PATH_USED_FOR_SELECTED_MODE": "Path used for selected mode",
"PAUSE": "Pause",
"PAUSED": "Paused",
"PEAK_S": "Peak S",
"PEAK_SPEED_UNAVAILABLE": "Peak speed unavailable",
"PEERS": "Peers",
"PEERS_AUTO_REFRESH": "Peers auto refresh",
"PENDING_JOBS_OLDER_THAN_THIS_ARE_RESUBMITTED_IF_NO_WORKER_IS_CURRENTLY_HANDLING_": "Pending jobs older than this are resubmitted if no worker is currently handling them. Default: 900 seconds.",
"PENDING_RUNNING_DONE_FAILED_RETRY_AND_CANCEL_HISTORY": "Pending, running, done, failed, retry and cancel history.",
"PENDING_TIMEOUT_SECONDS": "Pending timeout seconds",
"PIECE_SIZE": "Piece size",
"PLANNER": "Planner",
"POLISH": "Polish",
"PORT": "Port",
"PORT_CHECKER": "Port checker",
"PORT_CHECK_DISABLED": "Port check disabled",
"PORT_UNKNOWN": "Port - unknown",
"POST_CHECK": "Post-check",
"PREFERENCES": "Preferences",
"PREFERENCES_SAVED": "Preferences saved",
"PRIORITY": "Priority",
"PRIVATE_TORRENT": "Private torrent",
"PROFILE_BACKUP": "Profile backup",
"PROFILE_BACKUP_NAME": "Profile backup name",
"PROFILE_BACKUP_RESTORES_ONLY_THE_ACTIVE_PROFILE_CONTEXT_APPLICATION_BACKUP_RESTO": "Profile backup restores only the active profile context. Application backup restores global application data and is available only to admins.",
"PROFILE_NAME": "Profile name",
"PROFILE_SCOPED_LOG_COUNTS_AND_CLEANUP_OVERVIEW": "Profile-scoped log counts and cleanup overview.",
"PROGRESS": "Progress",
"PROGRESS_IS_AT_LEAST": "Progress is at least %",
"PROGRESS_IS_AT_MOST": "Progress is at most %",
"PROGRESS_SOURCE": "Progress source",
"PROTECT_ACTIVE_COUNT_BELOW_CAP": "Protect active count below cap",
"PYTORRENT_EXPECTS_TRUSTED_REVERSE_PROXY_IDENTITY_HEADERS_IF_YOU_ARE_ALREADY_SIGN": "pyTorrent expects trusted reverse-proxy identity headers. If you are already signed in, check provider headers and user mapping.",
"PYTORRENT_LOGIN": "pyTorrent login",
"PYTORRENT_STATUS": "pyTorrent status",
"P_PAUSE": "P - pause",
"QUALITY": "Quality",
"QUEUE_REFILL_DURING_COOLDOWN": "Queue refill during cooldown",
"RANDOM_CLICK_IMAGE_URL": "Random click image URL",
"RATIO": "Ratio",
"RATIO_GROUP": "Ratio group",
"RATIO_GROUPS": "Ratio groups",
"RATIO_IS_AT_LEAST": "Ratio is at least",
"RATIO_RULES": "Ratio rules",
"REANNOUNCE": "Reannounce",
"RECHECK": "Recheck",
"RECHECK_AFTER_MOVE": "Recheck after move",
"RECOMMENDED_COLUMNS": "Recommended columns",
"RECOMMENDED_COLUMNS_APPLIED": "Recommended columns applied",
"RECONNECTING": "reconnecting",
"REFERENCE_VALUE_IS_KEPT_FROM_THE_FIRST_OVERRIDE_SAVE_LATER_SAVES_ADD_OR_CLEAR_DI": "Reference value is kept from the first override save. Later saves add or clear differences without replacing the original reference.",
"REFRESH": "Refresh",
"REFRESH_NOW": "Refresh now",
"REGEX_TEXT": "Regex / text",
"RELOAD": "Reload",
"REMOTE_LOCATION": "Remote location",
"REMOVE": "Remove",
"REMOVE_DATA": "remove data",
"REMOVE_LABEL": "Remove label",
"REMOVE_SELECTED_TORRENTS": "Remove selected torrents",
"REMOVE_WITH_DATA": "Remove with data",
"REPOSITORY": "Repository",
"RESET": "Reset",
"RESET_UI_SETTINGS": "Reset UI settings",
"RESET_VIEW_DEFAULTS": "Reset view defaults",
"RESIZE_TORRENT_DETAILS_PANEL": "Resize torrent details panel",
"RESOLVE_PEER_IP_TO_REVERSE_DNS_HOST": "Resolve peer IP to reverse DNS host",
"RESUME": "Resume",
"RETENTION_DAYS": "Retention days",
"RETENTION_MODE": "Retention mode",
"RETRY": "Retry",
"REVERSE_DNS": "Reverse DNS",
"RSS_DOWNLOADER": "RSS downloader",
"RTORRENT": "rTorrent",
"RTORRENTS": "rTorrents",
"RTORRENT_CONFIG": "rTorrent config",
"RTORRENT_INCOMING_PORT": "rTorrent incoming port",
"RTORRENT_IS_STARTING_OR_NOT_RESPONDING_YET": "rTorrent is starting or not responding yet.",
"RTORRENT_PROFILES": "rTorrent profiles",
"RULE": "Rule",
"RULES": "Rules",
"RULES_ARE_CHECKED_AUTOMATICALLY_EVERY_5_MINUTES_A_TORRENT_USES_THE_GROUP_STORED_": "Rules are checked automatically every 5 minutes. A torrent uses the group stored in its rTorrent custom ratio field.",
"RULE_NAME": "Rule name",
"RUNTIME": "Runtime",
"RUN_A_PROFILE_TEST_TO_SHOW_DIAGNOSTICS": "Run a profile test to show diagnostics.",
"RUN_SMART_QUEUE_DURING_POLLING_STOPPED_TORRENTS_ARE_MANAGED_PAUSED_TORRENTS_STAY": "Run Smart Queue during polling. Stopped torrents are managed; Paused torrents stay user-controlled.",
"R_RESUME": "R - resume",
"SAVE": "Save",
"SAVED_LABELS": "Saved labels",
"SAVE_COLUMNS": "Save columns",
"SAVE_CONFIG": "Save config",
"SAVE_EASTER_EGG": "Save easter egg",
"SAVE_EXCEPTIONS": "Save exceptions",
"SAVE_FEED": "Save feed",
"SAVE_FOOTER": "Save footer",
"SAVE_GROUP": "Save group",
"SAVE_JOB_SETTINGS": "Save job settings",
"SAVE_LIMITS": "Save limits",
"SAVE_LOCATION": "Save location",
"SAVE_LOG_VIEW": "Save log view",
"SAVE_PATH": "Save path",
"SAVE_PREFERENCES": "Save preferences",
"SAVE_RETENTION": "Save retention",
"SAVE_RULE": "Save rule",
"SAVE_SCHEDULE": "Save schedule",
"SAVE_USER": "Save user",
"SCGI_URL": "SCGI URL",
"SEARCH_LOGS": "Search logs...",
"SEARCH_TORRENTS": "Search torrents...",
"SEARCH_TORRENTS_TO_EXCLUDE": "Search torrents to exclude...",
"SEASON": "Season",
"SECONDS_TO_WAIT_FOR_RTORRENT_RESPONSE": "Seconds to wait for rTorrent response.",
"SEEDING": "Seeding",
"SEEDS": "Seeds",
"SEEDS_ARE_LOW_FOR_TIME": "Seeds are low for time",
"SEED_MINUTES": "Seed minutes",
"SELECT": "Select",
"SELECTED": "selected",
"SELECTED_LABELS": "Selected labels",
"SELECTED_MONITORED_PATH": "Selected monitored path",
"SELECTED_TORRENTS_WILL_BE_REMOVED": "selected torrents will be removed.",
"SELECT_A_MONITORED_PATH_FIRST": "Select a monitored path first.",
"SELECT_A_TORRENT": "Select a torrent.",
"SELECT_ONE_OR_MORE_TORRENT_FILES": "Select one or more .torrent files.",
"SELECT_PATH": "Select path",
"SELECT_TORRENTS_THAT_SMART_QUEUE_SHOULD_IGNORE_USE_SEARCH_TO_FILTER_BY_NAME_LABE": "Select torrents that Smart Queue should ignore. Use search to filter by name, label, status or hash.",
"SELECT_VISIBLE": "Select visible",
"SEPARATE_SLOT_POOL_FOR_LIGHTWEIGHT_CONTROL_JOBS_SO_THEY_DO_NOT_WAIT_BEHIND_HEAVY": "Separate slot pool for lightweight control jobs so they do not wait behind heavy IO work. Default: 4.",
"SETTINGS": "Settings",
"SET_LABEL": "Set label...",
"SET_LABELS": "Set labels",
"SET_RATIO_GROUP": "Set ratio group...",
"SHARE_AFTER_CREATING": "Share after creating",
"SHORTCUTS": "Shortcuts",
"SHOWN": "Shown",
"SHOWN_IN_LOADING_STATES_INSTEAD_OF_THE_STANDARD_SPINNER": "Shown in loading states instead of the standard spinner.",
"SHOWS_TRACKER_ICONS_IN_THE_SIDEBAR_TRACKER_FILTER_WHEN_AVAILABLE": "Shows tracker icons in the sidebar tracker filter when available.",
"SHOW_COMBINED_USAGE_SINGLE_PATH_SELECTION_IS_DISABLED_IN_THIS_MODE": "Show combined usage. Single path selection is disabled in this mode.",
"SHOW_DL_UP_IN_BROWSER_TITLE": "Show DL/UP in browser title",
"SHOW_SMART_QUEUE_AUTOMATIC_RUN_MESSAGES": "Show Smart Queue automatic run messages.",
"SHOW_TOASTS_CREATED_BY_AUTOMATION_RUNS": "Show toasts created by automation runs.",
"SIGN_IN": "Sign in",
"SIZE": "Size",
"SKIP_ACTIVE_UPLOAD": "Skip active upload",
"SKIP_PRIVATE_TORRENTS": "Skip private torrents",
"SMART_FILTERS": "Smart filters",
"SMART_QUEUE": "Smart Queue",
"SMART_QUEUE_EXCEPTIONS": "Smart Queue exceptions",
"SMART_QUEUE_KEEPS_ONLY_THIS_MANY_ACTIVE_DOWNLOADS_OVERFLOW_IS_STOPPED": "Smart Queue keeps only this many active downloads; overflow is stopped.",
"SMART_QUEUE_TOASTS": "Smart Queue toasts",
"SOCKETS": "Sockets",
"SOURCE": "Source",
"SPACE_START": "Space - start",
"SPEED": "Speed",
"SPEED_LIMITS": "Speed limits",
"SPEED_TREND": "Speed trend",
"STALLED_AFTER_SECONDS": "Stalled after seconds",
"START": "Start",
"START_AFTER_ADD": "Start after add",
"START_GRACE_SECONDS": "Start grace seconds",
"STATE": "State",
"STATUS": "Status",
"STATUS_EQUALS": "Status equals",
"STOP": "Stop",
"STOPPED": "Stopped",
"S_STOP": "S - stop",
"TARGET_ACTIVE_DOWNLOADS": "Target active downloads",
"TARGET_PATH": "Target path",
"TEST_RULE": "Test rule",
"TEST_SCGI": "Test SCGI",
"THEME_TYPOGRAPHY_AND_INTERFACE_SCALE": "Theme, typography and interface scale.",
"THE_FOOTER_TOOLTIP_ALWAYS_SHOWS_DETAILS_FOR_AVAILABLE_PATHS_THIS_SETTING_ONLY_DE": "The footer tooltip always shows details for available paths; this setting only decides which value drives the visible progress bar.",
"TIMEOUT": "Timeout",
"TOAST_NOTIFICATIONS_FROM_AUTOMATIC_SYSTEMS": "Toast notifications from automatic systems.",
"TOOLS": "Tools",
"TOOLS_RTORRENTS": "Tools & rTorrents",
"TOOLS_SECTIONS": "Tools sections",
"TORRENT_ADDED": "Torrent added",
"TORRENT_COMPLETED": "Torrent completed",
"TORRENT_DETAILS": "Torrent details",
"TORRENT_FILES": "Torrent files",
"TORRENT_FILTERS": "Torrent filters",
"TORRENT_PROPERTIES": "Torrent properties",
"TORRENT_REMOVED": "Torrent removed",
"TORRENT_STATISTICS": "Torrent statistics",
"TORRENT_STATS": "Torrent stats",
"TOTAL_DL_UP": "Total DL/UP",
"TO_DOWNLOAD": "To download",
"TRACKERS": "Trackers",
"TRACKER_ICONS": "Tracker icons",
"TRANSFER": "Transfer",
"TRANSFERRED_DATA": "Transferred data",
"TRANSFER_HISTORY": "Transfer history",
"UNDEFINED": "undefined",
"UNLIMITED": "Unlimited",
"UPLOADED": "Uploaded",
"UPLOADS": "Uploads",
"UPLOAD_KIB_S": "Upload KiB/s",
"USER": "User",
"USERS": "Users",
"USES_A_LIGHTWEIGHT_BUILT_IN_RESOLVER_WITH_CACHE_HOSTNAMES_APPEAR_ONLY_IN_THE_PEE": "Uses a lightweight built-in resolver with cache. Hostnames appear only in the Peers tab.",
"USES_LOWER_ROWS_AND_SMALLER_LIST_ELEMENTS_ON_DESKTOP_AND_MOBILE_SO_MORE_TORRENTS": "Uses lower rows and smaller list elements on desktop and mobile so more torrents fit on screen.",
"USES_YOUGETSIGNAL_FIRST_MANUAL_CHECK_BYPASSES_THE_6H_CACHE": "Uses YouGetSignal first. Manual check bypasses the 6h cache.",
"USE_ONE_CUSTOM_PATH_BELOW_AS_THE_FOOTER_PROGRESS_VALUE": "Use one custom path below as the footer progress value.",
"USE_SELECTED": "Use selected",
"USE_THE_MAIN_DIRECTORY_FROM_THE_ACTIVE_RTORRENT_PROFILE": "Use the main directory from the active rTorrent profile.",
"VIEW_PREFERENCES_RESET": "View preferences reset",
"VIEW_STATE_IS_SAVED_AUTOMATICALLY_IN_THE_DATABASE_CURRENT_TORRENT_FILTER_LAST_SO": "View state is saved automatically in the database: current torrent filter, last sort column and direction, visible columns, and details panel height.",
"VISIBLE_NAME_USED_IN_THE_PROFILE_SELECTOR": "Visible name used in the profile selector.",
"VISUAL_HELPER_FOR_TRACKER_FILTERS_IN_THE_SIDEBAR": "Visual helper for tracker filters in the sidebar.",
"WAITING_FOR_DATA": "Waiting for data.",
"WAITING_FOR_TORRENT_DATA_FROM_THE_ACTIVE_PROFILE": "Waiting for torrent data from the active profile.",
"WATCHDOG_MARKS_A_LIGHT_JOB_AS_FAILED_AFTER_THIS_TIME_DEFAULT_300_SECONDS": "Watchdog marks a light job as failed after this time. Default: 300 seconds.",
"WATCHDOG_TIMEOUT_FOR_MOVE_REMOVE_ADD_JOBS_DEFAULT_7200_SECONDS": "Watchdog timeout for move/remove/add jobs. Default: 7200 seconds.",
"WHEN_DISABLED_THE_APPLICATION_USES_THE_NORMAL_PRODUCTION_UI": "When disabled, the application uses the normal production UI.",
"WHEN_ENABLED_LOW_SPEED_IS_NOT_REQUIRED_WITH_SOURCE_AND_SPEED_IGNORES_ENABLED_ONL": "When enabled, low speed is not required. With source and speed ignores enabled, only Stalled after seconds decides.",
"WHEN_ENABLED_SMART_QUEUE_DOES_NOT_USE_SEED_PEER_COUNT_AS_A_STALLED_CRITERION": "When enabled, Smart Queue does not use seed/peer count as a stalled criterion.",
"WITH_ERROR": "With error",
"WORKING": "Working...",
"FRENCH": "French",
"CZECH": "Czech",
"SPANISH": "Spanish",
"NORWEGIAN": "Norwegian",
"RUSSIAN": "Russian"
}
}

View File

@@ -0,0 +1,566 @@
{
"meta": {
"locale": "es_ES",
"label": "Español",
"flag": "es"
},
"translations": {
"0_MEANS_ONLY_SEED_THRESHOLD_IS_REQUIRED": "0 means solo seed threshold está required.",
"0_MEANS_UNLIMITED_SLIDERS_USE_MBIT_S_AND_SAVE_THROUGH_THE_EXISTING_SPEED_LIMITS_": "0 means unlimited. Sliders usar Mbit/s y Guardar through existing Velocidad Límites API.",
"0_SELECTED": "0 seleccionado",
"ABOUT_PYTORRENT": "About pyTorrent",
"ACTION": "Acción",
"ACTIVE": "Activo",
"ACTIVE_RTORRENT_DOWNLOADS_MAX_GLOBAL_DOWNLOADS": "Activo rTorrent Descargas / max global Descargas",
"ACTIVE_RTORRENT_UPLOADS_MAX_GLOBAL_UPLOADS": "Activo rTorrent Subidas / max global Subidas",
"ADD": "Añadir",
"ADDED": "Added",
"ADDS_REMOVALS_COMPLETIONS_AND_QUEUED_OPERATION_RESULTS": "Adds, removals, completions y queued operation results.",
"ADD_ACTION": "Añadir Acción",
"ADD_CONDITION": "Añadir Condición",
"ADD_CREATE_TORRENT": "Añadir / Crear Torrent",
"ADD_FIRST_LABEL_ABOVE": "Añadir Primero Etiqueta above.",
"ADD_LABEL": "Añadir Etiqueta",
"ADD_MAGNET_LINK": "Añadir magnet link",
"ADD_NEW_LABEL": "Añadir Nuevo Etiqueta",
"ADD_OR_EDIT_GROUP": "Añadir o Editar Grupo",
"ADD_PATH": "Añadir Ruta",
"ADD_PROFILE": "Añadir Perfil",
"ADD_RTORRENT_PROFILE": "Añadir rTorrent Perfil",
"ADD_THE_FIRST_RTORRENT_PROFILE_TO_START_LOADING_TORRENTS": "Añadir Primero rTorrent Perfil a Iniciar Cargando Torrents.",
"ADD_TORRENT": "Añadir Torrent",
"ADD_TORRENT_FILE": "Añadir Torrent Archivo",
"ADMIN": "Administrador",
"ADMIN_ONLY_FULL_APPLICATION_BACKUP_RESTORE_CAN_REPLACE_USERS_PERMISSIONS_PROFILE": "Administrador-solo full Aplicación copia de seguridad. Restaurar puede replace Usuarios, permissions, Perfiles y global Aplicación Ajustes.",
"AGGREGATE_ALL_PATHS": "Aggregate Todos rutas",
"ALL": "Todos",
"ALL_NON_JOB_TYPES": "Todos non-Tarea types",
"ALL_PROFILES": "Todos Perfiles",
"ALL_TYPES": "Todos types",
"ANNOUNCE": "Announce",
"API_DOCS": "API docs",
"APPEARANCE": "Apariencia",
"APPEARANCE_PREFERENCES_SAVED": "Apariencia Preferencias Guardado",
"APPEARS_NEAR_CLICKED_BUTTONS_ONLY_SOMETIMES": "Appears near clicked buttons solo sometimes.",
"APPLICATION_BACKUP": "Aplicación copia de seguridad",
"APPLICATION_BACKUP_NAME": "Aplicación copia de seguridad Nombre",
"APPLY": "Apply",
"APPLY_RETENTION_NOW": "Apply Retención now",
"APPLY_SAVED_CHANGES_60S_AFTER_PYTORRENT_START": "Apply Guardado changes 60s después pyTorrent Iniciar",
"APP_STATUS": "App Estado",
"AUTHENTICATION_IS_ENABLED_FOR_THIS_PYTORRENT_INSTANCE": "Authentication está Activado para este pyTorrent instancia.",
"AUTHOR": "Autor",
"AUTOMATIC": "Automático",
"AUTOMATIC_KEEPS_THE_CURRENT_POLLER_CADENCE_CUSTOM_RUNS_ONLY_AFTER_THE_SELECTED_N": "Automático keeps Actual poller cadence. Personalizado runs solo después seleccionado number de minutos. Off disables refill completely.",
"AUTOMATIC_QUEUE_BALANCING_FOR_SLOW_OR_STALLED_DOWNLOADS": "Automático Cola equilibrado para lentas o atascadas Descargas.",
"AUTOMATIC_RUNS_USE_THE_COOLDOWN_BELOW_MANUAL_CHECK_NOW_STILL_RUNS_IMMEDIATELY": "Automático runs usar Espera below. Manual Comprobar now still runs immediately.",
"AUTOMATIONS": "Automations",
"AUTOMATIONS_RULES": "Automations / Reglas",
"AUTOMATION_TOASTS": "Automation toasts",
"AUTO_STOP_WHEN_IDLE": "Auto-Detener when idle",
"BACKEND": "Backend",
"BACKUP": "Copia de seguridad",
"BACKUP_RESTORE": "Copia de seguridad / Restaurar",
"BACK_TO_DASHBOARD": "Back a dashboard",
"BOOTSTRAP_THEME": "Bootstrap Tema",
"BROWSER": "Navegador",
"BROWSER_TITLE": "Navegador Título",
"BUILD_A_RULE_AS_CONDITIONS_FIRST_THEN_ORDERED_ACTIONS_MATCHING_TORRENTS_ARE_HAND": "Build Regla como: Condiciones Primero, luego ordered Acciones. Matching Torrents son handled como uno batch y Espera está aplicado a whole Regla.",
"BUSY": "busy",
"BY_DAYS": "Por días",
"BY_LINE_COUNT": "Por line count",
"CACHED_METADATA_SUMMARY_FILE_METADATA_IS_REFRESHED_EVERY_15_MINUTES_A_FEW_MINUTE": "Cached metadata summary. Archivo metadata está refreshed every 15 minutos, unos minutos después inicio, o manually.",
"CANCEL": "Cancelar",
"CANCEL_EDIT": "Cancelar Editar",
"CATEGORY": "Categoría",
"CHANGES_APPLY_IMMEDIATELY_WHERE_POSSIBLE_INITIAL_STARTUP_LOADER_USES_THEM_AFTER_": "Changes apply immediately where possible; initial inicio loader uses ellos después reload.",
"CHANGE_RTORRENT": "Cambiar rTorrent",
"CHANGE_THEME": "Cambiar Tema",
"CHANGING_RTORRENT_RELOADS_THE_LIVE_TORRENT_SNAPSHOT": "Cambio rTorrent reloads live Torrent instantánea.",
"CHECKING": "Comprobando",
"CHECK_NOW": "Comprobar ahora",
"CHECK_PORT_NOW": "Comprobar puerto ahora",
"CHECK_THIS_IF_YOU_WANT_TO_CONNECT_TO_A_REMOTE_RTORRENT_INSTANCE_INSTEAD_OF_LOCAL": "Comprobar este si usted quiere a conectar a Remoto rTorrent instancia en lugar de de localhost.",
"CHOOSE_COLUMNS_VISIBLE_IN_THE_TORRENT_LIST": "Elegir Columnas visible en Torrent lista.",
"CHOOSE_FILES": "Elegir Archivos",
"CHOOSE_RTORRENT": "Elegir rTorrent",
"CHOOSE_TORRENTS_IGNORED_BY_SMART_QUEUE_EXISTING_BEHAVIOR_STAYS_UNCHANGED_FOR_ALL": "Elegir Torrents ignored por Smart Queue. Existing behavior stays unchanged para Todos non-excluded Torrents.",
"CHOOSE_WHAT_THE_FOOTER_DISK_BAR_SHOULD_REPRESENT_AND_ADD_EXTRA_STORAGE_PATHS": "Elegir what Pie disk barra debe represent y Añadir extra storage rutas.",
"CHOOSE_WHICH_STATUS_ITEMS_ARE_VISIBLE_IN_THE_BOTTOM_BAR": "Elegir qué Estado items son visible en bottom barra.",
"CHUNKS": "Chunks",
"CLEANUP": "Limpieza",
"CLEANUP_RETENTION": "Limpieza / Retención",
"CLEAR": "Limpiar",
"CLEAR_CURRENT_FILTER": "Limpiar Actual filtro",
"CLEAR_FINISHED": "Limpiar finished",
"CLEAR_LABELS": "Limpiar Etiquetas",
"CLEAR_SELECTION": "Limpiar selection",
"CLEAR_VISIBLE": "Limpiar visible",
"CLOSE": "Cerrar",
"COLUMNS": "Columnas",
"COLUMNS_SAVED": "Columnas Guardado",
"COMMENT": "Comment",
"COMPACT_TORRENT_LIST": "Compact Torrent lista",
"COMPLETE": "Complete",
"COMPLETED": "Completado",
"CONFIGURED_RTORRENTS": "Configured rTorrents",
"CONNECTING_TO_RTORRENT_AND_PREPARING_DATA": "Connecting a rTorrent y preparing Datos.",
"CONNECTION_ADDRESS_IN_SCGI_HOST_PORT_RPC2_FORMAT": "Conexión address en scgi://host:Puerto/RPC2 format.",
"CONTROLS_THE_DEFAULT_CATEGORY_AND_JOB_LOG_VISIBILITY_USED_BY_THE_LOGS_MODAL": "Controls Predeterminado Categoría y Tarea Registro visibility used por Registros modal.",
"CONTROLS_WHAT_IS_SHOWN_IN_THE_BROWSER_TAB": "Controls what está shown en Navegador pestaña.",
"COOLDOWN_MINUTES": "Espera minutos",
"COPY": "Copiar",
"COPY_HASH": "Copiar Hash",
"COPY_NAME": "Copiar Nombre",
"COPY_PATH": "Copiar Ruta",
"CPU_RAM_USAGE": "CPU / RAM usage",
"CREATE": "Crear",
"CREATES_AND_RESTORES_SETTINGS_FOR_THE_CURRENTLY_SELECTED_PROFILE_USER_SCOPED_PRE": "Creates y restores Ajustes para actualmente seleccionado Perfil. Usuario-scoped Preferencias son remapped a Actual Usuario where needed.",
"CREATE_APPLICATION_BACKUP": "Crear Aplicación copia de seguridad",
"CREATE_ONE_RTORRENT_PROFILE_AT_A_TIME_MOVE_REMOVE_QUEUES_KEEP_THEIR_ORDER_FOR_EA": "Crear uno rTorrent Perfil at tiempo. Mover/Quitar queues keep sus order para cada Perfil.",
"CREATE_PROFILE_BACKUP": "Crear Perfil copia de seguridad",
"CREATE_REUSABLE_LABELS_AND_REMOVE_LABELS_THAT_ARE_NO_LONGER_NEEDED": "Crear reutilizables Etiquetas y Quitar Etiquetas que son Sin más tiempo needed.",
"CREATE_TORRENT": "Crear Torrent",
"CTRL_A_SELECT_VISIBLE": "Ctrl+ - Seleccionar visible",
"CTRL_I_INVERT_VISIBLE": "Ctrl+I - invert visible",
"CTRL_O_ADD": "Ctrl+O - Añadir",
"CTRL_S_DOWNLOAD_TORRENT": "Ctrl+S - Descarga .Torrent",
"CURRENT_TRANSFER_SPEED": "Actual transfer Velocidad",
"CUSTOM_DOWNLOAD": "Personalizado Descarga",
"CUSTOM_UPLOAD": "Personalizado Subida",
"DAYS_AND_LINE_COUNT": "Días y line count",
"DECREASE_OR_INCREASE_THE_WHOLE_INTERFACE_SIZE": "Decrease o increase whole interface Tamaño.",
"DEFAULT_LOG_CATEGORY": "Predeterminado Registro Categoría",
"DEFAULT_LOG_VIEW": "Predeterminado Registro view",
"DEFAULT_RTORRENT_PATH": "Predeterminado rTorrent Ruta",
"DEGRADED": "degraded",
"DELETE": "Eliminar",
"DELETE_REMOVE": "Eliminar - Quitar",
"DESKTOP": "Escritorio",
"DETAILS_WILL_APPEAR_AFTER_THE_FIRST_SUCCESSFUL_RESPONSE": "Detalles será appear después Primero correcta respuesta.",
"DIAGNOSTICS": "Diagnóstico",
"DISABLED": "Desactivado",
"DISK_MONITOR": "Disk monitor",
"DISK_USAGE_UNAVAILABLE": "Disk usage unavailable",
"DISPLAYS_CURRENT_SPEEDS_NEXT_TO_PYTORRENT_IN_THE_TAB_TITLE": "Displays Actual speeds Siguiente a pyTorrent en pestaña Título.",
"DOCS_API": "Docs API",
"DOWNLOADED": "Descargado",
"DOWNLOADING": "Descargando",
"DOWNLOADS": "Descargas",
"DOWNLOAD_KIB_S": "Descarga KiB/s",
"DOWNLOAD_STARTED": "Descarga iniciados",
"DOWNLOAD_TORRENT": "Descarga .Torrent",
"DOWNLOAD_TRACKER_FAVICONS": "Descarga Tracker favicons",
"DRAG_TO_RESIZE_DETAILS_PANEL": "Drag a resize Detalles Panel",
"EASTER_EGG": "Easter egg",
"EDIT": "Editar",
"EMERGENCY_CANCEL": "Emergency Cancelar",
"EMERGENCY_CLEAN_ALL": "Emergency clean Todos",
"ENABLED": "Activado",
"ENABLE_AUTOMATIC_APPLICATION_BACKUPS": "Activar Automático Aplicación copias de seguridad",
"ENABLE_AUTOMATIC_PROFILE_BACKUPS": "Activar Automático Perfil copias de seguridad",
"ENABLE_EASTER_EGG": "Activar easter egg",
"ENABLE_INCOMING_PORT_CHECK": "Activar incoming Puerto Comprobar",
"ENABLE_REVERSE_DNS_FOR_PEERS": "Activar reverse DNS para Peers",
"ENGLISH": "Inglés",
"EPISODE": "Episodio",
"ESC_CLEAR_SELECTION": "Esc - Limpiar selection",
"ETA": "ETA",
"EVERY_N_MINUTES": "Every N minutos",
"EVERY_X_HOURS": "Every X hours",
"EXCLUDE": "Excluir",
"EXCLUDE_FROM_SMART_QUEUE": "Excluir desde Smart Queue",
"EXCLUDE_PATTERN": "Excluir Patrón",
"EXISTING_GROUPS": "Existing Grupos",
"EXPORT": "Exportar",
"EXPORT_JSON": "Exportar JSON",
"EXTERNAL_AUTHENTICATION_IS_ENABLED_THROUGH_EXTERNAL_PROVIDER": "External authentication está Activado through {{ external_provider }}.",
"E_G_PUNKTY_KATALOGOWANIE": "e.g. Punkty katalogowanie",
"FEATURES": "Funciones",
"FEED": "Feed",
"FEEDS_ARE_CHECKED_BY_SCHEDULE_AND_EVERY_MATCH_IS_LOGGED_PER_FEED_RULE": "Feeds son comprobados por programación y every coincidencia está registrada per feed/Regla.",
"FEEDS_RULES_AND_MATCHES": "Feeds, Reglas y coincidencias",
"FEED_NAME": "Feed Nombre",
"FEED_URL": "Feed URL",
"FILES": "Archivos",
"FILE_OR_DIRECTORY_PATH": "Archivo o Directorio Ruta",
"FONT": "Fuente",
"FOOTER": "Pie",
"FORCE": "Forzar",
"FORCE_RECHECK": "Forzar Recomprobar",
"FRONTEND": "Frontend",
"FULL": "Full",
"GENERAL": "General",
"GENERATED_RTORRENT_CONFIG_CHANGES_WILL_APPEAR_HERE": "Generado rTorrent Configuración changes será appear here.",
"GENERATE_CONFIG": "Generate Configuración",
"GERMAN": "Alemán",
"GO": "Go",
"GROUPED_RTORRENT_RUNTIME_SETTINGS_WITH_INLINE_RECOMMENDATIONS_AND_COMPATIBILITY_": "Agrupados rTorrent Runtime Ajustes con inline recomendaciones y compatibilidad Estado.",
"GROUP_NAME": "Grupo Nombre",
"HASH": "Hash",
"HASHING": "Hashing",
"HEAVY_PARALLEL_JOBS": "Heavy parallel Tareas",
"HEAVY_TIMEOUT_SECONDS": "Heavy Timeout segundos",
"HIDE_JOB_LOGS": "Ocultar Tarea Registros",
"HIDE_JOB_LOGS_BY_DEFAULT": "Ocultar Tarea Registros por Predeterminado",
"HISTORY": "Historial",
"HOW_LONG_A_MATCHING_ACTIVE_TORRENT_MUST_STAY_STALLED_BEFORE_IT_CAN_BE_REPLACED": "Cuánto largo matching Activo Torrent debe stay atascadas antes it puede estar reemplazado.",
"IGNORE_MISSING_SEEDS_PEERS_FOR_STALLED_TIMER": "Ignorar missing Seeds/Peers para atascadas timer",
"IGNORE_SPEED_FOR_STALLED_TIMER": "Ignorar Velocidad para atascadas timer",
"IMPORT": "Importar",
"IMPORT_JSON": "Importar JSON",
"INCLUDE_PATTERN": "Incluir Patrón",
"INCOMING_CONNECTION_TEST_SEPARATE_FROM_VISUAL_PREFERENCES": "Incoming Conexión Prueba, separate desde visual Preferencias.",
"INTERFACE_SCALE": "Interface scale",
"INTERVAL_MINUTES": "Intervalo minutos",
"INVERT_VISIBLE": "invert visible",
"JOBS": "Tareas",
"JOB_DONE": "Tarea hecho",
"JOB_FAILED": "Tarea fallido",
"JOB_QUEUE": "Tarea Cola",
"JOB_SCHEDULING": "Tarea scheduling",
"JOB_STARTED": "Tarea iniciados",
"KEEP_LINES": "Keep lines",
"KEEP_SEEDING": "Keep Compartiendo",
"LABEL": "Etiqueta",
"LABELS": "Etiquetas",
"LABELS_SEPARATED_BY_COMMA": "Etiquetas separated por comma",
"LABEL_AFTER_SHARE": "Etiqueta después share",
"LABEL_EXISTS": "Etiqueta exists",
"LABEL_IS_MISSING": "Etiqueta está missing",
"LABEL_NAME_OR_SEVERAL_SEPARATED_BY_COMMA": "Etiqueta Nombre, o varios separated por comma",
"LANGUAGE": "Idioma",
"LANGUAGE_SAVED": "Idioma Guardado",
"LAST_OPERATIONS": "Último operations",
"LICENSE": "Licencia",
"LIGHTWEIGHT_WEB_PANEL_FOR_RTORRENT_MANAGEMENT_QUEUE_CONTROL_AND_LIVE_TORRENT_DIA": "Lightweight web Panel para rTorrent management, Cola control y live Torrent Diagnóstico.",
"LIGHT_PARALLEL_JOBS": "Light parallel Tareas",
"LIGHT_TIMEOUT_SECONDS": "Light Timeout segundos",
"LIMIT_DL": "Límite DL",
"LOADING": "Cargando",
"LOADING_CLEANUP_DATA": "Cargando Limpieza Datos...",
"LOADING_CONFIG": "Cargando Configuración...",
"LOADING_IMAGE_URL": "Cargando image URL",
"LOADING_JOBS": "Cargando Tareas...",
"LOADING_LOGS": "Cargando Registros...",
"LOADING_PROFILES": "Cargando Perfiles...",
"LOADING_STATISTICS": "Cargando Estadísticas...",
"LOADING_TORRENTS": "Cargando Torrents...",
"LOADING_TORRENT_DETAILS": "Cargando Torrent Detalles...",
"LOCAL_BROWSER_TIME": "Local Navegador tiempo",
"LOCATION": "Ubicación",
"LOG": "Registro",
"LOGS": "Registros",
"LOG_IN": "Iniciar sesión",
"LOG_OUT": "Cerrar sesión",
"LOG_STATISTICS": "Registro Estadísticas",
"MAGNET_AND_TORRENT_UPLOAD_FILE_PRIORITIES_LABELS_RATIO_GROUPS_SMART_QUEUE_AUTOMA": "Magnet y Torrent Subida, Archivo prioridades, Etiquetas, Ratio Grupos, Smart Queue, automation Reglas, RSS, tráfico gráficos, Puerto comprobador, system Estado.",
"MAGNET_LINKS": "Magnet links",
"MANAGE_EXCEPTIONS": "Manage exceptions",
"MANAGE_OPERATION_LOG_RETENTION_WITHOUT_CHANGING_TORRENT_DATA": "Manage operation Registro Retención sin Cambio Torrent Datos.",
"MANAGE_OPTIONAL_PYTORRENT_USERS_EMPTY_PROFILE_MEANS_ALL_PROFILES_R_O_BLOCKS_RTOR": "Manage opcional pyTorrent Usuarios. Empty Perfil means Todos Perfiles. R/O blocks rTorrent-Cambio Acciones; Full allows ellos.",
"MANUAL_CLEANUP_ONLY": "Manual Limpieza solo",
"MAXIMUM_HEAVY_JOBS_RUNNING_AT_ONCE_FOR_THIS_PROFILE_DEFAULT_5": "Máximo heavy Tareas running at once para este Perfil. Predeterminado: 5.",
"MAXIMUM_QUEUED_ACTIONS_RUNNING_AT_ONCE": "Máximo queued Acciones running at once.",
"MAXIMUM_STALLED_OVERFLOW_DOWNLOADS_SMART_QUEUE_MAY_STOP_IN_ONE_PASS": "Máximo atascadas/overflow Descargas Smart Queue may Detener en uno pass.",
"MAX_MB": "Max MB",
"MAX_RATIO": "Max Ratio",
"MAX_STOPS_PER_CHECK": "Max stops per Comprobar",
"MESSAGE": "Mensaje",
"MINUTES": "Minutos",
"MIN_MB": "Min MB",
"MIN_PEERS": "Min Peers",
"MIN_RATIO": "Min Ratio",
"MIN_SEEDS": "Min Seeds",
"MIN_SEED_MINUTES": "Min seed minutos",
"MIN_SPEED_KIB_S": "Min Velocidad KiB/s",
"MOBILE": "Móvil",
"MOBILE_COLUMNS": "Móvil Columnas",
"MOBILE_FILTER_GROUPS": "Móvil filtro Grupos",
"MOBILE_SIMPLE_MODE": "Móvil/simple mode",
"MOBILE_SORT_FILTERS": "Móvil sort filters",
"MODE": "Mode",
"MONITORED_PATHS": "Monitored rutas",
"MOVE": "Mover",
"MOVE_DATA": "Mover Datos",
"MOVE_DATA_FILES": "Mover Datos Archivos",
"MOVE_PATH": "Mover Ruta",
"MOVE_TO_PATH": "Mover a Ruta",
"MOVIES": "películas",
"MOVING": "Moving",
"M_MOVE": "M - Mover",
"NAME": "Nombre",
"NEGATE": "Negate",
"NEWLY_QUEUE_STARTED_TORRENTS_ARE_PROTECTED_FROM_STALLED_CLEANUP_DURING_THIS_WARM": "Recién Cola-iniciados Torrents son protegidos desde atascadas Limpieza durante este calentamiento-arriba.",
"NEW_LABEL": "Nuevo Etiqueta",
"NEXT_READY": "Siguiente: ready",
"NEXT_SMART_QUEUE_RUN": "Siguiente Smart Queue ejecución",
"NOTIFICATIONS": "Notificaciones",
"NOT_LOADED": "No cargado.",
"NO_CHANGES": "Sin cambios",
"NO_FILES_RETURNED_BY_RTORRENT": "Sin Archivos returned por rTorrent.",
"NO_FILES_SELECTED": "No hay archivos seleccionados.",
"NO_LABELS": "Sin Etiquetas.",
"NO_LABELS_SELECTED": "No hay etiquetas seleccionadas.",
"NO_PATH_LOADED": "Sin Ruta loaded.",
"NO_PEERS_RETURNED_BY_RTORRENT": "Sin Peers returned por rTorrent.",
"NO_RTORRENT_PROFILE_CONFIGURED": "Sin rTorrent Perfil configured.",
"NO_SAVED_LABELS": "Sin Guardado Etiquetas.",
"NO_TORRENTS": "Sin Torrents.",
"NO_TORRENTS_FOR_THIS_FILTER": "Sin Torrents para este filtro.",
"NO_TORRENTS_SELECTED": "No hay torrents seleccionados",
"NO_TORRENT_SELECTED": "No hay torrent seleccionado",
"NO_TRACKERS_RETURNED_BY_RTORRENT": "Sin Trackers returned por rTorrent.",
"OFF": "Off",
"OFFLINE": "sin conexión",
"ONE_PLACE_TO_CLEAR_LOGS_AND_ACTIVE_PROFILE_CACHES_PENDING_RUNNING_JOBS_RULES_SET": "Uno place a Limpiar Registros y Activo Perfil caches. Pendientes/running Tareas, Reglas, Ajustes y Torrents son preserved.",
"ONE_TRACKER_URL_PER_LINE": "Uno Tracker URL per line",
"ONLINE": "en línea",
"ONLY_SELECTED": "Solo seleccionado",
"OPEN_DOWNLOAD_PLANNER": "Abrir Descarga planner",
"OPEN_RTORRENT_FILES_MAX_OPEN_FILES": "Abrir rTorrent Archivos / max Abrir Archivos",
"OPEN_RTORRENT_HTTP_CONNECTIONS_MAX_HTTP_CONNECTIONS": "Abrir rTorrent HTTP connections / max HTTP connections",
"OPEN_RTORRENT_SOCKETS": "Abrir rTorrent sockets",
"OPEN_SOURCE": "Open source",
"OPEN_THIS_TAB_TO_LOAD_DIAGNOSTICS": "Abrir este pestaña a cargar Diagnóstico.",
"OPEN_THIS_TAB_TO_LOAD_STATISTICS": "Abrir este pestaña a cargar Estadísticas.",
"OPERATION_LOG_RETENTION": "Operation Registro Retención",
"OPTIONAL": "opcional",
"OPTIONAL_PEER_TABLE_HELPERS": "Opcional peer table helpers.",
"OPTIONAL_PRIVATE_SOURCE_TAG": "opcional Privado Fuente tag",
"OPTIONAL_VISUAL_EASTER_EGG_FOR_LOADING_STATES_AND_OCCASIONAL_BUTTON_CLICKS_DISAB": "Opcional visual easter egg para Cargando estados y ocasionales botón clics. Desactivado por Predeterminado.",
"PARALLEL_JOBS": "Parallel Tareas",
"PARENT_DIRECTORY": "Padre Directorio",
"PARTIAL_DETAILS_LOADED": "Partial Detalles loaded",
"PASSWORD": "Contraseña",
"PASSWORD_NEW_PASSWORD": "Contraseña / Nuevo Contraseña",
"PASTE_ONE_MAGNET_URI_PER_LINE": "Paste uno magnet URI per line.",
"PATH": "Ruta",
"PATH_CONTAINS": "Ruta contains",
"PATH_TEXT": "Ruta Texto",
"PATH_USED_FOR_SELECTED_MODE": "Ruta used para seleccionado mode",
"PAUSE": "Pausa",
"PAUSED": "Pausado",
"PEAK_S": "Peak S",
"PEAK_SPEED_UNAVAILABLE": "Peak Velocidad unavailable",
"PEERS": "Peers",
"PEERS_AUTO_REFRESH": "Peers auto Actualizar",
"PENDING_JOBS_OLDER_THAN_THIS_ARE_RESUBMITTED_IF_NO_WORKER_IS_CURRENTLY_HANDLING_": "Pendientes Tareas más antiguas que este son reenviadas si Sin worker está actualmente procesa ellos. Predeterminado: 900 segundos.",
"PENDING_RUNNING_DONE_FAILED_RETRY_AND_CANCEL_HISTORY": "Pendientes, running, hecho, fallido, Reintentar y Cancelar Historial.",
"PENDING_TIMEOUT_SECONDS": "Pendientes Timeout segundos",
"PIECE_SIZE": "Piece Tamaño",
"PLANNER": "Planner",
"POLISH": "Polaco",
"PORT": "Puerto",
"PORT_CHECKER": "Puerto comprobador",
"PORT_CHECK_DISABLED": "Puerto Comprobar Desactivado",
"PORT_UNKNOWN": "Puerto - unknown",
"POST_CHECK": "Post-Comprobar",
"PREFERENCES": "Preferencias",
"PREFERENCES_SAVED": "Preferencias Guardado",
"PRIORITY": "Prioridad",
"PRIVATE_TORRENT": "Privado Torrent",
"PROFILE_BACKUP": "Perfil copia de seguridad",
"PROFILE_BACKUP_NAME": "Perfil copia de seguridad Nombre",
"PROFILE_BACKUP_RESTORES_ONLY_THE_ACTIVE_PROFILE_CONTEXT_APPLICATION_BACKUP_RESTO": "Perfil copia de seguridad restores solo Activo Perfil contexto. Aplicación copia de seguridad restores global Aplicación Datos y está disponibles solo a administradores.",
"PROFILE_NAME": "Perfil Nombre",
"PROFILE_SCOPED_LOG_COUNTS_AND_CLEANUP_OVERVIEW": "Perfil-scoped Registro recuentos y Limpieza resumen.",
"PROGRESS": "Progreso",
"PROGRESS_IS_AT_LEAST": "Progreso está at least %",
"PROGRESS_IS_AT_MOST": "Progreso está at most %",
"PROGRESS_SOURCE": "Progreso Fuente",
"PROTECT_ACTIVE_COUNT_BELOW_CAP": "Protect Activo count below cap",
"PYTORRENT_EXPECTS_TRUSTED_REVERSE_PROXY_IDENTITY_HEADERS_IF_YOU_ARE_ALREADY_SIGN": "pyTorrent expects trusted reverse-proxy identity headers. Si usted son already signed en, Comprobar provider headers y Usuario mapping.",
"PYTORRENT_LOGIN": "pyTorrent login",
"PYTORRENT_STATUS": "pyTorrent Estado",
"P_PAUSE": "P - Pausa",
"QUALITY": "Calidad",
"QUEUE_REFILL_DURING_COOLDOWN": "Cola refill durante Espera",
"RANDOM_CLICK_IMAGE_URL": "Random click image URL",
"RATIO": "Ratio",
"RATIO_GROUP": "Ratio Grupo",
"RATIO_GROUPS": "Ratio Grupos",
"RATIO_IS_AT_LEAST": "Ratio está at least",
"RATIO_RULES": "Ratio Reglas",
"REANNOUNCE": "Reannounce",
"RECHECK": "Recomprobar",
"RECHECK_AFTER_MOVE": "Recomprobar después Mover",
"RECOMMENDED_COLUMNS": "Recomendado Columnas",
"RECOMMENDED_COLUMNS_APPLIED": "Recomendado Columnas aplicado",
"RECONNECTING": "reconnecting",
"REFERENCE_VALUE_IS_KEPT_FROM_THE_FIRST_OVERRIDE_SAVE_LATER_SAVES_ADD_OR_CLEAR_DI": "Reference valor está kept desde Primero override Guardar. Later saves Añadir o Limpiar differences sin replacing original reference.",
"REFRESH": "Actualizar",
"REFRESH_NOW": "Actualizar now",
"REGEX_TEXT": "Regex / Texto",
"RELOAD": "Reload",
"REMOTE_LOCATION": "Remoto Ubicación",
"REMOVE": "Quitar",
"REMOVE_DATA": "Quitar Datos",
"REMOVE_LABEL": "Quitar Etiqueta",
"REMOVE_SELECTED_TORRENTS": "Quitar seleccionado Torrents",
"REMOVE_WITH_DATA": "Quitar con Datos",
"REPOSITORY": "Repositorio",
"RESET": "Restablecer",
"RESET_UI_SETTINGS": "Restablecer UI Ajustes",
"RESET_VIEW_DEFAULTS": "Restablecer view defaults",
"RESIZE_TORRENT_DETAILS_PANEL": "Resize Torrent Detalles Panel",
"RESOLVE_PEER_IP_TO_REVERSE_DNS_HOST": "Resolve peer IP a reverse DNS host",
"RESUME": "Reanudar",
"RETENTION_DAYS": "Retención días",
"RETENTION_MODE": "Retención mode",
"RETRY": "Reintentar",
"REVERSE_DNS": "Reverse DNS",
"RSS_DOWNLOADER": "RSS downloader",
"RTORRENT": "rTorrent",
"RTORRENTS": "rTorrents",
"RTORRENT_CONFIG": "rTorrent Configuración",
"RTORRENT_INCOMING_PORT": "rTorrent incoming Puerto",
"RTORRENT_IS_STARTING_OR_NOT_RESPONDING_YET": "rTorrent está starting o no responding yet.",
"RTORRENT_PROFILES": "rTorrent Perfiles",
"RULE": "Regla",
"RULES": "Reglas",
"RULES_ARE_CHECKED_AUTOMATICALLY_EVERY_5_MINUTES_A_TORRENT_USES_THE_GROUP_STORED_": "Reglas son comprobados automatically every 5 minutos. Torrent uses Grupo stored en its rTorrent Personalizado Ratio field.",
"RULE_NAME": "Regla Nombre",
"RUNTIME": "Runtime",
"RUN_A_PROFILE_TEST_TO_SHOW_DIAGNOSTICS": "Ejecución Perfil Prueba a Mostrar Diagnóstico.",
"RUN_SMART_QUEUE_DURING_POLLING_STOPPED_TORRENTS_ARE_MANAGED_PAUSED_TORRENTS_STAY": "Ejecución Smart Queue durante polling. Detenido Torrents son gestionados; Pausado Torrents stay Usuario-controlled.",
"R_RESUME": "R - Reanudar",
"SAVE": "Guardar",
"SAVED_LABELS": "Guardado Etiquetas",
"SAVE_COLUMNS": "Guardar Columnas",
"SAVE_CONFIG": "Guardar Configuración",
"SAVE_EASTER_EGG": "Guardar easter egg",
"SAVE_EXCEPTIONS": "Guardar exceptions",
"SAVE_FEED": "Guardar feed",
"SAVE_FOOTER": "Guardar Pie",
"SAVE_GROUP": "Guardar Grupo",
"SAVE_JOB_SETTINGS": "Guardar Tarea Ajustes",
"SAVE_LIMITS": "Guardar Límites",
"SAVE_LOCATION": "Guardar Ubicación",
"SAVE_LOG_VIEW": "Guardar Registro view",
"SAVE_PATH": "Guardar Ruta",
"SAVE_PREFERENCES": "Guardar Preferencias",
"SAVE_RETENTION": "Guardar Retención",
"SAVE_RULE": "Guardar Regla",
"SAVE_SCHEDULE": "Guardar programación",
"SAVE_USER": "Guardar Usuario",
"SCGI_URL": "SCGI URL",
"SEARCH_LOGS": "Buscar Registros...",
"SEARCH_TORRENTS": "Buscar Torrents...",
"SEARCH_TORRENTS_TO_EXCLUDE": "Buscar Torrents a Excluir...",
"SEASON": "Temporada",
"SECONDS_TO_WAIT_FOR_RTORRENT_RESPONSE": "Segundos a esperar para rTorrent respuesta.",
"SEEDING": "Compartiendo",
"SEEDS": "Seeds",
"SEEDS_ARE_LOW_FOR_TIME": "Seeds son bajo para tiempo",
"SEED_MINUTES": "Seed minutos",
"SELECT": "Seleccionar",
"SELECTED": "seleccionado",
"SELECTED_LABELS": "Seleccionado Etiquetas",
"SELECTED_MONITORED_PATH": "Seleccionado monitored Ruta",
"SELECTED_TORRENTS_WILL_BE_REMOVED": "seleccionado Torrents será estar removed.",
"SELECT_A_MONITORED_PATH_FIRST": "Seleccionar monitored Ruta Primero.",
"SELECT_A_TORRENT": "Seleccionar Torrent.",
"SELECT_ONE_OR_MORE_TORRENT_FILES": "Seleccionar uno o more .Torrent Archivos.",
"SELECT_PATH": "Seleccionar Ruta",
"SELECT_TORRENTS_THAT_SMART_QUEUE_SHOULD_IGNORE_USE_SEARCH_TO_FILTER_BY_NAME_LABE": "Seleccionar Torrents que Smart Queue debe ignorar. Usar Buscar a filtro por Nombre, Etiqueta, Estado o Hash.",
"SELECT_VISIBLE": "Seleccionar visible",
"SEPARATE_SLOT_POOL_FOR_LIGHTWEIGHT_CONTROL_JOBS_SO_THEY_DO_NOT_WAIT_BEHIND_HEAVY": "Separate slot pool para lightweight control Tareas so they do no esperar detrás heavy IO trabajo. Predeterminado: 4.",
"SETTINGS": "Ajustes",
"SET_LABEL": "Set Etiqueta...",
"SET_LABELS": "Set Etiquetas",
"SET_RATIO_GROUP": "Set Ratio Grupo...",
"SHARE_AFTER_CREATING": "Share después creating",
"SHORTCUTS": "Shortcuts",
"SHOWN": "Shown",
"SHOWN_IN_LOADING_STATES_INSTEAD_OF_THE_STANDARD_SPINNER": "Shown en Cargando estados en lugar de de estándar spinner.",
"SHOWS_TRACKER_ICONS_IN_THE_SIDEBAR_TRACKER_FILTER_WHEN_AVAILABLE": "Muestra Tracker iconos en barra lateral Tracker filtro when disponibles.",
"SHOW_COMBINED_USAGE_SINGLE_PATH_SELECTION_IS_DISABLED_IN_THIS_MODE": "Mostrar combinada usage. Única Ruta selection está Desactivado en este mode.",
"SHOW_DL_UP_IN_BROWSER_TITLE": "Mostrar DL/UP en Navegador Título",
"SHOW_SMART_QUEUE_AUTOMATIC_RUN_MESSAGES": "Mostrar Smart Queue Automático ejecución mensajes.",
"SHOW_TOASTS_CREATED_BY_AUTOMATION_RUNS": "Mostrar toasts created por automation runs.",
"SIGN_IN": "Iniciar sesión",
"SIZE": "Tamaño",
"SKIP_ACTIVE_UPLOAD": "Skip Activo Subida",
"SKIP_PRIVATE_TORRENTS": "Skip Privado Torrents",
"SMART_FILTERS": "Smart filters",
"SMART_QUEUE": "Smart Queue",
"SMART_QUEUE_EXCEPTIONS": "Smart Queue exceptions",
"SMART_QUEUE_KEEPS_ONLY_THIS_MANY_ACTIVE_DOWNLOADS_OVERFLOW_IS_STOPPED": "Smart Queue keeps solo este many Activo Descargas; overflow está Detenido.",
"SMART_QUEUE_TOASTS": "Smart Queue toasts",
"SOCKETS": "Sockets",
"SOURCE": "Fuente",
"SPACE_START": "Space - Iniciar",
"SPEED": "Velocidad",
"SPEED_LIMITS": "Velocidad Límites",
"SPEED_TREND": "Velocidad trend",
"STALLED_AFTER_SECONDS": "Atascadas después segundos",
"START": "Iniciar",
"START_AFTER_ADD": "Iniciar después Añadir",
"START_GRACE_SECONDS": "Iniciar grace segundos",
"STATE": "Estado",
"STATUS": "Estado",
"STATUS_EQUALS": "Estado equals",
"STOP": "Detener",
"STOPPED": "Detenido",
"S_STOP": "S - Detener",
"TARGET_ACTIVE_DOWNLOADS": "Objetivo Activo Descargas",
"TARGET_PATH": "Objetivo Ruta",
"TEST_RULE": "Prueba Regla",
"TEST_SCGI": "Prueba SCGI",
"THEME_TYPOGRAPHY_AND_INTERFACE_SCALE": "Tema, typography y interface scale.",
"THE_FOOTER_TOOLTIP_ALWAYS_SHOWS_DETAILS_FOR_AVAILABLE_PATHS_THIS_SETTING_ONLY_DE": "Pie tooltip siempre muestra Detalles para disponibles rutas; este ajuste solo decide qué valor controla visible Progreso barra.",
"TIMEOUT": "Timeout",
"TOAST_NOTIFICATIONS_FROM_AUTOMATIC_SYSTEMS": "Toast Notificaciones desde Automático systems.",
"TOOLS": "Herramientas",
"TOOLS_RTORRENTS": "Herramientas & rTorrents",
"TOOLS_SECTIONS": "Herramientas sections",
"TORRENT_ADDED": "Torrent added",
"TORRENT_COMPLETED": "Torrent Completado",
"TORRENT_DETAILS": "Torrent Detalles",
"TORRENT_FILES": "Torrent Archivos",
"TORRENT_FILTERS": "Torrent filters",
"TORRENT_PROPERTIES": "Torrent properties",
"TORRENT_REMOVED": "Torrent removed",
"TORRENT_STATISTICS": "Torrent Estadísticas",
"TORRENT_STATS": "Torrent stats",
"TOTAL_DL_UP": "Total DL/UP",
"TO_DOWNLOAD": "A Descarga",
"TRACKERS": "Trackers",
"TRACKER_ICONS": "Tracker iconos",
"TRANSFER": "Transfer",
"TRANSFERRED_DATA": "Transferred Datos",
"TRANSFER_HISTORY": "Transfer Historial",
"UNDEFINED": "undefined",
"UNLIMITED": "Unlimited",
"UPLOADED": "Subido",
"UPLOADS": "Subidas",
"UPLOAD_KIB_S": "Subida KiB/s",
"USER": "Usuario",
"USERS": "Usuarios",
"USES_A_LIGHTWEIGHT_BUILT_IN_RESOLVER_WITH_CACHE_HOSTNAMES_APPEAR_ONLY_IN_THE_PEE": "Uses lightweight built-en resolvedor con caché. Nombres de host appear solo en Peers pestaña.",
"USES_LOWER_ROWS_AND_SMALLER_LIST_ELEMENTS_ON_DESKTOP_AND_MOBILE_SO_MORE_TORRENTS": "Uses más bajas filas y smaller lista elements on Escritorio y Móvil so more Torrents caben on pantalla.",
"USES_YOUGETSIGNAL_FIRST_MANUAL_CHECK_BYPASSES_THE_6H_CACHE": "Uses YouGetSignal Primero. Manual Comprobar bypasses 6h caché.",
"USE_ONE_CUSTOM_PATH_BELOW_AS_THE_FOOTER_PROGRESS_VALUE": "Usar uno Personalizado Ruta below como Pie Progreso valor.",
"USE_SELECTED": "Usar seleccionado",
"USE_THE_MAIN_DIRECTORY_FROM_THE_ACTIVE_RTORRENT_PROFILE": "Usar main Directorio desde Activo rTorrent Perfil.",
"VIEW_PREFERENCES_RESET": "View Preferencias Restablecer",
"VIEW_STATE_IS_SAVED_AUTOMATICALLY_IN_THE_DATABASE_CURRENT_TORRENT_FILTER_LAST_SO": "View Estado está Guardado automatically en database: Actual Torrent filtro, Último sort column y direction, visible Columnas, y Detalles Panel height.",
"VISIBLE_NAME_USED_IN_THE_PROFILE_SELECTOR": "Visible Nombre used en Perfil selector.",
"VISUAL_HELPER_FOR_TRACKER_FILTERS_IN_THE_SIDEBAR": "Visual ayudante para Tracker filters en barra lateral.",
"WAITING_FOR_DATA": "Esperando para Datos.",
"WAITING_FOR_TORRENT_DATA_FROM_THE_ACTIVE_PROFILE": "Esperando para Torrent Datos desde Activo Perfil.",
"WATCHDOG_MARKS_A_LIGHT_JOB_AS_FAILED_AFTER_THIS_TIME_DEFAULT_300_SECONDS": "Watchdog marks light Tarea como fallido después este tiempo. Predeterminado: 300 segundos.",
"WATCHDOG_TIMEOUT_FOR_MOVE_REMOVE_ADD_JOBS_DEFAULT_7200_SECONDS": "Watchdog Timeout para Mover/Quitar/Añadir Tareas. Predeterminado: 7200 segundos.",
"WHEN_DISABLED_THE_APPLICATION_USES_THE_NORMAL_PRODUCTION_UI": "When Desactivado, Aplicación uses normal producción UI.",
"WHEN_ENABLED_LOW_SPEED_IS_NOT_REQUIRED_WITH_SOURCE_AND_SPEED_IGNORES_ENABLED_ONL": "When Activado, bajo Velocidad está no required. Con Fuente y Velocidad ignores Activado, solo Atascadas después segundos decide.",
"WHEN_ENABLED_SMART_QUEUE_DOES_NOT_USE_SEED_PEER_COUNT_AS_A_STALLED_CRITERION": "When Activado, Smart Queue does no usar seed/peer count como atascadas criterio.",
"WITH_ERROR": "Con Error",
"WORKING": "Trabajando...",
"FRENCH": "Francés",
"CZECH": "Checo",
"SPANISH": "Español",
"NORWEGIAN": "Noruego",
"RUSSIAN": "Ruso"
}
}

View File

@@ -0,0 +1,566 @@
{
"meta": {
"locale": "fr_FR",
"label": "Français",
"flag": "fr"
},
"translations": {
"0_MEANS_ONLY_SEED_THRESHOLD_IS_REQUIRED": "0 means seulement seed threshold est required.",
"0_MEANS_UNLIMITED_SLIDERS_USE_MBIT_S_AND_SAVE_THROUGH_THE_EXISTING_SPEED_LIMITS_": "0 means unlimited. Sliders utiliser Mbit/s et Enregistrer through existing Vitesse Limites API.",
"0_SELECTED": "0 sélectionné",
"ABOUT_PYTORRENT": "About pyTorrent",
"ACTION": "Action",
"ACTIVE": "Actif",
"ACTIVE_RTORRENT_DOWNLOADS_MAX_GLOBAL_DOWNLOADS": "Actif rTorrent Téléchargements / max global Téléchargements",
"ACTIVE_RTORRENT_UPLOADS_MAX_GLOBAL_UPLOADS": "Actif rTorrent Envois / max global Envois",
"ADD": "Ajouter",
"ADDED": "Added",
"ADDS_REMOVALS_COMPLETIONS_AND_QUEUED_OPERATION_RESULTS": "Adds, removals, completions et queued operation results.",
"ADD_ACTION": "Ajouter Action",
"ADD_CONDITION": "Ajouter Condition",
"ADD_CREATE_TORRENT": "Ajouter / Créer Torrent",
"ADD_FIRST_LABEL_ABOVE": "Ajouter Premier Étiquette above.",
"ADD_LABEL": "Ajouter Étiquette",
"ADD_MAGNET_LINK": "Ajouter magnet link",
"ADD_NEW_LABEL": "Ajouter Nouveau Étiquette",
"ADD_OR_EDIT_GROUP": "Ajouter ou Modifier Groupe",
"ADD_PATH": "Ajouter Chemin",
"ADD_PROFILE": "Ajouter Profil",
"ADD_RTORRENT_PROFILE": "Ajouter rTorrent Profil",
"ADD_THE_FIRST_RTORRENT_PROFILE_TO_START_LOADING_TORRENTS": "Ajouter Premier rTorrent Profil à Démarrer Chargement Torrents.",
"ADD_TORRENT": "Ajouter Torrent",
"ADD_TORRENT_FILE": "Ajouter Torrent Fichier",
"ADMIN": "Administrateur",
"ADMIN_ONLY_FULL_APPLICATION_BACKUP_RESTORE_CAN_REPLACE_USERS_PERMISSIONS_PROFILE": "Administrateur-seulement full Application sauvegarde. Restaurer peut replace Utilisateurs, permissions, Profils et global Application Paramètres.",
"AGGREGATE_ALL_PATHS": "Aggregate Tous chemins",
"ALL": "Tous",
"ALL_NON_JOB_TYPES": "Tous non-Tâche types",
"ALL_PROFILES": "Tous Profils",
"ALL_TYPES": "Tous types",
"ANNOUNCE": "Announce",
"API_DOCS": "API docs",
"APPEARANCE": "Apparence",
"APPEARANCE_PREFERENCES_SAVED": "Apparence Préférences Enregistré",
"APPEARS_NEAR_CLICKED_BUTTONS_ONLY_SOMETIMES": "Appears near clicked buttons seulement sometimes.",
"APPLICATION_BACKUP": "Application sauvegarde",
"APPLICATION_BACKUP_NAME": "Application sauvegarde Nom",
"APPLY": "Apply",
"APPLY_RETENTION_NOW": "Apply Rétention now",
"APPLY_SAVED_CHANGES_60S_AFTER_PYTORRENT_START": "Apply Enregistré changes 60s après pyTorrent Démarrer",
"APP_STATUS": "App État",
"AUTHENTICATION_IS_ENABLED_FOR_THIS_PYTORRENT_INSTANCE": "Authentication est Activé pour ce pyTorrent instance.",
"AUTHOR": "Auteur",
"AUTOMATIC": "Automatique",
"AUTOMATIC_KEEPS_THE_CURRENT_POLLER_CADENCE_CUSTOM_RUNS_ONLY_AFTER_THE_SELECTED_N": "Automatique keeps Actuel poller cadence. Personnalisé runs seulement après sélectionné number de minutes. Off disables refill completely.",
"AUTOMATIC_QUEUE_BALANCING_FOR_SLOW_OR_STALLED_DOWNLOADS": "Automatique File équilibrage pour lents ou bloqués Téléchargements.",
"AUTOMATIC_RUNS_USE_THE_COOLDOWN_BELOW_MANUAL_CHECK_NOW_STILL_RUNS_IMMEDIATELY": "Automatique runs utiliser Délai below. Manuel Vérifier now still runs immediately.",
"AUTOMATIONS": "Automations",
"AUTOMATIONS_RULES": "Automations / Règles",
"AUTOMATION_TOASTS": "Automation toasts",
"AUTO_STOP_WHEN_IDLE": "Auto-Arrêter when idle",
"BACKEND": "Backend",
"BACKUP": "Sauvegarde",
"BACKUP_RESTORE": "Sauvegarde / Restaurer",
"BACK_TO_DASHBOARD": "Back à dashboard",
"BOOTSTRAP_THEME": "Bootstrap Thème",
"BROWSER": "Navigateur",
"BROWSER_TITLE": "Navigateur Titre",
"BUILD_A_RULE_AS_CONDITIONS_FIRST_THEN_ORDERED_ACTIONS_MATCHING_TORRENTS_ARE_HAND": "Build Règle comme: Conditions Premier, puis ordered Actions. Matching Torrents sont handled comme un batch et Délai est appliqué à whole Règle.",
"BUSY": "busy",
"BY_DAYS": "Par jours",
"BY_LINE_COUNT": "Par line count",
"CACHED_METADATA_SUMMARY_FILE_METADATA_IS_REFRESHED_EVERY_15_MINUTES_A_FEW_MINUTE": "Cached metadata summary. Fichier metadata est refreshed every 15 minutes, quelques minutes après démarrage, ou manually.",
"CANCEL": "Annuler",
"CANCEL_EDIT": "Annuler Modifier",
"CATEGORY": "Catégorie",
"CHANGES_APPLY_IMMEDIATELY_WHERE_POSSIBLE_INITIAL_STARTUP_LOADER_USES_THEM_AFTER_": "Changes apply immediately where possible; initial démarrage loader uses les après reload.",
"CHANGE_RTORRENT": "Changer rTorrent",
"CHANGE_THEME": "Changer Thème",
"CHANGING_RTORRENT_RELOADS_THE_LIVE_TORRENT_SNAPSHOT": "Changement rTorrent reloads live Torrent instantané.",
"CHECKING": "Vérification",
"CHECK_NOW": "Vérifier maintenant",
"CHECK_PORT_NOW": "Vérifier le port maintenant",
"CHECK_THIS_IF_YOU_WANT_TO_CONNECT_TO_A_REMOTE_RTORRENT_INSTANCE_INSTEAD_OF_LOCAL": "Vérifier ce si vous voulez à connecter à Distant rTorrent instance au lieu de de localhost.",
"CHOOSE_COLUMNS_VISIBLE_IN_THE_TORRENT_LIST": "Choisir Colonnes visible dans Torrent liste.",
"CHOOSE_FILES": "Choisir Fichiers",
"CHOOSE_RTORRENT": "Choisir rTorrent",
"CHOOSE_TORRENTS_IGNORED_BY_SMART_QUEUE_EXISTING_BEHAVIOR_STAYS_UNCHANGED_FOR_ALL": "Choisir Torrents ignored par Smart Queue. Existing behavior stays unchanged pour Tous non-excluded Torrents.",
"CHOOSE_WHAT_THE_FOOTER_DISK_BAR_SHOULD_REPRESENT_AND_ADD_EXTRA_STORAGE_PATHS": "Choisir what Pied de page disk barre doit represent et Ajouter extra storage chemins.",
"CHOOSE_WHICH_STATUS_ITEMS_ARE_VISIBLE_IN_THE_BOTTOM_BAR": "Choisir quelle État items sont visible dans bottom barre.",
"CHUNKS": "Chunks",
"CLEANUP": "Nettoyage",
"CLEANUP_RETENTION": "Nettoyage / Rétention",
"CLEAR": "Effacer",
"CLEAR_CURRENT_FILTER": "Effacer Actuel filtre",
"CLEAR_FINISHED": "Effacer finished",
"CLEAR_LABELS": "Effacer Étiquettes",
"CLEAR_SELECTION": "Effacer selection",
"CLEAR_VISIBLE": "Effacer visible",
"CLOSE": "Fermer",
"COLUMNS": "Colonnes",
"COLUMNS_SAVED": "Colonnes Enregistré",
"COMMENT": "Comment",
"COMPACT_TORRENT_LIST": "Compact Torrent liste",
"COMPLETE": "Complete",
"COMPLETED": "Terminé",
"CONFIGURED_RTORRENTS": "Configured rTorrents",
"CONNECTING_TO_RTORRENT_AND_PREPARING_DATA": "Connecting à rTorrent et preparing Données.",
"CONNECTION_ADDRESS_IN_SCGI_HOST_PORT_RPC2_FORMAT": "Connexion address dans scgi://host:Port/RPC2 format.",
"CONTROLS_THE_DEFAULT_CATEGORY_AND_JOB_LOG_VISIBILITY_USED_BY_THE_LOGS_MODAL": "Controls Défaut Catégorie et Tâche Journal visibility used par Journaux fenêtre.",
"CONTROLS_WHAT_IS_SHOWN_IN_THE_BROWSER_TAB": "Controls what est shown dans Navigateur onglet.",
"COOLDOWN_MINUTES": "Délai minutes",
"COPY": "Copier",
"COPY_HASH": "Copier Hash",
"COPY_NAME": "Copier Nom",
"COPY_PATH": "Copier Chemin",
"CPU_RAM_USAGE": "CPU / RAM usage",
"CREATE": "Créer",
"CREATES_AND_RESTORES_SETTINGS_FOR_THE_CURRENTLY_SELECTED_PROFILE_USER_SCOPED_PRE": "Creates et restores Paramètres pour actuellement sélectionné Profil. Utilisateur-scoped Préférences sont remapped à Actuel Utilisateur where needed.",
"CREATE_APPLICATION_BACKUP": "Créer Application sauvegarde",
"CREATE_ONE_RTORRENT_PROFILE_AT_A_TIME_MOVE_REMOVE_QUEUES_KEEP_THEIR_ORDER_FOR_EA": "Créer un rTorrent Profil at temps. Déplacer/Supprimer queues keep leur order pour chaque Profil.",
"CREATE_PROFILE_BACKUP": "Créer Profil sauvegarde",
"CREATE_REUSABLE_LABELS_AND_REMOVE_LABELS_THAT_ARE_NO_LONGER_NEEDED": "Créer réutilisables Étiquettes et Supprimer Étiquettes que sont Aucun plus longtemps needed.",
"CREATE_TORRENT": "Créer Torrent",
"CTRL_A_SELECT_VISIBLE": "Ctrl+ - Sélectionner visible",
"CTRL_I_INVERT_VISIBLE": "Ctrl+I - invert visible",
"CTRL_O_ADD": "Ctrl+O - Ajouter",
"CTRL_S_DOWNLOAD_TORRENT": "Ctrl+S - Téléchargement .Torrent",
"CURRENT_TRANSFER_SPEED": "Actuel transfer Vitesse",
"CUSTOM_DOWNLOAD": "Personnalisé Téléchargement",
"CUSTOM_UPLOAD": "Personnalisé Envoi",
"DAYS_AND_LINE_COUNT": "Jours et line count",
"DECREASE_OR_INCREASE_THE_WHOLE_INTERFACE_SIZE": "Decrease ou increase whole interface Taille.",
"DEFAULT_LOG_CATEGORY": "Défaut Journal Catégorie",
"DEFAULT_LOG_VIEW": "Défaut Journal view",
"DEFAULT_RTORRENT_PATH": "Défaut rTorrent Chemin",
"DEGRADED": "degraded",
"DELETE": "Supprimer",
"DELETE_REMOVE": "Supprimer - Supprimer",
"DESKTOP": "Bureau",
"DETAILS_WILL_APPEAR_AFTER_THE_FIRST_SUCCESSFUL_RESPONSE": "Détails sera appear après Premier réussie réponse.",
"DIAGNOSTICS": "Diagnostics",
"DISABLED": "Désactivé",
"DISK_MONITOR": "Disk monitor",
"DISK_USAGE_UNAVAILABLE": "Disk usage unavailable",
"DISPLAYS_CURRENT_SPEEDS_NEXT_TO_PYTORRENT_IN_THE_TAB_TITLE": "Displays Actuel speeds Suivant à pyTorrent dans onglet Titre.",
"DOCS_API": "Docs API",
"DOWNLOADED": "Téléchargé",
"DOWNLOADING": "Téléchargement",
"DOWNLOADS": "Téléchargements",
"DOWNLOAD_KIB_S": "Téléchargement KiB/s",
"DOWNLOAD_STARTED": "Téléchargement démarrés",
"DOWNLOAD_TORRENT": "Téléchargement .Torrent",
"DOWNLOAD_TRACKER_FAVICONS": "Téléchargement Tracker favicons",
"DRAG_TO_RESIZE_DETAILS_PANEL": "Drag à resize Détails Panneau",
"EASTER_EGG": "Easter egg",
"EDIT": "Modifier",
"EMERGENCY_CANCEL": "Emergency Annuler",
"EMERGENCY_CLEAN_ALL": "Emergency clean Tous",
"ENABLED": "Activé",
"ENABLE_AUTOMATIC_APPLICATION_BACKUPS": "Activer Automatique Application sauvegardes",
"ENABLE_AUTOMATIC_PROFILE_BACKUPS": "Activer Automatique Profil sauvegardes",
"ENABLE_EASTER_EGG": "Activer easter egg",
"ENABLE_INCOMING_PORT_CHECK": "Activer incoming Port Vérifier",
"ENABLE_REVERSE_DNS_FOR_PEERS": "Activer reverse DNS pour Pairs",
"ENGLISH": "Anglais",
"EPISODE": "Épisode",
"ESC_CLEAR_SELECTION": "Esc - Effacer selection",
"ETA": "ETA",
"EVERY_N_MINUTES": "Every N minutes",
"EVERY_X_HOURS": "Every X hours",
"EXCLUDE": "Exclure",
"EXCLUDE_FROM_SMART_QUEUE": "Exclure depuis Smart Queue",
"EXCLUDE_PATTERN": "Exclure Motif",
"EXISTING_GROUPS": "Existing Groupes",
"EXPORT": "Exporter",
"EXPORT_JSON": "Exporter JSON",
"EXTERNAL_AUTHENTICATION_IS_ENABLED_THROUGH_EXTERNAL_PROVIDER": "External authentication est Activé through {{ external_provider }}.",
"E_G_PUNKTY_KATALOGOWANIE": "e.g. Punkty katalogowanie",
"FEATURES": "Fonctions",
"FEED": "Feed",
"FEEDS_ARE_CHECKED_BY_SCHEDULE_AND_EVERY_MATCH_IS_LOGGED_PER_FEED_RULE": "Flux sont vérifiés par calendrier et every correspondance est journalisée per feed/Règle.",
"FEEDS_RULES_AND_MATCHES": "Flux, Règles et correspondances",
"FEED_NAME": "Feed Nom",
"FEED_URL": "Feed URL",
"FILES": "Fichiers",
"FILE_OR_DIRECTORY_PATH": "Fichier ou Répertoire Chemin",
"FONT": "Police",
"FOOTER": "Pied de page",
"FORCE": "Forcer",
"FORCE_RECHECK": "Forcer Revérifier",
"FRONTEND": "Frontend",
"FULL": "Full",
"GENERAL": "Général",
"GENERATED_RTORRENT_CONFIG_CHANGES_WILL_APPEAR_HERE": "Généré rTorrent Configuration changes sera appear here.",
"GENERATE_CONFIG": "Generate Configuration",
"GERMAN": "Allemand",
"GO": "Go",
"GROUPED_RTORRENT_RUNTIME_SETTINGS_WITH_INLINE_RECOMMENDATIONS_AND_COMPATIBILITY_": "Groupés rTorrent Runtime Paramètres avec inline recommandations et compatibilité État.",
"GROUP_NAME": "Groupe Nom",
"HASH": "Hash",
"HASHING": "Hashing",
"HEAVY_PARALLEL_JOBS": "Heavy parallel Tâches",
"HEAVY_TIMEOUT_SECONDS": "Heavy Timeout secondes",
"HIDE_JOB_LOGS": "Masquer Tâche Journaux",
"HIDE_JOB_LOGS_BY_DEFAULT": "Masquer Tâche Journaux par Défaut",
"HISTORY": "Historique",
"HOW_LONG_A_MATCHING_ACTIVE_TORRENT_MUST_STAY_STALLED_BEFORE_IT_CAN_BE_REPLACED": "Combien longtemps matching Actif Torrent doit stay bloqués avant it peut être remplacé.",
"IGNORE_MISSING_SEEDS_PEERS_FOR_STALLED_TIMER": "Ignorer missing Seeds/Pairs pour bloqués timer",
"IGNORE_SPEED_FOR_STALLED_TIMER": "Ignorer Vitesse pour bloqués timer",
"IMPORT": "Importer",
"IMPORT_JSON": "Importer JSON",
"INCLUDE_PATTERN": "Inclure Motif",
"INCOMING_CONNECTION_TEST_SEPARATE_FROM_VISUAL_PREFERENCES": "Incoming Connexion Test, separate depuis visual Préférences.",
"INTERFACE_SCALE": "Interface scale",
"INTERVAL_MINUTES": "Intervalle minutes",
"INVERT_VISIBLE": "invert visible",
"JOBS": "Tâches",
"JOB_DONE": "Tâche terminé",
"JOB_FAILED": "Tâche échoué",
"JOB_QUEUE": "Tâche File",
"JOB_SCHEDULING": "Tâche scheduling",
"JOB_STARTED": "Tâche démarrés",
"KEEP_LINES": "Keep lines",
"KEEP_SEEDING": "Keep Partage",
"LABEL": "Étiquette",
"LABELS": "Étiquettes",
"LABELS_SEPARATED_BY_COMMA": "Étiquettes separated par comma",
"LABEL_AFTER_SHARE": "Étiquette après share",
"LABEL_EXISTS": "Étiquette exists",
"LABEL_IS_MISSING": "Étiquette est missing",
"LABEL_NAME_OR_SEVERAL_SEPARATED_BY_COMMA": "Étiquette Nom, ou plusieurs separated par comma",
"LANGUAGE": "Langue",
"LANGUAGE_SAVED": "Langue Enregistré",
"LAST_OPERATIONS": "Dernier operations",
"LICENSE": "Licence",
"LIGHTWEIGHT_WEB_PANEL_FOR_RTORRENT_MANAGEMENT_QUEUE_CONTROL_AND_LIVE_TORRENT_DIA": "Lightweight web Panneau pour rTorrent management, File control et live Torrent Diagnostics.",
"LIGHT_PARALLEL_JOBS": "Light parallel Tâches",
"LIGHT_TIMEOUT_SECONDS": "Light Timeout secondes",
"LIMIT_DL": "Limite DL",
"LOADING": "Chargement",
"LOADING_CLEANUP_DATA": "Chargement Nettoyage Données...",
"LOADING_CONFIG": "Chargement Configuration...",
"LOADING_IMAGE_URL": "Chargement image URL",
"LOADING_JOBS": "Chargement Tâches...",
"LOADING_LOGS": "Chargement Journaux...",
"LOADING_PROFILES": "Chargement Profils...",
"LOADING_STATISTICS": "Chargement Statistiques...",
"LOADING_TORRENTS": "Chargement Torrents...",
"LOADING_TORRENT_DETAILS": "Chargement Torrent Détails...",
"LOCAL_BROWSER_TIME": "Local Navigateur temps",
"LOCATION": "Emplacement",
"LOG": "Journal",
"LOGS": "Journaux",
"LOG_IN": "Connexion",
"LOG_OUT": "Déconnexion",
"LOG_STATISTICS": "Journal Statistiques",
"MAGNET_AND_TORRENT_UPLOAD_FILE_PRIORITIES_LABELS_RATIO_GROUPS_SMART_QUEUE_AUTOMA": "Magnet et Torrent Envoi, Fichier priorités, Étiquettes, Ratio Groupes, Smart Queue, automation Règles, RSS, trafic graphiques, Port vérificateur, system État.",
"MAGNET_LINKS": "Magnet links",
"MANAGE_EXCEPTIONS": "Manage exceptions",
"MANAGE_OPERATION_LOG_RETENTION_WITHOUT_CHANGING_TORRENT_DATA": "Manage operation Journal Rétention sans Changement Torrent Données.",
"MANAGE_OPTIONAL_PYTORRENT_USERS_EMPTY_PROFILE_MEANS_ALL_PROFILES_R_O_BLOCKS_RTOR": "Manage optionnel pyTorrent Utilisateurs. Empty Profil means Tous Profils. R/O blocks rTorrent-Changement Actions; Full allows les.",
"MANUAL_CLEANUP_ONLY": "Manuel Nettoyage seulement",
"MAXIMUM_HEAVY_JOBS_RUNNING_AT_ONCE_FOR_THIS_PROFILE_DEFAULT_5": "Maximum heavy Tâches running at once pour ce Profil. Défaut: 5.",
"MAXIMUM_QUEUED_ACTIONS_RUNNING_AT_ONCE": "Maximum queued Actions running at once.",
"MAXIMUM_STALLED_OVERFLOW_DOWNLOADS_SMART_QUEUE_MAY_STOP_IN_ONE_PASS": "Maximum bloqués/overflow Téléchargements Smart Queue may Arrêter dans un pass.",
"MAX_MB": "Max MB",
"MAX_RATIO": "Max Ratio",
"MAX_STOPS_PER_CHECK": "Max stops per Vérifier",
"MESSAGE": "Message",
"MINUTES": "Minutes",
"MIN_MB": "Min MB",
"MIN_PEERS": "Min Pairs",
"MIN_RATIO": "Min Ratio",
"MIN_SEEDS": "Min Seeds",
"MIN_SEED_MINUTES": "Min seed minutes",
"MIN_SPEED_KIB_S": "Min Vitesse KiB/s",
"MOBILE": "Mobile",
"MOBILE_COLUMNS": "Mobile Colonnes",
"MOBILE_FILTER_GROUPS": "Mobile filtre Groupes",
"MOBILE_SIMPLE_MODE": "Mobile/simple mode",
"MOBILE_SORT_FILTERS": "Mobile sort filters",
"MODE": "Mode",
"MONITORED_PATHS": "Monitored chemins",
"MOVE": "Déplacer",
"MOVE_DATA": "Déplacer Données",
"MOVE_DATA_FILES": "Déplacer Données Fichiers",
"MOVE_PATH": "Déplacer Chemin",
"MOVE_TO_PATH": "Déplacer à Chemin",
"MOVIES": "films",
"MOVING": "Moving",
"M_MOVE": "M - Déplacer",
"NAME": "Nom",
"NEGATE": "Negate",
"NEWLY_QUEUE_STARTED_TORRENTS_ARE_PROTECTED_FROM_STALLED_CLEANUP_DURING_THIS_WARM": "Nouvellement File-démarrés Torrents sont protégés depuis bloqués Nettoyage pendant ce préchauffage-haut.",
"NEW_LABEL": "Nouveau Étiquette",
"NEXT_READY": "Suivant: ready",
"NEXT_SMART_QUEUE_RUN": "Suivant Smart Queue exécution",
"NOTIFICATIONS": "Notifications",
"NOT_LOADED": "Non chargé.",
"NO_CHANGES": "Aucun changement",
"NO_FILES_RETURNED_BY_RTORRENT": "Aucun Fichiers returned par rTorrent.",
"NO_FILES_SELECTED": "Aucun fichier sélectionné.",
"NO_LABELS": "Aucun Étiquettes.",
"NO_LABELS_SELECTED": "Aucune étiquette sélectionnée.",
"NO_PATH_LOADED": "Aucun Chemin loaded.",
"NO_PEERS_RETURNED_BY_RTORRENT": "Aucun Pairs returned par rTorrent.",
"NO_RTORRENT_PROFILE_CONFIGURED": "Aucun rTorrent Profil configured.",
"NO_SAVED_LABELS": "Aucun Enregistré Étiquettes.",
"NO_TORRENTS": "Aucun Torrents.",
"NO_TORRENTS_FOR_THIS_FILTER": "Aucun Torrents pour ce filtre.",
"NO_TORRENTS_SELECTED": "Aucun torrent sélectionné",
"NO_TORRENT_SELECTED": "Aucun torrent sélectionné",
"NO_TRACKERS_RETURNED_BY_RTORRENT": "Aucun Trackers returned par rTorrent.",
"OFF": "Off",
"OFFLINE": "hors ligne",
"ONE_PLACE_TO_CLEAR_LOGS_AND_ACTIVE_PROFILE_CACHES_PENDING_RUNNING_JOBS_RULES_SET": "Un place à Effacer Journaux et Actif Profil caches. En attente/running Tâches, Règles, Paramètres et Torrents sont preserved.",
"ONE_TRACKER_URL_PER_LINE": "Un Tracker URL per line",
"ONLINE": "en ligne",
"ONLY_SELECTED": "Seulement sélectionné",
"OPEN_DOWNLOAD_PLANNER": "Ouvrir Téléchargement planner",
"OPEN_RTORRENT_FILES_MAX_OPEN_FILES": "Ouvrir rTorrent Fichiers / max Ouvrir Fichiers",
"OPEN_RTORRENT_HTTP_CONNECTIONS_MAX_HTTP_CONNECTIONS": "Ouvrir rTorrent HTTP connections / max HTTP connections",
"OPEN_RTORRENT_SOCKETS": "Ouvrir rTorrent sockets",
"OPEN_SOURCE": "Open source",
"OPEN_THIS_TAB_TO_LOAD_DIAGNOSTICS": "Ouvrir ce onglet à charger Diagnostics.",
"OPEN_THIS_TAB_TO_LOAD_STATISTICS": "Ouvrir ce onglet à charger Statistiques.",
"OPERATION_LOG_RETENTION": "Operation Journal Rétention",
"OPTIONAL": "optionnel",
"OPTIONAL_PEER_TABLE_HELPERS": "Optionnel peer table helpers.",
"OPTIONAL_PRIVATE_SOURCE_TAG": "optionnel Privé Source tag",
"OPTIONAL_VISUAL_EASTER_EGG_FOR_LOADING_STATES_AND_OCCASIONAL_BUTTON_CLICKS_DISAB": "Optionnel visual easter egg pour Chargement états et occasionnels bouton clics. Désactivé par Défaut.",
"PARALLEL_JOBS": "Parallel Tâches",
"PARENT_DIRECTORY": "Parent Répertoire",
"PARTIAL_DETAILS_LOADED": "Partial Détails loaded",
"PASSWORD": "Mot de passe",
"PASSWORD_NEW_PASSWORD": "Mot de passe / Nouveau Mot de passe",
"PASTE_ONE_MAGNET_URI_PER_LINE": "Paste un magnet URI per line.",
"PATH": "Chemin",
"PATH_CONTAINS": "Chemin contains",
"PATH_TEXT": "Chemin Texte",
"PATH_USED_FOR_SELECTED_MODE": "Chemin used pour sélectionné mode",
"PAUSE": "Pause",
"PAUSED": "En pause",
"PEAK_S": "Peak S",
"PEAK_SPEED_UNAVAILABLE": "Peak Vitesse unavailable",
"PEERS": "Pairs",
"PEERS_AUTO_REFRESH": "Pairs auto Actualiser",
"PENDING_JOBS_OLDER_THAN_THIS_ARE_RESUBMITTED_IF_NO_WORKER_IS_CURRENTLY_HANDLING_": "En attente Tâches plus anciennes que ce sont renvoyées si Aucun worker est actuellement traite les. Défaut: 900 secondes.",
"PENDING_RUNNING_DONE_FAILED_RETRY_AND_CANCEL_HISTORY": "En attente, running, terminé, échoué, Réessayer et Annuler Historique.",
"PENDING_TIMEOUT_SECONDS": "En attente Timeout secondes",
"PIECE_SIZE": "Piece Taille",
"PLANNER": "Planner",
"POLISH": "Polonais",
"PORT": "Port",
"PORT_CHECKER": "Port vérificateur",
"PORT_CHECK_DISABLED": "Port Vérifier Désactivé",
"PORT_UNKNOWN": "Port - unknown",
"POST_CHECK": "Post-Vérifier",
"PREFERENCES": "Préférences",
"PREFERENCES_SAVED": "Préférences Enregistré",
"PRIORITY": "Priorité",
"PRIVATE_TORRENT": "Privé Torrent",
"PROFILE_BACKUP": "Profil sauvegarde",
"PROFILE_BACKUP_NAME": "Profil sauvegarde Nom",
"PROFILE_BACKUP_RESTORES_ONLY_THE_ACTIVE_PROFILE_CONTEXT_APPLICATION_BACKUP_RESTO": "Profil sauvegarde restores seulement Actif Profil contexte. Application sauvegarde restores global Application Données et est disponibles seulement à administrateurs.",
"PROFILE_NAME": "Profil Nom",
"PROFILE_SCOPED_LOG_COUNTS_AND_CLEANUP_OVERVIEW": "Profil-scoped Journal comptes et Nettoyage aperçu.",
"PROGRESS": "Progression",
"PROGRESS_IS_AT_LEAST": "Progression est at least %",
"PROGRESS_IS_AT_MOST": "Progression est at most %",
"PROGRESS_SOURCE": "Progression Source",
"PROTECT_ACTIVE_COUNT_BELOW_CAP": "Protect Actif count below cap",
"PYTORRENT_EXPECTS_TRUSTED_REVERSE_PROXY_IDENTITY_HEADERS_IF_YOU_ARE_ALREADY_SIGN": "pyTorrent expects trusted reverse-proxy identity headers. Si vous sont already signed dans, Vérifier provider headers et Utilisateur mapping.",
"PYTORRENT_LOGIN": "pyTorrent login",
"PYTORRENT_STATUS": "pyTorrent État",
"P_PAUSE": "P - Pause",
"QUALITY": "Qualité",
"QUEUE_REFILL_DURING_COOLDOWN": "File refill pendant Délai",
"RANDOM_CLICK_IMAGE_URL": "Random click image URL",
"RATIO": "Ratio",
"RATIO_GROUP": "Ratio Groupe",
"RATIO_GROUPS": "Ratio Groupes",
"RATIO_IS_AT_LEAST": "Ratio est at least",
"RATIO_RULES": "Ratio Règles",
"REANNOUNCE": "Reannounce",
"RECHECK": "Revérifier",
"RECHECK_AFTER_MOVE": "Revérifier après Déplacer",
"RECOMMENDED_COLUMNS": "Recommandé Colonnes",
"RECOMMENDED_COLUMNS_APPLIED": "Recommandé Colonnes appliqué",
"RECONNECTING": "reconnecting",
"REFERENCE_VALUE_IS_KEPT_FROM_THE_FIRST_OVERRIDE_SAVE_LATER_SAVES_ADD_OR_CLEAR_DI": "Reference valeur est kept depuis Premier override Enregistrer. Later saves Ajouter ou Effacer differences sans replacing original reference.",
"REFRESH": "Actualiser",
"REFRESH_NOW": "Actualiser now",
"REGEX_TEXT": "Regex / Texte",
"RELOAD": "Reload",
"REMOTE_LOCATION": "Distant Emplacement",
"REMOVE": "Supprimer",
"REMOVE_DATA": "Supprimer Données",
"REMOVE_LABEL": "Supprimer Étiquette",
"REMOVE_SELECTED_TORRENTS": "Supprimer sélectionné Torrents",
"REMOVE_WITH_DATA": "Supprimer avec Données",
"REPOSITORY": "Dépôt",
"RESET": "Réinitialiser",
"RESET_UI_SETTINGS": "Réinitialiser UI Paramètres",
"RESET_VIEW_DEFAULTS": "Réinitialiser view defaults",
"RESIZE_TORRENT_DETAILS_PANEL": "Resize Torrent Détails Panneau",
"RESOLVE_PEER_IP_TO_REVERSE_DNS_HOST": "Resolve peer IP à reverse DNS host",
"RESUME": "Reprendre",
"RETENTION_DAYS": "Rétention jours",
"RETENTION_MODE": "Rétention mode",
"RETRY": "Réessayer",
"REVERSE_DNS": "Reverse DNS",
"RSS_DOWNLOADER": "RSS downloader",
"RTORRENT": "rTorrent",
"RTORRENTS": "rTorrents",
"RTORRENT_CONFIG": "rTorrent Configuration",
"RTORRENT_INCOMING_PORT": "rTorrent incoming Port",
"RTORRENT_IS_STARTING_OR_NOT_RESPONDING_YET": "rTorrent est starting ou non responding yet.",
"RTORRENT_PROFILES": "rTorrent Profils",
"RULE": "Règle",
"RULES": "Règles",
"RULES_ARE_CHECKED_AUTOMATICALLY_EVERY_5_MINUTES_A_TORRENT_USES_THE_GROUP_STORED_": "Règles sont vérifiés automatically every 5 minutes. Torrent uses Groupe stored dans its rTorrent Personnalisé Ratio field.",
"RULE_NAME": "Règle Nom",
"RUNTIME": "Runtime",
"RUN_A_PROFILE_TEST_TO_SHOW_DIAGNOSTICS": "Exécution Profil Test à Afficher Diagnostics.",
"RUN_SMART_QUEUE_DURING_POLLING_STOPPED_TORRENTS_ARE_MANAGED_PAUSED_TORRENTS_STAY": "Exécution Smart Queue pendant polling. Arrêté Torrents sont gérés; En pause Torrents stay Utilisateur-controlled.",
"R_RESUME": "R - Reprendre",
"SAVE": "Enregistrer",
"SAVED_LABELS": "Enregistré Étiquettes",
"SAVE_COLUMNS": "Enregistrer Colonnes",
"SAVE_CONFIG": "Enregistrer Configuration",
"SAVE_EASTER_EGG": "Enregistrer easter egg",
"SAVE_EXCEPTIONS": "Enregistrer exceptions",
"SAVE_FEED": "Enregistrer feed",
"SAVE_FOOTER": "Enregistrer Pied de page",
"SAVE_GROUP": "Enregistrer Groupe",
"SAVE_JOB_SETTINGS": "Enregistrer Tâche Paramètres",
"SAVE_LIMITS": "Enregistrer Limites",
"SAVE_LOCATION": "Enregistrer Emplacement",
"SAVE_LOG_VIEW": "Enregistrer Journal view",
"SAVE_PATH": "Enregistrer Chemin",
"SAVE_PREFERENCES": "Enregistrer Préférences",
"SAVE_RETENTION": "Enregistrer Rétention",
"SAVE_RULE": "Enregistrer Règle",
"SAVE_SCHEDULE": "Enregistrer calendrier",
"SAVE_USER": "Enregistrer Utilisateur",
"SCGI_URL": "SCGI URL",
"SEARCH_LOGS": "Rechercher Journaux...",
"SEARCH_TORRENTS": "Rechercher Torrents...",
"SEARCH_TORRENTS_TO_EXCLUDE": "Rechercher Torrents à Exclure...",
"SEASON": "Saison",
"SECONDS_TO_WAIT_FOR_RTORRENT_RESPONSE": "Secondes à attendre pour rTorrent réponse.",
"SEEDING": "Partage",
"SEEDS": "Seeds",
"SEEDS_ARE_LOW_FOR_TIME": "Seeds sont faible pour temps",
"SEED_MINUTES": "Seed minutes",
"SELECT": "Sélectionner",
"SELECTED": "sélectionné",
"SELECTED_LABELS": "Sélectionné Étiquettes",
"SELECTED_MONITORED_PATH": "Sélectionné monitored Chemin",
"SELECTED_TORRENTS_WILL_BE_REMOVED": "sélectionné Torrents sera être removed.",
"SELECT_A_MONITORED_PATH_FIRST": "Sélectionner monitored Chemin Premier.",
"SELECT_A_TORRENT": "Sélectionner Torrent.",
"SELECT_ONE_OR_MORE_TORRENT_FILES": "Sélectionner un ou more .Torrent Fichiers.",
"SELECT_PATH": "Sélectionner Chemin",
"SELECT_TORRENTS_THAT_SMART_QUEUE_SHOULD_IGNORE_USE_SEARCH_TO_FILTER_BY_NAME_LABE": "Sélectionner Torrents que Smart Queue doit ignorer. Utiliser Rechercher à filtre par Nom, Étiquette, État ou Hash.",
"SELECT_VISIBLE": "Sélectionner visible",
"SEPARATE_SLOT_POOL_FOR_LIGHTWEIGHT_CONTROL_JOBS_SO_THEY_DO_NOT_WAIT_BEHIND_HEAVY": "Separate emplacement pool pour lightweight control Tâches so they do non attendre derrière heavy IO travail. Défaut: 4.",
"SETTINGS": "Paramètres",
"SET_LABEL": "Set Étiquette...",
"SET_LABELS": "Set Étiquettes",
"SET_RATIO_GROUP": "Set Ratio Groupe...",
"SHARE_AFTER_CREATING": "Share après creating",
"SHORTCUTS": "Shortcuts",
"SHOWN": "Shown",
"SHOWN_IN_LOADING_STATES_INSTEAD_OF_THE_STANDARD_SPINNER": "Shown dans Chargement états au lieu de de standard spinner.",
"SHOWS_TRACKER_ICONS_IN_THE_SIDEBAR_TRACKER_FILTER_WHEN_AVAILABLE": "Affiche Tracker icônes dans barre latérale Tracker filtre when disponibles.",
"SHOW_COMBINED_USAGE_SINGLE_PATH_SELECTION_IS_DISABLED_IN_THIS_MODE": "Afficher combinée usage. Unique Chemin selection est Désactivé dans ce mode.",
"SHOW_DL_UP_IN_BROWSER_TITLE": "Afficher DL/UP dans Navigateur Titre",
"SHOW_SMART_QUEUE_AUTOMATIC_RUN_MESSAGES": "Afficher Smart Queue Automatique exécution messages.",
"SHOW_TOASTS_CREATED_BY_AUTOMATION_RUNS": "Afficher toasts created par automation runs.",
"SIGN_IN": "Se connecter",
"SIZE": "Taille",
"SKIP_ACTIVE_UPLOAD": "Skip Actif Envoi",
"SKIP_PRIVATE_TORRENTS": "Skip Privé Torrents",
"SMART_FILTERS": "Smart filters",
"SMART_QUEUE": "Smart Queue",
"SMART_QUEUE_EXCEPTIONS": "Smart Queue exceptions",
"SMART_QUEUE_KEEPS_ONLY_THIS_MANY_ACTIVE_DOWNLOADS_OVERFLOW_IS_STOPPED": "Smart Queue keeps seulement ce many Actif Téléchargements; overflow est Arrêté.",
"SMART_QUEUE_TOASTS": "Smart Queue toasts",
"SOCKETS": "Sockets",
"SOURCE": "Source",
"SPACE_START": "Space - Démarrer",
"SPEED": "Vitesse",
"SPEED_LIMITS": "Vitesse Limites",
"SPEED_TREND": "Vitesse trend",
"STALLED_AFTER_SECONDS": "Bloqués après secondes",
"START": "Démarrer",
"START_AFTER_ADD": "Démarrer après Ajouter",
"START_GRACE_SECONDS": "Démarrer grace secondes",
"STATE": "État",
"STATUS": "État",
"STATUS_EQUALS": "État equals",
"STOP": "Arrêter",
"STOPPED": "Arrêté",
"S_STOP": "S - Arrêter",
"TARGET_ACTIVE_DOWNLOADS": "Cible Actif Téléchargements",
"TARGET_PATH": "Cible Chemin",
"TEST_RULE": "Test Règle",
"TEST_SCGI": "Test SCGI",
"THEME_TYPOGRAPHY_AND_INTERFACE_SCALE": "Thème, typography et interface scale.",
"THE_FOOTER_TOOLTIP_ALWAYS_SHOWS_DETAILS_FOR_AVAILABLE_PATHS_THIS_SETTING_ONLY_DE": "Pied de page infobulle toujours affiche Détails pour disponibles chemins; ce paramètre seulement décide quelle valeur pilote visible Progression barre.",
"TIMEOUT": "Timeout",
"TOAST_NOTIFICATIONS_FROM_AUTOMATIC_SYSTEMS": "Toast Notifications depuis Automatique systems.",
"TOOLS": "Outils",
"TOOLS_RTORRENTS": "Outils & rTorrents",
"TOOLS_SECTIONS": "Outils sections",
"TORRENT_ADDED": "Torrent added",
"TORRENT_COMPLETED": "Torrent Terminé",
"TORRENT_DETAILS": "Torrent Détails",
"TORRENT_FILES": "Torrent Fichiers",
"TORRENT_FILTERS": "Torrent filters",
"TORRENT_PROPERTIES": "Torrent properties",
"TORRENT_REMOVED": "Torrent removed",
"TORRENT_STATISTICS": "Torrent Statistiques",
"TORRENT_STATS": "Torrent stats",
"TOTAL_DL_UP": "Total DL/UP",
"TO_DOWNLOAD": "À Téléchargement",
"TRACKERS": "Trackers",
"TRACKER_ICONS": "Tracker icônes",
"TRANSFER": "Transfer",
"TRANSFERRED_DATA": "Transferred Données",
"TRANSFER_HISTORY": "Transfer Historique",
"UNDEFINED": "undefined",
"UNLIMITED": "Unlimited",
"UPLOADED": "Envoyé",
"UPLOADS": "Envois",
"UPLOAD_KIB_S": "Envoi KiB/s",
"USER": "Utilisateur",
"USERS": "Utilisateurs",
"USES_A_LIGHTWEIGHT_BUILT_IN_RESOLVER_WITH_CACHE_HOSTNAMES_APPEAR_ONLY_IN_THE_PEE": "Uses lightweight built-dans résolveur avec cache. Noms dhôte appear seulement dans Pairs onglet.",
"USES_LOWER_ROWS_AND_SMALLER_LIST_ELEMENTS_ON_DESKTOP_AND_MOBILE_SO_MORE_TORRENTS": "Uses plus basses lignes et smaller liste elements on Bureau et Mobile so more Torrents tiennent on écran.",
"USES_YOUGETSIGNAL_FIRST_MANUAL_CHECK_BYPASSES_THE_6H_CACHE": "Uses YouGetSignal Premier. Manuel Vérifier bypasses 6h cache.",
"USE_ONE_CUSTOM_PATH_BELOW_AS_THE_FOOTER_PROGRESS_VALUE": "Utiliser un Personnalisé Chemin below comme Pied de page Progression valeur.",
"USE_SELECTED": "Utiliser sélectionné",
"USE_THE_MAIN_DIRECTORY_FROM_THE_ACTIVE_RTORRENT_PROFILE": "Utiliser main Répertoire depuis Actif rTorrent Profil.",
"VIEW_PREFERENCES_RESET": "View Préférences Réinitialiser",
"VIEW_STATE_IS_SAVED_AUTOMATICALLY_IN_THE_DATABASE_CURRENT_TORRENT_FILTER_LAST_SO": "View État est Enregistré automatically dans database: Actuel Torrent filtre, Dernier sort column et direction, visible Colonnes, et Détails Panneau height.",
"VISIBLE_NAME_USED_IN_THE_PROFILE_SELECTOR": "Visible Nom used dans Profil selector.",
"VISUAL_HELPER_FOR_TRACKER_FILTERS_IN_THE_SIDEBAR": "Visual assistant pour Tracker filters dans barre latérale.",
"WAITING_FOR_DATA": "Attente pour Données.",
"WAITING_FOR_TORRENT_DATA_FROM_THE_ACTIVE_PROFILE": "Attente pour Torrent Données depuis Actif Profil.",
"WATCHDOG_MARKS_A_LIGHT_JOB_AS_FAILED_AFTER_THIS_TIME_DEFAULT_300_SECONDS": "Watchdog marks light Tâche comme échoué après ce temps. Défaut: 300 secondes.",
"WATCHDOG_TIMEOUT_FOR_MOVE_REMOVE_ADD_JOBS_DEFAULT_7200_SECONDS": "Watchdog Timeout pour Déplacer/Supprimer/Ajouter Tâches. Défaut: 7200 secondes.",
"WHEN_DISABLED_THE_APPLICATION_USES_THE_NORMAL_PRODUCTION_UI": "When Désactivé, Application uses normale production UI.",
"WHEN_ENABLED_LOW_SPEED_IS_NOT_REQUIRED_WITH_SOURCE_AND_SPEED_IGNORES_ENABLED_ONL": "When Activé, faible Vitesse est non required. Avec Source et Vitesse ignores Activé, seulement Bloqués après secondes décide.",
"WHEN_ENABLED_SMART_QUEUE_DOES_NOT_USE_SEED_PEER_COUNT_AS_A_STALLED_CRITERION": "When Activé, Smart Queue does non utiliser seed/peer count comme bloqués critère.",
"WITH_ERROR": "Avec Erreur",
"WORKING": "Traitement...",
"FRENCH": "Français",
"CZECH": "Tchèque",
"SPANISH": "Espagnol",
"NORWEGIAN": "Norvégien",
"RUSSIAN": "Russe"
}
}

View File

@@ -0,0 +1,566 @@
{
"meta": {
"locale": "nb_NO",
"label": "Norsk bokmål",
"flag": "no"
},
"translations": {
"0_MEANS_ONLY_SEED_THRESHOLD_IS_REQUIRED": "0 means bare seed threshold er required.",
"0_MEANS_UNLIMITED_SLIDERS_USE_MBIT_S_AND_SAVE_THROUGH_THE_EXISTING_SPEED_LIMITS_": "0 means unlimited. Sliders bruk Mbit/s og Lagre through existing Hastighet Grenser API.",
"0_SELECTED": "0 valgt",
"ABOUT_PYTORRENT": "About pyTorrent",
"ACTION": "Handling",
"ACTIVE": "Aktiv",
"ACTIVE_RTORRENT_DOWNLOADS_MAX_GLOBAL_DOWNLOADS": "Aktiv rTorrent Nedlastinger / max global Nedlastinger",
"ACTIVE_RTORRENT_UPLOADS_MAX_GLOBAL_UPLOADS": "Aktiv rTorrent Opplastinger / max global Opplastinger",
"ADD": "Legg til",
"ADDED": "Added",
"ADDS_REMOVALS_COMPLETIONS_AND_QUEUED_OPERATION_RESULTS": "Adds, removals, completions og queued operation results.",
"ADD_ACTION": "Legg til Handling",
"ADD_CONDITION": "Legg til Betingelse",
"ADD_CREATE_TORRENT": "Legg til / Opprett Torrent",
"ADD_FIRST_LABEL_ABOVE": "Legg til Første Etikett above.",
"ADD_LABEL": "Legg til Etikett",
"ADD_MAGNET_LINK": "Legg til magnet link",
"ADD_NEW_LABEL": "Legg til Ny Etikett",
"ADD_OR_EDIT_GROUP": "Legg til eller Rediger Gruppe",
"ADD_PATH": "Legg til Sti",
"ADD_PROFILE": "Legg til Profil",
"ADD_RTORRENT_PROFILE": "Legg til rTorrent Profil",
"ADD_THE_FIRST_RTORRENT_PROFILE_TO_START_LOADING_TORRENTS": "Legg til Første rTorrent Profil til Start Laster Torrenter.",
"ADD_TORRENT": "Legg til Torrent",
"ADD_TORRENT_FILE": "Legg til Torrent Fil",
"ADMIN": "Administrator",
"ADMIN_ONLY_FULL_APPLICATION_BACKUP_RESTORE_CAN_REPLACE_USERS_PERMISSIONS_PROFILE": "Administrator-bare full Applikasjon sikkerhetskopi. Gjenopprett kan replace Brukere, permissions, Profiler og global Applikasjon Innstillinger.",
"AGGREGATE_ALL_PATHS": "Aggregate Alle stier",
"ALL": "Alle",
"ALL_NON_JOB_TYPES": "Alle non-Jobb types",
"ALL_PROFILES": "Alle Profiler",
"ALL_TYPES": "Alle types",
"ANNOUNCE": "Announce",
"API_DOCS": "API docs",
"APPEARANCE": "Utseende",
"APPEARANCE_PREFERENCES_SAVED": "Utseende Innstillinger Lagret",
"APPEARS_NEAR_CLICKED_BUTTONS_ONLY_SOMETIMES": "Appears near clicked buttons bare sometimes.",
"APPLICATION_BACKUP": "Applikasjon sikkerhetskopi",
"APPLICATION_BACKUP_NAME": "Applikasjon sikkerhetskopi Navn",
"APPLY": "Apply",
"APPLY_RETENTION_NOW": "Apply Oppbevaring now",
"APPLY_SAVED_CHANGES_60S_AFTER_PYTORRENT_START": "Apply Lagret changes 60s etter pyTorrent Start",
"APP_STATUS": "App Status",
"AUTHENTICATION_IS_ENABLED_FOR_THIS_PYTORRENT_INSTANCE": "Authentication er Aktivert for denne pyTorrent instans.",
"AUTHOR": "Forfatter",
"AUTOMATIC": "Automatisk",
"AUTOMATIC_KEEPS_THE_CURRENT_POLLER_CADENCE_CUSTOM_RUNS_ONLY_AFTER_THE_SELECTED_N": "Automatisk keeps Gjeldende poller cadence. Egendefinert runs bare etter valgt number minutter. Off disables refill completely.",
"AUTOMATIC_QUEUE_BALANCING_FOR_SLOW_OR_STALLED_DOWNLOADS": "Automatisk Kø balansering for trege eller stoppede Nedlastinger.",
"AUTOMATIC_RUNS_USE_THE_COOLDOWN_BELOW_MANUAL_CHECK_NOW_STILL_RUNS_IMMEDIATELY": "Automatisk runs bruk Nedkjøling below. Manuell Sjekk now still runs immediately.",
"AUTOMATIONS": "Automations",
"AUTOMATIONS_RULES": "Automations / Regler",
"AUTOMATION_TOASTS": "Automation toasts",
"AUTO_STOP_WHEN_IDLE": "Auto-Stopp when idle",
"BACKEND": "Backend",
"BACKUP": "Sikkerhetskopi",
"BACKUP_RESTORE": "Sikkerhetskopi / Gjenopprett",
"BACK_TO_DASHBOARD": "Back til dashboard",
"BOOTSTRAP_THEME": "Bootstrap Tema",
"BROWSER": "Nettleser",
"BROWSER_TITLE": "Nettleser Tittel",
"BUILD_A_RULE_AS_CONDITIONS_FIRST_THEN_ORDERED_ACTIONS_MATCHING_TORRENTS_ARE_HAND": "Build Regel som: Betingelser Første, deretter ordered Handlinger. Matching Torrenter er handled som én batch og Nedkjøling er brukt til whole Regel.",
"BUSY": "busy",
"BY_DAYS": "Etter dager",
"BY_LINE_COUNT": "Etter line count",
"CACHED_METADATA_SUMMARY_FILE_METADATA_IS_REFRESHED_EVERY_15_MINUTES_A_FEW_MINUTE": "Cached metadata summary. Fil metadata er refreshed every 15 minutter, noen minutter etter oppstart, eller manually.",
"CANCEL": "Avbryt",
"CANCEL_EDIT": "Avbryt Rediger",
"CATEGORY": "Kategori",
"CHANGES_APPLY_IMMEDIATELY_WHERE_POSSIBLE_INITIAL_STARTUP_LOADER_USES_THEM_AFTER_": "Changes apply immediately where possible; initial oppstart loader uses dem etter reload.",
"CHANGE_RTORRENT": "Endre rTorrent",
"CHANGE_THEME": "Endre Tema",
"CHANGING_RTORRENT_RELOADS_THE_LIVE_TORRENT_SNAPSHOT": "Endring rTorrent reloads live Torrent øyeblikksbilde.",
"CHECKING": "Sjekker",
"CHECK_NOW": "Sjekk nå",
"CHECK_PORT_NOW": "Sjekk port nå",
"CHECK_THIS_IF_YOU_WANT_TO_CONNECT_TO_A_REMOTE_RTORRENT_INSTANCE_INSTEAD_OF_LOCAL": "Sjekk denne hvis du vil til koble til til Ekstern rTorrent instans i stedet for localhost.",
"CHOOSE_COLUMNS_VISIBLE_IN_THE_TORRENT_LIST": "Velg Kolonner synlig i Torrent liste.",
"CHOOSE_FILES": "Velg Filer",
"CHOOSE_RTORRENT": "Velg rTorrent",
"CHOOSE_TORRENTS_IGNORED_BY_SMART_QUEUE_EXISTING_BEHAVIOR_STAYS_UNCHANGED_FOR_ALL": "Velg Torrenter ignored etter Smart Queue. Existing behavior stays unchanged for Alle non-excluded Torrenter.",
"CHOOSE_WHAT_THE_FOOTER_DISK_BAR_SHOULD_REPRESENT_AND_ADD_EXTRA_STORAGE_PATHS": "Velg what Bunnlinje disk linje skal represent og Legg til extra storage stier.",
"CHOOSE_WHICH_STATUS_ITEMS_ARE_VISIBLE_IN_THE_BOTTOM_BAR": "Velg hvilken Status items er synlig i bottom linje.",
"CHUNKS": "Chunks",
"CLEANUP": "Opprydding",
"CLEANUP_RETENTION": "Opprydding / Oppbevaring",
"CLEAR": "Tøm",
"CLEAR_CURRENT_FILTER": "Tøm Gjeldende filter",
"CLEAR_FINISHED": "Tøm finished",
"CLEAR_LABELS": "Tøm Etiketter",
"CLEAR_SELECTION": "Tøm selection",
"CLEAR_VISIBLE": "Tøm synlig",
"CLOSE": "Lukk",
"COLUMNS": "Kolonner",
"COLUMNS_SAVED": "Kolonner Lagret",
"COMMENT": "Comment",
"COMPACT_TORRENT_LIST": "Compact Torrent liste",
"COMPLETE": "Complete",
"COMPLETED": "Fullført",
"CONFIGURED_RTORRENTS": "Configured rTorrents",
"CONNECTING_TO_RTORRENT_AND_PREPARING_DATA": "Connecting til rTorrent og preparing Data.",
"CONNECTION_ADDRESS_IN_SCGI_HOST_PORT_RPC2_FORMAT": "Tilkobling address i scgi://host:Port/RPC2 format.",
"CONTROLS_THE_DEFAULT_CATEGORY_AND_JOB_LOG_VISIBILITY_USED_BY_THE_LOGS_MODAL": "Controls Standard Kategori og Jobb Logg visibility used etter Logger modal.",
"CONTROLS_WHAT_IS_SHOWN_IN_THE_BROWSER_TAB": "Controls what er shown i Nettleser fane.",
"COOLDOWN_MINUTES": "Nedkjøling minutter",
"COPY": "Kopier",
"COPY_HASH": "Kopier Hash",
"COPY_NAME": "Kopier Navn",
"COPY_PATH": "Kopier Sti",
"CPU_RAM_USAGE": "CPU / RAM usage",
"CREATE": "Opprett",
"CREATES_AND_RESTORES_SETTINGS_FOR_THE_CURRENTLY_SELECTED_PROFILE_USER_SCOPED_PRE": "Creates og restores Innstillinger for for øyeblikket valgt Profil. Bruker-scoped Innstillinger er remapped til Gjeldende Bruker where needed.",
"CREATE_APPLICATION_BACKUP": "Opprett Applikasjon sikkerhetskopi",
"CREATE_ONE_RTORRENT_PROFILE_AT_A_TIME_MOVE_REMOVE_QUEUES_KEEP_THEIR_ORDER_FOR_EA": "Opprett én rTorrent Profil at tid. Flytt/Fjern queues keep deres order for hver Profil.",
"CREATE_PROFILE_BACKUP": "Opprett Profil sikkerhetskopi",
"CREATE_REUSABLE_LABELS_AND_REMOVE_LABELS_THAT_ARE_NO_LONGER_NEEDED": "Opprett gjenbrukbare Etiketter og Fjern Etiketter at er Ingen lenger needed.",
"CREATE_TORRENT": "Opprett Torrent",
"CTRL_A_SELECT_VISIBLE": "Ctrl+ - Velg synlig",
"CTRL_I_INVERT_VISIBLE": "Ctrl+I - invert synlig",
"CTRL_O_ADD": "Ctrl+O - Legg til",
"CTRL_S_DOWNLOAD_TORRENT": "Ctrl+S - Nedlasting .Torrent",
"CURRENT_TRANSFER_SPEED": "Gjeldende transfer Hastighet",
"CUSTOM_DOWNLOAD": "Egendefinert Nedlasting",
"CUSTOM_UPLOAD": "Egendefinert Opplasting",
"DAYS_AND_LINE_COUNT": "Dager og line count",
"DECREASE_OR_INCREASE_THE_WHOLE_INTERFACE_SIZE": "Decrease eller increase whole interface Størrelse.",
"DEFAULT_LOG_CATEGORY": "Standard Logg Kategori",
"DEFAULT_LOG_VIEW": "Standard Logg view",
"DEFAULT_RTORRENT_PATH": "Standard rTorrent Sti",
"DEGRADED": "degraded",
"DELETE": "Slett",
"DELETE_REMOVE": "Slett - Fjern",
"DESKTOP": "Skrivebord",
"DETAILS_WILL_APPEAR_AFTER_THE_FIRST_SUCCESSFUL_RESPONSE": "Detaljer vil appear etter Første vellykket svar.",
"DIAGNOSTICS": "Diagnostikk",
"DISABLED": "Deaktivert",
"DISK_MONITOR": "Disk monitor",
"DISK_USAGE_UNAVAILABLE": "Disk usage unavailable",
"DISPLAYS_CURRENT_SPEEDS_NEXT_TO_PYTORRENT_IN_THE_TAB_TITLE": "Displays Gjeldende speeds Neste til pyTorrent i fane Tittel.",
"DOCS_API": "Docs API",
"DOWNLOADED": "Lastet ned",
"DOWNLOADING": "Laster ned",
"DOWNLOADS": "Nedlastinger",
"DOWNLOAD_KIB_S": "Nedlasting KiB/s",
"DOWNLOAD_STARTED": "Nedlasting startede",
"DOWNLOAD_TORRENT": "Nedlasting .Torrent",
"DOWNLOAD_TRACKER_FAVICONS": "Nedlasting Tracker favicons",
"DRAG_TO_RESIZE_DETAILS_PANEL": "Drag til resize Detaljer Panel",
"EASTER_EGG": "Easter egg",
"EDIT": "Rediger",
"EMERGENCY_CANCEL": "Emergency Avbryt",
"EMERGENCY_CLEAN_ALL": "Emergency clean Alle",
"ENABLED": "Aktivert",
"ENABLE_AUTOMATIC_APPLICATION_BACKUPS": "Aktiver Automatisk Applikasjon sikkerhetskopier",
"ENABLE_AUTOMATIC_PROFILE_BACKUPS": "Aktiver Automatisk Profil sikkerhetskopier",
"ENABLE_EASTER_EGG": "Aktiver easter egg",
"ENABLE_INCOMING_PORT_CHECK": "Aktiver incoming Port Sjekk",
"ENABLE_REVERSE_DNS_FOR_PEERS": "Aktiver reverse DNS for Peers",
"ENGLISH": "Engelsk",
"EPISODE": "Episode",
"ESC_CLEAR_SELECTION": "Esc - Tøm selection",
"ETA": "ETA",
"EVERY_N_MINUTES": "Every N minutter",
"EVERY_X_HOURS": "Every X hours",
"EXCLUDE": "Ekskluder",
"EXCLUDE_FROM_SMART_QUEUE": "Ekskluder fra Smart Queue",
"EXCLUDE_PATTERN": "Ekskluder Mønster",
"EXISTING_GROUPS": "Existing Grupper",
"EXPORT": "Eksport",
"EXPORT_JSON": "Eksport JSON",
"EXTERNAL_AUTHENTICATION_IS_ENABLED_THROUGH_EXTERNAL_PROVIDER": "External authentication er Aktivert through {{ external_provider }}.",
"E_G_PUNKTY_KATALOGOWANIE": "e.g. Punkty katalogowanie",
"FEATURES": "Funksjoner",
"FEED": "Feed",
"FEEDS_ARE_CHECKED_BY_SCHEDULE_AND_EVERY_MATCH_IS_LOGGED_PER_FEED_RULE": "Feeds er sjekkes etter plan og every treff er loggført per feed/Regel.",
"FEEDS_RULES_AND_MATCHES": "Feeds, Regler og treff",
"FEED_NAME": "Feed Navn",
"FEED_URL": "Feed URL",
"FILES": "Filer",
"FILE_OR_DIRECTORY_PATH": "Fil eller Mappe Sti",
"FONT": "Skrift",
"FOOTER": "Bunnlinje",
"FORCE": "Tving",
"FORCE_RECHECK": "Tving Sjekk på nytt",
"FRONTEND": "Frontend",
"FULL": "Full",
"GENERAL": "Generelt",
"GENERATED_RTORRENT_CONFIG_CHANGES_WILL_APPEAR_HERE": "Generert rTorrent Konfigurasjon changes vil appear here.",
"GENERATE_CONFIG": "Generate Konfigurasjon",
"GERMAN": "Tysk",
"GO": "Go",
"GROUPED_RTORRENT_RUNTIME_SETTINGS_WITH_INLINE_RECOMMENDATIONS_AND_COMPATIBILITY_": "Grupperte rTorrent Runtime Innstillinger med inline anbefalinger og kompatibilitet Status.",
"GROUP_NAME": "Gruppe Navn",
"HASH": "Hash",
"HASHING": "Hashing",
"HEAVY_PARALLEL_JOBS": "Heavy parallel Jobber",
"HEAVY_TIMEOUT_SECONDS": "Heavy Tidsavbrudd sekunder",
"HIDE_JOB_LOGS": "Skjul Jobb Logger",
"HIDE_JOB_LOGS_BY_DEFAULT": "Skjul Jobb Logger etter Standard",
"HISTORY": "Historikk",
"HOW_LONG_A_MATCHING_ACTIVE_TORRENT_MUST_STAY_STALLED_BEFORE_IT_CAN_BE_REPLACED": "Hvor lenge matching Aktiv Torrent må stay stoppede før it kan være erstattet.",
"IGNORE_MISSING_SEEDS_PEERS_FOR_STALLED_TIMER": "Ignorere missing Seeds/Peers for stoppede timer",
"IGNORE_SPEED_FOR_STALLED_TIMER": "Ignorere Hastighet for stoppede timer",
"IMPORT": "Import",
"IMPORT_JSON": "Import JSON",
"INCLUDE_PATTERN": "Inkluder Mønster",
"INCOMING_CONNECTION_TEST_SEPARATE_FROM_VISUAL_PREFERENCES": "Incoming Tilkobling Test, separate fra visual Innstillinger.",
"INTERFACE_SCALE": "Interface scale",
"INTERVAL_MINUTES": "Intervall minutter",
"INVERT_VISIBLE": "invert synlig",
"JOBS": "Jobber",
"JOB_DONE": "Jobb ferdig",
"JOB_FAILED": "Jobb mislyktes",
"JOB_QUEUE": "Jobb Kø",
"JOB_SCHEDULING": "Jobb scheduling",
"JOB_STARTED": "Jobb startede",
"KEEP_LINES": "Keep lines",
"KEEP_SEEDING": "Keep Seeding",
"LABEL": "Etikett",
"LABELS": "Etiketter",
"LABELS_SEPARATED_BY_COMMA": "Etiketter separated etter comma",
"LABEL_AFTER_SHARE": "Etikett etter share",
"LABEL_EXISTS": "Etikett exists",
"LABEL_IS_MISSING": "Etikett er missing",
"LABEL_NAME_OR_SEVERAL_SEPARATED_BY_COMMA": "Etikett Navn, eller flere separated etter comma",
"LANGUAGE": "Språk",
"LANGUAGE_SAVED": "Språk Lagret",
"LAST_OPERATIONS": "Siste operations",
"LICENSE": "Lisens",
"LIGHTWEIGHT_WEB_PANEL_FOR_RTORRENT_MANAGEMENT_QUEUE_CONTROL_AND_LIVE_TORRENT_DIA": "Lightweight web Panel for rTorrent management, Kø control og live Torrent Diagnostikk.",
"LIGHT_PARALLEL_JOBS": "Light parallel Jobber",
"LIGHT_TIMEOUT_SECONDS": "Light Tidsavbrudd sekunder",
"LIMIT_DL": "Grense DL",
"LOADING": "Laster",
"LOADING_CLEANUP_DATA": "Laster Opprydding Data...",
"LOADING_CONFIG": "Laster Konfigurasjon...",
"LOADING_IMAGE_URL": "Laster image URL",
"LOADING_JOBS": "Laster Jobber...",
"LOADING_LOGS": "Laster Logger...",
"LOADING_PROFILES": "Laster Profiler...",
"LOADING_STATISTICS": "Laster Statistikk...",
"LOADING_TORRENTS": "Laster Torrenter...",
"LOADING_TORRENT_DETAILS": "Laster Torrent Detaljer...",
"LOCAL_BROWSER_TIME": "Lokal Nettleser tid",
"LOCATION": "Plassering",
"LOG": "Logg",
"LOGS": "Logger",
"LOG_IN": "Logg inn",
"LOG_OUT": "Logg ut",
"LOG_STATISTICS": "Logg Statistikk",
"MAGNET_AND_TORRENT_UPLOAD_FILE_PRIORITIES_LABELS_RATIO_GROUPS_SMART_QUEUE_AUTOMA": "Magnet og Torrent Opplasting, Fil prioriteter, Etiketter, Ratio Grupper, Smart Queue, automation Regler, RSS, trafikk diagrammer, Port sjekker, system Status.",
"MAGNET_LINKS": "Magnet links",
"MANAGE_EXCEPTIONS": "Manage exceptions",
"MANAGE_OPERATION_LOG_RETENTION_WITHOUT_CHANGING_TORRENT_DATA": "Manage operation Logg Oppbevaring uten Endring Torrent Data.",
"MANAGE_OPTIONAL_PYTORRENT_USERS_EMPTY_PROFILE_MEANS_ALL_PROFILES_R_O_BLOCKS_RTOR": "Manage valgfritt pyTorrent Brukere. Empty Profil means Alle Profiler. R/O blocks rTorrent-Endring Handlinger; Full allows dem.",
"MANUAL_CLEANUP_ONLY": "Manuell Opprydding bare",
"MAXIMUM_HEAVY_JOBS_RUNNING_AT_ONCE_FOR_THIS_PROFILE_DEFAULT_5": "Maksimum heavy Jobber running at once for denne Profil. Standard: 5.",
"MAXIMUM_QUEUED_ACTIONS_RUNNING_AT_ONCE": "Maksimum queued Handlinger running at once.",
"MAXIMUM_STALLED_OVERFLOW_DOWNLOADS_SMART_QUEUE_MAY_STOP_IN_ONE_PASS": "Maksimum stoppede/overflow Nedlastinger Smart Queue may Stopp i én pass.",
"MAX_MB": "Max MB",
"MAX_RATIO": "Max Ratio",
"MAX_STOPS_PER_CHECK": "Max stops per Sjekk",
"MESSAGE": "Melding",
"MINUTES": "Minutter",
"MIN_MB": "Min MB",
"MIN_PEERS": "Min Peers",
"MIN_RATIO": "Min Ratio",
"MIN_SEEDS": "Min Seeds",
"MIN_SEED_MINUTES": "Min seed minutter",
"MIN_SPEED_KIB_S": "Min Hastighet KiB/s",
"MOBILE": "Mobil",
"MOBILE_COLUMNS": "Mobil Kolonner",
"MOBILE_FILTER_GROUPS": "Mobil filter Grupper",
"MOBILE_SIMPLE_MODE": "Mobil/simple mode",
"MOBILE_SORT_FILTERS": "Mobil sort filters",
"MODE": "Mode",
"MONITORED_PATHS": "Monitored stier",
"MOVE": "Flytt",
"MOVE_DATA": "Flytt Data",
"MOVE_DATA_FILES": "Flytt Data Filer",
"MOVE_PATH": "Flytt Sti",
"MOVE_TO_PATH": "Flytt til Sti",
"MOVIES": "filmer",
"MOVING": "Moving",
"M_MOVE": "M - Flytt",
"NAME": "Navn",
"NEGATE": "Negate",
"NEWLY_QUEUE_STARTED_TORRENTS_ARE_PROTECTED_FROM_STALLED_CLEANUP_DURING_THIS_WARM": "Nylig Kø-startede Torrenter er beskyttet fra stoppede Opprydding under denne oppvarming-opp.",
"NEW_LABEL": "Ny Etikett",
"NEXT_READY": "Neste: ready",
"NEXT_SMART_QUEUE_RUN": "Neste Smart Queue kjøring",
"NOTIFICATIONS": "Varsler",
"NOT_LOADED": "Ikke lastet.",
"NO_CHANGES": "Ingen endringer",
"NO_FILES_RETURNED_BY_RTORRENT": "Ingen Filer returned etter rTorrent.",
"NO_FILES_SELECTED": "Ingen filer valgt.",
"NO_LABELS": "Ingen Etiketter.",
"NO_LABELS_SELECTED": "Ingen etiketter valgt.",
"NO_PATH_LOADED": "Ingen Sti loaded.",
"NO_PEERS_RETURNED_BY_RTORRENT": "Ingen Peers returned etter rTorrent.",
"NO_RTORRENT_PROFILE_CONFIGURED": "Ingen rTorrent Profil configured.",
"NO_SAVED_LABELS": "Ingen Lagret Etiketter.",
"NO_TORRENTS": "Ingen Torrenter.",
"NO_TORRENTS_FOR_THIS_FILTER": "Ingen Torrenter for denne filter.",
"NO_TORRENTS_SELECTED": "Ingen torrenter valgt",
"NO_TORRENT_SELECTED": "Ingen torrent valgt",
"NO_TRACKERS_RETURNED_BY_RTORRENT": "Ingen Trackere returned etter rTorrent.",
"OFF": "Off",
"OFFLINE": "frakoblet",
"ONE_PLACE_TO_CLEAR_LOGS_AND_ACTIVE_PROFILE_CACHES_PENDING_RUNNING_JOBS_RULES_SET": "Én place til Tøm Logger og Aktiv Profil caches. Ventende/running Jobber, Regler, Innstillinger og Torrenter er preserved.",
"ONE_TRACKER_URL_PER_LINE": "Én Tracker URL per line",
"ONLINE": "på nett",
"ONLY_SELECTED": "Bare valgt",
"OPEN_DOWNLOAD_PLANNER": "Åpne Nedlasting planner",
"OPEN_RTORRENT_FILES_MAX_OPEN_FILES": "Åpne rTorrent Filer / max Åpne Filer",
"OPEN_RTORRENT_HTTP_CONNECTIONS_MAX_HTTP_CONNECTIONS": "Åpne rTorrent HTTP connections / max HTTP connections",
"OPEN_RTORRENT_SOCKETS": "Åpne rTorrent sockets",
"OPEN_SOURCE": "Open source",
"OPEN_THIS_TAB_TO_LOAD_DIAGNOSTICS": "Åpne denne fane til laste Diagnostikk.",
"OPEN_THIS_TAB_TO_LOAD_STATISTICS": "Åpne denne fane til laste Statistikk.",
"OPERATION_LOG_RETENTION": "Operation Logg Oppbevaring",
"OPTIONAL": "valgfritt",
"OPTIONAL_PEER_TABLE_HELPERS": "Valgfritt peer table helpers.",
"OPTIONAL_PRIVATE_SOURCE_TAG": "valgfritt Privat Kilde tag",
"OPTIONAL_VISUAL_EASTER_EGG_FOR_LOADING_STATES_AND_OCCASIONAL_BUTTON_CLICKS_DISAB": "Valgfritt visual easter egg for Laster tilstander og sporadiske knapp klikk. Deaktivert etter Standard.",
"PARALLEL_JOBS": "Parallel Jobber",
"PARENT_DIRECTORY": "Overordnet Mappe",
"PARTIAL_DETAILS_LOADED": "Partial Detaljer loaded",
"PASSWORD": "Passord",
"PASSWORD_NEW_PASSWORD": "Passord / Ny Passord",
"PASTE_ONE_MAGNET_URI_PER_LINE": "Paste én magnet URI per line.",
"PATH": "Sti",
"PATH_CONTAINS": "Sti contains",
"PATH_TEXT": "Sti Tekst",
"PATH_USED_FOR_SELECTED_MODE": "Sti used for valgt mode",
"PAUSE": "Pause",
"PAUSED": "Pauset",
"PEAK_S": "Peak S",
"PEAK_SPEED_UNAVAILABLE": "Peak Hastighet unavailable",
"PEERS": "Peers",
"PEERS_AUTO_REFRESH": "Peers auto Oppdater",
"PENDING_JOBS_OLDER_THAN_THIS_ARE_RESUBMITTED_IF_NO_WORKER_IS_CURRENTLY_HANDLING_": "Ventende Jobber eldre enn denne er sendes på nytt hvis Ingen worker er for øyeblikket håndterer dem. Standard: 900 sekunder.",
"PENDING_RUNNING_DONE_FAILED_RETRY_AND_CANCEL_HISTORY": "Ventende, running, ferdig, mislyktes, Prøv igjen og Avbryt Historikk.",
"PENDING_TIMEOUT_SECONDS": "Ventende Tidsavbrudd sekunder",
"PIECE_SIZE": "Piece Størrelse",
"PLANNER": "Planner",
"POLISH": "Polsk",
"PORT": "Port",
"PORT_CHECKER": "Port sjekker",
"PORT_CHECK_DISABLED": "Port Sjekk Deaktivert",
"PORT_UNKNOWN": "Port - unknown",
"POST_CHECK": "Post-Sjekk",
"PREFERENCES": "Innstillinger",
"PREFERENCES_SAVED": "Innstillinger Lagret",
"PRIORITY": "Prioritet",
"PRIVATE_TORRENT": "Privat Torrent",
"PROFILE_BACKUP": "Profil sikkerhetskopi",
"PROFILE_BACKUP_NAME": "Profil sikkerhetskopi Navn",
"PROFILE_BACKUP_RESTORES_ONLY_THE_ACTIVE_PROFILE_CONTEXT_APPLICATION_BACKUP_RESTO": "Profil sikkerhetskopi restores bare Aktiv Profil kontekst. Applikasjon sikkerhetskopi restores global Applikasjon Data og er tilgjengelige bare til administratorer.",
"PROFILE_NAME": "Profil Navn",
"PROFILE_SCOPED_LOG_COUNTS_AND_CLEANUP_OVERVIEW": "Profil-scoped Logg antall og Opprydding oversikt.",
"PROGRESS": "Fremdrift",
"PROGRESS_IS_AT_LEAST": "Fremdrift er at least %",
"PROGRESS_IS_AT_MOST": "Fremdrift er at most %",
"PROGRESS_SOURCE": "Fremdrift Kilde",
"PROTECT_ACTIVE_COUNT_BELOW_CAP": "Protect Aktiv count below cap",
"PYTORRENT_EXPECTS_TRUSTED_REVERSE_PROXY_IDENTITY_HEADERS_IF_YOU_ARE_ALREADY_SIGN": "pyTorrent expects trusted reverse-proxy identity headers. Hvis du er already signed i, Sjekk provider headers og Bruker mapping.",
"PYTORRENT_LOGIN": "pyTorrent login",
"PYTORRENT_STATUS": "pyTorrent Status",
"P_PAUSE": "P - Pause",
"QUALITY": "Kvalitet",
"QUEUE_REFILL_DURING_COOLDOWN": "Kø refill under Nedkjøling",
"RANDOM_CLICK_IMAGE_URL": "Random click image URL",
"RATIO": "Ratio",
"RATIO_GROUP": "Ratio Gruppe",
"RATIO_GROUPS": "Ratio Grupper",
"RATIO_IS_AT_LEAST": "Ratio er at least",
"RATIO_RULES": "Ratio Regler",
"REANNOUNCE": "Reannounce",
"RECHECK": "Sjekk på nytt",
"RECHECK_AFTER_MOVE": "Sjekk på nytt etter Flytt",
"RECOMMENDED_COLUMNS": "Anbefalt Kolonner",
"RECOMMENDED_COLUMNS_APPLIED": "Anbefalt Kolonner brukt",
"RECONNECTING": "reconnecting",
"REFERENCE_VALUE_IS_KEPT_FROM_THE_FIRST_OVERRIDE_SAVE_LATER_SAVES_ADD_OR_CLEAR_DI": "Reference verdi er kept fra Første override Lagre. Later saves Legg til eller Tøm differences uten replacing original reference.",
"REFRESH": "Oppdater",
"REFRESH_NOW": "Oppdater now",
"REGEX_TEXT": "Regex / Tekst",
"RELOAD": "Reload",
"REMOTE_LOCATION": "Ekstern Plassering",
"REMOVE": "Fjern",
"REMOVE_DATA": "Fjern Data",
"REMOVE_LABEL": "Fjern Etikett",
"REMOVE_SELECTED_TORRENTS": "Fjern valgt Torrenter",
"REMOVE_WITH_DATA": "Fjern med Data",
"REPOSITORY": "Repo",
"RESET": "Tilbakestill",
"RESET_UI_SETTINGS": "Tilbakestill UI Innstillinger",
"RESET_VIEW_DEFAULTS": "Tilbakestill view defaults",
"RESIZE_TORRENT_DETAILS_PANEL": "Resize Torrent Detaljer Panel",
"RESOLVE_PEER_IP_TO_REVERSE_DNS_HOST": "Resolve peer IP til reverse DNS host",
"RESUME": "Fortsett",
"RETENTION_DAYS": "Oppbevaring dager",
"RETENTION_MODE": "Oppbevaring mode",
"RETRY": "Prøv igjen",
"REVERSE_DNS": "Reverse DNS",
"RSS_DOWNLOADER": "RSS downloader",
"RTORRENT": "rTorrent",
"RTORRENTS": "rTorrents",
"RTORRENT_CONFIG": "rTorrent Konfigurasjon",
"RTORRENT_INCOMING_PORT": "rTorrent incoming Port",
"RTORRENT_IS_STARTING_OR_NOT_RESPONDING_YET": "rTorrent er starting eller ikke responding yet.",
"RTORRENT_PROFILES": "rTorrent Profiler",
"RULE": "Regel",
"RULES": "Regler",
"RULES_ARE_CHECKED_AUTOMATICALLY_EVERY_5_MINUTES_A_TORRENT_USES_THE_GROUP_STORED_": "Regler er sjekkes automatically every 5 minutter. Torrent uses Gruppe stored i its rTorrent Egendefinert Ratio field.",
"RULE_NAME": "Regel Navn",
"RUNTIME": "Runtime",
"RUN_A_PROFILE_TEST_TO_SHOW_DIAGNOSTICS": "Kjøring Profil Test til Vis Diagnostikk.",
"RUN_SMART_QUEUE_DURING_POLLING_STOPPED_TORRENTS_ARE_MANAGED_PAUSED_TORRENTS_STAY": "Kjøring Smart Queue under polling. Stoppet Torrenter er administrert; Pauset Torrenter stay Bruker-controlled.",
"R_RESUME": "R - Fortsett",
"SAVE": "Lagre",
"SAVED_LABELS": "Lagret Etiketter",
"SAVE_COLUMNS": "Lagre Kolonner",
"SAVE_CONFIG": "Lagre Konfigurasjon",
"SAVE_EASTER_EGG": "Lagre easter egg",
"SAVE_EXCEPTIONS": "Lagre exceptions",
"SAVE_FEED": "Lagre feed",
"SAVE_FOOTER": "Lagre Bunnlinje",
"SAVE_GROUP": "Lagre Gruppe",
"SAVE_JOB_SETTINGS": "Lagre Jobb Innstillinger",
"SAVE_LIMITS": "Lagre Grenser",
"SAVE_LOCATION": "Lagre Plassering",
"SAVE_LOG_VIEW": "Lagre Logg view",
"SAVE_PATH": "Lagre Sti",
"SAVE_PREFERENCES": "Lagre Innstillinger",
"SAVE_RETENTION": "Lagre Oppbevaring",
"SAVE_RULE": "Lagre Regel",
"SAVE_SCHEDULE": "Lagre plan",
"SAVE_USER": "Lagre Bruker",
"SCGI_URL": "SCGI URL",
"SEARCH_LOGS": "Søk Logger...",
"SEARCH_TORRENTS": "Søk Torrenter...",
"SEARCH_TORRENTS_TO_EXCLUDE": "Søk Torrenter til Ekskluder...",
"SEASON": "Sesong",
"SECONDS_TO_WAIT_FOR_RTORRENT_RESPONSE": "Sekunder til vente for rTorrent svar.",
"SEEDING": "Seeding",
"SEEDS": "Seeds",
"SEEDS_ARE_LOW_FOR_TIME": "Seeds er lav for tid",
"SEED_MINUTES": "Seed minutter",
"SELECT": "Velg",
"SELECTED": "valgt",
"SELECTED_LABELS": "Valgt Etiketter",
"SELECTED_MONITORED_PATH": "Valgt monitored Sti",
"SELECTED_TORRENTS_WILL_BE_REMOVED": "valgt Torrenter vil være removed.",
"SELECT_A_MONITORED_PATH_FIRST": "Velg monitored Sti Første.",
"SELECT_A_TORRENT": "Velg Torrent.",
"SELECT_ONE_OR_MORE_TORRENT_FILES": "Velg én eller more .Torrent Filer.",
"SELECT_PATH": "Velg Sti",
"SELECT_TORRENTS_THAT_SMART_QUEUE_SHOULD_IGNORE_USE_SEARCH_TO_FILTER_BY_NAME_LABE": "Velg Torrenter at Smart Queue skal ignorere. Bruk Søk til filter etter Navn, Etikett, Status eller Hash.",
"SELECT_VISIBLE": "Velg synlig",
"SEPARATE_SLOT_POOL_FOR_LIGHTWEIGHT_CONTROL_JOBS_SO_THEY_DO_NOT_WAIT_BEHIND_HEAVY": "Separate spor pool for lightweight control Jobber so they do ikke vente bak heavy IO arbeid. Standard: 4.",
"SETTINGS": "Innstillinger",
"SET_LABEL": "Set Etikett...",
"SET_LABELS": "Set Etiketter",
"SET_RATIO_GROUP": "Set Ratio Gruppe...",
"SHARE_AFTER_CREATING": "Share etter creating",
"SHORTCUTS": "Shortcuts",
"SHOWN": "Shown",
"SHOWN_IN_LOADING_STATES_INSTEAD_OF_THE_STANDARD_SPINNER": "Shown i Laster tilstander i stedet for standard spinner.",
"SHOWS_TRACKER_ICONS_IN_THE_SIDEBAR_TRACKER_FILTER_WHEN_AVAILABLE": "Viser Tracker ikoner i sidefelt Tracker filter when tilgjengelige.",
"SHOW_COMBINED_USAGE_SINGLE_PATH_SELECTION_IS_DISABLED_IN_THIS_MODE": "Vis kombinert usage. Enkelt Sti selection er Deaktivert i denne mode.",
"SHOW_DL_UP_IN_BROWSER_TITLE": "Vis DL/UP i Nettleser Tittel",
"SHOW_SMART_QUEUE_AUTOMATIC_RUN_MESSAGES": "Vis Smart Queue Automatisk kjøring meldinger.",
"SHOW_TOASTS_CREATED_BY_AUTOMATION_RUNS": "Vis toasts created etter automation runs.",
"SIGN_IN": "Logg inn",
"SIZE": "Størrelse",
"SKIP_ACTIVE_UPLOAD": "Skip Aktiv Opplasting",
"SKIP_PRIVATE_TORRENTS": "Skip Privat Torrenter",
"SMART_FILTERS": "Smart filters",
"SMART_QUEUE": "Smart Queue",
"SMART_QUEUE_EXCEPTIONS": "Smart Queue exceptions",
"SMART_QUEUE_KEEPS_ONLY_THIS_MANY_ACTIVE_DOWNLOADS_OVERFLOW_IS_STOPPED": "Smart Queue keeps bare denne many Aktiv Nedlastinger; overflow er Stoppet.",
"SMART_QUEUE_TOASTS": "Smart Queue toasts",
"SOCKETS": "Sockets",
"SOURCE": "Kilde",
"SPACE_START": "Space - Start",
"SPEED": "Hastighet",
"SPEED_LIMITS": "Hastighet Grenser",
"SPEED_TREND": "Hastighet trend",
"STALLED_AFTER_SECONDS": "Stoppede etter sekunder",
"START": "Start",
"START_AFTER_ADD": "Start etter Legg til",
"START_GRACE_SECONDS": "Start grace sekunder",
"STATE": "Status",
"STATUS": "Status",
"STATUS_EQUALS": "Status equals",
"STOP": "Stopp",
"STOPPED": "Stoppet",
"S_STOP": "S - Stopp",
"TARGET_ACTIVE_DOWNLOADS": "Mål Aktiv Nedlastinger",
"TARGET_PATH": "Mål Sti",
"TEST_RULE": "Test Regel",
"TEST_SCGI": "Test SCGI",
"THEME_TYPOGRAPHY_AND_INTERFACE_SCALE": "Tema, typography og interface scale.",
"THE_FOOTER_TOOLTIP_ALWAYS_SHOWS_DETAILS_FOR_AVAILABLE_PATHS_THIS_SETTING_ONLY_DE": "Bunnlinje tooltip alltid viser Detaljer for tilgjengelige stier; denne innstilling bare avgjør hvilken verdi styrer synlig Fremdrift linje.",
"TIMEOUT": "Tidsavbrudd",
"TOAST_NOTIFICATIONS_FROM_AUTOMATIC_SYSTEMS": "Toast Varsler fra Automatisk systems.",
"TOOLS": "Verktøy",
"TOOLS_RTORRENTS": "Verktøy & rTorrents",
"TOOLS_SECTIONS": "Verktøy sections",
"TORRENT_ADDED": "Torrent added",
"TORRENT_COMPLETED": "Torrent Fullført",
"TORRENT_DETAILS": "Torrent Detaljer",
"TORRENT_FILES": "Torrent Filer",
"TORRENT_FILTERS": "Torrent filters",
"TORRENT_PROPERTIES": "Torrent properties",
"TORRENT_REMOVED": "Torrent removed",
"TORRENT_STATISTICS": "Torrent Statistikk",
"TORRENT_STATS": "Torrent stats",
"TOTAL_DL_UP": "Total DL/UP",
"TO_DOWNLOAD": "Til Nedlasting",
"TRACKERS": "Trackere",
"TRACKER_ICONS": "Tracker ikoner",
"TRANSFER": "Transfer",
"TRANSFERRED_DATA": "Transferred Data",
"TRANSFER_HISTORY": "Transfer Historikk",
"UNDEFINED": "undefined",
"UNLIMITED": "Unlimited",
"UPLOADED": "Lastet opp",
"UPLOADS": "Opplastinger",
"UPLOAD_KIB_S": "Opplasting KiB/s",
"USER": "Bruker",
"USERS": "Brukere",
"USES_A_LIGHTWEIGHT_BUILT_IN_RESOLVER_WITH_CACHE_HOSTNAMES_APPEAR_ONLY_IN_THE_PEE": "Uses lightweight built-i resolver med cache. Vertsnavn appear bare i Peers fane.",
"USES_LOWER_ROWS_AND_SMALLER_LIST_ELEMENTS_ON_DESKTOP_AND_MOBILE_SO_MORE_TORRENTS": "Uses lavere rader og smaller liste elements on Skrivebord og Mobil so more Torrenter får plass on skjerm.",
"USES_YOUGETSIGNAL_FIRST_MANUAL_CHECK_BYPASSES_THE_6H_CACHE": "Uses YouGetSignal Første. Manuell Sjekk bypasses 6h cache.",
"USE_ONE_CUSTOM_PATH_BELOW_AS_THE_FOOTER_PROGRESS_VALUE": "Bruk én Egendefinert Sti below som Bunnlinje Fremdrift verdi.",
"USE_SELECTED": "Bruk valgt",
"USE_THE_MAIN_DIRECTORY_FROM_THE_ACTIVE_RTORRENT_PROFILE": "Bruk main Mappe fra Aktiv rTorrent Profil.",
"VIEW_PREFERENCES_RESET": "View Innstillinger Tilbakestill",
"VIEW_STATE_IS_SAVED_AUTOMATICALLY_IN_THE_DATABASE_CURRENT_TORRENT_FILTER_LAST_SO": "View Status er Lagret automatically i database: Gjeldende Torrent filter, Siste sort column og direction, synlig Kolonner, og Detaljer Panel height.",
"VISIBLE_NAME_USED_IN_THE_PROFILE_SELECTOR": "Synlig Navn used i Profil selector.",
"VISUAL_HELPER_FOR_TRACKER_FILTERS_IN_THE_SIDEBAR": "Visual hjelper for Tracker filters i sidefelt.",
"WAITING_FOR_DATA": "Venter for Data.",
"WAITING_FOR_TORRENT_DATA_FROM_THE_ACTIVE_PROFILE": "Venter for Torrent Data fra Aktiv Profil.",
"WATCHDOG_MARKS_A_LIGHT_JOB_AS_FAILED_AFTER_THIS_TIME_DEFAULT_300_SECONDS": "Watchdog marks light Jobb som mislyktes etter denne tid. Standard: 300 sekunder.",
"WATCHDOG_TIMEOUT_FOR_MOVE_REMOVE_ADD_JOBS_DEFAULT_7200_SECONDS": "Watchdog Tidsavbrudd for Flytt/Fjern/Legg til Jobber. Standard: 7200 sekunder.",
"WHEN_DISABLED_THE_APPLICATION_USES_THE_NORMAL_PRODUCTION_UI": "When Deaktivert, Applikasjon uses normal produksjon UI.",
"WHEN_ENABLED_LOW_SPEED_IS_NOT_REQUIRED_WITH_SOURCE_AND_SPEED_IGNORES_ENABLED_ONL": "When Aktivert, lav Hastighet er ikke required. Med Kilde og Hastighet ignores Aktivert, bare Stoppede etter sekunder avgjør.",
"WHEN_ENABLED_SMART_QUEUE_DOES_NOT_USE_SEED_PEER_COUNT_AS_A_STALLED_CRITERION": "When Aktivert, Smart Queue does ikke bruk seed/peer count som stoppede kriterium.",
"WITH_ERROR": "Med Feil",
"WORKING": "Arbeider...",
"FRENCH": "Fransk",
"CZECH": "Tsjekkisk",
"SPANISH": "Spansk",
"NORWEGIAN": "Norsk",
"RUSSIAN": "Russisk"
}
}

View File

@@ -0,0 +1,566 @@
{
"meta": {
"locale": "pl_PL",
"label": "Polski",
"flag": "pl"
},
"translations": {
"0_MEANS_ONLY_SEED_THRESHOLD_IS_REQUIRED": "0 oznacza tylko seed próg jest wymagany.",
"0_MEANS_UNLIMITED_SLIDERS_USE_MBIT_S_AND_SAVE_THROUGH_THE_EXISTING_SPEED_LIMITS_": "0 oznacza bez limitu. Suwaki użyj Mbit/s i zapisz przez istniejące prędkość limity API.",
"0_SELECTED": "0 wybrane",
"ABOUT_PYTORRENT": "O pyTorrent",
"ACTION": "Akcja",
"ACTIVE": "Aktywne",
"ACTIVE_RTORRENT_DOWNLOADS_MAX_GLOBAL_DOWNLOADS": "Aktywne pobierania rTorrent / globalny limit pobierań",
"ACTIVE_RTORRENT_UPLOADS_MAX_GLOBAL_UPLOADS": "Aktywne wysyłania rTorrent / globalny limit wysyłań",
"ADD": "Dodaj",
"ADDED": "Dodano",
"ADDS_REMOVALS_COMPLETIONS_AND_QUEUED_OPERATION_RESULTS": "Adds, usunięcia, ukończenia i w kolejce operacja wyniki.",
"ADD_ACTION": "Dodaj akcja",
"ADD_CONDITION": "Dodaj warunek",
"ADD_CREATE_TORRENT": "Dodaj / utwórz torrent",
"ADD_FIRST_LABEL_ABOVE": "Dodaj pierwszą etykietę powyżej.",
"ADD_LABEL": "Dodaj etykieta",
"ADD_MAGNET_LINK": "Dodaj link magnet",
"ADD_NEW_LABEL": "Dodaj nowy etykieta",
"ADD_OR_EDIT_GROUP": "Dodaj lub edytuj grupa",
"ADD_PATH": "Dodaj ścieżkę",
"ADD_PROFILE": "Dodaj profil",
"ADD_RTORRENT_PROFILE": "Dodaj profil rTorrent",
"ADD_THE_FIRST_RTORRENT_PROFILE_TO_START_LOADING_TORRENTS": "Dodaj pierwszy profil rTorrent, aby zacząć ładować torrenty.",
"ADD_TORRENT": "Dodaj torrent",
"ADD_TORRENT_FILE": "Dodaj plik torrent",
"ADMIN": "administrator",
"ADMIN_ONLY_FULL_APPLICATION_BACKUP_RESTORE_CAN_REPLACE_USERS_PERMISSIONS_PROFILE": "Pełna kopia aplikacji tylko dla administratora. Przywracanie może zastąpić użytkowników, uprawnienia, profile i globalne ustawienia aplikacji.",
"AGGREGATE_ALL_PATHS": "Agreguj wszystkie ścieżki",
"ALL": "Wszystkie",
"ALL_NON_JOB_TYPES": "Wszystkie nie-zadanie typy",
"ALL_PROFILES": "Wszystkie profile",
"ALL_TYPES": "Wszystkie typy",
"ANNOUNCE": "Ogłoś",
"API_DOCS": "API dokumentacja",
"APPEARANCE": "Wygląd",
"APPEARANCE_PREFERENCES_SAVED": "Preferencje wyglądu zapisane",
"APPEARS_NEAR_CLICKED_BUTTONS_ONLY_SOMETIMES": "Czasami pojawia się przy klikniętych przyciskach.",
"APPLICATION_BACKUP": "Aplikacja kopia zapasowa",
"APPLICATION_BACKUP_NAME": "Aplikacja kopia zapasowa nazwa",
"APPLY": "Zastosuj",
"APPLY_RETENTION_NOW": "Zastosuj retencja teraz",
"APPLY_SAVED_CHANGES_60S_AFTER_PYTORRENT_START": "Zastosuj zapisane zmiany 60s po pyTorrent start",
"APP_STATUS": "Aplikacja status",
"AUTHENTICATION_IS_ENABLED_FOR_THIS_PYTORRENT_INSTANCE": "Uwierzytelnianie jest włączone dla ten pyTorrent instancja.",
"AUTHOR": "Autor",
"AUTOMATIC": "Automatyczne",
"AUTOMATIC_KEEPS_THE_CURRENT_POLLER_CADENCE_CUSTOM_RUNS_ONLY_AFTER_THE_SELECTED_N": "Tryb automatyczny zachowuje bieżący rytm odświeżania. Tryb własny uruchamia się dopiero po wybranej liczbie minut. Wyłączenie całkowicie wyłącza uzupełnianie.",
"AUTOMATIC_QUEUE_BALANCING_FOR_SLOW_OR_STALLED_DOWNLOADS": "Automatyczne kolejka równoważenie for wolne lub zatrzymane pobierania.",
"AUTOMATIC_RUNS_USE_THE_COOLDOWN_BELOW_MANUAL_CHECK_NOW_STILL_RUNS_IMMEDIATELY": "Automatyczne uruchomienia używają poniższego czasu odczekania. Ręczne „Sprawdź teraz” nadal uruchamia się od razu.",
"AUTOMATIONS": "Automatyzacje",
"AUTOMATIONS_RULES": "Automatyzacje / reguły",
"AUTOMATION_TOASTS": "Toasty automatyzacji",
"AUTO_STOP_WHEN_IDLE": "Auto-stop when bezczynność",
"BACKEND": "Backend",
"BACKUP": "Kopia zapasowa",
"BACKUP_RESTORE": "Kopia zapasowa / przywróć",
"BACK_TO_DASHBOARD": "Wróć to panel",
"BOOTSTRAP_THEME": "Motyw Bootstrap",
"BROWSER": "Przeglądarka",
"BROWSER_TITLE": "Tytuł przeglądarki",
"BUILD_A_RULE_AS_CONDITIONS_FIRST_THEN_ORDERED_ACTIONS_MATCHING_TORRENTS_ARE_HAND": "Zbuduj regułę: najpierw warunki, potem uporządkowane akcje. Pasujące torrenty są obsługiwane jako jedna partia, a czas odczekania dotyczy całej reguły.",
"BUSY": "zajęty",
"BY_DAYS": "Według dni",
"BY_LINE_COUNT": "Według linia liczba",
"CACHED_METADATA_SUMMARY_FILE_METADATA_IS_REFRESHED_EVERY_15_MINUTES_A_FEW_MINUTE": "Podsumowanie metadanych z pamięci podręcznej. Metadane plików są odświeżane co 15 minut, kilka minut po starcie lub ręcznie.",
"CANCEL": "Anuluj",
"CANCEL_EDIT": "Anuluj edytuj",
"CATEGORY": "Kategoria",
"CHANGES_APPLY_IMMEDIATELY_WHERE_POSSIBLE_INITIAL_STARTUP_LOADER_USES_THEM_AFTER_": "Zmiany są stosowane od razu tam, gdzie to możliwe; początkowy loader użyje ich po przeładowaniu.",
"CHANGE_RTORRENT": "Zmień rTorrent",
"CHANGE_THEME": "Zmień motyw",
"CHANGING_RTORRENT_RELOADS_THE_LIVE_TORRENT_SNAPSHOT": "Zmiana rTorrent przeładowuje bieżący torrent migawka.",
"CHECKING": "Sprawdzanie",
"CHECK_NOW": "Sprawdź teraz",
"CHECK_PORT_NOW": "Sprawdź port teraz",
"CHECK_THIS_IF_YOU_WANT_TO_CONNECT_TO_A_REMOTE_RTORRENT_INSTANCE_INSTEAD_OF_LOCAL": "Sprawdź ten jeśli chcesz chcesz do połączyć do a zdalna rTorrent instancja zamiast of localhost.",
"CHOOSE_COLUMNS_VISIBLE_IN_THE_TORRENT_LIST": "Wybierz kolumny widoczny w torrent lista.",
"CHOOSE_FILES": "Wybierz files",
"CHOOSE_RTORRENT": "Wybierz rTorrent",
"CHOOSE_TORRENTS_IGNORED_BY_SMART_QUEUE_EXISTING_BEHAVIOR_STAYS_UNCHANGED_FOR_ALL": "Wybierz torrenty ignorowane przez Smart Queue. Obecne działanie pozostaje bez zmian dla wszystkich niewykluczonych torrentów.",
"CHOOSE_WHAT_THE_FOOTER_DISK_BAR_SHOULD_REPRESENT_AND_ADD_EXTRA_STORAGE_PATHS": "Wybierz, co ma pokazywać pasek dysku w stopce, i dodaj dodatkowe ścieżki przechowywania.",
"CHOOSE_WHICH_STATUS_ITEMS_ARE_VISIBLE_IN_THE_BOTTOM_BAR": "Wybierz elementy statusu widoczne na dolnym pasku.",
"CHUNKS": "Części",
"CLEANUP": "Czyszczenie",
"CLEANUP_RETENTION": "Czyszczenie / retencja",
"CLEAR": "Wyczyść",
"CLEAR_CURRENT_FILTER": "Wyczyść bieżące filter",
"CLEAR_FINISHED": "Wyczyść ukończone",
"CLEAR_LABELS": "Wyczyść etykiety",
"CLEAR_SELECTION": "wyczyść zaznaczenie",
"CLEAR_VISIBLE": "Wyczyść widoczne",
"CLOSE": "Zamknij",
"COLUMNS": "Kolumny",
"COLUMNS_SAVED": "Kolumny zapisane",
"COMMENT": "Komentarz",
"COMPACT_TORRENT_LIST": "Kompaktowa lista torrentów",
"COMPLETE": "Ukończone",
"COMPLETED": "Ukończone",
"CONFIGURED_RTORRENTS": "Skonfigurowane rTorrents",
"CONNECTING_TO_RTORRENT_AND_PREPARING_DATA": "Łączenie z rTorrent i przygotowywanie danych.",
"CONNECTION_ADDRESS_IN_SCGI_HOST_PORT_RPC2_FORMAT": "Adres połączenia w formacie scgi://host:port/RPC2.",
"CONTROLS_THE_DEFAULT_CATEGORY_AND_JOB_LOG_VISIBILITY_USED_BY_THE_LOGS_MODAL": "Steruje domyślną kategorią i widocznością logów zadań w oknie logów.",
"CONTROLS_WHAT_IS_SHOWN_IN_THE_BROWSER_TAB": "Steruje tym, co widać w karcie przeglądarki.",
"COOLDOWN_MINUTES": "Czas odczekania minuty",
"COPY": "Kopiuj",
"COPY_HASH": "Kopiuj hash",
"COPY_NAME": "Kopiuj nazwa",
"COPY_PATH": "Kopiuj ścieżka",
"CPU_RAM_USAGE": "CPU / RAM użycie",
"CREATE": "Utwórz",
"CREATES_AND_RESTORES_SETTINGS_FOR_THE_CURRENTLY_SELECTED_PROFILE_USER_SCOPED_PRE": "Tworzy i przywraca ustawienia aktualnie wybranego profilu. Preferencje przypisane do użytkownika są w razie potrzeby przepinane na bieżącego użytkownika.",
"CREATE_APPLICATION_BACKUP": "Utwórz aplikacja kopia zapasowa",
"CREATE_ONE_RTORRENT_PROFILE_AT_A_TIME_MOVE_REMOVE_QUEUES_KEEP_THEIR_ORDER_FOR_EA": "Twórz po jednym profilu rTorrent naraz. Kolejki przenoszenia/usuwania zachowują kolejność dla każdego profilu.",
"CREATE_PROFILE_BACKUP": "Utwórz profil kopia zapasowa",
"CREATE_REUSABLE_LABELS_AND_REMOVE_LABELS_THAT_ARE_NO_LONGER_NEEDED": "Twórz etykiety wielokrotnego użytku i usuwaj te, które nie są już potrzebne.",
"CREATE_TORRENT": "Utwórz torrent",
"CTRL_A_SELECT_VISIBLE": "Ctrl+A - wybierz widoczne",
"CTRL_I_INVERT_VISIBLE": "Ctrl+I - odwróć widoczne",
"CTRL_O_ADD": "Ctrl+O - dodaj",
"CTRL_S_DOWNLOAD_TORRENT": "Ctrl+S - pobierz .torrent",
"CURRENT_TRANSFER_SPEED": "Aktualna prędkość transferu",
"CUSTOM_DOWNLOAD": "Własne pobierz",
"CUSTOM_UPLOAD": "Własne wyślij",
"DAYS_AND_LINE_COUNT": "Dni i linia liczba",
"DECREASE_OR_INCREASE_THE_WHOLE_INTERFACE_SIZE": "Zmniejsz lub zwiększ rozmiar całego interfejsu.",
"DEFAULT_LOG_CATEGORY": "Domyślne log kategoria",
"DEFAULT_LOG_VIEW": "Domyślne log view",
"DEFAULT_RTORRENT_PATH": "Domyślna ścieżka rTorrent",
"DEGRADED": "ograniczone",
"DELETE": "Usuń",
"DELETE_REMOVE": "Delete - usuń",
"DESKTOP": "Desktop",
"DETAILS_WILL_APPEAR_AFTER_THE_FIRST_SUCCESSFUL_RESPONSE": "Szczegóły pojawią się po pierwszej poprawnej odpowiedzi.",
"DIAGNOSTICS": "Diagnostyka",
"DISABLED": "wyłączone",
"DISK_MONITOR": "Monitor dysku",
"DISK_USAGE_UNAVAILABLE": "Dysk użycie niedostępna",
"DISPLAYS_CURRENT_SPEEDS_NEXT_TO_PYTORRENT_IN_THE_TAB_TITLE": "Pokazuje bieżące prędkości obok pyTorrent w tytule karty.",
"DOCS_API": "Dokumentacja API",
"DOWNLOADED": "Pobrano",
"DOWNLOADING": "Pobieranie",
"DOWNLOADS": "Pobierania",
"DOWNLOAD_KIB_S": "Pobierz KiB/s",
"DOWNLOAD_STARTED": "Pobieranie rozpoczęte",
"DOWNLOAD_TORRENT": "Pobierz .torrent",
"DOWNLOAD_TRACKER_FAVICONS": "Pobieraj favikony trackerów",
"DRAG_TO_RESIZE_DETAILS_PANEL": "Przeciągnij to zmień rozmiar szczegóły panel",
"EASTER_EGG": "Easter egg",
"EDIT": "Edytuj",
"EMERGENCY_CANCEL": "Awaryjnie anuluj",
"EMERGENCY_CLEAN_ALL": "Awaryjne wyczyść wszystkie",
"ENABLED": "Włączone",
"ENABLE_AUTOMATIC_APPLICATION_BACKUPS": "Enable automatyczne aplikacja backups",
"ENABLE_AUTOMATIC_PROFILE_BACKUPS": "Enable automatyczne profil backups",
"ENABLE_EASTER_EGG": "Włącz easter egg",
"ENABLE_INCOMING_PORT_CHECK": "Włącz sprawdzanie portu przychodzącego",
"ENABLE_REVERSE_DNS_FOR_PEERS": "Włącz Reverse DNS dla peerów",
"ENGLISH": "Angielski",
"EPISODE": "Odcinek",
"ESC_CLEAR_SELECTION": "Esc - wyczyść zaznaczenie",
"ETA": "ETA",
"EVERY_N_MINUTES": "Co N minuty",
"EVERY_X_HOURS": "Co X hours",
"EXCLUDE": "Wyklucz",
"EXCLUDE_FROM_SMART_QUEUE": "Wyklucz z Smart Queue",
"EXCLUDE_PATTERN": "Wyklucz wzorzec",
"EXISTING_GROUPS": "Istniejące groups",
"EXPORT": "Eksport",
"EXPORT_JSON": "Eksport JSON",
"EXTERNAL_AUTHENTICATION_IS_ENABLED_THROUGH_EXTERNAL_PROVIDER": "Zewnętrzne uwierzytelnianie jest włączone przez {{ external_provider }}.",
"E_G_PUNKTY_KATALOGOWANIE": "e.g. Punkty katalogowanie",
"FEATURES": "Funkcje",
"FEED": "Kanał",
"FEEDS_ARE_CHECKED_BY_SCHEDULE_AND_EVERY_MATCH_IS_LOGGED_PER_FEED_RULE": "Kanały są sprawdzane według harmonogramu, a każde dopasowanie jest zapisywane dla kanału/reguły.",
"FEEDS_RULES_AND_MATCHES": "Kanały, reguły i dopasowania",
"FEED_NAME": "Kanał nazwa",
"FEED_URL": "Kanał URL",
"FILES": "Pliki",
"FILE_OR_DIRECTORY_PATH": "Plik lub katalog ścieżka",
"FONT": "Czcionka",
"FOOTER": "Stopka",
"FORCE": "Wymuś",
"FORCE_RECHECK": "Wymuś sprawdź ponownie",
"FRONTEND": "Frontend",
"FULL": "Full",
"GENERAL": "Ogólne",
"GENERATED_RTORRENT_CONFIG_CHANGES_WILL_APPEAR_HERE": "Wygenerowane rTorrent konfiguracja zmiany będzie pojawi się tutaj.",
"GENERATE_CONFIG": "Generuj konfiguracje",
"GERMAN": "Niemiecki",
"GO": "Go",
"GROUPED_RTORRENT_RUNTIME_SETTINGS_WITH_INLINE_RECOMMENDATIONS_AND_COMPATIBILITY_": "Pogrupowane ustawienia runtime rTorrent z rekomendacjami inline i statusem zgodności.",
"GROUP_NAME": "Grupa nazwa",
"HASH": "Hash",
"HASHING": "Hashowanie",
"HEAVY_PARALLEL_JOBS": "Ciężkie równoległe zadania",
"HEAVY_TIMEOUT_SECONDS": "Ciężkie limit czasu sekundy",
"HIDE_JOB_LOGS": "Ukryj zadanie logi",
"HIDE_JOB_LOGS_BY_DEFAULT": "Ukryj zadanie logi według domyślne",
"HISTORY": "Historia",
"HOW_LONG_A_MATCHING_ACTIVE_TORRENT_MUST_STAY_STALLED_BEFORE_IT_CAN_BE_REPLACED": "Jak długo pasujący aktywny torrent musi pozostawać zatrzymany, zanim będzie można go zastąpić.",
"IGNORE_MISSING_SEEDS_PEERS_FOR_STALLED_TIMER": "Ignoruj brakujące seeds/peery for zatrzymane licznik",
"IGNORE_SPEED_FOR_STALLED_TIMER": "Ignoruj prędkość for zatrzymane licznik",
"IMPORT": "Import",
"IMPORT_JSON": "Import JSON",
"INCLUDE_PATTERN": "Uwzględnij wzorzec",
"INCOMING_CONNECTION_TEST_SEPARATE_FROM_VISUAL_PREFERENCES": "Przychodzący połączenie test, oddzielny z wizualne preferencje.",
"INTERFACE_SCALE": "Skala interfejsu",
"INTERVAL_MINUTES": "Interwał minuty",
"INVERT_VISIBLE": "odwróć widoczne",
"JOBS": "Zadania",
"JOB_DONE": "Zadanie ukończone",
"JOB_FAILED": "Zadanie nieudane",
"JOB_QUEUE": "Zadanie kolejka",
"JOB_SCHEDULING": "Zadanie planowanie",
"JOB_STARTED": "Zadanie rozpoczęto",
"KEEP_LINES": "Zachowaj lines",
"KEEP_SEEDING": "Zachowaj seedowanie",
"LABEL": "Etykieta",
"LABELS": "Etykiety",
"LABELS_SEPARATED_BY_COMMA": "Etykiety oddzielone według przecinek",
"LABEL_AFTER_SHARE": "Etykieta po udostępnij",
"LABEL_EXISTS": "Etykieta exists",
"LABEL_IS_MISSING": "Etykieta jest brakujące",
"LABEL_NAME_OR_SEVERAL_SEPARATED_BY_COMMA": "Etykieta nazwa, lub several oddzielone według przecinek",
"LANGUAGE": "Język",
"LANGUAGE_SAVED": "Język zapisane",
"LAST_OPERATIONS": "Ostatnie operacje",
"LICENSE": "Licencja",
"LIGHTWEIGHT_WEB_PANEL_FOR_RTORRENT_MANAGEMENT_QUEUE_CONTROL_AND_LIVE_TORRENT_DIA": "Lekki panel webowy do zarządzania rTorrent, kontroli kolejki i diagnostyki torrentów na żywo.",
"LIGHT_PARALLEL_JOBS": "Lekkie równoległe zadania",
"LIGHT_TIMEOUT_SECONDS": "Lekkie limit czasu sekundy",
"LIMIT_DL": "Limit DL",
"LOADING": "Ładowanie",
"LOADING_CLEANUP_DATA": "Ładowanie czyszczenie dane...",
"LOADING_CONFIG": "Ładowanie konfiguracja...",
"LOADING_IMAGE_URL": "Ładowanie obraz URL",
"LOADING_JOBS": "Ładowanie zadania...",
"LOADING_LOGS": "Ładowanie logi...",
"LOADING_PROFILES": "Ładowanie profile...",
"LOADING_STATISTICS": "Ładowanie statystyki...",
"LOADING_TORRENTS": "Ładowanie torrentów...",
"LOADING_TORRENT_DETAILS": "Ładowanie szczegółów torrenta...",
"LOCAL_BROWSER_TIME": "Local przeglądarka time",
"LOCATION": "Lokalizacja",
"LOG": "Log",
"LOGS": "Logi",
"LOG_IN": "Zaloguj",
"LOG_OUT": "Wyloguj",
"LOG_STATISTICS": "Log statystyki",
"MAGNET_AND_TORRENT_UPLOAD_FILE_PRIORITIES_LABELS_RATIO_GROUPS_SMART_QUEUE_AUTOMA": "Dodawanie magnet i plików torrent, priorytety plików, etykiety, grupy ratio, Smart Queue, reguły automatyzacji, RSS, wykresy transferu, sprawdzanie portu i status systemu.",
"MAGNET_LINKS": "Magnet links",
"MANAGE_EXCEPTIONS": "Zarządzaj exceptions",
"MANAGE_OPERATION_LOG_RETENTION_WITHOUT_CHANGING_TORRENT_DATA": "Zarządzaj operacja log retencja bez zmiana torrent dane.",
"MANAGE_OPTIONAL_PYTORRENT_USERS_EMPTY_PROFILE_MEANS_ALL_PROFILES_R_O_BLOCKS_RTOR": "Zarządzaj opcjonalnymi użytkownikami pyTorrent. Pusty profil oznacza wszystkie profile. R/O blokuje akcje zmieniające rTorrent; Full je dopuszcza.",
"MANUAL_CLEANUP_ONLY": "Ręczne czyszczenie tylko",
"MAXIMUM_HEAVY_JOBS_RUNNING_AT_ONCE_FOR_THIS_PROFILE_DEFAULT_5": "Maximum ciężkie zadania uruchomione at naraz dla ten profil. Domyślne: 5.",
"MAXIMUM_QUEUED_ACTIONS_RUNNING_AT_ONCE": "Maximum w kolejce akcje uruchomione at naraz.",
"MAXIMUM_STALLED_OVERFLOW_DOWNLOADS_SMART_QUEUE_MAY_STOP_IN_ONE_PASS": "Maksimum zatrzymane/nadmiarowe pobierania Smart Queue może stop w jedno przebieg.",
"MAX_MB": "Maks. MB",
"MAX_RATIO": "Maks. ratio",
"MAX_STOPS_PER_CHECK": "Maks. stops per sprawdź",
"MESSAGE": "Komunikat",
"MINUTES": "Minuty",
"MIN_MB": "Min. MB",
"MIN_PEERS": "Min. peery",
"MIN_RATIO": "Min. ratio",
"MIN_SEEDS": "Min. seeds",
"MIN_SEED_MINUTES": "Min. seed minuty",
"MIN_SPEED_KIB_S": "Min. prędkość KiB/s",
"MOBILE": "Mobile",
"MOBILE_COLUMNS": "Kolumny mobile",
"MOBILE_FILTER_GROUPS": "Grupy filtrów mobile",
"MOBILE_SIMPLE_MODE": "Mobile/prosty tryb",
"MOBILE_SORT_FILTERS": "Filtry sortowania mobile",
"MODE": "Tryb",
"MONITORED_PATHS": "Monitorowane ścieżki",
"MOVE": "Przenieś",
"MOVE_DATA": "Przenieś dane",
"MOVE_DATA_FILES": "Przenieś dane files",
"MOVE_PATH": "Przenieś ścieżka",
"MOVE_TO_PATH": "Przenieś to ścieżka",
"MOVIES": "filmy",
"MOVING": "Przenoszenie",
"M_MOVE": "M - przenieś",
"NAME": "Nazwa",
"NEGATE": "Zaneguj",
"NEWLY_QUEUE_STARTED_TORRENTS_ARE_PROTECTED_FROM_STALLED_CLEANUP_DURING_THIS_WARM": "Newly kolejka-rozpoczęto torrents są chronione z zatrzymane czyszczenie podczas ten rozgrzewka-up.",
"NEW_LABEL": "Nowy etykieta",
"NEXT_READY": "następne: gotowe",
"NEXT_SMART_QUEUE_RUN": "Następne Smart Queue uruchomienie",
"NOTIFICATIONS": "Powiadomienia",
"NOT_LOADED": "Nie załadowano.",
"NO_CHANGES": "Brak zmian",
"NO_FILES_RETURNED_BY_RTORRENT": "rTorrent nie zwrócił plików.",
"NO_FILES_SELECTED": "Nie zaznaczono plików",
"NO_LABELS": "Brak etykiet.",
"NO_LABELS_SELECTED": "Nie wybrano etykiet.",
"NO_PATH_LOADED": "Brak ścieżka załadowane.",
"NO_PEERS_RETURNED_BY_RTORRENT": "rTorrent nie zwrócił peerów.",
"NO_RTORRENT_PROFILE_CONFIGURED": "Brak skonfigurowanego profilu rTorrent.",
"NO_SAVED_LABELS": "Brak zapisanych etykiet.",
"NO_TORRENTS": "Brak torrentów.",
"NO_TORRENTS_FOR_THIS_FILTER": "Brak torrentów dla tego filtra.",
"NO_TORRENTS_SELECTED": "Nie zaznaczono torrentów",
"NO_TORRENT_SELECTED": "Nie zaznaczono torrenta",
"NO_TRACKERS_RETURNED_BY_RTORRENT": "rTorrent nie zwrócił trackerów.",
"OFF": "Wył.",
"OFFLINE": "offline",
"ONE_PLACE_TO_CLEAR_LOGS_AND_ACTIVE_PROFILE_CACHES_PENDING_RUNNING_JOBS_RULES_SET": "Jedno miejsce do czyszczenia logów i pamięci podręcznej aktywnego profilu. Oczekujące/uruchomione zadania, reguły, ustawienia i torrenty pozostają bez zmian.",
"ONE_TRACKER_URL_PER_LINE": "Jeden tracker URL per linia",
"ONLINE": "online",
"ONLY_SELECTED": "Tylko wybrane",
"OPEN_DOWNLOAD_PLANNER": "Otwórz Pobierz planer",
"OPEN_RTORRENT_FILES_MAX_OPEN_FILES": "Otwórz rTorrent files / maks. otwórz files",
"OPEN_RTORRENT_HTTP_CONNECTIONS_MAX_HTTP_CONNECTIONS": "Otwórz rTorrent HTTP connections / maks. HTTP connections",
"OPEN_RTORRENT_SOCKETS": "Otwórz rTorrent sockets",
"OPEN_SOURCE": "Open source",
"OPEN_THIS_TAB_TO_LOAD_DIAGNOSTICS": "Otwórz ten karta do załaduj diagnostyka.",
"OPEN_THIS_TAB_TO_LOAD_STATISTICS": "Otwórz ten karta do załaduj statystyki.",
"OPERATION_LOG_RETENTION": "Operacja log retencja",
"OPTIONAL": "opcjonalne",
"OPTIONAL_PEER_TABLE_HELPERS": "Opcjonalne peer table helpers.",
"OPTIONAL_PRIVATE_SOURCE_TAG": "opcjonalne prywatny źródło tag",
"OPTIONAL_VISUAL_EASTER_EGG_FOR_LOADING_STATES_AND_OCCASIONAL_BUTTON_CLICKS_DISAB": "Opcjonalne wizualne easter egg dla ładowanie stany i sporadyczne przycisk kliknięcia. Wyłączone według domyślne.",
"PARALLEL_JOBS": "Równoległe zadania",
"PARENT_DIRECTORY": "Nadrzędny katalog",
"PARTIAL_DETAILS_LOADED": "Załadowano część szczegółów",
"PASSWORD": "Hasło",
"PASSWORD_NEW_PASSWORD": "Hasło / nowy hasło",
"PASTE_ONE_MAGNET_URI_PER_LINE": "Wklej jeden magnet URI per linia.",
"PATH": "Ścieżka",
"PATH_CONTAINS": "Ścieżka zawiera",
"PATH_TEXT": "Ścieżka tekst",
"PATH_USED_FOR_SELECTED_MODE": "Ścieżka używana dla wybranego trybu",
"PAUSE": "Pauza",
"PAUSED": "Wstrzymane",
"PEAK_S": "Szczyt S",
"PEAK_SPEED_UNAVAILABLE": "Szczyt prędkość niedostępna",
"PEERS": "Peery",
"PEERS_AUTO_REFRESH": "Peery auto odśwież",
"PENDING_JOBS_OLDER_THAN_THIS_ARE_RESUBMITTED_IF_NO_WORKER_IS_CURRENTLY_HANDLING_": "Oczekujące zadania starsze niż ten są ponownie zgłaszane jeśli brak worker jest obecnie obsługuje je. Domyślne: 900 sekundy.",
"PENDING_RUNNING_DONE_FAILED_RETRY_AND_CANCEL_HISTORY": "Oczekujące, uruchomione, ukończone, nieudane, ponów i anuluj historia.",
"PENDING_TIMEOUT_SECONDS": "Oczekujące limit czasu sekundy",
"PIECE_SIZE": "Część size",
"PLANNER": "Planer",
"POLISH": "Polski",
"PORT": "Port",
"PORT_CHECKER": "Sprawdzanie portu",
"PORT_CHECK_DISABLED": "Port sprawdź wyłączone",
"PORT_UNKNOWN": "Port - unknown",
"POST_CHECK": "Po sprawdzeniu",
"PREFERENCES": "Preferencje",
"PREFERENCES_SAVED": "Preferencje zapisane",
"PRIORITY": "Priorytet",
"PRIVATE_TORRENT": "Prywatny torrent",
"PROFILE_BACKUP": "Profil kopia zapasowa",
"PROFILE_BACKUP_NAME": "Profil kopia zapasowa nazwa",
"PROFILE_BACKUP_RESTORES_ONLY_THE_ACTIVE_PROFILE_CONTEXT_APPLICATION_BACKUP_RESTO": "Profil kopia przywraca tylko aktywne profil kontekst. Aplikacja kopia przywraca globalne aplikacja dane i jest dostępne tylko do administratorzy.",
"PROFILE_NAME": "Profil nazwa",
"PROFILE_SCOPED_LOG_COUNTS_AND_CLEANUP_OVERVIEW": "Profil-zakresowe log liczby i czyszczenie przegląd.",
"PROGRESS": "Postęp",
"PROGRESS_IS_AT_LEAST": "Postęp jest at least %",
"PROGRESS_IS_AT_MOST": "Postęp jest at most %",
"PROGRESS_SOURCE": "Źródło postępu",
"PROTECT_ACTIVE_COUNT_BELOW_CAP": "Chroń aktywne liczba poniżej cap",
"PYTORRENT_EXPECTS_TRUSTED_REVERSE_PROXY_IDENTITY_HEADERS_IF_YOU_ARE_ALREADY_SIGN": "pyTorrent oczekuje zaufanych nagłówków tożsamości z reverse proxy. Jeśli jesteś już zalogowany, sprawdź nagłówki dostawcy i mapowanie użytkownika.",
"PYTORRENT_LOGIN": "pyTorrent login",
"PYTORRENT_STATUS": "pyTorrent status",
"P_PAUSE": "P - pause",
"QUALITY": "Jakość",
"QUEUE_REFILL_DURING_COOLDOWN": "Kolejka uzupełnianie podczas czas odczekania",
"RANDOM_CLICK_IMAGE_URL": "Losowy click obraz URL",
"RATIO": "Ratio",
"RATIO_GROUP": "Grupa ratio",
"RATIO_GROUPS": "Ratio groups",
"RATIO_IS_AT_LEAST": "Ratio jest at least",
"RATIO_RULES": "Reguły ratio",
"REANNOUNCE": "Reannounce",
"RECHECK": "Sprawdź ponownie",
"RECHECK_AFTER_MOVE": "Sprawdź ponownie po przenieś",
"RECOMMENDED_COLUMNS": "Rekomendowane kolumny",
"RECOMMENDED_COLUMNS_APPLIED": "Zastosowano rekomendowane kolumny",
"RECONNECTING": "ponowne łączenie",
"REFERENCE_VALUE_IS_KEPT_FROM_THE_FIRST_OVERRIDE_SAVE_LATER_SAVES_ADD_OR_CLEAR_DI": "Wartość referencyjna wartość jest zachowana z pierwszy nadpisanie zapisz. Późniejsze zapisy dodaj lub wyczyść różnice bez replacing oryginał wartość referencyjna.",
"REFRESH": "Odśwież",
"REFRESH_NOW": "Odśwież teraz",
"REGEX_TEXT": "Regex / tekst",
"RELOAD": "Przeładowanie",
"REMOTE_LOCATION": "Zdalna lokalizacja",
"REMOVE": "Usuń",
"REMOVE_DATA": "usuń dane",
"REMOVE_LABEL": "Usuń etykieta",
"REMOVE_SELECTED_TORRENTS": "Usuń wybrane torrents",
"REMOVE_WITH_DATA": "Usuń z dane",
"REPOSITORY": "Repozytorium",
"RESET": "Resetuj",
"RESET_UI_SETTINGS": "Resetuj ustawienia UI",
"RESET_VIEW_DEFAULTS": "Resetuj ustawienia widoków",
"RESIZE_TORRENT_DETAILS_PANEL": "Zmień rozmiar torrent szczegóły panel",
"RESOLVE_PEER_IP_TO_REVERSE_DNS_HOST": "Rozwiąż peer IP to reverse DNS host",
"RESUME": "Wznów",
"RETENTION_DAYS": "dni retencji",
"RETENTION_MODE": "tryb retencji",
"RETRY": "Ponów",
"REVERSE_DNS": "Reverse DNS",
"RSS_DOWNLOADER": "RSS pobieracz",
"RTORRENT": "rTorrent",
"RTORRENTS": "rTorrents",
"RTORRENT_CONFIG": "rTorrent konfiguracja",
"RTORRENT_INCOMING_PORT": "rTorrent przychodzący port",
"RTORRENT_IS_STARTING_OR_NOT_RESPONDING_YET": "rTorrent uruchamia się albo jeszcze nie odpowiada.",
"RTORRENT_PROFILES": "rTorrent profile",
"RULE": "Zasada",
"RULES": "Zasady",
"RULES_ARE_CHECKED_AUTOMATICALLY_EVERY_5_MINUTES_A_TORRENT_USES_THE_GROUP_STORED_": "Reguły są sprawdzane automatically co 5 minut. torrent używa grupa zapisana w its rTorrent własne ratio pole.",
"RULE_NAME": "Reguła nazwa",
"RUNTIME": "Runtime",
"RUN_A_PROFILE_TEST_TO_SHOW_DIAGNOSTICS": "Run a profil test to pokaż diagnostyka.",
"RUN_SMART_QUEUE_DURING_POLLING_STOPPED_TORRENTS_ARE_MANAGED_PAUSED_TORRENTS_STAY": "Uruchomienie Smart Queue podczas pollingu. Zatrzymane torrents są zarządzane; Wstrzymane torrents pozostają użytkownik-kontrolowane.",
"R_RESUME": "R - wznów",
"SAVE": "Zapisz",
"SAVED_LABELS": "Zapisane etykiety",
"SAVE_COLUMNS": "Zapisz kolumny",
"SAVE_CONFIG": "Zapisz konfiguracja",
"SAVE_EASTER_EGG": "Zapisz easter egg",
"SAVE_EXCEPTIONS": "Zapisz exceptions",
"SAVE_FEED": "Zapisz kanał",
"SAVE_FOOTER": "Zapisz stopka",
"SAVE_GROUP": "Zapisz grupa",
"SAVE_JOB_SETTINGS": "Zapisz zadanie ustawienia",
"SAVE_LIMITS": "Zapisz limity",
"SAVE_LOCATION": "Zapisz lokalizacja",
"SAVE_LOG_VIEW": "Zapisz log view",
"SAVE_PATH": "Zapisz ścieżka",
"SAVE_PREFERENCES": "Zapisz preferencje",
"SAVE_RETENTION": "Zapisz retencja",
"SAVE_RULE": "Zapisz reguła",
"SAVE_SCHEDULE": "Zapisz harmonogram",
"SAVE_USER": "Zapisz użytkownik",
"SCGI_URL": "SCGI URL",
"SEARCH_LOGS": "Szukaj logi...",
"SEARCH_TORRENTS": "Szukaj torrentów...",
"SEARCH_TORRENTS_TO_EXCLUDE": "Szukaj torrents to wyklucz...",
"SEASON": "Sezon",
"SECONDS_TO_WAIT_FOR_RTORRENT_RESPONSE": "Sekundy to czekać for rTorrent odpowiedź.",
"SEEDING": "Seedowanie",
"SEEDS": "Seedy",
"SEEDS_ARE_LOW_FOR_TIME": "Seeds are niska for time",
"SEED_MINUTES": "Seed minuty",
"SELECT": "Wybierz",
"SELECTED": "zaznaczono",
"SELECTED_LABELS": "Wybrane etykiety",
"SELECTED_MONITORED_PATH": "Wybrana monitorowana ścieżka",
"SELECTED_TORRENTS_WILL_BE_REMOVED": "wybrane torrents będzie be removed.",
"SELECT_A_MONITORED_PATH_FIRST": "Najpierw wybierz monitorowaną ścieżkę.",
"SELECT_A_TORRENT": "Wybierz a torrent.",
"SELECT_ONE_OR_MORE_TORRENT_FILES": "Wybierz jeden lub more .torrent files.",
"SELECT_PATH": "Wybierz ścieżka",
"SELECT_TORRENTS_THAT_SMART_QUEUE_SHOULD_IGNORE_USE_SEARCH_TO_FILTER_BY_NAME_LABE": "Wybierz torrents że Smart Queue powinien ignorować. Użyj szukaj do filtr według nazwa, etykieta, status lub hash.",
"SELECT_VISIBLE": "zaznacz widoczne",
"SEPARATE_SLOT_POOL_FOR_LIGHTWEIGHT_CONTROL_JOBS_SO_THEY_DO_NOT_WAIT_BEHIND_HEAVY": "Oddzielny slot pool for lekki kontrola zadania so they do nie czekać behind ciężkie IO work. Domyślne: 4.",
"SETTINGS": "Ustawienia",
"SET_LABEL": "Set etykieta...",
"SET_LABELS": "Set etykiety",
"SET_RATIO_GROUP": "Set ratio grupa...",
"SHARE_AFTER_CREATING": "Udostępnij po creating",
"SHORTCUTS": "Skróty",
"SHOWN": "Pokazane",
"SHOWN_IN_LOADING_STATES_INSTEAD_OF_THE_STANDARD_SPINNER": "Pokazane w ładowanie stany zamiast standardowy spinner.",
"SHOWS_TRACKER_ICONS_IN_THE_SIDEBAR_TRACKER_FILTER_WHEN_AVAILABLE": "Pokazuje tracker ikony w panel boczny tracker filtr when dostępne.",
"SHOW_COMBINED_USAGE_SINGLE_PATH_SELECTION_IS_DISABLED_IN_THIS_MODE": "Pokaż łączne użycie. Pojedynczy ścieżka zaznaczenie jest wyłączone zaloguj ten tryb.",
"SHOW_DL_UP_IN_BROWSER_TITLE": "Pokaż DL/UP w tytule przeglądarki",
"SHOW_SMART_QUEUE_AUTOMATIC_RUN_MESSAGES": "Pokaż Smart Queue automatyczne uruchomienie komunikaty.",
"SHOW_TOASTS_CREATED_BY_AUTOMATION_RUNS": "Pokaż toasty created według automation uruchomienia.",
"SIGN_IN": "Zaloguj",
"SIZE": "Rozmiar",
"SKIP_ACTIVE_UPLOAD": "Skip aktywne wyślij",
"SKIP_PRIVATE_TORRENTS": "Skip prywatny torrents",
"SMART_FILTERS": "Inteligentne filtry",
"SMART_QUEUE": "Smart Queue",
"SMART_QUEUE_EXCEPTIONS": "Smart Queue exceptions",
"SMART_QUEUE_KEEPS_ONLY_THIS_MANY_ACTIVE_DOWNLOADS_OVERFLOW_IS_STOPPED": "Smart Queue keeps tylko ten many aktywne pobierania; nadmiarowe jest zatrzymane.",
"SMART_QUEUE_TOASTS": "Smart Queue toasty",
"SOCKETS": "Sockets",
"SOURCE": "Źródło",
"SPACE_START": "Spacja - start",
"SPEED": "Prędkość",
"SPEED_LIMITS": "Prędkość limity",
"SPEED_TREND": "Prędkość trend",
"STALLED_AFTER_SECONDS": "Zatrzymane po sekundy",
"START": "Start",
"START_AFTER_ADD": "Start po dodaj",
"START_GRACE_SECONDS": "Start grace sekundy",
"STATE": "Stan",
"STATUS": "Status",
"STATUS_EQUALS": "Status równa się",
"STOP": "Stop",
"STOPPED": "Zatrzymane",
"S_STOP": "S - stop",
"TARGET_ACTIVE_DOWNLOADS": "Docelowy aktywne pobierania",
"TARGET_PATH": "Docelowy ścieżka",
"TEST_RULE": "Test reguła",
"TEST_SCGI": "Test SCGI",
"THEME_TYPOGRAPHY_AND_INTERFACE_SCALE": "Motyw, typografia i skala interfejsu.",
"THE_FOOTER_TOOLTIP_ALWAYS_SHOWS_DETAILS_FOR_AVAILABLE_PATHS_THIS_SETTING_ONLY_DE": "stopka podpowiedź zawsze pokazuje szczegóły dla dostępne ścieżki; ten ustawienie tylko decyduje która wartość steruje widoczny postęp pasek.",
"TIMEOUT": "Limit czasu",
"TOAST_NOTIFICATIONS_FROM_AUTOMATIC_SYSTEMS": "Toast powiadomienia z automatyczne systems.",
"TOOLS": "Narzędzia",
"TOOLS_RTORRENTS": "Narzędzia & rTorrents",
"TOOLS_SECTIONS": "Narzędzia sekcje",
"TORRENT_ADDED": "Torrent dodano",
"TORRENT_COMPLETED": "Torrent ukończone",
"TORRENT_DETAILS": "Szczegóły torrenta",
"TORRENT_FILES": "Torrent files",
"TORRENT_FILTERS": "Torrent filtry",
"TORRENT_PROPERTIES": "Torrent właściwości",
"TORRENT_REMOVED": "Torrent removed",
"TORRENT_STATISTICS": "Torrent statystyki",
"TORRENT_STATS": "Torrent statystyki",
"TOTAL_DL_UP": "Suma DL/UP",
"TO_DOWNLOAD": "Do pobrania",
"TRACKERS": "Trackery",
"TRACKER_ICONS": "Ikony trackerów",
"TRANSFER": "Transfer",
"TRANSFERRED_DATA": "Przesłane dane",
"TRANSFER_HISTORY": "Transfer historia",
"UNDEFINED": "niezdefiniowane",
"UNLIMITED": "Bez limitu",
"UPLOADED": "Wysłano",
"UPLOADS": "Wysyłania",
"UPLOAD_KIB_S": "Wyślij KiB/s",
"USER": "Użytkownik",
"USERS": "Użytkownicy",
"USES_A_LIGHTWEIGHT_BUILT_IN_RESOLVER_WITH_CACHE_HOSTNAMES_APPEAR_ONLY_IN_THE_PEE": "Używa lekki built-w resolver z cache. Nazwy hostów pojawi się tylko w Peery karta.",
"USES_LOWER_ROWS_AND_SMALLER_LIST_ELEMENTS_ON_DESKTOP_AND_MOBILE_SO_MORE_TORRENTS": "Używa niższe wiersze i smaller lista elements on desktop i mobile so more torrents zmieścić on ekran.",
"USES_YOUGETSIGNAL_FIRST_MANUAL_CHECK_BYPASSES_THE_6H_CACHE": "Używa YouGetSignal pierwszy. Ręczne sprawdź bypasses 6h cache.",
"USE_ONE_CUSTOM_PATH_BELOW_AS_THE_FOOTER_PROGRESS_VALUE": "Użyj jedno własne ścieżka poniżej jako stopka postęp wartość.",
"USE_SELECTED": "Używają wybrane",
"USE_THE_MAIN_DIRECTORY_FROM_THE_ACTIVE_RTORRENT_PROFILE": "Użyj main katalog z aktywne rTorrent profil.",
"VIEW_PREFERENCES_RESET": "Zresetowano ustawienia widoku",
"VIEW_STATE_IS_SAVED_AUTOMATICALLY_IN_THE_DATABASE_CURRENT_TORRENT_FILTER_LAST_SO": "Stan widoku jest zapisywany automatycznie w bazie: bieżący filtr torrentów, ostatnia kolumna sortowania i kierunek, widoczne kolumny oraz wysokość panelu szczegółów.",
"VISIBLE_NAME_USED_IN_THE_PROFILE_SELECTOR": "Widoczny nazwa używana w profil selector.",
"VISUAL_HELPER_FOR_TRACKER_FILTERS_IN_THE_SIDEBAR": "Wizualne pomocnik dla tracker filtry w panel boczny.",
"WAITING_FOR_DATA": "Oczekiwanie for dane.",
"WAITING_FOR_TORRENT_DATA_FROM_THE_ACTIVE_PROFILE": "Oczekiwanie na dane torrentów z aktywnego profilu.",
"WATCHDOG_MARKS_A_LIGHT_JOB_AS_FAILED_AFTER_THIS_TIME_DEFAULT_300_SECONDS": "Watchdog oznacza a lekkie zadanie jako nieudane po ten czas. Domyślne: 300 sekundy.",
"WATCHDOG_TIMEOUT_FOR_MOVE_REMOVE_ADD_JOBS_DEFAULT_7200_SECONDS": "Watchdog limit czasu for przenieś/usuń/dodaj zadania. Domyślne: 7200 sekundy.",
"WHEN_DISABLED_THE_APPLICATION_USES_THE_NORMAL_PRODUCTION_UI": "When wyłączone, aplikacja używa normalny produkcyjny UI.",
"WHEN_ENABLED_LOW_SPEED_IS_NOT_REQUIRED_WITH_SOURCE_AND_SPEED_IGNORES_ENABLED_ONL": "When włączone, niskie prędkość jest nie wymagany. Z źródło i prędkość ignores włączone, tylko Zatrzymane po sekund decyduje.",
"WHEN_ENABLED_SMART_QUEUE_DOES_NOT_USE_SEED_PEER_COUNT_AS_A_STALLED_CRITERION": "When włączone, Smart Queue does nie użyj seed/peer liczba jako zatrzymane kryterium.",
"WITH_ERROR": "Z błędem",
"WORKING": "Pracuję...",
"FRENCH": "Francuski",
"CZECH": "Czeski",
"SPANISH": "Hiszpański",
"NORWEGIAN": "Norweski",
"RUSSIAN": "Rosyjski"
}
}

View File

@@ -0,0 +1,566 @@
{
"meta": {
"locale": "ru_RU",
"label": "Русский",
"flag": "ru"
},
"translations": {
"0_MEANS_ONLY_SEED_THRESHOLD_IS_REQUIRED": "0 означает, что требуется только порог раздачи.",
"0_MEANS_UNLIMITED_SLIDERS_USE_MBIT_S_AND_SAVE_THROUGH_THE_EXISTING_SPEED_LIMITS_": "0 означает без ограничений. Ползунки используют Mbit/s и сохраняют значения через существующий API лимитов скорости.",
"0_SELECTED": "0 выбрано",
"ABOUT_PYTORRENT": "О pyTorrent",
"ACTION": "Действие",
"ACTIVE": "Активные",
"ACTIVE_RTORRENT_DOWNLOADS_MAX_GLOBAL_DOWNLOADS": "Активные загрузки rTorrent / макс. глобальные загрузки",
"ACTIVE_RTORRENT_UPLOADS_MAX_GLOBAL_UPLOADS": "Активные отдачи rTorrent / макс. глобальные отдачи",
"ADD": "Добавить",
"ADDED": "Добавлено",
"ADDS_REMOVALS_COMPLETIONS_AND_QUEUED_OPERATION_RESULTS": "Добавления, удаления, завершения и результаты операций в очереди.",
"ADD_ACTION": "Добавить действие",
"ADD_CONDITION": "Добавить условие",
"ADD_CREATE_TORRENT": "Добавить / создать торрент",
"ADD_FIRST_LABEL_ABOVE": "Добавьте первую метку выше.",
"ADD_LABEL": "Добавить метку",
"ADD_MAGNET_LINK": "Добавить magnet-ссылку",
"ADD_NEW_LABEL": "Добавить новую метку",
"ADD_OR_EDIT_GROUP": "Добавить или изменить группу",
"ADD_PATH": "Добавить путь",
"ADD_PROFILE": "Добавить профиль",
"ADD_RTORRENT_PROFILE": "Добавить профиль rTorrent",
"ADD_THE_FIRST_RTORRENT_PROFILE_TO_START_LOADING_TORRENTS": "Добавьте первый профиль rTorrent, чтобы начать загрузку торрентов.",
"ADD_TORRENT": "Добавить торрент",
"ADD_TORRENT_FILE": "Добавить torrent-файл",
"ADMIN": "администратор",
"ADMIN_ONLY_FULL_APPLICATION_BACKUP_RESTORE_CAN_REPLACE_USERS_PERMISSIONS_PROFILE": "Полная резервная копия приложения только для администратора. Восстановление может заменить пользователей, разрешения, профили и глобальные настройки приложения.",
"AGGREGATE_ALL_PATHS": "Агрегировать все пути",
"ALL": "Все",
"ALL_NON_JOB_TYPES": "Все типы, кроме заданий",
"ALL_PROFILES": "Все профили",
"ALL_TYPES": "Все типы",
"ANNOUNCE": "Анонсировать",
"API_DOCS": "Документация API",
"APPEARANCE": "Внешний вид",
"APPEARANCE_PREFERENCES_SAVED": "Настройки внешнего вида сохранены",
"APPEARS_NEAR_CLICKED_BUTTONS_ONLY_SOMETIMES": "Иногда появляется рядом с нажатыми кнопками.",
"APPLICATION_BACKUP": "Резервная копия приложения",
"APPLICATION_BACKUP_NAME": "Имя резервной копии приложения",
"APPLY": "Применить",
"APPLY_RETENTION_NOW": "Применить хранение сейчас",
"APPLY_SAVED_CHANGES_60S_AFTER_PYTORRENT_START": "Применить сохранённые изменения через 60s после запуска pyTorrent",
"APP_STATUS": "Статус приложения",
"AUTHENTICATION_IS_ENABLED_FOR_THIS_PYTORRENT_INSTANCE": "Аутентификация включена для этого экземпляра pyTorrent.",
"AUTHOR": "Автор",
"AUTOMATIC": "Автоматически",
"AUTOMATIC_KEEPS_THE_CURRENT_POLLER_CADENCE_CUSTOM_RUNS_ONLY_AFTER_THE_SELECTED_N": "Автоматический режим сохраняет текущий ритм poller. Пользовательский режим запускается только после выбранного числа минут. Выключение полностью отключает пополнение очереди.",
"AUTOMATIC_QUEUE_BALANCING_FOR_SLOW_OR_STALLED_DOWNLOADS": "Автоматическая балансировка очереди для медленных или зависших загрузок.",
"AUTOMATIC_RUNS_USE_THE_COOLDOWN_BELOW_MANUAL_CHECK_NOW_STILL_RUNS_IMMEDIATELY": "Автоматические запуски используют паузу ниже. Ручная проверка всё равно запускается сразу.",
"AUTOMATIONS": "Автоматизации",
"AUTOMATIONS_RULES": "Автоматизации / правила",
"AUTOMATION_TOASTS": "Уведомления автоматизации",
"AUTO_STOP_WHEN_IDLE": "Автостоп при простое",
"BACKEND": "Backend",
"BACKUP": "Резервная копия",
"BACKUP_RESTORE": "Резервное копирование / восстановление",
"BACK_TO_DASHBOARD": "Назад к панели",
"BOOTSTRAP_THEME": "Тема Bootstrap",
"BROWSER": "Браузер",
"BROWSER_TITLE": "Заголовок браузера",
"BUILD_A_RULE_AS_CONDITIONS_FIRST_THEN_ORDERED_ACTIONS_MATCHING_TORRENTS_ARE_HAND": "Создайте правило так: сначала условия, затем действия по порядку. Подходящие торренты обрабатываются одной партией, а пауза применяется ко всему правилу.",
"BUSY": "занято",
"BY_DAYS": "По дням",
"BY_LINE_COUNT": "По числу строк",
"CACHED_METADATA_SUMMARY_FILE_METADATA_IS_REFRESHED_EVERY_15_MINUTES_A_FEW_MINUTE": "Сводка метаданных из кэша. Метаданные файлов обновляются каждые 15 минут, через несколько минут после запуска или вручную.",
"CANCEL": "Отмена",
"CANCEL_EDIT": "Отменить редактирование",
"CATEGORY": "Категория",
"CHANGES_APPLY_IMMEDIATELY_WHERE_POSSIBLE_INITIAL_STARTUP_LOADER_USES_THEM_AFTER_": "Изменения применяются сразу, где это возможно; начальный загрузчик применит их после перезагрузки.",
"CHANGE_RTORRENT": "Сменить rTorrent",
"CHANGE_THEME": "Сменить тему",
"CHANGING_RTORRENT_RELOADS_THE_LIVE_TORRENT_SNAPSHOT": "Смена rTorrent перезагружает текущий снимок торрентов.",
"CHECKING": "Проверка",
"CHECK_NOW": "Проверить сейчас",
"CHECK_PORT_NOW": "Проверить порт сейчас",
"CHECK_THIS_IF_YOU_WANT_TO_CONNECT_TO_A_REMOTE_RTORRENT_INSTANCE_INSTEAD_OF_LOCAL": "Отметьте это, если хотите подключиться к удалённому экземпляру rTorrent вместо localhost.",
"CHOOSE_COLUMNS_VISIBLE_IN_THE_TORRENT_LIST": "Выберите столбцы, видимые в списке торрентов.",
"CHOOSE_FILES": "Выбрать файлы",
"CHOOSE_RTORRENT": "Выбрать rTorrent",
"CHOOSE_TORRENTS_IGNORED_BY_SMART_QUEUE_EXISTING_BEHAVIOR_STAYS_UNCHANGED_FOR_ALL": "Выберите торренты, игнорируемые Smart Queue. Текущее поведение остаётся без изменений для всех не исключённых торрентов.",
"CHOOSE_WHAT_THE_FOOTER_DISK_BAR_SHOULD_REPRESENT_AND_ADD_EXTRA_STORAGE_PATHS": "Выберите, что должна показывать полоса диска внизу, и добавьте дополнительные пути хранения.",
"CHOOSE_WHICH_STATUS_ITEMS_ARE_VISIBLE_IN_THE_BOTTOM_BAR": "Выберите элементы статуса, видимые на нижней панели.",
"CHUNKS": "Части",
"CLEANUP": "Очистка",
"CLEANUP_RETENTION": "Очистка / хранение",
"CLEAR": "Очистить",
"CLEAR_CURRENT_FILTER": "Очистить текущий фильтр",
"CLEAR_FINISHED": "Очистить завершённые",
"CLEAR_LABELS": "Очистить метки",
"CLEAR_SELECTION": "очистить выбор",
"CLEAR_VISIBLE": "Очистить видимые",
"CLOSE": "Закрыть",
"COLUMNS": "Столбцы",
"COLUMNS_SAVED": "Столбцы сохранены",
"COMMENT": "Комментарий",
"COMPACT_TORRENT_LIST": "Компактный список торрентов",
"COMPLETE": "Завершено",
"COMPLETED": "Завершённые",
"CONFIGURED_RTORRENTS": "Настроенные rTorrent",
"CONNECTING_TO_RTORRENT_AND_PREPARING_DATA": "Подключение к rTorrent и подготовка данных.",
"CONNECTION_ADDRESS_IN_SCGI_HOST_PORT_RPC2_FORMAT": "Адрес подключения в формате scgi://host:port/RPC2.",
"CONTROLS_THE_DEFAULT_CATEGORY_AND_JOB_LOG_VISIBILITY_USED_BY_THE_LOGS_MODAL": "Управляет категорией по умолчанию и видимостью логов заданий в модальном окне логов.",
"CONTROLS_WHAT_IS_SHOWN_IN_THE_BROWSER_TAB": "Управляет тем, что отображается во вкладке браузера.",
"COOLDOWN_MINUTES": "Пауза, минуты",
"COPY": "Копировать",
"COPY_HASH": "Копировать hash",
"COPY_NAME": "Копировать имя",
"COPY_PATH": "Копировать путь",
"CPU_RAM_USAGE": "Использование CPU / RAM",
"CREATE": "Создать",
"CREATES_AND_RESTORES_SETTINGS_FOR_THE_CURRENTLY_SELECTED_PROFILE_USER_SCOPED_PRE": "Создаёт и восстанавливает настройки для текущего выбранного профиля. Пользовательские предпочтения при необходимости переназначаются на текущего пользователя.",
"CREATE_APPLICATION_BACKUP": "Создать резервную копию приложения",
"CREATE_ONE_RTORRENT_PROFILE_AT_A_TIME_MOVE_REMOVE_QUEUES_KEEP_THEIR_ORDER_FOR_EA": "Создавайте по одному профилю rTorrent за раз. Очереди перемещения/удаления сохраняют порядок для каждого профиля.",
"CREATE_PROFILE_BACKUP": "Создать резервную копию профиля",
"CREATE_REUSABLE_LABELS_AND_REMOVE_LABELS_THAT_ARE_NO_LONGER_NEEDED": "Создавайте многоразовые метки и удаляйте метки, которые больше не нужны.",
"CREATE_TORRENT": "Создать торрент",
"CTRL_A_SELECT_VISIBLE": "Ctrl+A - выбрать видимые",
"CTRL_I_INVERT_VISIBLE": "Ctrl+I - инвертировать видимые",
"CTRL_O_ADD": "Ctrl+O - добавить",
"CTRL_S_DOWNLOAD_TORRENT": "Ctrl+S - скачать .torrent",
"CURRENT_TRANSFER_SPEED": "Текущая скорость передачи",
"CUSTOM_DOWNLOAD": "Пользовательская загрузка",
"CUSTOM_UPLOAD": "Пользовательская отдача",
"DAYS_AND_LINE_COUNT": "Дни и число строк",
"DECREASE_OR_INCREASE_THE_WHOLE_INTERFACE_SIZE": "Уменьшите или увеличьте размер всего интерфейса.",
"DEFAULT_LOG_CATEGORY": "Категория логов по умолчанию",
"DEFAULT_LOG_VIEW": "Вид логов по умолчанию",
"DEFAULT_RTORRENT_PATH": "Путь rTorrent по умолчанию",
"DEGRADED": "ограничено",
"DELETE": "Удалить",
"DELETE_REMOVE": "Delete - удалить",
"DESKTOP": "Рабочий стол",
"DETAILS_WILL_APPEAR_AFTER_THE_FIRST_SUCCESSFUL_RESPONSE": "Подробности появятся после первого успешного ответа.",
"DIAGNOSTICS": "Диагностика",
"DISABLED": "отключено",
"DISK_MONITOR": "Монитор диска",
"DISK_USAGE_UNAVAILABLE": "Использование диска недоступно",
"DISPLAYS_CURRENT_SPEEDS_NEXT_TO_PYTORRENT_IN_THE_TAB_TITLE": "Показывает текущие скорости рядом с pyTorrent в заголовке вкладки.",
"DOCS_API": "Документация API",
"DOWNLOADED": "Скачано",
"DOWNLOADING": "Загрузка",
"DOWNLOADS": "Загрузки",
"DOWNLOAD_KIB_S": "Загрузка KiB/s",
"DOWNLOAD_STARTED": "Загрузка начата",
"DOWNLOAD_TORRENT": "Скачать .torrent",
"DOWNLOAD_TRACKER_FAVICONS": "Загружать favicon трекеров",
"DRAG_TO_RESIZE_DETAILS_PANEL": "Перетащите, чтобы изменить размер панели деталей",
"EASTER_EGG": "Easter egg",
"EDIT": "Редактировать",
"EMERGENCY_CANCEL": "Аварийная отмена",
"EMERGENCY_CLEAN_ALL": "Аварийно очистить всё",
"ENABLED": "Включено",
"ENABLE_AUTOMATIC_APPLICATION_BACKUPS": "Включить автоматические резервные копии приложения",
"ENABLE_AUTOMATIC_PROFILE_BACKUPS": "Включить автоматические резервные копии профиля",
"ENABLE_EASTER_EGG": "Включить easter egg",
"ENABLE_INCOMING_PORT_CHECK": "Включить проверку входящего порта",
"ENABLE_REVERSE_DNS_FOR_PEERS": "Включить reverse DNS для пиров",
"ENGLISH": "Английский",
"EPISODE": "Эпизод",
"ESC_CLEAR_SELECTION": "Esc - очистить выбор",
"ETA": "ETA",
"EVERY_N_MINUTES": "Каждые N минут",
"EVERY_X_HOURS": "Каждые X часов",
"EXCLUDE": "Исключить",
"EXCLUDE_FROM_SMART_QUEUE": "Исключить из Smart Queue",
"EXCLUDE_PATTERN": "Шаблон исключения",
"EXISTING_GROUPS": "Существующие группы",
"EXPORT": "Экспорт",
"EXPORT_JSON": "Экспорт JSON",
"EXTERNAL_AUTHENTICATION_IS_ENABLED_THROUGH_EXTERNAL_PROVIDER": "Внешняя аутентификация включена через {{ external_provider }}.",
"E_G_PUNKTY_KATALOGOWANIE": "например: Punkty katalogowanie",
"FEATURES": "Возможности",
"FEED": "Лента",
"FEEDS_ARE_CHECKED_BY_SCHEDULE_AND_EVERY_MATCH_IS_LOGGED_PER_FEED_RULE": "Ленты проверяются по расписанию, и каждое совпадение записывается для ленты/правила.",
"FEEDS_RULES_AND_MATCHES": "Ленты, правила и совпадения",
"FEED_NAME": "Имя ленты",
"FEED_URL": "URL ленты",
"FILES": "Файлы",
"FILE_OR_DIRECTORY_PATH": "Путь к файлу или каталогу",
"FONT": "Шрифт",
"FOOTER": "Нижняя панель",
"FORCE": "Принудительно",
"FORCE_RECHECK": "Принудительная перепроверка",
"FRONTEND": "Frontend",
"FULL": "Полный",
"GENERAL": "Общее",
"GENERATED_RTORRENT_CONFIG_CHANGES_WILL_APPEAR_HERE": "Сгенерированные изменения конфигурации rTorrent появятся здесь.",
"GENERATE_CONFIG": "Сгенерировать конфигурацию",
"GERMAN": "Немецкий",
"GO": "Перейти",
"GROUPED_RTORRENT_RUNTIME_SETTINGS_WITH_INLINE_RECOMMENDATIONS_AND_COMPATIBILITY_": "Сгруппированные runtime-настройки rTorrent с inline-рекомендациями и статусом совместимости.",
"GROUP_NAME": "Имя группы",
"HASH": "Hash",
"HASHING": "Хеширование",
"HEAVY_PARALLEL_JOBS": "Тяжёлые параллельные задания",
"HEAVY_TIMEOUT_SECONDS": "Таймаут тяжёлых заданий, секунды",
"HIDE_JOB_LOGS": "Скрыть логи заданий",
"HIDE_JOB_LOGS_BY_DEFAULT": "Скрывать логи заданий по умолчанию",
"HISTORY": "История",
"HOW_LONG_A_MATCHING_ACTIVE_TORRENT_MUST_STAY_STALLED_BEFORE_IT_CAN_BE_REPLACED": "Как долго подходящий активный торрент должен оставаться зависшим, прежде чем его можно заменить.",
"IGNORE_MISSING_SEEDS_PEERS_FOR_STALLED_TIMER": "Игнорировать отсутствие сидов/пиров для таймера зависания",
"IGNORE_SPEED_FOR_STALLED_TIMER": "Игнорировать скорость для таймера зависания",
"IMPORT": "Импорт",
"IMPORT_JSON": "Импорт JSON",
"INCLUDE_PATTERN": "Шаблон включения",
"INCOMING_CONNECTION_TEST_SEPARATE_FROM_VISUAL_PREFERENCES": "Проверка входящего соединения, отдельно от визуальных настроек.",
"INTERFACE_SCALE": "Масштаб интерфейса",
"INTERVAL_MINUTES": "Интервал, минуты",
"INVERT_VISIBLE": "инвертировать видимые",
"JOBS": "Задания",
"JOB_DONE": "Задание выполнено",
"JOB_FAILED": "Задание не удалось",
"JOB_QUEUE": "Очередь заданий",
"JOB_SCHEDULING": "Планирование заданий",
"JOB_STARTED": "Задание начато",
"KEEP_LINES": "Сохранять строки",
"KEEP_SEEDING": "Продолжать раздачу",
"LABEL": "Метка",
"LABELS": "Метки",
"LABELS_SEPARATED_BY_COMMA": "Метки через запятую",
"LABEL_AFTER_SHARE": "Метка после раздачи",
"LABEL_EXISTS": "Метка существует",
"LABEL_IS_MISSING": "Метка отсутствует",
"LABEL_NAME_OR_SEVERAL_SEPARATED_BY_COMMA": "Имя метки или несколько через запятую",
"LANGUAGE": "Язык",
"LANGUAGE_SAVED": "Язык сохранён",
"LAST_OPERATIONS": "Последние операции",
"LICENSE": "Лицензия",
"LIGHTWEIGHT_WEB_PANEL_FOR_RTORRENT_MANAGEMENT_QUEUE_CONTROL_AND_LIVE_TORRENT_DIA": "Лёгкая веб-панель для управления rTorrent, контроля очереди и живой диагностики торрентов.",
"LIGHT_PARALLEL_JOBS": "Лёгкие параллельные задания",
"LIGHT_TIMEOUT_SECONDS": "Таймаут лёгких заданий, секунды",
"LIMIT_DL": "Лимит DL",
"LOADING": "Загрузка",
"LOADING_CLEANUP_DATA": "Загрузка данных очистки...",
"LOADING_CONFIG": "Загрузка конфигурации...",
"LOADING_IMAGE_URL": "Загрузка URL изображения",
"LOADING_JOBS": "Загрузка заданий...",
"LOADING_LOGS": "Загрузка логов...",
"LOADING_PROFILES": "Загрузка профилей...",
"LOADING_STATISTICS": "Загрузка статистики...",
"LOADING_TORRENTS": "Загрузка торрентов...",
"LOADING_TORRENT_DETAILS": "Загрузка деталей торрента...",
"LOCAL_BROWSER_TIME": "Локальное время браузера",
"LOCATION": "Расположение",
"LOG": "Лог",
"LOGS": "Логи",
"LOG_IN": "Войти",
"LOG_OUT": "Выйти",
"LOG_STATISTICS": "Статистика логов",
"MAGNET_AND_TORRENT_UPLOAD_FILE_PRIORITIES_LABELS_RATIO_GROUPS_SMART_QUEUE_AUTOMA": "Загрузка magnet и torrent-файлов, приоритеты файлов, метки, группы ratio, Smart Queue, правила автоматизации, RSS, графики трафика, проверка порта и статус системы.",
"MAGNET_LINKS": "Magnet-ссылки",
"MANAGE_EXCEPTIONS": "Управлять исключениями",
"MANAGE_OPERATION_LOG_RETENTION_WITHOUT_CHANGING_TORRENT_DATA": "Управляйте хранением логов операций без изменения данных торрентов.",
"MANAGE_OPTIONAL_PYTORRENT_USERS_EMPTY_PROFILE_MEANS_ALL_PROFILES_R_O_BLOCKS_RTOR": "Управляйте дополнительными пользователями pyTorrent. Пустой профиль означает все профили. R/O блокирует действия, изменяющие rTorrent; Full разрешает их.",
"MANUAL_CLEANUP_ONLY": "Только ручная очистка",
"MAXIMUM_HEAVY_JOBS_RUNNING_AT_ONCE_FOR_THIS_PROFILE_DEFAULT_5": "Максимум тяжёлых заданий, выполняемых одновременно для этого профиля. По умолчанию: 5.",
"MAXIMUM_QUEUED_ACTIONS_RUNNING_AT_ONCE": "Максимум действий из очереди, выполняемых одновременно.",
"MAXIMUM_STALLED_OVERFLOW_DOWNLOADS_SMART_QUEUE_MAY_STOP_IN_ONE_PASS": "Максимум зависших/лишних загрузок, которые Smart Queue может остановить за один проход.",
"MAX_MB": "Макс. MB",
"MAX_RATIO": "Макс. ratio",
"MAX_STOPS_PER_CHECK": "Макс. остановок за проверку",
"MESSAGE": "Сообщение",
"MINUTES": "Минуты",
"MIN_MB": "Мин. MB",
"MIN_PEERS": "Мин. пиров",
"MIN_RATIO": "Мин. ratio",
"MIN_SEEDS": "Мин. сидов",
"MIN_SEED_MINUTES": "Мин. минут раздачи",
"MIN_SPEED_KIB_S": "Мин. скорость KiB/s",
"MOBILE": "Мобильный",
"MOBILE_COLUMNS": "Мобильные столбцы",
"MOBILE_FILTER_GROUPS": "Мобильные группы фильтров",
"MOBILE_SIMPLE_MODE": "Мобильный/простой режим",
"MOBILE_SORT_FILTERS": "Мобильные сортировки и фильтры",
"MODE": "Режим",
"MONITORED_PATHS": "Отслеживаемые пути",
"MOVE": "Переместить",
"MOVE_DATA": "Переместить данные",
"MOVE_DATA_FILES": "Переместить файлы данных",
"MOVE_PATH": "Путь перемещения",
"MOVE_TO_PATH": "Переместить в путь",
"MOVIES": "фильмы",
"MOVING": "Перемещение",
"M_MOVE": "M - переместить",
"NAME": "Имя",
"NEGATE": "Инвертировать",
"NEWLY_QUEUE_STARTED_TORRENTS_ARE_PROTECTED_FROM_STALLED_CLEANUP_DURING_THIS_WARM": "Торренты, недавно запущенные очередью, защищены от очистки зависших во время этого прогрева.",
"NEW_LABEL": "Новая метка",
"NEXT_READY": "далее: готово",
"NEXT_SMART_QUEUE_RUN": "Следующий запуск Smart Queue",
"NOTIFICATIONS": "Уведомления",
"NOT_LOADED": "Не загружено.",
"NO_CHANGES": "Нет изменений",
"NO_FILES_RETURNED_BY_RTORRENT": "rTorrent не вернул файлы.",
"NO_FILES_SELECTED": "Файлы не выбраны.",
"NO_LABELS": "Нет меток.",
"NO_LABELS_SELECTED": "Метки не выбраны.",
"NO_PATH_LOADED": "Путь не загружен.",
"NO_PEERS_RETURNED_BY_RTORRENT": "rTorrent не вернул пиров.",
"NO_RTORRENT_PROFILE_CONFIGURED": "Профиль rTorrent не настроен.",
"NO_SAVED_LABELS": "Нет сохранённых меток.",
"NO_TORRENTS": "Нет торрентов.",
"NO_TORRENTS_FOR_THIS_FILTER": "Нет торрентов для этого фильтра.",
"NO_TORRENTS_SELECTED": "Торренты не выбраны",
"NO_TORRENT_SELECTED": "Торрент не выбран",
"NO_TRACKERS_RETURNED_BY_RTORRENT": "rTorrent не вернул трекеры.",
"OFF": "Выкл.",
"OFFLINE": "офлайн",
"ONE_PLACE_TO_CLEAR_LOGS_AND_ACTIVE_PROFILE_CACHES_PENDING_RUNNING_JOBS_RULES_SET": "Одно место для очистки логов и кэшей активного профиля. Ожидающие/выполняющиеся задания, правила, настройки и торренты сохраняются.",
"ONE_TRACKER_URL_PER_LINE": "Один URL трекера на строку",
"ONLINE": "онлайн",
"ONLY_SELECTED": "Только выбранные",
"OPEN_DOWNLOAD_PLANNER": "Открыть планировщик загрузок",
"OPEN_RTORRENT_FILES_MAX_OPEN_FILES": "Открытые файлы rTorrent / макс. открытых файлов",
"OPEN_RTORRENT_HTTP_CONNECTIONS_MAX_HTTP_CONNECTIONS": "Открытые HTTP-соединения rTorrent / макс. HTTP-соединений",
"OPEN_RTORRENT_SOCKETS": "Открытые сокеты rTorrent",
"OPEN_SOURCE": "Open source",
"OPEN_THIS_TAB_TO_LOAD_DIAGNOSTICS": "Откройте эту вкладку, чтобы загрузить диагностику.",
"OPEN_THIS_TAB_TO_LOAD_STATISTICS": "Откройте эту вкладку, чтобы загрузить статистику.",
"OPERATION_LOG_RETENTION": "Хранение логов операций",
"OPTIONAL": "необязательно",
"OPTIONAL_PEER_TABLE_HELPERS": "Дополнительные помощники таблицы пиров.",
"OPTIONAL_PRIVATE_SOURCE_TAG": "необязательный приватный тег источника",
"OPTIONAL_VISUAL_EASTER_EGG_FOR_LOADING_STATES_AND_OCCASIONAL_BUTTON_CLICKS_DISAB": "Дополнительный визуальный easter egg для состояний загрузки и редких нажатий кнопок. По умолчанию отключён.",
"PARALLEL_JOBS": "Параллельные задания",
"PARENT_DIRECTORY": "Родительский каталог",
"PARTIAL_DETAILS_LOADED": "Загружены частичные детали",
"PASSWORD": "Пароль",
"PASSWORD_NEW_PASSWORD": "Пароль / новый пароль",
"PASTE_ONE_MAGNET_URI_PER_LINE": "Вставьте по одному magnet URI на строку.",
"PATH": "Путь",
"PATH_CONTAINS": "Путь содержит",
"PATH_TEXT": "Текст пути",
"PATH_USED_FOR_SELECTED_MODE": "Путь, используемый для выбранного режима",
"PAUSE": "Пауза",
"PAUSED": "На паузе",
"PEAK_S": "Пик S",
"PEAK_SPEED_UNAVAILABLE": "Пиковая скорость недоступна",
"PEERS": "Пиры",
"PEERS_AUTO_REFRESH": "Автообновление пиров",
"PENDING_JOBS_OLDER_THAN_THIS_ARE_RESUBMITTED_IF_NO_WORKER_IS_CURRENTLY_HANDLING_": "Ожидающие задания старше этого времени отправляются повторно, если ни один worker их сейчас не обрабатывает. По умолчанию: 900 секунд.",
"PENDING_RUNNING_DONE_FAILED_RETRY_AND_CANCEL_HISTORY": "История ожидающих, выполняющихся, выполненных, неудачных, повторных и отменённых заданий.",
"PENDING_TIMEOUT_SECONDS": "Таймаут ожидания, секунды",
"PIECE_SIZE": "Размер части",
"PLANNER": "Планировщик",
"POLISH": "Польский",
"PORT": "Порт",
"PORT_CHECKER": "Проверка порта",
"PORT_CHECK_DISABLED": "Проверка порта отключена",
"PORT_UNKNOWN": "Порт - неизвестен",
"POST_CHECK": "Проверка после",
"PREFERENCES": "Предпочтения",
"PREFERENCES_SAVED": "Предпочтения сохранены",
"PRIORITY": "Приоритет",
"PRIVATE_TORRENT": "Приватный торрент",
"PROFILE_BACKUP": "Резервная копия профиля",
"PROFILE_BACKUP_NAME": "Имя резервной копии профиля",
"PROFILE_BACKUP_RESTORES_ONLY_THE_ACTIVE_PROFILE_CONTEXT_APPLICATION_BACKUP_RESTO": "Резервная копия профиля восстанавливает только контекст активного профиля. Резервная копия приложения восстанавливает глобальные данные приложения и доступна только администраторам.",
"PROFILE_NAME": "Имя профиля",
"PROFILE_SCOPED_LOG_COUNTS_AND_CLEANUP_OVERVIEW": "Количество логов и обзор очистки в рамках профиля.",
"PROGRESS": "Прогресс",
"PROGRESS_IS_AT_LEAST": "Прогресс не менее %",
"PROGRESS_IS_AT_MOST": "Прогресс не более %",
"PROGRESS_SOURCE": "Источник прогресса",
"PROTECT_ACTIVE_COUNT_BELOW_CAP": "Защитить активное число ниже лимита",
"PYTORRENT_EXPECTS_TRUSTED_REVERSE_PROXY_IDENTITY_HEADERS_IF_YOU_ARE_ALREADY_SIGN": "pyTorrent ожидает доверенные заголовки идентификации от reverse proxy. Если вы уже вошли, проверьте заголовки провайдера и сопоставление пользователя.",
"PYTORRENT_LOGIN": "Вход в pyTorrent",
"PYTORRENT_STATUS": "Статус pyTorrent",
"P_PAUSE": "P - пауза",
"QUALITY": "Качество",
"QUEUE_REFILL_DURING_COOLDOWN": "Пополнение очереди во время паузы",
"RANDOM_CLICK_IMAGE_URL": "URL изображения случайного клика",
"RATIO": "Ratio",
"RATIO_GROUP": "Группа ratio",
"RATIO_GROUPS": "Группы ratio",
"RATIO_IS_AT_LEAST": "Ratio не менее",
"RATIO_RULES": "Правила ratio",
"REANNOUNCE": "Повторный announce",
"RECHECK": "Перепроверить",
"RECHECK_AFTER_MOVE": "Перепроверить после перемещения",
"RECOMMENDED_COLUMNS": "Рекомендуемые столбцы",
"RECOMMENDED_COLUMNS_APPLIED": "Рекомендуемые столбцы применены",
"RECONNECTING": "переподключение",
"REFERENCE_VALUE_IS_KEPT_FROM_THE_FIRST_OVERRIDE_SAVE_LATER_SAVES_ADD_OR_CLEAR_DI": "Эталонное значение сохраняется из первого сохранения переопределения. Последующие сохранения добавляют или очищают различия без замены исходного эталона.",
"REFRESH": "Обновить",
"REFRESH_NOW": "Обновить сейчас",
"REGEX_TEXT": "Regex / текст",
"RELOAD": "Перезагрузить",
"REMOTE_LOCATION": "Удалённое расположение",
"REMOVE": "Удалить",
"REMOVE_DATA": "удалить данные",
"REMOVE_LABEL": "Удалить метку",
"REMOVE_SELECTED_TORRENTS": "Удалить выбранные торренты",
"REMOVE_WITH_DATA": "Удалить с данными",
"REPOSITORY": "Репозиторий",
"RESET": "Сбросить",
"RESET_UI_SETTINGS": "Сбросить настройки UI",
"RESET_VIEW_DEFAULTS": "Сбросить настройки видов",
"RESIZE_TORRENT_DETAILS_PANEL": "Изменить размер панели деталей торрента",
"RESOLVE_PEER_IP_TO_REVERSE_DNS_HOST": "Разрешать IP пира в host reverse DNS",
"RESUME": "Возобновить",
"RETENTION_DAYS": "Дни хранения",
"RETENTION_MODE": "Режим хранения",
"RETRY": "Повторить",
"REVERSE_DNS": "Reverse DNS",
"RSS_DOWNLOADER": "RSS-загрузчик",
"RTORRENT": "rTorrent",
"RTORRENTS": "rTorrent",
"RTORRENT_CONFIG": "Конфигурация rTorrent",
"RTORRENT_INCOMING_PORT": "Входящий порт rTorrent",
"RTORRENT_IS_STARTING_OR_NOT_RESPONDING_YET": "rTorrent запускается или пока не отвечает.",
"RTORRENT_PROFILES": "Профили rTorrent",
"RULE": "Правило",
"RULES": "Правила",
"RULES_ARE_CHECKED_AUTOMATICALLY_EVERY_5_MINUTES_A_TORRENT_USES_THE_GROUP_STORED_": "Правила автоматически проверяются каждые 5 минут. Торрент использует группу, сохранённую в пользовательском поле ratio rTorrent.",
"RULE_NAME": "Имя правила",
"RUNTIME": "Runtime",
"RUN_A_PROFILE_TEST_TO_SHOW_DIAGNOSTICS": "Запустите тест профиля, чтобы показать диагностику.",
"RUN_SMART_QUEUE_DURING_POLLING_STOPPED_TORRENTS_ARE_MANAGED_PAUSED_TORRENTS_STAY": "Запускать Smart Queue во время polling. Остановленные торренты управляются системой; торренты на паузе остаются под контролем пользователя.",
"R_RESUME": "R - возобновить",
"SAVE": "Сохранить",
"SAVED_LABELS": "Сохранённые метки",
"SAVE_COLUMNS": "Сохранить столбцы",
"SAVE_CONFIG": "Сохранить конфигурацию",
"SAVE_EASTER_EGG": "Сохранить easter egg",
"SAVE_EXCEPTIONS": "Сохранить исключения",
"SAVE_FEED": "Сохранить ленту",
"SAVE_FOOTER": "Сохранить нижнюю панель",
"SAVE_GROUP": "Сохранить группу",
"SAVE_JOB_SETTINGS": "Сохранить настройки заданий",
"SAVE_LIMITS": "Сохранить лимиты",
"SAVE_LOCATION": "Сохранить расположение",
"SAVE_LOG_VIEW": "Сохранить вид логов",
"SAVE_PATH": "Сохранить путь",
"SAVE_PREFERENCES": "Сохранить предпочтения",
"SAVE_RETENTION": "Сохранить хранение",
"SAVE_RULE": "Сохранить правило",
"SAVE_SCHEDULE": "Сохранить расписание",
"SAVE_USER": "Сохранить пользователя",
"SCGI_URL": "SCGI URL",
"SEARCH_LOGS": "Искать в логах...",
"SEARCH_TORRENTS": "Искать торренты...",
"SEARCH_TORRENTS_TO_EXCLUDE": "Искать торренты для исключения...",
"SEASON": "Сезон",
"SECONDS_TO_WAIT_FOR_RTORRENT_RESPONSE": "Сколько секунд ждать ответа rTorrent.",
"SEEDING": "Раздача",
"SEEDS": "Сиды",
"SEEDS_ARE_LOW_FOR_TIME": "Мало сидов в течение времени",
"SEED_MINUTES": "Минуты раздачи",
"SELECT": "Выбрать",
"SELECTED": "выбрано",
"SELECTED_LABELS": "Выбранные метки",
"SELECTED_MONITORED_PATH": "Выбранный отслеживаемый путь",
"SELECTED_TORRENTS_WILL_BE_REMOVED": "выбранные торренты будут удалены.",
"SELECT_A_MONITORED_PATH_FIRST": "Сначала выберите отслеживаемый путь.",
"SELECT_A_TORRENT": "Выберите торрент.",
"SELECT_ONE_OR_MORE_TORRENT_FILES": "Выберите один или несколько .torrent файлов.",
"SELECT_PATH": "Выбрать путь",
"SELECT_TORRENTS_THAT_SMART_QUEUE_SHOULD_IGNORE_USE_SEARCH_TO_FILTER_BY_NAME_LABE": "Выберите торренты, которые Smart Queue должна игнорировать. Используйте поиск для фильтрации по имени, метке, статусу или hash.",
"SELECT_VISIBLE": "Выбрать видимые",
"SEPARATE_SLOT_POOL_FOR_LIGHTWEIGHT_CONTROL_JOBS_SO_THEY_DO_NOT_WAIT_BEHIND_HEAVY": "Отдельный пул слотов для лёгких управляющих заданий, чтобы они не ждали за тяжёлой I/O работой. По умолчанию: 4.",
"SETTINGS": "Настройки",
"SET_LABEL": "Установить метку...",
"SET_LABELS": "Установить метки",
"SET_RATIO_GROUP": "Установить группу ratio...",
"SHARE_AFTER_CREATING": "Раздавать после создания",
"SHORTCUTS": "Горячие клавиши",
"SHOWN": "Показано",
"SHOWN_IN_LOADING_STATES_INSTEAD_OF_THE_STANDARD_SPINNER": "Показывается в состояниях загрузки вместо стандартного spinner.",
"SHOWS_TRACKER_ICONS_IN_THE_SIDEBAR_TRACKER_FILTER_WHEN_AVAILABLE": "Показывает иконки трекеров в фильтре трекеров на боковой панели, когда они доступны.",
"SHOW_COMBINED_USAGE_SINGLE_PATH_SELECTION_IS_DISABLED_IN_THIS_MODE": "Показывать суммарное использование. В этом режиме выбор одного пути отключён.",
"SHOW_DL_UP_IN_BROWSER_TITLE": "Показывать DL/UP в заголовке браузера",
"SHOW_SMART_QUEUE_AUTOMATIC_RUN_MESSAGES": "Показывать сообщения автоматических запусков Smart Queue.",
"SHOW_TOASTS_CREATED_BY_AUTOMATION_RUNS": "Показывать уведомления, созданные автоматизациями.",
"SIGN_IN": "Войти",
"SIZE": "Размер",
"SKIP_ACTIVE_UPLOAD": "Пропускать активную отдачу",
"SKIP_PRIVATE_TORRENTS": "Пропускать приватные торренты",
"SMART_FILTERS": "Умные фильтры",
"SMART_QUEUE": "Smart Queue",
"SMART_QUEUE_EXCEPTIONS": "Исключения Smart Queue",
"SMART_QUEUE_KEEPS_ONLY_THIS_MANY_ACTIVE_DOWNLOADS_OVERFLOW_IS_STOPPED": "Smart Queue оставляет только столько активных загрузок; лишние останавливаются.",
"SMART_QUEUE_TOASTS": "Уведомления Smart Queue",
"SOCKETS": "Сокеты",
"SOURCE": "Источник",
"SPACE_START": "Space - старт",
"SPEED": "Скорость",
"SPEED_LIMITS": "Лимиты скорости",
"SPEED_TREND": "Тренд скорости",
"STALLED_AFTER_SECONDS": "Завис после секунд",
"START": "Старт",
"START_AFTER_ADD": "Запускать после добавления",
"START_GRACE_SECONDS": "Защитные секунды после старта",
"STATE": "Состояние",
"STATUS": "Статус",
"STATUS_EQUALS": "Статус равен",
"STOP": "Стоп",
"STOPPED": "Остановлено",
"S_STOP": "S - остановить",
"TARGET_ACTIVE_DOWNLOADS": "Целевые активные загрузки",
"TARGET_PATH": "Целевой путь",
"TEST_RULE": "Тестировать правило",
"TEST_SCGI": "Тестировать SCGI",
"THEME_TYPOGRAPHY_AND_INTERFACE_SCALE": "Тема, типографика и масштаб интерфейса.",
"THE_FOOTER_TOOLTIP_ALWAYS_SHOWS_DETAILS_FOR_AVAILABLE_PATHS_THIS_SETTING_ONLY_DE": "Подсказка нижней панели всегда показывает детали доступных путей; эта настройка определяет только значение, управляющее видимой полосой прогресса.",
"TIMEOUT": "Таймаут",
"TOAST_NOTIFICATIONS_FROM_AUTOMATIC_SYSTEMS": "Toast-уведомления от автоматических систем.",
"TOOLS": "Инструменты",
"TOOLS_RTORRENTS": "Инструменты и rTorrent",
"TOOLS_SECTIONS": "Разделы инструментов",
"TORRENT_ADDED": "Торрент добавлен",
"TORRENT_COMPLETED": "Торрент завершён",
"TORRENT_DETAILS": "Детали торрента",
"TORRENT_FILES": "Файлы торрента",
"TORRENT_FILTERS": "Фильтры торрентов",
"TORRENT_PROPERTIES": "Свойства торрента",
"TORRENT_REMOVED": "Торрент удалён",
"TORRENT_STATISTICS": "Статистика торрента",
"TORRENT_STATS": "Статистика торрентов",
"TOTAL_DL_UP": "Всего DL/UP",
"TO_DOWNLOAD": "К загрузке",
"TRACKERS": "Трекеры",
"TRACKER_ICONS": "Иконки трекеров",
"TRANSFER": "Передача",
"TRANSFERRED_DATA": "Переданные данные",
"TRANSFER_HISTORY": "История передачи",
"UNDEFINED": "не определено",
"UNLIMITED": "Без ограничений",
"UPLOADED": "Отдано",
"UPLOADS": "Отдачи",
"UPLOAD_KIB_S": "Отдача KiB/s",
"USER": "Пользователь",
"USERS": "Пользователи",
"USES_A_LIGHTWEIGHT_BUILT_IN_RESOLVER_WITH_CACHE_HOSTNAMES_APPEAR_ONLY_IN_THE_PEE": "Использует лёгкий встроенный resolver с кэшем. Имена host появляются только на вкладке пиров.",
"USES_LOWER_ROWS_AND_SMALLER_LIST_ELEMENTS_ON_DESKTOP_AND_MOBILE_SO_MORE_TORRENTS": "Использует более низкие строки и меньшие элементы списка на desktop и mobile, чтобы на экране помещалось больше торрентов.",
"USES_YOUGETSIGNAL_FIRST_MANUAL_CHECK_BYPASSES_THE_6H_CACHE": "Сначала использует YouGetSignal. Ручная проверка обходит кэш 6h.",
"USE_ONE_CUSTOM_PATH_BELOW_AS_THE_FOOTER_PROGRESS_VALUE": "Использовать один пользовательский путь ниже как значение прогресса в нижней панели.",
"USE_SELECTED": "Использовать выбранное",
"USE_THE_MAIN_DIRECTORY_FROM_THE_ACTIVE_RTORRENT_PROFILE": "Использовать основной каталог активного профиля rTorrent.",
"VIEW_PREFERENCES_RESET": "Настройки вида сброшены",
"VIEW_STATE_IS_SAVED_AUTOMATICALLY_IN_THE_DATABASE_CURRENT_TORRENT_FILTER_LAST_SO": "Состояние вида автоматически сохраняется в базе данных: текущий фильтр торрентов, последний столбец сортировки и направление, видимые столбцы и высота панели деталей.",
"VISIBLE_NAME_USED_IN_THE_PROFILE_SELECTOR": "Видимое имя, используемое в селекторе профиля.",
"VISUAL_HELPER_FOR_TRACKER_FILTERS_IN_THE_SIDEBAR": "Визуальная помощь для фильтров трекеров на боковой панели.",
"WAITING_FOR_DATA": "Ожидание данных.",
"WAITING_FOR_TORRENT_DATA_FROM_THE_ACTIVE_PROFILE": "Ожидание данных торрентов из активного профиля.",
"WATCHDOG_MARKS_A_LIGHT_JOB_AS_FAILED_AFTER_THIS_TIME_DEFAULT_300_SECONDS": "Watchdog помечает лёгкое задание как неудачное после этого времени. По умолчанию: 300 секунд.",
"WATCHDOG_TIMEOUT_FOR_MOVE_REMOVE_ADD_JOBS_DEFAULT_7200_SECONDS": "Таймаут watchdog для заданий move/remove/add. По умолчанию: 7200 секунд.",
"WHEN_DISABLED_THE_APPLICATION_USES_THE_NORMAL_PRODUCTION_UI": "Когда отключено, приложение использует обычный production UI.",
"WHEN_ENABLED_LOW_SPEED_IS_NOT_REQUIRED_WITH_SOURCE_AND_SPEED_IGNORES_ENABLED_ONL": "Когда включено, низкая скорость не требуется. При включённом игнорировании источника и скорости решает только „Завис после секунд”.",
"WHEN_ENABLED_SMART_QUEUE_DOES_NOT_USE_SEED_PEER_COUNT_AS_A_STALLED_CRITERION": "Когда включено, Smart Queue не использует число сидов/пиров как критерий зависания.",
"WITH_ERROR": "С ошибкой",
"WORKING": "Работаю...",
"FRENCH": "Французский",
"CZECH": "Чешский",
"SPANISH": "Испанский",
"NORWEGIAN": "Норвежский",
"RUSSIAN": "Русский"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,5 @@
import { stateSource } from './state.js'; import { stateSource } from './state.js';
import { i18nSource } from './i18n.js';
import { torrentsSource } from './torrents.js'; import { torrentsSource } from './torrents.js';
import { mobileSource } from './mobile.js'; import { mobileSource } from './mobile.js';
import { messagesSource } from './messages.js'; import { messagesSource } from './messages.js';
@@ -9,6 +10,7 @@ import { torrentDetailsSource } from './torrentDetails.js';
import { modalsSource } from './modals.js'; import { modalsSource } from './modals.js';
import { rssSource } from './rss.js'; import { rssSource } from './rss.js';
import { smartQueueSource } from './smartQueue.js'; import { smartQueueSource } from './smartQueue.js';
import { authUsersSource } from './authUsers.js';
import { plannerSource } from './planner.js'; import { plannerSource } from './planner.js';
import { pollerSource } from './poller.js'; import { pollerSource } from './poller.js';
import { profilesSource } from './profiles.js'; import { profilesSource } from './profiles.js';
@@ -19,6 +21,7 @@ import { bootstrapSource } from './bootstrap.js';
export const moduleSources = [ export const moduleSources = [
stateSource, stateSource,
i18nSource,
torrentsSource, torrentsSource,
mobileSource, mobileSource,
messagesSource, messagesSource,
@@ -29,6 +32,7 @@ export const moduleSources = [
modalsSource, modalsSource,
rssSource, rssSource,
smartQueueSource, smartQueueSource,
authUsersSource,
plannerSource, plannerSource,
dashboardSource, dashboardSource,
operationLogsSource, operationLogsSource,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,66 @@
/* Balanced violet-blue theme with calm defaults. */
:root, [data-bs-theme="light"] {
color-scheme: light;
--bs-primary: #6750a4;
--bs-primary-rgb: 103,80,164;
--bs-link-color: #6750a4;
--bs-link-color-rgb: 103,80,164;
--bs-link-hover-color: #4b2f8f;
--bs-link-hover-color-rgb: 103,80,164;
--bs-body-bg: #f6f7fb;
--bs-body-bg-rgb: 246,247,251;
--bs-body-color: #1f2937;
--bs-secondary-bg: #ffffff;
--bs-secondary-bg-rgb: 255,255,255;
--bs-tertiary-bg: #eef1f7;
--bs-border-color: #d9deea;
--bs-secondary-color: #667085;
--bs-primary-bg-subtle: #ece7ff;
--bs-primary-text-emphasis: #4b2f8f;
--torrent-progress-complete: #2f9e75;
--pytorrent-page-bg: linear-gradient(180deg, #f7f8fc 0%, #eef1f7 100%);
--pytorrent-shell-shadow: 0 16px 40px rgba(39, 45, 73, 0.12);
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-primary: #8f7cff;
--bs-primary-rgb: 143,124,255;
--bs-link-color: #8f7cff;
--bs-link-color-rgb: 143,124,255;
--bs-link-hover-color: #c6bdff;
--bs-link-hover-color-rgb: 143,124,255;
--bs-body-bg: #080b12;
--bs-body-bg-rgb: 8,11,18;
--bs-body-color: #dce3f0;
--bs-secondary-bg: #101624;
--bs-secondary-bg-rgb: 16,22,36;
--bs-tertiary-bg: #151d2e;
--bs-border-color: #273247;
--bs-secondary-color: #97a4ba;
--bs-primary-bg-subtle: #191735;
--bs-primary-text-emphasis: #c6bdff;
--torrent-progress-complete: #2f9e75;
--pytorrent-page-bg: radial-gradient(circle at top left, rgba(143, 124, 255, 0.16), transparent 34%), #080b12;
--pytorrent-shell-shadow: 0 18px 55px rgba(0, 0, 0, 0.48);
}
.btn-primary {
--bs-btn-bg: var(--bs-primary);
--bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary-text-emphasis);
--bs-btn-hover-border-color: var(--bs-primary-text-emphasis);
}
.btn-outline-primary {
--bs-btn-color: var(--bs-primary);
--bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72);
--bs-btn-hover-bg: var(--bs-primary);
--bs-btn-hover-border-color: var(--bs-primary);
}
.nav-pills {
--bs-nav-pills-link-active-bg: var(--bs-primary);
}
.progress,
.progress-stacked {
--bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82);
}

View File

@@ -0,0 +1,66 @@
/* Warm amber theme, good for light mode and softer dark mode. */
:root, [data-bs-theme="light"] {
color-scheme: light;
--bs-primary: #b45309;
--bs-primary-rgb: 180,83,9;
--bs-link-color: #b45309;
--bs-link-color-rgb: 180,83,9;
--bs-link-hover-color: #92400e;
--bs-link-hover-color-rgb: 180,83,9;
--bs-body-bg: #fff8ed;
--bs-body-bg-rgb: 255,248,237;
--bs-body-color: #2c1d0b;
--bs-secondary-bg: #ffffff;
--bs-secondary-bg-rgb: 255,255,255;
--bs-tertiary-bg: #ffefd6;
--bs-border-color: #f3d7aa;
--bs-secondary-color: #7a6750;
--bs-primary-bg-subtle: #ffecd0;
--bs-primary-text-emphasis: #92400e;
--torrent-progress-complete: #16a34a;
--pytorrent-page-bg: linear-gradient(180deg, #fffaf2 0%, #fff0d8 100%);
--pytorrent-shell-shadow: 0 16px 38px rgba(146, 64, 14, 0.13);
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-primary: #f59e0b;
--bs-primary-rgb: 245,158,11;
--bs-link-color: #f59e0b;
--bs-link-color-rgb: 245,158,11;
--bs-link-hover-color: #fde68a;
--bs-link-hover-color-rgb: 245,158,11;
--bs-body-bg: #140d05;
--bs-body-bg-rgb: 20,13,5;
--bs-body-color: #f5e9d6;
--bs-secondary-bg: #211607;
--bs-secondary-bg-rgb: 33,22,7;
--bs-tertiary-bg: #2b1d0b;
--bs-border-color: #4c3515;
--bs-secondary-color: #c3a780;
--bs-primary-bg-subtle: #3a2609;
--bs-primary-text-emphasis: #fde68a;
--torrent-progress-complete: #22c55e;
--pytorrent-page-bg: radial-gradient(circle at top left, rgba(245, 158, 11, 0.16), transparent 34%), #140d05;
--pytorrent-shell-shadow: 0 18px 55px rgba(30, 15, 0, 0.58);
}
.btn-primary {
--bs-btn-bg: var(--bs-primary);
--bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary-text-emphasis);
--bs-btn-hover-border-color: var(--bs-primary-text-emphasis);
}
.btn-outline-primary {
--bs-btn-color: var(--bs-primary);
--bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72);
--bs-btn-hover-bg: var(--bs-primary);
--bs-btn-hover-border-color: var(--bs-primary);
}
.nav-pills {
--bs-nav-pills-link-active-bg: var(--bs-primary);
}
.progress,
.progress-stacked {
--bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82);
}

View File

@@ -0,0 +1,247 @@
/* Note: Bootstrap 2 Inverse keeps the old beveled control language with darker navigation chrome. */
:root {
--bs-border-radius: 4px;
--bs-border-radius-sm: 3px;
--bs-border-radius-lg: 5px;
--bs-font-sans-serif: "Helvetica Neue", Arial, sans-serif;
}
[data-bs-theme="light"] {
--bs-body-bg: #eceff1;
--bs-body-color: #2f2f2f;
--bs-primary: #2f96b4;
--bs-primary-rgb: 47, 150, 180;
--bs-secondary-bg: #d9dee2;
--bs-secondary-bg-rgb: 217, 222, 226;
--bs-secondary-color: #4d5963;
--bs-tertiary-bg: #ffffff;
--bs-border-color: #b8c0c7;
--bs-link-color: #2f96b4;
}
[data-bs-theme="dark"] {
--bs-body-bg: #161a1d;
--bs-body-color: #e7ecef;
--bs-primary: #49afcd;
--bs-primary-rgb: 73, 175, 205;
--bs-secondary-bg: #252b30;
--bs-secondary-bg-rgb: 37, 43, 48;
--bs-secondary-color: #c9d3da;
--bs-tertiary-bg: #20262a;
--bs-border-color: #48535c;
--bs-link-color: #5bc0de;
}
/* Note: Bootstrap 2 surfaces were simple grey panels with subtle inset highlights. */
.card,
.dropdown-menu,
.modal-content,
.surface-section,
.smart-setting-row,
.table,
.toast {
background-image: linear-gradient(#ffffff, #f7f7f7);
border: 1px solid var(--bs-border-color);
border-radius: 4px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55), 0 1px 2px rgba(0, 0, 0, 0.08);
}
[data-bs-theme="dark"] .card,
[data-bs-theme="dark"] .dropdown-menu,
[data-bs-theme="dark"] .modal-content,
[data-bs-theme="dark"] .surface-section,
[data-bs-theme="dark"] .smart-setting-row,
[data-bs-theme="dark"] .table,
[data-bs-theme="dark"] .toast {
background-image: linear-gradient(#303840, #252c33);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 1px 2px rgba(0, 0, 0, 0.25);
}
.btn {
border-color: rgba(0, 0, 0, 0.22);
border-radius: 4px;
border-width: 1px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.38), 0 1px 2px rgba(0, 0, 0, 0.08);
font-weight: 600;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25);
transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease;
}
.btn:hover {
filter: none;
text-decoration: none;
}
.btn:focus-visible {
box-shadow: 0 0 0 .2rem rgba(var(--bs-primary-rgb), .32);
filter: none;
text-decoration: none;
}
.btn-primary {
background-image: linear-gradient(#08c, #04c);
border-color: #0044cc #0044cc #002a80;
color: #ffffff;
}
.btn-primary:hover,
.btn-primary:focus-visible {
background-image: linear-gradient(#0077d9, #003bb3);
border-color: #003bb3 #003bb3 #001f66;
}
.btn-success,
.btn-success:hover,
.btn-success:focus-visible {
background-image: linear-gradient(#62c462, #51a351);
border-color: #51a351 #51a351 #387038;
color: #ffffff;
}
.btn-danger,
.btn-danger:hover,
.btn-danger:focus-visible {
background-image: linear-gradient(#ee5f5b, #bd362f);
border-color: #bd362f #bd362f #802420;
color: #ffffff;
}
.btn-secondary,
.btn-outline-secondary,
.btn-light {
background-image: linear-gradient(#ffffff, #e6e6e6);
border-color: #cccccc #cccccc #b3b3b3;
color: #333333;
}
.btn-outline-primary,
.btn-outline-success,
.btn-outline-danger,
.btn-outline-warning,
.btn-outline-info {
background-color: var(--bs-body-bg);
}
.btn-outline-primary:hover,
.btn-outline-primary:focus-visible,
.nav-pills .nav-link.active,
.nav-pills .show > .nav-link {
background-image: linear-gradient(#08c, #04c);
color: #ffffff;
}
.btn-outline-success:hover,
.btn-outline-success:focus-visible {
background-image: linear-gradient(#62c462, #51a351);
color: #ffffff;
}
.btn-outline-danger:hover,
.btn-outline-danger:focus-visible {
background-image: linear-gradient(#ee5f5b, #bd362f);
color: #ffffff;
}
.btn-outline-warning:hover,
.btn-outline-warning:focus-visible {
background-image: linear-gradient(#fbb450, #f89406);
color: #111111;
}
.btn-outline-info:hover,
.btn-outline-info:focus-visible {
background-image: linear-gradient(#5bc0de, #2f96b4);
color: #ffffff;
}
.btn-secondary:hover,
.btn-secondary:focus-visible,
.btn-outline-secondary:hover,
.btn-outline-secondary:focus-visible {
background-image: linear-gradient(#e6e6e6, #cfcfcf);
border-color: #adadad;
color: #222222;
}
[data-bs-theme="dark"] .btn-secondary,
[data-bs-theme="dark"] .btn-outline-secondary,
[data-bs-theme="dark"] .btn-light {
background-image: linear-gradient(#4b545d, #303840);
border-color: #5c6670;
color: #f0f0f0;
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.45);
}
[data-bs-theme="dark"] .btn-secondary:hover,
[data-bs-theme="dark"] .btn-secondary:focus-visible,
[data-bs-theme="dark"] .btn-outline-secondary:hover,
[data-bs-theme="dark"] .btn-outline-secondary:focus-visible {
background-image: linear-gradient(#68737e, #48515a);
color: #ffffff;
}
.form-control,
.form-select,
.input-group-text {
border: 1px solid #cccccc;
border-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
}
.form-control:focus,
.form-select:focus {
border-color: rgba(82, 168, 236, 0.8);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6);
}
.modal-header,
.card-header,
.dropdown-header,
.table thead th {
background-image: linear-gradient(#f9f9f9, #eeeeee);
border-bottom: 1px solid var(--bs-border-color);
color: var(--bs-body-color);
font-weight: 700;
}
[data-bs-theme="dark"] .modal-header,
[data-bs-theme="dark"] .card-header,
[data-bs-theme="dark"] .dropdown-header,
[data-bs-theme="dark"] .table thead th {
background-image: linear-gradient(#39434c, #2c343b);
}
.badge,
.label {
border-radius: 3px;
}
.alert {
border-radius: 3px;
border-width: 1px;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.35);
}
.progress {
background-image: linear-gradient(#f5f5f5, #e6e6e6);
border-radius: 3px;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18);
}
.progress-bar {
background-image: linear-gradient(#149bdf, #0480be);
}
.table > :not(caption) > * > * {
border-bottom-color: var(--bs-border-color);
}
/* Note: Inverse variant reproduces the dark Bootstrap 2 navbar strip. */
.navbar,
.sidebar,
.topbar {
background-image: linear-gradient(#444444, #222222);
border-color: #252525;
color: #eeeeee;
}

View File

@@ -0,0 +1,237 @@
/* Note: Bootstrap 2 Classic maps the app UI to the old beveled Bootstrap 2 control language. */
:root {
--bs-border-radius: 4px;
--bs-border-radius-sm: 3px;
--bs-border-radius-lg: 5px;
--bs-font-sans-serif: "Helvetica Neue", Arial, sans-serif;
}
[data-bs-theme="light"] {
--bs-body-bg: #f5f5f5;
--bs-body-color: #333333;
--bs-primary: #006dcc;
--bs-primary-rgb: 0, 109, 204;
--bs-secondary-bg: #eeeeee;
--bs-secondary-bg-rgb: 238, 238, 238;
--bs-secondary-color: #555555;
--bs-tertiary-bg: #ffffff;
--bs-border-color: #c8c8c8;
--bs-link-color: #0088cc;
}
[data-bs-theme="dark"] {
--bs-body-bg: #1f252b;
--bs-body-color: #e6e6e6;
--bs-primary: #4aa3df;
--bs-primary-rgb: 74, 163, 223;
--bs-secondary-bg: #2f363d;
--bs-secondary-bg-rgb: 47, 54, 61;
--bs-secondary-color: #c7d0d8;
--bs-tertiary-bg: #252b31;
--bs-border-color: #4a535c;
--bs-link-color: #6bbdf0;
}
/* Note: Bootstrap 2 surfaces were simple grey panels with subtle inset highlights. */
.card,
.dropdown-menu,
.modal-content,
.surface-section,
.smart-setting-row,
.table,
.toast {
background-image: linear-gradient(#ffffff, #f7f7f7);
border: 1px solid var(--bs-border-color);
border-radius: 4px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55), 0 1px 2px rgba(0, 0, 0, 0.08);
}
[data-bs-theme="dark"] .card,
[data-bs-theme="dark"] .dropdown-menu,
[data-bs-theme="dark"] .modal-content,
[data-bs-theme="dark"] .surface-section,
[data-bs-theme="dark"] .smart-setting-row,
[data-bs-theme="dark"] .table,
[data-bs-theme="dark"] .toast {
background-image: linear-gradient(#303840, #252c33);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 1px 2px rgba(0, 0, 0, 0.25);
}
.btn {
border-color: rgba(0, 0, 0, 0.22);
border-radius: 4px;
border-width: 1px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.38), 0 1px 2px rgba(0, 0, 0, 0.08);
font-weight: 600;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25);
transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease;
}
.btn:hover {
filter: none;
text-decoration: none;
}
.btn:focus-visible {
box-shadow: 0 0 0 .2rem rgba(var(--bs-primary-rgb), .32);
filter: none;
text-decoration: none;
}
.btn-primary {
background-image: linear-gradient(#08c, #04c);
border-color: #0044cc #0044cc #002a80;
color: #ffffff;
}
.btn-primary:hover,
.btn-primary:focus-visible {
background-image: linear-gradient(#0077d9, #003bb3);
border-color: #003bb3 #003bb3 #001f66;
}
.btn-success,
.btn-success:hover,
.btn-success:focus-visible {
background-image: linear-gradient(#62c462, #51a351);
border-color: #51a351 #51a351 #387038;
color: #ffffff;
}
.btn-danger,
.btn-danger:hover,
.btn-danger:focus-visible {
background-image: linear-gradient(#ee5f5b, #bd362f);
border-color: #bd362f #bd362f #802420;
color: #ffffff;
}
.btn-secondary,
.btn-outline-secondary,
.btn-light {
background-image: linear-gradient(#ffffff, #e6e6e6);
border-color: #cccccc #cccccc #b3b3b3;
color: #333333;
}
.btn-outline-primary,
.btn-outline-success,
.btn-outline-danger,
.btn-outline-warning,
.btn-outline-info {
background-color: var(--bs-body-bg);
}
.btn-outline-primary:hover,
.btn-outline-primary:focus-visible,
.nav-pills .nav-link.active,
.nav-pills .show > .nav-link {
background-image: linear-gradient(#08c, #04c);
color: #ffffff;
}
.btn-outline-success:hover,
.btn-outline-success:focus-visible {
background-image: linear-gradient(#62c462, #51a351);
color: #ffffff;
}
.btn-outline-danger:hover,
.btn-outline-danger:focus-visible {
background-image: linear-gradient(#ee5f5b, #bd362f);
color: #ffffff;
}
.btn-outline-warning:hover,
.btn-outline-warning:focus-visible {
background-image: linear-gradient(#fbb450, #f89406);
color: #111111;
}
.btn-outline-info:hover,
.btn-outline-info:focus-visible {
background-image: linear-gradient(#5bc0de, #2f96b4);
color: #ffffff;
}
.btn-secondary:hover,
.btn-secondary:focus-visible,
.btn-outline-secondary:hover,
.btn-outline-secondary:focus-visible {
background-image: linear-gradient(#e6e6e6, #cfcfcf);
border-color: #adadad;
color: #222222;
}
[data-bs-theme="dark"] .btn-secondary,
[data-bs-theme="dark"] .btn-outline-secondary,
[data-bs-theme="dark"] .btn-light {
background-image: linear-gradient(#4b545d, #303840);
border-color: #5c6670;
color: #f0f0f0;
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.45);
}
[data-bs-theme="dark"] .btn-secondary:hover,
[data-bs-theme="dark"] .btn-secondary:focus-visible,
[data-bs-theme="dark"] .btn-outline-secondary:hover,
[data-bs-theme="dark"] .btn-outline-secondary:focus-visible {
background-image: linear-gradient(#68737e, #48515a);
color: #ffffff;
}
.form-control,
.form-select,
.input-group-text {
border: 1px solid #cccccc;
border-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
}
.form-control:focus,
.form-select:focus {
border-color: rgba(82, 168, 236, 0.8);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6);
}
.modal-header,
.card-header,
.dropdown-header,
.table thead th {
background-image: linear-gradient(#f9f9f9, #eeeeee);
border-bottom: 1px solid var(--bs-border-color);
color: var(--bs-body-color);
font-weight: 700;
}
[data-bs-theme="dark"] .modal-header,
[data-bs-theme="dark"] .card-header,
[data-bs-theme="dark"] .dropdown-header,
[data-bs-theme="dark"] .table thead th {
background-image: linear-gradient(#39434c, #2c343b);
}
.badge,
.label {
border-radius: 3px;
}
.alert {
border-radius: 3px;
border-width: 1px;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.35);
}
.progress {
background-image: linear-gradient(#f5f5f5, #e6e6e6);
border-radius: 3px;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18);
}
.progress-bar {
background-image: linear-gradient(#149bdf, #0480be);
}
.table > :not(caption) > * > * {
border-bottom-color: var(--bs-border-color);
}

View File

@@ -0,0 +1,268 @@
/* Note: Bootstrap 3 Inverse keeps the dark navbar era with Bootstrap 3 panel and button shapes. */
:root {
--bs-border-radius: 4px;
--bs-border-radius-sm: 3px;
--bs-border-radius-lg: 4px;
--bs-font-sans-serif: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
[data-bs-theme="light"] {
--bs-body-bg: #eceff2;
--bs-body-color: #2d3338;
--bs-primary: #428bca;
--bs-primary-rgb: 66, 139, 202;
--bs-success: #5cb85c;
--bs-danger: #d9534f;
--bs-warning: #f0ad4e;
--bs-info: #5bc0de;
--bs-secondary-bg: #dfe4e8;
--bs-secondary-bg-rgb: 223, 228, 232;
--bs-secondary-color: #4c5963;
--bs-tertiary-bg: #ffffff;
--bs-border-color: #c5cdd4;
--bs-link-color: #2a6496;
}
[data-bs-theme="dark"] {
--bs-body-bg: #151a1f;
--bs-body-color: #edf2f6;
--bs-primary: #6fb4eb;
--bs-primary-rgb: 111, 180, 235;
--bs-success: #7dd37d;
--bs-danger: #ec7773;
--bs-warning: #f6c572;
--bs-info: #83d8ee;
--bs-secondary-bg: #232b33;
--bs-secondary-bg-rgb: 35, 43, 51;
--bs-secondary-color: #c3ccd4;
--bs-tertiary-bg: #1d242b;
--bs-border-color: #3f4b56;
--bs-link-color: #94cdf5;
}
/* Note: Bootstrap 3 panels and wells are represented through shared app containers. */
.card,
.dropdown-menu,
.modal-content,
.surface-section,
.smart-setting-row,
.toast {
background-color: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: 4px;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
}
.navbar,
.topbar {
background-image: linear-gradient(#ffffff, #f2f2f2);
border-bottom: 1px solid var(--bs-border-color);
}
[data-bs-theme="dark"] .navbar,
[data-bs-theme="dark"] .topbar {
background-image: linear-gradient(#303941, #20272e);
}
.btn {
border-width: 1px;
border-radius: 4px;
font-weight: 600;
transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease;
}
.btn:hover {
filter: none;
text-decoration: none;
}
.btn:focus-visible {
box-shadow: 0 0 0 .2rem rgba(var(--bs-primary-rgb), .32);
filter: none;
text-decoration: none;
}
.btn-primary {
background-image: linear-gradient(#428bca, #2d6ca2);
border-color: #2b669a;
color: #ffffff;
}
.btn-primary:hover,
.btn-primary:focus-visible,
.nav-pills .nav-link.active,
.nav-pills .show > .nav-link {
background-color: #2d6ca2;
color: #ffffff;
}
.btn-success,
.btn-success:hover,
.btn-success:focus-visible {
background-color: #5cb85c;
border-color: #4cae4c;
color: #ffffff;
}
.btn-danger,
.btn-danger:hover,
.btn-danger:focus-visible {
background-color: #d9534f;
border-color: #d43f3a;
color: #ffffff;
}
.btn-warning,
.btn-warning:hover,
.btn-warning:focus-visible {
background-color: #f0ad4e;
border-color: #eea236;
color: #111111;
}
.btn-info,
.btn-info:hover,
.btn-info:focus-visible {
background-color: #5bc0de;
border-color: #46b8da;
color: #ffffff;
}
.btn-secondary,
.btn-outline-secondary,
.btn-light {
background-color: #ffffff;
border-color: #cccccc;
color: #333333;
}
.btn-outline-primary,
.btn-outline-success,
.btn-outline-danger,
.btn-outline-warning,
.btn-outline-info {
background-color: var(--bs-body-bg);
}
.btn-outline-primary:hover,
.btn-outline-primary:focus-visible {
background-color: var(--bs-primary);
border-color: var(--bs-primary);
color: #ffffff;
}
.btn-outline-success:hover,
.btn-outline-success:focus-visible {
background-color: var(--bs-success);
border-color: var(--bs-success);
color: #ffffff;
}
.btn-outline-danger:hover,
.btn-outline-danger:focus-visible {
background-color: var(--bs-danger);
border-color: var(--bs-danger);
color: #ffffff;
}
.btn-outline-warning:hover,
.btn-outline-warning:focus-visible {
background-color: var(--bs-warning);
border-color: var(--bs-warning);
color: #111111;
}
.btn-outline-info:hover,
.btn-outline-info:focus-visible {
background-color: var(--bs-info);
border-color: var(--bs-info);
color: #111111;
}
.btn-secondary:hover,
.btn-secondary:focus-visible,
.btn-outline-secondary:hover,
.btn-outline-secondary:focus-visible {
background-color: #e6e6e6;
border-color: #adadad;
color: #222222;
}
[data-bs-theme="dark"] .btn-secondary,
[data-bs-theme="dark"] .btn-outline-secondary,
[data-bs-theme="dark"] .btn-light {
background-color: #323b44;
border-color: #53606b;
color: #edf2f6;
}
[data-bs-theme="dark"] .btn-secondary:hover,
[data-bs-theme="dark"] .btn-secondary:focus-visible,
[data-bs-theme="dark"] .btn-outline-secondary:hover,
[data-bs-theme="dark"] .btn-outline-secondary:focus-visible {
background-color: #46515b;
color: #ffffff;
}
.form-control,
.form-select,
.input-group-text {
border: 1px solid #cccccc;
border-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
}
.form-control:focus,
.form-select:focus {
border-color: #66afe9;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6);
}
.card-header,
.modal-header,
.table thead th {
background-color: #f5f5f5;
border-bottom: 1px solid var(--bs-border-color);
color: var(--bs-body-color);
font-weight: 700;
}
[data-bs-theme="dark"] .card-header,
[data-bs-theme="dark"] .modal-header,
[data-bs-theme="dark"] .table thead th {
background-color: #303941;
}
.badge,
.label {
border-radius: 4px;
}
.alert {
border-radius: 4px;
border-width: 1px;
}
.progress {
background-color: #f5f5f5;
border-radius: 4px;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
}
.progress-bar {
background-image: linear-gradient(#5bc0de, #339bb9);
}
.table > :not(caption) > * > * {
border-bottom-color: var(--bs-border-color);
}
/* Note: Inverse variant reproduces Bootstrap 3's dark default navbar treatment. */
.navbar,
.sidebar,
.topbar {
background-image: linear-gradient(#3c3c3c, #222222);
border-color: #080808;
color: #eeeeee;
}

View File

@@ -0,0 +1,258 @@
/* Note: Bootstrap 3 Glyph uses flat panels, square controls and the Bootstrap 3 palette. */
:root {
--bs-border-radius: 4px;
--bs-border-radius-sm: 3px;
--bs-border-radius-lg: 4px;
--bs-font-sans-serif: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
[data-bs-theme="light"] {
--bs-body-bg: #f8f8f8;
--bs-body-color: #333333;
--bs-primary: #337ab7;
--bs-primary-rgb: 51, 122, 183;
--bs-success: #5cb85c;
--bs-danger: #d9534f;
--bs-warning: #f0ad4e;
--bs-info: #5bc0de;
--bs-secondary-bg: #eeeeee;
--bs-secondary-bg-rgb: 238, 238, 238;
--bs-secondary-color: #555555;
--bs-tertiary-bg: #ffffff;
--bs-border-color: #dddddd;
--bs-link-color: #337ab7;
}
[data-bs-theme="dark"] {
--bs-body-bg: #1f252b;
--bs-body-color: #e7edf2;
--bs-primary: #5dade2;
--bs-primary-rgb: 93, 173, 226;
--bs-success: #70c770;
--bs-danger: #e26b67;
--bs-warning: #f4bd65;
--bs-info: #73cde6;
--bs-secondary-bg: #2b333b;
--bs-secondary-bg-rgb: 43, 51, 59;
--bs-secondary-color: #bac4cd;
--bs-tertiary-bg: #252c33;
--bs-border-color: #46515b;
--bs-link-color: #8cc8f0;
}
/* Note: Bootstrap 3 panels and wells are represented through shared app containers. */
.card,
.dropdown-menu,
.modal-content,
.surface-section,
.smart-setting-row,
.toast {
background-color: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: 4px;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
}
.navbar,
.topbar {
background-image: linear-gradient(#ffffff, #f2f2f2);
border-bottom: 1px solid var(--bs-border-color);
}
[data-bs-theme="dark"] .navbar,
[data-bs-theme="dark"] .topbar {
background-image: linear-gradient(#303941, #20272e);
}
.btn {
border-width: 1px;
border-radius: 4px;
font-weight: 600;
transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease;
}
.btn:hover {
filter: none;
text-decoration: none;
}
.btn:focus-visible {
box-shadow: 0 0 0 .2rem rgba(var(--bs-primary-rgb), .32);
filter: none;
text-decoration: none;
}
.btn-primary {
background-image: linear-gradient(#428bca, #2d6ca2);
border-color: #2b669a;
color: #ffffff;
}
.btn-primary:hover,
.btn-primary:focus-visible,
.nav-pills .nav-link.active,
.nav-pills .show > .nav-link {
background-color: #2d6ca2;
color: #ffffff;
}
.btn-success,
.btn-success:hover,
.btn-success:focus-visible {
background-color: #5cb85c;
border-color: #4cae4c;
color: #ffffff;
}
.btn-danger,
.btn-danger:hover,
.btn-danger:focus-visible {
background-color: #d9534f;
border-color: #d43f3a;
color: #ffffff;
}
.btn-warning,
.btn-warning:hover,
.btn-warning:focus-visible {
background-color: #f0ad4e;
border-color: #eea236;
color: #111111;
}
.btn-info,
.btn-info:hover,
.btn-info:focus-visible {
background-color: #5bc0de;
border-color: #46b8da;
color: #ffffff;
}
.btn-secondary,
.btn-outline-secondary,
.btn-light {
background-color: #ffffff;
border-color: #cccccc;
color: #333333;
}
.btn-outline-primary,
.btn-outline-success,
.btn-outline-danger,
.btn-outline-warning,
.btn-outline-info {
background-color: var(--bs-body-bg);
}
.btn-outline-primary:hover,
.btn-outline-primary:focus-visible {
background-color: var(--bs-primary);
border-color: var(--bs-primary);
color: #ffffff;
}
.btn-outline-success:hover,
.btn-outline-success:focus-visible {
background-color: var(--bs-success);
border-color: var(--bs-success);
color: #ffffff;
}
.btn-outline-danger:hover,
.btn-outline-danger:focus-visible {
background-color: var(--bs-danger);
border-color: var(--bs-danger);
color: #ffffff;
}
.btn-outline-warning:hover,
.btn-outline-warning:focus-visible {
background-color: var(--bs-warning);
border-color: var(--bs-warning);
color: #111111;
}
.btn-outline-info:hover,
.btn-outline-info:focus-visible {
background-color: var(--bs-info);
border-color: var(--bs-info);
color: #111111;
}
.btn-secondary:hover,
.btn-secondary:focus-visible,
.btn-outline-secondary:hover,
.btn-outline-secondary:focus-visible {
background-color: #e6e6e6;
border-color: #adadad;
color: #222222;
}
[data-bs-theme="dark"] .btn-secondary,
[data-bs-theme="dark"] .btn-outline-secondary,
[data-bs-theme="dark"] .btn-light {
background-color: #323b44;
border-color: #53606b;
color: #edf2f6;
}
[data-bs-theme="dark"] .btn-secondary:hover,
[data-bs-theme="dark"] .btn-secondary:focus-visible,
[data-bs-theme="dark"] .btn-outline-secondary:hover,
[data-bs-theme="dark"] .btn-outline-secondary:focus-visible {
background-color: #46515b;
color: #ffffff;
}
.form-control,
.form-select,
.input-group-text {
border: 1px solid #cccccc;
border-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
}
.form-control:focus,
.form-select:focus {
border-color: #66afe9;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6);
}
.card-header,
.modal-header,
.table thead th {
background-color: #f5f5f5;
border-bottom: 1px solid var(--bs-border-color);
color: var(--bs-body-color);
font-weight: 700;
}
[data-bs-theme="dark"] .card-header,
[data-bs-theme="dark"] .modal-header,
[data-bs-theme="dark"] .table thead th {
background-color: #303941;
}
.badge,
.label {
border-radius: 4px;
}
.alert {
border-radius: 4px;
border-width: 1px;
}
.progress {
background-color: #f5f5f5;
border-radius: 4px;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
}
.progress-bar {
background-image: linear-gradient(#5bc0de, #339bb9);
}
.table > :not(caption) > * > * {
border-bottom-color: var(--bs-border-color);
}

View File

@@ -0,0 +1,142 @@
/* Note: Bootstrap 4 Cards uses the v4 blue, subtler radii and card-first surfaces. */
:root {
--bs-border-radius: .25rem;
--bs-border-radius-sm: .2rem;
--bs-border-radius-lg: .3rem;
--bs-font-sans-serif: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
[data-bs-theme="light"] {
--bs-body-bg: #f8f9fa;
--bs-body-color: #212529;
--bs-primary: #007bff;
--bs-primary-rgb: 0, 123, 255;
--bs-success: #28a745;
--bs-danger: #dc3545;
--bs-warning: #ffc107;
--bs-info: #17a2b8;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-secondary-color: #6c757d;
--bs-tertiary-bg: #ffffff;
--bs-border-color: #dee2e6;
--bs-link-color: #007bff;
}
[data-bs-theme="dark"] {
--bs-body-bg: #181c20;
--bs-body-color: #e9ecef;
--bs-primary: #4dabf7;
--bs-primary-rgb: 77, 171, 247;
--bs-success: #51cf66;
--bs-danger: #ff6b6b;
--bs-warning: #ffd43b;
--bs-info: #3bc9db;
--bs-secondary-bg: #2a3036;
--bs-secondary-bg-rgb: 42, 48, 54;
--bs-secondary-color: #c1c7cd;
--bs-tertiary-bg: #22272c;
--bs-border-color: #444c55;
--bs-link-color: #74c0fc;
}
.card,
.surface-section,
.modal-content,
.dropdown-menu {
border-radius: .25rem;
box-shadow: 0 .125rem .25rem rgba(0, 0, 0, .075);
}
.btn-primary:hover,
.btn-primary:focus-visible {
background-color: #0069d9;
border-color: #0062cc;
}
[data-bs-theme="dark"] .btn-primary:hover,
[data-bs-theme="dark"] .btn-primary:focus-visible {
background-color: #228be6;
border-color: #1c7ed6;
}
.nav-pills .nav-link.active,
.nav-pills .show > .nav-link {
background-color: var(--bs-primary);
color: #ffffff;
}
.btn {
border-width: 1px;
font-weight: 600;
transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease, filter .15s ease;
}
.btn:hover,
.btn:focus-visible {
filter: none;
text-decoration: none;
}
.btn-primary,
.btn-success,
.btn-danger,
.btn-warning,
.btn-info {
color: #ffffff;
}
.btn-outline-primary,
.btn-outline-success,
.btn-outline-danger,
.btn-outline-warning,
.btn-outline-info,
.btn-outline-secondary {
background-color: var(--bs-body-bg);
}
.btn-outline-primary:hover,
.btn-outline-primary:focus-visible {
background-color: var(--bs-primary);
border-color: var(--bs-primary);
color: #ffffff;
}
.btn-outline-secondary:hover,
.btn-outline-secondary:focus-visible {
background-color: var(--bs-secondary-color);
border-color: var(--bs-secondary-color);
color: var(--bs-body-bg);
}
.btn-outline-success:hover,
.btn-outline-success:focus-visible {
background-color: var(--bs-success);
border-color: var(--bs-success);
color: #ffffff;
}
.btn-outline-danger:hover,
.btn-outline-danger:focus-visible {
background-color: var(--bs-danger);
border-color: var(--bs-danger);
color: #ffffff;
}
.btn-outline-warning:hover,
.btn-outline-warning:focus-visible {
background-color: var(--bs-warning);
border-color: var(--bs-warning);
color: #111111;
}
.btn-outline-info:hover,
.btn-outline-info:focus-visible {
background-color: var(--bs-info);
border-color: var(--bs-info);
color: #111111;
}
.btn:focus-visible {
box-shadow: 0 0 0 .2rem rgba(var(--bs-primary-rgb), .32);
}

View File

@@ -0,0 +1,66 @@
/* High-contrast red accent theme without the previous purple feel. */
:root, [data-bs-theme="light"] {
color-scheme: light;
--bs-primary: #be123c;
--bs-primary-rgb: 190,18,60;
--bs-link-color: #be123c;
--bs-link-color-rgb: 190,18,60;
--bs-link-hover-color: #9f1239;
--bs-link-hover-color-rgb: 190,18,60;
--bs-body-bg: #fff5f7;
--bs-body-bg-rgb: 255,245,247;
--bs-body-color: #2b1118;
--bs-secondary-bg: #ffffff;
--bs-secondary-bg-rgb: 255,255,255;
--bs-tertiary-bg: #ffe4ea;
--bs-border-color: #f5c6d1;
--bs-secondary-color: #76545d;
--bs-primary-bg-subtle: #ffe1e9;
--bs-primary-text-emphasis: #9f1239;
--torrent-progress-complete: #16a34a;
--pytorrent-page-bg: linear-gradient(180deg, #fff8fa 0%, #ffe8ee 100%);
--pytorrent-shell-shadow: 0 16px 38px rgba(159, 18, 57, 0.13);
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-primary: #fb7185;
--bs-primary-rgb: 251,113,133;
--bs-link-color: #fb7185;
--bs-link-color-rgb: 251,113,133;
--bs-link-hover-color: #fecdd3;
--bs-link-hover-color-rgb: 251,113,133;
--bs-body-bg: #13070a;
--bs-body-bg-rgb: 19,7,10;
--bs-body-color: #f7dce2;
--bs-secondary-bg: #211014;
--bs-secondary-bg-rgb: 33,16,20;
--bs-tertiary-bg: #2b151b;
--bs-border-color: #4b2430;
--bs-secondary-color: #c096a1;
--bs-primary-bg-subtle: #3b111b;
--bs-primary-text-emphasis: #fecdd3;
--torrent-progress-complete: #22c55e;
--pytorrent-page-bg: radial-gradient(circle at top left, rgba(251, 113, 133, 0.15), transparent 35%), #13070a;
--pytorrent-shell-shadow: 0 18px 55px rgba(28, 0, 8, 0.58);
}
.btn-primary {
--bs-btn-bg: var(--bs-primary);
--bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary-text-emphasis);
--bs-btn-hover-border-color: var(--bs-primary-text-emphasis);
}
.btn-outline-primary {
--bs-btn-color: var(--bs-primary);
--bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72);
--bs-btn-hover-bg: var(--bs-primary);
--bs-btn-hover-border-color: var(--bs-primary);
}
.nav-pills {
--bs-nav-pills-link-active-bg: var(--bs-primary);
}
.progress,
.progress-stacked {
--bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82);
}

View File

@@ -0,0 +1,66 @@
/* Green productivity theme with warm contrast. */
:root, [data-bs-theme="light"] {
color-scheme: light;
--bs-primary: #2f7d32;
--bs-primary-rgb: 47,125,50;
--bs-link-color: #2f7d32;
--bs-link-color-rgb: 47,125,50;
--bs-link-hover-color: #256329;
--bs-link-hover-color-rgb: 47,125,50;
--bs-body-bg: #f4faf2;
--bs-body-bg-rgb: 244,250,242;
--bs-body-color: #172417;
--bs-secondary-bg: #ffffff;
--bs-secondary-bg-rgb: 255,255,255;
--bs-tertiary-bg: #e7f4e4;
--bs-border-color: #cce4c7;
--bs-secondary-color: #61735e;
--bs-primary-bg-subtle: #dcf2d7;
--bs-primary-text-emphasis: #256329;
--torrent-progress-complete: #22c55e;
--pytorrent-page-bg: linear-gradient(180deg, #fbfff9 0%, #e9f6e5 100%);
--pytorrent-shell-shadow: 0 16px 38px rgba(35, 84, 38, 0.13);
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-primary: #4ade80;
--bs-primary-rgb: 74,222,128;
--bs-link-color: #4ade80;
--bs-link-color-rgb: 74,222,128;
--bs-link-hover-color: #bbf7d0;
--bs-link-hover-color-rgb: 74,222,128;
--bs-body-bg: #071109;
--bs-body-bg-rgb: 7,17,9;
--bs-body-color: #d9f1dc;
--bs-secondary-bg: #0f1f12;
--bs-secondary-bg-rgb: 15,31,18;
--bs-tertiary-bg: #152b18;
--bs-border-color: #24472a;
--bs-secondary-color: #95b79a;
--bs-primary-bg-subtle: #13381a;
--bs-primary-text-emphasis: #bbf7d0;
--torrent-progress-complete: #4ade80;
--pytorrent-page-bg: radial-gradient(circle at top left, rgba(74, 222, 128, 0.14), transparent 36%), #071109;
--pytorrent-shell-shadow: 0 18px 55px rgba(0, 20, 4, 0.58);
}
.btn-primary {
--bs-btn-bg: var(--bs-primary);
--bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary-text-emphasis);
--bs-btn-hover-border-color: var(--bs-primary-text-emphasis);
}
.btn-outline-primary {
--bs-btn-color: var(--bs-primary);
--bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72);
--bs-btn-hover-bg: var(--bs-primary);
--bs-btn-hover-border-color: var(--bs-primary);
}
.nav-pills {
--bs-nav-pills-link-active-bg: var(--bs-primary);
}
.progress,
.progress-stacked {
--bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82);
}

View File

@@ -0,0 +1,66 @@
/* Neutral grey theme for users who want low saturation. */
:root, [data-bs-theme="light"] {
color-scheme: light;
--bs-primary: #334155;
--bs-primary-rgb: 51,65,85;
--bs-link-color: #334155;
--bs-link-color-rgb: 51,65,85;
--bs-link-hover-color: #1f2937;
--bs-link-hover-color-rgb: 51,65,85;
--bs-body-bg: #f5f6f8;
--bs-body-bg-rgb: 245,246,248;
--bs-body-color: #18202f;
--bs-secondary-bg: #ffffff;
--bs-secondary-bg-rgb: 255,255,255;
--bs-tertiary-bg: #eceff3;
--bs-border-color: #d7dce3;
--bs-secondary-color: #667085;
--bs-primary-bg-subtle: #e4e8ef;
--bs-primary-text-emphasis: #1f2937;
--torrent-progress-complete: #16a34a;
--pytorrent-page-bg: linear-gradient(180deg, #fafafa 0%, #edf0f4 100%);
--pytorrent-shell-shadow: 0 16px 38px rgba(31, 41, 55, 0.13);
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-primary: #94a3b8;
--bs-primary-rgb: 148,163,184;
--bs-link-color: #94a3b8;
--bs-link-color-rgb: 148,163,184;
--bs-link-hover-color: #e2e8f0;
--bs-link-hover-color-rgb: 148,163,184;
--bs-body-bg: #07090d;
--bs-body-bg-rgb: 7,9,13;
--bs-body-color: #d7dde7;
--bs-secondary-bg: #11151c;
--bs-secondary-bg-rgb: 17,21,28;
--bs-tertiary-bg: #171c25;
--bs-border-color: #2b3442;
--bs-secondary-color: #99a3b3;
--bs-primary-bg-subtle: #1d2531;
--bs-primary-text-emphasis: #e2e8f0;
--torrent-progress-complete: #22c55e;
--pytorrent-page-bg: radial-gradient(circle at top left, rgba(148, 163, 184, 0.11), transparent 34%), #07090d;
--pytorrent-shell-shadow: 0 18px 55px rgba(0, 0, 0, 0.52);
}
.btn-primary {
--bs-btn-bg: var(--bs-primary);
--bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary-text-emphasis);
--bs-btn-hover-border-color: var(--bs-primary-text-emphasis);
}
.btn-outline-primary {
--bs-btn-color: var(--bs-primary);
--bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72);
--bs-btn-hover-bg: var(--bs-primary);
--bs-btn-hover-border-color: var(--bs-primary);
}
.nav-pills {
--bs-nav-pills-link-active-bg: var(--bs-primary);
}
.progress,
.progress-stacked {
--bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82);
}

View File

@@ -0,0 +1,66 @@
/* Cool blue-grey theme inspired by Nordic palettes. */
:root, [data-bs-theme="light"] {
color-scheme: light;
--bs-primary: #4c6a92;
--bs-primary-rgb: 76,106,146;
--bs-link-color: #4c6a92;
--bs-link-color-rgb: 76,106,146;
--bs-link-hover-color: #365174;
--bs-link-hover-color-rgb: 76,106,146;
--bs-body-bg: #f4f7fb;
--bs-body-bg-rgb: 244,247,251;
--bs-body-color: #182334;
--bs-secondary-bg: #ffffff;
--bs-secondary-bg-rgb: 255,255,255;
--bs-tertiary-bg: #e7edf5;
--bs-border-color: #d0d9e8;
--bs-secondary-color: #607089;
--bs-primary-bg-subtle: #dfe8f4;
--bs-primary-text-emphasis: #365174;
--torrent-progress-complete: #3aa675;
--pytorrent-page-bg: linear-gradient(180deg, #fbfdff 0%, #e9eff7 100%);
--pytorrent-shell-shadow: 0 16px 38px rgba(47, 64, 87, 0.13);
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-primary: #88c0d0;
--bs-primary-rgb: 136,192,208;
--bs-link-color: #88c0d0;
--bs-link-color-rgb: 136,192,208;
--bs-link-hover-color: #b6e3ee;
--bs-link-hover-color-rgb: 136,192,208;
--bs-body-bg: #0b111a;
--bs-body-bg-rgb: 11,17,26;
--bs-body-color: #d8dee9;
--bs-secondary-bg: #111927;
--bs-secondary-bg-rgb: 17,25,39;
--bs-tertiary-bg: #172233;
--bs-border-color: #2e3a4d;
--bs-secondary-color: #9aa8bc;
--bs-primary-bg-subtle: #132b3a;
--bs-primary-text-emphasis: #b6e3ee;
--torrent-progress-complete: #a3be8c;
--pytorrent-page-bg: radial-gradient(circle at top left, rgba(136, 192, 208, 0.14), transparent 35%), #0b111a;
--pytorrent-shell-shadow: 0 18px 55px rgba(3, 8, 15, 0.55);
}
.btn-primary {
--bs-btn-bg: var(--bs-primary);
--bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary-text-emphasis);
--bs-btn-hover-border-color: var(--bs-primary-text-emphasis);
}
.btn-outline-primary {
--bs-btn-color: var(--bs-primary);
--bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72);
--bs-btn-hover-bg: var(--bs-primary);
--bs-btn-hover-border-color: var(--bs-primary);
}
.nav-pills {
--bs-nav-pills-link-active-bg: var(--bs-primary);
}
.progress,
.progress-stacked {
--bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82);
}

View File

@@ -0,0 +1,66 @@
/* Dark teal/navy with a bright, clean light mode. */
:root, [data-bs-theme="light"] {
color-scheme: light;
--bs-primary: #0f766e;
--bs-primary-rgb: 15,118,110;
--bs-link-color: #0f766e;
--bs-link-color-rgb: 15,118,110;
--bs-link-hover-color: #095c55;
--bs-link-hover-color-rgb: 15,118,110;
--bs-body-bg: #f2fbfa;
--bs-body-bg-rgb: 242,251,250;
--bs-body-color: #102a2a;
--bs-secondary-bg: #ffffff;
--bs-secondary-bg-rgb: 255,255,255;
--bs-tertiary-bg: #dff5f2;
--bs-border-color: #bfe4df;
--bs-secondary-color: #54706f;
--bs-primary-bg-subtle: #d6f4ef;
--bs-primary-text-emphasis: #095c55;
--torrent-progress-complete: #14b8a6;
--pytorrent-page-bg: linear-gradient(180deg, #f7fffe 0%, #e6f7f5 100%);
--pytorrent-shell-shadow: 0 16px 38px rgba(10, 78, 74, 0.13);
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-primary: #2dd4bf;
--bs-primary-rgb: 45,212,191;
--bs-link-color: #2dd4bf;
--bs-link-color-rgb: 45,212,191;
--bs-link-hover-color: #99f6e4;
--bs-link-hover-color-rgb: 45,212,191;
--bs-body-bg: #031316;
--bs-body-bg-rgb: 3,19,22;
--bs-body-color: #d5f4f1;
--bs-secondary-bg: #082326;
--bs-secondary-bg-rgb: 8,35,38;
--bs-tertiary-bg: #0d3034;
--bs-border-color: #17494e;
--bs-secondary-color: #8ab6b2;
--bs-primary-bg-subtle: #063a3c;
--bs-primary-text-emphasis: #99f6e4;
--torrent-progress-complete: #34d399;
--pytorrent-page-bg: radial-gradient(circle at top left, rgba(45, 212, 191, 0.16), transparent 36%), #031316;
--pytorrent-shell-shadow: 0 18px 55px rgba(0, 20, 24, 0.58);
}
.btn-primary {
--bs-btn-bg: var(--bs-primary);
--bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary-text-emphasis);
--bs-btn-hover-border-color: var(--bs-primary-text-emphasis);
}
.btn-outline-primary {
--bs-btn-color: var(--bs-primary);
--bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72);
--bs-btn-hover-bg: var(--bs-primary);
--bs-btn-hover-border-color: var(--bs-primary);
}
.nav-pills {
--bs-nav-pills-link-active-bg: var(--bs-primary);
}
.progress,
.progress-stacked {
--bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82);
}

View File

@@ -0,0 +1,66 @@
/* Fresh lighter blue theme with a readable dark variant. */
:root, [data-bs-theme="light"] {
color-scheme: light;
--bs-primary: #0284c7;
--bs-primary-rgb: 2,132,199;
--bs-link-color: #0284c7;
--bs-link-color-rgb: 2,132,199;
--bs-link-hover-color: #0369a1;
--bs-link-hover-color-rgb: 2,132,199;
--bs-body-bg: #f2f9ff;
--bs-body-bg-rgb: 242,249,255;
--bs-body-color: #102033;
--bs-secondary-bg: #ffffff;
--bs-secondary-bg-rgb: 255,255,255;
--bs-tertiary-bg: #dff1ff;
--bs-border-color: #bddff5;
--bs-secondary-color: #567185;
--bs-primary-bg-subtle: #d7efff;
--bs-primary-text-emphasis: #0369a1;
--torrent-progress-complete: #10b981;
--pytorrent-page-bg: linear-gradient(180deg, #f8fcff 0%, #e5f4ff 100%);
--pytorrent-shell-shadow: 0 16px 38px rgba(3, 105, 161, 0.13);
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-primary: #38bdf8;
--bs-primary-rgb: 56,189,248;
--bs-link-color: #38bdf8;
--bs-link-color-rgb: 56,189,248;
--bs-link-hover-color: #bae6fd;
--bs-link-hover-color-rgb: 56,189,248;
--bs-body-bg: #06111a;
--bs-body-bg-rgb: 6,17,26;
--bs-body-color: #d9ecf8;
--bs-secondary-bg: #0d1c29;
--bs-secondary-bg-rgb: 13,28,41;
--bs-tertiary-bg: #12283a;
--bs-border-color: #21435b;
--bs-secondary-color: #91b4ca;
--bs-primary-bg-subtle: #0b334d;
--bs-primary-text-emphasis: #bae6fd;
--torrent-progress-complete: #34d399;
--pytorrent-page-bg: radial-gradient(circle at top left, rgba(56, 189, 248, 0.15), transparent 36%), #06111a;
--pytorrent-shell-shadow: 0 18px 55px rgba(0, 13, 24, 0.58);
}
.btn-primary {
--bs-btn-bg: var(--bs-primary);
--bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary-text-emphasis);
--bs-btn-hover-border-color: var(--bs-primary-text-emphasis);
}
.btn-outline-primary {
--bs-btn-color: var(--bs-primary);
--bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72);
--bs-btn-hover-bg: var(--bs-primary);
--bs-btn-hover-border-color: var(--bs-primary);
}
.nav-pills {
--bs-nav-pills-link-active-bg: var(--bs-primary);
}
.progress,
.progress-stacked {
--bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -16,6 +16,13 @@
<div class="initial-loader-brand"><i class="fa-solid fa-robot"></i> pyTorrent</div> <div class="initial-loader-brand"><i class="fa-solid fa-robot"></i> pyTorrent</div>
<div class="auth-lock" aria-hidden="true"><i class="fa-solid fa-lock"></i></div> <div class="auth-lock" aria-hidden="true"><i class="fa-solid fa-lock"></i></div>
<h1 class="initial-loader-title">Sign in</h1> <h1 class="initial-loader-title">Sign in</h1>
{% if external_provider %}
<p class="initial-loader-text">External authentication is enabled through {{ external_provider }}.</p>
{% if error %}<div class="alert alert-warning auth-alert">{{ error }}</div>{% endif %}
<div class="auth-provider-note">
pyTorrent expects trusted reverse-proxy identity headers. If you are already signed in, check provider headers and user mapping.
</div>
{% else %}
<p class="initial-loader-text">Authentication is enabled for this pyTorrent instance.</p> <p class="initial-loader-text">Authentication is enabled for this pyTorrent instance.</p>
{% if error %}<div class="alert alert-danger auth-alert">{{ error }}</div>{% endif %} {% if error %}<div class="alert alert-danger auth-alert">{{ error }}</div>{% endif %}
<form class="auth-form" method="post"> <form class="auth-form" method="post">
@@ -25,6 +32,7 @@
<input id="password" class="form-control" name="password" type="password" autocomplete="current-password"> <input id="password" class="form-control" name="password" type="password" autocomplete="current-password">
<button class="btn btn-primary w-100" type="submit"><i class="fa-solid fa-right-to-bracket"></i> Log in</button> <button class="btn btn-primary w-100" type="submit"><i class="fa-solid fa-right-to-bracket"></i> Log in</button>
</form> </form>
{% endif %}
</main> </main>
</body> </body>
</html> </html>

View File

@@ -5,3 +5,4 @@ geoip2>=4.8
psutil>=5.9 psutil>=5.9
simple-websocket>=1.0 simple-websocket>=1.0
gunicorn>=22.0 gunicorn>=22.0
hachoir>=3.3

199
scripts/db_cleanup.py Normal file
View File

@@ -0,0 +1,199 @@
#!/usr/bin/env python3
import shutil
import sqlite3
from datetime import datetime
from pathlib import Path
DB_PATH = Path("/opt/pyTorrent/data/pytorrent.sqlite3")
DROP_COLUMNS = {
"rss_feeds": [
"user_id",
],
"rss_rules": [
"user_id",
],
"rss_history": [
"user_id",
],
"smart_queue_settings": [
"user_id",
],
"smart_queue_exclusions": [
"user_id",
],
"smart_queue_history": [
"user_id",
],
"rtorrent_config_overrides": [
"user_id",
],
"user_preferences": [
"table_columns_json",
"peers_refresh_seconds",
"port_check_enabled",
"tracker_favicons_enabled",
"reverse_dns_enabled",
"disk_monitor_paths_json",
"disk_monitor_mode",
"disk_monitor_selected_path",
"disk_monitor_stop_enabled",
"disk_monitor_stop_threshold",
"torrent_sort_json",
"active_filter",
],
}
DROP_INDEXES = [
"idx_rss_feeds_user_profile",
"idx_rss_rules_user_profile",
"idx_rss_history_user_profile",
"idx_smart_queue_settings_user_profile",
"idx_smart_queue_exclusions_user_profile",
"idx_smart_queue_history_user_profile",
"idx_rtorrent_config_overrides_user_profile",
]
EXPECTED_PROFILE_TABLES = {
"rss_feeds": ["profile_id"],
"rss_rules": ["profile_id"],
"rss_history": ["profile_id"],
"smart_queue_settings": ["profile_id"],
"smart_queue_exclusions": ["profile_id"],
"smart_queue_history": ["profile_id"],
"rtorrent_config_overrides": ["profile_id"],
}
def table_exists(conn: sqlite3.Connection, table: str) -> bool:
row = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
(table,),
).fetchone()
return row is not None
def index_exists(conn: sqlite3.Connection, index: str) -> bool:
row = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='index' AND name=?",
(index,),
).fetchone()
return row is not None
def columns(conn: sqlite3.Connection, table: str) -> list[str]:
return [row[1] for row in conn.execute(f'PRAGMA table_info("{table}")')]
def quote_ident(name: str) -> str:
return '"' + name.replace('"', '""') + '"'
def validate_profile_tables(conn: sqlite3.Connection) -> None:
print("Checking required profile scoped tables...")
for table, required_columns in EXPECTED_PROFILE_TABLES.items():
if not table_exists(conn, table):
print(f"SKIP table missing: {table}")
continue
table_columns = columns(conn, table)
for column in required_columns:
if column not in table_columns:
raise RuntimeError(
f"Unsafe cleanup: table {table} does not contain required column {column}"
)
print(f"OK {table}: has {', '.join(required_columns)}")
def drop_indexes(conn: sqlite3.Connection) -> None:
print("\nDropping obsolete indexes if present...")
for index in DROP_INDEXES:
if not index_exists(conn, index):
print(f"SKIP index missing: {index}")
continue
conn.execute(f"DROP INDEX {quote_ident(index)}")
print(f"DROPPED index: {index}")
def drop_obsolete_columns(conn: sqlite3.Connection) -> None:
print("\nDropping obsolete columns if present...")
for table, obsolete_columns in DROP_COLUMNS.items():
if not table_exists(conn, table):
print(f"SKIP table missing: {table}")
continue
current_columns = columns(conn, table)
for column in obsolete_columns:
if column not in current_columns:
print(f"SKIP column missing: {table}.{column}")
continue
try:
conn.execute(
f"ALTER TABLE {quote_ident(table)} DROP COLUMN {quote_ident(column)}"
)
print(f"DROPPED column: {table}.{column}")
current_columns.remove(column)
except sqlite3.OperationalError as exc:
print(f"FAILED column: {table}.{column} -> {exc}")
print("This usually means the column is used by an index, constraint, or old SQLite version.")
def vacuum(conn: sqlite3.Connection) -> None:
print("\nRunning VACUUM...")
conn.execute("VACUUM")
print("VACUUM done.")
def main() -> None:
if not DB_PATH.exists():
raise SystemExit(f"Database not found: {DB_PATH}")
backup_path = DB_PATH.with_suffix(
DB_PATH.suffix + f".cleanup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.bak"
)
print(f"Database: {DB_PATH}")
print(f"Backup: {backup_path}")
shutil.copy2(DB_PATH, backup_path)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
conn.execute("PRAGMA foreign_keys = OFF")
validate_profile_tables(conn)
conn.execute("BEGIN")
drop_indexes(conn)
drop_obsolete_columns(conn)
conn.commit()
conn.execute("PRAGMA foreign_keys = ON")
vacuum(conn)
print("\nCleanup completed successfully.")
print(f"Backup saved as: {backup_path}")
except Exception:
conn.rollback()
print("\nCleanup failed. Database rollback completed.")
print(f"Backup is available at: {backup_path}")
raise
finally:
conn.close()
if __name__ == "__main__":
main()

View File

@@ -41,18 +41,71 @@ def google_fonts_css_url() -> str:
return f"https://fonts.googleapis.com/css2?{families}&display=swap" return f"https://fonts.googleapis.com/css2?{families}&display=swap"
BOOTSTRAP_THEMES = ( DEVEXPRESS_BOOTSTRAP_THEMES = {
"default", "blazing-berry": "Blazing Berry",
"flatly", "office-white": "Office White",
"litera", "purple": "Purple",
"lumen", }
"minty",
"sketchy", PYTORRENT_APP_THEMES = {
"solar", "adaptive": "pyTorrent Adaptive",
"spacelab", "ocean": "pyTorrent Ocean",
"united", "graphite": "pyTorrent Graphite",
"zephyr", "forest": "pyTorrent Forest",
) "amber": "pyTorrent Amber",
"nord": "pyTorrent Nord",
"crimson": "pyTorrent Crimson",
"sky": "pyTorrent Sky",
}
BOOTSTRAP_THEME_DEFINITIONS = {
"default": {
"label": "Default Bootstrap",
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/css/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/css/bootstrap.min.css",
},
# Bootswatch themes.
"flatly": {"label": "Bootswatch: Flatly", "provider": "bootswatch"},
"litera": {"label": "Bootswatch: Litera", "provider": "bootswatch"},
"lumen": {"label": "Bootswatch: Lumen", "provider": "bootswatch"},
"minty": {"label": "Bootswatch: Minty", "provider": "bootswatch"},
"sketchy": {"label": "Bootswatch: Sketchy", "provider": "bootswatch"},
"spacelab": {"label": "Bootswatch: Spacelab", "provider": "bootswatch"},
"united": {"label": "Bootswatch: United", "provider": "bootswatch"},
"zephyr": {"label": "Bootswatch: Zephyr", "provider": "bootswatch"},
# Complete DevExpress Bootstrap v5 dist.v5 set.
**{
f"dx-{theme}": {
"label": f"DevExpress: {label}",
"provider": "devexpress",
"local": f"{LIBS_STATIC_DIR}/devexpress-bootstrap-themes/dist.v5/{theme}/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/gh/DevExpress/bootstrap-themes@master/dist.v5/{theme}/bootstrap.min.css",
}
for theme, label in DEVEXPRESS_BOOTSTRAP_THEMES.items()
},
# App-specific Bootstrap variable overrides. These sit on top of default Bootstrap.
**{
f"pytorrent-{theme}": {
"label": f"Custom: {label}",
"provider": "pytorrent",
"local": f"{LIBS_STATIC_DIR}/pytorrent-themes/{theme}/bootstrap.min.css",
"cdn": f"/static/{LIBS_STATIC_DIR}/pytorrent-themes/{theme}/bootstrap.min.css",
}
for theme, label in PYTORRENT_APP_THEMES.items()
},
}
def _theme_definition(theme: str | None) -> dict[str, str]:
theme = theme if theme in BOOTSTRAP_THEME_DEFINITIONS else "default"
item = dict(BOOTSTRAP_THEME_DEFINITIONS[theme])
if item.get("provider") == "bootswatch":
item["local"] = f"{LIBS_STATIC_DIR}/bootswatch/{BOOTSWATCH_VERSION}/{theme}/bootstrap.min.css"
item["cdn"] = f"https://cdn.jsdelivr.net/npm/bootswatch@{BOOTSWATCH_VERSION}/dist/{theme}/bootstrap.min.css"
return item
BOOTSTRAP_THEMES = tuple(BOOTSTRAP_THEME_DEFINITIONS.keys())
STATIC_ASSETS = { STATIC_ASSETS = {
"bootstrap_js": { "bootstrap_js": {
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/js/bootstrap.bundle.min.js", "local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/js/bootstrap.bundle.min.js",
@@ -88,15 +141,8 @@ ANY_URL_RE = re.compile(r"url\((['\"]?)(?!data:)([^)'\"]+)\1\)")
def bootstrap_css_asset(theme: str) -> dict[str, str]: def bootstrap_css_asset(theme: str) -> dict[str, str]:
if theme == "default": item = _theme_definition(theme)
return { return {"local": item["local"], "cdn": item["cdn"]}
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/css/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/css/bootstrap.min.css",
}
return {
"local": f"{LIBS_STATIC_DIR}/bootswatch/{BOOTSWATCH_VERSION}/{theme}/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/npm/bootswatch@{BOOTSWATCH_VERSION}/dist/{theme}/bootstrap.min.css",
}
def download(url: str, dest: Path) -> None: def download(url: str, dest: Path) -> None:
@@ -169,7 +215,11 @@ def main() -> None:
for item in items: for item in items:
url = item["cdn"] url = item["cdn"]
dest = ROOT / "pytorrent" / "static" / item["local"] dest = ROOT / "pytorrent" / "static" / item["local"]
if item.get("local") == STATIC_ASSETS["font_css"]["local"]: if url.startswith("/static/"):
if not dest.is_file() or dest.stat().st_size <= 0:
raise RuntimeError(f"Bundled app theme is missing: {dest.relative_to(ROOT)}")
print(f"OK {dest.relative_to(ROOT)}")
elif item.get("local") == STATIC_ASSETS["font_css"]["local"]:
download_google_fonts_css(url, dest) download_google_fonts_css(url, dest)
elif dest.suffix == ".css": elif dest.suffix == ".css":
download_css_with_assets(url, dest) download_css_with_assets(url, dest)

View File

@@ -4,7 +4,7 @@ global.window = {PYTORRENT_DISABLE_AUTOSTART: true};
const app = await import('../pytorrent/static/js/app.js'); const app = await import('../pytorrent/static/js/app.js');
const source = app.buildRuntimeSource(); const source = app.buildRuntimeSource();
assert.equal(app.moduleSources.length, 12, 'all frontend module chunks are loaded'); assert.equal(app.moduleSources.length, 18, 'all frontend module chunks are loaded');
assert.doesNotThrow(() => Function('io', source), 'assembled frontend runtime compiles'); assert.doesNotThrow(() => Function('io', source), 'assembled frontend runtime compiles');
for (const marker of [ for (const marker of [