import asyncio import os import shutil import subprocess import socket import time import uuid from collections import deque from pathlib import Path from typing import Dict, List, Optional from urllib.parse import urlparse, unquote import re import unicodedata import telnetlib import requests from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field # ---------------- Config ---------------- default_movies_dir = Path.home() BASE_MOVIES_DIR = Path(os.getenv("BASE_MOVIES_DIR", default_movies_dir)).resolve() PASSWORD = os.getenv("VLC_PASSWORD", "pass") TELNET_HOST = "localhost" VLC_EXECUTABLE = os.getenv("VLC_EXECUTABLE", "vlc") class SimpleDownload: def __init__(self, url: str): self.gid = str(uuid.uuid4())[:16] self.url = url self.status = "waiting" # waiting, active, paused, complete, error, removed self.total_length = 0 self.completed_length = 0 self.download_speed = 0 self.download_speed_str = "0 B/s" self.eta = "—" self.name = "" self.error_message = "" self.file_path = None self._cancel_event = False self._pause_event = False class InHouseDownloader: def __init__(self): self.downloads = {} # gid -> SimpleDownload self._tasks = {} # gid -> asyncio.Task def get_downloads(self): return list(self.downloads.values()) def get_download(self, gid: str): return self.downloads.get(gid) def add(self, url: str, filename: Optional[str] = None, subfolder: Optional[str] = None): dl = SimpleDownload(url) if filename: name = sanitize_filename(filename) else: parsed = urlparse(url) name = os.path.basename(unquote(parsed.path)) if not name: name = "download_" + dl.gid dl.name = name target_dir = BASE_MOVIES_DIR if subfolder: # Allow nested directories by splitting on slashes parts = [re.sub(r'[?:*""<>|]', "", p).strip() for p in re.split(r'[\\/]', subfolder)] parts = [p for p in parts if p] if parts: target_dir = BASE_MOVIES_DIR.joinpath(*parts) target_dir.mkdir(parents=True, exist_ok=True) dl.file_path = target_dir / name self.downloads[dl.gid] = dl # Start download task self.resume(dl.gid) return dl def pause(self, gid: str): dl = self.downloads.get(gid) if dl and dl.status == "active": dl.status = "paused" dl._pause_event = True def resume(self, gid: str): dl = self.downloads.get(gid) if not dl: return dl.status = "active" dl._pause_event = False dl._cancel_event = False task = asyncio.create_task(self._download_loop(dl)) self._tasks[dl.gid] = task def remove(self, gid: str): dl = self.downloads.get(gid) if dl: dl.status = "removed" dl._cancel_event = True if dl.file_path and dl.file_path.exists(): try: dl.file_path.unlink() except Exception: pass self.downloads.pop(gid, None) self._tasks.pop(gid, None) def autopurge(self): to_purge = [gid for gid, dl in self.downloads.items() if dl.status in ("complete", "error", "removed")] for gid in to_purge: self.downloads.pop(gid, None) self._tasks.pop(gid, None) async def _download_loop(self, dl: SimpleDownload): try: headers = {} if dl.completed_length > 0: headers["Range"] = f"bytes={dl.completed_length}-" response = await asyncio.to_thread( requests.get, dl.url, headers=headers, stream=True, timeout=15, ) if "content-disposition" in response.headers: cd = response.headers["content-disposition"] for part in cd.split(";"): if "filename=" in part: dl.name = part.split("=")[1].strip('"\'') parent_dir = dl.file_path.parent if dl.file_path else BASE_MOVIES_DIR dl.file_path = parent_dir / dl.name is_resume = response.status_code == 206 if not is_resume and dl.completed_length > 0: dl.completed_length = 0 if "content-length" in response.headers: total_len = int(response.headers["content-length"]) dl.total_length = total_len + (dl.completed_length if is_resume else 0) mode = "ab" if (is_resume and dl.file_path.exists()) else "wb" start_time = time.time() bytes_since_start = 0 with open(dl.file_path, mode) as f: for chunk in response.iter_content(chunk_size=128 * 1024): if dl._cancel_event or dl.status == "removed": return if dl._pause_event or dl.status == "paused": return if chunk: f.write(chunk) dl.completed_length += len(chunk) bytes_since_start += len(chunk) elapsed = time.time() - start_time if elapsed > 0.5: dl.download_speed = bytes_since_start / elapsed speed = dl.download_speed if speed >= 1024**2: dl.download_speed_str = f"{speed / 1024**2:.2f} MB/s" elif speed >= 1024: dl.download_speed_str = f"{speed / 1024:.2f} KB/s" else: dl.download_speed_str = f"{speed:.2f} B/s" remaining = dl.total_length - dl.completed_length if remaining > 0 and dl.download_speed > 0: eta_secs = int(remaining / dl.download_speed) if eta_secs >= 3600: dl.eta = f"{eta_secs // 3600}h {(eta_secs % 3600) // 60}m" elif eta_secs >= 60: dl.eta = f"{eta_secs // 60}m {eta_secs % 60}s" else: dl.eta = f"{eta_secs}s" else: dl.eta = "0s" await asyncio.sleep(0.001) dl.status = "complete" dl.download_speed_str = "0 B/s" dl.eta = "done" except Exception as e: dl.status = "error" dl.error_message = str(e) dl.download_speed_str = "0 B/s" dl.eta = "—" aria2_manager = InHouseDownloader() app = FastAPI(title="VLC Control API", debug=True) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) # ---------------- Utilities ---------------- def find_free_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind(("localhost", 0)) return s.getsockname()[1] # ---------------- Models ---------------- class StartVLCRequest(BaseModel): video_path: str = Field(..., description="Path relative to BASE_MOVIES_DIR, or absolute") stream_key: Optional[str] = Field(None, description="Twitch stream key") rtmp_url: Optional[str] = Field(None, description="Full RTMP dst, overrides stream_key") loop: bool = True video_bitrate: int = 3000 audio_bitrate: int = 128 fps: int = 60 name: Optional[str] = None audio_track: Optional[int] = Field(None, description="Audio track index (0 to n)") sub_track: Optional[int] = Field(None, description="Subtitle track index (0 to n)") scale: str = "Auto" vcodec: str = "h264" acodec: str = "mp4a" samplerate: int = 44100 preset: str = "veryfast" keyint: int = 60 start_time: Optional[int] = Field(None, description="Start playback time in seconds") sort_by: Optional[str] = Field(None, description="Sorting logic for folders (natural, ctime_oldest, ctime_newest)") class InstanceInfo(BaseModel): id: str name: str pid: Optional[int] telnet_port: int http_port: int video_path: str status: str started_at: Optional[float] returncode: Optional[int] start_req: Optional[StartVLCRequest] = None class ExecRequest(BaseModel): command: str class AddDownloadRequest(BaseModel): uri: str class DeleteFileRequest(BaseModel): path: str class UpdateConfigRequest(BaseModel): movies_dir: str create_if_missing: bool = True class SearchRequest(BaseModel): query: str class SourceQualityRequest(BaseModel): url: str class SourceEpisodesRequest(BaseModel): url: str class AcerDownloadRequest(BaseModel): url: str filename: str series_type: str = "episode" subfolder: Optional[str] = None def sanitize_filename(filename): normalized = unicodedata.normalize('NFKD', filename).encode('ascii', 'ignore').decode('ascii') sanitized = re.sub(r'[\\/*?:"<>|]', "", normalized) sanitized = sanitized.replace(' ', '_') sanitized = re.sub(r'_+', '_', sanitized).strip('._') if not sanitized: return 'download' stem, ext = os.path.splitext(sanitized) max_length = 180 if len(sanitized) <= max_length: return sanitized allowed_stem_length = max_length - len(ext) shortened_stem = stem[:allowed_stem_length].rstrip('._') return f"{shortened_stem}{ext}" if shortened_stem else f"download{ext}" # ---------------- VLC instance / manager ---------------- class VLCInstance: def __init__(self, instance_id: str, name: str, video_path: str, telnet_port: int, http_port: int): self.id = instance_id self.name = name self.video_path = video_path self.telnet_port = telnet_port self.http_port = http_port self.process: Optional[subprocess.Popen] = None self.status = "starting" self.started_at: Optional[float] = None self.log_lines: deque = deque(maxlen=200) self._reader_task: Optional[asyncio.Task] = None self.start_req: Optional[StartVLCRequest] = None def to_info(self) -> InstanceInfo: return InstanceInfo( id=self.id, name=self.name, pid=self.process.pid if self.process else None, telnet_port=self.telnet_port, http_port=self.http_port, video_path=self.video_path, status=self.status, started_at=self.started_at, returncode=self.process.poll() if self.process else None, start_req=self.start_req, ) async def _read_logs(self): if not self.process or not self.process.stderr: return def read_loop(): try: for line in self.process.stderr: self.log_lines.append(line.decode(errors="replace").strip()) except Exception as e: self.log_lines.append(f"[log reader error] {e}") finally: if self.status not in ("stopped", "killed"): self.status = "exited" await asyncio.to_thread(read_loop) class VLCManager: def __init__(self): self.instances: Dict[str, VLCInstance] = {} self._lock = asyncio.Lock() def _resolve_video_path(self, raw: str) -> Path: p = Path(raw) if not p.is_absolute(): p = BASE_MOVIES_DIR / raw return p.resolve() async def start(self, req: StartVLCRequest, instance_id: Optional[str] = None, telnet_port: Optional[int] = None, http_port: Optional[int] = None) -> VLCInstance: async with self._lock: video_path = self._resolve_video_path(req.video_path) if not video_path.exists(): raise HTTPException(status_code=404, detail=f"Video not found: {video_path}") if shutil.which(VLC_EXECUTABLE) is None: raise HTTPException(status_code=500, detail="vlc executable not found on PATH") dst = req.rtmp_url if not dst: if not req.stream_key: raise HTTPException(status_code=400, detail="Provide rtmp_url or stream_key") dst = f"rtmp://live.twitch.tv/app/{req.stream_key}" if not instance_id: instance_id = uuid.uuid4().hex[:8] name = req.name or instance_id if not telnet_port: telnet_port = find_free_port() if not http_port: http_port = find_free_port() # If path is a directory, gather all media files and sort them. Otherwise just use it as a single file. input_items = [] if video_path.is_dir(): media_exts = {".mp4", ".mkv", ".avi", ".mov", ".m4v", ".flv", ".mp3", ".wav", ".aac"} media_files = [f for f in video_path.iterdir() if f.is_file() and f.suffix.lower() in media_exts] if not media_files: raise HTTPException(status_code=400, detail="No streamable media files found in directory") # Sorting if req.sort_by == "ctime_oldest": media_files.sort(key=lambda f: f.stat().st_ctime) elif req.sort_by == "ctime_newest": media_files.sort(key=lambda f: f.stat().st_ctime, reverse=True) else: # Default: Natural Alphanumeric Sort def natural_keys(path): text = path.name return [int(c) if c.isdigit() else c.lower() for c in re.split(r'(\d+)', text)] media_files.sort(key=natural_keys) input_items = [str(f.resolve()) for f in media_files] else: input_items = [str(video_path)] # Generate dynamic SOUT options based on request params transcode_opts = ( f"vcodec={req.vcodec},vb={req.video_bitrate}," f"scale={req.scale},acodec={req.acodec},ab={req.audio_bitrate}," f"channels=2,samplerate={req.samplerate},fps={req.fps}" ) if req.preset or req.keyint: venc_opts = [] if req.preset: venc_opts.append(f"preset={req.preset}") if req.keyint: venc_opts.append(f"keyint={req.keyint}") transcode_opts += f",venc=x264{{{','.join(venc_opts)}}}" if req.sub_track is not None: transcode_opts += ",soverlay" sout = ( f"#transcode{{{transcode_opts}}}" f":std{{access=rtmp,mux=flv,dst={dst}}}" ) args = [ VLC_EXECUTABLE, ] args.extend(input_items) args.extend([ f"--sout={sout}", "--intf", "telnet", "--telnet-password", PASSWORD, "--telnet-port", str(telnet_port), "--extraintf", "http", "--http-password", PASSWORD, "--http-port", str(http_port), "--no-sout-all", "--sout-keep", "-vv", ]) if req.loop: args.append("--loop") if req.audio_track is not None: args.append(f"--audio-track={req.audio_track}") if req.sub_track is not None: args.append(f"--sub-track={req.sub_track}") if req.start_time is not None and req.start_time > 0: args.append(f"--start-time={req.start_time}") # Reuse existing instance configuration if reconfiguring if instance_id in self.instances: instance = self.instances[instance_id] instance.video_path = str(video_path) instance.status = "starting" instance.process = None instance.log_lines.clear() else: instance = VLCInstance(instance_id, name, str(video_path), telnet_port, http_port) instance.start_req = req import traceback print("--- Launching VLC ---") print(f"Executable: {VLC_EXECUTABLE}") print(f"Resolved executable: {shutil.which(VLC_EXECUTABLE)}") print(f"Arguments: {args}") try: process = await asyncio.to_thread( subprocess.Popen, args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) print(f"Successfully launched VLC. PID: {process.pid}") except Exception as e: print(f"Exception while launching VLC: {e}") traceback.print_exc() raise HTTPException(status_code=500, detail=f"Failed to launch VLC: {e}") instance.process = process instance.started_at = time.time() instance._reader_task = asyncio.create_task(instance._read_logs()) self.instances[instance_id] = instance ok = await self._wait_for_telnet(instance, timeout=8) instance.status = "running" if ok else "error" if ok: # Headless VLC often starts muted (volume 0) when no soundcard is detected. # Explicitly initialize VLC volume to 256 (100%) so stream audio works. try: await asyncio.to_thread( requests.get, f"http://localhost:{instance.http_port}/requests/status.json?command=volume&val=256", auth=("", PASSWORD), timeout=2, ) except Exception: pass if not ok: instance.log_lines.append("Telnet interface did not become ready in time.") return instance async def _wait_for_telnet(self, instance: VLCInstance, timeout: float = 8) -> bool: deadline = time.time() + timeout while time.time() < deadline: if instance.process.poll() is not None: return False try: if await asyncio.to_thread(self._telnet_probe, instance.telnet_port): return True except Exception: pass await asyncio.sleep(0.5) return False @staticmethod def _telnet_probe(port: int) -> bool: tn = None try: tn = telnetlib.Telnet(TELNET_HOST, port, timeout=2) tn.read_until(b"Password: ", timeout=2) tn.write(PASSWORD.encode("ascii") + b"\n") tn.read_until(b"> ", timeout=2) return True except Exception: return False finally: if tn: tn.close() def get(self, instance_id: str) -> VLCInstance: inst = self.instances.get(instance_id) if not inst: raise HTTPException(status_code=404, detail=f"No such instance: {instance_id}") return inst def list(self) -> List[VLCInstance]: return list(self.instances.values()) async def stop(self, instance_id: str, force: bool = False) -> VLCInstance: inst = self.get(instance_id) if inst.process is None or inst.process.poll() is not None: inst.status = "stopped" if inst.process is None else "exited" return inst try: if force: inst.process.kill() else: inst.process.terminate() try: await asyncio.wait_for(asyncio.to_thread(inst.process.wait), timeout=6) except asyncio.TimeoutError: if not force: inst.process.kill() await asyncio.wait_for(asyncio.to_thread(inst.process.wait), timeout=6) inst.status = "killed" if force else "stopped" except ProcessLookupError: inst.status = "stopped" except Exception as e: inst.log_lines.append(f"[stop error] {e}") raise HTTPException(status_code=500, detail=f"Failed to stop instance: {e}") return inst def remove(self, instance_id: str): self.instances.pop(instance_id, None) manager = VLCManager() def _telnet_exec(port: int, command: str, timeout: float = 5) -> str: tn = telnetlib.Telnet(TELNET_HOST, port, timeout=timeout) try: tn.read_until(b"Password: ", timeout=timeout) tn.write(PASSWORD.encode("ascii") + b"\n") tn.read_until(b"> ", timeout=timeout) tn.write(command.encode("ascii", errors="replace") + b"\n") return tn.read_until(b"> ", timeout=timeout).decode("ascii", errors="replace") finally: tn.close() # ---------------- Routes ---------------- # @app.get("/") # async def root(): # return {"message": "VLC Control API"} @app.get("/movies/probe/{path:path}") async def probe_movie(path: str): import json target = (BASE_MOVIES_DIR / path).resolve() try: target.relative_to(BASE_MOVIES_DIR) except ValueError: raise HTTPException(status_code=400, detail="Path escapes movies directory") if not target.exists(): raise HTTPException(status_code=404, detail="Path not found") if target.is_dir(): raise HTTPException(status_code=400, detail="Path is a directory") cmd = [ "ffprobe", "-v", "error", "-show_entries", "stream=index,codec_type,codec_name:stream_tags=language,title", "-of", "json", str(target) ] try: process = await asyncio.to_thread( subprocess.run, cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True ) data = json.loads(process.stdout) except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to probe file: {e}") streams = data.get("streams", []) audio_tracks = [] sub_tracks = [] audio_index = 0 sub_index = 0 for stream in streams: t = stream.get("codec_type") tags = stream.get("tags", {}) language = tags.get("language", "und") title = tags.get("title", "") codec = stream.get("codec_name", "") info = { "vlc_index": None, "ffprobe_index": stream.get("index"), "codec": codec, "language": language, "title": title } if t == "audio": info["vlc_index"] = audio_index audio_tracks.append(info) audio_index += 1 elif t == "subtitle": info["vlc_index"] = sub_index sub_tracks.append(info) sub_index += 1 return { "audio": audio_tracks, "subtitle": sub_tracks } @app.get("/movies") @app.get("/movies/{path:path}") async def get_movies(path: str = ""): target = (BASE_MOVIES_DIR / path).resolve() try: target.relative_to(BASE_MOVIES_DIR) except ValueError: raise HTTPException(status_code=400, detail="Path escapes movies directory") if not target.exists(): raise HTTPException(status_code=404, detail="Path not found") if not target.is_dir(): raise HTTPException(status_code=400, detail="Path is not a directory") try: subfolders = [f.name for f in os.scandir(target) if f.is_dir() and not f.name.startswith(".")] files = [f.name for f in os.scandir(target) if not f.is_dir() and not f.name.startswith(".")] except PermissionError: raise HTTPException(status_code=403, detail=f"Permission denied accessing directory: {target}") except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to scan directory: {e}") return {"message": {"files": files, "subfolders": subfolders}} @app.post("/vlc/start", response_model=InstanceInfo) async def start_vlc(req: StartVLCRequest): instance = await manager.start(req) return instance.to_info() @app.post("/vlc/{instance_id}/reconfigure", response_model=InstanceInfo) async def reconfigure_instance(instance_id: str, req: StartVLCRequest): inst = manager.get(instance_id) # Stop the current process await manager.stop(instance_id, force=True) # Start it again on the same ports! new_inst = await manager.start( req, instance_id=instance_id, telnet_port=inst.telnet_port, http_port=inst.http_port ) return new_inst.to_info() @app.get("/vlc/instances", response_model=List[InstanceInfo]) async def list_instances(): return [i.to_info() for i in manager.list()] @app.get("/vlc/{instance_id}", response_model=InstanceInfo) async def get_instance(instance_id: str): return manager.get(instance_id).to_info() @app.get("/vlc/{instance_id}/logs") async def get_logs(instance_id: str, lines: int = 50): inst = manager.get(instance_id) return {"status": "success", "message": list(inst.log_lines)[-lines:]} @app.post("/vlc/{instance_id}/stop", response_model=InstanceInfo) async def stop_instance(instance_id: str): return (await manager.stop(instance_id, force=False)).to_info() @app.post("/vlc/{instance_id}/kill", response_model=InstanceInfo) async def kill_instance(instance_id: str): return (await manager.stop(instance_id, force=True)).to_info() @app.delete("/vlc/{instance_id}") async def delete_instance(instance_id: str): inst = manager.get(instance_id) if inst.process and inst.process.poll() is None: await manager.stop(instance_id, force=True) manager.remove(instance_id) return {"status": "success", "message": f"Removed instance {instance_id}"} @app.post("/vlc/{instance_id}/exec") async def exec_command(instance_id: str, body: ExecRequest): inst = manager.get(instance_id) if inst.process is None or inst.process.poll() is not None: raise HTTPException(status_code=409, detail="Instance is not running") try: output = await asyncio.to_thread(_telnet_exec, inst.telnet_port, body.command) return {"status": "success", "message": output} except (ConnectionRefusedError, OSError) as e: raise HTTPException(status_code=502, detail=f"Could not connect to VLC telnet: {e}") except Exception as e: raise HTTPException(status_code=500, detail=f"Exec failed: {e}") @app.get("/vlc/{instance_id}/status") async def instance_status(instance_id: str): inst = manager.get(instance_id) if inst.process is None or inst.process.poll() is not None: return {"status": "error", "message": "Instance is not running"} try: res = await asyncio.to_thread( requests.get, f"http://localhost:{inst.http_port}/requests/status.json", auth=("", PASSWORD), timeout=5, ) return {"status": "success", "message": res.json()} except Exception as e: return {"status": "error", "message": f"Could not connect to VLC HTTP interface. Error: {e}"} @app.post("/vlc/{instance_id}/volume") async def change_volume(instance_id: str, val: str): inst = manager.get(instance_id) if inst.process is None or inst.process.poll() is not None: raise HTTPException(status_code=409, detail="Instance is not running") try: res = await asyncio.to_thread( requests.get, f"http://localhost:{inst.http_port}/requests/status.json?command=volume&val={val}", auth=("", PASSWORD), timeout=5, ) return {"status": "success", "message": res.json()} except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to change volume: {e}") @app.post("/vlc/{instance_id}/track") async def change_track(instance_id: str, type: str, val: int): inst = manager.get(instance_id) if inst.process is None or inst.process.poll() is not None: raise HTTPException(status_code=409, detail="Instance is not running") command = "audio_track" if type == "audio" else "subtitle_track" try: res = await asyncio.to_thread( requests.get, f"http://localhost:{inst.http_port}/requests/status.json?command={command}&val={val}", auth=("", PASSWORD), timeout=5, ) return {"status": "success", "message": res.json()} except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to change track: {e}") # ---------------- Aria2 / Explorer API routes ---------------- @app.get("/aria2/downloads") async def get_downloads(): try: downloads = aria2_manager.get_downloads() results = [] for dl in downloads: total = dl.total_length completed = dl.completed_length pct = (completed / total * 100) if total > 0 else 0 results.append({ "gid": dl.gid, "name": dl.name, "status": dl.status, "total_length": total, "completed_length": completed, "progress": round(pct, 2), "download_speed": dl.download_speed, "download_speed_str": dl.download_speed_str, "eta": dl.eta, "files": [str(dl.file_path)] if dl.file_path else [], "error_message": dl.error_message }) return {"status": "success", "downloads": results} except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to fetch downloads: {e}") @app.post("/aria2/add") async def add_download(req: AddDownloadRequest): try: dl = aria2_manager.add(req.uri) return {"status": "success", "gid": dl.gid} except Exception as e: raise HTTPException(status_code=400, detail=f"Failed to add download: {e}") @app.post("/aria2/pause/{gid}") async def pause_download(gid: str): try: aria2_manager.pause(gid) return {"status": "success"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.post("/aria2/resume/{gid}") async def resume_download(gid: str): try: aria2_manager.resume(gid) return {"status": "success"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.post("/aria2/remove/{gid}") async def remove_download(gid: str): try: aria2_manager.remove(gid) return {"status": "success"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.post("/aria2/purge") async def purge_downloads(): try: aria2_manager.autopurge() return {"status": "success"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) # ---------------- Acer Scraper APIs ---------------- API_BASE_URL = 'https://api2.acermovies.fun/api' DEFAULT_HEADERS = { "Accept": "*/*", "Accept-Language": "en-US,en;q=0.9", "Content-Type": "application/json", "Origin": "https://acermovies.fun", "Referer": "https://acermovies.fun/", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36 Edg/150.0.0.0", "sec-ch-ua": '"Not;A=Brand";v="8", "Chromium";v="150", "Microsoft Edge";v="150"', "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": '"Windows"', "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", "sec-fetch-site": "same-site" } @app.post("/acer/search") async def acer_search(req: SearchRequest): try: res = await asyncio.to_thread( requests.post, f"{API_BASE_URL}/search", headers=DEFAULT_HEADERS, json={"searchQuery": req.query}, timeout=10 ) res.raise_for_status() return res.json() except Exception as e: raise HTTPException(status_code=500, detail=f"Search failed: {e}") @app.post("/acer/qualities") async def acer_qualities(req: SourceQualityRequest): try: res = await asyncio.to_thread( requests.post, f"{API_BASE_URL}/sourceQuality", headers=DEFAULT_HEADERS, json={"url": req.url}, timeout=10 ) res.raise_for_status() return res.json() except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to fetch qualities: {e}") @app.post("/acer/episodes") async def acer_episodes(req: SourceEpisodesRequest): try: res = await asyncio.to_thread( requests.post, f"{API_BASE_URL}/sourceEpisodes", headers=DEFAULT_HEADERS, json={"url": req.url}, timeout=10 ) res.raise_for_status() return res.json() except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to fetch episodes: {e}") @app.post("/acer/download") async def acer_download(req: AcerDownloadRequest): try: # 1. Resolve sourceUrl res = await asyncio.to_thread( requests.post, f"{API_BASE_URL}/sourceUrl", headers=DEFAULT_HEADERS, json={"url": req.url, "seriesType": req.series_type}, timeout=15 ) res.raise_for_status() data = res.json() direct_url = data.get("sourceUrl") if not direct_url: raise HTTPException(status_code=400, detail="Could not resolve download URL") # Decode URL direct_url = unquote(direct_url) # 2. Add to downloader dl = aria2_manager.add(direct_url, filename=req.filename, subfolder=req.subfolder) return {"status": "success", "gid": dl.gid, "filename": dl.name} except Exception as e: raise HTTPException(status_code=500, detail=f"Download queue failed: {e}") @app.get("/config") async def get_config(): return { "status": "success", "default_movies_dir": str(default_movies_dir), "current_movies_dir": str(BASE_MOVIES_DIR), } @app.post("/config") async def update_config(req: UpdateConfigRequest): global BASE_MOVIES_DIR target = Path(req.movies_dir).expanduser().resolve() if not target.exists(): if req.create_if_missing: try: target.mkdir(parents=True, exist_ok=True) except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to create directory: {e}") else: raise HTTPException(status_code=400, detail="Directory does not exist") BASE_MOVIES_DIR = target return { "status": "success", "current_movies_dir": str(BASE_MOVIES_DIR), "message": f"Updated active movies directory to {BASE_MOVIES_DIR}" } @app.get("/explorer") @app.get("/explorer/{path:path}") async def explorer_list(path: str = ""): target = (BASE_MOVIES_DIR / path).resolve() try: target.relative_to(BASE_MOVIES_DIR) except ValueError: raise HTTPException(status_code=400, detail="Access denied") if not target.exists(): raise HTTPException(status_code=404, detail="Path not found") if not target.is_dir(): raise HTTPException(status_code=400, detail="Not a directory") entries = [] try: for item in os.scandir(target): if item.name.startswith("."): continue stat = item.stat() relative = str(Path(item.path).relative_to(BASE_MOVIES_DIR)).replace("\\", "/") is_directory = item.is_dir() size_bytes = stat.st_size if not is_directory else 0 if is_directory: size_str = "dir" else: if size_bytes >= 1024**3: size_str = f"{size_bytes / 1024**3:.2f} GB" elif size_bytes >= 1024**2: size_str = f"{size_bytes / 1024**2:.2f} MB" elif size_bytes >= 1024: size_str = f"{size_bytes / 1024:.2f} KB" else: size_str = f"{size_bytes} B" entries.append({ "name": item.name, "type": "directory" if is_directory else "file", "size": size_bytes, "size_str": size_str, "modified_at": stat.st_mtime, "path": relative }) except PermissionError: raise HTTPException(status_code=403, detail="Permission denied") entries.sort(key=lambda x: (x["type"] != "directory", x["name"].lower())) return {"status": "success", "entries": entries, "current_path": path} @app.post("/explorer/delete") async def explorer_delete(req: DeleteFileRequest): target = (BASE_MOVIES_DIR / req.path).resolve() try: target.relative_to(BASE_MOVIES_DIR) except ValueError: raise HTTPException(status_code=400, detail="Access denied") if not target.exists(): raise HTTPException(status_code=404, detail="File or directory not found") try: if target.is_dir(): shutil.rmtree(target) else: target.unlink() return {"status": "success", "message": f"Successfully deleted {req.path}"} except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to delete: {e}") from fastapi import UploadFile, Form import zipfile class CreateFolderRequest(BaseModel): parent_path: str folder_name: str class RenameRequest(BaseModel): old_path: str new_name: str class CopyMoveRequest(BaseModel): source_paths: List[str] target_dir: str class ZipRequest(BaseModel): paths: List[str] target_zip_name: str current_dir: str class UnzipRequest(BaseModel): zip_path: str target_dir: str class ResumePositionRequest(BaseModel): file_path: str resume_position: int # Watch History JSON file storage helpers HISTORY_FILE = Path.home() / ".watchparty_history.json" def _load_history() -> List[dict]: if not HISTORY_FILE.exists(): return [] try: import json with open(HISTORY_FILE, "r", encoding="utf-8") as f: return json.load(f) except Exception: return [] def _save_history(history: List[dict]): try: import json with open(HISTORY_FILE, "w", encoding="utf-8") as f: json.dump(history, f, indent=2, ensure_ascii=False) except Exception: pass @app.get("/history") async def get_history(): return {"status": "success", "history": _load_history()} @app.post("/history/resume") async def update_resume_position(req: ResumePositionRequest): history = _load_history() history = [item for item in history if item.get("file_path") != req.file_path] history.insert(0, { "file_path": req.file_path, "last_streamed": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "resume_position": req.resume_position }) _save_history(history[:50]) return {"status": "success"} @app.post("/explorer/mkdir") async def explorer_mkdir(req: CreateFolderRequest): parent = (BASE_MOVIES_DIR / req.parent_path).resolve() try: parent.relative_to(BASE_MOVIES_DIR) except ValueError: raise HTTPException(status_code=400, detail="Access denied") # Sanitize folder name sanitized = re.sub(r'[\\/*?:"<>|]', "", req.folder_name).strip() if not sanitized: raise HTTPException(status_code=400, detail="Invalid folder name") target = parent / sanitized try: target.mkdir(parents=True, exist_ok=True) return {"status": "success", "message": f"Created folder {sanitized}"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.post("/explorer/rename") async def explorer_rename(req: RenameRequest): old_target = (BASE_MOVIES_DIR / req.old_path).resolve() try: old_target.relative_to(BASE_MOVIES_DIR) except ValueError: raise HTTPException(status_code=400, detail="Access denied") if not old_target.exists(): raise HTTPException(status_code=404, detail="Path not found") sanitized = re.sub(r'[\\/*?:"<>|]', "", req.new_name).strip() if not sanitized: raise HTTPException(status_code=400, detail="Invalid target name") new_target = old_target.parent / sanitized if new_target.exists(): raise HTTPException(status_code=400, detail="Target name already exists") try: shutil.move(str(old_target), str(new_target)) return {"status": "success", "message": f"Renamed to {sanitized}"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.post("/explorer/copy") async def explorer_copy(req: CopyMoveRequest): target_dir = (BASE_MOVIES_DIR / req.target_dir).resolve() try: target_dir.relative_to(BASE_MOVIES_DIR) except ValueError: raise HTTPException(status_code=400, detail="Access denied") if not target_dir.exists() or not target_dir.is_dir(): raise HTTPException(status_code=400, detail="Target directory invalid") copied = [] errors = [] for sp in req.source_paths: src = (BASE_MOVIES_DIR / sp).resolve() try: src.relative_to(BASE_MOVIES_DIR) dest = target_dir / src.name if src.is_dir(): shutil.copytree(src, dest, dirs_exist_ok=True) else: shutil.copy2(src, dest) copied.append(sp) except Exception as e: errors.append(f"Failed to copy {sp}: {e}") return {"status": "success", "copied": copied, "errors": errors} @app.post("/explorer/move") async def explorer_move(req: CopyMoveRequest): target_dir = (BASE_MOVIES_DIR / req.target_dir).resolve() try: target_dir.relative_to(BASE_MOVIES_DIR) except ValueError: raise HTTPException(status_code=400, detail="Access denied") if not target_dir.exists() or not target_dir.is_dir(): raise HTTPException(status_code=400, detail="Target directory invalid") moved = [] errors = [] for sp in req.source_paths: src = (BASE_MOVIES_DIR / sp).resolve() try: src.relative_to(BASE_MOVIES_DIR) dest = target_dir / src.name shutil.move(str(src), str(dest)) moved.append(sp) except Exception as e: errors.append(f"Failed to move {sp}: {e}") return {"status": "success", "moved": moved, "errors": errors} @app.post("/explorer/upload") async def explorer_upload( target_dir: str = Form(""), file: UploadFile = Form(...) ): dest_dir = (BASE_MOVIES_DIR / target_dir).resolve() try: dest_dir.relative_to(BASE_MOVIES_DIR) except ValueError: raise HTTPException(status_code=400, detail="Access denied") if not dest_dir.exists() or not dest_dir.is_dir(): raise HTTPException(status_code=400, detail="Target directory invalid") dest_path = dest_dir / file.filename try: with open(dest_path, "wb") as f: f.write(await file.read()) return {"status": "success", "filename": file.filename} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.post("/explorer/zip") async def explorer_zip(req: ZipRequest): curr_dir = (BASE_MOVIES_DIR / req.current_dir).resolve() try: curr_dir.relative_to(BASE_MOVIES_DIR) except ValueError: raise HTTPException(status_code=400, detail="Access denied") zip_name = req.target_zip_name.strip() if not zip_name.endswith(".zip"): zip_name += ".zip" zip_path = curr_dir / zip_name try: with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: for sp in req.paths: src = (BASE_MOVIES_DIR / sp).resolve() src.relative_to(BASE_MOVIES_DIR) if src.is_dir(): for root, dirs, files in os.walk(src): for f in files: fpath = Path(root) / f arcname = fpath.relative_to(curr_dir) zipf.write(fpath, arcname) else: zipf.write(src, src.name) return {"status": "success", "zip_file": zip_name} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.post("/explorer/unzip") async def explorer_unzip(req: UnzipRequest): zip_target = (BASE_MOVIES_DIR / req.zip_path).resolve() target_dir = (BASE_MOVIES_DIR / req.target_dir).resolve() try: zip_target.relative_to(BASE_MOVIES_DIR) target_dir.relative_to(BASE_MOVIES_DIR) except ValueError: raise HTTPException(status_code=400, detail="Access denied") if not zip_target.exists() or zip_target.suffix.lower() != ".zip": raise HTTPException(status_code=400, detail="Invalid zip file source") try: with zipfile.ZipFile(zip_target, 'r') as zipf: zipf.extractall(target_dir) return {"status": "success", "message": f"Successfully unzipped to {req.target_dir}"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.on_event("shutdown") async def shutdown_event(): for task in list(aria2_manager._tasks.values()): task.cancel() for inst in list(manager.instances.values()): if inst.process and inst.process.poll() is None: try: inst.process.kill() except Exception: pass from fastapi.responses import FileResponse # Serve Next.js static export files from 'out' folder OUT_DIR = Path(__file__).parent / "out" @app.get('/') @app.get("/{catchall:path}", include_in_schema=False) async def serve_static(catchall: str): if not OUT_DIR.exists(): return {"message": "VLC Control API (Frontend static files 'out' folder not found)"} # Try to serve exact file requested file_path = (OUT_DIR / catchall).resolve() try: file_path.relative_to(OUT_DIR) if file_path.is_file(): return FileResponse(file_path) # Try appending .html (Next.js default static routing) html_file = file_path.with_suffix(".html") if html_file.is_file(): return FileResponse(html_file) if file_path.is_dir(): index_file = file_path / "index.html" if index_file.is_file(): return FileResponse(index_file) except ValueError: pass # Otherwise fall back to index.html (SPA routing) index_path = OUT_DIR / "index.html" if index_path.exists(): return FileResponse(index_path) return {"message": "VLC Control API (index.html not found)"}