Feature test 01
This commit is contained in:
parent
f9455c1a31
commit
2650d8e475
0
.perm_test_apache
Normal file
0
.perm_test_apache
Normal file
0
.perm_test_exec
Normal file
0
.perm_test_exec
Normal 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}:
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
807
frontend/src/pages/radio.tsx
Normal file
807
frontend/src/pages/radio.tsx
Normal 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.1–102.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.1–102.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;
|
||||
Loading…
x
Reference in New Issue
Block a user