feat: import a Calibre library
Reads metadata.db and copies the books into a library — from a zip uploaded on the library settings page, or from a path with `litestar calibre-import`. The source is never touched, and re-running only picks up what is new. Also names the formats mimetypes does not know: a Calibre library is full of MOBI and AZW3, and a null content type used to fail the book endpoint.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"""backfill file content types
|
||||
|
||||
Revision ID: d2d69065ede3
|
||||
Revises: 49a9e85a0ffc
|
||||
Create Date: 2026-08-17 11:37:58.959135
|
||||
|
||||
"""
|
||||
|
||||
import warnings
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from advanced_alchemy.types import EncryptedString, EncryptedText, GUID, ORA_JSONB, DateTimeUTC, StoredObject, PasswordHash, FernetBackend
|
||||
from advanced_alchemy.types.encrypted_string import PGCryptoBackend
|
||||
from advanced_alchemy.types.password_hash.argon2 import Argon2Hasher
|
||||
from advanced_alchemy.types.password_hash.passlib import PasslibHasher
|
||||
from advanced_alchemy.types.password_hash.pwdlib import PwdlibHasher
|
||||
from sqlalchemy import Text # noqa: F401
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
__all__ = ["downgrade", "upgrade", "schema_upgrades", "schema_downgrades", "data_upgrades", "data_downgrades"]
|
||||
|
||||
sa.GUID = GUID
|
||||
sa.DateTimeUTC = DateTimeUTC
|
||||
sa.ORA_JSONB = ORA_JSONB
|
||||
sa.EncryptedString = EncryptedString
|
||||
sa.EncryptedText = EncryptedText
|
||||
sa.StoredObject = StoredObject
|
||||
sa.PasswordHash = PasswordHash
|
||||
sa.Argon2Hasher = Argon2Hasher
|
||||
sa.PasslibHasher = PasslibHasher
|
||||
sa.PwdlibHasher = PwdlibHasher
|
||||
sa.FernetBackend = FernetBackend
|
||||
sa.PGCryptoBackend = PGCryptoBackend
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'd2d69065ede3'
|
||||
down_revision = '49a9e85a0ffc'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings("ignore", category=UserWarning)
|
||||
with op.get_context().autocommit_block():
|
||||
schema_upgrades()
|
||||
data_upgrades()
|
||||
|
||||
def downgrade() -> None:
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings("ignore", category=UserWarning)
|
||||
with op.get_context().autocommit_block():
|
||||
data_downgrades()
|
||||
schema_downgrades()
|
||||
|
||||
def schema_upgrades() -> None:
|
||||
"""schema upgrade migrations go here."""
|
||||
pass
|
||||
|
||||
def schema_downgrades() -> None:
|
||||
"""schema downgrade migrations go here."""
|
||||
pass
|
||||
|
||||
def data_upgrades() -> None:
|
||||
"""
|
||||
Name the format of every file whose content type was never worked out.
|
||||
|
||||
`create_many_from_existing_files` filled the column from `mimetypes.guess_type`,
|
||||
which answers None for `.mobi`, `.azw`, `.fb2` and `.lit` — so a consume-directory
|
||||
import of any of those stored a null, and the OPDS acquisition link a reader app
|
||||
uses to decide what it can open carried nothing.
|
||||
|
||||
Both write paths now go through `guess_content_type`, which this uses too, so the
|
||||
formats in its table get named retroactively. A row it still cannot name is **left
|
||||
null** rather than filled with a placeholder: null is the truth, the column is
|
||||
nullable, and the one consumer that needs a string substitutes one itself.
|
||||
Idempotent: it only looks at rows that carry nothing.
|
||||
"""
|
||||
from chitai.services.utils import guess_content_type
|
||||
|
||||
connection = op.get_bind()
|
||||
|
||||
files = connection.execute(
|
||||
sa.text(
|
||||
"SELECT id, path FROM file_metadata "
|
||||
"WHERE content_type IS NULL OR content_type = ''"
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
parameters = [
|
||||
{"id": id, "content_type": content_type}
|
||||
for id, path in files
|
||||
if (content_type := guess_content_type(path)) is not None
|
||||
]
|
||||
|
||||
if not parameters:
|
||||
return
|
||||
|
||||
batch_size = 1000
|
||||
for start in range(0, len(parameters), batch_size):
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"UPDATE file_metadata SET content_type = :content_type WHERE id = :id"
|
||||
),
|
||||
parameters[start : start + batch_size],
|
||||
)
|
||||
|
||||
|
||||
def data_downgrades() -> None:
|
||||
"""Add any optional data downgrade migrations here!"""
|
||||
Reference in New Issue
Block a user