Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions PixivDBManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
72 changes: 72 additions & 0 deletions SERVER_README.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 21 additions & 23 deletions common/PixivConfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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",
Expand Down Expand Up @@ -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", ""),
Expand All @@ -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",
Expand All @@ -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"),
Expand All @@ -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),
Expand All @@ -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):
Expand All @@ -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:
Expand Down Expand Up @@ -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)}")
1 change: 1 addition & 0 deletions common/PixivConstant.py
Original file line number Diff line number Diff line change
@@ -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'

Expand Down
6 changes: 2 additions & 4 deletions handler/PixivDownloadHandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 9 additions & 2 deletions handler/PixivImageHandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions model/PixivImage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:

Expand Down
Empty file added test/downstream/__init__.py
Empty file.
17 changes: 17 additions & 0 deletions test/downstream/test_PixivDateManagement.py
Original file line number Diff line number Diff line change
@@ -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()