Compare commits
6
Commits
9cd7fb204e
...
0c71d3c58b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c71d3c58b | ||
|
|
5ec816c163 | ||
|
|
c7a381c4b9 | ||
|
|
a30791a036 | ||
|
|
55cd3e7ec0 | ||
|
|
5d5371c991 |
@@ -1,39 +1,33 @@
|
|||||||
# VidEnc
|
# Video Encoding App
|
||||||
|
|
||||||
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.
|
## Overview
|
||||||
|
This app takes a video file and an IMDb ID as input, generates a shell script to encode the video streams to AV1 and audio streams to Opus format, converts anamorphic video to SAR 1:1, and adds metadata from OMDB while stripping all other metadata.
|
||||||
|
|
||||||
## Workflow
|
## Input Requirements
|
||||||
|
1. **Video File**: A valid video file in a supported format.
|
||||||
|
2. **IMDb ID**: A valid IMDb ID to fetch metadata from OMDB.
|
||||||
|
|
||||||
1. **Input:** The application takes a video file path as input, along with an optional IMDB ID and a flag to disable subtitle processing.
|
## Optional CLI Arguments
|
||||||
|
1. **`-ns`/`--nosubtitles`**: Disables subtitle processing.
|
||||||
|
2. **`-p`/`--preset`**: Sets the encoding preset for the video. Default is `2`.
|
||||||
|
3. **`-c`/`--crf`**: Sets the CRF value for the video. Default is `30`.
|
||||||
|
4. **`-o`/`--original-media-type`**: Sets the ORIGINAL_MEDIA_TYPE metadata. Default is `DVD`.
|
||||||
|
|
||||||
2. **FFprobe Analysis:** It runs `ffprobe` on the input video file to extract stream information in JSON format.
|
## Output Details
|
||||||
|
The app generates a shell script that performs the following tasks:
|
||||||
|
1. Encodes the video to AV1 format.
|
||||||
|
2. Encodes the audio to Opus format.
|
||||||
|
3. Converts anamorphic video to SAR 1:1.
|
||||||
|
4. Adds metadata from OMDB using the provided IMDb ID.
|
||||||
|
5. Strips all other metadata from the video.
|
||||||
|
|
||||||
3. **Stream Parsing:** The JSON output is parsed to identify video, audio, and subtitle streams.
|
## Metadata Handling
|
||||||
|
- **Added Metadata**: Metadata fetched from OMDB using the IMDb ID.
|
||||||
|
- **Stripped Metadata**: All other metadata is removed from the video.
|
||||||
|
|
||||||
4. **OMDb API Lookup (Optional):** If an IMDB ID is provided, the application queries the OMDb API to retrieve movie title and release date.
|
## Anamorphic Video Conversion
|
||||||
|
Anamorphic videos are converted to SAR (Sample Aspect Ratio) 1:1 to ensure proper display on all devices.
|
||||||
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.
|
|
||||||
- Every audio stream that is side loaded, must be converted to 5.1 channel layout with `channelmap=channel_layout=5.1`
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
|
## Example Usage
|
||||||
```bash
|
```bash
|
||||||
python src/app.py <video_file> [-i <imdb_id>] [-ns]
|
./encode_video.sh input_video.mp4 -i tt1234567
|
||||||
```
|
|
||||||
|
|
||||||
* `<video_file>`: Path to the video file.
|
|
||||||
* `-i <imdb_id>`: (Optional) IMDB ID of the movie.
|
|
||||||
* `-ns`: (Optional) Flag to disable subtitle processing.
|
|
||||||
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python src/app.py my_movie.mp4 -i tt1234567
|
|
||||||
```
|
|
||||||
|
|
||||||
This would analyze `my_movie.mp4`, fetch details from OMDb using IMDB ID `tt1234567`, and print a command string.
|
|
||||||
|
|
||||||
## Unit Tests
|
|
||||||
|
|
||||||
There are no unit tests included in this project.
|
|
||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
+16
-13
@@ -1,13 +1,13 @@
|
|||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
import click
|
import typer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
# from rich import print
|
|
||||||
|
|
||||||
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,15 +23,16 @@ def parse_streams(stream_data: dict, VideoFile: VideoFile) -> VideoFile:
|
|||||||
return VideoFile
|
return VideoFile
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@app.command()
|
||||||
@click.argument("filename", type=click.Path(exists=True))
|
def main(
|
||||||
@click.option("-i", "--imdb", type=str, help="IMDB ID")
|
filename: Path = typer.Argument(..., exists=True, help="Input video file"),
|
||||||
@click.option("-ns", "--nosubtitles", is_flag=True, help="No subtitles")
|
imdb: str = typer.Option(None, "-i", "--imdb", help="IMDB ID"),
|
||||||
@click.option("-p", "--preset", type=int, default=2, help="Encoding preset (default: 2)")
|
nosubtitles: bool = typer.Option(False, "-ns", "--nosubtitles", help="Remove subtitles"),
|
||||||
@click.option("-c", "--crf", type=int, default=30, help="CRF value (default: 30)")
|
preset: int = typer.Option(2, "-p", "--preset", help="Encoding preset"),
|
||||||
@click.option('-o', '--original-media-type', default='DVD', help='Set the ORIGINAL_MEDIA_TYPE metadata')
|
crf: int = typer.Option(30, "-c", "--crf", help="CRF value"),
|
||||||
def main(filename: Path, imdb: str, nosubtitles: bool, preset: int, crf: int, original_media_type: str):
|
original_media_type: OriginalMediaType = typer.Option(OriginalMediaType.DVD, "-o", "--original-media-type", case_sensitive=False, help="Source of the original media."),
|
||||||
"""Run ffprobe on a file and print the output in JSON format."""
|
film_grain: int = typer.Option(20, "-fg", "--film-grain", help="Film grain amount (default: 20)")
|
||||||
|
):
|
||||||
video_details = VideoFile(
|
video_details = VideoFile(
|
||||||
imdb=imdb, video_path=filename, allow_subtitle=not nosubtitles
|
imdb=imdb, video_path=filename, allow_subtitle=not nosubtitles
|
||||||
)
|
)
|
||||||
@@ -49,10 +50,12 @@ def main(filename: Path, imdb: str, nosubtitles: bool, preset: int, crf: int, or
|
|||||||
]
|
]
|
||||||
result = subprocess.run(cmd, capture_output=True, check=True)
|
result = subprocess.run(cmd, capture_output=True, check=True)
|
||||||
data = parse_streams(json.loads(result.stdout), video_details)
|
data = parse_streams(json.loads(result.stdout), video_details)
|
||||||
|
data.original_media_type = original_media_type
|
||||||
for stream in data.video_streams:
|
for stream in data.video_streams:
|
||||||
stream.preset = preset
|
stream.preset = preset
|
||||||
stream.crf = crf
|
stream.crf = crf
|
||||||
stream.original_media_type = original_media_type
|
stream.original_media_type = original_media_type
|
||||||
|
stream.film_grain = film_grain
|
||||||
export_data = " ".join(data.cmd())
|
export_data = " ".join(data.cmd())
|
||||||
export_path = Path(filename).parent / "encode.sh"
|
export_path = Path(filename).parent / "encode.sh"
|
||||||
write_shell_script(export_data, export_path)
|
write_shell_script(export_data, export_path)
|
||||||
@@ -62,4 +65,4 @@ def main(filename: Path, imdb: str, nosubtitles: bool, preset: int, crf: int, or
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
app()
|
||||||
|
|||||||
+64
-44
@@ -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,22 +25,31 @@ 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 | None = Field(None)
|
tags: Tags | None = Field(None)
|
||||||
preset: int = Field(2)
|
preset: int = Field(2)
|
||||||
crf: int = Field(30)
|
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}",
|
||||||
@@ -44,11 +60,11 @@ class VideoStream(BaseModel):
|
|||||||
"-preset",
|
"-preset",
|
||||||
str(self.preset),
|
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),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -61,20 +77,24 @@ class AudioStream(BaseModel):
|
|||||||
tags: Tags | None = Field(None)
|
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
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,23 +106,27 @@ class SubtitleStream(BaseModel):
|
|||||||
tags: Tags | None = Field(None)
|
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",
|
||||||
@@ -112,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):
|
||||||
@@ -130,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