import os import tempfile import unittest from pathlib import Path os.environ['MUSIC_WORKSHOP_DB_PATH'] = str( Path(tempfile.gettempdir()) / f'music_workshop_library_api_{next(tempfile._get_candidate_names())}.db' ) from backend.app.storage import ConfigStore from backend.app.task_store import TaskConflictError try: from backend.app.library_service import LibraryTrackNotFoundError from backend.app.schemas import ( LibraryMoveToExceptionResponse, LibrarySummaryPayload, LibraryTracksPageResponse ) import backend.app.main as main_module except ModuleNotFoundError as error: main_module = None LibraryTrackNotFoundError = None LibraryMoveToExceptionResponse = None LibrarySummaryPayload = None LibraryTracksPageResponse = None FASTAPI_IMPORT_ERROR = error else: FASTAPI_IMPORT_ERROR = None @unittest.skipIf(main_module is None, f'api deps unavailable: {FASTAPI_IMPORT_ERROR}') class LibraryApiTests(unittest.TestCase): def setUp(self): self.db_path = Path(os.environ['MUSIC_WORKSHOP_DB_PATH']) if self.db_path.exists(): self.db_path.unlink() self.store = ConfigStore(self.db_path) config = self.store.get_config() config['output'] = '/tmp/library-output' self.store.save_config(config) self.previous_store = main_module.store self.previous_service = main_module.library_service self.fake_service = _FakeLibraryService() main_module.store = self.store main_module.library_service = self.fake_service def tearDown(self): main_module.store = self.previous_store main_module.library_service = self.previous_service def test_get_library_summary_uses_current_output_config(self): response = main_module.get_library_summary() payload = LibrarySummaryPayload.model_validate(response) self.assertEqual(payload.total_tracks, 12) self.assertEqual(payload.total_albums, 3) self.assertEqual(payload.total_artists, 2) self.assertEqual(self.fake_service.summary_calls, ['/tmp/library-output']) def test_get_library_tracks_passes_pagination_filters_and_serializes_provenance(self): response = main_module.get_library_tracks( q='echoes', artist='Artist A', album='Album A', format='FLAC', has_provenance=True, page=2, page_size=25, sort_by='filename', sort_order='asc' ) payload = LibraryTracksPageResponse.model_validate(response) self.assertEqual(payload.page, 2) self.assertEqual(payload.page_size, 25) self.assertEqual(payload.total, 1) self.assertEqual(payload.items[0].track_id, 'track-1') self.assertEqual(payload.items[0].ingest_provenance.task_id, 'task-123') self.assertEqual( self.fake_service.track_calls, [ { 'output_dir': '/tmp/library-output', 'q': 'echoes', 'artist': 'Artist A', 'album': 'Album A', 'format': 'FLAC', 'has_provenance': True, 'page': 2, 'page_size': 25, 'sort_by': 'filename', 'sort_order': 'asc' } ] ) def test_move_library_track_to_exception_uses_current_config(self): response = main_module.move_library_track_to_exception('track-1') payload = LibraryMoveToExceptionResponse.model_validate(response) self.assertEqual(payload.exception_id, 123) self.assertEqual(payload.library_relative_path, 'A/Artist A/Album A/01 - Echoes.flac') self.assertEqual( self.fake_service.move_calls, [{'output': '/tmp/library-output', 'trash': '/volume1/docker/navidrome/trash', 'track_id': 'track-1'}] ) def test_move_library_track_to_exception_maps_conflict_to_409(self): self.fake_service.move_error = TaskConflictError('active-task') response = main_module.move_library_track_to_exception('track-1') self.assertEqual(response.status_code, 409) def test_library_track_not_found_handler_returns_404(self): response = main_module.library_track_not_found_error_handler( None, LibraryTrackNotFoundError('missing-track') ) self.assertEqual(response.status_code, 404) class _FakeLibraryService: def __init__(self): self.summary_calls: list[str] = [] self.track_calls: list[dict] = [] self.move_calls: list[dict] = [] self.move_error = None def get_summary(self, output_dir: str) -> dict: self.summary_calls.append(output_dir) return { 'total_tracks': 12, 'total_albums': 3, 'total_artists': 2, 'suspected_duplicates': 1, 'scanned_at': '2024-01-03T12:00:00Z' } def get_tracks_page(self, output_dir: str, **kwargs) -> dict: self.track_calls.append({'output_dir': output_dir, **kwargs}) return { 'items': [ { 'track_id': 'track-1', 'library_relative_path': 'A/Artist A/Album A/01 - Echoes.flac', 'library_file_path': '/tmp/library-output/A/Artist A/Album A/01 - Echoes.flac', 'filename': '01 - Echoes.flac', 'title': 'Echoes', 'artist': 'Artist A', 'album': 'Album A', 'album_artist': 'Artist A', 'track_number': 1, 'disc_number': 1, 'year': 2024, 'duration_seconds': 301.4, 'format': 'FLAC', 'codec': 'FLAC', 'bitrate': 980000, 'sample_rate': 96000, 'bit_depth': 24, 'channels': 2, 'size_bytes': 12345678, 'modified_at': '2024-01-02T12:00:00Z', 'ingest_provenance': { 'task_id': 'task-123', 'organized_at': '2024-01-03T12:00:00Z', 'match_source': 'musicbrainz', 'match_confidence': 95.2, 'dedupe_status': 'unique' } } ], 'page': kwargs['page'], 'page_size': kwargs['page_size'], 'total': 1 } def move_track_to_exception(self, config_snapshot: dict, track_id: str) -> dict: if self.move_error: raise self.move_error self.move_calls.append( { 'output': config_snapshot['output'], 'trash': config_snapshot['trash'], 'track_id': track_id } ) return { 'exception_id': 123, 'library_relative_path': 'A/Artist A/Album A/01 - Echoes.flac', 'trash_file_path': '/tmp/trash/match_failed/task-1/01 - Echoes.flac', 'message': '已移入异常中心,等待重新匹配' } if __name__ == '__main__': unittest.main()