snapshot
This commit is contained in:
+182
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = [
|
||||
# "psycopg[binary]",
|
||||
# ]
|
||||
# ///
|
||||
import argparse
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import typing
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg
|
||||
import psycopg.rows
|
||||
|
||||
import ffmpeg
|
||||
|
||||
|
||||
def hash_partial(f: Path) -> str:
|
||||
sha1 = hashlib.sha1()
|
||||
chunk_size = 1024 * 1024 * 10 # 10MB chunk size
|
||||
|
||||
total_read = 0
|
||||
with f.open("rb") as file:
|
||||
while chunk := file.read(chunk_size):
|
||||
total_read += len(chunk)
|
||||
sha1.update(chunk)
|
||||
break # Only reads the first 10MB
|
||||
|
||||
return f"sha1:{total_read}:{sha1.hexdigest()}"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def connect_db() -> typing.Generator[psycopg.Connection, typing.Any, typing.Any]:
|
||||
with psycopg.connect("postgres://abdus:abdus@db.abdus.dev:5444/smut?sslmode=disable") as conn:
|
||||
conn.row_factory = psycopg.rows.dict_row
|
||||
yield conn
|
||||
|
||||
|
||||
def video_exists(con: psycopg.Connection, file_path: str) -> bool:
|
||||
stmt = con.execute(
|
||||
"UPDATE videos SET last_seen_at = NOW() WHERE file_path = %s RETURNING true AS exists",
|
||||
[file_path],
|
||||
)
|
||||
row = stmt.fetchone()
|
||||
return row["exists"]
|
||||
|
||||
|
||||
def delete_video(con: psycopg.Connection, file_path: str) -> bool:
|
||||
con.execute("DELETE FROM videos WHERE file_path = %s", [file_path])
|
||||
|
||||
|
||||
def save_video(
|
||||
con: psycopg.Connection,
|
||||
category: str,
|
||||
file_path: str,
|
||||
ffprobe: dict,
|
||||
hash_partial: str,
|
||||
):
|
||||
sql = """INSERT INTO videos(category, file_path, ffprobe, hash_partial) VALUES (%s, %s, %s, %s) ON CONFLICT(file_path) SET last_seen_at = NOW();"""
|
||||
con.execute(
|
||||
sql,
|
||||
[
|
||||
category,
|
||||
file_path,
|
||||
json.dumps(ffprobe),
|
||||
hash_partial,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def save_error(con: psycopg.Connection, file_path: str, stderr: str):
|
||||
sql = """INSERT INTO scan_state(file_path, stderr) VALUES (%s, %s) ON conflict do nothing"""
|
||||
con.execute(
|
||||
sql,
|
||||
[
|
||||
file_path,
|
||||
stderr,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def scan_videos(con: psycopg.Connection, video_paths: list[Path]) -> None:
|
||||
for i, f in enumerate(video_paths):
|
||||
if "/_picks/" in str(f):
|
||||
continue
|
||||
progress = f"[{i + 1}/{len(video_paths)}]"
|
||||
if f.is_dir():
|
||||
continue
|
||||
|
||||
file_path = str(f.absolute())
|
||||
|
||||
if video_exists(con, file_path=file_path):
|
||||
logging.debug(f"{progress} video already exists {f=}")
|
||||
continue
|
||||
|
||||
logging.debug(f"probing {f=}")
|
||||
try:
|
||||
probed = ffmpeg.ffprobe(f)
|
||||
except subprocess.CalledProcessError as e:
|
||||
save_error(con, file_path=file_path, stderr=e.stderr)
|
||||
continue
|
||||
|
||||
file_hash = hash_partial(f)
|
||||
|
||||
save_video(
|
||||
category=category_for_file(f),
|
||||
con=con,
|
||||
file_path=file_path,
|
||||
ffprobe=probed.__dict__,
|
||||
hash_partial=file_hash,
|
||||
)
|
||||
logging.info(f"{progress} saved {f=}")
|
||||
con.commit()
|
||||
|
||||
|
||||
def category_for_file(file_path: Path) -> str:
|
||||
if str(file_path).startswith("/Volumes/"):
|
||||
return f"downloaded.{file_path.parts[1].lower()}"
|
||||
if str(file_path).startswith("/mnt/box"):
|
||||
return "box"
|
||||
raise ValueError(f"unknown category for {file_path}")
|
||||
|
||||
|
||||
def find_copies(con: psycopg.Connection, video_path: Path) -> list[str]:
|
||||
probed = ffmpeg.ffprobe(video_path)
|
||||
|
||||
sql = """
|
||||
SELECT
|
||||
file_path
|
||||
FROM videos
|
||||
WHERE
|
||||
file_path != %(file_path)s
|
||||
AND (
|
||||
hash_partial = %(hash_partial)s
|
||||
OR (abs(duration_sec - %(duration_sec)s) < 0.1 AND abs(size_bytes - %(size_bytes)s) < 3000000)
|
||||
);
|
||||
"""
|
||||
|
||||
stmt: psycopg.Cursor = con.execute(
|
||||
sql,
|
||||
{
|
||||
"file_path": str(video_path.absolute()),
|
||||
"hash_partial": hash_partial(video_path),
|
||||
"duration_sec": probed.duration_sec,
|
||||
"size_bytes": probed.size_bytes,
|
||||
},
|
||||
)
|
||||
rows = stmt.fetchall()
|
||||
|
||||
return [it["file_path"] for it in rows]
|
||||
|
||||
|
||||
def parse_args():
|
||||
arger = argparse.ArgumentParser()
|
||||
arger.add_argument("video_paths", nargs="+", type=Path, help="Path to the videos")
|
||||
arger.add_argument("--debug", action="store_true", help="Path to the videos")
|
||||
|
||||
return arger.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(format=logging.BASIC_FORMAT, level=logging.INFO)
|
||||
args = parse_args()
|
||||
if args.debug:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
|
||||
video_paths: list[Path] = args.video_paths
|
||||
if not video_paths:
|
||||
logging.info("no files to scan")
|
||||
return
|
||||
|
||||
with connect_db() as con:
|
||||
scan_videos(con=con, video_paths=video_paths)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user