Skip to content

Commit 7104533

Browse files
committed
release: v0.8.7 来源回跳与结构化命名
1 parent be00289 commit 7104533

11 files changed

Lines changed: 609 additions & 14 deletions

File tree

docs/release_notes.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Release Notes
22

3+
## v0.8.7 (2026-02-21)
4+
- 新增“来源回跳”能力:下载成功后将文件与原始 URL 建立映射,并新增 `~/.tiklocal/download_sources.json` 持久化来源索引。
5+
- 新增来源解析三层兜底:优先来源映射,其次 `.info.json``webpage_url/original_url`),最后按文件名结构推断平台链接(x/youtube/tiktok/instagram)。
6+
- 新增来源查询 API:`GET /api/source``POST /api/source/batch`,用于详情页与下载列表按文件获取来源信息。
7+
- 视频/图片详情页“操作”区新增“查看来源”入口;下载列表输出文件旁新增来源链接展示。
8+
- `yt-dlp` 输出命名升级为结构化短名,并启用 `--write-info-json` 以增强跨平台回跳恢复能力。
9+
- 新增来源相关测试覆盖:来源映射写入、历史清理保留、info.json/文件名回退、批量查询与删除文件同步清理映射。
10+
311
## v0.8.6 (2026-02-21)
412
- URL 下载中心升级为双引擎:支持按任务手动选择 `yt-dlp` / `gallery-dl`,并在运行状态中展示双引擎可用性与版本。
513
- 下载任务模型增强:新增 `engine``engine_version``output_files_rel``file_count` 字段;兼容旧任务历史记录恢复。

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "tiklocal",
3-
"version": "0.8.6",
3+
"version": "0.8.7",
44
"description": "A local media server that combines the features of TikTok and Pinterest",
55
"scripts": {
66
"build-css": "tailwindcss -i ./tiklocal/static/input.css -o ./tiklocal/static/output.css --watch",

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.8.6"
3+
version = "0.8.7"
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"

tests/test_download.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,3 +276,103 @@ def test_delete_and_clear_history(client):
276276
assert clear_res.status_code == 200
277277
assert clear_data["success"] is True
278278
assert clear_data["data"]["deleted"] >= 0
279+
280+
281+
def test_source_api_from_job_map(client):
282+
res = client.post("/api/download/jobs", json={"url": "https://x.com/i/web/status/1234567890123456789?utm_source=test"})
283+
data = res.get_json()
284+
assert res.status_code == 200
285+
job_id = data["data"]["job"]["id"]
286+
_wait_for_job(client, job_id)
287+
288+
source_res = client.get("/api/source", query_string={"file": "mock-output.mp4"})
289+
source_data = source_res.get_json()
290+
assert source_res.status_code == 200
291+
assert source_data["success"] is True
292+
assert source_data["data"]["source"]["resolved_by"] == "map"
293+
assert source_data["data"]["source"]["source_domain"] == "x.com"
294+
assert source_data["data"]["source"]["source_url_display"] == "https://x.com/i/web/status/1234567890123456789"
295+
296+
297+
def test_source_map_kept_after_clear_history(client):
298+
res = client.post("/api/download/jobs", json={"url": "https://example.com/keep-source"})
299+
job_id = res.get_json()["data"]["job"]["id"]
300+
_wait_for_job(client, job_id)
301+
302+
clear_res = client.post("/api/download/jobs/clear")
303+
assert clear_res.status_code == 200
304+
305+
source_res = client.get("/api/source", query_string={"file": "mock-output.mp4"})
306+
source_data = source_res.get_json()
307+
assert source_res.status_code == 200
308+
assert source_data["success"] is True
309+
assert source_data["data"]["source"]["source_url_raw"] == "https://example.com/keep-source"
310+
311+
312+
def test_source_resolve_from_info_json(client):
313+
media_root = client.application.config["MEDIA_ROOT"]
314+
media_file = media_root / "fallback-info.mp4"
315+
media_file.write_bytes(b"00")
316+
info_file = media_root / "fallback-info.info.json"
317+
info_file.write_text(
318+
'{"webpage_url":"https://www.youtube.com/watch?v=abc123&utm_source=mail"}',
319+
encoding="utf-8",
320+
)
321+
322+
source_res = client.get("/api/source", query_string={"file": "fallback-info.mp4"})
323+
source_data = source_res.get_json()
324+
assert source_res.status_code == 200
325+
assert source_data["success"] is True
326+
assert source_data["data"]["source"]["resolved_by"] == "infojson"
327+
assert source_data["data"]["source"]["source_url_display"] == "https://www.youtube.com/watch?v=abc123"
328+
assert source_data["data"]["source"]["source_domain"] == "www.youtube.com"
329+
330+
331+
def test_source_resolve_from_filename(client):
332+
media_root = client.application.config["MEDIA_ROOT"]
333+
name = "twitter__alice__189111222333444555__189111222333444555__20260221__01.mp4"
334+
(media_root / name).write_bytes(b"00")
335+
336+
source_res = client.get("/api/source", query_string={"file": name})
337+
source_data = source_res.get_json()
338+
assert source_res.status_code == 200
339+
assert source_data["success"] is True
340+
assert source_data["data"]["source"]["resolved_by"] == "filename"
341+
assert source_data["data"]["source"]["source_url_display"] == "https://x.com/alice/status/189111222333444555"
342+
assert source_data["data"]["source"]["source_domain"] == "x.com"
343+
344+
345+
def test_source_batch_api(client):
346+
media_root = client.application.config["MEDIA_ROOT"]
347+
media_file = media_root / "batch-fallback.mp4"
348+
media_file.write_bytes(b"00")
349+
(media_root / "batch-fallback.info.json").write_text(
350+
'{"webpage_url":"https://www.tiktok.com/@u/video/12345"}',
351+
encoding="utf-8",
352+
)
353+
354+
res = client.post("/api/source/batch", json={"files": ["batch-fallback.mp4", "missing.mp4"]})
355+
data = res.get_json()
356+
assert res.status_code == 200
357+
assert data["success"] is True
358+
assert data["data"]["items"]["batch-fallback.mp4"]["source_domain"] == "www.tiktok.com"
359+
assert data["data"]["items"]["missing.mp4"] is None
360+
361+
362+
def test_delete_file_also_deletes_source_map(client):
363+
media_root = client.application.config["MEDIA_ROOT"]
364+
media_file = media_root / "mock-output.mp4"
365+
media_file.write_bytes(b"00")
366+
367+
res = client.post("/api/download/jobs", json={"url": "https://example.com/delete-source"})
368+
job_id = res.get_json()["data"]["job"]["id"]
369+
_wait_for_job(client, job_id)
370+
371+
delete_res = client.post("/delete/mock-output.mp4", follow_redirects=False)
372+
assert delete_res.status_code in {301, 302, 303, 307, 308}
373+
374+
source_res = client.get("/api/source", query_string={"file": "mock-output.mp4"})
375+
source_data = source_res.get_json()
376+
assert source_res.status_code == 200
377+
assert source_data["success"] is True
378+
assert source_data["data"]["source"] is None

tiklocal/app.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
DEFAULT_DOWNLOAD_CONFIG,
3030
DownloadConfigStore,
3131
DownloadHistoryStore,
32+
DownloadSourceStore,
3233
DownloadManager,
3334
validate_download_config,
3435
validate_download_url,
@@ -39,6 +40,7 @@
3940
get_llm_config_path,
4041
get_download_config_path,
4142
get_download_jobs_path,
43+
get_download_sources_path,
4244
)
4345

