refactor: reorganize backend module boundaries

This commit is contained in:
jxxghp
2026-08-14 19:53:33 +08:00
parent fe0b444ceb
commit 369d7d6448
584 changed files with 2423 additions and 2275 deletions
+1
View File
@@ -0,0 +1 @@
"""操作系统、进程与运行资源适配器。"""
+29
View File
@@ -0,0 +1,29 @@
from pyvirtualdisplay import Display
from app.runtime.log import logger
from app.foundation.singleton import Singleton
from app.adapters.system.host import SystemUtils
import os
class DisplayHelper(metaclass=Singleton):
"""在容器环境中管理浏览器所需的虚拟显示。"""
def __init__(self):
"""仅在 Docker 内启动虚拟显示服务。"""
self._display = None
if not SystemUtils.is_docker():
return
try:
self._display = Display(visible=False, size=(1024, 768), extra_args=[os.environ['DISPLAY']])
self._display.start()
except Exception as err:
logger.error(f"DisplayHelper init error: {str(err)}")
def stop(self):
"""停止已经启动的虚拟显示服务。"""
if self._display:
logger.info("正在停止虚拟显示...")
self._display.stop()
logger.info("虚拟显示已停止")
+973
View File
@@ -0,0 +1,973 @@
import datetime
import hashlib
import os
import platform
import re
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 import schemas
from version import APP_VERSION
# 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(
pip_command: list,
env: Optional[dict[str, str]] = None,
safe_command: Optional[list[str]] = None,
) -> Tuple[bool, str]:
"""
执行命令并捕获标准输出和错误输出,记录日志。
:param pip_command: 要执行的命令,以列表形式提供
:param env: 传递给子进程的环境变量
:param safe_command: 用于错误信息展示的脱敏命令
:return: (命令是否成功, 输出信息或错误信息)
"""
display_command = safe_command or pip_command
try:
# 使用 subprocess.run 捕获标准输出和标准错误
result = subprocess.run(
pip_command,
check=True,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
)
# 合并 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 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 is_docker() -> bool:
"""
判断是否为Docker环境
"""
return Path("/.dockerenv").exists()
@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 os.name == "nt"
@staticmethod
def is_frozen() -> bool:
"""
判断是否为冻结的二进制文件
"""
return getattr(sys, 'frozen', False)
@staticmethod
def is_macos() -> bool:
"""
判断是否为MacOS系统
"""
return platform.system() == 'Darwin'
@staticmethod
def is_aarch64() -> bool:
"""
判断是否为ARM64架构
"""
return platform.machine().lower() in ('aarch64', 'arm64')
@staticmethod
def is_aarch() -> bool:
"""
判断是否为ARM32架构
"""
arch_name = platform.machine().lower()
return arch_name.startswith(('arm', 'aarch')) and arch_name not in ('aarch64', 'arm64')
@staticmethod
def is_x86_64() -> bool:
"""
判断是否为AMD64架构
"""
return platform.machine().lower() in ('amd64', 'x86_64')
@staticmethod
def is_x86_32() -> bool:
"""
判断是否为AMD32架构
"""
return platform.machine().lower() in ('i386', 'i686', 'x86', '386', '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"
elif SystemUtils.is_x86_32():
return "x86_32"
elif SystemUtils.is_aarch64():
return "Arm64"
elif SystemUtils.is_aarch():
return "Arm32"
else:
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[schemas.ProcessInfo]:
"""
获取所有进程
"""
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(schemas.ProcessInfo(
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() -> schemas.DashboardSystemInfo:
"""
获取仪表板展示所需的系统摘要信息。
运行时间以当前 MoviePilot 进程为基准,避免宿主机或容器长期运行时间
掩盖服务最近一次重启。
"""
return schemas.DashboardSystemInfo(
hostname=socket.gethostname(),
operating_system=SystemUtils._operating_system_name(),
runtime=max(0, int(time.time() - psutil.Process().create_time())),
version=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() -> schemas.DashboardMemoryInfo:
"""
获取当前 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 schemas.DashboardMemoryInfo(
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:
"""
获取配置路径
"""
if not config_dir:
config_dir = os.getenv("CONFIG_DIR")
if config_dir:
return Path(config_dir)
if SystemUtils.is_docker():
return Path("/config")
elif SystemUtils.is_frozen():
return Path(sys.executable).parent / "config"
else:
return Path(__file__).parents[2] / "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
+169
View File
@@ -0,0 +1,169 @@
from __future__ import annotations
import os
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
from urllib.parse import urlsplit, urlunsplit
PackageBackend = Literal["uv", "pip"]
@dataclass(frozen=True)
class PackageInstallRequest:
"""
Python 包安装请求,集中描述依赖文件、工具缓存、代理和本地 wheels 候选源。
"""
requirements_file: Path
python_bin: Path
find_links_dirs: list[Path] = field(default_factory=list)
constraints_file: Path | None = None
config_dir: Path = Path("/config")
package_cache_root: Path | None = None
pip_index_url: str | None = None
proxy_url: str | None = None
purpose: str = "plugin"
@dataclass(frozen=True)
class PackageInstallStrategy:
"""
单次安装尝试的完整执行信息,命令和日志展示命令分离以避免泄露凭据。
"""
strategy_name: str
backend: PackageBackend
command: list[str]
env: dict[str, str]
safe_log_command: list[str]
def redact_url(value: str) -> str:
"""
脱敏 URL 中的 userinfo,保留 scheme、host、path、query 便于定位镜像源。
"""
parsed = urlsplit(value)
if "@" not in parsed.netloc:
return value
host = parsed.netloc.rsplit("@", 1)[-1]
return urlunsplit((parsed.scheme, host, parsed.path, parsed.query, parsed.fragment))
def redact_command(command: list[str]) -> list[str]:
"""
脱敏命令参数中的 URL 凭据,用于日志展示。
"""
return [redact_url(item) if "://" in item else item for item in command]
def build_package_install_env(request: PackageInstallRequest, include_moviepilot_proxy: bool = True) -> dict[str, str]:
"""
构造 pip/uv 安装子进程环境,默认把包下载缓存放到持久化配置目录。
"""
env = os.environ.copy()
config_dir = Path(request.config_dir)
if request.package_cache_root:
package_cache_root = Path(request.package_cache_root)
env["PACKAGE_CACHE_ROOT"] = str(package_cache_root)
else:
package_cache_root = Path(env.get("PACKAGE_CACHE_ROOT") or config_dir / ".cache")
env.setdefault("PACKAGE_CACHE_ROOT", str(package_cache_root))
env.setdefault("PIP_CACHE_DIR", str(package_cache_root / "pip"))
env.setdefault("UV_CACHE_DIR", str(package_cache_root / "uv"))
proxy = (request.proxy_url or "").strip()
if proxy and include_moviepilot_proxy:
for key in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
env[key] = proxy
return env
def _find_uv(python_bin: Path) -> Path | None:
"""
优先使用解释器同目录 uv,保证虚拟环境内 wrapper 与真实安装环境一致。
"""
uv_name = "uv.exe" if os.name == "nt" else "uv"
sibling = python_bin.with_name(uv_name)
if sibling.exists():
return sibling
found = shutil.which("uv")
return Path(found) if found else None
def _base_install_args(request: PackageInstallRequest) -> list[str]:
args: list[str] = []
for directory in request.find_links_dirs:
args.extend(["--find-links", str(directory)])
if request.constraints_file:
args.extend(["-c", str(request.constraints_file)])
args.extend(["-r", str(request.requirements_file)])
return args
def _network_variants(request: PackageInstallRequest) -> list[tuple[str, bool, bool]]:
has_index = bool((request.pip_index_url or "").strip())
has_proxy = bool((request.proxy_url or "").strip())
variants: list[tuple[str, bool, bool]] = []
if has_index and has_proxy:
variants.append(("镜像+代理", True, True))
if has_index:
variants.append(("镜像", True, False))
if has_proxy:
variants.append(("代理", False, True))
variants.append(("直连", False, False))
return variants
def _build_uv_command(uv_bin: Path, request: PackageInstallRequest, use_index: bool) -> list[str]:
command = [str(uv_bin), "pip", "install", "--python", str(request.python_bin)]
if use_index and request.pip_index_url:
command.extend(["--default-index", request.pip_index_url])
command.extend(_base_install_args(request))
return command
def _build_pip_command(request: PackageInstallRequest, use_index: bool) -> list[str]:
command = [str(request.python_bin), "-m", "pip", "install"]
if use_index and request.pip_index_url:
command.extend(["-i", request.pip_index_url])
command.extend(_base_install_args(request))
return command
def build_package_install_strategies(request: PackageInstallRequest) -> list[PackageInstallStrategy]:
"""
按 uv 优先、pip 兜底顺序构造网络降级策略。
"""
strategies: list[PackageInstallStrategy] = []
variants = _network_variants(request)
uv_bin = _find_uv(Path(request.python_bin))
if uv_bin:
for variant_name, use_index, use_proxy in variants:
command = _build_uv_command(uv_bin, request, use_index)
env = build_package_install_env(request, include_moviepilot_proxy=use_proxy)
strategies.append(
PackageInstallStrategy(
strategy_name=f"uv:{variant_name}",
backend="uv",
command=command,
env=env,
safe_log_command=redact_command(command),
)
)
for variant_name, use_index, use_proxy in variants:
command = _build_pip_command(request, use_index)
env = build_package_install_env(request, include_moviepilot_proxy=use_proxy)
strategies.append(
PackageInstallStrategy(
strategy_name=f"pip:{variant_name}",
backend="pip",
command=command,
env=env,
safe_log_command=redact_command(command),
)
)
return strategies
+222
View File
@@ -0,0 +1,222 @@
import json
import platform
import sys
from pathlib import Path
from typing import Callable
from app.runtime.config import settings
from app.runtime.log import logger
from app.adapters.network.http import RequestUtils
from app.foundation.version import compare_version
from app.adapters.system.host import SystemUtils
ResourceVersionProvider = Callable[[], tuple[str, str]]
def _unavailable_resource_versions() -> tuple[str, str]:
"""站点能力尚未装配时返回可安全比较的空版本。"""
return "0", "0"
_resource_version_provider: ResourceVersionProvider = _unavailable_resource_versions
def configure_resource_version_provider(provider: ResourceVersionProvider) -> None:
"""由组合根注入已加载的认证与索引版本,保持适配器不依赖应用层。"""
global _resource_version_provider
_resource_version_provider = provider
class ResourceHelper:
"""
检测和更新资源包
"""
_base_dir: Path = settings.ROOT_PATH
_resource_target = Path("app/application/site")
_version_flag = settings.RESOURCE_VERSION_FLAG
_repo = (
f"{settings.GITHUB_PROXY}https://raw.githubusercontent.com/"
f"jxxghp/MoviePilot-Resources/main/package.{_version_flag}.json"
)
_files_api = (
"https://api.github.com/repos/jxxghp/"
f"MoviePilot-Resources/contents/resources.{_version_flag}"
)
@property
def proxies(self):
"""返回访问 GitHub 资源时应使用的代理配置。"""
return None if settings.GITHUB_PROXY else settings.PROXY
@staticmethod
def _get_python_version_tag() -> str:
"""返回资源文件名使用的 CPython ABI 标签。"""
version = sys.version_info
return f"cp{version.major}{version.minor}"
@staticmethod
def _get_machine_tag() -> str:
"""将系统架构名称归一为资源文件使用的标签。"""
machine = platform.machine().lower()
if machine in {"arm64", "aarch64"}:
return "aarch64"
elif machine in {"x86_64", "amd64"}:
return "x86_64"
return machine
@classmethod
def _get_needed_files(cls) -> list[str]:
"""返回 V3 资源在当前平台需要下载的文件名。"""
python_version = ResourceHelper._get_python_version_tag()
python_ver = python_version.replace("cp", "")
system = platform.system().lower()
machine = ResourceHelper._get_machine_tag()
files = [f"user.sites.{cls._version_flag}.bin"]
if system == "linux":
files.append(f"sites.cpython-{python_ver}-{machine}-linux-gnu.so")
elif system == "darwin":
files.append(f"sites.cpython-{python_ver}-darwin.so")
elif system == "windows":
files.append(f"sites.cp{python_ver}-win_amd64.pyd")
return files
def _load_resource_info(self):
"""读取 V3 资源清单。"""
response = RequestUtils(
proxies=self.proxies,
headers=settings.GITHUB_HEADERS,
timeout=10,
).get_res(self._repo)
return response if response and response.status_code == 200 else None
def check(
self,
*,
auth_version: str | None = None,
indexer_version: str | None = None,
) -> bool:
"""
检测并安装当前平台的资源更新。
:param auth_version: 当前已加载的站点认证资源版本;省略时使用组合根注入值
:param indexer_version: 当前已加载的站点索引资源版本;省略时使用组合根注入值
:return: 是否成功安装了需要由上层处理重启的新资源
"""
if not settings.AUTO_UPDATE_RESOURCE:
return False
if SystemUtils.is_frozen():
return False
if auth_version is None or indexer_version is None:
configured_auth_version, configured_indexer_version = (
_resource_version_provider()
)
auth_version = auth_version or configured_auth_version
indexer_version = indexer_version or configured_indexer_version
logger.info("开始检测资源包版本...")
res = self._load_resource_info()
if res:
try:
resource_info = json.loads(res.text)
online_version = resource_info.get("version")
if online_version:
logger.info(f"最新资源包版本:v{online_version}")
# 需要更新的资源包
need_updates = {}
# 资源明细
resources: dict = resource_info.get("resources") or {}
for rname, resource in resources.items():
rtype = resource.get("type")
platform = resource.get("platform")
declared_target = Path(str(resource.get("target") or ""))
version = resource.get("version")
# 判断平台
if platform and platform != SystemUtils.platform():
continue
# 判断版本号
if rtype == "auth":
# 站点认证资源
local_version = auth_version
elif rtype == "sites":
# 站点索引资源
local_version = indexer_version
else:
continue
if compare_version(version, ">", local_version):
logger.info(f"{rname} 资源包有更新,最新版本:v{version}")
else:
continue
# 需要安装
if declared_target != self._resource_target:
logger.warning(
"忽略资源 %s 的非 canonical 目标目录:%s",
rname,
declared_target,
)
continue
need_updates[rname] = self._resource_target
if need_updates:
# 下载文件信息列表
r = RequestUtils(
proxies=settings.PROXY,
headers=settings.GITHUB_HEADERS,
timeout=30,
).get_res(self._files_api)
if r and not r.ok:
logger.error(
f"连接仓库失败:{r.status_code} - {r.reason}"
)
return False
elif not r:
logger.error("连接仓库失败")
return False
files_info = r.json()
# 下载资源文件
needed_files = self._get_needed_files()
logger.info(f"需要下载的资源文件:{needed_files}")
success = True
for item in files_info:
file_name = item.get("name")
if file_name not in needed_files:
continue
save_path = need_updates.get(file_name)
if not save_path:
continue
if item.get("download_url"):
logger.info(f"开始更新资源文件:{file_name} ...")
download_url = (
f"{settings.GITHUB_PROXY}{item.get('download_url')}"
)
res = RequestUtils(
proxies=self.proxies,
headers=settings.GITHUB_HEADERS,
timeout=180,
).get_res(download_url)
if not res:
logger.error(f"文件 {file_name} 下载失败!")
success = False
break
elif res.status_code != 200:
logger.error(
f"下载文件 {file_name} 失败:{res.status_code} - {res.reason}"
)
success = False
break
file_path = self._base_dir / save_path / file_name
if not file_path.parent.exists():
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_bytes(res.content)
if success:
logger.info("资源包更新完成,等待启动层处理后续重启")
return True
else:
logger.warning("资源包更新失败,跳过升级!")
else:
logger.info("所有资源已最新,无需更新")
except json.JSONDecodeError:
logger.error("资源包仓库数据解析失败!")
return False
else:
logger.warning("无法连接资源包仓库!")
return False
+272
View File
@@ -0,0 +1,272 @@
import logging
from functools import lru_cache
from typing import List, Optional, Tuple
from app.runtime.config import settings
from app.runtime.log import logger, log_settings
try:
import moviepilot_rust as _moviepilot_rust
except Exception as err: # pragma: no cover - 取决于运行环境是否安装 Rust 扩展
_moviepilot_rust = None
_import_error = err
else:
_import_error = None
def is_available() -> bool:
"""
判断 Rust 扩展是否可用。
"""
return bool(_moviepilot_rust and _moviepilot_rust.is_available())
def is_config_enabled() -> bool:
"""
判断系统配置是否允许使用 Rust 加速。
"""
return bool(settings.RUST_ACCEL)
def is_enabled() -> bool:
"""
判断当前运行时是否实际启用 Rust 加速。
"""
return is_config_enabled() and is_available()
def status() -> dict:
"""
返回 Rust 加速能力与开关状态,供系统配置接口展示。
"""
return {
"available": is_available(),
"enabled": is_enabled(),
"import_error": str(_import_error) if _import_error else "",
}
def import_error() -> Optional[Exception]:
"""
返回 Rust 扩展导入失败的异常,便于调试构建问题。
"""
return _import_error
def parse_filter_rule(expression: str) -> Optional[list]:
"""
使用 Rust 解析过滤规则表达式,不可用时返回 None。
"""
if not is_enabled():
return None
try:
return _moviepilot_rust.parse_filter_rule_fast(expression)
except BaseException as err:
_raise_non_rust_panic(err)
logger.debug(f"Rust 过滤规则解析失败,回退 Python:{err}")
return None
def filter_torrents(
groups: list,
torrent_list: list,
rule_set: dict,
mediainfo=None,
metainfo_options: Optional[dict] = None,
) -> Optional[Tuple[list, list]]:
"""
使用 Rust 执行完整种子过滤入口,返回原列表下标、优先级和可选调试日志。
"""
if not is_enabled():
return None
try:
args = (
groups,
torrent_list,
rule_set,
mediainfo,
metainfo_options or {},
)
if is_debug_log_enabled() and hasattr(_moviepilot_rust, "filter_torrents_with_trace_fast"):
matched_orders, traces = _moviepilot_rust.filter_torrents_with_trace_fast(*args)
return matched_orders, traces
return _moviepilot_rust.filter_torrents_fast(*args), []
except BaseException as err:
_raise_non_rust_panic(err)
logger.debug(f"Rust 种子过滤失败,回退 Python:{err}")
return None
def is_debug_log_enabled() -> bool:
"""
判断当前日志配置是否会实际输出 debug 日志。
"""
if log_settings.DEBUG:
return True
return getattr(logging, log_settings.LOG_LEVEL.upper(), logging.INFO) <= logging.DEBUG
def parse_indexer_torrents(
html_text: str,
domain: str,
list_config: dict,
fields: dict,
category: Optional[dict] = None,
result_num: int = 100
) -> Optional[List[dict]]:
"""
使用 Rust 批量解析普通配置站点种子列表,不可用时返回 None。
"""
if not is_enabled():
return None
try:
return _moviepilot_rust.parse_indexer_torrents_fast(
html_text,
domain,
list_config,
fields,
category,
result_num
)
except BaseException as err:
_raise_non_rust_panic(err)
logger.debug(f"Rust 站点列表解析失败,使用 Python 解析兜底:{err}")
return None
def parse_indexer_subtitles(
html_text: str,
domain: str,
list_config: dict,
fields: dict,
result_num: int = 100
) -> Optional[List[dict]]:
"""
使用 Rust 批量解析普通配置站点字幕列表,不可用时返回 None。
"""
if not is_enabled():
return None
try:
return _moviepilot_rust.parse_indexer_subtitles_fast(
html_text,
domain,
list_config,
fields,
result_num
)
except BaseException as err:
_raise_non_rust_panic(err)
logger.debug(f"Rust 字幕列表解析失败,使用 Python 解析兜底:{err}")
return None
def parse_rss_items(xml_text: str, max_items: int = 1000) -> Optional[List[dict]]:
"""
使用 Rust 解析 RSS/Atom 条目,不可用或异常时返回 None。
"""
if not is_enabled():
return None
try:
return _moviepilot_rust.parse_rss_items_fast(xml_text, max_items)
except BaseException as err:
_raise_non_rust_panic(err)
logger.debug(f"Rust RSS解析失败,使用 Python 解析兜底:{err}")
return None
def parse_metainfo(title: str, subtitle: Optional[str] = None, options: Optional[dict] = None) -> Optional[dict]:
"""
使用 Rust 从标题入口解析 MetaInfo,不可用或异常时返回 None。
"""
if not is_enabled():
return None
try:
return _moviepilot_rust.parse_metainfo_fast(title, subtitle, options or {})
except BaseException as err:
_raise_non_rust_panic(err)
logger.debug(f"Rust MetaInfo解析失败,使用 Python 解析兜底:{err}")
return None
def parse_metainfo_path(path: str, options: Optional[dict] = None) -> Optional[dict]:
"""
使用 Rust 从路径入口解析 MetaInfoPath,不可用或异常时返回 None。
"""
if not is_enabled():
return None
try:
return _moviepilot_rust.parse_metainfo_path_fast(path, options or {})
except BaseException as err:
_raise_non_rust_panic(err)
logger.debug(f"Rust MetaInfoPath解析失败,使用 Python 解析兜底:{err}")
return None
def parse_metamusic(
title: str,
artists: Optional[List[str]] = None,
year: Optional[int] = None,
) -> Optional[dict]:
"""使用 Rust 解析音乐资源标题,旧扩展不支持时返回 None。
:param title: 音乐资源标题或文件主干名
:param artists: 调用方已有的高可信艺术家列表
:param year: 调用方已有的高可信发行年份
:return: Rust 解析字段,不可用、不支持或异常时返回 None
"""
if not is_enabled():
return None
parser = getattr(_moviepilot_rust, "parse_metamusic_fast", None)
if not callable(parser):
return None
try:
result = parser(title, artists, year)
except BaseException as err:
_raise_non_rust_panic(err)
logger.debug(f"Rust MetaMusic解析失败,使用 Python 解析兜底:{err}")
return None
return result if isinstance(result, dict) and result else None
def find_metainfo(title: str) -> Optional[dict]:
"""
使用 Rust 提取标题中的显式媒体标签,不可用或异常时返回 None。
"""
if not is_enabled():
return None
try:
return _moviepilot_rust.find_metainfo_fast(title)
except BaseException as err:
_raise_non_rust_panic(err)
logger.debug(f"Rust 显式媒体标签解析失败,使用 Python 解析兜底:{err}")
return None
@lru_cache(maxsize=1)
def supports_extended_media_ids() -> bool:
"""
判断当前 Rust 扩展是否支持 Bangumi 与 AniList 显式媒体标签。
:return: 是否支持扩展数据源ID字段
"""
if not is_enabled():
return False
try:
result = _moviepilot_rust.find_metainfo_fast("test [anilist=1]")
except BaseException as err:
_raise_non_rust_panic(err)
logger.debug(f"检测 Rust 扩展数据源ID能力失败:{err}")
return False
metainfo = result.get("metainfo") if isinstance(result, dict) else None
return bool(
metainfo
and metainfo.get("media_source") == "anilist"
and metainfo.get("media_id") == "1"
)
def _raise_non_rust_panic(err: BaseException) -> None:
"""
只吞掉 Rust 扩展 panic/异常,保留用户中断和进程退出语义。
"""
if isinstance(err, (KeyboardInterrupt, SystemExit)):
raise err
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
import io
import logging
import sys
import threading
from logging.handlers import RotatingFileHandler
from pathlib import Path
class RotatingLineStream(io.TextIOBase):
"""
将 stdout/stderr 按行写入滚动日志文件。
这里不复用业务 logger,避免 stdout 日志再次回流到控制台或普通业务日志文件,
同时保证启动阶段的 print/uvicorn 输出也能按配置滚动。
"""
def __init__(self, log_file: Path, max_bytes: int, backup_count: int):
"""创建写入指定滚动日志文件的文本流。"""
super().__init__()
self._buffer = ""
self._lock = threading.Lock()
logger_name = f"moviepilot-stdio::{log_file}"
self._logger = logging.getLogger(logger_name)
self._logger.setLevel(logging.INFO)
self._logger.propagate = False
self._logger.handlers.clear()
handler = RotatingFileHandler(
filename=str(log_file),
maxBytes=max_bytes,
backupCount=backup_count,
encoding="utf-8",
)
handler.setFormatter(logging.Formatter("%(message)s"))
self._logger.addHandler(handler)
@property
def encoding(self) -> str:
"""返回日志流使用的文本编码。"""
return "utf-8"
def writable(self) -> bool:
"""声明该文本流支持写入。"""
return True
def isatty(self) -> bool:
"""声明该日志流不是交互式终端。"""
return False
def write(self, message: str) -> int:
"""缓冲消息并按完整行写入滚动日志。"""
if not message:
return 0
with self._lock:
self._buffer += message.replace("\r\n", "\n")
while "\n" in self._buffer:
line, self._buffer = self._buffer.split("\n", 1)
self._logger.info(line)
return len(message)
def flush(self) -> None:
"""写出剩余缓冲区并刷新底层处理器。"""
with self._lock:
if self._buffer:
self._logger.info(self._buffer)
self._buffer = ""
for handler in self._logger.handlers:
handler.flush()
def configure_rotating_stdio(
*, log_file: Path, max_bytes: int, backup_count: int
) -> RotatingLineStream:
"""
将当前进程的 stdout/stderr 统一重定向到同一个滚动日志流。
"""
log_file.parent.mkdir(parents=True, exist_ok=True)
stream = RotatingLineStream(
log_file=log_file,
max_bytes=max_bytes,
backup_count=backup_count,
)
sys.stdout = stream
sys.stderr = stream
return stream