commit 865786f210c6a6ad20e75b6640751a2f31851150 Author: Mikhail Mitrofanov Date: Fri Oct 9 22:15:22 2026 +0300 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76e4f50 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +.venv/ +*.db +*.sqlite +*.sqlite-* diff --git a/README.md b/README.md new file mode 100644 index 0000000..0617155 --- /dev/null +++ b/README.md @@ -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//like -> {likes} (1 лайк с IP в сутки) +# POST /api/videos//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 diff --git a/app.py b/app.py new file mode 100644 index 0000000..0b28125 --- /dev/null +++ b/app.py @@ -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//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//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("/") +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) + diff --git a/db.py b/db.py new file mode 100644 index 0000000..d2b470f --- /dev/null +++ b/db.py @@ -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) diff --git a/deploy/vv.conf b/deploy/vv.conf new file mode 100644 index 0000000..98df48d --- /dev/null +++ b/deploy/vv.conf @@ -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; + } +} diff --git a/deploy/vv.service b/deploy/vv.service new file mode 100644 index 0000000..24deb29 --- /dev/null +++ b/deploy/vv.service @@ -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 diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..0c2b327 --- /dev/null +++ b/frontend/app.js @@ -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) => `#${t}`).join(" "); + s.innerHTML = ` + +
❤️
+
${escapeHtml(v.file)}
${tags}
+
+ + + +
`; + 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 = `
Ничего не найдено 😢
`; + 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 = ` + +
❤️ ${v.likes}${escapeHtml(shortName(v.file))}
+ `; + 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(); + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..3788d9b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,31 @@ + + + + + +VV — вертикальные видео + + + +
+ + +
+ + +
+ +
+ +
+
+
+ + +
+ + + diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..6274ba3 --- /dev/null +++ b/frontend/style.css @@ -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; } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cc688df --- /dev/null +++ b/pyproject.toml @@ -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 diff --git a/scanner.py b/scanner.py new file mode 100644 index 0000000..25c8055 --- /dev/null +++ b/scanner.py @@ -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) diff --git a/vv_ext.egg-info/PKG-INFO b/vv_ext.egg-info/PKG-INFO new file mode 100644 index 0000000..489773f --- /dev/null +++ b/vv_ext.egg-info/PKG-INFO @@ -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 diff --git a/vv_ext.egg-info/SOURCES.txt b/vv_ext.egg-info/SOURCES.txt new file mode 100644 index 0000000..265f154 --- /dev/null +++ b/vv_ext.egg-info/SOURCES.txt @@ -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 \ No newline at end of file diff --git a/vv_ext.egg-info/dependency_links.txt b/vv_ext.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/vv_ext.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/vv_ext.egg-info/requires.txt b/vv_ext.egg-info/requires.txt new file mode 100644 index 0000000..435bc58 --- /dev/null +++ b/vv_ext.egg-info/requires.txt @@ -0,0 +1,2 @@ +flask>=3.0 +waitress>=2.1 diff --git a/vv_ext.egg-info/top_level.txt b/vv_ext.egg-info/top_level.txt new file mode 100644 index 0000000..248eaf4 --- /dev/null +++ b/vv_ext.egg-info/top_level.txt @@ -0,0 +1,3 @@ +app +db +scanner