Skip to content

Commit 81289d4

Browse files
committed
Release v0.4.1: 偏好加权随机展示
1 parent 89dc312 commit 81289d4

3 files changed

Lines changed: 125 additions & 17 deletions

File tree

AGENTS.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Repository Guidelines
2+
3+
## Project Structure & Module Organization
4+
- `tiklocal/` contains the Flask app; `app.py` wires routes, `run.py` provides the CLI entrypoint, and helper modules live alongside routes.
5+
- `tiklocal/templates/` holds Jinja templates (`*.html`); match filenames to view names and use lower-case with hyphens when needed.
6+
- `tiklocal/static/` stores Tailwind sources (`input.css`), compiled `output.css`, and vendor JS.
7+
- `instance/` is created at runtime for per-machine overrides; keep secrets out of version control.
8+
9+
## Build, Test, and Development Commands
10+
- `poetry install` sets up the Python environment with Flask, Waitress, and PyYAML.
11+
- `poetry run tiklocal ~/MediaLibrary` launches the server against a local media directory; add `--port 9000` to test alternative ports.
12+
- `npm run build-css` watches Tailwind sources and regenerates `static/output.css` while you iterate on UI changes.
13+
- `npm run build-css-prod` minifies Tailwind output for release builds; run before publishing or creating screenshots.
14+
15+
## Coding Style & Naming Conventions
16+
- Follow PEP 8: four-space indentation, snake_case for functions, and PascalCase only for classes or dataclasses.
17+
- Keep Flask views lightweight; move shared helpers into module-level functions or new files in `tiklocal/` when they grow.
18+
- Use explicit imports and type hints for new utilities; mirror existing docstring style for user-facing helpers.
19+
- For templates, stick to descriptive block names (`content`, `sidebar`) and keep inline scripts minimal.
20+
21+
## Testing Guidelines
22+
- No automated suite ships today; add `tests/` with `pytest` fixtures that exercise key routes via Flask’s test client.
23+
- Run prospective suites with `poetry run pytest`; prefer temporary media directories created under `tmp_path`.
24+
- Perform manual smoke tests by running `poetry run tiklocal <media_path>` and browsing `/`, `/gallery`, and `/browse`.
25+
26+
## Commit & Pull Request Guidelines
27+
- Match the existing history: short, imperative messages (`fix: 调整视频播放布局`, `Release v0.4.0 …`); include scope prefixes when they clarify intent.
28+
- Each PR should explain the problem, list functional changes, and link issues; attach screenshots or clips for UI updates.
29+
- Confirm local testing (`tiklocal` run, Tailwind build, pytest when available) in the PR description and call out config files touched.
30+
31+
## Configuration Tips
32+
- Accept `MEDIA_ROOT`, `TIKLOCAL_HOST`, and `TIKLOCAL_PORT` via env vars or `~/.config/tiklocal/config.yaml`; document defaults in PRs when they change.
33+
- Keep large media samples out of the repo—reference relative paths (e.g., `~/Videos/demo`) in examples instead.

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.0"
3+
version = "0.4.1"
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/app.py

Lines changed: 91 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import subprocess as sp
1010
from urllib.parse import quote, unquote
1111
from importlib.metadata import version, PackageNotFoundError
12+
import math
1213

1314
from pathlib import Path
1415
from flask import Flask, render_template, send_from_directory, request, session, redirect
@@ -19,6 +20,79 @@
1920
except PackageNotFoundError:
2021
app_version = '1.0.0'
2122

