Skip to content
Merged
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
16 changes: 10 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,23 @@

## Coding Conventions

When working in the Papyrus workspace, read `../CODING_STYLE.md` for the
maintainer's reviewed examples. The rules below also apply in standalone checkouts.

- Use meaningful local and callback names; avoid single-letter operands and redundant qualifiers when a clear role name is available.
- Prefer clear branches or named predicates to nested conditional expressions and substantial inline validation conditions. Preserve predicate polarity and null handling when extracting helpers.
- Extract meaningful constants and focused helpers for distracting literals or construction details. Preserve types, scope, return values, and behavior.
- Keep Ruff's 120-character line length. Use trailing commas for deliberate multiline argument layouts, and give multiline model/schema fields breathing room while keeping related simple fields together.
- Remove documentation that merely repeats a declaration and decorative section banners. Retain substantive contracts and constraints in docstrings.
- Keep route handlers thin. They should parse input, enforce dependencies/auth, call a service, translate errors, and return schemas.
- Put business rules, query orchestration, and transaction-aware logic in `papyrus/services`, not in route modules.
- Reuse or extend existing schema modules before creating new top-level packages.
- Follow existing async patterns with `AsyncSession`, explicit return types, and Pydantic schemas.
- Use vertical spacing to separate logical steps inside functions. Keep related statements together, but add a blank line when moving between setup, validation, branching, and side effects.
- Prefer readable spacing over dense blocks. Short guard clauses, temporary assignments, and context-manager branches should usually be visually separated the way `papyrus/services/email.py` and `papyrus/services/auth/` are structured.
- Keep consecutive single-line statements together. A change in purpose does not require a blank line, including query execution, result extraction, assignments, persistence calls, and returns.
- Treat any multiline statement or block as a visual boundary inside a function. If one adjacent statement is multiline and both sides are real code, separate the two statements with a blank line.
- Apply that rule to multiline conditionals, loops, `with` blocks, `try` blocks, multiline calls, multiline literals, and multiline return values. Treat `if` / `elif` / `else` as one block.
- Do not add a blank line just because a docstring appears above the first statement in a function. The multiline-block rule applies between code statements, not between a docstring and the first line of code.
- Add a blank line between query execution and result extraction, especially around `session.execute(...)` and subsequent `scalar*()` reads.
- Add a blank line before persistence and side-effect boundaries such as `session.add(...)`, `send_email(...)`, `commit()`, redirects, and returned result objects when they start a new phase of the function.
- Keep tightly coupled short sequences together when they are clearly one step. Do not add spacing mechanically when it makes a two-line operation harder to read.
- Prefer extra spacing around state transitions and persistence boundaries rather than packing setup, branching, and writes into a single block.
- Do not pad the start or end of a block. Spacing follows blocks and multiline statements, not inferred logical phases or side effects.
- When adding a new router module, register it in `papyrus/api/routes/__init__.py`.
- Avoid adding dependencies unless the user explicitly asks for them.
- Keep changes scoped. Do not refactor unrelated areas as part of a focused fix.
Expand Down
9 changes: 8 additions & 1 deletion papyrus/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,14 @@ class PaginationParams:
def __init__(
self,
page: Annotated[int, Query(ge=1, description="Page number")] = 1,
limit: Annotated[int, Query(ge=1, le=100, description="Items per page")] = 20,
limit: Annotated[
int,
Query(
ge=1,
le=100,
description="Items per page",
),
] = 20,
sort: Annotated[
str | None,
Query(description="Sort field with optional - prefix for descending"),
Expand Down
36 changes: 32 additions & 4 deletions papyrus/api/routes/acquisition.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,17 +70,22 @@ def _credentials(api_key: str | None, username: str | None, password: str | None
values = {
key: value for key, value in {"api_key": api_key, "username": username, "password": password}.items() if value
}

if not values:
return None

return {"encrypted": encrypt_secret_payload(values)}


def _merge_credentials(endpoint: AcquisitionEndpointModel, replacement: dict[str, str] | None) -> None:
if replacement is None:
return

current = endpoint.credentials or {}

if encrypted := current.get("encrypted"):
current = decrypt_secret_payload(encrypted)

endpoint.credentials = {
"encrypted": encrypt_secret_payload({**current, **decrypt_secret_payload(replacement["encrypted"])})
}
Expand Down Expand Up @@ -133,6 +138,7 @@ async def list_endpoints(user_id: CurrentUserId, db: DbSession) -> list[Acquisit
.where(AcquisitionEndpointModel.owner_user_id == user_id)
.order_by(AcquisitionEndpointModel.name)
)

return list(result.scalars())


Expand All @@ -153,6 +159,7 @@ async def create_endpoint(
),
settings=request.settings,
)

db.add(endpoint)
await db.commit()
await db.refresh(endpoint)
Expand All @@ -165,13 +172,16 @@ async def update_endpoint(
) -> AcquisitionEndpointModel:
endpoint = await owned_endpoint(db, user_id, endpoint_id)
updates = request.model_dump(exclude_unset=True, exclude={"api_key", "username", "password"})

for field, value in updates.items():
setattr(endpoint, field, str(value) if field == "base_url" else value)

replacement = _credentials(
request.api_key.get_secret_value() if request.api_key else None,
request.username.get_secret_value() if request.username else None,
request.password.get_secret_value() if request.password else None,
)

_merge_credentials(endpoint, replacement)
await db.commit()
await db.refresh(endpoint)
Expand All @@ -190,9 +200,7 @@ async def test_connection(
db: DbSession,
) -> AcquisitionEndpointTestResult:
endpoint = await build_test_endpoint(db, user_id, request)

await test_endpoint_connection(endpoint)

return AcquisitionEndpointTestResult(ok=True)


Expand All @@ -203,10 +211,13 @@ async def search_releases(user_id: CurrentUserId, request: SearchRequest, db: Db
AcquisitionEndpointModel.enabled.is_(True),
AcquisitionEndpointModel.kind.in_(("prowlarr", "torznab")),
)

if request.endpoint_ids:
statement = statement.where(AcquisitionEndpointModel.endpoint_id.in_(request.endpoint_ids))

result = await db.execute(statement)
releases: list[Release] = []

for endpoint in result.scalars():
for candidate in await search_endpoint(endpoint, request.query):
releases.append(
Expand All @@ -221,6 +232,7 @@ async def search_releases(user_id: CurrentUserId, request: SearchRequest, db: Db
format_hints=candidate.format_hints,
)
)

return releases


Expand Down Expand Up @@ -274,26 +286,32 @@ async def submit_release_batch_route(


@protected_router.post(
"/arr/{endpoint_id}/commands", response_model=AcquisitionJob, status_code=status.HTTP_201_CREATED
"/arr/{endpoint_id}/commands",
response_model=AcquisitionJob,
status_code=status.HTTP_201_CREATED,
)
async def run_arr_command(
user_id: CurrentUserId, endpoint_id: UUID, request: ArrCommandRequest, db: DbSession
) -> AcquisitionJobModel:
"""Delegate a managed search/acquisition to Readarr or another Arr app."""
endpoint = await owned_endpoint(db, user_id, endpoint_id)

job = AcquisitionJobModel(
owner_user_id=user_id,
endpoint_id=endpoint.endpoint_id,
title=request.command,
download_url=f"arr-command:{request.command}",
)

db.add(job)

try:
job.client_reference = await dispatch_arr_command(endpoint, request.command, request.ids)
job.status = "submitted"
except HTTPException as exc:
job.status = "failed"
job.error = str(exc.detail)

await db.commit()
await db.refresh(job)
return job
Expand Down Expand Up @@ -343,7 +361,12 @@ async def choose_job_file(
request: AcquisitionFileSelectionRequest,
db: DbSession,
) -> AcquisitionJobModel:
return await select_job_file(db, user_id, job_id, request.file_index)
return await select_job_file(
db,
user_id,
job_id,
request.file_index,
)


@protected_router.post("/jobs/{job_id}/cancel", response_model=AcquisitionJob)
Expand Down Expand Up @@ -382,6 +405,7 @@ async def list_rules(user_id: CurrentUserId, db: DbSession) -> list[AcquisitionR
@protected_router.post("/rules", response_model=AcquisitionRule, status_code=status.HTTP_201_CREATED)
async def create_rule(user_id: CurrentUserId, request: AcquisitionRuleCreate, db: DbSession) -> AcquisitionRuleModel:
await owned_endpoint(db, user_id, request.download_client_id)

rule = AcquisitionRuleModel(
owner_user_id=user_id,
name=request.name,
Expand All @@ -391,6 +415,7 @@ async def create_rule(user_id: CurrentUserId, request: AcquisitionRuleCreate, db
filters=request.filters,
enabled=request.enabled,
)

db.add(rule)
await db.commit()
await db.refresh(rule)
Expand All @@ -404,9 +429,12 @@ async def run_acquisition_rule(user_id: CurrentUserId, rule_id: UUID, db: DbSess
AcquisitionRuleModel.rule_id == rule_id, AcquisitionRuleModel.owner_user_id == user_id
)
)

rule = result.scalar_one_or_none()

if rule is None:
raise HTTPException(status_code=404, detail="Acquisition rule not found")

return await run_rule(db, rule)


Expand Down
14 changes: 12 additions & 2 deletions papyrus/api/routes/goals.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ async def list_goals(user_id: CurrentUserId, db: DBSession, is_active: bool | No
return GoalList(goals=await service.list_goals(db, user_id, is_active))


@router.post("", response_model=Goal, status_code=status.HTTP_201_CREATED, summary="Create a new goal")
@router.post(
"",
response_model=Goal,
status_code=status.HTTP_201_CREATED,
summary="Create a new goal",
)
async def create_goal(user_id: CurrentUserId, request: CreateGoalRequest, db: DBSession) -> Goal:
return await service.create_goal(db, user_id, request)

Expand All @@ -32,7 +37,12 @@ async def get_goal(user_id: CurrentUserId, goal_id: UUID, db: DBSession) -> Goal

@router.patch("/{goal_id}", response_model=Goal, summary="Update goal")
async def update_goal(user_id: CurrentUserId, goal_id: UUID, request: UpdateGoalRequest, db: DBSession) -> Goal:
return await service.update_goal(db, user_id, goal_id, request)
return await service.update_goal(
db,
user_id,
goal_id,
request,
)


@router.delete("/{goal_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete goal")
Expand Down
25 changes: 22 additions & 3 deletions papyrus/api/routes/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@
DBSession = Annotated[AsyncSession, Depends(get_db)]


@router.post("", response_model=MediaAssetResponse, status_code=status.HTTP_201_CREATED, summary="Upload private media")
@router.post(
"",
response_model=MediaAssetResponse,
status_code=status.HTTP_201_CREATED,
summary="Upload private media",
)
@limiter.limit(lambda: f"{get_settings().rate_limit_upload}/minute")
async def upload_media(
request: Request,
Expand All @@ -30,24 +35,38 @@ async def upload_media(
file: Annotated[UploadFile, File()],
) -> MediaAssetResponse:
"""Upload a book file or cover image for the authenticated user."""
asset = await media_service.upload_media(db, user_id, book_id=book_id, kind=kind, file=file)
asset = await media_service.upload_media(
db,
user_id,
book_id=book_id,
kind=kind,
file=file,
)

return MediaAssetResponse.model_validate(asset)


@router.get("/usage", response_model=MediaUsageResponse, summary="Get media storage usage")
async def get_media_usage(user_id: CurrentUserId, db: DBSession) -> MediaUsageResponse:
"""Return authenticated user's file storage usage."""
used_bytes, quota_bytes, available_bytes = await media_service.usage(db, user_id)
return MediaUsageResponse(used_bytes=used_bytes, quota_bytes=quota_bytes, available_bytes=available_bytes)

return MediaUsageResponse(
used_bytes=used_bytes,
quota_bytes=quota_bytes,
available_bytes=available_bytes,
)


@router.get("/{asset_id}", summary="Download private media")
async def download_media(user_id: CurrentUserId, db: DBSession, asset_id: UUID) -> FileResponse:
"""Download an owned media asset."""
asset = await media_service.get_owned_asset(db, user_id, asset_id)
path = media_service.asset_path(asset)

if not path.exists():
raise NotFoundError("Media asset was not found")

return FileResponse(path, media_type=asset.content_type, filename=asset.original_filename)


Expand Down
2 changes: 2 additions & 0 deletions papyrus/api/routes/opds.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class RelayResponse(StreamingResponse):

def __init__(self, resource: RelayResource) -> None:
self.resource = resource

headers = {
"X-OPDS-URL": resource.url,
"Content-Type": resource.content_type,
Expand All @@ -27,6 +28,7 @@ def __init__(self, resource: RelayResource) -> None:
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "sandbox; default-src 'none'",
}

if resource.length is not None:
headers["Content-Length"] = str(resource.length)

Expand Down
16 changes: 14 additions & 2 deletions papyrus/api/routes/progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,14 @@ async def list_reading_sessions(
start_date: date | None = None,
end_date: date | None = None,
) -> ReadingSessionList:
return await service.list_sessions(db, user_id, pagination, book_id, start_date, end_date)
return await service.list_sessions(
db,
user_id,
pagination,
book_id,
start_date,
end_date,
)


@router.post("/sessions", response_model=ReadingSession, summary="Record reading session")
Expand All @@ -39,4 +46,9 @@ async def create_reading_session(
async def get_reading_statistics(
user_id: CurrentUserId, db: DBSession, start_date: date | None = None, end_date: date | None = None
) -> ReadingStatistics:
return await service.statistics(db, user_id, start_date, end_date)
return await service.statistics(
db,
user_id,
start_date,
end_date,
)
1 change: 1 addition & 0 deletions papyrus/api/routes/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
async def get_data_sync_settings() -> DataSyncSettingsResponse:
"""Return public sync capabilities for client custom-server setup."""
settings = get_settings()

return DataSyncSettingsResponse(
data_sync_url=settings.powersync_service_url,
file_storage=FileStorageSettings(
Expand Down
8 changes: 7 additions & 1 deletion papyrus/api/routes/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,5 +96,11 @@ async def change_password(
db: DBSession,
) -> MessageResponse:
"""Change the user's password and revoke existing sessions."""
await user_service.change_user_password(db, user_id, request.current_password, request.new_password)
await user_service.change_user_password(
db,
user_id,
request.current_password,
request.new_password,
)

return MessageResponse(message="Password changed successfully")
9 changes: 8 additions & 1 deletion papyrus/core/dev_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ def get_dev_page_assets(entry_module: str, settings: Settings | None = None) ->

if _should_use_vite(settings):
vite_url = settings.dev_pages_vite_url

return DevPageAssets(
script_urls=[f"{vite_url}/@vite/client", f"{vite_url}/{entry_module}"],
css_urls=[],
Expand Down Expand Up @@ -158,6 +159,7 @@ def render_dev_page(
"""Render a development page shell with either Vite or built assets."""
settings = get_settings()
assets = get_dev_page_assets(entry_module, settings)

context = {
"request": request,
"page_title": page_title,
Expand All @@ -166,4 +168,9 @@ def render_dev_page(
"page_config_json": json.dumps(page_config),
"dev_page_assets": assets,
}
return templates.TemplateResponse(request=request, name=template_name, context=context)

return templates.TemplateResponse(
request=request,
name=template_name,
context=context,
)
Loading
Loading