Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AppleNugs/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ final class AppModel {
let client: NugsClient
let player: PlayerService
let favorites = FavoritesStore()
let listening = ListeningStatsStore()
let videoProgress = VideoProgressStore()
let video: VideoPlayerService
let downloads: DownloadStore
Expand Down Expand Up @@ -60,6 +61,7 @@ final class AppModel {
downloads = DownloadStore(client: client)
// Local files take precedence over network stream resolution.
player.downloads = downloads
player.listening = listening
}

/// Resolve the persisted session at launch (refreshing the token if it
Expand Down
104 changes: 104 additions & 0 deletions AppleNugs/Core/LedgerSummary.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import Foundation

/// The Home listening ledger's rows and totals, as pure data.
///
/// Takes loose primitives rather than the `ListeningStats` table — the
/// `SignalReadout` idiom, and for the same reason: it stays Foundation-only and
/// compiles straight into the host-free `AppleNugsTests` target. The view does
/// the one-line mapping from the stored dictionary.
///
/// Every string the ledger shows is produced here, so the formatting is pinned
/// by tests rather than by a view body nobody can assert against.
enum LedgerSummary {

/// One artist's totals, already folded. `key` is the stored dictionary key;
/// it is used only for identity and the final tie-break, and is never shown.
struct Tally: Equatable {
var key: String
var displayName: String
var plays: Int
var seconds: Double
}

struct Row: Identifiable, Equatable {
let rank: Int
let key: String
let displayName: String
let playsText: String
let hoursText: String

var id: String { key }
}

struct Totals: Equatable {
let hoursText: String
let artistCountText: String
let artistCount: Int
}

/// The ledger lives inline on Home, so it is capped at what fits there.
static let topCount = 5

/// The section renders nothing at all until at least one play is credited.
/// Seconds alone are not enough: a few stray partial listens are not a
/// ledger, and Home already has a hero and a crate.
static func isEmpty(_ tallies: [Tally]) -> Bool {
!tallies.contains { $0.plays > 0 }
}

/// Two totals rows only. A `Plays` total was considered and cut — every
/// leaderboard row already carries a play count.
static func totals(_ tallies: [Tally]) -> Totals {
let seconds = tallies
.map(\.seconds)
.filter { $0.isFinite && $0 > 0 }
.reduce(0, +)
return Totals(hoursText: totalHoursText(seconds: seconds),
artistCountText: String(tallies.count),
artistCount: tallies.count)
}

/// Top artists by seconds. Ties break by plays, then by name, then by key,
/// so the order never flickers between renders.
static func top(_ tallies: [Tally]) -> [Row] {
tallies
.sorted { a, b in
if a.seconds != b.seconds { return a.seconds > b.seconds }
if a.plays != b.plays { return a.plays > b.plays }
let byName = a.displayName.localizedStandardCompare(b.displayName)
if byName != .orderedSame { return byName == .orderedAscending }
return a.key < b.key
}
.prefix(topCount)
.enumerated()
.map { offset, tally in
Row(rank: offset + 1,
key: tally.key,
displayName: tally.displayName,
playsText: playsText(tally.plays),
hoursText: hoursText(seconds: tally.seconds))
}
}

/// The Hours total: whole hours once there is one, `"<1"` below that. No
/// decimals — this is a ledger, not a stopwatch, and the row label already
/// says "Hours".
static func totalHoursText(seconds: Double) -> String {
guard seconds.isFinite, seconds > 0 else { return "0" }
let hours = seconds / 3600
return hours >= 1 ? String(Int(hours)) : "<1"
}

/// A leaderboard row's duration: whole hours once there is one, else whole
/// minutes, so a short-but-frequent artist never reads "0h".
static func hoursText(seconds: Double) -> String {
guard seconds.isFinite, seconds > 0 else { return "0m" }
let hours = Int(seconds / 3600)
if hours > 0 { return "\(hours)h" }
return "\(max(1, Int(seconds / 60)))m"
}

static func playsText(_ plays: Int) -> String {
plays == 1 ? "1 play" : "\(plays) plays"
}
}
129 changes: 129 additions & 0 deletions AppleNugs/Core/ListeningCredit.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import Foundation

/// The credit policy for listening stats: what one playback tick is worth, and
/// when a track visit has earned a "play".
///
/// This type also HOLDS the per-track accumulator, and that placement is
/// load-bearing. `PlayerService` keeps one of these as an `@ObservationIgnored`
/// property and feeds it from the ~4Hz tick (and, while the iOS app is
/// backgrounded and the tick is off, from playhead spans settled at commit
/// boundaries and the once-a-minute background wake); the store is only told about a
/// batch at a commit boundary — roughly twice a minute, never four times a
/// second. `@Observable` notifies on every set without comparing values, so if
/// ticking seconds landed in the store's observed counters the Home ledger
/// would re-diff at tick rate — precisely the failure mode the dashboard's
/// leaf-view isolation rule exists to prevent.
///
/// Deliberately Foundation-only (no SwiftUI, no AVFoundation, no Observation)
/// and taking loose primitives, so it compiles straight into the host-free
/// `AppleNugsTests` target.
struct ListeningCredit: Equatable {

/// What one commit hands to the store.
struct Batch: Equatable {
var seconds: Double
var completedPlay: Bool

/// Nothing to hand over — the caller should skip the store call entirely.
var isEmpty: Bool { seconds <= 0 && !completedPlay }
}

/// A tick contributes at most one second. A normal tick is 0.25s; a seek, a
/// wake-from-sleep, and a stall each produce one large jump, and the clamp
/// caps all of them without the policy having to detect any of them.
static let maxTickSeconds: Double = 1

/// The ceiling on the play threshold, and the flat threshold for a stream
/// that reports no duration.
static let playSecondsCap: Double = 240

/// How much uncommitted listening forces a commit.
static let commitIntervalSeconds: Double = 30

/// Listened seconds on the current track visit. Reset only by `startTrack()`.
private(set) var trackSeconds: Double = 0

/// Seconds not yet handed to the store.
private(set) var uncommittedSeconds: Double = 0

/// True once this visit has earned its play — the latch that stops one visit
/// earning two.
private(set) var playCredited = false

/// True when an earned play has not yet been handed to the store.
private(set) var uncommittedPlay = false

/// Listened seconds that earn a play: half the track, capped at four
/// minutes. A duration-less stream (livestream, webcast) takes the flat cap
/// — the same treatment the pill's progress ring and all six scrub sliders
/// already give `duration <= 0`.
static func playThreshold(duration: Double) -> Double {
guard duration.isFinite, duration > 0 else { return playSecondsCap }
return min(0.5 * duration, playSecondsCap)
}

/// Fold one playback tick.
///
/// `delta` is playhead movement since the previous tick, which is why it can
/// be negative (a backward seek) or huge (a forward seek, a stall, a wake):
/// both are handled by the clamp, not by special cases. Paused time
/// contributes nothing because the accumulator only runs while playing.
mutating func tick(delta: Double, isPlaying: Bool, duration: Double) {
guard isPlaying, delta.isFinite else { return }
add(min(max(delta, 0), Self.maxTickSeconds), duration: duration)
}

/// Fold a span with no ticks inside it — the iOS app backgrounded, where
/// the tick is off (see `PlayerService.appDidEnterBackground()`).
///
/// `delta` is playhead movement since the last sample and `wallSeconds` the
/// real time that passed meanwhile. A playhead being listened to can't move
/// faster than real time, so the wall clock is the clamp here — what the
/// 1s cap is for a tick: a forward seek inside the span earns at most the
/// time that actually passed. There is no `isPlaying` gate because none is
/// needed: a paused or stalled playhead doesn't move, so its delta is 0.
mutating func span(delta: Double, wallSeconds: Double, duration: Double) {
guard delta.isFinite, wallSeconds.isFinite else { return }
add(min(max(delta, 0), max(wallSeconds, 0)), duration: duration)
}

/// The accumulator and the play latch, shared by both entry points so a
/// visit that mixes ticks and spans still earns exactly one play.
private mutating func add(_ credited: Double, duration: Double) {
guard credited > 0 else { return }

trackSeconds += credited
uncommittedSeconds += credited

if !playCredited, trackSeconds >= Self.playThreshold(duration: duration) {
playCredited = true
uncommittedPlay = true
}
}

/// True when the caller should commit. An earned play commits immediately so
/// the ledger updates when it lands; otherwise a commit is due every 30s.
var isCommitDue: Bool {
uncommittedPlay || uncommittedSeconds >= Self.commitIntervalSeconds
}

/// Hand over everything accumulated since the last commit. Idempotent: a
/// second call with nothing accumulated returns an empty batch.
mutating func flush() -> Batch {
let batch = Batch(seconds: uncommittedSeconds, completedPlay: uncommittedPlay)
uncommittedSeconds = 0
uncommittedPlay = false
return batch
}

/// Begin a new track visit.
///
/// Call this only AFTER flushing, or the finished track's uncommitted
/// seconds are lost.
mutating func startTrack() {
trackSeconds = 0
uncommittedSeconds = 0
uncommittedPlay = false
playCredited = false
}
}
84 changes: 84 additions & 0 deletions AppleNugs/Core/ListeningStats.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import Foundation

/// One artist's all-time tally.
///
/// `displayName` is the spelling last seen in the player and is what the ledger
/// renders; the dictionary key it is filed under is the folded form and is
/// never shown.
struct ArtistTally: Codable, Equatable {
var displayName: String
var plays: Int
var seconds: Double
var firstPlayedAt: Date
var lastPlayedAt: Date
}

/// The whole listening table: rolling aggregates, artists only, all-time.
///
/// There is no event log behind this — nothing here can be re-derived after the
/// fact, and a change to the counting rule can only ever apply going forward.
/// That trade was made deliberately (see
/// `docs/superpowers/specs/2026-08-21-listening-stats-design.md`); the reset in
/// the account menu is the only remedy for polluted data, which is why it is
/// load-bearing rather than a nicety.
///
/// Deliberately Foundation-only so it compiles straight into the host-free
/// `AppleNugsTests` target.
struct ListeningStats: Codable, Equatable {
var artists: [String: ArtistTally]
var version: Int

static let currentVersion = 1
static let empty = ListeningStats(artists: [:], version: currentVersion)

/// The dictionary key for a display name.
///
/// `QueueTrack.artist` is a display name, not an id — no artist id exists
/// anywhere in the playback path — so the key has to be derived from the
/// name. The rule is `DeepLinkMatch.normalize`, the one the venue matcher
/// already uses: apostrophes (ASCII and U+2019) deleted outright, every
/// other non-alphanumeric run collapsed to one space, lowercased and
/// diacritic-folded. Do NOT write a second folding rule.
///
/// Returns nil for a name that folds to nothing, which is how blank and
/// punctuation-only artist names get dropped.
static func key(for displayName: String) -> String? {
let folded = DeepLinkMatch.normalize(displayName)
return folded.isEmpty ? nil : folded
}

/// Fold one commit into the table.
///
/// Pure: the caller supplies `now`, so timestamps are testable. A nil,
/// blank, or unfoldable name is dropped silently — the seam is deliberately
/// forgiving because `QueueTrack.artist` is optional.
///
/// Non-finite and negative seconds contribute zero. That guard is not
/// theoretical: a NaN would fail to encode and leave `listening.json`
/// undecodable on the next launch.
mutating func credit(artistName: String?,
seconds: Double,
completedPlay: Bool,
now: Date) {
guard let artistName else { return }
let trimmed = artistName.trimmingCharacters(in: .whitespacesAndNewlines)
guard let key = Self.key(for: trimmed) else { return }

let addedSeconds = (seconds.isFinite && seconds > 0) ? seconds : 0
guard addedSeconds > 0 || completedPlay else { return }

if var existing = artists[key] {
existing.displayName = trimmed
existing.plays += completedPlay ? 1 : 0
existing.seconds += addedSeconds
existing.lastPlayedAt = now
artists[key] = existing
} else {
artists[key] = ArtistTally(displayName: trimmed,
plays: completedPlay ? 1 : 0,
seconds: addedSeconds,
firstPlayedAt: now,
lastPlayedAt: now)
}
}
}
Loading
Loading