diff --git a/fluxtuner/core/discovery_execution.py b/fluxtuner/core/discovery_execution.py new file mode 100644 index 0000000..a30f977 --- /dev/null +++ b/fluxtuner/core/discovery_execution.py @@ -0,0 +1,56 @@ +# SPDX-License-Identifier: MIT +"""Execute desktop discovery with a worker-owned, read-only local connection.""" + +from __future__ import annotations + +import sqlite3 +from contextlib import closing +from pathlib import Path + +from fluxtuner.core import db +from fluxtuner.core.search_service import SearchRequest, SearchResult, SearchService +from fluxtuner.players.capabilities import PlayerCapabilities + + +def search_desktop_stations( + request: SearchRequest, + *, + profile_name: str | None = None, + capabilities: PlayerCapabilities | None = None, + db_path: Path | None = None, +) -> SearchResult: + """Search without creating profiles or sharing SQLite connections across threads.""" + if request.source == "radio-browser": + return SearchService(capabilities=capabilities).search(request) + + if request.source not in ("local", "all"): + raise ValueError("Unknown station discovery source.") + + name = db.DEFAULT_PROFILE_NAME if profile_name is None else profile_name.strip() + if not name: + raise ValueError("An existing profile is required.") + + path = db.DB_FILE if db_path is None else db_path + uri = path.resolve().as_uri() + "?mode=ro" + + with closing(sqlite3.connect(uri, uri=True)) as conn: + conn.row_factory = sqlite3.Row + profile = conn.execute( + """ + SELECT p.id + FROM profiles AS p + JOIN users AS u ON u.id = p.user_id + WHERE lower(u.username) = lower(?) + AND lower(p.name) = lower(?) + """, + (db.DEFAULT_USER_NAME, name), + ).fetchone() + if profile is None: + raise ValueError("The current profile is unavailable.") + + service = SearchService( + capabilities=capabilities, + local_connection=conn, + profile_id=int(profile["id"]), + ) + return service.search(request) diff --git a/fluxtuner/gui/window.py b/fluxtuner/gui/window.py index db63897..3602528 100644 --- a/fluxtuner/gui/window.py +++ b/fluxtuner/gui/window.py @@ -7,7 +7,7 @@ from contextlib import suppress from pathlib import Path from time import monotonic -from typing import Any +from typing import Any, Literal from uuid import uuid4 import gi @@ -30,6 +30,7 @@ ) from fluxtuner.core.custom_stations import add_custom_station # noqa: E402 from fluxtuner.core.data_usage import DataUsageTracker, format_usage_line # noqa: E402 +from fluxtuner.core.discovery_execution import search_desktop_stations # noqa: E402 from fluxtuner.core.favorites import ( # noqa: E402 add_favorite, all_favorite_tags, @@ -358,6 +359,14 @@ def _build_search_bar(self, root: Gtk.Box) -> None: self.min_bitrate_entry.connect("activate", self.on_search_clicked) search_bar.append(self.min_bitrate_entry) + self.search_source_dropdown = Gtk.DropDown.new_from_strings( + ["Radio Browser", "Local", "All"] + ) + self.search_source_dropdown.set_selected(0) + self.search_source_dropdown.set_tooltip_text("Station discovery source") + search_bar.append(self.search_source_dropdown) + self.search_source_dropdown.connect("notify::selected", self._on_search_source_changed) + search_button = Gtk.Button(label="Search") search_button.connect("clicked", self.on_search_clicked) search_bar.append(search_button) @@ -1354,25 +1363,56 @@ def _set_view_status( station_word = "station" if count == 1 else "stations" self.status_label.set_text(f"{view_name}{suffix} · {count} {station_word}") + def _selected_search_source(self) -> Literal["radio-browser", "local", "all"]: + sources: tuple[Literal["radio-browser", "local", "all"], ...] = ( + "radio-browser", + "local", + "all", + ) + selected = self.search_source_dropdown.get_selected() + if selected >= len(sources): + raise ValueError("Unknown station discovery source.") + return sources[selected] + + def _on_search_source_changed(self, _dropdown: Gtk.DropDown, _param: object) -> None: + self._search_lifecycle.invalidate() + self._view_state.show_search() + self.last_search_results = [] + self.stations = [] + self.selected_station = None + self._render_results() + self._update_playlist_status() + self._set_view_status("Search results", 0) + def on_search_clicked(self, _widget: Gtk.Widget) -> None: query = self.search_entry.get_text().strip() country = self.country_entry.get_text().strip() or None min_bitrate_text = self.min_bitrate_entry.get_text().strip() min_bitrate = int(min_bitrate_text) if min_bitrate_text.isdigit() else None + source = self._selected_search_source() + profile_name = self.profile_name + capabilities = self.player_capabilities generation = self._search_lifecycle.begin() self.status_label.set_text("Searching…") def worker() -> None: try: - result = self.search_service.search( - SearchRequest( - query=query, - country=country, - min_bitrate=min_bitrate, - limit=50, - ) + request = SearchRequest( + query=query, + country=country, + min_bitrate=min_bitrate, + limit=50, + source=source, ) + if source == "radio-browser": + result = self.search_service.search(request) + else: + result = search_desktop_stations( + request, + profile_name=profile_name, + capabilities=capabilities, + ) stations = result.stations except Exception as exc: # noqa: BLE001 - user-facing status in GTK GUI. GLib.idle_add(self._search_failed, generation, str(exc)) diff --git a/fluxtuner/themes/amber.tcss b/fluxtuner/themes/amber.tcss index 0986a34..16ac0e7 100644 --- a/fluxtuner/themes/amber.tcss +++ b/fluxtuner/themes/amber.tcss @@ -67,6 +67,17 @@ Footer .footer--highlight { color: #f5e6c8; } +#source-filter > SelectCurrent { + border: round #e3b241; + background: #111111; + color: #f5e6c8; +} + +#source-filter:focus > SelectCurrent { + border: heavy #e3b241; +} + + .filter-label { width: 10; content-align: right middle; diff --git a/fluxtuner/themes/default.tcss b/fluxtuner/themes/default.tcss index b778fed..95a3251 100644 --- a/fluxtuner/themes/default.tcss +++ b/fluxtuner/themes/default.tcss @@ -67,6 +67,17 @@ Footer .footer--highlight { color: #e5e7eb; } +#source-filter > SelectCurrent { + border: round #60a5fa; + background: #0f172a; + color: #e5e7eb; +} + +#source-filter:focus > SelectCurrent { + border: heavy #60a5fa; +} + + .filter-label { width: 10; content-align: right middle; diff --git a/fluxtuner/themes/dracula.tcss b/fluxtuner/themes/dracula.tcss index f657423..ffc8cc7 100644 --- a/fluxtuner/themes/dracula.tcss +++ b/fluxtuner/themes/dracula.tcss @@ -67,6 +67,17 @@ Footer .footer--highlight { color: #f8f8f2; } +#source-filter > SelectCurrent { + border: round #bd93f9; + background: #282a36; + color: #f8f8f2; +} + +#source-filter:focus > SelectCurrent { + border: heavy #bd93f9; +} + + .filter-label { width: 10; content-align: right middle; diff --git a/fluxtuner/themes/fluxtuner.tcss b/fluxtuner/themes/fluxtuner.tcss index fa315e0..2ee3903 100644 --- a/fluxtuner/themes/fluxtuner.tcss +++ b/fluxtuner/themes/fluxtuner.tcss @@ -68,6 +68,17 @@ Footer .footer--highlight { color: #f4f7fb; } +#source-filter > SelectCurrent { + border: round #6ee7d8; + background: #090d14; + color: #f4f7fb; +} + +#source-filter:focus > SelectCurrent { + border: heavy #6ee7d8; +} + + .filter-label { width: 10; content-align: right middle; diff --git a/fluxtuner/themes/nord.tcss b/fluxtuner/themes/nord.tcss index ab3f879..d30418f 100644 --- a/fluxtuner/themes/nord.tcss +++ b/fluxtuner/themes/nord.tcss @@ -67,6 +67,17 @@ Footer .footer--highlight { color: #eceff4; } +#source-filter > SelectCurrent { + border: round #88c0d0; + background: #2e3440; + color: #eceff4; +} + +#source-filter:focus > SelectCurrent { + border: heavy #88c0d0; +} + + .filter-label { width: 10; content-align: right middle; diff --git a/fluxtuner/themes/ptmtrx.tcss b/fluxtuner/themes/ptmtrx.tcss index bbcb87e..8d8fb2c 100644 --- a/fluxtuner/themes/ptmtrx.tcss +++ b/fluxtuner/themes/ptmtrx.tcss @@ -67,6 +67,17 @@ Footer .footer--highlight { color: #F8F8F2; } +#source-filter > SelectCurrent { + border: round #58d68d; + background: #1B1D1E; + color: #F8F8F2; +} + +#source-filter:focus > SelectCurrent { + border: heavy #58d68d; +} + + .filter-label { width: 10; content-align: right middle; diff --git a/fluxtuner/tui.py b/fluxtuner/tui.py index 38143b0..f7475f0 100644 --- a/fluxtuner/tui.py +++ b/fluxtuner/tui.py @@ -6,14 +6,14 @@ import time from contextlib import suppress from pathlib import Path -from typing import Any +from typing import Any, Literal from textual import on from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Horizontal, Vertical, VerticalScroll from textual.screen import ModalScreen -from textual.widgets import Button, DataTable, Footer, Header, Input, Label, Static +from textual.widgets import Button, DataTable, Footer, Header, Input, Label, Select, Static from fluxtuner.config import get_playback_state, save_playback_state, set_config_value from fluxtuner.core import db @@ -22,6 +22,7 @@ station_is_supported, ) from fluxtuner.core.data_usage import DataUsageTracker, format_usage_line +from fluxtuner.core.discovery_execution import search_desktop_stations from fluxtuner.core.favorites import ( add_favorite, all_favorite_tags, @@ -172,6 +173,13 @@ def close_from_button(self) -> None: class FluxTunerTUI(App[None]): """Textual application for FluxTuner.""" + CSS = """ + #source-filter { + width: 22; + height: 3; + } + """ + BINDINGS = [ Binding("space", "play_stop", "Play/Stop"), Binding("/", "focus_search", "Search"), @@ -233,6 +241,7 @@ def __init__(self, theme: str | None = None, player_name: str = "mpv") -> None: self.restored_muted: bool = False self.view_mode = "search" self._search_task: asyncio.Task[None] | None = None + self._search_generation = 0 self.pending_input_action: str | None = None self.favorite_tag_filter: str | None = None self.table_items: dict[str, tuple[str, Any]] = {} @@ -253,6 +262,17 @@ def compose(self) -> ComposeResult: yield Input(placeholder="optional", id="country-filter") yield Label("Min kbps", classes="filter-label") yield Input(placeholder="0", id="bitrate-filter") + yield Label("Source", classes="filter-label") + yield Select( + [ + ("Radio Browser", "radio-browser"), + ("Local", "local"), + ("All sources", "all"), + ], + value="radio-browser", + allow_blank=False, + id="source-filter", + ) yield Button( "Clear filters", id="clear-filters", classes="toolbar-button secondary-button" ) @@ -566,6 +586,27 @@ def live_search_from_input(self, event: Input.Changed) -> None: return self.schedule_live_search(event.value) + def _selected_discovery_source( + self, + ) -> Literal["radio-browser", "local", "all"]: + selected = self.query_one("#source-filter", Select).value + if selected == "radio-browser": + return "radio-browser" + if selected == "local": + return "local" + if selected == "all": + return "all" + raise ValueError("Unknown station discovery source.") + + @on(Select.Changed, "#source-filter") + def discovery_source_changed(self, _event: Select.Changed) -> None: + self.cancel_pending_search() + self._set_view_mode("search") + self.query_one("#stations", DataTable).clear(columns=True) + self._clear_station_view_selection() + self.update_mode_title("Search results") + self.set_status("Discovery source changed. Search to refresh results.") + @on(Input.Submitted, "#country-filter") async def search_from_country_filter(self, _event: Input.Submitted) -> None: self.cancel_pending_search() @@ -649,6 +690,8 @@ def _clear_view_selection(self) -> None: self.selected_playlist = None def _set_view_mode(self, mode: str) -> None: + if mode != "search": + self.cancel_pending_search() self.view_mode = mode def _clear_station_view_selection(self) -> None: @@ -657,11 +700,11 @@ def _clear_station_view_selection(self) -> None: self.update_details(None) def _enter_playlists_view(self) -> None: - self.view_mode = "playlists" + self._set_view_mode("playlists") self.active_playlist_name = None def _enter_playlist_stations_view(self, playlist_name: str) -> None: - self.view_mode = "playlist_stations" + self._set_view_mode("playlist_stations") self.active_playlist_name = playlist_name def _clear_playlist_station_selection(self, playlist_name: str) -> None: @@ -672,7 +715,7 @@ def _clear_playlist_station_selection(self, playlist_name: str) -> None: self.update_details(None) def _enter_themes_view(self) -> None: - self.view_mode = "themes" + self._set_view_mode("themes") def _clear_theme_selection(self) -> None: self.selected_station = None @@ -755,6 +798,7 @@ def item_selected(self, event: DataTable.RowSelected) -> None: self.smart_play_selected_playlist_or_tag() def cancel_pending_search(self) -> None: + self._search_generation = getattr(self, "_search_generation", 0) + 1 if self._search_task and not self._search_task.done(): self._search_task.cancel() self._search_task = None @@ -794,6 +838,8 @@ async def search(self, query: str, live: bool = False) -> None: self.set_status("Type a station name/genre, or use country/min kbps filters.") return + self._search_generation = getattr(self, "_search_generation", 0) + 1 + generation = self._search_generation self._set_view_mode("search") self._search_task = None self.update_mode_title("Search results") @@ -810,22 +856,36 @@ async def search(self, query: str, live: bool = False) -> None: except ValueError: self.set_status("Min kbps must be a number.") return - result = await asyncio.to_thread( - self.search_service.search, - SearchRequest( - query=query, - country=country or None, - min_bitrate=min_bitrate, - limit=50, - ), + source = self._selected_discovery_source() + request = SearchRequest( + query=query, + country=country or None, + min_bitrate=min_bitrate, + limit=50, + source=source, ) + if source == "radio-browser": + result = await asyncio.to_thread(self.search_service.search, request) + else: + result = await asyncio.to_thread( + search_desktop_stations, + request, + profile_name=self.profile_name, + capabilities=self.player_capabilities, + ) + if generation != self._search_generation or self.view_mode != "search": + return stations = result.stations except Exception as exc: # noqa: BLE001 + if generation != self._search_generation or self.view_mode != "search": + return self.set_status(f"Search failed: {exc}") self.notify(f"Search failed: {exc}", severity="error") return await self.populate_station_list(stations) + if generation != self._search_generation or self.view_mode != "search": + return if not live: self.query_one("#stations", DataTable).focus() filters = [] @@ -1563,7 +1623,7 @@ def prepare_favorite_tags_edit(self) -> None: ) def prepare_favorites_tag_filter(self) -> None: - self.view_mode = "favorites" + self._set_view_mode("favorites") query = self.query_one("#query", Input) query.value = self.favorite_tag_filter or "" query.focus() diff --git a/fluxtuner/web/library.py b/fluxtuner/web/library.py index 40adf6f..c22fca0 100644 --- a/fluxtuner/web/library.py +++ b/fluxtuner/web/library.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any +from typing import Any, Literal from fluxtuner.core import db from fluxtuner.core.api import search_stations_filtered_debug @@ -18,6 +18,7 @@ remove_station_from_playlist, ) from fluxtuner.core.profiles import get_profile_by_name +from fluxtuner.core.search_service import SearchRequest, SearchService from fluxtuner.web.payloads import station_payload SEARCH_STATUS_OK = "ok" @@ -45,21 +46,75 @@ def search_payload( min_bitrate: int, limit: int, debug: bool = False, + source: Literal["radio-browser", "local", "all"] = "radio-browser", + user_id: int | None = None, + profile_name: str | None = None, ) -> dict[str, Any]: normalized_query = query.strip() country_filter = country.strip() or None bitrate_filter = min_bitrate if min_bitrate > 0 else None - stations, debug_info = search_stations_filtered_debug( - query=normalized_query, - country=country_filter, - min_bitrate=bitrate_filter, - limit=limit, - use_cache=not debug, - ) + if source == "radio-browser": + # Preserve the existing remote search API and diagnostic contract. + stations, debug_info = search_stations_filtered_debug( + query=normalized_query, + country=country_filter, + min_bitrate=bitrate_filter, + limit=limit, + use_cache=not debug, + ) + status = _aggregate_search_status(debug_info) + else: + if source not in ("local", "all"): + raise ValueError("Unknown station discovery source.") + if user_id is None: + raise ValueError("Authenticated user is required.") + + requested_profile = db.DEFAULT_PROFILE_NAME if profile_name is None else profile_name + if not requested_profile: + raise ValueError("The requested profile is unavailable.") + + # Only the authenticated Web user's profile is eligible for discovery. + # Never use the desktop user or create profiles during search. + with db.connect() as conn: + profile = get_profile_by_name(conn, requested_profile, user_id=user_id) + if profile is None: + raise ValueError("The requested profile is unavailable.") + + result = SearchService( + local_connection=conn, + profile_id=int(profile["id"]), + ).search( + SearchRequest( + query=normalized_query, + country=country_filter, + min_bitrate=bitrate_filter, + limit=limit, + use_cache=not debug, + source=source, + ) + ) + + stations = result.stations + if source == "local": + status = SEARCH_STATUS_OK + elif result.remote_status == "error": + status = SEARCH_STATUS_PARTIAL if stations else SEARCH_STATUS_UNAVAILABLE + elif result.remote_status == "partial_error": + status = SEARCH_STATUS_PARTIAL + else: + status = SEARCH_STATUS_OK + + debug_info = { + "source": source, + "total_found": result.total_found, + "unsupported_count": result.unsupported_count, + "remote_status": result.remote_status, + "source_observations": result.source_observations, + } payload = { - "status": _aggregate_search_status(debug_info), + "status": status, "query": normalized_query, "country": country_filter or "", "min_bitrate": min_bitrate, diff --git a/fluxtuner/web/routes/library.py b/fluxtuner/web/routes/library.py index c321157..2c81582 100644 --- a/fluxtuner/web/routes/library.py +++ b/fluxtuner/web/routes/library.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any +from typing import Any, Literal from fastapi import APIRouter, Body, HTTPException, Path, Query, Request @@ -55,16 +55,24 @@ def search( min_bitrate: int = Query(default=0, ge=0, le=1000), limit: int = Query(default=25, ge=1, le=100), debug: bool = Query(default=False), + source: Literal["radio-browser", "local", "all"] = Query(default="radio-browser"), + profile: str | None = Query(default=None, max_length=80), ) -> dict[str, Any]: - _require_authenticated_user(request) + user = _require_authenticated_user(request) - return library.search_payload( - query=q, - country=country, - min_bitrate=min_bitrate, - limit=limit, - debug=debug, - ) + try: + return library.search_payload( + query=q, + country=country, + min_bitrate=min_bitrate, + limit=limit, + debug=debug, + source=source, + user_id=int(user["id"]), + profile_name=web_context.effective_profile_name(profile), + ) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc @router.get("/api/history") diff --git a/fluxtuner/web/static/js/search.js b/fluxtuner/web/static/js/search.js index a8fc484..658dc0b 100644 --- a/fluxtuner/web/static/js/search.js +++ b/fluxtuner/web/static/js/search.js @@ -74,6 +74,32 @@ export function createSearchController({ let activeSearchController = null; let searchRequestId = 0; + const sourceSelector = searchForm?.querySelector('[name="source"]'); + const sourceLabels = { + "radio-browser": "Radio Browser", + local: "Local library", + all: "All sources", + }; + + function selectedSource() { + const value = sourceSelector?.value || "radio-browser"; + return Object.hasOwn(sourceLabels, value) ? value : "radio-browser"; + } + + sourceSelector?.addEventListener("change", () => { + activeSearchController?.abort(); + activeSearchController = null; + ++searchRequestId; + + setSearchView(); + setResultsHeader(sourceLabels[selectedSource()], "Search stations"); + if (resultCountNode) resultCountNode.textContent = "No search yet."; + if (resultsNode) { + resultsNode.innerHTML = + '
Search with the selected source to see results.
'; + } + }); + function renderResults(payload) { if (!resultsNode || !resultCountNode) return; @@ -83,7 +109,7 @@ export function createSearchController({ if (status === "unavailable") { resultCountNode.textContent = "Search unavailable."; - resultsNode.innerHTML = `${debugPanel}Radio Browser is temporarily unavailable. Please try again.
`; + resultsNode.innerHTML = `${debugPanel}The selected search sources are temporarily unavailable. Please try again.
`; return; } @@ -133,6 +159,7 @@ export function createSearchController({ const params = new URLSearchParams(); params.set("q", String(formData.get("q") || "").trim()); + params.set("source", selectedSource()); params.set("country", String(formData.get("country") || "").trim()); const minBitrate = String(formData.get("min_bitrate") || "0").trim(); params.set("min_bitrate", minBitrate || "0"); @@ -147,10 +174,11 @@ export function createSearchController({ return; } + const sourceLabel = sourceLabels[selectedSource()]; setSearchView(); - setResultsHeader("Radio Browser", "Search stations"); + setResultsHeader(sourceLabel, "Search stations"); resultCountNode.textContent = "Searching..."; - resultsNode.innerHTML = 'Searching Radio Browser...
'; + resultsNode.innerHTML = `Searching ${sourceLabel}...
`; try { const response = await fetch(`/api/search?${params.toString()}`, { diff --git a/fluxtuner/web/static/search.css b/fluxtuner/web/static/search.css index 9e73d89..cffe02a 100644 --- a/fluxtuner/web/static/search.css +++ b/fluxtuner/web/static/search.css @@ -7,7 +7,8 @@ .search-form { display: grid; grid-template-columns: - minmax(13rem, 1.25fr) minmax(9rem, 0.85fr) minmax(6.5rem, 0.4fr) + minmax(9rem, 0.8fr) minmax(13rem, 1.25fr) + minmax(9rem, 0.85fr) minmax(6.5rem, 0.4fr) minmax(6.5rem, 0.35fr) auto auto; gap: 0.75rem; align-items: end; diff --git a/fluxtuner/web/templates/index.html b/fluxtuner/web/templates/index.html index 55d4e5d..f8bda16 100644 --- a/fluxtuner/web/templates/index.html +++ b/fluxtuner/web/templates/index.html @@ -452,6 +452,15 @@