4446

@@ -98,10 +100,12 @@ def create_app(test_config=None):
98100
llm_config_store = LLMConfigStore(get_llm_config_path())
99101
download_config_store = DownloadConfigStore(get_download_config_path())
100102
download_history_store = DownloadHistoryStore(get_download_jobs_path())
103+
download_source_store = DownloadSourceStore(get_download_sources_path())
101104
download_manager = DownloadManager(
102105
Path(media_root_str),
103106
download_config_store,
104107
download_history_store,
108+
source_store=download_source_store,
105109
)
106110

107111
def build_prompt_config_payload(custom_config=None):
@@ -290,6 +294,7 @@ def detail_view(name):
290294

291295
if target.suffix.lower() in IMAGE_EXTENSIONS:
292296
return redirect(f"/image?uri={quote(name)}")
297+
source_meta = download_manager.resolve_source_for_file(name)
293298

294299
# Context navigation (prev/next)
295300
# Note: Re-scanning every request is inefficient for large libraries,
@@ -310,7 +315,8 @@ def detail_view(name):
310315
mtime=datetime.datetime.fromtimestamp(target.stat().st_mtime).strftime('%Y-%m-%d %H:%M'),
311316
size=target.stat().st_size,
312317
previous_item=prev_item,
313-
next_item=next_item
318+
next_item=next_item,
319+
source_meta=source_meta,
314320
)
315321

