major server manager overhaul
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user