Skip to content

Commit f0c1eee

Browse files
committed
Attempt fix for large downloads
1 parent e671fb3 commit f0c1eee

2 files changed

Lines changed: 60 additions & 4 deletions

File tree

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,20 @@ docker build -t plexdlweb .
9191

9292
As mentioned, an update system is planned. In the meantime, just `git pull` and restart the service.
9393

94+
## Troubleshooting large downloads
95+
96+
If large downloads start quickly and then stall at `0 B/s` after a few gigabytes, check any reverse proxy in front of PlexDLWeb. Large media responses should not be buffered or transformed by the proxy.
97+
98+
For nginx, configure the PlexDLWeb location with:
99+
100+
```nginx
101+
proxy_buffering off;
102+
proxy_request_buffering off;
103+
gzip off;
104+
```
105+
106+
PlexDLWeb also sends `X-Accel-Buffering: no` and `Cache-Control: private, no-transform` on download responses, but proxy settings may still need to allow streaming large files directly to the client.
107+
94108
## Rationale
95109

96110
Plex is an amazing piece of software. Time isn't free, and Plex Inc. needs money. I paid €120 for the Plex Pass so my friends and family can use my server at its full potential (hardware transcoding, credits skipping, etc).

__main__.py

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from datetime import timedelta
22

33
import asyncio
4+
import logging
5+
import os
46
import humanize
57
from fastapi import Request
68
from fastapi.responses import RedirectResponse, FileResponse
@@ -17,6 +19,30 @@
1719
from common import io_bound
1820

1921

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+
2046
def apartial(func, *args, **kwargs):
2147
async def handler():
2248
return await func(*args, **kwargs)
@@ -70,14 +96,30 @@ def logout_handler():
7096
ui.label(_("user", user=user.email))
7197

7298

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):
75101
"""
76102
Downloads the specified media part from Plex
77103
"""
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?
79107
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+
)
81123

82124

83125
@ui.page("/", title="PlexDLWeb")

0 commit comments

Comments
 (0)