nonebot-plugin-randpic 2.0.0__tar.gz → 2.0.2__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.
Files changed (18) hide show
  1. {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.2}/PKG-INFO +9 -13
  2. {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.2}/README.md +7 -12
  3. {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.2}/nonebot_plugin_randpic/ali_oss.py +1 -1
  4. {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.2}/nonebot_plugin_randpic/compress.py +0 -23
  5. {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.2}/nonebot_plugin_randpic/config.py +5 -10
  6. {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.2}/nonebot_plugin_randpic/library.py +1 -28
  7. nonebot_plugin_randpic-2.0.2/nonebot_plugin_randpic/progress.py +118 -0
  8. {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.2}/nonebot_plugin_randpic/repository.py +4 -87
  9. {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.2}/nonebot_plugin_randpic/service.py +43 -38
  10. {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.2}/nonebot_plugin_randpic/web.py +7 -18
  11. {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.2}/pyproject.toml +2 -1
  12. {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.2}/LICENSE +0 -0
  13. {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.2}/nonebot_plugin_randpic/__init__.py +0 -0
  14. {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.2}/nonebot_plugin_randpic/drivers/fastapi.py +0 -0
  15. {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.2}/nonebot_plugin_randpic/handlers.py +0 -0
  16. {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.2}/nonebot_plugin_randpic/journal.py +0 -0
  17. {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.2}/nonebot_plugin_randpic/models.py +0 -0
  18. {nonebot_plugin_randpic-2.0.0 → nonebot_plugin_randpic-2.0.2}/nonebot_plugin_randpic/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nonebot-plugin-randpic
3
- Version: 2.0.0
3
+ Version: 2.0.2
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
- ### 2.0.0 升级说明
65
+ ### 初始化与数据持久化
65
66
 
66
- 2.0.0 重构了图片存储、数据库、指令和网页同步。原有 `.env` 配置项、指令配置 JSON、图片目录和命名规则继续使用。
67
+ 首次启动会创建数据库,并扫描配置的图片文件夹,根据实际图片建立索引,发送次数从 0 开始。数据库不存在时也可由图片文件重新建立索引。
67
68
 
68
- 首次启动会将旧版 `Pic_of_<指令>` 表和历史引用关系迁移到统一的图片表,保留能对应到现有图片的发送次数和引用关系。旧表保留,迁移前自动备份数据库到 `database/data.pre-v2.db`。之后启动只核对文件变化,不再清空并重建图片记录。
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
  ```
@@ -107,7 +105,7 @@ Bot 运行期间请通过指令增删图片,直接修改文件需重启才能
107
105
 
108
106
  使用 FastAPI 驱动时,Bot 启动后即可通过 `http://<host>:<port>/randpic/` 查看图库,无需配置 OSS。默认每页 100 张图片,可通过 `randpic_web_page_size` 调整。图片使用 `loading="lazy"`,按需加载。
109
107
 
110
- 增删图片后本地页面立即重新发布;已打开的网页在可见时每 15 秒检查一次目录版本,发现变化会自动刷新。没有 FastAPI 服务的驱动仍可使用聊天指令和 OSS 同步。
108
+ 增删图片后本地页面立即重新发布,浏览器手动刷新即可查看最新内容。没有 FastAPI 服务的驱动仍可使用聊天指令和 OSS 同步。
111
109
 
112
110
  #### 阿里云 OSS
113
111
 
@@ -122,9 +120,9 @@ Bot 运行期间请通过指令增删图片,直接修改文件需重启才能
122
120
 
123
121
  ![](./docs/static-page.png)
124
122
 
125
- 启动、添加和删除后会自动同步;`上传oss` 可手动全量重新发布、重试未完成任务。默认最多 16 个并发请求,终端按节流频率输出独立进度日志,不使用持续占用终端的进度条。
123
+ 启动、添加和删除后会自动同步;`上传oss` 可手动全量重新发布、重试未完成任务。默认最多 16 个并发请求,普通终端中以单行动态进度显示完成数、百分比和成功/失败数,最多每秒刷新 5 次;其他插件的默认 NoneBot 日志显示在进度行上方。同步结束后清除动态行,只保留一条结果汇总,错误详情仍单独记录。输出重定向、非交互控制台或已移除 NoneBot 默认日志处理器时仅输出汇总。
126
124
 
