Skip to content

Commit 9488ceb

Browse files
authored
Merge pull request #3167 from tmgast/fix/mod-zip-range-requests
feat: Support Range requests for multi-file ROM downloads
2 parents 62833ca + 41e15d4 commit 9488ceb

11 files changed

Lines changed: 910 additions & 13 deletions

File tree

backend/config/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ def _get_env(var: str, fallback: str | None = None) -> str | None:
3636
LIBRARY_BASE_PATH: Final[str] = f"{ROMM_BASE_PATH}/library"
3737
RESOURCES_BASE_PATH: Final[str] = f"{ROMM_BASE_PATH}/resources"
3838
ASSETS_BASE_PATH: Final[str] = f"{ROMM_BASE_PATH}/assets"
39+
ZIP_CACHE_PATH: Final[str] = f"{ROMM_BASE_PATH}/cache/zips"
3940
FRONTEND_RESOURCES_PATH: Final[str] = "/assets/romm/resources"
4041

4142
# SEVEN ZIP

backend/endpoints/roms/__init__.py

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,19 @@
7777
from utils.database import safe_int, safe_str_to_bool
7878
from utils.filesystem import sanitize_filename
7979
from utils.hashing import crc32_to_hex
80+
from utils.m3u import generate_m3u_content
8081
from utils.nginx import FileRedirectResponse, ZipContentLine, ZipResponse
8182
from utils.router import APIRouter
8283
from utils.screenshots import continue_playing_screenshot
8384
from utils.validation import ValidationError
85+
from utils.zip_cache import (
86+
BULK_CACHE_MAX_ROMS,
87+
ZipFileEntry,
88+
get_bulk_namespace,
89+
get_cache_key,
90+
get_cached_zip,
91+
resolve_cached_zip,
92+
)
8493

8594
from .files import router as files_router
8695
from .manual import router as manual_router
@@ -939,26 +948,49 @@ async def download_roms(
939948
f"User {hl(current_username, color=BLUE)} is downloading {len(rom_objects)} ROMs as zip"
940949
)
941950

