forked from qianlong520/Telegram_MistRelay
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdb.py
More file actions
1874 lines (1641 loc) · 70.9 KB
/
Copy pathdb.py
File metadata and controls
1874 lines (1641 loc) · 70.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
SQLite 数据库模块
=================
本模块主要用于记录两个维度的数据:
- tg_media 表:存放从 Telegram(Pyrogram Message/Media)解析出来的“媒体元数据”
- file_unique_id : Telegram 提供的全局唯一 ID,作为主键
- chat_id / message_id : 消息所在聊天与消息 ID,用于去重与回查原消息
- from_user_id : 发送用户 ID(私聊/群聊)
- sender_chat_id : 频道 ID(频道帖子)
- file_id : 真正下载用的 file_id(bot 专属)
- file_name : 文件名
- mime_type : MIME 类型(video/mp4 等)
- file_size : 文件大小(字节)
- duration/width/height: 媒体时长与分辨率
- caption : 说明文本
- caption_entities : 说明文本中的实体(hashtag、粗体等),JSON 字符串
- message_date : 消息时间(ISO8601 字符串)
- media_group_id : 媒体组 ID,相册/多媒体时使用
- has_media_spoiler : 是否剧透遮罩(0/1)
- supports_streaming : 是否支持流式播放(0/1)
- thumbs : 缩略图相关信息,预留为 JSON 字符串
- extra : 预留扩展字段(JSON 字符串)
- downloads 表:存放下载任务(aria2)与本地/网盘路径信息
- id : 自增主键
- file_unique_id : 外键,关联 tg_media
- gid : aria2 任务 ID
- source_url : 用于下载的直链 URL(WebStreamer 生成)
- status : 下载状态(pending/downloading/completed/failed)
- total_length : 文件总大小(字节)
- completed_length : 已完成大小(字节)
- download_speed : 当前下载速度(字节/秒)
- error_message : 失败原因
- retry_count : 重试次数
- local_path : 本地最终文件路径
- save_dir : 本地保存目录
- remote_path : 网盘路径(如 OneDrive/rclone)
- upload_status : 上传状态(pending/uploading/uploaded/failed 等)
- created_at : 创建时间(加入下载队列)
- started_at : 实际开始下载时间
- completed_at : 完成时间
- updated_at : 最近更新时间
"""
import os
import sqlite3
import json
import logging
from contextlib import contextmanager
from datetime import datetime
logger = logging.getLogger(__name__)
# 数据库路径:优先使用环境变量,否则使用 /app/db/downloads.db(确保在挂载的卷中)
_default_db_path = os.path.join("/app/db", "downloads.db")
DB_PATH = os.environ.get("MISTRELAY_DB_PATH", _default_db_path)
# 确保数据库目录存在
_db_dir = os.path.dirname(DB_PATH)
if _db_dir and not os.path.exists(_db_dir):
os.makedirs(_db_dir, exist_ok=True)
def _now_iso() -> str:
"""返回UTC时间的ISO8601格式字符串,带'Z'后缀表示UTC时区"""
return datetime.utcnow().isoformat(timespec="seconds") + 'Z'
def _format_message_date(msg_date) -> str:
"""格式化消息日期为ISO8601格式,确保带时区信息"""
if not msg_date:
return _now_iso()
# Pyrogram的message.date是UTC时间的datetime对象
# 转换为ISO格式并添加'Z'后缀表示UTC
iso_str = msg_date.isoformat()
# 如果已经有时区信息(带+或-),保持不变;否则添加'Z'
if 'Z' in iso_str or '+' in iso_str or (len(iso_str) > 10 and iso_str[-6] in '+-'):
return iso_str
# 移除微秒部分(如果有),只保留秒级精度
if '.' in iso_str:
iso_str = iso_str.split('.')[0]
return iso_str + 'Z'
def get_connection():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
# 启用 Write-Ahead Logging 模式,提升并发性能
conn.execute("PRAGMA journal_mode=WAL;")
return conn
@contextmanager
def db_cursor():
conn = get_connection()
try:
yield conn.cursor()
conn.commit()
finally:
conn.close()
def init_db():
"""初始化 SQLite 数据库(如果不存在就建表)"""
with db_cursor() as cur:
# Telegram 媒体信息
cur.execute(
"""
CREATE TABLE IF NOT EXISTS tg_media (
file_unique_id TEXT PRIMARY KEY, -- Telegram 提供的全局唯一 ID,主键
chat_id INTEGER NOT NULL, -- 消息所属聊天 ID(频道/群/私聊)
message_id INTEGER NOT NULL, -- 消息 ID
from_user_id INTEGER, -- 发送用户 ID(私聊/群聊)
sender_chat_id INTEGER, -- 发送频道 ID(频道帖子)
file_id TEXT NOT NULL, -- 实际用于下载的 file_id(bot 专属)
file_name TEXT, -- 文件名
mime_type TEXT, -- MIME 类型,如 video/mp4
file_size INTEGER, -- 文件大小(字节)
duration INTEGER, -- 媒体时长(秒)
width INTEGER, -- 媒体宽度(像素)
height INTEGER, -- 媒体高度(像素)
caption TEXT, -- 说明文本
caption_entities TEXT, -- 说明文本中的实体(hashtag 等),JSON 字符串
message_date TEXT NOT NULL, -- 消息时间,ISO8601 字符串
media_group_id TEXT, -- 媒体组 ID(相册/多媒体)
has_media_spoiler INTEGER, -- 是否启用剧透遮罩(0/1)
supports_streaming INTEGER, -- 是否支持流式播放(0/1)
thumbs TEXT, -- 缩略图信息,JSON 字符串(预留)
extra TEXT -- 扩展字段,JSON 字符串(预留)
)
"""
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_tg_media_chat_msg ON tg_media (chat_id, message_id)"
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_tg_media_media_group ON tg_media (media_group_id)"
)
# 下载任务信息
cur.execute(
"""
CREATE TABLE IF NOT EXISTS downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 自增主键
file_unique_id TEXT NOT NULL, -- 关联 tg_media.file_unique_id
gid TEXT, -- aria2 任务 ID
source_url TEXT, -- 用于下载的直链 URL
status TEXT NOT NULL DEFAULT 'pending', -- 下载状态:pending/downloading/completed/failed
total_length INTEGER, -- 文件总大小(字节)
completed_length INTEGER, -- 已完成大小(字节)
download_speed INTEGER, -- 当前下载速度(字节/秒)
error_message TEXT, -- 错误信息(失败原因)
retry_count INTEGER DEFAULT 0, -- 重试次数
local_path TEXT, -- 本地最终文件路径
save_dir TEXT, -- 本地保存目录
remote_path TEXT, -- 网盘路径(如 OneDrive/rclone)
upload_status TEXT, -- 上传状态:pending/uploading/uploaded/failed
created_at TEXT NOT NULL, -- 创建时间(加入下载队列)
started_at TEXT, -- 实际开始下载时间
completed_at TEXT, -- 下载完成时间
updated_at TEXT NOT NULL, -- 最近更新时间
FOREIGN KEY (file_unique_id) REFERENCES tg_media(file_unique_id) ON DELETE CASCADE -- 关联 Telegram 媒体
)
"""
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_downloads_file_unique_id ON downloads (file_unique_id)"
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads (status)"
)
# 上传任务信息
cur.execute(
"""
CREATE TABLE IF NOT EXISTS uploads (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 自增主键
download_id INTEGER NOT NULL, -- 关联 downloads.id
upload_target TEXT NOT NULL, -- 上传目标:onedrive/telegram/other
remote_path TEXT, -- 远程路径
status TEXT NOT NULL DEFAULT 'pending', -- 上传状态:pending/waiting_download/uploading/completed/failed/cancelled/paused
failure_reason TEXT, -- 失败原因分类:download_failed/code_error/network_error等
error_message TEXT, -- 详细错误信息
error_code TEXT, -- 错误代码
total_size INTEGER, -- 文件总大小(字节)
uploaded_size INTEGER DEFAULT 0, -- 已上传大小(字节)
upload_speed INTEGER, -- 上传速度(字节/秒)
retry_count INTEGER DEFAULT 0, -- 重试次数
max_retries INTEGER DEFAULT 3, -- 最大重试次数
created_at TEXT NOT NULL, -- 创建时间
started_at TEXT, -- 开始上传时间
completed_at TEXT, -- 完成时间
updated_at TEXT NOT NULL, -- 最近更新时间
extra TEXT, -- 扩展字段(JSON)
FOREIGN KEY (download_id) REFERENCES downloads(id) ON DELETE CASCADE
)
"""
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_uploads_download_id ON uploads (download_id)"
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_uploads_status ON uploads (status)"
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_uploads_target ON uploads (upload_target)"
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_uploads_failure_reason ON uploads (failure_reason)"
)
# 数据库迁移:为 uploads 表添加 cleaned_at 字段(如果不存在)
try:
cur.execute("ALTER TABLE uploads ADD COLUMN cleaned_at TEXT")
logging.info("已为 uploads 表添加 cleaned_at 字段")
except sqlite3.OperationalError as e:
# 字段已存在,忽略错误
if "duplicate column name" not in str(e).lower():
logging.warning(f"添加 cleaned_at 字段时出错(可能已存在): {e}")
# 系统配置表
cur.execute(
"""
CREATE TABLE IF NOT EXISTS config_settings (
key TEXT PRIMARY KEY, -- 配置键名
value TEXT, -- 配置值(JSON字符串,支持复杂类型)
value_type TEXT NOT NULL, -- 值类型:string, int, bool, list, json
category TEXT NOT NULL, -- 配置分类:telegram, rclone, aria2, stream, etc.
description TEXT, -- 配置说明
updated_at TEXT NOT NULL -- 更新时间
)
"""
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_config_category ON config_settings (category)"
)
# 检查是否需要从config.yml迁移配置(在with块外执行,因为需要独立的连接)
with get_connection() as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute("SELECT COUNT(*) as count FROM config_settings")
count = cur.fetchone()['count']
if count == 0:
# 配置表为空,尝试从config.yml导入
try:
if init_config_from_yaml():
logger.info("已从config.yml成功导入配置到数据库")
else:
logger.warning("配置表为空,且无法从config.yml导入配置")
except Exception as e:
logger.warning(f"从config.yml导入配置时出错: {e}")
def save_tg_media(message, media) -> str:
"""
保存/忽略一条 Telegram 媒体元数据,返回 file_unique_id。
"""
file_unique_id = media.file_unique_id
file_id = media.file_id
caption_entities = message.caption_entities or []
try:
ce_json = json.dumps(
[e.__dict__ for e in caption_entities], ensure_ascii=False
)
except Exception:
ce_json = "[]"
# thumbs 可以以后再扩展,现在先占位为空列表
thumbs_json = "[]"
with db_cursor() as cur:
cur.execute(
"""
INSERT OR IGNORE INTO tg_media (
file_unique_id, chat_id, message_id, from_user_id, sender_chat_id,
file_id, file_name, mime_type, file_size,
duration, width, height,
caption, caption_entities, message_date,
media_group_id, has_media_spoiler, supports_streaming,
thumbs
) VALUES (?, ?, ?, ?, ?,
?, ?, ?, ?,
?, ?, ?,
?, ?, ?,
?, ?, ?,
?)
""",
(
file_unique_id,
message.chat.id,
message.id,
message.from_user.id if message.from_user else None,
message.sender_chat.id if message.sender_chat else None,
file_id,
getattr(media, "file_name", None),
getattr(media, "mime_type", None),
getattr(media, "file_size", None),
getattr(media, "duration", None),
getattr(media, "width", None),
getattr(media, "height", None),
message.caption,
ce_json,
_format_message_date(message.date) if getattr(message, "date", None) else _now_iso(),
message.media_group_id,
int(bool(getattr(message, "has_media_spoiler", False))),
int(bool(getattr(media, "supports_streaming", False))),
thumbs_json,
),
)
return file_unique_id
def create_download(file_unique_id: str, gid: str | None, source_url: str | None) -> int:
"""创建一条下载记录,返回 downloads.id。"""
now = _now_iso()
with db_cursor() as cur:
cur.execute(
"""
INSERT INTO downloads (
file_unique_id, gid, source_url, status,
created_at, updated_at
) VALUES (?, ?, ?, 'pending', ?, ?)
""",
(file_unique_id, gid, source_url, now, now),
)
download_id = cur.lastrowid
# 如果有 gid,推送 WebSocket 更新(新记录通知)
if gid:
_notify_ws_download_update(gid)
# 推送统计更新,确保前端刷新列表
_notify_ws_statistics_update()
return download_id
def mark_download_started(gid: str):
"""标记下载开始时间。"""
now = _now_iso()
with db_cursor() as cur:
cur.execute(
"""
UPDATE downloads
SET status = 'downloading',
started_at = COALESCE(started_at, ?),
updated_at = ?
WHERE gid = ?
""",
(now, now, gid),
)
# 推送 WebSocket 更新
_notify_ws_download_update(gid)
def get_download_id_by_gid(gid: str) -> int | None:
"""根据 GID 获取下载记录 ID。"""
with get_connection() as conn:
cur = conn.cursor()
cur.execute("SELECT id FROM downloads WHERE gid = ?", (gid,))
row = cur.fetchone()
return row['id'] if row else None
def get_download_by_id(download_id: int):
"""根据 ID 获取下载记录。"""
with get_connection() as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute(
"""
SELECT * FROM downloads
WHERE id = ?
""",
(download_id,),
)
row = cur.fetchone()
return dict(row) if row else None
def _notify_ws_download_update(gid: str):
"""通过 WebSocket 推送下载状态更新(异步,不阻塞)"""
try:
from WebStreamer.server.ws_manager import ws_manager
import asyncio
# 获取下载记录
download_id = get_download_id_by_gid(gid)
if download_id:
download = get_download_by_id(download_id)
if download:
# 获取关联的上传记录,确保数据一致性
uploads = get_uploads_by_download(download_id)
uploads_data = []
for upload in uploads:
uploads_data.append({
"id": upload.get('id'),
"upload_target": upload.get('upload_target'),
"status": upload.get('status'),
"uploaded_size": upload.get('uploaded_size'),
"total_size": upload.get('total_size'),
"upload_speed": upload.get('upload_speed'),
"cleaned_at": upload.get('cleaned_at'),
})
# 异步推送更新
loop = None
try:
loop = asyncio.get_event_loop()
except RuntimeError:
# 如果没有事件循环,创建一个新的
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
if loop and not loop.is_closed():
asyncio.create_task(ws_manager.send_download_update({
"gid": gid,
"download_id": download_id,
"status": download.get('status'),
"completed_length": download.get('completed_length'),
"total_length": download.get('total_length'),
"download_speed": download.get('download_speed'),
"uploads": uploads_data, # 包含上传信息,确保数据一致性
}))
except Exception as e:
# 静默失败,不影响主流程
pass
def _notify_ws_upload_update(upload_id: int):
"""通过 WebSocket 推送上传状态更新(异步,不阻塞)"""
try:
from WebStreamer.server.ws_manager import ws_manager
import asyncio
upload = get_upload_by_id(upload_id)
if upload:
loop = None
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
if loop and not loop.is_closed():
asyncio.create_task(ws_manager.send_upload_update({
"upload_id": upload_id,
"download_id": upload.get('download_id'),
"status": upload.get('status'),
"uploaded_size": upload.get('uploaded_size'),
"total_size": upload.get('total_size'),
"upload_speed": upload.get('upload_speed'),
"cleaned_at": upload.get('cleaned_at'), # 包含清理状态
}))
except Exception as e:
pass
def _notify_ws_cleanup_update(upload_id: int):
"""通过 WebSocket 推送清理状态更新(异步,不阻塞)"""
try:
from WebStreamer.server.ws_manager import ws_manager
import asyncio
upload = get_upload_by_id(upload_id)
if upload:
loop = None
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
if loop and not loop.is_closed():
asyncio.create_task(ws_manager.send_cleanup_update({
"upload_id": upload_id,
"download_id": upload.get('download_id'),
"cleaned_at": upload.get('cleaned_at'),
}))
except Exception as e:
pass
def _notify_ws_statistics_update():
"""通过 WebSocket 推送统计信息更新(异步,不阻塞)"""
try:
from WebStreamer.server.ws_manager import ws_manager
import asyncio
# 获取统计信息
download_stats = get_download_statistics()
upload_stats = get_upload_statistics()
loop = None
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
if loop and not loop.is_closed():
asyncio.create_task(ws_manager.send_statistics_update({
"downloads": download_stats,
"uploads": upload_stats,
}))
except Exception as e:
# 静默失败,不影响主流程
pass
def mark_download_completed(gid: str, local_path: str | None, total_length: int | None):
"""
标记下载完成状态和本地路径。
注意:这里不立即标记为 completed,而是保持当前状态(downloading)。
只有在清理完成后,才会通过 mark_upload_cleaned 更新为 completed。
"""
now = _now_iso()
with db_cursor() as cur:
# 检查是否有上传任务,如果有,保持 downloading 状态;如果没有,标记为 completed
download_id = get_download_id_by_gid(gid)
has_uploads = False
if download_id:
cur.execute("SELECT COUNT(*) FROM uploads WHERE download_id = ?", (download_id,))
upload_count = cur.fetchone()[0]
has_uploads = upload_count > 0
# 如果有上传任务,保持 downloading 状态;否则标记为 completed
if has_uploads:
# 保持当前状态(通常是 downloading),只更新路径和大小
cur.execute(
"""
UPDATE downloads
SET local_path = COALESCE(?, local_path),
total_length = COALESCE(?, total_length),
completed_length = COALESCE(?, completed_length),
updated_at = ?
WHERE gid = ?
""",
(local_path, total_length, total_length, now, gid),
)
else:
# 没有上传任务,直接标记为 completed
cur.execute(
"""
UPDATE downloads
SET status = 'completed',
local_path = COALESCE(?, local_path),
total_length = COALESCE(?, total_length),
completed_length = COALESCE(?, completed_length),
completed_at = ?,
updated_at = ?
WHERE gid = ?
""",
(local_path, total_length, total_length, now, now, gid),
)
# 推送 WebSocket 更新
_notify_ws_download_update(gid)
def mark_download_failed(gid: str, error_message: str | None):
"""标记下载失败。"""
now = _now_iso()
with db_cursor() as cur:
cur.execute(
"""
UPDATE downloads
SET status = 'failed',
error_message = ?,
updated_at = ?
WHERE gid = ?
""",
(error_message, now, gid),
)
# 推送 WebSocket 更新
_notify_ws_download_update(gid)
def mark_download_paused(gid: str):
"""标记下载暂停。"""
now = _now_iso()
with db_cursor() as cur:
cur.execute(
"""
UPDATE downloads
SET status = 'paused',
download_speed = 0,
updated_at = ?
WHERE gid = ?
""",
(now, gid),
)
# 推送 WebSocket 更新
_notify_ws_download_update(gid)
def mark_download_resumed(gid: str):
"""标记下载恢复。"""
now = _now_iso()
with db_cursor() as cur:
cur.execute(
"""
UPDATE downloads
SET status = 'downloading',
updated_at = ?
WHERE gid = ? AND status = 'paused'
""",
(now, gid),
)
# 推送 WebSocket 更新
_notify_ws_download_update(gid)
def update_download_progress(gid: str, completed_length: int | None = None,
total_length: int | None = None,
download_speed: int | None = None):
"""更新下载进度。"""
now = _now_iso()
updates = ["updated_at = ?"]
values = [now]
if completed_length is not None:
updates.append("completed_length = ?")
values.append(completed_length)
if total_length is not None:
updates.append("total_length = ?")
values.append(total_length)
if download_speed is not None:
updates.append("download_speed = ?")
values.append(download_speed)
values.append(gid)
with db_cursor() as cur:
cur.execute(
f"""
UPDATE downloads
SET {', '.join(updates)}
WHERE gid = ?
""",
tuple(values),
)
# 推送 WebSocket 更新
_notify_ws_download_update(gid)
def fetch_recent_downloads(limit: int = 100):
"""
查询最近的下载记录(按创建时间倒序),包含部分 Telegram 媒体字段和上传信息,
用于 Web 管理页面展示。
"""
with get_connection() as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute(
"""
SELECT
d.id,
d.gid,
d.source_url,
d.status,
d.total_length,
d.completed_length,
d.download_speed,
d.local_path,
d.remote_path,
d.upload_status,
d.created_at,
d.started_at,
d.completed_at,
d.updated_at,
m.file_name,
m.mime_type,
m.file_size,
m.chat_id,
m.message_id,
m.media_group_id,
m.caption,
m.message_date,
u.id as upload_id,
u.upload_target,
u.remote_path as upload_remote_path,
u.status as upload_status_detail,
u.total_size as upload_total_size,
u.uploaded_size,
u.upload_speed,
u.failure_reason,
u.error_message as upload_error_message,
u.created_at as upload_created_at,
u.started_at as upload_started_at,
u.completed_at as upload_completed_at,
u.cleaned_at as upload_cleaned_at
FROM downloads AS d
LEFT JOIN tg_media AS m
ON d.file_unique_id = m.file_unique_id
LEFT JOIN uploads AS u
ON u.download_id = d.id
ORDER BY d.created_at DESC, u.created_at DESC
LIMIT ?
""",
(limit,),
)
rows = cur.fetchall()
# 将结果转换为字典,并处理多个上传记录的情况
result_dict = {}
for row in rows:
download_id = row['id']
if download_id not in result_dict:
# 创建下载记录
download_record = {
'id': row['id'],
'gid': row['gid'],
'source_url': row['source_url'],
'status': row['status'],
'total_length': row['total_length'],
'completed_length': row['completed_length'],
'download_speed': row['download_speed'],
'local_path': row['local_path'],
'remote_path': row['remote_path'],
'upload_status': row['upload_status'],
'created_at': row['created_at'],
'started_at': row['started_at'],
'completed_at': row['completed_at'],
'updated_at': row['updated_at'],
'file_name': row['file_name'],
'mime_type': row['mime_type'],
'file_size': row['file_size'],
'chat_id': row['chat_id'],
'message_id': row['message_id'],
'media_group_id': row['media_group_id'],
'caption': row['caption'],
'message_date': row['message_date'],
'uploads': []
}
result_dict[download_id] = download_record
# 添加上传记录(如果有)
if row['upload_id']:
upload_id = row['upload_id']
# 检查是否已经添加过这个上传记录
existing_upload_ids = [u['id'] for u in result_dict[download_id]['uploads']]
if upload_id not in existing_upload_ids:
upload_record = {
'id': upload_id,
'upload_target': row['upload_target'],
'remote_path': row['upload_remote_path'],
'status': row['upload_status_detail'],
'total_size': row['upload_total_size'],
'uploaded_size': row['uploaded_size'],
'upload_speed': row['upload_speed'],
'failure_reason': row['failure_reason'],
'error_message': row['upload_error_message'],
'created_at': row['upload_created_at'],
'started_at': row['upload_started_at'],
'completed_at': row['upload_completed_at'],
'cleaned_at': row['upload_cleaned_at']
}
result_dict[download_id]['uploads'].append(upload_record)
# 对每个下载记录的上传列表按创建时间倒序排序(保持稳定排序)
# 使用ID作为次要排序键,确保排序稳定
for download_record in result_dict.values():
if download_record.get('uploads'):
download_record['uploads'].sort(key=lambda u: (
u.get('created_at') or '', # 字符串排序(ISO格式天然支持)
u.get('id') or 0
), reverse=False) # 正序:先创建的在前,后创建的在后
return list(result_dict.values())
def fetch_downloads_grouped(limit: int = 100):
"""
查询下载记录并按消息分组。
返回格式:按消息组(media_group_id 或 chat_id+message_id)分组的数据
"""
records = fetch_recent_downloads(limit)
# 按消息分组
groups: dict[str, list] = {}
for record in records:
# 确定分组键:优先使用 media_group_id,否则使用 chat_id+message_id
if record.get('media_group_id'):
group_key = f"group_{record['media_group_id']}"
elif record.get('chat_id') and record.get('message_id'):
group_key = f"msg_{record['chat_id']}_{record['message_id']}"
else:
# 如果没有分组信息,使用下载ID作为独立组
group_key = f"single_{record['id']}"
if group_key not in groups:
groups[group_key] = []
groups[group_key].append(record)
# 转换为列表格式,每个组包含组信息和下载列表
result = []
for group_key, downloads in groups.items():
# 获取组的第一条记录作为组信息
first_record = downloads[0]
# 计算组统计信息
total_files = len(downloads)
# 计算已完成数量:下载完成且所有上传任务都已完成(或没有上传任务)
def is_truly_completed(download_record):
"""判断一个下载记录是否真正完成(下载完成且所有上传都完成)"""
if download_record.get('status') != 'completed':
return False
# 检查上传任务
uploads = download_record.get('uploads', [])
if not uploads:
# 没有上传任务,下载完成即完成
return True
# 检查所有上传任务是否都已完成或失败
for upload in uploads:
upload_status = upload.get('status')
# 如果有正在上传、等待下载或待处理的上传任务,不算完成
if upload_status in ['uploading', 'pending', 'waiting_download']:
return False
# 所有上传任务都已完成或失败
return True
completed = sum(1 for d in downloads if is_truly_completed(d))
downloading = sum(1 for d in downloads if d.get('status') == 'downloading')
failed = sum(1 for d in downloads if d.get('status') == 'failed')
pending = sum(1 for d in downloads if d.get('status') == 'pending')
# 统计跳过的文件(状态为failed且错误信息包含"跳过")
skipped = sum(1 for d in downloads if d.get('status') == 'failed' and d.get('error_message', '').find('跳过') != -1)
total_size = sum(d.get('total_length') or d.get('file_size') or 0 for d in downloads)
completed_size = sum(d.get('completed_length') or 0 for d in downloads)
# 对组内的下载记录按创建时间正序排序(保持稳定排序)
# 使用ID作为次要排序键,确保排序稳定
# 正序:先创建的在前,后创建的在后
downloads_sorted = sorted(downloads, key=lambda d: (
d.get('created_at') or '', # 字符串排序(ISO格式天然支持)
d.get('id') or 0
), reverse=False)
result.append({
'group_key': group_key,
'group_type': 'media_group' if first_record.get('media_group_id') else 'message',
'chat_id': first_record.get('chat_id'),
'message_id': first_record.get('message_id'),
'media_group_id': first_record.get('media_group_id'),
'caption': first_record.get('caption'),
'message_date': first_record.get('message_date') or first_record.get('created_at'),
'created_at': min(d.get('created_at', '') for d in downloads if d.get('created_at')),
'stats': {
'total_files': total_files,
'completed': completed,
'downloading': downloading,
'failed': failed,
'pending': pending,
'skipped': skipped,
'total_size': total_size,
'completed_size': completed_size
},
'downloads': downloads_sorted
})
# 按创建时间倒序排序(后创建的在前,先创建的在后)
result.sort(key=lambda x: x['created_at'], reverse=True)
return result
def get_config(key: str, default=None):
"""获取配置值"""
with get_connection() as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute(
"SELECT value, value_type FROM config_settings WHERE key = ?",
(key,)
)
row = cur.fetchone()
if row:
value = row['value']
value_type = row['value_type']
# 根据类型转换值
if value_type == 'int':
return int(value) if value else default
elif value_type == 'bool':
return value.lower() in ('true', '1', 'yes', 'on') if value else default
elif value_type == 'list':
return json.loads(value) if value else default
elif value_type == 'json':
return json.loads(value) if value else default
else:
return value if value else default
return default
def set_config(key: str, value: any, value_type: str = 'string', category: str = 'general', description: str = None):
"""设置配置值"""
now = _now_iso()
# 根据类型转换值
if value_type == 'list' or value_type == 'json':
value_str = json.dumps(value, ensure_ascii=False) if value else ''
else:
value_str = str(value) if value is not None else ''
with db_cursor() as cur:
cur.execute(
"""
INSERT OR REPLACE INTO config_settings (key, value, value_type, category, description, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(key, value_str, value_type, category, description, now)
)
def get_all_configs(category: str = None):
"""获取所有配置或指定分类的配置"""
with get_connection() as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
if category:
cur.execute(
"SELECT key, value, value_type, category, description FROM config_settings WHERE category = ? ORDER BY key",
(category,)
)
else:
cur.execute(
"SELECT key, value, value_type, category, description FROM config_settings ORDER BY category, key"
)
rows = cur.fetchall()
result = {}
for row in rows:
key = row['key']
value = row['value']
value_type = row['value_type']
# 根据类型转换值
if value_type == 'int':
result[key] = int(value) if value else None
elif value_type == 'bool':
result[key] = value.lower() in ('true', '1', 'yes', 'on') if value else False
elif value_type == 'list':
result[key] = json.loads(value) if value else []
elif value_type == 'json':
result[key] = json.loads(value) if value else {}
else:
result[key] = value if value else ''
return result
def init_config_from_yaml():
"""从config.yml初始化配置到数据库(迁移函数)"""
import yaml
import os
config_file = './db/config.yml'
if not os.path.exists(config_file):
return False
try:
with open(config_file, 'r', encoding='utf-8') as f:
yaml_config = yaml.load(f.read(), Loader=yaml.FullLoader)
# 配置项定义:key -> (value_type, category, description)
config_definitions = {
# Telegram配置
'API_ID': ('int', 'telegram', 'Telegram API ID'),
'API_HASH': ('string', 'telegram', 'Telegram API Hash'),
'BOT_TOKEN': ('string', 'telegram', 'Telegram Bot Token'),
'ADMIN_ID': ('int', 'telegram', 'Telegram管理员ID'),
'FORWARD_ID': ('string', 'telegram', '转发ID'),
'UP_TELEGRAM': ('bool', 'telegram', '是否上传到Telegram'),
# Rclone配置
'UP_ONEDRIVE': ('bool', 'rclone', '是否启用rclone上传到OneDrive'),
'RCLONE_REMOTE': ('string', 'rclone', 'rclone远程名称'),
'RCLONE_PATH': ('string', 'rclone', 'OneDrive目标路径'),
'AUTO_DELETE_AFTER_UPLOAD': ('bool', 'rclone', '上传后自动删除本地文件'),
# 谷歌网盘配置
'UP_GOOGLE_DRIVE': ('bool', 'rclone', '是否上传到Google Drive'),
'GOOGLE_DRIVE_REMOTE': ('string', 'rclone', 'Google Drive Rclone远程名称(默认gdrive),需与rclone.conf中的配置名称一致'),
'GOOGLE_DRIVE_PATH': ('string', 'rclone', 'Google Drive上传路径(默认/Downloads)'),
# 下载配置
'SAVE_PATH': ('string', 'download', '下载保存路径'),
'PROXY_IP': ('string', 'download', '代理IP'),
'PROXY_PORT': ('string', 'download', '代理端口'),
'SKIP_SMALL_FILES': ('bool', 'download', '是否跳过小于指定大小的媒体文件'),
'MIN_FILE_SIZE_MB': ('int', 'download', '最小文件大小(MB),小于此大小的文件将被跳过'),
# Aria2配置
'RPC_SECRET': ('string', 'aria2', 'Aria2 RPC密钥'),
'RPC_URL': ('string', 'aria2', 'Aria2 RPC URL'),
'MAX_CONCURRENT_UPLOADS': ('int', 'upload', '最大并发上传数(默认10)'),