diff --git a/wallpaper-apis/build.gradle.kts b/wallpaper-apis/build.gradle.kts index bbc18a8b..77366969 100644 --- a/wallpaper-apis/build.gradle.kts +++ b/wallpaper-apis/build.gradle.kts @@ -21,4 +21,7 @@ dependencies { implementation("com.squareup.okhttp3:logging-interceptor:5.0.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") implementation("com.fleeksoft.ksoup:ksoup:0.2.6") -} \ No newline at end of file +} +dependencies { + testImplementation("junit:junit:4.13.2") +} diff --git a/wallpaper-apis/src/main/java/net/youapps/wallpaper_apis/re/Reddit.kt b/wallpaper-apis/src/main/java/net/youapps/wallpaper_apis/re/Reddit.kt index 69bb4701..af909d95 100644 --- a/wallpaper-apis/src/main/java/net/youapps/wallpaper_apis/re/Reddit.kt +++ b/wallpaper-apis/src/main/java/net/youapps/wallpaper_apis/re/Reddit.kt @@ -1,16 +1,31 @@ package net.youapps.wallpaper_apis.re +import net.youapps.wallpaper_apis.re.obj.RedditListingResponse import okhttp3.ResponseBody +import retrofit2.Response import retrofit2.http.GET +import retrofit2.http.Header import retrofit2.http.Path import retrofit2.http.Query interface Reddit { + @GET("r/{subreddit}/{sort}.json") + suspend fun getRedditJson( + @Path("subreddit") subreddit: String, + @Path("sort") sort: String, + @Query("limit") limit: Int = 25, + @Query("t") time: String? = null, + @Query("after") after: String? = null, + @Query("raw_json") rawJson: Int = 1, + @Header("Cookie") cookie: String? = null + ): Response + @GET("r/{subreddit}/{sort}.rss") suspend fun getRedditData( @Path("subreddit") subreddit: String, @Path("sort") sort: String, @Query("t") time: String? = null, - @Query("after") after: String? = null + @Query("after") after: String? = null, + @Header("Cookie") cookie: String? = null ): ResponseBody } diff --git a/wallpaper-apis/src/main/java/net/youapps/wallpaper_apis/re/RedditApi.kt b/wallpaper-apis/src/main/java/net/youapps/wallpaper_apis/re/RedditApi.kt index e336628f..f81def63 100644 --- a/wallpaper-apis/src/main/java/net/youapps/wallpaper_apis/re/RedditApi.kt +++ b/wallpaper-apis/src/main/java/net/youapps/wallpaper_apis/re/RedditApi.kt @@ -4,6 +4,9 @@ import com.fleeksoft.ksoup.Ksoup import net.youapps.wallpaper_apis.RetrofitHelper import net.youapps.wallpaper_apis.Wallpaper import net.youapps.wallpaper_apis.WallpaperApi +import net.youapps.wallpaper_apis.re.obj.RedditGalleryItem +import net.youapps.wallpaper_apis.re.obj.RedditListingResponse +import java.time.Instant class RedditApi : WallpaperApi() { override val name = "Reddit" @@ -11,52 +14,226 @@ class RedditApi : WallpaperApi() { override val requiresCommunityName: Boolean = true override val availableFilters: Map> = mapOf( - "sort" to listOf("top", "new", "hot", "rising"), - "time" to listOf("month", "year", "hour", "day", "week") + "sort" to listOf("hot", "top", "new", "rising"), + "time" to listOf("day", "week", "month", "year", "all", "hour") ) val api = RetrofitHelper.create(baseUrl) override var communityName: String? = "r/wallpaper" - private val imageRegex = Regex("^.+\\.(jpg|jpeg|png|webp)$") + /** + * Optional provider for session cookies (e.g. from an in-app browser session) + * to avoid Reddit HTTP 429 rate limits and login walls. + */ + var cookieProvider: (() -> String?)? = null + + private val imageRegex = Regex("^.+\\.(jpg|jpeg|png|webp)(\\?.*)?$", RegexOption.IGNORE_CASE) private var nextPageAfter: String? = null + private var isUsingRssFallback = false override suspend fun getWallpapers(page: Int): List { // happens when there's no next page available if (page != 1 && nextPageAfter == null) return emptyList() // reset the after query if starting from the beginning - if (page == 1) nextPageAfter = null - val subreddit = communityName!!.replaceFirst("r/", "") - - val xml = api.getRedditData( - subreddit, - selectedFilters["sort"]!!, - selectedFilters["time"], - nextPageAfter - ).string() - - val doc = Ksoup.parseXml(xml) - val entries = doc.select("entry") - - nextPageAfter = entries.lastOrNull()?.selectFirst("id")?.text() - - return entries.mapNotNull { entry -> - val content = Ksoup.parse(entry.selectFirst("content")?.text().orEmpty()) - val imgSrc = content.select("a[href]") - .map { it.attr("href") } - .firstOrNull { it.matches(imageRegex) } ?: return@mapNotNull null - - Wallpaper( - imgSrc = imgSrc, - title = entry.selectFirst("title")?.text(), - thumb = content.selectFirst("img")?.attr("src"), - url = entry.selectFirst("link")?.attr("href"), - author = entry.selectFirst("author")?.selectFirst("name")?.text(), - creationDate = entry.selectFirst("published")?.text()?.take(10), - ) + if (page == 1) { + nextPageAfter = null + isUsingRssFallback = false + } + + val subreddit = communityName.orEmpty().trim().removePrefix("r/").removePrefix("/") + if (subreddit.isEmpty()) return emptyList() + + val sort = selectedFilters["sort"] ?: "hot" + val time = selectedFilters["time"] + val cookie = cookieProvider?.invoke() + + // 1. Primary Strategy: Fetch rich JSON listing + if (!isUsingRssFallback) { + try { + val response = api.getRedditJson( + subreddit = subreddit, + sort = sort, + limit = 25, + time = time, + after = nextPageAfter, + cookie = cookie + ) + + if (response.isSuccessful) { + val listing = response.body() + nextPageAfter = listing?.data?.after + val wallpapers = parseJsonListing(listing) + if (wallpapers.isNotEmpty() || listing?.data?.children?.isNotEmpty() == true) { + return wallpapers + } + } else if (response.code() in listOf(302, 403, 429)) { + isUsingRssFallback = true + } + } catch (_: Exception) { + isUsingRssFallback = true + } + } + + // 2. Fallback Strategy: Scrape RSS feed + return fetchFromRss(subreddit, sort, time, cookie, page) + } + + private fun parseJsonListing(listing: RedditListingResponse?): List { + val children = listing?.data?.children?.mapNotNull { it.data } ?: return emptyList() + val result = mutableListOf() + + for (post in children) { + if (post.id.isEmpty()) continue + + val createdDate = formatDate(post.createdUtc) + val postUrl = if (!post.permalink.isNullOrEmpty()) "https://www.reddit.com${post.permalink}" else null + + // Handle gallery posts + val mediaMetadata = post.mediaMetadata + val galleryItems = post.galleryData?.items + if (post.isGallery && !mediaMetadata.isNullOrEmpty()) { + val itemsToProcess = if (!galleryItems.isNullOrEmpty()) { + galleryItems.mapNotNull { item -> + mediaMetadata[item.mediaId]?.let { item to it } + } + } else { + mediaMetadata.entries.map { (id, item) -> RedditGalleryItem(mediaId = id) to item } + } + + val total = itemsToProcess.size + itemsToProcess.forEachIndexed { index, (galleryItem, mediaItem) -> + if (mediaItem.status == "valid" || mediaItem.status == null) { + val rawUrl = mediaItem.s?.u ?: mediaItem.s?.gif + if (rawUrl != null) { + val fullImg = unescapeUrl(rawUrl) + val thumbUrl = mediaItem.p.lastOrNull()?.u?.let { unescapeUrl(it) } ?: fullImg + val source = mediaItem.s + val res = if (source != null && source.x > 0 && source.y > 0) "${source.x}x${source.y}" else null + val titleText = if (total > 1) "${post.title} (${index + 1}/$total)" else post.title + + result.add( + Wallpaper( + imgSrc = fullImg, + title = titleText, + thumb = thumbUrl, + url = postUrl, + author = post.author, + resolution = res, + creationDate = createdDate + ) + ) + } + } + } + continue + } + + // Handle single image posts + var imgSrc: String? = null + var thumb: String? = null + var resolution: String? = null + + val previewImage = post.preview?.images?.firstOrNull() + if (previewImage?.source != null && previewImage.source.url.isNotEmpty()) { + imgSrc = unescapeUrl(previewImage.source.url) + resolution = if (previewImage.source.width > 0 && previewImage.source.height > 0) { + "${previewImage.source.width}x${previewImage.source.height}" + } else null + thumb = previewImage.resolutions.lastOrNull()?.url?.let { unescapeUrl(it) } + } + + val url = post.url + if (url.endsWith(".jpg", true) || url.endsWith(".png", true) || + url.endsWith(".jpeg", true) || url.endsWith(".webp", true) || + url.contains("i.redd.it") + ) { + imgSrc = url + } + + if (thumb == null) { + thumb = post.thumbnail?.takeIf { it.startsWith("http") } ?: imgSrc + } + + if (imgSrc != null) { + result.add( + Wallpaper( + imgSrc = imgSrc, + title = post.title, + thumb = thumb, + url = postUrl, + author = post.author, + resolution = resolution, + creationDate = createdDate + ) + ) + } + } + + return result + } + + private suspend fun fetchFromRss( + subreddit: String, + sort: String, + time: String?, + cookie: String?, + page: Int + ): List { + return try { + val xml = api.getRedditData( + subreddit = subreddit, + sort = sort, + time = time, + after = nextPageAfter, + cookie = cookie + ).string() + + val doc = Ksoup.parseXml(xml) + val entries = doc.select("entry") + + nextPageAfter = entries.lastOrNull()?.selectFirst("id")?.text() + + entries.mapNotNull { entry -> + val contentHtml = entry.selectFirst("content")?.text().orEmpty() + val content = Ksoup.parse(contentHtml) + + val imgSrc = content.select("a[href]") + .map { it.attr("href") } + .firstOrNull { it.matches(imageRegex) || it.contains("i.redd.it") } + ?: return@mapNotNull null + + val unescapedImgSrc = unescapeUrl(imgSrc) + val thumb = content.selectFirst("img")?.attr("src")?.let { unescapeUrl(it) } + + Wallpaper( + imgSrc = unescapedImgSrc, + title = entry.selectFirst("title")?.text(), + thumb = thumb ?: unescapedImgSrc, + url = entry.selectFirst("link")?.attr("href"), + author = entry.selectFirst("author")?.selectFirst("name")?.text(), + creationDate = entry.selectFirst("published")?.text()?.take(10) + ) + } + } catch (_: Exception) { + emptyList() + } + } + + private fun unescapeUrl(url: String): String = + url.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + + private fun formatDate(epochSeconds: Double): String? { + if (epochSeconds <= 0.0) return null + return try { + Instant.ofEpochSecond(epochSeconds.toLong()).toString().take(10) + } catch (_: Throwable) { + null } } diff --git a/wallpaper-apis/src/main/java/net/youapps/wallpaper_apis/re/obj/RedditModels.kt b/wallpaper-apis/src/main/java/net/youapps/wallpaper_apis/re/obj/RedditModels.kt new file mode 100644 index 00000000..058a5f25 --- /dev/null +++ b/wallpaper-apis/src/main/java/net/youapps/wallpaper_apis/re/obj/RedditModels.kt @@ -0,0 +1,100 @@ +package net.youapps.wallpaper_apis.re.obj + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RedditListingResponse( + val kind: String? = null, + val data: RedditListingData? = null +) + +@Serializable +data class RedditListingData( + val after: String? = null, + val before: String? = null, + val dist: Int? = null, + val children: List = emptyList() +) + +@Serializable +data class RedditChild( + val kind: String? = null, + val data: RedditPostData? = null +) + +@Serializable +data class RedditPostData( + val id: String = "", + val name: String = "", + val title: String = "", + val author: String? = null, + val subreddit: String? = null, + val permalink: String? = null, + val url: String = "", + val thumbnail: String? = null, + @SerialName("over_18") + val over18: Boolean = false, + val score: Int = 0, + @SerialName("created_utc") + val createdUtc: Double = 0.0, + @SerialName("is_gallery") + val isGallery: Boolean = false, + @SerialName("post_hint") + val postHint: String? = null, + val preview: RedditPreview? = null, + @SerialName("gallery_data") + val galleryData: RedditGalleryData? = null, + @SerialName("media_metadata") + val mediaMetadata: Map? = null +) + +@Serializable +data class RedditPreview( + val images: List = emptyList(), + val enabled: Boolean = false +) + +@Serializable +data class RedditPreviewImage( + val source: RedditImageSource? = null, + val resolutions: List = emptyList(), + val id: String? = null +) + +@Serializable +data class RedditImageSource( + val url: String = "", + val width: Int = 0, + val height: Int = 0 +) + +@Serializable +data class RedditGalleryData( + val items: List = emptyList() +) + +@Serializable +data class RedditGalleryItem( + @SerialName("media_id") + val mediaId: String = "", + val id: Long? = null, + val caption: String? = null +) + +@Serializable +data class RedditMediaItem( + val status: String? = null, + val e: String? = null, + val m: String? = null, + val s: RedditMediaSource? = null, + val p: List = emptyList() +) + +@Serializable +data class RedditMediaSource( + val x: Int = 0, + val y: Int = 0, + val u: String? = null, + val gif: String? = null +) diff --git a/wallpaper-apis/src/test/java/net/youapps/wallpaper_apis/re/RedditApiTest.kt b/wallpaper-apis/src/test/java/net/youapps/wallpaper_apis/re/RedditApiTest.kt new file mode 100644 index 00000000..0737132e --- /dev/null +++ b/wallpaper-apis/src/test/java/net/youapps/wallpaper_apis/re/RedditApiTest.kt @@ -0,0 +1,132 @@ +package net.youapps.wallpaper_apis.re + +import kotlinx.serialization.json.Json +import net.youapps.wallpaper_apis.RetrofitHelper +import net.youapps.wallpaper_apis.re.obj.RedditListingResponse +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class RedditApiTest { + + @Test + fun testRedditJsonDeserializationAndGallerySupport() { + val sampleJson = """ + { + "kind": "Listing", + "data": { + "after": "t3_post_after_token", + "children": [ + { + "kind": "t3", + "data": { + "id": "single_img_1", + "name": "t3_single_img_1", + "title": "Single Wallpaper Image", + "author": "photographer1", + "permalink": "/r/wallpapers/comments/single_img_1/single_wallpaper/", + "url": "https://i.redd.it/single_pic.jpg", + "over_18": false, + "created_utc": 1710000000.0, + "preview": { + "images": [ + { + "source": { + "url": "https://preview.redd.it/single_pic.jpg?width=3840&crop=smart&auto=webp&s=abc", + "width": 3840, + "height": 2160 + }, + "resolutions": [ + { + "url": "https://preview.redd.it/single_pic.jpg?width=1080&crop=smart&auto=webp&s=abc", + "width": 1080, + "height": 607 + } + ] + } + ] + } + } + }, + { + "kind": "t3", + "data": { + "id": "gallery_1", + "name": "t3_gallery_1", + "title": "Nature Gallery Set", + "author": "nature_lover", + "permalink": "/r/wallpapers/comments/gallery_1/nature_gallery/", + "url": "https://www.reddit.com/gallery/gallery_1", + "is_gallery": true, + "created_utc": 1710005000.0, + "gallery_data": { + "items": [ + { "media_id": "img_g1" }, + { "media_id": "img_g2" } + ] + }, + "media_metadata": { + "img_g1": { + "status": "valid", + "m": "image/jpg", + "s": { + "x": 2560, + "y": 1440, + "u": "https://preview.redd.it/img_g1.jpg?width=2560&crop=smart&auto=webp&s=123" + }, + "p": [ + { + "x": 640, + "y": 360, + "u": "https://preview.redd.it/img_g1.jpg?width=640&crop=smart&auto=webp&s=123" + } + ] + }, + "img_g2": { + "status": "valid", + "m": "image/png", + "s": { + "x": 3840, + "y": 2160, + "u": "https://preview.redd.it/img_g2.png?width=3840&crop=smart&auto=webp&s=456" + } + } + } + } + } + ] + } + } + """.trimIndent() + + val json = RetrofitHelper.json + val listing = json.decodeFromString(sampleJson) + + assertNotNull(listing.data) + assertEquals("t3_post_after_token", listing.data?.after) + assertEquals(2, listing.data?.children?.size) + + val children = listing.data?.children?.mapNotNull { it.data }!! + assertEquals(2, children.size) + + // Single image check + val single = children[0] + assertEquals("Single Wallpaper Image", single.title) + assertEquals("https://i.redd.it/single_pic.jpg", single.url) + assertEquals(3840, single.preview?.images?.firstOrNull()?.source?.width) + assertEquals(2160, single.preview?.images?.firstOrNull()?.source?.height) + + // Gallery check + val gallery = children[1] + assertTrue(gallery.isGallery) + assertEquals(2, gallery.galleryData?.items?.size) + assertEquals(2, gallery.mediaMetadata?.size) + + val firstMedia = gallery.mediaMetadata?.get("img_g1") + assertNotNull(firstMedia) + assertEquals(2560, firstMedia?.s?.x) + assertEquals(1440, firstMedia?.s?.y) + assertTrue(firstMedia?.s?.u?.contains("img_g1.jpg") == true) + } +}