mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
feat: store sign-in walls and the Steam home wall fill with curated featured art (#835)
This commit is contained in:
1 parent
46fac69438
commit
bf99dbbe73
12 files changed
+276
-16
No files matched your search
@@ -0,0 +1,91 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
object SteamFeatured {
|
||||
class Game(val appId: Int, val name: String, val art: String)
|
||||
|
||||
private const val TAG = "SteamFeatured"
|
||||
private const val PREFS = "steam_featured"
|
||||
private const val KEY_GAMES = "games_v2"
|
||||
private const val KEY_AT = "at"
|
||||
private const val FRESH_MS = 6 * 60 * 60 * 1000L
|
||||
private val SECTIONS = listOf("top_sellers", "new_releases", "specials")
|
||||
private const val ASSET_ROOT = "https://shared.akamai.steamstatic.com/store_item_assets/"
|
||||
|
||||
private val HIDDEN_DESCRIPTORS = setOf(1, 3, 4)
|
||||
|
||||
var games by mutableStateOf<List<Game>>(emptyList())
|
||||
private set
|
||||
private val loading = AtomicBoolean(false)
|
||||
|
||||
fun load(context: Context) {
|
||||
val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
if (games.isEmpty()) parse(prefs.getString(KEY_GAMES, null))?.let { games = it }
|
||||
if (System.currentTimeMillis() - prefs.getLong(KEY_AT, 0L) in 0 until FRESH_MS && games.isNotEmpty()) return
|
||||
if (!loading.compareAndSet(false, true)) return
|
||||
Thread({
|
||||
try {
|
||||
val cc = Locale.getDefault().country.takeIf { it.length == 2 } ?: "US"
|
||||
val body = StoreNet.get("https://store.steampowered.com/api/featuredcategories?cc=$cc&l=english") ?: return@Thread
|
||||
val root = JSONObject(body)
|
||||
val ids = LinkedHashSet<Int>()
|
||||
for (section in SECTIONS) {
|
||||
val items = root.optJSONObject(section)?.optJSONArray("items") ?: continue
|
||||
for (i in 0 until items.length()) {
|
||||
val o = items.optJSONObject(i) ?: continue
|
||||
val id = o.optInt("id")
|
||||
if (id > 0 && o.optInt("type", 0) == 0) ids += id
|
||||
}
|
||||
}
|
||||
val list = withArt(ids, cc)
|
||||
if (list.isEmpty()) return@Thread
|
||||
prefs.edit().putString(KEY_GAMES, JSONArray(list.map { JSONObject().put("id", it.appId).put("name", it.name).put("art", it.art) }).toString())
|
||||
.putLong(KEY_AT, System.currentTimeMillis()).apply()
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post { games = list }
|
||||
Log.i(TAG, "featured games: ${list.size}")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "featured: ${e.javaClass.simpleName}")
|
||||
} finally {
|
||||
loading.set(false)
|
||||
}
|
||||
}, "steam-featured").start()
|
||||
}
|
||||
|
||||
private fun withArt(ids: Collection<Int>, cc: String): List<Game> {
|
||||
if (ids.isEmpty()) return emptyList()
|
||||
val input = JSONObject()
|
||||
.put("ids", JSONArray(ids.map { JSONObject().put("appid", it) }))
|
||||
.put("context", JSONObject().put("language", "english").put("country_code", cc))
|
||||
.put("data_request", JSONObject().put("include_assets", true))
|
||||
val body = StoreNet.get(
|
||||
"https://api.steampowered.com/IStoreBrowseService/GetItems/v1/?input_json=" + java.net.URLEncoder.encode(input.toString(), "UTF-8"),
|
||||
) ?: return emptyList()
|
||||
val items = JSONObject(body).optJSONObject("response")?.optJSONArray("store_items") ?: return emptyList()
|
||||
return (0 until items.length()).mapNotNull { i ->
|
||||
val o = items.optJSONObject(i) ?: return@mapNotNull null
|
||||
val descriptors = o.optJSONArray("content_descriptorids")
|
||||
if (descriptors != null && (0 until descriptors.length()).any { descriptors.optInt(it) in HIDDEN_DESCRIPTORS }) return@mapNotNull null
|
||||
val assets = o.optJSONObject("assets") ?: return@mapNotNull null
|
||||
val format = assets.optString("asset_url_format").takeIf { "\${FILENAME}" in it } ?: return@mapNotNull null
|
||||
val file = listOf("library_capsule_2x", "library_capsule").firstNotNullOfOrNull { k -> assets.optString(k).takeIf { it.isNotEmpty() } }
|
||||
?: return@mapNotNull null
|
||||
Game(o.optInt("appid"), o.optString("name"), ASSET_ROOT + format.replace("\${FILENAME}", file))
|
||||
}
|
||||
}
|
||||
|
||||
private fun parse(text: String?): List<Game>? {
|
||||
val a = runCatching { JSONArray(text ?: return null) }.getOrNull() ?: return null
|
||||
return (0 until a.length()).mapNotNull { a.optJSONObject(it) }.map { Game(it.optInt("id"), it.optString("name"), it.optString("art")) }
|
||||
.filter { it.appId > 0 && it.art.isNotEmpty() }.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ class StoreShelves(
|
||||
val deals: List<CatalogItem> = emptyList(),
|
||||
val free: List<CatalogItem> = emptyList(),
|
||||
val trending: List<CatalogItem> = emptyList(),
|
||||
val art: List<String> = emptyList(),
|
||||
) {
|
||||
val isEmpty: Boolean get() = whatsNew.isEmpty() && deals.isEmpty() && free.isEmpty() && trending.isEmpty()
|
||||
val all: List<CatalogItem> get() = (whatsNew + deals + free + trending).distinctBy { it.id }
|
||||
|
||||
@@ -55,7 +55,20 @@ object AmazonBackend : StoreBackend {
|
||||
Thread({ AmazonLibrary.sync(app, force) }, "amazon-sync").start()
|
||||
}
|
||||
|
||||
override fun loadShelves(context: Context, force: Boolean) {}
|
||||
override fun loadShelves(context: Context, force: Boolean) {
|
||||
val app = context.applicationContext
|
||||
AmazonShelves.cached(app)?.let { s -> StoresState.post { withArt(s.art) } }
|
||||
Thread({
|
||||
val shelves = AmazonShelves.featured(app, force)
|
||||
if (shelves != null) StoresState.post { withArt(shelves.art) }
|
||||
}, "amazon-shelves").start()
|
||||
}
|
||||
|
||||
private fun withArt(art: List<String>) {
|
||||
val s = StoresState.shelves[store]
|
||||
StoresState.shelves[store] = if (s == null) com.droiddeck.launcher.stores.StoreShelves(art = art)
|
||||
else com.droiddeck.launcher.stores.StoreShelves(s.whatsNew, s.deals, s.free, s.trending, art)
|
||||
}
|
||||
|
||||
override fun install(context: Context, item: CatalogItem, root: java.io.File) {
|
||||
val app = context.applicationContext
|
||||
|
||||
@@ -102,7 +102,7 @@ object AmazonLibrary {
|
||||
StoresState.post {
|
||||
StoresState.library[store] = mergeItems(StoresState.library[store], items)
|
||||
val installed = StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id }.toSet()
|
||||
StoresState.shelves[store] = StoreShelves(trending = items.filterNot { it.id in installed })
|
||||
StoresState.shelves[store] = StoreShelves(trending = items.filterNot { it.id in installed }, art = StoresState.shelves[store]?.art.orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.droiddeck.launcher.stores.amazon
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import com.droiddeck.launcher.stores.StoreShelves
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
object AmazonShelves {
|
||||
private const val TAG = "AmazonShelves"
|
||||
private const val PAGE = "https://luna.amazon.com/claims/home"
|
||||
private const val GRAPHQL = "https://luna.amazon.com/graphql"
|
||||
private const val KEY_ART = "store_art"
|
||||
private const val KEY_ART_AT = "store_art_at"
|
||||
private const val FRESH_MS = 3 * 60 * 60 * 1000L
|
||||
private const val QUERY = "query { items(collectionType: FEATURED_CONTENT, pageSize: 50) { items { assets { title " +
|
||||
"cardMedia { defaultMedia { src1x src2x } } } } } }"
|
||||
|
||||
@Volatile private var memory: StoreShelves? = null
|
||||
|
||||
fun cached(context: Context): StoreShelves? =
|
||||
memory ?: fromPrefs(AmazonPrefs.get(context).getString(KEY_ART, null))?.also { memory = it }
|
||||
|
||||
fun featured(context: Context, force: Boolean): StoreShelves? {
|
||||
val prefs = AmazonPrefs.get(context)
|
||||
if (!force && System.currentTimeMillis() - prefs.getLong(KEY_ART_AT, 0L) in 0 until FRESH_MS) cached(context)?.let { return it }
|
||||
val art = runCatching { fetch() }.onFailure { Log.w(TAG, "featured: ${it.javaClass.simpleName}") }.getOrNull().orEmpty()
|
||||
if (art.isEmpty()) return cached(context)
|
||||
prefs.edit().putString(KEY_ART, JSONArray(art).toString()).putLong(KEY_ART_AT, System.currentTimeMillis()).apply()
|
||||
Log.i(TAG, "featured art: ${art.size}")
|
||||
return StoreShelves(art = art).also { memory = it }
|
||||
}
|
||||
|
||||
private fun fromPrefs(text: String?): StoreShelves? {
|
||||
val a = runCatching { JSONArray(text ?: return null) }.getOrNull() ?: return null
|
||||
val art = List(a.length()) { a.optString(it) }.filter { it.isNotEmpty() }
|
||||
return if (art.isEmpty()) null else StoreShelves(art = art)
|
||||
}
|
||||
|
||||
private fun fetch(): List<String> {
|
||||
val (page, cookies) = openPage() ?: return emptyList()
|
||||
val token = Regex("""name=['"]csrf-key['"] value=['"]([^'"]+)['"]""").find(page)?.groupValues?.get(1) ?: run {
|
||||
Log.w(TAG, "claims page had no csrf key")
|
||||
return emptyList()
|
||||
}
|
||||
val body = StoreNet.postJson(
|
||||
GRAPHQL, JSONObject().put("query", QUERY).toString(),
|
||||
headers = mapOf("client-id" to "CarboniteApp", "csrf-token" to token, "Cookie" to cookies, "Origin" to "https://luna.amazon.com", "Referer" to PAGE),
|
||||
) ?: return emptyList()
|
||||
val items = JSONObject(body).optJSONObject("data")?.optJSONObject("items")?.optJSONArray("items") ?: return emptyList()
|
||||
return (0 until items.length()).mapNotNull { i ->
|
||||
val media = items.optJSONObject(i)?.optJSONObject("assets")?.optJSONObject("cardMedia")?.optJSONObject("defaultMedia")
|
||||
media?.optString("src2x")?.takeIf { it.isNotEmpty() } ?: media?.optString("src1x")?.takeIf { it.isNotEmpty() }
|
||||
}.distinct()
|
||||
}
|
||||
|
||||
private fun openPage(): Pair<String, String>? {
|
||||
val jar = LinkedHashMap<String, String>()
|
||||
var url = PAGE
|
||||
repeat(5) {
|
||||
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 20_000
|
||||
readTimeout = 20_000
|
||||
instanceFollowRedirects = false
|
||||
setRequestProperty("User-Agent", StoreNet.BROWSER_UA)
|
||||
setRequestProperty("Accept", "text/html")
|
||||
if (jar.isNotEmpty()) setRequestProperty("Cookie", jar.entries.joinToString("; ") { "${it.key}=${it.value}" })
|
||||
}
|
||||
try {
|
||||
val code = conn.responseCode
|
||||
conn.headerFields["Set-Cookie"].orEmpty().forEach { c ->
|
||||
val pair = c.substringBefore(';')
|
||||
val name = pair.substringBefore('=').trim()
|
||||
if (name.isNotEmpty() && '=' in pair) jar[name] = pair.substringAfter('=')
|
||||
}
|
||||
if (code in 300..399) {
|
||||
url = URL(URL(url), conn.getHeaderField("Location") ?: return null).toString()
|
||||
return@repeat
|
||||
}
|
||||
if (code !in 200..299) { Log.w(TAG, "claims page $code"); return null }
|
||||
val page = conn.inputStream.bufferedReader().use { it.readText() }
|
||||
return page to jar.entries.joinToString("; ") { "${it.key}=${it.value}" }
|
||||
} finally {
|
||||
runCatching { conn.disconnect() }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -64,14 +64,16 @@ object EpicShelves {
|
||||
val a = o.optJSONArray(key) ?: return emptyList()
|
||||
return (0 until a.length()).mapNotNull { a.optJSONObject(it) }.mapNotNull(::itemFrom).distinctBy { it.id }
|
||||
}
|
||||
val s = StoreShelves(shelf("new"), shelf("deals"), shelf("free"), shelf("trending"))
|
||||
return if (s.isEmpty) null else s
|
||||
val art = o.optJSONArray("art")?.let { a -> List(a.length()) { a.optString(it) }.filter { it.isNotEmpty() } }.orEmpty()
|
||||
val s = StoreShelves(shelf("new"), shelf("deals"), shelf("free"), shelf("trending"), art)
|
||||
return if (s.isEmpty && art.isEmpty()) null else s
|
||||
}
|
||||
|
||||
fun toJson(s: StoreShelves): String = JSONObject().apply {
|
||||
listOf(s.whatsNew, s.deals, s.free, s.trending).zip(SHELF_KEYS).forEach { (items, key) ->
|
||||
put(key, JSONArray().apply { items.forEach { put(itemJson(it)) } })
|
||||
}
|
||||
put("art", JSONArray(s.art))
|
||||
}.toString()
|
||||
|
||||
fun cached(context: Context): StoreShelves? =
|
||||
@@ -111,6 +113,6 @@ object EpicShelves {
|
||||
releaseDate = owned.releaseDate.ifBlank { shelf.releaseDate },
|
||||
)
|
||||
}
|
||||
return StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending))
|
||||
return StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending), s.art)
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ object GogBackend : StoreBackend {
|
||||
private fun markOwned(s: com.droiddeck.launcher.stores.StoreShelves): com.droiddeck.launcher.stores.StoreShelves {
|
||||
val owned = StoresState.library[store]?.associateBy { it.id } ?: return s
|
||||
fun mark(l: List<CatalogItem>) = l.map { i -> owned[i.id]?.let { o -> i.copy(owned = true, sizeBytes = o.sizeBytes) } ?: i }
|
||||
return com.droiddeck.launcher.stores.StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending))
|
||||
return com.droiddeck.launcher.stores.StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending), s.art)
|
||||
}
|
||||
|
||||
override fun install(context: Context, item: CatalogItem, root: File) {
|
||||
|
||||
@@ -46,10 +46,10 @@ import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
|
||||
@Composable
|
||||
internal fun EmptyLibrary(avoid: Rect?, fade: @Composable () -> Unit) {
|
||||
internal fun EmptyLibrary(avoid: Rect?, wall: (@Composable () -> Unit)? = null, fade: @Composable () -> Unit) {
|
||||
val clock = rememberMarkClock(avoid)
|
||||
Box(Modifier.fillMaxSize().onSizeChanged { clock.page = Size(it.width.toFloat(), it.height.toFloat()) }) {
|
||||
EmptyWall(clock)
|
||||
if (wall != null) wall() else EmptyWall(clock)
|
||||
fade()
|
||||
Mark(clock)
|
||||
}
|
||||
|
||||
@@ -68,11 +68,15 @@ import coil.transform.RoundedCornersTransformation
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val WALL_MIN_GAMES = 4
|
||||
private const val WALL_DISTINCT = 40
|
||||
|
||||
private const val DRIFT_STEP_NS = 15_000_000L
|
||||
|
||||
@Composable
|
||||
internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier) {
|
||||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||
LaunchedEffect(Unit) { com.droiddeck.launcher.frontend.SteamFeatured.load(ctx) }
|
||||
val featured = com.droiddeck.launcher.frontend.SteamFeatured.games
|
||||
val games = remember(s.steamGames) { s.steamGames.sortedWith(compareByDescending<Library.SteamGame> { it.lastPlayed }.thenBy { it.gameId }) }
|
||||
val narrow = LocalNarrowPane.current
|
||||
var words by remember { mutableStateOf<Rect?>(null) }
|
||||
@@ -100,9 +104,12 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
|
||||
start = if (narrow) 20.dp else 44.dp, bottom = if (narrow) 20.dp else 44.dp,
|
||||
background = {
|
||||
when {
|
||||
games.isEmpty() -> EmptyLibrary(avoid = words) { Box(Modifier.fillMaxSize().graphicsLayer { scaleX = 1.06f; scaleY = 1.06f }) { WallFade() } }
|
||||
games.isEmpty() -> EmptyLibrary(
|
||||
avoid = words,
|
||||
wall = if (featured.isEmpty()) null else { { CapsuleWall(emptyList(), featured = featured) } },
|
||||
) { Box(Modifier.fillMaxSize().graphicsLayer { scaleX = 1.06f; scaleY = 1.06f }) { WallFade() } }
|
||||
games.size < WALL_MIN_GAMES -> CapsuleFan(games)
|
||||
else -> CapsuleWall(games)
|
||||
else -> CapsuleWall(games, featured = featured)
|
||||
}
|
||||
},
|
||||
scrim = { if (games.size >= WALL_MIN_GAMES) WallFade() },
|
||||
@@ -143,10 +150,21 @@ internal fun CapsuleWall(
|
||||
games: List<Library.SteamGame>,
|
||||
driftMs: Int = if (games.isEmpty()) 75_000 else 40_000,
|
||||
modifier: Modifier = Modifier,
|
||||
featured: List<com.droiddeck.launcher.frontend.SteamFeatured.Game> = emptyList(),
|
||||
) {
|
||||
val pal = LocalPalette.current
|
||||
val blank = remember(pal) { WallCapsule(Brush.linearGradient(listOf(pal.surface, pal.background)), edge = pal.line) }
|
||||
TiltedWall(games.size, driftMs, modifier) { i -> if (games.isEmpty()) blank else WallCapsule(artBrush(hueOf(games[i].name)), games[i].art) }
|
||||
val padding = remember(games, featured) {
|
||||
val owned = games.map { it.appId }.toSet()
|
||||
featured.filter { it.appId !in owned }.take((WALL_DISTINCT - games.size).coerceAtLeast(0))
|
||||
}
|
||||
TiltedWall(games.size + padding.size, driftMs, modifier) { i ->
|
||||
when {
|
||||
i < games.size -> WallCapsule(artBrush(hueOf(games[i].name)), games[i].art)
|
||||
i - games.size < padding.size -> padding[i - games.size].let { WallCapsule(artBrush(hueOf(it.name)), it.art) }
|
||||
else -> blank
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class WallCapsule(val ground: Brush, val art: Any? = null, val edge: Color? = null, val icon: Any? = null, val bar: Color? = null)
|
||||
|
||||
@@ -649,8 +649,14 @@ private fun SignInCard(store: Store, height: Dp) {
|
||||
val c = sourceColours(store.id)
|
||||
LaunchedEffect(store) { StoresState.preview(ctx, store) }
|
||||
val art = remember(StoresState.shelves[store]) {
|
||||
StoresState.shelves[store]?.all.orEmpty().filter { !it.mature }.mapNotNull { it.tallImageUrl ?: it.imageUrl }.distinct().take(48)
|
||||
.takeIf { it.size >= 8 }.orEmpty()
|
||||
val shelves = StoresState.shelves[store]
|
||||
val urls = (shelves?.art?.takeIf { it.isNotEmpty() }
|
||||
?: shelves?.all.orEmpty().filter { !it.mature && !it.owned }.mapNotNull { it.tallImageUrl ?: it.imageUrl }).distinct().take(48)
|
||||
when {
|
||||
urls.size >= 8 -> urls
|
||||
urls.size >= 4 -> urls + urls.drop(urls.size / 2) + urls.take(urls.size / 2)
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
val shape = RoundedCornerShape(20.dp)
|
||||
val blank = WallCapsule(Brush.linearGradient(listOf(c.fill, colors.background)), edge = c.dot.copy(alpha = 0.22f))
|
||||
|
||||
@@ -155,7 +155,7 @@ synchronous on the calling thread (network calls block: call them off the main t
|
||||
| `build.info` | `accessToken`, `namespace`, `catalogItemId`, `appName` | `info`: the opaque build description (`manifest`, `buildVersion`, `deploymentId`, `cdns[]` of `base`/`dir`/`auth`) |
|
||||
| `build.cdns` | `info` | `cdns[]` |
|
||||
| `build.prereq` | `info` | `name`, `path`, `read` (downloads and parses the manifest) |
|
||||
| `shop.shelves` | `country` | `new`, `deals`, `free`, `trending`: arrays in the `store_featured` item shape plus `allTags` |
|
||||
| `shop.shelves` | `country` | `new`, `deals`, `free`, `trending`: arrays in the `store_featured` item shape plus `allTags`; `art`: cover URLs from every promotion, for the signed-out wall |
|
||||
| `achievements` | `accessToken`, `namespace`, `accountId`, `locale` | `list[]` of `id`, `name`, `description`, `hidden`, `unlocked`, `unlockedAt` (s), `icon`, `iconLocked` |
|
||||
| `cloud.list` | `accessToken`, `accountId`, `appName` | `files[]` of `name`, `modified` (ms, -1 unknown), `hash`, `readLink` |
|
||||
| `cloud.writeLinks` | `accessToken`, `accountId`, `appName`, `names[]` | `links`: name → write link |
|
||||
|
||||
@@ -232,6 +232,28 @@ fn unique(items: impl IntoIterator<Item = Value>) -> Vec<Value> {
|
||||
items.into_iter().filter(|i| seen.insert(net::str_of(i, "id").to_string())).collect()
|
||||
}
|
||||
|
||||
const MATURE_TAGS: [&str; 8] = ["mature", "nsfw", "adult", "adult only", "adults only", "nudity", "sexual content", "sexual-content"];
|
||||
|
||||
fn mature_tags(item: &Value) -> bool {
|
||||
item["allTags"].as_array().is_some_and(|tags| {
|
||||
tags.iter().filter_map(|t| t.as_str()).any(|t| MATURE_TAGS.contains(&t.trim().to_lowercase().as_str()))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn wall_art(promotion_list: &[Value]) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
promotion_list
|
||||
.iter()
|
||||
.filter_map(item_of)
|
||||
.filter(|i| !mature_tags(i))
|
||||
.map(|i| {
|
||||
let tall = net::str_of(&i, "tall");
|
||||
if tall.is_empty() { net::str_of(&i, "image").to_string() } else { tall.to_string() }
|
||||
})
|
||||
.filter(|u| !u.is_empty() && seen.insert(u.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn shelves(country: &str) -> Value {
|
||||
let country = country_code(country);
|
||||
let now = now_stamp();
|
||||
@@ -240,10 +262,12 @@ pub fn shelves(country: &str) -> Value {
|
||||
};
|
||||
let new = unique(shelf(Shelf::New));
|
||||
let deals = unique(shelf(Shelf::Deals).into_iter().filter(|i| i["disc"].as_i64().unwrap_or(0) > 0));
|
||||
let promos: Vec<Value> = promotions(&country).iter().filter_map(free_now).collect();
|
||||
let promotion_list = promotions(&country);
|
||||
let art = wall_art(&promotion_list);
|
||||
let promos: Vec<Value> = promotion_list.iter().filter_map(free_now).collect();
|
||||
let free = unique(promos.into_iter().chain(shelf(Shelf::Free)));
|
||||
let trending = unique(shelf(Shelf::Trending));
|
||||
json!({ "new": new, "deals": deals, "free": free, "trending": trending })
|
||||
json!({ "new": new, "deals": deals, "free": free, "trending": trending, "art": art })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -304,6 +328,19 @@ mod tests {
|
||||
assert!(free_now(&el).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wall_art_takes_every_promotion_once_and_skips_mature() {
|
||||
let mut clean = element();
|
||||
clean["tags"] = json!([{"name": "Action"}]);
|
||||
let mut wide_only = clean.clone();
|
||||
wide_only["id"] = json!("offer2");
|
||||
wide_only["keyImages"] = json!([{"type": "OfferImageWide", "url": "w2"}]);
|
||||
let mut mature = element();
|
||||
mature["id"] = json!("offer3");
|
||||
mature["keyImages"] = json!([{"type": "DieselStoreFrontTall", "url": "m"}]);
|
||||
assert_eq!(wall_art(&[clean.clone(), clean, wide_only, mature]), vec!["tl", "w2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_and_dates() {
|
||||
assert_eq!(country_code("de"), "DE");
|
||||
|
||||
Reference in new issue
Block a user