diff --git a/.vscode/agents/git-auto-commit.md b/.vscode/agents/git-auto-commit.md new file mode 100644 index 0000000..4775db0 --- /dev/null +++ b/.vscode/agents/git-auto-commit.md @@ -0,0 +1,226 @@ +# Git Auto Commit Agent + +You are working inside this VS Code workspace. + +Your task is to inspect the current Git changes, generate a clear commit message, stage the safe changed files, and create a local Git commit. + +## Goal + +Create one clean Git commit that accurately describes the current workspace changes. + +## Important Rules + +* Do not push. +* Do not run `git push`. +* Do not create tags. +* Do not rewrite history. +* Do not run `git reset --hard`. +* Do not run destructive cleanup commands. +* Do not modify source files unless absolutely necessary. +* Do not bypass Git hooks. +* Do not commit secrets or private files. +* Do not commit files that are clearly generated, temporary, cached, or environment-specific unless they are already intentionally tracked by the project. + +## Files That Must Not Be Committed + +Before staging, carefully check for sensitive or unsafe files. + +Never commit files like: + +```text +.env +.env.* +*.key +*.pem +*.p12 +*.pfx +*.crt +*.sql +*.sqlite +*.db +id_rsa +id_ed25519 +npm-debug.log +yarn-error.log +storage/logs/* +storage/framework/cache/* +storage/framework/sessions/* +storage/framework/views/* +node_modules/* +vendor/* +.DS_Store +Thumbs.db +``` + +If such files appear in the changes, do not stage them. Report them clearly. + +## Git Checks To Run + +First inspect the repository state: + +```bash +git branch --show-current +git status --short +git diff --stat +git diff +``` + +Also check staged changes if needed: + +```bash +git diff --cached --stat +git diff --cached +``` + +## Commit Message Style + +Use Conventional Commits format: + +```text +type: short summary +``` + +Allowed types: + +```text +feat +fix +refactor +chore +docs +style +test +build +perf +ci +``` + +Choose the most accurate type. + +## Commit Message Examples + +```text +feat: add managed stations JSON endpoint +fix: route managed stations JSON request to PHP endpoint +chore: update Apache vhost rewrite config +refactor: simplify station API routing +docs: add deployment notes +build: update frontend build config +``` + +## How To Choose Commit Type + +Use: + +* `feat` for a new feature or visible capability +* `fix` for a bug fix +* `refactor` for code restructuring without behavior change +* `chore` for maintenance, config, cleanup, or small project updates +* `docs` for documentation-only changes +* `style` for formatting-only changes +* `test` for test changes +* `build` for build system, dependencies, Vite, npm, Composer, Docker, or CI-related changes +* `perf` for performance improvements +* `ci` for GitHub Actions, GitLab CI, or deployment pipeline changes + +## Summary Before Commit + +Before committing, show a short summary with: + +```text +Branch: +Changed files: +Main changes: +Proposed commit message: +Skipped files: +``` + +## Staging Rules + +Stage all safe changes using: + +```bash +git add -A +``` + +If unsafe files are detected, stage only safe files individually. + +Example: + +```bash +git add path/to/safe-file.php path/to/another-safe-file.js +``` + +Do not stage unsafe files. + +## Commit Command + +Create the commit using the generated message. + +For a simple commit: + +```bash +git commit -m "type: short summary" +``` + +For a commit that needs more detail: + +```bash +git commit -m "type: short summary" -m "Additional explanation of the important changes." +``` + +## Commit Body Rules + +Use a commit body only when helpful. + +The body should explain: + +* what changed +* why it changed +* any important technical notes + +Keep it short and useful. + +## If There Are No Changes + +If the working tree is clean, say: + +```text +No changes to commit. +``` + +Do not create an empty commit. + +## If Commit Fails + +If the commit fails: + +1. Show the error. +2. Explain what probably caused it. +3. Do not bypass hooks. +4. Do not force the commit. +5. Stop and wait for the user. + +## Final Response + +After creating the commit, respond with: + +```text +Commit created. + +Branch: +Commit: +Message: +``` + +Also mention any skipped files if there were any. + +## Absolute Prohibition + +Never run: + +```bash +git push +``` + +Only create the local commit. diff --git a/README.md b/README.md index 5f690b4..855b435 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,24 @@ # RadioPlayer -RadioPlayer is a Vite + React web app for browsing, playing, and casting radio stations. It loads its bundled managed catalog from `public/stations.json`, exposes that catalog through a same-origin backend endpoint at `/api/managed-stations.json`, supports custom stations, and includes a built-in updater for refreshing the managed list from the live Radio.si feed. +RadioPlayer is a Vite + React web app for browsing, playing, and casting radio stations. +It uses a managed catalog from `public/stations.json`, exposes a same-origin managed endpoint at `/api/managed-stations.json`, supports user stations with local persistence, and includes scripts to refresh station data from Radio.si. ## Features -- Station browser with search, categories, favourites, and recent stations -- Audio playback with previous/next station controls -- Cast support -- Production service worker for app-shell caching, offline launch support, and faster repeat visits -- App install prompt for supported browsers -- Custom station editor -- Live station metadata and artwork rendering +- Station library with search, category tabs, favorites, recents, sorting, and pagination. +- Country filtering plus a country picker to choose which Radio Browser countries are synced and shown. +- Audio playback with previous/next controls, volume/mute, and coverflow quick picks. +- Google Cast and AirPlay output support. +- User station management with local import/export/reset tooling. +- Managed catalog fallback chain: remote endpoint -> cached remote -> bundled `stations.json`. +- Production service worker with app shell caching, station sync cache, and periodic refresh registration. +- PWA install prompt and offline-friendly launch behavior. ## Requirements -- Node.js 18 or newer +- Node.js 18+ - npm +- For production managed endpoint: PHP-enabled web server with rewrite support (Apache + `.htaccess` in this repo) ## Getting Started @@ -25,7 +28,7 @@ Install dependencies: npm install ``` -Start the development server: +Run development server: ```bash npm run dev @@ -37,35 +40,32 @@ Build for production: npm run build ``` -Preview the production build: +Preview production build locally: ```bash npm run preview ``` -Run the production build with the bundled same-origin backend endpoint: - -```bash -npm run serve:backend -``` - -Deploy the built frontend plus backend server files to the remote host: +Deploy built assets with the included script: ```bash bash sync.sh ``` -## Station Data +## Managed Catalog Endpoint -The app keeps the editorial managed list in `public/stations.json`. - -At runtime, the frontend prefers the same-origin managed endpoint: +Runtime managed endpoint path: ```text /api/managed-stations.json ``` -That endpoint returns: +In production this is served by: + +- `public/.htaccess` rewrite rule +- `public/api/managed-stations.php` + +Response shape: ```json { @@ -75,55 +75,107 @@ That endpoint returns: } ``` -If the backend endpoint is unavailable, the frontend and service worker fall back to the bundled `stations.json` file. +In dev/preview, Vite middleware in `vite.config.js` serves the same envelope from `stations.json`. -To refresh the file from the remote source, run: +If the endpoint is unavailable, frontend and service worker fall back to bundled `public/stations.json`. + +## Station Data Refresh + +Refresh managed stations from Radio.si: ```bash npm run update:stations ``` -That command fetches the latest station list from: +Refresh and rebuild in one command: -```text -https://data.radio.si/api/radiostations?857df78efd094abcb98c7bbb53303c3d +```bash +npm run update:stations:build ``` -and rewrites `public/stations.json` while preserving the existing JSON structure used by the app. The backend endpoint reads from that file and wraps it in the runtime envelope shown above. - -You can also pass a custom source URL or a custom output path if needed: +Custom source and output: ```bash node scripts/update-stations.mjs ``` +## Cron Automation (Server) + +Helper script: + +```bash +bash scripts/cron-refresh-stations.sh +``` + +Update-only helper script: + +```bash +bash scripts/cron-update-stations.sh +``` + +The script: + +- acquires a lock file to prevent overlapping runs, +- runs station refresh and build, +- optionally runs `DEPLOY_CMD`, +- writes logs to `/tmp/radioplayer-refresh-stations.log` by default, +- rotates logs automatically when they exceed 1 MB, keeping 5 archives by default. + +The update-only script: + +- acquires its own lock file, +- runs only `npm run update:stations`, +- optionally runs `POST_UPDATE_CMD`, +- writes logs to `/tmp/radioplayer-update-stations.log`, +- uses the same log rotation behavior. + +Example crontab (every 6 hours): + +```cron +0 */6 * * * REPO_DIR=/opt/www/virtual/RadioPlayer DEPLOY_CMD='rsync -av --delete /opt/www/virtual/RadioPlayer/dist/ /opt/www/virtual/RadioPlayer/' /opt/www/virtual/RadioPlayer/scripts/cron-refresh-stations.sh +``` + +Example update-only crontab (every 2 hours): + +```cron +0 */2 * * * REPO_DIR=/opt/www/virtual/RadioPlayer /opt/www/virtual/RadioPlayer/scripts/cron-update-stations.sh +``` + ## Project Structure ```text index.html +privacy.html package.json public/ + .htaccess + api/ + managed-stations.php + data/ + radio-stations.json + radio-stations-sync.json manifest.json stations.json sw.js -server/ - index.mjs - managedCatalogData.mjs +scripts/ + bump-sw-cache-version.mjs + cron-refresh-stations.sh + cron-update-stations.sh + import-radio-stations.ts + update-stations.mjs src/ App.jsx main.jsx player.js styles.css -scripts/ - update-stations.mjs + radio/ + storage/ ``` ## Notes -- The app uses a module-based frontend build, so `src/main.jsx` is the browser entry point. -- The service worker is registered only in production builds. During development, existing service workers and caches are cleared automatically to avoid stale assets while iterating. -- The updater script uses the remote feed as the source of truth for the station list and writes the merged result into `public/stations.json`. -- Vite dev and preview both expose `/api/managed-stations.json` on the same origin via middleware, and `npm run serve:backend` serves the built app plus the same endpoint from Node. -- `sync.sh` now deploys both the built frontend files and the `server/` runtime so the same-origin backend can be started remotely with `node /opt/www/virtual/RadioPlayer/server/index.mjs`. -- If you add or edit stations manually, re-run `npm run update:stations` when you want to sync back to the remote catalog. -- Static-only deployments still work because the frontend falls back to bundled `stations.json`, but a true same-origin backend endpoint requires deploying a server that answers `/api/managed-stations.json`. +- Service worker is only active in production builds. In dev, SW registrations and caches are cleared automatically. +- `src/main.jsx` registers background sync / periodic sync where supported. +- `src/player.js` also refreshes managed catalog on app focus after a timeout (fallback for browsers without periodic sync). +- `sync.sh` deploys `dist/` only. +- If you edit stations manually, rerun `npm run update:stations` to resync from upstream when needed. diff --git a/package.json b/package.json index ce955d5..697f34f 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "vite preview", "serve:backend": "node server/index.mjs", "update:stations": "node scripts/update-stations.mjs", + "update:stations:build": "npm run update:stations && npm run build", "radio:import": "tsx scripts/import-radio-stations.ts", "typecheck": "tsc --noEmit" }, diff --git a/public/sw.js b/public/sw.js index ae8a3ac..0029938 100644 --- a/public/sw.js +++ b/public/sw.js @@ -3,7 +3,7 @@ // // This value is rewritten automatically before each build so deployed clients // refresh to the newest shell and cached assets. -const CACHE_NAME = 'radioplayer-pwa-v5-1777463324180'; +const CACHE_NAME = 'radioplayer-pwa-v5-1777473175316'; const STATION_SYNC_CACHE_NAME = 'radioplayer-station-sync-v1'; const MANAGED_CATALOG_CACHE_NAME = 'radioplayer-managed-catalog-v1'; const RADIO_BROWSER_API_ENDPOINT = 'https://de1.api.radio-browser.info/json/stations/search'; @@ -60,6 +60,8 @@ const RADIO_COUNTRIES = [ { name: 'Turkey', code: 'TR' }, { name: 'Ukraine', code: 'UA' }, ]; +const DEFAULT_SYNC_COUNTRY_CODES = RADIO_COUNTRIES.map((country) => country.code); +const MANAGED_COUNTRY_CODE = 'SI'; const MAX_TAGS = 12; const OBVIOUSLY_UNSUPPORTED_CODECS = new Set([ 'wma', @@ -96,6 +98,7 @@ const IMAGE_FALLBACK_PATH = new URL('images/radio-placeholder.svg', self.registr const SYNC_CATALOG_URL = new URL('data/radio-stations-sync.json', self.registration.scope).href; const SYNC_CATALOG_PATH = new URL('data/radio-stations-sync.json', self.registration.scope).pathname; const SYNC_META_URL = new URL('data/radio-stations-sync-meta.json', self.registration.scope).href; +const SYNC_SETTINGS_URL = new URL('data/radio-stations-sync-settings.json', self.registration.scope).href; const SYNC_COUNTRY_PREFIX_PATH = new URL('data/countries/', self.registration.scope).pathname; const BUNDLED_MANAGED_CATALOG_URL = new URL('stations.json', self.registration.scope).href; const MANAGED_CATALOG_PATH = new URL('api/managed-stations.json', self.registration.scope).pathname; @@ -190,6 +193,54 @@ async function writeJsonToCache(cache, requestUrl, payload) { })); } +function sanitizeSelectedCountryCodes(countryCodes) { + const uniqueCodes = Array.isArray(countryCodes) + ? Array.from(new Set( + countryCodes + .map((entry) => (typeof entry === 'string' ? entry.trim().toUpperCase() : '')) + .filter((entry) => /^[A-Z]{2}$/.test(entry)), + )) + : []; + + if (!uniqueCodes.includes(MANAGED_COUNTRY_CODE)) { + uniqueCodes.unshift(MANAGED_COUNTRY_CODE); + } + + return uniqueCodes.length > 0 ? uniqueCodes : [...DEFAULT_SYNC_COUNTRY_CODES]; +} + +async function readSyncSettings(cache) { + const cached = await cache.match(SYNC_SETTINGS_URL); + if (!cached) { + return { selectedCountryCodes: [...DEFAULT_SYNC_COUNTRY_CODES] }; + } + + try { + const payload = await cached.json(); + return { + selectedCountryCodes: sanitizeSelectedCountryCodes(payload?.selectedCountryCodes), + }; + } catch { + return { selectedCountryCodes: [...DEFAULT_SYNC_COUNTRY_CODES] }; + } +} + +async function writeSyncSettings(cache, countryCodes) { + const payload = { + selectedCountryCodes: sanitizeSelectedCountryCodes(countryCodes), + updatedAt: new Date().toISOString(), + }; + await writeJsonToCache(cache, SYNC_SETTINGS_URL, payload); + return payload; +} + +function getSyncCountries(selectedCountryCodes) { + return sanitizeSelectedCountryCodes(selectedCountryCodes).map((code) => { + const existing = RADIO_COUNTRIES.find((country) => country.code === code); + return existing || { name: code, code }; + }); +} + async function fetchCountryStations(country) { const url = new URL(RADIO_BROWSER_API_ENDPOINT); url.search = new URLSearchParams({ @@ -223,15 +274,19 @@ async function fetchCountryStations(country) { .filter(Boolean); } -async function syncRadioStations(reason = 'sync') { +async function syncRadioStations(reason = 'sync', selectedCountryCodesOverride = null) { const syncCache = await caches.open(STATION_SYNC_CACHE_NAME); + const syncSettings = selectedCountryCodesOverride + ? { selectedCountryCodes: sanitizeSelectedCountryCodes(selectedCountryCodesOverride) } + : await readSyncSettings(syncCache); + const syncCountries = getSyncCountries(syncSettings.selectedCountryCodes); const aggregatedStations = []; const seenStationIds = new Set(); const seenStreamUrls = new Set(); const failedCountries = []; let syncedCountries = 0; - for (const country of RADIO_COUNTRIES) { + for (const country of syncCountries) { try { const stations = await fetchCountryStations(country); await writeJsonToCache(syncCache, getCountryCatalogUrl(country.code), stations); @@ -263,6 +318,7 @@ async function syncRadioStations(reason = 'sync') { reason, syncedAt: new Date().toISOString(), countryCount: syncedCountries, + selectedCountryCodes: syncSettings.selectedCountryCodes, failedCountries, stationCount: aggregatedStations.length, }; @@ -477,6 +533,31 @@ self.addEventListener('sync', (event) => { event.waitUntil(syncRadioStations('background-sync')); }); +self.addEventListener('message', (event) => { + if (event.data?.type !== 'set-sync-countries') return; + + event.waitUntil((async () => { + try { + const syncCache = await caches.open(STATION_SYNC_CACHE_NAME); + const syncSettings = await writeSyncSettings(syncCache, event.data?.countryCodes); + const syncResult = event.data?.syncNow + ? await syncRadioStations('country-selection-update', syncSettings.selectedCountryCodes) + : null; + + event.ports?.[0]?.postMessage({ + ok: true, + selectedCountryCodes: syncSettings.selectedCountryCodes, + syncResult, + }); + } catch (error) { + event.ports?.[0]?.postMessage({ + ok: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + })()); +}); + self.addEventListener('periodicsync', (event) => { if (event.tag === STATION_PERIODIC_SYNC_TAG) { event.waitUntil(syncRadioStations('periodic-sync')); diff --git a/scripts/cron-refresh-stations.sh b/scripts/cron-refresh-stations.sh new file mode 100644 index 0000000..c88c3b6 --- /dev/null +++ b/scripts/cron-refresh-stations.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Cron helper for refreshing station catalog and rebuilding assets. +# Optional env vars: +# - REPO_DIR: repository root (defaults to script parent) +# - LOCK_FILE: lock path (defaults to /tmp/radioplayer-refresh-stations.lock) +# - LOG_FILE: log path (defaults to /tmp/radioplayer-refresh-stations.log) +# - LOG_MAX_BYTES: rotate log when it grows beyond this size (default 1048576) +# - LOG_KEEP_COUNT: number of rotated logs to keep (default 5) +# - DEPLOY_CMD: optional shell command executed after successful build + +REPO_DIR="${REPO_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +LOCK_FILE="${LOCK_FILE:-/tmp/radioplayer-refresh-stations.lock}" +LOG_FILE="${LOG_FILE:-/tmp/radioplayer-refresh-stations.log}" +LOG_MAX_BYTES="${LOG_MAX_BYTES:-1048576}" +LOG_KEEP_COUNT="${LOG_KEEP_COUNT:-5}" + +rotate_log_if_needed() { + if [[ ! -f "$LOG_FILE" ]]; then + return + fi + + local size + size=$(wc -c < "$LOG_FILE") + if [[ "$size" -lt "$LOG_MAX_BYTES" ]]; then + return + fi + + local i + for ((i=LOG_KEEP_COUNT; i>=1; i--)); do + if [[ -f "$LOG_FILE.$i" ]]; then + if [[ "$i" -ge "$LOG_KEEP_COUNT" ]]; then + rm -f "$LOG_FILE.$i" + else + mv "$LOG_FILE.$i" "$LOG_FILE.$((i + 1))" + fi + fi + done + + mv "$LOG_FILE" "$LOG_FILE.1" +} + +mkdir -p "$(dirname "$LOCK_FILE")" "$(dirname "$LOG_FILE")" +rotate_log_if_needed + +exec 9>"$LOCK_FILE" +if ! flock -n 9; then + echo "[$(date -Is)] refresh skipped: another run is active" >> "$LOG_FILE" + exit 0 +fi + +{ + echo "[$(date -Is)] refresh start" + cd "$REPO_DIR" + npm ci --silent + npm run update:stations + npm run build + + if [[ -n "${DEPLOY_CMD:-}" ]]; then + echo "[$(date -Is)] running deploy command" + bash -lc "$DEPLOY_CMD" + fi + + echo "[$(date -Is)] refresh complete" +} >> "$LOG_FILE" 2>&1 diff --git a/scripts/cron-update-stations.sh b/scripts/cron-update-stations.sh new file mode 100644 index 0000000..dd07aaf --- /dev/null +++ b/scripts/cron-update-stations.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Cron helper for refreshing only the managed station catalog. +# Optional env vars: +# - REPO_DIR: repository root (defaults to script parent) +# - LOCK_FILE: lock path (defaults to /tmp/radioplayer-update-stations.lock) +# - LOG_FILE: log path (defaults to /tmp/radioplayer-update-stations.log) +# - LOG_MAX_BYTES: rotate log when it grows beyond this size (default 1048576) +# - LOG_KEEP_COUNT: number of rotated logs to keep (default 5) +# - POST_UPDATE_CMD: optional shell command executed after successful station update + +REPO_DIR="${REPO_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +LOCK_FILE="${LOCK_FILE:-/tmp/radioplayer-update-stations.lock}" +LOG_FILE="${LOG_FILE:-/tmp/radioplayer-update-stations.log}" +LOG_MAX_BYTES="${LOG_MAX_BYTES:-1048576}" +LOG_KEEP_COUNT="${LOG_KEEP_COUNT:-5}" + +rotate_log_if_needed() { + if [[ ! -f "$LOG_FILE" ]]; then + return + fi + + local size + size=$(wc -c < "$LOG_FILE") + if [[ "$size" -lt "$LOG_MAX_BYTES" ]]; then + return + fi + + local i + for ((i=LOG_KEEP_COUNT; i>=1; i--)); do + if [[ -f "$LOG_FILE.$i" ]]; then + if [[ "$i" -ge "$LOG_KEEP_COUNT" ]]; then + rm -f "$LOG_FILE.$i" + else + mv "$LOG_FILE.$i" "$LOG_FILE.$((i + 1))" + fi + fi + done + + mv "$LOG_FILE" "$LOG_FILE.1" +} + +mkdir -p "$(dirname "$LOCK_FILE")" "$(dirname "$LOG_FILE")" +rotate_log_if_needed + +exec 9>"$LOCK_FILE" +if ! flock -n 9; then + echo "[$(date -Is)] update skipped: another run is active" >> "$LOG_FILE" + exit 0 +fi + +{ + echo "[$(date -Is)] update start" + cd "$REPO_DIR" + npm ci --silent + npm run update:stations + + if [[ -n "${POST_UPDATE_CMD:-}" ]]; then + echo "[$(date -Is)] running post-update command" + bash -lc "$POST_UPDATE_CMD" + fi + + echo "[$(date -Is)] update complete" +} >> "$LOG_FILE" 2>&1 diff --git a/src/App.jsx b/src/App.jsx index 7707b90..e3e7807 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -385,6 +385,42 @@ function EditorOverlay() { ); } +function CountrySelectionOverlay() { + return ( + + ); +} + function StationLibrary() { return (