feat: add movies stats screen and improve provider sync
- Add MoviesScreen and corresponding navigation - Implement movie data aggregation in ViewingDao and repository - Enhance NetflixParser to support multiple JSON payload shapes - Apply custom app theme and update launcher icons - Add back navigation handling to ProviderSyncScreen
This commit is contained in:
parent
dce3b2258b
commit
f55ff411c6
20 changed files with 1086 additions and 143 deletions
|
|
@ -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<MovieSummary>`-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 `<meta>`-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).
|
||||
|
|
@ -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">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@android:style/Theme.Material.NoTitleBar">
|
||||
android:theme="@style/Theme.BingeStats">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Int>
|
||||
|
||||
@Query("SELECT COUNT(DISTINCT showTitle) FROM viewing_items")
|
||||
@Query("SELECT COUNT(DISTINCT showTitle) FROM viewing_items WHERE contentType != 'MOVIE'")
|
||||
fun getUniqueShowsCount(): Flow<Int>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM viewing_items WHERE contentType = 'MOVIE'")
|
||||
fun getMoviesCount(): Flow<Int>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM viewing_items WHERE contentType != 'MOVIE'")
|
||||
fun getSeriesEpisodesCount(): Flow<Int>
|
||||
|
||||
@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<List<ShowAggregation>>
|
||||
|
||||
@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<List<MovieAggregation>>
|
||||
|
||||
@Query("""
|
||||
SELECT strftime('%Y-%m', watchDate / 1000, 'unixepoch') as yearMonth, SUM(durationMinutes) as totalMinutes
|
||||
FROM viewing_items
|
||||
|
|
|
|||
|
|
@ -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<ShowSummary> = emptyList(),
|
||||
val topMovies: List<MovieSummary> = emptyList(),
|
||||
val monthlyStats: List<MonthlyStat> = emptyList(),
|
||||
val dayOfWeekStats: List<DayOfWeekStat> = emptyList(),
|
||||
val providerStats: List<ProviderStat> = emptyList()
|
||||
|
|
|
|||
|
|
@ -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<ViewingItem> {
|
||||
fun parseJsonApiPayload(
|
||||
jsonText: String,
|
||||
profileName: String = "Main Profile",
|
||||
provider: String = StreamingProvider.NETFLIX.id
|
||||
): List<ViewingItem> {
|
||||
val items = mutableListOf<ViewingItem>()
|
||||
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 item = viewingData[i].takeIf { it.isJsonObject }?.asJsonObject ?: continue
|
||||
|
||||
val parsedTitle = TitleParser.parseNetflixTitle(title)
|
||||
val (timestamp, dateFormatted) = TitleParser.parseDateToEpoch(dateEpoch.toString())
|
||||
// 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<String> {
|
||||
val result = mutableListOf<String>()
|
||||
var inQuotes = false
|
||||
|
|
|
|||
|
|
@ -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 <script type="text/template"> tags
|
||||
* and lazy-loads more via fetch.
|
||||
*/
|
||||
object WebSyncScripts {
|
||||
|
||||
/**
|
||||
* Netflix: read BUILD_IDENTIFIER + authURL from the page's global context, then
|
||||
* page through /api/shakti/<build>/viewingactivity collecting viewedItems.
|
||||
* Returns a JSON array string (or "[]" on failure).
|
||||
*/
|
||||
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 = "";
|
||||
try {
|
||||
authURL = ctx.models.memberContext.data.userInfo.data.authURL
|
||||
|| ctx.models.userInfo.data.authURL || "";
|
||||
} catch (e) {}
|
||||
|
||||
var collected = [];
|
||||
var pageSize = 100;
|
||||
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 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.
|
||||
if (items.length === 0 || (total > 0 && collected.length >= total) || pg > 200) {
|
||||
resolve(JSON.stringify(collected));
|
||||
} else {
|
||||
loadPage(pg);
|
||||
}
|
||||
})
|
||||
.catch(function() { resolve(JSON.stringify(collected)); });
|
||||
}
|
||||
loadPage(0);
|
||||
});
|
||||
} catch (e) {
|
||||
return JSON.stringify([]);
|
||||
}
|
||||
})()
|
||||
""".trimIndent()
|
||||
|
||||
/**
|
||||
* Prime Video: parse the inline JSON embedded in <script type="text/template"> tags.
|
||||
* The structure is props -> widgets[] -> content.content.titles[] -> date sections,
|
||||
* each section has .titles[] with {title:{text}, time, children:[...] }.
|
||||
* Returns a JSON array string of {title, episodeTitle, time, duration}.
|
||||
*/
|
||||
val prime: String = """
|
||||
(function() {
|
||||
try {
|
||||
var results = [];
|
||||
var scripts = document.querySelectorAll('script[type="text/template"]');
|
||||
for (var i = 0; i < scripts.length; i++) {
|
||||
var raw = scripts[i].textContent.trim();
|
||||
if (!raw || raw.charAt(0) !== '{') continue;
|
||||
var obj;
|
||||
try { obj = JSON.parse(raw); } catch (e) { continue; }
|
||||
var widgets = (obj && obj.props) ? obj.props : [];
|
||||
if (!Array.isArray(widgets)) widgets = [widgets];
|
||||
widgets.forEach(function(w) {
|
||||
try {
|
||||
var titles = w && w.content && w.content.content && w.content.content.titles;
|
||||
if (!Array.isArray(titles)) return;
|
||||
titles.forEach(function(section) {
|
||||
var items = section.titles || [];
|
||||
items.forEach(function(t) {
|
||||
if (!t || !t.title || !t.title.text) return;
|
||||
results.push({
|
||||
title: t.title.text,
|
||||
time: t.time || null,
|
||||
duration: t.duration || null
|
||||
});
|
||||
(t.children || []).forEach(function(c) {
|
||||
if (!c || !c.title || !c.title.text) return;
|
||||
results.push({
|
||||
seriesTitle: t.title.text,
|
||||
episodeTitle: c.title.text,
|
||||
time: c.time || t.time || null,
|
||||
duration: c.duration || null
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
return JSON.stringify(results);
|
||||
} catch (e) {
|
||||
return JSON.stringify([]);
|
||||
}
|
||||
})()
|
||||
""".trimIndent()
|
||||
}
|
||||
|
|
@ -1,7 +1,12 @@
|
|||
package com.bingestats.app.data.repository
|
||||
|
||||
import com.bingestats.app.data.db.MovieAggregation
|
||||
import com.bingestats.app.data.db.MonthlyAggregation
|
||||
import com.bingestats.app.data.db.ProviderAggregation
|
||||
import com.bingestats.app.data.db.ShowAggregation
|
||||
import com.bingestats.app.data.db.ViewingDatabase
|
||||
import com.bingestats.app.data.model.DayOfWeekStat
|
||||
import com.bingestats.app.data.model.MovieSummary
|
||||
import com.bingestats.app.data.model.MonthlyStat
|
||||
import com.bingestats.app.data.model.ProviderStat
|
||||
import com.bingestats.app.data.model.ShowSummary
|
||||
|
|
@ -30,10 +35,26 @@ class StatsRepository(private val db: ViewingDatabase) {
|
|||
viewingDao.getTotalWatchTimeMinutes(),
|
||||
viewingDao.getTotalItemsCount(),
|
||||
viewingDao.getUniqueShowsCount(),
|
||||
viewingDao.getMoviesCount(),
|
||||
viewingDao.getSeriesEpisodesCount(),
|
||||
viewingDao.getTopShows(20),
|
||||
viewingDao.getTopMovies(20),
|
||||
viewingDao.getMonthlyStats(),
|
||||
viewingDao.getProviderStats()
|
||||
) { totalMinutes, itemCount, uniqueShows, topShowsAgg, monthlyAgg, providerAgg ->
|
||||
) { values ->
|
||||
val totalMinutes = values[0] as Long?
|
||||
val itemCount = values[1] as Int
|
||||
val uniqueShows = values[2] as Int
|
||||
val moviesCount = values[3] as Int
|
||||
val seriesEpisodesCount = values[4] as Int
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val topShowsAgg = values[5] as List<ShowAggregation>
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val topMoviesAgg = values[6] as List<MovieAggregation>
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val monthlyAgg = values[7] as List<MonthlyAggregation>
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val providerAgg = values[8] as List<ProviderAggregation>
|
||||
|
||||
val topShows = topShowsAgg.map { agg ->
|
||||
ShowSummary(
|
||||
|
|
@ -46,6 +67,17 @@ class StatsRepository(private val db: ViewingDatabase) {
|
|||
)
|
||||
}
|
||||
|
||||
val topMovies = topMoviesAgg.map { agg ->
|
||||
MovieSummary(
|
||||
showTitle = agg.showTitle,
|
||||
provider = StreamingProvider.fromId(agg.provider),
|
||||
watchCount = agg.watchCount,
|
||||
totalMinutes = agg.totalMinutes,
|
||||
lastWatchedDate = agg.lastWatchedDate,
|
||||
posterPath = agg.posterPath
|
||||
)
|
||||
}
|
||||
|
||||
val monthlyStats = monthlyAgg.map { agg ->
|
||||
MonthlyStat(
|
||||
yearMonth = agg.yearMonth,
|
||||
|
|
@ -68,10 +100,11 @@ class StatsRepository(private val db: ViewingDatabase) {
|
|||
WatchStats(
|
||||
totalWatchTimeMinutes = totalMinutes ?: 0L,
|
||||
totalItemsCount = itemCount,
|
||||
seriesEpisodesCount = topShows.sumOf { it.episodeCount },
|
||||
moviesCount = (itemCount - topShows.sumOf { it.episodeCount }).coerceAtLeast(0),
|
||||
seriesEpisodesCount = seriesEpisodesCount,
|
||||
moviesCount = moviesCount,
|
||||
uniqueShowsCount = uniqueShows,
|
||||
topShows = topShows,
|
||||
topMovies = topMovies,
|
||||
monthlyStats = monthlyStats,
|
||||
providerStats = providerStats
|
||||
)
|
||||
|
|
|
|||
172
app/src/main/java/com/bingestats/app/ui/screens/MoviesScreen.kt
Normal file
172
app/src/main/java/com/bingestats/app/ui/screens/MoviesScreen.kt
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
package com.bingestats.app.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Movie
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bingestats.app.data.model.MovieSummary
|
||||
import com.bingestats.app.ui.components.ProviderBadge
|
||||
import com.bingestats.app.ui.theme.CardBackgroundDark
|
||||
import com.bingestats.app.ui.theme.GlassCardBorder
|
||||
import com.bingestats.app.ui.theme.NetflixRed
|
||||
import com.bingestats.app.ui.theme.ObsidianBlack
|
||||
import com.bingestats.app.ui.theme.TextMuted
|
||||
import com.bingestats.app.ui.theme.TextSecondary
|
||||
import com.bingestats.app.ui.viewmodel.MainViewModel
|
||||
|
||||
@Composable
|
||||
fun MoviesScreen(viewModel: MainViewModel) {
|
||||
val stats by viewModel.watchStats.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(ObsidianBlack)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Top Filme Ranking",
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = "Deine meistgesehenen Filme nach Gesamtzeit",
|
||||
fontSize = 13.sp,
|
||||
color = TextMuted
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
if (stats.topMovies.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Noch keine Filme erfasst.\nImportiere deinen Verlauf unter Sync.",
|
||||
color = TextMuted,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
itemsIndexed(stats.topMovies) { index, movie ->
|
||||
MovieRankItem(rank = index + 1, movie = movie)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MovieRankItem(
|
||||
rank: Int,
|
||||
movie: MovieSummary
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.border(1.dp, GlassCardBorder, RoundedCornerShape(12.dp)),
|
||||
colors = CardDefaults.cardColors(containerColor = CardBackgroundDark),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Rank Number
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(36.dp)
|
||||
.height(36.dp)
|
||||
.background(
|
||||
if (rank <= 3) NetflixRed.copy(alpha = 0.2f) else Color.Transparent,
|
||||
RoundedCornerShape(8.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "#$rank",
|
||||
fontWeight = FontWeight.Black,
|
||||
fontSize = 16.sp,
|
||||
color = if (rank <= 3) NetflixRed else TextMuted
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = movie.showTitle,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ProviderBadge(provider = movie.provider)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Default.Movie,
|
||||
contentDescription = null,
|
||||
tint = TextSecondary,
|
||||
modifier = Modifier.width(16.dp).height(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = if (movie.watchCount == 1) "1x gesehen" else "${movie.watchCount}x gesehen",
|
||||
fontSize = 12.sp,
|
||||
color = TextSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = "${movie.totalMinutes / 60}h ${movie.totalMinutes % 60}m",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = NetflixRed
|
||||
)
|
||||
Text(
|
||||
text = "${movie.totalMinutes} Minuten",
|
||||
fontSize = 11.sp,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import android.webkit.CookieManager
|
|||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
|
|
@ -26,7 +27,6 @@ import androidx.compose.material.icons.Icons
|
|||
import androidx.compose.material.icons.filled.CloudUpload
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
|
|
@ -39,6 +39,7 @@ import androidx.compose.material3.TabRowDefaults
|
|||
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
|
|
@ -54,6 +55,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.bingestats.app.data.model.StreamingProvider
|
||||
import com.bingestats.app.data.parser.WebSyncScripts
|
||||
import com.bingestats.app.ui.components.ProviderBadge
|
||||
import com.bingestats.app.ui.theme.CardBackgroundDark
|
||||
import com.bingestats.app.ui.theme.GlassCardBorder
|
||||
|
|
@ -232,9 +234,33 @@ fun WebViewSyncSection(
|
|||
provider: StreamingProvider,
|
||||
onExtractHistory: (String) -> Unit
|
||||
) {
|
||||
var webViewRef by remember { mutableStateOf<WebView?>(null) }
|
||||
var currentUrl by remember { mutableStateOf(provider.defaultWebUrl) }
|
||||
var isLoadingWeb by remember { mutableStateOf(false) }
|
||||
// webSyncSupported == false -> show a graceful hint instead of a broken webview.
|
||||
val webSyncSupported = provider == StreamingProvider.NETFLIX || provider == StreamingProvider.AMAZON_PRIME
|
||||
|
||||
var currentUrl by remember(provider) { mutableStateOf(provider.viewingHistoryUrl) }
|
||||
var isLoadingWeb by remember { mutableStateOf(true) }
|
||||
var isExtracting by remember { mutableStateOf(false) }
|
||||
var statusMessage by remember { mutableStateOf<String?>(null) }
|
||||
val webViewRef = remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
// Clean up the WebView when the composable leaves the composition. Without this the
|
||||
// WebView can keep running JS/holding the screen, which previously blocked navigation.
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
webViewRef.value?.apply {
|
||||
stopLoading()
|
||||
removeAllViews()
|
||||
destroy()
|
||||
}
|
||||
webViewRef.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Route the system back button: if the WebView has history, go back inside it;
|
||||
// otherwise let the default (NavController pop) take over.
|
||||
BackHandler(enabled = webViewRef.value?.canGoBack() == true) {
|
||||
webViewRef.value?.goBack()
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Card(
|
||||
|
|
@ -243,13 +269,9 @@ fun WebViewSyncSection(
|
|||
.border(1.dp, GlassCardBorder, RoundedCornerShape(12.dp)),
|
||||
colors = CardDefaults.cardColors(containerColor = CardBackgroundDark)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.Lock, contentDescription = null, tint = TextMuted, modifier = Modifier.padding(4.dp))
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Lock, contentDescription = null, tint = TextMuted, modifier = Modifier.padding(end = 6.dp))
|
||||
Text(
|
||||
text = currentUrl,
|
||||
fontSize = 11.sp,
|
||||
|
|
@ -258,30 +280,65 @@ fun WebViewSyncSection(
|
|||
maxLines = 1
|
||||
)
|
||||
Button(
|
||||
enabled = webSyncSupported && !isExtracting,
|
||||
onClick = {
|
||||
webViewRef?.evaluateJavascript(
|
||||
"(function() { return document.documentElement.outerHTML; })();"
|
||||
) { html ->
|
||||
if (!html.isNullOrBlank()) {
|
||||
onExtractHistory(html)
|
||||
val webview = webViewRef.value ?: return@Button
|
||||
isExtracting = true
|
||||
statusMessage = "Lese Verlauf aus…"
|
||||
runExtraction(webview, provider) { result ->
|
||||
isExtracting = false
|
||||
statusMessage = null
|
||||
if (!result.isNullOrBlank() && result != "[]" && result != "null") {
|
||||
onExtractHistory(result)
|
||||
} else {
|
||||
statusMessage = "Keine Daten gefunden. Stelle sicher, dass du eingeloggt bist und sich der Verlauf geladen hat."
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = provider.primaryColor)
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = provider.primaryColor,
|
||||
disabledContainerColor = provider.primaryColor.copy(alpha = 0.4f)
|
||||
)
|
||||
) {
|
||||
Text("Verlauf Auslesen", fontSize = 12.sp, color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
statusMessage?.let { msg ->
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(text = msg, fontSize = 11.sp, color = TextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
if (!webSyncSupported) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(CardBackgroundDark, RoundedCornerShape(12.dp))
|
||||
.border(1.dp, GlassCardBorder, RoundedCornerShape(12.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Web-Sync für ${provider.displayName} wird noch nicht unterstützt.\nBitte nutze den CSV-Import.",
|
||||
color = TextMuted,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(24.dp)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
// Allow third-party cookies so login flows survive the redirect chain.
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
|
||||
settings.userAgentString = "Mozilla/5.0 (Linux; Android 10; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
|
|
@ -289,6 +346,12 @@ fun WebViewSyncSection(
|
|||
super.onPageFinished(view, url)
|
||||
currentUrl = url ?: ""
|
||||
isLoadingWeb = false
|
||||
// Install the JS<->Kotlin bridge: the extraction promise resolves
|
||||
// into window.__bingeResult, which runExtraction() then polls.
|
||||
view?.evaluateJavascript(
|
||||
"window.__bingeResult = null; window.__bingeExtracted = function(v) { window.__bingeResult = (typeof v === 'string') ? v : JSON.stringify(v); };",
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||
|
|
@ -297,20 +360,17 @@ fun WebViewSyncSection(
|
|||
}
|
||||
}
|
||||
loadUrl(provider.viewingHistoryUrl)
|
||||
webViewRef = this
|
||||
}
|
||||
},
|
||||
update = { webView ->
|
||||
if (webView.url != provider.viewingHistoryUrl && !currentUrl.contains(provider.id)) {
|
||||
webView.loadUrl(provider.viewingHistoryUrl)
|
||||
webViewRef.value = this
|
||||
}
|
||||
},
|
||||
// Removed the old aggressive reload: only react to provider changes, not every recomposition.
|
||||
update = { _ -> },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.border(1.dp, GlassCardBorder, RoundedCornerShape(8.dp))
|
||||
)
|
||||
|
||||
if (isLoadingWeb) {
|
||||
if (isLoadingWeb || isExtracting) {
|
||||
CircularProgressIndicator(
|
||||
color = provider.primaryColor,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
|
|
@ -320,6 +380,59 @@ fun WebViewSyncSection(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects the provider-specific extraction script into the WebView. The Netflix script
|
||||
* returns a Promise, so we wrap evaluation in a `.then()` and read the resolved JSON.
|
||||
*/
|
||||
private fun runExtraction(
|
||||
webView: WebView,
|
||||
provider: StreamingProvider,
|
||||
onResult: (String?) -> Unit
|
||||
) {
|
||||
val script = when (provider) {
|
||||
StreamingProvider.NETFLIX -> WebSyncScripts.netflix
|
||||
StreamingProvider.AMAZON_PRIME -> WebSyncScripts.prime
|
||||
else -> { onResult(null); return }
|
||||
}
|
||||
|
||||
val wrapped = """
|
||||
(function() {
|
||||
var r = (function() { $script })();
|
||||
if (r && typeof r.then === 'function') {
|
||||
r.then(function(v) { __bingeExtracted(v); }).catch(function() { __bingeExtracted('[]'); });
|
||||
} else {
|
||||
__bingeExtracted(r);
|
||||
}
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
// Bridge from JS back to Kotlin: store the result in a JS variable we then retrieve.
|
||||
webView.evaluateJavascript(wrapped, null)
|
||||
// Small delay so the async fetch (Netflix pagination) has time to complete before polling.
|
||||
pollExtraction(webView, attempts = 0, onResult = onResult)
|
||||
}
|
||||
|
||||
private fun pollExtraction(webView: WebView, attempts: Int, onResult: (String?) -> Unit) {
|
||||
// The injected promise calls __bingeExtracted(v); we expose it via addJavascriptInterface-like
|
||||
// retrieval: evaluate a getter. Cap attempts so we never spin forever.
|
||||
val maxAttempts = 20
|
||||
if (attempts > maxAttempts) {
|
||||
onResult(null)
|
||||
return
|
||||
}
|
||||
webView.postDelayed({
|
||||
webView.evaluateJavascript(
|
||||
"(window.__bingeResult === undefined) ? null : window.__bingeResult;"
|
||||
) { raw ->
|
||||
if (raw == null || raw == "null" || raw == "undefined") {
|
||||
pollExtraction(webView, attempts + 1, onResult)
|
||||
} else {
|
||||
onResult(raw)
|
||||
}
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CsvImportSection(
|
||||
provider: StreamingProvider,
|
||||
|
|
|
|||
|
|
@ -88,10 +88,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||
viewModelScope.launch {
|
||||
_uiState.value = UiState.Loading
|
||||
try {
|
||||
val items = if (jsonOrCsvText.trim().startsWith("{")) {
|
||||
NetflixParser.parseJsonApiPayload(jsonOrCsvText)
|
||||
val trimmed = jsonOrCsvText.trim()
|
||||
// The WebView extraction returns a JSON array (or object); older flows may
|
||||
// still pass CSV. Detect by the leading character.
|
||||
val items = if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
||||
when (provider) {
|
||||
StreamingProvider.NETFLIX,
|
||||
StreamingProvider.AMAZON_PRIME ->
|
||||
NetflixParser.parseJsonApiPayload(trimmed, provider = provider.id)
|
||||
else -> NetflixParser.parseJsonApiPayload(trimmed, provider = provider.id)
|
||||
}
|
||||
} else {
|
||||
NetflixParser.parseCsv(jsonOrCsvText)
|
||||
when (provider) {
|
||||
StreamingProvider.AMAZON_PRIME -> PrimeVideoParser.parseCsv(trimmed)
|
||||
else -> NetflixParser.parseCsv(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
if (items.isEmpty()) {
|
||||
|
|
|
|||
10
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
10
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#141414"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
</vector>
|
||||
11
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
11
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Rounded bar chart background mark in Netflix red -->
|
||||
<path
|
||||
android:fillColor="#E50914"
|
||||
android:pathData="M34,64h8v16h-8zM46,52h8v28h-8zM58,40h8v40h-8zM70,28h8v52h-8z" />
|
||||
</vector>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
3
app/src/main/res/values/strings.xml
Normal file
3
app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<string name="app_name">BingeStats</string>
|
||||
</resources>
|
||||
4
app/src/main/res/values/themes.xml
Normal file
4
app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<resources>
|
||||
<!-- Base theme for the Compose app: dark, no action bar -->
|
||||
<style name="Theme.BingeStats" parent="android:Theme.Material.NoActionBar" />
|
||||
</resources>
|
||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
|
|
@ -1,6 +1,6 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
|
|
|||
252
gradlew
vendored
Executable file
252
gradlew
vendored
Executable file
|
|
@ -0,0 +1,252 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||
' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
46
gradlew.bat
vendored
46
gradlew.bat
vendored
|
|
@ -13,6 +13,8 @@
|
|||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
|
|
@ -26,6 +28,14 @@ if "%OS%"=="Windows_NT" setlocal
|
|||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
|
|
@ -33,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome
|
|||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%"=="0" goto execute
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation.
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
|
|
@ -49,18 +59,18 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
|||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation.
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%DIRNAME%\gradle\wrapper\gradle-wrapper.jar
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
|
|
@ -68,13 +78,17 @@ set CLASSPATH=%DIRNAME%\gradle\wrapper\gradle-wrapper.jar
|
|||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe / c's_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue