81 lines
4.3 KiB
Python
81 lines
4.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Bounded, on-demand metadata for one local copied file; never modifies it."""
|
|
import base64
|
|
import json
|
|
import mimetypes
|
|
import os
|
|
from pathlib import Path
|
|
import stat
|
|
import sys
|
|
import time
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
from capture import read_bounded
|
|
|
|
|
|
def preview(path):
|
|
result = {'path': path}
|
|
deadline = time.monotonic() + 6
|
|
|
|
def run(args, limit=128 * 1024):
|
|
remaining = deadline - time.monotonic()
|
|
return read_bounded(args, limit, timeout=min(2, remaining)) if remaining > 0 else None
|
|
|
|
try:
|
|
info = os.stat(path)
|
|
result.update(bytes=info.st_size, modified=int(info.st_mtime))
|
|
if stat.S_ISDIR(info.st_mode):
|
|
result['kind'] = 'Folder'
|
|
return result
|
|
if not stat.S_ISREG(info.st_mode):
|
|
result['kind'] = 'Special file'
|
|
return result
|
|
mime = mimetypes.guess_type(path)[0] or 'application/octet-stream'
|
|
result['mime'] = mime
|
|
result['kind'] = mime
|
|
# Do not let a media playlist cause ffmpeg to open referenced resources.
|
|
media = Path(path).suffix.lower() in {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff', '.tif', '.avif', '.heic', '.mp4', '.mkv', '.webm', '.mov', '.avi', '.m4v', '.mp3', '.flac', '.wav', '.ogg', '.opus', '.m4a', '.aac'}
|
|
if media:
|
|
options = ['-v', 'error', '-max_alloc', '67108864', '-protocol_whitelist', 'file', '-probesize', '1048576', '-analyzeduration', '1000000']
|
|
data = run(['ffprobe'] + options + ['-show_entries', 'format=duration:stream=codec_type,codec_name,width,height,sample_rate,channels', '-of', 'json', path])
|
|
if data:
|
|
parsed = json.loads(data)
|
|
duration = float(parsed.get('format', {}).get('duration', 0))
|
|
if 0 < duration < 1e9:
|
|
result['duration'] = duration
|
|
streams = parsed.get('streams', [])
|
|
visual = next((s for s in streams if s.get('codec_type') == 'video'), {})
|
|
audio = next((s for s in streams if s.get('codec_type') == 'audio'), {})
|
|
for key in ('width', 'height'):
|
|
if visual.get(key):
|
|
result[key] = int(visual[key])
|
|
if audio:
|
|
result['audio'] = ' · '.join(str(audio[k]) + suffix for k, suffix in [('codec_name', ''), ('sample_rate', ' Hz'), ('channels', ' channels')] if audio.get(k))
|
|
if 0 < result.get('width', 0) * result.get('height', 0) <= 40000000:
|
|
thumb = run(['ffmpeg'] + options + ['-nostdin', '-threads', '1', '-i', path, '-frames:v', '1', '-vf', 'scale=640:360:force_original_aspect_ratio=decrease', '-threads', '1', '-f', 'image2pipe', '-c:v', 'mjpeg', '-protocol_whitelist', 'pipe', 'pipe:1'], 512 * 1024)
|
|
if thumb:
|
|
result['thumbnail'] = 'data:image/jpeg;base64,' + base64.b64encode(thumb).decode('ascii')
|
|
elif mime == 'application/pdf':
|
|
thumb = run(['pdftoppm', '-f', '1', '-singlefile', '-scale-to', '640', '-jpeg', path], 512 * 1024)
|
|
if thumb:
|
|
result['thumbnail'] = 'data:image/jpeg;base64,' + base64.b64encode(thumb).decode('ascii')
|
|
data = run(['pdfinfo', path])
|
|
if data:
|
|
result['details'] = '\n'.join(line.strip() for line in data.decode('utf-8', 'replace').splitlines() if line.startswith(('Pages:', 'Page size:', 'Title:', 'Author:')))
|
|
elif mime.startswith('text/') or Path(path).suffix.lower() in {'.json', '.yaml', '.yml', '.toml', '.md', '.py', '.js', '.ts', '.qml', '.sh', '.rs', '.go', '.log', '.csv'}:
|
|
# Nonblocking open avoids hanging on a file replaced with a FIFO.
|
|
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
|
|
with os.fdopen(fd, 'rb') as source:
|
|
if stat.S_ISREG(os.fstat(source.fileno()).st_mode):
|
|
data = source.read(8193)
|
|
if b'\0' not in data:
|
|
result['text'] = data[:8192].decode('utf-8', 'replace')
|
|
result['truncated'] = len(data) > 8192
|
|
except (OSError, ValueError, TypeError) as error:
|
|
result['error'] = str(error)
|
|
return result
|
|
|
|
|
|
if __name__ == '__main__':
|
|
print(json.dumps({'request': sys.argv[1], 'file': preview(os.path.abspath(sys.argv[2]))}))
|