Fix episode parsing, add missing categories, improve search stability

- Add old_ent (추억의 예능) and old_drama (추억의 드라마) categories
- Fix episode parsing to prioritize #epListScroll selector
- Add pagination logic to fetch more episodes when 25+ episodes
- Fix extractSeriesUrl to include old_ent and old_drama
- Add crash protection in search with try-catch
- Add showSearchDialog to MainActivity for better search UX
- Fix SearchResultsAdapter to use Presenter pattern correctly
- Remove unused scale variable in SearchCardPresenter
This commit is contained in:
tvmon-dev
2026-04-16 09:54:02 +09:00
parent 453c0d3ec1
commit 0c8bc6252b
7 changed files with 492 additions and 253 deletions

View File

@@ -59,8 +59,7 @@
android:exported="true"
android:label="@string/search_title"
android:screenOrientation="landscape"
android:theme="@style/Theme.Tvmon.Search"
android:launchMode="singleTop">
android:theme="@style/Theme.Tvmon.Search">
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
</intent-filter>

View File

@@ -15,7 +15,7 @@ class TvmonScraper {
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(
val CATEGORIES = mapOf(
"popular" to Category("popular", "인기영상", "/popular"),
"movie" to Category("movie", "영화", "/movie"),
"kor_movie" to Category("kor_movie", "한국영화", "/kor_movie"),
@@ -25,7 +25,9 @@ class TvmonScraper {
"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")
"animation" to Category("animation", "일반 애니메이션", "/animation"),
"old_ent" to Category("old_ent", "추억의 예능", "/old_ent"),
"old_drama" to Category("old_drama", "추억의 드라마", "/old_drama")
)
private val NAV_PATTERNS = listOf("/login", "/logout", "/register", "/mypage", "/bbs/", "/menu", "/faq", "/privacy")
@@ -61,8 +63,8 @@ class TvmonScraper {
}
}
private fun extractSeriesUrl(url: String): String {
val pattern = Pattern.compile("(/(drama|movie|ent|world|animation|kor_movie|sisa|ott_ent|ani_movie)/\\d+)/\\d+")
private fun extractSeriesUrl(url: String): String {
val pattern = Pattern.compile("(/(drama|movie|ent|world|animation|kor_movie|sisa|ott_ent|ani_movie|old_ent|old_drama)/\\d+)/\\d+")
val matcher = pattern.matcher(url)
if (matcher.find()) {
return BASE_URL + matcher.group(1)
@@ -407,13 +409,19 @@ class TvmonScraper {
}
}
val episodes = mutableListOf<Episode>()
val episodes = mutableListOf<Episode>()
val videoLinks = mutableListOf<VideoLink>()
val seenEpisodeIds = mutableSetOf<String>()
val seriesId = seriesUrl.substringAfterLast("/").substringBefore("?")
val allEpisodeLinks = doc.select("a[href*='/$seriesId/'], a[href*='/${seriesId}/']")
// Parse episodes from #epListScroll first (ep-item class)
val epListScroll = doc.select("#epListScroll")
val allEpisodeLinks = if (epListScroll.isNotEmpty()) {
epListScroll.select("a.ep-item[href*='/$seriesId/']")
} else {
doc.select("a[href*='/$seriesId/'], a[href*='/${seriesId}/']")
}
var episodeIndex = 0
@@ -493,6 +501,62 @@ class TvmonScraper {
))
}
// If we have 25+ episodes, try to fetch more episodes from the series list page
if (episodes.size >= 25) {
val category = getCategoryFromUrl(seriesUrl)
val listPageUrl = "$BASE_URL/$category/$seriesId"
val listHtml = get(listPageUrl)
if (listHtml != null) {
val listDoc = Jsoup.parse(listHtml)
val listEpLinks = listDoc.select("a[href*='/$seriesId/']")
for (link in listEpLinks) {
val href = link.attr("href")
if (href.isBlank()) continue
val fullUrl = resolveUrl(href)
if (seenEpisodeIds.contains(fullUrl)) continue
val episodeIdMatch = Pattern.compile("/$seriesId/(\\d+)").matcher(href)
if (!episodeIdMatch.find()) continue
val episodeId = episodeIdMatch.group(1) ?: ""
if (episodeId in seenEpisodeIds) continue
seenEpisodeIds.add(episodeId)
val linkText = link.text().trim()
if (linkText.isBlank()) continue
if (linkText.contains("로그인") || linkText.contains("비밀번호") ||
linkText.contains("마이페이지") || linkText.contains("전체 목록")) {
continue
}
val cleanLinkText = linkText.replace("시청중", "").replace("NEW", "").trim()
val episodeNumMatch = Pattern.compile("(\\d+)\\s*화|(\\d+)\\s*회|EP\\.?(\\d+)|제\\s*(\\d+)\\s*부").matcher(cleanLinkText)
val episodeTitle = if (episodeNumMatch.find()) {
episodeNumMatch.group(1) ?: episodeNumMatch.group(2) ?: episodeNumMatch.group(3) ?: episodeNumMatch.group(4)
} else {
null
}
val finalNumber = episodeTitle ?: (episodes.size + 1).toString()
episodes.add(Episode(
number = finalNumber,
title = cleanLinkText.ifBlank { finalNumber },
url = fullUrl,
type = "webview",
date = ""
))
videoLinks.add(VideoLink(
type = "play_page",
url = fullUrl,
title = linkText
))
}
}
}
episodes.sortByDescending { episode ->
val numberStr = episode.number
val pattern = Pattern.compile("\\d+")

View File

@@ -1,6 +1,16 @@
package com.example.tvmon.ui.main
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import android.text.InputType
import android.view.Window
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.TextView
import androidx.fragment.app.FragmentActivity
import com.example.tvmon.R
@@ -10,4 +20,107 @@ class MainActivity : FragmentActivity() {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
fun showSearchDialog(onSearch: (String) -> Unit) {
val layout = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(60, 40, 60, 40)
setBackgroundColor(android.graphics.Color.parseColor("#1A1A1A"))
}
val titleText = TextView(this).apply {
text = "검색"
textSize = 28f
setTextColor(android.graphics.Color.WHITE)
setPadding(0, 0, 0, 30)
}
val editText = EditText(this).apply {
hint = "검색어를 입력하세요"
textSize = 22f
setTextColor(android.graphics.Color.WHITE)
setHintTextColor(android.graphics.Color.GRAY)
setBackgroundColor(android.graphics.Color.parseColor("#333333"))
setPadding(30, 25, 30, 25)
inputType = InputType.TYPE_CLASS_TEXT
imeOptions = EditorInfo.IME_ACTION_SEARCH
isFocusable = true
isFocusableInTouchMode = true
requestFocus()
}
val buttonLayout = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(0, 30, 0, 0)
gravity = android.view.Gravity.CENTER
}
val cancelBtn = Button(this).apply {
text = "취소"
textSize = 18f
setBackgroundColor(android.graphics.Color.parseColor("#555555"))
setTextColor(android.graphics.Color.WHITE)
setPadding(40, 20, 40, 20)
}
val searchBtn = Button(this).apply {
text = "검색"
textSize = 18f
setBackgroundColor(android.graphics.Color.parseColor("#E50914"))
setTextColor(android.graphics.Color.WHITE)
setPadding(40, 20, 40, 20)
}
buttonLayout.addView(cancelBtn)
buttonLayout.addView(searchBtn)
layout.addView(titleText)
layout.addView(editText)
layout.addView(buttonLayout)
val dialog = Dialog(this, android.R.style.Theme_Translucent_NoTitleBar)
dialog.requestWindowFeature(Window.FEATURE_NO_TITLE)
dialog.setContentView(layout)
dialog.window?.setLayout(
(resources.displayMetrics.widthPixels * 0.8).toInt(),
LinearLayout.LayoutParams.WRAP_CONTENT
)
dialog.window?.setBackgroundDrawableResource(android.R.color.black)
cancelBtn.setOnClickListener {
dialog.dismiss()
}
searchBtn.setOnClickListener {
val query = editText.text.toString().trim()
if (query.isNotEmpty()) {
onSearch(query)
dialog.dismiss()
}
}
editText.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_SEARCH || actionId == EditorInfo.IME_ACTION_DONE) {
val query = editText.text.toString().trim()
if (query.isNotEmpty()) {
onSearch(query)
dialog.dismiss()
}
true
} else {
false
}
}
dialog.setOnShowListener {
editText.post {
editText.requestFocus()
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
}
}
dialog.show()
}
}

