Skip to content

Commit 83197e8

Browse files
Rename import command to process
Tidy cli commands
1 parent 3d0840b commit 83197e8

15 files changed

Lines changed: 443 additions & 402 deletions

File tree

README.md

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -26,40 +26,28 @@ Unlike other tagging tools, Perdoo employs a manual approach when metadata files
2626

2727
## Usage
2828

29-
<details><summary>Perdoo Commands</summary>
29+
<details><summary>perdoo Commands</summary>
3030

3131
<!-- RICH-CODEX hide_command: true -->
32-
![`uv run Perdoo --help`](docs/img/perdoo-commands.svg)
32+
![`uv run perdoo`](docs/img/perdoo-commands.svg)
3333

3434
</details>
35-
<details><summary>Perdoo import</summary>
35+
<details><summary>perdoo process</summary>
3636

3737
<!-- RICH-CODEX hide_command: true -->
38-
![`uv run Perdoo import --help`](docs/img/perdoo-import.svg)
38+
![`uv run perdoo process --help`](docs/img/perdoo-process.svg)
3939

4040
</details>
41-
42-
### Perdoo archive Commands
43-
44-
<details><summary>Perdoo archive view</summary>
45-
46-
<!-- RICH-CODEX hide_command: true -->
47-
![`uv run Perdoo archive view --help`](docs/img/perdoo-archive-view.svg)
48-
49-
</details>
50-
51-
### Perdoo settings Commands
52-
53-
<details><summary>Perdoo settings view</summary>
41+
<details><summary>perdoo archive</summary>
5442

5543
<!-- RICH-CODEX hide_command: true -->
56-
![`uv run Perdoo settings view --help`](docs/img/perdoo-settings-view.svg)
44+
![`uv run perdoo archive --help`](docs/img/perdoo-archive.svg)
5745

5846
</details>
59-
<details><summary>Perdoo settings locate</summary>
47+
<details><summary>perdoo settings</summary>
6048

6149
<!-- RICH-CODEX hide_command: true -->
62-
![`uv run Perdoo settings locate --help`](docs/img/perdoo-settings-locate.svg)
50+
![`uv run perdoo settings --help`](docs/img/perdoo-settings.svg)
6351

6452
</details>
6553

perdoo/__main__.py

Lines changed: 2 additions & 328 deletions
Original file line numberDiff line numberDiff line change
@@ -1,330 +1,4 @@
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
3282

3293
if __name__ == "__main__":
330-
app(prog_name="Perdoo")
4+
app(prog_name="perdoo")

perdoo/cli/__init__.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
__all__ = ["archive_app", "settings_app"]
1+
__all__ = ["app", "archive", "process", "settings"]
22

3-
from perdoo.cli.archive import app as archive_app
4-
from perdoo.cli.settings import app as settings_app
3+
from perdoo.cli._typer import app
4+
from perdoo.cli.archive import archive
5+
from perdoo.cli.process import process
6+
from perdoo.cli.settings import settings

0 commit comments

Comments
 (0)