gdown2 0.2.0__py3-none-any.whl
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.
- gdown2/__init__.py +27 -0
- gdown2/__main__.py +9 -0
- gdown2/application/__init__.py +31 -0
- gdown2/application/crawler.py +128 -0
- gdown2/application/exporter.py +40 -0
- gdown2/application/planner.py +193 -0
- gdown2/application/ports.py +85 -0
- gdown2/application/reporter.py +285 -0
- gdown2/application/resolver.py +50 -0
- gdown2/application/scheduler.py +150 -0
- gdown2/application/verifier.py +81 -0
- gdown2/application/worker.py +307 -0
- gdown2/domain/__init__.py +89 -0
- gdown2/domain/errors.py +151 -0
- gdown2/domain/models.py +254 -0
- gdown2/domain/path_policy.py +138 -0
- gdown2/domain/policies.py +117 -0
- gdown2/domain/tree.py +113 -0
- gdown2/domain/url_parser.py +148 -0
- gdown2/infrastructure/__init__.py +12 -0
- gdown2/infrastructure/auth/__init__.py +16 -0
- gdown2/infrastructure/auth/anonymous.py +21 -0
- gdown2/infrastructure/auth/api_key.py +25 -0
- gdown2/infrastructure/auth/base.py +21 -0
- gdown2/infrastructure/auth/oauth.py +128 -0
- gdown2/infrastructure/auth/service_account.py +92 -0
- gdown2/infrastructure/cache.py +131 -0
- gdown2/infrastructure/config.py +182 -0
- gdown2/infrastructure/drive/__init__.py +20 -0
- gdown2/infrastructure/drive/metadata.py +105 -0
- gdown2/infrastructure/drive/source_api.py +204 -0
- gdown2/infrastructure/drive/source_composite.py +70 -0
- gdown2/infrastructure/drive/source_web.py +666 -0
- gdown2/infrastructure/http/__init__.py +14 -0
- gdown2/infrastructure/http/client.py +209 -0
- gdown2/infrastructure/http/ratelimit.py +46 -0
- gdown2/infrastructure/http/retry.py +114 -0
- gdown2/infrastructure/storage/__init__.py +7 -0
- gdown2/infrastructure/storage/fs_sink.py +87 -0
- gdown2/infrastructure/storage/resume_state.py +94 -0
- gdown2/interfaces/__init__.py +6 -0
- gdown2/interfaces/cli/__init__.py +6 -0
- gdown2/interfaces/cli/app.py +405 -0
- gdown2/interfaces/cli/render.py +247 -0
- gdown2/interfaces/json_events.py +27 -0
- gdown2/interfaces/tui/__init__.py +6 -0
- gdown2/interfaces/tui/app.py +205 -0
- gdown2/py.typed +0 -0
- gdown2/sdk.py +278 -0
- gdown2-0.2.0.dist-info/METADATA +456 -0
- gdown2-0.2.0.dist-info/RECORD +55 -0
- gdown2-0.2.0.dist-info/WHEEL +5 -0
- gdown2-0.2.0.dist-info/entry_points.txt +2 -0
- gdown2-0.2.0.dist-info/licenses/LICENSE +22 -0
- gdown2-0.2.0.dist-info/top_level.txt +1 -0
gdown2/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""gdown2 — Fast, resumable Google Drive file and folder downloader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
from gdown2.domain.models import DownloadTask, DriveItem, FolderNode, TaskState
|
|
8
|
+
from gdown2.sdk import download, download_sync, get_info, get_tree, resume_directory
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
__version__ = version("gdown2")
|
|
12
|
+
except PackageNotFoundError: # pragma: no cover - source checkout without installation
|
|
13
|
+
__version__ = "0.2.0"
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"DownloadTask",
|
|
17
|
+
"DriveItem",
|
|
18
|
+
"FolderNode",
|
|
19
|
+
"TaskState",
|
|
20
|
+
"__version__",
|
|
21
|
+
"download",
|
|
22
|
+
"download_sync",
|
|
23
|
+
"get_info",
|
|
24
|
+
"get_tree",
|
|
25
|
+
"resume_directory",
|
|
26
|
+
]
|
|
27
|
+
|
gdown2/__main__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Application orchestration services for gdown2."""
|
|
2
|
+
|
|
3
|
+
from gdown2.application.crawler import FolderCrawler
|
|
4
|
+
from gdown2.application.exporter import ExportService
|
|
5
|
+
from gdown2.application.planner import DownloadPlan, Planner
|
|
6
|
+
from gdown2.application.ports import DriveSource, MediaStream, MetadataCache, ResumeRepository
|
|
7
|
+
from gdown2.application.reporter import EventType, Observer, Reporter, TransferEvent
|
|
8
|
+
from gdown2.application.resolver import Resolver
|
|
9
|
+
from gdown2.application.scheduler import Scheduler
|
|
10
|
+
from gdown2.application.verifier import Verifier
|
|
11
|
+
from gdown2.application.worker import DownloadWorker
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"DownloadPlan",
|
|
15
|
+
"DownloadWorker",
|
|
16
|
+
"DriveSource",
|
|
17
|
+
"EventType",
|
|
18
|
+
"ExportService",
|
|
19
|
+
"FolderCrawler",
|
|
20
|
+
"MediaStream",
|
|
21
|
+
"MetadataCache",
|
|
22
|
+
"Observer",
|
|
23
|
+
"Planner",
|
|
24
|
+
"Reporter",
|
|
25
|
+
"Resolver",
|
|
26
|
+
"ResumeRepository",
|
|
27
|
+
"Scheduler",
|
|
28
|
+
"TransferEvent",
|
|
29
|
+
"Verifier",
|
|
30
|
+
]
|
|
31
|
+
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Recursive folder crawler with shortcut resolution and cycle prevention."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from pathlib import PurePosixPath
|
|
7
|
+
|
|
8
|
+
from gdown2.application.ports import DriveSource, MetadataCache
|
|
9
|
+
from gdown2.domain.errors import PermissionDeniedError, ResourceNotFoundError
|
|
10
|
+
from gdown2.domain.models import DriveItem, FileEntry, FolderNode
|
|
11
|
+
from gdown2.domain.path_policy import PathPolicy
|
|
12
|
+
from gdown2.domain.url_parser import ParsedTarget, TargetKind
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FolderCrawler:
|
|
16
|
+
"""Builds a complete :class:`FolderNode` tree from a root Drive folder."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
source: DriveSource,
|
|
21
|
+
path_policy: PathPolicy | None = None,
|
|
22
|
+
cache: MetadataCache | None = None,
|
|
23
|
+
max_depth: int = 32,
|
|
24
|
+
follow_shortcuts: bool = True,
|
|
25
|
+
preferred_export_format: str | None = None,
|
|
26
|
+
concurrency: int = 6,
|
|
27
|
+
) -> None:
|
|
28
|
+
self._source = source
|
|
29
|
+
self._path_policy = path_policy or PathPolicy()
|
|
30
|
+
self._cache = cache
|
|
31
|
+
self._max_depth = max_depth
|
|
32
|
+
self._follow_shortcuts = follow_shortcuts
|
|
33
|
+
self._preferred_export_format = preferred_export_format
|
|
34
|
+
self._concurrency = max(1, concurrency)
|
|
35
|
+
|
|
36
|
+
async def crawl(self, root_item: DriveItem) -> FolderNode:
|
|
37
|
+
"""Traverse ``root_item`` in breadth-first order and return its :class:`FolderNode`."""
|
|
38
|
+
root_name = self._path_policy.sanitize_segment(root_item.name, fallback=root_item.id)
|
|
39
|
+
root_node = FolderNode(
|
|
40
|
+
item=root_item,
|
|
41
|
+
relative_path=PurePosixPath(root_name),
|
|
42
|
+
depth=0,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
visited_folders: set[str] = {root_item.id}
|
|
46
|
+
current_level: list[FolderNode] = [root_node]
|
|
47
|
+
sem = asyncio.Semaphore(self._concurrency)
|
|
48
|
+
|
|
49
|
+
async def _fetch_children(node: FolderNode) -> tuple[FolderNode, list[DriveItem]]:
|
|
50
|
+
async with sem:
|
|
51
|
+
try:
|
|
52
|
+
children = await self._source.list_folder_children(
|
|
53
|
+
node.item.id,
|
|
54
|
+
resource_key=node.item.resource_key,
|
|
55
|
+
)
|
|
56
|
+
return node, children
|
|
57
|
+
except (ResourceNotFoundError, PermissionDeniedError):
|
|
58
|
+
if node.depth == 0:
|
|
59
|
+
raise
|
|
60
|
+
return node, []
|
|
61
|
+
|
|
62
|
+
while current_level:
|
|
63
|
+
results = await asyncio.gather(*(_fetch_children(node) for node in current_level))
|
|
64
|
+
next_level: list[FolderNode] = []
|
|
65
|
+
|
|
66
|
+
for current, children in results:
|
|
67
|
+
for child in children:
|
|
68
|
+
resolved = await self._resolve_if_shortcut(child)
|
|
69
|
+
if resolved is None:
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
if self._cache is not None:
|
|
73
|
+
self._cache.put(resolved, persist=False)
|
|
74
|
+
|
|
75
|
+
if resolved.is_folder:
|
|
76
|
+
if resolved.id in visited_folders:
|
|
77
|
+
# Skip shortcut cycles pointing back to an ancestor or queued folder.
|
|
78
|
+
continue
|
|
79
|
+
visited_folders.add(resolved.id)
|
|
80
|
+
folder_rel = self._path_policy.allocate_child_path(
|
|
81
|
+
current.relative_path,
|
|
82
|
+
resolved.name,
|
|
83
|
+
)
|
|
84
|
+
sub_node = FolderNode(
|
|
85
|
+
item=resolved,
|
|
86
|
+
relative_path=folder_rel,
|
|
87
|
+
depth=current.depth + 1,
|
|
88
|
+
)
|
|
89
|
+
current.subfolders.append(sub_node)
|
|
90
|
+
if sub_node.depth < self._max_depth:
|
|
91
|
+
next_level.append(sub_node)
|
|
92
|
+
else:
|
|
93
|
+
forced_ext: str | None = None
|
|
94
|
+
if resolved.is_google_doc:
|
|
95
|
+
_, forced_ext = resolved.resolve_export(self._preferred_export_format)
|
|
96
|
+
file_rel = self._path_policy.allocate_child_path(
|
|
97
|
+
current.relative_path,
|
|
98
|
+
resolved.name,
|
|
99
|
+
forced_extension=forced_ext,
|
|
100
|
+
)
|
|
101
|
+
current.files.append(
|
|
102
|
+
FileEntry(
|
|
103
|
+
item=resolved,
|
|
104
|
+
relative_path=file_rel,
|
|
105
|
+
depth=current.depth + 1,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if self._cache is not None:
|
|
110
|
+
self._cache.flush()
|
|
111
|
+
current_level = next_level
|
|
112
|
+
|
|
113
|
+
return root_node
|
|
114
|
+
|
|
115
|
+
async def _resolve_if_shortcut(self, item: DriveItem) -> DriveItem | None:
|
|
116
|
+
if not item.is_shortcut:
|
|
117
|
+
return item
|
|
118
|
+
if not self._follow_shortcuts or not item.shortcut_target_id:
|
|
119
|
+
return None
|
|
120
|
+
try:
|
|
121
|
+
target = await self._source.fetch_metadata(
|
|
122
|
+
ParsedTarget(id=item.shortcut_target_id, kind=TargetKind.UNKNOWN)
|
|
123
|
+
)
|
|
124
|
+
except (ResourceNotFoundError, PermissionDeniedError):
|
|
125
|
+
return None
|
|
126
|
+
if item.name:
|
|
127
|
+
target.name = item.name
|
|
128
|
+
return target
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Export service for Google Workspace documents (Docs, Sheets, Slides, Drawings)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from gdown2.application.ports import DriveSource
|
|
8
|
+
from gdown2.domain.models import DownloadTask
|
|
9
|
+
from gdown2.infrastructure.storage.fs_sink import FileSystemSink
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ExportService:
|
|
13
|
+
"""Streams Google Workspace documents in their target export MIME format."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, source: DriveSource, sink: FileSystemSink) -> None:
|
|
16
|
+
self._source = source
|
|
17
|
+
self._sink = sink
|
|
18
|
+
|
|
19
|
+
async def export_task(
|
|
20
|
+
self,
|
|
21
|
+
task: DownloadTask,
|
|
22
|
+
*,
|
|
23
|
+
on_bytes: Callable[[int], None] | None = None,
|
|
24
|
+
) -> int:
|
|
25
|
+
"""Stream the exported document into ``task.part_path`` and return total bytes written."""
|
|
26
|
+
export_mime, _ = task.item.resolve_export(task.export_format)
|
|
27
|
+
written = 0
|
|
28
|
+
|
|
29
|
+
async with self._source.open_export_stream(task.item, export_mime) as media:
|
|
30
|
+
with self._sink.open_part(task.part_path, append=False) as handle:
|
|
31
|
+
async for chunk in media.chunks:
|
|
32
|
+
if not chunk:
|
|
33
|
+
continue
|
|
34
|
+
handle.write(chunk)
|
|
35
|
+
written += len(chunk)
|
|
36
|
+
if on_bytes is not None:
|
|
37
|
+
on_bytes(len(chunk))
|
|
38
|
+
|
|
39
|
+
return written
|
|
40
|
+
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Execution planner turning resolved Drive items or folder trees into DownloadTasks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
|
|
9
|
+
from gdown2.domain.models import (
|
|
10
|
+
DownloadTask,
|
|
11
|
+
DriveItem,
|
|
12
|
+
FolderNode,
|
|
13
|
+
TaskState,
|
|
14
|
+
TransferState,
|
|
15
|
+
)
|
|
16
|
+
from gdown2.domain.path_policy import PathPolicy
|
|
17
|
+
from gdown2.domain.policies import ExistingFilePolicy
|
|
18
|
+
from gdown2.domain.tree import walk_files, walk_folders_bfs
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class DownloadPlan:
|
|
23
|
+
"""Ordered directories to create and download tasks to execute."""
|
|
24
|
+
|
|
25
|
+
root_dir: Path
|
|
26
|
+
directories: list[Path] = field(default_factory=list)
|
|
27
|
+
tasks: list[DownloadTask] = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def total_bytes(self) -> int:
|
|
31
|
+
return sum(t.bytes_total or 0 for t in self.tasks)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Planner:
|
|
35
|
+
"""Produces a deterministic :class:`DownloadPlan` from domain objects."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
path_policy: PathPolicy | None = None,
|
|
40
|
+
existing_policy: ExistingFilePolicy = ExistingFilePolicy.RESUME,
|
|
41
|
+
flat: bool = False,
|
|
42
|
+
export_format: str | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._path_policy = path_policy or PathPolicy()
|
|
45
|
+
self._existing_policy = existing_policy
|
|
46
|
+
self._flat = flat
|
|
47
|
+
self._export_format = export_format
|
|
48
|
+
|
|
49
|
+
def plan_single(
|
|
50
|
+
self,
|
|
51
|
+
item: DriveItem,
|
|
52
|
+
output: Path,
|
|
53
|
+
*,
|
|
54
|
+
saved_states: dict[str, TransferState] | None = None,
|
|
55
|
+
) -> DownloadPlan:
|
|
56
|
+
"""Create a plan for a single file or Google Workspace document."""
|
|
57
|
+
forced_ext: str | None = None
|
|
58
|
+
if item.is_google_doc:
|
|
59
|
+
_, forced_ext = item.resolve_export(self._export_format)
|
|
60
|
+
|
|
61
|
+
# If output is an existing directory (or ends with a separator), place the file inside it.
|
|
62
|
+
is_directory_output = output.is_dir() or str(output).endswith(("/", "\\"))
|
|
63
|
+
if is_directory_output:
|
|
64
|
+
root_dir = output.resolve()
|
|
65
|
+
rel = self._path_policy.allocate_child_path(
|
|
66
|
+
PurePosixPath("."),
|
|
67
|
+
item.name,
|
|
68
|
+
forced_extension=forced_ext,
|
|
69
|
+
)
|
|
70
|
+
local_path = self._path_policy.resolve_local_path(root_dir, rel)
|
|
71
|
+
else:
|
|
72
|
+
root_dir = output.parent.resolve()
|
|
73
|
+
rel = PurePosixPath(self._path_policy.sanitize_segment(output.name, fallback=item.name))
|
|
74
|
+
if forced_ext and not rel.name.lower().endswith(forced_ext.lower()):
|
|
75
|
+
rel = PurePosixPath(f"{rel.name}{forced_ext}")
|
|
76
|
+
local_path = self._path_policy.resolve_local_path(root_dir, rel)
|
|
77
|
+
|
|
78
|
+
task = self._build_task(item, local_path, rel, saved_states=saved_states)
|
|
79
|
+
return DownloadPlan(
|
|
80
|
+
root_dir=root_dir,
|
|
81
|
+
directories=[root_dir],
|
|
82
|
+
tasks=[task],
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def plan_tree(
|
|
86
|
+
self,
|
|
87
|
+
tree: FolderNode,
|
|
88
|
+
output_dir: Path,
|
|
89
|
+
*,
|
|
90
|
+
saved_states: dict[str, TransferState] | None = None,
|
|
91
|
+
) -> DownloadPlan:
|
|
92
|
+
"""Create a plan for an entire :class:`FolderNode` hierarchy."""
|
|
93
|
+
root_dir = output_dir.resolve()
|
|
94
|
+
directories: list[Path] = [root_dir]
|
|
95
|
+
tasks: list[DownloadTask] = []
|
|
96
|
+
|
|
97
|
+
if not self._flat:
|
|
98
|
+
for folder in walk_folders_bfs(tree):
|
|
99
|
+
folder_path = self._path_policy.resolve_local_path(root_dir, folder.relative_path)
|
|
100
|
+
if folder_path not in directories:
|
|
101
|
+
directories.append(folder_path)
|
|
102
|
+
|
|
103
|
+
flat_policy = PathPolicy()
|
|
104
|
+
for entry in walk_files(tree):
|
|
105
|
+
if self._flat:
|
|
106
|
+
forced_ext: str | None = None
|
|
107
|
+
if entry.item.is_google_doc:
|
|
108
|
+
_, forced_ext = entry.item.resolve_export(self._export_format)
|
|
109
|
+
rel = flat_policy.allocate_child_path(
|
|
110
|
+
PurePosixPath("."),
|
|
111
|
+
entry.item.name,
|
|
112
|
+
forced_extension=forced_ext,
|
|
113
|
+
)
|
|
114
|
+
else:
|
|
115
|
+
rel = entry.relative_path
|
|
116
|
+
|
|
117
|
+
local_path = self._path_policy.resolve_local_path(root_dir, rel)
|
|
118
|
+
task = self._build_task(entry.item, local_path, rel, saved_states=saved_states)
|
|
119
|
+
tasks.append(task)
|
|
120
|
+
|
|
121
|
+
return DownloadPlan(
|
|
122
|
+
root_dir=root_dir,
|
|
123
|
+
directories=directories,
|
|
124
|
+
tasks=tasks,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def _build_task(
|
|
128
|
+
self,
|
|
129
|
+
item: DriveItem,
|
|
130
|
+
local_path: Path,
|
|
131
|
+
relative_path: PurePosixPath,
|
|
132
|
+
*,
|
|
133
|
+
saved_states: dict[str, TransferState] | None = None,
|
|
134
|
+
) -> DownloadTask:
|
|
135
|
+
task = DownloadTask(
|
|
136
|
+
item=item,
|
|
137
|
+
local_path=local_path,
|
|
138
|
+
relative_path=relative_path,
|
|
139
|
+
bytes_total=item.size,
|
|
140
|
+
etag=item.etag,
|
|
141
|
+
export_format=self._export_format if item.is_google_doc else None,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Check if the final file already exists on disk.
|
|
145
|
+
if local_path.exists() and local_path.is_file():
|
|
146
|
+
existing_size = local_path.stat().st_size
|
|
147
|
+
if self._existing_policy == ExistingFilePolicy.SKIP:
|
|
148
|
+
task.state = TaskState.SKIPPED
|
|
149
|
+
task.bytes_done = existing_size
|
|
150
|
+
return task
|
|
151
|
+
if self._existing_policy == ExistingFilePolicy.RESUME and self._is_complete(
|
|
152
|
+
item, local_path, existing_size
|
|
153
|
+
):
|
|
154
|
+
task.state = TaskState.SKIPPED
|
|
155
|
+
task.bytes_done = existing_size
|
|
156
|
+
return task
|
|
157
|
+
|
|
158
|
+
# Overwriting must never resume stale bytes from a previous transfer.
|
|
159
|
+
if self._existing_policy == ExistingFilePolicy.OVERWRITE and task.part_path.exists():
|
|
160
|
+
task.stale_part = True
|
|
161
|
+
task.resume_offset = 0
|
|
162
|
+
task.bytes_done = 0
|
|
163
|
+
return task
|
|
164
|
+
|
|
165
|
+
# Inspect .part file and saved TransferState for mid-stream resume.
|
|
166
|
+
if self._existing_policy == ExistingFilePolicy.RESUME and task.part_path.exists():
|
|
167
|
+
part_size = task.part_path.stat().st_size
|
|
168
|
+
saved = (saved_states or {}).get(task.task_id)
|
|
169
|
+
if saved and saved.etag and item.etag and saved.etag != item.etag:
|
|
170
|
+
# Remote file changed since last session; discard stale partial data.
|
|
171
|
+
task.stale_part = True
|
|
172
|
+
task.resume_offset = 0
|
|
173
|
+
task.bytes_done = 0
|
|
174
|
+
else:
|
|
175
|
+
task.resume_offset = part_size
|
|
176
|
+
task.bytes_done = part_size
|
|
177
|
+
if saved and saved.etag and not task.etag:
|
|
178
|
+
task.etag = saved.etag
|
|
179
|
+
|
|
180
|
+
return task
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def _is_complete(item: DriveItem, local_path: Path, existing_size: int) -> bool:
|
|
184
|
+
if item.size is None or existing_size != item.size:
|
|
185
|
+
return False
|
|
186
|
+
if not item.md5:
|
|
187
|
+
return True
|
|
188
|
+
hasher = hashlib.md5()
|
|
189
|
+
with local_path.open("rb") as handle:
|
|
190
|
+
while chunk := handle.read(256 * 1024):
|
|
191
|
+
hasher.update(chunk)
|
|
192
|
+
return hasher.hexdigest().lower() == item.md5.lower()
|
|
193
|
+
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Ports (interfaces) used by the application layer to talk to infrastructure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from contextlib import AbstractAsyncContextManager
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
from gdown2.domain.models import DriveItem, TransferState
|
|
11
|
+
from gdown2.domain.url_parser import ParsedTarget
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class MediaStream:
|
|
16
|
+
"""Opened byte stream along with HTTP transfer headers."""
|
|
17
|
+
|
|
18
|
+
chunks: AsyncIterator[bytes]
|
|
19
|
+
status_code: int = 200
|
|
20
|
+
content_length: int | None = None
|
|
21
|
+
etag: str | None = None
|
|
22
|
+
accept_ranges: bool = False
|
|
23
|
+
is_range_satisfied: bool = True
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DriveSource(Protocol):
|
|
27
|
+
"""Port for querying Google Drive metadata and streaming file contents."""
|
|
28
|
+
|
|
29
|
+
async def fetch_metadata(self, target: ParsedTarget) -> DriveItem:
|
|
30
|
+
"""Fetch metadata for a single file, folder, or native doc."""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
async def list_folder_children(
|
|
34
|
+
self,
|
|
35
|
+
folder_id: str,
|
|
36
|
+
*,
|
|
37
|
+
resource_key: str | None = None,
|
|
38
|
+
) -> list[DriveItem]:
|
|
39
|
+
"""List immediate children of a Drive folder (handling pagination internally)."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
def open_download_stream(
|
|
43
|
+
self,
|
|
44
|
+
item: DriveItem,
|
|
45
|
+
*,
|
|
46
|
+
start_byte: int = 0,
|
|
47
|
+
end_byte: int | None = None,
|
|
48
|
+
) -> AbstractAsyncContextManager[MediaStream]:
|
|
49
|
+
"""Open a binary download stream, optionally starting at ``start_byte``."""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
def open_export_stream(
|
|
53
|
+
self,
|
|
54
|
+
item: DriveItem,
|
|
55
|
+
export_mime: str,
|
|
56
|
+
) -> AbstractAsyncContextManager[MediaStream]:
|
|
57
|
+
"""Open an export stream for a Google Workspace native document."""
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class MetadataCache(Protocol):
|
|
62
|
+
"""Port for caching resolved DriveItem records."""
|
|
63
|
+
|
|
64
|
+
def get(self, item_id: str) -> DriveItem | None:
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
def put(self, item: DriveItem, *, persist: bool = True) -> None:
|
|
68
|
+
...
|
|
69
|
+
|
|
70
|
+
def flush(self) -> None:
|
|
71
|
+
...
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ResumeRepository(Protocol):
|
|
75
|
+
"""Port for persisting and loading transfer state across runs."""
|
|
76
|
+
|
|
77
|
+
def load_all(self) -> dict[str, TransferState]:
|
|
78
|
+
...
|
|
79
|
+
|
|
80
|
+
def save_task(self, state: TransferState) -> None:
|
|
81
|
+
...
|
|
82
|
+
|
|
83
|
+
def remove_task(self, task_key: str) -> None:
|
|
84
|
+
...
|
|
85
|
+
|