Compare commits

...
3 Commits
Author SHA1 Message Date
Esa Kataja aa47fd5ddb Add jinja templating to script writer 2025-07-02 20:49:47 +03:00
Esa Kataja cefd878059 Add roadmap 2025-07-01 23:01:39 +03:00
Esa Kataja de52d5a660 Add bwdif deinterlacing 2025-07-01 22:57:50 +03:00
6 changed files with 279 additions and 102 deletions
+1
View File
@@ -6,6 +6,7 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"httpx>=0.28.1",
"jinja2>=3.1.6",
"pydantic>=2.9.2",
"python-dotenv>=1.0.1",
"selectolax>=0.3.31",
+85
View File
@@ -0,0 +1,85 @@
# Development Roadmap
This document outlines the planned development steps for the VidEnc application.
## Phase 1: Code Refactoring
The first priority is to refactor the existing codebase to improve its structure, maintainability, and readability. This will provide a solid foundation for future feature development.
### Detailed Plan for Phase 1
This phase will be broken down into the following steps:
1. **Centralize Encoding Settings:**
* **Create `EncodingSettings` Model:** In a new file, `src/models/settings.py`, create a new Pydantic model named `EncodingSettings`.
* **Define Fields:** This model will have the following fields, corresponding to the Typer options in `app.py`:
* `preset: int`
* `crf: int`
* `original_media_type: OriginalMediaType`
* `film_grain: int`
* **Integrate into `app.py`:**
* In the `main` function, create an instance of `EncodingSettings` by passing the values from the Typer options.
* The `apply_stream_settings` function will be updated to accept this `EncodingSettings` object instead of individual parameters, simplifying its signature.
2. **Refactor IMDb Search:**
* Create a Pydantic model, `IMDbSearchResult`, to represent a single search result (e.g., with `title`, `year`, `imdb_id`).
* Modify the `search_imdb` function in `src/lib/movie_details.py` to no longer print to the console. Instead, it will return a list of `IMDbSearchResult` objects.
* The interactive selection logic will be handled separately in `app.py` after calling the refactored `search_imdb`.
3. **[x] Implement Template-Based Command Generation:** The `ffmpeg` command is now generated using a Jinja2 template, separating the command logic from the Python code.
## Phase 2: Logging
Implement comprehensive logging using the `loguru` library. This will help with debugging and tracking the application's behavior during encoding jobs.
## Phase 3: Configuration File
Introduce a configuration file managed by the `pydantic-settings` package. This will allow loading settings from a file (e.g., `.env` or `config.toml`) and environment variables, making the application more flexible and easier to configure for different users.
## Phase 4: Enhanced Error Handling
Improve error handling throughout the application, especially for external interactions. This will make the application more robust and provide clearer feedback to the user when things go wrong. Key areas include:
- API interactions (e.g., invalid API keys, network errors, movies not found).
- `ffprobe` and `ffmpeg` command execution failures.
- File system operations (e.g., permission errors).
## Phase 5: Encoding Profiles
Introduce encoding profiles (e.g., `dvd`, `bluray`, `tvrip`) to simplify the user experience. These profiles will map to pre-defined sets of encoding parameters (`preset`, `crf`, `film_grain`, etc.), allowing users to choose a quality target without needing to know the underlying `ffmpeg` settings.
## Phase 6: Batch Processing
Implement a stateful batch processing system. This will involve creating a set of commands to manage an internal encoding queue:
- A command to process a video and add its generated `ffmpeg` command to the queue.
- A command to write all queued commands into a single, consolidated shell script.
- Commands to view and clear the queue.
This provides a more flexible and robust workflow for batch encoding.
## Future Goals
### Advanced Subtitle Handling
Implement a sophisticated subtitle processing pipeline to automatically convert bitmap-based subtitles (DVD VobSub, Blu-ray PGS) into the text-based SubRip (.srt) format. This is a significant research and development task that will be tackled after the initial roadmap is complete. It involves:
- Automating the OCR process for `dvd_sub` streams using tools like `mkvextract` and `vobsub2srt`.
- Researching and implementing a solution for the more complex `pgs` subtitle format, potentially using an OCR engine like Tesseract via a command-line tool.
### Direct Execution
Add an option to execute the generated `ffmpeg` command directly from the application. This will require research into the best way to run and monitor long-running subprocesses in Python, providing real-time feedback and progress to the user.
### GUI Development
Develop a graphical user interface (GUI) to make the application more accessible and user-friendly for a broader audience. This will be a significant undertaking and will be considered after the core CLI functionality is mature and stable. Research into the best GUI framework (e.g., Dear PyGui, CustomTkinter, PySide6) will be the first step.
### Deinterlacing
A `bwdif` deinterlacing filter is currently hardcoded in the video processing pipeline as a temporary solution.
**To-Do:**
- Implement detection for interlaced video streams (e.g., by checking the `field_order` property from `ffprobe` output).
- Apply the deinterlacing filter conditionally, only when interlaced content is detected, to avoid unnecessary processing on progressive sources.
---
*This roadmap will be updated as development progresses.*
+76 -17
View File
@@ -5,12 +5,20 @@ from pathlib import Path
import re
import click
from models.streams import VideoFile, VideoStream, AudioStream, SubtitleStream, OriginalMediaType
import jinja2
from models.streams import (
VideoFile,
VideoStream,
AudioStream,
SubtitleStream,
OriginalMediaType,
)
from lib.movie_details import get_movie_details, search_imdb
from lib.fs import write_shell_script
from lib.fs import write_shell_script, sanitize_filename
app = typer.Typer()
def parse_streams(stream_data: dict, VideoFile: VideoFile) -> VideoFile:
for stream in stream_data["streams"]:
if (
@@ -30,7 +38,9 @@ def validate_imdb(value: str) -> str:
if value is None:
return value
if not re.match(r"^tt\d{7,8}$", value):
raise click.BadParameter("IMDb ID must be 'tt' followed by 7-8 digits (e.g. tt1234567)")
raise click.BadParameter(
"IMDb ID must be 'tt' followed by 7-8 digits (e.g. tt1234567)"
)
return value
@@ -38,15 +48,21 @@ def validate_imdb(value: str) -> str:
def run_ffprobe(filename: Path) -> dict:
"""Execute ffprobe and return parsed JSON data."""
cmd = [
"ffprobe", "-v", "quiet",
"-print_format", "json",
"-show_format", "-show_streams",
"ffprobe",
"-v",
"quiet",
"-print_format",
"json",
"-show_format",
"-show_streams",
filename,
]
try:
result = subprocess.run(cmd, capture_output=True, check=True)
except FileNotFoundError:
typer.secho("Error: ffprobe not found. Please install FFmpeg.", fg="red", err=True)
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)
@@ -55,11 +71,19 @@ def run_ffprobe(filename: Path) -> dict:
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:
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:
@@ -69,6 +93,7 @@ def apply_stream_settings(video_details: VideoFile, preset: int, crf: int, origi
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():
@@ -79,35 +104,69 @@ def confirm_and_write_script(export_data: str, export_path: Path):
write_shell_script(export_data, export_path)
@app.command()
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"),
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)"),
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)
video_details = apply_stream_settings(
video_details, preset, crf, original_media_type, film_grain
)
export_data = " ".join(video_details.cmd())
# Create the output filename
year = (
video_details.date_released.year if video_details.date_released else "unknown"
)
output_filename = (
f"{sanitize_filename(video_details.title or 'untitled')}.{year}.mkv"
)
output_file_path = video_details.video_path.absolute().parent / output_filename
# Load Jinja2 template and render it
template_loader = jinja2.FileSystemLoader(
searchpath=Path(__file__).parent / "templates"
)
template_env = jinja2.Environment(loader=template_loader)
template = template_env.get_template("ffmpeg_command.sh.j2")
export_data = template.render(
video_file=video_details, output_file=str(output_file_path)
)
# Write the script
export_path = filename.parent / "encode.sh"
confirm_and_write_script(export_data, export_path)
typer.echo(str(filename.parent))
typer.echo(export_data)
# typer.echo(str(filename.parent))
# typer.echo(export_data)
@app.command()
def search_web(search_string: str):
imdb_id = search_imdb(search_string)
typer.echo(imdb_id)
if __name__ == "__main__":
app()
-85
View File
@@ -4,7 +4,6 @@ from datetime import datetime
from pathlib import Path
from functools import cached_property
from lib.fs import sanitize_filename
class OriginalMediaType(str, Enum):
DVD = "DVD"
@@ -48,25 +47,6 @@ class VideoStream(BaseModel):
except Exception:
return 0.0
def cmd_flags(self) -> list[str]:
"""Generate ffmpeg command flags for this video stream."""
return [
"-map",
f"0:{self.index}",
"-c:v",
"libsvtav1",
"-crf",
str(self.crf),
"-preset",
str(self.preset),
"-svtav1-params",
f"tune=0:film-grain={self.film_grain}:scd=1",
"-vf",
"'scale=iw*sar:ih,setsar=1,scale=-2:ih:lanczos'",
"-g",
str(round(self.fps) * 5),
]
class AudioStream(BaseModel):
index: int
@@ -76,28 +56,6 @@ class AudioStream(BaseModel):
disposition: Disposition
tags: Tags | None = Field(None)
def cmd_flags(self, index: int) -> list[str]:
"""Generate ffmpeg command flags for this audio stream."""
side_loaded = []
if "side" in self.channel_layout:
side_loaded = [f"-filter:a:{index}", "'channelmap=channel_layout=5.1'"]
default_disposition = (
[f"-disposition:a:{index}", "default"] if self.disposition.default else []
)
language = getattr(self.tags, 'language', None) if self.tags else None
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 (
["-map", f"0:{self.index}", f"-c:a:{index}", "libopus"]
+ side_loaded
+ meta_flags
+ default_disposition
)
class SubtitleStream(BaseModel):
index: int
@@ -105,14 +63,6 @@ class SubtitleStream(BaseModel):
disposition: Disposition
tags: Tags | None = Field(None)
def cmd_flags(self, index: int) -> list[str]:
"""Generate ffmpeg command flags for this subtitle stream."""
language = getattr(self.tags, 'language', None) if self.tags else None
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):
video_path: Path
@@ -124,38 +74,3 @@ class VideoFile(BaseModel):
subtitle_streams: list[SubtitleStream] = Field(default_factory=list)
allow_subtitle: bool = Field(True)
original_media_type: OriginalMediaType = Field(OriginalMediaType.DVD)
def cmd(self) -> list[str]:
"""Generate the full ffmpeg command for this video file."""
cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"error",
"-i",
f"'{str(self.video_path.absolute())}'",
"-map_metadata",
"-1",
]
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:
cmd.extend(stream.cmd_flags())
for i, stream in enumerate(self.audio_streams):
cmd.extend(stream.cmd_flags(i))
if self.allow_subtitle:
for i, stream in enumerate(self.subtitle_streams):
cmd.extend(stream.cmd_flags(i))
year = self.date_released.year if self.date_released else "unknown"
output_file = (
self.video_path.absolute().parent
/ f"{sanitize_filename(self.title or 'untitled')}.{year}.mkv"
)
cmd.append(f"'{str(output_file)}'")
return cmd
+65
View File
@@ -0,0 +1,65 @@
#!/bin/bash
set -euo pipefail
# This script is a template. It needs to be rendered with:
# - video_file: A VideoFile object from models.streams.
# - output_file: The absolute path to the output file.
# By default, set log level to error.
LOG_FLAGS=("-loglevel" "error")
# If -v or --verbose is passed as the first argument, remove the log flags
# to use ffmpeg's default log level (usually 'info').
if [[ "$1" == "-v" || "$1" == "--verbose" ]]; then
LOG_FLAGS=()
fi
ffmpeg -hide_banner "${LOG_FLAGS[@]}" -i '{{ video_file.video_path.absolute() }}' \
-map_metadata -1 \
{%- if video_file.title %}
-metadata title='{{ video_file.title }}' \
{%- endif %}
{%- if video_file.date_released %}
-metadata date_released='{{ video_file.date_released.strftime("%Y-%m-%d") }}' \
{%- endif %}
{%- if video_file.imdb %}
-metadata imdb='{{ video_file.imdb }}' \
{%- endif %}
{%- if video_file.original_media_type %}
-metadata original_media_type='{{ video_file.original_media_type.value }}' \
{%- endif %}
-pix_fmt yuv420p10le \
{%- for stream in video_file.video_streams %}
-map 0:{{ stream.index }} \
-c:v libsvtav1 \
-crf {{ stream.crf }} \
-preset {{ stream.preset }} \
-svtav1-params tune=0:film-grain={{ stream.film_grain }}:scd=1 \
-vf 'bwdif=mode=0,scale=iw*sar:ih,setsar=1,scale=-2:ih:lanczos' \
-g {{ (stream.fps | round * 5) | int }} \
{%- endfor %}
{%- for stream in video_file.audio_streams %}
-map 0:{{ stream.index }} -c:a:{{ loop.index0 }} libopus \
{%- if 'side' in stream.channel_layout %}
-filter:a:{{ loop.index0 }} 'channelmap=channel_layout=5.1' \
{%- endif %}
{%- if stream.tags and stream.tags.language %}
-metadata:s:a:{{ loop.index0 }} language={{ stream.tags.language }} \
{%- endif %}
{%- if stream.tags and stream.tags.title %}
-metadata:s:a:{{ loop.index0 }} title='{{ stream.tags.title }}' \
{%- endif %}
{%- if stream.disposition.default %}
-disposition:a:{{ loop.index0 }} default \
{%- endif %}
{%- endfor %}
{%- if video_file.allow_subtitle %}
{%- for stream in video_file.subtitle_streams %}
-map 0:{{ stream.index }} -c:s:{{ loop.index0 }} copy \
{%- if stream.tags and stream.tags.language %}
-metadata:s:s:{{ loop.index0 }} language={{ stream.tags.language }} \
{%- endif %}
{%- endfor %}
{%- endif %}
'{{ output_file }}'
Generated
+52
View File
@@ -105,6 +105,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
@@ -117,6 +129,44 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" },
{ url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" },
{ url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" },
{ url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" },
{ url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" },
{ url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" },
{ url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" },
{ url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" },
{ url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" },
{ url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" },
{ url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" },
{ url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" },
{ url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" },
{ url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" },
{ url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" },
{ url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" },
{ url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" },
{ url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" },
{ url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" },
{ url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" },
{ url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" },
{ url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" },
{ url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" },
{ url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" },
{ url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" },
{ url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" },
{ url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" },
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
@@ -274,6 +324,7 @@ version = "1.0.1"
source = { virtual = "." }
dependencies = [
{ name = "httpx" },
{ name = "jinja2" },
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "selectolax" },
@@ -283,6 +334,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "jinja2", specifier = ">=3.1.6" },
{ name = "pydantic", specifier = ">=2.9.2" },
{ name = "python-dotenv", specifier = ">=1.0.1" },
{ name = "selectolax", specifier = ">=0.3.31" },