Add CategoryContent entity, DAO, and CategoryCacheRepository

This commit is contained in:
tvmon-dev
2026-04-16 08:17:28 +09:00
parent 9a85738ec3
commit f65e3b9f35
8 changed files with 273 additions and 212 deletions

View File

@@ -54,12 +54,13 @@
android:screenOrientation="landscape"
android:theme="@style/Theme.Tvmon.Search" />
<activity
android:name=".ui.search.SearchActivity"
android:exported="true"
android:label="@string/search_title"
android:screenOrientation="landscape"
android:theme="@style/Theme.Leanback">
<activity
android:name=".ui.search.SearchActivity"
android:exported="true"
android:label="@string/search_title"
android:screenOrientation="landscape"
android:theme="@style/Theme.Tvmon.Search"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
</intent-filter>

View File

@@ -3,9 +3,11 @@ package com.example.tvmon.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
import com.example.tvmon.data.local.dao.BookmarkDao
import com.example.tvmon.data.local.dao.CategoryContentDao
import com.example.tvmon.data.local.dao.SearchHistoryDao
import com.example.tvmon.data.local.dao.WatchHistoryDao
import com.example.tvmon.data.local.entity.Bookmark
import com.example.tvmon.data.local.entity.CategoryContent
import com.example.tvmon.data.local.entity.SearchHistory
import com.example.tvmon.data.local.entity.WatchHistory
@@ -13,13 +15,15 @@ import com.example.tvmon.data.local.entity.WatchHistory
entities = [
WatchHistory::class,
Bookmark::class,
SearchHistory::class
SearchHistory::class,
CategoryContent::class
],
version = 1,
version = 2,
exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
abstract fun watchHistoryDao(): WatchHistoryDao
abstract fun bookmarkDao(): BookmarkDao
abstract fun searchHistoryDao(): SearchHistoryDao
abstract fun categoryContentDao(): CategoryContentDao
}

View File

@@ -0,0 +1,37 @@
package com.example.tvmon.data.local.dao
import androidx.room.*
import com.example.tvmon.data.local.entity.CategoryContent
@Dao
interface CategoryContentDao {
@Query("SELECT * FROM category_content WHERE categoryKey = :categoryKey ORDER BY cachedAt ASC")
suspend fun getByCategory(categoryKey: String): List<CategoryContent>
@Query("SELECT * FROM category_content WHERE categoryKey = :categoryKey AND pageNumber <= :page ORDER BY cachedAt ASC")
suspend fun getByCategoryUntilPage(categoryKey: String, page: Int): List<CategoryContent>
@Query("SELECT MAX(pageNumber) FROM category_content WHERE categoryKey = :categoryKey")
suspend fun getMaxPage(categoryKey: String): Int?
@Query("SELECT MAX(cachedAt) FROM category_content WHERE categoryKey = :categoryKey")
suspend fun getLastCacheTime(categoryKey: String): Long?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(contents: List<CategoryContent>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOne(content: CategoryContent)
@Query("DELETE FROM category_content WHERE categoryKey = :categoryKey")
suspend fun deleteByCategory(categoryKey: String)
@Query("DELETE FROM category_content WHERE categoryKey = :categoryKey AND pageNumber >= :fromPage")
suspend fun deleteFromPage(categoryKey: String, fromPage: Int)
@Query("SELECT COUNT(*) FROM category_content WHERE categoryKey = :categoryKey")
suspend fun getCount(categoryKey: String): Int
@Query("DELETE FROM category_content")
suspend fun deleteAll()
}

View File

@@ -0,0 +1,16 @@
package com.example.tvmon.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "category_content")
data class CategoryContent(
@PrimaryKey
val contentUrl: String,
val categoryKey: String,
val contentId: String,
val title: String,
val thumbnail: String,
val pageNumber: Int = 1,
val cachedAt: Long = System.currentTimeMillis()
)

View File

