devart-dl 4.3.4__tar.gz → 4.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {devart_dl-4.3.4/devart_dl.egg-info → devart_dl-4.4.0}/PKG-INFO +2 -1
  2. {devart_dl-4.3.4 → devart_dl-4.4.0}/README.en.md +1 -0
  3. {devart_dl-4.3.4 → devart_dl-4.4.0}/README.md +1 -0
  4. {devart_dl-4.3.4 → devart_dl-4.4.0}/da_downloader/manager.py +11 -3
  5. {devart_dl-4.3.4 → devart_dl-4.4.0}/da_downloader/models.py +64 -3
  6. {devart_dl-4.3.4 → devart_dl-4.4.0}/da_downloader/provider.py +51 -1
  7. {devart_dl-4.3.4 → devart_dl-4.4.0/devart_dl.egg-info}/PKG-INFO +2 -1
  8. {devart_dl-4.3.4 → devart_dl-4.4.0}/pyproject.toml +1 -1
  9. {devart_dl-4.3.4 → devart_dl-4.4.0}/tests/test_manager.py +15 -0
  10. {devart_dl-4.3.4 → devart_dl-4.4.0}/tests/test_official_provider.py +45 -0
  11. {devart_dl-4.3.4 → devart_dl-4.4.0}/tests/test_provider.py +18 -0
  12. {devart_dl-4.3.4 → devart_dl-4.4.0}/.env.example +0 -0
  13. {devart_dl-4.3.4 → devart_dl-4.4.0}/LICENSE +0 -0
  14. {devart_dl-4.3.4 → devart_dl-4.4.0}/MANIFEST.in +0 -0
  15. {devart_dl-4.3.4 → devart_dl-4.4.0}/bin/devart-dl +0 -0
  16. {devart_dl-4.3.4 → devart_dl-4.4.0}/bin/install.sh +0 -0
  17. {devart_dl-4.3.4 → devart_dl-4.4.0}/da_downloader/__init__.py +0 -0
  18. {devart_dl-4.3.4 → devart_dl-4.4.0}/da_downloader/api.py +0 -0
  19. {devart_dl-4.3.4 → devart_dl-4.4.0}/da_downloader/archive.py +0 -0
  20. {devart_dl-4.3.4 → devart_dl-4.4.0}/da_downloader/auth.py +0 -0
  21. {devart_dl-4.3.4 → devart_dl-4.4.0}/da_downloader/cli.py +0 -0
  22. {devart_dl-4.3.4 → devart_dl-4.4.0}/da_downloader/da_cookie_login.mjs +0 -0
  23. {devart_dl-4.3.4 → devart_dl-4.4.0}/da_downloader/download.py +0 -0
  24. {devart_dl-4.3.4 → devart_dl-4.4.0}/da_downloader/errors.py +0 -0
  25. {devart_dl-4.3.4 → devart_dl-4.4.0}/da_downloader/http.py +0 -0
  26. {devart_dl-4.3.4 → devart_dl-4.4.0}/da_downloader/oauth.py +0 -0
  27. {devart_dl-4.3.4 → devart_dl-4.4.0}/da_downloader/official_api.py +0 -0
  28. {devart_dl-4.3.4 → devart_dl-4.4.0}/da_downloader/path.py +0 -0
  29. {devart_dl-4.3.4 → devart_dl-4.4.0}/da_downloader/targets.py +0 -0
  30. {devart_dl-4.3.4 → devart_dl-4.4.0}/devart_dl.egg-info/SOURCES.txt +0 -0
  31. {devart_dl-4.3.4 → devart_dl-4.4.0}/devart_dl.egg-info/dependency_links.txt +0 -0
  32. {devart_dl-4.3.4 → devart_dl-4.4.0}/devart_dl.egg-info/entry_points.txt +0 -0
  33. {devart_dl-4.3.4 → devart_dl-4.4.0}/devart_dl.egg-info/requires.txt +0 -0
  34. {devart_dl-4.3.4 → devart_dl-4.4.0}/devart_dl.egg-info/top_level.txt +0 -0
  35. {devart_dl-4.3.4 → devart_dl-4.4.0}/requirements.txt +0 -0
  36. {devart_dl-4.3.4 → devart_dl-4.4.0}/setup.cfg +0 -0
  37. {devart_dl-4.3.4 → devart_dl-4.4.0}/setup.py +0 -0
  38. {devart_dl-4.3.4 → devart_dl-4.4.0}/tests/test_api.py +0 -0
  39. {devart_dl-4.3.4 → devart_dl-4.4.0}/tests/test_archive.py +0 -0
  40. {devart_dl-4.3.4 → devart_dl-4.4.0}/tests/test_cli.py +0 -0
  41. {devart_dl-4.3.4 → devart_dl-4.4.0}/tests/test_conformance.py +0 -0
  42. {devart_dl-4.3.4 → devart_dl-4.4.0}/tests/test_download.py +0 -0
  43. {devart_dl-4.3.4 → devart_dl-4.4.0}/tests/test_http_downloader.py +0 -0
  44. {devart_dl-4.3.4 → devart_dl-4.4.0}/tests/test_path.py +0 -0
  45. {devart_dl-4.3.4 → devart_dl-4.4.0}/tests/test_targets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devart-dl
3
- Version: 4.3.4
3
+ Version: 4.4.0
4
4
  Summary: 专业的 DeviantArt 作品批量下载工具 - Professional DeviantArt batch downloader with anti-ban features
5
5
  Author: DeviantArt Downloader Team
6
6
  License-Expression: MIT
@@ -89,6 +89,7 @@ devart-dl login browser # 打开你的 Chrome 登录,自动保存 Cookie,
89
89
 
90
90
  - **官方 API(OAuth)优先**:登录一次即可下载原图,无需导出 Cookie,也无需防封延时。
91
91
  - **URL 优先**:直接粘贴作品 / 画廊 / 收藏夹 / 标签链接即可下载。
92
+ - **文本作品**:文学 / 日志型 deviation 在响应内直接提取正文,保存为 UTF-8 `.txt`;媒体与文本自动分流。
92
93
  - **下载可靠**:HTTP Range 断点续传、自动重试、429 退避、原子落盘。
93
94
  - **下载档案**:`--archive` 用 SQLite 记住已下载,跨会话自动跳过。
94
95
  - **路径模板 + 元数据 sidecar**:`--directory`/`--filename` 模板与 `--write-info-json`。
@@ -20,6 +20,7 @@ See [CHANGELOG.md](CHANGELOG.md) for release notes.
20
20
 
21
21
  - **Official API first (OAuth)** — log in once to download originals; no cookie export, no anti-ban delays.
22
22
  - **URL-first** — `devart-dl URL` downloads artworks / galleries / favourites / tags / fav.me links directly.
23
+ - **Literature support** — extracts inline literature/journal bodies and saves them as UTF-8 `.txt`, with media and text handled by the same pipeline.
23
24
  - **Reliable downloads** — HTTP Range resume, automatic retry, 429 backoff, HTML/empty-file validation, atomic finalize.
24
25
  - **Download archive** — `--archive` remembers what you've downloaded in SQLite, skipping it across sessions.
25
26
  - **Path templates** — `--directory` / `--filename` support `{id}` `{title}` `{author}` `{published}` `{ext}` and more, with safe sanitization.
@@ -55,6 +55,7 @@ devart-dl login browser # 打开你的 Chrome 登录,自动保存 Cookie,
55
55
 
56
56
  - **官方 API(OAuth)优先**:登录一次即可下载原图,无需导出 Cookie,也无需防封延时。
57
57
  - **URL 优先**:直接粘贴作品 / 画廊 / 收藏夹 / 标签链接即可下载。
58
+ - **文本作品**:文学 / 日志型 deviation 在响应内直接提取正文,保存为 UTF-8 `.txt`;媒体与文本自动分流。
58
59
  - **下载可靠**:HTTP Range 断点续传、自动重试、429 退避、原子落盘。
59
60
  - **下载档案**:`--archive` 用 SQLite 记住已下载,跨会话自动跳过。
60
61
  - **路径模板 + 元数据 sidecar**:`--directory`/`--filename` 模板与 `--write-info-json`。
