diff --git a/app/src/main/java/com/bingestats/app/data/db/ViewingDao.kt b/app/src/main/java/com/bingestats/app/data/db/ViewingDao.kt index 0e115af..a994361 100644 --- a/app/src/main/java/com/bingestats/app/data/db/ViewingDao.kt +++ b/app/src/main/java/com/bingestats/app/data/db/ViewingDao.kt @@ -4,9 +4,19 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction import com.bingestats.app.data.model.ViewingItem import kotlinx.coroutines.flow.Flow +/** + * A batch of runtime/poster updates to apply in a single DB transaction. + */ +data class RuntimeUpdate( + val showTitle: String, + val durationMinutes: Int, + val posterPath: String? +) + data class ShowAggregation( val showTitle: String, val provider: String, @@ -105,6 +115,17 @@ interface ViewingDao { @Query("UPDATE viewing_items SET durationMinutes = :duration, posterPath = :posterPath WHERE showTitle = :showTitle") suspend fun updateShowRuntimeAndPoster(showTitle: String, duration: Int, posterPath: String?) + /** + * Apply many runtime/poster updates in a single transaction so Room emits only one + * change notification (instead of one per title, which would storm the stats Flows). + */ + @Transaction + suspend fun updateRuntimesBatch(updates: List) { + updates.forEach { u -> + updateShowRuntimeAndPoster(u.showTitle, u.durationMinutes, u.posterPath) + } + } + @Query("DELETE FROM viewing_items") suspend fun clearAll() } diff --git a/app/src/main/java/com/bingestats/app/data/model/StreamingProvider.kt b/app/src/main/java/com/bingestats/app/data/model/StreamingProvider.kt index 94a3bee..b0d3626 100644 --- a/app/src/main/java/com/bingestats/app/data/model/StreamingProvider.kt +++ b/app/src/main/java/com/bingestats/app/data/model/StreamingProvider.kt @@ -24,22 +24,15 @@ enum class StreamingProvider( id = "prime", displayName = "Prime Video", brandColorHex = "#00A8E1", - defaultWebUrl = "https://www.amazon.com/ap/signin", - viewingHistoryUrl = "https://www.amazon.com/gp/your-account/order-history" + defaultWebUrl = "https://www.primevideo.com", + viewingHistoryUrl = "https://www.primevideo.com/settings/watch-history" ), - DISNEY_PLUS( - id = "disney", - displayName = "Disney+", - brandColorHex = "#113CCF", - defaultWebUrl = "https://www.disneyplus.com/login", - viewingHistoryUrl = "https://www.disneyplus.com/account" - ), - APPLE_TV( - id = "apple_tv", - displayName = "Apple TV+", - brandColorHex = "#A2A2A2", - defaultWebUrl = "https://tv.apple.com", - viewingHistoryUrl = "https://tv.apple.com/settings" + OTHER( + id = "other", + displayName = "Andere", + brandColorHex = "#A0A0A0", + defaultWebUrl = "", + viewingHistoryUrl = "" ); val primaryColor: Color @@ -47,7 +40,7 @@ enum class StreamingProvider( companion object { fun fromId(id: String): StreamingProvider { - return entries.find { it.id.equals(id, ignoreCase = true) } ?: NETFLIX + return entries.find { it.id.equals(id, ignoreCase = true) } ?: OTHER } } } diff --git a/app/src/main/java/com/bingestats/app/data/parser/WebSyncScripts.kt b/app/src/main/java/com/bingestats/app/data/parser/WebSyncScripts.kt index cb02b32..a8445cd 100644 --- a/app/src/main/java/com/bingestats/app/data/parser/WebSyncScripts.kt +++ b/app/src/main/java/com/bingestats/app/data/parser/WebSyncScripts.kt @@ -15,76 +15,288 @@ package com.bingestats.app.data.parser object WebSyncScripts { /** - * Netflix: read BUILD_IDENTIFIER + authURL from the page's global context, then - * page through /api/shakti//viewingactivity collecting viewedItems. - * Returns a JSON array string (or "[]" on failure). + * Netflix: extract the full viewing history via the internal Shakti API. + * + * The page itself already fetched the viewing activity while loading, so the exact + * API URL (including all required query params like authURL) is present in the + * browser's performance resource entries. We clone that URL and only swap the page + * number for pagination. Building the URL ourselves (even with a correct authURL) + * yields HTTP 421 "Misdirected Request", because Netflix rejects hand-crafted + * requests via HTTP/2 connection coalescing checks. + * + * Fallbacks: reactContext BUILD_IDENTIFIER, HTML regex, and finally a DOM scrape. + * Each step logs to console (logcat tag BingeStats_Web). + * Returns a Promise resolving to a JSON array string of viewing items. */ val netflix: String = """ (function() { - try { - var ctx = window.netflix && window.netflix.reactContext; - if (!ctx) return JSON.stringify([]); - var serverDefs = ctx.models && ctx.models.serverDefs && ctx.models.serverDefs.data; - if (!serverDefs || !serverDefs.BUILD_IDENTIFIER) return JSON.stringify([]); - var buildId = serverDefs.BUILD_IDENTIFIER; - var authURL = ""; + function log(msg) { try { console.log('[BingeStats] ' + msg); } catch(e){} } + + // Find the real viewingactivity URL the page itself used (preferred) or + // reconstruct a root from reactContext/HTML as a last resort. + function discoverApi() { + // --- Preferred: performance resource entries (the SPA just called it) --- try { - authURL = ctx.models.memberContext.data.userInfo.data.authURL - || ctx.models.userInfo.data.authURL || ""; - } catch (e) {} + var entries = performance.getEntriesByType('resource').map(function(e){ return e.name; }); + for (var i = 0; i < entries.length; i++) { + if (entries[i].indexOf('viewingactivity') !== -1) { + log('perf viewingactivity URL gefunden'); + return { mode: 'url', template: entries[i] }; + } + } + log('kein viewingactivity perf-Eintrag (' + entries.length + ' resources)'); + } catch (e) { log('perf Fehler: ' + e.message); } - var collected = []; - var pageSize = 100; - var pg = 0; - var total = -1; + // --- Fallback A: reactContext build id (+ authURL) --- + try { + var n = window.netflix; + if (n) { + var rc = n.reactContext || n.appContext; + if (rc && rc.models) { + var sd = rc.models.serverDefs && rc.models.serverDefs.data; + if (sd && sd.BUILD_IDENTIFIER) { + var auth = ""; + try { + var mc = rc.models.memberContext || rc.models.userInfo; + auth = (mc && mc.data && (mc.data.userInfo && mc.data.userInfo.data && mc.data.userInfo.data.authURL)) + || (mc && mc.data && mc.data.authURL) || ""; + } catch (e) {} + log('reactContext OK, build=' + sd.BUILD_IDENTIFIER + (auth ? ' +auth' : ' ohne auth')); + return { mode: 'build', root: '/api/shakti/' + sd.BUILD_IDENTIFIER, auth: auth }; + } + } + } + log('reactContext nicht gefunden'); + } catch (e) { log('reactContext Fehler: ' + e.message); } + + // --- Fallback B: regex shakti path out of the page HTML --- + try { + var html = document.documentElement.outerHTML; + var m = html.match(/\/api\/shakti\/([a-zA-Z0-9]+)\//); + if (m) { + log('HTML regex build=' + m[1]); + return { mode: 'build', root: '/api/shakti/' + m[1], auth: '' }; + } + log('kein shakti-Pfad im HTML'); + } catch (e) { log('HTML regex Fehler: ' + e.message); } + + return null; + } + + // Build the fetch URL for a given page number from the discovered API info. + function urlForPage(api, n) { + if (api.mode === 'url') { + // Clone the captured URL, replace/insert pg and pgsize params. + var u = api.template; + u = u.replace(/([?&])pg=[^&]*/, '$1pg=' + n); + u = u.replace(/([?&])pgsize=[^&]*/, '$1pgsize=100'); + if (u.indexOf('pg=') === -1) u += (u.indexOf('?') === -1 ? '?' : '&') + 'pg=' + n; + if (u.indexOf('pgsize=') === -1) u += '&pgsize=100'; + return u; + } + var u = api.root + '/viewingactivity?pg=' + n + '&pgsize=100'; + if (api.auth) u += '&authURL=' + encodeURIComponent(api.auth); + return u; + } + + return new Promise(function(resolve) { + try { + // DOM scraper: Netflix's viewing-activity page is server-rendered, so the + // rows are present in the DOM. The exact class names are hashed/unstable, + // so we try several selector strategies and pick whichever yields items. + // Read one snapshot of the currently rendered rows. + function scrapeDomOnce() { + var out = []; + + // Strategy A:
  • rows whose text looks like "Title ... Date". + var lis = document.querySelectorAll('li'); + for (var i = 0; i < lis.length; i++) { + try { + var li = lis[i]; + var txt = li.textContent.replace(/\s+/g, ' ').trim(); + if (txt.length < 3 || txt.length > 300) continue; + // Split trailing date token (e.g. "Show Name 01.02.24" or "... 12/31/23"). + var m = txt.match(/^(.*?)[\s]+(\d{1,2}[\.\/]\d{1,2}[\.\/]\d{2,4})$/); + if (m) { + out.push({ key: txt, title: m[1].trim(), date: m[2] }); + } else { + out.push({ key: txt, title: txt, date: null }); + } + } catch (e) {} + } + if (out.length) return out; + + // Strategy B: any element carrying a data-title or aria-label. + var labelled = document.querySelectorAll('[data-title], [aria-label]'); + for (var j = 0; j < labelled.length; j++) { + try { + var t = (labelled[j].getAttribute('data-title') || labelled[j].getAttribute('aria-label') || '').trim(); + if (t.length < 3 || t.length > 300) continue; + out.push({ key: t, title: t, date: null }); + } catch (e) {} + } + return out; + } + + // Netflix lazy-loads older entries as you scroll (infinite scroll). So we + // scroll to the bottom repeatedly, snapshotting new rows into a deduped map + // until the count stops growing, then resolve with everything collected. + function scrollAndCollect(onDone) { + var seen = {}; + var collected = []; + var stableRounds = 0; + var lastCount = -1; + var attempt = 0; + + // Seed with the currently visible rows (logged once for debugging). + var firstBatch = document.querySelectorAll('li'); + if (firstBatch.length) { + log('DOM: ' + firstBatch.length + '
  • Elemente, erstes: ' + firstBatch[0].outerHTML.substring(0, 160)); + } + + // Netflix lazy-loads older entries via a "Show more" / "Mehr anzeigen" + // BUTTON (not infinite scroll), so we click it after each snapshot. + function clickShowMore() { + var btns = document.querySelectorAll('button, a'); + for (var i = 0; i < btns.length; i++) { + var b = btns[i]; + var txt = (b.textContent || '').trim().toLowerCase(); + if (txt.length < 2 || txt.length > 40) continue; + if (txt.indexOf('mehr anzeigen') !== -1 || + txt.indexOf('show more') !== -1 || + txt.indexOf('ver más') !== -1 || + txt.indexOf('afficher plus') !== -1 || + txt === 'mehr' || txt === 'more') { + b.click(); + return true; + } + } + return false; + } + + function tick() { + attempt++; + var snap = scrapeDomOnce(); + var added = 0; + for (var i = 0; i < snap.length; i++) { + var it = snap[i]; + if (!seen[it.key]) { + seen[it.key] = true; + collected.push({ title: it.title, date: it.date }); + added++; + } + } + if (collected.length === lastCount) { + stableRounds++; + } else { + stableRounds = 0; + lastCount = collected.length; + } + log('Schritt ' + attempt + ': ' + added + ' neu, gesamt ' + collected.length + ' (stable ' + stableRounds + ')'); + // Stop after the list stops growing (button gone), or at a hard cap. + if (stableRounds >= 4 || attempt > 500 || collected.length > 6000) { + onDone(collected); + } else { + var clicked = clickShowMore(); + if (!clicked) window.scrollTo(0, document.body.scrollHeight); + setTimeout(tick, 800); + } + } + tick(); + } + + var api = discoverApi(); + // If the page is server-rendered (no perf entry) the hand-built API call + // returns HTTP 421. Prefer the DOM scrape right away in that case: it is + // more reliable than fighting Netflix's anti-bot on the API. + if (!api || api.mode !== 'url') { + log('Server-gerendert oder keine URL -> DOM-Scrape mit Auto-Scroll'); + scrollAndCollect(function(items) { + log('DOM-Scrape fertig: ' + items.length + ' Eintraege'); + resolve(JSON.stringify(items)); + }); + return; + } + + var collected = []; + var pg = 0; + var total = -1; - // Synchronous loop over pages. fetch() is async, so we chain via a - // recursive helper and resolve the outer Promise. - return new Promise(function(resolve) { function loadPage(n) { - var url = '/api/shakti/' + buildId + '/viewingactivity?pg=' + n + '&pgsize=' + pageSize; - if (authURL) url += '&authURL=' + encodeURIComponent(authURL); - fetch(url, { credentials: 'include' }) - .then(function(r) { return r.ok ? r.json() : null; }) - .then(function(data) { - if (!data) { resolve(JSON.stringify(collected)); return; } + var url = urlForPage(api, n); + log('lade Seite ' + n + ': ' + url.substring(0, 120)); + fetch(url, { credentials: 'include', headers: { 'Accept': 'application/json' } }) + .then(function(r) { + log('Seite ' + n + ' HTTP ' + r.status); + return r.ok ? r.text() : null; + }) + .then(function(text) { + if (!text) { + // If the very first page fails, fall back to the scrolling + // DOM scrape before giving up. + if (n === 0) { + log('API-Fehler -> DOM-Scrape mit Auto-Scroll'); + scrollAndCollect(function(items) { + log('DOM-Scrape fertig: ' + items.length + ' Eintraege'); + resolve(JSON.stringify(items)); + }); + } else { + resolve(JSON.stringify(collected)); + } + return; + } + var data; + try { data = JSON.parse(text); } catch (e) { + log('JSON-Parse-Fehler Seite ' + n + ': ' + text.substring(0, 120)); + resolve(JSON.stringify(collected)); return; + } var items = data.viewedItems || []; collected = collected.concat(items); if (total < 0) total = data.vhSize || 0; pg++; - // Stop when a page is empty or we've read everything. + log('Seite ' + n + ': ' + items.length + ' Items, gesamt ' + collected.length + '/' + total); if (items.length === 0 || (total > 0 && collected.length >= total) || pg > 200) { resolve(JSON.stringify(collected)); } else { loadPage(pg); } }) - .catch(function() { resolve(JSON.stringify(collected)); }); + .catch(function(err) { + log('fetch Fehler Seite ' + n + ': ' + err.message); + resolve(JSON.stringify(collected)); + }); } loadPage(0); - }); - } catch (e) { - return JSON.stringify([]); - } + } catch (e) { + log('Fehler: ' + e.message); + resolve(JSON.stringify([])); + } + }); })() """.trimIndent() + /** - * Prime Video: parse the inline JSON embedded in