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

1import subprocess 

2import webbrowser 

3from pathlib import Path 

4from typing import Annotated, Optional 

5 

6import typer 

7from rich.progress import track 

8 

9from netflix_open_content_helper import CONFIG, __version__ 

10 

11 

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. 

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 

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

44 

45 

46app = typer.Typer() 

47 

48 

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 

61 

62 

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) 

86 

87 

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

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

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. 

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 

229if __name__ == "__main__": 

230 app()