-
Notifications
You must be signed in to change notification settings - Fork 858
Expand file tree
/
Copy pathEpisodePreferenceHelper.kt
More file actions
262 lines (237 loc) · 10 KB
/
EpisodePreferenceHelper.kt
File metadata and controls
262 lines (237 loc) · 10 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
package com.lagradost.cloudstream3.ui.player
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.isEpisodeBased
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
import com.lagradost.cloudstream3.utils.ExtractorLink
private const val PLAYER_EPISODE_PREFERENCES = "player_episode_preferences"
/**
* Maximum age for episode preferences in milliseconds (30 days).
* Preferences older than this are automatically expired and removed
* to prevent backup files from growing endlessly.
*/
private const val PREFERENCE_MAX_AGE_MS = 30L * 24 * 60 * 60 * 1000
internal data class EpisodePlaybackPreference(
val sourceDisplayName: String? = null,
val subtitleOriginalName: String? = null,
/** Subtitle URL for more stable matching. */
val subtitleUrl: String? = null,
/** Subtitle name suffix (e.g. " 3" in "Turkish 3") for cross-episode matching when URLs change. */
val subtitleNameSuffix: String? = null,
val subtitleLanguageTag: String? = null,
val subtitlesDisabled: Boolean = false,
/** Timestamp when this preference was last saved, used for expiration. */
val savedAt: Long = System.currentTimeMillis(),
)
internal data class ResolvedEpisodeSubtitlePreference(
val subtitle: SubtitleData?,
val blockFallback: Boolean,
)
/**
* Helper object for managing episode playback preferences (source and subtitle selections).
* Stores the user's selected source and subtitle for each series so that the same selections are automatically applied when switching episodes.
* Preferences are automatically expired after 30 days.
*/
internal object EpisodePreferenceHelper {
fun getSourceDisplayName(link: Pair<ExtractorLink?, ExtractorUri?>?): String? {
return link?.first?.name ?: link?.second?.name
}
/**
* Resolves the preferred source from the stored episode preference.
* Returns null if no preference exists or the preferred source is not available.
*/
fun resolvePreferenceSource(
links: List<Pair<ExtractorLink?, ExtractorUri?>>,
preference: EpisodePlaybackPreference?,
): Pair<ExtractorLink?, ExtractorUri?>? {
val preferredSourceName = preference?.sourceDisplayName ?: return null
return links.firstOrNull { getSourceDisplayName(it) == preferredSourceName }
}
/**
* Resolves the preferred subtitle from the stored episode preference.
*
* Matching priority:
* 1. Exact match by originalName + URL (most stable, same episode re-selection)
* 2. Match by originalName + nameSuffix (cross-episode: e.g. "Turkish 3" stays "Turkish 3")
* 3. Match by originalName only (fallback if suffix not found)
* 4. Match by languageTag + nameSuffix (fallback with suffix preference)
* 5. Match by languageTag only (broadest fallback)
*
* If subtitles were explicitly disabled, returns blockFallback=true
* to prevent auto-selection from overriding the user's choice.
*/
fun resolvePreferenceSubtitle(
subtitles: Set<SubtitleData>,
preference: EpisodePlaybackPreference?,
): ResolvedEpisodeSubtitlePreference {
if (preference == null) {
return ResolvedEpisodeSubtitlePreference(
subtitle = null,
blockFallback = false,
)
}
if (preference.subtitlesDisabled) {
return ResolvedEpisodeSubtitlePreference(
subtitle = null,
blockFallback = true,
)
}
val sortedSubtitles = sortSubs(subtitles)
// Priority 1: Match by originalName + URL (most stable, works for same episode)
sortedSubtitles.firstOrNull { subtitle ->
subtitle.originalName == preference.subtitleOriginalName &&
subtitle.url == preference.subtitleUrl
}?.let { subtitle ->
return ResolvedEpisodeSubtitlePreference(
subtitle = subtitle,
blockFallback = false,
)
}
// Priority 2: Match by originalName + nameSuffix (cross-episode, preserves "Turkish 3" selection)
if (preference.subtitleNameSuffix != null) {
preference.subtitleOriginalName?.let { originalName ->
sortedSubtitles.firstOrNull { subtitle ->
subtitle.originalName == originalName &&
subtitle.nameSuffix == preference.subtitleNameSuffix
}?.let { subtitle ->
return ResolvedEpisodeSubtitlePreference(
subtitle = subtitle,
blockFallback = false,
)
}
}
}
// Priority 3: Match by originalName only
preference.subtitleOriginalName?.let { originalName ->
sortedSubtitles.firstOrNull { subtitle ->
subtitle.originalName == originalName
}?.let { subtitle ->
return ResolvedEpisodeSubtitlePreference(
subtitle = subtitle,
blockFallback = false,
)
}
}
// Priority 4: Match by languageTag + nameSuffix
if (preference.subtitleNameSuffix != null) {
preference.subtitleLanguageTag?.let { languageTag ->
sortedSubtitles.firstOrNull { subtitle ->
subtitle.matchesLanguageCode(languageTag) &&
subtitle.nameSuffix == preference.subtitleNameSuffix
}?.let { subtitle ->
return ResolvedEpisodeSubtitlePreference(
subtitle = subtitle,
blockFallback = false,
)
}
}
}
// Priority 5: Match by languageTag only
preference.subtitleLanguageTag?.let { languageTag ->
sortedSubtitles.firstOrNull { subtitle ->
subtitle.matchesLanguageCode(languageTag)
}?.let { subtitle ->
return ResolvedEpisodeSubtitlePreference(
subtitle = subtitle,
blockFallback = false,
)
}
}
return ResolvedEpisodeSubtitlePreference(
subtitle = null,
blockFallback = false,
)
}
/** Extracts the parent ID for episode-based content, returns null for movies. */
fun getEpisodePreferenceParentId(meta: Any?): Int? {
return when (meta) {
is ResultEpisode -> meta.parentId.takeIf { meta.tvType.isEpisodeBased() }
is ExtractorUri -> meta.parentId.takeIf { meta.tvType?.isEpisodeBased() == true }
else -> null
}
}
/**
* Retrieves the stored episode preference for the given meta.
* Returns null if no preference is stored or if the preference has expired (older than 30 days).
*/
fun getEpisodePreference(meta: Any?): EpisodePlaybackPreference? {
val parentId = getEpisodePreferenceParentId(meta) ?: return null
val preference = getKey<EpisodePlaybackPreference>(
"$currentAccount/$PLAYER_EPISODE_PREFERENCES",
parentId.toString()
) ?: return null
// Expire preferences older than 30 days
if (System.currentTimeMillis() - preference.savedAt > PREFERENCE_MAX_AGE_MS) {
return null
}
return preference
}
/**
* Updates the episode preference with the given transformation.
* Automatically sets the savedAt timestamp for expiration tracking.
*/
fun updateEpisodePreference(
meta: Any?,
update: EpisodePlaybackPreference.() -> EpisodePlaybackPreference,
) {
val parentId = getEpisodePreferenceParentId(meta) ?: return
val current = getEpisodePreference(meta) ?: EpisodePlaybackPreference()
val updatedPreference = update(current).copy(savedAt = System.currentTimeMillis())
setKey(
"$currentAccount/$PLAYER_EPISODE_PREFERENCES",
parentId.toString(),
updatedPreference
)
}
/** Saves the user's selected source for the current series. */
fun persistSourcePreference(
meta: Any?,
link: Pair<ExtractorLink?, ExtractorUri?>?,
) {
val sourceDisplayName = getSourceDisplayName(link) ?: return
updateEpisodePreference(meta) {
copy(sourceDisplayName = sourceDisplayName)
}
}
/** Saves the user's selected subtitle for the current series. Uses URL + nameSuffix for matching. */
fun persistSubtitlePreference(
meta: Any?,
subtitle: SubtitleData?,
) {
updateEpisodePreference(meta) {
if (subtitle == null) {
copy(
subtitleOriginalName = null,
subtitleUrl = null,
subtitleNameSuffix = null,
subtitleLanguageTag = null,
subtitlesDisabled = true,
)
} else {
copy(
subtitleOriginalName = subtitle.originalName,
subtitleUrl = subtitle.url,
subtitleNameSuffix = subtitle.nameSuffix,
subtitleLanguageTag = subtitle.getIETF_tag(),
subtitlesDisabled = false,
)
}
}
}
/**
* Checks whether we should wait for the preferred source to become available
* before auto-starting playback. Returns true if a preference exists but the
* preferred source has not been loaded yet.
*/
fun shouldWaitForPreferredSource(
links: Set<Pair<ExtractorLink?, ExtractorUri?>>,
meta: Any?,
): Boolean {
val preferredSourceName = getEpisodePreference(meta)?.sourceDisplayName ?: return false
return links.none { link ->
getSourceDisplayName(link) == preferredSourceName
}
}
}