diff --git a/docs/install.md b/docs/install.md index 3f0f5aa0..f8fc1cb5 100644 --- a/docs/install.md +++ b/docs/install.md @@ -219,6 +219,20 @@ Don't forget to rebuild! For audio/video thumbnails and playback you'll need [FFmpeg](https://ffmpeg.org/download.html) installed on your system. If you encounter any issues with this, please reference our [FFmpeg Help](./help/ffmpeg.md) guide. +### RAR extractor + +To generate thumbnails for RAR-based files (like `.cbr`) you'll need an extractor capable of handling them. + +On Linux you'll need to install either `unrar` (likely in you distro's non-free repository) or `unrar-free` from your package manager. + +On Mac `unrar` can be installed through Homebrew's [`rar`](https://formulae.brew.sh/cask/rar) formula. + +On Windows you'll need to install either [`WinRAR`](https://www.rarlab.com/download.htm) or [`7-zip`](https://www.7-zip.org/) and add their folder to you `PATH`. + +#### Note + +Both `unrar` and `WinRAR` require a license, but since the evaluation copy has no time limit you can simply dismiss the prompt. + ### ripgrep A recommended tool to improve the performance of directory scanning is [`ripgrep`](https://github.com/BurntSushi/ripgrep), a Rust-based directory walker that natively integrates with our [`.ts_ignore`](./utilities/ignore.md) (`.gitignore`-style) pattern matching system for excluding files and directories. Ripgrep is already pre-installed on some Linux distributions and also available from several package managers. diff --git a/pyproject.toml b/pyproject.toml index fcd9e994..2a706e8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "pydantic~=2.10", "pydub~=0.25", "PySide6==6.8.0.*", + "rarfile==4.2", "rawpy~=0.24", "Send2Trash~=1.8", "SQLAlchemy~=2.0", diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index 069e735e..1c9cda26 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -19,6 +19,7 @@ from xml.etree.ElementTree import Element import cv2 import numpy as np import pillow_avif # noqa: F401 # pyright: ignore[reportUnusedImport] +import rarfile import rawpy import srctools import structlog @@ -857,11 +858,12 @@ class ThumbRenderer(QObject): return im @staticmethod - def _epub_cover(filepath: Path) -> Image.Image | None: + def _epub_cover(filepath: Path, ext: str) -> Image.Image | None: """Extracts the cover specified by ComicInfo.xml or first image found in the ePub file. Args: filepath (Path): The path to the ePub file. + ext (str): The file extension. Returns: Image: The cover specified in ComicInfo.xml, @@ -869,21 +871,25 @@ class ThumbRenderer(QObject): """ im: Image.Image | None = None try: - with zipfile.ZipFile(filepath, "r") as zip_file: - if "ComicInfo.xml" in zip_file.namelist(): - comic_info = ET.fromstring(zip_file.read("ComicInfo.xml")) - im = ThumbRenderer.__cover_from_comic_info(zip_file, comic_info, "FrontCover") + archiver: type[zipfile.ZipFile] | type[rarfile.RarFile] = zipfile.ZipFile + if ext == ".cbr": + archiver = rarfile.RarFile + + with archiver(filepath, "r") as archive: + if "ComicInfo.xml" in archive.namelist(): + comic_info = ET.fromstring(archive.read("ComicInfo.xml")) + im = ThumbRenderer.__cover_from_comic_info(archive, comic_info, "FrontCover") if not im: im = ThumbRenderer.__cover_from_comic_info( - zip_file, comic_info, "InnerCover" + archive, comic_info, "InnerCover" ) if not im: - for file_name in zip_file.namelist(): + for file_name in archive.namelist(): if file_name.lower().endswith( (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg") ): - image_data = zip_file.read(file_name) + image_data = archive.read(file_name) im = Image.open(BytesIO(image_data)) break except Exception as e: @@ -893,12 +899,12 @@ class ThumbRenderer(QObject): @staticmethod def __cover_from_comic_info( - zip_file: zipfile.ZipFile, comic_info: Element, cover_type: str + archive: zipfile.ZipFile | rarfile.RarFile, comic_info: Element, cover_type: str ) -> Image.Image | None: """Extract the cover specified in ComicInfo.xml. Args: - zip_file (zipfile.ZipFile): The current ePub file. + archive (zipfile.ZipFile | rarfile.RarFile): The current ePub file. comic_info (Element): The parsed ComicInfo.xml. cover_type (str): The type of cover to load. @@ -909,10 +915,10 @@ class ThumbRenderer(QObject): cover = comic_info.find(f"./*Page[@Type='{cover_type}']") if cover is not None: - pages = [f for f in zip_file.namelist() if f != "ComicInfo.xml"] + pages = [f for f in archive.namelist() if f != "ComicInfo.xml"] page_name = pages[int(cover.get("Image"))] if page_name.endswith((".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg")): - image_data = zip_file.read(page_name) + image_data = archive.read(page_name) im = Image.open(BytesIO(image_data)) return im @@ -1573,7 +1579,7 @@ class ThumbRenderer(QObject): if MediaCategories.is_ext_in_category( ext, MediaCategories.EBOOK_TYPES, mime_fallback=True ): - image = self._epub_cover(_filepath) + image = self._epub_cover(_filepath, ext) # Krita ======================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.KRITA_TYPES, mime_fallback=True