mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
1214 lines
42 KiB
Python
1214 lines
42 KiB
Python
import asyncio
|
||
import datetime
|
||
import hashlib
|
||
import os
|
||
import platform
|
||
import re
|
||
import signal
|
||
import shutil
|
||
import socket
|
||
import struct
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
import urllib.parse
|
||
import uuid
|
||
from pathlib import Path
|
||
from typing import List, Optional, Tuple, Union
|
||
|
||
try:
|
||
import fcntl
|
||
except ImportError:
|
||
fcntl = None
|
||
|
||
import psutil
|
||
|
||
from app.schemas.dashboard import DashboardMemoryInfo as _SchemaDashboardMemoryInfo
|
||
from app.schemas.dashboard import DashboardSystemInfo as _SchemaDashboardSystemInfo
|
||
from app.schemas.dashboard import ProcessInfo as _SchemaProcessInfo
|
||
from app.runtime.version import get_app_version
|
||
from app.foundation.environment import (
|
||
is_aarch,
|
||
is_aarch64,
|
||
is_docker,
|
||
is_frozen,
|
||
is_macos,
|
||
is_windows,
|
||
is_x86_32,
|
||
is_x86_64,
|
||
)
|
||
|
||
|
||
# Linux amd64/arm64 UAPI: _IOR(BTRFS_IOCTL_MAGIC, 31, struct btrfs_ioctl_fs_info_args)
|
||
_BTRFS_IOC_FS_INFO = 0x8400941F
|
||
_BTRFS_FS_INFO_SIZE = 1024
|
||
_BTRFS_FSID_OFFSET = 16
|
||
_BTRFS_FSID_SIZE = 16
|
||
|
||
|
||
class SystemUtils:
|
||
"""
|
||
系统工具类,提供系统相关的操作和信息获取方法。
|
||
"""
|
||
|
||
_URL_WITH_USERINFO_PATTERN = re.compile(r"([A-Za-z][A-Za-z0-9+.-]*://[^\s]+)")
|
||
|
||
@staticmethod
|
||
def execute(cmd: str) -> str:
|
||
"""
|
||
执行命令,获得返回结果
|
||
"""
|
||
try:
|
||
with os.popen(cmd) as p:
|
||
return p.readline().strip()
|
||
except Exception as err:
|
||
print(str(err))
|
||
return ""
|
||
|
||
@staticmethod
|
||
def redact_url_userinfo(value: str) -> str:
|
||
"""
|
||
脱敏 URL 中的 userinfo,避免命令输出泄露镜像源或代理凭据。
|
||
"""
|
||
def replace(match: re.Match[str]) -> str:
|
||
candidate = match.group(1)
|
||
trailing = ""
|
||
while candidate and candidate[-1] in ".,;:)":
|
||
trailing = candidate[-1] + trailing
|
||
candidate = candidate[:-1]
|
||
parsed = urllib.parse.urlsplit(candidate)
|
||
if not parsed.username and not parsed.password:
|
||
return match.group(1)
|
||
host = parsed.netloc.rsplit("@", 1)[-1]
|
||
redacted = urllib.parse.urlunsplit((
|
||
parsed.scheme,
|
||
host,
|
||
parsed.path,
|
||
parsed.query,
|
||
parsed.fragment,
|
||
))
|
||
return f"{redacted}{trailing}"
|
||
|
||
return SystemUtils._URL_WITH_USERINFO_PATTERN.sub(replace, value or "")
|
||
|
||
@staticmethod
|
||
def redact_command_url_userinfo(command: list[str]) -> List[str]:
|
||
"""
|
||
脱敏命令参数中的 URL userinfo,供错误信息展示。
|
||
"""
|
||
return [SystemUtils.redact_url_userinfo(str(item)) for item in command]
|
||
|
||
@staticmethod
|
||
def execute_with_subprocess(
|
||
command: list,
|
||
env: Optional[dict[str, str]] = None,
|
||
safe_command: Optional[list[str]] = None,
|
||
timeout: Optional[float] = 300,
|
||
) -> Tuple[bool, str]:
|
||
"""
|
||
执行命令并捕获标准输出和错误输出,记录日志。
|
||
|
||
:param command: 要执行的命令,以列表形式提供
|
||
:param env: 传递给子进程的环境变量
|
||
:param safe_command: 用于错误信息展示的脱敏命令
|
||
:param timeout: 子进程最长运行时间,None 表示不设置超时
|
||
:return: (命令是否成功, 输出信息或错误信息)
|
||
"""
|
||
display_command = safe_command or command
|
||
try:
|
||
# 使用 subprocess.run 捕获标准输出和标准错误
|
||
result = subprocess.run(
|
||
command,
|
||
check=True,
|
||
text=True,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE,
|
||
env=env,
|
||
timeout=timeout,
|
||
)
|
||
# 合并 stdout 和 stderr
|
||
output = SystemUtils.redact_url_userinfo(result.stdout + result.stderr)
|
||
return True, output
|
||
except subprocess.CalledProcessError as e:
|
||
stdout = SystemUtils.redact_url_userinfo((e.stdout or "").strip())
|
||
stderr = SystemUtils.redact_url_userinfo((e.stderr or "").strip())
|
||
# 不同命令/兼容层可能把失败原因写入 stdout,失败时需要同时保留两路输出。
|
||
output_parts = []
|
||
if stdout:
|
||
output_parts.append(f"标准输出:{stdout}")
|
||
if stderr:
|
||
output_parts.append(f"错误输出:{stderr}")
|
||
if not output_parts:
|
||
output_parts.append("无标准输出或错误输出")
|
||
error_message = (
|
||
f"命令:{' '.join(SystemUtils.redact_command_url_userinfo(display_command))},执行失败,"
|
||
f"返回码:{e.returncode},{'; '.join(output_parts)}"
|
||
)
|
||
return False, error_message
|
||
except subprocess.TimeoutExpired as e:
|
||
stdout = SystemUtils.redact_url_userinfo(
|
||
SystemUtils._decode_subprocess_output(e.stdout)
|
||
)
|
||
stderr = SystemUtils.redact_url_userinfo(
|
||
SystemUtils._decode_subprocess_output(e.stderr)
|
||
)
|
||
output = ";".join(
|
||
part for part in (
|
||
f"标准输出:{stdout}" if stdout else "",
|
||
f"错误输出:{stderr}" if stderr else "",
|
||
) if part
|
||
)
|
||
if output:
|
||
output = f",{output}"
|
||
timeout_text = "未设置" if timeout is None else f"{timeout:g} 秒"
|
||
return False, (
|
||
f"命令:{' '.join(SystemUtils.redact_command_url_userinfo(display_command))},"
|
||
f"执行超时({timeout_text}){output}"
|
||
)
|
||
except Exception as e:
|
||
error_message = (
|
||
f"未知错误,命令:{' '.join(SystemUtils.redact_command_url_userinfo(display_command))},"
|
||
f"错误:{SystemUtils.redact_url_userinfo(str(e))}"
|
||
)
|
||
return False, error_message
|
||
|
||
@staticmethod
|
||
def _decode_subprocess_output(value: object) -> str:
|
||
"""把 subprocess 的文本或字节输出统一成可脱敏的字符串。"""
|
||
if value is None:
|
||
return ""
|
||
if isinstance(value, bytes):
|
||
return value.decode(errors="replace").strip()
|
||
return str(value).strip()
|
||
|
||
@staticmethod
|
||
async def _terminate_async_subprocess(
|
||
process: asyncio.subprocess.Process,
|
||
communication_task: "asyncio.Task[tuple[bytes, bytes]]",
|
||
grace_seconds: float = 5,
|
||
) -> None:
|
||
"""终止安装子进程及其同组子进程,并确保句柄已回收。"""
|
||
process_tree = SystemUtils._process_tree(process.pid)
|
||
if process.returncode is None:
|
||
try:
|
||
if os.name == "nt":
|
||
SystemUtils._signal_process_tree(process_tree)
|
||
process.terminate()
|
||
else:
|
||
os.killpg(process.pid, signal.SIGTERM)
|
||
except (ProcessLookupError, OSError):
|
||
pass
|
||
|
||
try:
|
||
await asyncio.wait_for(
|
||
asyncio.shield(communication_task),
|
||
timeout=grace_seconds,
|
||
)
|
||
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||
pass
|
||
|
||
# 管道可能在父进程退出时立即关闭,而后代仍在运行或忽略终止信号。
|
||
# 通信任务完成只代表 stdout/stderr 已收口,不能作为进程树已收口的依据。
|
||
try:
|
||
known_pids = {item.pid for item in process_tree}
|
||
process_tree.extend(
|
||
process_item
|
||
for process_item in SystemUtils._process_tree(process.pid)
|
||
if process_item.pid not in known_pids
|
||
)
|
||
except (ProcessLookupError, OSError):
|
||
pass
|
||
|
||
alive_processes = SystemUtils._alive_processes(process_tree)
|
||
if alive_processes or process.returncode is None:
|
||
if os.name == "nt":
|
||
try:
|
||
SystemUtils._signal_process_tree(alive_processes, force=True)
|
||
process.kill()
|
||
except (ProcessLookupError, OSError):
|
||
pass
|
||
else:
|
||
try:
|
||
os.killpg(process.pid, signal.SIGKILL)
|
||
except (ProcessLookupError, OSError):
|
||
pass
|
||
try:
|
||
SystemUtils._signal_process_tree(alive_processes, force=True)
|
||
except (ProcessLookupError, OSError):
|
||
pass
|
||
await SystemUtils._wait_process_tree(
|
||
process_tree,
|
||
grace_seconds,
|
||
)
|
||
if not communication_task.done():
|
||
communication_task.cancel()
|
||
await asyncio.gather(communication_task, return_exceptions=True)
|
||
try:
|
||
await process.wait()
|
||
except (ProcessLookupError, OSError):
|
||
pass
|
||
|
||
@staticmethod
|
||
def _process_tree(pid: int) -> list[psutil.Process]:
|
||
"""收集安装进程及其后代,避免构建进程继续写运行环境。"""
|
||
try:
|
||
parent = psutil.Process(pid)
|
||
return [parent, *parent.children(recursive=True)]
|
||
except (psutil.Error, OSError):
|
||
return []
|
||
|
||
@staticmethod
|
||
def _alive_processes(processes: list[psutil.Process]) -> list[psutil.Process]:
|
||
"""返回仍可能执行外部副作用的进程,忽略已退出和僵尸进程。"""
|
||
alive = []
|
||
seen_pids = set()
|
||
for process in processes:
|
||
if process.pid in seen_pids:
|
||
continue
|
||
seen_pids.add(process.pid)
|
||
try:
|
||
if process.is_running() and process.status() != psutil.STATUS_ZOMBIE:
|
||
alive.append(process)
|
||
except (psutil.Error, OSError):
|
||
continue
|
||
return alive
|
||
|
||
@staticmethod
|
||
async def _wait_process_tree(
|
||
processes: list[psutil.Process],
|
||
timeout: float,
|
||
) -> list[psutil.Process]:
|
||
"""在事件循环中有界等待整棵进程树退出,并返回残留进程。"""
|
||
deadline = asyncio.get_running_loop().time() + max(timeout, 0)
|
||
while True:
|
||
alive = SystemUtils._alive_processes(processes)
|
||
if not alive:
|
||
return []
|
||
remaining = deadline - asyncio.get_running_loop().time()
|
||
if remaining <= 0:
|
||
return alive
|
||
await asyncio.sleep(min(0.05, remaining))
|
||
|
||
@staticmethod
|
||
def _signal_process_tree(
|
||
processes: list[psutil.Process],
|
||
force: bool = False,
|
||
) -> None:
|
||
"""向进程树发送终止或强制结束信号。"""
|
||
for child in reversed(processes):
|
||
try:
|
||
(child.kill if force else child.terminate)()
|
||
except (psutil.Error, OSError):
|
||
continue
|
||
|
||
@staticmethod
|
||
async def execute_with_subprocess_async(
|
||
command: list,
|
||
env: Optional[dict[str, str]] = None,
|
||
safe_command: Optional[list[str]] = None,
|
||
timeout: Optional[float] = 300,
|
||
) -> Tuple[bool, str]:
|
||
"""异步执行可取消的子进程,超时或取消时回收整个进程组。"""
|
||
display_command = safe_command or command
|
||
process: Optional[asyncio.subprocess.Process] = None
|
||
communication_task: Optional["asyncio.Task[tuple[bytes, bytes]]"] = None
|
||
try:
|
||
creationflags = (
|
||
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||
if os.name == "nt"
|
||
else 0
|
||
)
|
||
process = await asyncio.create_subprocess_exec(
|
||
*command,
|
||
stdout=asyncio.subprocess.PIPE,
|
||
stderr=asyncio.subprocess.PIPE,
|
||
env=env,
|
||
start_new_session=os.name != "nt",
|
||
creationflags=creationflags,
|
||
)
|
||
communication_task = asyncio.create_task(process.communicate())
|
||
try:
|
||
stdout, stderr = await asyncio.wait_for(
|
||
asyncio.shield(communication_task),
|
||
timeout=timeout,
|
||
)
|
||
except asyncio.TimeoutError:
|
||
await SystemUtils._terminate_async_subprocess(
|
||
process,
|
||
communication_task,
|
||
)
|
||
timeout_text = "未设置" if timeout is None else f"{timeout:g} 秒"
|
||
return False, (
|
||
f"命令:{' '.join(SystemUtils.redact_command_url_userinfo(display_command))},"
|
||
f"执行超时({timeout_text})"
|
||
)
|
||
except asyncio.CancelledError:
|
||
await SystemUtils._terminate_async_subprocess(
|
||
process,
|
||
communication_task,
|
||
)
|
||
raise
|
||
|
||
output = SystemUtils.redact_url_userinfo(
|
||
SystemUtils._decode_subprocess_output(stdout)
|
||
+ SystemUtils._decode_subprocess_output(stderr)
|
||
)
|
||
if process.returncode == 0:
|
||
return True, output
|
||
return False, (
|
||
f"命令:{' '.join(SystemUtils.redact_command_url_userinfo(display_command))},"
|
||
f"执行失败,返回码:{process.returncode},"
|
||
f"{output or '无标准输出或错误输出'}"
|
||
)
|
||
except asyncio.CancelledError:
|
||
if process is not None and communication_task is not None:
|
||
await SystemUtils._terminate_async_subprocess(
|
||
process,
|
||
communication_task,
|
||
)
|
||
raise
|
||
except Exception as e:
|
||
if process is not None and communication_task is not None:
|
||
await SystemUtils._terminate_async_subprocess(
|
||
process,
|
||
communication_task,
|
||
)
|
||
error_message = (
|
||
f"未知错误,命令:{' '.join(SystemUtils.redact_command_url_userinfo(display_command))},"
|
||
f"错误:{SystemUtils.redact_url_userinfo(str(e))}"
|
||
)
|
||
return False, error_message
|
||
|
||
@staticmethod
|
||
def is_docker() -> bool:
|
||
"""
|
||
判断是否为Docker环境
|
||
"""
|
||
return is_docker()
|
||
|
||
@staticmethod
|
||
def is_synology() -> bool:
|
||
"""
|
||
判断是否为群晖系统
|
||
"""
|
||
if SystemUtils.is_windows():
|
||
return False
|
||
return "synology" in SystemUtils.execute('uname -a')
|
||
|
||
@staticmethod
|
||
def is_windows() -> bool:
|
||
"""
|
||
判断是否为Windows系统
|
||
"""
|
||
return is_windows()
|
||
|
||
@staticmethod
|
||
def is_frozen() -> bool:
|
||
"""
|
||
判断是否为冻结的二进制文件
|
||
"""
|
||
return is_frozen()
|
||
|
||
@staticmethod
|
||
def is_macos() -> bool:
|
||
"""
|
||
判断是否为MacOS系统
|
||
"""
|
||
return is_macos()
|
||
|
||
@staticmethod
|
||
def is_aarch64() -> bool:
|
||
"""
|
||
判断是否为ARM64架构
|
||
"""
|
||
return is_aarch64()
|
||
|
||
@staticmethod
|
||
def is_aarch() -> bool:
|
||
"""
|
||
判断是否为ARM32架构
|
||
"""
|
||
return is_aarch()
|
||
|
||
@staticmethod
|
||
def is_x86_64() -> bool:
|
||
"""
|
||
判断是否为AMD64架构
|
||
"""
|
||
return is_x86_64()
|
||
|
||
@staticmethod
|
||
def is_x86_32() -> bool:
|
||
"""
|
||
判断是否为AMD32架构
|
||
"""
|
||
return is_x86_32()
|
||
|
||
@staticmethod
|
||
def platform() -> str:
|
||
"""
|
||
获取系统平台
|
||
"""
|
||
if SystemUtils.is_windows():
|
||
return "Windows"
|
||
elif SystemUtils.is_macos():
|
||
return "MacOS"
|
||
elif SystemUtils.is_aarch64():
|
||
return "Arm64"
|
||
else:
|
||
return "Linux"
|
||
|
||
@staticmethod
|
||
def cpu_arch() -> str:
|
||
"""
|
||
获取CPU架构
|
||
"""
|
||
if SystemUtils.is_x86_64():
|
||
return "x86_64"
|
||
if SystemUtils.is_x86_32():
|
||
return "x86_32"
|
||
if SystemUtils.is_aarch64():
|
||
return "Arm64"
|
||
if SystemUtils.is_aarch():
|
||
return "Arm32"
|
||
return platform.machine()
|
||
|
||
@staticmethod
|
||
def copy(src: Path, dest: Path) -> Tuple[int, str]:
|
||
"""
|
||
复制
|
||
"""
|
||
try:
|
||
shutil.copy2(src, dest)
|
||
return 0, ""
|
||
except Exception as err:
|
||
return -1, str(err)
|
||
|
||
@staticmethod
|
||
def move(src: Path, dest: Path) -> Tuple[int, str]:
|
||
"""
|
||
移动
|
||
"""
|
||
try:
|
||
# 直接移动到目标路径,避免中间改名步骤触发目录监控
|
||
shutil.move(src, dest)
|
||
return 0, ""
|
||
except Exception as err:
|
||
return -1, str(err)
|
||
|
||
@staticmethod
|
||
def link(src: Path, dest: Path) -> Tuple[int, str]:
|
||
"""
|
||
硬链接
|
||
"""
|
||
try:
|
||
# 准备目标路径,增加后缀 .mp
|
||
tmp_path = dest.with_suffix(dest.suffix + ".mp")
|
||
# 检查目标路径是否已存在,如果存在则先unlink
|
||
if tmp_path.exists():
|
||
tmp_path.unlink()
|
||
tmp_path.hardlink_to(src)
|
||
# 硬链接完成,移除 .mp 后缀
|
||
shutil.move(tmp_path, dest)
|
||
return 0, ""
|
||
except Exception as err:
|
||
return -1, str(err)
|
||
|
||
@staticmethod
|
||
def softlink(src: Path, dest: Path) -> Tuple[int, str]:
|
||
"""
|
||
软链接
|
||
"""
|
||
try:
|
||
dest.symlink_to(src)
|
||
return 0, ""
|
||
except Exception as err:
|
||
return -1, str(err)
|
||
|
||
@staticmethod
|
||
def list_files(directory: Path, extensions: list = None,
|
||
min_filesize: int = 0, recursive: bool = True) -> List[Path]:
|
||
"""
|
||
获取目录下所有指定扩展名的文件(包括子目录)
|
||
:param directory: 指定的父目录
|
||
:param extensions: 需要包含的扩展名列表,例如 ['mkv', 'mp4']
|
||
:param min_filesize: 文件最低大小,单位 MB
|
||
:param recursive: 是否递归查找,可选参数,默认 True
|
||
:return: 文件 Path 列表
|
||
"""
|
||
|
||
if not min_filesize:
|
||
min_filesize = 0
|
||
|
||
if not directory.exists():
|
||
return []
|
||
|
||
if directory.is_file():
|
||
return [directory]
|
||
|
||
files = []
|
||
# 预编译正则表达式
|
||
if extensions:
|
||
pattern = re.compile(r".*(" + "|".join(extensions) + r")$", re.IGNORECASE)
|
||
else:
|
||
pattern = re.compile(r".*")
|
||
|
||
def _scan_directory(dir_path: Path, is_recursive: bool):
|
||
try:
|
||
with os.scandir(dir_path) as entries:
|
||
for entry in entries:
|
||
try:
|
||
if entry.is_file(follow_symlinks=False):
|
||
entry_path = Path(entry.path)
|
||
if (pattern.match(entry.name) and
|
||
(min_filesize <= 0 or entry.stat().st_size >= min_filesize * 1024 * 1024)):
|
||
files.append(entry_path)
|
||
elif entry.is_dir() and is_recursive:
|
||
_scan_directory(Path(entry.path), is_recursive)
|
||
except (OSError, PermissionError):
|
||
continue
|
||
except (OSError, PermissionError):
|
||
pass
|
||
|
||
_scan_directory(directory, recursive)
|
||
return files
|
||
|
||
@staticmethod
|
||
def unpack_archive(archive_file: Path, extract_dir: Path, archive_format: Optional[str] = None) -> None:
|
||
"""
|
||
解压压缩包,并补充标准库未覆盖的 RAR 格式支持。
|
||
|
||
:param archive_file: 待解压的压缩包文件
|
||
:param extract_dir: 解压目标目录
|
||
:param archive_format: 压缩包格式,未指定时按文件后缀推断
|
||
"""
|
||
if archive_format == "rar" or (not archive_format and archive_file.suffix.lower() == ".rar"):
|
||
SystemUtils.__unpack_rar_archive(archive_file, extract_dir)
|
||
return
|
||
shutil.unpack_archive(archive_file, extract_dir, format=archive_format)
|
||
|
||
@staticmethod
|
||
def __unpack_rar_archive(archive_file: Path, extract_dir: Path) -> None:
|
||
"""
|
||
调用系统解压工具处理 RAR 压缩包。
|
||
"""
|
||
extract_dir.mkdir(parents=True, exist_ok=True)
|
||
commands = []
|
||
if shutil.which("unar"):
|
||
commands.append([
|
||
"unar",
|
||
"-quiet",
|
||
"-force-overwrite",
|
||
"-output-directory",
|
||
extract_dir.as_posix(),
|
||
archive_file.as_posix(),
|
||
])
|
||
if shutil.which("unrar"):
|
||
commands.append([
|
||
"unrar",
|
||
"x",
|
||
"-o+",
|
||
"-idq",
|
||
archive_file.as_posix(),
|
||
f"{extract_dir.as_posix()}/",
|
||
])
|
||
if shutil.which("7z"):
|
||
commands.append([
|
||
"7z",
|
||
"x",
|
||
"-y",
|
||
f"-o{extract_dir.as_posix()}",
|
||
archive_file.as_posix(),
|
||
])
|
||
if shutil.which("bsdtar"):
|
||
commands.append([
|
||
"bsdtar",
|
||
"-xf",
|
||
archive_file.as_posix(),
|
||
"-C",
|
||
extract_dir.as_posix(),
|
||
])
|
||
if not commands:
|
||
raise RuntimeError("未找到可用的 RAR 解压工具,请安装 unar、unrar、7z 或 bsdtar")
|
||
|
||
errors = []
|
||
for command in commands:
|
||
try:
|
||
result = subprocess.run(
|
||
command,
|
||
check=False,
|
||
text=True,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE,
|
||
timeout=120,
|
||
)
|
||
except Exception as err:
|
||
errors.append(f"{command[0]}:{str(err)}")
|
||
continue
|
||
if result.returncode == 0:
|
||
return
|
||
output = (result.stderr or result.stdout or "").strip()
|
||
errors.append(f"{command[0]}:{output or f'返回码 {result.returncode}'}")
|
||
raise RuntimeError(f"RAR 压缩包解压失败:{';'.join(errors)}")
|
||
|
||
@staticmethod
|
||
def exits_files(directory: Path, extensions: list, min_filesize: int = 0, recursive: bool = True) -> bool:
|
||
"""
|
||
判断目录下是否存在指定扩展名的文件
|
||
|
||
:param directory: 指定的父目录
|
||
:param extensions: 需要包含的扩展名列表,例如 ['mkv', 'mp4']
|
||
:param min_filesize: 文件最低大小,单位 MB
|
||
:param recursive: 是否递归查找,可选参数,默认 True
|
||
:return: True存在 False不存在
|
||
"""
|
||
|
||
if not directory.exists():
|
||
return False
|
||
|
||
# 预编译正则表达式
|
||
if extensions:
|
||
pattern = re.compile(r".*(" + "|".join(extensions) + r")$", re.IGNORECASE)
|
||
else:
|
||
pattern = re.compile(r".*")
|
||
|
||
if directory.is_file():
|
||
# 检查单个文件是否符合条件
|
||
if extensions and not pattern.match(directory.name):
|
||
return False
|
||
if min_filesize > 0 and directory.stat().st_size < min_filesize * 1024 * 1024:
|
||
return False
|
||
return True
|
||
|
||
def _search_files(dir_path: Path, is_recursive: bool) -> bool:
|
||
try:
|
||
with os.scandir(dir_path) as entries:
|
||
for entry in entries:
|
||
try:
|
||
if entry.is_file(follow_symlinks=False):
|
||
# 检查文件是否符合条件
|
||
if (pattern.match(entry.name) and
|
||
(min_filesize <= 0 or entry.stat().st_size >= min_filesize * 1024 * 1024)):
|
||
return True
|
||
elif entry.is_dir() and is_recursive:
|
||
# 递归搜索子目录
|
||
if _search_files(Path(entry.path), is_recursive):
|
||
return True
|
||
except (OSError, PermissionError):
|
||
continue
|
||
except (OSError, PermissionError):
|
||
pass
|
||
return False
|
||
|
||
return _search_files(directory, recursive)
|
||
|
||
@staticmethod
|
||
def list_sub_files(directory: Path, extensions: list) -> List[Path]:
|
||
"""
|
||
列出当前目录下的所有指定扩展名的文件(不包括子目录)
|
||
"""
|
||
if not directory.exists():
|
||
return []
|
||
|
||
if directory.is_file():
|
||
return [directory]
|
||
|
||
files = []
|
||
|
||
# 预编译正则表达式
|
||
if extensions:
|
||
pattern = re.compile(r".*(" + "|".join(extensions) + r")$", re.IGNORECASE)
|
||
else:
|
||
pattern = re.compile(r".*")
|
||
|
||
try:
|
||
with os.scandir(directory) as entries:
|
||
for entry in entries:
|
||
if entry.is_file() and pattern.match(entry.name):
|
||
files.append(Path(entry.path))
|
||
except OSError:
|
||
pass
|
||
|
||
return files
|
||
|
||
@staticmethod
|
||
def list_sub_directory(directory: Path) -> List[Path]:
|
||
"""
|
||
列出当前目录下的所有子目录(不递归)
|
||
"""
|
||
if not directory.exists():
|
||
return []
|
||
|
||
if directory.is_file():
|
||
return []
|
||
|
||
dirs = []
|
||
|
||
# 遍历目录
|
||
for path in directory.iterdir():
|
||
if path.is_dir():
|
||
if not SystemUtils.is_windows() and path.name.startswith("."):
|
||
continue
|
||
if path.name == "@eaDir":
|
||
continue
|
||
dirs.append(path)
|
||
|
||
return dirs
|
||
|
||
@staticmethod
|
||
def list_sub_file(directory: Path) -> List[Path]:
|
||
"""
|
||
列出当前目录下的所有子目录和文件(不递归)
|
||
"""
|
||
if not directory.exists():
|
||
return []
|
||
|
||
if directory.is_file():
|
||
return [directory]
|
||
|
||
items = []
|
||
|
||
# 遍历目录
|
||
for path in directory.iterdir():
|
||
if path.is_file():
|
||
items.append(path)
|
||
|
||
return items
|
||
|
||
@staticmethod
|
||
def get_directory_size(path: Path) -> int:
|
||
"""
|
||
计算目录的大小
|
||
|
||
参数:
|
||
directory_path (Path): 目录路径
|
||
|
||
返回:
|
||
int: 目录的大小(以字节为单位)
|
||
"""
|
||
if not path or not path.exists():
|
||
return 0
|
||
|
||
def _calc_dir_size(dir_path):
|
||
total = 0
|
||
try:
|
||
with os.scandir(dir_path) as entries:
|
||
for entry in entries:
|
||
if entry.is_file():
|
||
total += entry.stat().st_size
|
||
elif entry.is_dir():
|
||
total += _calc_dir_size(entry.path)
|
||
except OSError:
|
||
pass
|
||
return total
|
||
|
||
return _calc_dir_size(path) if path.is_dir() else path.stat().st_size
|
||
|
||
@staticmethod
|
||
def _get_btrfs_fsid(dir_path: Path) -> Optional[bytes]:
|
||
"""读取目录所属 Btrfs 文件系统的 FSID,无法确认时返回 None。"""
|
||
if not sys.platform.startswith("linux") or fcntl is None \
|
||
or not (SystemUtils.is_x86_64() or SystemUtils.is_aarch64()):
|
||
return None
|
||
|
||
fd = None
|
||
try:
|
||
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_CLOEXEC", 0)
|
||
fd = os.open(dir_path, flags)
|
||
fs_info = bytearray(_BTRFS_FS_INFO_SIZE)
|
||
fcntl.ioctl(fd, _BTRFS_IOC_FS_INFO, fs_info, True)
|
||
if len(fs_info) != _BTRFS_FS_INFO_SIZE:
|
||
return None
|
||
num_devices = struct.unpack_from("=Q", fs_info, 8)[0]
|
||
fsid = bytes(fs_info[_BTRFS_FSID_OFFSET:_BTRFS_FSID_OFFSET + _BTRFS_FSID_SIZE])
|
||
if num_devices == 0 or fsid == bytes(_BTRFS_FSID_SIZE):
|
||
return None
|
||
return fsid
|
||
except (OSError, OverflowError, ValueError, struct.error):
|
||
return None
|
||
finally:
|
||
if fd is not None:
|
||
try:
|
||
os.close(fd)
|
||
except OSError:
|
||
pass
|
||
|
||
@staticmethod
|
||
def space_usage(dir_list: Union[Path, List[Path]], btrfs_fsid_dedup: bool = False) -> Tuple[float, float]:
|
||
"""
|
||
计算多个目录的总可用空间/剩余空间(单位:Byte),并去除重复磁盘
|
||
|
||
:param dir_list: 待统计的目录或目录列表
|
||
:param btrfs_fsid_dedup: 是否在 Linux amd64/arm64 下以 Btrfs FSID 辅助去重
|
||
|
||
默认保持原有的驱动器号/st_dev 去重。显式启用后,st_dev 仍作为基础依据,
|
||
FSID 仅用于合并同一 Btrfs 文件系统中的不同子卷;证据缺失或冲突时
|
||
保守回退 st_dev,允许多计而不猜测合并独立存储。
|
||
"""
|
||
if not dir_list:
|
||
return 0.0, 0.0
|
||
if not isinstance(dir_list, list):
|
||
dir_list = [dir_list]
|
||
|
||
use_btrfs_fsid = btrfs_fsid_dedup and sys.platform.startswith("linux") \
|
||
and (SystemUtils.is_x86_64() or SystemUtils.is_aarch64())
|
||
if not use_btrfs_fsid:
|
||
# 默认分支保留原有行为,不打开目录或调用 ioctl。
|
||
disk_set = set()
|
||
total_free_space = 0.0
|
||
total_space = 0.0
|
||
for dir_path in dir_list:
|
||
if not dir_path:
|
||
continue
|
||
if not dir_path.exists():
|
||
continue
|
||
if os.name == "nt":
|
||
disk = dir_path.drive
|
||
else:
|
||
disk = os.stat(dir_path).st_dev
|
||
if disk not in disk_set:
|
||
disk_set.add(disk)
|
||
total_space += SystemUtils.total_space(dir_path)
|
||
total_free_space += SystemUtils.free_space(dir_path)
|
||
return total_space, total_free_space
|
||
|
||
total_free_space = 0.0
|
||
total_space = 0.0
|
||
# 先按 st_dev 恢复原有去重语义,再用组内唯一可信的 FSID 合并 Btrfs 子卷。
|
||
disk_groups = {}
|
||
for dir_path in dir_list:
|
||
if not dir_path or not dir_path.exists():
|
||
continue
|
||
st_dev = os.stat(dir_path).st_dev
|
||
if st_dev not in disk_groups:
|
||
disk_groups[st_dev] = (dir_path, set())
|
||
btrfs_fsid = SystemUtils._get_btrfs_fsid(dir_path)
|
||
if btrfs_fsid:
|
||
disk_groups[st_dev][1].add(btrfs_fsid)
|
||
|
||
disk_set = set()
|
||
for st_dev, (dir_path, fsids) in disk_groups.items():
|
||
# 同一 st_dev 出现冲突 FSID 可能是挂载变化导致的不一致快照,
|
||
# 此时禁止跨 st_dev 合并,避免少计独立存储。
|
||
disk = ("btrfs_fsid", next(iter(fsids))) if len(fsids) == 1 else ("st_dev", st_dev)
|
||
if disk not in disk_set:
|
||
disk_set.add(disk)
|
||
total_space += SystemUtils.total_space(dir_path)
|
||
total_free_space += SystemUtils.free_space(dir_path)
|
||
return total_space, total_free_space
|
||
|
||
@staticmethod
|
||
def free_space(path: Path) -> float:
|
||
"""
|
||
获取指定路径的剩余空间(单位:Byte)
|
||
"""
|
||
if not os.path.exists(path):
|
||
return 0.0
|
||
return psutil.disk_usage(str(path)).free
|
||
|
||
@staticmethod
|
||
def total_space(path: Path) -> float:
|
||
"""
|
||
获取指定路径的总空间(单位:Byte)
|
||
"""
|
||
if not os.path.exists(path):
|
||
return 0.0
|
||
return psutil.disk_usage(str(path)).total
|
||
|
||
@staticmethod
|
||
def processes() -> List[_SchemaProcessInfo]:
|
||
"""
|
||
获取所有进程
|
||
"""
|
||
processes = []
|
||
for proc in psutil.process_iter(['pid', 'name', 'create_time', 'memory_info', 'status']):
|
||
try:
|
||
if proc.status() != psutil.STATUS_ZOMBIE:
|
||
runtime = datetime.datetime.now() - datetime.datetime.fromtimestamp(
|
||
int(getattr(proc, 'create_time', 0)()))
|
||
mem_info = getattr(proc, 'memory_info', None)()
|
||
if mem_info is not None:
|
||
mem_mb = round(mem_info.rss / (1024 * 1024), 1)
|
||
processes.append(_SchemaProcessInfo(
|
||
pid=proc.pid, name=proc.name(), run_time=runtime.seconds, memory=mem_mb
|
||
))
|
||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||
pass
|
||
return processes
|
||
|
||
@staticmethod
|
||
def dashboard_system_info() -> _SchemaDashboardSystemInfo:
|
||
"""
|
||
获取仪表板展示所需的系统摘要信息。
|
||
|
||
运行时间以当前 MoviePilot 进程为基准,避免宿主机或容器长期运行时间
|
||
掩盖服务最近一次重启。
|
||
"""
|
||
return _SchemaDashboardSystemInfo(
|
||
hostname=socket.gethostname(),
|
||
operating_system=SystemUtils._operating_system_name(),
|
||
runtime=max(0, int(time.time() - psutil.Process().create_time())),
|
||
version=get_app_version(),
|
||
)
|
||
|
||
@staticmethod
|
||
def _operating_system_name() -> str:
|
||
"""返回适合在仪表板展示的操作系统名称。"""
|
||
if SystemUtils.is_windows():
|
||
return platform.platform()
|
||
if SystemUtils.is_macos():
|
||
version = platform.mac_ver()[0]
|
||
return f"macOS {version}".strip()
|
||
|
||
try:
|
||
operating_system = platform.freedesktop_os_release()
|
||
return operating_system.get("PRETTY_NAME") or operating_system.get("NAME") or platform.platform()
|
||
except OSError:
|
||
return platform.platform()
|
||
|
||
@staticmethod
|
||
def is_bluray_dir(dir_path: Path) -> bool:
|
||
"""
|
||
判断是否为蓝光原盘目录
|
||
|
||
(该方法已弃用,改用`StorageChain().is_bluray_folder)`
|
||
"""
|
||
if not dir_path.is_dir():
|
||
return False
|
||
# 蓝光原盘目录必备的文件或文件夹
|
||
required_files = ['BDMV', 'CERTIFICATE']
|
||
# 检查目录下是否存在所需文件或文件夹
|
||
for item in required_files:
|
||
if (dir_path / item).exists():
|
||
return True
|
||
return False
|
||
|
||
@staticmethod
|
||
def get_windows_drives():
|
||
"""
|
||
获取Windows所有盘符
|
||
"""
|
||
vols = []
|
||
for i in range(65, 91):
|
||
vol = chr(i) + ':'
|
||
if os.path.isdir(vol):
|
||
vols.append(vol)
|
||
return vols
|
||
|
||
@staticmethod
|
||
def cpu_usage():
|
||
"""
|
||
获取CPU使用率
|
||
"""
|
||
return psutil.cpu_percent()
|
||
|
||
@staticmethod
|
||
def memory_usage() -> _SchemaDashboardMemoryInfo:
|
||
"""
|
||
获取当前 MoviePilot 进程内存与系统缓存、可用和总内存信息。
|
||
"""
|
||
memory = psutil.virtual_memory()
|
||
total = max(0, int(memory.total))
|
||
used = max(0, int(psutil.Process().memory_info().rss))
|
||
cached = max(
|
||
0,
|
||
int(getattr(memory, "cached", 0) or 0)
|
||
+ int(getattr(memory, "buffers", 0) or 0),
|
||
)
|
||
available = max(0, int(memory.available))
|
||
usage = used / total * 100 if total else 0.0
|
||
return _SchemaDashboardMemoryInfo(
|
||
total=total,
|
||
used=used,
|
||
cached=cached,
|
||
available=available,
|
||
usage=usage,
|
||
)
|
||
|
||
@staticmethod
|
||
def network_usage() -> List[int]:
|
||
"""
|
||
获取当前网络流量(上行和下行流量,单位:bytes/s)
|
||
"""
|
||
import time
|
||
# 获取初始网络统计
|
||
net_io_1 = psutil.net_io_counters()
|
||
time.sleep(1) # 等待1秒
|
||
# 获取1秒后的网络统计
|
||
net_io_2 = psutil.net_io_counters()
|
||
|
||
# 计算1秒内的流量变化
|
||
upload_speed = net_io_2.bytes_sent - net_io_1.bytes_sent
|
||
download_speed = net_io_2.bytes_recv - net_io_1.bytes_recv
|
||
|
||
return [upload_speed, download_speed]
|
||
|
||
@staticmethod
|
||
def is_hardlink(src: Path, dest: Path) -> bool:
|
||
"""
|
||
判断是否为硬链接(可能无法支持宿主机挂载smb盘符映射docker的场景)
|
||
"""
|
||
try:
|
||
if not src.exists() or not dest.exists():
|
||
return False
|
||
if src.is_file():
|
||
# 如果是文件,直接比较文件
|
||
return src.samefile(dest)
|
||
else:
|
||
for src_file in src.glob("**/*"):
|
||
if src_file.is_dir():
|
||
continue
|
||
# 计算目标文件路径
|
||
relative_path = src_file.relative_to(src)
|
||
target_file = dest.joinpath(relative_path)
|
||
# 检查是否是硬链接
|
||
if not target_file.exists() or not src_file.samefile(target_file):
|
||
return False
|
||
return True
|
||
except Exception as e:
|
||
print(f"Error occurred: {e}")
|
||
return False
|
||
|
||
@staticmethod
|
||
def is_network_filesystem(
|
||
directory: Path, include_local_fuse: bool = False
|
||
) -> bool:
|
||
"""
|
||
检测是否为网络文件系统
|
||
:param directory: 目录路径
|
||
:param include_local_fuse: 是否将本地 FUSE 挂载视为挂载文件系统
|
||
:return: 是否为网络文件系统
|
||
"""
|
||
try:
|
||
system = platform.system()
|
||
if system == 'Linux':
|
||
# 检查挂载信息
|
||
result = subprocess.run(['df', '-T', str(directory)],
|
||
capture_output=True, text=True, timeout=5)
|
||
if result.returncode == 0:
|
||
output = result.stdout.lower()
|
||
# 以下本地文件系统含有fuse关键字
|
||
local_fs = [
|
||
"fuse.shfs", # Unraid
|
||
"zfuse.zfsv", # 极空间(zfuse.zfsv2、zfuse.zfsv3、...)
|
||
"fuseblk",
|
||
# TBD
|
||
]
|
||
if (
|
||
not include_local_fuse
|
||
and any(fs in output for fs in local_fs)
|
||
):
|
||
return False
|
||
network_fs = ['nfs', 'cifs', 'smbfs', 'fuse', 'sshfs', 'ftpfs']
|
||
return any(fs in output for fs in network_fs)
|
||
elif system == 'Darwin':
|
||
# macOS 检查
|
||
result = subprocess.run(['df', '-T', str(directory)],
|
||
capture_output=True, text=True, timeout=5)
|
||
if result.returncode == 0:
|
||
output = result.stdout.lower()
|
||
return (
|
||
'nfs' in output
|
||
or 'smbfs' in output
|
||
or (include_local_fuse and 'fuse' in output)
|
||
)
|
||
elif system == 'Windows':
|
||
# Windows 检查网络驱动器
|
||
return str(directory).startswith('\\\\')
|
||
except Exception as e:
|
||
print(f"Error occurred: {e}")
|
||
return False
|
||
|
||
@staticmethod
|
||
def is_same_disk(src: Path, dest: Path) -> bool:
|
||
"""
|
||
判断两个路径是否在同一磁盘
|
||
"""
|
||
if not src.exists() or not dest.exists():
|
||
return False
|
||
if os.name == "nt":
|
||
return src.drive == dest.drive
|
||
return os.stat(src).st_dev == os.stat(dest).st_dev
|
||
|
||
@staticmethod
|
||
def get_config_path(config_dir: Optional[str] = None) -> Path:
|
||
"""
|
||
获取配置路径
|
||
"""
|
||
configured = config_dir or os.getenv("CONFIG_DIR")
|
||
if configured:
|
||
return Path(configured)
|
||
if SystemUtils.is_docker():
|
||
return Path("/config")
|
||
if SystemUtils.is_frozen():
|
||
return Path(sys.executable).parent / "config"
|
||
return Path(__file__).resolve().parents[3] / "config"
|
||
|
||
@staticmethod
|
||
def get_env_path() -> Path:
|
||
"""
|
||
获取配置路径
|
||
"""
|
||
return SystemUtils.get_config_path() / "app.env"
|
||
|
||
@staticmethod
|
||
def clear(temp_path: Path, days: int):
|
||
"""
|
||
清理指定目录中指定天数前的文件,递归删除子文件及空文件夹
|
||
"""
|
||
if not temp_path.exists():
|
||
return
|
||
# 遍历目录及子目录中的所有文件和文件夹
|
||
for file in temp_path.rglob('*'):
|
||
# 如果是文件并且符合时间条件,则删除
|
||
if file.is_file() and (
|
||
datetime.datetime.now() - datetime.datetime.fromtimestamp(file.stat().st_mtime)).days > days:
|
||
file.unlink()
|
||
# 删除空的文件夹
|
||
for folder in sorted(temp_path.rglob('*'), reverse=True):
|
||
# 确保是空文件夹
|
||
if folder.is_dir() and not any(folder.iterdir()):
|
||
folder.rmdir()
|
||
|
||
@staticmethod
|
||
def generate_user_unique_id():
|
||
"""
|
||
根据优先级依次尝试生成稳定唯一ID:
|
||
1. 文件系统唯一标识符。
|
||
2. MAC 地址。
|
||
3. 主机名。
|
||
"""
|
||
|
||
def get_filesystem_unique_id():
|
||
"""
|
||
获取文件系统的唯一标识符。
|
||
使用根目录的设备号和 inode。
|
||
"""
|
||
try:
|
||
stat_info = os.stat("/")
|
||
fs_id = f"{stat_info.st_dev}-{stat_info.st_ino}"
|
||
return hashlib.sha256(fs_id.encode("utf-8")).hexdigest()
|
||
except Exception as e:
|
||
print(str(e))
|
||
return None
|
||
|
||
def get_mac_address_id():
|
||
"""
|
||
获取设备的 MAC 地址并生成唯一标识符。
|
||
"""
|
||
try:
|
||
mac_address = uuid.getnode()
|
||
if (mac_address >> 40) % 2: # 检查是否是虚拟MAC地址
|
||
raise ValueError("MAC地址可能是虚拟地址")
|
||
mac_str = f"{mac_address:012x}"
|
||
return hashlib.sha256(mac_str.encode("utf-8")).hexdigest()
|
||
except Exception as e:
|
||
print(str(e))
|
||
return None
|
||
|
||
for method in [get_filesystem_unique_id, get_mac_address_id]:
|
||
unique_id = method()
|
||
if unique_id:
|
||
return unique_id
|
||
return None
|