127
- 失败任务存入数据库,后台约每 30 秒重试,重启后继续处理。OSS 的图片、页面和目录版本依次同步,网络故障期间云端可能暂时落后于本地图。`randpic_oss_no_upload_list` 只影响 OSS,本地网页仍显示所有配置的分类;此前已同步的分类被排除后,其受本插件跟踪的云端对象会清理。未被数据库跟踪的历史对象不保证自动清理。
125
+ 失败任务存入数据库,后台约每 30 秒重试,重启后继续处理。OSS 的图片和页面依次同步,网络故障期间云端可能暂时落后于本地图。`randpic_oss_no_upload_list` 只影响 OSS,本地网页仍显示所有配置的分类;此前已同步的分类被排除后,其受本插件跟踪的云端对象会清理。未被数据库跟踪的历史对象不保证自动清理。
128
126
 
129
127
  ## 💿 安装
130
128
 
@@ -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
- #### 静态页面网页效果(旧版示意图,2.0 支持本地及 OSS)
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
- ### 2.0.0 升级说明
34
+ ### 初始化与数据持久化
35
35
 
36
- 2.0.0 重构了图片存储、数据库、指令和网页同步。原有 `.env` 配置项、指令配置 JSON、图片目录和命名规则继续使用。
36
+ 首次启动会创建数据库,并扫描配置的图片文件夹,根据实际图片建立索引,发送次数从 0 开始。数据库不存在时也可由图片文件重新建立索引。
37
37
 
38
- 首次启动会将旧版 `Pic_of_<指令>` 表和历史引用关系迁移到统一的图片表,保留能对应到现有图片的发送次数和引用关系。旧表保留,迁移前自动备份数据库到 `database/data.pre-v2.db`。之后启动只核对文件变化,不再清空并重建图片记录。
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
  ```
@@ -77,7 +74,7 @@ Bot 运行期间请通过指令增删图片,直接修改文件需重启才能
77
74
 
78
75
  使用 FastAPI 驱动时,Bot 启动后即可通过 `http://<host>:<port>/randpic/` 查看图库,无需配置 OSS。默认每页 100 张图片,可通过 `randpic_web_page_size` 调整。图片使用 `loading="lazy"`,按需加载。
79
76
 
80
- 增删图片后本地页面立即重新发布;已打开的网页在可见时每 15 秒检查一次目录版本,发现变化会自动刷新。没有 FastAPI 服务的驱动仍可使用聊天指令和 OSS 同步。
77
+ 增删图片后本地页面立即重新发布,浏览器手动刷新即可查看最新内容。没有 FastAPI 服务的驱动仍可使用聊天指令和 OSS 同步。
81
78
 
82
79
  #### 阿里云 OSS
83
80
 
@@ -92,9 +89,9 @@ Bot 运行期间请通过指令增删图片,直接修改文件需重启才能
92
89
 
93
90
  ![](./docs/static-page.png)
94
91
 
95
- 启动、添加和删除后会自动同步;`上传oss` 可手动全量重新发布、重试未完成任务。默认最多 16 个并发请求,终端按节流频率输出独立进度日志,不使用持续占用终端的进度条。
92
+ 启动、添加和删除后会自动同步;`上传oss` 可手动全量重新发布、重试未完成任务。默认最多 16 个并发请求,普通终端中以单行动态进度显示完成数、百分比和成功/失败数,最多每秒刷新 5 次;其他插件的默认 NoneBot 日志显示在进度行上方。同步结束后清除动态行,只保留一条结果汇总,错误详情仍单独记录。输出重定向、非交互控制台或已移除 NoneBot 默认日志处理器时仅输出汇总。
96
93
 
97
- 失败任务存入数据库,后台约每 30 秒重试,重启后继续处理。OSS 的图片、页面和目录版本依次同步,网络故障期间云端可能暂时落后于本地图。`randpic_oss_no_upload_list` 只影响 OSS,本地网页仍显示所有配置的分类;此前已同步的分类被排除后,其受本插件跟踪的云端对象会清理。未被数据库跟踪的历史对象不保证自动清理。
94
+ 失败任务存入数据库,后台约每 30 秒重试,重启后继续处理。OSS 的图片和页面依次同步,网络故障期间云端可能暂时落后于本地图。`randpic_oss_no_upload_list` 只影响 OSS,本地网页仍显示所有配置的分类;此前已同步的分类被排除后,其受本插件跟踪的云端对象会清理。未被数据库跟踪的历史对象不保证自动清理。
98
95
 
