Skip to content

Commit a115a18

Browse files
Refactor archives to have less extracts
1 parent 7b0ffb0 commit a115a18

17 files changed

Lines changed: 546 additions & 319 deletions

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ Unlike other tagging tools, Perdoo employs a manual approach when metadata files
7575
### Output Extensions
7676

7777
- .cbz
78+
- .cbt
79+
- .cb7 _(Requires installing `cb7` dependencies: `pipx install perdoo[cb7]`)_
7880

7981
### Metadata Files
8082

@@ -144,6 +146,7 @@ File will be created on first run.
144146
```toml
145147
[output]
146148
folder = "~/.local/share/perdoo"
149+
format = "cbz"
147150

148151
[output.comic_info]
149152
create = true
@@ -183,6 +186,11 @@ password = "<Metron Password>"
183186
The folder where the output files will be stored.
184187
Defaults to `~/.local/share/perdoo`.
185188

189+
- `output.format`
190+
The output file format for the comic archives.
191+
Defaults to `cbz`.
192+
Options are `cbz`, `cbt` or `cb7`
193+
186194
- `output.comic_info.create`
187195
Whether to create a ComicInfo.xml file in the output archive.
188196
Defaults to `true`.
@@ -209,7 +217,8 @@ password = "<Metron Password>"
209217
The order in which the services will be used for metadata retrieval.
210218
Metadata will be fetched from the first service that returns a result.
211219
Don't include the service name in the list if you don't want to use it.
212-
Defaults to `["Metron", "Comicvine"]`, options are `Metron` and `Comicvine`.
220+
Defaults to `["Metron", "Comicvine"]`.
221+
Options are `Metron` or `Comicvine`.
213222

214223
## Socials
215224

perdoo/__main__.py

Lines changed: 73 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import logging
22
from datetime import date
33
from enum import Enum
4-
from io import BytesIO
54
from pathlib import Path
65
from platform import python_version
76
from typing import Annotated
@@ -14,9 +13,9 @@
1413
from perdoo.comic import Comic
1514
from perdoo.comic.errors import ComicArchiveError, ComicMetadataError
1615
from perdoo.comic.metadata import ComicInfo, MetronInfo
17-
from perdoo.comic.metadata.comic_info import Page
1816
from perdoo.comic.metadata.metron_info import Id, InformationSource
1917
from perdoo.console import CONSOLE
18+
from perdoo.processing import ProcessingPlan
2019
from perdoo.services import BaseService, Comicvine, Metron
2120
from perdoo.settings import Service, Services, Settings
2221
from perdoo.utils import (
@@ -70,7 +69,25 @@ def get_services(settings: Services) -> dict[Service, BaseService]:
7069
return output
7170

7271

73-
def _load_comics(target: Path) -> list[Comic]:
72+
def setup_environment(
73+
clean_cache: bool, sync: SyncOption, settings: Settings, debug: bool = False
74+
) -> tuple[dict[Service, BaseService], SyncOption]:
75+
setup_logging(debug=debug)
76+
LOGGER.info("Python v%s", python_version())
77+
LOGGER.info("Perdoo v%s", __version__)
78+
79+
if clean_cache:
80+
LOGGER.info("Cleaning Cache")
81+
recursive_delete(path=get_cache_root())
82+
83+
services = get_services(settings=settings.services)
84+
if not services and sync != SyncOption.SKIP:
85+
LOGGER.warning("No external services configured")
86+
sync = SyncOption.SKIP
87+
return services, sync
88+
89+
90+
def load_comics(target: Path) -> list[Comic]:
7491
comics = []
7592
files = list_files(target) if target.is_dir() else [target]
7693
for file in files:
@@ -81,11 +98,31 @@ def _load_comics(target: Path) -> list[Comic]:
8198
return comics
8299

83100

101+
def prepare_comic(entry: Comic, settings: Settings, skip_convert: bool) -> bool:
102+
if not skip_convert:
103+
entry.convert_to(settings.output.format)
104+
if not entry.archive.IS_WRITEABLE:
105+
LOGGER.warning("Archive format %s is not writeable", entry.archive.EXTENSION)
106+
return False
107+
return True
108+
109+
110+
def should_sync_metadata(sync: SyncOption, metroninfo: MetronInfo | None) -> bool:
111+
if sync == SyncOption.SKIP:
112+
return False
113+
if sync == SyncOption.FORCE:
114+
return True
115+
if metroninfo and metroninfo.last_modified:
116+
age = (date.today() - metroninfo.last_modified.date()).days
117+
return age >= 28
118+
return True
119+
120+
84121
def _get_id_value(ids: list[Id], source: InformationSource) -> str | None:
85122
return next((x.value for x in ids if x.source == source), None)
86123

87124

88-
def _create_search_from_metron(metron_info: MetronInfo) -> Search:
125+
def _create_search_from_metron_info(metron_info: MetronInfo) -> Search:
89126
series_id = metron_info.series.id
90127
source = next((x.source for x in metron_info.ids if x.primary), None)
91128
return Search(
@@ -124,46 +161,12 @@ def get_search_details(
124161
) -> Search:
125162
metron_info, comic_info = metadata
126163
if metron_info and metron_info.series and metron_info.series.name:
127-
return _create_search_from_metron(metron_info=metron_info)
164+
return _create_search_from_metron_info(metron_info=metron_info)
128165
if comic_info and comic_info.series:
129166
return _create_search_from_comic_info(comic_info=comic_info, filename=filename)
130167
return _create_search_from_filename(filename=filename)
131168

132169

133-
def load_page_info(entry: Comic, comic_info: ComicInfo) -> list[Page]:
134-
from PIL import Image # noqa: PLC0415
135-
136-
from perdoo.comic import IMAGE_EXTENSIONS # noqa: PLC0415
137-
from perdoo.comic.metadata.comic_info import PageType # noqa: PLC0415
138-
139-
pages = set()
140-
image_files = [
141-
x for x in entry.archive.list_filenames() if Path(x).suffix.lower() in IMAGE_EXTENSIONS
142-
]
143-
for idx, file in enumerate(image_files):
144-
page = next((x for x in comic_info.pages if x.image == idx), None)
145-
if page:
146-
page_type = page.type
147-
elif idx == 0:
148-
page_type = PageType.FRONT_COVER
149-
elif idx == len(image_files) - 1:
150-
page_type = PageType.BACK_COVER
151-
else:
152-
page_type = PageType.STORY
153-
if not page:
154-
page = Page(image=idx)
155-
page.type = page_type
156-
page_bytes = entry.archive.read_file(file)
157-
page.image_size = len(page_bytes)
158-
with Image.open(BytesIO(page_bytes)) as page_data:
159-
width, height = page_data.size
160-
page.double_page = width >= height
161-
page.image_height = height
162-
page.image_width = width
163-
pages.add(page)
164-
return sorted(pages)
165-
166-
167170
def sync_metadata(
168171
search: Search, services: dict[Service, BaseService | None], settings: Settings
169172
) -> tuple[MetronInfo | None, ComicInfo | None]:
@@ -175,6 +178,17 @@ def sync_metadata(
175178
return None, None
176179

177180

181+
def resolve_metadata(
182+
entry: Comic, services: dict[Service, BaseService], settings: Settings, sync: SyncOption
183+
) -> tuple[MetronInfo | None, ComicInfo | None]:
184+
metroninfo, comicinfo = entry.read_metadata()
185+
if not should_sync_metadata(sync=sync, metroninfo=metroninfo):
186+
return metroninfo, comicinfo
187+
search = get_search_details(metadata=(metroninfo, comicinfo), filename=entry.filepath.stem)
188+
search.filename = entry.filepath.stem
189+
return sync_metadata(search=search, services=services, settings=settings)
190+
191+
178192
@app.command(name="import", help="Import comics into your collection using Perdoo.")
179193
def run(
180194
target: Annotated[
@@ -223,74 +237,36 @@ def run(
223237
bool, Option("--debug", help="Enable debug mode to show extra information.")
224238
] = False,
225239
) -> None:
226-
setup_logging(debug=debug)
227-
LOGGER.info("Python v%s", python_version())
228-
LOGGER.info("Perdoo v%s", __version__)
229-
230240
settings = Settings.load()
231241
settings.save()
232-
if debug:
233-
CONSOLE.print(
234-
{
235-
"target": target,
236-
"flags.skip-convert": skip_convert,
237-
"flags.sync": sync,
238-
"flags.skip-clean": skip_clean,
239-
"flags.skip-rename": skip_rename,
240-
"flags.clean-cache": clean_cache,
241-
}
242-
)
243-
if clean_cache:
244-
LOGGER.info("Cleaning Cache")
245-
recursive_delete(path=get_cache_root())
246-
services = get_services(settings=settings.services)
247-
if not services and sync != SyncOption.SKIP:
248-
LOGGER.warning("No external services configured")
249-
sync = SyncOption.SKIP
242+
services, sync = setup_environment(
243+
clean_cache=clean_cache, sync=sync, settings=settings, debug=debug
244+
)
250245

251-
comics = _load_comics(target=target)
246+
comics = load_comics(target=target)
252247
for index, entry in enumerate(comics):
253248
CONSOLE.rule(
254249
f"[{index + 1}/{len(comics)}] Importing {entry.filepath.name}",
255250
align="left",
256251
style="subtitle",
257252
)
258-
if not skip_convert:
259-
with CONSOLE.status("Converting to '.cbz'", spinner="simpleDotsScrolling"):
260-
entry.convert_to(extension="cbz")
261-
262-
metadata: tuple[MetronInfo | None, ComicInfo | None] = (entry.metron_info, entry.comic_info)
263-
264-
if sync != SyncOption.SKIP:
265-
search = get_search_details(metadata=metadata, filename=entry.filepath.stem)
266-
search.filename = entry.filepath.stem
267-
last_modified = date(1900, 1, 1)
268-
if sync == SyncOption.OUTDATED:
269-
metron_info, _ = metadata
270-
if metron_info and metron_info.last_modified:
271-
last_modified = metron_info.last_modified.date()
272-
if (date.today() - last_modified).days >= 28:
273-
metadata = sync_metadata(search=search, services=services, settings=settings)
274-
else:
275-
LOGGER.info("Metadata up-to-date")
276-
277-
if not skip_clean:
278-
with CONSOLE.status("Cleaning Archive", spinner="simpleDotsScrolling"):
279-
entry.clean_archive()
280-
if settings.output.metron_info.create and metadata[0]:
281-
entry.write_metadata(metadata=metadata[0])
282-
if settings.output.comic_info.create and metadata[1]:
283-
metadata[1].pages = (
284-
load_page_info(entry=entry, comic_info=metadata[1])
285-
if settings.output.comic_info.handle_pages
286-
else []
287-
)
288-
entry.write_metadata(metadata=metadata[1])
289-
290-
if not skip_rename:
291-
with CONSOLE.status("Renaming based on metadata", spinner="simpleDotsScrolling"):
292-
entry.rename(naming=settings.output.naming, output_folder=settings.output.folder)
293253

254+
if not prepare_comic(entry=entry, settings=settings, skip_convert=skip_convert):
255+
continue
256+
metroninfo, comicinfo = resolve_metadata(
257+
entry=entry, services=services, settings=settings, sync=sync
258+
)
259+
plan = ProcessingPlan.build(
260+
entry=entry,
261+
metroninfo=metroninfo,
262+
comicinfo=comicinfo,
263+
settings=settings.output,
264+
skip_clean=skip_clean,
265+
skip_rename=skip_rename,
266+
)
267+
plan.apply()
268+
if plan.naming:
269+
entry.move_to(naming=plan.naming, output_folder=settings.output.folder)
294270
with CONSOLE.status("Cleaning up empty folders"):
295271
delete_empty_folders(folder=target)
296272

perdoo/comic/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
__all__ = ["IMAGE_EXTENSIONS", "Comic"]
1+
__all__ = ["Comic"]
22

3-
from perdoo.comic.comic import IMAGE_EXTENSIONS, Comic
3+
from perdoo.comic.comic import Comic

perdoo/comic/archive/_base.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
class Archive(ABC):
1616
_registry: ClassVar[list[type["Archive"]]] = []
1717
EXTENSION: ClassVar[str] = ""
18+
IS_READABLE: ClassVar[bool] = False
19+
IS_WRITEABLE: ClassVar[bool] = False
20+
IS_EDITABLE: ClassVar[bool] = False
1821

1922
def __init__(self, filepath: Path) -> None:
2023
self._filepath = filepath
@@ -41,9 +44,6 @@ def is_archive(cls, path: Path) -> bool: ...
4144
@abstractmethod
4245
def list_filenames(self) -> list[str]: ...
4346

44-
def exists(self, filename: str) -> bool:
45-
return filename in self.list_filenames()
46-
4747
@abstractmethod
4848
def read_file(self, filename: str) -> bytes: ...
4949

@@ -53,12 +53,17 @@ def write_file(self, filename: str, data: str | bytes) -> None: # noqa: ARG002
5353
def remove_file(self, filename: str) -> None:
5454
raise ComicArchiveError(f"Unable to delete {filename} in {self.filepath.name}.")
5555

56+
def rename_file(self, filename: str, new_name: str, override: bool = False) -> None: # noqa: ARG002
57+
raise ComicArchiveError(
58+
f"Unable to rename {filename} to {new_name} in {self.filepath.name}."
59+
)
60+
5661
@abstractmethod
5762
def extract_files(self, destination: Path) -> None: ...
5863

5964
@classmethod
60-
def archive_files(cls, src: Path, output_name: str, files: list[Path]) -> Self: # noqa: ARG003
61-
raise ComicArchiveError(f"Unable to archive files to {output_name}.")
65+
def archive_files(cls, src: Path, output_name: str, files: list[Path]) -> Path: # noqa: ARG003
66+
raise ComicArchiveError(f"Unable to archive files to {output_name}{cls.EXTENSION}.")
6267

6368
@classmethod
6469
def convert_from(cls, old_archive: "Archive") -> Self:

perdoo/comic/archive/rar.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414

1515
class CBRArchive(Archive):
1616
EXTENSION: ClassVar[str] = ".cbr"
17+
IS_READABLE: ClassVar[bool] = True
18+
IS_WRITEABLE: ClassVar[bool] = False
19+
IS_EDITABLE: ClassVar[bool] = False
1720

1821
@classmethod
1922
def is_archive(cls, path: Path) -> bool:

0 commit comments

Comments
 (0)