From 1c1b8663b4f9cce86d665e1cd4d6e237f15a5f68 Mon Sep 17 00:00:00 2001 From: Chaoscat Date: Mon, 11 Aug 2025 06:30:49 +0000 Subject: [PATCH] Dateien nach "/" hochladen --- app.py | 658 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 658 insertions(+) create mode 100644 app.py diff --git a/app.py b/app.py new file mode 100644 index 0000000..716388c --- /dev/null +++ b/app.py @@ -0,0 +1,658 @@ +from flask import Flask, render_template, request, jsonify, redirect, url_for, session, flash +from flask_sqlalchemy import SQLAlchemy +from flask_login import LoginManager, login_user, logout_user, login_required, current_user, UserMixin +import requests +import os +import threading +import time +import subprocess +import random +import string +from datetime import datetime +import logging +from urllib.parse import urlsplit, urlunsplit +from bs4 import BeautifulSoup +from urllib.request import Request, urlopen +import json +import re +from base64 import b64decode +from seleniumbase import SB +import psutil +from zipfile import ZipFile +from io import BytesIO +from celery import Celery +from celery.result import AsyncResult +import redis + +app = Flask(__name__) +app.secret_key = os.environ.get('SECRET_KEY', 'supergeheim') + +# Database Configuration +app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:///aniworld_downloader.db') +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + +# Celery Configuration +app.config['CELERY'] = { + 'broker_url': os.environ.get('CELERY_BROKER_URL', 'redis://localhost:6379/0'), + 'result_backend': os.environ.get('CELERY_RESULT_BACKEND', 'redis://localhost:6379/0'), + 'task_ignore_result': False, + 'task_serializer': 'json', + 'result_serializer': 'json', + 'accept_content': ['json'], + 'timezone': 'UTC', + 'enable_utc': True, + 'task_soft_time_limit': 3600, # 1 hour + 'task_time_limit': 3900, # 1 hour 5 minutes + 'worker_prefetch_multiplier': 1, + 'task_acks_late': True, + 'worker_max_tasks_per_child': 50, +} + +# Jellyfin Configuration +JELLYFIN_URL = os.environ.get('JELLYFIN_URL', 'http://dein-jellyfin-server:8096') +JELLYFIN_API_KEY = os.environ.get('JELLYFIN_API_KEY') + +# VPN Configuration +VPN_CONFIG_PATH = os.environ.get('VPN_CONFIG_PATH', '/etc/openvpn/config.ovpn') +VPN_PID_FILE = "/tmp/aniworld_vpn.pid" + +db = SQLAlchemy(app) +login_manager = LoginManager(app) +login_manager.login_view = 'login' + +# Logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +# Celery Setup +def make_celery(app): + celery = Celery( + app.import_name, + backend=app.config['CELERY']['result_backend'], + broker=app.config['CELERY']['broker_url'] + ) + celery.conf.update(app.config['CELERY']) + + class ContextTask(celery.Task): + def __call__(self, *args, **kwargs): + with app.app_context(): + return self.run(*args, **kwargs) + + celery.Task = ContextTask + return celery + + +celery = make_celery(app) + +# Provider and language configurations +provider_priority = ["Vidmoly", "VOE", "SpeedFiles", "Vidoza", "Doodstream", "Streamtape"] +languages = ["German", "Ger-Sub", "Eng-Sub", "English"] + +# Compilation patterns for content extraction +VOE_PATTERNS = [re.compile(r"'hls': '(?P.+)'"), + re.compile(r'prompt\("Node",\s*"(?P[^"]+)"'), + re.compile(r"window\.location\.href = '(?P[^']+)'")] +STREAMTAPE_PATTERN = re.compile(r'get_video\?id=[^&\'\s]+&expires=[^&\'\s]+&ip=[^&\'\s]+&token=[^&\'\s]+\'') +DOODSTREAM_PATTERN = re.compile(r"/pass_md5/[\w-]+/(?P[\w-]+)") +VIDMOLY_PATTERN = re.compile(r"sources: \[{file:\"(?P.*?)\"}]") +SPEEDFILES_PATTERN = re.compile(r"var _0x5opu234 = \"(?P.*?)\";") + + +# Database Models +class User(UserMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + jellyfin_username = db.Column(db.String(80), unique=True, nullable=False) + jellyfin_user_id = db.Column(db.String(100), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + downloads = db.relationship('Download', backref='user', lazy=True) + + +class Download(db.Model): + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(200), nullable=False) + season = db.Column(db.Integer) + episode = db.Column(db.Integer) + provider = db.Column(db.String(50), nullable=False) + language = db.Column(db.String(20), nullable=False) + status = db.Column(db.String(20), default='queued') # queued, downloading, completed, failed, cancelled + progress = db.Column(db.Float, default=0.0) + file_path = db.Column(db.String(500)) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + download_url = db.Column(db.String(1000)) + redirect_link = db.Column(db.String(1000)) + celery_task_id = db.Column(db.String(100)) # Celery Task ID + error_message = db.Column(db.Text) + + +class ProviderError(Exception): + def __init__(self, *args: object) -> None: + super().__init__(*args) + + +class LanguageError(Exception): + def __init__(self, *args: object) -> None: + super().__init__(*args) + + +@login_manager.user_loader +def load_user(user_id): + return User.query.get(int(user_id)) + + +# Jellyfin Authentication Functions +def authenticate_jellyfin(username, password): + """Authenticate user with Jellyfin server""" + try: + auth_url = f"{JELLYFIN_URL}/Users/authenticatebyname" + headers = { + 'Content-Type': 'application/json', + 'X-Emby-Authorization': f'MediaBrowser Client="AniWorld Downloader", Device="WebApp", DeviceId="webapp-{random.randint(1000, 9999)}", Version="1.0.0"' + } + + payload = { + "Username": username, + "Pw": password + } + + response = requests.post(auth_url, json=payload, headers=headers) + + if response.status_code == 200: + data = response.json() + return { + 'user_id': data['User']['Id'], + 'username': data['User']['Name'], + 'access_token': data['AccessToken'] + } + else: + logger.error(f"Jellyfin authentication failed: {response.status_code}") + return None + + except Exception as e: + logger.error(f"Error during Jellyfin authentication: {e}") + return None + + +def verify_jellyfin_token(access_token): + """Verify if Jellyfin token is still valid""" + try: + headers = { + 'X-Emby-Token': access_token + } + response = requests.get(f"{JELLYFIN_URL}/System/Info", headers=headers) + return response.status_code == 200 + except Exception as e: + logger.error(f"Error verifying Jellyfin token: {e}") + return False + + +# VPN Functions (Verbessert) +def get_vpn_process(): + """Find the specific VPN process started by this script""" + try: + if os.path.exists(VPN_PID_FILE): + with open(VPN_PID_FILE, 'r') as f: + pid = int(f.read().strip()) + + if psutil.pid_exists(pid): + proc = psutil.Process(pid) + if 'openvpn' in proc.name().lower(): + return proc + + # Fallback: Look for openvpn process with our config file + for proc in psutil.process_iter(['pid', 'name', 'cmdline']): + try: + if ('openvpn' in proc.info['name'].lower() and + any(VPN_CONFIG_PATH in cmd for cmd in proc.info['cmdline'])): + return proc + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + + except Exception as e: + logger.error(f"Error finding VPN process: {e}") + + return None + + +def disconnect_vpn(): + """Disconnect only our specific VPN connection""" + try: + vpn_proc = get_vpn_process() + if vpn_proc: + logger.info(f"Terminating VPN process with PID: {vpn_proc.pid}") + vpn_proc.terminate() + + try: + vpn_proc.wait(timeout=5) + except psutil.TimeoutExpired: + logger.warning("VPN process didn't terminate gracefully, forcing kill") + vpn_proc.kill() + + if os.path.exists(VPN_PID_FILE): + os.remove(VPN_PID_FILE) + + logger.info("VPN disconnected successfully") + return True + else: + logger.info("No VPN process found to disconnect") + return True + + except Exception as e: + logger.error(f"Error disconnecting VPN: {e}") + return False + + +def connect_vpn(): + """Connect to VPN and save PID""" + try: + proc = subprocess.Popen([ + 'sudo', 'openvpn', + '--config', VPN_CONFIG_PATH, + '--daemon', + '--writepid', VPN_PID_FILE + ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + time.sleep(2) + + if os.path.exists(VPN_PID_FILE): + with open(VPN_PID_FILE, 'r') as f: + pid = int(f.read().strip()) + + if psutil.pid_exists(pid): + logger.info(f"VPN connected successfully with PID: {pid}") + return True + + logger.error("VPN connection failed - PID file not created") + return False + + except Exception as e: + logger.error(f"Error connecting VPN: {e}") + return False + + +def reconnect_vpn(): + """Reconnect to VPN without affecting other VPN connections""" + try: + logger.info("Starting VPN reconnection...") + + if not disconnect_vpn(): + logger.error("Failed to disconnect VPN") + return False + + time.sleep(3) + + if connect_vpn(): + time.sleep(5) + logger.info("VPN reconnected successfully") + return True + else: + logger.error("Failed to reconnect VPN") + return False + + except Exception as e: + logger.error(f"Error reconnecting VPN: {e}") + return False + + +# Content extraction functions (keeping existing ones) +def get_titles_dict(url): + """Get titles dictionary from streaming sites""" + try: + html_response = urlopen(url) + soup = BeautifulSoup(html_response, "html.parser") + matching_li_elements = str(soup.find_all("li")).split(",
  • 0: + progress = min(95.0, (elapsed / 300) * 100) # Estimate 5 minutes + download.progress = progress + db.session.commit() + + # Update Celery task progress + self.update_state( + state='PROGRESS', + meta={'current': progress, 'total': 100, 'status': f'Downloading {download.title}...'} + ) + + time.sleep(3) + + # Check if download completed successfully + if process.returncode == 0 and os.path.exists(file_path): + download.status = 'completed' + download.progress = 100.0 + download.file_path = file_path + db.session.commit() + + logger.info(f"Download completed: {file_path}") + + # Reconnect VPN after download + reconnect_vpn() + + return {'status': 'completed', 'file_path': file_path} + else: + error_msg = f"Download failed: {file_path}" + download.status = 'failed' + download.error_message = error_msg + db.session.commit() + + logger.error(error_msg) + + # Clean up partial file + if os.path.exists(file_path): + os.remove(file_path) + + return {'status': 'failed', 'error': error_msg} + + except Exception as e: + error_msg = f"Error downloading episode {download_id}: {str(e)}" + logger.error(error_msg) + + download = Download.query.get(download_id) + if download: + download.status = 'failed' + download.error_message = error_msg + db.session.commit() + + return {'status': 'failed', 'error': error_msg} + + +# [Alle anderen Hilfsfunktionen bleiben gleich...] +# (find_content_url, get_season_count, get_episodes, etc.) + +# Routes +@app.route('/') +@login_required +def index(): + return render_template('index.html') + + +@app.route('/login', methods=['GET', 'POST']) +def login(): + if request.method == 'POST': + if request.is_json: + data = request.get_json() + username = data.get('username') + password = data.get('password') + else: + username = request.form['username'] + password = request.form['password'] + + jellyfin_user = authenticate_jellyfin(username, password) + if jellyfin_user: + user = User.query.filter_by(jellyfin_username=username).first() + if not user: + user = User( + jellyfin_username=username, + jellyfin_user_id=jellyfin_user['user_id'] + ) + db.session.add(user) + db.session.commit() + login_user(user) + if request.is_json: + return jsonify({'success': True}) + return redirect(url_for('index')) + else: + if request.is_json: + return jsonify({'success': False, 'error': 'Login fehlgeschlagen'}), 401 + flash('Ungültige Jellyfin-Anmeldedaten') + + return render_template('login.html') + + +@app.route('/logout') +@login_required +def logout(): + logout_user() + return redirect(url_for('login')) + + +@app.route('/api/download/start', methods=['POST']) +@login_required +def start_download(): + data = request.json + link = data.get('link') + provider = data.get('provider', 'Vidmoly') + language = data.get('language', 'German') + content_type = data.get('content_type', 'episodes') + + if not link: + return jsonify({'error': 'No link provided'}), 400 + + normalized_link = validate_link(link) + if not normalized_link: + return jsonify({'error': 'Invalid link format'}), 400 + + title = extract_title(normalized_link) + site = normalized_link[:normalized_link.index(".to/") + 3] + + download_ids = [] + task_ids = [] + + if content_type == 'episodes': + season_start = data.get('season_start', 1) + season_end = data.get('season_end', 1) + episode_start = data.get('episode_start', 1) + episode_end = data.get('episode_end', 1) + + for season in range(season_start, season_end + 1): + ep_start = episode_start if season == season_start else 1 + ep_end = episode_end if season == season_end else get_episodes(normalized_link, season) + + for episode in range(ep_start, ep_end + 1): + if "bs.to" in normalized_link: + episode_link = normalized_link + "{}/{}-".format(season, episode) + else: + episode_link = normalized_link + "staffel-{}/episode-{}".format(season, episode) + + try: + redirect_link = get_redirect_link_by_provider( + site, episode_link, language, provider, season, episode, current_user.id + ) + + if redirect_link: + download = Download( + title=title, + season=season, + episode=episode, + provider=provider, + language=language, + redirect_link=redirect_link, + user_id=current_user.id + ) + db.session.add(download) + db.session.commit() + download_ids.append(download.id) + + # Start Celery task + task = download_episode_task.delay(download.id) + task_ids.append(task.id) + + # Update download with task ID + download.celery_task_id = task.id + db.session.commit() + + except Exception as e: + logger.error(f"Error adding download for S{season}E{episode}: {e}") + + return jsonify({ + 'success': True, + 'downloads_added': len(download_ids), + 'download_ids': download_ids, + 'task_ids': task_ids + }) + + +@app.route('/api/download//status') +@login_required +def download_status(download_id): + download = Download.query.get_or_404(download_id) + if download.user_id != current_user.id: + return jsonify({'error': 'Unauthorized'}), 403 + + result_data = { + 'id': download.id, + 'status': download.status, + 'progress': download.progress, + 'error_message': download.error_message + } + + # Get Celery task status if available + if download.celery_task_id: + try: + task_result = AsyncResult(download.celery_task_id, app=celery) + if task_result.state == 'PROGRESS': + result_data['celery_progress'] = task_result.info + elif task_result.state == 'SUCCESS': + result_data['celery_result'] = task_result.result + elif task_result.state == 'FAILURE': + result_data['celery_error'] = str(task_result.info) + except Exception as e: + logger.error(f"Error getting Celery task status: {e}") + + return jsonify(result_data) + + +@app.route('/api/download//cancel', methods=['POST']) +@login_required +def cancel_download(download_id): + download = Download.query.get_or_404(download_id) + if download.user_id != current_user.id: + return jsonify({'error': 'Unauthorized'}), 403 + + if download.status in ['queued', 'downloading']: + download.status = 'cancelled' + db.session.commit() + + # Cancel Celery task if exists + if download.celery_task_id: + try: + celery.control.revoke(download.celery_task_id, terminate=True) + logger.info(f"Cancelled Celery task {download.celery_task_id}") + except Exception as e: + logger.error(f"Error cancelling Celery task: {e}") + + return jsonify({ + 'success': True, + 'status': download.status + }) + + +@app.route('/queue') +@login_required +def queue(): + downloads = Download.query.filter_by(user_id=current_user.id).order_by(Download.created_at.desc()).all() + return render_template('queue.html', downloads=downloads) + + +@app.route('/api/downloads') +@login_required +def api_downloads(): + downloads = Download.query.filter_by(user_id=current_user.id).all() + return jsonify([{ + 'id': d.id, + 'title': d.title, + 'season': d.season, + 'episode': d.episode, + 'status': d.status, + 'progress': d.progress, + 'provider': d.provider, + 'language': d.language, + 'created_at': d.created_at.isoformat(), + 'error_message': d.error_message + } for d in downloads]) + + +# [Alle anderen Routes bleiben gleich...] + +if __name__ == '__main__': + with app.app_context(): + db.create_all() + app.run(debug=False, host='0.0.0.0', port=5000) # debug=False für Production! \ No newline at end of file