Coverage for src/netflix_open_content_helper/cli.py: 94%

1 import subprocess

2 import webbrowser

3 from pathlib import Path

4 from typing import Annotated, Optional

5

6 import typer

7 from rich.progress import track

8

9 from netflix_open_content_helper import CONFIG, __version__

10

11

12 def 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.

17

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)

37

38

39 def 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()

44

45

46 app = typer.Typer()

47

48

49@app.callback()

50 def 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

61

62

63@app.command()

64 def 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)

86

87

88@app.command()

89 def 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."""

128

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 )

139

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

148

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.")

155

156 asset = assets[0]

157 # Check if the S3 URI is configured for the asset

158 s3_uri = asset["s3_uri"]

159

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

199

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)

203

204

205@app.command("list")

206 def 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.

211

212 Some open content assets may not have frame content.

213

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']}")

227

228

229 if __name__ == "__main__":

230 app()