feat(music-server): expose topn tracks endpoint

This commit is contained in:
2026-07-17 11:42:28 +08:00
parent 069af30dba
commit 4eea6ef8c9
4 changed files with 183 additions and 3 deletions
+56 -1
View File
@@ -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()