Author SHA1 Message Date
patrick ead43b2562 fix: download every selected book, not just the last
ci / backend (push) Failing after 3m3s
ci / frontend (push) Successful in 2m2s
The frontend built book_ids with `set` rather than `append`, so the archive
only ever held one book. The proxy also sent an empty Content-Length, which
is not valid HTTP/1.1, and buffered the whole zip before forwarding a byte
of it.

Entries now carry the file's own name, suffixed when two books claim the
same one, and the books are resolved before the stream starts so a miss is
a 404 instead of a truncated archive under a 200.
2026-09-14 11:04:47 -04:00
patrick 412a73cedf fix: tell the tests where the database actually is
ci / backend (push) Failing after 1m16s
ci / frontend (push) Successful in 1m58s
DOCKER_HOST only decides which daemon creates the container; the address the
test dials comes from POSTGRES_HOST, which defaults to the job container's own
loopback. Setting one without the other still times out.
2026-08-18 00:49:41 -04:00
patrick 5176dfcb77 fix: give the backend CI job a docker daemon it can address
ci / backend (push) Failing after 1m30s
ci / frontend (push) Successful in 1m58s
The runner has docker, so the database container started; the job just could
not reach it. pytest-databases takes the address from DOCKER_HOST, and unset
means 127.0.0.1 -- the job container's loopback, not the namespace the sibling
published on. A dind service makes it resolve to a host that answers.
2026-08-18 00:45:43 -04:00
patrick b85ba92380 fix: declare ruff as a dev dependency
ci / backend (push) Failing after 1m7s
ci / frontend (push) Successful in 1m59s
CI has no nix shell, and ruff was only ever a nix package, so both lint steps
died with "Failed to spawn: ruff". Pinned exactly: the formatter deciding what
is correct must be one version, or CI rejects what the dev shell produced.
2026-08-18 00:37:00 -04:00
11 changed files with 316 additions and 68 deletions
+24 -2
View File
@@ -19,6 +19,30 @@ jobs:
backend:
runs-on: ubuntu-latest
# pytest-databases starts PostgreSQL in a container and then connects to it, and those are
# two different addresses that have to be set separately:
#
# DOCKER_HOST which daemon to create the container on (_service.py get_docker_host)
# POSTGRES_HOST where the test then connects (docker/postgres.py, default
# 127.0.0.1 -- the job container's own loopback, where nothing listens,
# because the database is a sibling container on another namespace)
#
# Setting only the first leaves the tests dialling 127.0.0.1 and timing out with
# "Service 'pytest_databases_postgres' failed to come online".
#
# If the runner is ever given its own dind sidecar, drop this services block and keep the
# two env vars pointed at whatever host it exposes.
services:
docker:
image: docker:27-dind
options: --privileged
env:
DOCKER_TLS_CERTDIR: ''
env:
DOCKER_HOST: tcp://docker:2375
POSTGRES_HOST: docker
defaults:
run:
working-directory: backend
@@ -42,8 +66,6 @@ jobs:
- name: Lint
run: uv run ruff check src/ tests/
# pytest-databases starts a throwaway PostgreSQL container, so this needs a working
# Docker daemon on the runner — the same requirement the release workflow has.
- name: Tests
run: uv run pytest tests/ -q
+2 -1
View File
@@ -36,6 +36,7 @@ dev = [
"pytest>=8.4.2",
"pytest-asyncio>=1.2.0",
"pytest-databases[postgres]>=0.15.0",
"ruff==0.15.14",
]
[tool.ruff.lint.per-file-ignores]
@@ -48,4 +49,4 @@ asyncio_mode = "auto"
testpaths = ["tests"]
filterwarnings = [
"ignore::jwt.warnings.InsecureKeyLengthWarning",
]
]
+2 -1
View File
@@ -4,7 +4,8 @@ pkgs.mkShell {
buildInputs = with pkgs; [
# Python development environment for Chitai
python313Packages.greenlet
python313Packages.ruff
# ruff is a dev dependency in pyproject.toml, not a shell package: CI has no nix, so a
# nix-only formatter is one CI cannot run, and two copies could disagree on formatting.
uv
# postgres database
+7 -1
View File
@@ -509,9 +509,15 @@ class BookController(Controller):
Returns:
A stream of the compressed ZIP file.
Raises:
FileNotFoundError: If the selection turned up no file that is on disk,
which the handler renders as a 404. Awaiting here is what makes that
possible: the books are resolved before the response begins, so a
miss is still a status code rather than a truncated archive.
"""
return Stream(
books_service.get_files(book_ids, library_id),
await books_service.get_files(book_ids, library_id),
headers={
"Content-Disposition": 'attachment; filename="download.zip"',
"Content-Type": "application/zip",
+21 -1
View File
@@ -12,4 +12,24 @@ def not_found_exception_handler(_: Request, exc: NotFoundError) -> Response:
)
exception_handlers = {NotFoundError: not_found_exception_handler}
def file_not_found_exception_handler(_: Request, exc: FileNotFoundError) -> Response:
"""
Handler for a row that describes a file the caller cannot be given.
The row exists, so `NotFoundError` is the wrong answer and the services raise
`FileNotFoundError` instead: the book is real, the file it names is not on disk,
or the file belongs to another book. All three are the caller asking for
something that is not there, which is a 404 — before this they escaped as
`ValueError` and reached the client as a 500.
"""
return Response(
media_type=MediaType.TEXT,
content=f"Not found error: {exc}",
status_code=404,
)
exception_handlers = {
NotFoundError: not_found_exception_handler,
FileNotFoundError: file_not_found_exception_handler,
}
+105 -41
View File
@@ -243,6 +243,67 @@ def _unused_path(path: Path) -> Path:
return candidate
def _unused_name(name: str, taken: set[str]) -> str:
"""
The given name, or the first free `name (n).ext` beside it.
The in-memory counterpart of `_unused_path`, for names within an archive rather
than a directory. Two books can hold a file called `Dune.epub` — nothing stops
them, since each lives in its own directory — and a zip happily stores the name
twice, which loses one of them on extraction.
Args:
name: The filename to place.
taken: Names already placed; the chosen one is added to it.
Returns:
A name that nothing in `taken` occupies.
"""
path = Path(name)
candidate = name
suffix = 1
while candidate in taken:
suffix += 1
candidate = f"{path.stem} ({suffix}){path.suffix}"
taken.add(candidate)
return candidate
async def _stream_zip(entries: list[tuple[str, Path]]) -> AsyncIterator[bytes]:
"""
Compress already-resolved files into a zip, a chunk at a time.
Args:
entries: `(name within the archive, path on disk)` pairs, in archive order.
Yields:
Chunks of compressed zip file data.
"""
stream = _ZipStream()
chunk_size = 32768 # 32 KiB
with zipfile.ZipFile(stream, "w", zipfile.ZIP_DEFLATED) as zip_file:
for arcname, path in entries:
info = zipfile.ZipInfo.from_file(path, arcname=arcname)
info.compress_type = zipfile.ZIP_DEFLATED
with zip_file.open(info, "w") as entry:
async with aiofiles.open(path, "rb") as source:
while content := await source.read(chunk_size):
entry.write(content)
if chunk := stream.drain():
yield chunk
if chunk := stream.drain():
yield chunk
# The central directory, written when ZipFile closes.
if chunk := stream.drain():
yield chunk
def _title_keys(title: str, authors: set[str]) -> set[str]:
"""
Every normalized title one book could reasonably be filed under.
@@ -1815,11 +1876,11 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
A File object ready for download.
Raises:
ValueError: If the file is missing or not found for the given book.
FileNotFoundError: If the file is missing or not found for the given book.
"""
book = await self.get(book_id)
if book.path is None:
raise ValueError("Cannot download file: book has no path")
raise FileNotFoundError("Cannot download file: book has no path")
for file in book.files:
if file.id != file_id:
@@ -1827,16 +1888,23 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
path = Path(book.path) / Path(file.path)
if not await aios.path.isfile(path):
raise ValueError("The file is missing")
raise FileNotFoundError("The file is missing")
# Rows written before `guess_content_type` existed can hold null here, and
# so can `guess_content_type` itself. Litestar fills a null in from the
# filename, falling back to `application/octet-stream` on the response.
#
# `filename` is what puts a name in the Content-Disposition. Without it
# Litestar sends `attachment; filename=""` and a client that has nothing
# else to go on — an OPDS reader, curl -O — names the file after the
# numeric URL, extension and all.
return File(
path, media_type=file.content_type or guess_content_type(file.path)
path,
filename=Path(file.path).name,
media_type=file.content_type or guess_content_type(file.path),
)
raise ValueError("No such file for the given book")
raise FileNotFoundError("No such file for the given book")
async def get_files(
self, book_ids: list[int], library_id: int
@@ -1844,55 +1912,51 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
"""
Get all selected book files as a compressed zip file.
Streams the zip file in chunks to avoid loading the entire archive into memory.
Each book gets its own directory in the archive, so two books that happen to
share a filename do not overwrite one another. Files that have a row but no
longer exist on disk are skipped.
Every book and path is resolved here, up front, and only the compression is
left to the returned iterator. That ordering is the point: a generator that
queried on its first iteration would run *after* the response had already
started, since Litestar only drains a `Stream` once the headers are away — so
a missing book or a database error arrived as a truncated archive under a
`200` rather than as an error anyone could act on.
Entries carry the file's own name. A name claimed twice — two books can each
hold a `Dune.epub`, living as they do in separate directories — gets the
second suffixed to `Dune (2).epub`, so nothing is overwritten on extraction.
Files that have a row but no longer exist on disk are skipped.
Args:
book_ids: List of book IDs to include in the zip.
library_id: The ID of the library containing the books.
Yields:
Chunks of compressed zip file data.
Returns:
An async iterator over chunks of compressed zip file data.
Raises:
FileNotFoundError: If the selection turned up no file that is on disk.
"""
books = await self.list(Book.id.in_(book_ids), Book.library_id == library_id)
stream = _ZipStream()
chunk_size = 32768 # 32 KiB
entries: list[tuple[str, Path]] = []
taken: set[str] = set()
with zipfile.ZipFile(stream, "w", zipfile.ZIP_DEFLATED) as zip_file:
for book in books:
# Books without files have no path, and `file.path` is stored relative
# to it, so there is nothing to resolve against.
if book.path is None:
for book in books:
# Books without files have no path, and `file.path` is stored relative
# to it, so there is nothing to resolve against.
if book.path is None:
continue
book_path = Path(book.path)
for file in book.files:
path = book_path / file.path
if not await aios.path.isfile(path):
continue
book_path = Path(book.path)
for file in book.files:
path = book_path / file.path
if not await aios.path.isfile(path):
continue
entries.append((_unused_name(Path(file.path).name, taken), path))
# Namespaced by the book's own directory to avoid collisions.
info = zipfile.ZipInfo.from_file(
path, arcname=f"{book_path.name}/{Path(file.path).name}"
)
info.compress_type = zipfile.ZIP_DEFLATED
if not entries:
raise FileNotFoundError("No files to download for the given books")
with zip_file.open(info, "w") as entry:
async with aiofiles.open(path, "rb") as source:
while content := await source.read(chunk_size):
entry.write(content)
if chunk := stream.drain():
yield chunk
if chunk := stream.drain():
yield chunk
# The central directory, written when ZipFile closes.
if chunk := stream.drain():
yield chunk
return _stream_zip(entries)
async def update_book(
self, book_id: int, update_data: ModelDictT[Book], library: Library
@@ -221,7 +221,9 @@ class TestBookServiceCRUD:
archive_bytes = b"".join(
[
chunk
async for chunk in books_service.get_files([book.id], test_library.id)
async for chunk in await books_service.get_files(
[book.id], test_library.id
)
]
)
@@ -231,13 +233,79 @@ class TestBookServiceCRUD:
names = archive.namelist()
assert len(names) == len(contents)
# Entries are namespaced by the book's directory, so two books sharing a
# filename cannot overwrite one another.
assert {Path(name).parent.name for name in names} == {"Dracula"}
# Entries carry the file's own name, with no directory in front of it.
assert set(names) == set(contents)
for name, payload in contents.items():
entry = next(n for n in names if Path(n).name == name)
assert archive.read(entry) == payload
assert archive.read(name) == payload
async def test_get_files_suffixes_a_name_claimed_twice(
self, books_service: BookService, test_library: m.Library
) -> None:
"""Two books can each hold a `Dune.epub`; the archive must keep both.
Entries are named after the file, and a zip stores the same name twice
happily — leaving whichever copy was written last as the one that survives
extraction. The second is suffixed instead.
"""
payloads = {
"Herbert": b"herbert-payload-" * 512,
"Other": b"other-payload-" * 512,
}
book_ids = []
for author, payload in payloads.items():
book_data = BookCreate(
library_id=test_library.id,
title="Dune",
authors=[author],
pages=412,
)
book = await books_service.to_model_on_create(book_data.model_dump())
assert isinstance(book, m.Book)
book.path = f"{test_library.root_path}/{author}/Dune"
await aios.makedirs(book.path) # type: ignore[arg-type]
Path(book.path, "Dune.epub").write_bytes(payload)
book.files.append(
m.FileMetadata(
path="Dune.epub",
size=len(payload),
hash=f"hash-{author}",
content_type=None,
)
)
books_service.repository.session.add(book)
await books_service.repository.session.commit()
book_ids.append(book.id)
archive_bytes = b"".join(
[
chunk
async for chunk in await books_service.get_files(
book_ids, test_library.id
)
]
)
with zipfile.ZipFile(BytesIO(archive_bytes)) as archive:
assert archive.testzip() is None
assert sorted(archive.namelist()) == ["Dune (2).epub", "Dune.epub"]
stored = {archive.read(name) for name in archive.namelist()}
assert stored == set(payloads.values())
async def test_get_files_refuses_a_selection_with_nothing_in_it(
self, books_service: BookService, test_library: m.Library
) -> None:
"""A miss is a 404, raised before the response starts.
Resolving lazily inside the stream meant an empty selection produced a valid
22-byte archive under a 200, which tells the caller nothing went wrong.
"""
with pytest.raises(FileNotFoundError):
await books_service.get_files([424242], test_library.id)
@pytest.mark.asyncio
+27
View File
@@ -261,6 +261,7 @@ dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-databases", extra = ["postgres"] },
{ name = "ruff" },
]
[package.metadata]
@@ -286,6 +287,7 @@ dev = [
{ name = "pytest", specifier = ">=8.4.2" },
{ name = "pytest-asyncio", specifier = ">=1.2.0" },
{ name = "pytest-databases", extras = ["postgres"], specifier = ">=0.15.0" },
{ name = "ruff", specifier = "==0.15.14" },
]
[[package]]
@@ -1277,6 +1279,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/e5/d708d262b600a352abe01c2ae360d8ff75b0af819b78e9af293191d928e6/rich_click-1.9.7-py3-none-any.whl", hash = "sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b", size = 71491, upload-time = "2026-01-31T04:29:26.777Z" },
]
[[package]]
name = "ruff"
version = "0.15.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" },
{ url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" },
{ url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" },
{ url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" },
{ url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" },
{ url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" },
{ url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" },
{ url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" },
{ url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" },
{ url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" },
{ url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" },
{ url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" },
{ url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" },
{ url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" },
{ url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" },
]
[[package]]
name = "six"
version = "1.17.0"
+31 -6
View File
@@ -99,12 +99,37 @@ the workflow.
1. **A registered `act_runner`.** Gitea Actions is enabled instance-side but does nothing without a
runner. Register one against the repo or the instance with the label `ubuntu-latest`.
2. **The runner needs a Docker daemon.** This is the single most common failure for image-building
workflows on Gitea. `act_runner` in docker mode runs each job inside a container that has no
daemon of its own. Either run a `docker:dind` sidecar next to the runner and set
`DOCKER_HOST=tcp://docker:2376` (with TLS certs shared over a volume), or run the runner in host
mode with the socket mounted. The dind sidecar is the safer of the two — mounting the host socket
into job containers gives any workflow root on the runner host.
2. **The runner needs a Docker daemon, at an address the job can reach.** `act_runner` in docker
mode runs each job inside a container with no daemon of its own. Either run a `docker:dind`
sidecar next to the runner and set `DOCKER_HOST=tcp://docker:2376` (with TLS certs shared over a
volume), or run the runner in host mode. The dind sidecar is the safer of the two — mounting the
host socket into job containers gives any workflow root on the runner host.
**Reachability is a separate question from availability, and it bit us.** A containerised job
with a working daemon still fails `pytest tests/` with
`Service 'pytest_databases_postgres' failed to come online`: the database container starts
fine, but its published port lands on the daemon's network namespace while the test process
looks for it on the job container's loopback.
Two variables control two different things, and both must be set:
| Variable | Decides | Read by |
| --- | --- | --- |
| `DOCKER_HOST` | which daemon the container is created on | `_service.py` `get_docker_host()` |
| `POSTGRES_HOST` | the address the test then connects to | `docker/postgres.py` `postgres_host`, default `127.0.0.1` |
Setting only `DOCKER_HOST` is not enough — `DockerService.run()` takes `container_host` as a
plain argument defaulting to `127.0.0.1`, and the postgres fixture fills it from
`POSTGRES_HOST`. (There *is* a `DOCKER_HOST`-parsing helper in `pytest_databases`, but it is
`_get_docker_ip()` on the docker-compose class in `docker/__init__.py` and no part of this
path uses it. Do not be misled by it, as I was.)
Until the runner grows its own sidecar, `ci.yml`'s backend job carries a `docker:dind` service
of its own with `DOCKER_HOST: tcp://docker:2375`. That needs the runner to permit
`--privileged`. The same treatment is still owed to `release.yml` — its `quality` job runs the
same tests, and its `smoke` job talks to compose, where the published ports would move to the
dind host too, so `curl http://localhost:3000` becomes `curl http://docker:3000`. Configuring
the runner once (option 1) avoids all of that.
3. **Action resolution.** A bare `uses: docker/build-push-action@v6` does not mean github.com here.
Gitea resolves it against `[actions] DEFAULT_ACTIONS_URL`, which defaults to `https://gitea.com`.
That is fine as it stands — `actions/checkout@v4`, `docker/setup-buildx-action@v3`,
@@ -134,8 +134,11 @@ export class BookOperationsState {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local temporary, not reactive state
const downloadUrl = new URL(`/api/books/download`, page.url.origin);
downloadUrl.searchParams.set('library_id', this.libraryId);
// `append`, not `set` — `set` replaces the existing value, so every book but
// the last was dropped from the query string and the archive came back holding
// one book however many were selected.
bookIds.forEach((id) => {
downloadUrl.searchParams.set('book_ids', id.toString());
downloadUrl.searchParams.append('book_ids', id.toString());
});
this.download(downloadUrl, filename || 'download.zip');
+19 -8
View File
@@ -16,16 +16,27 @@ async function handleResponse(response: Response) {
const data = await response.json();
return json(data);
} else {
// Handle file or other non-JSON response
const responseBody = await response.arrayBuffer();
// Only headers the backend actually sent are forwarded. Defaulting a missing
// one to '' put `Content-Length: ` on the wire — SvelteKit's `setResponse`
// hands every header to `res.setHeader` as-is, empty or not — and an empty
// Content-Length is not valid HTTP/1.1, so strict clients reject the whole
// response. The multi-book ZIP is streamed and carries no Content-Length,
// which made it the one download that always tripped over this.
const headers = new Headers({ 'Content-Type': contentType });
for (const name of ['Content-Disposition', 'Content-Length']) {
const value = response.headers.get(name);
if (value) headers.set(name, value);
}
return new Response(responseBody, {
// The body is passed through as a stream, matching what `bodyOf` does for
// requests. `arrayBuffer()` held the whole response here first, so nothing
// reached the browser until the last byte had been read and this process
// carried a copy of it — survivable for one book, not for a ZIP of a whole
// shelf, which only starts arriving once the backend has finished
// compressing all of it.
return new Response(response.body, {
status: response.status,
headers: {
'Content-Type': contentType,
'Content-Disposition': response.headers.get('Content-Disposition') || '',
'Content-Length': response.headers.get('Content-Length') || ''
}
headers
});
}
}