@@ -0,0 +1,132 @@
package com.example.tvmon.data.repository
import android.util.Log
import com.example.tvmon.data.local.dao.CategoryContentDao
import com.example.tvmon.data.local.entity.CategoryContent
import com.example.tvmon.data.model.Category
import com.example.tvmon.data.model.Content
import com.example.tvmon.data.model.Pagination
import com.example.tvmon.data.scraper.TvmonScraper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class CategoryCacheRepository(
private val categoryContentDao: CategoryContentDao
) {
companion object {
private const val TAG = "CategoryCacheRepo"
private const val CACHE_TTL_MS = 30 * 60 * 1000L
}
private val loadedPages = mutableMapOf<String, Int>()
suspend fun getCategoryWithCache(categoryKey: String, page: Int = 1): CategoryResultCache {
return withContext(Dispatchers.IO) {
val cachedItems = categoryContentDao.getByCategoryUntilPage(categoryKey, page)
val lastCacheTime = categoryContentDao.getLastCacheTime(categoryKey)
val cachedMaxPage = categoryContentDao.getMaxPage(categoryKey) ?: 1
val now = System.currentTimeMillis()
val isCacheValid = lastCacheTime != null && (now - lastCacheTime) < CACHE_TTL_MS
Log.w(TAG, "getCategoryWithCache: $categoryKey page=$page, cached=${cachedItems.size}, cacheValid=$isCacheValid, cachedMaxPage=$cachedMaxPage")
if (cachedItems.isNotEmpty() && isCacheValid && page <= cachedMaxPage) {
val contents = cachedItems.map { it.toContent() }
CategoryResultCache(
success = true,
items = contents,
page = page,
pagination = Pagination(page, cachedMaxPage),
fromCache = true
)
} else {
val result = scraper.getCategory(categoryKey, page)
if (result.success && result.items.isNotEmpty()) {
val entities = result.items.map { it.toEntity(categoryKey, page) }
categoryContentDao.insert(entities)
val newMaxPage = result.pagination.maxPage
if (page == 1) {
loadedPages[categoryKey] = 1
}
Log.w(TAG, "getCategoryWithCache: fetched from network, saved ${entities.size} items, maxPage=$newMaxPage")
}
CategoryResultCache(
success = result.success,
items = result.items,
page = result.page,
pagination = result.pagination,
fromCache = false
)
}
}
}
suspend fun loadMoreForCategory(categoryKey: String): List<Content> {
return withContext(Dispatchers.IO) {
val currentPage = loadedPages[categoryKey] ?: 1
val nextPage = currentPage + 1
Log.w(TAG, "loadMoreForCategory: $categoryKey currentPage=$currentPage, nextPage=$nextPage")
val result = scraper.getCategory(categoryKey, nextPage)
if (result.success && result.items.isNotEmpty()) {
val entities = result.items.map { it.toEntity(categoryKey, nextPage) }
categoryContentDao.insert(entities)
loadedPages[categoryKey] = nextPage
Log.w(TAG, "loadMoreForCategory: saved ${entities.size} items for page $nextPage")
} else {
Log.w(TAG, "loadMoreForCategory: no more items for $categoryKey")
}
result.items
}
}
suspend fun getLoadedPageCount(categoryKey: String): Int {
return loadedPages[categoryKey] ?: 0
}
suspend fun getCachedItems(categoryKey: String): List<Content> {
return withContext(Dispatchers.IO) {
categoryContentDao.getByCategory(categoryKey).map { it.toContent() }
}
}
suspend fun clearCache() {
categoryContentDao.deleteAll()
loadedPages.clear()
}
private val scraper = TvmonScraper()
}
data class CategoryResultCache(
val success: Boolean,
val items: List<Content>,
val page: Int,
val pagination: Pagination,
val fromCache: Boolean
)
private fun CategoryContent.toContent(): Content {
return Content(
id = contentId,
title = title,
url = contentUrl,
thumbnail = thumbnail,
category = categoryKey
)
}
private fun Content.toEntity(categoryKey: String, page: Int): CategoryContent {
return CategoryContent(
contentUrl = url,
categoryKey = categoryKey,
contentId = id,
title = title,
thumbnail = thumbnail,
pageNumber = page,
cachedAt = System.currentTimeMillis()
)
}

