7777from utils .database import safe_int , safe_str_to_bool
7878from utils .filesystem import sanitize_filename
7979from utils .hashing import crc32_to_hex
80+ from utils .m3u import generate_m3u_content
8081from utils .nginx import FileRedirectResponse , ZipContentLine , ZipResponse
8182from utils .router import APIRouter
8283from utils .screenshots import continue_playing_screenshot
8384from 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
8594from .files import router as files_router
8695from .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 )),
0 commit comments