diff --git a/.perm_test_apache b/.perm_test_apache new file mode 100644 index 0000000..e69de29 diff --git a/.perm_test_exec b/.perm_test_exec new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/routes/station_logs.js b/backend/src/routes/station_logs.js index f500f45..9877d81 100644 --- a/backend/src/routes/station_logs.js +++ b/backend/src/routes/station_logs.js @@ -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}: diff --git a/backend/src/services/station_logs.js b/backend/src/services/station_logs.js index dfc9504..70ee371 100644 --- a/backend/src/services/station_logs.js +++ b/backend/src/services/station_logs.js @@ -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(), + }; + } + }; diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index f49cc86..f0c1dc2 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -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 \ No newline at end of file diff --git a/frontend/src/pages/radio.tsx b/frontend/src/pages/radio.tsx new file mode 100644 index 0000000..74939ca --- /dev/null +++ b/frontend/src/pages/radio.tsx @@ -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) { + 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(null); + const [selectedMarket, setSelectedMarket] = useState(DEFAULT_MARKET); + const [stations, setStations] = useState([]); + const [isStationsLoading, setIsStationsLoading] = useState(false); + const [stationsErrorMessage, setStationsErrorMessage] = useState(''); + const [playingStation, setPlayingStation] = useState(null); + const [isPlaying, setIsPlaying] = useState(false); + const [songQuery, setSongQuery] = useState(''); + const [songResults, setSongResults] = useState([]); + const [isSongSearchLoading, setIsSongSearchLoading] = useState(false); + const [songSearchErrorMessage, setSongSearchErrorMessage] = useState(''); + const [selectedSong, setSelectedSong] = useState(null); + const [stationMatches, setStationMatches] = useState([]); + const [isSongMatchLoading, setIsSongMatchLoading] = useState(false); + const [songMatchErrorMessage, setSongMatchErrorMessage] = useState(''); + + 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 ( + <> + + {getPageTitle('Radio')} + + + + +
Dark market-limited radio
+
+ +
+
+ +
+
+
+

Allowed radio markets

+

Only your approved Canadian dial zones

+

+ 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. +

+
+ +
+ + +
+
+

Country

+

Canada only

+
+
+

FM band

+

88.1–102.0

+
+
+

Song match window

+

Last {SONG_MATCH_LOOKBACK_HOURS} hours

+
+
+
+
+ +
+

Live dashboard

+
+
+

Market

+

{market.label}

+
+
+

Stations on dial

+

{stations.length}

+
+
+

Song matches

+

{stationMatches.length}

+
+
+
+
+
+ + +
+
+
+

SongCatch search

+

Search a song, then find matching stations

+

+ 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. +

+
+
+ +
+ + +
+ { + void runSongSearch(); + }} + disabled={isSongSearchLoading} + /> +
+
+ + {songSearchErrorMessage && ( +
+ {songSearchErrorMessage} +
+ )} + +
+
+
+ + Song results + +
+

Results

+

Pick a result to find stations in {market.label}

+
+
+ +
+ {isSongSearchLoading ? ( +
+ Searching the music catalog… +
+ ) : songResults.length === 0 ? ( +
+ Search results will appear here. +
+ ) : ( + songResults.map((song) => { + const isSelectedSong = selectedSong?.trackId === song.trackId; + + return ( +
+
+
+

{song.trackName}

+

{song.artistName}

+

+ {song.collectionName || 'Unknown release'} + {song.primaryGenreName ? ` • ${song.primaryGenreName}` : ''} +

+
+ + { + void findStationsPlayingSong(song); + }} + disabled={isSongMatchLoading} + /> +
+
+ ); + }) + )} +
+
+ +
+
+ + Station matches + +
+

Station matches

+

+ {selectedSong + ? `${selectedSong.trackName} • ${selectedSong.artistName}` + : 'Choose a song result to check live station logs'} +

+
+
+ +
+ {isSongMatchLoading ? ( +
+ Checking recent station logs… +
+ ) : stationMatches.length === 0 ? ( +
+ {songMatchErrorMessage || + 'When logs are available, matching stations in the selected market will appear here.'} +
+ ) : ( + stationMatches.map((match) => ( +
+
+
+

{match.stationName}

+

+ {match.artist || 'Unknown artist'} • {match.song || 'Unknown song'} +

+

+ {[match.city, match.state, match.country].filter(Boolean).join(', ') || 'Area not listed'} +

+
+ +
+
{match.frequency.toFixed(1)} FM
+
Matched
+
+
+ +
+
+ Last detected:{' '} + {match.playedAt ? playedAtFormatter.format(new Date(match.playedAt)) : 'Unknown'} +
+
+ Area: {match.area} +
+
+
+ )) + )} +
+
+
+
+
+ + +
+
+
+

Area station dial

+

Tune approved stations in {market.label}

+

+ Every station below passed the same frequency rule and market filter used by the song-match flow. +

+
+
+ + {stationsErrorMessage && ( +
+ {stationsErrorMessage} +
+ )} + +
+
+ Freq + Station + Market hint + Bitrate + Action +
+ +
+ {isStationsLoading ? ( +
Scanning the dial for {market.label}…
+ ) : stations.length === 0 ? ( +
+ No stations with a confirmed {FM_MIN.toFixed(1)}–{FM_MAX.toFixed(1)} FM frequency were returned + for {market.label}. +
+ ) : ( + stations.map((station) => { + const isCurrentStation = playingStation?.stationuuid === station.stationuuid; + + return ( +
+
+ {station.frequency.toFixed(1)} +
+
+

{station.name}

+

{station.codec || 'Unknown codec'}

+
+
+

{station.state || market.label}

+

{station.country}

+
+
{station.bitrate ? `${station.bitrate} kbps` : '—'}
+
+ { + if (isCurrentStation && isPlaying) { + handleStop(); + return; + } + + void handlePlay(station); + }} + /> +
+
+ ); + }) + )} +
+
+
+
+
+ + +
+
+

Now tuned

+
{nowPlayingLabel}
+

+ {playingStation + ? `${playingStation.name} • ${playingStation.state || market.label}` + : 'Pick a station from the list to start streaming.'} +

+ +
+
+ Band + 88.1–102.0 FM +
+
+ Status + {isPlaying ? 'On air' : 'Idle'} +
+
+
+ +
+
+
+ + + +
+
+

Selected market

+

{market.label}

+
+
+ +
+
+ + + +
+
+

SongCatch tip

+

+ 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. +

+
+
+
+ +
+ +

+ Streaming is enabled only for stations whose FM frequency and market could be verified from upstream + radio data. +

+
+
+
+
+
+ + ); +}; + +RadioPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default RadioPage;