Coverage for src/lexigram/admin/media/library.py: 90%

81 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 14:56 +0800

1"""MediaLibrary — metadata management for the admin media library.""" 

2 

3from __future__ import annotations 

4 

5from datetime import UTC, datetime 

6from typing import Any 

7import uuid 

8 

9from lexigram.admin.media.models import MediaItem, MediaPage 

10 

11 

12class MediaLibrary: 

13 """Manages the metadata layer for the admin media library. 

14 

15 Args: 

16 storage: Optional storage service instance (used for 

17 :meth:`delete_storage`). 

18 default_folder: Default folder prefix for new uploads. 

19 """ 

20 

21 def __init__( 

22 self, 

23 storage: Any = None, 

24 default_folder: str = "uploads", 

25 ) -> None: 

26 self._storage = storage 

27 self._default_folder = default_folder 

28 self._items: dict[str, MediaItem] = {} 

29 

30 # ------------------------------------------------------------------ 

31 # Register / update 

32 # ------------------------------------------------------------------ 

33 

34 async def register( 

35 self, 

36 path: str, 

37 *, 

38 filename: str = "", 

39 mime_type: str = "", 

40 size_bytes: int = 0, 

41 uploaded_by: str = "system", 

42 alt_text: str = "", 

43 caption: str = "", 

44 tags: list[str] | None = None, 

45 width: int = 0, 

46 height: int = 0, 

47 item_id: str | None = None, 

48 metadata: dict[str, Any] | None = None, 

49 ) -> MediaItem: 

50 """Register a newly uploaded file in the library. 

51 

52 Args: 

53 path: Storage path. 

54 filename: Human-readable filename. 

55 mime_type: MIME type. 

56 size_bytes: File size in bytes. 

57 uploaded_by: Uploader user ID. 

58 alt_text: Accessible alt text. 

59 caption: Optional caption. 

60 tags: Categorisation tags. 

61 width: Image width (pixels). 

62 height: Image height (pixels). 

63 item_id: Explicit ID (auto-generated if omitted). 

64 metadata: Extra metadata. 

65 

66 Returns: 

67 The registered :class:`MediaItem`. 

68 """ 

69 folder = "/".join(path.split("/")[:-1]) if "/" in path else self._default_folder 

70 if not filename: 

71 filename = path.rsplit("/", maxsplit=1)[-1] 

72 

73 item = MediaItem( 

74 item_id=item_id or str(uuid.uuid4())[:12], 

75 path=path, 

76 filename=filename, 

77 mime_type=mime_type, 

78 size_bytes=size_bytes, 

79 folder=folder, 

80 uploaded_by=uploaded_by, 

81 alt_text=alt_text, 

82 caption=caption, 

83 tags=tags or [], 

84 width=width, 

85 height=height, 

86 metadata=metadata or {}, 

87 ) 

88 self._items[item.item_id] = item 

89 return item 

90 

91 def update( 

92 self, 

93 item_id: str, 

94 *, 

95 alt_text: str | None = None, 

96 caption: str | None = None, 

97 tags: list[str] | None = None, 

98 ) -> MediaItem | None: 

99 """Update the metadata of a registered item. 

100 

101 Args: 

102 item_id: Library item ID. 

103 alt_text: New alt text (``None`` = no change). 

104 caption: New caption. 

105 tags: New tags list. 

106 

107 Returns: 

108 Updated :class:`MediaItem` or ``None`` if not found. 

109 """ 

110 item = self._items.get(item_id) 

111 if item is None: 

112 return None 

113 if alt_text is not None: 

114 item.alt_text = alt_text 

115 if caption is not None: 

116 item.caption = caption 

117 if tags is not None: 

118 item.tags = list(tags) 

119 return item 

120 

121 # ------------------------------------------------------------------ 

122 # Browse 

123 # ------------------------------------------------------------------ 

124 

125 def get(self, item_id: str) -> MediaItem | None: 

126 """Return a single item by ID. 

127 

128 Args: 

129 item_id: Library item ID. 

130 """ 

131 return self._items.get(item_id) 

132 

133 def list_folder( 

134 self, 

135 folder: str = "", 

136 *, 

137 page: int = 1, 

138 page_size: int = 24, 

139 include_deleted: bool = False, 

140 ) -> MediaPage: 

