Coverage for src/ph/organizer.py: 92.5%

120 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-28 08:17 +0000

1"""Move completed downloads into a target's ROM library.""" 

2 

3import logging 

4import os 

5import shutil 

6import stat 

7import zipfile 

8from collections.abc import Callable, Iterable, Sequence 

9from pathlib import Path, PurePosixPath 

10 

11from ph.models import DownloadResult, Platform 

12from ph.targets import DARKOS 

13 

14LOGGER = logging.getLogger(__name__) 

15 

16 

17class OrganizeError(RuntimeError): 

18 """A completed file could not be placed in the ROM library.""" 

19 

20 

21MAX_BIOS_FILES = 128 

22MAX_BIOS_FILE_SIZE = 64 * 1024 * 1024 

23MAX_BIOS_TOTAL_SIZE = 256 * 1024 * 1024 

24ROM_LOCAL_BIOS: dict[str, frozenset[str]] = { 

25 "adventure-vision": frozenset({"advision.zip"}), 

26 "astrocade": frozenset({"astrocde.zip"}), 

27 "coco-3": frozenset({"coco.zip", "coco2.zip", "coco2b.zip", "coco3.zip", "coco3p.zip"}), 

28} 

29ROM_AND_SHARED_BIOS: dict[str, frozenset[str]] = { 

30 "neo-geo": frozenset({"aes.zip", "neogeo.zip"}), 

31} 

32 

33 

34def detect_roms_directories( 

35 configured: Path | Sequence[Path] | None = None, 

36 candidates: Sequence[Path] = DARKOS.rom_roots, 

37) -> tuple[Path, ...]: 

38 """Find all active ROM partitions for the selected Linux target.""" 

39 

40 if configured is not None: 40 ↛ 43line 40 didn't jump to line 43 because the condition on line 40 was always true

41 return (configured,) if isinstance(configured, Path) else tuple(configured) 

42 

43 return available_roms_directories(candidates) 

44 

45 

46def available_roms_directories(candidates: Sequence[Path]) -> tuple[Path, ...]: 

47 """Return every candidate that looks like a mounted, writable ROM partition.""" 

48 

49 # Every official image exposes some combination of system folders. Avoid a 

50 # short console allow-list so customized distribution images are recognized. 

51 available = tuple( 

52 candidate 

53 for candidate in candidates 

54 if candidate.is_dir() 

55 and ( 

56 any(path.is_dir() for path in candidate.iterdir()) or os.access(str(candidate), os.W_OK) 

57 ) 

58 ) 

59 return available 

60 

61 

62def detect_roms_directory( 

63 configured: Path | Sequence[Path] | None = None, 

64 candidates: Sequence[Path] = DARKOS.rom_roots, 

65) -> Path | None: 

66 """Find the preferred target ROM partition for non-interactive use.""" 

67 

68 directories = detect_roms_directories(configured, candidates) 

69 return directories[0] if directories else None 

70 

71 

72def install_downloads( 

73 downloads: Iterable[DownloadResult], 

74 platform: Platform, 

75 roms_directory: Path, 

76 bios_installed: Callable[[Path], None] | None = None, 

77 bios_directory: str = "bios", 

78) -> list[DownloadResult]: 

79 """Move completed files to a platform folder without overwriting existing ROMs.""" 

80 

81 if platform.rom_folder is None: 

82 raise OrganizeError(f"{platform.name} does not have a supported ROM folder.") 

83 existing_folder = next( 

84 (folder for folder in platform.rom_folders if (roms_directory / folder).is_dir()), 

85 platform.rom_folder, 

86 ) 

87 destination_directory = roms_directory / existing_folder 

88 LOGGER.debug( 

89 "Installing downloads platform=%s destination=%s", 

90 platform.alias, 

91 destination_directory, 

92 ) 

93 try: 

94 destination_directory.mkdir(parents=True, exist_ok=True) 

95 except OSError as error: 

96 raise OrganizeError(f"Cannot create {destination_directory}: {error}") from error 

97 

98 moved: list[DownloadResult] = [] 

99 for download in downloads: 

100 source = download.path 

101 if not source.is_file(): 

102 raise OrganizeError(f"Completed file was not found: {source}") 

103 for bios_path in install_bundled_bios( 

104 source, 

105 platform, 

106 roms_directory, 

107 bios_directory, 

108 ): 

109 if bios_installed is not None: 

110 bios_installed(bios_path) 

111 destination = unique_destination(destination_directory / source.name) 

112 try: 

113 final_path = Path(shutil.move(str(source), str(destination))) 

114 except OSError as error: 

115 raise OrganizeError(f"Could not move {source.name}: {error}") from error 

116 moved.append(DownloadResult(url=download.url, path=final_path)) 