View File

@@ -3,6 +3,7 @@ package com.example.tvmon.di
import androidx.room.Room
import com.example.tvmon.data.local.AppDatabase
import com.example.tvmon.data.repository.BookmarkRepository
import com.example.tvmon.data.repository.CategoryCacheRepository
import com.example.tvmon.data.repository.WatchHistoryRepository
import com.example.tvmon.data.scraper.TvmonScraper
import org.koin.android.ext.koin.androidContext
@@ -15,18 +16,20 @@ val databaseModule = module {
AppDatabase::class.java,
"tvmon_database"
)
.fallbackToDestructiveMigration()
.build()
.fallbackToDestructiveMigration()
.build()
}
single { get<AppDatabase>().watchHistoryDao() }
single { get<AppDatabase>().bookmarkDao() }
single { get<AppDatabase>().searchHistoryDao() }
single { get<AppDatabase>().categoryContentDao() }
}
val repositoryModule = module {
single { WatchHistoryRepository(get()) }
single { BookmarkRepository(get()) }
single { CategoryCacheRepository(get()) }
}
val scraperModule = module {

View File

@@ -2,8 +2,6 @@ package com.example.tvmon.ui.main
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.widget.Toast
import androidx.core.content.ContextCompat
@@ -11,8 +9,8 @@ import androidx.leanback.app.BrowseSupportFragment
import androidx.leanback.widget.*
import androidx.lifecycle.lifecycleScope
import com.example.tvmon.R
import com.example.tvmon.data.model.Category
import com.example.tvmon.data.model.Content
import com.example.tvmon.data.repository.CategoryCacheRepository
import com.example.tvmon.data.repository.WatchHistoryRepository
import com.example.tvmon.data.scraper.TvmonScraper
import com.example.tvmon.ui.detail.DetailsActivity
@@ -24,33 +22,20 @@ class MainFragment : BrowseSupportFragment(), OnItemViewClickedListener, OnItemV
companion object {
private const val TAG = "TVMON_MAIN"
private const val PRELOAD_THRESHOLD = 20
}
private val scraper: TvmonScraper by inject()
private val categoryCacheRepository: CategoryCacheRepository by inject()
private val watchHistoryRepository: WatchHistoryRepository by inject()
private val rowsAdapter = ArrayObjectAdapter(ListRowPresenter())
private val categoryPages = mutableMapOf<String, Int>()
private val categoryLoading = mutableMapOf<String, Boolean>()
private val categoryMaxPage = mutableMapOf<String, Int>()
private val categoryItems = mutableMapOf<String, MutableList<Content>>()
private val categoryRowAdapters = mutableMapOf<String, ArrayObjectAdapter>()
private val handler = Handler(Looper.getMainLooper())
private var currentSelectedRowIndex = -1
private val loadingStates = mutableMapOf<String, Boolean>()
private var isDataLoaded = false
private var lastPreloadedPage = mutableMapOf<String, Int>()
private var currentCategoryKey: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.w(TAG, "=== MainFragment onCreate ===")
try {
setupUI()
setupEventListeners()
Log.w(TAG, "setupUI completed successfully")
} catch (e: Exception) {
Log.e(TAG, "Error in onCreate", e)
}
setupUI()
setupEventListeners()
}
override fun onStart() {
@@ -58,16 +43,9 @@ class MainFragment : BrowseSupportFragment(), OnItemViewClickedListener, OnItemV
Log.w(TAG, "=== MainFragment onStart ===")
if (!isDataLoaded) {
loadData()
} else {
Log.w(TAG, "Data already loaded, skipping reload")
}
}
override fun onDestroy() {
handler.removeCallbacksAndMessages(null)
super.onDestroy()
}
private fun setupUI() {
Log.w(TAG, "setupUI: Setting up UI")
headersState = HEADERS_ENABLED
@@ -89,114 +67,6 @@ class MainFragment : BrowseSupportFragment(), OnItemViewClickedListener, OnItemV
onItemViewClickedListener = this
}
private fun handleRowSelection(position: Int) {
if (position == currentSelectedRowIndex) return
currentSelectedRowIndex = position
if (position >= 0 && position < rowsAdapter.size()) {
val categoryKey = findCategoryKeyForRow(position)
if (categoryKey != null && categoryLoading[categoryKey] != true) {
val currentPage = categoryPages[categoryKey] ?: 1
val maxPage = categoryMaxPage[categoryKey] ?: 1
Log.w(TAG, "handleRowSelection: $categoryKey currentPage=$currentPage maxPage=$maxPage")
if (currentPage < maxPage) {
preloadNextPage(categoryKey, currentPage + 1)
}
}
}
}
private fun preloadNextPage(categoryKey: String, page: Int) {
if (categoryLoading[categoryKey] == true) return
if (categoryPages[categoryKey] ?: 0 >= page) return
categoryLoading[categoryKey] = true
lifecycleScope.launch {
try {
val result = scraper.getCategory(categoryKey, page)
Log.w(TAG, "preloadNextPage: $categoryKey page $page success=${result.success}, items=${result.items.size}")
if (result.success && result.items.isNotEmpty()) {
val items = categoryItems.getOrPut(categoryKey) { mutableListOf() }
val existingUrls = items.map { it.url }.toSet()
val newItems = result.items.filter { it.url !in existingUrls }
if (newItems.isNotEmpty()) {
items.addAll(newItems)
val adapter = categoryRowAdapters[categoryKey]
activity?.runOnUiThread {
adapter?.let {
newItems.forEach { item -> it.add(item) }
}
}
}
categoryPages[categoryKey] = page
Log.w(TAG, "Preloaded page $page for $categoryKey, total items=${items.size}")
}
} catch (e: Exception) {
Log.e(TAG, "Error preloading page $page for $categoryKey", e)
} finally {
categoryLoading[categoryKey] = false
}
}
}
private fun findCategoryKeyForRow(rowIndex: Int): String? {
for ((key, _) in categoryPages) {
val catIndex = TvmonScraper.CATEGORIES.keys.toList().indexOf(key)
if (catIndex == rowIndex) return key
}
val row = rowsAdapter.get(rowIndex) as? ListRow ?: return null
val headerName = row.headerItem?.name ?: return null
for ((key, cat) in TvmonScraper.CATEGORIES) {
if (cat.name == headerName) return key
}
return null
}
private fun loadNextPage(categoryKey: String, page: Int) {
if (categoryLoading[categoryKey] == true) return
categoryLoading[categoryKey] = true
lifecycleScope.launch {
try {
val result = scraper.getCategory(categoryKey, page)
Log.w(TAG, "loadNextPage: $categoryKey page $page success=${result.success}, items=${result.items.size}")
if (result.success && result.items.isNotEmpty()) {
val items = categoryItems.getOrPut(categoryKey) { mutableListOf() }
val existingUrls = items.map { it.url }.toSet()
val newItems = result.items.filter { it.url !in existingUrls }
if (newItems.isNotEmpty()) {
items.addAll(newItems)
val adapter = categoryRowAdapters[categoryKey]
activity?.runOnUiThread {
adapter?.let {
newItems.forEach { item -> it.add(item) }
}
}
}
categoryPages[categoryKey] = page
Log.w(TAG, "Loaded page $page for $categoryKey, total items=${items.size}")
}
} catch (e: Exception) {
Log.e(TAG, "Error loading page $page for $categoryKey", e)
} finally {
categoryLoading[categoryKey] = false
}
}
}
private fun loadData() {
Log.w(TAG, "=== loadData called ===")
lifecycleScope.launch {
@@ -213,42 +83,33 @@ private fun loadNextPage(categoryKey: String, page: Int) {
private suspend fun loadCategories() {
Log.w(TAG, "=== loadCategories: Starting ===")
Log.w(TAG, "Categories to load: ${TvmonScraper.CATEGORIES.keys}")
var successCount = 0
var failCount = 0
TvmonScraper.CATEGORIES.values.forEach { category ->
for (category in TvmonScraper.CATEGORIES.values) {
Log.w(TAG, "Loading category: ${category.key} - ${category.name}")
val success = loadCategoryRows(category)
val success = loadCategoryWithCache(category)
if (success) successCount++ else failCount++
}
Log.w(TAG, "=== loadCategories COMPLETE: success=$successCount, fail=$failCount ===")
Log.w(TAG, "rowsAdapter size: ${rowsAdapter.size()}")
isDataLoaded = true
activity?.runOnUiThread {
if (rowsAdapter.size() == 0) {
Toast.makeText(requireContext(), "카테고리를 불러오지 못했습니다", Toast.LENGTH_LONG).show()
}
if (rowsAdapter.size() == 0) {
Toast.makeText(requireContext(), "카테고리를 불러오지 못했습니다", Toast.LENGTH_LONG).show()
}
}
private suspend fun loadCategoryRows(category: Category): Boolean {
private suspend fun loadCategoryWithCache(category: com.example.tvmon.data.model.Category): Boolean {
return try {
Log.w(TAG, "Fetching: ${category.key} from ${TvmonScraper.BASE_URL}${category.path}")
val result1 = scraper.getCategory(category.key, page = 1)
Log.w(TAG, "Page 1 for ${category.key}: success=${result1.success}, items=${result1.items.size}")
val maxPage = result1.pagination.maxPage
if (result1.success && result1.items.isNotEmpty()) {
Log.w(TAG, "loadCategoryWithCache: ${category.key}")
val result = categoryCacheRepository.getCategoryWithCache(category.key, page = 1)
if (result.success && result.items.isNotEmpty()) {
val listRowAdapter = ArrayObjectAdapter(ContentCardPresenter())
result1.items.forEach { content ->
Log.d(TAG, " Item: ${content.title} -> thumb: ${content.thumbnail}")
result.items.forEach { content ->
listRowAdapter.add(content)
}
@@ -257,18 +118,10 @@ private fun loadNextPage(categoryKey: String, page: Int) {
activity?.runOnUiThread {
rowsAdapter.add(row)
Log.w(TAG, "ADDED ROW: ${category.name} with ${result1.items.size} items")
Log.w(TAG, "ADDED ROW: ${category.name} with ${result.items.size} items (fromCache=${result.fromCache})")
}
categoryPages[category.key] = 1
categoryMaxPage[category.key] = maxPage
categoryItems[category.key] = result1.items.toMutableList()
categoryRowAdapters[category.key] = listRowAdapter
if (maxPage > 1) {
categoryLoading[category.key] = false
}
true
} else {
Log.w(TAG, "No items for ${category.key}")
@@ -286,19 +139,14 @@ private fun loadNextPage(categoryKey: String, page: Int) {
rowViewHolder: RowPresenter.ViewHolder?,
row: Row?
) {
Log.w(TAG, "=== onItemClicked: item=$item, itemViewHolder=$itemViewHolder ===")
Log.w(TAG, "=== onItemClicked: item=$item ===")
when (item) {
is Content -> {
Log.w(TAG, "Content clicked: ${item.title}, url=${item.url}")
try {
val intent = Intent(requireContext(), DetailsActivity::class.java).apply {
putExtra(DetailsActivity.EXTRA_CONTENT, item)
}
startActivity(intent)
Log.w(TAG, "Started DetailsActivity successfully")
} catch (e: Exception) {
Log.e(TAG, "ERROR starting DetailsActivity", e)
val intent = Intent(requireContext(), DetailsActivity::class.java).apply {
putExtra(DetailsActivity.EXTRA_CONTENT, item)
}
startActivity(intent)
}
else -> {
Log.w(TAG, "Unknown item type: ${item?.javaClass?.simpleName}")
@@ -316,21 +164,45 @@ private fun loadNextPage(categoryKey: String, page: Int) {
val rowIndex = rowsAdapter.indexOf(row)
if (rowIndex >= 0) {
val categoryKey = findCategoryKeyForRow(rowIndex)
currentCategoryKey = categoryKey
handleRowSelection(rowIndex)
if (categoryKey != null && item is Content) {
checkAndLoadMore(categoryKey)
}
}
}
}
private fun checkAndPreload(categoryKey: String, position: Int) {
val currentPage = categoryPages[categoryKey] ?: 1
val maxPage = categoryMaxPage[categoryKey] ?: 1
val thresholdPage = (position / PRELOAD_THRESHOLD) + 1
if (thresholdPage > currentPage && thresholdPage <= maxPage) {
Log.w(TAG, "checkAndPreload: $categoryKey at position $position, preloading page $thresholdPage")
preloadNextPage(categoryKey, thresholdPage)
private fun checkAndLoadMore(categoryKey: String) {
val adapter = categoryRowAdapters[categoryKey] ?: return
if (loadingStates[categoryKey] == true) return
val position = adapter.size() - 1
if (position < 5) return
lifecycleScope.launch {
try {
loadingStates[categoryKey] = true
val items = categoryCacheRepository.loadMoreForCategory(categoryKey)
if (items.isNotEmpty()) {
activity?.runOnUiThread {
items.forEach { adapter.add(it) }
Log.w(TAG, "checkAndLoadMore: $categoryKey added ${items.size} items, total=${adapter.size()}")
}
}
} catch (e: Exception) {
Log.e(TAG, "checkAndLoadMore error: ${e.message}")
} finally {
loadingStates[categoryKey] = false
}
}
}
}
private fun findCategoryKeyForRow(rowIndex: Int): String? {
val row = rowsAdapter.get(rowIndex) as? ListRow ?: return null
val headerName = row.headerItem?.name ?: return null
for ((key, cat) in TvmonScraper.CATEGORIES) {
if (cat.name == headerName) return key
}
return null
}
}

View File

@@ -159,6 +159,7 @@ class PlaybackActivity : AppCompatActivity() {
webSettings.javaScriptEnabled = true
webSettings.domStorageEnabled = true
webSettings.databaseEnabled = true
webSettings.allowFileAccess = true
webSettings.allowContentAccess = true
webSettings.mediaPlaybackRequiresUserGesture = false
@@ -167,7 +168,7 @@ class PlaybackActivity : AppCompatActivity() {
webSettings.userAgentString = USER_AGENT
webSettings.useWideViewPort = true
webSettings.loadWithOverviewMode = true
webSettings.cacheMode = WebSettings.LOAD_NO_CACHE
webSettings.cacheMode = WebSettings.LOAD_DEFAULT
webSettings.allowUniversalAccessFromFileURLs = true
webSettings.allowFileAccessFromFileURLs = true
@@ -283,21 +284,16 @@ class PlaybackActivity : AppCompatActivity() {
super.onPageFinished(view, url)
android.util.Log.i("PlaybackActivity", "Page finished: $url")
handler.postDelayed({
injectFullscreenScript()
android.util.Log.i("PlaybackActivity", "Fullscreen script injected")
}, 300)
handler.postDelayed({
injectFullscreenScript()
injectEnhancedAutoPlayScript()
}, 200)
handler.postDelayed({
injectEnhancedAutoPlayScript()
android.util.Log.i("PlaybackActivity", "Enhanced AutoPlay script injected")
}, 800)
handler.postDelayed({
runOnUiThread {
loadingOverlay.visibility = View.GONE
}
}, 1500)
handler.postDelayed({
runOnUiThread {
loadingOverlay.visibility = View.GONE
}
}, 1000)
}
}