99
96
  ## 💿 安装
100
97
 
@@ -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
- #### 静态页面网页效果(旧版示意图,2.0 支持本地及 OSS)
224
+ #### 静态页面网页示意(支持本地及 OSS)
230
225
  <img src="./docs/web_page.png" width="375" alt="静态页面网页效果"/>
231
226
 
232
227
  ## 本地开发检查
@@ -44,7 +44,7 @@ class OSSUploaderV2:
44
44
  result = await asyncio.wait_for(self.client.put_object(oss.PutObjectRequest(
45
45
  bucket=self.bucket, key=oss_key, body=data,
46
46
  content_type=mimetypes.guess_type(oss_key)[0] or 'application/octet-stream',
47
- cache_control='no-cache' if oss_key.endswith(('.html', '.json')) else 'public, max-age=31536000, immutable',
47
+ cache_control='no-cache' if oss_key.endswith('.html') else 'public, max-age=31536000, immutable',
48
48
  )), timeout=60)
49
49
  if 200 <= result.status_code < 300:
50
50
  return True
@@ -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
@@ -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
- """保留旧版 list[str] 和 dict[str, list[int]] 格式;损坏的文件绝不覆盖。"""
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, list):
52
- entries = [(name, []) for name in data]
53
- elif isinstance(data, dict):
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:
@@ -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', self.image_root)
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()
@@ -1,17 +1,12 @@
1
1
  """SQLite 存储。连接只在线程内使用,事务不跨聊天请求或网络请求。"""
2
2
 
3
3
  import sqlite3
4
- import time
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 Dict, Iterator, List, Mapping, Optional, Tuple
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, image_root: Path):
61
- self.path, self.image_root = path, image_root
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)]
@@ -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,50 @@ class RandpicService:
192
193
  if not jobs:
193
194
  return SyncResult()
194
195
  succeeded = failed = completed = 0
195
- last_report = 0.0
196
-
197
- async def phase(items):
198
- queue = asyncio.Queue()
199
- for job in items:
200
- queue.put_nowait(job)
201
-
202
- async def worker():
203
- nonlocal succeeded, failed, completed, last_report
204
- while not queue.empty():
205
- job = queue.get_nowait()
206
- try:
207
- if job.action == 'delete':
208
- ok = await self.uploader.delete_file(job.key)
209
- else:
210
- ok = await self.uploader.upload_file(job.local_path, job.key, job.content_hash)
211
- if ok:
212
- await run_blocking(self.repository.complete_job, job)
213
- succeeded += 1
214
- else:
215
- await run_blocking(self.repository.fail_job, job, '远端请求失败或任务已过期')
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
- except Exception as exc:
218
- logger.error(f'同步 OSS 任务失败,已保留以便重试:{job.key} ({exc})')
219
- failed += 1
220
- finally:
221
- queue.task_done()
222
- completed += 1
223
- now = asyncio.get_running_loop().time()
224
- if now - last_report >= 1 or completed == len(jobs):
225
- logger.info(f'OSS同步进度: {completed}/{len(jobs)} ({completed * 100 / len(jobs):.1f}%),成功 {succeeded},失败 {failed}')
226
- last_report = now
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')])
231
+ await phase([job for job in jobs if job.key.endswith('.html')])
234
232
  pending = len(await run_blocking(self.repository.sync_jobs))
233
+ elapsed = asyncio.get_running_loop().time() - started
234
+ summary = (f'OSS 同步完成:成功 {succeeded},失败 {failed},待处理 {pending},'
235
+ f'用时 {elapsed:.1f} 秒')
236
+ if failed:
237
+ logger.warning(summary + ';失败任务将自动重试')
238
+ else:
239
+ logger.info(summary)
235
240
  return SyncResult(succeeded=succeeded, failed=failed, pending=pending)
236
241
 
237
242
  async def _retry_worker(self) -> None:
@@ -1,7 +1,6 @@
1
- """静态网页发布:原子替换文件、分页、懒加载,以及增删后的自动刷新。"""
1
+ """静态网页发布:原子替换文件、分页与图片懒加载。"""
2
2
 
3
3
  import hashlib
4
- import json
5
4
  import math
6
5
  from html import escape
7
6
  from pathlib import Path
@@ -67,8 +66,7 @@ def _url(image: ImageRecord) -> str:
67
66
  return quote(image.filename, safe='') + '?v=' + image.content_hash[:16]
68
67
 
