Compare commits
14
Commits
53a8469b8f
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a45407384e | ||
|
|
708c7dc4cd | ||
|
|
21383a391a | ||
|
|
97e40731b2 | ||
|
|
65777071f4 | ||
|
|
404b61aa00 | ||
|
|
cf4b7fa106 | ||
|
|
0c71d3c58b | ||
|
|
5ec816c163 | ||
|
|
c7a381c4b9 | ||
|
|
a30791a036 | ||
|
|
55cd3e7ec0 | ||
|
|
5d5371c991 | ||
|
|
9cd7fb204e |
@@ -0,0 +1,29 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [1.0.0] - 2025-04-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial release of **videnc-reeffed** CLI for encoding videos.
|
||||||
|
- Support for:
|
||||||
|
- Specifying input video file and IMDb ID validation.
|
||||||
|
- Removing subtitles with `--nosubtitles`.
|
||||||
|
- Encoding presets (`--preset`) and CRF settings (`--crf`).
|
||||||
|
- Selecting original media type (`--original-media-type`).
|
||||||
|
- Adjusting film grain amount (`--film-grain`).
|
||||||
|
- Implemented core functions:
|
||||||
|
- `run_ffprobe` for extracting stream metadata.
|
||||||
|
- `parse_streams` to model video, audio, and subtitle streams.
|
||||||
|
- `apply_stream_settings` to apply encoding parameters.
|
||||||
|
- `confirm_and_write_script` to generate and confirm overwrite of `encode.sh` script.
|
||||||
|
- Integrated modules and helpers:
|
||||||
|
- `models/streams` data models: `VideoFile`, `VideoStream`, `AudioStream`, `SubtitleStream`, `OriginalMediaType`.
|
||||||
|
- `movie_details.get_movie_details` for enriching metadata.
|
||||||
|
- `lib/fs.write_shell_script` for file system operations.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Adopted `src` directory layout according to standard Python packaging.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- None (initial release).
|
||||||
@@ -1,39 +1,102 @@
|
|||||||
# VidEnc
|
# VidEnc: Video Encoding with OMDB Metadata
|
||||||
|
|
||||||
This is a command-line application that analyzes video files using `ffprobe` to extract stream information (video, audio, subtitles). It then uses the OMDb API to fetch additional movie details such as title and release date, given an IMDB ID.
|
VidEnc streamlines complex video transcoding workflows into a single CLI command by generating a portable FFmpeg-based shell script that:
|
||||||
|
- Converts video streams to AV1 for maximum compression efficiency
|
||||||
|
- Encodes audio tracks to Opus for reduced file size without sacrificing quality
|
||||||
|
- Adjusts anamorphic sources to a correct 1:1 sample aspect ratio automatically
|
||||||
|
- Retrieves and embeds movie title and release date from OMDB using your IMDb ID
|
||||||
|
- Strips all other metadata to produce a clean, optimized output
|
||||||
|
|
||||||
## Workflow
|
Designed for media archivists, home theater enthusiasts, and content creators, VidEnc offers customizable presets (CRF, film grain strength, speed), original media type tagging, optional subtitle handling, and shell completion support for a seamless CLI experience.
|
||||||
|
|
||||||
1. **Input:** The application takes a video file path as input, along with an optional IMDB ID and a flag to disable subtitle processing.
|
---
|
||||||
|
|
||||||
2. **FFprobe Analysis:** It runs `ffprobe` on the input video file to extract stream information in JSON format.
|
## 🚀 Features
|
||||||
|
|
||||||
3. **Stream Parsing:** The JSON output is parsed to identify video, audio, and subtitle streams.
|
- Encode video streams to AV1 and audio streams to Opus
|
||||||
|
- Convert anamorphic video to 1:1 pixel aspect ratio
|
||||||
|
- Fetch and inject movie title and release date from OMDB
|
||||||
|
- Strip all other metadata to produce a clean output
|
||||||
|
- Configurable presets, CRF, film grain, media type, and subtitle handling
|
||||||
|
- Automatic generation of a reusable `encode.sh` script
|
||||||
|
- Shell completion support
|
||||||
|
|
||||||
4. **OMDb API Lookup (Optional):** If an IMDB ID is provided, the application queries the OMDb API to retrieve movie title and release date.
|
## 🔮 Future Features
|
||||||
|
|
||||||
5. **Output:** Finally, it prints a command string based on the extracted information. This command string is likely intended for further video processing or encoding.
|
- Streamlit UI for interactive configuration and metadata preview.
|
||||||
- Every audio stream that is side loaded, must be converted to 5.1 channel layout with `channelmap=channel_layout=5.1`
|
|
||||||
|
|
||||||
## Usage
|
## 🔧 Prerequisites
|
||||||
|
|
||||||
|
- **Python** >= 3.12
|
||||||
|
- **FFmpeg** (including `ffprobe`) installed and available in your `PATH`
|
||||||
|
- **OMDB API key**: Sign up at https://www.omdbapi.com/ and set `API_KEY` as an environment variable or in a `.env` file
|
||||||
|
|
||||||
|
## ⚙️ Installation
|
||||||
|
|
||||||
|
1. Clone the repository (or navigate to your project folder):
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Kessinen/VidEnc.git
|
||||||
|
cd VidEnc
|
||||||
|
```
|
||||||
|
2. Install Python dependencies using **uv**:
|
||||||
|
```bash
|
||||||
|
uv install
|
||||||
|
```
|
||||||
|
3. Create a `.env` file with your OMDB API key:
|
||||||
|
```bash
|
||||||
|
echo "API_KEY=your_omdb_api_key" > .env
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Usage
|
||||||
|
|
||||||
|
### Generate the encoding script
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python src/app.py <video_file> [-i <imdb_id>] [-ns]
|
# via uv:
|
||||||
|
uv run src/app.py path/to/video.mp4 --imdb tt1234567
|
||||||
|
|
||||||
|
# or directly:
|
||||||
|
python src/app.py path/to/video.mp4 -i tt1234567
|
||||||
```
|
```
|
||||||
|
|
||||||
* `<video_file>`: Path to the video file.
|
This will probe the input, fetch metadata, and write an executable `encode.sh` next to your video.
|
||||||
* `-i <imdb_id>`: (Optional) IMDB ID of the movie.
|
|
||||||
* `-ns`: (Optional) Flag to disable subtitle processing.
|
|
||||||
|
|
||||||
|
### Execute the encoding script
|
||||||
## Example
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python src/app.py my_movie.mp4 -i tt1234567
|
bash encode.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This would analyze `my_movie.mp4`, fetch details from OMDb using IMDB ID `tt1234567`, and print a command string.
|
## ⚙️ CLI Options
|
||||||
|
|
||||||
## Unit Tests
|
| Option | Description | Default |
|
||||||
|
|-------------------------------|----------------------------------------------------------------------|-------------|
|
||||||
|
| -ns, --nosubtitles | Disable subtitle processing | false |
|
||||||
|
| -p, --preset INT | Encoding preset (1–10 scale; higher = faster, lower quality) | 2 |
|
||||||
|
| -c, --crf INT | Constant Rate Factor for video quality | 30 |
|
||||||
|
| -o, --original-media-type | Source media type (DVD, BluRay, TVRip, WebRip, Other) | DVD |
|
||||||
|
| -fg, --film-grain INT | Film grain strength | 20 |
|
||||||
|
| --install-completion | Install shell completion for your current shell | — |
|
||||||
|
| --show-completion | Display shell completion script for customization | — |
|
||||||
|
|
||||||
There are no unit tests included in this project.
|
```bash
|
||||||
|
# Install bash completion (example):
|
||||||
|
python src/app.py --install-completion
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 How It Works
|
||||||
|
|
||||||
|
1. **Probe** the input file with `ffprobe` to collect stream data.
|
||||||
|
2. **Parse** video, audio, and subtitle streams into a structured model.
|
||||||
|
3. **Fetch** title and release date from OMDB using the provided IMDb ID.
|
||||||
|
4. **Configure** each video stream with presets, CRF, film grain, and media type.
|
||||||
|
5. **Generate** a single FFmpeg command to re-encode streams, convert SAR, embed metadata, and strip all other metadata.
|
||||||
|
6. **Write** the command into `encode.sh`, prompting before overwrite if it exists.
|
||||||
|
|
||||||
|
## 💡 Contributing
|
||||||
|
|
||||||
|
Contributions, issues, and feature requests are welcome! Feel free to open a PR or issue in the [GitHub repository](https://github.com/Kessinen/VidEnc).
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License. See [LICENSE](LICENSE) for details.
|
||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "videnc-reeffed"
|
name = "videnc-reeffed"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
description = "Add your description here"
|
description = "Add your description here"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
@@ -10,4 +10,5 @@ dependencies = [
|
|||||||
"python-dotenv>=1.0.1",
|
"python-dotenv>=1.0.1",
|
||||||
"requests>=2.32.3",
|
"requests>=2.32.3",
|
||||||
"rich>=13.9.4",
|
"rich>=13.9.4",
|
||||||
|
"typer>=0.15.2",
|
||||||
]
|
]
|
||||||
|
|||||||
+77
-28
@@ -1,13 +1,15 @@
|
|||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
import click
|
import typer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
# from rich import print
|
import re
|
||||||
|
import click
|
||||||
|
|
||||||
from models.streams import VideoFile, VideoStream, AudioStream, SubtitleStream
|
from models.streams import VideoFile, VideoStream, AudioStream, SubtitleStream, OriginalMediaType
|
||||||
from movie_details import get_movie_details
|
from movie_details import get_movie_details
|
||||||
from lib.fs import write_shell_script
|
from lib.fs import write_shell_script
|
||||||
|
|
||||||
|
app = typer.Typer()
|
||||||
|
|
||||||
def parse_streams(stream_data: dict, VideoFile: VideoFile) -> VideoFile:
|
def parse_streams(stream_data: dict, VideoFile: VideoFile) -> VideoFile:
|
||||||
for stream in stream_data["streams"]:
|
for stream in stream_data["streams"]:
|
||||||
@@ -23,36 +25,83 @@ def parse_streams(stream_data: dict, VideoFile: VideoFile) -> VideoFile:
|
|||||||
return VideoFile
|
return VideoFile
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
def validate_imdb(ctx: click.Context, param: click.Parameter, value: str) -> str:
|
||||||
@click.argument("filename", type=click.Path(exists=True))
|
"""Validate that an IMDb ID matches 'tt' followed by 7-8 digits."""
|
||||||
@click.option("-i", "--imdb", type=str, help="IMDB ID")
|
if value is None:
|
||||||
@click.option("-ns", "--nosubtitles", is_flag=True, help="No subtitles")
|
return value
|
||||||
def main(filename: Path, imdb: str, nosubtitles: bool):
|
if not re.match(r"^tt\d{7,8}$", value):
|
||||||
"""Run ffprobe on a file and print the output in JSON format."""
|
raise click.BadParameter("IMDb ID must be 'tt' followed by 7-8 digits (e.g. tt1234567)")
|
||||||
video_details = VideoFile(
|
return value
|
||||||
imdb=imdb, video_path=filename, allow_subtitle=not nosubtitles
|
|
||||||
)
|
|
||||||
video_details = get_movie_details(video_details)
|
|
||||||
|
|
||||||
|
|
||||||
|
# Helper functions for SOLID refactoring
|
||||||
|
def run_ffprobe(filename: Path) -> dict:
|
||||||
|
"""Execute ffprobe and return parsed JSON data."""
|
||||||
cmd = [
|
cmd = [
|
||||||
"ffprobe",
|
"ffprobe", "-v", "quiet",
|
||||||
"-v",
|
"-print_format", "json",
|
||||||
"quiet",
|
"-show_format", "-show_streams",
|
||||||
"-print_format",
|
|
||||||
"json",
|
|
||||||
"-show_format",
|
|
||||||
"-show_streams",
|
|
||||||
filename,
|
filename,
|
||||||
]
|
]
|
||||||
result = subprocess.run(cmd, capture_output=True, check=True)
|
try:
|
||||||
data = parse_streams(json.loads(result.stdout), video_details)
|
result = subprocess.run(cmd, capture_output=True, check=True)
|
||||||
export_data = " ".join(data.cmd())
|
except FileNotFoundError:
|
||||||
export_path = Path(filename).parent / "encode.sh"
|
typer.secho("Error: ffprobe not found. Please install FFmpeg.", fg="red", err=True)
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
typer.secho("Error: ffprobe failed to probe the file.", fg="red", err=True)
|
||||||
|
error_output = e.stderr.decode() if e.stderr else str(e)
|
||||||
|
typer.secho(error_output, fg="red", err=True)
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
def prepare_video_file(filename: Path, imdb: str, nosubtitles: bool) -> VideoFile:
|
||||||
|
"""Initialize VideoFile with metadata."""
|
||||||
|
return VideoFile(imdb=imdb, video_path=filename, allow_subtitle=not nosubtitles)
|
||||||
|
|
||||||
|
def apply_stream_settings(video_details: VideoFile, preset: int, crf: int, original_media_type: OriginalMediaType, film_grain: int) -> VideoFile:
|
||||||
|
"""Apply encoding settings to video streams."""
|
||||||
|
video_details.original_media_type = original_media_type
|
||||||
|
for stream in video_details.video_streams:
|
||||||
|
stream.preset = preset
|
||||||
|
stream.crf = crf
|
||||||
|
stream.original_media_type = original_media_type
|
||||||
|
stream.film_grain = film_grain
|
||||||
|
return video_details
|
||||||
|
|
||||||
|
def confirm_and_write_script(export_data: str, export_path: Path):
|
||||||
|
"""Confirm overwrite and write shell script."""
|
||||||
|
if export_path.exists():
|
||||||
|
if not typer.confirm(f"'{export_path}' already exists. Overwrite? "):
|
||||||
|
typer.echo("Aborting.")
|
||||||
|
raise typer.Exit()
|
||||||
|
export_path.unlink()
|
||||||
write_shell_script(export_data, export_path)
|
write_shell_script(export_data, export_path)
|
||||||
print(Path(filename).parent)
|
|
||||||
print(" ".join(data.cmd()))
|
@app.command()
|
||||||
# json.dump(data, sys.stdout, indent=4)
|
def main(
|
||||||
|
filename: Path = typer.Argument(..., exists=True, help="Input video file"),
|
||||||
|
imdb: str = typer.Option(None, "-i", "--imdb", callback=validate_imdb, help="IMDB ID"),
|
||||||
|
nosubtitles: bool = typer.Option(False, "-ns", "--nosubtitles", help="Remove subtitles"),
|
||||||
|
preset: int = typer.Option(2, "-p", "--preset", help="Encoding preset"),
|
||||||
|
crf: int = typer.Option(30, "-c", "--crf", help="CRF value"),
|
||||||
|
original_media_type: OriginalMediaType = typer.Option(OriginalMediaType.DVD, "-o", "--original-media-type", case_sensitive=False, help="Source of the original media."),
|
||||||
|
film_grain: int = typer.Option(20, "-fg", "--film-grain", help="Film grain amount (default: 20)")
|
||||||
|
):
|
||||||
|
video_details = prepare_video_file(filename, imdb, nosubtitles)
|
||||||
|
video_details = get_movie_details(video_details)
|
||||||
|
|
||||||
|
stream_data = run_ffprobe(filename)
|
||||||
|
video_details = parse_streams(stream_data, video_details)
|
||||||
|
video_details = apply_stream_settings(video_details, preset, crf, original_media_type, film_grain)
|
||||||
|
|
||||||
|
export_data = " ".join(video_details.cmd())
|
||||||
|
export_path = filename.parent / "encode.sh"
|
||||||
|
confirm_and_write_script(export_data, export_path)
|
||||||
|
|
||||||
|
typer.echo(str(filename.parent))
|
||||||
|
typer.echo(export_data)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
app()
|
||||||
|
|||||||
+71
-49
@@ -1,15 +1,22 @@
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import Optional
|
from enum import Enum
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
|
||||||
from lib.fs import sanitize_filename
|
from lib.fs import sanitize_filename
|
||||||
|
|
||||||
|
class OriginalMediaType(str, Enum):
|
||||||
|
DVD = "DVD"
|
||||||
|
BluRay = "BluRay"
|
||||||
|
TVRip = "TVRip"
|
||||||
|
WebRip = "WebRip"
|
||||||
|
Other = "Other"
|
||||||
|
|
||||||
|
|
||||||
class Tags(BaseModel):
|
class Tags(BaseModel):
|
||||||
title: Optional[str] = Field(None)
|
title: str | None = Field(None)
|
||||||
language: Optional[str] = Field(None)
|
language: str | None = Field(None)
|
||||||
|
|
||||||
|
|
||||||
class Disposition(BaseModel):
|
class Disposition(BaseModel):
|
||||||
@@ -18,35 +25,46 @@ class Disposition(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class VideoStream(BaseModel):
|
class VideoStream(BaseModel):
|
||||||
index: int = Field(None)
|
index: int | None = Field(None)
|
||||||
width: int = Field(None)
|
width: int | None = Field(None)
|
||||||
height: int = Field(None)
|
height: int | None = Field(None)
|
||||||
avg_frame_rate: str = Field(None, alias="avg_frame_rate")
|
avg_frame_rate: str | None = Field(None, alias="avg_frame_rate")
|
||||||
codec_name: str = Field(None)
|
codec_name: str | None = Field(None)
|
||||||
disposition: Disposition
|
disposition: Disposition
|
||||||
tags: Tags
|
tags: Tags | None = Field(None)
|
||||||
|
preset: int = Field(2)
|
||||||
|
crf: int = Field(30)
|
||||||
|
original_media_type: str | None = Field(None)
|
||||||
|
film_grain: int = Field(20)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def fps(self) -> float:
|
def fps(self) -> float:
|
||||||
frame_numbers = [int(x) for x in self.avg_frame_rate.split("/")]
|
"""Calculate frames per second from avg_frame_rate string."""
|
||||||
return frame_numbers[0] / frame_numbers[1]
|
try:
|
||||||
|
if not self.avg_frame_rate:
|
||||||
|
return 0.0
|
||||||
|
num, denom = (int(x) for x in self.avg_frame_rate.split("/"))
|
||||||
|
return num / denom if denom else 0.0
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
def cmd_flags(self) -> list[str]:
|
def cmd_flags(self) -> list[str]:
|
||||||
|
"""Generate ffmpeg command flags for this video stream."""
|
||||||
return [
|
return [
|
||||||
"-map",
|
"-map",
|
||||||
f"0:{self.index}",
|
f"0:{self.index}",
|
||||||
"-c:v",
|
"-c:v",
|
||||||
"libsvtav1",
|
"libsvtav1",
|
||||||
"-crf",
|
"-crf",
|
||||||
"30",
|
str(self.crf),
|
||||||
"-preset",
|
"-preset",
|
||||||
"6",
|
str(self.preset),
|
||||||
"-svtav1-params",
|
"-svtav1-params",
|
||||||
"tune=0:film-grain=20:scd=1",
|
f"tune=0:film-grain={self.film_grain}:scd=1",
|
||||||
"-vf",
|
"-vf",
|
||||||
"'scale=iw*sar:ih,setsar=1,scale=-2:ih:lanczos'",
|
"'scale=iw*sar:ih,setsar=1,scale=-2:ih:lanczos'",
|
||||||
"-g",
|
"-g",
|
||||||
f"{round(self.fps) * 5}",
|
str(round(self.fps) * 5),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -56,23 +74,27 @@ class AudioStream(BaseModel):
|
|||||||
sample_fmt: str
|
sample_fmt: str
|
||||||
channel_layout: str
|
channel_layout: str
|
||||||
disposition: Disposition
|
disposition: Disposition
|
||||||
tags: Tags
|
tags: Tags | None = Field(None)
|
||||||
|
|
||||||
def cmd_flags(self, index: int) -> list[str]:
|
def cmd_flags(self, index: int) -> list[str]:
|
||||||
|
"""Generate ffmpeg command flags for this audio stream."""
|
||||||
side_loaded = []
|
side_loaded = []
|
||||||
|
if "side" in self.channel_layout:
|
||||||
|
side_loaded = [f"-filter:a:{index}", "'channelmap=channel_layout=5.1'"]
|
||||||
default_disposition = (
|
default_disposition = (
|
||||||
[f"-disposition:a:{index}", "default"] if self.disposition.default else []
|
[f"-disposition:a:{index}", "default"] if self.disposition.default else []
|
||||||
)
|
)
|
||||||
if "side" in self.channel_layout:
|
language = getattr(self.tags, 'language', None) if self.tags else None
|
||||||
side_loaded = [f"-filter:a:{index}", "'channelmap=channel_layout=5.1'"]
|
title = getattr(self.tags, 'title', None) if self.tags else None
|
||||||
|
meta_flags = []
|
||||||
|
if language:
|
||||||
|
meta_flags += [f"-metadata:s:a:{index}", f"language={language}"]
|
||||||
|
if title:
|
||||||
|
meta_flags += [f"-metadata:s:a:{index}", f"title='{title}'"]
|
||||||
return (
|
return (
|
||||||
["-map", f"0:{self.index}", f"-c:a:{index}", "libopus"]
|
["-map", f"0:{self.index}", f"-c:a:{index}", "libopus"]
|
||||||
+ side_loaded
|
+ side_loaded
|
||||||
+ [f"-metadata:s:a:{index}", f"language={self.tags.language}"]
|
+ meta_flags
|
||||||
+ [
|
|
||||||
f"-metadata:s:a:{index}",
|
|
||||||
f"title='{self.tags.title}'",
|
|
||||||
]
|
|
||||||
+ default_disposition
|
+ default_disposition
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -81,26 +103,30 @@ class SubtitleStream(BaseModel):
|
|||||||
index: int
|
index: int
|
||||||
codec_name: str
|
codec_name: str
|
||||||
disposition: Disposition
|
disposition: Disposition
|
||||||
tags: Tags
|
tags: Tags | None = Field(None)
|
||||||
|
|
||||||
def cmd_flags(self, index: int) -> list[str]:
|
def cmd_flags(self, index: int) -> list[str]:
|
||||||
return ["-map", f"0:s:{index}", f"-c:s:{index}", "copy"] + [
|
"""Generate ffmpeg command flags for this subtitle stream."""
|
||||||
f"-metadata:s:s:{index}",
|
language = getattr(self.tags, 'language', None) if self.tags else None
|
||||||
f"language={self.tags.language}",
|
meta_flags = []
|
||||||
]
|
if language:
|
||||||
|
meta_flags += [f"-metadata:s:s:{index}", f"language={language}"]
|
||||||
|
return ["-map", f"0:s:{index}", f"-c:s:{index}", "copy"] + meta_flags
|
||||||
|
|
||||||
|
|
||||||
class VideoFile(BaseModel):
|
class VideoFile(BaseModel):
|
||||||
video_path: Path
|
video_path: Path
|
||||||
title: str = Field(None)
|
title: str | None = Field(None)
|
||||||
date_released: Optional[datetime] = Field(None)
|
date_released: datetime | None = Field(None)
|
||||||
imdb: Optional[str] = Field(None)
|
imdb: str | None = Field(None)
|
||||||
video_streams: list[VideoStream] = Field([])
|
video_streams: list[VideoStream] = Field(default_factory=list)
|
||||||
audio_streams: list[AudioStream] = Field([])
|
audio_streams: list[AudioStream] = Field(default_factory=list)
|
||||||
subtitle_streams: list[SubtitleStream] = Field([])
|
subtitle_streams: list[SubtitleStream] = Field(default_factory=list)
|
||||||
allow_subtitle: bool = Field(True)
|
allow_subtitle: bool = Field(True)
|
||||||
|
original_media_type: OriginalMediaType = Field(OriginalMediaType.DVD)
|
||||||
|
|
||||||
def cmd(self) -> list[str]:
|
def cmd(self) -> list[str]:
|
||||||
|
"""Generate the full ffmpeg command for this video file."""
|
||||||
cmd = [
|
cmd = [
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-hide_banner",
|
"-hide_banner",
|
||||||
@@ -110,17 +136,15 @@ class VideoFile(BaseModel):
|
|||||||
f"'{str(self.video_path.absolute())}'",
|
f"'{str(self.video_path.absolute())}'",
|
||||||
"-map_metadata",
|
"-map_metadata",
|
||||||
"-1",
|
"-1",
|
||||||
"-metadata",
|
|
||||||
f"title='{self.title}'",
|
|
||||||
"-metadata",
|
|
||||||
f"date_released={self.date_released.strftime('%Y-%m-%d')}",
|
|
||||||
"-metadata",
|
|
||||||
f"imdb={self.imdb}",
|
|
||||||
"-metadata",
|
|
||||||
"original_media_type=DVD",
|
|
||||||
"-pix_fmt",
|
|
||||||
"yuv420p10le",
|
|
||||||
]
|
]
|
||||||
|
if self.title:
|
||||||
|
cmd += ["-metadata", f"title='{self.title}'"]
|
||||||
|
if self.date_released:
|
||||||
|
cmd += ["-metadata", f"date_released={self.date_released.strftime('%Y-%m-%d')}"]
|
||||||
|
if self.imdb:
|
||||||
|
cmd += ["-metadata", f"imdb={self.imdb}"]
|
||||||
|
cmd += ["-metadata", f"original_media_type={self.original_media_type.value}"]
|
||||||
|
cmd += ["-pix_fmt", "yuv420p10le"]
|
||||||
for stream in self.video_streams:
|
for stream in self.video_streams:
|
||||||
cmd.extend(stream.cmd_flags())
|
cmd.extend(stream.cmd_flags())
|
||||||
for i, stream in enumerate(self.audio_streams):
|
for i, stream in enumerate(self.audio_streams):
|
||||||
@@ -128,12 +152,10 @@ class VideoFile(BaseModel):
|
|||||||
if self.allow_subtitle:
|
if self.allow_subtitle:
|
||||||
for i, stream in enumerate(self.subtitle_streams):
|
for i, stream in enumerate(self.subtitle_streams):
|
||||||
cmd.extend(stream.cmd_flags(i))
|
cmd.extend(stream.cmd_flags(i))
|
||||||
|
year = self.date_released.year if self.date_released else "unknown"
|
||||||
output_file = (
|
output_file = (
|
||||||
self.video_path.absolute().parent
|
self.video_path.absolute().parent
|
||||||
/ f"{sanitize_filename(self.title)}.{self.date_released.year}.mkv"
|
/ f"{sanitize_filename(self.title or 'untitled')}.{year}.mkv"
|
||||||
)
|
)
|
||||||
|
cmd.append(f"'{str(output_file)}'")
|
||||||
cmd.extend([f"'{str(output_file)}'"])
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
version = 1
|
version = 1
|
||||||
|
revision = 1
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"python_full_version < '3.13'",
|
"python_full_version < '3.13'",
|
||||||
@@ -67,7 +68,7 @@ name = "click"
|
|||||||
version = "8.1.7"
|
version = "8.1.7"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "platform_system == 'Windows'" },
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 }
|
sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -208,6 +209,30 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 },
|
{ url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shellingham"
|
||||||
|
version = "1.5.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typer"
|
||||||
|
version = "0.15.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "rich" },
|
||||||
|
{ name = "shellingham" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8b/6f/3991f0f1c7fcb2df31aef28e0594d8d54b05393a0e4e34c65e475c2a5d41/typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5", size = 100711 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc", size = 45061 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.12.2"
|
version = "4.12.2"
|
||||||
@@ -236,6 +261,7 @@ dependencies = [
|
|||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
|
{ name = "typer" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
@@ -245,4 +271,5 @@ requires-dist = [
|
|||||||
{ name = "python-dotenv", specifier = ">=1.0.1" },
|
{ name = "python-dotenv", specifier = ">=1.0.1" },
|
||||||
{ name = "requests", specifier = ">=2.32.3" },
|
{ name = "requests", specifier = ">=2.32.3" },
|
||||||
{ name = "rich", specifier = ">=13.9.4" },
|
{ name = "rich", specifier = ">=13.9.4" },
|
||||||
|
{ name = "typer", specifier = ">=0.15.2" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user