Skip to content

Commit 64d88f9

Browse files
committed
Release 3.4.4: fix search and resume metadata guards
1 parent 5a8a14c commit 64d88f9

8 files changed

Lines changed: 399 additions & 17 deletions

File tree

app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import java.util.Properties
33
import org.gradle.api.provider.Property
44
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
55

6-
val appVersionCode = 139
7-
val appVersionName = "3.4.3"
6+
val appVersionCode = 140
7+
val appVersionName = "3.4.4"
88

99
plugins {
1010
alias(libs.plugins.android.application)

app/src/main/java/com/example/xtreamplayer/content/ContinueWatchingRepository.kt

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,19 @@ class ContinueWatchingRepository(private val context: Context) {
6262
)
6363

6464
val shouldSkipWrite =
65-
existingEntry != null &&
66-
abs(existingEntry.positionMs - positionMs) < MIN_SAVE_DELTA_MS &&
67-
existingEntry.durationMs == durationMs &&
68-
existingEntry.parentItem?.id == parentItem?.id &&
69-
existingEntry.subtitleFileName == resolvedSubtitlePersistence.subtitleFileName &&
70-
existingEntry.subtitleLanguage == resolvedSubtitlePersistence.subtitleLanguage &&
71-
existingEntry.subtitleLabel == resolvedSubtitlePersistence.subtitleLabel &&
72-
existingEntry.subtitleOffsetMs == resolvedSubtitlePersistence.subtitleOffsetMs
65+
shouldSkipContinueWatchingWrite(
66+
existingEntry = existingEntry,
67+
targetKey = key,
68+
item = item,
69+
positionMs = positionMs,
70+
durationMs = durationMs,
71+
parentItem = parentItem,
72+
subtitleFileName = resolvedSubtitlePersistence.subtitleFileName,
73+
subtitleLanguage = resolvedSubtitlePersistence.subtitleLanguage,
74+
subtitleLabel = resolvedSubtitlePersistence.subtitleLabel,
75+
subtitleOffsetMs = resolvedSubtitlePersistence.subtitleOffsetMs,
76+
minSaveDeltaMs = MIN_SAVE_DELTA_MS
77+
)
7378
if (shouldSkipWrite) {
7479
return@edit
7580
}
@@ -351,3 +356,47 @@ class ContinueWatchingRepository(private val context: Context) {
351356
const val MIN_SAVE_DELTA_MS = 30_000L
352357
}
353358
}
359+
360+
internal fun shouldSkipContinueWatchingWrite(
361+
existingEntry: ContinueWatchingEntry?,
362+
targetKey: String,
363+
item: ContentItem,
364+
positionMs: Long,
365+
durationMs: Long,
366+
parentItem: ContentItem?,
367+
subtitleFileName: String?,
368+
subtitleLanguage: String?,
369+
subtitleLabel: String?,
370+
subtitleOffsetMs: Long,
371+
minSaveDeltaMs: Long
372+
): Boolean {
373+
if (existingEntry == null) {
374+
return false
375+
}
376+
return abs(existingEntry.positionMs - positionMs) < minSaveDeltaMs &&
377+
existingEntry.key == targetKey &&
378+
hasSamePersistedContinueWatchingItem(existingEntry.item, item) &&
379+
existingEntry.durationMs == durationMs &&
380+
hasSamePersistedContinueWatchingItem(existingEntry.parentItem, parentItem) &&
381+
existingEntry.subtitleFileName == subtitleFileName &&
382+
existingEntry.subtitleLanguage == subtitleLanguage &&
383+
existingEntry.subtitleLabel == subtitleLabel &&
384+
existingEntry.subtitleOffsetMs == subtitleOffsetMs
385+
}
386+
387+
private fun hasSamePersistedContinueWatchingItem(
388+
existing: ContentItem?,
389+
incoming: ContentItem?
390+
): Boolean {
391+
if (existing == null || incoming == null) {
392+
return existing == incoming
393+
}
394+
return existing.id == incoming.id &&
395+
existing.title == incoming.title &&
396+
existing.subtitle == incoming.subtitle &&
397+
existing.imageUrl == incoming.imageUrl &&
398+
existing.section == incoming.section &&
399+
existing.contentType == incoming.contentType &&
400+
existing.streamId == incoming.streamId &&
401+
existing.containerExtension == incoming.containerExtension
402+
}

