import sqlite3 import tempfile import unittest from contextlib import closing from pathlib import Path from unittest.mock import ANY, patch from click.testing import CliRunner class CatalogCliTests(unittest.TestCase): def test_init_db_command_creates_sqlite_file(self): from musicdl.catalogsync.cli import cli runner = CliRunner() with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir: db_path = Path(tmpdir) / "catalogsync.db" library_root = Path(tmpdir) / "library" result = runner.invoke( cli, ["init-db", "--db", str(db_path), "--library-root", str(library_root)], ) self.assertEqual(0, result.exit_code, msg=result.output) self.assertTrue(db_path.exists()) with closing(sqlite3.connect(db_path)) as conn: table_names = { row[0] for row in conn.execute( "SELECT name FROM sqlite_master WHERE type = 'table'" ).fetchall() } self.assertIn("songs", table_names) def test_init_db_command_creates_resolver_stats_side_db(self): from musicdl.catalogsync.cli import cli from musicdl.catalogsync.resolver_stats import default_resolver_stats_db_path runner = CliRunner() with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir: db_path = Path(tmpdir) / "catalogsync.db" library_root = Path(tmpdir) / "library" result = runner.invoke( cli, ["init-db", "--db", str(db_path), "--library-root", str(library_root)], ) self.assertEqual(0, result.exit_code, msg=result.output) resolver_stats_db_path = default_resolver_stats_db_path(db_path) self.assertTrue(resolver_stats_db_path.exists()) def test_run_command_wires_collect_sync_and_download_steps(self): from musicdl.catalogsync.cli import cli runner = CliRunner() with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir: db_path = Path(tmpdir) / "catalogsync.db" with patch("musicdl.catalogsync.cli.CatalogSyncApplication") as app_cls: app = app_cls.return_value result = runner.invoke( cli, [ "run", "--db", str(db_path), "--sources", "netease,qq", "--download-sources", "qq,kuwo,migu", "--library-root", str(Path(tmpdir) / "library"), "--workers", "3", ], ) self.assertEqual(0, result.exit_code, msg=result.output) app.collect_playlists.assert_called_once() app.sync_playlist_catalog.assert_called_once() app.download_pending.assert_called_once_with( ["netease", "qq"], limit=None, workers=3, download_sources=["qq", "kuwo", "migu"], lyrics_enabled=True, overwrite_lyrics=False, ) def test_run_command_uses_playlist_file_branch_without_collect(self): from musicdl.catalogsync.cli import cli runner = CliRunner() with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir: db_path = Path(tmpdir) / "catalogsync.db" playlist_file = Path(tmpdir) / "playlists.txt" playlist_file.write_text( "https://music.163.com/#/playlist?id=17745989905\n", encoding="utf-8", ) with patch("musicdl.catalogsync.cli.CatalogSyncApplication") as app_cls: app = app_cls.return_value result = runner.invoke( cli, [ "run", "--db", str(db_path), "--library-root", str(Path(tmpdir) / "library"), "--playlist-file", str(playlist_file), "--download-sources", "qq,kuwo", ], ) self.assertEqual(0, result.exit_code, msg=result.output) app.collect_playlists.assert_not_called() app.run_playlist_file.assert_called_once_with( playlist_file=str(playlist_file), limit=None, workers=10, download_sources=["qq", "kuwo"], lyrics_enabled=True, overwrite_lyrics=False, ) def test_download_command_defaults_workers_to_ten(self): from musicdl.catalogsync.cli import cli runner = CliRunner() with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir: db_path = Path(tmpdir) / "catalogsync.db" with patch("musicdl.catalogsync.cli.CatalogSyncApplication") as app_cls: app = app_cls.return_value result = runner.invoke( cli, [ "download", "--db", str(db_path), "--sources", "netease,qq", "--download-sources", "qq,kuwo,migu", "--library-root", str(Path(tmpdir) / "library"), ], ) self.assertEqual(0, result.exit_code, msg=result.output) app.download_pending.assert_called_once_with( ["netease", "qq"], limit=None, workers=10, download_sources=["qq", "kuwo", "migu"], lyrics_enabled=True, overwrite_lyrics=False, ) def test_download_command_reads_workers_from_download_workers_env(self): from musicdl.catalogsync.cli import cli runner = CliRunner() with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir: db_path = Path(tmpdir) / "catalogsync.db" with patch("musicdl.catalogsync.cli.CatalogSyncApplication") as app_cls: app = app_cls.return_value result = runner.invoke( cli, [ "download", "--db", str(db_path), "--sources", "netease,qq", "--download-sources", "qq,kuwo,migu", "--library-root", str(Path(tmpdir) / "library"), ], env={"DOWNLOAD_WORKERS": "8"}, ) self.assertEqual(0, result.exit_code, msg=result.output) app.download_pending.assert_called_once_with( ["netease", "qq"], limit=None, workers=8, download_sources=["qq", "kuwo", "migu"], lyrics_enabled=True, overwrite_lyrics=False, ) def test_download_command_forwards_workers(self): from musicdl.catalogsync.cli import cli runner = CliRunner() with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir: db_path = Path(tmpdir) / "catalogsync.db" with patch("musicdl.catalogsync.cli.CatalogSyncApplication") as app_cls: app = app_cls.return_value result = runner.invoke( cli, [ "download", "--db", str(db_path), "--sources", "netease,qq", "--download-sources", "qq,kuwo,migu", "--library-root", str(Path(tmpdir) / "library"), "--workers", "5", ], ) self.assertEqual(0, result.exit_code, msg=result.output) app.download_pending.assert_called_once_with( ["netease", "qq"], limit=None, workers=5, download_sources=["qq", "kuwo", "migu"], lyrics_enabled=True, overwrite_lyrics=False, ) def test_download_command_forwards_lyrics_flags(self): from musicdl.catalogsync.cli import cli runner = CliRunner() with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir: db_path = Path(tmpdir) / "catalogsync.db" with patch("musicdl.catalogsync.cli.CatalogSyncApplication") as app_cls: app = app_cls.return_value result = runner.invoke( cli, [ "download", "--db", str(db_path), "--sources", "netease", "--download-sources", "qq", "--library-root", str(Path(tmpdir) / "library"), "--no-lyrics", "--overwrite-lyrics", ], ) self.assertEqual(0, result.exit_code, msg=result.output) app.download_pending.assert_called_once_with( ["netease"], limit=None, workers=10, download_sources=["qq"], lyrics_enabled=False, overwrite_lyrics=True, ) def test_lyrics_command_wires_application_method_and_filters(self): from musicdl.catalogsync.cli import cli runner = CliRunner() with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir: db_path = Path(tmpdir) / "catalogsync.db" with patch("musicdl.catalogsync.cli.CatalogSyncApplication") as app_cls: app = app_cls.return_value def sync_local_lyrics_side_effect(*args, **kwargs): progress_callback = kwargs["progress_callback"] progress_callback( total=10, processed=0, saved=0, skipped=0, failed=0, progress_percent=0, ) progress_callback( total=10, processed=10, saved=7, skipped=2, failed=1, progress_percent=100, ) return {"total": 10, "processed": 10, "saved": 7, "skipped": 2, "failed": 1} app.sync_local_lyrics.side_effect = sync_local_lyrics_side_effect result = runner.invoke( cli, [ "lyrics", "--db", str(db_path), "--sources", "netease,qq", "--playlist-ids", "12,15", "--limit", "200", "--workers", "8", "--overwrite-lyrics", ], ) self.assertEqual(0, result.exit_code, msg=result.output) app.sync_local_lyrics.assert_called_once_with( sources=["netease", "qq"], playlist_ids=[12, 15], limit=200, workers=8, progress_callback=ANY, overwrite_lyrics=True, ) self.assertIn("Lyrics progress: 0/10 (0%)", result.output) self.assertIn("Lyrics progress: 10/10 (100%)", result.output) def test_register_object_backend_command_wires_application_method(self): from musicdl.catalogsync.cli import cli runner = CliRunner() with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir: db_path = Path(tmpdir) / "catalogsync.db" with patch("musicdl.catalogsync.cli.CatalogSyncApplication") as app_cls: app = app_cls.return_value result = runner.invoke( cli, [ "register-object-backend", "--db", str(db_path), "--backend", "main-s3", "--bucket", "music-bucket", "--endpoint", "https://s3.example.com", "--region", "auto", "--base-prefix", "music", "--credential-env-prefix", "CATALOGSYNC_MAIN_S3", ], ) self.assertEqual(0, result.exit_code, msg=result.output) app.register_object_backend.assert_called_once_with( backend_name="main-s3", container_name="music-bucket", endpoint="https://s3.example.com", region="auto", base_prefix="music", credential_env_prefix="CATALOGSYNC_MAIN_S3", addressing_style=None, public_base_url=None, ) def test_upload_command_wires_application_method_and_filters(self): from musicdl.catalogsync.cli import cli runner = CliRunner() with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir: db_path = Path(tmpdir) / "catalogsync.db" with patch("musicdl.catalogsync.cli.CatalogSyncApplication") as app_cls: app = app_cls.return_value result = runner.invoke( cli, [ "upload", "--db", str(db_path), "--backend", "main-s3", "--sources", "netease,qq", "--playlist-ids", "12,15", "--limit", "200", "--workers", "4", ], ) self.assertEqual(0, result.exit_code, msg=result.output) app.upload_files.assert_called_once_with( backend_name="main-s3", sources=["netease", "qq"], playlist_ids=[12, 15], limit=200, workers=4, ) def test_serve_command_wires_ops_web_app_and_uvicorn(self): from musicdl.catalogsync.cli import cli runner = CliRunner() with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir: db_path = Path(tmpdir) / "catalogsync.db" env_file = Path(tmpdir) / "catalogsync.env" fake_app = object() with patch( "musicdl.catalogsync.cli.create_ops_web_app", return_value=fake_app, ) as create_app_mock, patch("musicdl.catalogsync.cli.uvicorn.run") as uvicorn_run_mock: result = runner.invoke( cli, [ "serve", "--db", str(db_path), "--env-file", str(env_file), "--host", "0.0.0.0", "--port", "19090", ], ) self.assertEqual(0, result.exit_code, msg=result.output) create_app_mock.assert_called_once_with( db_path=str(db_path), env_path=str(env_file), ) uvicorn_run_mock.assert_called_once_with( fake_app, host="0.0.0.0", port=19090, ) def test_serve_command_rejects_out_of_range_port(self): from musicdl.catalogsync.cli import cli runner = CliRunner() with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir: db_path = Path(tmpdir) / "catalogsync.db" env_file = Path(tmpdir) / "catalogsync.env" with patch("musicdl.catalogsync.cli.create_ops_web_app") as create_app_mock: result = runner.invoke( cli, [ "serve", "--db", str(db_path), "--env-file", str(env_file), "--port", "70000", ], ) self.assertNotEqual(0, result.exit_code) self.assertIn("is not in the range", result.output) create_app_mock.assert_not_called() if __name__ == "__main__": unittest.main()