diff --git a/Music_Server/src/music_server/routes/player.py b/Music_Server/src/music_server/routes/player.py index a9b3deb..478cedb 100644 --- a/Music_Server/src/music_server/routes/player.py +++ b/Music_Server/src/music_server/routes/player.py @@ -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) diff --git a/Music_Server/src/music_server/services/cache_service.py b/Music_Server/src/music_server/services/cache_service.py index c8af500..dfdf2f2 100644 --- a/Music_Server/src/music_server/services/cache_service.py +++ b/Music_Server/src/music_server/services/cache_service.py @@ -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) diff --git a/Music_Server/src/music_server/services/catalog_reader.py b/Music_Server/src/music_server/services/catalog_reader.py index 1a7cb88..e2acbe9 100644 --- a/Music_Server/src/music_server/services/catalog_reader.py +++ b/Music_Server/src/music_server/services/catalog_reader.py @@ -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() diff --git a/Music_Server/tests/test_player_history_routes.py b/Music_Server/tests/test_player_history_routes.py index 65a693f..b631db1 100644 --- a/Music_Server/tests/test_player_history_routes.py +++ b/Music_Server/tests/test_player_history_routes.py @@ -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()