Feature test 01

This commit is contained in:
Flatlogic Bot 2026-04-16 04:12:06 +00:00
parent f9455c1a31
commit 2650d8e475
6 changed files with 1035 additions and 7 deletions

0
.perm_test_apache Normal file
View File

0
.perm_test_exec Normal file
View File

View File

@ -395,6 +395,11 @@ router.get('/autocomplete', async (req, res) => {
res.status(200).send(payload);
});
router.get('/song-match', wrapAsync(async (req, res) => {
const payload = await Station_logsService.findSongMatches(req.query);
res.status(200).send(payload);
}));
/**
* @swagger
* /api/station_logs/{id}:

View File

@ -3,13 +3,110 @@ const Station_logsDBApi = require('../db/api/station_logs');
const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
const axios = require('axios');
const config = require('../config');
const stream = require('stream');
const { Op } = db.Sequelize;
const FM_MIN = 88.1;
const FM_MAX = 102.0;
const DEFAULT_MATCH_LIMIT = 12;
const MAX_MATCH_LIMIT = 50;
const DEFAULT_LOOKBACK_HOURS = 12;
const MAX_LOOKBACK_HOURS = 72;
const frequencyPattern =
/(?:^|[^\d])((?:88\.[1-9])|(?:89\.\d)|(?:9\d\.\d)|(?:10[0-1]\.\d)|(?:102\.0))(?:\s?(?:fm))?(?=$|[^\d])/i;
const SUPPORTED_AREAS = Object.freeze({
greater_vancouver: {
label: 'Greater Vancouver',
terms: [
'greater vancouver',
'metro vancouver',
'vancouver',
'burnaby',
'richmond',
'new westminster',
'north vancouver',
'west vancouver',
'coquitlam',
'port coquitlam',
'port moody',
'delta',
],
},
surrey: {
label: 'Surrey',
terms: ['surrey', 'cloverdale', 'fleetwood', 'guildford', 'newton', 'whalley'],
},
vancouver_island: {
label: 'Vancouver Island',
terms: [
'vancouver island',
'victoria',
'nanaimo',
'duncan',
'langford',
'sidney',
'courtenay',
'comox',
'campbell river',
'port alberni',
'parksville',
],
},
toronto: {
label: 'Toronto',
terms: ['toronto', 'scarborough', 'etobicoke', 'north york', 'downtown toronto'],
},
});
function toSearchText(...values) {
return values
.flatMap((value) => (Array.isArray(value) ? value : [value]))
.filter(Boolean)
.join(' ')
.toLowerCase();
}
function extractFrequency(...values) {
for (const value of values) {
if (!value) {
continue;
}
const match = `${value}`.match(frequencyPattern);
if (!match?.[1]) {
continue;
}
const frequency = Number.parseFloat(match[1]);
if (Number.isFinite(frequency) && frequency >= FM_MIN && frequency <= FM_MAX) {
return frequency;
}
}
return null;
}
function getAreaOptions() {
return Object.entries(SUPPORTED_AREAS).map(([value, config]) => ({
value,
label: config.label,
}));
}
function matchesArea(areaConfig, stationLog) {
const searchText = toSearchText(
stationLog.city,
stationLog.stream_title_raw,
stationLog.source_stream_url,
stationLog.station?.name,
stationLog.station?.city,
stationLog.station?.state,
stationLog.station?.homepage,
stationLog.station?.stream_url,
);
return areaConfig.terms.some((term) => searchText.includes(term));
}
module.exports = class Station_logsService {
static async create(data, currentUser) {
@ -28,9 +125,9 @@ module.exports = class Station_logsService {
await transaction.rollback();
throw error;
}
};
}
static async bulkImport(req, res, sendInvitationEmails = true, host) {
static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction();
try {
@ -95,7 +192,7 @@ module.exports = class Station_logsService {
await transaction.rollback();
throw error;
}
};
}
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
@ -132,7 +229,121 @@ module.exports = class Station_logsService {
}
}
static async findSongMatches(filters) {
const areaKey = `${filters?.area || ''}`.trim();
const areaConfig = SUPPORTED_AREAS[areaKey];
if (!areaConfig) {
throw new ValidationError('station_logsAreaUnsupported');
}
const song = `${filters?.song || filters?.query || ''}`.trim();
const artist = `${filters?.artist || ''}`.trim();
if (!song && !artist) {
throw new ValidationError('station_logsSongMatchQueryRequired');
}
const requestedLimit = Number.parseInt(filters?.limit, 10);
const requestedLookbackHours = Number.parseInt(filters?.lookbackHours || filters?.hours, 10);
const limit = Number.isFinite(requestedLimit)
? Math.min(Math.max(requestedLimit, 1), MAX_MATCH_LIMIT)
: DEFAULT_MATCH_LIMIT;
const lookbackHours = Number.isFinite(requestedLookbackHours)
? Math.min(Math.max(requestedLookbackHours, 1), MAX_LOOKBACK_HOURS)
: DEFAULT_LOOKBACK_HOURS;
const playedAfter = new Date(Date.now() - lookbackHours * 60 * 60 * 1000);
const andConditions = [{ played_at: { [Op.gte]: playedAfter } }];
if (song) {
andConditions.push({
[Op.or]: [
{ song: { [Op.iLike]: `%${song}%` } },
{ stream_title_raw: { [Op.iLike]: `%${song}%` } },
],
});
}
if (artist) {
andConditions.push({
[Op.or]: [
{ artist: { [Op.iLike]: `%${artist}%` } },
{ stream_title_raw: { [Op.iLike]: `%${artist}%` } },
],
});
}
const rows = await db.station_logs.findAll({
where: { [Op.and]: andConditions },
include: [
{
model: db.stations,
as: 'station',
required: false,
},
],
order: [['played_at', 'desc']],
limit: Math.max(limit * 8, 40),
});
const matches = [];
const seenStations = new Set();
for (const row of rows) {
const stationLog = row.get({ plain: true });
if (!matchesArea(areaConfig, stationLog)) {
continue;
}
const frequency = extractFrequency(
stationLog.station?.name,
stationLog.station?.homepage,
stationLog.station?.stream_url,
stationLog.stream_title_raw,
);
if (!frequency) {
continue;
}
const stationKey = stationLog.station?.id || stationLog.station?.station_uuid || stationLog.source_stream_url;
if (!stationKey || seenStations.has(stationKey)) {
continue;
}
seenStations.add(stationKey);
matches.push({
id: stationKey,
stationId: stationLog.station?.id || null,
stationName: stationLog.station?.name || stationLog.city || 'Unknown station',
city: stationLog.station?.city || stationLog.city || null,
state: stationLog.station?.state || null,
country: stationLog.station?.country || 'Canada',
frequency,
song: stationLog.song || null,
artist: stationLog.artist || null,
playedAt: stationLog.played_at || null,
streamTitleRaw: stationLog.stream_title_raw || null,
streamUrl: stationLog.station?.stream_url || stationLog.source_stream_url || null,
area: areaConfig.label,
});
if (matches.length >= limit) {
break;
}
}
return {
rows: matches,
count: matches.length,
area: areaConfig.label,
lookbackHours,
supportedAreas: getAreaOptions(),
};
}
};

View File

@ -7,6 +7,11 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
},
{
href: '/radio',
label: 'Radio',
icon: icon.mdiRadio,
},
{
href: '/users/users-list',
@ -120,4 +125,4 @@ const menuAside: MenuAsideItem[] = [
},
]
export default menuAside
export default menuAside

View File

@ -0,0 +1,807 @@
import { mdiMagnify, mdiMapMarker, mdiMusicNote, mdiPlay, mdiRadio, mdiStop } from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import React, { ReactElement, useEffect, useMemo, useRef, useState } from 'react';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import LayoutAuthenticated from '../layouts/Authenticated';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import { getPageTitle } from '../config';
type RadioBrowserStation = {
stationuuid: string;
name: string;
country: string;
countrycode?: string;
state?: string;
homepage?: string;
tags?: string;
bitrate?: number;
codec?: string;
url?: string;
url_resolved?: string;
};
type StationWithFrequency = RadioBrowserStation & {
frequency: number;
};
type SongSearchResult = {
trackId: number;
trackName: string;
artistName: string;
collectionName?: string;
artworkUrl100?: string;
primaryGenreName?: string;
releaseDate?: string;
};
type StationMatchResult = {
id: string;
stationId?: string | null;
stationName: string;
city?: string | null;
state?: string | null;
country?: string | null;
frequency: number;
song?: string | null;
artist?: string | null;
playedAt?: string | null;
streamTitleRaw?: string | null;
streamUrl?: string | null;
area: string;
};
type RadioMarket = {
value: string;
label: string;
terms: string[];
};
const RADIO_BROWSER_BASE_URL = 'https://de1.api.radio-browser.info/json';
const CANADA = 'Canada';
const FM_MIN = 88.1;
const FM_MAX = 102.0;
const MAX_STATIONS = 36;
const MAX_SONG_RESULTS = 8;
const SONG_MATCH_LOOKBACK_HOURS = 24;
const DEFAULT_MARKET = 'greater_vancouver';
const collator = new Intl.Collator('en', { sensitivity: 'base' });
const frequencyPattern =
/(?:^|[^\d])((?:88\.[1-9])|(?:89\.\d)|(?:9\d\.\d)|(?:10[0-1]\.\d)|(?:102\.0))(?:\s?(?:fm))?(?=$|[^\d])/i;
const RADIO_MARKETS: RadioMarket[] = [
{
value: 'greater_vancouver',
label: 'Greater Vancouver',
terms: [
'greater vancouver',
'metro vancouver',
'vancouver',
'burnaby',
'richmond',
'new westminster',
'north vancouver',
'west vancouver',
'coquitlam',
'port coquitlam',
'port moody',
'delta',
],
},
{
value: 'surrey',
label: 'Surrey',
terms: ['surrey', 'cloverdale', 'fleetwood', 'guildford', 'newton', 'whalley'],
},
{
value: 'vancouver_island',
label: 'Vancouver Island',
terms: [
'vancouver island',
'victoria',
'nanaimo',
'duncan',
'langford',
'sidney',
'courtenay',
'comox',
'campbell river',
'port alberni',
'parksville',
],
},
{
value: 'toronto',
label: 'Toronto',
terms: ['toronto', 'scarborough', 'etobicoke', 'north york', 'downtown toronto'],
},
];
function extractFrequency(...values: Array<string | undefined>) {
for (const value of values) {
if (!value) {
continue;
}
const match = value.match(frequencyPattern);
if (!match?.[1]) {
continue;
}
const frequency = Number.parseFloat(match[1]);
if (Number.isFinite(frequency) && frequency >= FM_MIN && frequency <= FM_MAX) {
return frequency;
}
}
return null;
}
function matchesMarket(station: RadioBrowserStation, market: RadioMarket) {
const searchText = [
station.name,
station.state,
station.tags,
station.homepage,
station.url_resolved,
station.url,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return market.terms.some((term) => searchText.includes(term));
}
function normalizeStations(stations: RadioBrowserStation[], market: RadioMarket) {
return stations
.map((station) => {
const frequency = extractFrequency(
station.name,
station.tags,
station.homepage,
station.url_resolved,
station.url,
);
if (!frequency || !station.url_resolved || !matchesMarket(station, market)) {
return null;
}
return {
...station,
frequency,
} as StationWithFrequency;
})
.filter((station): station is StationWithFrequency => Boolean(station))
.sort((left, right) => {
if (left.frequency !== right.frequency) {
return left.frequency - right.frequency;
}
return collator.compare(left.name, right.name);
})
.slice(0, MAX_STATIONS);
}
const RadioPage = () => {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [selectedMarket, setSelectedMarket] = useState<string>(DEFAULT_MARKET);
const [stations, setStations] = useState<StationWithFrequency[]>([]);
const [isStationsLoading, setIsStationsLoading] = useState<boolean>(false);
const [stationsErrorMessage, setStationsErrorMessage] = useState<string>('');
const [playingStation, setPlayingStation] = useState<StationWithFrequency | null>(null);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [songQuery, setSongQuery] = useState<string>('');
const [songResults, setSongResults] = useState<SongSearchResult[]>([]);
const [isSongSearchLoading, setIsSongSearchLoading] = useState<boolean>(false);
const [songSearchErrorMessage, setSongSearchErrorMessage] = useState<string>('');
const [selectedSong, setSelectedSong] = useState<SongSearchResult | null>(null);
const [stationMatches, setStationMatches] = useState<StationMatchResult[]>([]);
const [isSongMatchLoading, setIsSongMatchLoading] = useState<boolean>(false);
const [songMatchErrorMessage, setSongMatchErrorMessage] = useState<string>('');
const market = useMemo(
() => RADIO_MARKETS.find((radioMarket) => radioMarket.value === selectedMarket) ?? RADIO_MARKETS[0],
[selectedMarket],
);
const nowPlayingLabel = useMemo(() => {
if (!playingStation) {
return '--.-';
}
return `${playingStation.frequency.toFixed(1)} FM`;
}, [playingStation]);
const playedAtFormatter = useMemo(
() =>
new Intl.DateTimeFormat('en-CA', {
dateStyle: 'medium',
timeStyle: 'short',
}),
[],
);
const handleStop = () => {
if (!audioRef.current) {
return;
}
audioRef.current.pause();
audioRef.current.removeAttribute('src');
audioRef.current.load();
setIsPlaying(false);
setPlayingStation(null);
};
const handlePlay = async (station: StationWithFrequency) => {
if (!audioRef.current || !station.url_resolved) {
return;
}
try {
audioRef.current.src = station.url_resolved;
audioRef.current.load();
await audioRef.current.play();
setPlayingStation(station);
setIsPlaying(true);
} catch (error) {
console.error('Failed to start radio stream:', error);
setStationsErrorMessage(`Could not start ${station.name}. Please try a different station.`);
}
};
useEffect(() => {
handleStop();
}, [selectedMarket]);
useEffect(() => {
let isMounted = true;
const fetchStations = async () => {
try {
setStationsErrorMessage('');
setIsStationsLoading(true);
const params = new URLSearchParams({
tag: 'fm',
country: CANADA,
hidebroken: 'true',
limit: '250',
order: 'clickcount',
reverse: 'true',
});
const response = await fetch(`${RADIO_BROWSER_BASE_URL}/stations/search?${params.toString()}`);
const data = (await response.json()) as RadioBrowserStation[];
if (!isMounted || !Array.isArray(data)) {
return;
}
setStations(normalizeStations(data, market));
} catch (error) {
console.error('Failed to load area stations:', error);
if (isMounted) {
setStations([]);
setStationsErrorMessage('Could not load the radio dial right now. Please try again in a moment.');
}
} finally {
if (isMounted) {
setIsStationsLoading(false);
}
}
};
void fetchStations();
return () => {
isMounted = false;
};
}, [market]);
const runSongSearch = async () => {
const trimmedQuery = songQuery.trim();
if (!trimmedQuery) {
setSongSearchErrorMessage('Type a song title, lyric clue, or artist to start SongCatch search.');
setSongResults([]);
return;
}
try {
setIsSongSearchLoading(true);
setSongSearchErrorMessage('');
setSelectedSong(null);
setStationMatches([]);
setSongMatchErrorMessage('');
const params = new URLSearchParams({
term: trimmedQuery,
media: 'music',
entity: 'song',
country: 'CA',
limit: `${MAX_SONG_RESULTS}`,
});
const response = await fetch(`https://itunes.apple.com/search?${params.toString()}`);
const data = (await response.json()) as { results?: SongSearchResult[] };
const results = Array.isArray(data.results)
? data.results.filter((result) => result.trackId && result.trackName && result.artistName)
: [];
setSongResults(results);
if (results.length === 0) {
setSongSearchErrorMessage('No song results came back. Try a more specific title or artist.');
}
} catch (error) {
console.error('Song search failed:', error);
setSongResults([]);
setSongSearchErrorMessage('SongCatch search could not reach the music catalog right now.');
} finally {
setIsSongSearchLoading(false);
}
};
const findStationsPlayingSong = async (song: SongSearchResult) => {
try {
setSelectedSong(song);
setStationMatches([]);
setSongMatchErrorMessage('');
setIsSongMatchLoading(true);
const response = await axios.get('/station_logs/song-match', {
params: {
area: market.value,
song: song.trackName,
artist: song.artistName,
lookbackHours: SONG_MATCH_LOOKBACK_HOURS,
limit: 10,
},
});
const rows = Array.isArray(response.data?.rows) ? (response.data.rows as StationMatchResult[]) : [];
setStationMatches(rows);
if (rows.length === 0) {
setSongMatchErrorMessage(
`No recent ${market.label} station logs matched this song in the last ${SONG_MATCH_LOOKBACK_HOURS} hours.`,
);
}
} catch (error) {
console.error('Station match lookup failed:', error);
setStationMatches([]);
setSongMatchErrorMessage('We could not search your station logs right now.');
} finally {
setIsSongMatchLoading(false);
}
};
return (
<>
<Head>
<title>{getPageTitle('Radio')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiRadio} title="SongCatch Radio" main>
<div className="text-xs uppercase tracking-[0.25em] text-emerald-400">Dark market-limited radio</div>
</SectionTitleLineWithButton>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.55fr)_minmax(320px,0.95fr)]">
<div className="space-y-6">
<CardBox className="border border-slate-700 bg-slate-950 text-slate-100 shadow-2xl shadow-slate-950/60">
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.25fr)_minmax(280px,0.95fr)]">
<div className="space-y-5">
<div>
<p className="text-xs uppercase tracking-[0.35em] text-emerald-400">Allowed radio markets</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Only your approved Canadian dial zones</h2>
<p className="mt-2 max-w-2xl text-sm text-slate-400">
SongCatch is locked to Greater Vancouver, Surrey, Vancouver Island, and Toronto, and only shows
stations with a confirmed {FM_MIN.toFixed(1)}{FM_MAX.toFixed(1)} FM frequency.
</p>
</div>
<div className="grid gap-4 md:grid-cols-[minmax(0,240px)_1fr]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">
Market
</span>
<select
className="w-full rounded-xl border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white focus:border-emerald-400 focus:outline-none"
value={selectedMarket}
onChange={(event) => {
setSelectedMarket(event.target.value);
setSelectedSong(null);
setStationMatches([]);
setSongMatchErrorMessage('');
}}
>
{RADIO_MARKETS.map((radioMarket) => (
<option key={radioMarket.value} value={radioMarket.value}>
{radioMarket.label}
</option>
))}
</select>
</label>
<div className="grid gap-3 rounded-2xl border border-slate-800 bg-slate-900/60 p-4 text-sm text-slate-300 md:grid-cols-3">
<div>
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">Country</p>
<p className="mt-1 font-semibold text-white">Canada only</p>
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">FM band</p>
<p className="mt-1 font-semibold text-white">88.1102.0</p>
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">Song match window</p>
<p className="mt-1 font-semibold text-white">Last {SONG_MATCH_LOOKBACK_HOURS} hours</p>
</div>
</div>
</div>
</div>
<div className="rounded-3xl border border-slate-700 bg-gradient-to-br from-slate-900 via-slate-950 to-black p-5 shadow-inner shadow-black/50">
<p className="text-xs uppercase tracking-[0.35em] text-slate-500">Live dashboard</p>
<div className="mt-4 grid gap-4 sm:grid-cols-3">
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 p-4">
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">Market</p>
<p className="mt-2 text-lg font-semibold text-emerald-300">{market.label}</p>
</div>
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 p-4">
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">Stations on dial</p>
<p className="mt-2 text-lg font-semibold text-emerald-300">{stations.length}</p>
</div>
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 p-4">
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">Song matches</p>
<p className="mt-2 text-lg font-semibold text-emerald-300">{stationMatches.length}</p>
</div>
</div>
</div>
</div>
</CardBox>
<CardBox className="border border-slate-700 bg-slate-950 text-slate-100 shadow-2xl shadow-slate-950/60">
<div className="space-y-5">
<div className="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.35em] text-emerald-400">SongCatch search</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Search a song, then find matching stations</h2>
<p className="mt-2 text-sm text-slate-400">
This is the practical first version of your Shazam-like flow: search a title or artist, pick the
best result, then ask the app to check recent station logs for the selected market.
</p>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">
Song or artist
</span>
<input
className="w-full rounded-xl border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white placeholder:text-slate-500 focus:border-emerald-400 focus:outline-none"
placeholder="Try: Blinding Lights, Drake, or a lyric clue"
value={songQuery}
onChange={(event) => setSongQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void runSongSearch();
}
}}
/>
</label>
<div className="flex items-end">
<BaseButton
color="success"
icon={mdiMagnify}
label={isSongSearchLoading ? 'Searching…' : 'Search songs'}
onClick={() => {
void runSongSearch();
}}
disabled={isSongSearchLoading}
/>
</div>
</div>
{songSearchErrorMessage && (
<div className="rounded-xl border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">
{songSearchErrorMessage}
</div>
)}
<div className="grid gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-slate-800 bg-slate-950/80 p-4">
<div className="flex items-center gap-2">
<span className="rounded-full border border-slate-700 bg-slate-900 p-2 text-emerald-300">
<span className="sr-only">Song results</span>
</span>
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Results</p>
<p className="text-sm text-white">Pick a result to find stations in {market.label}</p>
</div>
</div>
<div className="mt-4 space-y-3">
{isSongSearchLoading ? (
<div className="rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-5 text-sm text-slate-400">
Searching the music catalog
</div>
) : songResults.length === 0 ? (
<div className="rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-5 text-sm text-slate-400">
Search results will appear here.
</div>
) : (
songResults.map((song) => {
const isSelectedSong = selectedSong?.trackId === song.trackId;
return (
<div
key={song.trackId}
className={`rounded-2xl border px-4 py-4 transition ${
isSelectedSong
? 'border-emerald-400 bg-emerald-500/10'
: 'border-slate-800 bg-slate-900/60'
}`}
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="truncate text-base font-semibold text-white">{song.trackName}</p>
<p className="truncate text-sm text-slate-300">{song.artistName}</p>
<p className="truncate text-xs text-slate-500">
{song.collectionName || 'Unknown release'}
{song.primaryGenreName ? `${song.primaryGenreName}` : ''}
</p>
</div>
<BaseButton
color="success"
small
icon={mdiRadio}
label={isSongMatchLoading && isSelectedSong ? 'Checking…' : `Find in ${market.label}`}
onClick={() => {
void findStationsPlayingSong(song);
}}
disabled={isSongMatchLoading}
/>
</div>
</div>
);
})
)}
</div>
</div>
<div className="rounded-2xl border border-slate-800 bg-slate-950/80 p-4">
<div className="flex items-center gap-2">
<span className="rounded-full border border-slate-700 bg-slate-900 p-2 text-emerald-300">
<span className="sr-only">Station matches</span>
</span>
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Station matches</p>
<p className="text-sm text-white">
{selectedSong
? `${selectedSong.trackName}${selectedSong.artistName}`
: 'Choose a song result to check live station logs'}
</p>
</div>
</div>
<div className="mt-4 space-y-3">
{isSongMatchLoading ? (
<div className="rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-5 text-sm text-slate-400">
Checking recent station logs
</div>
) : stationMatches.length === 0 ? (
<div className="rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-5 text-sm text-slate-400">
{songMatchErrorMessage ||
'When logs are available, matching stations in the selected market will appear here.'}
</div>
) : (
stationMatches.map((match) => (
<div key={match.id} className="rounded-2xl border border-slate-800 bg-slate-900/60 px-4 py-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<p className="truncate text-base font-semibold text-white">{match.stationName}</p>
<p className="truncate text-sm text-slate-300">
{match.artist || 'Unknown artist'} {match.song || 'Unknown song'}
</p>
<p className="truncate text-xs text-slate-500">
{[match.city, match.state, match.country].filter(Boolean).join(', ') || 'Area not listed'}
</p>
</div>
<div className="rounded-xl border border-slate-700 bg-slate-950/70 px-3 py-2 text-right text-sm text-emerald-300">
<div className="font-mono text-lg font-semibold">{match.frequency.toFixed(1)} FM</div>
<div className="text-xs uppercase tracking-[0.16em] text-slate-500">Matched</div>
</div>
</div>
<div className="mt-3 grid gap-2 text-xs text-slate-400 sm:grid-cols-2">
<div>
<span className="text-slate-500">Last detected:</span>{' '}
{match.playedAt ? playedAtFormatter.format(new Date(match.playedAt)) : 'Unknown'}
</div>
<div>
<span className="text-slate-500">Area:</span> {match.area}
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
</CardBox>
<CardBox className="border border-slate-700 bg-slate-950 text-slate-100 shadow-2xl shadow-slate-950/60">
<div className="space-y-5">
<div className="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.35em] text-emerald-400">Area station dial</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Tune approved stations in {market.label}</h2>
<p className="mt-2 text-sm text-slate-400">
Every station below passed the same frequency rule and market filter used by the song-match flow.
</p>
</div>
</div>
{stationsErrorMessage && (
<div className="rounded-xl border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">
{stationsErrorMessage}
</div>
)}
<div className="overflow-hidden rounded-2xl border border-slate-800">
<div className="grid grid-cols-[110px_minmax(0,1.5fr)_minmax(0,1fr)_90px_120px] gap-3 border-b border-slate-800 bg-slate-900 px-4 py-3 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
<span>Freq</span>
<span>Station</span>
<span>Market hint</span>
<span>Bitrate</span>
<span className="text-right">Action</span>
</div>
<div className="divide-y divide-slate-800 bg-slate-950">
{isStationsLoading ? (
<div className="px-4 py-8 text-sm text-slate-400">Scanning the dial for {market.label}</div>
) : stations.length === 0 ? (
<div className="px-4 py-8 text-sm text-slate-400">
No stations with a confirmed {FM_MIN.toFixed(1)}{FM_MAX.toFixed(1)} FM frequency were returned
for {market.label}.
</div>
) : (
stations.map((station) => {
const isCurrentStation = playingStation?.stationuuid === station.stationuuid;
return (
<div
key={station.stationuuid}
className={`grid grid-cols-[110px_minmax(0,1.5fr)_minmax(0,1fr)_90px_120px] gap-3 px-4 py-3 text-sm transition ${
isCurrentStation ? 'bg-emerald-500/10' : 'hover:bg-slate-900/70'
}`}
>
<div className="font-mono text-lg font-semibold text-emerald-300">
{station.frequency.toFixed(1)}
</div>
<div className="min-w-0">
<p className="truncate font-semibold text-white">{station.name}</p>
<p className="truncate text-xs text-slate-500">{station.codec || 'Unknown codec'}</p>
</div>
<div className="min-w-0">
<p className="truncate text-slate-200">{station.state || market.label}</p>
<p className="truncate text-xs text-slate-500">{station.country}</p>
</div>
<div className="text-slate-300">{station.bitrate ? `${station.bitrate} kbps` : '—'}</div>
<div className="flex justify-end">
<BaseButton
color={isCurrentStation && isPlaying ? 'danger' : 'success'}
small
icon={isCurrentStation && isPlaying ? mdiStop : mdiPlay}
label={isCurrentStation && isPlaying ? 'Stop' : 'Play'}
onClick={() => {
if (isCurrentStation && isPlaying) {
handleStop();
return;
}
void handlePlay(station);
}}
/>
</div>
</div>
);
})
)}
</div>
</div>
</div>
</CardBox>
</div>
<CardBox className="border border-slate-700 bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950 text-emerald-300 shadow-2xl shadow-slate-950/50">
<div className="flex h-full flex-col justify-between gap-6">
<div className="rounded-[2rem] border-4 border-slate-700 bg-slate-900/70 p-6 text-center shadow-inner shadow-black/40">
<p className="text-xs uppercase tracking-[0.35em] text-slate-500">Now tuned</p>
<div className="mt-6 text-4xl font-bold tracking-[0.18em] text-emerald-300">{nowPlayingLabel}</div>
<p className="mt-3 min-h-[3rem] text-sm text-slate-300">
{playingStation
? `${playingStation.name}${playingStation.state || market.label}`
: 'Pick a station from the list to start streaming.'}
</p>
<div className="mt-6 grid grid-cols-2 gap-3 text-left text-xs uppercase tracking-[0.18em] text-slate-400">
<div className="rounded-2xl border border-slate-700 bg-slate-950/70 px-4 py-3">
<span className="block text-slate-500">Band</span>
<span className="mt-1 block text-sm text-emerald-300">88.1102.0 FM</span>
</div>
<div className="rounded-2xl border border-slate-700 bg-slate-950/70 px-4 py-3">
<span className="block text-slate-500">Status</span>
<span className="mt-1 block text-sm text-emerald-300">{isPlaying ? 'On air' : 'Idle'}</span>
</div>
</div>
</div>
<div className="grid gap-4 rounded-2xl border border-slate-800 bg-slate-950/80 p-4 text-sm text-slate-300">
<div className="flex items-start gap-3 rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<div className="mt-0.5 text-emerald-300">
<svg viewBox="0 0 24 24" className="h-5 w-5 fill-current">
<path d={mdiMapMarker} />
</svg>
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">Selected market</p>
<p className="mt-1 font-semibold text-white">{market.label}</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<div className="mt-0.5 text-emerald-300">
<svg viewBox="0 0 24 24" className="h-5 w-5 fill-current">
<path d={mdiMusicNote} />
</svg>
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">SongCatch tip</p>
<p className="mt-1 text-sm text-slate-300">
If no match appears, the usual reason is simple: the app does not yet have a fresh station log for
that song in this market.
</p>
</div>
</div>
</div>
<div className="rounded-2xl border border-slate-800 bg-slate-950/80 p-4">
<audio
ref={audioRef}
className="w-full accent-emerald-400"
controls
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
>
<track kind="captions" />
</audio>
<p className="mt-3 text-xs text-slate-500">
Streaming is enabled only for stations whose FM frequency and market could be verified from upstream
radio data.
</p>
</div>
</div>
</CardBox>
</div>
</SectionMain>
</>
);
};
RadioPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default RadioPage;