Skip to content

Commit 591bd40

Browse files
ChanMoclaude
andcommitted
release: v0.8.11 架构收敛与废弃路由清理
- 新增 flow_session.js、flow_actions_shared.js、flow_media_actions_controller.js,统一 Home / Library / Favorites 的会话状态与媒体动作编排 - 移除废弃路由 /browse、/gallery 及旧接口 /api/videos、/api/random-images - 删除未使用模板 browse.html、favorite.html、gallery.html、index.html Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5c0a580 commit 591bd40

14 files changed

Lines changed: 703 additions & 1349 deletions

docs/release_notes.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# Release Notes
22

33
## Unreleased
4-
- 待补充
4+
- 架构收敛:新增 `flow_session.js``flow_actions_shared.js``flow_media_actions_controller.js`,统一 Home / Library / Favorites 的会话状态与媒体动作编排。
5+
- 清理废弃能力:移除 `/browse``/gallery` 旧路由与 `/api/videos``/api/random-images` 旧接口,删除未使用模板 `browse.html``favorite.html``gallery.html``index.html`
56

67
## v0.8.10 (2026-02-22)
78
- 修复 Library/Favorites 的 Quick Viewer 关闭后页面滚动锁死问题:补齐 body 滚动状态恢复逻辑,确保关闭弹层后列表可继续滚动。

tests/test_library_upgrade.py

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
from urllib.parse import quote
23

34
import pytest
45

@@ -51,7 +52,12 @@ def test_library_page_has_mode_tabs_and_no_masonry_label(client):
5152
assert "scheduleWaterfallRelayout" in body
5253
assert "flow_ui_shared.js" in body
5354
assert "flow_state_controller.js" in body
55+
assert "flow_session.js" in body
56+
assert "flow_actions_shared.js" in body
57+
assert "flow_media_actions_controller.js" in body
5458
assert "createFlowStateController(" in body
59+
assert "createFlowSession(" in body
60+
assert "createFlowMediaActionsController(" in body
5561
assert "uiShared.updateMagnifierContent({" in body
5662

5763

@@ -62,7 +68,12 @@ def test_home_feed_uses_unified_immersive_model(client):
6268
assert "body.immersive-mode .caption-panel:not(.is-hidden)" in body
6369
assert "flow_ui_shared.js" in body
6470
assert "flow_state_controller.js" in body
71+
assert "flow_session.js" in body
72+
assert "flow_actions_shared.js" in body
73+
assert "flow_media_actions_controller.js" in body
6574
assert "createFlowStateController(" in body
75+
assert "createFlowSession(" in body
76+
assert "createFlowMediaActionsController(" in body
6677
assert "flowState.toggleImmersive()" in body
6778
assert "flowState.setMagnifying(active)" in body
6879
assert "uiShared.updateMagnifierContent({" in body
@@ -139,19 +150,18 @@ def test_api_library_items_dedupes_symlink_aliases(tmp_path, monkeypatch):
139150
assert video_items[0]["name"] in {"origin.mp4", "alias.mp4"}
140151

141152

142-
def test_legacy_browse_and_gallery_redirects(client):
143-
browse = client.get("/browse", follow_redirects=False)
144-
assert browse.status_code in {301, 302, 308}
145-
assert browse.headers.get("Location", "").startswith("/library?mode=video_latest")
153+
def test_removed_legacy_routes_and_apis_return_404(client):
154+
browse = client.get("/browse")
155+
assert browse.status_code == 404
146156

147-
browse_big = client.get("/browse?filter=big&min_mb=3", follow_redirects=False)
148-
assert browse_big.status_code in {301, 302, 308}
149-
assert "mode=big_files" in browse_big.headers.get("Location", "")
150-
assert "min_mb=3" in browse_big.headers.get("Location", "")
157+
gallery = client.get("/gallery")
158+
assert gallery.status_code == 404
151159

152-
gallery = client.get("/gallery", follow_redirects=False)
153-
assert gallery.status_code in {301, 302, 308}
154-
assert gallery.headers.get("Location", "").startswith("/library?mode=image_random")
160+
api_videos = client.get("/api/videos")
161+
assert api_videos.status_code == 404
162+
163+
api_random_images = client.get("/api/random-images?page=1&size=10")
164+
assert api_random_images.status_code == 404
155165

156166

