diff --git a/instagrapi/image_util.py b/instagrapi/image_util.py new file mode 100644 index 00000000..33e26a15 --- /dev/null +++ b/instagrapi/image_util.py @@ -0,0 +1,305 @@ +# Copyright (c) 2017 https://github.com/ping +# +# This software is released under the MIT License. +# https://opensource.org/licenses/MIT + +import os +import io +import re +import tempfile +import shutil + +try: + from PIL import Image +except ImportError: + raise Exception("You don't have PIL installed. Please install PIL or Pillow>=8.1.1") + +import requests + + +def calc_resize(max_size, curr_size, min_size=(0, 0)): + """ + Calculate if resize is required based on the max size desired + and the current size + + :param max_size: tuple of (width, height) + :param curr_size: tuple of (width, height) + :param min_size: tuple of (width, height) + :return: + """ + max_width, max_height = max_size or (0, 0) + min_width, min_height = min_size or (0, 0) + + if (max_width and min_width > max_width) or (max_height and min_height > max_height): + raise ValueError('Invalid min / max sizes.') + + orig_width, orig_height = curr_size + if max_width and max_height and (orig_width > max_width or orig_height > max_height): + resize_factor = min( + 1.0 * max_width / orig_width, + 1.0 * max_height / orig_height) + new_width = int(resize_factor * orig_width) + new_height = int(resize_factor * orig_height) + return new_width, new_height + + elif min_width and min_height and (orig_width < min_width or orig_height < min_height): + resize_factor = max( + 1.0 * min_width / orig_width, + 1.0 * min_height / orig_height + ) + new_width = int(resize_factor * orig_width) + new_height = int(resize_factor * orig_height) + return new_width, new_height + + +def calc_crop(aspect_ratios, curr_size): + """ + Calculate if cropping is required based on the desired aspect + ratio and the current size. + + :param aspect_ratios: single float value or tuple of (min_ratio, max_ratio) + :param curr_size: tuple of (width, height) + :return: + """ + try: + if len(aspect_ratios) == 2: + min_aspect_ratio = float(aspect_ratios[0]) + max_aspect_ratio = float(aspect_ratios[1]) + else: + raise ValueError('Invalid aspect ratios') + except TypeError: + # not a min-max range + min_aspect_ratio = float(aspect_ratios) + max_aspect_ratio = float(aspect_ratios) + + curr_aspect_ratio = 1.0 * curr_size[0] / curr_size[1] + if not min_aspect_ratio <= curr_aspect_ratio <= max_aspect_ratio: + curr_width = curr_size[0] + curr_height = curr_size[1] + if curr_aspect_ratio > max_aspect_ratio: + # media is too wide + new_height = curr_height + new_width = max_aspect_ratio * new_height + else: + # media is too tall + new_width = curr_width + new_height = new_width / min_aspect_ratio + left = int((curr_width - new_width)/2) + top = int((curr_height - new_height)/2) + right = int((curr_width + new_width)/2) + bottom = int((curr_height + new_height)/2) + return left, top, right, bottom + + +def is_remote(media): + """Detect if media specified is a url""" + if re.match(r'^https?://', media): + return True + return False + + +def prepare_image(img, max_size=(1080, 1350), + aspect_ratios=(4.0 / 5.0, 90.0 / 47.0), + save_path=None, **kwargs): + """ + Prepares an image file for posting. + Defaults for size and aspect ratio from https://help.instagram.com/1469029763400082 + + :param img: file path + :param max_size: tuple of (max_width, max_height) + :param aspect_ratios: single float value or tuple of (min_ratio, max_ratio) + :param save_path: optional output file path + :param kwargs: + - **min_size**: tuple of (min_width, min_height) + :return: + """ + min_size = kwargs.pop('min_size', (320, 167)) + if is_remote(img): + res = requests.get(img) + im = Image.open(io.BytesIO(res.content)) + else: + im = Image.open(img) + + if aspect_ratios: + crop_box = calc_crop(aspect_ratios, im.size) + if crop_box: + im = im.crop(crop_box) + + new_size = calc_resize(max_size, im.size, min_size=min_size) + if new_size: + im = im.resize(new_size) + + if im.mode != 'RGB': + # Removes transparency (alpha) + im = im.convert('RGBA') + im2 = Image.new('RGB', im.size, (255, 255, 255)) + im2.paste(im, (0, 0), im) + im = im2 + if save_path: + im.save(save_path) + + b = io.BytesIO() + im.save(b, 'JPEG') + return b.getvalue(), im.size + + +def prepare_video(vid, thumbnail_frame_ts=0.0, + max_size=(1080, 1350), + aspect_ratios=(4.0 / 5.0, 90.0 / 47.0), + max_duration=60.0, + save_path=None, + skip_reencoding=False, + **kwargs): + """ + Prepares a video file for posting. + Defaults for size and aspect ratio from https://help.instagram.com/1469029763400082 + + :param vid: file path + :param thumbnail_frame_ts: the frame of clip corresponding to time t (in seconds) to be used as the thumbnail + :param max_size: tuple of (max_width, max_height) + :param aspect_ratios: single float value or tuple of (min_ratio, max_ratio) + :param max_duration: maximum video duration in seconds + :param save_path: optional output video file path + :param skip_reencoding: if set to True, the file will not be re-encoded + if there are no modifications required. Default: False. + :param kwargs: + - **min_size**: tuple of (min_width, min_height) + - **progress_bar**: bool flag to show/hide progress bar + - **save_only**: bool flag to return only the path to the saved video file. Requires save_path be set. + - **preset**: Sets the time that FFMPEG will spend optimizing the compression. + Choices are: ultrafast, superfast, veryfast, faster, fast, medium, + slow, slower, veryslow, placebo. Note that this does not impact + the quality of the video, only the size of the video file. So + choose ultrafast when you are in a hurry and file size does not matter. + :return: + """ + from moviepy.video.io.VideoFileClip import VideoFileClip + from moviepy.video.fx.all import resize, crop + + min_size = kwargs.pop('min_size', (612, 320)) + progress_bar = True if kwargs.pop('progress_bar', None) else False + save_only = kwargs.pop('save_only', False) + preset = kwargs.pop('preset', 'medium') + if save_only and not save_path: + raise ValueError('"save_path" cannot be empty.') + if save_path: + if not save_path.lower().endswith('.mp4'): + raise ValueError('You must specify a .mp4 save path') + + vid_is_modified = False # flag to track if re-encoding can be skipped + + temp_video_file = tempfile.NamedTemporaryFile(prefix='ipae_', suffix='.mp4', delete=False) + + if is_remote(vid): + # Download remote file + res = requests.get(vid) + temp_video_file.write(res.content) + video_src_filename = temp_video_file.name + else: + shutil.copyfile(vid, temp_video_file.name) + video_src_filename = vid + + vidclip = VideoFileClip(temp_video_file.name) + + if vidclip.duration < 3 * 1.0: + raise ValueError('Duration is too short') + + if vidclip.duration > max_duration * 1.0: + vidclip = vidclip.subclip(0, max_duration) + vid_is_modified = True + + if thumbnail_frame_ts > vidclip.duration: + raise ValueError('Invalid thumbnail frame') + + if aspect_ratios: + crop_box = calc_crop(aspect_ratios, vidclip.size) + if crop_box: + vidclip = crop(vidclip, x1=crop_box[0], y1=crop_box[1], x2=crop_box[2], y2=crop_box[3]) + vid_is_modified = True + + if max_size or min_size: + new_size = calc_resize(max_size, vidclip.size, min_size=min_size) + if new_size: + vidclip = resize(vidclip, newsize=new_size) + vid_is_modified = True + + temp_vid_output_file = tempfile.NamedTemporaryFile(prefix='ipae_', suffix='.mp4', delete=False) + if vid_is_modified or not skip_reencoding: + # write out + vidclip.write_videofile( + temp_vid_output_file.name, codec='libx264', audio=True, audio_codec='aac', + verbose=False, progress_bar=progress_bar, preset=preset, remove_temp=True) + else: + # no reencoding + shutil.copyfile(video_src_filename, temp_vid_output_file.name) + + if save_path: + shutil.copyfile(temp_vid_output_file.name, save_path) + + # Temp thumbnail img filename + temp_thumbnail_file = tempfile.NamedTemporaryFile(prefix='ipae_', suffix='.jpg', delete=False) + vidclip.save_frame(temp_thumbnail_file.name, t=thumbnail_frame_ts) + + video_duration = vidclip.duration + video_size = vidclip.size + del vidclip # clear it out + + video_thumbnail_content = temp_thumbnail_file.read() + + if not save_only: + video_content_len = os.path.getsize(temp_vid_output_file.name) + video_content = temp_vid_output_file.read() + else: + video_content_len = os.path.getsize(save_path) + video_content = save_path # return the file path instead + + if video_content_len > 50 * 1024 * 1000: + raise ValueError('Video file is too big.') + + return video_content, video_size, video_duration, video_thumbnail_content + + +if __name__ == '__main__': # pragma: no cover + # pylint: disable-all + import argparse + + parser = argparse.ArgumentParser(description='Demo media.py') + parser.add_argument('-i', '--image', dest='image', type=str) + parser.add_argument('-v', '--video', dest='video', type=str) + parser.add_argument('-video-story', dest='videostory', type=str) + + args = parser.parse_args() + + if args.image: + photo_data, size = prepare_image(args.image, max_size=(1000, 800), aspect_ratios=0.9) + print('Image dimensions: {0:d}x{1:d}'.format(size[0], size[1])) + + def print_vid_info(video_data, size, duration, thumbnail_data): + print( + 'vid file size: {0:d}, thumbnail file size: {1:d}, , ' + 'vid dimensions: {2:d}x{3:d}, duration: {4:f}'.format( + len(video_data), len(thumbnail_data), size[0], size[1], duration)) + + if args.video: + print('Example 1: Resize video to aspect ratio 1, duration 10s') + video_data, size, duration, thumbnail_data = prepare_video( + args.video, aspect_ratios=1.0, max_duration=10, + save_path='example1.mp4') + print_vid_info(video_data, size, duration, thumbnail_data) + + print('Example 2: Resize video to no greater than 480x480') + video_data, size, duration, thumbnail_data = prepare_video( + args.video, thumbnail_frame_ts=2.0, max_size=(480, 480)) + print_vid_info(video_data, size, duration, thumbnail_data) + + print('Example 3: Leave video intact and speed up retrieval') + video_data, size, duration, thumbnail_data = prepare_video( + args.video, max_size=None, skip_reencoding=True) + print_vid_info(video_data, size, duration, thumbnail_data) + + if args.videostory: + print('Generate a video suitable for posting as a story') + video_data, size, duration, thumbnail_data = prepare_video( + args.videostory, aspect_ratios=(3.0/4), max_duration=14.9, + min_size=(612, 612), max_size=(1080, 1080), save_path='story.mp4') + print_vid_info(video_data, size, duration, thumbnail_data) diff --git a/instagrapi/mixins/photo.py b/instagrapi/mixins/photo.py index 3e2f96db..b5b3d4de 100644 --- a/instagrapi/mixins/photo.py +++ b/instagrapi/mixins/photo.py @@ -29,6 +29,7 @@ Usertag, ) from instagrapi.utils import date_time_original, dumps +from instagrapi.image_util import prepare_image try: from PIL import Image @@ -140,10 +141,15 @@ def photo_rupload( (Upload ID for the media, width, height) """ assert isinstance(path, Path), f"Path must been Path, now {path} ({type(path)})" - valid_extensions = [".jpg", ".jpeg"] + valid_extensions = [".jpg", ".jpeg", ".png", ".webp"] if path.suffix.lower() not in valid_extensions: - raise ValueError("Invalid file format. Only JPG/JPEG files are supported.") - + raise ValueError("Invalid file format. Only JPG/JPEG/PNG/WEBP files are supported.") + image_type = "image/jpeg" + if path.suffix.lower() == ".png": + image_type = "image/png" + elif path.suffix.lower() == ".webp": + image_type = "image/webp" + # upload_id = 516057248854759 upload_id = upload_id or str(int(time.time() * 1000)) assert path, "Not specified path to photo" @@ -164,14 +170,13 @@ def photo_rupload( } if to_album: rupload_params["is_sidecar"] = "1" - with open(path, "rb") as fp: - photo_data = fp.read() - photo_len = str(len(photo_data)) + photo_data, photo_size = prepare_image(str(path), max_side=1080) + photo_len = str(len(photo_data)) headers = { "Accept-Encoding": "gzip", "X-Instagram-Rupload-Params": json.dumps(rupload_params), "X_FB_PHOTO_WATERFALL_ID": waterfall_id, - "X-Entity-Type": "image/jpeg", + "X-Entity-Type": image_type, "Offset": "0", "X-Entity-Name": upload_name, "X-Entity-Length": photo_len, @@ -196,6 +201,7 @@ def photo_rupload( width, height = im.size return upload_id, width, height + def photo_upload( self, path: Path, @@ -229,9 +235,9 @@ def photo_upload( An object of Media class """ path = Path(path) - valid_extensions = [".jpg", ".jpeg"] + valid_extensions = [".jpg", ".jpeg", ".png", ".webp"] if path.suffix.lower() not in valid_extensions: - raise ValueError("Invalid file format. Only JPG/JPEG files are supported.") + raise ValueError("Invalid file format. Only JPG/JPEG/PNG/WEBP files are supported.") upload_id, width, height = self.photo_rupload(path, upload_id) for attempt in range(10): diff --git a/instagrapi/types.py b/instagrapi/types.py index f8150a53..587dcc67 100644 --- a/instagrapi/types.py +++ b/instagrapi/types.py @@ -12,7 +12,7 @@ def validate_external_url(cls, v): class Resource(BaseModel): pk: str - video_url: Optional[HttpUrl] # for Video and IGTV + video_url: Optional[HttpUrl] = None # for Video and IGTV thumbnail_url: HttpUrl media_type: int @@ -23,33 +23,33 @@ class User(BaseModel): full_name: str is_private: bool profile_pic_url: HttpUrl - profile_pic_url_hd: Optional[HttpUrl] + profile_pic_url_hd: Optional[HttpUrl] = None is_verified: bool media_count: int follower_count: int following_count: int biography: Optional[str] = "" - external_url: Optional[str] - account_type: Optional[int] + external_url: Optional[str] = None + account_type: Optional[int] = None is_business: bool - public_email: Optional[str] - contact_phone_number: Optional[str] - public_phone_country_code: Optional[str] - public_phone_number: Optional[str] - business_contact_method: Optional[str] - business_category_name: Optional[str] - category_name: Optional[str] - category: Optional[str] - - address_street: Optional[str] - city_id: Optional[str] - city_name: Optional[str] - latitude: Optional[float] - longitude: Optional[float] - zip: Optional[str] - instagram_location_id: Optional[str] - interop_messaging_user_fbid: Optional[str] + public_email: Optional[str] = None + contact_phone_number: Optional[str] = None + public_phone_country_code: Optional[str] = None + public_phone_number: Optional[str] = None + business_contact_method: Optional[str] = None + business_category_name: Optional[str] = None + category_name: Optional[str] = None + category: Optional[str] = None + + address_street: Optional[str] = None + city_id: Optional[str] = None + city_name: Optional[str] = None + latitude: Optional[float] = None + longitude: Optional[float] = None + zip: Optional[str] = None + instagram_location_id: Optional[str] = None + interop_messaging_user_fbid: Optional[str] = None _external_url = validator("external_url", allow_reuse=True)(validate_external_url) @@ -62,23 +62,23 @@ class Account(BaseModel): profile_pic_url: HttpUrl is_verified: bool biography: Optional[str] = "" - external_url: Optional[str] + external_url: Optional[str] = None is_business: bool - birthday: Optional[str] - phone_number: Optional[str] - gender: Optional[int] - email: Optional[str] + birthday: Optional[str] = None + phone_number: Optional[str] = None + gender: Optional[int] = None + email: Optional[str] = None _external_url = validator("external_url", allow_reuse=True)(validate_external_url) class UserShort(BaseModel): pk: str - username: Optional[str] + username: Optional[str] = None full_name: Optional[str] = "" - profile_pic_url: Optional[HttpUrl] - profile_pic_url_hd: Optional[HttpUrl] - is_private: Optional[bool] + profile_pic_url: Optional[HttpUrl] = None + profile_pic_url_hd: Optional[HttpUrl] = None + is_private: Optional[bool] = None # is_verified: bool # not found in hashtag_medias_v1 stories: List = [] @@ -90,7 +90,7 @@ class Usertag(BaseModel): class Location(BaseModel): - pk: Optional[int] + pk: Optional[int] = None name: str phone: Optional[str] = "" website: Optional[str] = "" @@ -99,37 +99,37 @@ class Location(BaseModel): address: Optional[str] = "" city: Optional[str] = "" zip: Optional[str] = "" - lng: Optional[float] - lat: Optional[float] - external_id: Optional[int] - external_id_source: Optional[str] + lng: Optional[float] = None + lat: Optional[float] = None + external_id: Optional[int] = None + external_id_source: Optional[str] = None # address_json: Optional[dict] = {} # profile_pic_url: Optional[HttpUrl] # directory: Optional[dict] = {} class Media(BaseModel): - pk: str + pk: str | int id: str code: str taken_at: datetime media_type: int image_versions2: Optional[dict] = {} product_type: Optional[str] = "" # igtv or feed - thumbnail_url: Optional[HttpUrl] + thumbnail_url: Optional[HttpUrl] = None location: Optional[Location] = None user: UserShort comment_count: Optional[int] = 0 comments_disabled: Optional[bool] = False commenting_disabled_for_viewer: Optional[bool] = False like_count: int - play_count: Optional[int] - has_liked: Optional[bool] + play_count: Optional[int] = None + has_liked: Optional[bool] = None caption_text: str - accessibility_caption: Optional[str] + accessibility_caption: Optional[str] = None usertags: List[Usertag] sponsor_tags: List[UserShort] - video_url: Optional[HttpUrl] # for Video and IGTV + video_url: Optional[HttpUrl] = None # for Video and IGTV view_count: Optional[int] = 0 # for Video and IGTV video_duration: Optional[float] = 0.0 # for Video and IGTV title: Optional[str] = "" @@ -141,13 +141,13 @@ class MediaXma(BaseModel): # media_type: int video_url: HttpUrl # for Video and IGTV title: Optional[str] = "" - preview_url: Optional[HttpUrl] - preview_url_mime_type: Optional[str] - header_icon_url: Optional[HttpUrl] - header_icon_width: Optional[int] - header_icon_height: Optional[int] - header_title_text: Optional[str] - preview_media_fbid: Optional[str] + preview_url: Optional[HttpUrl] = None + preview_url_mime_type: Optional[str] = None + header_icon_url: Optional[HttpUrl] = None + header_icon_width: Optional[int] = None + header_icon_height: Optional[int] = None + header_title_text: Optional[str] = None + preview_media_fbid: Optional[str] = None class MediaOembed(BaseModel): @@ -182,23 +182,23 @@ class Comment(BaseModel): created_at_utc: datetime content_type: str status: str - has_liked: Optional[bool] - like_count: Optional[int] + has_liked: Optional[bool] = None + like_count: Optional[int] = None class Hashtag(BaseModel): id: str name: str - media_count: Optional[int] - profile_pic_url: Optional[HttpUrl] + media_count: Optional[int] = None + profile_pic_url: Optional[HttpUrl] = None class StoryMention(BaseModel): user: UserShort - x: Optional[float] - y: Optional[float] - width: Optional[float] - height: Optional[float] + x: Optional[float] = None + y: Optional[float] = None + width: Optional[float] = None + height: Optional[float] = None class StoryMedia(BaseModel): @@ -211,41 +211,41 @@ class StoryMedia(BaseModel): width: float = 0.8 height: float = 0.60572916 rotation: float = 0.0 - is_pinned: Optional[bool] - is_hidden: Optional[bool] - is_sticker: Optional[bool] - is_fb_sticker: Optional[bool] + is_pinned: Optional[bool] = None + is_hidden: Optional[bool] = None + is_sticker: Optional[bool] = None + is_fb_sticker: Optional[bool] = None media_pk: int - user_id: Optional[int] - product_type: Optional[str] - media_code: Optional[str] + user_id: Optional[int] = None + product_type: Optional[str] = None + media_code: Optional[str] = None class StoryHashtag(BaseModel): hashtag: Hashtag - x: Optional[float] - y: Optional[float] - width: Optional[float] - height: Optional[float] + x: Optional[float] = None + y: Optional[float] = None + width: Optional[float] = None + height: Optional[float] = None class StoryLocation(BaseModel): location: Location - x: Optional[float] - y: Optional[float] - width: Optional[float] - height: Optional[float] + x: Optional[float] = None + y: Optional[float] = None + width: Optional[float] = None + height: Optional[float] = None class StoryStickerLink(BaseModel): url: HttpUrl - link_title: Optional[str] - link_type: Optional[str] - display_url: Optional[str] + link_title: Optional[str] = None + link_type: Optional[str] = None + display_url: Optional[str] = None class StorySticker(BaseModel): - id: Optional[str] + id: Optional[str] = None type: Optional[str] = "gif" x: float y: float @@ -253,7 +253,7 @@ class StorySticker(BaseModel): width: float height: float rotation: Optional[float] = 0.0 - story_link: Optional[StoryStickerLink] + story_link: Optional[StoryStickerLink] = None extra: Optional[dict] = {} @@ -281,9 +281,9 @@ class Story(BaseModel): taken_at: datetime media_type: int product_type: Optional[str] = "" - thumbnail_url: Optional[HttpUrl] + thumbnail_url: Optional[HttpUrl] = None user: UserShort - video_url: Optional[HttpUrl] # for Video and IGTV + video_url: Optional[HttpUrl] = None # for Video and IGTV video_duration: Optional[float] = 0.0 # for Video and IGTV sponsor_tags: List[UserShort] mentions: List[StoryMention] @@ -295,71 +295,71 @@ class Story(BaseModel): class Guide(BaseModel): - id: Optional[str] - title: Optional[str] + id: Optional[str] = None + title: Optional[str] = None description: str cover_media: Media - feedback_item: Optional[dict] + feedback_item: Optional[dict] = None class DirectMedia(BaseModel): id: str media_type: int - user: Optional[UserShort] - thumbnail_url: Optional[HttpUrl] - video_url: Optional[HttpUrl] - audio_url: Optional[HttpUrl] + user: Optional[UserShort] = None + thumbnail_url: Optional[HttpUrl] = None + video_url: Optional[HttpUrl] = None + audio_url: Optional[HttpUrl] = None class ReplyMessage(BaseModel): id: str - user_id: Optional[str] + user_id: Optional[str] = None timestamp: datetime - item_type: Optional[str] - is_sent_by_viewer: Optional[bool] - is_shh_mode: Optional[bool] - text: Optional[str] - link: Optional[dict] - animated_media: Optional[dict] - media: Optional[DirectMedia] - visual_media: Optional[dict] - media_share: Optional[Media] - reel_share: Optional[dict] - story_share: Optional[dict] - felix_share: Optional[dict] - xma_share: Optional[MediaXma] - clip: Optional[Media] - placeholder: Optional[dict] + item_type: Optional[str] = None + is_sent_by_viewer: Optional[bool] = None + is_shh_mode: Optional[bool] = None + text: Optional[str] = None + link: Optional[dict] = None + animated_media: Optional[dict] = None + media: Optional[DirectMedia] = None + visual_media: Optional[dict] = None + media_share: Optional[Media] = None + reel_share: Optional[dict] = None + story_share: Optional[dict] = None + felix_share: Optional[dict] = None + xma_share: Optional[MediaXma] = None + clip: Optional[Media] = None + placeholder: Optional[dict] = None class DirectMessage(BaseModel): id: str # e.g. 28597946203914980615241927545176064 - user_id: Optional[str] - thread_id: Optional[int] # e.g. 340282366841710300949128531777654287254 + user_id: Optional[str] = None + thread_id: Optional[int] = None # e.g. 340282366841710300949128531777654287254 timestamp: datetime - item_type: Optional[str] - is_sent_by_viewer: Optional[bool] - is_shh_mode: Optional[bool] - reactions: Optional[dict] - text: Optional[str] - reply: Optional[ReplyMessage] - link: Optional[dict] - animated_media: Optional[dict] - media: Optional[DirectMedia] - visual_media: Optional[dict] - media_share: Optional[Media] - reel_share: Optional[dict] - story_share: Optional[dict] - felix_share: Optional[dict] - xma_share: Optional[MediaXma] - clip: Optional[Media] - placeholder: Optional[dict] + item_type: Optional[str] = None + is_sent_by_viewer: Optional[bool] = None + is_shh_mode: Optional[bool] = None + reactions: Optional[dict] = None + text: Optional[str] = None + reply: Optional[ReplyMessage] = None + link: Optional[dict] = None + animated_media: Optional[dict] = None + media: Optional[DirectMedia] = None + visual_media: Optional[dict] = None + media_share: Optional[Media] = None + reel_share: Optional[dict] = None + story_share: Optional[dict] = None + felix_share: Optional[dict] = None + xma_share: Optional[MediaXma] = None + clip: Optional[Media] = None + placeholder: Optional[dict] = None class DirectResponse(BaseModel): - unseen_count: Optional[int] - unseen_count_ts: Optional[int] - status: Optional[str] + unseen_count: Optional[int] = None + unseen_count_ts: Optional[int] = None + status: Optional[str] = None class DirectShortThread(BaseModel): @@ -378,12 +378,12 @@ class DirectThread(BaseModel): id: str # thread_id, e.g. 340282366841510300949128268610842297468 messages: List[DirectMessage] users: List[UserShort] - inviter: Optional[UserShort] + inviter: Optional[UserShort] = None left_users: List[UserShort] = [] admin_user_ids: list last_activity_at: datetime muted: bool - is_pin: Optional[bool] + is_pin: Optional[bool] = None named: bool canonical: bool pending: bool @@ -468,20 +468,20 @@ class Track(BaseModel): subtitle: str display_artist: str audio_cluster_id: int - artist_id: Optional[int] - cover_artwork_uri: Optional[HttpUrl] - cover_artwork_thumbnail_uri: Optional[HttpUrl] - progressive_download_url: Optional[HttpUrl] - fast_start_progressive_download_url: Optional[HttpUrl] - reactive_audio_download_url: Optional[HttpUrl] + artist_id: Optional[int] = None + cover_artwork_uri: Optional[HttpUrl] = None + cover_artwork_thumbnail_uri: Optional[HttpUrl] = None + progressive_download_url: Optional[HttpUrl] = None + fast_start_progressive_download_url: Optional[HttpUrl] = None + reactive_audio_download_url: Optional[HttpUrl] = None highlight_start_times_in_ms: List[int] is_explicit: bool dash_manifest: str - uri: Optional[HttpUrl] + uri: Optional[HttpUrl] = None has_lyrics: bool audio_asset_id: int duration_in_ms: int - dark_message: Optional[str] + dark_message: Optional[str] = None allows_saving: bool territory_validity_periods: dict diff --git a/requirements.lock b/requirements.lock index 6afe3359..7357cc03 100644 --- a/requirements.lock +++ b/requirements.lock @@ -11,7 +11,7 @@ Pillow==8.2.0 pip==21.0.1 proglog==0.1.9 pycryptodomex==3.9.9 -pydantic==1.8.1 +pydantic==2.4.2 PySocks==1.7.1 requests==2.25.1 setuptools==53.0.0 diff --git a/requirements.txt b/requirements.txt index 3eaa64d1..6a9ddbc7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ requests==2.31.0 PySocks==1.7.1 -pydantic==1.10.9 +pydantic==2.4.2 moviepy==1.0.3 pycryptodomex==3.18.0 diff --git a/setup.py b/setup.py index a253c45d..ade20ee2 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ requirements = [ "requests<3.0,>=2.25.1", "PySocks==1.7.1", - "pydantic==1.10.9", + "pydantic==2.4.2", "pycryptodomex==3.18.0", ] # requirements = [