Files
drive/naming.py
T
2026-10-18 23:48:22 +05:30

183 lines
6.3 KiB
Python

"""Filename and folder-name rules: sanitising, extension detection, collisions."""
from __future__ import annotations
import os
import re
import unicodedata
from pathlib import Path
from errors import PermanentError
VIDEO_EXTENSIONS = frozenset(
{".mp4", ".m4v", ".mov", ".mkv", ".webm", ".avi", ".flv", ".wmv", ".ts", ".m2ts", ".mts", ".3gp", ".mpg", ".mpeg", ".ogv"}
)
# Extensions that share a container format; a title saying ".mov" for an MP4 file is fine.
_FAMILIES = (
frozenset({".mp4", ".m4v", ".mov", ".3gp"}),
frozenset({".mkv", ".webm"}),
frozenset({".ts", ".m2ts", ".mts"}),
frozenset({".mpg", ".mpeg"}),
)
MIME_TO_EXTENSION = {
"video/mp4": ".mp4",
"video/x-m4v": ".m4v",
"video/quicktime": ".mov",
"video/x-matroska": ".mkv",
"video/webm": ".webm",
"video/x-msvideo": ".avi",
"video/x-flv": ".flv",
"video/x-ms-wmv": ".wmv",
"video/mp2t": ".ts",
"video/3gpp": ".3gp",
"video/mpeg": ".mpg",
"video/ogg": ".ogv",
}
EXTENSION_TO_MIME = {ext: mime for mime, ext in MIME_TO_EXTENSION.items()} | {
".mpeg": "video/mpeg",
".m2ts": "video/mp2t",
".mts": "video/mp2t",
}
MAX_NAME_LENGTH = 200
_ALWAYS_REPLACE = re.compile(r"[/\\]")
_STRICT_REPLACE = re.compile(r'[<>:"|?*]')
def normalize_key(text: str) -> str:
"""Comparison key for folder names: NFC, collapsed whitespace, case-insensitive."""
return " ".join(unicodedata.normalize("NFC", text).split()).casefold()
def clean_component(text: str | None, *, strict: bool = False) -> str:
"""Make a caption value safe as a Drive file/folder name while keeping Unicode.
Control characters and path separators are replaced; letters, bullets, dots,
parentheses and other punctuation are kept. ``strict`` additionally replaces
characters Windows cannot store (useful with Google Drive for desktop).
"""
if not text:
return ""
text = unicodedata.normalize("NFC", text)
text = "".join(" " if unicodedata.category(ch) == "Cc" else ch for ch in text)
text = _ALWAYS_REPLACE.sub("_", text)
if strict:
text = _STRICT_REPLACE.sub("_", text).rstrip(". ")
text = " ".join(text.split())
return truncate_name(text)
def split_video_extension(name: str) -> tuple[str, str]:
"""Split off a *video* extension only, so 'Lecture 1.5' keeps its '.5'."""
stem, ext = os.path.splitext(name)
if ext.lower() in VIDEO_EXTENSIONS and stem:
return stem, ext
return name, ""
def truncate_name(name: str, limit: int = MAX_NAME_LENGTH) -> str:
if len(name) <= limit:
return name
stem, ext = split_video_extension(name)
return stem[: limit - len(ext)].rstrip() + ext
def same_family(a: str, b: str) -> bool:
a, b = a.lower(), b.lower()
if a == b:
return True
return any(a in family and b in family for family in _FAMILIES)
def extension_from_mime(mime_type: str | None) -> str | None:
return MIME_TO_EXTENSION.get((mime_type or "").lower())
def mime_for_name(name: str, fallback: str | None = None) -> str:
_, ext = split_video_extension(name)
return EXTENSION_TO_MIME.get(ext.lower()) or fallback or "application/octet-stream"
def detect_video_extension(path: Path) -> str | None:
"""Identify the container from magic bytes; ``None`` if unknown."""
with open(path, "rb") as fh:
head = fh.read(4096)
if len(head) >= 12 and head[4:8] == b"ftyp":
brand = head[8:12]
if brand == b"qt ":
return ".mov"
if brand.startswith(b"3g"):
return ".3gp"
if brand.startswith(b"M4V"):
return ".m4v"
return ".mp4"
if head.startswith(b"\x1a\x45\xdf\xa3"):
return ".webm" if b"webm" in head[:64] else ".mkv"
if head[:4] == b"RIFF" and head[8:12] == b"AVI ":
return ".avi"
if head[:3] == b"FLV":
return ".flv"
if head[:8] == b"\x30\x26\xb2\x75\x8e\x66\xcf\x11":
return ".wmv"
if head[:4] in (b"\x00\x00\x01\xba", b"\x00\x00\x01\xb3"):
return ".mpg"
if head[:4] == b"OggS":
return ".ogv"
if len(head) > 376 and head[0] == 0x47 and head[188] == 0x47 and head[376] == 0x47:
return ".ts"
return None
def build_filename(
title: str | None,
*,
message_id: int,
original_filename: str | None,
mime_type: str | None,
detected_ext: str | None,
missing_title_policy: str = "fallback",
mismatch_policy: str = "keep",
strict: bool = False,
) -> tuple[str, list[str]]:
"""Return the Drive filename for a video plus any warnings worth recording.
The Title field is used verbatim (sanitised). The extension is only added when
the title has none; a title whose extension disagrees with the real container
is kept as-is unless EXTENSION_MISMATCH_POLICY=append.
"""
warnings: list[str] = []
name = clean_component(title, strict=strict)
if not name:
if missing_title_policy == "fail":
raise PermanentError(
f"message {message_id} has no usable Title", public="Caption has no usable Title (MISSING_TITLE_POLICY=fail)"
)
name = clean_component(original_filename, strict=strict)
if name:
warnings.append("Title missing; used the Telegram file name")
else:
name = f"telegram_{message_id}"
warnings.append("Title missing; used a generated name")
_, ext = split_video_extension(name)
_, original_ext = split_video_extension(original_filename or "")
actual = detected_ext or extension_from_mime(mime_type) or (original_ext.lower() or None)
if not ext:
if actual:
name = truncate_name(name, MAX_NAME_LENGTH - len(actual)) + actual
else:
warnings.append("could not determine the file extension")
elif detected_ext and not same_family(ext, detected_ext):
warnings.append(f"title extension {ext} does not match detected container {detected_ext}")
if mismatch_policy == "append":
name = truncate_name(name, MAX_NAME_LENGTH - len(detected_ext)) + detected_ext
return name, warnings
def add_suffix(name: str, suffix: str) -> str:
"""'Class 01.mp4' + '(2)' -> 'Class 01 (2).mp4'."""
stem, ext = split_video_extension(name)
if not ext:
stem, ext = os.path.splitext(name)
if len(ext) > 6 or not stem:
stem, ext = name, ""
return truncate_name(f"{stem} {suffix}{ext}")