|
1 | 1 | from datetime import timedelta |
2 | 2 |
|
3 | 3 | import asyncio |
| 4 | +import logging |
| 5 | +import os |
4 | 6 | import humanize |
5 | 7 | from fastapi import Request |
6 | 8 | from fastapi.responses import RedirectResponse, FileResponse |
|
17 | 19 | from common import io_bound |
18 | 20 |
|
19 | 21 |
|
| 22 | +logger = logging.getLogger("plexdlweb.download") |
| 23 | + |
| 24 | + |
| 25 | +class DownloadFileResponse(FileResponse): |
| 26 | + # Starlette's default is 64 KiB. Larger chunks reduce per-chunk overhead for |
| 27 | + # multi-gigabyte video downloads while preserving Range support. |
| 28 | + chunk_size = 1024 * 1024 |
| 29 | + |
| 30 | + async def __call__(self, scope, receive, send) -> None: |
| 31 | + try: |
| 32 | + await super().__call__(scope, receive, send) |
| 33 | + except Exception: |
| 34 | + logger.exception("Download failed while streaming %s", self.path) |
| 35 | + raise |
| 36 | + |
| 37 | + |
| 38 | +DOWNLOAD_HEADERS = { |
| 39 | + # Prevent common reverse proxies from buffering large responses to disk. |
| 40 | + "X-Accel-Buffering": "no", |
| 41 | + # Tell intermediaries not to compress or otherwise rewrite video downloads. |
| 42 | + "Cache-Control": "private, no-transform", |
| 43 | +} |
| 44 | + |
| 45 | + |
20 | 46 | def apartial(func, *args, **kwargs): |
21 | 47 | async def handler(): |
22 | 48 | return await func(*args, **kwargs) |
@@ -70,14 +96,30 @@ def logout_handler(): |
70 | 96 | ui.label(_("user", user=user.email)) |
71 | 97 |
|
72 | 98 |
|
73 | | -@app.get("/download/{media}/{index}") |
74 | | -async def download(media: int, index: int): |
| 99 | +@app.api_route("/download/{media}/{index}", methods=["GET", "HEAD"]) |
| 100 | +async def download(request: Request, media: int, index: int): |
75 | 101 | """ |
76 | 102 | Downloads the specified media part from Plex |
77 | 103 | """ |
78 | | - part = (await get_server()).fetchItem(media).media[index].parts[0] # is there ever more than one part per media? |
| 104 | + server = await get_server() |
| 105 | + item = await io_bound(server.fetchItem, media) |
| 106 | + part = item.media[index].parts[0] # is there ever more than one part per media? |
79 | 107 | filename = os.path.basename(part.file) |
80 | | - return FileResponse(part.file, filename=filename, stat_result=os.stat(part.file)) |
| 108 | + stat_result = os.stat(part.file) |
| 109 | + logger.info( |
| 110 | + "Starting download media=%s index=%s filename=%r size=%s range=%r", |
| 111 | + media, |
| 112 | + index, |
| 113 | + filename, |
| 114 | + stat_result.st_size, |
| 115 | + request.headers.get("range"), |
| 116 | + ) |
| 117 | + return DownloadFileResponse( |
| 118 | + part.file, |
| 119 | + filename=filename, |
| 120 | + stat_result=stat_result, |
| 121 | + headers=DOWNLOAD_HEADERS, |
| 122 | + ) |
81 | 123 |
|
82 | 124 |
|
83 | 125 | @ui.page("/", title="PlexDLWeb") |
|
0 commit comments