diff --git a/.zcode/plans/plan-sess_b4edee64-dc94-41eb-b331-7a42ff971024.md b/.zcode/plans/plan-sess_b4edee64-dc94-41eb-b331-7a42ff971024.md new file mode 100644 index 0000000..5c61261 --- /dev/null +++ b/.zcode/plans/plan-sess_b4edee64-dc94-41eb-b331-7a42ff971024.md @@ -0,0 +1,63 @@ +# Plan: Filme-Tab + Web-Sync-Reparatur + Navigations-Bug + +Drei unabhängige Fixes, alle ohne Datenbank-Migration (Room `fallbackToDestructiveMigration` ist aktiv, Schema bleibt v1, nur neue Queries). + +--- + +## Fix 1: Filme als eigener Tab in der Bottom-Navi + +Aktuell ist das Dashboard "Filme"-Zählen kaputt (`itemCount - topShows.episodeCount` mit `LIMIT 20`) und Filme tauchen im Serien-Ranking auf. + +**1a. Neue DAO-Queries** (`ViewingDao.kt`) — Room speichert `contentType` als TEXT (`'MOVIE'`/`'SERIES'`), daher String-Filter: +- `getTopMovies(limit)` → `SELECT ... WHERE contentType = 'MOVIE' GROUP BY showTitle ORDER BY totalMinutes DESC` +- `getTopShows(limit)` → ergänze `WHERE contentType != 'MOVIE'` damit Filme aus dem Serien-Ranking verschwinden +- `getMoviesCount()` → `SELECT COUNT(*) ... WHERE contentType = 'MOVIE'` +- `getSeriesEpisodesCount()` → `SELECT COUNT(*) ... WHERE contentType != 'MOVIE'` + +**1b. `WatchStats` korrekt befüllen** (`StatsRepository.kt`) — `combine` von 7 Flows (Vararg-Variante wie bereits vorhanden), echte Counts statt Heuristik. `moviesCount` und `seriesEpisodesCount` direkt aus der DB. + +**1c. Neue Datenmodelle** (`WatchStats.kt`) — `MovieSummary` (analog `ShowSummary`) + `topMovies: List`-Feld in `WatchStats`. + +**1d. Neuer Screen `MoviesScreen.kt`** — Clone von `SeriesStatsScreen.kt` mit `MovieRankItem` (zeigt Dauer statt Folgenanzahl, Movie-Icon statt "X Folgen"). Wiederverwendet `ProviderBadge`. + +**1e. Bottom-Navi erweitern** (`MainActivity.kt`) — Neuer `Screen.Movies("movies", "Filme", Icons.Default.Movie)` zwischen Series und Analytics. `NavHost` erhält die neue Route. + +--- + +## Fix 2: Web-Sync JS-Extraktion reparieren + +Root Cause: Der Button nimmt die **komplette HTML-Seite** (`document.documentElement.outerHTML`) und füttert sie dem CSV-Parser → 1 Müll-Eintrag mit ``-Tag-Text. + +Recherche zeigt: DOM-Klassen sind bei Netflix gehasht (brittel). Der robuste Weg ist die **Netflix Shakti-API** aus dem WebView heraus, da die Session-Cookies (`NetflixId`) automatisch vorliegen. + +**2a. Provider-spezifische JS-Extraktion** (`ProviderSyncScreen.kt` → `WebViewSyncSection`): +- **Netflix**: Injiziere JS, das `window.netflix.reactContext.models.serverDefs.data.BUILD_IDENTIFIER` + `authURL` liest, dann `fetch('/api/shakti/'+buildId+'/viewingactivity?pg='+n)` paginiert (Schleife bis `vhSize` erreicht) und ein sauberes JSON-Array von `{title, seriesTitle, episodeTitle, date, duration}` zurückgibt. +- **Prime Video**: Parse inline-JSON aus `script[type="text/template"]` (`props`→widgets→`content.content.titles[]`), fallback auf `[data-automation-id^="wh-date"]`. +- **Andere (Disney+/Apple)**: Greaceful-Degradation-Meldung "Web-Sync noch nicht unterstützt, bitte CSV verwenden". + +**2b. Toast-Feedback statt HTML-String**: Der Button zeigt Fortschritt ("Lade Seite N…"). Die extrahierten JSON-Daten gehen an `viewModel.importScrapedWebHistory(json, provider)` — bereits vorhanden, leitet an `NetflixParser.parseJsonApiPayload` weiter. + +**2c. `parseJsonApiPayload` robuster machen** (`NetflixParser.kt`): Akzeptiert jetzt sowohl das Netflix-`viewedItems`-Array **als auch** ein flaches Array (für den Prime-Fall). Field-Reading defensiv (`seriesTitle` für Episoden, `title` für Filme → korrekter `contentType`). Dauer aus dem `duration`-Feld (ms → Minuten) falls vorhanden, sonst Default. + +--- + +## Fix 3: Navigations-Bug (hängt nach "Verlauf Auslesen" fest) + +Symptom: Nach dem Sync-Screen kommt man nicht mehr aufs Dashboard. Ursachen: WebView wird nicht disposed, `update`-Lambda erzwingt Reloads, und das alte Parsing der kompletten HTML lief auf dem Main-Thread. + +**3a. WebView sauber lifecycle-gebunden** (`WebViewSyncSection`): +- `DisposableEffect` / `remember { ...; onDispose { webView.destroy() } }` statt freischwebendem Ref — verhindert, dass der WebView den Screen blockiert. +- Reload-Schleife in `update`-Lambda entfernen (vergleicht nur noch, ob sich der Provider geändert hat, nicht jeden Frame). + +**3b. Parsing off-main**: Die extrahierten JSON-Daten werden ohnehin über `viewModelScope.launch(Dispatchers.IO)` verarbeitet (bereits in `importScrapedWebHistory`), aber das JS-`evaluateJavascript`-Ergebnis wird vor dem Parsen als String geprüft — keine riesige HTML-Seite mehr, die den Main-Thread blockiert. + +**3c. Navi-Zurück-Verhalten**: `BackHandler` im Sync-Screen, der bei geladenem WebView zuerst `webView.goBack()` macht (falls History vorhanden) und erst dann `navController.popBackStack()`. So kommt man sicher vom Sync-Screen weg. + +--- + +## Validierung +- `./gradlew assembleDebug` (Java 21) baut fehlerfrei. +- `adb install -r` auf das S24 Ultra. +- Manueller Smoke-Test: Sample-Daten → Filme-Tab zeigt Filme, Serien-Tab nur Serien, Dashboard zählt korrekt. Navi nach Sync funktioniert. + +Alle drei Fixes sind voneinander unabhängig und werden in einem Build-Zyklus umgesetzt. Keine DB-Schema-Änderung (nur neue Queries auf bestehenden Spalten). \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 721b0e9..ea2109a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,13 +12,13 @@ android:label="BingeStats" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@android:style/Theme.Material.NoTitleBar" + android:theme="@style/Theme.BingeStats" android:usesCleartextTraffic="true"> - + + android:theme="@style/Theme.BingeStats"> diff --git a/app/src/main/java/com/bingestats/app/MainActivity.kt b/app/src/main/java/com/bingestats/app/MainActivity.kt index 6f89d0a..393cf5e 100644 --- a/app/src/main/java/com/bingestats/app/MainActivity.kt +++ b/app/src/main/java/com/bingestats/app/MainActivity.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Analytics import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Movie import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.filled.Tv @@ -29,6 +30,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import com.bingestats.app.ui.screens.AnalyticsScreen import com.bingestats.app.ui.screens.DashboardScreen +import com.bingestats.app.ui.screens.MoviesScreen import com.bingestats.app.ui.screens.ProviderSyncScreen import com.bingestats.app.ui.screens.SeriesStatsScreen import com.bingestats.app.ui.screens.SettingsScreen @@ -42,6 +44,7 @@ import com.bingestats.app.ui.viewmodel.MainViewModel sealed class Screen(val route: String, val title: String, val icon: ImageVector) { object Dashboard : Screen("dashboard", "Home", Icons.Default.Home) object Series : Screen("series", "Serien", Icons.Default.Tv) + object Movies : Screen("movies", "Filme", Icons.Default.Movie) object Analytics : Screen("analytics", "Analytics", Icons.Default.Analytics) object Sync : Screen("sync", "Sync", Icons.Default.Sync) object Settings : Screen("settings", "Settings", Icons.Default.Settings) @@ -52,7 +55,7 @@ class MainActivity : ComponentActivity() { private val viewModel: MainViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate() + super.onCreate(savedInstanceState) setContent { BingeStatsTheme { MainAppStructure(viewModel) @@ -70,6 +73,7 @@ fun MainAppStructure(viewModel: MainViewModel) { val screens = listOf( Screen.Dashboard, Screen.Series, + Screen.Movies, Screen.Analytics, Screen.Sync, Screen.Settings @@ -123,6 +127,9 @@ fun MainAppStructure(viewModel: MainViewModel) { composable(Screen.Series.route) { SeriesStatsScreen(viewModel = viewModel) } + composable(Screen.Movies.route) { + MoviesScreen(viewModel = viewModel) + } composable(Screen.Analytics.route) { AnalyticsScreen(viewModel = viewModel) } 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 822e8bc..0e115af 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 @@ -16,6 +16,15 @@ data class ShowAggregation( val posterPath: String? ) +data class MovieAggregation( + val showTitle: String, + val provider: String, + val watchCount: Int, + val totalMinutes: Int, + val lastWatchedDate: Long, + val posterPath: String? +) + data class MonthlyAggregation( val yearMonth: String, val totalMinutes: Int @@ -48,18 +57,35 @@ interface ViewingDao { @Query("SELECT COUNT(*) FROM viewing_items") fun getTotalItemsCount(): Flow - @Query("SELECT COUNT(DISTINCT showTitle) FROM viewing_items") + @Query("SELECT COUNT(DISTINCT showTitle) FROM viewing_items WHERE contentType != 'MOVIE'") fun getUniqueShowsCount(): Flow + @Query("SELECT COUNT(*) FROM viewing_items WHERE contentType = 'MOVIE'") + fun getMoviesCount(): Flow + + @Query("SELECT COUNT(*) FROM viewing_items WHERE contentType != 'MOVIE'") + fun getSeriesEpisodesCount(): Flow + @Query(""" SELECT showTitle, provider, COUNT(*) as episodeCount, SUM(durationMinutes) as totalMinutes, MAX(watchDate) as lastWatchedDate, MAX(posterPath) as posterPath FROM viewing_items + WHERE contentType != 'MOVIE' GROUP BY showTitle ORDER BY totalMinutes DESC LIMIT :limit """) fun getTopShows(limit: Int = 20): Flow> + @Query(""" + SELECT showTitle, provider, COUNT(*) as watchCount, SUM(durationMinutes) as totalMinutes, MAX(watchDate) as lastWatchedDate, MAX(posterPath) as posterPath + FROM viewing_items + WHERE contentType = 'MOVIE' + GROUP BY showTitle + ORDER BY totalMinutes DESC + LIMIT :limit + """) + fun getTopMovies(limit: Int = 20): Flow> + @Query(""" SELECT strftime('%Y-%m', watchDate / 1000, 'unixepoch') as yearMonth, SUM(durationMinutes) as totalMinutes FROM viewing_items diff --git a/app/src/main/java/com/bingestats/app/data/model/WatchStats.kt b/app/src/main/java/com/bingestats/app/data/model/WatchStats.kt index 2c8ffca..cf28681 100644 --- a/app/src/main/java/com/bingestats/app/data/model/WatchStats.kt +++ b/app/src/main/java/com/bingestats/app/data/model/WatchStats.kt @@ -12,6 +12,18 @@ data class ShowSummary( get() = totalMinutes / 60f } +data class MovieSummary( + val showTitle: String, + val provider: StreamingProvider, + val watchCount: Int, + val totalMinutes: Int, + val lastWatchedDate: Long, + val posterPath: String? = null +) { + val totalHours: Float + get() = totalMinutes / 60f +} + data class MonthlyStat( val yearMonth: String, // e.g. "2024-05" val monthLabel: String, // e.g. "Mai 2024" @@ -39,6 +51,7 @@ data class WatchStats( val moviesCount: Int = 0, val uniqueShowsCount: Int = 0, val topShows: List = emptyList(), + val topMovies: List = emptyList(), val monthlyStats: List = emptyList(), val dayOfWeekStats: List = emptyList(), val providerStats: List = emptyList() diff --git a/app/src/main/java/com/bingestats/app/data/parser/NetflixParser.kt b/app/src/main/java/com/bingestats/app/data/parser/NetflixParser.kt index 6b318c1..46b189a 100644 --- a/app/src/main/java/com/bingestats/app/data/parser/NetflixParser.kt +++ b/app/src/main/java/com/bingestats/app/data/parser/NetflixParser.kt @@ -55,34 +55,80 @@ object NetflixParser { } /** - * Parse Netflix JSON API viewing activity payload (intercepted during WebView browsing). + * Parse JSON viewing activity payload (intercepted during WebView browsing). + * Accepts three shapes: + * 1. The Netflix Shakti API object: { "viewedItems": [ ... ] } + * 2. A flat JSON array: [ { ... }, { ... } ] + * 3. Any object exposing a "viewedItems" or "items" array. + * Each entry may use Netflix fields (title/seriesTitle/episodeTitle/date/duration) + * or the simpler Prime-style fields (title/episodeTitle/time/duration). + * + * @param provider the provider id stamped onto every parsed item. */ - fun parseJsonApiPayload(jsonText: String, profileName: String = "Main Profile"): List { + fun parseJsonApiPayload( + jsonText: String, + profileName: String = "Main Profile", + provider: String = StreamingProvider.NETFLIX.id + ): List { val items = mutableListOf() try { val gson = Gson() - val jsonObject = gson.fromJson(jsonText, JsonObject::class.java) - val viewingData = jsonObject.getAsJsonArray("viewedItems") ?: return items + val jsonElement = gson.fromJson(jsonText, com.google.gson.JsonElement::class.java) ?: return items + + val viewingData: JsonArray = when { + jsonElement.isJsonArray -> jsonElement.asJsonArray + jsonElement.isJsonObject -> { + val obj = jsonElement.asJsonObject + obj.getAsJsonArray("viewedItems") + ?: obj.getAsJsonArray("items") + ?: obj.getAsJsonArray("history") + ?: return items + } + else -> return items + } for (i in 0 until viewingData.size()) { - val item = viewingData[i].asJsonObject - val title = item.get("title")?.asString ?: item.get("seriesTitle")?.asString ?: continue - val dateEpoch = item.get("date")?.asLong ?: System.currentTimeMillis() - - val parsedTitle = TitleParser.parseNetflixTitle(title) - val (timestamp, dateFormatted) = TitleParser.parseDateToEpoch(dateEpoch.toString()) + val item = viewingData[i].takeIf { it.isJsonObject }?.asJsonObject ?: continue + + // Series episodes carry seriesTitle + episodeTitle; movies carry only title. + val seriesTitle = item.get("seriesTitle")?.takeIf { !it.isJsonNull }?.asString + val episodeTitle = item.get("episodeTitle")?.takeIf { !it.isJsonNull }?.asString + val title = item.get("title")?.takeIf { !it.isJsonNull }?.asString + ?: seriesTitle + ?: continue + + // Build a raw title string the TitleParser understands. + val rawTitle = when { + seriesTitle != null && episodeTitle != null -> "$seriesTitle: $episodeTitle" + else -> title + } + + val parsedTitle = TitleParser.parseNetflixTitle(rawTitle) + + // Date: "date" (ms epoch) for Netflix, "time" (ISO string or epoch) for Prime. + val dateEpoch = when { + item.has("date") && !item.get("date").isJsonNull -> item.get("date").asLong + item.has("time") && !item.get("time").isJsonNull -> parseTimeToEpoch(item.get("time").asString) + else -> System.currentTimeMillis() + } + val (_, dateFormatted) = TitleParser.parseDateToEpoch(dateEpoch.toString()) + + // Duration in minutes: Netflix/Prime provide it in ms. + val durationMs = item.get("duration")?.takeIf { !it.isJsonNull }?.asLong + val durationMinutes = (durationMs?.let { (it / 60000L).toInt() }?.takeIf { d -> d > 0 }) + ?: if (parsedTitle.contentType == com.bingestats.app.data.model.ContentType.SERIES) 45 else 105 items.add( ViewingItem( - provider = StreamingProvider.NETFLIX.id, - rawTitle = title, + provider = provider, + rawTitle = rawTitle, showTitle = parsedTitle.showTitle, seasonTitle = parsedTitle.seasonTitle, episodeTitle = parsedTitle.episodeTitle, contentType = parsedTitle.contentType, watchDate = dateEpoch, dateFormatted = dateFormatted, - durationMinutes = if (parsedTitle.contentType == com.bingestats.app.data.model.ContentType.SERIES) 45 else 105, + durationMinutes = durationMinutes, profileName = profileName ) ) @@ -93,6 +139,38 @@ object NetflixParser { return items } + /** + * Best-effort parse of a time value to epoch millis. + * Accepts a numeric epoch (seconds or millis) or an ISO-8601 datetime string. + */ + private fun parseTimeToEpoch(timeStr: String): Long { + val trimmed = timeStr.trim() + // Pure number → epoch seconds (typical) or millis. + return trimmed.toLongOrNull()?.let { num -> + // Heuristic: values < year 3000 in seconds (~325 billion) treat as seconds. + if (num < 325_000_000_000L) num * 1000L else num + } ?: run { + // ISO-8601 string, e.g. "2025-07-15 21:04" + val formats = listOf( + "yyyy-MM-dd'T'HH:mm:ss", + "yyyy-MM-dd'T'HH:mm:ssXXX", + "yyyy-MM-dd HH:mm:ss", + "yyyy-MM-dd HH:mm", + "yyyy-MM-dd" + ) + for (format in formats) { + try { + val sdf = java.text.SimpleDateFormat(format, java.util.Locale.getDefault()) + sdf.timeZone = java.util.TimeZone.getDefault() + return sdf.parse(trimmed)?.time ?: continue + } catch (_: Exception) { + // try next format + } + } + System.currentTimeMillis() + } + } + private fun parseCsvLine(line: String): List { val result = mutableListOf() var inQuotes = false 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 new file mode 100644 index 0000000..cb02b32 --- /dev/null +++ b/app/src/main/java/com/bingestats/app/data/parser/WebSyncScripts.kt @@ -0,0 +1,123 @@ +package com.bingestats.app.data.parser + +/** + * Provider-specific JavaScript snippets injected into the in-app WebView to extract + * viewing history as a clean JSON string. The result is fed to + * [NetflixParser.parseJsonApiPayload]. + * + * Why JS extraction instead of parsing the rendered DOM: + * - Netflix's page is a React SPA with hashed, unstable CSS class names. + * The reliable source is the internal Shakti API, reachable in-page because the + * WebView session already holds the NetflixId cookies. + * - Prime Video embeds history as inline JSON in