22333Misaka commited on
Commit
aa342df
·
verified ·
1 Parent(s): 780f494

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +300 -0
app.py ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import time
3
+ import threading
4
+ import requests
5
+ import logging
6
+ from datetime import datetime
7
+ from flask import Flask, render_template_string, request, jsonify
8
+ from flask_sqlalchemy import SQLAlchemy
9
+
10
+ # ================= 配置区域 =================
11
+ # 优先从环境变量获取数据库连接字符串 (适配 Docker)
12
+ # 如果没有环境变量,则使用默认值 (请修改下方的默认值)
13
+ DEFAULT_DB_URI = 'postgresql://postgres:password@192.168.1.10:5432/alist_sync'
14
+ DB_URI = os.environ.get('DB_URI', DEFAULT_DB_URI)
15
+
16
+ PORT = int(os.environ.get('PORT', 5000))
17
+ # ===========================================
18
+
19
+ # 初始化
20
+ app = Flask(__name__)
21
+ app.config['SQLALCHEMY_DATABASE_URI'] = DB_URI
22
+ app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
23
+ app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {'pool_pre_ping': True} # 自动检测断开的连接
24
+
25
+ db = SQLAlchemy(app)
26
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(message)s')
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # --- 数据库模型 ---
30
+ class AppConfig(db.Model):
31
+ __tablename__ = 'config'
32
+ id = db.Column(db.Integer, primary_key=True)
33
+ url = db.Column(db.String(255), default="http://localhost:5244")
34
+ username = db.Column(db.String(255), default="admin")
35
+ password = db.Column(db.String(255), default="")
36
+ path1 = db.Column(db.String(500), default="") # 源路径
37
+ path2 = db.Column(db.String(500), default="") # 目标路径
38
+ interval = db.Column(db.Integer, default=60) # 分钟
39
+ auto_sync = db.Column(db.Boolean, default=False)
40
+
41
+ class AppLog(db.Model):
42
+ __tablename__ = 'logs'
43
+ id = db.Column(db.Integer, primary_key=True)
44
+ timestamp = db.Column(db.DateTime, default=datetime.now)
45
+ message = db.Column(db.Text)
46
+
47
+ # --- 工具函数 ---
48
+ def add_log(msg):
49
+ """写入日志到数据库 (线程安全)"""
50
+ print(f"[系统日志] {msg}")
51
+ try:
52
+ with app.app_context():
53
+ log = AppLog(message=str(msg), timestamp=datetime.now())
54
+ db.session.add(log)
55
+ db.session.commit()
56
+ # 清理旧日志 (保留最近500条)
57
+ last_id = db.session.query(db.func.max(AppLog.id)).scalar()
58
+ if last_id and last_id > 1000:
59
+ AppLog.query.filter(AppLog.id < (last_id - 500)).delete()
60
+ db.session.commit()
61
+ except Exception as e:
62
+ print(f"日志写入失败: {e}")
63
+
64
+ def get_token(base_url, username, password):
65
+ try:
66
+ res = requests.post(f"{base_url.rstrip('/')}/api/auth/login",
67
+ json={'username': username, 'password': password}, timeout=10)
68
+ if res.status_code == 200:
69
+ return res.json().get('data', {}).get('token')
70
+ except Exception as e:
71
+ add_log(f"登录失败: {e}")
72
+ return None
73
+
74
+ def list_files(base_url, token, path):
75
+ files = []
76
+ try:
77
+ res = requests.post(f"{base_url.rstrip('/')}/api/fs/list",
78
+ headers={'Authorization': token},
79
+ json={"path": path, "password": "", "page": 1, "per_page": 0, "refresh": True}, timeout=30)
80
+ if res.status_code == 200:
81
+ data = res.json().get('data', {}).get('content', [])
82
+ if data: files = data
83
+ except Exception as e:
84
+ add_log(f"获取列表失败: {path} - {e}")
85
+ return files
86
+
87
+ def copy_files_api(base_url, token, src_dir, dst_dir, file_names):
88
+ try:
89
+ res = requests.post(f"{base_url.rstrip('/')}/api/fs/copy",
90
+ headers={'Authorization': token},
91
+ json={"src_dir": src_dir, "dst_dir": dst_dir, "names": file_names}, timeout=30)
92
+ return res.json()
93
+ except Exception as e:
94
+ return {"code": 500, "message": str(e)}
95
+
96
+ # --- 后台守护线程 ---
97
+ def background_worker():
98
+ """后台执行自动同步任务"""
99
+ add_log("✅ 后台守护线程已启动")
100
+ while True:
101
+ try:
102
+ with app.app_context():
103
+ cfg = AppConfig.query.first()
104
+ if not cfg: # 初始化配置
105
+ cfg = AppConfig()
106
+ db.session.add(cfg)
107
+ db.session.commit()
108
+
109
+ if not cfg.auto_sync:
110
+ time.sleep(10)
111
+ continue
112
+
113
+ # 开始任务
114
+ interval = cfg.interval if cfg.interval > 0 else 60
115
+ add_log(f"⏳ 开始执行定时同步任务 (间隔: {interval}分钟)...")
116
+
117
+ token = get_token(cfg.url, cfg.username, cfg.password)
118
+ if token:
119
+ src_list = list_files(cfg.url, token, cfg.path1)
120
+ dst_list = list_files(cfg.url, token, cfg.path2)
121
+
122
+ if src_list:
123
+ dst_names = {f['name'] for f in dst_list}
124
+ missing_files = [item['name'] for item in src_list if item['name'] not in dst_names]
125
+
126
+ if missing_files:
127
+ add_log(f"发现 {len(missing_files)} 个缺失文件,开始推送任务...")
128
+ # 分批处理,每批20个
129
+ batch_size = 20
130
+ for i in range(0, len(missing_files), batch_size):
131
+ batch = missing_files[i:i+batch_size]
132
+ res = copy_files_api(cfg.url, token, cfg.path1, cfg.path2, batch)
133
+ if res.get('code') == 200:
134
+ add_log(f"✅ 指令发送成功: {batch}")
135
+ else:
136
+ add_log(f"❌ 指令发送失败: {res.get('message')}")
137
+ else:
138
+ add_log("无差异文件,无需同步")
139
+
140
+ add_log(f"💤 任务结束,休眠 {interval} 分钟")
141
+ time.sleep(interval * 60)
142
+
143
+ except Exception as e:
144
+ add_log(f"❌ 后台线程异常: {e}")
145
+ time.sleep(60)
146
+
147
+ # --- Web 路由 ---
148
+ HTML_TEMPLATE = """
149
+ <!DOCTYPE html>
150
+ <html lang="zh-CN">
151
+ <head>
152
+ <meta charset="UTF-8">
153
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
154
+ <title>Alist 自动同步 (PostgreSQL版)</title>
155
+ <link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/5.1.3/css/bootstrap.min.css" rel="stylesheet">
156
+ <style>
157
+ body { background: #f4f7f6; padding-top: 30px; }
158
+ .log-box { height: 400px; overflow-y: scroll; background: #1e1e1e; color: #00ff00; padding: 10px; font-family: 'Consolas', monospace; font-size: 13px; border-radius: 5px; }
159
+ </style>
160
+ </head>
161
+ <body>
162
+ <div class="container">
163
+ <div class="row">
164
+ <div class="col-lg-8 offset-lg-2">
165
+ <div class="card shadow">
166
+ <div class="card-header bg-dark text-white d-flex justify-content-between">
167
+ <h5 class="mb-0">Alist 增量同步控制台</h5>
168
+ <span class="badge bg-info">DB: PostgreSQL/MySQL</span>
169
+ </div>
170
+ <div class="card-body">
171
+ <form id="cfgForm">
172
+ <div class="row g-3">
173
+ <div class="col-md-4"><label class="form-label">Alist URL</label><input type="text" class="form-control" name="url"></div>
174
+ <div class="col-md-4"><label class="form-label">用户名</label><input type="text" class="form-control" name="username"></div>
175
+ <div class="col-md-4"><label class="form-label">密码</label><input type="password" class="form-control" name="password"></div>
176
+ <div class="col-12"><hr></div>
177
+ <div class="col-md-6"><label class="form-label">源路径 (Source)</label><input type="text" class="form-control" name="path1" placeholder="/阿里云盘/电影"></div>
178
+ <div class="col-md-6"><label class="form-label">目标路径 (Target)</label><input type="text" class="form-control" name="path2" placeholder="/本地存储/电影"></div>
179
+ <div class="col-md-6"><label class="form-label">同步间隔 (分钟)</label><input type="number" class="form-control" name="interval"></div>
180
+ <div class="col-md-6 d-flex align-items-end">
181
+ <div class="form-check form-switch mb-2">
182
+ <input class="form-check-input" type="checkbox" id="auto_sync" name="auto_sync">
183
+ <label class="form-check-label fw-bold" for="auto_sync">开启后台自动同步</label>
184
+ </div>
185
+ </div>
186
+ </div>
187
+ <div class="mt-4 text-end">
188
+ <button type="button" class="btn btn-success" onclick="saveConfig()">💾 保存配置并应用</button>
189
+ </div>
190
+ </form>
191
+ </div>
192
+ </div>
193
+
194
+ <div class="card shadow mt-4">
195
+ <div class="card-header bg-secondary text-white">
196
+ 运行日志 <button class="btn btn-sm btn-light float-end" onclick="loadLogs()">刷新</button>
197
+ </div>
198
+ <div class="card-body p-0">
199
+ <div class="log-box" id="logContainer">加载中...</div>
200
+ </div>
201
+ </div>
202
+ </div>
203
+ </div>
204
+ </div>
205
+ <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
206
+ <script>
207
+ function loadData() {
208
+ $.get('/api/config', function(res) {
209
+ $('input[name="url"]').val(res.url);
210
+ $('input[name="username"]').val(res.username);
211
+ $('input[name="password"]').val(res.password);
212
+ $('input[name="path1"]').val(res.path1);
213
+ $('input[name="path2"]').val(res.path2);
214
+ $('input[name="interval"]').val(res.interval);
215
+ $('#auto_sync').prop('checked', res.auto_sync);
216
+ });
217
+ loadLogs();
218
+ }
219
+ function saveConfig() {
220
+ const data = {
221
+ url: $('input[name="url"]').val(),
222
+ username: $('input[name="username"]').val(),
223
+ password: $('input[name="password"]').val(),
224
+ path1: $('input[name="path1"]').val(),
225
+ path2: $('input[name="path2"]').val(),
226
+ interval: parseInt($('input[name="interval"]').val()),
227
+ auto_sync: $('#auto_sync').is(':checked')
228
+ };
229
+ $.ajax({ url: '/api/config', type: 'POST', contentType: 'application/json', data: JSON.stringify(data),
230
+ success: function() { alert('保存成功!后台将在下一周期应用新配置。'); loadLogs(); }});
231
+ }
232
+ function loadLogs() {
233
+ $.get('/api/logs', function(res) {
234
+ let html = '';
235
+ res.forEach(l => { html += `<div>[${l.timestamp}] ${l.message}</div>`; });
236
+ $('#logContainer').html(html);
237
+ });
238
+ }
239
+ loadData();
240
+ setInterval(loadLogs, 10000);
241
+ </script>
242
+ </body>
243
+ </html>
244
+ """
245
+
246
+ @app.route('/')
247
+ def index():
248
+ return render_template_string(HTML_TEMPLATE)
249
+
250
+ @app.route('/api/config', methods=['GET', 'POST'])
251
+ def api_config():
252
+ cfg = AppConfig.query.first()
253
+ if not cfg:
254
+ cfg = AppConfig()
255
+ db.session.add(cfg)
256
+ db.session.commit()
257
+
258
+ if request.method == 'POST':
259
+ data = request.json
260
+ cfg.url = data.get('url')
261
+ cfg.username = data.get('username')
262
+ cfg.password = data.get('password')
263
+ cfg.path1 = data.get('path1')
264
+ cfg.path2 = data.get('path2')
265
+ cfg.interval = data.get('interval')
266
+ status_change = data.get('auto_sync') != cfg.auto_sync
267
+ cfg.auto_sync = data.get('auto_sync')
268
+ db.session.commit()
269
+ if status_change:
270
+ add_log(f"用户手动{'开启' if cfg.auto_sync else '关闭'}了自动同步")
271
+ return jsonify({'success': True})
272
+
273
+ return jsonify({
274
+ 'url': cfg.url, 'username': cfg.username, 'password': cfg.password,
275
+ 'path1': cfg.path1, 'path2': cfg.path2,
276
+ 'interval': cfg.interval, 'auto_sync': cfg.auto_sync
277
+ })
278
+
279
+ @app.route('/api/logs')
280
+ def api_logs():
281
+ logs = AppLog.query.order_by(AppLog.timestamp.desc()).limit(50).all()
282
+ return jsonify([{'timestamp': l.timestamp.strftime("%m-%d %H:%M:%S"), 'message': l.message} for l in logs])
283
+
284
+ # --- 启动逻辑 ---
285
+ if __name__ == '__main__':
286
+ # 等待数据库准备好 (针对 Docker compose 启动慢的情况)
287
+ with app.app_context():
288
+ try:
289
+ db.create_all()
290
+ print("✅ 数据库连接成功,表结构已就绪。")
291
+ except Exception as e:
292
+ print(f"❌ 数据库连接失败,请检查配置: {e}")
293
+ # 不退出,让用户看报错信息
294
+
295
+ # 启动后台线程
296
+ t = threading.Thread(target=background_worker, daemon=True)
297
+ t.start()
298
+
299
+ # 启动 Flask
300
+ app.run(host='0.0.0.0', port=PORT, debug=False, use_reloader=False)