1010from urllib .parse import quote , unquote
1111from importlib .metadata import version , PackageNotFoundError
1212import math
13+ import hashlib
1314
1415from pathlib import Path
15- from flask import Flask , render_template , send_from_directory , request , session , redirect
16+ from flask import Flask , render_template , send_from_directory , request , session , redirect , send_file
17+ from tiklocal .paths import get_thumbnails_dir , get_thumbs_map_path
1618
1719
1820try :
2123 app_version = '1.0.0'
2224
2325FAVORITE_FILENAME = 'favorite.json'
26+ VIDEO_EXTENSIONS = {'.mp4' , '.webm' , '.mov' , '.mkv' , '.avi' , '.m4v' }
2427
2528
2629def load_favorites (media_root : Path ) -> set [str ]:
@@ -107,6 +110,153 @@ def create_app(test_config=None):
107110 except OSError :
108111 pass
109112
113+ # 缩略图配置(统一使用全局数据目录 ~/.tiklocal 或 TIKLOCAL_INSTANCE)
114+ THUMB_DIR = get_thumbnails_dir ()
115+ THUMB_MAP = get_thumbs_map_path ()
116+
117+ PLACEHOLDER_PNG = (
118+ b"\x89 PNG\r \n \x1a \n \x00 \x00 \x00 \r IHDR\x00 \x00 \x00 \x01 \x00 \x00 \x00 \x01 \x08 \x06 \x00 \x00 \x00 \x1f \x15 \xc4 \x89 \x00 \x00 \x00 \x0c IDAT\x08 \x99 c\xf8 \xff \xff ?\x00 \x05 \xfe \x02 \xfe A\x93 \x8a \x1d \x00 \x00 \x00 \x00 IEND\xae B`\x82 "
119+ )
120+
121+ def _thumb_key (rel_path : str ) -> str :
122+ return hashlib .sha1 (rel_path .encode ('utf-8' , errors = 'ignore' )).hexdigest () + '.jpg'
123+
124+ def _thumb_path (rel_path : str ) -> Path :
125+ return THUMB_DIR / _thumb_key (rel_path )
126+
127+ def _load_thumb_map () -> dict :
128+ if THUMB_MAP .exists ():
129+ try :
130+ with THUMB_MAP .open ('r' , encoding = 'utf-8' ) as f :
131+ data = json .load (f )
132+ if isinstance (data , dict ):
133+ return data
134+ except Exception as exc :
135+ print (f"读取缩略图映射失败: { exc } " , file = sys .stderr )
136+ return {}
137+
138+ def _save_thumb_map (data : dict ) -> None :
139+ try :
140+ with THUMB_MAP .open ('w' , encoding = 'utf-8' ) as f :
141+ json .dump (data , f , ensure_ascii = False )
142+ except Exception as exc :
143+ print (f"保存缩略图映射失败: { exc } " , file = sys .stderr )
144+
145+ def _ffmpeg_capture (input_path : Path , output_path : Path , ts : float | None ) -> bool :
146+ """用 ffmpeg 截帧到 output_path,返回是否成功。"""
147+ candidates = []
148+ if ts is not None and ts >= 0 :
149+ candidates = [ts ]
150+ else :
151+ # 无时长信息时的保守候选
152+ candidates = [5.0 , 1.0 , 0.1 ]
153+
154+ for t in candidates :
155+ cmd = [
156+ 'ffmpeg' ,
157+ '-y' ,
158+ '-ss' , str (max (0.0 , float (t ))),
159+ '-i' , str (input_path ),
160+ '-frames:v' , '1' ,
161+ '-vf' , 'scale=-1:360:force_original_aspect_ratio=decrease' ,
162+ '-q:v' , '3' ,
163+ str (output_path )
164+ ]
165+ try :
166+ proc = sp .run (cmd , stdout = sp .DEVNULL , stderr = sp .DEVNULL , timeout = 30 )
167+ if output_path .exists () and output_path .stat ().st_size > 0 :
168+ return True
169+ except FileNotFoundError :
170+ # 未安装 ffmpeg
171+ return False
172+ except sp .TimeoutExpired :
173+ continue
174+ except Exception as exc :
175+ print (f"ffmpeg 截帧错误: { exc } " , file = sys .stderr )
176+ continue
177+ return False
178+
179+ def _is_video (path : Path ) -> bool :
180+ try :
181+ mime = mimetypes .guess_type (path .name )[0 ] or ''
182+ except Exception :
183+ mime = ''
184+ if path .suffix .lower () in VIDEO_EXTENSIONS :
185+ return True
186+ return mime .startswith ('video/' )
187+
188+ @app .route ('/thumb' )
189+ def thumb_view ():
190+ """按需返回或生成缩略图,不污染 MEDIA_ROOT。"""
191+ uri = request .args .get ('uri' )
192+ if not uri :
193+ return send_file (io .BytesIO (PLACEHOLDER_PNG ), mimetype = 'image/png' )
194+
195+ # 解析并校验路径
196+ try :
197+ media_root : Path = Path (app .config ["MEDIA_ROOT" ])
198+ target = media_root / Path (unquote (uri ))
199+ target = target .resolve ()
200+ # 拒绝越权访问
201+ if not str (target ).startswith (str (media_root .resolve ())):
202+ return send_file (io .BytesIO (PLACEHOLDER_PNG ), mimetype = 'image/png' )
203+ except Exception :
204+ return send_file (io .BytesIO (PLACEHOLDER_PNG ), mimetype = 'image/png' )
205+
206+ rel_path = str (Path (unquote (uri )))
207+ thumb_file = _thumb_path (rel_path )
208+
209+ # 仅为视频生成缩略图
210+ if not _is_video (target ):
211+ return send_file (io .BytesIO (PLACEHOLDER_PNG ), mimetype = 'image/png' )
212+
213+ if not thumb_file .exists ():
214+ # 生成缩略图(懒生成)
215+ ok = _ffmpeg_capture (target , thumb_file , None )
216+ if not ok :
217+ return send_file (io .BytesIO (PLACEHOLDER_PNG ), mimetype = 'image/png' )
218+
219+ return send_from_directory (THUMB_DIR , thumb_file .name )
220+
221+ @app .route ('/api/thumbnail/<path:name>' , methods = ['POST' ])
222+ def set_thumbnail (name ):
223+ """将指定时间点设为缩略图。"""
224+ try :
225+ media_root : Path = Path (app .config ["MEDIA_ROOT" ])
226+ target = (media_root / name ).resolve ()
227+ if not target .exists () or not str (target ).startswith (str (media_root .resolve ())):
228+ return {'success' : False , 'error' : '文件不存在或非法路径' }, 400
229+
230+ payload = request .get_json (silent = True ) or {}
231+ ts = payload .get ('time' , None )
232+ try :
233+ ts_val = None if ts is None else max (0.0 , float (ts ))
234+ except (TypeError , ValueError ):
235+ ts_val = None
236+
237+ rel_path = str (Path (name ))
238+ thumb_file = _thumb_path (rel_path )
239+ # 重新生成
240+ ok = _ffmpeg_capture (target , thumb_file , ts_val )
241+ if not ok :
242+ return {'success' : False , 'error' : '缩略图生成失败(可能未安装 ffmpeg)' }, 500
243+
244+ # 记录映射
245+ mapping = _load_thumb_map ()
246+ mapping [rel_path ] = {
247+ 'ts' : ts_val if ts_val is not None else None ,
248+ 'updated_at' : datetime .datetime .now ().isoformat (timespec = 'seconds' )
249+ }
250+ _save_thumb_map (mapping )
251+
252+ return {
253+ 'success' : True ,
254+ 'url' : f"/thumb?uri={ quote (rel_path )} &v={ int (datetime .datetime .now ().timestamp ())} "
255+ }
256+ except Exception as exc :
257+ print (f"设置缩略图错误: { exc } " , file = sys .stderr )
258+ return {'success' : False , 'error' : '内部错误' }, 500
259+
110260 # 添加自定义过滤器
111261 @app .template_filter ('timestamp_to_date' )
112262 def timestamp_to_date (timestamp ):
@@ -211,6 +361,26 @@ def get_files(directory, media_type='video'):
211361 def browse ():
212362 root = Path (app .config ["MEDIA_ROOT" ])
213363 videos = list (root .glob ('**/*.mp4' )) + list (root .glob ('**/*.webm' ))
364+
365+ # 大文件快速筛选:通过 query 参数启用(size=big),默认阈值 50MB,可用 min_mb 调整
366+ size_mode = request .args .get ('size' , 'all' )
367+ try :
368+ min_mb = int (request .args .get ('min_mb' , 50 ))
369+ except ValueError :
370+ min_mb = 50
371+ has_min_mb = request .args .get ('min_mb' ) is not None
372+
373+ if size_mode == 'big' :
374+ threshold = min_mb * 1024 * 1024
375+ filtered = []
376+ for v in videos :
377+ try :
378+ if v .stat ().st_size >= threshold :
379+ filtered .append (v )
380+ except (FileNotFoundError , PermissionError ):
381+ continue
382+ videos = filtered
383+
214384 videos = sorted (videos , key = lambda row :row .stat ().st_ctime , reverse = True )
215385 count = len (videos )
216386 page = int (request .args .get ('page' , 1 ))
@@ -228,6 +398,9 @@ def browse():
228398 length = length ,
229399 files = res ,
230400 menu = 'browse' ,
401+ size_mode = size_mode ,
402+ min_mb = min_mb ,
403+ has_min_mb = has_min_mb ,
231404 has_previous = page > 1 ,
232405 has_next = len (videos [offset + length :])> 1
233406 )
@@ -337,6 +510,18 @@ def delete_view(name):
337510 file_path = Path (app .config ["MEDIA_ROOT" ]) / name
338511 if file_path .exists ():
339512 os .unlink (file_path )
513+ # 清理缩略图及映射
514+ try :
515+ rel_path = str (Path (name ))
516+ thumb_file = _thumb_path (rel_path )
517+ if thumb_file .exists ():
518+ thumb_file .unlink (missing_ok = True )
519+ mapping = _load_thumb_map ()
520+ if rel_path in mapping :
521+ mapping .pop (rel_path , None )
522+ _save_thumb_map (mapping )
523+ except Exception as _ :
524+ pass
340525 return redirect ('/browse' )
341526 except Exception as e :
342527 print (f"删除文件错误: { e } " )
0 commit comments