Skip to content

Commit e59d05b

Browse files
committed
release: v0.8.5 URL下载中心与极简交互优化
1 parent 9a11f4e commit e59d05b

13 files changed

Lines changed: 1925 additions & 2026 deletions

File tree

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,29 @@ Options for `dedupe`:
7676
- `--execute`: Execute actual deletion
7777
- `--auto-confirm`: Skip confirmation prompt
7878

79+
### URL Download (Web)
80+
81+
TikLocal includes a `/download` page where you can paste a media URL and enqueue a background download job.
82+
83+
Requirements:
84+
- `yt-dlp` (required)
85+
- `ffmpeg` (recommended for format merge)
86+
87+
Cookie for login-only content (optional):
88+
- Put exported cookie files in `~/.tiklocal/cookies`
89+
- Filename should include domain, e.g. `x.com.txt`, `youtube.com.cookies`
90+
- The download page supports `Auto match` or manual file selection per task
91+
- The download page also supports cookie file upload/replace, history delete/clear, and retry for failed tasks
92+
93+
Example installs:
94+
```bash
95+
# macOS (Homebrew)
96+
brew install yt-dlp ffmpeg
97+
98+
# Ubuntu / Debian
99+
sudo apt install yt-dlp ffmpeg
100+
```
101+
79102
### Configuration
80103

81104
TikLocal provides some configuration options that you can adjust to your needs.

README_zh.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,29 @@ tiklocal dedupe /path --keep newest # 保留最新的文件
7474
- `--execute`: 执行实际删除
7575
- `--auto-confirm`: 跳过确认提示
7676

77+
### URL 下载(Web)
78+
79+
TikLocal 新增了 `/download` 页面,可粘贴媒体 URL 并创建后台下载任务。
80+
81+
依赖要求:
82+
- `yt-dlp`(必需)
83+
- `ffmpeg`(建议,用于格式合并)
84+
85+
登录态内容(可选):
86+
- 将导出的 cookie 文件放到 `~/.tiklocal/cookies`
87+
- 文件名建议包含域名,例如 `x.com.txt``youtube.com.cookies`
88+
- 下载页面支持“自动匹配”或按任务手动指定 cookie 文件
89+
- 下载页面也支持凭据文件上传/覆盖、历史删除/清空,以及失败任务重试
90+
91+
安装示例:
92+
```bash
93+
# macOS (Homebrew)
94+
brew install yt-dlp ffmpeg
95+
96+
# Ubuntu / Debian
97+
sudo apt install yt-dlp ffmpeg
98+
```
99+
77100
### 配置
78101

79102
TikLocal 提供了一些配置选项,您可以根据自己的需要进行调整。
@@ -106,5 +129,3 @@ TikLocal 是一个开源项目,您可以通过以下方式进行贡献:
106129

