返回 douyin-downloader
file_manager.py
根目录 / storage / file_manager.py
1 import os
2 from pathlib import Path
3 from typing import Dict, Optional, Union
4
5 import aiofiles
6 import aiohttp
7
8 from utils.logger import setup_logger
9 from utils.validators import sanitize_filename
10
11 logger = setup_logger("FileManager")
12
13
14 class FileManager:
15 _IMAGE_CONTENT_TYPE_SUFFIXES = {
16 "image/gif": ".gif",
17 "image/jpeg": ".jpg",
18 "image/jpg": ".jpg",
19 "image/png": ".png",
20 "image/webp": ".webp",
21 }
22
23 # 作者目录层可选风格(与 DEFAULT_CONFIG["author_dir"]、REST SettingsPatch
24 # 的 Literal、前端下拉三处保持一致)。
25 _AUTHOR_DIR_STYLES = ("nickname", "sec_uid", "nickname_uid")
26
27 def __init__(self, base_path: str = "./Downloaded"):
28 self.base_path = Path(base_path)
29 self.base_path.mkdir(parents=True, exist_ok=True)
30
31 def get_save_path(
32 self,
33 author_name: str,
34 mode: str = None,
35 aweme_title: str = None,
36 aweme_id: str = None,
37 folderstyle: bool = True,
38 download_date: str = "",
39 folder_name: Optional[str] = None,
40 *,
41 author_sec_uid: Optional[str] = None,
42 author_dir_style: str = "nickname",
43 ) -> Path:
44 """Compute (and create) the destination directory for a download.
45
46 ``folder_name`` is the pre-rendered, already-sanitized leaf directory
47 name produced by ``utils.naming.render_template``. When provided, it
48 overrides the legacy ``{date}_{title}_{id}`` layout. When omitted we
49 fall back to the historical composition so external callers and the
50 sibling CLI project keep working unchanged.
51
52 ``author_dir_style`` controls how the author-level directory is
53 composed (see :data:`_AUTHOR_DIR_STYLES`). Unknown values or missing
54 ``author_sec_uid`` fall back to ``nickname`` with a ``WARNING`` so
55 downloads never fail on a misconfiguration.
56 """
57 safe_author = self._compose_author_dir(author_name, author_sec_uid, author_dir_style)
58
59 if mode:
60 save_dir = self.base_path / safe_author / mode
61 else:
62 save_dir = self.base_path / safe_author
63
64 if folderstyle:
65 leaf = folder_name
66 if leaf is None and aweme_title and aweme_id:
67 safe_title = sanitize_filename(aweme_title)
68 date_prefix = f"{download_date}_" if download_date else ""
69 leaf = f"{date_prefix}{safe_title}_{aweme_id}"
70 if leaf:
71 save_dir = save_dir / leaf
72
73 save_dir.mkdir(parents=True, exist_ok=True)
74 return save_dir
75
76 @classmethod
77 def _compose_author_dir(
78 cls,
79 author_name: str,
80 author_sec_uid: Optional[str],
81 style: str,
82 ) -> str:
83 """Build the sanitized author-level directory name per ``style``.
84
85 Behaviour matrix (kept in lock-step with the ``author_dir`` option
86 surfaced in settings UI and ``DEFAULT_CONFIG``):
87
88 - ``nickname`` → ``sanitize_filename(author_name)`` (legacy)
89 - ``sec_uid`` → ``sanitize_filename(author_sec_uid)``;
90 empty/None → fall back to nickname + ``logger.warning``.
91 - ``nickname_uid`` → ``sanitize_filename(f"{author_name}_{author_sec_uid}")``;
92 sec_uid missing → fall back to nickname + ``logger.warning``.
93 - Unknown style → fall back to nickname + ``logger.warning``.
94
95 Never raises — a misconfiguration must degrade into a still-working
96 download, not a hard failure.
97 """
98 nickname_dir = sanitize_filename(author_name)
99 sec_uid = (author_sec_uid or "").strip()
100
101 if style not in cls._AUTHOR_DIR_STYLES:
102 logger.warning(
103 "Unknown author_dir style %r, falling back to nickname (%s)",
104 style,
105 nickname_dir,
106 )
107 return nickname_dir
108
109 if style == "nickname":
110 return nickname_dir
111
112 if style == "sec_uid":
113 if not sec_uid:
114 logger.warning(
115 "author_dir=sec_uid but sec_uid is missing for %r, falling back to nickname",
116 author_name,
117 )
118 return nickname_dir
119 return sanitize_filename(sec_uid)
120
121 # style == "nickname_uid"
122 if not sec_uid:
123 logger.warning(
124 "author_dir=nickname_uid but sec_uid is missing for %r, falling back to nickname",
125 author_name,
126 )
127 return nickname_dir
128 return sanitize_filename(f"{author_name}_{sec_uid}")
129
130 async def download_file(
131 self,
132 url: str,
133 save_path: Path,
134 session: aiohttp.ClientSession = None,
135 headers: Optional[Dict[str, str]] = None,
136 proxy: Optional[str] = None,
137 *,
138 prefer_response_content_type: bool = False,
139 return_saved_path: bool = False,
140 ) -> Union[bool, Path]:
141 should_close = False
142 if session is None:
143 default_headers = headers or {
144 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
145 "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
146 "Referer": "https://www.douyin.com/",
147 "Accept": "*/*",
148 }
149 session = aiohttp.ClientSession(headers=default_headers)
150 should_close = True
151
152 final_path = save_path
153 tmp_path = save_path.with_suffix(save_path.suffix + ".tmp")
154 try:
155 async with session.get(
156 url,
157 timeout=aiohttp.ClientTimeout(total=300),
158 headers=headers,
159 proxy=proxy or None,
160 ) as response:
161 if response.status == 200:
162 final_path = self._resolve_save_path_from_content_type(
163 save_path,
164 response.headers,
165 prefer_response_content_type=prefer_response_content_type,
166 )
167 tmp_path = final_path.with_suffix(final_path.suffix + ".tmp")
168 expected_size = response.content_length
169 written = 0
170 async with aiofiles.open(tmp_path, "wb") as f:
171 async for chunk in response.content.iter_chunked(8192):
172 await f.write(chunk)
173 written += len(chunk)
174 if expected_size is not None and written != expected_size:
175 logger.warning(
176 "Size mismatch for %s: expected %d, got %d",
177 save_path.name,
178 expected_size,
179 written,
180 )
181 tmp_path.unlink(missing_ok=True)
182 return False
183 os.replace(str(tmp_path), str(final_path))
184 return final_path if return_saved_path else True
185 else:
186 logger.debug(
187 "Download failed for %s, status=%s",
188 final_path.name,
189 response.status,
190 )
191 return False
192 except Exception as e:
193 logger.debug("Download error for %s: %s", final_path.name, e)
194 tmp_path.unlink(missing_ok=True)
195 return False
196 finally:
197 if should_close:
198 await session.close()
199
200 @classmethod
201 def _resolve_save_path_from_content_type(
202 cls,
203 save_path: Path,
204 response_headers,
205 *,
206 prefer_response_content_type: bool = False,
207 ) -> Path:
208 if not prefer_response_content_type:
209 return save_path
210
211 content_type = response_headers.get("Content-Type", "") if response_headers else ""
212 normalized_type = content_type.split(";", 1)[0].strip().lower()
213 suffix = cls._IMAGE_CONTENT_TYPE_SUFFIXES.get(normalized_type)
214 if not suffix:
215 return save_path
216 return save_path.with_suffix(suffix)
217
218 def file_exists(self, file_path: Path) -> bool:
219 try:
220 return file_path.exists() and file_path.stat().st_size > 0
221 except OSError:
222 return False
223
224 def get_file_size(self, file_path: Path) -> int:
225 try:
226 return file_path.stat().st_size if self.file_exists(file_path) else 0
227 except OSError:
228 return 0
229
229 lines PYTHON