diff --git a/docs/configuration.rst b/docs/configuration.rst index 23b791de4e..d518a50717 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -466,6 +466,7 @@ Description * ``idolcomplex`` * ``imgbb`` * ``inkbunny`` + * ``iwara`` * ``kemono`` * ``mangadex`` * ``mangoxo`` diff --git a/docs/gallery-dl.conf b/docs/gallery-dl.conf index 2b7d03ecf6..e2dddf4171 100644 --- a/docs/gallery-dl.conf +++ b/docs/gallery-dl.conf @@ -413,6 +413,11 @@ "sleep-request": "0.5-1.5", "videos": true }, + "iwara": + { + "username": "", + "password": "" + }, "kemono": { "username": "", diff --git a/docs/supportedsites.md b/docs/supportedsites.md index 8c21c6704a..51d6fe6809 100644 --- a/docs/supportedsites.md +++ b/docs/supportedsites.md @@ -517,6 +517,12 @@ Consider all listed sites to potentially be NSFW. Games + + Iwara + https://www.iwara.tv/ + Favorites, Followers, Followed Users, individual Images, Playlists, Search Results, Tag Searches, User Profiles, User Images, User Playlists, User Videos, Videos + Supported + Keenspot http://www.keenspot.com/ diff --git a/gallery_dl/extractor/__init__.py b/gallery_dl/extractor/__init__.py index 03a4444b90..9142fd78f4 100644 --- a/gallery_dl/extractor/__init__.py +++ b/gallery_dl/extractor/__init__.py @@ -92,6 +92,7 @@ "issuu", "itaku", "itchio", + "iwara", "jschan", "kabeuchi", "keenspot", diff --git a/gallery_dl/extractor/iwara.py b/gallery_dl/extractor/iwara.py new file mode 100644 index 0000000000..abee3651de --- /dev/null +++ b/gallery_dl/extractor/iwara.py @@ -0,0 +1,440 @@ +# -*- coding: utf-8 -*- + +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. + +"""Extractors for https://www.iwara.tv/""" + +from .common import Extractor, Message, Dispatch +from .. import text, util, exception +from ..cache import cache, memcache +import hashlib + +BASE_PATTERN = r"(?:https?://)?(?:www\.)?iwara\.tv" +USER_PATTERN = rf"{BASE_PATTERN}/profile/([^/?#]+)" + + +class IwaraExtractor(Extractor): + """Base class for iwara.tv extractors""" + category = "iwara" + root = "https://www.iwara.tv" + directory_fmt = ("{category}", "{user[name]}") + filename_fmt = "{date} {id} {title[:200]} {filename}.{extension}" + archive_fmt = "{type} {user[name]} {id} {file_id}" + + def _init(self): + self.api = IwaraAPI(self) + + def items_image(self, images, user=None): + for image in images: + try: + if "image" in image: + # could extract 'date_favorited' here + image = image["image"] + if not (files := image.get("files")): + image = self.api.image(image["id"]) + files = image["files"] + + group_info = self.extract_media_info(image, "file", False) + group_info["user"] = (self.extract_user_info(image) + if user is None else user) + except Exception as exc: + self.status |= 1 + self.log.error("Failed to process image %s (%s: %s)", + image["id"], exc.__class__.__name__, exc) + continue + + yield Message.Directory, group_info + for file in files: + file_info = self.extract_media_info(file, None) + file_id = file_info["file_id"] + url = (f"https://i.iwara.tv/image/original/" + f"{file_id}/{file_id}.{file_info['extension']}") + yield Message.Url, url, {**file_info, **group_info} + + def items_video(self, videos, user=None): + for video in videos: + try: + if "video" in video: + video = video["video"] + if "fileUrl" not in video: + video = self.api.video(video["id"]) + file_url = video["fileUrl"] + sources = self.api.source(file_url) + source = next((s for s in sources + if s.get("name") == "Source"), None) + download_url = source.get('src', {}).get('download') + + info = self.extract_media_info(video, "file") + info["user"] = (self.extract_user_info(video) + if user is None else user) + except Exception as exc: + self.status |= 1 + self.log.error("Failed to process video %s (%s: %s)", + video["id"], exc.__class__.__name__, exc) + continue + + yield Message.Directory, info + yield Message.Url, f"https:{download_url}", info + + def items_user(self, users, key): + base = f"{self.root}/profile/" + for user in users: + user = user[key] + if (username := user["username"]) is None: + continue + user["_extractor"] = IwaraUserExtractor + yield Message.Queue, f"{base}{username}", user + + def extract_media_info(self, item, key, include_file_info=True): + title = t.strip() if (t := item.get("title")) else "" + + if include_file_info: + file_info = item if key is None else item.get(key) or {} + filename, _, extension = file_info.get("name", "").rpartition(".") + + return { + "id" : item["id"], + "file_id" : file_info.get("id"), + "title" : title, + "filename" : filename, + "extension": extension, + "date" : text.parse_datetime( + file_info.get("createdAt"), "%Y-%m-%dT%H:%M:%S.%fZ"), + "date_updated": text.parse_datetime( + file_info.get("updatedAt"), "%Y-%m-%dT%H:%M:%S.%fZ"), + "mime" : file_info.get("mime"), + "size" : file_info.get("size"), + "width" : file_info.get("width"), + "height" : file_info.get("height"), + "duration" : file_info.get("duration"), + "type" : file_info.get("type"), + } + else: + return { + "id" : item["id"], + "title": title, + } + + def extract_user_info(self, profile): + user = profile.get("user") or {} + return { + "id" : user.get("id"), + "name" : user.get("username"), + "nick" : user.get("name").strip(), + "status" : user.get("status"), + "role" : user.get("role"), + "premium": user.get("premium"), + "date" : text.parse_datetime( + user.get("createdAt"), "%Y-%m-%dT%H:%M:%S.000Z"), + "description": profile.get("body"), + } + + def _user_params(self): + user, qs = self.groups + params = text.parse_query(qs) + profile = self.api.profile(user) + params["user"] = profile["user"]["id"] + return self.extract_user_info(profile), params + + +class IwaraUserExtractor(Dispatch, IwaraExtractor): + """Extractor for iwara.tv profile pages""" + pattern = rf"{USER_PATTERN}/?$" + example = "https://www.iwara.tv/profile/USERNAME" + + def items(self): + base = f"{self.root}/profile/{self.groups[0]}/" + return self._dispatch_extractors(( + (IwaraUserImagesExtractor , f"{base}images"), + (IwaraUserVideosExtractor , f"{base}videos"), + (IwaraUserPlaylistsExtractor, f"{base}playlists"), + ), ("user-images", "user-videos")) + + +class IwaraUserImagesExtractor(IwaraExtractor): + subcategory = "user-images" + pattern = rf"{USER_PATTERN}/images(?:\?([^#]+))?" + example = "https://www.iwara.tv/profile/USERNAME/images" + + def items(self): + user, params = self._user_params() + return self.items_image(self.api.images(params), user) + + +class IwaraUserVideosExtractor(IwaraExtractor): + subcategory = "user-videos" + pattern = rf"{USER_PATTERN}/videos(?:\?([^#]+))?" + example = "https://www.iwara.tv/profile/USERNAME/videos" + + def items(self): + user, params = self._user_params() + return self.items_video(self.api.videos(params), user) + + +class IwaraUserPlaylistsExtractor(IwaraExtractor): + subcategory = "user-playlists" + pattern = rf"{USER_PATTERN}/playlists(?:\?([^#]+))?" + example = "https://www.iwara.tv/profile/USERNAME/playlists" + + def items(self): + base = f"{self.root}/playlist/" + + for playlist in self.api.playlists(self._user_params()[1]): + playlist["_extractor"] = IwaraPlaylistExtractor + url = f"{base}{playlist['id']}" + yield Message.Queue, url, playlist + + +class IwaraFollowingExtractor(IwaraExtractor): + subcategory = "following" + pattern = rf"{USER_PATTERN}/following" + example = "https://www.iwara.tv/profile/USERNAME/following" + + def items(self): + uid = self.api.profile(self.groups[0])["user"]["id"] + return self.items_user(self.api.user_following(uid), "user") + + +class IwaraFollowersExtractor(IwaraExtractor): + subcategory = "followers" + pattern = rf"{USER_PATTERN}/followers" + example = "https://www.iwara.tv/profile/USERNAME/followers" + + def items(self): + uid = self.api.profile(self.groups[0])["user"]["id"] + return self.items_user(self.api.user_followers(uid), "follower") + + +class IwaraImageExtractor(IwaraExtractor): + """Extractor for individual iwara.tv image pages""" + subcategory = "image" + pattern = rf"{BASE_PATTERN}/image/([^/?#]+)" + example = "https://www.iwara.tv/image/ID" + + def items(self): + return self.items_image((self.api.image(self.groups[0]),)) + + +class IwaraVideoExtractor(IwaraExtractor): + """Extractor for individual iwara.tv videos""" + subcategory = "video" + pattern = rf"{BASE_PATTERN}/video/([^/?#]+)" + example = "https://www.iwara.tv/video/ID" + + def items(self): + return self.items_video((self.api.video(self.groups[0]),)) + + +class IwaraPlaylistExtractor(IwaraExtractor): + """Extractor for individual iwara.tv playlist pages""" + subcategory = "playlist" + pattern = rf"{BASE_PATTERN}/playlist/([^/?#]+)" + example = "https://www.iwara.tv/playlist/ID" + + def items(self): + return self.items_video(self.api.playlist(self.groups[0])) + + +class IwaraFavoriteExtractor(IwaraExtractor): + subcategory = "favorite" + pattern = rf"{BASE_PATTERN}/favorites(?:/(image|video)s)?" + example = "https://www.iwara.tv/favorites/videos" + + def items(self): + type = self.groups[0] or "vidoo" + + results = self.api.favorites(type) + if type == "image": + return self.items_image(results) + else: + return self.items_video(results) + + +class IwaraSearchExtractor(IwaraExtractor): + """Extractor for iwara.tv search pages""" + subcategory = "search" + pattern = rf"{BASE_PATTERN}/search\?([^#]+)" + example = "https://www.iwara.tv/search?query=QUERY&type=TYPE" + + def items(self): + params = text.parse_query(self.groups[0]) + self.kwdict["search_type"] = type = params.get("type", "video") + self.kwdict["search_tags"] = query = params.get("query") + + results = self.api.search(type, query) + if type == "image": + return self.items_image(results) + if type == "video": + return self.items_video(results) + + raise exception.AbortExtraction("Unsupported search type '%s'", type) + + +class IwaraTagExtractor(IwaraExtractor): + """Extractor for iwara.tv tag search""" + subcategory = "tag" + pattern = rf"{BASE_PATTERN}/(videos|images)(?:\?([^#]+))?" + example = "https://www.iwara.tv/videos?tags=TAGS" + + def items(self): + type, qs = self.groups + params = text.parse_query(qs) + self.kwdict["search_type"] = type + self.kwdict["search_tags"] = params.get("tags") + + if type == "images": + return self.items_image(self.api.images(params)) + else: + return self.items_video(self.api.videos(params)) + + +class IwaraAPI(): + """Interface for the Iwara API""" + root = "https://api.iwara.tv" + + def __init__(self, extractor): + self.extractor = extractor + self.headers = { + "Referer" : f"{extractor.root}/", + "Content-Type": "application/json", + "Origin" : extractor.root, + } + + self.username, self.password = extractor._get_auth_info() + if not self.username: + self.authenticate = util.noop + + def image(self, image_id): + endpoint = f"/image/{image_id}" + return self._call(endpoint) + + def video(self, video_id): + endpoint = f"/video/{video_id}" + return self._call(endpoint) + + def playlist(self, playlist_id): + endpoint = f"/playlist/{playlist_id}" + return self._pagination(endpoint) + + def detail(self, media): + endpoint = f"/{media['type']}/{media['id']}" + return self._call(endpoint) + + def images(self, params): + endpoint = "/images" + params.setdefault("rating", "all") + return self._pagination(endpoint, params) + + def videos(self, params): + endpoint = "/videos" + params.setdefault("rating", "all") + return self._pagination(endpoint, params) + + def playlists(self, params): + endpoint = "/playlists" + return self._pagination(endpoint, params) + + def collection(self, type, params): + endpoint = f"/{type}s" + params.setdefault("rating", "all") + return self._pagination(endpoint, params) + + def favorites(self, type): + endpoint = f"/favorites/{type}s" + return self._pagination(endpoint) + + def search(self, type, query): + endpoint = "/search" + params = {"type": type, "query": query} + return self._pagination(endpoint, params) + + @memcache(keyarg=1) + def profile(self, username): + endpoint = f"/profile/{username}" + return self._call(endpoint) + + def user_following(self, user_id): + endpoint = f"/user/{user_id}/following" + return self._pagination(endpoint) + + def user_followers(self, user_id): + endpoint = f"/user/{user_id}/followers" + return self._pagination(endpoint) + + def source(self, file_url): + base, _, query = file_url.partition("?") + if not (expires := text.extr(query, "expires=", "&")): + return () + file_id = base.rpartition("/")[2] + sha_postfix = "5nFp9kmbNnHdAFhaqMvt" + sha_key = f"{file_id}_{expires}_{sha_postfix}" + hash = hashlib.sha1(sha_key.encode()).hexdigest() + headers = {"X-Version": hash, **self.headers} + return self.extractor.request_json(file_url, headers=headers) + + def authenticate(self): + self.headers["Authorization"] = self._authenticate_impl(self.username) + + @cache(maxage=3600, keyarg=1) + def _authenticate_impl(self, username): + refresh_token = _refresh_token_cache(username) + if refresh_token is None: + self.extractor.log.info("Logging in as %s", username) + + url = f"{self.root}/user/login" + json = { + "email" : username, + "password": self.password + } + data = self.extractor.request_json( + url, method="POST", headers=self.headers, json=json, + fatal=False) + + if not (refresh_token := data.get("token")): + self.extractor.log.debug(data) + raise exception.AuthenticationError(data.get("message")) + _refresh_token_cache.update(username, refresh_token) + + self.extractor.log.info("Refreshing access token for %s", username) + + url = f"{self.root}/user/token" + headers = {"Authorization": f"Bearer {refresh_token}", **self.headers} + data = self.extractor.request_json( + url, method="POST", headers=headers, fatal=False) + + if not (access_token := data.get("accessToken")): + self.extractor.log.debug(data) + raise exception.AuthenticationError(data.get("message")) + return f"Bearer {access_token}" + + def _call(self, endpoint, params=None, headers=None): + if headers is None: + headers = self.headers + + url = self.root + endpoint + self.authenticate() + return self.extractor.request_json(url, params=params, headers=headers) + + def _pagination(self, endpoint, params=None): + if params is None: + params = {} + params["page"] = 0 + params["limit"] = 50 + + while True: + data = self._call(endpoint, params) + + if not (results := data.get("results")): + break + yield from results + + if len(results) < params["limit"]: + break + params["page"] += 1 + + +@cache(maxage=28*86400, keyarg=0) +def _refresh_token_cache(username): + return None diff --git a/scripts/supportedsites.py b/scripts/supportedsites.py index 172d10f012..1dc2f0071d 100755 --- a/scripts/supportedsites.py +++ b/scripts/supportedsites.py @@ -287,6 +287,11 @@ "saved": "Saved Posts", "tagged": "Tagged Posts", }, + "iwara": { + "user-images": "User Images", + "user-videos": "User Videos", + "user-playlists": "User Playlists", + }, "kemono": { "discord" : "Discord Servers", "discord-server": "", @@ -475,6 +480,7 @@ "imgbb" : "Supported", "inkbunny" : "Supported", "instagram" : _COOKIES, + "iwara" : "Supported", "kemono" : "Supported", "mangadex" : "Supported", "mangoxo" : "Supported", diff --git a/test/results/iwara.py b/test/results/iwara.py new file mode 100644 index 0000000000..37e34012a0 --- /dev/null +++ b/test/results/iwara.py @@ -0,0 +1,292 @@ +# -*- coding: utf-8 -*- + +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. + +from gallery_dl.extractor import iwara + + +__tests__ = ( +{ + "#url" : "https://www.iwara.tv/profile/user2426993", + "#class" : iwara.IwaraUserExtractor, + "#results" : ( + "https://www.iwara.tv/profile/user2426993/images", + "https://www.iwara.tv/profile/user2426993/videos", + ), +}, + +{ + "#url" : "https://www.iwara.tv/profile/user2426993/images", + "#class" : iwara.IwaraUserImagesExtractor, + "#results" : ( + "https://i.iwara.tv/image/original/215ef6c5-47a9-4894-aaef-7bbc7ed2b5d0/215ef6c5-47a9-4894-aaef-7bbc7ed2b5d0.png", + "https://i.iwara.tv/image/original/382ce6bc-0393-43dd-adb7-dfd514a72011/382ce6bc-0393-43dd-adb7-dfd514a72011.png", + "https://i.iwara.tv/image/original/57fad542-d5c7-4671-b295-f7c4886db80e/57fad542-d5c7-4671-b295-f7c4886db80e.png", + "https://i.iwara.tv/image/original/80b61308-08b5-469b-ab86-b2d1a9819a32/80b61308-08b5-469b-ab86-b2d1a9819a32.png", + ), + + "extension": "png", + "type" : "image", +}, + +{ + "#url" : "https://www.iwara.tv/profile/user2426993/videos", + "#class" : iwara.IwaraUserVideosExtractor, + "#pattern" : ( + r"https://\w+.iwara.tv/download\?filename=8035c1cb-6ac6-45df-a171-4d981a8339c5_Source.mp4&path=2025%2F07%2F04&expires=\d+.+", + r"https://\w+.iwara.tv/download\?filename=59691a5b-dd5d-4476-919d-dc0d8c9ee11f_Source.mp4&path=2025%2F06%2F21&expires=\d+.+", + ), + + "extension": "mp4", + "type" : "video", +}, + +{ + "#url" : "https://www.iwara.tv/profile/tyron82/playlists", + "#class" : iwara.IwaraUserPlaylistsExtractor, + "#pattern" : iwara.IwaraPlaylistExtractor.pattern, + "#count" : range(10, 20), +}, + +{ + "#url" : "https://www.iwara.tv/profile/tyron82/following", + "#class" : iwara.IwaraFollowingExtractor, + "#pattern" : iwara.IwaraUserExtractor.pattern, + "#range" : "1-100", + "#count" : 100, +}, + +{ + "#url" : "https://www.iwara.tv/profile/tyron82/followers", + "#class" : iwara.IwaraFollowersExtractor, + "#pattern" : iwara.IwaraUserExtractor.pattern, + "#range" : "1-100", + "#count" : 100, +}, + +{ + "#url" : "https://www.iwara.tv/playlist/01ea603a-4e70-4a36-bc28-dc717eebc2d7", + "#category" : ("", "iwara", "playlist"), + "#class" : iwara.IwaraPlaylistExtractor, + "#pattern" : r"https://\w+.iwara.tv/download\?filename=b7708020-f531-4eb4-bfd3-c62f3d17927e_Source.mp4&path=2024%2F05%2F12&.+", + "#count" : 1, + + "id" : "OaoVL8nqijDjhB", + "title" : "MMD.RuanMei's body modification", + "file_id" : "b7708020-f531-4eb4-bfd3-c62f3d17927e", + "filename" : "b7708020-f531-4eb4-bfd3-c62f3d17927e", + "extension" : "mp4", + "mime" : "video/mp4", + "size" : 225197782, + "width" : None, + "height" : None, + "duration" : 654, + "type" : "video", + "user" : { + "date" : "dt:2020-05-15 09:59:32", + "description": str, + "id" : "c9a08dd5-3cb5-4d7c-b9bb-9eb4c55eda14", + "name" : "arisananades", + "nick" : "Arisananades", + "premium" : False, + "role" : "user", + "status" : "active", + }, +}, + +{ + "#url" : "https://www.iwara.tv/favorites/videos", + "#class" : iwara.IwaraFavoriteExtractor, + "#auth" : True, +}, + +{ + "#url" : "https://www.iwara.tv/favorites/images", + "#class" : iwara.IwaraFavoriteExtractor, + "#auth" : True, +}, + +{ + "#url" : "https://www.iwara.tv/search?query=genshin%20tentacle&type=video", + "#category" : ("", "iwara", "search"), + "#class" : iwara.IwaraSearchExtractor, + "#count" : 5, + + "extension" : "mp4", + "mime" : "video/mp4", + "width" : None, + "height" : None, + "type" : "video", + "user": { + "date" : "dt:2022-01-12 17:08:38", + "description": str, + "id" : "3ec40862-bcb6-4c2e-9f3b-6da3a00cc2d9", + "name" : "nizipaco-kyu", + "nick" : "Nizipaco - Kyu", + "premium" : False, + "role" : "user", + "status" : "active", + }, +}, + +{ + "#url" : "https://www.iwara.tv/search?query=genshin%20layla%20sex&type=image", + "#category" : ("", "iwara", "search"), + "#class" : iwara.IwaraSearchExtractor, + "#count" : 20, + + "duration" : None, + "type" : "image", +}, + +{ + "#url" : "https://www.iwara.tv/videos?tags=aether%2Ccitlali", + "#category" : ("", "iwara", "tag"), + "#class" : iwara.IwaraTagExtractor, + "#pattern" : ( + r"https://\w+.iwara.tv/download\?filename=d8e3735d-048c-4525-adcf-4265c8b45444_Source.mp4&path=2025%2F05%2F15&expires=\d+&.+", + r"https://\w+.iwara.tv/download\?filename=cc1a1aba-10b9-4e0f-a20f-5b9b17b33db1_Source.mp4&path=2025%2F04%2F03&expires=\d+&.+", + r"https://\w+.iwara.tv/download\?filename=94a8a1b9-7586-4771-accd-6f9cb4c6a5a1_Source.mp4&path=2025%2F03%2F21&expires=\d+&.+", + ), + + "user": { + "id" : "2b4391f3-c46f-43f9-b18f-8bdb8a9df74f", + "name": "lenoria", + "nick": "lenoria", + }, + "extension" : "mp4", + "mime" : "video/mp4", + "width" : None, + "height" : None, + "type" : "video", + "search_tags" : "aether,citlali", + "search_type" : "videos", + "duration" : range(90, 200), +}, + +{ + "#url" : "https://www.iwara.tv/images?tags=genshin_impact%2Ccitlali", + "#category" : ("", "iwara", "tag"), + "#class" : iwara.IwaraTagExtractor, + "#results" : ( + "https://i.iwara.tv/image/original/c442c69f-30fb-4fd4-8f8f-338bbc77c07d/c442c69f-30fb-4fd4-8f8f-338bbc77c07d.jpg", + "https://i.iwara.tv/image/original/7b53cc07-3640-4749-8c11-6da5f5a292a0/7b53cc07-3640-4749-8c11-6da5f5a292a0.jpg", + "https://i.iwara.tv/image/original/373cc1cb-028e-44bd-aef3-3400de4f995b/373cc1cb-028e-44bd-aef3-3400de4f995b.jpg", + "https://i.iwara.tv/image/original/0256b01b-8b4d-47f7-894d-2aceba6b8ab8/0256b01b-8b4d-47f7-894d-2aceba6b8ab8.jpg", + "https://i.iwara.tv/image/original/8541dab6-9c67-419d-8af8-2e040ae487dc/8541dab6-9c67-419d-8af8-2e040ae487dc.png", + "https://i.iwara.tv/image/original/8eba51de-c618-4853-964f-25f526b58398/8eba51de-c618-4853-964f-25f526b58398.webm", + ), + + "duration" : None, + "extension" : {"jpg", "png", "webm"}, + "mime" : {"image/jpeg", "image/png", "video/webm"}, + "search_tags" : "genshin_impact,citlali", + "search_type" : "images", + "type" : "image", +}, + +{ + "#url" : "https://www.iwara.tv/video/6QvQvzZnELJ9vv/bluearchive-rio", + "#category" : ("", "iwara", "video"), + "#class" : iwara.IwaraVideoExtractor, + "#pattern" : r"https://\w+.iwara.tv/download\?filename=7ba6e734-b9df-4588-88fc-4eef2bbf5c56_Source.mp4&path=2025%2F07%2F05&expires=\d+&hash=[0-9a-f]{64}", + "#count" : 1, + + "user": { + "id" : "b3f86af1-874c-41f1-b62e-4e4b736ad3a4", + "name": "croove", + "nick": "crooveNSFW", + }, + "id" : "6QvQvzZnELJ9vv", + "title" : "[BlueArchive / ブルアカ] Rio", + "file_id" : "7ba6e734-b9df-4588-88fc-4eef2bbf5c56", + "filename" : "7ba6e734-b9df-4588-88fc-4eef2bbf5c56", + "extension" : "mp4", + "mime" : "video/mp4", + "size" : 86328642, + "width" : None, + "height" : None, + "duration" : 107, + "type" : "video", + "date" : "dt:2025-07-05 06:49:56", + "date_updated" : "dt:2025-07-05 06:50:14", +}, + +{ + "#url" : "https://www.iwara.tv/image/5m3gLfcei6BQsL/sparkle", + "#category" : ("", "iwara", "image"), + "#class" : iwara.IwaraImageExtractor, + "#pattern" : r"https://i.iwara.tv/image/original/[\w-]{36}/[\w-]{36}\.png", + "#count" : 13, + + "user": { + "id" : "771d2b29-5935-43d7-85e1-30abbf47ccad", + "name": "zcccz", + "nick": "zcccz", + }, + "id" : "5m3gLfcei6BQsL", + "title" : "Sparkle", + "extension" : "png", + "mime" : "image/png", + "type" : "image", + "date" : "type:datetime", + "date_updated" : "type:datetime", +}, + +{ + "#url" : "https://www.iwara.tv/image/PbYJb57QqwrFp0", + "#category" : ("", "iwara", "image"), + "#class" : iwara.IwaraImageExtractor, + "#results" : "https://i.iwara.tv/image/original/0302deee-9cd5-4c1f-b931-04caf329c0c7/0302deee-9cd5-4c1f-b931-04caf329c0c7.png", + "#sha1_content" : "9fc2ae4d0d26d4b50c38ff2c5c235d33e8b56d1c", + + "user": { + "id" : "ef14099e-a6db-4325-9c67-51c0615985d5", + "name": "sanka", + "nick": "Cerodiers", + }, + "id" : "PbYJb57QqwrFp0", + "title" : "还没做完", + "file_id" : "0302deee-9cd5-4c1f-b931-04caf329c0c7", + "filename" : "0302deee-9cd5-4c1f-b931-04caf329c0c7", + "extension" : "png", + "mime" : "image/png", + "size" : 3564514, + "width" : 2560, + "height" : 1440, + "duration" : None, + "type" : "image", + "date" : "dt:2025-07-04 03:15:37", + "date_updated" : "dt:2025-07-04 03:15:53", +}, + +{ + "#url" : "https://www.iwara.tv/image/sjqkK5EobXucju/ellen-joe-dancing", + "#comment" : "WebM video with sound classified as 'image'", + "#class" : iwara.IwaraImageExtractor, + "#results" : "https://i.iwara.tv/image/original/cf1686ac-9796-4213-bea3-71b6dcaac658/cf1686ac-9796-4213-bea3-71b6dcaac658.webm", + + "date" : "dt:2025-07-07 17:06:47", + "date_updated": "dt:2025-07-07 17:07:11", + "duration" : None, + "extension" : "webm", + "file_id" : "cf1686ac-9796-4213-bea3-71b6dcaac658", + "filename" : "cf1686ac-9796-4213-bea3-71b6dcaac658", + "width" : 1366, + "height" : 768, + "id" : "sjqkK5EobXucju", + "mime" : "video/webm", + "size" : 4747505, + "subcategory" : "image", + "title" : "Ellen Joe Dancing To Body Shaming", + "type" : "image", + "user": { + "id" : "f7625ea7-c1c8-416b-b929-a245892911a6", + "name": "marzcade", + "nick": "Marzcade", + }, +}, + +)