Fix episode parsing: use li.searchText[data-ep-idx], add category to URL

- Parse all episodes from li.searchText[data-ep-idx] (1600+ episodes)
- Extract category from seriesUrl for correct episode URL construction
- Add focus_border drawable and D-pad focus animation in search results
- Increase poster size (120dp -> 160dp) and improve item layout
- Add try-catch in search for crash protection
- Add old_ent and old_drama categories
This commit is contained in:
tvmon-dev
2026-04-16 11:09:14 +09:00
parent 0c8bc6252b
commit 3d6437be35
5 changed files with 128 additions and 168 deletions

View File

@@ -413,150 +413,39 @@ val episodes = mutableListOf<Episode>()
val videoLinks = mutableListOf<VideoLink>()
val seenEpisodeIds = mutableSetOf<String>()
val category = seriesUrl.substringAfter("$BASE_URL/").substringBefore("/")
val seriesId = seriesUrl.substringAfterLast("/").substringBefore("?")
// 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}/']")
}
val allEpisodeItems = doc.select("#other_list li.searchText[data-ep-idx]")
var episodeIndex = 0
for (link in allEpisodeLinks) {
val href = link.attr("href")
if (href.isBlank()) continue
if (!href.contains("/$seriesId/") && !href.contains("/${seriesId}/")) continue
val fullUrl = resolveUrl(href)
val episodeIdMatch = Pattern.compile("/$seriesId/(\\d+)").matcher(href)
if (!episodeIdMatch.find()) continue
val episodeId = episodeIdMatch.group(1) ?: ""
if (episodeId in seenEpisodeIds) continue
for (item in allEpisodeItems) {
val episodeId = item.attr("data-ep-idx")
if (episodeId.isBlank() || episodeId in seenEpisodeIds) continue
seenEpisodeIds.add(episodeId)
val linkText = link.text().trim()
if (linkText.isBlank()) continue
val fullUrl = "$BASE_URL/$category/$seriesId/$episodeId"
if (linkText.contains("로그인") || linkText.contains("비밀번호") ||
linkText.contains("마이페이지") || linkText.contains("전체 목록")) {
continue
}
val classText = item.className().replace("searchText", "").trim()
val episodeNumMatch = Pattern.compile("(\\d+)").matcher(classText)
val episodeNum = if (episodeNumMatch.find()) episodeNumMatch.group(1) ?: "" else ""
val cleanLinkText = linkText
.replace("시청중", "")
.replace("NEW", "")
.trim()
val datePattern = Pattern.compile("^(\\d{2})[./](\\d{2})[./](\\d{2,4})\\s+(.+)$|^(\\d{2})[./](\\d{2})[./](\\d{2,4})(.+)$")
val dateMatch = datePattern.matcher(linkText)
var dateStr = ""
var episodeTitleStr = cleanLinkText
if (dateMatch.find()) {
val day = dateMatch.group(1) ?: dateMatch.group(5)
val month = dateMatch.group(2) ?: dateMatch.group(6)
val year = dateMatch.group(3) ?: dateMatch.group(7)
val titlePart = dateMatch.group(4) ?: dateMatch.group(8)
if (day != null && month != null && year != null) {
dateStr = if (year.length == 2) {
"20$year/$month/$day"
} else {
"$year/$month/$day"
}
episodeTitleStr = titlePart?.trim() ?: cleanLinkText
}
}
val episodeNumMatch = Pattern.compile("(\\d+)\\s*화|(\\d+)\\s*회|EP\\.?(\\d+)|제\\s*(\\d+)\\s*부").matcher(episodeTitleStr)
val episodeTitle = if (episodeNumMatch.find()) {
episodeNumMatch.group(1) ?: episodeNumMatch.group(2) ?: episodeNumMatch.group(3) ?: episodeNumMatch.group(4)
} else {
null
}
val finalNumber = if (!episodeTitle.isNullOrBlank()) {
episodeTitle
} else {
episodeIndex++.toString()
}
if (episodeNum.isBlank()) continue
episodes.add(Episode(
number = dateStr.ifBlank { finalNumber },
title = episodeTitleStr.ifBlank { finalNumber },
number = episodeNum,
title = classText.ifBlank { "${episodeNum}" },
url = fullUrl,
type = "webview",
date = dateStr
date = ""
))
videoLinks.add(VideoLink(
type = "play_page",
url = fullUrl,
title = linkText.ifBlank { finalNumber }
title = "${episodeNum}"
))
}
// 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

@@ -6,7 +6,7 @@ import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.example.tvmon.R
import com.example.tvmon.data.model.Content
@@ -20,7 +20,6 @@ class SearchActivity : AppCompatActivity() {
companion object {
private const val TAG = "TVMON_SEARCH"
private const val NUM_COLUMNS = 4
}
private val scraper: TvmonScraper by inject()
@@ -48,9 +47,9 @@ class SearchActivity : AppCompatActivity() {
openDetail(content)
}
recyclerView.layoutManager = GridLayoutManager(this, NUM_COLUMNS)
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.adapter = adapter
recyclerView.setHasFixedSize(true)
recyclerView.setHasFixedSize(false)
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {

View File

@@ -1,16 +1,19 @@
package com.example.tvmon.ui.search
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.leanback.widget.Presenter
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
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>) {
@@ -20,18 +23,60 @@ class SearchResultsAdapter(
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(presenter.onCreateViewHolder(parent))
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]
presenter.onBindViewHolder(holder.viewHolder, content)
holder.viewHolder.view.setOnClickListener { onItemClick(content) }
holder.bind(content, onItemClick)
}
override fun getItemCount(): Int = results.size
class ViewHolder(val viewHolder: Presenter.ViewHolder) : RecyclerView.ViewHolder(viewHolder.view)
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)
private val rootView: ViewGroup = view.findViewById(R.id.item_root)
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)
}
rootView.onFocusChangeListener = View.OnFocusChangeListener { v, hasFocus ->
if (hasFocus) {
v.animate()
.scaleX(1.08f)
.scaleY(1.08f)
.translationZ(10f)
.setDuration(100)
.start()
v.findViewById<View>(R.id.focus_border)?.visibility = View.VISIBLE
} else {
v.animate()
.scaleX(1.0f)
.scaleY(1.0f)
.translationZ(0f)
.setDuration(100)
.start()
v.findViewById<View>(R.id.focus_border)?.visibility = View.GONE
}
}
itemView.setOnClickListener { onClick(content) }
}
}
}

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<stroke
android:width="3dp"
android:color="#00D4FF" />
<corners android:radius="8dp" />
</shape>

View File

@@ -1,38 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/item_root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:focusable="true"
android:focusableInTouchMode="true"
android:padding="6dp">
<View
android:id="@+id/focus_border"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/focus_border"
android:visibility="gone"
android:padding="2dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="8dp">
android:padding="6dp">
<ImageView
android:id="@+id/thumbnail"
android:layout_width="120dp"
android:layout_height="80dp"
android:scaleType="centerCrop"
android:src="@drawable/default_background" />
android:id="@+id/thumbnail"
android:layout_width="160dp"
android:layout_height="100dp"
android:scaleType="centerCrop"
android:src="@drawable/default_background" />
<LinearLayout
android:layout_width="0dp"
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: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" />
android:textColor="@android:color/white"
android:textSize="18sp"
android:maxLines="2"
android:ellipsize="end" />
<TextView
android:id="@+id/category"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textColor="@android:color/darker_gray"
android:textSize="14sp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</FrameLayout>