From 9786526c1f87ddf3b2a17c1c0e711969e2cef2e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Nyakas?= Date: Wed, 3 Jun 2026 12:44:02 +0200 Subject: [PATCH 1/6] PS Plus Cloud Play: region catalog, PS4/PS5 streaming, cross-gen library + ownership (Qt/iOS/Android) Reworks PlayStation Plus Cloud Play end to end so it works in English-only regions (e.g. Hungary), streams owned PS4 and PS5 titles correctly, shows cross-gen editions, and classifies ownership (full game vs trial vs add-on). Applied across the Qt (C++/QML), iOS (Swift) and Android (Kotlin) clients. Catalog / region - Store-locale fallback chain (lang-COUNTRY -> en-COUNTRY -> en-US) so the imagic catalog loads in every region; the validated locale persists. - Accept PS4 (not just PS5) cloud titles in the merge; capture streamingSupported=false subscription titles into the library-stream supplement from every subscription list (these stream via the legacy Kamaji/kratos path even though they are absent from public cloud browse). - Scope the views: Game Catalog = PS Plus subscription lists (plusCatalog tag); Library "all" = full streamable universe + owned; Library "owned" = owned. - Catalog falls back to the imagic catalog when the legacy PS Now /user/stores browse 404s (it does in many regions). - Dedupe per game per platform so cross-gen PS4/PS5 editions both appear. - Broaden the owned-games filter; match owned entitlements by conceptId in addition to product id / stable key. Owned-title streaming (entitlement resolution) - PS5 streams the owned PRODUCT id, not the entitlement id: a cross-gen upgrade (PS4 purchase + free PS5 copy) carries a stale original-SKU entitlement id that Gaikai's cloud catalog has no game for (-> noGameForEntitlementId); product_id is the current streamable SKU. (Fixed Alan Wake Remastered, Death Stranding DC.) - When several SKUs collapse to one edition (base game + bonus/upgrade/avatars), keep the canonical full-game entitlement -- the one whose entitlement id EQUALS its product_id. Package/feature flags don't disambiguate (Death Stranding DC's "Bonus Content" is also PSGD + feature_type 3), so the id==product_id signal is what selects the real game over a DLC product Gaikai can't stream. - PS4 streams the catalog's streamable variant (e.g. God of War's "...N" SKU whose Kamaji container holds the PS-Now license_type=4 SKU), not the owned download SKU; derive the streaming platform from the owned product (cross-gen catalog entries list the other generation). PS4 (CUSA) -> Kamaji/psnow; PS5 (PPSA) -> direct Gaikai (cronos). Datacenter ping no longer hard-fails on a measurement error (Qt). Ownership classification (feature_type) - feature_type 3/5 = full game owned, 1 = trial / free-to-play, 0 = add-on/DLC. - Drop feature_type==0 extras from the owned set (DLC/themes/avatars are never a base game). Keep trials and free-to-play; a trial is kept as its own card so the full version still shows separately as "Add Game" (a trial does not collapse into the full-game catalog entry). Cross-gen owned-library split - Key owned-edition identity on conceptId + PLATFORM (matching the catalog tab) in both the owned cross-reference dedupe and the library merge, so a title owned on PS4 and PS5 (e.g. Days Gone + Days Gone Remastered) shows two separate, independently-streamable cards. Platform labels - Derive PS4/PS5 from the title id (CUSA/PPSA) instead of the hard-coded platform="ps5" -- Android at display time (CloudGameAdapter), iOS in the parser/deserializer (self-correcting the cache); Qt already did. Catalog ownership UX - Cross-reference the catalog against owned entitlements (mark-only): owned -> "Stream", non-owned modern cloud titles -> "Add Game"; OWNED / NOT OWNED badge. Build - Remove the committed machine-specific org.gradle.java.home (an absolute Windows path that broke every non-Windows / CI Gradle build); document selecting the JDK 21 daemon per-machine via JAVA_HOME / ~/.gradle / the IDE Gradle JDK setting. Verified - Qt/macOS (Hungary / PS Plus Premium): catalog loads region-wide; owned PS4 (God of War) and PS5 (Alan Wake Remastered, Death Stranding DC) stream from Library and Catalog; cross-gen Days Gone shows + streams both editions; a trial (Cyberpunk) shows its own Stream card plus an "Add Game" card for the full version; adding the PS5 Remaster lets Spider-Man stream; labels and OWNED/NOT OWNED badges correct. - iOS: swiftc -parse clean. Android: compiles (compileDebugKotlin). Mobile not device-re-tested for the latest streaming/ownership pass. Upstream reconciliation (PR #15) - Sits on top of the merged "PS5 cloud ownership matching" PR (#15) and incorporates its useful additions: bundle-sibling expansion (a bundle entitlement, e.g. RE7 Gold, expands to its component games via componentIdsByProductId) and stable-key matching on the entitlement id. These are grafted onto our cross-reference as additive fallbacks (they only fire when our direct cascade finds no match), keeping our dedupe (conceptId+platform + canonical-entitlement rank), feature_type filtering and field convention where the two approaches differed. Known limitations - Some PS Plus titles are download-only (no cloud-streaming SKU, e.g. Far Cry 5, original Spider-Man PS4): indistinguishable in the catalog from streamable PS4 titles, so they appear but fail at sessions/start with noGameForEntitlementId. - PS5 catalog-only titles must be added to the library externally (PS App) first. - PS3 titles absent from the modern imagic API; PS5 HEVC video-decode freeze is a separate pipeline issue. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../metallic/chiaki/cloudplay/CloudLocale.kt | 25 + .../chiaki/cloudplay/api/PSKamajiSession.kt | 38 + .../cloudplay/api/PsCloudCatalogService.kt | 86 ++- .../chiaki/cloudplay/api/PsCloudOwnership.kt | 267 +++++-- .../chiaki/cloudplay/model/CloudGame.kt | 4 +- .../repository/CloudGameRepository.kt | 135 +++- .../com/metallic/chiaki/common/Preferences.kt | 13 + .../metallic/chiaki/main/CloudGameAdapter.kt | 19 +- .../metallic/chiaki/main/CloudPlayFragment.kt | 12 +- android/gradle.properties | 7 +- gui/include/cloudcatalogbackend.h | 11 +- gui/src/cloudcatalogbackend.cpp | 709 ++++++++++++------ gui/src/cloudstreaming/psgaikaistreaming.cpp | 21 +- gui/src/cloudstreaming/pskamajisession.cpp | 71 +- gui/src/cloudstreamingbackend.cpp | 97 +-- gui/src/qml/CloudGameCard.qml | 71 +- gui/src/qml/CloudPlayView.qml | 176 ++++- ios/Pylux/Models/CloudModels.swift | 79 +- ios/Pylux/Services/CloudCatalogService.swift | 156 ++-- ios/Pylux/Services/PSKamajiSession.swift | 55 +- ios/Pylux/Services/PsCloudOwnership.swift | 200 +++-- ios/Pylux/Views/CloudPlayView.swift | 30 +- 22 files changed, 1653 insertions(+), 629 deletions(-) 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/api/PSKamajiSession.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/api/PSKamajiSession.kt index b995de55..4a6675d1 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 @@ -412,6 +412,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")) { 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..e927bd74 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 ) } 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..0f6805e9 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 @@ -15,7 +15,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 +28,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 +87,143 @@ 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)) - } - else if (ent.id.isNotEmpty() && catalogMap.containsKey(ent.id)) - { - matches.add(catalogMap.getValue(ent.id)) - } - else if (ent.productId.isNotEmpty() && ent.id == ent.productId - && supplementMap.containsKey(ent.productId)) - { - matches.add(supplementMap.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 (meta != null) { - val entitlementStableKey = productIdStableKey(ent.id) - if (entitlementStableKey != null && !skipStableDemo - && browseStableKey.containsKey(entitlementStableKey)) - { - matches.add(browseStableKey.getValue(entitlementStableKey)) - } - else if (entitlementStableKey != null && !skipStableDemo - && supplementStableKey.containsKey(entitlementStableKey)) - { - matches.add(supplementStableKey.getValue(entitlementStableKey)) - } + emit(meta, ent) + continue } - if (matches.isEmpty()) + // 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()) { - 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) - } + 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) } - - if (matches.isEmpty()) - continue - - for (meta in matches) - emitMatch(meta, ent) } 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 -> "" + } + + /** 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 +268,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 +290,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 +305,7 @@ object PsCloudOwnership continue } + if (!addUnmatched) continue val entry = owned.copy(isOwned = true) registerInCatalogIndex(entry, games.size, catalogIndex) games.add(entry) @@ -247,12 +321,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 +373,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 +388,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..008419c3 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,7 +49,7 @@ 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 } @@ -86,14 +86,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 +163,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 +178,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 +260,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, @@ -267,7 +328,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 +368,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 +485,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 +511,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..ff18783f 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 @@ -509,7 +509,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() @@ -1120,9 +1120,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 +1339,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/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..0e63ef01 100644 --- a/gui/include/cloudcatalogbackend.h +++ b/gui/include/cloudcatalogbackend.h @@ -107,6 +107,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 +138,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; @@ -153,6 +161,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/src/cloudcatalogbackend.cpp b/gui/src/cloudcatalogbackend.cpp index 993211f3..a408f376 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; } } @@ -872,6 +876,42 @@ void CloudCatalogBackend::processPsnowCatalogComplete() 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 +921,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 +957,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 +1044,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 +1110,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 +1129,7 @@ static void mergeImagicListIntoPs5Catalog(const QString &categoryList, QMap &productIdAliases, int &totalGamesSeen) { + const bool plusCatalog = isPlusCatalogList(categoryList); if (!doc.isArray()) return; @@ -985,36 +1143,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 +1199,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 +1209,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 +1238,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 +1303,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 +1356,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 +1379,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 +1740,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 +1753,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 +1765,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 +1797,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 +1945,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 +1959,7 @@ void CloudCatalogBackend::getOwnedPs5CloudGames(const QJSValue &callback) } } } - + // If we have both from cache, process immediately if (catalogFromCache && ownedFromCache) { processCrossReferenceComplete(); @@ -2072,7 +2296,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 +2304,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 +2486,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 +2521,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,47 +2543,126 @@ 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); } } @@ -2427,10 +2671,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 +2704,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 +2718,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..f866592c 100644 --- a/gui/src/cloudstreaming/pskamajisession.cpp +++ b/gui/src/cloudstreaming/pskamajisession.cpp @@ -478,15 +478,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 +555,9 @@ void PSKamajiSession::handleProductIdConversionResponse(QNetworkReply *reply) hasPS3 = true; } } - if (hasPS4) { + if (hasPS5) { + detectedPlatform = "ps5"; + } else if (hasPS4) { detectedPlatform = "ps4"; } else if (hasPS3) { detectedPlatform = "ps3"; 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..40bd35f9 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) { @@ -197,15 +201,94 @@ 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(); + Qt.callLater(() => { + if (gamesGrid.count > 0) { + gamesGrid.currentIndex = 0; + gamesGrid.forceActiveFocus(); + } + }); + }); }); } - + function ps5CloudProductId(game) { if (!game) return ""; @@ -221,6 +304,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 +340,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 +354,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 +369,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 +389,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 +408,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,7 +421,12 @@ Pane { if (streamId) existing.id = streamId; let ownedProductId = ps5CloudProductId(ownedGame); - if (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. + if (ownedProductId && ps5CloudPlatformToken(ownedProductId) === "ps5") { if (!existing.product_id) existing.product_id = ownedProductId; if (!existing.productId) @@ -314,6 +436,9 @@ Pane { 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 +458,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,7 +493,11 @@ 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; @@ -1370,8 +1502,14 @@ 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). + isPsnow: currentSection === "catalog" && !catalogImagicFallback + // 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). + libraryFilter: (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..3d6b27c1 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) @@ -191,6 +228,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 +261,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..94be7133 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 ) } diff --git a/ios/Pylux/Services/PSKamajiSession.swift b/ios/Pylux/Services/PSKamajiSession.swift index 6ce3325c..8b1cb55d 100644 --- a/ios/Pylux/Services/PSKamajiSession.swift +++ b/ios/Pylux/Services/PSKamajiSession.swift @@ -161,33 +161,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" } } diff --git a/ios/Pylux/Services/PsCloudOwnership.swift b/ios/Pylux/Services/PsCloudOwnership.swift index d6778e95..5a352bd4 100644 --- a/ios/Pylux/Services/PsCloudOwnership.swift +++ b/ios/Pylux/Services/PsCloudOwnership.swift @@ -9,6 +9,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 +24,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 +64,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 +86,125 @@ 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 } - - for meta in matches { - emitMatch(meta: meta, ent: ent) + // 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) } } 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 "" + } + + // 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 +231,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 +272,7 @@ enum PsCloudOwnership { continue } + guard addUnmatched else { continue } var entry = owned entry.isOwned = true registerInCatalogIndex(entry, index: games.count, catalogIndex: &catalogIndex) @@ -207,12 +289,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 +318,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 +329,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..98d8e7cf 100644 --- a/ios/Pylux/Views/CloudPlayView.swift +++ b/ios/Pylux/Views/CloudPlayView.swift @@ -114,7 +114,13 @@ final class CloudPlayViewModel: ObservableObject { 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 case .library: if ownedOnly { loadedGames = self.catalogService.fetchOwnedPs5Games(npssoToken: npssoToken) @@ -171,7 +177,12 @@ 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 case .library: if ownedOnly { loadedGames = self.catalogService.fetchOwnedPs5Games(npssoToken: npssoToken, forceRefresh: true) @@ -195,9 +206,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 +551,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 +607,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) }, From 36e5affbde4eacddb9834ac393499801fd80a299 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Nyakas?= Date: Wed, 3 Jun 2026 22:00:14 +0200 Subject: [PATCH 2/6] Add PS3 Classics cloud streaming (region-generic, all platforms) PS Plus Premium streams ~250-330 PS3 Classics that never appear in the imagic/gameslist catalog the rest of cloud play uses. Source them from the public pcnow ("Apollo") container API and stream them via the existing Gaikai konan path. - Catalog: new fetchPs3Catalog walks the public Apollo PS3 container (no auth), paginated; surfaced in the Game Catalog and Library "all" views (not "owned"). PS3 cards always show "Stream Game". - Region-generic: pcnow has two Classics id families -- Americas/SCEA (store MSF192018, UP/NPUA/BLUS ids, child APOLLOPS3GAMES) and PAL/SCEE (store MSF192014, EP/NPEA/NPEB/BLES ids, child APOLLOPS3). The account region group selects the store; everything outside the Americas -> PAL. - Streaming: for legacy (non-CUSA/PPSA) ids, resolve product->entitlement in the region-group store, and skip the regional checkout/acquire on a 404 (Premium auto-authorizes at Gaikai; the checkout is unavailable in regions without a pcnow storefront, e.g. Hungary). - PS4 (CUSA) / PS5 (PPSA) paths unchanged. Ported across macOS (Qt), iOS (Swift), Android (Kotlin). macOS + Android verified streaming on a real PS Plus Premium account; iOS compile-verified. Co-Authored-By: Claude Opus 4.8 --- .../chiaki/cloudplay/PsnApiConstants.kt | 38 ++++- .../chiaki/cloudplay/api/PSKamajiSession.kt | 36 ++++- .../cloudplay/api/PsCloudCatalogService.kt | 118 ++++++++++++++ .../repository/CloudGameRepository.kt | 41 +++++ .../metallic/chiaki/main/CloudPlayFragment.kt | 46 +++--- .../chiaki/main/CloudPlayViewModel.kt | 59 ++++++- gui/include/cloudcatalogbackend.h | 22 ++- gui/include/cloudstreaming/pskamajisession.h | 33 ++++ gui/src/cloudcatalogbackend.cpp | 146 ++++++++++++++++++ gui/src/cloudstreaming/pskamajisession.cpp | 37 ++++- gui/src/qml/CloudPlayView.qml | 64 +++++++- ios/Pylux/Models/CloudModels.swift | 33 ++++ ios/Pylux/Services/CloudCatalogService.swift | 79 ++++++++++ ios/Pylux/Services/PSKamajiSession.swift | 33 +++- ios/Pylux/Views/CloudPlayView.swift | 12 +- 15 files changed, 757 insertions(+), 40 deletions(-) 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 4a6675d1..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") @@ -560,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 e927bd74..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 @@ -477,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/repository/CloudGameRepository.kt b/android/app/src/main/java/com/metallic/chiaki/cloudplay/repository/CloudGameRepository.kt index 008419c3..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 @@ -52,6 +52,10 @@ class CloudGameRepository( 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) @@ -280,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 */ 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 ff18783f..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 @@ -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) } } } 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/gui/include/cloudcatalogbackend.h b/gui/include/cloudcatalogbackend.h index 0e63ef01..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; @@ -152,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(); 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 a408f376..e45179ea 100644 --- a/gui/src/cloudcatalogbackend.cpp +++ b/gui/src/cloudcatalogbackend.cpp @@ -874,6 +874,152 @@ 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. diff --git a/gui/src/cloudstreaming/pskamajisession.cpp b/gui/src/cloudstreaming/pskamajisession.cpp index f866592c..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); @@ -904,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/qml/CloudPlayView.qml b/gui/src/qml/CloudPlayView.qml index 40bd35f9..ddd963f2 100644 --- a/gui/src/qml/CloudPlayView.qml +++ b/gui/src/qml/CloudPlayView.qml @@ -180,6 +180,7 @@ Pane { authErrorMessage = ""; } applySearchFilter(); + appendPs3Catalog(); // Set focus after games are loaded Qt.callLater(() => { if (gamesGrid.count > 0) { @@ -279,6 +280,7 @@ Pane { ownedProductIds = Array.from(merged.ownedIds); isLoading = false; applySearchFilter(); + appendPs3Catalog(); Qt.callLater(() => { if (gamesGrid.count > 0) { gamesGrid.currentIndex = 0; @@ -289,6 +291,53 @@ Pane { }); } + // 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 ""; @@ -501,6 +550,7 @@ Pane { 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) { @@ -1504,12 +1554,20 @@ Pane { activeFocusOnTab: false // The catalog is normally PS Now; when it falls back to the imagic // cloud catalog the cards are pscloud (correct streaming path/platform). - isPsnow: currentSection === "catalog" && !catalogImagicFallback + // 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). - libraryFilter: (currentSection === "catalog" && catalogImagicFallback) - ? "catalog" : root.libraryFilter + // 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 3d6b27c1..2f523556 100644 --- a/ios/Pylux/Models/CloudModels.swift +++ b/ios/Pylux/Models/CloudModels.swift @@ -172,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 { diff --git a/ios/Pylux/Services/CloudCatalogService.swift b/ios/Pylux/Services/CloudCatalogService.swift index 94be7133..4adac4cf 100644 --- a/ios/Pylux/Services/CloudCatalogService.swift +++ b/ios/Pylux/Services/CloudCatalogService.swift @@ -638,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 8b1cb55d..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: [ @@ -230,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/Views/CloudPlayView.swift b/ios/Pylux/Views/CloudPlayView.swift index 98d8e7cf..5a5b7ec7 100644 --- a/ios/Pylux/Views/CloudPlayView.swift +++ b/ios/Pylux/Views/CloudPlayView.swift @@ -110,7 +110,7 @@ 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: @@ -121,11 +121,15 @@ final class CloudPlayViewModel: ObservableObject { 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() } } @@ -166,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 @@ -183,11 +187,15 @@ final class CloudPlayViewModel: ObservableObject { 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) } } From 773c9a95ba1526f5ba3d220246aa67c13a62fd1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Nyakas?= Date: Wed, 3 Jun 2026 22:08:54 +0200 Subject: [PATCH 3/6] Fix crash when starting a stream in portrait on tablets Starting a cloud stream locks the activity to landscape via requestedOrientation. On large tablets, portrait<->landscape also changes screenLayout/smallestScreenSize, which MainActivity didn't declare in configChanges -- so Android recreated the activity, detached CloudPlayFragment, and the in-flight startCloudStreaming coroutine then crashed on requireActivity() ("Fragment not attached to an activity"). Declare screenLayout|smallestScreenSize so MainActivity handles the rotation itself instead of being recreated, keeping the fragment attached. Co-Authored-By: Claude Opus 4.8 --- android/app/src/main/AndroidManifest.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"> From a05e6d186545b143ceeb6f055236eb0cc3fc21f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Nyakas?= Date: Thu, 4 Jun 2026 14:26:47 +0200 Subject: [PATCH 4/6] Cloud library: stream the owned full game for disc-upgrade titles PS Plus disc-upgrade entitlements (feature_type 5, e.g. Horizon Forbidden West EP9000-PPSA01521) are the SKU the imagic browse catalog binds the concept to, but Gaikai refuses to cloud-stream them ("disc-upgrade-unsupported"). The owned streamable edition (e.g. the Complete Edition PPSA17903) is a different title id that is absent from the catalog and -- like every commerce-API entitlement -- carries no conceptId, so the owned cross-reference never matches it and only the unstreamable disc-upgrade SKU survives the dedupe. Add a disc-upgrade rescue to the owned cross-reference on all platforms (Qt/iOS/Android): when a concept's surviving owned SKU 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. Since the only in-data bridge is the title name, it is guarded to stay safe: same platform only (a PS5 disc upgrade can never resolve to a PS4 CUSA SKU), prefer the canonical base game (product_id == entitlement id), and bail on genuine ambiguity rather than guess. Verified on macOS: Horizon Forbidden West now streams PPSA17903 instead of the rejected PPSA01521. Co-Authored-By: Claude Opus 4.8 --- .../chiaki/cloudplay/api/PsCloudOwnership.kt | 67 +++++++++++++++++ gui/src/cloudcatalogbackend.cpp | 75 +++++++++++++++++++ ios/Pylux/Services/PsCloudOwnership.swift | 71 ++++++++++++++++++ 3 files changed, 213 insertions(+) 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 0f6805e9..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 @@ -177,6 +179,64 @@ object PsCloudOwnership } } + // 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) + { + 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) + { + if (candPid !in canonical) canonical.add(candPid) + } + else if (candPid !in other) + { + other.add(candPid) + } + } + val replacement = when + { + canonical.size == 1 -> canonical[0] + canonical.isEmpty() && other.size == 1 -> other[0] + else -> null + } + if (replacement == null) + { + if (canonical.isNotEmpty() || other.isNotEmpty()) + Log.w(TAG, "disc-upgrade rescue: ambiguous candidates for $discName -- leaving disc SKU") + continue + } + byKey[key] = game.copy(storeProductId = replacement) + Log.i(TAG, "disc-upgrade rescue: $discName $discPid -> $replacement") + } + return byKey.values.toList() } @@ -199,6 +259,13 @@ object PsCloudOwnership 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") diff --git a/gui/src/cloudcatalogbackend.cpp b/gui/src/cloudcatalogbackend.cpp index e45179ea..ad3337a0 100644 --- a/gui/src/cloudcatalogbackend.cpp +++ b/gui/src/cloudcatalogbackend.cpp @@ -2812,6 +2812,81 @@ void CloudCatalogBackend::processCrossReferenceComplete() } } + // 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) filteredGames.append(gameObj); diff --git a/ios/Pylux/Services/PsCloudOwnership.swift b/ios/Pylux/Services/PsCloudOwnership.swift index 5a352bd4..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 { @@ -160,6 +163,64 @@ enum PsCloudOwnership { } } + // 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) } @@ -180,6 +241,16 @@ enum PsCloudOwnership { 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 { From 48c0bf3c92737e201a51d0c1345dee4a33e044ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Nyakas?= Date: Thu, 4 Jun 2026 17:29:29 +0200 Subject: [PATCH 5/6] Cloud library: always use the owned PS5 product id on merged catalog cards The "all" library view merges owned entitlements into the browse catalog. For PS5 (PPSA) the override of the catalog card's product id was guarded (if (!existing.product_id)), so it applied only when the catalog card had no id. When the browse row carries a product id -- e.g. Horizon Forbidden West's concept is bound to the disc-upgrade SKU PPSA01521 -- the guard kept that unstreamable id even though the cross-reference had rescued the owned full game (PPSA17903), so Gaikai rejected it with "disc-upgrade-unsupported". Override unconditionally for PS5, matching the iOS and Android merges (which always copy the owned storeProductId). The owned PS5 product IS the streamable entitlement, so it must win over the catalog's fixed per-concept SKU. PS4 (CUSA) is unaffected (the whole block is PS5-only). Fixes Horizon Forbidden West failing to stream on the Steam Deck / Linux build while macOS and Android worked -- the guard only happened to pass on those when the catalog cache had a null product id (data-dependent). Co-Authored-By: Claude Opus 4.8 --- gui/src/qml/CloudPlayView.qml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/gui/src/qml/CloudPlayView.qml b/gui/src/qml/CloudPlayView.qml index ddd963f2..9827bbef 100644 --- a/gui/src/qml/CloudPlayView.qml +++ b/gui/src/qml/CloudPlayView.qml @@ -475,11 +475,16 @@ Pane { // 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 -- the catalog card already has a (wrong) product_id, so a guarded assignment + // would keep the unstreamable id. The owned product id must win. if (ownedProductId && ps5CloudPlatformToken(ownedProductId) === "ps5") { - if (!existing.product_id) - existing.product_id = ownedProductId; - if (!existing.productId) - existing.productId = ownedProductId; + existing.product_id = ownedProductId; + existing.productId = ownedProductId; } games[catalogMatch] = existing; continue; From fbef1ee271636404e836b113ee8d525ccdb67a2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Nyakas?= Date: Thu, 4 Jun 2026 19:25:37 +0200 Subject: [PATCH 6/6] Cloud library: fix PS5 owned-id override never running in the "all" view ps5CloudPlatformToken() takes a GAME OBJECT (it reads game.productId / game.id), but the "all"-view merge passed it the product-id STRING. A string has no .productId/.id/.device, so it always returned "", the `=== "ps5"` test was never true, and the block that copies the owned product id onto the matched catalog card never executed -- for any game. That left the catalog card's own (often unstreamable) SKU in place. For Horizon Forbidden West the catalog binds the concept to the disc-upgrade SKU PPSA01521, so the "all" filter streamed that and Gaikai rejected it ("disc-upgrade-unsupported"), while the "owned" filter worked (it uses the cross-reference output directly, which already carries the rescued PPSA17903). Pass the game object so the platform check resolves to "ps5" and the owned product id wins. Pre-existing bug -- the earlier guard/un-guard edits were both inside this dead block, which is why neither changed anything. iOS/Android were unaffected (their merges copy storeProductId with no platform-token check). Co-Authored-By: Claude Opus 4.8 --- gui/src/qml/CloudPlayView.qml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/gui/src/qml/CloudPlayView.qml b/gui/src/qml/CloudPlayView.qml index 9827bbef..d7539af5 100644 --- a/gui/src/qml/CloudPlayView.qml +++ b/gui/src/qml/CloudPlayView.qml @@ -476,13 +476,16 @@ Pane { // 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 + // 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 -- the catalog card already has a (wrong) product_id, so a guarded assignment - // would keep the unstreamable id. The owned product id must win. - if (ownedProductId && ps5CloudPlatformToken(ownedProductId) === "ps5") { + // 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; }