Compare commits

..
2 Commits
Author SHA1 Message Date
ModerateWinGuy 0157f462cc reformatting
formatting_check / formatting_check (push) Failing after 3s
Python Test / test (.ps1, windows-latest, 3.9) (push) Has been cancelled
Python Test / test (.sh, macos-latest, 3.9) (push) Has been cancelled
Python Test / test (.sh, ubuntu-latest, 3.9) (push) Has been cancelled
2026-07-14 21:32:55 +12:00
ModerateWinGuy 3d0658b483 fixed lintng
formatting_check / formatting_check (push) Failing after 4s
Python Test / test (.ps1, windows-latest, 3.9) (push) Has been cancelled
Python Test / test (.sh, macos-latest, 3.9) (push) Has been cancelled
Python Test / test (.sh, ubuntu-latest, 3.9) (push) Has been cancelled
2026-07-14 21:23:30 +12:00
18 changed files with 439 additions and 378 deletions
+11 -2
View File
@@ -26,7 +26,11 @@ _common_options = [
click.option("--file-scheme", default=None, type=str), click.option("--file-scheme", default=None, type=str),
click.option("--filename-restriction-scheme", type=click.Choice(("linux", "windows")), default=None), click.option("--filename-restriction-scheme", type=click.Choice(("linux", "windows")), default=None),
click.option("--folder-scheme", default=None, type=str), click.option("--folder-scheme", default=None, type=str),
click.option("--strip-unicode/--no-strip-unicode", default=None, help="Strip Unicode characters that cause Windows SMB issues (default: enabled)"), click.option(
"--strip-unicode/--no-strip-unicode",
default=None,
help="Strip Unicode characters that cause Windows SMB issues (default: enabled)",
),
click.option("--ignore-user", type=str, multiple=True, default=None), click.option("--ignore-user", type=str, multiple=True, default=None),
click.option("--include-id-file", multiple=True, default=None), click.option("--include-id-file", multiple=True, default=None),
click.option("--log", type=str, default=None), click.option("--log", type=str, default=None),
@@ -54,7 +58,12 @@ _downloader_options = [
click.option("--max-wait-time", type=int, default=None), click.option("--max-wait-time", type=int, default=None),
click.option("--no-dupes", is_flag=True, default=None), click.option("--no-dupes", is_flag=True, default=None),
click.option("--search-existing", is_flag=True, default=None), click.option("--search-existing", is_flag=True, default=None),
click.option("--simple-check", is_flag=True, default=None, help="Enable fast URL-based duplicate checking (works with --no-dupes)"), click.option(
"--simple-check",
is_flag=True,
default=None,
help="Enable fast URL-based duplicate checking (works with --no-dupes)",
),
click.option("--skip", default=None, multiple=True), click.option("--skip", default=None, multiple=True),
click.option("--skip-domain", default=None, multiple=True), click.option("--skip-domain", default=None, multiple=True),
click.option("--skip-subreddit", default=None, multiple=True), click.option("--skip-subreddit", default=None, multiple=True),
+194 -153
View File
@@ -25,12 +25,12 @@ from typing import Any, Dict, List, Optional, Union
import prawcore import prawcore
from bdfr import exceptions as errors
from bdfr.archiver import Archiver
from bdfr.cloner import RedditCloner
from bdfr.configuration import Configuration from bdfr.configuration import Configuration
from bdfr.connector import RedditConnector from bdfr.connector import RedditConnector
from bdfr.downloader import RedditDownloader from bdfr.downloader import RedditDownloader
from bdfr.archiver import Archiver
from bdfr.cloner import RedditCloner
from bdfr import exceptions as errors
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -84,7 +84,9 @@ def retry_reddit_api_call(api_call_func: Callable, max_retries: int = 5, base_wa
raise Exception(f"Reddit API rate limited after {max_retries + 1} attempts: {e}") raise Exception(f"Reddit API rate limited after {max_retries + 1} attempts: {e}")
# Wait before retrying # Wait before retrying
logger.info(f"Reddit API rate limited, waiting {current_wait_time} seconds before retry {attempt + 1}/{max_retries}") logger.info(
f"Reddit API rate limited, waiting {current_wait_time} seconds before retry {attempt + 1}/{max_retries}"
)
time.sleep(current_wait_time) time.sleep(current_wait_time)
# Increase wait time for next attempt (exponential backoff) # Increase wait time for next attempt (exponential backoff)
@@ -93,6 +95,7 @@ def retry_reddit_api_call(api_call_func: Callable, max_retries: int = 5, base_wa
class DownloadType(Enum): class DownloadType(Enum):
"""Types of downloads supported by BDFR""" """Types of downloads supported by BDFR"""
SUBREDDIT = "subreddit" SUBREDDIT = "subreddit"
USER = "user" USER = "user"
MULTIREDDIT = "multireddit" MULTIREDDIT = "multireddit"
@@ -103,6 +106,7 @@ class DownloadType(Enum):
class DownloadStatus(Enum): class DownloadStatus(Enum):
"""Status of a download operation""" """Status of a download operation"""
QUEUED = "queued" QUEUED = "queued"
RUNNING = "running" RUNNING = "running"
COMPLETED = "completed" COMPLETED = "completed"
@@ -121,7 +125,7 @@ class ProgressEvent:
message: str, message: str,
progress: Optional[float] = None, progress: Optional[float] = None,
data: Optional[Dict[str, Any]] = None, data: Optional[Dict[str, Any]] = None,
timestamp: Optional[datetime] = None timestamp: Optional[datetime] = None,
): ):
self.event_type = event_type # "progress", "status", "error", "completed" self.event_type = event_type # "progress", "status", "error", "completed"
self.download_id = download_id self.download_id = download_id
@@ -138,7 +142,7 @@ class ProgressEvent:
"message": self.message, "message": self.message,
"progress": self.progress, "progress": self.progress,
"data": self.data, "data": self.data,
"timestamp": self.timestamp.isoformat() "timestamp": self.timestamp.isoformat(),
} }
@@ -177,8 +181,8 @@ class LoggingCallback(ProgressCallback):
async def on_error(self, event: ProgressEvent): async def on_error(self, event: ProgressEvent):
"""Log error events""" """Log error events"""
self.logger.error(f"[{event.download_id}] ERROR: {event.message}") self.logger.error(f"[{event.download_id}] ERROR: {event.message}")
if event.data.get('exception'): if event.data.get("exception"):
self.logger.exception(f"[{event.download_id}] Exception details", exc_info=event.data['exception']) self.logger.exception(f"[{event.download_id}] Exception details", exc_info=event.data["exception"])
async def on_completed(self, event: ProgressEvent): async def on_completed(self, event: ProgressEvent):
"""Log completion events""" """Log completion events"""
@@ -258,7 +262,7 @@ class BDFRManager:
download_type: DownloadType, download_type: DownloadType,
name: str, name: str,
config: Optional[Configuration] = None, config: Optional[Configuration] = None,
progress_callbacks: Optional[List[ProgressCallback]] = None progress_callbacks: Optional[List[ProgressCallback]] = None,
) -> str: ) -> str:
""" """
Create a new download operation. Create a new download operation.
@@ -326,7 +330,7 @@ class BDFRManager:
"items_processed": 0, "items_processed": 0,
"items_found": 0, "items_found": 0,
"current_item": None, "current_item": None,
"phase": "queued" "phase": "queued",
} }
self.logger.info(f"Created download {download_id} for {download_type.value}: {name}") self.logger.info(f"Created download {download_id} for {download_type.value}: {name}")
@@ -356,11 +360,7 @@ class BDFRManager:
download_info["start_time"] = datetime.now() download_info["start_time"] = datetime.now()
# Create and start download thread # Create and start download thread
download_thread = threading.Thread( download_thread = threading.Thread(target=self._run_download, args=(download_id,), daemon=True)
target=self._run_download,
args=(download_id,),
daemon=True
)
self._download_threads[download_id] = download_thread self._download_threads[download_id] = download_thread
download_thread.start() download_thread.start()
@@ -375,8 +375,12 @@ class BDFRManager:
downloader = None downloader = None
try: try:
self.logger.info(f"[DEBUG] Starting download {download_id} for {download_info['type']}: {download_info['name']}") self.logger.info(
self.logger.info(f"[DEBUG] Config: subreddit={getattr(config, 'subreddit', None)}, user={getattr(config, 'user', None)}, limit={getattr(config, 'limit', None)}") f"[DEBUG] Starting download {download_id} for {download_info['type']}: {download_info['name']}"
)
self.logger.info(
f"[DEBUG] Config: subreddit={getattr(config, 'subreddit', None)}, user={getattr(config, 'user', None)}, limit={getattr(config, 'limit', None)}"
)
# Create progress event for start # Create progress event for start
start_event = ProgressEvent( start_event = ProgressEvent(
@@ -384,11 +388,13 @@ class BDFRManager:
download_id, download_id,
f"Starting {download_info['type']} download: {download_info['name']}", f"Starting {download_info['type']} download: {download_info['name']}",
0.0, 0.0,
{"phase": "starting"} {"phase": "starting"},
) )
# Notify callbacks # Notify callbacks
self.logger.info(f"[BDFR-API] Notifying {len(callbacks)} callbacks of progress event for download {download_id}") self.logger.info(
f"[BDFR-API] Notifying {len(callbacks)} callbacks of progress event for download {download_id}"
)
asyncio.run(self._notify_callbacks(callbacks, "on_progress", start_event)) asyncio.run(self._notify_callbacks(callbacks, "on_progress", start_event))
# Set up authentication token in config if provided # Set up authentication token in config if provided
@@ -411,7 +417,9 @@ class BDFRManager:
log_path.parent.mkdir(parents=True, exist_ok=True) log_path.parent.mkdir(parents=True, exist_ok=True)
# Determine which BDFR class to use based on configuration # Determine which BDFR class to use based on configuration
self.logger.info(f"[DEBUG] Determining downloader type - submitted={getattr(config, 'submitted', False)}, upvoted={getattr(config, 'upvoted', False)}, saved={getattr(config, 'saved', False)}, format={getattr(config, 'format', 'N/A')}") self.logger.info(
f"[DEBUG] Determining downloader type - submitted={getattr(config, 'submitted', False)}, upvoted={getattr(config, 'upvoted', False)}, saved={getattr(config, 'saved', False)}, format={getattr(config, 'format', 'N/A')}"
)
try: try:
if config.submitted or config.upvoted or config.saved: if config.submitted or config.upvoted or config.saved:
@@ -420,7 +428,7 @@ class BDFRManager:
downloader = RedditDownloader(config, []) downloader = RedditDownloader(config, [])
download_info["operation_type"] = "user_download" download_info["operation_type"] = "user_download"
self.logger.info(f"[DEBUG] Using RedditDownloader for user data") self.logger.info(f"[DEBUG] Using RedditDownloader for user data")
elif hasattr(config, 'format') and config.format in ["json", "xml", "yaml"]: elif hasattr(config, "format") and config.format in ["json", "xml", "yaml"]:
# Archive operation # Archive operation
self.logger.info(f"[DEBUG] Creating Archiver for archive operation") self.logger.info(f"[DEBUG] Creating Archiver for archive operation")
downloader = Archiver(config, []) downloader = Archiver(config, [])
@@ -447,7 +455,9 @@ class BDFRManager:
# Mark as completed only if not already failed or cancelled # Mark as completed only if not already failed or cancelled
if download_info.get("status") not in [DownloadStatus.FAILED.value, DownloadStatus.CANCELLED.value]: if download_info.get("status") not in [DownloadStatus.FAILED.value, DownloadStatus.CANCELLED.value]:
self.logger.info(f"[DEBUG] Marking download {download_id} as completed") self.logger.info(f"[DEBUG] Marking download {download_id} as completed")
self.logger.info(f"[DEBUG] Final stats - items_processed: {download_info['items_processed']}, items_found: {download_info['items_found']}") self.logger.info(
f"[DEBUG] Final stats - items_processed: {download_info['items_processed']}, items_found: {download_info['items_found']}"
)
download_info["status"] = DownloadStatus.COMPLETED.value download_info["status"] = DownloadStatus.COMPLETED.value
download_info["progress"] = 100.0 download_info["progress"] = 100.0
download_info["end_time"] = datetime.now() download_info["end_time"] = datetime.now()
@@ -460,18 +470,21 @@ class BDFRManager:
{ {
"items_processed": download_info["items_processed"], "items_processed": download_info["items_processed"],
"items_found": download_info["items_found"], "items_found": download_info["items_found"],
"operation_type": download_info.get("operation_type") "operation_type": download_info.get("operation_type"),
} },
) )
self.logger.info(f"[DEBUG] Sending completion callback for {download_id}") self.logger.info(f"[DEBUG] Sending completion callback for {download_id}")
self.logger.info(f"[BDFR-API] Sending completion event for download {download_id}") self.logger.info(f"[BDFR-API] Sending completion event for download {download_id}")
asyncio.run(self._notify_callbacks(callbacks, "on_completed", completed_event)) asyncio.run(self._notify_callbacks(callbacks, "on_completed", completed_event))
else: else:
self.logger.info(f"[DEBUG] Skipping completion for {download_id} due to status={download_info.get('status')}") self.logger.info(
f"[DEBUG] Skipping completion for {download_id} due to status={download_info.get('status')}"
)
except Exception as e: except Exception as e:
import traceback import traceback
# Get full stack trace for debugging # Get full stack trace for debugging
stack_trace = traceback.format_exc() stack_trace = traceback.format_exc()
@@ -479,6 +492,7 @@ class BDFRManager:
is_rate_limited = False is_rate_limited = False
try: try:
import prawcore import prawcore
TooManyRequests = getattr(prawcore.exceptions, "TooManyRequests", None) TooManyRequests = getattr(prawcore.exceptions, "TooManyRequests", None)
if TooManyRequests is not None and isinstance(e, TooManyRequests): if TooManyRequests is not None and isinstance(e, TooManyRequests):
is_rate_limited = True is_rate_limited = True
@@ -503,7 +517,11 @@ class BDFRManager:
download_id, download_id,
f"Download failed: {str(e)}", f"Download failed: {str(e)}",
None, None,
{"exception": str(e), "stack_trace": stack_trace, "phase": ("rate_limited" if is_rate_limited else "failed")} {
"exception": str(e),
"stack_trace": stack_trace,
"phase": ("rate_limited" if is_rate_limited else "failed"),
},
) )
asyncio.run(self._notify_callbacks(callbacks, "on_error", error_event)) asyncio.run(self._notify_callbacks(callbacks, "on_error", error_event))
@@ -519,17 +537,19 @@ class BDFRManager:
# Close any logging handlers that might be holding file locks # Close any logging handlers that might be holding file locks
logger = logging.getLogger() logger = logging.getLogger()
for handler in logger.handlers[:]: for handler in logger.handlers[:]:
if hasattr(handler, 'close') and hasattr(handler, 'baseFilename'): if hasattr(handler, "close") and hasattr(handler, "baseFilename"):
# Check if this handler is for our download's log file # Check if this handler is for our download's log file
if hasattr(config, 'log') and config.log: if hasattr(config, "log") and config.log:
handler_path = getattr(handler, 'baseFilename', '') handler_path = getattr(handler, "baseFilename", "")
if handler_path == config.log or handler_path.startswith(str(self.download_directory)): if handler_path == config.log or handler_path.startswith(str(self.download_directory)):
try: try:
handler.close() handler.close()
logger.removeHandler(handler) logger.removeHandler(handler)
self.logger.info(f"[DEBUG] Closed logging handler for {handler_path}") self.logger.info(f"[DEBUG] Closed logging handler for {handler_path}")
except Exception as cleanup_error: except Exception as cleanup_error:
self.logger.warning(f"[DEBUG] Error closing handler {handler_path}: {cleanup_error}") self.logger.warning(
f"[DEBUG] Error closing handler {handler_path}: {cleanup_error}"
)
except Exception as cleanup_error: except Exception as cleanup_error:
self.logger.warning(f"[DEBUG] Error during logging cleanup: {cleanup_error}") self.logger.warning(f"[DEBUG] Error during logging cleanup: {cleanup_error}")
@@ -549,17 +569,13 @@ class BDFRManager:
# Update phase to fetching # Update phase to fetching
download_info["phase"] = "fetching_submissions" download_info["phase"] = "fetching_submissions"
progress_event = ProgressEvent( progress_event = ProgressEvent(
"progress", "progress", download_id, "Fetching submissions...", 10.0, {"phase": "fetching_submissions"}
download_id,
"Fetching submissions...",
10.0,
{"phase": "fetching_submissions"}
) )
self.logger.info(f"[BDFR-API] Sending progress update for download {download_id}") self.logger.info(f"[BDFR-API] Sending progress update for download {download_id}")
asyncio.run(self._notify_callbacks(callbacks, "on_progress", progress_event)) asyncio.run(self._notify_callbacks(callbacks, "on_progress", progress_event))
# For RedditDownloader, we need to intercept the download process # For RedditDownloader, we need to intercept the download process
if hasattr(downloader, 'reddit_lists') and hasattr(downloader, '_download_submission'): if hasattr(downloader, "reddit_lists") and hasattr(downloader, "_download_submission"):
processed_submissions = 0 processed_submissions = 0
self.logger.info(f"[DEBUG] RedditDownloader has reddit_lists and _download_submission methods") self.logger.info(f"[DEBUG] RedditDownloader has reddit_lists and _download_submission methods")
@@ -567,7 +583,7 @@ class BDFRManager:
# Get the limit from config - this is our target/goal for items_found # Get the limit from config - this is our target/goal for items_found
# items_found represents "how many items are available to download (up to limit)" # items_found represents "how many items are available to download (up to limit)"
# items_processed represents "how many we've actually processed so far" # items_processed represents "how many we've actually processed so far"
limit = getattr(config, 'limit', 100) limit = getattr(config, "limit", 100)
download_info["items_found"] = limit download_info["items_found"] = limit
self.logger.info(f"[DEBUG] Set items_found (target) to limit: {limit}") self.logger.info(f"[DEBUG] Set items_found (target) to limit: {limit}")
@@ -590,7 +606,9 @@ class BDFRManager:
return return
try: try:
self.logger.info(f"[DEBUG] Processing submission {submission_count}: {submission.id} from r/{submission.subreddit.display_name}") self.logger.info(
f"[DEBUG] Processing submission {submission_count}: {submission.id} from r/{submission.subreddit.display_name}"
)
# Update current item being processed # Update current item being processed
download_info["current_item"] = submission.id download_info["current_item"] = submission.id
@@ -614,8 +632,8 @@ class BDFRManager:
"current_item_type": "submission", "current_item_type": "submission",
"current_subreddit": submission.subreddit.display_name, "current_subreddit": submission.subreddit.display_name,
"items_processed": processed_submissions, "items_processed": processed_submissions,
"items_found": download_info["items_found"] # Keep showing the limit as target "items_found": download_info["items_found"], # Keep showing the limit as target
} },
) )
asyncio.run(self._notify_callbacks(callbacks, "on_progress", progress_event)) asyncio.run(self._notify_callbacks(callbacks, "on_progress", progress_event))
@@ -628,8 +646,12 @@ class BDFRManager:
# Check if this is a rate limiting error that should be retried # Check if this is a rate limiting error that should be retried
error_msg = str(e) error_msg = str(e)
if "429" in error_msg: if "429" in error_msg:
self.logger.warning(f"[DEBUG] Rate limited while processing submission {submission.id}: {e}") self.logger.warning(
self.logger.warning(f"[DEBUG] Will continue with next submission instead of failing entire download") f"[DEBUG] Rate limited while processing submission {submission.id}: {e}"
)
self.logger.warning(
f"[DEBUG] Will continue with next submission instead of failing entire download"
)
continue continue
else: else:
# Not a rate limiting error, re-raise # Not a rate limiting error, re-raise
@@ -640,6 +662,7 @@ class BDFRManager:
except Exception as e: except Exception as e:
import traceback import traceback
error_msg = str(e) error_msg = str(e)
submission_id = submission.id if submission is not None else "unknown" submission_id = submission.id if submission is not None else "unknown"
@@ -647,7 +670,9 @@ class BDFRManager:
stack_trace = traceback.format_exc() stack_trace = traceback.format_exc()
if "429" in error_msg: if "429" in error_msg:
self.logger.error(f"[DEBUG] Rate limited while processing submission {submission_id}: {e}") self.logger.error(
f"[DEBUG] Rate limited while processing submission {submission_id}: {e}"
)
self.logger.error(f"[DEBUG] Stack trace: {stack_trace}") self.logger.error(f"[DEBUG] Stack trace: {stack_trace}")
# Mark download as failed due to rate limiting # Mark download as failed due to rate limiting
download_info["status"] = DownloadStatus.FAILED.value download_info["status"] = DownloadStatus.FAILED.value
@@ -659,7 +684,7 @@ class BDFRManager:
download_id, download_id,
f"Rate limited by Reddit API: {e}", f"Rate limited by Reddit API: {e}",
None, None,
{"exception": str(e), "stack_trace": stack_trace, "phase": "rate_limited"} {"exception": str(e), "stack_trace": stack_trace, "phase": "rate_limited"},
) )
asyncio.run(self._notify_callbacks(callbacks, "on_error", error_event)) asyncio.run(self._notify_callbacks(callbacks, "on_error", error_event))
return return
@@ -675,9 +700,11 @@ class BDFRManager:
if actual_count < limit: if actual_count < limit:
download_info["items_found"] = actual_count download_info["items_found"] = actual_count
self.logger.info(f"[DEBUG] Adjusted items_found from {limit} to actual count {actual_count}") self.logger.info(f"[DEBUG] Adjusted items_found from {limit} to actual count {actual_count}")
self.logger.info(f"[DEBUG] Final progress update - processed {processed_submissions} out of {download_info['items_found']} found") self.logger.info(
f"[DEBUG] Final progress update - processed {processed_submissions} out of {download_info['items_found']} found"
)
progress_event = ProgressEvent( progress_event = ProgressEvent(
"progress", "progress",
download_id, download_id,
@@ -686,26 +713,26 @@ class BDFRManager:
{ {
"phase": "completed", "phase": "completed",
"items_processed": processed_submissions, "items_processed": processed_submissions,
"items_found": download_info["items_found"] "items_found": download_info["items_found"],
} },
) )
asyncio.run(self._notify_callbacks(callbacks, "on_progress", progress_event)) asyncio.run(self._notify_callbacks(callbacks, "on_progress", progress_event))
elif hasattr(downloader, 'reddit_lists') and hasattr(downloader, 'write_entry'): elif hasattr(downloader, "reddit_lists") and hasattr(downloader, "write_entry"):
# For Archiver, we need to intercept the archiving process # For Archiver, we need to intercept the archiving process
processed_items = 0 processed_items = 0
self.logger.info(f"[DEBUG] Archiver detected with reddit_lists and write_entry methods") self.logger.info(f"[DEBUG] Archiver detected with reddit_lists and write_entry methods")
# Get the limit from config # Get the limit from config
limit = getattr(config, 'limit', 100) limit = getattr(config, "limit", 100)
download_info["items_found"] = limit download_info["items_found"] = limit
self.logger.info(f"[DEBUG] Set items_found (target) to limit: {limit}") self.logger.info(f"[DEBUG] Set items_found (target) to limit: {limit}")
# Process items with progress tracking # Process items with progress tracking
self.logger.info(f"[DEBUG] Starting archiving loop...") self.logger.info(f"[DEBUG] Starting archiving loop...")
actual_count = 0 actual_count = 0
for generator in downloader.reddit_lists: for generator in downloader.reddit_lists:
self.logger.info(f"[DEBUG] Processing generator for archiving...") self.logger.info(f"[DEBUG] Processing generator for archiving...")
item_count = 0 item_count = 0
@@ -714,14 +741,15 @@ class BDFRManager:
continue continue
item_count += 1 item_count += 1
actual_count += 1 actual_count += 1
if download_info["status"] == DownloadStatus.CANCELLED.value: if download_info["status"] == DownloadStatus.CANCELLED.value:
self.logger.info(f"Download {download_id} was cancelled") self.logger.info(f"Download {download_id} was cancelled")
return return
try: try:
# Determine item type # Determine item type
import praw.models import praw.models
if isinstance(item, praw.models.Submission): if isinstance(item, praw.models.Submission):
item_id = item.id item_id = item.id
item_type = "submission" item_type = "submission"
@@ -729,23 +757,27 @@ class BDFRManager:
elif isinstance(item, praw.models.Comment): elif isinstance(item, praw.models.Comment):
item_id = item.id item_id = item.id
item_type = "comment" item_type = "comment"
subreddit_name = item.subreddit.display_name if hasattr(item, 'subreddit') else "unknown" subreddit_name = (
item.subreddit.display_name if hasattr(item, "subreddit") else "unknown"
)
else: else:
item_id = str(item) item_id = str(item)
item_type = "item" item_type = "item"
subreddit_name = "unknown" subreddit_name = "unknown"
self.logger.info(f"[DEBUG] Archiving {item_type} {item_count}: {item_id} from r/{subreddit_name}") self.logger.info(
f"[DEBUG] Archiving {item_type} {item_count}: {item_id} from r/{subreddit_name}"
)
# Update current item being processed # Update current item being processed
download_info["current_item"] = item_id download_info["current_item"] = item_id
download_info["current_item_type"] = item_type download_info["current_item_type"] = item_type
download_info["current_subreddit"] = subreddit_name download_info["current_subreddit"] = subreddit_name
# Send progress update for this item # Send progress update for this item
processed_items += 1 processed_items += 1
progress_percent = 20.0 + (processed_items / max(limit, 1)) * 70.0 progress_percent = 20.0 + (processed_items / max(limit, 1)) * 70.0
progress_event = ProgressEvent( progress_event = ProgressEvent(
"progress", "progress",
download_id, download_id,
@@ -757,11 +789,11 @@ class BDFRManager:
"current_item_type": item_type, "current_item_type": item_type,
"current_subreddit": subreddit_name, "current_subreddit": subreddit_name,
"items_processed": processed_items, "items_processed": processed_items,
"items_found": download_info["items_found"] "items_found": download_info["items_found"],
} },
) )
asyncio.run(self._notify_callbacks(callbacks, "on_progress", progress_event)) asyncio.run(self._notify_callbacks(callbacks, "on_progress", progress_event))
# Archive this item with retry mechanism for rate limiting # Archive this item with retry mechanism for rate limiting
self.logger.info(f"[DEBUG] Calling write_entry for {item_id}") self.logger.info(f"[DEBUG] Calling write_entry for {item_id}")
try: try:
@@ -772,34 +804,37 @@ class BDFRManager:
error_msg = str(e) error_msg = str(e)
if "429" in error_msg: if "429" in error_msg:
self.logger.warning(f"[DEBUG] Rate limited while archiving item {item_id}: {e}") self.logger.warning(f"[DEBUG] Rate limited while archiving item {item_id}: {e}")
self.logger.warning(f"[DEBUG] Will continue with next item instead of failing entire download") self.logger.warning(
f"[DEBUG] Will continue with next item instead of failing entire download"
)
continue continue
else: else:
# Not a rate limiting error, re-raise # Not a rate limiting error, re-raise
raise raise
# Update processed count # Update processed count
download_info["items_processed"] = processed_items download_info["items_processed"] = processed_items
except Exception as e: except Exception as e:
import traceback import traceback
error_msg = str(e) error_msg = str(e)
item_id = item_id if 'item_id' in locals() else "unknown" item_id = item_id if "item_id" in locals() else "unknown"
stack_trace = traceback.format_exc() stack_trace = traceback.format_exc()
if "429" in error_msg: if "429" in error_msg:
self.logger.error(f"[DEBUG] Rate limited while archiving item {item_id}: {e}") self.logger.error(f"[DEBUG] Rate limited while archiving item {item_id}: {e}")
self.logger.error(f"[DEBUG] Stack trace: {stack_trace}") self.logger.error(f"[DEBUG] Stack trace: {stack_trace}")
download_info["status"] = DownloadStatus.FAILED.value download_info["status"] = DownloadStatus.FAILED.value
download_info["error"] = f"Rate limited by Reddit API: {e}" download_info["error"] = f"Rate limited by Reddit API: {e}"
download_info["end_time"] = datetime.now() download_info["end_time"] = datetime.now()
error_event = ProgressEvent( error_event = ProgressEvent(
"error", "error",
download_id, download_id,
f"Rate limited by Reddit API: {e}", f"Rate limited by Reddit API: {e}",
None, None,
{"exception": str(e), "stack_trace": stack_trace, "phase": "rate_limited"} {"exception": str(e), "stack_trace": stack_trace, "phase": "rate_limited"},
) )
asyncio.run(self._notify_callbacks(callbacks, "on_error", error_event)) asyncio.run(self._notify_callbacks(callbacks, "on_error", error_event))
return return
@@ -807,16 +842,18 @@ class BDFRManager:
self.logger.error(f"[DEBUG] Error archiving item {item_id}: {e}") self.logger.error(f"[DEBUG] Error archiving item {item_id}: {e}")
self.logger.error(f"[DEBUG] Stack trace: {stack_trace}") self.logger.error(f"[DEBUG] Stack trace: {stack_trace}")
continue continue
self.logger.info(f"[DEBUG] Generator archived {item_count} items") self.logger.info(f"[DEBUG] Generator archived {item_count} items")
# Final progress update # Final progress update
if actual_count < limit: if actual_count < limit:
download_info["items_found"] = actual_count download_info["items_found"] = actual_count
self.logger.info(f"[DEBUG] Adjusted items_found from {limit} to actual count {actual_count}") self.logger.info(f"[DEBUG] Adjusted items_found from {limit} to actual count {actual_count}")
self.logger.info(f"[DEBUG] Final progress update - archived {processed_items} out of {download_info['items_found']} found") self.logger.info(
f"[DEBUG] Final progress update - archived {processed_items} out of {download_info['items_found']} found"
)
progress_event = ProgressEvent( progress_event = ProgressEvent(
"progress", "progress",
download_id, download_id,
@@ -825,26 +862,32 @@ class BDFRManager:
{ {
"phase": "completed", "phase": "completed",
"items_processed": processed_items, "items_processed": processed_items,
"items_found": download_info["items_found"] "items_found": download_info["items_found"],
} },
) )
asyncio.run(self._notify_callbacks(callbacks, "on_progress", progress_event)) asyncio.run(self._notify_callbacks(callbacks, "on_progress", progress_event))
elif hasattr(downloader, 'reddit_lists') and hasattr(downloader, '_download_submission') and hasattr(downloader, 'write_entry'): elif (
hasattr(downloader, "reddit_lists")
and hasattr(downloader, "_download_submission")
and hasattr(downloader, "write_entry")
):
# For RedditCloner (which has both download and archive capabilities) # For RedditCloner (which has both download and archive capabilities)
processed_items = 0 processed_items = 0
self.logger.info(f"[DEBUG] RedditCloner detected with reddit_lists, _download_submission, and write_entry methods") self.logger.info(
f"[DEBUG] RedditCloner detected with reddit_lists, _download_submission, and write_entry methods"
)
# Get the limit from config # Get the limit from config
limit = getattr(config, 'limit', 100) limit = getattr(config, "limit", 100)
download_info["items_found"] = limit download_info["items_found"] = limit
self.logger.info(f"[DEBUG] Set items_found (target) to limit: {limit}") self.logger.info(f"[DEBUG] Set items_found (target) to limit: {limit}")
# Process submissions with progress tracking (both download and archive) # Process submissions with progress tracking (both download and archive)
self.logger.info(f"[DEBUG] Starting cloning loop...") self.logger.info(f"[DEBUG] Starting cloning loop...")
actual_count = 0 actual_count = 0
for generator in downloader.reddit_lists: for generator in downloader.reddit_lists:
self.logger.info(f"[DEBUG] Processing generator for cloning...") self.logger.info(f"[DEBUG] Processing generator for cloning...")
submission_count = 0 submission_count = 0
@@ -853,23 +896,25 @@ class BDFRManager:
continue continue
submission_count += 1 submission_count += 1
actual_count += 1 actual_count += 1
if download_info["status"] == DownloadStatus.CANCELLED.value: if download_info["status"] == DownloadStatus.CANCELLED.value:
self.logger.info(f"Download {download_id} was cancelled") self.logger.info(f"Download {download_id} was cancelled")
return return
try: try:
self.logger.info(f"[DEBUG] Cloning submission {submission_count}: {submission.id} from r/{submission.subreddit.display_name}") self.logger.info(
f"[DEBUG] Cloning submission {submission_count}: {submission.id} from r/{submission.subreddit.display_name}"
)
# Update current item being processed # Update current item being processed
download_info["current_item"] = submission.id download_info["current_item"] = submission.id
download_info["current_item_type"] = "submission" download_info["current_item_type"] = "submission"
download_info["current_subreddit"] = submission.subreddit.display_name download_info["current_subreddit"] = submission.subreddit.display_name
# Send progress update for this submission # Send progress update for this submission
processed_items += 1 processed_items += 1
progress_percent = 20.0 + (processed_items / max(limit, 1)) * 70.0 progress_percent = 20.0 + (processed_items / max(limit, 1)) * 70.0
progress_event = ProgressEvent( progress_event = ProgressEvent(
"progress", "progress",
download_id, download_id,
@@ -881,11 +926,11 @@ class BDFRManager:
"current_item_type": "submission", "current_item_type": "submission",
"current_subreddit": submission.subreddit.display_name, "current_subreddit": submission.subreddit.display_name,
"items_processed": processed_items, "items_processed": processed_items,
"items_found": download_info["items_found"] "items_found": download_info["items_found"],
} },
) )
asyncio.run(self._notify_callbacks(callbacks, "on_progress", progress_event)) asyncio.run(self._notify_callbacks(callbacks, "on_progress", progress_event))
# Clone this submission (download + archive) with retry mechanism for rate limiting # Clone this submission (download + archive) with retry mechanism for rate limiting
self.logger.info(f"[DEBUG] Calling _download_submission for {submission.id}") self.logger.info(f"[DEBUG] Calling _download_submission for {submission.id}")
try: try:
@@ -897,36 +942,43 @@ class BDFRManager:
# Check if this is a rate limiting error that should be retried # Check if this is a rate limiting error that should be retried
error_msg = str(e) error_msg = str(e)
if "429" in error_msg: if "429" in error_msg:
self.logger.warning(f"[DEBUG] Rate limited while cloning submission {submission.id}: {e}") self.logger.warning(
self.logger.warning(f"[DEBUG] Will continue with next submission instead of failing entire download") f"[DEBUG] Rate limited while cloning submission {submission.id}: {e}"
)
self.logger.warning(
f"[DEBUG] Will continue with next submission instead of failing entire download"
)
continue continue
else: else:
# Not a rate limiting error, re-raise # Not a rate limiting error, re-raise
raise raise
# Update processed count # Update processed count
download_info["items_processed"] = processed_items download_info["items_processed"] = processed_items
except Exception as e: except Exception as e:
import traceback import traceback
error_msg = str(e) error_msg = str(e)
submission_id = submission.id if submission is not None else "unknown" submission_id = submission.id if submission is not None else "unknown"
stack_trace = traceback.format_exc() stack_trace = traceback.format_exc()
if "429" in error_msg: if "429" in error_msg:
self.logger.error(f"[DEBUG] Rate limited while cloning submission {submission_id}: {e}") self.logger.error(
f"[DEBUG] Rate limited while cloning submission {submission_id}: {e}"
)
self.logger.error(f"[DEBUG] Stack trace: {stack_trace}") self.logger.error(f"[DEBUG] Stack trace: {stack_trace}")
self.logger.info(f"[DEBUG] Error message contains '429': {error_msg}") self.logger.info(f"[DEBUG] Error message contains '429': {error_msg}")
download_info["status"] = DownloadStatus.FAILED.value download_info["status"] = DownloadStatus.FAILED.value
download_info["error"] = f"Rate limited by Reddit API: {e}" download_info["error"] = f"Rate limited by Reddit API: {e}"
download_info["end_time"] = datetime.now() download_info["end_time"] = datetime.now()
error_event = ProgressEvent( error_event = ProgressEvent(
"error", "error",
download_id, download_id,
f"Rate limited by Reddit API: {e}", f"Rate limited by Reddit API: {e}",
None, None,
{"exception": str(e), "stack_trace": stack_trace, "phase": "rate_limited"} {"exception": str(e), "stack_trace": stack_trace, "phase": "rate_limited"},
) )
asyncio.run(self._notify_callbacks(callbacks, "on_error", error_event)) asyncio.run(self._notify_callbacks(callbacks, "on_error", error_event))
return return
@@ -934,16 +986,18 @@ class BDFRManager:
self.logger.error(f"[DEBUG] Error cloning submission {submission_id}: {e}") self.logger.error(f"[DEBUG] Error cloning submission {submission_id}: {e}")
self.logger.error(f"[DEBUG] Stack trace: {stack_trace}") self.logger.error(f"[DEBUG] Stack trace: {stack_trace}")
continue continue
self.logger.info(f"[DEBUG] Generator cloned {submission_count} submissions") self.logger.info(f"[DEBUG] Generator cloned {submission_count} submissions")
# Final progress update # Final progress update
if actual_count < limit: if actual_count < limit:
download_info["items_found"] = actual_count download_info["items_found"] = actual_count
self.logger.info(f"[DEBUG] Adjusted items_found from {limit} to actual count {actual_count}") self.logger.info(f"[DEBUG] Adjusted items_found from {limit} to actual count {actual_count}")
self.logger.info(f"[DEBUG] Final progress update - cloned {processed_items} out of {download_info['items_found']} found") self.logger.info(
f"[DEBUG] Final progress update - cloned {processed_items} out of {download_info['items_found']} found"
)
progress_event = ProgressEvent( progress_event = ProgressEvent(
"progress", "progress",
download_id, download_id,
@@ -952,11 +1006,11 @@ class BDFRManager:
{ {
"phase": "completed", "phase": "completed",
"items_processed": processed_items, "items_processed": processed_items,
"items_found": download_info["items_found"] "items_found": download_info["items_found"],
} },
) )
asyncio.run(self._notify_callbacks(callbacks, "on_progress", progress_event)) asyncio.run(self._notify_callbacks(callbacks, "on_progress", progress_event))
else: else:
# For other downloaders, just run normally # For other downloaders, just run normally
self.logger.info(f"[DEBUG] Using fallback downloader (no specific handler)") self.logger.info(f"[DEBUG] Using fallback downloader (no specific handler)")
@@ -964,6 +1018,7 @@ class BDFRManager:
except Exception as e: except Exception as e:
import traceback import traceback
stack_trace = traceback.format_exc() stack_trace = traceback.format_exc()
self.logger.error(f"Error in progress download for {download_id}: {e}") self.logger.error(f"Error in progress download for {download_id}: {e}")
self.logger.error(f"[DEBUG] Stack trace: {stack_trace}") self.logger.error(f"[DEBUG] Stack trace: {stack_trace}")
@@ -971,7 +1026,7 @@ class BDFRManager:
raise raise
# Set up authentication if token provided # Set up authentication if token provided
if hasattr(config, 'auth_token') and config.auth_token: if hasattr(config, "auth_token") and config.auth_token:
self.logger.info(f"[DEBUG] Setting up authenticated Reddit instance") self.logger.info(f"[DEBUG] Setting up authenticated Reddit instance")
# Monkey patch the create_reddit_instance method to use our token # Monkey patch the create_reddit_instance method to use our token
original_create_reddit_instance = downloader.create_reddit_instance original_create_reddit_instance = downloader.create_reddit_instance
@@ -985,11 +1040,12 @@ class BDFRManager:
# Override the reddit_instance with authenticated version # Override the reddit_instance with authenticated version
import praw import praw
downloader.reddit_instance = praw.Reddit( downloader.reddit_instance = praw.Reddit(
client_id=downloader.cfg_parser.get("DEFAULT", "client_id"), client_id=downloader.cfg_parser.get("DEFAULT", "client_id"),
client_secret=downloader.cfg_parser.get("DEFAULT", "client_secret"), client_secret=downloader.cfg_parser.get("DEFAULT", "client_secret"),
user_agent=getattr(downloader, 'user_agent', 'BDFR-Web-Interface/1.0'), user_agent=getattr(downloader, "user_agent", "BDFR-Web-Interface/1.0"),
token=auth_token token=auth_token,
) )
downloader.authenticated = True downloader.authenticated = True
self.logger.info(f"[DEBUG] Created authenticated Reddit instance successfully") self.logger.info(f"[DEBUG] Created authenticated Reddit instance successfully")
@@ -1005,10 +1061,14 @@ class BDFRManager:
async def _notify_callbacks(self, callbacks: List[ProgressCallback], method_name: str, event: ProgressEvent): async def _notify_callbacks(self, callbacks: List[ProgressCallback], method_name: str, event: ProgressEvent):
"""Notify all callbacks of an event""" """Notify all callbacks of an event"""
self.logger.info(f"[BDFR-API] Notifying {len(callbacks)} callbacks of {method_name} event for download {event.download_id}") self.logger.info(
f"[BDFR-API] Notifying {len(callbacks)} callbacks of {method_name} event for download {event.download_id}"
)
for callback in callbacks: for callback in callbacks:
try: try:
logger.info(f"[BDFR-API] Invoking {method_name} on callback {callback.__class__.__name__} for download {event.download_id}") logger.info(
f"[BDFR-API] Invoking {method_name} on callback {callback.__class__.__name__} for download {event.download_id}"
)
method = getattr(callback, method_name) method = getattr(callback, method_name)
if asyncio.iscoroutinefunction(method): if asyncio.iscoroutinefunction(method):
await method(event) await method(event)
@@ -1080,10 +1140,7 @@ class BDFRManager:
Returns: Returns:
Dictionary of download information keyed by download ID Dictionary of download information keyed by download ID
""" """
return { return {download_id: self.get_download_status(download_id) for download_id in self._active_downloads}
download_id: self.get_download_status(download_id)
for download_id in self._active_downloads
}
def cleanup_completed(self, max_age_seconds: int = 3600) -> int: def cleanup_completed(self, max_age_seconds: int = 3600) -> int:
""" """
@@ -1099,7 +1156,11 @@ class BDFRManager:
to_remove = [] to_remove = []
for download_id, download_info in self._active_downloads.items(): for download_id, download_info in self._active_downloads.items():
if download_info["status"] in [DownloadStatus.COMPLETED.value, DownloadStatus.FAILED.value, DownloadStatus.CANCELLED.value]: if download_info["status"] in [
DownloadStatus.COMPLETED.value,
DownloadStatus.FAILED.value,
DownloadStatus.CANCELLED.value,
]:
end_time = download_info.get("end_time") end_time = download_info.get("end_time")
if end_time and (current_time - end_time).total_seconds() > max_age_seconds: if end_time and (current_time - end_time).total_seconds() > max_age_seconds:
to_remove.append(download_id) to_remove.append(download_id)
@@ -1124,7 +1185,7 @@ class BDFRManager:
time_filter: str = "all", time_filter: str = "all",
no_dupes: bool = False, no_dupes: bool = False,
simple_check: bool = False, simple_check: bool = False,
progress_callbacks: Optional[List[ProgressCallback]] = None progress_callbacks: Optional[List[ProgressCallback]] = None,
) -> str: ) -> str:
""" """
Download content from a subreddit. Download content from a subreddit.
@@ -1168,12 +1229,7 @@ class BDFRManager:
config.no_dupes = no_dupes config.no_dupes = no_dupes
config.simple_check = simple_check config.simple_check = simple_check
download_id = self.create_download( download_id = self.create_download(DownloadType.SUBREDDIT, subreddit_name, config, progress_callbacks)
DownloadType.SUBREDDIT,
subreddit_name,
config,
progress_callbacks
)
self.start_download(download_id) self.start_download(download_id)
return download_id return download_id
@@ -1187,7 +1243,7 @@ class BDFRManager:
saved: bool = False, saved: bool = False,
no_dupes: bool = False, no_dupes: bool = False,
simple_check: bool = False, simple_check: bool = False,
progress_callbacks: Optional[List[ProgressCallback]] = None progress_callbacks: Optional[List[ProgressCallback]] = None,
) -> str: ) -> str:
""" """
Download content from a user. Download content from a user.
@@ -1234,7 +1290,7 @@ class BDFRManager:
config.saved = saved config.saved = saved
config.no_dupes = no_dupes config.no_dupes = no_dupes
config.simple_check = simple_check config.simple_check = simple_check
# Set authentication if token is available for this manager instance # Set authentication if token is available for this manager instance
if self.auth_token: if self.auth_token:
config.authenticate = True config.authenticate = True
@@ -1244,12 +1300,7 @@ class BDFRManager:
config.authenticate = False config.authenticate = False
logger.info(f"[DEBUG] No authentication token available for user download") logger.info(f"[DEBUG] No authentication token available for user download")
download_id = self.create_download( download_id = self.create_download(DownloadType.USER, username, config, progress_callbacks)
DownloadType.USER,
username,
config,
progress_callbacks
)
self.start_download(download_id) self.start_download(download_id)
return download_id return download_id
@@ -1260,7 +1311,7 @@ class BDFRManager:
format_type: str = "json", format_type: str = "json",
limit: Optional[int] = None, limit: Optional[int] = None,
simple_check: bool = False, simple_check: bool = False,
progress_callbacks: Optional[List[ProgressCallback]] = None progress_callbacks: Optional[List[ProgressCallback]] = None,
) -> str: ) -> str:
""" """
Archive subreddit data (metadata only, no downloads). Archive subreddit data (metadata only, no downloads).
@@ -1298,12 +1349,7 @@ class BDFRManager:
config.limit = limit config.limit = limit
config.format = format_type config.format = format_type
download_id = self.create_download( download_id = self.create_download(DownloadType.ARCHIVE, subreddit_name, config, progress_callbacks)
DownloadType.ARCHIVE,
subreddit_name,
config,
progress_callbacks
)
self.start_download(download_id) self.start_download(download_id)
return download_id return download_id
@@ -1315,7 +1361,7 @@ class BDFRManager:
format_type: str = "json", format_type: str = "json",
no_dupes: bool = False, no_dupes: bool = False,
simple_check: bool = False, simple_check: bool = False,
progress_callbacks: Optional[List[ProgressCallback]] = None progress_callbacks: Optional[List[ProgressCallback]] = None,
) -> str: ) -> str:
""" """
Clone subreddit (both download and archive). Clone subreddit (both download and archive).
@@ -1355,12 +1401,7 @@ class BDFRManager:
config.format = format_type config.format = format_type
config.no_dupes = no_dupes config.no_dupes = no_dupes
download_id = self.create_download( download_id = self.create_download(DownloadType.CLONE, subreddit_name, config, progress_callbacks)
DownloadType.CLONE,
subreddit_name,
config,
progress_callbacks
)
self.start_download(download_id) self.start_download(download_id)
return download_id return download_id
@@ -1394,4 +1435,4 @@ def set_bdfr_manager(manager: BDFRManager):
manager: BDFRManager instance to use as default manager: BDFRManager instance to use as default
""" """
global _default_manager global _default_manager
_default_manager = manager _default_manager = manager
+3 -1
View File
@@ -52,7 +52,9 @@ class Archiver(RedditConnector):
logger.error(f"Submission {submission.id} failed to be archived due to a PRAW exception: {e}") logger.error(f"Submission {submission.id} failed to be archived due to a PRAW exception: {e}")
except prawcore.PrawcoreException as e: except prawcore.PrawcoreException as e:
if submission is not None: if submission is not None:
logger.error(f"The submission after {submission.id} failed to download due to a PRAW exception: {e}") logger.error(
f"The submission after {submission.id} failed to download due to a PRAW exception: {e}"
)
else: else:
logger.error(f"Download failed due to a PRAW exception: {e}") logger.error(f"Download failed due to a PRAW exception: {e}")
logger.debug("Waiting 60 seconds to continue") logger.debug("Waiting 60 seconds to continue")
+3 -1
View File
@@ -30,7 +30,9 @@ class RedditCloner(RedditDownloader, Archiver):
logger.error(f"Submission {submission.id} failed to be cloned due to a PRAW exception: {e}") logger.error(f"Submission {submission.id} failed to be cloned due to a PRAW exception: {e}")
except prawcore.PrawcoreException as e: except prawcore.PrawcoreException as e:
if submission is not None: if submission is not None:
logger.error(f"The submission after {submission.id} failed to download due to a PRAW exception: {e}") logger.error(
f"The submission after {submission.id} failed to download due to a PRAW exception: {e}"
)
else: else:
logger.error(f"Download failed due to a PRAW exception: {e}") logger.error(f"Download failed due to a PRAW exception: {e}")
logger.debug("Waiting 60 seconds to continue") logger.debug("Waiting 60 seconds to continue")
+11 -3
View File
@@ -414,9 +414,13 @@ class RedditConnector(metaclass=ABCMeta):
if TooManyRequests is not None and isinstance(e, TooManyRequests): if TooManyRequests is not None and isinstance(e, TooManyRequests):
is_rate_limited = True is_rate_limited = True
logger.info(f"Rate limited detected: Exception is TooManyRequests for user {user}") logger.info(f"Rate limited detected: Exception is TooManyRequests for user {user}")
elif (hasattr(e, "response") and getattr(e.response, "status_code", None) == 429) or "429" in str(e): elif (hasattr(e, "response") and getattr(e.response, "status_code", None) == 429) or "429" in str(
e
):
is_rate_limited = True is_rate_limited = True
logger.info(f"Rate limited detected: Status code 429 or '429' in error message for user {user}. Error: {e}") logger.info(
f"Rate limited detected: Status code 429 or '429' in error message for user {user}. Error: {e}"
)
if is_rate_limited: if is_rate_limited:
logger.error("Received HTTP 429 (rate limited). Propagating error to fail the download.") logger.error("Received HTTP 429 (rate limited). Propagating error to fail the download.")
@@ -441,7 +445,11 @@ class RedditConnector(metaclass=ABCMeta):
def create_file_name_formatter(self) -> FileNameFormatter: def create_file_name_formatter(self) -> FileNameFormatter:
return FileNameFormatter( return FileNameFormatter(
self.args.file_scheme, self.args.folder_scheme, self.args.time_format, self.args.filename_restriction_scheme, self.args.strip_unicode self.args.file_scheme,
self.args.folder_scheme,
self.args.time_format,
self.args.filename_restriction_scheme,
self.args.strip_unicode,
) )
def create_time_filter(self) -> RedditTypes.TimeType: def create_time_filter(self) -> RedditTypes.TimeType:
+39 -39
View File
@@ -50,11 +50,13 @@ class RedditDownloader(RedditConnector):
hash_data = self._load_hash_list() hash_data = self._load_hash_list()
# Handle both old and new hash file formats # Handle both old and new hash file formats
if isinstance(hash_data, dict) and 'files' in hash_data: if isinstance(hash_data, dict) and "files" in hash_data:
# New format with enhanced structure # New format with enhanced structure
self.master_hash_list = {k: v['path'] for k, v in hash_data['files'].items()} self.master_hash_list = {k: v["path"] for k, v in hash_data["files"].items()}
self.url_list = hash_data.get('urls', {}) self.url_list = hash_data.get("urls", {})
logger.info(f"Loaded {len(self.master_hash_list)} hashes and {len(self.url_list)} URLs from enhanced hash file") logger.info(
f"Loaded {len(self.master_hash_list)} hashes and {len(self.url_list)} URLs from enhanced hash file"
)
else: else:
# Old format - just hashes # Old format - just hashes
self.master_hash_list = hash_data self.master_hash_list = hash_data
@@ -70,8 +72,10 @@ class RedditDownloader(RedditConnector):
if hash_value not in existing_hashes: if hash_value not in existing_hashes:
self.master_hash_list[hash_value] = file_path self.master_hash_list[hash_value] = file_path
logger.info(f"Loaded {len(self.master_hash_list)} total hashes " logger.info(
f"({len(existing_hashes)} from file, {len(all_files_hashes) - len(existing_hashes)} new)") f"Loaded {len(self.master_hash_list)} total hashes "
f"({len(existing_hashes)} from file, {len(all_files_hashes) - len(existing_hashes)} new)"
)
def download(self): def download(self):
for generator in self.reddit_lists: for generator in self.reddit_lists:
@@ -164,7 +168,7 @@ class RedditDownloader(RedditConnector):
self.master_hash_list[existing_file_hash] = destination self.master_hash_list[existing_file_hash] = destination
# Store URL mapping for simple-check functionality if URL is available # Store URL mapping for simple-check functionality if URL is available
if hasattr(res, 'url') and self.args.simple_check: if hasattr(res, "url") and self.args.simple_check:
self.url_list[res.url] = existing_file_hash self.url_list[res.url] = existing_file_hash
logger.debug(f"Added hash for existing file: {existing_file_hash}") logger.debug(f"Added hash for existing file: {existing_file_hash}")
@@ -190,7 +194,7 @@ class RedditDownloader(RedditConnector):
resource_hash = res.hash.hexdigest() resource_hash = res.hash.hexdigest()
# Simple-check: URL-based duplicate detection (fast path) # Simple-check: URL-based duplicate detection (fast path)
if self.args.simple_check and hasattr(res, 'url') and res.url in self.url_list: if self.args.simple_check and hasattr(res, "url") and res.url in self.url_list:
stored_hash = self.url_list[res.url] stored_hash = self.url_list[res.url]
if stored_hash in self.master_hash_list: if stored_hash in self.master_hash_list:
logger.info(f"URL {res.url} from submission {submission.id} already downloaded (simple-check)") logger.info(f"URL {res.url} from submission {submission.id} already downloaded (simple-check)")
@@ -233,7 +237,7 @@ class RedditDownloader(RedditConnector):
self.master_hash_list[resource_hash] = destination self.master_hash_list[resource_hash] = destination
# Store URL mapping for simple-check functionality # Store URL mapping for simple-check functionality
if hasattr(res, 'url') and self.args.simple_check: if hasattr(res, "url") and self.args.simple_check:
self.url_list[res.url] = resource_hash self.url_list[res.url] = resource_hash
logger.debug(f"Hash added to master list: {resource_hash}") logger.debug(f"Hash added to master list: {resource_hash}")
@@ -252,7 +256,7 @@ class RedditDownloader(RedditConnector):
@staticmethod @staticmethod
def scan_existing_files(directory: Path) -> dict[str, Path]: def scan_existing_files(directory: Path) -> dict[str, Path]:
files = [] files = []
for (dirpath, _dirnames, filenames) in os.walk(directory): for dirpath, _dirnames, filenames in os.walk(directory):
files.extend([Path(dirpath, file) for file in filenames]) files.extend([Path(dirpath, file) for file in filenames])
logger.info(f"Calculating hashes for {len(files)} files") logger.info(f"Calculating hashes for {len(files)} files")
@@ -270,14 +274,14 @@ class RedditDownloader(RedditConnector):
def _load_hash_list(self) -> dict[str, Path]: def _load_hash_list(self) -> dict[str, Path]:
"""Load existing hash list from .bdfr_hashes.json in download directory.""" """Load existing hash list from .bdfr_hashes.json in download directory."""
logger.debug(f"Loading hash list from directory: {self.download_directory}") logger.debug(f"Loading hash list from directory: {self.download_directory}")
hash_file_path = self.download_directory / '.bdfr_hashes.json' hash_file_path = self.download_directory / ".bdfr_hashes.json"
if not hash_file_path.exists(): if not hash_file_path.exists():
logger.debug(f"No existing hash file found at {hash_file_path}") logger.debug(f"No existing hash file found at {hash_file_path}")
return {} return {}
try: try:
with open(hash_file_path, 'r', encoding='utf-8') as f: with open(hash_file_path, "r", encoding="utf-8") as f:
hash_data = json.load(f) hash_data = json.load(f)
if not isinstance(hash_data, dict): if not isinstance(hash_data, dict):
@@ -285,16 +289,16 @@ class RedditDownloader(RedditConnector):
return {} return {}
# Handle new enhanced format # Handle new enhanced format
if 'files' in hash_data and isinstance(hash_data['files'], dict): if "files" in hash_data and isinstance(hash_data["files"], dict):
# New format with enhanced structure # New format with enhanced structure
files_data = hash_data['files'] files_data = hash_data["files"]
loaded_hashes = {} loaded_hashes = {}
urls_data = hash_data.get('urls', {}) urls_data = hash_data.get("urls", {})
for hash_value, file_info in files_data.items(): for hash_value, file_info in files_data.items():
if isinstance(file_info, dict) and 'path' in file_info: if isinstance(file_info, dict) and "path" in file_info:
# New format: {"hash": {"path": "relative/path", "url": "http://..."}} # New format: {"hash": {"path": "relative/path", "url": "http://..."}}
relative_path = file_info['path'] relative_path = file_info["path"]
absolute_path = self.download_directory / relative_path absolute_path = self.download_directory / relative_path
if absolute_path.exists(): if absolute_path.exists():
loaded_hashes[hash_value] = absolute_path loaded_hashes[hash_value] = absolute_path
@@ -302,8 +306,8 @@ class RedditDownloader(RedditConnector):
logger.debug(f"File {absolute_path} from hash file no longer exists") logger.debug(f"File {absolute_path} from hash file no longer exists")
# Load URL mapping for simple-check # Load URL mapping for simple-check
if 'url' in file_info and file_info['url']: if "url" in file_info and file_info["url"]:
self.url_list[file_info['url']] = hash_value self.url_list[file_info["url"]] = hash_value
elif isinstance(file_info, str): elif isinstance(file_info, str):
# Legacy format within new structure: {"hash": "relative/path"} # Legacy format within new structure: {"hash": "relative/path"}
absolute_path = self.download_directory / file_info absolute_path = self.download_directory / file_info
@@ -337,30 +341,30 @@ class RedditDownloader(RedditConnector):
def _save_hash_list(self) -> None: def _save_hash_list(self) -> None:
"""Save current hash list to .bdfr_hashes.json in download directory using atomic write.""" """Save current hash list to .bdfr_hashes.json in download directory using atomic write."""
hash_file_path = self.download_directory / '.bdfr_hashes.json' hash_file_path = self.download_directory / ".bdfr_hashes.json"
# Build enhanced data structure for new format # Build enhanced data structure for new format
if self.args.simple_check: if self.args.simple_check:
# New enhanced format with URLs and metadata # New enhanced format with URLs and metadata
hash_data = { hash_data = {
'files': {}, "files": {},
'urls': self.url_list.copy(), "urls": self.url_list.copy(),
'metadata': { "metadata": {
'version': '2.0', "version": "2.0",
'created_with': 'simple_check' if self.args.simple_check else 'standard', "created_with": "simple_check" if self.args.simple_check else "standard",
'url_count': len(self.url_list), "url_count": len(self.url_list),
'hash_count': len(self.master_hash_list) "hash_count": len(self.master_hash_list),
} },
} }
# Convert absolute paths to relative paths for portability # Convert absolute paths to relative paths for portability
for hash_value, absolute_path in self.master_hash_list.items(): for hash_value, absolute_path in self.master_hash_list.items():
try: try:
relative_path = absolute_path.relative_to(self.download_directory) relative_path = absolute_path.relative_to(self.download_directory)
hash_data['files'][hash_value] = { hash_data["files"][hash_value] = {
'path': str(relative_path), "path": str(relative_path),
'url': next((url for url, h in self.url_list.items() if h == hash_value), None), "url": next((url for url, h in self.url_list.items() if h == hash_value), None),
'check_method': 'hash' "check_method": "hash",
} }
except ValueError: except ValueError:
# File is not relative to download directory, skip it # File is not relative to download directory, skip it
@@ -381,17 +385,13 @@ class RedditDownloader(RedditConnector):
# Atomic write: write to temporary file first, then rename # Atomic write: write to temporary file first, then rename
try: try:
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(
mode='w', mode="w", dir=self.download_directory, suffix=".tmp", delete=False, encoding="utf-8"
dir=self.download_directory,
suffix='.tmp',
delete=False,
encoding='utf-8'
) as temp_file: ) as temp_file:
json.dump(hash_data, temp_file, indent=2) json.dump(hash_data, temp_file, indent=2)
temp_file_path = temp_file.name temp_file_path = temp_file.name
# Atomic rename # Atomic rename
if os.name == 'nt': # Windows if os.name == "nt": # Windows
# On Windows, we need to remove the target file first if it exists # On Windows, we need to remove the target file first if it exists
if hash_file_path.exists(): if hash_file_path.exists():
hash_file_path.unlink() hash_file_path.unlink()
@@ -407,7 +407,7 @@ class RedditDownloader(RedditConnector):
logger.error(f"Unexpected error saving hash file {hash_file_path}: {e}") logger.error(f"Unexpected error saving hash file {hash_file_path}: {e}")
# Clean up temp file if it still exists # Clean up temp file if it still exists
try: try:
if 'temp_file_path' in locals(): if "temp_file_path" in locals():
os.unlink(temp_file_path) os.unlink(temp_file_path)
except (OSError, IOError): except (OSError, IOError):
pass pass
+24 -50
View File
@@ -19,20 +19,16 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
from bdfr.api import ( from bdfr.api import (
BDFRManager, BDFRManager,
DownloadType,
DownloadStatus, DownloadStatus,
ProgressEvent, DownloadType,
ProgressCallback,
LoggingCallback, LoggingCallback,
get_bdfr_manager ProgressCallback,
ProgressEvent,
get_bdfr_manager,
) )
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(level=logging.INFO, format="[%(asctime)s] %(levelname)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
level=logging.INFO,
format='[%(asctime)s] %(levelname)s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -53,7 +49,7 @@ class WebSocketCallback(ProgressCallback):
async def on_error(self, event: ProgressEvent): async def on_error(self, event: ProgressEvent):
"""Send error update to WebSocket""" """Send error update to WebSocket"""
print(f"❌ [{self.websocket_id}] ERROR: {event.message}") print(f"❌ [{self.websocket_id}] ERROR: {event.message}")
if event.data.get('exception'): if event.data.get("exception"):
print(f" Exception: {event.data['exception']}") print(f" Exception: {event.data['exception']}")
async def on_completed(self, event: ProgressEvent): async def on_completed(self, event: ProgressEvent):
@@ -95,9 +91,9 @@ async def example_basic_usage():
# Create a download for a subreddit # Create a download for a subreddit
download_id = manager.download_subreddit( download_id = manager.download_subreddit(
"python", # subreddit name "python", # subreddit name
limit=10, # download 10 posts limit=10, # download 10 posts
sort="hot", # sort by hot sort="hot", # sort by hot
no_dupes=True # avoid duplicates no_dupes=True, # avoid duplicates
) )
print(f"📋 Created download with ID: {download_id}") print(f"📋 Created download with ID: {download_id}")
@@ -111,7 +107,7 @@ async def example_basic_usage():
print(f"📊 Status: {status['status']} | Progress: {int(round(status['progress']))}%") print(f"📊 Status: {status['status']} | Progress: {int(round(status['progress']))}%")
if status['status'] in ['completed', 'failed', 'cancelled']: if status["status"] in ["completed", "failed", "cancelled"]:
print(f"🏁 Download finished with status: {status['status']}") print(f"🏁 Download finished with status: {status['status']}")
break break
@@ -126,11 +122,7 @@ async def example_advanced_usage():
print("=" * 50) print("=" * 50)
# Create custom callbacks # Create custom callbacks
callbacks = [ callbacks = [LoggingCallback("web_interface"), WebSocketCallback("user_123"), DatabaseCallback()]
LoggingCallback("web_interface"),
WebSocketCallback("user_123"),
DatabaseCallback()
]
# Create manager with custom download directory # Create manager with custom download directory
manager = BDFRManager("./custom_downloads") manager = BDFRManager("./custom_downloads")
@@ -140,11 +132,7 @@ async def example_advanced_usage():
download_ids = [] download_ids = []
for subreddit in subreddits: for subreddit in subreddits:
download_id = manager.create_download( download_id = manager.create_download(DownloadType.SUBREDDIT, subreddit, progress_callbacks=callbacks)
DownloadType.SUBREDDIT,
subreddit,
progress_callbacks=callbacks
)
# Start the download # Start the download
manager.start_download(download_id) manager.start_download(download_id)
@@ -164,7 +152,7 @@ async def example_advanced_usage():
print(f"📊 {download_id}: {status['status']} ({int(round(status['progress']))}%)") print(f"📊 {download_id}: {status['status']} ({int(round(status['progress']))}%)")
if status['status'] in ['completed', 'failed', 'cancelled']: if status["status"] in ["completed", "failed", "cancelled"]:
print(f"🏁 Download {download_id} finished") print(f"🏁 Download {download_id} finished")
download_ids.remove(download_id) download_ids.remove(download_id)
else: else:
@@ -187,11 +175,7 @@ async def example_user_download():
# Download user's submitted posts # Download user's submitted posts
download_id = manager.download_user( download_id = manager.download_user(
"testuser", # username "testuser", limit=25, submitted=True, upvoted=False, saved=False # username # 25 posts
limit=25, # 25 posts
submitted=True,
upvoted=False,
saved=False
) )
print(f"📋 Created user download: {download_id}") print(f"📋 Created user download: {download_id}")
@@ -205,7 +189,7 @@ async def example_user_download():
print(f"📊 Status: {status['status']} | Progress: {int(round(status['progress']))}%") print(f"📊 Status: {status['status']} | Progress: {int(round(status['progress']))}%")
if status['status'] in ['completed', 'failed']: if status["status"] in ["completed", "failed"]:
break break
await asyncio.sleep(2) await asyncio.sleep(2)
@@ -221,11 +205,7 @@ async def example_archive_operation():
manager = BDFRManager("./archives") manager = BDFRManager("./archives")
# Archive subreddit data (metadata only) # Archive subreddit data (metadata only)
download_id = manager.archive_subreddit( download_id = manager.archive_subreddit("dataisbeautiful", format_type="json", limit=50)
"dataisbeautiful",
format_type="json",
limit=50
)
print(f"📋 Created archive operation: {download_id}") print(f"📋 Created archive operation: {download_id}")
@@ -238,7 +218,7 @@ async def example_archive_operation():
print(f"📊 Archive status: {status['status']} | Progress: {int(round(status['progress']))}%") print(f"📊 Archive status: {status['status']} | Progress: {int(round(status['progress']))}%")
if status['status'] in ['completed', 'failed']: if status["status"] in ["completed", "failed"]:
print(f"🏁 Archive finished with status: {status['status']}") print(f"🏁 Archive finished with status: {status['status']}")
break break
@@ -265,11 +245,7 @@ async def example_web_integration():
callback = WebSocketCallback(f"ws_{user_id}") callback = WebSocketCallback(f"ws_{user_id}")
# Create and start download # Create and start download
download_id = self.manager.download_subreddit( download_id = self.manager.download_subreddit(subreddit, limit=limit, progress_callbacks=[callback])
subreddit,
limit=limit,
progress_callbacks=[callback]
)
# Track for this user session # Track for this user session
if user_id not in self.active_sessions: if user_id not in self.active_sessions:
@@ -279,7 +255,7 @@ async def example_web_integration():
return { return {
"success": True, "success": True,
"download_id": download_id, "download_id": download_id,
"message": f"Started download of r/{subreddit} (limit: {limit})" "message": f"Started download of r/{subreddit} (limit: {limit})",
} }
async def get_user_downloads(self, user_id: str): async def get_user_downloads(self, user_id: str):
@@ -329,7 +305,7 @@ async def example_web_integration():
# Cancel one download # Cancel one download
if user_downloads: if user_downloads:
cancel_result = await app.cancel_user_download(user_id, user_downloads[0]['id']) cancel_result = await app.cancel_user_download(user_id, user_downloads[0]["id"])
print(f"Cancel result: {cancel_result}") print(f"Cancel result: {cancel_result}")
return len(user_downloads) return len(user_downloads)
@@ -343,10 +319,7 @@ async def example_error_handling():
manager = BDFRManager("./test_downloads") manager = BDFRManager("./test_downloads")
# Try to download from a non-existent subreddit # Try to download from a non-existent subreddit
download_id = manager.download_subreddit( download_id = manager.download_subreddit("this_subreddit_does_not_exist", limit=5)
"this_subreddit_does_not_exist",
limit=5
)
print(f"📋 Created download for non-existent subreddit: {download_id}") print(f"📋 Created download for non-existent subreddit: {download_id}")
@@ -359,7 +332,7 @@ async def example_error_handling():
print(f"📊 Status: {status['status']}") print(f"📊 Status: {status['status']}")
if status['status'] == 'failed': if status["status"] == "failed":
print(f"🏁 Download failed as expected: {status.get('error', 'Unknown error')}") print(f"🏁 Download failed as expected: {status.get('error', 'Unknown error')}")
break break
@@ -401,9 +374,10 @@ async def main():
except Exception as e: except Exception as e:
print(f"\n❌ Error running examples: {e}") print(f"\n❌ Error running examples: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
if __name__ == "__main__": if __name__ == "__main__":
# Run the examples # Run the examples
asyncio.run(main()) asyncio.run(main())
+19 -3
View File
@@ -245,10 +245,26 @@ class FileNameFormatter:
if ord(char) < 0x80: if ord(char) < 0x80:
result.append(char) result.append(char)
# Keep common Unicode letters, numbers, and punctuation # Keep common Unicode letters, numbers, and punctuation
elif unicodedata.category(char) in ['Lu', 'Ll', 'Lt', 'Lm', 'Lo', 'Nd', 'Nl', 'No', 'Pc', 'Pd', 'Ps', 'Pe', 'Pi', 'Pf', 'Po']: elif unicodedata.category(char) in [
"Lu",
"Ll",
"Lt",
"Lm",
"Lo",
"Nd",
"Nl",
"No",
"Pc",
"Pd",
"Ps",
"Pe",
"Pi",
"Pf",
"Po",
]:
result.append(char) result.append(char)
# Strip emoji, symbols, and other special characters that cause 8.3 names # Strip emoji, symbols, and other special characters that cause 8.3 names
elif unicodedata.category(char).startswith(('S', 'So', 'Sk', 'Sm')): # Symbols elif unicodedata.category(char).startswith(("S", "So", "Sk", "Sm")): # Symbols
continue continue
elif ord(char) > 0x1F000: # High Unicode ranges often contain emoji elif ord(char) > 0x1F000: # High Unicode ranges often contain emoji
continue continue
@@ -256,7 +272,7 @@ class FileNameFormatter:
# Keep other Unicode characters that are generally safe # Keep other Unicode characters that are generally safe
result.append(char) result.append(char)
return ''.join(result) return "".join(result)
@staticmethod @staticmethod
def _strip_emojis(input_string: str) -> str: def _strip_emojis(input_string: str) -> str:
+22 -20
View File
@@ -24,11 +24,11 @@ class Resource:
self.content: Optional[bytes] = None self.content: Optional[bytes] = None
self.url = url self.url = url
self.hash: Optional[_hashlib.HASH] = None self.hash: Optional[_hashlib.HASH] = None
# Log the original extension before normalization # Log the original extension before normalization
if extension: if extension:
logger.debug(f"Resource constructor received extension: '{extension}' for URL: {url}") logger.debug(f"Resource constructor received extension: '{extension}' for URL: {url}")
self.extension = self._normalize_extension(extension) self.extension = self._normalize_extension(extension)
self.download_function = download_function self.download_function = download_function
if not self.extension: if not self.extension:
@@ -72,14 +72,14 @@ class Resource:
if self.url.startswith("https://www.reddit.com/media"): if self.url.startswith("https://www.reddit.com/media"):
logger.debug(f"Detected Reddit media URL: {self.url}") logger.debug(f"Detected Reddit media URL: {self.url}")
parsed_url = urllib.parse.urlparse(self.url) parsed_url = urllib.parse.urlparse(self.url)
url_param = urllib.parse.parse_qs(parsed_url.query).get('url', [None])[0] url_param = urllib.parse.parse_qs(parsed_url.query).get("url", [None])[0]
if url_param: if url_param:
decoded_url = urllib.parse.unquote(url_param) decoded_url = urllib.parse.unquote(url_param)
logger.debug(f"Reddit media URL decoded to: {decoded_url}") logger.debug(f"Reddit media URL decoded to: {decoded_url}")
stripped_url = urllib.parse.urlsplit(decoded_url).path stripped_url = urllib.parse.urlsplit(decoded_url).path
# Also handle preview.redd.it URLs which might not have extensions # Also handle preview.redd.it URLs which might not have extensions
elif "preview.redd.it" in self.url and not stripped_url.endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')): elif "preview.redd.it" in self.url and not stripped_url.endswith((".jpg", ".jpeg", ".png", ".gif", ".webp")):
logger.debug(f"Detected preview.redd.it URL without extension: {self.url}") logger.debug(f"Detected preview.redd.it URL without extension: {self.url}")
# For preview URLs, try to infer from common patterns or add fallback logic # For preview URLs, try to infer from common patterns or add fallback logic
@@ -92,7 +92,7 @@ class Resource:
logger.warning(f"Could not determine extension for URL: {self.url} (path: {stripped_url})") logger.warning(f"Could not determine extension for URL: {self.url} (path: {stripped_url})")
# As a last resort, if we have content, try to detect by magic numbers # As a last resort, if we have content, try to detect by magic numbers
if hasattr(self, 'content') and self.content: if hasattr(self, "content") and self.content:
detected = self._detect_extension_by_content() detected = self._detect_extension_by_content()
return self._normalize_extension(detected) if detected else None return self._normalize_extension(detected) if detected else None
@@ -104,21 +104,21 @@ class Resource:
return None return None
# Check for common image formats # Check for common image formats
if self.content.startswith(b'\xFF\xD8\xFF'): if self.content.startswith(b"\xff\xd8\xff"):
logger.debug(f"Detected JPEG by magic number for URL: {self.url}") logger.debug(f"Detected JPEG by magic number for URL: {self.url}")
return '.jpg' return ".jpg"
elif self.content.startswith(b'\x89PNG\r\n\x1a\n'): elif self.content.startswith(b"\x89PNG\r\n\x1a\n"):
logger.debug(f"Detected PNG by magic number for URL: {self.url}") logger.debug(f"Detected PNG by magic number for URL: {self.url}")
return '.png' return ".png"
elif self.content.startswith(b'GIF87a') or self.content.startswith(b'GIF89a'): elif self.content.startswith(b"GIF87a") or self.content.startswith(b"GIF89a"):
logger.debug(f"Detected GIF by magic number for URL: {self.url}") logger.debug(f"Detected GIF by magic number for URL: {self.url}")
return '.gif' return ".gif"
elif self.content.startswith(b'RIFF') and self.content[8:12] == b'WEBP': elif self.content.startswith(b"RIFF") and self.content[8:12] == b"WEBP":
logger.debug(f"Detected WebP by magic number for URL: {self.url}") logger.debug(f"Detected WebP by magic number for URL: {self.url}")
return '.webp' return ".webp"
elif self.content.startswith(b'BM'): elif self.content.startswith(b"BM"):
logger.debug(f"Detected BMP by magic number for URL: {self.url}") logger.debug(f"Detected BMP by magic number for URL: {self.url}")
return '.bmp' return ".bmp"
logger.debug(f"Could not detect file type by magic number for URL: {self.url}") logger.debug(f"Could not detect file type by magic number for URL: {self.url}")
return None return None
@@ -127,15 +127,17 @@ class Resource:
"""Normalize extension to lowercase for consistency""" """Normalize extension to lowercase for consistency"""
if not extension: if not extension:
return None return None
original = extension original = extension
# Ensure extension starts with a dot # Ensure extension starts with a dot
if not extension.startswith('.'): if not extension.startswith("."):
extension = '.' + extension extension = "." + extension
normalized = extension.lower() normalized = extension.lower()
if original != normalized: if original != normalized:
logger.info(f"Extension normalization: '{original}' -> '{normalized}' for URL: {self.url if hasattr(self, 'url') else 'unknown'}") logger.info(
f"Extension normalization: '{original}' -> '{normalized}' for URL: {self.url if hasattr(self, 'url') else 'unknown'}"
)
return normalized return normalized
@staticmethod @staticmethod
@@ -25,6 +25,7 @@ class DownloadFactory:
@staticmethod @staticmethod
def pull_lever(url: str) -> type[BaseDownloader]: def pull_lever(url: str) -> type[BaseDownloader]:
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
sanitised_url = DownloadFactory.sanitise_url(url).lower() sanitised_url = DownloadFactory.sanitise_url(url).lower()
+7 -5
View File
@@ -4,14 +4,15 @@ Test script to verify that the duplicate folder creation fix works correctly.
This script simulates the scenario where duplicate posts would previously create empty folders. This script simulates the scenario where duplicate posts would previously create empty folders.
""" """
import tempfile
import shutil import shutil
from pathlib import Path
from unittest.mock import MagicMock
# Add the bdfr module to the path # Add the bdfr module to the path
import sys import sys
sys.path.insert(0, '.') import tempfile
from pathlib import Path
from unittest.mock import MagicMock
sys.path.insert(0, ".")
from bdfr.configuration import Configuration from bdfr.configuration import Configuration
from bdfr.connector import RedditConnector from bdfr.connector import RedditConnector
@@ -69,6 +70,7 @@ def test_duplicate_folder_creation_fix():
# Mock the download factory # Mock the download factory
import bdfr.site_downloaders.download_factory as df import bdfr.site_downloaders.download_factory as df
original_pull_lever = df.DownloadFactory.pull_lever original_pull_lever = df.DownloadFactory.pull_lever
df.DownloadFactory.pull_lever = MagicMock(return_value=mock_downloader_class) df.DownloadFactory.pull_lever = MagicMock(return_value=mock_downloader_class)
@@ -93,4 +95,4 @@ def test_duplicate_folder_creation_fix():
if __name__ == "__main__": if __name__ == "__main__":
test_duplicate_folder_creation_fix() test_duplicate_folder_creation_fix()
print("All tests passed! The duplicate folder creation fix is working correctly.") print("All tests passed! The duplicate folder creation fix is working correctly.")
+11 -5
View File
@@ -3,8 +3,10 @@
Test extension case normalization functionality Test extension case normalization functionality
""" """
import pytest
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest
from bdfr.resource import Resource from bdfr.resource import Resource
@@ -43,7 +45,9 @@ class TestExtensionNormalization:
mock_submission.id = "test123" mock_submission.id = "test123"
resource = Resource(mock_submission, url, lambda: None) resource = Resource(mock_submission, url, lambda: None)
assert resource.extension == expected, f"Reddit media URL {url} should normalize to {expected}, got {resource.extension}" assert (
resource.extension == expected
), f"Reddit media URL {url} should normalize to {expected}, got {resource.extension}"
def test_constructor_extensions_normalized(self): def test_constructor_extensions_normalized(self):
"""Test that extensions passed to constructor are normalized""" """Test that extensions passed to constructor are normalized"""
@@ -66,7 +70,9 @@ class TestExtensionNormalization:
mock_submission.id = "test123" mock_submission.id = "test123"
resource = Resource(mock_submission, "https://example.com/test", lambda: None, input_ext) resource = Resource(mock_submission, "https://example.com/test", lambda: None, input_ext)
assert resource.extension == expected, f"Constructor extension {input_ext} should normalize to {expected}, got {resource.extension}" assert (
resource.extension == expected
), f"Constructor extension {input_ext} should normalize to {expected}, got {resource.extension}"
def test_magic_number_detection_normalized(self): def test_magic_number_detection_normalized(self):
"""Test that magic number detection returns normalized extensions""" """Test that magic number detection returns normalized extensions"""
@@ -74,7 +80,7 @@ class TestExtensionNormalization:
mock_submission.id = "test123" mock_submission.id = "test123"
# Test JPEG magic number detection # Test JPEG magic number detection
jpeg_content = b'\xFF\xD8\xFF' + b'0' * 100 # JPEG magic number jpeg_content = b"\xff\xd8\xff" + b"0" * 100 # JPEG magic number
resource = Resource(mock_submission, "https://example.com/no-extension", lambda params: jpeg_content) resource = Resource(mock_submission, "https://example.com/no-extension", lambda params: jpeg_content)
resource.download() # Trigger content-based detection resource.download() # Trigger content-based detection
assert resource.extension == ".jpg", f"Magic number detection should return .jpg, got {resource.extension}" assert resource.extension == ".jpg", f"Magic number detection should return .jpg, got {resource.extension}"
+6 -8
View File
@@ -3,13 +3,16 @@
Test script to debug file extension detection issues Test script to debug file extension detection issues
""" """
import sys
import os import os
import sys
sys.path.insert(0, os.path.dirname(__file__)) sys.path.insert(0, os.path.dirname(__file__))
from unittest.mock import MagicMock from unittest.mock import MagicMock
from bdfr.resource import Resource from bdfr.resource import Resource
def test_extension_detection(): def test_extension_detection():
"""Test extension detection with various URL patterns""" """Test extension detection with various URL patterns"""
@@ -18,26 +21,20 @@ def test_extension_detection():
("https://example.com/image.jpg", ".jpg"), ("https://example.com/image.jpg", ".jpg"),
("https://example.com/video.mp4", ".mp4"), ("https://example.com/video.mp4", ".mp4"),
("https://files.example.com/document.pdf", ".pdf"), ("https://files.example.com/document.pdf", ".pdf"),
# URLs without extensions # URLs without extensions
("https://example.com/api/data", None), ("https://example.com/api/data", None),
("https://example.com/path/without/extension", None), ("https://example.com/path/without/extension", None),
# URLs with query parameters # URLs with query parameters
("https://example.com/image.jpg?size=large", ".jpg"), ("https://example.com/image.jpg?size=large", ".jpg"),
("https://example.com/video.mp4?utm_source=test", ".mp4"), ("https://example.com/video.mp4?utm_source=test", ".mp4"),
# URLs with fragments # URLs with fragments
("https://example.com/image.png#section", ".png"), ("https://example.com/image.png#section", ".png"),
# Complex paths # Complex paths
("https://imgur.com/a/gallery123", None), ("https://imgur.com/a/gallery123", None),
("https://reddit.com/r/test/abc123_def456_789", None), ("https://reddit.com/r/test/abc123_def456_789", None),
# Edge cases that might cause weird names # Edge cases that might cause weird names
("https://example.com/L7SW9E~G", None), ("https://example.com/L7SW9E~G", None),
("https://example.com/temp/file", None), ("https://example.com/temp/file", None),
# Reddit media URLs (the actual issue) # Reddit media URLs (the actual issue)
("https://www.reddit.com/media?url=https%3A%2F%2Fi.redd.it%2Fr2mv10i4vkfd1.jpeg", ".jpeg"), ("https://www.reddit.com/media?url=https%3A%2F%2Fi.redd.it%2Fr2mv10i4vkfd1.jpeg", ".jpeg"),
("https://i.redd.it/r2mv10i4vkfd1.jpeg", ".jpeg"), ("https://i.redd.it/r2mv10i4vkfd1.jpeg", ".jpeg"),
@@ -60,5 +57,6 @@ def test_extension_detection():
print(f"Match: {'YES' if resource.extension == expected else 'NO'}") print(f"Match: {'YES' if resource.extension == expected else 'NO'}")
print("-" * 40) print("-" * 40)
if __name__ == "__main__": if __name__ == "__main__":
test_extension_detection() test_extension_detection()
+12 -9
View File
@@ -7,16 +7,18 @@ This script simulates the scenario where a download fails and then tries to redo
import asyncio import asyncio
import logging import logging
import os import os
# Add the bdfr module to the path
import sys
import tempfile import tempfile
import time import time
from pathlib import Path from pathlib import Path
# Add the bdfr module to the path
import sys
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
from bdfr.api import BDFRManager, DownloadType, LoggingCallback, ProgressEvent from bdfr.api import BDFRManager, DownloadType, LoggingCallback, ProgressEvent
class TestProgressCallback(LoggingCallback): class TestProgressCallback(LoggingCallback):
"""Test callback that simulates a failure""" """Test callback that simulates a failure"""
@@ -36,6 +38,7 @@ class TestProgressCallback(LoggingCallback):
self.events.append(event) self.events.append(event)
await super().on_completed(event) await super().on_completed(event)
def test_file_locking_fix(): def test_file_locking_fix():
"""Test that the file locking issue is resolved""" """Test that the file locking issue is resolved"""
print("Testing file locking fix...") print("Testing file locking fix...")
@@ -53,7 +56,7 @@ def test_file_locking_fix():
download_id1 = manager.create_download( download_id1 = manager.create_download(
DownloadType.USER, DownloadType.USER,
"test_user_12345", # This user doesn't exist, should fail "test_user_12345", # This user doesn't exist, should fail
progress_callbacks=[TestProgressCallback()] progress_callbacks=[TestProgressCallback()],
) )
# Start the download (it should fail) # Start the download (it should fail)
@@ -71,7 +74,7 @@ def test_file_locking_fix():
download_id2 = manager.create_download( download_id2 = manager.create_download(
DownloadType.USER, DownloadType.USER,
"test_user_67890", # This user also doesn't exist, should fail "test_user_67890", # This user also doesn't exist, should fail
progress_callbacks=[TestProgressCallback()] progress_callbacks=[TestProgressCallback()],
) )
# Start the second download # Start the second download
@@ -100,7 +103,7 @@ def test_file_locking_fix():
print(f" - {log_file.name}") print(f" - {log_file.name}")
# Check if file is accessible (not locked) # Check if file is accessible (not locked)
try: try:
with open(log_file, 'r') as f: with open(log_file, "r") as f:
content = f.read() content = f.read()
print(f" SUCCESS: Log file is accessible ({len(content)} characters)") print(f" SUCCESS: Log file is accessible ({len(content)} characters)")
except PermissionError: except PermissionError:
@@ -112,9 +115,7 @@ def test_file_locking_fix():
# Test 4: Try to create a third download to ensure cleanup worked # Test 4: Try to create a third download to ensure cleanup worked
print("\n4. Creating third download to verify cleanup...") print("\n4. Creating third download to verify cleanup...")
download_id3 = manager.create_download( download_id3 = manager.create_download(
DownloadType.USER, DownloadType.USER, "test_user_cleanup", progress_callbacks=[TestProgressCallback()]
"test_user_cleanup",
progress_callbacks=[TestProgressCallback()]
) )
success3 = manager.start_download(download_id3) success3 = manager.start_download(download_id3)
@@ -132,6 +133,7 @@ def test_file_locking_fix():
print("\nSUCCESS: All tests passed! File locking issue appears to be fixed.") print("\nSUCCESS: All tests passed! File locking issue appears to be fixed.")
return True return True
if __name__ == "__main__": if __name__ == "__main__":
try: try:
success = test_file_locking_fix() success = test_file_locking_fix()
@@ -144,5 +146,6 @@ if __name__ == "__main__":
except Exception as e: except Exception as e:
print(f"\nTest failed with exception: {e}") print(f"\nTest failed with exception: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
sys.exit(1) sys.exit(1)
+2 -2
View File
@@ -538,7 +538,7 @@ def test_strip_unicode_chars(input_string: str, expected: str):
def test_unicode_stripping_enabled(submission: MagicMock): def test_unicode_stripping_enabled(submission: MagicMock):
"""Test that Unicode stripping is applied when enabled""" """Test that Unicode stripping is applied when enabled"""
submission.title = 'Test 💕 emoji' submission.title = "Test 💕 emoji"
formatter = FileNameFormatter("{TITLE}", "", "", strip_unicode=True) formatter = FileNameFormatter("{TITLE}", "", "", strip_unicode=True)
result = formatter._format_name(submission, "{TITLE}") result = formatter._format_name(submission, "{TITLE}")
assert "💕" not in result assert "💕" not in result
@@ -547,7 +547,7 @@ def test_unicode_stripping_enabled(submission: MagicMock):
def test_unicode_stripping_disabled(submission: MagicMock): def test_unicode_stripping_disabled(submission: MagicMock):
"""Test that Unicode stripping is not applied when disabled""" """Test that Unicode stripping is not applied when disabled"""
submission.title = 'Test 💕 emoji' submission.title = "Test 💕 emoji"
formatter = FileNameFormatter("{TITLE}", "", "", strip_unicode=False) formatter = FileNameFormatter("{TITLE}", "", "", strip_unicode=False)
result = formatter._format_name(submission, "{TITLE}") result = formatter._format_name(submission, "{TITLE}")
assert "💕" in result assert "💕" in result
+43 -41
View File
@@ -3,14 +3,15 @@
Test script to verify hash persistence functionality. Test script to verify hash persistence functionality.
""" """
import json import json
import tempfile
import shutil import shutil
from pathlib import Path
from unittest.mock import Mock
# Import the necessary modules # Import the necessary modules
import sys import sys
sys.path.insert(0, '/Users/Daniel/Documents/GitHub/bulk-downloader-for-reddit') import tempfile
from pathlib import Path
from unittest.mock import Mock
sys.path.insert(0, "/Users/Daniel/Documents/GitHub/bulk-downloader-for-reddit")
from bdfr.configuration import Configuration from bdfr.configuration import Configuration
from bdfr.downloader import RedditDownloader from bdfr.downloader import RedditDownloader
@@ -59,7 +60,7 @@ def test_hash_persistence():
# Test 2: Save empty hash list # Test 2: Save empty hash list
print("Test 2: Saving empty hash list") print("Test 2: Saving empty hash list")
downloader._save_hash_list() downloader._save_hash_list()
hash_file = temp_path / '.bdfr_hashes.json' hash_file = temp_path / ".bdfr_hashes.json"
assert hash_file.exists(), "Hash file should be created even when empty" assert hash_file.exists(), "Hash file should be created even when empty"
print("PASS Passed") print("PASS Passed")
@@ -71,18 +72,18 @@ def test_hash_persistence():
# Test 4: Add some test data and save # Test 4: Add some test data and save
print("Test 4: Adding test data and saving") print("Test 4: Adding test data and saving")
test_file = temp_path / 'test.txt' test_file = temp_path / "test.txt"
test_file.write_text("test content") test_file.write_text("test content")
downloader.master_hash_list['test_hash_123'] = test_file downloader.master_hash_list["test_hash_123"] = test_file
downloader._save_hash_list() downloader._save_hash_list()
# Verify the saved JSON structure # Verify the saved JSON structure
with open(hash_file, 'r') as f: with open(hash_file, "r") as f:
saved_data = json.load(f) saved_data = json.load(f)
assert 'test_hash_123' in saved_data, "Test hash should be in saved data" assert "test_hash_123" in saved_data, "Test hash should be in saved data"
assert saved_data['test_hash_123'] == 'test.txt', f"Expected 'test.txt', got {saved_data['test_hash_123']}" assert saved_data["test_hash_123"] == "test.txt", f"Expected 'test.txt', got {saved_data['test_hash_123']}"
print("PASS Passed") print("PASS Passed")
# Test 5: Load hash list and verify data is restored # Test 5: Load hash list and verify data is restored
@@ -100,13 +101,15 @@ def test_hash_persistence():
loaded_hash_list = new_downloader._load_hash_list() loaded_hash_list = new_downloader._load_hash_list()
assert len(loaded_hash_list) == 1, f"Expected 1 hash, got {len(loaded_hash_list)}" assert len(loaded_hash_list) == 1, f"Expected 1 hash, got {len(loaded_hash_list)}"
assert 'test_hash_123' in loaded_hash_list, "Test hash should be loaded" assert "test_hash_123" in loaded_hash_list, "Test hash should be loaded"
assert loaded_hash_list['test_hash_123'] == test_file, f"File path should match: {loaded_hash_list['test_hash_123']} != {test_file}" assert (
loaded_hash_list["test_hash_123"] == test_file
), f"File path should match: {loaded_hash_list['test_hash_123']} != {test_file}"
print("PASS Passed") print("PASS Passed")
# Test 6: Test corrupted hash file handling # Test 6: Test corrupted hash file handling
print("Test 6: Testing corrupted hash file handling") print("Test 6: Testing corrupted hash file handling")
with open(hash_file, 'w') as f: with open(hash_file, "w") as f:
f.write("invalid json content") f.write("invalid json content")
corrupted_downloader = RedditDownloader.__new__(RedditDownloader) corrupted_downloader = RedditDownloader.__new__(RedditDownloader)
@@ -122,7 +125,9 @@ def test_hash_persistence():
# Should handle corrupted file gracefully and return empty dict # Should handle corrupted file gracefully and return empty dict
corrupted_hash_list = corrupted_downloader._load_hash_list() corrupted_hash_list = corrupted_downloader._load_hash_list()
assert len(corrupted_hash_list) == 0, f"Expected empty hash list for corrupted file, got {len(corrupted_hash_list)}" assert (
len(corrupted_hash_list) == 0
), f"Expected empty hash list for corrupted file, got {len(corrupted_hash_list)}"
print("PASS Passed") print("PASS Passed")
print("\nAll tests passed! Hash persistence functionality is working correctly.") print("\nAll tests passed! Hash persistence functionality is working correctly.")
@@ -174,7 +179,7 @@ def test_simple_check_functionality():
# Test 2: Add test data and save with simple_check format # Test 2: Add test data and save with simple_check format
print("Test 2: Adding test data and saving with simple_check format") print("Test 2: Adding test data and saving with simple_check format")
test_file = temp_path / 'test.txt' test_file = temp_path / "test.txt"
test_file.write_text("test content") test_file.write_text("test content")
test_url = "https://example.com/test.txt" test_url = "https://example.com/test.txt"
test_hash = "test_hash_123" test_hash = "test_hash_123"
@@ -185,16 +190,16 @@ def test_simple_check_functionality():
downloader._save_hash_list() downloader._save_hash_list()
# Verify the saved JSON structure has enhanced format # Verify the saved JSON structure has enhanced format
with open(temp_path / '.bdfr_hashes.json', 'r') as f: with open(temp_path / ".bdfr_hashes.json", "r") as f:
saved_data = json.load(f) saved_data = json.load(f)
assert 'files' in saved_data, "Enhanced format should have 'files' section" assert "files" in saved_data, "Enhanced format should have 'files' section"
assert 'urls' in saved_data, "Enhanced format should have 'urls' section" assert "urls" in saved_data, "Enhanced format should have 'urls' section"
assert 'metadata' in saved_data, "Enhanced format should have 'metadata' section" assert "metadata" in saved_data, "Enhanced format should have 'metadata' section"
assert test_hash in saved_data['files'], "Test hash should be in files section" assert test_hash in saved_data["files"], "Test hash should be in files section"
assert test_url in saved_data['urls'], "Test URL should be in urls section" assert test_url in saved_data["urls"], "Test URL should be in urls section"
assert saved_data['metadata']['version'] == '2.0', "Version should be 2.0" assert saved_data["metadata"]["version"] == "2.0", "Version should be 2.0"
assert saved_data['metadata']['created_with'] == 'simple_check', "Should be created with simple_check" assert saved_data["metadata"]["created_with"] == "simple_check", "Should be created with simple_check"
print("PASS") print("PASS")
# Test 3: Load hash list and verify URL mapping is restored # Test 3: Load hash list and verify URL mapping is restored
@@ -228,7 +233,7 @@ def test_simple_check_functionality():
mock_resource.hash.hexdigest.return_value = test_hash mock_resource.hash.hexdigest.return_value = test_hash
# Create a mock destination that exists # Create a mock destination that exists
mock_destination = temp_path / 'existing_file.txt' mock_destination = temp_path / "existing_file.txt"
mock_destination.parent.mkdir(parents=True, exist_ok=True) mock_destination.parent.mkdir(parents=True, exist_ok=True)
mock_destination.write_text("existing content") mock_destination.write_text("existing content")
@@ -260,17 +265,14 @@ def test_backward_compatibility():
temp_path = Path(temp_dir) temp_path = Path(temp_dir)
# Create old-format hash file manually # Create old-format hash file manually
(temp_path / 'relative' / 'path').mkdir(parents=True, exist_ok=True) (temp_path / "relative" / "path").mkdir(parents=True, exist_ok=True)
(temp_path / 'relative' / 'path' / 'file1.txt').write_text("content1") (temp_path / "relative" / "path" / "file1.txt").write_text("content1")
(temp_path / 'relative' / 'path' / 'file2.txt').write_text("content2") (temp_path / "relative" / "path" / "file2.txt").write_text("content2")
old_hash_data = { old_hash_data = {"hash1": "relative/path/file1.txt", "hash2": "relative/path/file2.txt"}
"hash1": "relative/path/file1.txt",
"hash2": "relative/path/file2.txt"
}
hash_file = temp_path / '.bdfr_hashes.json' hash_file = temp_path / ".bdfr_hashes.json"
with open(hash_file, 'w') as f: with open(hash_file, "w") as f:
json.dump(old_hash_data, f) json.dump(old_hash_data, f)
# Create downloader and load old format # Create downloader and load old format
@@ -294,21 +296,21 @@ def test_backward_compatibility():
print("PASS - Old format loaded correctly") print("PASS - Old format loaded correctly")
# Test saving in new format # Test saving in new format
(temp_path / 'another').mkdir(parents=True, exist_ok=True) (temp_path / "another").mkdir(parents=True, exist_ok=True)
test_file = temp_path / 'another' / 'new_file.txt' test_file = temp_path / "another" / "new_file.txt"
test_file.write_text("new content") test_file.write_text("new content")
downloader.master_hash_list["new_hash"] = test_file downloader.master_hash_list["new_hash"] = test_file
downloader._save_hash_list() downloader._save_hash_list()
# Verify new format was created # Verify new format was created
with open(hash_file, 'r') as f: with open(hash_file, "r") as f:
new_data = json.load(f) new_data = json.load(f)
assert 'files' in new_data, "New format should have 'files' section" assert "files" in new_data, "New format should have 'files' section"
assert 'urls' in new_data, "New format should have 'urls' section" assert "urls" in new_data, "New format should have 'urls' section"
assert 'metadata' in new_data, "New format should have 'metadata' section" assert "metadata" in new_data, "New format should have 'metadata' section"
assert new_data['metadata']['version'] == '2.0', "Should be version 2.0" assert new_data["metadata"]["version"] == "2.0", "Should be version 2.0"
print("PASS - Old format upgraded to new format correctly") print("PASS - Old format upgraded to new format correctly")
@@ -318,4 +320,4 @@ def test_backward_compatibility():
if __name__ == "__main__": if __name__ == "__main__":
test_hash_persistence() test_hash_persistence()
test_simple_check_functionality() test_simple_check_functionality()
test_backward_compatibility() test_backward_compatibility()
+31 -35
View File
@@ -14,10 +14,11 @@ import tempfile
from pathlib import Path from pathlib import Path
# Set UTF-8 encoding for Windows console # Set UTF-8 encoding for Windows console
if sys.platform == 'win32': if sys.platform == "win32":
import codecs import codecs
sys.stdout = codecs.getwriter('utf-8')(sys.stdout.buffer, 'strict')
sys.stderr = codecs.getwriter('utf-8')(sys.stderr.buffer, 'strict') sys.stdout = codecs.getwriter("utf-8")(sys.stdout.buffer, "strict")
sys.stderr = codecs.getwriter("utf-8")(sys.stderr.buffer, "strict")
from bdfr.api import BDFRManager, DownloadType from bdfr.api import BDFRManager, DownloadType
from bdfr.configuration import Configuration from bdfr.configuration import Configuration
@@ -26,27 +27,24 @@ from bdfr.configuration import Configuration
def test_subreddit_directory_structure(): def test_subreddit_directory_structure():
"""Test that subreddit downloads use correct directory structure""" """Test that subreddit downloads use correct directory structure"""
print("\n=== Testing Subreddit Directory Structure ===") print("\n=== Testing Subreddit Directory Structure ===")
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
manager = BDFRManager(download_directory=tmpdir) manager = BDFRManager(download_directory=tmpdir)
# Create a download for a subreddit # Create a download for a subreddit
download_id = manager.create_download( download_id = manager.create_download(DownloadType.SUBREDDIT, "test_subreddit")
DownloadType.SUBREDDIT,
"test_subreddit"
)
download_info = manager.get_download_status(download_id) download_info = manager.get_download_status(download_id)
config = download_info["config"] config = download_info["config"]
expected_dir = str(Path(tmpdir)) expected_dir = str(Path(tmpdir))
actual_dir = config.directory actual_dir = config.directory
print(f"Expected directory: {expected_dir}") print(f"Expected directory: {expected_dir}")
print(f"Actual directory: {actual_dir}") print(f"Actual directory: {actual_dir}")
print(f"Subreddit config: {config.subreddit}") print(f"Subreddit config: {config.subreddit}")
print(f"Folder scheme: {config.folder_scheme}") print(f"Folder scheme: {config.folder_scheme}")
assert actual_dir == expected_dir, f"Subreddit directory mismatch!" assert actual_dir == expected_dir, f"Subreddit directory mismatch!"
print("[OK] Subreddit directory structure is correct") print("[OK] Subreddit directory structure is correct")
print(f" Files will be saved to: {actual_dir}/{{SUBREDDIT}}/{{files}}") print(f" Files will be saved to: {actual_dir}/{{SUBREDDIT}}/{{files}}")
@@ -55,28 +53,25 @@ def test_subreddit_directory_structure():
def test_user_directory_structure(): def test_user_directory_structure():
"""Test that user downloads use correct directory structure""" """Test that user downloads use correct directory structure"""
print("\n=== Testing User Directory Structure ===") print("\n=== Testing User Directory Structure ===")
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
manager = BDFRManager(download_directory=tmpdir) manager = BDFRManager(download_directory=tmpdir)
# Create a download for a user # Create a download for a user
username = "test_user" username = "test_user"
download_id = manager.create_download( download_id = manager.create_download(DownloadType.USER, username)
DownloadType.USER,
username
)
download_info = manager.get_download_status(download_id) download_info = manager.get_download_status(download_id)
config = download_info["config"] config = download_info["config"]
expected_dir = str(Path(tmpdir) / username) expected_dir = str(Path(tmpdir) / username)
actual_dir = config.directory actual_dir = config.directory
print(f"Expected directory: {expected_dir}") print(f"Expected directory: {expected_dir}")
print(f"Actual directory: {actual_dir}") print(f"Actual directory: {actual_dir}")
print(f"User config: {config.user}") print(f"User config: {config.user}")
print(f"Folder scheme: {config.folder_scheme}") print(f"Folder scheme: {config.folder_scheme}")
assert actual_dir == expected_dir, f"User directory mismatch!" assert actual_dir == expected_dir, f"User directory mismatch!"
print("[OK] User directory structure is correct") print("[OK] User directory structure is correct")
print(f" Files will be saved to: {actual_dir}/{{SUBREDDIT}}/{{files}}") print(f" Files will be saved to: {actual_dir}/{{SUBREDDIT}}/{{files}}")
@@ -85,26 +80,26 @@ def test_user_directory_structure():
def test_convenience_method(): def test_convenience_method():
"""Test the convenience method download_user()""" """Test the convenience method download_user()"""
print("\n=== Testing download_user() Convenience Method ===") print("\n=== Testing download_user() Convenience Method ===")
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
manager = BDFRManager(download_directory=tmpdir) manager = BDFRManager(download_directory=tmpdir)
# Don't actually start the download, just check the config # Don't actually start the download, just check the config
username = "convenience_test_user" username = "convenience_test_user"
# Create config manually like the convenience method does # Create config manually like the convenience method does
config = Configuration() config = Configuration()
user_directory = Path(tmpdir) / username user_directory = Path(tmpdir) / username
config.directory = str(user_directory) config.directory = str(user_directory)
config.user = [username] config.user = [username]
expected_dir = str(Path(tmpdir) / username) expected_dir = str(Path(tmpdir) / username)
actual_dir = config.directory actual_dir = config.directory
print(f"Expected directory: {expected_dir}") print(f"Expected directory: {expected_dir}")
print(f"Actual directory: {actual_dir}") print(f"Actual directory: {actual_dir}")
print(f"User config: {config.user}") print(f"User config: {config.user}")
assert actual_dir == expected_dir, f"Convenience method directory mismatch!" assert actual_dir == expected_dir, f"Convenience method directory mismatch!"
print("[OK] Convenience method directory structure is correct") print("[OK] Convenience method directory structure is correct")
@@ -118,7 +113,7 @@ def demonstrate_folder_structure():
print(" ├── file1.jpg") print(" ├── file1.jpg")
print(" ├── file2.png") print(" ├── file2.png")
print(" └── file3.mp4") print(" └── file3.mp4")
print("\nWhen downloading from a USER 'spez' who posts to multiple subreddits:") print("\nWhen downloading from a USER 'spez' who posts to multiple subreddits:")
print(" downloads/") print(" downloads/")
print(" └── spez/") print(" └── spez/")
@@ -129,7 +124,7 @@ def demonstrate_folder_structure():
print(" │ └── file3.jpg") print(" │ └── file3.jpg")
print(" └── pics/") print(" └── pics/")
print(" └── file4.png") print(" └── file4.png")
print("\n[OK] This structure allows:") print("\n[OK] This structure allows:")
print(" 1. Easy identification of user-specific downloads") print(" 1. Easy identification of user-specific downloads")
print(" 2. Organization by subreddit within each user folder") print(" 2. Organization by subreddit within each user folder")
@@ -140,22 +135,23 @@ if __name__ == "__main__":
print("=" * 60) print("=" * 60)
print("Testing User Folder Structure Changes") print("Testing User Folder Structure Changes")
print("=" * 60) print("=" * 60)
try: try:
test_subreddit_directory_structure() test_subreddit_directory_structure()
test_user_directory_structure() test_user_directory_structure()
test_convenience_method() test_convenience_method()
demonstrate_folder_structure() demonstrate_folder_structure()
print("\n" + "=" * 60) print("\n" + "=" * 60)
print("[SUCCESS] All tests passed!") print("[SUCCESS] All tests passed!")
print("=" * 60) print("=" * 60)
except AssertionError as e: except AssertionError as e:
print(f"\n[FAIL] Test failed: {e}") print(f"\n[FAIL] Test failed: {e}")
exit(1) exit(1)
except Exception as e: except Exception as e:
print(f"\n[ERROR] Unexpected error: {e}") print(f"\n[ERROR] Unexpected error: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
exit(1) exit(1)
-1
View File
@@ -454,7 +454,6 @@ async def create_download_with_bdfr_api(download_type: str, name: str, **kwargs)
return download_id return download_id
# Get global auth token if available # Get global auth token if available
global _bdfr_auth_token
auth_token = _bdfr_auth_token auth_token = _bdfr_auth_token
logger.info(f"[DEBUG] Global auth token available: {auth_token is not None}") logger.info(f"[DEBUG] Global auth token available: {auth_token is not None}")
logger.info(f"[DEBUG] Auth token for BDFR: {auth_token[:10]}..." if auth_token else "None") logger.info(f"[DEBUG] Auth token for BDFR: {auth_token[:10]}..." if auth_token else "None")