Initial commit: Video metadata registration service
- Add FastAPI server for video metadata registration - PostgreSQL database models for videos, video_streams, audio_streams, subtitle_streams - Batch registration script for .probe.json files - RESTful API endpoints for CRUD operations - Search functionality by title, artist, codec, resolution
This commit is contained in:
1
app/services/__init__.py
Normal file
1
app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# services package
|
||||
120
app/services/probe_parser.py
Normal file
120
app/services/probe_parser.py
Normal file
@@ -0,0 +1,120 @@
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ProbeParser:
|
||||
@staticmethod
|
||||
def load_probe_json(probe_json_path: str) -> Dict[str, Any]:
|
||||
if not os.path.exists(probe_json_path):
|
||||
raise FileNotFoundError(f"Probe JSON file not found: {probe_json_path}")
|
||||
|
||||
with open(probe_json_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
@staticmethod
|
||||
def parse_video_metadata(
|
||||
probe_data: Dict[str, Any], absolute_file_path: str
|
||||
) -> Dict[str, Any]:
|
||||
format_data = probe_data.get("format", {})
|
||||
video_stream = probe_data.get("video_stream")
|
||||
audio_streams = probe_data.get("audio_streams", [])
|
||||
subtitle_streams = probe_data.get("subtitle_streams", [])
|
||||
|
||||
file_name = os.path.basename(absolute_file_path)
|
||||
name_without_ext = os.path.splitext(file_name)[0]
|
||||
file_ext = os.path.splitext(file_name)[1]
|
||||
|
||||
tags = format_data.get("tags", {})
|
||||
|
||||
probed_at_str = probe_data.get("probed_at")
|
||||
probed_at = None
|
||||
if probed_at_str:
|
||||
try:
|
||||
probed_at = datetime.fromisoformat(probed_at_str)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
metadata = {
|
||||
"file_path": absolute_file_path,
|
||||
"file_name": name_without_ext,
|
||||
"file_extension": file_ext,
|
||||
"file_size": format_data.get("size"),
|
||||
"format_name": format_data.get("format_name"),
|
||||
"format_long_name": format_data.get("format_long_name"),
|
||||
"duration": format_data.get("duration"),
|
||||
"bit_rate": format_data.get("bit_rate"),
|
||||
"nb_streams": len(probe_data.get("streams", [])),
|
||||
"start_time": video_stream.get("start_time") if video_stream else 0,
|
||||
"title": tags.get("title"),
|
||||
"artist": tags.get("artist"),
|
||||
"description": tags.get("description"),
|
||||
"probed_at": probed_at,
|
||||
}
|
||||
|
||||
return metadata
|
||||
|
||||
@staticmethod
|
||||
def parse_video_stream(video_stream: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
if not video_stream:
|
||||
return {}
|
||||
|
||||
return {
|
||||
"stream_index": video_stream.get("index"),
|
||||
"codec_name": video_stream.get("codec_name"),
|
||||
"codec_long_name": video_stream.get("codec_long_name"),
|
||||
"profile": video_stream.get("profile"),
|
||||
"level": video_stream.get("level"),
|
||||
"width": video_stream.get("width"),
|
||||
"height": video_stream.get("height"),
|
||||
"coded_width": video_stream.get("coded_width"),
|
||||
"coded_height": video_stream.get("coded_height"),
|
||||
"aspect_ratio": video_stream.get("aspect_ratio"),
|
||||
"pix_fmt": video_stream.get("pix_fmt"),
|
||||
"field_order": video_stream.get("field_order"),
|
||||
"frame_rate": video_stream.get("r_frame_rate"),
|
||||
"start_time": video_stream.get("start_time"),
|
||||
"duration": video_stream.get("duration"),
|
||||
"bit_rate": video_stream.get("bit_rate"),
|
||||
"nb_frames": video_stream.get("nb_frames"),
|
||||
"color_range": video_stream.get("color_range"),
|
||||
"color_space": video_stream.get("color_space"),
|
||||
"has_b_frames": video_stream.get("has_b_frames"),
|
||||
"sample_aspect_ratio": video_stream.get("sample_aspect_ratio"),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def parse_audio_streams(audio_streams: list) -> list:
|
||||
result = []
|
||||
for audio in audio_streams:
|
||||
result.append(
|
||||
{
|
||||
"stream_index": audio.get("index"),
|
||||
"codec_name": audio.get("codec_name"),
|
||||
"codec_long_name": audio.get("codec_long_name"),
|
||||
"profile": audio.get("profile"),
|
||||
"channels": audio.get("channels"),
|
||||
"channel_layout": audio.get("channel_layout"),
|
||||
"sample_rate": audio.get("sample_rate"),
|
||||
"sample_fmt": audio.get("sample_fmt"),
|
||||
"bit_rate": audio.get("bit_rate"),
|
||||
"duration": audio.get("duration"),
|
||||
"language": audio.get("tags", {}).get("language"),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def parse_subtitle_streams(subtitle_streams: list) -> list:
|
||||
result = []
|
||||
for subtitle in subtitle_streams:
|
||||
result.append(
|
||||
{
|
||||
"stream_index": subtitle.get("index"),
|
||||
"codec_name": subtitle.get("codec_name"),
|
||||
"language": subtitle.get("language"),
|
||||
}
|
||||
)
|
||||
return result
|
||||
202
app/services/video_register.py
Normal file
202
app/services/video_register.py
Normal file
@@ -0,0 +1,202 @@
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.video import Video, VideoStream, AudioStream, SubtitleStream
|
||||
from app.services.probe_parser import ProbeParser
|
||||
|
||||
|
||||
class VideoRegisterService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.parser = ProbeParser()
|
||||
|
||||
def register_video(self, probe_json_path: str, absolute_file_path: str) -> Video:
|
||||
probe_data = self.parser.load_probe_json(probe_json_path)
|
||||
|
||||
existing_video = (
|
||||
self.db.query(Video).filter(Video.file_path == absolute_file_path).first()
|
||||
)
|
||||
|
||||
if existing_video:
|
||||
return self._update_video(existing_video, probe_data)
|
||||
|
||||
return self._create_video(probe_data, absolute_file_path)
|
||||
|
||||
def _create_video(
|
||||
self, probe_data: Dict[str, Any], absolute_file_path: str
|
||||
) -> Video:
|
||||
video_metadata = self.parser.parse_video_metadata(
|
||||
probe_data, absolute_file_path
|
||||
)
|
||||
video_stream_data = self.parser.parse_video_stream(
|
||||
probe_data.get("video_stream")
|
||||
)
|
||||
audio_streams_data = self.parser.parse_audio_streams(
|
||||
probe_data.get("audio_streams", [])
|
||||
)
|
||||
subtitle_streams_data = self.parser.parse_subtitle_streams(
|
||||
probe_data.get("subtitle_streams", [])
|
||||
)
|
||||
|
||||
video = Video(**video_metadata)
|
||||
|
||||
if video_stream_data:
|
||||
video.video_streams.append(VideoStream(**video_stream_data))
|
||||
|
||||
for audio_data in audio_streams_data:
|
||||
video.audio_streams.append(AudioStream(**audio_data))
|
||||
|
||||
for subtitle_data in subtitle_streams_data:
|
||||
video.subtitle_streams.append(SubtitleStream(**subtitle_data))
|
||||
|
||||
self.db.add(video)
|
||||
self.db.commit()
|
||||
self.db.refresh(video)
|
||||
|
||||
return video
|
||||
|
||||
def _update_video(self, video: Video, probe_data: Dict[str, Any]) -> Video:
|
||||
video_metadata = self.parser.parse_video_metadata(probe_data, video.file_path)
|
||||
video_stream_data = self.parser.parse_video_stream(
|
||||
probe_data.get("video_stream")
|
||||
)
|
||||
audio_streams_data = self.parser.parse_audio_streams(
|
||||
probe_data.get("audio_streams", [])
|
||||
)
|
||||
subtitle_streams_data = self.parser.parse_subtitle_streams(
|
||||
probe_data.get("subtitle_streams", [])
|
||||
)
|
||||
|
||||
for key, value in video_metadata.items():
|
||||
if value is not None:
|
||||
setattr(video, key, value)
|
||||
|
||||
video.updated_at = datetime.utcnow()
|
||||
|
||||
self.db.query(VideoStream).filter(VideoStream.video_id == video.id).delete()
|
||||
self.db.query(AudioStream).filter(AudioStream.video_id == video.id).delete()
|
||||
self.db.query(SubtitleStream).filter(
|
||||
SubtitleStream.video_id == video.id
|
||||
).delete()
|
||||
|
||||
if video_stream_data:
|
||||
video.video_streams.append(VideoStream(**video_stream_data))
|
||||
|
||||
for audio_data in audio_streams_data:
|
||||
video.audio_streams.append(AudioStream(**audio_data))
|
||||
|
||||
for subtitle_data in subtitle_streams_data:
|
||||
video.subtitle_streams.append(SubtitleStream(**subtitle_data))
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(video)
|
||||
|
||||
return video
|
||||
|
||||
def register_batch(self, directory: str) -> List[Video]:
|
||||
videos = []
|
||||
|
||||
for root, dirs, files in os.walk(directory):
|
||||
for file in files:
|
||||
if file.endswith(".probe.json"):
|
||||
probe_json_path = os.path.join(root, file)
|
||||
|
||||
video_filename = file.replace(".probe.json", "")
|
||||
|
||||
possible_extensions = [
|
||||
".mp4",
|
||||
".mov",
|
||||
".avi",
|
||||
".mkv",
|
||||
".m4v",
|
||||
".wmv",
|
||||
".flv",
|
||||
".webm",
|
||||
]
|
||||
absolute_file_path = None
|
||||
|
||||
for ext in possible_extensions:
|
||||
test_path = os.path.join(root, video_filename + ext)
|
||||
if os.path.exists(test_path):
|
||||
absolute_file_path = test_path
|
||||
break
|
||||
|
||||
if not absolute_file_path:
|
||||
video_file = video_filename
|
||||
for f in os.listdir(root):
|
||||
if (
|
||||
f.startswith(video_filename)
|
||||
and not f.endswith(".probe.json")
|
||||
and not f.endswith(".yolo.json")
|
||||
):
|
||||
absolute_file_path = os.path.join(root, f)
|
||||
break
|
||||
|
||||
if absolute_file_path:
|
||||
try:
|
||||
video = self.register_video(
|
||||
probe_json_path, absolute_file_path
|
||||
)
|
||||
videos.append(video)
|
||||
print(f"Registered: {video.file_name}")
|
||||
except Exception as e:
|
||||
print(f"Error registering {probe_json_path}: {e}")
|
||||
else:
|
||||
print(f"Video file not found for: {probe_json_path}")
|
||||
|
||||
return videos
|
||||
|
||||
def get_video_by_id(self, video_id: uuid.UUID) -> Video:
|
||||
return self.db.query(Video).filter(Video.id == video_id).first()
|
||||
|
||||
def get_video_by_path(self, file_path: str) -> Video:
|
||||
return self.db.query(Video).filter(Video.file_path == file_path).first()
|
||||
|
||||
def search_videos(
|
||||
self,
|
||||
title=None,
|
||||
artist=None,
|
||||
codec_name=None,
|
||||
min_width=None,
|
||||
max_width=None,
|
||||
min_height=None,
|
||||
max_height=None,
|
||||
format_name=None,
|
||||
skip=0,
|
||||
limit=20,
|
||||
):
|
||||
query = self.db.query(Video)
|
||||
|
||||
if title:
|
||||
query = query.filter(Video.title.ilike(f"%{title}%"))
|
||||
|
||||
if artist:
|
||||
query = query.filter(Video.artist.ilike(f"%{artist}%"))
|
||||
|
||||
if format_name:
|
||||
query = query.filter(Video.format_name.ilike(f"%{format_name}%"))
|
||||
|
||||
if min_width or max_width or min_height or max_height:
|
||||
query = query.join(VideoStream).filter(VideoStream.video_id == Video.id)
|
||||
|
||||
if min_width:
|
||||
query = query.filter(VideoStream.width >= min_width)
|
||||
if max_width:
|
||||
query = query.filter(VideoStream.width <= max_width)
|
||||
if min_height:
|
||||
query = query.filter(VideoStream.height >= min_height)
|
||||
if max_height:
|
||||
query = query.filter(VideoStream.height <= max_height)
|
||||
|
||||
if codec_name:
|
||||
query = query.join(VideoStream).filter(
|
||||
VideoStream.video_id == Video.id,
|
||||
VideoStream.codec_name.ilike(f"%{codec_name}%"),
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
videos = query.offset(skip).limit(limit).all()
|
||||
|
||||
return total, videos
|
||||
Reference in New Issue
Block a user