View File

@@ -57,8 +57,14 @@ class MainFragment : BrowseSupportFragment(), OnItemViewClickedListener, OnItemV
onItemViewClickedListener = this
onItemViewSelectedListener = this
setOnSearchClickedListener {
startActivity(Intent(requireContext(), com.example.tvmon.ui.search.SearchActivity::class.java))
setOnSearchClickedListener {
val activity = requireActivity() as? MainActivity
activity?.showSearchDialog { query ->
val intent = Intent(requireContext(), com.example.tvmon.ui.search.SearchActivity::class.java).apply {
putExtra("initial_query", query)
}
startActivity(intent)
}
}
Log.w(TAG, "setupUI: UI setup complete, adapter set")
}

View File

@@ -1,5 +1,6 @@
package com.example.tvmon.ui.presenter
import android.view.View
import android.view.ViewGroup
import androidx.leanback.widget.ImageCardView
import androidx.leanback.widget.Presenter
@@ -50,6 +51,69 @@ class ContentCardPresenter : Presenter() {
}
}
class SearchCardPresenter : 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))
cardType = ImageCardView.CARD_TYPE_INFO_UNDER
infoVisibility = View.VISIBLE
}
cardView.setOnFocusChangeListener { v, hasFocus ->
if (hasFocus) {
v.animate()
.scaleX(1.1f)
.scaleY(1.1f)
.translationZ(10f)
.setDuration(100)
.start()
} else {
v.animate()
.scaleX(1.0f)
.scaleY(1.0f)
.translationZ(0f)
.setDuration(100)
.start()
}
}
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 = content.category
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 {

View File

@@ -2,15 +2,17 @@ package com.example.tvmon.ui.search
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.GridLayoutManager
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 com.example.tvmon.ui.presenter.SearchCardPresenter
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
@@ -18,6 +20,7 @@ class SearchActivity : AppCompatActivity() {
companion object {
private const val TAG = "TVMON_SEARCH"
private const val NUM_COLUMNS = 4
}
private val scraper: TvmonScraper by inject()
@@ -30,6 +33,11 @@ class SearchActivity : AppCompatActivity() {
setContentView(R.layout.activity_search)
setupUI()
val initialQuery = intent.getStringExtra("initial_query")
if (!initialQuery.isNullOrBlank()) {
searchView.setQuery(initialQuery, true)
}
}
private fun setupUI() {
@@ -40,8 +48,9 @@ class SearchActivity : AppCompatActivity() {
openDetail(content)
}
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.layoutManager = GridLayoutManager(this, NUM_COLUMNS)
recyclerView.adapter = adapter
recyclerView.setHasFixedSize(true)
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
@@ -68,10 +77,18 @@ class SearchActivity : AppCompatActivity() {
private fun search(query: String) {
lifecycleScope.launch {
try {
val result = scraper.search(query)
runOnUiThread {
if (result.success) {
if (result.success && result.results.isNotEmpty()) {
adapter.updateResults(result.results)
} else {
adapter.updateResults(emptyList())
}
}
} catch (e: Exception) {
runOnUiThread {
adapter.updateResults(emptyList())
}
}
}

View File

@@ -1,19 +1,16 @@
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.leanback.widget.Presenter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.example.tvmon.R
import com.example.tvmon.data.model.Content
import com.example.tvmon.ui.presenter.SearchCardPresenter
class SearchResultsAdapter(
private val onItemClick: (Content) -> Unit
) : RecyclerView.Adapter<SearchResultsAdapter.ViewHolder>() {
private val presenter = SearchCardPresenter()
private var results = mutableListOf<Content>()
fun updateResults(newResults: List<Content>) {
@@ -23,39 +20,18 @@ class SearchResultsAdapter(
}
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)
return ViewHolder(presenter.onCreateViewHolder(parent))
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val content = results[position]
holder.bind(content, onItemClick)
presenter.onBindViewHolder(holder.viewHolder, content)
holder.viewHolder.view.setOnClickListener { onItemClick(content) }
}
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) }
}
}
class ViewHolder(val viewHolder: Presenter.ViewHolder) : RecyclerView.ViewHolder(viewHolder.view)
}