1
1
import mimetypes
2
+ import os
2
3
from pathlib import Path
4
+ from typing import AsyncIterator , Union
3
5
6
+ import aiofiles
4
7
from fastapi import APIRouter , HTTPException , Request
5
- from fastapi .responses import FileResponse , HTMLResponse
8
+ from fastapi .responses import FileResponse , HTMLResponse , StreamingResponse
6
9
from fastapi .staticfiles import StaticFiles
7
10
8
11
# Get current module path
9
12
PACKAGE_DIR = Path (__file__ ).parent .parent
10
13
11
14
router = APIRouter (tags = ["page" ])
12
15
16
+ async def stream_file_range (file_path : Path , start : int , end : int ) -> AsyncIterator [bytes ]:
17
+ """以块的方式流式读取文件"""
18
+ chunk_size = 4 * 1024 * 1024 # 4MB 块大小,提高传输速度
19
+ async with aiofiles .open (file_path , mode = "rb" ) as f :
20
+ await f .seek (start )
21
+ bytes_remaining = end - start + 1
22
+ while bytes_remaining > 0 :
23
+ chunk = await f .read (min (chunk_size , bytes_remaining ))
24
+ if not chunk :
25
+ break
26
+ bytes_remaining -= len (chunk )
27
+ yield chunk
28
+
29
+ async def handle_file_request (
30
+ file_path : Path ,
31
+ range_header : str = None ,
32
+ as_attachment : bool = False
33
+ ) -> Union [FileResponse , StreamingResponse ]:
34
+ """处理文件请求的通用函数"""
35
+ try :
36
+ if not file_path .exists () or not file_path .is_file ():
37
+ raise HTTPException (status_code = 404 , detail = "File not found" )
38
+
39
+ file_size = os .path .getsize (file_path )
40
+ mime_type = mimetypes .guess_type (str (file_path ))[0 ] or "application/octet-stream"
41
+
42
+ # 如果是下载请求,强制使用 application/octet-stream
43
+ if as_attachment :
44
+ mime_type = "application/octet-stream"
45
+
46
+ # 如果没有 Range 头,直接返回完整文件
47
+ if not range_header :
48
+ headers = {}
49
+ if as_attachment :
50
+ headers ["Content-Disposition" ] = f'attachment; filename="{ file_path .name } "'
51
+ else :
52
+ headers ["Content-Disposition" ] = f'inline; filename="{ file_path .name } "'
53
+
54
+ return FileResponse (
55
+ path = file_path ,
56
+ media_type = mime_type ,
57
+ headers = headers
58
+ )
59
+
60
+ try :
61
+ # 解析 Range 头
62
+ start , end = range_header .replace ("bytes=" , "" ).split ("-" )
63
+ start = int (start ) if start else 0
64
+ end = min (int (end ), file_size - 1 ) if end else file_size - 1
65
+
66
+ if start >= file_size :
67
+ raise HTTPException (status_code = 416 , detail = "Range not satisfiable" )
68
+
69
+ headers = {
70
+ "Content-Range" : f"bytes { start } -{ end } /{ file_size } " ,
71
+ "Accept-Ranges" : "bytes" ,
72
+ "Content-Length" : str (end - start + 1 ),
73
+ "Content-Type" : mime_type ,
74
+ "Content-Disposition" : "attachment" if as_attachment else "inline" + f'; filename="{ file_path .name } "'
75
+ }
76
+
77
+ return StreamingResponse (
78
+ stream_file_range (file_path , start , end ),
79
+ status_code = 206 ,
80
+ headers = headers
81
+ )
82
+
83
+ except (ValueError , IndexError ):
84
+ raise HTTPException (status_code = 416 , detail = "Invalid range header" )
85
+
86
+ except Exception as e :
87
+ raise HTTPException (status_code = 500 , detail = str (e ))
88
+
13
89
# Mount static files for direct access to static assets
14
90
def init_static_files (app ):
15
91
"""Initialize static file serving"""
@@ -29,38 +105,30 @@ async def serve_blob_path(path: str):
29
105
30
106
@router .get ("/raw/{file_path:path}" )
31
107
async def get_raw_file (file_path : str , request : Request ):
32
- """Get raw file content"""
108
+ """Get raw file content with Range support """
33
109
try :
34
110
file_path = request .app .state .ROOT_DIR / file_path
35
- if not file_path .exists () or not file_path .is_file ():
36
- raise HTTPException (status_code = 404 , detail = "File not found" )
37
-
38
- # Get file MIME type
39
- mime_type , _ = mimetypes .guess_type (str (file_path ))
40
- if mime_type is None :
41
- mime_type = "application/octet-stream"
42
-
43
- return FileResponse (
44
- path = file_path ,
45
- media_type = mime_type ,
46
- filename = file_path .name
111
+ return await handle_file_request (
112
+ file_path = file_path ,
113
+ range_header = request .headers .get ("range" ),
114
+ as_attachment = False
47
115
)
48
116
except Exception as e :
117
+ if isinstance (e , HTTPException ):
118
+ raise e
49
119
raise HTTPException (status_code = 500 , detail = str (e ))
50
120
51
-
52
121
@router .get ("/download/{file_path:path}" )
53
122
async def download_file (file_path : str , request : Request ):
54
- """Download file"""
123
+ """Download file with Range support """
55
124
try :
56
125
file_path = request .app .state .ROOT_DIR / file_path
57
- if not file_path .exists () or not file_path .is_file ():
58
- return {"error" : "File not found" }
59
-
60
- return FileResponse (
61
- file_path ,
62
- filename = file_path .name ,
63
- media_type = "application/octet-stream"
126
+ return await handle_file_request (
127
+ file_path = file_path ,
128
+ range_header = request .headers .get ("range" ),
129
+ as_attachment = True
64
130
)
65
131
except Exception as e :
66
- return {"error" : str (e )}
132
+ if isinstance (e , HTTPException ):
133
+ raise e
134
+ raise HTTPException (status_code = 500 , detail = str (e ))
0 commit comments