aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitattributes3
-rw-r--r--.gitignore4
-rw-r--r--README.md51
-rwxr-xr-xradio-tui12
-rw-r--r--radio_tui/__init__.py3
-rw-r--r--radio_tui/__main__.py4
-rw-r--r--radio_tui/app.py370
-rw-r--r--radio_tui/player.py163
-rw-r--r--radio_tui/stations.py58
-rw-r--r--requirements.txt1
-rw-r--r--telugu-radio-stations.csv43
-rw-r--r--telugu-radio-stations.json302
12 files changed, 1014 insertions, 0 deletions
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..87d4686
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,3 @@
+* text=auto eol=lf
+*.sh text eol=lf
+radio-tui text eol=lf
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..887bd7d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+.venv/
+__pycache__/
+*.py[cod]
+.pytest_cache/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..215f112
--- /dev/null
+++ b/README.md
@@ -0,0 +1,51 @@
+# Telugu Radio TUI
+
+A terminal UI for browsing and playing Telugu online radio stations from [OnlineRadioFM](https://onlineradiofm.in/language/telugu).
+
+## Requirements
+
+- Python 3.11+
+- [mpv](https://mpv.io/) — playback
+- [socat](https://sourceforge.net/projects/socat/) — mpv IPC
+
+## Quick start
+
+```bash
+cd ~/code/radio_tui
+chmod +x radio-tui
+./radio-tui
+```
+
+The launcher creates a local `.venv` and installs dependencies on first run.
+
+Or manually:
+
+```bash
+python3 -m venv .venv
+source .venv/bin/activate
+pip install -r requirements.txt
+python -m radio_tui
+```
+
+## Keys
+
+| Key | Action |
+|-----|--------|
+| `/` | Focus search |
+| `Enter` | Play selected station |
+| `n` / `p` | Next / previous station (and play) |
+| `+` / `-` | Volume up / down |
+| `m` | Mute |
+| `s` | Stop |
+| `f` | Toggle favorite |
+| `y` | Show stream URL |
+| `q` | Quit |
+
+Favorites are saved to `~/.radio_tui_favorites.json`.
+
+## Data
+
+- `telugu-radio-stations.json` — station metadata and stream URLs
+- `telugu-radio-stations.csv` — same data in CSV format
+
+Source: https://onlineradiofm.in/language/telugu
diff --git a/radio-tui b/radio-tui
new file mode 100755
index 0000000..94a1e40
--- /dev/null
+++ b/radio-tui
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+VENV="$ROOT/.venv"
+
+if [[ ! -d "$VENV" ]]; then
+ python3 -m venv "$VENV"
+ "$VENV/bin/pip" install -q -r "$ROOT/requirements.txt"
+fi
+
+exec "$VENV/bin/python" -m radio_tui "$@"
diff --git a/radio_tui/__init__.py b/radio_tui/__init__.py
new file mode 100644
index 0000000..5646c5d
--- /dev/null
+++ b/radio_tui/__init__.py
@@ -0,0 +1,3 @@
+"""Telugu radio TUI for OnlineRadioFM station listings."""
+
+__version__ = "0.1.0"
diff --git a/radio_tui/__main__.py b/radio_tui/__main__.py
new file mode 100644
index 0000000..6c3b8fb
--- /dev/null
+++ b/radio_tui/__main__.py
@@ -0,0 +1,4 @@
+from radio_tui.app import main
+
+if __name__ == "__main__":
+ main()
diff --git a/radio_tui/app.py b/radio_tui/app.py
new file mode 100644
index 0000000..a1d4695
--- /dev/null
+++ b/radio_tui/app.py
@@ -0,0 +1,370 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from textual import on
+from textual.app import App, ComposeResult
+from textual.binding import Binding
+from textual.containers import Horizontal, Vertical
+from textual.reactive import reactive
+from textual.widgets import (
+ DataTable,
+ Footer,
+ Header,
+ Input,
+ Label,
+ Static,
+ Switch,
+)
+
+from radio_tui.player import MpvPlayer
+from radio_tui.stations import Station, load_stations
+
+
+class NowPlayingPanel(Static):
+ DEFAULT_CSS = """
+ NowPlayingPanel {
+ height: 8;
+ border: round $accent;
+ padding: 1 2;
+ background: $surface;
+ }
+
+ NowPlayingPanel .title {
+ text-style: bold;
+ color: $accent;
+ }
+
+ NowPlayingPanel .offline {
+ color: $error;
+ }
+ """
+
+ station = reactive("")
+ track = reactive("")
+ volume = reactive(50)
+ muted = reactive(False)
+ offline = reactive(False)
+
+ def render(self) -> str:
+ station = self.station or "Nothing playing"
+ track = self.track or "Select a station and press Enter"
+ volume = f"{self.volume}%"
+ if self.muted:
+ volume += " (muted)"
+ status = "offline" if self.offline else "live"
+ lines = [
+ "[b accent]Now Playing[/]",
+ f"[bold]{station}[/]",
+ f"[dim]{track}[/]" if not self.offline else f"[error]{track}[/]",
+ f"Volume: {volume} · {status}",
+ ]
+ return "\n".join(lines)
+
+
+class RadioApp(App):
+ TITLE = "Telugu Radio"
+ SUB_TITLE = "OnlineRadioFM"
+ CSS = """
+ Screen {
+ layout: vertical;
+ }
+
+ #main {
+ height: 1fr;
+ }
+
+ #sidebar {
+ width: 34;
+ border-right: solid $primary;
+ padding: 0 1;
+ }
+
+ #sidebar Label {
+ margin: 1 0 0 0;
+ text-style: bold;
+ }
+
+ #search {
+ margin: 1 0;
+ }
+
+ #playable-only {
+ margin: 0 0 1 0;
+ }
+
+ #station-table {
+ height: 1fr;
+ }
+
+ #details {
+ padding: 1 2;
+ height: 1fr;
+ }
+
+ #details-title {
+ text-style: bold;
+ margin-bottom: 1;
+ }
+
+ #details-body {
+ color: $text-muted;
+ }
+
+ .stat-block {
+ margin-top: 1;
+ padding: 1;
+ border: round $primary-lighten-2;
+ background: $panel;
+ }
+ """
+
+ BINDINGS = [
+ Binding("q", "quit", "Quit"),
+ Binding("enter", "play_selected", "Play"),
+ Binding("space", "play_selected", "Play", show=False),
+ Binding("n", "next_station", "Next"),
+ Binding("p", "prev_station", "Prev"),
+ Binding("/", "focus_search", "Search"),
+ Binding("+", "volume_up", "Vol +"),
+ Binding("=", "volume_up", "Vol +", show=False),
+ Binding("-", "volume_down", "Vol -"),
+ Binding("m", "toggle_mute", "Mute"),
+ Binding("s", "stop", "Stop"),
+ Binding("f", "toggle_favorite", "Favorite"),
+ Binding("y", "copy_stream", "Copy URL"),
+ ]
+
+ favorites_path = Path.home() / ".radio_tui_favorites.json"
+ selected_index = reactive(0)
+ filter_text = reactive("")
+ playable_only = reactive(True)
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.all_stations = load_stations()
+ self.filtered_stations: list[Station] = []
+ self.player = MpvPlayer()
+ self.favorites: set[str] = self._load_favorites()
+
+ def compose(self) -> ComposeResult:
+ yield Header()
+ with Horizontal(id="main"):
+ with Vertical(id="sidebar"):
+ yield Label("Search")
+ yield Input(placeholder="Filter stations...", id="search")
+ with Horizontal(id="playable-only"):
+ yield Switch(value=True, id="playable-switch")
+ yield Label("Playable only")
+ yield DataTable(id="station-table", zebra_stripes=True, cursor_type="row")
+ with Vertical(id="details"):
+ yield NowPlayingPanel(id="now-playing")
+ yield Static("Select a station", id="details-title")
+ yield Static("", id="details-body")
+ yield Static("", id="details-stats", classes="stat-block")
+ yield Footer()
+
+ def on_mount(self) -> None:
+ if not self.player.available:
+ self.notify(
+ "mpv and socat are required for playback.",
+ severity="error",
+ timeout=8,
+ )
+ table = self.query_one("#station-table", DataTable)
+ table.add_columns("Station", "Live")
+ self.refresh_table()
+ table.focus()
+ self.set_interval(1, self.refresh_now_playing)
+
+ def _load_favorites(self) -> set[str]:
+ if not self.favorites_path.exists():
+ return set()
+ try:
+ data = json.loads(self.favorites_path.read_text(encoding="utf-8"))
+ return set(data)
+ except (json.JSONDecodeError, TypeError):
+ return set()
+
+ def _save_favorites(self) -> None:
+ self.favorites_path.write_text(
+ json.dumps(sorted(self.favorites), indent=2),
+ encoding="utf-8",
+ )
+
+ def _apply_filter(self) -> None:
+ query = self.filter_text.casefold().strip()
+ stations = self.all_stations
+ if self.playable_only:
+ stations = [station for station in stations if station.playable]
+ if query:
+ stations = [
+ station
+ for station in stations
+ if query in station.name.casefold()
+ ]
+ self.filtered_stations = stations
+
+ def refresh_table(self) -> None:
+ self._apply_filter()
+ table = self.query_one("#station-table", DataTable)
+ table.clear()
+ for station in self.filtered_stations:
+ fav = "★ " if station.name in self.favorites else ""
+ live = "●" if station.playable else "○"
+ table.add_row(f"{fav}{station.name}", live)
+ if self.filtered_stations:
+ self.selected_index = min(self.selected_index, len(self.filtered_stations) - 1)
+ table.move_cursor(row=self.selected_index)
+ self.update_details()
+ else:
+ self.query_one("#details-title", Static).update("No stations match")
+ self.query_one("#details-body", Static).update("")
+ self.query_one("#details-stats", Static).update("")
+
+ def selected_station(self) -> Station | None:
+ if not self.filtered_stations:
+ return None
+ index = max(0, min(self.selected_index, len(self.filtered_stations) - 1))
+ return self.filtered_stations[index]
+
+ def update_details(self) -> None:
+ station = self.selected_station()
+ if not station:
+ return
+ favorite = "Yes" if station.name in self.favorites else "No"
+ stream = station.stream_url or "Not available online"
+ body = (
+ f"Page: {station.page_url}\n"
+ f"Stream: {stream}\n"
+ f"Favorite: {favorite}"
+ )
+ playable = sum(1 for item in self.all_stations if item.playable)
+ stats = (
+ f"{len(self.all_stations)} stations total · "
+ f"{playable} playable · "
+ f"{len(self.favorites)} favorites"
+ )
+ self.query_one("#details-title", Static).update(station.name)
+ self.query_one("#details-body", Static).update(body)
+ self.query_one("#details-stats", Static).update(stats)
+
+ def refresh_now_playing(self) -> None:
+ panel = self.query_one("#now-playing", NowPlayingPanel)
+ state = self.player.snapshot()
+ panel.station = state.station_name
+ panel.track = state.media_title
+ panel.volume = state.volume
+ panel.muted = state.muted
+ panel.offline = state.offline
+
+ @on(Input.Changed, "#search")
+ def on_search_changed(self, event: Input.Changed) -> None:
+ self.filter_text = event.value
+ self.selected_index = 0
+ self.refresh_table()
+
+ @on(Switch.Changed, "#playable-switch")
+ def on_playable_changed(self, event: Switch.Changed) -> None:
+ self.playable_only = event.value
+ self.selected_index = 0
+ self.refresh_table()
+
+ @on(DataTable.RowHighlighted)
+ def on_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
+ if event.cursor_row is not None:
+ self.selected_index = event.cursor_row
+ self.update_details()
+
+ @on(DataTable.RowSelected)
+ def on_row_selected(self, event: DataTable.RowSelected) -> None:
+ if event.cursor_row is not None:
+ self.selected_index = event.cursor_row
+ self.update_details()
+ self.action_play_selected()
+
+ def action_focus_search(self) -> None:
+ self.query_one("#search", Input).focus()
+
+ def action_play_selected(self) -> None:
+ station = self.selected_station()
+ if not station:
+ return
+ if not station.playable or not station.stream_url:
+ self.notify(f"No stream for {station.name}", severity="warning")
+ return
+ self.player.play(station.name, station.stream_url)
+ self.notify(f"Playing {station.name}")
+
+ def action_stop(self) -> None:
+ self.player.stop()
+ self.notify("Stopped")
+
+ def action_next_station(self) -> None:
+ if not self.filtered_stations:
+ return
+ self.selected_index = (self.selected_index + 1) % len(self.filtered_stations)
+ table = self.query_one("#station-table", DataTable)
+ table.move_cursor(row=self.selected_index)
+ self.action_play_selected()
+
+ def action_prev_station(self) -> None:
+ if not self.filtered_stations:
+ return
+ self.selected_index = (self.selected_index - 1) % len(self.filtered_stations)
+ table = self.query_one("#station-table", DataTable)
+ table.move_cursor(row=self.selected_index)
+ self.action_play_selected()
+
+ def action_volume_up(self) -> None:
+ self.player.adjust_volume(5)
+ self.refresh_now_playing()
+
+ def action_volume_down(self) -> None:
+ self.player.adjust_volume(-5)
+ self.refresh_now_playing()
+
+ def action_toggle_mute(self) -> None:
+ self.player.toggle_mute()
+ self.refresh_now_playing()
+
+ def action_toggle_favorite(self) -> None:
+ station = self.selected_station()
+ if not station:
+ return
+ if station.name in self.favorites:
+ self.favorites.remove(station.name)
+ self.notify(f"Removed {station.name} from favorites")
+ else:
+ self.favorites.add(station.name)
+ self.notify(f"Added {station.name} to favorites")
+ self._save_favorites()
+ self.refresh_table()
+
+ def action_copy_stream(self) -> None:
+ import shutil
+ import subprocess
+
+ station = self.selected_station()
+ if not station or not station.stream_url:
+ self.notify("No stream URL to copy", severity="warning")
+ return
+ url = station.stream_url
+ for copy_cmd in (["wl-copy"], ["xclip", "-selection", "clipboard"]):
+ if shutil.which(copy_cmd[0]):
+ subprocess.run(copy_cmd, input=url.encode(), check=False)
+ self.notify("Stream URL copied")
+ return
+ self.notify(url, title="Stream URL", timeout=10)
+
+ def on_unmount(self) -> None:
+ self.player.stop()
+
+
+def main() -> None:
+ RadioApp().run()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/radio_tui/player.py b/radio_tui/player.py
new file mode 100644
index 0000000..7f87782
--- /dev/null
+++ b/radio_tui/player.py
@@ -0,0 +1,163 @@
+from __future__ import annotations
+
+import json
+import shutil
+import subprocess
+import threading
+import time
+from dataclasses import dataclass
+from pathlib import Path
+
+
+@dataclass
+class PlayerState:
+ station_name: str = ""
+ stream_url: str = ""
+ media_title: str = ""
+ volume: int = 50
+ muted: bool = False
+ playing: bool = False
+ offline: bool = False
+
+
+class MpvPlayer:
+ def __init__(self, socket_path: Path | None = None) -> None:
+ self.socket_path = socket_path or Path("/tmp/radio_tui_mpvsocket")
+ self.state = PlayerState()
+ self._process: subprocess.Popen[bytes] | None = None
+ self._poll_thread: threading.Thread | None = None
+ self._stop_polling = threading.Event()
+ self._lock = threading.Lock()
+
+ @property
+ def available(self) -> bool:
+ return shutil.which("mpv") is not None and shutil.which("socat") is not None
+
+ def _ipc(self, command: list[str]) -> dict | None:
+ if not self.socket_path.exists():
+ return None
+ payload = json.dumps({"command": command}) + "\n"
+ try:
+ result = subprocess.run(
+ ["socat", "-", str(self.socket_path)],
+ input=payload.encode(),
+ capture_output=True,
+ timeout=2,
+ check=False,
+ )
+ except (OSError, subprocess.TimeoutExpired):
+ return None
+ if not result.stdout:
+ return None
+ try:
+ return json.loads(result.stdout.decode())
+ except json.JSONDecodeError:
+ return None
+
+ def _ensure_mpv(self, stream_url: str, volume: int) -> None:
+ if self._process and self._process.poll() is None:
+ return
+ self._stop_polling.set()
+ if self._poll_thread:
+ self._poll_thread.join(timeout=1)
+ self._stop_polling.clear()
+ if self.socket_path.exists():
+ self.socket_path.unlink(missing_ok=True)
+ self._process = subprocess.Popen(
+ [
+ "mpv",
+ f"--volume={volume}",
+ f"--input-ipc-server={self.socket_path}",
+ "--idle=yes",
+ "--no-terminal",
+ ],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ for _ in range(30):
+ if self.socket_path.exists():
+ break
+ time.sleep(0.1)
+
+ def play(self, station_name: str, stream_url: str) -> None:
+ with self._lock:
+ volume = self.state.volume
+ self._ensure_mpv(stream_url, volume)
+ if self._process and self._process.poll() is None and self.socket_path.exists():
+ self._ipc(["loadfile", stream_url])
+ self.state.station_name = station_name
+ self.state.stream_url = stream_url
+ self.state.media_title = "Connecting..."
+ self.state.playing = True
+ self.state.offline = False
+ self._start_poll()
+
+ def stop(self) -> None:
+ with self._lock:
+ self._stop_polling.set()
+ if self._poll_thread:
+ self._poll_thread.join(timeout=1)
+ if self._process and self._process.poll() is None:
+ self._process.terminate()
+ try:
+ self._process.wait(timeout=2)
+ except subprocess.TimeoutExpired:
+ self._process.kill()
+ self._process = None
+ self.socket_path.unlink(missing_ok=True)
+ self.state = PlayerState(volume=self.state.volume)
+
+ def adjust_volume(self, delta: int) -> None:
+ with self._lock:
+ self._ipc(["add", "volume", delta])
+ response = self._ipc(["get_property", "volume"])
+ if response and "data" in response:
+ self.state.volume = int(response["data"])
+
+ def toggle_mute(self) -> None:
+ with self._lock:
+ self._ipc(["cycle", "mute"])
+ response = self._ipc(["get_property", "mute"])
+ if response and "data" in response:
+ self.state.muted = bool(response["data"])
+
+ def _start_poll(self) -> None:
+ if self._poll_thread and self._poll_thread.is_alive():
+ self._stop_polling.set()
+ self._poll_thread.join(timeout=1)
+ self._stop_polling.clear()
+
+ def poll() -> None:
+ misses = 0
+ while not self._stop_polling.is_set():
+ response = self._ipc(["get_property", "media-title"])
+ title = ""
+ if response and response.get("data"):
+ title = str(response["data"])
+ if title:
+ misses = 0
+ with self._lock:
+ self.state.media_title = title
+ self.state.offline = title == "Station Offline"
+ else:
+ misses += 1
+ if misses >= 3:
+ with self._lock:
+ self.state.media_title = "Station Offline"
+ self.state.offline = True
+ self._stop_polling.wait(3)
+
+ self._poll_thread = threading.Thread(target=poll, daemon=True)
+ self._poll_thread.start()
+
+ def snapshot(self) -> PlayerState:
+ with self._lock:
+ return PlayerState(
+ station_name=self.state.station_name,
+ stream_url=self.state.stream_url,
+ media_title=self.state.media_title,
+ volume=self.state.volume,
+ muted=self.state.muted,
+ playing=self.state.playing,
+ offline=self.state.offline,
+ )
diff --git a/radio_tui/stations.py b/radio_tui/stations.py
new file mode 100644
index 0000000..8da3f58
--- /dev/null
+++ b/radio_tui/stations.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+import csv
+import json
+from dataclasses import dataclass
+from pathlib import Path
+
+
+@dataclass(frozen=True, slots=True)
+class Station:
+ name: str
+ page_url: str
+ stream_url: str | None
+ stream_available: bool
+
+ @property
+ def playable(self) -> bool:
+ return bool(self.stream_url)
+
+
+def data_dir() -> Path:
+ return Path(__file__).resolve().parent.parent
+
+
+def load_stations(path: Path | None = None) -> list[Station]:
+ json_path = path or data_dir() / "telugu-radio-stations.json"
+ with json_path.open(encoding="utf-8") as handle:
+ payload = json.load(handle)
+
+ stations: list[Station] = []
+ for item in payload["stations"]:
+ stations.append(
+ Station(
+ name=item["name"],
+ page_url=item["page_url"],
+ stream_url=item.get("stream_url") or None,
+ stream_available=bool(item.get("stream_available")),
+ )
+ )
+ return stations
+
+
+def export_csv(stations: list[Station], path: Path) -> None:
+ with path.open("w", encoding="utf-8", newline="") as handle:
+ writer = csv.DictWriter(
+ handle,
+ fieldnames=["name", "page_url", "stream_url", "stream_available"],
+ )
+ writer.writeheader()
+ for station in stations:
+ writer.writerow(
+ {
+ "name": station.name,
+ "page_url": station.page_url,
+ "stream_url": station.stream_url or "",
+ "stream_available": station.stream_available,
+ }
+ )
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..e397aa4
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1 @@
+textual>=1.0.0
diff --git a/telugu-radio-stations.csv b/telugu-radio-stations.csv
new file mode 100644
index 0000000..deafef6
--- /dev/null
+++ b/telugu-radio-stations.csv
@@ -0,0 +1,43 @@
+name,page_url,stream_url,stream_available,error
+AIR Akashvani Port Blair,https://onlineradiofm.in/stations/air-port-blair-pc,https://air.pc.cdn.bitgravity.com/air/live/pbaudio038/playlist.m3u8,True,
+Air FM Rainbow Hyderabad,https://onlineradiofm.in/telangana/hyderabad/fm-rainbow,https://air.pc.cdn.bitgravity.com/air/live/pbaudio031/playlist.m3u8,True,
+Air FM Rainbow Vijayawada,https://onlineradiofm.in/andhra-pradesh/vijayawada/fm-rainbow,https://air.pc.cdn.bitgravity.com/air/live/pbaudio174/playlist.m3u8,True,
+Air FM Rainbow Visakhapatnam,https://onlineradiofm.in/andhra-pradesh/visakhapatnam/fm-rainbow,https://air.pc.cdn.bitgravity.com/air/live/pbaudio081/playlist.m3u8,True,
+Akashvani Anantapur,https://onlineradiofm.in/stations/all-india-air-anantapur,https://air.pc.cdn.bitgravity.com/air/live/pbaudio054/playlist.m3u8,True,
+All India Radio AIR Adilabad,https://onlineradiofm.in/stations/all-india-air-aadilabad,https://air.pc.cdn.bitgravity.com/air/live/pbaudio218/playlist.m3u8,True,
+All India Radio Air Akashvani,https://onlineradiofm.in/stations/all-india-air-akashvani,https://airhlspush.pc.cdn.bitgravity.com/httppush/hlspbaudio002/hlspbaudio002_Auto.m3u8,True,
+All India Radio Air Cuddapah (Akashvani Kadapa),https://onlineradiofm.in/stations/all-india-air-cuddapah,https://air.pc.cdn.bitgravity.com/air/live/pbaudio018/playlist.m3u8,True,
+All India Radio AIR Hyderabad A,https://onlineradiofm.in/stations/all-india-air-hyderabad-a,https://air.pc.cdn.bitgravity.com/air/live/pbaudio032/playlist.m3u8,True,
+All India Radio AIR Hyderabad B,https://onlineradiofm.in/stations/all-india-air-hyderabad-b,https://air.pc.cdn.bitgravity.com/air/live/pbaudio033/playlist.m3u8,True,
+All India Radio AIR Kothagudem,https://onlineradiofm.in/stations/all-india-air-kothagudem,https://air.pc.cdn.bitgravity.com/air/live/pbaudio116/playlist.m3u8,True,
+All india radio Air Kurnool,https://onlineradiofm.in/stations/all-india-air-kurnool,https://air.pc.cdn.bitgravity.com/air/live/pbaudio052/playlist.m3u8,True,
+All india radio Air Markapur,https://onlineradiofm.in/stations/all-india-air-markapur,https://air.pc.cdn.bitgravity.com/air/live/pbaudio039/playlist.m3u8,True,
+All India Radio AIR Nellore,https://onlineradiofm.in/stations/all-india-air-nellore,https://air.pc.cdn.bitgravity.com/air/live/pbaudio168/playlist.m3u8,True,
+All India Radio AIR Nellore Nellore,https://onlineradiofm.in/andhra-pradesh/nellore/all-india-air-nellore,https://air.pc.cdn.bitgravity.com/air/live/pbaudio168/playlist.m3u8,True,
+All India Radio AIR Nizamabad,https://onlineradiofm.in/stations/all-india-air-nizamabad,https://air.pc.cdn.bitgravity.com/air/live/pbaudio222/playlist.m3u8,True,
+All India Radio Air Telugu,https://onlineradiofm.in/stations/all-india-air-telugu,https://air.pc.cdn.bitgravity.com/air/live/pbaudio032/playlist.m3u8,True,
+All India Radio AIR Tirupati,https://onlineradiofm.in/stations/all-india-air-tirupati,https://air.pc.cdn.bitgravity.com/air/live/pbaudio144/playlist.m3u8,True,
+All India Radio AIR Vijayawada - Akashvani Andhra Pradesh,https://onlineradiofm.in/stations/all-india-air-vijayawada,https://air.pc.cdn.bitgravity.com/air/live/pbaudio175/playlist.m3u8,True,
+All India Radio Air Visakhapatnam,https://onlineradiofm.in/stations/all-india-radio-air-visakhapatnam,https://air.pc.cdn.bitgravity.com/air/live/pbaudio080/playlist.m3u8,True,
+All India Radio AIR Warangal,https://onlineradiofm.in/stations/all-india-air-warangal,https://air.pc.cdn.bitgravity.com/air/live/pbaudio154/playlist.m3u8,True,
+AP 9 FM Radio,https://onlineradiofm.in/stations/ap-9-fm-telugu,https://stream.ap9fm.in/radio/8000/radio.mp3,True,
+London Telugu Radio,https://onlineradiofm.in/stations/london-telugu,https://c8.radioboss.fm/stream/33,True,
+Magic 106.4,https://onlineradiofm.in/stations/magic,,False,
+Melody Radio Telugu,https://onlineradiofm.in/stations/melody-radio-telugu,https://a1.asurahosting.com:9580/radio.mp3,True,
+Mohammed Rafi Radio,https://onlineradiofm.in/stations/city-mohammed-rafi,https://stream.zeno.fm/2xx62x8ztm0uv,True,
+Radio Bol Hyderabad,https://onlineradiofm.in/stations/bol-hyderabad,,False,
+Radio City Ghantasala Hits,https://onlineradiofm.in/stations/ghantasala-hits,,False,
+Radio City Telugu Gold,https://onlineradiofm.in/stations/city-telugu-gold,,False,
+Radio Hungama Hot now Telugu,https://onlineradiofm.in/stations/radio-hungama-hot-now-telugu,https://stream.zeno.fm/ysucrq37uwzuv,True,
+Radio Hungama Telugu FM,https://onlineradiofm.in/stations/hungama-telugu-fm,https://radioindia.net/radio/humgama-telugu/icecast.audio,True,
+Radio Mirchi 98.3 FM,https://onlineradiofm.in/stations/mirchi,https://eu8.fastcast4u.com/proxy/clyedupq?mp=%2F1?aw_0_req_lsid=2c0fae177108c9a42a7cf24878625444,True,
+Radio Mirchi 98.3 FM Hyderabad,https://onlineradiofm.in/telangana/hyderabad/mirchi,https://eu8.fastcast4u.com/proxy/clyedupq?mp=%2F1?aw_0_req_lsid=2c0fae177108c9a42a7cf24878625444,True,
+Radio Mirchi Bay Area Telugu,https://onlineradiofm.in/stations/mirchi-bay-area-telugu,https://playerservices.streamtheworld.com/api/livestream-redirect/MTL_TEL_ESTAAC.m3u8,True,
+Radio Myind Media,https://onlineradiofm.in/stations/myind-media,https://radioindia.net/radio/myndmedia/icecast.audio,True,
+Radio Ranjan 90.4 FM,https://onlineradiofm.in/stations/radio-ranjan-90.4-fm,https://stream.zeno.fm/rfqqoi2w5u0tv,True,
+Radio Vishnu 90.4,https://onlineradiofm.in/stations/radio-vishnu-90.4,https://streamasiahlscdn.atc-labs.com/radiovishnu.m3u8,True,
+Red FM 93.5,https://onlineradiofm.in/stations/red,https://stream.zeno.fm/9phrkb1e3v8uv,True,
+Telugu NRI Radio,https://onlineradiofm.in/stations/telugu-nri,https://cast5.asurahosting.com:2199/proxy/telugunr?mp=/stream,True,
+Teluguone Radio (TORI),https://onlineradiofm.in/stations/teluguone,https://stream.teluguoneradio.com:8164/;stream/1,True,
+Vividh Bharati Hyderabad,https://onlineradiofm.in/telangana/hyderabad/vividh-bharati,https://air.pc.cdn.bitgravity.com/air/live/pbaudio034/playlist.m3u8,True,
+Vividh Bharati Vijayawada,https://onlineradiofm.in/andhra-pradesh/vijayawada/vividh-bharati,https://air.pc.cdn.bitgravity.com/air/live/pbaudio176/playlist.m3u8,True,
diff --git a/telugu-radio-stations.json b/telugu-radio-stations.json
new file mode 100644
index 0000000..f27aebd
--- /dev/null
+++ b/telugu-radio-stations.json
@@ -0,0 +1,302 @@
+{
+ "source": "https://onlineradiofm.in/language/telugu",
+ "fetched_at": "2026-06-16",
+ "total_stations": 42,
+ "stations_with_stream": 38,
+ "stations": [
+ {
+ "name": "AIR Akashvani Port Blair",
+ "page_url": "https://onlineradiofm.in/stations/air-port-blair-pc",
+ "stream_url": "https://air.pc.cdn.bitgravity.com/air/live/pbaudio038/playlist.m3u8",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "Air FM Rainbow Hyderabad",
+ "page_url": "https://onlineradiofm.in/telangana/hyderabad/fm-rainbow",
+ "stream_url": "https://air.pc.cdn.bitgravity.com/air/live/pbaudio031/playlist.m3u8",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "Air FM Rainbow Vijayawada",
+ "page_url": "https://onlineradiofm.in/andhra-pradesh/vijayawada/fm-rainbow",
+ "stream_url": "https://air.pc.cdn.bitgravity.com/air/live/pbaudio174/playlist.m3u8",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "Air FM Rainbow Visakhapatnam",
+ "page_url": "https://onlineradiofm.in/andhra-pradesh/visakhapatnam/fm-rainbow",
+ "stream_url": "https://air.pc.cdn.bitgravity.com/air/live/pbaudio081/playlist.m3u8",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "Akashvani Anantapur",
+ "page_url": "https://onlineradiofm.in/stations/all-india-air-anantapur",
+ "stream_url": "https://air.pc.cdn.bitgravity.com/air/live/pbaudio054/playlist.m3u8",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "All India Radio AIR Adilabad",
+ "page_url": "https://onlineradiofm.in/stations/all-india-air-aadilabad",
+ "stream_url": "https://air.pc.cdn.bitgravity.com/air/live/pbaudio218/playlist.m3u8",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "All India Radio Air Akashvani",
+ "page_url": "https://onlineradiofm.in/stations/all-india-air-akashvani",
+ "stream_url": "https://airhlspush.pc.cdn.bitgravity.com/httppush/hlspbaudio002/hlspbaudio002_Auto.m3u8",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "All India Radio Air Cuddapah (Akashvani Kadapa)",
+ "page_url": "https://onlineradiofm.in/stations/all-india-air-cuddapah",
+ "stream_url": "https://air.pc.cdn.bitgravity.com/air/live/pbaudio018/playlist.m3u8",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "All India Radio AIR Hyderabad A",
+ "page_url": "https://onlineradiofm.in/stations/all-india-air-hyderabad-a",
+ "stream_url": "https://air.pc.cdn.bitgravity.com/air/live/pbaudio032/playlist.m3u8",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "All India Radio AIR Hyderabad B",
+ "page_url": "https://onlineradiofm.in/stations/all-india-air-hyderabad-b",
+ "stream_url": "https://air.pc.cdn.bitgravity.com/air/live/pbaudio033/playlist.m3u8",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "All India Radio AIR Kothagudem",
+ "page_url": "https://onlineradiofm.in/stations/all-india-air-kothagudem",
+ "stream_url": "https://air.pc.cdn.bitgravity.com/air/live/pbaudio116/playlist.m3u8",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "All india radio Air Kurnool",
+ "page_url": "https://onlineradiofm.in/stations/all-india-air-kurnool",
+ "stream_url": "https://air.pc.cdn.bitgravity.com/air/live/pbaudio052/playlist.m3u8",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "All india radio Air Markapur",
+ "page_url": "https://onlineradiofm.in/stations/all-india-air-markapur",
+ "stream_url": "https://air.pc.cdn.bitgravity.com/air/live/pbaudio039/playlist.m3u8",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "All India Radio AIR Nellore",
+ "page_url": "https://onlineradiofm.in/stations/all-india-air-nellore",
+ "stream_url": "https://air.pc.cdn.bitgravity.com/air/live/pbaudio168/playlist.m3u8",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "All India Radio AIR Nellore Nellore",
+ "page_url": "https://onlineradiofm.in/andhra-pradesh/nellore/all-india-air-nellore",
+ "stream_url": "https://air.pc.cdn.bitgravity.com/air/live/pbaudio168/playlist.m3u8",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "All India Radio AIR Nizamabad",
+ "page_url": "https://onlineradiofm.in/stations/all-india-air-nizamabad",
+ "stream_url": "https://air.pc.cdn.bitgravity.com/air/live/pbaudio222/playlist.m3u8",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "All India Radio Air Telugu",
+ "page_url": "https://onlineradiofm.in/stations/all-india-air-telugu",
+ "stream_url": "https://air.pc.cdn.bitgravity.com/air/live/pbaudio032/playlist.m3u8",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "All India Radio AIR Tirupati",
+ "page_url": "https://onlineradiofm.in/stations/all-india-air-tirupati",
+ "stream_url": "https://air.pc.cdn.bitgravity.com/air/live/pbaudio144/playlist.m3u8",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "All India Radio AIR Vijayawada - Akashvani Andhra Pradesh",
+ "page_url": "https://onlineradiofm.in/stations/all-india-air-vijayawada",
+ "stream_url": "https://air.pc.cdn.bitgravity.com/air/live/pbaudio175/playlist.m3u8",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "All India Radio Air Visakhapatnam",
+ "page_url": "https://onlineradiofm.in/stations/all-india-radio-air-visakhapatnam",
+ "stream_url": "https://air.pc.cdn.bitgravity.com/air/live/pbaudio080/playlist.m3u8",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "All India Radio AIR Warangal",
+ "page_url": "https://onlineradiofm.in/stations/all-india-air-warangal",
+ "stream_url": "https://air.pc.cdn.bitgravity.com/air/live/pbaudio154/playlist.m3u8",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "AP 9 FM Radio",
+ "page_url": "https://onlineradiofm.in/stations/ap-9-fm-telugu",
+ "stream_url": "https://stream.ap9fm.in/radio/8000/radio.mp3",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "London Telugu Radio",
+ "page_url": "https://onlineradiofm.in/stations/london-telugu",
+ "stream_url": "https://c8.radioboss.fm/stream/33",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "Magic 106.4",
+ "page_url": "https://onlineradiofm.in/stations/magic",
+ "stream_url": null,
+ "stream_available": false,
+ "error": null
+ },
+ {
+ "name": "Melody Radio Telugu",
+ "page_url": "https://onlineradiofm.in/stations/melody-radio-telugu",
+ "stream_url": "https://a1.asurahosting.com:9580/radio.mp3",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "Mohammed Rafi Radio",
+ "page_url": "https://onlineradiofm.in/stations/city-mohammed-rafi",
+ "stream_url": "https://stream.zeno.fm/2xx62x8ztm0uv",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "Radio Bol Hyderabad",
+ "page_url": "https://onlineradiofm.in/stations/bol-hyderabad",
+ "stream_url": null,
+ "stream_available": false,
+ "error": null
+ },
+ {
+ "name": "Radio City Ghantasala Hits",
+ "page_url": "https://onlineradiofm.in/stations/ghantasala-hits",
+ "stream_url": null,
+ "stream_available": false,
+ "error": null
+ },
+ {
+ "name": "Radio City Telugu Gold",
+ "page_url": "https://onlineradiofm.in/stations/city-telugu-gold",
+ "stream_url": null,
+ "stream_available": false,
+ "error": null
+ },
+ {
+ "name": "Radio Hungama Hot now Telugu",
+ "page_url": "https://onlineradiofm.in/stations/radio-hungama-hot-now-telugu",
+ "stream_url": "https://stream.zeno.fm/ysucrq37uwzuv",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "Radio Hungama Telugu FM",
+ "page_url": "https://onlineradiofm.in/stations/hungama-telugu-fm",
+ "stream_url": "https://radioindia.net/radio/humgama-telugu/icecast.audio",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "Radio Mirchi 98.3 FM",
+ "page_url": "https://onlineradiofm.in/stations/mirchi",
+ "stream_url": "https://eu8.fastcast4u.com/proxy/clyedupq?mp=%2F1?aw_0_req_lsid=2c0fae177108c9a42a7cf24878625444",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "Radio Mirchi 98.3 FM Hyderabad",
+ "page_url": "https://onlineradiofm.in/telangana/hyderabad/mirchi",
+ "stream_url": "https://eu8.fastcast4u.com/proxy/clyedupq?mp=%2F1?aw_0_req_lsid=2c0fae177108c9a42a7cf24878625444",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "Radio Mirchi Bay Area Telugu",
+ "page_url": "https://onlineradiofm.in/stations/mirchi-bay-area-telugu",
+ "stream_url": "https://playerservices.streamtheworld.com/api/livestream-redirect/MTL_TEL_ESTAAC.m3u8",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "Radio Myind Media",
+ "page_url": "https://onlineradiofm.in/stations/myind-media",
+ "stream_url": "https://radioindia.net/radio/myndmedia/icecast.audio",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "Radio Ranjan 90.4 FM",
+ "page_url": "https://onlineradiofm.in/stations/radio-ranjan-90.4-fm",
+ "stream_url": "https://stream.zeno.fm/rfqqoi2w5u0tv",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "Radio Vishnu 90.4",
+ "page_url": "https://onlineradiofm.in/stations/radio-vishnu-90.4",
+ "stream_url": "https://streamasiahlscdn.atc-labs.com/radiovishnu.m3u8",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "Red FM 93.5",
+ "page_url": "https://onlineradiofm.in/stations/red",
+ "stream_url": "https://stream.zeno.fm/9phrkb1e3v8uv",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "Telugu NRI Radio",
+ "page_url": "https://onlineradiofm.in/stations/telugu-nri",
+ "stream_url": "https://cast5.asurahosting.com:2199/proxy/telugunr?mp=/stream",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "Teluguone Radio (TORI)",
+ "page_url": "https://onlineradiofm.in/stations/teluguone",
+ "stream_url": "https://stream.teluguoneradio.com:8164/;stream/1",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "Vividh Bharati Hyderabad",
+ "page_url": "https://onlineradiofm.in/telangana/hyderabad/vividh-bharati",
+ "stream_url": "https://air.pc.cdn.bitgravity.com/air/live/pbaudio034/playlist.m3u8",
+ "stream_available": true,
+ "error": null
+ },
+ {
+ "name": "Vividh Bharati Vijayawada",
+ "page_url": "https://onlineradiofm.in/andhra-pradesh/vijayawada/vividh-bharati",
+ "stream_url": "https://air.pc.cdn.bitgravity.com/air/live/pbaudio176/playlist.m3u8",
+ "stream_available": true,
+ "error": null
+ }
+ ]
+}