app/src/main/java/com/example/xtreamplayer/content/LocalPlaybackResumeRepository.kt

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,15 @@ class LocalPlaybackResumeRepository(private val context: Context) {
3434
val raw = prefs[Keys.LOCAL_PLAYBACK_RESUME_ENTRIES] ?: "[]"
3535
val entries = parseEntries(raw).toMutableList()
3636
val existing = entries.firstOrNull { it.mediaId == mediaId }
37-
if (existing != null && abs(existing.positionMs - positionMs) < MIN_SAVE_DELTA_MS) {
37+
if (
38+
shouldSkipLocalResumeWrite(
39+
existing = existing,
40+
title = title,
41+
positionMs = positionMs,
42+
durationMs = durationMs,
43+
minSaveDeltaMs = MIN_SAVE_DELTA_MS
44+
)
45+
) {
3846
return@edit
3947
}
4048
entries.removeAll { it.mediaId == mediaId }
@@ -107,6 +115,21 @@ class LocalPlaybackResumeRepository(private val context: Context) {
107115
}
108116
}
109117

118+
internal fun shouldSkipLocalResumeWrite(
119+
existing: LocalPlaybackResumeEntry?,
120+
title: String,
121+
positionMs: Long,
122+
durationMs: Long,
123+
minSaveDeltaMs: Long
124+
): Boolean {
125+
if (existing == null) {
126+
return false
127+
}
128+
return abs(existing.positionMs - positionMs) < minSaveDeltaMs &&
129+
existing.title == title &&
130+
existing.durationMs == durationMs
131+
}
132+
110133
data class LocalPlaybackResumeEntry(
111134
val mediaId: String,
112135
val title: String,

app/src/main/java/com/example/xtreamplayer/content/SearchNormalizer.kt

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,47 @@
11
package com.example.xtreamplayer.content
22

3-
import android.util.LruCache
3+
import androidx.collection.LruCache
44

55
object SearchNormalizer {
66
private val diacriticsRegex = Regex("\\p{Mn}+")
77
private val nonAlnumRegex = Regex("[^\\p{L}\\p{N}]+")
8+
private val languagePrefixCodes = setOf(
9+
"en", "eng",
10+
"es", "spa",
11+
"fr", "fre", "fra",
12+
"de", "ger", "deu",
13+
"it", "ita",
14+
"pt", "por",
15+
"ru", "rus",
16+
"ja", "jpn",
17+
"ko", "kor",
18+
"zh", "chi", "zho",
19+
"ar", "ara",
20+
"hi", "hin",
21+
"tr", "tur",
22+
"nl", "dut", "nld",
23+
"pl", "pol",
24+
"sv", "swe",
25+
"no", "nor",
26+
"da", "dan",
27+
"fi", "fin",
28+
"cs", "cze", "ces",
29+
"el", "gre", "ell",
30+
"he", "heb",
31+
"th", "tha",
32+
"vi", "vie",
33+
"id", "ind",
34+
"ms", "may", "msa",
35+
"ro", "rum", "ron",
36+
"hu", "hun",
37+
"uk", "ukr",
38+
"bg", "bul",
39+
"hr", "hrv",
40+
"sr", "srp",
41+
"sk", "slo", "slk",
42+
"ca", "cat",
43+
"fa", "per", "fas"
44+
)
845
private const val TITLE_CACHE_MAX_ENTRIES = 75_000
946
private const val PREWARM_MAX_INSERTS = 5_000
1047
private val titleCache = LruCache<String, String>(TITLE_CACHE_MAX_ENTRIES)
@@ -24,20 +61,20 @@ object SearchNormalizer {
2461
if (inserted >= insertBudget) return@forEach
2562
if (title.isBlank()) return@forEach
2663
if (titleCache.get(title) == null) {
27-
val normalized = normalize(title)
64+
val normalized = normalizeTitleValue(title)
2865
titleCache.put(title, normalized)
2966
inserted++
3067
}
3168
}
3269
}
3370

3471
fun normalizeQuery(raw: String): String {
35-
return normalize(raw)
72+
return normalizeQueryValue(raw)
3673
}
3774

3875
fun normalizeTitle(raw: String): String {
3976
titleCache.get(raw)?.let { return it }
40-
val normalized = normalize(raw)
77+
val normalized = normalizeTitleValue(raw)
4178
titleCache.put(raw, normalized)
4279
return normalized
4380
}
@@ -57,8 +94,16 @@ object SearchNormalizer {
5794
return tokens.all { normalizedTitle.contains(it) }
5895
}
5996

97+
private fun normalizeQueryValue(raw: String): String {
98+
return normalize(stripLanguagePrefix(raw))
99+
}
100+
101+
private fun normalizeTitleValue(raw: String): String {
102+
return normalize(raw)
103+
}
104+
60105
private fun normalize(raw: String): String {
61-
val trimmed = stripLanguagePrefix(raw).trim()
106+
val trimmed = raw.trim()
62107
if (trimmed.isEmpty()) {
63108
return trimmed
64109
}
@@ -82,9 +127,26 @@ object SearchNormalizer {
82127
if (separatorIndex <= 0) {
83128
return trimmed
84129
}
130+
if (separatorIndex >= trimmed.lastIndex) {
131+
return trimmed
132+
}
133+
val separator = trimmed[separatorIndex]
134+
val hasValidDelimiterFormat =
135+
when (separator) {
136+
'|' -> trimmed[separatorIndex - 1].isWhitespace() && trimmed[separatorIndex + 1].isWhitespace()
137+
'-', ':' -> true
138+
else -> false
139+
}
140+
if (!hasValidDelimiterFormat) {
141+
return trimmed
142+
}
85143
val prefix = trimmed.substring(0, separatorIndex).trim()
86144
val compactPrefix = prefix.replace(" ", "")
87-
val isLanguagePrefix = compactPrefix.length in 2..3 && compactPrefix.all { it.isLetter() }
145+
val normalizedPrefix = compactPrefix.lowercase()
146+
val isLanguagePrefix =
147+
compactPrefix.length in 2..3 &&
148+
compactPrefix.all { it.isLetter() } &&
149+
normalizedPrefix in languagePrefixCodes
88150
if (!isLanguagePrefix) {
89151
return trimmed
90152
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package com.example.xtreamplayer.content
2+
3+
import com.example.xtreamplayer.Section
4+
import org.junit.Assert.assertFalse
5+
import org.junit.Assert.assertTrue
6+
import org.junit.Test
7+
8+
class ContinueWatchingRepositoryWriteGuardTest {
9+
10+
@Test
11+
fun shouldSkipContinueWatchingWrite_returnsTrue_when_only_position_changes_within_delta() {
12+
val existing = entry()
13+
14+
val shouldSkip =
15+
shouldSkipContinueWatchingWrite(
16+
existingEntry = existing,
17+
targetKey = existing.key,
18+
item = existing.item,
19+
positionMs = existing.positionMs + 1_000L,
20+
durationMs = existing.durationMs,
21+
parentItem = existing.parentItem,
22+
subtitleFileName = existing.subtitleFileName,
23+
subtitleLanguage = existing.subtitleLanguage,
24+
subtitleLabel = existing.subtitleLabel,
25+
subtitleOffsetMs = existing.subtitleOffsetMs,
26+
minSaveDeltaMs = 30_000L
27+
)
28+
29+
assertTrue(shouldSkip)
30+
}
31+
32+
@Test
33+
fun shouldSkipContinueWatchingWrite_returnsFalse_when_metadata_changes() {
34+
val existing = entry()
35+
val changedItem = existing.item.copy(title = "Updated Title")
36+
37+
val shouldSkip =
38+
shouldSkipContinueWatchingWrite(
39+
existingEntry = existing,
40+
targetKey = existing.key,
41+
item = changedItem,
42+
positionMs = existing.positionMs + 1_000L,
43+
durationMs = existing.durationMs,
44+
parentItem = existing.parentItem,
45+
subtitleFileName = existing.subtitleFileName,
46+
subtitleLanguage = existing.subtitleLanguage,
47+
subtitleLabel = existing.subtitleLabel,
48+
subtitleOffsetMs = existing.subtitleOffsetMs,
49+
minSaveDeltaMs = 30_000L
50+
)
51+
52+
assertFalse(shouldSkip)
53+
}
54+
55+
@Test
56+
fun shouldSkipContinueWatchingWrite_returnsTrue_when_only_nonpersisted_item_fields_change() {
57+
val existing = entry()
58+
val changedItem =
59+
existing.item.copy(
60+
description = "Updated description",
61+
duration = "52m",
62+
rating = "4.5",
63+
seasonLabel = "Season 2",
64+
episodeNumber = "8",
65+
categoryId = "cat-9"
66+
)
67+
68+
val shouldSkip =
69+
shouldSkipContinueWatchingWrite(
70+
existingEntry = existing,
71+
targetKey = existing.key,
72+
item = changedItem,
73+
positionMs = existing.positionMs + 1_000L,
74+
durationMs = existing.durationMs,
75+
parentItem = existing.parentItem,
76+
subtitleFileName = existing.subtitleFileName,
77+
subtitleLanguage = existing.subtitleLanguage,
78+
subtitleLabel = existing.subtitleLabel,
79+
subtitleOffsetMs = existing.subtitleOffsetMs,
80+
minSaveDeltaMs = 30_000L
81+
)
82+
83+
assertTrue(shouldSkip)
84+
}
85+
86+
@Test
87+
fun shouldSkipContinueWatchingWrite_returnsFalse_when_key_needs_migration() {
88+
val existing = entry()
89+
90+
val shouldSkip =
91+
shouldSkipContinueWatchingWrite(
92+
existingEntry = existing,
93+
targetKey = "${existing.key}-new",
94+
item = existing.item,
95+
positionMs = existing.positionMs + 1_000L,
96+
durationMs = existing.durationMs,
97+
parentItem = existing.parentItem,
98+
subtitleFileName = existing.subtitleFileName,
99+
subtitleLanguage = existing.subtitleLanguage,
100+
subtitleLabel = existing.subtitleLabel,
101+
subtitleOffsetMs = existing.subtitleOffsetMs,
102+
minSaveDeltaMs = 30_000L
103+
)
104+
105+
assertFalse(shouldSkip)
106+
}
107+
108+
private fun entry(): ContinueWatchingEntry {
109+
val item =
110+
ContentItem(
111+
id = "movie-1",
112+
title = "Movie",
113+
subtitle = "Subtitle",
114+
imageUrl = "https://img.example/poster.jpg",
115+
section = Section.MOVIES,
116+
contentType = ContentType.MOVIES,
117+
streamId = "stream-1",
118+
containerExtension = "mp4"
119+
)
120+
return ContinueWatchingEntry(
121+
key = "https://service|user|MOVIES|stream-1",
122+
item = item,
123+
positionMs = 120_000L,
124+
durationMs = 3_600_000L,
125+
timestampMs = 1L,
126+
parentItem = null,
127+
subtitleFileName = "movie_en.srt",
128+
subtitleLanguage = "en",
129+
subtitleLabel = "English",
130+
subtitleOffsetMs = 0L
131+
)
132+
}
133+
}

0 commit comments

Comments
 (0)