diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ee1ffd17..d4cc4420 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -33,7 +33,7 @@ + android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize"> diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/CloudLocale.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/CloudLocale.kt index a3d572e3..66f81380 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/CloudLocale.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/CloudLocale.kt @@ -25,6 +25,31 @@ object CloudLocale return "$lang-${cty.uppercase()}" } + /** + * Ordered store locales to try when fetching the catalog. Sony serves a fixed set of + * language-COUNTRY combinations: the country is always valid but the language may not be + * (a Hungarian-language account yields "hu-HU", which 404s, while "en-HU" works). Fall back + * to English for the same country, then en-US, so the catalog loads in every region. + * Each pair is (canonical "ll-CC" for storage, lowercased "ll-cc" for the imagic URL). + */ + fun fallbackChain(stored: String): List> + { + val (country, language) = parseStorePath(stored) + val seen = LinkedHashSet() + val chain = mutableListOf>() + fun add(lang: String, ctry: String) + { + val canonical = "$lang-$ctry" + val imagic = canonical.lowercase() + if (seen.add(imagic)) + chain.add(canonical to imagic) + } + add(language, country) + add("en", country) + add("en", "US") + return chain + } + /** Non-fatal warning when locale could not be learned from Kamaji (catalog may use en-US). */ fun unconfiguredWarning(): String = "Could not detect your PlayStation region. The catalog may not match your store." diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/PsnApiConstants.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/PsnApiConstants.kt index 3decfc13..39235f76 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/PsnApiConstants.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/PsnApiConstants.kt @@ -22,7 +22,43 @@ object PsnApiConstants const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) playstation-now/0.0.0 Chrome/83.0.4103.104 Electron/9.0.4 Safari/537.36 gkApollo" const val PS4_SCOPES = "kamaji:commerce_native kamaji:commerce_container kamaji:lists kamaji:s2s.subscriptionsPremium.get" - + const val ROOT_CONTAINER_ID = "STORE-MSF75508-PSNOWALLGAMES" } +/** + * PS3 / Classics pcnow store helpers, by account region group. + * Mirrors KamajiConsts (gui/include/cloudstreaming/pskamajisession.h) exactly. + * + * pcnow (the PS Plus PC "Apollo" backend) has only TWO Classics id families: + * - SCEA / Americas -> store MSF192018, US-region ids (UP/NPUA/BLUS), + * PS3 child container "APOLLOPS3GAMES" + * - SCEE / PAL (rest) -> store MSF192014, EU-region ids (EP/NPEA/NPEB/BLES), + * PS3 child container "APOLLOPS3" + * JP / Asia have no Apollo store (the PC app isn't offered there), so they fall back to + * PAL. A PS Plus account is authorized at Gaikai only for the id family of its own region + * group, so the catalog must be browsed + resolved in the account's group. Region is keyed + * by the ACCOUNT's region group, NOT by parsing the product-id prefix. + */ +object KamajiClassics +{ + private val AMERICAS = setOf( + "US", "CA", "MX", "BR", "AR", "CL", "CO", "PE", "EC", "BO", "PY", "UY", + "CR", "GT", "HN", "NI", "PA", "SV", "DO" + ) + + fun isAmericasClassicsRegion(countryCode: String): Boolean = + AMERICAS.contains(countryCode.uppercase()) + + /** Country path to use for container/conversion calls (US for Americas, GB for PAL). */ + fun classicsStoreCountry(accountCountry: String): String = + if (isAmericasClassicsRegion(accountCountry)) "US" else "GB" + + /** Fully-qualified PS3 catalog container id for the account's region group. */ + fun classicsPs3ContainerId(accountCountry: String): String = + if (isAmericasClassicsRegion(accountCountry)) + "STORE-MSF192018-APOLLOPS3GAMES" + else + "STORE-MSF192014-APOLLOPS3" +} + diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt index b995de55..a632df15 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt @@ -309,8 +309,23 @@ class PSKamajiSession( try { val localeSetting = preferences.getCloudLanguage() - val (country, language) = com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(localeSetting) + var (country, language) = com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(localeSetting) Log.i(TAG, "Using locale from settings: $localeSetting -> country=$country, language=$language") + + // PS3 / Classics product ids (NPEA/NPEB/BLES or NPUA/NPUB/BLUS -- anything that isn't a modern + // CUSA/PPSA id) come from the public Apollo catalog, which we walk in the account's region + // group (Americas -> US store, everything else -> PAL/GB). Resolve them against that SAME + // region's container so the lookup finds the product and returns the PSNW entitlement the + // account is authorized for at Gaikai. The account's own locale country can be a region with + // no pcnow storefront (e.g. Hungary -> "Storefront not found") and the raw locale 404s, so map + // to the region-group store. Must match the PS3 catalog source (fetchPs3ClassicsCatalog). + val isLegacyClassicsId = !productId.contains("CUSA") && !productId.contains("PPSA") + if (isLegacyClassicsId) + { + country = com.metallic.chiaki.cloudplay.KamajiClassics.classicsStoreCountry(country) + language = "en" + Log.i(TAG, "Legacy Classics id -> region-group container: country=$country, language=$language") + } val url = "$storeBase/container/$country/$language/19/$productId?useOffers=true&gkb=1&gkb2=1" Log.d(TAG, "Step 0.5d: Convert Product ID") @@ -412,6 +427,44 @@ class PSKamajiSession( } } + + // Full-game fallback: PS Plus catalog titles have no license_type==4 entitlement; their + // full-game entitlement is license_type 0 with packageType "*GD". Prefer the one whose id + // matches the requested product's title id so cross-gen picks the consistent platform. + if (streamingEntitlementId.isEmpty()) + { + val requestedTitleId = productId.split("-").getOrNull(1)?.split("_")?.firstOrNull() ?: "" + fun pickFullGameEntitlement(skuObj: JSONObject, requireTitleMatch: Boolean): Boolean + { + val ents = skuObj.optJSONArray("entitlements") ?: return false + for (j in 0 until ents.length()) + { + val ent = ents.getJSONObject(j) + val entId = ent.optString("id", "") + val pkg = ent.optString("packageType", "") + if (entId.isEmpty() || !pkg.endsWith("GD")) continue + if (requireTitleMatch && requestedTitleId.isNotEmpty() && !entId.contains(requestedTitleId)) continue + streamingEntitlementId = entId + sku = skuObj.optString("id", "") + Log.i(TAG, "Found full-game Entitlement ID (PS Plus catalog fallback): $streamingEntitlementId packageType: $pkg titleMatch: $requireTitleMatch") + return true + } + return false + } + for (requireTitleMatch in listOf(true, false)) + { + if (json.has("default_sku") && pickFullGameEntitlement(json.getJSONObject("default_sku"), requireTitleMatch)) break + if (streamingEntitlementId.isEmpty() && json.has("skus")) + { + val skus = json.getJSONArray("skus") + for (i in 0 until skus.length()) + { + if (pickFullGameEntitlement(skus.getJSONObject(i), requireTitleMatch)) break + } + } + if (streamingEntitlementId.isNotEmpty()) break + } + } // Try to extract platform from playable_platform if (json.has("playable_platform")) { @@ -522,9 +575,24 @@ class PSKamajiSession( return true } - // User doesn't have entitlement (404), try to acquire it + // User doesn't have the per-game entitlement on the account (404). + // PS3 / Classics: the streaming entitlement is granted by the PS Plus subscription (a free + // 100%-off checkout), but that checkout requires a pcnow storefront in the account's region + // -- which many regions (e.g. Hungary) don't have, so the acquire fails with "Against + // Eligibility Rule". On a real PS5 the subscription alone grants streaming with no purchase, + // so skip the acquire and let Gaikai validate the Premium subscription directly. If Gaikai + // genuinely needs the entitlement, it returns noGameForEntitlementId downstream. + // CUSA/PS4 and PPSA/PS5 keep the existing acquire behavior. + val isLegacyClassicsId = !productId.contains("CUSA") && !productId.contains("PPSA") + if (isLegacyClassicsId) + { + Log.i(TAG, "Kamaji Step 0.5e.2 - Entitlement not found (404); legacy Classics id -> skipping acquire, proceeding to Gaikai") + return true + } + + // PS4/PS5 catalog: try to acquire it via checkout. Log.i(TAG, "Kamaji Step 0.5e.2 - Entitlement not found (404), will attempt to acquire") - + // Step 0.5e.3: Checkout preview // Throws PsPlusSubscriptionException if user doesn't have required subscription val previewOk = step0_5e3_CheckoutPreview(sessionId) diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudCatalogService.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudCatalogService.kt index e80a54b0..cfcce94e 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudCatalogService.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudCatalogService.kt @@ -124,6 +124,7 @@ class PsCloudCatalogService productIdAliases: LinkedHashMap, ): Int { + val plusCatalog = isPlusCatalogList(categoryList) // subscription catalog vs all-ps5 universe var rows = 0 for (i in 0 until jsonArray.length()) { @@ -132,64 +133,80 @@ class PsCloudCatalogService for (j in 0 until games.length()) { val gameObj = games.getJSONObject(j) - if (!isPs5Game(gameObj)) + // Accept PS4 and PS5; the old PS5-only gate dropped PS4-only PS-Plus-catalog + // titles (e.g. God of War 2018) before they could reach the supplement below. + if (!isCloudDeviceGame(gameObj)) continue - if (categoryList == "plus-games-list" - && !gameObj.optBoolean("streamingSupported", false)) + // Subscription-catalog titles with streamingSupported=false → library-stream + // supplement, captured from EVERY subscription list (not just plus-games-list). + if (plusCatalog && !gameObj.optBoolean("streamingSupported", false)) { val productId = gameObj.optString("productId", "") if (productId.isNotEmpty()) + { + gameObj.put("plusCatalog", true) plusSupplementByProductId.putIfAbsent(productId, gameObj) + } continue } - if (!isPs5StreamingGame(gameObj)) + if (!isCloudStreamingGame(gameObj)) continue - val key = conceptKey(gameObj) + val key = editionKey(gameObj) // per game per platform (cross-gen split) val productId = gameObj.optString("productId", "") if (key.isEmpty() || productId.isEmpty()) continue if (byConceptId.containsKey(key)) { - val canonicalProductId = byConceptId[key]?.optString("productId", "") ?: "" + val existing = byConceptId[key] + val canonicalProductId = existing?.optString("productId", "") ?: "" if (canonicalProductId.isNotEmpty() && productId != canonicalProductId && !productIdAliases.containsKey(productId)) { productIdAliases[productId] = canonicalProductId } + // Lists fetch in parallel; upgrade the flag so subscription membership wins + // regardless of arrival order. + if (plusCatalog && existing != null && !existing.optBoolean("plusCatalog", false)) + existing.put("plusCatalog", true) continue } + gameObj.put("plusCatalog", plusCatalog) byConceptId[key] = gameObj } } return rows } - private fun isPs5Game(gameObj: JSONObject): Boolean + // PS Plus cloud streaming covers PS4 and PS5 titles (PS3 is not in these imagic lists). + // A PS4-only title such as God of War (2018) is streamable when owned even though it + // carries device ["PS4"], so the catalog must not discard it. + // The PS Plus subscription catalog = these curated lists (≈ what Sony lists). all-ps5-list is + // the full streamable universe and must NOT count as subscription catalog. + private fun isPlusCatalogList(categoryList: String): Boolean = + categoryList == "plus-games-list" || categoryList == "plus-classics-list" || + categoryList == "ubisoft-classics-list" || categoryList == "plus-monthly-games-list" + + private fun isCloudDeviceGame(gameObj: JSONObject): Boolean { val devices = gameObj.optJSONArray("device") ?: return false for (i in 0 until devices.length()) { - if (devices.optString(i) == "PS5") + val d = devices.optString(i) + if (d == "PS5" || d == "PS4") return true } return false } - private fun isPs5StreamingGame(gameObj: JSONObject): Boolean + private fun isCloudStreamingGame(gameObj: JSONObject): Boolean { if (!gameObj.optBoolean("streamingSupported", false)) return false - val devices = gameObj.optJSONArray("device") ?: return false - for (i in 0 until devices.length()) - { - if (devices.optString(i) == "PS5") - return true - } - return false + return isCloudDeviceGame(gameObj) } private fun conceptKey(gameObj: JSONObject): String @@ -205,6 +222,23 @@ class PsCloudCatalogService return gameObj.optString("productId", "") } + // Platform token from a product id (CUSA = PS4, PPSA = PS5). + private fun ps5PlatformToken(productId: String): String = when + { + productId.contains("PPSA") -> "ps5" + productId.contains("CUSA") -> "ps4" + else -> "" + } + + // Dedupe identity: one entry per game PER PLATFORM, so cross-gen PS4/PS5 editions (e.g. Deliver + // Us The Moon) both appear, while duplicate same-platform SKUs still collapse. + private fun editionKey(gameObj: JSONObject): String + { + val c = conceptKey(gameObj) + if (c.isEmpty()) return "" + return c + "|" + ps5PlatformToken(gameObj.optString("productId", "")) + } + private fun jsonToCloudGame(gameObj: JSONObject): CloudGame? { val productId = gameObj.optString("productId", "") @@ -261,11 +295,12 @@ class PsCloudCatalogService name = gameName, imageUrl = finalCoverUrl, landscapeImageUrl = finalLandscapeUrl, - platform = "ps5", + platform = ps5PlatformToken(productId).ifEmpty { "ps5" }, serviceType = "pscloud", conceptUrl = conceptUrl, conceptId = conceptKey(gameObj), - isOwned = false + isOwned = false, + plusCatalog = gameObj.optBoolean("plusCatalog", false) ) } @@ -315,16 +350,17 @@ class PsCloudCatalogService kotlinx.coroutines.delay(PsCloudOwnership.PAGE_COOLDOWN_MS) val rawEntitlements = fetchEntitlementsPaginated(oauthToken) - val componentIdsByProductId = HashMap>() - for (e in rawEntitlements) - { - if (e.productId.isNotEmpty() && e.id.isNotEmpty()) - componentIdsByProductId.getOrPut(e.productId) { mutableListOf() }.add(e.id) - } val filtered = PsCloudOwnership.filterOwnedPs5Games(rawEntitlements) + // Map each bundle product_id -> the entitlement ids sharing it, so a bundle (e.g. RE7 Gold) + // expands to its component games during cross-reference (upstream PR #15 bundle-sibling match). + val componentIds = mutableMapOf>() + for (ent in rawEntitlements) + if (ent.productId.isNotEmpty() && ent.id.isNotEmpty()) + componentIds.getOrPut(ent.productId) { mutableListOf() }.add(ent.id) + return PsCloudOwnership.crossReferenceOwnedGames( - filtered, publicCatalog, plusLibrarySupplement, productIdAliases, componentIdsByProductId + filtered, publicCatalog, plusLibrarySupplement, productIdAliases, componentIds ) } @@ -441,6 +477,124 @@ class PsCloudCatalogService return all } + // --------------------------------------------------------------------------- + // PS3 Classics catalog (public Apollo container walk) + // + // The PS Plus PC ("Apollo") app browses the streamable catalog through the public pcnow + // container API at psnow.playstation.com. There is a dedicated PS3 container that lists the + // streamable PS3 titles with their PS3 product ids (NPUA/NPUB/BLUS/EP9000/...) -- none of + // which appear in the imagic gameslist the rest of the catalog uses. The container API needs + // no OAuth or per-account session (unlike /user/stores, which 404s in regions where the PC + // app is unavailable, e.g. Hungary), so we can walk it directly in any region. The resulting + // titles carry playable_platform ["PS3"] and stream via the existing PSNOW -> Gaikai konan + // path. Mirrors CloudCatalogBackend::fetchPs3Catalog() (Qt). + // + // pcnow has two Classics id families (Americas/SCEA and PAL/SCEE); a PS Plus account is + // authorized at Gaikai only for the family of its own region group, so the catalog must be + // browsed in that group. See KamajiClassics.classicsStoreCountry / classicsPs3ContainerId. + // --------------------------------------------------------------------------- + suspend fun fetchPs3ClassicsCatalog(accountCountry: String): List + { + val storeCountry = com.metallic.chiaki.cloudplay.KamajiClassics.classicsStoreCountry(accountCountry) + val containerId = com.metallic.chiaki.cloudplay.KamajiClassics.classicsPs3ContainerId(accountCountry) + val containerUrl = + "https://psnow.playstation.com/store/api/pcnow/00_09_000/container/$storeCountry/en/19/$containerId" + + Log.i(TAG, "=== Fetching PS3 Classics catalog (region group $storeCountry for account country $accountCountry) ===") + + val games = mutableListOf() + var start = 0 + var totalResults = -1 + + while (true) + { + val url = "$containerUrl?useOffers=true&gkb=1&gkb2=1&start=$start&size=100" + val response = HttpClient.get( + url = url, + headers = mapOf( + "Accept" to "application/json", + "User-Agent" to PsnApiConstants.USER_AGENT + ) + ) + + if (response.statusCode != 200) + { + Log.w(TAG, "PS3 catalog page fetch failed (HTTP ${response.statusCode})") + if (games.isEmpty()) + throw Exception("Failed to fetch PS3 Classics catalog: HTTP ${response.statusCode}") + break // Partial data already collected: return what we have. + } + + val obj = JSONObject(response.body) + if (totalResults < 0) + totalResults = obj.optInt("total_results", 0) + + val links = obj.optJSONArray("links") ?: JSONArray() + var productCount = 0 + for (i in 0 until links.length()) + { + val g = links.optJSONObject(i) ?: continue + if (g.optString("container_type") != "product") + continue + ps3JsonToCloudGame(g)?.let { games.add(it); productCount++ } + } + + Log.i(TAG, " PS3 page products: $productCount, accumulated: ${games.size} of $totalResults") + + start += 100 + if (productCount == 0 || start >= totalResults) + break + } + + Log.i(TAG, " PS3 Classics catalog complete: ${games.size} titles") + return games + } + + // Map a single pcnow PS3 container product into a CloudGame. The streaming id is the + // product `id` (Kamaji converts it -> entitlement -> Gaikai). Platform is detected from + // playable_platform containing "PS3" like the PSNow parser does. + private fun ps3JsonToCloudGame(gameObj: JSONObject): CloudGame? + { + val productId = gameObj.optString("id") + val name = gameObj.optString("name") + if (productId.isEmpty() || name.isEmpty()) + return null + + val (coverUrl, landscapeUrl) = extractImageUrls(gameObj) + var imageUrl = coverUrl + var landscapeImageUrl = landscapeUrl + if (imageUrl.startsWith("http://")) + imageUrl = imageUrl.replace("http://", "https://") + if (landscapeImageUrl.startsWith("http://")) + landscapeImageUrl = landscapeImageUrl.replace("http://", "https://") + + // Detect PS3 from playable_platform (matches the PSNow parser); default to ps3 for this + // container since every product in it is a streamable PS3 Classic. + var platform = "ps3" + val playablePlatformArray = gameObj.optJSONArray("playable_platform") + if (playablePlatformArray != null && playablePlatformArray.length() > 0) + { + for (i in 0 until playablePlatformArray.length()) + { + val platformStr = playablePlatformArray.optString(i, "").uppercase() + if (platformStr.contains("PS3")) + { + platform = "ps3" + break + } + } + } + + return CloudGame( + productId = productId, + name = name, + imageUrl = imageUrl, + landscapeImageUrl = landscapeImageUrl, + platform = platform, + serviceType = "psnow" // subscription-streamable via the PSNow/Gaikai konan path + ) + } + /** * Extract both cover and landscape image URLs from game object * Returns Pair diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt index 6ae7e5eb..5a054b8e 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PsCloudOwnership.kt @@ -2,11 +2,13 @@ package com.metallic.chiaki.cloudplay.api +import android.util.Log import com.metallic.chiaki.cloudplay.model.CloudGame import org.json.JSONObject object PsCloudOwnership { + private const val TAG = "PsCloudOwnership" const val PAGE_SIZE = 300 const val PAGE_COOLDOWN_MS = 100L @@ -15,7 +17,9 @@ object PsCloudOwnership val productId: String, val activeFlag: Boolean, val packageType: String, - val name: String + val name: String, + val conceptId: String, + val featureType: Int // PSN feature_type: 3=full game, 1=trial/free, 0=add-on/DLC ) private data class CatalogIndex( @@ -26,25 +30,44 @@ object PsCloudOwnership fun filterOwnedPs5Games(entitlements: List): List { return entitlements.filter { ent -> - ent.packageType == "PSGD" && - ent.activeFlag && + // Previously required packageType == "PSGD" (PS5 only), which dropped owned PS4 + // titles (e.g. God of War 2018) and PS3 titles. Accept every active game entitlement; + // streamability is enforced downstream by the cross-reference (deduped by conceptId), + // so non-streamable / add-on entitlements are harmlessly dropped there. + ent.activeFlag && !ent.productId.startsWith("IP") && - !ent.productId.startsWith("SUB") + !ent.productId.startsWith("SUB") && + // Hide EXTRAS: feature_type==0 is DLC/add-ons/themes/avatars/tracks, never a base game + // (games are ft 1=trial/free or 3/5=full). Safe -- it can never hide a game. + ent.featureType != 0 } } + /** Normalize a conceptId (imagic encodes it as a number) to a non-empty string, else "". */ + private fun conceptIdString(value: Any?): String = when (value) + { + is Number -> value.toLong().let { if (it > 0) it.toString() else "" } + is String -> value + else -> "" + } + fun parseEntitlement(obj: JSONObject): Entitlement? { val id = obj.optString("id", "") if (id.isEmpty()) return null val gameMeta = obj.optJSONObject("game_meta") ?: JSONObject() val name = gameMeta.optString("name", id) + val conceptId = conceptIdString(gameMeta.opt("conceptId")) + .ifEmpty { conceptIdString(gameMeta.opt("concept_id")) } + .ifEmpty { conceptIdString(obj.opt("conceptId")) } return Entitlement( id = id, productId = obj.optString("product_id", ""), activeFlag = obj.optBoolean("active_flag", false), packageType = gameMeta.optString("package_type", ""), - name = name + name = name, + conceptId = conceptId, + featureType = obj.optInt("feature_type", 0) ) } @@ -66,106 +89,208 @@ object PsCloudOwnership val supplementMap = catalogMapFirstWins(plusLibrarySupplement) val browseStableKey = buildStableKeyIndex(publicCatalog) val supplementStableKey = buildStableKeyIndex(plusLibrarySupplement) + val browseByConcept = buildConceptIdIndex(publicCatalog) + val supplementByConcept = buildConceptIdIndex(plusLibrarySupplement) val byKey = linkedMapOf() + val byKeyRank = mutableMapOf() - fun emitMatch(meta: CloudGame, ent: Entitlement) + // Enrich one matched catalog row into an owned CloudGame and dedupe it into byKey, keeping OUR + // convention (conceptId+PLATFORM dedupe, canonical-entitlement rank). Called once for a direct + // match, or once per component for a bundle (upstream PR #15 bundle-sibling matching). + fun emit(meta: CloudGame, ent: Entitlement) { val displayName = meta.name.ifEmpty { ent.name } val game = meta.copy( name = displayName, isOwned = true, entitlementId = ent.id, - storeProductId = ent.productId + storeProductId = ent.productId, + featureType = ent.featureType ) val key = ownedDedupeKey(meta, ent) - val existing = byKey[key] - byKey[key] = if (existing == null) game else preferOwnedEntry(existing, game) + val candidateRank = ownedStreamRank(ent) + if (byKey[key] == null) + { + byKey[key] = game + byKeyRank[key] = candidateRank + } + // Keep the best streaming candidate: the canonical full-game entitlement (its product_id is + // the real streamable game, not a DLC/bonus product Gaikai rejects). + else if (candidateRank > (byKeyRank[key] ?: -1)) + { + byKey[key] = game + byKeyRank[key] = candidateRank + } } for (ent in filteredEntitlements) { + val stable = productIdStableKey(ent.productId) + val entStable = productIdStableKey(ent.id) val skipStableDemo = ent.name.contains("demo", ignoreCase = true) - val matches = mutableListOf() - - if (ent.productId.isNotEmpty() && catalogMap.containsKey(ent.productId)) - { - matches.add(catalogMap.getValue(ent.productId)) + val meta = when { + ent.productId.isNotEmpty() && catalogMap.containsKey(ent.productId) -> + catalogMap[ent.productId] + ent.id.isNotEmpty() && catalogMap.containsKey(ent.id) -> + catalogMap[ent.id] + // conceptId is region-stable; product IDs are region-prefixed (EP9000 vs UP9000). + ent.conceptId.isNotEmpty() && browseByConcept.containsKey(ent.conceptId) -> + browseByConcept[ent.conceptId] + ent.conceptId.isNotEmpty() && supplementByConcept.containsKey(ent.conceptId) -> + supplementByConcept[ent.conceptId] + ent.productId.isNotEmpty() && ent.id == ent.productId + && supplementMap.containsKey(ent.productId) -> + supplementMap[ent.productId] + stable != null && !skipStableDemo && browseStableKey.containsKey(stable) -> + browseStableKey[stable] + stable != null && !skipStableDemo && supplementStableKey.containsKey(stable) -> + supplementStableKey[stable] + // Stable-key match on the ENTITLEMENT id (upstream PR #15): catches cross-gen / upgrade + // entitlement ids whose stable key matches a catalog row even when product_id did not. + entStable != null && !skipStableDemo && browseStableKey.containsKey(entStable) -> + browseStableKey[entStable] + entStable != null && !skipStableDemo && supplementStableKey.containsKey(entStable) -> + supplementStableKey[entStable] + else -> null } - else if (ent.id.isNotEmpty() && catalogMap.containsKey(ent.id)) + + if (meta != null) { - matches.add(catalogMap.getValue(ent.id)) + emit(meta, ent) + continue } - else if (ent.productId.isNotEmpty() && ent.id == ent.productId - && supplementMap.containsKey(ent.productId)) + + // Bundle-sibling expansion (upstream PR #15): a bundle entitlement (e.g. RE7 Gold) has no + // direct catalog row, but its component entitlement ids each map to a component game. + val seenPids = mutableSetOf() + for (siblingId in componentIdsByProductId[ent.productId] ?: emptyList()) { - matches.add(supplementMap.getValue(ent.productId)) + val siblingMeta = when { + catalogMap.containsKey(siblingId) -> catalogMap[siblingId] + supplementMap.containsKey(siblingId) -> supplementMap[siblingId] + else -> { + val s2 = productIdStableKey(siblingId) + if (s2 != null && !skipStableDemo) browseStableKey[s2] ?: supplementStableKey[s2] else null + } + } ?: continue + if (siblingMeta.productId.isEmpty() || seenPids.contains(siblingMeta.productId)) continue + seenPids.add(siblingMeta.productId) + emit(siblingMeta, ent) } - else + } + + // Disc-upgrade rescue (mirrors cloudcatalogbackend.cpp). feature_type 5 is a PS4-disc -> PS5 + // *disc upgrade* license; Gaikai refuses to cloud-stream it ("disc-upgrade-unsupported"). The + // browse catalog often binds the concept to exactly that SKU (e.g. Horizon Forbidden West + // concept 10000886 -> PPSA01521), while the user's streamable full-game entitlement is a + // DIFFERENT title id (e.g. Complete Edition PPSA17903) that is absent from the catalog and + // carries no conceptId -- so it never matches and only the disc-upgrade SKU survives. When a + // concept winner is a disc upgrade, adopt a same-name full-game (feature_type 3) owned SKU's + // product id so the card streams the edition Gaikai accepts. + // + // Entitlements carry no conceptId and the disc-upgrade SKU shares no id/sku with the real + // edition, so the only in-data bridge is the title name. To keep that safe: SAME PLATFORM only + // (a PS5/PPSA disc upgrade must never resolve to a PS4/CUSA SKU), prefer the canonical base game + // (product_id == entitlement id), and BAIL on genuine ambiguity rather than guess. + for (key in byKey.keys.toList()) + { + val game = byKey[key] ?: continue + if (game.featureType != 5) continue + val discPid = game.storeProductId + val discPlatform = platformToken(discPid) + val discEnt = filteredEntitlements.firstOrNull { + it.productId == discPid && it.featureType == 5 + } ?: continue + val discName = normalizeTitle(discEnt.name) + if (discName.isEmpty()) continue + val canonical = mutableListOf() // base-game SKUs (product_id == entitlement id) + val other = mutableListOf() // non-canonical full-game SKUs + for (cand in filteredEntitlements) { - val entitlementStableKey = productIdStableKey(ent.id) - if (entitlementStableKey != null && !skipStableDemo - && browseStableKey.containsKey(entitlementStableKey)) + if (cand.featureType != 3) continue + if (normalizeTitle(cand.name) != discName) continue + val candPid = cand.productId + if (candPid.isEmpty() || candPid == discPid) continue + if (platformToken(candPid) != discPlatform) continue + if (candPid == cand.id) { - matches.add(browseStableKey.getValue(entitlementStableKey)) + if (candPid !in canonical) canonical.add(candPid) } - else if (entitlementStableKey != null && !skipStableDemo - && supplementStableKey.containsKey(entitlementStableKey)) + else if (candPid !in other) { - matches.add(supplementStableKey.getValue(entitlementStableKey)) + other.add(candPid) } } - - if (matches.isEmpty()) + val replacement = when { - val seenProductIds = mutableSetOf() - for (siblingId in componentIdsByProductId[ent.productId].orEmpty()) - { - val siblingMeta = when - { - catalogMap.containsKey(siblingId) -> catalogMap[siblingId] - supplementMap.containsKey(siblingId) -> supplementMap[siblingId] - else -> - { - val siblingStableKey = productIdStableKey(siblingId) - if (siblingStableKey != null && !skipStableDemo) - browseStableKey[siblingStableKey] - ?: supplementStableKey[siblingStableKey] - else - null - } - } ?: continue - if (siblingMeta.productId.isEmpty() || siblingMeta.productId in seenProductIds) - continue - seenProductIds.add(siblingMeta.productId) - matches.add(siblingMeta) - } + canonical.size == 1 -> canonical[0] + canonical.isEmpty() && other.size == 1 -> other[0] + else -> null } - - if (matches.isEmpty()) + if (replacement == null) + { + if (canonical.isNotEmpty() || other.isNotEmpty()) + Log.w(TAG, "disc-upgrade rescue: ambiguous candidates for $discName -- leaving disc SKU") continue - - for (meta in matches) - emitMatch(meta, ent) + } + byKey[key] = game.copy(storeProductId = replacement) + Log.i(TAG, "disc-upgrade rescue: $discName $discPid -> $replacement") } return byKey.values.toList() } + // Edition identity = conceptId + PLATFORM (matching the catalog's edition key), so a cross-gen + // title owned on both PS4 and PS5 stays as two separate library entries instead of collapsing + // into one. Same-platform duplicate SKUs (a remaster's add-ons) still merge. private fun ownedDedupeKey(meta: CloudGame, ent: Entitlement): String { - if (meta.conceptId.isNotEmpty()) return "c:${meta.conceptId}" + if (meta.conceptId.isNotEmpty()) return "c:${meta.conceptId}:${platformToken(ent.productId)}" if (meta.productId.isNotEmpty()) return "p:${meta.productId}" if (ent.id.isNotEmpty()) return "e:${ent.id}" return "u:${meta.productId}:${ent.id}" } - private fun preferOwnedEntry(existing: CloudGame, candidate: CloudGame): CloudGame + /** Platform token from a product id (CUSA = PS4, PPSA = PS5). */ + private fun platformToken(productId: String): String = when { - return when - { - existing.entitlementId.isEmpty() && candidate.entitlementId.isNotEmpty() -> candidate - else -> existing - } + productId.contains("PPSA") -> "ps5" + productId.contains("CUSA") -> "ps4" + else -> "" + } + + /** Lowercase, strip trademark/registered/service-mark glyphs, and collapse whitespace so two owned + * entitlements for the same game compare equal across punctuation/spacing differences. */ + private fun normalizeTitle(raw: String): String = + raw.lowercase() + .replace("™", "").replace("®", "").replace("℠", "") + .trim().split(Regex("\\s+")).filter { it.isNotEmpty() }.joinToString(" ") + + /** A full-game entitlement (vs add-on/avatar): base game has a *GD package_type. */ + private fun isFullGameEntitlement(ent: Entitlement): Boolean = + ent.featureType == 3 || ent.packageType.endsWith("GD") + + // Rank an owned entitlement as THE streaming candidate for its edition (higher = preferred). + // Bonus/upgrade SKUs collapse to the same conceptId+platform as the base game; package/feature + // flags don't disambiguate (Death Stranding DC's "Bonus Content" is also PSGD + feature_type 3). + // The reliable signal: the base game's entitlement id EQUALS its product_id, while bonus/upgrade + // SKUs carry a different id -- so prefer the canonical full-game entitlement. + private fun ownedStreamRank(ent: Entitlement): Int + { + var rank = 0 + if (ent.productId.isNotEmpty() && ent.productId == ent.id) rank += 4 // canonical base-game SKU + if (isFullGameEntitlement(ent)) rank += 2 + if (ent.id.isNotEmpty()) rank += 1 + return rank + } + + /** conceptId + platform; the owned product id (storeProductId) takes precedence so the owned + * edition's platform is used, else the catalog product id. */ + private fun conceptPlatformKey(game: CloudGame): String + { + if (game.conceptId.isEmpty()) return "" + val pid = if (game.storeProductId.isNotEmpty()) game.storeProductId else game.productId + return "${game.conceptId}|${platformToken(pid)}" } private fun catalogMapFirstWins(games: List): MutableMap @@ -210,9 +335,21 @@ object PsCloudOwnership return index } + private fun buildConceptIdIndex(games: List): Map + { + val index = linkedMapOf() + for (game in games) + { + if (game.conceptId.isNotEmpty() && game.conceptId !in index) + index[game.conceptId] = game + } + return index + } + fun mergeOwnedIntoBrowseCatalog( browseCatalog: List, - ownedCrossRef: List + ownedCrossRef: List, + addUnmatched: Boolean = true // false = only mark ownership (Catalog tab), never add ): List { val games = browseCatalog.toMutableList() @@ -220,7 +357,10 @@ object PsCloudOwnership for (owned in ownedCrossRef) { - val catalogMatch = findCatalogIndexForOwned(owned, catalogIndex) + // Trials / free-to-play (feature_type 1) are kept as their OWN card so the user can Stream + // the trial/free build, while the full version still shows separately as a not-owned + // "Add Game" card -- so a trial must NOT collapse into the full-game catalog entry. + val catalogMatch = if (owned.featureType == 1) -1 else findCatalogIndexForOwned(owned, catalogIndex) if (catalogMatch >= 0) { val existing = games[catalogMatch] @@ -232,6 +372,7 @@ object PsCloudOwnership continue } + if (!addUnmatched) continue val entry = owned.copy(isOwned = true) registerInCatalogIndex(entry, games.size, catalogIndex) games.add(entry) @@ -247,12 +388,45 @@ object PsCloudOwnership { if (game.serviceType.equals("pscloud", ignoreCase = true)) { - if (game.entitlementId.isNotEmpty()) return game.entitlementId + // Stream the owned PRODUCT id (storeProductId) before the entitlement id: for cross-gen + // upgrades the entitlement id is the stale original SKU Gaikai has no game for. if (game.storeProductId.isNotEmpty()) return game.storeProductId + if (game.entitlementId.isNotEmpty()) return game.entitlementId } return game.productId } + // A PlayStation title id encodes its platform: CUSAxxxxx = PS4, PPSAxxxxx = PS5. This is more + // reliable than the catalog device list and decides the streaming path: PS4 catalog titles go + // through Kamaji (psnow) to acquire the streaming entitlement, PS5 streams directly (pscloud). + fun streamPlatform(game: CloudGame): String + { + // Prefer the OWNED product id (storeProductId): for a cross-gen title you upgraded, the catalog + // productId may be the other generation (Alan Wake catalog = PS4 CUSA, but you own the PS5 PPSA). + val p = game.storeProductId.ifEmpty { game.productId.ifEmpty { game.entitlementId } } + return when + { + p.contains("PPSA") -> "ps5" + p.contains("CUSA") -> "ps4" + else -> game.platform.ifEmpty { "ps5" } + } + } + + /** Real legacy PS Now games stay psnow; otherwise route by title-id platform. */ + fun streamServiceType(game: CloudGame): String + { + if (game.serviceType.equals("psnow", ignoreCase = true)) return "psnow" + return if (streamPlatform(game) == "ps4") "psnow" else "pscloud" + } + + /** Identifier for startCompleteCloudSession: psnow sends the product id (Kamaji converts it + * and acquires via PS Plus); pscloud sends the owned entitlement id (direct). */ + fun streamIdentifier(game: CloudGame): String + { + return if (streamServiceType(game) == "psnow") game.productId.ifEmpty { streamingIdentifier(game) } + else streamingIdentifier(game) + } + private fun buildCatalogIndex(games: List): CatalogIndex { val byProductId = mutableMapOf() @@ -266,8 +440,9 @@ object PsCloudOwnership { if (game.productId.isNotEmpty()) catalogIndex.byProductId[game.productId] = index - if (game.conceptId.isNotEmpty()) - catalogIndex.byConceptId[game.conceptId] = index + val conceptKey = conceptPlatformKey(game) + if (conceptKey.isNotEmpty()) + catalogIndex.byConceptId[conceptKey] = index if (game.entitlementId.isNotEmpty() && game.entitlementId != game.productId) catalogIndex.byProductId[game.entitlementId] = index } @@ -280,8 +455,11 @@ object PsCloudOwnership return catalogIndex.byProductId.getValue(owned.entitlementId) if (owned.storeProductId.isNotEmpty() && catalogIndex.byProductId.containsKey(owned.storeProductId)) return catalogIndex.byProductId.getValue(owned.storeProductId) - if (owned.conceptId.isNotEmpty() && catalogIndex.byConceptId.containsKey(owned.conceptId)) - return catalogIndex.byConceptId.getValue(owned.conceptId) + // Match by conceptId + platform so an owned PS4 edition does not match a PS5-only catalog + // entry (and vice-versa); cross-gen editions stay as separate library cards. + val conceptKey = conceptPlatformKey(owned) + if (conceptKey.isNotEmpty() && catalogIndex.byConceptId.containsKey(conceptKey)) + return catalogIndex.byConceptId.getValue(conceptKey) return -1 } } diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/model/CloudGame.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/model/CloudGame.kt index fb8d75bd..aae1e084 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/model/CloudGame.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/model/CloudGame.kt @@ -17,7 +17,9 @@ data class CloudGame( val conceptId: String = "", // Imagic conceptId for catalog dedupe (PS5 cloud) val isOwned: Boolean = false, // Whether user owns this game (PS5 games) val entitlementId: String = "", // PSCloud: entitlement id for streaming (Qt gameData.id) - val storeProductId: String = "" // PSCloud: product_id from entitlements API + val storeProductId: String = "", // PSCloud: product_id from entitlements API + val plusCatalog: Boolean = false, // In the PS Plus subscription catalog (vs full streamable universe) + val featureType: Int = 0 // PSN entitlement feature_type (owned games): 3=full game, 1=trial/free, 0=add-on ) /** diff --git a/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt index f92b1f6e..85ec3f85 100644 --- a/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt +++ b/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt @@ -29,7 +29,7 @@ class CloudGameRepository( companion object { private const val TAG = "CloudGameRepository" - private const val CACHE_DIR = "cloud_catalog_cache" + private const val CACHE_DIR = "cloud_catalog_cache_v2" // v2: catalog games carry plusCatalog tag fun invalidateCatalogCache(context: Context) { @@ -49,9 +49,13 @@ class CloudGameRepository( } private const val PSNOW_CACHE_FILE = "psnow_catalog.json" private const val PSCLOUD_ALL_CACHE_FILE = "pscloud_catalog.json" - private const val PSCLOUD_OWNED_CACHE_FILE = "pscloud_owned.json" + private const val PSCLOUD_OWNED_CACHE_FILE = "pscloud_owned_v2.json" // v2: ft0 filter + rank dedupe + featureType private const val PS5_CATALOG_V3_CACHE_FILE = "ps5_cloud_catalog_v3.json" private const val CACHE_DURATION_MS = 24 * 60 * 60 * 1000L // 24 hours + + // Region-group-specific so an Americas/PAL switch doesn't serve stale ids (e.g. "_US"/"_GB"). + private fun ps3ClassicsCacheFile(accountCountry: String): String = + "ps3_classics_catalog_${com.metallic.chiaki.cloudplay.KamajiClassics.classicsStoreCountry(accountCountry)}.json" } private val psnowCatalogService = PsnCatalogService(preferences) @@ -86,14 +90,60 @@ class CloudGameRepository( // Fetch from network Log.i(TAG, "Fetching fresh PSNow catalog from network") val result = psnowCatalogService.fetchPsnowCatalog(npssoToken) - - // Cache if successful - if (result is PsnResult.Success) + + // Cache and return only if the legacy PS Now browse store actually returned games. + if (result is PsnResult.Success && result.data.isNotEmpty()) { cacheGames(result.data, PSNOW_CACHE_FILE) + return@withContext result + } + + // The legacy PS Now (Kamaji) browse store is region-locked / deprecated and 404s in + // many regions (e.g. Hungary). Fall back to the PS Plus subscription catalog (~630), + // NOT the full ~4000 streamable universe (that is the Library "all" view). + Log.w(TAG, "PSNow catalog unavailable/empty, falling back to PS Plus subscription catalog") + fetchPlusCatalog(npssoToken, forceRefresh) + } + } + + /** + * Fetch the PS Plus subscription catalog (Catalog tab): plusCatalog browse titles + the + * library-stream supplement, NOT the full all-ps5 universe. No ownership merge — every + * subscription title is shown as streamable. Mirrors Qt ps5PlusCatalogGames. + */ + suspend fun fetchPlusCatalog(npssoToken: String, forceRefresh: Boolean = false): PsnResult> + { + return withContext(Dispatchers.IO) + { + CloudLocaleBootstrap.ensureConfigured(preferences, npssoToken) + try + { + val stored = preferences.getCloudLanguage() + val catalog = fetchPs5CatalogV3(stored, forceRefresh) + var games = (catalog.browseGames.filter { it.plusCatalog } + catalog.plusLibrarySupplement) + .sortedBy { it.name.lowercase() } + // Mark owned subscription titles so owned -> Stream and non-owned -> Add Game. + // addUnmatched=false keeps the Catalog the pure subscription set (mark only). + if (npssoToken.isNotEmpty()) + { + try + { + val ownedCrossRef = pscloudCatalogService.getOwnedPs5CloudGames( + npssoToken, catalog.browseGames, catalog.plusLibrarySupplement, catalog.productIdAliases) + games = PsCloudOwnership.mergeOwnedIntoBrowseCatalog(games, ownedCrossRef, addUnmatched = false) + } + catch (e: Exception) + { + Log.w(TAG, "Catalog ownership marking failed; showing as not owned", e) + } + } + PsnResult.Success(games) + } + catch (e: Exception) + { + Log.e(TAG, "Failed to fetch PS Plus subscription catalog", e) + PsnResult.Error("Failed to fetch catalog: ${e.message}", e) } - - result } } @@ -117,19 +167,8 @@ class CloudGameRepository( try { val stored = preferences.getCloudLanguage() - val locale = com.metallic.chiaki.cloudplay.CloudLocale.toImagicLocale(stored) - Log.i(TAG, "Fetching PS5 Cloud catalog locale=$stored imagic=$locale forceRefresh=$forceRefresh") - - val catalog = (if (!forceRefresh) loadCachedPs5CatalogV3(stored) else null) - ?: run { - lastCatalogFetchWarning = null - val fetched = pscloudCatalogService.fetchPs5CloudCatalog(locale) - if (fetched.shouldCacheV3) - cachePs5CatalogV3(fetched, stored) - lastCatalogFetchWarning = fetched.catalogFetchWarning - fetched - } - + Log.i(TAG, "Fetching PS5 Cloud catalog stored=$stored forceRefresh=$forceRefresh") + val catalog = fetchPs5CatalogV3(stored, forceRefresh) val gamesWithOwnership = crossReferenceOwnership(catalog, npssoToken) if (gamesWithOwnership.isNotEmpty()) cacheGames(gamesWithOwnership, PSCLOUD_ALL_CACHE_FILE) @@ -143,6 +182,42 @@ class CloudGameRepository( } } + /** + * Fetch the PS5 imagic catalog, trying the store-locale fallback chain + * (session locale -> en-COUNTRY -> en-US) since Sony 404s unsupported locales (e.g. hu-HU). + * Persists the locale that works. Returns the cached v3 catalog when available. + */ + private suspend fun fetchPs5CatalogV3(stored: String, forceRefresh: Boolean): Ps5CloudCatalogResult + { + if (!forceRefresh) + loadCachedPs5CatalogV3(stored)?.let { return it } + + lastCatalogFetchWarning = null + var lastError: Exception? = null + for ((canonical, imagic) in com.metallic.chiaki.cloudplay.CloudLocale.fallbackChain(stored)) + { + try + { + val fetched = pscloudCatalogService.fetchPs5CloudCatalog(imagic) + if (canonical != stored) + { + Log.i(TAG, "PS5 store locale settled on $canonical (was $stored)") + preferences.setCloudLanguage(canonical) + } + if (fetched.shouldCacheV3) + cachePs5CatalogV3(fetched, canonical) + lastCatalogFetchWarning = fetched.catalogFetchWarning + return fetched + } + catch (e: Exception) + { + Log.i(TAG, "PS5 imagic locale $imagic failed, trying next tier: ${e.message}") + lastError = e + } + } + throw (lastError ?: Exception("All imagic locales failed to load")) + } + /** * Cross-reference public catalog with owned games to mark ownership status */ @@ -189,17 +264,7 @@ class CloudGameRepository( try { val stored = preferences.getCloudLanguage() - val locale = com.metallic.chiaki.cloudplay.CloudLocale.toImagicLocale(stored) - - val catalog = (if (!forceRefresh) loadCachedPs5CatalogV3(stored) else null) - ?: run { - lastCatalogFetchWarning = null - val fetched = pscloudCatalogService.fetchPs5CloudCatalog(locale) - if (fetched.shouldCacheV3) - cachePs5CatalogV3(fetched, stored) - lastCatalogFetchWarning = fetched.catalogFetchWarning - fetched - } + val catalog = fetchPs5CatalogV3(stored, forceRefresh) val games = pscloudCatalogService.getOwnedPs5CloudGames( npssoToken, @@ -219,6 +284,43 @@ class CloudGameRepository( } } + /** + * Fetch the streamable PS3 Classics (public Apollo container) with region-keyed caching. + * Subscription-streamable (never "owned"), so callers append these to the Catalog and the + * Library "all" view only. Mirrors CloudCatalogBackend::fetchPs3Catalog() (Qt). + */ + suspend fun fetchPs3ClassicsCatalog(forceRefresh: Boolean = false): PsnResult> + { + return withContext(Dispatchers.IO) + { + CloudLocaleBootstrap.ensureConfigured(preferences, preferences.getNpssoToken()) + // Account country = country part of the store locale (e.g. "en-HU" -> "HU"). + val (accountCountry, _) = com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(preferences.getCloudLanguage()) + val cacheFile = ps3ClassicsCacheFile(accountCountry) + + if (!forceRefresh) + { + loadCachedGames(cacheFile)?.let { cached -> + Log.i(TAG, "Returning ${cached.size} PS3 Classics from cache ($cacheFile)") + return@withContext PsnResult.Success(cached) + } + } + + try + { + val games = pscloudCatalogService.fetchPs3ClassicsCatalog(accountCountry) + if (games.isNotEmpty()) + cacheGames(games, cacheFile) + PsnResult.Success(games) + } + catch (e: Exception) + { + Log.w(TAG, "Failed to fetch PS3 Classics catalog", e) + PsnResult.Error("Failed to fetch PS3 Classics catalog: ${e.message}", e) + } + } + } + /** * Load games from cache if valid */ @@ -267,7 +369,9 @@ class CloudGameRepository( conceptId = obj.optString("conceptId", ""), isOwned = obj.optBoolean("isOwned", false), entitlementId = obj.optString("entitlementId", ""), - storeProductId = obj.optString("storeProductId", "") + storeProductId = obj.optString("storeProductId", ""), + plusCatalog = obj.optBoolean("plusCatalog", false), + featureType = obj.optInt("featureType", 0) )) } @@ -305,6 +409,8 @@ class CloudGameRepository( obj.put("isOwned", game.isOwned) obj.put("entitlementId", game.entitlementId) obj.put("storeProductId", game.storeProductId) + obj.put("plusCatalog", game.plusCatalog) + obj.put("featureType", game.featureType) jsonArray.put(obj) } @@ -420,7 +526,9 @@ class CloudGameRepository( conceptId = obj.optString("conceptId", ""), isOwned = obj.optBoolean("isOwned", false), entitlementId = obj.optString("entitlementId", ""), - storeProductId = obj.optString("storeProductId", "") + storeProductId = obj.optString("storeProductId", ""), + plusCatalog = obj.optBoolean("plusCatalog", false), + featureType = obj.optInt("featureType", 0) ) ) } @@ -444,6 +552,8 @@ class CloudGameRepository( obj.put("isOwned", game.isOwned) obj.put("entitlementId", game.entitlementId) obj.put("storeProductId", game.storeProductId) + obj.put("plusCatalog", game.plusCatalog) + obj.put("featureType", game.featureType) jsonArray.put(obj) } return jsonArray diff --git a/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt b/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt index 7747f390..f4e558ef 100644 --- a/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt +++ b/android/app/src/main/java/com/metallic/chiaki/common/Preferences.kt @@ -260,6 +260,19 @@ class Preferences(context: Context) fun setCloudLanguageFromSession(language: String?, country: String?) { val locale = com.metallic.chiaki.cloudplay.CloudLocale.fromSession(language, country) ?: return + if (isCloudLanguageConfigured()) + { + // The country is the real region signal; the language part may get auto-corrected by + // the imagic fetch (e.g. hu-HU settles on en-HU). Only re-save when the country changes, + // otherwise we'd clobber the validated locale on every Kamaji session and thrash the cache. + val storedCountry = com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(getCloudLanguage()).first + val sessionCountry = com.metallic.chiaki.cloudplay.CloudLocale.parseStorePath(locale).first + if (storedCountry == sessionCountry) + { + Log.i("Preferences", "Kamaji session country unchanged ($sessionCountry), keeping validated locale ${getCloudLanguage()}") + return + } + } setCloudLanguage(locale) } diff --git a/android/app/src/main/java/com/metallic/chiaki/main/CloudGameAdapter.kt b/android/app/src/main/java/com/metallic/chiaki/main/CloudGameAdapter.kt index 3ca12bfc..5cc5a744 100644 --- a/android/app/src/main/java/com/metallic/chiaki/main/CloudGameAdapter.kt +++ b/android/app/src/main/java/com/metallic/chiaki/main/CloudGameAdapter.kt @@ -97,11 +97,20 @@ class CloudGameAdapter( fun bind(game: CloudGame) { binding.gameNameTextView.text = game.name - binding.gamePlatformTextView.text = when (game.platform.lowercase()) { - "ps3" -> "3" - "ps4" -> "4" - "ps5" -> "5" - else -> game.platform.takeLast(1) + // Derive the badge from the title id (PPSA = PS5, CUSA = PS4) like the Qt client does, + // since the catalog parser tags everything "ps5"; fall back to the platform field. + binding.gamePlatformTextView.text = run { + val pid = game.productId.ifEmpty { game.storeProductId } + when { + pid.contains("PPSA") -> "5" + pid.contains("CUSA") -> "4" + else -> when (game.platform.lowercase()) { + "ps3" -> "3" + "ps4" -> "4" + "ps5" -> "5" + else -> game.platform.takeLast(1) + } + } } if (showOwnershipBadge && game.serviceType == "pscloud") { diff --git a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt index c20ba0ff..439cedc5 100644 --- a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt +++ b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayFragment.kt @@ -408,8 +408,9 @@ class CloudPlayFragment : Fragment() val currentlyOwned = viewModel.preferences.getPsCloudFilterOwned() viewModel.preferences.setPsCloudFilterOwned(!currentlyOwned) updateOwnedToggleButton() - // Re-fetch with new filter - viewModel.fetchPs5CloudCatalog(showOnlyOwned = !currentlyOwned) + // Re-fetch with new filter. PS3 Classics only in the streamable "all" view (not "owned"). + val newShowOnlyOwned = !currentlyOwned + viewModel.fetchPs5CloudCatalog(showOnlyOwned = newShowOnlyOwned, appendPs3Classics = !newShowOnlyOwned) } // Icon buttons in header @@ -509,7 +510,7 @@ class CloudPlayFragment : Fragment() // Update section viewModel.setCurrentSection("psnow") - adapter.showOwnershipBadge = false + adapter.showOwnershipBadge = true // owned/not-owned shown in Catalog too binding.sortOptionLayout.visibility = android.view.View.VISIBLE binding.filterOptionLayout.visibility = android.view.View.VISIBLE updateSortButtonText() @@ -517,8 +518,9 @@ class CloudPlayFragment : Fragment() // Update favorites icon to match new section updateFavoritesIcon() - - viewModel.fetchPsnowCatalog() + + // Append the streamable PS3 Classics (public Apollo container) to the Catalog after it loads. + viewModel.fetchPsnowCatalog(appendPs3Classics = true) } private fun selectLibraryTab() @@ -552,11 +554,13 @@ class CloudPlayFragment : Fragment() val isOwnedFilter = viewModel.preferences.getPsCloudFilterOwned() val isFavoritesFilter = preferences.getPsCloudFilterFavorites() - + + // PS3 Classics belong in the streamable "all" view only (never the "owned" list). The + // favorites filter draws from the same "all" set, so include PS3 there too. if (isFavoritesFilter) { - viewModel.fetchPs5CloudCatalog(showOnlyOwned = false) + viewModel.fetchPs5CloudCatalog(showOnlyOwned = false, appendPs3Classics = true) } else { - viewModel.fetchPs5CloudCatalog(showOnlyOwned = isOwnedFilter) + viewModel.fetchPs5CloudCatalog(showOnlyOwned = isOwnedFilter, appendPs3Classics = !isOwnedFilter) } } @@ -612,9 +616,10 @@ class CloudPlayFragment : Fragment() val currentSection = viewModel.getCurrentSection() if (currentSection == "pscloud") { val isOwnedFilter = viewModel.preferences.getPsCloudFilterOwned() - viewModel.fetchPs5CloudCatalog(showOnlyOwned = isOwnedFilter, forceRefresh = true) + // PS3 Classics belong in the streamable "all" view only, never the "owned" list. + viewModel.fetchPs5CloudCatalog(showOnlyOwned = isOwnedFilter, forceRefresh = true, appendPs3Classics = !isOwnedFilter) } else { - viewModel.fetchPsnowCatalog(forceRefresh = true) + viewModel.fetchPsnowCatalog(forceRefresh = true, appendPs3Classics = true) } } @@ -675,11 +680,12 @@ class CloudPlayFragment : Fragment() if (currentSection == "pscloud") { val isOwnedFilter = viewModel.preferences.getPsCloudFilterOwned() - viewModel.fetchPs5CloudCatalog(showOnlyOwned = isOwnedFilter, forceRefresh = true) + // PS3 Classics belong in the streamable "all" view only, never the "owned" list. + viewModel.fetchPs5CloudCatalog(showOnlyOwned = isOwnedFilter, forceRefresh = true, appendPs3Classics = !isOwnedFilter) } else { - viewModel.fetchPsnowCatalog(forceRefresh = true) + viewModel.fetchPsnowCatalog(forceRefresh = true, appendPs3Classics = true) } } @@ -730,8 +736,8 @@ class CloudPlayFragment : Fragment() ) viewModel.setSortedGames(sortedGames) } else { - // Catalog: Reload from cache to restore original API order - viewModel.fetchPsnowCatalog(forceRefresh = false) + // Catalog: Reload from cache to restore original API order (PS3 Classics included) + viewModel.fetchPsnowCatalog(forceRefresh = false, appendPs3Classics = true) } } 1 -> { @@ -805,22 +811,22 @@ class CloudPlayFragment : Fragment() // Game Library when (selectedItem) { 0 -> { - // All Games + // All Games (streamable universe includes PS3 Classics) preferences.setPsCloudFilterFavorites(false) preferences.setPsCloudFilterOwned(false) - viewModel.fetchPs5CloudCatalog(showOnlyOwned = false, forceRefresh = false) + viewModel.fetchPs5CloudCatalog(showOnlyOwned = false, forceRefresh = false, appendPs3Classics = true) } 1 -> { - // Owned Games + // Owned Games (PS3 Classics are subscription-streamable, never "owned") preferences.setPsCloudFilterFavorites(false) preferences.setPsCloudFilterOwned(true) viewModel.fetchPs5CloudCatalog(showOnlyOwned = true, forceRefresh = false) } 2 -> { - // Favorites + // Favorites (drawn from the "all" set, so include PS3 Classics) preferences.setPsCloudFilterFavorites(true) preferences.setPsCloudFilterOwned(false) - viewModel.fetchPs5CloudCatalog(showOnlyOwned = false, forceRefresh = false) + viewModel.fetchPs5CloudCatalog(showOnlyOwned = false, forceRefresh = false, appendPs3Classics = true) } } } else { @@ -829,12 +835,12 @@ class CloudPlayFragment : Fragment() 0 -> { // All Games preferences.setPsnowFilterFavorites(false) - viewModel.fetchPsnowCatalog(forceRefresh = false) + viewModel.fetchPsnowCatalog(forceRefresh = false, appendPs3Classics = true) } 1 -> { // Favorites preferences.setPsnowFilterFavorites(true) - viewModel.fetchPsnowCatalog(forceRefresh = false) + viewModel.fetchPsnowCatalog(forceRefresh = false, appendPs3Classics = true) } } } @@ -1120,9 +1126,7 @@ class CloudPlayFragment : Fragment() private fun onGameClicked(game: CloudGame) { val isPscloud = game.serviceType == "pscloud" - val isAllGamesFilter = !viewModel.preferences.getPsCloudFilterOwned() - - if (isPscloud && isAllGamesFilter && !game.isOwned) + if (isPscloud && !game.isOwned) { // Show dialog to add game to library showAddToLibraryDialog(game) @@ -1341,9 +1345,11 @@ class CloudPlayFragment : Fragment() try { val backend = CloudStreamingBackend(requireContext(), viewModel.preferences) + // Route by the title-id platform: PS4 catalog titles go through Kamaji (psnow) to + // acquire the streaming entitlement; PS5 streams directly (pscloud). val result = backend.startCompleteCloudSession( - serviceType = game.serviceType, - gameIdentifier = PsCloudOwnership.streamingIdentifier(game), + serviceType = PsCloudOwnership.streamServiceType(game), + gameIdentifier = PsCloudOwnership.streamIdentifier(game), gameName = game.name, npssoToken = npssoToken, onProgress = { message -> diff --git a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayViewModel.kt b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayViewModel.kt index eb20f075..e76cd176 100644 --- a/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayViewModel.kt +++ b/android/app/src/main/java/com/metallic/chiaki/main/CloudPlayViewModel.kt @@ -62,7 +62,7 @@ class CloudPlayViewModel( /** * Fetch PSNow catalog from network/cache */ - fun fetchPsnowCatalog(forceRefresh: Boolean = false) + fun fetchPsnowCatalog(forceRefresh: Boolean = false, appendPs3Classics: Boolean = false) { viewModelScope.launch { try @@ -70,11 +70,11 @@ class CloudPlayViewModel( _loading.value = true _error.value = null _warning.value = null - + Log.i(TAG, "Fetching PSNow catalog (forceRefresh=$forceRefresh)") - + val npssoToken = preferences.getNpssoToken() - + when (val result = repository.fetchPsnowCatalog(npssoToken, forceRefresh)) { is PsnResult.Success -> @@ -82,6 +82,9 @@ class CloudPlayViewModel( allGames = result.data Log.i(TAG, "Successfully loaded ${allGames.size} games") applySearchFilter() + // PS3 Classics are subscription-streamable -> always shown in the Catalog. + if (appendPs3Classics) + fetchPs3ClassicsCatalog(forceRefresh) } is PsnResult.Error -> { @@ -106,7 +109,7 @@ class CloudPlayViewModel( * Fetch PS5 Cloud catalog from network/cache * @param showOnlyOwned If true, fetches only user's owned games; if false, fetches all PS5 games */ - fun fetchPs5CloudCatalog(showOnlyOwned: Boolean = false, forceRefresh: Boolean = false) + fun fetchPs5CloudCatalog(showOnlyOwned: Boolean = false, forceRefresh: Boolean = false, appendPs3Classics: Boolean = false) { viewModelScope.launch { try @@ -149,6 +152,9 @@ class CloudPlayViewModel( Log.i(TAG, "Successfully loaded ${allGames.size} PS5 games") repository.lastCatalogFetchWarning?.let { _warning.value = it } applySearchFilter() + // Library "all" (streamable universe) includes PS3 Classics; "owned" does not. + if (appendPs3Classics) + fetchPs3ClassicsCatalog(forceRefresh) } is PsnResult.Error -> { @@ -171,6 +177,49 @@ class CloudPlayViewModel( } } + /** + * Fetch the streamable PS3 Classics (public Apollo container) and APPEND them to the + * already-displayed list. Additive: it never replaces the PS4/PS5 catalog already loaded, + * so it works whether the primary catalog came from PS Now or the imagic fallback. PS3 + * Classics are subscription-streamable, so they belong in the Game Catalog and in the + * Library "all" view -- but NOT the "owned" view. Mirrors CloudPlayView.qml appendPs3Catalog(). + */ + fun fetchPs3ClassicsCatalog(forceRefresh: Boolean = false) + { + viewModelScope.launch { + try + { + when (val result = repository.fetchPs3ClassicsCatalog(forceRefresh)) + { + is PsnResult.Success -> + { + if (result.data.isNotEmpty()) + { + // De-dupe by productId in case of a re-entrant append. + val existingIds = allGames.mapTo(HashSet()) { it.productId } + val toAdd = result.data.filter { existingIds.add(it.productId) } + if (toAdd.isNotEmpty()) + { + allGames = allGames + toAdd + Log.i(TAG, "Appended ${toAdd.size} PS3 Classics to catalog") + applySearchFilter() + } + } + } + is PsnResult.Error -> + { + // Non-fatal: PS3 Classics are supplementary to the primary catalog. + Log.w(TAG, "PS3 Classics catalog unavailable: ${result.message}") + } + } + } + catch (e: Exception) + { + Log.w(TAG, "Unexpected error fetching PS3 Classics catalog", e) + } + } + } + /** * Get current section */ diff --git a/android/gradle.properties b/android/gradle.properties index 357391df..a2e2757f 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -8,9 +8,10 @@ # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -# Force Gradle to use Java 21 (required for AGP 8.7+) -# Java 21 is LTS and works perfectly with AGP 8.7 and Gradle 8.9+ -org.gradle.java.home=C\:\\Program Files\\Android\\Android Studio2\\jbr +# This build must run Gradle on a JDK 21 (AGP does not support newer JDKs). +# Do NOT hardcode a machine-specific org.gradle.java.home here -- an absolute path +# breaks every other machine/OS/CI. Select the JDK 21 daemon per-machine via JAVA_HOME, +# ~/.gradle/gradle.properties (org.gradle.java.home), or your IDE's "Gradle JDK" setting. # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects diff --git a/gui/include/cloudcatalogbackend.h b/gui/include/cloudcatalogbackend.h index f750b97e..75c968d7 100644 --- a/gui/include/cloudcatalogbackend.h +++ b/gui/include/cloudcatalogbackend.h @@ -41,6 +41,11 @@ class CloudCatalogBackend : public QObject // Main catalog fetching methods Q_INVOKABLE void fetchPsnowCatalog(const QJSValue &callback); + // Streamable PS3 Classics catalog. Walks the PUBLIC pcnow (Apollo) container + // STORE-MSF192018-APOLLOPS3GAMES (no OAuth/session needed -- the container API is + // open), returning ~300 PS3 titles that imagic/gameslist never lists. These stream + // via the PSNOW -> Gaikai konan path the streaming code already supports. + Q_INVOKABLE void fetchPs3Catalog(const QJSValue &callback); Q_INVOKABLE void fetchPs5CloudCatalog(const QJSValue &callback); Q_INVOKABLE void fetchOwnedPs5Games(const QJSValue &callback); Q_INVOKABLE void getOwnedPs5CloudGames(const QJSValue &callback); @@ -95,7 +100,18 @@ private slots: QString duid; bool authInProgress; } psnowState; - + + // PS3 Classics catalog fetching state (public Apollo PS3 container, paginated). + // containerUrl is resolved per account region group (Americas vs PAL) at fetch time. + struct Ps3FetchState { + QJSValue callback; + QJsonArray allGames; + QString containerUrl; + int currentStart = 0; + int totalResults = -1; + bool inProgress = false; + } ps3State; + // PS5 catalog fetching state (six imagic lists, merged like Sony's PS5 cloud finder) struct Ps5FetchState { QJSValue callback; @@ -107,6 +123,12 @@ private slots: QMap plusLibrarySupplementByProductId; QMap productIdAliases; // alternate imagic productId -> canonical browse productId int totalGamesSeen = 0; + // Store-locale fallback: Sony serves a fixed set of language-COUNTRY locales. + // The country is always valid but the language may not be (e.g. hu-HU 404s, + // en-HU works). We try the session locale, then en-COUNTRY, then en-US. + QStringList localeChain; + int localeTierIndex = 0; + QString activeLocale; // canonical "ll-CC" form for the tier currently being fetched } ps5State; // Owned games fetching state @@ -132,7 +154,9 @@ private slots: QJsonArray plusLibrarySupplement; QJsonArray ownedGames; QMap productIdAliases; - QMap componentIdsByProductId; // product_id -> all sibling entitlement ids (full list) + // Bundle product_id -> its component entitlement ids, for bundle-sibling matching (from + // upstream PR #15): a bundle entitlement (e.g. RE7 Gold) expands to its component games. + QMap componentIdsByProductId; bool catalogFetched; bool ownedGamesFetched; } crossReferenceState; @@ -144,6 +168,10 @@ private slots: void ensureCacheDirectory(); void fetchPsnowCategory(int categoryIndex); void processPsnowCatalogComplete(); + QString ps3AccountCountry() const; + void fetchPs3CatalogPage(); + void handlePs3CatalogPageResponse(); + void finishPs3Catalog(); void fetchOwnedGamesOAuthToken(); void fetchPsnowOAuthToken(); void fetchPsnowSession(); @@ -153,6 +181,7 @@ private slots: void handlePsnowSessionResponse(); void handlePsnowStoresResponse(); void handlePsnowRootContainerResponse(); + void startPs5ImagicListFetch(); // fires the six imagic list requests for ps5State.activeLocale void executeGameDetailsFetch(const QString &productId); QJsonArray filterStreamingSupportedGames(const QJsonArray &games); QJsonArray filterOwnedPs5Games(const QJsonArray &entitlements); diff --git a/gui/include/cloudstreaming/pskamajisession.h b/gui/include/cloudstreaming/pskamajisession.h index 0750ea8d..d3314aa5 100644 --- a/gui/include/cloudstreaming/pskamajisession.h +++ b/gui/include/cloudstreaming/pskamajisession.h @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -29,6 +30,38 @@ namespace KamajiConsts { static const QString REFERER = "https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/"; static const QString REDIRECT_URI = "https://psnow.playstation.com/app/2.2.0/133/5cdcc037d/grc-response.html"; static const QString USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) playstation-now/0.0.0 Chrome/83.0.4103.104 Electron/9.0.4 Safari/537.36 gkApollo"; + + // --- PS3 / Classics pcnow store, by account region group --------------------- + // pcnow (the PS Plus PC "Apollo" backend) has only TWO Classics id families: + // * SCEA / Americas -> store MSF192018, US-region ids (UP*/NPUA*/BLUS*), + // PS3 child container "APOLLOPS3GAMES" + // * SCEE / PAL (rest) -> store MSF192014, EU-region ids (EP*/NPEA*/NPEB*/BLES*), + // PS3 child container "APOLLOPS3" + // JP / Asia have no Apollo store (the PC app isn't offered there), so they fall + // back to PAL. A PS Plus account is authorized at Gaikai only for the id family of + // its own region group, so we must browse + resolve in the account's group. + inline bool isAmericasClassicsRegion(const QString &countryCode) { + static const QSet kAmericas = { + QStringLiteral("US"), QStringLiteral("CA"), QStringLiteral("MX"), + QStringLiteral("BR"), QStringLiteral("AR"), QStringLiteral("CL"), + QStringLiteral("CO"), QStringLiteral("PE"), QStringLiteral("EC"), + QStringLiteral("BO"), QStringLiteral("PY"), QStringLiteral("UY"), + QStringLiteral("CR"), QStringLiteral("GT"), QStringLiteral("HN"), + QStringLiteral("NI"), QStringLiteral("PA"), QStringLiteral("SV"), + QStringLiteral("DO") }; + return kAmericas.contains(countryCode.toUpper()); + } + // Country path to use for container/conversion calls (US for Americas, GB for PAL). + inline QString classicsStoreCountry(const QString &accountCountry) { + return isAmericasClassicsRegion(accountCountry) ? QStringLiteral("US") + : QStringLiteral("GB"); + } + // Fully-qualified PS3 catalog container id for the account's region group. + inline QString classicsPs3ContainerId(const QString &accountCountry) { + return isAmericasClassicsRegion(accountCountry) + ? QStringLiteral("STORE-MSF192018-APOLLOPS3GAMES") + : QStringLiteral("STORE-MSF192014-APOLLOPS3"); + } } /** diff --git a/gui/src/cloudcatalogbackend.cpp b/gui/src/cloudcatalogbackend.cpp index 993211f3..ad3337a0 100644 --- a/gui/src/cloudcatalogbackend.cpp +++ b/gui/src/cloudcatalogbackend.cpp @@ -134,13 +134,13 @@ QString CloudCatalogBackend::getCachedData(const QString &key, int maxAge) QString CloudCatalogBackend::getCachedPs5CatalogV3(int maxAge) { - const QString cached = getCachedData(QStringLiteral("ps5_cloud_catalog_v3"), maxAge); + const QString cached = getCachedData(QStringLiteral("ps5_cloud_catalog_v6"), maxAge); if (cached.isEmpty()) return QString(); const QJsonDocument doc = QJsonDocument::fromJson(cached.toUtf8()); if (!doc.isObject()) { - QFile::remove(getCacheFilePath(QStringLiteral("ps5_cloud_catalog_v3"))); + QFile::remove(getCacheFilePath(QStringLiteral("ps5_cloud_catalog_v6"))); return QString(); } @@ -149,7 +149,7 @@ QString CloudCatalogBackend::getCachedPs5CatalogV3(int maxAge) if (!cachedLocale.isEmpty() && cachedLocale != expectedLocale) { qInfo() << "[CACHE LOCALE MISMATCH] PS5 catalog v3 locale" << cachedLocale << "!=" << expectedLocale << ", refetching"; - QFile::remove(getCacheFilePath(QStringLiteral("ps5_cloud_catalog_v3"))); + QFile::remove(getCacheFilePath(QStringLiteral("ps5_cloud_catalog_v6"))); return QString(); } @@ -460,19 +460,23 @@ void CloudCatalogBackend::handlePsnowSessionResponse() // Save country and language from session response to settings QString country = data["country"].toString(); QString language = data["language"].toString(); - if (!country.isEmpty() && !language.isEmpty()) { + if (!country.isEmpty() && !language.isEmpty() && settings) { // Format: language-COUNTRY (e.g., "nl-NL" or "en-US") - QString locale = QString("%1-%2").arg(language, country.toUpper()); - if (settings) { - QString previousLocale = settings->GetCloudLanguagePSCloud(); - settings->SetCloudLanguagePSCloud(locale); - qInfo() << "[PSNOW] Saved locale from session:" << locale; - - // Invalidate cache if locale changed - if (previousLocale != locale) { - qInfo() << "[PSNOW] Locale changed from" << previousLocale << "to" << locale << "- invalidating cache"; - invalidateCache(); - } + const QString sessionLocale = QString("%1-%2").arg(language.toLower(), country.toUpper()); + const QString previousLocale = settings->GetCloudLanguagePSCloud(); + // The country is the real region signal; the language part may get + // auto-corrected later (the imagic fetch settles e.g. hu-HU on en-HU). + // Only re-save when the country actually changes, otherwise we'd clobber + // the validated locale on every visit and thrash the cache. + const QString previousCountry = previousLocale.section(QLatin1Char('-'), 1, 1).toUpper(); + if (previousCountry != country.toUpper()) { + settings->SetCloudLanguagePSCloud(sessionLocale); + qInfo() << "[PSNOW] Region changed, saved locale from session:" << sessionLocale + << "(was" << previousLocale << ") - invalidating cache"; + invalidateCache(); + } else if (settings->GetLogVerbose()) { + qInfo() << "[PSNOW] Session country unchanged (" << country + << "), keeping validated locale" << previousLocale; } } @@ -870,8 +874,190 @@ void CloudCatalogBackend::processPsnowCatalogComplete() emit catalogUpdated(); } +// --------------------------------------------------------------------------- +// PS3 Classics catalog (public Apollo container walk) +// +// The PS Plus PC ("Apollo") app browses the streamable catalog through the public +// pcnow container API at psnow.playstation.com. There is a dedicated PS3 container, +// STORE-MSF192018-APOLLOPS3GAMES, that lists ~300 streamable PS3 titles with their +// PS3 product ids (NPUA/NPUB/BLUS/BCUS) -- none of which appear in the imagic +// gameslist the rest of the catalog uses. The container API needs no OAuth or +// per-account session (unlike /user/stores, which 404s in regions where the PC app +// is unavailable, e.g. Hungary), so we can walk it directly in any region. The +// resulting titles carry playable_platform ["PS3"] and stream via the existing +// PSNOW -> Gaikai konan path. +// --------------------------------------------------------------------------- +// Resolve the account's region group from its store locale (e.g. "en-HU" -> "HU"). +// pcnow has two Classics id families (Americas/SCEA and PAL/SCEE); a PS Plus account is +// authorized at Gaikai only for the family of its own region group, so the catalog must +// be browsed in that group. See KamajiConsts::classicsStoreCountry / classicsPs3ContainerId. +QString CloudCatalogBackend::ps3AccountCountry() const +{ + QString locale = settings ? settings->GetCloudLanguagePSCloud() : QStringLiteral("en-US"); + QStringList parts = locale.split(QLatin1Char('-')); + QString cc = parts.size() > 1 ? parts[1] : QStringLiteral("US"); + return cc.toUpper(); +} + +void CloudCatalogBackend::fetchPs3Catalog(const QJSValue &callback) +{ + const QString cc = ps3AccountCountry(); + // Region-group-specific cache key so an Americas/PAL switch doesn't serve stale ids. + const QString cacheKey = QStringLiteral("ps3_catalog_") + KamajiConsts::classicsStoreCountry(cc); + QString cached = getCachedData(cacheKey, CACHE_DURATION_CATALOG); + if (!cached.isEmpty()) { + qInfo() << "[CACHE] Using cached PS3 catalog"; + QJsonDocument doc = QJsonDocument::fromJson(cached.toUtf8()); + if (callback.isCallable()) + callback.call({true, "Cached", QJSValue(QString::fromUtf8(doc.toJson(QJsonDocument::Compact)))}); + return; + } + + if (ps3State.inProgress) { + qInfo() << "[PS3] Catalog fetch already in progress"; + if (callback.isCallable()) + callback.call({false, "Request already in progress", QJSValue()}); + return; + } + + ps3State.containerUrl = QStringLiteral( + "https://psnow.playstation.com/store/api/pcnow/00_09_000/container/%1/en/19/%2") + .arg(KamajiConsts::classicsStoreCountry(cc), KamajiConsts::classicsPs3ContainerId(cc)); + qInfo() << "[API CALL] Fetching PS3 Classics catalog (region group" + << KamajiConsts::classicsStoreCountry(cc) << "for account country" << cc << ")"; + ps3State.callback = callback; + ps3State.allGames = QJsonArray(); + ps3State.currentStart = 0; + ps3State.totalResults = -1; + ps3State.inProgress = true; + fetchPs3CatalogPage(); +} + +void CloudCatalogBackend::fetchPs3CatalogPage() +{ + QString url = QString("%1?useOffers=true&gkb=1&gkb2=1&start=%2&size=100") + .arg(ps3State.containerUrl) + .arg(ps3State.currentStart); + + if (settings && settings->GetLogVerbose()) { + qInfo() << "=== CloudCatalogBackend: PS3 catalog page ==="; + qInfo() << " URL:" << url; + } + + QNetworkRequest req{QUrl(url)}; + req.setRawHeader("Accept", "application/json"); + req.setRawHeader("User-Agent", KamajiConsts::USER_AGENT.toUtf8()); + + QNetworkReply *reply = networkManager->get(req); + connect(reply, &QNetworkReply::finished, this, &CloudCatalogBackend::handlePs3CatalogPageResponse); +} + +void CloudCatalogBackend::handlePs3CatalogPageResponse() +{ + QNetworkReply *reply = qobject_cast(sender()); + if (!reply) return; + reply->deleteLater(); + + int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + QByteArray data = reply->readAll(); + + if (reply->error() != QNetworkReply::NoError || statusCode != 200) { + QString errorMsg = QString("PS3 catalog fetch failed (HTTP %1): %2") + .arg(statusCode).arg(reply->errorString()); + qWarning() << "CloudCatalogBackend:" << errorMsg; + if (ps3State.allGames.isEmpty()) { + ps3State.inProgress = false; + if (ps3State.callback.isCallable()) + ps3State.callback.call({false, errorMsg, QJSValue()}); + return; + } + // Partial data already collected: return what we have. + finishPs3Catalog(); + return; + } + + QJsonObject obj = QJsonDocument::fromJson(data).object(); + if (ps3State.totalResults < 0) + ps3State.totalResults = obj.value("total_results").toInt(); + + QJsonArray links = obj.value("links").toArray(); + int productCount = 0; + for (const QJsonValue &v : links) { + QJsonObject g = v.toObject(); + if (g.value("container_type").toString() != QLatin1String("product")) + continue; + QString img = extractCoverImageFromGameObject(g); + if (!img.isEmpty()) + g["imageUrl"] = img; + ps3State.allGames.append(g); + productCount++; + } + + if (settings && settings->GetLogVerbose()) + qInfo() << " PS3 page games:" << productCount << "accumulated:" << ps3State.allGames.size() + << "of" << ps3State.totalResults; + + ps3State.currentStart += 100; + if (productCount > 0 && ps3State.currentStart < ps3State.totalResults) { + fetchPs3CatalogPage(); + } else { + finishPs3Catalog(); + } +} + +void CloudCatalogBackend::finishPs3Catalog() +{ + QJsonObject result; + result["games"] = ps3State.allGames; + result["total"] = ps3State.allGames.size(); + QJsonDocument resultDoc(result); + setCachedData(QStringLiteral("ps3_catalog_") + KamajiConsts::classicsStoreCountry(ps3AccountCountry()), resultDoc); + + qInfo() << "[PS3] Catalog complete:" << ps3State.allGames.size() << "PS3 titles"; + + ps3State.inProgress = false; + if (ps3State.callback.isCallable()) + ps3State.callback.call({true, "Success", QJSValue(QString::fromUtf8(resultDoc.toJson(QJsonDocument::Compact)))}); +} + namespace { +// Canonicalize a "language-COUNTRY" locale to lowercase-language / uppercase-country. +static QString canonicalStoreLocale(const QString &raw) +{ + QString s = raw.trimmed(); + if (s.isEmpty()) + return QStringLiteral("en-US"); + const QStringList parts = s.split(QLatin1Char('-')); + QString lang = parts.value(0).toLower(); + QString country = parts.value(1).toUpper(); + if (lang.isEmpty()) + lang = QStringLiteral("en"); + if (country.isEmpty()) + country = QStringLiteral("US"); + return lang + QLatin1Char('-') + country; +} + +// Build the ordered list of store locales to try. Sony's storefront/imagic endpoints +// only serve a fixed set of language-COUNTRY combinations: the country is always +// served, but the language may not be (e.g. a Hungarian-language account yields +// "hu-HU", which 404s, while "en-HU" works). Fall back to English for the same +// country, then en-US, so the catalog loads in every region. +static QStringList buildStoreLocaleFallbackChain(const QString &stored) +{ + const QString canonical = canonicalStoreLocale(stored); + const QString country = canonical.section(QLatin1Char('-'), 1, 1); + QStringList chain; + auto add = [&chain](const QString &loc) { + if (!loc.isEmpty() && !chain.contains(loc)) + chain.append(loc); + }; + add(canonical); + add(QStringLiteral("en-") + country); + add(QStringLiteral("en-US")); + return chain; +} + static const QStringList kPs5ImagicCategoryLists = { QStringLiteral("plus-games-list"), QStringLiteral("ubisoft-classics-list"), @@ -881,21 +1067,25 @@ static const QStringList kPs5ImagicCategoryLists = { QStringLiteral("all-ps5-list"), }; -static bool isPs5Game(const QJsonObject &gameObj) +// PS Plus cloud streaming covers PS4 and PS5 titles (PS3 is not present in these +// imagic lists). A PS4-only title such as God of War (2018) is streamable when +// owned even though it carries device ["PS4"], so the catalog must not discard it. +static bool isCloudDeviceGame(const QJsonObject &gameObj) { const QJsonArray devices = gameObj.value(QStringLiteral("device")).toArray(); for (const QJsonValue &device : devices) { - if (device.toString() == QLatin1String("PS5")) + const QString d = device.toString(); + if (d == QLatin1String("PS5") || d == QLatin1String("PS4")) return true; } return false; } -static bool isPs5StreamingGame(const QJsonObject &gameObj) +static bool isCloudStreamingGame(const QJsonObject &gameObj) { if (!gameObj.value(QStringLiteral("streamingSupported")).toBool()) return false; - return isPs5Game(gameObj); + return isCloudDeviceGame(gameObj); } static QString ps5CloudConceptKey(const QJsonObject &gameObj) @@ -913,6 +1103,60 @@ static QString ps5CloudConceptKey(const QJsonObject &gameObj) return gameObj.value(QStringLiteral("productId")).toString(); } +// Platform token from a product id's title id: CUSA = PS4, PPSA = PS5. +static QString ps5CloudPlatformToken(const QString &productId) +{ + if (productId.contains(QLatin1String("PPSA"))) + return QStringLiteral("ps5"); + if (productId.contains(QLatin1String("CUSA"))) + return QStringLiteral("ps4"); + return QString(); +} + +// Catalog dedupe identity: one entry per game PER PLATFORM, so a cross-gen title that Sony lists +// as separate PS4 and PS5 editions (e.g. Deliver Us The Moon) shows as two cards, while duplicate +// same-platform SKUs still collapse. (conceptId alone collapsed the PS4/PS5 editions into one.) +static QString ps5CloudEditionKey(const QJsonObject &gameObj) +{ + const QString concept = ps5CloudConceptKey(gameObj); + if (concept.isEmpty()) + return QString(); + return concept + QLatin1Char('|') + + ps5CloudPlatformToken(gameObj.value(QStringLiteral("productId")).toString()); +} + +// A "full game" entitlement (vs an add-on / avatar / theme). PSN marks the base game with +// feature_type 3 and a *GD package_type (PSGD/PS4GD); add-ons use feature_type 0 and +// PS4MISC/PSAL/etc. Used to keep the base game when collapsing same-platform SKUs. +static bool ps5CloudIsFullGameEntitlement(const QJsonObject &ownedGameObj) +{ + if (ownedGameObj.value(QStringLiteral("feature_type")).toInt() == 3) + return true; + const QString pt = ownedGameObj.value(QStringLiteral("game_meta")).toObject() + .value(QStringLiteral("package_type")).toString(); + return pt.endsWith(QStringLiteral("GD")); +} + +// Rank an owned entitlement as THE streaming candidate for its edition (higher = preferred). +// Upgrade / bonus / cross-buy SKUs collapse to the same conceptId+platform as the base game, so we +// must pick which one's product_id the card streams. The package_type/feature_type flags are not +// enough: Death Stranding DC's "Bonus Content" SKU is ALSO PSGD + feature_type 3, identical to the +// game. The reliable signal is that the BASE GAME's entitlement id EQUALS its product_id (e.g. +// ...DEATHSTRANDINGEU == ...DEATHSTRANDINGEU), while bonus/upgrade SKUs carry a different id (the +// bonus is product_id ...DEATHSTRADCDDE01 but id ...PPSA02624...). Prefer the canonical full-game +// entitlement so getStreamingIdentifier streams the real game's product_id, not a DLC product that +// Gaikai has no game for (-> noGameForEntitlementId). +static int ps5CloudOwnedStreamRank(const QJsonObject &ownedGameObj) +{ + const QString id = ownedGameObj.value(QStringLiteral("id")).toString(); + const QString pid = ownedGameObj.value(QStringLiteral("product_id")).toString(); + int rank = 0; + if (!pid.isEmpty() && pid == id) rank += 4; // canonical product (the base game SKU) + if (ps5CloudIsFullGameEntitlement(ownedGameObj)) rank += 2; // full game (feature_type 3 / *GD) + if (!id.isEmpty()) rank += 1; // has a real entitlement id + return rank; +} + static QString ps5CloudProductIdStableKey(const QString &productId) { if (productId.isEmpty()) @@ -946,6 +1190,53 @@ static QMap buildStableKeyIndex(const QJsonArray &games) return index; } +// imagic encodes conceptId as a JSON number; entitlements (if present) may use a +// number or string. Normalize to a non-empty decimal string, else empty. +static QString ps5CloudConceptIdString(const QJsonValue &conceptIdVal) +{ + if (conceptIdVal.isDouble()) { + const qint64 c = static_cast(conceptIdVal.toDouble()); + return c > 0 ? QString::number(c) : QString(); + } + if (conceptIdVal.isString()) + return conceptIdVal.toString(); + return QString(); +} + +// conceptId is region-stable (227770 for God of War 2018) whereas product IDs are +// region-prefixed (EP9000 vs UP9000) and vary by edition, so it is the most reliable +// owned->catalog match when both sides carry one. +static QMap buildConceptIdIndex(const QJsonArray &games) +{ + QMap index; + for (const QJsonValue &game : games) { + if (!game.isObject()) + continue; + const QJsonObject gameObj = game.toObject(); + const QString concept = ps5CloudConceptIdString(gameObj.value(QStringLiteral("conceptId"))); + if (concept.isEmpty() || index.contains(concept)) + continue; + index.insert(concept, gameObj); + } + return index; +} + +// Pull a conceptId out of an owned entitlement, checking the field names the +// commerce API and our merged objects use. Returns empty if none is present. +static QString ownedEntitlementConceptId(const QJsonObject &ownedGameObj) +{ + QString concept = ps5CloudConceptIdString(ownedGameObj.value(QStringLiteral("conceptId"))); + if (concept.isEmpty()) + concept = ps5CloudConceptIdString(ownedGameObj.value(QStringLiteral("concept_id"))); + if (concept.isEmpty()) { + const QJsonObject gameMeta = ownedGameObj.value(QStringLiteral("game_meta")).toObject(); + concept = ps5CloudConceptIdString(gameMeta.value(QStringLiteral("conceptId"))); + if (concept.isEmpty()) + concept = ps5CloudConceptIdString(gameMeta.value(QStringLiteral("concept_id"))); + } + return concept; +} + static QJsonObject productIdAliasesToJson(const QMap &aliases) { QJsonObject obj; @@ -965,6 +1256,18 @@ static QMap productIdAliasesFromJson(const QJsonObject &obj) return aliases; } +// The PS Plus subscription "Game Catalog" (what Sony lists on the PS Plus games page) is the +// union of these curated lists. The other source we fetch, "all-ps5-list", is the entire +// cloud-streamable PS5 universe (~7000 titles) — useful for matching owned games but NOT the +// subscription catalog, so it must not inflate the Catalog tab. +static bool isPlusCatalogList(const QString &categoryList) +{ + return categoryList == QLatin1String("plus-games-list") + || categoryList == QLatin1String("plus-classics-list") + || categoryList == QLatin1String("ubisoft-classics-list") + || categoryList == QLatin1String("plus-monthly-games-list"); +} + static void mergeImagicListIntoPs5Catalog(const QString &categoryList, const QJsonDocument &doc, QMap &gamesByConceptId, @@ -972,6 +1275,7 @@ static void mergeImagicListIntoPs5Catalog(const QString &categoryList, QMap &productIdAliases, int &totalGamesSeen) { + const bool plusCatalog = isPlusCatalogList(categoryList); if (!doc.isArray()) return; @@ -985,36 +1289,53 @@ static void mergeImagicListIntoPs5Catalog(const QString &categoryList, if (!game.isObject()) continue; QJsonObject gameObj = game.toObject(); - if (!isPs5Game(gameObj)) + // Accept both PS4 and PS5 cloud titles. The old PS5-only gate silently + // dropped PS4-only PS-Plus-catalog games (e.g. God of War 2018) before + // they could reach the library-stream supplement below. + if (!isCloudDeviceGame(gameObj)) continue; - // Plus catalog titles excluded from public cloud browse (library-stream candidates) - if (categoryList == QLatin1String("plus-games-list") + // Subscription-catalog titles excluded from public cloud browse (library-stream + // candidates): streamingSupported=false but streamable once owned/acquired. Capture + // these from EVERY subscription list (plus-games, classics, ubisoft, monthly) so the + // Game Catalog includes them too — not just plus-games-list. + if (plusCatalog && !gameObj.value(QStringLiteral("streamingSupported")).toBool()) { const QString productId = gameObj.value(QStringLiteral("productId")).toString(); - if (!productId.isEmpty()) + if (!productId.isEmpty()) { + gameObj.insert(QStringLiteral("plusCatalog"), true); plusLibrarySupplementByProductId.insert(productId, gameObj); + } continue; } - if (!isPs5StreamingGame(gameObj)) + if (!isCloudStreamingGame(gameObj)) continue; - const QString key = ps5CloudConceptKey(gameObj); + // Dedupe per game PER PLATFORM so cross-gen PS4/PS5 editions both appear. + const QString key = ps5CloudEditionKey(gameObj); const QString productId = gameObj.value(QStringLiteral("productId")).toString(); if (key.isEmpty() || productId.isEmpty()) continue; if (gamesByConceptId.contains(key)) { - const QString canonicalProductId = - gamesByConceptId.value(key).value(QStringLiteral("productId")).toString(); + QJsonObject existing = gamesByConceptId.value(key); + const QString canonicalProductId = existing.value(QStringLiteral("productId")).toString(); if (!canonicalProductId.isEmpty() && productId != canonicalProductId && !productIdAliases.contains(productId)) { productIdAliases.insert(productId, canonicalProductId); } + // Lists are fetched in parallel, so a title may be seen first via all-ps5-list + // (not subscription) and later via a subscription list. Upgrade the flag so the + // subscription membership wins regardless of arrival order. + if (plusCatalog && !existing.value(QStringLiteral("plusCatalog")).toBool()) { + existing.insert(QStringLiteral("plusCatalog"), true); + gamesByConceptId.insert(key, existing); + } continue; } + gameObj.insert(QStringLiteral("plusCatalog"), plusCatalog); gamesByConceptId.insert(key, gameObj); } } @@ -1024,10 +1345,6 @@ static void mergeImagicListIntoPs5Catalog(const QString &categoryList, void CloudCatalogBackend::fetchPs5CloudCatalog(const QJSValue &callback) { - // Get locale from unified language setting and convert to lowercase for API - QString localeSetting = settings ? settings->GetCloudLanguagePSCloud() : "en-US"; - QString locale = localeSetting.toLower(); // Convert "en-US" to "en-us" - // Check cache first QString cached = getCachedPs5CatalogV3(CACHE_DURATION_CATALOG); if (!cached.isEmpty()) { @@ -1038,9 +1355,26 @@ void CloudCatalogBackend::fetchPs5CloudCatalog(const QJSValue &callback) } return; } - + qInfo() << "[API CALL] Fetching PS5 cloud catalog (6 imagic lists, cache miss or expired)"; ps5State.callback = callback; + + // Build the store-locale fallback chain (session locale -> en-COUNTRY -> en-US) + // and start with the first tier. Tiers escalate only when a whole tier 404s. + ps5State.localeChain = + buildStoreLocaleFallbackChain(settings ? settings->GetCloudLanguagePSCloud() + : QStringLiteral("en-US")); + ps5State.localeTierIndex = 0; + startPs5ImagicListFetch(); +} + +void CloudCatalogBackend::startPs5ImagicListFetch() +{ + ps5State.activeLocale = ps5State.localeChain.value(ps5State.localeTierIndex, + QStringLiteral("en-US")); + const QString locale = ps5State.activeLocale.toLower(); // imagic wants "en-us" + + // Reset per-tier accumulators so a failed tier leaves nothing behind. ps5State.gamesByConceptId.clear(); ps5State.plusLibrarySupplementByProductId.clear(); ps5State.productIdAliases.clear(); @@ -1050,6 +1384,11 @@ void CloudCatalogBackend::fetchPs5CloudCatalog(const QJSValue &callback) ps5State.failedLists.clear(); ps5State.pendingListFetches = kPs5ImagicCategoryLists.size(); + if (settings && settings->GetLogVerbose()) { + qInfo() << "[API CALL] PS5 imagic fetch using locale tier" << ps5State.localeTierIndex + << ":" << ps5State.activeLocale; + } + for (const QString &categoryList : kPs5ImagicCategoryLists) { const QString url = QStringLiteral( "https://www.playstation.com/bin/imagic/gameslist?locale=%1&categoryList=%2") @@ -1110,6 +1449,17 @@ void CloudCatalogBackend::handlePs5ImagicListResponse() ps5State.pendingListFetches--; if (ps5State.pendingListFetches <= 0) { if (ps5State.succeededListFetches <= 0) { + // The whole tier failed (typically a 404 for an unsupported store + // locale such as hu-HU). Escalate to the next locale tier before + // giving up, so regions Sony only serves in English still load. + if (ps5State.localeTierIndex + 1 < ps5State.localeChain.size()) { + ps5State.localeTierIndex++; + qWarning() << "[API] All imagic lists failed for locale" + << ps5State.activeLocale << "- retrying with" + << ps5State.localeChain.value(ps5State.localeTierIndex); + startPs5ImagicListFetch(); + return; + } if (ps5State.callback.isCallable()) { ps5State.callback.call({false, QStringLiteral("All imagic lists failed to load"), @@ -1152,9 +1502,20 @@ void CloudCatalogBackend::finalizePs5CloudCatalogFetch() qInfo() << " Product ID aliases (same conceptId):" << ps5State.productIdAliases.size(); } + // Persist the locale that actually worked so game-details fetches and the + // cache locale check all agree on it (e.g. a hu-HU account settles on en-HU). + const QString workingLocale = !ps5State.activeLocale.isEmpty() + ? ps5State.activeLocale + : (settings ? settings->GetCloudLanguagePSCloud() + : QStringLiteral("en-US")); + if (settings && settings->GetCloudLanguagePSCloud() != workingLocale) { + qInfo() << "[PSCLOUD] Store locale settled on" << workingLocale + << "(was" << settings->GetCloudLanguagePSCloud() << ")"; + settings->SetCloudLanguagePSCloud(workingLocale); + } + QJsonObject result; - result.insert(QStringLiteral("locale"), - settings ? settings->GetCloudLanguagePSCloud() : QStringLiteral("en-US")); + result.insert(QStringLiteral("locale"), workingLocale); result[QStringLiteral("games")] = allGames; result[QStringLiteral("total")] = allGames.size(); result[QStringLiteral("plusLibrarySupplement")] = plusSupplementGames; @@ -1164,7 +1525,7 @@ void CloudCatalogBackend::finalizePs5CloudCatalogFetch() const QJsonDocument resultDoc(result); if (ps5State.allPs5ListSucceeded) - setCachedData(QStringLiteral("ps5_cloud_catalog_v3"), resultDoc); + setCachedData(QStringLiteral("ps5_cloud_catalog_v6"), resultDoc); QString callbackMessage = QStringLiteral("Success"); if (!ps5State.failedLists.isEmpty()) { @@ -1525,6 +1886,9 @@ void CloudCatalogBackend::handleOwnedGamesResponse() // Filter for PS5 games (package_type=PSGD) QJsonArray ps5Games = filterOwnedPs5Games(ownedGamesState.accumulatedEntitlements); + // Map each bundle product_id -> the entitlement ids that share it, so a bundle (e.g. RE7 Gold, + // whose components each carry the bundle product_id but a distinct entitlement id) can expand to + // its component games during cross-reference (upstream PR #15's bundle-sibling matching). QMap componentIds; for (const QJsonValue &ent : ownedGamesState.accumulatedEntitlements) { if (!ent.isObject()) @@ -1535,11 +1899,11 @@ void CloudCatalogBackend::handleOwnedGamesResponse() if (!pid.isEmpty() && !eid.isEmpty()) componentIds[pid].append(eid); } - + if (settings && settings->GetLogVerbose()) { qInfo() << " PS5 games (PSGD):" << ps5Games.size(); } - + QJsonObject result; result["games"] = ps5Games; result["total"] = ps5Games.size(); @@ -1547,12 +1911,12 @@ void CloudCatalogBackend::handleOwnedGamesResponse() for (auto it = componentIds.cbegin(); it != componentIds.cend(); ++it) componentObj.insert(it.key(), QJsonArray::fromStringList(it.value())); result[QStringLiteral("componentIdsByProductId")] = componentObj; - + QJsonDocument resultDoc(result); - + // Cache the result setCachedData("ps5_cloud_library", resultDoc); - + // If cross-reference is active, populate its state if (crossReferenceState.callback.isCallable() && !crossReferenceState.ownedGamesFetched) { crossReferenceState.ownedGames = ps5Games; @@ -1579,78 +1943,83 @@ QJsonArray CloudCatalogBackend::filterOwnedPs5Games(const QJsonArray &entitlemen QJsonArray ps5Games; for (const QJsonValue &ent : entitlements) { - if (ent.isObject()) { - QJsonObject entObj = ent.toObject(); - - // Check for game_meta and package_type - if (entObj.contains("game_meta") && entObj["game_meta"].isObject()) { - QJsonObject gameMeta = entObj["game_meta"].toObject(); - QString packageType = gameMeta["package_type"].toString(); - - // Filter for PS5 games (PSGD) - if (packageType == "PSGD") { - // Skip inactive games (active_flag must be true) - bool activeFlag = entObj.contains("active_flag") && entObj["active_flag"].toBool(); - if (!activeFlag) { - continue; - } - - // Skip subscriptions/services (Product IDs starting with IP or SUB) - QString productId = entObj["product_id"].toString(); - if (!productId.startsWith("IP") && !productId.startsWith("SUB")) { - // Extract cover image from game_meta.icon_url (this is the primary field for entitlements API) - QString coverImageUrl; - - // Check game_meta.icon_url first (this is where the API returns images) - if (gameMeta.contains("icon_url")) { - coverImageUrl = gameMeta["icon_url"].toString(); - } - - // Fallback: try extractCoverImageFromGameObject for images array if present - if (coverImageUrl.isEmpty()) { - coverImageUrl = extractCoverImageFromGameObject(gameMeta); - } - if (coverImageUrl.isEmpty()) { - coverImageUrl = extractCoverImageFromGameObject(entObj); - } - - // Additional fallbacks for other common image field names - if (coverImageUrl.isEmpty()) { - if (gameMeta.contains("imageUrl")) { - coverImageUrl = gameMeta["imageUrl"].toString(); - } else if (gameMeta.contains("image_url")) { - coverImageUrl = gameMeta["image_url"].toString(); - } else if (gameMeta.contains("thumbnail_url")) { - coverImageUrl = gameMeta["thumbnail_url"].toString(); - } else if (entObj.contains("imageUrl")) { - coverImageUrl = entObj["imageUrl"].toString(); - } else if (entObj.contains("image_url")) { - coverImageUrl = entObj["image_url"].toString(); - } else if (entObj.contains("thumbnail_url")) { - coverImageUrl = entObj["thumbnail_url"].toString(); - } - } - - if (!coverImageUrl.isEmpty()) { - entObj["imageUrl"] = coverImageUrl; - if (settings && settings->GetLogVerbose()) { - QString gameName = gameMeta.contains("name") ? gameMeta["name"].toString() : productId; - qInfo() << " Extracted cover image for PS5 game:" << gameName << "from icon_url"; - } - } else { - if (settings && settings->GetLogVerbose()) { - QString gameName = gameMeta.contains("name") ? gameMeta["name"].toString() : productId; - qInfo() << " No image found in entitlement response for PS5 game:" << gameName; - } - } - - ps5Games.append(entObj); - } - } + if (!ent.isObject()) + continue; + QJsonObject entObj = ent.toObject(); + + // Must look like a game entitlement (has game_meta). + if (!entObj.contains("game_meta") || !entObj["game_meta"].isObject()) + continue; + QJsonObject gameMeta = entObj["game_meta"].toObject(); + const QString packageType = gameMeta["package_type"].toString(); + + // Skip inactive entitlements (active_flag must be true). + const bool activeFlag = entObj.contains("active_flag") && entObj["active_flag"].toBool(); + if (!activeFlag) + continue; + + // Skip subscriptions/services (Product IDs starting with IP or SUB). + const QString productId = entObj["product_id"].toString(); + if (productId.startsWith("IP") || productId.startsWith("SUB")) + continue; + + // Hide EXTRAS: feature_type==0 is DLC / add-ons / themes / avatars / season passes / cross-buy + // "tracks" (PS4MISC/PSAL/PSTRACK/PS4AC/...), NEVER a base game -- every *GD game package is + // feature_type 1 (trial / free-to-play) or 3/5 (full game). Dropping ft==0 keeps add-ons from + // cluttering the library or marking a game "owned" via DLC, and is safe (it can't hide a game). + // Trials/free (ft1) and full games (ft3/5) are KEPT; the trial-vs-full split is handled when + // merging owned games into the catalog (a trial stays its own card so the full version can + // still show "Add Game"). + if (entObj.value(QStringLiteral("feature_type")).toInt() == 0) + continue; + + // Previously this required package_type == "PSGD" (PS5 only), which dropped + // owned PS4 titles (e.g. God of War 2018) and PS3 titles. We now accept every + // active game entitlement; streamability is enforced downstream by the catalog + // cross-reference (only titles present in the streamable catalog/supplement are + // shown), and matches are deduped by conceptId, so non-streamable or add-on + // entitlements are harmlessly dropped there. + const QString gameName = gameMeta.contains("name") ? gameMeta["name"].toString() : productId; + if (settings && settings->GetLogVerbose()) { + qInfo() << " Owned entitlement:" << gameName + << "package_type:" << (packageType.isEmpty() ? QStringLiteral("(none)") : packageType) + << "product_id:" << productId; + } + + // Extract cover image from game_meta.icon_url (the primary field for the entitlements API). + QString coverImageUrl; + if (gameMeta.contains("icon_url")) { + coverImageUrl = gameMeta["icon_url"].toString(); + } + if (coverImageUrl.isEmpty()) { + coverImageUrl = extractCoverImageFromGameObject(gameMeta); + } + if (coverImageUrl.isEmpty()) { + coverImageUrl = extractCoverImageFromGameObject(entObj); + } + // Additional fallbacks for other common image field names. + if (coverImageUrl.isEmpty()) { + if (gameMeta.contains("imageUrl")) { + coverImageUrl = gameMeta["imageUrl"].toString(); + } else if (gameMeta.contains("image_url")) { + coverImageUrl = gameMeta["image_url"].toString(); + } else if (gameMeta.contains("thumbnail_url")) { + coverImageUrl = gameMeta["thumbnail_url"].toString(); + } else if (entObj.contains("imageUrl")) { + coverImageUrl = entObj["imageUrl"].toString(); + } else if (entObj.contains("image_url")) { + coverImageUrl = entObj["image_url"].toString(); + } else if (entObj.contains("thumbnail_url")) { + coverImageUrl = entObj["thumbnail_url"].toString(); } } + if (!coverImageUrl.isEmpty()) { + entObj["imageUrl"] = coverImageUrl; + } + + ps5Games.append(entObj); } - + return ps5Games; } @@ -1722,6 +2091,7 @@ void CloudCatalogBackend::getOwnedPs5CloudGames(const QJSValue &callback) qInfo() << "[CROSS-REF] Loaded owned PS5 games from cache:" << crossReferenceState.ownedGames.size() << "games"; } } + // Bundle->components map for bundle-sibling matching (upstream PR #15). if (obj.contains(QStringLiteral("componentIdsByProductId")) && obj.value(QStringLiteral("componentIdsByProductId")).isObject()) { const QJsonObject m = obj.value(QStringLiteral("componentIdsByProductId")).toObject(); @@ -1735,7 +2105,7 @@ void CloudCatalogBackend::getOwnedPs5CloudGames(const QJSValue &callback) } } } - + // If we have both from cache, process immediately if (catalogFromCache && ownedFromCache) { processCrossReferenceComplete(); @@ -2072,7 +2442,7 @@ QString CloudCatalogBackend::getGameLandscapeImageFromCache(const QString &servi } // Fallback to catalog (may not have landscape images) - cacheKey = "ps5_cloud_catalog_v3"; + cacheKey = "ps5_cloud_catalog_v6"; isPsCloudLibrary = false; } else { qWarning() << "getGameLandscapeImage: Unknown service type:" << serviceType; @@ -2080,7 +2450,7 @@ QString CloudCatalogBackend::getGameLandscapeImageFromCache(const QString &servi } // Load cache - use very large maxAge to never invalidate cache (read-only operation) - QString cached = (cacheKey == QLatin1String("ps5_cloud_catalog_v3")) + QString cached = (cacheKey == QLatin1String("ps5_cloud_catalog_v6")) ? getCachedPs5CatalogV3(INT_MAX) : getCachedData(cacheKey, INT_MAX); if (cached.isEmpty()) { @@ -2262,20 +2632,29 @@ void CloudCatalogBackend::processCrossReferenceComplete() buildStableKeyIndex(crossReferenceState.cloudCatalogGames); const QMap supplementStableKey = buildStableKeyIndex(crossReferenceState.plusLibrarySupplement); + const QMap browseByConcept = + buildConceptIdIndex(crossReferenceState.cloudCatalogGames); + const QMap supplementByConcept = + buildConceptIdIndex(crossReferenceState.plusLibrarySupplement); if (settings && settings->GetLogVerbose()) { qInfo() << "[CROSS-REF] Cloud catalog map size:" << cloudCatalogMap.size(); qInfo() << "[CROSS-REF] Product ID aliases:" << crossReferenceState.productIdAliases.size(); qInfo() << "[CROSS-REF] Plus library supplement map size:" << plusSupplementMap.size(); + qInfo() << "[CROSS-REF] Concept-id index (browse/supplement):" + << browseByConcept.size() << "/" << supplementByConcept.size(); qInfo() << "[CROSS-REF] Owned games count:" << crossReferenceState.ownedGames.size(); } QJsonArray filteredGames; int matchedCount = 0; - int t1Count = 0; - int t2Count = 0; - int t3Count = 0; - int t4Count = 0; + int productIdMatchCount = 0; + int entitlementIdMatchCount = 0; + int supplementMatchCount = 0; + int conceptIdBrowseMatchCount = 0; + int conceptIdSupplementMatchCount = 0; + int stableKeyBrowseMatchCount = 0; + int stableKeySupplementMatchCount = 0; QMap ownedByKey; for (const QJsonValue &ownedGame : crossReferenceState.ownedGames) { @@ -2288,84 +2667,16 @@ void CloudCatalogBackend::processCrossReferenceComplete() const QString entName = ownedGameObj.value(QStringLiteral("game_meta")).toObject() .value(QStringLiteral("name")).toString(); const bool skipStableDemo = entName.contains(QStringLiteral("demo"), Qt::CaseInsensitive); - - QList> matches; - int matchTier = 0; - - if (!productId.isEmpty() && cloudCatalogMap.contains(productId)) { - matches.append({cloudCatalogMap.value(productId), false}); - matchTier = 1; - } else if (!entitlementId.isEmpty() && cloudCatalogMap.contains(entitlementId)) { - matches.append({cloudCatalogMap.value(entitlementId), false}); - matchTier = 2; - } else if (!productId.isEmpty() && !entitlementId.isEmpty() - && entitlementId == productId && plusSupplementMap.contains(productId)) { - matches.append({plusSupplementMap.value(productId), true}); - matchTier = 2; - } else { - const QString entitlementStableKey = ps5CloudProductIdStableKey(entitlementId); - if (!entitlementStableKey.isEmpty() && !skipStableDemo - && browseStableKey.contains(entitlementStableKey)) { - matches.append({browseStableKey.value(entitlementStableKey), false}); - matchTier = 3; - } else if (!entitlementStableKey.isEmpty() && !skipStableDemo - && supplementStableKey.contains(entitlementStableKey)) { - matches.append({supplementStableKey.value(entitlementStableKey), true}); - matchTier = 3; - } - } - - if (matches.isEmpty()) { - QSet seenProductIds; - for (const QString &siblingId : - crossReferenceState.componentIdsByProductId.value(productId)) { - QJsonObject siblingMeta; - bool siblingFromSupplement = false; - if (cloudCatalogMap.contains(siblingId)) { - siblingMeta = cloudCatalogMap.value(siblingId); - } else if (plusSupplementMap.contains(siblingId)) { - siblingMeta = plusSupplementMap.value(siblingId); - siblingFromSupplement = true; - } else { - const QString siblingStableKey = ps5CloudProductIdStableKey(siblingId); - if (!siblingStableKey.isEmpty() && !skipStableDemo) { - if (browseStableKey.contains(siblingStableKey)) { - siblingMeta = browseStableKey.value(siblingStableKey); - } else if (supplementStableKey.contains(siblingStableKey)) { - siblingMeta = supplementStableKey.value(siblingStableKey); - siblingFromSupplement = true; - } - } - } - if (siblingMeta.isEmpty()) - continue; - const QString matchedPid = - siblingMeta.value(QStringLiteral("productId")).toString(); - if (matchedPid.isEmpty() || seenProductIds.contains(matchedPid)) - continue; - seenProductIds.insert(matchedPid); - matches.append({siblingMeta, siblingFromSupplement}); - } - if (!matches.isEmpty()) - matchTier = 4; - } - - if (matches.isEmpty()) - continue; - - switch (matchTier) { - case 1: t1Count++; break; - case 2: t2Count++; break; - case 3: t3Count++; break; - case 4: t4Count++; break; - default: break; - } - - for (const QPair &match : matches) { - const QJsonObject meta = match.first; - const bool fromSupplement = match.second; + const QString stableKey = ps5CloudProductIdStableKey(productId); + const QString entStableKey = ps5CloudProductIdStableKey(entitlementId); + const QString ownedConceptId = ownedEntitlementConceptId(ownedGameObj); + + // Enrich the owned entitlement with a matched catalog row and dedupe it into ownedByKey, in + // OUR field convention (catalogProductId = streamable catalog variant, productId = owned + // product, conceptId+PLATFORM dedupe, canonical-entitlement rank). Called once for a direct + // match, or once per component for a bundle (upstream PR #15 bundle-sibling expansion). + auto emitOwned = [&](const QJsonObject &meta, bool fromSupplement) { QJsonObject entry = ownedGameObj; - if (meta.contains(QStringLiteral("name"))) { const QString imagicName = meta.value(QStringLiteral("name")).toString(); if (!imagicName.isEmpty()) @@ -2378,48 +2689,202 @@ void CloudCatalogBackend::processCrossReferenceComplete() if (meta.contains(QStringLiteral("conceptUrl"))) { entry.insert(QStringLiteral("conceptUrl"), meta.value(QStringLiteral("conceptUrl"))); } - // Identify the owned entry by the MATCHED CATALOG ROW (productId + - // conceptId) so the QML merge (findPs5CloudCatalogIndexForOwned) can - // link it back to the catalog card. Using the entitlement's bundle - // product_id here breaks T3/T4 matches whose entitlement id/product_id - // do not equal any catalog productId (e.g. RE7 base reached via the - // RE7 Gold bundle). The entitlement product_id is retained as - // storeProductId for streaming/store lookups. - const QString catalogProductId = meta.value(QStringLiteral("productId")).toString(); - entry.insert(QStringLiteral("productId"), - !catalogProductId.isEmpty() ? catalogProductId : productId); - entry.insert(QStringLiteral("storeProductId"), productId); - - // conceptId may be a JSON number or string; normalize to a string. - const QJsonValue conceptVal = meta.value(QStringLiteral("conceptId")); - const QString conceptId = conceptVal.isString() - ? conceptVal.toString() - : (conceptVal.isDouble() - ? QString::number(static_cast(conceptVal.toDouble())) - : QString()); - if (!conceptId.isEmpty()) - entry.insert(QStringLiteral("conceptId"), conceptId); + // Carry the catalog device list so the UI can tell PS4 from PS5 and route PS4 via Kamaji. + if (meta.contains(QStringLiteral("device"))) { + entry.insert(QStringLiteral("device"), meta.value(QStringLiteral("device"))); + } + // Cloud streaming binds to the catalog product variant (carries the PS Plus streaming + // offer), not the owned download product (e.g. God of War owned ...GODOFWAR vs catalog + // ...GODOFWARN). Keep the catalog productId so PS4 streaming converts the right variant. + const QString metaProductId = meta.value(QStringLiteral("productId")).toString(); + if (!metaProductId.isEmpty()) + entry.insert(QStringLiteral("catalogProductId"), metaProductId); + entry.insert(QStringLiteral("productId"), productId); entry.insert(QStringLiteral("streamingSupported"), !fromSupplement); - // Dedupe by the MATCHED CATALOG identity (conceptId, then catalog - // productId). Using the entitlement product_id here collapses every - // bundle sibling (e.g. RE7 Gold -> RE7 base + Village) into a single - // entry, dropping all but the first match. - const QString dedupeKey = !conceptId.isEmpty() ? QStringLiteral("c:") + conceptId - : !catalogProductId.isEmpty() ? QStringLiteral("p:") + catalogProductId + const QString conceptId = ps5CloudConceptIdString(meta.value(QStringLiteral("conceptId"))); + if (!conceptId.isEmpty()) + entry.insert(QStringLiteral("conceptId"), conceptId); + // Dedupe identity = conceptId + PLATFORM (the catalog edition key): a cross-gen title + // owned on PS4 and PS5 stays as two cards; same-platform SKUs (bonus/avatars) merge. + const QString platformToken = ps5CloudPlatformToken(productId); + const QString dedupeKey = !conceptId.isEmpty() ? QStringLiteral("c:") + conceptId + QLatin1Char(':') + platformToken + : !productId.isEmpty() ? QStringLiteral("p:") + productId : !entitlementId.isEmpty() ? QStringLiteral("e:") + entitlementId - : QStringLiteral("u:") + catalogProductId + QLatin1Char(':') + entitlementId; - + : QStringLiteral("u:") + productId + QLatin1Char(':') + entitlementId; if (ownedByKey.contains(dedupeKey)) { const QJsonObject existing = ownedByKey.value(dedupeKey); - const QString existingEntId = existing.value(QStringLiteral("id")).toString(); - if (existingEntId.isEmpty() && !entitlementId.isEmpty()) + // Keep the best streaming candidate: the canonical full-game entitlement (its + // product_id is the real streamable game, not a DLC/bonus product Gaikai rejects). + if (ps5CloudOwnedStreamRank(entry) > ps5CloudOwnedStreamRank(existing)) ownedByKey.insert(dedupeKey, entry); } else { ownedByKey.insert(dedupeKey, entry); } matchedCount++; + }; + + QJsonObject meta; + bool found = false; + bool fromSupplement = false; + + if (!productId.isEmpty() && cloudCatalogMap.contains(productId)) { + meta = cloudCatalogMap.value(productId); + found = true; + productIdMatchCount++; + } else if (!entitlementId.isEmpty() && cloudCatalogMap.contains(entitlementId)) { + meta = cloudCatalogMap.value(entitlementId); + found = true; + entitlementIdMatchCount++; + } else if (!ownedConceptId.isEmpty() && browseByConcept.contains(ownedConceptId)) { + meta = browseByConcept.value(ownedConceptId); + found = true; + conceptIdBrowseMatchCount++; + } else if (!ownedConceptId.isEmpty() && supplementByConcept.contains(ownedConceptId)) { + meta = supplementByConcept.value(ownedConceptId); + found = true; + fromSupplement = true; + conceptIdSupplementMatchCount++; + } else if (!productId.isEmpty() && !entitlementId.isEmpty() + && entitlementId == productId && plusSupplementMap.contains(productId)) { + meta = plusSupplementMap.value(productId); + found = true; + fromSupplement = true; + supplementMatchCount++; + } else if (!stableKey.isEmpty() && !skipStableDemo && browseStableKey.contains(stableKey)) { + meta = browseStableKey.value(stableKey); + found = true; + stableKeyBrowseMatchCount++; + } else if (!stableKey.isEmpty() && !skipStableDemo + && supplementStableKey.contains(stableKey)) { + meta = supplementStableKey.value(stableKey); + found = true; + fromSupplement = true; + stableKeySupplementMatchCount++; + } else if (!entStableKey.isEmpty() && !skipStableDemo && browseStableKey.contains(entStableKey)) { + // Stable-key match on the ENTITLEMENT id (upstream PR #15): catches cross-gen / upgrade + // entitlement ids whose stable key matches a catalog row even when product_id did not. + meta = browseStableKey.value(entStableKey); + found = true; + stableKeyBrowseMatchCount++; + } else if (!entStableKey.isEmpty() && !skipStableDemo && supplementStableKey.contains(entStableKey)) { + meta = supplementStableKey.value(entStableKey); + found = true; + fromSupplement = true; + stableKeySupplementMatchCount++; + } + + if (found) { + emitOwned(meta, fromSupplement); + continue; + } + + // Bundle-sibling expansion (upstream PR #15): a bundle entitlement (e.g. RE7 Gold) has no + // direct catalog row, but its component entitlement ids each map to a component game. Emit + // each distinct component as its own owned entry (via emitOwned, so OUR dedupe/rank apply). + QStringList seenMetaPids; + for (const QString &siblingId : crossReferenceState.componentIdsByProductId.value(productId)) { + QJsonObject siblingMeta; + bool siblingFromSupplement = false; + if (cloudCatalogMap.contains(siblingId)) { + siblingMeta = cloudCatalogMap.value(siblingId); + } else if (plusSupplementMap.contains(siblingId)) { + siblingMeta = plusSupplementMap.value(siblingId); + siblingFromSupplement = true; + } else { + const QString siblingStableKey = ps5CloudProductIdStableKey(siblingId); + if (!siblingStableKey.isEmpty() && !skipStableDemo) { + if (browseStableKey.contains(siblingStableKey)) { + siblingMeta = browseStableKey.value(siblingStableKey); + } else if (supplementStableKey.contains(siblingStableKey)) { + siblingMeta = supplementStableKey.value(siblingStableKey); + siblingFromSupplement = true; + } + } + } + if (siblingMeta.isEmpty()) + continue; + const QString siblingPid = siblingMeta.value(QStringLiteral("productId")).toString(); + if (siblingPid.isEmpty() || seenMetaPids.contains(siblingPid)) + continue; + seenMetaPids.append(siblingPid); + emitOwned(siblingMeta, siblingFromSupplement); + } + } + + // Disc-upgrade rescue. feature_type 5 marks a PS4-disc -> PS5 *disc upgrade* license; Gaikai + // refuses to cloud-stream it ("disc-upgrade-unsupported"). The browse catalog often binds a + // concept to exactly that SKU (e.g. Horizon Forbidden West concept 10000886 -> PPSA01521), while + // the user's streamable full-game entitlement is a DIFFERENT title id (e.g. Complete Edition + // PPSA17903) that is absent from the catalog and carries no conceptId -- so the cross-reference + // never matches it and only the disc-upgrade SKU survives the dedupe. When a concept winner is a + // disc upgrade, adopt the product id of a same-name full-game (feature_type 3) owned SKU so the + // card streams the edition Gaikai accepts. + // + // Entitlements carry no conceptId (the commerce API omits it) and the disc-upgrade SKU shares no + // id/sku with the real edition, so the only in-data bridge is the title name. To keep that safe: + // - SAME PLATFORM only (a PS5/PPSA disc upgrade must never pull in a PS4/CUSA SKU of a + // same-named game), + // - prefer the CANONICAL base game (product_id == entitlement id, i.e. not a bundle/add-on SKU), + // - and BAIL on genuine ambiguity (two distinct base games sharing one name) rather than guess. + auto normalizeTitle = [](const QString &raw) { + return raw.toLower().remove(QChar(0x2122)).remove(QChar(0x00AE)).remove(QChar(0x2120)).simplified(); + }; + for (auto it = ownedByKey.begin(); it != ownedByKey.end(); ++it) { + QJsonObject entry = it.value(); + if (entry.value(QStringLiteral("feature_type")).toInt() != 5) + continue; + const QString discPid = entry.value(QStringLiteral("product_id")).toString(); + const QString discPlatform = ps5CloudPlatformToken(discPid); + const QString discName = normalizeTitle(entry.value(QStringLiteral("game_meta")).toObject() + .value(QStringLiteral("name")).toString()); + if (discName.isEmpty()) + continue; + QStringList canonicalPids; // base-game SKUs (product_id == entitlement id) + QStringList otherPids; // non-canonical full-game SKUs (bundle/edition products) + for (const QJsonValue &candVal : crossReferenceState.ownedGames) { + if (!candVal.isObject()) + continue; + const QJsonObject cand = candVal.toObject(); + // Require a standard digital full game (feature_type 3) -- not another disc upgrade, + // DLC/add-on (ft 0) or trial (ft 1) -- whose name matches the disc-upgrade title. + if (cand.value(QStringLiteral("feature_type")).toInt() != 3) + continue; + const QString candName = normalizeTitle(cand.value(QStringLiteral("game_meta")).toObject() + .value(QStringLiteral("name")).toString()); + if (candName != discName) + continue; + const QString candPid = cand.value(QStringLiteral("product_id")).toString(); + if (candPid.isEmpty() || candPid == discPid) + continue; + if (ps5CloudPlatformToken(candPid) != discPlatform) + continue; // never cross platforms (PS5 disc upgrade must not resolve to a PS4 SKU) + const QString candId = cand.value(QStringLiteral("id")).toString(); + if (candPid == candId) { + if (!canonicalPids.contains(candPid)) canonicalPids.append(candPid); + } else { + if (!otherPids.contains(candPid)) otherPids.append(candPid); + } + } + // A single canonical base game wins; else a single non-canonical full game; else bail. + QString replacementPid; + if (canonicalPids.size() == 1) + replacementPid = canonicalPids.first(); + else if (canonicalPids.isEmpty() && otherPids.size() == 1) + replacementPid = otherPids.first(); + if (replacementPid.isEmpty()) { + if (!canonicalPids.isEmpty() || !otherPids.isEmpty()) + qWarning() << "[CROSS-REF] disc-upgrade rescue: ambiguous full-game candidates for" + << discName << "canonical=" << canonicalPids << "other=" << otherPids + << "-- leaving disc-upgrade SKU in place"; + continue; } + entry.insert(QStringLiteral("product_id"), replacementPid); + entry.insert(QStringLiteral("productId"), replacementPid); + entry.insert(QStringLiteral("catalogProductId"), replacementPid); + it.value() = entry; + qInfo() << "[CROSS-REF] disc-upgrade rescue:" << discName << ":" << discPid + << "-> streamable" << replacementPid; } for (const QJsonObject &gameObj : ownedByKey) @@ -2427,10 +2892,13 @@ void CloudCatalogBackend::processCrossReferenceComplete() if (settings && settings->GetLogVerbose()) { qInfo() << "[CROSS-REF] Matched games (cloud streamable):" << matchedCount; - qInfo() << "[CROSS-REF] T1 (product_id):" << t1Count; - qInfo() << "[CROSS-REF] T2 (entitlement id):" << t2Count; - qInfo() << "[CROSS-REF] T3 (stable key on id):" << t3Count; - qInfo() << "[CROSS-REF] T4 (bundle siblings):" << t4Count; + qInfo() << "[CROSS-REF] By product_id:" << productIdMatchCount; + qInfo() << "[CROSS-REF] By entitlement id (fallback):" << entitlementIdMatchCount; + qInfo() << "[CROSS-REF] By Plus library supplement:" << supplementMatchCount; + qInfo() << "[CROSS-REF] By conceptId (browse):" << conceptIdBrowseMatchCount; + qInfo() << "[CROSS-REF] By conceptId (supplement):" << conceptIdSupplementMatchCount; + qInfo() << "[CROSS-REF] By stable product id key (browse):" << stableKeyBrowseMatchCount; + qInfo() << "[CROSS-REF] By stable product id key (supplement):" << stableKeySupplementMatchCount; } QJsonObject result; @@ -2457,8 +2925,8 @@ void CloudCatalogBackend::processCrossReferenceComplete() void CloudCatalogBackend::invalidatePs5CatalogCache() { for (const QString &key : - {QStringLiteral("ps5_cloud_catalog_v3"), QStringLiteral("ps5_cloud_catalog_v2"), - QStringLiteral("ps5_cloud_catalog")}) { + {QStringLiteral("ps5_cloud_catalog_v6"), QStringLiteral("ps5_cloud_catalog_v5"), QStringLiteral("ps5_cloud_catalog_v4"), QStringLiteral("ps5_cloud_catalog_v3"), + QStringLiteral("ps5_cloud_catalog_v2"), QStringLiteral("ps5_cloud_catalog")}) { const QString path = getCacheFilePath(key); if (QFile::exists(path)) { QFile::remove(path); @@ -2471,7 +2939,7 @@ void CloudCatalogBackend::invalidateCache() { // Invalidate specific cache files (PSNOW, PS5 cloud catalog, and PS5 cloud library) QString psnowPath = getCacheFilePath("psnow_catalog"); - QString ps5CatalogPath = getCacheFilePath("ps5_cloud_catalog_v3"); + QString ps5CatalogPath = getCacheFilePath("ps5_cloud_catalog_v6"); QString ps5CatalogV2Path = getCacheFilePath("ps5_cloud_catalog_v2"); QString ps5LibraryPath = getCacheFilePath("ps5_cloud_library"); diff --git a/gui/src/cloudstreaming/psgaikaistreaming.cpp b/gui/src/cloudstreaming/psgaikaistreaming.cpp index 9cc4d09e..d0e569e2 100644 --- a/gui/src/cloudstreaming/psgaikaistreaming.cpp +++ b/gui/src/cloudstreaming/psgaikaistreaming.cpp @@ -1206,9 +1206,13 @@ void PSGaikaiStreaming::step11_GetDatacenters() if(pingResultsMap.contains(datacenterName)) { // Use actual ping result - allResults.append(pingResultsMap[datacenterName]); + QJsonObject measured = pingResultsMap[datacenterName]; + measured["measured"] = true; // real RTT measurement + allResults.append(measured); } else { - // Use dummy data (999ms RTT) for datacenters that weren't pinged + // Use dummy data (999ms RTT) for datacenters that weren't pinged. + // Mark it unmeasured so the latency gate doesn't treat a failed + // measurement as genuinely-high latency. QJsonObject dummyResult; dummyResult["dataCenter"] = datacenterName; dummyResult["rtt"] = 999; @@ -1218,6 +1222,7 @@ void PSGaikaiStreaming::step11_GetDatacenters() dummyResult["port"] = dc["port"].toInt(); dummyResult["publicIp"] = dc["publicIp"].toString(); dummyResult["maxBandwidth"] = dc["maxBandwidth"].toInt(); + dummyResult["measured"] = false; allResults.append(dummyResult); } } @@ -1314,17 +1319,25 @@ void PSGaikaiStreaming::step12_SelectDatacenter(QJsonArray pingResults) } } - // Validate ping for auto-selected datacenters (manual selection bypasses this check) + // Validate ping for auto-selected datacenters (manual selection bypasses this check). + // Only gate on a REAL measurement: when the ping couldn't complete the result is a + // fabricated 999ms placeholder (measured=false), which must not be mistaken for genuine + // high latency — otherwise a transient ping failure blocks an otherwise-fine datacenter. bool isAutoSelected = (selectedDatacenterSetting == "Auto" || selectedDatacenterSetting.isEmpty()); if (isAutoSelected) { + const bool measured = selectedDatacenterPingResult.value("measured").toBool(false); int rtt_ms = selectedDatacenterPingResult["rtt"].toInt(0); - if (rtt_ms > 80) { + if (measured && rtt_ms > 80) { qWarning() << "Selected datacenter ping too high:" << selectedDatacenter << "RTT:" << rtt_ms << "ms (max: 80ms)"; emit pingTimeoutError(); emit AllocationError("Ping must be < 80ms to start a cloud session"); emit Finished(); return; } + if (!measured) { + qWarning() << "Datacenter latency could not be measured for" << selectedDatacenter + << "- proceeding without the latency gate (ping measurement failed, not necessarily high latency)"; + } } emit AllocationProgress(QString("Selecting Datacenter (%1) - Step 9 of 10").arg(selectedDatacenter)); diff --git a/gui/src/cloudstreaming/pskamajisession.cpp b/gui/src/cloudstreaming/pskamajisession.cpp index 71366781..6435d56b 100644 --- a/gui/src/cloudstreaming/pskamajisession.cpp +++ b/gui/src/cloudstreaming/pskamajisession.cpp @@ -325,7 +325,22 @@ void PSKamajiSession::step0_5d_ConvertProductId() QStringList localeParts = locale.split("-"); QString country = localeParts.size() > 1 ? localeParts[1].toUpper() : "US"; QString language = localeParts[0].toLower(); - + + // PS3 / Classics product ids (NPEA/NPEB/BLES/BCES or NPUA/NPUB/BLUS/BCUS -- anything + // that isn't a modern CUSA/PPSA id) come from the public Apollo catalog, which we walk + // in the account's region group (Americas -> US store, everything else -> PAL/GB). + // Resolve them against that SAME region's container so the lookup finds the product and + // returns the PSNW entitlement the account is authorized for at Gaikai. The account's + // own locale country can be a region with no pcnow storefront (e.g. Hungary -> "Storefront + // not found"), and the wrong region's ids return 401 "invalidEntitlement", so map to the + // region-group store. Must match CloudCatalogBackend's PS3 catalog source. + const bool isLegacyClassicsId = !productId.contains(QLatin1String("CUSA")) + && !productId.contains(QLatin1String("PPSA")); + if (isLegacyClassicsId) { + country = KamajiConsts::classicsStoreCountry(country); + language = QStringLiteral("en"); + } + QString url = QString("https://psnow.playstation.com/store/api/pcnow/00_09_000/container/%1/%2/19/%3?useOffers=true&gkb=1&gkb2=1") .arg(country, language, productId); @@ -478,15 +493,76 @@ void PSKamajiSession::handleProductIdConversionResponse(QNetworkReply *reply) if (!streamingEntitlementId.isEmpty()) break; } } - - // Determine platform from playable_platform strings (pick highest: PS4 > PS3) + + // PS Plus catalog titles (e.g. PS4 games via PS Plus Premium) don't carry a per-game + // streaming license (license_type == 4) like the old PS Now catalog did — their full-game + // entitlement is license_type 0 with packageType "PS4GD"/"PS5GD"/"PSGD", streamable via the + // PS Plus subscription. Fall back to that full-game entitlement so step0_5e can acquire it. + if (streamingEntitlementId.isEmpty()) { + // Title id of the requested product, e.g. "EP1464-CUSA24653_00-..." -> "CUSA24653". + // Cross-gen containers list BOTH the PS4 (CUSA) and PS5 (PPSA) full-game entitlements; + // we must pick the one matching the requested product so the entitlement platform stays + // consistent with the streaming session (a PS5 entitlement on a PS4/kratos session makes + // the senkusha ping server never ack -> 0/5 pings -> allocation fails). + QString requestedTitleId; + { + const QStringList dashParts = productId.split(QLatin1Char('-')); + if (dashParts.size() >= 2) + requestedTitleId = dashParts[1].split(QLatin1Char('_')).value(0); + } + auto pickFullGameEntitlement = [&](const QJsonObject &skuObj, bool requireTitleMatch) -> bool { + if (!skuObj.contains("entitlements") || !skuObj["entitlements"].isArray()) + return false; + const QJsonArray entitlements = skuObj["entitlements"].toArray(); + for (const QJsonValue &entValue : entitlements) { + const QJsonObject ent = entValue.toObject(); + const QString entId = ent["id"].toString(); + const QString pkgType = ent["packageType"].toString(); + // Full game digital ("*GD"); skip add-ons (PS4AL), themes (PS4MISC), etc. + if (entId.isEmpty() || !pkgType.endsWith(QStringLiteral("GD"))) + continue; + if (requireTitleMatch && !requestedTitleId.isEmpty() && !entId.contains(requestedTitleId)) + continue; + streamingEntitlementId = entId; + sku = skuObj["id"].toString(); + streamingSku = sku; + qInfo() << "Found full-game Entitlement ID (PS Plus catalog fallback):" + << streamingEntitlementId << "packageType:" << pkgType << "SKU:" << sku + << "titleMatch:" << requireTitleMatch; + return true; + } + return false; + }; + // Pass 1: prefer the entitlement matching the requested product's title id (platform-consistent). + // Pass 2: fall back to any full-game entitlement. + for (bool requireTitleMatch : {true, false}) { + if (streamingEntitlementId.isEmpty() && pickFullGameEntitlement(defaultSku, requireTitleMatch)) + break; + if (streamingEntitlementId.isEmpty() && obj.contains("skus") && obj["skus"].isArray()) { + const QJsonArray skus = obj["skus"].toArray(); + for (const QJsonValue &skuValue : skus) { + if (pickFullGameEntitlement(skuValue.toObject(), requireTitleMatch)) + break; + } + } + if (!streamingEntitlementId.isEmpty()) + break; + } + } + + // Determine platform from playable_platform strings (pick highest: PS5 > PS4 > PS3) if (!playablePlatformArray.isEmpty()) { + bool hasPS5 = false; bool hasPS4 = false; bool hasPS3 = false; for (const QJsonValue &platformValue : playablePlatformArray) { QString platformStr = platformValue.toString(); + // Check PS5 first ("PS5™"/"PS5"); PS4/PS5 cross-gen containers may list both. + if (platformStr.contains("PS5", Qt::CaseInsensitive)) { + hasPS5 = true; + } // Check for PS4 (handles "PS4™" and "PS4") - if (platformStr.contains("PS4", Qt::CaseInsensitive)) { + else if (platformStr.contains("PS4", Qt::CaseInsensitive)) { hasPS4 = true; } // Check for PS3 (handles "PS3™" and "PS3") @@ -494,7 +570,9 @@ void PSKamajiSession::handleProductIdConversionResponse(QNetworkReply *reply) hasPS3 = true; } } - if (hasPS4) { + if (hasPS5) { + detectedPlatform = "ps5"; + } else if (hasPS4) { detectedPlatform = "ps4"; } else if (hasPS3) { detectedPlatform = "ps3"; @@ -841,10 +919,24 @@ void PSKamajiSession::handleCheckEntitlementResponse(QNetworkReply *reply) step5_GetAuthCode(); return; } else if (statusCode == 404) { - // User doesn't have entitlement - try to acquire it + // User doesn't have the per-game entitlement on the account. + const bool isLegacyClassicsId = !productId.contains(QLatin1String("CUSA")) + && !productId.contains(QLatin1String("PPSA")); + if (isLegacyClassicsId) { + // PS3 / Classics: the streaming entitlement is granted by the PS Plus + // subscription (a free 100%-off checkout), but that checkout requires a + // pcnow storefront in the account's region -- which many regions (e.g. + // Hungary) don't have, so the acquire fails with "Against Eligibility Rule". + // On a real PS5 the subscription alone grants streaming with no purchase, so + // skip the acquire and let Gaikai validate the Premium subscription directly. + // If Gaikai genuinely needs the entitlement on the account, it returns + // noGameForEntitlementId downstream and we learn the wall is at Gaikai. + qInfo() << "Kamaji Step 0.5e.2 - Entitlement not found (404); legacy Classics id -> skipping acquire, proceeding to Gaikai"; + step5_GetAuthCode(); + return; + } + // PS4/PS5 catalog: try to acquire it via checkout. qInfo() << "Kamaji Step 0.5e.2 - Entitlement not found (404), will attempt to acquire"; - - // Continue to checkout preview step0_5e_CheckoutPreview(); return; } else { diff --git a/gui/src/cloudstreamingbackend.cpp b/gui/src/cloudstreamingbackend.cpp index 66161249..1bb20856 100644 --- a/gui/src/cloudstreamingbackend.cpp +++ b/gui/src/cloudstreamingbackend.cpp @@ -131,109 +131,58 @@ void CloudStreamingBackend::continueCloudSessionAfterAuth(QString serviceType, Q oauthApiPath = "/v1"; // ACCOUNT_BASE already includes /api } - // Determine ChiakiTarget (device/console type used by Chiaki core). - // PSCLOUD should be treated as PS5. - // PSNOW target will be determined after platform is detected from API response. - ChiakiTarget target; - if (serviceType == "pscloud") { - target = CHIAKI_TARGET_PS5_1; - } else { // psnow - will be updated based on detected platform - target = CHIAKI_TARGET_PS4_9; // Default, will be updated if PS3 is detected - } - + // ChiakiTarget (console type for the Chiaki core). PSCLOUD = PS5; PSNOW refined after the + // Kamaji platform detection. + ChiakiTarget target = (serviceType == "pscloud") ? CHIAKI_TARGET_PS5_1 : CHIAKI_TARGET_PS4_9; qInfo() << "Using DUID:" << sharedDuid; - qInfo() << "Determined ChiakiTarget:" << target; - - // For PSNOW: Create Kamaji session handler (Steps 0.5a-0.5d) - // For PSCLOUD: Skip Kamaji entirely - PSKamajiSession *kamajiSession = nullptr; - QString finalEntitlementId = gameIdentifier; - + + // PS4 / PS3 (PSNOW) titles go through a Kamaji session: the PS4 store container exposes the + // streaming/full-game entitlement, which Kamaji converts and acquires via PS Plus. + // PS5 (PSCLOUD) titles skip Kamaji: PS5 store containers carry NO entitlements/skus to + // convert, so we stream the owned entitlement id directly via Gaikai (cronos). if (serviceType == "psnow") { qInfo() << "=== PSNOW Flow: Starting Kamaji Session ==="; - // Create Kamaji session with productId (will be converted to entitlementId) - // Platform will be automatically detected from the API response - kamajiSession = new PSKamajiSession( - settings, - sharedDuid, - gameIdentifier, // productId for PSNOW - CloudConfig::ACCOUNT_BASE, - redirectUri, - userAgent, - this - ); - - // When Kamaji completes, continue to Gaikai allocation - // Connect PS Plus subscription error signal + PSKamajiSession *kamajiSession = new PSKamajiSession( + settings, sharedDuid, gameIdentifier, CloudConfig::ACCOUNT_BASE, + KamajiConsts::REDIRECT_URI, KamajiConsts::USER_AGENT, this); + connect(kamajiSession, &PSKamajiSession::psPlusSubscriptionError, this, [this]() { QmlBackend *qmlBackend = qobject_cast(parent()); - if (qmlBackend) { - qmlBackend->setShowPSPlusSubscriptionDialog(true); - } + if (qmlBackend) qmlBackend->setShowPSPlusSubscriptionDialog(true); }); - - // Connect account privacy settings error signal connect(kamajiSession, &PSKamajiSession::accountPrivacySettingsError, this, [this](QString upgradeUrl) { - qInfo() << "Account privacy settings error - URL:" << upgradeUrl; QmlBackend *qmlBackend = qobject_cast(parent()); if (qmlBackend) { qmlBackend->setAccountPrivacyUpgradeUrl(upgradeUrl); qmlBackend->setShowAccountPrivacySettingsDialog(true); - qInfo() << "Dialog triggered with URL length:" << upgradeUrl.length(); } }); - - connect(kamajiSession, &PSKamajiSession::sessionComplete, this, - [this, kamajiSession, callback, sharedDuid, serviceType, gameIdentifier, target, redirectUri, userAgent, oauthApiPath](bool success, QString message, QString entitlementId) { + connect(kamajiSession, &PSKamajiSession::sessionComplete, this, + [this, kamajiSession, callback, sharedDuid, serviceType, target, redirectUri, userAgent, oauthApiPath](bool success, QString message, QString entitlementId) { if (!success) { qWarning() << "Kamaji session creation failed:" << message; - - // Clear game image on error setGameImageUrl(QString()); - - // Emit sessionError to dismiss loading screen QmlBackend *qmlBackend = qobject_cast(parent()); - if (qmlBackend) { - emit qmlBackend->sessionError(tr("Cloud Streaming Failed"), + if (qmlBackend) + emit qmlBackend->sessionError(tr("Cloud Streaming Failed"), QString("Session creation failed: %1").arg(message)); - } - - if (callback.isCallable()) { + if (callback.isCallable()) callback.call({false, QString("Session creation failed: %1").arg(message)}); - } kamajiSession->deleteLater(); return; } - qInfo() << "=== Kamaji Session Created, Starting Allocation ==="; qInfo() << "Converted Entitlement ID:" << entitlementId; - - // Get platform from Kamaji session (detected from API response) - QString detectedPlatform = kamajiSession->getPlatform(); - qInfo() << "Detected platform from Kamaji session:" << detectedPlatform; - - // Update target based on detected platform - ChiakiTarget platformTarget = target; - if (detectedPlatform == "ps3") { - platformTarget = CHIAKI_TARGET_PS4_9; // PS3 games use PS4 target for streaming - } else { - platformTarget = CHIAKI_TARGET_PS4_9; // PS4 games use PS4 target - } - - // Continue to Gaikai allocation with converted entitlementId + QString detectedPlatform = kamajiSession->getPlatform(); // ps4 / ps3 + ChiakiTarget platformTarget = CHIAKI_TARGET_PS4_9; // PS4 and PS3 both stream as PS4 startGaikaiAllocation(serviceType, detectedPlatform, entitlementId, sharedDuid, redirectUri, userAgent, oauthApiPath, platformTarget, callback, kamajiSession); }); - - // Start the Kamaji authentication flow kamajiSession->startSessionCreation(); } else { - // PSCLOUD: Skip Kamaji, start directly with Gaikai - // PSCLOUD always uses PS5 platform - QString ps5Platform = "ps5"; - qInfo() << "=== PSCLOUD Flow: Skipping Kamaji, Starting Gaikai Directly ==="; - qInfo() << "Using PS5 platform for PSCLOUD"; - startGaikaiAllocation(serviceType, ps5Platform, finalEntitlementId, sharedDuid, + // PSCLOUD: stream the owned entitlement id directly (no Kamaji — PS5 containers have none). + qInfo() << "=== PSCLOUD Flow: Direct Gaikai (PS5), entitlement:" << gameIdentifier << "==="; + startGaikaiAllocation(serviceType, QStringLiteral("ps5"), gameIdentifier, sharedDuid, redirectUri, userAgent, oauthApiPath, target, callback, nullptr); } } diff --git a/gui/src/qml/CloudGameCard.qml b/gui/src/qml/CloudGameCard.qml index a1648531..a65a3b98 100644 --- a/gui/src/qml/CloudGameCard.qml +++ b/gui/src/qml/CloudGameCard.qml @@ -16,11 +16,18 @@ Rectangle { property string cachedImageUrl: "" property string libraryFilter: "owned" // "owned" or "all" or "favorites" - filter mode for Game Library property var qrCodeDialog: null // Reference to QR code dialog + // In the modern PS Plus catalog (imagic; isPsnow=false) a game you don't own can't be streamed + // until it's added to your library: Gaikai rejects an unowned PS5 entitlement, and the legacy + // Kamaji $0-acquire only works for the old PS Now free-SKU titles, not modern Extra/Premium ones + // (e.g. Far Cry 5's streaming SKU is paid, so the acquire 500s). So ANY non-owned catalog game + // shows "Add Game" (QR to the store / Add-to-Library); owned games stream directly. Legacy + // PS Now browse cards (isPsnow) keep one-click Stream — free streaming is the PS Now model. + readonly property bool needsAddToLibrary: !isPsnow && gameData && !gameData.isOwned property bool isFavorite: false // Whether this game is favorited // Steam library shortcut: only when Steamworks build + Steam client (same gate as createCloudSteamShortcut usefulness) readonly property bool showCloudSteamShortcut: Chiaki.cloudSteamShortcutEnabled - && !(!isPsnow && libraryFilter === "all" && gameData && !gameData.isOwned) + && !needsAddToLibrary signal streamGame(string productId, string platform, string serviceType) signal createShortcut(string productId, string entitlementId, string platform, string serviceType, string gameName) @@ -78,16 +85,23 @@ Rectangle { // Get the identifier to use for streaming (entitlement ID for PSCloud, product ID for PSNOW) function getStreamingIdentifier() { if (!gameData) return ""; - if (isPsnow) { - // PSNOW: use product ID (will be converted to entitlement ID by Kamaji) - return getProductId(); - } else { - // PSCloud: use entitlement ID (the 'id' field), fallback to product_id if id doesn't exist - if (gameData.id) return gameData.id; // Entitlement ID for PSCloud library games - if (gameData.product_id) return gameData.product_id; // Fallback - if (gameData.productId) return gameData.productId; // Fallback for catalog games - return ""; + if (isPsnow) return getProductId(); // legacy PS Now browse catalog + if (streamPlatform() === "ps4") { + // PS4 catalog: send the CUSA product id; Kamaji converts it and acquires the + // streaming entitlement via PS Plus (PS4 store containers expose the entitlement). + let p = streamProductId(); + return p !== "" ? p : getProductId(); } + // PS5: stream the owned PRODUCT id via the direct Gaikai path -- NOT the entitlement `id`. + // For cross-gen titles you upgraded (PS4 purchase + free PS5 copy), Sony's entitlement id + // is the stale ORIGINAL SKU (e.g. Alan Wake Remastered's old CUSA24653 license; Death + // Stranding's pre-Director's-Cut PPSA02624 SKU). Gaikai's cloud catalog has no game mapped + // to that stale id -> noGameForEntitlementId. The owned product_id is the current streamable + // PS5 SKU (Alan Wake -> PPSA01925; Death Stranding DC -> PPSA01968), which Gaikai accepts. + if (gameData.product_id) return gameData.product_id; + if (gameData.productId) return gameData.productId; + if (gameData.id) return gameData.id; + return ""; } function getPlatform() { @@ -120,12 +134,33 @@ Rectangle { } return "ps4"; } else { - return "ps5"; + return streamPlatform(); } } - + + // The product id to stream. Cloud streaming binds to the *catalog* product variant (the + // streamable representative, e.g. God of War's ...GODOFWARN or Alan Wake's PS5 PPSA id), + // not the user's owned download/trial/cross-gen entitlement — so prefer catalogProductId. + function streamProductId() { + if (!gameData) return ""; + return gameData.catalogProductId || gameData.product_id || gameData.productId || gameData.id || ""; + } + + // Platform to stream, from the chosen product's title id: CUSAxxxxx = PS4, PPSAxxxxx = PS5. + // This drives the streaming path (PS4 = kratos, PS5 = cronos); both go through the Kamaji + // acquire-flow. More reliable than the catalog "device" list (cross-gen titles list both) + // or whichever entitlement the user owns. Defaults to PS5 (the modern catalog). + function streamPlatform() { + let p = String(streamProductId()); + if (p.indexOf("PPSA") !== -1) return "ps5"; + if (p.indexOf("CUSA") !== -1) return "ps4"; + return "ps5"; + } + function getServiceType() { - return isPsnow ? "psnow" : "pscloud"; + if (isPsnow) return "psnow"; // legacy PS Now browse catalog + // serviceType selects the Gaikai spec/consts/virtType: psnow = PS4/kratos, pscloud = PS5/cronos. + return (streamPlatform() === "ps4") ? "psnow" : "pscloud"; } function getImageUrl() { @@ -308,8 +343,8 @@ Rectangle { height: 22 radius: 4 color: gameData && gameData.isOwned ? "#4CAF50" : "#FF9800" - visible: !isPsnow && libraryFilter === "all" - + visible: !isPsnow && (libraryFilter === "all" || libraryFilter === "catalog") + Label { id: ownedLabel anchors.centerIn: parent @@ -416,7 +451,7 @@ Rectangle { console.log("[CloudGameCard] qrCodeDialog:", qrCodeDialog); // Check if this is a non-owned game in "All" filter mode - if (!isPsnow && libraryFilter === "all" && gameData && !gameData.isOwned) { + if (needsAddToLibrary) { console.log("[CloudGameCard] Condition met for QR code - showing dialog"); // Show QR code dialog with conceptUrl let conceptUrl = gameData.conceptUrl || gameData.concept_url; @@ -451,7 +486,7 @@ Rectangle { Label { anchors.centerIn: parent text: { - if (!isPsnow && libraryFilter === "all" && gameData && !gameData.isOwned) { + if (needsAddToLibrary) { return qsTr("Add Game") } return qsTr("Stream Game") @@ -531,7 +566,7 @@ Rectangle { // Cross/A button (Enter/Space) - Stream game or show QR code if (event.key === Qt.Key_Return || event.key === Qt.Key_Space || event.key === Qt.Key_Enter) { // Check if this is a non-owned game in "All" filter mode - if (!isPsnow && libraryFilter === "all" && gameData && !gameData.isOwned) { + if (needsAddToLibrary) { // Show QR code dialog with conceptUrl let conceptUrl = gameData.conceptUrl || gameData.concept_url; if (conceptUrl && qrCodeDialog) { diff --git a/gui/src/qml/CloudPlayView.qml b/gui/src/qml/CloudPlayView.qml index 1ffc2fc4..d7539af5 100644 --- a/gui/src/qml/CloudPlayView.qml +++ b/gui/src/qml/CloudPlayView.qml @@ -32,6 +32,9 @@ Pane { property string authErrorMessage: "" // Persistent auth error message property string libraryFilter: "all" // "all", "owned", or "favorites" - filter for Game Library property string catalogFilter: "all" // "all" or "favorites" - filter for Game Catalog + // When the legacy PS Now (Kamaji) browse store is unavailable for the region, + // the Game Catalog falls back to the modern imagic cloud catalog (pscloud). + property bool catalogImagicFallback: false property var ownedProductIds: [] // Set of product IDs that are owned (for filtering) property var favoriteProductIds: [] // Set of product IDs that are favorited property var qrCodeDialogRef: null // Reference to QR code dialog for child components @@ -164,6 +167,7 @@ Pane { filteredGames = []; currentPageGames = []; isLoading = true; + catalogImagicFallback = false; // attempt the legacy PS Now browse store first Chiaki.cloudCatalog.fetchPsnowCatalog(function(success, message, jsonData) { isLoading = false; if (success && jsonData) { @@ -176,6 +180,7 @@ Pane { authErrorMessage = ""; } applySearchFilter(); + appendPs3Catalog(); // Set focus after games are loaded Qt.callLater(() => { if (gamesGrid.count > 0) { @@ -197,15 +202,142 @@ Pane { showErrorToast(qsTr("Parse Error"), qsTr("Failed to parse catalog data: %1").arg(e.toString())); } } else { - console.error("Failed to fetch PSNOW catalog:", message); + // The legacy PS Now (Kamaji) browse store is region-locked / deprecated + // and 404s in many regions (e.g. Hungary). Fall back to the modern imagic + // cloud catalog so the Game Catalog shows streamable titles everywhere. + console.warn("PSNOW catalog unavailable, falling back to imagic cloud catalog:", message); + loadCatalogImagicFallback(); + } + }); + } + + // Game Catalog fallback: source the streamable PS4/PS5 cloud titles from the imagic + // catalog (the same source the Library uses) and mark which the user owns, so owned + // titles stream and the rest offer "Add Game". Presented as pscloud, not psnow. + // The PS Plus subscription catalog (what Sony lists on the PS Plus games page, ~630 in HU): + // browse titles tagged plusCatalog + the library-stream supplement (catalog titles with + // streamingSupported=false, e.g. God of War). Excludes the full ~7000-title all-ps5 universe, + // which is fetched only to match the games you own. + function ps5PlusCatalogGames(data) { + let games = []; + if (data && data.games && Array.isArray(data.games)) { + for (let i = 0; i < data.games.length; i++) { + if (data.games[i] && data.games[i].plusCatalog) + games.push(data.games[i]); + } + } + if (data && data.plusLibrarySupplement && Array.isArray(data.plusLibrarySupplement)) { + for (let i = 0; i < data.plusLibrarySupplement.length; i++) + games.push(data.plusLibrarySupplement[i]); + } + return games; + } + + function loadCatalogImagicFallback() { + catalogImagicFallback = true; + isLoading = true; + Chiaki.cloudCatalog.fetchPs5CloudCatalog(function(success, message, jsonData) { + if (!success || !jsonData) { + isLoading = false; allGames = []; filteredGames = []; currentPageGames = []; + console.error("Failed to fetch imagic cloud catalog:", message); showErrorToast(qsTr("API Error"), message || qsTr("Failed to fetch game catalog")); + return; + } + let browseGames = []; + try { + let data = JSON.parse(jsonData); + // Game Catalog = the PS Plus subscription catalog only (not the full streamable universe). + browseGames = ps5PlusCatalogGames(data); + } catch (e) { + isLoading = false; + console.error("Failed to parse imagic cloud catalog:", e); + showErrorToast(qsTr("Parse Error"), qsTr("Failed to parse catalog data: %1").arg(e.toString())); + return; + } + if (message && message !== "Success" && message !== "Cached") + showErrorToast(qsTr("Partial Catalog"), message); + // Mark which subscription titles you already own, so a non-owned PS5 catalog game shows + // "Add Game" (it must be added to your library before Gaikai will stream it) while PS4 + // titles and owned games show "Stream". addUnmatchedOwned=false keeps the Catalog the + // pure subscription set (we only mark ownership, never add owned-but-uncatalogued games). + Chiaki.cloudCatalog.getOwnedPs5CloudGames(function(ownedSuccess, ownedMessage, ownedJsonData) { + let ownedGames = []; + if (ownedSuccess && ownedJsonData) { + try { + let ownedData = JSON.parse(ownedJsonData); + if (ownedData.games && Array.isArray(ownedData.games)) + ownedGames = ownedData.games; + } catch (e) { + console.warn("Catalog: failed to parse owned games for ownership marking:", e); + } + } + let merged = mergeOwnedPs5CloudIntoBrowseCatalog(browseGames, ownedGames, false); + sortPs5CloudLibraryGames(merged.games); + allGames = merged.games; + ownedProductIds = Array.from(merged.ownedIds); + isLoading = false; + applySearchFilter(); + appendPs3Catalog(); + Qt.callLater(() => { + if (gamesGrid.count > 0) { + gamesGrid.currentIndex = 0; + gamesGrid.forceActiveFocus(); + } + }); + }); + }); + } + + // True for streamable PS3 Classics (from the public Apollo PS3 container). They carry + // playable_platform ["PS3"] and a PS3 product id, and must stream via the PSNOW/konan path. + function gameIsPs3(g) { + if (!g) + return false; + let pp = g.playable_platform; + if (!pp) + return false; + let arr = []; + if (Array.isArray(pp)) + arr = pp; + else if (typeof pp === "object" && pp.length !== undefined) { + for (let i = 0; i < pp.length; i++) arr.push(pp[i]); + } else if (typeof pp === "string") + arr = [pp]; + for (let i = 0; i < arr.length; i++) + if (String(arr[i]).indexOf("PS3") !== -1) return true; + return false; + } + + // Fetch the streamable PS3 Classics (public Apollo container) and append them to the + // current catalog. Additive: it never replaces the PS4/PS5 catalog already loaded, so + // it works regardless of whether the primary catalog came from PS Now or the imagic + // fallback. PS3 belongs only in the subscription Catalog (not the owned Library). + function appendPs3Catalog() { + // PS3 Classics are subscription-streamable, so they belong in the Game Catalog and + // in the Library "all" (streamable universe) view -- but NOT the "owned" view. + if (currentSection === "library" && libraryFilter !== "all") + return; + Chiaki.cloudCatalog.fetchPs3Catalog(function(success, message, jsonData) { + if (!success || !jsonData) { + console.warn("PS3 Classics catalog unavailable:", message); + return; + } + try { + let d = JSON.parse(jsonData); + if (d.games && Array.isArray(d.games) && d.games.length > 0) { + allGames = allGames.concat(d.games); + applySearchFilter(); + console.log("[CloudPlayView] Appended", d.games.length, "PS3 Classics to catalog"); + } + } catch (e) { + console.warn("Failed to parse PS3 catalog:", e); } }); } - + function ps5CloudProductId(game) { if (!game) return ""; @@ -221,6 +353,28 @@ Pane { return String(conceptId); } + // Platform from the title id (PPSA = PS5, CUSA = PS4), falling back to the device array. + function ps5CloudPlatformToken(game) { + let pid = ps5CloudProductId(game) || ps5CloudStreamingId(game) || ""; + if (pid.indexOf("PPSA") !== -1) return "ps5"; + if (pid.indexOf("CUSA") !== -1) return "ps4"; + let dev = game ? game.device : null; + if (Array.isArray(dev)) { + if (dev.indexOf("PS5") !== -1) return "ps5"; + if (dev.indexOf("PS4") !== -1) return "ps4"; + } + return ""; + } + + // Edition identity = conceptId + platform, so cross-gen editions (PS4 + PS5) of the same + // game are treated as distinct entries instead of being merged by conceptId alone. + function ps5CloudConceptPlatformKey(game) { + let c = ps5CloudConceptId(game); + if (!c) + return ""; + return c + "|" + ps5CloudPlatformToken(game); + } + function ps5CloudStreamingId(game) { if (!game) return ""; @@ -235,9 +389,9 @@ Pane { let productId = ps5CloudProductId(game); if (productId) byProductId[productId] = i; - let conceptId = ps5CloudConceptId(game); - if (conceptId) - byConceptId[conceptId] = i; + let conceptKey = ps5CloudConceptPlatformKey(game); + if (conceptKey) + byConceptId[conceptKey] = i; let streamId = ps5CloudStreamingId(game); if (streamId && streamId !== productId) byProductId[streamId] = i; @@ -249,9 +403,9 @@ Pane { let productId = ps5CloudProductId(game); if (productId) catalogIndex.byProductId[productId] = index; - let conceptId = ps5CloudConceptId(game); - if (conceptId) - catalogIndex.byConceptId[conceptId] = index; + let conceptKey = ps5CloudConceptPlatformKey(game); + if (conceptKey) + catalogIndex.byConceptId[conceptKey] = index; let streamId = ps5CloudStreamingId(game); if (streamId && streamId !== productId) catalogIndex.byProductId[streamId] = index; @@ -264,9 +418,11 @@ Pane { let streamId = ps5CloudStreamingId(ownedGame); if (streamId && catalogIndex.byProductId.hasOwnProperty(streamId)) return catalogIndex.byProductId[streamId]; - let conceptId = ps5CloudConceptId(ownedGame); - if (conceptId && catalogIndex.byConceptId.hasOwnProperty(conceptId)) - return catalogIndex.byConceptId[conceptId]; + // Match by conceptId + platform so an owned PS4 edition does NOT match a PS5-only catalog + // entry (and vice-versa); cross-gen editions stay as separate library cards. + let conceptKey = ps5CloudConceptPlatformKey(ownedGame); + if (conceptKey && catalogIndex.byConceptId.hasOwnProperty(conceptKey)) + return catalogIndex.byConceptId[conceptKey]; return -1; } @@ -282,7 +438,12 @@ Pane { }); } - function mergeOwnedPs5CloudIntoBrowseCatalog(browseGames, ownedGames) { + // addUnmatchedOwned: when true (Library), owned games not found in the browse list are + // appended; when false (Catalog), we only MARK ownership on catalog entries and never add + // owned-but-not-in-catalog titles — so the Catalog stays the pure subscription catalog. + function mergeOwnedPs5CloudIntoBrowseCatalog(browseGames, ownedGames, addUnmatchedOwned) { + if (addUnmatchedOwned === undefined) + addUnmatchedOwned = true; let games = browseGames.slice(); let catalogIndex = buildPs5CloudCatalogIndex(games); let ownedIds = new Set(); @@ -296,7 +457,12 @@ Pane { if (streamId) ownedIds.add(streamId); - let catalogMatch = findPs5CloudCatalogIndexForOwned(ownedGame, catalogIndex); + // Trials / free-to-play (feature_type 1) are kept as their OWN library card so the user + // can Stream the trial/free build, while the full version still appears separately as a + // not-owned "Add Game" card. So a trial must NOT collapse into the full-game catalog + // entry. Full games (ft 3/5) merge normally (mark the catalog entry owned). + let isTrialTier = ownedGame && ownedGame.feature_type === 1; + let catalogMatch = isTrialTier ? -1 : findPs5CloudCatalogIndexForOwned(ownedGame, catalogIndex); if (catalogMatch >= 0) { let existing = games[catalogMatch]; existing.isOwned = true; @@ -304,16 +470,32 @@ Pane { if (streamId) existing.id = streamId; let ownedProductId = ps5CloudProductId(ownedGame); - if (ownedProductId) { - if (!existing.product_id) - existing.product_id = ownedProductId; - if (!existing.productId) - existing.productId = ownedProductId; + // Carry the OWNED product id onto the catalog card only for PS5 (PPSA): an owned PS5 + // product IS the streamable entitlement (streamed directly via cronos). For PS4 (CUSA) + // the owned DOWNLOAD product (e.g. ...GODOFWAR) has NO PS Now streaming SKU -- the + // catalog entry's own productId (e.g. ...GODOFWARN, the "N" variant) is what Kamaji + // converts to a streaming entitlement -- so leave the catalog productId intact. + // + // Override unconditionally for PS5 (matching the iOS/Android merge, which always copy + // storeProductId): the catalog card carries one fixed SKU per concept, but you can only + // stream the edition you actually own. When they differ -- e.g. the catalog SKU is a + // disc-upgrade you can't stream and the cross-reference rescued you to the owned full + // game (Horizon: PPSA01521 -> PPSA17903) -- the catalog card's product_id is the wrong + // (unstreamable) one, so the owned product id must win. + // NOTE: ps5CloudPlatformToken takes a GAME OBJECT, not a product-id string -- passing + // the string here made it always return "" so this override never ran (the bug that + // broke the "all" view while the "owned" view, which uses the cross-ref directly, worked). + if (ownedProductId && ps5CloudPlatformToken(ownedGame) === "ps5") { + existing.product_id = ownedProductId; + existing.productId = ownedProductId; } games[catalogMatch] = existing; continue; } + if (!addUnmatchedOwned) + continue; // Catalog: don't add owned titles that aren't in the subscription catalog + let entry = Object.assign({}, ownedGame); entry.isOwned = true; if (!entry.productId && entry.product_id) @@ -333,9 +515,12 @@ Pane { filteredGames = []; currentPageGames = []; isLoading = true; - + + // Library "all" = the PS Plus catalog with your owned titles merged in (owned ones show + // "Stream Game", the rest "Add Game"). Library "owned" = only the games you own. The + // Game Catalog tab is the all-streamable view where everything shows "Stream Game". if (libraryFilter === "all") { - // Fetch all streamable games from game catalog + // Fetch the catalog, then merge owned games in (marking ownership + adding owned extras). Chiaki.cloudCatalog.fetchPs5CloudCatalog(function(success, message, jsonData) { if (success && jsonData) { try { @@ -365,10 +550,15 @@ Pane { ownershipErrorMsg = ownedMessage || qsTr("Failed to verify game ownership"); } - let merged = mergeOwnedPs5CloudIntoBrowseCatalog(data.games, ownedGames); + // Library "all" = the full streamable universe (every PS4/PS5 cloud + // title) with owned titles merged in; non-owned show "Add Game". + // (The Game Catalog tab is the curated subscription view.) + let browse = (data.games && Array.isArray(data.games)) ? data.games : []; + let merged = mergeOwnedPs5CloudIntoBrowseCatalog(browse, ownedGames); ownedProductIds = Array.from(merged.ownedIds); allGames = merged.games; isLoading = false; + appendPs3Catalog(); // PS3 Classics are part of the streamable "all" view // Handle ownership check failure with user-visible feedback if (ownershipCheckFailed) { @@ -1370,8 +1560,22 @@ Pane { gameData: modelData focus: false // GridView handles focus, not individual cards activeFocusOnTab: false - isPsnow: currentSection === "catalog" - libraryFilter: root.libraryFilter + // The catalog is normally PS Now; when it falls back to the imagic + // cloud catalog the cards are pscloud (correct streaming path/platform). + // PS3 Classics (appended from the Apollo container) are always PS Now: + // isPsnow=true makes the card read playable_platform -> "ps3" and route + // to the PSNOW/konan streaming path regardless of the catalog source. + isPsnow: (currentSection === "catalog" && !catalogImagicFallback) + || gameIsPs3(modelData) + // Catalog cards: every subscription title is streamable, so use a non-"all" + // value to suppress the "Add Game" state — all of them show "Stream Game". + // Library cards use the real filter ("all" enables Add Game for non-owned). + // PS3 Classics are subscription-streamable (never "owned"), so they always + // show "Stream Game" regardless of section/filter. + libraryFilter: gameIsPs3(modelData) + ? "catalog" + : ((currentSection === "catalog" && catalogImagicFallback) + ? "catalog" : root.libraryFilter) qrCodeDialog: root.qrCodeDialogRef // Bind isFavorite to favoriteProductIds array changes diff --git a/ios/Pylux/Models/CloudModels.swift b/ios/Pylux/Models/CloudModels.swift index 83048d0b..2f523556 100644 --- a/ios/Pylux/Models/CloudModels.swift +++ b/ios/Pylux/Models/CloudModels.swift @@ -19,11 +19,14 @@ struct CloudGame: Identifiable, Hashable { var isOwned: Bool // Whether user owns this game (PS5) var entitlementId: String // PSCloud: entitlement id for streaming (Qt gameData.id) var storeProductId: String // PSCloud: product_id from entitlements API + var plusCatalog: Bool // In the PS Plus subscription catalog (vs the full streamable universe) + var featureType: Int // PSN entitlement feature_type (owned games): 3=full game, 1=trial/free, 0=add-on init(productId: String, name: String, imageUrl: String, landscapeImageUrl: String = "", platform: String = "ps4", serviceType: String = "psnow", conceptUrl: String = "", conceptId: String = "", isOwned: Bool = false, - entitlementId: String = "", storeProductId: String = "") { + entitlementId: String = "", storeProductId: String = "", plusCatalog: Bool = false, + featureType: Int = 0) { self.id = productId self.name = name self.imageUrl = imageUrl @@ -35,16 +38,50 @@ struct CloudGame: Identifiable, Hashable { self.isOwned = isOwned self.entitlementId = entitlementId self.storeProductId = storeProductId + self.plusCatalog = plusCatalog + self.featureType = featureType } - /// Mirrors CloudGameCard.qml getStreamingIdentifier() for PSCloud. + /// Mirrors CloudGameCard.qml getStreamingIdentifier() for PSCloud. Stream the owned PRODUCT id + /// (storeProductId), NOT the entitlement id: for cross-gen titles you upgraded, Sony's entitlement + /// id is the stale ORIGINAL SKU (Alan Wake's old CUSA license; Death Stranding's pre-DC SKU) that + /// Gaikai's cloud catalog has no game for -> noGameForEntitlementId. product_id is the current SKU. var streamingIdentifier: String { if serviceType.lowercased() == "pscloud" { - if !entitlementId.isEmpty { return entitlementId } if !storeProductId.isEmpty { return storeProductId } + if !entitlementId.isEmpty { return entitlementId } } return id } + + // A PlayStation title id encodes its platform: CUSAxxxxx = PS4, PPSAxxxxx = PS5. This is + // more reliable than the catalog device list, and decides the streaming path: PS4 goes + // through Kamaji (psnow) to acquire the streaming entitlement, PS5 streams directly (pscloud). + var streamPlatform: String { + // Prefer the OWNED product id (storeProductId): for a cross-gen title you upgraded, the catalog + // `id` may be the OTHER generation (Alan Wake's catalog entry is PS4 CUSA, but you own the PS5 + // PPSA), and the owned product is what decides the streaming path. + let p = !storeProductId.isEmpty ? storeProductId : (!id.isEmpty ? id : entitlementId) + if p.contains("PPSA") { return "ps5" } + if p.contains("CUSA") { return "ps4" } + return platform.isEmpty ? "ps5" : platform + } + + /// Service type to stream with: real legacy PS Now games stay psnow; otherwise route by the + /// title-id platform (PS4 catalog titles need the Kamaji acquire-flow, PS5 stays direct). + var streamServiceType: String { + if serviceType.lowercased() == "psnow" { return "psnow" } + return streamPlatform == "ps4" ? "psnow" : "pscloud" + } + + /// Identifier to send to startCompleteCloudSession: PS4/psnow sends the product id (Kamaji + /// converts it to an entitlement); PS5/pscloud sends the owned entitlement id (direct). + var streamIdentifier: String { + if streamServiceType == "psnow" { + return id.isEmpty ? streamingIdentifier : id + } + return streamingIdentifier + } } // MARK: - CloudStreamSession (matches Android CloudStreamSession.kt) @@ -135,6 +172,39 @@ enum CloudApiConstants { static let accountBase = "https://ca.account.sony.com/api" } +// MARK: - PS3 / Classics region (mirrors KamajiConsts in gui/include/cloudstreaming/pskamajisession.h) + +/// pcnow (the PS Plus PC "Apollo" backend) has only TWO Classics id families: +/// * SCEA / Americas -> store MSF192018, US-region ids (UP*/NPUA*/BLUS*), +/// PS3 child container "APOLLOPS3GAMES" +/// * SCEE / PAL (rest) -> store MSF192014, EU-region ids (EP*/NPEA*/NPEB*/BLES*), +/// PS3 child container "APOLLOPS3" +/// JP / Asia have no Apollo store (the PC app isn't offered there), so they fall back to +/// PAL. A PS Plus account is authorized at Gaikai only for the id family of its own region +/// group, so we must browse + resolve in the account's group. +enum ClassicsRegion { + private static let americas: Set = [ + "US", "CA", "MX", "BR", "AR", "CL", "CO", "PE", "EC", "BO", + "PY", "UY", "CR", "GT", "HN", "NI", "PA", "SV", "DO" + ] + + static func isAmericasClassicsRegion(_ countryCode: String) -> Bool { + return americas.contains(countryCode.uppercased()) + } + + /// Country path to use for container/conversion calls (US for Americas, GB for PAL). + static func classicsStoreCountry(_ accountCountry: String) -> String { + return isAmericasClassicsRegion(accountCountry) ? "US" : "GB" + } + + /// Fully-qualified PS3 catalog container id for the account's region group. + static func classicsPs3ContainerId(_ accountCountry: String) -> String { + return isAmericasClassicsRegion(accountCountry) + ? "STORE-MSF192018-APOLLOPS3GAMES" + : "STORE-MSF192014-APOLLOPS3" + } +} + // MARK: - Gaikai Allocation Result struct GaikaiAllocationResult { @@ -191,6 +261,26 @@ enum CloudLocaleSettings { return (country, lang) } + /// Ordered store locales to try when fetching the catalog. Sony serves a fixed set of + /// language-COUNTRY combinations: the country is always valid but the language may not be + /// (a Hungarian-language account yields "hu-HU", which 404s, while "en-HU" works). Fall + /// back to English for the same country, then en-US, so the catalog loads in every region. + /// Each tuple is (canonical "ll-CC" for storage, lowercased "ll-cc" for the imagic URL). + static func fallbackChain() -> [(canonical: String, imagic: String)] { + let (country, lang) = parseStorePath(stored) + var seen = Set() + var chain: [(String, String)] = [] + func add(_ l: String, _ c: String) { + let canonical = "\(l)-\(c)" + let imagic = canonical.lowercased() + if seen.insert(imagic).inserted { chain.append((canonical, imagic)) } + } + add(lang, country) + add("en", country) + add("en", "US") + return chain + } + static func fromSession(language: String?, country: String?) -> String? { let lang = language?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let cty = country?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -204,10 +294,18 @@ enum CloudLocaleSettings { "Kamaji session: no language/country in response (stored=%{public}s)", stored) return } - if isConfigured && locale == stored { - os_log(.info, log: cloudLocaleLog, - "Kamaji session locale unchanged: %{public}s", locale) - return + if isConfigured { + // The country is the real region signal; the language part may get auto-corrected + // by the imagic fetch (e.g. hu-HU settles on en-HU). Only re-save when the country + // changes, otherwise we'd clobber the validated locale on every Kamaji session. + let storedCountry = parseStorePath(stored).country + let sessionCountry = parseStorePath(locale).country + if storedCountry == sessionCountry { + os_log(.info, log: cloudLocaleLog, + "Kamaji session country unchanged (%{public}s), keeping validated locale %{public}s", + sessionCountry, stored) + return + } } setStored(locale) } diff --git a/ios/Pylux/Services/CloudCatalogService.swift b/ios/Pylux/Services/CloudCatalogService.swift index 9538b55a..4adac4cf 100644 --- a/ios/Pylux/Services/CloudCatalogService.swift +++ b/ios/Pylux/Services/CloudCatalogService.swift @@ -18,9 +18,9 @@ final class CloudCatalogService { private static let cacheDuration: TimeInterval = 86400 // 24 hours private static let psnowCacheFile = "psnow_catalog.json" - private static let ps5PublicCacheFile = "ps5_cloud_catalog_v3.json" - private static let pscloudAllCacheFile = "pscloud_catalog.json" - private static let pscloudOwnedCacheFile = "pscloud_owned.json" + private static let ps5PublicCacheFile = "ps5_cloud_catalog_v4.json" // v4: adds plusCatalog tag + broader supplement + private static let pscloudAllCacheFile = "pscloud_catalog_v2.json" + private static let pscloudOwnedCacheFile = "pscloud_owned_v3.json" // v3: ft0 filter + rank dedupe + featureType private static var cacheDir: URL = { let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] @@ -95,7 +95,8 @@ final class CloudCatalogService { "platform": g.platform, "serviceType": g.serviceType, "conceptUrl": g.conceptUrl, "conceptId": g.conceptId, "isOwned": g.isOwned, - "entitlementId": g.entitlementId, "storeProductId": g.storeProductId + "entitlementId": g.entitlementId, "storeProductId": g.storeProductId, + "plusCatalog": g.plusCatalog, "featureType": g.featureType ] } @@ -106,13 +107,15 @@ final class CloudCatalogService { productId: pid, name: name, imageUrl: d["imageUrl"] as? String ?? "", landscapeImageUrl: d["landscapeImageUrl"] as? String ?? "", - platform: d["platform"] as? String ?? "ps4", + platform: { let p = ps5PlatformToken(pid); return p.isEmpty ? (d["platform"] as? String ?? "ps4") : p }(), serviceType: d["serviceType"] as? String ?? "psnow", conceptUrl: d["conceptUrl"] as? String ?? "", conceptId: d["conceptId"] as? String ?? "", isOwned: d["isOwned"] as? Bool ?? false, entitlementId: d["entitlementId"] as? String ?? "", - storeProductId: d["storeProductId"] as? String ?? "" + storeProductId: d["storeProductId"] as? String ?? "", + plusCatalog: d["plusCatalog"] as? Bool ?? false, + featureType: (d["featureType"] as? NSNumber)?.intValue ?? 0 ) } @@ -124,10 +127,9 @@ final class CloudCatalogService { private func loadPs5CloudCatalog(forceRefresh: Bool) -> Ps5CloudCatalogResult { let stored = CloudLocaleSettings.stored - let locale = CloudLocaleSettings.imagicLocale os_log(.info, log: catalogLog, - "PS5 catalog stored=%{public}s imagic=%{public}s forceRefresh=%{public}s", - stored, locale, forceRefresh ? "yes" : "no") + "PS5 catalog stored=%{public}s forceRefresh=%{public}s", + stored, forceRefresh ? "yes" : "no") if !forceRefresh, let cached = loadCachedPs5CatalogV3(expectedLocale: stored) { os_log(.info, log: catalogLog, "PS5 catalog: using disk cache") lastCatalogFetchWarning = nil @@ -135,20 +137,33 @@ final class CloudCatalogService { } lastCatalogFetchWarning = nil - guard let fetched = fetchPs5CloudCatalogFromNetwork(locale: locale) else { - return Ps5CloudCatalogResult( - browseGames: [], plusLibrarySupplement: [], productIdAliases: [:], - shouldCacheV3: false - ) - } - if fetched.shouldCacheV3, - !fetched.browseGames.isEmpty || !fetched.plusLibrarySupplement.isEmpty { - cachePs5CatalogV3(fetched, locale: stored) - } - if let warning = fetched.catalogFetchWarning { - lastCatalogFetchWarning = warning + // Try the store-locale fallback chain (session locale -> en-COUNTRY -> en-US). A whole + // tier returning nil means it 404'd for an unsupported locale; escalate to the next. + for tier in CloudLocaleSettings.fallbackChain() { + guard let fetched = fetchPs5CloudCatalogFromNetwork(locale: tier.imagic) else { + os_log(.info, log: catalogLog, + "PS5 imagic locale %{public}s failed, trying next tier", tier.imagic) + continue + } + // Persist the locale that actually worked so game details and the cache agree on it. + if tier.canonical != stored { + os_log(.info, log: catalogLog, + "PS5 store locale settled on %{public}s (was %{public}s)", tier.canonical, stored) + CloudLocaleSettings.setStored(tier.canonical) + } + if fetched.shouldCacheV3, + !fetched.browseGames.isEmpty || !fetched.plusLibrarySupplement.isEmpty { + cachePs5CatalogV3(fetched, locale: tier.canonical) + } + if let warning = fetched.catalogFetchWarning { + lastCatalogFetchWarning = warning + } + return fetched } - return fetched + return Ps5CloudCatalogResult( + browseGames: [], plusLibrarySupplement: [], productIdAliases: [:], + shouldCacheV3: false + ) } private func loadCachedPs5CatalogV3(expectedLocale: String) -> Ps5CloudCatalogResult? { @@ -258,36 +273,46 @@ final class CloudCatalogService { allPs5ListSucceeded = true } + let isPlus = isPlusCatalogList(categoryList) // subscription catalog vs the all-ps5 universe for category in categories { guard let gameArray = category["games"] as? [[String: Any]] else { continue } totalRows += gameArray.count for gameObj in gameArray { - guard isPs5Game(gameObj) else { continue } + // Accept PS4 and PS5; the old PS5-only gate dropped PS4-only PS-Plus-catalog + // titles (e.g. God of War 2018) before they could reach the supplement below. + guard isCloudDeviceGame(gameObj) else { continue } - if categoryList == "plus-games-list", - (gameObj["streamingSupported"] as? Bool) != true { + // Subscription-catalog titles with streamingSupported=false → library-stream + // supplement, captured from EVERY subscription list (not just plus-games-list). + if isPlus, (gameObj["streamingSupported"] as? Bool) != true { let productId = gameObj["productId"] as? String ?? "" if !productId.isEmpty, plusSupplementByProductId[productId] == nil { - plusSupplementByProductId[productId] = gameObj + var g = gameObj; g["plusCatalog"] = true + plusSupplementByProductId[productId] = g } continue } - guard isPs5StreamingGame(gameObj) else { continue } - let key = conceptKey(for: gameObj) + guard isCloudStreamingGame(gameObj) else { continue } + let key = editionKey(for: gameObj) // per game per platform (cross-gen split) let productId = gameObj["productId"] as? String ?? "" guard !key.isEmpty, !productId.isEmpty else { continue } - if let existing = byConceptId[key] { + if var existing = byConceptId[key] { let canonicalProductId = existing["productId"] as? String ?? "" if !canonicalProductId.isEmpty, productId != canonicalProductId, productIdAliases[productId] == nil { productIdAliases[productId] = canonicalProductId } + if isPlus, (existing["plusCatalog"] as? Bool) != true { + existing["plusCatalog"] = true + byConceptId[key] = existing + } continue } - byConceptId[key] = gameObj + var g = gameObj; g["plusCatalog"] = isPlus + byConceptId[key] = g order.append(key) } } @@ -326,15 +351,24 @@ final class CloudCatalogService { ) } - private func isPs5Game(_ gameObj: [String: Any]) -> Bool { + // PS Plus cloud streaming covers PS4 and PS5 titles (PS3 is not in these imagic lists). + // A PS4-only title such as God of War (2018) is streamable when owned even though it + // carries device ["PS4"], so the catalog must not discard it. + private func isCloudDeviceGame(_ gameObj: [String: Any]) -> Bool { guard let devices = gameObj["device"] as? [String] else { return false } - return devices.contains("PS5") + return devices.contains("PS5") || devices.contains("PS4") } - private func isPs5StreamingGame(_ gameObj: [String: Any]) -> Bool { + private func isCloudStreamingGame(_ gameObj: [String: Any]) -> Bool { guard (gameObj["streamingSupported"] as? Bool) == true else { return false } - guard let devices = gameObj["device"] as? [String] else { return false } - return devices.contains("PS5") + return isCloudDeviceGame(gameObj) + } + + // The PS Plus subscription catalog = these curated lists (≈ what Sony lists). all-ps5-list is + // the full streamable universe and must NOT count as subscription catalog. + private func isPlusCatalogList(_ categoryList: String) -> Bool { + return categoryList == "plus-games-list" || categoryList == "plus-classics-list" + || categoryList == "ubisoft-classics-list" || categoryList == "plus-monthly-games-list" } private func conceptKey(for gameObj: [String: Any]) -> String { @@ -344,6 +378,21 @@ final class CloudCatalogService { return gameObj["productId"] as? String ?? "" } + // Platform token from a product id (CUSA = PS4, PPSA = PS5). + private func ps5PlatformToken(_ productId: String) -> String { + if productId.contains("PPSA") { return "ps5" } + if productId.contains("CUSA") { return "ps4" } + return "" + } + + // Dedupe identity: one entry per game PER PLATFORM, so cross-gen PS4/PS5 editions (e.g. Deliver + // Us The Moon) both appear, while duplicate same-platform SKUs still collapse. + private func editionKey(for gameObj: [String: Any]) -> String { + let c = conceptKey(for: gameObj) + if c.isEmpty { return "" } + return c + "|" + ps5PlatformToken(gameObj["productId"] as? String ?? "") + } + private func cloudGameFromImagic(_ gameObj: [String: Any]) -> CloudGame? { let productId = gameObj["productId"] as? String ?? "" guard !productId.isEmpty else { return nil } @@ -358,9 +407,10 @@ final class CloudCatalogService { return CloudGame( productId: productId, name: name, imageUrl: imageUrl, landscapeImageUrl: imageUrl, - platform: "ps5", serviceType: "pscloud", + platform: { let p = ps5PlatformToken(productId); return p.isEmpty ? "ps5" : p }(), serviceType: "pscloud", conceptUrl: conceptUrl, conceptId: conceptKey(for: gameObj), - isOwned: false + isOwned: false, + plusCatalog: gameObj["plusCatalog"] as? Bool ?? false ) } @@ -413,6 +463,25 @@ final class CloudCatalogService { return allGames } + // MARK: - PS Plus Subscription Catalog (Catalog tab) + + /// The PS Plus subscription catalog: plusCatalog browse titles + the library-stream supplement + /// (the ~630 set Sony lists), NOT the full all-ps5 universe. No ownership fetch — every + /// subscription title is shown as streamable. Mirrors Qt ps5PlusCatalogGames + Catalog tab. + func fetchPlusCatalogGames(npssoToken: String = "", forceRefresh: Bool = false) -> [CloudGame] { + let catalog = loadPs5CloudCatalog(forceRefresh: forceRefresh) + var games = catalog.browseGames.filter { $0.plusCatalog } + games.append(contentsOf: catalog.plusLibrarySupplement) + games.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + // Mark which subscription titles you already own, so owned games show "Stream" and non-owned + // show "Add Game" (they must be added to your library first). addUnmatched:false keeps the + // Catalog the pure subscription set (mark only; never add owned-but-uncatalogued games). + guard !npssoToken.isEmpty else { return games } + let owned = fetchOwnedPs5Games(npssoToken: npssoToken, forceRefresh: forceRefresh) + return PsCloudOwnership.mergeOwnedIntoBrowseCatalog( + browseCatalog: games, ownedCrossRef: owned, addUnmatched: false) + } + // MARK: - PS5 Cloud Library: Owned Only /// Mirrors CloudCatalogBackend::getOwnedPs5CloudGames (owned tab) @@ -466,18 +535,21 @@ final class CloudCatalogService { return nil } let rawEntitlements = rawObjects.compactMap { PsCloudOwnership.parseEntitlement($0) } - var componentIdsByProductId: [String: [String]] = [:] - for e in rawEntitlements where !e.productId.isEmpty && !e.id.isEmpty { - componentIdsByProductId[e.productId, default: []].append(e.id) - } let filtered = PsCloudOwnership.filterOwnedPs5Games(rawEntitlements) + // Map each bundle product_id -> the entitlement ids sharing it, so a bundle (e.g. RE7 Gold) + // expands to its component games during cross-reference (upstream PR #15 bundle-sibling match). + var componentIds: [String: [String]] = [:] + for ent in rawEntitlements where !ent.productId.isEmpty && !ent.id.isEmpty { + componentIds[ent.productId, default: []].append(ent.id) + } + return PsCloudOwnership.crossReferenceOwnedGames( filteredEntitlements: filtered, publicCatalog: publicCatalog, plusLibrarySupplement: plusLibrarySupplement, productIdAliases: productIdAliases, - componentIdsByProductId: componentIdsByProductId + componentIdsByProductId: componentIds ) } @@ -566,6 +638,85 @@ final class CloudCatalogService { return allGames } + // MARK: - PS3 Classics Catalog (public Apollo container walk) + // + // The PS Plus PC ("Apollo") app browses the streamable catalog through the public pcnow + // container API at psnow.playstation.com. There is a dedicated PS3 container (e.g. + // STORE-MSF192018-APOLLOPS3GAMES for Americas) that lists ~300 streamable PS3 titles with + // their PS3 product ids (NPUA/NPUB/BLUS/BCUS) — none of which appear in the imagic gameslist + // the rest of the catalog uses. The container API needs no OAuth or per-account session + // (unlike /user/stores, which 404s in regions where the PC app is unavailable, e.g. Hungary), + // so we can walk it directly in any region. The resulting titles carry playable_platform + // ["PS3"] and stream via the existing PSNOW -> Gaikai konan path. + + /// Resolve the account's region group from its stored store locale (e.g. "en-HU" -> "HU"). + /// pcnow has two Classics id families (Americas/SCEA and PAL/SCEE); a PS Plus account is + /// authorized at Gaikai only for the family of its own region group, so the catalog must be + /// browsed in that group. See ClassicsRegion.classicsStoreCountry / classicsPs3ContainerId. + private func ps3AccountCountry() -> String { + return CloudLocaleSettings.parseStorePath(CloudLocaleSettings.stored).country + } + + /// Fetch the streamable PS3 Classics from the public Apollo container for the account's + /// region group. Mirrors Qt CloudCatalogBackend::fetchPs3Catalog. PUBLIC API: no auth. + func fetchPs3Catalog(forceRefresh: Bool = false) -> [CloudGame] { + let country = ps3AccountCountry() + let storeCountry = ClassicsRegion.classicsStoreCountry(country) + // Region-group-specific cache key so an Americas/PAL switch doesn't serve stale ids. + let cacheFile = "ps3_catalog_\(storeCountry).json" + if !forceRefresh, let cached = loadCachedGames(cacheFile) { + os_log(.info, log: catalogLog, "Returning %d PS3 Classics from cache", cached.count) + return cached + } + + let containerId = ClassicsRegion.classicsPs3ContainerId(country) + let containerUrl = "\(CloudApiConstants.storeBase)/container/\(storeCountry)/en/19/\(containerId)" + os_log(.info, log: catalogLog, + "=== Fetching PS3 Classics catalog (region group %{public}s for account country %{public}s) ===", + storeCountry, country) + + var allGames: [CloudGame] = [] + var start = 0 + var totalResults = -1 + + while true { + let url = "\(containerUrl)?useOffers=true&gkb=1&gkb2=1&start=\(start)&size=100" + guard let response = CloudHttpClient.get(url: url, headers: [ + "Accept": "application/json", + "User-Agent": CloudApiConstants.kamajiUserAgent + ]), response.statusCode == 200, + let data = response.body.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + os_log(.error, log: catalogLog, "PS3 catalog page failed at start=%d", start) + break + } + + if totalResults < 0 { + totalResults = (json["total_results"] as? Int) + ?? (json["total_results"] as? NSNumber)?.intValue ?? 0 + } + + let links = json["links"] as? [[String: Any]] ?? [] + var productCount = 0 + for link in links { + guard (link["container_type"] as? String) == "product" else { continue } + guard let game = parsePsnowGameObject(link) else { continue } + allGames.append(game) + productCount += 1 + } + + os_log(.info, log: catalogLog, "PS3 page games: %d accumulated: %d of %d", + productCount, allGames.count, totalResults) + + start += 100 + if productCount == 0 || start >= totalResults { break } + } + + os_log(.info, log: catalogLog, "PS3 Classics catalog: %d titles", allGames.count) + if !allGames.isEmpty { cacheGames(allGames, filename: cacheFile) } + return allGames + } + // MARK: - PSNow helpers private func fetchPsnowOAuthCode(npssoToken: String, duid: String) -> String? { diff --git a/ios/Pylux/Services/PSKamajiSession.swift b/ios/Pylux/Services/PSKamajiSession.swift index 6ce3325c..64cf6bcf 100644 --- a/ios/Pylux/Services/PSKamajiSession.swift +++ b/ios/Pylux/Services/PSKamajiSession.swift @@ -143,9 +143,26 @@ final class PSKamajiSession { let sku: String } + // PS3 / Classics product ids (NPEA/NPEB/BLES/BCES or NPUA/NPUB/BLUS/BCUS — anything that + // isn't a modern CUSA/PPSA id) come from the public Apollo catalog, which we walk in the + // account's region group (Americas -> US store, everything else -> PAL/GB). Resolve them + // against that SAME region's container so the lookup finds the product and returns the PSNW + // entitlement the account is authorized for at Gaikai. The account's own locale country can + // be a region with no pcnow storefront (e.g. Hungary -> "Storefront not found"), so map to + // the region-group store. Must match CloudCatalogService's PS3 catalog source. + private var isLegacyClassicsId: Bool { + return !productId.contains("CUSA") && !productId.contains("PPSA") + } + private func step0_5d_ConvertProductId(sessionId: String) -> ProductConversion? { let storePath = CloudLocaleSettings.parseStorePath(CloudLocaleSettings.stored) - let url = "\(storeBase)/container/\(storePath.country)/\(storePath.language)/19/\(productId)?useOffers=true&gkb=1&gkb2=1" + var country = storePath.country + var language = storePath.language + if isLegacyClassicsId { + country = ClassicsRegion.classicsStoreCountry(country) + language = "en" + } + let url = "\(storeBase)/container/\(country)/\(language)/19/\(productId)?useOffers=true&gkb=1&gkb2=1" os_log(.info, log: kamajiLog, "Store container locale: %{public}s", CloudLocaleSettings.stored) guard let response = CloudHttpClient.get(url: url, headers: [ @@ -161,33 +178,56 @@ final class PSKamajiSession { var sku = "" var detectedPlatform = "ps4" - // Check default_sku for streaming entitlements (license_type == 4) - if let defaultSku = json["default_sku"] as? [String: Any], - let ents = defaultSku["entitlements"] as? [[String: Any]] { + // PS Now streaming entitlements have license_type == 4. Check default_sku, then skus. + func pickStreamingEntitlement(_ skuObj: [String: Any]) -> Bool { + guard let ents = skuObj["entitlements"] as? [[String: Any]] else { return false } for ent in ents { if (ent["license_type"] as? Int) == 4, let id = ent["id"] as? String, !id.isEmpty { - eid = id; sku = defaultSku["id"] as? String ?? ""; break + eid = id; sku = skuObj["id"] as? String ?? ""; return true } } + return false } - - // Fallback to skus array + if let defaultSku = json["default_sku"] as? [String: Any] { _ = pickStreamingEntitlement(defaultSku) } if eid.isEmpty, let skus = json["skus"] as? [[String: Any]] { - for skuObj in skus { - if let ents = skuObj["entitlements"] as? [[String: Any]] { - for ent in ents { - if (ent["license_type"] as? Int) == 4, let id = ent["id"] as? String, !id.isEmpty { - eid = id; sku = skuObj["id"] as? String ?? ""; break - } - } + for skuObj in skus where pickStreamingEntitlement(skuObj) { break } + } + + // Full-game fallback: PS Plus catalog titles have no license_type==4 entitlement; their + // full-game entitlement is license_type 0 with packageType "*GD". Prefer the one whose id + // matches the requested product's title id so cross-gen picks the consistent platform. + if eid.isEmpty { + let requestedTitleId: String = { + let dash = productId.split(separator: "-") + guard dash.count >= 2 else { return "" } + return String(dash[1].split(separator: "_").first ?? "") + }() + func pickFullGameEntitlement(_ skuObj: [String: Any], requireTitleMatch: Bool) -> Bool { + guard let ents = skuObj["entitlements"] as? [[String: Any]] else { return false } + for ent in ents { + guard let id = ent["id"] as? String, !id.isEmpty else { continue } + let pkg = ent["packageType"] as? String ?? "" + guard pkg.hasSuffix("GD") else { continue } + if requireTitleMatch, !requestedTitleId.isEmpty, !id.contains(requestedTitleId) { continue } + eid = id; sku = skuObj["id"] as? String ?? ""; return true + } + return false + } + for requireTitleMatch in [true, false] { + if let defaultSku = json["default_sku"] as? [String: Any], + pickFullGameEntitlement(defaultSku, requireTitleMatch: requireTitleMatch) { break } + if let skus = json["skus"] as? [[String: Any]] { + var found = false + for skuObj in skus where pickFullGameEntitlement(skuObj, requireTitleMatch: requireTitleMatch) { found = true; break } + if found { break } } - if !eid.isEmpty { break } } } - // Detect platform + // Detect platform (PS5 > PS4 > PS3) if let platforms = json["playable_platform"] as? [String] { - if platforms.contains(where: { $0.localizedCaseInsensitiveContains("PS4") }) { detectedPlatform = "ps4" } + if platforms.contains(where: { $0.localizedCaseInsensitiveContains("PS5") }) { detectedPlatform = "ps5" } + else if platforms.contains(where: { $0.localizedCaseInsensitiveContains("PS4") }) { detectedPlatform = "ps4" } else if platforms.contains(where: { $0.localizedCaseInsensitiveContains("PS3") }) { detectedPlatform = "ps3" } } @@ -207,6 +247,20 @@ final class PSKamajiSession { if hasEntitlement == nil { return false } if hasEntitlement == true { return true } + // Entitlement not found (404). For PS3 / Classics (legacy non-CUSA/PPSA ids) the streaming + // entitlement is granted by the PS Plus subscription via a free 100%-off checkout, but that + // checkout requires a pcnow storefront in the account's region — which many regions (e.g. + // Hungary) don't have, so the acquire fails with "Against Eligibility Rule". On a real PS5 + // the subscription alone grants streaming with no purchase, so skip the acquire and let + // Gaikai validate the Premium subscription directly. If Gaikai genuinely needs the + // entitlement, it returns noGameForEntitlementId downstream and we learn the wall is there. + if isLegacyClassicsId { + os_log(.info, log: kamajiLog, + "Entitlement not found (404); legacy Classics id -> skipping acquire, proceeding to Gaikai") + return true + } + + // PS4/PS5 catalog: try to acquire it via checkout. // Step 0.5e.3: Checkout preview guard step0_5e3_CheckoutPreview(sessionId: sessionId) else { return false } diff --git a/ios/Pylux/Services/PsCloudOwnership.swift b/ios/Pylux/Services/PsCloudOwnership.swift index d6778e95..1d7b07ee 100644 --- a/ios/Pylux/Services/PsCloudOwnership.swift +++ b/ios/Pylux/Services/PsCloudOwnership.swift @@ -1,6 +1,9 @@ // SPDX-License-Identifier: LicenseRef-AGPL-3.0-only-OpenSSL import Foundation +import os.log + +private let ownershipLog = OSLog(subsystem: "com.pylux.stream", category: "CloudOwnership") /// Raw entitlement fields from Sony internal_entitlements API. struct PsCloudEntitlement { @@ -9,6 +12,8 @@ struct PsCloudEntitlement { let activeFlag: Bool let packageType: String let name: String + let conceptId: String + let featureType: Int // PSN feature_type: 3=full game, 1=trial/free, 0=add-on/DLC } enum PsCloudOwnership { @@ -22,10 +27,18 @@ enum PsCloudOwnership { static func filterOwnedPs5Games(_ entitlements: [PsCloudEntitlement]) -> [PsCloudEntitlement] { entitlements.filter { ent in - guard ent.packageType == "PSGD" else { return false } + // Previously required packageType == "PSGD" (PS5 only), which dropped owned + // PS4 titles (e.g. God of War 2018) and PS3 titles. Accept every active game + // entitlement; streamability is enforced downstream by the catalog cross-reference + // (matches are deduped by conceptId), so non-streamable / add-on entitlements are + // harmlessly dropped there. guard ent.activeFlag else { return false } let pid = ent.productId guard !pid.hasPrefix("IP"), !pid.hasPrefix("SUB") else { return false } + // Hide EXTRAS: feature_type==0 is DLC / add-ons / themes / avatars / cross-buy "tracks", + // never a base game (games are feature_type 1=trial/free or 3/5=full). Safe: can't hide a + // game. Trials/free and full games are kept; the trial-vs-full split is handled at merge. + guard ent.featureType != 0 else { return false } return true } } @@ -54,10 +67,16 @@ enum PsCloudOwnership { let browseStableKey = buildStableKeyIndex(publicCatalog) let supplementStableKey = buildStableKeyIndex(plusLibrarySupplement) + let browseByConcept = buildConceptIdIndex(publicCatalog) + let supplementByConcept = buildConceptIdIndex(plusLibrarySupplement) var byKey: [String: CloudGame] = [:] + var byKeyRank: [String: Int] = [:] - func emitMatch(meta: CloudGame, ent: PsCloudEntitlement) { + // Enrich one matched catalog row into an owned CloudGame and dedupe it into byKey, keeping + // OUR convention (conceptId+PLATFORM dedupe, canonical-entitlement rank). Called once for a + // direct match, or once per component for a bundle (upstream PR #15 bundle-sibling matching). + func emit(_ meta: CloudGame, _ ent: PsCloudEntitlement) { let displayName = meta.name.isEmpty ? ent.name : meta.name let game = CloudGame( productId: meta.id, @@ -70,83 +89,193 @@ enum PsCloudOwnership { conceptId: meta.conceptId, isOwned: true, entitlementId: ent.id, - storeProductId: ent.productId + storeProductId: ent.productId, + featureType: ent.featureType ) - let key = ownedDedupeKey(meta: meta, ent: ent) - if let existing = byKey[key] { - byKey[key] = preferOwnedEntry(existing: existing, candidate: game) + let candidateRank = ownedStreamRank(ent) + if byKey[key] != nil { + // Keep the best streaming candidate: the canonical full-game entitlement (its + // product_id is the real streamable game, not a DLC/bonus product Gaikai rejects). + if candidateRank > (byKeyRank[key] ?? -1) { + byKey[key] = game + byKeyRank[key] = candidateRank + } } else { byKey[key] = game + byKeyRank[key] = candidateRank } } for ent in filteredEntitlements { + let stable = productIdStableKey(ent.productId) + let entStable = productIdStableKey(ent.id) let skipStableDemo = ent.name.localizedCaseInsensitiveContains("demo") - var matches: [CloudGame] = [] - + let meta: CloudGame? if !ent.productId.isEmpty, let g = catalogMap[ent.productId] { - matches.append(g) + meta = g } else if !ent.id.isEmpty, let g = catalogMap[ent.id] { - matches.append(g) + meta = g + } else if !ent.conceptId.isEmpty, let g = browseByConcept[ent.conceptId] { + // conceptId is region-stable; product IDs are region-prefixed (EP9000 vs UP9000). + meta = g + } else if !ent.conceptId.isEmpty, let g = supplementByConcept[ent.conceptId] { + meta = g } else if !ent.productId.isEmpty, ent.id == ent.productId, let g = supplementMap[ent.productId] { - matches.append(g) + meta = g + } else if let stable, !skipStableDemo, let g = browseStableKey[stable] { + meta = g + } else if let stable, !skipStableDemo, let g = supplementStableKey[stable] { + meta = g + } else if let entStable, !skipStableDemo, let g = browseStableKey[entStable] { + // Stable-key match on the ENTITLEMENT id (upstream PR #15): catches cross-gen / upgrade + // entitlement ids whose stable key matches a catalog row even when product_id did not. + meta = g + } else if let entStable, !skipStableDemo, let g = supplementStableKey[entStable] { + meta = g } else { - let entitlementStableKey = productIdStableKey(ent.id) - if let entitlementStableKey, !skipStableDemo, - let g = browseStableKey[entitlementStableKey] { - matches.append(g) - } else if let entitlementStableKey, !skipStableDemo, - let g = supplementStableKey[entitlementStableKey] { - matches.append(g) - } + meta = nil } - if matches.isEmpty { - var seenProductIds: Set = [] - for siblingId in componentIdsByProductId[ent.productId] ?? [] { - let siblingMeta: CloudGame? - if let g = catalogMap[siblingId] { - siblingMeta = g - } else if let g = supplementMap[siblingId] { - siblingMeta = g - } else if let siblingStableKey = productIdStableKey(siblingId), !skipStableDemo { - siblingMeta = browseStableKey[siblingStableKey] - ?? supplementStableKey[siblingStableKey] - } else { - siblingMeta = nil - } - - guard let meta = siblingMeta else { continue } - if meta.id.isEmpty || seenProductIds.contains(meta.id) { continue } - seenProductIds.insert(meta.id) - matches.append(meta) - } + if let meta { + emit(meta, ent) + continue } - if matches.isEmpty { continue } + // Bundle-sibling expansion (upstream PR #15): a bundle entitlement (e.g. RE7 Gold) has no + // direct catalog row, but its component entitlement ids each map to a component game. + var seenPids = Set() + for siblingId in componentIdsByProductId[ent.productId] ?? [] { + let siblingMeta: CloudGame? + if let g = catalogMap[siblingId] { + siblingMeta = g + } else if let g = supplementMap[siblingId] { + siblingMeta = g + } else if let sStable = productIdStableKey(siblingId), !skipStableDemo { + siblingMeta = browseStableKey[sStable] ?? supplementStableKey[sStable] + } else { + siblingMeta = nil + } + guard let sMeta = siblingMeta, !sMeta.id.isEmpty, !seenPids.contains(sMeta.id) else { continue } + seenPids.insert(sMeta.id) + emit(sMeta, ent) + } + } - for meta in matches { - emitMatch(meta: meta, ent: ent) + // Disc-upgrade rescue (mirrors cloudcatalogbackend.cpp). feature_type 5 is a PS4-disc -> PS5 + // *disc upgrade* license; Gaikai refuses to cloud-stream it ("disc-upgrade-unsupported"). The + // browse catalog often binds the concept to exactly that SKU (e.g. Horizon Forbidden West + // concept 10000886 -> PPSA01521), while the user's streamable full-game entitlement is a + // DIFFERENT title id (e.g. Complete Edition PPSA17903) that is absent from the catalog and + // carries no conceptId -- so it never matches and only the disc-upgrade SKU survives. When a + // concept winner is a disc upgrade, adopt a same-name full-game (feature_type 3) owned SKU's + // product id so the card streams the edition Gaikai accepts. + // + // Entitlements carry no conceptId and the disc-upgrade SKU shares no id/sku with the real + // edition, so the only in-data bridge is the title name. To keep that safe: SAME PLATFORM only + // (a PS5/PPSA disc upgrade must never resolve to a PS4/CUSA SKU), prefer the canonical base + // game (product_id == entitlement id), and BAIL on genuine ambiguity rather than guess. + for key in Array(byKey.keys) { + guard let game = byKey[key], game.featureType == 5 else { continue } + let discPid = game.storeProductId + let discPlatform = platformToken(discPid) + guard let discEnt = filteredEntitlements.first(where: { + $0.productId == discPid && $0.featureType == 5 + }) else { continue } + let discName = normalizeTitle(discEnt.name) + guard !discName.isEmpty else { continue } + var canonical: [String] = [] // base-game SKUs (product_id == entitlement id) + var other: [String] = [] // non-canonical full-game SKUs + for cand in filteredEntitlements where cand.featureType == 3 { + guard normalizeTitle(cand.name) == discName else { continue } + let candPid = cand.productId + guard !candPid.isEmpty, candPid != discPid else { continue } + guard platformToken(candPid) == discPlatform else { continue } + if candPid == cand.id { + if !canonical.contains(candPid) { canonical.append(candPid) } + } else if !other.contains(candPid) { + other.append(candPid) + } + } + let replacement: String? + if canonical.count == 1 { + replacement = canonical[0] + } else if canonical.isEmpty, other.count == 1 { + replacement = other[0] + } else { + replacement = nil } + guard let rep = replacement else { + if !canonical.isEmpty || !other.isEmpty { + os_log(.info, log: ownershipLog, + "disc-upgrade rescue: ambiguous candidates for %{public}s -- leaving disc SKU", + discName) + } + continue + } + var updated = game + updated.storeProductId = rep + byKey[key] = updated + os_log(.info, log: ownershipLog, "disc-upgrade rescue: %{public}s %{public}s -> %{public}s", + discName, discPid, rep) } return Array(byKey.values) } + // Edition identity = conceptId + PLATFORM (matching the catalog's edition key), so a cross-gen + // title owned on both PS4 and PS5 stays as two separate library entries instead of collapsing + // into one. Same-platform duplicate SKUs (a remaster's add-ons) still merge. private static func ownedDedupeKey(meta: CloudGame, ent: PsCloudEntitlement) -> String { - if !meta.conceptId.isEmpty { return "c:\(meta.conceptId)" } + if !meta.conceptId.isEmpty { return "c:\(meta.conceptId):\(platformToken(ent.productId))" } if !meta.id.isEmpty { return "p:\(meta.id)" } if !ent.id.isEmpty { return "e:\(ent.id)" } return "u:\(meta.id):\(ent.id)" } - private static func preferOwnedEntry(existing: CloudGame, candidate: CloudGame) -> CloudGame { - if existing.entitlementId.isEmpty, !candidate.entitlementId.isEmpty { - return candidate - } - return existing + // Platform token from a product id (CUSA = PS4, PPSA = PS5). + static func platformToken(_ productId: String) -> String { + if productId.contains("PPSA") { return "ps5" } + if productId.contains("CUSA") { return "ps4" } + return "" + } + + // Lowercase, strip trademark/registered/service-mark glyphs, and collapse whitespace so two owned + // entitlements for the same game compare equal across punctuation/spacing differences. + private static func normalizeTitle(_ raw: String) -> String { + let stripped = raw.lowercased() + .replacingOccurrences(of: "\u{2122}", with: "") + .replacingOccurrences(of: "\u{00AE}", with: "") + .replacingOccurrences(of: "\u{2120}", with: "") + return stripped.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ") + } + + // A "full game" entitlement (vs add-on/avatar/theme): PSN marks the base game with a *GD + // package_type (PSGD/PS4GD); add-ons use PS4MISC/PSAL/etc. + private static func isFullGameEntitlement(_ ent: PsCloudEntitlement) -> Bool { + ent.featureType == 3 || ent.packageType.hasSuffix("GD") + } + + // Rank an owned entitlement as THE streaming candidate for its edition (higher = preferred). + // Bonus/upgrade SKUs collapse to the same conceptId+platform as the base game; package/feature + // flags don't disambiguate (Death Stranding DC's "Bonus Content" is also PSGD + feature_type 3). + // The reliable signal: the base game's entitlement id EQUALS its product_id, while bonus/upgrade + // SKUs carry a different id -- so prefer the canonical full-game entitlement. + private static func ownedStreamRank(_ ent: PsCloudEntitlement) -> Int { + var rank = 0 + if !ent.productId.isEmpty && ent.productId == ent.id { rank += 4 } // canonical base-game SKU + if isFullGameEntitlement(ent) { rank += 2 } + if !ent.id.isEmpty { rank += 1 } + return rank + } + + // conceptId + platform for an owned/catalog game; the owned product id (storeProductId) takes + // precedence so the owned edition's platform is used, else the catalog product id. + private static func conceptPlatformKey(_ game: CloudGame) -> String { + guard !game.conceptId.isEmpty else { return "" } + let pid = game.storeProductId.isEmpty ? game.id : game.storeProductId + return "\(game.conceptId)|\(platformToken(pid))" } /// Tokenize on '-' and '_'; identity is all tokens except the last (store SKU). @@ -173,15 +302,38 @@ enum PsCloudOwnership { return index } + private static func buildConceptIdIndex(_ games: [CloudGame]) -> [String: CloudGame] { + var index: [String: CloudGame] = [:] + for game in games where !game.conceptId.isEmpty { + if index[game.conceptId] == nil { + index[game.conceptId] = game + } + } + return index + } + + /// Normalize a conceptId (imagic encodes it as a number) to a non-empty string, else nil. + static func conceptIdString(_ value: Any?) -> String? { + if let i = value as? Int { return i > 0 ? String(i) : nil } + if let d = value as? Double { return d > 0 ? String(Int(d)) : nil } + if let s = value as? String, !s.isEmpty { return s } + return nil + } + static func mergeOwnedIntoBrowseCatalog( browseCatalog: [CloudGame], - ownedCrossRef: [CloudGame] + ownedCrossRef: [CloudGame], + addUnmatched: Bool = true // false = only mark ownership on catalog entries (Catalog tab) ) -> [CloudGame] { var games = browseCatalog var catalogIndex = buildCatalogIndex(games) for owned in ownedCrossRef { - let catalogMatch = findCatalogIndexForOwned(owned, catalogIndex: catalogIndex) + // Trials / free-to-play (feature_type 1) are kept as their OWN card so the user can Stream + // the trial/free build, while the full version still shows separately as a not-owned + // "Add Game" card -- so a trial must NOT collapse into the full-game catalog entry. + let isTrialTier = owned.featureType == 1 + let catalogMatch = isTrialTier ? -1 : findCatalogIndexForOwned(owned, catalogIndex: catalogIndex) if catalogMatch >= 0 { var existing = games[catalogMatch] existing.isOwned = true @@ -191,6 +343,7 @@ enum PsCloudOwnership { continue } + guard addUnmatched else { continue } var entry = owned entry.isOwned = true registerInCatalogIndex(entry, index: games.count, catalogIndex: &catalogIndex) @@ -207,12 +360,18 @@ enum PsCloudOwnership { guard let id = obj["id"] as? String, !id.isEmpty else { return nil } let gameMeta = obj["game_meta"] as? [String: Any] ?? [:] let name = (gameMeta["name"] as? String) ?? id + let conceptId = conceptIdString(gameMeta["conceptId"]) + ?? conceptIdString(gameMeta["concept_id"]) + ?? conceptIdString(obj["conceptId"]) + ?? "" return PsCloudEntitlement( id: id, productId: (obj["product_id"] as? String) ?? "", activeFlag: (obj["active_flag"] as? Bool) ?? false, packageType: (gameMeta["package_type"] as? String) ?? "", - name: name + name: name, + conceptId: conceptId, + featureType: (obj["feature_type"] as? NSNumber)?.intValue ?? 0 ) } @@ -230,7 +389,8 @@ enum PsCloudOwnership { catalogIndex: inout CatalogIndex ) { if !game.id.isEmpty { catalogIndex.byProductId[game.id] = index } - if !game.conceptId.isEmpty { catalogIndex.byConceptId[game.conceptId] = index } + let conceptKey = conceptPlatformKey(game) + if !conceptKey.isEmpty { catalogIndex.byConceptId[conceptKey] = index } if !game.entitlementId.isEmpty, game.entitlementId != game.id { catalogIndex.byProductId[game.entitlementId] = index } @@ -240,7 +400,10 @@ enum PsCloudOwnership { if !owned.id.isEmpty, let idx = catalogIndex.byProductId[owned.id] { return idx } if !owned.entitlementId.isEmpty, let idx = catalogIndex.byProductId[owned.entitlementId] { return idx } if !owned.storeProductId.isEmpty, let idx = catalogIndex.byProductId[owned.storeProductId] { return idx } - if !owned.conceptId.isEmpty, let idx = catalogIndex.byConceptId[owned.conceptId] { return idx } + // Match by conceptId + platform so an owned PS4 edition does not match a PS5-only catalog + // entry (and vice-versa); cross-gen editions stay as separate library cards. + let conceptKey = conceptPlatformKey(owned) + if !conceptKey.isEmpty, let idx = catalogIndex.byConceptId[conceptKey] { return idx } return -1 } } diff --git a/ios/Pylux/Views/CloudPlayView.swift b/ios/Pylux/Views/CloudPlayView.swift index bd3bea81..5a5b7ec7 100644 --- a/ios/Pylux/Views/CloudPlayView.swift +++ b/ios/Pylux/Views/CloudPlayView.swift @@ -110,16 +110,26 @@ final class CloudPlayViewModel: ObservableObject { Task.detached(priority: .userInitiated) { [weak self] in guard let self = self else { return } - let loadedGames: [CloudGame] + var loadedGames: [CloudGame] switch section { case .catalog: - loadedGames = self.catalogService.fetchPsnowCatalog(npssoToken: npssoToken) + let psnow = self.catalogService.fetchPsnowCatalog(npssoToken: npssoToken) + // The legacy PS Now (Kamaji) browse store 404s in many regions. Fall back to the + // PS Plus subscription catalog (~630), NOT the full ~4000 universe — the Library + // "all" view is the full-universe browse. + loadedGames = psnow.isEmpty + ? self.catalogService.fetchPlusCatalogGames(npssoToken: npssoToken) + : psnow + // PS3 Classics are subscription-streamable, so they belong in the Game Catalog. + loadedGames += self.catalogService.fetchPs3Catalog() case .library: if ownedOnly { loadedGames = self.catalogService.fetchOwnedPs5Games(npssoToken: npssoToken) } else { loadedGames = self.catalogService.fetchAllPs5CloudGames(npssoToken: npssoToken) + // PS3 Classics are part of the streamable "all" universe (never the "owned" view). + loadedGames += self.catalogService.fetchPs3Catalog() } } @@ -160,7 +170,7 @@ final class CloudPlayViewModel: ObservableObject { let ownedOnly = showOwnedOnly Task.detached(priority: .userInitiated) { [weak self] in - let loadedGames: [CloudGame] + var loadedGames: [CloudGame] = [] defer { Task { @MainActor in self?.loading = false @@ -171,12 +181,21 @@ final class CloudPlayViewModel: ObservableObject { switch section { case .catalog: - loadedGames = self.catalogService.fetchPsnowCatalog(npssoToken: npssoToken, forceRefresh: true) + let psnow = self.catalogService.fetchPsnowCatalog(npssoToken: npssoToken, forceRefresh: true) + // Fall back to the PS Plus subscription catalog when the legacy PS Now store is + // unavailable for the region (Library "all" is the full-universe browse). + loadedGames = psnow.isEmpty + ? self.catalogService.fetchPlusCatalogGames(npssoToken: npssoToken, forceRefresh: true) + : psnow + // PS3 Classics are subscription-streamable, so they belong in the Game Catalog. + loadedGames += self.catalogService.fetchPs3Catalog(forceRefresh: true) case .library: if ownedOnly { loadedGames = self.catalogService.fetchOwnedPs5Games(npssoToken: npssoToken, forceRefresh: true) } else { loadedGames = self.catalogService.fetchAllPs5CloudGames(npssoToken: npssoToken, forceRefresh: true) + // PS3 Classics are part of the streamable "all" universe (never the "owned" view). + loadedGames += self.catalogService.fetchPs3Catalog(forceRefresh: true) } } @@ -195,9 +214,11 @@ final class CloudPlayViewModel: ObservableObject { Task.detached(priority: .userInitiated) { [weak self] in guard let self = self else { return } - let gameIdentifier = game.streamingIdentifier + // Route by the title-id platform: PS4 catalog titles go through Kamaji (psnow) to + // acquire the streaming entitlement; PS5 streams directly (pscloud). + let gameIdentifier = game.streamIdentifier let gameName = game.name - let serviceType = game.serviceType + let serviceType = game.streamServiceType var cancelled = false do { @@ -538,11 +559,12 @@ struct CloudPlayView: View { .padding(.vertical, 8) } - /// Matches Android `CloudPlayFragment.onGameClicked`: PS Cloud + All filter + not owned → add-to-library, else stream. + /// Any non-owned modern cloud-catalog game (PS4 or PS5) must be added to your library before it + /// can stream — Gaikai rejects an unowned PS5 entitlement, and modern PS-Plus PS4 titles (e.g. + /// Far Cry 5) have no free Kamaji SKU. Owned games stream directly. (Legacy PS Now is psnow.) private func handleGameTap(_ game: CloudGame) { let isPscloud = game.serviceType.lowercased() == "pscloud" - let isAllGamesFilter = !viewModel.showOwnedOnly - if viewModel.currentSection == .library && isPscloud && isAllGamesFilter && !game.isOwned { + if isPscloud && !game.isOwned { let url = game.conceptUrl.trimmingCharacters(in: .whitespacesAndNewlines) if url.isEmpty { showMissingConceptAlert = true @@ -593,7 +615,7 @@ struct CloudPlayView: View { CloudGameCardView( game: game, isFavorite: viewModel.favoriteIds.contains(game.id), - showOwnershipBadge: viewModel.currentSection == .library, + showOwnershipBadge: true, // owned/not-owned shown in Library AND Catalog (pscloud cards) onTap: { handleGameTap(game) },