Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update the fetch-lcp command to have two subcommands #15

Merged
merged 2 commits into from
Mar 7, 2024
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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@
- `audiobook-manifest-summary` (`summarize_rwpm_audio_manifest.py`)
- Produce a summary description from a [Readium Web Publication Manifest (RWPM)](https://github.com/readium/webpub-manifest)
manifest conforming to the [Audiobook Profile](https://github.com/readium/webpub-manifest/blob/master/profiles/audiobook.md).
- `fetch-lcp-audiobook-manifest`
- Given an LCP audiobook fulfillment URL, retrieve it and store/print its manifest.
- `fetch-lcp`
- `audiobook-manifest`
- Given an LCP audiobook fulfillment URL, retrieve it and store/print its manifest.
- `files`
- Given an LCP audiobook fulfillment URL, retrieve and store the lcp and lcpl files.
- `patron-bookshelf`
- Print a patron's bookshelf as either a summary or in full as JSON.
- `validate-audiobook-manifests`
Expand Down
2 changes: 1 addition & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ types-pytz = "^2024.1.0.20240203"

[tool.poetry.scripts]
audiobook-manifest-summary = "palace_tools.cli.summarize_rwpm_audio_manifest:main"
fetch-lcp-audiobook-manifest = "palace_tools.cli.download_lcp_manifest:main"
fetch-lcp = "palace_tools.cli.fetch_lcp:main"
palace-terminal = "palace_tools.cli.palace_terminal:main"
patron-bookshelf = "palace_tools.cli.patron_bookshelf:main"
validate-audiobook-manifests = "palace_tools.cli.validate_manifests:main"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import zipfile
from io import BytesIO
from pathlib import Path
from typing import BinaryIO, TextIO

import typer

Expand All @@ -23,11 +24,55 @@


def main() -> None:
run_typer_app_as_main(app, prog_name="download-lcp-audio-manifest")
run_typer_app_as_main(app, prog_name="fetch-lcp")


@app.command()
def command(
def files(
fulfillment_url: str = typer.Argument(..., help="LCP audiobook fulfillment URL"),
output_dir: Path = typer.Argument(
...,
dir_okay=True,
file_okay=False,
help="Manifest output file.",
),
username: str = typer.Option(..., "--username", "-u", help="Username or barcode."),
password: str = typer.Option(None, "--password", "-p", help="Password or PIN."),
) -> None:
asyncio.run(
process_files_command(
fulfillment_url=fulfillment_url,
output_dir=output_dir,
username=username,
password=password,
)
)


async def process_files_command(
fulfillment_url: str,
output_dir: Path,
username: str,
password: str | None,
) -> None:
if not output_dir.exists():
print(f"Creating directory {output_dir}.")
output_dir.mkdir(parents=True)

license_file = output_dir / "license.lcpl"
lcp_file = output_dir / "audiobook.lcp"

with (
open(license_file, "w") as license_file_io,
open(lcp_file, "wb") as lcp_file_io,
):
await process_command(
fulfillment_url, license_file_io, lcp_file_io, username, password
)


@app.command()
def audiobook_manifest(
fulfillment_url: str = typer.Argument(..., help="LCP audiobook fulfillment URL"),
manifest_file: Path = typer.Option(
...,
Expand All @@ -48,10 +93,8 @@ def command(
help="Pretty print the result.",
),
) -> None:
# print(stdout if manifest_file == Path("-") else manifest_file)
# return
asyncio.run(
process_command(
process_audiobook_manifest_command(
fulfillment_url=fulfillment_url,
manifest_file=STDOUT if manifest_file == Path("-") else manifest_file,
username=username,
Expand All @@ -61,13 +104,34 @@ def command(
)


async def process_command(
async def process_audiobook_manifest_command(
fulfillment_url: str,
manifest_file: Path | str | int,
username: str,
password: str | None,
manifest_member_name: str = "manifest.json",
pretty_print: bool = False,
) -> None:
file = BytesIO()

await process_command(fulfillment_url, None, file, username, password)

zf = zipfile.ZipFile(file)
manifest = zf.read(name=manifest_member_name)
print(f"Sending output to {manifest_file}.")
with open(manifest_file, "w") as f:
if pretty_print:
json.dump(json.loads(manifest, strict=False), f, indent=2)
else:
f.write(manifest.decode("utf-8"))


async def process_command(
fulfillment_url: str,
license_file: TextIO | None,
lcp_file: BinaryIO,
username: str,
password: str | None,
) -> None:
client_headers = {"User-Agent": "Palace"}
token: BaseAuthorizationToken = BasicAuthToken.from_username_and_password(
Expand All @@ -77,6 +141,10 @@ async def process_command(
async with HTTPXAsyncClient(headers=client_headers) as client:
response = await client.get(fulfillment_url, headers=token.as_http_headers)
response.raise_for_status()

if license_file:
license_file.write(response.text)

lcp_license = LCPLicenseDocument.model_validate(response.json())
lcp_audiobook_links = match_links(
lcp_license.links,
Expand All @@ -86,21 +154,14 @@ async def process_command(
if not lcp_audiobook_links:
return
lcp_audiobook_url = lcp_audiobook_links[0].href

lcp_audiobook_response = await streaming_fetch_with_progress(
str(lcp_audiobook_url),
file := BytesIO(),
lcp_file,
task_label="Downloading audiobook zip...",
http_client=client,
)
lcp_audiobook_response.raise_for_status()
zf = zipfile.ZipFile(file)
manifest = zf.read(name=manifest_member_name)
print(f"Sending output to {manifest_file}.")
with open(manifest_file, "w") as f:
if pretty_print:
json.dump(json.loads(manifest, strict=False), f, indent=2)
else:
f.write(manifest.decode("utf-8"))


if __name__ == "__main__":
Expand Down
8 changes: 2 additions & 6 deletions src/palace_tools/cli/patron_bookshelf.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,8 @@ def main() -> None:
)
def patron_bookshelf(
*,
username: str = typer.Argument(
None, help="Username to present to the OPDS server."
),
password: str = typer.Argument(
None, help="Password to present to the OPDS server."
),
username: str = typer.Option(..., "--username", "-u", help="Username or barcode."),
password: str = typer.Option(None, "--password", "-p", help="Password or PIN."),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated these to match the fetch_lcp command, since one took args and one took options. Was a bit confusing when using the two commands back to back.

auth_doc_url: str = typer.Option(
None,
"--auth_doc",
Expand Down
2 changes: 1 addition & 1 deletion src/palace_tools/constants.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Some defaults
DEFAULT_ASYNC_TIMEOUT = 30.0
DEFAULT_AUTH_DOC_PATH_SUFFIX = "/authentication-document"
DEFAULT_AUTH_DOC_PATH_SUFFIX = "authentication_document"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needed to be updated to be able to use the OPDS Server Heuristic args to patron-bookshelf.

DEFAULT_REGISTRY_URL = "https://registry.thepalaceproject.org"
DEFAULT_USER_AGENT = "Palace"

Expand Down
11 changes: 10 additions & 1 deletion src/palace_tools/models/internal/bookshelf.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from palace_tools.models.api.opds2 import OPDS2Feed
from palace_tools.utils.misc import ensure_list


def print_bookshelf_summary(bookshelf: OPDS2Feed) -> None:
Expand All @@ -18,13 +19,21 @@ def print_bookshelf_summary(bookshelf: OPDS2Feed) -> None:

print("\n", " Loans:" if loans else " No loans.", sep="")
for p in loans:
print(f"\n {p.metadata.title}")
authors = ", ".join([a.name for a in ensure_list(p.metadata.author)])
print(f"\n {p.metadata.title} ({authors})")
for link in p.acquisition_links:
print(f" Fulfillment url: {link.href}")
for indirect in (
lnk for lnk in link.indirect_acquisition_links if lnk.get("type")
):
print(f" Indirect type: {indirect['type']}")

if (
hashed_pw := link.properties.get("lcp_hashed_passphrase")
if link.properties
else None
):
print(f" LCP hashed passphrase: {hashed_pw}")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is useful to have included in the output for the bookshelf.

print("\n", " Holds:" if holds else " No holds.", sep="")
for p in holds:
print(f" {p.metadata.title}")