942-
content_lines = []
951+
all_entries = []
943952
for rom in rom_objects:
944953
rom_files = sorted(rom.files, key=lambda x: x.file_name)
945954
for file in rom_files:
946-
content_lines.append(
947-
ZipContentLine(
948-
crc32=None, # The CRC hash stored for compressed files is for the uncompressed content
949-
size_bytes=file.file_size_bytes,
950-
encoded_location=quote(f"/library/{file.full_path}"),
951-
filename=file.full_path,
955+
all_entries.append(
956+
ZipFileEntry(
957+
download_name=file.full_path,
958+
full_path=file.full_path,
959+
file_size_bytes=file.file_size_bytes,
960+
updated_at_epoch=file.updated_at.timestamp(),
952961
)
953962
)
954963

955964
if filename:
956965
file_name = sanitize_filename(filename)
957966
else:
958-
base64_content = b64encode(
959-
("\n".join([str(line) for line in content_lines])).encode()
967+
content_summary = "\n".join(
968+
f"{e.download_name}:{e.file_size_bytes}" for e in all_entries
969+
).encode()
970+
file_name = f"{len(rom_objects)} ROMs ({crc32_to_hex(binascii.crc32(content_summary))}).zip"
971+
972+
range_header = request.headers.get("range")
973+
if range_header and len(rom_objects) <= BULK_CACHE_MAX_ROMS:
974+
redirect_path = await resolve_cached_zip(
975+
get_bulk_namespace([r.id for r in rom_objects]),
976+
all_entries,
977+
log_label=f"bulk download ({len(rom_objects)} ROMs)",
978+
)
979+
if redirect_path:
980+
return FileRedirectResponse(
981+
download_path=redirect_path,
982+
filename=file_name,
983+
)
984+
985+
content_lines = [
986+
ZipContentLine(
987+
crc32=None,
988+
size_bytes=e.file_size_bytes,
989+
encoded_location=quote(f"/library/{e.full_path}"),
990+
filename=e.download_name,
960991
)
961-
file_name = f"{len(rom_objects)} ROMs ({crc32_to_hex(binascii.crc32(base64_content))}).zip"
992+
for e in all_entries
993+
]
962994

963995
return ZipResponse(
964996
content_lines=content_lines,
@@ -1201,6 +1233,21 @@ async def head_rom_content(
12011233
download_path=Path(f"/library/{files[0].full_path}"),
12021234
)
12031235

1236+
hidden_folder = safe_str_to_bool(request.query_params.get("hidden_folder", ""))
1237+
entries = [ZipFileEntry.from_rom_file(f, hidden_folder) for f in files]
1238+
namespace = str(rom.id)
1239+
cache_key = get_cache_key(namespace, entries, hidden_folder)
1240+
zip_path = get_cached_zip(namespace, cache_key)
1241+
if zip_path:
1242+
return Response(
1243+
headers={
1244+
"Content-Type": "application/zip",
1245+
"Content-Length": str(zip_path.stat().st_size),
1246+
"Accept-Ranges": "bytes",
1247+
"Content-Disposition": f"attachment; filename*=UTF-8''{quote(file_name)}.zip; filename=\"{quote(file_name)}.zip\"",
1248+
},
1249+
)
1250+
12041251
return Response(
12051252
media_type="application/zip",
12061253
headers={
@@ -1347,6 +1394,25 @@ async def build_zip_in_memory() -> bytes:
13471394
download_path=Path(f"/library/{files[0].full_path}"),
13481395
)
13491396

1397+
# Multi-file path: serve cached ZIP for Range requests (resumable),
1398+
# fall through to mod_zip streaming for non-Range requests.
1399+
range_header = request.headers.get("range")
1400+
if range_header:
1401+
has_m3u = rom.has_m3u_file()
1402+
redirect_path = await resolve_cached_zip(
1403+
str(rom.id),
1404+
[ZipFileEntry.from_rom_file(f, hidden_folder) for f in files],
1405+
hidden_folder=hidden_folder,
1406+
m3u_content=None if has_m3u else generate_m3u_content(files, hidden_folder),
1407+
m3u_filename=None if has_m3u else f"{file_name}.m3u",
1408+
log_label=f"ROM {rom.id}",
1409+
)
1410+
if redirect_path:
1411+
return FileRedirectResponse(
1412+
download_path=redirect_path,
1413+
filename=f"{file_name}.zip",
1414+
)
1415+
13501416
content_lines = [
13511417
ZipContentLine(
13521418
crc32=None, # The CRC hash stored for compressed files is for the uncompressed content
@@ -1358,9 +1424,7 @@ async def build_zip_in_memory() -> bytes:
13581424
]
13591425

13601426
if not rom.has_m3u_file():
1361-
m3u_encoded_content = "\n".join(
1362-
[f.file_name_for_download(hidden_folder) for f in m3u_files]
1363-
).encode()
1427+
m3u_encoded_content = generate_m3u_content(files, hidden_folder)
13641428
m3u_base64_content = b64encode(m3u_encoded_content).decode()
13651429
m3u_line = ZipContentLine(
13661430
crc32=crc32_to_hex(binascii.crc32(m3u_encoded_content)),

backend/endpoints/tasks.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
recompute_save_content_hashes_task,
4141
)
4242
from tasks.manual.sync_folder_scan import sync_folder_scan_task
43+
from tasks.scheduled.cleanup_zip_cache import cleanup_zip_cache_task
4344
from tasks.scheduled.convert_images_to_webp import convert_images_to_webp_task
4445
from tasks.scheduled.scan_library import scan_library_task
4546
from tasks.scheduled.update_launchbox_metadata import update_launchbox_metadata_task
@@ -95,6 +96,13 @@ class ManualTask(ScheduledTask):
9596
"task": convert_images_to_webp_task,
9697
}
9798
),
99+
ScheduledTask(
100+
{
101+
"name": "cleanup_zip_cache",
102+
"type": TaskType.CLEANUP,
103+
"task": cleanup_zip_cache_task,
104+
}
105+
),
98106
]
99107

100108
manual_tasks: list[ManualTask] = [

backend/startup.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
)
3535
from tasks.scheduled.cleanup_netplay import cleanup_netplay_task
3636
from tasks.scheduled.cleanup_upload_tmp import cleanup_upload_tmp_task
37+
from tasks.scheduled.cleanup_zip_cache import cleanup_zip_cache_task
3738
from tasks.scheduled.convert_images_to_webp import convert_images_to_webp_task
3839
from tasks.scheduled.scan_library import scan_library_task
3940
from tasks.scheduled.sync_retroachievements_progress import (
@@ -140,6 +141,7 @@ async def main() -> None:
140141

141142
# Initialize scheduled tasks
142143
cleanup_netplay_task.init()
144+
cleanup_zip_cache_task.init()
143145
cleanup_upload_tmp_task.init()
144146

145147
if ENABLE_SCHEDULED_RESCAN:
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from logger.logger import log
2+
from tasks.tasks import PeriodicTask, TaskType
3+
from utils.zip_cache import cleanup_stale_zips
4+
5+
6+
class CleanupZipCacheTask(PeriodicTask):
7+
def __init__(self):
8+
super().__init__(
9+
title="Scheduled ZIP cache cleanup",
10+
description="Removes stale cached ZIP files based on tiered TTL",
11+
task_type=TaskType.CLEANUP,
12+
enabled=True,
13+
manual_run=False,
14+
cron_string="0 4 * * *",
15+
func="tasks.scheduled.cleanup_zip_cache.cleanup_zip_cache_task.run",
16+
)
17+
18+
async def run(self) -> None:
19+
if not self.enabled:
20+
self.unschedule()
21+
return
22+
23+
deleted = cleanup_stale_zips()
24+
if deleted:
25+
log.info(f"Cleaned up {deleted} stale cached ZIP files")
26+
27+
28+
cleanup_zip_cache_task = CleanupZipCacheTask()
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from unittest.mock import MagicMock
2+
3+
from tasks.scheduled.cleanup_zip_cache import CleanupZipCacheTask
4+
5+
6+
class TestCleanupZipCacheTask:
7+
def test_configuration(self):
8+
task = CleanupZipCacheTask()
9+
assert task.enabled is True
10+
assert task.cron_string == "0 4 * * *"
11+
assert "cleanup_zip_cache" in task.func
12+
13+
async def test_run_calls_cleanup(self, mocker):
14+
task = CleanupZipCacheTask()
15+
mock_cleanup = mocker.patch(
16+
"tasks.scheduled.cleanup_zip_cache.cleanup_stale_zips",
17+
return_value=3,
18+
)
19+
await task.run()
20+
mock_cleanup.assert_called_once_with()
21+
22+
async def test_run_disabled_unschedules(self, mocker):
23+
task = CleanupZipCacheTask()
24+
task.enabled = False
25+
mocker.patch.object(task, "unschedule", MagicMock())
26+
mock_cleanup = mocker.patch(
27+
"tasks.scheduled.cleanup_zip_cache.cleanup_stale_zips",
28+
)
29+
await task.run()
30+
mock_cleanup.assert_not_called()

backend/tests/utils/test_m3u.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from unittest.mock import MagicMock
2+
3+
from utils.m3u import generate_m3u_content
4+
5+
6+
def _make_file(name: str, extension: str, download_name: str | None = None):
7+
f = MagicMock()
8+
f.file_extension = extension
9+
f.file_name_for_download.return_value = download_name or name
10+
return f
11+
12+
13+
class TestGenerateM3uContent:
14+
def test_single_file(self):
15+
files = [_make_file("game.bin", "bin")]
16+
result = generate_m3u_content(files, hidden_folder=False)
17+
assert result == b"game.bin"
18+
files[0].file_name_for_download.assert_called_once_with(False)
19+
20+
def test_multiple_files(self):
21+
files = [
22+
_make_file("disc1.chd", "chd"),
23+
_make_file("disc2.chd", "chd"),
24+
_make_file("disc3.chd", "chd"),
25+
]
26+
result = generate_m3u_content(files, hidden_folder=False)
27+
assert result == b"disc1.chd\ndisc2.chd\ndisc3.chd"
28+
29+
def test_cue_files_preferred_over_bin(self):
30+
files = [
31+
_make_file("track01.bin", "bin"),
32+
_make_file("track02.bin", "bin"),
33+
_make_file("game.cue", "cue"),
34+
]
35+
result = generate_m3u_content(files, hidden_folder=False)
36+
assert result == b"game.cue"
37+
38+
def test_cue_case_insensitive(self):
39+
files = [
40+
_make_file("track.bin", "bin"),
41+
_make_file("game.CUE", "CUE", download_name="game.CUE"),
42+
]
43+
result = generate_m3u_content(files, hidden_folder=False)
44+
assert result == b"game.CUE"
45+
46+
def test_hidden_folder_passed_through(self):
47+
files = [_make_file("game.chd", "chd", download_name=".hidden/game.chd")]
48+
result = generate_m3u_content(files, hidden_folder=True)
49+
assert result == b".hidden/game.chd"
50+
files[0].file_name_for_download.assert_called_once_with(True)
51+
52+
def test_no_cue_files_lists_all(self):
53+
files = [
54+
_make_file("disc1.chd", "chd"),
55+
_make_file("disc2.chd", "chd"),
56+
]
57+
result = generate_m3u_content(files, hidden_folder=False)
58+
lines = result.decode().split("\n")
59+
assert len(lines) == 2

0 commit comments

Comments
 (0)