|
1 | | -import logging |
2 | | -from datetime import date |
3 | | -from enum import Enum |
4 | | -from pathlib import Path |
5 | | -from platform import python_version |
6 | | -from typing import Annotated |
7 | | - |
8 | | -from comicfn2dict import comicfn2dict |
9 | | -from typer import Argument, Context, Exit, Option, Typer |
10 | | - |
11 | | -from perdoo import __version__, get_cache_root, setup_logging |
12 | | -from perdoo.cli import archive_app, settings_app |
13 | | -from perdoo.comic import Comic |
14 | | -from perdoo.comic.archives import ArchiveSession |
15 | | -from perdoo.comic.errors import ComicArchiveError, ComicMetadataError |
16 | | -from perdoo.comic.metadata import ComicInfo, MetronInfo |
17 | | -from perdoo.comic.metadata.metron_info import Id, InformationSource |
18 | | -from perdoo.console import CONSOLE |
19 | | -from perdoo.services import BaseService, Comicvine, Metron |
20 | | -from perdoo.settings import Naming, Output, Service, Services, Settings |
21 | | -from perdoo.utils import ( |
22 | | - IssueSearch, |
23 | | - Search, |
24 | | - SeriesSearch, |
25 | | - delete_empty_folders, |
26 | | - list_files, |
27 | | - recursive_delete, |
28 | | -) |
29 | | - |
30 | | -app = Typer(help="CLI tool for managing comic collections and settings.") |
31 | | -app.add_typer(archive_app, name="archive") |
32 | | -app.add_typer(settings_app, name="settings") |
33 | | -LOGGER = logging.getLogger("perdoo") |
34 | | - |
35 | | - |
36 | | -class SyncOption(str, Enum): |
37 | | - FORCE = "Force" |
38 | | - OUTDATED = "Outdated" |
39 | | - SKIP = "Skip" |
40 | | - |
41 | | - |
42 | | -@app.callback(invoke_without_command=True) |
43 | | -def common( |
44 | | - ctx: Context, |
45 | | - version: Annotated[ |
46 | | - bool | None, Option("--version", is_eager=True, help="Show the version and exit.") |
47 | | - ] = None, |
48 | | -) -> None: |
49 | | - if ctx.invoked_subcommand: |
50 | | - return |
51 | | - if version: |
52 | | - CONSOLE.print(f"Perdoo v{__version__}") |
53 | | - raise Exit |
54 | | - |
55 | | - |
56 | | -def get_services(settings: Services) -> dict[Service, BaseService]: |
57 | | - output = {} |
58 | | - if settings.comicvine.api_key: |
59 | | - output[Service.COMICVINE] = Comicvine(settings.comicvine) |
60 | | - if settings.metron.username and settings.metron.password: |
61 | | - output[Service.METRON] = Metron(settings.metron) |
62 | | - return output |
63 | | - |
64 | | - |
65 | | -def setup_environment( |
66 | | - clean_cache: bool, sync: SyncOption, settings: Settings, debug: bool = False |
67 | | -) -> tuple[dict[Service, BaseService], SyncOption]: |
68 | | - setup_logging(debug=debug) |
69 | | - LOGGER.info("Python v%s", python_version()) |
70 | | - LOGGER.info("Perdoo v%s", __version__) |
71 | | - |
72 | | - if clean_cache: |
73 | | - LOGGER.info("Cleaning Cache") |
74 | | - recursive_delete(path=get_cache_root()) |
75 | | - |
76 | | - services = get_services(settings=settings.services) |
77 | | - if not services and sync is not SyncOption.SKIP: |
78 | | - LOGGER.warning("No external services configured") |
79 | | - sync = SyncOption.SKIP |
80 | | - return services, sync |
81 | | - |
82 | | - |
83 | | -def load_comics(target: Path) -> list[Comic]: |
84 | | - comics = [] |
85 | | - files = list_files(target) if target.is_dir() else [target] |
86 | | - for file in files: |
87 | | - try: |
88 | | - comics.append(Comic(filepath=file)) |
89 | | - except (ComicArchiveError, ComicMetadataError) as err: # noqa: PERF203 |
90 | | - LOGGER.error("Failed to load '%s' as a Comic: %s", file, err) |
91 | | - return comics |
92 | | - |
93 | | - |
94 | | -def prepare_comic(entry: Comic, settings: Settings, skip_convert: bool) -> bool: |
95 | | - if not skip_convert: |
96 | | - entry.convert_to(settings.output.format) |
97 | | - if not entry.archive.IS_WRITEABLE: |
98 | | - LOGGER.warning("Archive format %s is not writeable", entry.archive.EXTENSION) |
99 | | - return False |
100 | | - return True |
101 | | - |
102 | | - |
103 | | -def should_sync_metadata(sync: SyncOption, metron_info: MetronInfo | None) -> bool: |
104 | | - if sync is SyncOption.SKIP: |
105 | | - return False |
106 | | - if sync is SyncOption.FORCE: |
107 | | - return True |
108 | | - if metron_info and metron_info.last_modified: |
109 | | - age = (date.today() - metron_info.last_modified.date()).days |
110 | | - return age >= 28 |
111 | | - return True |
112 | | - |
113 | | - |
114 | | -def get_id(ids: list[Id], source: InformationSource) -> str | None: |
115 | | - return next((x.value for x in ids if x.source is source), None) |
116 | | - |
117 | | - |
118 | | -def search_from_metron_info(metron_info: MetronInfo) -> Search: |
119 | | - series_id = metron_info.series.id |
120 | | - source = next((x.source for x in metron_info.ids if x.primary), None) |
121 | | - return Search( |
122 | | - series=SeriesSearch( |
123 | | - name=metron_info.series.name, |
124 | | - volume=metron_info.series.volume, |
125 | | - year=metron_info.series.start_year, |
126 | | - comicvine=series_id if source == InformationSource.COMIC_VINE else None, |
127 | | - metron=series_id if source == InformationSource.METRON else None, |
128 | | - ), |
129 | | - issue=IssueSearch( |
130 | | - number=metron_info.number, |
131 | | - comicvine=get_id(metron_info.ids, InformationSource.COMIC_VINE), |
132 | | - metron=get_id(metron_info.ids, InformationSource.METRON), |
133 | | - ), |
134 | | - ) |
135 | | - |
136 | | - |
137 | | -def search_from_comic_info(comic_info: ComicInfo, filename: str) -> Search: |
138 | | - volume = comic_info.volume |
139 | | - year = volume if volume and volume > 1900 else None |
140 | | - volume = volume if volume and volume < 1900 else None |
141 | | - return Search( |
142 | | - series=SeriesSearch(name=comic_info.series or filename, volume=volume, year=year), |
143 | | - issue=IssueSearch(number=comic_info.number), |
144 | | - ) |
145 | | - |
146 | | - |
147 | | -def search_from_filename(filename: str) -> Search: |
148 | | - series_name = comicfn2dict(filename).get("series", filename).replace("-", " ") |
149 | | - return Search(series=SeriesSearch(name=series_name), issue=IssueSearch()) |
150 | | - |
151 | | - |
152 | | -def build_search( |
153 | | - metron_info: MetronInfo | None, comic_info: ComicInfo | None, filename: str |
154 | | -) -> Search: |
155 | | - if metron_info and metron_info.series and metron_info.series.name: |
156 | | - return search_from_metron_info(metron_info=metron_info) |
157 | | - if comic_info and comic_info.series: |
158 | | - return search_from_comic_info(comic_info=comic_info, filename=filename) |
159 | | - return search_from_filename(filename=filename) |
160 | | - |
161 | | - |
162 | | -def sync_metadata( |
163 | | - search: Search, services: dict[Service, BaseService], service_order: tuple[Service, ...] |
164 | | -) -> tuple[MetronInfo | None, ComicInfo | None]: |
165 | | - for service_name in service_order: |
166 | | - if service := services.get(service_name): |
167 | | - metron_info, comic_info = service.fetch(search=search) |
168 | | - if metron_info or comic_info: |
169 | | - return metron_info, comic_info |
170 | | - return None, None |
171 | | - |
172 | | - |
173 | | -def resolve_metadata( |
174 | | - entry: Comic, |
175 | | - session: ArchiveSession, |
176 | | - services: dict[Service, BaseService], |
177 | | - settings: Services, |
178 | | - sync: SyncOption, |
179 | | -) -> tuple[MetronInfo | None, ComicInfo | None]: |
180 | | - metron_info, comic_info = entry.read_metadata(session=session) |
181 | | - if not should_sync_metadata(sync=sync, metron_info=metron_info): |
182 | | - return metron_info, comic_info |
183 | | - search = build_search( |
184 | | - metron_info=metron_info, comic_info=comic_info, filename=entry.filepath.stem |
185 | | - ) |
186 | | - search.filename = entry.filepath.stem |
187 | | - return sync_metadata(search=search, services=services, service_order=settings.order) |
188 | | - |
189 | | - |
190 | | -def generate_naming( |
191 | | - settings: Naming, metron_info: MetronInfo | None, comic_info: ComicInfo | None |
192 | | -) -> str | None: |
193 | | - filepath = None |
194 | | - if metron_info: |
195 | | - filepath = metron_info.get_filename(settings=settings) |
196 | | - if not filepath and comic_info: |
197 | | - filepath = comic_info.get_filename(settings=settings) |
198 | | - return filepath.lstrip("/") if filepath else None |
199 | | - |
200 | | - |
201 | | -def apply_changes( |
202 | | - entry: Comic, |
203 | | - session: ArchiveSession, |
204 | | - metron_info: MetronInfo | None, |
205 | | - comic_info: ComicInfo | None, |
206 | | - skip_clean: bool, |
207 | | - skip_rename: bool, |
208 | | - settings: Output, |
209 | | -) -> str | None: |
210 | | - local_metron_info, local_comic_info = entry.read_metadata(session=session) |
211 | | - if local_metron_info != metron_info: |
212 | | - if metron_info: |
213 | | - session.write(filename=MetronInfo.FILENAME, data=metron_info.to_bytes()) |
214 | | - else: |
215 | | - session.delete(filename=MetronInfo.FILENAME) |
216 | | - |
217 | | - if local_comic_info != comic_info: |
218 | | - if comic_info: |
219 | | - session.write(filename=ComicInfo.FILENAME, data=comic_info.to_bytes()) |
220 | | - else: |
221 | | - session.delete(filename=ComicInfo.FILENAME) |
222 | | - |
223 | | - if not skip_clean: |
224 | | - for extra in entry.list_extras(): |
225 | | - session.delete(filename=extra.name) |
226 | | - |
227 | | - naming = None |
228 | | - if not skip_rename and ( |
229 | | - naming := generate_naming( |
230 | | - settings=settings.naming, metron_info=metron_info, comic_info=comic_info |
231 | | - ) |
232 | | - ): |
233 | | - images = entry.list_images() |
234 | | - stem = Path(naming).stem |
235 | | - pad = len(str(len(images))) |
236 | | - for idx, img in enumerate(images): |
237 | | - new_name = f"{stem}_{str(idx).zfill(pad)}{img.suffix}" |
238 | | - if img.name != new_name: |
239 | | - session.rename(filename=img.name, new_name=new_name) |
240 | | - return naming |
241 | | - |
242 | | - |
243 | | -@app.command(name="import", help="Import comics into your collection using Perdoo.") |
244 | | -def run( |
245 | | - target: Annotated[ |
246 | | - Path, |
247 | | - Argument( |
248 | | - exists=True, help="Import comics from the specified file/folder.", show_default=False |
249 | | - ), |
250 | | - ], |
251 | | - skip_convert: Annotated[ |
252 | | - bool, Option("--skip-convert", help="Skip converting comics to the configured format.") |
253 | | - ] = False, |
254 | | - sync: Annotated[ |
255 | | - SyncOption, |
256 | | - Option( |
257 | | - "--sync", |
258 | | - "-s", |
259 | | - case_sensitive=False, |
260 | | - help="Sync ComicInfo/MetronInfo with online services.", |
261 | | - ), |
262 | | - ] = SyncOption.OUTDATED, |
263 | | - skip_clean: Annotated[ |
264 | | - bool, |
265 | | - Option( |
266 | | - "--skip-clean", |
267 | | - help="Skip removing any files not listed in the 'image_extensions' setting.", |
268 | | - ), |
269 | | - ] = False, |
270 | | - skip_rename: Annotated[ |
271 | | - bool, |
272 | | - Option( |
273 | | - "--skip-rename", |
274 | | - help="Skip organizing and renaming comics based on their MetronInfo/ComicInfo.", |
275 | | - ), |
276 | | - ] = False, |
277 | | - clean_cache: Annotated[ |
278 | | - bool, |
279 | | - Option( |
280 | | - "--clean", |
281 | | - "-c", |
282 | | - show_default=False, |
283 | | - help="Clean the cache before starting the synchronization process. " |
284 | | - "Removes all cached files.", |
285 | | - ), |
286 | | - ] = False, |
287 | | - debug: Annotated[ |
288 | | - bool, Option("--debug", help="Enable debug mode to show extra information.") |
289 | | - ] = False, |
290 | | -) -> None: |
291 | | - settings = Settings.load() |
292 | | - settings.save() |
293 | | - services, sync = setup_environment( |
294 | | - clean_cache=clean_cache, sync=sync, settings=settings, debug=debug |
295 | | - ) |
296 | | - |
297 | | - comics = load_comics(target=target) |
298 | | - total = len(comics) |
299 | | - for index, entry in enumerate(comics, start=1): |
300 | | - CONSOLE.rule( |
301 | | - f"[{index}/{total}] Importing {entry.filepath.name}", align="left", style="subtitle" |
302 | | - ) |
303 | | - |
304 | | - if not prepare_comic(entry=entry, settings=settings, skip_convert=skip_convert): |
305 | | - continue |
306 | | - with entry.open_session() as session: |
307 | | - metron_info, comic_info = resolve_metadata( |
308 | | - entry=entry, |
309 | | - session=session, |
310 | | - services=services, |
311 | | - settings=settings.services, |
312 | | - sync=sync, |
313 | | - ) |
314 | | - naming = apply_changes( |
315 | | - entry=entry, |
316 | | - session=session, |
317 | | - metron_info=metron_info, |
318 | | - comic_info=comic_info, |
319 | | - skip_clean=skip_clean, |
320 | | - skip_rename=skip_rename, |
321 | | - settings=settings.output, |
322 | | - ) |
323 | | - if naming: |
324 | | - entry.move_to(naming=naming, output_folder=settings.output.folder) |
325 | | - with CONSOLE.status("Cleaning up empty folders"): |
326 | | - delete_empty_folders(folder=target) |
327 | | - |
| 1 | +from perdoo.cli import app |
328 | 2 |
|
329 | 3 | if __name__ == "__main__": |
330 | | - app(prog_name="Perdoo") |
| 4 | + app(prog_name="perdoo") |
0 commit comments