Compare commits

...

9 Commits

Author SHA1 Message Date
Cp0204
fe4643ff7c 🔧 修改emby_id为media_id,更新配置结构
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
2024-11-14 02:51:57 +08:00
Cp0204
588a957768 📝 更新README及媒体库模块文档 2024-11-14 02:37:20 +08:00
Cp0204
8c0be4cf4d 添加alist-strm模块 2024-11-14 02:14:42 +08:00
Cp0204
6c8416d7d6 🧩 优化媒体库模块加载与调用逻辑 2024-11-14 02:14:18 +08:00
Cp0204
bdc9068f3d ️ 优化Alist和Emby模块错误处理逻辑 2024-11-14 02:13:30 +08:00
Cp0204
0faf3ec569 📝 UI优化说明帮助链接 2024-11-13 23:26:59 +08:00
Cp0204
10a8ce01ba 📝 添加媒体服务器模块开发指南 2024-11-13 23:24:48 +08:00
Cp0204
dfc33a19c5 添加刷新 Alist 目录模块 2024-11-13 23:11:05 +08:00
Cp0204
ae64cb3dfb ♻️ 优化Emby模块,重构调用逻辑 2024-11-13 23:10:20 +08:00
8 changed files with 338 additions and 72 deletions

View File

@ -11,10 +11,10 @@
定期执行本脚本自动转存、文件名整理,配合 Alist, rclone, Emby 可达到自动追更的效果。🥳
[![wiki][wiki-image]][wiki-url] [![github tag][gitHub-tag-image]][github-url] [![docker pulls][docker-pulls-image]][docker-url] [![docker image size][docker-image-size-image]][docker-url]
[![wiki][wiki-image]][wiki-url] [![github releases][gitHub-releases-image]][github-url] [![docker pulls][docker-pulls-image]][docker-url] [![docker image size][docker-image-size-image]][docker-url]
[wiki-image]: https://img.shields.io/badge/wiki-Documents-green?logo=github
[gitHub-tag-image]: https://img.shields.io/github/v/tag/Cp0204/quark-auto-save?logo=github
[gitHub-releases-image]: https://img.shields.io/github/v/release/Cp0204/quark-auto-save?logo=github
[docker-pulls-image]: https://img.shields.io/docker/pulls/cp0204/quark-auto-save?logo=docker&&logoColor=white
[docker-image-size-image]: https://img.shields.io/docker/image-size/cp0204/quark-auto-save?logo=docker&&logoColor=white
[github-url]: https://github.com/Cp0204/quark-auto-save
@ -57,6 +57,7 @@
- 媒体库整合
- [x] 根据任务名搜索 Emby 媒体库
- [x] 追更或整理后自动刷新 Emby 媒体库
- [x] **媒体库模块化,用户可很方便地[开发自己的媒体库hook模块](./media_servers)**
- 其它
- [x] 每日签到领空间 <sup>[?](https://github.com/Cp0204/quark-auto-save/wiki/%E4%BD%BF%E7%94%A8%E6%8A%80%E5%B7%A7%E9%9B%86%E9%94%A6#%E6%AF%8F%E6%97%A5%E7%AD%BE%E5%88%B0%E9%A2%86%E7%A9%BA%E9%97%B4)</sup>

View File

@ -60,7 +60,7 @@
<div class="col">
<h2 style="display: inline-block;">通知</h2>
<span class="badge badge-pill badge-light">
<a href="https://github.com/Cp0204/quark-auto-save/wiki/%E9%80%9A%E7%9F%A5%E6%8E%A8%E9%80%81%E6%9C%8D%E5%8A%A1%E9%85%8D%E7%BD%AE" target="_blank">?</a>
<a href="https://github.com/Cp0204/quark-auto-save/wiki/%E9%80%9A%E7%9F%A5%E6%8E%A8%E9%80%81%E6%9C%8D%E5%8A%A1%E9%85%8D%E7%BD%AE" target="_blank" title="通知配置说明">?</a>
</span>
</div>
<div class="col text-right">
@ -82,7 +82,10 @@
<div class="row title">
<div class="col">
<h2>媒体库</h2>
<h2 style="display: inline-block;">媒体库</h2>
<span class="badge badge-pill badge-light">
<a href="https://github.com/Cp0204/quark-auto-save/tree/main/media_servers" target="_blank" title="媒体库模块开发指南">?</a>
</span>
</div>
</div>
<div v-for="(server, serverName) in formData.media_servers" :key="serverName" class="task mb-3">
@ -97,7 +100,7 @@
<div v-for="(value, key) in server" :key="key" class="form-group row">
<label class="col-sm-2 col-form-label">{{ key }}</label>
<div class="col-sm-10">
<input type="text" v-model="formData.media_servers[serverName][key]" class="form-control" :placeholder="key === 'url' ? 'URL' : 'API Key/Token'">
<input type="text" v-model="formData.media_servers[serverName][key]" class="form-control">
</div>
</div>
</div>
@ -109,7 +112,7 @@
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label">Crontab <span class="badge badge-pill badge-light"><a target="_blank" href="https://tool.lu/crontab/">?</a></span></label>
<label class="col-sm-2 col-form-label">Crontab <span class="badge badge-pill badge-light"><a target="_blank" href="https://tool.lu/crontab/" title="CRON时间计算器">?</a></span></label>
<div class="col-sm-10">
<input type="text" v-model="formData.crontab" class="form-control" placeholder="必填">
</div>

87
media_servers/README.md Normal file
View File

@ -0,0 +1,87 @@
# 媒体库模块开发指南
本指南介绍如何开发自定义媒体库模块,你可以通过添加新的媒体库模块来扩展项目功能。
## 基本结构
* 模块位于 `media_servers` 目录下.
* 每个模块是一个 `.py` 文件 (例如 `emby.py`, `plex.py`),文件名小写。
* 每个模块文件包含一个与文件名对应的大驼峰命名法类(例如 `emby.py` 中的 `Emby` 类)。
## 模块要求
每个模块类必须包含以下内容:
* **`default_config`**:字典,包含模块所需参数及其默认值。例如:
```python
# 该模块必须配置的键,值可留空
default_config = {"url": "", "token": ""}
```
* **`is_active`**:布尔值,默认为 `False`.
* **`__init__(self, **kwargs)`**:构造函数,接收配置参数 `kwargs`。它应该:
1. 检查 `kwargs` 是否包含所有 `default_config` 中的参数,缺少参数则打印警告。
2. 若参数完整,尝试连接服务器并验证配置,成功则设置 `self.is_active = True`
* **`run(self, task)`**:整个模块入口函数,处理模块逻辑。
* `task` 是一个字典,包含任务信息。如果需要修改任务参数,返回修改后的 `task` 字典;
* 无修改则不返回或返回 `False`
## 模块示例
参考 [emby.py](emby.py)
参考函数:
* **`get_info(self)`**:获取服务器信息(例如名称、版本),成功返回 `True`,失败返回 `False` 。用于验证赋值 `self.is_active`
* **`refresh(self, media_id)`**:刷新指定媒体信息,成功返回服务器响应数据(通常是字典),失败返回 `None`
* **`search(self, media_name)`**:搜索媒体. 成功返回服务器响应数据通常包含媒体ID的字典, 失败返回 `None`
### 最佳实践
requests 部分使用 try-except 块,以防模块请求出错中断整个转存任务。
```python
try:
response = requests.request("GET", url, headers=headers, params=querystring)
# 处理响应数据
# ......
# 返回
except requests.exceptions.RequestException as e:
print(f"Error: {e}")
return False
```
## 使用自定义模块
放到 `/media_servers` 目录即可识别,如果你使用 docker 运行:
```shell
docker run -d \
# ... 例如添加这行挂载,其它一致
-v ./quark-auto-save/media_servers/plex.py:/app/media_servers/plex.py \
# ...
```
如果你有写自定义模块的能力,相信你也知道如何挂载自定义模块,算我啰嗦。🙃
## 配置文件
`quark_config.json``media_servers` 中配置模块参数:
```json
{
"media_servers": {
"emby": {
"url": "http://your-emby-server:8096",
"token": "YOUR_EMBY_TOKEN"
}
}
}
```
当模块代码正确赋值 `default_config` 时,首次运行会自动补充缺失的键。

84
media_servers/alist.py Normal file
View File

@ -0,0 +1,84 @@
import os
import re
import requests
class Alist:
default_config = {"url": "", "token": "", "path_prefix": "/quark"}
is_active = False
def __init__(self, **kwargs):
if kwargs:
for key, value in self.default_config.items():
if key in kwargs:
setattr(self, key, kwargs[key])
else:
print(f"{self.__class__.__name__} 模块缺少必要参数: {key}")
if self.url and self.token:
if self.get_info():
self.is_active = True
def run(self, task):
if task.get("savepath"):
path = self._normalize_path(task["savepath"])
self.refresh(path)
def get_info(self):
url = f"{self.url}/api/admin/setting/list"
headers = {"Authorization": self.token}
querystring = {"group": "1"}
try:
response = requests.request("GET", url, headers=headers, params=querystring)
response.raise_for_status()
response = response.json()
if response.get("code") == 200:
print(
f"Alist: {response.get('data',[])[1].get('value','')} {response.get('data',[])[0].get('value','')}"
)
return True
else:
print(f"Alist: 连接失败❌ {response.get('message')}")
except requests.exceptions.RequestException as e:
print(f"获取Alist信息出错: {e}")
return False
def refresh(self, path, force_refresh=True):
url = f"{self.url}/api/fs/list"
headers = {"Authorization": self.token}
querystring = {
"path": path,
"refresh": force_refresh,
"page": 1,
"per_page": 0,
}
try:
response = requests.request(
"POST", url, headers=headers, params=querystring
)
response.raise_for_status()
response = response.json()
if response.get("code") == 200:
print(f"📁 刷新Alist目录{path} 成功✅")
return response.get("data")
elif "object not found" in response.get("message", ""):
# 如果是根目录就不再往上查找
if path == "/" or path == self.path_prefix:
print(f"📁 刷新Alist目录根目录不存在请检查 Alist 配置")
return False
# 获取父目录
parent_path = os.path.dirname(path)
print(f"📁 刷新Alist目录{path} 不存在,转父目录 {parent_path}")
# 递归刷新父目录
return self.refresh(parent_path)
else:
print(f"📁 刷新Alist目录失败❌ {response.get('message')}")
except requests.exceptions.RequestException as e:
print(f"刷新Alist目录出错: {e}")
return False
def _normalize_path(self, path):
"""标准化路径格式"""
if not path.startswith(self.path_prefix):
path = f"/{self.path_prefix}/{path}"
return re.sub(r"/{2,}", "/", path)

View File

@ -0,0 +1,69 @@
import re
import requests
"""
配合 alist-strm 项目触发特定配置运行
https://github.com/tefuirZ/alist-strm
"""
class Alist_strm:
default_config = {"url": "", "cookie": "", "config_id": ""}
is_active = False
def __init__(self, **kwargs):
if kwargs:
for key, value in self.default_config.items():
if key in kwargs:
setattr(self, key, kwargs[key])
else:
print(f"{self.__class__.__name__} 模块缺少必要参数: {key}")
if self.url and self.cookie and self.config_id:
if self.get_info(self.config_id):
self.is_active = True
def run(self, task):
self.run_selected_configs(self.config_id)
def get_info(self, config_id):
url = f"{self.url}/edit/{config_id}"
headers = {"Cookie": self.cookie}
try:
response = requests.request("GET", url, headers=headers)
response.raise_for_status()
html_content = response.text
# 用正则提取 config_name 的值
match = re.search(r'name="config_name" value="([^"]+)"', html_content)
if match:
config_name = match.group(1)
print(f"alist-strm配置: {config_name}")
return True
else:
print(f"alist-strm配置: 匹配失败❌")
except requests.exceptions.RequestException as e:
print(f"获取alist-strm配置信息出错: {e}")
return False
def run_selected_configs(self, selected_configs_str):
url = f"{self.url}/run_selected_configs"
headers = {"Cookie": self.cookie}
try:
selected_configs = [int(x.strip()) for x in selected_configs_str.split(",")]
except ValueError:
print("Error: 运行alist-strm配置错误id应以,分割")
return None
data = [("selected_configs", config_id) for config_id in selected_configs]
data.append(("action", "run_selected"))
try:
response = requests.post(url, headers=headers, data=data)
response.raise_for_status()
html_content = response.text
# 用正则提取 config_name 的值
match = re.search(r'role="alert">\s*([^<]+)\s*<button', html_content)
if match:
alert = match.group(1).strip()
print(f"🔗 alist-strm配置运行: {alert}")
return True
else:
print(f"🔗 alist-strm配置运行: 失败❌")
except requests.exceptions.RequestException as e:
print(f"Error: {e}")
return False

View File

@ -3,71 +3,91 @@ import requests
class Emby:
default_config = {"url": "", "apikey": ""}
default_config = {"url": "", "token": ""}
is_active = False
def __init__(self, **kwargs):
self.is_active = False
if kwargs:
for key, value in self.default_config.items():
if key in kwargs:
setattr(self, key, kwargs[key])
else:
print(f"{self.__class__.__name__} 模块缺少必要参数: {key}")
if self.url and self.apikey:
if self.url and self.token:
if self.get_info():
self.is_active = True
def run(self, task):
if task.get("media_id"):
if task["media_id"] != "0":
self.refresh(task["media_id"])
else:
match_media_id = self.search(task["taskname"])
if match_media_id:
task["media_id"] = match_media_id
self.refresh(match_media_id)
return task
def get_info(self):
url = f"{self.url}/emby/System/Info"
headers = {"X-Emby-Token": self.apikey}
headers = {"X-Emby-Token": self.token}
querystring = {}
response = requests.request("GET", url, headers=headers, params=querystring)
if "application/json" in response.headers["Content-Type"]:
response = response.json()
print(
f"Emby媒体库: {response.get('ServerName','')} v{response.get('Version','')}"
)
return True
else:
print(f"Emby媒体库: 连接失败❌ {response.text}")
return False
try:
response = requests.request("GET", url, headers=headers, params=querystring)
if "application/json" in response.headers["Content-Type"]:
response = response.json()
print(
f"Emby媒体库: {response.get('ServerName','')} v{response.get('Version','')}"
)
return True
else:
print(f"Emby媒体库: 连接失败❌ {response.text}")
except requests.exceptions.RequestException as e:
print(f"获取Emby媒体库信息出错: {e}")
return False
def refresh(self, emby_id):
if emby_id:
url = f"{self.url}/emby/Items/{emby_id}/Refresh"
headers = {"X-Emby-Token": self.apikey}
querystring = {
"Recursive": "true",
"MetadataRefreshMode": "FullRefresh",
"ImageRefreshMode": "FullRefresh",
"ReplaceAllMetadata": "false",
"ReplaceAllImages": "false",
}
if not emby_id:
return False
url = f"{self.url}/emby/Items/{emby_id}/Refresh"
headers = {"X-Emby-Token": self.token}
querystring = {
"Recursive": "true",
"MetadataRefreshMode": "FullRefresh",
"ImageRefreshMode": "FullRefresh",
"ReplaceAllMetadata": "false",
"ReplaceAllImages": "false",
}
try:
response = requests.request(
"POST", url, headers=headers, params=querystring
)
if response.text == "":
print(f"🎞 刷新Emby媒体库成功✅")
print(f"🎞 刷新Emby媒体库成功✅")
return True
else:
print(f"🎞 刷新Emby媒体库{response.text}")
return False
print(f"🎞️ 刷新Emby媒体库{response.text}")
except requests.exceptions.RequestException as e:
print(f"刷新Emby媒体库出错: {e}")
return False
def search(self, media_name):
if media_name:
url = f"{self.url}/emby/Items"
headers = {"X-Emby-Token": self.apikey}
querystring = {
"IncludeItemTypes": "Series",
"StartIndex": 0,
"SortBy": "SortName",
"SortOrder": "Ascending",
"ImageTypeLimit": 0,
"Recursive": "true",
"SearchTerm": media_name,
"Limit": 10,
"IncludeSearchTypes": "false",
}
if not media_name:
return False
url = f"{self.url}/emby/Items"
headers = {"X-Emby-Token": self.token}
querystring = {
"IncludeItemTypes": "Series",
"StartIndex": 0,
"SortBy": "SortName",
"SortOrder": "Ascending",
"ImageTypeLimit": 0,
"Recursive": "true",
"SearchTerm": media_name,
"Limit": 10,
"IncludeSearchTypes": "false",
}
try:
response = requests.request("GET", url, headers=headers, params=querystring)
if "application/json" in response.headers["Content-Type"]:
response = response.json()
@ -75,9 +95,11 @@ class Emby:
for item in response["Items"]:
if item["IsFolder"]:
print(
f"🎞{item['Name']}》匹配到Emby媒体库ID{item['Id']}"
f"🎞{item['Name']}》匹配到Emby媒体库ID{item['Id']}"
)
return item["Id"]
else:
print(f"🎞 搜索Emby媒体库{response.text}")
print(f"🎞️ 搜索Emby媒体库{response.text}")
except requests.exceptions.RequestException as e:
print(f"搜索Emby媒体库出错: {e}")
return False

View File

@ -692,6 +692,7 @@ def load_media_servers(media_servers_config, media_servers_dir="media_servers"):
available_modules = [
f.replace(".py", "") for f in os.listdir(media_servers_dir) if f.endswith(".py")
]
print(f"🧩 载入媒体库模块")
for module_name in available_modules:
try:
module = importlib.import_module(f"{media_servers_dir}.{module_name}")
@ -703,7 +704,8 @@ def load_media_servers(media_servers_config, media_servers_dir="media_servers"):
else:
media_servers_config[module_name] = ServerClass().default_config
except (ImportError, AttributeError):
print(f"加载模块 {module_name} 失败")
print(f"载入模块 {module_name} 失败")
print()
return media_servers
@ -795,8 +797,8 @@ def do_save(account, tasklist=[]):
print(f"正则替换: {task['replace']}")
if task.get("enddate"):
print(f"任务截止: {task['enddate']}")
if task.get("emby_id"):
print(f"刷媒体库: {task['emby_id']}")
if task.get("media_id"):
print(f"刷媒体库: {task['media_id']}")
if task.get("ignore_extension"):
print(f"忽略后缀: {task['ignore_extension']}")
if task.get("update_subdir"):
@ -804,20 +806,12 @@ def do_save(account, tasklist=[]):
print()
is_new = account.do_save_task(task)
is_rename = account.do_rename_task(task)
# 刷新媒体库
for server_name, media_server in media_servers.items():
if (
media_server.is_active
and (is_new or is_rename)
and task.get("media_id") != "0"
):
if task.get("media_id"):
media_server.refresh(task["media_id"])
else:
match_media_id = media_server.search(task["taskname"])
if match_media_id:
task["media_id"] = match_media_id
media_server.refresh(match_media_id)
# 调用媒体库模块
if is_new or is_rename:
print(f"🧩 调用媒体库模块")
for server_name, media_server in media_servers.items():
if media_server.is_active:
task = media_server.run(task) or task
print()
@ -825,11 +819,15 @@ def reaking_change_update():
global CONFIG_DATA
# print("Update config v0.3.6.1 to 0.3.7")
if CONFIG_DATA.get("emby"):
CONFIG_DATA.setdefault("media_servers", {})["emby"] = CONFIG_DATA["emby"]
CONFIG_DATA.setdefault("media_servers", {})["emby"] = {
"url": CONFIG_DATA["emby"]["url"],
"token": CONFIG_DATA["emby"]["apikey"],
}
del CONFIG_DATA["emby"]
for task in CONFIG_DATA.get("tasklist", {}):
task["media_id"] = task.get("emby_id", "")
del task["emby_id"]
if task.get("emby_id"):
del task["emby_id"]
def main():

View File

@ -6,9 +6,11 @@
"QUARK_SIGN_NOTIFY": true,
"其他推送渠道//此项可删": "配置方法同青龙"
},
"emby": {
"url": "",
"apikey": ""
"media_servers": {
"emby": {
"url": "",
"token": ""
}
},
"tasklist": [
{
@ -18,7 +20,7 @@
"pattern": "$TV",
"replace": "",
"enddate": "2099-01-30",
"emby_id": "",
"media_id": "",
"update_subdir": "4k|1080p"
},
{