157167
def test_favorite_scope_and_detail_links(client):
@@ -166,3 +176,37 @@ def test_favorite_scope_and_detail_links(client):
166176
assert "i01.jpg" in names
167177
assert any(item["detail_url"] == "/detail/v01.mp4" for item in data["items"])
168178
assert any(item["detail_url"] == "/image?uri=i01.jpg" for item in data["items"])
179+
180+
181+
def test_special_chars_in_media_urls_are_encoded(tmp_path, monkeypatch):
182+
media_root = tmp_path / "media"
183+
media_root.mkdir(parents=True, exist_ok=True)
184+
video_name = "v#1+.mp4"
185+
image_name = "a&b.jpg"
186+
(media_root / video_name).write_bytes(b"video")
187+
(media_root / image_name).write_bytes(b"image")
188+
189+
data_root = tmp_path / "tiklocal-data"
190+
monkeypatch.setenv("MEDIA_ROOT", str(media_root))
191+
monkeypatch.setenv("TIKLOCAL_INSTANCE", str(data_root))
192+
app = create_app({"TESTING": True, "MEDIA_ROOT": media_root})
193+
local_client = app.test_client()
194+
195+
video_detail = local_client.get(f"/detail/{quote(video_name, safe='')}")
196+
assert video_detail.status_code == 200
197+
video_body = video_detail.data.decode("utf-8")
198+
assert 'src="/media/v%231%2B.mp4"' in video_body
199+
assert 'poster="/thumb?uri=v%231%2B.mp4"' in video_body
200+
assert "const fileName = \"v#1+.mp4\";" in video_body
201+
202+
image_detail = local_client.get(f"/image?uri={quote(image_name, safe='')}")
203+
assert image_detail.status_code == 200
204+
image_body = image_detail.data.decode("utf-8")
205+
assert 'src="/media?uri=a%26b.jpg"' in image_body
206+
assert "const imageUri = \"a\\u0026b.jpg\";" in image_body
207+
assert "const imageUriEncoded = \"a%26b.jpg\";" in image_body
208+
assert "window.location.href = '/delete?uri=a%26b.jpg';" in image_body
209+
210+
media_res = local_client.get(f"/media?uri={quote(video_name, safe='')}", follow_redirects=False)
211+
assert media_res.status_code in {301, 302, 308}
212+
assert media_res.headers.get("Location", "").endswith("/media/v%231%2B.mp4")

tiklocal/app.py

Lines changed: 18 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -418,27 +418,11 @@ def tiktok():
418418
"""Immersive Mixed Media Feed"""
419419
return render_template('tiktok.html', menu='index')
420420

421-
@app.route('/gallery')
422-
def gallery():
423-
"""Legacy route: image discovery now maps to Library image mode."""
424-
return redirect('/library?mode=image_random')
425-
426421
@app.route('/download')
427422
def download_view():
428423
"""URL Download Center"""
429424
return render_template('download.html', menu='download')
430425

431-
@app.route('/browse')
432-
def browse():
433-
"""Legacy route: video list now maps to Library video mode."""
434-
filter_mode = str(request.args.get('filter', 'all')).strip()
435-
if filter_mode == 'favorite':
436-
return redirect('/favorite')
437-
if filter_mode == 'big':
438-
min_mb = _read_int_arg('min_mb', 50, minimum=1, maximum=10240)
439-
return redirect(f'/library?mode=big_files&min_mb={min_mb}')
440-
return redirect('/library?mode=video_latest')
441-
442426
@app.route('/settings/')
443427
def settings_view():
444428
from tiklocal.paths import get_thumbnails_dir
@@ -510,6 +494,8 @@ def detail_view(name):
510494
if target.suffix.lower() in IMAGE_EXTENSIONS:
511495
return redirect(f"/image?uri={quote(name)}")
512496
source_meta = download_manager.resolve_source_for_file(name)
497+
file_path_encoded = quote(name, safe='/')
498+
file_query_encoded = quote(name, safe='')
513499

514500
# Context navigation (prev/next)
515501
# Note: Re-scanning every request is inefficient for large libraries,
@@ -523,14 +509,20 @@ def detail_view(name):
523509
next_item = video_names[index+1] if index < len(video_names)-1 else None
524510
except ValueError:
525511
prev_item = next_item = None
512+
prev_item_path_encoded = quote(prev_item, safe='/') if prev_item else None
513+
next_item_path_encoded = quote(next_item, safe='/') if next_item else None
526514

527515
return render_template(
528516
'detail.html',
529517
file=name,
518+
file_path_encoded=file_path_encoded,
519+
file_query_encoded=file_query_encoded,
530520
mtime=datetime.datetime.fromtimestamp(target.stat().st_mtime).strftime('%Y-%m-%d %H:%M'),
531521
size=target.stat().st_size,
532522
previous_item=prev_item,
533523
next_item=next_item,
524+
previous_item_path_encoded=prev_item_path_encoded,
525+
next_item_path_encoded=next_item_path_encoded,
534526
source_meta=source_meta,
535527
)
536528

