diff --git a/PixivDBManager.py b/PixivDBManager.py index 6434ef0b..3ae65bcf 100644 --- a/PixivDBManager.py +++ b/PixivDBManager.py @@ -31,6 +31,13 @@ def __init__(self, root_directory, target="", timeout=5 * 60): else: PixivHelper.print_and_log("info", "Using custom DB Path: " + target) self.rootDirectory = root_directory + + # Ensure the directory for the database file exists + db_dir = os.path.dirname(target) + if db_dir and not os.path.exists(db_dir): + PixivHelper.print_and_log('info', f"Creating database directory: {db_dir}") + os.makedirs(db_dir, exist_ok=True) + self.conn = sqlite3.connect(target, timeout) def close(self): @@ -127,6 +134,14 @@ def createDatabase(self): last_update_date DATE, PRIMARY KEY (image_id, tag_id) )""") + + c.execute("""CREATE TABLE IF NOT EXISTS pixiv_date_info ( + image_id INTEGER PRIMARY KEY, + created_date_epoch INTEGER, + uploaded_date_epoch INTEGER, + created_date DATE, + last_update_date DATE + )""") # image ID is primary key, may not reference to pixiv_master_image as it may not # be downloaded. Used for filtering out AI images. @@ -1377,6 +1392,39 @@ def selectAiTypeByImageId(self, image_id): finally: c.close() + def insertDateInfo(self, image_id, created_date_epoch, uploaded_date_epoch): + try: + c = self.conn.cursor() + image_id = int(image_id) + c.execute('''INSERT OR IGNORE INTO pixiv_date_info (image_id, created_date_epoch, uploaded_date_epoch, created_date, last_update_date) + VALUES (?, ?, ?, datetime('now'), datetime('now')) + ON CONFLICT(image_id) DO UPDATE SET + created_date_epoch = excluded.created_date_epoch, + uploaded_date_epoch = excluded.uploaded_date_epoch, + last_update_date = datetime('now')''', + (image_id, created_date_epoch, uploaded_date_epoch)) + self.conn.commit() + except BaseException: + print('Error at insertDateInfo():', str(sys.exc_info())) + print('failed') + raise + finally: + c.close() + + def selectDateInfoByImageId(self, image_id): + try: + c = self.conn.cursor() + image_id = int(image_id) + c.execute('''SELECT created_date_epoch, uploaded_date_epoch FROM pixiv_date_info WHERE image_id = ?''', (image_id,)) + result = c.fetchone() + return result if result is not None else None + except BaseException: + print('Error at selectDateInfoByImageId():', str(sys.exc_info())) + print('failed') + raise + finally: + c.close() + def cleanUp(self): anim_ext = [".zip", ".gif", ".apng", ".ugoira", ".webm"] try: diff --git a/SERVER_README.md b/SERVER_README.md new file mode 100644 index 00000000..053a1289 --- /dev/null +++ b/SERVER_README.md @@ -0,0 +1,72 @@ +# PixivUtil2 - Server Mode + +This README document serves to distinguish this branch from the upstream PixivUtil2 master branch. + +Server Mode is a specific operation mode of PixivUtil2 which is not meant to be directly used by a user; instead, it is meant to be used as an engine and API client under the PixivUtil Server cluster. + +Do not merge this with the upstream branch. + +## Master Branch Deviations + +Server mode has certain differences from the upstream master branch, as it is optimized to be used by [PixivUtil Server](https://github.com/psilabs-dev/pixivutil-server) and compatibility with [LANraragi](https://github.com/psilabs-dev/LANraragi). + +### Content + +Unlike PixivUtil2, Server Mode prioritizes data integrity and therefore imposes certain limitations to ensure that data is stable. Unless you know exactly what you're doing and understand SQL, direct interaction with downloaded artworks, including updating and deleting of artworks and artwork metadata, is not recommended. + +- All artworks will be downloaded under the `./downloads` directory under the current working directory. +- Archive mode is enabled by default, and Pixiv artworks are automatically sorted to their corresponding Pixiv member ID subdirectories. This format preserves data integrity, as the names of artists and artworks may change over time. +- All videos are saved as GIFs. + +### Metadata + +- Additional metadata of Pixiv artworks, including an artwork's uploaded and created dates, are fetched. This is to maintain parity with LANraragi's Pixiv metadata plugin. +- Member, tag, caption and series info are automatically added (by default, they are disabled in upstream). + +Schema changes include the inclusion of a `pixiv_date_info` table. + +### Authentication + +- `PIXIVUTIL2_COOKIE` environment variable-based cookie loading is enabled. This allows PixivUtil2 to be run as a Dockerized worker container for convenient image deployment. + +## Downstream Branch Naming Conventions + +Development branches targeting `server-mode/main` must use the `server-mode/*` or `server-mode-v*` branch pattern; this also applies to all fixes or patches which target an existing, downstream-only feature or change. Other branches targeting the upstream master branch must use a "bugfix", "dev", or "feature" branch pattern instead. Developers working on a feature for server mode should read the `SERVER_README.md` file. + +> refer to `common/PixivConstant.py::PIXIVUTIL_SERVER_VERSION` for current version. + +Development of new versions of server mode shall include a version in their namespace. For example: + +- "server-mode-v1.2.3/main" (main development branch of server mode v1.2.3) + +### Dev Branches + +In the event that an upstream PR is not merged, a temporary downstream dev branch may be created for the purposes of PixivUtil server deployment: + +- "server-mode/dev" +- "server-mode-v1.2.3/dev" + +Dev (or patch) branches may contain changes from upstream-targeting PRs, such as upstream features or bug fixes. Dev branches shall persist until the PR is merged or closed. If a PR is closed, then the changes shall be considered a downstream-only change and merged permanently into downstream main branches. + +### Tag Conventions + +Tags are used by PixivUtil server, and will use the Server Mode version followed by optional suffix. + +- "v.0.1.0" +- "v.0.1.0-patch" +- "v.0.1.0-dev" + +It is understood that all versions correspond to Server Mode versions, not upstream versions, unless explicitly specified: "v.0.1.0-upstream". + + +## Changelog + +The following changes are downstream features and bugfixes only. Upstream features, bug fixes, and other changes shall not be discussed in this changelog. + +2026-02-22 + +- When downloading via PixivImageHandler, connection failures would result in URL being skipped silently. Explicitly raise PixivException during image downloads instead for caller-side handling. https://github.com/psilabs-dev/PixivUtil2/issues/37 + +2026-02-07 + +- Fixed import bug causing created and uploaded dates of artworks to not be saved, and added corresponding unit tests. diff --git a/common/PixivConfig.py b/common/PixivConfig.py index a13280bf..ba162df3 100644 --- a/common/PixivConfig.py +++ b/common/PixivConfig.py @@ -84,10 +84,10 @@ class PixivConfig(): ConfigItem("IrfanView", "startIrfanSlide", False), ConfigItem("IrfanView", "createDownloadLists", False), - ConfigItem("Settings", "downloadListDirectory", ".", followup=os.path.expanduser), + ConfigItem("Settings", "downloadListDirectory", "." + os.sep + "downloads", followup=os.path.expanduser), ConfigItem("Settings", "useList", False), ConfigItem("Settings", "processFromDb", True), - ConfigItem("Settings", "rootDirectory", "."), + ConfigItem("Settings", "rootDirectory", "." + os.sep + "downloads"), ConfigItem("Settings", "downloadAvatar", False), ConfigItem("Settings", "useSuppressTags", False), ConfigItem("Settings", "tagsLimit", -1), @@ -99,22 +99,22 @@ class PixivConfig(): ConfigItem("Settings", "includeSeriesJSON", False), ConfigItem("Settings", "writeImageXMP", False), ConfigItem("Settings", "writeImageXMPPerImage", False), - ConfigItem("Settings", "verifyImage", False), + ConfigItem("Settings", "verifyImage", True), ConfigItem("Settings", "writeUrlInDescription", False), ConfigItem("Settings", "stripHTMLTagsFromCaption", False), ConfigItem("Settings", "urlBlacklistRegex", ""), - ConfigItem("Settings", "dbPath", ""), + ConfigItem("Settings", "dbPath", "." + os.sep + ".pixivUtil2" + os.sep + "db" + os.sep + "db.sqlite"), ConfigItem("Settings", "setLastModified", True), ConfigItem("Settings", "useLocalTimezone", False), ConfigItem("Settings", "defaultSketchOption", ""), ConfigItem("Filename", "filenameFormat", - "%artist% (%member_id%)" + os.sep + "%urlFilename% - %title%", + "%member_id%" + os.sep + "pixiv_%image_id%" + os.sep + "p_0%page_number%", restriction=stringNotEmpty), ConfigItem("Filename", "filenameMangaFormat", - "%artist% (%member_id%)" + os.sep + "%urlFilename% - %title%", + "%member_id%" + os.sep + "pixiv_%image_id%" + os.sep + "p_0%page_number%", restriction=lambda x: stringNotEmpty(x) and (x.find("%urlFilename%") >= 0 or (x.find('%page_index%') >= 0 or x.find('%page_number%') >= 0)), error_message="At least %urlFilename%, %page_index%, or %page_number% is required in"), ConfigItem("Filename", "filenameInfoFormat", @@ -144,7 +144,7 @@ class PixivConfig(): ConfigItem("Authentication", "username", ""), ConfigItem("Authentication", "password", ""), - ConfigItem("Authentication", "cookie", ""), + ConfigItem("Authentication", "cookie", os.getenv("PIXIVUTIL2_COOKIE")), ConfigItem("Authentication", "cookieFanbox", ""), ConfigItem("Authentication", "cookieFanboxTemp", ""), ConfigItem("Authentication", "refresh_token", ""), @@ -156,10 +156,10 @@ class PixivConfig(): ConfigItem("Pixiv", "r18mode", False), ConfigItem("Pixiv", "r18Type", 0), # Issue #439 ConfigItem("Pixiv", "dateFormat", ""), - ConfigItem("Pixiv", "autoAddMember", False), - ConfigItem("Pixiv", "autoAddTag", False), - ConfigItem("Pixiv", "autoAddCaption", False), - ConfigItem("Pixiv", "autoAddSeries", False), + ConfigItem("Pixiv", "autoAddMember", True), + ConfigItem("Pixiv", "autoAddTag", True), + ConfigItem("Pixiv", "autoAddCaption", True), + ConfigItem("Pixiv", "autoAddSeries", True), ConfigItem("Pixiv", "aiDisplayFewer", False), ConfigItem("FANBOX", "filenameFormatFanboxCover", @@ -182,7 +182,7 @@ class PixivConfig(): ConfigItem("FANBOX", "checkUpdatedLimitFanbox", 0), ConfigItem("FANBOX", "listPathFanbox", "listfanbox.txt"), - ConfigItem("FFmpeg", "ffmpeg", "ffmpeg.exe"), + ConfigItem("FFmpeg", "ffmpeg", "/usr/bin/ffmpeg"), ConfigItem("FFmpeg", "ffmpegCodec", "libvpx-vp9"), ConfigItem("FFmpeg", "ffmpegExt", "webm"), ConfigItem("FFmpeg", "ffmpegParam", "-lossless 0 -crf 15 -b 0 -vsync 0 -pix_fmt yuv420p"), @@ -198,15 +198,15 @@ class PixivConfig(): ConfigItem("FFmpeg", "verboseOutput", False), ConfigItem("Ugoira", "writeUgoiraInfo", False), - ConfigItem("Ugoira", "createUgoira", False), + ConfigItem("Ugoira", "createUgoira", True), ConfigItem("Ugoira", "createMkv", False), ConfigItem("Ugoira", "createWebm", False), ConfigItem("Ugoira", "createWebp", False), - ConfigItem("Ugoira", "createGif", False), + ConfigItem("Ugoira", "createGif", True), ConfigItem("Ugoira", "createApng", False), ConfigItem("Ugoira", "createAvif", False), - ConfigItem("Ugoira", "deleteUgoira", False), - ConfigItem("Ugoira", "deleteZipFile", False), + ConfigItem("Ugoira", "deleteUgoira", True), + ConfigItem("Ugoira", "deleteZipFile", True), ConfigItem("DownloadControl", "minFileSize", 0), ConfigItem("DownloadControl", "maxFileSize", 0), @@ -228,10 +228,10 @@ class PixivConfig(): ConfigItem("DownloadControl", "postProcessingCmd", ""), ConfigItem("DownloadControl", "extensionFilter", ""), ConfigItem("DownloadControl", "downloadBuffer", 512, restriction=lambda x: int(x) > 0), - ConfigItem("DownloadControl", "createPixivArchive", False), - ConfigItem("DownloadControl", "createPixivArchiveCompressionType", "ZIP_STORED", + ConfigItem("DownloadControl", "createPixivArchive", True), + ConfigItem("DownloadControl", "createPixivArchiveCompressionType", "ZIP_DEFLATED", restriction=lambda algorithm: algorithm in {"ZIP_STORED", "ZIP_DEFLATED", "ZIP_BZIP2", "ZIP_LZMA"}), - ConfigItem("DownloadControl", "createPixivArchiveCompressionLevel", 0, restriction=lambda level: level in range(0, 10)), + ConfigItem("DownloadControl", "createPixivArchiveCompressionLevel", 9, restriction=lambda level: level in range(0, 10)), ] def __init__(self): @@ -257,9 +257,7 @@ def loadConfig(self, path=None): else: self.configFileLocation = script_path + os.sep + 'config.ini' - self.configFileLocation = os.path.abspath(self.configFileLocation) - - print(f'Reading {self.configFileLocation} ...') + print('Reading', self.configFileLocation, '...') config = configparser.RawConfigParser() try: @@ -382,4 +380,4 @@ def printConfig(self): cfg.loadConfig("./config.ini") test_filename = "C:\\haha\\hehe\\ ()\\filename.jpg" print(f"[{cfg.customCleanUpRe}]") - print(f"{test_filename} ==> {re.sub(cfg.customCleanUpRe, '', test_filename)}") + print(f"{test_filename} ==> {re.sub(cfg.customCleanUpRe, '', test_filename)}") \ No newline at end of file diff --git a/common/PixivConstant.py b/common/PixivConstant.py index 700d3a17..c1e851c9 100644 --- a/common/PixivConstant.py +++ b/common/PixivConstant.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- PIXIVUTIL_VERSION = '20251112' +PIXIVUTIL_SERVER_VERSION = '0.2.0' PIXIVUTIL_LINK = 'https://github.com/Nandaka/PixivUtil2/releases' PIXIVUTIL_DONATE = 'https://bit.ly/PixivUtilDonation' diff --git a/handler/PixivDownloadHandler.py b/handler/PixivDownloadHandler.py index ca11185b..593707e9 100644 --- a/handler/PixivDownloadHandler.py +++ b/handler/PixivDownloadHandler.py @@ -219,12 +219,10 @@ def download_image(caller, elif config.verifyImage and filename_save.endswith((".jpg", ".png", ".gif")) and is_exists: fp = None try: - from PIL import Image, ImageFile + from PIL import Image fp = open(filename_save, "rb") - # Fix Issue #269, refer to https://stackoverflow.com/a/42682508 - ImageFile.LOAD_TRUNCATED_IMAGES = True img = Image.open(fp) - img.load() + img.verify() fp.close() PixivHelper.print_and_log('info', ' Image verified.') except BaseException: diff --git a/handler/PixivImageHandler.py b/handler/PixivImageHandler.py index d28fe3f2..085b106e 100644 --- a/handler/PixivImageHandler.py +++ b/handler/PixivImageHandler.py @@ -476,8 +476,9 @@ def process_image(caller, manga_files.append((image_id, page, filename)) page = page + 1 - except URLError: - PixivHelper.print_and_log('error', f'Error when download_image(), giving up url: {img}') + except URLError as url_err: + PixivHelper.print_and_log('error', f'Error when download_image(), network failure for url: {img}') + raise PixivException(f'Network failure when downloading: {img}', errorCode=PixivException.DOWNLOAD_FAILED_NETWORK) from url_err PixivHelper.print_and_log(None, '') # XMP image info per images @@ -647,6 +648,12 @@ def get_info_filename(extension): caption_to_update = image.imageCaption if config.autoAddCaption else None db.updateImage(image.imageId, image.imageTitle, filename, image.imageMode, caption=caption_to_update) + # Save date info + created_epoch = image.get_created_date_epoch() + uploaded_epoch = image.get_uploaded_date_epoch() + if created_epoch is not None or uploaded_epoch is not None: + db.insertDateInfo(image.imageId, created_epoch, uploaded_epoch) + if len(manga_files) > 0: if archive_mode_update_manga_image_paths: # Rewrite manga save names to point to their place in the archive. diff --git a/model/PixivImage.py b/model/PixivImage.py index de4827de..41fc458b 100644 --- a/model/PixivImage.py +++ b/model/PixivImage.py @@ -81,6 +81,7 @@ def __init__(self, self.fromBookmark = fromBookmark self.worksDateDateTime = datetime.fromordinal(1) self.js_createDate = None + self.js_uploadDate = None self.bookmark_count = bookmark_count self.image_response_count = image_response_count self.ugoira_data = "" @@ -207,6 +208,9 @@ def ParseInfo(self, page, writeRawJSON): self.worksDateDateTime = datetime_z.parse_datetime(root["createDate"]) assert (self.worksDateDateTime is not None) self.js_createDate = root["createDate"] # store for json file + # uploadDate : "2018-06-08T15:00:04+00:00", + if "uploadDate" in root: + self.js_uploadDate = root["uploadDate"] # Issue #420 if self._tzInfo is not None: self.worksDateDateTime = self.worksDateDateTime.astimezone(self._tzInfo) @@ -607,6 +611,34 @@ def get_translated_tags(self, locale): # Feature #1216 translated_tags.append(tag) return translated_tags + def get_date_epoch_seconds(self, date_string): + """Convert ISO 8601 formatted date to epoch seconds (LANraragi metadata parity)""" + if not date_string: + return None + try: + # Parse the full ISO 8601 date with timezone + import re + from datetime import datetime + + # Use the same parsing logic as LANraragi + # Remove timezone part for manual conversion to handle it like LANraragi + cleaned_date = re.sub(r'(\+\d{2}:\d{2})$', '', date_string) + if 'T' in cleaned_date: + # Parse as naive datetime and treat as UTC (like LANraragi does) + dt = datetime.fromisoformat(cleaned_date) + # Convert to UTC timestamp + return int(dt.replace(tzinfo=datetime_z.utc).timestamp()) + return None + except (ValueError, ImportError): + return None + + def get_created_date_epoch(self): + """Get created date as epoch seconds""" + return self.get_date_epoch_seconds(self.js_createDate) + + def get_uploaded_date_epoch(self): + """Get uploaded date as epoch seconds""" + return self.get_date_epoch_seconds(self.js_uploadDate) class PixivMangaSeries: diff --git a/test/downstream/__init__.py b/test/downstream/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/downstream/test_PixivDateManagement.py b/test/downstream/test_PixivDateManagement.py new file mode 100644 index 00000000..1a16ae60 --- /dev/null +++ b/test/downstream/test_PixivDateManagement.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +import unittest + +# Import PixivHelper first to avoid circular import when PixivImage loads PixivHelper. +import common.PixivHelper # noqa: F401 +from model.PixivImage import PixivImage + + +class TestPixivDateManagement(unittest.TestCase): + def test_get_date_epoch_seconds_parses_iso8601(self): + image = PixivImage() + epoch = image.get_date_epoch_seconds("2023-01-02T03:04:05+00:00") + self.assertEqual(epoch, 1672628645) + + +if __name__ == "__main__": + unittest.main()