107130
* GitHub 项目地址:[https://github.com/ChanMo/TikLocal/](https://github.com/ChanMo/TikLocal/)
108131
* 邮箱:[chan.mo@outlook.com]
109-
110-

docs/release_notes.md

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

3+
## v0.8.5 (2026-02-20)
4+
- 新增 URL 下载中心(`/download`)与后台任务队列:支持任务创建、取消、删除、清空历史与失败重试。
5+
- 新增 cookie 文件方案:支持 `~/.tiklocal/cookies` 自动匹配/手动指定、页面上传即同名覆盖更新。
6+
- 下载链路增强网络容错:启用 `yt-dlp` 继续下载与重试参数,提升断网恢复能力。
7+
- 完成下载页交互重构:单主操作流、上传入口收敛、状态标签降饱和、Toast 反馈替代 alert。
8+
- 新增 `tests/test_download.py`,覆盖下载配置、cookie 处理、重试与历史清理接口。
9+
310
## v0.8.4 (2026-02-20)
411
- 新增 AI Prompt 配置能力:支持在设置页自定义 system/user prompt、temperature、tags_limit,并支持重置默认值。
512
- 新增 LLM 运行时配置:支持在设置页配置 `base_url``model_name`,并展示 API Key 是否已配置。

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.4",
3+
"version": "0.8.5",
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.4"
3+
version = "0.8.5"
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: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import time
2+
from io import BytesIO
3+
4+
import pytest
5+
6+
from tiklocal.app import create_app
7+
8+
9+
@pytest.fixture
10+
def client(tmp_path, monkeypatch):
11+
media_root = tmp_path / "media"
12+
media_root.mkdir(parents=True, exist_ok=True)
13+
cookie_root = tmp_path / "cookies"
14+
cookie_root.mkdir(parents=True, exist_ok=True)
15+
(cookie_root / "x.com.txt").write_text("# Netscape HTTP Cookie File\n", encoding="utf-8")
16+
(cookie_root / "youtube.com.cookies").write_text("# Netscape HTTP Cookie File\n", encoding="utf-8")
17+
18+
data_root = tmp_path / "tiklocal-data"
19+
monkeypatch.setenv("MEDIA_ROOT", str(media_root))
20+
monkeypatch.setenv("TIKLOCAL_INSTANCE", str(data_root))
21+
monkeypatch.setenv("TIKLOCAL_COOKIE_DIR", str(cookie_root))
22+
23+
def fake_execute_download(self, job_id): # noqa: ARG001
24+
return 0, "", "mock-output.mp4"
25+
26+
monkeypatch.setattr("tiklocal.services.downloader.DownloadManager._execute_download", fake_execute_download)
27+
28+
app = create_app({"TESTING": True, "MEDIA_ROOT": media_root})
29+
return app.test_client()
30+
31+
32+
def _wait_for_job(client, job_id, timeout=2.0):
33+
end = time.time() + timeout
34+
while time.time() < end:
35+
res = client.get(f"/api/download/jobs/{job_id}")
36+
assert res.status_code == 200
37+
data = res.get_json()
38+
job = data["data"]["job"]
39+
if job["status"] in {"success", "failed", "canceled"}:
40+
return job
41+
time.sleep(0.05)
42+
return job
43+
44+
45+
def test_download_config_api(client):
46+
res = client.get("/api/download/config")
47+
data = res.get_json()
48+
assert res.status_code == 200
49+
assert data["success"] is True
50+
assert data["data"]["effective"]["max_concurrent"] == 2
51+
52+
res = client.post(
53+
"/api/download/config",
54+
json={
55+
"enabled": True,
56+
"default_to_root": True,
57+
"allow_playlist": False,
58+
"max_concurrent": 0,
59+
},
60+
)
61+
data = res.get_json()
62+
assert res.status_code == 200
63+
assert data["success"] is True
64+
assert data["data"]["effective"]["max_concurrent"] == 0
65+
66+
67+
def test_download_config_validation(client):
68+
res = client.post(
69+
"/api/download/config",
70+
json={
71+
"enabled": True,
72+
"default_to_root": True,
73+
"allow_playlist": False,
74+
"max_concurrent": -1,
75+
},
76+
)
77+
data = res.get_json()
78+
assert res.status_code == 400
79+
assert data["success"] is False
80+
assert "max_concurrent" in data["error"]
81+
82+
83+
def test_create_download_job_success(client):
84+
res = client.post("/api/download/jobs", json={"url": "https://example.com/video"})
85+
data = res.get_json()
86+
assert res.status_code == 200
87+
assert data["success"] is True
88+
job_id = data["data"]["job"]["id"]
89+
90+
final_job = _wait_for_job(client, job_id)
91+
assert final_job["status"] == "success"
92+
assert final_job["output_path_rel"] == "mock-output.mp4"
93+
assert final_job["cookie_match_mode"] == "none"
94+
95+
96+
def test_create_download_job_validation(client):
97+
res = client.post("/api/download/jobs", json={"url": "file:///tmp/a.mp4"})
98+
data = res.get_json()
99+
assert res.status_code == 400
100+
assert data["success"] is False
101+
assert "http/https" in data["error"]
102+
103+
104+
def test_cancel_download_job(client, monkeypatch):
105+
def slow_execute_download(self, job_id): # noqa: ARG001
106+
time.sleep(0.25)
107+
return 0, "", "mock-output.mp4"
108+
109+
monkeypatch.setattr("tiklocal.services.downloader.DownloadManager._execute_download", slow_execute_download)
110+
111+
res = client.post("/api/download/jobs", json={"url": "https://example.com/video2"})
112+
data = res.get_json()
113+
assert res.status_code == 200
114+
job_id = data["data"]["job"]["id"]
115+
116+
cancel_res = client.post(f"/api/download/jobs/{job_id}/cancel")
117+
cancel_data = cancel_res.get_json()
118+
assert cancel_res.status_code == 200
119+
assert cancel_data["success"] is True
120+
121+
final_job = _wait_for_job(client, job_id)
122+
assert final_job["status"] in {"canceled", "success"}
123+
124+
125+
def test_download_probe_api(client):
126+
res = client.post("/api/download/probe")
127+
data = res.get_json()
128+
assert res.status_code == 200
129+
assert data["success"] is True
130+
assert "yt_dlp_available" in data["data"]
131+
assert "ffmpeg_available" in data["data"]
132+
133+
134+
def test_download_cookie_files_api(client):
135+
res = client.get("/api/download/cookies")
136+
data = res.get_json()
137+
assert res.status_code == 200
138+
assert data["success"] is True
139+
assert "x.com.txt" in data["data"]["files"]
140+
assert "youtube.com.cookies" in data["data"]["files"]
141+
142+
143+
def test_download_job_auto_cookie_match(client):
144+
res = client.post("/api/download/jobs", json={"url": "https://m.x.com/video/123"})
145+
data = res.get_json()
146+
assert res.status_code == 200
147+
assert data["success"] is True
148+
job = data["data"]["job"]
149+
assert job["cookie_match_mode"] == "auto"
150+
assert job["cookie_file"] == "x.com.txt"
151+
152+
153+
def test_download_job_manual_cookie_file(client):
154+
res = client.post(
155+
"/api/download/jobs",
156+
json={"url": "https://example.com/private", "cookie_mode": "manual", "cookie_file": "youtube.com.cookies"},
157+
)
158+
data = res.get_json()
159+
assert res.status_code == 200
160+
assert data["success"] is True
161+
job = data["data"]["job"]
162+
assert job["cookie_match_mode"] == "manual"
163+
assert job["cookie_file"] == "youtube.com.cookies"
164+
165+
166+
def test_download_job_rejects_invalid_cookie_file(client):
167+
res = client.post(
168+
"/api/download/jobs",
169+
json={"url": "https://example.com/private", "cookie_mode": "manual", "cookie_file": "../secrets.txt"},
170+
)
171+
data = res.get_json()
172+
assert res.status_code == 400
173+
assert data["success"] is False
174+
assert "cookie_file" in data["error"]
175+
176+
177+
def test_upload_cookie_file_and_replace(client):
178+
res = client.post(
179+
"/api/download/cookies/upload",
180+
data={"file": (BytesIO(b"# Netscape HTTP Cookie File\n"), "instagram.com.txt")},
181+
content_type="multipart/form-data",
182+
)
183+
data = res.get_json()
184+
assert res.status_code == 200
185+
assert data["success"] is True
186+
assert data["data"]["filename"] == "instagram.com.txt"
187+
188+
res = client.post(
189+
"/api/download/cookies/upload",
190+
data={"file": (BytesIO(b"# Netscape HTTP Cookie File\n"), "instagram.com.txt")},
191+
content_type="multipart/form-data",
192+
)
193+
data = res.get_json()
194+
assert res.status_code == 200
195+
assert data["success"] is True
196+
197+
198+
def test_retry_failed_job(client, monkeypatch):
199+
def fail_execute(self, job_id): # noqa: ARG001
200+
return 1, "network", ""
201+
202+
monkeypatch.setattr("tiklocal.services.downloader.DownloadManager._execute_download", fail_execute)
203+
res = client.post("/api/download/jobs", json={"url": "https://example.com/fail"})
204+
data = res.get_json()
205+
assert res.status_code == 200
206+
failed_job = _wait_for_job(client, data["data"]["job"]["id"])
207+
assert failed_job["status"] == "failed"
208+
209+
def ok_execute(self, job_id): # noqa: ARG001
210+
return 0, "", "retry-ok.mp4"
211+
212+
monkeypatch.setattr("tiklocal.services.downloader.DownloadManager._execute_download", ok_execute)
213+
retry_res = client.post(f"/api/download/jobs/{failed_job['id']}/retry")
214+
retry_data = retry_res.get_json()
215+
assert retry_res.status_code == 200
216+
new_job_id = retry_data["data"]["job"]["id"]
217+
assert new_job_id != failed_job["id"]
218+
assert retry_data["data"]["job"]["retry_of"] == failed_job["id"]
219+
final = _wait_for_job(client, new_job_id)
220+
assert final["status"] == "success"
221+
222+
223+
def test_delete_and_clear_history(client):
224+
res1 = client.post("/api/download/jobs", json={"url": "https://example.com/a"})
225+
res2 = client.post("/api/download/jobs", json={"url": "https://example.com/b"})
226+
j1 = _wait_for_job(client, res1.get_json()["data"]["job"]["id"])
227+
_wait_for_job(client, res2.get_json()["data"]["job"]["id"])
228+
229+
del_res = client.delete(f"/api/download/jobs/{j1['id']}")
230+
del_data = del_res.get_json()
231+
assert del_res.status_code == 200
232+
assert del_data["success"] is True
233+
234+
check_res = client.get(f"/api/download/jobs/{j1['id']}")
235+
assert check_res.status_code == 404
236+
237+
clear_res = client.post("/api/download/jobs/clear")
238+
clear_data = clear_res.get_json()
239+
assert clear_res.status_code == 200
240+
assert clear_data["success"] is True
241+
assert clear_data["data"]["deleted"] >= 0

0 commit comments

Comments
 (0)