diff --git a/README.md b/README.md index bad09e3..d209908 100644 --- a/README.md +++ b/README.md @@ -41,12 +41,6 @@ pip install yandex_fuse-*.tar.gz #### Запускаем -```shell -systemctl --user start yamusic-fs.service -``` - -Или - ```shell yamusic-fs ~/Music/Yandex/ ``` @@ -62,12 +56,6 @@ yamusic-fs ~/Music/Yandex/ #### Отмонтировать -```shell -systemctl stop yamusic-fs.service --user -``` - -Или - ```shell fusermount -u ~/Music/Yandex ``` @@ -79,14 +67,14 @@ fusermount -u ~/Music/Yandex ```json { "token": "", - "best_codec": "aac", + "quality": "hq", "blacklist": [], } ``` token = Токен доступа -best_codec = aac или mp3 +quality = lossless, hq blacklist = "Черный список" жанров для "Моя волна" diff --git a/tests/test_music_fs.py b/tests/test_music_fs.py new file mode 100644 index 0000000..9029cbe --- /dev/null +++ b/tests/test_music_fs.py @@ -0,0 +1,126 @@ +# ruff: noqa: S101 +# ruff: noqa: ARG002 +# ruff: noqa: ANN001 +# ruff: noqa: ANN201 +# ruff: noqa: ANN202 +# ruff: noqa: ANN204 +# mypy: ignore-errors + +from typing import ParamSpec, TypeVar +from unittest import mock + +import pytest +from pytest_mock import MockerFixture + +from yandex_fuse.ya_music_fs import SQLTrack, StreamReader, YaMusicFS + +P = ParamSpec("P") +T = TypeVar("T") + + +class MockResponse: + def __init__(self, text: str, status: int) -> None: + self._text = text + self.status = status + + async def text(self) -> str: + return self._text + + async def __aexit__(self, exc_type, exc, tb): + pass + + async def __aenter__(self): + return self + + +TRACK_INFO = SQLTrack( + name="TestTrack", + inode=10, + track_id=10, + codec="acc", + bitrate=128, + artist="test", + title="test", + album="test", + year="2024", + genre="test", + duration_ms=100, + playlist_id="NOT", + quality="hq", + size=100, +) + + +@pytest.fixture(autouse="True") +def client_session_mock(): + with mock.patch("yandex_fuse.ya_music_fs.YaMusicFS._client_session") as m: + yield m + + +@mock.patch("yandex_fuse.ya_music_fs.Buffer.download", mock.AsyncMock()) +@mock.patch("yandex_fuse.ya_music_fs.YaMusicFS._ya_player", mock.AsyncMock()) +@pytest.mark.asyncio +class TestMusicFS: + @pytest.fixture(scope="session") + def ya_music_fs(self) -> YaMusicFS: + yandex_music = YaMusicFS + yandex_music.FILE_DB = "file::memory:?cache=shared" + return yandex_music() + + @mock.patch("yandex_fuse.ya_music_fs.YaMusicFS._get_track_by_inode") + @mock.patch("yandex_fuse.ya_music_fs.YaMusicFS.get_or_update_direct_link") + async def test_open( + self, + mock_get_or_update_direct_link: mock.Mock, + mock_get_track_by_inode: mock.Mock, + ya_music_fs: YaMusicFS, + ) -> None: + mock_get_track_by_inode.return_value = TRACK_INFO + file_info = await ya_music_fs.open(519, 0o664, None) + assert file_info.fh == 1 + + assert mock_get_or_update_direct_link.call_count == 1 + + file_info = await ya_music_fs.open(519, 0o664, None) + assert file_info.fh == 2 # noqa: PLR2004 + + async def test_read( + self, + ya_music_fs: YaMusicFS, + mocker: MockerFixture, + ) -> None: + buffer = mock.MagicMock() + + buffer.read_from = mock.AsyncMock() + buffer.read_from.return_value = b"Test" + buffer.total_second.return_value = 0 + + stream = StreamReader( + buffer=buffer, + track=TRACK_INFO, + is_send_feedback=False, + ) + mocker.patch.object(ya_music_fs, "_fd_map_stream", {10: stream}) + chunk = await ya_music_fs.read(10, 100, 100) + assert chunk == b"Test" + + @mock.patch("yandex_fuse.virt_fs.VirtFS._get_file_stat_by_inode") + async def test_release( + self, + mock_get_file_stat_by_inode: mock.Mock(), + ya_music_fs: YaMusicFS, + mocker: MockerFixture, + ) -> None: + stream = StreamReader( + buffer=mock.MagicMock(), + track=TRACK_INFO, + is_send_feedback=False, + ) + mocker.patch.object(ya_music_fs, "_fd_map_inode", {10: 519}) + mocker.patch.object(ya_music_fs, "_fd_map_stream", {10: stream}) + + await ya_music_fs.release(10) + + # TODO(vm86): check via mock + assert ya_music_fs._fd_map_inode == {} # noqa: SLF001 + assert ya_music_fs._fd_map_stream == {} # noqa: SLF001 diff --git a/yandex_fuse/virt_fs.py b/yandex_fuse/virt_fs.py index 128df25..4d52ed9 100644 --- a/yandex_fuse/virt_fs.py +++ b/yandex_fuse/virt_fs.py @@ -238,12 +238,10 @@ def _get_list_row( return [dict(row) for row in cur.fetchall()] def _get_fd(self) -> int: - for i in range(2048): - if i in self._fd_map_inode: - continue - - return i - raise FUSEError(errno.ENOENT) + try: + return max(self._fd_map_inode.keys()) + 1 + except ValueError: + return 1 @property def _inode_map_fd(self) -> dict[InodeT, set[int]]: @@ -322,9 +320,10 @@ def queue_later_invalidate_inode(self) -> set[InodeT]: return self.__later_invalidate_inode def _invalidate_inode(self, inode: InodeT) -> None: + self.__later_invalidate_inode.discard(inode) if inode not in self._nlookup: return - if len(self._fd_map_inode) > 0: + if inode in self._inode_map_fd: log.warning( "Invalidate inode %d skip. There are open descriptors.", inode, @@ -435,6 +434,8 @@ async def opendir(self, inode: InodeT, ctx: RequestContext) -> FileHandleT: # n async def releasedir(self, fd: FileHandleT) -> None: inode = self._fd_map_inode.pop(fd) self.__fd_token_read.pop(inode, None) + if inode in self.__later_invalidate_inode: + self._invalidate_inode(inode) @fail_is_exit async def statfs(self, ctx: RequestContext) -> StatvfsData: # noqa: ARG002 @@ -531,6 +532,8 @@ async def release(self, fd: int) -> None: if self._get_file_stat_by_inode(inode).st_nlink == 0: with self._db_cursor() as cur: cur.execute("DELETE FROM inodes WHERE id=?", (inode,)) + if inode in self.__later_invalidate_inode: + self._invalidate_inode(inode) @fail_is_exit async def unlink( diff --git a/yandex_fuse/ya_music_fs.py b/yandex_fuse/ya_music_fs.py index 860a250..196c4b7 100644 --- a/yandex_fuse/ya_music_fs.py +++ b/yandex_fuse/ya_music_fs.py @@ -260,6 +260,16 @@ class SQLPlaylist(SQLRow): id: int | None = None +@dataclass +class SQLDirectLink(SQLRow): + __tablename__ = "direct_link" + + track_id: str + link: str + expired: int + id: int | None = None + + @dataclass class StreamReader: buffer: Buffer @@ -324,8 +334,6 @@ def __init__(self, *args: P.args, **kwargs: P.kwargs) -> None: self.__client_session: ClientSession | None = None self.__ya_player: YandexMusicPlayer | None = None self._fd_map_stream: dict[int, StreamReader] = {} - self._station_id_map_inode: dict[int, int] = {} - self._tracks = 0 async def start(self) -> None: self.__client_session = ClientSession(