feat(music-server): expose topn tracks endpoint
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
|
||||
from ..auth import require_bearer_token
|
||||
from ..services.cache_service import CacheService
|
||||
from ..services.catalog_reader import CatalogReader
|
||||
from ..services.player_service import PlayerService
|
||||
from ..settings import get_settings
|
||||
@@ -16,6 +17,29 @@ def _catalog_reader() -> CatalogReader:
|
||||
return CatalogReader(db_path=get_settings().catalog_db_path)
|
||||
|
||||
|
||||
def _cache_service() -> CacheService:
|
||||
settings = get_settings()
|
||||
return CacheService(
|
||||
player_db_path=settings.player_db_path,
|
||||
catalog_db_path=settings.catalog_db_path,
|
||||
secret_encryption_key=settings.secret_encryption_key,
|
||||
local_library_root=settings.local_library_root,
|
||||
cache_relay_enabled=settings.cache_relay_enabled,
|
||||
)
|
||||
|
||||
|
||||
def _to_music_item(row: dict) -> dict:
|
||||
return {
|
||||
"id": f"catalogsync:song:{row['song_id']}",
|
||||
"platform": "catalogsync",
|
||||
"title": row["name"],
|
||||
"artist": row.get("singers") or "",
|
||||
"album": row.get("album") or "",
|
||||
"artwork": row.get("cover_url") or "",
|
||||
"duration": int(row.get("duration_ms") or 0) // 1000,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/home")
|
||||
def home() -> dict:
|
||||
return {
|
||||
@@ -25,6 +49,37 @@ def home() -> dict:
|
||||
}
|
||||
|
||||
|
||||
@router.get("/topn")
|
||||
def topn(limit: int = Query(default=100, ge=1, le=200)) -> dict:
|
||||
hot_songs = _cache_service().list_hot_song_summaries(limit=limit)
|
||||
songs = _catalog_reader().list_songs_by_ids(
|
||||
[int(item["song_id"]) for item in hot_songs]
|
||||
)
|
||||
songs_by_id = {int(song["song_id"]): song for song in songs}
|
||||
|
||||
music_list = []
|
||||
for rank, hot_song in enumerate(hot_songs, start=1):
|
||||
song_id = int(hot_song["song_id"])
|
||||
song = songs_by_id.get(song_id)
|
||||
if song is None:
|
||||
continue
|
||||
item = _to_music_item(song)
|
||||
item.update(
|
||||
{
|
||||
"rank": rank,
|
||||
"playCount30d": int(hot_song["play_count_30d"]),
|
||||
"playCountTotal": int(hot_song["play_count_total"]),
|
||||
"lastPlayedAt": hot_song.get("last_played_at"),
|
||||
}
|
||||
)
|
||||
music_list.append(item)
|
||||
|
||||
return {
|
||||
"periodDays": 30,
|
||||
"musicList": music_list,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/me/favorites/tracks/{track_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def add_favorite_track(track_id: int) -> Response:
|
||||
_player_service().add_favorite_track(track_id=track_id)
|
||||
|
||||
@@ -832,7 +832,7 @@ class CacheService:
|
||||
urls[song_id] = str(best["public_url"])
|
||||
return urls
|
||||
|
||||
def list_hot_songs(self, *, limit: int = 100) -> list[dict[str, Any]]:
|
||||
def list_hot_song_summaries(self, *, limit: int = 100) -> list[dict[str, Any]]:
|
||||
with closing(connect_sqlite(self._player_db_path)) as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
@@ -844,7 +844,10 @@ class CacheService:
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
items = [dict(row) for row in rows]
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def list_hot_songs(self, *, limit: int = 100) -> list[dict[str, Any]]:
|
||||
items = self.list_hot_song_summaries(limit=limit)
|
||||
song_ids = [int(item["song_id"]) for item in items]
|
||||
names_by_song_id = self._fetch_track_names(song_ids)
|
||||
cache_urls_by_song_id = self._fetch_cached_public_urls(song_ids)
|
||||
|
||||
@@ -325,6 +325,46 @@ class CatalogReader:
|
||||
).fetchone()
|
||||
return cast(SongRow, dict(row)) if row else None
|
||||
|
||||
def list_songs_by_ids(self, song_ids: list[int]) -> list[SongRow]:
|
||||
normalized_ids = list(dict.fromkeys(int(song_id) for song_id in song_ids))
|
||||
if not normalized_ids:
|
||||
return []
|
||||
|
||||
placeholders = ",".join("?" for _ in normalized_ids)
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
select
|
||||
t.song_id,
|
||||
t.name,
|
||||
t.singers,
|
||||
t.album,
|
||||
t.cover_url,
|
||||
t.duration_ms,
|
||||
(
|
||||
select f.locator
|
||||
from catalog_track_files f
|
||||
where f.song_id = t.song_id
|
||||
and f.status = 'active'
|
||||
and f.backend_type = 'local_fs'
|
||||
order by f.is_primary desc, f.locator asc
|
||||
limit 1
|
||||
) as local_locator
|
||||
from catalog_tracks t
|
||||
where t.song_id in ({placeholders})
|
||||
and exists (
|
||||
select 1
|
||||
from catalog_track_files f
|
||||
where f.song_id = t.song_id
|
||||
and f.status = 'active'
|
||||
)
|
||||
""",
|
||||
tuple(normalized_ids),
|
||||
).fetchall()
|
||||
|
||||
rows_by_id = {int(row["song_id"]): cast(SongRow, dict(row)) for row in rows}
|
||||
return [rows_by_id[song_id] for song_id in normalized_ids if song_id in rows_by_id]
|
||||
|
||||
def search_sheets(self, query: str, page: int, page_size: int) -> list[SheetSearchRow]:
|
||||
page, page_size = self._normalize_pagination(page, page_size)
|
||||
term = str(query or "").strip()
|
||||
|
||||
@@ -26,6 +26,19 @@ class PlayerHistoryRouteTests(unittest.TestCase):
|
||||
playlist_id integer primary key,
|
||||
added_at text not null
|
||||
);
|
||||
|
||||
create table song_heat_summary (
|
||||
song_id integer primary key,
|
||||
play_count_total integer not null default 0,
|
||||
play_count_30d integer not null default 0,
|
||||
last_played_at text
|
||||
);
|
||||
|
||||
insert into song_heat_summary (
|
||||
song_id, play_count_total, play_count_30d, last_played_at
|
||||
) values
|
||||
(42, 8, 5, '2026-07-16T10:00:00+00:00'),
|
||||
(7, 12, 9, '2026-07-16T11:00:00+00:00');
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
@@ -66,6 +79,44 @@ class PlayerHistoryRouteTests(unittest.TestCase):
|
||||
1,
|
||||
),
|
||||
)
|
||||
conn.executescript(
|
||||
"""
|
||||
create table catalog_tracks (
|
||||
song_id integer primary key,
|
||||
platform text not null,
|
||||
remote_track_id text not null,
|
||||
name text not null,
|
||||
singers text,
|
||||
album text,
|
||||
cover_url text,
|
||||
duration_ms integer not null
|
||||
);
|
||||
|
||||
create table catalog_track_files (
|
||||
id integer primary key autoincrement,
|
||||
song_id integer not null,
|
||||
quality_label text,
|
||||
backend_type text not null,
|
||||
backend_name text,
|
||||
locator text not null,
|
||||
public_url text,
|
||||
status text not null,
|
||||
is_primary integer not null default 0
|
||||
);
|
||||
|
||||
insert into catalog_tracks (
|
||||
song_id, platform, remote_track_id, name, singers, album, cover_url, duration_ms
|
||||
) values
|
||||
(42, 'kuwo', '42', '热门歌曲二', '歌手乙', '专辑乙', 'https://img/42.jpg', 242000),
|
||||
(7, 'qq', '7', '热门歌曲一', '歌手甲', '专辑甲', 'https://img/7.jpg', 198000);
|
||||
|
||||
insert into catalog_track_files (
|
||||
song_id, quality_label, backend_type, backend_name, locator, public_url, status, is_primary
|
||||
) values
|
||||
(42, 'lossless', 'local_fs', 'default-local', 'kuwo/42.flac', null, 'active', 1),
|
||||
(7, 'high', 'local_fs', 'default-local', 'qq/7.mp3', null, 'active', 1);
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -140,6 +191,37 @@ class PlayerHistoryRouteTests(unittest.TestCase):
|
||||
self.assertEqual(400, invalid_track_id.status_code)
|
||||
self.assertEqual(400, invalid_progress.status_code)
|
||||
|
||||
def test_topn_returns_playable_tracks_in_heat_order(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
player_db_path = Path(tmpdir) / "player.db"
|
||||
catalog_db_path = Path(tmpdir) / "catalog_read.db"
|
||||
self._prepare_player_db(player_db_path)
|
||||
self._prepare_catalog_db(catalog_db_path)
|
||||
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"PLAYER_DB_PATH": str(player_db_path),
|
||||
"CATALOG_DB_PATH": str(catalog_db_path),
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
response = TestClient(create_app()).get(
|
||||
"/player/v1/topn?limit=2",
|
||||
headers=auth_headers(player_db_path),
|
||||
)
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
payload = response.json()
|
||||
self.assertEqual(30, payload["periodDays"])
|
||||
self.assertEqual(
|
||||
["catalogsync:song:7", "catalogsync:song:42"],
|
||||
[item["id"] for item in payload["musicList"]],
|
||||
)
|
||||
self.assertEqual([1, 2], [item["rank"] for item in payload["musicList"]])
|
||||
self.assertEqual([9, 5], [item["playCount30d"] for item in payload["musicList"]])
|
||||
self.assertEqual("歌手甲", payload["musicList"][0]["artist"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user