@@ -542,7 +534,15 @@ def image_view():
542534
target = library_service.resolve_path(uri)
543535
if not target or not target.exists(): return "File not found", 404
544536
source_meta = download_manager.resolve_source_for_file(uri)
545-
return render_template('image_detail.html', image=target, uri=uri, stat=target.stat(), source_meta=source_meta)
537+
uri_query_encoded = quote(uri, safe='')
538+
return render_template(
539+
'image_detail.html',
540+
image=target,
541+
uri=uri,
542+
uri_query_encoded=uri_query_encoded,
543+
stat=target.stat(),
544+
source_meta=source_meta,
545+
)
546546

547547
@app.route("/delete/<path:name>", methods=['POST', 'GET'])
548548
def delete_view(name):
@@ -608,7 +608,7 @@ def serve_media_legacy():
608608
# Legacy support for /media?uri=...
609609
uri = request.args.get('uri')
610610
if not uri: return "Missing uri", 400
611-
return redirect(f"/media/{uri}")
611+
return redirect(f"/media/{quote(uri, safe='/')}")
612612

613613
@app.route('/thumb')
614614
def thumb_view():
@@ -622,36 +622,6 @@ def thumb_view():
622622

623623

624624
# --- API Routes ---
625-
@app.route('/api/videos')
626-
def api_videos():
627-
# Clean JSON API
628-
selected = recommend_service.get_weighted_selection(file_type='video', limit=20)
629-
return json.dumps(selected)
630-
631-
@app.route('/api/random-images')
632-
def api_random_images():
633-
page = _read_int_arg('page', 1, minimum=1)
634-
size = _read_int_arg('size', 30, minimum=1, maximum=100)
635-
seed = request.args.get('seed') or str(random.randint(1, 999999))
636-
637-
# Get recommended images (all of them, weighted)
638-
# Note: RecommendService currently returns a list. For true scale, we'd paginate inside Service.
639-
# For now, consistent with previous behavior, we get all and slice.
640-
all_images = recommend_service.get_weighted_selection(file_type='image', limit=99999, seed=seed)
641-
642-
total = len(all_images)
643-
start = (page - 1) * size
644-
end = start + size
645-
page_images = all_images[start:end]
646-
647-
return {
648-
'images': page_images,
649-
'page': page,
650-
'total': total,
651-
'has_more': end < total,
652-
'seed': seed
653-
}
654-
655625
@app.route('/api/feed/mix')
656626
def api_feed_mix():
657627
page = _read_int_arg('page', 1, minimum=1)
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
(function (global) {
2+
'use strict';
3+
4+
function encodeName(name) {
5+
return encodeURIComponent(String(name || ''));
6+
}
7+
8+
async function readJsonResponse(response) {
9+
try {
10+
return await response.json();
11+
} catch (error) {
12+
return null;
13+
}
14+
}
15+
16+
async function getFavoriteState(name) {
17+
var encoded = encodeName(name);
18+
if (!encoded) return false;
19+
var response = await fetch('/api/favorite/' + encoded);
20+
var data = await readJsonResponse(response);
21+
return !!(data && data.favorite);
22+
}
23+
24+
async function toggleFavorite(name) {
25+
var encoded = encodeName(name);
26+
if (!encoded) return false;
27+
var response = await fetch('/api/favorite/' + encoded, { method: 'POST' });
28+
var data = await readJsonResponse(response);
29+
return !!(data && data.favorite);
30+
}
31+
32+
async function getSourceMeta(name) {
33+
var encoded = encodeName(name);
34+
if (!encoded) return null;
35+
var response = await fetch('/api/source?file=' + encoded);
36+
var data = await readJsonResponse(response);
37+
if (!response.ok || !(data && data.success)) return null;
38+
return (data.data && data.data.source) || null;
39+
}
40+
41+
async function getImageMetadata(uri) {
42+
var encoded = encodeName(uri);
43+
if (!encoded) return { success: false, error: 'missing uri' };
44+
var response = await fetch('/api/image/metadata?uri=' + encoded);
45+
var data = await readJsonResponse(response);
46+
if (data && typeof data === 'object') return data;
47+
return { success: false, error: 'invalid response' };
48+
}
49+
50+
async function generateImageMetadata(uri) {
51+
if (!uri) return { success: false, error: 'missing uri' };
52+
var response = await fetch('/api/image/metadata', {
53+
method: 'POST',
54+
headers: { 'Content-Type': 'application/json' },
55+
body: JSON.stringify({ uri: uri, force: true }),
56+
});
57+
var data = await readJsonResponse(response);
58+
if (data && typeof data === 'object') return data;
59+
return { success: false, error: 'invalid response' };
60+
}
61+
62+
global.FlowActionsShared = {
63+
getFavoriteState: getFavoriteState,
64+
toggleFavorite: toggleFavorite,
65+
getSourceMeta: getSourceMeta,
66+
getImageMetadata: getImageMetadata,
67+
generateImageMetadata: generateImageMetadata,
68+
};
69+
})(window);

0 commit comments

Comments
 (0)