Coverage for src/netflix_open_content_helper/cli.py: 94%
87 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-07 20:27 -0700
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-07 20:27 -0700
1import subprocess
2import webbrowser
3from pathlib import Path
4from typing import Annotated, Optional
6import typer
7from rich.progress import track
9from netflix_open_content_helper import CONFIG, __version__
12def download_from_s3(
13 s3_uri: str, s3_path: str, dest_path: str = ".", dry_run: bool = False
14) -> None:
15 """
16 Download a file from AWS S3.
18 Args:
19 s3_uri (str): The base S3 URI.
20 s3_path (str): The specific path to the file in S3.
21 dest_path (str): The destination path for the downloaded file.
22 dry_run (bool): If true, show what would be done, but do not do it.
23 """
24 commands = [
25 "aws",
26 "s3",
27 "cp",
28 "--quiet",
29 "--no-sign-request",
30 f"{s3_uri}/{s3_path}",
31 dest_path,
32 ]
33 if dry_run:
34 print(f"dry-run: {' '.join(commands)}")
35 else:
36 subprocess.run(commands, check=True)
39def version_callback(value: bool) -> None:
40 """Display the version of the package."""
41 if value:
42 typer.echo(f"Netflix Open Content Helper, version {__version__}")
43 raise typer.Exit()
46app = typer.Typer()
49@app.callback()
50def common(
51 version: bool = typer.Option(
52 False,
53 "--version",
54 is_eager=True,
55 help="Show the version of the package.",
56 callback=version_callback,
57 ),
58) -> None:
59 """A helper suite for Netflix Open Content media."""
60 pass
63@app.command()
64def browse() -> None:
65 """
66 Open a web browser to the Netflix Open Content URL.
67 """
68 NETFLIX_OPEN_CONTENT_URL = CONFIG["netflix_open_content_url"]
69 # Check if the URL is configured
70 if not NETFLIX_OPEN_CONTENT_URL:
71 raise ValueError(
72 "Netflix Open Content URL is not configured. Check the config file."
73 )
74 # Check if the URL is valid
75 if not NETFLIX_OPEN_CONTENT_URL.startswith(("http://", "https://")):
76 raise ValueError(
77 f"Invalid URL format for url {NETFLIX_OPEN_CONTENT_URL}. Should start with 'http://' or 'https://'."
78 )
79 # Open the URL in the default web browser
80 # This will open the URL in a new tab if the browser is already open
81 # or in a new window if the browser is not open
82 # Note: This will not work in a headless environment
83 # such as a server without a GUI
84 # or in a terminal without a web browser
85 webbrowser.open_new(NETFLIX_OPEN_CONTENT_URL)
88@app.command()
89def download(
90 name: Annotated[
91 str, typer.Argument(help="The name of the project to download from.")
92 ],
93 frame_start: Annotated[
94 int,
95 typer.Option("--frame-start", "-fs", help="The start frame for the download."),
96 ] = 1,
97 frame_end: Annotated[
98 int, typer.Option("--frame-end", "-fe", help="The end frame for the download.")
99 ] = 1,
100 force: Annotated[
101 bool,
102 typer.Option(
103 "--force",
104 "-f",
105 help="Force download/overwrite of files that already exist.",
106 ),
107 ] = False,
108 dry_run: Annotated[
109 bool,
110 typer.Option(
111 "--dry-run",
112 "-n",
113 help="Show what would be done, but do not do it.",
114 ),
115 ] = False,
116 rename: Annotated[
117 Optional[str],
118 typer.Option(help="A new name for the downloaded frames. Ex. name.%04d.ext."),
119 ] = "",
120 renumber: Annotated[
121 Optional[int],
122 typer.Option(
123 help="A new start frame for the downloaded frames (with rename). Ex. 1001."
124 ),
125 ] = None,
126) -> None:
127 """Download frames from Netflix Open Content project NAME to the current directory."""
129 typer.echo(f"Downloading: {name} frames {frame_start}-{frame_end}")
130 # Validate the frame range
131 if frame_start < 1 or frame_end < 1:
132 raise ValueError(
133 f"Frame numbers ({frame_start}, {frame_end}) must be positive integers."
134 )
135 if frame_start > frame_end:
136 raise ValueError(
137 f"Start frame ({frame_start}) must be less than or equal to end frame ({frame_end})."
138 )
140 # Check if the AWS CLI is installed
141 test_commands = ["aws", "--version"]
142 try:
143 subprocess.run(test_commands, check=True, capture_output=True)
144 except subprocess.CalledProcessError as exc:
145 raise OSError(
146 "AWS CLI is not installed. Please install it to use this feature."
147 ) from exc
149 # Obtain the asset configuration, conform to lower-case name
150 assets = [d for d in CONFIG["assets"] if d["name"] == name.lower()]
151 if not assets:
152 print(f"Asset {name} not found in config.")
153 list_assets()
154 raise ValueError(f"Asset '{name}' not found in config. Check asset name.")
156 asset = assets[0]
157 # Check if the S3 URI is configured for the asset
158 s3_uri = asset["s3_uri"]
160 if not s3_uri:
161 raise ValueError(
162 f"S3 URI is not configured for '{name}'. Check the config file."
163 )
164 # Check if the S3 URI is valid
165 if not s3_uri.startswith("s3://"):
166 raise ValueError(f"Invalid S3 URI format {s3_uri}. Must start with 's3://'.")
167 s3_basename = asset["s3_basename"]
168 if not s3_basename:
169 raise ValueError(
170 f"S3 basename is not configured for '{name}'. Check the config file."
171 )
172 # Check if the S3 basename is valid
173 if "%" not in s3_basename:
174 raise ValueError(
175 f"Invalid S3 basename format '{s3_basename}'. Must contain a frame substitution wildcard like %04d. Check the config file."
176 )
177 # check if the rename syntax is valid.
178 if rename and "%" not in rename:
179 raise ValueError(
180 f"Invalid rename format '{rename}'. Must contain a frame substitution wildcard like %04d."
181 )
182 # Generate the S3 path for each frame
183 if renumber:
184 if not rename:
185 raise ValueError("Option --renumber requires --rename.")
186 renumber_offset = renumber - frame_start
187 for value in track(range(frame_start, frame_end + 1), description="Downloading..."):
188 # Generate the S3 path
189 s3_path = s3_basename % value
190 frame_path = Path(s3_path)
191 if rename:
192 rename_value = value + renumber_offset if renumber else value
193 rename_path = rename % rename_value
194 frame_path = Path(rename_path)
195 # check if the frame exists on disk already
196 if Path(frame_path.name).is_file() and not force:
197 print(f"file {frame_path.name} exists, skipping. Use --force to overwrite.")
198 continue
200 # Download the content from S3, renaming if requested
201 dest_path = rename_path if rename else "."
202 download_from_s3(s3_uri, s3_path, dest_path=dest_path, dry_run=dry_run)
205@app.command("list")
206def list_assets(
207 only_frames: bool = typer.Option(True, help="Only list assets with frame content."),
208) -> None:
209 """
210 List available Netflix Open Content.
212 Some open content assets may not have frame content.
214 Args:
215 only_frames (bool): If True, only list assets with frames.
216 """
217 message = "Available content"
218 if only_frames:
219 message += " with frames:"
220 else:
221 message += ":"
222 typer.echo(message)
223 for asset in sorted(CONFIG["assets"], key=lambda x: x["name"]):
224 if only_frames and not asset.get("s3_uri"):
225 continue
226 typer.echo(f"- {asset['name']:<20}: {asset['description']}")
229if __name__ == "__main__":
230 app()