Skip to content

Commit 1d3f40b

Browse files
authored
Merge pull request #77 from raulshma/dev/v0.9.1
Enhance Home Sections, Admin Features, and Live TV Functionality
2 parents 50e8930 + b9898ff commit 1d3f40b

105 files changed

Lines changed: 4768 additions & 475 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/src/main/java/com/raulshma/jellyplay/MainViewModel.kt

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ class MainViewModel @Inject constructor(
4343
val preferencesStore: UserPreferencesStore,
4444
val networkMonitor: NetworkMonitor,
4545
val syncPlayManager: com.raulshma.jellyplay.core.data.syncplay.SyncPlayManager,
46-
val webSocketClient: com.raulshma.jellyplay.core.data.syncplay.JellyfinWebSocketClient,
46+
val webSocketClient: com.raulshma.jellyplay.core.network.websocket.JellyfinWebSocketClient,
4747
private val apiClient: com.raulshma.jellyplay.core.network.JellyfinApiClient,
4848
val audioPlaybackManager: AudioPlaybackManager,
4949
val videoMiniPlayerState: VideoMiniPlayerState,
@@ -74,6 +74,25 @@ class MainViewModel @Inject constructor(
7474
.map { it?.isAdmin == true }
7575
.stateIn(scope, SharingStarted.WhileSubscribed(5_000), false)
7676

77+
/**
78+
* True while a server admin-status refresh is in flight. Collected by the
79+
* [com.raulshma.jellyplay.feature.admin.navigation.AdminRouteContainer]
80+
* guard so it can show a brief loading state instead of flashing the
81+
* access-denied screen before the first refresh completes.
82+
*/
83+
private val _isRefreshingAdmin = stateFlow(false)
84+
val isRefreshingAdmin = _isRefreshingAdmin.flow
85+
86+
/**
87+
* Wall-clock millis of the last successful [refreshAdminStatus]. Prevents
88+
* every admin screen from re-fetching the policy on rapid back/forward
89+
* navigation within the admin area. Read/written only on the Main thread
90+
* (all callers run via [launch] on the viewModelScope's Main dispatcher),
91+
* so a plain non-volatile field is safe here.
92+
*/
93+
private var lastAdminRefreshAt = 0L
94+
private val adminRefreshIntervalMs = 30_000L
95+
7796
val preferences = preferencesStore.preferences
7897
.stateIn(scope, SharingStarted.WhileSubscribed(5_000), UserPreferences())
7998

@@ -177,6 +196,33 @@ class MainViewModel @Inject constructor(
177196
}
178197
}
179198

199+
/**
200+
* Re-validates the current user's admin status against the server. Called
201+
* by [com.raulshma.jellyplay.feature.admin.navigation.AdminRouteContainer]
202+
* on entering any admin screen, but de-duplicated to at most once per
203+
* [adminRefreshIntervalMs] so navigation between admin screens doesn't
204+
* hammer the server. Failures other than access-denied are swallowed
205+
* (the cached value is kept) — see [AuthRepository.refreshCurrentUser].
206+
*/
207+
fun refreshAdminStatus() {
208+
// Early-out synchronously (before launch) to guard against the window
209+
// where two admin entries compose simultaneously during a transition
210+
// and both fire LaunchedEffect. The in-flight flag serializes genuine
211+
// concurrent entries; the timestamp bounds re-fetches to one per window.
212+
if (_isRefreshingAdmin.value) return
213+
val now = System.currentTimeMillis()
214+
if (now - lastAdminRefreshAt < adminRefreshIntervalMs) return
215+
launch {
216+
_isRefreshingAdmin.set(true)
217+
try {
218+
authRepository.refreshCurrentUser()
219+
lastAdminRefreshAt = System.currentTimeMillis()
220+
} finally {
221+
_isRefreshingAdmin.set(false)
222+
}
223+
}
224+
}
225+
180226
fun handleShortcutIntent(intent: Intent) {
181227
val route = when (intent.action) {
182228
AppShortcutManager.ACTION_CONTINUE_WATCHING ->

app/src/main/java/com/raulshma/jellyplay/navigation/JellyPlayApp.kt

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1404,6 +1404,15 @@ private fun MainNavDisplay(
14041404
return transition.enter togetherWith transition.exit
14051405
}
14061406

1407+
// Admin access-control state, collected once here (a @Composable context)
1408+
// and threaded into the admin section as read lambdas so the navigation
1409+
// entries — which are composed lazily — observe the latest value without
1410+
// re-building the entry graph. MainViewModel is activity-scoped, so this
1411+
// resolves to the same instance held by JellyPlayApp/MainActivity.
1412+
val mainViewModel: MainViewModel = androidx.hilt.navigation.compose.hiltViewModel()
1413+
val isAdminState = mainViewModel.isAdmin.collectAsStateWithLifecycle()
1414+
val isRefreshingAdminState = mainViewModel.isRefreshingAdmin.collectAsStateWithLifecycle()
1415+
14071416
// Remember the entry provider graph so the ~25 section builders aren't
14081417
// re-invoked (allocating fresh lambdas + entry objects) on every
14091418
// MainNavDisplay recomposition. Re-key on the values it captures.
@@ -1450,7 +1459,12 @@ private fun MainNavDisplay(
14501459
downloadsSection(navigator)
14511460
authSection(navigator) { navigator.goBack() }
14521461
settingsSection(navigator, onLogout) { navigator.navigate(Route.Onboarding) }
1453-
adminSection(navigator)
1462+
adminSection(
1463+
navigator = navigator,
1464+
isAdmin = { isAdminState.value },
1465+
isRefreshingAdmin = { isRefreshingAdminState.value },
1466+
onRefreshAdmin = { mainViewModel.refreshAdminStatus() },
1467+
)
14541468
musicSection(navigator)
14551469
syncPlaySection(navigator)
14561470
onboardingSection { navigator.goBack() }

app/src/main/java/com/raulshma/jellyplay/widget/LibraryRecommendationsWidgetWorker.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ class LibraryRecommendationsWidgetWorker @AssistedInject constructor(
9595
com.raulshma.jellyplay.core.model.HomeSectionType.RECENTLY_ADDED,
9696
),
9797
)
98-
val items = sectionsResult.getOrNull().orEmpty()
98+
val items = sectionsResult.getOrNull()?.sections.orEmpty()
9999
.flatMap { it.items }
100100
.distinctBy { it.id }
101101
.take(MAX_ITEMS)

core/data/src/main/java/com/raulshma/jellyplay/core/data/cast/remote/JellyfinRemotePlayCastStrategy.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class JellyfinRemotePlayCastStrategy @Inject constructor(
3131
@ApplicationContext private val appContext: Context,
3232
private val adminApiClient: AdminApiClient,
3333
private val preferencesStore: UserPreferencesStore,
34-
private val webSocketClient: com.raulshma.jellyplay.core.data.syncplay.JellyfinWebSocketClient,
34+
private val webSocketClient: com.raulshma.jellyplay.core.network.websocket.JellyfinWebSocketClient,
3535
private val imageUrlProvider: ImageUrlProvider,
3636
) : CastStrategy {
3737

core/data/src/main/java/com/raulshma/jellyplay/core/data/remote/RemoteControlReceiver.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ package com.raulshma.jellyplay.core.data.remote
33
import android.util.Log
44
import com.raulshma.jellyplay.core.data.repository.AuthRepository
55
import com.raulshma.jellyplay.core.data.repository.MediaRepository
6-
import com.raulshma.jellyplay.core.data.syncplay.JellyfinWebSocketClient
7-
import com.raulshma.jellyplay.core.data.syncplay.WebSocketEvent
6+
import com.raulshma.jellyplay.core.network.websocket.JellyfinWebSocketClient
7+
import com.raulshma.jellyplay.core.network.websocket.WebSocketEvent
88
import com.raulshma.jellyplay.core.datastore.UserPreferencesStore
99
import com.raulshma.jellyplay.core.model.MediaType
1010
import com.raulshma.jellyplay.core.model.remote.GeneralCommand

core/data/src/main/java/com/raulshma/jellyplay/core/data/repository/AuthRepository.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,20 @@ interface AuthRepository {
4444

4545
suspend fun restoreSession(): Result<Unit>
4646

47+
/**
48+
* Re-fetches the current user's policy from the server and updates the
49+
* cached [UserInfo] (and its encrypted persistence). Used to catch a
50+
* server-side admin demotion without forcing re-login.
51+
*
52+
* Failure handling:
53+
* - HTTP 401/403 → the cached user is treated as no longer authorized
54+
* (admin status cleared); the server is the ultimate authority.
55+
* - Any other failure (network, 5xx) → the cached value is preserved so
56+
* a flaky connection can't lock an admin out; the server still 403s
57+
* on the actual privileged call as a backstop.
58+
*/
59+
suspend fun refreshCurrentUser(): Result<UserInfo>
60+
4761
suspend fun logout()
4862

4963
/**

core/data/src/main/java/com/raulshma/jellyplay/core/data/repository/AuthRepositoryImpl.kt

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,56 @@ class AuthRepositoryImpl @Inject constructor(
295295
Log.e("AuthRepository", "restoreSession failed", e)
296296
}
297297

298+
override suspend fun refreshCurrentUser(): Result<UserInfo> {
299+
val cached = apiClient.currentUser.first()
300+
?: return Result.failure(Exception("No active user to refresh"))
301+
302+
val result = apiClient.getCurrentUser()
303+
return result.fold(
304+
onSuccess = { managed ->
305+
val refreshed = cached.copy(
306+
isAdmin = managed.policy.isAdministrator,
307+
canDeleteContent = managed.policy.enableContentDeletion,
308+
)
309+
apiClient.setUser(refreshed)
310+
persistRefreshedFlags(refreshed)
311+
Result.success(refreshed)
312+
},
313+
onFailure = { e ->
314+
// 401/403 = the server has revoked/demoted this user. Clear
315+
// admin status so the admin area is blocked immediately; the
316+
// server remains the ultimate authority on the next call.
317+
if (e is com.raulshma.jellyplay.core.network.api.ApiException && e.isAccessDenied) {
318+
val demoted = cached.copy(isAdmin = false, canDeleteContent = false)
319+
apiClient.setUser(demoted)
320+
persistRefreshedFlags(demoted)
321+
Result.success(demoted)
322+
} else {
323+
// Transient/non-access failure — keep the cached value so a
324+
// flaky network can't lock an admin out. The server still
325+
// 403s on the real privileged call as a backstop.
326+
Result.failure(e)
327+
}
328+
},
329+
)
330+
}
331+
332+
/**
333+
* Persists just the refreshed permission flags ([UserEntity.isAdmin] and
334+
* [UserEntity.canDeleteContent]) for the active user without rewriting the
335+
* whole entity (avoids clobbering the encrypted token, image tag, etc.).
336+
*/
337+
private suspend fun persistRefreshedFlags(user: UserInfo) {
338+
val existing = userDao.getUserById(user.id) ?: return
339+
if (existing.isAdmin == user.isAdmin && existing.canDeleteContent == user.canDeleteContent) return
340+
userDao.updateUser(
341+
existing.copy(
342+
isAdmin = user.isAdmin,
343+
canDeleteContent = user.canDeleteContent,
344+
)
345+
)
346+
}
347+
298348
override suspend fun logout() {
299349
apiClient.disconnect()
300350
// Clear only the active session selection — preserve the stable

core/data/src/main/java/com/raulshma/jellyplay/core/data/repository/MediaRepository.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import androidx.paging.PagingData
44
import com.raulshma.jellyplay.core.model.Genre
55
import com.raulshma.jellyplay.core.model.HomeSection
66
import com.raulshma.jellyplay.core.model.HomeSectionType
7+
import com.raulshma.jellyplay.core.model.HomeSectionsResult
78
import com.raulshma.jellyplay.core.model.LibraryFolder
89
import com.raulshma.jellyplay.core.model.MediaDetail
910
import com.raulshma.jellyplay.core.model.MediaItem
@@ -17,13 +18,13 @@ interface MediaRepository : LiveTvRepository, SyncPlayRepository, NewsletterRepo
1718

1819
suspend fun getHomeSections(
1920
enabledSections: Set<HomeSectionType> = HomeSectionType.CONFIGURABLE.toSet(),
20-
hiddenLibraryIds: Set<String> = emptySet(),
21+
libraryHomeSectionOverrides: Map<String, Set<HomeSectionType>> = emptyMap(),
2122
nextUpRewatching: Boolean = false,
2223
nextUpMaxDays: Int = 0,
2324
nextUpExcludedSeriesIds: Set<String> = emptySet(),
2425
hiddenCwItemIds: Set<String> = emptySet(),
2526
pinnedSections: List<PinnedHomeSection> = emptyList(),
26-
): Result<List<HomeSection>>
27+
): Result<HomeSectionsResult>
2728

2829
suspend fun getLibraryFolders(): Result<List<LibraryFolder>>
2930

core/data/src/main/java/com/raulshma/jellyplay/core/data/repository/MediaRepositoryImpl.kt

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import com.raulshma.jellyplay.core.model.Genre
1616
import com.raulshma.jellyplay.core.model.HomeSection
1717
import com.raulshma.jellyplay.core.model.TtlCache
1818
import com.raulshma.jellyplay.core.model.HomeSectionType
19+
import com.raulshma.jellyplay.core.model.HomeSectionsResult
1920
import com.raulshma.jellyplay.core.model.LibraryFolder
2021
import com.raulshma.jellyplay.core.model.LiveTvChannel
2122
import com.raulshma.jellyplay.core.model.LiveTvProgram
@@ -81,7 +82,7 @@ class MediaRepositoryImpl @Inject constructor(
8182
}
8283

8384
@Volatile
84-
private var cachedHomeSections: List<HomeSection>? = null
85+
private var cachedHomeSections: HomeSectionsResult? = null
8586
@Volatile
8687
private var cachedHomeSectionsTimestamp: Long = 0L
8788
@Volatile
@@ -144,14 +145,14 @@ class MediaRepositoryImpl @Inject constructor(
144145

145146
override suspend fun getHomeSections(
146147
enabledSections: Set<HomeSectionType>,
147-
hiddenLibraryIds: Set<String>,
148+
libraryHomeSectionOverrides: Map<String, Set<HomeSectionType>>,
148149
nextUpRewatching: Boolean,
149150
nextUpMaxDays: Int,
150151
nextUpExcludedSeriesIds: Set<String>,
151152
hiddenCwItemIds: Set<String>,
152153
pinnedSections: List<PinnedHomeSection>,
153-
): Result<List<HomeSection>> {
154-
val cacheKey = "${enabledSections.sortedBy { it.name }}|$hiddenLibraryIds|$nextUpRewatching|$nextUpMaxDays|$nextUpExcludedSeriesIds|$hiddenCwItemIds|$pinnedSections"
154+
): Result<HomeSectionsResult> {
155+
val cacheKey = "${enabledSections.sortedBy { it.name }}|$libraryHomeSectionOverrides|$nextUpRewatching|$nextUpMaxDays|$nextUpExcludedSeriesIds|$hiddenCwItemIds|$pinnedSections"
155156
val cached = cachedHomeSections
156157
val timestamp = cachedHomeSectionsTimestamp
157158
if (cached != null && cacheKey == cachedHomeSectionsKey &&
@@ -161,16 +162,16 @@ class MediaRepositoryImpl @Inject constructor(
161162
}
162163
return apiClient.getHomeSections(
163164
enabledSections,
164-
hiddenLibraryIds,
165+
libraryHomeSectionOverrides,
165166
nextUpRewatching,
166167
nextUpMaxDays,
167168
nextUpExcludedSeriesIds,
168169
hiddenCwItemIds,
169170
pinnedSections,
170171
).also { result ->
171-
result.getOrNull()?.let { sections ->
172+
result.getOrNull()?.let { homeResult ->
172173
synchronized(homeSectionsLock) {
173-
cachedHomeSections = sections
174+
cachedHomeSections = homeResult
174175
cachedHomeSectionsKey = cacheKey
175176
cachedHomeSectionsTimestamp = android.os.SystemClock.elapsedRealtime()
176177
}
@@ -774,12 +775,13 @@ class MediaRepositoryImpl @Inject constructor(
774775
cachedHomeSectionsKey = ""
775776
}
776777
// Also clear the secondary caches — they hold user-scoped data (library folders,
777-
// latest media, genres, studios) that would otherwise leak across user/server
778-
// switches until their TTL expires.
778+
// latest media, genres, studios, photo folder child URLs) that would otherwise
779+
// leak across user/server switches until their TTL expires.
779780
libraryFoldersCache.clear()
780781
latestMediaCache.clear()
781782
genresCache.clear()
782783
studiosCache.clear()
784+
photoFolderChildUrlCache.clear()
783785
}
784786

785787
override suspend fun getNewsletterData(sinceDate: String, limit: Int): Result<NewsletterData> =

core/data/src/main/java/com/raulshma/jellyplay/core/data/syncplay/SyncPlayManager.kt

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import com.raulshma.jellyplay.core.model.SyncPlayGroup
88
import com.raulshma.jellyplay.core.model.SyncPlayRepeatMode
99
import com.raulshma.jellyplay.core.model.SyncPlayShuffleMode
1010
import com.raulshma.jellyplay.core.network.JellyfinApiClient
11+
import com.raulshma.jellyplay.core.network.websocket.JellyfinWebSocketClient
1112
import kotlinx.coroutines.CoroutineScope
1213
import kotlinx.coroutines.Dispatchers
1314
import kotlinx.coroutines.Job
@@ -48,7 +49,6 @@ class SyncPlayManager @Inject constructor(
4849
private val cachedGroup = AtomicReference<SyncPlayGroup?>(null)
4950
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
5051
private var eventJob: Job? = null
51-
private var keepAliveJob: Job? = null
5252
private var pingReportJob: Job? = null
5353

5454
private val queuedEvent = AtomicReference<SyncPlayEvent?>(null)
@@ -71,22 +71,10 @@ class SyncPlayManager @Inject constructor(
7171
handleEvent(typedEvent)
7272
}
7373
}
74-
keepAliveJob?.cancel()
75-
keepAliveJob = scope.launch {
76-
try {
77-
while (true) {
78-
delay(60_000)
79-
try {
80-
webSocketClient.sendKeepAlive()
81-
} catch (ce: CancellationException) {
82-
throw ce
83-
} catch (_: Exception) {
84-
}
85-
}
86-
} catch (_: CancellationException) {
87-
// Expected when leaveGroup()/reset() cancels the job.
88-
}
89-
}
74+
// KeepAlive is now owned by JellyfinWebSocketClient itself (it self-pings
75+
// while connected and reacts to the server's ForceKeepAlive). SyncPlay no
76+
// longer needs its own loop, which previously left the app-lifetime socket
77+
// un-kept during non-SyncPlay sessions (e.g. admin dashboards).
9078
}
9179

9280
private fun handleEvent(event: SyncPlayEvent) {
@@ -261,7 +249,6 @@ class SyncPlayManager @Inject constructor(
261249
queuedEvent.set(null)
262250
syncPlayEnabledAtMs.set(0L)
263251
eventJob?.cancel()
264-
keepAliveJob?.cancel()
265252
pingReportJob?.cancel()
266253
queueCore.clear()
267254
playbackCore.onGroupLeft()
@@ -345,7 +332,6 @@ class SyncPlayManager @Inject constructor(
345332
syncPlayEnabledAtMs.set(0L)
346333
queuedEvent.set(null)
347334
eventJob?.cancel()
348-
keepAliveJob?.cancel()
349335
pingReportJob?.cancel()
350336
queueCore.clear()
351337
playbackCore.onGroupLeft()

0 commit comments

Comments
 (0)