@@ -73,9 +73,17 @@ class DownloadManager:
73
73
  return DownloadOutcome(item.artwork_id, "planned", path=path)
74
74
 
75
75
  try:
76
- result: TransferResult = self.downloader.download(
77
- item.media_url, path, overwrite=self.overwrite
78
- )
76
+ if item.content is not None:
77
+ path.parent.mkdir(parents=True, exist_ok=True)
78
+ part = path.with_name(path.name + ".part")
79
+ with part.open("w", encoding="utf-8", newline="\n") as stream:
80
+ stream.write(item.content)
81
+ part.replace(path)
82
+ result = TransferResult(path, len(item.content.encode("utf-8")))
83
+ else:
84
+ result = self.downloader.download(
85
+ item.media_url, path, overwrite=self.overwrite
86
+ )
79
87
  except DeviantArtError as exc:
80
88
  return DownloadOutcome(item.artwork_id, "failed", path=path, reason=str(exc))
81
89
 
@@ -3,9 +3,12 @@ DownloadItem contract that crosses the provider/downloader boundary."""
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
+ import json
7
+ import re
6
8
  from dataclasses import dataclass, field
7
9
  from datetime import datetime
8
10
  from enum import Enum
11
+ from html import unescape
9
12
  from typing import Any, Dict, Mapping, Optional
10
13
 
11
14
 
@@ -30,6 +33,7 @@ class Deviation:
30
33
  is_mature: bool
31
34
  deviation_type: str
32
35
  premium: bool = False
36
+ text_content: Dict[str, Any] = field(default_factory=dict)
33
37
 
34
38
  @classmethod
35
39
  def from_api_response(cls, data: Dict[str, Any]) -> "Deviation":
@@ -46,6 +50,9 @@ class Deviation:
46
50
  media = data.get("media") or {}
47
51
  if not isinstance(media, dict):
48
52
  media = {}
53
+ text_content = data.get("textContent") or data.get("text_content") or {}
54
+ if not isinstance(text_content, dict):
55
+ text_content = {}
49
56
 
50
57
  deviation_type = data.get("type", "unknown")
51
58
  if data.get("isVideo"):
@@ -65,6 +72,7 @@ class Deviation:
65
72
  is_mature=bool(data.get("isMature", data.get("is_mature", False))),
66
73
  deviation_type=str(deviation_type),
67
74
  premium=_is_locked(data),
75
+ text_content=text_content,
68
76
  )
69
77
 
70
78
  def _extract_extension_from_media(self, media: Dict[str, Any]) -> str:
@@ -101,6 +109,54 @@ class Deviation:
101
109
  return f"{self.title} by {self.author}{flag_str}"
102
110
 
103
111
 
112
+ def literature_text(payload: Mapping[str, Any]) -> str:
113
+ """Extract plain UTF-8 text from a DeviantArt literature DTO.
114
+
115
+ DeviantArt currently stores tiptap JSON in ``textContent.html.markup``;
116
+ older journal payloads use HTML. Both are accepted, while non-literature
117
+ DTOs return an empty string.
118
+ """
119
+ html = payload.get("textContent") or payload.get("text_content") or {}
120
+ if not isinstance(html, dict):
121
+ return ""
122
+ markup = (html.get("html") or {}).get("markup")
123
+ if markup is None:
124
+ return ""
125
+ if isinstance(markup, dict):
126
+ return _tiptap_text(markup).strip()
127
+ if not isinstance(markup, str) or not markup.strip():
128
+ return ""
129
+ try:
130
+ return _tiptap_text(json.loads(markup)).strip()
131
+ except (TypeError, ValueError):
132
+ text = re.sub(r"<br\s*/?>", "\n", markup, flags=re.IGNORECASE)
133
+ text = re.sub(r"</p\s*>", "\n", text, flags=re.IGNORECASE)
134
+ text = re.sub(r"<[^>]+>", "", text)
135
+ return re.sub(r"\n{3,}", "\n\n", unescape(text)).strip()
136
+
137
+
138
+ def _tiptap_text(node: Any) -> str:
139
+ if not isinstance(node, dict):
140
+ return ""
141
+ parts: list[str] = []
142
+ if isinstance(node.get("text"), str):
143
+ parts.append(node["text"])
144
+ if node.get("type") == "hardBreak":
145
+ parts.append("\n")
146
+ content = node.get("content")
147
+ if isinstance(content, list):
148
+ parts.extend(_tiptap_text(child) for child in content)
149
+ if node.get("type") in {
150
+ "paragraph",
151
+ "bulletList",
152
+ "orderedList",
153
+ "blockquote",
154
+ "codeBlock",
155
+ }:
156
+ parts.append("\n")
157
+ return "".join(parts)
158
+
159
+
104
160
  def _is_locked(data: Dict[str, Any]) -> bool:
105
161
  """True for premium/subscription-locked works (official/web DTO shapes)."""
106
162
  premium = data.get("premium_folder_data") or data.get("premiumFolderData")
@@ -123,14 +179,16 @@ class DownloadItem:
123
179
  url: str # deviation page URL (for metadata / archive identity)
124
180
  title: str
125
181
  author: str
126
- media_url: str # the actual file to download
182
+ media_url: str # the actual file to download (empty for inline literature)
127
183
  extension: Optional[str] = None # without leading dot, e.g. "jpg"
184
+ content: Optional[str] = None # inline UTF-8 payload (literature); no HTTP download
128
185
  published_at: Optional[datetime] = None
129
186
  mature: bool = False
130
187
  metadata: Mapping[str, Any] = field(default_factory=dict)
131
188
 
132
189
 
133
190
  _MIME_BY_EXT = {
191
+ "txt": "text/plain",
134
192
  "jpg": "image/jpeg",
135
193
  "jpeg": "image/jpeg",
136
194
  "png": "image/png",
@@ -145,7 +203,7 @@ _MIME_BY_EXT = {
145
203
 
146
204
  _VIDEO_EXTS = frozenset({"mp4", "webm", "mov", "m4v"})
147
205
 
148
- _DEFAULT_MIME = {"image": "image/jpeg", "video": "video/mp4"}
206
+ _DEFAULT_MIME = {"image": "image/jpeg", "video": "video/mp4", "document": "text/plain"}
149
207
 
150
208
 
151
209
  @dataclass(frozen=True)
@@ -214,7 +272,10 @@ def make_media_asset(
214
272
  mature: bool = False,
215
273
  ) -> MediaAsset:
216
274
  ext = (extension or "").lstrip(".").lower() or None
217
- kind = "video" if ext in _VIDEO_EXTS else "image"
275
+ if ext == "txt":
276
+ kind = "document"
277
+ else:
278
+ kind = "video" if ext in _VIDEO_EXTS else "image"
218
279
  return MediaAsset(
219
280
  id=f"deviantart:{deviation_id}:p{index}",
220
281
  deviation_id=deviation_id,
@@ -14,7 +14,7 @@ from typing import Any, Callable, Iterator, Optional, Protocol
14
14
  from .api import ActionType, DeviantArtAPI
15
15
  from .errors import MediaUnavailableError, ParseError
16
16
  from .errors import AuthenticationError, NetworkError
17
- from .models import Deviation, DownloadItem
17
+ from .models import Deviation, DownloadItem, literature_text
18
18
  from .official_api import (
19
19
  OfficialApiClient,
20
20
  additional_media_urls,
@@ -116,6 +116,23 @@ class WebProvider:
116
116
  break
117
117
 
118
118
  def _to_item(self, deviation: Deviation) -> Optional[DownloadItem]:
119
+ if deviation.deviation_type == "literature":
120
+ text = literature_text({"textContent": deviation.text_content})
121
+ if text:
122
+ return DownloadItem(
123
+ artwork_id=deviation.deviation_id,
124
+ url=deviation.url,
125
+ title=deviation.title,
126
+ author=deviation.author,
127
+ media_url="",
128
+ extension="txt",
129
+ content=text,
130
+ mature=deviation.is_mature,
131
+ metadata={"kind": "literature"},
132
+ )
133
+ logger.warning("empty literature payload for %r; skipping", deviation.title)
134
+ return None
135
+
119
136
  media_url = self.api.get_download_url(deviation, self.quality)
120
137
  if media_url is None and self.quality == "o":
121
138
  media_url = self.api.get_download_url(deviation, "f")
@@ -214,6 +231,24 @@ class OfficialProvider:
214
231
  identifier = target.identifier or ""
215
232
  init_data = deviation_init(identifier, target.username)
216
233
  uuid = deviation_uuid(init_data)
234
+ init_deviation = init_data.get("deviation") if isinstance(init_data, dict) else {}
235
+ if isinstance(init_deviation, dict) and init_deviation.get("type") == "literature":
236
+ text = literature_text(init_deviation)
237
+ if text:
238
+ author = init_deviation.get("author") or {}
239
+ yield DownloadItem(
240
+ artwork_id=uuid,
241
+ url=str(init_deviation.get("url") or ""),
242
+ title=str(init_deviation.get("title") or "Untitled"),
243
+ author=str(author.get("username") or "unknown"),
244
+ media_url="",
245
+ extension="txt",
246
+ content=text,
247
+ published_at=_parse_published(init_deviation.get("published_time")),
248
+ mature=bool(init_deviation.get("is_mature", False)),
249
+ metadata={"kind": "literature", "quality": self.quality},
250
+ )
251
+ return
217
252
  main = self._item(self.client.deviation(uuid))
218
253
  yield main
219
254
  for index, url in enumerate(additional_media_urls(init_data), start=1):
@@ -251,6 +286,21 @@ class OfficialProvider:
251
286
  if not uuid:
252
287
  raise ParseError("a deviation is missing its id")
253
288
  author = deviation.get("author") or {}
289
+ if deviation.get("type") == "literature":
290
+ text = literature_text(deviation)
291
+ if text:
292
+ return DownloadItem(
293
+ artwork_id=uuid,
294
+ url=str(deviation.get("url") or ""),
295
+ title=str(deviation.get("title") or "Untitled"),
296
+ author=str(author.get("username") or "unknown"),
297
+ media_url="",
298
+ extension="txt",
299
+ content=text,
300
+ published_at=_parse_published(deviation.get("published_time")),
301
+ mature=bool(deviation.get("is_mature", False)),
302
+ metadata={"kind": "literature", "quality": self.quality},
303
+ )
254
304
  media_url, ext = self._media(deviation, uuid)
255
305
  return DownloadItem(
256
306
  artwork_id=uuid,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devart-dl
3
- Version: 4.3.4
3
+ Version: 4.4.0
4
4
  Summary: 专业的 DeviantArt 作品批量下载工具 - Professional DeviantArt batch downloader with anti-ban features
5
5
  Author: DeviantArt Downloader Team
6
6
  License-Expression: MIT
@@ -89,6 +89,7 @@ devart-dl login browser # 打开你的 Chrome 登录,自动保存 Cookie,
89
89
 
90
90
  - **官方 API(OAuth)优先**:登录一次即可下载原图,无需导出 Cookie,也无需防封延时。
91
91
  - **URL 优先**:直接粘贴作品 / 画廊 / 收藏夹 / 标签链接即可下载。
92
+ - **文本作品**:文学 / 日志型 deviation 在响应内直接提取正文,保存为 UTF-8 `.txt`;媒体与文本自动分流。
92
93
  - **下载可靠**:HTTP Range 断点续传、自动重试、429 退避、原子落盘。
93
94
  - **下载档案**:`--archive` 用 SQLite 记住已下载,跨会话自动跳过。
94
95
  - **路径模板 + 元数据 sidecar**:`--directory`/`--filename` 模板与 `--write-info-json`。
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devart-dl"
7
- version = "4.3.4"
7
+ version = "4.4.0"
8
8
  description = "专业的 DeviantArt 作品批量下载工具 - Professional DeviantArt batch downloader with anti-ban features"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -156,3 +156,18 @@ def test_dry_run_plans_without_writing(tmp_path: Path) -> None:
156
156
  assert not outcome.path.exists()
157
157
  assert downloader.calls == [] # never touched the network or disk
158
158
 
159
+
160
+
161
+ def test_inline_literature_writes_text_without_http(tmp_path: Path) -> None:
162
+ downloader = FakeDownloader()
163
+ manager = make_manager(tmp_path, downloader=downloader)
164
+ item = make_item(media_url="", extension="txt", content="Chapter\n\nText")
165
+
166
+ outcome = manager.run(item)
167
+
168
+ assert outcome.status == "downloaded"
169
+ assert outcome.path is not None
170
+ assert outcome.path.read_bytes() == "Chapter\n\nText".encode("utf-8")
171
+ assert outcome.size == len("Chapter\n\nText".encode("utf-8"))
172
+ assert downloader.calls == []
173
+ assert not outcome.path.with_name(outcome.path.name + ".part").exists()
@@ -333,3 +333,48 @@ def test_resolve_short_id_follows_redirect() -> None:
333
333
  return FakeResponse()
334
334
 
335
335
  assert _resolve_short_id("abc123", FakeHttp(), 30) == "123456"
336
+
337
+
338
+ def test_artwork_literature_uses_init_text_and_skips_media_api(monkeypatch) -> None:
339
+ markup = "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"Once upon a time.\"}]}]}"
340
+ init = {
341
+ "deviation": {
342
+ "extended": {"deviationUuid": "uuid-1", "additionalMedia": []},
343
+ "type": "literature",
344
+ "title": "Story",
345
+ "author": {"username": "alice"},
346
+ "url": "https://www.deviantart.com/alice/art/story-123456",
347
+ "textContent": {"html": {"markup": markup}},
348
+ }
349
+ }
350
+ monkeypatch.setattr(provider_mod, "deviation_init", lambda identifier, username=None, **_: init)
351
+ provider = OfficialProvider(FakeClient(), quality="o")
352
+
353
+ items = list(provider.resolve(TargetParser.parse("123456")))
354
+
355
+ assert items[0].content == "Once upon a time."
356
+ assert items[0].media_url == ""
357
+ assert items[0].extension == "txt"
358
+ assert items[0].metadata == {"kind": "literature", "quality": "o"}
359
+
360
+
361
+ def test_gallery_literature_is_inline_text() -> None:
362
+ client = FakeClient()
363
+ client.pages[("gallery_all", "alice", 0)] = {
364
+ "results": [{
365
+ "deviationid": "uuid-1",
366
+ "title": "Story",
367
+ "author": {"username": "alice"},
368
+ "url": "https://www.deviantart.com/alice/art/story-1",
369
+ "type": "literature",
370
+ "textContent": {"html": {"markup": "{\"type\":\"doc\",\"content\":[{\"type\":\"text\",\"text\":\"Text\"}]}"}},
371
+ }],
372
+ "has_more": False,
373
+ }
374
+ provider = OfficialProvider(client, quality="o")
375
+
376
+ items = list(provider.resolve(TargetParser.parse("https://www.deviantart.com/alice/gallery")))
377
+
378
+ assert items[0].content == "Text"
379
+ assert items[0].media_url == ""
380
+ assert items[0].extension == "txt"
@@ -249,3 +249,21 @@ def test_composite_tag_without_official_requires_official() -> None:
249
249
  router = CompositeProvider(official=None, web=web)
250
250
  with pytest.raises(MediaUnavailableError):
251
251
  list(router.resolve(TargetParser.parse("https://www.deviantart.com/tag/landscape")))
252
+
253
+
254
+ def test_literature_item_is_delivered_as_inline_text() -> None:
255
+ dev = make_deviation("123", title="Story")
256
+ dev.deviation_type = "literature"
257
+ dev.media = {}
258
+ dev.text_content = {
259
+ "html": {"markup": "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"Chapter one\"}]},{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"Chapter two\"}]}]}"}
260
+ }
261
+ provider = make_provider(pages=[([dev], False, 1, "")])
262
+
263
+ items = list(provider.resolve(TargetParser.parse("https://www.deviantart.com/alice/gallery")))
264
+
265
+ assert len(items) == 1
266
+ assert items[0].content == "Chapter one\nChapter two"
267
+ assert items[0].media_url == ""
268
+ assert items[0].extension == "txt"
269
+ assert items[0].metadata == {"kind": "literature"}
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes