From 082506d00c242ca0c36a6503722c9fefabff2697 Mon Sep 17 00:00:00 2001 From: zogzog Date: Mon, 23 Mar 2026 22:24:24 +0100 Subject: [PATCH] initial commit --- CHANGELOG.md | 57 ++ Dockerfile | 12 + app.py | 688 +++++++++++++++++ data/connections.json | 43 ++ data/history.json | 178 +++++ docker-compose.yml | 22 + plugins/__init__.py | 59 ++ plugins/base.py | 140 ++++ plugins/local.py | 111 +++ plugins/sftp.py | 202 +++++ requirements.txt | 4 + templates/index.html | 1671 +++++++++++++++++++++++++++++++++++++++++ templates/login.html | 175 +++++ 13 files changed, 3362 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 app.py create mode 100644 data/connections.json create mode 100644 data/history.json create mode 100644 docker-compose.yml create mode 100644 plugins/__init__.py create mode 100644 plugins/base.py create mode 100644 plugins/local.py create mode 100644 plugins/sftp.py create mode 100644 requirements.txt create mode 100644 templates/index.html create mode 100644 templates/login.html diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..321b6ac --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +## [1.3.0] - Sécurité +- Ajout d'une page de login (identifiant + mot de passe) +- Session Flask sécurisée (HttpOnly, SameSite Lax, durée configurable) +- Protection timing attack via `hmac.compare_digest` +- Toutes les routes API protégées par `@login_required` +- Bouton Déconnexion dans le header +- Credentials configurables dans le `docker-compose.yml` (`APP_USERNAME`, `APP_PASSWORD`, `SECRET_KEY`) + +## [1.2.0] - Pause et Stop +- Bouton ⏸ Pause — suspend le transfert entre deux chunks +- Bouton ⏹ Stop — annule le transfert et supprime le fichier partiel +- Les boutons sont grisés quand aucun transfert n'est actif +- État pause/stop correctement restauré à la reconnexion + +## [1.1.0] - Cache et performance +- Cache serveur avec TTL configurable (`CACHE_TTL`, défaut 60s) +- Prefetch automatique des 5 premiers sous-dossiers en arrière-plan +- Invalidation du cache après mkdir, rename et fin de transfert +- Bouton ↻ Actualiser force un rechargement en bypass du cache + +## [1.0.2] - Corrections +- Fix : double ajout en queue lors d'un transfert (push local supprimé, sync via serveur uniquement) +- Fix : `transfer_stop` / `transfer_pause` non déclarés en `global` dans `queue_worker`, coupant tous les transferts suivants après un premier Stop +- Fix : IndentationError sur `transfer_thread = None` introduite par un patch sed + +## [1.0.1] - Reconnexion et progression +- Synchronisation de la file d'attente au rechargement de la page via `/api/queue` +- Le transfert en cours et son pourcentage sont restaurés à la reconnexion +- `current_percent` suivi côté serveur pour être exposé à la reconnexion + +## [1.0.0] - Version initiale +- Interface dual-pane Seedbox / NAS +- File d'attente séquentielle avec progression WebSocket en temps réel +- Navigation par double-clic avec breadcrumb +- Sélection multiple côté seedbox +- Création de dossiers sur les deux panneaux +- Renommage côté NAS uniquement +- Seedbox montée en lecture seule (`:ro`) +- Serveur Flask + Eventlet pour les requêtes concurrentes pendant les transferts +- Compatible smartphone + +## [2.0.0] - Architecture plugin +- Refonte complète en architecture plugin pour les systèmes de fichiers +- Interface `AbstractFS` dans `plugins/base.py` — tout plugin hérite de cette classe +- Plugin `LocalFS` (`plugins/local.py`) — remplace le code direct `os.*` précédent +- Plugin `SFTPfs` (`plugins/sftp.py`) — accès SFTP via Paramiko avec prefetch +- Registre auto-découverte (`plugins/__init__.py`) — tout fichier dans `plugins/` est chargé automatiquement +- Ajouter un nouveau protocole = créer un seul fichier `plugins/monprotocole.py` +- Gestion des connexions dans l'interface : ajout, suppression, test de connexion +- Chaque panneau peut pointer vers n'importe quelle connexion configurée +- Les connexions sont persistées dans `data/connections.json` (volume Docker) +- Connexions par défaut (Seedbox/NAS locaux) créées automatiquement depuis les variables d'environnement +- Moteur de copie universel : fonctionne entre n'importe quels deux FS (Local→SFTP, SFTP→Local, Local→Local…) +- Ajout de `paramiko` dans les dépendances +- Nouveau volume `./data:/app/data` pour la persistance des connexions diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..46d8dec --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 5000 + +CMD ["python", "app.py"] \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..44787b2 --- /dev/null +++ b/app.py @@ -0,0 +1,688 @@ +import eventlet +eventlet.monkey_patch() + +import os +import ctypes +import json +import uuid +import threading +import time +import hmac +from functools import wraps +from flask import Flask, render_template, request, jsonify, session, redirect, url_for +from flask_socketio import SocketIO + +import plugins as plugin_registry + +app = Flask(__name__) +app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'seedmover-secret') +app.config['SESSION_COOKIE_HTTPONLY'] = True +app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' +app.config['PERMANENT_SESSION_LIFETIME'] = int(os.environ.get('SESSION_LIFETIME', 86400)) +socketio = SocketIO(app, cors_allowed_origins="*", async_mode='eventlet') + +SEEDBOX_PATH = os.environ.get('SEEDBOX_PATH', '/mnt/seedbox') +NAS_PATH = os.environ.get('NAS_PATH', '/mnt/nas') +APP_TITLE = os.environ.get('APP_TITLE', 'SeedMover') +APP_USERNAME = os.environ.get('APP_USERNAME', 'admin') +APP_PASSWORD = os.environ.get('APP_PASSWORD', 'changeme') +CACHE_TTL = int(os.environ.get('CACHE_TTL', 60)) +DATA_DIR = os.environ.get('DATA_DIR', '/app/data') +CONNECTIONS_FILE = os.path.join(DATA_DIR, 'connections.json') + +os.makedirs(DATA_DIR, exist_ok=True) + +HISTORY_FILE = os.path.join(DATA_DIR, 'history.json') +HISTORY_MAX = int(os.environ.get('HISTORY_MAX', 50)) +history_lock = threading.Lock() + +def _load_history(): + try: + with open(HISTORY_FILE) as f: + return json.load(f) + except Exception: + return [] + +def _save_history(entries): + with open(HISTORY_FILE, 'w') as f: + json.dump(entries[:HISTORY_MAX], f, indent=2) + +def _add_history(entry): + with history_lock: + entries = _load_history() + entries.insert(0, entry) + _save_history(entries) + +# ─── Connexions persistées ──────────────────────────────────────────────────── +connections_lock = threading.Lock() + +def _load_connections(): + try: + with open(CONNECTIONS_FILE) as f: + return json.load(f) + except Exception: + return {} + +def _save_connections(conns): + with open(CONNECTIONS_FILE, 'w') as f: + json.dump(conns, f, indent=2) + +def _ensure_default_connections(): + conns = _load_connections() + changed = False + if 'seedbox' not in conns: + conns['seedbox'] = { + 'id': 'seedbox', 'name': 'Seedbox', 'type': 'local', + 'readonly': True, 'config': {'root_path': SEEDBOX_PATH} + } + changed = True + if 'nas' not in conns: + conns['nas'] = { + 'id': 'nas', 'name': 'NAS', 'type': 'local', + 'readonly': False, 'config': {'root_path': NAS_PATH} + } + changed = True + if changed: + _save_connections(conns) + +_ensure_default_connections() + +# ─── Pool de connexions FS ──────────────────────────────────────────────────── +fs_pool = {} # connection_id -> instance FS +fs_pool_lock = threading.Lock() +fs_conn_locks = {} # connection_id -> RLock (sérialise les accès SFTP) + + +def _get_conn_lock(connection_id): + """Retourne un verrou dédié à cette connexion (crée si absent).""" + with fs_pool_lock: + if connection_id not in fs_conn_locks: + fs_conn_locks[connection_id] = threading.RLock() + return fs_conn_locks[connection_id] + + +def get_fs(connection_id): + conns = _load_connections() + conn_def = conns.get(connection_id) + if not conn_def: + raise ValueError(f"Connexion inconnue : {connection_id}") + with fs_pool_lock: + fs = fs_pool.get(connection_id) + if fs and fs.is_connected(): + return fs, conn_def + cls = plugin_registry.get_plugin(conn_def['type']) + if not cls: + raise ValueError(f"Plugin inconnu : {conn_def['type']}") + fs = cls() + fs.connect(conn_def['config']) + fs_pool[connection_id] = fs + return fs, conn_def + +def invalidate_fs(connection_id): + with fs_pool_lock: + fs_pool.pop(connection_id, None) + +# ─── Cache ──────────────────────────────────────────────────────────────────── +dir_cache = {} +dir_cache_lock = threading.Lock() + +def cache_get(key): + with dir_cache_lock: + entry = dir_cache.get(key) + if entry and (time.time() - entry['ts']) < CACHE_TTL: + return entry['data'] + return None + +def cache_set(key, data): + with dir_cache_lock: + dir_cache[key] = {'data': data, 'ts': time.time()} + +def cache_invalidate(connection_id, path): + import posixpath, os.path as osp + try: + parent = posixpath.dirname(path) or osp.dirname(path) + except Exception: + parent = path + with dir_cache_lock: + for k in list(dir_cache.keys()): + if k in (f"{connection_id}:{path}", f"{connection_id}:{parent}"): + dir_cache.pop(k, None) + +# ─── File de transfert ─────────────────────────────────────────────────────── +transfer_queue = [] +transfer_lock = threading.Lock() +transfer_thread = None +current_transfer = None +current_percent = 0 +transfer_stop = False +transfer_pause = False + + +class TransferStopped(Exception): + pass + + +def _trim_memory(): + """Force glibc à rendre la mémoire inutilisée au système après un transfert.""" + import gc + gc.collect() + try: + ctypes.cdll.LoadLibrary("libc.so.6").malloc_trim(0) + except Exception: + pass # Non-Linux, on ignore + + +def format_size(size): + for unit in ['B', 'KB', 'MB', 'GB', 'TB']: + if size < 1024: + return f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} PB" + + +def copy_between_fs(src_fs, src_path, dst_fs, dst_path, transfer_id): + global current_percent, transfer_stop, transfer_pause + total = src_fs.get_total_size(src_path) + copied = [0] + + def open_dst(d_path): + """Ouvre le fichier destination en écriture, retourne un handle.""" + parent = dst_fs.dirname(d_path) + if parent: + dst_fs.mkdir(parent) + try: + # SFTP — garder le handle ouvert tout le transfert + return dst_fs._sftp.open(d_path, 'wb') + except AttributeError: + # Local + return open(d_path, 'wb') + + def stream_file(s_path, d_path): + """Copie chunk par chunk sans jamais accumuler en mémoire.""" + handle = open_dst(d_path) + try: + for chunk in src_fs.read_chunks(s_path, chunk_size=1024*1024): + while transfer_pause and not transfer_stop: + eventlet.sleep(0.2) + if transfer_stop: + raise TransferStopped() + handle.write(chunk) # écriture directe, pas de buffer + copied[0] += len(chunk) + pct = int(copied[0] / total * 100) if total > 0 else 100 + current_percent = pct + eventlet.sleep(0) # céder la main à eventlet + socketio.emit('transfer_progress', { + 'id': transfer_id, 'percent': pct, + 'copied': copied[0], 'total': total, + 'copied_fmt': format_size(copied[0]), + 'total_fmt': format_size(total) + }) + finally: + handle.close() + + try: + if src_fs.isdir(src_path): + name = src_fs.basename(src_path) + dst_base = dst_fs.join(dst_path, name) + dst_fs.mkdir(dst_base) + for root, dirs, files in src_fs.walk(src_path): + rel = src_fs.relpath(root, src_path) + dst_root = dst_fs.join(dst_base, rel) if rel and rel != '.' else dst_base + dst_fs.mkdir(dst_root) + for fname in files: + stream_file(src_fs.join(root, fname), dst_fs.join(dst_root, fname)) + else: + stream_file(src_path, dst_fs.join(dst_path, src_fs.basename(src_path))) + + socketio.emit('transfer_done', {'id': transfer_id, 'success': True}) + + except TransferStopped: + try: + p = dst_fs.join(dst_path, src_fs.basename(src_path)) + if dst_fs.exists(p): + dst_fs.remove(p) + except Exception: + pass + socketio.emit('transfer_done', { + 'id': transfer_id, 'success': False, + 'error': "Arrêté par l'utilisateur" + }) + except Exception as e: + socketio.emit('transfer_done', {'id': transfer_id, 'success': False, 'error': str(e)}) + + +def queue_worker(): + global transfer_queue, current_transfer, transfer_thread + global transfer_stop, transfer_pause, current_percent + while True: + with transfer_lock: + if not transfer_queue: + current_transfer = None + current_percent = 0 + transfer_thread = None + return + transfer = transfer_queue.pop(0) + current_transfer = transfer + transfer_stop = False + transfer_pause = False + socketio.emit('transfer_started', {'id': transfer['id'], 'name': transfer['name']}) + try: + src_fs, _ = get_fs(transfer['src_connection']) + dst_fs, _ = get_fs(transfer['dst_connection']) + src_lock = _get_conn_lock(transfer['src_connection']) + dst_lock = _get_conn_lock(transfer['dst_connection']) + # Acquérir les deux verrous dans un ordre fixe pour éviter le deadlock + locks = sorted([ + (transfer['src_connection'], src_lock), + (transfer['dst_connection'], dst_lock) + ], key=lambda x: x[0]) + t_start = time.time() + with locks[0][1]: + with locks[1][1]: + copy_between_fs(src_fs, transfer['src'], dst_fs, transfer['dst'], transfer['id']) + t_end = time.time() + cache_invalidate(transfer['dst_connection'], transfer['dst']) + # Historique — on récupère la taille réelle si possible + try: + fsize = src_fs.get_total_size(transfer['src']) + except Exception: + fsize = 0 + _add_history({ + 'name': transfer['name'], + 'src': transfer['src'], + 'dst': transfer['dst'], + 'src_connection': transfer['src_connection'], + 'dst_connection': transfer['dst_connection'], + 'size': fsize, + 'duration': round(t_end - t_start, 1), + 'date': time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(t_end)), + 'success': True + }) + except Exception as e: + _add_history({ + 'name': transfer.get('name', ''), + 'src': transfer.get('src', ''), + 'dst': transfer.get('dst', ''), + 'src_connection': transfer.get('src_connection', ''), + 'dst_connection': transfer.get('dst_connection', ''), + 'size': 0, + 'duration': 0, + 'date': time.strftime('%Y-%m-%d %H:%M:%S'), + 'success': False, + 'error': str(e) + }) + socketio.emit('transfer_done', {'id': transfer['id'], 'success': False, 'error': str(e)}) + with transfer_lock: + current_transfer = None + current_percent = 0 + _trim_memory() + time.sleep(0.1) + + +# ─── Auth ───────────────────────────────────────────────────────────────────── +def check_password(password): + return hmac.compare_digest(password, APP_PASSWORD) + +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if not session.get('logged_in'): + if request.is_json or request.path.startswith('/api/'): + return jsonify({'error': 'Non authentifié'}), 401 + return redirect(url_for('login', next=request.path)) + return f(*args, **kwargs) + return decorated + +# ─── Routes Auth ────────────────────────────────────────────────────────────── +@app.route('/login', methods=['GET', 'POST']) +def login(): + error = None + if request.method == 'POST': + username = request.form.get('username', '').strip() + password = request.form.get('password', '') + if username == APP_USERNAME and check_password(password): + session.permanent = True + session['logged_in'] = True + next_url = request.args.get('next', '/') + if not next_url.startswith('/'): + next_url = '/' + return redirect(next_url) + error = 'Identifiants incorrects' + return render_template('login.html', title=APP_TITLE, error=error) + +@app.route('/logout') +def logout(): + session.clear() + return redirect(url_for('login')) + +@app.route('/') +@login_required +def index(): + return render_template('index.html', + title=APP_TITLE, + plugins=plugin_registry.list_plugins()) + +# ─── API Connexions ─────────────────────────────────────────────────────────── +@app.route('/api/connections') +@login_required +def list_connections(): + conns = _load_connections() + safe = [] + for c in conns.values(): + if not isinstance(c, dict) or 'type' not in c: + continue # ignorer les clés internes comme __defaults__ + sc = {k: v for k, v in c.items() if k != 'config'} + cfg_safe = {} + for k, v in c.get('config', {}).items(): + cfg_safe[k] = '***' if any(x in k.lower() for x in ['password', 'key']) else v + sc['config'] = cfg_safe + cls = plugin_registry.get_plugin(c['type']) + sc['type_label'] = cls.PLUGIN_LABEL if cls else c['type'] + safe.append(sc) + return jsonify(safe) + +@app.route('/api/connections', methods=['POST']) +@login_required +def add_connection(): + data = request.json + conn_type = data.get('type') + name = data.get('name', '').strip() + config = data.get('config', {}) + if not conn_type or not name: + return jsonify({'error': 'type et name requis'}), 400 + cls = plugin_registry.get_plugin(conn_type) + if not cls: + return jsonify({'error': f'Plugin inconnu : {conn_type}'}), 400 + try: + fs = cls() + fs.connect(config) + fs.list(config.get('root_path', '/')) + fs.disconnect() + except Exception as e: + return jsonify({'error': f'Connexion échouée : {e}'}), 400 + conn_id = str(uuid.uuid4())[:8] + conn = {'id': conn_id, 'name': name, 'type': conn_type, + 'readonly': data.get('readonly', False), 'config': config} + with connections_lock: + conns = _load_connections() + conns[conn_id] = conn + _save_connections(conns) + return jsonify({'success': True, 'id': conn_id}) + +@app.route('/api/connections/', methods=['PUT']) +@login_required +def update_connection(conn_id): + data = request.json + name = data.get('name', '').strip() + config = data.get('config', {}) + if not name: + return jsonify({'error': 'name requis'}), 400 + with connections_lock: + conns = _load_connections() + if conn_id not in conns: + return jsonify({'error': 'Connexion introuvable'}), 404 + conn = conns[conn_id] + conn_type = conn['type'] + # Fusionner la config : garder les valeurs masquées (***) de l'ancienne config + merged_config = dict(conn.get('config', {})) + for k, v in config.items(): + if v != '***': + merged_config[k] = v + # Tester la connexion avec la nouvelle config + cls = plugin_registry.get_plugin(conn_type) + try: + fs = cls() + fs.connect(merged_config) + fs.list(merged_config.get('root_path', '/')) + fs.disconnect() + except Exception as e: + return jsonify({'error': f'Connexion échouée : {e}'}), 400 + conn['name'] = name + conn['config'] = merged_config + conn['readonly'] = data.get('readonly', conn.get('readonly', False)) + conns[conn_id] = conn + _save_connections(conns) + invalidate_fs(conn_id) + return jsonify({'success': True}) + + +@app.route('/api/connections/', methods=['DELETE']) +@login_required +def delete_connection(conn_id): + if conn_id in ('seedbox', 'nas'): + return jsonify({'error': 'Impossible de supprimer les connexions par défaut'}), 400 + with connections_lock: + conns = _load_connections() + conns.pop(conn_id, None) + _save_connections(conns) + invalidate_fs(conn_id) + return jsonify({'success': True}) + +@app.route('/api/connections//test', methods=['POST']) +@login_required +def test_connection(conn_id): + try: + fs, conn_def = get_fs(conn_id) + fs.list(conn_def['config'].get('root_path', '/')) + return jsonify({'success': True}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}) + +# ─── API Fichiers ───────────────────────────────────────────────────────────── +@app.route('/api/list') +@login_required +def list_dir(): + connection_id = request.args.get('connection', 'seedbox') + path = request.args.get('path', '') + force = request.args.get('force', 'false').lower() == 'true' + cache_key = f"{connection_id}:{path}" + if not force: + cached = cache_get(cache_key) + if cached is not None: + return jsonify(cached) + try: + fs, conn_def = get_fs(connection_id) + root = conn_def['config'].get('root_path', '/') + if not path: + path = root + conn_lock = _get_conn_lock(connection_id) + with conn_lock: + items = fs.list(path) + result = {'items': items, 'path': path, 'readonly': conn_def.get('readonly', False)} + cache_set(cache_key, result) + # Prefetch uniquement pour les FS locaux (SFTP non thread-safe sur même connexion) + if conn_def.get('type') == 'local': + subdirs = [i['path'] for i in items if i['is_dir']] + if subdirs: + def prefetch(): + for sd in subdirs[:5]: + k = f"{connection_id}:{sd}" + if cache_get(k) is None: + try: + i2 = fs.list(sd) + cache_set(k, {'items': i2, 'path': sd, + 'readonly': conn_def.get('readonly', False)}) + except Exception: + pass + eventlet.sleep(0) + eventlet.spawn(prefetch) + return jsonify(result) + except Exception as e: + return jsonify({'error': str(e), 'items': []}) + +@app.route('/api/mkdir', methods=['POST']) +@login_required +def mkdir(): + data = request.json + connection_id = data.get('connection', 'nas') + path = data.get('path', '') + name = data.get('name', '').strip() + if not name or '/' in name or '..' in name: + return jsonify({'error': 'Nom invalide'}), 400 + try: + fs, conn_def = get_fs(connection_id) + if conn_def.get('readonly'): + return jsonify({'error': 'Connexion en lecture seule'}), 403 + fs.mkdir(fs.join(path, name)) + cache_invalidate(connection_id, path) + return jsonify({'success': True}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/rename', methods=['POST']) +@login_required +def rename(): + data = request.json + connection_id = data.get('connection', 'nas') + old_path = data.get('old_path', '') + new_name = data.get('new_name', '').strip() + if not new_name or '/' in new_name or '..' in new_name: + return jsonify({'error': 'Nom invalide'}), 400 + try: + fs, conn_def = get_fs(connection_id) + if conn_def.get('readonly'): + return jsonify({'error': 'Connexion en lecture seule'}), 403 + parent = fs.dirname(old_path) + new_path = fs.join(parent, new_name) + fs.rename(old_path, new_path) + cache_invalidate(connection_id, parent) + return jsonify({'success': True}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +# ─── API Queue ──────────────────────────────────────────────────────────────── +@app.route('/api/queue/add', methods=['POST']) +@login_required +def add_to_queue(): + global transfer_thread + data = request.json + src = data.get('src') + dst = data.get('dst') + src_connection = data.get('src_connection', 'seedbox') + dst_connection = data.get('dst_connection', 'nas') + name = data.get('name', '') + if not src or not dst: + return jsonify({'error': 'src et dst requis'}), 400 + conns = _load_connections() + dst_conn = conns.get(dst_connection, {}) + if dst_conn.get('readonly'): + return jsonify({'error': 'Destination en lecture seule'}), 403 + item_name = name or src.split('/')[-1] + force = data.get('force', False) + + # Vérifier si le fichier/dossier existe déjà à destination + if not force: + try: + dst_fs, _ = get_fs(dst_connection) + dst_full = dst_fs.join(dst, item_name) + if dst_fs.exists(dst_full): + try: + existing_size = dst_fs.get_total_size(dst_full) + except Exception: + existing_size = 0 + return jsonify({ + 'exists': True, + 'name': item_name, + 'dst_path': dst_full, + 'existing_size': existing_size + }) + except Exception: + pass # En cas d'erreur de vérification, on laisse passer + + transfer_id = f"t_{int(time.time() * 1000)}" + transfer = { + 'id': transfer_id, 'src': src, 'dst': dst, + 'src_connection': src_connection, 'dst_connection': dst_connection, + 'name': item_name + } + with transfer_lock: + transfer_queue.append(transfer) + queue_snapshot = [{'id': t['id'], 'name': t['name']} for t in transfer_queue] + socketio.emit('queue_updated', {'queue': queue_snapshot}) + if transfer_thread is None or not transfer_thread.is_alive(): + transfer_thread = threading.Thread(target=queue_worker, daemon=True) + transfer_thread.start() + return jsonify({'success': True, 'id': transfer_id}) + +@app.route('/api/queue/remove', methods=['POST']) +@login_required +def remove_from_queue(): + data = request.json + transfer_id = data.get('id') + with transfer_lock: + transfer_queue[:] = [t for t in transfer_queue if t['id'] != transfer_id] + queue_snapshot = [{'id': t['id'], 'name': t['name']} for t in transfer_queue] + socketio.emit('queue_updated', {'queue': queue_snapshot}) + return jsonify({'success': True}) + +@app.route('/api/queue') +@login_required +def get_queue(): + with transfer_lock: + queue_snapshot = [{'id': t['id'], 'name': t['name']} for t in transfer_queue] + cur = { + 'id': current_transfer['id'], 'name': current_transfer['name'], + 'percent': current_percent + } if current_transfer else None + return jsonify({'queue': queue_snapshot, 'current': cur, 'paused': transfer_pause}) + +@app.route('/api/transfer/stop', methods=['POST']) +@login_required +def transfer_stop_route(): + global transfer_stop, transfer_pause + transfer_stop = True + transfer_pause = False + return jsonify({'success': True}) + +@app.route('/api/transfer/pause', methods=['POST']) +@login_required +def transfer_pause_route(): + global transfer_pause + transfer_pause = not transfer_pause + socketio.emit('transfer_paused', {'paused': transfer_pause}) + return jsonify({'success': True, 'paused': transfer_pause}) + +@app.route('/api/panels/default', methods=['GET']) +@login_required +def get_default_panels(): + conns = _load_connections() + defaults = conns.get('__defaults__', { + 'left': {'connection': 'seedbox', 'path': ''}, + 'right': {'connection': 'nas', 'path': ''} + }) + return jsonify(defaults) + +@app.route('/api/panels/default', methods=['POST']) +@login_required +def save_default_panels(): + data = request.json + with connections_lock: + conns = _load_connections() + conns['__defaults__'] = { + 'left': {'connection': data.get('left_connection', 'seedbox'), + 'path': data.get('left_path', '')}, + 'right': {'connection': data.get('right_connection', 'nas'), + 'path': data.get('right_path', '')} + } + _save_connections(conns) + return jsonify({'success': True}) + +@app.route('/api/history') +@login_required +def get_history(): + return jsonify(_load_history()) + +@app.route('/api/history/clear', methods=['POST']) +@login_required +def clear_history(): + with history_lock: + _save_history([]) + return jsonify({'success': True}) + +@app.route('/api/plugins') +@login_required +def get_plugins(): + return jsonify(plugin_registry.list_plugins()) + +if __name__ == '__main__': + socketio.run(app, host='0.0.0.0', port=5000, debug=False, allow_unsafe_werkzeug=True) \ No newline at end of file diff --git a/data/connections.json b/data/connections.json new file mode 100644 index 0000000..648e33a --- /dev/null +++ b/data/connections.json @@ -0,0 +1,43 @@ +{ + "seedbox": { + "id": "seedbox", + "name": "Seedbox", + "type": "local", + "readonly": true, + "config": { + "root_path": "/mnt/seedbox" + } + }, + "nas": { + "id": "nas", + "name": "NAS", + "type": "local", + "readonly": false, + "config": { + "root_path": "/mnt/nas" + } + }, + "fdcdfbe5": { + "id": "fdcdfbe5", + "name": "Useed SFTP", + "type": "sftp", + "readonly": true, + "config": { + "host": "geco.useed.me", + "port": 13616, + "username": "SB2269", + "password": "rimyW9VSmlkqsd", + "root_path": "/SB2269/torrent" + } + }, + "__defaults__": { + "left": { + "connection": "fdcdfbe5", + "path": "" + }, + "right": { + "connection": "nas", + "path": "/mnt/nas" + } + } +} \ No newline at end of file diff --git a/data/history.json b/data/history.json new file mode 100644 index 0000000..ade51df --- /dev/null +++ b/data/history.json @@ -0,0 +1,178 @@ +[ + { + "name": "Traques.S01E02.FRENCH.AD.1080p.WEBrip.EAC3.5.1.x265-TyHD.mkv", + "src": "/SB2269/torrent/Series/Traques.S01E02.FRENCH.AD.1080p.WEBrip.EAC3.5.1.x265-TyHD.mkv", + "dst": "/mnt/nas/Media/Video/Series/Traques/Traques - S1", + "src_connection": "fdcdfbe5", + "dst_connection": "nas", + "size": 898963075, + "duration": 51.5, + "date": "2026-03-11 06:00:15", + "success": true + }, + { + "name": "Traques.S01E01.FRENCH.AD.1080p.WEBrip.EAC3.5.1.x265-TyHD.mkv", + "src": "/SB2269/torrent/Series/Traques.S01E01.FRENCH.AD.1080p.WEBrip.EAC3.5.1.x265-TyHD.mkv", + "dst": "/mnt/nas/Media/Video/Series/Traques/Traques - S1", + "src_connection": "fdcdfbe5", + "dst_connection": "nas", + "size": 890462705, + "duration": 48.3, + "date": "2026-03-11 05:59:23", + "success": true + }, + { + "name": "Stranger.Things.S05E08.MULTI.1080p.WEBrip.x265-TyHD.mkv", + "src": "/SB2269/torrent/Series/Stranger.Things.S05E08.MULTI.1080p.WEBrip.x265-TyHD.mkv", + "dst": "/mnt/nas/test", + "src_connection": "fdcdfbe5", + "dst_connection": "nas", + "size": 2624750852, + "duration": 136.6, + "date": "2026-03-10 21:54:22", + "success": true + }, + { + "name": "Arco.2025.MULTi.TRUEFRENCH.2160p.HDR.DV.WEB-DL.H265-Slay3R.mkv", + "src": "/SB2269/torrent/Films/Arco.2025.MULTi.TRUEFRENCH.2160p.HDR.DV.WEB-DL.H265-Slay3R.mkv", + "dst": "/mnt/nas/test", + "src_connection": "fdcdfbe5", + "dst_connection": "nas", + "size": 16755361521, + "duration": 862.2, + "date": "2026-03-10 15:55:32", + "success": true + }, + { + "name": "Roofman.2025.MULTi.TRUEFRENCH.1080p.WEB.H265-SUPPLY", + "src": "/SB2269/torrent/Films/Roofman.2025.MULTi.TRUEFRENCH.1080p.WEB.H265-SUPPLY", + "dst": "/mnt/nas/test", + "src_connection": "fdcdfbe5", + "dst_connection": "nas", + "size": 3589620970, + "duration": 198.7, + "date": "2026-03-10 15:37:20", + "success": true + }, + { + "name": "Deadpool and Wolverine (2024) Hybrid MULTi VFF 2160p 10bit 4KLight DV HDR10Plus BluRay DDP 7.1 x265-QTZ.mkv", + "src": "/SB2269/torrent/Films/Deadpool and Wolverine (2024) Hybrid MULTi VFF 2160p 10bit 4KLight DV HDR10Plus BluRay DDP 7.1 x265-QTZ.mkv", + "dst": "/mnt/nas/test", + "src_connection": "fdcdfbe5", + "dst_connection": "nas", + "size": 7585847557, + "duration": 417.4, + "date": "2026-03-10 15:24:36", + "success": true + }, + { + "name": "Hellboy II (2008) Les L\u00e9gions d'Or Maudites [2160p HDR10 x265 MULTI VFF 5.1 DTS VO 7.1 HDMA X].mkv", + "src": "/SB2269/torrent/Films/Hellboy II (2008) Les L\u00e9gions d'Or Maudites [2160p HDR10 x265 MULTI VFF 5.1 DTS VO 7.1 HDMA X].mkv", + "dst": "/mnt/nas/test", + "src_connection": "fdcdfbe5", + "dst_connection": "nas", + "size": 14091470311, + "duration": 753.2, + "date": "2026-03-10 15:11:56", + "success": true + }, + { + "name": "Fantastic Four 2005 MULTi VFF 1080p BluRay E-AC3 x265-Winks.mkv", + "src": "/SB2269/torrent/Films/Fantastic Four 2005 MULTi VFF 1080p BluRay E-AC3 x265-Winks.mkv", + "dst": "/mnt/nas/test", + "src_connection": "fdcdfbe5", + "dst_connection": "nas", + "size": 5097511665, + "duration": 290.8, + "date": "2026-03-10 14:54:27", + "success": true + }, + { + "name": "Avatar 2009 MULTi VFF 1080p DNSP WEB x265 HDR10+ DDP 5.1-Decha", + "src": "/SB2269/torrent/Films/Avatar 2009 MULTi VFF 1080p DNSP WEB x265 HDR10+ DDP 5.1-Decha", + "dst": "/mnt/nas/test", + "src_connection": "fdcdfbe5", + "dst_connection": "nas", + "size": 7021710760, + "duration": 373.9, + "date": "2026-03-10 14:47:16", + "success": true + }, + { + "name": "Asterix & Obelix Mission Cleopatre (2002) VOF 2160p 10bit 4KLight DV HDR BluRay DDP 7.1 x265-QTZ.mkv", + "src": "/SB2269/torrent/Films/Asterix & Obelix Mission Cleopatre (2002) VOF 2160p 10bit 4KLight DV HDR BluRay DDP 7.1 x265-QTZ.mkv", + "dst": "/mnt/nas/test", + "src_connection": "fdcdfbe5", + "dst_connection": "nas", + "size": 5559752956, + "duration": 297.9, + "date": "2026-03-10 14:38:20", + "success": true + }, + { + "name": "13.jours.13.nuits.2025.FRENCH.AD.1080p.WEB.H265-TyHD.mkv", + "src": "/SB2269/torrent/Films/13.jours.13.nuits.2025.FRENCH.AD.1080p.WEB.H265-TyHD.mkv", + "dst": "/mnt/nas/test", + "src_connection": "fdcdfbe5", + "dst_connection": "nas", + "size": 2094587907, + "duration": 113.5, + "date": "2026-03-10 14:33:22", + "success": true + }, + { + "name": "Igorrr - Amen (2025)", + "src": "/SB2269/torrent/Music/Igorrr - Amen (2025)", + "dst": "/mnt/nas/test", + "src_connection": "fdcdfbe5", + "dst_connection": "nas", + "size": 108724752, + "duration": 11.8, + "date": "2026-03-10 13:58:06", + "success": true + }, + { + "name": "Asterix & Obelix Mission Cleopatre (2002) VOF 2160p 10bit 4KLight DV HDR BluRay DDP 7.1 x265-QTZ.mkv", + "src": "/SB2269/torrent/Films/Asterix & Obelix Mission Cleopatre (2002) VOF 2160p 10bit 4KLight DV HDR BluRay DDP 7.1 x265-QTZ.mkv", + "dst": "/mnt/nas/test", + "src_connection": "fdcdfbe5", + "dst_connection": "nas", + "size": 0, + "duration": 96.4, + "date": "2026-03-10 13:53:48", + "success": true + }, + { + "name": "13.jours.13.nuits.2025.FRENCH.AD.1080p.WEB.H265-TyHD.mkv", + "src": "/SB2269/torrent/Films/13.jours.13.nuits.2025.FRENCH.AD.1080p.WEB.H265-TyHD.mkv", + "dst": "/mnt/nas/test", + "src_connection": "fdcdfbe5", + "dst_connection": "nas", + "size": 2094587907, + "duration": 153.8, + "date": "2026-03-10 12:41:46", + "success": true + }, + { + "name": "13.jours.13.nuits.2025.FRENCH.AD.1080p.WEB.H265-TyHD.mkv", + "src": "/SB2269/torrent/Films/13.jours.13.nuits.2025.FRENCH.AD.1080p.WEB.H265-TyHD.mkv", + "dst": "/mnt/nas/test", + "src_connection": "fdcdfbe5", + "dst_connection": "nas", + "size": 2094587907, + "duration": 143.4, + "date": "2026-03-10 12:34:56", + "success": true + }, + { + "name": "Igorrr - Amen (2025)", + "src": "/SB2269/torrent/Music/Igorrr - Amen (2025)", + "dst": "/mnt/nas/test", + "src_connection": "fdcdfbe5", + "dst_connection": "nas", + "size": 108724752, + "duration": 11.2, + "date": "2026-03-10 12:32:20", + "success": true + } +] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5236a49 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +services: + seedmover: + build: . + container_name: seedmover-2 + ports: + - 5019:5000 + environment: + - SEEDBOX_PATH=/mnt/seedbox + - NAS_PATH=/mnt/nas + - APP_TITLE=SeedMover + - SECRET_KEY=049b72cbe3e3f6dbbb242b30a3087dda9290ddaf1ab10a53ae160d09146b33e5 + - APP_USERNAME=admin + - APP_PASSWORD=changeme + - SESSION_LIFETIME=86400 + - CACHE_TTL=60 + - DATA_DIR=/app/data + volumes: + - /mnt/nas/Useed/Media_useed:/mnt/seedbox:ro + - /mnt/nas/BOB4-Syno:/mnt/nas + - ./data:/app/data # persistance des connexions + restart: unless-stopped +networks: {} diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 0000000..e706958 --- /dev/null +++ b/plugins/__init__.py @@ -0,0 +1,59 @@ +""" +Registre de plugins SeedMover. +Tout plugin placé dans ce dossier et héritant de AbstractFS +est automatiquement découvert et enregistré. +""" +import importlib +import os +from .base import AbstractFS + +_registry: dict[str, type] = {} + + +def _discover(): + """Scan le dossier plugins/ et importe chaque module.""" + plugins_dir = os.path.dirname(__file__) + for fname in os.listdir(plugins_dir): + if fname.startswith('_') or not fname.endswith('.py'): + continue + module_name = fname[:-3] + if module_name in ('base',): + continue + try: + mod = importlib.import_module(f'.{module_name}', package='plugins') + for attr in dir(mod): + cls = getattr(mod, attr) + if ( + isinstance(cls, type) + and issubclass(cls, AbstractFS) + and cls is not AbstractFS + and cls.PLUGIN_NAME + ): + _registry[cls.PLUGIN_NAME] = cls + except Exception as e: + print(f"[plugins] Impossible de charger {fname}: {e}") + + +def get_plugin(name: str) -> type: + """Retourne la classe plugin pour un nom donné.""" + if not _registry: + _discover() + return _registry.get(name) + + +def list_plugins() -> list: + """Retourne la liste des plugins disponibles avec leur config.""" + if not _registry: + _discover() + return [ + { + 'name': cls.PLUGIN_NAME, + 'label': cls.PLUGIN_LABEL, + 'fields': cls.get_config_fields() + } + for cls in _registry.values() + ] + + +# Découverte au chargement du module +_discover() diff --git a/plugins/base.py b/plugins/base.py new file mode 100644 index 0000000..423839e --- /dev/null +++ b/plugins/base.py @@ -0,0 +1,140 @@ +from abc import ABC, abstractmethod + + +class AbstractFS(ABC): + """ + Interface abstraite pour tous les plugins de système de fichiers. + Pour créer un nouveau plugin : + 1. Créer plugins/monplugin.py + 2. Hériter de AbstractFS + 3. Implémenter toutes les méthodes abstraites + 4. Définir PLUGIN_NAME et PLUGIN_LABEL + Le plugin sera automatiquement découvert au démarrage. + """ + + PLUGIN_NAME = None # identifiant interne ex: "sftp" + PLUGIN_LABEL = None # label affiché ex: "SFTP" + + # ─── Cycle de vie ──────────────────────────────────────────── + + @abstractmethod + def connect(self, config: dict): + """Établir la connexion avec la config fournie.""" + pass + + @abstractmethod + def disconnect(self): + """Fermer proprement la connexion.""" + pass + + @abstractmethod + def is_connected(self) -> bool: + """Retourner True si la connexion est active.""" + pass + + # ─── Navigation ────────────────────────────────────────────── + + @abstractmethod + def list(self, path: str) -> list: + """ + Lister le contenu d'un dossier. + Retourne une liste de dicts : + { name, path, is_dir, size, mtime } + Triés : dossiers d'abord, puis fichiers, alphabétique. + """ + pass + + @abstractmethod + def isdir(self, path: str) -> bool: + pass + + @abstractmethod + def exists(self, path: str) -> bool: + pass + + @abstractmethod + def getsize(self, path: str) -> int: + pass + + @abstractmethod + def join(self, *parts) -> str: + """Équivalent os.path.join pour ce FS.""" + pass + + @abstractmethod + def basename(self, path: str) -> str: + pass + + @abstractmethod + def dirname(self, path: str) -> str: + pass + + @abstractmethod + def relpath(self, path: str, base: str) -> str: + pass + + # ─── Opérations ────────────────────────────────────────────── + + @abstractmethod + def mkdir(self, path: str): + """Créer un dossier (et les parents si nécessaire).""" + pass + + @abstractmethod + def rename(self, old_path: str, new_path: str): + pass + + @abstractmethod + def remove(self, path: str): + pass + + @abstractmethod + def walk(self, path: str): + """ + Générateur identique à os.walk : + yield (root, dirs, files) + """ + pass + + # ─── Transfert ─────────────────────────────────────────────── + + @abstractmethod + def read_chunks(self, path: str, chunk_size: int = 4 * 1024 * 1024): + """ + Générateur qui yield des bytes chunk par chunk. + Utilisé par le moteur de copie. + """ + pass + + @abstractmethod + def write_chunks(self, path: str, chunks): + """ + Écrire un fichier à partir d'un générateur de chunks bytes. + Utilisé par le moteur de copie. + """ + pass + + def get_total_size(self, path: str) -> int: + """Taille totale d'un fichier ou dossier récursif.""" + if not self.isdir(path): + return self.getsize(path) + total = 0 + for root, dirs, files in self.walk(path): + for f in files: + try: + total += self.getsize(self.join(root, f)) + except Exception: + pass + return total + + # ─── Métadonnées du plugin ──────────────────────────────────── + + @classmethod + def get_config_fields(cls) -> list: + """ + Retourne la liste des champs de config nécessaires pour ce plugin. + Chaque champ : { name, label, type, required, default } + type : "text" | "password" | "number" | "file" + Surcharger dans chaque plugin. + """ + return [] diff --git a/plugins/local.py b/plugins/local.py new file mode 100644 index 0000000..1663b2a --- /dev/null +++ b/plugins/local.py @@ -0,0 +1,111 @@ +import os +from .base import AbstractFS + + +class LocalFS(AbstractFS): + + PLUGIN_NAME = "local" + PLUGIN_LABEL = "Local" + + def __init__(self): + self._root = "/" + self._connected = False + + def connect(self, config: dict): + self._root = config.get("root_path", "/") + self._connected = True + + def disconnect(self): + self._connected = False + + def is_connected(self) -> bool: + return self._connected + + # ─── Navigation ────────────────────────────────────────────── + + def list(self, path: str) -> list: + entries = sorted(os.scandir(path), key=lambda e: (not e.is_dir(), e.name.lower())) + items = [] + for entry in entries: + try: + stat = entry.stat() + items.append({ + 'name': entry.name, + 'path': entry.path, + 'is_dir': entry.is_dir(), + 'size': stat.st_size if not entry.is_dir() else 0, + 'mtime': stat.st_mtime, + }) + except PermissionError: + continue + return items + + def isdir(self, path: str) -> bool: + return os.path.isdir(path) + + def exists(self, path: str) -> bool: + return os.path.exists(path) + + def getsize(self, path: str) -> int: + return os.path.getsize(path) + + def join(self, *parts) -> str: + return os.path.join(*parts) + + def basename(self, path: str) -> str: + return os.path.basename(path) + + def dirname(self, path: str) -> str: + return os.path.dirname(path) + + def relpath(self, path: str, base: str) -> str: + return os.path.relpath(path, base) + + # ─── Opérations ────────────────────────────────────────────── + + def mkdir(self, path: str): + os.makedirs(path, exist_ok=True) + + def rename(self, old_path: str, new_path: str): + os.rename(old_path, new_path) + + def remove(self, path: str): + if os.path.isdir(path): + import shutil + shutil.rmtree(path) + else: + os.remove(path) + + def walk(self, path: str): + yield from os.walk(path) + + # ─── Transfert ─────────────────────────────────────────────── + + def read_chunks(self, path: str, chunk_size: int = 4 * 1024 * 1024): + with open(path, 'rb') as f: + while True: + buf = f.read(chunk_size) + if not buf: + break + yield buf + + def write_chunks(self, path: str, chunks): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, 'wb') as f: + for chunk in chunks: + f.write(chunk) + + # ─── Config ────────────────────────────────────────────────── + + @classmethod + def get_config_fields(cls) -> list: + return [ + { + 'name': 'root_path', + 'label': 'Chemin racine', + 'type': 'text', + 'required': True, + 'default': '/', + 'placeholder': '/mnt/nas' + } + ] diff --git a/plugins/sftp.py b/plugins/sftp.py new file mode 100644 index 0000000..ea62218 --- /dev/null +++ b/plugins/sftp.py @@ -0,0 +1,202 @@ +import os +import stat +import posixpath +import paramiko +from .base import AbstractFS + + +class SFTPfs(AbstractFS): + + PLUGIN_NAME = "sftp" + PLUGIN_LABEL = "SFTP" + + def __init__(self): + self._client = None # paramiko SSHClient + self._sftp = None # paramiko SFTPClient + self._root = "/" + self._connected = False + + def connect(self, config: dict): + host = config['host'] + port = int(config.get('port', 22)) + username = config['username'] + password = config.get('password') or None + key_path = config.get('key_path') or None + self._root = config.get('root_path', '/') + + self._client = paramiko.SSHClient() + self._client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + connect_kwargs = dict(hostname=host, port=port, username=username, timeout=10) + if key_path: + connect_kwargs['key_filename'] = key_path + if password: + connect_kwargs['password'] = password + + self._client.connect(**connect_kwargs) + self._sftp = self._client.open_sftp() + self._connected = True + + def disconnect(self): + try: + if self._sftp: + self._sftp.close() + if self._client: + self._client.close() + except Exception: + pass + self._sftp = None + self._client = None + self._connected = False + + def is_connected(self) -> bool: + if not self._connected or not self._sftp: + return False + try: + self._sftp.stat('.') + return True + except Exception: + self._connected = False + return False + + def _reconnect_if_needed(self, config): + if not self.is_connected(): + self.connect(config) + + # ─── Navigation ────────────────────────────────────────────── + + def list(self, path: str) -> list: + items = [] + for attr in self._sftp.listdir_attr(path): + is_dir = stat.S_ISDIR(attr.st_mode) + items.append({ + 'name': attr.filename, + 'path': posixpath.join(path, attr.filename), + 'is_dir': is_dir, + 'size': attr.st_size if not is_dir else 0, + 'mtime': attr.st_mtime, + }) + items.sort(key=lambda e: (not e['is_dir'], e['name'].lower())) + return items + + def isdir(self, path: str) -> bool: + try: + return stat.S_ISDIR(self._sftp.stat(path).st_mode) + except Exception: + return False + + def exists(self, path: str) -> bool: + try: + self._sftp.stat(path) + return True + except Exception: + return False + + def getsize(self, path: str) -> int: + return self._sftp.stat(path).st_size + + def join(self, *parts) -> str: + return posixpath.join(*parts) + + def basename(self, path: str) -> str: + return posixpath.basename(path) + + def dirname(self, path: str) -> str: + return posixpath.dirname(path) + + def relpath(self, path: str, base: str) -> str: + # posixpath n'a pas relpath, on le simule + if path.startswith(base): + rel = path[len(base):] + return rel.lstrip('/') + return path + + # ─── Opérations ────────────────────────────────────────────── + + def mkdir(self, path: str): + """Crée le dossier et tous les parents manquants.""" + parts = path.split('/') + current = '' + for part in parts: + if not part: + current = '/' + continue + current = posixpath.join(current, part) + try: + self._sftp.stat(current) + except IOError: + self._sftp.mkdir(current) + + def rename(self, old_path: str, new_path: str): + self._sftp.rename(old_path, new_path) + + def remove(self, path: str): + if self.isdir(path): + for attr in self._sftp.listdir_attr(path): + child = posixpath.join(path, attr.filename) + if stat.S_ISDIR(attr.st_mode): + self.remove(child) + else: + self._sftp.remove(child) + self._sftp.rmdir(path) + else: + self._sftp.remove(path) + + def walk(self, path: str): + """os.walk équivalent pour SFTP.""" + dirs = [] + files = [] + for attr in self._sftp.listdir_attr(path): + if stat.S_ISDIR(attr.st_mode): + dirs.append(attr.filename) + else: + files.append(attr.filename) + yield path, dirs, files + for d in dirs: + yield from self.walk(posixpath.join(path, d)) + + # ─── Transfert ─────────────────────────────────────────────── + + def read_chunks(self, path: str, chunk_size: int = 4 * 1024 * 1024): + # Limiter le pipelining Paramiko pour contrôler la RAM + import paramiko + old_max = paramiko.sftp_file.SFTPFile.MAX_REQUEST_SIZE + paramiko.sftp_file.SFTPFile.MAX_REQUEST_SIZE = 32768 # 32KB natif SFTP + import gc + SFTP_BLOCK = 32768 + try: + with self._sftp.open(path, 'rb') as f: + accumulated = bytearray() + while True: + block = f.read(SFTP_BLOCK) + if not block: + break + accumulated += block + if len(accumulated) >= chunk_size: + data = bytes(accumulated) + accumulated = bytearray() + gc.collect() # forcer Python à rendre la mémoire + yield data + if accumulated: + yield bytes(accumulated) + finally: + paramiko.sftp_file.SFTPFile.MAX_REQUEST_SIZE = old_max + + def write_chunks(self, path: str, chunks): + self.mkdir(posixpath.dirname(path)) + with self._sftp.open(path, 'wb') as f: + for chunk in chunks: + f.write(chunk) + + # ─── Config ────────────────────────────────────────────────── + + @classmethod + def get_config_fields(cls) -> list: + return [ + {'name': 'host', 'label': 'Hôte', 'type': 'text', 'required': True, 'placeholder': 'sftp.exemple.com'}, + {'name': 'port', 'label': 'Port', 'type': 'number', 'required': False, 'default': 22}, + {'name': 'username', 'label': 'Utilisateur', 'type': 'text', 'required': True, 'placeholder': 'user'}, + {'name': 'password', 'label': 'Mot de passe', 'type': 'password', 'required': False, 'placeholder': 'Laisser vide si clé SSH'}, + {'name': 'key_path', 'label': 'Clé SSH (chemin)', 'type': 'text', 'required': False, 'placeholder': '/root/.ssh/id_rsa'}, + {'name': 'root_path', 'label': 'Dossier racine', 'type': 'text', 'required': False, 'default': '/', 'placeholder': '/home/user'}, + ] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d1ad2d2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +flask==3.0.3 +flask-socketio==5.3.6 +eventlet==0.36.1 +paramiko==3.4.0 diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..216c785 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,1671 @@ + + + + + +{{ title }} + + + + + + + +
+ +
+
+
Inactif
+
+ + + ⏏ Déconnexion +
+
+
+
+ +
+ +
+ + +
+ + +
+
+ +
+
+ Seedbox + + + +
+ +
Chargement…
+
+ + +
+
+ + +
+
+ NAS + + + +
+ +
Chargement…
+
+ + +
+
+
+ + +
+
+ File de transfert + 0 +
+ + +
+
+
+
File vide — sélectionne des fichiers et clique sur "Copier vers NAS"
+
+
+
+ + +
+
+ 0 transferts + + +
+
+ + + + + + + + + + + + + + + +
FichierSourceDestinationTailleDuréeDate
Aucun transfert enregistré
+
+
+ +
+ + + + + + + + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..2b8e5bb --- /dev/null +++ b/templates/login.html @@ -0,0 +1,175 @@ + + + + + +{{ title }} — Connexion + + + + + + +
+ +
Gestionnaire de transferts
+ + {% if error %} +
⚠ {{ error }}
+ {% endif %} + +
+
+ + +
+
+ + +
+ +
+
+ + +