mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-31 04:57:23 +08:00
486 lines
17 KiB
Python
486 lines
17 KiB
Python
import os
|
||
import shutil
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Optional, List
|
||
|
||
from app.schemas.file import StorageUsage as _SchemaStorageUsage
|
||
from app.schemas.workflow import FileItem as _SchemaFileItem
|
||
from app.runtime.config import global_vars
|
||
from app.runtime.settings import RuntimeSettingsCompat
|
||
|
||
settings = RuntimeSettingsCompat()
|
||
from app.application.directory import DirectoryHelper
|
||
from app.runtime.log import logger
|
||
from app.adapters.system.fsproxy import fsproxy
|
||
from app.modules.filemanager.storages import StorageBase, transfer_process
|
||
from app.schemas.exception import StorageQueryError
|
||
from app.schemas.types import StorageSchema
|
||
from app.adapters.system.host import SystemUtils
|
||
|
||
|
||
class LocalStorage(StorageBase):
|
||
"""
|
||
本地文件操作
|
||
"""
|
||
|
||
# 存储类型
|
||
schema = StorageSchema.Local
|
||
# 支持的整理方式
|
||
transtype = {
|
||
"copy": "复制",
|
||
"move": "移动",
|
||
"link": "硬链接",
|
||
"softlink": "软链接"
|
||
}
|
||
|
||
# 文件块大小,默认10MB
|
||
chunk_size = 10 * 1024 * 1024
|
||
|
||
def init_storage(self):
|
||
"""
|
||
初始化
|
||
"""
|
||
pass
|
||
|
||
def check(self) -> bool:
|
||
"""
|
||
检查存储是否可用
|
||
"""
|
||
return True
|
||
|
||
def __get_fileitem(self, path: Path) -> _SchemaFileItem:
|
||
"""
|
||
获取文件项
|
||
"""
|
||
# 走代理读取:挂载挂死时这一步会在超时后抛 OSError,而不是永久悬挂线程。
|
||
# 顺带只 stat 一次——原先 size 与 modify_time 各 stat 一次,在网络挂载上
|
||
# 等于把这个热点路径的开销翻倍
|
||
info = fsproxy.stat(path)
|
||
return _SchemaFileItem(
|
||
storage=self.schema.value,
|
||
type="file",
|
||
path=path.as_posix(),
|
||
name=path.name,
|
||
basename=path.stem,
|
||
extension=path.suffix[1:],
|
||
size=info["size"],
|
||
modify_time=info["mtime"],
|
||
)
|
||
|
||
def __get_diritem(self, path: Path) -> _SchemaFileItem:
|
||
"""
|
||
获取目录项
|
||
"""
|
||
return _SchemaFileItem(
|
||
storage=self.schema.value,
|
||
type="dir",
|
||
path=path.as_posix() + "/",
|
||
name=path.name,
|
||
basename=path.stem,
|
||
modify_time=fsproxy.stat(path)["mtime"],
|
||
)
|
||
|
||
def list(self, fileitem: _SchemaFileItem) -> List[_SchemaFileItem]:
|
||
"""
|
||
浏览文件
|
||
"""
|
||
# 返回结果
|
||
ret_items = []
|
||
path = fileitem.path
|
||
if not fileitem.path or fileitem.path == "/":
|
||
if SystemUtils.is_windows():
|
||
partitions = SystemUtils.get_windows_drives() or ["C:/"]
|
||
for partition in partitions:
|
||
ret_items.append(_SchemaFileItem(
|
||
storage=self.schema.value,
|
||
type="dir",
|
||
path=partition + "/",
|
||
name=partition,
|
||
basename=partition
|
||
))
|
||
return ret_items
|
||
else:
|
||
path = "/"
|
||
else:
|
||
if SystemUtils.is_windows():
|
||
path = path.lstrip("/")
|
||
elif not path.startswith("/"):
|
||
path = "/" + path
|
||
|
||
# 遍历目录
|
||
path_obj = Path(path)
|
||
try:
|
||
info = fsproxy.stat(path_obj)
|
||
except (FileNotFoundError, NotADirectoryError):
|
||
logger.warn(f"【本地】目录不存在:{path}")
|
||
return []
|
||
|
||
# 如果是文件
|
||
if info["is_file"]:
|
||
ret_items.append(self.__get_fileitem(path_obj))
|
||
return ret_items
|
||
|
||
# 扁历所有目录
|
||
for item in SystemUtils.list_sub_directory(path_obj):
|
||
ret_items.append(self.__get_diritem(item))
|
||
|
||
# 遍历所有文件,不含子目录
|
||
for item in SystemUtils.list_sub_file(path_obj):
|
||
ret_items.append(self.__get_fileitem(item))
|
||
return ret_items
|
||
|
||
def create_folder(self, fileitem: _SchemaFileItem, name: str) -> Optional[_SchemaFileItem]:
|
||
"""
|
||
创建目录
|
||
:param fileitem: 父目录
|
||
:param name: 目录名
|
||
"""
|
||
if not fileitem.path:
|
||
return None
|
||
path_obj = Path(fileitem.path) / name
|
||
if not path_obj.exists():
|
||
path_obj.mkdir(parents=True, exist_ok=True)
|
||
return self.__get_diritem(path_obj)
|
||
|
||
def get_folder(self, path: Path) -> Optional[_SchemaFileItem]:
|
||
"""
|
||
获取目录
|
||
"""
|
||
if not path.exists():
|
||
path.mkdir(parents=True, exist_ok=True)
|
||
return self.__get_diritem(path)
|
||
|
||
def get_item(self, path: Path) -> Optional[_SchemaFileItem]:
|
||
"""
|
||
获取文件或目录,不存在返回None
|
||
"""
|
||
try:
|
||
info = fsproxy.stat(path)
|
||
except (FileNotFoundError, NotADirectoryError):
|
||
return None
|
||
if info["is_file"]:
|
||
return self.__get_fileitem(path)
|
||
return self.__get_diritem(path)
|
||
|
||
def get_item_strict(self, path: Path) -> Optional[_SchemaFileItem]:
|
||
"""
|
||
获取文件或目录,无法确认状态时抛出 StorageQueryError。
|
||
Path.exists() 会把部分 errno(如 EBADF/ELOOP)归入「不存在」,
|
||
网络/FUSE 挂载抖动时会误判,这里用 stat 显式区分。
|
||
挂载完全无响应时代理会超时并抛 FileSystemTimeout(OSError 子类),
|
||
同样落入下面的分支,转化成调用方能处理的查询失败。
|
||
"""
|
||
try:
|
||
fsproxy.stat(path)
|
||
except (FileNotFoundError, NotADirectoryError):
|
||
return None
|
||
except OSError as e:
|
||
raise StorageQueryError(f"【本地】读取文件状态失败: {path} - {e}") from e
|
||
try:
|
||
return self.get_item(path)
|
||
except OSError as e:
|
||
raise StorageQueryError(f"【本地】读取文件信息失败: {path} - {e}") from e
|
||
|
||
def detail(self, fileitem: _SchemaFileItem) -> Optional[_SchemaFileItem]:
|
||
"""
|
||
获取文件详情
|
||
"""
|
||
path_obj = Path(fileitem.path)
|
||
if not path_obj.exists():
|
||
return None
|
||
return self.__get_fileitem(path_obj)
|
||
|
||
def delete(self, fileitem: _SchemaFileItem) -> bool:
|
||
"""
|
||
删除文件
|
||
"""
|
||
if not fileitem.path:
|
||
return False
|
||
path_obj = Path(fileitem.path)
|
||
try:
|
||
info = fsproxy.stat(path_obj)
|
||
except (FileNotFoundError, NotADirectoryError):
|
||
return True
|
||
except OSError as e:
|
||
logger.error(f"【本地】读取待删除文件状态失败:{e}")
|
||
return False
|
||
try:
|
||
if info["is_file"]:
|
||
fsproxy.unlink(path_obj)
|
||
else:
|
||
fsproxy.rmtree(path_obj)
|
||
except Exception as e:
|
||
logger.error(f"【本地】删除文件失败:{e}")
|
||
return False
|
||
return True
|
||
|
||
def rename(self, fileitem: _SchemaFileItem, name: str) -> bool:
|
||
"""
|
||
重命名文件
|
||
"""
|
||
path_obj = Path(fileitem.path)
|
||
try:
|
||
fsproxy.rename(path_obj, path_obj.parent / name)
|
||
except (FileNotFoundError, NotADirectoryError):
|
||
return False
|
||
except Exception as e:
|
||
logger.error(f"【本地】重命名文件失败:{e}")
|
||
return False
|
||
return True
|
||
|
||
def download(self, fileitem: _SchemaFileItem, path: Path = None) -> Optional[Path]:
|
||
"""
|
||
下载文件
|
||
"""
|
||
return Path(fileitem.path)
|
||
|
||
# 写入中的临时文件后缀。点开头(隐藏)+ 专用后缀双重保证:即使进程被
|
||
# SIGKILL、临时文件残留,媒体库也不会把半成品当成媒体收录
|
||
PARTIAL_SUFFIX = ".mp-partial"
|
||
# 临时文件被认定为中断残留的时长(秒)。正常失败路径会自行清理,只有被
|
||
# 强杀才会残留;阈值取得宽松,避免误删仍在写入的大文件
|
||
PARTIAL_STALE_SECONDS = 24 * 3600
|
||
|
||
@classmethod
|
||
def _partial_path(cls, dest: Path) -> Path:
|
||
"""
|
||
生成写入中的临时文件路径。
|
||
|
||
必须与目标同目录:os.replace 只有在同一文件系统内才是原子的,放到
|
||
/tmp 之类的地方会退化成一次完整拷贝,原子性荡然无存。带 PID 是为了
|
||
避免多进程同时写同一目标时互相踩踏。
|
||
:param dest: 目标文件路径
|
||
:return: 临时文件路径
|
||
"""
|
||
return dest.parent / f".{dest.name}.{os.getpid()}{cls.PARTIAL_SUFFIX}"
|
||
|
||
@classmethod
|
||
def _cleanup_stale_partials(cls, directory: Path):
|
||
"""
|
||
清理目录下中断残留的临时文件。
|
||
|
||
只做局部清理而不是全库扫描:在网络挂载上遍历整个媒体库代价不可接受,
|
||
而残留只可能出现在曾经写入过的目录里,因此每次写入时顺带清理即可。
|
||
本方法是尽力而为的旁路操作,任何失败都不影响主流程。
|
||
:param directory: 目标目录
|
||
"""
|
||
try:
|
||
threshold = time.time() - cls.PARTIAL_STALE_SECONDS
|
||
for item in directory.glob(f"*{cls.PARTIAL_SUFFIX}"):
|
||
try:
|
||
if item.stat().st_mtime < threshold:
|
||
item.unlink()
|
||
logger.info(f"【本地】已清理中断残留的临时文件:{item}")
|
||
except OSError:
|
||
continue
|
||
except Exception as err:
|
||
logger.debug(f"【本地】清理临时文件失败:{directory} - {err}")
|
||
|
||
def _write_atomically(self, src: Path, dest: Path) -> bool:
|
||
"""
|
||
以「写临时名 → os.replace」的方式把源文件内容落到目标。
|
||
|
||
直接写目标路径的话,进程被杀(OOM、重启、宿主断电、SIGKILL)会在媒体库
|
||
里留下一个**叫最终文件名的半截文件**:媒体库会把它扫进去,后续的
|
||
「目标已存在」判断也会把它当成完成品。os.replace 在同目录内由内核保证
|
||
原子性,因此目标要么完整存在,要么根本不存在。
|
||
:param src: 源文件路径
|
||
:param dest: 目标文件路径
|
||
:return: 是否成功
|
||
"""
|
||
self._cleanup_stale_partials(dest.parent)
|
||
partial = self._partial_path(dest)
|
||
# 进度只在需要展示时才回调 UI,但代理内部始终按固定间隔上报——那是判定
|
||
# 「传输是否还在推进」的心跳,不能因为不展示进度就关掉
|
||
progress_callback = (
|
||
transfer_process(src.as_posix())
|
||
if self.__should_show_progress(src, dest) else None
|
||
)
|
||
try:
|
||
copied = fsproxy.copy(
|
||
src, partial,
|
||
progress_cb=progress_callback,
|
||
cancel_cb=lambda: global_vars.is_transfer_stopped(src.as_posix()),
|
||
chunk_size=self.chunk_size,
|
||
)
|
||
if not copied:
|
||
logger.info(f"【本地】{src} 复制未完成")
|
||
return False
|
||
os.replace(partial, dest)
|
||
return True
|
||
except Exception as err:
|
||
logger.error(f"【本地】复制文件失败:{err}")
|
||
return False
|
||
finally:
|
||
if progress_callback:
|
||
progress_callback(100)
|
||
# 失败路径留下的临时文件就地清掉;成功时 replace 已经把它移走
|
||
try:
|
||
if partial.exists():
|
||
partial.unlink()
|
||
except OSError:
|
||
pass
|
||
|
||
@staticmethod
|
||
def _copy_with_target_permissions(src: Path, dest: Path) -> Path:
|
||
"""
|
||
复制文件内容和时间戳,并保留目标目录赋予新文件的权限。
|
||
|
||
目标目录的默认权限或继承 ACL 应作为媒体库的访问策略,复制完成后不能再用
|
||
源文件权限覆盖,否则部分文件系统会清除已继承的 ACL。
|
||
|
||
:param src: 源文件路径
|
||
:param dest: 目标文件路径
|
||
:return: 目标文件路径
|
||
"""
|
||
src = Path(src)
|
||
dest = Path(dest)
|
||
src_stat = src.stat()
|
||
shutil.copyfile(src, dest)
|
||
os.utime(dest, ns=(src_stat.st_atime_ns, src_stat.st_mtime_ns))
|
||
return dest
|
||
|
||
def _copy_with_progress(self, src: Path, dest: Path) -> bool:
|
||
"""
|
||
分块复制文件并回调进度
|
||
"""
|
||
src_stat = src.stat()
|
||
total_size = src_stat.st_size
|
||
copied_size = 0
|
||
progress_callback = transfer_process(src.as_posix())
|
||
try:
|
||
with open(src, "rb") as fsrc, open(dest, "wb") as fdst:
|
||
while True:
|
||
if global_vars.is_transfer_stopped(src.as_posix()):
|
||
logger.info(f"【本地】{src} 复制已取消!")
|
||
return False
|
||
buf = fsrc.read(self.chunk_size)
|
||
if not buf:
|
||
break
|
||
fdst.write(buf)
|
||
copied_size += len(buf)
|
||
# 更新进度
|
||
if progress_callback:
|
||
percent = copied_size / total_size * 100
|
||
progress_callback(percent)
|
||
os.utime(dest, ns=(src_stat.st_atime_ns, src_stat.st_mtime_ns))
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"【本地】复制文件 {src} 失败:{e}")
|
||
return False
|
||
finally:
|
||
progress_callback(100)
|
||
|
||
def upload(
|
||
self,
|
||
fileitem: _SchemaFileItem,
|
||
path: Path,
|
||
new_name: Optional[str] = None
|
||
) -> Optional[_SchemaFileItem]:
|
||
"""
|
||
上传文件(带进度)
|
||
"""
|
||
try:
|
||
dir_path = Path(fileitem.path)
|
||
target_path = dir_path / (new_name or path.name)
|
||
# 先原子地把内容落到目标,确认完整之后才删源
|
||
if self._write_atomically(path, target_path):
|
||
# 上传删除源文件
|
||
path.unlink()
|
||
return self.get_item(target_path)
|
||
except Exception as err:
|
||
logger.error(f"【本地】上传文件失败:{err}")
|
||
return None
|
||
|
||
@staticmethod
|
||
def __should_show_progress(src: Path, dest: Path):
|
||
"""
|
||
是否显示进度条
|
||
"""
|
||
src_isnetwork = SystemUtils.is_network_filesystem(src)
|
||
dest_isnetwork = SystemUtils.is_network_filesystem(dest)
|
||
if src_isnetwork and dest_isnetwork and SystemUtils.is_same_disk(src, dest):
|
||
return True
|
||
return False
|
||
|
||
def copy(
|
||
self,
|
||
fileitem: _SchemaFileItem,
|
||
path: Path,
|
||
new_name: str
|
||
) -> bool:
|
||
"""
|
||
复制文件(带进度)
|
||
"""
|
||
return self._write_atomically(Path(fileitem.path), path / new_name)
|
||
|
||
def move(
|
||
self,
|
||
fileitem: _SchemaFileItem,
|
||
path: Path,
|
||
new_name: str
|
||
) -> bool:
|
||
"""
|
||
移动文件(带进度)
|
||
"""
|
||
src = Path(fileitem.path)
|
||
dest = path / new_name
|
||
if src == dest:
|
||
# 目标和源文件相同,直接返回成功,不做任何操作
|
||
return True
|
||
try:
|
||
# 同一文件系统内 rename 是原子操作:中断后要么完全成功、要么完全
|
||
# 没发生,既不需要临时文件也不会留下半成品。直接尝试而不预先比较
|
||
# st_dev,省掉挂载上的两次 stat——跨设备会以 EXDEV 失败并落到下面
|
||
os.replace(src, dest)
|
||
return True
|
||
except OSError as err:
|
||
logger.debug(f"【本地】直接移动未成功,降级为复制:{src} -> {dest} - {err}")
|
||
# 跨文件系统:先原子地把内容落到目标,确认完整之后才删源。
|
||
# 顺序不能反——先删源再失败就是永久丢件
|
||
if not self._write_atomically(src, dest):
|
||
return False
|
||
try:
|
||
src.unlink()
|
||
except OSError as err:
|
||
logger.warn(f"【本地】移动已完成但删除源文件失败:{src} - {err}")
|
||
return True
|
||
|
||
def link(self, fileitem: _SchemaFileItem, target_file: Path) -> bool:
|
||
"""
|
||
硬链接文件
|
||
"""
|
||
file_path = Path(fileitem.path)
|
||
code, message = SystemUtils.link(file_path, target_file)
|
||
if code != 0:
|
||
logger.error(f"【本地】硬链接文件失败:{message}")
|
||
return False
|
||
return True
|
||
|
||
def softlink(self, fileitem: _SchemaFileItem, target_file: Path) -> bool:
|
||
"""
|
||
软链接文件
|
||
"""
|
||
file_path = Path(fileitem.path)
|
||
code, message = SystemUtils.softlink(file_path, target_file)
|
||
if code != 0:
|
||
logger.error(f"【本地】软链接文件失败:{message}")
|
||
return False
|
||
return True
|
||
|
||
def usage(self) -> Optional[_SchemaStorageUsage]:
|
||
"""
|
||
存储使用情况
|
||
"""
|
||
directory_helper = DirectoryHelper()
|
||
total_storage, free_storage = SystemUtils.space_usage(
|
||
[Path(d.download_path) for d in directory_helper.get_local_download_dirs() if d.download_path] +
|
||
[Path(d.library_path) for d in directory_helper.get_local_library_dirs() if d.library_path],
|
||
btrfs_fsid_dedup=settings.BTRFS_FSID_DEDUP,
|
||
)
|
||
return _SchemaStorageUsage(
|
||
total=total_storage,
|
||
available=free_storage
|
||
)
|