117 LOGGER.info("Installed ROM platform=%s path=%s", platform.alias, final_path) 

118 return moved 

119 

120 

121def install_bundled_bios( 

122 archive_path: Path, 

123 platform: Platform, 

124 roms_directory: Path, 

125 bios_directory: str = "bios", 

126) -> tuple[Path, ...]: 

127 """Safely install files explicitly stored under a BIOS directory in a game ZIP.""" 

128 

129 if archive_path.suffix.casefold() != ".zip" or not zipfile.is_zipfile(archive_path): 

130 return () 

131 try: 

132 with zipfile.ZipFile(archive_path) as archive: 

133 members = _bundled_bios_members(archive) 

134 installed: list[Path] = [] 

135 for member, relative_path in members: 

136 for destination in _bios_destinations( 

137 relative_path, 

138 platform, 

139 roms_directory, 

140 bios_directory, 

141 ): 

142 if destination.exists(): 

143 continue 

144 destination.parent.mkdir(parents=True, exist_ok=True) 

145 partial = destination.with_name(destination.name + ".part") 

146 try: 

147 with archive.open(member) as source, partial.open("wb") as output: 

148 shutil.copyfileobj(source, output, length=1024 * 256) 

149 os.replace(partial, destination) 

150 except (OSError, RuntimeError, zipfile.BadZipFile) as error: 

151 partial.unlink(missing_ok=True) 

152 raise OrganizeError( 

153 f"Could not install bundled BIOS {relative_path}: {error}" 

154 ) from error 

155 installed.append(destination) 

156 LOGGER.info("Installed bundled BIOS path=%s", destination) 

157 return tuple(installed) 

158 except zipfile.BadZipFile as error: 

159 raise OrganizeError(f"Could not inspect bundled BIOS files: {error}") from error 

160 

161 

162def _bundled_bios_members( 

163 archive: zipfile.ZipFile, 

164) -> tuple[tuple[zipfile.ZipInfo, PurePosixPath], ...]: 

165 selected: list[tuple[zipfile.ZipInfo, PurePosixPath]] = [] 

166 total_size = 0 

167 for member in archive.infolist(): 

168 normalized = member.filename.replace("\\", "/") 

169 parts = PurePosixPath(normalized).parts 

170 bios_index = next( 

171 (index for index, part in enumerate(parts) if part.casefold() == "bios"), 

172 None, 

173 ) 

174 if bios_index is None or member.is_dir(): 

175 continue 

176 relative_parts = parts[bios_index + 1 :] 

177 if not relative_parts or any(part in {"", ".", "..", "/"} for part in relative_parts): 

178 raise OrganizeError(f"Unsafe bundled BIOS path: {member.filename}") 

179 mode = member.external_attr >> 16 

180 if stat.S_ISLNK(mode): 

181 raise OrganizeError(f"Bundled BIOS cannot be a symbolic link: {member.filename}") 

182 if member.file_size > MAX_BIOS_FILE_SIZE: 182 ↛ 183line 182 didn't jump to line 183 because the condition on line 182 was never true

183 raise OrganizeError(f"Bundled BIOS is unexpectedly large: {member.filename}") 

184 total_size += member.file_size 

185 if len(selected) >= MAX_BIOS_FILES or total_size > MAX_BIOS_TOTAL_SIZE: 185 ↛ 186line 185 didn't jump to line 186 because the condition on line 185 was never true

186 raise OrganizeError("Bundled BIOS payload exceeds the safe extraction limit.") 

187 selected.append((member, PurePosixPath(*relative_parts))) 

188 return tuple(selected) 

189 

190 

191def _bios_destinations( 

192 relative_path: PurePosixPath, 

193 platform: Platform, 

194 roms_directory: Path, 

195 bios_directory: str = "bios", 

196) -> tuple[Path, ...]: 

197 folder = platform.rom_folder or "" 

198 filename = relative_path.name.casefold() 

199 local = roms_directory / folder / Path(*relative_path.parts) 

200 shared = roms_directory / bios_directory / Path(*relative_path.parts) 

201 if filename in ROM_LOCAL_BIOS.get(platform.slug, frozenset()): 

202 return (local,) 

203 if filename in ROM_AND_SHARED_BIOS.get(platform.slug, frozenset()): 

204 return (shared, local) 

205 return (shared,) 

206 

207 

208def unique_destination(destination: Path) -> Path: 

209 if not destination.exists(): 

210 return destination 

211 suffixes = "".join(destination.suffixes) 

212 stem = destination.name[: -len(suffixes)] if suffixes else destination.name 

213 counter = 2 

214 while True: 

215 candidate = destination.with_name("%s (%d)%s" % (stem, counter, suffixes)) 

216 if not candidate.exists(): 

217 return candidate 

218 counter += 1