Skip to content

Commit 6cc9e6f

Browse files
committed
Add admin dashboard, API endpoints, CLI provider
Add admin UI and backend integrations for the CLI, plus a command-palette provider and deployment configs. New admin screen (cli/src/spotdl_cli/screens/admin.py) implements dashboard, matches/reports/users tables and actions; account screen shows an Admin Dashboard button and pushes the admin screen. API client (cli/src/spotdl_cli/core/api_client.py) gains admin endpoints: get_admin_stats, get_admin_matches, approve_match, reject_match, get_admin_reports, resolve_report, get_admin_users, update_user_role, and ban_user. Add a SpotDLCommandProvider (cli/src/spotdl_cli/providers.py) to surface universal search results in the command palette and register it with the app. Small test update: test_api_client now calls get_metadata_sources with an id. Also add railway.toml files for backend and frontend deployment configuration.
1 parent 73c3612 commit 6cc9e6f

File tree

9 files changed

+689
-1
lines changed

9 files changed

+689
-1
lines changed

backend/railway.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[build]
2+
builder = "DOCKERFILE"
3+
dockerfilePath = "Dockerfile"
4+
watchPatterns = ["backend/**", "core/**"]
5+
6+
[deploy]
7+
healthcheckPath = "/api/v1/health"
8+
healthcheckTimeout = 100
9+
restartPolicyType = "ON_FAILURE"

cli/src/spotdl_cli/app.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from spotdl_cli.screens.onboarding import OnboardingScreen, should_show_onboarding
2626
from spotdl_cli.screens.queue import QueueScreen
2727
from spotdl_cli.screens.settings import SettingsScreen
28+
from spotdl_cli.providers import SpotDLCommandProvider
2829

2930
if TYPE_CHECKING:
3031
from textual.screen import Screen
@@ -38,6 +39,8 @@ class SpotDLApp(App[None]):
3839
TITLE = "SpotDL"
3940
SUB_TITLE = f"v{__version__}"
4041
CSS_PATH = "app.tcss"
42+
43+
COMMANDS = App.COMMANDS | {SpotDLCommandProvider}
4144

