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
27 changes: 27 additions & 0 deletions admin-ui/__specs__/persona-taste-thumbs.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ interface BoothLogEntryFixture {
summary?: string;
id?: number;
personaId?: number | null;
tasteExcluded?: boolean;
}

function makeBoothLogEntry(overrides: BoothLogEntryFixture = {}) {
Expand Down Expand Up @@ -393,6 +394,32 @@ describe("Feature: Persona taste thumbs", () => {
expect(screen.queryByRole("button", { name: /Taste (up|down) for/ })).not.toBeInTheDocument();
});

it("offers no taste thumb on a safe-content row, even a persona-stamped one (gh-#99)", async () => {
installBoothLogFetchMock(
defaultBoothLogState({
head: ok({
entries: [
makeBoothLogEntry({
id: 12,
personaId: 7,
tasteExcluded: true,
summary: "Started 'Please Stand By (Station Default)'",
}),
],
nextBefore: null,
}),
})
);

renderBoothLog();
await flush();

expect(screen.getByText("Started 'Please Stand By (Station Default)'")).toBeInTheDocument();
// Safe-loop tracks and station IDs never accrue taste β€” same no-control-not-disabled
// posture as the unstamped row above; the endpoint refuses the write independently.
expect(screen.queryByRole("button", { name: /Taste (up|down) for/ })).not.toBeInTheDocument();
});

it("disables the tapped direction after recording (idempotency affordance)", async () => {
installBoothLogFetchMock(defaultBoothLogState());

Expand Down
4 changes: 3 additions & 1 deletion admin-ui/app/(authed)/booth-log/BoothLogFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ interface BoothLogFeedProps {
* disabled one. `typeof` rather than `!== null` deliberately covers a row that omits the field
* entirely (predates the column) the same way it covers an explicit `null`. */
function isThumbable(entry: BoothLogEntry): entry is BoothLogEntry & { personaId: number } {
return entry.kind === "track-started" && typeof entry.personaId === "number";
// gh-#99: a safe-scope airing (safe-loop track, station ID) offers no thumb control either β€”
// same no-control-not-disabled posture as the persona gate below (the endpoint refuses it too).
return entry.kind === "track-started" && typeof entry.personaId === "number" && entry.tasteExcluded !== true;
}

/** Human copy for the three narrative kinds this feed's writer produces (SPEC F72.1,
Expand Down
13 changes: 8 additions & 5 deletions admin-ui/app/(authed)/catalog/CatalogTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -324,11 +324,14 @@ export function CatalogTable({
<td className="py-2 pr-3">
<div className="flex items-center gap-2">
{neverPlay && <NeverPlayBadge />}
<NeverPlayControl
mediaId={item.mediaId}
neverPlay={neverPlay}
onChange={(next) => handleNeverPlayChange(item.mediaId, next)}
/>
{/* gh-#99: safe-scope rows (rateable: false) get no control, not a disabled one */}
{item.rateable !== false && (
<NeverPlayControl
mediaId={item.mediaId}
neverPlay={neverPlay}
onChange={(next) => handleNeverPlayChange(item.mediaId, next)}
/>
)}
</div>
</td>
<td className="py-2 pr-3 text-right tabular-nums text-ink">{formatDuration(item.durationMs)}</td>
Expand Down
4 changes: 4 additions & 0 deletions admin-ui/app/(authed)/catalog/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ export interface AdminMediaDto {
* nullable) so pre-T80 `AdminMediaDto` object literals across the test suite keep compiling
* unchanged, mirroring the `artistExact`/`genresExact` precedent on {@link BulkFilter}. */
moods?: string[] | null;
/** gh-#99 β€” `false` for safe-scope content (safe-loop tracks, station IDs): render NO
* never-play control at all, not a disabled one (the write endpoint refuses it regardless).
* Optional so pre-#99 object literals keep compiling; absent means rateable. */
rateable?: boolean;
}

/** Parsed `X-Pagination: total=…,pages=…,page=…,limit=…` header. */
Expand Down
6 changes: 4 additions & 2 deletions admin-ui/app/(authed)/live/LiveView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import type { ReactNode } from "react";
import { usePoll } from "@/lib/use-poll";
import { fetchNowPlaying, fetchPlayHistory, isCatalogMediaId } from "@/lib/broadcast-api";
import { fetchNowPlaying, fetchPlayHistory, isCatalogMediaId, isRateable } from "@/lib/broadcast-api";
import { personaNameOrFallback, usePersonaDirectory } from "@/lib/use-persona-directory";
import { NowPlayingCard } from "../_components/NowPlayingCard";
import { PersonaTasteThumbs } from "../_components/PersonaTasteThumbs";
Expand Down Expand Up @@ -67,8 +67,10 @@ export function LiveView({ timeZone }: LiveViewProps = {}): ReactNode {

const { ratings, applyRating } = useLiveRatings(visibleIds);

// gh-#99: safe-scope content (safe-loop tracks, station IDs) renders no rating control at all β€”
// the server stamps `rateable: false` on the batch ratings read and refuses the write regardless.
const nowPlayingRatingControls =
nowPlayingMediaId !== null ? (
nowPlayingMediaId !== null && isRateable(ratings.get(nowPlayingMediaId)) ? (
<RatingControls
mediaId={nowPlayingMediaId}
value={ratings.get(nowPlayingMediaId) ?? DEFAULT_RATING}
Expand Down
5 changes: 3 additions & 2 deletions admin-ui/app/(authed)/live/PlayHistoryTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { ReactNode } from "react";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { formatClockTime, formatDurationCell } from "@/lib/format-clock";
import { isCatalogMediaId, type PlayHistoryEntry, type RatingEntry } from "@/lib/broadcast-api";
import { isCatalogMediaId, isRateable, type PlayHistoryEntry, type RatingEntry } from "@/lib/broadcast-api";
import { RatingControls, type RatingControlsValue } from "../_components/RatingControls";
import { DEFAULT_RATING } from "./useLiveRatings";

Expand Down Expand Up @@ -129,7 +129,8 @@ export function PlayHistoryTable({
{formatDurationCell(entry.durationMs)}
</td>
<td className="py-2">
{isCatalogMediaId(entry.mediaId) && (
{/* gh-#99: safe-scope rows (rateable: false) get no control, not a disabled one */}
{isCatalogMediaId(entry.mediaId) && isRateable(ratings.get(entry.mediaId)) && (
<RatingControls
mediaId={entry.mediaId}
value={ratings.get(entry.mediaId) ?? DEFAULT_RATING}
Expand Down
3 changes: 3 additions & 0 deletions admin-ui/app/(authed)/live/useNowPlayingTasteAttribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ function latestTrackStartAttribution(page: BoothLogPage | null): NowPlayingTaste
if (page === null || !Array.isArray(page.entries)) return null;
const row = page.entries.find((entry) => entry.kind === "track-started" && typeof entry.personaId === "number");
if (row === undefined || typeof row.personaId !== "number") return null;
// gh-#99: a safe-scope airing (safe-loop track, station ID) resolves to null β€” no taste thumbs
// on the Live card, the same no-control posture the booth-log feed applies to the same row.
if (row.tasteExcluded === true) return null;
return { boothLogRowId: row.id, personaId: row.personaId, pick: row.pick };
}

Expand Down
4 changes: 4 additions & 0 deletions admin-ui/lib/booth-log-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ export interface BoothLogEntry {
* predate the column, mirroring `BoothLogEntryDto.Pick`'s `JsonIgnore(WhenWritingNull)`
* discipline. Feeds `PickChips` directly. */
pick?: BoothLogPick;
/** gh-#99 β€” `true` for a track-start row whose media is safe-scope content (safe-loop tracks,
* station IDs): render NO taste thumbs regardless of `personaId`. Optional so an older API
* shape keeps the pre-#99 behavior. */
tasteExcluded?: boolean;
}

/** One newest-first keyset page (SPEC F72.2) β€” `nextBefore` is `null` once this is the oldest page. */
Expand Down
11 changes: 11 additions & 0 deletions admin-ui/lib/broadcast-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ export interface RatingEntry {
mediaId: string;
score: number;
neverPlay: boolean;
/** gh-#99 β€” `false` for safe-scope content (safe-loop tracks, station IDs): render NO vote or
* never-play control at all, not a disabled one. Optional so a cached/older API shape (absent
* field) keeps the pre-#99 behavior: everything rateable. */
rateable?: boolean;
}

/** gh-#99 β€” the one gate every rating surface shares: an entry is rateable unless the server
* said otherwise. `undefined` (no entry fetched yet, or an older API) stays rateable β€” the write
* endpoints refuse safe content independently, so this is presentation, not enforcement. */
export function isRateable(entry: RatingEntry | undefined): boolean {
return entry?.rateable !== false;
}

export type VoteDirection = "up" | "down";
Expand Down
8 changes: 7 additions & 1 deletion db/06-station-settings-migration.sh
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,13 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-'
-- column β€” never backfilled (F84.6 precedent). Scores, pool size, and degradation step are
-- deliberately NOT stored β€” those rename with ranker tuning; the F82.6 debug log line remains
-- their one durable-enough record.
pick jsonb
pick jsonb,
-- gh-#99: the aired catalog row's numeric library.media id, captured at publish time the same
-- way as persona_id/artist above. NULL for every non-track row, a non-catalog id, or a row that
-- predates this column. Deliberately NO foreign key β€” library.media lives on the other side of
-- the schema-role boundary (station_svc has no grant there); the Host resolves safe-scope
-- membership for the taste-thumb exclusion via the library connection instead.
media_id bigint
);

-- Keyset paging spine (SPEC F72.2): newest-first (occurred_at DESC, id DESC) with no OFFSET β€”
Expand Down
22 changes: 22 additions & 0 deletions db/22-booth-log-media-id-migration.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/bin/bash
# 22-booth-log-media-id-migration.sh β€” idempotent in-place upgrade for existing DBs.
# Adds station.booth_log.media_id β€” introduced in gh-#99 (safe-content taste exclusion): the aired
# catalog row's numeric library.media id, captured by BoothLogWriter.Publish at air time, same
# discipline persona_id (db/17), artist (db/18), and pick (db/21) already established for this
# table. NULL for every non-track row, a non-catalog id (e.g. tts:*), or a row that predates this
# column β€” never backfilled. Deliberately NO foreign key: library.media lives on the other side of
# the schema-role boundary (station_svc has no grant there); the Host resolves safe-scope
# membership for the taste-thumb exclusion via the library connection instead.
# Safe to run multiple times (ADD COLUMN IF NOT EXISTS). Run this script once against any DB
# initialised before 06-station-settings-migration.sh received station.booth_log.media_id.
set -euo pipefail

: "${POSTGRES_USER:?POSTGRES_USER must be set}" "${POSTGRES_DB:?POSTGRES_DB must be set}"

psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-'SQL'
set role station_svc;
set search_path = station;

alter table station.booth_log
add column if not exists media_id bigint;
SQL
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using GenWave.Core.Domain;

namespace GenWave.Core.Abstractions;

/// <summary>
/// gh-#99 β€” the one question the taste-thumb and booth-log surfaces need answered across the
/// station/library schema boundary: which of these media ids live in the given libraries?
/// <c>station.booth_log</c> rows are read by the station role, which deliberately has no grant on
/// <c>library.media</c> β€” so safe-content membership is resolved through this seam on the library
/// connection instead of a cross-schema SQL join, and the Host composes the two.
///
/// Deliberately its own narrow interface rather than a new <see cref="IMediaCatalog"/> member:
/// every existing catalog fake keeps compiling, and this seam's one consumer concern (exclusion
/// checks) never grows read-amplification temptations.
/// </summary>
public interface IMediaLibraryMembership
{
/// <summary>
/// Returns the subset of <paramref name="mediaIds"/> whose row's <c>library_id</c> falls in
/// <paramref name="libraries"/>. Unknown ids are simply absent from the result β€” never an error.
/// An empty <paramref name="libraries"/> scope returns the empty set without touching the
/// database.
/// </summary>
Task<IReadOnlySet<long>> FilterToLibrariesAsync(
IReadOnlyCollection<long> mediaIds, LibraryScope libraries, CancellationToken ct);
}
23 changes: 23 additions & 0 deletions src/GenWave.Abstractions/Abstractions/ISafeScopeProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using GenWave.Core.Domain;

namespace GenWave.Core.Abstractions;

/// <summary>
/// gh-#99 β€” the safe-content scope accessor: which libraries hold the station's functional audio
/// (the seeded safe loop, authored safe segments, station IDs) rather than rateable music. The
/// mirror of <see cref="IStationScopeProvider"/> for <c>Station:SafeScope:LibraryIds</c>, and the
/// same contract: implementations MUST re-evaluate <see cref="Current"/> on every read β€” never cache
/// the result in a field β€” so a live SafeScope edit is visible to the very next exclusion check.
///
/// <para>
/// Consumers use this to EXCLUDE safe-scope rows from taste surfaces (F33 votes/never-play, F84
/// taste thumbs): ranking a "Please Stand By" loop or a station ID is never meaningful, and a
/// never-play write against the safe loop could silence the never-silent fallback itself. An empty
/// safe scope excludes nothing β€” the pre-#99 behavior.
/// </para>
/// </summary>
public interface ISafeScopeProvider
{
/// <summary>The station's current safe-content scope, evaluated fresh on every call.</summary>
LibraryScope Current { get; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ public sealed record BoothLogFiredRuleSummary(string Summary, double Weight)
/// not a debug-log token.
/// </summary>
public static BoothLogFiredRuleSummary FromTasteRule(TasteRule rule) =>
new(rule.Predicate.Artist ?? rule.Predicate.Genre ?? rule.Predicate.Tag ?? "this pick", rule.Weight);
new(rule.Predicate.LabelOr("this pick"), rule.Weight);
}
6 changes: 5 additions & 1 deletion src/GenWave.Abstractions/Domain/MediaRating.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,9 @@ namespace GenWave.Core.Domain;
/// (SPEC F33.2, F33.9). A media id with no row in <c>library.media_rating</c> resolves to the ledger
/// default (<see cref="Score"/> 50, <see cref="NeverPlay"/> false) β€” there is no backfill, so "never
/// rated" and "explicitly rated 50/playable" are indistinguishable by design.
///
/// <see cref="Rateable"/> (gh-#99) is false for a row living in a <c>Station:SafeScope:LibraryIds</c>
/// library β€” safe-loop tracks and station IDs β€” telling every rating surface to render no vote or
/// never-play control at all; the write endpoints independently refuse such rows regardless.
/// </summary>
public sealed record MediaRating(string MediaId, int Score, bool NeverPlay);
public sealed record MediaRating(string MediaId, int Score, bool NeverPlay, bool Rateable = true);
12 changes: 10 additions & 2 deletions src/GenWave.Abstractions/Domain/RatingWriteResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ namespace GenWave.Core.Domain;
/// <summary>
/// Shared outcome of a single-row rating write (SPEC F33.3, F33.4; STORY-109). Deliberately smaller
/// than <see cref="MediaWriteResult"/>: <see cref="Abstractions.IMediaRating"/> writes are never
/// scope-gated (F33.5) and carry no <c>expectedVersion</c> to conflict on (no <c>If-Match</c>
/// anywhere in this seam), so the only failure mode a vote or never-play set can hit is a missing row.
/// MAIN-scope-gated (F33.5) and carry no <c>expectedVersion</c> to conflict on (no <c>If-Match</c>
/// anywhere in this seam), so a vote or never-play set can only fail on a missing row β€” or, since
/// gh-#99, on targeting safe-scope content.
/// </summary>
public enum RatingWriteResult
{
Expand All @@ -13,4 +14,11 @@ public enum RatingWriteResult

/// <summary>No row with the given media id exists in <c>library.media</c>.</summary>
NotFound,

/// <summary>
/// gh-#99 β€” the row exists but lives in a <c>Station:SafeScope:LibraryIds</c> library: safe-loop
/// tracks and station IDs are functional audio, never rateable, and a never-play write against
/// them could silence the never-silent fallback itself. Nothing was written.
/// </summary>
SafeContentExcluded,
}
12 changes: 11 additions & 1 deletion src/GenWave.Abstractions/Domain/TastePredicate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,14 @@ namespace GenWave.Core.Domain;
/// Case-insensitive comparison against catalog data is the ranker's concern (F82.5, a later task);
/// this shape only carries what the rule is about.
/// </summary>
public sealed record TastePredicate(string? Artist, string? Genre, string? Tag);
public sealed record TastePredicate(string? Artist, string? Genre, string? Tag)
{
/// <summary>
/// The one home of the display-label precedence (gh-#89): the most specific non-null field
/// names the rule β€” artist over genre over tag β€” falling back to <paramref name="fallback"/>
/// for the match-anything predicate. Callers keep their own surface-appropriate fallback wording
/// ("this pick" in prose, "any" in the debug log, "any track" in the taste table) β€” that
/// divergence is deliberate and documented at each call site; the precedence itself is not.
/// </summary>
public string LabelOr(string fallback) => Artist ?? Genre ?? Tag ?? fallback;
}
6 changes: 5 additions & 1 deletion src/GenWave.Core/Abstractions/IBoothLogAppender.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ public interface IBoothLogAppender
/// that same track's persona-pick stamp β€” the caller's already-serialized jsonb text (see
/// <c>GenWave.Core.Domain.BoothLogPickStampSerializer</c>), or <see langword="null"/> for every
/// non-track row, an engine-initiated play, or a persona-off pick. Never backfilled.
/// <paramref name="mediaId"/> (gh-#99) is the aired row's numeric catalog id β€” captured the same
/// way, <see langword="null"/> for every non-track row or a non-catalog id. It exists so the
/// Host can resolve safe-scope membership for the taste-thumb exclusion on the library
/// connection; <c>station.booth_log</c> itself can never join <c>library.media</c>.
/// </summary>
Task AppendAsync(string kind, string summary, long? personaId, string? artist, string? pick, CancellationToken ct);
Task AppendAsync(string kind, string summary, long? personaId, string? artist, string? pick, long? mediaId, CancellationToken ct);
}
8 changes: 8 additions & 0 deletions src/GenWave.Core/Abstractions/IBoothLogReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,12 @@ public interface IBoothLogReader
/// (<see langword="null"/> = the newest page), up to <paramref name="take"/> rows.
/// </summary>
Task<BoothLogPage> ReadAsync(BoothLogCursor? before, int take, CancellationToken ct);

/// <summary>
/// gh-#99 β€” the stamped catalog media id of booth-log row <paramref name="id"/>:
/// <see langword="null"/> for a missing row, a non-track row, or a row that predates the
/// <c>media_id</c> column. The taste-thumb endpoint resolves this first, checks safe-scope
/// membership on the library connection, and only then lets the accrual write proceed.
/// </summary>
Task<long?> GetMediaIdAsync(long id, CancellationToken ct);
}
3 changes: 2 additions & 1 deletion src/GenWave.Core/Domain/AdminMediaDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,5 @@ public sealed record AdminMediaDto(
bool NeverPlay = false,
double? Bpm = null,
double? TrackEnergy = null,
IReadOnlyList<string>? Moods = null);
IReadOnlyList<string>? Moods = null,
bool Rateable = true);
3 changes: 2 additions & 1 deletion src/GenWave.Core/Domain/BoothLogEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ namespace GenWave.Core.Domain;
/// engine-initiated play, a persona-off pick, or a row that predates the column.
/// </summary>
public sealed record BoothLogEntry(
long Id, DateTime OccurredAt, string Kind, string Summary, long? PersonaId = null, string? Pick = null);
long Id, DateTime OccurredAt, string Kind, string Summary, long? PersonaId = null, string? Pick = null,
long? MediaId = null);
Loading
Loading