diff --git a/config/args.py b/config/args.py index fd2164f..607e99c 100644 --- a/config/args.py +++ b/config/args.py @@ -121,13 +121,13 @@ def parse_args() -> argparse.Namespace: help='Use "Collection" download mode. This will ony download a collection.', ) download_modes.add_argument( - '--single', + '--posts', required=False, default=None, - metavar='POST_ID', - dest='download_mode_single', - help='Use "Single" download mode. This will download a single post ' - "by ID from an arbitrary creator. " + nargs='*', + dest='download_mode_posts', + help='Use "Posts" download mode. This will download all desired posts ' + "by ID from arbitrary creators. Append all IDs separated by a whitespace." "A post ID must be at least 10 characters and consist of digits only." "Example - https://fansly.com/post/1283998432982 -> ID is: 1283998432982", ) @@ -375,17 +375,18 @@ def map_args_to_config(args: argparse.Namespace, config: FanslyConfig) -> None: config.download_mode = DownloadMode.COLLECTION config_overridden = True - if args.download_mode_single is not None: - post_id = args.download_mode_single - config.download_mode = DownloadMode.SINGLE - - if not is_valid_post_id(post_id): - raise ConfigError( - f"Argument error - '{post_id}' is not a valid post ID. " - "At least 10 characters/only digits required." - ) + if args.download_mode_posts is not None: + post_ids = args.download_mode_posts + config.download_mode = DownloadMode.POSTS + + for post_id in post_ids: + if not is_valid_post_id(post_id): + raise ConfigError( + f"Argument error - '{post_id}' is not a valid post ID. " + "At least 10 characters/only digits required." + ) - config.post_id = post_id + config.post_ids = post_ids config_overridden = True if args.metadata_handling is not None: diff --git a/config/config.py b/config/config.py index 94d16bb..8d6e134 100644 --- a/config/config.py +++ b/config/config.py @@ -215,7 +215,7 @@ def load_config(config: FanslyConfig) -> None: config._parser.get(options_section, 'download_directory', fallback='Local_directory') ) - # Normal (Timeline & Messages), Timeline, Messages, Single (Single by post id) or Collections -> str + # Normal (Timeline & Messages), Timeline, Messages, Posts (Posts by post ids) or Collections -> str download_mode = config._parser.get(options_section, 'download_mode', fallback='Normal') config.download_mode = DownloadMode(download_mode.upper()) diff --git a/config/fanslyconfig.py b/config/fanslyconfig.py index 95a37b4..6bde007 100644 --- a/config/fanslyconfig.py +++ b/config/fanslyconfig.py @@ -4,7 +4,7 @@ from configparser import ConfigParser from dataclasses import dataclass from pathlib import Path -from typing import Optional +from typing import Optional, List from config.modes import DownloadMode from config.metadatahandling import MetadataHandling @@ -35,7 +35,7 @@ class FanslyConfig(object): token_from_browser_name: Optional[str] = None debug: bool = False # If specified on the command-line - post_id: Optional[str] = None + post_ids: Optional[List[str]] = None # Set on start after self-update updated_to: Optional[str] = None @@ -57,7 +57,7 @@ class FanslyConfig(object): #session_id: str = 'null' # Options - # "Normal" | "Timeline" | "Messages" | "Single" | "Collection" + # "Normal" | "Timeline" | "Messages" | "Posts" | "Collection" download_mode: DownloadMode = DownloadMode.NORMAL download_directory: (None | Path) = None download_media_previews: bool = True diff --git a/config/modes.py b/config/modes.py index f1cc7aa..8943470 100644 --- a/config/modes.py +++ b/config/modes.py @@ -9,5 +9,5 @@ class DownloadMode(StrEnum): COLLECTION = auto() MESSAGES = auto() NORMAL = auto() - SINGLE = auto() + POSTS = auto() TIMELINE = auto() diff --git a/download/account.py b/download/account.py index c371501..5db01e3 100644 --- a/download/account.py +++ b/download/account.py @@ -25,7 +25,7 @@ def get_creator_account_info(config: FanslyConfig, state: DownloadState) -> None raise RuntimeError(message) # Collections are independent of creators and - # single posts may diverge from configured creators + # posts may diverge from configured creators if any([config.download_mode == DownloadMode.MESSAGES, config.download_mode == DownloadMode.NORMAL, config.download_mode == DownloadMode.TIMELINE]): diff --git a/download/common.py b/download/common.py index 3036d94..e936c3e 100644 --- a/download/common.py +++ b/download/common.py @@ -103,7 +103,7 @@ def process_download_accessible_media( currently being downloaded. :param list[dict] media_infos: A list of media informations from posts, timelines, messages, collections and so on. - :param str|None post_id: The post ID required for "Single" download mode. + :param str|None post_id: The post ID required for "Posts" download mode. :return: "False" as a break indicator for "Timeline" downloads, "True" otherwise. diff --git a/download/core.py b/download/core.py index 21acf9a..2379c60 100644 --- a/download/core.py +++ b/download/core.py @@ -11,7 +11,7 @@ from .downloadstate import DownloadState from .globalstate import GlobalState from .messages import download_messages -from .single import download_single_post +from .posts import download_posts from .timeline import download_timeline @@ -19,7 +19,7 @@ 'download_collections', 'print_download_info', 'download_messages', - 'download_single_post', + 'download_posts', 'download_timeline', 'DownloadState', 'GlobalState', diff --git a/download/posts.py b/download/posts.py new file mode 100644 index 0000000..025073a --- /dev/null +++ b/download/posts.py @@ -0,0 +1,119 @@ +"""Posts Downloading""" + + +from .common import get_unique_media_ids, process_download_accessible_media +from .core import DownloadState +from .media import download_media_infos +from .types import DownloadType + +from config import FanslyConfig +from fileio.dedupe import dedupe_init +from textio import input_enter_continue, print_error, print_info, print_warning +from utils.common import is_valid_post_id + + +def download_posts(config: FanslyConfig, state: DownloadState): + """Downloads all desired posts.""" + + # This is important for directory creation later on. + state.download_type = DownloadType.POSTS + + print_info(f"You have launched in Posts download mode.") + + if config.post_ids is not None: + print_info(f"Trying to download posts {config.post_ids} as specified on the command-line ...") + post_ids = config.post_ids + + elif not config.interactive: + raise RuntimeError( + 'Posts downloading is not supported in non-interactive mode ' + 'unless post IDs are specified via command-line.' + ) + + else: + print_info(f"Please enter the IDs of the posts you would like to download one after another (confirm with )." + f"\n{17*' '}After you click on a post, it will show in your browsers URL bar." + ) + print() + + post_ids = [] + post_id = "dummy" + + while len(post_id) > 0: + while True: + post_id = input(f"\n{17*' '}► Post ID: ") + + if len(post_id) == 0: + print_info("All desired post IDs are recorded. Proceeding to download.") + break + elif is_valid_post_id(post_id): + post_ids.append(post_id) + print_info(f"Post {post_id} is recorded. Enter the next one or hit enter to proceed.") + break + else: + print_error(f"The input string '{post_id}' can not be a valid post ID." + f"\n{22*' '}The last few numbers in the url is the post ID" + f"\n{22*' '}Example: 'https://fansly.com/post/1283998432982'" + f"\n{22*' '}In the example, '1283998432982' would be the post ID.", + 17 + ) + + for post_id in post_ids: + + post_response = config.get_api() \ + .get_post(post_id) + + if post_response.status_code == 200: + # From: "accounts" + creator_username, creator_display_name = None, None + + # post object contains: posts, aggregatedPosts, accountMediaBundles, accountMedia, accounts, tips, tipGoals, stories, polls + post_object = post_response.json()['response'] + + # if access to post content / post contains content + if post_object['accountMediaBundles'] or post_object['accountMedia']: + + # parse post creator name + if creator_username is None: + # the post creators reliable accountId + if post_object['accountMediaBundles']: + state.creator_id = post_object['accountMediaBundles'][0]['accountId'] + else: + state.creator_id = post_object['accountMedia'][0]['accountId'] + + creator_display_name, creator_username = next( + (account.get('displayName'), account.get('username')) + for account in post_object.get('accounts', []) + if account.get('id') == state.creator_id + ) + + # Override the creator's name with the one from the posting. + # Post ID could be from a different creator than specified + # in the config file. + state.creator_name = creator_username + + if creator_display_name and creator_username: + print_info(f"Inspecting a post by {creator_display_name} (@{creator_username})") + else: + print_info(f"Inspecting a post by {creator_username.capitalize()}") + + # Deferred deduplication init because directory may have changed + # depending on post creator (!= configured creator) + dedupe_init(config, state) + + all_media_ids = get_unique_media_ids(post_object) + media_infos = download_media_infos(config, all_media_ids) + + process_download_accessible_media(config, state, media_infos, post_id) + + if state.duplicate_count > 0 and config.show_downloads and not config.show_skipped_downloads: + print_info( + f"Skipped {state.duplicate_count} already downloaded media item{'' if state.duplicate_count == 1 else 's'}." + ) + + else: + print_warning(f"Could not find any accessible content in post {post_id}.") + + else: + print_error(f"Failed to download post {post_id}. Response code: {post_response.status_code}\n{post_response.text}", 20) + input_enter_continue(config.interactive) diff --git a/download/single.py b/download/single.py deleted file mode 100644 index 885de79..0000000 --- a/download/single.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Single Post Downloading""" - - -from .common import get_unique_media_ids, process_download_accessible_media -from .core import DownloadState -from .media import download_media_infos -from .types import DownloadType - -from config import FanslyConfig -from fileio.dedupe import dedupe_init -from textio import input_enter_continue, print_error, print_info, print_warning -from utils.common import is_valid_post_id - - -def download_single_post(config: FanslyConfig, state: DownloadState): - """Downloads a single post.""" - - # This is important for directory creation later on. - state.download_type = DownloadType.SINGLE - - print_info(f"You have launched in Single Post download mode.") - - if config.post_id is not None: - print_info(f"Trying to download post {config.post_id} as specified on the command-line ...") - post_id = config.post_id - - elif not config.interactive: - raise RuntimeError( - 'Single Post downloading is not supported in non-interactive mode ' - 'unless a post ID is specified via command-line.' - ) - - else: - print_info(f"Please enter the ID of the post you would like to download." - f"\n{17*' '}After you click on a post, it will show in your browsers URL bar." - ) - print() - - while True: - post_id = input(f"\n{17*' '}► Post ID: ") - - if is_valid_post_id(post_id): - break - - else: - print_error(f"The input string '{post_id}' can not be a valid post ID." - f"\n{22*' '}The last few numbers in the url is the post ID" - f"\n{22*' '}Example: 'https://fansly.com/post/1283998432982'" - f"\n{22*' '}In the example, '1283998432982' would be the post ID.", - 17 - ) - - post_response = config.get_api() \ - .get_post(post_id) - - if post_response.status_code == 200: - # From: "accounts" - creator_username, creator_display_name = None, None - - # post object contains: posts, aggregatedPosts, accountMediaBundles, accountMedia, accounts, tips, tipGoals, stories, polls - post_object = post_response.json()['response'] - - # if access to post content / post contains content - if post_object['accountMediaBundles'] or post_object['accountMedia']: - - # parse post creator name - if creator_username is None: - # the post creators reliable accountId - if post_object['accountMediaBundles']: - state.creator_id = post_object['accountMediaBundles'][0]['accountId'] - else: - state.creator_id = post_object['accountMedia'][0]['accountId'] - - creator_display_name, creator_username = next( - (account.get('displayName'), account.get('username')) - for account in post_object.get('accounts', []) - if account.get('id') == state.creator_id - ) - - # Override the creator's name with the one from the posting. - # Post ID could be from a different creator than specified - # in the config file. - state.creator_name = creator_username - - if creator_display_name and creator_username: - print_info(f"Inspecting a post by {creator_display_name} (@{creator_username})") - else: - print_info(f"Inspecting a post by {creator_username.capitalize()}") - - # Deferred deduplication init because directory may have changed - # depending on post creator (!= configured creator) - dedupe_init(config, state) - - all_media_ids = get_unique_media_ids(post_object) - media_infos = download_media_infos(config, all_media_ids) - - process_download_accessible_media(config, state, media_infos, post_id) - - if state.duplicate_count > 0 and config.show_downloads and not config.show_skipped_downloads: - print_info( - f"Skipped {state.duplicate_count} already downloaded media item{'' if state.duplicate_count == 1 else 's'}." - ) - - else: - print_warning(f"Could not find any accessible content in the single post.") - - else: - print_error(f"Failed single post download. Response code: {post_response.status_code}\n{post_response.text}", 20) - input_enter_continue(config.interactive) diff --git a/download/types.py b/download/types.py index 96ad809..ac50aad 100644 --- a/download/types.py +++ b/download/types.py @@ -8,5 +8,5 @@ class DownloadType(StrEnum): NOTSET = auto() COLLECTIONS = auto() MESSAGES = auto() - SINGLE = auto() + POSTS = auto() TIMELINE = auto() diff --git a/fansly_downloader_ng.py b/fansly_downloader_ng.py index 83115ef..1265a86 100644 --- a/fansly_downloader_ng.py +++ b/fansly_downloader_ng.py @@ -138,7 +138,7 @@ def main(config: FanslyConfig) -> int: state = DownloadState(creator_name=creator_name) # Special treatment for deviating folder names later - if not config.download_mode == DownloadMode.SINGLE: + if not config.download_mode == DownloadMode.POSTS: dedupe_init(config, state) print_download_info(config) @@ -149,14 +149,14 @@ def main(config: FanslyConfig) -> int: # Normal: Downloads Timeline + Messages one after another. # Timeline: Scrapes only the creator's timeline content. # Messages: Scrapes only the creator's messages content. - # Single: Fetch a single post by the post's ID. Click on a post to see its ID in the url bar e.g. ../post/1283493240234 + # Posts: Fetch all desired post by their ID. Click on a post to see its ID in the url bar e.g. ../post/1283493240234 # Collection: Download all content listed within the "Purchased Media Collection" print_info(f'Download mode is: {config.download_mode_str()}') print() - if config.download_mode == DownloadMode.SINGLE: - download_single_post(config, state) + if config.download_mode == DownloadMode.POSTS: + download_posts(config, state) elif config.download_mode == DownloadMode.COLLECTION: download_collections(config, state) diff --git a/pathio/pathio.py b/pathio/pathio.py index 2b294aa..0afccff 100644 --- a/pathio/pathio.py +++ b/pathio/pathio.py @@ -73,7 +73,7 @@ def set_create_directory_for_download(config: FanslyConfig, state: DownloadState elif state.download_type == DownloadType.TIMELINE and config.separate_timeline: download_directory = user_base_path / 'Timeline' - elif state.download_type == DownloadType.SINGLE and config.separate_timeline: + elif state.download_type == DownloadType.POSTS and config.separate_timeline: download_directory = user_base_path / 'Timeline' # Save state