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:exported="true"
android:label="@string/search_title" android:label="@string/search_title"
android:screenOrientation="landscape" android:screenOrientation="landscape"
android:theme="@style/Theme.Tvmon.Search" android:theme="@style/Theme.Tvmon.Search">
android:launchMode="singleTop">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEARCH" /> <action android:name="android.intent.action.SEARCH" />
</intent-filter> </intent-filter>

View File

@@ -25,7 +25,9 @@ class TvmonScraper {
"world" to Category("world", "해외드라마", "/world"), "world" to Category("world", "해외드라마", "/world"),
"ott_ent" to Category("ott_ent", "해외 (예능/다큐)", "/ott_ent"), "ott_ent" to Category("ott_ent", "해외 (예능/다큐)", "/ott_ent"),
"ani_movie" to Category("ani_movie", "[극장판] 애니메이션", "/ani_movie"), "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") private val NAV_PATTERNS = listOf("/login", "/logout", "/register", "/mypage", "/bbs/", "/menu", "/faq", "/privacy")
@@ -62,7 +64,7 @@ class TvmonScraper {
} }
private fun extractSeriesUrl(url: String): String { 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 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) val matcher = pattern.matcher(url)
if (matcher.find()) { if (matcher.find()) {
return BASE_URL + matcher.group(1) return BASE_URL + matcher.group(1)
@@ -413,7 +415,13 @@ class TvmonScraper {
val seriesId = seriesUrl.substringAfterLast("/").substringBefore("?") 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 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 -> episodes.sortByDescending { episode ->
val numberStr = episode.number val numberStr = episode.number
val pattern = Pattern.compile("\\d+") val pattern = Pattern.compile("\\d+")

View File

@@ -1,6 +1,16 @@
package com.example.tvmon.ui.main package com.example.tvmon.ui.main
import android.app.Dialog
import android.content.Context
import android.os.Bundle 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 androidx.fragment.app.FragmentActivity
import com.example.tvmon.R import com.example.tvmon.R
@@ -10,4 +20,107 @@ class MainActivity : FragmentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) 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

@@ -58,7 +58,13 @@ class MainFragment : BrowseSupportFragment(), OnItemViewClickedListener, OnItemV
onItemViewSelectedListener = this onItemViewSelectedListener = this
setOnSearchClickedListener { setOnSearchClickedListener {
startActivity(Intent(requireContext(), com.example.tvmon.ui.search.SearchActivity::class.java)) 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") Log.w(TAG, "setupUI: UI setup complete, adapter set")
} }

View File

@@ -1,5 +1,6 @@
package com.example.tvmon.ui.presenter package com.example.tvmon.ui.presenter
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.leanback.widget.ImageCardView import androidx.leanback.widget.ImageCardView
import androidx.leanback.widget.Presenter 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() { class CategoryCardPresenter : Presenter() {
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder { override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {

View File

@@ -2,15 +2,17 @@ package com.example.tvmon.ui.search
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.example.tvmon.R import com.example.tvmon.R
import com.example.tvmon.data.model.Content import com.example.tvmon.data.model.Content
import com.example.tvmon.data.scraper.TvmonScraper import com.example.tvmon.data.scraper.TvmonScraper
import com.example.tvmon.ui.detail.DetailsActivity import com.example.tvmon.ui.detail.DetailsActivity
import com.example.tvmon.ui.presenter.SearchCardPresenter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
@@ -18,6 +20,7 @@ class SearchActivity : AppCompatActivity() {
companion object { companion object {
private const val TAG = "TVMON_SEARCH" private const val TAG = "TVMON_SEARCH"
private const val NUM_COLUMNS = 4
} }
private val scraper: TvmonScraper by inject() private val scraper: TvmonScraper by inject()
@@ -30,6 +33,11 @@ class SearchActivity : AppCompatActivity() {
setContentView(R.layout.activity_search) setContentView(R.layout.activity_search)
setupUI() setupUI()
val initialQuery = intent.getStringExtra("initial_query")
if (!initialQuery.isNullOrBlank()) {
searchView.setQuery(initialQuery, true)
}
} }
private fun setupUI() { private fun setupUI() {
@@ -40,8 +48,9 @@ class SearchActivity : AppCompatActivity() {
openDetail(content) openDetail(content)
} }
recyclerView.layoutManager = LinearLayoutManager(this) recyclerView.layoutManager = GridLayoutManager(this, NUM_COLUMNS)
recyclerView.adapter = adapter recyclerView.adapter = adapter
recyclerView.setHasFixedSize(true)
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean { override fun onQueryTextSubmit(query: String?): Boolean {
@@ -68,10 +77,18 @@ class SearchActivity : AppCompatActivity() {
private fun search(query: String) { private fun search(query: String) {
lifecycleScope.launch { lifecycleScope.launch {
try {
val result = scraper.search(query) val result = scraper.search(query)
runOnUiThread { runOnUiThread {
if (result.success) { if (result.success && result.results.isNotEmpty()) {
adapter.updateResults(result.results) 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 package com.example.tvmon.ui.search
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import androidx.leanback.widget.Presenter
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView 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.data.model.Content
import com.example.tvmon.ui.presenter.SearchCardPresenter
class SearchResultsAdapter( class SearchResultsAdapter(
private val onItemClick: (Content) -> Unit private val onItemClick: (Content) -> Unit
) : RecyclerView.Adapter<SearchResultsAdapter.ViewHolder>() { ) : RecyclerView.Adapter<SearchResultsAdapter.ViewHolder>() {
private val presenter = SearchCardPresenter()
private var results = mutableListOf<Content>() private var results = mutableListOf<Content>()
fun updateResults(newResults: List<Content>) { fun updateResults(newResults: List<Content>) {
@@ -23,39 +20,18 @@ class SearchResultsAdapter(
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context) return ViewHolder(presenter.onCreateViewHolder(parent))
.inflate(R.layout.item_search_result, parent, false)
return ViewHolder(view)
} }
override fun onBindViewHolder(holder: ViewHolder, position: Int) { override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val content = results[position] 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 override fun getItemCount(): Int = results.size
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { class ViewHolder(val viewHolder: Presenter.ViewHolder) : RecyclerView.ViewHolder(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) }
}
}
} }