316322
@app.route('/image')
@@ -320,8 +326,8 @@ def image_view():
320326

321327
target = library_service.resolve_path(uri)
322328
if not target or not target.exists(): return "File not found", 404
323-
324-
return render_template('image_detail.html', image=target, uri=uri, stat=target.stat())
329+
source_meta = download_manager.resolve_source_for_file(uri)
330+
return render_template('image_detail.html', image=target, uri=uri, stat=target.stat(), source_meta=source_meta)
325331

326332
@app.route("/delete/<path:name>", methods=['POST', 'GET'])
327333
def delete_view(name):
@@ -330,6 +336,7 @@ def delete_view(name):
330336
if target and target.exists():
331337
try:
332338
target.unlink()
339+
download_manager.delete_source_for_file(name)
333340
# Thumbnails are handled by OS or periodic cleanup, but ideally Service should handle it
334341
except Exception as e:
335342
return f"Error deleting file: {e}", 500
@@ -516,6 +523,31 @@ def api_download_job_retry(job_id):
516523
return {'success': False, 'error': error}, status
517524
return {'success': True, 'data': {'job': job}}
518525

526+
@app.route('/api/source')
527+
def api_source_single():
528+
file_rel = str(request.args.get('file', '')).strip()
529+
if not file_rel:
530+
return {'success': False, 'error': 'file 不能为空。'}, 400
531+
source = download_manager.resolve_source_for_file(file_rel)
532+
return {'success': True, 'data': {'file': file_rel, 'source': source}}
533+
534+
@app.route('/api/source/batch', methods=['POST'])
535+
def api_source_batch():
536+
payload = request.get_json(silent=True) or {}
537+
files = payload.get('files')
538+
if not isinstance(files, list):
539+
return {'success': False, 'error': 'files 必须是数组。'}, 400
540+
541+
normalized: list[str] = []
542+
for item in files:
543+
value = str(item or '').strip()
544+
if value:
545+
normalized.append(value)
546+
if len(normalized) >= 200:
547+
break
548+
items = download_manager.resolve_sources_for_files(normalized)
549+
return {'success': True, 'data': {'items': items}}
550+
519551
@app.route('/api/ai/prompt-config', methods=['GET', 'POST'])
520552
def api_prompt_config():
521553
if request.method == 'GET':

tiklocal/paths.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,7 @@ def get_download_config_path() -> Path:
4343

4444
def get_download_jobs_path() -> Path:
4545
return get_data_dir() / 'download_jobs.json'
46+
47+
48+
def get_download_sources_path() -> Path:
49+
return get_data_dir() / 'download_sources.json'

0 commit comments

Comments
 (0)