major server manager overhaul

This commit is contained in:
2026-01-09 19:24:41 +00:00
parent 6a667d1019
commit c0ac650625
202 changed files with 325323 additions and 6929 deletions
+103 -28
View File
@@ -1,40 +1,115 @@
from fastapi import APIRouter, BackgroundTasks
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from typing import List, Optional
import asyncio
import os
from ..comics_manager import ComicsManager
from ..config import settings
from ..samba_manager import SambaManager
from ..tasks import task_organize_comics, task_move_comics, task_update_metadata, task_sort_by_artist
router = APIRouter()
def run_organize_comics_background():
app_settings = settings
manager = ComicsManager()
samba_manager = SambaManager(
app_settings.samba_server_ip,
"isolation",
app_settings.samba_username,
app_settings.samba_password,
class MoveRequest(BaseModel):
series_names: List[str]
destination_share: Optional[str] = "comics"
destination_path: Optional[str] = "/manga"
class MetadataUpdateRequest(BaseModel):
target_path: Optional[str] = "/comics/manga"
force_update: Optional[bool] = False
class FolderScanRequest(BaseModel):
root_path: Optional[str] = "/comics/manga"
threshold: float = 0.9
class DeleteFolderRequest(BaseModel):
folder_path: str
class SortRequest(BaseModel):
root_path: Optional[str] = "/comics/manga"
def get_samba_manager(share="isolation"):
return SambaManager(
settings.samba_server_ip,
share,
settings.samba_username,
settings.samba_password,
)
manager.organize_comics(samba_manager)
samba_manager.close()
@router.post("/organize")
def organize_comics(background_tasks: BackgroundTasks):
background_tasks.add_task(run_organize_comics_background)
return {"message": "Comics organization started in the background."}
def organize_comics():
task = task_organize_comics()
return {"message": f"Organization queued (Task ID: {task.id}). Check logs."}
def run_cleanup_toberead_background():
app_settings = settings
@router.get("/pending")
def get_pending_comics():
manager = ComicsManager()
samba_manager = SambaManager(
app_settings.samba_server_ip,
"isolation",
app_settings.samba_username,
app_settings.samba_password,
)
manager.cleanup_toberead(samba_manager)
samba_manager.close()
samba_manager = get_samba_manager("isolation")
try:
return manager.get_pending_comics(samba_manager)
finally:
samba_manager.close()
@router.delete("/cleanup_toberead")
def cleanup_toberead(background_tasks: BackgroundTasks):
background_tasks.add_task(run_cleanup_toberead_background)
return {"message": "Comics cleanup started in the background."}
@router.post("/move")
def move_comics(request: MoveRequest):
task = task_move_comics(request.series_names, request.destination_share, request.destination_path)
return {"message": f"Move queued (Task ID: {task.id}). Check logs."}
@router.post("/update_metadata")
def update_metadata(request: MetadataUpdateRequest):
task = task_update_metadata(request.target_path, request.force_update)
return {"message": f"Metadata update queued (Task ID: {task.id}). Check logs."}
@router.post("/folders/scan")
def scan_folders(request: FolderScanRequest):
# This remains synchronous to return results immediately to UI
manager = ComicsManager()
samba_manager = get_samba_manager("isolation")
try:
groups = manager.find_similar_folders(samba_manager, request.root_path, request.threshold)
return groups
finally:
samba_manager.close()
@router.post("/folders/delete")
def delete_folder(request: DeleteFolderRequest):
manager = ComicsManager()
samba_manager = get_samba_manager("isolation")
try:
return manager.delete_folder(samba_manager, request.folder_path)
finally:
samba_manager.close()
@router.post("/sort_by_artist")
def sort_by_artist(request: SortRequest):
task = task_sort_by_artist(request.root_path)
return {"message": f"Sort by Artist queued (Task ID: {task.id}). Check logs."}
@router.websocket("/ws/logs")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
file_path = "logs/comics_organization.log"
# Ensure file exists
if not os.path.exists(file_path):
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, "w") as f: f.write("")
try:
with open(file_path, "r") as f:
# Send last 20 lines for context
lines = f.readlines()
for line in lines[-20:]:
await websocket.send_text(line)
# Follow the file
f.seek(0, 2)
while True:
line = f.readline()
if line:
await websocket.send_text(line)
else:
await asyncio.sleep(0.1)
except WebSocketDisconnect:
pass
@@ -1,19 +1,30 @@
from fastapi import APIRouter, Depends, BackgroundTasks
from fastapi import APIRouter, Depends, BackgroundTasks, HTTPException
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from pydantic import BaseModel
from ..duplicates_manager import DuplicatesManager
from ..config import settings, Settings
from ..database import get_db, SessionLocal
from ..samba_manager import SambaManager
from .. import models
import os
import json
from typing import List
router = APIRouter()
class DeleteFilesRequest(BaseModel):
filepaths: List[str]
class ScanConfig(BaseModel):
paths: List[str] = ["/videos"]
algorithm: str = "phash"
scan_type: str = "fast" # fast or scene
def run_scan_videos_background():
class DeleteRequest(BaseModel):
ids: List[int]
class ExclusionRequest(BaseModel):
path: str
def run_scan_videos_background(paths, algorithm, scan_type):
db = SessionLocal()
app_settings = Settings()
manager = DuplicatesManager(app_settings, db)
@@ -24,42 +35,72 @@ def run_scan_videos_background():
app_settings.samba_password,
)
try:
manager.scan_videos(samba_manager)
manager.scan_videos(samba_manager, paths, algorithm, scan_type)
finally:
samba_manager.close()
db.close()
@router.post("/scan")
def scan_videos(background_tasks: BackgroundTasks):
background_tasks.add_task(run_scan_videos_background)
return {"message": "Video scan started in the background."}
@router.post("/scan/start")
def start_scan(config: ScanConfig, background_tasks: BackgroundTasks):
background_tasks.add_task(run_scan_videos_background, config.paths, config.algorithm, config.scan_type)
return {"message": "Scan started"}
@router.post("/scan/pause")
def pause_scan():
with open("logs/duplicates_scan.pause", 'w') as f:
pass
return {"message": "Video scan paused."}
@router.post("/scan/cancel")
def cancel_scan():
try:
os.makedirs("logs", exist_ok=True)
with open("logs/duplicates_scan.cancel", 'w') as f:
f.write("cancel")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to cancel scan: {e}")
return {"message": "Cancellation requested"}
@router.post("/scan/resume")
def resume_scan():
if os.path.exists("logs/duplicates_scan.pause"):
os.remove("logs/duplicates_scan.pause")
return {"message": "Video scan resumed."}
@router.get("/scan/log")
def get_scan_log(limit: int = 100):
log_file = "logs/duplicates_scan.log"
if not os.path.exists(log_file):
return {"lines": []}
try:
# Simple read and tail (not efficient for huge files but fine here)
with open(log_file, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
return {"lines": lines[-limit:]}
except Exception as e:
return {"lines": [f"Error reading log: {e}"]}
@router.get("/scan/status")
def get_scan_status():
if os.path.exists("logs/duplicates_scan.pause"):
return {"status": "Paused"}
if os.path.exists("logs/duplicates_scan.progress") or os.path.exists("logs/duplicates_scan.state"):
return {"status": "Scanning"}
return {"status": "Idle"}
@router.get("/scan/progress")
def get_scan_progress():
path = "logs/duplicates_scan.json"
if os.path.exists(path):
try:
with open(path, 'r') as f:
return json.load(f)
except:
return {"status": "error", "message": "Read failed"}
return {"status": "idle"}
@router.get("/exclusions")
def get_exclusions(db: Session = Depends(get_db)):
manager = DuplicatesManager(settings, db)
return manager.exclusions
@router.post("/exclusions")
def add_exclusion(req: ExclusionRequest, db: Session = Depends(get_db)):
manager = DuplicatesManager(settings, db)
manager.add_exclusion(req.path)
return {"message": "Added"}
@router.delete("/exclusions")
def remove_exclusion(req: ExclusionRequest, db: Session = Depends(get_db)):
manager = DuplicatesManager(settings, db)
manager.remove_exclusion(req.path)
return {"message": "Removed"}
@router.post("/find")
def find_duplicates(threshold: float = 0.95, db: Session = Depends(get_db)):
def find_duplicates(threshold: float = 0.95, method: str = "fast", db: Session = Depends(get_db)):
manager = DuplicatesManager(settings, db)
result = manager.find_duplicates(threshold)
return result
return manager.find_duplicates(threshold, method)
@router.get("/reports")
def get_reports(db: Session = Depends(get_db)):
@@ -69,9 +110,24 @@ def get_reports(db: Session = Depends(get_db)):
@router.get("/report/{report_id}")
def get_report(report_id: int, db: Session = Depends(get_db)):
manager = DuplicatesManager(settings, db)
return manager.get_duplicate_report(report_id)
res = manager.get_report(report_id)
if not res: raise HTTPException(status_code=404)
return res
@router.delete("/files")
def delete_files(request: DeleteFilesRequest, db: Session = Depends(get_db)):
def delete_files(request: DeleteRequest, db: Session = Depends(get_db)):
manager = DuplicatesManager(settings, db)
return manager.delete_files(request.filepaths)
deleted = manager.delete_files(request.ids)
return {"deleted_ids": deleted}
@router.get("/thumbnails/{video_id}")
def get_thumbnail(video_id: int, db: Session = Depends(get_db)):
video = db.query(models.VideoFile).get(video_id)
if not video or not video.contact_sheet_path:
raise HTTPException(status_code=404, detail="Thumbnail not found")
path = os.path.join("resources/cache/thumbnails", video.contact_sheet_path)
if not os.path.exists(path):
raise HTTPException(status_code=404, detail="Thumbnail file missing")
return FileResponse(path)
@@ -0,0 +1,63 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import List
from ..qbittorrent_manager import QbittorrentManager
router = APIRouter()
manager = QbittorrentManager()
class TorrentActionRequest(BaseModel):
hashes: List[str]
class DeleteRequest(TorrentActionRequest):
delete_files: bool = False
class LocationRequest(TorrentActionRequest):
location: str
@router.get("/")
def list_torrents():
try:
return manager.get_torrents()
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/pause")
def pause_torrents(request: TorrentActionRequest):
try:
manager.pause_torrents(request.hashes)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/resume")
def resume_torrents(request: TorrentActionRequest):
try:
manager.resume_torrents(request.hashes)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/delete")
def delete_torrents(request: DeleteRequest):
try:
manager.delete_torrents(request.hashes, request.delete_files)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/recheck")
def recheck_torrents(request: TorrentActionRequest):
try:
manager.recheck_torrents(request.hashes)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/location")
def set_location(request: LocationRequest):
try:
manager.set_location(request.hashes, request.location)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -215,3 +215,16 @@ def rename_file(share_name: str, request: RenameRequest):
result = manager.rename_file(request.old_path, request.new_path)
manager.close()
return result
@router.get("/search/{share_name}")
def search_files(share_name: str, query: str, path: str = "/"):
manager = SambaManager(
settings.samba_server_ip,
share_name,
username=settings.samba_username,
password=settings.samba_password,
)
try:
return manager.search_files(query, path)
finally:
manager.close()
@@ -0,0 +1,34 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import List, Optional, Dict, Any
from ..scheduler import add_job, get_jobs, remove_job
router = APIRouter()
class JobCreate(BaseModel):
task_name: str
cron_expression: str
args: Optional[List[Any]] = []
kwargs: Optional[Dict[str, Any]] = {}
@router.get("/")
def list_jobs():
return get_jobs()
@router.post("/")
def create_job(job: JobCreate):
try:
job_id = add_job(job.task_name, job.cron_expression, job.args, job.kwargs)
return {"id": job_id, "message": "Job scheduled"}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{job_id}")
def delete_job(job_id: str):
try:
remove_job(job_id)
return {"message": "Job deleted"}
except Exception:
raise HTTPException(status_code=404, detail="Job not found")
@@ -0,0 +1,34 @@
from fastapi import APIRouter
from pydantic import BaseModel
from typing import Optional
from ..config import settings
router = APIRouter()
class SettingsUpdate(BaseModel):
samba_server_ip: str
samba_username: str
samba_password: str
qbittorrent_url: Optional[str]
qbittorrent_username: Optional[str]
qbittorrent_password: Optional[str]
default_comics_path: str
default_videos_path: str
stash_enabled: Optional[bool]
stash_db_path: Optional[str]
stash_generated_path: Optional[str]
stash_remote_base: Optional[str]
stash_container_base: Optional[str]
stash_share: Optional[str]
@router.get("/")
def get_settings():
return settings.model_dump()
@router.post("/")
def update_settings(update: SettingsUpdate):
for key, value in update.model_dump().items():
if hasattr(settings, key):
setattr(settings, key, value)
settings.save_to_json()
return {"message": "Settings updated"}
@@ -0,0 +1,44 @@
from fastapi import APIRouter, Depends, BackgroundTasks, HTTPException
from sqlalchemy.orm import Session
from ..config import settings, Settings
from ..samba_manager import SambaManager
from ..database import get_db
import os
import logging
router = APIRouter()
logger = logging.getLogger(__name__)
def sync_stash_db_task():
samba_manager = SambaManager(
settings.samba_server_ip,
settings.stash_share,
settings.samba_username,
settings.samba_password,
)
try:
logger.info(f"Syncing Stash DB from {settings.stash_remote_db_path}")
with open(settings.stash_db_path, "wb") as f:
samba_manager.download_file(settings.stash_remote_db_path, f)
logger.info("Stash DB synced successfully")
except Exception as e:
logger.error(f"Stash DB sync failed: {e}")
finally:
samba_manager.close()
@router.post("/sync")
def sync_stash(background_tasks: BackgroundTasks):
background_tasks.add_task(sync_stash_db_task)
return {"message": "Stash sync started"}
@router.get("/status")
def get_stash_status():
exists = os.path.exists(settings.stash_db_path)
size = os.path.getsize(settings.stash_db_path) if exists else 0
mtime = os.path.getmtime(settings.stash_db_path) if exists else 0
return {
"enabled": settings.stash_enabled,
"db_exists": exists,
"db_size": size,
"db_mtime": mtime
}
@@ -0,0 +1,60 @@
from fastapi import APIRouter
import psutil
import time
import os
router = APIRouter()
def get_size(bytes, suffix="B"):
"""
Scale bytes to its proper format
e.g:
1253656 => '1.20MB'
1253656678 => '1.17GB'
"""
factor = 1024
for unit in ["", "K", "M", "G", "T", "P"]:
if bytes < factor:
return f"{bytes:.2f}{unit}{suffix}"
bytes /= factor
@router.get("/stats")
def get_system_stats():
# CPU
cpu_usage = psutil.cpu_percent(interval=1)
# Memory
svmem = psutil.virtual_memory()
memory_stats = {
"total": get_size(svmem.total),
"available": get_size(svmem.available),
"percent": svmem.percent,
"used": get_size(svmem.used)
}
# Disk (Root)
try:
partition = psutil.disk_usage("/")
disk_stats = {
"total": get_size(partition.total),
"free": get_size(partition.free),
"percent": partition.percent,
"used": get_size(partition.used)
}
except Exception:
disk_stats = {"error": "Unavailable"}
# Uptime
boot_time = psutil.boot_time()
uptime_seconds = time.time() - boot_time
uptime_string = time.strftime("%H:%M:%S", time.gmtime(uptime_seconds))
if uptime_seconds > 86400:
days = int(uptime_seconds // 86400)
uptime_string = f"{days} days, " + uptime_string
return {
"cpu": cpu_usage,
"memory": memory_stats,
"disk": disk_stats,
"uptime": uptime_string
}
@@ -0,0 +1,10 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from ..database import get_db
from ..models import TaskHistory
router = APIRouter()
@router.get("/")
def get_tasks(limit: int = 50, db: Session = Depends(get_db)):
return db.query(TaskHistory).order_by(TaskHistory.start_time.desc()).limit(limit).all()