Initial commit: tvmon v1.0.0 - Android TV app with pagination fix and cast display
This commit is contained in:
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
# Environment files (sensitive)
|
||||
.env.local
|
||||
.env2.local
|
||||
|
||||
# Gradle
|
||||
.gradle/
|
||||
build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Android Studio / IntelliJ
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
# Local config
|
||||
local.properties
|
||||
|
||||
# Python virtual environment
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# APK (will be added separately for release)
|
||||
*.apk
|
||||
81
tvmon-app/app/build.gradle
Normal file
81
tvmon-app/app/build.gradle
Normal file
@@ -0,0 +1,81 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'kotlin-kapt'
|
||||
id 'kotlin-parcelize'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'com.example.tvmon'
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.example.tvmon"
|
||||
minSdk 28
|
||||
targetSdk 34
|
||||
versionCode 1
|
||||
versionName "1.0.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
debug {
|
||||
minifyEnabled false
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'androidx.leanback:leanback:1.0.0'
|
||||
implementation 'androidx.leanback:leanback-preference:1.0.0'
|
||||
implementation 'androidx.tv:tv-foundation:1.0.0-alpha10'
|
||||
implementation 'androidx.tv:tv-material:1.0.0-alpha10'
|
||||
implementation 'com.google.android.material:material:1.11.0'
|
||||
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.7.0'
|
||||
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
|
||||
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||
implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
|
||||
|
||||
implementation 'org.jsoup:jsoup:1.17.2'
|
||||
|
||||
implementation 'com.github.bumptech.glide:glide:4.16.0'
|
||||
kapt 'com.github.bumptech.glide:compiler:4.16.0'
|
||||
|
||||
implementation 'androidx.room:room-runtime:2.6.1'
|
||||
implementation 'androidx.room:room-ktx:2.6.1'
|
||||
kapt 'androidx.room:room-compiler:2.6.1'
|
||||
|
||||
implementation 'io.insert-koin:koin-android:3.5.3'
|
||||
implementation 'io.insert-koin:koin-androidx-compose:3.5.3'
|
||||
implementation 'io.insert-koin:koin-core:3.5.3'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
}
|
||||
37
tvmon-app/app/proguard-rules.pro
vendored
Normal file
37
tvmon-app/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
-keepattributes *Annotation*
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# OkHttp
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase
|
||||
|
||||
# Jsoup
|
||||
-keep class org.jsoup.** { *; }
|
||||
-dontwarn org.jsoup.**
|
||||
|
||||
# Glide
|
||||
-keep public class * implements com.bumptech.glide.module.GlideModule
|
||||
-keep class * extends com.bumptech.glide.module.AppGlideModule {
|
||||
<init>(...);
|
||||
}
|
||||
-keep public enum com.bumptech.glide.load.ImageHeaderParser$** {
|
||||
**[] $VALUES;
|
||||
public *;
|
||||
}
|
||||
-keep class com.bumptech.glide.load.data.ParcelFileDescriptorRewinder$InternalRewinder {
|
||||
*** rewind(android.os.ParcelFileDescriptor);
|
||||
}
|
||||
|
||||
# Kotlin Coroutines
|
||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||
|
||||
# Keep Parcelables
|
||||
-keepclassmembers class * implements android.os.Parcelable {
|
||||
static ** CREATOR;
|
||||
}
|
||||
|
||||
# Keep Models
|
||||
-keep class com.example.tvmon.data.model.** { *; }
|
||||
72
tvmon-app/app/src/main/AndroidManifest.xml
Normal file
72
tvmon-app/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.type.tv"
|
||||
android:required="true" />
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="true" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.faketouch"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".TvmonApplication"
|
||||
android:allowBackup="true"
|
||||
android:banner="@drawable/app_banner"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:logo="@drawable/app_logo"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Tvmon"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="31">
|
||||
|
||||
<activity
|
||||
android:name=".ui.main.MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:screenOrientation="landscape">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.detail.DetailsActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.playback.PlaybackActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="landscape"
|
||||
android:theme="@style/Theme.Tvmon.Search" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.search.SearchActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/search_title"
|
||||
android:theme="@style/Theme.Tvmon.Search">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEARCH" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.app.searchable"
|
||||
android:resource="@xml/searchable" />
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.example.tvmon
|
||||
|
||||
import android.app.Application
|
||||
import com.example.tvmon.di.appModules
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.android.ext.koin.androidLogger
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.logger.Level
|
||||
|
||||
class TvmonApplication : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
startKoin {
|
||||
androidLogger(Level.ERROR)
|
||||
androidContext(this@TvmonApplication)
|
||||
modules(appModules)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.example.tvmon
|
||||
|
||||
import android.content.Context
|
||||
import com.bumptech.glide.GlideBuilder
|
||||
import com.bumptech.glide.annotation.GlideModule
|
||||
import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory
|
||||
import com.bumptech.glide.load.engine.cache.LruResourceCache
|
||||
import com.bumptech.glide.module.AppGlideModule
|
||||
|
||||
@GlideModule
|
||||
class TvmonGlideModule : AppGlideModule() {
|
||||
override fun applyOptions(context: Context, builder: GlideBuilder) {
|
||||
val memoryCacheSizeBytes = 1024 * 1024 * 50
|
||||
builder.setMemoryCache(LruResourceCache(memoryCacheSizeBytes.toLong()))
|
||||
|
||||
val diskCacheSizeBytes = 1024L * 1024L * 200L
|
||||
builder.setDiskCache(InternalCacheDiskCacheFactory(context, "image_cache", diskCacheSizeBytes))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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.SearchHistoryDao
|
||||
import com.example.tvmon.data.local.dao.WatchHistoryDao
|
||||
import com.example.tvmon.data.local.entity.Bookmark
|
||||
import com.example.tvmon.data.local.entity.SearchHistory
|
||||
import com.example.tvmon.data.local.entity.WatchHistory
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
WatchHistory::class,
|
||||
Bookmark::class,
|
||||
SearchHistory::class
|
||||
],
|
||||
version = 1,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun watchHistoryDao(): WatchHistoryDao
|
||||
abstract fun bookmarkDao(): BookmarkDao
|
||||
abstract fun searchHistoryDao(): SearchHistoryDao
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.example.tvmon.data.local.dao
|
||||
|
||||
import androidx.room.*
|
||||
import com.example.tvmon.data.local.entity.Bookmark
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface BookmarkDao {
|
||||
|
||||
@Query("SELECT * FROM bookmarks ORDER BY timestamp DESC")
|
||||
fun getAllFlow(): Flow<List<Bookmark>>
|
||||
|
||||
@Query("SELECT * FROM bookmarks WHERE contentId = :contentId")
|
||||
suspend fun getById(contentId: String): Bookmark?
|
||||
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM bookmarks WHERE contentId = :contentId)")
|
||||
suspend fun isBookmarked(contentId: String): Boolean
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(bookmark: Bookmark)
|
||||
|
||||
@Delete
|
||||
suspend fun delete(bookmark: Bookmark)
|
||||
|
||||
@Query("DELETE FROM bookmarks WHERE contentId = :contentId")
|
||||
suspend fun deleteById(contentId: String)
|
||||
|
||||
@Query("DELETE FROM bookmarks")
|
||||
suspend fun deleteAll()
|
||||
|
||||
@Transaction
|
||||
suspend fun toggleBookmark(bookmark: Bookmark) {
|
||||
val existing = getById(bookmark.contentId)
|
||||
if (existing != null) {
|
||||
deleteById(bookmark.contentId)
|
||||
} else {
|
||||
insert(bookmark)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.example.tvmon.data.local.dao
|
||||
|
||||
import androidx.room.*
|
||||
import com.example.tvmon.data.local.entity.SearchHistory
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface SearchHistoryDao {
|
||||
|
||||
@Query("SELECT * FROM search_history ORDER BY timestamp DESC LIMIT :limit")
|
||||
fun getRecentFlow(limit: Int = 20): Flow<List<SearchHistory>>
|
||||
|
||||
@Query("SELECT keyword FROM search_history ORDER BY timestamp DESC LIMIT :limit")
|
||||
fun getRecentKeywordsFlow(limit: Int = 10): Flow<List<String>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(history: SearchHistory)
|
||||
|
||||
@Query("DELETE FROM search_history WHERE keyword = :keyword")
|
||||
suspend fun deleteByKeyword(keyword: String)
|
||||
|
||||
@Query("DELETE FROM search_history")
|
||||
suspend fun deleteAll()
|
||||
|
||||
@Transaction
|
||||
suspend fun addSearchKeyword(keyword: String) {
|
||||
deleteByKeyword(keyword)
|
||||
insert(SearchHistory(keyword = keyword, timestamp = System.currentTimeMillis()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.example.tvmon.data.local.dao
|
||||
|
||||
import androidx.room.*
|
||||
import com.example.tvmon.data.local.entity.WatchHistory
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface WatchHistoryDao {
|
||||
|
||||
@Query("SELECT * FROM watch_history ORDER BY timestamp DESC")
|
||||
fun getAllFlow(): Flow<List<WatchHistory>>
|
||||
|
||||
@Query("SELECT * FROM watch_history ORDER BY timestamp DESC LIMIT :limit")
|
||||
fun getRecentFlow(limit: Int = 10): Flow<List<WatchHistory>>
|
||||
|
||||
@Query("SELECT * FROM watch_history WHERE contentId = :contentId")
|
||||
suspend fun getById(contentId: String): WatchHistory?
|
||||
|
||||
@Query("SELECT * FROM watch_history WHERE contentId IN (:contentIds)")
|
||||
suspend fun getByIds(contentIds: List<String>): List<WatchHistory>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(history: WatchHistory)
|
||||
|
||||
@Update
|
||||
suspend fun update(history: WatchHistory)
|
||||
|
||||
@Delete
|
||||
suspend fun delete(history: WatchHistory)
|
||||
|
||||
@Query("DELETE FROM watch_history WHERE contentId = :contentId")
|
||||
suspend fun deleteById(contentId: String)
|
||||
|
||||
@Query("DELETE FROM watch_history")
|
||||
suspend fun deleteAll()
|
||||
|
||||
@Transaction
|
||||
suspend fun upsert(history: WatchHistory) {
|
||||
val existing = getById(history.contentId)
|
||||
if (existing != null) {
|
||||
update(history)
|
||||
} else {
|
||||
insert(history)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.example.tvmon.data.local.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import com.example.tvmon.data.model.Content
|
||||
|
||||
@Entity(tableName = "watch_history")
|
||||
data class WatchHistory(
|
||||
@PrimaryKey
|
||||
val contentId: String,
|
||||
val title: String,
|
||||
val url: String,
|
||||
val thumbnail: String,
|
||||
val category: String,
|
||||
val lastEpisodeNumber: String,
|
||||
val lastEpisodeUrl: String,
|
||||
val lastPosition: Long = 0,
|
||||
val timestamp: Long = System.currentTimeMillis()
|
||||
) {
|
||||
fun toContent(): Content {
|
||||
return Content(
|
||||
id = contentId,
|
||||
title = title,
|
||||
url = url,
|
||||
thumbnail = thumbnail,
|
||||
category = category
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Entity(tableName = "bookmarks")
|
||||
data class Bookmark(
|
||||
@PrimaryKey
|
||||
val contentId: String,
|
||||
val title: String,
|
||||
val url: String,
|
||||
val thumbnail: String,
|
||||
val category: String,
|
||||
val timestamp: Long = System.currentTimeMillis()
|
||||
) {
|
||||
fun toContent(): Content {
|
||||
return Content(
|
||||
id = contentId,
|
||||
title = title,
|
||||
url = url,
|
||||
thumbnail = thumbnail,
|
||||
category = category
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Entity(tableName = "search_history")
|
||||
data class SearchHistory(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Long = 0,
|
||||
val keyword: String,
|
||||
val timestamp: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.example.tvmon.data.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class Category(
|
||||
val key: String,
|
||||
val name: String,
|
||||
val path: String
|
||||
) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class Content(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val url: String,
|
||||
val thumbnail: String = "",
|
||||
val category: String = ""
|
||||
) : Parcelable {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Content) return false
|
||||
return id == other.id
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = id.hashCode()
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class ContentDetail(
|
||||
val success: Boolean,
|
||||
val url: String,
|
||||
val title: String,
|
||||
val thumbnail: String,
|
||||
val poster: String = "",
|
||||
val overview: String = "",
|
||||
val country: String = "",
|
||||
val year: String = "",
|
||||
val cast: List<CastMember> = emptyList(),
|
||||
val episodes: List<Episode>,
|
||||
val videoLinks: List<VideoLink>,
|
||||
val playUrl: String = ""
|
||||
) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class CastMember(
|
||||
val name: String,
|
||||
val character: String = "",
|
||||
val profilePath: String = ""
|
||||
) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class Episode(
|
||||
val number: String,
|
||||
val title: String,
|
||||
val url: String,
|
||||
val type: String = "webview"
|
||||
) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class VideoLink(
|
||||
val type: String,
|
||||
val url: String,
|
||||
val title: String = ""
|
||||
) : Parcelable
|
||||
|
||||
data class CategoryResult(
|
||||
val success: Boolean,
|
||||
val category: String,
|
||||
val items: List<Content>,
|
||||
val page: Int,
|
||||
val pagination: Pagination
|
||||
)
|
||||
|
||||
data class Pagination(
|
||||
val current: Int,
|
||||
val maxPage: Int
|
||||
)
|
||||
|
||||
data class SearchResult(
|
||||
val success: Boolean,
|
||||
val keyword: String,
|
||||
val results: List<Content>,
|
||||
val page: Int
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.example.tvmon.data.repository
|
||||
|
||||
import com.example.tvmon.data.local.dao.BookmarkDao
|
||||
import com.example.tvmon.data.local.entity.Bookmark
|
||||
import com.example.tvmon.data.model.Content
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
class BookmarkRepository(private val dao: BookmarkDao) {
|
||||
|
||||
fun getAllBookmarks(): Flow<List<Bookmark>> {
|
||||
return dao.getAllFlow()
|
||||
}
|
||||
|
||||
suspend fun isBookmarked(contentId: String): Boolean {
|
||||
return dao.isBookmarked(contentId)
|
||||
}
|
||||
|
||||
suspend fun addBookmark(content: Content) {
|
||||
val bookmark = Bookmark(
|
||||
contentId = content.id,
|
||||
title = content.title,
|
||||
url = content.url,
|
||||
thumbnail = content.thumbnail,
|
||||
category = content.category,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
dao.insert(bookmark)
|
||||
}
|
||||
|
||||
suspend fun removeBookmark(contentId: String) {
|
||||
dao.deleteById(contentId)
|
||||
}
|
||||
|
||||
suspend fun toggleBookmark(content: Content) {
|
||||
val bookmark = Bookmark(
|
||||
contentId = content.id,
|
||||
title = content.title,
|
||||
url = content.url,
|
||||
thumbnail = content.thumbnail,
|
||||
category = content.category,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
dao.toggleBookmark(bookmark)
|
||||
}
|
||||
|
||||
suspend fun clearAllBookmarks() {
|
||||
dao.deleteAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.example.tvmon.data.repository
|
||||
|
||||
import com.example.tvmon.data.local.dao.BookmarkDao
|
||||
import com.example.tvmon.data.local.dao.WatchHistoryDao
|
||||
import com.example.tvmon.data.local.entity.Bookmark
|
||||
import com.example.tvmon.data.local.entity.WatchHistory
|
||||
import com.example.tvmon.data.model.Content
|
||||
import com.example.tvmon.data.model.Episode
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
class WatchHistoryRepository(private val dao: WatchHistoryDao) {
|
||||
|
||||
fun getRecentHistory(limit: Int = 10): Flow<List<WatchHistory>> {
|
||||
return dao.getRecentFlow(limit)
|
||||
}
|
||||
|
||||
fun getAllHistory(): Flow<List<WatchHistory>> {
|
||||
return dao.getAllFlow()
|
||||
}
|
||||
|
||||
suspend fun addToHistory(
|
||||
content: Content,
|
||||
episode: Episode,
|
||||
position: Long = 0
|
||||
) {
|
||||
val history = WatchHistory(
|
||||
contentId = content.id,
|
||||
title = content.title,
|
||||
url = content.url,
|
||||
thumbnail = content.thumbnail,
|
||||
category = content.category,
|
||||
lastEpisodeNumber = episode.number,
|
||||
lastEpisodeUrl = episode.url,
|
||||
lastPosition = position,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
dao.upsert(history)
|
||||
}
|
||||
|
||||
suspend fun updatePosition(contentId: String, position: Long) {
|
||||
dao.getById(contentId)?.let { history ->
|
||||
dao.update(history.copy(lastPosition = position))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getHistory(contentId: String): WatchHistory? {
|
||||
return dao.getById(contentId)
|
||||
}
|
||||
|
||||
suspend fun deleteHistory(contentId: String) {
|
||||
dao.deleteById(contentId)
|
||||
}
|
||||
|
||||
suspend fun clearAllHistory() {
|
||||
dao.deleteAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
package com.example.tvmon.data.scraper
|
||||
|
||||
import com.example.tvmon.data.model.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.jsoup.Jsoup
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.regex.Pattern
|
||||
|
||||
class TvmonScraper {
|
||||
|
||||
companion object {
|
||||
const val BASE_URL = "https://tvmon.site"
|
||||
private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
|
||||
val CATEGORIES = mapOf(
|
||||
"movie" to Category("movie", "영화", "/movie"),
|
||||
"kor_movie" to Category("kor_movie", "한국영화", "/kor_movie"),
|
||||
"drama" to Category("drama", "드라마", "/drama"),
|
||||
"ent" to Category("ent", "예능프로그램", "/ent"),
|
||||
"sisa" to Category("sisa", "시사/다큐", "/sisa"),
|
||||
"world" to Category("world", "해외드라마", "/world"),
|
||||
"ott_ent" to Category("ott_ent", "해외 (예능/다큐)", "/ott_ent"),
|
||||
"ani_movie" to Category("ani_movie", "[극장판] 애니메이션", "/ani_movie"),
|
||||
"animation" to Category("animation", "일반 애니메이션", "/animation")
|
||||
)
|
||||
|
||||
private val NAV_PATTERNS = listOf("/login", "/logout", "/register", "/mypage", "/bbs/", "/menu", "/faq", "/privacy")
|
||||
}
|
||||
|
||||
private val client: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
|
||||
private suspend fun get(url: String): String? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
.header("Accept-Language", "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7")
|
||||
.header("Referer", BASE_URL)
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
response.body?.string()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractSeriesUrl(url: String): String {
|
||||
val pattern = Pattern.compile("(/(drama|movie|ent|world|animation|kor_movie|sisa|ott_ent|ani_movie)/\\d+)/\\d+")
|
||||
val matcher = pattern.matcher(url)
|
||||
if (matcher.find()) {
|
||||
return BASE_URL + matcher.group(1)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
suspend fun getHomepage(): Map<String, Any> = withContext(Dispatchers.IO) {
|
||||
val html = get("$BASE_URL/") ?: return@withContext mapOf("success" to false)
|
||||
|
||||
val doc = Jsoup.parse(html)
|
||||
val result = mutableMapOf<String, Any>(
|
||||
"success" to true,
|
||||
"popular" to mutableListOf<Map<String, String>>(),
|
||||
"latest" to mutableMapOf<String, List<Map<String, String>>>()
|
||||
)
|
||||
|
||||
val popularLinks = doc.select("a[href*='/drama/'], a[href*='/movie/'], a[href*='/kor_movie/'], a[href*='/world/'], a[href*='/animation/']")
|
||||
val popularList = mutableListOf<Map<String, String>>()
|
||||
for (link in popularLinks.take(10)) {
|
||||
val href = link.attr("href")
|
||||
val title = link.text()
|
||||
if (title.isNotBlank() && href.isNotBlank()) {
|
||||
popularList.add(mapOf(
|
||||
"title" to title,
|
||||
"url" to resolveUrl(href),
|
||||
"category" to getCategoryFromUrl(href)
|
||||
))
|
||||
}
|
||||
}
|
||||
result["popular"] = popularList
|
||||
|
||||
val latest = mutableMapOf<String, List<Map<String, String>>>()
|
||||
listOf("영화", "드라마", "예능", "해외드라마", "애니메이션").forEach { section ->
|
||||
latest[section] = emptyList()
|
||||
}
|
||||
|
||||
val movieItems = doc.select("a[href*='/movie/']")
|
||||
val movieList = mutableListOf<Map<String, String>>()
|
||||
for (item in movieItems.take(6)) {
|
||||
val href = item.attr("href")
|
||||
val title = item.text()
|
||||
val img = item.selectFirst("img")
|
||||
val imgUrl = img?.attr("src") ?: img?.attr("data-src") ?: ""
|
||||
|
||||
if (title.isNotBlank() && href.isNotBlank() && "/movie/" in href) {
|
||||
movieList.add(mapOf(
|
||||
"title" to title,
|
||||
"url" to resolveUrl(href),
|
||||
"thumbnail" to imgUrl
|
||||
))
|
||||
}
|
||||
}
|
||||
latest["영화"] = movieList
|
||||
|
||||
val dramaItems = doc.select("a[href*='/drama/']")
|
||||
val dramaList = mutableListOf<Map<String, String>>()
|
||||
for (item in dramaItems.take(6)) {
|
||||
val href = item.attr("href")
|
||||
val title = item.text()
|
||||
val img = item.selectFirst("img")
|
||||
val imgUrl = img?.attr("src") ?: img?.attr("data-src") ?: ""
|
||||
|
||||
if (title.isNotBlank() && href.isNotBlank() && "/drama/" in href) {
|
||||
dramaList.add(mapOf(
|
||||
"title" to title,
|
||||
"url" to resolveUrl(href),
|
||||
"thumbnail" to imgUrl
|
||||
))
|
||||
}
|
||||
}
|
||||
latest["드라마"] = dramaList
|
||||
|
||||
result["latest"] = latest
|
||||
result
|
||||
}
|
||||
|
||||
suspend fun getCategory(categoryKey: String, page: Int = 1): CategoryResult = withContext(Dispatchers.IO) {
|
||||
val catInfo = CATEGORIES[categoryKey] ?: return@withContext CategoryResult(
|
||||
success = false,
|
||||
category = "Unknown",
|
||||
items = emptyList(),
|
||||
page = page,
|
||||
pagination = Pagination(1, 1)
|
||||
)
|
||||
|
||||
val url = if (page == 1) "$BASE_URL${catInfo.path}" else "$BASE_URL${catInfo.path}?page=$page"
|
||||
val html = get(url) ?: return@withContext CategoryResult(
|
||||
success = false,
|
||||
category = catInfo.name,
|
||||
items = emptyList(),
|
||||
page = page,
|
||||
pagination = Pagination(page, 1)
|
||||
)
|
||||
|
||||
val doc = Jsoup.parse(html)
|
||||
val items = mutableListOf<Content>()
|
||||
val seen = mutableSetOf<String>()
|
||||
|
||||
val titleLinks = doc.select("a.title[href*=/$categoryKey/]")
|
||||
|
||||
for (titleLink in titleLinks) {
|
||||
val href = titleLink.attr("href")
|
||||
if (href.isBlank() || "/$categoryKey/" !in href) continue
|
||||
|
||||
val fullUrl = resolveUrl(href)
|
||||
if (fullUrl in seen) continue
|
||||
seen.add(fullUrl)
|
||||
|
||||
val idMatch = Pattern.compile("/$categoryKey/(\\d+)/").matcher(href)
|
||||
val contentId = if (idMatch.find()) idMatch.group(1) ?: "" else ""
|
||||
|
||||
val title = titleLink.text().trim()
|
||||
|
||||
val imgLink = doc.selectFirst("a.img[href=$href]")
|
||||
val imgTag = imgLink?.selectFirst("img")
|
||||
val imgUrl = imgTag?.attr("src") ?: ""
|
||||
|
||||
if (title.isNotBlank()) {
|
||||
items.add(Content(
|
||||
id = contentId,
|
||||
title = title,
|
||||
url = fullUrl,
|
||||
thumbnail = if (imgUrl.startsWith("http")) imgUrl else BASE_URL + imgUrl,
|
||||
category = categoryKey
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
var maxPage = 1
|
||||
val pageLinks = doc.select("a[href*='/page/'], a[href*='page=']")
|
||||
for (pageLink in pageLinks) {
|
||||
val pageMatch = Pattern.compile("[/&]?page[=/](\\d+)").matcher(pageLink.attr("href"))
|
||||
if (pageMatch.find()) {
|
||||
val pageNum = pageMatch.group(1).toInt()
|
||||
if (pageNum > maxPage) maxPage = pageNum
|
||||
}
|
||||
}
|
||||
|
||||
CategoryResult(
|
||||
success = true,
|
||||
category = catInfo.name,
|
||||
items = items,
|
||||
page = page,
|
||||
pagination = Pagination(page, maxPage)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun getDetail(urlOrId: String, category: String? = null): ContentDetail = withContext(Dispatchers.IO) {
|
||||
val seriesUrl = extractSeriesUrl(
|
||||
if (urlOrId.startsWith("http")) urlOrId
|
||||
else if (category != null) "$BASE_URL/$category/$urlOrId" else "$BASE_URL/movie/$urlOrId"
|
||||
)
|
||||
|
||||
val html = get(seriesUrl) ?: return@withContext ContentDetail(
|
||||
success = false,
|
||||
url = seriesUrl,
|
||||
title = "",
|
||||
thumbnail = "",
|
||||
poster = "",
|
||||
overview = "",
|
||||
country = "",
|
||||
year = "",
|
||||
cast = emptyList(),
|
||||
episodes = emptyList(),
|
||||
videoLinks = emptyList(),
|
||||
playUrl = ""
|
||||
)
|
||||
|
||||
val doc = Jsoup.parse(html)
|
||||
|
||||
var title = ""
|
||||
val titleTag = doc.selectFirst("#bo_v_title, h1, h2.title, .content-title, title")
|
||||
if (titleTag != null) {
|
||||
val titleText = titleTag.text()
|
||||
title = titleText
|
||||
.replace("다시보기", "")
|
||||
.replace(" - 티비몬", "")
|
||||
.replace(" - tvmon", "")
|
||||
.trim()
|
||||
}
|
||||
if (title.isBlank()) {
|
||||
val ogTitle = doc.selectFirst("meta[property=og:title]")?.attr("content") ?: ""
|
||||
if (ogTitle.isNotBlank()) {
|
||||
title = ogTitle.split(" - ").firstOrNull()?.trim() ?: ogTitle
|
||||
}
|
||||
}
|
||||
|
||||
var poster = ""
|
||||
val posterImg = doc.selectFirst("#bo_v_poster img")
|
||||
if (posterImg != null) {
|
||||
val posterSrc = posterImg.attr("src")
|
||||
if (posterSrc.isNotBlank() && posterSrc != "/img/no3.png") {
|
||||
poster = resolveUrl(posterSrc)
|
||||
}
|
||||
}
|
||||
if (poster.isBlank()) {
|
||||
val ogImage = doc.selectFirst("meta[property=og:image]")?.attr("content") ?: ""
|
||||
if (ogImage.isNotBlank() && ogImage != "$BASE_URL/") {
|
||||
poster = ogImage
|
||||
}
|
||||
}
|
||||
|
||||
var country = ""
|
||||
var year = ""
|
||||
var language = ""
|
||||
|
||||
val infoDls = doc.select("#bo_v_movinfo dl.bo_v_info, dl.bo_v_info")
|
||||
for (dl in infoDls) {
|
||||
val dds = dl.select("dd")
|
||||
for (dd in dds) {
|
||||
val text = dd.text().trim()
|
||||
if (text.contains(":")) {
|
||||
val parts = text.split(":", limit = 2)
|
||||
val key = parts[0].trim()
|
||||
val value = parts.getOrNull(1)?.trim() ?: ""
|
||||
|
||||
when {
|
||||
key.contains("국가") -> country = value
|
||||
key.contains("언어") -> language = value
|
||||
key.contains("개봉") || key.contains("방영") || key.contains("년도") -> {
|
||||
val yearMatch = Pattern.compile("(\\d{4})").matcher(value)
|
||||
if (yearMatch.find()) {
|
||||
year = yearMatch.group(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (country.isNullOrBlank()) country = "대한민국"
|
||||
|
||||
var overview = ""
|
||||
val metaDesc = doc.selectFirst("meta[name=description], meta[property=og:description]")
|
||||
if (metaDesc != null) {
|
||||
overview = metaDesc.attr("content").trim()
|
||||
}
|
||||
|
||||
val contentDiv = doc.selectFirst("#bo_v_atc, .bo_v_atc, #bo_v_content")
|
||||
if (contentDiv != null) {
|
||||
val contentText = contentDiv.text().trim()
|
||||
if (contentText.isNotBlank() && contentText.length > overview.length) {
|
||||
overview = contentText
|
||||
}
|
||||
}
|
||||
|
||||
val castList = mutableListOf<CastMember>()
|
||||
|
||||
val seriesHtml = get(seriesUrl)
|
||||
if (seriesHtml != null) {
|
||||
val seriesDoc = Jsoup.parse(seriesHtml)
|
||||
|
||||
val castLinks = seriesDoc.select(".cast-list a, .bo_v_info a[href*='/actor/'], .cast-item a, .cast a, #bo_v_info a")
|
||||
for (link in castLinks) {
|
||||
val href = link.attr("href")
|
||||
val name = link.text().trim()
|
||||
if (name.isNotBlank() && name.length > 1) {
|
||||
val profilePath = resolveUrl(link.selectFirst("img")?.attr("src") ?: "")
|
||||
if (name !in castList.map { it.name }) {
|
||||
castList.add(CastMember(name = name, character = "", profilePath = profilePath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (castList.isEmpty()) {
|
||||
val infoText = seriesDoc.selectFirst(".bo_v_info, .info-area, #bo_v_info")?.text() ?: ""
|
||||
|
||||
val patterns = listOf(
|
||||
"출연[^:]*[:\\s]*(.+?)(?:\\n|$)",
|
||||
"출演[^:]*[:\\s]*(.+?)(?:\\n|$)",
|
||||
"배우[^:]*[:\\s]*(.+?)(?:\\n|$)",
|
||||
"Cast[^:]*[:\\s]*(.+?)(?:\\n|$)"
|
||||
)
|
||||
|
||||
for (pattern in patterns) {
|
||||
val castMatch = Pattern.compile(pattern, Pattern.CASE_INSENSITIVE).matcher(infoText)
|
||||
if (castMatch.find()) {
|
||||
val castStr = castMatch.group(1).trim()
|
||||
val castNames = castStr.split(",", " ", "·", "│").map { it.trim() }.filter { it.isNotBlank() && it.length > 1 }
|
||||
castNames.take(10).forEach { name ->
|
||||
if (name !in castList.map { it.name }) {
|
||||
castList.add(CastMember(name = name, character = ""))
|
||||
}
|
||||
}
|
||||
if (castList.isNotEmpty()) break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val episodes = mutableListOf<Episode>()
|
||||
val videoLinks = mutableListOf<VideoLink>()
|
||||
val seenEpisodeIds = mutableSetOf<String>()
|
||||
|
||||
val episodeLinks = doc.select(".next-ep-list-scroll .ep-item, .ep-item")
|
||||
|
||||
for (link in episodeLinks) {
|
||||
val href = link.attr("href")
|
||||
if (href.isBlank()) continue
|
||||
|
||||
val fullUrl = resolveUrl(href)
|
||||
|
||||
val episodeIdMatch = Pattern.compile("/(\\d+)/(\\d+)$").matcher(href)
|
||||
if (!episodeIdMatch.find()) continue
|
||||
|
||||
val episodeId = episodeIdMatch.group(2) ?: ""
|
||||
if (episodeId in seenEpisodeIds) continue
|
||||
seenEpisodeIds.add(episodeId)
|
||||
|
||||
val titleEl = link.selectFirst(".ep-item-title")
|
||||
val linkText = titleEl?.text()?.trim() ?: link.text().trim()
|
||||
|
||||
val episodeNumMatch = Pattern.compile("(\\d+ 화|\\d+ 회|EP?\\d+|제?\\d+부?|\\d+)").matcher(linkText)
|
||||
val episodeTitle = if (episodeNumMatch.find()) {
|
||||
episodeNumMatch.group(1)
|
||||
} else {
|
||||
"Episode ${episodes.size + 1}"
|
||||
}
|
||||
|
||||
episodes.add(Episode(
|
||||
number = episodeTitle ?: "Episode ${episodes.size + 1}",
|
||||
title = linkText.ifBlank { episodeTitle ?: "Episode ${episodes.size + 1}" },
|
||||
url = fullUrl,
|
||||
type = "webview"
|
||||
))
|
||||
|
||||
videoLinks.add(VideoLink(
|
||||
type = "play_page",
|
||||
url = fullUrl,
|
||||
title = linkText.ifBlank { episodeTitle ?: "Episode ${videoLinks.size + 1}" }
|
||||
))
|
||||
}
|
||||
|
||||
episodes.sortByDescending { episode ->
|
||||
val numberStr = episode.number
|
||||
val pattern = Pattern.compile("\\d+")
|
||||
val matcher = pattern.matcher(numberStr)
|
||||
if (matcher.find()) {
|
||||
matcher.group().toIntOrNull() ?: 0
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
val videoLinkMap = videoLinks.associateBy { it.url }
|
||||
videoLinks.clear()
|
||||
episodes.forEach { episode ->
|
||||
videoLinkMap[episode.url]?.let { videoLinks.add(it) }
|
||||
}
|
||||
|
||||
val playUrl = episodes.firstOrNull()?.url ?: ""
|
||||
|
||||
ContentDetail(
|
||||
success = true,
|
||||
url = seriesUrl,
|
||||
title = title,
|
||||
thumbnail = poster.ifBlank {
|
||||
episodes.firstOrNull()?.let {
|
||||
""
|
||||
} ?: ""
|
||||
},
|
||||
poster = poster,
|
||||
overview = overview,
|
||||
country = country,
|
||||
year = year,
|
||||
cast = castList,
|
||||
episodes = episodes,
|
||||
videoLinks = videoLinks,
|
||||
playUrl = playUrl
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun search(keyword: String, page: Int = 1): SearchResult = withContext(Dispatchers.IO) {
|
||||
val encoded = java.net.URLEncoder.encode(keyword, "UTF-8")
|
||||
val url = if (page == 1) "$BASE_URL/search?stx=$encoded" else "$BASE_URL/search?stx=$encoded&page=$page"
|
||||
|
||||
val html = get(url) ?: return@withContext SearchResult(
|
||||
success = false,
|
||||
keyword = keyword,
|
||||
results = emptyList(),
|
||||
page = page
|
||||
)
|
||||
|
||||
val doc = Jsoup.parse(html)
|
||||
val results = mutableListOf<Content>()
|
||||
val seen = mutableSetOf<String>()
|
||||
|
||||
val allLinks = doc.select("a[href*='/movie/'], a[href*='/drama/'], a[href*='/ent/'], a[href*='/world/'], a[href*='/animation/'], a[href*='/kor_movie/']")
|
||||
|
||||
for (link in allLinks) {
|
||||
val href = link.attr("href")
|
||||
if (href.isBlank()) continue
|
||||
|
||||
val fullUrl = resolveUrl(href)
|
||||
if (fullUrl in seen) continue
|
||||
if (NAV_PATTERNS.any { it in fullUrl }) continue
|
||||
seen.add(fullUrl)
|
||||
|
||||
val idMatch = Pattern.compile("/(\\d+)(?:/|\\$|\\?)").matcher(href)
|
||||
val contentId = if (idMatch.find()) idMatch.group(1) else ""
|
||||
|
||||
val category = getCategoryFromUrl(href)
|
||||
|
||||
val imgTag = link.selectFirst("img")
|
||||
val imgUrl = imgTag?.attr("src") ?: imgTag?.attr("data-src") ?: ""
|
||||
|
||||
var title = link.text()
|
||||
if (title.isBlank()) {
|
||||
val titleTag = link.selectFirst(".title, .movie-title")
|
||||
title = titleTag?.text() ?: ""
|
||||
}
|
||||
|
||||
if (title.isNotBlank()) {
|
||||
results.add(Content(
|
||||
id = contentId ?: "",
|
||||
title = title,
|
||||
url = fullUrl,
|
||||
thumbnail = imgUrl,
|
||||
category = category
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
SearchResult(
|
||||
success = true,
|
||||
keyword = keyword,
|
||||
results = results,
|
||||
page = page
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun getPopular(): List<Content> = withContext(Dispatchers.IO) {
|
||||
val html = get("$BASE_URL/popular") ?: return@withContext emptyList()
|
||||
|
||||
val doc = Jsoup.parse(html)
|
||||
val items = mutableListOf<Content>()
|
||||
val seen = mutableSetOf<String>()
|
||||
|
||||
val allLinks = doc.select("a[href*='/movie/'], a[href*='/drama/'], a[href*='/ent/'], a[href*='/world/'], a[href*='/animation/'], a[href*='/kor_movie/']")
|
||||
|
||||
for (link in allLinks) {
|
||||
val href = link.attr("href")
|
||||
if (href.isBlank()) continue
|
||||
|
||||
val fullUrl = resolveUrl(href)
|
||||
if (fullUrl in seen) continue
|
||||
if (NAV_PATTERNS.any { it in fullUrl }) continue
|
||||
seen.add(fullUrl)
|
||||
|
||||
val idMatch = Pattern.compile("/(\\d+)(?:/|\\$|\\?)").matcher(href)
|
||||
val contentId = if (idMatch.find()) idMatch.group(1) else ""
|
||||
|
||||
val category = getCategoryFromUrl(href)
|
||||
|
||||
val imgTag = link.selectFirst("img")
|
||||
val imgUrl = imgTag?.attr("src") ?: imgTag?.attr("data-src") ?: ""
|
||||
|
||||
var title = link.text()
|
||||
if (title.isBlank()) {
|
||||
val titleTag = link.selectFirst(".title, .movie-title")
|
||||
title = titleTag?.text() ?: ""
|
||||
}
|
||||
|
||||
if (title.isNotBlank()) {
|
||||
items.add(Content(
|
||||
id = contentId ?: "",
|
||||
title = title,
|
||||
url = fullUrl,
|
||||
thumbnail = imgUrl,
|
||||
category = category
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
private fun resolveUrl(href: String): String {
|
||||
return if (href.startsWith("http")) {
|
||||
href
|
||||
} else {
|
||||
BASE_URL + (if (href.startsWith("/")) href else "/$href")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCategoryFromUrl(url: String): String {
|
||||
for (catKey in CATEGORIES.keys) {
|
||||
if ("/$catKey/" in url) {
|
||||
return catKey
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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.WatchHistoryRepository
|
||||
import com.example.tvmon.data.scraper.TvmonScraper
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
|
||||
val databaseModule = module {
|
||||
single {
|
||||
Room.databaseBuilder(
|
||||
androidContext(),
|
||||
AppDatabase::class.java,
|
||||
"tvmon_database"
|
||||
)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
}
|
||||
|
||||
single { get<AppDatabase>().watchHistoryDao() }
|
||||
single { get<AppDatabase>().bookmarkDao() }
|
||||
single { get<AppDatabase>().searchHistoryDao() }
|
||||
}
|
||||
|
||||
val repositoryModule = module {
|
||||
single { WatchHistoryRepository(get()) }
|
||||
single { BookmarkRepository(get()) }
|
||||
}
|
||||
|
||||
val scraperModule = module {
|
||||
single { TvmonScraper() }
|
||||
}
|
||||
|
||||
val appModules = listOf(
|
||||
databaseModule,
|
||||
repositoryModule,
|
||||
scraperModule
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.example.tvmon.ui.detail
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.example.tvmon.R
|
||||
import com.example.tvmon.data.model.Content
|
||||
|
||||
class DetailsActivity : FragmentActivity() {
|
||||
|
||||
companion object {
|
||||
const val EXTRA_CONTENT = "extra_content"
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_details)
|
||||
|
||||
val content = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(EXTRA_CONTENT, Content::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableExtra(EXTRA_CONTENT)
|
||||
}
|
||||
|
||||
if (savedInstanceState == null && content != null) {
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.details_fragment, DetailsFragment.newInstance(content))
|
||||
.commitNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
package com.example.tvmon.ui.detail
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.leanback.app.DetailsSupportFragment
|
||||
import androidx.leanback.widget.*
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.target.CustomTarget
|
||||
import com.bumptech.glide.request.transition.Transition
|
||||
import com.example.tvmon.R
|
||||
import com.example.tvmon.data.model.Content
|
||||
import com.example.tvmon.data.model.Episode
|
||||
import com.example.tvmon.data.model.ContentDetail
|
||||
import com.example.tvmon.data.model.CastMember
|
||||
import com.example.tvmon.data.repository.WatchHistoryRepository
|
||||
import com.example.tvmon.data.scraper.TvmonScraper
|
||||
import com.example.tvmon.ui.playback.PlaybackActivity
|
||||
import com.example.tvmon.ui.presenter.EpisodePresenter
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.ext.android.inject
|
||||
|
||||
class DetailsFragment : DetailsSupportFragment() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TVMON_DETAIL"
|
||||
private const val ARG_CONTENT = "content"
|
||||
private const val ACTION_PLAY = 1L
|
||||
private const val ACTION_BOOKMARK = 2L
|
||||
private const val ACTION_REFRESH = 3L
|
||||
|
||||
fun newInstance(content: Content): DetailsFragment {
|
||||
val fragment = DetailsFragment()
|
||||
val args = Bundle()
|
||||
args.putParcelable(ARG_CONTENT, content)
|
||||
fragment.arguments = args
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
|
||||
private val scraper: TvmonScraper by inject()
|
||||
private val watchHistoryRepository: WatchHistoryRepository by inject()
|
||||
private lateinit var content: Content
|
||||
private lateinit var rowsAdapter: ArrayObjectAdapter
|
||||
private var cachedDetail: ContentDetail? = null
|
||||
private var isDataLoaded = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Log.w(TAG, "=== DetailsFragment onCreate ===")
|
||||
|
||||
content = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||
requireArguments().getParcelable(ARG_CONTENT, Content::class.java)!!
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
requireArguments().getParcelable(ARG_CONTENT)!!
|
||||
}
|
||||
|
||||
Log.w(TAG, "Content: ${content.title}, url=${content.url}")
|
||||
setupDetails()
|
||||
loadDetailInfo()
|
||||
|
||||
onItemViewClickedListener = ItemViewClickedListener()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (isDataLoaded && cachedDetail != null) {
|
||||
refreshDetailInfo()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupDetails() {
|
||||
Log.w(TAG, "setupDetails: Setting up details UI")
|
||||
val presenterSelector = ClassPresenterSelector()
|
||||
val detailsPresenter = FullWidthDetailsOverviewRowPresenter(DetailsDescriptionPresenter())
|
||||
detailsPresenter.actionsBackgroundColor = ContextCompat.getColor(requireContext(), R.color.primary)
|
||||
detailsPresenter.backgroundColor = ContextCompat.getColor(requireContext(), R.color.detail_background)
|
||||
presenterSelector.addClassPresenter(DetailsOverviewRow::class.java, detailsPresenter)
|
||||
presenterSelector.addClassPresenter(ListRow::class.java, ListRowPresenter())
|
||||
|
||||
rowsAdapter = ArrayObjectAdapter(presenterSelector)
|
||||
|
||||
val detailsOverview = DetailsOverviewRow(content).apply {
|
||||
imageDrawable = ContextCompat.getDrawable(requireContext(), R.drawable.default_background)
|
||||
|
||||
if (content.thumbnail.isNotBlank()) {
|
||||
Glide.with(requireContext())
|
||||
.load(content.thumbnail)
|
||||
.centerCrop()
|
||||
.error(R.drawable.default_background)
|
||||
.into(object : CustomTarget<android.graphics.drawable.Drawable>() {
|
||||
override fun onResourceReady(
|
||||
resource: android.graphics.drawable.Drawable,
|
||||
transition: Transition<in android.graphics.drawable.Drawable>?
|
||||
) {
|
||||
imageDrawable = resource
|
||||
}
|
||||
|
||||
override fun onLoadCleared(placeholder: android.graphics.drawable.Drawable?) {
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
val actionAdapter = ArrayObjectAdapter()
|
||||
actionAdapter.add(
|
||||
Action(
|
||||
ACTION_PLAY,
|
||||
getString(R.string.play),
|
||||
null
|
||||
)
|
||||
)
|
||||
actionAdapter.add(
|
||||
Action(
|
||||
ACTION_BOOKMARK,
|
||||
getString(R.string.bookmark),
|
||||
null
|
||||
)
|
||||
)
|
||||
actionAdapter.add(
|
||||
Action(
|
||||
ACTION_REFRESH,
|
||||
"새로고침",
|
||||
null
|
||||
)
|
||||
)
|
||||
detailsOverview.actionsAdapter = actionAdapter
|
||||
|
||||
rowsAdapter.add(detailsOverview)
|
||||
adapter = rowsAdapter
|
||||
|
||||
episodesRowIndex = 1
|
||||
Log.w(TAG, "setupDetails: Done, rowsAdapter size=${rowsAdapter.size()}, episodesRowIndex=$episodesRowIndex")
|
||||
}
|
||||
|
||||
private var episodesRowIndex = -1
|
||||
|
||||
private fun loadDetailInfo() {
|
||||
Log.w(TAG, "loadDetailInfo: Fetching detail for ${content.url}")
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val detail = scraper.getDetail(content.url, content.category)
|
||||
Log.w(TAG, "loadDetailInfo: success=${detail.success}, episodes=${detail.episodes.size}")
|
||||
|
||||
cachedDetail = detail
|
||||
|
||||
if (detail.success && detail.episodes.isNotEmpty()) {
|
||||
Log.w(TAG, "loadDetailInfo: Adding ${detail.episodes.size} episodes")
|
||||
|
||||
updateDetailsOverview(detail)
|
||||
addEpisodesRow(detail.episodes)
|
||||
|
||||
if (detail.cast.isNotEmpty()) {
|
||||
addCastRow(detail.cast)
|
||||
}
|
||||
|
||||
isDataLoaded = true
|
||||
} else if (detail.success && detail.episodes.isEmpty()) {
|
||||
Log.w(TAG, "loadDetailInfo: No episodes, opening playback directly")
|
||||
openPlayback(content.url, content.title)
|
||||
} else {
|
||||
Log.e(TAG, "loadDetailInfo: Failed to load detail, success=${detail.success}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "loadDetailInfo: ERROR - ${e.javaClass.simpleName}: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshDetailInfo() {
|
||||
Log.w(TAG, "refreshDetailInfo: Refreshing detail for ${content.url}")
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val detail = scraper.getDetail(content.url, content.category)
|
||||
Log.w(TAG, "refreshDetailInfo: success=${detail.success}, episodes=${detail.episodes.size}")
|
||||
|
||||
if (detail.success) {
|
||||
cachedDetail = detail
|
||||
|
||||
updateDetailsOverview(detail)
|
||||
|
||||
if (detail.episodes.isNotEmpty()) {
|
||||
updateEpisodesRow(detail.episodes)
|
||||
}
|
||||
|
||||
if (detail.cast.isNotEmpty()) {
|
||||
updateCastRow(detail.cast)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "refreshDetailInfo: ERROR - ${e.javaClass.simpleName}: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateDetailsOverview(detail: ContentDetail) {
|
||||
activity?.runOnUiThread {
|
||||
val detailsOverview = rowsAdapter.get(0) as? DetailsOverviewRow ?: return@runOnUiThread
|
||||
|
||||
val updatedContent = Content(
|
||||
id = content.id,
|
||||
title = detail.title.ifBlank { content.title },
|
||||
url = content.url,
|
||||
thumbnail = detail.poster.ifBlank { detail.thumbnail }.ifBlank { content.thumbnail },
|
||||
category = content.category
|
||||
)
|
||||
detailsOverview.item = updatedContent
|
||||
|
||||
if (detail.poster.isNotBlank() || detail.thumbnail.isNotBlank()) {
|
||||
val imageUrl = detail.poster.ifBlank { detail.thumbnail }
|
||||
Glide.with(requireContext())
|
||||
.load(imageUrl)
|
||||
.centerCrop()
|
||||
.error(R.drawable.default_background)
|
||||
.into(object : CustomTarget<android.graphics.drawable.Drawable>() {
|
||||
override fun onResourceReady(
|
||||
resource: android.graphics.drawable.Drawable,
|
||||
transition: Transition<in android.graphics.drawable.Drawable>?
|
||||
) {
|
||||
detailsOverview.imageDrawable = resource
|
||||
}
|
||||
override fun onLoadCleared(placeholder: android.graphics.drawable.Drawable?) {}
|
||||
})
|
||||
}
|
||||
|
||||
rowsAdapter.notifyArrayItemRangeChanged(0, 1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addEpisodesRow(episodes: List<Episode>) {
|
||||
val episodesRowAdapter = ArrayObjectAdapter(EpisodePresenter())
|
||||
episodes.forEachIndexed { index, episode ->
|
||||
Log.d(TAG, " Episode $index: ${episode.number} - ${episode.title}")
|
||||
episodesRowAdapter.add(episode)
|
||||
}
|
||||
|
||||
val header = HeaderItem(getString(R.string.episodes))
|
||||
val row = ListRow(header, episodesRowAdapter)
|
||||
|
||||
activity?.runOnUiThread {
|
||||
if (episodesRowIndex >= 0 && episodesRowIndex < rowsAdapter.size()) {
|
||||
rowsAdapter.add(episodesRowIndex, row)
|
||||
} else {
|
||||
rowsAdapter.add(row)
|
||||
}
|
||||
Log.w(TAG, "loadDetailInfo: Added episodes row at index $episodesRowIndex, rowsAdapter size=${rowsAdapter.size()}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateEpisodesRow(episodes: List<Episode>) {
|
||||
activity?.runOnUiThread {
|
||||
val existingRowIndex = findRowIndexByHeader(getString(R.string.episodes))
|
||||
|
||||
if (existingRowIndex >= 0) {
|
||||
rowsAdapter.removeItems(existingRowIndex, 1)
|
||||
}
|
||||
|
||||
val episodesRowAdapter = ArrayObjectAdapter(EpisodePresenter())
|
||||
episodes.forEach { episode ->
|
||||
episodesRowAdapter.add(episode)
|
||||
}
|
||||
|
||||
val header = HeaderItem(getString(R.string.episodes))
|
||||
val row = ListRow(header, episodesRowAdapter)
|
||||
|
||||
if (existingRowIndex >= 0 && existingRowIndex < rowsAdapter.size()) {
|
||||
rowsAdapter.add(existingRowIndex, row)
|
||||
} else {
|
||||
rowsAdapter.add(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addCastRow(cast: List<CastMember>) {
|
||||
if (cast.isEmpty()) return
|
||||
|
||||
val castAdapter = ArrayObjectAdapter(CastPresenter())
|
||||
cast.forEach { member ->
|
||||
castAdapter.add(member)
|
||||
}
|
||||
|
||||
val header = HeaderItem("출연진")
|
||||
val row = ListRow(header, castAdapter)
|
||||
|
||||
activity?.runOnUiThread {
|
||||
rowsAdapter.add(row)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCastRow(cast: List<CastMember>) {
|
||||
activity?.runOnUiThread {
|
||||
val existingRowIndex = findRowIndexByHeader("출연진")
|
||||
|
||||
if (existingRowIndex >= 0) {
|
||||
rowsAdapter.removeItems(existingRowIndex, 1)
|
||||
}
|
||||
|
||||
if (cast.isNotEmpty()) {
|
||||
addCastRow(cast)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun findRowIndexByHeader(headerText: String): Int {
|
||||
for (i in 0 until rowsAdapter.size()) {
|
||||
val row = rowsAdapter.get(i) as? ListRow ?: continue
|
||||
if (row.headerItem.name == headerText) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private inner class ItemViewClickedListener : OnItemViewClickedListener {
|
||||
override fun onItemClicked(
|
||||
itemViewHolder: Presenter.ViewHolder?,
|
||||
item: Any?,
|
||||
rowViewHolder: RowPresenter.ViewHolder?,
|
||||
row: Row?
|
||||
) {
|
||||
Log.w(TAG, "onItemClicked: item=$item")
|
||||
when (item) {
|
||||
is Action -> {
|
||||
when (item.id) {
|
||||
ACTION_PLAY.toLong() -> {
|
||||
lifecycleScope.launch {
|
||||
val detail = cachedDetail ?: scraper.getDetail(content.url, content.category)
|
||||
if (detail.playUrl.isNotBlank()) {
|
||||
detail.episodes.firstOrNull()?.let { ep ->
|
||||
watchHistoryRepository.addToHistory(content, ep)
|
||||
}
|
||||
openPlayback(detail.playUrl, detail.title)
|
||||
} else {
|
||||
openPlayback(content.url, content.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
ACTION_BOOKMARK.toLong() -> {
|
||||
}
|
||||
ACTION_REFRESH.toLong() -> {
|
||||
refreshDetailInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
is Episode -> {
|
||||
lifecycleScope.launch {
|
||||
watchHistoryRepository.addToHistory(content, item)
|
||||
}
|
||||
openPlayback(item.url, item.title)
|
||||
}
|
||||
is CastMember -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openPlayback(url: String, title: String) {
|
||||
Log.w(TAG, "openPlayback: url=$url, title=$title")
|
||||
val intent = Intent(requireContext(), PlaybackActivity::class.java).apply {
|
||||
putExtra(PlaybackActivity.EXTRA_URL, url)
|
||||
putExtra(PlaybackActivity.EXTRA_TITLE, title)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private inner class DetailsDescriptionPresenter : AbstractDetailsDescriptionPresenter() {
|
||||
override fun onBindDescription(viewHolder: ViewHolder, item: Any) {
|
||||
val contentItem = item as Content
|
||||
viewHolder.title.text = cachedDetail?.title?.ifBlank { contentItem.title } ?: contentItem.title
|
||||
|
||||
val subtitle = buildString {
|
||||
cachedDetail?.let { detail ->
|
||||
if (detail.year.isNotBlank()) {
|
||||
append(detail.year)
|
||||
append("년")
|
||||
}
|
||||
if (detail.country.isNotBlank()) {
|
||||
if (isNotEmpty()) append(" | ")
|
||||
append(detail.country)
|
||||
}
|
||||
}
|
||||
}
|
||||
viewHolder.subtitle.text = subtitle
|
||||
|
||||
val body = buildString {
|
||||
cachedDetail?.let { detail ->
|
||||
if (detail.overview.isNotBlank()) {
|
||||
append(detail.overview)
|
||||
}
|
||||
}
|
||||
}
|
||||
viewHolder.body.text = body
|
||||
}
|
||||
}
|
||||
|
||||
private inner class CastPresenter : Presenter() {
|
||||
override fun onCreateViewHolder(parent: ViewGroup): Presenter.ViewHolder {
|
||||
val cardView = ImageCardView(parent.context).apply {
|
||||
isFocusable = true
|
||||
isFocusableInTouchMode = true
|
||||
setBackgroundColor(parent.context.getColor(R.color.default_background))
|
||||
}
|
||||
return ViewHolder(cardView)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(viewHolder: Presenter.ViewHolder, item: Any) {
|
||||
val castMember = item as CastMember
|
||||
val cardView = viewHolder.view as ImageCardView
|
||||
val res = cardView.context.resources
|
||||
|
||||
cardView.titleText = castMember.name
|
||||
cardView.contentText = if (castMember.character.isNotBlank()) castMember.character else ""
|
||||
|
||||
val width = res.getDimensionPixelSize(R.dimen.cast_card_width)
|
||||
val height = res.getDimensionPixelSize(R.dimen.cast_card_height)
|
||||
cardView.setMainImageDimensions(width, height)
|
||||
|
||||
if (castMember.profilePath.isNotBlank()) {
|
||||
Glide.with(cardView.context)
|
||||
.load(castMember.profilePath)
|
||||
.centerCrop()
|
||||
.error(R.drawable.default_background)
|
||||
.into(cardView.mainImageView)
|
||||
} else {
|
||||
cardView.mainImageView.setImageResource(R.drawable.default_background)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUnbindViewHolder(viewHolder: Presenter.ViewHolder) {
|
||||
val cardView = viewHolder.view as ImageCardView
|
||||
cardView.badgeImage = null
|
||||
cardView.mainImage = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.example.tvmon.ui.main
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.example.tvmon.R
|
||||
|
||||
class MainActivity : FragmentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
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
|
||||
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.WatchHistoryRepository
|
||||
import com.example.tvmon.data.scraper.TvmonScraper
|
||||
import com.example.tvmon.ui.detail.DetailsActivity
|
||||
import com.example.tvmon.ui.presenter.ContentCardPresenter
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.ext.android.inject
|
||||
|
||||
class MainFragment : BrowseSupportFragment(), OnItemViewClickedListener, OnItemViewSelectedListener {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TVMON_MAIN"
|
||||
private const val PRELOAD_THRESHOLD = 5 // 로딩 시작 위치
|
||||
}
|
||||
|
||||
private val scraper: TvmonScraper 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
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
Log.w(TAG, "=== MainFragment onStart ===")
|
||||
loadData()
|
||||
}
|
||||
|
||||
private fun setupUI() {
|
||||
Log.w(TAG, "setupUI: Setting up UI")
|
||||
headersState = HEADERS_ENABLED
|
||||
title = getString(R.string.browse_title)
|
||||
brandColor = ContextCompat.getColor(requireContext(), R.color.primary)
|
||||
searchAffordanceColor = ContextCompat.getColor(requireContext(), R.color.search_opaque)
|
||||
|
||||
adapter = rowsAdapter
|
||||
onItemViewClickedListener = this
|
||||
onItemViewSelectedListener = this
|
||||
|
||||
setOnSearchClickedListener {
|
||||
startActivity(Intent(requireContext(), com.example.tvmon.ui.search.SearchActivity::class.java))
|
||||
}
|
||||
Log.w(TAG, "setupUI: UI setup complete, adapter set")
|
||||
}
|
||||
|
||||
private fun setupEventListeners() {
|
||||
onItemViewClickedListener = this
|
||||
}
|
||||
|
||||
private fun handleRowSelection(position: Int) {
|
||||
if (position == currentSelectedRowIndex) return
|
||||
currentSelectedRowIndex = position
|
||||
|
||||
if (position >= 0 && position < rowsAdapter.size()) {
|
||||
val row = rowsAdapter.get(position) as? ListRow ?: return
|
||||
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) {
|
||||
loadNextPage(categoryKey, currentPage + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() }
|
||||
items.addAll(result.items)
|
||||
|
||||
val adapter = categoryRowAdapters[categoryKey]
|
||||
adapter?.let {
|
||||
result.items.forEach { item -> it.add(item) }
|
||||
}
|
||||
|
||||
val loadedPage = page
|
||||
categoryPages[categoryKey] = loadedPage
|
||||
Log.w(TAG, "Loaded page $page for $categoryKey, total items=${items.size}")
|
||||
|
||||
val currentMaxPage = categoryMaxPage[categoryKey] ?: 1
|
||||
if (loadedPage < currentMaxPage) {
|
||||
handler.postDelayed({
|
||||
loadNextPage(categoryKey, loadedPage + 1)
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
} 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 {
|
||||
try {
|
||||
loadCategories()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "FATAL ERROR in loadData", e)
|
||||
activity?.runOnUiThread {
|
||||
Toast.makeText(requireContext(), "데이터 로드 실패: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 ->
|
||||
Log.w(TAG, "Loading category: ${category.key} - ${category.name}")
|
||||
val success = loadCategoryRows(category)
|
||||
if (success) successCount++ else failCount++
|
||||
}
|
||||
|
||||
Log.w(TAG, "=== loadCategories COMPLETE: success=$successCount, fail=$failCount ===")
|
||||
Log.w(TAG, "rowsAdapter size: ${rowsAdapter.size()}")
|
||||
|
||||
activity?.runOnUiThread {
|
||||
if (rowsAdapter.size() == 0) {
|
||||
Toast.makeText(requireContext(), "카테고리를 불러오지 못했습니다", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadCategoryRows(category: 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()) {
|
||||
val listRowAdapter = ArrayObjectAdapter(ContentCardPresenter())
|
||||
result1.items.forEach { content ->
|
||||
Log.d(TAG, " Item: ${content.title} -> thumb: ${content.thumbnail}")
|
||||
listRowAdapter.add(content)
|
||||
}
|
||||
|
||||
val header = HeaderItem(category.name)
|
||||
val row = ListRow(header, listRowAdapter)
|
||||
|
||||
activity?.runOnUiThread {
|
||||
rowsAdapter.add(row)
|
||||
Log.w(TAG, "ADDED ROW: ${category.name} with ${result1.items.size} items")
|
||||
}
|
||||
|
||||
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}")
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "ERROR loading ${category.key}: ${e.javaClass.simpleName}: ${e.message}", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemClicked(
|
||||
itemViewHolder: Presenter.ViewHolder?,
|
||||
item: Any?,
|
||||
rowViewHolder: RowPresenter.ViewHolder?,
|
||||
row: Row?
|
||||
) {
|
||||
Log.w(TAG, "=== onItemClicked: item=$item, itemViewHolder=$itemViewHolder ===")
|
||||
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)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Unknown item type: ${item?.javaClass?.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemSelected(
|
||||
itemViewHolder: Presenter.ViewHolder?,
|
||||
item: Any?,
|
||||
rowViewHolder: RowPresenter.ViewHolder?,
|
||||
row: Row?
|
||||
) {
|
||||
if (row is ListRow) {
|
||||
val rowIndex = rowsAdapter.indexOf(row)
|
||||
if (rowIndex >= 0) {
|
||||
handleRowSelection(rowIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,692 @@
|
||||
package com.example.tvmon.ui.playback
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.webkit.ConsoleMessage
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.example.tvmon.R
|
||||
import java.io.ByteArrayInputStream
|
||||
|
||||
class PlaybackActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
const val EXTRA_URL = "extra_url"
|
||||
const val EXTRA_TITLE = "extra_title"
|
||||
private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
}
|
||||
|
||||
private lateinit var webView: WebView
|
||||
private lateinit var loadingOverlay: View
|
||||
private var isVideoPlaying = false
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
window.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_FULLSCREEN,
|
||||
WindowManager.LayoutParams.FLAG_FULLSCREEN
|
||||
)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
window.decorView.systemUiVisibility = (
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
)
|
||||
|
||||
setContentView(R.layout.activity_playback)
|
||||
|
||||
val url = intent.getStringExtra(EXTRA_URL) ?: return
|
||||
val title = intent.getStringExtra(EXTRA_TITLE) ?: ""
|
||||
|
||||
loadingOverlay = findViewById(R.id.loading_overlay)
|
||||
|
||||
if (title.isNotBlank()) {
|
||||
setTitle("$title - Tvmon")
|
||||
} else {
|
||||
setTitle("Tvmon")
|
||||
}
|
||||
|
||||
val existingWebView = findViewById<WebView>(R.id.webview)
|
||||
val parent = existingWebView.parent as android.view.ViewGroup
|
||||
val index = parent.indexOfChild(existingWebView)
|
||||
val layoutParams = existingWebView.layoutParams
|
||||
parent.removeView(existingWebView)
|
||||
|
||||
webView = object : WebView(this) {
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
android.util.Log.d("WebViewKey", "WebView.onKeyDown: keyCode=$keyCode")
|
||||
|
||||
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT ||
|
||||
keyCode == KeyEvent.KEYCODE_DPAD_RIGHT ||
|
||||
keyCode == KeyEvent.KEYCODE_DPAD_UP ||
|
||||
keyCode == KeyEvent.KEYCODE_DPAD_DOWN ||
|
||||
keyCode == KeyEvent.KEYCODE_BACK) {
|
||||
return false
|
||||
}
|
||||
return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
|
||||
val keyCode = event?.keyCode ?: return super.dispatchKeyEvent(event)
|
||||
android.util.Log.d("WebViewKey", "WebView.dispatchKeyEvent: keyCode=$keyCode, action=${event.action}")
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
override fun scrollTo(x: Int, y: Int) {}
|
||||
override fun scrollBy(x: Int, y: Int) {}
|
||||
}
|
||||
|
||||
webView.id = R.id.webview
|
||||
webView.layoutParams = layoutParams
|
||||
webView.isFocusable = true
|
||||
webView.isFocusableInTouchMode = true
|
||||
parent.addView(webView, index)
|
||||
|
||||
webView.setOnKeyListener { _, keyCode, event ->
|
||||
if (event.action == KeyEvent.ACTION_DOWN) {
|
||||
when (keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> {
|
||||
simulateCenterClick()
|
||||
toggleVideoPlayback()
|
||||
true
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> {
|
||||
seekVideo(-10000)
|
||||
android.util.Log.d("PlaybackActivity", "setOnKeyListener: LEFT")
|
||||
true
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> {
|
||||
seekVideo(10000)
|
||||
android.util.Log.d("PlaybackActivity", "setOnKeyListener: RIGHT")
|
||||
true
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_UP -> {
|
||||
adjustVolume(0.1f)
|
||||
android.util.Log.d("PlaybackActivity", "setOnKeyListener: UP")
|
||||
true
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> {
|
||||
adjustVolume(-0.1f)
|
||||
android.util.Log.d("PlaybackActivity", "setOnKeyListener: DOWN")
|
||||
true
|
||||
}
|
||||
KeyEvent.KEYCODE_BACK -> {
|
||||
if (webView.canGoBack()) {
|
||||
webView.goBack()
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
webView.requestFocus()
|
||||
setupWebView()
|
||||
webView.loadUrl(url)
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private fun setupWebView() {
|
||||
val webSettings: WebSettings = webView.settings
|
||||
|
||||
webSettings.javaScriptEnabled = true
|
||||
webSettings.domStorageEnabled = true
|
||||
webSettings.allowFileAccess = true
|
||||
webSettings.allowContentAccess = true
|
||||
webSettings.mediaPlaybackRequiresUserGesture = false
|
||||
webSettings.loadsImagesAutomatically = true
|
||||
webSettings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
webSettings.userAgentString = USER_AGENT
|
||||
webSettings.useWideViewPort = true
|
||||
webSettings.loadWithOverviewMode = true
|
||||
webSettings.cacheMode = WebSettings.LOAD_NO_CACHE
|
||||
webSettings.allowUniversalAccessFromFileURLs = true
|
||||
webSettings.allowFileAccessFromFileURLs = true
|
||||
|
||||
webView.addJavascriptInterface(object {
|
||||
@android.webkit.JavascriptInterface
|
||||
fun hideLoadingOverlay() {
|
||||
runOnUiThread {
|
||||
loadingOverlay.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
@android.webkit.JavascriptInterface
|
||||
fun isVideoPlaying() {
|
||||
webView.evaluateJavascript(
|
||||
"""
|
||||
(function() {
|
||||
var videos = document.querySelectorAll('video');
|
||||
var playing = false;
|
||||
videos.forEach(function(v) {
|
||||
if (!v.paused && v.currentTime > 0) {
|
||||
playing = true;
|
||||
}
|
||||
});
|
||||
return playing ? 'playing' : 'not_playing';
|
||||
})();
|
||||
""".trimIndent()
|
||||
) { result ->
|
||||
android.util.Log.i("PlaybackActivity", "Video playing status: $result")
|
||||
if (result == "\"playing\"") {
|
||||
runOnUiThread {
|
||||
loadingOverlay.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@android.webkit.JavascriptInterface
|
||||
fun onVideoPlay() {
|
||||
runOnUiThread {
|
||||
loadingOverlay.visibility = View.GONE
|
||||
android.util.Log.d("PlaybackActivity", "Video started playing")
|
||||
}
|
||||
}
|
||||
}, "Android")
|
||||
|
||||
val cookieManager = CookieManager.getInstance()
|
||||
cookieManager.setAcceptCookie(true)
|
||||
cookieManager.setAcceptThirdPartyCookies(webView, true)
|
||||
|
||||
webView.webChromeClient = object : WebChromeClient() {
|
||||
override fun onConsoleMessage(cm: ConsoleMessage?): Boolean {
|
||||
val message = cm?.message() ?: return false
|
||||
android.util.Log.d("WebViewConsole", "[$${cm.sourceId()}:${cm.lineNumber()}] $message")
|
||||
|
||||
if (message.contains("player") || message.contains("video") || message.contains(".m3u8") || message.contains(".mp4")) {
|
||||
android.util.Log.i("WebViewConsole", "Player info: $message")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPermissionRequest(request: android.webkit.PermissionRequest?) {
|
||||
android.util.Log.d("PlaybackActivity", "Permission request: ${request?.resources?.joinToString()}")
|
||||
request?.grant(request.resources)
|
||||
}
|
||||
}
|
||||
|
||||
webView.webViewClient = object : WebViewClient() {
|
||||
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
|
||||
val url = request?.url?.toString() ?: ""
|
||||
|
||||
if (url.contains(".css") || url.contains(".js") && url.contains("tvmon.site")) {
|
||||
try {
|
||||
val conn = java.net.URL(url).openConnection() as java.net.HttpURLConnection
|
||||
conn.requestMethod = "GET"
|
||||
conn.connectTimeout = 8000
|
||||
conn.readTimeout = 8000
|
||||
conn.setRequestProperty("User-Agent", USER_AGENT)
|
||||
conn.setRequestProperty("Referer", "https://tvmon.site/")
|
||||
|
||||
val contentType = conn.contentType ?: "text/plain"
|
||||
val encoding = conn.contentEncoding ?: "UTF-8"
|
||||
val inputStream = conn.inputStream
|
||||
val content = inputStream.bufferedReader().use { it.readText() }
|
||||
inputStream.close()
|
||||
|
||||
var modifiedContent = content
|
||||
|
||||
if (contentType.contains("text/html", true)) {
|
||||
modifiedContent = injectFullscreenStyles(content)
|
||||
}
|
||||
|
||||
return WebResourceResponse(
|
||||
contentType, encoding,
|
||||
200, "OK",
|
||||
mapOf("Access-Control-Allow-Origin" to "*"),
|
||||
ByteArrayInputStream(modifiedContent.toByteArray(charset(encoding)))
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("PlaybackActivity", "Intercept failed for $url: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
return super.shouldInterceptRequest(view, request)
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
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({
|
||||
injectEnhancedAutoPlayScript()
|
||||
android.util.Log.i("PlaybackActivity", "Enhanced AutoPlay script injected (first)")
|
||||
}, 800)
|
||||
|
||||
handler.postDelayed({
|
||||
injectEnhancedAutoPlayScript()
|
||||
android.util.Log.i("PlaybackActivity", "Enhanced AutoPlay script injected (second)")
|
||||
}, 2000)
|
||||
|
||||
handler.postDelayed({
|
||||
injectEnhancedAutoPlayScript()
|
||||
android.util.Log.i("PlaybackActivity", "Enhanced AutoPlay script injected (third)")
|
||||
}, 1500)
|
||||
|
||||
handler.postDelayed({
|
||||
runOnUiThread {
|
||||
loadingOverlay.visibility = View.GONE
|
||||
}
|
||||
}, 1500)
|
||||
}
|
||||
}
|
||||
|
||||
webView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||
}
|
||||
|
||||
private fun injectFullscreenStyles(html: String): String {
|
||||
val styleInjection = """
|
||||
<style>
|
||||
body { margin: 0 !important; padding: 0 !important; background: #000 !important; }
|
||||
#header_wrap, .header_mobile, #side_wrap, .banner_wrap2, .notice, .bo_v_nav,
|
||||
.bo_v_option, .bo_v_sns, .bo_v_com, #view_comment, .bo_v_btn, .banner-area,
|
||||
[class*="banner"], [id*="banner"], .bo_v_act, .bo_v_ex,
|
||||
.navbar, .sidebar, .footer, .comment, .related, .ads, .ad-banner,
|
||||
.header, #header, .gnb, #gnb, .snb, #snb { display: none !important; visibility: hidden !important; height: 0 !important; overflow: hidden !important; }
|
||||
#content_wrap, #body_wrap, #container_wrap { padding: 0 !important; margin: 0 !important; }
|
||||
.embed-container, .bo_v_mov, #playerArea {
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
z-index: 99999 !important;
|
||||
background: #000 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
iframe#view_iframe, iframe {
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
border: none !important;
|
||||
z-index: 99999 !important;
|
||||
}
|
||||
.iframe-loading { display: none !important; }
|
||||
</style>
|
||||
""".trimIndent()
|
||||
|
||||
return if (html.contains("</head>", true)) {
|
||||
html.replace("</head>", styleInjection + "</head>", ignoreCase = true)
|
||||
} else if (html.contains("<body", true)) {
|
||||
html.replace("<body", styleInjection + "<body", ignoreCase = true)
|
||||
} else {
|
||||
styleInjection + html
|
||||
}
|
||||
}
|
||||
|
||||
private fun injectFullscreenScript() {
|
||||
webView.evaluateJavascript(
|
||||
"""
|
||||
(function() {
|
||||
if (window._fullscreenInjected) return;
|
||||
window._fullscreenInjected = true;
|
||||
|
||||
var style = document.createElement('style');
|
||||
style.textContent = `
|
||||
body { margin: 0 !important; padding: 0 !important; background: #000 !important; overflow: hidden !important; }
|
||||
* { visibility: hidden !important; }
|
||||
video, video *,
|
||||
.bo_v_mov, .bo_v_mov *,
|
||||
.embed-container, .embed-container *,
|
||||
#playerArea, #playerArea *,
|
||||
[class*="player"], [class*="player"] *,
|
||||
[id*="player"], [id*="player"] *,
|
||||
.video-wrap, .video-wrap *,
|
||||
.player-wrap, .player-wrap *,
|
||||
iframe, iframe * { visibility: visible !important; }
|
||||
video {
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
z-index: 99999 !important;
|
||||
background: #000 !important;
|
||||
object-fit: contain !important;
|
||||
}
|
||||
.bo_v_mov, .embed-container, #playerArea,
|
||||
[class*="player"], [id*="player"], .video-wrap, .player-wrap {
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
z-index: 99999 !important;
|
||||
background: #000 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
iframe {
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
border: none !important;
|
||||
z-index: 99999 !important;
|
||||
background: #000 !important;
|
||||
}
|
||||
#header_wrap, .header_mobile, #side_wrap, .banner_wrap2, .notice, .bo_v_nav,
|
||||
.bo_v_option, .bo_v_sns, .bo_v_com, #view_comment, .bo_v_btn, .banner-area,
|
||||
[class*="banner"], [id*="banner"], .bo_v_act, .bo_v_ex,
|
||||
.navbar, .sidebar, .footer, .comment, .related, .ads, .ad-banner,
|
||||
.header, #header, .gnb, #gnb, .snb, #snb, .bo_v_info, .bo_v_title,
|
||||
.bo_v_cate, .bo_v_date, .bo_v_cnt, .bo_v_con, .bo_v_tit,
|
||||
.btn_login, .btn_side, .logo, .iframe-loading, .bo_v_top, .bo_v_info,
|
||||
.share, .relate, .reply, .good, .view-bottom {
|
||||
display: none !important;
|
||||
height: 0 !important;
|
||||
width: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
#content_wrap, #body_wrap, #container_wrap {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
* { box-sizing: border-box !important; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
var makePlayerFullscreen = function() {
|
||||
document.querySelectorAll('video').forEach(function(v) {
|
||||
v.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:99999;background:#000;object-fit:contain;';
|
||||
v.setAttribute('allowfullscreen', 'true');
|
||||
v.setAttribute('playsinline', 'true');
|
||||
v.setAttribute('webkit-playsinline', 'true');
|
||||
});
|
||||
|
||||
var selectors = ['.bo_v_mov', '.embed-container', '#playerArea', '[class*="player"]', '[id*="player"]', '.video-wrap', '.player-wrap'];
|
||||
selectors.forEach(function(sel) {
|
||||
document.querySelectorAll(sel).forEach(function(el) {
|
||||
el.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:99999;background:#000;margin:0;padding:0;';
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('iframe').forEach(function(iframe) {
|
||||
iframe.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;border:none;z-index:99999;background:#000;';
|
||||
iframe.setAttribute('allowfullscreen', 'true');
|
||||
iframe.setAttribute('allow', 'autoplay; fullscreen; encrypted-media; picture-in-picture; playsinline');
|
||||
});
|
||||
|
||||
document.body.style.cssText = 'margin:0;padding:0;background:#000;overflow:hidden;';
|
||||
|
||||
var hideSelectors = ['#header_wrap', '.header_mobile', '#side_wrap', '.banner_wrap2', '.notice', '.bo_v_nav', '.bo_v_option', '.bo_v_sns', '.bo_v_com', '#view_comment', '.bo_v_btn', '.banner-area', '[class*="banner"]', '[id*="banner"]', '.bo_v_act', '.bo_v_ex', '.navbar', '.sidebar', '.footer', '.comment', '.related', '.ads', '.ad-banner', '.header', '#header', '.gnb', '#gnb', '.snb', '#snb', '.bo_v_info', '.bo_v_title', '.bo_v_cate', '.bo_v_date', '.bo_v_cnt', '.bo_v_con', '.bo_v_tit', '.btn_login', '.btn_side', '.logo', '.iframe-loading'];
|
||||
hideSelectors.forEach(function(sel) {
|
||||
document.querySelectorAll(sel).forEach(function(el) {
|
||||
el.style.display = 'none';
|
||||
el.style.height = '0';
|
||||
el.style.overflow = 'hidden';
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
makePlayerFullscreen();
|
||||
setTimeout(makePlayerFullscreen, 500);
|
||||
setTimeout(makePlayerFullscreen, 1500);
|
||||
setTimeout(makePlayerFullscreen, 3000);
|
||||
setInterval(makePlayerFullscreen, 5000);
|
||||
})();
|
||||
""".trimIndent(),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
private fun injectEnhancedAutoPlayScript() {
|
||||
webView.evaluateJavascript(
|
||||
"""
|
||||
(function() {
|
||||
console.log('AutoPlay: Starting...');
|
||||
|
||||
var tryPlay = function(v) {
|
||||
console.log('AutoPlay: Trying to play video...');
|
||||
v.muted = true;
|
||||
v.setAttribute('playsinline', 'true');
|
||||
|
||||
v.play().then(function() {
|
||||
console.log('AutoPlay: Video play() succeeded');
|
||||
if (window.Android) window.Android.onVideoPlay();
|
||||
}).catch(function(e) {
|
||||
console.log('AutoPlay: Video play() failed: ' + e.message);
|
||||
});
|
||||
};
|
||||
|
||||
var initVideo = function(v) {
|
||||
v.muted = true;
|
||||
v.setAttribute('playsinline', 'true');
|
||||
v.setAttribute('webkit-playsinline', 'true');
|
||||
|
||||
v.addEventListener('canplaythrough', function() {
|
||||
console.log('AutoPlay: canplaythrough event fired');
|
||||
tryPlay(v);
|
||||
}, {once: true});
|
||||
|
||||
if (v.readyState >= 2) {
|
||||
tryPlay(v);
|
||||
}
|
||||
};
|
||||
|
||||
var videos = document.querySelectorAll('video');
|
||||
console.log('AutoPlay: Found ' + videos.length + ' videos');
|
||||
videos.forEach(initVideo);
|
||||
|
||||
var iframes = document.querySelectorAll('iframe');
|
||||
iframes.forEach(function(f) {
|
||||
try {
|
||||
var innerDoc = f.contentDocument || f.contentWindow.document;
|
||||
var innerVideos = innerDoc.querySelectorAll('video');
|
||||
innerVideos.forEach(initVideo);
|
||||
} catch(e) {
|
||||
console.log('AutoPlay: Cannot access iframe');
|
||||
}
|
||||
});
|
||||
})();
|
||||
""".trimIndent(),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
|
||||
val keyCode = event?.keyCode ?: return super.dispatchKeyEvent(event)
|
||||
val action = event.action
|
||||
|
||||
if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER)
|
||||
&& action == KeyEvent.ACTION_DOWN) {
|
||||
android.util.Log.d("PlaybackActivity", "OK button pressed, triggering enhanced autoplay")
|
||||
simulateCenterClick()
|
||||
injectEnhancedAutoPlayScript()
|
||||
toggleVideoPlayback()
|
||||
return true
|
||||
}
|
||||
|
||||
if (keyCode == KeyEvent.KEYCODE_BACK && action == KeyEvent.ACTION_DOWN) {
|
||||
if (webView.canGoBack()) {
|
||||
webView.goBack()
|
||||
return true
|
||||
}
|
||||
finish()
|
||||
return true
|
||||
}
|
||||
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
private fun handleDpadKey(keyCode: Int) {
|
||||
android.util.Log.d("PlaybackActivity", "handleDpadKey: $keyCode")
|
||||
when (keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> seekVideo(-10000)
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> seekVideo(10000)
|
||||
KeyEvent.KEYCODE_DPAD_UP -> adjustVolume(0.1f)
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> adjustVolume(-0.1f)
|
||||
}
|
||||
}
|
||||
|
||||
private fun adjustVolume(delta: Float) {
|
||||
webView.evaluateJavascript(
|
||||
"""
|
||||
(function() {
|
||||
var video = document.querySelector('video');
|
||||
if (video) {
|
||||
video.volume = Math.max(0, Math.min(1, video.volume + $delta));
|
||||
return video.volume;
|
||||
}
|
||||
return -1;
|
||||
})();
|
||||
""".trimIndent(),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
private fun toggleVideoPlayback() {
|
||||
webView.evaluateJavascript(
|
||||
"""
|
||||
(function() {
|
||||
var video = document.querySelector('video');
|
||||
if (video && video.duration > 0) {
|
||||
if (video.paused) {
|
||||
video.play();
|
||||
return 'played';
|
||||
} else {
|
||||
video.pause();
|
||||
return 'paused';
|
||||
}
|
||||
}
|
||||
|
||||
var iframes = document.querySelectorAll('iframe');
|
||||
for (var i = 0; i < iframes.length; i++) {
|
||||
try {
|
||||
iframes[i].contentWindow.postMessage({ type: 'toggle', action: 'playpause' }, '*');
|
||||
iframes[i].contentWindow.postMessage('toggle', '*');
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
var focused = document.activeElement;
|
||||
if (focused && focused !== document.body) {
|
||||
try { focused.click(); } catch(e) {}
|
||||
}
|
||||
|
||||
return 'sent';
|
||||
})();
|
||||
""".trimIndent()
|
||||
) { result ->
|
||||
android.util.Log.d("PlaybackActivity", "Toggle result: $result")
|
||||
}
|
||||
}
|
||||
|
||||
private fun seekVideo(offsetMs: Int) {
|
||||
val offsetSec = offsetMs / 1000.0
|
||||
webView.evaluateJavascript(
|
||||
"""
|
||||
(function() {
|
||||
var video = document.querySelector('video');
|
||||
if (video && video.duration > 0) {
|
||||
video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime + $offsetSec));
|
||||
return 'seeked';
|
||||
}
|
||||
|
||||
var iframes = document.querySelectorAll('iframe');
|
||||
for (var i = 0; i < iframes.length; i++) {
|
||||
try {
|
||||
iframes[i].contentWindow.postMessage({ type: 'seek', offset: $offsetSec }, '*');
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
return 'sent';
|
||||
})();
|
||||
""".trimIndent(),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
private fun simulateCenterClick() {
|
||||
runOnUiThread {
|
||||
val centerX = webView.width / 2f
|
||||
val centerY = webView.height / 2f
|
||||
val downTime = SystemClock.uptimeMillis()
|
||||
|
||||
val downEvent = MotionEvent.obtain(
|
||||
downTime, downTime,
|
||||
MotionEvent.ACTION_DOWN, centerX, centerY, 0
|
||||
)
|
||||
val upEvent = MotionEvent.obtain(
|
||||
downTime, downTime + 100,
|
||||
MotionEvent.ACTION_UP, centerX, centerY, 0
|
||||
)
|
||||
|
||||
webView.dispatchTouchEvent(downEvent)
|
||||
webView.dispatchTouchEvent(upEvent)
|
||||
|
||||
downEvent.recycle()
|
||||
upEvent.recycle()
|
||||
|
||||
android.util.Log.d("PlaybackActivity", "Center click simulated at ($centerX, $centerY)")
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onBackPressed() {
|
||||
if (::webView.isInitialized && webView.canGoBack()) {
|
||||
webView.goBack()
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
if (::webView.isInitialized) {
|
||||
webView.onPause()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (::webView.isInitialized) {
|
||||
webView.onResume()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
if (::webView.isInitialized) {
|
||||
webView.destroy()
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.example.tvmon.ui.presenter
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.leanback.widget.ImageCardView
|
||||
import androidx.leanback.widget.Presenter
|
||||
import com.bumptech.glide.Glide
|
||||
import com.example.tvmon.R
|
||||
import com.example.tvmon.data.model.Content
|
||||
import com.example.tvmon.data.model.Category
|
||||
|
||||
class ContentCardPresenter : Presenter() {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
|
||||
val cardView = ImageCardView(parent.context).apply {
|
||||
isFocusable = true
|
||||
isFocusableInTouchMode = true
|
||||
setBackgroundColor(parent.context.getColor(R.color.default_background))
|
||||
}
|
||||
return ViewHolder(cardView)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
|
||||
val content = item as Content
|
||||
val cardView = viewHolder.view as ImageCardView
|
||||
val res = cardView.context.resources
|
||||
|
||||
cardView.titleText = content.title
|
||||
cardView.contentText = null
|
||||
|
||||
val width = res.getDimensionPixelSize(R.dimen.card_width)
|
||||
val height = res.getDimensionPixelSize(R.dimen.card_height)
|
||||
cardView.setMainImageDimensions(width, height)
|
||||
|
||||
if (content.thumbnail.isNotBlank()) {
|
||||
Glide.with(cardView.context)
|
||||
.load(content.thumbnail)
|
||||
.error(R.drawable.default_background)
|
||||
.placeholder(R.drawable.default_background)
|
||||
.centerCrop()
|
||||
.into(cardView.mainImageView)
|
||||
} else {
|
||||
cardView.mainImageView.setImageResource(R.drawable.default_background)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUnbindViewHolder(viewHolder: ViewHolder) {
|
||||
val cardView = viewHolder.view as ImageCardView
|
||||
cardView.badgeImage = null
|
||||
cardView.mainImage = null
|
||||
}
|
||||
}
|
||||
|
||||
class CategoryCardPresenter : Presenter() {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
|
||||
val cardView = ImageCardView(parent.context).apply {
|
||||
isFocusable = true
|
||||
isFocusableInTouchMode = true
|
||||
setBackgroundColor(parent.context.getColor(R.color.category_background))
|
||||
}
|
||||
return ViewHolder(cardView)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
|
||||
val category = item as Category
|
||||
val cardView = viewHolder.view as ImageCardView
|
||||
val res = cardView.context.resources
|
||||
|
||||
cardView.titleText = category.name
|
||||
|
||||
val width = res.getDimensionPixelSize(R.dimen.category_card_width)
|
||||
val height = res.getDimensionPixelSize(R.dimen.category_card_height)
|
||||
cardView.setMainImageDimensions(width, height)
|
||||
|
||||
cardView.mainImageView.setImageResource(R.drawable.category_background)
|
||||
}
|
||||
|
||||
override fun onUnbindViewHolder(viewHolder: ViewHolder) {
|
||||
val cardView = viewHolder.view as ImageCardView
|
||||
cardView.mainImage = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.example.tvmon.ui.presenter
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.leanback.widget.ImageCardView
|
||||
import androidx.leanback.widget.Presenter
|
||||
import com.example.tvmon.R
|
||||
import com.example.tvmon.data.model.Episode
|
||||
|
||||
class EpisodePresenter : Presenter() {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
|
||||
val cardView = ImageCardView(parent.context).apply {
|
||||
isFocusable = true
|
||||
isFocusableInTouchMode = true
|
||||
setBackgroundColor(parent.context.getColor(R.color.default_background))
|
||||
}
|
||||
return ViewHolder(cardView)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
|
||||
val episode = item as Episode
|
||||
val cardView = viewHolder.view as ImageCardView
|
||||
val res = cardView.context.resources
|
||||
|
||||
cardView.titleText = episode.number
|
||||
cardView.contentText = episode.title
|
||||
|
||||
val width = res.getDimensionPixelSize(R.dimen.episode_card_width)
|
||||
val height = res.getDimensionPixelSize(R.dimen.episode_card_height)
|
||||
cardView.setMainImageDimensions(width, height)
|
||||
|
||||
cardView.mainImageView.setImageResource(R.drawable.episode_placeholder)
|
||||
}
|
||||
|
||||
override fun onUnbindViewHolder(viewHolder: ViewHolder) {
|
||||
val cardView = viewHolder.view as ImageCardView
|
||||
cardView.badgeImage = null
|
||||
cardView.mainImage = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.example.tvmon.ui.search
|
||||
|
||||
import android.app.SearchManager
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.example.tvmon.R
|
||||
import com.example.tvmon.data.model.Content
|
||||
import com.example.tvmon.data.scraper.TvmonScraper
|
||||
import com.example.tvmon.ui.detail.DetailsActivity
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.ext.android.inject
|
||||
|
||||
class SearchActivity : AppCompatActivity() {
|
||||
|
||||
private val scraper: TvmonScraper by inject()
|
||||
private lateinit var recyclerView: RecyclerView
|
||||
private lateinit var adapter: SearchResultsAdapter
|
||||
private lateinit var searchView: SearchView
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_search)
|
||||
|
||||
setupUI()
|
||||
|
||||
if (Intent.ACTION_SEARCH == intent.action) {
|
||||
val query = intent.getStringExtra(SearchManager.QUERY)
|
||||
if (!query.isNullOrBlank()) {
|
||||
search(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupUI() {
|
||||
searchView = findViewById(R.id.search_view)
|
||||
recyclerView = findViewById(R.id.search_results)
|
||||
|
||||
adapter = SearchResultsAdapter { content ->
|
||||
openDetail(content)
|
||||
}
|
||||
recyclerView.layoutManager = LinearLayoutManager(this)
|
||||
recyclerView.adapter = adapter
|
||||
|
||||
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||
if (!query.isNullOrBlank()) {
|
||||
search(query)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
searchView.requestFocus()
|
||||
}
|
||||
|
||||
private fun search(query: String) {
|
||||
lifecycleScope.launch {
|
||||
val result = scraper.search(query)
|
||||
if (result.success) {
|
||||
adapter.updateResults(result.results)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openDetail(content: Content) {
|
||||
val intent = Intent(this, DetailsActivity::class.java).apply {
|
||||
putExtra(DetailsActivity.EXTRA_CONTENT, content)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.example.tvmon.ui.search
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.example.tvmon.R
|
||||
import com.example.tvmon.data.model.Content
|
||||
|
||||
class SearchResultsAdapter(
|
||||
private val onItemClick: (Content) -> Unit
|
||||
) : RecyclerView.Adapter<SearchResultsAdapter.ViewHolder>() {
|
||||
|
||||
private var results = mutableListOf<Content>()
|
||||
|
||||
fun updateResults(newResults: List<Content>) {
|
||||
results.clear()
|
||||
results.addAll(newResults)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_search_result, parent, false)
|
||||
return ViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val content = results[position]
|
||||
holder.bind(content, onItemClick)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = results.size
|
||||
|
||||
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
private val thumbnailView: ImageView = view.findViewById(R.id.thumbnail)
|
||||
private val titleView: TextView = view.findViewById(R.id.title)
|
||||
private val categoryView: TextView = view.findViewById(R.id.category)
|
||||
|
||||
fun bind(content: Content, onClick: (Content) -> Unit) {
|
||||
titleView.text = content.title
|
||||
categoryView.text = content.category
|
||||
|
||||
if (content.thumbnail.isNotBlank()) {
|
||||
Glide.with(itemView.context)
|
||||
.load(content.thumbnail)
|
||||
.placeholder(R.drawable.default_background)
|
||||
.error(R.drawable.default_background)
|
||||
.centerCrop()
|
||||
.into(thumbnailView)
|
||||
} else {
|
||||
thumbnailView.setImageResource(R.drawable.default_background)
|
||||
}
|
||||
|
||||
itemView.setOnClickListener { onClick(content) }
|
||||
}
|
||||
}
|
||||
}
|
||||
37
tvmon-app/app/src/main/proguard-rules.pro
vendored
Normal file
37
tvmon-app/app/src/main/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
-keepattributes *Annotation*
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# OkHttp
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase
|
||||
|
||||
# Jsoup
|
||||
-keep class org.jsoup.** { *; }
|
||||
-dontwarn org.jsoup.**
|
||||
|
||||
# Glide
|
||||
-keep public class * implements com.bumptech.glide.module.GlideModule
|
||||
-keep class * extends com.bumptech.glide.module.AppGlideModule {
|
||||
<init>(...);
|
||||
}
|
||||
-keep public enum com.bumptech.glide.load.ImageHeaderParser$** {
|
||||
**[] $VALUES;
|
||||
public *;
|
||||
}
|
||||
-keep class com.bumptech.glide.load.data.ParcelFileDescriptorRewinder$InternalRewinder {
|
||||
*** rewind(android.os.ParcelFileDescriptor);
|
||||
}
|
||||
|
||||
# Kotlin Coroutines
|
||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||
|
||||
# Keep Parcelables
|
||||
-keepclassmembers class * implements android.os.Parcelable {
|
||||
static ** CREATOR;
|
||||
}
|
||||
|
||||
# Keep Models
|
||||
-keep class com.example.tvmon.data.model.** { *; }
|
||||
6
tvmon-app/app/src/main/res/drawable/app_banner.xml
Normal file
6
tvmon-app/app/src/main/res/drawable/app_banner.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#FF6B6B" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
49
tvmon-app/app/src/main/res/drawable/app_banner_vector.xml
Normal file
49
tvmon-app/app/src/main/res/drawable/app_banner_vector.xml
Normal file
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="320dp"
|
||||
android:height="180dp"
|
||||
android:viewportWidth="320"
|
||||
android:viewportHeight="180">
|
||||
|
||||
<!-- Background -->
|
||||
<path
|
||||
android:fillColor="#1F1F1F"
|
||||
android:pathData="M0,0h320v180h-320z"/>
|
||||
|
||||
<!-- Gradient Overlay -->
|
||||
<path
|
||||
android:fillColor="#80000000"
|
||||
android:pathData="M0,0h320v180h-320z"/>
|
||||
|
||||
<!-- TV Icon -->
|
||||
<group
|
||||
android:translateX="120"
|
||||
android:translateY="40">
|
||||
|
||||
<!-- TV Screen -->
|
||||
<path
|
||||
android:fillColor="#FF6B6B"
|
||||
android:pathData="M0,0h80v60h-80z"/>
|
||||
|
||||
<!-- TV Stand -->
|
||||
<path
|
||||
android:fillColor="#FF6B6B"
|
||||
android:pathData="M20,65h40v5h-40z"/>
|
||||
<path
|
||||
android:fillColor="#FF6B6B"
|
||||
android:pathData="M10,70h60v3h-60z"/>
|
||||
|
||||
<!-- Play Icon -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M30,18l25,17l-25,17z"/>
|
||||
</group>
|
||||
|
||||
<!-- App Name -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M120,130h80v4h-80z"/>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M140,136h40v3h-40z"/>
|
||||
</vector>
|
||||
5
tvmon-app/app/src/main/res/drawable/app_logo.xml
Normal file
5
tvmon-app/app/src/main/res/drawable/app_logo.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#FF6B6B" />
|
||||
</shape>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#3A3A3A" />
|
||||
</shape>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#282828" />
|
||||
</shape>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#4A4A4A" />
|
||||
</shape>
|
||||
@@ -0,0 +1,52 @@
|
||||
<?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">
|
||||
|
||||
<!-- Background Circle -->
|
||||
<path
|
||||
android:fillColor="#FF6B6B"
|
||||
android:pathData="M54,54m-40,0a40,40 0,1 1,80 0a40,40 0,1 1,-80 0"/>
|
||||
|
||||
<!-- TV Screen -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M34,38h40v28h-40z"/>
|
||||
|
||||
<!-- TV Stand -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M44,70h20v4h-20z"/>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M40,74h28v2h-28z"/>
|
||||
|
||||
<!-- Play Button -->
|
||||
<path
|
||||
android:fillColor="#FF6B6B"
|
||||
android:pathData="M50,44l12,8l-12,8z"/>
|
||||
|
||||
<!-- tvmon Text -->
|
||||
<!-- Letter 't' -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M38,82h4v-2h-4v-2h6v10h-2v-6h-4z"/>
|
||||
<!-- Letter 'v' -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M45,78h2l2,6l2,-6h2l-3,8h-2z"/>
|
||||
<!-- Letter 'm' -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M54,86v-8h2v1c0.5,-0.7 1.2,-1 2,-1c0.9,0 1.6,0.4 2,1.1c0.5,-0.7 1.3,-1.1 2.2,-1.1c1.6,0 2.8,1.2 2.8,2.8v5.2h-2v-5c0,-0.9 -0.7,-1.5 -1.5,-1.5c-0.9,0 -1.5,0.6 -1.5,1.5v5h-2v-5c0,-0.9 -0.7,-1.5 -1.5,-1.5c-0.9,0 -1.5,0.6 -1.5,1.5v5z"/>
|
||||
<!-- Letter 'o' -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M66,82c0,-2.2 1.8,-4 4,-4s4,1.8 4,4s-1.8,4 -4,4s-4,-1.8 -4,-4zm2,0c0,1.1 0.9,2 2,2s2,-0.9 2,-2s-0.9,-2 -2,-2s-2,0.9 -2,2z"/>
|
||||
<!-- Letter 'n' -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M76,86v-8h2v1c0.6,-0.7 1.4,-1 2.3,-1c1.9,0 3.2,1.3 3.2,3.2v4.8h-2v-4.5c0,-1 -0.7,-1.7 -1.7,-1.7c-1,0 -1.8,0.7 -1.8,1.7v4.5z"/>
|
||||
</vector>
|
||||
5
tvmon-app/app/src/main/res/layout/activity_details.xml
Normal file
5
tvmon-app/app/src/main/res/layout/activity_details.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/details_fragment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
11
tvmon-app/app/src/main/res/layout/activity_main.xml
Normal file
11
tvmon-app/app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<fragment
|
||||
android:id="@+id/main_browse_fragment"
|
||||
android:name="com.example.tvmon.ui.main.MainFragment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</FrameLayout>
|
||||
31
tvmon-app/app/src/main/res/layout/activity_playback.xml
Normal file
31
tvmon-app/app/src/main/res/layout/activity_playback.xml
Normal file
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#000000">
|
||||
|
||||
<WebView
|
||||
android:id="@+id/webview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/loading_overlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#000000"
|
||||
android:visibility="visible">
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress_bar"
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="80dp"
|
||||
android:layout_gravity="center"
|
||||
android:indeterminate="true"
|
||||
android:indeterminateTint="#FFFFFF" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
</FrameLayout>
|
||||
20
tvmon-app/app/src/main/res/layout/activity_search.xml
Normal file
20
tvmon-app/app/src/main/res/layout/activity_search.xml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<androidx.appcompat.widget.SearchView
|
||||
android:id="@+id/search_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:iconifiedByDefault="false"
|
||||
android:queryHint="@string/search_hint" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/search_results"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginTop="16dp" />
|
||||
</LinearLayout>
|
||||
38
tvmon-app/app/src/main/res/layout/item_search_result.xml
Normal file
38
tvmon-app/app/src/main/res/layout/item_search_result.xml
Normal file
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="8dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/thumbnail"
|
||||
android:layout_width="120dp"
|
||||
android:layout_height="80dp"
|
||||
android:scaleType="centerCrop"
|
||||
android:src="@drawable/default_background" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/category"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textColor="@android:color/darker_gray"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -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="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -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="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
10
tvmon-app/app/src/main/res/values/colors.xml
Normal file
10
tvmon-app/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="primary">#1F1F1F</color>
|
||||
<color name="primary_dark">#000000</color>
|
||||
<color name="accent">#FF6B6B</color>
|
||||
<color name="default_background">#282828</color>
|
||||
<color name="category_background">#3A3A3A</color>
|
||||
<color name="detail_background">#1F1F1F</color>
|
||||
<color name="search_opaque">#AAFFFFFF</color>
|
||||
</resources>
|
||||
14
tvmon-app/app/src/main/res/values/dimens.xml
Normal file
14
tvmon-app/app/src/main/res/values/dimens.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<dimen name="card_width">180dp</dimen>
|
||||
<dimen name="card_height">240dp</dimen>
|
||||
|
||||
<dimen name="category_card_width">240dp</dimen>
|
||||
<dimen name="category_card_height">160dp</dimen>
|
||||
|
||||
<dimen name="episode_card_width">200dp</dimen>
|
||||
<dimen name="episode_card_height">120dp</dimen>
|
||||
|
||||
<dimen name="cast_card_width">120dp</dimen>
|
||||
<dimen name="cast_card_height">160dp</dimen>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1F1F1F</color>
|
||||
</resources>
|
||||
11
tvmon-app/app/src/main/res/values/strings.xml
Normal file
11
tvmon-app/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Tvmon</string>
|
||||
<string name="browse_title">Tvmon - TV 다시보기</string>
|
||||
<string name="search_title">검색</string>
|
||||
<string name="search_hint">영화, 드라마, 예능 검색…</string>
|
||||
<string name="play">재생</string>
|
||||
<string name="bookmark">북마크</string>
|
||||
<string name="episodes">에피소드</string>
|
||||
<string name="continue_watching">이어보기</string>
|
||||
</resources>
|
||||
22
tvmon-app/app/src/main/res/values/themes.xml
Normal file
22
tvmon-app/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Tvmon" parent="Theme.Leanback">
|
||||
<item name="android:colorPrimary">@color/primary</item>
|
||||
<item name="android:colorPrimaryDark">@color/primary_dark</item>
|
||||
<item name="android:colorAccent">@color/accent</item>
|
||||
<item name="android:windowBackground">@color/default_background</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Tvmon.Playback" parent="Theme.Leanback">
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Tvmon.Search" parent="Theme.AppCompat.NoActionBar">
|
||||
<item name="android:colorPrimary">@color/primary</item>
|
||||
<item name="android:colorPrimaryDark">@color/primary_dark</item>
|
||||
<item name="android:colorAccent">@color/accent</item>
|
||||
<item name="android:windowBackground">@color/default_background</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
5
tvmon-app/app/src/main/res/xml/searchable.xml
Normal file
5
tvmon-app/app/src/main/res/xml/searchable.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<searchable xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:label="@string/search_title"
|
||||
android:hint="@string/search_hint"
|
||||
android:voiceSearchMode="showVoiceSearchButton|launchRecognizer" />
|
||||
21
tvmon-app/build.gradle
Normal file
21
tvmon-app/build.gradle
Normal file
@@ -0,0 +1,21 @@
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.2.2'
|
||||
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22'
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
4
tvmon-app/gradle.properties
Normal file
4
tvmon-app/gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
BIN
tvmon-app/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
tvmon-app/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
tvmon-app/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
tvmon-app/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
14
tvmon-app/gradlew
vendored
Executable file
14
tvmon-app/gradlew
vendored
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
|
||||
APP_BASE_NAME=$(basename "$0")
|
||||
APP_HOME=$(dirname "$(readlink -f "$0")")
|
||||
|
||||
if [ -n "$JAVA_HOME" ]; then
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
else
|
||||
JAVACMD="java"
|
||||
fi
|
||||
|
||||
CLASSPATH="$APP_HOME/gradle/wrapper/gradle-wrapper.jar"
|
||||
|
||||
exec "$JAVACMD" -Xmx2048m -Dfile.encoding=UTF-8 -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
|
||||
2
tvmon-app/settings.gradle
Normal file
2
tvmon-app/settings.gradle
Normal file
@@ -0,0 +1,2 @@
|
||||
rootProject.name = "Tvmon"
|
||||
include(":app")
|
||||
557
tvmon_scraper.py
Normal file
557
tvmon_scraper.py
Normal file
@@ -0,0 +1,557 @@
|
||||
"""
|
||||
티비몬 (tvmon.site) 스크래퍼
|
||||
카테고리: 영화, 한국영화, 드라마, 예능, 시사/다큐, 해외드라마, 해외(예능/다큐), [극장판] 애니메이션, 일반 애니메이션
|
||||
|
||||
WebView 기반 재생을 위한 스크래퍼 - 복잡한 MP4 파싱 없이 웹페이지 URL만 제공
|
||||
"""
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from urllib.parse import urljoin, quote
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
BASE_URL = "https://tvmon.site"
|
||||
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
HEADERS = {
|
||||
"User-Agent": USER_AGENT,
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
"Referer": BASE_URL,
|
||||
}
|
||||
|
||||
# 카테고리 매핑
|
||||
CATEGORIES = {
|
||||
"movie": {"name": "영화", "path": "/movie"},
|
||||
"kor_movie": {"name": "한국영화", "path": "/kor_movie"},
|
||||
"drama": {"name": "드라마", "path": "/drama"},
|
||||
"ent": {"name": "예능프로그램", "path": "/ent"},
|
||||
"sisa": {"name": "시사/다큐", "path": "/sisa"},
|
||||
"world": {"name": "해외드라마", "path": "/world"},
|
||||
"ott_ent": {"name": "해외(예능/다큐)", "path": "/ott_ent"},
|
||||
"ani_movie": {"name": "[극장판] 애니메이션", "path": "/ani_movie"},
|
||||
"animation": {"name": "일반 애니메이션", "path": "/animation"},
|
||||
}
|
||||
|
||||
|
||||
class TvmonScraper:
|
||||
def __init__(self):
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(HEADERS)
|
||||
|
||||
def _get(self, url: str, timeout: int = 15) -> Optional[requests.Response]:
|
||||
"""GET 요청 with 재시도"""
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
resp = self.session.get(url, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
return resp
|
||||
except requests.RequestException as e:
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(1)
|
||||
else:
|
||||
print(f"❌ 요청 실패: {url} - {e}")
|
||||
return None
|
||||
return None
|
||||
|
||||
def get_homepage(self) -> Dict:
|
||||
"""홈페이지 크롤링 - 인기 순위 및 최신 콘텐츠"""
|
||||
print("=" * 60)
|
||||
print("1. 홈페이지 크롤링")
|
||||
print("=" * 60)
|
||||
|
||||
resp = self._get(f"{BASE_URL}/")
|
||||
if not resp:
|
||||
return {"success": False}
|
||||
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
result = {
|
||||
"success": True,
|
||||
"popular": [],
|
||||
"latest": {}
|
||||
}
|
||||
|
||||
# 인기 순위 (무료 다시보기 순위)
|
||||
popular_section = soup.select_one("h2:contains('무료 다시보기 순위')")
|
||||
if popular_section:
|
||||
popular_items = soup.select(".popular-section a, .ranking-item a")
|
||||
# 실제 구조에 맞게 조정 필요
|
||||
for item in soup.select("a[href*='/drama/'], a[href*='/movie/'], a[href*='/kor_movie/'], a[href*='/world/'], a[href*='/animation/']")[:10]:
|
||||
href = item.get("href", "")
|
||||
title = item.get_text(strip=True)
|
||||
if title and href:
|
||||
result["popular"].append({
|
||||
"title": title,
|
||||
"url": urljoin(BASE_URL, href),
|
||||
"category": self._get_category_from_url(href)
|
||||
})
|
||||
|
||||
# 최신 콘텐츠 섹션별
|
||||
sections = ["영화", "드라마", "예능", "해외드라마", "애니메이션"]
|
||||
for section in sections:
|
||||
result["latest"][section] = []
|
||||
|
||||
# 최신 영화
|
||||
movie_items = soup.select("a[href*='/movie/']")
|
||||
for item in movie_items[:6]:
|
||||
href = item.get("href", "")
|
||||
title = item.get_text(strip=True)
|
||||
img = item.select_one("img")
|
||||
img_url = img.get("src") or img.get("data-src", "") if img else ""
|
||||
|
||||
if title and href and "/movie/" in href:
|
||||
result["latest"]["영화"].append({
|
||||
"title": title,
|
||||
"url": urljoin(BASE_URL, href),
|
||||
"thumbnail": img_url
|
||||
})
|
||||
|
||||
# 최신 드라마
|
||||
drama_items = soup.select("a[href*='/drama/']")
|
||||
for item in drama_items[:6]:
|
||||
href = item.get("href", "")
|
||||
title = item.get_text(strip=True)
|
||||
img = item.select_one("img")
|
||||
img_url = img.get("src") or img.get("data-src", "") if img else ""
|
||||
|
||||
if title and href and "/drama/" in href:
|
||||
result["latest"]["드라마"].append({
|
||||
"title": title,
|
||||
"url": urljoin(BASE_URL, href),
|
||||
"thumbnail": img_url
|
||||
})
|
||||
|
||||
print(f" 인기 순위: {len(result['popular'])}개")
|
||||
for section, items in result["latest"].items():
|
||||
print(f" 최신 {section}: {len(items)}개")
|
||||
|
||||
return result
|
||||
|
||||
def get_category(self, category_key: str, page: int = 1) -> Dict:
|
||||
if category_key not in CATEGORIES:
|
||||
print(f"❌ 알 수 없는 카테고리: {category_key}")
|
||||
return {"success": False, "items": []}
|
||||
|
||||
cat_info = CATEGORIES[category_key]
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"2. 카테고리 크롤링: {cat_info['name']} (page={page})")
|
||||
print("=" * 60)
|
||||
|
||||
url = f"{BASE_URL}{cat_info['path']}" if page == 1 else f"{BASE_URL}{cat_info['path']}?page={page}"
|
||||
resp = self._get(url)
|
||||
if not resp:
|
||||
return {"success": False, "items": [], "category": cat_info['name']}
|
||||
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
items = []
|
||||
seen = set()
|
||||
|
||||
for link in soup.select(f"a[href*='/{category_key}/']"):
|
||||
href = link.get("href", "")
|
||||
if not href or f"/{category_key}/" not in href:
|
||||
continue
|
||||
|
||||
full_url = urljoin(BASE_URL, href)
|
||||
if full_url in seen:
|
||||
continue
|
||||
seen.add(full_url)
|
||||
|
||||
id_match = re.search(r'/(\d+)(?:/|$|\?)', href)
|
||||
content_id = id_match.group(1) if id_match else ""
|
||||
|
||||
img_tag = link.select_one("img")
|
||||
img_url = ""
|
||||
if img_tag:
|
||||
img_url = img_tag.get("src") or img_tag.get("data-src") or img_tag.get("data-original", "")
|
||||
|
||||
title = link.get_text(strip=True)
|
||||
if not title:
|
||||
title_tag = link.select_one(".title, .movie-title, .content-title")
|
||||
title = title_tag.get_text(strip=True) if title_tag else ""
|
||||
|
||||
if title:
|
||||
items.append({
|
||||
"id": content_id,
|
||||
"title": title,
|
||||
"url": full_url,
|
||||
"thumbnail": img_url,
|
||||
"category": category_key
|
||||
})
|
||||
|
||||
pagination = {"current": page, "max_page": 1}
|
||||
for page_link in soup.select("a[href*='/page/'], a[href*='page=']"):
|
||||
href = page_link.get("href", "")
|
||||
page_match = re.search(r'[/&]?page[=/](\d+)', href)
|
||||
if page_match:
|
||||
page_num = int(page_match.group(1))
|
||||
if page_num > pagination["max_page"]:
|
||||
pagination["max_page"] = page_num
|
||||
|
||||
print(f" 항목 수: {len(items)}")
|
||||
print(f" 페이지 정보: 현재 {page} / 최대 {pagination['max_page']}")
|
||||
|
||||
for item in items[:5]:
|
||||
print(f" - [{item['id']}] {item['title'][:50]}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"category": cat_info['name'],
|
||||
"items": items,
|
||||
"page": page,
|
||||
"pagination": pagination
|
||||
}
|
||||
|
||||
def get_detail(self, url_or_id: str, category: str = None) -> Dict:
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"3. 상세 페이지 크롤링")
|
||||
print("=" * 60)
|
||||
|
||||
if url_or_id.startswith("http"):
|
||||
url = url_or_id
|
||||
else:
|
||||
if category:
|
||||
url = f"{BASE_URL}/{category}/{url_or_id}"
|
||||
else:
|
||||
url = f"{BASE_URL}/movie/{url_or_id}"
|
||||
|
||||
resp = self._get(url)
|
||||
if not resp:
|
||||
return {"success": False}
|
||||
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
result = {
|
||||
"success": True,
|
||||
"url": url,
|
||||
"title": "",
|
||||
"thumbnail": "",
|
||||
"info": {},
|
||||
"episodes": [],
|
||||
"video_links": [],
|
||||
"play_url": ""
|
||||
}
|
||||
|
||||
title_tag = soup.select_one("h1, h2.title, .content-title, title")
|
||||
if title_tag:
|
||||
title_text = title_tag.get_text(strip=True)
|
||||
if " - " in title_text:
|
||||
result["title"] = title_text.split(" - ")[0].strip()
|
||||
else:
|
||||
result["title"] = title_text
|
||||
|
||||
og_image = soup.select_one('meta[property="og:image"]')
|
||||
if og_image:
|
||||
result["thumbnail"] = og_image.get("content", "")
|
||||
|
||||
seen_episode_ids = set()
|
||||
|
||||
episode_links = soup.select(f"a[href*='/{category or 'drama'}/']")
|
||||
|
||||
for link in episode_links:
|
||||
href = link.get("href", "")
|
||||
if not href:
|
||||
continue
|
||||
|
||||
full_url = urljoin(BASE_URL, href)
|
||||
|
||||
if full_url == url:
|
||||
continue
|
||||
|
||||
episode_id_match = re.search(r'/(\d+)/(\d+)', href)
|
||||
if not episode_id_match:
|
||||
continue
|
||||
|
||||
episode_id = episode_id_match.group(2)
|
||||
if episode_id in seen_episode_ids:
|
||||
continue
|
||||
seen_episode_ids.add(episode_id)
|
||||
|
||||
link_text = link.get_text(strip=True)
|
||||
|
||||
episode_num = re.search(r'(\d+화|\d+회|EP?\d+|제?\d+부?|\d+)', link_text, re.IGNORECASE)
|
||||
if episode_num:
|
||||
episode_title = f"{episode_num.group(1)}"
|
||||
else:
|
||||
episode_title = f"Episode {len(result['episodes']) + 1}"
|
||||
|
||||
result["episodes"].append({
|
||||
"number": episode_title,
|
||||
"title": link_text or episode_title,
|
||||
"url": full_url,
|
||||
"type": "webview"
|
||||
})
|
||||
|
||||
result["video_links"].append({
|
||||
"type": "play_page",
|
||||
"url": full_url,
|
||||
"title": link_text or episode_title
|
||||
})
|
||||
|
||||
if result["episodes"]:
|
||||
result["play_url"] = result["episodes"][0]["url"]
|
||||
|
||||
print(f" 제목: {result['title']}")
|
||||
print(f" 썸네일: {result['thumbnail'][:60]}...")
|
||||
print(f" 에피소드 수: {len(result['episodes'])}")
|
||||
print(f" 비디오 링크 수: {len(result['video_links'])}")
|
||||
|
||||
for ep in result["episodes"][:5]:
|
||||
print(f" - [{ep['number']}] {ep['title'][:40]}")
|
||||
|
||||
for vl in result["video_links"][:3]:
|
||||
print(f" - [{vl['type']}] {vl['url'][:80]}...")
|
||||
|
||||
return result
|
||||
|
||||
def search(self, keyword: str, page: int = 1) -> Dict:
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"4. 검색: '{keyword}' (page={page})")
|
||||
print("=" * 60)
|
||||
|
||||
encoded = quote(keyword)
|
||||
url = f"{BASE_URL}/search?stx={encoded}" if page == 1 else f"{BASE_URL}/search?stx={encoded}&page={page}"
|
||||
|
||||
resp = self._get(url)
|
||||
if not resp:
|
||||
return {"success": False, "keyword": keyword, "results": []}
|
||||
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
results = []
|
||||
seen = set()
|
||||
|
||||
nav_patterns = ['/login', '/logout', '/register', '/mypage', '/bbs/', '/menu', '/faq', '/privacy']
|
||||
|
||||
all_links = soup.select("a[href*='/movie/'], a[href*='/drama/'], a[href*='/ent/'], a[href*='/world/'], a[href*='/animation/'], a[href*='/kor_movie/']")
|
||||
|
||||
for link in all_links:
|
||||
href = link.get("href", "")
|
||||
if not href:
|
||||
continue
|
||||
|
||||
full_url = urljoin(BASE_URL, href)
|
||||
|
||||
if full_url in seen:
|
||||
continue
|
||||
if any(nav in full_url for nav in nav_patterns):
|
||||
continue
|
||||
|
||||
seen.add(full_url)
|
||||
|
||||
id_match = re.search(r'/(\d+)(?:/|$|\?)', href)
|
||||
content_id = id_match.group(1) if id_match else ""
|
||||
|
||||
category = self._get_category_from_url(href)
|
||||
|
||||
img_tag = link.select_one("img")
|
||||
img_url = ""
|
||||
if img_tag:
|
||||
img_url = img_tag.get("src") or img_tag.get("data-src", "")
|
||||
|
||||
title = link.get_text(strip=True)
|
||||
if not title:
|
||||
title_tag = link.select_one(".title, .movie-title")
|
||||
title = title_tag.get_text(strip=True) if title_tag else ""
|
||||
|
||||
if title:
|
||||
results.append({
|
||||
"id": content_id,
|
||||
"title": title,
|
||||
"url": full_url,
|
||||
"thumbnail": img_url,
|
||||
"category": category
|
||||
})
|
||||
|
||||
print(f" 검색 결과: {len(results)}개")
|
||||
for r in results[:10]:
|
||||
print(f" - [{r['category']}] {r['title'][:50]}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"keyword": keyword,
|
||||
"results": results,
|
||||
"page": page
|
||||
}
|
||||
|
||||
def get_popular(self) -> Dict:
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"5. 인기 영상 크롤링")
|
||||
print("=" * 60)
|
||||
|
||||
url = f"{BASE_URL}/popular"
|
||||
resp = self._get(url)
|
||||
if not resp:
|
||||
return {"success": False, "items": []}
|
||||
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
items = []
|
||||
seen = set()
|
||||
|
||||
nav_patterns = ['/login', '/logout', '/register', '/mypage', '/bbs/', '/menu', '/faq', '/privacy']
|
||||
|
||||
all_links = soup.select("a[href*='/movie/'], a[href*='/drama/'], a[href*='/ent/'], a[href*='/world/'], a[href*='/animation/'], a[href*='/kor_movie/']")
|
||||
|
||||
for link in all_links:
|
||||
href = link.get("href", "")
|
||||
if not href:
|
||||
continue
|
||||
|
||||
full_url = urljoin(BASE_URL, href)
|
||||
|
||||
if full_url in seen:
|
||||
continue
|
||||
if any(nav in full_url for nav in nav_patterns):
|
||||
continue
|
||||
|
||||
seen.add(full_url)
|
||||
|
||||
id_match = re.search(r'/(\d+)(?:/|$|\?)', href)
|
||||
content_id = id_match.group(1) if id_match else ""
|
||||
|
||||
category = self._get_category_from_url(href)
|
||||
|
||||
img_tag = link.select_one("img")
|
||||
img_url = ""
|
||||
if img_tag:
|
||||
img_url = img_tag.get("src") or img_tag.get("data-src", "")
|
||||
|
||||
title = link.get_text(strip=True)
|
||||
if not title:
|
||||
title_tag = link.select_one(".title, .movie-title")
|
||||
title = title_tag.get_text(strip=True) if title_tag else ""
|
||||
|
||||
if title:
|
||||
items.append({
|
||||
"id": content_id,
|
||||
"title": title,
|
||||
"url": full_url,
|
||||
"thumbnail": img_url,
|
||||
"category": category
|
||||
})
|
||||
|
||||
print(f" 인기 항목 수: {len(items)}")
|
||||
for item in items[:10]:
|
||||
print(f" - [{item['category']}] {item['title'][:50]}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"items": items
|
||||
}
|
||||
|
||||
def _get_category_from_url(self, url: str) -> str:
|
||||
"""URL에서 카테고리 추출"""
|
||||
for cat_key in CATEGORIES:
|
||||
if f"/{cat_key}/" in url:
|
||||
return cat_key
|
||||
return "unknown"
|
||||
|
||||
def _extract_id_from_url(self, url: str) -> str:
|
||||
"""URL에서 콘텐츠 ID 추출"""
|
||||
# 패턴: /category/12345 또는 /category/12345/67890
|
||||
match = re.search(r'/(\d+)(?:/|$|\?)', url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return ""
|
||||
|
||||
def _extract_episode_number(self, text: str) -> str:
|
||||
"""텍스트에서 회차 번호 추출"""
|
||||
match = re.search(r'(\d+화|\d+회|제?\d+부?|EP?\d+)', text, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return text
|
||||
|
||||
def _get_pagination(self, soup: BeautifulSoup) -> Dict:
|
||||
"""페이지네이션 정보 추출"""
|
||||
pagination = {"current": 1, "max_page": 1}
|
||||
|
||||
page_links = soup.select("a[href*='/page/']")
|
||||
for link in page_links:
|
||||
href = link.get("href", "")
|
||||
match = re.search(r'/page/(\d+)', href)
|
||||
if match:
|
||||
page_num = int(match.group(1))
|
||||
if page_num > pagination["max_page"]:
|
||||
pagination["max_page"] = page_num
|
||||
|
||||
return pagination
|
||||
|
||||
|
||||
def test_all_categories():
|
||||
"""모든 카테고리 테스트"""
|
||||
scraper = TvmonScraper()
|
||||
|
||||
print("\n🔍 tvmon.site 카테고리별 크롤링 테스트\n")
|
||||
|
||||
results = {}
|
||||
|
||||
# 홈페이지
|
||||
try:
|
||||
results["homepage"] = scraper.get_homepage()
|
||||
except Exception as e:
|
||||
print(f" ❌ 홈페이지 실패: {e}")
|
||||
results["homepage"] = {"success": False}
|
||||
|
||||
# 각 카테고리 테스트
|
||||
for cat_key in ["movie", "kor_movie", "drama", "ent", "sisa", "world", "ott_ent", "ani_movie", "animation"]:
|
||||
try:
|
||||
results[cat_key] = scraper.get_category(cat_key, page=1)
|
||||
except Exception as e:
|
||||
print(f" ❌ {cat_key} 실패: {e}")
|
||||
results[cat_key] = {"success": False}
|
||||
|
||||
# 검색 테스트
|
||||
try:
|
||||
results["search"] = scraper.search("사냥개들")
|
||||
except Exception as e:
|
||||
print(f" ❌ 검색 실패: {e}")
|
||||
results["search"] = {"success": False}
|
||||
|
||||
# 인기 영상
|
||||
try:
|
||||
results["popular"] = scraper.get_popular()
|
||||
except Exception as e:
|
||||
print(f" ❌ 인기 영상 실패: {e}")
|
||||
results["popular"] = {"success": False}
|
||||
|
||||
# 결과 요약
|
||||
print(f"\n{'=' * 60}")
|
||||
print("📊 테스트 결과 요약")
|
||||
print("=" * 60)
|
||||
|
||||
for name, result in results.items():
|
||||
status = "✅ PASS" if result.get("success") else "❌ FAIL"
|
||||
print(f" {status} - {name}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def test_detail_page():
|
||||
"""상세 페이지 테스트"""
|
||||
scraper = TvmonScraper()
|
||||
|
||||
print("\n🔍 상세 페이지 크롤링 테스트\n")
|
||||
|
||||
# 테스트할 URL들 (사용자가 제공한 URL 기반)
|
||||
test_urls = [
|
||||
"https://tvmon.site/drama/3781", # 사냥개들 시즌 2
|
||||
"https://tvmon.site/kor_movie/30314", # 휴민트
|
||||
"https://tvmon.site/world/19479", # 월린기기
|
||||
]
|
||||
|
||||
for url in test_urls:
|
||||
try:
|
||||
result = scraper.get_detail(url)
|
||||
print(f"\n{'=' * 40}")
|
||||
except Exception as e:
|
||||
print(f" ❌ 상세 페이지 실패 ({url}): {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 전체 테스트 실행
|
||||
test_all_categories()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
|
||||
# 상세 페이지 테스트
|
||||
test_detail_page()
|
||||
Reference in New Issue
Block a user