23+
FAVORITE_FILENAME = 'favorite.json'
24+
25+
26+
def load_favorites(media_root: Path) -> set[str]:
27+
"""Read favorite entries stored alongside the media library."""
28+
favorites_path = media_root / FAVORITE_FILENAME
29+
if not favorites_path.exists():
30+
return set()
31+
32+
try:
33+
with favorites_path.open('r', encoding='utf-8') as f:
34+
data = json.load(f)
35+
if isinstance(data, list):
36+
return {str(item) for item in data}
37+
except Exception as exc:
38+
print(f"读取收藏列表失败: {exc}", file=sys.stderr)
39+
return set()
40+
41+
42+
def build_weighted_entries(files: list[Path], favorites: set[str], root: Path) -> list[tuple[str, float]]:
43+
"""Assign a weight to each file based on recency and favorite status."""
44+
now = datetime.datetime.now()
45+
weighted: list[tuple[str, float]] = []
46+
for file_path in files:
47+
if not file_path.exists() or not file_path.is_file():
48+
continue
49+
50+
try:
51+
mtime = file_path.stat().st_mtime
52+
except (FileNotFoundError, PermissionError):
53+
continue
54+
55+
rel_path = str(file_path.relative_to(root))
56+
favorite_boost = 3.0 if rel_path in favorites else 1.0
57+
age_days = max((now - datetime.datetime.fromtimestamp(mtime)).total_seconds() / 86400, 0.0)
58+
time_weight = math.exp(-age_days / 90.0)
59+
weight = favorite_boost * (0.1 + time_weight)
60+
weighted.append((rel_path, weight))
61+
return weighted
62+
63+
64+
def weighted_select(entries: list[tuple[str, float]], limit: int | None = None, rng: random.Random | None = None) -> list[str]:
65+
"""Pick items without replacement using the provided weights."""
66+
if not entries:
67+
return []
68+
69+
rng = rng or random
70+
pool = entries[:]
71+
target = len(pool) if limit is None else min(limit, len(pool))
72+
result: list[str] = []
73+
74+
while pool and len(result) < target:
75+
total_weight = sum(weight for _, weight in pool)
76+
if total_weight <= 0:
77+
rng.shuffle(pool)
78+
result.extend([path for path, _ in pool][: target - len(result)])
79+
break
80+
81+
pick = rng.random() * total_weight
82+
cumulative = 0.0
83+
for index, (path, weight) in enumerate(pool):
84+
cumulative += weight
85+
if cumulative >= pick:
86+
result.append(path)
87+
pool.pop(index)
88+
break
89+
90+
if limit is None and pool:
91+
rng.shuffle(pool)
92+
result.extend([path for path, _ in pool])
93+
94+
return result
95+
2296

2397
def create_app(test_config=None):
2498
app = Flask(__name__, instance_relative_config=True)
@@ -172,13 +246,10 @@ def api_videos():
172246
""" API to get random videos """
173247
root = Path(app.config["MEDIA_ROOT"])
174248
videos = list(root.glob('**/*.mp4')) + list(root.glob('**/*.webm'))
175-
random.shuffle(videos)
176-
res = []
177-
178-
for row in videos[:20]:
179-
res.append(str(row.relative_to(root)))
180-
181-
return json.dumps(res)
249+
favorites = load_favorites(root)
250+
weighted = build_weighted_entries(videos, favorites, root)
251+
selected = weighted_select(weighted, limit=20)
252+
return json.dumps(selected)
182253

183254
@app.route('/api/random-images')
184255
def api_random_images():
@@ -193,18 +264,22 @@ def api_random_images():
193264
images.extend(root.glob(f'**/{ext}'))
194265
images.extend(root.glob(f'**/{ext.upper()}'))
195266

196-
# 随机打乱(使用固定种子确保同一会话中的一致性)
197-
seed = request.args.get('seed', str(random.randint(1, 999999)))
198-
random.seed(seed)
199-
random.shuffle(images)
267+
seed = request.args.get('seed')
268+
if seed is None:
269+
seed = str(random.randint(1, 999999))
270+
271+
favorites = load_favorites(root)
272+
weighted = build_weighted_entries(images, favorites, root)
273+
rng = random.Random(seed)
274+
ordered = weighted_select(weighted, limit=len(weighted), rng=rng)
200275

201276
# 分页
202-
total = len(images)
277+
total = len(ordered)
203278
start = (page - 1) * page_size
204279
end = start + page_size
205-
page_images = images[start:end]
280+
page_images = ordered[start:end]
206281

207-
res = [str(img.relative_to(root)) for img in page_images]
282+
res = page_images
208283

209284
return {
210285
'images': res,
@@ -302,7 +377,7 @@ def video_view(name):
302377

303378
@app.route('/favorite')
304379
def favorite_view():
305-
db = Path(app.config["MEDIA_ROOT"]) / 'favorite.json'
380+
db = Path(app.config["MEDIA_ROOT"]) / FAVORITE_FILENAME
306381
text = []
307382
if db.exists():
308383
with db.open() as f:
@@ -317,7 +392,7 @@ def favorite_view():
317392
@app.route('/api/favorite/<name>', methods=['GET', 'POST'])
318393
def favorite_api(name):
319394
try:
320-
db = Path(app.config["MEDIA_ROOT"]) / 'favorite.json'
395+
db = Path(app.config["MEDIA_ROOT"]) / FAVORITE_FILENAME
321396
text = []
322397
if db.exists():
323398
with db.open() as f:

0 commit comments

Comments
 (0)