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 ..auth import require_bearer_token
|
||||||
|
from ..services.cache_service import CacheService
|
||||||
from ..services.catalog_reader import CatalogReader
|
from ..services.catalog_reader import CatalogReader
|
||||||
from ..services.player_service import PlayerService
|
from ..services.player_service import PlayerService
|
||||||
from ..settings import get_settings
|
from ..settings import get_settings
|
||||||
@@ -16,6 +17,29 @@ def _catalog_reader() -> CatalogReader:
|
|||||||
return CatalogReader(db_path=get_settings().catalog_db_path)
|
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")
|
@router.get("/home")
|
||||||
def home() -> dict:
|
def home() -> dict:
|
||||||
return {
|
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)
|
@router.put("/me/favorites/tracks/{track_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def add_favorite_track(track_id: int) -> Response:
|
def add_favorite_track(track_id: int) -> Response:
|
||||||
_player_service().add_favorite_track(track_id=track_id)
|
_player_service().add_favorite_track(track_id=track_id)
|
||||||
|
|||||||
@@ -832,7 +832,7 @@ class CacheService:
|
|||||||
urls[song_id] = str(best["public_url"])
|
urls[song_id] = str(best["public_url"])
|
||||||
return urls
|
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:
|
with closing(connect_sqlite(self._player_db_path)) as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -844,7 +844,10 @@ class CacheService:
|
|||||||
""",
|
""",
|
||||||
(limit,),
|
(limit,),
|
||||||
).fetchall()
|
).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]
|
song_ids = [int(item["song_id"]) for item in items]
|
||||||
names_by_song_id = self._fetch_track_names(song_ids)
|
names_by_song_id = self._fetch_track_names(song_ids)
|
||||||
cache_urls_by_song_id = self._fetch_cached_public_urls(song_ids)
|
cache_urls_by_song_id = self._fetch_cached_public_urls(song_ids)
|
||||||
|
|||||||
@@ -325,6 +325,46 @@ class CatalogReader:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
return cast(SongRow, dict(row)) if row else None
|
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]:
|
def search_sheets(self, query: str, page: int, page_size: int) -> list[SheetSearchRow]:
|
||||||
page, page_size = self._normalize_pagination(page, page_size)
|
page, page_size = self._normalize_pagination(page, page_size)
|
||||||
term = str(query or "").strip()
|
term = str(query or "").strip()
|
||||||
|
|||||||
@@ -26,6 +26,19 @@ class PlayerHistoryRouteTests(unittest.TestCase):
|
|||||||
playlist_id integer primary key,
|
playlist_id integer primary key,
|
||||||
added_at text not null
|
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()
|
conn.commit()
|
||||||
@@ -66,6 +79,44 @@ class PlayerHistoryRouteTests(unittest.TestCase):
|
|||||||
1,
|
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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -140,6 +191,37 @@ class PlayerHistoryRouteTests(unittest.TestCase):
|
|||||||
self.assertEqual(400, invalid_track_id.status_code)
|
self.assertEqual(400, invalid_track_id.status_code)
|
||||||
self.assertEqual(400, invalid_progress.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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user