nonebot-plugin-randpic 2.0.0__tar.gz → 2.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/PKG-INFO +7 -11
- {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/README.md +5 -10
- {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/nonebot_plugin_randpic/compress.py +0 -23
- {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/nonebot_plugin_randpic/config.py +5 -10
- {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/nonebot_plugin_randpic/library.py +1 -28
- nonebot_plugin_randpic-2.0.1/nonebot_plugin_randpic/progress.py +118 -0
- {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/nonebot_plugin_randpic/repository.py +4 -87
- {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/nonebot_plugin_randpic/service.py +44 -38
- {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/pyproject.toml +2 -1
- {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/LICENSE +0 -0
- {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/nonebot_plugin_randpic/__init__.py +0 -0
- {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/nonebot_plugin_randpic/ali_oss.py +0 -0
- {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/nonebot_plugin_randpic/drivers/fastapi.py +0 -0
- {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/nonebot_plugin_randpic/handlers.py +0 -0
- {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/nonebot_plugin_randpic/journal.py +0 -0
- {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/nonebot_plugin_randpic/models.py +0 -0
- {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/nonebot_plugin_randpic/utils.py +0 -0
- {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/nonebot_plugin_randpic/web.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nonebot-plugin-randpic
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.1
|
|
4
4
|
Summary: 一个通过指令随机发送你存储的图片的nonebot2插件
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -24,6 +24,7 @@ Requires-Dist: nonebot-adapter-onebot (>=2.1.5,<3.0.0)
|
|
|
24
24
|
Requires-Dist: nonebot-plugin-localstore (>=0.6.0,<0.7.0)
|
|
25
25
|
Requires-Dist: nonebot2[fastapi] (>=2.0.0,<3.0.0)
|
|
26
26
|
Requires-Dist: pillow (>=11.0.0)
|
|
27
|
+
Requires-Dist: rich (>=13.0,<15.0)
|
|
27
28
|
Project-URL: Homepage, https://github.com/HuParry/nonebot-plugin-randpic
|
|
28
29
|
Project-URL: Repository, https://github.com/HuParry/nonebot-plugin-randpic
|
|
29
30
|
Description-Content-Type: text/markdown
|
|
@@ -61,13 +62,11 @@ _✨ 一个发送指令就能让你的 bot 发出对应指令的图片的插件
|
|
|
61
62
|
- 自动压缩超过 1MiB 的图片,群内添加时使用感知哈希检查相似图片。
|
|
62
63
|
- 本地静态图库无需 OSS;可选同步到阿里云 OSS。增删后自动更新网页,网页分页并使用浏览器原生懒加载。
|
|
63
64
|
|
|
64
|
-
###
|
|
65
|
+
### 初始化与数据持久化
|
|
65
66
|
|
|
66
|
-
|
|
67
|
+
首次启动会创建数据库,并扫描配置的图片文件夹,根据实际图片建立索引,发送次数从 0 开始。数据库不存在时也可由图片文件重新建立索引。
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
升级前建议完整备份存储目录。若需要回退 1.x,请恢复完整备份;2.0 不会持续更新旧表,不能直接让旧版读取新运行状态。
|
|
69
|
+
之后每次启动都会核对文件变化,并保留已有图片的发送次数、消息引用关系和同步状态。这些运行数据无法仅凭图片内容恢复,因此不要在正常使用期间删除数据库。
|
|
71
70
|
|
|
72
71
|
实现说明见 [架构与数据一致性](./docs/architecture.md)。
|
|
73
72
|
|
|
@@ -86,7 +85,6 @@ data/randpic/
|
|
|
86
85
|
│ └── 马哥/
|
|
87
86
|
├── database/
|
|
88
87
|
│ ├── data.db # 图片、发送次数、历史消息、待同步任务
|
|
89
|
-
│ ├── data.pre-v2.db # 从旧版迁移时生成的原始数据库备份
|
|
90
88
|
│ └── ... # 文件恢复日志、网页上传缓存等内部文件
|
|
91
89
|
└── public/ # 自动生成的本地静态网页与图片副本,请勿手动维护
|
|
92
90
|
```
|
|
@@ -122,7 +120,7 @@ Bot 运行期间请通过指令增删图片,直接修改文件需重启才能
|
|
|
122
120
|
|
|
123
121
|

|
|
124
122
|
|
|
125
|
-
启动、添加和删除后会自动同步;`上传oss` 可手动全量重新发布、重试未完成任务。默认最多 16
|
|
123
|
+
启动、添加和删除后会自动同步;`上传oss` 可手动全量重新发布、重试未完成任务。默认最多 16 个并发请求,普通终端中以单行动态进度显示完成数、百分比和成功/失败数,最多每秒刷新 5 次;其他插件的默认 NoneBot 日志显示在进度行上方。同步结束后清除动态行,只保留一条结果汇总,错误详情仍单独记录。输出重定向、非交互控制台或已移除 NoneBot 默认日志处理器时仅输出汇总。
|
|
126
124
|
|
|
127
125
|
失败任务存入数据库,后台约每 30 秒重试,重启后继续处理。OSS 的图片、页面和目录版本依次同步,网络故障期间云端可能暂时落后于本地图。`randpic_oss_no_upload_list` 只影响 OSS,本地网页仍显示所有配置的分类;此前已同步的分类被排除后,其受本插件跟踪的云端对象会清理。未被数据库跟踪的历史对象不保证自动清理。
|
|
128
126
|
|
|
@@ -194,8 +192,6 @@ Bot 运行期间请通过指令增删图片,直接修改文件需重启才能
|
|
|
194
192
|
|
|
195
193
|
上面表示 `capoo` 指令对所有群启用,`马哥` 指令仅在群号 `123456789` 中被禁用。
|
|
196
194
|
|
|
197
|
-
> 旧版(v1.4.0 及更早)的纯数组格式 `["capoo", "马哥"]` 仍可识别:插件启动时会自动迁移为上述字典格式并回写文件。
|
|
198
|
-
|
|
199
195
|
在 `.env` 中,例如这样配置(这是例子,不代表你也需要这样配置):
|
|
200
196
|
```
|
|
201
197
|
randpic_store_dir_path="data/randpic"
|
|
@@ -256,7 +252,7 @@ randpic_oss_no_upload_list=["capoo"]
|
|
|
256
252
|
#### `启用马哥` 指令
|
|
257
253
|
<img src="./docs/enable.jpg" width="375" alt="启用马哥"/>
|
|
258
254
|
|
|
259
|
-
####
|
|
255
|
+
#### 静态页面网页示意(支持本地及 OSS)
|
|
260
256
|
<img src="./docs/web_page.png" width="375" alt="静态页面网页效果"/>
|
|
261
257
|
|
|
262
258
|
## 本地开发检查
|
|
@@ -31,13 +31,11 @@ _✨ 一个发送指令就能让你的 bot 发出对应指令的图片的插件
|
|
|
31
31
|
- 自动压缩超过 1MiB 的图片,群内添加时使用感知哈希检查相似图片。
|
|
32
32
|
- 本地静态图库无需 OSS;可选同步到阿里云 OSS。增删后自动更新网页,网页分页并使用浏览器原生懒加载。
|
|
33
33
|
|
|
34
|
-
###
|
|
34
|
+
### 初始化与数据持久化
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
首次启动会创建数据库,并扫描配置的图片文件夹,根据实际图片建立索引,发送次数从 0 开始。数据库不存在时也可由图片文件重新建立索引。
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
升级前建议完整备份存储目录。若需要回退 1.x,请恢复完整备份;2.0 不会持续更新旧表,不能直接让旧版读取新运行状态。
|
|
38
|
+
之后每次启动都会核对文件变化,并保留已有图片的发送次数、消息引用关系和同步状态。这些运行数据无法仅凭图片内容恢复,因此不要在正常使用期间删除数据库。
|
|
41
39
|
|
|
42
40
|
实现说明见 [架构与数据一致性](./docs/architecture.md)。
|
|
43
41
|
|
|
@@ -56,7 +54,6 @@ data/randpic/
|
|
|
56
54
|
│ └── 马哥/
|
|
57
55
|
├── database/
|
|
58
56
|
│ ├── data.db # 图片、发送次数、历史消息、待同步任务
|
|
59
|
-
│ ├── data.pre-v2.db # 从旧版迁移时生成的原始数据库备份
|
|
60
57
|
│ └── ... # 文件恢复日志、网页上传缓存等内部文件
|
|
61
58
|
└── public/ # 自动生成的本地静态网页与图片副本,请勿手动维护
|
|
62
59
|
```
|
|
@@ -92,7 +89,7 @@ Bot 运行期间请通过指令增删图片,直接修改文件需重启才能
|
|
|
92
89
|
|
|
93
90
|

|
|
94
91
|
|
|
95
|
-
启动、添加和删除后会自动同步;`上传oss` 可手动全量重新发布、重试未完成任务。默认最多 16
|
|
92
|
+
启动、添加和删除后会自动同步;`上传oss` 可手动全量重新发布、重试未完成任务。默认最多 16 个并发请求,普通终端中以单行动态进度显示完成数、百分比和成功/失败数,最多每秒刷新 5 次;其他插件的默认 NoneBot 日志显示在进度行上方。同步结束后清除动态行,只保留一条结果汇总,错误详情仍单独记录。输出重定向、非交互控制台或已移除 NoneBot 默认日志处理器时仅输出汇总。
|
|
96
93
|
|
|
97
94
|
失败任务存入数据库,后台约每 30 秒重试,重启后继续处理。OSS 的图片、页面和目录版本依次同步,网络故障期间云端可能暂时落后于本地图。`randpic_oss_no_upload_list` 只影响 OSS,本地网页仍显示所有配置的分类;此前已同步的分类被排除后,其受本插件跟踪的云端对象会清理。未被数据库跟踪的历史对象不保证自动清理。
|
|
98
95
|
|
|
@@ -164,8 +161,6 @@ Bot 运行期间请通过指令增删图片,直接修改文件需重启才能
|
|
|
164
161
|
|
|
165
162
|
上面表示 `capoo` 指令对所有群启用,`马哥` 指令仅在群号 `123456789` 中被禁用。
|
|
166
163
|
|
|
167
|
-
> 旧版(v1.4.0 及更早)的纯数组格式 `["capoo", "马哥"]` 仍可识别:插件启动时会自动迁移为上述字典格式并回写文件。
|
|
168
|
-
|
|
169
164
|
在 `.env` 中,例如这样配置(这是例子,不代表你也需要这样配置):
|
|
170
165
|
```
|
|
171
166
|
randpic_store_dir_path="data/randpic"
|
|
@@ -226,7 +221,7 @@ randpic_oss_no_upload_list=["capoo"]
|
|
|
226
221
|
#### `启用马哥` 指令
|
|
227
222
|
<img src="./docs/enable.jpg" width="375" alt="启用马哥"/>
|
|
228
223
|
|
|
229
|
-
####
|
|
224
|
+
#### 静态页面网页示意(支持本地及 OSS)
|
|
230
225
|
<img src="./docs/web_page.png" width="375" alt="静态页面网页效果"/>
|
|
231
226
|
|
|
232
227
|
## 本地开发检查
|
{nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/nonebot_plugin_randpic/compress.py
RENAMED
|
@@ -136,26 +136,3 @@ def process_image(data: bytes, max_bytes: int = MAX_BYTES, target_quality: int =
|
|
|
136
136
|
except Exception as exc:
|
|
137
137
|
raise InvalidImage('图片损坏、像素过大或格式无法处理。') from exc
|
|
138
138
|
return ProcessedImage(data, extension, hashlib.sha256(data).hexdigest(), phash, original_hash)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
# 保留常用图片工具入口,便于既有调用迁移。
|
|
142
|
-
def compress_image_from_bytes(image_bytes: bytes, log_progress: bool = True) -> bytes:
|
|
143
|
-
return process_image(image_bytes).data
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def adaptive_compress_bytes(image_bytes: bytes, max_size_mb: float = 1, target_quality: int = 85) -> bytes:
|
|
147
|
-
if max_size_mb <= 0:
|
|
148
|
-
raise ValueError('目标大小必须大于 0。')
|
|
149
|
-
return process_image(image_bytes, int(max_size_mb * MAX_BYTES), target_quality).data
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def get_image_extension(image_bytes: bytes) -> str:
|
|
153
|
-
try:
|
|
154
|
-
with _open(image_bytes) as image:
|
|
155
|
-
return FORMATS[image.format]
|
|
156
|
-
except Exception as exc:
|
|
157
|
-
raise InvalidImage('无法识别图片格式。') from exc
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def compute_phash(image_bytes: bytes) -> str:
|
|
161
|
-
return process_image(image_bytes).phash
|
{nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/nonebot_plugin_randpic/config.py
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""插件设置与指令配置文件。"""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
from pathlib import Path
|
|
@@ -34,7 +34,7 @@ class Config(BaseModel):
|
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
class CommandSettings:
|
|
37
|
-
"""
|
|
37
|
+
"""读取 dict[str, list[int]] 格式的指令设置;损坏的文件绝不覆盖。"""
|
|
38
38
|
|
|
39
39
|
def __init__(self, root: Path):
|
|
40
40
|
self.path = root / 'randpic_commands.json'
|
|
@@ -48,12 +48,9 @@ class CommandSettings:
|
|
|
48
48
|
return
|
|
49
49
|
try:
|
|
50
50
|
data = json.loads(self.path.read_text(encoding='utf-8'))
|
|
51
|
-
if isinstance(data,
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
entries = list(data.items())
|
|
55
|
-
else:
|
|
56
|
-
raise ValueError('顶层必须是对象或旧版指令数组')
|
|
51
|
+
if not isinstance(data, dict):
|
|
52
|
+
raise ValueError('顶层必须是对象:指令名对应禁用群号列表')
|
|
53
|
+
entries = list(data.items())
|
|
57
54
|
except (OSError, ValueError) as exc:
|
|
58
55
|
self.writable = False
|
|
59
56
|
self.commands = {}
|
|
@@ -71,8 +68,6 @@ class CommandSettings:
|
|
|
71
68
|
valid_groups = frozenset(g for g in groups if type(g) is int and g > 0)
|
|
72
69
|
commands[name] = commands.get(name, frozenset()) | valid_groups
|
|
73
70
|
self.commands = commands
|
|
74
|
-
if isinstance(data, list):
|
|
75
|
-
self.save()
|
|
76
71
|
|
|
77
72
|
def save(self) -> bool:
|
|
78
73
|
if not self.writable:
|
{nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/nonebot_plugin_randpic/library.py
RENAMED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
"""图库业务:启动整理、添加、引用删除、发送快照,均在工作线程中执行。"""
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
|
-
import re
|
|
5
4
|
import sqlite3
|
|
6
5
|
import uuid
|
|
7
6
|
from collections import defaultdict
|
|
8
|
-
from contextlib import closing
|
|
9
7
|
from dataclasses import dataclass, replace
|
|
10
8
|
from pathlib import Path
|
|
11
9
|
from typing import Dict, List, Optional, Tuple
|
|
@@ -36,7 +34,7 @@ class ImageLibrary:
|
|
|
36
34
|
def __init__(self, root: Path):
|
|
37
35
|
self.root = root.resolve()
|
|
38
36
|
self.image_root = self.root / 'img'
|
|
39
|
-
self.repository = Repository(self.root / 'database' / 'data.db'
|
|
37
|
+
self.repository = Repository(self.root / 'database' / 'data.db')
|
|
40
38
|
self.settings = CommandSettings(self.root)
|
|
41
39
|
self.journal = FileJournal(self.root, self.repository)
|
|
42
40
|
self.store_lock = StoreLock(self.root / 'database' / 'randpic.lock')
|
|
@@ -48,7 +46,6 @@ class ImageLibrary:
|
|
|
48
46
|
directory.mkdir(parents=True, exist_ok=True)
|
|
49
47
|
self.store_lock.acquire()
|
|
50
48
|
try:
|
|
51
|
-
self._recover_v1_staging()
|
|
52
49
|
self.repository.initialize()
|
|
53
50
|
self.journal.initialize()
|
|
54
51
|
self.journal.recover()
|
|
@@ -61,30 +58,6 @@ class ImageLibrary:
|
|
|
61
58
|
def close(self) -> None:
|
|
62
59
|
self.store_lock.release()
|
|
63
60
|
|
|
64
|
-
def _recover_v1_staging(self) -> None:
|
|
65
|
-
if not self.repository.path.exists():
|
|
66
|
-
return
|
|
67
|
-
with closing(sqlite3.connect(self.repository.path)) as db:
|
|
68
|
-
if not db.execute("SELECT 1 FROM sqlite_master WHERE name = 'randpic_sent_images'").fetchone():
|
|
69
|
-
return
|
|
70
|
-
for staged in self.image_root.glob('*/.randpic-delete-*.tmp'):
|
|
71
|
-
match = re.fullmatch(r'\.randpic-delete-(.+)\.[0-9a-f]{32}\.tmp', staged.name)
|
|
72
|
-
if match is None:
|
|
73
|
-
continue
|
|
74
|
-
try:
|
|
75
|
-
key = f'{staged.parent.name}/{match[1]}'
|
|
76
|
-
original = safe_path(self.image_root, key)
|
|
77
|
-
row = db.execute('SELECT MIN(deleted) FROM randpic_sent_images WHERE img_url = ? AND content_hash = ?',
|
|
78
|
-
(key, digest_file(staged))).fetchone()
|
|
79
|
-
if row[0] == 1:
|
|
80
|
-
staged.unlink()
|
|
81
|
-
elif not original.exists():
|
|
82
|
-
staged.rename(original)
|
|
83
|
-
else:
|
|
84
|
-
raise RandpicError('恢复位置已存在')
|
|
85
|
-
except Exception as exc:
|
|
86
|
-
logger.error(f'恢复旧版图片备份失败,已保留文件:{staged} ({exc})')
|
|
87
|
-
|
|
88
61
|
def _candidates(self, command: str, existing: List[ImageRecord]) -> List[_Candidate]:
|
|
89
62
|
directory = safe_path(self.image_root, f'{command}/placeholder').parent
|
|
90
63
|
directory.mkdir(parents=True, exist_ok=True)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""终端单行进度,与 NoneBot 默认控制台日志共用 Rich Console。"""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from nonebot import log as nonebot_log
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.live import Live
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SyncProgress:
|
|
15
|
+
# 同时维护多个图库时也只显示一个动态行,不等待其他同步结束。
|
|
16
|
+
_display_lock = threading.Lock()
|
|
17
|
+
|
|
18
|
+
def __init__(self, total: int):
|
|
19
|
+
self.total = total
|
|
20
|
+
self.stream = sys.stdout
|
|
21
|
+
self.console: Optional[Console] = None
|
|
22
|
+
self.live: Optional[Live] = None
|
|
23
|
+
self._handler_id: Optional[int] = None
|
|
24
|
+
self._owns_display = False
|
|
25
|
+
self._last_refresh = 0.0
|
|
26
|
+
|
|
27
|
+
def _text(self, completed: int, succeeded: int, failed: int) -> Text:
|
|
28
|
+
percent = completed * 100 / self.total if self.total else 100
|
|
29
|
+
return Text(
|
|
30
|
+
f'OSS 同步 {completed}/{self.total} ({percent:.1f}%) '
|
|
31
|
+
f'成功 {succeeded},失败 {failed}',
|
|
32
|
+
no_wrap=True, overflow='ellipsis',
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def _write_log(self, message) -> None:
|
|
36
|
+
# 保留 NoneBot 格式和 ANSI 颜色;日志由 Live 自动排到进度行上方。
|
|
37
|
+
self.console.print(Text.from_ansi(str(message).rstrip('\n')), soft_wrap=True)
|
|
38
|
+
|
|
39
|
+
def __enter__(self):
|
|
40
|
+
self.console = Console(file=self.stream)
|
|
41
|
+
if not self.console.is_terminal or self.console.is_dumb_terminal or self.console.is_jupyter:
|
|
42
|
+
return self
|
|
43
|
+
if not self._display_lock.acquire(blocking=False):
|
|
44
|
+
return self
|
|
45
|
+
self._owns_display = True
|
|
46
|
+
try:
|
|
47
|
+
# 只临时接管 NoneBot 自带的控制台输出,文件和其他自定义 sink 保持原样。
|
|
48
|
+
# 若默认 sink 已被用户移除,则尊重其日志配置,只输出最终汇总。
|
|
49
|
+
try:
|
|
50
|
+
nonebot_log.logger.remove(nonebot_log.logger_id)
|
|
51
|
+
except ValueError:
|
|
52
|
+
self._release_display()
|
|
53
|
+
return self
|
|
54
|
+
try:
|
|
55
|
+
self._handler_id = nonebot_log.logger.add(
|
|
56
|
+
self._write_log, level=0, diagnose=False,
|
|
57
|
+
filter=nonebot_log.default_filter, format=nonebot_log.default_format,
|
|
58
|
+
colorize=True,
|
|
59
|
+
)
|
|
60
|
+
except Exception:
|
|
61
|
+
nonebot_log.logger_id = self._add_default_handler()
|
|
62
|
+
raise
|
|
63
|
+
nonebot_log.logger_id = self._handler_id
|
|
64
|
+
self.live = Live(
|
|
65
|
+
self._text(0, 0, 0), console=self.console, transient=True,
|
|
66
|
+
auto_refresh=False, redirect_stdout=True, redirect_stderr=True,
|
|
67
|
+
)
|
|
68
|
+
self.live.start(refresh=True)
|
|
69
|
+
self._last_refresh = time.monotonic()
|
|
70
|
+
except Exception:
|
|
71
|
+
# 显示不可用不能中断上传;退出时同时恢复日志和标准输出。
|
|
72
|
+
self.__exit__(None, None, None)
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
def update(self, completed: int, succeeded: int, failed: int) -> None:
|
|
76
|
+
if self.live is None:
|
|
77
|
+
return
|
|
78
|
+
now = time.monotonic()
|
|
79
|
+
if completed != self.total and now - self._last_refresh < 0.2:
|
|
80
|
+
return
|
|
81
|
+
try:
|
|
82
|
+
self.live.update(self._text(completed, succeeded, failed), refresh=True)
|
|
83
|
+
self._last_refresh = now
|
|
84
|
+
except Exception:
|
|
85
|
+
self.__exit__(None, None, None)
|
|
86
|
+
|
|
87
|
+
def _add_default_handler(self) -> int:
|
|
88
|
+
return nonebot_log.logger.add(
|
|
89
|
+
self.stream, level=0, diagnose=False,
|
|
90
|
+
filter=nonebot_log.default_filter, format=nonebot_log.default_format,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def _release_display(self) -> None:
|
|
94
|
+
if self._owns_display:
|
|
95
|
+
self._owns_display = False
|
|
96
|
+
self._display_lock.release()
|
|
97
|
+
|
|
98
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
99
|
+
try:
|
|
100
|
+
if self.live is not None:
|
|
101
|
+
try:
|
|
102
|
+
self.live.stop()
|
|
103
|
+
except Exception:
|
|
104
|
+
pass # 终端关闭等显示错误不应把成功的上传变成失败。
|
|
105
|
+
finally:
|
|
106
|
+
self.live = None
|
|
107
|
+
finally:
|
|
108
|
+
try:
|
|
109
|
+
if self._handler_id is not None:
|
|
110
|
+
handler_id, self._handler_id = self._handler_id, None
|
|
111
|
+
try:
|
|
112
|
+
nonebot_log.logger.remove(handler_id)
|
|
113
|
+
except ValueError:
|
|
114
|
+
pass # 同步期间用户重新配置了日志,不重新添加默认控制台。
|
|
115
|
+
else:
|
|
116
|
+
nonebot_log.logger_id = self._add_default_handler()
|
|
117
|
+
finally:
|
|
118
|
+
self._release_display()
|
{nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/nonebot_plugin_randpic/repository.py
RENAMED
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
"""SQLite 存储。连接只在线程内使用,事务不跨聊天请求或网络请求。"""
|
|
2
2
|
|
|
3
3
|
import sqlite3
|
|
4
|
-
import
|
|
5
|
-
import uuid
|
|
6
|
-
from contextlib import closing, contextmanager
|
|
4
|
+
from contextlib import contextmanager
|
|
7
5
|
from dataclasses import fields
|
|
8
6
|
from pathlib import Path
|
|
9
|
-
from typing import
|
|
10
|
-
|
|
11
|
-
from nonebot.log import logger
|
|
7
|
+
from typing import Iterator, List, Mapping, Optional, Tuple
|
|
12
8
|
|
|
13
9
|
from .models import ImageRecord, RandpicError, SyncJob
|
|
14
|
-
from .utils import digest_file, parse_index, safe_path, valid_command
|
|
15
10
|
|
|
16
11
|
|
|
17
12
|
IMAGE_COLUMNS = ', '.join(field.name for field in fields(ImageRecord))
|
|
@@ -57,8 +52,8 @@ def _hamming(left: str, right: str) -> int:
|
|
|
57
52
|
|
|
58
53
|
|
|
59
54
|
class Repository:
|
|
60
|
-
def __init__(self, path: Path
|
|
61
|
-
self.path
|
|
55
|
+
def __init__(self, path: Path):
|
|
56
|
+
self.path = path
|
|
62
57
|
|
|
63
58
|
@contextmanager
|
|
64
59
|
def connection(self, *, write: bool = False) -> Iterator[sqlite3.Connection]:
|
|
@@ -98,93 +93,15 @@ class Repository:
|
|
|
98
93
|
version = int(self.meta(db, 'schema_version')) if 'randpic_meta' in names else 0
|
|
99
94
|
if version > 2:
|
|
100
95
|
raise RandpicError('数据库来自更新版本的 Randpic,不能用 2.0.0 覆盖。')
|
|
101
|
-
legacy = any(name.startswith('Pic_of_') for name in names) or 'randpic_sent_images' in names
|
|
102
|
-
backup = self.path.with_name(self.path.stem + '.pre-v2.db')
|
|
103
|
-
if not version and legacy and not backup.exists():
|
|
104
|
-
temporary = backup.with_name(backup.name + '.' + uuid.uuid4().hex + '.tmp')
|
|
105
|
-
try:
|
|
106
|
-
with closing(sqlite3.connect(temporary)) as destination:
|
|
107
|
-
db.backup(destination)
|
|
108
|
-
temporary.replace(backup)
|
|
109
|
-
finally:
|
|
110
|
-
if temporary.exists():
|
|
111
|
-
temporary.unlink()
|
|
112
96
|
db.execute('PRAGMA journal_mode = WAL')
|
|
113
97
|
db.execute('PRAGMA synchronous = FULL')
|
|
114
98
|
db.executescript(SCHEMA)
|
|
115
99
|
with self.connection(write=True) as db:
|
|
116
100
|
if int(self.meta(db, 'schema_version')) == 0:
|
|
117
|
-
self._migrate_legacy(db, names)
|
|
118
101
|
self.set_meta(db, 'schema_version', '2')
|
|
119
102
|
self.set_meta(db, 'published_revision', '-1')
|
|
120
103
|
self.mark_dirty(db)
|
|
121
104
|
|
|
122
|
-
def _migrate_legacy(self, db: sqlite3.Connection, names: set) -> None:
|
|
123
|
-
current: Dict[Tuple[str, str], ImageRecord] = {}
|
|
124
|
-
for name in sorted(names):
|
|
125
|
-
if not name.startswith('Pic_of_') or not valid_command(name[7:]):
|
|
126
|
-
continue
|
|
127
|
-
command = name[7:]
|
|
128
|
-
self.set_sequence(db, command, 0)
|
|
129
|
-
columns = {row[1] for row in db.execute(f'PRAGMA table_info("{name}")')}
|
|
130
|
-
if 'img_url' not in columns:
|
|
131
|
-
continue
|
|
132
|
-
for row in db.execute(f'SELECT * FROM "{name}"'):
|
|
133
|
-
key = str(row['img_url']).replace('\\', '/')
|
|
134
|
-
try:
|
|
135
|
-
path = safe_path(self.image_root, key)
|
|
136
|
-
if key.split('/')[0] != command:
|
|
137
|
-
raise RandpicError('分类与图片路径不一致')
|
|
138
|
-
self.set_sequence(db, command, parse_index(path.stem, command) or 0)
|
|
139
|
-
exists = path.is_file()
|
|
140
|
-
content_hash = digest_file(path) if exists else ''
|
|
141
|
-
stat = path.stat() if exists else None
|
|
142
|
-
count = max(0, int(row['send_count'] or 0)) if 'send_count' in columns else 0
|
|
143
|
-
except (OSError, ValueError, RandpicError) as exc:
|
|
144
|
-
logger.warning(f'迁移旧图片记录失败,保留原表:{key} ({exc})')
|
|
145
|
-
continue
|
|
146
|
-
previous = current.get((command, path.name))
|
|
147
|
-
if previous is not None:
|
|
148
|
-
db.execute('UPDATE randpic_images SET send_count = send_count + ? WHERE id = ?', (count, previous.id))
|
|
149
|
-
continue
|
|
150
|
-
image = ImageRecord(
|
|
151
|
-
uuid.uuid4().hex, command, path.name, content_hash,
|
|
152
|
-
str(row['phash'] or '') if 'phash' in columns else '', count,
|
|
153
|
-
stat.st_size if stat else 0, stat.st_mtime_ns if stat else 0, not exists, False,
|
|
154
|
-
)
|
|
155
|
-
self.upsert_image(db, image)
|
|
156
|
-
current[(command, path.name)] = image
|
|
157
|
-
db.execute('INSERT OR IGNORE INTO randpic_remote VALUES (?, ?)', (key, ''))
|
|
158
|
-
db.execute('INSERT OR IGNORE INTO randpic_remote VALUES (?, ?)', (f'{command}/index.html', ''))
|
|
159
|
-
if current:
|
|
160
|
-
db.execute('INSERT OR IGNORE INTO randpic_remote VALUES (?, ?)', ('index.html', ''))
|
|
161
|
-
if 'randpic_sent_images' not in names:
|
|
162
|
-
return
|
|
163
|
-
historical: Dict[Tuple[str, str, str], str] = {}
|
|
164
|
-
for row in db.execute('SELECT * FROM randpic_sent_images'):
|
|
165
|
-
try:
|
|
166
|
-
command, key, content_hash = str(row['command']), str(row['img_url']).replace('\\', '/'), str(row['content_hash'])
|
|
167
|
-
path = safe_path(self.image_root, key)
|
|
168
|
-
if path.parent.name != command:
|
|
169
|
-
raise RandpicError('分类与图片路径不一致')
|
|
170
|
-
self.set_sequence(db, command, parse_index(path.stem, command) or 0)
|
|
171
|
-
image = current.get((command, path.name))
|
|
172
|
-
if row['deleted'] or image is None or image.deleted or image.content_hash != content_hash:
|
|
173
|
-
identity = (command, path.name, content_hash)
|
|
174
|
-
image_id = historical.get(identity)
|
|
175
|
-
if image_id is None:
|
|
176
|
-
image_id = uuid.uuid4().hex
|
|
177
|
-
historical[identity] = image_id
|
|
178
|
-
self.upsert_image(db, ImageRecord(image_id, command, path.name, content_hash, '', deleted=True, validated=False))
|
|
179
|
-
if row['sync_pending']:
|
|
180
|
-
db.execute('INSERT OR IGNORE INTO randpic_remote VALUES (?, ?)', (key, ''))
|
|
181
|
-
else:
|
|
182
|
-
image_id = image.id
|
|
183
|
-
db.execute('INSERT OR REPLACE INTO randpic_messages VALUES (?, ?, ?, ?)',
|
|
184
|
-
(str(row['bot_id']), int(row['group_id']), int(row['message_id']), image_id))
|
|
185
|
-
except (KeyError, IndexError, ValueError, RandpicError) as exc:
|
|
186
|
-
logger.warning(f'迁移历史发图消息失败,保留原记录:{exc}')
|
|
187
|
-
|
|
188
105
|
@staticmethod
|
|
189
106
|
def upsert_image(db: sqlite3.Connection, image: ImageRecord) -> None:
|
|
190
107
|
names = [field.name for field in fields(ImageRecord)]
|
{nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/nonebot_plugin_randpic/service.py
RENAMED
|
@@ -13,6 +13,7 @@ from .ali_oss import OSSUploaderV2
|
|
|
13
13
|
from .config import Config
|
|
14
14
|
from .library import ImageLibrary
|
|
15
15
|
from .models import ImageRecord, ImageSnapshot, MutationResult, RandpicError, SyncResult
|
|
16
|
+
from .progress import SyncProgress
|
|
16
17
|
from .utils import run_blocking
|
|
17
18
|
from .web import GalleryPublisher
|
|
18
19
|
|
|
@@ -192,46 +193,51 @@ class RandpicService:
|
|
|
192
193
|
if not jobs:
|
|
193
194
|
return SyncResult()
|
|
194
195
|
succeeded = failed = completed = 0
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
196
|
+
started = asyncio.get_running_loop().time()
|
|
197
|
+
|
|
198
|
+
with SyncProgress(len(jobs)) as progress:
|
|
199
|
+
async def phase(items):
|
|
200
|
+
queue = asyncio.Queue()
|
|
201
|
+
for job in items:
|
|
202
|
+
queue.put_nowait(job)
|
|
203
|
+
|
|
204
|
+
async def worker():
|
|
205
|
+
nonlocal succeeded, failed, completed
|
|
206
|
+
while not queue.empty():
|
|
207
|
+
job = queue.get_nowait()
|
|
208
|
+
try:
|
|
209
|
+
if job.action == 'delete':
|
|
210
|
+
ok = await self.uploader.delete_file(job.key)
|
|
211
|
+
else:
|
|
212
|
+
ok = await self.uploader.upload_file(job.local_path, job.key, job.content_hash)
|
|
213
|
+
if ok:
|
|
214
|
+
await run_blocking(self.repository.complete_job, job)
|
|
215
|
+
succeeded += 1
|
|
216
|
+
else:
|
|
217
|
+
await run_blocking(self.repository.fail_job, job, '远端请求失败或任务已过期')
|
|
218
|
+
failed += 1
|
|
219
|
+
except Exception as exc:
|
|
220
|
+
logger.error(f'同步 OSS 任务失败,已保留以便重试:{job.key} ({exc})')
|
|
216
221
|
failed += 1
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
await asyncio.gather(*(worker() for _ in range(min(self.config.randpic_upload_concurrency, len(items)))))
|
|
229
|
-
|
|
230
|
-
# 先同步图片,再同步页面,最后更新用于浏览器自动刷新的目录版本。
|
|
231
|
-
await phase([job for job in jobs if not job.key.endswith(('.html', '.json'))])
|
|
232
|
-
await phase([job for job in jobs if job.key.endswith('.html')])
|
|
233
|
-
await phase([job for job in jobs if job.key.endswith('.json')])
|
|
222
|
+
finally:
|
|
223
|
+
queue.task_done()
|
|
224
|
+
completed += 1
|
|
225
|
+
progress.update(completed, succeeded, failed)
|
|
226
|
+
|
|
227
|
+
await asyncio.gather(*(worker() for _ in range(min(self.config.randpic_upload_concurrency, len(items)))))
|
|
228
|
+
|
|
229
|
+
# 先同步图片,再同步页面,最后更新用于浏览器自动刷新的目录版本。
|
|
230
|
+
await phase([job for job in jobs if not job.key.endswith(('.html', '.json'))])
|
|
231
|
+
await phase([job for job in jobs if job.key.endswith('.html')])
|
|
232
|
+
await phase([job for job in jobs if job.key.endswith('.json')])
|
|
234
233
|
pending = len(await run_blocking(self.repository.sync_jobs))
|
|
234
|
+
elapsed = asyncio.get_running_loop().time() - started
|
|
235
|
+
summary = (f'OSS 同步完成:成功 {succeeded},失败 {failed},待处理 {pending},'
|
|
236
|
+
f'用时 {elapsed:.1f} 秒')
|
|
237
|
+
if failed:
|
|
238
|
+
logger.warning(summary + ';失败任务将自动重试')
|
|
239
|
+
else:
|
|
240
|
+
logger.info(summary)
|
|
235
241
|
return SyncResult(succeeded=succeeded, failed=failed, pending=pending)
|
|
236
242
|
|
|
237
243
|
async def _retry_worker(self) -> None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "nonebot-plugin-randpic"
|
|
3
|
-
version = "2.0.
|
|
3
|
+
version = "2.0.1"
|
|
4
4
|
description = "一个通过指令随机发送你存储的图片的nonebot2插件"
|
|
5
5
|
authors = ["HuParry <hu_parry@qq.com>"]
|
|
6
6
|
license = "MIT"
|
|
@@ -20,6 +20,7 @@ alibabacloud_oss_v2 = ">=1.2.0"
|
|
|
20
20
|
aiohttp = ">=3.8.0,<4.0.0"
|
|
21
21
|
pillow = ">=11.0.0"
|
|
22
22
|
imagehash = ">=4.3.0"
|
|
23
|
+
rich = ">=13.0,<15.0"
|
|
23
24
|
|
|
24
25
|
[build-system]
|
|
25
26
|
requires = ["poetry-core"]
|
|
File without changes
|
{nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/nonebot_plugin_randpic/__init__.py
RENAMED
|
File without changes
|
{nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/nonebot_plugin_randpic/ali_oss.py
RENAMED
|
File without changes
|
|
File without changes
|
{nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/nonebot_plugin_randpic/handlers.py
RENAMED
|
File without changes
|
{nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/nonebot_plugin_randpic/journal.py
RENAMED
|
File without changes
|
{nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/nonebot_plugin_randpic/models.py
RENAMED
|
File without changes
|
{nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.1}/nonebot_plugin_randpic/utils.py
RENAMED
|
File without changes
|
|
File without changes
|