Merge pull request 'Notifications inbox (M489), Discover taste-arm fix, web tooling majors' (#151) from dev into main
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m8s
release / go (push) Successful in 1m27s
release / integration (push) Successful in 5m1s
release / android (push) Successful in 5m35s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m34s
release / Verify release artifacts (tag releases only) (push) Skipped

This commit was merged in pull request #151.
This commit is contained in:
2026-10-08 11:11:08 -04:00
297 changed files with 10804 additions and 3013 deletions
+7 -6
View File
@@ -295,12 +295,13 @@ jobs:
- name: Install deps
run: npm ci
# What ships to browsers: `dependencies` and the runtime they pull in
# (svelte, devalue). Build and test tooling (vite, vitest, tailwind,
# kit's dev server) is left out because none of it reaches a user, and
# its open advisories need major-version upgrades tracked separately.
- name: npm audit (shipped dependencies)
run: npm audit --omit=dev --audit-level=moderate
# The whole tree, build and test tooling included. Until #5021 this
# audited only what ships to browsers (`--omit=dev`), because vite,
# vitest, tailwind and kit carried advisories that needed major
# upgrades. Those upgrades landed and the full tree audits clean, so
# the tooling that builds the shipped bundle is held to the same bar.
- name: npm audit (all dependencies)
run: npm audit --audit-level=moderate
- name: Type-check + svelte-check
run: npm run check
+27
View File
@@ -8,6 +8,10 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Notifications when the app is closed (M489 #5347): the delivery
service, and starting it again after a reboot or an update. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- In-app self-update. REQUEST_INSTALL_PACKAGES lets us hand an APK to the
platform installer at all; UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+)
is what lets that install happen with NO confirm dialog. The platform
@@ -57,6 +61,29 @@
</intent-filter>
</service>
<!-- Keeps the process alive so notifications arrive with the app
closed. specialUse: dataSync is stopped after six hours on
Android 15, and shortService after three minutes. -->
<service
android:name=".notifications.delivery.DeliveryService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Maintains the connection to the user's own Minstrel server that delivers their notifications, in place of a third-party push service." />
</service>
<!-- Starts delivery after a reboot or an update; both broadcasts may
start a foreground service from the background. -->
<receiver
android:name=".notifications.delivery.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<!-- The FileProvider that used to live here existed solely to expose the
downloaded update APK as a content:// URI for the old ACTION_VIEW
install intent. A PackageInstaller session takes a stream instead,
@@ -1,10 +1,14 @@
package com.fabledsword.minstrel
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
@@ -16,9 +20,12 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.rememberNavController
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
import com.fabledsword.minstrel.cache.CachedTrackIds
import com.fabledsword.minstrel.connectivity.LocalServerHealth
@@ -27,7 +34,10 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.nav.DetailSeedCache
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.nav.MinstrelNavGraph
import com.fabledsword.minstrel.nav.Notifications
import com.fabledsword.minstrel.nav.NowPlaying
import com.fabledsword.minstrel.notifications.delivery.deliveryWanted
import com.fabledsword.minstrel.notifications.ui.routeForLink
import com.fabledsword.minstrel.shared.widgets.LocalCachedTrackIds
import com.fabledsword.minstrel.theme.MinstrelTheme
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
@@ -35,6 +45,10 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
@@ -42,41 +56,72 @@ class MainActivity : ComponentActivity() {
@Inject lateinit var seedCache: DetailSeedCache
@Inject lateinit var cachedTrackIds: CachedTrackIds
@Inject lateinit var serverHealth: NetworkStatusController
@Inject lateinit var authStore: AuthStore
// Flipped to true when the user taps the media notification (or
// any other entry point that asks for the full player). The App
// composable observes this, navigates to NowPlaying once the
// NavHost is ready, then calls back to reset the flag so the
// navigation doesn't re-fire on the next recomposition.
private val pendingOpenNowPlaying = MutableStateFlow(false)
// Set when the user taps a notification: the media one asks for the full
// player, a Minstrel notice for what it is about. The App composable
// navigates there once the NavHost is ready, then calls back to clear it
// so the navigation doesn't re-fire on the next recomposition.
private val pendingRoute = MutableStateFlow<Any?>(null)
// The answer needs no handling: the system remembers it, and the
// notification settings screen reads it on every resume.
private val askToNotify = registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
consumeOpenNowPlayingIntent(intent)
consumeRouteIntent(intent)
askToNotifyOnceWanted()
setContent {
App(
seedCache = seedCache,
cachedTrackIds = cachedTrackIds,
serverHealth = serverHealth,
pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(),
onOpenedNowPlaying = { pendingOpenNowPlaying.value = false },
pendingRoute = pendingRoute.asStateFlow(),
onOpenedRoute = { pendingRoute.value = null },
)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
consumeOpenNowPlayingIntent(intent)
consumeRouteIntent(intent)
}
private fun consumeOpenNowPlayingIntent(intent: Intent?) {
if (intent?.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false) == true) {
pendingOpenNowPlaying.value = true
private fun consumeRouteIntent(intent: Intent?) {
if (intent == null) return
if (intent.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false)) {
pendingRoute.value = NowPlaying
// Strip the extra so a subsequent config-change recreation
// doesn't re-trigger the navigation.
intent.removeExtra(EXTRA_OPEN_NOW_PLAYING)
}
intent.getStringExtra(EXTRA_NOTIFICATION_LINK)?.let { link ->
// A notice the app has no screen for, or a pile of them, opens
// the inbox.
pendingRoute.value = routeForLink(link) ?: Notifications
intent.removeExtra(EXTRA_NOTIFICATION_LINK)
}
}
/**
* Android 13+ asks before an app may post notifications (M489 #5347).
* Asked once delivery is wanted (signed in, background delivery on),
* each launch until answered: after a second "no" the system stops
* showing the prompt by itself, and Settings → Notifications links to
* the system page.
*/
private fun askToNotifyOnceWanted() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
lifecycleScope.launch {
val signedIn = authStore.sessionCookie.map { !it.isNullOrEmpty() }
combine(signedIn, authStore.backgroundDelivery, ::deliveryWanted).first { it }
val permission = Manifest.permission.POST_NOTIFICATIONS
val granted = ContextCompat.checkSelfPermission(this@MainActivity, permission) ==
PackageManager.PERMISSION_GRANTED
if (!granted) askToNotify.launch(permission)
}
}
companion object {
@@ -84,6 +129,10 @@ class MainActivity : ComponentActivity() {
* so a media-notification tap lands on the full NowPlaying screen
* instead of whatever shell route MainActivity last rendered. */
const val EXTRA_OPEN_NOW_PLAYING = "com.fabledsword.minstrel.action.OPEN_NOW_PLAYING"
/** PendingIntent extra on a Minstrel notice: the web path it links to,
* or empty for a pile, which opens the inbox. */
const val EXTRA_NOTIFICATION_LINK = "com.fabledsword.minstrel.action.NOTIFICATION_LINK"
}
}
@@ -92,15 +141,15 @@ private fun App(
seedCache: DetailSeedCache,
cachedTrackIds: CachedTrackIds,
serverHealth: NetworkStatusController,
pendingOpenNowPlaying: StateFlow<Boolean>,
onOpenedNowPlaying: () -> Unit,
pendingRoute: StateFlow<Any?>,
onOpenedRoute: () -> Unit,
themeVm: ThemePreferenceViewModel = hiltViewModel(),
gate: AuthGateViewModel = hiltViewModel(),
) {
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
val health: ServerHealth by serverHealth.state.collectAsStateWithLifecycle()
val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle()
val pending by pendingRoute.collectAsStateWithLifecycle()
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
CompositionLocalProvider(
LocalDetailSeedCache provides seedCache,
@@ -119,16 +168,14 @@ private fun App(
// Queue / unauthenticated) bypass the shell entirely.
val navController = rememberNavController()
// Honour a pending notification-tap once the NavHost is
// mounted. launchSingleTop avoids stacking copies of
// NowPlaying if the user taps the notification while
// already on it; the callback clears the flag so a later
// recomposition (config change, theme switch) doesn't
// re-navigate.
// mounted. launchSingleTop avoids stacking copies of a
// screen if the user taps the notification while already
// on it; the callback clears it so a later recomposition
// (config change, theme switch) doesn't re-navigate.
LaunchedEffect(pending, navController) {
if (pending) {
navController.navigate(NowPlaying) { launchSingleTop = true }
onOpenedNowPlaying()
}
val route = pending ?: return@LaunchedEffect
navController.navigate(route) { launchSingleTop = true }
onOpenedRoute()
}
MinstrelNavGraph(
navController = navController,
@@ -16,6 +16,7 @@ import com.fabledsword.minstrel.diagnostics.DiagnosticsUploader
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.events.LiveEventsDispatcher
import com.fabledsword.minstrel.metadata.FreshnessSweeper
import com.fabledsword.minstrel.notifications.delivery.DeliveryLauncher
import com.fabledsword.minstrel.player.AudioPrefetcher
import com.fabledsword.minstrel.player.CoverPrefetcher
import com.fabledsword.minstrel.player.PlayEventsReporter
@@ -75,6 +76,14 @@ class MinstrelApplication :
*/
@Suppress("unused") @Inject lateinit var mutationReplayer: MutationReplayer
/**
* Same construct-the-singleton trick — DeliveryLauncher starts and stops
* the background-delivery service and runs the notification catch-up on
* every nudge and reconnect (M489 #5347). Without this @Inject no phone
* notification would ever be posted.
*/
@Suppress("unused") @Inject lateinit var deliveryLauncher: DeliveryLauncher
/**
* Same construct-the-singleton trick — PlayEventsReporter's init
* block subscribes to PlayerController.uiState and reports the
@@ -0,0 +1,90 @@
package com.fabledsword.minstrel.api.endpoints
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
/**
* The notifications inbox and its per-user settings (M489). The server
* renders each notice's title, body and link, so the app shows them as given.
*/
interface NotificationsApi {
@GET("api/me/notifications")
suspend fun list(@Query("limit") limit: Int): NotificationsPageWire
/** 204; 404 when the notice is gone, which a replay treats as done. */
@POST("api/me/notifications/{id}/read")
suspend fun markRead(@Path("id") id: String)
@POST("api/me/notifications/read-all")
suspend fun readAll(@Body body: ReadAllBody)
@GET("api/me/notification-settings")
suspend fun getSettings(): NotificationSettingsWire
@PUT("api/me/notification-settings")
suspend fun putSettings(@Body body: PutNotificationSettingsBody): NotificationSettingsWire
}
@Serializable
data class NotificationWire(
val id: String,
val kind: String,
val title: String,
val body: String,
val link: String,
@SerialName("created_at") val createdAt: String,
@SerialName("read_at") val readAt: String? = null,
)
@Serializable
data class NotificationsPageWire(
val items: List<NotificationWire>,
@SerialName("unread_count") val unreadCount: Long,
@SerialName("next_before") val nextBefore: String? = null,
)
/**
* `upTo` limits "mark all read" to what existed when the user asked, so a
* replay landing later leaves newer notices unread. No default on purpose:
* the app's Json drops default-valued fields.
*/
@Serializable
data class ReadAllBody(@SerialName("up_to") val upTo: String?)
@Serializable
data class NotificationKindSettingWire(
val kind: String,
@SerialName("admin_only") val adminOnly: Boolean,
val inbox: Boolean,
val phone: Boolean,
val email: Boolean,
)
@Serializable
data class NotificationSettingsWire(
val kinds: List<NotificationKindSettingWire>,
@SerialName("email_available") val emailAvailable: Boolean,
@SerialName("email_unavailable_reason") val emailUnavailableReason: String? = null,
)
/**
* One kind's change. Untouched channels stay null and, being equal to their
* default, are left out of the JSON, so the server changes only the channel
* the user touched.
*/
@Serializable
data class NotificationSettingChangeWire(
val kind: String,
val inbox: Boolean? = null,
val phone: Boolean? = null,
val email: Boolean? = null,
)
@Serializable
data class PutNotificationSettingsBody(val kinds: List<NotificationSettingChangeWire>)
@@ -81,6 +81,12 @@ class AuthStore @Inject constructor(
private val normalizationState = MutableStateFlow(NormalizationPrefs.DEFAULT)
val normalization: StateFlow<NormalizationPrefs> = normalizationState.asStateFlow()
// Background delivery (M489 #5347): the device's choice, on by default.
// The shade's high-water mark lives in the same row but is read and
// written through the DAO by NotificationSync, awaited, never cached here.
private val backgroundDeliveryState = MutableStateFlow(true)
val backgroundDelivery: StateFlow<Boolean> = backgroundDeliveryState.asStateFlow()
private val json = Json { ignoreUnknownKeys = true }
// Serialises every cookie persist with the one-time hydration, so a
@@ -104,6 +110,7 @@ class AuthStore @Inject constructor(
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
normalizationState.value = decodeNormalization(row?.normalizationJson)
backgroundDeliveryState.value = row?.backgroundDelivery ?: true
}
}
}
@@ -205,6 +212,17 @@ class AuthStore @Inject constructor(
scope.launch { persistNormalization(encoded) }
}
fun setBackgroundDelivery(value: Boolean) {
backgroundDeliveryState.value = value
scope.launch {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(backgroundDelivery = value))
} else {
dao.setBackgroundDelivery(value)
}
}
}
private suspend fun persistLegacyCookie(value: String?) {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(sessionCookie = value))
@@ -287,6 +305,7 @@ class AuthStore @Inject constructor(
NormalizationPrefs.serializer(),
normalizationState.value,
),
backgroundDelivery = backgroundDeliveryState.value,
)
companion object {
@@ -13,6 +13,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
@@ -28,6 +29,8 @@ import com.fabledsword.minstrel.cache.db.entities.CachedHistorySnapshotEntity
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationSettingsEntity
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
@@ -66,7 +69,14 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
CachedHistorySnapshotEntity::class,
AuthSessionEntity::class,
DiagnosticEventEntity::class,
CachedNotificationEntity::class,
CachedNotificationSettingsEntity::class,
],
// v12: + auth_session.backgroundDelivery and notifiedUpTo, the device's
// background-delivery choice and its shade high-water mark (M489 #5347).
// v11: + cached_notifications and cached_notification_settings, the
// notifications inbox and its settings (M489). MIGRATION_10_11 creates
// both; nothing to backfill, the first refresh fills them.
// v10: + cached_tracks.trackGain/trackPeak and cached_albums.albumGain/
// albumPeak, the ReplayGain values the player levels by (M464 #5000).
// MIGRATION_9_10 also rewinds the sync cursor, so the next sync re-sends
@@ -82,7 +92,7 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
// which is exactly right here: the next sync refills every row with the
// new column populated, so there is nothing to migrate by hand.
version = 10,
version = 12,
exportSchema = true,
)
@TypeConverters(MinstrelTypeConverters::class)
@@ -102,6 +112,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
abstract fun authSessionDao(): AuthSessionDao
abstract fun diagnosticEventDao(): DiagnosticEventDao
abstract fun cachedNotificationDao(): CachedNotificationDao
}
/** v8 → v9: add the nullable normalization preference column (#4998). */
@@ -125,3 +136,34 @@ val MIGRATION_9_10: Migration = object : Migration(9, 10) {
db.execSQL("UPDATE sync_metadata SET cursor = 0")
}
}
/**
* v10 → v11: the notifications inbox cache and its settings (M489). The SQL
* matches what Room generates for the two entities; Room checks it on open.
*/
val MIGRATION_10_11: Migration = object : Migration(10, 11) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS `cached_notifications` (" +
"`id` TEXT NOT NULL, `kind` TEXT NOT NULL, `title` TEXT NOT NULL, " +
"`body` TEXT NOT NULL, `link` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, " +
"`readAt` INTEGER, PRIMARY KEY(`id`))",
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS `cached_notification_settings` (" +
"`id` INTEGER NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`id`))",
)
}
}
/**
* v11 → v12: background delivery (M489 #5347). The device choice defaults on,
* as the entity's column default says; the high-water mark starts empty, so
* the first catch-up sets it without announcing anything.
*/
val MIGRATION_11_12: Migration = object : Migration(11, 12) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE auth_session ADD COLUMN backgroundDelivery INTEGER NOT NULL DEFAULT 1")
db.execSQL("ALTER TABLE auth_session ADD COLUMN notifiedUpTo INTEGER")
}
}
@@ -10,6 +10,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
@@ -37,7 +38,7 @@ object DatabaseModule {
// launch, so users lose only the unsynced mutation queue
// (acceptable while we're iterating). Replace with explicit
// Migration entries before the first tagged release.
.addMigrations(MIGRATION_8_9, MIGRATION_9_10)
.addMigrations(MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12)
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
@@ -112,5 +113,10 @@ object DatabaseModule {
fun provideDiagnosticEventDao(db: AppDatabase): DiagnosticEventDao =
db.diagnosticEventDao()
@Provides
@Singleton
fun provideCachedNotificationDao(db: AppDatabase): CachedNotificationDao =
db.cachedNotificationDao()
private const val DATABASE_NAME = "minstrel.db"
}
@@ -6,6 +6,7 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.datetime.Instant
@Dao
interface AuthSessionDao {
@@ -50,4 +51,12 @@ interface AuthSessionDao {
/** Partial update: change only the serialized normalization preference. */
@Query("UPDATE auth_session SET normalizationJson = :json WHERE id = 0")
suspend fun setNormalizationJson(json: String?)
/** Partial update: change only the background-delivery choice. */
@Query("UPDATE auth_session SET backgroundDelivery = :enabled WHERE id = 0")
suspend fun setBackgroundDelivery(enabled: Boolean)
/** Partial update: change only the shade's high-water mark. */
@Query("UPDATE auth_session SET notifiedUpTo = :upTo WHERE id = 0")
suspend fun setNotifiedUpTo(upTo: Instant?)
}
@@ -0,0 +1,51 @@
package com.fabledsword.minstrel.cache.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationSettingsEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.datetime.Instant
@Dao
interface CachedNotificationDao {
@Query("SELECT * FROM cached_notifications ORDER BY createdAt DESC, id DESC")
fun observeAll(): Flow<List<CachedNotificationEntity>>
@Query("SELECT COUNT(*) FROM cached_notifications WHERE readAt IS NULL")
fun observeUnreadCount(): Flow<Int>
@Query("SELECT * FROM cached_notifications")
suspend fun getAll(): List<CachedNotificationEntity>
@Query("DELETE FROM cached_notifications")
suspend fun clear()
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(rows: List<CachedNotificationEntity>)
/** The newest page replaces the cache whole: a notice gone server-side goes here too. */
@Transaction
suspend fun replaceAll(rows: List<CachedNotificationEntity>) {
clear()
insertAll(rows)
}
@Query("UPDATE cached_notifications SET readAt = :at WHERE id = :id AND readAt IS NULL")
suspend fun markRead(id: String, at: Instant)
@Query("UPDATE cached_notifications SET readAt = :at WHERE readAt IS NULL")
suspend fun markAllRead(at: Instant)
@Query("SELECT * FROM cached_notification_settings WHERE id = 1")
fun observeSettings(): Flow<CachedNotificationSettingsEntity?>
@Query("SELECT * FROM cached_notification_settings WHERE id = 1")
suspend fun getSettings(): CachedNotificationSettingsEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertSettings(row: CachedNotificationSettingsEntity)
}
@@ -1,7 +1,9 @@
package com.fabledsword.minstrel.cache.db.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.datetime.Instant
/**
* Single-row table holding the user's session cookie, configured
@@ -49,4 +51,17 @@ data class AuthSessionEntity(
* defaults apply. Kept so offline playback still levels.
*/
val normalizationJson: String? = null,
/**
* "Notifications when the app is closed" (M489 #5347): keep the
* delivery foreground service running. A device choice, on by default.
* The column default matches MIGRATION_11_12's, which Room checks.
*/
@ColumnInfo(defaultValue = "1")
val backgroundDelivery: Boolean = true,
/**
* The newest notice this device has announced in the shade. A catch-up
* announces only what is newer, so a reboot or a reconnect never
* re-announces a backlog. Cleared on sign-out.
*/
val notifiedUpTo: Instant? = null,
)
@@ -0,0 +1,21 @@
package com.fabledsword.minstrel.cache.db.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.datetime.Instant
/**
* One notice from the user's inbox (M489), kept so the Notifications screen
* and the bell's badge work offline, and so a read made offline shows at once.
* The newest page is cached; older notices are the server's to keep.
*/
@Entity(tableName = "cached_notifications")
data class CachedNotificationEntity(
@PrimaryKey val id: String,
val kind: String,
val title: String,
val body: String,
val link: String,
val createdAt: Instant,
val readAt: Instant?,
)
@@ -0,0 +1,18 @@
package com.fabledsword.minstrel.cache.db.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Single-row copy of the user's notification settings (M489), stored as the
* wire JSON, so the settings screen opens offline and a toggle shows at once.
*/
@Entity(tableName = "cached_notification_settings")
data class CachedNotificationSettingsEntity(
@PrimaryKey val id: Int = SINGLETON_ID,
val json: String,
) {
companion object {
const val SINGLETON_ID = 1
}
}
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.cache.mutations
import com.fabledsword.minstrel.api.endpoints.NotificationSettingChangeWire
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
@@ -47,6 +48,17 @@ object MutationKind {
// preference, a target state like the toggles above, so queued changes
// collapse to the last one and an older one can never be replayed last.
const val NORMALIZATION_SET: String = "normalization_set"
// M489 notifications inbox. A read is a one-way action (read never goes
// back to unread), so neither read kind needs collapsing. Read-all
// carries the moment the user asked, so a late replay leaves newer
// notices unread.
const val NOTIFICATION_READ: String = "notification_read"
const val NOTIFICATIONS_READ_ALL: String = "notifications_read_all"
// M489 per-kind channel setting. One row per (kind, channel) target
// state, collapsed on that pair, so the newest choice is the one sent.
const val NOTIFICATION_SETTING_SET: String = "notification_setting_set"
}
/**
@@ -190,6 +202,25 @@ class MutationQueue @Inject constructor(
json.encodeToString(NormalizationPrefs.serializer(), prefs),
)
suspend fun enqueueNotificationRead(id: String): Long = insertUserDriven(
MutationKind.NOTIFICATION_READ,
json.encodeToString(NotificationReadPayload.serializer(), NotificationReadPayload(id)),
)
suspend fun enqueueNotificationsReadAll(upToIso: String?): Long = insertUserDriven(
MutationKind.NOTIFICATIONS_READ_ALL,
json.encodeToString(
NotificationsReadAllPayload.serializer(),
NotificationsReadAllPayload(upToIso),
),
)
suspend fun enqueueNotificationSettingSet(payload: NotificationSettingPayload): Long =
insertUserDriven(
MutationKind.NOTIFICATION_SETTING_SET,
json.encodeToString(NotificationSettingPayload.serializer(), payload),
)
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
MutationKind.REQUEST_CANCEL,
json.encodeToString(
@@ -335,3 +366,36 @@ data class PlaybackErrorReportPayload(
val detail: String? = null,
val clientId: String,
)
/** Persisted payload for `MutationKind.NOTIFICATION_READ` (M489). */
@Serializable
data class NotificationReadPayload(val id: String)
/**
* Persisted payload for `MutationKind.NOTIFICATIONS_READ_ALL` (M489).
* `upToIso` is the newest notice the user could see when they asked; null
* when the inbox was empty on the device, which marks everything.
*/
@Serializable
data class NotificationsReadAllPayload(val upToIso: String?)
/**
* Persisted payload for `MutationKind.NOTIFICATION_SETTING_SET` (M489): one
* kind's one channel, as a target state. `channel` is "inbox" | "phone" |
* "email". No defaults, so every field is always written.
*/
@Serializable
data class NotificationSettingPayload(
val kind: String,
val channel: String,
val value: Boolean,
)
/** The wire change for one queued channel setting, or null for an unknown channel. */
internal fun notificationSettingChange(p: NotificationSettingPayload): NotificationSettingChangeWire? =
when (p.channel) {
"inbox" -> NotificationSettingChangeWire(kind = p.kind, inbox = p.value)
"phone" -> NotificationSettingChangeWire(kind = p.kind, phone = p.value)
"email" -> NotificationSettingChangeWire(kind = p.kind, email = p.value)
else -> null
}
@@ -8,6 +8,9 @@ import com.fabledsword.minstrel.api.endpoints.EventsApi
import com.fabledsword.minstrel.api.endpoints.FlagRequest
import com.fabledsword.minstrel.api.endpoints.LikesApi
import com.fabledsword.minstrel.api.endpoints.MeApi
import com.fabledsword.minstrel.api.endpoints.NotificationsApi
import com.fabledsword.minstrel.api.endpoints.PutNotificationSettingsBody
import com.fabledsword.minstrel.api.endpoints.ReadAllBody
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
@@ -82,6 +85,7 @@ class MutationReplayer @Inject constructor(
private val requestsApi: RequestsApi = retrofit.create()
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
private val meApi: MeApi = retrofit.create()
private val notificationsApi: NotificationsApi = retrofit.create()
private val mutex = Mutex()
@@ -170,10 +174,22 @@ class MutationReplayer @Inject constructor(
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
MutationKind.NORMALIZATION_SET -> dispatchNormalizationSet(row.payload)
MutationKind.NOTIFICATION_READ,
MutationKind.NOTIFICATIONS_READ_ALL,
MutationKind.NOTIFICATION_SETTING_SET,
-> dispatchNotification(row)
// Unknown kind — drop so a stale schema entry can't wedge the queue.
else -> Outcome.DROP
}
/** The notifications inbox's kinds (M489), split out to keep [dispatch] simple. */
private suspend fun dispatchNotification(row: CachedMutationEntity): Outcome = when (row.kind) {
MutationKind.NOTIFICATION_READ -> dispatchNotificationRead(row.payload)
MutationKind.NOTIFICATIONS_READ_ALL -> dispatchNotificationsReadAll(row.payload)
MutationKind.NOTIFICATION_SETTING_SET -> dispatchNotificationSettingSet(row.payload)
else -> Outcome.DROP
}
private suspend fun dispatchLikeToggle(payload: String): Outcome {
val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload)
val kindPath = when (decoded.entityType) {
@@ -293,6 +309,27 @@ class MutationReplayer @Inject constructor(
return Outcome.SENT
}
/** A 404 (the notice was trimmed or already gone) is a 4xx, so DROP: nothing left to do. */
private suspend fun dispatchNotificationRead(payload: String): Outcome {
val decoded = json.decodeFromString(NotificationReadPayload.serializer(), payload)
notificationsApi.markRead(decoded.id)
return Outcome.SENT
}
private suspend fun dispatchNotificationsReadAll(payload: String): Outcome {
val decoded = json.decodeFromString(NotificationsReadAllPayload.serializer(), payload)
notificationsApi.readAll(ReadAllBody(upTo = decoded.upToIso))
return Outcome.SENT
}
/** An unknown channel can only come from a corrupt row: DROP it. */
private suspend fun dispatchNotificationSettingSet(payload: String): Outcome {
val decoded = json.decodeFromString(NotificationSettingPayload.serializer(), payload)
val change = notificationSettingChange(decoded) ?: return Outcome.DROP
notificationsApi.putSettings(PutNotificationSettingsBody(listOf(change)))
return Outcome.SENT
}
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
playbackErrorsApi.report(
@@ -355,6 +392,10 @@ private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when (
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
MutationKind.NOTIFICATION_SETTING_SET -> runCatching {
json.decodeFromString(NotificationSettingPayload.serializer(), row.payload)
}.getOrNull()?.let { "${row.kind}:${it.kind}:${it.channel}" }
// One preference per user, so every normalization row shares one key.
MutationKind.NORMALIZATION_SET -> runCatching {
json.decodeFromString(NormalizationPrefs.serializer(), row.payload)
@@ -1,14 +1,18 @@
package com.fabledsword.minstrel.events
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
import com.fabledsword.minstrel.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@@ -28,9 +32,6 @@ import javax.inject.Singleton
private const val SSE_PATH = "/api/events/stream"
private const val EVENTS_BUFFER_CAPACITY = 64
private const val BASE_BACKOFF_MS = 1_000L
private const val MAX_BACKOFF_MS = 30_000L
private const val BACKOFF_FACTOR = 2
/**
* Long-lived SSE subscription to `GET /api/events/stream`. Exposes
@@ -45,11 +46,16 @@ private const val BACKOFF_FACTOR = 2
* - No client-side timeout — the server emits 15s heartbeats which
* okhttp-sse handles transparently.
* - Reconnect-with-backoff: if the stream drops mid-session (server
* restart, network blip) it reconnects with exponential backoff
* (1s → 2s → … → 30s cap), reset to 1s on a successful open. Only
* restart, network blip) it reconnects after [ReconnectBackoff]'s
* jittered wait (2s doubling to 5 min), reset on a successful open.
* A network coming up reconnects at once: the callback is a hint, and
* the only test of whether the server is reachable is trying it. Only
* reconnects while still signed in; a sign-out cancels the pending
* retry. Without this a single blip silently kills cross-device
* reactivity until the next app launch.
* - [connected] says whether a stream is open. Background delivery
* (M489 #5347) catches up on every rising edge: nothing replays a
* frame sent while the stream was down.
*
* The URL passes through the placeholder host that
* `BaseUrlInterceptor` rewrites — same mechanism the rest of the
@@ -62,6 +68,7 @@ class EventsStream @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
private val okHttpClient: OkHttpClient,
private val json: Json,
private val connectivity: ConnectivityObserver,
) {
private val factory = EventSources.createFactory(okHttpClient)
@@ -72,10 +79,13 @@ class EventsStream @Inject constructor(
)
val events: SharedFlow<LiveEvent> = emitter.asSharedFlow()
private val connectedState = MutableStateFlow(false)
val connected: StateFlow<Boolean> = connectedState.asStateFlow()
private var currentSource: EventSource? = null
@Volatile private var signedIn = false
private var reconnectJob: Job? = null
private var backoffMs = BASE_BACKOFF_MS
private var backoffMs = ReconnectBackoff.BASE_MS
init {
scope.launch {
@@ -85,13 +95,30 @@ class EventsStream @Inject constructor(
.collect { isSignedIn ->
signedIn = isSignedIn
if (isSignedIn) {
backoffMs = BASE_BACKOFF_MS
backoffMs = ReconnectBackoff.BASE_MS
connect()
} else {
disconnect()
}
}
}
scope.launch {
connectivity.online.collect { up -> if (up) reconnectNow() }
}
}
/**
* Cuts a pending backoff short: reconnects at once and starts the ladder
* over. For a network that has just come up, or the app coming to the
* foreground, where waiting out a five-minute backoff would leave the
* server unheard from for nothing. Does nothing while a stream is open or
* opening.
*/
@Synchronized
fun reconnectNow() {
if (!signedIn || reconnectJob?.isActive != true) return
backoffMs = ReconnectBackoff.BASE_MS
connect()
}
@Synchronized
@@ -106,20 +133,22 @@ class EventsStream @Inject constructor(
reconnectJob?.cancel()
currentSource?.cancel()
currentSource = null
connectedState.value = false
}
/**
* Schedule a reconnect after the current backoff, then double it
* (capped). No-op when signed out — sign-out's [disconnect]
* Schedule a reconnect after the current backoff, jittered, then
* double it (capped). No-op when signed out — sign-out's [disconnect]
* cancels the pending job. A successful [Listener.onOpen] resets
* the backoff to the floor.
*/
@Synchronized
private fun scheduleReconnect() {
connectedState.value = false
if (!signedIn) return
reconnectJob?.cancel()
val waitMs = backoffMs
backoffMs = (backoffMs * BACKOFF_FACTOR).coerceAtMost(MAX_BACKOFF_MS)
val waitMs = ReconnectBackoff.jittered(backoffMs)
backoffMs = ReconnectBackoff.next(backoffMs)
reconnectJob = scope.launch {
delay(waitMs)
if (signedIn) connect()
@@ -136,7 +165,8 @@ class EventsStream @Inject constructor(
private inner class Listener : EventSourceListener() {
override fun onOpen(eventSource: EventSource, response: Response) {
backoffMs = BASE_BACKOFF_MS
backoffMs = ReconnectBackoff.BASE_MS
connectedState.value = true
}
override fun onEvent(
@@ -5,6 +5,7 @@ import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -30,6 +31,7 @@ import javax.inject.Singleton
class LiveEventsDispatcher @Inject constructor(
private val eventsStream: EventsStream,
private val likes: LikesRepository,
private val notifications: NotificationsRepository,
@ApplicationScope private val scope: CoroutineScope,
) : DefaultLifecycleObserver {
@@ -49,6 +51,8 @@ class LiveEventsDispatcher @Inject constructor(
"artist.liked",
"artist.unliked",
-> refreshLikes()
// M489: a contentless nudge; the inbox refetches its newest page.
"notification.created" -> refreshNotifications()
}
// Other kinds (playlist.*, quarantine.*, request.status_changed,
// scan.*) reach screen-scoped subscribers via EventsStream
@@ -58,9 +62,18 @@ class LiveEventsDispatcher @Inject constructor(
override fun onStart(owner: LifecycleOwner) {
// App returned to the foreground. SSE will catch up but might
// not have reconnected yet; flush the cross-screen refreshes
// not have reconnected yet (it may be deep in its backoff, so it
// is told to try now); flush the cross-screen refreshes
// defensively.
eventsStream.reconnectNow()
refreshLikes()
refreshNotifications()
}
private fun refreshNotifications() {
scope.launch {
runCatching { notifications.refresh() }
}
}
private fun refreshLikes() {
@@ -0,0 +1,31 @@
package com.fabledsword.minstrel.events
import kotlin.random.Random
/**
* How long [EventsStream] waits before reconnecting (M489 #5347).
*
* The stream is wanted around the clock once notifications arrive with the app
* closed, so a server that is down overnight must not cost a radio wakeup
* every few seconds: Roundtable's flat 2s came to about 43,000 of them. The
* wait doubles from [BASE_MS] to [MAX_MS]. The jitter spreads the moment every
* phone on the server reconnects, which is when the server has just come back
* and can least take a spike.
*
* A network coming up (a hint, never a gate) or the app coming to the
* foreground reconnects at once and starts the ladder over.
*/
internal object ReconnectBackoff {
const val BASE_MS = 2_000L
const val MAX_MS = 300_000L
private const val JITTER = 0.25
/** The wait after [currentMs], before jitter. */
fun next(currentMs: Long): Long = (currentMs * 2).coerceAtMost(MAX_MS)
/** [ms] moved by up to a quarter either way. */
fun jittered(ms: Long, random: Random = Random.Default): Long {
val spread = ms * JITTER
return (ms + random.nextDouble(-spread, spread)).toLong()
}
}
@@ -25,6 +25,8 @@ import com.fabledsword.minstrel.home.ui.HomeScreen
import com.fabledsword.minstrel.library.ui.AlbumDetailScreen
import com.fabledsword.minstrel.library.ui.ArtistDetailScreen
import com.fabledsword.minstrel.library.ui.LibraryScreen
import com.fabledsword.minstrel.notifications.ui.NotificationSettingsScreen
import com.fabledsword.minstrel.notifications.ui.NotificationsScreen
import com.fabledsword.minstrel.player.ui.NowPlayingScreen
import com.fabledsword.minstrel.player.ui.QueueScreen
import com.fabledsword.minstrel.playlists.ui.PlaylistDetailScreen
@@ -59,6 +61,7 @@ fun MinstrelNavGraph(
) {
inShellTopLevel(navController, expandPlayer)
inShellDetail(navController, expandPlayer)
inShellNotifications(navController, expandPlayer)
outsideShell(navController)
}
}
@@ -200,6 +203,27 @@ private fun NavGraphBuilder.inShellDetail(
}
}
/** The notifications inbox and its settings (M489). */
private fun NavGraphBuilder.inShellNotifications(
navController: NavHostController,
expandPlayer: () -> Unit,
) {
composable<Notifications> {
WithAnimatedScope {
ShellScaffold(onExpandPlayer = expandPlayer) {
NotificationsScreen(navController = navController)
}
}
}
composable<NotificationSettings> {
WithAnimatedScope {
ShellScaffold(onExpandPlayer = expandPlayer) {
NotificationSettingsScreen(navController = navController)
}
}
}
}
private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
composable<NowPlaying>(
// Slide up from the bottom on enter; back down on dismiss.
@@ -13,6 +13,8 @@ import kotlinx.serialization.Serializable
@Serializable data object Settings
@Serializable data object Admin
@Serializable data object Requests
@Serializable data object Notifications
@Serializable data object NotificationSettings
// ── In-shell detail / push-on-top destinations ────────────────────────
@@ -0,0 +1,104 @@
package com.fabledsword.minstrel.notifications.data
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
import com.fabledsword.minstrel.api.endpoints.NotificationsApi
import com.fabledsword.minstrel.api.endpoints.PutNotificationSettingsBody
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationSettingsEntity
import com.fabledsword.minstrel.cache.mutations.MutationKind
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import com.fabledsword.minstrel.cache.mutations.NotificationSettingPayload
import com.fabledsword.minstrel.cache.mutations.notificationSettingChange
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
import retrofit2.Retrofit
import retrofit2.create
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/** A notification channel, as the server names it. */
enum class NotificationChannel(val wire: String) { INBOX("inbox"), PHONE("phone"), EMAIL("email") }
/**
* Per-user notification settings (M489), following snippet #5107: the device
* copy (a cached JSON row) shows a change at once, the PUT is best effort,
* and a failed or out-of-order one is queued for the MutationReplayer.
*/
@Singleton
class NotificationSettingsRepository @Inject constructor(
retrofit: Retrofit,
private val dao: CachedNotificationDao,
private val mutationDao: CachedMutationDao,
private val mutationQueue: MutationQueue,
private val json: Json,
) {
private val api: NotificationsApi = retrofit.create()
val settings: Flow<NotificationSettingsWire?> = dao.observeSettings().map { it?.let(::decode) }
/**
* Takes the server's settings unless a change made here is still queued:
* the server has not seen it, and its older value would undo the change
* on screen until the replay lands. Throws on a network failure.
*/
suspend fun refresh() {
val server = api.getSettings()
if (mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET)) return
save(server)
}
suspend fun set(kind: String, channel: NotificationChannel, value: Boolean) {
current()?.let { save(it.withChannel(kind, channel, value)) }
val payload = NotificationSettingPayload(kind = kind, channel = channel.wire, value = value)
// An earlier change still queued would replay after this PUT; queue
// behind it instead, and the replayer sends the newest per channel.
if (mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET)) {
mutationQueue.enqueueNotificationSettingSet(payload)
return
}
val change = notificationSettingChange(payload) ?: return
try {
save(api.putSettings(PutNotificationSettingsBody(listOf(change))))
} catch (e: CancellationException) {
throw e
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
Timber.i(e, "notification settings: PUT failed; queued")
mutationQueue.enqueueNotificationSettingSet(payload)
}
}
private suspend fun current(): NotificationSettingsWire? = dao.getSettings()?.let(::decode)
/** Null for an unreadable row: the next refresh writes a good one. */
private fun decode(row: CachedNotificationSettingsEntity): NotificationSettingsWire? =
runCatching { json.decodeFromString(NotificationSettingsWire.serializer(), row.json) }.getOrNull()
private suspend fun save(s: NotificationSettingsWire) {
val encoded = json.encodeToString(NotificationSettingsWire.serializer(), s)
dao.upsertSettings(CachedNotificationSettingsEntity(json = encoded))
}
}
internal fun NotificationSettingsWire.withChannel(
kind: String,
channel: NotificationChannel,
value: Boolean,
): NotificationSettingsWire = copy(
kinds = kinds.map { k ->
if (k.kind != kind) {
k
} else {
when (channel) {
NotificationChannel.INBOX -> k.copy(inbox = value)
NotificationChannel.PHONE -> k.copy(phone = value)
NotificationChannel.EMAIL -> k.copy(email = value)
}
}
},
)
@@ -0,0 +1,132 @@
package com.fabledsword.minstrel.notifications.data
import com.fabledsword.minstrel.api.endpoints.NotificationWire
import com.fabledsword.minstrel.api.endpoints.NotificationsApi
import com.fabledsword.minstrel.api.endpoints.ReadAllBody
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.create
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
/**
* The notifications inbox (M489). The newest page lives in Room so the bell's
* badge and the Notifications screen work offline. Reads follow rule 100: the
* device marks the row at once, the call is best effort, and a failed call is
* queued for the MutationReplayer.
*/
@Singleton
class NotificationsRepository @Inject constructor(
retrofit: Retrofit,
private val dao: CachedNotificationDao,
private val mutationQueue: MutationQueue,
) {
private val api: NotificationsApi = retrofit.create()
val notifications: Flow<List<CachedNotificationEntity>> = dao.observeAll().distinctUntilChanged()
/** Unread notices in the cached page, which is what the badge shows. */
val unreadCount: Flow<Int> = dao.observeUnreadCount().distinctUntilChanged()
/**
* Replaces the cache with the server's newest page. A read made on this
* device and not yet replayed stays read: a read never goes back to
* unread, so the device's mark wins over the server's older view.
* Throws on a network failure; the cache then stands.
*/
suspend fun refresh() {
val page = api.list(limit = PAGE_SIZE)
val localReads = dao.getAll().associate { it.id to it.readAt }
dao.replaceAll(page.items.mapNotNull { it.toEntity(localReads[it.id]) })
}
suspend fun markRead(id: String) {
dao.markRead(id, Clock.System.now())
try {
api.markRead(id)
} catch (e: CancellationException) {
throw e
} catch (e: HttpException) {
// A 4xx (404: trimmed or gone server-side) leaves nothing to mark;
// a 5xx is the server's trouble, so the read waits in the queue.
if (e.code() >= HTTP_SERVER_ERROR) {
mutationQueue.enqueueNotificationRead(id)
} else {
Timber.i(e, "notifications: mark read refused (%d)", e.code())
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
Timber.i(e, "notifications: mark read failed; queued")
mutationQueue.enqueueNotificationRead(id)
}
}
/**
* Marks everything the device holds read. The server is asked to mark
* only what existed up to the newest notice shown here, so a replay that
* lands later leaves anything newer unread.
*/
suspend fun markAllRead() {
val upTo = readAllCutoff(dao.getAll().map { it.createdAt })?.toString()
dao.markAllRead(Clock.System.now())
try {
api.readAll(ReadAllBody(upTo = upTo))
} catch (e: CancellationException) {
throw e
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
Timber.i(e, "notifications: mark all read failed; queued")
mutationQueue.enqueueNotificationsReadAll(upTo)
}
}
/** The page the device holds, newest first. */
suspend fun cachedPage(): List<CachedNotificationEntity> = dao.getAll()
/** On sign-out: the next account on this device must not see these. */
suspend fun clearLocal() {
dao.clear()
}
companion object {
/** The newest page the device keeps; older notices stay on the server. */
const val PAGE_SIZE = 50
private const val HTTP_SERVER_ERROR = 500
}
}
/**
* The newest notice shown, rounded up a millisecond. Room keeps milliseconds
* and the server microseconds, so the stored time can sit just before the
* server's; without rounding up, the newest notice would stay unread.
*/
internal fun readAllCutoff(createdAts: List<Instant>): Instant? =
createdAts.maxOrNull()?.plus(1.milliseconds)
/** Null for a row whose timestamp will not parse, rather than failing the page. */
internal fun NotificationWire.toEntity(localReadAt: Instant?): CachedNotificationEntity? {
val created = runCatching { Instant.parse(createdAt) }.getOrNull() ?: return null
val serverRead = readAt?.let { runCatching { Instant.parse(it) }.getOrNull() }
return CachedNotificationEntity(
id = id,
kind = kind,
title = title,
body = body,
link = link,
createdAt = created,
readAt = serverRead ?: localReadAt,
)
}
@@ -0,0 +1,46 @@
package com.fabledsword.minstrel.notifications.delivery
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.di.ApplicationScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Starts background delivery after a reboot, and after the app updates itself
* (M489 #5347). Without it, notifications stop until the user next opens the
* app, which is exactly when they least need telling.
*
* Both broadcasts are among the few exemptions allowed to start a foreground
* service from the background, so the start happens here, inside the
* receiver's window, rather than whenever [DeliveryLauncher] next observes it.
*/
@AndroidEntryPoint
class BootReceiver : BroadcastReceiver() {
@Inject lateinit var authStore: AuthStore
@Inject @ApplicationScope lateinit var scope: CoroutineScope
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED && intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return
val pending = goAsync()
scope.launch {
try {
// Bounded (rule 156): the session load gives up after its
// own deadline and answers from what it has.
authStore.awaitSessionHydrated()
val signedIn = !authStore.sessionCookie.value.isNullOrEmpty()
if (deliveryWanted(signedIn, authStore.backgroundDelivery.value)) {
DeliveryService.start(context)
}
} finally {
pending.finish()
}
}
}
}
@@ -0,0 +1,69 @@
package com.fabledsword.minstrel.notifications.delivery
import android.content.Context
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.events.EventsStream
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* Phone notifications (M489 #5347). Activated by force-@Inject in
* MinstrelApplication, and the single owner of [DeliveryService]'s lifetime:
* it runs exactly while signed in with "Notifications when the app is closed"
* on. Signing out stops it, so a logged-out device holds no connection.
*
* The catch-up runs here, in the process, whether or not the service does:
* with background delivery off, notifications still reach the shade while the
* process is alive (the app open, or music playing).
*/
@Singleton
class DeliveryLauncher @Inject constructor(
@ApplicationContext private val context: Context,
authStore: AuthStore,
eventsStream: EventsStream,
sync: NotificationSync,
@ApplicationScope scope: CoroutineScope,
) {
init {
val signedIn = authStore.sessionCookie.map { !it.isNullOrEmpty() }.distinctUntilChanged()
scope.launch {
combine(signedIn, authStore.backgroundDelivery, ::deliveryWanted)
.distinctUntilChanged()
.collect { wanted -> if (wanted) DeliveryService.start(context) else DeliveryService.stop(context) }
}
// A nudge and a reconnect mean the same thing: go and look. The
// stream only runs while signed in, so neither fires signed out.
scope.launch {
eventsStream.events
.filter { it.kind == NOTIFICATION_CREATED }
.collect { sync.catchUp() }
}
scope.launch {
eventsStream.connected.filter { it }.collect { sync.catchUp() }
}
// A sign-out, not the signed-out start a cold launch begins with
// before the session loads: that would wipe the mark on every start.
scope.launch {
var wasSignedIn = false
signedIn.collect { now ->
if (wasSignedIn && !now) sync.forget()
wasSignedIn = now
}
}
}
private companion object {
const val NOTIFICATION_CREATED = "notification.created"
}
}
@@ -0,0 +1,94 @@
package com.fabledsword.minstrel.notifications.delivery
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
import kotlinx.datetime.Instant
/**
* The decisions behind phone notifications (M489 #5347), kept free of Android
* so they can be tested as plain functions.
*/
/** A handful each get a line; more than this become one line with a count. */
internal const val INDIVIDUAL_LIMIT = 3
/** What a catch-up announces, and the high-water mark to keep afterwards. */
internal data class CatchUp(
val announce: List<CachedNotificationEntity>,
val upTo: Instant?,
)
/**
* Picks what to announce from the newest page.
*
* - With no mark yet (a fresh sign-in on this device), the mark is set to the
* newest notice and nothing is announced: the user is looking at the app,
* and a backlog in the shade is noise. An empty inbox sets the mark to the
* start of time, so the very first notice is announced.
* - Otherwise: unread notices newer than the mark, whose kind has the phone
* on, oldest first so the newest lands on top of the shade.
* - The mark moves to the newest notice in the page, announced or not, so a
* kind with the phone off is never announced later.
*
* A coalesced notice (tracks missing, now 5) moves its time forward when its
* count grows, so it is announced again with the new count.
*/
internal fun planCatchUp(
page: List<CachedNotificationEntity>,
mark: Instant?,
phoneOn: (String) -> Boolean,
): CatchUp {
val newest = page.maxOfOrNull { it.createdAt }
if (mark == null) return CatchUp(emptyList(), newest ?: Instant.DISTANT_PAST)
val fresh = page
.filter { it.readAt == null && it.createdAt > mark && phoneOn(it.kind) }
.sortedBy { it.createdAt }
val upTo = if (newest != null && newest > mark) newest else mark
return CatchUp(fresh, upTo)
}
/** How a catch-up reaches the shade. */
internal sealed interface Announcement {
data class Each(val items: List<CachedNotificationEntity>) : Announcement
/** Coming back from a day offline is one line, not forty buzzes. */
data class Pile(val count: Int, val channel: ShadeChannel) : Announcement
}
internal fun announcementFor(fresh: List<CachedNotificationEntity>): Announcement? = when {
fresh.isEmpty() -> null
fresh.size <= INDIVIDUAL_LIMIT -> Announcement.Each(fresh)
// A pile goes where its notices would: library health only when every
// one of them is, so a listener's own news is never filed under it.
fresh.all { shadeChannelFor(it.kind) == ShadeChannel.LIBRARY_HEALTH } ->
Announcement.Pile(fresh.size, ShadeChannel.LIBRARY_HEALTH)
else -> Announcement.Pile(fresh.size, ShadeChannel.YOUR_REQUESTS)
}
/** The phone channel per kind, as the user set it. No settings cached: on, the default for every kind. */
internal fun phoneOnFor(settings: NotificationSettingsWire?): (String) -> Boolean {
val byKind = settings?.kinds?.associateBy { it.kind }.orEmpty()
return { kind -> byKind[kind]?.let { it.inbox && it.phone } ?: true }
}
/**
* Android channels, so either audience can be silenced in system settings as
* well as in Minstrel's. The ids are permanent: renaming one orphans the
* user's system-level choice for it.
*/
internal enum class ShadeChannel(val id: String, val title: String, val description: String) {
YOUR_REQUESTS("your_requests", "Your requests", "Approved, declined, and new music arriving"),
LIBRARY_HEALTH("library_health", "Library health", "For admins: requests to review and library problems"),
}
private val requesterKinds = setOf("request_approved", "request_rejected", "request_completed")
/** Everything a listener can receive is theirs; the rest is admin work. */
internal fun shadeChannelFor(kind: String): ShadeChannel =
if (kind in requesterKinds) ShadeChannel.YOUR_REQUESTS else ShadeChannel.LIBRARY_HEALTH
/** The delivery service runs exactly while signed in with background delivery on. */
internal fun deliveryWanted(signedIn: Boolean, enabled: Boolean): Boolean = signedIn && enabled
/** One line for a pile. */
internal fun pileText(count: Int): String = "$count new notifications"
@@ -0,0 +1,124 @@
package com.fabledsword.minstrel.notifications.delivery
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import com.fabledsword.minstrel.MainActivity
import com.fabledsword.minstrel.R
import timber.log.Timber
/**
* Keeps Minstrel's process alive so notifications arrive with the app closed
* (M489 #5347, Roundtable's DeliveryService).
*
* **Why a held connection instead of push.** The APK installs from the
* Minstrel server itself, not the Play Store, so nothing pushes the app onto
* Firebase; the alternatives cost more (UnifiedPush's only embedded
* distributor is Firebase, and ntfy is a second app to install and set up).
*
* **This service holds nothing itself.** [com.fabledsword.minstrel.events.EventsStream]
* stays connected for as long as the process lives and someone is signed in,
* and [DeliveryLauncher] runs the catch-up on every nudge and reconnect. All
* this does is keep the process from being reclaimed. While music plays the
* player's own foreground service does that too; this one still runs, so
* delivery does not depend on playback.
*
* **Typed `specialUse`, and that is not arbitrary.** Android 15 stops a
* `dataSync` service after six hours, and `shortService` is capped at three
* minutes. `specialUse` is the only type that may run as long as the user
* wants it to.
*
* The quiet notice in the shade is the honest price of not using Google's
* push, and the settings screen says so.
*/
class DeliveryService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
ensureChannel()
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
} else {
0
}
ServiceCompat.startForeground(this, NOTIFICATION_ID, ongoing(), type)
// If the system reclaims the process, bring it back: the point is to
// be running when nothing else is.
return START_STICKY
}
private fun ongoing(): Notification {
val open = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Minstrel")
.setContentText("Listening for notifications")
.setOngoing(true)
.setContentIntent(open)
// MIN: at the bottom of the shade, no sound, no heads-up. It has
// to exist; it does not have to be loud.
.setPriority(NotificationCompat.PRIORITY_MIN)
.build()
}
private fun ensureChannel() {
val manager = getSystemService(NotificationManager::class.java) ?: return
manager.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Background connection", NotificationManager.IMPORTANCE_MIN)
.apply { description = "The quiet notice shown while Minstrel listens for notifications" },
)
}
companion object {
const val ACTION_STOP = "com.fabledsword.minstrel.DELIVERY_STOP"
private const val CHANNEL_ID = "background_connection"
private const val NOTIFICATION_ID = 4801
/**
* Starting a foreground service from the background is refused on
* Android 12+ outside the exemptions (boot, an update, the app on
* screen). A refusal is logged, not thrown: the app's next start, or
* the next boot, tries again.
*/
fun start(context: Context) {
try {
context.startForegroundService(Intent(context, DeliveryService::class.java))
} catch (
@Suppress("TooGenericExceptionCaught") e: RuntimeException,
) {
Timber.w(e, "delivery service: start refused")
}
}
fun stop(context: Context) {
try {
context.startService(Intent(context, DeliveryService::class.java).setAction(ACTION_STOP))
} catch (
@Suppress("TooGenericExceptionCaught") e: RuntimeException,
) {
// Not running and not startable from here: nothing to stop.
Timber.i(e, "delivery service: stop not delivered")
}
}
}
}
@@ -0,0 +1,86 @@
package com.fabledsword.minstrel.notifications.delivery
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.fabledsword.minstrel.MainActivity
import com.fabledsword.minstrel.R
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Puts an [Announcement] in the shade (M489 #5347). The words are the
* server's: the same title and body the inbox shows. Tapping one opens what it
* is about; tapping a pile opens the inbox.
*/
@Singleton
class NotificationPoster @Inject constructor(
@ApplicationContext private val context: Context,
) {
// Checked just below: posting is skipped when the user has said no.
@SuppressLint("MissingPermission")
internal fun post(announcement: Announcement) {
val manager = NotificationManagerCompat.from(context)
// Denied POST_NOTIFICATIONS, or every channel silenced: say nothing.
// The inbox still has it all.
if (!manager.areNotificationsEnabled()) return
ensureChannels()
when (announcement) {
is Announcement.Each -> announcement.items.forEach { manager.notify(it.id.hashCode(), single(it)) }
is Announcement.Pile -> manager.notify(PILE_ID, pile(announcement))
}
}
private fun single(item: CachedNotificationEntity) =
NotificationCompat.Builder(context, shadeChannelFor(item.kind).id)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(item.title)
.setContentText(item.body)
.setStyle(NotificationCompat.BigTextStyle().bigText(item.body))
.setWhen(item.createdAt.toEpochMilliseconds())
.setShowWhen(true)
.setAutoCancel(true)
.setContentIntent(openIntent(item.link, item.id.hashCode()))
.build()
private fun pile(p: Announcement.Pile) =
NotificationCompat.Builder(context, p.channel.id)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Minstrel")
.setContentText(pileText(p.count))
.setAutoCancel(true)
.setContentIntent(openIntent(link = null, requestCode = PILE_ID))
.build()
/** Each notice gets its own request code, so their links stay apart. */
private fun openIntent(link: String?, requestCode: Int): PendingIntent =
PendingIntent.getActivity(
context,
requestCode,
Intent(context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
.putExtra(MainActivity.EXTRA_NOTIFICATION_LINK, link.orEmpty()),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
private fun ensureChannels() {
val manager = context.getSystemService(NotificationManager::class.java) ?: return
ShadeChannel.entries.forEach { c ->
manager.createNotificationChannel(
NotificationChannel(c.id, c.title, NotificationManager.IMPORTANCE_DEFAULT)
.apply { description = c.description },
)
}
}
private companion object {
const val PILE_ID = 4802
}
}
@@ -0,0 +1,79 @@
package com.fabledsword.minstrel.notifications.delivery
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.notifications.data.NotificationSettingsRepository
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* Decides what the shade says by asking the server, never by trusting a frame
* (M489 #5347, Roundtable's NotificationSync).
*
* A `notification.created` frame and a reconnect both mean "go and look", so
* both run [catchUp]. The frame carries nothing, so nothing can be announced
* twice: the answer is the server's unread state and this device's
* high-water mark, which survive a reboot. A notice raised while the phone was
* unreachable is not lost either: the event bus replays nothing, but the row
* is still there, unread.
*/
@Singleton
class NotificationSync @Inject constructor(
private val notifications: NotificationsRepository,
private val settings: NotificationSettingsRepository,
private val prefs: AuthSessionDao,
private val poster: NotificationPoster,
) {
// One catch-up at a time: a nudge arriving with a reconnect must not
// read the mark before the other has moved it.
private val mutex = Mutex()
/**
* Announces what is new since the mark, then moves it. Failure is quiet on
* purpose: this runs on a reconnect, exactly when a request is most likely
* to lose a race with a network still settling, and the next trigger tries
* again. While the app is on screen nothing is posted (the bell shows it),
* but the mark still moves, so it is not announced later either.
*/
suspend fun catchUp() {
mutex.withLock { catchUpLocked() }
}
private suspend fun catchUpLocked() {
try {
notifications.refresh()
} catch (e: CancellationException) {
throw e
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
Timber.i(e, "notification catch-up: refresh failed; next trigger retries")
return
}
val plan = planCatchUp(
page = notifications.cachedPage(),
mark = prefs.get()?.notifiedUpTo,
phoneOn = phoneOnFor(settings.settings.first()),
)
if (!appOnScreen()) announcementFor(plan.announce)?.let(poster::post)
plan.upTo?.let { prefs.setNotifiedUpTo(it) }
}
/** On sign-out: the next account starts with no mark, so it announces no backlog. */
suspend fun forget() {
mutex.withLock { prefs.setNotifiedUpTo(null) }
}
private suspend fun appOnScreen(): Boolean = withContext(Dispatchers.Main) {
ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
}
}
@@ -0,0 +1,27 @@
package com.fabledsword.minstrel.notifications.ui
import com.fabledsword.minstrel.nav.Admin
import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Requests
/**
* The app screen for a notice's link. The server writes web paths, the same
* for every client; this maps them onto the app's routes. Admin pages the app
* has no screen for (missing files, duplicates, playback errors) open the
* Admin landing; anything unknown opens nothing.
*/
internal fun routeForLink(link: String): Any? {
val parts = link.trim('/').split('/').filter { it.isNotEmpty() }
return when {
parts.size == 2 && parts[0] == "albums" -> AlbumDetail(parts[1])
parts.size == 2 && parts[0] == "artists" -> ArtistDetail(parts[1])
parts == listOf("requests") -> Requests
parts == listOf("admin", "requests") -> AdminRequests
parts == listOf("admin", "quarantine") -> AdminQuarantine
parts.firstOrNull() == "admin" -> Admin
else -> null
}
}
@@ -0,0 +1,312 @@
@file:Suppress("TooManyFunctions") // Compose screen + private row composables
package com.fabledsword.minstrel.notifications.ui
import android.Manifest
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationManagerCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.composables.icons.lucide.ChevronRight
import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.api.endpoints.NotificationKindSettingWire
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
import com.fabledsword.minstrel.nav.NotificationSettings
import com.fabledsword.minstrel.notifications.data.NotificationChannel
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
private val CELL_WIDTH = 56.dp
/**
* Per-user notification settings (M489): a row per kind with a box for each
* channel. Rows read as a menu (rule 188); anything that needs explaining
* is one short line.
*/
@Composable
fun NotificationSettingsScreen(
navController: NavHostController,
viewModel: NotificationSettingsViewModel = hiltViewModel(),
) {
val settings by viewModel.settings.collectAsStateWithLifecycle()
val isAdmin by viewModel.isAdmin.collectAsStateWithLifecycle()
val backgroundDelivery by viewModel.backgroundDelivery.collectAsStateWithLifecycle()
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
title = "Notifications",
navController = navController,
currentRouteName = NotificationSettings::class.qualifiedName,
onBack = { navController.popBackStack() },
)
},
) { inner ->
val s = settings
if (s == null) {
LoadingCentered(modifier = Modifier.padding(inner))
} else {
SettingsBody(
settings = s,
isAdmin = isAdmin,
deviceRows = { DeviceRows(backgroundDelivery, viewModel::setBackgroundDelivery) },
onSet = viewModel::set,
modifier = Modifier.padding(inner),
)
}
}
}
@Composable
private fun SettingsBody(
settings: NotificationSettingsWire,
isAdmin: Boolean,
deviceRows: @Composable () -> Unit,
onSet: (String, NotificationChannel, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
deviceRows()
HeaderRow()
settings.kinds.filter { !it.adminOnly }.forEach { KindRow(it, settings.emailAvailable, onSet) }
val adminKinds = settings.kinds.filter { it.adminOnly }
if (adminKinds.isNotEmpty()) {
SectionLabel("Library health")
adminKinds.forEach { KindRow(it, settings.emailAvailable, onSet) }
}
if (!settings.emailAvailable) {
Muted(emailUnavailableLine(settings.emailUnavailableReason, isAdmin))
}
}
}
@Composable
private fun HeaderRow() {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.weight(1f))
NotificationChannel.entries.forEach { c ->
Text(
text = channelLabel(c),
modifier = Modifier.width(CELL_WIDTH),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
@Composable
private fun KindRow(
row: NotificationKindSettingWire,
emailAvailable: Boolean,
onSet: (String, NotificationChannel, Boolean) -> Unit,
) {
val label = kindLabel(row.kind)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(label, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge)
NotificationChannel.entries.forEach { c ->
val enabled = channelEnabled(row, c, emailAvailable)
Box(Modifier.width(CELL_WIDTH), contentAlignment = Alignment.Center) {
Checkbox(
checked = channelValue(row, c) && enabled,
onCheckedChange = { onSet(row.kind, c, it) },
enabled = enabled,
modifier = Modifier.semantics { contentDescription = "$label: ${channelLabel(c)}" },
)
}
}
}
}
/**
* What this device does, above the per-kind grid that follows the account:
* whether notifications arrive with the app closed (M489 #5347), and whether
* the system lets Minstrel post them at all.
*/
@Composable
private fun DeviceRows(backgroundDelivery: Boolean, onBackgroundDelivery: (Boolean) -> Unit) {
val context = LocalContext.current
// Re-read on every resume: the user may come back from system settings.
var phoneAllowed by remember { mutableStateOf(true) }
LifecycleResumeEffect(Unit) {
phoneAllowed = NotificationManagerCompat.from(context).areNotificationsEnabled()
onPauseOrDispose { }
}
val askToNotify = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
phoneAllowed = granted
}
BackgroundDeliveryRow(
checked = backgroundDelivery,
onChange = { on ->
onBackgroundDelivery(on)
// Android 13+: turning it on is the moment to ask, if not yet allowed.
if (on && !phoneAllowed && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
askToNotify.launch(Manifest.permission.POST_NOTIFICATIONS)
}
},
)
if (!phoneAllowed) {
PhoneBlockedRow(onClick = { openAppNotificationSettings(context) })
}
}
@Composable
private fun BackgroundDeliveryRow(checked: Boolean, onChange: (Boolean) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.toggleable(value = checked, role = Role.Switch, onValueChange = onChange)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(Modifier.weight(1f)) {
Text("Notifications when the app is closed", style = MaterialTheme.typography.bodyLarge)
// The ongoing notice is the price of not using Google's push;
// said plainly rather than left to be discovered.
Muted("Keeps a quiet notice in the shade, in place of Google's push", padded = false)
}
Switch(checked = checked, onCheckedChange = null)
}
}
@Composable
private fun PhoneBlockedRow(onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(Modifier.weight(1f)) {
Text("Phone alerts are off", style = MaterialTheme.typography.bodyLarge)
Muted("Allow notifications for Minstrel in system settings", padded = false)
}
Icon(Lucide.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun SectionLabel(text: String) {
Text(
text = text,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable
private fun Muted(text: String, padded: Boolean = true) {
Text(
text = text,
modifier = if (padded) Modifier.padding(horizontal = 16.dp, vertical = 12.dp) else Modifier,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
private fun openAppNotificationSettings(context: Context) {
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { context.startActivity(intent) }
}
/** Phone and email ride on the inbox; email also needs an address and SMTP. */
internal fun channelEnabled(
row: NotificationKindSettingWire,
channel: NotificationChannel,
emailAvailable: Boolean,
): Boolean = when (channel) {
NotificationChannel.INBOX -> true
NotificationChannel.PHONE -> row.inbox
NotificationChannel.EMAIL -> row.inbox && emailAvailable
}
internal fun channelValue(row: NotificationKindSettingWire, channel: NotificationChannel): Boolean =
when (channel) {
NotificationChannel.INBOX -> row.inbox
NotificationChannel.PHONE -> row.phone
NotificationChannel.EMAIL -> row.email
}
internal fun channelLabel(c: NotificationChannel): String = when (c) {
NotificationChannel.INBOX -> "Inbox"
NotificationChannel.PHONE -> "Phone"
NotificationChannel.EMAIL -> "Email"
}
/** Short row labels, the same as the web's. Unknown kinds show their key. */
internal fun kindLabel(kind: String): String = when (kind) {
"request_approved" -> "Request approved"
"request_rejected" -> "Request declined"
"request_completed" -> "New music arrived"
"request_pending" -> "Requests to review"
"quarantine_flagged" -> "Tracks flagged"
"scan_failed" -> "Library scan failed"
"tracks_missing" -> "Tracks gone missing"
"duplicates_found" -> "Duplicates to review"
"playback_errors" -> "Playback errors"
else -> kind
}
internal fun emailUnavailableLine(reason: String?, isAdmin: Boolean): String = when {
reason == "no_address" -> "Email is off: add an email address in your profile"
isAdmin -> "Email is off: SMTP isn't set up on the server"
else -> "Email is off: this server doesn't send email"
}
@@ -0,0 +1,48 @@
package com.fabledsword.minstrel.notifications.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
import com.fabledsword.minstrel.auth.AuthController
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.notifications.data.NotificationChannel
import com.fabledsword.minstrel.notifications.data.NotificationSettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
@HiltViewModel
class NotificationSettingsViewModel @Inject constructor(
private val repository: NotificationSettingsRepository,
authController: AuthController,
private val authStore: AuthStore,
) : ViewModel() {
/** "Notifications when the app is closed": this device's choice (M489 #5347). */
val backgroundDelivery: StateFlow<Boolean> = authStore.backgroundDelivery
val settings: StateFlow<NotificationSettingsWire?> = repository.settings
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), null)
val isAdmin: StateFlow<Boolean> = authController.currentUser
.map { it?.isAdmin == true }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), false)
init {
// Offline, the device's copy stands.
viewModelScope.launch { runCatching { repository.refresh() } }
}
fun set(kind: String, channel: NotificationChannel, value: Boolean) {
viewModelScope.launch { repository.set(kind, channel, value) }
}
fun setBackgroundDelivery(on: Boolean) {
authStore.setBackgroundDelivery(on)
}
}
@@ -0,0 +1,155 @@
package com.fabledsword.minstrel.notifications.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.composables.icons.lucide.Bell
import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
import com.fabledsword.minstrel.nav.Notifications
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
/**
* The notifications inbox (M489): one row per notice, its title on one line
* and how long ago. Tapping a row marks it read and opens what it is about.
*/
@Composable
fun NotificationsScreen(
navController: NavHostController,
viewModel: NotificationsViewModel = hiltViewModel(),
) {
val items by viewModel.items.collectAsStateWithLifecycle()
val anyUnread = items?.any { it.readAt == null } == true
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
title = "Notifications",
navController = navController,
currentRouteName = Notifications::class.qualifiedName,
onBack = { navController.popBackStack() },
actions = {
if (anyUnread) {
TextButton(onClick = viewModel::markAllRead) { Text("Mark all read") }
}
},
)
},
) { inner ->
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
val list = items
when {
list == null -> LoadingCentered()
list.isEmpty() -> EmptyState(
title = "Nothing waiting for you",
body = "Requests, new music and anything needing a look will gather here.",
icon = Lucide.Bell,
)
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
items(list, key = { it.id }) { item ->
NotificationRow(
item = item,
onClick = {
viewModel.open(item)
routeForLink(item.link)?.let { navController.navigate(it) }
},
)
HorizontalDivider()
}
}
}
}
}
}
@Composable
private fun NotificationRow(item: CachedNotificationEntity, onClick: () -> Unit) {
val unread = item.readAt == null
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp)
.semantics(mergeDescendants = true) {
if (unread) contentDescription = "Unread: ${item.title}"
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.size(8.dp)
.then(
if (unread) {
Modifier.background(MaterialTheme.colorScheme.onSurface, CircleShape)
} else {
Modifier
},
),
)
Text(
text = item.title,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (unread) FontWeight.Medium else FontWeight.Normal,
color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = ago(item.createdAt),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/** "just now", "12m", "5h", "3d": the same coarse steps as the web inbox. */
internal fun ago(at: Instant, now: Instant = Clock.System.now()): String {
val d = now - at
return when {
d >= 1.days -> "${d.inWholeDays}d"
d >= 1.hours -> "${d.inWholeHours}h"
d >= 1.minutes -> "${d.inWholeMinutes}m"
else -> "just now"
}
}
@@ -0,0 +1,49 @@
package com.fabledsword.minstrel.notifications.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
/** `items` is null until the cache has been read once, so the screen can tell "loading" from "empty". */
@HiltViewModel
class NotificationsViewModel @Inject constructor(
private val repository: NotificationsRepository,
networkStatus: NetworkStatusController,
) : ViewModel() {
val items: StateFlow<List<CachedNotificationEntity>?> = repository.notifications
.map<List<CachedNotificationEntity>, List<CachedNotificationEntity>?> { it }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), null)
init {
refresh()
// Back online: the cached page may be stale (#1245's recovery idiom).
viewModelScope.launch { networkStatus.recoveries().collect { refresh() } }
}
/** Offline, the cached page stands. */
fun refresh(): Job = viewModelScope.launch {
runCatching { repository.refresh() }
}
fun open(item: CachedNotificationEntity) {
if (item.readAt != null) return
viewModelScope.launch { repository.markRead(item.id) }
}
fun markAllRead() {
viewModelScope.launch { repository.markAllRead() }
}
}
@@ -45,6 +45,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.composables.icons.lucide.Bell
import com.composables.icons.lucide.ChevronRight
import com.composables.icons.lucide.ListMusic
import com.composables.icons.lucide.LogOut
@@ -52,6 +53,7 @@ import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Shield
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.nav.Admin
import com.fabledsword.minstrel.nav.NotificationSettings
import com.fabledsword.minstrel.nav.Requests
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
import com.fabledsword.minstrel.nav.ServerUrl
@@ -98,6 +100,7 @@ fun SettingsScreen(
themeMode = themeMode,
onPickTheme = themeVm::setThemeMode,
onNavToRequests = { navController.navigate(Requests) },
onNavToNotifications = { navController.navigate(NotificationSettings) },
onNavToAdmin = { navController.navigate(Admin) },
onToggleDiagnostics = viewModel::setDiagnosticsOptOut,
onSignOutClick = { showSignOutConfirm = true },
@@ -121,6 +124,7 @@ private fun SettingsList(
themeMode: ThemeMode,
onPickTheme: (ThemeMode) -> Unit,
onNavToRequests: () -> Unit,
onNavToNotifications: () -> Unit,
onNavToAdmin: () -> Unit,
onToggleDiagnostics: (Boolean) -> Unit,
onSignOutClick: () -> Unit,
@@ -144,6 +148,12 @@ private fun SettingsList(
subtitle = "Track what you've asked Minstrel to add",
onClick = onNavToRequests,
)
NavTile(
icon = Lucide.Bell,
title = "Notifications",
subtitle = "What reaches you, and where",
onClick = onNavToNotifications,
)
if (state.isAdmin) {
NavTile(
icon = Lucide.Shield,
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.auth.AuthController
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -28,6 +29,7 @@ data class SettingsState(
class SettingsViewModel @Inject constructor(
private val authController: AuthController,
private val authStore: AuthStore,
private val notifications: NotificationsRepository,
) : ViewModel() {
private val transient = MutableStateFlow(TransientState())
@@ -70,6 +72,8 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch {
transient.update { it.copy(isSigningOut = true) }
authController.signOut()
// The inbox is this account's; the next one on the device must not see it.
runCatching { notifications.clearLocal() }
transient.update { it.copy(isSigningOut = false, signedOut = true) }
}
}
@@ -3,6 +3,7 @@ package com.fabledsword.minstrel.shared.widgets
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.auth.AuthController
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -22,6 +23,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
@HiltViewModel
class AppBarActionsViewModel @Inject constructor(
authController: AuthController,
notifications: NotificationsRepository,
) : ViewModel() {
val isAdmin: StateFlow<Boolean> = authController.currentUser
.map { it?.isAdmin == true }
@@ -30,4 +32,12 @@ class AppBarActionsViewModel @Inject constructor(
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = false,
)
/** Unread notices for the bell's badge (M489). */
val unreadCount: StateFlow<Int> = notifications.unreadCount
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = 0,
)
}
@@ -1,10 +1,13 @@
package com.fabledsword.minstrel.shared.widgets
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -14,6 +17,7 @@ import androidx.compose.runtime.setValue
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.composables.icons.lucide.Bell
import com.composables.icons.lucide.House
import com.composables.icons.lucide.LibraryBig
import com.composables.icons.lucide.Lucide
@@ -23,6 +27,7 @@ import com.fabledsword.minstrel.nav.Admin
import com.fabledsword.minstrel.nav.Discover
import com.fabledsword.minstrel.nav.Home
import com.fabledsword.minstrel.nav.Library
import com.fabledsword.minstrel.nav.Notifications
import com.fabledsword.minstrel.nav.Playlists
import com.fabledsword.minstrel.nav.Search as SearchRoute
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
@@ -50,6 +55,7 @@ fun MainAppBarActions(
viewModel: AppBarActionsViewModel = hiltViewModel(),
) {
val isAdmin by viewModel.isAdmin.collectAsStateWithLifecycle()
val unread by viewModel.unreadCount.collectAsStateWithLifecycle()
Row {
if (currentRouteName != Home::class.qualifiedName) {
IconButton(onClick = { navController.navigate(Home) }) {
@@ -66,10 +72,47 @@ fun MainAppBarActions(
Icon(Lucide.SearchIcon, contentDescription = "Search")
}
}
if (currentRouteName != Notifications::class.qualifiedName) {
NotificationsBell(unread = unread, onClick = { navController.navigate(Notifications) })
}
OverflowMenu(navController = navController, isAdmin = isAdmin)
}
}
/** The inbox bell with its unread badge (M489): the count to 9, then "9+". */
@Composable
private fun NotificationsBell(unread: Int, onClick: () -> Unit) {
val label = badgeLabel(unread)
IconButton(onClick = onClick) {
// Inverse of the bar, not error red: an unread count is news, not a
// fault, and the house style keeps the accent off general chrome.
BadgedBox(
badge = {
if (label.isNotEmpty()) {
Badge(
containerColor = MaterialTheme.colorScheme.onSurface,
contentColor = MaterialTheme.colorScheme.surface,
) { Text(label) }
}
},
) {
Icon(
Lucide.Bell,
contentDescription = if (unread > 0) "Notifications, $unread unread" else "Notifications",
)
}
}
}
/** Badge text: nothing at zero, the count up to nine, then "9+". */
internal fun badgeLabel(count: Int): String = when {
count <= 0 -> ""
count > MAX_BADGE -> "$MAX_BADGE+"
else -> count.toString()
}
private const val MAX_BADGE = 9
@Composable
private fun OverflowMenu(navController: NavHostController, isAdmin: Boolean) {
var expanded by remember { mutableStateOf(false) }
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Status-bar icon for Minstrel's own notifications (M489 #5347): Lucide's
bell, stroked in white. The system tints it; only the alpha is used. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M6,8a6,6 0,0 1,12 0c0,7 3,9 3,9H3s3,-2 3,-9"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<path
android:pathData="M10.3,21a1.94,1.94 0,0 0,3.4 0"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.cache.mutations
import com.fabledsword.minstrel.api.endpoints.NotificationSettingChangeWire
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import com.fabledsword.minstrel.settings.data.NormalizationMode
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
@@ -153,4 +154,34 @@ class SupersededToggleIdsTest {
)
assertEquals(setOf(1L, 3L), supersededToggleIds(rows, json))
}
private fun settingRow(id: Long, kind: String, channel: String, value: Boolean) = CachedMutationEntity(
id = id,
kind = MutationKind.NOTIFICATION_SETTING_SET,
payload = json.encodeToString(
NotificationSettingPayload.serializer(),
NotificationSettingPayload(kind, channel, value),
),
)
@Test
fun `notification settings collapse per kind and channel, never across them`() {
val rows = listOf(
settingRow(1, "request_completed", "email", false),
settingRow(2, "request_completed", "phone", false),
settingRow(3, "request_completed", "email", true),
settingRow(4, "request_approved", "email", false),
)
// Only the older email toggle for request_completed is superseded.
assertEquals(setOf(1L), supersededToggleIds(rows, json))
}
@Test
fun `a queued setting becomes a one-channel change, and an unknown channel none`() {
assertEquals(
NotificationSettingChangeWire(kind = "k", phone = true),
notificationSettingChange(NotificationSettingPayload("k", "phone", true)),
)
assertEquals(null, notificationSettingChange(NotificationSettingPayload("k", "pager", true)))
}
}
@@ -0,0 +1,24 @@
package com.fabledsword.minstrel.events
import org.junit.jupiter.api.Test
import kotlin.random.Random
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ReconnectBackoffTest {
@Test
fun `doubles from two seconds to a five minute cap`() {
val ladder = generateSequence(ReconnectBackoff.BASE_MS) { ReconnectBackoff.next(it) }.take(10).toList()
assertEquals(listOf(2_000L, 4_000L, 8_000L, 16_000L, 32_000L, 64_000L, 128_000L, 256_000L), ladder.take(8))
assertEquals(300_000L, ladder[8])
assertEquals(300_000L, ladder[9])
}
@Test
fun `jitter stays within a quarter either way and does spread`() {
val random = Random(42)
val waits = List(1_000) { ReconnectBackoff.jittered(100_000L, random) }
assertTrue(waits.all { it in 75_000L..125_000L }, "out of bounds: ${waits.minOrNull()}..${waits.maxOrNull()}")
assertTrue(waits.toSet().size > 100, "jitter should spread reconnects")
}
}
@@ -0,0 +1,107 @@
package com.fabledsword.minstrel.notifications.data
import com.fabledsword.minstrel.api.endpoints.NotificationKindSettingWire
import com.fabledsword.minstrel.api.endpoints.NotificationSettingChangeWire
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
import com.fabledsword.minstrel.api.endpoints.NotificationsApi
import com.fabledsword.minstrel.api.endpoints.PutNotificationSettingsBody
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationSettingsEntity
import com.fabledsword.minstrel.cache.mutations.MutationKind
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import com.fabledsword.minstrel.cache.mutations.NotificationSettingPayload
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Test
import retrofit2.Retrofit
import java.io.IOException
import kotlin.test.assertEquals
import kotlin.test.assertFalse
/** Settings follow snippet #5107: shown at once, never overwritten or reordered by an older change. */
class NotificationSettingsRepositoryTest {
private val json = Json { ignoreUnknownKeys = true }
private val api: NotificationsApi = mockk(relaxed = true)
private val dao: CachedNotificationDao = mockk(relaxed = true)
private val mutationDao: CachedMutationDao = mockk()
private val queue: MutationQueue = mockk(relaxed = true)
private val retrofit: Retrofit = mockk {
every { create(NotificationsApi::class.java) } returns api
}
private val repo = NotificationSettingsRepository(retrofit, dao, mutationDao, queue, json)
private val settings = NotificationSettingsWire(
kinds = listOf(
NotificationKindSettingWire("request_completed", false, inbox = true, phone = true, email = true),
),
emailAvailable = true,
)
private fun cached() {
coEvery { dao.getSettings() } returns CachedNotificationSettingsEntity(
json = json.encodeToString(NotificationSettingsWire.serializer(), settings),
)
}
@Test
fun `a toggle is shown at once and sends only that channel`() = runTest {
cached()
coEvery { mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET) } returns false
val saved = mutableListOf<CachedNotificationSettingsEntity>()
coEvery { dao.upsertSettings(capture(saved)) } returns Unit
val body = slot<PutNotificationSettingsBody>()
coEvery { api.putSettings(capture(body)) } returns settings
repo.set("request_completed", NotificationChannel.EMAIL, false)
val optimistic = json.decodeFromString(NotificationSettingsWire.serializer(), saved.first().json)
assertFalse(optimistic.kinds.single().email)
assertEquals(
listOf(NotificationSettingChangeWire(kind = "request_completed", email = false)),
body.captured.kinds,
)
coVerify(exactly = 0) { queue.enqueueNotificationSettingSet(any()) }
}
@Test
fun `a toggle that cannot reach the server is queued`() = runTest {
cached()
coEvery { mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET) } returns false
coEvery { api.putSettings(any()) } throws IOException("offline")
repo.set("request_completed", NotificationChannel.PHONE, false)
coVerify {
queue.enqueueNotificationSettingSet(NotificationSettingPayload("request_completed", "phone", false))
}
}
@Test
fun `a toggle behind a queued one queues too`() = runTest {
cached()
coEvery { mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET) } returns true
repo.set("request_completed", NotificationChannel.INBOX, false)
coVerify(exactly = 0) { api.putSettings(any()) }
coVerify {
queue.enqueueNotificationSettingSet(NotificationSettingPayload("request_completed", "inbox", false))
}
}
@Test
fun `refresh leaves a queued change on screen`() = runTest {
coEvery { api.getSettings() } returns settings
coEvery { mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET) } returns true
repo.refresh()
coVerify(exactly = 0) { dao.upsertSettings(any()) }
}
}
@@ -0,0 +1,136 @@
package com.fabledsword.minstrel.notifications.data
import com.fabledsword.minstrel.api.endpoints.NotificationWire
import com.fabledsword.minstrel.api.endpoints.NotificationsApi
import com.fabledsword.minstrel.api.endpoints.NotificationsPageWire
import com.fabledsword.minstrel.api.endpoints.ReadAllBody
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.jupiter.api.Test
import retrofit2.HttpException
import retrofit2.Response
import retrofit2.Retrofit
import java.io.IOException
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* Reads are offline-first (rule 100): the device marks the row at once, and a
* read the server has not taken is queued, except when the server says the
* notice is gone.
*/
class NotificationsRepositoryTest {
private val api: NotificationsApi = mockk(relaxed = true)
private val dao: CachedNotificationDao = mockk(relaxed = true)
private val queue: MutationQueue = mockk(relaxed = true)
private val retrofit: Retrofit = mockk {
every { create(NotificationsApi::class.java) } returns api
}
private val repo = NotificationsRepository(retrofit, dao, queue)
private fun httpError(status: Int) = HttpException(Response.error<Unit>(status, "".toResponseBody()))
private fun row(id: String, created: String, readAt: Instant? = null) = CachedNotificationEntity(
id = id,
kind = "request_completed",
title = "t",
body = "b",
link = "/requests",
createdAt = Instant.parse(created),
readAt = readAt,
)
@Test
fun `a read is shown at once and sent`() = runTest {
repo.markRead("n1")
coVerify { dao.markRead("n1", any()) }
coVerify { api.markRead("n1") }
coVerify(exactly = 0) { queue.enqueueNotificationRead(any()) }
}
@Test
fun `a read that cannot reach the server is queued`() = runTest {
coEvery { api.markRead("n1") } throws IOException("offline")
repo.markRead("n1")
coVerify { dao.markRead("n1", any()) }
coVerify { queue.enqueueNotificationRead("n1") }
}
@Test
fun `a notice the server no longer has is not queued`() = runTest {
coEvery { api.markRead("n1") } throws httpError(404)
repo.markRead("n1")
coVerify(exactly = 0) { queue.enqueueNotificationRead(any()) }
}
@Test
fun `a server error queues the read for later`() = runTest {
coEvery { api.markRead("n1") } throws httpError(503)
repo.markRead("n1")
coVerify { queue.enqueueNotificationRead("n1") }
}
@Test
fun `mark all read sends the newest notice shown, rounded up, and queues it offline`() = runTest {
coEvery { dao.getAll() } returns listOf(
row("a", "2026-10-08T10:00:00.123Z"),
row("b", "2026-10-08T11:00:00.456Z"),
)
val body = slot<ReadAllBody>()
coEvery { api.readAll(capture(body)) } throws IOException("offline")
repo.markAllRead()
coVerify { dao.markAllRead(any()) }
assertEquals("2026-10-08T11:00:00.457Z", body.captured.upTo)
coVerify { queue.enqueueNotificationsReadAll("2026-10-08T11:00:00.457Z") }
}
@Test
fun `refresh keeps a read made here that the server has not seen yet`() = runTest {
val readHere = Instant.parse("2026-10-08T12:00:00Z")
coEvery { dao.getAll() } returns listOf(row("a", "2026-10-08T10:00:00Z", readAt = readHere))
coEvery { api.list(any()) } returns NotificationsPageWire(
items = listOf(
NotificationWire("a", "request_completed", "t", "b", "/requests", "2026-10-08T10:00:00Z", null),
NotificationWire("b", "request_completed", "t", "b", "/requests", "2026-10-08T11:00:00Z", null),
),
unreadCount = 2,
)
val saved = slot<List<CachedNotificationEntity>>()
coEvery { dao.replaceAll(capture(saved)) } returns Unit
repo.refresh()
val byId = saved.captured.associateBy { it.id }
assertEquals(readHere, byId.getValue("a").readAt)
assertNull(byId.getValue("b").readAt)
}
@Test
fun `a notice with an unreadable timestamp is skipped, not the whole page`() {
val bad = NotificationWire("x", "k", "t", "b", "/", "yesterday", null)
assertNull(bad.toEntity(null))
}
@Test
fun `no cutoff when nothing is shown`() {
assertNull(readAllCutoff(emptyList()))
}
}
@@ -0,0 +1,128 @@
package com.fabledsword.minstrel.notifications.delivery
import com.fabledsword.minstrel.api.endpoints.NotificationKindSettingWire
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
class DeliveryPlanTest {
private val t0 = Instant.parse("2026-10-08T12:00:00Z")
private fun notice(
id: String,
minutes: Long,
kind: String = "request_approved",
read: Boolean = false,
) = CachedNotificationEntity(
id = id,
kind = kind,
title = "t-$id",
body = "b-$id",
link = "/requests",
createdAt = Instant.fromEpochMilliseconds(t0.toEpochMilliseconds() + minutes * 60_000),
readAt = if (read) t0 else null,
)
private val allOn: (String) -> Boolean = { true }
@Test
fun `a first look sets the mark to the newest and announces nothing`() {
val page = listOf(notice("b", 5), notice("a", 1))
val plan = planCatchUp(page, mark = null, phoneOn = allOn)
assertTrue(plan.announce.isEmpty())
assertEquals(page[0].createdAt, plan.upTo)
}
@Test
fun `a first look at an empty inbox still lets the first notice through`() {
val first = planCatchUp(emptyList(), mark = null, phoneOn = allOn)
assertEquals(Instant.DISTANT_PAST, first.upTo)
val next = planCatchUp(listOf(notice("a", 1)), mark = first.upTo, phoneOn = allOn)
assertEquals(listOf("a"), next.announce.map { it.id })
}
@Test
fun `announces unread notices newer than the mark, oldest first, and moves the mark`() {
val page = listOf(notice("c", 9), notice("b", 6), notice("old", 2), notice("read", 7, read = true))
val plan = planCatchUp(page, mark = notice("m", 3).createdAt, phoneOn = allOn)
assertEquals(listOf("b", "c"), plan.announce.map { it.id })
assertEquals(page[0].createdAt, plan.upTo)
}
@Test
fun `nothing new leaves the mark where it was`() {
val mark = notice("m", 10).createdAt
val plan = planCatchUp(listOf(notice("a", 1)), mark = mark, phoneOn = allOn)
assertTrue(plan.announce.isEmpty())
assertEquals(mark, plan.upTo)
}
@Test
fun `a kind with the phone off is passed over, and not announced later either`() {
val page = listOf(notice("h", 5, kind = "tracks_missing"), notice("r", 4))
val plan = planCatchUp(page, mark = t0, phoneOn = { it != "tracks_missing" })
assertEquals(listOf("r"), plan.announce.map { it.id })
assertEquals(page[0].createdAt, plan.upTo, "the mark passes it")
}
@Test
fun `phone prefs come from the cached settings, with every kind on when none are cached`() {
val settings = NotificationSettingsWire(
kinds = listOf(
NotificationKindSettingWire("request_approved", false, inbox = true, phone = false, email = true),
NotificationKindSettingWire("request_completed", false, inbox = false, phone = true, email = true),
NotificationKindSettingWire("request_rejected", false, inbox = true, phone = true, email = true),
),
emailAvailable = true,
)
val phoneOn = phoneOnFor(settings)
assertFalse(phoneOn("request_approved"))
assertFalse(phoneOn("request_completed"), "phone rides on the inbox")
assertTrue(phoneOn("request_rejected"))
assertTrue(phoneOnFor(null)("tracks_missing"))
}
@Test
fun `three get a line each, more become one line with the count`() {
assertNull(announcementFor(emptyList()))
val three = (1..3).map { notice("n$it", it.toLong()) }
assertEquals(Announcement.Each(three), announcementFor(three))
val five = (1..5).map { notice("n$it", it.toLong()) }
val pile = assertIs<Announcement.Pile>(announcementFor(five))
assertEquals(5, pile.count)
assertEquals(ShadeChannel.YOUR_REQUESTS, pile.channel)
assertEquals("5 new notifications", pileText(pile.count))
}
@Test
fun `a pile of admin notices goes to library health, a mixed one does not`() {
val admin = (1..4).map { notice("a$it", it.toLong(), kind = "tracks_missing") }
assertEquals(ShadeChannel.LIBRARY_HEALTH, assertIs<Announcement.Pile>(announcementFor(admin)).channel)
val mixed = admin + notice("r", 9)
assertEquals(ShadeChannel.YOUR_REQUESTS, assertIs<Announcement.Pile>(announcementFor(mixed)).channel)
}
@Test
fun `listener kinds are their requests, the rest is library health`() {
listOf("request_approved", "request_rejected", "request_completed").forEach {
assertEquals(ShadeChannel.YOUR_REQUESTS, shadeChannelFor(it))
}
listOf("request_pending", "quarantine_flagged", "scan_failed", "tracks_missing").forEach {
assertEquals(ShadeChannel.LIBRARY_HEALTH, shadeChannelFor(it))
}
}
@Test
fun `the service runs only signed in with background delivery on`() {
assertTrue(deliveryWanted(signedIn = true, enabled = true))
assertFalse(deliveryWanted(signedIn = true, enabled = false))
assertFalse(deliveryWanted(signedIn = false, enabled = true))
}
}
@@ -0,0 +1,66 @@
package com.fabledsword.minstrel.notifications.ui
import com.fabledsword.minstrel.api.endpoints.NotificationKindSettingWire
import com.fabledsword.minstrel.nav.Admin
import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Requests
import com.fabledsword.minstrel.notifications.data.NotificationChannel
import com.fabledsword.minstrel.shared.widgets.badgeLabel
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class NotificationUiTest {
@Test
fun `server links open the matching screens`() {
assertEquals(AlbumDetail("al-1"), routeForLink("/albums/al-1"))
assertEquals(ArtistDetail("ar-1"), routeForLink("/artists/ar-1"))
assertEquals(Requests, routeForLink("/requests"))
assertEquals(AdminRequests, routeForLink("/admin/requests"))
assertEquals(AdminQuarantine, routeForLink("/admin/quarantine"))
// Admin pages the app has no screen for open the Admin landing.
assertEquals(Admin, routeForLink("/admin/duplicates"))
assertEquals(Admin, routeForLink("/admin"))
assertNull(routeForLink("/somewhere-new"))
}
@Test
fun `the badge counts to nine, then 9+`() {
assertEquals("", badgeLabel(0))
assertEquals("1", badgeLabel(1))
assertEquals("9", badgeLabel(9))
assertEquals("9+", badgeLabel(10))
}
@Test
fun `ages read in coarse steps`() {
val now = Instant.parse("2026-10-08T12:00:00Z")
assertEquals("just now", ago(Instant.parse("2026-10-08T11:59:30Z"), now))
assertEquals("12m", ago(Instant.parse("2026-10-08T11:48:00Z"), now))
assertEquals("5h", ago(Instant.parse("2026-10-08T07:00:00Z"), now))
assertEquals("3d", ago(Instant.parse("2026-10-05T12:00:00Z"), now))
}
@Test
fun `phone and email ride on the inbox, and email needs to be available`() {
val on = NotificationKindSettingWire("request_completed", false, inbox = true, phone = true, email = true)
val off = on.copy(inbox = false)
assertTrue(channelEnabled(on, NotificationChannel.PHONE, emailAvailable = true))
assertFalse(channelEnabled(off, NotificationChannel.PHONE, emailAvailable = true))
assertFalse(channelEnabled(on, NotificationChannel.EMAIL, emailAvailable = false))
assertTrue(channelEnabled(off, NotificationChannel.INBOX, emailAvailable = false))
}
@Test
fun `the email line says why it is off`() {
assertEquals("Email is off: add an email address in your profile", emailUnavailableLine("no_address", false))
assertEquals("Email is off: SMTP isn't set up on the server", emailUnavailableLine("smtp_not_configured", true))
assertEquals("Email is off: this server doesn't send email", emailUnavailableLine("smtp_not_configured", false))
}
}
+26 -2
View File
@@ -26,6 +26,8 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
@@ -313,7 +315,14 @@ func run() error {
}
return lidarr.NewClient(c.BaseURL, c.APIKey)
}
// The notifications inbox's one writer (M489), shared by every
// background producer started here. The API builds its own over the
// same pool and bus.
notifier := notifications.New(pool, bus, logger.With("component", "notifications"))
lidarrReconciler := lidarrrequests.NewReconciler(pool, lidarrCfg, lidarrClientFn, logger.With("component", "lidarr"), bus)
lidarrReconciler.SetNotifier(notifier)
library.SetNotifier(notifier)
go lidarrReconciler.Run(ctx)
// Missing-file re-acquisition (milestone #290). Turns albums whose files
@@ -331,12 +340,14 @@ func run() error {
if reacqErr != nil {
logger.Warn("reacquisition: using default settings", "err", reacqErr)
}
go reacquisition.NewSweeper(
reacqSweeper := reacquisition.NewSweeper(
pool,
reacqSettings,
lidarrrequests.NewService(pool, lidarrCfg, lidarrClientFn, nil),
logger.With("component", "reacquisition"),
).Run(ctx)
)
reacqSweeper.SetNotifier(notifier)
go reacqSweeper.Run(ctx)
// library_changes compactor (#357 follow-up). Daily tick; deletes
// rows older than the configured retention so the change-log table
@@ -345,6 +356,19 @@ func run() error {
libraryChangesCompactor := syncpkg.NewCompactor(pool, logger.With("component", "library_changes_compactor"))
go libraryChangesCompactor.Run(ctx)
// Notifications inbox retention (M489). Daily: read rows go after 90
// days, anything at all after a year.
go notifications.NewRetention(pool, logger.With("component", "notifications_retention")).Run(ctx)
// Notifications by email (M489 #5346), grouped, never one per event: new
// music as a daily summary at a local hour, everything else batched an
// hour (admin-configurable) after the first item.
go notifications.NewDigest(
pool,
mailer.NewSMTPSender(pool, logger.With("component", "mailer")),
logger.With("component", "notification_digest"),
).Run(ctx)
// Per-user system-playlist scheduler (#392 Half B). Fires each
// active user's daily build at 03:00 in their stored timezone.
// Replaces the 24h-anchored cron loop (removed in the next commit
+1 -1
View File
@@ -94,7 +94,7 @@ Keystore, so a copy of the app's files doesn't yield a usable session.
Nothing is published unless every check passes: the Go, integration, web and
Android test suites, `govulncheck` against the toolchain that builds the
image, and `npm audit` on the packages that ship to the browser. See
image, and `npm audit` on every web dependency, build tooling included. See
`.gitea/workflows/release.yml`.
## Reporting a problem
+55
View File
@@ -0,0 +1,55 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
)
// notificationEmailBody is the wire shape for GET and PUT
// /api/admin/notification-email (M489 #5346): when the grouped emails go out.
type notificationEmailBody struct {
SummaryHour int32 `json:"summary_hour"`
BatchWindowMinutes int32 `json:"batch_window_minutes"`
}
func notificationEmailBodyOf(s notifications.EmailSettings) notificationEmailBody {
return notificationEmailBody{SummaryHour: s.SummaryHour, BatchWindowMinutes: s.BatchWindowMinutes}
}
// handleGetNotificationEmail implements GET /api/admin/notification-email.
func (h *handlers) handleGetNotificationEmail(w http.ResponseWriter, r *http.Request) {
s, err := notifications.LoadEmailSettings(r.Context(), dbq.New(h.pool))
if err != nil {
writeErrWithLog(w, h.logger, "admin notification email: load failed", apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, notificationEmailBodyOf(s))
}
// handleUpdateNotificationEmail implements PUT /api/admin/notification-email.
// A whole-row write; the digest reads it on its next tick.
func (h *handlers) handleUpdateNotificationEmail(w http.ResponseWriter, r *http.Request) {
var req notificationEmailBody
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON"))
return
}
saved, err := notifications.SaveEmailSettings(r.Context(), dbq.New(h.pool), notifications.EmailSettings{
SummaryHour: req.SummaryHour,
BatchWindowMinutes: req.BatchWindowMinutes,
})
if err != nil {
if errors.Is(err, notifications.ErrEmailSettingOutOfRange) {
writeErr(w, apierror.BadRequest("invalid_setting", err.Error()))
return
}
writeErrWithLog(w, h.logger, "admin notification email: update failed", apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, notificationEmailBodyOf(saved))
}
@@ -0,0 +1,33 @@
package api
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestUpdateNotificationEmail_Rejects(t *testing.T) {
// No pool: every case is refused before the database is reached.
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
for name, tc := range map[string]struct {
body string
code string
mentions string
}{
"an hour past 23": {body: `{"summary_hour":24,"batch_window_minutes":60}`, code: "invalid_setting", mentions: "summary_hour"},
"a window under 15m": {body: `{"summary_hour":9,"batch_window_minutes":5}`, code: "invalid_setting", mentions: "batch_window_minutes"},
"a body missing both": {body: `{}`, code: "invalid_setting", mentions: "batch_window_minutes"},
"malformed JSON": {body: `{"summary_hour":`, code: "invalid_body"},
} {
rec := httptest.NewRecorder()
h.handleUpdateNotificationEmail(rec, httptest.NewRequest(
http.MethodPut, "/api/admin/notification-email", strings.NewReader(tc.body)))
body := rec.Body.String()
if rec.Code != http.StatusBadRequest || !strings.Contains(body, `"`+tc.code+`"`) || !strings.Contains(body, tc.mentions) {
t.Errorf("%s: status %d body %s; want 400 %s mentioning %q", name, rec.Code, body, tc.code, tc.mentions)
}
}
}
+3
View File
@@ -12,6 +12,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
)
// validRequestStatuses is the set of allowed values for the ?status= param.
@@ -125,6 +126,7 @@ func (h *handlers) handleApproveRequest(w http.ResponseWriter, r *http.Request)
return
}
h.notifyRequestDecided(r.Context(), notifications.KindRequestApproved, admin, row, "")
h.publishRequestStatusChanged(row)
writeJSON(w, http.StatusOK, requestViewFrom(row))
}
@@ -165,6 +167,7 @@ func (h *handlers) handleRejectRequest(w http.ResponseWriter, r *http.Request) {
return
}
h.notifyRequestDecided(r.Context(), notifications.KindRequestRejected, admin, row, body.Notes)
h.publishRequestStatusChanged(row)
writeJSON(w, http.StatusOK, requestViewFrom(row))
}
+27 -14
View File
@@ -23,6 +23,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
@@ -61,6 +62,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
dataDir: dataDir,
mailer: sender,
eventbus: bus,
notifier: notifications.New(pool, bus, logger.With("component", "notifications")),
playlistScheduler: playlistScheduler,
streamSecret: streamSecret,
netSettings: netSettings,
@@ -124,6 +126,12 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Get("/me/sessions", h.handleListMySessions)
authed.Delete("/me/sessions/{id}", h.handleRevokeMySession)
authed.Post("/me/sessions/logout-others", h.handleRevokeMyOtherSessions)
authed.Get("/me/notifications", h.handleListMyNotifications)
authed.Get("/me/notifications/unread-count", h.handleMyUnreadNotificationCount)
authed.Post("/me/notifications/read-all", h.handleMarkAllMyNotificationsRead)
authed.Post("/me/notifications/{id}/read", h.handleMarkMyNotificationRead)
authed.Get("/me/notification-settings", h.handleGetMyNotificationSettings)
authed.Put("/me/notification-settings", h.handlePutMyNotificationSettings)
authed.Get("/artists", h.handleListArtists)
authed.Get("/artists/{id}", h.handleGetArtist)
@@ -292,6 +300,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Get("/smtp-config", h.handleGetSMTPConfig)
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
admin.Get("/notification-email", h.handleGetNotificationEmail)
admin.Put("/notification-email", h.handleUpdateNotificationEmail)
// Recommendation tuning lab (#1250): scoring-weight
// profiles + taste-build knobs, DB-backed, live effect.
@@ -327,20 +337,23 @@ type handlers struct {
// librarySize memoises the track count that sizes the candidate pool
// (#3880). Held here rather than counted per request: the count is a
// full table scan, and library size only moves when a scan runs.
librarySize *recommendation.LibrarySize
lidarrCfg *lidarrconfig.Service
lidarrRequests *lidarrrequests.Service
lidarrQuarantine *lidarrquarantine.Service
tracks *tracks.Service
playlists *playlists.Service
coverart *coverart.Enricher
coverSettings *coverart.SettingsService
tagSettings *tags.SettingsService
scanner *library.Scanner
scanCfg library.RunScanConfig
dataDir string
mailer mailer.Sender
eventbus *eventbus.Bus
librarySize *recommendation.LibrarySize
lidarrCfg *lidarrconfig.Service
lidarrRequests *lidarrrequests.Service
lidarrQuarantine *lidarrquarantine.Service
tracks *tracks.Service
playlists *playlists.Service
coverart *coverart.Enricher
coverSettings *coverart.SettingsService
tagSettings *tags.SettingsService
scanner *library.Scanner
scanCfg library.RunScanConfig
dataDir string
mailer mailer.Sender
eventbus *eventbus.Bus
// notifier writes the notifications inbox (M489). Nil-safe: a nil
// notifier records nothing, which is what most handler tests want.
notifier *notifications.Notifier
playlistScheduler *playlists.Scheduler
// reacqSettings is the DB-backed policy for auto re-acquisition of
// missing files (milestone #290) — grace window, backoff, attempt caps.
+2 -1
View File
@@ -27,6 +27,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
@@ -72,7 +73,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
dataDir := t.TempDir()
tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
playlistsSvc := playlists.NewService(pool, logger, dataDir)
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}}
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}, notifier: notifications.New(pool, nil, nil)}
return h, pool
}
+272
View File
@@ -0,0 +1,272 @@
package api
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
)
// The notifications inbox (M489). Rows are written by internal/notifications;
// this surface lists them, counts the unread, marks them read, and holds each
// user's per-kind settings.
const (
notificationsDefaultLimit = 30
notificationsMaxLimit = 100
)
// notificationResp is one inbox row, rendered server-side so every client
// says the same thing (notifications.Render).
type notificationResp struct {
ID string `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"`
Body string `json:"body"`
Link string `json:"link"`
CreatedAt time.Time `json:"created_at"`
ReadAt *time.Time `json:"read_at"`
}
type notificationsPageResp struct {
Items []notificationResp `json:"items"`
UnreadCount int64 `json:"unread_count"`
// NextBefore is the cursor for the next page, absent on the last one.
NextBefore string `json:"next_before,omitempty"`
}
type unreadCountResp struct {
UnreadCount int64 `json:"unread_count"`
}
// notificationSettingsResp is every kind the caller can receive, plus whether
// email can be delivered at all, so a client can say why before anyone tries.
type notificationSettingsResp struct {
Kinds []notifications.KindSetting `json:"kinds"`
EmailAvailable bool `json:"email_available"`
// EmailUnavailableReason is "no_address" or "smtp_not_configured" when
// EmailAvailable is false.
EmailUnavailableReason string `json:"email_unavailable_reason,omitempty"`
}
type notificationSettingsReq struct {
Kinds []notifications.SettingChange `json:"kinds"`
}
// handleListMyNotifications implements GET /api/me/notifications?limit&before.
func (h *handlers) handleListMyNotifications(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
limit := notificationsDefaultLimit
if raw := r.URL.Query().Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
writeErr(w, apierror.BadRequest("bad_limit", "limit must be a positive integer"))
return
}
limit = min(n, notificationsMaxLimit)
}
params := dbq.ListNotificationsParams{UserID: user.ID, PageLimit: int32(limit)}
if raw := r.URL.Query().Get("before"); raw != "" {
at, id, ok := parseNotificationCursor(raw)
if !ok {
writeErr(w, apierror.BadRequest("bad_cursor", "before is not a cursor this server issued"))
return
}
params.BeforeCreatedAt, params.BeforeID = at, id
}
q := dbq.New(h.pool)
rows, err := q.ListNotifications(r.Context(), params)
if err != nil {
writeErrWithLog(w, h.logger, "notifications: list", apierror.Internal(err))
return
}
unread, err := q.CountUnreadNotifications(r.Context(), user.ID)
if err != nil {
writeErrWithLog(w, h.logger, "notifications: count", apierror.Internal(err))
return
}
out := notificationsPageResp{Items: make([]notificationResp, 0, len(rows)), UnreadCount: unread}
for _, row := range rows {
rendered := notifications.Render(notifications.Kind(row.Kind), row.Payload)
item := notificationResp{
ID: uuidToString(row.ID),
Kind: row.Kind,
Title: rendered.Title,
Body: rendered.Body,
Link: rendered.Link,
CreatedAt: row.CreatedAt.Time,
}
if row.ReadAt.Valid {
t := row.ReadAt.Time
item.ReadAt = &t
}
out.Items = append(out.Items, item)
}
if len(rows) == limit {
last := rows[len(rows)-1]
out.NextBefore = formatNotificationCursor(last.CreatedAt, last.ID)
}
writeJSON(w, http.StatusOK, out)
}
// handleMyUnreadNotificationCount implements GET /api/me/notifications/unread-count,
// the cheap call behind the badge.
func (h *handlers) handleMyUnreadNotificationCount(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
n, err := dbq.New(h.pool).CountUnreadNotifications(r.Context(), user.ID)
if err != nil {
writeErrWithLog(w, h.logger, "notifications: count", apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, unreadCountResp{UnreadCount: n})
}
// handleMarkMyNotificationRead implements POST /api/me/notifications/{id}/read.
// Repeating it is harmless; another user's id is a 404, the same answer as a
// malformed one, so ids can't be probed.
func (h *handlers) handleMarkMyNotificationRead(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
id, ok := parseUUID(chi.URLParam(r, "id"))
if !ok {
writeErr(w, apierror.NotFound("notification"))
return
}
n, err := dbq.New(h.pool).MarkNotificationRead(r.Context(), dbq.MarkNotificationReadParams{ID: id, UserID: user.ID})
if err != nil {
writeErrWithLog(w, h.logger, "notifications: mark read", apierror.Internal(err))
return
}
if n == 0 {
writeErr(w, apierror.NotFound("notification"))
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleMarkAllMyNotificationsRead implements POST /api/me/notifications/read-all.
func (h *handlers) handleMarkAllMyNotificationsRead(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
// Optional {"up_to": RFC3339}: only what existed then. A client replaying
// an offline "mark all read" sends the moment the user asked.
var body struct {
UpTo *time.Time `json:"up_to"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON body"))
return
}
params := dbq.MarkAllNotificationsReadParams{UserID: user.ID}
if body.UpTo != nil {
params.UpTo = pgtype.Timestamptz{Time: *body.UpTo, Valid: true}
}
if _, err := dbq.New(h.pool).MarkAllNotificationsRead(r.Context(), params); err != nil {
writeErrWithLog(w, h.logger, "notifications: mark all read", apierror.Internal(err))
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleGetMyNotificationSettings implements GET /api/me/notification-settings.
func (h *handlers) handleGetMyNotificationSettings(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
q := dbq.New(h.pool)
kinds, err := notifications.LoadSettings(r.Context(), q, user.ID, user.IsAdmin)
if err != nil {
writeErrWithLog(w, h.logger, "notifications: load settings", apierror.Internal(err))
return
}
h.writeNotificationSettings(w, r, q, user, kinds)
}
// handlePutMyNotificationSettings implements PUT /api/me/notification-settings.
// A partial update: only the kinds and channels named change.
func (h *handlers) handlePutMyNotificationSettings(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
var body notificationSettingsReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON body"))
return
}
q := dbq.New(h.pool)
kinds, err := notifications.SaveSettings(r.Context(), q, user.ID, user.IsAdmin, body.Kinds)
if errors.Is(err, notifications.ErrSettingInvalid) {
writeErr(w, apierror.BadRequest("invalid_notification_setting", err.Error()))
return
}
if err != nil {
writeErrWithLog(w, h.logger, "notifications: save settings", apierror.Internal(err))
return
}
h.writeNotificationSettings(w, r, q, user, kinds)
}
func (h *handlers) writeNotificationSettings(w http.ResponseWriter, r *http.Request, q *dbq.Queries, user dbq.User, kinds []notifications.KindSetting) {
resp := notificationSettingsResp{Kinds: kinds, EmailAvailable: true}
if user.Email == nil || strings.TrimSpace(*user.Email) == "" {
resp.EmailAvailable, resp.EmailUnavailableReason = false, "no_address"
} else {
// A failed read is an error, not "not configured": that would tell
// the user something about the server the read never established.
cfg, err := q.GetSMTPConfig(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "notifications: read smtp config", apierror.Internal(err))
return
}
if !mailer.Configured(cfg) {
resp.EmailAvailable, resp.EmailUnavailableReason = false, "smtp_not_configured"
}
}
writeJSON(w, http.StatusOK, resp)
}
// The cursor is the last row's (created_at, id), opaque to clients.
func formatNotificationCursor(at pgtype.Timestamptz, id pgtype.UUID) string {
return at.Time.UTC().Format(time.RFC3339Nano) + "_" + uuidToString(id)
}
func parseNotificationCursor(raw string) (pgtype.Timestamptz, pgtype.UUID, bool) {
ts, idStr, found := strings.Cut(raw, "_")
if !found {
return pgtype.Timestamptz{}, pgtype.UUID{}, false
}
at, err := time.Parse(time.RFC3339Nano, ts)
if err != nil {
return pgtype.Timestamptz{}, pgtype.UUID{}, false
}
id, ok := parseUUID(idStr)
if !ok {
return pgtype.Timestamptz{}, pgtype.UUID{}, false
}
return pgtype.Timestamptz{Time: at, Valid: true}, id, true
}
+264
View File
@@ -0,0 +1,264 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
)
func notificationsRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Get("/api/me/notifications", h.handleListMyNotifications)
r.Get("/api/me/notifications/unread-count", h.handleMyUnreadNotificationCount)
r.Post("/api/me/notifications/read-all", h.handleMarkAllMyNotificationsRead)
r.Post("/api/me/notifications/{id}/read", h.handleMarkMyNotificationRead)
r.Get("/api/me/notification-settings", h.handleGetMyNotificationSettings)
r.Put("/api/me/notification-settings", h.handlePutMyNotificationSettings)
return r
}
func callAs(t *testing.T, r chi.Router, user dbq.User, method, path, body string, out any) int {
t.Helper()
req := withUser(httptest.NewRequest(method, path, bytes.NewBufferString(body)), user)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if out != nil && rec.Code == http.StatusOK {
if err := json.Unmarshal(rec.Body.Bytes(), out); err != nil {
t.Fatalf("decode %s: %v", rec.Body.String(), err)
}
}
return rec.Code
}
func notifyN(t *testing.T, pool *pgxpool.Pool, user dbq.User, n int) {
t.Helper()
nt := notifications.New(pool, nil, nil)
for i := 0; i < n; i++ {
if err := nt.Notify(context.Background(), notifications.KindRequestCompleted,
notifications.ToUser(user.ID), notifications.Payload{Name: "Album", AlbumID: "al-1"}.Map()); err != nil {
t.Fatalf("notify: %v", err)
}
}
}
func TestMyNotifications_ListRendersPagesAndCounts(t *testing.T) {
h, pool := testHandlers(t)
alice := seedUser(t, pool, "notif-alice", "pw", false)
bob := seedUser(t, pool, "notif-bob", "pw", false)
notifyN(t, pool, alice, 3)
notifyN(t, pool, bob, 1)
r := notificationsRouter(h)
var page notificationsPageResp
if code := callAs(t, r, alice, http.MethodGet, "/api/me/notifications?limit=2", "", &page); code != http.StatusOK {
t.Fatalf("list = %d", code)
}
if len(page.Items) != 2 || page.UnreadCount != 3 || page.NextBefore == "" {
t.Fatalf("first page = %d items, unread %d, next %q; want 2, 3, a cursor", len(page.Items), page.UnreadCount, page.NextBefore)
}
first := page.Items[0]
if first.Title != "Now in your library" || first.Body != "Album has arrived." || first.Link != "/albums/al-1" || first.ReadAt != nil {
t.Errorf("rendered item = %+v", first)
}
var rest notificationsPageResp
callAs(t, r, alice, http.MethodGet, "/api/me/notifications?limit=2&before="+page.NextBefore, "", &rest)
if len(rest.Items) != 1 || rest.NextBefore != "" {
t.Errorf("second page = %d items, next %q; want 1 and no cursor", len(rest.Items), rest.NextBefore)
}
seen := map[string]bool{}
for _, it := range append(page.Items, rest.Items...) {
if seen[it.ID] {
t.Errorf("item %s on two pages", it.ID)
}
seen[it.ID] = true
}
for _, bad := range []string{"?limit=0", "?limit=x", "?before=nonsense"} {
if code := callAs(t, r, alice, http.MethodGet, "/api/me/notifications"+bad, "", nil); code != http.StatusBadRequest {
t.Errorf("GET %s = %d, want 400", bad, code)
}
}
}
func TestMyNotifications_MarkReadIsOwnerScopedAndCountsDown(t *testing.T) {
h, pool := testHandlers(t)
alice := seedUser(t, pool, "notif-owner", "pw", false)
mallory := seedUser(t, pool, "notif-mallory", "pw", false)
notifyN(t, pool, alice, 2)
r := notificationsRouter(h)
var page notificationsPageResp
callAs(t, r, alice, http.MethodGet, "/api/me/notifications", "", &page)
id := page.Items[0].ID
if code := callAs(t, r, mallory, http.MethodPost, "/api/me/notifications/"+id+"/read", "", nil); code != http.StatusNotFound {
t.Errorf("another user's mark-read = %d, want 404", code)
}
for i := 0; i < 2; i++ {
if code := callAs(t, r, alice, http.MethodPost, "/api/me/notifications/"+id+"/read", "", nil); code != http.StatusNoContent {
t.Errorf("mark-read #%d = %d, want 204 (repeat is harmless)", i+1, code)
}
}
var count unreadCountResp
callAs(t, r, alice, http.MethodGet, "/api/me/notifications/unread-count", "", &count)
if count.UnreadCount != 1 {
t.Errorf("unread = %d, want 1", count.UnreadCount)
}
if code := callAs(t, r, alice, http.MethodPost, "/api/me/notifications/read-all", "", nil); code != http.StatusNoContent {
t.Fatalf("read-all = %d", code)
}
callAs(t, r, alice, http.MethodGet, "/api/me/notifications/unread-count", "", &count)
if count.UnreadCount != 0 {
t.Errorf("unread after read-all = %d, want 0", count.UnreadCount)
}
if code := callAs(t, r, alice, http.MethodPost, "/api/me/notifications/not-a-uuid/read", "", nil); code != http.StatusNotFound {
t.Errorf("malformed id = %d, want 404", code)
}
}
// A "mark all read" replayed from an offline queue carries the moment the
// user asked; what arrived after it stays unread.
func TestMyNotifications_ReadAllUpToLeavesLaterOnesUnread(t *testing.T) {
h, pool := testHandlers(t)
alice := seedUser(t, pool, "notif-upto", "pw", false)
r := notificationsRouter(h)
notifyN(t, pool, alice, 1)
var page notificationsPageResp
callAs(t, r, alice, http.MethodGet, "/api/me/notifications", "", &page)
seen := page.Items[0].CreatedAt
notifyN(t, pool, alice, 1)
body := `{"up_to":"` + seen.Format(time.RFC3339Nano) + `"}`
if code := callAs(t, r, alice, http.MethodPost, "/api/me/notifications/read-all", body, nil); code != http.StatusNoContent {
t.Fatalf("read-all up_to = %d", code)
}
var count unreadCountResp
callAs(t, r, alice, http.MethodGet, "/api/me/notifications/unread-count", "", &count)
if count.UnreadCount != 1 {
t.Errorf("unread = %d, want 1 (the one that arrived later)", count.UnreadCount)
}
if code := callAs(t, r, alice, http.MethodPost, "/api/me/notifications/read-all", `{"up_to":"yesterday"}`, nil); code != http.StatusBadRequest {
t.Errorf("malformed up_to = %d, want 400", code)
}
}
func strPtr(s string) *string { return &s }
func setSMTP(t *testing.T, pool *pgxpool.Pool, enabled bool) {
t.Helper()
ctx := context.Background()
q := dbq.New(pool)
prev, err := q.GetSMTPConfig(ctx)
if err != nil {
t.Fatalf("read smtp: %v", err)
}
t.Cleanup(func() {
_ = q.UpdateSMTPConfig(context.Background(), dbq.UpdateSMTPConfigParams{
Enabled: prev.Enabled, Host: prev.Host, Port: prev.Port, Username: prev.Username,
Password: prev.Password, FromAddress: prev.FromAddress, FromName: prev.FromName, UseTls: prev.UseTls,
})
})
if err := q.UpdateSMTPConfig(ctx, dbq.UpdateSMTPConfigParams{
Enabled: enabled, Host: "smtp.example.com", Port: 587, FromAddress: "minstrel@example.com", FromName: "Minstrel", UseTls: true,
}); err != nil {
t.Fatalf("set smtp: %v", err)
}
}
func TestMyNotificationSettings_DefaultsRoundTripAndAdminKinds(t *testing.T) {
h, pool := testHandlers(t)
setSMTP(t, pool, true)
user := seedUser(t, pool, "notif-settings", "pw", false)
admin := seedUser(t, pool, "notif-settings-admin", "pw", true)
r := notificationsRouter(h)
var s notificationSettingsResp
if code := callAs(t, r, user, http.MethodGet, "/api/me/notification-settings", "", &s); code != http.StatusOK {
t.Fatalf("get = %d", code)
}
for _, k := range s.Kinds {
if k.AdminOnly {
t.Errorf("non-admin offered admin kind %s", k.Kind)
}
}
if s.EmailAvailable || s.EmailUnavailableReason != "no_address" {
t.Errorf("no address on file: email_available=%v reason=%q", s.EmailAvailable, s.EmailUnavailableReason)
}
// A partial change touches only what it names.
body := `{"kinds":[{"kind":"request_completed","email":false}]}`
if code := callAs(t, r, user, http.MethodPut, "/api/me/notification-settings", body, &s); code != http.StatusOK {
t.Fatalf("put = %d", code)
}
for _, k := range s.Kinds {
if k.Kind == notifications.KindRequestCompleted && (k.Email || !k.Inbox || !k.Phone) {
t.Errorf("after PUT request_completed = %+v, want inbox+phone on, email off", k)
}
if k.Kind == notifications.KindRequestApproved && !k.Email {
t.Errorf("an untouched kind changed: %+v", k)
}
}
// A non-admin can't set an admin kind; nothing in the batch is applied.
bad := `{"kinds":[{"kind":"request_approved","inbox":false},{"kind":"tracks_missing","inbox":false}]}`
if code := callAs(t, r, user, http.MethodPut, "/api/me/notification-settings", bad, nil); code != http.StatusBadRequest {
t.Errorf("non-admin setting an admin kind = %d, want 400", code)
}
callAs(t, r, user, http.MethodGet, "/api/me/notification-settings", "", &s)
for _, k := range s.Kinds {
if k.Kind == notifications.KindRequestApproved && !k.Inbox {
t.Error("a refused batch was partly applied")
}
}
if code := callAs(t, r, user, http.MethodPut, "/api/me/notification-settings", `{"kinds":[{"kind":"bogus"}]}`, nil); code != http.StatusBadRequest {
t.Errorf("unknown kind = %d, want 400", code)
}
// An admin with an address and SMTP on sees the admin kinds and can email.
if _, err := pool.Exec(context.Background(), `UPDATE users SET email = 'admin@example.com' WHERE id = $1`, admin.ID); err != nil {
t.Fatal(err)
}
admin.Email = strPtr("admin@example.com")
callAs(t, r, admin, http.MethodGet, "/api/me/notification-settings", "", &s)
if !s.EmailAvailable {
t.Errorf("admin with address + SMTP: email unavailable (%q)", s.EmailUnavailableReason)
}
var adminKinds int
for _, k := range s.Kinds {
if k.AdminOnly {
adminKinds++
if k.Kind == notifications.KindTracksMissing && k.Email {
t.Error("tracks_missing should default to email off")
}
}
}
if adminKinds == 0 {
t.Error("admin was offered no admin kinds")
}
}
func TestMyNotificationSettings_EmailUnavailableWhenSMTPIsOff(t *testing.T) {
h, pool := testHandlers(t)
setSMTP(t, pool, false)
user := seedUser(t, pool, "notif-nosmtp", "pw", false)
user.Email = strPtr("someone@example.com")
var s notificationSettingsResp
callAs(t, notificationsRouter(h), user, http.MethodGet, "/api/me/notification-settings", "", &s)
if s.EmailAvailable || s.EmailUnavailableReason != "smtp_not_configured" {
t.Errorf("SMTP off: email_available=%v reason=%q", s.EmailAvailable, s.EmailUnavailableReason)
}
}
+86
View File
@@ -0,0 +1,86 @@
package api
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
)
// Helpers for the handlers that produce notifications (M489). Every call is
// NotifyLogged: a notification never fails the action that caused it.
// userLabel is how a user is named to someone else: their display name, or
// their username when they have none.
func userLabel(u dbq.User) string {
if u.DisplayName != nil && *u.DisplayName != "" {
return *u.DisplayName
}
return u.Username
}
func requestPayload(row dbq.LidarrRequest) notifications.Payload {
return notifications.Payload{
RequestID: uuidToString(row.ID),
RequestKind: string(row.Kind),
Name: lidarrrequests.DisplayName(row),
}
}
// notifyRequestDecided tells the requester an admin approved or declined
// their request. An admin deciding their own request needs no notice.
func (h *handlers) notifyRequestDecided(ctx context.Context, kind notifications.Kind, admin dbq.User, row dbq.LidarrRequest, reason string) {
if row.UserID == admin.ID {
return
}
p := requestPayload(row)
p.Reason = reason
h.notifier.NotifyLogged(ctx, kind, notifications.ToUser(row.UserID), p.Map())
}
// quarantineReasonLabel is a flag reason as the flag popover words it, in
// lower case to sit inside a sentence ("flagged X: bad rip").
func quarantineReasonLabel(reason string) string {
switch reason {
case "bad_rip":
return "bad rip"
case "wrong_file":
return "wrong file"
case "wrong_tags":
return "wrong tags"
case "duplicate":
return "duplicate"
default:
return ""
}
}
// trackLabel names a track "Artist – Title" for a notification, falling back
// to the title alone, or to nothing, rather than failing the caller.
func (h *handlers) trackLabel(ctx context.Context, trackID pgtype.UUID) string {
q := dbq.New(h.pool)
t, err := q.GetTrackByID(ctx, trackID)
if err != nil {
return ""
}
if a, aerr := q.GetArtistByID(ctx, t.ArtistID); aerr == nil && a.Name != "" {
return a.Name + " – " + t.Title
}
return t.Title
}
// notifyPlaybackErrors tells admins how many playback errors await review.
// The notice coalesces, so while it is unread a new report only updates the
// count.
func (h *handlers) notifyPlaybackErrors(ctx context.Context) {
n, err := dbq.New(h.pool).CountUnresolvedPlaybackErrors(ctx)
if err != nil {
h.logger.Warn("api: playback_error: count unresolved", "err", err)
return
}
h.notifier.NotifyLogged(ctx, notifications.KindPlaybackErrors, notifications.ToAdmins(pgtype.UUID{}),
notifications.Payload{Count: n}.Map())
}
+238
View File
@@ -0,0 +1,238 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
)
// inboxItem is one thing the notifier left for one user: kind and decoded payload,
// newest first.
type inboxItem struct {
Kind string
Payload notifications.Payload
}
func inboxOf(t *testing.T, pool *pgxpool.Pool, user dbq.User) []inboxItem {
t.Helper()
rows, err := dbq.New(pool).ListNotifications(context.Background(), dbq.ListNotificationsParams{
UserID: user.ID, PageLimit: 100,
})
if err != nil {
t.Fatalf("list notifications: %v", err)
}
out := make([]inboxItem, 0, len(rows))
for _, r := range rows {
var p notifications.Payload
if err := json.Unmarshal(r.Payload, &p); err != nil {
t.Fatalf("payload %s: %v", r.Payload, err)
}
out = append(out, inboxItem{Kind: r.Kind, Payload: p})
}
return out
}
func wantInbox(t *testing.T, who string, got []inboxItem, kinds ...notifications.Kind) {
t.Helper()
if len(got) != len(kinds) {
t.Fatalf("%s inbox = %+v, want kinds %v", who, got, kinds)
}
for i, k := range kinds {
if got[i].Kind != string(k) {
t.Errorf("%s inbox[%d] = %q, want %q", who, i, got[i].Kind, k)
}
}
}
// newApprovingLidarrStub answers every Lidarr call an approval makes.
func newApprovingLidarrStub(t *testing.T) *httptest.Server {
t.Helper()
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.Contains(r.URL.Path, "/metadataprofile"):
_, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
case strings.Contains(r.URL.Path, "/qualityprofile"):
_, _ = w.Write([]byte(`[{"id":1,"name":"Lossless"}]`))
default:
_, _ = w.Write([]byte(`{"id":1}`))
}
}))
t.Cleanup(stub.Close)
return stub
}
func TestNotify_NewRequestReachesOtherAdminsOnce(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
alice := seedUser(t, pool, "np-alice", "pw", false)
admin := seedUser(t, pool, "np-admin", "pw", true)
selfAdmin := seedUser(t, pool, "np-self", "pw", true)
rv := createArtistRequest(t, h, alice, "np-mbid-1", "Pending Band")
// The same request again dedups into the first and is not announced twice.
createArtistRequest(t, h, alice, "np-mbid-1", "Pending Band")
got := inboxOf(t, pool, admin)
wantInbox(t, "admin", got, notifications.KindRequestPending)
if got[0].Payload.RequestID != uuidToString(rv.ID) || got[0].Payload.Name != "Pending Band" || got[0].Payload.Actor != alice.Username {
t.Errorf("pending payload = %+v", got[0].Payload)
}
wantInbox(t, "requester", inboxOf(t, pool, alice))
// An admin's own request goes to the other admins, never back to them.
createArtistRequest(t, h, selfAdmin, "np-mbid-2", "Own Band")
wantInbox(t, "self-admin", inboxOf(t, pool, selfAdmin), notifications.KindRequestPending)
if n := len(inboxOf(t, pool, admin)); n != 2 {
t.Errorf("admin inbox = %d, want 2 (alice's and the self-admin's)", n)
}
}
func TestNotify_AutoApprovedRequestWaitsOnNobody(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
saveLidarrConfig(t, h, newApprovingLidarrStub(t).URL, true)
alice := seedUser(t, h.pool, "np-auto", "pw", false)
admin := seedUser(t, h.pool, "np-auto-admin", "pw", true)
if _, err := h.pool.Exec(context.Background(),
"UPDATE users SET auto_approve_requests = true WHERE id = $1", alice.ID); err != nil {
t.Fatalf("set auto_approve: %v", err)
}
alice, err := dbq.New(h.pool).GetUserByID(context.Background(), alice.ID)
if err != nil {
t.Fatalf("reload user: %v", err)
}
rv := createArtistRequest(t, h, alice, "np-auto-mbid", "Auto Band")
if rv.Status != "approved" {
t.Fatalf("status = %q, want approved — the stub should accept the add", rv.Status)
}
wantInbox(t, "admin", inboxOf(t, h.pool, admin))
wantInbox(t, "requester", inboxOf(t, h.pool, alice))
}
func TestNotify_DecisionsReachTheRequester(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
saveLidarrConfig(t, h, newApprovingLidarrStub(t).URL, true)
alice := seedUser(t, h.pool, "np-dec-alice", "pw", false)
admin := seedUser(t, h.pool, "np-dec-admin", "pw", true)
approved := seedPendingArtistRequest(t, h, alice, "np-dec-1", "Yes Band")
rejected := seedPendingArtistRequest(t, h, alice, "np-dec-2", "No Band")
own := seedPendingArtistRequest(t, h, admin, "np-dec-3", "Own Band")
for _, c := range []struct {
id string
verb string
body []byte
}{
{uuidToString(approved.ID), "approve", nil},
{uuidToString(rejected.ID), "reject", []byte(`{"notes":"Only a live bootleg exists"}`)},
{uuidToString(own.ID), "approve", nil},
} {
w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+c.id+"/"+c.verb, c.body, admin)
if w.Code != http.StatusOK {
t.Fatalf("%s %s: status = %d; body = %s", c.verb, c.id, w.Code, w.Body.String())
}
}
got := inboxOf(t, h.pool, alice)
wantInbox(t, "requester", got, notifications.KindRequestRejected, notifications.KindRequestApproved)
if got[0].Payload.Name != "No Band" || got[0].Payload.Reason != "Only a live bootleg exists" {
t.Errorf("rejected payload = %+v", got[0].Payload)
}
if got[1].Payload.Name != "Yes Band" || got[1].Payload.Reason != "" {
t.Errorf("approved payload = %+v", got[1].Payload)
}
// The admin decided their own request: no notice. alice's two pending
// notices are all the admin holds.
for _, it := range inboxOf(t, h.pool, admin) {
if it.Kind != string(notifications.KindRequestPending) {
t.Errorf("admin inbox holds %q, want only request_pending", it.Kind)
}
}
}
func TestNotify_FlagReachesAdminsButNotTheFlagger(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
alice := seedUser(t, pool, "np-flag-alice", "pw", false)
admin := seedUser(t, pool, "np-flag-admin", "pw", true)
other := seedUser(t, pool, "np-flag-other", "pw", true)
track := seedQuarantineTrack(t, h, "np")
w := doFlag(h, alice, fmt.Sprintf(`{"track_id":%q,"reason":"bad_rip"}`, uuidToString(track.ID)))
if w.Code != http.StatusCreated {
t.Fatalf("flag: status = %d; body = %s", w.Code, w.Body.String())
}
got := inboxOf(t, pool, admin)
wantInbox(t, "admin", got, notifications.KindQuarantineFlagged)
if p := got[0].Payload; p.Name != "Q Artist np – Q Track np" || p.Actor != alice.Username || p.Reason != "bad rip" {
t.Errorf("flag payload = %+v", p)
}
wantInbox(t, "flagger", inboxOf(t, pool, alice))
// An admin flagging a track tells the other admins only.
track2 := seedQuarantineTrack(t, h, "np2")
w = doFlag(h, admin, fmt.Sprintf(`{"track_id":%q,"reason":"other"}`, uuidToString(track2.ID)))
if w.Code != http.StatusCreated {
t.Fatalf("admin flag: status = %d; body = %s", w.Code, w.Body.String())
}
if n := len(inboxOf(t, pool, admin)); n != 1 {
t.Errorf("flagging admin inbox = %d, want 1 (alice's flag only)", n)
}
if n := len(inboxOf(t, pool, other)); n != 2 {
t.Errorf("other admin inbox = %d, want 2", n)
}
}
func TestQuarantineReasonLabel(t *testing.T) {
for in, want := range map[string]string{
"bad_rip": "bad rip", "wrong_file": "wrong file", "wrong_tags": "wrong tags",
"duplicate": "duplicate", "other": "", "": "",
} {
if got := quarantineReasonLabel(in); got != want {
t.Errorf("quarantineReasonLabel(%q) = %q, want %q", in, got, want)
}
}
}
func TestNotify_PlaybackErrorsCoalesceIntoOneCount(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
alice := seedUser(t, pool, "np-play-alice", "pw", false)
admin := seedUser(t, pool, "np-play-admin", "pw", true)
track := seedQuarantineTrack(t, h, "play")
for i := 0; i < 2; i++ {
body := fmt.Sprintf(`{"track_id":%q,"kind":"load_failed","client_id":"web-%d"}`, uuidToString(track.ID), i)
req := withUser(httptest.NewRequest(http.MethodPost, "/api/playback-errors", strings.NewReader(body)), alice)
w := httptest.NewRecorder()
h.handleReportPlaybackError(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("report %d: status = %d; body = %s", i, w.Code, w.Body.String())
}
}
got := inboxOf(t, pool, admin)
wantInbox(t, "admin", got, notifications.KindPlaybackErrors)
if got[0].Payload.Count != 2 {
t.Errorf("count = %d, want 2 unresolved", got[0].Payload.Count)
}
wantInbox(t, "reporter", inboxOf(t, pool, alice))
}
+1
View File
@@ -130,6 +130,7 @@ func (h *handlers) handleReportPlaybackError(w http.ResponseWriter, r *http.Requ
writeErr(w, apierror.Internal(err))
return
}
h.notifyPlaybackErrors(r.Context())
writeJSON(w, http.StatusCreated, map[string]string{"id": uuidToString(row.ID)})
}
+5
View File
@@ -9,6 +9,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
)
// quarantineView is the JSON shape returned by the user-facing endpoints
@@ -70,6 +71,10 @@ func (h *handlers) handleFlag(w http.ResponseWriter, r *http.Request) {
// Broadcast: the flagging user's other clients invalidate their
// Hidden tab; admins' clients invalidate their quarantine queue.
h.publishQuarantineEvent("quarantine.flagged", user.ID, trackID, true)
// Admins review flags (M489). An admin flagging a track needs no notice
// of their own flag.
h.notifier.NotifyLogged(r.Context(), notifications.KindQuarantineFlagged, notifications.ToAdmins(user.ID),
notifications.Payload{Name: h.trackLabel(r.Context(), trackID), Actor: userLabel(user), Reason: quarantineReasonLabel(body.Reason)}.Map())
writeJSON(w, http.StatusCreated, quarantineViewFrom(row))
}
+12 -1
View File
@@ -10,6 +10,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
)
// requestView is the JSON shape returned by all /api/requests handlers.
@@ -130,7 +131,7 @@ func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) {
return
}
row, err := h.lidarrRequests.Create(r.Context(), user.ID, lidarrrequests.CreateParams{
row, created, err := h.lidarrRequests.CreateTracked(r.Context(), user.ID, lidarrrequests.CreateParams{
Kind: body.Kind,
LidarrArtistMBID: body.LidarrArtistMBID,
LidarrAlbumMBID: body.LidarrAlbumMBID,
@@ -175,6 +176,16 @@ func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) {
}
}
// A new request still pending after any auto-approval waits on an admin
// (M489). One that deduped into a request already in flight was
// announced when it was first made, and the requester, if they are an
// admin themselves, needs no notice of their own request.
if created && row.Status == dbq.LidarrRequestStatusPending {
p := requestPayload(row)
p.Actor = userLabel(user)
h.notifier.NotifyLogged(r.Context(), notifications.KindRequestPending, notifications.ToAdmins(user.ID), p.Map())
}
h.publishRequestStatusChanged(row)
writeJSON(w, http.StatusCreated, requestViewFrom(row))
}
+59 -27
View File
@@ -216,35 +216,55 @@ func (q *Queries) ListRandomUnheardTracksForDiscover(ctx context.Context, arg Li
}
const listTasteUnheardTracksForDiscover = `-- name: ListTasteUnheardTracksForDiscover :many
SELECT t.id, t.album_id, t.artist_id
FROM tracks t
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
JOIN taste_profile_tags nt ON nt.user_id = $1 AND trim(g_split.g) = nt.tag
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
AND nt.weight > 0
AND trim(g_split.g) <> ''
AND NOT EXISTS (
SELECT 1 FROM play_events pe
WHERE pe.user_id = $1
AND pe.track_id = t.id
AND pe.was_skipped = false
)
AND NOT EXISTS (
SELECT 1 FROM general_likes gl
WHERE gl.user_id = $1 AND gl.track_id = t.id
)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
GROUP BY t.id, t.album_id, t.artist_id
ORDER BY SUM(nt.weight) DESC, md5(t.id::text || $2::text)
WITH scored AS (
SELECT t.id, t.album_id, t.artist_id,
SUM(nt.weight) AS weight,
md5(t.id::text || $2::text) AS tiebreak
FROM tracks t
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
JOIN taste_profile_tags nt ON nt.user_id = $3 AND trim(g_split.g) = nt.tag
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
AND nt.weight > 0
AND trim(g_split.g) <> ''
AND NOT EXISTS (
SELECT 1 FROM play_events pe
WHERE pe.user_id = $3
AND pe.track_id = t.id
AND pe.was_skipped = false
)
AND NOT EXISTS (
SELECT 1 FROM general_likes gl
WHERE gl.user_id = $3 AND gl.track_id = t.id
)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $3 AND q.track_id = t.id
)
GROUP BY t.id, t.album_id, t.artist_id
),
album_capped AS (
SELECT s.id, s.album_id, s.artist_id, s.weight, s.tiebreak,
row_number() OVER (PARTITION BY s.album_id ORDER BY s.weight DESC, s.tiebreak) AS album_rank
FROM scored s
),
artist_capped AS (
SELECT a.id, a.album_id, a.artist_id, a.weight, a.tiebreak, a.album_rank,
row_number() OVER (PARTITION BY a.artist_id ORDER BY a.weight DESC, a.tiebreak) AS artist_rank
FROM album_capped a
WHERE a.album_rank <= $4::int
)
SELECT c.id, c.album_id, c.artist_id
FROM artist_capped c
WHERE c.artist_rank <= $1::int
ORDER BY c.weight DESC, c.tiebreak
LIMIT 120
`
type ListTasteUnheardTracksForDiscoverParams struct {
UserID pgtype.UUID
Column2 string
MaxPerArtist int32
DateSeed string
UserID pgtype.UUID
MaxPerAlbum int32
}
type ListTasteUnheardTracksForDiscoverRow struct {
@@ -261,9 +281,21 @@ type ListTasteUnheardTracksForDiscoverRow struct {
// [;,]). Same exclusion filters as the other buckets. Returns nothing
// when the user has no taste tags yet (cold start), so the caller
// redistributes its slots to the other buckets. Stamped 'taste_unheard'.
// $1 = user_id, $2 = date string for md5 tiebreak ordering.
//
// The per-album and per-artist caps apply BEFORE the LIMIT (#5356). Summed
// weight rewards a track for carrying many of the user's tags, so a few
// artists whose every track is tagged with the whole profile take every row
// of a plain LIMIT; the caller's caps then left 6 of 120 on the deploy, and
// the best-performing arm handed its slots to the others. Ranking within
// album, then within artist over what the album cap kept, is the same walk
// capByAlbumAndArtist makes, so the caller's caps keep everything here.
func (q *Queries) ListTasteUnheardTracksForDiscover(ctx context.Context, arg ListTasteUnheardTracksForDiscoverParams) ([]ListTasteUnheardTracksForDiscoverRow, error) {
rows, err := q.db.Query(ctx, listTasteUnheardTracksForDiscover, arg.UserID, arg.Column2)
rows, err := q.db.Query(ctx, listTasteUnheardTracksForDiscover,
arg.MaxPerArtist,
arg.DateSeed,
arg.UserID,
arg.MaxPerAlbum,
)
if err != nil {
return nil, err
}
+18
View File
@@ -27,6 +27,24 @@ func (q *Queries) AddDuplicateGroupMember(ctx context.Context, arg AddDuplicateG
return err
}
const countDuplicateGroupsDetectedSince = `-- name: CountDuplicateGroupsDetectedSince :one
SELECT count(*)::bigint
FROM duplicate_groups
WHERE status = 'pending'
AND detected_at >= $1
`
// Pending proposals first made at or after `since`: what a sweep that started
// then found for the first time. A refreshed proposal keeps its detected_at,
// so a sweep that only re-finds known groups counts none (M489: admins are
// told about new duplicates, not reminded of the same ones every sweep).
func (q *Queries) CountDuplicateGroupsDetectedSince(ctx context.Context, since pgtype.Timestamptz) (int64, error) {
row := q.db.QueryRow(ctx, countDuplicateGroupsDetectedSince, since)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}
const countPendingDuplicateGroups = `-- name: CountPendingDuplicateGroups :one
SELECT count(*)::bigint
FROM duplicate_groups g
+36
View File
@@ -472,6 +472,13 @@ type NetworkSetting struct {
PublicUrl string
}
type NotificationEmailSetting struct {
ID bool
SummaryHour int32
BatchWindowMinutes int32
UpdatedAt pgtype.Timestamptz
}
type PasswordReset struct {
Token string
UserID pgtype.UUID
@@ -831,6 +838,35 @@ type UserNormalizationPref struct {
UpdatedAt pgtype.Timestamptz
}
type UserNotification struct {
ID pgtype.UUID
UserID pgtype.UUID
Kind string
Payload []byte
CreatedAt pgtype.Timestamptz
ReadAt pgtype.Timestamptz
CoalesceKey *string
EmailedAt pgtype.Timestamptz
}
type UserNotificationEmailState struct {
UserID pgtype.UUID
EmailGroup string
BatchOpenedAt pgtype.Timestamptz
LastSentAt pgtype.Timestamptz
Failures int32
RetryAfter pgtype.Timestamptz
}
type UserNotificationPref struct {
UserID pgtype.UUID
Kind string
Inbox bool
Phone bool
Email bool
UpdatedAt pgtype.Timestamptz
}
type YouMightLikeAlbum struct {
UserID pgtype.UUID
AlbumID pgtype.UUID
+566
View File
@@ -0,0 +1,566 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: notifications.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const countUnreadNotifications = `-- name: CountUnreadNotifications :one
SELECT count(*) FROM user_notifications
WHERE user_id = $1 AND read_at IS NULL
`
func (q *Queries) CountUnreadNotifications(ctx context.Context, userID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, countUnreadNotifications, userID)
var count int64
err := row.Scan(&count)
return count, err
}
const getNotificationEmailSettings = `-- name: GetNotificationEmailSettings :one
SELECT id, summary_hour, batch_window_minutes, updated_at FROM notification_email_settings WHERE id = true
`
func (q *Queries) GetNotificationEmailSettings(ctx context.Context) (NotificationEmailSetting, error) {
row := q.db.QueryRow(ctx, getNotificationEmailSettings)
var i NotificationEmailSetting
err := row.Scan(
&i.ID,
&i.SummaryHour,
&i.BatchWindowMinutes,
&i.UpdatedAt,
)
return i, err
}
const insertNotification = `-- name: InsertNotification :one
INSERT INTO user_notifications (user_id, kind, payload, emailed_at)
VALUES ($1, $2, $3,
CASE WHEN $4::boolean THEN NULL ELSE now() END)
RETURNING id
`
type InsertNotificationParams struct {
UserID pgtype.UUID
Kind string
Payload []byte
EmailWanted bool
}
// M489 notifications inbox (#726). Rows are written only through
// internal/notifications.Notifier, which decides recipients and honours each
// recipient's inbox preference before it reaches these.
// email_wanted is the recipient's email channel for this kind as of now. A
// row nobody wants emailed is stamped at once, so the digest never has to
// judge it and a later "email on" doesn't send an old backlog.
func (q *Queries) InsertNotification(ctx context.Context, arg InsertNotificationParams) (pgtype.UUID, error) {
row := q.db.QueryRow(ctx, insertNotification,
arg.UserID,
arg.Kind,
arg.Payload,
arg.EmailWanted,
)
var id pgtype.UUID
err := row.Scan(&id)
return id, err
}
const listAdminUserIDs = `-- name: ListAdminUserIDs :many
SELECT id FROM users WHERE is_admin = true ORDER BY created_at, id
`
func (q *Queries) ListAdminUserIDs(ctx context.Context) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, listAdminUserIDs)
if err != nil {
return nil, err
}
defer rows.Close()
var items []pgtype.UUID
for rows.Next() {
var id pgtype.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listEmailPendingNotifications = `-- name: ListEmailPendingNotifications :many
SELECT n.id, n.user_id, n.kind, n.payload, n.created_at,
u.email::text AS email, u.username, u.display_name, u.timezone,
now()::timestamptz AS read_as_of
FROM user_notifications n
JOIN users u ON u.id = n.user_id
WHERE n.read_at IS NULL
AND n.emailed_at IS NULL
AND u.email IS NOT NULL AND u.email <> ''
ORDER BY n.user_id, n.created_at, n.id
`
type ListEmailPendingNotificationsRow struct {
ID pgtype.UUID
UserID pgtype.UUID
Kind string
Payload []byte
CreatedAt pgtype.Timestamptz
Email string
Username string
DisplayName *string
Timezone string
ReadAsOf pgtype.Timestamptz
}
// Email digest (#5346) ------------------------------------------------------
// Every unread, un-emailed row of a user who has an address, oldest first.
// Read rows are never selected: the user has seen them, so they are not news.
// read_as_of is the database's clock at the read, handed back to
// MarkNotificationsEmailed.
func (q *Queries) ListEmailPendingNotifications(ctx context.Context) ([]ListEmailPendingNotificationsRow, error) {
rows, err := q.db.Query(ctx, listEmailPendingNotifications)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListEmailPendingNotificationsRow
for rows.Next() {
var i ListEmailPendingNotificationsRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Kind,
&i.Payload,
&i.CreatedAt,
&i.Email,
&i.Username,
&i.DisplayName,
&i.Timezone,
&i.ReadAsOf,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listNotificationEmailState = `-- name: ListNotificationEmailState :many
SELECT user_id, email_group, batch_opened_at, last_sent_at, failures, retry_after
FROM user_notification_email_state
`
func (q *Queries) ListNotificationEmailState(ctx context.Context) ([]UserNotificationEmailState, error) {
rows, err := q.db.Query(ctx, listNotificationEmailState)
if err != nil {
return nil, err
}
defer rows.Close()
var items []UserNotificationEmailState
for rows.Next() {
var i UserNotificationEmailState
if err := rows.Scan(
&i.UserID,
&i.EmailGroup,
&i.BatchOpenedAt,
&i.LastSentAt,
&i.Failures,
&i.RetryAfter,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listNotificationPrefsForKind = `-- name: ListNotificationPrefsForKind :many
SELECT user_id, inbox, phone, email
FROM user_notification_prefs
WHERE kind = $1 AND user_id = ANY($2::uuid[])
`
type ListNotificationPrefsForKindParams struct {
Kind string
UserIds []pgtype.UUID
}
type ListNotificationPrefsForKindRow struct {
UserID pgtype.UUID
Inbox bool
Phone bool
Email bool
}
// The stored prefs of one kind for a set of recipients. A recipient with no
// row has the kind's defaults.
func (q *Queries) ListNotificationPrefsForKind(ctx context.Context, arg ListNotificationPrefsForKindParams) ([]ListNotificationPrefsForKindRow, error) {
rows, err := q.db.Query(ctx, listNotificationPrefsForKind, arg.Kind, arg.UserIds)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListNotificationPrefsForKindRow
for rows.Next() {
var i ListNotificationPrefsForKindRow
if err := rows.Scan(
&i.UserID,
&i.Inbox,
&i.Phone,
&i.Email,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listNotificationPrefsForUser = `-- name: ListNotificationPrefsForUser :many
SELECT kind, inbox, phone, email
FROM user_notification_prefs
WHERE user_id = $1
`
type ListNotificationPrefsForUserRow struct {
Kind string
Inbox bool
Phone bool
Email bool
}
func (q *Queries) ListNotificationPrefsForUser(ctx context.Context, userID pgtype.UUID) ([]ListNotificationPrefsForUserRow, error) {
rows, err := q.db.Query(ctx, listNotificationPrefsForUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListNotificationPrefsForUserRow
for rows.Next() {
var i ListNotificationPrefsForUserRow
if err := rows.Scan(
&i.Kind,
&i.Inbox,
&i.Phone,
&i.Email,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listNotifications = `-- name: ListNotifications :many
SELECT id, kind, payload, created_at, read_at
FROM user_notifications
WHERE user_id = $1
AND ($2::timestamptz IS NULL
OR (created_at, id) < ($2::timestamptz, $3::uuid))
ORDER BY created_at DESC, id DESC
LIMIT $4
`
type ListNotificationsParams struct {
UserID pgtype.UUID
BeforeCreatedAt pgtype.Timestamptz
BeforeID pgtype.UUID
PageLimit int32
}
type ListNotificationsRow struct {
ID pgtype.UUID
Kind string
Payload []byte
CreatedAt pgtype.Timestamptz
ReadAt pgtype.Timestamptz
}
// Newest first, keyset-paged on (created_at, id). Pass both cursor halves
// from the last row of the previous page, or neither for the first page.
func (q *Queries) ListNotifications(ctx context.Context, arg ListNotificationsParams) ([]ListNotificationsRow, error) {
rows, err := q.db.Query(ctx, listNotifications,
arg.UserID,
arg.BeforeCreatedAt,
arg.BeforeID,
arg.PageLimit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListNotificationsRow
for rows.Next() {
var i ListNotificationsRow
if err := rows.Scan(
&i.ID,
&i.Kind,
&i.Payload,
&i.CreatedAt,
&i.ReadAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markAllNotificationsRead = `-- name: MarkAllNotificationsRead :execrows
UPDATE user_notifications
SET read_at = now()
WHERE user_id = $1
AND read_at IS NULL
AND ($2::timestamptz IS NULL OR created_at <= $2)
`
type MarkAllNotificationsReadParams struct {
UserID pgtype.UUID
UpTo pgtype.Timestamptz
}
// up_to, when set, limits it to what existed when the user asked: a "mark
// all read" queued offline and replayed later must not mark notices that
// arrived in between, which the user never saw. A coalesced row updated
// since then carries a newer created_at, so it stays unread too.
func (q *Queries) MarkAllNotificationsRead(ctx context.Context, arg MarkAllNotificationsReadParams) (int64, error) {
result, err := q.db.Exec(ctx, markAllNotificationsRead, arg.UserID, arg.UpTo)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const markNotificationRead = `-- name: MarkNotificationRead :execrows
UPDATE user_notifications
SET read_at = COALESCE(read_at, now())
WHERE id = $1 AND user_id = $2
`
type MarkNotificationReadParams struct {
ID pgtype.UUID
UserID pgtype.UUID
}
// Scoped to the owner: another user's id matches no row. Marking an already
// read row keeps its original read_at and still matches, so a repeat is not
// mistaken for "not yours".
func (q *Queries) MarkNotificationRead(ctx context.Context, arg MarkNotificationReadParams) (int64, error) {
result, err := q.db.Exec(ctx, markNotificationRead, arg.ID, arg.UserID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const markNotificationsEmailed = `-- name: MarkNotificationsEmailed :execrows
UPDATE user_notifications
SET emailed_at = now()
WHERE id = ANY($1::uuid[])
AND created_at <= $2::timestamptz
AND emailed_at IS NULL
`
type MarkNotificationsEmailedParams struct {
Ids []pgtype.UUID
ReadAsOf pgtype.Timestamptz
}
// Stamps the rows an email carried, or that were judged not to need one.
// read_as_of is from ListEmailPendingNotifications: a coalesced row updated
// since that read carries a later created_at, holds newer news, and stays
// pending for the next email.
func (q *Queries) MarkNotificationsEmailed(ctx context.Context, arg MarkNotificationsEmailedParams) (int64, error) {
result, err := q.db.Exec(ctx, markNotificationsEmailed, arg.Ids, arg.ReadAsOf)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const trimNotifications = `-- name: TrimNotifications :execrows
DELETE FROM user_notifications
WHERE (read_at IS NOT NULL AND read_at < $1)
OR created_at < $2
`
type TrimNotificationsParams struct {
ReadCutoff pgtype.Timestamptz
AnyCutoff pgtype.Timestamptz
}
// Retention: read rows go after read_cutoff, and anything at all after
// any_cutoff, so an inbox nobody opens doesn't grow without bound either.
func (q *Queries) TrimNotifications(ctx context.Context, arg TrimNotificationsParams) (int64, error) {
result, err := q.db.Exec(ctx, trimNotifications, arg.ReadCutoff, arg.AnyCutoff)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const updateNotificationEmailSettings = `-- name: UpdateNotificationEmailSettings :one
UPDATE notification_email_settings
SET summary_hour = $1,
batch_window_minutes = $2,
updated_at = now()
WHERE id = true
RETURNING id, summary_hour, batch_window_minutes, updated_at
`
type UpdateNotificationEmailSettingsParams struct {
SummaryHour int32
BatchWindowMinutes int32
}
// Migration 0074's CHECKs are the backstop behind the service's validation.
func (q *Queries) UpdateNotificationEmailSettings(ctx context.Context, arg UpdateNotificationEmailSettingsParams) (NotificationEmailSetting, error) {
row := q.db.QueryRow(ctx, updateNotificationEmailSettings, arg.SummaryHour, arg.BatchWindowMinutes)
var i NotificationEmailSetting
err := row.Scan(
&i.ID,
&i.SummaryHour,
&i.BatchWindowMinutes,
&i.UpdatedAt,
)
return i, err
}
const upsertCoalescedNotification = `-- name: UpsertCoalescedNotification :one
INSERT INTO user_notifications (user_id, kind, payload, coalesce_key, emailed_at)
VALUES ($1, $2, $3, $4,
CASE WHEN $5::boolean THEN NULL ELSE now() END)
ON CONFLICT (user_id, coalesce_key) WHERE read_at IS NULL AND coalesce_key IS NOT NULL
DO UPDATE SET
payload = CASE
WHEN $6::boolean THEN
EXCLUDED.payload || jsonb_build_object('count',
COALESCE((user_notifications.payload->>'count')::bigint, 0)
+ COALESCE((EXCLUDED.payload->>'count')::bigint, 0))
ELSE EXCLUDED.payload
END,
created_at = now(),
emailed_at = EXCLUDED.emailed_at
RETURNING id
`
type UpsertCoalescedNotificationParams struct {
UserID pgtype.UUID
Kind string
Payload []byte
CoalesceKey *string
EmailWanted bool
SumCount bool
}
// One unread row per (user, coalesce_key). A new event while that row is
// unread updates it in place and moves it back to the top of the inbox.
//
// sum_count: the payload's `count` adds to the unread row's count rather than
// replacing it. For kinds whose event is "N more happened" (tracks marked
// missing). Kinds whose event states the whole current total (pending
// duplicate groups) pass false and the payload simply replaces.
//
// emailed_at is cleared so the newer state goes out in the next batch; an
// emailed-but-unread row that keeps growing is news the user hasn't seen.
// Unless email_wanted is false, as in InsertNotification.
func (q *Queries) UpsertCoalescedNotification(ctx context.Context, arg UpsertCoalescedNotificationParams) (pgtype.UUID, error) {
row := q.db.QueryRow(ctx, upsertCoalescedNotification,
arg.UserID,
arg.Kind,
arg.Payload,
arg.CoalesceKey,
arg.EmailWanted,
arg.SumCount,
)
var id pgtype.UUID
err := row.Scan(&id)
return id, err
}
const upsertNotificationEmailState = `-- name: UpsertNotificationEmailState :exec
INSERT INTO user_notification_email_state
(user_id, email_group, batch_opened_at, last_sent_at, failures, retry_after)
VALUES ($1, $2, $3,
$4, $5, $6)
ON CONFLICT (user_id, email_group) DO UPDATE SET
batch_opened_at = EXCLUDED.batch_opened_at,
last_sent_at = EXCLUDED.last_sent_at,
failures = EXCLUDED.failures,
retry_after = EXCLUDED.retry_after
`
type UpsertNotificationEmailStateParams struct {
UserID pgtype.UUID
EmailGroup string
BatchOpenedAt pgtype.Timestamptz
LastSentAt pgtype.Timestamptz
Failures int32
RetryAfter pgtype.Timestamptz
}
func (q *Queries) UpsertNotificationEmailState(ctx context.Context, arg UpsertNotificationEmailStateParams) error {
_, err := q.db.Exec(ctx, upsertNotificationEmailState,
arg.UserID,
arg.EmailGroup,
arg.BatchOpenedAt,
arg.LastSentAt,
arg.Failures,
arg.RetryAfter,
)
return err
}
const upsertNotificationPref = `-- name: UpsertNotificationPref :exec
INSERT INTO user_notification_prefs (user_id, kind, inbox, phone, email)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, kind) DO UPDATE SET
inbox = EXCLUDED.inbox,
phone = EXCLUDED.phone,
email = EXCLUDED.email,
updated_at = now()
`
type UpsertNotificationPrefParams struct {
UserID pgtype.UUID
Kind string
Inbox bool
Phone bool
Email bool
}
func (q *Queries) UpsertNotificationPref(ctx context.Context, arg UpsertNotificationPrefParams) error {
_, err := q.db.Exec(ctx, upsertNotificationPref,
arg.UserID,
arg.Kind,
arg.Inbox,
arg.Phone,
arg.Email,
)
return err
}
+11
View File
@@ -11,6 +11,17 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const countUnresolvedPlaybackErrors = `-- name: CountUnresolvedPlaybackErrors :one
SELECT count(*)::bigint FROM playback_errors WHERE resolved_at IS NULL
`
func (q *Queries) CountUnresolvedPlaybackErrors(ctx context.Context) (int64, error) {
row := q.db.QueryRow(ctx, countUnresolvedPlaybackErrors)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}
const insertPlaybackError = `-- name: InsertPlaybackError :one
INSERT INTO playback_errors (track_id, user_id, client_id, kind, detail)
VALUES ($1, $2, $3, $4, $5)
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS user_notification_prefs;
DROP TABLE IF EXISTS user_notifications;
@@ -0,0 +1,74 @@
-- M489: a persistent, per-user notifications inbox (#726).
--
-- The event bus is fire-and-forget: a client that is not connected when a
-- request completes, or when the scanner marks tracks missing, never hears of
-- it. A row here is the durable record; the bus only nudges open clients to
-- come and read it.
--
-- `kind` is CHECK-gated (rule 36). A new kind adds its value here, in the
-- same migration as the code that produces it. The list is mirrored in
-- internal/notifications/kinds.go, and both CHECKs below must agree with it.
CREATE TABLE user_notifications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind text NOT NULL CHECK (kind IN (
'request_approved',
'request_rejected',
'request_completed',
'request_pending',
'quarantine_flagged',
'scan_failed',
'tracks_missing',
'duplicates_found',
'playback_errors'
)),
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
read_at timestamptz,
-- Burst-prone kinds share one key per user. While a row with that key is
-- unread, a new event updates it in place rather than adding another, so
-- fourteen missing tracks are one notification with a count.
coalesce_key text,
-- Stamped once the row has gone out in an email (or been judged not to
-- need one), so the digest never sends the same item twice.
emailed_at timestamptz
);
-- The inbox list, newest first.
CREATE INDEX user_notifications_user_created_idx
ON user_notifications (user_id, created_at DESC);
-- The unread badge, and the digest's selection of unread rows.
CREATE INDEX user_notifications_unread_idx
ON user_notifications (user_id, created_at DESC)
WHERE read_at IS NULL;
-- At most one unread row per coalesce key per user. The upsert in
-- notifications.sql targets this index.
CREATE UNIQUE INDEX user_notifications_coalesce_idx
ON user_notifications (user_id, coalesce_key)
WHERE read_at IS NULL AND coalesce_key IS NOT NULL;
-- Per user, per kind: which channels a kind reaches. A missing row means the
-- kind's defaults (internal/notifications/kinds.go), so nothing is seeded and
-- a new kind needs no backfill.
CREATE TABLE user_notification_prefs (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind text NOT NULL CHECK (kind IN (
'request_approved',
'request_rejected',
'request_completed',
'request_pending',
'quarantine_flagged',
'scan_failed',
'tracks_missing',
'duplicates_found',
'playback_errors'
)),
inbox boolean NOT NULL,
phone boolean NOT NULL,
email boolean NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, kind)
);
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS user_notifications_email_pending_idx;
DROP TABLE IF EXISTS user_notification_email_state;
DROP TABLE IF EXISTS notification_email_settings;
@@ -0,0 +1,54 @@
-- M489 #5346: notifications by email, grouped. Nothing is emailed per event.
--
-- New music (request_completed) goes out as at most one summary a day, at a set
-- hour in each user's own timezone. Everything else is batched: a batch opens
-- at the first un-emailed item and one email goes out a window later, holding
-- whatever accumulated. internal/notifications/digest.go is the one sender.
-- The two knobs, in admin Settings (rule 25). Singleton in the style of
-- fingerprint_settings (0061).
CREATE TABLE notification_email_settings (
id boolean PRIMARY KEY DEFAULT true,
-- The local hour (0-23, in each user's timezone) the daily new-music
-- summary goes out.
summary_hour integer NOT NULL DEFAULT 9,
-- How long a batch stays open after its first item before it is sent.
batch_window_minutes integer NOT NULL DEFAULT 60,
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT notification_email_settings_singleton CHECK (id = true),
CONSTRAINT notification_email_settings_hour_range
CHECK (summary_hour >= 0 AND summary_hour <= 23),
CONSTRAINT notification_email_settings_window_range
CHECK (batch_window_minutes >= 15 AND batch_window_minutes <= 1440)
);
INSERT INTO notification_email_settings (id) VALUES (true) ON CONFLICT (id) DO NOTHING;
-- Per user, per email group: where the digest stands. A missing row is a user
-- who has never had a batch open or a summary sent.
CREATE TABLE user_notification_email_state (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
email_group text NOT NULL CHECK (email_group IN ('batch', 'summary')),
-- When the open batch started. Held here rather than read off the items,
-- because a coalesced item moves its created_at forward on every update
-- and would otherwise keep a batch from ever coming due.
batch_opened_at timestamptz,
-- The last email of this group the mailer accepted. A summary is due once
-- per local day, after the summary hour, if this is before it.
last_sent_at timestamptz,
-- A failed send backs off: nothing is tried for this group before
-- retry_after, and the gap doubles with each failure in a row.
failures integer NOT NULL DEFAULT 0,
retry_after timestamptz,
PRIMARY KEY (user_id, email_group)
);
-- The digest's selection: unread rows not yet emailed.
CREATE INDEX user_notifications_email_pending_idx
ON user_notifications (user_id, created_at)
WHERE read_at IS NULL AND emailed_at IS NULL;
-- Rows written before this migration were never judged for email. Treat them
-- as already handled, so the first digest after the upgrade doesn't send a
-- backlog nobody asked for.
UPDATE user_notifications SET emailed_at = now() WHERE emailed_at IS NULL;
+49 -24
View File
@@ -115,28 +115,53 @@ SELECT t.id, t.album_id, t.artist_id
-- [;,]). Same exclusion filters as the other buckets. Returns nothing
-- when the user has no taste tags yet (cold start), so the caller
-- redistributes its slots to the other buckets. Stamped 'taste_unheard'.
-- $1 = user_id, $2 = date string for md5 tiebreak ordering.
SELECT t.id, t.album_id, t.artist_id
FROM tracks t
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
JOIN taste_profile_tags nt ON nt.user_id = $1 AND trim(g_split.g) = nt.tag
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
AND nt.weight > 0
AND trim(g_split.g) <> ''
AND NOT EXISTS (
SELECT 1 FROM play_events pe
WHERE pe.user_id = $1
AND pe.track_id = t.id
AND pe.was_skipped = false
)
AND NOT EXISTS (
SELECT 1 FROM general_likes gl
WHERE gl.user_id = $1 AND gl.track_id = t.id
)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
GROUP BY t.id, t.album_id, t.artist_id
ORDER BY SUM(nt.weight) DESC, md5(t.id::text || $2::text)
--
-- The per-album and per-artist caps apply BEFORE the LIMIT (#5356). Summed
-- weight rewards a track for carrying many of the user's tags, so a few
-- artists whose every track is tagged with the whole profile take every row
-- of a plain LIMIT; the caller's caps then left 6 of 120 on the deploy, and
-- the best-performing arm handed its slots to the others. Ranking within
-- album, then within artist over what the album cap kept, is the same walk
-- capByAlbumAndArtist makes, so the caller's caps keep everything here.
WITH scored AS (
SELECT t.id, t.album_id, t.artist_id,
SUM(nt.weight) AS weight,
md5(t.id::text || sqlc.arg(date_seed)::text) AS tiebreak
FROM tracks t
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
JOIN taste_profile_tags nt ON nt.user_id = sqlc.arg(user_id) AND trim(g_split.g) = nt.tag
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
AND nt.weight > 0
AND trim(g_split.g) <> ''
AND NOT EXISTS (
SELECT 1 FROM play_events pe
WHERE pe.user_id = sqlc.arg(user_id)
AND pe.track_id = t.id
AND pe.was_skipped = false
)
AND NOT EXISTS (
SELECT 1 FROM general_likes gl
WHERE gl.user_id = sqlc.arg(user_id) AND gl.track_id = t.id
)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id
)
GROUP BY t.id, t.album_id, t.artist_id
),
album_capped AS (
SELECT s.*,
row_number() OVER (PARTITION BY s.album_id ORDER BY s.weight DESC, s.tiebreak) AS album_rank
FROM scored s
),
artist_capped AS (
SELECT a.*,
row_number() OVER (PARTITION BY a.artist_id ORDER BY a.weight DESC, a.tiebreak) AS artist_rank
FROM album_capped a
WHERE a.album_rank <= sqlc.arg(max_per_album)::int
)
SELECT c.id, c.album_id, c.artist_id
FROM artist_capped c
WHERE c.artist_rank <= sqlc.arg(max_per_artist)::int
ORDER BY c.weight DESC, c.tiebreak
LIMIT 120;
+10
View File
@@ -154,3 +154,13 @@ SELECT p.id AS group_id,
UPDATE duplicate_groups
SET status = 'dismissed', resolved_at = now()
WHERE id = sqlc.arg(id) AND status = 'pending';
-- name: CountDuplicateGroupsDetectedSince :one
-- Pending proposals first made at or after `since`: what a sweep that started
-- then found for the first time. A refreshed proposal keeps its detected_at,
-- so a sweep that only re-finds known groups counts none (M489: admins are
-- told about new duplicates, not reminded of the same ones every sweep).
SELECT count(*)::bigint
FROM duplicate_groups
WHERE status = 'pending'
AND detected_at >= sqlc.arg(since);
+160
View File
@@ -0,0 +1,160 @@
-- M489 notifications inbox (#726). Rows are written only through
-- internal/notifications.Notifier, which decides recipients and honours each
-- recipient's inbox preference before it reaches these.
-- name: InsertNotification :one
-- email_wanted is the recipient's email channel for this kind as of now. A
-- row nobody wants emailed is stamped at once, so the digest never has to
-- judge it and a later "email on" doesn't send an old backlog.
INSERT INTO user_notifications (user_id, kind, payload, emailed_at)
VALUES (sqlc.arg(user_id), sqlc.arg(kind), sqlc.arg(payload),
CASE WHEN sqlc.arg(email_wanted)::boolean THEN NULL ELSE now() END)
RETURNING id;
-- name: UpsertCoalescedNotification :one
-- One unread row per (user, coalesce_key). A new event while that row is
-- unread updates it in place and moves it back to the top of the inbox.
--
-- sum_count: the payload's `count` adds to the unread row's count rather than
-- replacing it. For kinds whose event is "N more happened" (tracks marked
-- missing). Kinds whose event states the whole current total (pending
-- duplicate groups) pass false and the payload simply replaces.
--
-- emailed_at is cleared so the newer state goes out in the next batch; an
-- emailed-but-unread row that keeps growing is news the user hasn't seen.
-- Unless email_wanted is false, as in InsertNotification.
INSERT INTO user_notifications (user_id, kind, payload, coalesce_key, emailed_at)
VALUES (sqlc.arg(user_id), sqlc.arg(kind), sqlc.arg(payload), sqlc.arg(coalesce_key),
CASE WHEN sqlc.arg(email_wanted)::boolean THEN NULL ELSE now() END)
ON CONFLICT (user_id, coalesce_key) WHERE read_at IS NULL AND coalesce_key IS NOT NULL
DO UPDATE SET
payload = CASE
WHEN sqlc.arg(sum_count)::boolean THEN
EXCLUDED.payload || jsonb_build_object('count',
COALESCE((user_notifications.payload->>'count')::bigint, 0)
+ COALESCE((EXCLUDED.payload->>'count')::bigint, 0))
ELSE EXCLUDED.payload
END,
created_at = now(),
emailed_at = EXCLUDED.emailed_at
RETURNING id;
-- name: ListNotifications :many
-- Newest first, keyset-paged on (created_at, id). Pass both cursor halves
-- from the last row of the previous page, or neither for the first page.
SELECT id, kind, payload, created_at, read_at
FROM user_notifications
WHERE user_id = sqlc.arg(user_id)
AND (sqlc.narg(before_created_at)::timestamptz IS NULL
OR (created_at, id) < (sqlc.narg(before_created_at)::timestamptz, sqlc.narg(before_id)::uuid))
ORDER BY created_at DESC, id DESC
LIMIT sqlc.arg(page_limit);
-- name: CountUnreadNotifications :one
SELECT count(*) FROM user_notifications
WHERE user_id = $1 AND read_at IS NULL;
-- name: MarkNotificationRead :execrows
-- Scoped to the owner: another user's id matches no row. Marking an already
-- read row keeps its original read_at and still matches, so a repeat is not
-- mistaken for "not yours".
UPDATE user_notifications
SET read_at = COALESCE(read_at, now())
WHERE id = sqlc.arg(id) AND user_id = sqlc.arg(user_id);
-- name: MarkAllNotificationsRead :execrows
-- up_to, when set, limits it to what existed when the user asked: a "mark
-- all read" queued offline and replayed later must not mark notices that
-- arrived in between, which the user never saw. A coalesced row updated
-- since then carries a newer created_at, so it stays unread too.
UPDATE user_notifications
SET read_at = now()
WHERE user_id = sqlc.arg(user_id)
AND read_at IS NULL
AND (sqlc.narg(up_to)::timestamptz IS NULL OR created_at <= sqlc.narg(up_to));
-- name: TrimNotifications :execrows
-- Retention: read rows go after read_cutoff, and anything at all after
-- any_cutoff, so an inbox nobody opens doesn't grow without bound either.
DELETE FROM user_notifications
WHERE (read_at IS NOT NULL AND read_at < sqlc.arg(read_cutoff))
OR created_at < sqlc.arg(any_cutoff);
-- name: ListAdminUserIDs :many
SELECT id FROM users WHERE is_admin = true ORDER BY created_at, id;
-- name: ListNotificationPrefsForUser :many
SELECT kind, inbox, phone, email
FROM user_notification_prefs
WHERE user_id = $1;
-- name: ListNotificationPrefsForKind :many
-- The stored prefs of one kind for a set of recipients. A recipient with no
-- row has the kind's defaults.
SELECT user_id, inbox, phone, email
FROM user_notification_prefs
WHERE kind = sqlc.arg(kind) AND user_id = ANY(sqlc.arg(user_ids)::uuid[]);
-- name: UpsertNotificationPref :exec
INSERT INTO user_notification_prefs (user_id, kind, inbox, phone, email)
VALUES (sqlc.arg(user_id), sqlc.arg(kind), sqlc.arg(inbox), sqlc.arg(phone), sqlc.arg(email))
ON CONFLICT (user_id, kind) DO UPDATE SET
inbox = EXCLUDED.inbox,
phone = EXCLUDED.phone,
email = EXCLUDED.email,
updated_at = now();
-- Email digest (#5346) ------------------------------------------------------
-- name: ListEmailPendingNotifications :many
-- Every unread, un-emailed row of a user who has an address, oldest first.
-- Read rows are never selected: the user has seen them, so they are not news.
-- read_as_of is the database's clock at the read, handed back to
-- MarkNotificationsEmailed.
SELECT n.id, n.user_id, n.kind, n.payload, n.created_at,
u.email::text AS email, u.username, u.display_name, u.timezone,
now()::timestamptz AS read_as_of
FROM user_notifications n
JOIN users u ON u.id = n.user_id
WHERE n.read_at IS NULL
AND n.emailed_at IS NULL
AND u.email IS NOT NULL AND u.email <> ''
ORDER BY n.user_id, n.created_at, n.id;
-- name: MarkNotificationsEmailed :execrows
-- Stamps the rows an email carried, or that were judged not to need one.
-- read_as_of is from ListEmailPendingNotifications: a coalesced row updated
-- since that read carries a later created_at, holds newer news, and stays
-- pending for the next email.
UPDATE user_notifications
SET emailed_at = now()
WHERE id = ANY(sqlc.arg(ids)::uuid[])
AND created_at <= sqlc.arg(read_as_of)::timestamptz
AND emailed_at IS NULL;
-- name: ListNotificationEmailState :many
SELECT user_id, email_group, batch_opened_at, last_sent_at, failures, retry_after
FROM user_notification_email_state;
-- name: UpsertNotificationEmailState :exec
INSERT INTO user_notification_email_state
(user_id, email_group, batch_opened_at, last_sent_at, failures, retry_after)
VALUES (sqlc.arg(user_id), sqlc.arg(email_group), sqlc.narg(batch_opened_at),
sqlc.narg(last_sent_at), sqlc.arg(failures), sqlc.narg(retry_after))
ON CONFLICT (user_id, email_group) DO UPDATE SET
batch_opened_at = EXCLUDED.batch_opened_at,
last_sent_at = EXCLUDED.last_sent_at,
failures = EXCLUDED.failures,
retry_after = EXCLUDED.retry_after;
-- name: GetNotificationEmailSettings :one
SELECT * FROM notification_email_settings WHERE id = true;
-- name: UpdateNotificationEmailSettings :one
-- Migration 0074's CHECKs are the backstop behind the service's validation.
UPDATE notification_email_settings
SET summary_hour = sqlc.arg(summary_hour),
batch_window_minutes = sqlc.arg(batch_window_minutes),
updated_at = now()
WHERE id = true
RETURNING *;
+3
View File
@@ -61,3 +61,6 @@ UPDATE playback_errors
resolved_by = $2,
resolution = $3
WHERE track_id = $1 AND resolved_at IS NULL;
-- name: CountUnresolvedPlaybackErrors :one
SELECT count(*)::bigint FROM playback_errors WHERE resolved_at IS NULL;
+12
View File
@@ -97,6 +97,11 @@ var dataTables = []string{
"track_loudness", // M464
"album_loudness", // M464
"user_normalization_prefs", // M464
// M489. Both cascade from users, but the operator's own admin row is
// never deleted, so rows a test wrote for it would otherwise survive.
"user_notifications",
"user_notification_prefs",
"user_notification_email_state", // #5346
"tracks",
"albums",
"artists",
@@ -154,4 +159,11 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) {
); err != nil {
t.Fatalf("dbtest.ResetDB reset loudness settings: %v", err)
}
// Notification email settings (M489 #5346), reset the same way.
if _, err := pool.Exec(ctx, `
UPDATE notification_email_settings
SET summary_hour = DEFAULT, batch_window_minutes = DEFAULT, updated_at = DEFAULT`,
); err != nil {
t.Fatalf("dbtest.ResetDB reset notification email settings: %v", err)
}
}
+26
View File
@@ -14,6 +14,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
@@ -101,12 +102,37 @@ func runDuplicateSweep(
}
}
if runErr == nil {
notifyNewDuplicates(finishCtx, q, sweep.StartedAt, logger)
}
logger.Info("duplicate sweep complete",
"candidates", res.Candidates, "groups", res.Groups, "proposed", res.Proposed,
"suppressed", res.Suppressed, "retired", res.Retired, "oversize", res.Oversize, "err", runErr)
return res, runErr
}
// notifyNewDuplicates tells admins when a sweep proposed a group it had not
// proposed before (M489), counting every proposal awaiting review. A sweep
// that only re-finds known groups says nothing, so reading the notice once
// is enough until something new turns up.
func notifyNewDuplicates(ctx context.Context, q *dbq.Queries, sweepStarted pgtype.Timestamptz, logger *slog.Logger) {
fresh, err := q.CountDuplicateGroupsDetectedSince(ctx, sweepStarted)
if err != nil {
logger.Warn("duplicate sweep: counting new proposals failed", "err", err)
return
}
if fresh == 0 {
return
}
pending, err := q.CountPendingDuplicateGroups(ctx)
if err != nil {
logger.Warn("duplicate sweep: counting pending proposals failed", "err", err)
return
}
notifyAdmins(ctx, notifications.KindDuplicatesFound, notifications.Payload{Count: pending})
}
func sweepDuplicates(
ctx context.Context, q *dbq.Queries, sweepID pgtype.UUID, cfg FingerprintSettings, pageSize int32,
) (DuplicateSweepResult, error) {
+44
View File
@@ -0,0 +1,44 @@
package library
import (
"context"
"sync"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
)
// Package-level notifier for library-health notices to admins (M489): a
// scan that failed, tracks gone missing, new duplicates. Set once at startup
// beside SetEventBus, for the same reason the bus is package-level. Nil, as in
// tests that never set it, means nobody is told.
var (
notifierMu sync.RWMutex
notifier *notifications.Notifier
)
// SetNotifier wires the notifications inbox into the library package.
func SetNotifier(n *notifications.Notifier) {
notifierMu.Lock()
defer notifierMu.Unlock()
notifier = n
}
// notifyAdmins tells every admin about a library-health event. These kinds
// coalesce, so a burst is one unread notice with a running count.
func notifyAdmins(ctx context.Context, kind notifications.Kind, p notifications.Payload) {
notifierMu.RLock()
n := notifier
notifierMu.RUnlock()
n.NotifyLogged(ctx, kind, notifications.ToAdmins(pgtype.UUID{}), p.Map())
}
// notifyScanFinished tells admins a scan run ended in error. A scan cut short
// by shutdown is not a failure anyone needs telling about.
func notifyScanFinished(ctx context.Context, errMsg string) {
if errMsg == "" || ctx.Err() != nil {
return
}
notifyAdmins(ctx, notifications.KindScanFailed, notifications.Payload{Count: 1, Detail: errMsg})
}
+180
View File
@@ -0,0 +1,180 @@
package library
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"path/filepath"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
)
// notifyingAdmin wires a real notifier for the test and returns an admin whose
// inbox the test reads.
func notifyingAdmin(t *testing.T, pool *pgxpool.Pool) dbq.User {
t.Helper()
admin, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "libnotify", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: true,
})
if err != nil {
t.Fatalf("admin: %v", err)
}
SetNotifier(notifications.New(pool, nil, nil))
t.Cleanup(func() { SetNotifier(nil) })
return admin
}
type notice struct {
kind string
count int64
body string
}
func unreadNotices(t *testing.T, pool *pgxpool.Pool, user dbq.User) []notice {
t.Helper()
rows, err := dbq.New(pool).ListNotifications(context.Background(), dbq.ListNotificationsParams{
UserID: user.ID, PageLimit: 50,
})
if err != nil {
t.Fatalf("list notifications: %v", err)
}
var out []notice
for _, r := range rows {
if r.ReadAt.Valid {
continue
}
var p notifications.Payload
if err := json.Unmarshal(r.Payload, &p); err != nil {
t.Fatalf("payload: %v", err)
}
out = append(out, notice{kind: r.Kind, count: p.Count, body: p.Detail})
}
return out
}
func TestNotifyScanFinished_FailuresCoalesceAndShutdownIsSilent(t *testing.T) {
pool := newPool(t)
admin := notifyingAdmin(t, pool)
ctx := context.Background()
notifyScanFinished(ctx, "")
if got := unreadNotices(t, pool, admin); len(got) != 0 {
t.Fatalf("a clean scan notified: %+v", got)
}
cancelled, cancel := context.WithCancel(ctx)
cancel()
notifyScanFinished(cancelled, "library: context canceled")
if got := unreadNotices(t, pool, admin); len(got) != 0 {
t.Fatalf("a scan cut short by shutdown notified: %+v", got)
}
notifyScanFinished(ctx, "library: root /music missing")
notifyScanFinished(ctx, "library: root /music still missing")
got := unreadNotices(t, pool, admin)
if len(got) != 1 || got[0].kind != string(notifications.KindScanFailed) || got[0].count != 2 ||
got[0].body != "library: root /music still missing" {
t.Fatalf("notices = %+v, want one scan_failed counting 2 with the latest error", got)
}
}
func TestReconcileMissing_NotifiesAdminsWithARunningCount(t *testing.T) {
pool := newPool(t)
admin := notifyingAdmin(t, pool)
s := testScanner(t, populatedRoot(t))
// Two scans, each losing 2 of 10 tracks (under the mark cap).
for pass := 0; pass < 2; pass++ {
rows := make([]dbq.ListTrackPathsForReconcileRow, 0, 10)
seen := map[string]struct{}{}
for i := 0; i < 10; i++ {
p := fmt.Sprintf("/music/pass-%d-%02d.mp3", pass, i)
rows = append(rows, row(byte(pass*10+i), p, false))
if i >= 2 {
seen[p] = struct{}{}
}
}
var stats Stats
if err := s.reconcileMissing(context.Background(), &fakeReconciler{rows: rows}, seen, &stats); err != nil {
t.Fatalf("reconcile pass %d: %v", pass, err)
}
}
got := unreadNotices(t, pool, admin)
if len(got) != 1 || got[0].kind != string(notifications.KindTracksMissing) || got[0].count != 4 {
t.Fatalf("notices = %+v, want one tracks_missing counting 4", got)
}
}
func TestDuplicateSweep_NotifiesOnlyWhenSomethingNewIsProposed(t *testing.T) {
pool := newPool(t)
admin := notifyingAdmin(t, pool)
ctx := context.Background()
q := dbq.New(pool)
dir := t.TempDir()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
_, album, artist := seedTrack(t, pool, filepath.Join(dir, "seed.mp3"))
// Same audio-stream hash: an exact pair, whatever the prints say.
pair := func(name string, b byte, seed uint64) {
t.Helper()
for i := 1; i <= 2; i++ {
tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: name, AlbumID: album.ID, ArtistID: artist.ID, DurationMs: 200000,
FilePath: filepath.Join(dir, fmt.Sprintf("%s-%d.mp3", name, i)), FileSize: 100, FileFormat: "mp3",
})
if err != nil {
t.Fatalf("track %s: %v", name, err)
}
if err := q.UpsertTrackFingerprint(ctx, dbq.UpsertTrackFingerprintParams{
TrackID: tr.ID, AudioStreamSha256: bytes.Repeat([]byte{b}, 32),
Chromaprint: randomPrint(seed+uint64(i), printLen), FingerprintVersion: fingerprintVersion,
ChromaprintLengthSec: defaultChromaprintLengthSec,
}); err != nil {
t.Fatalf("fingerprint %s: %v", name, err)
}
}
}
sweep := func() {
t.Helper()
if _, err := runDuplicateSweep(ctx, pool, logger, DefaultFingerprintSettings, duplicateCandidatePage); err != nil {
t.Fatalf("sweep: %v", err)
}
}
markAllRead := func() {
t.Helper()
if _, err := q.MarkAllNotificationsRead(ctx, dbq.MarkAllNotificationsReadParams{UserID: admin.ID}); err != nil {
t.Fatalf("mark read: %v", err)
}
}
pair("first", 1, 100)
sweep()
got := unreadNotices(t, pool, admin)
if len(got) != 1 || got[0].kind != string(notifications.KindDuplicatesFound) || got[0].count != 1 {
t.Fatalf("after the first sweep notices = %+v, want one duplicates_found counting 1", got)
}
// Read, then swept again with nothing new: no reminder.
markAllRead()
sweep()
if got := unreadNotices(t, pool, admin); len(got) != 0 {
t.Fatalf("a sweep that found nothing new notified: %+v", got)
}
// A new pair is news, and the count is everything awaiting review.
pair("second", 2, 200)
sweep()
got = unreadNotices(t, pool, admin)
if len(got) != 1 || got[0].count != 2 {
t.Fatalf("after a new pair notices = %+v, want one counting 2", got)
}
}
+4
View File
@@ -9,6 +9,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
@@ -128,6 +129,9 @@ func (s *Scanner) reconcileMissing(
// this line.
s.logger.Warn("library scan: tracks marked missing (files not found)",
"count", n, "library_total", len(rows))
if n > 0 {
notifyAdmins(ctx, notifications.KindTracksMissing, notifications.Payload{Count: n})
}
return nil
}
+1
View File
@@ -245,6 +245,7 @@ func RunScan(
}
logger.Info("scan run complete", "id", row.ID, "error", errMsg)
notifyScanFinished(ctx, errMsg)
publishScanEvent("scan.run_finished", row.ID, map[string]any{
"error_message": errMsg,
})
+27 -6
View File
@@ -15,6 +15,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
@@ -30,6 +31,7 @@ type Reconciler struct {
clientFn func() *lidarr.Client
logger *slog.Logger
bus *eventbus.Bus
notifier *notifications.Notifier // nil: no inbox notifications
tick time.Duration
batch int32
// releaseGroup names the MusicBrainz release group of a release id, to
@@ -61,9 +63,28 @@ func NewReconciler(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func(
}
}
// publishCompleted broadcasts a request.status_changed event scoped to
// the original requester. No-op when bus is nil.
func (r *Reconciler) publishCompleted(row dbq.LidarrRequest) {
// SetNotifier makes completions land in the requester's notifications inbox
// (M489). Without one, completions are only broadcast on the bus.
func (r *Reconciler) SetNotifier(n *notifications.Notifier) { r.notifier = n }
// publishCompleted tells the requester their request arrived: a
// request.status_changed event for open screens, and a notification that
// outlives the connection.
func (r *Reconciler) publishCompleted(ctx context.Context, row dbq.LidarrRequest) {
p := notifications.Payload{
RequestID: formatUUIDForBus(row.ID),
RequestKind: string(row.Kind),
Name: DisplayName(row),
Artist: row.ArtistName,
Title: requestedTitle(row),
}
if row.MatchedAlbumID.Valid {
p.AlbumID = formatUUIDForBus(row.MatchedAlbumID)
} else if row.MatchedArtistID.Valid {
p.ArtistID = formatUUIDForBus(row.MatchedArtistID)
}
r.notifier.NotifyLogged(ctx, notifications.KindRequestCompleted, notifications.ToUser(row.UserID), p.Map())
if r.bus == nil {
return
}
@@ -308,7 +329,7 @@ func (r *Reconciler) reconcileArtist(ctx context.Context, q *dbq.Queries, row db
if err != nil {
return err
}
r.publishCompleted(completed)
r.publishCompleted(ctx, completed)
return nil
}
@@ -333,7 +354,7 @@ func (r *Reconciler) reconcileAlbum(ctx context.Context, q *dbq.Queries, row dbq
if err != nil {
return err
}
r.publishCompleted(completed)
r.publishCompleted(ctx, completed)
return nil
}
@@ -374,7 +395,7 @@ func (r *Reconciler) reconcileTrack(ctx context.Context, q *dbq.Queries, row dbq
if err != nil {
return err
}
r.publishCompleted(completed)
r.publishCompleted(ctx, completed)
return nil
}
@@ -0,0 +1,52 @@
package lidarrrequests
import (
"context"
"encoding/json"
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
)
// M489: an album request that arrives lands in the requester's inbox once,
// linked to the album it matched.
func TestReconciler_CompletionNotifiesRequester(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
artist := seedArtist(t, q, "Notify Artist", "notify-artist-mbid")
album := seedAlbum(t, q, artist.ID, "Notify Album", "notify-album-mbid")
_ = seedTrack(t, q, album.ID, artist.ID, "Notify Track", "/music/notify/01.flac")
seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "album", LidarrArtistMBID: "notify-artist-mbid", LidarrAlbumMBID: "notify-album-mbid",
ArtistName: "Notify Artist", AlbumTitle: "Notify Album",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
rec.SetNotifier(notifications.New(pool, nil, nil))
for i := 0; i < 2; i++ { // the second tick finds nothing left to complete
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
}
rows, err := q.ListNotifications(ctx, dbq.ListNotificationsParams{UserID: user, PageLimit: 10})
if err != nil {
t.Fatalf("list notifications: %v", err)
}
if len(rows) != 1 || rows[0].Kind != string(notifications.KindRequestCompleted) {
t.Fatalf("inbox = %+v, want one request_completed", rows)
}
var p notifications.Payload
if err := json.Unmarshal(rows[0].Payload, &p); err != nil {
t.Fatal(err)
}
if p.Name != "Notify Artist – Notify Album" || p.AlbumID != formatUUIDForBus(album.ID) || p.ArtistID != "" {
t.Errorf("payload = %+v", p)
}
}
+41 -5
View File
@@ -80,8 +80,16 @@ func NewService(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func() *
// Create validates the kind→required-fields invariant and inserts a
// pending row.
func (s *Service) Create(ctx context.Context, userID pgtype.UUID, p CreateParams) (dbq.LidarrRequest, error) {
row, _, err := s.CreateTracked(ctx, userID, p)
return row, err
}
// CreateTracked is Create, also reporting whether a row was inserted. A
// request that dedupes into one already in flight is not new, so callers
// that announce new requests (M489) say nothing for it.
func (s *Service) CreateTracked(ctx context.Context, userID pgtype.UUID, p CreateParams) (dbq.LidarrRequest, bool, error) {
if err := validateKindFields(p); err != nil {
return dbq.LidarrRequest{}, err
return dbq.LidarrRequest{}, false, err
}
q := dbq.New(s.pool)
// Dedup: if a non-terminal request for this MBID already exists,
@@ -97,9 +105,9 @@ func (s *Service) Create(ctx context.Context, userID pgtype.UUID, p CreateParams
dedupMBID = p.LidarrTrackMBID
}
if existing, derr := q.GetNonTerminalRequestForMBID(ctx, dedupMBID); derr == nil {
return existing, nil
return existing, false, nil
} else if !errors.Is(derr, pgx.ErrNoRows) {
return dbq.LidarrRequest{}, fmt.Errorf("create: dedup check: %w", derr)
return dbq.LidarrRequest{}, false, fmt.Errorf("create: dedup check: %w", derr)
}
row, err := q.CreateLidarrRequest(ctx, dbq.CreateLidarrRequestParams{
UserID: userID,
@@ -112,9 +120,37 @@ func (s *Service) Create(ctx context.Context, userID pgtype.UUID, p CreateParams
TrackTitle: strPtr(p.TrackTitle),
})
if err != nil {
return dbq.LidarrRequest{}, fmt.Errorf("create: %w", err)
return dbq.LidarrRequest{}, false, fmt.Errorf("create: %w", err)
}
return row, nil
return row, true, nil
}
// DisplayName is a request as a person would say it: "Moe Shop" for an
// artist, "Moe Shop – WWW" for an album or a track.
func DisplayName(row dbq.LidarrRequest) string {
switch row.Kind {
case dbq.LidarrRequestKindTrack:
if row.TrackTitle != nil && *row.TrackTitle != "" {
return row.ArtistName + " – " + *row.TrackTitle
}
case dbq.LidarrRequestKindAlbum:
if row.AlbumTitle != nil && *row.AlbumTitle != "" {
return row.ArtistName + " – " + *row.AlbumTitle
}
}
return row.ArtistName
}
// requestedTitle is the album or track a request names, or "" for an
// artist request. DisplayName is the artist and this, joined.
func requestedTitle(row dbq.LidarrRequest) string {
switch {
case row.Kind == dbq.LidarrRequestKindTrack && row.TrackTitle != nil:
return *row.TrackTitle
case row.Kind == dbq.LidarrRequestKindAlbum && row.AlbumTitle != nil:
return *row.AlbumTitle
}
return ""
}
func validateKindFields(p CreateParams) error {
+20 -2
View File
@@ -12,9 +12,11 @@ import (
"errors"
"fmt"
"log/slog"
"mime"
"net"
"net/smtp"
"strconv"
"strings"
"sync"
"time"
@@ -57,7 +59,7 @@ func (s *SMTPSender) Send(ctx context.Context, to, subject, textBody, htmlBody s
if err != nil {
return fmt.Errorf("mailer: load config: %w", err)
}
if !cfg.Enabled || cfg.Host == "" || cfg.FromAddress == "" {
if !Configured(cfg) {
return ErrNotConfigured
}
@@ -86,6 +88,13 @@ func (s *SMTPSender) Send(ctx context.Context, to, subject, textBody, htmlBody s
return nil
}
// Configured reports whether cfg can send at all: enabled, with a host and a
// from address. Send refuses with ErrNotConfigured otherwise, and settings
// screens use it to say why email is unavailable before anyone tries.
func Configured(cfg dbq.SmtpConfig) bool {
return cfg.Enabled && cfg.Host != "" && cfg.FromAddress != ""
}
// sendMail wraps net/smtp's SendMail with optional TLS verification.
// Mostly identical to smtp.SendMail but explicitly handles the
// use_tls flag.
@@ -144,7 +153,7 @@ func composeMessage(cfg dbq.SmtpConfig, to, subject, textBody, htmlBody string)
}
fmt.Fprintf(&buf, "From: %s\r\n", from)
fmt.Fprintf(&buf, "To: %s\r\n", to)
fmt.Fprintf(&buf, "Subject: %s\r\n", subject)
fmt.Fprintf(&buf, "Subject: %s\r\n", encodeSubject(subject))
fmt.Fprintf(&buf, "MIME-Version: 1.0\r\n")
fmt.Fprintf(&buf, "Content-Type: multipart/alternative; boundary=\"%s\"\r\n\r\n", boundary)
@@ -162,6 +171,15 @@ func composeMessage(cfg dbq.SmtpConfig, to, subject, textBody, htmlBody string)
return buf.Bytes()
}
// encodeSubject makes subject safe as a header value. Line breaks are
// replaced, so a name carried into a subject cannot start a header of its
// own, and non-ASCII text ("Moe Shop – WWW") is RFC 2047 encoded, which
// mime.QEncoding leaves alone when there is nothing to encode.
func encodeSubject(subject string) string {
subject = strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ").Replace(subject)
return mime.QEncoding.Encode("utf-8", subject)
}
// SentEmail is the in-memory record FakeSender keeps. Tests assert
// against these.
type SentEmail struct {
+25
View File
@@ -0,0 +1,25 @@
package mailer
import (
"mime"
"strings"
"testing"
)
func TestEncodeSubject(t *testing.T) {
if got := encodeSubject("Minstrel: 2 updates"); got != "Minstrel: 2 updates" {
t.Errorf("plain ASCII changed: %q", got)
}
enc := encodeSubject("Minstrel: Moe Shop – WWW")
if !strings.HasPrefix(enc, "=?utf-8?q?") {
t.Errorf("non-ASCII not RFC 2047 encoded: %q", enc)
}
if dec, err := new(mime.WordDecoder).DecodeHeader(enc); err != nil || dec != "Minstrel: Moe Shop – WWW" {
t.Errorf("round trip = %q, %v", dec, err)
}
if got := encodeSubject("Hi\r\nBcc: everyone@example.com"); strings.ContainsAny(got, "\r\n") {
t.Errorf("a line break survived into the header: %q", got)
}
}
+390
View File
@@ -0,0 +1,390 @@
package notifications
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
)
// The email digest (#5346). Nothing is emailed per event. Operator,
// 2026-10-08: "music and stuff coming in should land as a summary email and
// not everytime same for a approvals the emails should be grouped by a
// reasonable amount of time like an hour or so."
//
// - EmailSummary kinds (new music) go out at most once a day, at the summary
// hour in the user's own timezone, listing everything since the last one.
// - EmailBatch kinds go out a batch window after the first un-emailed item,
// holding whatever accumulated.
// - A row the user has read is never emailed: they have seen it.
const (
// digestInterval is how often the digest looks for due groups. It bounds
// how late an email can be, not how often one is sent.
digestInterval = 5 * time.Minute
// maxEmailAge: an item older than this when the digest first gets to it is
// not news worth an email. It covers a user who adds an address, or an
// SMTP server set up, after weeks of unread notifications.
maxEmailAge = 7 * 24 * time.Hour
// A failed send waits retryBase, doubling with each failure in a row, up
// to retryMax.
retryBase = 5 * time.Minute
retryMax = 6 * time.Hour
)
const (
groupBatch = "batch"
groupSummary = "summary"
)
func (g EmailGroup) key() string {
if g == EmailSummary {
return groupSummary
}
return groupBatch
}
// groupState is where one user's group stands; user_notification_email_state.
type groupState struct {
BatchOpenedAt time.Time
LastSentAt time.Time
Failures int32
RetryAfter time.Time
}
// pendingItem is one unread, un-emailed notification.
type pendingItem struct {
ID pgtype.UUID
Kind Kind
Payload []byte
CreatedAt time.Time
}
// userPlan is what the digest does for one user on one tick.
type userPlan struct {
// Skip is stamped emailed without sending: email is off for its kind now,
// or it is too old to be news.
Skip []pendingItem
Batch []pendingItem
BatchDue bool
// BatchOpenedAt is the open batch's start, kept until it is sent. Zero
// when nothing is pending in the batch group.
BatchOpenedAt time.Time
Summary []pendingItem
SummaryDue bool
}
// planUser decides, from the clock and stored state alone, what goes out for
// one user. items are oldest first. emailOn is the user's effective email
// channel per kind.
func planUser(now time.Time, cfg EmailSettings, loc *time.Location, items []pendingItem,
emailOn map[Kind]bool, batch, summary groupState) userPlan {
var p userPlan
for _, it := range items {
switch {
case !emailOn[it.Kind] || now.Sub(it.CreatedAt) > maxEmailAge:
p.Skip = append(p.Skip, it)
case it.Kind.EmailGroup() == EmailSummary:
p.Summary = append(p.Summary, it)
default:
p.Batch = append(p.Batch, it)
}
}
if len(p.Batch) > 0 {
p.BatchOpenedAt = batch.BatchOpenedAt
if p.BatchOpenedAt.IsZero() {
p.BatchOpenedAt = p.Batch[0].CreatedAt
}
p.BatchDue = !now.Before(batch.RetryAfter) && !now.Before(p.BatchOpenedAt.Add(cfg.BatchWindow()))
}
if len(p.Summary) > 0 {
slot := summarySlot(now, loc, int(cfg.SummaryHour))
p.SummaryDue = !now.Before(summary.RetryAfter) && !now.Before(slot) && summary.LastSentAt.Before(slot)
}
return p
}
// summarySlot is today's summary time in loc: the summary hour on the user's
// local calendar day. Across a DST change the hour keeps its local meaning,
// so the instant moves by the shift. An hour that does not exist that day
// (inside a spring-forward gap) is normalised by time.Date to one that does.
func summarySlot(now time.Time, loc *time.Location, hour int) time.Time {
l := now.In(loc)
return time.Date(l.Year(), l.Month(), l.Day(), hour, 0, 0, 0, loc)
}
// retryDelay is the wait after the failures-th failure in a row.
func retryDelay(failures int32) time.Duration {
d := retryBase
for i := int32(1); i < failures && d < retryMax; i++ {
d *= 2
}
return min(d, retryMax)
}
// Digest sends the grouped emails. One per process, on a ticker.
type Digest struct {
pool *pgxpool.Pool
sender mailer.Sender
logger *slog.Logger
publicURL func(context.Context) string
}
// NewDigest returns a Digest sending through sender. Links in its emails are
// built from the public address in admin Settings, never from a request.
func NewDigest(pool *pgxpool.Pool, sender mailer.Sender, logger *slog.Logger) *Digest {
if logger == nil {
logger = slog.Default()
}
d := &Digest{pool: pool, sender: sender, logger: logger}
d.publicURL = func(ctx context.Context) string {
row, err := dbq.New(pool).GetNetworkSettings(ctx)
if err != nil {
return ""
}
return row.PublicUrl
}
return d
}
// Run blocks until ctx is cancelled. It ticks once at startup, so a process
// that has been down past a batch or a summary hour catches up, then every
// few minutes.
func (d *Digest) Run(ctx context.Context) {
d.Tick(ctx, time.Now())
t := time.NewTicker(digestInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-t.C:
d.Tick(ctx, now)
}
}
}
// Tick sends every group that is due as of now. Failures are logged and
// retried on a later tick; nothing here is fatal.
func (d *Digest) Tick(ctx context.Context, now time.Time) {
q := dbq.New(d.pool)
cfg, err := LoadEmailSettings(ctx, q)
if err != nil {
d.logger.Warn("notification digest: using default settings", "err", err)
}
rows, err := q.ListEmailPendingNotifications(ctx)
if err != nil {
d.logger.Warn("notification digest: list pending", "err", err)
return
}
states, err := d.loadStates(ctx, q)
if err != nil {
d.logger.Warn("notification digest: load state", "err", err)
return
}
base := d.publicURL(ctx)
handled := map[pgtype.UUID]bool{}
for start := 0; start < len(rows); {
end := start + 1
for end < len(rows) && rows[end].UserID == rows[start].UserID {
end++
}
u := rows[start]
handled[u.UserID] = true
d.digestUser(ctx, q, now, cfg, base, rows[start:end], states[u.UserID])
start = end
}
// A user with nothing pending at all whose batch is still open read
// everything before it came due. Close it, so their next item opens a
// fresh batch rather than going out at once.
for userID, st := range states {
if !handled[userID] && !st[groupBatch].BatchOpenedAt.IsZero() {
closed := st[groupBatch]
closed.BatchOpenedAt = time.Time{}
d.saveState(ctx, q, userID, groupBatch, closed)
}
}
}
// digestUser handles one user's pending rows.
func (d *Digest) digestUser(ctx context.Context, q *dbq.Queries, now time.Time, cfg EmailSettings, base string,
rows []dbq.ListEmailPendingNotificationsRow, st map[string]groupState) {
u := rows[0]
emailOn, err := emailChannels(ctx, q, u.UserID)
if err != nil {
d.logger.Warn("notification digest: read prefs", "err", err)
return
}
items := make([]pendingItem, len(rows))
for i, r := range rows {
items[i] = pendingItem{ID: r.ID, Kind: Kind(r.Kind), Payload: r.Payload, CreatedAt: r.CreatedAt.Time}
}
plan := planUser(now, cfg, userLocation(u.Timezone), items, emailOn, st[groupBatch], st[groupSummary])
readAsOf := u.ReadAsOf.Time
if len(plan.Skip) > 0 {
if _, err := q.MarkNotificationsEmailed(ctx, dbq.MarkNotificationsEmailedParams{
Ids: itemIDs(plan.Skip), ReadAsOf: ts(readAsOf),
}); err != nil {
d.logger.Warn("notification digest: stamp skipped", "err", err)
}
}
// The batch's start is recorded before anything is sent, so a failed send
// below keeps it along with its retry. With nothing left in the batch
// group (all read, or skipped), an open batch is closed.
batchSt := st[groupBatch]
if !plan.BatchOpenedAt.Equal(batchSt.BatchOpenedAt) {
batchSt.BatchOpenedAt = plan.BatchOpenedAt
d.saveState(ctx, q, u.UserID, groupBatch, batchSt)
}
to := recipient{email: u.Email, name: displayName(u)}
if plan.SummaryDue {
d.send(ctx, q, now, u.UserID, to, EmailSummary, plan.Summary, st[groupSummary], base, readAsOf)
}
if plan.BatchDue {
d.send(ctx, q, now, u.UserID, to, EmailBatch, plan.Batch, batchSt, base, readAsOf)
}
}
type recipient struct{ email, name string }
// send renders and sends one group's email. Only once the mailer has accepted
// it are its rows stamped and the group's state reset, in one transaction, so
// a failure sends again later and a success never sends twice.
func (d *Digest) send(ctx context.Context, q *dbq.Queries, now time.Time, userID pgtype.UUID, to recipient,
group EmailGroup, items []pendingItem, st groupState, base string, readAsOf time.Time) {
msg, err := renderDigest(group, to.name, items, base)
if err != nil {
d.logger.Error("notification digest: render", "group", group.key(), "err", err)
return
}
err = d.sender.Send(ctx, to.email, msg.Subject, msg.Text, msg.HTML)
if errors.Is(err, mailer.ErrNotConfigured) {
// Not a failure to back off from: nothing can go until SMTP is set
// up, and the rows wait, unread and un-emailed, until it is.
return
}
if err != nil {
st.Failures++
st.RetryAfter = now.Add(retryDelay(st.Failures))
d.logger.Warn("notification digest: send failed",
"group", group.key(), "failures", st.Failures, "retry_after", st.RetryAfter, "err", err)
d.saveState(ctx, q, userID, group.key(), st)
return
}
sent := groupState{LastSentAt: now}
if err := pgx.BeginFunc(ctx, d.pool, func(tx pgx.Tx) error {
tq := dbq.New(tx)
if _, err := tq.MarkNotificationsEmailed(ctx, dbq.MarkNotificationsEmailedParams{
Ids: itemIDs(items), ReadAsOf: ts(readAsOf),
}); err != nil {
return err
}
return tq.UpsertNotificationEmailState(ctx, stateParams(userID, group.key(), sent))
}); err != nil {
// The email is out but its rows are not stamped, so the next due
// group would repeat them. Logged at ERROR: it needs a look.
d.logger.Error("notification digest: sent but not recorded", "group", group.key(), "err", err)
}
}
func (d *Digest) loadStates(ctx context.Context, q *dbq.Queries) (map[pgtype.UUID]map[string]groupState, error) {
rows, err := q.ListNotificationEmailState(ctx)
if err != nil {
return nil, err
}
out := make(map[pgtype.UUID]map[string]groupState, len(rows))
for _, r := range rows {
if out[r.UserID] == nil {
out[r.UserID] = map[string]groupState{}
}
out[r.UserID][r.EmailGroup] = groupState{
BatchOpenedAt: tsTime(r.BatchOpenedAt),
LastSentAt: tsTime(r.LastSentAt),
Failures: r.Failures,
RetryAfter: tsTime(r.RetryAfter),
}
}
return out, nil
}
func (d *Digest) saveState(ctx context.Context, q *dbq.Queries, userID pgtype.UUID, group string, st groupState) {
if err := q.UpsertNotificationEmailState(ctx, stateParams(userID, group, st)); err != nil {
d.logger.Warn("notification digest: save state", "group", group, "err", err)
}
}
// emailChannels is the user's effective email channel for every kind: their
// stored preference, or the kind's default.
func emailChannels(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) (map[Kind]bool, error) {
rows, err := q.ListNotificationPrefsForUser(ctx, userID)
if err != nil {
return nil, fmt.Errorf("prefs: %w", err)
}
out := make(map[Kind]bool, len(order))
for _, k := range order {
out[k] = k.Defaults().Effective().Email
}
for _, r := range rows {
out[Kind(r.Kind)] = Channels{Inbox: r.Inbox, Phone: r.Phone, Email: r.Email}.Effective().Email
}
return out, nil
}
// userLocation is the user's timezone, or UTC when it does not parse.
func userLocation(tz string) *time.Location {
if loc, err := time.LoadLocation(tz); err == nil && tz != "" {
return loc
}
return time.UTC
}
func displayName(u dbq.ListEmailPendingNotificationsRow) string {
if u.DisplayName != nil && *u.DisplayName != "" {
return *u.DisplayName
}
return u.Username
}
func itemIDs(items []pendingItem) []pgtype.UUID {
out := make([]pgtype.UUID, len(items))
for i, it := range items {
out[i] = it.ID
}
return out
}
func stateParams(userID pgtype.UUID, group string, st groupState) dbq.UpsertNotificationEmailStateParams {
return dbq.UpsertNotificationEmailStateParams{
UserID: userID,
EmailGroup: group,
BatchOpenedAt: ts(st.BatchOpenedAt),
LastSentAt: ts(st.LastSentAt),
Failures: st.Failures,
RetryAfter: ts(st.RetryAfter),
}
}
func ts(t time.Time) pgtype.Timestamptz { return pgtype.Timestamptz{Time: t, Valid: !t.IsZero()} }
func tsTime(t pgtype.Timestamptz) time.Time {
if !t.Valid {
return time.Time{}
}
return t.Time
}
+223
View File
@@ -0,0 +1,223 @@
package notifications_test
import (
"context"
"errors"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
)
// emailUser is a user with an address, so the digest can reach them.
func emailUser(t *testing.T, pool *pgxpool.Pool, name string, admin bool) pgtype.UUID {
t.Helper()
id := mkUser(t, dbq.New(pool), name, admin)
_, err := pool.Exec(context.Background(), "UPDATE users SET email = $2 WHERE id = $1", id, name+"@example.com")
require.NoError(t, err)
return id
}
func digestWith(pool *pgxpool.Pool) (*notifications.Digest, *mailer.FakeSender) {
fake := &mailer.FakeSender{}
return notifications.NewDigest(pool, fake, nil), fake
}
func TestDigest_BatchGoesOutOnceAWindowAfterTheFirstItem(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
alice := emailUser(t, pool, "alice", false)
n := notifications.New(pool, nil, nil)
d, fake := digestWith(pool)
start := time.Now()
require.NoError(t, n.Notify(ctx, notifications.KindRequestApproved, notifications.ToUser(alice),
notifications.Payload{Name: "Moe Shop – WWW"}.Map()))
require.NoError(t, n.Notify(ctx, notifications.KindRequestRejected, notifications.ToUser(alice),
notifications.Payload{Name: "Tycho – Awake", Reason: "already have it"}.Map()))
d.Tick(ctx, start.Add(30*time.Minute))
require.Empty(t, fake.Sent, "inside the window nothing goes")
d.Tick(ctx, start.Add(61*time.Minute))
require.Len(t, fake.Sent, 1, "both items, one email")
require.Equal(t, "alice@example.com", fake.Sent[0].To)
require.Equal(t, "Minstrel: 2 updates", fake.Sent[0].Subject)
require.Contains(t, fake.Sent[0].TextBody, "Moe Shop – WWW is on its way.")
require.Contains(t, fake.Sent[0].TextBody, "Tycho – Awake was declined: already have it")
d.Tick(ctx, start.Add(3*time.Hour))
require.Len(t, fake.Sent, 1, "never sent twice")
}
func TestDigest_ReadBeforeTheEmailIsNotEmailed(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
q := dbq.New(pool)
alice := emailUser(t, pool, "alice", false)
n := notifications.New(pool, nil, nil)
d, fake := digestWith(pool)
start := time.Now()
require.NoError(t, n.Notify(ctx, notifications.KindRequestApproved, notifications.ToUser(alice), nil))
_, err := q.MarkAllNotificationsRead(ctx, dbq.MarkAllNotificationsReadParams{UserID: alice})
require.NoError(t, err)
d.Tick(ctx, start.Add(2*time.Hour))
require.Empty(t, fake.Sent, "everything was read: nothing to send")
}
func TestDigest_EmailOffAtNotifyTimeIsNeverSent(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
q := dbq.New(pool)
alice := emailUser(t, pool, "alice", false)
off := false
_, err := notifications.SaveSettings(ctx, q, alice, false, []notifications.SettingChange{
{Kind: notifications.KindRequestApproved, Email: &off},
})
require.NoError(t, err)
n := notifications.New(pool, nil, nil)
d, fake := digestWith(pool)
start := time.Now()
require.NoError(t, n.Notify(ctx, notifications.KindRequestApproved, notifications.ToUser(alice), nil))
on := true
_, err = notifications.SaveSettings(ctx, q, alice, false, []notifications.SettingChange{
{Kind: notifications.KindRequestApproved, Email: &on},
})
require.NoError(t, err)
d.Tick(ctx, start.Add(2*time.Hour))
require.Empty(t, fake.Sent, "turning email on later does not send what came before")
}
func TestDigest_NoAddressNoEmail(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
bob := mkUser(t, dbq.New(pool), "bob", false)
n := notifications.New(pool, nil, nil)
d, fake := digestWith(pool)
require.NoError(t, n.Notify(ctx, notifications.KindRequestApproved, notifications.ToUser(bob), nil))
d.Tick(ctx, time.Now().Add(2*time.Hour))
require.Empty(t, fake.Sent)
}
func TestDigest_CoalescedItemAppearsOnceWithItsLatestCount(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
q := dbq.New(pool)
root := emailUser(t, pool, "root", true)
on := true
_, err := notifications.SaveSettings(ctx, q, root, true, []notifications.SettingChange{
{Kind: notifications.KindTracksMissing, Email: &on},
})
require.NoError(t, err)
n := notifications.New(pool, nil, nil)
d, fake := digestWith(pool)
start := time.Now()
for _, c := range []int64{3, 2} {
require.NoError(t, n.Notify(ctx, notifications.KindTracksMissing,
notifications.ToAdmins(pgtype.UUID{}), notifications.Payload{Count: c}.Map()))
}
d.Tick(ctx, start.Add(2*time.Hour))
require.Len(t, fake.Sent, 1)
require.Equal(t, "Minstrel: 5 tracks went missing", fake.Sent[0].Subject)
}
func TestDigest_MailerFailureRetriesWithBackoffAndSendsOnce(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
alice := emailUser(t, pool, "alice", false)
n := notifications.New(pool, nil, nil)
d, fake := digestWith(pool)
start := time.Now()
require.NoError(t, n.Notify(ctx, notifications.KindRequestApproved, notifications.ToUser(alice), nil))
fake.FailNext = errors.New("smtp: 421 try later")
d.Tick(ctx, start.Add(61*time.Minute))
require.Empty(t, fake.Sent)
d.Tick(ctx, start.Add(63*time.Minute))
require.Empty(t, fake.Sent, "waits out the first retry gap")
d.Tick(ctx, start.Add(67*time.Minute))
require.Len(t, fake.Sent, 1, "then goes")
d.Tick(ctx, start.Add(80*time.Minute))
require.Len(t, fake.Sent, 1, "and is not sent again")
}
func TestDigest_NewMusicWaitsForTheSummaryHour(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
alice := emailUser(t, pool, "alice", false) // timezone defaults to UTC
n := notifications.New(pool, nil, nil)
d, fake := digestWith(pool)
now := time.Now().UTC()
slot := time.Date(now.Year(), now.Month(), now.Day(), 9, 0, 0, 0, time.UTC)
if !slot.After(now) {
slot = slot.AddDate(0, 0, 1)
}
for _, p := range []notifications.Payload{
{Name: "Moe Shop – WWW", Artist: "Moe Shop", Title: "WWW"},
{Name: "Moe Shop – Pure", Artist: "Moe Shop", Title: "Pure"},
} {
require.NoError(t, n.Notify(ctx, notifications.KindRequestCompleted, notifications.ToUser(alice), p.Map()))
}
d.Tick(ctx, slot.Add(-time.Minute))
require.Empty(t, fake.Sent, "new music never goes out in an hourly batch")
d.Tick(ctx, slot)
require.Len(t, fake.Sent, 1)
require.Equal(t, "Minstrel: new music in your library", fake.Sent[0].Subject)
require.Contains(t, fake.Sent[0].TextBody, "Moe Shop\n - WWW")
require.Contains(t, fake.Sent[0].TextBody, " - Pure")
d.Tick(ctx, slot.Add(5*time.Hour))
require.Len(t, fake.Sent, 1, "one summary a day")
}
func TestEmailSettings_DefaultsMatchTheMigrationAndRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
q := dbq.New(pool)
got, err := notifications.LoadEmailSettings(ctx, q)
require.NoError(t, err)
require.Equal(t, notifications.DefaultEmailSettings, got)
saved, err := notifications.SaveEmailSettings(ctx, q, notifications.EmailSettings{SummaryHour: 7, BatchWindowMinutes: 30})
require.NoError(t, err)
require.Equal(t, notifications.EmailSettings{SummaryHour: 7, BatchWindowMinutes: 30}, saved)
got, err = notifications.LoadEmailSettings(ctx, q)
require.NoError(t, err)
require.Equal(t, saved, got)
}
func TestDigest_UsesTheConfiguredBatchWindow(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
alice := emailUser(t, pool, "alice", false)
_, err := notifications.SaveEmailSettings(ctx, dbq.New(pool), notifications.EmailSettings{SummaryHour: 9, BatchWindowMinutes: 15})
require.NoError(t, err)
n := notifications.New(pool, nil, nil)
d, fake := digestWith(pool)
start := time.Now()
require.NoError(t, n.Notify(ctx, notifications.KindRequestApproved, notifications.ToUser(alice), nil))
d.Tick(ctx, start.Add(16*time.Minute))
require.Len(t, fake.Sent, 1)
}
+120
View File
@@ -0,0 +1,120 @@
package notifications
import (
"bytes"
"embed"
"encoding/json"
"fmt"
htmltemplate "html/template"
"strings"
texttemplate "text/template"
)
//go:embed templates/digest.txt templates/digest.html
var digestFS embed.FS
var (
digestText = texttemplate.Must(texttemplate.ParseFS(digestFS, "templates/digest.txt"))
digestHTML = htmltemplate.Must(htmltemplate.ParseFS(digestFS, "templates/digest.html"))
)
// digestEmail is one rendered email.
type digestEmail struct {
Subject string
Text string
HTML string
}
type digestLine struct {
Title string
Body string
URL string
}
type digestArtist struct {
Name string
URL string
Titles []digestLine
}
type digestVars struct {
Name string
Intro string
Items []digestLine
Artists []digestArtist
SettingsURL string
}
// renderDigest renders one group's email. base is the public address; with
// none set, the email still goes out, without links.
func renderDigest(group EmailGroup, name string, items []pendingItem, base string) (digestEmail, error) {
vars := digestVars{Name: name, SettingsURL: joinURL(base, "/settings#notifications")}
var subject string
if group == EmailSummary {
vars.Artists = summaryArtists(items, base)
vars.Intro = "New in your library since the last summary:"
subject = "Minstrel: new music in your library"
} else {
for _, it := range items {
r := Render(it.Kind, it.Payload)
vars.Items = append(vars.Items, digestLine{Title: r.Title, Body: r.Body, URL: joinURL(base, r.Link)})
}
vars.Intro = "Here's what happened on Minstrel:"
subject = fmt.Sprintf("Minstrel: %d updates", len(items))
if len(items) == 1 {
subject = "Minstrel: " + vars.Items[0].Title
}
}
var text, html bytes.Buffer
if err := digestText.Execute(&text, vars); err != nil {
return digestEmail{}, fmt.Errorf("render text: %w", err)
}
if err := digestHTML.Execute(&html, vars); err != nil {
return digestEmail{}, fmt.Errorf("render html: %w", err)
}
return digestEmail{Subject: subject, Text: text.String(), HTML: html.String()}, nil
}
// summaryArtists groups new-music arrivals by artist, in the order each
// artist first arrived, with each artist's albums and tracks under them. An
// artist request has no title of its own; the artist links to its page.
func summaryArtists(items []pendingItem, base string) []digestArtist {
var out []digestArtist
index := map[string]int{}
for _, it := range items {
var p Payload
_ = json.Unmarshal(it.Payload, &p)
artist, title := p.Artist, p.Title
if artist == "" {
// Rows from before Artist was recorded carry only "Artist – Title".
artist, title, _ = strings.Cut(p.Name, " – ")
}
if artist == "" {
artist = "Something you asked for"
}
i, ok := index[artist]
if !ok {
i = len(out)
index[artist] = i
out = append(out, digestArtist{Name: artist})
}
link := Render(it.Kind, it.Payload).Link
if title == "" {
if p.ArtistID != "" {
out[i].URL = joinURL(base, link)
}
continue
}
out[i].Titles = append(out[i].Titles, digestLine{Title: title, URL: joinURL(base, link)})
}
return out
}
// joinURL is base followed by path, or "" when no public address is set.
func joinURL(base, path string) string {
if base == "" || path == "" {
return ""
}
return strings.TrimRight(base, "/") + path
}
+178
View File
@@ -0,0 +1,178 @@
package notifications
import (
"encoding/json"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/require"
)
var (
digestCfg = EmailSettings{SummaryHour: 9, BatchWindowMinutes: 60}
t0 = time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
)
func item(k Kind, at time.Time) pendingItem {
return pendingItem{ID: pgtype.UUID{Bytes: [16]byte{byte(at.Minute()), byte(len(k))}, Valid: true}, Kind: k, CreatedAt: at}
}
func allEmail() map[Kind]bool {
m := map[Kind]bool{}
for _, k := range Kinds() {
m[k] = true
}
return m
}
func TestPlanUser_BatchWindow(t *testing.T) {
first := item(KindRequestApproved, t0)
later := item(KindRequestRejected, t0.Add(40*time.Minute))
cases := []struct {
name string
now time.Time
state groupState
items []pendingItem
wantDue bool
wantOpen time.Time
}{
{"opens at the first item and is not due inside the window", t0.Add(59 * time.Minute), groupState{}, []pendingItem{first, later}, false, t0},
{"due a window after the first item, carrying everything since", t0.Add(60 * time.Minute), groupState{}, []pendingItem{first, later}, true, t0},
{"a recorded start wins over an item that moved later", t0.Add(61 * time.Minute), groupState{BatchOpenedAt: t0}, []pendingItem{later}, true, t0},
{"a failed send waits out its retry", t0.Add(90 * time.Minute), groupState{BatchOpenedAt: t0, RetryAfter: t0.Add(95 * time.Minute)}, []pendingItem{first}, false, t0},
{"and goes once the retry has passed", t0.Add(96 * time.Minute), groupState{BatchOpenedAt: t0, RetryAfter: t0.Add(95 * time.Minute)}, []pendingItem{first}, true, t0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
p := planUser(c.now, digestCfg, time.UTC, c.items, allEmail(), c.state, groupState{})
require.Equal(t, c.wantDue, p.BatchDue)
require.Equal(t, c.wantOpen, p.BatchOpenedAt)
require.Len(t, p.Batch, len(c.items))
})
}
}
func TestPlanUser_QuietBatchSendsNothing(t *testing.T) {
p := planUser(t0.Add(3*time.Hour), digestCfg, time.UTC, nil, allEmail(), groupState{BatchOpenedAt: t0}, groupState{})
require.False(t, p.BatchDue)
require.True(t, p.BatchOpenedAt.IsZero(), "nothing pending leaves no batch open")
}
func TestPlanUser_SummaryHourInUserTimezone(t *testing.T) {
ny, err := time.LoadLocation("America/New_York")
require.NoError(t, err)
arrived := []pendingItem{item(KindRequestCompleted, time.Date(2026, 7, 1, 3, 0, 0, 0, time.UTC))}
// 09:00 in New York in July is 13:00 UTC (EDT, UTC-4).
cases := []struct {
name string
now time.Time
lastSent time.Time
want bool
}{
{"before the local hour", time.Date(2026, 7, 1, 12, 59, 0, 0, time.UTC), time.Time{}, false},
{"at the local hour", time.Date(2026, 7, 1, 13, 0, 0, 0, time.UTC), time.Time{}, true},
{"once a day: already sent after today's hour", time.Date(2026, 7, 1, 18, 0, 0, 0, time.UTC), time.Date(2026, 7, 1, 13, 1, 0, 0, time.UTC), false},
{"yesterday's summary does not hold today's", time.Date(2026, 7, 2, 13, 5, 0, 0, time.UTC), time.Date(2026, 7, 1, 13, 1, 0, 0, time.UTC), true},
// 03:30 UTC on 2 July is 23:30 on 1 July in New York: still the 1st there.
{"the local day, not the UTC day", time.Date(2026, 7, 2, 3, 30, 0, 0, time.UTC), time.Date(2026, 7, 1, 13, 1, 0, 0, time.UTC), false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
p := planUser(c.now, digestCfg, ny, arrived, allEmail(), groupState{}, groupState{LastSentAt: c.lastSent})
require.Equal(t, c.want, p.SummaryDue)
require.False(t, p.BatchDue, "new music never goes out in a batch")
})
}
}
func TestPlanUser_SummaryAcrossDST(t *testing.T) {
ny, err := time.LoadLocation("America/New_York")
require.NoError(t, err)
arrived := []pendingItem{item(KindRequestCompleted, time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC))}
// Clocks go back on 1 November 2026: 09:00 the day before is 13:00 UTC
// (EDT), and 09:00 that day is 14:00 UTC (EST).
before := groupState{LastSentAt: time.Date(2026, 10, 31, 13, 0, 0, 0, time.UTC)}
p := planUser(time.Date(2026, 11, 1, 13, 30, 0, 0, time.UTC), digestCfg, ny, arrived, allEmail(), groupState{}, before)
require.False(t, p.SummaryDue, "08:30 local after the change is not yet the hour")
p = planUser(time.Date(2026, 11, 1, 14, 0, 0, 0, time.UTC), digestCfg, ny, arrived, allEmail(), groupState{}, before)
require.True(t, p.SummaryDue, "09:00 local after the change")
// Spring forward, 8 March 2026: 02:00 does not exist. A summary hour of
// 2 still goes out that day, once.
gap := EmailSettings{SummaryHour: 2, BatchWindowMinutes: 60}
spring := []pendingItem{item(KindRequestCompleted, time.Date(2026, 3, 8, 0, 0, 0, 0, time.UTC))}
at := time.Date(2026, 3, 8, 9, 0, 0, 0, time.UTC) // 05:00 EDT
p = planUser(at, gap, ny, spring, allEmail(), groupState{}, groupState{LastSentAt: time.Date(2026, 3, 7, 7, 0, 0, 0, time.UTC)})
require.True(t, p.SummaryDue)
p = planUser(at.Add(time.Hour), gap, ny, spring, allEmail(), groupState{}, groupState{LastSentAt: at})
require.False(t, p.SummaryDue)
}
func TestPlanUser_EmailOffAndStaleItemsAreSkippedNotSent(t *testing.T) {
off := allEmail()
off[KindRequestRejected] = false
items := []pendingItem{
item(KindRequestRejected, t0),
item(KindRequestApproved, t0.Add(-8*24*time.Hour)),
}
p := planUser(t0.Add(2*time.Hour), digestCfg, time.UTC, items, off, groupState{}, groupState{})
require.Len(t, p.Skip, 2)
require.Empty(t, p.Batch)
require.False(t, p.BatchDue, "nothing left after filtering: no email")
require.False(t, p.SummaryDue)
}
func TestRetryDelay_DoublesToACap(t *testing.T) {
require.Equal(t, 5*time.Minute, retryDelay(1))
require.Equal(t, 10*time.Minute, retryDelay(2))
require.Equal(t, 40*time.Minute, retryDelay(4))
require.Equal(t, 6*time.Hour, retryDelay(20))
}
func TestRenderDigest_SummaryGroupsByArtist(t *testing.T) {
arrival := func(p Payload) pendingItem {
b, _ := json.Marshal(p.Map())
return pendingItem{Kind: KindRequestCompleted, Payload: b}
}
items := []pendingItem{
arrival(Payload{Name: "Moe Shop – WWW", Artist: "Moe Shop", Title: "WWW", AlbumID: "al-1"}),
arrival(Payload{Name: "Boards of Canada", Artist: "Boards of Canada", ArtistID: "ar-2"}),
arrival(Payload{Name: "Moe Shop – Pure", Artist: "Moe Shop", Title: "Pure", AlbumID: "al-3"}),
arrival(Payload{Name: "Tycho – Awake"}), // stored before Artist was recorded
}
e, err := renderDigest(EmailSummary, "alice", items, "https://music.example/")
require.NoError(t, err)
require.Equal(t, "Minstrel: new music in your library", e.Subject)
moe := strings.Index(e.Text, "Moe Shop")
require.GreaterOrEqual(t, moe, 0)
require.Equal(t, moe, strings.LastIndex(e.Text, "Moe Shop\n"), "one heading per artist")
require.Less(t, moe, strings.Index(e.Text, "Boards of Canada"), "artists in the order they arrived")
require.Contains(t, e.Text, " - WWW\n https://music.example/albums/al-1")
require.Contains(t, e.Text, " - Pure\n https://music.example/albums/al-3")
require.Contains(t, e.Text, "Boards of Canada\n https://music.example/artists/ar-2")
require.Contains(t, e.Text, "Tycho\n - Awake")
require.Contains(t, e.Text, "https://music.example/settings#notifications")
require.Contains(t, e.HTML, `<a href="https://music.example/albums/al-1"`)
}
func TestRenderDigest_BatchListsEachItemAndEscapesHTML(t *testing.T) {
b, _ := json.Marshal(Payload{Name: "<script>x</script>", Reason: "dup"}.Map())
items := []pendingItem{
{Kind: KindRequestApproved, Payload: []byte(`{"name":"WWW"}`)},
{Kind: KindRequestRejected, Payload: b},
}
e, err := renderDigest(EmailBatch, "alice", items, "")
require.NoError(t, err)
require.Equal(t, "Minstrel: 2 updates", e.Subject)
require.Contains(t, e.Text, "- Request approved\n WWW is on its way.")
require.NotContains(t, e.Text, "http", "no public address: no links")
require.Contains(t, e.Text, "Settings → Notifications")
require.NotContains(t, e.HTML, "<script>")
one, err := renderDigest(EmailBatch, "alice", items[:1], "")
require.NoError(t, err)
require.Equal(t, "Minstrel: Request approved", one.Subject)
}
+66
View File
@@ -0,0 +1,66 @@
package notifications
import (
"context"
"errors"
"fmt"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// EmailSettings are the digest's two knobs, in admin Settings (rule 25).
type EmailSettings struct {
// SummaryHour is the local hour (0-23, in each user's own timezone) the
// daily new-music summary goes out.
SummaryHour int32
// BatchWindowMinutes is how long a batch stays open after its first item.
BatchWindowMinutes int32
}
const (
minBatchWindowMinutes = 15
maxBatchWindowMinutes = 1440
)
// DefaultEmailSettings mirrors migration 0074's column defaults.
var DefaultEmailSettings = EmailSettings{SummaryHour: 9, BatchWindowMinutes: 60}
// ErrEmailSettingOutOfRange is a value migration 0074's CHECKs would reject,
// so the API answers 400 naming the field rather than a constraint violation.
var ErrEmailSettingOutOfRange = errors.New("notification email setting out of range")
// BatchWindow is BatchWindowMinutes as a duration.
func (s EmailSettings) BatchWindow() time.Duration {
return time.Duration(s.BatchWindowMinutes) * time.Minute
}
// LoadEmailSettings reads the settings. A failed read returns the defaults
// with the error, so the digest keeps its shipped cadence rather than stopping.
func LoadEmailSettings(ctx context.Context, q *dbq.Queries) (EmailSettings, error) {
row, err := q.GetNotificationEmailSettings(ctx)
if err != nil {
return DefaultEmailSettings, fmt.Errorf("notification email settings: load: %w", err)
}
return EmailSettings{SummaryHour: row.SummaryHour, BatchWindowMinutes: row.BatchWindowMinutes}, nil
}
// SaveEmailSettings validates and writes the settings, returning them as saved.
// The digest reads them on its next tick; nothing is cached.
func SaveEmailSettings(ctx context.Context, q *dbq.Queries, in EmailSettings) (EmailSettings, error) {
switch {
case in.SummaryHour < 0 || in.SummaryHour > 23:
return EmailSettings{}, fmt.Errorf("%w: summary_hour must be 0-23", ErrEmailSettingOutOfRange)
case in.BatchWindowMinutes < minBatchWindowMinutes || in.BatchWindowMinutes > maxBatchWindowMinutes:
return EmailSettings{}, fmt.Errorf("%w: batch_window_minutes must be %d-%d",
ErrEmailSettingOutOfRange, minBatchWindowMinutes, maxBatchWindowMinutes)
}
row, err := q.UpdateNotificationEmailSettings(ctx, dbq.UpdateNotificationEmailSettingsParams{
SummaryHour: in.SummaryHour,
BatchWindowMinutes: in.BatchWindowMinutes,
})
if err != nil {
return EmailSettings{}, fmt.Errorf("notification email settings: save: %w", err)
}
return EmailSettings{SummaryHour: row.SummaryHour, BatchWindowMinutes: row.BatchWindowMinutes}, nil
}
+130
View File
@@ -0,0 +1,130 @@
// Package notifications is the one writer of the per-user notifications inbox
// (M489, #726). Producers across the server call Notifier.Notify; nothing else
// inserts into user_notifications.
//
// The event bus alone was fire-and-forget: a client that was not connected
// when a request completed, or when tracks went missing, never heard of it. A
// row here is the durable record. The bus only nudges open clients to come
// and read it.
package notifications
// Kind names one sort of notification. The set is CHECK-gated in migration
// 0073 (rule 36): a new kind adds its value there, in the same change, and
// TestKindsMatchMigrationCheck fails until it does.
type Kind string
const (
KindRequestApproved Kind = "request_approved"
KindRequestRejected Kind = "request_rejected"
KindRequestCompleted Kind = "request_completed"
KindRequestPending Kind = "request_pending"
KindQuarantineFlagged Kind = "quarantine_flagged"
KindScanFailed Kind = "scan_failed"
KindTracksMissing Kind = "tracks_missing"
KindDuplicatesFound Kind = "duplicates_found"
KindPlaybackErrors Kind = "playback_errors"
)
// Audience is who a kind can reach. Admin kinds are never offered to, or
// stored for, a non-admin.
type Audience int
const (
AudienceRequester Audience = iota
AudienceAdmin
)
// EmailGroup is how a kind's emails are grouped. Nothing is emailed per event.
type EmailGroup int
const (
// EmailBatch: one email per batch window, holding everything that
// accumulated since the first un-emailed item.
EmailBatch EmailGroup = iota
// EmailSummary: at most one summary a day, at a set local hour. For new
// music arriving, which comes in bursts and is not urgent.
EmailSummary
)
// Channels are where a kind reaches a user. Inbox is the master switch: with
// it off nothing is stored, so there is nothing for the phone or email to
// deliver. Effective applies that.
type Channels struct {
Inbox bool
Phone bool
Email bool
}
// Effective is what will actually be delivered: phone and email ride on the
// inbox row, so they cannot be on without it.
func (c Channels) Effective() Channels {
return Channels{Inbox: c.Inbox, Phone: c.Inbox && c.Phone, Email: c.Inbox && c.Email}
}
type spec struct {
audience Audience
// coalesce: while one of these is unread, a new event updates it in place
// rather than adding a row. For the burst-prone admin kinds.
coalesce bool
// sumCount: a coalesced update adds the payload's `count` to the unread
// row's, because each event is "N more". Without it the newer payload
// replaces the older, because each event states the whole current total.
sumCount bool
group EmailGroup
defaults Channels
}
var (
allOn = Channels{Inbox: true, Phone: true, Email: true}
healthAlert = Channels{Inbox: true, Phone: true, Email: false}
)
var specs = map[Kind]spec{
KindRequestApproved: {audience: AudienceRequester, group: EmailBatch, defaults: allOn},
KindRequestRejected: {audience: AudienceRequester, group: EmailBatch, defaults: allOn},
KindRequestCompleted: {audience: AudienceRequester, group: EmailSummary, defaults: allOn},
KindRequestPending: {audience: AudienceAdmin, group: EmailBatch, defaults: allOn},
KindQuarantineFlagged: {audience: AudienceAdmin, group: EmailBatch, defaults: allOn},
KindScanFailed: {audience: AudienceAdmin, coalesce: true, sumCount: true, group: EmailBatch, defaults: healthAlert},
KindTracksMissing: {audience: AudienceAdmin, coalesce: true, sumCount: true, group: EmailBatch, defaults: healthAlert},
KindDuplicatesFound: {audience: AudienceAdmin, coalesce: true, group: EmailBatch, defaults: healthAlert},
KindPlaybackErrors: {audience: AudienceAdmin, coalesce: true, group: EmailBatch, defaults: healthAlert},
}
// order is the display order for settings screens: the requester's own kinds
// first, then the admin ones.
var order = []Kind{
KindRequestApproved,
KindRequestRejected,
KindRequestCompleted,
KindRequestPending,
KindQuarantineFlagged,
KindScanFailed,
KindTracksMissing,
KindDuplicatesFound,
KindPlaybackErrors,
}
// Kinds returns every kind in display order.
func Kinds() []Kind { return append([]Kind(nil), order...) }
// Valid reports whether k is a known kind.
func (k Kind) Valid() bool { _, ok := specs[k]; return ok }
// AdminOnly reports whether k reaches only admins.
func (k Kind) AdminOnly() bool { return specs[k].audience == AudienceAdmin }
// Defaults are the channels a user has for k until they change them.
func (k Kind) Defaults() Channels { return specs[k].defaults }
// EmailGroup is how k's emails are grouped.
func (k Kind) EmailGroup() EmailGroup { return specs[k].group }
// coalesceKey is the key k's rows coalesce on, or "" when k never coalesces.
// One key per kind: two unread "tracks missing" rows would only split a count.
func (k Kind) coalesceKey() string {
if specs[k].coalesce {
return string(k)
}
return ""
}
+61
View File
@@ -0,0 +1,61 @@
package notifications
import (
"context"
"testing"
"github.com/stretchr/testify/require"
)
func TestEffective_PhoneAndEmailRideOnInbox(t *testing.T) {
cases := []struct {
in, want Channels
}{
{Channels{true, true, true}, Channels{true, true, true}},
{Channels{true, false, true}, Channels{true, false, true}},
{Channels{false, true, true}, Channels{false, false, false}},
{Channels{false, false, false}, Channels{false, false, false}},
}
for _, c := range cases {
require.Equal(t, c.want, c.in.Effective(), "in=%+v", c.in)
}
}
// The display order and the spec table must name the same kinds, or a kind
// is either unreachable from settings or has no behaviour.
func TestKinds_OrderAndSpecsAgree(t *testing.T) {
require.Len(t, order, len(specs))
seen := map[Kind]bool{}
for _, k := range Kinds() {
require.True(t, k.Valid(), "%s in order but not in specs", k)
require.False(t, seen[k], "%s listed twice", k)
seen[k] = true
}
}
func TestKinds_AdminKindsDefaultEmailOffExceptRequestQueue(t *testing.T) {
// The burst-prone health kinds stay out of email unless an admin opts
// in; the request queue and quarantine flags are things an admin acts on.
for _, k := range []Kind{KindScanFailed, KindTracksMissing, KindDuplicatesFound, KindPlaybackErrors} {
require.True(t, k.AdminOnly(), k)
require.False(t, k.Defaults().Email, k)
require.NotEmpty(t, k.coalesceKey(), "%s should coalesce", k)
}
for _, k := range []Kind{KindRequestApproved, KindRequestRejected, KindRequestCompleted} {
require.False(t, k.AdminOnly(), k)
require.Equal(t, Channels{true, true, true}, k.Defaults(), k)
require.Empty(t, k.coalesceKey(), "%s must never coalesce: each request is its own news", k)
}
require.Equal(t, EmailSummary, KindRequestCompleted.EmailGroup())
require.Equal(t, EmailBatch, KindRequestApproved.EmailGroup())
}
func TestNotify_NilNotifierIsANoOp(t *testing.T) {
var n *Notifier
require.NoError(t, n.Notify(context.Background(), KindRequestApproved, Recipients{}, nil))
}
func TestNotify_UnknownKindIsRefusedBeforeTouchingTheDB(t *testing.T) {
n := New(nil, nil, nil)
require.Error(t, n.Notify(context.Background(), Kind("bogus"), Recipients{}, nil))
}
+202
View File
@@ -0,0 +1,202 @@
package notifications
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
)
// EventCreated is the bus event a recipient's open clients receive when a
// row lands for them. It is a nudge with no content: the client fetches the
// inbox. A frame that carried the notification could not be told apart from
// a repeat after a reconnect, and a frame nobody received is then one nobody
// needed, because the client pulls on reconnect anyway (Roundtable #2535).
const EventCreated = "notification.created"
// Recipients names who an event is for.
type Recipients struct {
Users []pgtype.UUID
// Admins adds every admin.
Admins bool
// Except is never notified, even when it is in Users or is an admin. The
// admin who filed a request needs no notice that it is pending.
Except pgtype.UUID
}
// ToUser addresses one user.
func ToUser(id pgtype.UUID) Recipients { return Recipients{Users: []pgtype.UUID{id}} }
// ToAdmins addresses every admin except one (pass the zero UUID for none).
func ToAdmins(except pgtype.UUID) Recipients { return Recipients{Admins: true, Except: except} }
// Notifier writes notifications and nudges recipients' clients. A nil
// *Notifier is valid and does nothing, so producers built without one (most
// tests) need no special case.
type Notifier struct {
db dbq.DBTX
bus *eventbus.Bus
logger *slog.Logger
}
// New returns a Notifier writing through db. bus may be nil.
func New(db dbq.DBTX, bus *eventbus.Bus, logger *slog.Logger) *Notifier {
if logger == nil {
logger = slog.Default()
}
return &Notifier{db: db, bus: bus, logger: logger}
}
// Notify records kind for each recipient whose inbox preference allows it,
// then nudges their open clients.
//
// For a coalescing kind, an unread row of that kind is updated in place
// rather than a new one added. payload["count"] then adds to the unread
// row's count for kinds whose events each mean "N more".
//
// The error is for logging. A notification must never fail the action that
// caused it, so producers log and carry on; NotifyLogged does exactly that.
func (n *Notifier) Notify(ctx context.Context, kind Kind, to Recipients, payload map[string]any) error {
if n == nil {
return nil
}
if !kind.Valid() {
return fmt.Errorf("notifications: unknown kind %q", kind)
}
q := dbq.New(n.db)
ids, err := n.resolve(ctx, q, kind, to)
if err != nil || len(ids) == 0 {
return err
}
channels, err := channelsFor(ctx, q, kind, ids)
if err != nil {
return err
}
if payload == nil {
payload = map[string]any{}
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("notifications: encode %s payload: %w", kind, err)
}
var delivered []pgtype.UUID
for _, id := range ids {
if !channels[id].Inbox {
continue
}
if err := write(ctx, q, kind, id, body, channels[id].Email); err != nil {
return fmt.Errorf("notifications: write %s: %w", kind, err)
}
delivered = append(delivered, id)
}
n.nudge(delivered)
return nil
}
// NotifyLogged is Notify for producers: a failure is logged at WARN and
// otherwise ignored.
func (n *Notifier) NotifyLogged(ctx context.Context, kind Kind, to Recipients, payload map[string]any) {
if err := n.Notify(ctx, kind, to, payload); err != nil {
n.logger.Warn("notifications: notify failed", "kind", string(kind), "err", err)
}
}
// resolve turns Recipients into a deduplicated list of user ids. Admin kinds
// are delivered only to admins, whatever Users says, so a producer that
// addresses a non-admin by mistake stores nothing for them.
func (n *Notifier) resolve(ctx context.Context, q *dbq.Queries, kind Kind, to Recipients) ([]pgtype.UUID, error) {
var admins []pgtype.UUID
if to.Admins || kind.AdminOnly() {
var err error
if admins, err = q.ListAdminUserIDs(ctx); err != nil {
return nil, fmt.Errorf("notifications: list admins: %w", err)
}
}
isAdmin := make(map[pgtype.UUID]bool, len(admins))
for _, a := range admins {
isAdmin[a] = true
}
var candidates []pgtype.UUID
candidates = append(candidates, to.Users...)
if to.Admins {
candidates = append(candidates, admins...)
}
seen := make(map[pgtype.UUID]bool, len(candidates))
out := make([]pgtype.UUID, 0, len(candidates))
for _, id := range candidates {
if !id.Valid || seen[id] || (to.Except.Valid && id == to.Except) {
continue
}
if kind.AdminOnly() && !isAdmin[id] {
continue
}
seen[id] = true
out = append(out, id)
}
return out, nil
}
// channelsFor returns each recipient's effective channels for kind: their
// stored preference, or the kind's defaults when they have none.
func channelsFor(ctx context.Context, q *dbq.Queries, kind Kind, ids []pgtype.UUID) (map[pgtype.UUID]Channels, error) {
rows, err := q.ListNotificationPrefsForKind(ctx, dbq.ListNotificationPrefsForKindParams{
Kind: string(kind),
UserIds: ids,
})
if err != nil {
return nil, fmt.Errorf("notifications: read prefs: %w", err)
}
out := make(map[pgtype.UUID]Channels, len(ids))
for _, id := range ids {
out[id] = kind.Defaults().Effective()
}
for _, r := range rows {
out[r.UserID] = Channels{Inbox: r.Inbox, Phone: r.Phone, Email: r.Email}.Effective()
}
return out, nil
}
// write stores one row. emailWanted is the recipient's email channel for the
// kind: a row nobody wants emailed is stamped emailed_at at once, so the
// digest never sends it, even if email is switched on later.
func write(ctx context.Context, q *dbq.Queries, kind Kind, userID pgtype.UUID, body []byte, emailWanted bool) error {
key := kind.coalesceKey()
if key == "" {
_, err := q.InsertNotification(ctx, dbq.InsertNotificationParams{
UserID: userID, Kind: string(kind), Payload: body, EmailWanted: emailWanted,
})
return err
}
_, err := q.UpsertCoalescedNotification(ctx, dbq.UpsertCoalescedNotificationParams{
UserID: userID,
Kind: string(kind),
Payload: body,
CoalesceKey: &key,
EmailWanted: emailWanted,
SumCount: specs[kind].sumCount,
})
return err
}
func (n *Notifier) nudge(ids []pgtype.UUID) {
if n.bus == nil {
return
}
for _, id := range ids {
n.bus.Publish(eventbus.Event{
Kind: EventCreated,
UserID: uuid.UUID(id.Bytes).String(),
Data: map[string]any{},
})
}
}
+267
View File
@@ -0,0 +1,267 @@
package notifications_test
import (
"context"
"encoding/json"
"io"
"log/slog"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
)
func testPool(t *testing.T) *pgxpool.Pool {
t.Helper()
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
dbtest.ResetDB(t, pool)
return pool
}
func mkUser(t *testing.T, q *dbq.Queries, name string, admin bool) pgtype.UUID {
t.Helper()
u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name,
PasswordHash: "x",
ApiTokenHash: "tok-" + name, // unique per user
IsAdmin: admin,
})
require.NoError(t, err)
return u.ID
}
func rowsFor(t *testing.T, q *dbq.Queries, user pgtype.UUID) []dbq.ListNotificationsRow {
t.Helper()
rows, err := q.ListNotifications(context.Background(), dbq.ListNotificationsParams{UserID: user, PageLimit: 100})
require.NoError(t, err)
return rows
}
func count(t *testing.T, payload []byte) int64 {
t.Helper()
var p struct {
Count int64 `json:"count"`
}
require.NoError(t, json.Unmarshal(payload, &p))
return p.Count
}
func TestNotify_WritesARowAndNudgesOnlyThatUser(t *testing.T) {
pool := testPool(t)
q := dbq.New(pool)
ctx := context.Background()
alice := mkUser(t, q, "alice", false)
bob := mkUser(t, q, "bob", false)
bus := eventbus.New()
events, unsub := bus.Subscribe(8)
defer unsub()
n := notifications.New(pool, bus, nil)
require.NoError(t, n.Notify(ctx, notifications.KindRequestApproved,
notifications.ToUser(alice), map[string]any{"request_id": "r1", "title": "WWW"}))
rows := rowsFor(t, q, alice)
require.Len(t, rows, 1)
require.Equal(t, "request_approved", rows[0].Kind)
require.False(t, rows[0].ReadAt.Valid)
require.JSONEq(t, `{"request_id":"r1","title":"WWW"}`, string(rows[0].Payload))
require.Empty(t, rowsFor(t, q, bob))
select {
case e := <-events:
require.Equal(t, notifications.EventCreated, e.Kind)
require.Equal(t, uuid.UUID(alice.Bytes).String(), e.UserID)
require.Empty(t, e.Data, "the nudge carries no content; the client fetches")
case <-time.After(time.Second):
t.Fatal("no nudge published")
}
}
func TestNotify_AdminKindReachesAdminsOnly_AndNeverTheExcepted(t *testing.T) {
pool := testPool(t)
q := dbq.New(pool)
ctx := context.Background()
requester := mkUser(t, q, "admin-requester", true)
other := mkUser(t, q, "admin-other", true)
plain := mkUser(t, q, "plain", false)
n := notifications.New(pool, nil, nil)
// A plain user named directly must still get nothing: the kind is admin-only.
to := notifications.ToAdmins(requester)
to.Users = []pgtype.UUID{plain}
require.NoError(t, n.Notify(ctx, notifications.KindRequestPending, to, nil))
require.Empty(t, rowsFor(t, q, requester), "the admin who filed it needs no notice")
require.Len(t, rowsFor(t, q, other), 1)
require.Empty(t, rowsFor(t, q, plain))
}
func TestNotify_CoalescedCountAddsUpWhileUnread_AndStartsAfreshOnceRead(t *testing.T) {
pool := testPool(t)
q := dbq.New(pool)
ctx := context.Background()
admin := mkUser(t, q, "admin", true)
n := notifications.New(pool, nil, nil)
to := notifications.ToUser(admin)
require.NoError(t, n.Notify(ctx, notifications.KindTracksMissing, to, map[string]any{"count": 3}))
require.NoError(t, n.Notify(ctx, notifications.KindTracksMissing, to, map[string]any{"count": 4}))
rows := rowsFor(t, q, admin)
require.Len(t, rows, 1, "a burst is one notification")
require.Equal(t, int64(7), count(t, rows[0].Payload))
_, err := q.MarkNotificationRead(ctx, dbq.MarkNotificationReadParams{ID: rows[0].ID, UserID: admin})
require.NoError(t, err)
require.NoError(t, n.Notify(ctx, notifications.KindTracksMissing, to, map[string]any{"count": 2}))
rows = rowsFor(t, q, admin)
require.Len(t, rows, 2, "after it is read, the next event is new news")
require.Equal(t, int64(2), count(t, rows[0].Payload))
require.False(t, rows[0].ReadAt.Valid)
require.True(t, rows[1].ReadAt.Valid)
}
func TestNotify_CoalescedTotalReplacesWhenEventsStateTheWhole(t *testing.T) {
pool := testPool(t)
q := dbq.New(pool)
ctx := context.Background()
admin := mkUser(t, q, "admin", true)
n := notifications.New(pool, nil, nil)
// duplicates_found states the pending total each time; it must not sum.
require.NoError(t, n.Notify(ctx, notifications.KindDuplicatesFound, notifications.ToUser(admin), map[string]any{"count": 5}))
require.NoError(t, n.Notify(ctx, notifications.KindDuplicatesFound, notifications.ToUser(admin), map[string]any{"count": 3}))
rows := rowsFor(t, q, admin)
require.Len(t, rows, 1)
require.Equal(t, int64(3), count(t, rows[0].Payload))
}
func TestNotify_RequestKindsNeverCoalesce(t *testing.T) {
pool := testPool(t)
q := dbq.New(pool)
ctx := context.Background()
alice := mkUser(t, q, "alice", false)
n := notifications.New(pool, nil, nil)
for _, id := range []string{"r1", "r2"} {
require.NoError(t, n.Notify(ctx, notifications.KindRequestCompleted, notifications.ToUser(alice), map[string]any{"request_id": id}))
}
require.Len(t, rowsFor(t, q, alice), 2)
}
func TestNotify_InboxOffStoresNothing_AndDefaultsApplyWithoutAPref(t *testing.T) {
pool := testPool(t)
q := dbq.New(pool)
ctx := context.Background()
muted := mkUser(t, q, "muted", false)
fresh := mkUser(t, q, "fresh", false)
require.NoError(t, q.UpsertNotificationPref(ctx, dbq.UpsertNotificationPrefParams{
UserID: muted, Kind: "request_rejected", Inbox: false, Phone: true, Email: true,
}))
n := notifications.New(pool, nil, nil)
to := notifications.Recipients{Users: []pgtype.UUID{muted, fresh}}
require.NoError(t, n.Notify(ctx, notifications.KindRequestRejected, to, nil))
require.Empty(t, rowsFor(t, q, muted), "inbox off is off, whatever phone and email say")
require.Len(t, rowsFor(t, q, fresh), 1)
}
func TestMarkRead_ScopedToTheOwner_AndIdempotent(t *testing.T) {
pool := testPool(t)
q := dbq.New(pool)
ctx := context.Background()
alice := mkUser(t, q, "alice", false)
mallory := mkUser(t, q, "mallory", false)
n := notifications.New(pool, nil, nil)
require.NoError(t, n.Notify(ctx, notifications.KindRequestApproved, notifications.ToUser(alice), nil))
id := rowsFor(t, q, alice)[0].ID
got, err := q.MarkNotificationRead(ctx, dbq.MarkNotificationReadParams{ID: id, UserID: mallory})
require.NoError(t, err)
require.Zero(t, got, "another user's id matches nothing")
require.False(t, rowsFor(t, q, alice)[0].ReadAt.Valid)
for i := 0; i < 2; i++ {
got, err = q.MarkNotificationRead(ctx, dbq.MarkNotificationReadParams{ID: id, UserID: alice})
require.NoError(t, err)
require.Equal(t, int64(1), got, "a repeat still matches, so it is not mistaken for 'not yours'")
}
unread, err := q.CountUnreadNotifications(ctx, alice)
require.NoError(t, err)
require.Zero(t, unread)
}
// Every kind the code knows must pass both CHECKs in the real schema. This is
// rule 36's guard: a kind added in Go without the migration fails here, not in
// production at INSERT time.
func TestEveryKindPassesTheSchemaChecks(t *testing.T) {
pool := testPool(t)
q := dbq.New(pool)
ctx := context.Background()
user := mkUser(t, q, "kinds", true)
for _, k := range notifications.Kinds() {
_, err := q.InsertNotification(ctx, dbq.InsertNotificationParams{UserID: user, Kind: string(k), Payload: []byte(`{}`)})
require.NoError(t, err, "user_notifications rejects %s", k)
require.NoError(t, q.UpsertNotificationPref(ctx, dbq.UpsertNotificationPrefParams{
UserID: user, Kind: string(k), Inbox: true, Phone: true, Email: true,
}), "user_notification_prefs rejects %s", k)
}
// And the check is real: an unknown kind is refused.
_, err := q.InsertNotification(ctx, dbq.InsertNotificationParams{UserID: user, Kind: "bogus", Payload: []byte(`{}`)})
require.Error(t, err)
}
func TestRetention_TrimsOldReadRowsAndAncientUnreadOnes(t *testing.T) {
pool := testPool(t)
q := dbq.New(pool)
ctx := context.Background()
user := mkUser(t, q, "retention", false)
now := time.Now()
insert := func(created time.Time, read *time.Time) {
var readAt any
if read != nil {
readAt = *read
}
_, err := pool.Exec(ctx,
`INSERT INTO user_notifications (user_id, kind, created_at, read_at) VALUES ($1, 'request_approved', $2, $3)`,
user, created, readAt)
require.NoError(t, err)
}
day := 24 * time.Hour
oldRead := now.Add(-100 * day)
recentRead := now.Add(-10 * day)
insert(now.Add(-120*day), &oldRead) // read 100 days ago: goes
insert(now.Add(-20*day), &recentRead) // read 10 days ago: stays
insert(now.Add(-400*day), nil) // unread but over a year old: goes
insert(now.Add(-200*day), nil) // unread, 200 days: stays
r := notifications.NewRetention(pool, slog.New(slog.NewTextHandler(io.Discard, nil)))
require.Equal(t, int64(2), r.TrimOnce(ctx, now))
require.Len(t, rowsFor(t, q, user), 2)
}
+139
View File
@@ -0,0 +1,139 @@
package notifications
import (
"encoding/json"
"fmt"
)
// Rendered is a notification as people read it. The server renders it once,
// so the web inbox, the Android inbox, the phone's shade and the email digest
// all say the same thing, and a wording change is one edit here rather than
// a phrase table per client.
type Rendered struct {
Title string `json:"title"`
Body string `json:"body"`
// Link is the web path the notification opens. Android maps the same
// paths onto its screens.
Link string `json:"link"`
}
// Payload is what producers put in a notification. Every field is optional;
// each kind documents the ones it uses in Render.
type Payload struct {
RequestID string `json:"request_id,omitempty"`
RequestKind string `json:"request_kind,omitempty"` // artist | album | track
// Name is what was requested or flagged, as the user would say it:
// "WWW", "Moe Shop", "Moe Shop – WWW".
Name string `json:"name,omitempty"`
// Artist and Title are Name's parts, for request_completed: the new-music
// summary email groups arrivals by artist. Title is empty for an artist
// request.
Artist string `json:"artist,omitempty"`
Title string `json:"title,omitempty"`
ArtistID string `json:"artist_id,omitempty"`
AlbumID string `json:"album_id,omitempty"`
// Actor is the other person involved: who asked, who flagged.
Actor string `json:"actor,omitempty"`
Reason string `json:"reason,omitempty"`
// Count is the coalesced kinds' running total.
Count int64 `json:"count,omitempty"`
// Detail is free text for scan_failed (the error message).
Detail string `json:"detail,omitempty"`
}
// Map turns a Payload into the map Notify takes, dropping empty fields.
func (p Payload) Map() map[string]any {
b, _ := json.Marshal(p)
var m map[string]any
_ = json.Unmarshal(b, &m)
return m
}
// Render turns a stored row into what people read. An unreadable payload
// still renders, from the kind alone, rather than failing the whole inbox.
func Render(kind Kind, payload []byte) Rendered {
var p Payload
_ = json.Unmarshal(payload, &p)
name := p.Name
if name == "" {
name = "Your request"
}
switch kind {
case KindRequestApproved:
return Rendered{Title: "Request approved", Body: name + " is on its way.", Link: "/requests"}
case KindRequestRejected:
body := name + " was declined."
if p.Reason != "" {
body = name + " was declined: " + p.Reason
}
return Rendered{Title: "Request declined", Body: body, Link: "/requests"}
case KindRequestCompleted:
link := "/requests"
switch {
case p.AlbumID != "":
link = "/albums/" + p.AlbumID
case p.ArtistID != "":
link = "/artists/" + p.ArtistID
}
return Rendered{Title: "Now in your library", Body: name + " has arrived.", Link: link}
case KindRequestPending:
who := p.Actor
if who == "" {
who = "Someone"
}
return Rendered{Title: "Request to review", Body: who + " asked for " + name + ".", Link: "/admin/requests"}
case KindQuarantineFlagged:
who := p.Actor
if who == "" {
who = "Someone"
}
body := who + " flagged " + name + "."
if p.Reason != "" {
body = who + " flagged " + name + ": " + p.Reason
}
return Rendered{Title: "Track flagged", Body: body, Link: "/admin/quarantine"}
case KindScanFailed:
title := "Library scan failed"
if p.Count > 1 {
title = fmt.Sprintf("Library scan failed %d times", p.Count)
}
body := p.Detail
if body == "" {
body = "The last scan stopped with an error."
}
return Rendered{Title: title, Body: body, Link: "/admin"}
case KindTracksMissing:
return Rendered{
Title: plural(p.Count, "track went missing", "tracks went missing"),
Body: "Their files are no longer where the library expects them.",
Link: "/admin/missing-files",
}
case KindDuplicatesFound:
return Rendered{
Title: plural(p.Count, "duplicate group to review", "duplicate groups to review"),
Body: "The duplicate sweep found tracks holding the same recording.",
Link: "/admin/duplicates",
}
case KindPlaybackErrors:
return Rendered{
Title: plural(p.Count, "playback error reported", "playback errors reported"),
Body: "A track failed to play for someone.",
Link: "/admin/playback-errors",
}
default:
return Rendered{Title: "Notification", Link: "/"}
}
}
func plural(n int64, one, many string) string {
if n == 1 {
return "1 " + one
}
if n < 1 {
// A coalesced kind always carries a count; this only guards a
// malformed row from reading "0 tracks went missing".
return "Some " + many
}
return fmt.Sprintf("%d %s", n, many)
}
+56
View File
@@ -0,0 +1,56 @@
package notifications
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
// render stores p the way Notify does (through Map) and reads it back.
func render(k Kind, p Payload) Rendered {
b, err := json.Marshal(p.Map())
if err != nil {
panic(err)
}
return Render(k, b)
}
func TestRender_EveryKindHasATitleAndALink(t *testing.T) {
for _, k := range Kinds() {
r := Render(k, []byte(`{}`))
require.NotEmpty(t, r.Title, k)
require.NotEqual(t, "Notification", r.Title, "%s falls through to the default", k)
require.NotEmpty(t, r.Link, k)
}
}
func TestRender_RequestCompletedLinksToWhatArrived(t *testing.T) {
require.Equal(t, "/albums/al-1", render(KindRequestCompleted, Payload{Name: "WWW", AlbumID: "al-1", ArtistID: "ar-1"}).Link)
require.Equal(t, "/artists/ar-1", render(KindRequestCompleted, Payload{Name: "Moe Shop", ArtistID: "ar-1"}).Link)
require.Equal(t, "/requests", render(KindRequestCompleted, Payload{Name: "WWW"}).Link)
require.Equal(t, "WWW has arrived.", render(KindRequestCompleted, Payload{Name: "WWW"}).Body)
}
func TestRender_RejectionCarriesTheReasonWhenThereIsOne(t *testing.T) {
require.Equal(t, "WWW was declined.", render(KindRequestRejected, Payload{Name: "WWW"}).Body)
require.Equal(t, "WWW was declined: already owned", render(KindRequestRejected, Payload{Name: "WWW", Reason: "already owned"}).Body)
}
func TestRender_CoalescedCountsReadNaturally(t *testing.T) {
require.Equal(t, "1 track went missing", render(KindTracksMissing, Payload{Count: 1}).Title)
require.Equal(t, "14 tracks went missing", render(KindTracksMissing, Payload{Count: 14}).Title)
require.Equal(t, "Library scan failed", render(KindScanFailed, Payload{Count: 1, Detail: "x"}).Title)
require.Equal(t, "Library scan failed 3 times", render(KindScanFailed, Payload{Count: 3}).Title)
require.Equal(t, "Some tracks went missing", Render(KindTracksMissing, []byte(`{}`)).Title)
}
func TestRender_AnUnreadablePayloadStillRenders(t *testing.T) {
r := Render(KindRequestApproved, []byte(`not json`))
require.Equal(t, "Request approved", r.Title)
require.Equal(t, "Your request is on its way.", r.Body)
}
func TestPayloadMap_DropsEmptyFields(t *testing.T) {
require.Equal(t, map[string]any{"name": "WWW", "count": float64(2)}, Payload{Name: "WWW", Count: 2}.Map())
}
+66
View File
@@ -0,0 +1,66 @@
package notifications
import (
"context"
"log/slog"
"time"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
const (
// ReadRetention is how long a read notification is kept. An inbox is a
// record of what happened lately, not an archive.
ReadRetention = 90 * 24 * time.Hour
// AnyRetention bounds an inbox nobody opens: past it, even unread rows go.
AnyRetention = 365 * 24 * time.Hour
// retentionInterval matches the library_changes compactor's daily tick.
retentionInterval = 24 * time.Hour
)
// Retention trims old notifications on a daily tick, in the shape of the
// library_changes compactor (internal/sync/compactor.go).
type Retention struct {
db dbq.DBTX
logger *slog.Logger
}
// NewRetention returns a Retention trimming through db.
func NewRetention(db dbq.DBTX, logger *slog.Logger) *Retention {
return &Retention{db: db, logger: logger}
}
// Run blocks until ctx is cancelled. It trims once at startup, so a process
// that has been down a while catches up, then daily.
func (r *Retention) Run(ctx context.Context) {
r.TrimOnce(ctx, time.Now())
t := time.NewTicker(retentionInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-t.C:
r.TrimOnce(ctx, now)
}
}
}
// TrimOnce deletes what has outlived its retention as of now. Errors are
// logged, never fatal: the next tick tries again.
func (r *Retention) TrimOnce(ctx context.Context, now time.Time) int64 {
n, err := dbq.New(r.db).TrimNotifications(ctx, dbq.TrimNotificationsParams{
ReadCutoff: pgtype.Timestamptz{Time: now.Add(-ReadRetention), Valid: true},
AnyCutoff: pgtype.Timestamptz{Time: now.Add(-AnyRetention), Valid: true},
})
if err != nil {
r.logger.Warn("notifications retention: trim failed", "err", err)
return 0
}
if n > 0 {
r.logger.Info("notifications retention: trimmed rows", "count", n)
}
return n
}
+102
View File
@@ -0,0 +1,102 @@
package notifications
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// KindSetting is one row of a user's notification settings.
type KindSetting struct {
Kind Kind `json:"kind"`
AdminOnly bool `json:"admin_only"`
Inbox bool `json:"inbox"`
Phone bool `json:"phone"`
Email bool `json:"email"`
}
// SettingChange changes one kind. A nil field is left as it is, so a client
// replaying a queued toggle offline sends only what the user touched.
type SettingChange struct {
Kind Kind `json:"kind"`
Inbox *bool `json:"inbox,omitempty"`
Phone *bool `json:"phone,omitempty"`
Email *bool `json:"email,omitempty"`
}
// ErrSettingInvalid is a change naming an unknown kind, or an admin kind for
// a user who is not an admin.
var ErrSettingInvalid = errors.New("notification setting invalid")
// LoadSettings returns every kind the user can receive, in display order,
// with their stored channels or the kind's defaults. Admin kinds are listed
// only for admins.
//
// The stored values are returned as set, not as Effective: a user who turns
// the inbox off and on again gets back the phone and email choices they had.
func LoadSettings(ctx context.Context, q *dbq.Queries, userID pgtype.UUID, isAdmin bool) ([]KindSetting, error) {
rows, err := q.ListNotificationPrefsForUser(ctx, userID)
if err != nil {
return nil, fmt.Errorf("notifications: load settings: %w", err)
}
stored := make(map[Kind]Channels, len(rows))
for _, r := range rows {
stored[Kind(r.Kind)] = Channels{Inbox: r.Inbox, Phone: r.Phone, Email: r.Email}
}
out := make([]KindSetting, 0, len(order))
for _, k := range order {
if k.AdminOnly() && !isAdmin {
continue
}
c, ok := stored[k]
if !ok {
c = k.Defaults()
}
out = append(out, KindSetting{Kind: k, AdminOnly: k.AdminOnly(), Inbox: c.Inbox, Phone: c.Phone, Email: c.Email})
}
return out, nil
}
// SaveSettings applies changes and returns the settings as they now stand.
// The whole batch is validated before anything is written.
func SaveSettings(ctx context.Context, q *dbq.Queries, userID pgtype.UUID, isAdmin bool, changes []SettingChange) ([]KindSetting, error) {
for _, c := range changes {
if !c.Kind.Valid() {
return nil, fmt.Errorf("%w: unknown kind %q", ErrSettingInvalid, c.Kind)
}
if c.Kind.AdminOnly() && !isAdmin {
return nil, fmt.Errorf("%w: %s is for admins", ErrSettingInvalid, c.Kind)
}
}
current, err := LoadSettings(ctx, q, userID, isAdmin)
if err != nil {
return nil, err
}
byKind := make(map[Kind]KindSetting, len(current))
for _, s := range current {
byKind[s.Kind] = s
}
for _, c := range changes {
s := byKind[c.Kind]
if c.Inbox != nil {
s.Inbox = *c.Inbox
}
if c.Phone != nil {
s.Phone = *c.Phone
}
if c.Email != nil {
s.Email = *c.Email
}
byKind[c.Kind] = s
if err := q.UpsertNotificationPref(ctx, dbq.UpsertNotificationPrefParams{
UserID: userID, Kind: string(c.Kind), Inbox: s.Inbox, Phone: s.Phone, Email: s.Email,
}); err != nil {
return nil, fmt.Errorf("notifications: save %s: %w", c.Kind, err)
}
}
return LoadSettings(ctx, q, userID, isAdmin)
}
@@ -0,0 +1,25 @@
<!doctype html>
<html>
<body style="font-family: -apple-system, system-ui, sans-serif; max-width: 560px; margin: 0 auto; padding: 24px; color: #2c2c2c;">
<p>Hello <strong>{{.Name}}</strong>,</p>
<p>{{.Intro}}</p>
{{range .Artists}}
<p style="margin: 20px 0 4px; font-weight: 600;">{{if .URL}}<a href="{{.URL}}" style="color: #2c2c2c;">{{.Name}}</a>{{else}}{{.Name}}{{end}}</p>
{{if .Titles}}<ul style="margin: 0; padding-left: 20px;">
{{range .Titles}}<li style="margin: 2px 0;">{{if .URL}}<a href="{{.URL}}" style="color: #4a6b5c;">{{.Title}}</a>{{else}}{{.Title}}{{end}}</li>
{{end}}</ul>{{end}}
{{end}}{{if .Items}}
<ul style="margin: 16px 0; padding-left: 20px;">
{{range .Items}}<li style="margin: 8px 0;">
{{if .URL}}<a href="{{.URL}}" style="color: #2c2c2c; font-weight: 600;">{{.Title}}</a>{{else}}<strong>{{.Title}}</strong>{{end}}
{{if .Body}}<br><span style="color: #555;">{{.Body}}</span>{{end}}
</li>
{{end}}</ul>
{{end}}
<p style="font-size: 12px; color: #999; margin-top: 32px; border-top: 1px solid #eee; padding-top: 16px;">
{{if .SettingsURL}}<a href="{{.SettingsURL}}" style="color: #999;">Choose which notifications are emailed to you</a>{{else}}Choose which notifications are emailed to you in Minstrel, under Settings → Notifications.{{end}}<br>
— Minstrel
</p>
</body>
</html>
@@ -0,0 +1,16 @@
Hello {{.Name}},
{{.Intro}}
{{range .Artists}}
{{.Name}}{{if .URL}}
{{.URL}}{{end}}{{range .Titles}}
- {{.Title}}{{if .URL}}
{{.URL}}{{end}}{{end}}
{{end}}{{range .Items}}
- {{.Title}}{{if .Body}}
{{.Body}}{{end}}{{if .URL}}
{{.URL}}{{end}}
{{end}}
{{if .SettingsURL}}Choose which notifications are emailed to you: {{.SettingsURL}}{{else}}Choose which notifications are emailed to you in Minstrel, under Settings → Notifications.{{end}}
— Minstrel
+4 -1
View File
@@ -95,8 +95,11 @@ type discoverPools struct {
}
func loadDiscoverPools(ctx context.Context, q *dbq.Queries, logger *slog.Logger, userID pgtype.UUID, dateStr string) discoverPools {
// The caps go into the query too (#5356): applied only here, after its
// LIMIT, they left a heavily tagged artist or two owning the whole bucket.
tasteRows, err := q.ListTasteUnheardTracksForDiscover(ctx, dbq.ListTasteUnheardTracksForDiscoverParams{
UserID: userID, Column2: dateStr,
UserID: userID, DateSeed: dateStr,
MaxPerAlbum: discoverMaxTracksPerAlbum, MaxPerArtist: discoverMaxTracksPerArtist,
})
if err != nil {
logger.Warn("discover: taste-unheard bucket failed; continuing with empty pool",
@@ -0,0 +1,100 @@
package playlists_test
import (
"context"
"fmt"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func setGenre(t *testing.T, pool *pgxpool.Pool, trackID pgtype.UUID, genre string) {
t.Helper()
if _, err := pool.Exec(context.Background(),
`UPDATE tracks SET genre = $2 WHERE id = $1`, trackID, genre); err != nil {
t.Fatalf("set genre: %v", err)
}
}
func seedTasteTag(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, tag string, weight float64) {
t.Helper()
if _, err := pool.Exec(context.Background(),
`INSERT INTO taste_profile_tags (user_id, tag, weight) VALUES ($1, $2, $3)
ON CONFLICT (user_id, tag) DO UPDATE SET weight = EXCLUDED.weight`,
userID, tag, weight); err != nil {
t.Fatalf("seed taste tag: %v", err)
}
}
// TestListTasteUnheardTracksForDiscover_CapsBeforeLimit is the deploy's shape
// (#5356): one artist whose every track carries the whole taste profile
// outscores everything on summed weight. Capped only after the LIMIT, that
// artist took all 120 rows and the bucket shrank to its 3. Capped before, the
// artist keeps 3 and the single-tag artists below it fill the rest.
func TestListTasteUnheardTracksForDiscover_CapsBeforeLimit(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
u := seedUser(t, pool, "tastecap")
profile := []string{"Lo-Fi", "Downtempo", "Hip Hop", "Instrumental", "Chillwave"}
for _, tag := range profile {
seedTasteTag(t, pool, u.ID, tag, 10)
}
allTags := "Lo-Fi; Downtempo; Hip Hop; Instrumental; Chillwave"
// 13 albums x 10 tracks = 130, more than the query's LIMIT of 120.
heavy := seedTrack(t, pool, "Heavy 0", "Heavy Artist")
setGenre(t, pool, heavy.ID, allTags)
album := heavy.AlbumID
for i := 1; i < 130; i++ {
if i%10 == 0 {
album = seedAlbumForArtist(t, pool, fmt.Sprintf("Heavy Album %d", i/10), heavy.ArtistID)
}
tr := seedTrackForArtist(t, pool, fmt.Sprintf("Heavy %d", i), album, heavy.ArtistID)
setGenre(t, pool, tr.ID, allTags)
}
// Twelve artists with one track each, matching a single profile tag.
const light = 12
for i := 0; i < light; i++ {
tr := seedTrack(t, pool, fmt.Sprintf("Light %d", i), fmt.Sprintf("Light Artist %d", i))
setGenre(t, pool, tr.ID, profile[i%len(profile)])
}
rows, err := dbq.New(pool).ListTasteUnheardTracksForDiscover(ctx, dbq.ListTasteUnheardTracksForDiscoverParams{
UserID: u.ID, DateSeed: "2026-10-08", MaxPerAlbum: 2, MaxPerArtist: 3,
})
if err != nil {
t.Fatalf("list taste unheard: %v", err)
}
perArtist := map[pgtype.UUID]int{}
perAlbum := map[pgtype.UUID]int{}
for _, r := range rows {
perArtist[r.ArtistID]++
perAlbum[r.AlbumID]++
}
if got := perArtist[heavy.ArtistID]; got != 3 {
t.Errorf("heavy artist rows = %d, want exactly the artist cap of 3", got)
}
for id, n := range perAlbum {
if n > 2 {
t.Errorf("album %v has %d rows, over the album cap of 2", id, n)
}
}
if got, want := len(perArtist), 1+light; got != want {
t.Errorf("distinct artists = %d, want %d (the heavy artist plus every single-tag artist)", got, want)
}
if got, want := len(rows), 3+light; got != want {
t.Errorf("rows = %d, want %d", got, want)
}
// Still ranked by weight: the heavy artist's three lead.
for i := 0; i < 3 && i < len(rows); i++ {
if rows[i].ArtistID != heavy.ArtistID {
t.Errorf("row %d is not the heavy artist's; higher summed weight should rank first", i)
}
}
}
+25 -2
View File
@@ -7,12 +7,14 @@ import (
"log/slog"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
@@ -20,7 +22,7 @@ import (
// narrowed to an interface so the pass can be tested without a Lidarr client
// or an approval path that talks to one.
type requestCreator interface {
Create(ctx context.Context, userID pgtype.UUID, p lidarrrequests.CreateParams) (dbq.LidarrRequest, error)
CreateTracked(ctx context.Context, userID pgtype.UUID, p lidarrrequests.CreateParams) (dbq.LidarrRequest, bool, error)
Approve(ctx context.Context, requestID, adminID pgtype.UUID, ov lidarrrequests.ApproveOverrides) (dbq.LidarrRequest, error)
}
@@ -37,6 +39,9 @@ type Sweeper struct {
settings *SettingsService
requests requestCreator
logger *slog.Logger
// notifier tells admins about a request the sweeper filed that still
// needs their approval (M489). Nil: nobody is told.
notifier *notifications.Notifier
tick time.Duration
// releaseGroup names the MusicBrainz release group of a release id, for an
// album whose tags never carried one (#5241). Lidarr knows albums only by
@@ -92,6 +97,10 @@ type PassResult struct {
Unresolved int
}
// SetNotifier makes requests the sweeper files, and cannot approve itself,
// reach the admins' notifications inbox (M489).
func (s *Sweeper) SetNotifier(n *notifications.Notifier) { s.notifier = n }
// SweepOnce runs one pass. Exported so the admin surface can offer a "run
// now" without waiting out the tick, and so tests drive it directly.
func (s *Sweeper) SweepOnce(ctx context.Context) error {
@@ -182,7 +191,7 @@ func (s *Sweeper) attempt(
return fmt.Errorf("release group: %w", err)
}
req, err := s.requests.Create(ctx, adminID, lidarrrequests.CreateParams{
req, created, err := s.requests.CreateTracked(ctx, adminID, lidarrrequests.CreateParams{
Kind: "album",
LidarrArtistMBID: *album.ArtistMbid,
ArtistName: album.ArtistName,
@@ -206,11 +215,15 @@ func (s *Sweeper) attempt(
return fmt.Errorf("record attempt: %w", err)
}
// A request the sweeper filed and could not approve waits on an admin.
// One it deduped into was announced when it was first filed.
awaitingAdmin := created
if cfg.AutoApprove {
_, aerr := s.requests.Approve(ctx, req.ID, adminID, lidarrrequests.ApproveOverrides{})
switch {
case aerr == nil:
res.Approved++
awaitingAdmin = false
case errors.Is(aerr, lidarrrequests.ErrLidarrDisabled):
// Leave it pending rather than treating it as a failure. The
// request is still the right record of intent, and it becomes
@@ -225,6 +238,16 @@ func (s *Sweeper) attempt(
}
}
if awaitingAdmin {
s.notifier.NotifyLogged(ctx, notifications.KindRequestPending, notifications.ToAdmins(pgtype.UUID{}),
notifications.Payload{
RequestID: uuid.UUID(req.ID.Bytes).String(),
RequestKind: string(req.Kind),
Name: lidarrrequests.DisplayName(req),
Actor: "Re-acquisition",
}.Map())
}
if row.Attempts >= cfg.MaxAttempts {
if err := q.MarkReacquisitionGaveUp(ctx, album.AlbumID); err != nil {
return fmt.Errorf("mark gave up: %w", err)

Some files were not shown because too many files have changed in this diff Show More