主要解决了大盘鸡不够大、不够便宜的问题。用极低成本(1个E5账号)扩展了25*5T存储。适合自用或分享给三五个好友使用。
此方案不保证存储稳定性(主要取决于你的e5账号稳不稳),算是薅羊毛方案。
最近用来存新番的1t小盘机老让我有点存储焦虑,而且性能实在太差了。前几天买了个ovh2t*3,raid5后实际也就3.5t可用,还是有点少了。索性就不在本地存了,准备放到e5的onedrive,顺便还能帮我保活。
目前打算用qbittorrent rss新番到netcup vps1000 arm翻倍机,上传onedrive后删除本地文件,再挂载onedrive到本地emby,设置缓存新剧集。6H8G500G,配置够用了。
1. 自建OneDrive API
1.1 创建E5子号
使用E5管理员账号登录全局管理面板: https://admin.microsoft.com/#/homepage
添加新用户,给这个子号至少分配应用程序管理员(Application Administrator)身份。
1.2 打开 Azure Portal 登录
- 国际版、个人版、E3、E5 等版本:https://portal.azure.com/
- 世纪互联版:https://portal.azure.cn/
登录上一步创建的子号
1.3 主页搜索”应用注册” -> “新注册”
如图所示注册:
记录应用程序id:
获取client secret(客户端密码):
记录 Client secret(客户端密码):
1.4 设置API权限
添加Files.Read
、Files.ReadWrite
、Files.Read.All
、Files.ReadWrite.All
、offline_access
、User.Read
这些权限。
检查权限是否完整:
2. 获取Rclone token
在本地电脑下载rclone https://rclone.org/downloads/
在地址栏输入cmd 回车打开
替换以下命令中的Client_ID
、Client_secret
并执行。
rclone authorize "onedrive" "Client_ID" "Client_secret"
在弹出的浏览器窗口登录并授权
返回cmd窗口,获取并保存token: {"access_token":"xxxxxxxxxxxxxxxxxx","expiry":"2024-11-16T00:25:38.91326+08:00"}
3. 在服务器使用rclone挂载onedrive
apt install rclone
rclone config
输入n,新建。然后输入网盘名用于区分,比如我这里设置为onedrive1
输入onedrive对应的标号。注意随着rclone更新 会添加更多支持项,标号可能改变。
输入之前保存的应用程序(客户端) ID和Client secret(客户端密码)
选择对应地区。
advanced config 和 auto config 选no
config token输入之前在windows rclone获取的access_token
确认对应配置
挂载完成,q退出。
测试上传功能是否正常:
echo "This is a test file" > test.txt
rclone copy test.txt onedrive1:/
文件正常上传,rclone配置完成。
4. 安装qbittorrent-nox
本来想用docker版,但docker版执行外部脚本要处理太多权限问题,实在麻烦,因此直接apt安装:
apt install qbittorrent-nox -y
vim /etc/systemd/system/qbittorrent-nox.service
[Unit]
Description=qBittorrent Command Line Client
After=network.target
[Service]
Type=forking
User=root
Group=root
UMask=007
ExecStart=/usr/bin/qbittorrent-nox -d --webui-port=8080
Restart=on-failure
[Install]
WantedBy=multi-user.target
systemctl daemon-reload && systemctl enable qbittorrent-nox
systemctl start qbittorrent-nox
systemctl status qbittorrent-nox
安装完成后通过 ip:8080 访问即可,默认用户名 密码分别为admin adminadmin
2024年11月,我使用apt安装的是4.5老版,但docker最新版已经到5.0,密码是随机生成。如果以后你发现无法使用默认密码登录,请另行搜索。
此外,如果你要rss pt站,别忘了修改密码、更换端口、关闭dht等常规操作。
如果你想使用autobangumi rss mikan,也可以参考:
5. 配置qbittorrent自动上传
本步骤目的是当qbit rss下载资源后,通过rclone上传到onedrive,并彻底删除本地任务、种子、文件。
并且为了便于后期扩展,我们要将不同类别的文件上传到不同的onedrive账号和不同的文件夹,使用标签区分。
创建一个脚本,注意qbit权限问题。本文使用root权限运行qbit,因此不考虑此问题。
mkdir /root/sh
cd /root/sh
vim qbit-upload.py
输入以下内容后修改顶部配置项,esc :wq保存。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
import sys
import uuid
import json
import shutil
import logging
import threading
import queue
import time
from typing import Dict, List, Optional
from dataclasses import dataclass
from logging.handlers import RotatingFileHandler
import subprocess
from qbittorrent import Client
# 用户配置部分
#============================
# qBittorrent 设置
QB_URL = "http://localhost:8080"
QB_USERNAME = "your_username"
QB_PASSWORD = "your_password"
# 最大并发上传任务数
MAX_WORKERS = 4
# 临时文件目录
TEMP_DIR = "/tmp/qbit-upload"
# 日志设置
LOG_FILE = "/root/sh/qbit-upload.log"
MAX_LOG_SIZE = 10 * 1024 # 10KB
LOG_BACKUP_COUNT = 3
# OneDrive 映射配置 (标签 -> rclone远程名称)
ONEDRIVE_MAPPINGS = {
"up-o1": "onedrive1:",
"up-o2": "onedrive2:",
"up-o3": "onedrive3:",
}
# 目录映射配置 (标签 -> 目标路径)
DIR_MAPPINGS = {
"dir-tv": "TV",
"dir-tvgjdm": "TV/国产动漫",
"dir-tvgcjj": "TV/国产剧集",
"dir-tvhwdm": "TV/海外动漫",
"dir-tvhwjj": "TV/海外剧集",
"dir-movie": "Movie",
"dir-moviedmdy": "Movie/动漫电影",
"dir-moviegcdy": "Movie/国产电影",
"dir-moviehwdy": "Movie/海外电影",
"dir-jlp": "纪录片",
"dir-bangumi": "Bangumi",
}
#============================
@dataclass
class UploadTask:
"""上传任务数据类"""
torrent_hash: str # 种子哈希值
tags: List[str] # 标签列表
temp_path: str # 临时目录路径
target_drive: Optional[str] = None # 目标网盘
target_path: Optional[str] = None # 目标路径
class UploadWorker:
"""上传工作器类"""
def __init__(self):
# 初始化qBittorrent客户端
self.qb = Client(QB_URL)
self.qb.login(QB_USERNAME, QB_PASSWORD)
self.logger = logging.getLogger('UploadWorker')
# 创建任务队列和工作线程
self.task_queue = queue.Queue()
self.workers = []
self.should_stop = False
self._lock = threading.Lock()
self.active_tasks = 0
# 启动工作线程
for _ in range(MAX_WORKERS):
worker = threading.Thread(target=self._worker_loop)
worker.daemon = True
self.workers.append(worker)
worker.start()
def add_task(self, torrent_hash: str, tags: str):
"""添加新的上传任务"""
# 解析标签
tags_list = [tag.strip() for tag in tags.split(',') if tag.strip()]
# 检查标签是否匹配配置的规则
target_drive = None
target_path = None
for tag in tags_list:
if tag in ONEDRIVE_MAPPINGS:
target_drive = ONEDRIVE_MAPPINGS[tag]
elif tag in DIR_MAPPINGS:
target_path = DIR_MAPPINGS[tag]
# 如果没有找到匹配的网盘和路径标签,跳过处理
if not (target_drive and target_path):
self.logger.info(f"跳过种子 {torrent_hash}: 缺少必需的标签")
return
# 创建唯一的临时目录
task_uuid = str(uuid.uuid4())
temp_path = os.path.join(TEMP_DIR, task_uuid)
os.makedirs(temp_path, exist_ok=True)
# 创建上传任务
task = UploadTask(
torrent_hash=torrent_hash,
tags=tags_list,
temp_path=temp_path,
target_drive=target_drive,
target_path=target_path
)
# 添加任务到队列
with self._lock:
self.active_tasks += 1
self.task_queue.put(task)
def _worker_loop(self):
"""工作线程主循环"""
while not self.should_stop:
try:
# 从队列获取任务
task = self.task_queue.get(timeout=1)
try:
self._process_task(task)
finally:
self.task_queue.task_done()
with self._lock:
self.active_tasks -= 1
except queue.Empty:
continue
except Exception as e:
self.logger.error(f"工作线程错误: {str(e)}")
def _is_path_used_by_other_torrents(self, target_path: str, current_hash: str) -> bool:
"""检查路径是否被其他种子使用"""
try:
# 获取所有种子信息
all_torrents = self.qb.torrents(filter='all')
# 标准化目标路径
target_path = os.path.normpath(target_path)
for torrent in all_torrents:
# 跳过当前种子
if torrent['hash'].lower() == current_hash.lower():
continue
# 获取种子的保存路径
torrent_path = os.path.normpath(os.path.join(torrent['save_path'], torrent['name']))
# 检查路径是否相同或是否是子目录关系
if (target_path == torrent_path or
target_path.startswith(torrent_path + os.sep) or
torrent_path.startswith(target_path + os.sep)):
return True
return False
except Exception as e:
self.logger.error(f"检查路径占用时出错: {str(e)}")
return True # 如果出错,保守起见认为路径被占用
def _process_task(self, task: UploadTask):
"""处理单个上传任务"""
try:
# 获取种子信息
torrent_list = self.qb.torrents(filter='all')
torrent = None
for t in torrent_list:
if t['hash'].lower() == task.torrent_hash.lower():
torrent = t
break
if not torrent:
raise Exception(f"找不到种子 {task.torrent_hash}")
# 构建源文件路径
source_path = os.path.normpath(os.path.join(torrent['save_path'], torrent['name']))
self.logger.info(f"正在处理种子: {torrent['name']}")
self.logger.info(f"源路径: {source_path}")
# 验证源文件路径是否存在
if not os.path.exists(source_path):
self.logger.error(f"源文件路径不存在: {source_path}")
return
# 复制文件到临时目录
dest_path = os.path.join(task.temp_path, torrent['name'])
if os.path.isfile(source_path):
shutil.copy2(source_path, dest_path)
else:
shutil.copytree(source_path, dest_path)
# 使用rclone上传到OneDrive
target = f"{task.target_drive}{task.target_path}/{torrent['name']}"
self.logger.info(f"正在上传到: {target}")
rclone_cmd = [
'rclone',
'copy',
dest_path,
target,
'--progress',
'--ignore-existing', # 忽略已存在的文件
'--retries', '3', # 失败重试3次
'--low-level-retries', '10', # 低级别重试10次
'--ignore-errors' # 忽略错误继续上传
]
process = subprocess.run(
rclone_cmd,
capture_output=True,
text=True
)
# 检查上传结果
upload_success = process.returncode == 0
if not upload_success:
self.logger.error(f"Rclone上传失败: {process.stderr}")
return
# 清理临时文件
if os.path.exists(task.temp_path):
shutil.rmtree(task.temp_path)
self.logger.info(f"已清理临时目录: {task.temp_path}")
# 删除种子任务
self.qb.delete_permanently([task.torrent_hash])
self.logger.info(f"已删除种子任务: {task.torrent_hash}")
# 删除源文件(仅删除未被占用的文件)
try:
is_path_used = self._is_path_used_by_other_torrents(source_path, task.torrent_hash)
if os.path.exists(source_path):
if os.path.isfile(source_path):
if not is_path_used and not self._is_file_in_use(source_path):
os.remove(source_path)
self.logger.info(f"已删除源文件: {source_path}")
else:
self.logger.info(f"源文件被占用,跳过删除: {source_path}")
else:
# 对于目录,递归删除未被占用的文件
if is_path_used:
self.logger.info(f"源目录被其他种子占用,跳过删除: {source_path}")
else:
self._remove_unused_directory(source_path)
self.logger.info(f"已删除未被占用的文件: {source_path}")
except Exception as e:
self.logger.error(f"清理源文件时出错 {task.torrent_hash}: {str(e)}")
except Exception as e:
self.logger.error(f"任务 {task.torrent_hash} 失败: {str(e)}")
# 确保清理临时目录
if os.path.exists(task.temp_path):
try:
shutil.rmtree(task.temp_path)
self.logger.info(f"已清理临时目录: {task.temp_path}")
except Exception as cleanup_error:
self.logger.error(f"清理临时目录失败: {str(cleanup_error)}")
def _is_file_in_use(self, filepath: str) -> bool:
"""检查文件是否被其他进程占用"""
try:
with open(filepath, 'rb') as f:
return False
except IOError:
return True
def _remove_unused_directory(self, dirpath: str):
"""递归删除未被占用的目录及其内容"""
for root, dirs, files in os.walk(dirpath, topdown=False):
for name in files:
filepath = os.path.join(root, name)
if not self._is_file_in_use(filepath):
try:
os.remove(filepath)
self.logger.info(f"删除文件: {filepath}")
except OSError:
self.logger.warning(f"无法删除文件: {filepath}")
for name in dirs:
try:
dirpath = os.path.join(root, name)
if not os.listdir(dirpath): # 只删除空目录
os.rmdir(dirpath)
self.logger.info(f"删除空目录: {dirpath}")
except OSError:
self.logger.warning(f"无法删除目录: {dirpath}")
def shutdown(self):
"""优雅地关闭工作线程"""
self.should_stop = True
for worker in self.workers:
worker.join()
def setup_logging():
"""配置日志系统"""
logger = logging.getLogger()
logger.setLevel(logging.INFO)
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
# 控制台日志处理器
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
# 文件日志处理器(带轮转)
file_handler = RotatingFileHandler(
LOG_FILE,
maxBytes=MAX_LOG_SIZE,
backupCount=LOG_BACKUP_COUNT
)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
return logger
def main():
"""主函数"""
if len(sys.argv) != 3:
print("用法: qbit-upload.py <torrent_hash> <tags>")
sys.exit(1)
torrent_hash = sys.argv[1]
tags = sys.argv[2]
logger = setup_logging()
logger.info(f"正在处理种子 {torrent_hash} 标签: {tags}")
# 创建临时目录
os.makedirs(TEMP_DIR, exist_ok=True)
# 初始化并运行工作器
worker = UploadWorker()
worker.add_task(torrent_hash, tags)
# 等待所有任务完成
while worker.active_tasks > 0:
time.sleep(1)
worker.shutdown()
if __name__ == "__main__":
main()
给权限:
sudo chmod +x qbit-upload.py
创建虚拟环境(推荐),安装python-qbittorrent 包::
# 安装 python3-venv
apt install python3-venv
# 创建虚拟环境
python3 -m venv /root/venv
# 激活虚拟环境
source /root/venv/bin/activate
# 在虚拟环境中安装包
pip install python-qbittorrent
# 退出
deactivate
在qbittorrent中设置torrent完成时运行外部程序:
/root/venv/bin/python3 /root/sh/qbit-upload.py "%I" "%G"
可以先用小种子测试一下,没问题再进行下一步。比如模拟rss一个种子:
rss时可以直接添加标签,无需担心。
大文件上传可能比较慢,耐心等待一下。上传成功即可进行下一步。
如果你遇到了问题,可以用以下命令检查,能正常上传可以跳过。
add.测试相关
6. 配置环境
6.1 docker compose
主要是方便安装npm反代qb、emby,以及安装emby开心版。
6.2 FUSE (Filesystem in Userspace)
安装fuse使docker中的emby也能获取挂载的onedrive的文件权限。
- 在 Debian/Ubuntu 系统上安装 FUSE:
sudo apt-get update
sudo apt-get install fuse
- 确保 fusermount 可执行文件存在:
which fusermount
7. docker搭建Emby
7.1 docker-compose.yml
注意设置devices、security_opt和cap_add,用于获取onedrive文件。
services:
emby:
image: lovechen/embyserver:latest
container_name: emby # 容器名称
restart: unless-stopped
devices:
- /dev/fuse:/dev/fuse
security_opt:
- apparmor:unconfined
cap_add:
- SYS_ADMIN
volumes:
- ./config:/config # 挂载配置文件路径
- /root/emby/mnt:/data # 挂载下载路径
environment:
- TZ=Asia/Shanghai # 时区设置
- UID=0 # 用户 ID
- GID=0 # 组 ID
- GIDLIST=0 # GID 列表
labels:
- "com.centurylinklabs.watchtower.enable=false"
## 以下是个人网络配置,方便npm内网反代,如果不添加,需要开放8096端口
networks:
default:
external: true
name: dockernetwork
8. 挂载onedrive到本地
创建服务配置文件:
sudo vim /etc/systemd/system/rclone-onedrive1.service
保存以下内容
[Unit]
Description=Rclone OneDrive1 Mount
After=network-online.target
Wants=network-online.target
[Service]
Type=notify
ExecStartPre=/bin/mkdir -p /root/emby/mnt/onedrive1
ExecStartPre=/bin/mkdir -p /var/cache/rclone/onedrive1
ExecStart=/usr/bin/rclone mount \
onedrive1:/ /root/emby/mnt/onedrive1 \
--allow-other \
--buffer-size 256M \
--transfers 4 \
--dir-cache-time 72h \
--poll-interval 20m \
--vfs-cache-mode writes \
--vfs-cache-max-size 180G \
--vfs-read-chunk-size 64M \
--vfs-read-chunk-size-limit 512M \
--cache-dir /var/cache/rclone/onedrive1 \
--cache-info-age 72h \
--timeout 2h \
--drive-chunk-size 64M \
--attr-timeout 72h \
--log-level ERROR \
--log-file /var/log/rclone-onedrive1.log \
--umask 000
User=root
Group=root
Restart=on-abort
RestartSec=5
MemoryMax=1.5G
CPUQuota=50%
[Install]
WantedBy=multi-user.target
[Unit]
Description=Rclone OneDrive2 Mount (Large Files)
After=network-online.target
Wants=network-online.target
[Service]
Type=notify
ExecStartPre=/bin/mkdir -p /root/emby/mnt/onedrive2
ExecStartPre=/bin/mkdir -p /var/cache/rclone/onedrive2
ExecStart=/usr/bin/rclone mount \
onedrive2:/ /root/emby/mnt/onedrive2 \
--allow-other \
--buffer-size 512M \
--transfers 2 \
--dir-cache-time 72h \
--poll-interval 60m \
--vfs-cache-mode writes \
--vfs-cache-max-size 150G \
--vfs-read-chunk-size 128M \
--vfs-read-chunk-size-limit 1G \
--cache-dir /var/cache/rclone/onedrive2 \
--cache-info-age 72h \
--timeout 4h \
--drive-chunk-size 128M \
--attr-timeout 72h \
--log-level ERROR \
--log-file /var/log/rclone-onedrive2.log \
--umask 000
Restart=on-abort
RestartSec=5
User=root
Group=root
MemoryMax=2G
CPUQuota=40%
[Install]
WantedBy=multi-user.target
挂载参数解释:
[Unit]
Description=Rclone OneDrive1 Mount
After=network-online.target
Wants=network-online.target
[Service]
Type=notify
ExecStartPre=/bin/mkdir -p /root/emby/mnt/onedrive1
ExecStartPre=/bin/mkdir -p /var/cache/rclone/onedrive1
ExecStart=/usr/bin/rclone mount \
onedrive1:/ /mnt/onedrive1 \
--allow-other \
--buffer-size 256M \
--transfers 4 \
--dir-cache-time 72h \
--poll-interval 15s \
--vfs-cache-mode writes \
--vfs-cache-max-age 336h \
--vfs-cache-max-size 200G \
--vfs-read-chunk-size 64M \
--vfs-read-chunk-size-limit 512M \
--cache-dir /var/cache/rclone/onedrive1 \
--cache-info-age 72h \
--cache-chunk-clean-interval 15m \
--timeout 2h \
--drive-chunk-size 64M \
--attr-timeout 72h \
--log-level INFO \
--log-file /var/log/rclone-onedrive1.log \
--umask 000
User=root
Group=root
Restart=on-abort
RestartSec=5
MemoryMax=1.5G
CPUQuota=50%
[Install]
WantedBy=multi-user.target
重新加载systemd配置:
sudo systemctl daemon-reload
启用服务开机自启:
sudo systemctl enable rclone-onedrive1.service
立即启动服务:
sudo systemctl start rclone-onedrive1.service
检查服务状态:
sudo systemctl status rclone-onedrive1.service
查看挂载日志:
sudo journalctl -u rclone-onedrive1.service -f
如果需要卸载:
sudo systemctl stop rclone-onedrive1.service
sudo fusermount -u /mnt/onedrive1
如果要修改配置:
sudo systemctl edit rclone-onedrive1.service
sudo systemctl daemon-reload
sudo systemctl restart rclone-onedrive1.service
9. Emby设置
在emby添加媒体库等操作和常规使用相同,区别是像元数据、图片等使用频率高、比较小的文件尽量放在本地,减少向onedrive请求的次数。因为azure主要风控的是api请求次数和频率。因此需要注意配置以下项目:
媒体库-高级
- 检查元数据路径,默认放在/config/metadata就不错,因为这是一个本地路径。
媒体库-编辑每个单独媒体库-打开高级选项
- 禁用 实时监控
- 关闭 将媒体图像保存到媒体文件夹中 (否则图像会存储在OneDrive)
计划任务
- Scan Metadata Folder – 设置为每1小时运行
- Scan media library – 设置为每1小时运行
转码
- 关闭 转码(在机器配置较低时)
用户-编辑
- 关闭 允许转码(在机器配置较低时)
我也是第一次这样搭建,如果还有设置可以优化,请评论告诉我,十分感谢。
至此搭建完成,主要是处理上传脚本浪费了很多时间。除此之外,你可以使用npm反代一下qbit和emby。
参考
创建api:https://p3terx.com/archives/rclone-connect-onedrive-with-selfbuilt-api.html
qb-nox:https://blog.tanglu.me/use-rclone-to-upload-torrents-when-downloaded
另外感谢claude😀
暂无评论内容