Initial commit: docker compose config
Release Docker multi arch / docker (push) Has been cancelled
Test Install Script / Test Script Syntax (push) Has been cancelled
Test Install Script / Test on almalinux-10 (default) (push) Has been cancelled
Test Install Script / Test on almalinux-10 (root) (push) Has been cancelled
Test Install Script / Test on almalinux-8 (default) (push) Has been cancelled
Test Install Script / Test on almalinux-8 (root) (push) Has been cancelled
Test Install Script / Test on almalinux-9 (default) (push) Has been cancelled
Test Install Script / Test on almalinux-9 (root) (push) Has been cancelled
Test Install Script / Test on amazonlinux-2 (default) (push) Has been cancelled
Test Install Script / Test on amazonlinux-2 (root) (push) Has been cancelled
Test Install Script / Test on debian-11 (default) (push) Has been cancelled
Test Install Script / Test on debian-11 (root) (push) Has been cancelled
Test Install Script / Test on debian-12 (default) (push) Has been cancelled
Test Install Script / Test on debian-12 (root) (push) Has been cancelled
Test Install Script / Test on debian-13 (default) (push) Has been cancelled
Test Install Script / Test on debian-13 (root) (push) Has been cancelled
Test Install Script / Test on fedora-latest (default) (push) Has been cancelled
Test Install Script / Test on fedora-latest (root) (push) Has been cancelled
Test Install Script / Test on rocky-10 (default) (push) Has been cancelled
Test Install Script / Test on rocky-10 (root) (push) Has been cancelled
Test Install Script / Test on rocky-8 (default) (push) Has been cancelled
Test Install Script / Test on rocky-8 (root) (push) Has been cancelled
Test Install Script / Test on rocky-9 (default) (push) Has been cancelled
Test Install Script / Test on rocky-9 (root) (push) Has been cancelled
Test Install Script / Test on ubuntu-22.04 (default) (push) Has been cancelled
Test Install Script / Test on ubuntu-22.04 (root) (push) Has been cancelled
Test Install Script / Test on ubuntu-24.04 (default) (push) Has been cancelled
Test Install Script / Test on ubuntu-24.04 (root) (push) Has been cancelled
Release Docker multi arch / docker (push) Has been cancelled
Test Install Script / Test Script Syntax (push) Has been cancelled
Test Install Script / Test on almalinux-10 (default) (push) Has been cancelled
Test Install Script / Test on almalinux-10 (root) (push) Has been cancelled
Test Install Script / Test on almalinux-8 (default) (push) Has been cancelled
Test Install Script / Test on almalinux-8 (root) (push) Has been cancelled
Test Install Script / Test on almalinux-9 (default) (push) Has been cancelled
Test Install Script / Test on almalinux-9 (root) (push) Has been cancelled
Test Install Script / Test on amazonlinux-2 (default) (push) Has been cancelled
Test Install Script / Test on amazonlinux-2 (root) (push) Has been cancelled
Test Install Script / Test on debian-11 (default) (push) Has been cancelled
Test Install Script / Test on debian-11 (root) (push) Has been cancelled
Test Install Script / Test on debian-12 (default) (push) Has been cancelled
Test Install Script / Test on debian-12 (root) (push) Has been cancelled
Test Install Script / Test on debian-13 (default) (push) Has been cancelled
Test Install Script / Test on debian-13 (root) (push) Has been cancelled
Test Install Script / Test on fedora-latest (default) (push) Has been cancelled
Test Install Script / Test on fedora-latest (root) (push) Has been cancelled
Test Install Script / Test on rocky-10 (default) (push) Has been cancelled
Test Install Script / Test on rocky-10 (root) (push) Has been cancelled
Test Install Script / Test on rocky-8 (default) (push) Has been cancelled
Test Install Script / Test on rocky-8 (root) (push) Has been cancelled
Test Install Script / Test on rocky-9 (default) (push) Has been cancelled
Test Install Script / Test on rocky-9 (root) (push) Has been cancelled
Test Install Script / Test on ubuntu-22.04 (default) (push) Has been cancelled
Test Install Script / Test on ubuntu-22.04 (root) (push) Has been cancelled
Test Install Script / Test on ubuntu-24.04 (default) (push) Has been cancelled
Test Install Script / Test on ubuntu-24.04 (root) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
web/node_modules
|
||||
web/build
|
||||
web/dist
|
||||
web/.env_example
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG]"
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. Linux Ubuntu 22.04]
|
||||
- TorrServer Version [e.g. MatriX.129]
|
||||
|
||||
**Smartphone or tvbox on Android (please complete the following information):**
|
||||
- Device: [e.g. Ugoos am6]
|
||||
- OS: [e.g. Android 9]
|
||||
- TorrServer Version [e.g. MatriX.129]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: "[Feature]"
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
Executable
+412
@@ -0,0 +1,412 @@
|
||||
#!/usr/bin/env bash
|
||||
# Test script for installTorrServerLinux.sh
|
||||
# This script runs inside Docker containers to test the installation script
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
readonly RED='\033[0;31m'
|
||||
readonly GREEN='\033[0;32m'
|
||||
readonly YELLOW='\033[1;33m'
|
||||
readonly NC='\033[0m' # No Color
|
||||
|
||||
# Test configuration
|
||||
readonly SCRIPT_NAME="installTorrServerLinux.sh"
|
||||
readonly INSTALL_DIR="/opt/torrserver"
|
||||
readonly GLIBC_LIMITED_VERSION="135"
|
||||
readonly MIN_GLIBC_VERSION="2.32"
|
||||
readonly MAX_RETRIES="${MAX_RETRIES:-3}"
|
||||
readonly RETRY_DELAY="${RETRY_DELAY:-2}"
|
||||
|
||||
# Helper functions
|
||||
log_info() {
|
||||
echo -e "${GREEN}✓${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}✗${NC} $1"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}⚠${NC} $1"
|
||||
}
|
||||
|
||||
log_test() {
|
||||
echo "Test $1: $2"
|
||||
}
|
||||
|
||||
# Check if OS requires glibc-limited version
|
||||
is_glibc_limited_os() {
|
||||
local os="$1"
|
||||
local glibc_limited_oses="$2"
|
||||
echo "$glibc_limited_oses" | grep -qE "(^|\|)$os(\||$)"
|
||||
}
|
||||
|
||||
# Get glibc version message for OS
|
||||
get_glibc_message() {
|
||||
local os="$1"
|
||||
case "$os" in
|
||||
debian-11)
|
||||
echo "Note: Debian 11 has glibc 2.31, installing version $GLIBC_LIMITED_VERSION (version 136+ requires glibc >= $MIN_GLIBC_VERSION)"
|
||||
;;
|
||||
almalinux-8)
|
||||
echo "Note: AlmaLinux 8 has glibc 2.28, installing version $GLIBC_LIMITED_VERSION (version 136+ requires glibc >= $MIN_GLIBC_VERSION)"
|
||||
;;
|
||||
rocky-8)
|
||||
echo "Note: Rocky 8 has glibc 2.28, installing version $GLIBC_LIMITED_VERSION (version 136+ requires glibc >= $MIN_GLIBC_VERSION)"
|
||||
;;
|
||||
amazonlinux-2)
|
||||
echo "Note: Amazon Linux 2 has glibc 2.26, installing version $GLIBC_LIMITED_VERSION (version 136+ requires glibc >= $MIN_GLIBC_VERSION)"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Install RPM packages (dnf/yum)
|
||||
install_rpm_packages() {
|
||||
local pkg_manager="$1"
|
||||
shift
|
||||
local packages=("$@")
|
||||
|
||||
"$pkg_manager" makecache -q || true
|
||||
# Always remove curl-minimal first to avoid conflicts
|
||||
"$pkg_manager" remove -y -q curl-minimal 2>/dev/null || true
|
||||
|
||||
# Check if curl package is installed (not just curl-minimal)
|
||||
if rpm -qa curl >/dev/null 2>&1; then
|
||||
# curl package is already installed, just install other packages
|
||||
"$pkg_manager" install -y -q "${packages[@]}" || true
|
||||
else
|
||||
# curl package not installed, install curl with --allowerasing
|
||||
"$pkg_manager" install -y -q --allowerasing curl "${packages[@]}" || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Install dependencies based on OS
|
||||
install_dependencies() {
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
retry_command "apt-get update" "apt-get update -qq" 3 1 || true
|
||||
retry_command "apt-get install" "apt-get install -y -qq curl iputils-ping dnsutils" 3 1 || true
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
retry_command "dnf install" "install_rpm_packages dnf iputils bind-utils" 3 1 || true
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
retry_command "yum install" "install_rpm_packages yum iputils bind-utils" 3 1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Verify curl installation
|
||||
verify_curl_installation() {
|
||||
if command -v rpm >/dev/null 2>&1; then
|
||||
if ! rpm -qa curl >/dev/null 2>&1; then
|
||||
log_error "curl package is not installed after dependency installation"
|
||||
exit 1
|
||||
fi
|
||||
# Verify curl-minimal is not present (it should have been removed)
|
||||
if rpm -qa curl-minimal >/dev/null 2>&1; then
|
||||
log_warning "curl-minimal is still installed, removing it..."
|
||||
rpm -e --nodeps curl-minimal 2>/dev/null || true
|
||||
fi
|
||||
elif command -v dpkg >/dev/null 2>&1; then
|
||||
if ! dpkg -s curl >/dev/null 2>&1; then
|
||||
log_error "curl package is not installed after dependency installation"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Retry a command with exponential backoff
|
||||
retry_command() {
|
||||
local test_name="$1"
|
||||
local test_command="$2"
|
||||
local max_attempts="${3:-$MAX_RETRIES}"
|
||||
local delay="${4:-$RETRY_DELAY}"
|
||||
local attempt=1
|
||||
local last_error=0
|
||||
|
||||
# Print command before first attempt
|
||||
if [[ $attempt -eq 1 ]]; then
|
||||
echo "Executing: $test_command"
|
||||
fi
|
||||
|
||||
while [[ $attempt -le $max_attempts ]]; do
|
||||
if [[ $attempt -gt 1 ]]; then
|
||||
echo "Retry attempt $attempt/$max_attempts: $test_command"
|
||||
fi
|
||||
if eval "$test_command"; then
|
||||
if [[ $attempt -gt 1 ]]; then
|
||||
log_info "$test_name (succeeded on attempt $attempt)"
|
||||
fi
|
||||
return 0
|
||||
else
|
||||
last_error=$?
|
||||
if [[ $attempt -lt $max_attempts ]]; then
|
||||
log_warning "$test_name failed (attempt $attempt/$max_attempts), retrying in ${delay}s..."
|
||||
sleep "$delay"
|
||||
delay=$((delay * 2))
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
log_error "$test_name (failed after $max_attempts attempts)"
|
||||
return $last_error
|
||||
}
|
||||
|
||||
# Run a test command and handle errors
|
||||
run_test() {
|
||||
local test_name="$1"
|
||||
local test_command="$2"
|
||||
local skip_on_error="${3:-false}"
|
||||
local use_retry="${4:-true}"
|
||||
|
||||
if [[ "$use_retry" == "true" ]]; then
|
||||
if retry_command "$test_name" "$test_command"; then
|
||||
log_info "$test_name"
|
||||
return 0
|
||||
else
|
||||
if [[ "$skip_on_error" == "true" ]]; then
|
||||
log_warning "$test_name (skipped after retries)"
|
||||
return 0
|
||||
else
|
||||
log_error "$test_name"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
if eval "$test_command"; then
|
||||
log_info "$test_name"
|
||||
return 0
|
||||
else
|
||||
if [[ "$skip_on_error" == "true" ]]; then
|
||||
log_warning "$test_name (skipped)"
|
||||
return 0
|
||||
else
|
||||
log_error "$test_name"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Main test execution
|
||||
main() {
|
||||
local matrix_os="${MATRIX_OS:-}"
|
||||
local test_user="${TEST_USER:-default}"
|
||||
local glibc_limited_oses="${GLIBC_LIMITED_OSES:-}"
|
||||
|
||||
# Determine root flag
|
||||
local root_flag=''
|
||||
if [[ "$test_user" == 'root' ]]; then
|
||||
root_flag='--root'
|
||||
fi
|
||||
|
||||
# Check if OS requires glibc-limited version
|
||||
local is_glibc_limited=false
|
||||
if is_glibc_limited_os "$matrix_os" "$glibc_limited_oses"; then
|
||||
is_glibc_limited=true
|
||||
fi
|
||||
|
||||
echo "========================================"
|
||||
echo "Testing $SCRIPT_NAME"
|
||||
echo "OS: $matrix_os"
|
||||
echo "User: $test_user"
|
||||
echo "Retry settings: max=$MAX_RETRIES, initial_delay=${RETRY_DELAY}s"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Test 1: Check script syntax
|
||||
echo "::group::Test 1: Check script syntax"
|
||||
log_test "1" "Checking script syntax..."
|
||||
echo "Executing: bash -n $SCRIPT_NAME"
|
||||
if bash -n "$SCRIPT_NAME"; then
|
||||
log_info "Script syntax is valid"
|
||||
else
|
||||
log_error "Script syntax check failed"
|
||||
echo "::endgroup::"
|
||||
exit 1
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
echo ""
|
||||
|
||||
# Test 2: Show help
|
||||
echo "::group::Test 2: Test help command"
|
||||
log_test "2" "Testing help command..."
|
||||
echo "Executing: ./$SCRIPT_NAME --help"
|
||||
if ./"$SCRIPT_NAME" --help > /dev/null; then
|
||||
log_info "Help command works"
|
||||
else
|
||||
log_error "Help command failed"
|
||||
echo "::endgroup::"
|
||||
exit 1
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
echo ""
|
||||
|
||||
# Test 3: Install in silent mode
|
||||
echo "::group::Test 3: Install TorrServer"
|
||||
log_test "3" "Installing TorrServer (silent mode)..."
|
||||
if [[ "$is_glibc_limited" == "true" ]]; then
|
||||
local glibc_msg
|
||||
glibc_msg=$(get_glibc_message "$matrix_os")
|
||||
if [[ -n "$glibc_msg" ]]; then
|
||||
echo "$glibc_msg"
|
||||
fi
|
||||
if retry_command "Installation" "./$SCRIPT_NAME --install $GLIBC_LIMITED_VERSION --silent $root_flag"; then
|
||||
log_info "Installation completed"
|
||||
else
|
||||
log_error "Installation failed after retries"
|
||||
echo "::endgroup::"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if retry_command "Installation" "./$SCRIPT_NAME --install --silent $root_flag"; then
|
||||
log_info "Installation completed"
|
||||
else
|
||||
log_error "Installation failed after retries"
|
||||
echo "::endgroup::"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
echo ""
|
||||
|
||||
# Test 4: Check installation
|
||||
echo "::group::Test 4: Verify installation"
|
||||
log_test "4" "Checking installation..."
|
||||
echo "Executing: ls $INSTALL_DIR/TorrServer-linux-*"
|
||||
if ls "$INSTALL_DIR"/TorrServer-linux-* >/dev/null 2>&1; then
|
||||
log_info "Binary file exists"
|
||||
else
|
||||
log_error "Binary file not found"
|
||||
echo "::endgroup::"
|
||||
exit 1
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
echo ""
|
||||
|
||||
# Test 5: Check version
|
||||
echo "::group::Test 5: Check version"
|
||||
log_test "5" "Checking for updates..."
|
||||
if [[ "$is_glibc_limited" == "true" ]]; then
|
||||
echo "Note: Skipping version check (latest version requires glibc >= $MIN_GLIBC_VERSION)"
|
||||
log_info "Version check skipped (expected)"
|
||||
else
|
||||
if retry_command "Version check" "./$SCRIPT_NAME --check --silent $root_flag"; then
|
||||
log_info "Version check completed"
|
||||
else
|
||||
log_error "Version check failed after retries"
|
||||
echo "::endgroup::"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
echo ""
|
||||
|
||||
# Test 6: Update (if available)
|
||||
echo "::group::Test 6: Test update command"
|
||||
log_test "6" "Testing update command..."
|
||||
if [[ "$is_glibc_limited" == "true" ]]; then
|
||||
echo "Note: Skipping update test (latest version requires glibc >= $MIN_GLIBC_VERSION)"
|
||||
log_info "Update check skipped (expected)"
|
||||
else
|
||||
if retry_command "Update check" "./$SCRIPT_NAME --update --silent $root_flag"; then
|
||||
log_info "Update check completed"
|
||||
else
|
||||
log_error "Update check failed after retries"
|
||||
echo "::endgroup::"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
echo ""
|
||||
|
||||
# Test 7: Reconfigure
|
||||
echo "::group::Test 7: Test reconfigure command"
|
||||
log_test "7" "Testing reconfigure command..."
|
||||
if retry_command "Reconfigure" "./$SCRIPT_NAME --reconfigure --silent $root_flag"; then
|
||||
log_info "Reconfigure completed"
|
||||
else
|
||||
log_error "Reconfigure failed after retries"
|
||||
echo "::endgroup::"
|
||||
exit 1
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
echo ""
|
||||
|
||||
# Test 8: Change user (if not already root)
|
||||
if [[ "$test_user" == 'default' ]]; then
|
||||
echo "::group::Test 8: Test change-user to root"
|
||||
log_test "8" "Testing change-user to root..."
|
||||
if retry_command "User change to root" "./$SCRIPT_NAME --change-user root --silent"; then
|
||||
log_info "User change to root completed"
|
||||
else
|
||||
log_error "User change to root failed after retries"
|
||||
echo "::endgroup::"
|
||||
exit 1
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
echo ""
|
||||
|
||||
# Test 8b: Change user back to default (only for Ubuntu to test full flow)
|
||||
if [[ "$matrix_os" == 'ubuntu-22.04' ]] || [[ "$matrix_os" == 'ubuntu-24.04' ]]; then
|
||||
echo "::group::Test 8b: Test change-user back to default"
|
||||
log_test "8b" "Testing change-user back to default..."
|
||||
if retry_command "User change back to default" "./$SCRIPT_NAME --change-user torrserver --silent"; then
|
||||
log_info "User change back to default completed"
|
||||
else
|
||||
log_error "User change back to default failed after retries"
|
||||
echo "::endgroup::"
|
||||
exit 1
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
# Test 9: Cleanup - Uninstall
|
||||
echo "::group::Test 9: Uninstall TorrServer"
|
||||
log_test "9" "Uninstalling TorrServer..."
|
||||
if retry_command "Uninstallation" "./$SCRIPT_NAME --remove --silent"; then
|
||||
log_info "Uninstallation completed"
|
||||
else
|
||||
log_error "Uninstallation failed after retries"
|
||||
echo "::endgroup::"
|
||||
exit 1
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
echo ""
|
||||
|
||||
# Test 10: Verify cleanup
|
||||
echo "::group::Test 10: Verify cleanup"
|
||||
log_test "10" "Verifying cleanup..."
|
||||
echo "Executing: Checking if $INSTALL_DIR is empty or doesn't exist"
|
||||
if [[ ! -d "$INSTALL_DIR" ]] || [[ -z "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]]; then
|
||||
log_info "Cleanup verified"
|
||||
else
|
||||
log_warning "Installation directory still exists (may be expected)"
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
echo ""
|
||||
|
||||
echo "========================================"
|
||||
echo "All tests passed! ✓"
|
||||
echo "========================================"
|
||||
}
|
||||
|
||||
# Setup and run tests
|
||||
setup() {
|
||||
echo "::group::Setup: Install dependencies"
|
||||
# Install dependencies
|
||||
install_dependencies
|
||||
|
||||
# Verify curl installation
|
||||
verify_curl_installation
|
||||
|
||||
# Make script executable
|
||||
chmod +x "$SCRIPT_NAME"
|
||||
echo "::endgroup::"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Run setup and main
|
||||
setup
|
||||
main
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Release Docker multi arch
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- "**"
|
||||
|
||||
concurrency:
|
||||
group: docker-build-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set lower case repository name
|
||||
run: |
|
||||
echo "REG_REPO=${REPO,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
REPO: "${{ github.repository }}"
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ env.REG_REPO }}
|
||||
tags: |
|
||||
type=raw,value=master,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=${{ github.workflow }}
|
||||
cache-to: type=gha,mode=max,scope=${{ github.workflow }}
|
||||
@@ -0,0 +1,122 @@
|
||||
name: Test Install Script
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "installTorrServerLinux.sh"
|
||||
- ".github/workflows/test-install-script.yml"
|
||||
- ".github/scripts/test-install-script.sh"
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "installTorrServerLinux.sh"
|
||||
- ".github/workflows/test-install-script.yml"
|
||||
- ".github/scripts/test-install-script.sh"
|
||||
workflow_dispatch:
|
||||
|
||||
# OSes that require version 135 due to glibc < 2.32
|
||||
# Version 136+ requires glibc >= 2.32
|
||||
env:
|
||||
GLIBC_LIMITED_OSES: "debian-11|almalinux-8|rocky-8|amazonlinux-2"
|
||||
MAX_RETRIES: "3"
|
||||
RETRY_DELAY: "60"
|
||||
|
||||
jobs:
|
||||
test-script-syntax:
|
||||
name: Test Script Syntax
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Test script syntax
|
||||
run: |
|
||||
chmod +x installTorrServerLinux.sh
|
||||
bash -n installTorrServerLinux.sh
|
||||
echo "✓ Script syntax is valid"
|
||||
|
||||
- name: Test help command
|
||||
run: |
|
||||
./installTorrServerLinux.sh --help > /dev/null
|
||||
./installTorrServerLinux.sh help > /dev/null
|
||||
echo "✓ Help command works"
|
||||
|
||||
test-install-script:
|
||||
name: Test on ${{ matrix.os }} (${{ matrix.test-user }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-22.04
|
||||
- ubuntu-24.04
|
||||
- debian-11
|
||||
- debian-12
|
||||
- debian-13
|
||||
- fedora-latest
|
||||
- almalinux-8
|
||||
- almalinux-9
|
||||
- almalinux-10
|
||||
- rocky-8
|
||||
- rocky-9
|
||||
- rocky-10
|
||||
- amazonlinux-2
|
||||
test-user: [root, default]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Get Docker image for OS
|
||||
id: get-image
|
||||
run: |
|
||||
# Map OS to Docker image
|
||||
case "${{ matrix.os }}" in
|
||||
ubuntu-22.04) IMAGE="ubuntu:22.04" ;;
|
||||
ubuntu-24.04) IMAGE="ubuntu:24.04" ;;
|
||||
debian-11) IMAGE="debian:11" ;;
|
||||
debian-12) IMAGE="debian:12" ;;
|
||||
debian-13) IMAGE="debian:13" ;;
|
||||
fedora-latest) IMAGE="fedora:latest" ;;
|
||||
almalinux-8) IMAGE="almalinux:8" ;;
|
||||
almalinux-9) IMAGE="almalinux:9" ;;
|
||||
almalinux-10) IMAGE="almalinux:10" ;;
|
||||
rocky-8) IMAGE="rockylinux/rockylinux:8" ;;
|
||||
rocky-9) IMAGE="rockylinux/rockylinux:9" ;;
|
||||
rocky-10) IMAGE="rockylinux/rockylinux:10" ;;
|
||||
amazonlinux-2) IMAGE="amazonlinux:2" ;;
|
||||
*)
|
||||
echo "Warning: Unknown OS ${{ matrix.os }}, using ubuntu:22.04 as default"
|
||||
IMAGE="ubuntu:22.04"
|
||||
;;
|
||||
esac
|
||||
echo "image=$IMAGE" >> $GITHUB_OUTPUT
|
||||
echo "Using Docker image: $IMAGE for OS: ${{ matrix.os }}"
|
||||
|
||||
- name: Run tests in container
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace \
|
||||
--privileged \
|
||||
-e MATRIX_OS="${{ matrix.os }}" \
|
||||
-e TEST_USER="${{ matrix.test-user }}" \
|
||||
-e GLIBC_LIMITED_OSES="${{ env.GLIBC_LIMITED_OSES }}" \
|
||||
-e MAX_RETRIES="${{ env.MAX_RETRIES }}" \
|
||||
-e RETRY_DELAY="${{ env.RETRY_DELAY }}" \
|
||||
"${{ steps.get-image.outputs.image }}" \
|
||||
bash -c "
|
||||
set -e
|
||||
|
||||
# Make test script executable
|
||||
chmod +x .github/scripts/test-install-script.sh
|
||||
|
||||
# Run the test script
|
||||
.github/scripts/test-install-script.sh
|
||||
"
|
||||
@@ -0,0 +1,121 @@
|
||||
name: Build and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
NODE_OPTIONS: --openssl-legacy-provider
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- go_version: 'stable'
|
||||
group: 'standard'
|
||||
- go_version: '1.25'
|
||||
group: 'android'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go_version }}
|
||||
cache: true
|
||||
|
||||
- name: Set version from tag
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||
|
||||
- name: Install Swag
|
||||
run: go install github.com/swaggo/swag/cmd/swag@latest
|
||||
|
||||
- name: Build Web and Docs
|
||||
if: matrix.group == 'standard'
|
||||
run: |
|
||||
go run gen_web.go
|
||||
cd server && ~/go/bin/swag init -g web/server.go
|
||||
|
||||
- name: Build Standard Platforms
|
||||
if: matrix.group == 'standard'
|
||||
run: |
|
||||
LDFLAGS="-s -w -checklinkname=0 -X server/version.Version=${{ env.VERSION }}"
|
||||
mkdir -p "${{ github.workspace }}/dist"
|
||||
cd server
|
||||
|
||||
# Linux
|
||||
GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -tags=nosqlite -trimpath -o "${{ github.workspace }}/dist/TorrServer-linux-amd64" ./cmd
|
||||
GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -tags=nosqlite -trimpath -o "${{ github.workspace }}/dist/TorrServer-linux-arm64" ./cmd
|
||||
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$LDFLAGS" -tags=nosqlite -trimpath -o "${{ github.workspace }}/dist/TorrServer-linux-arm7" ./cmd
|
||||
GOOS=linux GOARCH=arm GOARM=5 go build -ldflags="$LDFLAGS" -tags=nosqlite -trimpath -o "${{ github.workspace }}/dist/TorrServer-linux-arm5" ./cmd
|
||||
GOOS=linux GOARCH=386 go build -ldflags="$LDFLAGS" -tags=nosqlite -trimpath -o "${{ github.workspace }}/dist/TorrServer-linux-386" ./cmd
|
||||
|
||||
# Windows
|
||||
GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -tags=nosqlite -trimpath -o "${{ github.workspace }}/dist/TorrServer-windows-amd64.exe" ./cmd
|
||||
GOOS=windows GOARCH=386 go build -ldflags="$LDFLAGS" -tags=nosqlite -trimpath -o "${{ github.workspace }}/dist/TorrServer-windows-386.exe" ./cmd
|
||||
|
||||
# Darwin
|
||||
GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -tags=nosqlite -trimpath -o "${{ github.workspace }}/dist/TorrServer-darwin-amd64" ./cmd
|
||||
GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -tags=nosqlite -trimpath -o "${{ github.workspace }}/dist/TorrServer-darwin-arm64" ./cmd
|
||||
|
||||
# FreeBSD
|
||||
GOOS=freebsd GOARCH=amd64 go build -ldflags="$LDFLAGS" -tags=nosqlite -trimpath -o "${{ github.workspace }}/dist/TorrServer-freebsd-amd64" ./cmd
|
||||
GOOS=freebsd GOARCH=arm GOARM=7 go build -ldflags="$LDFLAGS" -tags=nosqlite -trimpath -o "${{ github.workspace }}/dist/TorrServer-freebsd-arm7" ./cmd
|
||||
|
||||
# MIPS
|
||||
GOOS=linux GOARCH=mips GOMIPS=softfloat go build -ldflags="$LDFLAGS" -tags=nosqlite -trimpath -o "${{ github.workspace }}/dist/TorrServer-linux-mips" ./cmd
|
||||
GOOS=linux GOARCH=mipsle GOMIPS=softfloat go build -ldflags="$LDFLAGS" -tags=nosqlite -trimpath -o "${{ github.workspace }}/dist/TorrServer-linux-mipsle" ./cmd
|
||||
GOOS=linux GOARCH=mips64 GOMIPS64=softfloat go build -ldflags="$LDFLAGS" -tags=nosqlite -trimpath -o "${{ github.workspace }}/dist/TorrServer-linux-mips64" ./cmd
|
||||
GOOS=linux GOARCH=mips64le GOMIPS64=softfloat go build -ldflags="$LDFLAGS" -tags=nosqlite -trimpath -o "${{ github.workspace }}/dist/TorrServer-linux-mips64le" ./cmd
|
||||
GOOS=linux GOARCH=riscv64 go build -ldflags="$LDFLAGS" -tags=nosqlite -trimpath -o "${{ github.workspace }}/dist/TorrServer-linux-riscv64" ./cmd
|
||||
|
||||
- name: Build Android (Go 1.25)
|
||||
if: matrix.group == 'android'
|
||||
run: |
|
||||
go run gen_web.go
|
||||
|
||||
LDFLAGS="-s -w -checklinkname=0 -X server/version.Version=${{ env.VERSION }}"
|
||||
mkdir -p "${{ github.workspace }}/dist"
|
||||
export NDK_TOOLCHAIN="$ANDROID_NDK_LATEST_HOME/toolchains/llvm/prebuilt/linux-x86_64"
|
||||
cd server
|
||||
|
||||
CC=$NDK_TOOLCHAIN/bin/armv7a-linux-androideabi21-clang CGO_ENABLED=1 GOOS=android GOARCH=arm GOARM=7 go build -ldflags="$LDFLAGS" -tags=nosqlite -trimpath -o "${{ github.workspace }}/dist/TorrServer-android-arm7" ./cmd
|
||||
CC=$NDK_TOOLCHAIN/bin/aarch64-linux-android21-clang CGO_ENABLED=1 GOOS=android GOARCH=arm64 go build -ldflags="$LDFLAGS" -tags=nosqlite -trimpath -o "${{ github.workspace }}/dist/TorrServer-android-arm64" ./cmd
|
||||
CC=$NDK_TOOLCHAIN/bin/i686-linux-android21-clang CGO_ENABLED=1 GOOS=android GOARCH=386 go build -ldflags="$LDFLAGS" -tags=nosqlite -trimpath -o "${{ github.workspace }}/dist/TorrServer-android-386" ./cmd
|
||||
CC=$NDK_TOOLCHAIN/bin/x86_64-linux-android21-clang CGO_ENABLED=1 GOOS=android GOARCH=amd64 go build -ldflags="$LDFLAGS" -tags=nosqlite -trimpath -o "${{ github.workspace }}/dist/TorrServer-android-amd64" ./cmd
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: binaries-${{ matrix.group }}
|
||||
path: ${{ github.workspace }}/dist/*
|
||||
|
||||
release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ./all-dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Debug - List files
|
||||
run: |
|
||||
echo "Listing files to be uploaded:"
|
||||
ls -R ./all-dist
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
files: ./all-dist/*
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
# Secrets
|
||||
.env
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# Docker volumes / data
|
||||
data/
|
||||
*-data/
|
||||
vw-data/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Backups
|
||||
*.tar.gz
|
||||
*.bak
|
||||
@@ -0,0 +1,24 @@
|
||||
stages:
|
||||
- build_go
|
||||
|
||||
build_go:
|
||||
image: golang:latest
|
||||
stage: build_go
|
||||
when: manual
|
||||
tags:
|
||||
- amd64
|
||||
artifacts:
|
||||
name: "TorrServer"
|
||||
paths:
|
||||
- dist
|
||||
script:
|
||||
- apt update
|
||||
- apt install -y npm zip
|
||||
- rm -rf /var/lib/apt/lists/*
|
||||
- npm install -g yarn
|
||||
- wget -q "https://dl.google.com/android/repository/android-ndk-r25c-linux.zip"
|
||||
- unzip ./android-ndk-r25c-linux.zip
|
||||
- rm ./android-ndk-r25c-linux.zip
|
||||
- pwd
|
||||
- ls -l
|
||||
- ./build-all.sh
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
### FRONT BUILD START ###
|
||||
FROM --platform=$BUILDPLATFORM node:16-alpine AS front
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG REACT_APP_SERVER_HOST='.'
|
||||
ARG REACT_APP_TMDB_API_KEY=''
|
||||
ARG PUBLIC_URL=''
|
||||
|
||||
ENV REACT_APP_SERVER_HOST=$REACT_APP_SERVER_HOST
|
||||
ENV REACT_APP_TMDB_API_KEY=$REACT_APP_TMDB_API_KEY
|
||||
ENV PUBLIC_URL=$PUBLIC_URL
|
||||
|
||||
COPY ./web/package.json ./web/yarn.lock ./
|
||||
RUN yarn install
|
||||
|
||||
# Build front once upon multiarch build
|
||||
COPY ./web .
|
||||
RUN yarn run build
|
||||
### FRONT BUILD END ###
|
||||
|
||||
|
||||
### BUILD TORRSERVER MULTIARCH START ###
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.2-alpine AS builder
|
||||
|
||||
COPY . /opt/src
|
||||
COPY --from=front /app/build /opt/src/web/build
|
||||
|
||||
WORKDIR /opt/src
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
# Step for multiarch build with docker buildx
|
||||
ENV GOARCH=$TARGETARCH
|
||||
|
||||
# Build torrserver
|
||||
RUN apk add --update g++ \
|
||||
&& go run gen_web.go \
|
||||
&& cd server \
|
||||
&& go mod tidy \
|
||||
&& go clean -i -r -cache \
|
||||
&& go build -ldflags '-w -s' --o "torrserver" ./cmd
|
||||
### BUILD TORRSERVER MULTIARCH END ###
|
||||
|
||||
|
||||
### UPX COMPRESSING START ###
|
||||
FROM ubuntu AS compressed
|
||||
|
||||
COPY --from=builder /opt/src/server/torrserver ./torrserver
|
||||
|
||||
RUN apt update && apt install -y upx-ucl && upx --best --lzma ./torrserver
|
||||
# Compress torrserver only for amd64 and arm64 no variant platforms
|
||||
# ARG TARGETARCH
|
||||
# ARG TARGETVARIANT
|
||||
# RUN if [ "$TARGETARCH" == 'amd64' ]; then compress=1; elif [ "$TARGETARCH" == 'arm64' ] && [ -z "$TARGETVARIANT" ]; then compress=1; else compress=0; fi \
|
||||
# && if [[ "$compress" -eq 1 ]]; then ./upx --best --lzma ./torrserver; fi
|
||||
### UPX COMPRESSING END ###
|
||||
|
||||
|
||||
### BUILD MAIN IMAGE START ###
|
||||
FROM alpine
|
||||
|
||||
ENV TS_CONF_PATH="/opt/ts/config"
|
||||
ENV TS_LOG_PATH="/opt/ts/log"
|
||||
ENV TS_TORR_DIR="/opt/ts/torrents"
|
||||
ENV TS_PORT=8090
|
||||
ENV GODEBUG=madvdontneed=1
|
||||
|
||||
COPY --from=compressed ./torrserver /usr/bin/torrserver
|
||||
COPY ./docker-entrypoint.sh /docker-entrypoint.sh
|
||||
|
||||
RUN apk add --no-cache --update ffmpeg
|
||||
|
||||
CMD /docker-entrypoint.sh
|
||||
### BUILD MAIN IMAGE end ###
|
||||
@@ -0,0 +1,75 @@
|
||||
### FRONT BUILD START ###
|
||||
FROM --platform=$BUILDPLATFORM node:16-alpine AS front
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG REACT_APP_SERVER_HOST='.'
|
||||
ARG REACT_APP_TMDB_API_KEY=''
|
||||
ARG PUBLIC_URL=''
|
||||
|
||||
ENV REACT_APP_SERVER_HOST=$REACT_APP_SERVER_HOST
|
||||
ENV REACT_APP_TMDB_API_KEY=$REACT_APP_TMDB_API_KEY
|
||||
ENV PUBLIC_URL=$PUBLIC_URL
|
||||
|
||||
COPY ./web/package.json ./web/yarn.lock ./
|
||||
RUN yarn install
|
||||
|
||||
# Build front once upon multiarch build
|
||||
COPY ./web .
|
||||
RUN yarn run build
|
||||
### FRONT BUILD END ###
|
||||
|
||||
|
||||
### BUILD TORRSERVER MULTIARCH START ###
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.2-alpine AS builder
|
||||
|
||||
COPY . /opt/src
|
||||
COPY --from=front /app/build /opt/src/web/build
|
||||
|
||||
WORKDIR /opt/src
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
# Step for multiarch build with docker buildx
|
||||
ENV GOARCH=$TARGETARCH
|
||||
|
||||
# Build torrserver
|
||||
RUN apk add --update g++ \
|
||||
&& go run gen_web.go \
|
||||
&& cd server \
|
||||
&& go mod tidy \
|
||||
&& go clean -i -r -cache \
|
||||
&& go build -ldflags '-w -s' --o "torrserver" ./cmd
|
||||
### BUILD TORRSERVER MULTIARCH END ###
|
||||
|
||||
|
||||
### UPX COMPRESSING START ###
|
||||
FROM ubuntu AS compressed
|
||||
|
||||
COPY --from=builder /opt/src/server/torrserver ./torrserver
|
||||
|
||||
RUN apt update && apt install -y upx-ucl && upx --best --lzma ./torrserver
|
||||
# Compress torrserver only for amd64 and arm64 no variant platforms
|
||||
# ARG TARGETARCH
|
||||
# ARG TARGETVARIANT
|
||||
# RUN if [ "$TARGETARCH" == 'amd64' ]; then compress=1; elif [ "$TARGETARCH" == 'arm64' ] && [ -z "$TARGETVARIANT" ]; then compress=1; else compress=0; fi \
|
||||
# && if [[ "$compress" -eq 1 ]]; then ./upx --best --lzma ./torrserver; fi
|
||||
### UPX COMPRESSING END ###
|
||||
|
||||
|
||||
### BUILD MAIN IMAGE START ###
|
||||
FROM alpine
|
||||
|
||||
ENV TS_CONF_PATH="/opt/ts/config"
|
||||
ENV TS_LOG_PATH="/opt/ts/log"
|
||||
ENV TS_TORR_DIR="/opt/ts/torrents"
|
||||
ENV TS_PORT=8090
|
||||
ENV GODEBUG=madvdontneed=1
|
||||
|
||||
COPY --from=compressed ./torrserver /usr/bin/torrserver
|
||||
COPY ./docker-entrypoint.sh /docker-entrypoint.sh
|
||||
|
||||
RUN apk add --no-cache --update ffmpeg
|
||||
|
||||
CMD /docker-entrypoint.sh
|
||||
### BUILD MAIN IMAGE end ###
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,416 @@
|
||||
<p align="center" style="text-align: center">
|
||||
<img src="https://github.com/YouROK/TorrServer/assets/144587546/53f7175a-cda4-4a06-86b6-2ac07582dcf1" width="33%"><br/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Simple and powerful tool for streaming torrents.
|
||||
<br/>
|
||||
<br/>
|
||||
<a href="https://github.com/YouROK/TorrServer/blob/master/LICENSE">
|
||||
<img alt="GitHub" src="https://img.shields.io/github/license/YouROK/TorrServer"/>
|
||||
</a>
|
||||
<a href="https://goreportcard.com/report/github.com/YouROK/TorrServer">
|
||||
<img src="https://goreportcard.com/badge/github.com/YouROK/TorrServer" />
|
||||
</a>
|
||||
<a href="https://pkg.go.dev/github.com/YouROK/TorrServer">
|
||||
<img src="https://pkg.go.dev/badge/github.com/YouROK/TorrServer.svg" alt="Go Reference"/>
|
||||
</a>
|
||||
<a href="https://github.com/YouROK/TorrServer/issues">
|
||||
<img src="https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat" alt="CodeFactor" />
|
||||
</a>
|
||||
<a href="https://github.com/YouROK/TorrServer/actions/workflows/docker_image.yml" rel="nofollow">
|
||||
<img src="https://img.shields.io/github/actions/workflow/status/YouROK/TorrServer/docker_image.yml?logo=Github" alt="Build" />
|
||||
</a>
|
||||
<a href="https://github.com/YouROK/TorrServer/releases" rel="nofollow">
|
||||
<img alt="GitHub release (latest SemVer)" src="https://img.shields.io/github/v/release/YouROK/TorrServer?label=version"/>
|
||||
</a>
|
||||
<a href="https://github.com/YouROK/TorrServer/tags" rel="nofollow">
|
||||
<img alt="GitHub tag (latest SemVer pre-release)" src="https://img.shields.io/github/v/tag/YouROK/TorrServer?include_prereleases&label=pre-release"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Introduction
|
||||
|
||||
TorrServer is a program that allows users to view torrents online without the need for preliminary file downloading.
|
||||
The core functionality of TorrServer includes caching torrents and subsequent data transfer via the HTTP protocol,
|
||||
allowing the cache size to be adjusted according to the system parameters and the user's internet connection speed.
|
||||
|
||||
## AI Documentation
|
||||
|
||||
[](https://deepwiki.com/YouROK/TorrServer)
|
||||
|
||||
## Features
|
||||
|
||||
- Caching
|
||||
- Streaming
|
||||
- Local and Remote Server
|
||||
- Viewing torrents on various devices
|
||||
- Integration with other apps through API
|
||||
- Torznab search (Jackett, Prowlarr, and similar indexer managers)
|
||||
- Cross-browser modern web interface
|
||||
- Optional DLNA server
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Installation
|
||||
|
||||
Download the application for the required platform in the [releases](https://github.com/YouROK/TorrServer/releases) page. After installation, open the link <http://127.0.0.1:8090> in the browser.
|
||||
|
||||
#### Windows
|
||||
|
||||
Run `TorrServer-windows-amd64.exe`.
|
||||
|
||||
#### Linux
|
||||
|
||||
Run in console
|
||||
|
||||
```bash
|
||||
curl -s https://raw.githubusercontent.com/YouROK/TorrServer/master/installTorrServerLinux.sh | sudo bash
|
||||
```
|
||||
|
||||
The script supports interactive and non-interactive installation, configuration, updates, and removal. When running the script interactively, you can:
|
||||
|
||||
- **Install/Update**: Choose to install or update TorrServer
|
||||
- **Reconfigure**: If TorrServer is already installed, you'll be prompted to reconfigure settings (port, auth, read-only mode, logging, BBR)
|
||||
- **Uninstall**: Type `Delete` (or `Удалить` in Russian) to uninstall TorrServer
|
||||
|
||||
**Download first and set execute permissions:**
|
||||
|
||||
```bash
|
||||
curl -s https://raw.githubusercontent.com/YouROK/TorrServer/master/installTorrServerLinux.sh -o installTorrServerLinux.sh && chmod 755 installTorrServerLinux.sh
|
||||
```
|
||||
|
||||
**Command-line examples:**
|
||||
|
||||
- Install a specific version:
|
||||
|
||||
```bash
|
||||
sudo bash ./installTorrServerLinux.sh --install 135 --silent
|
||||
```
|
||||
|
||||
- Update to latest version:
|
||||
|
||||
```bash
|
||||
sudo bash ./installTorrServerLinux.sh --update --silent
|
||||
```
|
||||
|
||||
- Reconfigure settings interactively:
|
||||
|
||||
```bash
|
||||
sudo bash ./installTorrServerLinux.sh --reconfigure
|
||||
```
|
||||
|
||||
- Check for updates:
|
||||
|
||||
```bash
|
||||
sudo bash ./installTorrServerLinux.sh --check
|
||||
```
|
||||
|
||||
- Downgrade to a specific version:
|
||||
|
||||
```bash
|
||||
sudo bash ./installTorrServerLinux.sh --down 135
|
||||
```
|
||||
|
||||
- Remove/uninstall:
|
||||
|
||||
```bash
|
||||
sudo bash ./installTorrServerLinux.sh --remove --silent
|
||||
```
|
||||
|
||||
- Change the systemd service user:
|
||||
|
||||
```bash
|
||||
sudo bash ./installTorrServerLinux.sh --change-user root --silent
|
||||
```
|
||||
|
||||
**All available commands:**
|
||||
|
||||
- `--install [VERSION]` - Install latest or specific version
|
||||
- `--update` - Update to latest version
|
||||
- `--reconfigure` - Reconfigure TorrServer settings (port, auth, read-only mode, logging, BBR)
|
||||
- `--check` - Check for updates (version info only)
|
||||
- `--down VERSION` - Downgrade to specific version
|
||||
- `--remove` - Uninstall TorrServer
|
||||
- `--change-user USER` - Change service user (root|torrserver)
|
||||
- `--root` - Run service as root user
|
||||
- `--silent` - Non-interactive mode with defaults
|
||||
- `--help` - Show help message
|
||||
|
||||
#### macOS
|
||||
|
||||
Run in Terminal.app
|
||||
|
||||
```bash
|
||||
curl -s https://raw.githubusercontent.com/YouROK/TorrServer/master/installTorrServerMac.sh -o installTorrserverMac.sh && chmod 755 installTorrServerMac.sh && bash ./installTorrServerMac.sh
|
||||
```
|
||||
|
||||
Alternative install script for Intel Macs: <https://github.com/dancheskus/TorrServerMacInstaller>
|
||||
|
||||
#### IOCage Plugin (Unofficial)
|
||||
|
||||
On FreeBSD (TrueNAS/FreeNAS) you can use this plugin: <https://github.com/filka96/iocage-plugin-TorrServer>
|
||||
|
||||
#### NAS Systems (Unofficial)
|
||||
|
||||
- Several releases are available through this link: <https://github.com/vladlenas>
|
||||
- **Synology NAS** packages repo source: <https://grigi.lt>
|
||||
|
||||
### Server args
|
||||
|
||||
- `--port PORT`, `-p PORT` - web server port (default 8090)
|
||||
- `--ip IP`, `-i IP` - web server addr (default empty, binds to all interfaces)
|
||||
- `--ssl` - enables https for web server
|
||||
- `--sslport PORT` - web server https port (default 8091). If not set, will be taken from db (if stored previously) or the default will be used.
|
||||
- `--sslcert PATH` - path to ssl cert file. If not set, will be taken from db (if stored previously) or default self-signed certificate/key will be generated.
|
||||
- `--sslkey PATH` - path to ssl key file. If not set, will be taken from db (if stored previously) or default self-signed certificate/key will be generated.
|
||||
- `--force-https` - with `--ssl`, the HTTP listener (`--port`) answers only with **307 Temporary Redirect** to the same path on HTTPS (`--sslport`). The web UI and API are served on HTTPS only; nothing is served on HTTP except redirects. Requires `--ssl` (startup fails if `--force-https` is set without `--ssl`). Default is off so plain HTTP still works when SSL is disabled.
|
||||
- `--path PATH`, `-d PATH` - database and config dir path
|
||||
- `--logpath LOGPATH`, `-l LOGPATH` - server log file path
|
||||
- `--weblogpath WEBLOGPATH`, `-w WEBLOGPATH` - web access log file path
|
||||
- `--rdb`, `-r` - start in read-only DB mode
|
||||
- `--httpauth`, `-a` - enable http auth on all requests
|
||||
- `--dontkill`, `-k` - don't kill server on signal
|
||||
- `--ui`, `-u` - open torrserver page in browser
|
||||
- `--torrentsdir TORRENTSDIR`, `-t TORRENTSDIR` - autoload torrents from dir
|
||||
- `--torrentaddr TORRENTADDR` - Torrent client address (format [IP]:PORT, ex. :32000, 127.0.0.1:32768 etc)
|
||||
- `--pubipv4 PUBIPV4`, `-4 PUBIPV4` - set public IPv4 addr
|
||||
- `--pubipv6 PUBIPV6`, `-6 PUBIPV6` - set public IPv6 addr
|
||||
- `--searchwa`, `-s` - allow search without authentication
|
||||
- `--maxsize MAXSIZE`, `-m MAXSIZE` - max allowed stream size (in Bytes)
|
||||
- `--tg TGTOKEN`, `-T TGTOKEN` - [Telegram bot](server/tgbot/README.md) token
|
||||
- `--fuse FUSEPATH`, `-f FUSEPATH` - fuse mount path
|
||||
- `--webdav` - enable web dav
|
||||
- `--proxyurl PROXYURL` - set proxy URL for BitTorrent traffic (http, socks4, socks5, socks5h), example: socks5h://user:password@example.com:2080
|
||||
- `--proxymode PROXYMODE` - set proxy mode: "tracker" (only HTTP trackers, default), "peers" (only peer connections), or "full" (all traffic)
|
||||
- `--help`, `-h` - display this help and exit
|
||||
- `--version` - display version and exit
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
TorrServer-darwin-arm64 [--port PORT] [--ip IP] [--path PATH] [--logpath LOGPATH] [--weblogpath WEBLOGPATH] [--rdb] [--httpauth] [--dontkill] [--ui] [--torrentsdir TORRENTSDIR] [--torrentaddr TORRENTADDR] [--pubipv4 PUBIPV4] [--pubipv6 PUBIPV6] [--searchwa] [--maxsize MAXSIZE] [--tg TGTOKEN] [--fuse FUSEPATH] [--webdav] [--ssl] [--sslport PORT] [--sslcert PATH] [--sslkey PATH] [--force-https]
|
||||
```
|
||||
|
||||
### Running in Docker & Docker Compose
|
||||
|
||||
Run in console
|
||||
|
||||
```bash
|
||||
docker run --rm -d --name torrserver -p 8090:8090 ghcr.io/yourok/torrserver:latest
|
||||
```
|
||||
|
||||
For running in persistence mode, just mount volume to container by adding `-v ~/ts:/opt/ts`, where `~/ts` folder path is just example, but you could use it anyway... Result example command:
|
||||
|
||||
```bash
|
||||
docker run --rm -d --name torrserver -v ~/ts:/opt/ts -p 8090:8090 ghcr.io/yourok/torrserver:latest
|
||||
```
|
||||
|
||||
#### Environments
|
||||
|
||||
- `TS_HTTPAUTH` - 1, and place auth file into `~/ts/config` folder for enabling basic auth
|
||||
- `TS_RDB` - if 1, then the enabling `--rdb` flag
|
||||
- `TS_DONTKILL` - if 1, then the enabling `--dontkill` flag
|
||||
- `TS_PORT` - for changind default port to **5555** (example), also u need to change `-p 8090:8090` to `-p 5555:5555` (example)
|
||||
- `TS_CONF_PATH` - for overriding torrserver config path inside container. Example `/opt/tsss`
|
||||
- `TS_TORR_DIR` - for overriding torrents directory. Example `/opt/torr_files`
|
||||
- `TS_LOG_PATH` - for overriding log path. Example `/opt/torrserver.log`
|
||||
- `TS_PROXYURL` - set proxy URL for BitTorrent traffic (http, socks4, socks5, socks5h), example: socks5h://user:password@example.com:2080
|
||||
- `TS_PROXYMODE` - set proxy mode: "tracker" (only HTTP trackers, default), "peers" (only peer connections), or "full" (all traffic)
|
||||
|
||||
Example with full overrided command (on default values):
|
||||
|
||||
```bash
|
||||
docker run --rm -d -e TS_PORT=5665 -e TS_DONTKILL=1 -e TS_HTTPAUTH=1 -e TS_RDB=1 -e TS_CONF_PATH=/opt/ts/config -e TS_LOG_PATH=/opt/ts/log -e TS_TORR_DIR=/opt/ts/torrents -e TS_PROXYURL=socks5h://user:password@example.com:2080 -e TS_PROXYMODE=tracker --name torrserver -v ~/ts:/opt/ts -p 5665:5665 ghcr.io/yourok/torrserver:latest
|
||||
```
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
```yml
|
||||
# docker-compose.yml
|
||||
|
||||
version: '3.3'
|
||||
services:
|
||||
torrserver:
|
||||
image: ghcr.io/yourok/torrserver
|
||||
container_name: torrserver
|
||||
network_mode: host # to allow DLNA feature
|
||||
environment:
|
||||
- TS_PORT=5665
|
||||
- TS_DONTKILL=1
|
||||
- TS_HTTPAUTH=0
|
||||
- TS_CONF_PATH=/opt/ts/config
|
||||
- TS_TORR_DIR=/opt/ts/torrents
|
||||
volumes:
|
||||
- './CACHE:/opt/ts/torrents'
|
||||
- './CONFIG:/opt/ts/config'
|
||||
ports:
|
||||
- '5665:5665'
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
```
|
||||
|
||||
### Smart TV (using Media Station X)
|
||||
|
||||
1. Install **Media Station X** on your Smart TV (see [platform support](https://msx.benzac.de/info/?tab=PlatformSupport))
|
||||
|
||||
2. Open it and go to: **Settings -> Start Parameter -> Setup**
|
||||
|
||||
3. Enter current ip and port of the TorrServe(r), e.g. `127.0.0.1:8090`
|
||||
|
||||
## Development
|
||||
|
||||
### Go server
|
||||
|
||||
To run the Go server locally, just run
|
||||
|
||||
```bash
|
||||
cd server
|
||||
go run ./cmd
|
||||
```
|
||||
|
||||
### Web development
|
||||
|
||||
To run the web server locally, just run
|
||||
|
||||
```bash
|
||||
yarn start
|
||||
```
|
||||
|
||||
More info at <https://github.com/YouROK/TorrServer/tree/master/web#readme>
|
||||
|
||||
### Build
|
||||
|
||||
#### Server
|
||||
|
||||
- Install [Golang](https://golang.org/doc/install) 1.20+
|
||||
- Go to the TorrServer source directory
|
||||
- Run build script under linux or macOS `build-all.sh`
|
||||
|
||||
#### Web
|
||||
|
||||
- Install **npm** and **yarn**
|
||||
- Go to the web directory
|
||||
- Run `NODE_OPTIONS=--openssl-legacy-provider yarn build`
|
||||
|
||||
#### Android
|
||||
|
||||
To build an Android server you will need the Android Toolchain.
|
||||
|
||||
#### Swagger
|
||||
|
||||
`swag` must be installed on the system to [re]build Swagger documentation.
|
||||
|
||||
```bash
|
||||
go install github.com/swaggo/swag/cmd/swag@latest
|
||||
cd server; swag init -g web/server.go
|
||||
|
||||
# Documentation can be linted using
|
||||
swag fmt
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### API Docs
|
||||
|
||||
API documentation is hosted as Swagger format available at path `/swagger/index.html`.
|
||||
|
||||
## Authentication
|
||||
|
||||
The users data file should be located near to the settings. Basic auth, read more in wiki <https://en.wikipedia.org/wiki/Basic_access_authentication>.
|
||||
|
||||
`accs.db` in JSON format:
|
||||
|
||||
```json
|
||||
{
|
||||
"User1": "Pass1",
|
||||
"User2": "Pass2"
|
||||
}
|
||||
```
|
||||
|
||||
Note: You should enable authentication with -a (--httpauth) TorrServer startup option.
|
||||
|
||||
## Whitelist/Blacklist IP
|
||||
|
||||
The lists file should be located in the same directory with config.db.
|
||||
|
||||
- Whitelist file name: `wip.txt`
|
||||
- Blacklist file name: `bip.txt`
|
||||
|
||||
Whitelist has priority over everything else.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
local:127.0.0.0-127.0.0.255
|
||||
127.0.0.0-127.0.0.255
|
||||
local:127.0.0.1
|
||||
127.0.0.1
|
||||
# at the beginning of the line, comment
|
||||
```
|
||||
|
||||
## Torznab
|
||||
|
||||
TorrServer can talk to **Torznab** indexers so you can search for torrents from tools like **Jackett** and **Prowlarr**, including searching several configured indexers at once.
|
||||
|
||||
Configure it in the web UI: **Settings → Torznab**.
|
||||
|
||||
### Indexer parameters
|
||||
|
||||
Each Torznab indexer needs:
|
||||
|
||||
- **Host URL**: full URL to the Torznab API endpoint.
|
||||
- Jackett example:
|
||||
|
||||
```shell
|
||||
http://192.168.1.10:9117/api/v2.0/indexers/all/results/torznab/
|
||||
```
|
||||
|
||||
- Prowlarr example:
|
||||
|
||||
```shell
|
||||
http://localhost:9696/1
|
||||
```
|
||||
|
||||
- Make sure to include the correct trailing slash (`/`) in your indexer's URL,
|
||||
as required by your Torznab provider. TorrServer will try to properly format the path,
|
||||
but matching your indexer's expected format is best to avoid connection issues.
|
||||
|
||||
- **API Key**: the key from your Torznab indexer manager.
|
||||
|
||||
### Enabling Torznab search
|
||||
|
||||
1. Open **Settings**.
|
||||
2. Open the **Torznab** tab.
|
||||
3. Turn on **Enable Torznab Search**.
|
||||
4. Enter **Host URL** and **API Key**, then **Add Server** for each indexer.
|
||||
5. **Save** settings.
|
||||
|
||||
## Donate
|
||||
|
||||
- [YooMoney](https://yoomoney.ru/to/410013733697114/200)
|
||||
- [Boosty](https://boosty.to/yourok)
|
||||
- [TBank](https://www.tbank.ru/cf/742qEMhKhKn)
|
||||
|
||||
## Thanks to everyone who tested and helped
|
||||
|
||||
- [anacrolix](https://github.com/anacrolix) Matt Joiner
|
||||
- [tsynik](https://github.com/tsynik) Nikk Gitanes
|
||||
- [dancheskus](https://github.com/dancheskus) for react web GUI and PWA code
|
||||
- [kolsys](https://github.com/kolsys) for initial Media Station X support
|
||||
- [damiva](https://github.com/damiva) for Media Station X code updates
|
||||
- [vladlenas](https://github.com/vladlenas) for NAS builds
|
||||
- [pavelpikta](https://github.com/pavelpikta) Pavel Pikta for linux install script and more
|
||||
- [Nemiroff](https://github.com/Nemiroff) Tw1cker
|
||||
- [spawnlmg](https://github.com/spawnlmg) SpAwN_LMG for testing
|
||||
- [TopperBG](https://github.com/TopperBG) Dimitar Maznekov for Bulgarian web translation
|
||||
- [FaintGhost](https://github.com/FaintGhost) Zhang Yaowei for Simplified Chinese web translation
|
||||
- [Anton111111](https://github.com/Anton111111) Anton Potekhin for sleep on Windows fixes
|
||||
- [lieranderl](https://github.com/lieranderl) Evgeni for adding SSL support code
|
||||
- [cocool97](https://github.com/cocool97) for openapi API documentation and torrent categories
|
||||
- [shadeov](https://github.com/shadeov) for README improvements
|
||||
- [butaford](https://github.com/butaford) Pavel for make docker file and scripts
|
||||
- [filimonic](https://github.com/filimonic) Alexey D. Filimonov
|
||||
- [leporel](https://github.com/leporel) Viacheslav Evseev
|
||||
- and others
|
||||
Executable
+131
@@ -0,0 +1,131 @@
|
||||
#!/bin/bash
|
||||
|
||||
PLATFORMS=(
|
||||
'linux/amd64'
|
||||
'linux/arm64'
|
||||
'linux/arm7'
|
||||
'linux/arm5'
|
||||
'linux/386'
|
||||
'windows/amd64'
|
||||
'windows/386'
|
||||
'darwin/amd64'
|
||||
'darwin/arm64'
|
||||
'freebsd/amd64'
|
||||
'freebsd/arm7'
|
||||
'linux/mips'
|
||||
'linux/mipsle'
|
||||
'linux/mips64'
|
||||
'linux/mips64le'
|
||||
'linux/riscv64'
|
||||
)
|
||||
|
||||
type setopt >/dev/null 2>&1
|
||||
|
||||
set_goarm() {
|
||||
if [[ "$1" =~ arm([5,7]) ]]; then
|
||||
GOARCH="arm"
|
||||
GOARM="${BASH_REMATCH[1]}"
|
||||
GO_ARM="GOARM=${GOARM}"
|
||||
else
|
||||
GOARM=""
|
||||
GO_ARM=""
|
||||
fi
|
||||
}
|
||||
# use softfloat for mips builds
|
||||
set_gomips() {
|
||||
if [[ "$1" =~ mips ]]; then
|
||||
if [[ "$1" =~ mips(64) ]]; then MIPS64="${BASH_REMATCH[1]}"; fi
|
||||
GO_MIPS="GOMIPS${MIPS64}=softfloat"
|
||||
else
|
||||
GO_MIPS=""
|
||||
fi
|
||||
}
|
||||
|
||||
GOBIN="go"
|
||||
|
||||
$GOBIN version
|
||||
|
||||
LDFLAGS="'-s -w -checklinkname=0'"
|
||||
FAILURES=""
|
||||
ROOT=${PWD}
|
||||
OUTPUT="${ROOT}/dist/TorrServer"
|
||||
|
||||
#### Build web
|
||||
echo "Build web"
|
||||
export NODE_OPTIONS=--openssl-legacy-provider
|
||||
$GOBIN run gen_web.go
|
||||
|
||||
#### Update api docs
|
||||
echo "Build docs"
|
||||
$GOBIN install github.com/swaggo/swag/cmd/swag@latest
|
||||
cd "${ROOT}/server" || exit 1
|
||||
swag init -g web/server.go
|
||||
|
||||
#### Build server
|
||||
echo "Build server"
|
||||
cd "${ROOT}/server" || exit 1
|
||||
$GOBIN clean -i -r -cache # --modcache
|
||||
$GOBIN mod tidy
|
||||
|
||||
BUILD_FLAGS="-ldflags=${LDFLAGS} -tags=nosqlite -trimpath"
|
||||
|
||||
#####################################
|
||||
### X86 build section
|
||||
#####
|
||||
|
||||
for PLATFORM in "${PLATFORMS[@]}"; do
|
||||
GOOS=${PLATFORM%/*}
|
||||
GOARCH=${PLATFORM#*/}
|
||||
set_goarm "$GOARCH"
|
||||
set_gomips "$GOARCH"
|
||||
BIN_FILENAME="${OUTPUT}-${GOOS}-${GOARCH}${GOARM}"
|
||||
if [[ "${GOOS}" == "windows" ]]; then BIN_FILENAME="${BIN_FILENAME}.exe"; fi
|
||||
CMD="GOOS=${GOOS} GOARCH=${GOARCH} ${GO_ARM} ${GO_MIPS} ${GOBIN} build ${BUILD_FLAGS} -o ${BIN_FILENAME} ./cmd"
|
||||
echo "${CMD}"
|
||||
eval "$CMD" || FAILURES="${FAILURES} ${GOOS}/${GOARCH}${GOARM}"
|
||||
# CMD="../upx -q ${BIN_FILENAME}"; # upx --brute produce much smaller binaries
|
||||
# echo "compress with ${CMD}"
|
||||
# eval "$CMD"
|
||||
done
|
||||
|
||||
#####################################
|
||||
### Android build section
|
||||
#####
|
||||
|
||||
declare -a COMPILERS=(
|
||||
"arm7:armv7a-linux-androideabi21-clang"
|
||||
"arm64:aarch64-linux-android21-clang"
|
||||
"386:i686-linux-android21-clang"
|
||||
"amd64:x86_64-linux-android21-clang"
|
||||
)
|
||||
|
||||
export NDK_VERSION="25.2.9519653" # 25.1.8937393
|
||||
#export NDK_TOOLCHAIN=${ANDROID_HOME}/ndk/${NDK_VERSION}/toolchains/llvm/prebuilt/darwin-x86_64
|
||||
#export NDK_TOOLCHAIN="${PWD}/../android-ndk-r25c/toolchains/llvm/prebuilt/linux-x86_64"
|
||||
export NDK_TOOLCHAIN=/Users/yourok/Projects/AndroidNDK
|
||||
GOOS=android
|
||||
|
||||
for V in "${COMPILERS[@]}"; do
|
||||
GOARCH=${V%:*}
|
||||
COMPILER=${V#*:}
|
||||
export CC="$NDK_TOOLCHAIN/bin/$COMPILER"
|
||||
export CXX="$NDK_TOOLCHAIN/bin/$COMPILER++"
|
||||
set_goarm "$GOARCH"
|
||||
BIN_FILENAME="${OUTPUT}-${GOOS}-${GOARCH}${GOARM}"
|
||||
CMD="GOOS=${GOOS} GOARCH=${GOARCH} ${GO_ARM} CGO_ENABLED=1 ${GOBIN} build ${BUILD_FLAGS} -o ${BIN_FILENAME} ./cmd"
|
||||
echo "${CMD}"
|
||||
eval "${CMD}" || FAILURES="${FAILURES} ${GOOS}/${GOARCH}${GOARM}"
|
||||
# CMD="../upx -q ${BIN_FILENAME}"; # upx --brute produce much smaller binaries
|
||||
# echo "compress with ${CMD}"
|
||||
# eval "$CMD"
|
||||
done
|
||||
|
||||
# eval errors
|
||||
if [[ "${FAILURES}" != "" ]]; then
|
||||
echo ""
|
||||
echo "failed on: ${FAILURES}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "${ROOT}/docker/lite" || exit 1
|
||||
./makedocker.sh
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
|
||||
ROOT=${PWD}
|
||||
|
||||
#### Build web
|
||||
echo "Build web"
|
||||
go run gen_web.go
|
||||
|
||||
sudo docker run --rm -v "$PWD":/usr/src/torr -v ~/go/pkg/mod:/go/pkg/mod -w /usr/src/torr golang:1.17.5-stretch ./build-all.sh
|
||||
sudo chmod 0777 ./dist/*
|
||||
@@ -0,0 +1,45 @@
|
||||
services:
|
||||
torrserver:
|
||||
build: .
|
||||
restart: always
|
||||
ports:
|
||||
- "8090:8090"
|
||||
environment:
|
||||
TS_CONF_PATH: "/opt/ts/config"
|
||||
TS_LOG_PATH: "/opt/ts/log"
|
||||
TS_TORR_DIR: "/opt/ts/torrents"
|
||||
TS_PORT: "8090"
|
||||
volumes:
|
||||
- torrserver_config:/opt/ts/config
|
||||
- torrserver_log:/opt/ts/log
|
||||
- torrserver_torrents:/opt/ts/torrents
|
||||
# Путь к медиа на Synology (настрой под себя)
|
||||
- /mnt/synology_drive/media:/media:ro
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.torrserver.rule=Host(`${TORR_SUBDOMAIN}.${DOMAIN_NAME}`)
|
||||
- traefik.http.routers.torrserver.tls=true
|
||||
- traefik.http.routers.torrserver.entrypoints=web,websecure
|
||||
- traefik.http.routers.torrserver.tls.certresolver=myresolver
|
||||
# Security headers (аналогично n8n)
|
||||
- traefik.http.middlewares.torrserver-headers.headers.SSLRedirect=true
|
||||
- traefik.http.middlewares.torrserver-headers.headers.STSSeconds=315360000
|
||||
- traefik.http.middlewares.torrserver-headers.headers.browserXSSFilter=true
|
||||
- traefik.http.middlewares.torrserver-headers.headers.contentTypeNosniff=true
|
||||
- traefik.http.middlewares.torrserver-headers.headers.forceSTSHeader=true
|
||||
- traefik.http.middlewares.torrserver-headers.headers.SSLHost=${DOMAIN_NAME}
|
||||
- traefik.http.middlewares.torrserver-headers.headers.STSIncludeSubdomains=true
|
||||
- traefik.http.middlewares.torrserver-headers.headers.STSPreload=true
|
||||
- traefik.http.routers.torrserver.middlewares=torrserver-headers@docker
|
||||
networks:
|
||||
- proxy
|
||||
|
||||
volumes:
|
||||
torrserver_config:
|
||||
torrserver_log:
|
||||
torrserver_torrents:
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
name: traefik_proxy
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
|
||||
FLAGS="--path $TS_CONF_PATH --logpath $TS_LOG_PATH --port $TS_PORT --torrentsdir $TS_TORR_DIR"
|
||||
if [ -n "$TS_IP" ]; then FLAGS="${FLAGS} -i ${TS_IP}"; fi
|
||||
if [ "$TS_HTTPAUTH" = "1" ]; then FLAGS="${FLAGS} --httpauth"; fi
|
||||
if [ "$TS_RDB" = "1" ]; then FLAGS="${FLAGS} --rdb"; fi
|
||||
if [ "$TS_DONTKILL" = "1" ]; then FLAGS="${FLAGS} --dontkill"; fi
|
||||
if [ "$TS_EN_SSL" = "1" ]; then FLAGS="${FLAGS} --ssl"; fi
|
||||
if [ -n "$TS_SSL_PORT" ]; then FLAGS="${FLAGS} --sslport ${TS_SSL_PORT}"; fi
|
||||
if [ -n "$TS_PROXYURL" ]; then FLAGS="${FLAGS} --proxyurl ${TS_PROXYURL}"; fi
|
||||
if [ -n "$TS_PROXYMODE" ]; then FLAGS="${FLAGS} --proxymode ${TS_PROXYMODE}"; fi
|
||||
|
||||
if [ ! -d "$TS_CONF_PATH" ]; then
|
||||
mkdir -p "$TS_CONF_PATH"
|
||||
fi
|
||||
|
||||
if [ ! -d "$TS_TORR_DIR" ]; then
|
||||
mkdir -p "$TS_TORR_DIR"
|
||||
fi
|
||||
|
||||
if [ ! -f "$TS_LOG_PATH" ]; then
|
||||
touch "$TS_LOG_PATH"
|
||||
fi
|
||||
|
||||
echo "Running with: ${FLAGS}"
|
||||
|
||||
exec torrserver $FLAGS
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Создаём директории для конфига, логов и торрентов
|
||||
mkdir -p "$TS_CONF_PATH" "$TS_LOG_PATH" "$TS_TORR_DIR"
|
||||
|
||||
# Устанавливаем права доступа (на всякий случай)
|
||||
chmod 755 "$TS_CONF_PATH" "$TS_LOG_PATH" "$TS_TORR_DIR" 2>/dev/null || true
|
||||
|
||||
# Запускаем TorrServer
|
||||
exec /usr/bin/torrserver
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM alpine
|
||||
LABEL maintainer "yourok"
|
||||
RUN apk add --no-cache wget
|
||||
COPY start.sh /start.sh
|
||||
ENTRYPOINT /start.sh
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
## TorrServer
|
||||
|
||||
After starting the container, the latest server is downloaded from GitHub.\
|
||||
If you need update server to latest, repull container
|
||||
|
||||
Source code: https://github.com/YouROK/TorrServer
|
||||
|
||||
--------
|
||||
|
||||
Author of docker file and scripts [butaford (aka Pavel)](https://github.com/butaford)
|
||||
|
||||
--------
|
||||
|
||||
### Support platforms
|
||||
* TorrServer-linux-386
|
||||
* TorrServer-linux-amd64
|
||||
* TorrServer-linux-arm5
|
||||
* TorrServer-linux-arm64
|
||||
* TorrServer-linux-arm7
|
||||
|
||||
--------
|
||||
### Support env
|
||||
TS_PORT: TS web port\
|
||||
TS_PATH: config path and other\
|
||||
TS_LOGPATHDIR: log path\
|
||||
TS_LOGFILE: log file name\
|
||||
TS_WEBLOGFILE: web log file name\
|
||||
TS_RDB: read only config\
|
||||
TS_HTTPAUTH: auth for server, accs.db should be in the TS_PATH\
|
||||
TS_DONTKILL: don't kill server by signal\
|
||||
TS_TORRENTSDIR: torrents listen directory\
|
||||
TS_TORRENTADDR: torrents peer listen port\
|
||||
TS_PUBIPV4: the IP addresses as our peers should see them. May differ from the local interfaces due to NAT or other network configurations\
|
||||
TS_PUBIPV6: the IP addresses as our peers should see them. May differ from the local interfaces due to NAT or other network configurations\
|
||||
TS_SEARCHWA: disable auth for search torrents if auth is enable
|
||||
|
||||
--------
|
||||
### Docker run example
|
||||
```
|
||||
docker run -p 8090:8090 \
|
||||
-e TS_PORT=8090 \
|
||||
-e TS_PATH="/opt/torrserver/config" \
|
||||
-e TS_LOGPATHDIR="/opt/torrserver/log/" \
|
||||
-e TS_LOGFILE="ts.log" \
|
||||
-e TS_WEBLOGFILE="tsweb.log" \
|
||||
-e TS_RDB=true \
|
||||
-e TS_HTTPAUTH=true \
|
||||
-e TS_DONTKILL=true \
|
||||
-e TS_TORRENTSDIR="/opt/torrserver/torrents" \
|
||||
-e TS_TORRENTADDR=32000 \
|
||||
-e TS_PUBIPV4=publicIP \
|
||||
-e TS_PUBIPV6=publicIP \
|
||||
-e TS_SEARCHWA=true \
|
||||
yourok/torrserver
|
||||
```
|
||||
|
||||
--------
|
||||
### Docker compose example
|
||||
```
|
||||
version: '3.6'
|
||||
services:
|
||||
torrserver:
|
||||
container_name: torrserver
|
||||
image: ghcr.io/yourok/torrserver
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TS_PORT=8090
|
||||
- TS_PATH=/opt/torrserver/config
|
||||
- TS_LOGPATHDIR=/opt/torrserver/log
|
||||
- TS_LOGFILE=ts.log
|
||||
- TS_WEBLOGFILE=tsweb.log
|
||||
- TS_RDB=false
|
||||
- TS_HTTPAUTH=true
|
||||
- TS_DONTKILL=true
|
||||
- TS_TORRENTSDIR=/opt/torrserver/torrents
|
||||
- TS_TORRENTADDR=:32000
|
||||
- TS_PUBIPV4=publicIP
|
||||
- TS_PUBIPV6=publicIP
|
||||
- TS_SEARCHWA=true
|
||||
ports:
|
||||
- 8090:8090
|
||||
volumes:
|
||||
- ./torrserver/config:/opt/torrserver/config
|
||||
- ./torrserver/log:/opt/torrserver/log
|
||||
- ./torrserver/torrents:/opt/torrserver/torrents
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM debian:bookworm-slim as builder
|
||||
RUN mkdir /src
|
||||
COPY ./ /src
|
||||
RUN /src/cp.sh
|
||||
|
||||
FROM scratch
|
||||
COPY --from=builder /app/TorrServer /
|
||||
WORKDIR /
|
||||
ENTRYPOINT [ "/TorrServer" ]
|
||||
@@ -0,0 +1,30 @@
|
||||
## TorrServer
|
||||
|
||||
A lightweight container that contains a single TorrServer file
|
||||
|
||||
Source code: https://github.com/YouROK/TorrServer
|
||||
|
||||
--------
|
||||
|
||||
### Support platforms
|
||||
* TorrServer-linux-386
|
||||
* TorrServer-linux-amd64
|
||||
* TorrServer-linux-arm5
|
||||
* TorrServer-linux-arm64
|
||||
* TorrServer-linux-arm7
|
||||
|
||||
--------
|
||||
### Docker run example
|
||||
```
|
||||
docker run -p 8090:8090 yourok/torrlite:TAG [ ARGS ]
|
||||
```
|
||||
|
||||
TAG - tag of version in docker hub eg MatriX.134 \
|
||||
ARGS - args of torrserver
|
||||
|
||||
You can mount a directory like -v /your/local/path/:/cfg and write logs etc there
|
||||
|
||||
Example of run with args:
|
||||
```
|
||||
docker run -p 8099:8099 yourok/torrlite:MatriX.134 --port=8099
|
||||
```
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
|
||||
case $(uname -m) in
|
||||
i386) architecture="386" ;;
|
||||
i686) architecture="386" ;;
|
||||
x86_64) architecture="amd64" ;;
|
||||
aarch64) architecture="arm64" ;;
|
||||
armv7|armv7l) architecture="arm7" ;;
|
||||
armv6|armv6l) architecture="arm5" ;;
|
||||
# armv5|armv5l) architecture="arm5" ;;
|
||||
*) echo "Unsupported Arch. Can't continue."; exit 1 ;;
|
||||
esac
|
||||
|
||||
binName="TorrServer-linux-${architecture}"
|
||||
mkdir -p /app
|
||||
|
||||
cp /src/dist/$binName /app/TorrServer
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
cp -r ../../dist ./
|
||||
docker buildx build --platform "linux/386,linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6" --tag yourok/torrlite:$* --push .
|
||||
rm -rf ./dist
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/bin/sh
|
||||
|
||||
case $(uname -m) in
|
||||
i386) architecture="386" ;;
|
||||
i686) architecture="386" ;;
|
||||
x86_64) architecture="amd64" ;;
|
||||
aarch64) architecture="arm64" ;;
|
||||
armv7|armv7l) architecture="arm7" ;;
|
||||
armv6|armv6l) architecture="arm5" ;;
|
||||
# armv5|armv5l) architecture="arm5" ;;
|
||||
*) echo "Unsupported Arch. Can't continue."; exit 1 ;;
|
||||
esac
|
||||
|
||||
binName="TorrServer-linux-${architecture}"
|
||||
|
||||
mkdir -p /opt/torrserver
|
||||
cd /opt/torrserver
|
||||
|
||||
rm -f ${binName}*
|
||||
|
||||
wget -O $binName "https://github.com/YouROK/TorrServer/releases/latest/download/$binName"
|
||||
chmod +x $binName
|
||||
|
||||
FLAGS=""
|
||||
|
||||
#sets start flags
|
||||
[ ! -z "$TS_PORT" ] && echo "TS_PORT: $TS_PORT" && FLAGS="${FLAGS} --port ${TS_PORT}"
|
||||
[ ! -z "$TS_PATH" ] && echo "TS_PATH: $TS_PATH" && FLAGS="${FLAGS} --path ${TS_PATH}"
|
||||
[ ! -z "$TS_LOGPATHDIR" ] && echo "TS_LOGPATHDIR: $TS_LOGPATHDIR" && FLAGS="${FLAGS}"
|
||||
[ ! -z "$TS_LOGFILE" ] && echo "TS_LOGFILE: $TS_LOGPATHDIR/$TS_LOGFILE" && FLAGS="${FLAGS} --logpath $TS_LOGPATHDIR/${TS_LOGFILE}"
|
||||
[ ! -z "$TS_WEBLOGFILE" ] && echo "TS_WEBLOGFILE: $TS_LOGPATHDIR/$TS_WEBLOGFILE" && FLAGS="${FLAGS} --weblogpath $TS_LOGPATHDIR/${TS_WEBLOGFILE}"
|
||||
[ ! -z "$TS_RDB" ] | [ "$TS_RDB" = "true" ] && echo "TS_RDB: $TS_RDB" && FLAGS="${FLAGS} --rdb"
|
||||
[ ! -z "$TS_HTTPAUTH" ] && echo "TS_HTTPAUTH: $TS_HTTPAUTH" && FLAGS="${FLAGS} --httpauth"
|
||||
[ ! -z "$TS_DONTKILL" ] && echo "TS_DONTKILL: $TS_DONTKILL" && FLAGS="${FLAGS} --dontkill"
|
||||
[ ! -z "$TS_TORRENTSDIR" ] && echo "TS_TORRENTSDIR: $TS_TORRENTSDIR" && FLAGS="${FLAGS} --torrentsdir ${TS_TORRENTSDIR}"
|
||||
[ ! -z "$TS_TORRENTADDR" ] && echo "TS_TORRENTADDR: $TS_TORRENTADDR" && FLAGS="${FLAGS} --torrentaddr ${TS_TORRENTADDR}"
|
||||
[ ! -z "$TS_PUBIPV4" ] && echo "TS_PUBIPV4: $TS_PUBIPV4" && FLAGS="${FLAGS} --pubipv4 ${TS_PUBIPV4}"
|
||||
[ ! -z "$TS_PUBIPV6" ] && echo "TS_PUBIPV6: $TS_PUBIPV6" && FLAGS="${FLAGS} --pubipv6 ${TS_PUBIPV6}"
|
||||
[ ! -z "$TS_SEARCHWA" ]&& echo "TS_SEARCHWA: $TS_SEARCHWA" && FLAGS="${FLAGS} --searchwa"
|
||||
|
||||
#make directories
|
||||
[ ! -z "$TS_PATH" ] && [ ! -d "$TS_PATH" ] && mkdir -p $TS_PATH
|
||||
[ ! -z "$TS_LOGPATHDIR" ] && [ ! -d "$TS_LOGPATHDIR" ] && mkdir -p $TS_LOGPATHDIR
|
||||
[ ! -z "$TS_TORRENTSDIR" ] && [ ! -d "$TS_TORRENTSDIR" ] && mkdir $TS_TORRENTSDIR
|
||||
|
||||
echo "Running with: ${FLAGS}"
|
||||
export GODEBUG=madvdontneed=1
|
||||
|
||||
/opt/torrserver/${binName} ${FLAGS}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
description = "TorrServer - Simple and powerful tool for streaming torrents";
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
systems.url = "github:nix-systems/default-linux";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
systems,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (nixpkgs) lib;
|
||||
eachSystem = f: lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system});
|
||||
in
|
||||
{
|
||||
formatter = eachSystem (pkgs: pkgs.alejandra);
|
||||
|
||||
devShells = eachSystem (pkgs: {
|
||||
default = pkgs.mkShell {
|
||||
name = "torrserver";
|
||||
inputsFrom = [ self.packages.${pkgs.stdenv.system}.torrserver ];
|
||||
};
|
||||
});
|
||||
|
||||
packages = eachSystem (pkgs: {
|
||||
default = self.packages.${pkgs.stdenv.system}.torrserver;
|
||||
torrserver = pkgs.callPackage ./nix/packages/torrserver.nix { };
|
||||
});
|
||||
|
||||
homeModules = {
|
||||
default = self.homeModules.torrserver;
|
||||
torrserver = import ./nix/modules/home-manager.nix { inherit self; };
|
||||
};
|
||||
|
||||
nixosModules = {
|
||||
default = self.nixosModules.torrserver;
|
||||
torrserver = import ./nix/modules/nixos.nix { inherit self; };
|
||||
};
|
||||
};
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"mime"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dir, _ := os.Getwd()
|
||||
|
||||
if len(os.Args) > 0 {
|
||||
// Here you can be more cunning, but it will work anyway, for a clean build you need to clean the build folder using the --clean command
|
||||
if slices.ContainsFunc(os.Args, func(s string) bool {
|
||||
return s == "--clean" || s == "-c"
|
||||
}) {
|
||||
// There are problems with running under windows
|
||||
if err := run("rm", "-rf", "web/build"); err != nil {
|
||||
if strings.Contains(err.Error(), "executable file not found") {
|
||||
// Adding the ability to run on Windows with standard Go commands
|
||||
if err := os.RemoveAll("web/build"); err != nil {
|
||||
log.Default().Fatalln(err.Error())
|
||||
}
|
||||
} else {
|
||||
log.Default().Fatalln(err.Error())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Do not uncomment, be aware - its crash the build
|
||||
//log.Default().Fatalln("Wrong args?")
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := os.Stat("web/build/static"); os.IsNotExist(err) {
|
||||
os.Chdir("web")
|
||||
if err = run("yarn"); err != nil {
|
||||
log.Default().Fatalln(err.Error())
|
||||
}
|
||||
if err = run("yarn", "run", "build"); err != nil {
|
||||
log.Default().Fatalln(err.Error())
|
||||
}
|
||||
os.Chdir(dir)
|
||||
}
|
||||
|
||||
compileHtml := "web/build/"
|
||||
srcGo := "server/web/pages/"
|
||||
|
||||
// There are problems with running under windows
|
||||
if err := run("rm", "-rf", srcGo+"template/pages"); err != nil {
|
||||
if strings.Contains(err.Error(), "executable file not found") {
|
||||
// Adding the ability to run on Windows with standard Go commands
|
||||
if err = os.RemoveAll(srcGo + "template/pages"); err != nil {
|
||||
log.Default().Fatalln(err.Error())
|
||||
}
|
||||
} else {
|
||||
log.Default().Fatalln(err.Error())
|
||||
}
|
||||
}
|
||||
// There are problems with running under windows
|
||||
if err := run("cp", "-r", compileHtml, srcGo+"template/pages/"); err != nil {
|
||||
if strings.Contains(err.Error(), "executable file not found") {
|
||||
// Adding the ability to run on Windows with standard Go commands
|
||||
if err = os.CopyFS(srcGo+"template/pages/", os.DirFS(filepath.Dir(compileHtml))); err != nil {
|
||||
log.Default().Fatalln(err.Error())
|
||||
}
|
||||
} else {
|
||||
log.Default().Fatalln(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
files := make([]string, 0)
|
||||
|
||||
filepath.WalkDir(srcGo+"template/pages/", func(path string, d fs.DirEntry, err error) error {
|
||||
if !d.IsDir() {
|
||||
name := strings.TrimPrefix(path, srcGo+"template/")
|
||||
if strings.Contains(name, "\\") {
|
||||
// Adding the ability to run on Windows with standard Go commands
|
||||
name = strings.TrimPrefix(strings.ReplaceAll(name, "\\", "/"), "server/web/pages/template/")
|
||||
}
|
||||
if !strings.HasPrefix(filepath.Base(name), ".") {
|
||||
files = append(files, name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
sort.Strings(files)
|
||||
fmap := writeEmbed(srcGo+"template/html.go", files)
|
||||
writeRoute(srcGo+"template/route.go", fmap)
|
||||
}
|
||||
|
||||
func writeEmbed(fname string, files []string) map[string]string {
|
||||
ff, err := os.Create(fname)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer ff.Close()
|
||||
embedStr := `package template
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
`
|
||||
ret := make(map[string]string)
|
||||
|
||||
for _, f := range files {
|
||||
fname := cleanName(strings.TrimPrefix(f, "pages"))
|
||||
embedStr += "\n//go:embed " + f + "\nvar " + fname + " []byte\n"
|
||||
ret[strings.TrimPrefix(f, "pages")] = fname
|
||||
}
|
||||
|
||||
ff.WriteString(embedStr)
|
||||
return ret
|
||||
}
|
||||
|
||||
func writeRoute(fname string, fmap map[string]string) {
|
||||
ff, err := os.Create(fname)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer ff.Close()
|
||||
embedStr := `package template
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RouteWebPages(route gin.IRouter) {
|
||||
route.GET("/", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Indexhtml))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "text/html; charset=utf-8", Indexhtml)
|
||||
})
|
||||
`
|
||||
mime.AddExtensionType(".map", "application/json")
|
||||
mime.AddExtensionType(".webmanifest", "application/manifest+json")
|
||||
// sort fmap
|
||||
keys := make([]string, 0, len(fmap))
|
||||
for key := range fmap {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, link := range keys {
|
||||
fmime := mime.TypeByExtension(filepath.Ext(link))
|
||||
if fmime == "application/xml" || fmime == "application/javascript" {
|
||||
fmime = fmime + "; charset=utf-8"
|
||||
}
|
||||
if fmime == "image/x-icon" {
|
||||
fmime = "image/vnd.microsoft.icon"
|
||||
}
|
||||
embedStr += `
|
||||
route.GET("` + link + `", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(` + fmap[link] + `))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "` + fmime + `", ` + fmap[link] + `)
|
||||
})
|
||||
`
|
||||
}
|
||||
embedStr += "}\n"
|
||||
|
||||
ff.WriteString(embedStr)
|
||||
}
|
||||
|
||||
func run(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdout = os.Stdout
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func cleanName(fn string) string {
|
||||
reg, err := regexp.Compile("[^a-zA-Z0-9]+")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return strings.Title(reg.ReplaceAllString(fn, ""))
|
||||
}
|
||||
Executable
+2125
File diff suppressed because it is too large
Load Diff
Executable
+1338
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
||||
{
|
||||
self,
|
||||
}:
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (lib.modules) mkIf mkMerge;
|
||||
inherit (lib.options) mkOption mkEnableOption mkPackageOption;
|
||||
inherit (lib.types) nullOr bool;
|
||||
inherit (lib)
|
||||
optionalString
|
||||
types
|
||||
mapAttrs'
|
||||
mapAttrsToList
|
||||
nameValuePair
|
||||
mkDefault
|
||||
literalExpression
|
||||
;
|
||||
|
||||
cfg = config.services.torrserver;
|
||||
|
||||
in
|
||||
{
|
||||
options.services.torrserver = {
|
||||
enable = mkEnableOption "TorrServer service";
|
||||
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = self.packages.${pkgs.system}.torrserver;
|
||||
description = "TorrServer package to use";
|
||||
};
|
||||
|
||||
port = mkOption {
|
||||
type = types.int;
|
||||
default = 8090;
|
||||
description = "Web server port";
|
||||
};
|
||||
|
||||
address = mkOption {
|
||||
type = types.str;
|
||||
default = "0.0.0.0";
|
||||
description = "Web server address to bind to";
|
||||
};
|
||||
|
||||
dataDir = mkOption {
|
||||
type = types.path;
|
||||
default = "${config.home.homeDirectory}/.local/share/torrserver";
|
||||
description = "Directory for TorrServer data and cache";
|
||||
};
|
||||
|
||||
logDir = mkOption {
|
||||
type = types.path;
|
||||
default = "${config.home.homeDirectory}/.local/log/torrserver";
|
||||
description = "Directory for TorrServer logs";
|
||||
};
|
||||
|
||||
enableAuth = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable HTTP authentication";
|
||||
};
|
||||
|
||||
enableWebDAV = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable WebDAV server";
|
||||
};
|
||||
|
||||
enableDLNA = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable DLNA server";
|
||||
};
|
||||
|
||||
maxCacheSize = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "10GB";
|
||||
description = "Maximum cache size (e.g., 10GB, 5000MB)";
|
||||
};
|
||||
|
||||
torrentsDir = mkOption {
|
||||
type = types.nullOr types.path;
|
||||
default = null;
|
||||
description = "Directory for auto-loading torrents";
|
||||
};
|
||||
|
||||
ssl = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable HTTPS";
|
||||
};
|
||||
|
||||
sslPort = mkOption {
|
||||
type = types.int;
|
||||
default = 8091;
|
||||
description = "HTTPS port (requires ssl = true)";
|
||||
};
|
||||
|
||||
sslCert = mkOption {
|
||||
type = types.nullOr types.path;
|
||||
default = null;
|
||||
description = "Path to SSL certificate file";
|
||||
};
|
||||
|
||||
sslKey = mkOption {
|
||||
type = types.nullOr types.path;
|
||||
default = null;
|
||||
description = "Path to SSL key file";
|
||||
};
|
||||
|
||||
telegramToken = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
description = "Telegram bot token";
|
||||
};
|
||||
|
||||
readOnly = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Run in read-only database mode";
|
||||
};
|
||||
|
||||
extraArgs = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
description = "Additional command-line arguments";
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
home.activation.torrserverDirs = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
|
||||
mkdir -p ${cfg.dataDir} ${cfg.logDir}
|
||||
'';
|
||||
|
||||
systemd.user.services.torrserver = {
|
||||
Unit = {
|
||||
Description = "TorrServer - Stream torrents online";
|
||||
After = [ "network-online.target" ];
|
||||
Wants = [ "network-online.target" ];
|
||||
};
|
||||
|
||||
Service = {
|
||||
Type = "simple";
|
||||
ExecStart =
|
||||
let
|
||||
args = [
|
||||
"-p ${toString cfg.port}"
|
||||
"-i ${cfg.address}"
|
||||
"-d ${cfg.dataDir}"
|
||||
"-l ${cfg.logDir}/server.log"
|
||||
"-w ${cfg.logDir}/web.log"
|
||||
]
|
||||
++ lib.optionals cfg.enableAuth [ "-a" ]
|
||||
++ lib.optionals cfg.readOnly [ "-r" ]
|
||||
++ lib.optionals cfg.enableWebDAV [ "--webdav" ]
|
||||
++ lib.optionals cfg.ssl [
|
||||
"--ssl"
|
||||
"--ssl-port ${toString cfg.sslPort}"
|
||||
]
|
||||
++ lib.optionals (cfg.sslCert != null) [ "--ssl-cert ${cfg.sslCert}" ]
|
||||
++ lib.optionals (cfg.sslKey != null) [ "--ssl-key ${cfg.sslKey}" ]
|
||||
++ lib.optionals (cfg.maxCacheSize != null) [ "-m ${cfg.maxCacheSize}" ]
|
||||
++ lib.optionals (cfg.torrentsDir != null) [ "-t ${cfg.torrentsDir}" ]
|
||||
++ lib.optionals (cfg.telegramToken != null) [ "-T ${cfg.telegramToken}" ]
|
||||
++ cfg.extraArgs;
|
||||
in
|
||||
"${cfg.package}/bin/torrserver ${lib.strings.concatStringsSep " " args}";
|
||||
|
||||
Restart = "always";
|
||||
RestartSec = "10s";
|
||||
};
|
||||
|
||||
Install = {
|
||||
WantedBy = [ "default.target" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
{
|
||||
self,
|
||||
}:
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (lib.modules) mkIf mkMerge;
|
||||
inherit (lib.options) mkOption mkEnableOption mkPackageOption;
|
||||
inherit (lib.types) nullOr bool;
|
||||
inherit (lib)
|
||||
optionalString
|
||||
types
|
||||
mapAttrs'
|
||||
mapAttrsToList
|
||||
nameValuePair
|
||||
mkDefault
|
||||
literalExpression
|
||||
;
|
||||
|
||||
cfg = config.services.torrserver;
|
||||
|
||||
in
|
||||
{
|
||||
options.services.torrserver = {
|
||||
enable = mkEnableOption "TorrServer service";
|
||||
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = self.packages.${pkgs.system}.torrserver;
|
||||
description = "TorrServer package to use";
|
||||
};
|
||||
|
||||
port = mkOption {
|
||||
type = types.int;
|
||||
default = 8090;
|
||||
description = "Web server port";
|
||||
};
|
||||
|
||||
address = mkOption {
|
||||
type = types.str;
|
||||
default = "0.0.0.0";
|
||||
description = "Web server address to bind to";
|
||||
};
|
||||
|
||||
dataDir = mkOption {
|
||||
type = types.path;
|
||||
default = "/var/lib/torrserver";
|
||||
description = "Directory for TorrServer data and cache";
|
||||
};
|
||||
|
||||
logDir = mkOption {
|
||||
type = types.path;
|
||||
default = "/var/log/torrserver";
|
||||
description = "Directory for TorrServer logs";
|
||||
};
|
||||
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "torrserver";
|
||||
description = "User to run TorrServer as";
|
||||
};
|
||||
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = "torrserver";
|
||||
description = "Group to run TorrServer as";
|
||||
};
|
||||
|
||||
enableAuth = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable HTTP authentication";
|
||||
};
|
||||
|
||||
enableWebDAV = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable WebDAV server";
|
||||
};
|
||||
|
||||
enableDLNA = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable DLNA server";
|
||||
};
|
||||
|
||||
maxCacheSize = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "10GB";
|
||||
description = "Maximum cache size (e.g., 10GB, 5000MB)";
|
||||
};
|
||||
|
||||
torrentsDir = mkOption {
|
||||
type = types.nullOr types.path;
|
||||
default = null;
|
||||
description = "Directory for auto-loading torrents";
|
||||
};
|
||||
|
||||
ssl = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable HTTPS";
|
||||
};
|
||||
|
||||
sslPort = mkOption {
|
||||
type = types.int;
|
||||
default = 8091;
|
||||
description = "HTTPS port (requires ssl = true)";
|
||||
};
|
||||
|
||||
sslCert = mkOption {
|
||||
type = types.nullOr types.path;
|
||||
default = null;
|
||||
description = "Path to SSL certificate file";
|
||||
};
|
||||
|
||||
sslKey = mkOption {
|
||||
type = types.nullOr types.path;
|
||||
default = null;
|
||||
description = "Path to SSL key file";
|
||||
};
|
||||
|
||||
telegramToken = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
description = "Telegram bot token";
|
||||
};
|
||||
|
||||
readOnly = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Run in read-only database mode";
|
||||
};
|
||||
|
||||
extraArgs = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
description = "Additional command-line arguments";
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
users.users = mkIf (cfg.user == "torrserver") {
|
||||
torrserver = {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
home = cfg.dataDir;
|
||||
createHome = true;
|
||||
};
|
||||
};
|
||||
|
||||
users.groups = mkIf (cfg.group == "torrserver") {
|
||||
torrserver = { };
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${cfg.logDir} 0755 ${cfg.user} ${cfg.group} - -"
|
||||
];
|
||||
|
||||
systemd.services.torrserver = {
|
||||
description = "TorrServer - Stream torrents online";
|
||||
after = [ "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
|
||||
ExecStart =
|
||||
let
|
||||
args = [
|
||||
"-p ${toString cfg.port}"
|
||||
"-i ${cfg.address}"
|
||||
"-d ${cfg.dataDir}"
|
||||
"-l ${cfg.logDir}/server.log"
|
||||
"-w ${cfg.logDir}/web.log"
|
||||
]
|
||||
++ lib.optionals cfg.enableAuth [ "-a" ]
|
||||
++ lib.optionals cfg.readOnly [ "-r" ]
|
||||
++ lib.optionals cfg.enableWebDAV [ "--webdav" ]
|
||||
++ lib.optionals cfg.ssl [
|
||||
"--ssl"
|
||||
"--ssl-port ${toString cfg.sslPort}"
|
||||
]
|
||||
++ lib.optionals (cfg.sslCert != null) [ "--ssl-cert ${cfg.sslCert}" ]
|
||||
++ lib.optionals (cfg.sslKey != null) [ "--ssl-key ${cfg.sslKey}" ]
|
||||
++ lib.optionals (cfg.maxCacheSize != null) [ "-m ${cfg.maxCacheSize}" ]
|
||||
++ lib.optionals (cfg.torrentsDir != null) [ "-t ${cfg.torrentsDir}" ]
|
||||
++ lib.optionals (cfg.telegramToken != null) [ "-T ${cfg.telegramToken}" ]
|
||||
++ cfg.extraArgs;
|
||||
in
|
||||
"${cfg.package}/bin/torrserver ${lib.strings.concatStringsSep " " args}";
|
||||
|
||||
Restart = "always";
|
||||
RestartSec = "10s";
|
||||
|
||||
NoNewPrivileges = true;
|
||||
PrivateTmp = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
ReadWritePaths = [
|
||||
cfg.dataDir
|
||||
cfg.logDir
|
||||
];
|
||||
|
||||
LimitNOFILE = 65536;
|
||||
LimitNPROC = 512;
|
||||
};
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ cfg.port ] ++ lib.optionals cfg.ssl [ cfg.sslPort ];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
lib,
|
||||
buildGoModule,
|
||||
pkg-config,
|
||||
fetchFromGitHub,
|
||||
fetchYarnDeps,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
pkgs.stdenv.mkDerivation rec {
|
||||
pname = "torrserver";
|
||||
version = "MatriX.141";
|
||||
|
||||
src = pkgs.fetchgit {
|
||||
url = "https://github.com/YouROK/TorrServer.git";
|
||||
rev = "${version}";
|
||||
hash = "sha256-OeAAYyxfZxcx0ANeRAWJTrZMNWtdrM/pwXyO5QNTwYo=";
|
||||
};
|
||||
yarnOfflineCache = pkgs.fetchYarnDeps {
|
||||
yarnLock = "${src}/web/yarn.lock";
|
||||
hash = "sha256-B2D5HapIbrKLRvfKKF7HhJb6IlWRG2vi/qm4A5gJsNk=";
|
||||
};
|
||||
|
||||
goModules = pkgs.buildGoModule.override { go = pkgs.go_1_26; } {
|
||||
pname = "torrserver-go-deps";
|
||||
version = version;
|
||||
src = "${src}/server";
|
||||
vendorHash = "sha256-rjdE9yf6S3ZovEeRO0+5sJsy9PRdFFejFDhkgJLMz58=";
|
||||
modBuildPhase = ''
|
||||
go mod download
|
||||
go mod vendor
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
mkdir -p $out
|
||||
cp -r vendor $out/
|
||||
'';
|
||||
|
||||
doCheck = false;
|
||||
doInstallCheck = false;
|
||||
buildPhase = "true";
|
||||
};
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
go_1_26
|
||||
pkg-config
|
||||
git
|
||||
yarn
|
||||
fixup-yarn-lock
|
||||
nodejs
|
||||
go-swag
|
||||
];
|
||||
|
||||
buildInputs = with pkgs; [
|
||||
fuse
|
||||
];
|
||||
|
||||
buildPhase = ''
|
||||
export GOCACHE=$TMPDIR/go-build
|
||||
export GOMODCACHE=$TMPDIR/go-mod
|
||||
export HOME=$(mktemp -d)
|
||||
export NODE_OPTIONS=--openssl-legacy-provider
|
||||
export PATH=$PATH:$(go env GOPATH)/bin
|
||||
|
||||
cd web
|
||||
runHook preConfigure
|
||||
yarn config --offline set yarn-offline-mirror ${yarnOfflineCache}
|
||||
fixup-yarn-lock yarn.lock
|
||||
yarn install --offline --frozen-lockfile --ignore-platform --ignore-scripts --no-progress --non-interactive
|
||||
patchShebangs node_modules/
|
||||
yarn build
|
||||
cd ..
|
||||
|
||||
cd server
|
||||
swag init -g web/server.go --parseDependency --parseInternal --parseDepth 5
|
||||
cd ..
|
||||
|
||||
mkdir -p server/vendor
|
||||
cp -r ${goModules}/vendor/* server/vendor/
|
||||
chmod -R +w server/vendor
|
||||
|
||||
cd server
|
||||
mkdir -p ../dist
|
||||
GOOS=linux GOARCH=amd64 go build \
|
||||
-ldflags="-s -w -checklinkname=0" \
|
||||
-tags=nosqlite \
|
||||
-trimpath \
|
||||
-o ../dist/torrserver ./cmd
|
||||
cd ..
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
mkdir -p $out/bin
|
||||
cp dist/torrserver $out/bin/torrserver
|
||||
chmod +x $out/bin/torrserver
|
||||
'';
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "Simple and powerful tool for streaming torrents";
|
||||
homepage = "https://github.com/YouROK/TorrServer";
|
||||
license = licenses.gpl3Only;
|
||||
mainProgram = "torrserver";
|
||||
platforms = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
"aarch64-darwin"
|
||||
"x86_64-darwin"
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
--- reader.go.orig 2022-06-01 09:26:35.000000000 +0300
|
||||
+++ reader.go 2022-05-17 05:47:36.000000000 +0300
|
||||
@@ -102,9 +102,9 @@
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
- if !r.responsive && !r.t.pieceComplete(pieceIndex(req.Index)) {
|
||||
- break
|
||||
- }
|
||||
+// if !r.responsive && !r.t.pieceComplete(pieceIndex(req.Index)) {
|
||||
+// break
|
||||
+// }
|
||||
if !r.t.haveChunk(req) {
|
||||
break
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
--- tracker/udp/conn-client.go.orig 2022-05-16 05:10:10.000000000 +0300
|
||||
+++ tracker/udp/conn-client.go 2022-06-01 09:34:27.000000000 +0300
|
||||
@@ -38,9 +38,9 @@
|
||||
// TODO: Do bad things to the dispatcher, and incoming calls to the client if we have a
|
||||
// read error.
|
||||
cc.readErr = err
|
||||
- if !cc.closed {
|
||||
- panic(err)
|
||||
- }
|
||||
+// if !cc.closed {
|
||||
+// panic(err)
|
||||
+// }
|
||||
break
|
||||
}
|
||||
err = cc.d.Dispatch(b[:n], addr)
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"Name": "TorrServer",
|
||||
"Version": "1.1.77",
|
||||
"BuildDate": "05.08.2020",
|
||||
"Links": {
|
||||
"android-386": "https://github.com/YouROK/TorrServer/releases/download/1.1.77/TorrServer-android-386",
|
||||
"android-amd64": "https://github.com/YouROK/TorrServer/releases/download/1.1.77/TorrServer-android-amd64",
|
||||
"android-arm64": "https://github.com/YouROK/TorrServer/releases/download/1.1.77/TorrServer-android-arm64",
|
||||
"android-arm7": "https://github.com/YouROK/TorrServer/releases/download/1.1.77/TorrServer-android-arm7",
|
||||
"android-10-arm64": "https://github.com/YouROK/TorrServer/releases/download/1.1.77/TorrServer-android-arm64-10",
|
||||
"android-10-arm7": "https://github.com/YouROK/TorrServer/releases/download/1.1.77/TorrServer-android-arm7-10",
|
||||
"darwin-amd64": "https://github.com/YouROK/TorrServer/releases/download/1.1.77/TorrServer-darwin-amd64",
|
||||
"freebsd-amd64": "https://github.com/YouROK/TorrServer/releases/download/1.1.77/TorrServer-freebsd-amd64",
|
||||
"linux-386": "https://github.com/YouROK/TorrServer/releases/download/1.1.77/TorrServer-linux-386",
|
||||
"linux-amd64": "https://github.com/YouROK/TorrServer/releases/download/1.1.77/TorrServer-linux-amd64",
|
||||
"linux-arm5": "https://github.com/YouROK/TorrServer/releases/download/1.1.77/TorrServer-linux-arm5",
|
||||
"linux-arm6": "https://github.com/YouROK/TorrServer/releases/download/1.1.77/TorrServer-linux-arm6",
|
||||
"linux-arm64": "https://github.com/YouROK/TorrServer/releases/download/1.1.77/TorrServer-linux-arm64",
|
||||
"linux-arm7": "https://github.com/YouROK/TorrServer/releases/download/1.1.77/TorrServer-linux-arm7",
|
||||
"linux-mips": "https://github.com/YouROK/TorrServer/releases/download/1.1.77/TorrServer-linux-mips",
|
||||
"linux-mips64": "https://github.com/YouROK/TorrServer/releases/download/1.1.77/TorrServer-linux-mips64",
|
||||
"linux-mips64le": "https://github.com/YouROK/TorrServer/releases/download/1.1.77/TorrServer-linux-mips64le",
|
||||
"linux-mipsle": "https://github.com/YouROK/TorrServer/releases/download/1.1.77/TorrServer-linux-mipsle",
|
||||
"windows-386.exe": "https://github.com/YouROK/TorrServer/releases/download/1.1.77/TorrServer-windows-386.exe",
|
||||
"windows-amd64.exe": "https://github.com/YouROK/TorrServer/releases/download/1.1.77/TorrServer-windows-amd64.exe"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,493 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"server/torr/utils"
|
||||
|
||||
"github.com/alexflint/go-arg"
|
||||
"github.com/pkg/browser"
|
||||
|
||||
"server"
|
||||
"server/docs"
|
||||
"server/log"
|
||||
"server/settings"
|
||||
"server/torr"
|
||||
"server/version"
|
||||
)
|
||||
|
||||
type args struct {
|
||||
Port string `arg:"-p" help:"web server port (default 8090)"`
|
||||
IP string `arg:"-i" help:"web server addr (default empty)"`
|
||||
Ssl bool `help:"enables https"`
|
||||
SslPort string `help:"web server ssl port, If not set, will be set to default 8091 or taken from db(if stored previously). Accepted if --ssl enabled."`
|
||||
SslCert string `help:"path to ssl cert file. If not set, will be taken from db(if stored previously) or default self-signed certificate/key will be generated. Accepted if --ssl enabled."`
|
||||
SslKey string `help:"path to ssl key file. If not set, will be taken from db(if stored previously) or default self-signed certificate/key will be generated. Accepted if --ssl enabled."`
|
||||
Path string `arg:"-d" help:"database and config dir path"`
|
||||
LogPath string `arg:"-l" help:"server log file path"`
|
||||
WebLogPath string `arg:"-w" help:"web access log file path"`
|
||||
RDB bool `arg:"-r" help:"start in read-only DB mode"`
|
||||
HttpAuth bool `arg:"-a" help:"enable http auth on all requests"`
|
||||
DontKill bool `arg:"-k" help:"don't kill server on signal"`
|
||||
UI bool `arg:"-u" help:"open torrserver page in browser"`
|
||||
TorrentsDir string `arg:"-t" help:"autoload torrents from dir"`
|
||||
TorrentAddr string `help:"Torrent client address, like 127.0.0.1:1337 (default :PeersListenPort)"`
|
||||
PubIPv4 string `arg:"-4" help:"set public IPv4 addr"`
|
||||
PubIPv6 string `arg:"-6" help:"set public IPv6 addr"`
|
||||
SearchWA bool `arg:"-s" help:"search without auth"`
|
||||
MaxSize string `arg:"-m" help:"max allowed stream size (in Bytes)"`
|
||||
TGToken string `arg:"-T" help:"telegram bot token"`
|
||||
FusePath string `arg:"-f" help:"fuse mount path"`
|
||||
WebDAV bool `help:"web dav enable"`
|
||||
ProxyURL string `help:"proxy URL for BitTorrent traffic (http, socks4, socks5, socks5h), e.g. socks5://user:password@127.0.0.1:8080"`
|
||||
ProxyMode string `help:"proxy mode: tracker (only HTTP trackers, default), peers (only peer connections), or full (all traffic)"`
|
||||
ForceHTTPS bool `arg:"--force-https" help:"redirect all HTTP requests to HTTPS (requires --ssl)"`
|
||||
}
|
||||
|
||||
func (args) Version() string {
|
||||
return "TorrServer " + version.Version
|
||||
}
|
||||
|
||||
var params args
|
||||
|
||||
func main() {
|
||||
runtime.GOMAXPROCS(runtime.NumCPU())
|
||||
|
||||
arg.MustParse(¶ms)
|
||||
|
||||
if params.Path == "" {
|
||||
params.Path, _ = os.Getwd()
|
||||
}
|
||||
|
||||
if params.Port == "" {
|
||||
params.Port = "8090"
|
||||
}
|
||||
|
||||
settings.Path = params.Path
|
||||
settings.HttpAuth = params.HttpAuth
|
||||
log.Init(params.LogPath, params.WebLogPath)
|
||||
|
||||
fmt.Println("=========== START ===========")
|
||||
fmt.Println("TorrServer", version.Version+",", runtime.Version()+",", "CPU Num:", runtime.NumCPU())
|
||||
if params.HttpAuth {
|
||||
log.TLogln("Use HTTP Auth file", settings.Path+"/accs.db")
|
||||
}
|
||||
if params.RDB {
|
||||
log.TLogln("Running in Read-only DB mode!")
|
||||
}
|
||||
docs.SwaggerInfo.Version = version.Version
|
||||
|
||||
// Simple Usage:
|
||||
dnsResolve()
|
||||
|
||||
// Advanced Usage:
|
||||
// config := DNSConfig{
|
||||
// PrimaryServers: []string{"1.1.1.1:53", "8.8.8.8:53"},
|
||||
// Timeout: 3 * time.Second,
|
||||
// }
|
||||
// checker := NewDNSChecker(config)
|
||||
// // Perform DNS lookup with automatic fallback
|
||||
// addrs, err := checker.LookupHostWithFallback("themoviedb.org")
|
||||
// if err != nil {
|
||||
// log.TLogln("DNS lookup failed:", err)
|
||||
// } else {
|
||||
// fmt.Println("DNS resolved:", addrs)
|
||||
// }
|
||||
|
||||
Preconfig(params.DontKill)
|
||||
|
||||
if params.UI {
|
||||
go func() {
|
||||
time.Sleep(time.Second)
|
||||
if params.Ssl {
|
||||
browser.OpenURL("https://127.0.0.1:" + params.SslPort)
|
||||
} else {
|
||||
browser.OpenURL("http://127.0.0.1:" + params.Port)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if params.TorrentAddr != "" {
|
||||
settings.TorAddr = params.TorrentAddr
|
||||
}
|
||||
|
||||
if params.PubIPv4 != "" {
|
||||
settings.PubIPv4 = params.PubIPv4
|
||||
}
|
||||
|
||||
if params.PubIPv6 != "" {
|
||||
settings.PubIPv6 = params.PubIPv6
|
||||
}
|
||||
|
||||
if params.TorrentsDir != "" {
|
||||
go watchTDir(params.TorrentsDir)
|
||||
}
|
||||
|
||||
if params.MaxSize != "" {
|
||||
maxSize, err := strconv.ParseInt(params.MaxSize, 10, 64)
|
||||
if err == nil {
|
||||
settings.MaxSize = maxSize
|
||||
}
|
||||
}
|
||||
|
||||
if params.ProxyURL != "" && params.ProxyMode == "" {
|
||||
params.ProxyMode = "tracker" // default
|
||||
}
|
||||
if params.ProxyMode != "" && params.ProxyMode != "tracker" && params.ProxyMode != "peers" && params.ProxyMode != "full" {
|
||||
log.TLogln("Invalid proxy mode, using default 'tracker'")
|
||||
params.ProxyMode = "tracker"
|
||||
}
|
||||
|
||||
settings.Args = &settings.ExecArgs{
|
||||
Port: params.Port,
|
||||
IP: params.IP,
|
||||
Ssl: params.Ssl,
|
||||
SslPort: params.SslPort,
|
||||
SslCert: params.SslCert,
|
||||
SslKey: params.SslKey,
|
||||
Path: params.Path,
|
||||
LogPath: params.LogPath,
|
||||
WebLogPath: params.WebLogPath,
|
||||
RDB: params.RDB,
|
||||
HttpAuth: params.HttpAuth,
|
||||
DontKill: params.DontKill,
|
||||
UI: params.UI,
|
||||
TorrentsDir: params.TorrentsDir,
|
||||
TorrentAddr: params.TorrentAddr,
|
||||
PubIPv4: params.PubIPv4,
|
||||
PubIPv6: params.PubIPv6,
|
||||
SearchWA: params.SearchWA,
|
||||
MaxSize: params.MaxSize,
|
||||
TGToken: params.TGToken,
|
||||
FusePath: params.FusePath,
|
||||
WebDAV: params.WebDAV,
|
||||
ProxyURL: params.ProxyURL,
|
||||
ProxyMode: params.ProxyMode,
|
||||
ForceHTTPS: params.ForceHTTPS,
|
||||
}
|
||||
|
||||
if params.ProxyURL != "" {
|
||||
log.TLogln("Proxy configured from CLI:", params.ProxyURL, "mode:", settings.Args.ProxyMode)
|
||||
}
|
||||
|
||||
if params.ForceHTTPS && !params.Ssl {
|
||||
log.TLogln("Error: --force-https requires --ssl")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
server.Start()
|
||||
log.TLogln(server.WaitServer())
|
||||
log.Close()
|
||||
time.Sleep(time.Second * 3)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func watchTDir(dir string) {
|
||||
time.Sleep(5 * time.Second)
|
||||
path, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
path = dir
|
||||
}
|
||||
for {
|
||||
files, err := os.ReadDir(path)
|
||||
if err == nil {
|
||||
for _, file := range files {
|
||||
filename := filepath.Join(path, file.Name())
|
||||
if strings.ToLower(filepath.Ext(file.Name())) == ".torrent" {
|
||||
sp, err := utils.OpenTorrentFile(filename)
|
||||
if err == nil {
|
||||
tor, err := torr.AddTorrent(sp, "", "", "", "")
|
||||
if err == nil {
|
||||
if tor.GotInfo() {
|
||||
if tor.Title == "" {
|
||||
tor.Title = tor.Name()
|
||||
}
|
||||
torr.SaveTorrentToDB(tor)
|
||||
tor.Drop()
|
||||
os.Remove(filename)
|
||||
time.Sleep(time.Second)
|
||||
} else {
|
||||
log.TLogln("Error get info from torrent")
|
||||
}
|
||||
} else {
|
||||
log.TLogln("Error parse torrent file:", err)
|
||||
}
|
||||
} else {
|
||||
log.TLogln("Error parse file name:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.TLogln("Error read dir:", err)
|
||||
}
|
||||
time.Sleep(time.Second * 5)
|
||||
}
|
||||
}
|
||||
|
||||
///////////
|
||||
/// DNS
|
||||
///
|
||||
|
||||
// DNSConfig holds DNS resolver configuration
|
||||
type DNSConfig struct {
|
||||
PrimaryServers []string
|
||||
FallbackServers []string
|
||||
Timeout time.Duration
|
||||
CacheDuration time.Duration
|
||||
}
|
||||
|
||||
// DefaultDNSConfig returns a sensible default configuration
|
||||
func DefaultDNSConfig() DNSConfig {
|
||||
return DNSConfig{
|
||||
PrimaryServers: []string{
|
||||
"8.8.8.8:53", // Google DNS
|
||||
"1.1.1.1:53", // CloudFlare DNS
|
||||
"9.9.9.9:53", // Quad9 DNS
|
||||
},
|
||||
FallbackServers: []string{
|
||||
"208.67.222.222:53", // OpenDNS
|
||||
"64.6.64.6:53", // Verisign
|
||||
},
|
||||
Timeout: 5 * time.Second,
|
||||
CacheDuration: 5 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// DNSChecker manages DNS resolution with fallback support
|
||||
type DNSChecker struct {
|
||||
config DNSConfig
|
||||
customResolver *net.Resolver
|
||||
cache map[string][]string
|
||||
cacheTime map[string]time.Time
|
||||
mu sync.RWMutex
|
||||
useFallback bool
|
||||
}
|
||||
|
||||
// NewDNSChecker creates a new DNS checker instance
|
||||
func NewDNSChecker(config DNSConfig) *DNSChecker {
|
||||
if len(config.PrimaryServers) == 0 {
|
||||
config = DefaultDNSConfig()
|
||||
}
|
||||
|
||||
return &DNSChecker{
|
||||
config: config,
|
||||
cache: make(map[string][]string),
|
||||
cacheTime: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
// CheckAndResolve performs DNS check and returns a resolver
|
||||
func (d *DNSChecker) CheckAndResolve() *net.Resolver {
|
||||
// Test system DNS first
|
||||
if d.testSystemDNS() {
|
||||
log.TLogln("System DNS check passed")
|
||||
return net.DefaultResolver
|
||||
}
|
||||
|
||||
log.TLogln("System DNS check failed, using custom resolver")
|
||||
d.initCustomResolver()
|
||||
return d.customResolver
|
||||
}
|
||||
|
||||
// testSystemDNS checks if system DNS is working properly
|
||||
func (d *DNSChecker) testSystemDNS() bool {
|
||||
_, cancel := context.WithTimeout(context.Background(), d.config.Timeout)
|
||||
defer cancel()
|
||||
|
||||
addrs, err := net.LookupHost("themoviedb.org")
|
||||
if err != nil {
|
||||
log.TLogln("DNS lookup error:", err)
|
||||
return false
|
||||
}
|
||||
|
||||
if len(addrs) == 0 {
|
||||
log.TLogln("DNS lookup returned no addresses")
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for suspicious addresses (DNS hijacking/pollution)
|
||||
for _, addr := range addrs {
|
||||
if isSuspiciousAddress(addr) {
|
||||
log.TLogln("Suspicious DNS address detected:", addr)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// isSuspiciousAddress checks if an address indicates DNS issues
|
||||
func isSuspiciousAddress(addr string) bool {
|
||||
suspiciousPrefixes := []string{
|
||||
"127.0.0.1", // Localhost
|
||||
"0.0.0.0", // Invalid address
|
||||
"::1", // IPv6 localhost
|
||||
// "10.", // Private network
|
||||
"192.168.", // Private network
|
||||
"169.254.", // Link-local
|
||||
// "172.16.", "172.17.", "172.18.", "172.19.",
|
||||
// "172.20.", "172.21.", "172.22.", "172.23.",
|
||||
// "172.24.", "172.25.", "172.26.", "172.27.",
|
||||
// "172.28.", "172.29.", "172.30.", "172.31.", // Private network range
|
||||
}
|
||||
|
||||
for _, prefix := range suspiciousPrefixes {
|
||||
if strings.HasPrefix(addr, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// initCustomResolver creates a custom resolver with fallback support
|
||||
func (d *DNSChecker) initCustomResolver() {
|
||||
d.customResolver = &net.Resolver{
|
||||
PreferGo: true, // Use Go's DNS implementation
|
||||
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
dialer := &net.Dialer{
|
||||
Timeout: d.config.Timeout,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}
|
||||
|
||||
// Try primary servers first
|
||||
for _, dns := range d.config.PrimaryServers {
|
||||
conn, err := dialer.DialContext(ctx, network, dns)
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
log.TLogln("Failed to connect to DNS server", dns, ":", err)
|
||||
}
|
||||
|
||||
// Try fallback servers if primary fails
|
||||
for _, dns := range d.config.FallbackServers {
|
||||
conn, err := dialer.DialContext(ctx, network, dns)
|
||||
if err == nil {
|
||||
log.TLogln("Using fallback DNS server:", dns)
|
||||
return conn, nil
|
||||
}
|
||||
log.TLogln("Failed to connect to fallback DNS", dns, ":", err)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("all DNS servers failed")
|
||||
},
|
||||
}
|
||||
|
||||
d.useFallback = true
|
||||
}
|
||||
|
||||
// LookupHostWithFallback performs DNS lookup with automatic fallback
|
||||
func (d *DNSChecker) LookupHostWithFallback(host string) ([]string, error) {
|
||||
// Check cache first
|
||||
if addrs, ok := d.getFromCache(host); ok {
|
||||
return addrs, nil
|
||||
}
|
||||
|
||||
// Use appropriate resolver
|
||||
var resolver *net.Resolver
|
||||
if d.useFallback && d.customResolver != nil {
|
||||
resolver = d.customResolver
|
||||
} else {
|
||||
resolver = net.DefaultResolver
|
||||
}
|
||||
|
||||
// Perform lookup with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), d.config.Timeout)
|
||||
defer cancel()
|
||||
|
||||
addrs, err := resolver.LookupHost(ctx, host)
|
||||
if err != nil {
|
||||
// If using system DNS fails, try custom resolver
|
||||
if !d.useFallback && d.customResolver != nil {
|
||||
log.TLogln("System DNS failed, trying custom resolver")
|
||||
addrs, err = d.customResolver.LookupHost(ctx, host)
|
||||
}
|
||||
}
|
||||
|
||||
// Cache successful results
|
||||
if err == nil && len(addrs) > 0 {
|
||||
d.addToCache(host, addrs)
|
||||
}
|
||||
|
||||
return addrs, err
|
||||
}
|
||||
|
||||
// getFromCache retrieves DNS results from cache
|
||||
func (d *DNSChecker) getFromCache(host string) ([]string, bool) {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
|
||||
if addrs, ok := d.cache[host]; ok {
|
||||
if time.Since(d.cacheTime[host]) < d.config.CacheDuration {
|
||||
return addrs, true
|
||||
}
|
||||
// Expired, remove from cache
|
||||
delete(d.cache, host)
|
||||
delete(d.cacheTime, host)
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// addToCache adds DNS results to cache
|
||||
func (d *DNSChecker) addToCache(host string, addrs []string) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
d.cache[host] = addrs
|
||||
d.cacheTime[host] = time.Now()
|
||||
}
|
||||
|
||||
// Simple usage function (backward compatible)
|
||||
func dnsResolve() {
|
||||
checker := NewDNSChecker(DefaultDNSConfig())
|
||||
resolver := checker.CheckAndResolve()
|
||||
|
||||
// Store the resolver for later use if needed
|
||||
net.DefaultResolver = resolver // Optional: replace global resolver
|
||||
|
||||
// Test the resolver
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
addrs, err := resolver.LookupHost(ctx, "themoviedb.org")
|
||||
if err != nil {
|
||||
log.TLogln("DNS resolution test failed:", err)
|
||||
} else {
|
||||
log.TLogln("DNS resolution successful, addresses:", addrs)
|
||||
}
|
||||
}
|
||||
|
||||
// func dnsResolve() {
|
||||
// addrs, err := net.LookupHost("themoviedb.org")
|
||||
// if len(addrs) == 0 {
|
||||
// log.TLogln("System DNS check failed", err)
|
||||
|
||||
// fn := func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
// d := net.Dialer{}
|
||||
// return d.DialContext(ctx, "udp", "1.1.1.1:53")
|
||||
// }
|
||||
|
||||
// net.DefaultResolver = &net.Resolver{
|
||||
// Dial: fn,
|
||||
// }
|
||||
|
||||
// addrs, err = net.LookupHost("themoviedb.org")
|
||||
// if err != nil {
|
||||
// log.TLogln("Check CloudFlare DNS error:", err)
|
||||
// } else {
|
||||
// log.TLogln("Use CloudFlare DNS")
|
||||
// }
|
||||
// } else {
|
||||
// log.TLogln("System DNS check passed")
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,55 @@
|
||||
//go:build android
|
||||
// +build android
|
||||
|
||||
package main
|
||||
|
||||
// #cgo LDFLAGS: -static-libstdc++
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"server"
|
||||
"server/log"
|
||||
"server/settings"
|
||||
)
|
||||
|
||||
func Preconfig(dkill bool) {
|
||||
sigc := make(chan os.Signal, 1)
|
||||
signal.Notify(sigc,
|
||||
syscall.SIGHUP,
|
||||
syscall.SIGINT,
|
||||
syscall.SIGTERM,
|
||||
syscall.SIGQUIT)
|
||||
go func() {
|
||||
for s := range sigc {
|
||||
if dkill {
|
||||
if settings.BTsets.EnableDebug || s != syscall.SIGPIPE {
|
||||
log.TLogln("Signal catched:", s)
|
||||
log.TLogln("To stop server, close it from web / api")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
log.TLogln("Signal catched:", s, "stopping server...")
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
server.Stop()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
log.TLogln("Server stopped gracefully")
|
||||
case <-time.After(5 * time.Second):
|
||||
log.TLogln("Server stop timeout, exiting forcefully")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//go:build !windows && !android
|
||||
// +build !windows,!android
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"server"
|
||||
|
||||
"server/log"
|
||||
"server/settings"
|
||||
)
|
||||
|
||||
func Preconfig(dkill bool) {
|
||||
sigc := make(chan os.Signal, 1)
|
||||
signal.Notify(sigc,
|
||||
syscall.SIGHUP,
|
||||
syscall.SIGINT,
|
||||
syscall.SIGTERM,
|
||||
syscall.SIGQUIT)
|
||||
go func() {
|
||||
for s := range sigc {
|
||||
if dkill {
|
||||
if settings.BTsets.EnableDebug || s != syscall.SIGPIPE {
|
||||
log.TLogln("Signal catched:", s)
|
||||
log.TLogln("To stop server, close it from web / api")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
log.TLogln("Signal catched:", s, "stopping server...")
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
server.Stop()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
log.TLogln("Server stopped gracefully")
|
||||
case <-time.After(5 * time.Second):
|
||||
log.TLogln("Server stop timeout, exiting forcefully")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"server/torr"
|
||||
"server/torr/state"
|
||||
)
|
||||
|
||||
const (
|
||||
EsSystemRequired = 0x00000001
|
||||
EsAwaymodeRequired = 0x00000040 // Added for future improvements
|
||||
EsContinuous = 0x80000000
|
||||
)
|
||||
|
||||
var (
|
||||
pulseTime = 60 * time.Second
|
||||
clearFlagTimeout = 3 * 60 * time.Second
|
||||
)
|
||||
|
||||
func Preconfig(kill bool) {
|
||||
go func() {
|
||||
// need work on one thread because SetThreadExecutionState sets flag to thread. We need set and clear flag for same thread.
|
||||
runtime.LockOSThread()
|
||||
// don't sleep/hibernate windows
|
||||
kernel32 := syscall.NewLazyDLL("kernel32.dll")
|
||||
setThreadExecStateProc := kernel32.NewProc("SetThreadExecutionState")
|
||||
currentExecState := uintptr(EsContinuous)
|
||||
normalExecutionState := uintptr(EsContinuous)
|
||||
systemRequireState := uintptr(EsSystemRequired | EsContinuous)
|
||||
pulse := time.NewTicker(pulseTime)
|
||||
var clearFlagTime int64 = -1
|
||||
for {
|
||||
select {
|
||||
case <-pulse.C:
|
||||
{
|
||||
systemRequired := false
|
||||
for _, torrent := range torr.ListTorrent() {
|
||||
if torrent.Stat != state.TorrentInDB {
|
||||
systemRequired = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if systemRequired && currentExecState != systemRequireState {
|
||||
// Looks like sending just EsSystemRequired to clear timer is broken in Win11.
|
||||
// Enable system required to avoid the system to idle to sleep.
|
||||
currentExecState = systemRequireState
|
||||
setThreadExecStateProc.Call(systemRequireState)
|
||||
}
|
||||
|
||||
if !systemRequired && currentExecState != normalExecutionState {
|
||||
// Clear EXECUTION_STATE flags to disable away mode and allow the system to idle to sleep normally.
|
||||
|
||||
// Avoid clear flag immediately to add time to start next episode
|
||||
if clearFlagTime == -1 {
|
||||
clearFlagTime = time.Now().Unix() + int64(clearFlagTimeout.Seconds())
|
||||
}
|
||||
|
||||
if clearFlagTime >= time.Now().Unix() {
|
||||
clearFlagTime = -1
|
||||
currentExecState = normalExecutionState
|
||||
setThreadExecStateProc.Call(normalExecutionState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package dlna
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/anacrolix/dms/dlna/dms"
|
||||
"github.com/anacrolix/log"
|
||||
"github.com/wlynxg/anet"
|
||||
|
||||
"server/settings"
|
||||
"server/web/pages/template"
|
||||
)
|
||||
|
||||
var dmsServer *dms.Server
|
||||
|
||||
func Start() {
|
||||
logger := log.Default.WithNames("dlna")
|
||||
dmsServer = &dms.Server{
|
||||
Logger: logger.WithNames("dms", "server"),
|
||||
Interfaces: func() (ifs []net.Interface) {
|
||||
var err error
|
||||
ifaces, err := anet.Interfaces()
|
||||
if err != nil {
|
||||
logger.Levelf(log.Error, "%v", err)
|
||||
return
|
||||
// os.Exit(1) // avoid start on Android 13+
|
||||
}
|
||||
for _, i := range ifaces {
|
||||
// interface flags seem to always be 0 on Windows
|
||||
if runtime.GOOS != "windows" && (i.Flags&net.FlagLoopback != 0 || i.Flags&net.FlagUp == 0 || i.Flags&net.FlagMulticast == 0) {
|
||||
continue
|
||||
}
|
||||
ifs = append(ifs, i)
|
||||
}
|
||||
return
|
||||
}(),
|
||||
HTTPConn: func() net.Listener {
|
||||
port := 9080
|
||||
for {
|
||||
logger.Levelf(log.Info, "Check dlna port %d", port)
|
||||
m, err := net.Listen("tcp", settings.IP+":"+strconv.Itoa(port))
|
||||
if m != nil {
|
||||
m.Close()
|
||||
}
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
port++
|
||||
}
|
||||
logger.Levelf(log.Info, "Set dlna port %d", port)
|
||||
conn, err := net.Listen("tcp", settings.IP+":"+strconv.Itoa(port))
|
||||
if err != nil {
|
||||
logger.Levelf(log.Error, "%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return conn
|
||||
}(),
|
||||
FriendlyName: getDefaultFriendlyName(),
|
||||
NoTranscode: true,
|
||||
NoProbe: true,
|
||||
StallEventSubscribe: false,
|
||||
Icons: []dms.Icon{
|
||||
{
|
||||
Width: 48,
|
||||
Height: 48,
|
||||
Depth: 24,
|
||||
Mimetype: "image/png",
|
||||
Bytes: template.Dlnaicon48png,
|
||||
},
|
||||
{
|
||||
Width: 120,
|
||||
Height: 120,
|
||||
Depth: 24,
|
||||
Mimetype: "image/png",
|
||||
Bytes: template.Dlnaicon120png,
|
||||
},
|
||||
},
|
||||
LogHeaders: settings.BTsets.EnableDebug,
|
||||
NotifyInterval: 30 * time.Second,
|
||||
AllowedIpNets: func() []*net.IPNet {
|
||||
var nets []*net.IPNet
|
||||
_, ipnet, _ := net.ParseCIDR("0.0.0.0/0")
|
||||
nets = append(nets, ipnet)
|
||||
_, ipnet, _ = net.ParseCIDR("::/0")
|
||||
nets = append(nets, ipnet)
|
||||
return nets
|
||||
}(),
|
||||
OnBrowseDirectChildren: onBrowse,
|
||||
OnBrowseMetadata: onBrowseMeta,
|
||||
}
|
||||
|
||||
if err := dmsServer.Init(); err != nil {
|
||||
logger.Levelf(log.Error, "error initing dms server: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
go func() {
|
||||
if err := dmsServer.Run(); err != nil {
|
||||
logger.Levelf(log.Error, "%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func Stop() {
|
||||
if dmsServer != nil {
|
||||
dmsServer.Close()
|
||||
dmsServer = nil
|
||||
}
|
||||
}
|
||||
|
||||
func onBrowse(path, rootObjectPath, host, userAgent string) (ret []interface{}, err error) {
|
||||
if path == "/" {
|
||||
ret = getRoot()
|
||||
return
|
||||
} else if path == "/TR" {
|
||||
ret = getTorrents()
|
||||
return
|
||||
} else if isHashPath(path) {
|
||||
ret = getTorrent(path, host)
|
||||
return
|
||||
} else if filepath.Base(path) == "LD" {
|
||||
ret = loadTorrent(path, host)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func onBrowseMeta(path string, rootObjectPath string, host, userAgent string) (ret interface{}, err error) {
|
||||
ret = getTorrentMeta(path, host)
|
||||
if ret == nil {
|
||||
err = fmt.Errorf("meta not found")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func getDefaultFriendlyName() string {
|
||||
logger := log.Default.WithNames("dlna")
|
||||
|
||||
if settings.BTsets.FriendlyName != "" {
|
||||
return settings.BTsets.FriendlyName
|
||||
}
|
||||
|
||||
ret := "TorrServer"
|
||||
userName := ""
|
||||
user, err := user.Current()
|
||||
if err != nil {
|
||||
logger.Printf("getDefaultFriendlyName could not get username: %s", err)
|
||||
} else {
|
||||
userName = user.Name
|
||||
}
|
||||
host, err := os.Hostname()
|
||||
if err != nil {
|
||||
logger.Printf("getDefaultFriendlyName could not get hostname: %s", err)
|
||||
}
|
||||
|
||||
if userName == "" && host == "" {
|
||||
return ret
|
||||
}
|
||||
|
||||
if userName != "" && host != "" {
|
||||
if userName == host {
|
||||
return ret + ": " + userName
|
||||
}
|
||||
return ret + ": " + userName + " on " + host
|
||||
}
|
||||
|
||||
if host == "localhost" { // useless host, use 1st IP
|
||||
ifaces, err := anet.Interfaces()
|
||||
if err != nil {
|
||||
return ret + ": " + userName + "@" + host
|
||||
}
|
||||
var list []string
|
||||
for _, i := range ifaces {
|
||||
// interface flags seem to always be 0 on Windows
|
||||
if runtime.GOOS != "windows" && (i.Flags&net.FlagLoopback != 0 || i.Flags&net.FlagUp == 0 || i.Flags&net.FlagMulticast == 0) {
|
||||
continue
|
||||
}
|
||||
addrs, _ := anet.InterfaceAddrsByInterface(&i)
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
if !ip.IsLoopback() && ip.To4() != nil {
|
||||
list = append(list, ip.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(list) > 0 {
|
||||
sort.Strings(list)
|
||||
return ret + " " + list[0]
|
||||
}
|
||||
}
|
||||
return ret + ": " + userName + "@" + host
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package dlna
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/anacrolix/dms/dlna"
|
||||
"github.com/anacrolix/dms/upnpav"
|
||||
|
||||
"server/log"
|
||||
mt "server/mimetype"
|
||||
"server/settings"
|
||||
"server/torr"
|
||||
"server/torr/state"
|
||||
)
|
||||
|
||||
func getRoot() (ret []interface{}) {
|
||||
// Torrents Object (ROOT)
|
||||
tObj := upnpav.Object{
|
||||
ID: "%2FTR",
|
||||
ParentID: "0",
|
||||
Restricted: 1,
|
||||
Title: "Torrents",
|
||||
Class: "object.container.storageFolder",
|
||||
Date: upnpav.Timestamp{Time: time.Now()},
|
||||
}
|
||||
|
||||
// add Torrents Object
|
||||
vol := len(torr.ListTorrent())
|
||||
cnt := upnpav.Container{Object: tObj, ChildCount: vol}
|
||||
ret = append(ret, cnt)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func getTorrents() (ret []interface{}) {
|
||||
torrs := torr.ListTorrent()
|
||||
// sort by title as in cds SortCaps
|
||||
sort.Slice(torrs, func(i, j int) bool {
|
||||
return torrs[i].Title < torrs[j].Title
|
||||
})
|
||||
|
||||
vol := 0
|
||||
for _, t := range torrs {
|
||||
vol++
|
||||
obj := upnpav.Object{
|
||||
ID: "%2F" + t.TorrentSpec.InfoHash.HexString(),
|
||||
ParentID: "%2FTR",
|
||||
Restricted: 1,
|
||||
Title: strings.ReplaceAll(t.Title, "/", "|"),
|
||||
Class: "object.container.storageFolder",
|
||||
Icon: t.Poster,
|
||||
AlbumArtURI: t.Poster,
|
||||
Date: upnpav.Timestamp{Time: time.Unix(t.Timestamp, 0)}, // time.Now()
|
||||
}
|
||||
cnt := upnpav.Container{Object: obj, ChildCount: 1}
|
||||
ret = append(ret, cnt)
|
||||
}
|
||||
if vol == 0 {
|
||||
obj := upnpav.Object{
|
||||
ID: "%2FNT",
|
||||
ParentID: "%2FTR",
|
||||
Restricted: 1,
|
||||
Title: "No Torrents",
|
||||
Class: "object.container.storageFolder",
|
||||
Date: upnpav.Timestamp{Time: time.Now()},
|
||||
}
|
||||
cnt := upnpav.Container{Object: obj, ChildCount: 0}
|
||||
ret = append(ret, cnt)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func getTorrent(path, host string) (ret []interface{}) {
|
||||
// find torrent without load
|
||||
torrs := torr.ListTorrent()
|
||||
var torr *torr.Torrent
|
||||
for _, t := range torrs {
|
||||
if strings.Contains(path, t.TorrentSpec.InfoHash.HexString()) {
|
||||
torr = t
|
||||
break
|
||||
}
|
||||
}
|
||||
if torr == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// get content from torrent
|
||||
parent := "%2F" + torr.TorrentSpec.InfoHash.HexString()
|
||||
// if torrent not loaded, get button for load
|
||||
if torr.Files() == nil {
|
||||
obj := upnpav.Object{
|
||||
ID: parent + "%2FLD",
|
||||
ParentID: parent,
|
||||
Restricted: 1,
|
||||
Title: "Load Torrent",
|
||||
Class: "object.container.storageFolder",
|
||||
Date: upnpav.Timestamp{Time: time.Now()},
|
||||
}
|
||||
cnt := upnpav.Container{Object: obj, ChildCount: 1}
|
||||
ret = append(ret, cnt)
|
||||
return
|
||||
}
|
||||
|
||||
ret = loadTorrent(path, host)
|
||||
return
|
||||
}
|
||||
|
||||
func getTorrentMeta(path, host string) (ret interface{}) {
|
||||
// Meta object
|
||||
if path == "/" {
|
||||
// root object meta
|
||||
rootObj := upnpav.Object{
|
||||
ID: "0",
|
||||
ParentID: "-1",
|
||||
Restricted: 1,
|
||||
Searchable: 1,
|
||||
Title: "TorrServer",
|
||||
Date: upnpav.Timestamp{Time: time.Now()},
|
||||
Class: "object.container.storageFolder",
|
||||
}
|
||||
meta := upnpav.Container{Object: rootObj, ChildCount: 1}
|
||||
return meta
|
||||
} else if filepath.Base(path) == "TR" {
|
||||
// TR Object Meta
|
||||
trObj := upnpav.Object{
|
||||
ID: "%2FTR",
|
||||
ParentID: "0",
|
||||
Restricted: 1,
|
||||
Searchable: 1,
|
||||
Title: "Torrents",
|
||||
Date: upnpav.Timestamp{Time: time.Now()},
|
||||
Class: "object.container.storageFolder",
|
||||
}
|
||||
torrs := torr.ListTorrent()
|
||||
vol := len(torrs)
|
||||
meta := upnpav.Container{Object: trObj, ChildCount: vol}
|
||||
return meta
|
||||
} else if isHashPath(path) {
|
||||
// find torrent without load
|
||||
torrs := torr.ListTorrent()
|
||||
var torr *torr.Torrent
|
||||
for _, t := range torrs {
|
||||
if strings.Contains(path, t.TorrentSpec.InfoHash.HexString()) {
|
||||
torr = t
|
||||
break
|
||||
}
|
||||
}
|
||||
if torr == nil {
|
||||
return nil
|
||||
}
|
||||
// hash object meta
|
||||
obj := upnpav.Object{
|
||||
ID: "%2F" + torr.TorrentSpec.InfoHash.HexString(),
|
||||
ParentID: "%2FTR",
|
||||
Restricted: 1,
|
||||
Title: torr.Title,
|
||||
Date: upnpav.Timestamp{Time: time.Unix(torr.Timestamp, 0)}, // time.Now()
|
||||
}
|
||||
meta := upnpav.Container{Object: obj, ChildCount: 1}
|
||||
return meta
|
||||
} else if filepath.Base(path) == "LD" {
|
||||
parent := url.PathEscape(filepath.Dir(path))
|
||||
// LD object meta
|
||||
obj := upnpav.Object{
|
||||
ID: parent + "%2FLD",
|
||||
ParentID: parent,
|
||||
Restricted: 1,
|
||||
Searchable: 1,
|
||||
Title: "Load Torrents",
|
||||
Date: upnpav.Timestamp{Time: time.Now()},
|
||||
}
|
||||
meta := upnpav.Container{Object: obj, ChildCount: 1}
|
||||
return meta
|
||||
} else {
|
||||
file := filepath.Base(path)
|
||||
id := url.PathEscape(path)
|
||||
parent := url.PathEscape(filepath.Dir(path))
|
||||
// file object meta
|
||||
obj := upnpav.Object{
|
||||
ID: id,
|
||||
ParentID: parent,
|
||||
Restricted: 1,
|
||||
Searchable: 1,
|
||||
Title: file,
|
||||
Date: upnpav.Timestamp{Time: time.Now()},
|
||||
}
|
||||
meta := upnpav.Container{Object: obj, ChildCount: 1}
|
||||
return meta
|
||||
}
|
||||
}
|
||||
|
||||
func loadTorrent(path, host string) (ret []interface{}) {
|
||||
hash := filepath.Base(filepath.Dir(path))
|
||||
if hash == "/" || hash == "\\" {
|
||||
hash = filepath.Base(path)
|
||||
}
|
||||
if len(hash) != 40 {
|
||||
return
|
||||
}
|
||||
|
||||
tor := torr.GetTorrent(hash)
|
||||
if tor == nil {
|
||||
log.TLogln("Dlna error get info from torrent", hash)
|
||||
return
|
||||
}
|
||||
if len(tor.Files()) == 0 {
|
||||
time.Sleep(time.Millisecond * 200)
|
||||
timeout := time.Now().Add(time.Second * 60)
|
||||
for {
|
||||
tor = torr.GetTorrent(hash)
|
||||
if len(tor.Files()) > 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Millisecond * 200)
|
||||
if time.Now().After(timeout) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
parent := "%2F" + tor.TorrentSpec.InfoHash.HexString()
|
||||
files := tor.Status().FileStats
|
||||
for _, f := range files {
|
||||
obj := getObjFromTorrent(path, parent, host, tor, f)
|
||||
if obj != nil {
|
||||
ret = append(ret, obj)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func getLink(host, path string) string {
|
||||
if !strings.HasPrefix(host, "http") {
|
||||
host = "http://" + host
|
||||
}
|
||||
pos := strings.LastIndex(host, ":")
|
||||
if pos > 7 {
|
||||
host = host[:pos]
|
||||
}
|
||||
return host + ":" + settings.Port + "/" + path
|
||||
}
|
||||
|
||||
func getObjFromTorrent(path, parent, host string, torr *torr.Torrent, file *state.TorrentFileStat) (ret interface{}) {
|
||||
mime, err := mt.MimeTypeByPath(file.Path)
|
||||
if err != nil {
|
||||
if settings.BTsets.EnableDebug {
|
||||
log.TLogln("Can't detect mime type", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
// TODO: handle subtitles for media
|
||||
if !mime.IsMedia() {
|
||||
return
|
||||
}
|
||||
if settings.BTsets.EnableDebug {
|
||||
log.TLogln("mime type", mime.String(), file.Path)
|
||||
}
|
||||
|
||||
obj := upnpav.Object{
|
||||
ID: parent + "%2F" + url.PathEscape(file.Path),
|
||||
ParentID: parent,
|
||||
Restricted: 1,
|
||||
Title: file.Path,
|
||||
Class: "object.item." + mime.Type() + "Item",
|
||||
Date: upnpav.Timestamp{Time: time.Now()},
|
||||
}
|
||||
|
||||
item := upnpav.Item{
|
||||
Object: obj,
|
||||
Res: make([]upnpav.Resource, 0, 1),
|
||||
}
|
||||
// pathPlay := "stream/" + url.PathEscape(file.Path) + "?link=" + torr.TorrentSpec.InfoHash.HexString() + "&play&index=" + strconv.Itoa(file.Id)
|
||||
pathPlay := "play/" + torr.TorrentSpec.InfoHash.HexString() + "/" + strconv.Itoa(file.Id)
|
||||
item.Res = append(item.Res, upnpav.Resource{
|
||||
URL: getLink(host, pathPlay),
|
||||
ProtocolInfo: fmt.Sprintf("http-get:*:%s:%s", mime, dlna.ContentFeatures{
|
||||
SupportRange: true,
|
||||
SupportTimeSeek: true,
|
||||
}.String()),
|
||||
Size: uint64(file.Length),
|
||||
})
|
||||
return item
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package dlna
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func isHashPath(path string) bool {
|
||||
base := filepath.Base(path)
|
||||
if len(base) == 40 {
|
||||
data := []byte(base)
|
||||
for _, v := range data {
|
||||
if !(v >= 48 && v <= 57 || v >= 65 && v <= 70 || v >= 97 && v <= 102) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
+1316
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,866 @@
|
||||
basePath: /
|
||||
definitions:
|
||||
api.cacheReqJS:
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
hash:
|
||||
type: string
|
||||
type: object
|
||||
api.setsReqJS:
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
sets:
|
||||
$ref: '#/definitions/settings.BTSets'
|
||||
type: object
|
||||
api.torrReqJS:
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
category:
|
||||
type: string
|
||||
data:
|
||||
type: string
|
||||
filter:
|
||||
type: string
|
||||
hash:
|
||||
type: string
|
||||
link:
|
||||
type: string
|
||||
poster:
|
||||
type: string
|
||||
save_to_db:
|
||||
type: boolean
|
||||
title:
|
||||
type: string
|
||||
type: object
|
||||
api.viewedReqJS:
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
file_index:
|
||||
type: integer
|
||||
hash:
|
||||
type: string
|
||||
type: object
|
||||
models.TorrentDetails:
|
||||
properties:
|
||||
audioQuality:
|
||||
type: integer
|
||||
categories:
|
||||
type: string
|
||||
createDate:
|
||||
type: string
|
||||
hash:
|
||||
type: string
|
||||
imdbid:
|
||||
type: string
|
||||
link:
|
||||
type: string
|
||||
magnet:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
names:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
peer:
|
||||
type: integer
|
||||
seed:
|
||||
type: integer
|
||||
size:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
tracker:
|
||||
type: string
|
||||
videoQuality:
|
||||
type: integer
|
||||
year:
|
||||
type: integer
|
||||
type: object
|
||||
settings.BTSets:
|
||||
properties:
|
||||
cacheSize:
|
||||
description: Cache
|
||||
format: int64
|
||||
type: integer
|
||||
connectionsLimit:
|
||||
type: integer
|
||||
disableDHT:
|
||||
type: boolean
|
||||
disablePEX:
|
||||
type: boolean
|
||||
disableTCP:
|
||||
type: boolean
|
||||
disableUPNP:
|
||||
type: boolean
|
||||
disableUTP:
|
||||
type: boolean
|
||||
disableUpload:
|
||||
type: boolean
|
||||
downloadRateLimit:
|
||||
description: in kb, 0 - inf
|
||||
type: integer
|
||||
enableDLNA:
|
||||
description: DLNA
|
||||
type: boolean
|
||||
enableDebug:
|
||||
description: debug logs
|
||||
type: boolean
|
||||
enableIPv6:
|
||||
description: BT Config
|
||||
type: boolean
|
||||
enableProxy:
|
||||
description: P2P Proxy
|
||||
type: boolean
|
||||
enableRutorSearch:
|
||||
description: Rutor
|
||||
type: boolean
|
||||
enableTorznabSearch:
|
||||
description: Torznab
|
||||
type: boolean
|
||||
forceEncrypt:
|
||||
description: Torrent
|
||||
type: boolean
|
||||
friendlyName:
|
||||
type: string
|
||||
peersListenPort:
|
||||
type: integer
|
||||
preloadCache:
|
||||
description: in percent
|
||||
type: integer
|
||||
proxyHosts:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
readerReadAHead:
|
||||
description: in percent, 5%-100%, [...S__X__E...] [S-E] not clean
|
||||
type: integer
|
||||
removeCacheOnDrop:
|
||||
type: boolean
|
||||
responsiveMode:
|
||||
description: Reader
|
||||
type: boolean
|
||||
retrackersMode:
|
||||
description: 0 - don`t add, 1 - add retrackers (def), 2 - remove retrackers
|
||||
3 - replace retrackers
|
||||
type: integer
|
||||
showFSActiveTorr:
|
||||
description: FS
|
||||
type: boolean
|
||||
sslCert:
|
||||
type: string
|
||||
sslKey:
|
||||
type: string
|
||||
sslPort:
|
||||
description: HTTPS
|
||||
type: integer
|
||||
storeSettingsInJson:
|
||||
description: Storage preferences
|
||||
type: boolean
|
||||
storeViewedInJson:
|
||||
type: boolean
|
||||
tmdbsettings:
|
||||
allOf:
|
||||
- $ref: '#/definitions/settings.TMDBConfig'
|
||||
description: TMDB
|
||||
torrentDisconnectTimeout:
|
||||
description: in seconds
|
||||
type: integer
|
||||
torrentsSavePath:
|
||||
type: string
|
||||
torznabUrls:
|
||||
items:
|
||||
$ref: '#/definitions/settings.TorznabConfig'
|
||||
type: array
|
||||
uploadRateLimit:
|
||||
description: in kb, 0 - inf
|
||||
type: integer
|
||||
useDisk:
|
||||
description: Disk
|
||||
type: boolean
|
||||
type: object
|
||||
settings.TMDBConfig:
|
||||
properties:
|
||||
apikey:
|
||||
description: TMDB API Key
|
||||
type: string
|
||||
apiurl:
|
||||
description: 'Base API URL (default: https://api.themoviedb.org)'
|
||||
type: string
|
||||
imageURL:
|
||||
description: 'Image URL (default: https://image.tmdb.org)'
|
||||
type: string
|
||||
imageURLRu:
|
||||
description: 'Image URL for Russian users (default: https://imagetmdb.com)'
|
||||
type: string
|
||||
type: object
|
||||
settings.TorznabConfig:
|
||||
properties:
|
||||
host:
|
||||
type: string
|
||||
key:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
settings.Viewed:
|
||||
properties:
|
||||
file_index:
|
||||
type: integer
|
||||
hash:
|
||||
type: string
|
||||
type: object
|
||||
state.CacheState:
|
||||
properties:
|
||||
capacity:
|
||||
format: int64
|
||||
type: integer
|
||||
filled:
|
||||
format: int64
|
||||
type: integer
|
||||
hash:
|
||||
type: string
|
||||
pieces:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/state.ItemState'
|
||||
type: object
|
||||
piecesCount:
|
||||
type: integer
|
||||
piecesLength:
|
||||
format: int64
|
||||
type: integer
|
||||
readers:
|
||||
items:
|
||||
$ref: '#/definitions/state.ReaderState'
|
||||
type: array
|
||||
torrent:
|
||||
$ref: '#/definitions/state.TorrentStatus'
|
||||
type: object
|
||||
state.ItemState:
|
||||
properties:
|
||||
completed:
|
||||
type: boolean
|
||||
id:
|
||||
type: integer
|
||||
length:
|
||||
format: int64
|
||||
type: integer
|
||||
priority:
|
||||
type: integer
|
||||
size:
|
||||
format: int64
|
||||
type: integer
|
||||
type: object
|
||||
state.ReaderState:
|
||||
properties:
|
||||
end:
|
||||
type: integer
|
||||
reader:
|
||||
type: integer
|
||||
start:
|
||||
type: integer
|
||||
type: object
|
||||
state.TorrentFileStat:
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
length:
|
||||
type: integer
|
||||
path:
|
||||
type: string
|
||||
type: object
|
||||
state.TorrentStat:
|
||||
enum:
|
||||
- 0
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
- 4
|
||||
- 5
|
||||
type: integer
|
||||
x-enum-varnames:
|
||||
- TorrentAdded
|
||||
- TorrentGettingInfo
|
||||
- TorrentPreload
|
||||
- TorrentWorking
|
||||
- TorrentClosed
|
||||
- TorrentInDB
|
||||
state.TorrentStatus:
|
||||
properties:
|
||||
active_peers:
|
||||
type: integer
|
||||
bit_rate:
|
||||
type: string
|
||||
bytes_read:
|
||||
type: integer
|
||||
bytes_read_data:
|
||||
type: integer
|
||||
bytes_read_useful_data:
|
||||
type: integer
|
||||
bytes_written:
|
||||
type: integer
|
||||
bytes_written_data:
|
||||
type: integer
|
||||
category:
|
||||
type: string
|
||||
chunks_read:
|
||||
type: integer
|
||||
chunks_read_useful:
|
||||
type: integer
|
||||
chunks_read_wasted:
|
||||
type: integer
|
||||
chunks_written:
|
||||
type: integer
|
||||
connected_seeders:
|
||||
type: integer
|
||||
data:
|
||||
type: string
|
||||
download_speed:
|
||||
type: number
|
||||
duration_seconds:
|
||||
type: number
|
||||
file_stats:
|
||||
items:
|
||||
$ref: '#/definitions/state.TorrentFileStat'
|
||||
type: array
|
||||
half_open_peers:
|
||||
type: integer
|
||||
hash:
|
||||
type: string
|
||||
loaded_size:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
pending_peers:
|
||||
type: integer
|
||||
pieces_dirtied_bad:
|
||||
type: integer
|
||||
pieces_dirtied_good:
|
||||
type: integer
|
||||
poster:
|
||||
type: string
|
||||
preload_size:
|
||||
type: integer
|
||||
preloaded_bytes:
|
||||
type: integer
|
||||
stat:
|
||||
$ref: '#/definitions/state.TorrentStat'
|
||||
stat_string:
|
||||
type: string
|
||||
timestamp:
|
||||
type: integer
|
||||
title:
|
||||
type: string
|
||||
torrent_size:
|
||||
type: integer
|
||||
torrs_hash:
|
||||
type: string
|
||||
total_peers:
|
||||
type: integer
|
||||
upload_speed:
|
||||
type: number
|
||||
type: object
|
||||
externalDocs:
|
||||
description: OpenAPI
|
||||
url: https://swagger.io/resources/open-api/
|
||||
info:
|
||||
contact: {}
|
||||
description: Torrent streaming server.
|
||||
license:
|
||||
name: GPL 3.0
|
||||
title: Swagger Torrserver API
|
||||
version: '{version.Version}'
|
||||
paths:
|
||||
/cache:
|
||||
post:
|
||||
description: Return cache stats.
|
||||
parameters:
|
||||
- description: Cache stats request
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/api.cacheReqJS'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Cache stats
|
||||
schema:
|
||||
$ref: '#/definitions/state.CacheState'
|
||||
summary: Return cache stats
|
||||
tags:
|
||||
- API
|
||||
/download/{size}:
|
||||
get:
|
||||
description: Download the test file of given size (for speed testing purpose).
|
||||
parameters:
|
||||
- description: Test file size (in MB)
|
||||
in: path
|
||||
name: size
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/octet-stream
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
type: file
|
||||
summary: Generates test file of given size
|
||||
tags:
|
||||
- API
|
||||
/echo:
|
||||
get:
|
||||
description: Tests whether server is alive or not
|
||||
produces:
|
||||
- text/plain
|
||||
responses:
|
||||
"200":
|
||||
description: Server version
|
||||
schema:
|
||||
type: string
|
||||
summary: Tests server status
|
||||
tags:
|
||||
- API
|
||||
/ffp/{hash}/{id}:
|
||||
get:
|
||||
description: Gather informations using ffprobe.
|
||||
parameters:
|
||||
- description: Torrent hash
|
||||
in: path
|
||||
name: hash
|
||||
required: true
|
||||
type: string
|
||||
- description: File index in torrent
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Data returned from ffprobe
|
||||
summary: Gather informations using ffprobe
|
||||
tags:
|
||||
- API
|
||||
/magnets:
|
||||
get:
|
||||
description: Get HTML of magnet links.
|
||||
produces:
|
||||
- text/html
|
||||
responses:
|
||||
"200":
|
||||
description: HTML with Magnet links
|
||||
summary: Get HTML of magnet links
|
||||
tags:
|
||||
- Pages
|
||||
/play/{hash}/{id}:
|
||||
get:
|
||||
description: Play given torrent referenced by infohash and file id.
|
||||
parameters:
|
||||
- description: Torrent infohash
|
||||
in: path
|
||||
name: hash
|
||||
required: true
|
||||
type: string
|
||||
- description: File index in torrent
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/octet-stream
|
||||
responses:
|
||||
"200":
|
||||
description: Torrent data
|
||||
summary: Play given torrent by infohash
|
||||
tags:
|
||||
- API
|
||||
/playlist:
|
||||
get:
|
||||
description: Get HTTP link of torrent in M3U list.
|
||||
parameters:
|
||||
- description: Torrent hash
|
||||
in: query
|
||||
name: hash
|
||||
required: true
|
||||
type: string
|
||||
- description: From last play file
|
||||
in: query
|
||||
name: fromlast
|
||||
type: boolean
|
||||
produces:
|
||||
- audio/x-mpegurl
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
type: file
|
||||
summary: Get HTTP link of torrent in M3U list
|
||||
tags:
|
||||
- API
|
||||
/playlistall/all.m3u:
|
||||
get:
|
||||
description: Retrieve all torrents and generates a bundled M3U playlist.
|
||||
produces:
|
||||
- audio/x-mpegurl
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
type: file
|
||||
summary: Get a M3U playlist with all torrents
|
||||
tags:
|
||||
- API
|
||||
/search:
|
||||
get:
|
||||
description: Makes a rutor search.
|
||||
parameters:
|
||||
- description: Rutor query
|
||||
in: query
|
||||
name: query
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Rutor torrent search result(s)
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/models.TorrentDetails'
|
||||
type: array
|
||||
summary: Makes a rutor search
|
||||
tags:
|
||||
- API
|
||||
/settings:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Allow to get or set server settings.
|
||||
parameters:
|
||||
- description: 'Settings request. Available params for action: get, set, def'
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/api.setsReqJS'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Settings JSON or nothing. Depends on what action has been asked.
|
||||
schema:
|
||||
$ref: '#/definitions/settings.BTSets'
|
||||
summary: Get / Set server settings
|
||||
tags:
|
||||
- API
|
||||
/shutdown:
|
||||
get:
|
||||
description: Gracefully shuts down server after 1 second.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
summary: Shuts down server
|
||||
tags:
|
||||
- API
|
||||
/stat:
|
||||
get:
|
||||
description: Show server and torrents statistics.
|
||||
produces:
|
||||
- text/plain
|
||||
responses:
|
||||
"200":
|
||||
description: TorrServer statistics
|
||||
summary: TorrServer Statistics
|
||||
tags:
|
||||
- Pages
|
||||
/storage/settings:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Retrieves the current storage preferences for settings and viewed
|
||||
history
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Storage preferences
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Get storage configuration settings
|
||||
tags:
|
||||
- API
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
- application/x-www-form-urlencoded
|
||||
description: Updates the storage preferences for settings and viewed history.
|
||||
Requires application restart for changes to take effect.
|
||||
parameters:
|
||||
- description: Storage preferences to update
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
- description: Settings storage type
|
||||
enum:
|
||||
- json
|
||||
- bbolt
|
||||
in: formData
|
||||
name: settings
|
||||
type: string
|
||||
- description: Viewed history storage type
|
||||
enum:
|
||||
- json
|
||||
- bbolt
|
||||
in: formData
|
||||
name: viewed
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Update successful
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"400":
|
||||
description: Invalid input data
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"403":
|
||||
description: Read-only mode
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Update storage configuration settings
|
||||
tags:
|
||||
- API
|
||||
/stream:
|
||||
get:
|
||||
description: Multi usage endpoint.
|
||||
parameters:
|
||||
- description: Magnet/hash/link to torrent
|
||||
in: query
|
||||
name: link
|
||||
required: true
|
||||
type: string
|
||||
- description: File index in torrent
|
||||
in: query
|
||||
name: index
|
||||
type: string
|
||||
- description: Should preload torrent
|
||||
in: query
|
||||
name: preload
|
||||
type: string
|
||||
- description: Get statistics from torrent
|
||||
in: query
|
||||
name: stat
|
||||
type: string
|
||||
- description: Should save torrent
|
||||
in: query
|
||||
name: save
|
||||
type: string
|
||||
- description: Get torrent as M3U playlist
|
||||
in: query
|
||||
name: m3u
|
||||
type: string
|
||||
- description: Get M3U from last played file
|
||||
in: query
|
||||
name: fromlast
|
||||
type: string
|
||||
- description: Start stream torrent
|
||||
in: query
|
||||
name: play
|
||||
type: string
|
||||
- description: Set title of torrent
|
||||
in: query
|
||||
name: title
|
||||
type: string
|
||||
- description: Set poster link of torrent
|
||||
in: query
|
||||
name: poster
|
||||
type: string
|
||||
- description: 'Set category of torrent, used in web: movie, tv, music, other'
|
||||
in: query
|
||||
name: category
|
||||
type: string
|
||||
produces:
|
||||
- application/octet-stream
|
||||
responses:
|
||||
"200":
|
||||
description: Data returned according to query
|
||||
summary: Multi usage endpoint
|
||||
tags:
|
||||
- API
|
||||
/tmdb/settings:
|
||||
get:
|
||||
description: Get TMDB API configuration
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: TMDB settings
|
||||
schema:
|
||||
$ref: '#/definitions/settings.TMDBConfig'
|
||||
summary: Get TMDB settings
|
||||
tags:
|
||||
- API
|
||||
/torrent/upload:
|
||||
post:
|
||||
consumes:
|
||||
- multipart/form-data
|
||||
description: Supports multiple files. Returns array of statuses.
|
||||
parameters:
|
||||
- description: Torrent file(s) to insert
|
||||
in: formData
|
||||
name: file
|
||||
required: true
|
||||
type: file
|
||||
- description: Save to DB
|
||||
in: formData
|
||||
name: save
|
||||
type: string
|
||||
- description: Torrent title (single file only)
|
||||
in: formData
|
||||
name: title
|
||||
type: string
|
||||
- description: Torrent category
|
||||
in: formData
|
||||
name: category
|
||||
type: string
|
||||
- description: Torrent poster (single file only)
|
||||
in: formData
|
||||
name: poster
|
||||
type: string
|
||||
- description: Torrent data
|
||||
in: formData
|
||||
name: data
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Torrent statuses
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/state.TorrentStatus'
|
||||
type: array
|
||||
summary: Add .torrent files
|
||||
tags:
|
||||
- API
|
||||
/torrents:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Allow to list, add, remove, get, set, drop, wipe torrents on server.
|
||||
The action depends of what has been asked.
|
||||
parameters:
|
||||
- description: 'Torrent request. Available params for action: add, get, set,
|
||||
rem, list, drop, wipe. link required for add, hash required for get, set,
|
||||
rem, drop.'
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/api.torrReqJS'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
summary: Handle torrents informations
|
||||
tags:
|
||||
- API
|
||||
/torznab/search:
|
||||
get:
|
||||
description: Makes a torznab search.
|
||||
parameters:
|
||||
- description: Torznab query
|
||||
in: query
|
||||
name: query
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Torznab torrent search result(s)
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/models.TorrentDetails'
|
||||
type: array
|
||||
summary: Makes a torznab search
|
||||
tags:
|
||||
- API
|
||||
/viewed:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Allow to set, list or remove viewed torrents from server.
|
||||
parameters:
|
||||
- description: 'Viewed torrent request. Available params for action: set, rem,
|
||||
list'
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/api.viewedReqJS'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/settings.Viewed'
|
||||
type: array
|
||||
summary: Set / List / Remove viewed torrents
|
||||
tags:
|
||||
- API
|
||||
securityDefinitions:
|
||||
BasicAuth:
|
||||
type: basic
|
||||
swagger: "2.0"
|
||||
@@ -0,0 +1,52 @@
|
||||
package ffprobe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gopkg.in/vansante/go-ffprobe.v2"
|
||||
)
|
||||
|
||||
var binFile = "ffprobe"
|
||||
|
||||
func init() {
|
||||
path, err := exec.LookPath("ffprobe")
|
||||
if err == nil {
|
||||
ffprobe.SetFFProbeBinPath(path)
|
||||
binFile = path
|
||||
} else {
|
||||
// working dir
|
||||
if _, err := os.Stat("ffprobe"); os.IsNotExist(err) {
|
||||
ffprobe.SetFFProbeBinPath(filepath.Dir(os.Args[0]) + "/ffprobe")
|
||||
binFile = filepath.Dir(os.Args[0]) + "/ffprobe"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Exists() bool {
|
||||
_, err := os.Stat(binFile)
|
||||
return !os.IsNotExist(err)
|
||||
}
|
||||
|
||||
func ProbeUrl(link string) (*ffprobe.ProbeData, error) {
|
||||
data, err := ffprobe.ProbeURL(getCtx(), link)
|
||||
return data, err
|
||||
}
|
||||
|
||||
func ProbeReader(reader io.Reader) (*ffprobe.ProbeData, error) {
|
||||
data, err := ffprobe.ProbeReader(getCtx(), reader)
|
||||
return data, err
|
||||
}
|
||||
|
||||
func getCtx() context.Context {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(5 * time.Minute)
|
||||
cancel()
|
||||
}()
|
||||
return ctx
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
module server
|
||||
|
||||
go 1.25
|
||||
|
||||
replace (
|
||||
github.com/anacrolix/torrent v1.59.1 => github.com/tsynik/torrent v1.2.22
|
||||
github.com/anacrolix/upnp v0.1.4 => github.com/tsynik/upnp v0.1.5
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/YouROK/tunsgo v0.0.8
|
||||
github.com/agnivade/levenshtein v1.2.1
|
||||
github.com/alexflint/go-arg v1.6.0
|
||||
github.com/anacrolix/dms v1.7.2
|
||||
github.com/anacrolix/log v0.17.0
|
||||
github.com/anacrolix/missinggo/v2 v2.10.0
|
||||
github.com/anacrolix/publicip v0.3.1
|
||||
github.com/anacrolix/torrent v1.59.1
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/gin-contrib/cors v1.7.6
|
||||
github.com/gin-contrib/location/v2 v2.0.0
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/hanwen/go-fuse/v2 v2.9.0
|
||||
github.com/kljensen/snowball v0.10.0
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.1
|
||||
github.com/swaggo/swag v1.16.6
|
||||
github.com/wlynxg/anet v0.0.5
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96
|
||||
golang.org/x/image v0.33.0
|
||||
golang.org/x/net v0.49.0
|
||||
golang.org/x/time v0.14.0
|
||||
gopkg.in/telebot.v4 v4.0.0-beta.7
|
||||
gopkg.in/vansante/go-ffprobe.v2 v2.2.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/RoaringBitmap/roaring v1.9.4 // indirect
|
||||
github.com/alecthomas/atomic v0.1.0-alpha2 // indirect
|
||||
github.com/alexflint/go-scalar v1.2.0 // indirect
|
||||
github.com/anacrolix/chansync v0.7.0 // indirect
|
||||
github.com/anacrolix/dht/v2 v2.23.0 // indirect
|
||||
github.com/anacrolix/envpprof v1.4.0 // indirect
|
||||
github.com/anacrolix/ffprobe v1.1.0 // indirect
|
||||
github.com/anacrolix/generics v0.1.0 // indirect
|
||||
github.com/anacrolix/missinggo v1.3.0 // indirect
|
||||
github.com/anacrolix/missinggo/perf v1.0.0 // indirect
|
||||
github.com/anacrolix/multiless v0.4.0 // indirect
|
||||
github.com/anacrolix/stm v0.5.0 // indirect
|
||||
github.com/anacrolix/sync v0.5.4 // indirect
|
||||
github.com/anacrolix/upnp v0.1.4 // indirect
|
||||
github.com/anacrolix/utp v0.2.0 // indirect
|
||||
github.com/benbjohnson/clock v1.3.5 // indirect
|
||||
github.com/benbjohnson/immutable v0.4.3 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/dunglas/httpsfv v1.1.0 // indirect
|
||||
github.com/edsrzf/mmap-go v1.2.0 // indirect
|
||||
github.com/filecoin-project/go-clock v0.1.0 // indirect
|
||||
github.com/flynn/noise v1.1.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.3 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.3 // indirect
|
||||
github.com/go-openapi/spec v0.22.1 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.28.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.0 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/google/gopacket v1.1.19 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||
github.com/huandu/xstrings v1.5.0 // indirect
|
||||
github.com/huin/goupnp v1.3.0 // indirect
|
||||
github.com/ipfs/boxo v0.36.0 // indirect
|
||||
github.com/ipfs/go-cid v0.6.0 // indirect
|
||||
github.com/ipfs/go-datastore v0.9.1 // indirect
|
||||
github.com/ipfs/go-log/v2 v2.9.1 // indirect
|
||||
github.com/ipld/go-ipld-prime v0.21.0 // indirect
|
||||
github.com/jackpal/go-nat-pmp v1.0.2 // indirect
|
||||
github.com/jbenet/go-temp-err-catcher v0.1.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/koron/go-ssdp v0.0.6 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/libp2p/go-buffer-pool v0.1.0 // indirect
|
||||
github.com/libp2p/go-cidranger v1.1.0 // indirect
|
||||
github.com/libp2p/go-flow-metrics v0.3.0 // indirect
|
||||
github.com/libp2p/go-libp2p v0.47.0 // indirect
|
||||
github.com/libp2p/go-libp2p-asn-util v0.4.1 // indirect
|
||||
github.com/libp2p/go-libp2p-kad-dht v0.38.0 // indirect
|
||||
github.com/libp2p/go-libp2p-kbucket v0.8.0 // indirect
|
||||
github.com/libp2p/go-libp2p-record v0.3.1 // indirect
|
||||
github.com/libp2p/go-libp2p-routing-helpers v0.7.5 // indirect
|
||||
github.com/libp2p/go-msgio v0.3.0 // indirect
|
||||
github.com/libp2p/go-netroute v0.4.0 // indirect
|
||||
github.com/libp2p/go-reuseport v0.4.0 // indirect
|
||||
github.com/libp2p/go-yamux/v5 v5.0.1 // indirect
|
||||
github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/miekg/dns v1.1.72 // indirect
|
||||
github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b // indirect
|
||||
github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc // indirect
|
||||
github.com/minio/sha256-simd v1.0.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||
github.com/mschoch/smat v0.2.0 // indirect
|
||||
github.com/multiformats/go-base32 v0.1.0 // indirect
|
||||
github.com/multiformats/go-base36 v0.2.0 // indirect
|
||||
github.com/multiformats/go-multiaddr v0.16.1 // indirect
|
||||
github.com/multiformats/go-multiaddr-dns v0.4.1 // indirect
|
||||
github.com/multiformats/go-multiaddr-fmt v0.1.0 // indirect
|
||||
github.com/multiformats/go-multibase v0.2.0 // indirect
|
||||
github.com/multiformats/go-multicodec v0.10.0 // indirect
|
||||
github.com/multiformats/go-multihash v0.2.3 // indirect
|
||||
github.com/multiformats/go-multistream v0.6.1 // indirect
|
||||
github.com/multiformats/go-varint v0.1.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pion/datachannel v1.5.10 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.12 // indirect
|
||||
github.com/pion/dtls/v3 v3.1.1 // indirect
|
||||
github.com/pion/ice/v4 v4.0.10 // indirect
|
||||
github.com/pion/interceptor v0.1.40 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.15 // indirect
|
||||
github.com/pion/rtp v1.8.19 // indirect
|
||||
github.com/pion/sctp v1.8.39 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.13 // indirect
|
||||
github.com/pion/srtp/v3 v3.0.6 // indirect
|
||||
github.com/pion/stun v0.6.1 // indirect
|
||||
github.com/pion/stun/v3 v3.0.0 // indirect
|
||||
github.com/pion/transport/v2 v2.2.10 // indirect
|
||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||
github.com/pion/turn/v4 v4.0.2 // indirect
|
||||
github.com/pion/webrtc/v4 v4.1.2 // indirect
|
||||
github.com/polydawn/refmt v0.89.0 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.17.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/quic-go/webtransport-go v0.10.0 // indirect
|
||||
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529 // indirect
|
||||
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/whyrusleeping/go-keyspace v0.0.0-20160322163242-5b898ac5add1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.uber.org/dig v1.19.0 // indirect
|
||||
go.uber.org/fx v1.24.0 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.1 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
gonum.org/v1/gonum v0.17.0 // indirect
|
||||
lukechampine.com/blake3 v1.4.1 // indirect
|
||||
)
|
||||
+1458
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,121 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var (
|
||||
logPath = ""
|
||||
webLogPath = ""
|
||||
)
|
||||
|
||||
var webLog *log.Logger
|
||||
|
||||
var (
|
||||
logFile *os.File
|
||||
webLogFile *os.File
|
||||
)
|
||||
|
||||
func Init(path, webpath string) {
|
||||
webLogPath = webpath
|
||||
logPath = path
|
||||
|
||||
if webpath != "" {
|
||||
ff, err := os.OpenFile(webLogPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o666)
|
||||
if err != nil {
|
||||
TLogln("Error create web log file:", err)
|
||||
} else {
|
||||
webLogFile = ff
|
||||
webLog = log.New(ff, " ", log.LstdFlags)
|
||||
}
|
||||
}
|
||||
|
||||
if path != "" {
|
||||
if fi, err := os.Lstat(path); err == nil {
|
||||
if fi.Size() >= 100*1024*1024 { // 100MB
|
||||
os.Remove(path)
|
||||
}
|
||||
}
|
||||
ff, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o666)
|
||||
if err != nil {
|
||||
TLogln("Error create log file:", err)
|
||||
return
|
||||
}
|
||||
logFile = ff
|
||||
os.Stdout = ff
|
||||
os.Stderr = ff
|
||||
// var timeFmt string
|
||||
// var ok bool
|
||||
// timeFmt, ok = os.LookupEnv("GO_LOG_TIME_FMT")
|
||||
// if !ok {
|
||||
// timeFmt = "2006-01-02T15:04:05-0700"
|
||||
// }
|
||||
// log.SetFlags(log.Lmsgprefix)
|
||||
// log.SetPrefix(time.Now().Format(timeFmt) + " TSM ")
|
||||
log.SetFlags(log.LstdFlags | log.LUTC | log.Lmsgprefix)
|
||||
log.SetPrefix("UTC0 ")
|
||||
log.SetOutput(ff)
|
||||
}
|
||||
}
|
||||
|
||||
func Close() {
|
||||
if logFile != nil {
|
||||
logFile.Close()
|
||||
}
|
||||
if webLogFile != nil {
|
||||
webLogFile.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TLogln(v ...interface{}) {
|
||||
log.Println(v...)
|
||||
}
|
||||
|
||||
func WebLogln(v ...interface{}) {
|
||||
if webLog != nil {
|
||||
webLog.Println(v...)
|
||||
}
|
||||
}
|
||||
|
||||
func WebLogger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if webLog == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
body := ""
|
||||
// save body if not form or file
|
||||
if !strings.HasPrefix(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
|
||||
body, _ := io.ReadAll(c.Request.Body)
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
} else {
|
||||
body = "body hidden, too large"
|
||||
}
|
||||
c.Next()
|
||||
|
||||
statusCode := c.Writer.Status()
|
||||
clientIP := c.ClientIP()
|
||||
method := c.Request.Method
|
||||
path := c.Request.URL.Path
|
||||
raw := c.Request.URL.RawQuery
|
||||
if raw != "" {
|
||||
path = path + "?" + raw
|
||||
}
|
||||
|
||||
logStr := fmt.Sprintf("%3d | %12s | %-7s %#v %v",
|
||||
statusCode,
|
||||
clientIP,
|
||||
method,
|
||||
path,
|
||||
string(body),
|
||||
)
|
||||
WebLogln(logStr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package mimetype
|
||||
|
||||
import (
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Add a minimal number of mime types to augment go's built in types
|
||||
// for environments which don't have access to a mime.types file (e.g.
|
||||
// Termux on android)
|
||||
for _, t := range []struct {
|
||||
mimeType string
|
||||
extensions string
|
||||
}{
|
||||
{"image/bmp", ".bmp"},
|
||||
{"image/gif", ".gif"},
|
||||
{"image/jpeg", ".jpg,.jpeg"},
|
||||
{"image/png", ".png"},
|
||||
{"image/tiff", ".tiff,.tif"},
|
||||
{"audio/x-aac", ".aac"},
|
||||
{"audio/dsd", ".dsd,.dsf,.dff"},
|
||||
{"audio/flac", ".flac"},
|
||||
{"audio/mpeg", ".mpga,.mpega,.mp2,.mp3,.m4a"},
|
||||
{"audio/ogg", ".oga,.ogg,.opus,.spx"},
|
||||
{"audio/opus", ".opus"},
|
||||
{"audio/weba", ".weba"},
|
||||
{"audio/x-ape", ".ape"},
|
||||
// {"audio/x-dsd", ".dsd"},
|
||||
// {"audio/x-dff", ".dff"},
|
||||
// {"audio/x-dsf", ".dsf"},
|
||||
{"audio/x-wav", ".wav"},
|
||||
{"video/dv", ".dif,.dv"},
|
||||
{"video/fli", ".fli"},
|
||||
{"video/mp4", ".mp4"},
|
||||
{"video/mpeg", ".mpeg,.mpg,.mpe"},
|
||||
{"video/x-matroska", ".mpv,.mkv"},
|
||||
{"video/mp2t", ".ts,.m2ts,.mts"},
|
||||
{"video/ogg", ".ogv"},
|
||||
{"video/webm", ".webm"},
|
||||
{"video/x-ms-vob", ".vob"},
|
||||
{"video/x-msvideo", ".avi"},
|
||||
{"video/x-quicktime", ".qt,.mov"},
|
||||
{"text/srt", ".srt"},
|
||||
{"text/smi", ".smi"},
|
||||
{"text/ssa", ".ssa"},
|
||||
} {
|
||||
for _, ext := range strings.Split(t.extensions, ",") {
|
||||
err := mime.AddExtensionType(ext, t.mimeType)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := mime.AddExtensionType(".rmvb", "application/vnd.rn-realmedia-vbr"); err != nil {
|
||||
log.Printf("Could not register application/vnd.rn-realmedia-vbr MIME type: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Example: "video/mpeg"
|
||||
type mimeType string
|
||||
|
||||
// IsMedia returns true for media MIME-types
|
||||
func (mt mimeType) IsMedia() bool {
|
||||
return mt.IsVideo() || mt.IsAudio() || mt.IsImage()
|
||||
}
|
||||
|
||||
// IsVideo returns true for video MIME-types
|
||||
func (mt mimeType) IsVideo() bool {
|
||||
return strings.HasPrefix(string(mt), "video/") || mt == "application/vnd.rn-realmedia-vbr"
|
||||
}
|
||||
|
||||
// IsAudio returns true for audio MIME-types
|
||||
func (mt mimeType) IsAudio() bool {
|
||||
return strings.HasPrefix(string(mt), "audio/")
|
||||
}
|
||||
|
||||
// IsImage returns true for image MIME-types
|
||||
func (mt mimeType) IsImage() bool {
|
||||
return strings.HasPrefix(string(mt), "image/")
|
||||
}
|
||||
|
||||
// IsSub returns true for subtitles MIME-types
|
||||
func (mt mimeType) IsSub() bool {
|
||||
return strings.HasPrefix(string(mt), "text/srt") || strings.HasPrefix(string(mt), "text/smi") || strings.HasPrefix(string(mt), "text/ssa")
|
||||
}
|
||||
|
||||
// Returns the group "type", the part before the '/'.
|
||||
func (mt mimeType) Type() string {
|
||||
return strings.SplitN(string(mt), "/", 2)[0]
|
||||
}
|
||||
|
||||
// Returns the string representation of this MIME-type
|
||||
func (mt mimeType) String() string {
|
||||
return string(mt)
|
||||
}
|
||||
|
||||
// MimeTypeByPath determines the MIME-type of file at the given path
|
||||
func MimeTypeByPath(filePath string) (ret mimeType, err error) {
|
||||
ret = mimeTypeByBaseName(path.Base(filePath))
|
||||
if ret == "" {
|
||||
ret, err = mimeTypeByContent(filePath)
|
||||
}
|
||||
// Custom DLNA-compat mime mappings
|
||||
// TODO: make this depend on client headers / profile map
|
||||
if ret == "video/mp2t" {
|
||||
ret = "video/mpeg"
|
||||
// } else if ret == "video/x-matroska" {
|
||||
// ret = "video/mpeg"
|
||||
} else if ret == "video/x-msvideo" {
|
||||
ret = "video/avi"
|
||||
} else if ret == "" {
|
||||
ret = "application/octet-stream"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Guess MIME-type from the extension, ignoring ".part".
|
||||
func mimeTypeByBaseName(name string) mimeType {
|
||||
name = strings.TrimSuffix(name, ".part")
|
||||
ext := path.Ext(name)
|
||||
if ext != "" {
|
||||
return mimeType(mime.TypeByExtension(ext))
|
||||
}
|
||||
return mimeType("")
|
||||
}
|
||||
|
||||
// Guess the MIME-type by analysing the first 512 bytes of the file.
|
||||
func mimeTypeByContent(path string) (ret mimeType, err error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
var data [512]byte
|
||||
if n, err := file.Read(data[:]); err == nil {
|
||||
ret = mimeType(http.DetectContentType(data[:n]))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"server/log"
|
||||
"server/settings"
|
||||
|
||||
"github.com/YouROK/tunsgo/opts"
|
||||
"github.com/YouROK/tunsgo/p2p"
|
||||
)
|
||||
|
||||
var (
|
||||
P2Proxy *p2p.P2PServer
|
||||
)
|
||||
|
||||
func Start() {
|
||||
if settings.BTsets.EnableProxy {
|
||||
cfg := opts.DefOptions()
|
||||
var err error
|
||||
|
||||
cfg.Server.Port = settings.Args.Port
|
||||
cfg.Hosts = settings.BTsets.ProxyHosts
|
||||
|
||||
P2Proxy, err = p2p.NewP2PServer(cfg)
|
||||
if err != nil {
|
||||
log.TLogln("Error starting P2PServer:", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Stop() {
|
||||
if P2Proxy != nil {
|
||||
P2Proxy.Stop()
|
||||
P2Proxy = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package rutor
|
||||
|
||||
import (
|
||||
"compress/flate"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"server/rutor/models"
|
||||
)
|
||||
|
||||
func TestParseChannel(t *testing.T) {
|
||||
channel := make(chan *models.TorrentDetails, 0)
|
||||
var ftors []*models.TorrentDetails
|
||||
go func() {
|
||||
for torr := range channel {
|
||||
ftors = append(ftors, torr)
|
||||
}
|
||||
}()
|
||||
|
||||
path, _ := os.Getwd()
|
||||
ff, err := os.Open(filepath.Join(path, "rutor.ls"))
|
||||
if err == nil {
|
||||
defer ff.Close()
|
||||
r := flate.NewReader(ff)
|
||||
defer r.Close()
|
||||
dec := json.NewDecoder(r)
|
||||
|
||||
_, err := dec.Token()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
for dec.More() {
|
||||
var torr *models.TorrentDetails
|
||||
err = dec.Decode(&torr)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
channel <- torr
|
||||
}
|
||||
close(channel)
|
||||
} else {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArr(t *testing.T) {
|
||||
var ftors []*models.TorrentDetails
|
||||
path, _ := os.Getwd()
|
||||
ff, err := os.Open(filepath.Join(path, "rutor.ls"))
|
||||
if err == nil {
|
||||
defer ff.Close()
|
||||
r := flate.NewReader(ff)
|
||||
defer r.Close()
|
||||
dec := json.NewDecoder(r)
|
||||
|
||||
_, err := dec.Token()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
for dec.More() {
|
||||
var torr *models.TorrentDetails
|
||||
err = dec.Decode(&torr)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
ftors = append(ftors, torr)
|
||||
fmt.Println(len(ftors))
|
||||
}
|
||||
} else {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
CatMovie = "Movie"
|
||||
CatSeries = "Series"
|
||||
CatDocMovie = "DocMovie"
|
||||
CatDocSeries = "DocSeries"
|
||||
CatCartoonMovie = "CartoonMovie"
|
||||
CatCartoonSeries = "CartoonSeries"
|
||||
CatTVShow = "TVShow"
|
||||
CatAnime = "Anime"
|
||||
|
||||
Q_LOWER = 0
|
||||
Q_WEBDL_720 = 100
|
||||
Q_BDRIP_720 = 101
|
||||
Q_BDRIP_HEVC_720 = 102
|
||||
Q_WEBDL_1080 = 200
|
||||
Q_BDRIP_1080 = 201
|
||||
Q_BDRIP_HEVC_1080 = 202
|
||||
Q_BDREMUX_1080 = 203
|
||||
Q_WEBDL_SDR_2160 = 300
|
||||
Q_WEBDL_HDR_2160 = 301
|
||||
Q_WEBDL_DV_2160 = 302
|
||||
Q_BDRIP_SDR_2160 = 303
|
||||
Q_BDRIP_HDR_2160 = 304
|
||||
Q_BDRIP_DV_2160 = 305
|
||||
Q_UHD_BDREMUX_SDR = 306
|
||||
Q_UHD_BDREMUX_HDR = 307
|
||||
Q_UHD_BDREMUX_DV = 308
|
||||
|
||||
Q_UNKNOWN = 0
|
||||
Q_A = 1 // Авторский, по типу Гоблина или старых переводчиков
|
||||
Q_L1 = 100 // Любительский одноголосый закадровый
|
||||
Q_L2 = 101 // Любительский двухголосый закадровый
|
||||
Q_L = 102 // Любительский 3-5 человек закадровый
|
||||
Q_LS = 103 // Любительский студия
|
||||
Q_P1 = 200 // Професиональный одноголосый закадровый
|
||||
Q_P2 = 201 // Профессиональный двухголосый закадровый
|
||||
Q_P = 202 // Профессиональный 3-5 человек закадровый
|
||||
Q_PS = 203 // Профессиональный студия
|
||||
Q_D = 300 // Официальное профессиональное многоголосое озвучивание
|
||||
Q_LICENSE = 301 // Лицензия
|
||||
)
|
||||
|
||||
type TorrentDetails struct {
|
||||
Title string
|
||||
Name string
|
||||
Names []string
|
||||
Categories string
|
||||
Size string
|
||||
CreateDate time.Time
|
||||
Tracker string
|
||||
Link string
|
||||
Year int
|
||||
Peer int
|
||||
Seed int
|
||||
Magnet string
|
||||
Hash string
|
||||
IMDBID string
|
||||
VideoQuality int
|
||||
AudioQuality int
|
||||
}
|
||||
|
||||
type TorrentFile struct {
|
||||
Name string
|
||||
Size int64
|
||||
}
|
||||
|
||||
func (d TorrentDetails) GetNames() string {
|
||||
return strings.Join(d.Names, " ")
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package rutor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"server/rutor/models"
|
||||
"server/settings"
|
||||
)
|
||||
|
||||
// TestConcurrentSearchAndLoadDB проверяет отсутствие гонки при одновременном
|
||||
// обновлении индекса (loadDB) и поиске (Search).
|
||||
// !Запускать с -count=3
|
||||
func TestConcurrentSearchAndLoadDB(t *testing.T) {
|
||||
if settings.BTsets == nil {
|
||||
settings.BTsets = &settings.BTSets{EnableRutorSearch: true}
|
||||
defer func() { settings.BTsets = nil }()
|
||||
} else {
|
||||
old := settings.BTsets.EnableRutorSearch
|
||||
settings.BTsets.EnableRutorSearch = true
|
||||
defer func() { settings.BTsets.EnableRutorSearch = old }()
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
oldPath := settings.Path
|
||||
settings.Path = dir
|
||||
defer func() { settings.Path = oldPath }()
|
||||
|
||||
const numTorrents = 800
|
||||
seed := make([]*models.TorrentDetails, numTorrents)
|
||||
for i := 0; i < numTorrents; i++ {
|
||||
s := strconv.Itoa(i)
|
||||
seed[i] = &models.TorrentDetails{
|
||||
Title: "Test Film Number " + s + " Part One Two Three Year",
|
||||
Name: "Film " + s,
|
||||
Year: 2015 + i%10,
|
||||
}
|
||||
}
|
||||
data, err := json.Marshal(seed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var compressed bytes.Buffer
|
||||
w, _ := flate.NewWriter(&compressed, flate.DefaultCompression)
|
||||
_, _ = w.Write(data)
|
||||
_ = w.Close()
|
||||
if err := os.WriteFile(filepath.Join(dir, "rutor.ls"), compressed.Bytes(), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Горутина: многократно перезагружает БД (долгая перезапись индекса)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 20; i++ {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
loadDB()
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Несколько горутин: постоянный поиск, пока идёт переиндексация
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
queries := []string{"Test", "Film", "Number", "Part", "Year", "xxx"}
|
||||
for j := 0; j < 200; j++ {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
_ = Search(queries[j%len(queries)])
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Даём время на пересечение loadDB и Search
|
||||
time.Sleep(800 * time.Millisecond)
|
||||
close(done)
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package rutor
|
||||
|
||||
import (
|
||||
"compress/flate"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/agnivade/levenshtein"
|
||||
|
||||
"server/log"
|
||||
"server/rutor/models"
|
||||
"server/rutor/torrsearch"
|
||||
"server/rutor/utils"
|
||||
"server/settings"
|
||||
utils2 "server/torr/utils"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
torrs []*models.TorrentDetails
|
||||
isStop bool
|
||||
)
|
||||
|
||||
func Start() {
|
||||
go func() {
|
||||
if settings.BTsets.EnableRutorSearch {
|
||||
if !updateDB() {
|
||||
loadDB()
|
||||
}
|
||||
isStop = false
|
||||
for !isStop {
|
||||
for i := 0; i < 3*60*60; i++ {
|
||||
time.Sleep(time.Second)
|
||||
if isStop {
|
||||
return
|
||||
}
|
||||
}
|
||||
updateDB()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func Stop() {
|
||||
mu.Lock()
|
||||
isStop = true
|
||||
torrs = nil
|
||||
torrsearch.NewIndex(nil)
|
||||
mu.Unlock()
|
||||
utils2.FreeOSMemGC()
|
||||
time.Sleep(time.Millisecond * 1500)
|
||||
}
|
||||
|
||||
// http://releases.yourok.ru/torr/rutor.ls
|
||||
func updateDB() bool {
|
||||
log.TLogln("Update rutor db")
|
||||
|
||||
fnOrig := filepath.Join(settings.Path, "rutor.ls")
|
||||
|
||||
if fi, err := os.Stat(fnOrig); err == nil {
|
||||
if time.Since(fi.ModTime()) < time.Minute*175 /*2:55*/ {
|
||||
log.TLogln("Less 3 hours rutor db old")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fnTmp := filepath.Join(settings.Path, "rutor.tmp")
|
||||
out, err := os.Create(fnTmp)
|
||||
if err != nil {
|
||||
log.TLogln("Error create file rutor.tmp:", err)
|
||||
return false
|
||||
}
|
||||
|
||||
resp, err := http.Get("http://releases.yourok.ru/torr/rutor.ls")
|
||||
if err != nil {
|
||||
log.TLogln("Error connect to rutor db:", err)
|
||||
out.Close()
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
out.Close()
|
||||
if err != nil {
|
||||
log.TLogln("Error download rutor db:", err)
|
||||
return false
|
||||
}
|
||||
|
||||
md5Tmp := utils.MD5File(fnTmp)
|
||||
md5Orig := utils.MD5File(fnOrig)
|
||||
if md5Tmp != md5Orig {
|
||||
err = os.Remove(fnOrig)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
log.TLogln("Error remove old rutor db:", err)
|
||||
return false
|
||||
}
|
||||
err = os.Rename(fnTmp, fnOrig)
|
||||
if err != nil {
|
||||
log.TLogln("Error rename rutor db:", err)
|
||||
return false
|
||||
}
|
||||
loadDB()
|
||||
return true
|
||||
} else {
|
||||
os.Remove(fnTmp)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func loadDB() {
|
||||
log.TLogln("Load rutor db")
|
||||
ff, err := os.Open(filepath.Join(settings.Path, "rutor.ls"))
|
||||
if err == nil {
|
||||
defer ff.Close()
|
||||
r := flate.NewReader(ff)
|
||||
defer r.Close()
|
||||
var ftorrs []*models.TorrentDetails
|
||||
dec := json.NewDecoder(r)
|
||||
|
||||
_, err := dec.Token()
|
||||
if err != nil {
|
||||
log.TLogln("Error read token rutor db:", err)
|
||||
return
|
||||
}
|
||||
|
||||
for dec.More() {
|
||||
var torr *models.TorrentDetails
|
||||
err = dec.Decode(&torr)
|
||||
if err == nil {
|
||||
ftorrs = append(ftorrs, torr)
|
||||
}
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
torrs = ftorrs
|
||||
log.TLogln("Index rutor db")
|
||||
torrsearch.NewIndex(torrs)
|
||||
log.TLogln("Torrents count:", len(torrs))
|
||||
log.TLogln("Indexed words:", len(torrsearch.GetIDX()))
|
||||
|
||||
} else {
|
||||
log.TLogln("Error load rutor db:", err)
|
||||
}
|
||||
utils2.FreeOSMemGC()
|
||||
}
|
||||
|
||||
func Search(query string) []*models.TorrentDetails {
|
||||
if !settings.BTsets.EnableRutorSearch {
|
||||
return nil
|
||||
}
|
||||
mu.RLock()
|
||||
matchedIDs := torrsearch.Search(query)
|
||||
if len(matchedIDs) == 0 {
|
||||
mu.RUnlock()
|
||||
return nil
|
||||
}
|
||||
var list []*models.TorrentDetails
|
||||
for _, id := range matchedIDs {
|
||||
list = append(list, torrs[id])
|
||||
}
|
||||
mu.RUnlock()
|
||||
|
||||
hash := utils.ClearStr(query)
|
||||
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
lhash := utils.ClearStr(strings.ToLower(list[i].Name+list[i].GetNames())) + strconv.Itoa(list[i].Year)
|
||||
lev1 := levenshtein.ComputeDistance(hash, lhash)
|
||||
lhash = utils.ClearStr(strings.ToLower(list[j].Name+list[j].GetNames())) + strconv.Itoa(list[j].Year)
|
||||
lev2 := levenshtein.ComputeDistance(hash, lhash)
|
||||
if lev1 == lev2 {
|
||||
return list[j].CreateDate.Before(list[i].CreateDate)
|
||||
}
|
||||
return lev1 < lev2
|
||||
})
|
||||
return list
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package torrsearch
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
snowballeng "github.com/kljensen/snowball/english"
|
||||
snowballru "github.com/kljensen/snowball/russian"
|
||||
)
|
||||
|
||||
// lowercaseFilter returns a slice of tokens normalized to lower case.
|
||||
func lowercaseFilter(tokens []string) []string {
|
||||
r := make([]string, len(tokens))
|
||||
for i, token := range tokens {
|
||||
r[i] = replaceChars(strings.ToLower(token))
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// stopwordFilter returns a slice of tokens with stop words removed.
|
||||
func stopwordFilter(tokens []string) []string {
|
||||
r := make([]string, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
if !isStopWord(token) {
|
||||
r = append(r, token)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// stemmerFilter returns a slice of stemmed tokens.
|
||||
func stemmerFilter(tokens []string) []string {
|
||||
r := make([]string, len(tokens))
|
||||
for i, token := range tokens {
|
||||
worden := snowballeng.Stem(token, false)
|
||||
wordru := snowballru.Stem(token, false)
|
||||
if wordru == "" || worden == "" {
|
||||
continue
|
||||
}
|
||||
if wordru != token {
|
||||
r[i] = wordru
|
||||
} else {
|
||||
r[i] = worden
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func replaceChars(word string) string {
|
||||
out := []rune(word)
|
||||
for i, r := range out {
|
||||
if r == 'ё' {
|
||||
out[i] = 'е'
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func isStopWord(word string) bool {
|
||||
switch word {
|
||||
case "a", "am", "an", "and", "are", "as", "at", "be",
|
||||
"by", "did", "do", "is", "of", "or", "s", "so", "t",
|
||||
"и", "в", "с", "со", "а", "но", "к", "у",
|
||||
"же", "бы", "по", "от", "о", "из", "ну",
|
||||
"ли", "ни", "нибудь", "уж", "ведь", "ж", "об":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package torrsearch
|
||||
|
||||
import (
|
||||
"server/rutor/models"
|
||||
)
|
||||
|
||||
// Index is an inverted Index. It maps tokens to document IDs.
|
||||
type Index map[string][]int
|
||||
|
||||
var idx Index
|
||||
|
||||
func NewIndex(torrs []*models.TorrentDetails) {
|
||||
idx = make(Index)
|
||||
idx.add(torrs)
|
||||
}
|
||||
|
||||
func Search(text string) []int {
|
||||
return idx.search(text)
|
||||
}
|
||||
|
||||
func GetIDX() Index {
|
||||
return idx
|
||||
}
|
||||
|
||||
func (idx Index) add(torrs []*models.TorrentDetails) {
|
||||
for ID, torr := range torrs {
|
||||
for _, token := range analyze(torr.Title) {
|
||||
ids := idx[token]
|
||||
if ids != nil && ids[len(ids)-1] == ID {
|
||||
// Don't add same ID twice.
|
||||
continue
|
||||
}
|
||||
idx[token] = append(ids, ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// intersection returns the set intersection between a and b.
|
||||
// a and b have to be sorted in ascending order and contain no duplicates.
|
||||
func intersection(a []int, b []int) []int {
|
||||
maxLen := len(a)
|
||||
if len(b) > maxLen {
|
||||
maxLen = len(b)
|
||||
}
|
||||
r := make([]int, 0, maxLen)
|
||||
var i, j int
|
||||
for i < len(a) && j < len(b) {
|
||||
if a[i] < b[j] {
|
||||
i++
|
||||
} else if a[i] > b[j] {
|
||||
j++
|
||||
} else {
|
||||
r = append(r, a[i])
|
||||
i++
|
||||
j++
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Search queries the Index for the given text.
|
||||
func (idx Index) search(text string) []int {
|
||||
var r []int
|
||||
for _, token := range analyze(text) {
|
||||
if ids, ok := idx[token]; ok {
|
||||
if r == nil {
|
||||
r = ids
|
||||
} else {
|
||||
r = intersection(r, ids)
|
||||
}
|
||||
} else {
|
||||
// Token doesn't exist.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package torrsearch
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// tokenize returns a slice of tokens for the given text.
|
||||
func tokenize(text string) []string {
|
||||
return strings.FieldsFunc(text, func(r rune) bool {
|
||||
// Split on any character that is not a letter or a number.
|
||||
return !unicode.IsLetter(r) && !unicode.IsNumber(r)
|
||||
})
|
||||
}
|
||||
|
||||
// analyze analyzes the text and returns a slice of tokens.
|
||||
func analyze(text string) []string {
|
||||
tokens := tokenize(text)
|
||||
tokens = lowercaseFilter(tokens)
|
||||
tokens = stopwordFilter(tokens)
|
||||
// tokens = stemmerFilter(tokens)
|
||||
return tokens
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ClearStr(str string) string {
|
||||
ret := ""
|
||||
str = strings.ToLower(str)
|
||||
for _, r := range str {
|
||||
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'а' && r <= 'я') || r == 'ё' {
|
||||
ret = ret + string(r)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func MD5File(fname string) string {
|
||||
f, err := os.Open(fname)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
defer f.Close()
|
||||
|
||||
buf := make([]byte, 1024*1024)
|
||||
h := sha256.New()
|
||||
|
||||
for {
|
||||
bytesRead, err := f.Read(buf)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
h.Write(buf[:bytesRead])
|
||||
}
|
||||
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"server/tgbot"
|
||||
|
||||
"server/log"
|
||||
"server/settings"
|
||||
"server/web"
|
||||
)
|
||||
|
||||
func Start() {
|
||||
settings.InitSets(settings.Args.RDB, settings.Args.SearchWA)
|
||||
// https checks
|
||||
if settings.Args.Ssl {
|
||||
// set settings ssl enabled
|
||||
settings.Ssl = settings.Args.Ssl
|
||||
if settings.Args.SslPort == "" {
|
||||
dbSSlPort := strconv.Itoa(settings.BTsets.SslPort)
|
||||
if dbSSlPort != "0" {
|
||||
settings.Args.SslPort = dbSSlPort
|
||||
} else {
|
||||
settings.Args.SslPort = "8091"
|
||||
}
|
||||
} else { // store ssl port from params to DB
|
||||
dbSSlPort, err := strconv.Atoi(settings.Args.SslPort)
|
||||
if err == nil {
|
||||
settings.BTsets.SslPort = dbSSlPort
|
||||
}
|
||||
}
|
||||
// check if ssl cert and key files exist
|
||||
if settings.Args.SslCert != "" && settings.Args.SslKey != "" {
|
||||
// set settings ssl cert and key files
|
||||
settings.BTsets.SslCert = settings.Args.SslCert
|
||||
settings.BTsets.SslKey = settings.Args.SslKey
|
||||
}
|
||||
log.TLogln("Check web ssl port", settings.Args.SslPort)
|
||||
l, err := net.Listen("tcp", settings.Args.IP+":"+settings.Args.SslPort)
|
||||
if l != nil {
|
||||
l.Close()
|
||||
}
|
||||
if err != nil {
|
||||
log.TLogln("Port", settings.Args.SslPort, "already in use! Please set different ssl port for HTTPS. Abort")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
// http checks
|
||||
if settings.Args.Port == "" {
|
||||
settings.Args.Port = "8090"
|
||||
}
|
||||
|
||||
log.TLogln("Check web port", settings.Args.Port)
|
||||
l, err := net.Listen("tcp", settings.Args.IP+":"+settings.Args.Port)
|
||||
if l != nil {
|
||||
l.Close()
|
||||
}
|
||||
if err != nil {
|
||||
log.TLogln("Port", settings.Args.Port, "already in use! Please set different port for HTTP. Abort")
|
||||
os.Exit(1)
|
||||
}
|
||||
// remove old disk caches
|
||||
go cleanCache()
|
||||
// set settings http and https ports. Start web server.
|
||||
settings.Port = settings.Args.Port
|
||||
settings.SslPort = settings.Args.SslPort
|
||||
settings.IP = settings.Args.IP
|
||||
|
||||
if settings.Args.TGToken != "" {
|
||||
if err := tgbot.Start(settings.Args.TGToken); err != nil {
|
||||
log.TLogln("tg bot start failed", err)
|
||||
}
|
||||
}
|
||||
web.Start()
|
||||
}
|
||||
|
||||
func cleanCache() {
|
||||
if !settings.BTsets.UseDisk || settings.BTsets.TorrentsSavePath == "/" || settings.BTsets.TorrentsSavePath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
dirs, err := os.ReadDir(settings.BTsets.TorrentsSavePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
torrs := settings.ListTorrent()
|
||||
|
||||
log.TLogln("Remove unused cache in dir:", settings.BTsets.TorrentsSavePath)
|
||||
keep := map[string]bool{}
|
||||
for _, d := range dirs {
|
||||
if len(d.Name()) != 40 {
|
||||
// Not a hash
|
||||
continue
|
||||
}
|
||||
|
||||
if !settings.BTsets.RemoveCacheOnDrop {
|
||||
keep[d.Name()] = true
|
||||
for _, t := range torrs {
|
||||
if d.IsDir() && d.Name() == t.InfoHash.HexString() {
|
||||
keep[d.Name()] = false
|
||||
break
|
||||
}
|
||||
}
|
||||
for hash, del := range keep {
|
||||
if del && hash == d.Name() {
|
||||
log.TLogln("Remove unused cache:", d.Name())
|
||||
removeAllFiles(filepath.Join(settings.BTsets.TorrentsSavePath, d.Name()))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if d.IsDir() {
|
||||
log.TLogln("Remove unused cache:", d.Name())
|
||||
removeAllFiles(filepath.Join(settings.BTsets.TorrentsSavePath, d.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeAllFiles(path string) {
|
||||
files, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, f := range files {
|
||||
name := filepath.Join(path, f.Name())
|
||||
os.Remove(name)
|
||||
}
|
||||
os.Remove(path)
|
||||
}
|
||||
|
||||
func WaitServer() string {
|
||||
err := web.Wait()
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func Stop() {
|
||||
web.Stop()
|
||||
settings.CloseDB()
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package settings
|
||||
|
||||
type ExecArgs struct {
|
||||
Port string
|
||||
IP string
|
||||
Ssl bool
|
||||
SslPort string
|
||||
SslCert string
|
||||
SslKey string
|
||||
Path string
|
||||
LogPath string
|
||||
WebLogPath string
|
||||
RDB bool
|
||||
HttpAuth bool
|
||||
DontKill bool
|
||||
UI bool
|
||||
TorrentsDir string
|
||||
TorrentAddr string
|
||||
PubIPv4 string
|
||||
PubIPv6 string
|
||||
SearchWA bool
|
||||
MaxSize string
|
||||
TGToken string
|
||||
FusePath string
|
||||
WebDAV bool
|
||||
ProxyURL string
|
||||
ProxyMode string
|
||||
ForceHTTPS bool
|
||||
}
|
||||
|
||||
var Args *ExecArgs
|
||||
@@ -0,0 +1,212 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"server/log"
|
||||
)
|
||||
|
||||
type TorznabConfig struct {
|
||||
Host string
|
||||
Key string
|
||||
Name string
|
||||
}
|
||||
|
||||
type TMDBConfig struct {
|
||||
APIKey string // TMDB API Key
|
||||
APIURL string // Base API URL (default: https://api.themoviedb.org)
|
||||
ImageURL string // Image URL (default: https://image.tmdb.org)
|
||||
ImageURLRu string // Image URL for Russian users (default: https://imagetmdb.com)
|
||||
}
|
||||
|
||||
type BTSets struct {
|
||||
// Cache
|
||||
CacheSize int64 // in byte, def 64 MB
|
||||
ReaderReadAHead int // in percent, 5%-100%, [...S__X__E...] [S-E] not clean
|
||||
PreloadCache int // in percent
|
||||
|
||||
// Disk
|
||||
UseDisk bool
|
||||
TorrentsSavePath string
|
||||
RemoveCacheOnDrop bool
|
||||
|
||||
// Torrent
|
||||
ForceEncrypt bool
|
||||
RetrackersMode int // 0 - don`t add, 1 - add retrackers (def), 2 - remove retrackers 3 - replace retrackers
|
||||
TorrentDisconnectTimeout int // in seconds
|
||||
EnableDebug bool // debug logs
|
||||
|
||||
// DLNA
|
||||
EnableDLNA bool
|
||||
FriendlyName string
|
||||
|
||||
// Rutor
|
||||
EnableRutorSearch bool
|
||||
|
||||
// Torznab
|
||||
EnableTorznabSearch bool
|
||||
TorznabUrls []TorznabConfig
|
||||
|
||||
// TMDB
|
||||
TMDBSettings TMDBConfig
|
||||
|
||||
// BT Config
|
||||
EnableIPv6 bool
|
||||
DisableTCP bool
|
||||
DisableUTP bool
|
||||
DisableUPNP bool
|
||||
DisableDHT bool
|
||||
DisablePEX bool
|
||||
DisableUpload bool
|
||||
DownloadRateLimit int // in kb, 0 - inf
|
||||
UploadRateLimit int // in kb, 0 - inf
|
||||
ConnectionsLimit int
|
||||
PeersListenPort int
|
||||
|
||||
// HTTPS
|
||||
SslPort int
|
||||
SslCert string
|
||||
SslKey string
|
||||
|
||||
// Reader
|
||||
ResponsiveMode bool // enable Responsive reader (don't wait pieceComplete)
|
||||
|
||||
// FS
|
||||
ShowFSActiveTorr bool
|
||||
|
||||
// Storage preferences
|
||||
StoreSettingsInJson bool
|
||||
StoreViewedInJson bool
|
||||
|
||||
// P2P Proxy
|
||||
EnableProxy bool
|
||||
ProxyHosts []string
|
||||
}
|
||||
|
||||
func (v *BTSets) String() string {
|
||||
buf, _ := json.Marshal(v)
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
var BTsets *BTSets
|
||||
|
||||
func SetBTSets(sets *BTSets) {
|
||||
if ReadOnly {
|
||||
return
|
||||
}
|
||||
// failsafe checks (use defaults)
|
||||
if sets.CacheSize == 0 {
|
||||
sets.CacheSize = 64 * 1024 * 1024
|
||||
}
|
||||
if sets.ConnectionsLimit == 0 {
|
||||
sets.ConnectionsLimit = 25
|
||||
}
|
||||
if sets.TorrentDisconnectTimeout == 0 {
|
||||
sets.TorrentDisconnectTimeout = 30
|
||||
}
|
||||
|
||||
if sets.ReaderReadAHead < 5 {
|
||||
sets.ReaderReadAHead = 5
|
||||
}
|
||||
if sets.ReaderReadAHead > 100 {
|
||||
sets.ReaderReadAHead = 100
|
||||
}
|
||||
|
||||
if sets.PreloadCache < 0 {
|
||||
sets.PreloadCache = 0
|
||||
}
|
||||
if sets.PreloadCache > 100 {
|
||||
sets.PreloadCache = 100
|
||||
}
|
||||
|
||||
if sets.TorrentsSavePath == "" {
|
||||
sets.UseDisk = false
|
||||
} else if sets.UseDisk {
|
||||
BTsets = sets
|
||||
|
||||
go filepath.WalkDir(sets.TorrentsSavePath, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() && strings.ToLower(d.Name()) == ".tsc" {
|
||||
BTsets.TorrentsSavePath = path
|
||||
log.TLogln("Find directory \"" + BTsets.TorrentsSavePath + "\", use as cache dir")
|
||||
return io.EOF
|
||||
}
|
||||
if d.IsDir() && strings.HasPrefix(d.Name(), ".") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
BTsets = sets
|
||||
buf, err := json.Marshal(BTsets)
|
||||
if err != nil {
|
||||
log.TLogln("Error marshal btsets", err)
|
||||
return
|
||||
}
|
||||
tdb.Set("Settings", "BitTorr", buf)
|
||||
}
|
||||
|
||||
func SetDefaultConfig() {
|
||||
sets := new(BTSets)
|
||||
sets.CacheSize = 64 * 1024 * 1024 // 64 MB
|
||||
sets.PreloadCache = 50
|
||||
sets.ConnectionsLimit = 25
|
||||
sets.RetrackersMode = 1
|
||||
sets.TorrentDisconnectTimeout = 30
|
||||
sets.ReaderReadAHead = 95 // 95%
|
||||
sets.ResponsiveMode = true
|
||||
sets.ShowFSActiveTorr = true
|
||||
sets.StoreSettingsInJson = true
|
||||
// Set default TMDB settings
|
||||
sets.TMDBSettings = TMDBConfig{
|
||||
APIKey: "",
|
||||
APIURL: "https://api.themoviedb.org",
|
||||
ImageURL: "https://image.tmdb.org",
|
||||
ImageURLRu: "https://imagetmdb.com",
|
||||
}
|
||||
BTsets = sets
|
||||
if !ReadOnly {
|
||||
buf, err := json.Marshal(BTsets)
|
||||
if err != nil {
|
||||
log.TLogln("Error marshal btsets", err)
|
||||
return
|
||||
}
|
||||
tdb.Set("Settings", "BitTorr", buf)
|
||||
}
|
||||
//Proxy
|
||||
sets.EnableProxy = false
|
||||
sets.ProxyHosts = []string{"*themoviedb.org", "*tmdb.org", "rutor.info"}
|
||||
}
|
||||
|
||||
func loadBTSets() {
|
||||
buf := tdb.Get("Settings", "BitTorr")
|
||||
if len(buf) > 0 {
|
||||
err := json.Unmarshal(buf, &BTsets)
|
||||
if err == nil {
|
||||
if BTsets.ReaderReadAHead < 5 {
|
||||
BTsets.ReaderReadAHead = 5
|
||||
}
|
||||
// Set default TMDB settings if missing (for existing configs)
|
||||
if BTsets.TMDBSettings.APIURL == "" {
|
||||
BTsets.TMDBSettings = TMDBConfig{
|
||||
APIKey: "",
|
||||
APIURL: "https://api.themoviedb.org",
|
||||
ImageURL: "https://image.tmdb.org",
|
||||
ImageURLRu: "https://imagetmdb.com",
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
log.TLogln("Error unmarshal btsets", err)
|
||||
}
|
||||
// initialize defaults on error
|
||||
SetDefaultConfig()
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/log"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
type TDB struct {
|
||||
Path string
|
||||
db *bolt.DB
|
||||
}
|
||||
|
||||
var globalBboltDB TorrServerDB
|
||||
|
||||
func NewTDB() TorrServerDB {
|
||||
if globalBboltDB != nil {
|
||||
return globalBboltDB // Return existing instance
|
||||
}
|
||||
db, err := bolt.Open(filepath.Join(Path, "config.db"), 0o666, &bolt.Options{Timeout: 5 * time.Second})
|
||||
if err != nil {
|
||||
log.TLogln(err)
|
||||
return nil
|
||||
}
|
||||
|
||||
tdb := new(TDB)
|
||||
tdb.db = db
|
||||
tdb.Path = Path
|
||||
globalBboltDB = tdb
|
||||
return globalBboltDB
|
||||
}
|
||||
|
||||
func (v *TDB) CloseDB() {
|
||||
if v.db != nil {
|
||||
v.db.Close()
|
||||
v.db = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (v *TDB) Get(xpath, name string) []byte {
|
||||
spath := strings.Split(xpath, "/")
|
||||
if len(spath) == 0 {
|
||||
return nil
|
||||
}
|
||||
var ret []byte
|
||||
err := v.db.View(func(tx *bolt.Tx) error {
|
||||
buckt := tx.Bucket([]byte(spath[0]))
|
||||
if buckt == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, p := range spath {
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
buckt = buckt.Bucket([]byte(p))
|
||||
if buckt == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
data := buckt.Get([]byte(name))
|
||||
if data != nil {
|
||||
// CRITICAL: Copy the data before returning
|
||||
ret = make([]byte, len(data))
|
||||
copy(ret, data)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.TLogln("Error get sets", xpath+"/"+name, ", error:", err)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func (v *TDB) Set(xpath, name string, value []byte) {
|
||||
spath := strings.Split(xpath, "/")
|
||||
if len(spath) == 0 {
|
||||
return
|
||||
}
|
||||
err := v.db.Update(func(tx *bolt.Tx) error {
|
||||
buckt, err := tx.CreateBucketIfNotExists([]byte(spath[0]))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i, p := range spath {
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
buckt, err = buckt.CreateBucketIfNotExists([]byte(p))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return buckt.Put([]byte(name), value)
|
||||
})
|
||||
if err != nil {
|
||||
log.TLogln("Error put sets", xpath+"/"+name, ", error:", err)
|
||||
log.TLogln("value:", value)
|
||||
}
|
||||
}
|
||||
|
||||
func (v *TDB) List(xpath string) []string {
|
||||
spath := strings.Split(xpath, "/")
|
||||
if len(spath) == 0 {
|
||||
return nil
|
||||
}
|
||||
var ret []string
|
||||
err := v.db.View(func(tx *bolt.Tx) error {
|
||||
buckt := tx.Bucket([]byte(spath[0]))
|
||||
if buckt == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, p := range spath {
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
buckt = buckt.Bucket([]byte(p))
|
||||
if buckt == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
buckt.ForEach(func(k, _ []byte) error {
|
||||
if len(k) > 0 {
|
||||
ret = append(ret, string(k))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.TLogln("Error list sets", xpath, ", error:", err)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func (v *TDB) Rem(xpath, name string) {
|
||||
spath := strings.Split(xpath, "/")
|
||||
if len(spath) == 0 {
|
||||
return
|
||||
}
|
||||
err := v.db.Update(func(tx *bolt.Tx) error {
|
||||
buckt := tx.Bucket([]byte(spath[0]))
|
||||
if buckt == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, p := range spath {
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
buckt = buckt.Bucket([]byte(p))
|
||||
if buckt == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return buckt.Delete([]byte(name))
|
||||
})
|
||||
if err != nil {
|
||||
log.TLogln("Error rem sets", xpath+"/"+name, ", error:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (v *TDB) Clear(xPath string) {
|
||||
spath := strings.Split(xPath, "/")
|
||||
if len(spath) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
err := v.db.Update(func(tx *bolt.Tx) error {
|
||||
buckt := tx.Bucket([]byte(spath[0]))
|
||||
if buckt == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, p := range spath {
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
buckt = buckt.Bucket([]byte(p))
|
||||
if buckt == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all entries in this bucket
|
||||
return buckt.ForEach(func(k, _ []byte) error {
|
||||
return buckt.Delete(k)
|
||||
})
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.TLogln("Error clear xPath", xPath, ", error:", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"server/log"
|
||||
)
|
||||
|
||||
type DBReadCache struct {
|
||||
db TorrServerDB
|
||||
listCache map[string][]string
|
||||
listCacheMutex sync.RWMutex
|
||||
dataCache map[[2]string][]byte
|
||||
dataCacheMutex sync.RWMutex
|
||||
}
|
||||
|
||||
func NewDBReadCache(db TorrServerDB) TorrServerDB {
|
||||
cdb := &DBReadCache{
|
||||
db: db,
|
||||
listCache: map[string][]string{},
|
||||
dataCache: map[[2]string][]byte{},
|
||||
}
|
||||
return cdb
|
||||
}
|
||||
|
||||
func (v *DBReadCache) CloseDB() {
|
||||
v.db.CloseDB()
|
||||
v.db = nil
|
||||
v.listCache = nil
|
||||
v.dataCache = nil
|
||||
}
|
||||
|
||||
func (v *DBReadCache) Get(xPath, name string) []byte {
|
||||
if v.dataCache == nil {
|
||||
return nil // или panic, или возвращаем ошибку
|
||||
}
|
||||
cacheKey := v.makeDataCacheKey(xPath, name)
|
||||
|
||||
v.dataCacheMutex.RLock()
|
||||
if data, ok := v.dataCache[cacheKey]; ok {
|
||||
defer v.dataCacheMutex.RUnlock()
|
||||
return data
|
||||
}
|
||||
v.dataCacheMutex.RUnlock()
|
||||
|
||||
// Если база данных закрыта, не пытаемся к ней обращаться
|
||||
if v.db == nil {
|
||||
return nil
|
||||
}
|
||||
data := v.db.Get(xPath, name)
|
||||
|
||||
v.dataCacheMutex.Lock()
|
||||
if v.dataCache != nil { // Двойная проверка
|
||||
v.dataCache[cacheKey] = data
|
||||
}
|
||||
v.dataCacheMutex.Unlock()
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func (v *DBReadCache) Set(xPath, name string, value []byte) {
|
||||
if ReadOnly {
|
||||
if IsDebug() {
|
||||
log.TLogln("DBReadCache.Set: Read-only DB mode!", name)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Проверяем, не закрыта ли база
|
||||
if v.dataCache == nil || v.db == nil {
|
||||
log.TLogln("DBReadCache.Set: no dataCache or DB is closed, cannot set", name)
|
||||
return
|
||||
}
|
||||
|
||||
cacheKey := v.makeDataCacheKey(xPath, name)
|
||||
|
||||
v.dataCacheMutex.Lock()
|
||||
if v.dataCache != nil { // Двойная проверка
|
||||
v.dataCache[cacheKey] = value
|
||||
}
|
||||
v.dataCacheMutex.Unlock()
|
||||
|
||||
if v.listCache != nil {
|
||||
delete(v.listCache, xPath)
|
||||
}
|
||||
|
||||
v.db.Set(xPath, name, value)
|
||||
}
|
||||
|
||||
func (v *DBReadCache) List(xPath string) []string {
|
||||
if v.listCache == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
v.listCacheMutex.RLock()
|
||||
if names, ok := v.listCache[xPath]; ok {
|
||||
defer v.listCacheMutex.RUnlock()
|
||||
return names
|
||||
}
|
||||
v.listCacheMutex.RUnlock()
|
||||
|
||||
// Проверяем, не закрыта ли база
|
||||
if v.db == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
names := v.db.List(xPath)
|
||||
|
||||
v.listCacheMutex.Lock()
|
||||
if v.listCache != nil { // Двойная проверка
|
||||
v.listCache[xPath] = names
|
||||
}
|
||||
v.listCacheMutex.Unlock()
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
func (v *DBReadCache) Rem(xPath, name string) {
|
||||
if ReadOnly {
|
||||
if IsDebug() {
|
||||
log.TLogln("DBReadCache.Rem: Read-only DB mode!", name)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Проверяем, не закрыта ли база
|
||||
if v.dataCache == nil || v.db == nil {
|
||||
log.TLogln("DBReadCache.Rem: no dataCache or DB is closed, cannot remove", name)
|
||||
return
|
||||
}
|
||||
|
||||
cacheKey := v.makeDataCacheKey(xPath, name)
|
||||
|
||||
v.dataCacheMutex.Lock()
|
||||
if v.dataCache != nil {
|
||||
delete(v.dataCache, cacheKey)
|
||||
}
|
||||
v.dataCacheMutex.Unlock()
|
||||
|
||||
if v.listCache != nil {
|
||||
delete(v.listCache, xPath)
|
||||
}
|
||||
|
||||
v.db.Rem(xPath, name)
|
||||
}
|
||||
|
||||
func (v *DBReadCache) Clear(xPath string) {
|
||||
if ReadOnly {
|
||||
if IsDebug() {
|
||||
log.TLogln("DBReadCache.Clear: Read-only DB mode!", xPath)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Clear from underlying DB first
|
||||
if v.db != nil {
|
||||
v.db.Clear(xPath)
|
||||
}
|
||||
|
||||
// Clear cache
|
||||
v.listCacheMutex.Lock()
|
||||
delete(v.listCache, xPath)
|
||||
v.listCacheMutex.Unlock()
|
||||
|
||||
// Clear data cache entries for this xPath
|
||||
v.dataCacheMutex.Lock()
|
||||
for key := range v.dataCache {
|
||||
if key[0] == xPath {
|
||||
delete(v.dataCache, key)
|
||||
}
|
||||
}
|
||||
v.dataCacheMutex.Unlock()
|
||||
}
|
||||
|
||||
func (v *DBReadCache) makeDataCacheKey(xPath, name string) [2]string {
|
||||
return [2]string{xPath, name}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"server/log"
|
||||
)
|
||||
|
||||
type JsonDB struct {
|
||||
Path string
|
||||
filenameDelimiter string
|
||||
filenameExtension string
|
||||
fileMode fs.FileMode
|
||||
xPathDelimeter string
|
||||
}
|
||||
|
||||
var globalJsonDB TorrServerDB
|
||||
var jsonDbLocks = make(map[string]*sync.Mutex)
|
||||
var jsonDbLocksMutex sync.Mutex
|
||||
|
||||
func NewJsonDB() TorrServerDB {
|
||||
if globalJsonDB != nil {
|
||||
return globalJsonDB
|
||||
}
|
||||
globalJsonDB := &JsonDB{
|
||||
Path: Path,
|
||||
filenameDelimiter: ".",
|
||||
filenameExtension: ".json",
|
||||
fileMode: fs.FileMode(0o666),
|
||||
xPathDelimeter: "/",
|
||||
}
|
||||
return globalJsonDB
|
||||
}
|
||||
|
||||
func (v *JsonDB) CloseDB() {
|
||||
// Not necessary
|
||||
}
|
||||
|
||||
func (v *JsonDB) Set(xPath, name string, value []byte) {
|
||||
var err error = nil
|
||||
jsonObj := map[string]interface{}{}
|
||||
if err := json.Unmarshal(value, &jsonObj); err == nil {
|
||||
if filename, err := v.xPathToFilename(xPath); err == nil {
|
||||
v.lock(filename)
|
||||
defer v.unlock(filename)
|
||||
if root, err := v.readJsonFileAsMap(filename); err == nil {
|
||||
root[name] = jsonObj
|
||||
if err = v.writeMapAsJsonFile(filename, root); err == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
v.log(fmt.Sprintf("Set: error writing entry %s->%s", xPath, name), err)
|
||||
}
|
||||
|
||||
func (v *JsonDB) Get(xPath, name string) []byte {
|
||||
var err error = nil
|
||||
if filename, err := v.xPathToFilename(xPath); err == nil {
|
||||
v.lock(filename)
|
||||
defer v.unlock(filename)
|
||||
if root, err := v.readJsonFileAsMap(filename); err == nil {
|
||||
if jsonData, ok := root[name]; ok {
|
||||
if byteData, err := json.Marshal(jsonData); err == nil {
|
||||
// Return a copy to be safe
|
||||
data := make([]byte, len(byteData))
|
||||
copy(data, byteData)
|
||||
return data
|
||||
}
|
||||
} else {
|
||||
// We assume this is not 'error' but 'no entry' which is normal
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
v.log(fmt.Sprintf("Get: error reading entry %s->%s", xPath, name), err)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *JsonDB) List(xPath string) []string {
|
||||
var err error = nil
|
||||
if filename, err := v.xPathToFilename(xPath); err == nil {
|
||||
v.lock(filename)
|
||||
defer v.unlock(filename)
|
||||
if root, err := v.readJsonFileAsMap(filename); err == nil {
|
||||
nameList := make([]string, 0, len(root))
|
||||
for k := range root {
|
||||
nameList = append(nameList, k)
|
||||
}
|
||||
return nameList
|
||||
}
|
||||
}
|
||||
v.log(fmt.Sprintf("List: error reading entries in xPath %s", xPath), err)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *JsonDB) Rem(xPath, name string) {
|
||||
var err error = nil
|
||||
if filename, err := v.xPathToFilename(xPath); err == nil {
|
||||
v.lock(filename)
|
||||
defer v.unlock(filename)
|
||||
if root, err := v.readJsonFileAsMap(filename); err == nil {
|
||||
delete(root, name)
|
||||
if err = v.writeMapAsJsonFile(filename, root); err == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
v.log(fmt.Sprintf("Rem: error removing entry %s->%s", xPath, name), err)
|
||||
}
|
||||
|
||||
func (v *JsonDB) Clear(xPath string) {
|
||||
filename, err := v.xPathToFilename(xPath)
|
||||
if err != nil {
|
||||
v.log(fmt.Sprintf("Clear: error converting xPath %s to filename: %v", xPath, err))
|
||||
return
|
||||
}
|
||||
|
||||
v.lock(filename)
|
||||
defer v.unlock(filename)
|
||||
|
||||
path := filepath.Join(v.Path, filename)
|
||||
emptyData := []byte("{}")
|
||||
|
||||
if err := os.WriteFile(path, emptyData, v.fileMode); err != nil {
|
||||
v.log(fmt.Sprintf("Clear: error writing empty file for xPath %s: %v", xPath, err))
|
||||
}
|
||||
}
|
||||
|
||||
func (v *JsonDB) lock(filename string) {
|
||||
jsonDbLocksMutex.Lock()
|
||||
mtx, ok := jsonDbLocks[filename]
|
||||
if !ok {
|
||||
mtx = &sync.Mutex{}
|
||||
jsonDbLocks[filename] = mtx
|
||||
}
|
||||
jsonDbLocksMutex.Unlock()
|
||||
mtx.Lock()
|
||||
}
|
||||
|
||||
func (v *JsonDB) unlock(filename string) {
|
||||
jsonDbLocksMutex.Lock()
|
||||
if mtx, ok := jsonDbLocks[filename]; ok {
|
||||
mtx.Unlock()
|
||||
}
|
||||
jsonDbLocksMutex.Unlock()
|
||||
}
|
||||
|
||||
func (v *JsonDB) xPathToFilename(xPath string) (string, error) {
|
||||
if pathComponents := strings.Split(xPath, v.xPathDelimeter); len(pathComponents) > 0 {
|
||||
return strings.ToLower(strings.Join(pathComponents, v.filenameDelimiter) + v.filenameExtension), nil
|
||||
}
|
||||
return "", errors.New("xPath has no components")
|
||||
}
|
||||
|
||||
func (v *JsonDB) readJsonFileAsMap(filename string) (map[string]interface{}, error) {
|
||||
var err error = nil
|
||||
jsonData := map[string]interface{}{}
|
||||
path := filepath.Join(v.Path, filename)
|
||||
if fileData, err := os.ReadFile(path); err == nil {
|
||||
if err = json.Unmarshal(fileData, &jsonData); err != nil {
|
||||
v.log(fmt.Sprintf("readJsonFileAsMap(%s) fileData: %s error", filename, fileData), err)
|
||||
}
|
||||
}
|
||||
return jsonData, err
|
||||
}
|
||||
|
||||
func (v *JsonDB) writeMapAsJsonFile(filename string, o map[string]interface{}) error {
|
||||
var err error = nil
|
||||
path := filepath.Join(v.Path, filename)
|
||||
if fileData, err := json.MarshalIndent(o, "", " "); err == nil {
|
||||
if err = os.WriteFile(path, fileData, v.fileMode); err != nil {
|
||||
v.log(fmt.Sprintf("writeMapAsJsonFile path: %s, fileMode: %s, fileData: %s error", path, v.fileMode, fileData), err)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (v *JsonDB) log(s string, params ...interface{}) {
|
||||
if len(params) > 0 {
|
||||
log.TLogln(fmt.Sprintf("JsonDB: %s: %s", s, fmt.Sprint(params...)))
|
||||
} else {
|
||||
log.TLogln(fmt.Sprintf("JsonDB: %s", s))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"server/log"
|
||||
"server/web/api/utils"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
var dbTorrentsName = []byte("Torrents")
|
||||
|
||||
type torrentBackupDB struct {
|
||||
Name string
|
||||
Magnet string
|
||||
InfoBytes []byte
|
||||
Hash string
|
||||
Size int64
|
||||
Timestamp int64
|
||||
}
|
||||
|
||||
// Migrate from torrserver.db to config.db
|
||||
// TODO: migrate categories and data too
|
||||
func MigrateTorrents() {
|
||||
if _, err := os.Lstat(filepath.Join(Path, "torrserver.db")); os.IsNotExist(err) {
|
||||
return
|
||||
}
|
||||
|
||||
db, err := bolt.Open(filepath.Join(Path, "torrserver.db"), 0o666, &bolt.Options{Timeout: 5 * time.Second})
|
||||
if err != nil {
|
||||
log.TLogln("MigrateTorrents", err)
|
||||
return
|
||||
}
|
||||
|
||||
torrs := make([]*torrentBackupDB, 0)
|
||||
err = db.View(func(tx *bolt.Tx) error {
|
||||
tdb := tx.Bucket(dbTorrentsName)
|
||||
if tdb == nil {
|
||||
return nil
|
||||
}
|
||||
c := tdb.Cursor()
|
||||
for h, _ := c.First(); h != nil; h, _ = c.Next() {
|
||||
hdb := tdb.Bucket(h)
|
||||
if hdb != nil {
|
||||
torr := new(torrentBackupDB)
|
||||
torr.Hash = string(h)
|
||||
tmp := hdb.Get([]byte("Name"))
|
||||
if tmp == nil {
|
||||
return fmt.Errorf("error load torrent")
|
||||
}
|
||||
torr.Name = string(tmp)
|
||||
|
||||
tmp = hdb.Get([]byte("Link"))
|
||||
if tmp == nil {
|
||||
return fmt.Errorf("error load torrent")
|
||||
}
|
||||
torr.Magnet = string(tmp)
|
||||
|
||||
tmp = hdb.Get([]byte("Size"))
|
||||
if tmp == nil {
|
||||
return fmt.Errorf("error load torrent")
|
||||
}
|
||||
torr.Size = b2i(tmp)
|
||||
|
||||
tmp = hdb.Get([]byte("Timestamp"))
|
||||
if tmp == nil {
|
||||
return fmt.Errorf("error load torrent")
|
||||
}
|
||||
torr.Timestamp = b2i(tmp)
|
||||
|
||||
torrs = append(torrs, torr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
db.Close()
|
||||
if err == nil && len(torrs) > 0 {
|
||||
for _, torr := range torrs {
|
||||
spec, err := utils.ParseLink(torr.Magnet)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
title := torr.Name
|
||||
if len(spec.DisplayName) > len(title) {
|
||||
title = spec.DisplayName
|
||||
}
|
||||
log.TLogln("Migrate torrent", torr.Name, torr.Hash, torr.Size)
|
||||
AddTorrent(&TorrentDB{
|
||||
TorrentSpec: spec,
|
||||
Title: title,
|
||||
Timestamp: torr.Timestamp,
|
||||
Size: torr.Size,
|
||||
})
|
||||
}
|
||||
}
|
||||
os.Remove(filepath.Join(Path, "torrserver.db"))
|
||||
}
|
||||
|
||||
// MigrateSettingsToJson migrates Settings from BBolt to JSON
|
||||
func MigrateSettingsToJson(bboltDB, jsonDB TorrServerDB) error {
|
||||
// if BTsets != nil {
|
||||
// return errors.New("migration must be called before initializing BTSets")
|
||||
// }
|
||||
migrated, err := MigrateSingle(bboltDB, jsonDB, "Settings", "BitTorr")
|
||||
if migrated {
|
||||
log.TLogln("Settings migrated from BBolt to JSON")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// MigrateSettingsFromJson migrates Settings from JSON to BBolt
|
||||
func MigrateSettingsFromJson(jsonDB, bboltDB TorrServerDB) error {
|
||||
// if BTsets != nil {
|
||||
// return errors.New("migration must be called before initializing BTSets")
|
||||
// }
|
||||
migrated, err := MigrateSingle(jsonDB, bboltDB, "Settings", "BitTorr")
|
||||
if migrated {
|
||||
log.TLogln("Settings migrated from JSON to BBolt")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// MigrateViewedToJson migrates Viewed data from BBolt to JSON
|
||||
func MigrateViewedToJson(bboltDB, jsonDB TorrServerDB) error {
|
||||
migrated, skipped, err := MigrateAll(bboltDB, jsonDB, "Viewed")
|
||||
log.TLogln(fmt.Sprintf("Viewed->JSON: %d migrated, %d skipped", migrated, skipped))
|
||||
return err
|
||||
}
|
||||
|
||||
// MigrateViewedFromJson migrates Viewed data from JSON to BBolt
|
||||
func MigrateViewedFromJson(jsonDB, bboltDB TorrServerDB) error {
|
||||
migrated, skipped, err := MigrateAll(jsonDB, bboltDB, "Viewed")
|
||||
log.TLogln(fmt.Sprintf("Viewed->BBolt: %d migrated, %d skipped", migrated, skipped))
|
||||
return err
|
||||
}
|
||||
|
||||
// MigrateSingle migrates a single entry with validation
|
||||
// Returns: (migrated bool, error)
|
||||
func MigrateSingle(source, target TorrServerDB, xpath, name string) (bool, error) {
|
||||
sourceData := source.Get(xpath, name)
|
||||
if sourceData == nil {
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("No data to migrate for %s/%s", xpath, name))
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
targetData := target.Get(xpath, name)
|
||||
if targetData != nil {
|
||||
// Check if already identical
|
||||
if equal, err := isByteArraysEqualJson(sourceData, targetData); err == nil && equal {
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("Skipping %s/%s (already identical)", xpath, name))
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Perform migration
|
||||
target.Set(xpath, name, sourceData)
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("Migrating %s/%s", xpath, name))
|
||||
}
|
||||
|
||||
// Verify migration
|
||||
if err := verifyMigration(source, target, xpath, name, sourceData); err != nil {
|
||||
return false, fmt.Errorf("migration verification failed for %s/%s: %w", xpath, name, err)
|
||||
}
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("Successfully migrated %s/%s", xpath, name))
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// MigrateAll migrates all entries in an xpath with validation
|
||||
// Returns: (migratedCount, skippedCount, error)
|
||||
func MigrateAll(source, target TorrServerDB, xpath string) (int, int, error) {
|
||||
names := source.List(xpath)
|
||||
if len(names) == 0 {
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("No entries to migrate for %s", xpath))
|
||||
}
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
migratedCount := 0
|
||||
skippedCount := 0
|
||||
var firstError error
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("Starting migration of %d %s entries", len(names), xpath))
|
||||
}
|
||||
for i, name := range names {
|
||||
sourceData := source.Get(xpath, name)
|
||||
if sourceData == nil {
|
||||
skippedCount++
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("[%d/%d] Skipping %s/%s (no data in source)",
|
||||
i+1, len(names), xpath, name))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
targetData := target.Get(xpath, name)
|
||||
if targetData != nil {
|
||||
// Check if already identical
|
||||
if equal, err := isByteArraysEqualJson(sourceData, targetData); err == nil && equal {
|
||||
skippedCount++
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("[%d/%d] Skipping %s/%s (already identical)",
|
||||
i+1, len(names), xpath, name))
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Perform migration
|
||||
target.Set(xpath, name, sourceData)
|
||||
|
||||
// Verify migration
|
||||
if err := verifyMigration(source, target, xpath, name, sourceData); err != nil {
|
||||
log.TLogln(fmt.Sprintf("[%d/%d] Migration failed for %s/%s: %v",
|
||||
i+1, len(names), xpath, name, err))
|
||||
if firstError == nil {
|
||||
firstError = err
|
||||
}
|
||||
} else {
|
||||
migratedCount++
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("[%d/%d] Successfully migrated %s/%s",
|
||||
i+1, len(names), xpath, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("%s migration complete: %d migrated, %d skipped",
|
||||
xpath, migratedCount, skippedCount)
|
||||
if firstError != nil {
|
||||
summary += fmt.Sprintf(", 1+ errors (first: %v)", firstError)
|
||||
}
|
||||
if IsDebug() {
|
||||
log.TLogln(summary)
|
||||
}
|
||||
|
||||
return migratedCount, skippedCount, firstError
|
||||
}
|
||||
|
||||
// SmartMigrate - keep for manual/advanced use
|
||||
func SmartMigrate(bboltDB, jsonDB TorrServerDB, forceDirection string) error {
|
||||
// if BTsets != nil {
|
||||
// return errors.New("migration must be called before initializing BTSets")
|
||||
// }
|
||||
switch forceDirection {
|
||||
case "viewed_to_json":
|
||||
return MigrateViewedToJson(bboltDB, jsonDB)
|
||||
case "viewed_to_bbolt":
|
||||
return MigrateViewedFromJson(jsonDB, bboltDB)
|
||||
case "settings_to_json":
|
||||
return MigrateSettingsToJson(bboltDB, jsonDB)
|
||||
case "settings_to_bbolt":
|
||||
return MigrateSettingsFromJson(jsonDB, bboltDB)
|
||||
case "sync_both":
|
||||
// Simple sync: copy missing data both ways
|
||||
if err := migrateMissing(bboltDB, jsonDB, "Settings", "BitTorr"); err != nil {
|
||||
return err
|
||||
}
|
||||
return syncViewedSimple(bboltDB, jsonDB)
|
||||
default:
|
||||
return fmt.Errorf("unknown migration direction: %s", forceDirection)
|
||||
}
|
||||
}
|
||||
|
||||
func isByteArraysEqualJson(a, b []byte) (bool, error) {
|
||||
if len(a) == 0 && len(b) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
// Quick check: same length and byte equality
|
||||
if len(a) == len(b) {
|
||||
// Fast path: byte-by-byte comparison
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
break // Need to parse as JSON
|
||||
}
|
||||
}
|
||||
// If we get here, bytes are identical
|
||||
return true, nil
|
||||
}
|
||||
// Parse as JSON for structural comparison
|
||||
var objectA, objectB interface{}
|
||||
|
||||
if err := json.Unmarshal(a, &objectA); err != nil {
|
||||
return false, fmt.Errorf("error unmarshalling A: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(b, &objectB); err != nil {
|
||||
return false, fmt.Errorf("error unmarshalling B: %w", err)
|
||||
}
|
||||
|
||||
return reflect.DeepEqual(objectA, objectB), nil
|
||||
}
|
||||
|
||||
// Optimized version for performance
|
||||
func isByteArraysEqualJsonOptimized(a, b []byte) (bool, error) {
|
||||
// Fast paths
|
||||
if a == nil && b == nil {
|
||||
return true, nil
|
||||
}
|
||||
if len(a) != len(b) {
|
||||
return false, nil
|
||||
}
|
||||
if len(a) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
// Byte equality (fastest check)
|
||||
equal := true
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
equal = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if equal {
|
||||
return true, nil
|
||||
}
|
||||
// Parse as JSON (slower but accurate)
|
||||
return isByteArraysEqualJson(a, b)
|
||||
}
|
||||
|
||||
func verifyMigration(source, target TorrServerDB, xpath, name string, originalData []byte) error {
|
||||
// Get migrated data
|
||||
migratedData := target.Get(xpath, name)
|
||||
if migratedData == nil {
|
||||
return fmt.Errorf("migration failed: no data after migration for %s/%s", xpath, name)
|
||||
}
|
||||
// Compare with original
|
||||
if equal, err := isByteArraysEqualJsonOptimized(originalData, migratedData); err != nil {
|
||||
return fmt.Errorf("verification failed for %s/%s: %w", xpath, name, err)
|
||||
} else if !equal {
|
||||
return fmt.Errorf("data mismatch after migration for %s/%s", xpath, name)
|
||||
}
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("Verified migration of %s/%s", xpath, name))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func b2i(v []byte) int64 {
|
||||
return int64(binary.BigEndian.Uint64(v))
|
||||
}
|
||||
|
||||
func migrateMissing(db1, db2 TorrServerDB, xpath, name string) error {
|
||||
// Copy from db1 to db2 if missing
|
||||
if db2.Get(xpath, name) == nil {
|
||||
if data := db1.Get(xpath, name); data != nil {
|
||||
db2.Set(xpath, name, data)
|
||||
}
|
||||
}
|
||||
// Copy from db2 to db1 if missing
|
||||
if db1.Get(xpath, name) == nil {
|
||||
if data := db2.Get(xpath, name); data != nil {
|
||||
db1.Set(xpath, name, data)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncViewedSimple(bboltDB, jsonDB TorrServerDB) error {
|
||||
// Get all hashes from both
|
||||
bboltHashes := bboltDB.List("Viewed")
|
||||
jsonHashes := jsonDB.List("Viewed")
|
||||
|
||||
allHashes := make(map[string]bool)
|
||||
for _, h := range bboltHashes {
|
||||
allHashes[h] = true
|
||||
}
|
||||
for _, h := range jsonHashes {
|
||||
allHashes[h] = true
|
||||
}
|
||||
|
||||
// For each hash, ensure it exists in both with merged data
|
||||
for hash := range allHashes {
|
||||
bboltData := bboltDB.Get("Viewed", hash)
|
||||
jsonData := jsonDB.Get("Viewed", hash)
|
||||
|
||||
merged := mergeViewedDataSimple(bboltData, jsonData)
|
||||
if merged != nil {
|
||||
bboltDB.Set("Viewed", hash, merged)
|
||||
jsonDB.Set("Viewed", hash, merged)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeViewedDataSimple(data1, data2 []byte) []byte {
|
||||
if data1 == nil && data2 == nil {
|
||||
return nil
|
||||
}
|
||||
if data1 == nil {
|
||||
return data2
|
||||
}
|
||||
if data2 == nil {
|
||||
return data1
|
||||
}
|
||||
|
||||
// Try to merge
|
||||
var indices1, indices2 map[int]struct{}
|
||||
json.Unmarshal(data1, &indices1)
|
||||
json.Unmarshal(data2, &indices2)
|
||||
|
||||
merged := make(map[int]struct{})
|
||||
for idx := range indices1 {
|
||||
merged[idx] = struct{}{}
|
||||
}
|
||||
for idx := range indices2 {
|
||||
merged[idx] = struct{}{}
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(merged)
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"server/log"
|
||||
)
|
||||
|
||||
// Add a global lock for database operations during migration
|
||||
var dbMigrationLock sync.RWMutex
|
||||
|
||||
func IsDebug() bool {
|
||||
if BTsets != nil {
|
||||
return BTsets.EnableDebug
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var (
|
||||
tdb TorrServerDB
|
||||
Path string
|
||||
IP string
|
||||
Port string
|
||||
Ssl bool
|
||||
SslPort string
|
||||
ReadOnly bool
|
||||
HttpAuth bool
|
||||
SearchWA bool
|
||||
PubIPv4 string
|
||||
PubIPv6 string
|
||||
TorAddr string
|
||||
MaxSize int64
|
||||
)
|
||||
|
||||
func InitSets(readOnly, searchWA bool) {
|
||||
ReadOnly = readOnly
|
||||
SearchWA = searchWA
|
||||
|
||||
bboltDB := NewTDB()
|
||||
if bboltDB == nil {
|
||||
log.TLogln("Error open bboltDB:", filepath.Join(Path, "config.db"))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
jsonDB := NewJsonDB()
|
||||
if jsonDB == nil {
|
||||
log.TLogln("Error open jsonDB")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Optional forced migration (for manual control)
|
||||
if migrationMode := os.Getenv("TS_MIGRATION_MODE"); migrationMode != "" {
|
||||
log.TLogln(fmt.Sprintf("Executing forced migration: %s", migrationMode))
|
||||
if err := SmartMigrate(bboltDB, jsonDB, migrationMode); err != nil {
|
||||
log.TLogln("Migration warning:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine storage preferences
|
||||
settingsStoragePref, viewedStoragePref := determineStoragePreferences(bboltDB, jsonDB)
|
||||
|
||||
// Apply migrations (clean, one-way)
|
||||
applyCleanMigrations(bboltDB, jsonDB, settingsStoragePref, viewedStoragePref)
|
||||
|
||||
// Setup routing
|
||||
setupDatabaseRouting(bboltDB, jsonDB, settingsStoragePref, viewedStoragePref)
|
||||
|
||||
// Load settings
|
||||
loadBTSets()
|
||||
|
||||
// Update preferences if they changed
|
||||
if BTsets != nil && (BTsets.StoreSettingsInJson != settingsStoragePref || BTsets.StoreViewedInJson != viewedStoragePref) {
|
||||
BTsets.StoreSettingsInJson = settingsStoragePref
|
||||
BTsets.StoreViewedInJson = viewedStoragePref
|
||||
SetBTSets(BTsets)
|
||||
}
|
||||
|
||||
// Migrate old torrents
|
||||
MigrateTorrents()
|
||||
|
||||
logConfiguration(settingsStoragePref, viewedStoragePref)
|
||||
}
|
||||
|
||||
func determineStoragePreferences(bboltDB, jsonDB TorrServerDB) (settingsInJson, viewedInJson bool) {
|
||||
// Try to load existing settings first
|
||||
if existing := loadExistingSettings(bboltDB, jsonDB); existing != nil {
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("Found settings: StoreSettingsInJson=%v, StoreViewedInJson=%v",
|
||||
existing.StoreSettingsInJson, existing.StoreViewedInJson))
|
||||
}
|
||||
// Check if these are actually set or just default zero values
|
||||
// For now, trust the stored values
|
||||
return existing.StoreSettingsInJson, existing.StoreViewedInJson
|
||||
}
|
||||
|
||||
// Defaults (if not set by user)
|
||||
settingsInJson = true // JSON for settings (easy editable)
|
||||
viewedInJson = false // BBolt for viewed (performance)
|
||||
|
||||
// Environment overrides
|
||||
if env := os.Getenv("TS_SETTINGS_STORAGE"); env != "" {
|
||||
settingsInJson = (env == "json")
|
||||
}
|
||||
if env := os.Getenv("TS_VIEWED_STORAGE"); env != "" {
|
||||
viewedInJson = (env == "json")
|
||||
}
|
||||
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("Using flags: settingsInJson=%v, viewedInJson=%v",
|
||||
settingsInJson, viewedInJson))
|
||||
}
|
||||
return settingsInJson, viewedInJson
|
||||
}
|
||||
|
||||
func loadExistingSettings(bboltDB, jsonDB TorrServerDB) *BTSets {
|
||||
// Try JSON first
|
||||
if buf := jsonDB.Get("Settings", "BitTorr"); buf != nil {
|
||||
var sets BTSets
|
||||
if err := json.Unmarshal(buf, &sets); err == nil {
|
||||
return &sets
|
||||
}
|
||||
}
|
||||
// Try BBolt
|
||||
if buf := bboltDB.Get("Settings", "BitTorr"); buf != nil {
|
||||
var sets BTSets
|
||||
if err := json.Unmarshal(buf, &sets); err == nil {
|
||||
return &sets
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// func loadExistingSettingsDebug(bboltDB, jsonDB TorrServerDB) *BTSets {
|
||||
// // Try JSON first
|
||||
// if buf := jsonDB.Get("Settings", "BitTorr"); buf != nil {
|
||||
// log.TLogln(fmt.Sprintf("Found settings in JSON, size: %d bytes", len(buf)))
|
||||
// var sets BTSets
|
||||
// if err := json.Unmarshal(buf, &sets); err == nil {
|
||||
// log.TLogln(fmt.Sprintf("Parsed from JSON: StoreSettingsInJson=%v, StoreViewedInJson=%v",
|
||||
// sets.StoreSettingsInJson, sets.StoreViewedInJson))
|
||||
// return &sets
|
||||
// } else {
|
||||
// log.TLogln(fmt.Sprintf("Failed to parse JSON settings: %v", err))
|
||||
// }
|
||||
// } else {
|
||||
// log.TLogln("No settings found in JSON")
|
||||
// }
|
||||
|
||||
// // Try BBolt
|
||||
// if buf := bboltDB.Get("Settings", "BitTorr"); buf != nil {
|
||||
// log.TLogln(fmt.Sprintf("Found settings in BBolt, size: %d bytes", len(buf)))
|
||||
// var sets BTSets
|
||||
// if err := json.Unmarshal(buf, &sets); err == nil {
|
||||
// log.TLogln(fmt.Sprintf("Parsed from BBolt: StoreSettingsInJson=%v, StoreViewedInJson=%v",
|
||||
// sets.StoreSettingsInJson, sets.StoreViewedInJson))
|
||||
// return &sets
|
||||
// } else {
|
||||
// log.TLogln(fmt.Sprintf("Failed to parse BBolt settings: %v", err))
|
||||
// }
|
||||
// } else {
|
||||
// log.TLogln("No settings found in BBolt")
|
||||
// }
|
||||
|
||||
// log.TLogln("No existing storage settings found")
|
||||
// return nil
|
||||
// }
|
||||
|
||||
func applyCleanMigrations(bboltDB, jsonDB TorrServerDB, settingsInJson, viewedInJson bool) {
|
||||
// Settings migration
|
||||
if settingsInJson {
|
||||
safeMigrate(bboltDB, jsonDB, "Settings", "BitTorr", "JSON", true)
|
||||
} else {
|
||||
safeMigrate(jsonDB, bboltDB, "Settings", "BitTorr", "BBolt", true)
|
||||
}
|
||||
|
||||
// Viewed migration
|
||||
if viewedInJson {
|
||||
safeMigrateAll(bboltDB, jsonDB, "Viewed", "JSON", true)
|
||||
} else {
|
||||
safeMigrateAll(jsonDB, bboltDB, "Viewed", "BBolt", true)
|
||||
}
|
||||
}
|
||||
|
||||
func safeMigrate(source, target TorrServerDB, xpath, name, targetName string, clearSource bool) {
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("Checking migration of %s/%s to %s", xpath, name, targetName))
|
||||
}
|
||||
|
||||
migrated, err := MigrateSingle(source, target, xpath, name)
|
||||
if err != nil {
|
||||
log.TLogln(fmt.Sprintf("Migration error for %s/%s: %v", xpath, name, err))
|
||||
return
|
||||
}
|
||||
|
||||
if migrated {
|
||||
log.TLogln(fmt.Sprintf("Successfully migrated %s/%s to %s", xpath, name, targetName))
|
||||
// Clear source if requested
|
||||
if clearSource {
|
||||
source.Rem(xpath, name)
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("Cleared %s/%s from source", xpath, name))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.TLogln(fmt.Sprintf("No migration needed for %s/%s (already exists or no data)",
|
||||
xpath, name))
|
||||
}
|
||||
}
|
||||
|
||||
func safeMigrateAll(source, target TorrServerDB, xpath, targetName string, clearSource bool) {
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("Starting migration of all %s entries to %s", xpath, targetName))
|
||||
}
|
||||
|
||||
migrated, skipped, err := MigrateAll(source, target, xpath)
|
||||
log.TLogln(fmt.Sprintf("%s migration result: %d migrated, %d skipped", xpath, migrated, skipped))
|
||||
if err != nil {
|
||||
log.TLogln(fmt.Sprintf("Migration had errors: %v", err))
|
||||
}
|
||||
// Clear source if requested and we successfully migrated entries
|
||||
if clearSource && migrated > 0 {
|
||||
sourceCount := len(source.List(xpath))
|
||||
// Only clear if we migrated at least as many as were in source
|
||||
// (accounting for possible duplicates)
|
||||
if migrated >= sourceCount {
|
||||
source.Clear(xpath)
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("Cleared all %s entries from source", xpath))
|
||||
}
|
||||
} else {
|
||||
log.TLogln(fmt.Sprintf("Not clearing %s: only migrated %d of %d entries",
|
||||
xpath, migrated, sourceCount))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupDatabaseRouting(bboltDB, jsonDB TorrServerDB, settingsInJson, viewedInJson bool) {
|
||||
dbRouter := NewXPathDBRouter()
|
||||
|
||||
if settingsInJson {
|
||||
dbRouter.RegisterRoute(jsonDB, "Settings")
|
||||
} else {
|
||||
dbRouter.RegisterRoute(bboltDB, "Settings")
|
||||
}
|
||||
|
||||
if viewedInJson {
|
||||
dbRouter.RegisterRoute(jsonDB, "Viewed")
|
||||
} else {
|
||||
dbRouter.RegisterRoute(bboltDB, "Viewed")
|
||||
}
|
||||
|
||||
dbRouter.RegisterRoute(bboltDB, "Torrents")
|
||||
tdb = NewDBReadCache(dbRouter)
|
||||
}
|
||||
|
||||
func logConfiguration(settingsInJson, viewedInJson bool) {
|
||||
settingsLoc := "JSON"
|
||||
if !settingsInJson {
|
||||
settingsLoc = "BBolt"
|
||||
}
|
||||
viewedLoc := "JSON"
|
||||
if !viewedInJson {
|
||||
viewedLoc = "BBolt"
|
||||
}
|
||||
|
||||
log.TLogln(fmt.Sprintf("Storage: Settings->%s, Viewed->%s, Torrents->BBolt",
|
||||
settingsLoc, viewedLoc))
|
||||
}
|
||||
|
||||
// SwitchSettingsStorage - simplified version
|
||||
func SwitchSettingsStorage(useJson bool) error {
|
||||
if ReadOnly {
|
||||
return errors.New("read-only mode")
|
||||
}
|
||||
// Acquire exclusive lock for migration
|
||||
dbMigrationLock.Lock()
|
||||
defer dbMigrationLock.Unlock()
|
||||
|
||||
bboltDB := NewTDB()
|
||||
if bboltDB == nil {
|
||||
return errors.New("failed to open BBolt DB")
|
||||
}
|
||||
// DON'T CLOSE! They're still in use by tdb
|
||||
// defer bboltDB.CloseDB()
|
||||
|
||||
jsonDB := NewJsonDB()
|
||||
if jsonDB == nil {
|
||||
return errors.New("failed to open JSON DB")
|
||||
}
|
||||
// DON'T CLOSE! They're still in use by tdb
|
||||
// defer jsonDB.CloseDB()
|
||||
|
||||
log.TLogln(fmt.Sprintf("Switching Settings storage to %s",
|
||||
map[bool]string{true: "JSON", false: "BBolt"}[useJson]))
|
||||
|
||||
// Update storage preference (must be called before migrate as this setting migrate too)
|
||||
if BTsets != nil {
|
||||
BTsets.StoreSettingsInJson = useJson
|
||||
SetBTSets(BTsets)
|
||||
}
|
||||
|
||||
var err error
|
||||
if useJson {
|
||||
err = MigrateSettingsToJson(bboltDB, jsonDB)
|
||||
} else {
|
||||
err = MigrateSettingsFromJson(jsonDB, bboltDB)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.TLogln("Settings storage switched. Restart required for routing changes.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// SwitchViewedStorage - simplified version
|
||||
func SwitchViewedStorage(useJson bool) error {
|
||||
if ReadOnly {
|
||||
return errors.New("read-only mode")
|
||||
}
|
||||
// Acquire exclusive lock for migration
|
||||
dbMigrationLock.Lock()
|
||||
defer dbMigrationLock.Unlock()
|
||||
|
||||
bboltDB := NewTDB()
|
||||
if bboltDB == nil {
|
||||
return errors.New("failed to open BBolt DB")
|
||||
}
|
||||
// DON'T CLOSE! They're still in use by tdb
|
||||
// defer bboltDB.CloseDB()
|
||||
|
||||
jsonDB := NewJsonDB()
|
||||
if jsonDB == nil {
|
||||
return errors.New("failed to open JSON DB")
|
||||
}
|
||||
// DON'T CLOSE! They're still in use by tdb
|
||||
// defer jsonDB.CloseDB()
|
||||
|
||||
log.TLogln(fmt.Sprintf("Switching Viewed storage to %s",
|
||||
map[bool]string{true: "JSON", false: "BBolt"}[useJson]))
|
||||
|
||||
var err error
|
||||
if useJson {
|
||||
err = MigrateViewedToJson(bboltDB, jsonDB)
|
||||
if err == nil {
|
||||
bboltDB.Clear("Viewed")
|
||||
}
|
||||
} else {
|
||||
err = MigrateViewedFromJson(jsonDB, bboltDB)
|
||||
if err == nil {
|
||||
jsonDB.Clear("Viewed")
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update preference
|
||||
if BTsets != nil {
|
||||
BTsets.StoreViewedInJson = useJson
|
||||
SetBTSets(BTsets)
|
||||
}
|
||||
|
||||
log.TLogln("Viewed storage switched. Restart required for routing changes.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Used in /storage/settings web API
|
||||
func GetStoragePreferences() map[string]interface{} {
|
||||
prefs := map[string]interface{}{
|
||||
"settings": "json", // Default fallback
|
||||
"viewed": "bbolt", // Default fallback
|
||||
}
|
||||
|
||||
if BTsets != nil {
|
||||
// Convert boolean preferences to string values
|
||||
if BTsets.StoreSettingsInJson {
|
||||
prefs["settings"] = "json"
|
||||
} else {
|
||||
prefs["settings"] = "bbolt"
|
||||
}
|
||||
|
||||
if BTsets.StoreViewedInJson {
|
||||
prefs["viewed"] = "json"
|
||||
} else {
|
||||
prefs["viewed"] = "bbolt"
|
||||
}
|
||||
}
|
||||
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("GetStoragePreferences: settings=%s, viewed=%s",
|
||||
prefs["settings"], prefs["viewed"]))
|
||||
}
|
||||
if tdb != nil {
|
||||
prefs["viewedCount"] = len(tdb.List("Viewed"))
|
||||
}
|
||||
|
||||
return prefs
|
||||
}
|
||||
|
||||
// Used in /storage/settings web API
|
||||
func SetStoragePreferences(prefs map[string]interface{}) error {
|
||||
if ReadOnly || BTsets == nil {
|
||||
return errors.New("cannot change storage preferences. Read-only mode")
|
||||
}
|
||||
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("SetStoragePreferences received: %v", prefs))
|
||||
}
|
||||
|
||||
// Apply changes
|
||||
if settingsPref, ok := prefs["settings"].(string); ok && settingsPref != "" {
|
||||
useJson := (settingsPref == "json")
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("Changing settings storage to useJson=%v (was %v)",
|
||||
useJson, BTsets.StoreSettingsInJson))
|
||||
}
|
||||
if BTsets.StoreSettingsInJson != useJson {
|
||||
if err := SwitchSettingsStorage(useJson); err != nil {
|
||||
return fmt.Errorf("failed to switch settings storage: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if viewedPref, ok := prefs["viewed"].(string); ok && viewedPref != "" {
|
||||
useJson := (viewedPref == "json")
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("Changing viewed storage to useJson=%v (was %v)",
|
||||
useJson, BTsets.StoreViewedInJson))
|
||||
}
|
||||
if BTsets.StoreViewedInJson != useJson {
|
||||
if err := SwitchViewedStorage(useJson); err != nil {
|
||||
return fmt.Errorf("failed to switch viewed storage: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CloseDB() {
|
||||
if tdb != nil {
|
||||
tdb.CloseDB()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/anacrolix/torrent"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
)
|
||||
|
||||
type TorrentDB struct {
|
||||
*torrent.TorrentSpec
|
||||
|
||||
Title string `json:"title,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Poster string `json:"poster,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
|
||||
Timestamp int64 `json:"timestamp,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
}
|
||||
|
||||
type File struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Id int `json:"id,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
|
||||
func AddTorrent(torr *TorrentDB) {
|
||||
list := ListTorrent()
|
||||
mu.Lock()
|
||||
find := -1
|
||||
for i, db := range list {
|
||||
if db.InfoHash.HexString() == torr.InfoHash.HexString() {
|
||||
find = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if find != -1 {
|
||||
list[find] = torr
|
||||
} else {
|
||||
list = append(list, torr)
|
||||
}
|
||||
for _, db := range list {
|
||||
buf, err := json.Marshal(db)
|
||||
if err == nil {
|
||||
tdb.Set("Torrents", db.InfoHash.HexString(), buf)
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
func ListTorrent() []*TorrentDB {
|
||||
// Use read lock to prevent migration during read
|
||||
dbMigrationLock.RLock()
|
||||
defer dbMigrationLock.RUnlock()
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
var list []*TorrentDB
|
||||
keys := tdb.List("Torrents")
|
||||
for _, key := range keys {
|
||||
buf := tdb.Get("Torrents", key)
|
||||
if len(buf) > 0 {
|
||||
var torr *TorrentDB
|
||||
err := json.Unmarshal(buf, &torr)
|
||||
if err == nil {
|
||||
list = append(list, torr)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
return list[i].Timestamp > list[j].Timestamp
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
func RemTorrent(hash metainfo.Hash) {
|
||||
mu.Lock()
|
||||
tdb.Rem("Torrents", hash.HexString())
|
||||
mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package settings
|
||||
|
||||
type TorrServerDB interface {
|
||||
CloseDB()
|
||||
Get(xPath, name string) []byte
|
||||
Set(xPath, name string, value []byte)
|
||||
List(xPath string) []string
|
||||
Rem(xPath, name string)
|
||||
Clear(xPath string)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"server/log"
|
||||
)
|
||||
|
||||
type Viewed struct {
|
||||
Hash string `json:"hash"`
|
||||
FileIndex int `json:"file_index"`
|
||||
}
|
||||
|
||||
func SetViewed(vv *Viewed) {
|
||||
var indexes map[int]struct{}
|
||||
var err error
|
||||
|
||||
buf := tdb.Get("Viewed", vv.Hash)
|
||||
if len(buf) == 0 {
|
||||
indexes = make(map[int]struct{})
|
||||
indexes[vv.FileIndex] = struct{}{}
|
||||
buf, err = json.Marshal(indexes)
|
||||
if err == nil {
|
||||
tdb.Set("Viewed", vv.Hash, buf)
|
||||
}
|
||||
} else {
|
||||
err = json.Unmarshal(buf, &indexes)
|
||||
if err == nil {
|
||||
indexes[vv.FileIndex] = struct{}{}
|
||||
buf, err = json.Marshal(indexes)
|
||||
if err == nil {
|
||||
tdb.Set("Viewed", vv.Hash, buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
log.TLogln("Error set viewed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func RemViewed(vv *Viewed) {
|
||||
buf := tdb.Get("Viewed", vv.Hash)
|
||||
var indeces map[int]struct{}
|
||||
err := json.Unmarshal(buf, &indeces)
|
||||
if err == nil {
|
||||
if vv.FileIndex != -1 {
|
||||
delete(indeces, vv.FileIndex)
|
||||
buf, err = json.Marshal(indeces)
|
||||
if err == nil {
|
||||
tdb.Set("Viewed", vv.Hash, buf)
|
||||
}
|
||||
} else {
|
||||
tdb.Rem("Viewed", vv.Hash)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
log.TLogln("Error rem viewed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func ListViewed(hash string) []*Viewed {
|
||||
var err error
|
||||
if hash != "" {
|
||||
buf := tdb.Get("Viewed", hash)
|
||||
if len(buf) == 0 {
|
||||
return []*Viewed{}
|
||||
}
|
||||
var indeces map[int]struct{}
|
||||
err = json.Unmarshal(buf, &indeces)
|
||||
if err == nil {
|
||||
var ret []*Viewed
|
||||
for i := range indeces {
|
||||
ret = append(ret, &Viewed{hash, i})
|
||||
}
|
||||
return ret
|
||||
}
|
||||
} else {
|
||||
var ret []*Viewed
|
||||
keys := tdb.List("Viewed")
|
||||
for _, key := range keys {
|
||||
buf := tdb.Get("Viewed", key)
|
||||
if len(buf) == 0 {
|
||||
return []*Viewed{}
|
||||
}
|
||||
var indeces map[int]struct{}
|
||||
err = json.Unmarshal(buf, &indeces)
|
||||
if err == nil {
|
||||
for i := range indeces {
|
||||
ret = append(ret, &Viewed{key, i})
|
||||
}
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
log.TLogln("Error list viewed:", err)
|
||||
return []*Viewed{}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"server/log"
|
||||
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
type XPathDBRouter struct {
|
||||
dbs []TorrServerDB
|
||||
routes []string
|
||||
route2db map[string]TorrServerDB
|
||||
dbNames map[TorrServerDB]string
|
||||
}
|
||||
|
||||
func NewXPathDBRouter() *XPathDBRouter {
|
||||
router := &XPathDBRouter{
|
||||
dbs: []TorrServerDB{},
|
||||
dbNames: map[TorrServerDB]string{},
|
||||
routes: []string{},
|
||||
route2db: map[string]TorrServerDB{},
|
||||
}
|
||||
return router
|
||||
}
|
||||
|
||||
func (v *XPathDBRouter) RegisterRoute(db TorrServerDB, xPath string) error {
|
||||
newRoute := v.xPathToRoute(xPath)
|
||||
|
||||
if slices.Contains(v.routes, newRoute) {
|
||||
return fmt.Errorf("route \"%s\" already in routing table", newRoute)
|
||||
}
|
||||
|
||||
// First DB becomes Default DB with default route
|
||||
if len(v.dbs) == 0 && len(newRoute) != 0 {
|
||||
v.RegisterRoute(db, "")
|
||||
}
|
||||
|
||||
if !slices.Contains(v.dbs, db) {
|
||||
v.dbs = append(v.dbs, db)
|
||||
v.dbNames[db] = reflect.TypeOf(db).Elem().Name()
|
||||
v.log(fmt.Sprintf("Registered new DB \"%s\", total %d DBs registered", v.getDBName(db), len(v.dbs)))
|
||||
}
|
||||
|
||||
v.route2db[newRoute] = db
|
||||
v.routes = append(v.routes, newRoute)
|
||||
|
||||
// Sort routes by length descending.
|
||||
// It is important later to help selecting
|
||||
// most suitable route in getDBForXPath(xPath)
|
||||
sort.Slice(v.routes, func(iLeft, iRight int) bool {
|
||||
return len(v.routes[iLeft]) > len(v.routes[iRight])
|
||||
})
|
||||
v.log(fmt.Sprintf("Registered new route \"%s\" for DB \"%s\", total %d routes", getDefaultRoureName(newRoute), v.getDBName(db), len(v.routes)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func getDefaultRoureName(route string) string {
|
||||
if len(route) > 0 {
|
||||
return route
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
func (v *XPathDBRouter) xPathToRoute(xPath string) string {
|
||||
return strings.ToLower(strings.TrimSpace(xPath))
|
||||
}
|
||||
|
||||
func (v *XPathDBRouter) getDBForXPath(xPath string) TorrServerDB {
|
||||
if len(v.dbs) == 0 {
|
||||
return nil
|
||||
}
|
||||
lookup_route := v.xPathToRoute(xPath)
|
||||
var db TorrServerDB = nil
|
||||
// Expected v.routes sorted by length descending
|
||||
for _, route_prefix := range v.routes {
|
||||
if strings.HasPrefix(lookup_route, route_prefix) {
|
||||
db = v.route2db[route_prefix]
|
||||
break
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func (v *XPathDBRouter) Get(xPath, name string) []byte {
|
||||
return v.getDBForXPath(xPath).Get(xPath, name)
|
||||
}
|
||||
|
||||
func (v *XPathDBRouter) Set(xPath, name string, value []byte) {
|
||||
v.getDBForXPath(xPath).Set(xPath, name, value)
|
||||
}
|
||||
|
||||
func (v *XPathDBRouter) List(xPath string) []string {
|
||||
return v.getDBForXPath(xPath).List(xPath)
|
||||
}
|
||||
|
||||
func (v *XPathDBRouter) Rem(xPath, name string) {
|
||||
v.getDBForXPath(xPath).Rem(xPath, name)
|
||||
}
|
||||
|
||||
func (v *XPathDBRouter) Clear(xPath string) {
|
||||
v.getDBForXPath(xPath).Clear(xPath)
|
||||
}
|
||||
|
||||
func (v *XPathDBRouter) CloseDB() {
|
||||
for _, db := range v.dbs {
|
||||
db.CloseDB()
|
||||
}
|
||||
v.dbs = nil
|
||||
v.routes = nil
|
||||
v.route2db = nil
|
||||
v.dbNames = nil
|
||||
}
|
||||
|
||||
func (v *XPathDBRouter) getDBName(db TorrServerDB) string {
|
||||
return v.dbNames[db]
|
||||
}
|
||||
|
||||
func (v *XPathDBRouter) log(s string, params ...interface{}) {
|
||||
if len(params) > 0 {
|
||||
log.TLogln(fmt.Sprintf("XPathDBRouter: %s: %s", s, fmt.Sprint(params...)))
|
||||
} else {
|
||||
log.TLogln(fmt.Sprintf("XPathDBRouter: %s", s))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
# TorrServer Telegram Bot
|
||||
|
||||
[](https://github.com/YouROK/TorrServer/blob/master/LICENSE)
|
||||
[](https://github.com/YouROK/TorrServer)
|
||||
|
||||
## Introduction
|
||||
|
||||
Telegram bot for managing [TorrServer](https://github.com/YouROK/TorrServer) — add torrents, stream, search, and control the server directly from Telegram.
|
||||
|
||||
## Features
|
||||
|
||||
- Torrent management — add, remove, drop, list via magnet, hash, or `torrs://`
|
||||
- Export & import — magnets list; import multiple from text
|
||||
- Streaming — playback links, M3U playlists, preload
|
||||
- Search — RuTor and Torznab with one-click add
|
||||
- Inline mode — `@botname` in any chat: list torrents or search
|
||||
- Status & snake — real-time status, cache visualization
|
||||
- File operations — browse files, download to Telegram
|
||||
- FFprobe — media metadata via `/ffp`
|
||||
- Localization — Russian and English
|
||||
- Admin — shutdown, settings, presets (whitelist users only)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Enable the Bot
|
||||
|
||||
Start TorrServer with a Telegram bot token:
|
||||
|
||||
```bash
|
||||
TorrServer --tg YOUR_BOT_TOKEN
|
||||
```
|
||||
|
||||
Or use `-T`:
|
||||
|
||||
```bash
|
||||
TorrServer -T YOUR_BOT_TOKEN
|
||||
```
|
||||
|
||||
Create a bot via [@BotFather](https://t.me/BotFather) to get the token.
|
||||
|
||||
### Configuration
|
||||
|
||||
Config file `tg.cfg` (JSON) in the TorrServer data directory:
|
||||
|
||||
| Field | Description |
|
||||
|------------|-------------|
|
||||
| `HostTG` | Telegram API URL (default: `https://api.telegram.org`) |
|
||||
| `HostWeb` | Base URL for stream links (auto-detected if empty) |
|
||||
| `Socks5` | Optional SOCKS5 for reaching Telegram (e.g. `127.0.0.1:1080`, `socks5://user:pass@host:port`) if direct access to `api.telegram.org` is blocked or times out |
|
||||
| `WhiteIds` | Allowed user IDs (empty = allow all) |
|
||||
| `BlackIds` | Blocked user IDs |
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"HostTG": "https://api.telegram.org",
|
||||
"HostWeb": "http://192.168.1.100:8090",
|
||||
"Socks5": "127.0.0.1:1080",
|
||||
"WhiteIds": [123456789],
|
||||
"BlackIds": []
|
||||
}
|
||||
```
|
||||
|
||||
If your network cannot connect to Telegram’s API directly, run a local SOCKS5 proxy (for example [sing-box](https://github.com/SagerNet/sing-box), v2ray, or `ssh -D`) and set `Socks5` to its address.
|
||||
|
||||
## Commands
|
||||
|
||||
### Core
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/help`, `/start`, `/id` | Help and user ID |
|
||||
| `/list [compact]` | List torrents with buttons |
|
||||
| `/add <link>` | Add torrent (magnet, hash, torrs://) |
|
||||
| `/clear` | Remove all (with confirmation) |
|
||||
| `/hash [N]` | Show info hashes |
|
||||
|
||||
### Management
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/remove <hash\|N>` | Remove torrent |
|
||||
| `/drop <hash\|N>` | Disconnect (keep in DB) |
|
||||
| `/set <hash\|N> <title>` | Set title |
|
||||
| `/status [hash\|N]` | Status with refresh/stop |
|
||||
| `/cache <hash\|N>` | Cache stats |
|
||||
| `/preload <hash\|N> <index>` | Preload file |
|
||||
|
||||
### Links & Playback
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/link`, `/play` | Stream URL |
|
||||
| `/m3u`, `/m3uall` | M3U playlist |
|
||||
|
||||
### Search
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/search <query>` | RuTor + Torznab (all sources) |
|
||||
| `/rutor <query>` | RuTor only |
|
||||
| `/torznab <query> [index]` | Torznab indexers |
|
||||
|
||||
### Other
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/export`, `/import` | Export/import magnets |
|
||||
| `/categories` | List categories |
|
||||
| `/server`, `/stats`, `/stat` | Server info |
|
||||
| `/viewed` | Viewed files |
|
||||
| `/ffp <hash\|N> <id> [json]` | FFprobe metadata |
|
||||
| `/speedtest [size]` | Download test (1–100 MB) |
|
||||
| `/snake [hash\|N] [cols] [rows]` | Cache visualization |
|
||||
| `/lang [RU\|EN]` | Language |
|
||||
|
||||
### Admin Only
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/shutdown` | Shut down server |
|
||||
| `/settings` | Interactive settings menu (sub-pages: Search, Network, Other, Cache, Paths, Storage) |
|
||||
| `/preset <name>` | Apply named preset: `performance`, `storage`, `streaming`, `low`, `default` |
|
||||
| `/preset <key> <value> ...` | Apply key-value pairs: `cache 256`, `preload 50`, `conn 100`, etc. |
|
||||
|
||||
**Preset examples:**
|
||||
- `/preset performance` — max cache, high preload, no limits
|
||||
- `/preset cache 256 preload 50` — set cache 256 MB and preload 50%
|
||||
- `/preset cache 512 conn 100 down 0 up 0` — multiple values
|
||||
|
||||
**Preset keys:** `cache`, `preload`, `readahead`, `conn`, `timeout`, `port`, `down`, `up`, `retr`, `responsive`, `cachedrop`
|
||||
|
||||
## Inline Mode
|
||||
|
||||
Type `@YourBotName` in any chat:
|
||||
|
||||
- **Empty, "list", or "play"** — torrents with play links
|
||||
- **2+ characters** — search RuTor + Torznab
|
||||
|
||||
## Text Input
|
||||
|
||||
Paste as plain message to add torrent:
|
||||
|
||||
- `magnet:?xt=urn:btih:...`
|
||||
- `torrs://...`
|
||||
- 40-char info hash
|
||||
|
||||
Reply to file list with `2-12` to download files 2–12 to Telegram.
|
||||
|
||||
## Security
|
||||
|
||||
- **Whitelist** — restrict to specific user IDs
|
||||
- **Blacklist** — block user IDs
|
||||
- **Admin** — when whitelist is used, admin = whitelisted users
|
||||
- **Settings** — sensitive values masked in `/settings`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- [telebot v4](https://gopkg.in/telebot.v4) — Telegram Bot API
|
||||
- [go-humanize](https://github.com/dustin/go-humanize)
|
||||
- [go-ffprobe](https://gopkg.in/vansante/go-ffprobe.v2)
|
||||
@@ -0,0 +1,129 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/anacrolix/torrent"
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/log"
|
||||
set "server/settings"
|
||||
"server/torr"
|
||||
"server/web/api/utils"
|
||||
)
|
||||
|
||||
func addTorrentFromSpec(c tele.Context, torrSpec *torrent.TorrentSpec, displayLabel string) error {
|
||||
msg, err := c.Bot().Send(c.Sender(), tr(c.Sender().ID, "connecting"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tor, err := torr.AddTorrent(torrSpec, "", "", "", "")
|
||||
if err != nil {
|
||||
log.TLogln("tg add err", err)
|
||||
_, _ = c.Bot().Edit(msg, fmt.Sprintf(tr(c.Sender().ID, "add_error"), err.Error()))
|
||||
return err
|
||||
}
|
||||
if tor == nil {
|
||||
_, _ = c.Bot().Edit(msg, tr(c.Sender().ID, "add_not_created"))
|
||||
return errors.New("torrent not created")
|
||||
}
|
||||
|
||||
if set.BTsets != nil && set.BTsets.EnableDebug {
|
||||
if tor.Data != "" {
|
||||
log.TLogln("tg add data", logSafeStr(tor.Data, 60))
|
||||
}
|
||||
if tor.Category != "" {
|
||||
log.TLogln("tg add category", logSafeStr(tor.Category, 40))
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = c.Bot().Edit(msg, tr(c.Sender().ID, "add_getting_meta"))
|
||||
if !tor.GotInfo() {
|
||||
log.TLogln("tg add err", "timeout get torrent info")
|
||||
_, _ = c.Bot().Edit(msg, tr(c.Sender().ID, "add_timeout"))
|
||||
return errors.New("timeout connection get torrent info")
|
||||
}
|
||||
|
||||
if tor.Title == "" {
|
||||
tor.Title = torrSpec.DisplayName
|
||||
tor.Title = strings.ReplaceAll(tor.Title, "rutor.info", "")
|
||||
tor.Title = strings.ReplaceAll(tor.Title, "_", " ")
|
||||
tor.Title = strings.Trim(tor.Title, " ")
|
||||
if tor.Title == "" {
|
||||
tor.Title = tor.Name()
|
||||
}
|
||||
}
|
||||
|
||||
torr.SaveTorrentToDB(tor)
|
||||
|
||||
if len(displayLabel) > 80 {
|
||||
displayLabel = displayLabel[:77] + "..."
|
||||
}
|
||||
_, _ = c.Bot().Edit(msg, fmt.Sprintf(tr(c.Sender().ID, "add_success"), displayLabel))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func addTorrent(c tele.Context, link string) error {
|
||||
log.TLogln("tg add torrent", logHashOrTruncate(link))
|
||||
link = strings.ReplaceAll(link, "&", "&")
|
||||
var torrSpec *torrent.TorrentSpec
|
||||
var err error
|
||||
if strings.HasPrefix(strings.ToLower(link), "torrs://") {
|
||||
torrSpec, _, err = utils.ParseTorrsHash(link)
|
||||
} else {
|
||||
torrSpec, err = utils.ParseLink(link)
|
||||
}
|
||||
if err != nil {
|
||||
log.TLogln("tg add parse err", err)
|
||||
return err
|
||||
}
|
||||
return addTorrentFromSpec(c, torrSpec, link)
|
||||
}
|
||||
|
||||
func addTorrentFromDocument(c tele.Context, doc *tele.Document) error {
|
||||
if doc == nil || doc.FileID == "" {
|
||||
return errors.New("no document")
|
||||
}
|
||||
reader, err := c.Bot().File(&doc.File)
|
||||
if err != nil {
|
||||
log.TLogln("tg add document getfile err", err)
|
||||
return err
|
||||
}
|
||||
defer func() { _ = reader.Close() }()
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
log.TLogln("tg add document read err", err)
|
||||
return err
|
||||
}
|
||||
torrSpec, err := utils.ParseFromBytes(data)
|
||||
if err != nil {
|
||||
log.TLogln("tg add document parse err", err)
|
||||
return err
|
||||
}
|
||||
displayLabel := doc.FileName
|
||||
if displayLabel == "" {
|
||||
displayLabel = ".torrent"
|
||||
}
|
||||
return addTorrentFromSpec(c, torrSpec, displayLabel)
|
||||
}
|
||||
|
||||
func cmdAdd(c tele.Context) error {
|
||||
uid := c.Sender().ID
|
||||
args := c.Args()
|
||||
if len(args) == 0 {
|
||||
return c.Send(tr(uid, "add_usage"))
|
||||
}
|
||||
link := strings.TrimSpace(strings.Join(args, " "))
|
||||
if link == "" {
|
||||
return c.Send(tr(uid, "add_no_link"))
|
||||
}
|
||||
err := addTorrent(c, link)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return list(c)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"server/tgbot/config"
|
||||
)
|
||||
|
||||
func isAdmin(userID int64) bool {
|
||||
if len(config.Cfg.WhiteIds) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, id := range config.Cfg.WhiteIds {
|
||||
if id == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/dlna"
|
||||
"server/rutor"
|
||||
"server/settings"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
type pendingPreset struct {
|
||||
Sets *settings.BTSets
|
||||
Preset string // name for display
|
||||
UserID int64
|
||||
IsDef bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
pendingPresetMu sync.Mutex
|
||||
pendingPresets = make(map[string]pendingPreset)
|
||||
)
|
||||
|
||||
func init() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
for range ticker.C {
|
||||
pendingPresetMu.Lock()
|
||||
now := time.Now()
|
||||
for key, p := range pendingPresets {
|
||||
if now.Sub(p.CreatedAt) > 30*time.Minute {
|
||||
delete(pendingPresets, key)
|
||||
}
|
||||
}
|
||||
pendingPresetMu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func cmdPreset(c tele.Context) error {
|
||||
uid := c.Sender().ID
|
||||
if !isAdmin(uid) {
|
||||
return c.Send(tr(uid, "admin_only"))
|
||||
}
|
||||
if settings.BTsets == nil {
|
||||
return c.Send(tr(uid, "settings_not_loaded"))
|
||||
}
|
||||
if settings.ReadOnly {
|
||||
return c.Send(tr(uid, "settings_readonly"))
|
||||
}
|
||||
|
||||
args := strings.Fields(c.Text())
|
||||
if len(args) < 2 {
|
||||
return c.Send(tr(uid, "preset_usage"))
|
||||
}
|
||||
|
||||
sets := new(settings.BTSets)
|
||||
*sets = *settings.BTsets
|
||||
|
||||
first := strings.ToLower(args[1])
|
||||
presetName := first
|
||||
|
||||
if len(args) == 2 {
|
||||
if ok, _ := applyNamedPreset(sets, first, uid); ok {
|
||||
return sendPresetConfirm(c, uid, sets, presetName, false)
|
||||
}
|
||||
if first == "default" || first == "def" || first == "сброс" {
|
||||
return sendPresetConfirm(c, uid, nil, "default", true)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse key-value pairs: cache 256 preload 50 conn 100
|
||||
applied, errMsg := applyPresetKV(sets, args[1:], uid)
|
||||
if !applied {
|
||||
return c.Send(errMsg)
|
||||
}
|
||||
presetName = strings.Join(args[1:], " ")
|
||||
return sendPresetConfirm(c, uid, sets, presetName, false)
|
||||
}
|
||||
|
||||
func sendPresetConfirm(c tele.Context, uid int64, sets *settings.BTSets, presetName string, isDef bool) error {
|
||||
btnYes := tele.InlineButton{Text: tr(uid, "btn_yes"), Unique: "fpreset", Data: "1"}
|
||||
btnNo := tele.InlineButton{Text: tr(uid, "btn_no"), Unique: "fpreset", Data: "0"}
|
||||
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{{btnYes, btnNo}}}
|
||||
msg := tr(uid, "preset_confirm") + "\n\n<code>" + presetName + "</code>"
|
||||
sent, err := c.Bot().Send(c.Chat(), msg, kbd, tele.ModeHTML)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pendingPresetMu.Lock()
|
||||
pendingPresets[chatMsgKey(sent.Chat.ID, sent.ID)] = pendingPreset{
|
||||
Sets: sets, Preset: presetName, UserID: uid, IsDef: isDef, CreatedAt: time.Now(),
|
||||
}
|
||||
pendingPresetMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func presetConfirm(c tele.Context, confirm string) error {
|
||||
uid := c.Sender().ID
|
||||
if !isAdmin(uid) {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "admin_only")})
|
||||
}
|
||||
key := chatMsgKey(c.Callback().Message.Chat.ID, c.Callback().Message.ID)
|
||||
pendingPresetMu.Lock()
|
||||
p, ok := pendingPresets[key]
|
||||
delete(pendingPresets, key)
|
||||
pendingPresetMu.Unlock()
|
||||
if !ok || p.UserID != uid {
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "canceled")})
|
||||
}
|
||||
if confirm != "1" {
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "canceled")})
|
||||
}
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
if settings.ReadOnly {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_readonly")})
|
||||
}
|
||||
if p.IsDef {
|
||||
torr.SetDefSettings()
|
||||
dlna.Stop()
|
||||
rutor.Stop()
|
||||
rutor.Start()
|
||||
return c.Send(tr(uid, "settings_reset_done"))
|
||||
}
|
||||
if p.Sets == nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "callback_unknown")})
|
||||
}
|
||||
torr.SetSettings(p.Sets)
|
||||
dlna.Stop()
|
||||
if p.Sets.EnableDLNA {
|
||||
dlna.Start()
|
||||
}
|
||||
rutor.Stop()
|
||||
rutor.Start()
|
||||
return c.Send(tr(uid, "preset_applied") + p.Preset)
|
||||
}
|
||||
|
||||
func applyNamedPreset(s *settings.BTSets, name string, uid int64) (bool, string) {
|
||||
switch name {
|
||||
case "performance", "perf", "производительность":
|
||||
s.CacheSize = 512 * 1024 * 1024
|
||||
s.PreloadCache = 95
|
||||
s.ReaderReadAHead = 100
|
||||
s.ConnectionsLimit = 100
|
||||
s.TorrentDisconnectTimeout = 60
|
||||
s.PeersListenPort = 0
|
||||
s.DownloadRateLimit = 0
|
||||
s.UploadRateLimit = 0
|
||||
s.RetrackersMode = 1
|
||||
s.ResponsiveMode = true
|
||||
return true, tr(uid, "preset_applied") + " performance"
|
||||
case "storage", "store", "хранение":
|
||||
s.CacheSize = 64 * 1024 * 1024
|
||||
s.PreloadCache = 25
|
||||
s.ReaderReadAHead = 50
|
||||
s.RemoveCacheOnDrop = true
|
||||
return true, tr(uid, "preset_applied") + " storage"
|
||||
case "streaming", "stream", "стриминг":
|
||||
s.CacheSize = 256 * 1024 * 1024
|
||||
s.PreloadCache = 75
|
||||
s.ReaderReadAHead = 95
|
||||
s.ConnectionsLimit = 50
|
||||
s.ResponsiveMode = true
|
||||
return true, tr(uid, "preset_applied") + " streaming"
|
||||
case "low", "minimal", "минимум":
|
||||
s.CacheSize = 64 * 1024 * 1024
|
||||
s.PreloadCache = 25
|
||||
s.ReaderReadAHead = 50
|
||||
s.ConnectionsLimit = 25
|
||||
s.TorrentDisconnectTimeout = 30
|
||||
return true, tr(uid, "preset_applied") + " low"
|
||||
case "default", "def", "сброс":
|
||||
return false, "" // handled in cmdPreset
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func applyPresetKV(s *settings.BTSets, args []string, uid int64) (bool, string) {
|
||||
if len(args) < 2 {
|
||||
return false, tr(uid, "preset_usage")
|
||||
}
|
||||
applied := false
|
||||
for i := 0; i < len(args)-1; i += 2 {
|
||||
key := strings.ToLower(args[i])
|
||||
val := strings.ToLower(strings.TrimSpace(args[i+1]))
|
||||
ok := false
|
||||
switch key {
|
||||
case "cache":
|
||||
if v := parseInt(val); v > 0 {
|
||||
s.CacheSize = int64(v) * 1024 * 1024
|
||||
ok = true
|
||||
}
|
||||
case "preload":
|
||||
if v := parseInt(val); v >= 0 && v <= 100 {
|
||||
s.PreloadCache = v
|
||||
ok = true
|
||||
}
|
||||
case "readahead":
|
||||
if v := parseInt(val); v >= 5 && v <= 100 {
|
||||
s.ReaderReadAHead = v
|
||||
ok = true
|
||||
}
|
||||
case "conn", "connections":
|
||||
if v := parseInt(val); v > 0 {
|
||||
s.ConnectionsLimit = v
|
||||
ok = true
|
||||
}
|
||||
case "timeout":
|
||||
if v := parseInt(val); v > 0 {
|
||||
s.TorrentDisconnectTimeout = v
|
||||
ok = true
|
||||
}
|
||||
case "port":
|
||||
v := parseInt(val)
|
||||
if val == "auto" || val == "0" {
|
||||
v = 0
|
||||
}
|
||||
if v >= 0 && (v == 0 || (v >= 1024 && v <= 65535)) {
|
||||
s.PeersListenPort = v
|
||||
ok = true
|
||||
}
|
||||
case "down", "download":
|
||||
v := 0
|
||||
if val != "inf" && val != "∞" && val != "0" {
|
||||
v = parseInt(val)
|
||||
}
|
||||
s.DownloadRateLimit = v
|
||||
ok = true
|
||||
case "up", "upload":
|
||||
v := 0
|
||||
if val != "inf" && val != "∞" && val != "0" {
|
||||
v = parseInt(val)
|
||||
}
|
||||
s.UploadRateLimit = v
|
||||
ok = true
|
||||
case "retr", "retrackers":
|
||||
var v int
|
||||
switch val {
|
||||
case "off":
|
||||
v = 0
|
||||
case "add":
|
||||
v = 1
|
||||
case "rem", "remove":
|
||||
v = 2
|
||||
case "repl", "replace":
|
||||
v = 3
|
||||
default:
|
||||
v = parseInt(val)
|
||||
}
|
||||
if v >= 0 && v <= 3 {
|
||||
s.RetrackersMode = v
|
||||
ok = true
|
||||
}
|
||||
case "responsive":
|
||||
s.ResponsiveMode = val == "1" || val == "on" || val == "true" || val == "да" || val == "yes"
|
||||
ok = true
|
||||
case "cachedrop":
|
||||
s.RemoveCacheOnDrop = val == "1" || val == "on" || val == "true" || val == "да" || val == "yes"
|
||||
ok = true
|
||||
}
|
||||
if ok {
|
||||
applied = true
|
||||
}
|
||||
}
|
||||
if !applied {
|
||||
return false, tr(uid, "preset_usage")
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/dlna"
|
||||
"server/rutor"
|
||||
"server/settings"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
func cmdSettings(c tele.Context) error {
|
||||
uid := c.Sender().ID
|
||||
if settings.BTsets == nil {
|
||||
return c.Send(tr(uid, "settings_not_loaded"))
|
||||
}
|
||||
return sendSettingsMenu(c, uid)
|
||||
}
|
||||
|
||||
func sendSettingsMenu(c tele.Context, uid int64) error {
|
||||
return sendSettingsMenuPage(c, uid, "1")
|
||||
}
|
||||
|
||||
func sendSettingsMenuPage(c tele.Context, uid int64, page string) error {
|
||||
msg := sendSettingsMenuText(c, uid, page)
|
||||
kbd := sendSettingsMenuKbd(uid, page)
|
||||
return c.Send(msg, kbd)
|
||||
}
|
||||
|
||||
func sendSettingsMenuText(c tele.Context, uid int64, page string) string {
|
||||
s := settings.BTsets
|
||||
msg := "⚙️ <b>" + tr(uid, "settings_title") + "</b>"
|
||||
switch page {
|
||||
case "1":
|
||||
msg += "\n\n"
|
||||
msg += fmt.Sprintf("🔍 %s: RuTor %s · Torznab %s\n", tr(uid, "settings_section_search"), boolIcon(s.EnableRutorSearch), boolIcon(s.EnableTorznabSearch))
|
||||
msg += fmt.Sprintf("📺 %s: DLNA %s · IPv6 %s · DHT %s · PEX %s · TCP %s · UTP %s\n", tr(uid, "settings_section_network"), boolIcon(s.EnableDLNA), boolIcon(s.EnableIPv6), boolIcon(!s.DisableDHT), boolIcon(!s.DisablePEX), boolIcon(!s.DisableTCP), boolIcon(!s.DisableUTP))
|
||||
msg += fmt.Sprintf("📦 %s: CacheDrop %s · Proxy %s · UseDisk %s\n", tr(uid, "settings_section_other"), boolIcon(s.RemoveCacheOnDrop), boolIcon(s.EnableProxy), boolIcon(s.UseDisk))
|
||||
case "1a":
|
||||
msg += " — " + tr(uid, "settings_section_search")
|
||||
msg += "\n\n"
|
||||
msg += fmt.Sprintf("RuTor %s · Torznab %s", boolIcon(s.EnableRutorSearch), boolIcon(s.EnableTorznabSearch))
|
||||
case "1b":
|
||||
msg += " — " + tr(uid, "settings_section_network")
|
||||
msg += "\n\n"
|
||||
msg += fmt.Sprintf("DLNA %s · IPv6 %s · Upload %s · DHT %s · PEX %s\n", boolIcon(s.EnableDLNA), boolIcon(s.EnableIPv6), boolIcon(!s.DisableUpload), boolIcon(!s.DisableDHT), boolIcon(!s.DisablePEX))
|
||||
msg += fmt.Sprintf("TCP %s · UTP %s · UPNP %s · Encrypt %s · Debug %s", boolIcon(!s.DisableTCP), boolIcon(!s.DisableUTP), boolIcon(!s.DisableUPNP), boolIcon(s.ForceEncrypt), boolIcon(s.EnableDebug))
|
||||
case "1c":
|
||||
msg += " — " + tr(uid, "settings_section_other")
|
||||
msg += "\n\n"
|
||||
msg += fmt.Sprintf("CacheDrop %s · Responsive %s · Proxy %s · UseDisk %s · FSActive %s", boolIcon(s.RemoveCacheOnDrop), boolIcon(s.ResponsiveMode), boolIcon(s.EnableProxy), boolIcon(s.UseDisk), boolIcon(s.ShowFSActiveTorr))
|
||||
case "2":
|
||||
msg += " — " + tr(uid, "settings_page2")
|
||||
msg += "\n\n"
|
||||
msg += fmt.Sprintf("💾 %s: %d MB · Preload %d%% · ReadAhead %d%%\n", tr(uid, "settings_limits_cache"), s.CacheSize/(1024*1024), s.PreloadCache, s.ReaderReadAHead)
|
||||
msg += fmt.Sprintf("🔌 %s: %d · Port %s · Timeout %ds\n", tr(uid, "settings_limits_connections"), s.ConnectionsLimit, portStr(s.PeersListenPort), s.TorrentDisconnectTimeout)
|
||||
msg += fmt.Sprintf("⬇️ %s: Down %s · Up %s · Retr %s\n", tr(uid, "settings_limits_speed"), rateStr(s.DownloadRateLimit), rateStr(s.UploadRateLimit), retrackersStr(s.RetrackersMode))
|
||||
case "2a":
|
||||
msg += " — " + tr(uid, "settings_page2") + " · " + tr(uid, "settings_limits_cache")
|
||||
msg += "\n\n"
|
||||
msg += fmt.Sprintf("Cache %d MB · Preload %d%% · ReadAhead %d%%", s.CacheSize/(1024*1024), s.PreloadCache, s.ReaderReadAHead)
|
||||
case "2b":
|
||||
msg += " — " + tr(uid, "settings_page2") + " · " + tr(uid, "settings_limits_connections")
|
||||
msg += "\n\n"
|
||||
msg += fmt.Sprintf("Connections %d · Port %s · Timeout %ds", s.ConnectionsLimit, portStr(s.PeersListenPort), s.TorrentDisconnectTimeout)
|
||||
case "2c":
|
||||
msg += " — " + tr(uid, "settings_page2") + " · " + tr(uid, "settings_limits_speed")
|
||||
msg += "\n\n"
|
||||
msg += fmt.Sprintf("Down %s · Up %s · Retrackers %s", rateStr(s.DownloadRateLimit), rateStr(s.UploadRateLimit), retrackersStr(s.RetrackersMode))
|
||||
case "3":
|
||||
msg += " — " + tr(uid, "settings_page3")
|
||||
msg += "\n\n"
|
||||
msg += fmt.Sprintf("📺 DLNA: %s · 💾 Path: %s\n", maskStr(s.FriendlyName, 25), maskVal(s.TorrentsSavePath))
|
||||
msg += fmt.Sprintf("🔐 SSL: %s · 🔑 TMDB: %s · Torznab: %d\n", maskVal(s.SslCert), maskVal(s.TMDBSettings.APIKey), len(s.TorznabUrls))
|
||||
msg += fmt.Sprintf("🌐 Proxy: %s", maskStr(strings.Join(s.ProxyHosts, ", "), 35))
|
||||
case "4":
|
||||
msg += " — " + tr(uid, "settings_page4")
|
||||
msg += "\n\n"
|
||||
msg += fmt.Sprintf("📄 %s: %s · 📺 %s: %s\n", tr(uid, "settings_storage_settings"), storageType(s.StoreSettingsInJson), tr(uid, "settings_storage_viewed"), storageType(s.StoreViewedInJson))
|
||||
msg += fmt.Sprintf("🔑 TMDB: %s · 🖼 URL: %s", maskVal(s.TMDBSettings.APIKey), maskStr(s.TMDBSettings.ImageURL, 20))
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func storageType(useJSON bool) string {
|
||||
if useJSON {
|
||||
return "json"
|
||||
}
|
||||
return "bbolt"
|
||||
}
|
||||
|
||||
func rateStr(kb int) string {
|
||||
if kb == 0 {
|
||||
return "∞"
|
||||
}
|
||||
return fmt.Sprintf("%d", kb)
|
||||
}
|
||||
|
||||
func portStr(port int) string {
|
||||
if port == 0 {
|
||||
return "auto"
|
||||
}
|
||||
return fmt.Sprintf("%d", port)
|
||||
}
|
||||
|
||||
func maskStr(s string, maxLen int) string {
|
||||
if s == "" {
|
||||
return "—"
|
||||
}
|
||||
if len(s) > maxLen {
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func maskVal(s string) string {
|
||||
if s == "" {
|
||||
return "—"
|
||||
}
|
||||
return "***"
|
||||
}
|
||||
|
||||
func retrackersStr(mode int) string {
|
||||
switch mode {
|
||||
case 0:
|
||||
return "off"
|
||||
case 1:
|
||||
return "add"
|
||||
case 2:
|
||||
return "remove"
|
||||
case 3:
|
||||
return "replace"
|
||||
default:
|
||||
return "?"
|
||||
}
|
||||
}
|
||||
|
||||
func sendSettingsMenuKbd(uid int64, page string) *tele.ReplyMarkup {
|
||||
s := settings.BTsets
|
||||
var btns [][]tele.InlineButton
|
||||
|
||||
switch page {
|
||||
case "1":
|
||||
btns = [][]tele.InlineButton{
|
||||
{
|
||||
{Text: "🔍 " + tr(uid, "settings_section_search"), Unique: "fset", Data: "page|1a"},
|
||||
{Text: "📺 " + tr(uid, "settings_section_network"), Unique: "fset", Data: "page|1b"},
|
||||
{Text: "📦 " + tr(uid, "settings_section_other"), Unique: "fset", Data: "page|1c"},
|
||||
},
|
||||
{
|
||||
{Text: "📥 " + tr(uid, "settings_export"), Unique: "fset", Data: "export"},
|
||||
{Text: "📊 " + tr(uid, "settings_nav_cache"), Unique: "fset", Data: "page|2"},
|
||||
{Text: "✏️ " + tr(uid, "settings_nav_paths"), Unique: "fset", Data: "page|3"},
|
||||
{Text: "💾 " + tr(uid, "settings_nav_storage"), Unique: "fset", Data: "page|4"},
|
||||
},
|
||||
}
|
||||
case "1a":
|
||||
btns = [][]tele.InlineButton{
|
||||
{
|
||||
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|1"},
|
||||
},
|
||||
{
|
||||
{Text: toggleBtn("RuTor", s.EnableRutorSearch), Unique: "fset", Data: "rutor|1a"},
|
||||
{Text: toggleBtn("Torznab", s.EnableTorznabSearch), Unique: "fset", Data: "torznab|1a"},
|
||||
},
|
||||
}
|
||||
case "1b":
|
||||
btns = [][]tele.InlineButton{
|
||||
{
|
||||
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|1"},
|
||||
},
|
||||
{
|
||||
{Text: toggleBtn("DLNA", s.EnableDLNA), Unique: "fset", Data: "dlna|1b"},
|
||||
{Text: toggleBtn("IPv6", s.EnableIPv6), Unique: "fset", Data: "ipv6|1b"},
|
||||
{Text: toggleBtn("Upload", !s.DisableUpload), Unique: "fset", Data: "upload|1b"},
|
||||
},
|
||||
{
|
||||
{Text: toggleBtn("DHT", !s.DisableDHT), Unique: "fset", Data: "dht|1b"},
|
||||
{Text: toggleBtn("PEX", !s.DisablePEX), Unique: "fset", Data: "pex|1b"},
|
||||
{Text: toggleBtn("TCP", !s.DisableTCP), Unique: "fset", Data: "tcp|1b"},
|
||||
{Text: toggleBtn("UTP", !s.DisableUTP), Unique: "fset", Data: "utp|1b"},
|
||||
},
|
||||
{
|
||||
{Text: toggleBtn("UPNP", !s.DisableUPNP), Unique: "fset", Data: "upnp|1b"},
|
||||
{Text: toggleBtn("Encrypt", s.ForceEncrypt), Unique: "fset", Data: "encrypt|1b"},
|
||||
{Text: toggleBtn("Debug", s.EnableDebug), Unique: "fset", Data: "debug|1b"},
|
||||
},
|
||||
}
|
||||
case "1c":
|
||||
btns = [][]tele.InlineButton{
|
||||
{
|
||||
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|1"},
|
||||
},
|
||||
{
|
||||
{Text: toggleBtn("CacheDrop", s.RemoveCacheOnDrop), Unique: "fset", Data: "cachedrop|1c"},
|
||||
{Text: toggleBtn("Responsive", s.ResponsiveMode), Unique: "fset", Data: "responsive|1c"},
|
||||
{Text: toggleBtn("Proxy", s.EnableProxy), Unique: "fset", Data: "proxy|1c"},
|
||||
},
|
||||
{
|
||||
{Text: toggleBtn("UseDisk", s.UseDisk), Unique: "fset", Data: "usedisk|1c"},
|
||||
{Text: toggleBtn("FSActive", s.ShowFSActiveTorr), Unique: "fset", Data: "fsactive|1c"},
|
||||
},
|
||||
}
|
||||
case "2":
|
||||
btns = [][]tele.InlineButton{
|
||||
{
|
||||
{Text: "💾 " + tr(uid, "settings_limits_cache"), Unique: "fset", Data: "page|2a"},
|
||||
{Text: "🔌 " + tr(uid, "settings_limits_connections"), Unique: "fset", Data: "page|2b"},
|
||||
{Text: "⬇️ " + tr(uid, "settings_limits_speed"), Unique: "fset", Data: "page|2c"},
|
||||
},
|
||||
{
|
||||
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|1"},
|
||||
{Text: "✏️ " + tr(uid, "settings_nav_paths"), Unique: "fset", Data: "page|3"},
|
||||
{Text: "💾 " + tr(uid, "settings_nav_storage"), Unique: "fset", Data: "page|4"},
|
||||
},
|
||||
}
|
||||
case "2a":
|
||||
cacheMB := int(s.CacheSize / (1024 * 1024))
|
||||
btns = [][]tele.InlineButton{
|
||||
{
|
||||
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|2"},
|
||||
},
|
||||
{
|
||||
{Text: "💾 " + optBtn("64", cacheMB == 64), Unique: "fset", Data: "cache|64|2a"},
|
||||
{Text: optBtn("128", cacheMB == 128), Unique: "fset", Data: "cache|128|2a"},
|
||||
{Text: optBtn("256", cacheMB == 256), Unique: "fset", Data: "cache|256|2a"},
|
||||
{Text: optBtn("512", cacheMB == 512), Unique: "fset", Data: "cache|512|2a"},
|
||||
},
|
||||
{
|
||||
{Text: "📥 " + optBtn("25%", s.PreloadCache == 25), Unique: "fset", Data: "preload|25|2a"},
|
||||
{Text: optBtn("50%", s.PreloadCache == 50), Unique: "fset", Data: "preload|50|2a"},
|
||||
{Text: optBtn("75%", s.PreloadCache == 75), Unique: "fset", Data: "preload|75|2a"},
|
||||
{Text: optBtn("95%", s.PreloadCache == 95), Unique: "fset", Data: "preload|95|2a"},
|
||||
},
|
||||
{
|
||||
{Text: "📖 " + optBtn("50%", s.ReaderReadAHead == 50), Unique: "fset", Data: "readahead|50|2a"},
|
||||
{Text: optBtn("75%", s.ReaderReadAHead == 75), Unique: "fset", Data: "readahead|75|2a"},
|
||||
{Text: optBtn("95%", s.ReaderReadAHead == 95), Unique: "fset", Data: "readahead|95|2a"},
|
||||
{Text: optBtn("100%", s.ReaderReadAHead == 100), Unique: "fset", Data: "readahead|100|2a"},
|
||||
},
|
||||
}
|
||||
case "2b":
|
||||
btns = [][]tele.InlineButton{
|
||||
{
|
||||
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|2"},
|
||||
},
|
||||
{
|
||||
{Text: "🔌 " + optBtn("25", s.ConnectionsLimit == 25), Unique: "fset", Data: "conn|25|2b"},
|
||||
{Text: optBtn("50", s.ConnectionsLimit == 50), Unique: "fset", Data: "conn|50|2b"},
|
||||
{Text: optBtn("100", s.ConnectionsLimit == 100), Unique: "fset", Data: "conn|100|2b"},
|
||||
},
|
||||
{
|
||||
{Text: "⏱ " + optBtn("15s", s.TorrentDisconnectTimeout == 15), Unique: "fset", Data: "timeout|15|2b"},
|
||||
{Text: optBtn("30s", s.TorrentDisconnectTimeout == 30), Unique: "fset", Data: "timeout|30|2b"},
|
||||
{Text: optBtn("60s", s.TorrentDisconnectTimeout == 60), Unique: "fset", Data: "timeout|60|2b"},
|
||||
{Text: optBtn("120s", s.TorrentDisconnectTimeout == 120), Unique: "fset", Data: "timeout|120|2b"},
|
||||
},
|
||||
{
|
||||
{Text: "🔌 " + optBtn("auto", s.PeersListenPort == 0), Unique: "fset", Data: "port|0|2b"},
|
||||
{Text: optBtn("6881", s.PeersListenPort == 6881), Unique: "fset", Data: "port|6881|2b"},
|
||||
{Text: optBtn("51413", s.PeersListenPort == 51413), Unique: "fset", Data: "port|51413|2b"},
|
||||
},
|
||||
}
|
||||
case "2c":
|
||||
btns = [][]tele.InlineButton{
|
||||
{
|
||||
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|2"},
|
||||
},
|
||||
{
|
||||
{Text: "⬇️ " + optBtn("∞", s.DownloadRateLimit == 0), Unique: "fset", Data: "down|0|2c"},
|
||||
{Text: optBtn("1M", s.DownloadRateLimit == 1024), Unique: "fset", Data: "down|1024|2c"},
|
||||
{Text: optBtn("5M", s.DownloadRateLimit == 5120), Unique: "fset", Data: "down|5120|2c"},
|
||||
{Text: optBtn("10M", s.DownloadRateLimit == 10240), Unique: "fset", Data: "down|10240|2c"},
|
||||
},
|
||||
{
|
||||
{Text: "⬆️ " + optBtn("∞", s.UploadRateLimit == 0), Unique: "fset", Data: "up|0|2c"},
|
||||
{Text: optBtn("1M", s.UploadRateLimit == 1024), Unique: "fset", Data: "up|1024|2c"},
|
||||
{Text: optBtn("5M", s.UploadRateLimit == 5120), Unique: "fset", Data: "up|5120|2c"},
|
||||
{Text: optBtn("10M", s.UploadRateLimit == 10240), Unique: "fset", Data: "up|10240|2c"},
|
||||
},
|
||||
{
|
||||
{Text: "🔄 " + optBtn("off", s.RetrackersMode == 0), Unique: "fset", Data: "retr|0|2c"},
|
||||
{Text: optBtn("add", s.RetrackersMode == 1), Unique: "fset", Data: "retr|1|2c"},
|
||||
{Text: optBtn("rem", s.RetrackersMode == 2), Unique: "fset", Data: "retr|2|2c"},
|
||||
{Text: optBtn("repl", s.RetrackersMode == 3), Unique: "fset", Data: "retr|3|2c"},
|
||||
},
|
||||
}
|
||||
case "3":
|
||||
btns = [][]tele.InlineButton{
|
||||
{
|
||||
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|1"},
|
||||
{Text: "📊 " + tr(uid, "settings_nav_cache"), Unique: "fset", Data: "page|2"},
|
||||
{Text: "💾 " + tr(uid, "settings_nav_storage"), Unique: "fset", Data: "page|4"},
|
||||
},
|
||||
{
|
||||
{Text: "✏️ " + tr(uid, "settings_set_friendlyname"), Unique: "fset", Data: "ask|friendlyname"},
|
||||
},
|
||||
{
|
||||
{Text: "✏️ " + tr(uid, "settings_set_path"), Unique: "fset", Data: "ask|torrentssavepath"},
|
||||
},
|
||||
{
|
||||
{Text: "🔐 " + tr(uid, "settings_set_sslcert"), Unique: "fset", Data: "ask|sslcert"},
|
||||
{Text: "🔑 " + tr(uid, "settings_set_sslkey"), Unique: "fset", Data: "ask|sslkey"},
|
||||
},
|
||||
{
|
||||
{Text: "🎬 " + tr(uid, "settings_set_tmdbkey"), Unique: "fset", Data: "ask|tmdbkey"},
|
||||
},
|
||||
{
|
||||
{Text: "🔍 " + tr(uid, "settings_torznab_test"), Unique: "fset", Data: "ask|torznab_test"},
|
||||
{Text: "➕ " + tr(uid, "settings_add_torznab"), Unique: "fset", Data: "ask|torznab_add"},
|
||||
{Text: "🗑 " + tr(uid, "settings_clear_torznab"), Unique: "fset", Data: "torznab_clear"},
|
||||
},
|
||||
{
|
||||
{Text: "✏️ " + tr(uid, "settings_set_proxyhosts"), Unique: "fset", Data: "ask|proxyhosts"},
|
||||
},
|
||||
}
|
||||
case "4":
|
||||
btns = [][]tele.InlineButton{
|
||||
{
|
||||
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|1"},
|
||||
{Text: "📊 " + tr(uid, "settings_nav_cache"), Unique: "fset", Data: "page|2"},
|
||||
{Text: "✏️ " + tr(uid, "settings_nav_paths"), Unique: "fset", Data: "page|3"},
|
||||
},
|
||||
{
|
||||
{Text: "📄 " + optBtn("json", s.StoreSettingsInJson), Unique: "fset", Data: "storage_set|json"},
|
||||
{Text: optBtn("bbolt", !s.StoreSettingsInJson), Unique: "fset", Data: "storage_set|bbolt"},
|
||||
},
|
||||
{
|
||||
{Text: "📺 " + optBtn("json", s.StoreViewedInJson), Unique: "fset", Data: "storage_view|json"},
|
||||
{Text: optBtn("bbolt", !s.StoreViewedInJson), Unique: "fset", Data: "storage_view|bbolt"},
|
||||
},
|
||||
{
|
||||
{Text: "🔄 " + tr(uid, "settings_reset"), Unique: "fset", Data: "reset_confirm"},
|
||||
},
|
||||
}
|
||||
}
|
||||
return &tele.ReplyMarkup{InlineKeyboard: btns}
|
||||
}
|
||||
|
||||
func boolIcon(v bool) string {
|
||||
if v {
|
||||
return "✅"
|
||||
}
|
||||
return "❌"
|
||||
}
|
||||
|
||||
func toggleBtn(label string, on bool) string {
|
||||
if on {
|
||||
return label + " ✅"
|
||||
}
|
||||
return label + " ❌"
|
||||
}
|
||||
|
||||
func optBtn(label string, isCurrent bool) string {
|
||||
if isCurrent {
|
||||
return label + " ✓"
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
func settingsCallback(c tele.Context, action string) error {
|
||||
uid := c.Sender().ID
|
||||
if !isAdmin(uid) {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "admin_only")})
|
||||
}
|
||||
if settings.BTsets == nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_not_loaded")})
|
||||
}
|
||||
|
||||
if action == "export" {
|
||||
buf, err := json.MarshalIndent(settings.BTsets, "", " ")
|
||||
if err != nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: fmt.Sprintf(tr(uid, "settings_error"), err.Error())})
|
||||
}
|
||||
doc := &tele.Document{}
|
||||
doc.FileName = "torrserver_settings.json"
|
||||
doc.FileReader = bytes.NewReader(buf)
|
||||
doc.Caption = "⚙️ " + tr(uid, "settings_export_caption")
|
||||
_ = c.Send(doc)
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_exported")})
|
||||
}
|
||||
|
||||
if action == "input_cancel" {
|
||||
return cancelSettingsInput(c)
|
||||
}
|
||||
|
||||
if action == "reset_confirm" {
|
||||
btnYes := tele.InlineButton{Text: tr(uid, "btn_yes"), Unique: "fset", Data: "reset_def|1"}
|
||||
btnNo := tele.InlineButton{Text: tr(uid, "btn_no"), Unique: "fset", Data: "reset_def|0"}
|
||||
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{{btnYes, btnNo}}}
|
||||
msg := sendSettingsMenuText(c, uid, "4") + "\n\n⚠️ " + tr(uid, "settings_reset_confirm")
|
||||
if _, err := c.Bot().Edit(c.Callback().Message, msg, kbd, tele.ModeHTML); err != nil {
|
||||
_ = c.Send(tr(uid, "settings_reset_confirm"), kbd)
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{})
|
||||
}
|
||||
|
||||
if len(action) > 9 && action[:9] == "reset_def|" {
|
||||
if action[9:] != "1" {
|
||||
msg := sendSettingsMenuText(c, uid, "4")
|
||||
kbd := sendSettingsMenuKbd(uid, "4")
|
||||
if _, err := c.Bot().Edit(c.Callback().Message, msg, kbd, tele.ModeHTML); err != nil {
|
||||
_ = sendSettingsMenuPage(c, uid, "4")
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "canceled")})
|
||||
}
|
||||
if settings.ReadOnly {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_readonly")})
|
||||
}
|
||||
torr.SetDefSettings()
|
||||
dlna.Stop()
|
||||
rutor.Stop()
|
||||
rutor.Start()
|
||||
msg := sendSettingsMenuText(c, uid, "4")
|
||||
kbd := sendSettingsMenuKbd(uid, "4")
|
||||
if _, err := c.Bot().Edit(c.Callback().Message, msg, kbd, tele.ModeHTML); err != nil {
|
||||
_ = sendSettingsMenuPage(c, uid, "4")
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_reset_done")})
|
||||
}
|
||||
|
||||
if len(action) > 12 && action[:12] == "storage_set|" {
|
||||
if settings.ReadOnly {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_readonly")})
|
||||
}
|
||||
val := action[12:]
|
||||
prefs := map[string]interface{}{"settings": val}
|
||||
if err := settings.SetStoragePreferences(prefs); err != nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: fmt.Sprintf(tr(uid, "settings_error"), err.Error())})
|
||||
}
|
||||
page := "4"
|
||||
msg := sendSettingsMenuText(c, uid, page)
|
||||
kbd := sendSettingsMenuKbd(uid, page)
|
||||
if _, err := c.Bot().Edit(c.Callback().Message, msg, kbd, tele.ModeHTML); err != nil {
|
||||
_ = sendSettingsMenuPage(c, uid, page)
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_saved")})
|
||||
}
|
||||
|
||||
if len(action) > 12 && action[:12] == "storage_view|" {
|
||||
if settings.ReadOnly {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_readonly")})
|
||||
}
|
||||
val := action[12:]
|
||||
prefs := map[string]interface{}{"viewed": val}
|
||||
if err := settings.SetStoragePreferences(prefs); err != nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: fmt.Sprintf(tr(uid, "settings_error"), err.Error())})
|
||||
}
|
||||
page := "4"
|
||||
msg := sendSettingsMenuText(c, uid, page)
|
||||
kbd := sendSettingsMenuKbd(uid, page)
|
||||
if _, err := c.Bot().Edit(c.Callback().Message, msg, kbd, tele.ModeHTML); err != nil {
|
||||
_ = sendSettingsMenuPage(c, uid, page)
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_saved")})
|
||||
}
|
||||
|
||||
if len(action) > 4 && action[:4] == "ask|" {
|
||||
setting := action[4:]
|
||||
var hint string
|
||||
switch setting {
|
||||
case "friendlyname":
|
||||
hint = tr(uid, "settings_hint_friendlyname")
|
||||
case "torrentssavepath":
|
||||
hint = tr(uid, "settings_hint_path")
|
||||
case "sslcert":
|
||||
hint = tr(uid, "settings_hint_sslcert")
|
||||
case "sslkey":
|
||||
hint = tr(uid, "settings_hint_sslkey")
|
||||
case "tmdbkey":
|
||||
hint = tr(uid, "settings_hint_tmdbkey")
|
||||
case "proxyhosts":
|
||||
hint = tr(uid, "settings_hint_proxyhosts")
|
||||
case "torznab_add":
|
||||
hint = tr(uid, "settings_hint_torznab")
|
||||
case "torznab_test":
|
||||
hint = tr(uid, "settings_hint_torznab_test")
|
||||
default:
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "callback_unknown")})
|
||||
}
|
||||
return sendSettingsInputPrompt(c, uid, setting, hint)
|
||||
}
|
||||
|
||||
if len(action) > 5 && action[:5] == "page|" {
|
||||
page := action[5:]
|
||||
msg := sendSettingsMenuText(c, uid, page)
|
||||
kbd := sendSettingsMenuKbd(uid, page)
|
||||
if _, err := c.Bot().Edit(c.Callback().Message, msg, kbd, tele.ModeHTML); err != nil {
|
||||
_ = sendSettingsMenuPage(c, uid, page)
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{})
|
||||
}
|
||||
|
||||
if settings.ReadOnly {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_readonly")})
|
||||
}
|
||||
|
||||
sets := new(settings.BTSets)
|
||||
*sets = *settings.BTsets
|
||||
page := "1"
|
||||
|
||||
// Extract return page from action (e.g. "rutor|1a" -> action "rutor", page "1a")
|
||||
if idx := strings.Index(action, "|"); idx >= 0 {
|
||||
suffix := action[idx+1:]
|
||||
if suffix == "1a" || suffix == "1b" || suffix == "1c" {
|
||||
page = suffix
|
||||
action = action[:idx]
|
||||
}
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "rutor":
|
||||
sets.EnableRutorSearch = !sets.EnableRutorSearch
|
||||
case "torznab":
|
||||
sets.EnableTorznabSearch = !sets.EnableTorznabSearch
|
||||
case "dlna":
|
||||
sets.EnableDLNA = !sets.EnableDLNA
|
||||
case "ipv6":
|
||||
sets.EnableIPv6 = !sets.EnableIPv6
|
||||
case "upload":
|
||||
sets.DisableUpload = !sets.DisableUpload
|
||||
case "dht":
|
||||
sets.DisableDHT = !sets.DisableDHT
|
||||
case "pex":
|
||||
sets.DisablePEX = !sets.DisablePEX
|
||||
case "tcp":
|
||||
sets.DisableTCP = !sets.DisableTCP
|
||||
case "utp":
|
||||
sets.DisableUTP = !sets.DisableUTP
|
||||
case "upnp":
|
||||
sets.DisableUPNP = !sets.DisableUPNP
|
||||
case "encrypt":
|
||||
sets.ForceEncrypt = !sets.ForceEncrypt
|
||||
case "debug":
|
||||
sets.EnableDebug = !sets.EnableDebug
|
||||
case "cachedrop":
|
||||
sets.RemoveCacheOnDrop = !sets.RemoveCacheOnDrop
|
||||
case "responsive":
|
||||
sets.ResponsiveMode = !sets.ResponsiveMode
|
||||
case "proxy":
|
||||
sets.EnableProxy = !sets.EnableProxy
|
||||
case "usedisk":
|
||||
sets.UseDisk = !sets.UseDisk
|
||||
case "fsactive":
|
||||
sets.ShowFSActiveTorr = !sets.ShowFSActiveTorr
|
||||
case "storejson":
|
||||
sets.StoreSettingsInJson = !sets.StoreSettingsInJson
|
||||
case "viewedjson":
|
||||
sets.StoreViewedInJson = !sets.StoreViewedInJson
|
||||
case "torznab_clear":
|
||||
if settings.ReadOnly {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_readonly")})
|
||||
}
|
||||
sets.TorznabUrls = nil
|
||||
page = "3"
|
||||
torr.SetSettings(sets)
|
||||
rutor.Stop()
|
||||
rutor.Start()
|
||||
msg := sendSettingsMenuText(c, uid, page)
|
||||
kbd := sendSettingsMenuKbd(uid, page)
|
||||
if _, err := c.Bot().Edit(c.Callback().Message, msg, kbd, tele.ModeHTML); err != nil {
|
||||
_ = sendSettingsMenuPage(c, uid, page)
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_saved")})
|
||||
default:
|
||||
if parts := splitAction(action); len(parts) == 2 {
|
||||
key, value := parts[0], parts[1]
|
||||
page = "2"
|
||||
if idx := strings.Index(value, "|"); idx >= 0 {
|
||||
if ret := value[idx+1:]; ret == "2a" || ret == "2b" || ret == "2c" {
|
||||
page = ret
|
||||
}
|
||||
value = value[:idx]
|
||||
}
|
||||
switch key {
|
||||
case "cache":
|
||||
if v := parseInt(value); v > 0 {
|
||||
sets.CacheSize = int64(v) * 1024 * 1024
|
||||
}
|
||||
case "preload":
|
||||
if v := parseInt(value); v >= 0 && v <= 100 {
|
||||
sets.PreloadCache = v
|
||||
}
|
||||
case "readahead":
|
||||
if v := parseInt(value); v >= 5 && v <= 100 {
|
||||
sets.ReaderReadAHead = v
|
||||
}
|
||||
case "conn":
|
||||
if v := parseInt(value); v > 0 {
|
||||
sets.ConnectionsLimit = v
|
||||
}
|
||||
case "timeout":
|
||||
if v := parseInt(value); v > 0 {
|
||||
sets.TorrentDisconnectTimeout = v
|
||||
}
|
||||
case "port":
|
||||
v := parseInt(value)
|
||||
if v >= 0 && (v == 0 || (v >= 1024 && v <= 65535)) {
|
||||
sets.PeersListenPort = v
|
||||
}
|
||||
case "down":
|
||||
sets.DownloadRateLimit = parseInt(value)
|
||||
case "up":
|
||||
sets.UploadRateLimit = parseInt(value)
|
||||
case "retr":
|
||||
if v := parseInt(value); v >= 0 && v <= 3 {
|
||||
sets.RetrackersMode = v
|
||||
}
|
||||
default:
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "callback_unknown")})
|
||||
}
|
||||
} else {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "callback_unknown")})
|
||||
}
|
||||
}
|
||||
|
||||
torr.SetSettings(sets)
|
||||
dlna.Stop()
|
||||
if sets.EnableDLNA {
|
||||
dlna.Start()
|
||||
}
|
||||
rutor.Stop()
|
||||
rutor.Start()
|
||||
|
||||
msg := sendSettingsMenuText(c, uid, page)
|
||||
kbd := sendSettingsMenuKbd(uid, page)
|
||||
if _, err := c.Bot().Edit(c.Callback().Message, msg, kbd, tele.ModeHTML); err != nil {
|
||||
_ = sendSettingsMenuPage(c, uid, page)
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_saved")})
|
||||
}
|
||||
|
||||
func splitAction(action string) []string {
|
||||
for i := 0; i < len(action); i++ {
|
||||
if action[i] == '|' {
|
||||
return []string{action[:i], action[i+1:]}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseInt(s string) int {
|
||||
var n int
|
||||
for _, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
func cmdShutdown(c tele.Context) error {
|
||||
uid := c.Sender().ID
|
||||
btnYes := tele.InlineButton{Text: tr(uid, "btn_yes"), Unique: "fshutdown", Data: "1"}
|
||||
btnNo := tele.InlineButton{Text: tr(uid, "btn_no"), Unique: "fshutdown", Data: "0"}
|
||||
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{{btnYes, btnNo}}}
|
||||
return c.Send(tr(uid, "shutdown_confirm"), kbd)
|
||||
}
|
||||
|
||||
func shutdownConfirm(c tele.Context, confirm string) error {
|
||||
uid := c.Sender().ID
|
||||
if !isAdmin(uid) {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "admin_only")})
|
||||
}
|
||||
if confirm != "1" {
|
||||
_ = c.Respond(&tele.CallbackResponse{Text: tr(uid, "canceled")})
|
||||
return c.Bot().Delete(c.Callback().Message)
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "server_stopped")})
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
_ = c.Send(tr(c.Sender().ID, "server_stopped"))
|
||||
go func() {
|
||||
torr.Shutdown()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"gopkg.in/telebot.v4/middleware"
|
||||
|
||||
"server/log"
|
||||
"server/tgbot/config"
|
||||
up "server/tgbot/upload"
|
||||
)
|
||||
|
||||
func newTelegramHTTPClient() *http.Client {
|
||||
const timeout = 5 * time.Minute
|
||||
trimmed := strings.TrimSpace(config.Cfg.Socks5)
|
||||
if trimmed == "" {
|
||||
return &http.Client{Timeout: timeout}
|
||||
}
|
||||
raw := trimmed
|
||||
if !strings.Contains(raw, "://") {
|
||||
raw = "socks5://" + raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
log.TLogln("tg cfg Socks5 parse err, using direct", err)
|
||||
return &http.Client{Timeout: timeout}
|
||||
}
|
||||
if u.Scheme != "socks5" {
|
||||
log.TLogln("tg cfg Socks5: only socks5 is supported, got", u.Scheme)
|
||||
return &http.Client{Timeout: timeout}
|
||||
}
|
||||
proxyHost := u.Host
|
||||
if proxyHost == "" {
|
||||
log.TLogln("tg cfg Socks5: empty host, using direct")
|
||||
return &http.Client{Timeout: timeout}
|
||||
}
|
||||
var auth *proxy.Auth
|
||||
if u.User != nil {
|
||||
pw, _ := u.User.Password()
|
||||
auth = &proxy.Auth{User: u.User.Username(), Password: pw}
|
||||
}
|
||||
socksDial, err := proxy.SOCKS5("tcp", proxyHost, auth, proxy.Direct)
|
||||
if err != nil {
|
||||
log.TLogln("tg socks5 dialer err, using direct", err)
|
||||
return &http.Client{Timeout: timeout}
|
||||
}
|
||||
log.TLogln("tg using SOCKS5 proxy", proxyHost)
|
||||
transport := &http.Transport{
|
||||
Proxy: nil, // respect explicit socks only, not HTTP_PROXY, for this client
|
||||
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
_ = ctx
|
||||
return socksDial.Dial(network, address)
|
||||
},
|
||||
}
|
||||
return &http.Client{Transport: transport, Timeout: timeout}
|
||||
}
|
||||
|
||||
func Start(token string) error {
|
||||
config.LoadConfig()
|
||||
loadUserLangs()
|
||||
|
||||
pref := tele.Settings{
|
||||
URL: config.Cfg.HostTG,
|
||||
Token: token,
|
||||
Poller: &tele.LongPoller{Timeout: 5 * time.Minute},
|
||||
ParseMode: tele.ModeHTML,
|
||||
Client: newTelegramHTTPClient(),
|
||||
}
|
||||
|
||||
log.TLogln("tg bot starting")
|
||||
|
||||
b, err := tele.NewBot(pref)
|
||||
if err != nil {
|
||||
log.TLogln("tg bot start err", err)
|
||||
return err
|
||||
}
|
||||
|
||||
up.TrFunc = tr
|
||||
up.EscapeFunc = escapeHtml
|
||||
|
||||
if err := b.SetCommands([]tele.Command{
|
||||
{Text: "help", Description: "Help and user ID"},
|
||||
{Text: "start", Description: "Start bot"},
|
||||
{Text: "list", Description: "List torrents"},
|
||||
{Text: "add", Description: "Add torrent"},
|
||||
{Text: "search", Description: "Search all (RuTor+Torznab)"},
|
||||
{Text: "rutor", Description: "Search RuTor"},
|
||||
{Text: "torznab", Description: "Search Torznab"},
|
||||
{Text: "remove", Description: "Remove torrent"},
|
||||
{Text: "status", Description: "Torrent status"},
|
||||
{Text: "link", Description: "Stream link"},
|
||||
{Text: "m3u", Description: "M3U playlist"},
|
||||
{Text: "preload", Description: "Preload file"},
|
||||
{Text: "queue", Description: "Upload queue status"},
|
||||
{Text: "server", Description: "Server info"},
|
||||
{Text: "stats", Description: "Summary statistics"},
|
||||
{Text: "stat", Description: "Detailed status"},
|
||||
{Text: "snake", Description: "Cache visualization"},
|
||||
{Text: "clear", Description: "Remove all torrents"},
|
||||
{Text: "hash", Description: "Show hashes"},
|
||||
{Text: "export", Description: "Export torrents"},
|
||||
{Text: "import", Description: "Import torrents"},
|
||||
{Text: "categories", Description: "List categories"},
|
||||
{Text: "lang", Description: "Set language RU|EN"},
|
||||
}); err != nil {
|
||||
log.TLogln("tg setcmd err", err)
|
||||
}
|
||||
|
||||
if len(config.Cfg.WhiteIds) > 0 {
|
||||
b.Use(middleware.Whitelist(config.Cfg.WhiteIds...))
|
||||
}
|
||||
if len(config.Cfg.BlackIds) > 0 {
|
||||
b.Use(middleware.Blacklist(config.Cfg.BlackIds...))
|
||||
}
|
||||
|
||||
b.Use(func(next tele.HandlerFunc) tele.HandlerFunc {
|
||||
return func(c tele.Context) error {
|
||||
if c.Sender() == nil {
|
||||
return nil
|
||||
}
|
||||
if c.Message() != nil && c.Message().Text != "" {
|
||||
cmd := logSafeStr(c.Message().Text, 60)
|
||||
log.TLogln("tg cmd", logUser(c.Sender()), cmd)
|
||||
}
|
||||
err := next(c)
|
||||
if err != nil {
|
||||
log.TLogln("tg cmd err", logUser(c.Sender()), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
})
|
||||
|
||||
b.Handle("help", help)
|
||||
b.Handle("Help", help)
|
||||
b.Handle("/help", help)
|
||||
b.Handle("/Help", help)
|
||||
b.Handle("/start", help)
|
||||
b.Handle("/id", help)
|
||||
|
||||
b.Handle("/list", list)
|
||||
b.Handle("/clear", clear)
|
||||
b.Handle("/add", cmdAdd)
|
||||
b.Handle("/remove", cmdRemove)
|
||||
b.Handle("/drop", cmdDrop)
|
||||
b.Handle("/status", cmdStatus)
|
||||
b.Handle("/server", cmdServer)
|
||||
b.Handle("/link", cmdLink)
|
||||
b.Handle("/play", cmdLink)
|
||||
b.Handle("/cache", cmdCache)
|
||||
b.Handle("/m3u", cmdM3u)
|
||||
b.Handle("/m3uall", cmdM3uAll)
|
||||
b.Handle("/search", cmdSearch)
|
||||
b.Handle("/rutor", cmdSearchRutor)
|
||||
b.Handle("/torznab", cmdTorznab)
|
||||
b.Handle("/preload", cmdPreload)
|
||||
b.Handle("/queue", up.ShowQueue)
|
||||
b.Handle("/set", cmdSet)
|
||||
b.Handle("/hash", cmdHash)
|
||||
b.Handle("/export", cmdExport)
|
||||
b.Handle("/import", cmdImport)
|
||||
b.Handle("/categories", cmdCategories)
|
||||
b.Handle("/echo", cmdEcho)
|
||||
b.Handle("/db", cmdDb)
|
||||
b.Handle("/viewed", cmdViewed)
|
||||
b.Handle("/ffp", cmdFfp)
|
||||
b.Handle("/speedtest", cmdSpeedtest)
|
||||
b.Handle("/shutdown", adminOnly(cmdShutdown))
|
||||
b.Handle("/settings", adminOnly(cmdSettings))
|
||||
b.Handle("/preset", adminOnly(cmdPreset))
|
||||
b.Handle("/lang", cmdLang)
|
||||
b.Handle("/stats", cmdStats)
|
||||
b.Handle("/stat", cmdStat)
|
||||
b.Handle("/snake", cmdSnake)
|
||||
|
||||
b.Handle(tele.OnDocument, func(c tele.Context) error {
|
||||
if c.Message() == nil {
|
||||
return nil
|
||||
}
|
||||
doc := c.Message().Document
|
||||
if doc == nil {
|
||||
return nil
|
||||
}
|
||||
lowerName := strings.ToLower(doc.FileName)
|
||||
isTorrent := strings.HasSuffix(lowerName, ".torrent") ||
|
||||
strings.Contains(strings.ToLower(doc.MIME), "bittorrent")
|
||||
if isTorrent {
|
||||
err := addTorrentFromDocument(c, doc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return list(c)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
b.Handle(tele.OnText, func(c tele.Context) error {
|
||||
txt := c.Text()
|
||||
if handleSettingsInputReply(c) {
|
||||
return nil
|
||||
}
|
||||
lower := strings.ToLower(txt)
|
||||
if strings.HasPrefix(lower, "magnet:") || strings.HasPrefix(lower, "torrs://") ||
|
||||
strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") ||
|
||||
isHash(txt) {
|
||||
err := addTorrent(c, txt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return list(c)
|
||||
} else if c.Message().ReplyTo != nil && c.Message().ReplyTo.ReplyMarkup != nil && len(c.Message().ReplyTo.ReplyMarkup.InlineKeyboard) > 0 {
|
||||
var hash string
|
||||
for _, row := range c.Message().ReplyTo.ReplyMarkup.InlineKeyboard {
|
||||
for _, btn := range row {
|
||||
if btn.Data == "" {
|
||||
continue
|
||||
}
|
||||
if idx := strings.Index(btn.Data, "all|"); idx >= 0 {
|
||||
h := btn.Data[idx+4:]
|
||||
if len(h) >= 40 && isHash(h[:40]) {
|
||||
hash = h[:40]
|
||||
} else if isHash(h) {
|
||||
hash = h
|
||||
}
|
||||
} else if isHash(btn.Data) {
|
||||
hash = btn.Data
|
||||
}
|
||||
if hash != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
if hash != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
if hash != "" {
|
||||
from, to, err := ParseRange(c.Sender().ID, c.Message().Text)
|
||||
if err != nil {
|
||||
_ = c.Send(tr(c.Sender().ID, "range_error"))
|
||||
return err
|
||||
}
|
||||
up.AddRange(c, hash, from, to)
|
||||
}
|
||||
return nil
|
||||
} else {
|
||||
return c.Send(tr(c.Sender().ID, "add_magnet"))
|
||||
}
|
||||
})
|
||||
|
||||
b.Handle(tele.OnQuery, handleInlineQuery)
|
||||
|
||||
b.Handle(tele.OnCallback, func(c tele.Context) error {
|
||||
args := c.Args()
|
||||
if len(args) > 0 {
|
||||
cbInfo := strings.TrimPrefix(args[0], "\f")
|
||||
if len(args) >= 2 {
|
||||
cbInfo += " " + args[1]
|
||||
}
|
||||
cbInfo = logSafeStr(cbInfo, 80)
|
||||
log.TLogln("tg cb", logUser(c.Sender()), cbInfo)
|
||||
}
|
||||
err := handleCallback(c)
|
||||
if err != nil && len(args) > 0 {
|
||||
log.TLogln("tg cb err", logUser(c.Sender()), logSafeStr(args[0], 40), err)
|
||||
}
|
||||
return err
|
||||
})
|
||||
|
||||
up.Start()
|
||||
|
||||
go b.Start()
|
||||
return nil
|
||||
}
|
||||
|
||||
func help(c tele.Context) error {
|
||||
uid := c.Sender().ID
|
||||
id := strconv.FormatInt(uid, 10)
|
||||
var arr []string
|
||||
if c.Sender().Username != "" {
|
||||
arr = append(arr, c.Sender().Username)
|
||||
}
|
||||
if c.Sender().FirstName != "" {
|
||||
arr = append(arr, c.Sender().FirstName)
|
||||
}
|
||||
if c.Sender().LastName != "" {
|
||||
arr = append(arr, c.Sender().LastName)
|
||||
}
|
||||
msg := "🤖 <b>" + tr(uid, "help") + "</b>\n\n"
|
||||
msg += "📋 <b>" + tr(uid, "help_main") + "</b>\n"
|
||||
msg += " • /help — " + tr(uid, "help_help") + "\n"
|
||||
msg += " • " + tr(uid, "help_list") + "\n"
|
||||
msg += " • " + tr(uid, "help_clear") + "\n"
|
||||
msg += " • " + tr(uid, "help_add") + "\n"
|
||||
msg += " • " + tr(uid, "help_hash") + "\n"
|
||||
msg += " • /stats, /stat — " + tr(uid, "help_stats") + ", " + tr(uid, "help_stat") + "\n\n"
|
||||
msg += "🎛 <b>" + tr(uid, "help_manage") + "</b> " + tr(uid, "help_manage_desc") + "\n"
|
||||
msg += " • " + tr(uid, "help_remove") + "\n"
|
||||
msg += " • " + tr(uid, "help_links") + "\n\n"
|
||||
msg += "🔍 <b>" + tr(uid, "help_search") + "</b> " + tr(uid, "help_search_desc") + "\n"
|
||||
msg += " • " + tr(uid, "help_search_cmd") + "\n\n"
|
||||
msg += "📦 <b>" + tr(uid, "help_export_import") + "</b>\n"
|
||||
msg += " • " + tr(uid, "help_export") + "\n"
|
||||
msg += " • " + tr(uid, "help_import") + "\n\n"
|
||||
msg += "📁 <b>" + tr(uid, "help_categories_section") + "</b>\n"
|
||||
msg += " • " + tr(uid, "help_categories") + "\n\n"
|
||||
msg += "🖥 <b>" + tr(uid, "help_server") + "</b>\n"
|
||||
msg += " • " + tr(uid, "help_server_cmd") + "\n"
|
||||
msg += " • " + tr(uid, "help_echo") + "\n"
|
||||
msg += " • " + tr(uid, "help_db") + "\n\n"
|
||||
msg += "⚙️ <b>" + tr(uid, "help_other") + "</b>\n"
|
||||
msg += " • " + tr(uid, "help_other_cmd") + "\n"
|
||||
msg += " • " + tr(uid, "help_lang") + "\n"
|
||||
msg += " • " + tr(uid, "help_admin") + "\n\n"
|
||||
msg += "👤 " + tr(uid, "help_id") + ": <code>" + id + "</code>"
|
||||
if len(arr) > 0 {
|
||||
msg += " • " + strings.Join(arr, ", ")
|
||||
}
|
||||
return c.Send(msg)
|
||||
}
|
||||
|
||||
func isHash(txt string) bool {
|
||||
if len(txt) == 40 {
|
||||
for _, c := range strings.ToLower(txt) {
|
||||
switch c {
|
||||
case 'a', 'b', 'c', 'd', 'e', 'f', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ParseRange(userID int64, rng string) (int, int, error) {
|
||||
parts := strings.Split(rng, "-")
|
||||
|
||||
if len(parts) != 2 {
|
||||
return -1, -1, errors.New(tr(userID, "parse_range_err"))
|
||||
}
|
||||
|
||||
num1, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
if err1 != nil {
|
||||
return -1, -1, err1
|
||||
}
|
||||
|
||||
num2, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if err2 != nil {
|
||||
return -1, -1, err2
|
||||
}
|
||||
if num1 < 1 || num2 < 1 || num1 > num2 {
|
||||
return -1, -1, errors.New(tr(userID, "parse_range_err"))
|
||||
}
|
||||
return num1, num2, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
func cmdCache(c tele.Context) error {
|
||||
arg := ""
|
||||
if args := c.Args(); len(args) > 0 {
|
||||
arg = args[0]
|
||||
}
|
||||
hash := resolveHash(c, arg)
|
||||
if hash == "" {
|
||||
return c.Send(tr(c.Sender().ID, "cache_usage"))
|
||||
}
|
||||
|
||||
t := torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
return c.Send(tr(c.Sender().ID, "torrent_not_found") + ":\n<code>" + hash + "</code>")
|
||||
}
|
||||
|
||||
st := t.CacheState()
|
||||
if st == nil {
|
||||
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "cache_unavailable"), hash))
|
||||
}
|
||||
|
||||
uid := c.Sender().ID
|
||||
txt := "💾 <b>" + escapeHtml(st.Torrent.Title) + "</b>\n\n"
|
||||
txt += fmt.Sprintf("%s: %s\n", tr(uid, "cache_capacity"), humanize.IBytes(uint64(st.Capacity)))
|
||||
txt += fmt.Sprintf("%s: %s\n", tr(uid, "cache_filled"), humanize.IBytes(uint64(st.Filled)))
|
||||
txt += fmt.Sprintf("%s: %d\n", tr(uid, "cache_pieces"), st.PiecesCount)
|
||||
txt += fmt.Sprintf("%s: %d\n", tr(uid, "cache_readers"), len(st.Readers))
|
||||
txt += fmt.Sprintf("<code>%s</code>", hash)
|
||||
return c.Send(txt)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package tgbot
|
||||
|
||||
import tele "gopkg.in/telebot.v4"
|
||||
|
||||
// handleCallback routes callback queries to appropriate handlers
|
||||
func handleCallback(c tele.Context) error {
|
||||
if c.Sender() == nil {
|
||||
return nil
|
||||
}
|
||||
args := c.Args()
|
||||
if len(args) == 0 {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "\ffiles", "\fdelete", "\fupload", "\fuploadall", "\ffall", "\fcancel",
|
||||
"\ffstatus", "\ffm3u", "\fflink", "\ffdrop", "\ffstatusrefresh", "\ffstatusstop",
|
||||
"\fflist", "\ffrefresh", "\ffnop", "\ffpreload", "\ffitems", "\ffifresh",
|
||||
"\ffsnakerefresh", "\ffsnakestop":
|
||||
return handleCallbackTorrent(c, args)
|
||||
case "\ffadd", "\ffmore":
|
||||
return handleCallbackSearch(c, args)
|
||||
case "\ffexport", "\ffexportrefresh", "\ffhash", "\ffhashrefresh",
|
||||
"\ffstatusall", "\ffstatusallrefresh", "\ffdb", "\ffdbrefresh":
|
||||
return handleCallbackExport(c, args)
|
||||
case "\ffclear", "\ffshutdown", "\ffpreset", "\ffset":
|
||||
return handleCallbackAdmin(c, args)
|
||||
default:
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package tgbot
|
||||
|
||||
import tele "gopkg.in/telebot.v4"
|
||||
|
||||
func handleCallbackAdmin(c tele.Context, args []string) error {
|
||||
switch args[0] {
|
||||
case "\ffclear":
|
||||
if len(args) > 1 {
|
||||
return clearConfirm(c, args[1])
|
||||
}
|
||||
case "\ffshutdown":
|
||||
if len(args) > 1 {
|
||||
return shutdownConfirm(c, args[1])
|
||||
}
|
||||
case "\ffpreset":
|
||||
if len(args) > 1 {
|
||||
return presetConfirm(c, args[1])
|
||||
}
|
||||
case "\ffset":
|
||||
if len(args) > 1 {
|
||||
action := args[1]
|
||||
for i := 2; i < len(args); i++ {
|
||||
action += "|" + args[i]
|
||||
}
|
||||
return settingsCallback(c, action)
|
||||
}
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package tgbot
|
||||
|
||||
import tele "gopkg.in/telebot.v4"
|
||||
|
||||
func handleCallbackExport(c tele.Context, args []string) error {
|
||||
switch args[0] {
|
||||
case "\ffexport":
|
||||
data := ""
|
||||
if len(args) > 1 {
|
||||
data = args[1]
|
||||
}
|
||||
return callbackExportPage(c, data)
|
||||
case "\ffexportrefresh":
|
||||
data := ""
|
||||
if len(args) > 1 {
|
||||
data = args[1]
|
||||
}
|
||||
return callbackExportRefresh(c, data)
|
||||
case "\ffhash":
|
||||
data := ""
|
||||
if len(args) > 1 {
|
||||
data = args[1]
|
||||
}
|
||||
return callbackHashPage(c, data)
|
||||
case "\ffhashrefresh":
|
||||
data := ""
|
||||
if len(args) > 1 {
|
||||
data = args[1]
|
||||
}
|
||||
return callbackHashRefresh(c, data)
|
||||
case "\ffstatusall":
|
||||
data := ""
|
||||
if len(args) > 1 {
|
||||
data = args[1]
|
||||
}
|
||||
return callbackStatusAllPage(c, data)
|
||||
case "\ffstatusallrefresh":
|
||||
data := ""
|
||||
if len(args) > 1 {
|
||||
data = args[1]
|
||||
}
|
||||
return callbackStatusAllRefresh(c, data)
|
||||
case "\ffdb":
|
||||
data := ""
|
||||
if len(args) > 1 {
|
||||
data = args[1]
|
||||
}
|
||||
return callbackDbPage(c, data)
|
||||
case "\ffdbrefresh":
|
||||
data := ""
|
||||
if len(args) > 1 {
|
||||
data = args[1]
|
||||
}
|
||||
return callbackDbRefresh(c, data)
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package tgbot
|
||||
|
||||
import tele "gopkg.in/telebot.v4"
|
||||
|
||||
func handleCallbackSearch(c tele.Context, args []string) error {
|
||||
switch args[0] {
|
||||
case "\ffadd":
|
||||
if len(args) > 1 {
|
||||
return callbackSearchAdd(c, args[1])
|
||||
}
|
||||
case "\ffmore":
|
||||
if len(args) > 1 {
|
||||
return callbackSearchMore(c, args[1])
|
||||
}
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
up "server/tgbot/upload"
|
||||
)
|
||||
|
||||
func handleCallbackTorrent(c tele.Context, args []string) error {
|
||||
switch args[0] {
|
||||
case "\ffiles":
|
||||
return files(c)
|
||||
case "\fdelete":
|
||||
if len(args) < 2 {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
deleteTorrent(c)
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "deleted")})
|
||||
case "\fupload":
|
||||
return upload(c)
|
||||
case "\fuploadall", "\ffall":
|
||||
return uploadall(c)
|
||||
case "\fcancel":
|
||||
if len(args) > 1 {
|
||||
if num, err := strconv.Atoi(args[1]); err == nil {
|
||||
up.Cancel(num)
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
return c.Respond(&tele.CallbackResponse{})
|
||||
}
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{})
|
||||
case "\ffstatus", "\ffm3u", "\fflink", "\ffdrop", "\ffstatusrefresh", "\ffstatusstop":
|
||||
hash := ""
|
||||
if len(args) >= 2 {
|
||||
hash = args[1]
|
||||
}
|
||||
switch args[0] {
|
||||
case "\ffstatus":
|
||||
if hash == "" {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
return callbackStatus(c, hash)
|
||||
case "\ffstatusrefresh":
|
||||
return callbackStatusRefresh(c, hash)
|
||||
case "\ffstatusstop":
|
||||
return callbackStatusStop(c, hash)
|
||||
case "\ffm3u":
|
||||
if hash == "" {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
return callbackM3u(c, hash)
|
||||
case "\fflink":
|
||||
if len(args) < 2 {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
return callbackLink(c, args[1])
|
||||
case "\ffdrop":
|
||||
if hash == "" {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
return callbackDrop(c, hash)
|
||||
}
|
||||
case "\fflist":
|
||||
if len(args) > 1 {
|
||||
return callbackListPage(c, args[1])
|
||||
}
|
||||
case "\ffrefresh":
|
||||
if len(args) > 1 {
|
||||
return callbackListRefresh(c, args[1])
|
||||
}
|
||||
case "\ffitems":
|
||||
if len(args) >= 3 {
|
||||
return callbackFileListPage(c, args[1], args[2])
|
||||
}
|
||||
case "\ffifresh":
|
||||
if len(args) >= 3 {
|
||||
return callbackFileListRefresh(c, args[1], args[2])
|
||||
}
|
||||
case "\ffnop":
|
||||
return c.Respond(&tele.CallbackResponse{})
|
||||
case "\ffpreload":
|
||||
if len(args) >= 3 {
|
||||
return callbackPreload(c, args[1], args[2])
|
||||
}
|
||||
case "\ffsnakerefresh", "\ffsnakestop":
|
||||
data := ""
|
||||
if len(args) >= 2 {
|
||||
data = args[1]
|
||||
}
|
||||
switch args[0] {
|
||||
case "\ffsnakerefresh":
|
||||
return callbackSnakeRefresh(c, data)
|
||||
case "\ffsnakestop":
|
||||
return callbackSnakeStop(c, data)
|
||||
}
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
func cmdCategories(c tele.Context) error {
|
||||
torrents := torr.ListTorrent()
|
||||
if len(torrents) == 0 {
|
||||
return c.Send(tr(c.Sender().ID, "no_torrents"))
|
||||
}
|
||||
uid := c.Sender().ID
|
||||
catCount := make(map[string]int)
|
||||
for _, t := range torrents {
|
||||
cat := t.Category
|
||||
if cat == "" {
|
||||
cat = tr(uid, "categories_uncategorized")
|
||||
}
|
||||
catCount[cat]++
|
||||
}
|
||||
var cats []string
|
||||
for c := range catCount {
|
||||
cats = append(cats, c)
|
||||
}
|
||||
sort.Strings(cats)
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "📁 <b>%s</b>\n\n", tr(uid, "categories_title"))
|
||||
for _, cat := range cats {
|
||||
fmt.Fprintf(&sb, "• %s: %d\n", escapeHtml(cat), catCount[cat])
|
||||
}
|
||||
return c.Send(strings.TrimSuffix(sb.String(), "\n"))
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"server/log"
|
||||
"server/settings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HostTG string
|
||||
HostWeb string
|
||||
Socks5 string
|
||||
WhiteIds []int64
|
||||
BlackIds []int64
|
||||
}
|
||||
|
||||
var Cfg *Config
|
||||
|
||||
func LoadConfig() {
|
||||
Cfg = &Config{}
|
||||
fn := filepath.Join(settings.Path, "tg.cfg")
|
||||
buf, err := os.ReadFile(fn)
|
||||
if err != nil {
|
||||
Cfg.WhiteIds = []int64{}
|
||||
Cfg.BlackIds = []int64{}
|
||||
Cfg.HostTG = "https://api.telegram.org"
|
||||
buf, _ = json.MarshalIndent(Cfg, "", " ")
|
||||
if buf != nil {
|
||||
os.WriteFile(fn, buf, 0o600)
|
||||
}
|
||||
return
|
||||
}
|
||||
err = json.Unmarshal(buf, &Cfg)
|
||||
if err != nil {
|
||||
log.TLogln("tg config read err", err)
|
||||
Cfg.WhiteIds = []int64{}
|
||||
Cfg.BlackIds = []int64{}
|
||||
}
|
||||
if Cfg.HostTG == "" || (!strings.HasPrefix(Cfg.HostTG, "http://") && !strings.HasPrefix(Cfg.HostTG, "https://")) {
|
||||
Cfg.HostTG = "https://api.telegram.org"
|
||||
}
|
||||
if Cfg.WhiteIds == nil {
|
||||
Cfg.WhiteIds = []int64{}
|
||||
}
|
||||
if Cfg.BlackIds == nil {
|
||||
Cfg.BlackIds = []int64{}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/log"
|
||||
sets "server/settings"
|
||||
)
|
||||
|
||||
const dbPageSize = 10
|
||||
|
||||
func cmdDb(c tele.Context) error {
|
||||
return sendDbPage(c, 0)
|
||||
}
|
||||
|
||||
func sendDbPage(c tele.Context, page int) error {
|
||||
uid := c.Sender().ID
|
||||
dbList := sets.ListTorrent()
|
||||
if len(dbList) == 0 {
|
||||
return c.Send(tr(uid, "db_empty"))
|
||||
}
|
||||
|
||||
totalPages := (len(dbList) + dbPageSize - 1) / dbPageSize
|
||||
if page < 0 {
|
||||
page = 0
|
||||
}
|
||||
if page >= totalPages {
|
||||
page = totalPages - 1
|
||||
}
|
||||
start := page * dbPageSize
|
||||
end := start + dbPageSize
|
||||
if end > len(dbList) {
|
||||
end = len(dbList)
|
||||
}
|
||||
pageList := dbList[start:end]
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("📁 <b>" + tr(uid, "db_title") + "</b> (" + strconv.Itoa(len(dbList)) + ")\n\n")
|
||||
for i, t := range pageList {
|
||||
hash := t.InfoHash.HexString()
|
||||
sb.WriteString(strconv.Itoa(start+i+1) + ". <b>" + escapeHtml(t.Title) + "</b>")
|
||||
if t.Size > 0 {
|
||||
sb.WriteString(" <i>" + humanize.IBytes(uint64(t.Size)) + "</i>")
|
||||
}
|
||||
sb.WriteString("\n<code>" + hash + "</code>\n\n")
|
||||
}
|
||||
msg := strings.TrimSuffix(sb.String(), "\n\n")
|
||||
|
||||
navRow := []tele.InlineButton{}
|
||||
if totalPages > 1 {
|
||||
if page > 0 {
|
||||
navRow = append(navRow, tele.InlineButton{Text: "◀️", Unique: "fdb", Data: strconv.Itoa(page - 1)})
|
||||
}
|
||||
navRow = append(navRow, tele.InlineButton{Text: strconv.Itoa(page+1) + "/" + strconv.Itoa(totalPages), Unique: "fnop", Data: ""})
|
||||
if page < totalPages-1 {
|
||||
navRow = append(navRow, tele.InlineButton{Text: "▶️", Unique: "fdb", Data: strconv.Itoa(page + 1)})
|
||||
}
|
||||
}
|
||||
navRow = append(navRow, tele.InlineButton{Text: "🔄", Unique: "fdbrefresh", Data: strconv.Itoa(page)})
|
||||
|
||||
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{navRow}}
|
||||
if err := c.Send(msg, kbd); err != nil {
|
||||
log.TLogln("tg db send err", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func callbackDbPage(c tele.Context, data string) error {
|
||||
page := 0
|
||||
if data != "" {
|
||||
if p, err := strconv.Atoi(data); err == nil {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{})
|
||||
if c.Callback().Message != nil {
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
}
|
||||
return sendDbPage(c, page)
|
||||
}
|
||||
|
||||
func callbackDbRefresh(c tele.Context, data string) error {
|
||||
page := 0
|
||||
if data != "" {
|
||||
if p, err := strconv.Atoi(data); err == nil {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{Text: "🔄"})
|
||||
if c.Callback().Message != nil {
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
}
|
||||
return sendDbPage(c, page)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
func deleteTorrent(c tele.Context) {
|
||||
args := c.Args()
|
||||
if len(args) < 2 {
|
||||
return
|
||||
}
|
||||
hash := args[1]
|
||||
if !isHash(hash) {
|
||||
return
|
||||
}
|
||||
torr.RemTorrent(hash)
|
||||
}
|
||||
|
||||
func clear(c tele.Context) error {
|
||||
torrents := torr.ListTorrent()
|
||||
count := len(torrents)
|
||||
if count == 0 {
|
||||
return c.Send(tr(c.Sender().ID, "no_torrents"))
|
||||
}
|
||||
uid := c.Sender().ID
|
||||
btnYes := tele.InlineButton{Text: tr(uid, "btn_yes"), Unique: "fclear", Data: "1"}
|
||||
btnNo := tele.InlineButton{Text: tr(uid, "btn_no"), Unique: "fclear", Data: "0"}
|
||||
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{{btnYes, btnNo}}}
|
||||
return c.Send(fmt.Sprintf(tr(uid, "clear_confirm"), count), kbd)
|
||||
}
|
||||
|
||||
func clearConfirm(c tele.Context, confirm string) error {
|
||||
uid := c.Sender().ID
|
||||
if confirm != "1" {
|
||||
_ = c.Respond(&tele.CallbackResponse{Text: tr(uid, "canceled")})
|
||||
return c.Bot().Delete(c.Callback().Message)
|
||||
}
|
||||
torrents := torr.ListTorrent()
|
||||
count := len(torrents)
|
||||
for _, t := range torrents {
|
||||
torr.RemTorrent(t.Hash().HexString())
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{Text: tr(uid, "deleted")})
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
return c.Send(fmt.Sprintf(tr(uid, "clear_done"), count))
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
func callbackDrop(c tele.Context, hash string) error {
|
||||
torr.DropTorrent(hash)
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "drop_done")})
|
||||
}
|
||||
|
||||
func cmdDrop(c tele.Context) error {
|
||||
arg := ""
|
||||
if args := c.Args(); len(args) > 0 {
|
||||
arg = args[0]
|
||||
}
|
||||
hash := resolveHash(c, arg)
|
||||
if hash == "" {
|
||||
return c.Send(tr(c.Sender().ID, "remove_usage"))
|
||||
}
|
||||
|
||||
torr.DropTorrent(hash)
|
||||
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "drop_done_hash"), hash))
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/version"
|
||||
)
|
||||
|
||||
func cmdEcho(c tele.Context) error {
|
||||
v := version.Version
|
||||
if v == "" {
|
||||
v = "unknown"
|
||||
}
|
||||
return c.Send(fmt.Sprintf("🔄 TorrServer %s", v))
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/log"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
const exportPageSize = 10
|
||||
|
||||
func cmdExport(c tele.Context) error {
|
||||
torrents := torr.ListTorrent()
|
||||
if len(torrents) == 0 {
|
||||
return c.Send(tr(c.Sender().ID, "no_torrents"))
|
||||
}
|
||||
uid := c.Sender().ID
|
||||
|
||||
var magnets strings.Builder
|
||||
for _, t := range torrents {
|
||||
hash := t.Hash().HexString()
|
||||
title := t.Title
|
||||
if title == "" {
|
||||
title = t.Name()
|
||||
}
|
||||
magnet := fmt.Sprintf("magnet:?xt=urn:btih:%s", hash)
|
||||
if title != "" {
|
||||
magnet += "&dn=" + url.QueryEscape(title)
|
||||
}
|
||||
magnets.WriteString(magnet + "\n")
|
||||
}
|
||||
|
||||
doc := &tele.Document{}
|
||||
doc.FileName = "torrents.txt"
|
||||
doc.FileReader = bytes.NewReader([]byte(strings.TrimSuffix(magnets.String(), "\n")))
|
||||
doc.Caption = "📁 " + tr(uid, "export_file_caption")
|
||||
if err := c.Send(doc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return sendExportPage(c, 0)
|
||||
}
|
||||
|
||||
func sendExportPage(c tele.Context, page int) error {
|
||||
torrents := torr.ListTorrent()
|
||||
if len(torrents) == 0 {
|
||||
return c.Send(tr(c.Sender().ID, "no_torrents"))
|
||||
}
|
||||
|
||||
totalPages := (len(torrents) + exportPageSize - 1) / exportPageSize
|
||||
if page < 0 {
|
||||
page = 0
|
||||
}
|
||||
if page >= totalPages {
|
||||
page = totalPages - 1
|
||||
}
|
||||
start := page * exportPageSize
|
||||
end := start + exportPageSize
|
||||
if end > len(torrents) {
|
||||
end = len(torrents)
|
||||
}
|
||||
pageTorrents := torrents[start:end]
|
||||
|
||||
uid := c.Sender().ID
|
||||
var hashes strings.Builder
|
||||
fmt.Fprintf(&hashes, "📁 <b>%s</b> (%d)\n\n", tr(uid, "export_title"), len(torrents))
|
||||
for i, t := range pageTorrents {
|
||||
hash := t.Hash().HexString()
|
||||
title := t.Title
|
||||
if title == "" {
|
||||
title = t.Name()
|
||||
}
|
||||
fmt.Fprintf(&hashes, "%d. %s\n<code>%s</code>\n\n", start+i+1, escapeHtml(title), hash)
|
||||
}
|
||||
msg := strings.TrimSuffix(hashes.String(), "\n\n")
|
||||
|
||||
navRow := []tele.InlineButton{}
|
||||
if totalPages > 1 {
|
||||
if page > 0 {
|
||||
navRow = append(navRow, tele.InlineButton{Text: "◀️", Unique: "fexport", Data: strconv.Itoa(page - 1)})
|
||||
}
|
||||
navRow = append(navRow, tele.InlineButton{Text: strconv.Itoa(page+1) + "/" + strconv.Itoa(totalPages), Unique: "fnop", Data: ""})
|
||||
if page < totalPages-1 {
|
||||
navRow = append(navRow, tele.InlineButton{Text: "▶️", Unique: "fexport", Data: strconv.Itoa(page + 1)})
|
||||
}
|
||||
}
|
||||
navRow = append(navRow, tele.InlineButton{Text: "🔄", Unique: "fexportrefresh", Data: strconv.Itoa(page)})
|
||||
|
||||
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{navRow}}
|
||||
if err := c.Send(msg, kbd); err != nil {
|
||||
log.TLogln("tg export send err", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func callbackExportPage(c tele.Context, data string) error {
|
||||
page := 0
|
||||
if data != "" {
|
||||
if p, err := strconv.Atoi(data); err == nil {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{})
|
||||
if c.Callback().Message != nil {
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
}
|
||||
return sendExportPage(c, page)
|
||||
}
|
||||
|
||||
func callbackExportRefresh(c tele.Context, data string) error {
|
||||
page := 0
|
||||
if data != "" {
|
||||
if p, err := strconv.Atoi(data); err == nil {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{Text: "🔄"})
|
||||
if c.Callback().Message != nil {
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
}
|
||||
return sendExportPage(c, page)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"server/ffprobe"
|
||||
"server/settings"
|
||||
"server/torr"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
tele "gopkg.in/telebot.v4"
|
||||
ffp "gopkg.in/vansante/go-ffprobe.v2"
|
||||
)
|
||||
|
||||
// TODO: Use internal API for ffp
|
||||
|
||||
func cmdFfp(c tele.Context) error {
|
||||
uid := c.Sender().ID
|
||||
args := c.Args()
|
||||
if len(args) < 2 {
|
||||
return c.Send(tr(uid, "ffp_usage"))
|
||||
}
|
||||
hash := resolveHash(c, args[0])
|
||||
if hash == "" {
|
||||
return c.Send(tr(uid, "invalid_hash"))
|
||||
}
|
||||
id, err := strconv.Atoi(args[1])
|
||||
if err != nil || id < 1 {
|
||||
return c.Send(tr(uid, "ffp_file_index"))
|
||||
}
|
||||
|
||||
asJSON := false
|
||||
if len(args) >= 3 {
|
||||
last := strings.ToLower(strings.TrimSpace(args[len(args)-1]))
|
||||
if last == "json" || last == "--json" || last == "-j" {
|
||||
asJSON = true
|
||||
}
|
||||
}
|
||||
|
||||
t := torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
return c.Send(tr(uid, "torrent_not_found"))
|
||||
}
|
||||
|
||||
proto := "http"
|
||||
port := settings.Port
|
||||
if settings.Ssl {
|
||||
proto = "https"
|
||||
port = settings.SslPort
|
||||
}
|
||||
link := fmt.Sprintf("%s://127.0.0.1:%s/play/%s/%d", proto, port, hash, id)
|
||||
|
||||
data, err := ffprobe.ProbeUrl(link)
|
||||
if err != nil {
|
||||
return c.Send(fmt.Sprintf(tr(uid, "ffp_error"), err.Error()))
|
||||
}
|
||||
|
||||
var msg string
|
||||
if asJSON {
|
||||
buf, _ := json.MarshalIndent(data, "", " ")
|
||||
msg = "<pre>" + strings.ReplaceAll(string(buf), "<", "<") + "</pre>"
|
||||
if len(msg) > 4000 {
|
||||
msg = msg[:4000] + "\n...</pre>"
|
||||
}
|
||||
} else {
|
||||
msg = formatFfpHuman(data, uid)
|
||||
if len(msg) > 4000 {
|
||||
msg = msg[:4000] + "\n..."
|
||||
}
|
||||
}
|
||||
return c.Send(msg)
|
||||
}
|
||||
|
||||
func formatFfpHuman(data *ffp.ProbeData, uid int64) string {
|
||||
var sb strings.Builder
|
||||
|
||||
if data.Format != nil {
|
||||
f := data.Format
|
||||
sb.WriteString("<b>📁 " + tr(uid, "ffp_format") + "</b>\n")
|
||||
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_container"), f.FormatLongName)
|
||||
if f.DurationSeconds > 0 {
|
||||
d := int(f.DurationSeconds)
|
||||
h, m, s := d/3600, (d%3600)/60, d%60
|
||||
fmt.Fprintf(&sb, " %s: %02d:%02d:%02d\n", tr(uid, "ffp_duration"), h, m, s)
|
||||
}
|
||||
if f.Size != "" {
|
||||
if size, err := strconv.ParseInt(f.Size, 10, 64); err == nil {
|
||||
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_size"), humanize.IBytes(uint64(size)))
|
||||
} else {
|
||||
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_size"), f.Size)
|
||||
}
|
||||
}
|
||||
if f.BitRate != "" {
|
||||
if br, err := strconv.ParseInt(f.BitRate, 10, 64); err == nil {
|
||||
fmt.Fprintf(&sb, " %s: %s/s\n", tr(uid, "ffp_bitrate"), humanize.IBytes(uint64(br)))
|
||||
} else {
|
||||
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_bitrate"), f.BitRate)
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("<b>🎬 " + tr(uid, "ffp_streams") + "</b>\n\n")
|
||||
for i, s := range data.Streams {
|
||||
title := getTag(s.TagList, "title")
|
||||
lang := getTag(s.TagList, "language")
|
||||
if lang != "" {
|
||||
lang = " [" + lang + "]"
|
||||
}
|
||||
|
||||
switch s.CodecType {
|
||||
case "video":
|
||||
fmt.Fprintf(&sb, "<b>#%d %s</b>%s\n", i, tr(uid, "ffp_video"), lang)
|
||||
fmt.Fprintf(&sb, " %s: %s", tr(uid, "ffp_codec"), s.CodecLongName)
|
||||
if s.Profile != "" {
|
||||
fmt.Fprintf(&sb, " (%s)", s.Profile)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
if s.Width > 0 && s.Height > 0 {
|
||||
fmt.Fprintf(&sb, " %s: %d×%d", tr(uid, "ffp_resolution"), s.Width, s.Height)
|
||||
if s.DisplayAspectRatio != "" && s.DisplayAspectRatio != "0:0" {
|
||||
fmt.Fprintf(&sb, " (%s)", s.DisplayAspectRatio)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if s.PixFmt != "" {
|
||||
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_pixel"), s.PixFmt)
|
||||
}
|
||||
if s.RFrameRate != "" && s.RFrameRate != "0/0" {
|
||||
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_fps"), s.RFrameRate)
|
||||
}
|
||||
if s.BitRate != "" {
|
||||
if br, err := strconv.ParseInt(s.BitRate, 10, 64); err == nil {
|
||||
fmt.Fprintf(&sb, " %s: %s/s\n", tr(uid, "ffp_bitrate"), humanize.IBytes(uint64(br)))
|
||||
}
|
||||
}
|
||||
if s.ColorSpace != "" || s.ColorTransfer != "" {
|
||||
fmt.Fprintf(&sb, " %s: %s / %s / %s\n", tr(uid, "ffp_color"), s.ColorSpace, s.ColorTransfer, s.ColorPrimaries)
|
||||
}
|
||||
if title != "" {
|
||||
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_title"), escapeHtml(title))
|
||||
}
|
||||
|
||||
case "audio":
|
||||
fmt.Fprintf(&sb, "<b>#%d %s</b>%s\n", i, tr(uid, "ffp_audio"), lang)
|
||||
fmt.Fprintf(&sb, " %s: %s", tr(uid, "ffp_codec"), s.CodecLongName)
|
||||
if s.Profile != "" {
|
||||
fmt.Fprintf(&sb, " (%s)", s.Profile)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
if s.SampleRate != "" {
|
||||
fmt.Fprintf(&sb, " %s: %s Hz\n", tr(uid, "ffp_samplerate"), s.SampleRate)
|
||||
}
|
||||
if s.Channels > 0 {
|
||||
ch := s.ChannelLayout
|
||||
if ch == "" {
|
||||
ch = fmt.Sprintf("%d ch", s.Channels)
|
||||
}
|
||||
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_channels"), ch)
|
||||
}
|
||||
if s.BitRate != "" {
|
||||
if br, err := strconv.ParseInt(s.BitRate, 10, 64); err == nil {
|
||||
fmt.Fprintf(&sb, " %s: %s/s\n", tr(uid, "ffp_bitrate"), humanize.IBytes(uint64(br)))
|
||||
}
|
||||
}
|
||||
if title != "" {
|
||||
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_title"), escapeHtml(title))
|
||||
}
|
||||
|
||||
case "subtitle":
|
||||
fmt.Fprintf(&sb, "<b>#%d %s</b>%s\n", i, tr(uid, "ffp_subtitle"), lang)
|
||||
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_codec"), s.CodecLongName)
|
||||
if title != "" {
|
||||
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_title"), escapeHtml(title))
|
||||
}
|
||||
|
||||
default:
|
||||
fmt.Fprintf(&sb, "<b>#%d %s</b>\n", i, s.CodecType)
|
||||
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_codec"), s.CodecLongName)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return strings.TrimSuffix(sb.String(), "\n\n")
|
||||
}
|
||||
|
||||
func getTag(tags ffp.Tags, key string) string {
|
||||
if tags == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := tags[key]; ok && v != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
for k, v := range tags {
|
||||
if strings.HasPrefix(k, key+"-") && v != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
tele "gopkg.in/telebot.v4"
|
||||
|
||||
"server/log"
|
||||
sets "server/settings"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
// Telegram limits the serialized reply_markup size; many file rows with long
|
||||
// labels/URLs would exceed it (e.g. "reply markup is too long").
|
||||
const filesPageSize = 5
|
||||
|
||||
// Inline button text is limited to 64 characters in the Bot API.
|
||||
func truncateBtnText(s string) string {
|
||||
const max = 64
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
if max <= 1 {
|
||||
return string(r[:max])
|
||||
}
|
||||
return string(r[:max-1]) + "…"
|
||||
}
|
||||
|
||||
func files(c tele.Context) error {
|
||||
args := c.Args()
|
||||
if len(args) < 2 {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
hash := args[1]
|
||||
if !isHash(hash) {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
msg, err := c.Bot().Send(c.Sender(), tr(c.Sender().ID, "connecting"))
|
||||
t := torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
if err == nil {
|
||||
_, _ = c.Bot().Edit(msg, tr(c.Sender().ID, "torrent_not_found")+":\n<code>"+hash+"</code>")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err == nil {
|
||||
api := c.Bot()
|
||||
recipient := c.Sender()
|
||||
uid := c.Sender().ID
|
||||
go sendFilesList(api, recipient, msg, hash, uid, 0)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// sendFilesList shows one page of per-file actions; fitems / fifresh change the page in-place.
|
||||
func sendFilesList(api tele.API, recipient tele.Recipient, statusMsg *tele.Message, hash string, uid int64, page int) {
|
||||
t := torr.GetTorrent(hash)
|
||||
for t != nil && !t.WaitInfo() {
|
||||
time.Sleep(time.Second)
|
||||
t = torr.GetTorrent(hash)
|
||||
}
|
||||
_ = api.Delete(statusMsg)
|
||||
t = torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
ti := t.Status()
|
||||
if ti == nil {
|
||||
return
|
||||
}
|
||||
|
||||
host := getHost()
|
||||
txt, kbd := buildFilesListView(t, host, uid, page)
|
||||
if kbd == nil {
|
||||
return
|
||||
}
|
||||
if _, err := api.Send(recipient, txt, kbd, tele.ModeHTML); err != nil {
|
||||
log.TLogln("tg files send err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func buildFilesListView(t *torr.Torrent, host string, uid int64, page int) (string, *tele.ReplyMarkup) {
|
||||
ti := t.Status()
|
||||
if ti == nil {
|
||||
return "", nil
|
||||
}
|
||||
hex := t.Hash().HexString()
|
||||
n := len(ti.FileStats)
|
||||
if n == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
totalPages := (n + filesPageSize - 1) / filesPageSize
|
||||
if page < 0 {
|
||||
page = 0
|
||||
}
|
||||
if page >= totalPages {
|
||||
page = totalPages - 1
|
||||
}
|
||||
start := page * filesPageSize
|
||||
end := start + filesPageSize
|
||||
if end > n {
|
||||
end = n
|
||||
}
|
||||
pageFiles := ti.FileStats[start:end]
|
||||
|
||||
viewedSet := make(map[int]struct{})
|
||||
for _, v := range sets.ListViewed(ti.Hash) {
|
||||
viewedSet[v.FileIndex] = struct{}{}
|
||||
}
|
||||
|
||||
txt := "📁 <b>" + escapeHtml(ti.Title) + "</b> " +
|
||||
"<i>" + humanize.IBytes(uint64(ti.TorrentSize)) + "</i>\n\n" +
|
||||
"<code>" + ti.Hash + "</code>"
|
||||
if totalPages > 1 {
|
||||
txt += "\n\n" + tr(uid, "page") + " " + strconv.Itoa(page+1) + "/" + strconv.Itoa(totalPages)
|
||||
}
|
||||
if n > 1 {
|
||||
txt += "\n\n" + fmt.Sprintf(tr(uid, "files_range_hint"), n)
|
||||
}
|
||||
|
||||
m := &tele.ReplyMarkup{}
|
||||
var rows []tele.Row
|
||||
|
||||
for _, f := range pageFiles {
|
||||
viewedMark := ""
|
||||
if _, ok := viewedSet[f.Id]; ok {
|
||||
viewedMark = "✓ "
|
||||
}
|
||||
baseName := filepath.Base(f.Path)
|
||||
mline := viewedMark + "#" + strconv.Itoa(f.Id) + ": " + humanize.IBytes(uint64(f.Length)) + " — " + baseName
|
||||
fileLabel := truncateBtnText(mline)
|
||||
idStr := strconv.Itoa(f.Id)
|
||||
streamURL := host + "/stream/" + filepath.Base(f.Path) + "?link=" + hex + "&index=" + idStr + "&play"
|
||||
rows = append(rows, m.Row(
|
||||
m.Data(fileLabel, "upload", ti.Hash, idStr),
|
||||
m.URL(tr(uid, "files_link"), streamURL),
|
||||
m.Data("⏳", "fpreload", ti.Hash, idStr),
|
||||
))
|
||||
}
|
||||
|
||||
if totalPages > 1 {
|
||||
var nav []tele.Btn
|
||||
if page > 0 {
|
||||
nav = append(nav, m.Data("◀️", "fitems", strconv.Itoa(page-1), ti.Hash))
|
||||
}
|
||||
nav = append(nav, m.Data(strconv.Itoa(page+1)+"/"+strconv.Itoa(totalPages), "fnop"))
|
||||
if page < totalPages-1 {
|
||||
nav = append(nav, m.Data("▶️", "fitems", strconv.Itoa(page+1), ti.Hash))
|
||||
}
|
||||
nav = append(nav, m.Data("🔄", "fifresh", strconv.Itoa(page), ti.Hash))
|
||||
rows = append(rows, m.Row(nav...))
|
||||
} else {
|
||||
rows = append(rows, m.Row(m.Data("🔄", "fifresh", strconv.Itoa(page), ti.Hash)))
|
||||
}
|
||||
if n > 1 {
|
||||
rows = append(rows, m.Row(m.Data(tr(uid, "files_download_all"), "fall", "all", ti.Hash)))
|
||||
}
|
||||
m.Inline(rows...)
|
||||
return txt, m
|
||||
}
|
||||
|
||||
func callbackFileListPage(c tele.Context, pageStr, hash string) error {
|
||||
page, err := strconv.Atoi(pageStr)
|
||||
if err != nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
if !isHash(hash) {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{})
|
||||
|
||||
return editFilesListMessage(c, hash, c.Sender().ID, page)
|
||||
}
|
||||
|
||||
func callbackFileListRefresh(c tele.Context, pageStr, hash string) error {
|
||||
if !isHash(hash) {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
page, err := strconv.Atoi(pageStr)
|
||||
if err != nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{Text: "🔄"})
|
||||
return editFilesListMessage(c, hash, c.Sender().ID, page)
|
||||
}
|
||||
|
||||
func editFilesListMessage(c tele.Context, hash string, uid int64, page int) error {
|
||||
t := torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
_ = c.Send(tr(uid, "torrent_not_found") + ":\n<code>" + hash + "</code>")
|
||||
return nil
|
||||
}
|
||||
for t != nil && !t.WaitInfo() {
|
||||
time.Sleep(time.Second)
|
||||
t = torr.GetTorrent(hash)
|
||||
}
|
||||
t = torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
_ = c.Send(tr(uid, "torrent_not_found") + ":\n<code>" + hash + "</code>")
|
||||
return nil
|
||||
}
|
||||
host := getHost()
|
||||
txt, kbd := buildFilesListView(t, host, uid, page)
|
||||
if kbd == nil {
|
||||
log.TLogln("tg files: empty kbd for hash", logSafeStr(hash, 20))
|
||||
return nil
|
||||
}
|
||||
if c.Callback() == nil || c.Callback().Message == nil {
|
||||
_, err := c.Bot().Send(c.Sender(), txt, kbd, tele.ModeHTML)
|
||||
return err
|
||||
}
|
||||
_, err := c.Bot().Edit(c.Callback().Message, txt, kbd, tele.ModeHTML)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "message is not modified") {
|
||||
return nil
|
||||
}
|
||||
log.TLogln("tg files edit err", err)
|
||||
_, _ = c.Bot().Send(c.Sender(), tr(uid, "error")+":\n"+escapeHtml(err.Error()), tele.ModeHTML)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/log"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
// resolveHash returns hash from: 1) full hash string, 2) numeric index from list, 3) reply-to message
|
||||
func resolveHash(c tele.Context, arg string) string {
|
||||
arg = strings.TrimSpace(arg)
|
||||
if arg == "" {
|
||||
return extractHashFromReply(c)
|
||||
}
|
||||
if isHash(arg) {
|
||||
return arg
|
||||
}
|
||||
if idx, err := strconv.Atoi(arg); err == nil && idx > 0 {
|
||||
torrents := torr.ListTorrent()
|
||||
if idx <= len(torrents) {
|
||||
return torrents[idx-1].Hash().HexString()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractHashFromReply(c tele.Context) string {
|
||||
if c.Message() == nil || c.Message().ReplyTo == nil {
|
||||
return ""
|
||||
}
|
||||
reply := c.Message().ReplyTo
|
||||
if reply.ReplyMarkup == nil || len(reply.ReplyMarkup.InlineKeyboard) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, row := range reply.ReplyMarkup.InlineKeyboard {
|
||||
for _, btn := range row {
|
||||
if btn.Data == "" {
|
||||
continue
|
||||
}
|
||||
if isHash(btn.Data) {
|
||||
return btn.Data
|
||||
}
|
||||
if idx := strings.Index(btn.Data, "all|"); idx >= 0 {
|
||||
h := btn.Data[idx+4:]
|
||||
if len(h) >= 40 && isHash(h[:40]) {
|
||||
return h[:40]
|
||||
}
|
||||
if isHash(h) {
|
||||
return h
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const hashPageSize = 10
|
||||
|
||||
func cmdHash(c tele.Context) error {
|
||||
args := c.Args()
|
||||
torrents := torr.ListTorrent()
|
||||
if len(torrents) == 0 {
|
||||
return c.Send(tr(c.Sender().ID, "no_torrents"))
|
||||
}
|
||||
|
||||
if len(args) > 0 {
|
||||
idx, err := strconv.Atoi(strings.TrimSpace(args[0]))
|
||||
if err != nil || idx < 1 || idx > len(torrents) {
|
||||
return c.Send(tr(c.Sender().ID, "invalid_index"))
|
||||
}
|
||||
hash := torrents[idx-1].Hash().HexString()
|
||||
return c.Send("🔑 <code>" + hash + "</code>")
|
||||
}
|
||||
|
||||
return sendHashPage(c, 0)
|
||||
}
|
||||
|
||||
func sendHashPage(c tele.Context, page int) error {
|
||||
torrents := torr.ListTorrent()
|
||||
if len(torrents) == 0 {
|
||||
return c.Send(tr(c.Sender().ID, "no_torrents"))
|
||||
}
|
||||
|
||||
totalPages := (len(torrents) + hashPageSize - 1) / hashPageSize
|
||||
if page < 0 {
|
||||
page = 0
|
||||
}
|
||||
if page >= totalPages {
|
||||
page = totalPages - 1
|
||||
}
|
||||
start := page * hashPageSize
|
||||
end := start + hashPageSize
|
||||
if end > len(torrents) {
|
||||
end = len(torrents)
|
||||
}
|
||||
pageTorrents := torrents[start:end]
|
||||
|
||||
uid := c.Sender().ID
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🔑 <b>" + tr(uid, "hash_title") + "</b> (" + strconv.Itoa(len(torrents)) + ")\n\n")
|
||||
for i, t := range pageTorrents {
|
||||
sb.WriteString(strconv.Itoa(start+i+1) + ". <code>" + t.Hash().HexString() + "</code>\n")
|
||||
sb.WriteString(" " + escapeHtml(t.Title) + "\n\n")
|
||||
}
|
||||
msg := strings.TrimSuffix(sb.String(), "\n\n")
|
||||
|
||||
navRow := []tele.InlineButton{}
|
||||
if totalPages > 1 {
|
||||
if page > 0 {
|
||||
navRow = append(navRow, tele.InlineButton{Text: "◀️", Unique: "fhash", Data: strconv.Itoa(page - 1)})
|
||||
}
|
||||
navRow = append(navRow, tele.InlineButton{Text: strconv.Itoa(page+1) + "/" + strconv.Itoa(totalPages), Unique: "fnop", Data: ""})
|
||||
if page < totalPages-1 {
|
||||
navRow = append(navRow, tele.InlineButton{Text: "▶️", Unique: "fhash", Data: strconv.Itoa(page + 1)})
|
||||
}
|
||||
}
|
||||
navRow = append(navRow, tele.InlineButton{Text: "🔄", Unique: "fhashrefresh", Data: strconv.Itoa(page)})
|
||||
|
||||
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{navRow}}
|
||||
if err := c.Send(msg, kbd); err != nil {
|
||||
log.TLogln("tg hash send err", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func callbackHashPage(c tele.Context, data string) error {
|
||||
page := 0
|
||||
if data != "" {
|
||||
if p, err := strconv.Atoi(data); err == nil {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{})
|
||||
if c.Callback().Message != nil {
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
}
|
||||
return sendHashPage(c, page)
|
||||
}
|
||||
|
||||
func callbackHashRefresh(c tele.Context, data string) error {
|
||||
page := 0
|
||||
if data != "" {
|
||||
if p, err := strconv.Atoi(data); err == nil {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{Text: "🔄"})
|
||||
if c.Callback().Message != nil {
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
}
|
||||
return sendHashPage(c, page)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
)
|
||||
|
||||
var magnetRegex = regexp.MustCompile(`magnet:\?[^\s]+`)
|
||||
var torrsRegex = regexp.MustCompile(`torrs://[^\s]+`)
|
||||
var hashRegex = regexp.MustCompile(`\b([a-fA-F0-9]{40})\b`)
|
||||
|
||||
func cmdImport(c tele.Context) error {
|
||||
text := ""
|
||||
if c.Message() != nil && c.Message().Text != "" {
|
||||
text = strings.TrimPrefix(strings.TrimSpace(c.Message().Text), "/import")
|
||||
text = strings.TrimSpace(text)
|
||||
}
|
||||
if text == "" {
|
||||
return c.Send(tr(c.Sender().ID, "import_usage"))
|
||||
}
|
||||
var links []string
|
||||
seen := make(map[string]bool)
|
||||
for _, m := range magnetRegex.FindAllString(text, -1) {
|
||||
m = strings.TrimSpace(m)
|
||||
if m != "" && !seen[m] {
|
||||
seen[m] = true
|
||||
links = append(links, m)
|
||||
}
|
||||
}
|
||||
for _, m := range torrsRegex.FindAllString(text, -1) {
|
||||
m = strings.TrimSpace(m)
|
||||
if m != "" && !seen[m] {
|
||||
seen[m] = true
|
||||
links = append(links, m)
|
||||
}
|
||||
}
|
||||
for _, m := range hashRegex.FindAllString(text, -1) {
|
||||
h := strings.ToLower(strings.TrimSpace(m))
|
||||
if h != "" && !seen[h] {
|
||||
seen[h] = true
|
||||
links = append(links, h)
|
||||
}
|
||||
}
|
||||
if len(links) == 0 {
|
||||
return c.Send(tr(c.Sender().ID, "import_no_links"))
|
||||
}
|
||||
uid := c.Sender().ID
|
||||
added := 0
|
||||
for _, link := range links {
|
||||
if err := addTorrent(c, link); err != nil {
|
||||
_ = c.Send(fmt.Sprintf(tr(uid, "add_error"), err.Error()))
|
||||
continue
|
||||
}
|
||||
added++
|
||||
}
|
||||
return c.Send(fmt.Sprintf(tr(uid, "import_done"), added, len(links)))
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/rutor"
|
||||
"server/rutor/models"
|
||||
sets "server/settings"
|
||||
"server/torr"
|
||||
"server/torznab"
|
||||
)
|
||||
|
||||
const inlineMaxResults = 20
|
||||
|
||||
func handleInlineQuery(c tele.Context) error {
|
||||
query := strings.TrimSpace(c.Query().Text)
|
||||
uid := int64(0)
|
||||
if c.Query().Sender != nil {
|
||||
uid = c.Query().Sender.ID
|
||||
}
|
||||
|
||||
var results tele.Results
|
||||
id := 0
|
||||
|
||||
if query == "" || strings.ToLower(query) == "list" || strings.ToLower(query) == "play" {
|
||||
torrents := torr.ListTorrent()
|
||||
host := getHost()
|
||||
for _, t := range torrents {
|
||||
if id >= inlineMaxResults {
|
||||
break
|
||||
}
|
||||
hash := t.Hash().HexString()
|
||||
url := fmt.Sprintf("%s/play/%s/1", host, hash)
|
||||
title := t.Title
|
||||
if len(title) > 60 {
|
||||
title = title[:57] + "..."
|
||||
}
|
||||
results = append(results, &tele.ArticleResult{
|
||||
ResultBase: tele.ResultBase{ID: strconv.Itoa(id)},
|
||||
Title: "▶ " + title,
|
||||
Description: hash[:8] + "...",
|
||||
URL: url,
|
||||
Text: url,
|
||||
})
|
||||
id++
|
||||
}
|
||||
}
|
||||
|
||||
if len(query) >= 2 && sets.BTsets != nil && (sets.BTsets.EnableRutorSearch || sets.BTsets.EnableTorznabSearch) {
|
||||
var list []*models.TorrentDetails
|
||||
if sets.BTsets.EnableRutorSearch {
|
||||
list = append(list, rutor.Search(query)...)
|
||||
}
|
||||
if sets.BTsets.EnableTorznabSearch {
|
||||
list = append(list, torznab.Search(query, -1)...)
|
||||
}
|
||||
for _, item := range list {
|
||||
if id >= inlineMaxResults {
|
||||
break
|
||||
}
|
||||
link := item.Magnet
|
||||
if link == "" {
|
||||
link = item.Link
|
||||
}
|
||||
if link == "" {
|
||||
continue
|
||||
}
|
||||
title := item.Title
|
||||
if len(title) > 60 {
|
||||
title = title[:57] + "..."
|
||||
}
|
||||
size := item.Size
|
||||
if size == "" {
|
||||
size = "?"
|
||||
}
|
||||
results = append(results, &tele.ArticleResult{
|
||||
ResultBase: tele.ResultBase{ID: strconv.Itoa(id)},
|
||||
Title: "➕ " + title,
|
||||
Description: fmt.Sprintf("%s S:%d P:%d", size, item.Seed, item.Peer),
|
||||
Text: link,
|
||||
})
|
||||
id++
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
results = append(results, &tele.ArticleResult{
|
||||
ResultBase: tele.ResultBase{ID: "0"},
|
||||
Title: tr(uid, "no_torrents"),
|
||||
Description: tr(uid, "add_magnet"),
|
||||
Text: "",
|
||||
})
|
||||
}
|
||||
|
||||
return c.Answer(&tele.QueryResponse{
|
||||
Results: results,
|
||||
CacheTime: 60,
|
||||
IsPersonal: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/settings"
|
||||
)
|
||||
|
||||
const (
|
||||
LangRU = "ru"
|
||||
LangEN = "en"
|
||||
saveUserLangsWait = 2 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
userLang = make(map[int64]string)
|
||||
userLangMu sync.RWMutex
|
||||
saveUserLangsMu sync.Mutex
|
||||
saveUserLangsTimer *time.Timer
|
||||
)
|
||||
|
||||
func getUserLang(userID int64) string {
|
||||
userLangMu.RLock()
|
||||
defer userLangMu.RUnlock()
|
||||
if lang, ok := userLang[userID]; ok {
|
||||
return lang
|
||||
}
|
||||
return LangRU
|
||||
}
|
||||
|
||||
func setUserLang(userID int64, lang string) {
|
||||
if lang != LangRU && lang != LangEN {
|
||||
return
|
||||
}
|
||||
userLangMu.Lock()
|
||||
userLang[userID] = lang
|
||||
userLangMu.Unlock()
|
||||
scheduleSaveUserLangs()
|
||||
}
|
||||
|
||||
func scheduleSaveUserLangs() {
|
||||
saveUserLangsMu.Lock()
|
||||
defer saveUserLangsMu.Unlock()
|
||||
if saveUserLangsTimer != nil {
|
||||
saveUserLangsTimer.Stop()
|
||||
}
|
||||
saveUserLangsTimer = time.AfterFunc(saveUserLangsWait, func() {
|
||||
saveUserLangsMu.Lock()
|
||||
saveUserLangsTimer = nil
|
||||
saveUserLangsMu.Unlock()
|
||||
saveUserLangs()
|
||||
})
|
||||
}
|
||||
|
||||
func loadUserLangs() {
|
||||
fn := filepath.Join(settings.Path, "tg_langs.json")
|
||||
buf, err := os.ReadFile(fn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal(buf, &m); err != nil {
|
||||
return
|
||||
}
|
||||
userLangMu.Lock()
|
||||
for k, v := range m {
|
||||
if v == LangRU || v == LangEN {
|
||||
if id, parseErr := strconv.ParseInt(k, 10, 64); parseErr == nil {
|
||||
userLang[id] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
userLangMu.Unlock()
|
||||
}
|
||||
|
||||
func saveUserLangs() {
|
||||
userLangMu.RLock()
|
||||
m := make(map[string]string)
|
||||
for k, v := range userLang {
|
||||
m[strconv.FormatInt(k, 10)] = v
|
||||
}
|
||||
userLangMu.RUnlock()
|
||||
buf, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fn := filepath.Join(settings.Path, "tg_langs.json")
|
||||
_ = os.WriteFile(fn, buf, 0o600)
|
||||
}
|
||||
|
||||
func cmdLang(c tele.Context) error {
|
||||
uid := c.Sender().ID
|
||||
args := c.Args()
|
||||
if len(args) == 0 {
|
||||
lang := getUserLang(uid)
|
||||
if lang == LangEN {
|
||||
return c.Send(tr(uid, "lang_current_en") + "\n/lang RU — " + tr(uid, "lang_switch_ru"))
|
||||
}
|
||||
return c.Send(tr(uid, "lang_current_ru") + "\n/lang EN — " + tr(uid, "lang_switch_en"))
|
||||
}
|
||||
lang := strings.ToUpper(strings.TrimSpace(args[0]))
|
||||
if lang == "EN" {
|
||||
setUserLang(uid, LangEN)
|
||||
return c.Send(tr(uid, "lang_set_en"))
|
||||
}
|
||||
if lang == "RU" || lang == "РУ" {
|
||||
setUserLang(uid, LangRU)
|
||||
return c.Send(tr(uid, "lang_set"))
|
||||
}
|
||||
return c.Send(tr(uid, "lang_usage"))
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
func callbackLink(c tele.Context, data string) error {
|
||||
uid := c.Sender().ID
|
||||
index := 1
|
||||
hash := data
|
||||
if idx := strings.Index(data, "|"); idx >= 0 && idx+1 < len(data) {
|
||||
if i, err := strconv.Atoi(data[idx+1:]); err == nil && i > 0 {
|
||||
index = i
|
||||
hash = data[:idx]
|
||||
}
|
||||
}
|
||||
t := torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "torrent_not_found")})
|
||||
}
|
||||
if !strings.Contains(data, "|") && t.WaitInfo() {
|
||||
st := t.Status()
|
||||
if st != nil && len(st.FileStats) > 1 {
|
||||
maxFiles := 5
|
||||
if len(st.FileStats) < maxFiles {
|
||||
maxFiles = len(st.FileStats)
|
||||
}
|
||||
var rows [][]tele.InlineButton
|
||||
for i := 0; i < maxFiles; i++ {
|
||||
f := st.FileStats[i]
|
||||
btn := tele.InlineButton{Text: fmt.Sprintf("#%d", f.Id), Unique: "flink", Data: hash + "|" + strconv.Itoa(f.Id)}
|
||||
rows = append(rows, []tele.InlineButton{btn})
|
||||
}
|
||||
kbd := &tele.ReplyMarkup{InlineKeyboard: rows}
|
||||
_ = c.Respond(&tele.CallbackResponse{})
|
||||
return c.Send("🔗 "+tr(uid, "btn_link")+":", kbd)
|
||||
}
|
||||
}
|
||||
host := getHost()
|
||||
url := fmt.Sprintf("%s/play/%s/%d", host, hash, index)
|
||||
_ = c.Respond(&tele.CallbackResponse{})
|
||||
return c.Send(fmt.Sprintf(tr(uid, "link_play"), url))
|
||||
}
|
||||
|
||||
func cmdLink(c tele.Context) error {
|
||||
args := c.Args()
|
||||
arg := ""
|
||||
if len(args) > 0 {
|
||||
arg = args[0]
|
||||
}
|
||||
hash := resolveHash(c, arg)
|
||||
if hash == "" {
|
||||
return c.Send(tr(c.Sender().ID, "link_usage"))
|
||||
}
|
||||
|
||||
index := 1
|
||||
if len(args) > 1 {
|
||||
if i, err := strconv.Atoi(args[1]); err == nil && i > 0 {
|
||||
index = i
|
||||
}
|
||||
}
|
||||
|
||||
t := torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
return c.Send(tr(c.Sender().ID, "torrent_not_found") + ":\n<code>" + hash + "</code>")
|
||||
}
|
||||
|
||||
host := getHost()
|
||||
url := fmt.Sprintf("%s/play/%s/%d", host, hash, index)
|
||||
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "link_play"), url))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user