141 """Return a paginated list of items in *folder*. 

142 

143 Args: 

144 folder: Folder path prefix (empty = all items). 

145 page: 1-based page number. 

146 page_size: Items per page. 

147 include_deleted: When ``True``, soft-deleted items are included. 

148 

149 Returns: 

150 :class:`MediaPage` with items for the requested page. 

151 """ 

152 all_items = [ 

153 item 

154 for item in self._items.values() 

155 if (not folder or item.folder.startswith(folder)) 

156 and (include_deleted or not item.is_deleted) 

157 ] 

158 all_items.sort(key=lambda i: i.uploaded_at, reverse=True) 

159 total = len(all_items) 

160 start = (page - 1) * page_size 

161 end = start + page_size 

162 return MediaPage( 

163 items=all_items[start:end], 

164 page=page, 

165 page_size=page_size, 

166 total=total, 

167 ) 

168 

169 def search( 

170 self, 

171 query: str, 

172 *, 

173 mime_prefix: str = "", 

174 tags: list[str] | None = None, 

175 include_deleted: bool = False, 

176 ) -> list[MediaItem]: 

177 """Search for items by filename, alt text, caption, or tags. 

178 

179 Args: 

180 query: Search string (case-insensitive). 

181 mime_prefix: Filter by MIME type prefix (e.g. ``"image/"``). 

182 tags: Filter by tags (item must have all listed tags). 

183 include_deleted: Include soft-deleted items. 

184 

185 Returns: 

186 Matching items sorted by relevance (exact filename match first). 

187 """ 

188 q = query.lower() 

189 results = [] 

190 for item in self._items.values(): 

191 if not include_deleted and item.is_deleted: 

192 continue 

193 if mime_prefix and not item.mime_type.startswith(mime_prefix): 

194 continue 

195 if tags: 

196 if not all(t in item.tags for t in tags): 

197 continue 

198 if ( 

199 q in item.filename.lower() 

200 or q in item.alt_text.lower() 

201 or q in item.caption.lower() 

202 or any(q in tag.lower() for tag in item.tags) 

203 ): 

204 results.append(item) 

205 

206 results.sort( 

207 key=lambda i: ( 

208 0 if i.filename.lower() == q else 1, 

209 -i.uploaded_at.timestamp(), 

210 ) 

211 ) 

212 return results 

213 

214 def list_folders(self) -> list[str]: 

215 """Return all unique folder paths (sorted, excluding deleted items).""" 

216 return sorted( 

217 {item.folder for item in self._items.values() if not item.is_deleted} 

218 ) 

219 

220 # ------------------------------------------------------------------ 

221 # Soft delete / restore 

222 # ------------------------------------------------------------------ 

223 

224 def soft_delete(self, item_id: str) -> MediaItem | None: 

225 """Soft-delete an item (moves to trash without destroying storage). 

226 

227 Args: 

228 item_id: Library item ID. 

229 

230 Returns: 

231 Updated :class:`MediaItem` or ``None`` if not found. 

232 """ 

233 item = self._items.get(item_id) 

234 if item is None: 

235 return None 

236 item.is_deleted = True 

237 item.deleted_at = datetime.now(UTC) 

238 return item 

239 

240 def restore(self, item_id: str) -> MediaItem | None: 

241 """Restore a soft-deleted item. 

242 

243 Args: 

244 item_id: Library item ID. 

245 

246 Returns: 

247 Updated :class:`MediaItem` or ``None`` if not found. 

248 """ 

249 item = self._items.get(item_id) 

250 if item is None: 

251 return None 

252 item.is_deleted = False 

253 item.deleted_at = None 

254 return item 

255 

256 async def purge(self, item_id: str) -> bool: 

257 """Permanently remove an item from the library (and optionally storage). 

258 

259 Args: 

260 item_id: Library item ID. 

261 

262 Returns: 

263 ``True`` if removed, ``False`` if not found. 

264 """ 

265 item = self._items.pop(item_id, None) 

266 if item is None: 

267 return False 

268 if self._storage: 

269 try: 

270 await self._storage.delete(item.path) 

271 except Exception: # noqa: BLE001, S110 

272 pass 

273 return True 

274 

275 def trash(self) -> list[MediaItem]: 

276 """Return all soft-deleted items.""" 

277 return [i for i in self._items.values() if i.is_deleted] 

278 

279 

280__all__ = [ 

281 "MediaLibrary", 

282]