RecentShowEntity (recent_shows)
Tracks recently played shows using an UPSERT pattern. Each show has a single record that updates when played, eliminating complex GROUP BY queries while maintaining deduplication.
Purpose
RecentShowEntity provides efficient recent play tracking:
- Play history - What shows user has played
- Recency ordering - Most recent plays first
- Play frequency - Total play count per show
- First play tracking - When user first discovered show
- UPSERT pattern - Single record per show, updated on each play
Why UPSERT Pattern: Alternative would be inserting new row on each play, requiring GROUP BY showId with MAX(timestamp) for deduplication. UPSERT keeps one row per show, making queries simple and fast.
Schema
Table Definition
CREATE TABLE recent_shows (
show_id TEXT PRIMARY KEY,
-- Play tracking
last_played_timestamp INTEGER NOT NULL,
first_played_timestamp INTEGER NOT NULL,
total_play_count INTEGER NOT NULL,
FOREIGN KEY (show_id) REFERENCES shows(show_id) ON DELETE CASCADE
);
Indexes
CREATE INDEX idx_recent_shows_last_played ON recent_shows(last_played_timestamp DESC);
Fields
Primary Key
show_id (TEXT, PRIMARY KEY, FK, UNIQUE)
References parent show.
Example: "1977-05-08-barton-hall-cornell-u-ithaca-ny-usa"
Foreign Key: → shows.show_id with CASCADE DELETE
Unique Constraint: PRIMARY KEY ensures one record per show (UPSERT pattern)
Usage: Fast lookups, enforce one-entry-per-show constraint
Play Tracking
last_played_timestamp (INTEGER, NOT NULL, INDEXED DESC)
Timestamp of the most recent play.
Format: Unix timestamp (milliseconds)
Example: 1678901234000
Updated: Every time user plays any track from this show
Indexed (DESC): Sort recent shows by recency (newest first)
Usage: - Display "Recently played" section - Order shows by most recent play - Analytics: "Last played 2 days ago"
first_played_timestamp (INTEGER, NOT NULL)
Timestamp when user first played this show.
Format: Unix timestamp (milliseconds)
Example: 1678800000000
Set Once: Never changes after initial insert
Usage: - Analytics: "First played March 14, 2023" - Track user's discovery timeline - "New to you" features
total_play_count (INTEGER, NOT NULL)
Total number of times this show has been played.
Example: 12
Incremented: Each time user plays any track from this show (after meaningful play threshold)
Meaningful Play: Typically 30+ seconds or >50% of track duration (prevents accidental taps from counting)
Usage: - Display "Played 12 times" - Sort by most played - Analytics: User's favorite shows - Recommendations: "Shows you play often"
Relationships
To ShowEntity (N:1)
FOREIGN KEY (show_id) REFERENCES shows(show_id) ON DELETE CASCADE
Cardinality: Many recent play records reference one show (but UPSERT ensures at most one record per show)
Cascade Behavior: If show deleted from catalog (rare), recent play record deleted (orphaned entry meaningless)
Unique Constraint: show_id is PRIMARY KEY, enforcing one-entry-per-show
UPSERT Pattern
The UPSERT (update-or-insert) pattern is the core design of this table.
How It Works
First Play:
val existing = recentShowDao.getShowById(showId)
if (existing == null) {
// INSERT new record
recentShowDao.insert(
RecentShowEntity(
showId = showId,
lastPlayedTimestamp = now,
firstPlayedTimestamp = now,
totalPlayCount = 1
)
)
}
Subsequent Plays:
val existing = recentShowDao.getShowById(showId)
if (existing != null) {
// UPDATE existing record
recentShowDao.updateShow(
showId = showId,
timestamp = now,
playCount = existing.totalPlayCount + 1
)
}
Why UPSERT?
Alternative Approach (rejected):
-- Insert new row on each play
INSERT INTO play_history (show_id, timestamp);
-- Query requires GROUP BY for deduplication
SELECT show_id, MAX(timestamp) as last_played
FROM play_history
GROUP BY show_id
ORDER BY last_played DESC;
Problems with Alternative:
- Unbounded growth (millions of rows over time)
- Complex queries (GROUP BY, aggregations)
- Slower performance (scanning millions of rows)
- Play count requires COUNT(*) aggregation
UPSERT Advantages: - Bounded size (one row per unique show, ~200-2000 rows typical) - Simple queries (no GROUP BY needed) - Fast performance (direct index lookups) - Play count readily available (no aggregation) - Storage efficient
Common Queries
Get Recently Played Shows
SELECT * FROM recent_shows
ORDER BY last_played_timestamp DESC
LIMIT 8;
Performance: Fast via last_played_timestamp DESC index
Usage: Home screen "Recently Played" section
Typical Limit: 8-10 shows for UI display
Check if Show Was Played Recently
SELECT * FROM recent_shows
WHERE show_id = '1977-05-08-...'
AND last_played_timestamp > :cutoff;
Performance: O(1) via PRIMARY KEY index, then simple comparison
Usage: "Continue listening" badge, resume playback
Cutoff Example: System.currentTimeMillis() - (7 * 24 * 60 * 60 * 1000) (7 days)
Get Most Played Shows
SELECT * FROM recent_shows
ORDER BY total_play_count DESC, last_played_timestamp DESC
LIMIT 10;
Performance: Full table scan (no index on play count), but table is small
Usage: "Most played" analytics section
Tie-breaker: last_played_timestamp DESC for consistent ordering
Get Play Count for Show
SELECT total_play_count FROM recent_shows
WHERE show_id = '1977-05-08-...';
Performance: O(1) via PRIMARY KEY index
Usage: Display "You've played this 12 times"
Count Total Recent Shows
SELECT COUNT(*) FROM recent_shows;
Performance: Fast (simple count)
Usage: Analytics, debugging
Shows Played in Time Range
SELECT * FROM recent_shows
WHERE last_played_timestamp BETWEEN :start AND :end
ORDER BY last_played_timestamp DESC;
Performance: Full table scan, but table is small
Usage: "Shows played this week", analytics
Example Range: Last 7 days, last month, etc.
Clear Old Shows (Privacy/Storage)
DELETE FROM recent_shows
WHERE last_played_timestamp < :cutoff;
Performance: Table scan for matching rows
Usage: Privacy settings ("Clear plays older than 90 days")
Returns: Number of deleted rows
Common Operations
Record a Play (UPSERT)
suspend fun recordPlay(showId: String) {
val now = System.currentTimeMillis()
val existing = recentShowDao.getShowById(showId)
if (existing == null) {
// First play - INSERT
recentShowDao.insert(
RecentShowEntity(
showId = showId,
lastPlayedTimestamp = now,
firstPlayedTimestamp = now,
totalPlayCount = 1
)
)
} else {
// Subsequent play - UPDATE
recentShowDao.updateShow(
showId = showId,
timestamp = now,
playCount = existing.totalPlayCount + 1
)
}
}
Remove Show from Recent
suspend fun removeFromRecent(showId: String) {
recentShowDao.removeShow(showId)
}
Usage: User privacy ("Remove from recent"), accidental play cleanup
Clear All Recent Shows
suspend fun clearAllRecent() {
recentShowDao.clearAll()
}
Usage: Privacy settings ("Clear all play history")
Get Recent Shows with Full Show Data
suspend fun getRecentShowsWithDetails(): List<Show> {
val recentEntities = recentShowDao.getRecentShows(limit = 8)
val showIds = recentEntities.map { it.showId }
return showDao.getShowsByIds(showIds)
.sortedByDescending { show ->
// Preserve recency order from recent_shows
recentEntities.find { it.showId == show.showId }?.lastPlayedTimestamp ?: 0
}
}
Why Separate Queries: Recent shows table only has IDs, must JOIN/fetch full show data for display
Meaningful Play Threshold
Not all playback events count as "plays". A meaningful play typically requires:
Criteria (implementation-specific): - Duration threshold: Played for at least 30 seconds - OR Percentage threshold: Played at least 50% of track duration - Track skipping: Rapidly skipping through tracks doesn't count
Why: Prevents accidental taps, previewing, and seeking from inflating play counts.
Implementation Location: Playback service/ViewModel (not in database layer)
Example Logic:
// In playback service
fun onTrackEnded(track: Track, playedDuration: Long) {
val isMeaningfulPlay = playedDuration >= 30_000 ||
(playedDuration.toDouble() / track.duration >= 0.5)
if (isMeaningfulPlay) {
recentShowService.recordPlay(track.showId)
}
}
Reactive Queries
Use Flow for automatic UI updates:
// ViewModel
val recentShows: StateFlow<List<Show>> = recentShowDao.getRecentShowsFlow(limit = 8)
.map { recentEntities ->
// Fetch full show data
val showIds = recentEntities.map { it.showId }
showDao.getShowsByIds(showIds)
}
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
// UI updates automatically when new show is played
Privacy Considerations
Recent plays are sensitive user data. Consider implementing:
Retention Policies:
// Auto-delete plays older than 90 days
suspend fun pruneOldPlays() {
val cutoff = System.currentTimeMillis() - (90 * 24 * 60 * 60 * 1000L)
recentShowDao.deleteOldShows(cutoff)
}
User Controls: - "Clear recent plays" button - "Remove from recent" per-show action - Auto-delete after X days setting
Backup Considerations: - Some users may NOT want recent plays backed up - Consider separate backup flag for privacy-sensitive tables
Denormalization in ShowEntity
Unlike library_shows, there is no denormalization in the shows table for recent plays.
Why No Denormalization: - Recent plays change frequently (every time user plays a show) - Only needed for "Recently played" query (limited use case) - Not worth the sync overhead and potential stale data - Table is small enough that JOIN/fetch is fast
Query Pattern:
// Get recent show IDs, then fetch show data
val recentIds = recentShowDao.getRecentShows().map { it.showId }
val shows = showDao.getShowsByIds(recentIds)
Code Locations
Android
- Entity:
androidApp/v2/core/database/src/main/java/com/deadly/v2/core/database/entities/RecentShowEntity.kt:29 - DAO:
androidApp/v2/core/database/src/main/java/com/deadly/v2/core/database/dao/RecentShowDao.kt:17
iOS
TBD (to be implemented)
Implementation Notes
When to Record Plays
Trigger Points: 1. Track completion - User listened to entire track 2. Meaningful duration - User listened for 30+ seconds 3. Percentage threshold - User listened to 50%+ of track
Do NOT Record: - Rapid skipping through tracks - Accidental taps (< 5 seconds) - Seeking/scrubbing - Background/cached track loading
Implementation:
// In MediaSessionService or ViewModel
private var trackStartTime: Long = 0
private var currentTrackDuration: Long = 0
fun onTrackStarted(track: Track) {
trackStartTime = System.currentTimeMillis()
currentTrackDuration = track.duration
}
fun onTrackEnded() {
val playedDuration = System.currentTimeMillis() - trackStartTime
val isMeaningfulPlay = playedDuration >= 30_000 ||
(playedDuration.toDouble() / currentTrackDuration >= 0.5)
if (isMeaningfulPlay) {
viewModelScope.launch {
recentShowService.recordPlay(currentTrack.showId)
}
}
}
Transaction Safety
UPSERT operations are inherently safe (single-table operation), but wrap in transaction for consistency:
database.withTransaction {
val existing = recentShowDao.getShowById(showId)
if (existing == null) {
recentShowDao.insert(entity)
} else {
recentShowDao.updateShow(showId, timestamp, playCount)
}
}
Room @Upsert Support
Room 2.5+ supports @Upsert annotation (not used in current code):
@Upsert
suspend fun upsert(entity: RecentShowEntity)
// Usage
recentShowDao.upsert(
RecentShowEntity(
showId = showId,
lastPlayedTimestamp = now,
firstPlayedTimestamp = existing?.firstPlayedTimestamp ?: now,
totalPlayCount = (existing?.totalPlayCount ?: 0) + 1
)
)
Current Implementation: Manual getShowById + insert/updateShow pattern
Why: More explicit control over firstPlayedTimestamp preservation
See Also
- ShowEntity - Parent show entity
- Design Philosophy - Why UPSERT pattern
- LibraryShowEntity - Similar user data pattern (but different sync requirements)