first commit
This commit is contained in:
commit
865786f210
16 files changed
+743
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
.venv/
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-*
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# vv_ext — красивая оболочка для vv.micialware.ru
|
||||||
|
# Стек: Flask + SQLite + чистый JS + Tailwind CDN. Без сборки, без авторизации.
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
# app.py — бэкенд: 5 ручек API + SQLite
|
||||||
|
# scanner.py — сканирует папку с видео, добавляет новые файлы в БД
|
||||||
|
# frontend/index.html, app.js, style.css — лента + сетка
|
||||||
|
# deploy/vv.conf — nginx, deploy/vv.service — systemd
|
||||||
|
|
||||||
|
## Быстрый старт (локально)
|
||||||
|
# python -m venv .venv && source .venv/bin/activate
|
||||||
|
# pip install -e .
|
||||||
|
# VIDEO_DIR=/path/to/videos python scanner.py # наполнить БД
|
||||||
|
# VIDEO_DIR=/path/to/videos python app.py # http://127.0.0.1:8000
|
||||||
|
|
||||||
|
## API
|
||||||
|
# GET /api/health
|
||||||
|
# GET /api/tags -> [{name, count}]
|
||||||
|
# GET /api/videos?tag=&q=&sort=new|likes&page=1&per_page=30 -> {items, page, pages, total}
|
||||||
|
# POST /api/videos/<file>/like -> {likes} (1 лайк с IP в сутки)
|
||||||
|
# POST /api/videos/<file>/tags {"tag": "..."} -> {tags}
|
||||||
|
# POST /api/scan -> пересканировать VIDEO_DIR
|
||||||
|
|
||||||
|
## Прод (на VPS где уже лежит nginx-раздача)
|
||||||
|
# 1. залить frontend/ в /var/www/vv_ext/
|
||||||
|
# 2. app.py + db.sqlite в /opt/vv_ext/, VIDEO_DIR=/var/www/vv/ (где .mp4/.webm)
|
||||||
|
# 3. deploy/vv.conf -> /etc/nginx/conf.d/, deploy/vv.service -> /etc/systemd/system/
|
||||||
|
# 4. systemctl enable --now vv && systemctl reload nginx
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""Бэкенд vv_ext: Flask + SQLite. Всего 6 ручек, без авторизации."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import date
|
||||||
|
from urllib.parse import quote, unquote
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, request, send_from_directory
|
||||||
|
|
||||||
|
from db import connect, init_db
|
||||||
|
|
||||||
|
VIDEO_DIR = os.environ.get("VIDEO_DIR", os.path.join(os.path.dirname(__file__), "videos"))
|
||||||
|
VIDEO_URL_PREFIX = os.environ.get("VIDEO_URL_PREFIX", "/videos/")
|
||||||
|
FRONTEND_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend")
|
||||||
|
ALLOWED_EXT = {".mp4", ".webm", ".mov", ".m4v"}
|
||||||
|
TAG_RE = re.compile(r"[0-9a-zа-яё_\-]{1,32}")
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_tag(raw: str) -> str:
|
||||||
|
tag = raw.strip().lower().replace(" ", "_").replace("#", "")
|
||||||
|
return tag if TAG_RE.fullmatch(tag) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def video_url(filename: str) -> str:
|
||||||
|
return VIDEO_URL_PREFIX + quote(filename)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health():
|
||||||
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/tags")
|
||||||
|
def list_tags():
|
||||||
|
with connect() as con:
|
||||||
|
rows = con.execute(
|
||||||
|
"SELECT t.name, COUNT(vt.filename) AS count FROM tags t "
|
||||||
|
"JOIN video_tags vt ON vt.tag_id = t.id "
|
||||||
|
"GROUP BY t.id ORDER BY count DESC, t.name LIMIT 200",
|
||||||
|
).fetchall()
|
||||||
|
return jsonify([{"name": r["name"], "count": r["count"]} for r in rows])
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/videos")
|
||||||
|
def list_videos():
|
||||||
|
tag = normalize_tag(request.args.get("tag", ""))
|
||||||
|
q = request.args.get("q", "").strip()
|
||||||
|
sort = request.args.get("sort", "new")
|
||||||
|
page = max(1, int(request.args.get("page", 1)))
|
||||||
|
per_page = min(60, max(1, int(request.args.get("per_page", 20))))
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
|
where: list[str] = []
|
||||||
|
params: list = []
|
||||||
|
if tag:
|
||||||
|
where.append(
|
||||||
|
"v.filename IN (SELECT vt.filename FROM video_tags vt "
|
||||||
|
"JOIN tags t ON t.id = vt.tag_id WHERE t.name = ?)"
|
||||||
|
)
|
||||||
|
params.append(tag)
|
||||||
|
if q:
|
||||||
|
where.append("v.filename LIKE ?")
|
||||||
|
params.append(f"%{q}%")
|
||||||
|
where_sql = f"WHERE {' AND '.join(where)}" if where else ""
|
||||||
|
order_sql = "v.likes DESC, v.filename" if sort == "likes" else "v.filename DESC"
|
||||||
|
|
||||||
|
with connect() as con:
|
||||||
|
total = con.execute(f"SELECT COUNT(*) FROM videos v {where_sql}", params).fetchone()[0]
|
||||||
|
rows = con.execute(
|
||||||
|
f"SELECT v.filename, v.likes FROM videos v {where_sql} "
|
||||||
|
f"ORDER BY {order_sql} LIMIT ? OFFSET ?",
|
||||||
|
(*params, per_page, offset),
|
||||||
|
).fetchall()
|
||||||
|
fnames = [r["filename"] for r in rows]
|
||||||
|
tags_map: dict[str, list[str]] = {f: [] for f in fnames}
|
||||||
|
if fnames:
|
||||||
|
ph = ",".join("?" * len(fnames))
|
||||||
|
for r in con.execute(
|
||||||
|
"SELECT vt.filename, t.name FROM video_tags vt "
|
||||||
|
f"JOIN tags t ON t.id = vt.tag_id WHERE vt.filename IN ({ph})",
|
||||||
|
fnames,
|
||||||
|
):
|
||||||
|
tags_map[r["filename"]].append(r["name"])
|
||||||
|
|
||||||
|
items = [
|
||||||
|
{"file": r["filename"], "url": video_url(r["filename"]),
|
||||||
|
"likes": r["likes"], "tags": sorted(tags_map[r["filename"]])}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
pages = (total + per_page - 1) // per_page
|
||||||
|
return jsonify({"items": items, "page": page, "pages": pages, "total": total})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/videos/<path:raw_file>/like")
|
||||||
|
def like_video(raw_file: str):
|
||||||
|
filename = unquote(raw_file)
|
||||||
|
fwd = request.headers.get("X-Forwarded-For", request.remote_addr or "?")
|
||||||
|
ip = fwd.split(",")[0].strip()
|
||||||
|
today = date.today().isoformat()
|
||||||
|
with connect() as con:
|
||||||
|
row = con.execute("SELECT likes FROM videos WHERE filename = ?", (filename,)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return jsonify(error="not found"), 404
|
||||||
|
cur = con.execute(
|
||||||
|
"INSERT OR IGNORE INTO likes_log(ip, filename, day) VALUES (?, ?, ?)",
|
||||||
|
(ip, filename, today),
|
||||||
|
)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
return jsonify(likes=row["likes"], already=True)
|
||||||
|
con.execute("UPDATE videos SET likes = likes + 1 WHERE filename = ?", (filename,))
|
||||||
|
likes = row["likes"] + 1
|
||||||
|
return jsonify(likes=likes, already=False)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/videos/<path:raw_file>/tags")
|
||||||
|
def add_tag(raw_file: str):
|
||||||
|
filename = unquote(raw_file)
|
||||||
|
tag = normalize_tag((request.get_json(silent=True) or {}).get("tag", ""))
|
||||||
|
if not tag:
|
||||||
|
return jsonify(error="bad tag (a-z, 0-9, _, -, до 32 символов)"), 400
|
||||||
|
with connect() as con:
|
||||||
|
exists = con.execute("SELECT 1 FROM videos WHERE filename = ?", (filename,)).fetchone()
|
||||||
|
if exists is None:
|
||||||
|
return jsonify(error="not found"), 404
|
||||||
|
con.execute("INSERT OR IGNORE INTO tags(name) VALUES (?)", (tag,))
|
||||||
|
tag_id = con.execute("SELECT id FROM tags WHERE name = ?", (tag,)).fetchone()["id"]
|
||||||
|
con.execute(
|
||||||
|
"INSERT OR IGNORE INTO video_tags(filename, tag_id) VALUES (?, ?)",
|
||||||
|
(filename, tag_id),
|
||||||
|
)
|
||||||
|
tags = [
|
||||||
|
r["name"]
|
||||||
|
for r in con.execute(
|
||||||
|
"SELECT t.name FROM video_tags vt JOIN tags t ON t.id = vt.tag_id "
|
||||||
|
"WHERE vt.filename = ? ORDER BY t.name",
|
||||||
|
(filename,),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return jsonify(tags=tags), 201
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/scan")
|
||||||
|
def rescan():
|
||||||
|
from scanner import scan_directory
|
||||||
|
|
||||||
|
added, total = scan_directory(VIDEO_DIR)
|
||||||
|
return jsonify(added=added, total=total)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def index():
|
||||||
|
return send_from_directory(FRONTEND_DIR, "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/<path:path>")
|
||||||
|
def frontend_files(path: str):
|
||||||
|
if path.startswith("api/"):
|
||||||
|
return jsonify(error="not found"), 404
|
||||||
|
full = os.path.join(FRONTEND_DIR, path)
|
||||||
|
if os.path.isfile(full):
|
||||||
|
return send_from_directory(FRONTEND_DIR, path)
|
||||||
|
if os.path.splitext(path)[1].lower() in ALLOWED_EXT:
|
||||||
|
return send_from_directory(VIDEO_DIR, unquote(path))
|
||||||
|
return send_from_directory(FRONTEND_DIR, "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
init_db()
|
||||||
|
if os.environ.get("FLASK_DEBUG"):
|
||||||
|
app.run(host="127.0.0.1", port=8000, debug=True)
|
||||||
|
else:
|
||||||
|
from waitress import serve
|
||||||
|
|
||||||
|
print("Serving on http://127.0.0.1:8000")
|
||||||
|
serve(app, host="127.0.0.1", port=8000)
|
||||||
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""SQLite-слой: 3 таблицы, WAL-режим, один файл db.sqlite."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
PRAGMA journal_mode=WAL;
|
||||||
|
CREATE TABLE IF NOT EXISTS videos (
|
||||||
|
filename TEXT PRIMARY KEY,
|
||||||
|
likes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS video_tags (
|
||||||
|
filename TEXT NOT NULL REFERENCES videos(filename) ON DELETE CASCADE,
|
||||||
|
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE (filename, tag_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS likes_log (
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
day TEXT NOT NULL,
|
||||||
|
UNIQUE (ip, filename, day)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_video_tags_tag ON video_tags(tag_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_videos_likes ON videos(likes DESC);
|
||||||
|
"""
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get("DB_PATH", os.path.join(os.path.dirname(__file__), "db.sqlite"))
|
||||||
|
|
||||||
|
|
||||||
|
def connect() -> sqlite3.Connection:
|
||||||
|
con = sqlite3.connect(DB_PATH)
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
con.execute("PRAGMA journal_mode=WAL")
|
||||||
|
con.execute("PRAGMA foreign_keys=ON")
|
||||||
|
return con
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
with connect() as con:
|
||||||
|
con.executescript(SCHEMA)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# vv.micialware.ru — красивая обертка вместо autoindex
|
||||||
|
# Положить в /etc/nginx/conf.d/vv.conf (замени пути под себя)
|
||||||
|
upstream vv_ext { server 127.0.0.1:8000; }
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name vv.micialware.ru;
|
||||||
|
|
||||||
|
# фронт (index.html, app.js, style.css)
|
||||||
|
location / {
|
||||||
|
root /var/www/vv_ext;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# видео раздает nginx напрямую, Python не трогаем
|
||||||
|
location /videos/ {
|
||||||
|
alias /var/www/vv/;
|
||||||
|
autoindex off;
|
||||||
|
expires 30d;
|
||||||
|
add_header Accept-Ranges bytes;
|
||||||
|
# докачка/перемотка работают из коробки через Range
|
||||||
|
}
|
||||||
|
|
||||||
|
# API -> Flask
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://vv_ext;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=vv_ext Flask backend
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=www-data
|
||||||
|
WorkingDirectory=/opt/vv_ext
|
||||||
|
Environment="VIDEO_DIR=/var/www/vv"
|
||||||
|
Environment="DB_PATH=/opt/vv_ext/db.sqlite"
|
||||||
|
ExecStart=/opt/vv_ext/.venv/bin/python /opt/vv_ext/app.py
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
/* VV frontend: лента (scroll-snap) + сетка, лайки, теги. Без фреймворков. */
|
||||||
|
const $ = (s) => document.querySelector(s);
|
||||||
|
const feed = $("#feed"), grid = $("#grid"), chips = $("#chips");
|
||||||
|
const state = { tag: "", q: "", sort: "new", page: 1, pages: 1, items: [], tagFor: null,
|
||||||
|
loading: false, current: null };
|
||||||
|
|
||||||
|
const liked = new Set(JSON.parse(localStorage.getItem("vv_liked") || "[]"));
|
||||||
|
const saveLiked = () => localStorage.setItem("vv_liked", JSON.stringify([...liked].slice(-500)));
|
||||||
|
|
||||||
|
async function api(path, opts) {
|
||||||
|
const r = await fetch(path, opts);
|
||||||
|
if (!r.ok) throw new Error(await r.text());
|
||||||
|
return r.status === 204 ? null : r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- теги-чипсы --- */
|
||||||
|
async function loadTags() {
|
||||||
|
const tags = await api("/api/tags").catch(() => []);
|
||||||
|
chips.innerHTML = "";
|
||||||
|
const all = document.createElement("button");
|
||||||
|
all.textContent = "Все";
|
||||||
|
all.className = state.tag ? "" : "on";
|
||||||
|
all.onclick = () => { state.tag = ""; state.page = 1; loadTags(); reload(); };
|
||||||
|
chips.append(all);
|
||||||
|
for (const t of tags.slice(0, 40)) {
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.textContent = `#${t.name} ${t.count}`;
|
||||||
|
b.className = state.tag === t.name ? "on" : "";
|
||||||
|
b.onclick = () => { state.tag = t.name; state.page = 1; loadTags(); reload(); };
|
||||||
|
chips.append(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- загрузка списка --- */
|
||||||
|
async function fetchVideos(append) {
|
||||||
|
const p = new URLSearchParams({
|
||||||
|
tag: state.tag, q: state.q, sort: state.sort,
|
||||||
|
page: state.page, per_page: 20,
|
||||||
|
});
|
||||||
|
const data = await api("/api/videos?" + p);
|
||||||
|
state.pages = data.pages;
|
||||||
|
state.items = append ? state.items.concat(data.items) : data.items;
|
||||||
|
return data.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slideEl(v) {
|
||||||
|
const s = document.createElement("section");
|
||||||
|
s.className = "slide";
|
||||||
|
s.dataset.file = v.file;
|
||||||
|
const tags = v.tags.map((t) => `<span class="tag" data-tag="${t}">#${t}</span>`).join(" ");
|
||||||
|
s.innerHTML = `
|
||||||
|
<video src="${v.url}" loop muted playsinline preload="metadata"></video>
|
||||||
|
<div class="hint">❤️</div>
|
||||||
|
<div class="meta"><div class="fname">${escapeHtml(v.file)}</div><div>${tags}</div></div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="like ${liked.has(v.file) ? "liked" : ""}">❤️<small>${v.likes}</small></button>
|
||||||
|
<button class="tagbtn">🏷️</button>
|
||||||
|
<a href="${v.url}" download><button type="button">⬇️</button></a>
|
||||||
|
</div>`;
|
||||||
|
const video = s.querySelector("video");
|
||||||
|
s.querySelector(".like").onclick = () => likeVideo(v.file, s);
|
||||||
|
s.querySelector(".tagbtn").onclick = () => openTagForm(v.file);
|
||||||
|
s.querySelectorAll(".tag").forEach((el) => {
|
||||||
|
el.onclick = () => { state.tag = el.dataset.tag; state.page = 1; loadTags(); reload(); };
|
||||||
|
});
|
||||||
|
// тап по центру = play/pause, даблтап = лайк
|
||||||
|
let lastTap = 0;
|
||||||
|
video.onclick = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastTap < 300) likeVideo(v.file, s);
|
||||||
|
else if (video.paused) video.play().catch(() => {});
|
||||||
|
else video.pause();
|
||||||
|
lastTap = now;
|
||||||
|
};
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return s.replace(/[&<>"']/g, (c) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
feed.innerHTML = ""; grid.innerHTML = "";
|
||||||
|
state.page = 1; state.current = null;
|
||||||
|
state.loading = true;
|
||||||
|
const items = await fetchVideos(false).catch(() => []);
|
||||||
|
state.loading = false;
|
||||||
|
if (!items.length) {
|
||||||
|
feed.innerHTML = `<div class="empty">Ничего не найдено 😢</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderFeed(items);
|
||||||
|
renderGrid(items);
|
||||||
|
observeSlides();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (state.loading || state.page >= state.pages) return;
|
||||||
|
state.loading = true;
|
||||||
|
state.page++;
|
||||||
|
const before = state.items.length;
|
||||||
|
const items = await fetchVideos(true).catch(() => []);
|
||||||
|
state.loading = false;
|
||||||
|
const fresh = state.items.slice(before);
|
||||||
|
if (!fresh.length) return;
|
||||||
|
// убрать старую кнопку "Ещё" перед дорисовкой
|
||||||
|
feed.querySelector(".more")?.remove();
|
||||||
|
renderFeed(fresh, true);
|
||||||
|
renderGrid(fresh, true);
|
||||||
|
observeSlides();
|
||||||
|
void items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFeed(items, append) {
|
||||||
|
if (!append) feed.innerHTML = "";
|
||||||
|
for (const v of items) feed.append(slideEl(v));
|
||||||
|
if (state.page < state.pages) {
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.className = "more"; b.textContent = "Ещё ⬇";
|
||||||
|
b.onclick = () => { b.remove(); loadMore(); };
|
||||||
|
feed.append(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid(items, append) {
|
||||||
|
if (!append) grid.innerHTML = "";
|
||||||
|
for (const v of items) {
|
||||||
|
const c = document.createElement("div");
|
||||||
|
c.className = "card";
|
||||||
|
c.innerHTML = `
|
||||||
|
<video src="${v.url}#t=0.5" preload="metadata" muted playsinline></video>
|
||||||
|
<div class="row"><span>❤️ ${v.likes}</span><span>${escapeHtml(shortName(v.file))}</span></div>
|
||||||
|
<a class="open" href="#"></a>`;
|
||||||
|
c.querySelector(".open").onclick = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setMode("feed");
|
||||||
|
const el = feed.querySelector(`[data-file="${CSS.escape(v.file)}"]`);
|
||||||
|
if (el) el.scrollIntoView();
|
||||||
|
};
|
||||||
|
grid.append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortName(f) {
|
||||||
|
return f.length > 18 ? f.slice(0, 17) + "…" : f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* автоплей видимого слайда */
|
||||||
|
let observer;
|
||||||
|
function observeSlides() {
|
||||||
|
observer && observer.disconnect();
|
||||||
|
observer = new IntersectionObserver((entries) => {
|
||||||
|
for (const e of entries) {
|
||||||
|
const video = e.target.querySelector("video");
|
||||||
|
if (!video) continue;
|
||||||
|
if (e.intersectionRatio > 0.6) {
|
||||||
|
state.current = e.target;
|
||||||
|
video.muted = false;
|
||||||
|
video.play().catch(() => { video.muted = true; video.play().catch(() => {}); });
|
||||||
|
// догружаем следующую страницу заранее
|
||||||
|
const slides = [...feed.querySelectorAll(".slide")];
|
||||||
|
if (slides.indexOf(e.target) > slides.length - 4) loadMore();
|
||||||
|
} else {
|
||||||
|
video.pause();
|
||||||
|
video.muted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { root: feed, threshold: [0.6] });
|
||||||
|
feed.querySelectorAll(".slide").forEach((s) => observer.observe(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- лайки --- */
|
||||||
|
async function likeVideo(file, slide) {
|
||||||
|
const btn = slide.querySelector(".like");
|
||||||
|
const count = btn.querySelector("small");
|
||||||
|
const data = await api("/api/videos/" + encodeURIComponent(file) + "/like", { method: "POST" })
|
||||||
|
.catch(() => null);
|
||||||
|
if (!data) return;
|
||||||
|
count.textContent = data.likes;
|
||||||
|
liked.add(file); saveLiked();
|
||||||
|
btn.classList.add("liked");
|
||||||
|
if (!data.already) {
|
||||||
|
const hint = slide.querySelector(".hint");
|
||||||
|
hint.style.opacity = 1;
|
||||||
|
setTimeout(() => (hint.style.opacity = 0), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- теги --- */
|
||||||
|
function openTagForm(file) {
|
||||||
|
state.tagFor = file;
|
||||||
|
$("#tagform").classList.add("show");
|
||||||
|
$("#taginput").value = "";
|
||||||
|
$("#taginput").focus();
|
||||||
|
}
|
||||||
|
$("#tagform").onsubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const tag = $("#taginput").value.trim();
|
||||||
|
if (!tag || !state.tagFor) return;
|
||||||
|
const data = await api("/api/videos/" + encodeURIComponent(state.tagFor) + "/tags", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ tag }),
|
||||||
|
}).catch((err) => { alert("Не вышло: " + err.message); return null; });
|
||||||
|
if (!data) return;
|
||||||
|
$("#tagform").classList.remove("show");
|
||||||
|
loadTags();
|
||||||
|
reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
/* --- режимы, поиск, клавиатура --- */
|
||||||
|
function setMode(m) {
|
||||||
|
document.body.classList.toggle("mode-grid", m === "grid");
|
||||||
|
$("#btn-feed").classList.toggle("on", m === "feed");
|
||||||
|
$("#btn-grid").classList.toggle("on", m === "grid");
|
||||||
|
}
|
||||||
|
$("#btn-feed").onclick = () => setMode("feed");
|
||||||
|
$("#btn-grid").onclick = () => setMode("grid");
|
||||||
|
|
||||||
|
let qTimer;
|
||||||
|
$("#q").oninput = (e) => {
|
||||||
|
clearTimeout(qTimer);
|
||||||
|
qTimer = setTimeout(() => { state.q = e.target.value.trim(); reload(); }, 400);
|
||||||
|
};
|
||||||
|
$("#sort").onchange = (e) => { state.sort = e.target.value; reload(); };
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.target.tagName === "INPUT" || e.target.tagName === "SELECT") return;
|
||||||
|
const cur = state.current && document.contains(state.current) ? state.current : $(".slide");
|
||||||
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
const slides = [...feed.querySelectorAll(".slide")];
|
||||||
|
const i = slides.indexOf(cur);
|
||||||
|
const next = slides[i + (e.key === "ArrowDown" ? 1 : -1)];
|
||||||
|
if (next) next.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}
|
||||||
|
if ((e.key === "l" || e.key === "д" || e.key === "L") && cur) {
|
||||||
|
likeVideo(cur.dataset.file, cur);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loadTags();
|
||||||
|
reload();
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>VV — вертикальные видео</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="logo">VV<span>▶</span></div>
|
||||||
|
<input id="q" class="search" type="search" placeholder="Поиск…" autocomplete="off">
|
||||||
|
<div class="seg">
|
||||||
|
<button id="btn-feed" class="on">Лента</button>
|
||||||
|
<button id="btn-grid">Все</button>
|
||||||
|
</div>
|
||||||
|
<select id="sort" class="sort" title="Сортировка">
|
||||||
|
<option value="new">Новые</option>
|
||||||
|
<option value="likes">Топ</option>
|
||||||
|
</select>
|
||||||
|
</header>
|
||||||
|
<nav id="chips" class="chips"></nav>
|
||||||
|
<main id="feed" class="feed"></main>
|
||||||
|
<main id="grid" class="grid"></main>
|
||||||
|
<form id="tagform" class="tagform">
|
||||||
|
<input id="taginput" placeholder="тег: jojo, ф1, мем…" maxlength="32" autocomplete="off">
|
||||||
|
<button type="submit">+</button>
|
||||||
|
</form>
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
:root { color-scheme: dark; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
margin: 0; background: #000; color: #fff;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
position: fixed; inset: 0 0 auto 0; z-index: 20;
|
||||||
|
display: flex; gap: 8px; align-items: center; padding: 10px 12px;
|
||||||
|
background: linear-gradient(#000c, #0000);
|
||||||
|
}
|
||||||
|
.logo { font-weight: 800; font-size: 18px; letter-spacing: 1px; }
|
||||||
|
.logo span { color: #fe2c55; }
|
||||||
|
.search {
|
||||||
|
flex: 1; max-width: 320px; background: #ffffff22; border: 0; border-radius: 20px;
|
||||||
|
color: #fff; padding: 8px 14px; outline: none;
|
||||||
|
}
|
||||||
|
.seg { display: flex; background: #ffffff22; border-radius: 20px; overflow: hidden; }
|
||||||
|
.seg button { border: 0; background: transparent; color: #fff; padding: 8px 14px; cursor: pointer; }
|
||||||
|
.seg button.on { background: #fff; color: #000; font-weight: 700; }
|
||||||
|
.sort { background: #ffffff22; color: #fff; border: 0; border-radius: 20px; padding: 8px; }
|
||||||
|
.chips {
|
||||||
|
position: fixed; top: 52px; left: 0; right: 0; z-index: 20;
|
||||||
|
display: flex; gap: 6px; overflow-x: auto; padding: 6px 12px;
|
||||||
|
}
|
||||||
|
.chips:empty { display: none; }
|
||||||
|
.chips button {
|
||||||
|
flex: none; border: 1px solid #ffffff44; background: #000a;
|
||||||
|
color: #fff; border-radius: 16px; padding: 5px 12px; cursor: pointer; font-size: 13px;
|
||||||
|
}
|
||||||
|
.chips button.on { background: #fe2c55; border-color: #fe2c55; }
|
||||||
|
.feed {
|
||||||
|
height: 100dvh; overflow-y: scroll; scroll-snap-type: y mandatory;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.feed::-webkit-scrollbar { display: none; }
|
||||||
|
.slide {
|
||||||
|
position: relative; height: 100dvh; scroll-snap-align: start; scroll-snap-stop: always;
|
||||||
|
display: flex; align-items: center; justify-content: center; background: #111;
|
||||||
|
}
|
||||||
|
.slide video { height: 100%; max-width: 100%; object-fit: contain; background: #000; }
|
||||||
|
.slide .meta {
|
||||||
|
position: absolute; left: 12px; right: 76px; bottom: 18px; z-index: 5;
|
||||||
|
font-size: 14px; text-shadow: 0 1px 6px #000;
|
||||||
|
}
|
||||||
|
.slide .meta .fname { font-weight: 600; margin-bottom: 6px; word-break: break-word; }
|
||||||
|
.slide .meta .tag { color: #7dd3fc; margin-right: 8px; cursor: pointer; }
|
||||||
|
.actions {
|
||||||
|
position: absolute; right: 8px; bottom: 90px; z-index: 5;
|
||||||
|
display: flex; flex-direction: column; gap: 14px; align-items: center;
|
||||||
|
}
|
||||||
|
.actions button {
|
||||||
|
width: 52px; height: 52px; border-radius: 50%; border: 0; cursor: pointer;
|
||||||
|
background: #ffffff22; color: #fff; font-size: 22px; backdrop-filter: blur(4px);
|
||||||
|
display: flex; align-items: center; justify-content: center; flex-direction: column;
|
||||||
|
}
|
||||||
|
.actions button small { font-size: 11px; }
|
||||||
|
.actions button.liked { background: #fe2c55; }
|
||||||
|
.hint {
|
||||||
|
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||||
|
font-size: 64px; opacity: 0; pointer-events: none;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: none; height: 100dvh; overflow-y: auto; padding: 100px 12px 20px;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
body.mode-grid { overflow: auto; }
|
||||||
|
body.mode-grid .feed, body.mode-grid .chips { display: none; }
|
||||||
|
body.mode-grid .grid { display: grid; }
|
||||||
|
.card { position: relative; border-radius: 12px; overflow: hidden; background: #1a1a1a; }
|
||||||
|
.card video { width: 100%; aspect-ratio: 9/16; object-fit: cover; display: block; }
|
||||||
|
.card .row {
|
||||||
|
position: absolute; left: 0; right: 0; bottom: 0; padding: 6px 8px;
|
||||||
|
display: flex; justify-content: space-between; font-size: 12px;
|
||||||
|
background: linear-gradient(#0000, #000d);
|
||||||
|
}
|
||||||
|
.card .open { position: absolute; inset: 0; }
|
||||||
|
.tagform {
|
||||||
|
position: fixed; z-index: 30; left: 50%; bottom: 24px; transform: translateX(-50%);
|
||||||
|
display: none; gap: 6px; background: #1c1c1c; padding: 10px; border-radius: 14px;
|
||||||
|
border: 1px solid #ffffff33; width: min(92vw, 380px);
|
||||||
|
}
|
||||||
|
.tagform.show { display: flex; }
|
||||||
|
.tagform input {
|
||||||
|
flex: 1; background: #000; border: 1px solid #ffffff33; color: #fff;
|
||||||
|
border-radius: 10px; padding: 10px;
|
||||||
|
}
|
||||||
|
.tagform button { border-radius: 10px; border: 0; padding: 0 14px; cursor: pointer; }
|
||||||
|
.empty { text-align: center; padding: 40dvh 20px 0; color: #aaa; }
|
||||||
|
.more { display: block; margin: 12px auto; padding: 10px 26px; border-radius: 20px; border: 0; }
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "vv-ext"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = ["flask>=3.0", "waitress>=2.1"]
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
py-modules = ["app", "db", "scanner"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
"""Сканирует VIDEO_DIR, добавляет новые .mp4/.webm в БД. Удаляет из БД пропавшие файлы."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from db import connect, init_db
|
||||||
|
|
||||||
|
ALLOWED_EXT = {".mp4", ".webm", ".mov", ".m4v"}
|
||||||
|
|
||||||
|
|
||||||
|
def scan_directory(video_dir: str) -> tuple[int, int]:
|
||||||
|
init_db()
|
||||||
|
if not os.path.isdir(video_dir):
|
||||||
|
print(f"Нет папки: {video_dir}", file=sys.stderr)
|
||||||
|
return 0, 0
|
||||||
|
on_disk = {
|
||||||
|
f for f in os.listdir(video_dir)
|
||||||
|
if os.path.isfile(os.path.join(video_dir, f))
|
||||||
|
and os.path.splitext(f)[1].lower() in ALLOWED_EXT
|
||||||
|
}
|
||||||
|
with connect() as con:
|
||||||
|
in_db = {r["filename"] for r in con.execute("SELECT filename FROM videos")}
|
||||||
|
new = sorted(on_disk - in_db)
|
||||||
|
gone = sorted(in_db - on_disk)
|
||||||
|
con.executemany("INSERT OR IGNORE INTO videos(filename) VALUES (?)", [(f,) for f in new])
|
||||||
|
if gone:
|
||||||
|
ph = ",".join("?" * len(gone))
|
||||||
|
con.execute(f"DELETE FROM videos WHERE filename IN ({ph})", gone)
|
||||||
|
print(f"Видео на диске: {len(on_disk)}, добавлено: {len(new)}, удалено: {len(gone)}")
|
||||||
|
return len(new), len(on_disk)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
video_dir = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("VIDEO_DIR", "./videos")
|
||||||
|
scan_directory(video_dir)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
Metadata-Version: 2.4
|
||||||
|
Name: vv-ext
|
||||||
|
Version: 0.1.0
|
||||||
|
Requires-Python: >=3.10
|
||||||
|
Requires-Dist: flask>=3.0
|
||||||
|
Requires-Dist: waitress>=2.1
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
README.md
|
||||||
|
app.py
|
||||||
|
db.py
|
||||||
|
pyproject.toml
|
||||||
|
scanner.py
|
||||||
|
vv_ext.egg-info/PKG-INFO
|
||||||
|
vv_ext.egg-info/SOURCES.txt
|
||||||
|
vv_ext.egg-info/dependency_links.txt
|
||||||
|
vv_ext.egg-info/requires.txt
|
||||||
|
vv_ext.egg-info/top_level.txt
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
flask>=3.0
|
||||||
|
waitress>=2.1
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
app
|
||||||
|
db
|
||||||
|
scanner
|
||||||
Reference in new issue
Block a user