Skip to content

Commit 60102df

Browse files
committed
Release v0.4.2: 统一数据目录与缩略图CLI,仅视频缩略图、清理校验与进度条
1 parent 81289d4 commit 60102df

9 files changed

Lines changed: 601 additions & 35 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "TikLocal"
3-
version = "0.4.1"
3+
version = "0.4.2"
44
description = "A local media server that combines the features of TikTok and Pinterest"
55
authors = ["ChanMo <chan.mo@outlook.com>"]
66
readme = "README.md"

tiklocal/.DS_Store

6 KB
Binary file not shown.

tiklocal/app.py

Lines changed: 186 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010
from urllib.parse import quote, unquote
1111
from importlib.metadata import version, PackageNotFoundError
1212
import math
13+
import hashlib
1314

1415
from 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

1820
try:
@@ -21,6 +23,7 @@
2123
app_version = '1.0.0'
2224

2325
FAVORITE_FILENAME = 'favorite.json'
26+
VIDEO_EXTENSIONS = {'.mp4', '.webm', '.mov', '.mkv', '.avi', '.m4v'}
2427

2528

2629
def 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"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\x0cIDAT\x08\x99c\xf8\xff\xff?\x00\x05\xfe\x02\xfeA\x93\x8a\x1d\x00\x00\x00\x00IEND\xaeB`\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}")

tiklocal/paths.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import os
2+
from pathlib import Path
3+
4+
5+
def get_data_dir() -> Path:
6+
"""Return the global data directory for TikLocal.
7+
Default to ~/.tiklocal unless TIKLOCAL_INSTANCE is set.
8+
"""
9+
base = os.environ.get('TIKLOCAL_INSTANCE')
10+
if base:
11+
p = Path(base).expanduser()
12+
else:
13+
p = Path.home() / '.tiklocal'
14+
p.mkdir(parents=True, exist_ok=True)
15+
return p
16+
17+
18+
def get_thumbnails_dir() -> Path:
19+
d = get_data_dir() / 'thumbnails'
20+
d.mkdir(parents=True, exist_ok=True)
21+
return d
22+
23+
24+
def get_thumbs_map_path() -> Path:
25+
return get_data_dir() / 'thumbs.json'
26+

tiklocal/run.py

Lines changed: 59 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from pathlib import Path
55
from waitress import serve
66
from tiklocal.app import create_app
7+
from tiklocal.thumbs import generate_thumbnails
8+
from tiklocal.paths import get_data_dir
79

810
try:
911
import yaml
@@ -40,39 +42,70 @@ def main():
4042
# 读取配置文件
4143
config = load_config()
4244

43-
# 解析命令行参数
45+
# 预处理 argv,支持以下形式:
46+
# 1) tiklocal -> serve
47+
# 2) tiklocal /path -> serve /path
48+
# 3) tiklocal --port 9000 -> serve --port 9000
49+
# 4) tiklocal thumbs /path -> thumbs /path
50+
# 5) tiklocal /path thumbs -> thumbs /path
51+
argv = sys.argv[1:]
52+
if '-h' not in argv and '--help' not in argv:
53+
if 'thumbs' in argv:
54+
idx = argv.index('thumbs')
55+
if idx != 0:
56+
argv.pop(idx)
57+
argv.insert(0, 'thumbs')
58+
elif len(argv) == 0 or argv[0] not in ('serve', 'thumbs'):
59+
# 默认回退 serve(空参数或第一个不是已知子命令)
60+
argv.insert(0, 'serve')
61+
62+
# 解析命令行参数(支持子命令)
4463
parser = argparse.ArgumentParser(
4564
description='TikLocal - 本地媒体服务器',
4665
formatter_class=argparse.RawDescriptionHelpFormatter,
4766
epilog='''
4867
示例:
49-
tiklocal # 使用配置文件或环境变量
50-
tiklocal /path/to/media # 指定媒体目录
51-
tiklocal --port 9000 # 使用指定端口
52-
tiklocal /path/to/media --port 9000
68+
tiklocal # 启动服务(默认)
69+
tiklocal /path/to/media # 指定媒体目录
70+
tiklocal --port 9000 # 使用指定端口
71+
tiklocal serve /path --port 9000 # 显式使用 serve 子命令
72+
tiklocal thumbs /path --overwrite # 批量生成缩略图
5373
'''
5474
)
5575

56-
parser.add_argument(
57-
'media_root',
58-
nargs='?',
59-
help='媒体文件根目录路径'
60-
)
61-
parser.add_argument(
62-
'--host',
63-
default=None,
64-
help='服务器监听地址 (默认: 0.0.0.0)'
65-
)
66-
parser.add_argument(
67-
'--port',
68-
type=int,
69-
default=None,
70-
help='服务器端口 (默认: 8000)'
71-
)
72-
73-
args = parser.parse_args()
74-
75-
# 配置优先级: 命令行参数 > 环境变量 > 配置文件 > 默认值
76+
subparsers = parser.add_subparsers(dest='command')
77+
78+
# serve 子命令
79+
serve_parser = subparsers.add_parser('serve', help='启动服务器')
80+
serve_parser.add_argument('media_root', nargs='?', help='媒体文件根目录路径')
81+
serve_parser.add_argument('--host', default=None, help='服务器监听地址 (默认: 0.0.0.0)')
82+
serve_parser.add_argument('--port', type=int, default=None, help='服务器端口 (默认: 8000)')
83+
84+
# thumbs 子命令
85+
thumbs_parser = subparsers.add_parser('thumbs', help='批量生成视频缩略图')
86+
thumbs_parser.add_argument('media_root', nargs='?', help='媒体文件根目录路径(可省略以使用环境变量/配置文件)')
87+
thumbs_parser.add_argument('--overwrite', action='store_true', help='存在时覆盖重建')
88+
thumbs_parser.add_argument('--limit', type=int, default=0, help='最多处理多少个(0 表示全部)')
89+
90+
args = parser.parse_args(argv)
91+
92+
# 判断命令类型(无子命令时视为 serve)
93+
cmd = args.command or 'serve'
94+
95+
if cmd == 'thumbs':
96+
media_root = args.media_root or os.environ.get('MEDIA_ROOT') or config.get('media_root')
97+
if not media_root:
98+
parser.error('必须指定媒体目录:\n - tiklocal thumbs /path/to/media\n - 或设置环境变量: MEDIA_ROOT=/path/to/media')
99+
media_path = Path(media_root)
100+
if not media_path.exists() or not media_path.is_dir():
101+
print(f"错误: 媒体目录不可用: {media_root}", file=sys.stderr)
102+
sys.exit(1)
103+
print(f"数据目录: {get_data_dir()}")
104+
stats = generate_thumbnails(media_path, overwrite=getattr(args, 'overwrite', False), limit=getattr(args, 'limit', 0), show_progress=True)
105+
# 完成后退出
106+
return
107+
108+
# serve 路径
76109
media_root = args.media_root or os.environ.get('MEDIA_ROOT') or config.get('media_root')
77110
host = args.host or os.environ.get('TIKLOCAL_HOST') or config.get('host', '0.0.0.0')
78111
port = args.port or int(os.environ.get('TIKLOCAL_PORT', 0)) or config.get('port', 8000)
@@ -96,6 +129,7 @@ def main():
96129
# 启动服务器
97130
print(f"启动 TikLocal 服务器...")
98131
print(f"媒体目录: {media_path.absolute()}")
132+
print(f"数据目录: {get_data_dir()}")
99133
print(f"访问地址: http://{host}:{port}")
100134

101135
serve(create_app(), host=host, port=port)

tiklocal/static/output.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)