69
68
 
70
- def _shell(title: str, home: str, version: str, command: str, page: int, content: str) -> str:
71
- state = json.dumps({'version': version, 'command': command, 'page': page}, ensure_ascii=False).replace('<', '\\u003c')
69
+ def _shell(title: str, home: str, content: str) -> str:
72
70
  return f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
73
71
  <meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#f5f6f2">
74
72
  <title>{escape(title)} · Randpic</title><style>{GALLERY_STYLE}
@@ -77,22 +75,13 @@ def _shell(title: str, home: str, version: str, command: str, page: int, content
77
75
  </style></head><body><div class="shell">
78
76
  <nav class="topbar"><a class="brand" href="{home}"><span class="brand-mark">✳</span><span>RANDPIC / 图片收藏馆</span></a><span class="top-note">捕捉喜欢的瞬间</span></nav>
79
77
  {content}<footer>Randpic · 收藏每一个喜欢的瞬间</footer></div>
80
- <script>(()=>{{const state={state}; const home={json.dumps(home)};
81
- setInterval(async()=>{{if(document.hidden)return;try{{
82
- const response=await fetch(home+'catalog.json',{{cache:'no-store'}});if(!response.ok)return;
83
- const next=await response.json();if(next.version===state.version)return;
84
- if(state.command && (!(state.command in next.pages)||state.page>next.pages[state.command])){{location.href=state.command in next.pages?'./':home;return;}}
85
- location.reload();}}catch(error){{}}}},15000);}})();</script></body></html>'''
78
+ </body></html>'''
86
79
 
87
80
 
88
81
  def render_catalog(groups: Mapping[str, Sequence[ImageRecord]], page_size: int) -> Dict[str, bytes]:
89
- """相同图库总是产生相同 HTML;发送次数变化不会导致网页重复刷新。"""
90
- identity = [(command, [(image.id, image.filename, image.content_hash) for image in images])
91
- for command, images in sorted(groups.items())]
92
- version = hashlib.sha256(json.dumps(identity, ensure_ascii=False).encode()).hexdigest()
82
+ """生成纯静态 HTML,只让内容实际变化的页面产生同步任务。"""
93
83
  pages = {command: max(1, math.ceil(len(images) / page_size)) for command, images in groups.items()}
94
- catalog = json.dumps({'version': version, 'pages': pages}, ensure_ascii=False, sort_keys=True).encode()
95
- files = {'catalog.json': catalog}
84
+ files: Dict[str, bytes] = {}
96
85
  total = sum(len(images) for images in groups.values())
97
86
  cards = []
98
87
  for command, images in sorted(groups.items()):
@@ -106,7 +95,7 @@ def render_catalog(groups: Mapping[str, Sequence[ImageRecord]], page_size: int)
106
95
  home = f'''<header class="hero"><p class="eyebrow">YOUR IMAGE LIBRARY</p><h1>每一张,都值得<br>被好好收藏。</h1>
107
96
  <p class="hero-copy">在这里慢慢翻看收藏的每一份灵感。</p></header><div class="section-head"><h2>全部分类</h2>
108
97
  <span class="count">{len(groups)} 个分类 · {total} 张图片</span></div><main class="folder-grid">{''.join(cards)}</main>'''
109
- files['index.html'] = _shell('图片收藏馆', './', version, '', 1, home).encode()
98
+ files['index.html'] = _shell('图片收藏馆', './', home).encode()
110
99
  for command, images in groups.items():
111
100
  for page in range(1, pages[command] + 1):
112
101
  cards = []
@@ -128,7 +117,7 @@ def render_catalog(groups: Mapping[str, Sequence[ImageRecord]], page_size: int)
128
117
  <header class="gallery-title"><p class="eyebrow">COLLECTION</p><h1>{escape(command)}</h1><p class="hero-copy">本分类共收藏 {len(images)} 张图片。</p></header>
129
118
  <main class="image-grid">{''.join(cards)}</main><nav class="pagination">{''.join(navigation)}</nav>'''
130
119
  filename = 'index.html' if page == 1 else f'page-{page}.html'
131
- files[f'{command}/{filename}'] = _shell(command, '../', version, command, page, content).encode()
120
+ files[f'{command}/{filename}'] = _shell(command, '../', content).encode()
132
121
  return files
133
122
 
134
123
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "nonebot-plugin-randpic"
3
- version = "2.0.0"
3
+ version = "2.0.2"
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"]