added transcoding to mp4 in import to ensure web playback.
This commit is contained in:
+3
-3
@@ -74,9 +74,9 @@ def make_celery(app=None):
|
|||||||
task_acks_late=True, # Acknowledge after task completes
|
task_acks_late=True, # Acknowledge after task completes
|
||||||
task_reject_on_worker_lost=True, # Requeue if worker dies
|
task_reject_on_worker_lost=True, # Requeue if worker dies
|
||||||
|
|
||||||
# Time limits
|
# Time limits (video transcoding may need longer)
|
||||||
task_soft_time_limit=300, # 5 minutes soft limit
|
task_soft_time_limit=600, # 10 minutes soft limit
|
||||||
task_time_limit=600, # 10 minutes hard limit (archives may need more)
|
task_time_limit=900, # 15 minutes hard limit
|
||||||
|
|
||||||
# Periodic task schedule (Celery Beat)
|
# Periodic task schedule (Celery Beat)
|
||||||
beat_schedule={
|
beat_schedule={
|
||||||
|
|||||||
+50
-17
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
from flask import Blueprint, render_template, flash, redirect, url_for, abort, send_from_directory, request, jsonify
|
from flask import Blueprint, render_template, flash, redirect, url_for, abort, send_from_directory, request, jsonify
|
||||||
|
|
||||||
from sqlalchemy import desc, func, text, extract
|
from sqlalchemy import desc, func, text, extract, and_
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload, aliased
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -400,11 +400,15 @@ def tag_list():
|
|||||||
/tags?kind=series -> only series tags
|
/tags?kind=series -> only series tags
|
||||||
/tags?kind=rating -> only rating tags
|
/tags?kind=rating -> only rating tags
|
||||||
Shows a few preview images per tag.
|
Shows a few preview images per tag.
|
||||||
|
|
||||||
|
Optimized to use 2 queries instead of N+1:
|
||||||
|
1. Get all tags with counts
|
||||||
|
2. Get preview images for all tags at once using window function
|
||||||
"""
|
"""
|
||||||
images_per_tag = 3
|
images_per_tag = 3
|
||||||
kind = request.args.get('kind') # 'artist' | 'archive' | 'user' | 'character' | 'series' | 'rating' | None
|
kind = request.args.get('kind')
|
||||||
|
|
||||||
# Get tags with image counts
|
# Query 1: Get tags with image counts
|
||||||
q = db.session.query(
|
q = db.session.query(
|
||||||
Tag,
|
Tag,
|
||||||
func.count(image_tags.c.image_id).label('img_count')
|
func.count(image_tags.c.image_id).label('img_count')
|
||||||
@@ -415,26 +419,55 @@ def tag_list():
|
|||||||
|
|
||||||
tags_with_counts = q.order_by(Tag.name.asc()).all()
|
tags_with_counts = q.order_by(Tag.name.asc()).all()
|
||||||
|
|
||||||
tag_data = []
|
# Build tag_id list and initialize tag_data dict
|
||||||
|
tag_ids = [t.id for t, _ in tags_with_counts]
|
||||||
|
tag_map = {}
|
||||||
for t, count in tags_with_counts:
|
for t, count in tags_with_counts:
|
||||||
imgs = (
|
|
||||||
ImageRecord.query
|
|
||||||
.join(ImageRecord.tags)
|
|
||||||
.filter(Tag.id == t.id)
|
|
||||||
.order_by(desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at)))
|
|
||||||
.limit(images_per_tag)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
# Display label: for prefixed kinds use the part after ':', else full name
|
|
||||||
label = t.name.split(":", 1)[1] if (":" in t.name) else t.name
|
label = t.name.split(":", 1)[1] if (":" in t.name) else t.name
|
||||||
tag_data.append({
|
tag_map[t.id] = {
|
||||||
"id": t.id,
|
"id": t.id,
|
||||||
"name": label,
|
"name": label,
|
||||||
"tag": t.name,
|
"tag": t.name,
|
||||||
"images": imgs,
|
"images": [],
|
||||||
"kind": t.kind,
|
"kind": t.kind,
|
||||||
"count": count
|
"count": count
|
||||||
})
|
}
|
||||||
|
|
||||||
|
# Query 2: Get preview images for all tags at once using a subquery with row_number
|
||||||
|
# This fetches the top N images per tag in a single query
|
||||||
|
if tag_ids:
|
||||||
|
# Subquery to rank images within each tag
|
||||||
|
row_num = func.row_number().over(
|
||||||
|
partition_by=image_tags.c.tag_id,
|
||||||
|
order_by=desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at))
|
||||||
|
).label('rn')
|
||||||
|
|
||||||
|
subq = (
|
||||||
|
db.session.query(
|
||||||
|
image_tags.c.tag_id,
|
||||||
|
ImageRecord.id.label('image_id'),
|
||||||
|
row_num
|
||||||
|
)
|
||||||
|
.join(ImageRecord, ImageRecord.id == image_tags.c.image_id)
|
||||||
|
.filter(image_tags.c.tag_id.in_(tag_ids))
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get only the top N images per tag
|
||||||
|
preview_rows = (
|
||||||
|
db.session.query(subq.c.tag_id, ImageRecord)
|
||||||
|
.join(ImageRecord, ImageRecord.id == subq.c.image_id)
|
||||||
|
.filter(subq.c.rn <= images_per_tag)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Group images by tag_id
|
||||||
|
for tag_id, img in preview_rows:
|
||||||
|
if tag_id in tag_map:
|
||||||
|
tag_map[tag_id]["images"].append(img)
|
||||||
|
|
||||||
|
# Convert to list maintaining original order
|
||||||
|
tag_data = [tag_map[t.id] for t, _ in tags_with_counts]
|
||||||
|
|
||||||
return render_template('tags_list.html',
|
return render_template('tags_list.html',
|
||||||
tag_data=tag_data,
|
tag_data=tag_data,
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ def import_media_file(self, task_id: int):
|
|||||||
extract_metadata, calculate_hash, calculate_perceptual_hash,
|
extract_metadata, calculate_hash, calculate_perceptual_hash,
|
||||||
is_mostly_transparent, is_mostly_single_color, find_similar_image,
|
is_mostly_transparent, is_mostly_single_color, find_similar_image,
|
||||||
build_hashed_dest_path, load_import_settings, supersede_image,
|
build_hashed_dest_path, load_import_settings, supersede_image,
|
||||||
generate_thumbnail, generate_video_thumbnail_mirrored
|
generate_thumbnail, generate_video_thumbnail_mirrored,
|
||||||
|
transcode_video_to_mp4, needs_transcode
|
||||||
)
|
)
|
||||||
from app.utils.metadata_enrichment import find_sidecar_json
|
from app.utils.metadata_enrichment import find_sidecar_json
|
||||||
|
|
||||||
@@ -149,8 +150,33 @@ def import_media_file(self, task_id: int):
|
|||||||
# Normal import - copy to destination
|
# Normal import - copy to destination
|
||||||
target_dir = os.path.join(dest_dir, artist)
|
target_dir = os.path.join(dest_dir, artist)
|
||||||
os.makedirs(target_dir, exist_ok=True)
|
os.makedirs(target_dir, exist_ok=True)
|
||||||
dest_path = build_hashed_dest_path(target_dir, filename, file_hash)
|
|
||||||
shutil.copy2(src_path, dest_path)
|
# Transcode video if needed (non-MP4 formats to H.264 MP4)
|
||||||
|
transcoded_temp = None
|
||||||
|
actual_src = src_path
|
||||||
|
actual_filename = filename
|
||||||
|
|
||||||
|
if needs_transcode(src_path):
|
||||||
|
log.info(f"Transcoding video: {filename}")
|
||||||
|
# Transcode to temp location first
|
||||||
|
transcoded_temp = transcode_video_to_mp4(src_path)
|
||||||
|
if transcoded_temp:
|
||||||
|
actual_src = transcoded_temp
|
||||||
|
# Update filename to .mp4 extension
|
||||||
|
actual_filename = os.path.splitext(filename)[0] + '.mp4'
|
||||||
|
# Recalculate hash for transcoded file
|
||||||
|
file_hash = calculate_hash(transcoded_temp)
|
||||||
|
task.file_hash = file_hash
|
||||||
|
log.info(f"Transcoded successfully: {actual_filename}")
|
||||||
|
else:
|
||||||
|
log.warning(f"Transcode failed for {filename}, importing original")
|
||||||
|
|
||||||
|
dest_path = build_hashed_dest_path(target_dir, actual_filename, file_hash)
|
||||||
|
shutil.copy2(actual_src, dest_path)
|
||||||
|
|
||||||
|
# Clean up transcoded temp file
|
||||||
|
if transcoded_temp and os.path.exists(transcoded_temp):
|
||||||
|
os.remove(transcoded_temp)
|
||||||
|
|
||||||
# Generate thumbnail
|
# Generate thumbnail
|
||||||
try:
|
try:
|
||||||
@@ -159,20 +185,21 @@ def import_media_file(self, task_id: int):
|
|||||||
else:
|
else:
|
||||||
thumb_path = generate_thumbnail(dest_path)
|
thumb_path = generate_thumbnail(dest_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(f"Thumbnail generation failed for {filename}: {e}")
|
log.warning(f"Thumbnail generation failed for {actual_filename}: {e}")
|
||||||
thumb_path = None
|
thumb_path = None
|
||||||
|
|
||||||
# Create ImageRecord
|
# Create ImageRecord
|
||||||
|
# Use actual_filename in case video was transcoded to .mp4
|
||||||
record = ImageRecord(
|
record = ImageRecord(
|
||||||
filename=filename,
|
filename=actual_filename,
|
||||||
filepath=dest_path,
|
filepath=dest_path,
|
||||||
thumb_path=thumb_path,
|
thumb_path=thumb_path,
|
||||||
hash=file_hash,
|
hash=file_hash,
|
||||||
perceptual_hash=str(phash) if phash else None,
|
perceptual_hash=str(phash) if phash else None,
|
||||||
file_size=metadata['file_size'],
|
file_size=os.path.getsize(dest_path), # Use actual file size after transcode
|
||||||
width=metadata['width'],
|
width=metadata['width'],
|
||||||
height=metadata['height'],
|
height=metadata['height'],
|
||||||
format=metadata['format'],
|
format=metadata['format'] if not needs_transcode(src_path) else 'mp4',
|
||||||
camera_model=metadata.get('camera_model'),
|
camera_model=metadata.get('camera_model'),
|
||||||
taken_at=metadata.get('taken_at'),
|
taken_at=metadata.get('taken_at'),
|
||||||
imported_at=datetime.utcnow()
|
imported_at=datetime.utcnow()
|
||||||
@@ -200,7 +227,7 @@ def import_media_file(self, task_id: int):
|
|||||||
from app.tasks.scan import update_batch_stats
|
from app.tasks.scan import update_batch_stats
|
||||||
update_batch_stats.delay(task.batch_id)
|
update_batch_stats.delay(task.batch_id)
|
||||||
|
|
||||||
log.info(f"Imported: {filename} -> {dest_path}")
|
log.info(f"Imported: {actual_filename} -> {dest_path}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'status': 'imported',
|
'status': 'imported',
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from app import db
|
|||||||
from app.models import ImageRecord, Tag, ArchiveRecord
|
from app.models import ImageRecord, Tag, ArchiveRecord
|
||||||
|
|
||||||
FFMPEG_THUMB_TIMEOUT = int(os.environ.get("THUMBS_FFMPEG_TIMEOUT", "60")) # seconds
|
FFMPEG_THUMB_TIMEOUT = int(os.environ.get("THUMBS_FFMPEG_TIMEOUT", "60")) # seconds
|
||||||
|
FFMPEG_TRANSCODE_TIMEOUT = int(os.environ.get("FFMPEG_TRANSCODE_TIMEOUT", "600")) # 10 min default
|
||||||
Image.MAX_IMAGE_PIXELS = int(os.environ.get("PIL_MAX_IMAGE_PIXELS", "178956970"))
|
Image.MAX_IMAGE_PIXELS = int(os.environ.get("PIL_MAX_IMAGE_PIXELS", "178956970"))
|
||||||
ARCHIVE_NUM_WIDTH = int(os.environ.get("ARCHIVE_NUM_WIDTH", "4"))
|
ARCHIVE_NUM_WIDTH = int(os.environ.get("ARCHIVE_NUM_WIDTH", "4"))
|
||||||
|
|
||||||
@@ -36,9 +37,10 @@ ARCHIVE_NUM_WIDTH = int(os.environ.get("ARCHIVE_NUM_WIDTH", "4"))
|
|||||||
THUMB_SIZE = (400, 400)
|
THUMB_SIZE = (400, 400)
|
||||||
|
|
||||||
# Media and archive extensions we handle
|
# Media and archive extensions we handle
|
||||||
ALLOWED_MEDIA_EXTS = (
|
IMAGE_EXTS = (".jpg", ".jpeg", ".jfif", ".png", ".gif", ".bmp", ".tiff", ".webp")
|
||||||
".jpg", ".jpeg", ".jfif", ".png", ".gif", ".bmp", ".tiff", ".webp", ".mp4", ".mov"
|
VIDEO_EXTS = (".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv", ".flv")
|
||||||
)
|
VIDEO_EXTS_NEED_TRANSCODE = (".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv", ".flv") # Non-MP4 formats
|
||||||
|
ALLOWED_MEDIA_EXTS = IMAGE_EXTS + VIDEO_EXTS
|
||||||
ALLOWED_ARCHIVE_EXTS = (
|
ALLOWED_ARCHIVE_EXTS = (
|
||||||
".zip", ".rar", ".cbr", ".7z",
|
".zip", ".rar", ".cbr", ".7z",
|
||||||
".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz"
|
".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz"
|
||||||
@@ -1025,6 +1027,88 @@ def generate_video_thumbnail_mirrored(video_path: str, time_position: str = "00:
|
|||||||
return generate_video_thumbnail(str(video_path_p), str(thumb_path), time_position=time_position)
|
return generate_video_thumbnail(str(video_path_p), str(thumb_path), time_position=time_position)
|
||||||
|
|
||||||
|
|
||||||
|
def transcode_video_to_mp4(input_path: str, output_path: str = None) -> str | None:
|
||||||
|
"""
|
||||||
|
Transcode a video to H.264 MP4 for universal browser playback.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_path: Path to source video file
|
||||||
|
output_path: Optional destination path. If None, replaces extension with .mp4
|
||||||
|
in a temp location.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to transcoded MP4 file on success, None on failure.
|
||||||
|
"""
|
||||||
|
input_p = Path(input_path)
|
||||||
|
|
||||||
|
# Determine output path
|
||||||
|
if output_path is None:
|
||||||
|
# Create temp file with .mp4 extension
|
||||||
|
output_path = str(input_p.with_suffix(".mp4"))
|
||||||
|
if output_path == input_path:
|
||||||
|
# Already .mp4, use temp suffix
|
||||||
|
output_path = str(input_p.with_suffix(".transcoded.mp4"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||||
|
|
||||||
|
# FFmpeg command for H.264/AAC transcoding with good browser compatibility
|
||||||
|
# -movflags +faststart: Enables progressive playback (moov atom at start)
|
||||||
|
# -preset medium: Balance between speed and compression
|
||||||
|
# -crf 23: Good quality (lower = better, 18-28 typical range)
|
||||||
|
# -c:v libx264: H.264 video codec
|
||||||
|
# -c:a aac: AAC audio codec
|
||||||
|
# -pix_fmt yuv420p: Pixel format for maximum compatibility
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-i", str(input_path),
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-preset", "medium",
|
||||||
|
"-crf", "23",
|
||||||
|
"-pix_fmt", "yuv420p",
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-b:a", "128k",
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
str(output_path),
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"[INFO] Transcoding video: {input_path} -> {output_path}")
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
check=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
timeout=FFMPEG_TRANSCODE_TIMEOUT
|
||||||
|
)
|
||||||
|
print(f"[INFO] Transcoding complete: {output_path}")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
print(f"[WARN] Video transcode timed out after {FFMPEG_TRANSCODE_TIMEOUT}s: {input_path}")
|
||||||
|
# Clean up partial output
|
||||||
|
if os.path.exists(output_path):
|
||||||
|
os.remove(output_path)
|
||||||
|
return None
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"[WARN] Failed to transcode video {input_path}: {e}")
|
||||||
|
if e.stderr:
|
||||||
|
print(f"[WARN] FFmpeg stderr: {e.stderr.decode('utf-8', errors='ignore')[:500]}")
|
||||||
|
# Clean up partial output
|
||||||
|
if os.path.exists(output_path):
|
||||||
|
os.remove(output_path)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] Unexpected error transcoding video {input_path}: {e}")
|
||||||
|
if os.path.exists(output_path):
|
||||||
|
os.remove(output_path)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def needs_transcode(file_path: str) -> bool:
|
||||||
|
"""Check if a video file needs transcoding to MP4."""
|
||||||
|
ext = Path(file_path).suffix.lower()
|
||||||
|
return ext in VIDEO_EXTS_NEED_TRANSCODE
|
||||||
|
|
||||||
|
|
||||||
def calculate_hash(file_path: str) -> str:
|
def calculate_hash(file_path: str) -> str:
|
||||||
hash_func = hashlib.sha256()
|
hash_func = hashlib.sha256()
|
||||||
|
|||||||
Reference in New Issue
Block a user