4245
BINDINGS: ClassVar[list[Binding]] = [
4346
Binding("q", "quit", "Quit", priority=True),

cli/src/spotdl_cli/core/api_client.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1560,6 +1560,155 @@ async def enrich_song_all_sources(self, song_id: str) -> dict[str, Any]:
15601560
except httpx.HTTPError as e:
15611561
raise APIError(f"Request failed: {e}") from e
15621562

1563+
async def get_admin_stats(self) -> dict[str, Any]:
1564+
"""Get admin dashboard statistics."""
1565+
try:
1566+
client = await self._get_client()
1567+
response = await client.get("/api/v1/admin/stats")
1568+
response.raise_for_status()
1569+
return response.json()
1570+
except httpx.ConnectError as e:
1571+
raise ConnectionError(f"Cannot connect to API: {e}") from e
1572+
except httpx.HTTPStatusError as e:
1573+
raise APIError(f"API error: {e.response.text}") from e
1574+
except httpx.HTTPError as e:
1575+
raise APIError(f"Request failed: {e}") from e
1576+
1577+
async def get_admin_matches(
1578+
self, status: str | None = None, limit: int = 50, offset: int = 0
1579+
) -> dict[str, Any]:
1580+
"""Get matches for admin review."""
1581+
try:
1582+
client = await self._get_client()
1583+
params = {"limit": limit, "offset": offset}
1584+
if status:
1585+
params["status"] = status
1586+
response = await client.get("/api/v1/admin/matches", params=params)
1587+
response.raise_for_status()
1588+
return response.json()
1589+
except httpx.ConnectError as e:
1590+
raise ConnectionError(f"Cannot connect to API: {e}") from e
1591+
except httpx.HTTPStatusError as e:
1592+
raise APIError(f"API error: {e.response.text}") from e
1593+
except httpx.HTTPError as e:
1594+
raise APIError(f"Request failed: {e}") from e
1595+
1596+
async def approve_match(self, match_id: str) -> dict[str, Any]:
1597+
"""Approve a match submission."""
1598+
try:
1599+
client = await self._get_client()
1600+
response = await client.post(f"/api/v1/admin/matches/{match_id}/approve")
1601+
response.raise_for_status()
1602+
return response.json()
1603+
except httpx.ConnectError as e:
1604+
raise ConnectionError(f"Cannot connect to API: {e}") from e
1605+
except httpx.HTTPStatusError as e:
1606+
raise APIError(f"API error: {e.response.text}") from e
1607+
except httpx.HTTPError as e:
1608+
raise APIError(f"Request failed: {e}") from e
1609+
1610+
async def reject_match(self, match_id: str) -> dict[str, Any]:
1611+
"""Reject a match submission."""
1612+
try:
1613+
client = await self._get_client()
1614+
response = await client.post(f"/api/v1/admin/matches/{match_id}/reject")
1615+
response.raise_for_status()
1616+
return response.json()
1617+
except httpx.ConnectError as e:
1618+
raise ConnectionError(f"Cannot connect to API: {e}") from e
1619+
except httpx.HTTPStatusError as e:
1620+
raise APIError(f"API error: {e.response.text}") from e
1621+
except httpx.HTTPError as e:
1622+
raise APIError(f"Request failed: {e}") from e
1623+
1624+
async def get_admin_reports(
1625+
self, status: str | None = None, limit: int = 50, offset: int = 0
1626+
) -> dict[str, Any]:
1627+
"""Get reports for admin review."""
1628+
try:
1629+
client = await self._get_client()
1630+
params = {"limit": limit, "offset": offset}
1631+
if status:
1632+
params["status"] = status
1633+
response = await client.get("/api/v1/admin/reports", params=params)
1634+
response.raise_for_status()
1635+
return response.json()
1636+
except httpx.ConnectError as e:
1637+
raise ConnectionError(f"Cannot connect to API: {e}") from e
1638+
except httpx.HTTPStatusError as e:
1639+
raise APIError(f"API error: {e.response.text}") from e
1640+
except httpx.HTTPError as e:
1641+
raise APIError(f"Request failed: {e}") from e
1642+
1643+
async def resolve_report(
1644+
self, report_id: str, action: str, details: str = ""
1645+
) -> dict[str, Any]:
1646+
"""Resolve a report."""
1647+
try:
1648+
client = await self._get_client()
1649+
response = await client.post(
1650+
f"/api/v1/admin/reports/{report_id}/resolve",
1651+
json={"action": action, "details": details},
1652+
)
1653+
response.raise_for_status()
1654+
return response.json()
1655+
except httpx.ConnectError as e:
1656+
raise ConnectionError(f"Cannot connect to API: {e}") from e
1657+
except httpx.HTTPStatusError as e:
1658+
raise APIError(f"API error: {e.response.text}") from e
1659+
except httpx.HTTPError as e:
1660+
raise APIError(f"Request failed: {e}") from e
1661+
1662+
async def get_admin_users(
1663+
self, limit: int = 50, offset: int = 0
1664+
) -> dict[str, Any]:
1665+
"""Get users for admin view."""
1666+
try:
1667+
client = await self._get_client()
1668+
response = await client.get(
1669+
"/api/v1/admin/users", params={"limit": limit, "offset": offset}
1670+
)
1671+
response.raise_for_status()
1672+
return response.json()
1673+
except httpx.ConnectError as e:
1674+
raise ConnectionError(f"Cannot connect to API: {e}") from e
1675+
except httpx.HTTPStatusError as e:
1676+
raise APIError(f"API error: {e.response.text}") from e
1677+
except httpx.HTTPError as e:
1678+
raise APIError(f"Request failed: {e}") from e
1679+
1680+
async def update_user_role(self, user_id: str, is_admin: bool) -> dict[str, Any]:
1681+
"""Update a user's role."""
1682+
try:
1683+
client = await self._get_client()
1684+
response = await client.patch(
1685+
f"/api/v1/admin/users/{user_id}/role", json={"is_admin": is_admin}
1686+
)
1687+
response.raise_for_status()
1688+
return response.json()
1689+
except httpx.ConnectError as e:
1690+
raise ConnectionError(f"Cannot connect to API: {e}") from e
1691+
except httpx.HTTPStatusError as e:
1692+
raise APIError(f"API error: {e.response.text}") from e
1693+
except httpx.HTTPError as e:
1694+
raise APIError(f"Request failed: {e}") from e
1695+
1696+
async def ban_user(self, user_id: str, reason: str = "") -> dict[str, Any]:
1697+
"""Ban or unban a user."""
1698+
try:
1699+
client = await self._get_client()
1700+
response = await client.post(
1701+
f"/api/v1/admin/users/{user_id}/ban", json={"reason": reason}
1702+
)
1703+
response.raise_for_status()
1704+
return response.json()
1705+
except httpx.ConnectError as e:
1706+
raise ConnectionError(f"Cannot connect to API: {e}") from e
1707+
except httpx.HTTPStatusError as e:
1708+
raise APIError(f"API error: {e.response.text}") from e
1709+
except httpx.HTTPError as e:
1710+
raise APIError(f"Request failed: {e}") from e
1711+
15631712

15641713
# Global client instance
15651714
_api_client: APIClient | None = None

cli/src/spotdl_cli/providers.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
"""Command palette providers for SpotDL CLI."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import TYPE_CHECKING, Any
7+
8+
from textual.command import DiscoveryHit, Hit, Provider
9+
10+
from spotdl_cli.core import APIError, get_api_client
11+
from spotdl_cli.theme import get_platform_icon
12+
13+
if TYPE_CHECKING:
14+
from spotdl_cli.app import SpotDLApp
15+
from spotdl_cli.core.types import UniversalSearchResponse
16+
17+
logger = logging.getLogger(__name__)
18+
19+
20+
class SpotDLCommandProvider(Provider):
21+
"""Universal search provider for the command palette."""
22+
23+
async def search(self, query: str) -> None:
24+
"""Search for songs, albums, artists, and playlists.
25+
26+
Args:
27+
query: The search query from the command palette input.
28+
"""
29+
if len(query) < 2:
30+
return
31+
32+
matcher = self.matcher(query)
33+
api_client = get_api_client()
34+
35+
try:
36+
# We use universal_search returning 5 of each for quick palette lookups
37+
results = await api_client.universal_search(query, limit=5)
38+
39+
# Helper to create hits from entities
40+
hits = []
41+
42+
# Process artists
43+
for artist in results.artists:
44+
name = artist.get("name", "Unknown Artist")
45+
platform = artist.get("platform", "spotify")
46+
desc = "Artist \u2022 " + platform.title()
47+
icon = get_platform_icon(platform)
48+
49+
# Match against the artist name, and if it matches we show it
50+
score = matcher.match(name)
51+
if score > 0:
52+
hits.append(Hit(
53+
score,
54+
matcher.highlight(f"{icon} {name}"),
55+
self._make_navigate_callback("ArtistScreen", artist, "artist"),
56+
help=desc,
57+
))
58+
59+
# Process albums
60+
for album in results.albums:
61+
name = album.get("name", "Unknown Album")
62+
artist_name = album.get("artist_name", "") or album.get("artist", "")
63+
platform = album.get("platform", "spotify")
64+
desc = f"Album \u2022 {artist_name} \u2022 {platform.title()}"
65+
icon = get_platform_icon(platform)
66+
67+
# Match against album name or artist name
68+
score = matcher.match(f"{name} {artist_name}")
69+
if score > 0:
70+
hits.append(Hit(
71+
score,
72+
matcher.highlight(f"{icon} {name}"),
73+
self._make_navigate_callback("AlbumScreen", album, "album"),
74+
help=desc,
75+
))
76+
77+
# Process playlists
78+
for playlist in results.playlists:
79+
name = playlist.get("name", "Unknown Playlist")
80+
owner = playlist.get("owner_name", "")
81+
platform = playlist.get("platform", "spotify")
82+
desc = f"Playlist \u2022 {owner} \u2022 {platform.title()}"
83+
icon = get_platform_icon(platform)
84+
85+
score = matcher.match(f"{name} {owner}")
86+
if score > 0:
87+
hits.append(Hit(
88+
score,
89+
matcher.highlight(f"{icon} {name}"),
90+
self._make_navigate_callback("PlaylistScreen", playlist, "playlist"),
91+
help=desc,
92+
))
93+
94+
# Process tracks
95+
for track in results.tracks:
96+
name = track.get("name", "Unknown Track")
97+
artist_name = track.get("artist_name", "") or track.get("artist", "")
98+
platform = track.get("platform", "spotify")
99+
desc = f"Track \u2022 {artist_name} \u2022 {platform.title()}"
100+
icon = get_platform_icon(platform)
101+
102+
score = matcher.match(f"{name} {artist_name}")
103+
if score > 0:
104+
hits.append(Hit(
105+
score,
106+
matcher.highlight(f"{icon} {name}"),
107+
self._make_track_callback(track, "track"),
108+
help=desc,
109+
))
110+
111+
# Sort by score and yield
112+
hits.sort(key=lambda hit: hit.score, reverse=True)
113+
for hit in hits:
114+
yield hit
115+
116+
except APIError as e:
117+
logger.error(f"Search failed in command palette: {e}")
118+
yield Hit(1.0, f"Error: {e}", lambda: None, help="API Error")
119+
except Exception as e:
120+
logger.error(f"Error in command palette: {e}")
121+
122+
def _make_navigate_callback(self, screen_type: str, item: dict[str, Any], entity_type: str) -> Any:
123+
"""Create a callback that navigates to the given screen type."""
124+
app = self.app
125+
entity_id = item.get("id")
126+
platform = item.get("platform", "spotify")
127+
platform_id = item.get("platform_id", "")
128+
129+
async def navigate() -> None:
130+
if screen_type == "ArtistScreen":
131+
from spotdl_cli.screens.artist import ArtistScreen
132+
await app.push_screen(ArtistScreen(platform_id, platform, initial_data=item, entity_id=entity_id))
133+
elif screen_type == "AlbumScreen":
134+
from spotdl_cli.screens.album import AlbumScreen
135+
await app.push_screen(AlbumScreen(platform_id, platform, initial_data=item, entity_id=entity_id))
136+
elif screen_type == "PlaylistScreen":
137+
from spotdl_cli.screens.playlist import PlaylistScreen
138+
await app.push_screen(PlaylistScreen(platform_id, platform, initial_data=item, entity_id=entity_id))
139+
140+
return navigate
141+
142+
def _make_track_callback(self, track: dict[str, Any], entity_type: str) -> Any:
143+
"""Create a callback for track navigation."""
144+
app = self.app
145+
entity_id = track.get("id")
146+
platform = track.get("platform", "spotify")
147+
platform_id = track.get("platform_id", "")
148+
149+
async def navigate() -> None:
150+
from spotdl_cli.screens.track import TrackScreen
151+
from spotdl_cli.core.types import Song, Platform
152+
153+
song = Song(
154+
name=track.get("name", "Unknown"),
155+
artists=[track.get("artist_name") or track.get("artist", "Unknown")],
156+
artist=track.get("artist_name") or track.get("artist", "Unknown"),
157+
duration=track.get("duration", 0),
158+
platform=Platform(platform),
159+
platform_id=platform_id,
160+
url=track.get("url", ""),
161+
cover_url=track.get("cover_url"),
162+
)
163+
await app.push_screen(TrackScreen(song, platform_id, platform, initial_data=track, entity_id=entity_id))
164+
165+
return navigate
166+

cli/src/spotdl_cli/screens/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Textual screens for SpotDL CLI."""
22

3+
from spotdl_cli.screens.account import AccountScreen
4+
from spotdl_cli.screens.admin import AdminScreen
35
from spotdl_cli.screens.album import AlbumScreen
46
from spotdl_cli.screens.artist import ArtistScreen
57
from spotdl_cli.screens.main import MainScreen
@@ -10,6 +12,8 @@
1012
from spotdl_cli.screens.track import TrackScreen
1113

1214
__all__ = [
15+
"AccountScreen",
16+
"AdminScreen",
1317
"AlbumScreen",
1418
"ArtistScreen",
1519
"MainScreen",

cli/src/spotdl_cli/screens/account.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ def compose(self) -> ComposeResult:
5050
yield Static("", id="profile-role", classes="account-field")
5151
yield Static("", id="profile-member-since", classes="account-field")
5252
yield Static("", id="profile-status", classes="account-field")
53+
yield Button("Admin Dashboard", id="admin-dashboard-btn", variant="success", classes="hidden")
5354

5455
# Reputation card
5556
with Vertical(id="reputation-card", classes="card settings-group hidden"):
@@ -200,6 +201,12 @@ def _update_display(self) -> None:
200201
)
201202
role_text = "Admin" if is_admin else "User"
202203
self.query_one("#profile-role", Static).update(f"Role: {role_text}")
204+
205+
admin_btn = self.query_one("#admin-dashboard-btn", Button)
206+
if is_admin:
207+
admin_btn.remove_class("hidden")
208+
else:
209+
admin_btn.add_class("hidden")
203210

204211
if created_at:
205212
date_str = created_at[:10] if len(created_at) >= 10 else created_at
@@ -230,6 +237,9 @@ async def on_button_pressed(self, event: Button.Pressed) -> None:
230237
if button_id == "sign-in-btn":
231238
from spotdl_cli.screens.login import LoginScreen
232239
await self.app.push_screen(LoginScreen(), self._on_login_complete)
240+
elif button_id == "admin-dashboard-btn":
241+
from spotdl_cli.screens.admin import AdminScreen
242+
await self.app.push_screen(AdminScreen())
233243
elif button_id == "change-password-btn":
234244
await self._change_password()
235245
elif button_id == "logout-btn":

0 commit comments

Comments
 (0)