Coverage for seedboxsync/core/sync/services/seedbox.py: 89%
89 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-26 17:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-26 17:46 +0000
1#
2# Copyright (C) 2015-2026 Guillaume Kulakowski <guillaume@kulakowski.fr>
3#
4# For the full copyright and license information, please view the LICENSE
5# file that was distributed with this source code.
6#
7"""SeedboxSync sync service for seedbox."""
9import datetime
10from os import PathLike, fspath
11from pathlib import Path
12import re
13from paramiko import SSHException
14from seedboxsync.core import current_app as app, utils
15from seedboxsync.core.dao import Download
16from seedboxsync.core.exception import SeedboxSyncConfigurationError
17from seedboxsync.core.sync.download_progress import DownloadProgress
18from seedboxsync.core.taskmanager import track_taskstatus
20LOCK_NAME = "sync-seedbox"
21PRIORITY = 5
24@track_taskstatus(LOCK_NAME)
25def seedbox(dry_run: bool, ping: bool, only_store: bool) -> None:
26 """
27 Perform synchronization from the seedbox.
29 Downloads files from the remote seedbox to the local machine,
30 supports optional dry-run and only-store modes, applies exclusion patterns,
31 and persists download information in the database.
33 Args:
34 dry_run (bool): Whether to list files without downloading or persisting them.
35 ping (bool): Whether to ping the configured monitoring service.
36 only_store (bool): Whether to record remote files without downloading them.
37 """
38 if not app.seedboxsync_config.get("sync_seedbox_enabled"):
39 app.logger.info("Seedbox synchronization task is disabled")
40 return
42 app.logger.debug(f'sync seedbox dry-run: "{dry_run}"')
43 app.logger.debug(f'sync seeddbox only-store: "{only_store}"')
44 app.logger.debug(f'sync seedbox ping: "{ping}"')
46 # Call ping.start() if enabled
47 if ping:
48 app.ping.start("sync_seedbox")
50 finished_path = app.seedboxsync_config.get("seedbox_finished_path", "")
51 part_suffix = app.seedboxsync_config.get("seedbox_part_suffix")
52 app.logger.debug(f'Scanning files in "{finished_path}"')
54 # Walk through all files on the seedbox
55 try:
56 try:
57 app.sync.chdir(None) # type: ignore[arg-type]
58 app.sync.chdir(finished_path)
59 except FileNotFoundError as exc:
60 app.logger.error(f"{exc!s}\nFailed to scan directory: {finished_path}")
61 return
63 for root, _, filenames in app.sync.walk(""): # type: ignore[attr-defined]
64 root = Path(root)
66 for filename in filenames:
67 filepath = root / filename
69 if filepath.suffix == part_suffix:
70 app.logger.debug(f'Skipping part file "{filename}"')
71 elif Download.is_already_download(filepath):
72 app.logger.debug(f'Skipping already downloaded file "{filename}"')
73 elif __exclude_by_pattern(filepath):
74 app.logger.debug(f'Skipping excluded file "{filename}"')
75 else:
76 if dry_run:
77 app.logger.info(f'Dry-run: not downloading "{filepath}"')
78 continue
80 __get_file(filepath, only_store)
82 except (OSError, FileNotFoundError) as exc:
83 app.logger.error(f'SeedboxSyncError > "{exc}"')
85 # Call ping.success() if enabled
86 if ping:
87 app.ping.success("sync_seedbox")
90def __exclude_by_pattern(filepath: str | PathLike[str]) -> bool:
91 """
92 Determine if a file should be excluded from synchronization based on patterns.
94 Args:
95 ctx (Context): The Click context object.
96 filepath (str | PathLike[str]): Path of the file to check.
98 Returns:
99 bool: True if the file matches the exclude pattern, False otherwise.
101 Raises:
102 SeedboxSyncConfigurationError: If the exclude pattern is invalid.
103 """
104 pattern = app.seedboxsync_config.get("seedbox_exclude_syncing", "")
105 filepath = fspath(filepath)
106 if not pattern:
107 return False
109 try:
110 return re.search(pattern, filepath) is not None
111 except re.error as exc:
112 raise SeedboxSyncConfigurationError("Invalid configuration for exclude_syncing! See https://docs.python.org/3/library/re.html") from exc
115def __get_file(filepath: str | PathLike[str], only_store: bool) -> None:
116 """
117 Download a single file from the seedbox.
119 Handles file path creation, partial download suffix, database persistence,
120 size verification, and renaming after successful download.
122 Args:
123 ctx (Context): The Click context object.
124 filepath (str): Path of the file on the seedbox.
125 only_store (bool): Whether to record the file without downloading it.
126 """
127 filepath = fspath(filepath)
128 local_filepath = Path(app.seedboxsync_config.get("local_download_path", "")).expanduser().resolve() / filepath
129 part_suffix = app.seedboxsync_config.get("seedbox_part_suffix", "")
130 local_filepath_part = local_filepath.with_suffix(part_suffix)
131 local_path = local_filepath.expanduser().resolve().parent
133 # Create local directory tree if necessary
134 if not only_store:
135 utils.ensure_dir_exists(local_path)
136 app.logger.debug(f'Download: "{filepath}" to "{local_path}"')
138 try:
139 seedbox_size = app.sync.stat(filepath).st_size
140 if seedbox_size == 0:
141 app.logger.warning(f'Empty file: "{filepath}" ({seedbox_size!s})')
143 download = Download.create(path=filepath, seedbox_size=seedbox_size)
144 download.save()
146 if not only_store:
147 app.logger.info(f'Downloading "{filepath}"')
148 progress_callback = DownloadProgress(download)
149 app.sync.get(filepath, local_filepath_part, progress_callback=progress_callback)
150 local_size = Path(local_filepath_part).stat().st_size
152 if local_size == 0 or local_size != seedbox_size:
153 app.logger.error(f'Download failed: "{filepath}" ({local_size!s}/{seedbox_size!s})')
155 Path(local_filepath_part).rename(local_filepath)
156 else:
157 app.logger.info(f'Marking as downloaded "{filepath}"')
158 local_size = seedbox_size
160 download.local_size = local_size
161 download.finished = datetime.datetime.now()
162 download.save()
163 except SSHException as exc:
164 app.logger.error(f"Download failed: {exc!s}")